只有指定 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