69801ecc3f
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.
502 lines
14 KiB
Go
502 lines
14 KiB
Go
//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
|
|
} |