← 文档索引

私有目的地访问控制(设计 · 节点侧 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"},
    {"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"
}

血泪教训:VLESS 只认 auth_user,不认 user(2026-07-23 生产验证)

放行规则匹配凭证必须用 auth_user 而非 usersing-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 放行/拒绝生效。

规则顺序的三条硬约束

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

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 订阅链接用,别漏)。样例文件见 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。

9. 测试与验收

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

上线验收

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

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