# 私有目的地访问控制(节点侧 ACL)Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在节点 sing-box 上加一道按 dp_uuid 的闸,使家庭内网服务(brain/nas/git/win.51yanmei.com)只对白名单凭证开放,而不是对所有 pangolin 用户开放。 **Architecture:** 节点本地 `acl.json`(照 `warp.json` 骨架)声明「白名单 dp_uuid + 私有目的地清单」;agent 每次渲染 sing-box 配置时读取,为每个目的地产出一对 route 规则——先放行白名单、再兜底拒绝。ACL 规则与 WARP 规则由新的 `buildRoute()` 合并进同一个 route 块(现状是 WARP 整块覆盖 `cfg["route"]`,直接赋值会把对方干掉)。控制面、数据库、proto、客户端、管理后台一律不动。 **Tech Stack:** Go 1.2x,标准库;sing-box 1.13.13 配置语义;测试为同包 `*_test.go` + `t.TempDir()`,`go test ./internal/agentd/...`。 ## Global Constraints - 设计真相源:`docs/private-dest-acl-design.html`。本计划的任何取舍以该文档为准。 - **改动收敛在 `server/internal/agentd/` + `server/cmd/agent/main.go`**。不得新增 migration、不得改 proto、不得改控制面(`internal/httpapi`、`internal/nodes`)、不得改客户端。 - **失效方向必须 fail-closed**,与 `warp.go` 的 fail-open 先例相反。具体:读失败保留 last-good;`allow_dp_uuids: []` 仍产出拒绝规则;仅 `"enabled": false` 能关闭。 - **route 规则顺序硬约束**:`{"action":"sniff"}` 唯一且最先 → ACL 放行 → ACL 拒绝 → WARP 分流。 - **放行与拒绝的目的地条件必须逐字相同**,且全部放行规则排在全部拒绝规则之前(目的地可能重叠,交错会漏)。 - 向后兼容:ACL 与 WARP 均未启用时,渲染结果必须与改动前**逐字节一致**(现有测试 `TestRender_NoWarp_NoRouteSection` 守这条)。 - 每个 Task 结束必须 `cd server && go build ./... && go test ./internal/agentd/...` 全绿再提交。 - Commit message 前缀用 `feat(agent):` / `test(agent):` / `refactor(agent):`。 --- ### Task 1: ACL 配置类型、加载与 fail-closed 的 active() **Files:** - Create: `server/internal/agentd/acl.go` - Create: `server/internal/agentd/acl_test.go` - Modify: `server/internal/agentd/config.go`(新增 `ACLConfigPath` 字段、`withDefaults` 分支、`ACLLastGoodPath()`,并订正包注释) **Interfaces:** - Produces: `type ACLTarget struct{ Domain, DomainSuffix, IPCIDR []string; Port []int }`;`type ACLConfig struct{ Enabled bool; AllowDpUUIDs []string; Targets []ACLTarget }`;`func LoadACLConfig(path string) (*ACLConfig, error)`;`func (ac *ACLConfig) active() bool`;`func (ac *ACLConfig) cleanUUIDs() []string`;`func (ac *ACLConfig) cleanTargets() []ACLTarget`;`func (t ACLTarget) empty() bool`;`func (t ACLTarget) matchFields() map[string]any`;`func (c Config) ACLLastGoodPath() string` - Consumes: 无(本任务是叶子) - [ ] **Step 1: 写失败测试** 创建 `server/internal/agentd/acl_test.go`: ```go package agentd import ( "os" "path/filepath" "testing" ) // writeACL 把 acl.json 写到指定路径。 func writeACL(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } } const validACL = `{ "enabled": true, "allow_dp_uuids": ["uuid-me-1", "uuid-me-sub"], "targets": [ { "domain": ["brain.51yanmei.com", "git.51yanmei.com"] }, { "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] } ] }` func TestLoadACLConfig(t *testing.T) { dir := t.TempDir() t.Run("文件不存在返回 nil,nil(未配置,不是错误)", func(t *testing.T) { ac, err := LoadACLConfig(filepath.Join(dir, "missing.json")) if err != nil { t.Fatalf("want nil error, got %v", err) } if ac != nil { t.Fatalf("want nil config, got %+v", ac) } }) t.Run("坏 JSON 返回 error(绝不静默降级)", func(t *testing.T) { p := filepath.Join(dir, "bad.json") writeACL(t, p, `{"enabled": true,`) if _, err := LoadACLConfig(p); err == nil { t.Fatal("want error for malformed JSON, got nil") } }) t.Run("合法配置解析出全部字段", func(t *testing.T) { p := filepath.Join(dir, "acl.json") writeACL(t, p, validACL) ac, err := LoadACLConfig(p) if err != nil { t.Fatal(err) } if !ac.Enabled { t.Error("Enabled = false, want true") } if len(ac.AllowDpUUIDs) != 2 { t.Errorf("AllowDpUUIDs len = %d, want 2", len(ac.AllowDpUUIDs)) } if len(ac.Targets) != 2 { t.Fatalf("Targets len = %d, want 2", len(ac.Targets)) } if len(ac.Targets[0].Domain) != 2 { t.Errorf("Targets[0].Domain len = %d, want 2", len(ac.Targets[0].Domain)) } if len(ac.Targets[1].Port) != 4 { t.Errorf("Targets[1].Port len = %d, want 4", len(ac.Targets[1].Port)) } }) } // active() 的语义与 WARP 相反:空白名单不等于「关闭」,而等于「谁都不许进」。 func TestACLActive_FailClosed(t *testing.T) { cases := []struct { name string ac *ACLConfig want bool }{ {"nil 配置 → 未启用", nil, false}, {"enabled=false → 未启用(唯一的合法关闭途径)", &ACLConfig{ Enabled: false, AllowDpUUIDs: []string{"u"}, Targets: []ACLTarget{{Domain: []string{"a.com"}}}, }, false}, {"无 target → 未启用(无从拒起)", &ACLConfig{ Enabled: true, AllowDpUUIDs: []string{"u"}, }, false}, {"target 全为空条件 → 未启用", &ACLConfig{ Enabled: true, Targets: []ACLTarget{{}}, }, false}, {"白名单为空但有 target → 仍启用(拒绝所有人)", &ACLConfig{ Enabled: true, AllowDpUUIDs: nil, Targets: []ACLTarget{{Domain: []string{"a.com"}}}, }, true}, {"完整配置 → 启用", &ACLConfig{ Enabled: true, AllowDpUUIDs: []string{"u"}, Targets: []ACLTarget{{IPCIDR: []string{"1.2.3.4/32"}, Port: []int{443}}}, }, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tc.ac.active(); got != tc.want { t.Errorf("active() = %v, want %v", got, tc.want) } }) } } func TestACLCleanHelpers(t *testing.T) { ac := &ACLConfig{ Enabled: true, AllowDpUUIDs: []string{" uuid-a ", "", "uuid-b"}, Targets: []ACLTarget{ {Domain: []string{" BRAIN.51yanmei.com ", ""}}, {}, {IPCIDR: []string{"1.2.3.4/32"}}, }, } uuids := ac.cleanUUIDs() if len(uuids) != 2 || uuids[0] != "uuid-a" || uuids[1] != "uuid-b" { t.Errorf("cleanUUIDs() = %v, want [uuid-a uuid-b]", uuids) } targets := ac.cleanTargets() if len(targets) != 2 { t.Fatalf("cleanTargets() len = %d, want 2 (空 target 应被丢弃)", len(targets)) } if targets[0].Domain[0] != "brain.51yanmei.com" { t.Errorf("域名未规范化为小写去空白: %q", targets[0].Domain[0]) } } func TestACLTargetMatchFields(t *testing.T) { tgt := ACLTarget{ Domain: []string{"a.com"}, IPCIDR: []string{"1.2.3.4/32"}, Port: []int{443, 5001}, } m := tgt.matchFields() if _, ok := m["domain"]; !ok { t.Error("缺 domain 字段") } if _, ok := m["ip_cidr"]; !ok { t.Error("缺 ip_cidr 字段") } if _, ok := m["port"]; !ok { t.Error("缺 port 字段") } if _, ok := m["domain_suffix"]; ok { t.Error("空的 domain_suffix 不应出现在输出里") } // matchFields 必须每次返回新 map,否则放行/拒绝两条规则会共享同一对象, // 给其中一条加 "user"/"action" 会污染另一条。 m2 := tgt.matchFields() m2["user"] = []string{"x"} if _, ok := m["user"]; ok { t.Error("matchFields 返回了共享 map,放行与拒绝规则会互相污染") } } func TestConfigACLPaths(t *testing.T) { c := Config{StateDir: "/etc/pangolin-agent"}.withDefaults() if want := "/etc/pangolin-agent/acl.json"; c.ACLConfigPath != want { t.Errorf("ACLConfigPath = %q, want %q", c.ACLConfigPath, want) } if want := "/etc/pangolin-agent/acl.last-good.json"; c.ACLLastGoodPath() != want { t.Errorf("ACLLastGoodPath() = %q, want %q", c.ACLLastGoodPath(), want) } } ``` - [ ] **Step 2: 跑测试确认失败** ```bash cd server && go test ./internal/agentd/ -run 'ACL' -v ``` Expected: 编译失败,`undefined: LoadACLConfig` / `undefined: ACLConfig` / `c.ACLConfigPath undefined`。 - [ ] **Step 3: 写实现** 创建 `server/internal/agentd/acl.go`: ```go package agentd import ( "encoding/json" "fmt" "os" "strings" ) // ACLTarget 描述一组「私有目的地」的匹配条件。字段名与取值直接对应 sing-box // route rule 的同名字段:同一项内多字段是 AND,字段内多值是 OR。刻意不做自研 DSL // —— 形状即 sing-box 语义,少一层翻译就少一类 bug。 // // 典型两类: // - 与公开站共用 443 的私有 vhost(brain/git) → 用 domain,依赖 sniff 取 SNI // - 独占端口的服务(DSM 5001 / RDP 3389 / SSH 10022-10023) → 用 ip_cidr + port type ACLTarget struct { Domain []string `json:"domain,omitempty"` DomainSuffix []string `json:"domain_suffix,omitempty"` IPCIDR []string `json:"ip_cidr,omitempty"` Port []int `json:"port,omitempty"` } // ACLConfig 是节点本地的私有目的地访问控制表(默认 /acl.json)。 // 只有 AllowDpUUIDs 里的凭证能访问 Targets 描述的目的地,其余一律 reject。 // // 与 WarpConfig 的关键区别是失效方向:WARP 读不出来就不分流(fail-open)是安全的, // ACL 读不出来就不拦截等于把私有服务对全体用户敞开。故本类型的 active() 语义为 // fail-closed —— 空白名单意味着「没有人」,不是「所有人」。 type ACLConfig struct { Enabled bool `json:"enabled"` AllowDpUUIDs []string `json:"allow_dp_uuids"` Targets []ACLTarget `json:"targets"` } // LoadACLConfig 读取并解析 acl.json。文件不存在 → (nil, nil)(未配置该功能, // 不是错误)。解析失败返回 error,由调用方决定回退到 last-good 还是告警。 func LoadACLConfig(path string) (*ACLConfig, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("agentd: read acl config %q: %w", path, err) } var ac ACLConfig if err := json.Unmarshal(data, &ac); err != nil { return nil, fmt.Errorf("agentd: parse acl config %q: %w", path, err) } return &ac, nil } // empty 报告该 target 是否没有任何匹配条件(没有条件的规则会匹配一切,危险)。 func (t ACLTarget) empty() bool { return len(t.Domain) == 0 && len(t.DomainSuffix) == 0 && len(t.IPCIDR) == 0 && len(t.Port) == 0 } // matchFields 把 target 转成 sing-box route rule 的匹配字段。 // 每次调用返回全新 map —— 放行与拒绝两条规则各自在其上追加 user/outbound/action, // 共享同一对象会互相污染。 func (t ACLTarget) matchFields() map[string]any { m := make(map[string]any, 4) if len(t.Domain) > 0 { m["domain"] = t.Domain } if len(t.DomainSuffix) > 0 { m["domain_suffix"] = t.DomainSuffix } if len(t.IPCIDR) > 0 { m["ip_cidr"] = t.IPCIDR } if len(t.Port) > 0 { m["port"] = t.Port } return m } // active 报告本 ACL 是否应真正注入规则。 // // 注意与 WarpConfig.active() 的语义差别:此处 AllowDpUUIDs 为空**不影响**返回值。 // 空白名单是一个合法且有意义的状态 ——「谁都不许访问这些目的地」。把它当作未启用 // 会造成 fail-open。唯一的关闭途径是显式 "enabled": false。 func (ac *ACLConfig) active() bool { if ac == nil || !ac.Enabled { return false } return len(ac.cleanTargets()) > 0 } // cleanUUIDs 去空白/空项后返回白名单。 func (ac *ACLConfig) cleanUUIDs() []string { if ac == nil { return nil } out := make([]string, 0, len(ac.AllowDpUUIDs)) for _, u := range ac.AllowDpUUIDs { if u = strings.TrimSpace(u); u != "" { out = append(out, u) } } return out } // cleanTargets 规范化域名(小写去空白)并丢弃无任何条件的 target。 func (ac *ACLConfig) cleanTargets() []ACLTarget { if ac == nil { return nil } out := make([]ACLTarget, 0, len(ac.Targets)) for _, t := range ac.Targets { c := ACLTarget{ Domain: cleanHosts(t.Domain), DomainSuffix: cleanHosts(t.DomainSuffix), IPCIDR: cleanStrings(t.IPCIDR), Port: t.Port, } if !c.empty() { out = append(out, c) } } return out } func cleanHosts(in []string) []string { out := make([]string, 0, len(in)) for _, s := range in { if s = strings.TrimSpace(strings.ToLower(s)); s != "" { out = append(out, s) } } return out } func cleanStrings(in []string) []string { out := make([]string, 0, len(in)) for _, s := range in { if s = strings.TrimSpace(s); s != "" { out = append(out, s) } } return out } ``` 修改 `server/internal/agentd/config.go`: ① 包注释订正(原文声称 agent 保有零目的地数据,ACL 打破了这条,必须如实记录)。把第 7–10 行的 no-state invariant 段落改为: ```go // No-state invariant (doc/04 §2, doc/06 §3): the agent persists ONLY the // credential table (dp_uuid + expires_at) to disk. It keeps zero user identities // and writes no access logs. A seized node leaks only opaque dp_uuids, never // accounts. // // 例外(私有目的地 ACL):节点本地 acl.json 含一份 dp_uuid 白名单与目的地清单, // 由运营手工维护、不经控制面。它确实让节点知道「这几个 dp_uuid 享有私有访问权」 // 以及那几个私有域名/端口 —— 这是知情接受的不变式弱化,范围仅限该文件与渲染出的 // route 规则,不涉及账户身份,也不产生任何访问日志。设计见 // docs/private-dest-acl-design.html §12。 ``` ② 在 `WarpConfigPath` 字段之后新增: ```go // ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 /acl.json)。 // 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。 ACLConfigPath string ``` ③ 在 `withDefaults()` 里 `WarpConfigPath` 分支之后新增: ```go if c.ACLConfigPath == "" { c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json") } ``` ④ 在文件末尾路径方法区新增: ```go // ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json // 损坏的情况下兜底(fail-closed 跨重启成立的前提)。 func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") } ``` - [ ] **Step 4: 跑测试确认通过** ```bash cd server && go test ./internal/agentd/ -run 'ACL|ConfigACLPaths' -v ``` Expected: PASS,全部子测试通过。 - [ ] **Step 5: 全包回归 + 提交** ```bash cd server && go build ./... && go test ./internal/agentd/... git add server/internal/agentd/acl.go server/internal/agentd/acl_test.go server/internal/agentd/config.go git commit -m "feat(agent): 私有目的地 ACL 配置类型与 fail-closed 加载语义" ``` --- ### Task 2: 由 ACL 产出 route 规则对(放行 + 兜底拒绝) **Files:** - Modify: `server/internal/agentd/acl.go`(新增 `rules()`) - Modify: `server/internal/agentd/acl_test.go`(追加测试) **Interfaces:** - Consumes: Task 1 的 `ACLConfig.active()` / `cleanUUIDs()` / `cleanTargets()` / `ACLTarget.matchFields()` - Produces: `func (ac *ACLConfig) rules() []any` —— 返回按「全部放行规则 → 全部拒绝规则」排序的 sing-box route 规则切片;未 active 时返回 nil - [ ] **Step 1: 写失败测试** 追加到 `server/internal/agentd/acl_test.go`: ```go // rules() 必须产出「先全部放行、再全部拒绝」,且同一 target 两侧目的地条件逐字相同。 func TestACLRules_AllowThenDeny(t *testing.T) { ac := &ACLConfig{ Enabled: true, AllowDpUUIDs: []string{"uuid-me"}, Targets: []ACLTarget{ {Domain: []string{"brain.51yanmei.com"}}, {IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}}, }, } rules := ac.rules() if len(rules) != 4 { t.Fatalf("规则数 = %d, want 4 (2 target × 放行+拒绝)", len(rules)) } // 前两条是放行:带 user + outbound,不带 action for i := 0; i < 2; i++ { r := rules[i].(map[string]any) if _, ok := r["user"]; !ok { t.Errorf("rules[%d] 放行规则缺 user", i) } if r["outbound"] != directOutboundTag { t.Errorf("rules[%d] outbound = %v, want %q", i, r["outbound"], directOutboundTag) } if _, ok := r["action"]; ok { t.Errorf("rules[%d] 放行规则不应带 action", i) } } // 后两条是拒绝:带 action=reject,不带 user(对所有人生效) for i := 2; i < 4; i++ { r := rules[i].(map[string]any) if r["action"] != "reject" { t.Errorf("rules[%d] action = %v, want reject", i, r["action"]) } if _, ok := r["user"]; ok { t.Errorf("rules[%d] 拒绝规则不应带 user,否则会漏掉名单外的人", i) } } // 对称性:target[0] 的放行(rules[0])与拒绝(rules[2])目的地条件必须逐字相同 allow0 := rules[0].(map[string]any) deny0 := rules[2].(map[string]any) if fmt.Sprint(allow0["domain"]) != fmt.Sprint(deny0["domain"]) { t.Errorf("target0 放行/拒绝的 domain 不一致: %v vs %v", allow0["domain"], deny0["domain"]) } allow1 := rules[1].(map[string]any) deny1 := rules[3].(map[string]any) if fmt.Sprint(allow1["ip_cidr"]) != fmt.Sprint(deny1["ip_cidr"]) || fmt.Sprint(allow1["port"]) != fmt.Sprint(deny1["port"]) { t.Error("target1 放行/拒绝的 ip_cidr/port 不一致") } } // 空白名单 → 不产出放行规则,但拒绝规则照出(fail-closed 的核心断言)。 func TestACLRules_EmptyAllowlistStillDenies(t *testing.T) { ac := &ACLConfig{ Enabled: true, AllowDpUUIDs: nil, Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}}, } rules := ac.rules() if len(rules) != 1 { t.Fatalf("规则数 = %d, want 1 (仅拒绝)", len(rules)) } r := rules[0].(map[string]any) if r["action"] != "reject" { t.Errorf("action = %v, want reject", r["action"]) } } // 未 active(含 nil / enabled=false)→ 无规则。 func TestACLRules_InactiveYieldsNil(t *testing.T) { var nilACL *ACLConfig if got := nilACL.rules(); got != nil { t.Errorf("nil ACL rules() = %v, want nil", got) } off := &ACLConfig{Enabled: false, Targets: []ACLTarget{{Domain: []string{"a.com"}}}} if got := off.rules(); got != nil { t.Errorf("enabled=false rules() = %v, want nil", got) } } ``` 在 `acl_test.go` 的 import 块加入 `"fmt"`。 - [ ] **Step 2: 跑测试确认失败** ```bash cd server && go test ./internal/agentd/ -run 'ACLRules' -v ``` Expected: 编译失败,`ac.rules undefined (type *ACLConfig has no field or method rules)`。 - [ ] **Step 3: 写实现** 在 `server/internal/agentd/acl.go` 末尾追加: ```go // rules 产出 ACL 的 sing-box route 规则:每个 target 一对 —— 先放行白名单、再兜底拒绝。 // // 顺序是安全性的一部分,不可重排: // 1. 全部放行规则排在全部拒绝规则之前。不能按 target 交错(放行A/拒绝A/放行B/拒绝B), // 因为 target 之间可能重叠,交错会让 B 的成员被 A 的拒绝规则先命中。 // 2. 拒绝规则不带 user 维度 —— 它要对「白名单之外的所有人」生效。 // 3. 同一 target 的放行与拒绝,目的地条件由同一个 matchFields() 生成,保证逐字相同。 // 任何不对称都会造成「我自己也被拒」或「有人漏网」。 // // 白名单为空时只产出拒绝规则(谁都不许进),这是 fail-closed 的核心:空名单的语义是 // 「没有人」而非「所有人」。 func (ac *ACLConfig) rules() []any { if !ac.active() { return nil } uuids := ac.cleanUUIDs() targets := ac.cleanTargets() out := make([]any, 0, len(targets)*2) if len(uuids) > 0 { for _, t := range targets { r := t.matchFields() r["user"] = uuids r["outbound"] = directOutboundTag out = append(out, r) } } for _, t := range targets { r := t.matchFields() r["action"] = "reject" out = append(out, r) } return out } ``` - [ ] **Step 4: 跑测试确认通过** ```bash cd server && go test ./internal/agentd/ -run 'ACL' -v ``` Expected: PASS。 - [ ] **Step 5: 提交** ```bash cd server && go build ./... && go test ./internal/agentd/... git add server/internal/agentd/acl.go server/internal/agentd/acl_test.go git commit -m "feat(agent): ACL 渲染放行/拒绝规则对,空白名单仍拒绝" ``` --- ### Task 3: buildRoute 合并 ACL 与 WARP,接入渲染 **Files:** - Modify: `server/internal/agentd/render.go`(`:30-58` `renderSingboxConfig` 增参、替换 `cfg["route"]` 赋值、新增 `buildRoute`;`:14` 隐私不变式注释订正) - Modify: `server/internal/agentd/warp.go:113-121`(删除 `warpRoute()`,其唯一调用方被 `buildRoute` 取代) - Modify: `server/internal/agentd/singbox.go:326`(`renderSingboxConfig` 调用处补 `nil` 实参,本任务先传 nil,Task 4 换成真实 ACL) - Modify: `server/internal/agentd/acl_test.go`(追加四态矩阵测试) **Interfaces:** - Consumes: Task 2 的 `(*ACLConfig).rules()`;既有 `(*WarpConfig).active()` / `cleanDomains()` / `warpEndpoint()` - Produces: `func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any`(两者皆未激活时返回 nil);`renderSingboxConfig` 新签名 `func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error)` - [ ] **Step 1: 写失败测试** 追加到 `server/internal/agentd/acl_test.go`: ```go // buildRoute 四态矩阵:ACL×WARP 开关的四种组合。 func TestBuildRoute_Matrix(t *testing.T) { acl := &ACLConfig{ Enabled: true, AllowDpUUIDs: []string{"uuid-me"}, Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}}, } warp := &WarpConfig{ Enabled: true, PrivateKey: "k", PeerPublicKey: "pk", Endpoint: "162.159.192.1:2408", Address: []string{"172.16.0.2/32"}, Domains: []string{"reddit.com"}, } t.Run("都关 → 不产出 route(向后兼容)", func(t *testing.T) { if got := buildRoute(nil, nil); got != nil { t.Errorf("buildRoute(nil,nil) = %v, want nil", got) } }) t.Run("仅 WARP → sniff + warp 规则(与改动前逐字节一致)", func(t *testing.T) { r := buildRoute(nil, warp) rules := r["rules"].([]any) if len(rules) != 2 { t.Fatalf("规则数 = %d, want 2", len(rules)) } if rules[0].(map[string]any)["action"] != "sniff" { t.Error("首条不是 sniff") } if rules[1].(map[string]any)["outbound"] != warpOutboundTag { t.Error("次条不是 warp 分流") } if r["final"] != directOutboundTag { t.Errorf("final = %v, want %q", r["final"], directOutboundTag) } }) t.Run("仅 ACL → sniff + 放行 + 拒绝", func(t *testing.T) { r := buildRoute(acl, nil) rules := r["rules"].([]any) if len(rules) != 3 { t.Fatalf("规则数 = %d, want 3", len(rules)) } if rules[0].(map[string]any)["action"] != "sniff" { t.Error("首条不是 sniff") } if _, ok := rules[1].(map[string]any)["user"]; !ok { t.Error("第二条不是放行规则") } if rules[2].(map[string]any)["action"] != "reject" { t.Error("第三条不是拒绝规则") } }) t.Run("都开 → sniff + ACL(放行,拒绝) + warp,且 sniff 只出现一次", func(t *testing.T) { r := buildRoute(acl, warp) rules := r["rules"].([]any) if len(rules) != 4 { t.Fatalf("规则数 = %d, want 4", len(rules)) } sniffs := 0 for _, x := range rules { if x.(map[string]any)["action"] == "sniff" { sniffs++ } } if sniffs != 1 { t.Errorf("sniff 出现 %d 次, want 1", sniffs) } if rules[0].(map[string]any)["action"] != "sniff" { t.Error("sniff 必须最先") } // ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口 if rules[3].(map[string]any)["outbound"] != warpOutboundTag { t.Error("warp 规则必须排在最后") } if rules[2].(map[string]any)["action"] != "reject" { t.Error("ACL 拒绝规则必须排在 warp 之前") } }) } ``` - [ ] **Step 2: 跑测试确认失败** ```bash cd server && go test ./internal/agentd/ -run 'BuildRoute' -v ``` Expected: 编译失败,`undefined: buildRoute`。 - [ ] **Step 3: 写实现** ① 在 `server/internal/agentd/render.go` 中,把第 30 行签名与第 50–55 行的 route 注入替换为: ```go func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) { ``` ```go // WARP 分流(#29)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要 // 同时容纳 ACL 规则 —— 原先 cfg["route"] = warp.warpRoute() 是整块覆盖,直接赋值 // 会把对方的规则干掉。 if warp.active() { cfg["endpoints"] = []any{warp.warpEndpoint()} } if route := buildRoute(acl, warp); route != nil { cfg["route"] = route } ``` ② 在 `render.go` 的 `statsUsers` 之前插入: ```go // buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。 // 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。 // // 规则顺序是安全语义的一部分: // 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是 // 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。 // 2. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远 // 不该还有机会被路由到 warp 出口。 // 3. final 恒为 direct。 func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any { aclRules := acl.rules() warpActive := warp.active() if len(aclRules) == 0 && !warpActive { return nil } rules := make([]any, 0, len(aclRules)+2) rules = append(rules, map[string]any{"action": "sniff"}) rules = append(rules, aclRules...) if warpActive { rules = append(rules, map[string]any{ "domain_suffix": warp.cleanDomains(), "outbound": warpOutboundTag, }) } return map[string]any{"rules": rules, "final": directOutboundTag} } ``` ③ 删除 `server/internal/agentd/warp.go` 第 111–121 行的 `warpRoute()` 方法及其上方注释块(唯一调用方已被 `buildRoute` 取代;`grep -rn "warpRoute()" internal/agentd/` 应无剩余命中)。 ④ 订正 `render.go` 第 14 行的隐私不变式注释。原文: ```go // Only the opaque dp_uuid is ever written; no account identity touches the node. ``` 改为: ```go // Only the opaque dp_uuid is ever written; no account identity touches the node. // // 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有 // 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id), // 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化, // 设计与权衡见 docs/private-dest-acl-design.html §12。 ⑤ 修改 `server/internal/agentd/singbox.go` 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL): ```go return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil) ``` - [ ] **Step 4: 跑测试确认通过** ```bash cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v ``` Expected: PASS。特别确认既有的 `TestRender_NoWarp_NoRouteSection` 与 `TestRender_Warp_InjectsEndpointAndRoute` 仍绿——这两条守着向后兼容。 - [ ] **Step 5: 提交** ```bash 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" ``` --- ### Task 4: 渲染时读取 ACL,last-good 内存 + 落盘兜底 **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 测试) **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` - [ ] **Step 1: 写失败测试** 追加到 `server/internal/agentd/acl_test.go`: ```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"`。 - [ ] **Step 2: 跑测试确认失败** ```bash cd server && go test ./internal/agentd/ -run 'TestACL_' -v ``` Expected: `TestACL_PersistsLastGoodOnLoad` 失败(last-good 未落盘),`TestACL_BrokenFile...` / `TestACL_ColdStart...` 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。 - [ ] **Step 3: 写实现** ① 在 `server/internal/agentd/acl.go` 末尾追加: ```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` 之后新增字段: ```go // lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它, // 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。 lastGoodACL *ACLConfig ``` ③ 在 `server/internal/agentd/singbox.go` 的 `RenderConfig` 中,把 WARP 加载之后的部分替换为: ```go // 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: 跑测试确认通过** ```bash cd server && go test ./internal/agentd/ -run 'TestACL_' -v ``` Expected: PASS,四条全绿。 - [ ] **Step 5: 提交** ```bash 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 跨重启" ``` --- ### Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户) **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` 测试) **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`: ```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: 跑测试确认失败** ```bash cd server && go test ./internal/agentd/ -run 'Refresh' -v ``` Expected: 编译失败,`sb.Refresh undefined`。 - [ ] **Step 3: 写实现** ① 在 `server/internal/agentd/singbox.go` 的 `markDirty` 之后追加: ```go // Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件 // (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走 // 冷启动(Restart),把全部在线用户踢下线。 func (s *SingBox) Refresh() { s.markDirty() } ``` ② 修改 `server/cmd/agent/main.go`,把第 44–45 行替换为: ```go 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: 跑测试确认通过** ```bash cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v ``` Expected: PASS。 - [ ] **Step 5: 提交** ```bash 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`: ```json { "_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: 验证样例能被解析且渲染出预期规则** ```bash 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 首段(`

8. 名单维护 Runbook

` 之后的第一个 `

`)末尾追加一句: ```html 样例文件见 deploy/single-node/acl.json.example,复制到 /etc/pangolin-agent/acl.json 后按下方 SQL 填入 uuid。 ``` - [ ] **Step 4: 提交** ```bash 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,届时渲染层可原样保留)