diff --git a/server/cmd/agent/main.go b/server/cmd/agent/main.go index 8b24693..db358d7 100644 --- a/server/cmd/agent/main.go +++ b/server/cmd/agent/main.go @@ -44,6 +44,23 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + // SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP + // 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。 + hup := make(chan os.Signal, 1) + signal.Notify(hup, syscall.SIGHUP) + defer signal.Stop(hup) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-hup: + log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)") + agent.SingBox().Refresh() + } + } + }() + log.Printf("[pangolin-agent] starting (control-plane=%s, version=%s)", cfg.ControlPlaneAddr, cfg.AgentVersion) start := time.Now() if err := agent.Run(ctx); err != nil { diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go index 7100b9a..a974848 100644 --- a/server/internal/agentd/acl_test.go +++ b/server/internal/agentd/acl_test.go @@ -1,12 +1,14 @@ package agentd import ( + "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" + "time" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) @@ -439,6 +441,32 @@ func TestACL_DeletedFileKeepsInMemoryLastGood(t *testing.T) { } } +// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后 +// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。 +func TestSingBoxRefresh_TriggersRender(t *testing.T) { + cfg := testConfig(t) + writeACL(t, cfg.ACLConfigPath, validACL) + fr := &fakeRestarter{} + sb := NewSingBox(cfg, fr) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go sb.Run(ctx) + + // 等首次渲染落地(ApplyConfig 已 markDirty) + eventually(t, 2*time.Second, func() bool { + _, err := os.Stat(cfg.SingboxConfigPath) + return err == nil + }, "首次渲染写出配置") + + // 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染 + // 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。 + before := fr.reloadCount() + sb.Refresh() + eventually(t, 2*time.Second, func() bool { return fr.reloadCount() > before }, "Refresh 触发了热重载") +} + // 新 agent 实例(模拟进程重启) + 被删的 acl.json → 磁盘 last-good 兜底,规则仍在。 func TestACL_ColdStartAfterDeletedFileFallsBackToDisk(t *testing.T) { cfg := testConfig(t) diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index dc734ad..8c185b4 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -446,6 +446,11 @@ func (s *SingBox) markDirty() { } } +// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件 +// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走 +// 冷启动(Restart),把全部在线用户踢下线。 +func (s *SingBox) Refresh() { s.markDirty() } + // Run drives the debounce loop: it coalesces bursts of changes within // DebounceWindow into a single write+restart. It blocks until ctx is cancelled. func (s *SingBox) Run(ctx context.Context) {