Initial commit: frpc-client-win v1.0
Single-file Windows frpc service wrapper. Features: - Embeds frpc v0.70.1 (16.7MB) into one 18.9MB EXE - Registers as Windows Service (auto-start, no login required) - Auto-detects frpc config by three-tier search: 1. frpc.ini 2. frpc.toml 3. any *.ini/*.toml in exe dir - Self-healing supervisor: frpc crashes are restarted with exponential backoff (2s..60s), capped to avoid log spam - Console subsystem so install/start show feedback - FreeConsole on SCM start to suppress console window - Logs to <exe-dir>/logs/service.log Subcommands: install start stop remove import -d Smoke test (smoke_linux.go) covers all config detection paths including the custom-name (e.g. frpc-mike.ini) use case.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// Package config 管理 frpc-client 的运行配置与文件资源。
|
||||
//
|
||||
// 它负责三件事:
|
||||
//
|
||||
// 1. frpc.ini 的存在性检查与导入交互
|
||||
// 2. frpc.exe 的释放(优先使用 EXE 同目录的本地版,其次用内嵌版)
|
||||
// 3. 各种路径(日志目录、配置目录)的一致性
|
||||
package config
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"frpc-client-win/internal/logger"
|
||||
)
|
||||
|
||||
// Config 持有运行期所需的全部路径与资源。
|
||||
type Config struct {
|
||||
ExeDir string // 程序自身所在目录
|
||||
FrpcPath string // 释放后的 frpc.exe 完整路径
|
||||
IniPath string // 主配置(frpc.ini)完整路径 - 优先
|
||||
TomlPath string // 备选配置(frpc.toml)完整路径
|
||||
LogDir string // 日志目录
|
||||
Log *logger.Log
|
||||
FS embed.FS // 内嵌资源
|
||||
FrpcName string // 内嵌的 frpc 二进制文件名
|
||||
}
|
||||
|
||||
// New 构造一个 Config。
|
||||
func New(exeDir, frpcName string, frpcFS embed.FS, log *logger.Log) *Config {
|
||||
c := &Config{
|
||||
ExeDir: exeDir,
|
||||
FrpcPath: filepath.Join(exeDir, frpcName),
|
||||
IniPath: filepath.Join(exeDir, "frpc.ini"),
|
||||
TomlPath: filepath.Join(exeDir, "frpc.toml"),
|
||||
LogDir: filepath.Join(exeDir, "logs"),
|
||||
Log: log,
|
||||
FS: frpcFS,
|
||||
FrpcName: frpcName,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// EnsureFrpcBinary 确保 frpc.exe 在本地可用。
|
||||
//
|
||||
// 优先级:
|
||||
//
|
||||
// 本地已存在 -> 直接使用
|
||||
// 内嵌版本 -> 释放到本地
|
||||
//
|
||||
// 当内嵌版本也缺失(开发期没打包),返回 ErrFrpcMissing,
|
||||
// 由调用方决定是否尝试网络下载。
|
||||
func (c *Config) EnsureFrpcBinary() error {
|
||||
if _, err := os.Stat(c.FrpcPath); err == nil {
|
||||
c.Log.Info("using local frpc binary at %s", c.FrpcPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := fs.ReadFile(c.FS, "assets/"+c.FrpcName)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ErrFrpcMissing
|
||||
}
|
||||
return fmt.Errorf("read embedded frpc: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(c.FrpcPath, data, 0o755); err != nil {
|
||||
return fmt.Errorf("extract frpc to %s: %w", c.FrpcPath, err)
|
||||
}
|
||||
c.Log.Info("extracted embedded frpc (%d bytes) to %s", len(data), c.FrpcPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrFrpcMissing 表示内嵌和本地都没有 frpc.exe。
|
||||
var ErrFrpcMissing = errors.New("frpc binary not found: neither local nor embedded")
|
||||
|
||||
// EnsureIni 检查 frpc 配置文件是否存在,并选出实际使用的路径。
|
||||
//
|
||||
// 优先级:
|
||||
//
|
||||
// 1. frpc.ini (固定名)
|
||||
// 2. frpc.toml (固定名)
|
||||
// 3. 同目录任意唯一的 *.ini / *.toml 文件(适用于客户用 frpc-mike.ini 这类自定义名)
|
||||
//
|
||||
// 注意:不是把客户的 frpc-mike.ini 改名复制,而是直接把绝对路径传给 frpc -c,
|
||||
// 这样客户原始文件名保持不变(便于版本管理/多客户多份配置共存)。
|
||||
func (c *Config) EnsureIni() error {
|
||||
if _, _, err := c.findConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findConfig 实现三段式查找,返回(选中的文件路径, 来源标签, error)。
|
||||
//
|
||||
// 来源标签:
|
||||
//
|
||||
// "frpc.ini" -> 固定名 frpc.ini
|
||||
// "frpc.toml" -> 固定名 frpc.toml
|
||||
// "scan" -> 同目录扫描到的唯一 .ini/.toml
|
||||
// "" -> 未找到
|
||||
func (c *Config) findConfig() (string, string, error) {
|
||||
// 1. frpc.ini
|
||||
if _, err := os.Stat(c.IniPath); err == nil {
|
||||
return c.IniPath, "frpc.ini", nil
|
||||
}
|
||||
// 2. frpc.toml
|
||||
if _, err := os.Stat(c.TomlPath); err == nil {
|
||||
return c.TomlPath, "frpc.toml", nil
|
||||
}
|
||||
// 3. 同目录扫描
|
||||
candidates, err := c.scanConfigs()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("scan configs: %w", err)
|
||||
}
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return "", "", ErrIniMissing
|
||||
case 1:
|
||||
return candidates[0], "scan", nil
|
||||
default:
|
||||
// 多个 - 让用户选,而不是猜
|
||||
return "", "", fmt.Errorf("%w: found multiple candidates in %s: %s",
|
||||
ErrIniAmbiguous, c.ExeDir, strings.Join(candidates, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// scanConfigs 扫描 ExeDir 下所有 .ini/.toml 文件,返回绝对路径列表。
|
||||
func (c *Config) scanConfigs() ([]string, error) {
|
||||
entries, err := os.ReadDir(c.ExeDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var found []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||
if ext == ".ini" || ext == ".toml" {
|
||||
found = append(found, filepath.Join(c.ExeDir, e.Name()))
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// EffectiveConfigPath 返回实际可用的配置文件路径。
|
||||
// 由 Start 在 EnsureIni 之后调用。
|
||||
func (c *Config) EffectiveConfigPath() string {
|
||||
p, _, _ := c.findConfig()
|
||||
return p
|
||||
}
|
||||
|
||||
// ConfigSource 返回当前配置的来源标签(用于日志/排错)。
|
||||
func (c *Config) ConfigSource() string {
|
||||
_, src, _ := c.findConfig()
|
||||
return src
|
||||
}
|
||||
|
||||
// ErrIniMissing 表示 frpc 配置不存在。
|
||||
var ErrIniMissing = errors.New("frpc config not found (expected frpc.ini / frpc.toml / or any *.ini *.toml in the same directory as frpc-client.exe)")
|
||||
|
||||
// ErrIniAmbiguous 表示找到多个配置文件且无法判定使用哪个。
|
||||
var ErrIniAmbiguous = errors.New("ambiguous frpc config")
|
||||
|
||||
// ImportInteractive 交互式导入 frpc 配置。
|
||||
//
|
||||
// 服务模式下不应该调用,所以这里只支持控制台提示;
|
||||
// 用户拖入一个 .ini / .toml / .yaml 文件,我们复制到 EXE 同目录。
|
||||
func ImportInteractive(c *Config) error {
|
||||
fmt.Println("== frpc config import ==")
|
||||
fmt.Printf("当前目录: %s\n", c.ExeDir)
|
||||
fmt.Printf("目标文件: %s (或 frpc.toml)\n", c.IniPath)
|
||||
|
||||
if _, err := os.Stat(c.IniPath); err == nil {
|
||||
fmt.Println("frpc.ini 已存在,无需导入")
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(c.TomlPath); err == nil {
|
||||
fmt.Println("frpc.toml 已存在,无需导入")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Print("请拖入 frpc.ini / frpc.toml 文件(或粘贴完整路径),回车确认:\n> ")
|
||||
var src string
|
||||
if _, err := fmt.Scanln(&src); err != nil {
|
||||
return fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
src = filepath.Clean(stripQuotes(src))
|
||||
|
||||
// 根据扩展名决定目标文件
|
||||
dst := c.IniPath
|
||||
switch strings.ToLower(filepath.Ext(src)) {
|
||||
case ".toml":
|
||||
dst = c.TomlPath
|
||||
case ".yaml", ".yml":
|
||||
// frpc 不直接支持 yaml 配置文件 - 提示用户转换
|
||||
return fmt.Errorf("frpc 不直接支持 yaml 配置;请改用 .ini 或 .toml 格式")
|
||||
}
|
||||
return copyFile(src, dst)
|
||||
}
|
||||
|
||||
// stripQuotes 去掉用户拖入文件路径时常见的包裹引号。
|
||||
func stripQuotes(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create dest: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user