docs(agent): 修复计划未闭合代码块 + Task 6 改用常驻测试校验样例

未闭合的 ```go 让 Task 4-6 的标题落进代码块,task-brief 无法提取;
Task 6 原用 go run 校验样例文件,该文件在 module 外无法解析 import,
改为常驻测试 TestACLExampleFileStaysValid,并断言样例 uuid 为占位符
以防真实 dp_uuid 入库。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-23 01:12:06 +08:00
parent e4d014ba99
commit 6df7c9de44
2 changed files with 387 additions and 403 deletions
+349 -382
View File
@@ -718,402 +718,369 @@ func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
// 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有
// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id),
// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化,
// 设计与权衡见 docs/private-dest-acl-design.html §12。
// 设计与权衡见 docs/private-dest-acl-design.html §12。</code></pre>
<p>⑤ 修改 <code>server/internal/agentd/singbox.go</code> 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):</p>
<pre><code> return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;BuildRoute|Warp|Render&#x27; -v</code></pre>
<p>Expected: PASS。特别确认既有的 <code>TestRender_NoWarp_NoRouteSection</code><code>TestRender_Warp_InjectsEndpointAndRoute</code> 仍绿——这两条守着向后兼容。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/render.go server/internal/agentd/warp.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
git commit -m &quot;refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP&quot;</code></pre>
<hr>
<h3>Task 4: 渲染时读取 ACLlast-good 内存 + 落盘兜底</h3>
<p><b>Files:</b></p>
<ul>
<li>Modify: <code>server/internal/agentd/singbox.go:49-74</code><code>SingBox</code><code>lastGoodACL</code> 字段)、<code>:308-327</code><code>RenderConfig</code> 读 ACL</li>
<li>Modify: <code>server/internal/agentd/acl.go</code>(新增 <code>persistACL</code></li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加 last-good 测试)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: Task 1 的 <code>LoadACLConfig</code> / <code>Config.ACLConfigPath</code> / <code>Config.ACLLastGoodPath()</code>Task 3 的 <code>renderSingboxConfig</code> 六参签名;既有包内函数 <code>atomicWrite(path string, data []byte, perm os.FileMode) error</code></li>
<li>Produces: <code>func (s *SingBox) loadACL() *ACLConfig</code><code>func persistACL(path string, ac *ACLConfig) error</code></li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
⑤ 修改 `server/internal/agentd/singbox.go` 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):
</code></pre>
<p> return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)</p>
<pre><code>
- [ ] **Step 4: 跑测试确认通过**
</code></pre>
<p>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;BuildRoute|Warp|Render&#x27; -v</p>
<pre><code>
Expected: PASS。特别确认既有的 `TestRender_NoWarp_NoRouteSection` 与 `TestRender_Warp_InjectsEndpointAndRoute` 仍绿——这两条守着向后兼容。
if _, err := sb.RenderConfig(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
t.Fatalf(&quot;last-good 未落盘: %v&quot;, err)
}
}
- [ ] **Step 5: 提交**
</code></pre>
<p>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...</p>
<p>git add server/internal/agentd/render.go server/internal/agentd/warp.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go</p>
<p>git commit -m &quot;refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP&quot;</p>
<pre><code>
---
// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。
func TestACL_BrokenFileKeepsInMemoryLastGood(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb.RenderConfig(); err != nil {
t.Fatal(err)
}
### Task 4: 渲染时读取 ACLlast-good 内存 + 落盘兜底
writeACL(t, cfg.ACLConfigPath, `{&quot;enabled&quot;: true,`) // 手抖写坏
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), &quot;reject&quot;) {
t.Fatal(&quot;acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开&quot;)
}
}
**Files:**
- Modify: `server/internal/agentd/singbox.go:49-74``SingBox` 增 `lastGoodACL` 字段)、`:308-327``RenderConfig` 读 ACL
- Modify: `server/internal/agentd/acl.go`(新增 `persistACL`
- Modify: `server/internal/agentd/acl_test.go`(追加 last-good 测试)
// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。
func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
sb1 := NewSingBox(cfg, nil)
sb1.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
if _, err := sb1.RenderConfig(); err != nil {
t.Fatal(err)
}
**Interfaces:**
- Consumes: Task 1 的 `LoadACLConfig` / `Config.ACLConfigPath` / `Config.ACLLastGoodPath()`Task 3 的 `renderSingboxConfig` 六参签名;既有包内函数 `atomicWrite(path string, data []byte, perm os.FileMode) error`
- Produces: `func (s *SingBox) loadACL() *ACLConfig``func persistACL(path string, ac *ACLConfig) error`
writeACL(t, cfg.ACLConfigPath, `not json at all`)
sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空
sb2.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
data, err := sb2.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), &quot;reject&quot;) {
t.Fatal(&quot;冷启动未回退到磁盘 last-good,私有服务已敞开&quot;)
}
}
- [ ] **Step 1: 写失败测试**
// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
cfg := testConfig(t)
sb := NewSingBox(cfg, nil)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(data, &amp;m); err != nil {
t.Fatal(err)
}
if _, ok := m[&quot;route&quot;]; ok {
t.Error(&quot;未配置 ACL 也未启用 WARP,不应产出 route 块&quot;)
}
}</code></pre>
<p><code>acl_test.go</code> 的 import 块补齐 <code>&quot;encoding/json&quot;</code><code>&quot;strings&quot;</code>,以及 <code>agentv1 &quot;github.com/wangjia/pangolin/server/internal/pb/agentv1&quot;</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;TestACL_&#x27; -v</code></pre>
<p>Expected: <code>TestACL_PersistsLastGoodOnLoad</code> 失败(last-good 未落盘),<code>TestACL_BrokenFile...</code> / <code>TestACL_ColdStart...</code> 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。</p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p>① 在 <code>server/internal/agentd/acl.go</code> 末尾追加:</p>
<pre><code>// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。
func persistACL(path string, ac *ACLConfig) error {
data, err := json.MarshalIndent(ac, &quot;&quot;, &quot; &quot;)
if err != nil {
return fmt.Errorf(&quot;agentd: marshal acl snapshot: %w&quot;, err)
}
return atomicWrite(path, data, 0o600)
}</code></pre>
<p>② 在 <code>server/internal/agentd/singbox.go</code><code>SingBox</code> 结构体中,<code>configVersion int64</code> 之后新增字段:</p>
<pre><code> // lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
lastGoodACL *ACLConfig</code></pre>
<p>③ 在 <code>server/internal/agentd/singbox.go</code><code>RenderConfig</code> 中,把 WARP 加载之后的部分替换为:</p>
<pre><code> // WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。
// 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。
warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)
if err != nil {
logf(&quot;[warp] load %s failed, WARP routing disabled: %v&quot;, s.cfg.WarpConfigPath, err)
warp = nil
}
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())
}
追加到 `server/internal/agentd/acl_test.go`
</code></pre>
<p>// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。</p>
<p>func TestACL_PersistsLastGoodOnLoad(t *testing.T) {</p>
<p> cfg := testConfig(t)</p>
<p> writeACL(t, cfg.ACLConfigPath, validACL)</p>
<p> sb := NewSingBox(cfg, nil)</p>
<p> sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)</p>
<p> if _, err := sb.RenderConfig(); err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {</p>
<p> t.Fatalf(&quot;last-good 未落盘: %v&quot;, err)</p>
<p> }</p>
<p>}</p>
<p>// acl.json 被改坏 → 规则不能消失(内存 last-good 兜底)。</p>
<p>func TestACL_BrokenFileKeepsInMemoryLastGood(t *testing.T) {</p>
<p> cfg := testConfig(t)</p>
<p> writeACL(t, cfg.ACLConfigPath, validACL)</p>
<p> sb := NewSingBox(cfg, nil)</p>
<p> sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)</p>
<p> if _, err := sb.RenderConfig(); err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> writeACL(t, cfg.ACLConfigPath, <code>{&quot;enabled&quot;: true,</code>) // 手抖写坏</p>
<p> data, err := sb.RenderConfig()</p>
<p> if err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> if !strings.Contains(string(data), &quot;reject&quot;) {</p>
<p> t.Fatal(&quot;acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开&quot;)</p>
<p> }</p>
<p>}</p>
<p>// 新 agent 实例(模拟进程重启)+ 坏 acl.json → 磁盘 last-good 兜底,规则仍在。</p>
<p>func TestACL_ColdStartFallsBackToDiskLastGood(t *testing.T) {</p>
<p> cfg := testConfig(t)</p>
<p> writeACL(t, cfg.ACLConfigPath, validACL)</p>
<p> sb1 := NewSingBox(cfg, nil)</p>
<p> sb1.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)</p>
<p> if _, err := sb1.RenderConfig(); err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> writeACL(t, cfg.ACLConfigPath, <code>not json at all</code>)</p>
<p> sb2 := NewSingBox(cfg, nil) // 全新实例,内存 last-good 为空</p>
<p> sb2.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)</p>
<p> data, err := sb2.RenderConfig()</p>
<p> if err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> if !strings.Contains(string(data), &quot;reject&quot;) {</p>
<p> t.Fatal(&quot;冷启动未回退到磁盘 last-good,私有服务已敞开&quot;)</p>
<p> }</p>
<p>}</p>
<p>// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。</p>
<p>func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {</p>
<p> cfg := testConfig(t)</p>
<p> sb := NewSingBox(cfg, nil)</p>
<p> sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)</p>
<p> data, err := sb.RenderConfig()</p>
<p> if err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> var m map[string]any</p>
<p> if err := json.Unmarshal(data, &amp;m); err != nil {</p>
<p> t.Fatal(err)</p>
<p> }</p>
<p> if _, ok := m[&quot;route&quot;]; ok {</p>
<p> t.Error(&quot;未配置 ACL 也未启用 WARP,不应产出 route 块&quot;)</p>
<p> }</p>
<p>}</p>
<pre><code>
在 `acl_test.go` 的 import 块补齐 `&quot;encoding/json&quot;`、`&quot;strings&quot;`,以及 `agentv1 &quot;github.com/wangjia/pangolin/server/internal/pb/agentv1&quot;`。
// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。
//
// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也
// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:
// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用
// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失
// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,
// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警
func (s *SingBox) loadACL() *ACLConfig {
acl, err := LoadACLConfig(s.cfg.ACLConfigPath)
switch {
case err == nil &amp;&amp; acl != nil:
s.mu.Lock()
s.lastGoodACL = acl
s.mu.Unlock()
if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
logf(&quot;[acl] persist last-good to %s failed: %v&quot;, s.cfg.ACLLastGoodPath(), perr)
}
return acl
case err != nil:
logf(&quot;[acl] ERROR load %s failed: %v&quot;, s.cfg.ACLConfigPath, err)
}
- [ ] **Step 2: 跑测试确认失败**
</code></pre>
<p>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;TestACL_&#x27; -v</p>
<pre><code>
Expected: `TestACL_PersistsLastGoodOnLoad` 失败(last-good 未落盘),`TestACL_BrokenFile...` / `TestACL_ColdStart...` 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。
s.mu.Lock()
lg := s.lastGoodACL
s.mu.Unlock()
if lg != nil {
logf(&quot;[acl] falling back to in-memory last-good ACL&quot;)
return lg
}
- [ ] **Step 3: 写实现**
disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
if derr == nil &amp;&amp; disk != nil {
logf(&quot;[acl] falling back to on-disk last-good %s&quot;, s.cfg.ACLLastGoodPath())
s.mu.Lock()
s.lastGoodACL = disk
s.mu.Unlock()
return disk
}
① 在 `server/internal/agentd/acl.go` 末尾追加:
</code></pre>
<p>// persistACL 把成功加载的 ACL 快照原子写到 path,供 agent 冷启动兜底。</p>
<p>func persistACL(path string, ac *ACLConfig) error {</p>
<p> data, err := json.MarshalIndent(ac, &quot;&quot;, &quot; &quot;)</p>
<p> if err != nil {</p>
<p> return fmt.Errorf(&quot;agentd: marshal acl snapshot: %w&quot;, err)</p>
<p> }</p>
<p> return atomicWrite(path, data, 0o600)</p>
<p>}</p>
<pre><code>
② 在 `server/internal/agentd/singbox.go` 的 `SingBox` 结构体中,`configVersion int64` 之后新增字段:
</code></pre>
<p> // lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,</p>
<p> // 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。</p>
<p> lastGoodACL *ACLConfig</p>
<pre><code>
③ 在 `server/internal/agentd/singbox.go` 的 `RenderConfig` 中,把 WARP 加载之后的部分替换为:
</code></pre>
<p> // WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。</p>
<p> // 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。</p>
<p> warp, err := LoadWarpConfig(s.cfg.WarpConfigPath)</p>
<p> if err != nil {</p>
<p> logf(&quot;[warp] load %s failed, WARP routing disabled: %v&quot;, s.cfg.WarpConfigPath, err)</p>
<p> warp = nil</p>
<p> }</p>
<p> return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, s.loadACL())</p>
<p>}</p>
<p>// loadACL 读取私有目的地 ACL,并维护 last-good 兜底。</p>
<p>//</p>
<p>// 语义刻意与 WARP 相反:WARP 读失败静默降级为「不分流」是安全的,ACL 读失败若也</p>
<p>// 降级为「不启用」,等于把私有服务对全体 pangolin 用户敞开,而且是静默的。故:</p>
<p>// - 成功 → 更新内存 last-good 并落盘,供本进程后续与下次冷启动使用</p>
<p>// - 失败/文件消失 → 回退内存 last-good,再回退磁盘 last-good,规则不消失</p>
<p>// - 两级 last-good 都没有 → 只能不产出规则(白名单与目的地清单同在一个文件,</p>
<p>// 文件全丢时连「该拒绝哪些目的地」都无从得知),此时必须大声告警</p>
<p>func (s *SingBox) loadACL() *ACLConfig {</p>
<p> acl, err := LoadACLConfig(s.cfg.ACLConfigPath)</p>
<p> switch {</p>
<p> case err == nil &amp;&amp; acl != nil:</p>
<p> s.mu.Lock()</p>
<p> s.lastGoodACL = acl</p>
<p> s.mu.Unlock()</p>
<p> if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {</p>
<p> logf(&quot;[acl] persist last-good to %s failed: %v&quot;, s.cfg.ACLLastGoodPath(), perr)</p>
<p> }</p>
<p> return acl</p>
<p> case err != nil:</p>
<p> logf(&quot;[acl] ERROR load %s failed: %v&quot;, s.cfg.ACLConfigPath, err)</p>
<p> }</p>
<p> s.mu.Lock()</p>
<p> lg := s.lastGoodACL</p>
<p> s.mu.Unlock()</p>
<p> if lg != nil {</p>
<p> logf(&quot;[acl] falling back to in-memory last-good ACL&quot;)</p>
<p> return lg</p>
<p> }</p>
<p> disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())</p>
<p> if derr == nil &amp;&amp; disk != nil {</p>
<p> logf(&quot;[acl] falling back to on-disk last-good %s&quot;, s.cfg.ACLLastGoodPath())</p>
<p> s.mu.Lock()</p>
<p> s.lastGoodACL = disk</p>
<p> s.mu.Unlock()</p>
<p> return disk</p>
<p> }</p>
<p> if err != nil {</p>
<p> logf(&quot;[acl] ALERT acl.json is broken and no last-good snapshot exists — &quot;+</p>
<p> &quot;private destinations are UNPROTECTED (path=%s)&quot;, s.cfg.ACLConfigPath)</p>
<p> }</p>
<p> return nil</p>
<p>}</p>
<pre><code>
- [ ] **Step 4: 跑测试确认通过**
</code></pre>
<p>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;TestACL_&#x27; -v</p>
<pre><code>
Expected: PASS,四条全绿。
if err != nil {
logf(&quot;[acl] ALERT acl.json is broken and no last-good snapshot exists — &quot;+
&quot;private destinations are UNPROTECTED (path=%s)&quot;, s.cfg.ACLConfigPath)
}
return nil
}</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;TestACL_&#x27; -v</code></pre>
<p>Expected: PASS,四条全绿。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/acl.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go
git commit -m &quot;feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启&quot;</code></pre>
<hr>
<h3>Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户)</h3>
<p><b>Files:</b></p>
<ul>
<li>Modify: <code>server/internal/agentd/singbox.go</code>(新增导出的 <code>Refresh()</code></li>
<li>Modify: <code>server/cmd/agent/main.go:44-45</code>(注册 SIGHUP 处理)</li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加 <code>Refresh</code> 测试)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: 既有 <code>(*SingBox).markDirty()</code>(未导出)、<code>(*Agent).SingBox() *SingBox</code><code>agent.go:83</code>,已导出)</li>
<li>Produces: <code>func (s *SingBox) Refresh()</code> —— 外部触发一次重渲染</li>
</ul>
<p><b>为什么不用 <code>systemctl restart pangolin-agent</code></b> 新进程内 <code>s.started == false</code><code>singbox.go:64-67</code>),首次渲染会走 <code>restarter.Restart()</code><code>systemctl restart sing-box</code><b>全部在线用户瞬断</b>。SIGHUP 走的是既有的 debounce → <code>writeAndRestart</code><code>Reload()</code>SIGHUP 热重载)路径,不断线。</p>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写失败测试</b></div>
<p>追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后
// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。
func TestSingBoxRefresh_TriggersRender(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, validACL)
fr := &amp;fakeRestarter{}
sb := NewSingBox(cfg, fr)
sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)
- [ ] **Step 5: 提交**
</code></pre>
<p>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...</p>
<p>git add server/internal/agentd/acl.go server/internal/agentd/singbox.go server/internal/agentd/acl_test.go</p>
<p>git commit -m &quot;feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启&quot;</p>
<pre><code>
---
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sb.Run(ctx)
### Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户)
// 等首次渲染落地(ApplyConfig 已 markDirty)
eventually(t, 2*time.Second, func() bool {
_, err := os.Stat(cfg.SingboxConfigPath)
return err == nil
}, &quot;首次渲染写出配置&quot;)
**Files:**
- Modify: `server/internal/agentd/singbox.go`(新增导出的 `Refresh()`
- Modify: `server/cmd/agent/main.go:44-45`(注册 SIGHUP 处理)
- Modify: `server/internal/agentd/acl_test.go`(追加 `Refresh` 测试)
// 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染
// 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。
before := fr.reloadCount()
sb.Refresh()
eventually(t, 2*time.Second, func() bool { return fr.reloadCount() &gt; before }, &quot;Refresh 触发了热重载&quot;)
}</code></pre>
<p><code>acl_test.go</code> 的 import 块补齐 <code>&quot;context&quot;</code><code>&quot;time&quot;</code></p>
<blockquote><p>复用 <code>singbox_test.go:17-47</code> 既有的 <code>fakeRestarter</code><code>count()</code> 返回 Restart 次数、<code>reloadCount()</code> 返回 Reload 次数)与 <code>integration_test.go:248</code><code>eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)</code></p></blockquote>
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;Refresh&#x27; -v</code></pre>
<p>Expected: 编译失败,<code>sb.Refresh undefined</code></p>
<div class="step"><span class="box">[ ]</span><b>Step 3: 写实现</b></div>
<p>① 在 <code>server/internal/agentd/singbox.go</code><code>markDirty</code> 之后追加:</p>
<pre><code>// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
// 冷启动(Restart),把全部在线用户踢下线。
func (s *SingBox) Refresh() { s.markDirty() }</code></pre>
<p>② 修改 <code>server/cmd/agent/main.go</code>,把第 4445 行替换为:</p>
<pre><code> ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
**Interfaces:**
- Consumes: 既有 `(*SingBox).markDirty()`(未导出)、`(*Agent).SingBox() *SingBox``agent.go:83`,已导出)
- Produces: `func (s *SingBox) Refresh()` —— 外部触发一次重渲染
**为什么不用 `systemctl restart pangolin-agent`** 新进程内 `s.started == false``singbox.go:64-67`),首次渲染会走 `restarter.Restart()` 即 `systemctl restart sing-box`**全部在线用户瞬断**。SIGHUP 走的是既有的 debounce → `writeAndRestart` → `Reload()`SIGHUP 热重载)路径,不断线。
- [ ] **Step 1: 写失败测试**
追加到 `server/internal/agentd/acl_test.go`
</code></pre>
<p>// Refresh() 必须能触发一次重渲染(经 debounce 循环),用于「编辑 acl.json 后</p>
<p>// systemctl reload pangolin-agent」而不必重启 agent(重启会冷启 sing-box 踢人)。</p>
<p>func TestSingBoxRefresh_TriggersRender(t *testing.T) {</p>
<p> cfg := testConfig(t)</p>
<p> writeACL(t, cfg.ACLConfigPath, validACL)</p>
<p> fr := &amp;fakeRestarter{}</p>
<p> sb := NewSingBox(cfg, fr)</p>
<p> sb.ApplyConfig(sampleSnapshot(&amp;agentv1.Credential{DpUUID: &quot;aaaa&quot;, Protocol: agentv1.ProtocolBoth}), true)</p>
<p> ctx, cancel := context.WithCancel(context.Background())</p>
<p> defer cancel()</p>
<p> go sb.Run(ctx)</p>
<p> // 等首次渲染落地(ApplyConfig 已 markDirty)</p>
<p> eventually(t, 2*time.Second, func() bool {</p>
<p> _, err := os.Stat(cfg.SingboxConfigPath)</p>
<p> return err == nil</p>
<p> }, &quot;首次渲染写出配置&quot;)</p>
<p> // 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染</p>
<p> // 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。</p>
<p> before := fr.reloadCount()</p>
<p> sb.Refresh()</p>
<p> eventually(t, 2*time.Second, func() bool { return fr.reloadCount() &gt; before }, &quot;Refresh 触发了热重载&quot;)</p>
<p>}</p>
<pre><code>
在 `acl_test.go` 的 import 块补齐 `&quot;context&quot;` 与 `&quot;time&quot;`。
&gt; 复用 `singbox_test.go:17-47` 既有的 `fakeRestarter``count()` 返回 Restart 次数、`reloadCount()` 返回 Reload 次数)与 `integration_test.go:248` 的 `eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)`。
- [ ] **Step 2: 跑测试确认失败**
</code></pre>
<p>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;Refresh&#x27; -v</p>
<pre><code>
Expected: 编译失败,`sb.Refresh undefined`。
- [ ] **Step 3: 写实现**
① 在 `server/internal/agentd/singbox.go` 的 `markDirty` 之后追加:
</code></pre>
<p>// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件</p>
<p>// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走</p>
<p>// 冷启动(Restart),把全部在线用户踢下线。</p>
<p>func (s *SingBox) Refresh() { s.markDirty() }</p>
<pre><code>
② 修改 `server/cmd/agent/main.go`,把第 4445 行替换为:
</code></pre>
<p> ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)</p>
<p> defer stop()</p>
<p> // SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP</p>
<p> // 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。</p>
<p> hup := make(chan os.Signal, 1)</p>
<p> signal.Notify(hup, syscall.SIGHUP)</p>
<p> defer signal.Stop(hup)</p>
<p> go func() {</p>
<p> for {</p>
<p> select {</p>
<p> case &lt;-ctx.Done():</p>
<p> return</p>
<p> case &lt;-hup:</p>
<p> log.Printf(&quot;[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)&quot;)</p>
<p> agent.SingBox().Refresh()</p>
<p> }</p>
<p> }</p>
<p> }()</p>
<pre><code>
`os` 与 `log` 已在该文件 import 中(`os` 见 `env()` 用到的 `os.LookupEnv`);若 `go build` 报缺失,按提示补齐。
- [ ] **Step 4: 跑测试确认通过**
</code></pre>
<p>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/ -run &#x27;Refresh&#x27; -v</p>
<pre><code>
Expected: PASS。
- [ ] **Step 5: 提交**
</code></pre>
<p>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...</p>
<p>git add server/internal/agentd/singbox.go server/internal/agentd/acl_test.go server/cmd/agent/main.go</p>
<p>git commit -m &quot;feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户&quot;</p>
<pre><code>
---
### Task 6: 节点配置样例与运维文档
**Files:**
- Create: `deploy/single-node/acl.json.example`
- Modify: `docs/private-dest-acl-design.html`(把 §8 runbook 里的占位符换成指向样例文件)
**Interfaces:**
- Consumes: Task 1 定义的 `ACLConfig` JSON 形状
- Produces: 可直接复制到 `/etc/pangolin-agent/acl.json` 的样例
- [ ] **Step 1: 写样例文件**
创建 `deploy/single-node/acl.json.example`
</code></pre>
<p>{</p>
<p> &quot;_comment&quot;: &quot;私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restartrestart 会冷启 sing-box 踢掉全部在线用户)。&quot;,</p>
<p> &quot;enabled&quot;: true,</p>
<p> &quot;allow_dp_uuids&quot;: [</p>
<p> &quot;TODO-设备级-dp-uuid&quot;,</p>
<p> &quot;TODO-账户级-dp-uuid-供-sub-订阅链接用&quot;</p>
<p> ],</p>
<p> &quot;targets&quot;: [</p>
<p> {</p>
<p> &quot;_comment&quot;: &quot;与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分&quot;,</p>
<p> &quot;domain&quot;: [&quot;brain.51yanmei.com&quot;, &quot;git.51yanmei.com&quot;]</p>
<p> },</p>
<p> {</p>
<p> &quot;_comment&quot;: &quot;独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023&quot;,</p>
<p> &quot;ip_cidr&quot;: [&quot;182.92.213.171/32&quot;],</p>
<p> &quot;port&quot;: [5001, 3389, 10022, 10023]</p>
<p> }</p>
<p> ]</p>
<p>}</p>
<pre><code>
&gt; `_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),可安全保留作自文档。样例里的 `TODO-` 值是**样例文件**的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
- [ ] **Step 2: 验证样例能被解析且渲染出预期规则**
</code></pre>
<p>cd server &amp;&amp; cat &gt; /tmp/acl_example_check.go &lt;&lt;&#x27;EOF&#x27;</p>
<p>package main</p>
<p>import (</p>
<p> &quot;fmt&quot;</p>
<p> &quot;os&quot;</p>
<p> &quot;github.com/wangjia/pangolin/server/internal/agentd&quot;</p>
<p>)</p>
<p>func main() {</p>
<p> ac, err := agentd.LoadACLConfig(os.Args[1])</p>
<p> if err != nil {</p>
<p> fmt.Println(&quot;FAIL:&quot;, err)</p>
<p> os.Exit(1)</p>
<p> }</p>
<p> fmt.Printf(&quot;OK enabled=%v uuids=%d targets=%d\n&quot;, ac.Enabled, len(ac.AllowDpUUIDs), len(ac.Targets))</p>
<p>}</p>
<p>EOF</p>
<p>go run /tmp/acl_example_check.go ../deploy/single-node/acl.json.example &amp;&amp; rm /tmp/acl_example_check.go</p>
<pre><code>
Expected: `OK enabled=true uuids=2 targets=2`
- [ ] **Step 3: 更新设计文档的 runbook 指向**
在 `docs/private-dest-acl-design.html` 的 §8 首段(`&lt;h2&gt;8. 名单维护 Runbook&lt;/h2&gt;` 之后的第一个 `&lt;p&gt;`)末尾追加一句:
</code></pre>
<p>样例文件见 &lt;code&gt;deploy/single-node/acl.json.example&lt;/code&gt;,复制到 &lt;code&gt;/etc/pangolin-agent/acl.json&lt;/code&gt; 后按下方 SQL 填入 uuid。</p>
<pre><code>
- [ ] **Step 4: 提交**
</code></pre>
<p>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...</p>
<p>git add deploy/single-node/acl.json.example docs/private-dest-acl-design.html</p>
<p>git commit -m &quot;docs(agent): 私有目的地 ACL 配置样例与 runbook 指向&quot;</p>
<pre><code>
---
## 上线验收(人工,非代码任务)
代码合并后在 pangolin1 上执行。**先备份现网 sing-box 配置**`sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)`(此处 `$(...)` 在服务器交互 shell 中手动执行,不经 agent)。
1. 取白名单 uuid`UNION` 那一半是账户级 dp_uuid,供 `/sub` 订阅链接用,**漏了会把自己锁在外面**):
```sql
sqlite3 /var/lib/pangolin/pangolin.db \
&quot;SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id
WHERE u.email = &#x27;&lt;我的邮箱&gt;&#x27; AND d.dp_uuid IS NOT NULL
UNION
SELECT dp_uuid FROM users WHERE email = &#x27;&lt;我的邮箱&gt;&#x27;;&quot;
```
2. 写 `/etc/pangolin-agent/acl.json`(权限 `0600`,属主 `pangolin`),`systemctl reload pangolin-agent`。
3. `journalctl -u pangolin-agent -n 30` 确认已重渲染、无 `[acl] ERROR` / `[acl] ALERT`。
4. `sudo python3 -c &quot;import json;print(json.load(open(&#x27;/etc/sing-box/config.json&#x27;))[&#x27;route&#x27;])&quot;` 确认规则顺序为 sniff → 放行 → 拒绝 → warp。
5. **我的设备**brain 首页 200、DSM 5001 可登录、`ssh nas-r` 通。
6. **另一账号的设备**:以上全部被拒(连接被 reject,不是超时)。
7. **公开站不受影响**jiu / travel / sudoku / pay 在两个账号下均正常。
8. **WARP 未被破坏**reddit 仍走 warp 出口。
9. **fail-closed 实证**:把 `acl.json` 改坏 → `systemctl reload pangolin-agent` → 规则仍在、日志有 ERROR;恢复文件。
10. **不断线实证**:reload 期间另一台设备保持连接不掉。
## 不在本计划范围内
- 给 brain 加 mTLS / basic auth 作纵深防御(与本计划正交,约一小时,另开)
- 补 `assertNoIdentityFields` 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复)
- 控制面下发 ACL(演进路径见设计文档 §11,届时渲染层可原样保留)
</code></pre>
// SIGHUP 重读节点本地配置(acl.json / warp.json)并重渲染。走 sing-box 的 SIGHUP
// 热重载路径,在线用户不掉线;重启 agent 则会冷启 sing-box,把所有人踢下线。
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for {
select {
case &lt;-ctx.Done():
return
case &lt;-hup:
log.Printf(&quot;[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)&quot;)
agent.SingBox().Refresh()
}
}
}()</code></pre>
<p><code>os</code><code>log</code> 已在该文件 import 中(<code>os</code><code>env()</code> 用到的 <code>os.LookupEnv</code>);若 <code>go build</code> 报缺失,按提示补齐。</p>
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/ -run &#x27;Refresh&#x27; -v</code></pre>
<p>Expected: PASS。</p>
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add server/internal/agentd/singbox.go server/internal/agentd/acl_test.go server/cmd/agent/main.go
git commit -m &quot;feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户&quot;</code></pre>
<hr>
<h3>Task 6: 节点配置样例与运维文档</h3>
<p><b>Files:</b></p>
<ul>
<li>Create: <code>deploy/single-node/acl.json.example</code></li>
<li>Modify: <code>server/internal/agentd/acl_test.go</code>(追加样例文件常驻校验测试)</li>
<li>Modify: <code>docs/private-dest-acl-design.html</code>(把 §8 runbook 里的占位符换成指向样例文件)</li>
</ul>
<p><b>Interfaces:</b></p>
<ul>
<li>Consumes: Task 1 定义的 <code>ACLConfig</code> JSON 形状</li>
<li>Produces: 可直接复制到 <code>/etc/pangolin-agent/acl.json</code> 的样例</li>
</ul>
<div class="step"><span class="box">[ ]</span><b>Step 1: 写样例文件</b></div>
<p>创建 <code>deploy/single-node/acl.json.example</code></p>
<pre><code>{
&quot;_comment&quot;: &quot;私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restartrestart 会冷启 sing-box 踢掉全部在线用户)。&quot;,
&quot;enabled&quot;: true,
&quot;allow_dp_uuids&quot;: [
&quot;TODO-设备级-dp-uuid&quot;,
&quot;TODO-账户级-dp-uuid-供-sub-订阅链接用&quot;
],
&quot;targets&quot;: [
{
&quot;_comment&quot;: &quot;与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分&quot;,
&quot;domain&quot;: [&quot;brain.51yanmei.com&quot;, &quot;git.51yanmei.com&quot;]
},
{
&quot;_comment&quot;: &quot;独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023&quot;,
&quot;ip_cidr&quot;: [&quot;182.92.213.171/32&quot;],
&quot;port&quot;: [5001, 3389, 10022, 10023]
}
]
}</code></pre>
<blockquote><p><code>_comment</code> 字段会被 <code>encoding/json</code> 忽略(<code>ACLConfig</code> 无对应字段且未启用 <code>DisallowUnknownFields</code>),可安全保留作自文档。样例里的 <code>TODO-</code> 值是<b>样例文件</b>的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。</p></blockquote>
<div class="step"><span class="box">[ ]</span><b>Step 2: 加一条测试,锁死「样例文件永远可解析」</b></div>
<p>样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 <code>go run</code> 检查一次没有约束力。改为一条常驻测试。追加到 <code>server/internal/agentd/acl_test.go</code></p>
<pre><code>// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
func TestACLExampleFileStaysValid(t *testing.T) {
path := filepath.Join(&quot;..&quot;, &quot;..&quot;, &quot;..&quot;, &quot;deploy&quot;, &quot;single-node&quot;, &quot;acl.json.example&quot;)
ac, err := LoadACLConfig(path)
if err != nil {
t.Fatalf(&quot;样例文件解析失败 %s: %v&quot;, path, err)
}
if ac == nil {
t.Fatalf(&quot;样例文件不存在: %s&quot;, path)
}
if !ac.active() {
t.Error(&quot;样例文件应当是一份 active 的 ACL&quot;)
}
if len(ac.Targets) != 2 {
t.Errorf(&quot;样例 targets = %d, want 2&quot;, len(ac.Targets))
}
// 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
for _, u := range ac.AllowDpUUIDs {
if !strings.HasPrefix(u, &quot;TODO-&quot;) {
t.Errorf(&quot;样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git&quot;, u)
}
}
}</code></pre>
<p>跑:</p>
<pre><code>cd server &amp;&amp; go test ./internal/agentd/ -run &#x27;ExampleFileStaysValid&#x27; -v</code></pre>
<p>Expected: PASS。</p>
<blockquote><p>注:<code>_comment</code> 字段会被 <code>encoding/json</code> 忽略(<code>ACLConfig</code> 无对应字段且未启用 <code>DisallowUnknownFields</code>),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。</p></blockquote>
<div class="step"><span class="box">[ ]</span><b>Step 3: 更新设计文档的 runbook 指向</b></div>
<p><code>docs/private-dest-acl-design.html</code> 的 §8 首段(<code>&lt;h2&gt;8. 名单维护 Runbook&lt;/h2&gt;</code> 之后的第一个 <code>&lt;p&gt;</code>)末尾追加一句:</p>
<pre><code>样例文件见 &lt;code&gt;deploy/single-node/acl.json.example&lt;/code&gt;,复制到 &lt;code&gt;/etc/pangolin-agent/acl.json&lt;/code&gt; 后按下方 SQL 填入 uuid。</code></pre>
<div class="step"><span class="box">[ ]</span><b>Step 4: 提交</b></div>
<pre><code>cd server &amp;&amp; go build ./... &amp;&amp; go test ./internal/agentd/...
git add deploy/single-node/acl.json.example server/internal/agentd/acl_test.go docs/private-dest-acl-design.html
git commit -m &quot;docs(agent): 私有目的地 ACL 配置样例与常驻校验测试&quot;</code></pre>
<blockquote><p><code>git add</code> 只列具体路径,<b>切勿 <code>git add -A</code></b>:仓库里有未跟踪的 <code>pangolin-release.jks</code>(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。</p></blockquote>
<hr>
<h2>上线验收(人工,非代码任务)</h2>
<p>代码合并后在 pangolin1 上执行。<b>先备份现网 sing-box 配置</b><code>sudo cp /etc/sing-box/config.json /etc/sing-box/config.json.bak-$(date +%F)</code>(此处 <code>$(...)</code> 在服务器交互 shell 中手动执行,不经 agent)。</p>
<ol>
<li>取白名单 uuid<code>UNION</code> 那一半是账户级 dp_uuid,供 <code>/sub</code> 订阅链接用,<b>漏了会把自己锁在外面</b>):</li>
</ol>
<p> ```sql</p>
<p> sqlite3 /var/lib/pangolin/pangolin.db \</p>
<p> &quot;SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id</p>
<p> WHERE u.email = &#x27;&lt;我的邮箱&gt;&#x27; AND d.dp_uuid IS NOT NULL</p>
<p> UNION</p>
<p> SELECT dp_uuid FROM users WHERE email = &#x27;&lt;我的邮箱&gt;&#x27;;&quot;</p>
<p> ```</p>
<ol>
<li><code>/etc/pangolin-agent/acl.json</code>(权限 <code>0600</code>,属主 <code>pangolin</code>),<code>systemctl reload pangolin-agent</code></li>
<li><code>journalctl -u pangolin-agent -n 30</code> 确认已重渲染、无 <code>[acl] ERROR</code> / <code>[acl] ALERT</code></li>
<li><code>sudo python3 -c &quot;import json;print(json.load(open(&#x27;/etc/sing-box/config.json&#x27;))[&#x27;route&#x27;])&quot;</code> 确认规则顺序为 sniff → 放行 → 拒绝 → warp。</li>
<li><b>我的设备</b>brain 首页 200、DSM 5001 可登录、<code>ssh nas-r</code> 通。</li>
<li><b>另一账号的设备</b>:以上全部被拒(连接被 reject,不是超时)。</li>
<li><b>公开站不受影响</b>jiu / travel / sudoku / pay 在两个账号下均正常。</li>
<li><b>WARP 未被破坏</b>reddit 仍走 warp 出口。</li>
<li><b>fail-closed 实证</b>:把 <code>acl.json</code> 改坏 → <code>systemctl reload pangolin-agent</code> → 规则仍在、日志有 ERROR;恢复文件。</li>
<li><b>不断线实证</b>:reload 期间另一台设备保持连接不掉。</li>
</ol>
<h2>不在本计划范围内</h2>
<ul>
<li>给 brain 加 mTLS / basic auth 作纵深防御(与本计划正交,约一小时,另开)</li>
<li><code>assertNoIdentityFields</code> 对渲染配置的覆盖(既有缺口,本计划不扩大也不修复)</li>
<li>控制面下发 ACL(演进路径见设计文档 §11,届时渲染层可原样保留)</li>
</ul>
</div>
</body>
</html>
@@ -774,6 +774,7 @@ func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id),
// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化,
// 设计与权衡见 docs/private-dest-acl-design.html §12。
```
⑤ 修改 `server/internal/agentd/singbox.go` 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):
@@ -1119,6 +1120,7 @@ git commit -m "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不
**Files:**
- Create: `deploy/single-node/acl.json.example`
- Modify: `server/internal/agentd/acl_test.go`(追加样例文件常驻校验测试)
- Modify: `docs/private-dest-acl-design.html`(把 §8 runbook 里的占位符换成指向样例文件)
**Interfaces:**
@@ -1153,32 +1155,45 @@ git commit -m "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不
> `_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),可安全保留作自文档。样例里的 `TODO-` 值是**样例文件**的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
- [ ] **Step 2: 验证样例能被解析且渲染出预期规则**
- [ ] **Step 2: 加一条测试,锁死「样例文件永远可解析」**
```bash
cd server && cat > /tmp/acl_example_check.go <<'EOF'
package main
样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 `go run` 检查一次没有约束力。改为一条常驻测试。追加到 `server/internal/agentd/acl_test.go`
import (
"fmt"
"os"
"github.com/wangjia/pangolin/server/internal/agentd"
)
func main() {
ac, err := agentd.LoadACLConfig(os.Args[1])
```go
// 样例文件必须始终可被解析且产出预期规则,防止改了 ACLTarget 形状却忘了同步样例。
func TestACLExampleFileStaysValid(t *testing.T) {
path := filepath.Join("..", "..", "..", "deploy", "single-node", "acl.json.example")
ac, err := LoadACLConfig(path)
if err != nil {
fmt.Println("FAIL:", err)
os.Exit(1)
t.Fatalf("样例文件解析失败 %s: %v", path, err)
}
if ac == nil {
t.Fatalf("样例文件不存在: %s", path)
}
if !ac.active() {
t.Error("样例文件应当是一份 active 的 ACL")
}
if len(ac.Targets) != 2 {
t.Errorf("样例 targets = %d, want 2", len(ac.Targets))
}
// 样例里的 uuid 是占位符,不该是真实 uuid(真实 uuid 属标识信息,不入 git)
for _, u := range ac.AllowDpUUIDs {
if !strings.HasPrefix(u, "TODO-") {
t.Errorf("样例文件混入了非占位 uuid %q —— 真实 dp_uuid 不得入 git", u)
}
}
fmt.Printf("OK enabled=%v uuids=%d targets=%d\n", ac.Enabled, len(ac.AllowDpUUIDs), len(ac.Targets))
}
EOF
go run /tmp/acl_example_check.go ../deploy/single-node/acl.json.example && rm /tmp/acl_example_check.go
```
Expected: `OK enabled=true uuids=2 targets=2`
跑:
```bash
cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -v
```
Expected: PASS。
> 注:`_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。
- [ ] **Step 3: 更新设计文档的 runbook 指向**
@@ -1192,10 +1207,12 @@ Expected: `OK enabled=true uuids=2 targets=2`
```bash
cd server && go build ./... && go test ./internal/agentd/...
git add deploy/single-node/acl.json.example docs/private-dest-acl-design.html
git commit -m "docs(agent): 私有目的地 ACL 配置样例与 runbook 指向"
git add deploy/single-node/acl.json.example server/internal/agentd/acl_test.go docs/private-dest-acl-design.html
git commit -m "docs(agent): 私有目的地 ACL 配置样例与常驻校验测试"
```
> `git add` 只列具体路径,**切勿 `git add -A`**:仓库里有未跟踪的 `pangolin-release.jks`Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。
---
## 上线验收(人工,非代码任务)