chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
const (
|
||||
frpcCmdTimeout = 8 * time.Second
|
||||
frpcStartWait = 5 * time.Second
|
||||
frpcTerminateWait = 3 * time.Second
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cfg config.Config
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func NewManager(cfg config.Config, store *Store, logger *slog.Logger) *Manager {
|
||||
return &Manager{cfg: cfg, store: store, logger: logger.With("module", "FRP")}
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureDirs() error {
|
||||
return os.MkdirAll(m.cfg.FrpDir(), 0o755)
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, in SaveInput) (ApplyResult, error) {
|
||||
existing, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
keepToken := existing.HasAuthToken && (strings.TrimSpace(in.AuthToken) == "" || in.AuthToken == maskedToken)
|
||||
if err := m.store.Save(ctx, in, keepToken); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
if err := m.Stop(ctx); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.clearStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已停用"}, nil
|
||||
}
|
||||
|
||||
runtimeCfg, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
content, err := Generate(m.cfg, runtimeCfg, token, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
tmpPath := m.cfg.FrpConfigPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.cfg.FrpConfigPath()); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.verifyConfig(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !m.available() {
|
||||
msg := "frpc 未安装,配置已保存"
|
||||
_ = m.store.SetLastError(ctx, msg)
|
||||
return ApplyResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
running, _ := m.isRunning()
|
||||
if !running {
|
||||
if err := m.start(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已启动"}, nil
|
||||
}
|
||||
|
||||
if err := m.reload(ctx); err != nil {
|
||||
m.logger.Warn("frpc reload failed, trying restart", "error", err)
|
||||
if err := m.forceRestart(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重新启动"}, nil
|
||||
}
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重载"}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Bootstrap(ctx context.Context) error {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !m.available() {
|
||||
m.logger.Warn("frpc binary not found, skipping bootstrap", "bin", m.cfg.FrpcBin)
|
||||
return nil
|
||||
}
|
||||
_, err = m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: cfg.CustomDomains,
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Warn("initial frpc apply skipped", "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context) (Config, error) {
|
||||
return m.store.Load(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Status(ctx context.Context) Status {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return Status{Message: "读取配置失败"}
|
||||
}
|
||||
return BuildStatus(
|
||||
m.cfg,
|
||||
cfg,
|
||||
cfg.Enabled,
|
||||
m.store.LastError(ctx),
|
||||
m.store.StartedAt(ctx),
|
||||
ResolveClientVersion(m.cfg.FrpcBin),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Manager) SyncDomains(ctx context.Context, domains []string) (ApplyResult, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
merged := MergeDomains(cfg.CustomDomains, domains)
|
||||
return m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: merged,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Logs(ctx context.Context, limit int) ([]string, error) {
|
||||
return ReadLogTail(m.cfg.FrpLogPath(), limit)
|
||||
}
|
||||
|
||||
func (m *Manager) FRPSConfig(ctx context.Context) (string, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := cfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
return BuildFRPSConfig(port, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort, token), nil
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPPort() int {
|
||||
return m.cfg.NginxDefaultHTTPPort
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPSPort() int {
|
||||
return m.cfg.NginxDefaultHTTPSPort
|
||||
}
|
||||
|
||||
func (m *Manager) verifyConfig(ctx context.Context) error {
|
||||
if !m.available() {
|
||||
return nil
|
||||
}
|
||||
return m.runFrpc(ctx, []string{"verify", "-c", m.cfg.FrpConfigPath()}, "FRP 配置校验失败")
|
||||
}
|
||||
|
||||
func (m *Manager) markStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (m *Manager) clearStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, "")
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
if !m.isRunningQuick() {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
_ = terminateProcess(pid, syscall.SIGTERM)
|
||||
waitProcessExit(pid, frpcTerminateWait)
|
||||
if nginx.IsPIDAlive(pid) {
|
||||
_ = terminateProcess(pid, syscall.SIGKILL)
|
||||
waitProcessExit(pid, time.Second)
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
m.clearStarted(ctx)
|
||||
m.logger.Info("frpc stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) available() bool {
|
||||
_, err := exec.LookPath(m.cfg.FrpcBin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Manager) start(ctx context.Context) error {
|
||||
if m.isRunningQuick() {
|
||||
return m.reload(ctx)
|
||||
}
|
||||
args := []string{"-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpcStart(ctx, args, "FRP 启动失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reload(ctx context.Context) error {
|
||||
args := []string{"reload", "-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpc(ctx, args, "FRP 重载失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc reloaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) forceRestart(ctx context.Context) error {
|
||||
_ = m.Stop(ctx)
|
||||
return m.start(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpc(ctx context.Context, args []string, prefix string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, frpcCmdTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
msg = "操作超时"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpcStart(ctx context.Context, args []string, prefix string) error {
|
||||
cmd := exec.Command(m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
if err := writePIDFile(m.cfg.FrpPIDFile, cmd.Process.Pid); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
|
||||
deadline := time.Now().Add(frpcStartWait)
|
||||
for time.Now().Before(deadline) {
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
m.logger.Warn("frpc exited during start", "error", msg)
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
default:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, "操作超时")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), nil)
|
||||
if msg == "" {
|
||||
msg = "未检测到运行中的进程"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
|
||||
func frpcCommandError(stdout, stderr string, err error) string {
|
||||
msg := strings.TrimSpace(stderr)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(stdout)
|
||||
}
|
||||
if msg == "" && err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "进程已退出"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func writePIDFile(path string, pid int) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("无效的 frpc 进程 ID")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) isRunning() (bool, error) {
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) isRunningQuick() bool {
|
||||
ok, _ := m.isRunning()
|
||||
return ok
|
||||
}
|
||||
|
||||
func readPIDFile(path string) (int, error) {
|
||||
return nginx.ReadPID(path)
|
||||
}
|
||||
|
||||
func removePIDFile(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
func waitProcessExit(pid int, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func terminateProcess(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(sig)
|
||||
}
|
||||
|
||||
func FrpConfigDir(cfg config.Config) string {
|
||||
return filepath.Clean(cfg.FrpDir())
|
||||
}
|
||||
Reference in New Issue
Block a user