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:
cnbugs
2026-08-10 22:11:43 +08:00
commit 69801ecc3f
13 changed files with 1214 additions and 0 deletions
+18
View File
@@ -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
+112
View File
@@ -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。
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
module frpc-client-win
go 1.21
require golang.org/x/sys v0.15.0
+2
View File
@@ -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=
+234
View File
@@ -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
}
+81
View File
@@ -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)
}
}
+502
View File
@@ -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
}
+15
View File
@@ -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)
}
+20
View File
@@ -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)
}
+101
View File
@@ -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
View File
@@ -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")
}