From 2c1cf1260b778ffe2044a94d6f79b145a3415f1a Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Mon, 19 Oct 2026 13:50:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3frpc=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E7=9A=84=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/api/frpmulti_handler.go | 28 +++++++-- internal/app/app.go | 5 +- internal/frp/restart_test.go | 97 ++++++++++++++++++++++++++++++++ internal/frp/service.go | 68 ++++++++++++++++++++++ internal/settings/settings.go | 4 ++ 5 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 internal/frp/restart_test.go diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index dce16be..659ace3 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "io" @@ -961,20 +962,33 @@ func (h *FRPMultiHandler) DeleteProxy(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// frpDetachedContext 给 frpc 的启停操作一个不随请求取消的上下文。 +// +// 用户常常是从隧道那头点进来的(浏览器 → VPS → frpc → 本机),而「停止/重启」会先杀掉 +// frpc —— 那一刻用户的连接就断了,r.Context() 随之取消。继续拿它做后续的数据库读写, +// 就会出现「frpc 停掉了、却再也拉不起来」的经典事故。 +func frpDetachedContext(r *http.Request) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Minute) +} + func (h *FRPMultiHandler) Start(w http.ResponseWriter, r *http.Request) { - if err := h.svc.Start(r.Context()); err != nil { + ctx, cancel := frpDetachedContext(r) + defer cancel() + if err := h.svc.Start(ctx); err != nil { writeError(r, w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, h.svc.Status(r.Context())) + writeJSON(w, http.StatusOK, h.svc.Status(ctx)) } func (h *FRPMultiHandler) Stop(w http.ResponseWriter, r *http.Request) { - if err := h.svc.Stop(r.Context()); err != nil { + ctx, cancel := frpDetachedContext(r) + defer cancel() + if err := h.svc.Stop(ctx); err != nil { writeError(r, w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, h.svc.Status(r.Context())) + writeJSON(w, http.StatusOK, h.svc.Status(ctx)) } func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) { @@ -987,11 +1001,13 @@ func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) { // Restart 重启全部 frpc:比「停止 → 启动」更稳,重启后会重建 Agent 隧道。 func (h *FRPMultiHandler) Restart(w http.ResponseWriter, r *http.Request) { - if err := h.svc.Restart(r.Context()); err != nil { + ctx, cancel := frpDetachedContext(r) + defer cancel() + if err := h.svc.Restart(ctx); err != nil { writeError(r, w, http.StatusBadRequest, err.Error()) return } - writeJSON(w, http.StatusOK, h.svc.Status(r.Context())) + writeJSON(w, http.StatusOK, h.svc.Status(ctx)) } func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/app.go b/internal/app/app.go index ea6301b..7d12057 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -127,6 +127,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) conn.Close() return nil, err } + // 记录/恢复 frpc 运行状态:应用被停用再启用后要能自动回到运行状态 + frpMultiSvc.SetSettingsStore(settingsStore) cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc) if err := cloudflareSvc.EnsureDirs(); err != nil { conn.Close() @@ -350,7 +352,8 @@ func (a *App) Shutdown(ctx context.Context) error { a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error()) } if a.frpMulti != nil { - if err := a.frpMulti.Stop(ctx); err != nil { + // 退出时保留「上次在运行」标记,供下次启用应用时自动恢复 frpc + if err := a.frpMulti.StopForShutdown(ctx); err != nil { a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error()) } } diff --git a/internal/frp/restart_test.go b/internal/frp/restart_test.go new file mode 100644 index 0000000..596f410 --- /dev/null +++ b/internal/frp/restart_test.go @@ -0,0 +1,97 @@ +package frp + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/certificate" + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/db" + "github.com/havline/havline/internal/secret" + "github.com/havline/havline/internal/settings" +) + +func newRestartTestService(t *testing.T) (*Service, *settings.Store, context.Context) { + t.Helper() + dir := t.TempDir() + conn, err := db.Open(filepath.Join(dir, "havline.db")) + if err != nil { + t.Fatalf("打开数据库失败:%v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatalf("迁移失败:%v", err) + } + box, err := secret.NewBox("restart-test-key") + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, logger, certificate.NewStore(conn)) + if err := svc.EnsureDirs(); err != nil { + t.Fatalf("创建目录失败:%v", err) + } + settingsStore := settings.NewStore(conn) + svc.SetSettingsStore(settingsStore) + return svc, settingsStore, context.Background() +} + +// 用户往往是从隧道那头点「重启」:Stop 一杀 frpc,连接就断、请求上下文随即取消。 +// 重启流程必须无视这个取消走完,否则就是「停掉了却再也起不来」。 +func TestRestartIgnoresCancelledRequestContext(t *testing.T) { + svc, _, _ := newRestartTestService(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := svc.Restart(ctx) + if err != nil && strings.Contains(err.Error(), "context canceled") { + t.Fatalf("重启不应被请求取消打断:%v", err) + } +} + +// frpc 的启停状态要能被记录与读回:应用停用再启用后据此自动恢复。 +func TestFrpResumeStateRoundTrip(t *testing.T) { + svc, _, ctx := newRestartTestService(t) + + if svc.lastSessionWasRunning(ctx) { + t.Fatal("默认应为「上次未运行」") + } + svc.rememberResumeState(ctx, true) + if !svc.lastSessionWasRunning(ctx) { + t.Fatal("记录运行后应能读回,供下次启动自动恢复") + } + svc.rememberResumeState(ctx, false) + if svc.lastSessionWasRunning(ctx) { + t.Fatal("手动停止后不应再自动恢复") + } +} + +// 应用退出(StopForShutdown)不能把「上次在运行」标记抹掉, +// 否则「停用应用 → 重新启用」永远触发不了自动恢复。 +func TestStopForShutdownKeepsResumeFlag(t *testing.T) { + svc, _, ctx := newRestartTestService(t) + + // 模拟退出前 frpc 正在运行(真实环境里由 Status 判断,测试里直接给结论) + svc.runningProbe = func(context.Context) bool { return true } + svc.rememberResumeState(ctx, true) + if err := svc.StopForShutdown(ctx); err != nil { + t.Fatalf("退出时停止失败:%v", err) + } + if !svc.lastSessionWasRunning(ctx) { + t.Fatal("退出后仍应保留「上次在运行」标记,供下次启用时自动恢复") + } + + // 退出前本来就是停止的:不能凭空写成「在运行」 + svc.runningProbe = func(context.Context) bool { return false } + if err := svc.StopForShutdown(ctx); err != nil { + t.Fatalf("退出时停止失败:%v", err) + } + if svc.lastSessionWasRunning(ctx) { + t.Fatal("退出前没在运行就不该标记为自动恢复") + } +} diff --git a/internal/frp/service.go b/internal/frp/service.go index 45a9713..1eccbfd 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -18,6 +18,7 @@ import ( "github.com/havline/havline/internal/config" "github.com/havline/havline/internal/fsutil" "github.com/havline/havline/internal/secret" + "github.com/havline/havline/internal/settings" ) // maskedToken 是敏感凭据的统一回显掩码:API 永不返回明文;保存时收到掩码一律视为保留旧值 @@ -62,6 +63,13 @@ type Service struct { // applyMu 保证 FRP 配置文件写入与批量启动/重载的原子边界(23.5): // SQLite 事务只保护关系数据,不保护 frpc-server-*.toml 与 frpc 进程。 applyMu sync.Mutex + + // settings 用来记录 frpc 的启停状态(KeyFrpResumeOnStart):应用被停用再启用后 + // 据此自动恢复运行。未注入时自动恢复不生效。 + settings *settings.Store + + // runningProbe 仅供测试替换:默认按 Status 判断 frpc 是否在运行 + runningProbe func(ctx context.Context) bool } func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, certStore *certificate.Store) *Service { @@ -2292,6 +2300,7 @@ func (s *Service) Start(ctx context.Context) error { if len(failures) > 0 { return fmt.Errorf("%s", strings.Join(failures, ";")) } + s.rememberResumeState(ctx, true) return nil } @@ -2307,12 +2316,66 @@ func (s *Service) AutoStart(ctx context.Context) error { break } } + // 上次应用退出时 frpc 还在运行:这次也自动拉起来。 + // 否则「停用应用 → 重新启用」之后 frpc 是停的,用户点图标没反应还得绕路进后台手动开。 + if !shouldStart && len(servers) > 0 && s.lastSessionWasRunning(ctx) { + shouldStart = true + } if !shouldStart { return nil } return s.Start(ctx) } +// SetSettingsStore 注入设置存储:用来记录/读取 frpc 的运行状态。 +func (s *Service) SetSettingsStore(store *settings.Store) { + s.settings = store +} + +// rememberResumeState 记录 frpc 当前是否在运行,供下次应用启动时决定要不要自动拉起。 +func (s *Service) rememberResumeState(ctx context.Context, running bool) { + if s.settings == nil { + return + } + value := "0" + if running { + value = "1" + } + if err := s.settings.Set(ctx, settings.KeyFrpResumeOnStart, value); err != nil { + s.logger.Warn("记录 frpc 运行状态失败", "module", "FRP", "error", err.Error()) + } +} + +// lastSessionWasRunning 上次 frpc 处于运行状态时为真。 +func (s *Service) lastSessionWasRunning(ctx context.Context) bool { + if s.settings == nil { + return false + } + value, err := s.settings.Get(ctx, settings.KeyFrpResumeOnStart) + return err == nil && value == "1" +} + +// StopForShutdown 是应用退出时的停止:行为同 Stop,但保留「上次在运行」标记。 +// +// 否则「停用应用 → 重新启用」会被 Stop 写成「上次是停的」,自动恢复就永远不触发。 +func (s *Service) StopForShutdown(ctx context.Context) error { + wasRunning := s.frpRunning(ctx) + if err := s.Stop(ctx); err != nil { + return err + } + // Stop 会把标记写成「不再自动恢复」,这里按退出前的真实状态改回来 + s.rememberResumeState(ctx, wasRunning) + return nil +} + +// frpRunning 判断当前是否有 frpc 在运行。 +func (s *Service) frpRunning(ctx context.Context) bool { + if s.runningProbe != nil { + return s.runningProbe(ctx) + } + return s.Status(ctx).Status == "running" +} + func (s *Service) Stop(ctx context.Context) error { s.stopAgentTunnels() _ = s.ReconcileRuntime(ctx) @@ -2332,6 +2395,7 @@ func (s *Service) Stop(ctx context.Context) error { if len(messages) > 0 { return fmt.Errorf("%s", strings.Join(messages, ";")) } + s.rememberResumeState(ctx, false) return nil } @@ -2340,6 +2404,10 @@ func (s *Service) Stop(ctx context.Context) error { // 比手动「停止 → 启动」多一层保障:Stop 会把 Agent 隧道一起停掉,而单独点「启动」 // 不会重建它们;重启在这里顺带把隧道重新拉起来,避免停一次之后就再也连不上。 func (s *Service) Restart(ctx context.Context) error { + // 双保险:即使调用方传进来的是会被取消的请求上下文(用户正是从隧道那头点进来的), + // 重启流程也必须走完——否则就变成「停掉了却起不来」。 + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) + defer cancel() stopErr := s.Stop(ctx) startErr := s.Start(ctx) if startErr != nil { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index a94da89..a52a89c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -90,6 +90,10 @@ const ( KeyTerminalEnabled = "terminal_enabled" // KeyTerminalRecording 会话录制开关:默认关闭,开启后把终端输出写入 logs/terminal KeyTerminalRecording = "terminal_recording_enabled" + // KeyFrpResumeOnStart 记录「上次 frpc 是否处于运行状态」:应用被停用再启用后, + // 用它决定要不要自动把 frpc 拉起来(否则用户会发现应用起来了但 frpc 没起)。 + // 该键由程序内部维护,不在设置页暴露。 + KeyFrpResumeOnStart = "frp_resume_on_start" ) var Defaults = map[string]string{