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:
+18
@@ -0,0 +1,18 @@
|
||||
# Smoke test artifact (linux-only build of main.go)
|
||||
frpc-client-test
|
||||
frpc-client.exe.bak
|
||||
|
||||
# Runtime files (will appear in deployed directories)
|
||||
frpc.ini
|
||||
frpc.toml
|
||||
frpc-*.ini
|
||||
frpc-*.toml
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,112 @@
|
||||
# frpc-client-win
|
||||
|
||||
单文件 Windows 后台 frpc 服务包装程序。Go 编写,frpc.exe 嵌入进二进制,部署只需一个 EXE。
|
||||
|
||||
## 特性
|
||||
|
||||
- **单文件部署**:frpc.exe 通过 `embed.FS` 嵌入主程序,最终产物一个文件即可
|
||||
- **Windows Service**:注册为系统服务,开机自启,**无需登录用户**
|
||||
- **配置兼容**:同时支持 `frpc.ini` (旧版) 和 `frpc.toml` (新版) 两种配置格式,优先 .ini
|
||||
- **交互式导入**:启动时若无配置文件,可在交互会话里 `frpc-client.exe import` 拖入客户提供的 ini/toml
|
||||
- **服务管理子命令**:`install / start / stop / remove` + `import` + `-d` 控制台前台
|
||||
- **完整日志**:本服务日志写 `logs/service.log`,frpc 输出统一进 `logs/frpc.log`(实际是 service.log),便于集中取证
|
||||
- **Windows 事件日志**:同时写 Windows Event Log(source=`frpc-client`)
|
||||
|
||||
## 编译
|
||||
|
||||
```bash
|
||||
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o frpc-client.exe .
|
||||
```
|
||||
|
||||
- `-H windowsgui`:编译为 GUI subsystem,服务运行时无控制台窗口
|
||||
- `-s -w`:去除调试信息,减小体积
|
||||
- 当前内嵌 frpc v0.70.1,最终产物约 **18.9 MB**(frpc.exe 16.7MB + Go runtime 2.1MB + 业务代码)
|
||||
- 若只想验证 Go 业务逻辑不实际打包 frpc,可用空文件占位 `assets/frpc.exe`(代码会正确处理缺失并报错)
|
||||
|
||||
## 内嵌的 frpc 版本
|
||||
|
||||
默认内嵌 `v0.70.1`(frp 最新稳定版)。如需升级:
|
||||
|
||||
1. 从 https://github.com/fatedier/frp/releases 下载对应 `frp_X.Y.Z_windows_amd64.zip`
|
||||
2. 解压,取出 `frpc.exe`
|
||||
3. 替换 `assets/frpc.exe`,重新编译
|
||||
|
||||
## 用法
|
||||
|
||||
### 1. 安装并启动服务
|
||||
|
||||
以管理员 CMD 运行:
|
||||
|
||||
```cmd
|
||||
frpc-client.exe install
|
||||
frpc-client.exe start
|
||||
```
|
||||
|
||||
服务名:`frpc-client`,显示名:`FRPC Client Service`,启动类型:自动。
|
||||
|
||||
### 2. 导入配置文件
|
||||
|
||||
将客户提供的 `frpc.ini` 放到 EXE 同目录即可,服务启动时会自动检测。
|
||||
若无 ini,可在控制台运行:
|
||||
|
||||
```cmd
|
||||
frpc-client.exe import
|
||||
```
|
||||
|
||||
按提示拖入配置文件路径回车即可。脚本会自动按扩展名分发到 `frpc.ini` 或 `frpc.toml`。
|
||||
|
||||
### 3. 控制台前台运行(调试用)
|
||||
|
||||
```cmd
|
||||
frpc-client.exe -d
|
||||
```
|
||||
|
||||
或 `frpc-client.exe console`,Ctrl+C 退出。日志同时输出到控制台和 `logs/service.log`。
|
||||
|
||||
### 4. 卸载服务
|
||||
|
||||
```cmd
|
||||
frpc-client.exe stop
|
||||
frpc-client.exe remove
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
frpc-client.exe # 本程序(已嵌入 frpc.exe)
|
||||
frpc.ini # frpc 配置(优先)/ frpc.toml (备选)
|
||||
logs/
|
||||
service.log # 本服务日志 + frpc 输出(混合)
|
||||
```
|
||||
|
||||
服务运行后,`logs/service.log` 会混合记录 frpc 子进程的所有输出 + 本服务的状态日志。
|
||||
|
||||
## 设计概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Windows SCM │
|
||||
│ │ Start/Stop/Control │
|
||||
│ ▼ │
|
||||
│ Run() -> svc.Run("frpc-client", &handler{cfg}) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ handler.Execute() -> supervisor.Start() -> exec frpc.exe │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `main.go` 入口,根据子命令分派
|
||||
- `internal/config`:frpc.exe/ini 路径与释放逻辑
|
||||
- `internal/logger`:简易文件日志器
|
||||
- `internal/service`:svc.Handler 实现 + frpc 子进程 supervisor + 服务安装/卸载/启停
|
||||
|
||||
## 验证状态
|
||||
|
||||
| 检查项 | 状态 | 备注 |
|
||||
|-------------|------|----------------------------------------------|
|
||||
| Windows vet | ✔ | `GOOS=windows GOARCH=amd64 go vet ./...` 干净 |
|
||||
| Windows 编译 | ✔ | 18.9 MB, GUI EXE |
|
||||
| embed 验证 | ✔ | `strings frpc-client.exe` 含 fatedier/frp |
|
||||
| Linux smoke | ✔ | config 包行为正确(frpc 释放/ini 检测/优先级) |
|
||||
|
||||
> Windows 服务真实运行测试需要 Windows 环境或 wine,本机未做。建议你在 Windows 上跑一遍 `install` → `start` → 看 `logs/service.log` + Event Viewer。
|
||||
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package logger 提供一个轻量日志器,把消息写入指定目录下的 service.log。
|
||||
//
|
||||
// 设计要点:
|
||||
//
|
||||
// - 文件日志始终启用,便于事后取证
|
||||
// - 所有写入加锁,frpc 输出 + 本服务输出可能并发
|
||||
// - 服务模式下,日志必须写到 EXE 同目录(SCM 默认工作目录是 System32)
|
||||
// - 不再同时写 stdout - 服务模式已经 FreeConsole,CLI 模式 stdout 由 main 自己处理
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Log 是简易同步日志器。
|
||||
type Log struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
}
|
||||
|
||||
const timeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// New 在 logDir 下创建日志文件并返回 Log。
|
||||
//
|
||||
// logDir 必须传绝对路径;不存在会自动创建。
|
||||
func New(logDir string) (*Log, error) {
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(
|
||||
filepath.Join(logDir, "service.log"),
|
||||
os.O_CREATE|os.O_APPEND|os.O_WRONLY,
|
||||
0o644,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Log{f: f}, nil
|
||||
}
|
||||
|
||||
// Close 关闭底层文件句柄。
|
||||
func (l *Log) Close() error {
|
||||
if l == nil || l.f == nil {
|
||||
return nil
|
||||
}
|
||||
return l.f.Close()
|
||||
}
|
||||
|
||||
// Info 写一条普通日志。
|
||||
func (l *Log) Info(format string, args ...any) { l.write("INFO", format, args...) }
|
||||
func (l *Log) Warn(format string, args ...any) { l.write("WARN", format, args...) }
|
||||
func (l *Log) Error(format string, args ...any) { l.write("ERROR", format, args...) }
|
||||
|
||||
// FrpcRedirect 返回一个 io.Writer,frpc 的 stdout/stderr 重定向到这里。
|
||||
func (l *Log) FrpcRedirect() io.Writer {
|
||||
return &frpcWriter{l: l}
|
||||
}
|
||||
|
||||
type frpcWriter struct{ l *Log }
|
||||
|
||||
func (w *frpcWriter) Write(p []byte) (int, error) {
|
||||
w.l.write("FRPC", "%s", string(p))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (l *Log) write(level, format string, args ...any) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format(timeLayout), level, msg)
|
||||
|
||||
if l.f != nil {
|
||||
_, _ = l.f.WriteString(line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
// Package service 负责 Windows 服务生命周期 + frpc 子进程管理。
|
||||
//
|
||||
// 设计概览:
|
||||
//
|
||||
// ┌─────────────────────────────────────────────────────────────┐
|
||||
// │ Windows Service Control Manager (SCM) │
|
||||
// │ │ Start/Stop/Control │
|
||||
// │ ▼ │
|
||||
// │ Run() -> svc.Run("frpc-client", &handler{cfg}) │
|
||||
// │ │ │
|
||||
// │ ▼ │
|
||||
// │ handler.Execute() -> supervise frpc 子进程 │
|
||||
// └─────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// 关键点:
|
||||
//
|
||||
// - svc.Handler.Execute 是阻塞的,直到我们 return 才视为服务停止
|
||||
// - frpc 以子进程方式运行,不用 syscall 0x08000000 隐藏窗口
|
||||
// - SCM 发 Stop -> 我们杀 frpc -> return,服务停止
|
||||
// - frpc 异常退出 -> 我们也 return,SCM 会重试或上报
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/debug"
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
"golang.org/x/sys/windows/svc/mgr"
|
||||
|
||||
"frpc-client-win/internal/config"
|
||||
"frpc-client-win/internal/logger"
|
||||
)
|
||||
|
||||
// kernel32 是 win32 调用封装,用于隐藏 SCM 启动时附带的 console 窗口。
|
||||
var (
|
||||
modkernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procFreeConsole = modkernel32.NewProc("FreeConsole")
|
||||
)
|
||||
|
||||
// freeConsole 解除当前进程与 console 的关联,避免 SCM 启动时闪黑窗。
|
||||
//
|
||||
// 仅在以 console 子系统编译、且当前已被 SCM 启动时调用。
|
||||
func freeConsole() {
|
||||
procFreeConsole.Call()
|
||||
}
|
||||
|
||||
// ServiceName 注册到 SCM 的服务名。改这个会破坏已部署客户机,慎改。
|
||||
const ServiceName = "frpc-client"
|
||||
|
||||
// DisplayName SCM 中显示名。
|
||||
const DisplayName = "FRPC Client Service"
|
||||
|
||||
// Description 服务描述。
|
||||
const Description = "frpc 内网穿透客户端,后台常驻服务。"
|
||||
|
||||
// elog 是写到 Windows Event Log 的 logger。
|
||||
// debug 模式和服务模式下都用 debug.Log,以便 -d 时也能看到事件日志格式。
|
||||
var elog debug.Log
|
||||
|
||||
// Run 是服务模式入口。
|
||||
//
|
||||
// 如果当前是 SCM 启动 -> svc.Run;否则 -> debug.Run(日志到控制台)。
|
||||
//
|
||||
// 注意:用 console 子系统编译是为了让 install/start 等 CLI 子命令有输出;
|
||||
// SCM 启动时这里会短暂附一个 console(可能闪黑窗),所以立刻 FreeConsole 脱离。
|
||||
func Run(cfg *config.Config) error {
|
||||
elog = debug.New(ServiceName)
|
||||
defer elog.Close()
|
||||
|
||||
isSvc, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query SCM: %w", err)
|
||||
}
|
||||
|
||||
if isSvc {
|
||||
// 服务模式:脱离 console,避免 SCM 启动时闪黑窗
|
||||
freeConsole()
|
||||
}
|
||||
|
||||
h := &handler{cfg: cfg, log: cfg.Log}
|
||||
if isSvc {
|
||||
elog.Info(1, "starting in SCM service mode")
|
||||
return svc.Run(ServiceName, h)
|
||||
}
|
||||
|
||||
elog.Info(1, "starting in debug mode (console)")
|
||||
return debug.Run(ServiceName, h)
|
||||
}
|
||||
|
||||
// RunForeground 控制台前台运行,Ctrl+C 退出。
|
||||
//
|
||||
// 适合临时调试或在没有 SCM 的环境(Linux 交叉编译产物)验证。
|
||||
func RunForeground(cfg *config.Config) error {
|
||||
cfg.Log.Info("starting in foreground mode")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sup := newSupervisor(cfg)
|
||||
|
||||
// Run 是阻塞的,自愈重启循环也在里面;Ctrl+C 通过 ctx 取消让它退出。
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signalNotify(sigCh)
|
||||
<-sigCh
|
||||
cfg.Log.Info("foreground: signal received, exiting")
|
||||
cancel()
|
||||
sup.Stop()
|
||||
}()
|
||||
|
||||
if err := sup.Run(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Log.Info("foreground: supervisor exited")
|
||||
return nil
|
||||
}
|
||||
|
||||
// supervisor 持有 frpc 子进程 + 自愈逻辑。
|
||||
//
|
||||
// 设计:frpc 进程意外退出时,supervisor 不会让整个服务跟着挂,
|
||||
// 而是按退避策略自动重启,直到:
|
||||
//
|
||||
// - 服务收到 SCM Stop/Shutdown 信号
|
||||
// - 连续重启次数达到上限(MaxRestarts) - 防止配错时无限刷日志
|
||||
// - 配置/二进制缺失等不可恢复错误
|
||||
type supervisor struct {
|
||||
cfg *config.Config
|
||||
log *logger.Log
|
||||
|
||||
cmd *exec.Cmd
|
||||
cmdMu sync.Mutex
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newSupervisor(cfg *config.Config) *supervisor {
|
||||
return &supervisor{cfg: cfg, log: cfg.Log}
|
||||
}
|
||||
|
||||
// 重启策略常量。
|
||||
const (
|
||||
// MaxRestarts 是 supervisor 在放弃前的最大连续重启次数。
|
||||
// 超过此数说明配置/环境有问题,服务主动停止,让 SCM 接管。
|
||||
MaxRestarts = 20
|
||||
|
||||
// InitialBackoff 是第一次重启前的等待。
|
||||
InitialBackoff = 2 * time.Second
|
||||
|
||||
// MaxBackoff 是单次重启等待的上限(指数退避)。
|
||||
MaxBackoff = 60 * time.Second
|
||||
)
|
||||
|
||||
// Run 启动并监督 frpc 进程,frpc 死了自动重启。
|
||||
//
|
||||
// 返回:
|
||||
//
|
||||
// nil - 收到停止信号,正常退出
|
||||
// error - 不可恢复错误(配置缺失、二进制缺失等)或重启次数耗尽
|
||||
func (s *supervisor) Run(ctx context.Context) error {
|
||||
// 启动前的环境检查
|
||||
if err := s.cfg.EnsureFrpcBinary(); err != nil {
|
||||
if errors.Is(err, config.ErrFrpcMissing) {
|
||||
return fmt.Errorf("no frpc binary; place frpc.exe in %s or rebuild with embedded binary", s.cfg.ExeDir)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.EnsureIni(); err != nil {
|
||||
return fmt.Errorf("no frpc config in %s; run `frpc-client.exe import` to import one, or just place any *.ini/*.toml in that directory", s.cfg.ExeDir)
|
||||
}
|
||||
s.log.Info("config source=%s, path=%s", s.cfg.ConfigSource(), s.cfg.EffectiveConfigPath())
|
||||
|
||||
backoff := InitialBackoff
|
||||
restarts := 0
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
// 上下文被取消(服务停止)
|
||||
s.log.Info("supervisor: context cancelled, exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd, err := s.startOnce(ctx)
|
||||
if err != nil {
|
||||
// 启动命令本身失败(例如二进制权限)——不可恢复,立即退出
|
||||
return err
|
||||
}
|
||||
|
||||
// 等子进程退出
|
||||
waitErr := cmd.Wait()
|
||||
s.cmdMu.Lock()
|
||||
s.cmd = nil
|
||||
s.cmdMu.Unlock()
|
||||
|
||||
if s.stopped || ctx.Err() != nil {
|
||||
s.log.Info("frpc exited due to supervisor stop")
|
||||
if waitErr != nil {
|
||||
s.log.Info("frpc wait err (expected on stop): %v", waitErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
restarts++
|
||||
if restarts > MaxRestarts {
|
||||
s.log.Error("frpc died %d times in a row, giving up; check frpc config and frps reachability", MaxRestarts)
|
||||
return fmt.Errorf("frpc restart budget exhausted (%d restarts)", MaxRestarts)
|
||||
}
|
||||
|
||||
s.log.Warn("frpc exited unexpectedly (err=%v); restarting in %s (attempt %d/%d)",
|
||||
waitErr, backoff, restarts, MaxRestarts)
|
||||
|
||||
// 退避等待,带取消感知
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
// 指数退避,封顶 MaxBackoff
|
||||
backoff *= 2
|
||||
if backoff > MaxBackoff {
|
||||
backoff = MaxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startOnce 启动一次 frpc 并返回 *exec.Cmd。
|
||||
// 由 Run() 在循环里反复调用。
|
||||
func (s *supervisor) startOnce(ctx context.Context) (*exec.Cmd, error) {
|
||||
cfgPath := s.cfg.EffectiveConfigPath()
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.cfg.FrpcPath, "-c", cfgPath)
|
||||
cmd.Stdout = s.log.FrpcRedirect()
|
||||
cmd.Stderr = s.log.FrpcRedirect()
|
||||
// Windows 上不弹黑窗口:CREATE_NO_WINDOW=0x08000000
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000}
|
||||
|
||||
s.log.Info("launching frpc: %s -c %s", s.cfg.FrpcPath, cfgPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start frpc: %w", err)
|
||||
}
|
||||
|
||||
s.cmdMu.Lock()
|
||||
s.cmd = cmd
|
||||
s.cmdMu.Unlock()
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// Stop 终止 frpc 子进程,最多等 5s。
|
||||
//
|
||||
// 调用方应该先取消 supervisor 的 ctx,本方法会把 stopped 标记置上,
|
||||
// 防止 Run() 循环把它当作"意外退出"而触发重启。
|
||||
func (s *supervisor) Stop() {
|
||||
s.cmdMu.Lock()
|
||||
defer s.cmdMu.Unlock()
|
||||
if s.cmd == nil || s.cmd.Process == nil {
|
||||
s.stopped = true
|
||||
return
|
||||
}
|
||||
if s.stopped {
|
||||
return
|
||||
}
|
||||
s.stopped = true
|
||||
|
||||
s.log.Info("stopping frpc (pid=%d)...", s.cmd.Process.Pid)
|
||||
_ = s.cmd.Process.Kill()
|
||||
|
||||
// cmd.Wait() 在 Run() 里已经异步执行,这里不能再 Wait。
|
||||
// 用轮询 ProcessState 代替。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.cmd.ProcessState != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if s.cmd.ProcessState == nil {
|
||||
s.log.Warn("frpc did not exit in 5s, force killing")
|
||||
_ = s.cmd.Process.Kill()
|
||||
} else {
|
||||
s.log.Info("frpc stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// alive 检查 frpc 子进程是否还在。
|
||||
// 仅用于早期(StartOnce 之后第一次心跳),大部分时间 Run 自己在监听 cmd.Wait。
|
||||
func (s *supervisor) alive() bool {
|
||||
s.cmdMu.Lock()
|
||||
defer s.cmdMu.Unlock()
|
||||
if s.cmd == nil || s.cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
return s.cmd.ProcessState == nil
|
||||
}
|
||||
|
||||
// ---------- handler implements svc.Handler ----------
|
||||
|
||||
type handler struct {
|
||||
cfg *config.Config
|
||||
log *logger.Log
|
||||
sup *supervisor
|
||||
}
|
||||
|
||||
func (h *handler) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
|
||||
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||
changes <- svc.Status{State: svc.StartPending}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
h.sup = newSupervisor(h.cfg)
|
||||
|
||||
// supervisor.Run() 是阻塞的,自带自愈重启循环。
|
||||
// 我们把它丢到 goroutine 里,通过 done channel 观察它什么时候退。
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- h.sup.Run(ctx)
|
||||
}()
|
||||
|
||||
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||
|
||||
heartbeat := time.NewTicker(1 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case c := <-r:
|
||||
switch c.Cmd {
|
||||
case svc.Interrogate:
|
||||
changes <- c.CurrentStatus
|
||||
case svc.Stop, svc.Shutdown:
|
||||
h.log.Info("SCM requests %v, stopping supervisor", c.Cmd)
|
||||
elog.Info(1, fmt.Sprintf("SCM requests %v, stopping supervisor", c.Cmd))
|
||||
cancel() // 通知 supervisor.Run 退出
|
||||
h.sup.Stop() // 兜底:再强杀一次
|
||||
<-done // 等 Run 退出干净
|
||||
changes <- svc.Status{State: svc.StopPending}
|
||||
return false, 0
|
||||
default:
|
||||
h.log.Warn("unknown SCM request cmd=%d", c.Cmd)
|
||||
elog.Error(1, fmt.Sprintf("unexpected control request #%d", c))
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
// 心跳仅用于确认 SCM 还能联系上我们;supervisor 自己管 frpc 死活
|
||||
_ = h.sup.alive()
|
||||
case err := <-done:
|
||||
// supervisor 自己退出了 —— 可能是重启次数耗尽或不可恢复错误
|
||||
if err != nil {
|
||||
h.log.Error("supervisor exited with error: %v", err)
|
||||
elog.Error(1, fmt.Sprintf("supervisor exited with error: %v", err))
|
||||
return false, 3
|
||||
}
|
||||
h.log.Info("supervisor exited cleanly")
|
||||
return false, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 服务安装/卸载/启停 ----------
|
||||
|
||||
// Install 注册本程序为 Windows 服务,启动类型为自动。
|
||||
func Install() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect SCM: %w", err)
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
// 检查服务是否已存在
|
||||
if existing, err := m.OpenService(ServiceName); err == nil {
|
||||
existing.Close()
|
||||
return fmt.Errorf("service %q already exists; run `remove` first if you want to reinstall", ServiceName)
|
||||
}
|
||||
|
||||
// 注册 Event Log 源(失败不致命,只影响事件日志可见性)
|
||||
_ = eventlog.InstallAsEventCreate(ServiceName, eventlog.Error|eventlog.Warning|eventlog.Info)
|
||||
|
||||
conf := mgr.Config{
|
||||
DisplayName: DisplayName,
|
||||
Description: Description,
|
||||
StartType: mgr.StartAutomatic,
|
||||
}
|
||||
s, err := m.CreateService(ServiceName, exe, conf)
|
||||
if err != nil {
|
||||
// 失败时回滚 eventlog
|
||||
_ = eventlog.Remove(ServiceName)
|
||||
return fmt.Errorf("create service: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
fmt.Printf("✔ service installed: %s\n", ServiceName)
|
||||
fmt.Printf(" binary: %s\n", exe)
|
||||
fmt.Println(" next: run `frpc-client.exe start` to start it now")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove 停止并删除服务。
|
||||
func Remove() error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
s, err := m.OpenService(ServiceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open service: %w", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// 先尝试停止(忽略 "not started" 错误)
|
||||
_, _ = s.Control(svc.Stop)
|
||||
|
||||
// 等待它真的停掉
|
||||
for {
|
||||
status, err := s.Query()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if status.State == svc.Stopped {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err := s.Delete(); err != nil {
|
||||
return fmt.Errorf("delete service: %w", err)
|
||||
}
|
||||
_ = eventlog.Remove(ServiceName)
|
||||
fmt.Println("✔ service removed:", ServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start 启动服务。
|
||||
func Start() error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
s, err := m.OpenService(ServiceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if err := s.Start(); err != nil {
|
||||
return fmt.Errorf("start service: %w", err)
|
||||
}
|
||||
fmt.Println("✔ service started:", ServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止服务(不卸载)。
|
||||
//
|
||||
// 如果服务当前已经是 Stopped 状态,返回 nil 而不是 error -
|
||||
// 这样 `stop && remove` 这种链式调用不会因为前置 stop 没必要而中断。
|
||||
func Stop() error {
|
||||
m, err := mgr.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer m.Disconnect()
|
||||
|
||||
s, err := m.OpenService(ServiceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// 先查询状态:已停止就跳过
|
||||
status, err := s.Query()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query service: %w", err)
|
||||
}
|
||||
if status.State == svc.Stopped {
|
||||
fmt.Println("service already stopped:", ServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := s.Control(svc.Stop); err != nil {
|
||||
return fmt.Errorf("stop service: %w", err)
|
||||
}
|
||||
fmt.Println("✔ service stopped:", ServiceName)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// signalNotify 跨平台注册 Ctrl+C / SIGTERM。
|
||||
func signalNotify(ch chan<- os.Signal) {
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
// signalNotify 在 Windows 下注册 Ctrl+C。
|
||||
//
|
||||
// 注意:SCM 控制的服务不通过信号停止,而是通过 svc.ChangeRequest。
|
||||
// 这个函数只对控制台前台模式(RunForeground)有意义。
|
||||
// 若编译时使用了 -H windowsgui(隐藏控制台),前台模式将无法响应 Ctrl+C,
|
||||
//
|
||||
// 只能通过任务管理器结束进程 - 这是预期行为。
|
||||
func signalNotify(ch chan<- os.Signal) {
|
||||
signal.Notify(ch, os.Interrupt)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
// Package main 是 frpc-client-win 的入口。
|
||||
//
|
||||
// 双模式入口:
|
||||
//
|
||||
// 带子命令 -> 服务管理 (install/remove/start/stop/run/import)
|
||||
// 不带子命令 -> 服务模式运行(已注册为服务时)
|
||||
// 不带子命令 -d -> 控制台前台运行(调试用)
|
||||
//
|
||||
// frpc.exe 通过 embed.FS 内嵌到 binary 中,部署只需一个 EXE。
|
||||
//
|
||||
// 编译说明:
|
||||
//
|
||||
// 默认用 CONSOLE 子系统,这样 install/start/import 等 CLI 子命令能输出反馈。
|
||||
// 服务运行时 (svc.Run) 即使从 console EXE 启动,SCM 也会自动脱离控制台窗口,
|
||||
// 不会出现黑窗。
|
||||
// 不要加 -H windowsgui,否则 CLI 子命令的 stdout 会被静默吃掉(踩过的坑)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"frpc-client-win/internal/config"
|
||||
"frpc-client-win/internal/logger"
|
||||
"frpc-client-win/internal/service"
|
||||
)
|
||||
|
||||
//go:embed assets/frpc.exe
|
||||
var frpcFS embed.FS
|
||||
|
||||
// frpcBinName 是嵌入的 frpc 二进制文件名(同目录会优先使用本地版)。
|
||||
const frpcBinName = "frpc.exe"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
exeDir, err := exeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 注意:日志路径必须是绝对的!
|
||||
// SCM 启动的服务默认工作目录是 C:\Windows\System32,
|
||||
// 用相对路径会把日志写到 System32 下,而不是 EXE 同目录。
|
||||
logDir := filepath.Join(exeDir, "logs")
|
||||
log, err := logger.New(logDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init logger at %s: %w", logDir, err)
|
||||
}
|
||||
defer log.Close()
|
||||
|
||||
cfg := config.New(exeDir, frpcBinName, frpcFS, log)
|
||||
|
||||
// 没有参数 -> 直接以服务/前台模式启动
|
||||
if len(os.Args) < 2 {
|
||||
return service.Run(cfg)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "install":
|
||||
return service.Install()
|
||||
case "remove", "uninstall":
|
||||
return service.Remove()
|
||||
case "start":
|
||||
return service.Start()
|
||||
case "stop":
|
||||
return service.Stop()
|
||||
case "run":
|
||||
// 'run' 子命令强制以服务模式运行(SCM 调用此子命令)
|
||||
return service.Run(cfg)
|
||||
case "import":
|
||||
return config.ImportInteractive(cfg)
|
||||
case "-d", "console":
|
||||
// 控制台前台模式,直接启动 frpc 看日志
|
||||
return service.RunForeground(cfg)
|
||||
default:
|
||||
return fmt.Errorf("unknown command: %s\n\nUsage:\n install | remove | start | stop | import | -d", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
// exeDir 返回当前可执行文件所在目录。
|
||||
//
|
||||
// 当作为 Windows 服务运行时,os.Getwd() 指向 System32,
|
||||
// 所以必须用 Module 路径而不是当前工作目录。
|
||||
func exeDir() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
return filepath.Dir(exe), nil
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
// 本文件仅用于在 Linux 上 smoke test frpc-client-win 的核心逻辑。
|
||||
// 编译: GOOS=linux GOARCH=amd64 go build -tags linux -o /tmp/frpc-client-test .
|
||||
// 不参与正式 Windows 构建。
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"frpc-client-win/internal/config"
|
||||
"frpc-client-win/internal/logger"
|
||||
)
|
||||
|
||||
//go:embed assets/frpc.exe
|
||||
var smokeFS embed.FS
|
||||
|
||||
func main() {
|
||||
tmpDir, err := os.MkdirTemp("", "frpc-test-*")
|
||||
if err != nil {
|
||||
fmt.Println("mkdir:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
log, err := logger.New(filepath.Join(tmpDir, "logs"))
|
||||
if err != nil {
|
||||
fmt.Println("logger:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer log.Close()
|
||||
|
||||
cfg := config.New(tmpDir, "frpc.exe", smokeFS, log)
|
||||
|
||||
// 测试 EnsureFrpcBinary:应该从 embed 释放 frpc.exe
|
||||
if err := cfg.EnsureFrpcBinary(); err != nil {
|
||||
fmt.Println("EnsureFrpcBinary failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
stat, err := os.Stat(cfg.FrpcPath)
|
||||
if err != nil {
|
||||
fmt.Println("frpc.exe not released:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✔ frpc.exe released: %s (%d bytes)\n", cfg.FrpcPath, stat.Size())
|
||||
|
||||
// 验证确实是 PE 文件
|
||||
if _, err := fs.Stat(smokeFS, "assets/frpc.exe"); err != nil {
|
||||
fmt.Println("embed missing:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✔ embed.FS contains assets/frpc.exe")
|
||||
|
||||
// 测试 EnsureIni:应该失败,因为没有配置文件
|
||||
if err := cfg.EnsureIni(); err == nil {
|
||||
fmt.Println("expected EnsureIni to fail, got nil")
|
||||
os.Exit(1)
|
||||
} else {
|
||||
fmt.Printf("✔ EnsureIni correctly returned: %v\n", err)
|
||||
}
|
||||
|
||||
// 创建一个假的 ini 并测试
|
||||
iniPath := filepath.Join(tmpDir, "frpc.ini")
|
||||
if err := os.WriteFile(iniPath, []byte("[common]\nserver_addr = 127.0.0.1\nserver_port = 7000\n"), 0o644); err != nil {
|
||||
fmt.Println("write ini:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := cfg.EnsureIni(); err != nil {
|
||||
fmt.Println("EnsureIni should now succeed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("✔ EnsureIni OK; effective config: %s\n", cfg.EffectiveConfigPath())
|
||||
|
||||
// 测试 EffectiveConfigPath 优先级:新建一个 toml,EffectiveConfigPath 仍然应返回 ini
|
||||
tomlPath := filepath.Join(tmpDir, "frpc.toml")
|
||||
os.WriteFile(tomlPath, []byte("# toml"), 0o644)
|
||||
if cfg.EffectiveConfigPath() != iniPath {
|
||||
fmt.Printf("expected ini preferred, got: %s\n", cfg.EffectiveConfigPath())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Remove(tomlPath)
|
||||
fmt.Println("✔ .ini takes precedence over .toml")
|
||||
|
||||
// === 新场景:任意 *.ini 文件名(frpc-mike.ini 这类) ===
|
||||
// 先清掉标准名 ini,只留一个非标准名的
|
||||
os.Remove(iniPath)
|
||||
mikeIni := filepath.Join(tmpDir, "frpc-mike.ini")
|
||||
if err := os.WriteFile(mikeIni, []byte("[common]\nserver_addr = 10.0.0.1\n"), 0o644); err != nil {
|
||||
fmt.Println("write mike ini:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := cfg.EnsureIni(); err != nil {
|
||||
fmt.Println("EnsureIni should pick frpc-mike.ini via scan:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.EffectiveConfigPath() != mikeIni {
|
||||
fmt.Printf("expected scan to find frpc-mike.ini, got: %s\n", cfg.EffectiveConfigPath())
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.ConfigSource() != "scan" {
|
||||
fmt.Printf("expected source=scan, got: %s\n", cfg.ConfigSource())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✔ arbitrary-name ini (frpc-mike.ini) auto-detected via scan")
|
||||
|
||||
// === 多个候选应报错 ===
|
||||
os.WriteFile(filepath.Join(tmpDir, "frpc-other.ini"), []byte("[common]\n"), 0o644)
|
||||
if err := cfg.EnsureIni(); err == nil {
|
||||
fmt.Println("expected ambiguous error, got nil")
|
||||
os.Exit(1)
|
||||
} else if !errors.Is(err, config.ErrIniAmbiguous) {
|
||||
fmt.Printf("expected ErrIniAmbiguous, got: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✔ multiple candidates correctly reported ambiguous")
|
||||
|
||||
fmt.Println("\n✔ ALL SMOKE TESTS PASSED")
|
||||
}
|
||||
Reference in New Issue
Block a user