Files
pangolin/docs/private-dest-acl-design.html
T

252 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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": [
"&lt;我的设备级 dp_uuid&gt;",
"&lt;我的账户级 dp_uuid/sub 用)&gt;"
],
"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": ["&lt;我的uuid…&gt;"], "domain": ["brain.51yanmei.com","git.51yanmei.com"],
"outbound": "direct"},
{"auth_user": ["&lt;我的uuid…&gt;"], "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>&lt;StateDir&gt;/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>&lt;StateDir&gt;/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 = '&lt;我的邮箱&gt;' AND d.dp_uuid IS NOT NULL
UNION
SELECT dp_uuid FROM users WHERE email = '&lt;我的邮箱&gt;';"
# 填进 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> 改坏 → 规则仍在、日志有 ERRORfail-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>