@@ -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) {
|
||||
|
||||
+4
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("退出前没在运行就不该标记为自动恢复")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user