diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index 5e9b38b..70f5969 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -15,11 +15,12 @@ import ( agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) -// Restarter applies a freshly-rendered sing-box config. sing-box has no hot -// reload, so the production implementation restarts the systemd unit (a ~1-2s -// data-plane blip). Tests inject a fake. +// Restarter applies a freshly-rendered sing-box config. sing-box 支持 SIGHUP +// 热重载(进程内 validate→close 旧→start 新,坏配置保留旧实例不断网),故配置 +// 变更走 Reload(不整进程重启、不全断流),冷启动走 Restart。Tests inject a fake. type Restarter interface { - Restart(ctx context.Context) error + Restart(ctx context.Context) error // 冷启动:systemctl restart(~1-2s blip) + Reload(ctx context.Context) error // 热重载:SIGHUP 给 sing-box MainPID } // Cred is the agent's in-memory view of one data-plane credential. It is the @@ -60,6 +61,11 @@ type SingBox struct { hy2 *agentv1.Hy2Inbound configVersion int64 + // started 标记 sing-box 是否已被本 agent 冷启动过:首次走 Restart(冷启动), + // 之后配置变更走 Reload(SIGHUP 热重载)。agent 进程重启后复位为 false, + // 下次渲染做一次冷启动以确保与渲染配置一致。 + started bool + // debounce machinery dirty chan struct{} done chan struct{} @@ -328,7 +334,21 @@ func (s *SingBox) writeAndRestart(ctx context.Context) error { if err := atomicWrite(s.cfg.SingboxConfigPath, data, 0o644); err != nil { return err } - return s.restarter.Restart(ctx) + // 首次/进程未被本 agent 启动过 → 冷启动;已在跑 + 配置变更 → SIGHUP 热重载 + // (Reload 内部在取不到 PID/发信号失败时已自动回退 Restart)。 + s.mu.Lock() + started := s.started + s.mu.Unlock() + if !started { + if err := s.restarter.Restart(ctx); err != nil { + return err + } + s.mu.Lock() + s.started = true + s.mu.Unlock() + return nil + } + return s.restarter.Reload(ctx) } // ensureHy2Cert 为启用 hy2 的节点准备自签证书(幂等),并把 CertPath/KeyPath 写回当前 @@ -396,3 +416,4 @@ func (s *SingBox) Run(ctx context.Context) { type noopRestarter struct{} func (noopRestarter) Restart(context.Context) error { return nil } +func (noopRestarter) Reload(context.Context) error { return nil } diff --git a/server/internal/agentd/singbox_test.go b/server/internal/agentd/singbox_test.go index 209ee9c..e70b33d 100644 --- a/server/internal/agentd/singbox_test.go +++ b/server/internal/agentd/singbox_test.go @@ -15,8 +15,9 @@ import ( // fakeRestarter counts Restart calls. type fakeRestarter struct { - mu sync.Mutex - n int + mu sync.Mutex + n int // Restart 次数(冷启动) + reloads int // Reload 次数(热重载) } func (f *fakeRestarter) Restart(context.Context) error { @@ -26,12 +27,25 @@ func (f *fakeRestarter) Restart(context.Context) error { return nil } +func (f *fakeRestarter) Reload(context.Context) error { + f.mu.Lock() + f.reloads++ + f.mu.Unlock() + return nil +} + func (f *fakeRestarter) count() int { f.mu.Lock() defer f.mu.Unlock() return f.n } +func (f *fakeRestarter) reloadCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.reloads +} + func testConfig(t *testing.T) Config { t.Helper() dir := t.TempDir() @@ -227,3 +241,26 @@ func assertNoIdentityFields(t *testing.T, blob []byte) { } } } + +// 首次渲染走冷启动(Restart),之后的配置变更走 SIGHUP 热重载(Reload)。 +func TestWriteAndRestartFirstColdThenReload(t *testing.T) { + fr := &fakeRestarter{} + sb := NewSingBox(testConfig(t), fr) + sb.ApplyConfig(sampleSnapshot(), true) // 设置 inbounds 使渲染成功 + ctx := context.Background() + + if err := sb.writeAndRestart(ctx); err != nil { + t.Fatalf("first writeAndRestart: %v", err) + } + if r, rl := fr.count(), fr.reloadCount(); r != 1 || rl != 0 { + t.Fatalf("首次后 restarts=%d reloads=%d, 期望 1/0", r, rl) + } + + sb.Upsert(&Cred{DpUUID: "z", Protocol: agentv1.ProtocolBoth}) + if err := sb.writeAndRestart(ctx); err != nil { + t.Fatalf("second writeAndRestart: %v", err) + } + if r, rl := fr.count(), fr.reloadCount(); r != 1 || rl != 1 { + t.Fatalf("变更后 restarts=%d reloads=%d, 期望 1/1(不再 Restart,走 Reload)", r, rl) + } +} diff --git a/server/internal/agentd/util.go b/server/internal/agentd/util.go index d2019cf..2d7e5f9 100644 --- a/server/internal/agentd/util.go +++ b/server/internal/agentd/util.go @@ -7,6 +7,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" + "strings" + "syscall" ) // logf is the agent's structured-ish logger. It deliberately never logs @@ -48,15 +51,44 @@ type SystemdRestarter struct { Unit string // e.g. "sing-box" } -// Restart runs `systemctl restart `. +// Restart runs `systemctl restart ` (cold start: ~1-2s data-plane blip). func (r SystemdRestarter) Restart(ctx context.Context) error { - unit := r.Unit - if unit == "" { - unit = "sing-box" - } + unit := r.unit() cmd := exec.CommandContext(ctx, "systemctl", "restart", unit) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("agentd: restart %s: %w: %s", unit, err, out) } return nil } + +// Reload hot-reloads sing-box via SIGHUP to its MainPID. sing-box validates the +// new config first and keeps the OLD instance running on error (no full +// process restart). agent 与 sing-box 同 pangolin 用户,直接发信号即可(免 +// polkit/不改 unit)。PID 取不到或发信号失败时回退到 Restart(冷启动)。 +func (r SystemdRestarter) Reload(ctx context.Context) error { + unit := r.unit() + pid, err := singboxMainPID(ctx, unit) + if err != nil || pid <= 0 { + return r.Restart(ctx) + } + if err := syscall.Kill(pid, syscall.SIGHUP); err != nil { + return r.Restart(ctx) + } + return nil +} + +func (r SystemdRestarter) unit() string { + if r.Unit == "" { + return "sing-box" + } + return r.Unit +} + +// singboxMainPID 读 systemd 记录的主进程 PID(0 表示未在跑)。 +func singboxMainPID(ctx context.Context, unit string) (int, error) { + out, err := exec.CommandContext(ctx, "systemctl", "show", "-p", "MainPID", "--value", unit).Output() + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(out))) +}