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 {
|
2026-10-01 16:54:35 +08:00
|
|
|
return "", fmt.Errorf("未找到 frpc 二进制,请设置 HAVLINE_FRPC_BIN")
|
2026-09-23 09:40:59 +08:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|