diff --git a/docs/private-dest-acl-plan.html b/docs/private-dest-acl-plan.html index 390a184..b4f020e 100644 --- a/docs/private-dest-acl-plan.html +++ b/docs/private-dest-acl-plan.html @@ -718,402 +718,369 @@ func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any { // 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有 // 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id), // 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化, -// 设计与权衡见 docs/private-dest-acl-design.html §12。 +// 设计与权衡见 docs/private-dest-acl-design.html §12。 +
⑤ 修改 server/internal/agentd/singbox.go 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)
+cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v
+Expected: PASS。特别确认既有的 TestRender_NoWarp_NoRouteSection 与 TestRender_Warp_InjectsEndpointAndRoute 仍绿——这两条守着向后兼容。
cd server && go build ./... && go test ./internal/agentd/...
+git add server/internal/agentd/render.go server/internal/agentd/warp.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
+git commit -m "refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP"
+Files:
+server/internal/agentd/singbox.go:49-74(SingBox 增 lastGoodACL 字段)、:308-327(RenderConfig 读 ACL)server/internal/agentd/acl.go(新增 persistACL)server/internal/agentd/acl_test.go(追加 last-good 测试)Interfaces:
+LoadACLConfig / Config.ACLConfigPath / Config.ACLLastGoodPath();Task 3 的 renderSingboxConfig 六参签名;既有包内函数 atomicWrite(path string, data []byte, perm os.FileMode) errorfunc (s *SingBox) loadACL() *ACLConfig;func persistACL(path string, ac *ACLConfig) error追加到 server/internal/agentd/acl_test.go:
// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
+func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
+ cfg := testConfig(t)
+ writeACL(t, cfg.ACLConfigPath, validACL)
+ sb := NewSingBox(cfg, nil)
+ sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
-⑤ 修改 `server/internal/agentd/singbox.go` 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):
-
-return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)
-
-- [ ] **Step 4: 跑测试确认通过**
-
-cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v
-
-Expected: PASS。特别确认既有的 `TestRender_NoWarp_NoRouteSection` 与 `TestRender_Warp_InjectsEndpointAndRoute` 仍绿——这两条守着向后兼容。
+ if _, err := sb.RenderConfig(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
+ t.Fatalf("last-good 未落盘: %v", err)
+ }
+}
-- [ ] **Step 5: 提交**
-
-cd server && go build ./... && go test ./internal/agentd/...
-git add server/internal/agentd/render.go server/internal/agentd/warp.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
-git commit -m "refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP"
-
----
+// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。
+func TestACL_BrokenFileKeepsInMemoryLastGood(t *testing.T) {
+ cfg := testConfig(t)
+ writeACL(t, cfg.ACLConfigPath, validACL)
+ sb := NewSingBox(cfg, nil)
+ sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
+ if _, err := sb.RenderConfig(); err != nil {
+ t.Fatal(err)
+ }
-### Task 4: 渲染时读取 ACL,last-good 内存 + 落盘兜底
+ writeACL(t, cfg.ACLConfigPath, `{"enabled": true,`) // 手抖写坏
+ data, err := sb.RenderConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "reject") {
+ t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
+ }
+}
-**Files:**
-- Modify: `server/internal/agentd/singbox.go:49-74`(`SingBox` 增 `lastGoodACL` 字段)、`:308-327`(`RenderConfig` 读 ACL)
-- Modify: `server/internal/agentd/acl.go`(新增 `persistACL`)
-- Modify: `server/internal/agentd/acl_test.go`(追加 last-good 测试)
+// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。
+func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {
+ cfg := testConfig(t)
+ writeACL(t, cfg.ACLConfigPath, validACL)
+ sb1 := NewSingBox(cfg, nil)
+ sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
+ if _, err := sb1.RenderConfig(); err != nil {
+ t.Fatal(err)
+ }
-**Interfaces:**
-- Consumes: Task 1 的 `LoadACLConfig` / `Config.ACLConfigPath` / `Config.ACLLastGoodPath()`;Task 3 的 `renderSingboxConfig` 六参签名;既有包内函数 `atomicWrite(path string, data []byte, perm os.FileMode) error`
-- Produces: `func (s *SingBox) loadACL() *ACLConfig`;`func persistACL(path string, ac *ACLConfig) error`
+ writeACL(t, cfg.ACLConfigPath, `not json at all`)
+ sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
+ sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
+ data, err := sb2.RenderConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), "reject") {
+ t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")
+ }
+}
-- [ ] **Step 1: 写失败测试**
+// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
+func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
+ cfg := testConfig(t)
+ sb := NewSingBox(cfg, nil)
+ sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
+ data, err := sb.RenderConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(data, &m); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := m["route"]; ok {
+ t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")
+ }
+}
+在 acl_test.go 的 import 块补齐 "encoding/json"、"strings",以及 agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"。
cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+Expected: TestACL_PersistsLastGoodOnLoad 失败(last-good 未落盘),TestACL_BrokenFile... / TestACL_ColdStart... 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。
① 在 server/internal/agentd/acl.go 末尾追加:
// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
+func persistACL(path string, ac *ACLConfig) error {
+ data, err := json.MarshalIndent(ac, "", " ")
+ if err != nil {
+ return fmt.Errorf("agentd: marshal acl snapshot: %w", err)
+ }
+ return atomicWrite(path, data, 0o600)
+}
+② 在 server/internal/agentd/singbox.go 的 SingBox 结构体中,configVersion int64 之后新增字段:
// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
+ // 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
+ lastGoodACL *ACLConfig
+③ 在 server/internal/agentd/singbox.go 的 RenderConfig 中,把 WARP 加载之后的部分替换为:
// WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。
+ // 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。
+ warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)
+ if err != nil {
+ logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
+ warp = nil
+ }
+ return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
+}
-追加到 `server/internal/agentd/acl_test.go`:
-
-// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
-func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
-cfg := testConfig(t)
-writeACL(t, cfg.ACLConfigPath, validACL)
-sb := NewSingBox(cfg, nil)
-sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
-if _, err := sb.RenderConfig(); err != nil {
-t.Fatal(err)
-}
-if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
-t.Fatalf("last-good 未落盘: %v", err)
-}
-}
-// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。
-func TestACL_BrokenFileKeepsInMemoryLastGood(t *testing.T) {
-cfg := testConfig(t)
-writeACL(t, cfg.ACLConfigPath, validACL)
-sb := NewSingBox(cfg, nil)
-sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
-if _, err := sb.RenderConfig(); err != nil {
-t.Fatal(err)
-}
- writeACL(t, cfg.ACLConfigPath, {"enabled": true,) // 手抖写坏
data, err := sb.RenderConfig()
-if err != nil {
-t.Fatal(err)
-}
-if !strings.Contains(string(data), "reject") {
-t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
-}
-}
-// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。
-func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {
-cfg := testConfig(t)
-writeACL(t, cfg.ACLConfigPath, validACL)
-sb1 := NewSingBox(cfg, nil)
-sb1.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
-if _, err := sb1.RenderConfig(); err != nil {
-t.Fatal(err)
-}
- writeACL(t, cfg.ACLConfigPath, not json at all)
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
-sb2.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
-data, err := sb2.RenderConfig()
-if err != nil {
-t.Fatal(err)
-}
-if !strings.Contains(string(data), "reject") {
-t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")
-}
-}
-// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
-func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
-cfg := testConfig(t)
-sb := NewSingBox(cfg, nil)
-sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
-data, err := sb.RenderConfig()
-if err != nil {
-t.Fatal(err)
-}
-var m map[string]any
-if err := json.Unmarshal(data, &m); err != nil {
-t.Fatal(err)
-}
-if _, ok := m["route"]; ok {
-t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")
-}
-}
-
-在 `acl_test.go` 的 import 块补齐 `"encoding/json"`、`"strings"`,以及 `agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"`。
+// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。
+//
+// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也
+// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:
+// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用
+// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失
+// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,
+// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警
+func (s *SingBox) loadACL() *ACLConfig {
+ acl, err := LoadACLConfig(s.cfg.ACLConfigPath)
+ switch {
+ case err == nil && acl != nil:
+ s.mu.Lock()
+ s.lastGoodACL = acl
+ s.mu.Unlock()
+ if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
+ logf("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)
+ }
+ return acl
+ case err != nil:
+ logf("[acl] ERROR load %s failed: %v", s.cfg.ACLConfigPath, err)
+ }
-- [ ] **Step 2: 跑测试确认失败**
-
-cd server && go test ./internal/agentd/ -run 'TestACL_' -v
-
-Expected: `TestACL_PersistsLastGoodOnLoad` 失败(last-good 未落盘),`TestACL_BrokenFile...` / `TestACL_ColdStart...` 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。
+ s.mu.Lock()
+ lg := s.lastGoodACL
+ s.mu.Unlock()
+ if lg != nil {
+ logf("[acl] falling back to in-memory last-good ACL")
+ return lg
+ }
-- [ ] **Step 3: 写实现**
+ disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
+ if derr == nil && disk != nil {
+ logf("[acl] falling back to on-disk last-good %s", s.cfg.ACLLastGoodPath())
+ s.mu.Lock()
+ s.lastGoodACL = disk
+ s.mu.Unlock()
+ return disk
+ }
-① 在 `server/internal/agentd/acl.go` 末尾追加:
-
-// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
-func persistACL(path string, ac *ACLConfig) error {
-data, err := json.MarshalIndent(ac, "", " ")
-if err != nil {
-return fmt.Errorf("agentd: marshal acl snapshot: %w", err)
-}
-return atomicWrite(path, data, 0o600)
-}
-
-② 在 `server/internal/agentd/singbox.go` 的 `SingBox` 结构体中,`configVersion int64` 之后新增字段:
-
-// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
-// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
-lastGoodACL *ACLConfig
-
-③ 在 `server/internal/agentd/singbox.go` 的 `RenderConfig` 中,把 WARP 加载之后的部分替换为:
-
-// WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。
-// 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。
-warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)
-if err != nil {
-logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
-warp = nil
-}
-return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
-}
-// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。
-//
-// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也
-// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:
-// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用
-// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失
-// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,
-// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警
-func (s *SingBox) loadACL() *ACLConfig {
-acl, err := LoadACLConfig(s.cfg.ACLConfigPath)
-switch {
-case err == nil && acl != nil:
-s.mu.Lock()
-s.lastGoodACL = acl
-s.mu.Unlock()
-if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
-logf("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)
-}
-return acl
-case err != nil:
-logf("[acl] ERROR load %s failed: %v", s.cfg.ACLConfigPath, err)
-}
-s.mu.Lock()
-lg := s.lastGoodACL
-s.mu.Unlock()
-if lg != nil {
-logf("[acl] falling back to in-memory last-good ACL")
-return lg
-}
-disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
-if derr == nil && disk != nil {
-logf("[acl] falling back to on-disk last-good %s", s.cfg.ACLLastGoodPath())
-s.mu.Lock()
-s.lastGoodACL = disk
-s.mu.Unlock()
-return disk
-}
-if err != nil {
-logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+
-"private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)
-}
-return nil
-}
-
-- [ ] **Step 4: 跑测试确认通过**
-
-cd server && go test ./internal/agentd/ -run 'TestACL_' -v
-
-Expected: PASS,四条全绿。
+ if err != nil {
+ logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+
+ "private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)
+ }
+ return nil
+}
+cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+Expected: PASS,四条全绿。
+cd server && go build ./... && go test ./internal/agentd/...
+git add server/internal/agentd/acl.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
+git commit -m "feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启"
+Files:
+server/internal/agentd/singbox.go(新增导出的 Refresh())server/cmd/agent/main.go:44-45(注册 SIGHUP 处理)server/internal/agentd/acl_test.go(追加 Refresh 测试)Interfaces:
+(*SingBox).markDirty()(未导出)、(*Agent).SingBox() *SingBox(agent.go:83,已导出)func (s *SingBox) Refresh() —— 外部触发一次重渲染为什么不用 systemctl restart pangolin-agent: 新进程内 s.started == false(singbox.go:64-67),首次渲染会走 restarter.Restart() 即 systemctl restart sing-box,全部在线用户瞬断。SIGHUP 走的是既有的 debounce → writeAndRestart → Reload()(SIGHUP 热重载)路径,不断线。
追加到 server/internal/agentd/acl_test.go:
// 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)
-- [ ] **Step 5: 提交**
-
-cd server && go build ./... && go test ./internal/agentd/...
-git add server/internal/agentd/acl.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
-git commit -m "feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启"
-
----
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go sb.Run(ctx)
-### Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户)
+ // 等首次渲染落地(ApplyConfig 已 markDirty)
+ eventually(t, 2*time.Second, func() bool {
+ _, err := os.Stat(cfg.SingboxConfigPath)
+ return err == nil
+ }, "首次渲染写出配置")
-**Files:**
-- Modify: `server/internal/agentd/singbox.go`(新增导出的 `Refresh()`)
-- Modify: `server/cmd/agent/main.go:44-45`(注册 SIGHUP 处理)
-- Modify: `server/internal/agentd/acl_test.go`(追加 `Refresh` 测试)
+ // 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染
+ // 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。
+ before := fr.reloadCount()
+ sb.Refresh()
+ eventually(t, 2*time.Second, func() bool { return fr.reloadCount() > before }, "Refresh 触发了热重载")
+}
+在 acl_test.go 的 import 块补齐 "context" 与 "time"。
+复用
singbox_test.go:17-47既有的fakeRestarter(count()返回 Restart 次数、reloadCount()返回 Reload 次数)与integration_test.go:248的eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)。
cd server && go test ./internal/agentd/ -run 'Refresh' -v
+Expected: 编译失败,sb.Refresh undefined。
① 在 server/internal/agentd/singbox.go 的 markDirty 之后追加:
// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
+// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
+// 冷启动(Restart),把全部在线用户踢下线。
+func (s *SingBox) Refresh() { s.markDirty() }
+② 修改 server/cmd/agent/main.go,把第 44–45 行替换为:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
-**Interfaces:**
-- Consumes: 既有 `(*SingBox).markDirty()`(未导出)、`(*Agent).SingBox() *SingBox`(`agent.go:83`,已导出)
-- Produces: `func (s *SingBox) Refresh()` —— 外部触发一次重渲染
-
-**为什么不用 `systemctl restart pangolin-agent`:** 新进程内 `s.started == false`(`singbox.go:64-67`),首次渲染会走 `restarter.Restart()` 即 `systemctl restart sing-box`,**全部在线用户瞬断**。SIGHUP 走的是既有的 debounce → `writeAndRestart` → `Reload()`(SIGHUP 热重载)路径,不断线。
-
-- [ ] **Step 1: 写失败测试**
-
-追加到 `server/internal/agentd/acl_test.go`:
-
-// 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 触发了热重载")
-}
-
-在 `acl_test.go` 的 import 块补齐 `"context"` 与 `"time"`。
-
-> 复用 `singbox_test.go:17-47` 既有的 `fakeRestarter`(`count()` 返回 Restart 次数、`reloadCount()` 返回 Reload 次数)与 `integration_test.go:248` 的 `eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)`。
-
-- [ ] **Step 2: 跑测试确认失败**
-
-cd server && go test ./internal/agentd/ -run 'Refresh' -v
-
-Expected: 编译失败,`sb.Refresh undefined`。
-
-- [ ] **Step 3: 写实现**
-
-① 在 `server/internal/agentd/singbox.go` 的 `markDirty` 之后追加:
-
-// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
-// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
-// 冷启动(Restart),把全部在线用户踢下线。
-func (s *SingBox) Refresh() { s.markDirty() }
-
-② 修改 `server/cmd/agent/main.go`,把第 44–45 行替换为:
-
-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()
-}
-}
-}()
-
-`os` 与 `log` 已在该文件 import 中(`os` 见 `env()` 用到的 `os.LookupEnv`);若 `go build` 报缺失,按提示补齐。
-
-- [ ] **Step 4: 跑测试确认通过**
-
-cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v
-
-Expected: PASS。
-
-- [ ] **Step 5: 提交**
-
-cd server && go build ./... && go test ./internal/agentd/...
-git add server/internal/agentd/singbox.go server/internal/agentd/acl_test.go server/cmd/agent/main.go
-git commit -m "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户"
-
----
-
-### Task 6: 节点配置样例与运维文档
-
-**Files:**
-- Create: `deploy/single-node/acl.json.example`
-- Modify: `docs/private-dest-acl-design.html`(把 §8 runbook 里的占位符换成指向样例文件)
-
-**Interfaces:**
-- Consumes: Task 1 定义的 `ACLConfig` JSON 形状
-- Produces: 可直接复制到 `/etc/pangolin-agent/acl.json` 的样例
-
-- [ ] **Step 1: 写样例文件**
-
-创建 `deploy/single-node/acl.json.example`:
-
-{
-"_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",
-"enabled": true,
-"allow_dp_uuids": [
-"TODO-设备级-dp-uuid",
-"TODO-账户级-dp-uuid-供-sub-订阅链接用"
-],
-"targets": [
-{
-"_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",
-"domain": ["brain.51yanmei.com", "git.51yanmei.com"]
-},
-{
-"_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",
-"ip_cidr": ["182.92.213.171/32"],
-"port": [5001, 3389, 10022, 10023]
-}
-]
-}
-
-> `_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),可安全保留作自文档。样例里的 `TODO-` 值是**样例文件**的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
-
-- [ ] **Step 2: 验证样例能被解析且渲染出预期规则**
-
-cd server && cat > /tmp/acl_example_check.go <<'EOF'
-package main
-import (
-"fmt"
-"os"
-"github.com/wangjia/pangolin/server/internal/agentd"
-)
-func main() {
-ac, err := agentd.LoadACLConfig(os.Args[1])
-if err != nil {
-fmt.Println("FAIL:", err)
-os.Exit(1)
-}
-fmt.Printf("OK enabled=%v uuids=%d targets=%d\n", ac.Enabled, len(ac.AllowDpUUIDs), len(ac.Targets))
-}
-EOF
-go run /tmp/acl_example_check.go ../deploy/single-node/acl.json.example && rm /tmp/acl_example_check.go
-
-Expected: `OK enabled=true uuids=2 targets=2`
-
-- [ ] **Step 3: 更新设计文档的 runbook 指向**
-
-在 `docs/private-dest-acl-design.html` 的 §8 首段(`<h2>8. 名单维护 Runbook</h2>` 之后的第一个 `<p>`)末尾追加一句:
-
-样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。
-
-- [ ] **Step 4: 提交**
-
-cd server && go build ./... && go test ./internal/agentd/...
-git add deploy/single-node/acl.json.example docs/private-dest-acl-design.html
-git commit -m "docs(agent): 私有目的地 ACL 配置样例与 runbook 指向"
-
----
-
-## 上线验收(人工,非代码任务)
-
-代码合并后在 pangolin1 上执行。**先备份现网 sing-box 配置**:`sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)`(此处 `$(...)` 在服务器交互 shell 中手动执行,不经 agent)。
-
-1. 取白名单 uuid(`UNION` 那一半是账户级 dp_uuid,供 `/sub` 订阅链接用,**漏了会把自己锁在外面**):
- ```sql
- sqlite3 /var/lib/pangolin/pangolin.db \
- "SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id
- WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL
- UNION
- SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"
- ```
-2. 写 `/etc/pangolin-agent/acl.json`(权限 `0600`,属主 `pangolin`),`systemctl reload pangolin-agent`。
-3. `journalctl -u pangolin-agent -n 30` 确认已重渲染、无 `[acl] ERROR` / `[acl] ALERT`。
-4. `sudo python3 -c "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])"` 确认规则顺序为 sniff → 放行 → 拒绝 → warp。
-5. **我的设备**:brain 首页 200、DSM 5001 可登录、`ssh nas-r` 通。
-6. **另一账号的设备**:以上全部被拒(连接被 reject,不是超时)。
-7. **公开站不受影响**:jiu / travel / sudoku / pay 在两个账号下均正常。
-8. **WARP 未被破坏**:reddit 仍走 warp 出口。
-9. **fail-closed 实证**:把 `acl.json` 改坏 → `systemctl reload pangolin-agent` → 规则仍在、日志有 ERROR;恢复文件。
-10. **不断线实证**:reload 期间另一台设备保持连接不掉。
-
-## 不在本计划范围内
-
-- 给 brain 加 mTLS / basic auth 作纵深防御(与本计划正交,约一小时,另开)
-- 补 `assertNoIdentityFields` 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复)
-- 控制面下发 ACL(演进路径见设计文档 §11,届时渲染层可原样保留)
-
+ // 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()
+ }
+ }
+ }()
+os 与 log 已在该文件 import 中(os 见 env() 用到的 os.LookupEnv);若 go build 报缺失,按提示补齐。
cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v
+Expected: PASS。
+cd server && go build ./... && go test ./internal/agentd/...
+git add server/internal/agentd/singbox.go server/internal/agentd/acl_test.go server/cmd/agent/main.go
+git commit -m "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户"
+Files:
+deploy/single-node/acl.json.exampleserver/internal/agentd/acl_test.go(追加样例文件常驻校验测试)docs/private-dest-acl-design.html(把 §8 runbook 里的占位符换成指向样例文件)Interfaces:
+ACLConfig JSON 形状/etc/pangolin-agent/acl.json 的样例创建 deploy/single-node/acl.json.example:
{
+ "_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",
+ "enabled": true,
+ "allow_dp_uuids": [
+ "TODO-设备级-dp-uuid",
+ "TODO-账户级-dp-uuid-供-sub-订阅链接用"
+ ],
+ "targets": [
+ {
+ "_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",
+ "domain": ["brain.51yanmei.com", "git.51yanmei.com"]
+ },
+ {
+ "_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",
+ "ip_cidr": ["182.92.213.171/32"],
+ "port": [5001, 3389, 10022, 10023]
+ }
+ ]
+}
++
_comment字段会被encoding/json忽略(ACLConfig无对应字段且未启用DisallowUnknownFields),可安全保留作自文档。样例里的TODO-值是样例文件的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 go run 检查一次没有约束力。改为一条常驻测试。追加到 server/internal/agentd/acl_test.go:
// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
+func TestACLExampleFileStaysValid(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "deploy", "single-node", "acl.json.example")
+ ac, err := LoadACLConfig(path)
+ if err != nil {
+ t.Fatalf("样例文件解析失败 %s: %v", path, err)
+ }
+ if ac == nil {
+ t.Fatalf("样例文件不存在: %s", path)
+ }
+ if !ac.active() {
+ t.Error("样例文件应当是一份 active 的 ACL")
+ }
+ if len(ac.Targets) != 2 {
+ t.Errorf("样例 targets = %d, want 2", len(ac.Targets))
+ }
+ // 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
+ for _, u := range ac.AllowDpUUIDs {
+ if !strings.HasPrefix(u, "TODO-") {
+ t.Errorf("样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git", u)
+ }
+ }
+}
+跑:
+cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -v
+Expected: PASS。
++注:
_comment字段会被encoding/json忽略(ACLConfig无对应字段且未启用DisallowUnknownFields),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。
在 docs/private-dest-acl-design.html 的 §8 首段(<h2>8. 名单维护 Runbook</h2> 之后的第一个 <p>)末尾追加一句:
样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。
+cd server && go build ./... && go test ./internal/agentd/...
+git add deploy/single-node/acl.json.example server/internal/agentd/acl_test.go docs/private-dest-acl-design.html
+git commit -m "docs(agent): 私有目的地 ACL 配置样例与常驻校验测试"
++
git add只列具体路径,切勿git add -A:仓库里有未跟踪的pangolin-release.jks(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。
代码合并后在 pangolin1 上执行。先备份现网 sing-box 配置:sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)(此处 $(...) 在服务器交互 shell 中手动执行,不经 agent)。
UNION 那一半是账户级 dp_uuid,供 /sub 订阅链接用,漏了会把自己锁在外面):```sql
+sqlite3 /var/lib/pangolin/pangolin.db \
+"SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id
+WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL
+UNION
+SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"
+```
+/etc/pangolin-agent/acl.json(权限 0600,属主 pangolin),systemctl reload pangolin-agent。journalctl -u pangolin-agent -n 30 确认已重渲染、无 [acl] ERROR / [acl] ALERT。sudo python3 -c "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])" 确认规则顺序为 sniff → 放行 → 拒绝 → warp。ssh nas-r 通。acl.json 改坏 → systemctl reload pangolin-agent → 规则仍在、日志有 ERROR;恢复文件。assertNoIdentityFields 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复)