docs(agent): 私有目的地 ACL 设计方案与实现计划

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-23 01:01:19 +08:00
parent 74d8c857be
commit 9cbffbb727
6 changed files with 2789 additions and 11 deletions
+10
View File
@@ -44,6 +44,11 @@
</div>
<h2>设计方案 / Specs</h2>
<a class="doc" href="private-dest-acl-design.html">
<div class="t">私有目的地访问控制(节点侧 ACL<span class="tag html">HTML</span></div>
<div class="d">pangolin 出口 IP 曾等价于「只有我」,多用户后退化成「所有 pangolin 用户」——家庭内网服务(brain/nas/git/win.51yanmei.com)因此对全体用户敞开。在节点 sing-box 加一道按 dp_uuid 的闸:节点本地 acl.json(照 warp.json 骨架)→ 每个目的地渲染「放行白名单 + 兜底拒绝」一对规则,与 WARP 规则合并而非覆写。控制面/DB/proto/客户端/管理后台一律不动,改动收敛在 internal/agentd/。关键点:失效方向必须 fail-closed(与 WARP 的 fail-open 先例相反)、白名单须同时含设备级与账户级 dp_uuid(否则 /sub 订阅链接把自己锁在外面)、生效走 agent SIGHUP 以免重启踢掉全部在线用户。含四态渲染矩阵、规则顺序三条硬约束、名单维护 runbook、验收清单。</div>
<div class="path">docs/private-dest-acl-design.html</div>
</a>
<a class="doc" href="cicd-design.html">
<div class="t">CI/CD 全流程(tag 触发编译/发版/部署)<span class="tag html">HTML</span></div>
<div class="d">#30。参考 jiu 的 scripts/ci + .gitea/workflowstag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。</div>
@@ -86,6 +91,11 @@
</a>
<h2>实现计划 / Plans</h2>
<a class="doc" href="private-dest-acl-plan.html">
<div class="t">私有目的地 ACL(节点侧)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-23-private-dest-acl.md</code>(含 checkbox)。设计见 <code>docs/private-dest-acl-design.html</code>。6 个 TDD 任务:①ACL 配置类型 + fail-closed 的 active()(空白名单=拒绝所有人,非「未启用」)→ ②rules() 产出「全部放行→全部拒绝」规则对,两侧目的地条件逐字相同 → ③buildRoute 合并 ACL 与 WARP(四态矩阵;现状 <code>cfg["route"]</code> 是整块覆盖,直接赋值会干掉 WARP)→ ④渲染时读 ACL + 内存/磁盘双层 last-good 兜底(fail-closed 须跨重启成立)→ ⑤SIGHUP 触发热重渲染(重启 agent 会冷启 sing-box 踢掉全部在线用户)→ ⑥配置样例与 runbook。改动收敛在 <code>internal/agentd/</code> + <code>cmd/agent</code>,零 migration/零 proto/零客户端改动。</div>
<div class="path">docs/private-dest-acl-plan.html · 真相源 docs/superpowers/plans/2026-07-23-private-dest-acl.md</div>
</a>
<a class="doc" href="frontend-ds-refactor-plan.html">
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
+243
View File
@@ -0,0 +1,243 @@
<!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"},
{"user": ["&lt;我的uuid…&gt;"], "domain": ["brain.51yanmei.com","git.51yanmei.com"],
"outbound": "direct"},
{"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>
<p class="small">已用本机 sing-box 1.13.13 <code>sing-box check</code> 验证形状合法(exit 0)。</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>):</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> 通过</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 info">建议</span> 本闸失效时(agent 挂了、配置写错)brain 会重新变成对全体 pangolin 用户可读的裸站。建议<b>另外给 brain 加一层 mTLS 或 basic auth</b> 作纵深防御,约一小时工作量,与本设计正交。</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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+132 -9
View File
@@ -327,12 +327,12 @@ header .header-meta{color:var(--fg-soft)}
<header>
<div class="wrap">
<h1>feature+windows — 项目 TODO</h1>
<div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div>
<div class="header-meta">生成于 2026-07-23 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>18</strong>全部</div>
<div class="stat-pill"><strong>8</strong>待开始</div>
<div class="stat-pill"><strong>0</strong>开发中</div>
<div class="stat-pill"><strong>2</strong>待验收</div>
<div class="stat-pill"><strong>22</strong>全部</div>
<div class="stat-pill"><strong>9</strong>待开始</div>
<div class="stat-pill"><strong>1</strong>开发中</div>
<div class="stat-pill"><strong>4</strong>待验收</div>
<div class="stat-pill"><strong>8</strong>已验收</div>
</div>
@@ -376,7 +376,7 @@ header .header-meta{color:var(--fg-soft)}
<div class="section-block" id="section-open">
<div class="section-title st-open" data-toggle="open">
📋 待开始 <span class="s-count">8</span>
📋 待开始 <span class="s-count">9</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-open">
@@ -599,6 +599,37 @@ header .header-meta{color:var(--fg-soft)}
</div>
</li>
<li class="todo-card s-open"
data-id="21"
data-level="mid"
data-status="open"
data-tier=""
data-tags="">
<div class="card-header">
<span class="item-id">#21</span>
<span class="item-title">邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活</span>
<div class="card-badges">
<span class="tag status-badge s-open">待开始</span>
<span class="tag t-high">重要</span>
</div>
</div>
<div class="card-footer">
<div class="tag-row"></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-13</span>
</div>
</div>
</li>
<li class="todo-card s-open"
data-id="3"
data-level="low"
@@ -634,18 +665,48 @@ header .header-meta{color:var(--fg-soft)}
</div>
<div class="section-block" id="section-doing">
<div class="section-title st-doing" data-toggle="doing">
🔨 开发中 <span class="s-count">0</span>
🔨 开发中 <span class="s-count">1</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-doing">
<ul class="todo-list" id="list-doing">
<p class="empty-tip">暂无条目</p>
<li class="todo-card s-doing"
data-id="23"
data-level="mid"
data-status="doing"
data-tier=""
data-tags="">
<div class="card-header">
<span class="item-id">#23</span>
<span class="item-title">私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closedSIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md</span>
<div class="card-badges">
<span class="tag status-badge s-doing">开发中</span>
<span class="tag t-high">重要</span>
</div>
</div>
<div class="card-footer">
<div class="tag-row"></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-23</span>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="section-block" id="section-done">
<div class="section-title st-done" data-toggle="done">
🔍 待验收 <span class="s-count">2</span>
🔍 待验收 <span class="s-count">4</span>
<span class="s-arrow">▴ 收起</span>
</div>
<div class="section-list-wrap " id="list-wrap-done">
@@ -714,6 +775,68 @@ header .header-meta{color:var(--fg-soft)}
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-07</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="20"
data-level="mid"
data-status="done"
data-tier=""
data-tags="">
<div class="card-header">
<span class="item-id">#20</span>
<span class="item-title">Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<button class="reject-btn" data-id="20" data-title="Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)">拒绝验收</button>
</div>
</div>
<div class="card-footer">
<div class="tag-row"></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-12</span>
</div>
</div>
</li>
<li class="todo-card s-done"
data-id="22"
data-level="mid"
data-status="done"
data-tier=""
data-tags="">
<div class="card-header">
<span class="item-id">#22</span>
<span class="item-title">客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开</span>
<div class="card-badges">
<span class="tag status-badge s-done">待验收</span>
<span class="tag t-high">重要</span>
<button class="reject-btn" data-id="22" data-title="客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开">拒绝验收</button>
</div>
</div>
<div class="card-footer">
<div class="tag-row"></div>
<div class="item-meta">
<span class="tag owner-agent">🤖 agent</span>
<span class="meta-date">🕐 2026-07-16</span>
</div>
</div>
</li>
+58 -2
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "feature+windows — 项目 TODO",
"updated_at": "2026-07-07T17:45:18.036Z"
"updated_at": "2026-07-22T16:59:57.735Z"
},
"seq": 19,
"seq": 23,
"items": [
{
"id": 1,
@@ -317,6 +317,62 @@
"proposed_at": "2026-07-07T15:41:36.536Z",
"approved_at": "2026-07-07T15:54:54.386Z"
}
},
{
"id": 20,
"title": "Tablet(iPad)补通知入口 — tablet_shell 顶栏接铃铛 + content() 加 NavView.notifications 真屏(现平板铃铛/通知不可达,桌面/手机正常)",
"desc": null,
"level": "mid",
"tier": null,
"tags": [],
"owner": "agent",
"status": "done",
"created_at": "2026-07-12T10:48:49.013Z",
"done": false,
"completed_at": "2026-07-13T02:20:58.685Z",
"version": "site-v0.0.13"
},
{
"id": 21,
"title": "邀请/任务奖励明细列表 — GET /v1/invite/events(读 audit_log:action/target/days/时间)+ App 邀请页明细区(逐行:日期·事件·+N天)+ 六语;审计数据已齐(audit_log target 现带对方id),纯展示层活",
"desc": null,
"level": "mid",
"tier": null,
"tags": [],
"owner": "agent",
"status": "open",
"created_at": "2026-07-13T04:29:47.253Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 22,
"title": "客户端私有服务域名分流(BuildClientConfig): nas/git/win.yanmeiai.com 本地DNS+resolve+私网直连+强制走隧道,smartRoute 可重开",
"desc": null,
"level": "mid",
"tier": null,
"tags": [],
"owner": "agent",
"status": "done",
"created_at": "2026-07-15T21:40:35.427Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 23,
"title": "私有目的地 ACL(节点侧):只有白名单 dp_uuid 能经 pangolin 出口访问 brain/nas/git/win.51yanmei.com。节点本地 acl.json + agent 渲染放行/拒绝规则对,fail-closedSIGHUP 热生效。设计 docs/private-dest-acl-design.html,计划 docs/superpowers/plans/2026-07-23-private-dest-acl.md",
"desc": null,
"level": "mid",
"tier": null,
"tags": [],
"owner": "agent",
"status": "doing",
"created_at": "2026-07-22T16:59:41.945Z",
"done": false,
"completed_at": null,
"version": null
}
]
}