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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user