diff --git a/docs/index.html b/docs/index.html index d310d48..1387ef4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -44,6 +44,11 @@

设计方案 / Specs

+ +
私有目的地访问控制(节点侧 ACL)HTML
+
pangolin 出口 IP 曾等价于「只有我」,多用户后退化成「所有 pangolin 用户」——家庭内网服务(brain/nas/git/win.51yanmei.com)因此对全体用户敞开。在节点 sing-box 加一道按 dp_uuid 的闸:节点本地 acl.json(照 warp.json 骨架)→ 每个目的地渲染「放行白名单 + 兜底拒绝」一对规则,与 WARP 规则合并而非覆写。控制面/DB/proto/客户端/管理后台一律不动,改动收敛在 internal/agentd/。关键点:失效方向必须 fail-closed(与 WARP 的 fail-open 先例相反)、白名单须同时含设备级与账户级 dp_uuid(否则 /sub 订阅链接把自己锁在外面)、生效走 agent SIGHUP 以免重启踢掉全部在线用户。含四态渲染矩阵、规则顺序三条硬约束、名单维护 runbook、验收清单。
+
docs/private-dest-acl-design.html
+
CI/CD 全流程(tag 触发编译/发版/部署)HTML
#30。参考 jiu 的 scripts/ci + .gitea/workflows:tag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。
@@ -86,6 +91,11 @@

实现计划 / Plans

+ +
私有目的地 ACL(节点侧)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-23-private-dest-acl.md(含 checkbox)。设计见 docs/private-dest-acl-design.html。6 个 TDD 任务:①ACL 配置类型 + fail-closed 的 active()(空白名单=拒绝所有人,非「未启用」)→ ②rules() 产出「全部放行→全部拒绝」规则对,两侧目的地条件逐字相同 → ③buildRoute 合并 ACL 与 WARP(四态矩阵;现状 cfg["route"] 是整块覆盖,直接赋值会干掉 WARP)→ ④渲染时读 ACL + 内存/磁盘双层 last-good 兜底(fail-closed 须跨重启成立)→ ⑤SIGHUP 触发热重渲染(重启 agent 会冷启 sing-box 踢掉全部在线用户)→ ⑥配置样例与 runbook。改动收敛在 internal/agentd/ + cmd/agent,零 migration/零 proto/零客户端改动。
+
docs/private-dest-acl-plan.html · 真相源 docs/superpowers/plans/2026-07-23-private-dest-acl.md
+
前端设计系统治理重构(ds-flow 全端)HTML
阅读版;执行真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。非从零 bootstrap(已约 65% 达标):补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。
diff --git a/docs/private-dest-acl-design.html b/docs/private-dest-acl-design.html new file mode 100644 index 0000000..0e9e974 --- /dev/null +++ b/docs/private-dest-acl-design.html @@ -0,0 +1,243 @@ + + + + + +私有目的地访问控制(设计 · 节点侧 ACL) + + + +
+ ← 文档索引 +

私有目的地访问控制(设计 · 节点侧 ACL)

+

只有指定 dp_uuid 才能经 pangolin 出口访问家庭内网服务 · 2026-07-23

+ +
+

pangolin 出口 IP(103.119.13.48)是家庭内网服务在 ali 侧的唯一准入凭据——brain/nas/git/win.51yanmei.com 全靠「来自这个 IP」放行。单用户时它等价于「只有我」;现在 pangolin 有 5 个账号,它退化成了「所有 pangolin 用户」。本设计在节点 sing-box 上补一道按 dp_uuid 的闸,把语义拉回「只有我」。

+
+ +

1. 范围

+

只做「私有服务白名单」一件事。不做通用租户黑白名单、不做上网管控、不做管理后台、不要求实时生效。这些在评审中被显式砍掉——它们的复杂度大半来自「多策略 × 多组 × 实时下发」,而本需求三个都不需要。

+ +

问题的真实边界

+

经 pangolin 出口能摸到的家里服务,各自本来就有鉴权,唯一裸奔的是 brain

+ + + + + + + +
服务入口自带鉴权
git.51yanmei.comali 443 → GiteaGitea 登录
nas.51yanmei.com:5001frp → DSM账号 + 2FA + 自动封锁
nas/win SSH (10022/10023)frp密钥登录
win.51yanmei.com:3389frp → RDPNLA + 密码
brain.51yanmei.comali 443 → 静态站
+

所以本设计提供的是网络层前置闸(别人连登录页都摸不到),而不是唯一防线。

+ +

2. 关键决策

+ + + + + + + + + +
维度选择理由
执行点节点 sing-box route.rules控制面不在数据路径上;sing-box 无外部授权钩子(实测 external_controller/auth_request/external_acl/authenticator/webhook/http_provider/external_rule_provider 七个字段名全部 unknown field)。客户端执行会被用户自行修改,且 /sub/{token} 路径天然绕过。
名单来源节点本地 acl.jsonwarp.json 现成骨架。控制面/DB/proto/客户端/管理后台一律不动,改动收敛在 internal/agentd/ 一个包。
主体粒度dp_uuid 列表dp_uuid 每设备一个、懒生成、从不轮换RotateCredential 协议在但控制面无调用方),写死后基本一劳永逸。当前我的账号名下仅 1 台设备有 dp_uuid。
目的地表达域名 + IP:端口 两类brain/git 与公开的 jiu/travel/sudoku 共用 ali 的 443,只能靠 SNI 区分;DSM/RDP/SSH 用独立端口,直接 IP+端口匹配。
sniff复用现有WARP 已在节点启用,线上 route 块本来就是 [{"action":"sniff"},{...warp}]——域名匹配零额外成本。
失效方向fail-closed与 WARP 相反。见 §5,这是本设计唯一容易写错的地方。
生效方式agent SIGHUP 触发重渲染不要求延时,但重启 agent 会让 sing-box 走冷启动、踢掉全部在线用户(见 §6),故加一个轻量信号处理。
+ +

3. 现状事实(设计所依赖的)

+ + +

4. 设计

+ +

4.1 配置文件 /etc/pangolin-agent/acl.json

+
{
+  "enabled": true,
+  "allow_dp_uuids": [
+    "<我的设备级 dp_uuid>",
+    "<我的账户级 dp_uuid(/sub 用)>"
+  ],
+  "targets": [
+    { "domain":  ["brain.51yanmei.com", "git.51yanmei.com"] },
+    { "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
+  ]
+}
+

targets 每项是一个「目的地形状」,字段直接对应 sing-box route rule 的匹配字段(同一项内多字段是 AND,字段内多值是 OR)。刻意不做自研 DSL——形状即 sing-box 语义,减少一层翻译和一类 bug。

+ +

4.2 渲染出的 route 块

+

每个 target 产出一对规则:先放行白名单、再兜底拒绝。顺序即优先级,首条命中即返回。

+
"route": {
+  "rules": [
+    {"action": "sniff"},
+
+    {"user": ["<我的uuid…>"], "domain": ["brain.51yanmei.com","git.51yanmei.com"],
+     "outbound": "direct"},
+    {"user": ["<我的uuid…>"], "ip_cidr": ["182.92.213.171/32"],
+     "port": [5001,3389,10022,10023], "outbound": "direct"},
+
+    {"domain": ["brain.51yanmei.com","git.51yanmei.com"], "action": "reject"},
+    {"ip_cidr": ["182.92.213.171/32"], "port": [5001,3389,10022,10023],
+     "action": "reject"},
+
+    {"domain_suffix": ["reddit.com","redd.it",…], "outbound": "warp"}
+  ],
+  "final": "direct"
+}
+

已用本机 sing-box 1.13.13 sing-box check 验证形状合法(exit 0)。

+ +
+

规则顺序的三条硬约束

+
    +
  1. {"action":"sniff"} 必须最先且只出现一次——域名匹配依赖它取 TLS SNI。合并时若 WARP 已产出 sniff,不得重复追加。
  2. +
  3. 全部 ACL 规则(放行 + 拒绝)排在 WARP 规则之前。当前两者目的地不重叠(reddit vs 我的域名),但被拒绝的目的地永远不该有机会被路由到 warp 出口。
  4. +
  5. 放行必须排在拒绝之前,且两者目的地条件逐字相同。任何不对称都会造成「我自己也被拒」或「有人漏网」。
  6. +
+
+ +

4.3 与 WARP 的合并

+

render.gocfg["route"] = warp.warpRoute() 的直接赋值,改为由一个 buildRoute(acl, warp) 统一产出:

+ + + + + + +
ACLWARProute 块
不产出(与现有配置逐字节一致,保持向后兼容)
现状不变:sniff + warp 规则
sniff + ACL 规则final:"direct"
sniff + ACL 规则 + warp 规则final:"direct"endpoints 照旧由 WARP 注入
+ +

5. 失效语义 最易写错

+
+

WARP 的先例是 fail-open,绝不能照抄。singbox.go:321-324 加载失败即 warp = nil 放弃分流;warp.go:47-58active() 任一字段缺失即返回 false,注释写着「宁可不分流(全直连)」。对分流这是对的,对访问控制方向正好反了——同样的写法会让一次手抖的编辑把私有服务对全体 pangolin 用户敞开,而且是静默的。

+

本设计的规则:

+ +
+ +

6. 生效方式

+

编辑 acl.json 本身不会触发渲染——markDirty() 只在凭证变更时被调用。可选做法与代价:

+ + + + + +
做法代价
systemctl restart pangolin-agent会踢人 新进程内 s.started=false,首次渲染走 Restart()systemctl restart sing-box全部在线用户瞬断
等下一次凭证变更顺带生效时机不可控,可能几小时不生效
给 agent 加 SIGHUP 处理 → markDirty() 采用约 10 行;走 sing-box SIGHUP 热重载,不断线systemctl reload pangolin-agent 即可
+ +

7. 代码改动清单

+ + + + + + + + +
文件改动
internal/agentd/acl.go 新增ACLConfig 结构、LoadACLConfigactive()(fail-closed 语义)、aclRules() 产出规则对。镜像 warp.go 骨架。
internal/agentd/render.gorenderSingboxConfigacl 入参;抽出 buildRoute(acl, warp) 取代 cfg["route"] = warp.warpRoute()(§4.3 四态表)
internal/agentd/singbox.goRenderConfig 每次重读 acl.json(紧邻现有 warp 重读);持有 last-good ACL 字段,成功加载后落盘 acl.last-good.json,冷启动按 acl.json → last-good → 无 顺序回退
internal/agentd/config.go新增 ACLConfigPath,默认 <StateDir>/acl.json
internal/agentd/agent.gocmd/agentSIGHUP → markDirty()
internal/agentd/acl_test.go 新增见 §9
+

不动:控制面、数据库(零 migration)、proto、客户端、管理后台、CI。改动全部收敛在 internal/agentd/

+ +

8. 名单维护 Runbook

+

新增/更换设备后,重新生成白名单(UNION 那一半是账户级 dp_uuid,供 /sub 订阅链接用,别漏):

+
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 = '<我的邮箱>';"
+
+# 填进 acl.json 的 allow_dp_uuids 后
+systemctl reload pangolin-agent          # 不断线
+journalctl -u pangolin-agent -n 20       # 确认已重渲染、无 ACL ERROR
+

设备的 dp_uuid 是首次 connect 时懒生成的:新设备装好后要先连一次,才查得到 dp_uuid。

+ +

9. 测试与验收

+

单元测试(acl_test.go,表驱动,镜像 warp_test.go

+ +

上线验收

+
    +
  1. 我的设备:brain 首页 200、DSM 5001 可登录、ssh nas-r
  2. +
  3. 另一个账号的设备:以上全部被拒(连接被 reject,非超时)
  4. +
  5. 公开站不受影响:jiu / travel / sudoku / pay 在两个账号下均正常
  6. +
  7. WARP 未被破坏:reddit 仍走 warp 出口
  8. +
  9. acl.json 改坏 → 规则仍在、日志有 ERROR(fail-closed 实证)
  10. +
  11. systemctl reload pangolin-agent 期间在线用户不掉线
  12. +
+ +

10. 刻意不做(YAGNI)

+ + +

11. 演进路径

+

若将来真要做多租户策略,渲染层可原样保留,只把名单来源换掉:给 Credentialinternal/pb/agentv1/types.go + agentd/singbox.goCred)加一个 PrivateAccess bool。该字段同时走增量推送与 Register 全量快照两条通道,天然免疫「重连被 ApplyConfig(snap,true) 整表替换」的问题。届时 acl.json 退化为只保留 targetsallow_dp_uuids 由控制面下发。

+ +

12. 已知残留

+ + +

相关:~/code/brain/docs/remote-access-brain-domain.html(brain 外网入口与出口 IP 白名单)· baize 台账 ~/code/baize/data/{hosts,domains}.yaml

+
+ + diff --git a/docs/private-dest-acl-plan.html b/docs/private-dest-acl-plan.html new file mode 100644 index 0000000..390a184 --- /dev/null +++ b/docs/private-dest-acl-plan.html @@ -0,0 +1,1119 @@ + + + + + +私有目的地 ACL · 实现计划(阅读版) + + + +
+← 文档索引 + +

私有目的地访问控制(节点侧 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

+ +
+

Task 1: ACL 配置类型、加载与 fail-closed 的 active()

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

创建 server/internal/agentd/acl_test.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: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'ACL' -v
+

Expected: 编译失败,undefined: LoadACLConfig / undefined: ACLConfig / c.ACLConfigPath undefined

+
[ ]Step 3: 写实现
+

创建 server/internal/agentd/acl.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 是节点本地的私有目的地访问控制表(默认 <StateDir>/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 段落改为:

+
// 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 字段之后新增:

+
	// ACLConfigPath 指向节点本地的私有目的地访问控制表(默认 <StateDir>/acl.json)。
+	// 文件不存在 = 该功能未配置。渲染时读取,SIGHUP agent 即可生效。
+	ACLConfigPath string
+

③ 在 withDefaults()WarpConfigPath 分支之后新增:

+
	if c.ACLConfigPath == "" {
+		c.ACLConfigPath = filepath.Join(c.StateDir, "acl.json")
+	}
+

④ 在文件末尾路径方法区新增:

+
// ACLLastGoodPath 是最近一次成功加载的 ACL 快照,供 agent 冷启动时在 acl.json
+// 损坏的情况下兜底(fail-closed 跨重启成立的前提)。
+func (c Config) ACLLastGoodPath() string { return filepath.Join(c.StateDir, "acl.last-good.json") }
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'ACL|ConfigACLPaths' -v
+

Expected: PASS,全部子测试通过。

+
[ ]Step 5: 全包回归 + 提交
+
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:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.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: 跑测试确认失败
+
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 末尾追加:

+
// 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: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'ACL' -v
+

Expected: PASS。

+
[ ]Step 5: 提交
+
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:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.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: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'BuildRoute' -v
+

Expected: 编译失败,undefined: buildRoute

+
[ ]Step 3: 写实现
+

① 在 server/internal/agentd/render.go 中,把第 30 行签名与第 50–55 行的 route 注入替换为:

+
func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) {
+
	// 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.gostatsUsers 之前插入:

+
// 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 行的隐私不变式注释。原文:

+
// Only the opaque dp_uuid is ever written; no account identity touches the node.
+

改为:

+
// 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):
+
+

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` 仍绿——这两条守着向后兼容。
+
+- [ ] **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"

+

+---
+
+### 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`:
+
+

// 成功加载后必须把快照落盘,否则 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: 跑测试确认失败**
+
+

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` 末尾追加:
+
+

// 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,四条全绿。
+
+- [ ] **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 跨重启"

+

+---
+
+### 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`:
+
+

// 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,届时渲染层可原样保留)
+
+
+ + diff --git a/docs/superpowers/plans/2026-07-23-private-dest-acl.md b/docs/superpowers/plans/2026-07-23-private-dest-acl.md new file mode 100644 index 0000000..5aec700 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-private-dest-acl.md @@ -0,0 +1,1227 @@ +# 私有目的地访问控制(节点侧 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,届时渲染层可原样保留) diff --git a/todo/todo.html b/todo/todo.html index 9b710a2..cc8ebdd 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -327,12 +327,12 @@ header .header-meta{color:var(--fg-soft)}

feature+windows — 项目 TODO

-
生成于 2026-07-08 · 真相源 todo/todo.json
+
生成于 2026-07-23 · 真相源 todo/todo.json
-
18全部
-
8待开始
-
0开发中
-
2待验收
+
22全部
+
9待开始
+
1开发中
+
4待验收
8已验收
@@ -376,7 +376,7 @@ header .header-meta{color:var(--fg-soft)}
- 📋 待开始 8 + 📋 待开始 9 ▴ 收起
@@ -599,6 +599,37 @@ header .header-meta{color:var(--fg-soft)}
+
  • +
    + #21 + 邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活 +
    + 待开始 + 重要 + + + +
    +
    + + + + + +
  • +
  • - 🔨 开发中 0 + 🔨 开发中 1 ▴ 收起
      -

      暂无条目

      + +
    • +
      + #23 + 私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closed,SIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md +
      + 开发中 + 重要 + + + +
      +
      + + + + + +
    - 🔍 待验收 2 + 🔍 待验收 4 ▴ 收起
    @@ -714,6 +775,68 @@ header .header-meta{color:var(--fg-soft)} 🤖 agent 🕐 2026-07-07 +
    +
    +
  • + +
  • +
    + #20 + Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常) +
    + 待验收 + 重要 + + + +
    +
    + + + + + +
  • + +
  • +
    + #22 + 客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开 +
    + 待验收 + 重要 + + + +
    +
    + + + + +
  • diff --git a/todo/todo.json b/todo/todo.json index a7cd675..fad570d 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,9 +1,9 @@ { "meta": { "title": "feature+windows — 项目 TODO", - "updated_at": "2026-07-07T17:45:18.036Z" + "updated_at": "2026-07-22T16:59:57.735Z" }, - "seq": 19, + "seq": 23, "items": [ { "id": 1, @@ -317,6 +317,62 @@ "proposed_at": "2026-07-07T15:41:36.536Z", "approved_at": "2026-07-07T15:54:54.386Z" } + }, + { + "id": 20, + "title": "Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "done", + "created_at": "2026-07-12T10:48:49.013Z", + "done": false, + "completed_at": "2026-07-13T02:20:58.685Z", + "version": "site-v0.0.13" + }, + { + "id": 21, + "title": "邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "open", + "created_at": "2026-07-13T04:29:47.253Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 22, + "title": "客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "done", + "created_at": "2026-07-15T21:40:35.427Z", + "done": false, + "completed_at": null, + "version": null + }, + { + "id": 23, + "title": "私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closed,SIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md", + "desc": null, + "level": "mid", + "tier": null, + "tags": [], + "owner": "agent", + "status": "doing", + "created_at": "2026-07-22T16:59:41.945Z", + "done": false, + "completed_at": null, + "version": null } ] }