Files

301 lines
6.4 KiB
Go
Raw Permalink Normal View History

2026-09-23 09:40:59 +08:00
package frp
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type Manager struct {
bin string
configPath string
logPath string
logger *slog.Logger
mu sync.RWMutex
cmd *exec.Cmd
orphan *os.Process
orphanPID int
pid int
version string
started time.Time
reloaded time.Time
lastErr string
}
func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
}
func (m *Manager) Verify(ctx context.Context, configPath string) error {
bin, err := m.BinaryPath()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
}
return nil
}
func (m *Manager) VerifyBinary(ctx context.Context) error {
bin, err := m.BinaryPath()
if err != nil {
return err
}
output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
if err != nil {
return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
}
return nil
}
func (m *Manager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.runningLocked() {
return nil
}
bin, err := m.binaryPathLocked()
if err != nil {
m.lastErr = err.Error()
return err
}
logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
cmd := exec.Command(bin, "-c", m.configPath)
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
m.lastErr = err.Error()
return fmt.Errorf("启动 frpc 失败:%w", err)
}
m.cmd = cmd
m.pid = cmd.Process.Pid
m.started = time.Now().UTC()
m.reloaded = m.started
m.lastErr = ""
go m.wait(cmd, logFile)
return nil
}
func (m *Manager) Restart(ctx context.Context) error {
if err := m.Stop(ctx); err != nil {
return err
}
return m.Start()
}
func (m *Manager) Stop(ctx context.Context) error {
m.mu.Lock()
cmd := m.cmd
orphan := m.orphan
m.mu.Unlock()
if cmd == nil && orphan == nil {
return nil
}
process := orphan
if cmd != nil {
process = cmd.Process
}
if process == nil {
return nil
}
if err := process.Kill(); err != nil {
return fmt.Errorf("停止 frpc 失败:%w", err)
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
if !m.running() {
m.mu.Lock()
m.lastErr = ""
m.orphan = nil
m.orphanPID = 0
m.mu.Unlock()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("等待 frpc 停止超时")
case <-time.After(50 * time.Millisecond):
}
}
}
func (m *Manager) Status() RuntimeStatus {
m.mu.RLock()
defer m.mu.RUnlock()
return m.statusLocked()
}
func (m *Manager) statusLocked() RuntimeStatus {
status := RuntimeStatus{
Status: "stopped",
PID: m.pid,
Version: m.version,
LastError: m.lastErr,
}
if m.runningLocked() {
status.Status = "running"
if status.PID == 0 {
status.PID = m.orphanPID
}
} else if m.orphan != nil {
status.PID = 0
}
if !m.started.IsZero() {
status.LastStartedAt = m.started.Format(time.RFC3339)
}
if !m.reloaded.IsZero() {
status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
}
return status
}
func (m *Manager) RefreshVersion(ctx context.Context) {
bin, err := m.BinaryPath()
if err != nil {
return
}
output, err := exec.CommandContext(ctx, bin, "--version").Output()
if err != nil {
return
}
m.mu.Lock()
m.version = strings.TrimSpace(string(output))
m.mu.Unlock()
}
func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
logFile.Close()
m.mu.Lock()
if m.cmd != cmd {
m.mu.Unlock()
return
}
m.cmd = nil
m.pid = 0
if err != nil {
// 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
}
m.mu.Unlock()
}
// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
func tailFile(path string, maxLines int) string {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
return strings.Join(lines, " | ")
}
func (m *Manager) running() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.runningLocked()
}
func (m *Manager) runningLocked() bool {
if m.cmd != nil && m.cmd.Process != nil {
return true
}
return m.orphan != nil && processAlive(m.orphanPID)
}
func (m *Manager) Attach(pid int) bool {
bin, err := m.BinaryPath()
if err != nil || !processMatches(pid, bin, m.configPath) {
return false
}
process, err := os.FindProcess(pid)
if err != nil {
return false
}
m.mu.Lock()
m.orphan = process
m.orphanPID = pid
m.pid = pid
m.lastErr = ""
m.mu.Unlock()
return true
}
func processAlive(pid int) bool {
alive, _ := probeProcess(pid)
return alive
}
func processMatches(pid int, binary, configPath string) bool {
alive, commandLine := probeProcess(pid)
if !alive {
return false
}
commandLine = strings.ToLower(filepath.ToSlash(commandLine))
return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
}
func (m *Manager) BinaryPath() (string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.binaryPathLocked()
}
func (m *Manager) binaryPathLocked() (string, error) {
if strings.ContainsAny(m.bin, `/\\`) {
if _, err := os.Stat(m.bin); err != nil {
return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
}
return m.bin, nil
}
path, err := exec.LookPath(m.bin)
if err != nil {
return "", fmt.Errorf("未找到 frpc 二进制,请设置 FONU_FRPC_BIN")
}
return path, nil
}
func (m *Manager) SetBinary(bin string) {
m.mu.Lock()
m.bin = bin
m.version = ""
m.mu.Unlock()
}
func (m *Manager) LogPath() string {
return filepath.Clean(m.logPath)
}
func safeCommandOutput(output []byte, err error) string {
text := strings.TrimSpace(string(output))
if text == "" {
return err.Error()
}
if len(text) > 500 {
return text[:500]
}
return text
}