diff --git a/.gitignore b/.gitignore index a8fbf2e..a229dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,6 @@ client/windows/installer/Output/ /server/cover.out /server/coverage.txt /client/coverage/ + +# superpowers 执行进度暂存(本地,不入库) +.superpowers/ diff --git a/deploy/single-node/acl.json.example b/deploy/single-node/acl.json.example new file mode 100644 index 0000000..8229bae --- /dev/null +++ b/deploy/single-node/acl.json.example @@ -0,0 +1,19 @@ +{ + "_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] + } + ] +} diff --git a/deploy/single-node/systemd/pangolin-agent.service b/deploy/single-node/systemd/pangolin-agent.service index 5ce9add..f24fe38 100644 --- a/deploy/single-node/systemd/pangolin-agent.service +++ b/deploy/single-node/systemd/pangolin-agent.service @@ -12,6 +12,10 @@ StartLimitIntervalSec=0 Type=simple EnvironmentFile=/etc/pangolin-agent/agent.env ExecStart=/usr/local/bin/pangolin-agent +# SIGHUP → agent 重读节点本地配置(acl.json/warp.json)并热重渲染,不冷启动 +# sing-box、不踢在线用户。没有这行,`systemctl reload` 会报「Job type reload is +# not applicable」,运维只能退回 restart(整进程重启,sing-box 冷启动瞬断全员)。 +ExecReload=/bin/kill -HUP $MAINPID # 始终自愈(崩溃/被依赖停后都拉起);RestartSec 做退避。 Restart=always RestartSec=5 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 @@
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/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。只有指定 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 的闸,把语义拉回「只有我」。
只做「私有服务白名单」一件事。不做通用租户黑白名单、不做上网管控、不做管理后台、不要求实时生效。这些在评审中被显式砍掉——它们的复杂度大半来自「多策略 × 多组 × 实时下发」,而本需求三个都不需要。
+ +经 pangolin 出口能摸到的家里服务,各自本来就有鉴权,唯一裸奔的是 brain:
+| 服务 | 入口 | 自带鉴权 |
|---|---|---|
| git.51yanmei.com | ali 443 → Gitea | Gitea 登录 有 |
| nas.51yanmei.com:5001 | frp → DSM | 账号 + 2FA + 自动封锁 有 |
| nas/win SSH (10022/10023) | frp | 密钥登录 有 |
| win.51yanmei.com:3389 | frp → RDP | NLA + 密码 有 |
| brain.51yanmei.com | ali 443 → 静态站 | 无 |
所以本设计提供的是网络层前置闸(别人连登录页都摸不到),而不是唯一防线。
+ +| 维度 | 选择 | 理由 |
|---|---|---|
| 执行点 | 节点 sing-box route.rules | 控制面不在数据路径上;sing-box 无外部授权钩子(实测 external_controller/auth_request/external_acl/authenticator/webhook/http_provider/external_rule_provider 七个字段名全部 unknown field)。客户端执行会被用户自行修改,且 /sub/{token} 路径天然绕过。 |
| 名单来源 | 节点本地 acl.json | 照 warp.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),故加一个轻量信号处理。 |
/etc/pangolin-agent/warp.json 存在;线上 /etc/sing-box/config.json 的 route 块实测为 {"final":"direct","rules":[{"action":"sniff"},{"domain_suffix":["reddit.com",…],"outbound":"warp"}]}。internal/agentd/render.go:50-55 —— if warp.active() { cfg["endpoints"]=…; cfg["route"] = warp.warpRoute() }。关键 ACL 必须与 WARP 合并,直接赋值会把 WARP 分流干掉。internal/agentd/singbox.go:319-326(LoadWarpConfig),编辑后任一渲染即生效。internal/agentd/singbox.go:340-359 —— 进程内首次渲染走 Restart()(systemctl restart sing-box),之后走 Reload()(SIGHUP,取不到 PID 时自动回退 Restart)。DB_DRIVER=sqlite,DB_DSN=/var/lib/pangolin/pangolin.db(pangolin1 内存紧,刻意用 SQLite)。devices.dp_uuid(connect 用)与账户级 users.dp_uuid(/sub/{token} 订阅链接仍在用,internal/httpapi/subscription.go:104)。关键 白名单必须两个都包含,否则自己用订阅链接反而进不去。/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。
每个 target 产出一对规则:先放行白名单、再兜底拒绝。顺序即优先级,首条命中即返回。
+"route": {
+ "rules": [
+ {"action": "sniff"},
+ {"action": "resolve"},
+
+ {"auth_user": ["<我的uuid…>"], "domain": ["brain.51yanmei.com","git.51yanmei.com"],
+ "outbound": "direct"},
+ {"auth_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"
+}
+ auth_user,不认 user(2026-07-23 生产验证)放行规则匹配凭证必须用 auth_user 而非 user。sing-box check 对两者都语法通过,但 user 字段对 VLESS/REALITY 入站运行时根本不匹配——放行规则永不命中,结果连白名单用户也被兜底拒绝(全员进不去)。上线时正是踩了这个:节点端 git 一直 000,把本机 uuid 换着法加进白名单都没用。本地起一对真 VLESS 实例实测才定位:auth_user:["good"] 生效(good 通/bad 被 block),user 不生效。只跑 sing-box check 不足以验收访问控制,必须真连接跑一次。
已用本机 sing-box 1.13.13 真 VLESS 连接验证 auth_user 匹配 + resolve 后 ip_cidr/domain 仍匹配;节点端以 git 做「白名单一进一出」验证 per-user 放行/拒绝生效。
{"action":"sniff"} 必须最先且只出现一次——域名匹配依赖它取 TLS SNI。合并时若 WARP 已产出 sniff,不得重复追加。把 render.go 里 cfg["route"] = warp.warpRoute() 的直接赋值,改为由一个 buildRoute(acl, warp) 统一产出:
| ACL | WARP | route 块 |
|---|---|---|
| 关 | 关 | 不产出(与现有配置逐字节一致,保持向后兼容) |
| 关 | 开 | 现状不变:sniff + warp 规则 |
| 开 | 关 | sniff + ACL 规则,final:"direct" |
| 开 | 开 | sniff + ACL 规则 + warp 规则,final:"direct";endpoints 照旧由 WARP 注入 |
WARP 的先例是 fail-open,绝不能照抄。singbox.go:321-324 加载失败即 warp = nil 放弃分流;warp.go:47-58 的 active() 任一字段缺失即返回 false,注释写着「宁可不分流(全直连)」。对分流这是对的,对访问控制方向正好反了——同样的写法会让一次手抖的编辑把私有服务对全体 pangolin 用户敞开,而且是静默的。
本设计的规则:
+<StateDir>/acl.last-good.json(照 state.json 的持久化套路)。否则 agent 一重启,内存里的 last-good 就没了,fail-closed 只在进程生命周期内成立——而进程重启恰恰是最可能撞上坏配置的时刻。acl.json;失败则回退 acl.last-good.json;两者都失败才不产出 ACL 规则,同时打 ERROR 并告警。注意 这最后一档的实际后果是 fail-open(私有服务对全体 pangolin 用户敞开)——之所以只能如此,是因为白名单与目的地清单同在一个文件,文件全丢时连「该拒绝哪些目的地」都无从得知,无法凭空拒起。这也是为什么 last-good 落盘是必需项而非优化项。allow_dp_uuids 为空数组 → 仍产出拒绝规则,即「谁都不许进」。空名单的语义是「没有人」,不是「所有人」。"enabled": false。删文件不算(走 last-good 分支)。编辑 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 即可 |
| 文件 | 改动 |
|---|---|
internal/agentd/acl.go 新增 | ACLConfig 结构、LoadACLConfig、active()(fail-closed 语义)、aclRules() 产出规则对。镜像 warp.go 骨架。 |
internal/agentd/render.go | renderSingboxConfig 增 acl 入参;抽出 buildRoute(acl, warp) 取代 cfg["route"] = warp.warpRoute()(§4.3 四态表) |
internal/agentd/singbox.go | RenderConfig 每次重读 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.go 或 cmd/agent | SIGHUP → markDirty() |
internal/agentd/acl_test.go 新增 | 见 §9 |
不动:控制面、数据库(零 migration)、proto、客户端、管理后台、CI。改动全部收敛在 internal/agentd/。
新增/更换设备后,重新生成白名单(UNION 那一半是账户级 dp_uuid,供 /sub 订阅链接用,别漏)。样例文件见 deploy/single-node/acl.json.example,复制到 /etc/pangolin-agent/acl.json 后按下方 SQL 填入 uuid。
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。
+ +acl_test.go,表驱动,镜像 warp_test.go)allow_dp_uuids: [] 时仍产出拒绝规则;enabled:false 才真正关闭acl.last-good.json 已落盘;新建 agent 实例(模拟重启)+ 坏 acl.json → 仍产出规则;两文件皆坏 → 无规则且有 ERRORsniff 唯一且最先;放行先于拒绝;ACL 全部先于 WARPsing-box check 通过(注意:check 只验语法,不验 auth_user 运行时是否真匹配 VLESS——见 §4.2 血泪教训)auth_user 放行/拒绝生效;或节点端以某个白名单域名做「本机 uuid 一进一出」验证 per-user 生效。仅 sing-box check 绿=未验收。ssh nas-r 通acl.json 改坏 → 规则仍在、日志有 ERROR(fail-closed 实证)systemctl reload pangolin-agent 期间在线用户不掉线rule_set——目的地就几条,内联即可;远端规则集会引入以 update_interval 计的第二条延时链(小时级),且「节点只有不透明 blob」是假收益(节点自己下载的内容当然读得到,还落 cache)若将来真要做多租户策略,渲染层可原样保留,只把名单来源换掉:给 Credential(internal/pb/agentv1/types.go + agentd/singbox.go 的 Cred)加一个 PrivateAccess bool。该字段同时走增量推送与 Register 全量快照两条通道,天然免疫「重连被 ApplyConfig(snap,true) 整表替换」的问题。届时 acl.json 退化为只保留 targets,allow_dp_uuids 由控制面下发。
render.go:14 的「节点只见不透明 dp_uuid」不变式,属于知情接受,需在该注释处补一行说明。assertNoIdentityFields(agentd/singbox_test.go)只作用于 state.json,从不检查渲染出的 sing-box 配置。本设计不扩大这个缺口,但也没有补上——补闸另开。brain/git,节点侧唯一的区分手段是 sniff 出的 SNI。攻击者(持有效 dp_uuid 的其他 pangolin 用户)向 182.92.213.171:443 发起不带 SNI 的 TLS、握手后用 Host: brain.51yanmei.com 头访问,则不匹配任何 domain 规则、也不匹配 ip_cidr(443 不在端口清单里)→ 落 final:direct → nginx 按 Host 路由放行。这在 sing-box 层无法闭合——不能整封 :443,否则 jiu/travel/sudoku/pay 一起死。因此下面这条 brain 鉴权不是「可选纵深」,而是本闸对 brain 的前置条件。brain/nas 直连(smartRoute 把国内 IP 分流成直连),则流量根本不到节点、直接打 ali,brain 得 403(nginx deny 非白名单源 IP)、nas 超时。故 brain/nas/git/win.51yanmei.com 必须在控制面 PANGOLIN_PRIVATE_SPLIT_DOMAINS 里(已配),且客户端改动后要重连一次才拿到新分流规则。上线验证时 git 走隧道正常、brain/nas 因客户端未重连仍直连——排查时先确认「域名是否真走了隧道」(curl -v 看连的是不是 pangolin 出口),再判 ACL。/etc/nginx/conf.d/brain.conf 在原有 allow 103.119.13.48; deny all 之上叠加 auth_basic(satisfy 默认 all → 源 IP 白名单 与 口令二者都需满足),口令存 Bitwarden「brain basic auth」,htpasswd 仅存 apr1 哈希(明文不落 ali)。acme 通道(:80)与 robots.txt 免密。这样即便本节点 ACL 闸失效(agent 挂了、配置手抖)或被上面的 SNI 手法绕过,brain——唯一无自带鉴权的私有服务——仍不裸奔。相关:~/code/brain/docs/remote-access-brain-domain.html(brain 外网入口与出口 IP 白名单)· baize 台账 ~/code/baize/data/{hosts,domains}.yaml
+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/...。
docs/private-dest-acl-design.html。本计划的任何取舍以该文档为准。server/internal/agentd/ + server/cmd/agent/main.go。不得新增 migration、不得改 proto、不得改控制面(internal/httpapi、internal/nodes)、不得改客户端。warp.go 的 fail-open 先例相反。具体:读失败保留 last-good;allow_dp_uuids: [] 仍产出拒绝规则;仅 "enabled": false 能关闭。{"action":"sniff"} 唯一且最先 → ACL 放行 → ACL 拒绝 → WARP 分流。TestRender_NoWarp_NoRouteSection 守这条)。cd server && go build ./... && go test ./internal/agentd/... 全绿再提交。feat(agent): / test(agent): / refactor(agent):。+执行后修订(2026-07-23,提交
2abe4d0)· 血泪教训:放行规则匹配凭证必须用auth_user而非本计划各处写的user。sing-box check对两者都语法通过,但user字段对 VLESS/REALITY 入站运行时不匹配,导致放行规则永不命中、连白名单用户也被兜底拒绝(全员进不去)。生产上线时踩中,本地起真 VLESS 连接实测才定位。凡本计划(Task 2rules()、Task 3 测试)出现r["user"] = uuids或断言r["user"]之处,一律应为auth_user。 且验收不能只跑sing-box check——必须真连接跑一次(节点端「白名单 uuid 一进一出」即可)。
Files:
+server/internal/agentd/acl.goserver/internal/agentd/acl_test.goserver/internal/agentd/config.go(新增 ACLConfigPath 字段、withDefaults 分支、ACLLastGoodPath(),并订正包注释)Interfaces:
+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创建 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)
+ }
+}
+cd server && go test ./internal/agentd/ -run 'ACL' -v
+Expected: 编译失败,undefined: LoadACLConfig / undefined: ACLConfig / c.ACLConfigPath undefined。
创建 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") }
+cd server && go test ./internal/agentd/ -run 'ACL|ConfigACLPaths' -v
+Expected: PASS,全部子测试通过。
+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 加载语义"
++执行后修订(2026-07-23,提交
d4156b8):审查指出上面的cleanHosts与warp.go既有的cleanDomains是同包内逐字重复的逻辑。已按用户裁决改为cleanDomains委托cleanHosts(func (wc *WarpConfig) cleanDomains() []string { return cleanHosts(wc.Domains) },并移除warp.go中变为未使用的stringsimport)。若日后照本计划重新实现,请直接采用委托版本,不要写回两份重复实现。
Files:
+server/internal/agentd/acl.go(新增 rules())server/internal/agentd/acl_test.go(追加测试)Interfaces:
+ACLConfig.active() / cleanUUIDs() / cleanTargets() / ACLTarget.matchFields()func (ac *ACLConfig) rules() []any —— 返回按「全部放行规则 → 全部拒绝规则」排序的 sing-box route 规则切片;未 active 时返回 nil追加到 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"。
cd server && go test ./internal/agentd/ -run 'ACLRules' -v
+Expected: 编译失败,ac.rules undefined (type *ACLConfig has no field or method rules)。
在 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
+}
+cd server && go test ./internal/agentd/ -run 'ACL' -v
+Expected: PASS。
+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 渲染放行/拒绝规则对,空白名单仍拒绝"
+Files:
+server/internal/agentd/render.go(:30-58 renderSingboxConfig 增参、替换 cfg["route"] 赋值、新增 buildRoute;:14 隐私不变式注释订正)server/internal/agentd/warp.go:113-121(删除 warpRoute(),其唯一调用方被 buildRoute 取代)server/internal/agentd/singbox.go:326(renderSingboxConfig 调用处补 nil 实参,本任务先传 nil,Task 4 换成真实 ACL)server/internal/agentd/acl_test.go(追加四态矩阵测试)Interfaces:
+(*ACLConfig).rules();既有 (*WarpConfig).active() / cleanDomains() / warpEndpoint()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)追加到 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 之前")
+ }
+ })
+}
+cd server && go test ./internal/agentd/ -run 'BuildRoute' -v
+Expected: 编译失败,undefined: buildRoute。
① 在 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.go 的 statsUsers 之前插入:
// 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)
+cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v
+Expected: PASS。特别确认既有的 TestRender_NoWarp_NoRouteSection 与 TestRender_Warp_InjectsEndpointAndRoute 仍绿——这两条守着向后兼容。
cd server && go build ./... && go test ./internal/agentd/...
+git add server/internal/agentd/render.go server/internal/agentd/warp.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
+git commit -m "refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP"
+Files:
+server/internal/agentd/singbox.go:49-74(SingBox 增 lastGoodACL 字段)、:308-327(RenderConfig 读 ACL)server/internal/agentd/acl.go(新增 persistACL)server/internal/agentd/acl_test.go(追加 last-good 测试)Interfaces:
+LoadACLConfig / Config.ACLConfigPath / Config.ACLLastGoodPath();Task 3 的 renderSingboxConfig 六参签名;既有包内函数 atomicWrite(path string, data []byte, perm os.FileMode) errorfunc (s *SingBox) loadACL() *ACLConfig;func persistACL(path string, ac *ACLConfig) error追加到 server/internal/agentd/acl_test.go:
// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
+func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
+ cfg := testConfig(t)
+ writeACL(t, cfg.ACLConfigPath, validACL)
+ sb := NewSingBox(cfg, nil)
+ sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
+
+ 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"。
cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+Expected: TestACL_PersistsLastGoodOnLoad 失败(last-good 未落盘),TestACL_BrokenFile... / TestACL_ColdStart... 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。
① 在 server/internal/agentd/acl.go 末尾追加:
// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
+func persistACL(path string, ac *ACLConfig) error {
+ data, err := json.MarshalIndent(ac, "", " ")
+ if err != nil {
+ return fmt.Errorf("agentd: marshal acl snapshot: %w", err)
+ }
+ return atomicWrite(path, data, 0o600)
+}
+② 在 server/internal/agentd/singbox.go 的 SingBox 结构体中,configVersion int64 之后新增字段:
// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
+ // 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
+ lastGoodACL *ACLConfig
+③ 在 server/internal/agentd/singbox.go 的 RenderConfig 中,把 WARP 加载之后的部分替换为:
// WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。
+ // 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。
+ warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)
+ if err != nil {
+ logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
+ warp = nil
+ }
+ return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
+}
+
+// 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
+}
+cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+Expected: PASS,四条全绿。
+cd server && go build ./... && go test ./internal/agentd/...
+git add server/internal/agentd/acl.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
+git commit -m "feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启"
+Files:
+server/internal/agentd/singbox.go(新增导出的 Refresh())server/cmd/agent/main.go:44-45(注册 SIGHUP 处理)server/internal/agentd/acl_test.go(追加 Refresh 测试)Interfaces:
+(*SingBox).markDirty()(未导出)、(*Agent).SingBox() *SingBox(agent.go:83,已导出)func (s *SingBox) Refresh() —— 外部触发一次重渲染为什么不用 systemctl restart pangolin-agent: 新进程内 s.started == false(singbox.go:64-67),首次渲染会走 restarter.Restart() 即 systemctl restart sing-box,全部在线用户瞬断。SIGHUP 走的是既有的 debounce → writeAndRestart → Reload()(SIGHUP 热重载)路径,不断线。
追加到 server/internal/agentd/acl_test.go:
// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后
+// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。
+func TestSingBoxRefresh_TriggersRender(t *testing.T) {
+ cfg := testConfig(t)
+ writeACL(t, cfg.ACLConfigPath, validACL)
+ fr := &fakeRestarter{}
+ sb := NewSingBox(cfg, fr)
+ sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
+
+ 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)。
cd server && go test ./internal/agentd/ -run 'Refresh' -v
+Expected: 编译失败,sb.Refresh undefined。
① 在 server/internal/agentd/singbox.go 的 markDirty 之后追加:
// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
+// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
+// 冷启动(Restart),把全部在线用户踢下线。
+func (s *SingBox) Refresh() { s.markDirty() }
+② 修改 server/cmd/agent/main.go,把第 44–45 行替换为:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ // SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP
+ // 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。
+ hup := make(chan os.Signal, 1)
+ signal.Notify(hup, syscall.SIGHUP)
+ defer signal.Stop(hup)
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-hup:
+ log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")
+ agent.SingBox().Refresh()
+ }
+ }
+ }()
+os 与 log 已在该文件 import 中(os 见 env() 用到的 os.LookupEnv);若 go build 报缺失,按提示补齐。
cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v
+Expected: PASS。
+cd server && go build ./... && go test ./internal/agentd/...
+git add server/internal/agentd/singbox.go server/internal/agentd/acl_test.go server/cmd/agent/main.go
+git commit -m "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户"
+Files:
+deploy/single-node/acl.json.exampleserver/internal/agentd/acl_test.go(追加样例文件常驻校验测试)docs/private-dest-acl-design.html(把 §8 runbook 里的占位符换成指向样例文件)Interfaces:
+ACLConfig JSON 形状/etc/pangolin-agent/acl.json 的样例创建 deploy/single-node/acl.json.example:
{
+ "_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",
+ "enabled": true,
+ "allow_dp_uuids": [
+ "TODO-设备级-dp-uuid",
+ "TODO-账户级-dp-uuid-供-sub-订阅链接用"
+ ],
+ "targets": [
+ {
+ "_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",
+ "domain": ["brain.51yanmei.com", "git.51yanmei.com"]
+ },
+ {
+ "_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",
+ "ip_cidr": ["182.92.213.171/32"],
+ "port": [5001, 3389, 10022, 10023]
+ }
+ ]
+}
++
_comment字段会被encoding/json忽略(ACLConfig无对应字段且未启用DisallowUnknownFields),可安全保留作自文档。样例里的TODO-值是样例文件的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 go run 检查一次没有约束力。改为一条常驻测试。追加到 server/internal/agentd/acl_test.go:
// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
+func TestACLExampleFileStaysValid(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "deploy", "single-node", "acl.json.example")
+ ac, err := LoadACLConfig(path)
+ if err != nil {
+ t.Fatalf("样例文件解析失败 %s: %v", path, err)
+ }
+ if ac == nil {
+ t.Fatalf("样例文件不存在: %s", path)
+ }
+ if !ac.active() {
+ t.Error("样例文件应当是一份 active 的 ACL")
+ }
+ if len(ac.Targets) != 2 {
+ t.Errorf("样例 targets = %d, want 2", len(ac.Targets))
+ }
+ // 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
+ for _, u := range ac.AllowDpUUIDs {
+ if !strings.HasPrefix(u, "TODO-") {
+ t.Errorf("样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git", u)
+ }
+ }
+}
+跑:
+cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -v
+Expected: PASS。
++注:
_comment字段会被encoding/json忽略(ACLConfig无对应字段且未启用DisallowUnknownFields),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。
在 docs/private-dest-acl-design.html 的 §8 首段(<h2>8. 名单维护 Runbook</h2> 之后的第一个 <p>)末尾追加一句:
样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。
+cd server && go build ./... && go test ./internal/agentd/...
+git add deploy/single-node/acl.json.example server/internal/agentd/acl_test.go docs/private-dest-acl-design.html
+git commit -m "docs(agent): 私有目的地 ACL 配置样例与常驻校验测试"
++
git add只列具体路径,切勿git add -A:仓库里有未跟踪的pangolin-release.jks(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。
代码合并后在 pangolin1 上执行。先备份现网 sing-box 配置:sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)(此处 $(...) 在服务器交互 shell 中手动执行,不经 agent)。
UNION 那一半是账户级 dp_uuid,供 /sub 订阅链接用,漏了会把自己锁在外面):```sql
+sqlite3 /var/lib/pangolin/pangolin.db \
+"SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id
+WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL
+UNION
+SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"
+```
+/etc/pangolin-agent/acl.json(权限 0600,属主 pangolin),systemctl reload pangolin-agent。journalctl -u pangolin-agent -n 30 确认已重渲染、无 [acl] ERROR / [acl] ALERT。sudo python3 -c "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])" 确认规则顺序为 sniff → resolve → 放行 → 拒绝 → warp(resolve 见 I3:堵住 ip_cidr 目的地的域名形式绕过)。 config.json 内容正常不等于线上生效——SIGHUP 走的是 sing-box 自身校验,若新配置被 sing-box 拒绝,它会保留旧实例继续跑,agent 侧仍记"渲染成功"。reload 后必须额外确认:
systemctl is-active sing-box → active;journalctl -u sing-box -n5 | grep -v FATAL(有 FATAL 说明 sing-box 拒绝了新配置,旧实例还在跑,config.json 上的内容其实没生效)。ssh nas-r 通。curl https://brain.51yanmei.com 与 curl --resolve brain.51yanmei.com:443:103.119.13.48 https://brain.51yanmei.com;DSM 同理分别用 nas.51yanmei.com:5001 与 182.92.213.171:5001)——I3 表明目的地表达形式(域名 vs IP)会走到不同的 sing-box 匹配路径,只测一种形式验证不到位。acl.json 改坏 → systemctl reload pangolin-agent → 规则仍在、日志有 ERROR;恢复文件。assertNoIdentityFields 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复)`)末尾追加一句:
+
+```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 server/internal/agentd/acl_test.go docs/private-dest-acl-design.html
+git commit -m "docs(agent): 私有目的地 ACL 配置样例与常驻校验测试"
+```
+
+> `git add` 只列具体路径,**切勿 `git add -A`**:仓库里有未跟踪的 `pangolin-release.jks`(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。
+
+---
+
+## 上线验收(人工,非代码任务)
+
+代码合并后在 pangolin1 上执行。**先备份现网 sing-box 配置**:`sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)`(此处 `$(...)` 在服务器交互 shell 中手动执行,不经 agent)。
+
+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 → resolve → 放行 → 拒绝 → warp(resolve 见 I3:堵住 ip_cidr 目的地的域名形式绕过)。
+ **`config.json` 内容正常不等于线上生效**——SIGHUP 走的是 sing-box 自身校验,若新配置被 sing-box 拒绝,它会保留旧实例继续跑,agent 侧仍记"渲染成功"。reload 后必须额外确认:
+ - `systemctl is-active sing-box` → `active`;
+ - `journalctl -u sing-box -n5 | grep -v FATAL`(有 FATAL 说明 sing-box 拒绝了新配置,旧实例还在跑,config.json 上的内容其实没生效)。
+5. **我的设备**:brain 首页 200、DSM 5001 可登录、`ssh nas-r` 通。
+6. **另一账号的设备**:以上全部被拒(连接被 reject,不是超时)。**分别用域名和裸 IP 两种形式各测一遍**(如 `curl https://brain.51yanmei.com` 与 `curl --resolve brain.51yanmei.com:443:103.119.13.48 https://brain.51yanmei.com`;DSM 同理分别用 `nas.51yanmei.com:5001` 与 `182.92.213.171:5001`)——I3 表明目的地表达形式(域名 vs IP)会走到不同的 sing-box 匹配路径,只测一种形式验证不到位。
+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/server/cmd/agent/main.go b/server/cmd/agent/main.go
index 8b24693..db358d7 100644
--- a/server/cmd/agent/main.go
+++ b/server/cmd/agent/main.go
@@ -44,6 +44,23 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
+ // SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP
+ // 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。
+ hup := make(chan os.Signal, 1)
+ signal.Notify(hup, syscall.SIGHUP)
+ defer signal.Stop(hup)
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-hup:
+ log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")
+ agent.SingBox().Refresh()
+ }
+ }
+ }()
+
log.Printf("[pangolin-agent] starting (control-plane=%s, version=%s)", cfg.ControlPlaneAddr, cfg.AgentVersion)
start := time.Now()
if err := agent.Run(ctx); err != nil {
diff --git a/server/internal/agentd/acl.go b/server/internal/agentd/acl.go
new file mode 100644
index 0000000..76a174d
--- /dev/null
+++ b/server/internal/agentd/acl.go
@@ -0,0 +1,222 @@
+package agentd
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/netip"
+ "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 是节点本地的私有目的地访问控制表(默认 feature+windows — 项目 TODO
-
+
-