344dfe9a82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
24 KiB
HTML
252 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>私有目的地访问控制(设计 · 节点侧 ACL)</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||
p{margin:10px 0}
|
||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||
pre code{background:none;padding:0;color:inherit}
|
||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||
.card.root{border-left:3px solid var(--accent)}
|
||
.card.bad{border-left:3px solid var(--bad)}
|
||
.card.ok{border-left:3px solid var(--ok)}
|
||
.card h3{margin-top:0}
|
||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;display:block;overflow-x:auto}
|
||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||
th{color:var(--fg2);font-weight:600;font-size:13px;white-space:nowrap}
|
||
td code{font-size:.85em}
|
||
ul,ol{padding-left:22px;margin:10px 0}
|
||
li{margin:5px 0}
|
||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||
.small{color:var(--fg2);font-size:13px}
|
||
a{color:var(--accent2)}
|
||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||
b{color:#fff}
|
||
.flow{font-family:var(--mono);font-size:12.5px;line-height:1.9;color:#cdd3df;background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:16px;overflow-x:auto}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<a class="back" href="index.html">← 文档索引</a>
|
||
<h1>私有目的地访问控制(设计 · 节点侧 ACL)</h1>
|
||
<p class="sub">只有指定 dp_uuid 才能经 pangolin 出口访问家庭内网服务 · 2026-07-23</p>
|
||
|
||
<div class="lead">
|
||
<p>pangolin 出口 IP(<code>103.119.13.48</code>)是家庭内网服务在 ali 侧的唯一准入凭据——<code>brain</code>/<code>nas</code>/<code>git</code>/<code>win.51yanmei.com</code> 全靠「来自这个 IP」放行。单用户时它等价于「只有我」;<b>现在 pangolin 有 5 个账号,它退化成了「所有 pangolin 用户」</b>。本设计在节点 sing-box 上补一道按 dp_uuid 的闸,把语义拉回「只有我」。</p>
|
||
</div>
|
||
|
||
<h2>1. 范围</h2>
|
||
<p><b>只做「私有服务白名单」一件事。</b>不做通用租户黑白名单、不做上网管控、不做管理后台、不要求实时生效。这些在评审中被显式砍掉——它们的复杂度大半来自「多策略 × 多组 × 实时下发」,而本需求三个都不需要。</p>
|
||
|
||
<h3>问题的真实边界</h3>
|
||
<p>经 pangolin 出口能摸到的家里服务,各自本来就有鉴权,<b>唯一裸奔的是 brain</b>:</p>
|
||
<table>
|
||
<tr><th>服务</th><th>入口</th><th>自带鉴权</th></tr>
|
||
<tr><td>git.51yanmei.com</td><td>ali 443 → Gitea</td><td>Gitea 登录 <span class="tag ok">有</span></td></tr>
|
||
<tr><td>nas.51yanmei.com:5001</td><td>frp → DSM</td><td>账号 + 2FA + 自动封锁 <span class="tag ok">有</span></td></tr>
|
||
<tr><td>nas/win SSH (10022/10023)</td><td>frp</td><td>密钥登录 <span class="tag ok">有</span></td></tr>
|
||
<tr><td>win.51yanmei.com:3389</td><td>frp → RDP</td><td>NLA + 密码 <span class="tag ok">有</span></td></tr>
|
||
<tr><td><b>brain.51yanmei.com</b></td><td>ali 443 → 静态站</td><td><span class="tag bad">无</span></td></tr>
|
||
</table>
|
||
<p>所以本设计提供的是<b>网络层前置闸</b>(别人连登录页都摸不到),而不是唯一防线。</p>
|
||
|
||
<h2>2. 关键决策</h2>
|
||
<table>
|
||
<tr><th>维度</th><th>选择</th><th>理由</th></tr>
|
||
<tr><td>执行点</td><td><b>节点 sing-box route.rules</b></td><td>控制面不在数据路径上;sing-box 无外部授权钩子(实测 <code>external_controller</code>/<code>auth_request</code>/<code>external_acl</code>/<code>authenticator</code>/<code>webhook</code>/<code>http_provider</code>/<code>external_rule_provider</code> 七个字段名全部 <code>unknown field</code>)。客户端执行会被用户自行修改,且 <code>/sub/{token}</code> 路径天然绕过。</td></tr>
|
||
<tr><td>名单来源</td><td><b>节点本地 <code>acl.json</code></b></td><td>照 <code>warp.json</code> 现成骨架。控制面/DB/proto/客户端/管理后台<b>一律不动</b>,改动收敛在 <code>internal/agentd/</code> 一个包。</td></tr>
|
||
<tr><td>主体粒度</td><td><b>dp_uuid 列表</b></td><td>dp_uuid 每设备一个、懒生成、<b>从不轮换</b>(<code>RotateCredential</code> 协议在但控制面无调用方),写死后基本一劳永逸。当前我的账号名下仅 1 台设备有 dp_uuid。</td></tr>
|
||
<tr><td>目的地表达</td><td><b>域名 + IP:端口 两类</b></td><td>brain/git 与公开的 jiu/travel/sudoku 共用 ali 的 443,只能靠 SNI 区分;DSM/RDP/SSH 用独立端口,直接 IP+端口匹配。</td></tr>
|
||
<tr><td>sniff</td><td><b>复用现有</b></td><td>WARP 已在节点启用,线上 route 块本来就是 <code>[{"action":"sniff"},{...warp}]</code>——域名匹配零额外成本。</td></tr>
|
||
<tr><td>失效方向</td><td><b>fail-closed</b></td><td>与 WARP 相反。见 §5,这是本设计唯一容易写错的地方。</td></tr>
|
||
<tr><td>生效方式</td><td><b>agent SIGHUP 触发重渲染</b></td><td>不要求延时,但重启 agent 会让 sing-box 走冷启动、踢掉全部在线用户(见 §6),故加一个轻量信号处理。</td></tr>
|
||
</table>
|
||
|
||
<h2>3. 现状事实(设计所依赖的)</h2>
|
||
<ul>
|
||
<li><b>WARP 已启用</b>:<code>/etc/pangolin-agent/warp.json</code> 存在;线上 <code>/etc/sing-box/config.json</code> 的 route 块实测为 <code>{"final":"direct","rules":[{"action":"sniff"},{"domain_suffix":["reddit.com",…],"outbound":"warp"}]}</code>。</li>
|
||
<li><b>route 块是整块覆盖</b>:<code>internal/agentd/render.go:50-55</code> —— <code>if warp.active() { cfg["endpoints"]=…; cfg["route"] = warp.warpRoute() }</code>。<span class="tag warn">关键</span> ACL 必须与 WARP <b>合并</b>,直接赋值会把 WARP 分流干掉。</li>
|
||
<li><b>节点本地文件每次渲染重读</b>:<code>internal/agentd/singbox.go:319-326</code>(<code>LoadWarpConfig</code>),编辑后任一渲染即生效。</li>
|
||
<li><b>热重载语义</b>:<code>internal/agentd/singbox.go:340-359</code> —— 进程内首次渲染走 <code>Restart()</code>(<code>systemctl restart sing-box</code>),之后走 <code>Reload()</code>(SIGHUP,取不到 PID 时自动回退 Restart)。</li>
|
||
<li><b>控制面 DB</b>:<code>DB_DRIVER=sqlite</code>,<code>DB_DSN=/var/lib/pangolin/pangolin.db</code>(pangolin1 内存紧,刻意用 SQLite)。</li>
|
||
<li><b>两层 dp_uuid 并存</b>:设备级 <code>devices.dp_uuid</code>(connect 用)与账户级 <code>users.dp_uuid</code>(<code>/sub/{token}</code> 订阅链接仍在用,<code>internal/httpapi/subscription.go:104</code>)。<span class="tag warn">关键</span> 白名单必须<b>两个都包含</b>,否则自己用订阅链接反而进不去。</li>
|
||
</ul>
|
||
|
||
<h2>4. 设计</h2>
|
||
|
||
<h3>4.1 配置文件 <code>/etc/pangolin-agent/acl.json</code></h3>
|
||
<pre><code>{
|
||
"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] }
|
||
]
|
||
}</code></pre>
|
||
<p><code>targets</code> 每项是一个「目的地形状」,字段直接对应 sing-box route rule 的匹配字段(同一项内多字段是 AND,字段内多值是 OR)。刻意不做自研 DSL——形状即 sing-box 语义,减少一层翻译和一类 bug。</p>
|
||
|
||
<h3>4.2 渲染出的 route 块</h3>
|
||
<p>每个 target 产出<b>一对</b>规则:先放行白名单、再兜底拒绝。顺序即优先级,首条命中即返回。</p>
|
||
<pre><code>"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"
|
||
}</code></pre>
|
||
<div class="card bad">
|
||
<h3>血泪教训:VLESS 只认 <code>auth_user</code>,不认 <code>user</code>(2026-07-23 生产验证)</h3>
|
||
<p>放行规则匹配凭证必须用 <b><code>auth_user</code></b> 而非 <code>user</code>。<code>sing-box check</code> 对两者<b>都语法通过</b>,但 <code>user</code> 字段对 VLESS/REALITY 入站<b>运行时根本不匹配</b>——放行规则永不命中,结果<b>连白名单用户也被兜底拒绝</b>(全员进不去)。上线时正是踩了这个:节点端 git 一直 000,把本机 uuid 换着法加进白名单都没用。本地起一对真 VLESS 实例实测才定位:<code>auth_user:["good"]</code> 生效(good 通/bad 被 block),<code>user</code> 不生效。<b>只跑 <code>sing-box check</code> 不足以验收访问控制,必须真连接跑一次。</b></p>
|
||
</div>
|
||
<p class="small">已用本机 sing-box 1.13.13 <b>真 VLESS 连接</b>验证 <code>auth_user</code> 匹配 + <code>resolve</code> 后 ip_cidr/domain 仍匹配;节点端以 git 做「白名单一进一出」验证 per-user 放行/拒绝生效。</p>
|
||
|
||
<div class="card root">
|
||
<h3>规则顺序的三条硬约束</h3>
|
||
<ol>
|
||
<li><code>{"action":"sniff"}</code> <b>必须最先且只出现一次</b>——域名匹配依赖它取 TLS SNI。合并时若 WARP 已产出 sniff,不得重复追加。</li>
|
||
<li><b>全部 ACL 规则(放行 + 拒绝)排在 WARP 规则之前</b>。当前两者目的地不重叠(reddit vs 我的域名),但被拒绝的目的地永远不该有机会被路由到 warp 出口。</li>
|
||
<li><b>放行必须排在拒绝之前</b>,且两者目的地条件<b>逐字相同</b>。任何不对称都会造成「我自己也被拒」或「有人漏网」。</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<h3>4.3 与 WARP 的合并</h3>
|
||
<p>把 <code>render.go</code> 里 <code>cfg["route"] = warp.warpRoute()</code> 的直接赋值,改为由一个 <code>buildRoute(acl, warp)</code> 统一产出:</p>
|
||
<table>
|
||
<tr><th>ACL</th><th>WARP</th><th>route 块</th></tr>
|
||
<tr><td>关</td><td>关</td><td><b>不产出</b>(与现有配置逐字节一致,保持向后兼容)</td></tr>
|
||
<tr><td>关</td><td>开</td><td>现状不变:<code>sniff + warp 规则</code></td></tr>
|
||
<tr><td>开</td><td>关</td><td><code>sniff + ACL 规则</code>,<code>final:"direct"</code></td></tr>
|
||
<tr><td>开</td><td>开</td><td><code>sniff + ACL 规则 + warp 规则</code>,<code>final:"direct"</code>;<code>endpoints</code> 照旧由 WARP 注入</td></tr>
|
||
</table>
|
||
|
||
<h2>5. 失效语义 <span class="tag bad">最易写错</span></h2>
|
||
<div class="card bad">
|
||
<p><b>WARP 的先例是 fail-open,绝不能照抄。</b><code>singbox.go:321-324</code> 加载失败即 <code>warp = nil</code> 放弃分流;<code>warp.go:47-58</code> 的 <code>active()</code> 任一字段缺失即返回 false,注释写着「宁可不分流(全直连)」。对分流这是对的,<b>对访问控制方向正好反了</b>——同样的写法会让一次手抖的编辑把私有服务对全体 pangolin 用户敞开,而且是静默的。</p>
|
||
<p>本设计的规则:</p>
|
||
<ul>
|
||
<li><b>解析失败 / 文件读不出</b> → <b>保留上一次成功加载的 ACL</b>(agent 内存中持有 last-good),<b>不清空规则</b>,打 ERROR 日志。</li>
|
||
<li><b>last-good 必须落盘</b>:每次成功加载后把规范化结果写入 <code><StateDir>/acl.last-good.json</code>(照 <code>state.json</code> 的持久化套路)。否则 agent 一重启,内存里的 last-good 就没了,fail-closed 只在进程生命周期内成立——而进程重启恰恰是最可能撞上坏配置的时刻。</li>
|
||
<li><b>agent 冷启动</b>:先读 <code>acl.json</code>;失败则回退 <code>acl.last-good.json</code>;<b>两者都失败才不产出 ACL 规则</b>,同时打 ERROR 并告警。<span class="tag warn">注意</span> 这最后一档的实际后果是 fail-<b>open</b>(私有服务对全体 pangolin 用户敞开)——之所以只能如此,是因为白名单与<b>目的地清单同在一个文件</b>,文件全丢时连「该拒绝哪些目的地」都无从得知,无法凭空拒起。这也是为什么 last-good 落盘是必需项而非优化项。</li>
|
||
<li><b><code>allow_dp_uuids</code> 为空数组</b> → <b>仍产出拒绝规则</b>,即「谁都不许进」。空名单的语义是「没有人」,不是「所有人」。</li>
|
||
<li><b>关闭 ACL 只有一条合法途径</b>:显式写 <code>"enabled": false</code>。删文件不算(走 last-good 分支)。</li>
|
||
<li><b>agent 长期离线</b> → 节点保留旧配置,被移除的 uuid 在 agent 恢复前仍可进入。这是可接受的(不要求延时),但要在 runbook 里写明:<b>撤销某人的访问后,须确认 agent 已重新渲染</b>。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<h2>6. 生效方式</h2>
|
||
<p>编辑 <code>acl.json</code> 本身<b>不会</b>触发渲染——<code>markDirty()</code> 只在凭证变更时被调用。可选做法与代价:</p>
|
||
<table>
|
||
<tr><th>做法</th><th>代价</th></tr>
|
||
<tr><td><code>systemctl restart pangolin-agent</code></td><td><span class="tag bad">会踢人</span> 新进程内 <code>s.started=false</code>,首次渲染走 <code>Restart()</code> 即 <code>systemctl restart sing-box</code>,<b>全部在线用户瞬断</b></td></tr>
|
||
<tr><td>等下一次凭证变更顺带生效</td><td>时机不可控,可能几小时不生效</td></tr>
|
||
<tr><td><b>给 agent 加 SIGHUP 处理 → <code>markDirty()</code></b> <span class="tag ok">采用</span></td><td>约 10 行;走 sing-box SIGHUP 热重载,<b>不断线</b>;<code>systemctl reload pangolin-agent</code> 即可</td></tr>
|
||
</table>
|
||
|
||
<h2>7. 代码改动清单</h2>
|
||
<table>
|
||
<tr><th>文件</th><th>改动</th></tr>
|
||
<tr><td><code>internal/agentd/acl.go</code> <span class="tag info">新增</span></td><td><code>ACLConfig</code> 结构、<code>LoadACLConfig</code>、<code>active()</code>(fail-closed 语义)、<code>aclRules()</code> 产出规则对。镜像 <code>warp.go</code> 骨架。</td></tr>
|
||
<tr><td><code>internal/agentd/render.go</code></td><td><code>renderSingboxConfig</code> 增 <code>acl</code> 入参;抽出 <code>buildRoute(acl, warp)</code> 取代 <code>cfg["route"] = warp.warpRoute()</code>(§4.3 四态表)</td></tr>
|
||
<tr><td><code>internal/agentd/singbox.go</code></td><td><code>RenderConfig</code> 每次重读 <code>acl.json</code>(紧邻现有 warp 重读);持有 last-good ACL 字段,成功加载后落盘 <code>acl.last-good.json</code>,冷启动按 <code>acl.json → last-good → 无</code> 顺序回退</td></tr>
|
||
<tr><td><code>internal/agentd/config.go</code></td><td>新增 <code>ACLConfigPath</code>,默认 <code><StateDir>/acl.json</code></td></tr>
|
||
<tr><td><code>internal/agentd/agent.go</code> 或 <code>cmd/agent</code></td><td>SIGHUP → <code>markDirty()</code></td></tr>
|
||
<tr><td><code>internal/agentd/acl_test.go</code> <span class="tag info">新增</span></td><td>见 §9</td></tr>
|
||
</table>
|
||
<p><b>不动</b>:控制面、数据库(零 migration)、proto、客户端、管理后台、CI。改动全部收敛在 <code>internal/agentd/</code>。</p>
|
||
|
||
<h2>8. 名单维护 Runbook</h2>
|
||
<p>新增/更换设备后,重新生成白名单(<b>UNION 那一半是账户级 dp_uuid,供 <code>/sub</code> 订阅链接用,别漏</b>)。样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。</p>
|
||
<pre><code>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</code></pre>
|
||
<p class="small">设备的 dp_uuid 是<b>首次 connect 时懒生成</b>的:新设备装好后要先连一次,才查得到 dp_uuid。</p>
|
||
|
||
<h2>9. 测试与验收</h2>
|
||
<h3>单元测试(<code>acl_test.go</code>,表驱动,镜像 <code>warp_test.go</code>)</h3>
|
||
<ul>
|
||
<li>解析:合法配置 / 缺字段 / 坏 JSON / 文件不存在</li>
|
||
<li><b>fail-closed</b>:坏 JSON 时保留 last-good(<b>规则不消失</b>);<code>allow_dp_uuids: []</code> 时<b>仍产出拒绝规则</b>;<code>enabled:false</code> 才真正关闭</li>
|
||
<li><b>last-good 跨重启</b>:成功加载后 <code>acl.last-good.json</code> 已落盘;<b>新建 agent 实例</b>(模拟重启)+ 坏 <code>acl.json</code> → 仍产出规则;两文件皆坏 → 无规则且有 ERROR</li>
|
||
<li>渲染:四态矩阵(ACL×WARP 开关)逐一断言 route 结构</li>
|
||
<li>顺序:<code>sniff</code> 唯一且最先;放行先于拒绝;ACL 全部先于 WARP</li>
|
||
<li>对称性:同一 target 的放行与拒绝规则,目的地条件逐字相同</li>
|
||
<li>产物合法性:渲染结果喂 <code>sing-box check</code> 通过(<b>注意:check 只验语法,不验 <code>auth_user</code> 运行时是否真匹配 VLESS——见 §4.2 血泪教训</b>)</li>
|
||
<li><b>运行时匹配(不可省)</b>:起真 VLESS 连接实测 <code>auth_user</code> 放行/拒绝生效;或节点端以某个白名单域名做「本机 uuid 一进一出」验证 per-user 生效。仅 <code>sing-box check</code> 绿=未验收。</li>
|
||
</ul>
|
||
<h3>上线验收</h3>
|
||
<ol>
|
||
<li>我的设备:brain 首页 200、DSM 5001 可登录、<code>ssh nas-r</code> 通</li>
|
||
<li>另一个账号的设备:以上全部被拒(连接被 reject,非超时)</li>
|
||
<li>公开站不受影响:jiu / travel / sudoku / pay 在两个账号下均正常</li>
|
||
<li>WARP 未被破坏:reddit 仍走 warp 出口</li>
|
||
<li>把 <code>acl.json</code> 改坏 → 规则仍在、日志有 ERROR(fail-closed 实证)</li>
|
||
<li><code>systemctl reload pangolin-agent</code> 期间在线用户不掉线</li>
|
||
</ol>
|
||
|
||
<h2>10. 刻意不做(YAGNI)</h2>
|
||
<ul>
|
||
<li>用户组 / 租户表、多策略、优先级引擎——只有一个白名单,一张表都不建</li>
|
||
<li>管理后台页面——编辑一个 JSON 文件即可,加页面要动 9 处</li>
|
||
<li>控制面下发 / 增量推送——名单近乎不变,节点本地文件足够,还顺带避开了「策略必须进 ConfigSnapshot 否则重连被整表替换抹掉」这个坑</li>
|
||
<li>上网管控黑名单(B 场景)——本轮砍掉</li>
|
||
<li>remote <code>rule_set</code>——目的地就几条,内联即可;远端规则集会引入以 <code>update_interval</code> 计的第二条延时链(小时级),且「节点只有不透明 blob」是假收益(节点自己下载的内容当然读得到,还落 cache)</li>
|
||
</ul>
|
||
|
||
<h2>11. 演进路径</h2>
|
||
<p>若将来真要做多租户策略,<b>渲染层可原样保留</b>,只把名单来源换掉:给 <code>Credential</code>(<code>internal/pb/agentv1/types.go</code> + <code>agentd/singbox.go</code> 的 <code>Cred</code>)加一个 <code>PrivateAccess bool</code>。该字段<b>同时走增量推送与 Register 全量快照两条通道</b>,天然免疫「重连被 <code>ApplyConfig(snap,true)</code> 整表替换」的问题。届时 <code>acl.json</code> 退化为只保留 <code>targets</code>,<code>allow_dp_uuids</code> 由控制面下发。</p>
|
||
|
||
<h2>12. 已知残留</h2>
|
||
<ul>
|
||
<li><span class="tag warn">泄露</span> 节点配置里我的几台设备 uuid 会出现在同一条规则中(自我关联),且目的地明文可见。目的地本就在公网 DNS 里,泄露面小;但这确实<b>弱化了 <code>render.go:14</code> 的「节点只见不透明 dp_uuid」不变式</b>,属于知情接受,需在该注释处补一行说明。</li>
|
||
<li><span class="tag warn">缺闸</span> 守红线的 <code>assertNoIdentityFields</code>(<code>agentd/singbox_test.go</code>)只作用于 <code>state.json</code>,<b>从不检查渲染出的 sing-box 配置</b>。本设计不扩大这个缺口,但也没有补上——补闸另开。</li>
|
||
<li><span class="tag bad">架构限制</span> <b>443 vhost 的判定依赖 SNI,可被绕过。</b>对 <code>brain</code>/<code>git</code>,节点侧唯一的区分手段是 sniff 出的 SNI。攻击者(持有效 dp_uuid 的其他 pangolin 用户)向 <code>182.92.213.171:443</code> 发起<b>不带 SNI</b> 的 TLS、握手后用 <code>Host: brain.51yanmei.com</code> 头访问,则不匹配任何 <code>domain</code> 规则、也不匹配 <code>ip_cidr</code>(443 不在端口清单里)→ 落 <code>final:direct</code> → nginx 按 Host 路由放行。这在 sing-box 层<b>无法闭合</b>——不能整封 <code>:443</code>,否则 jiu/travel/sudoku/pay 一起死。因此下面这条 brain 鉴权不是「可选纵深」,而是本闸对 brain 的<b>前置条件</b>。</li>
|
||
<li><span class="tag warn">客户端前置</span> <b>私有域名必须走隧道,否则本闸无从谈起。</b>节点 ACL 只对经隧道进入 sing-box 的流量生效;若客户端把 <code>brain</code>/<code>nas</code> 直连(smartRoute 把国内 IP 分流成直连),则流量根本不到节点、直接打 ali,brain 得 403(nginx deny 非白名单源 IP)、nas 超时。故 <code>brain/nas/git/win.51yanmei.com</code> 必须在控制面 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code> 里(已配),且<b>客户端改动后要重连一次</b>才拿到新分流规则。上线验证时 git 走隧道正常、brain/nas 因客户端未重连仍直连——排查时先确认「域名是否真走了隧道」(curl -v 看连的是不是 pangolin 出口),再判 ACL。</li>
|
||
<li><span class="tag ok">已做(2026-07-23)</span> <b>brain 已加 nginx basic auth</b> 作纵深防御:ali 的 <code>/etc/nginx/conf.d/brain.conf</code> 在原有 <code>allow 103.119.13.48; deny all</code> 之上叠加 <code>auth_basic</code>(<code>satisfy</code> 默认 <code>all</code> → 源 IP 白名单 <b>与</b> 口令二者都需满足),口令存 Bitwarden「brain basic auth」,htpasswd 仅存 apr1 哈希(明文不落 ali)。acme 通道(:80)与 <code>robots.txt</code> 免密。这样即便本节点 ACL 闸失效(agent 挂了、配置手抖)或被上面的 SNI 手法绕过,brain——唯一无自带鉴权的私有服务——仍不裸奔。</li>
|
||
</ul>
|
||
|
||
<p class="small" style="margin-top:40px">相关:<code>~/code/brain/docs/remote-access-brain-domain.html</code>(brain 外网入口与出口 IP 白名单)· baize 台账 <code>~/code/baize/data/{hosts,domains}.yaml</code></p>
|
||
</div>
|
||
</body>
|
||
</html>
|