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:
+349
-382
@@ -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 && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -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 && go build ./... && 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 "refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP"</code></pre>
|
||||
<hr>
|
||||
<h3>Task 4: 渲染时读取 ACL,last-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(&agentv1.Credential{DpUUID: "aaaa", 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 && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -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("last-good 未落盘: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
</code></pre>
|
||||
<p>cd server && go build ./... && 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 "refactor(agent): route 块改由 buildRoute 统一产出,合并 ACL 与 WARP"</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(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
if _, err := sb.RenderConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
### Task 4: 渲染时读取 ACL,last-good 内存 + 落盘兜底
|
||||
writeACL(t, cfg.ACLConfigPath, `{"enabled": true,`) // 手抖写坏
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
|
||||
}
|
||||
}
|
||||
|
||||
**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(&agentv1.Credential{DpUUID: "aaaa", 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(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb2.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "reject") {
|
||||
t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")
|
||||
}
|
||||
}
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
// 从未配置过(无 acl.json 也无 last-good)→ 不产出 route,且不误报。
|
||||
func TestACL_NeverConfiguredYieldsNoRoute(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
sb := NewSingBox(cfg, nil)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
data, err := sb.RenderConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := m["route"]; ok {
|
||||
t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")
|
||||
}
|
||||
}</code></pre>
|
||||
<p>在 <code>acl_test.go</code> 的 import 块补齐 <code>"encoding/json"</code>、<code>"strings"</code>,以及 <code>agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"</code>。</p>
|
||||
<div class="step"><span class="box">[ ]</span><b>Step 2: 跑测试确认失败</b></div>
|
||||
<pre><code>cd server && go test ./internal/agentd/ -run 'TestACL_' -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, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("agentd: marshal acl snapshot: %w", 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("[warp] load %s failed, WARP routing disabled: %v", 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(&agentv1.Credential{DpUUID: "aaaa", 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("last-good 未落盘: %v", 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(&agentv1.Credential{DpUUID: "aaaa", 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>{"enabled": 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), "reject") {</p>
|
||||
<p> t.Fatal("acl.json 坏掉后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")</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(&agentv1.Credential{DpUUID: "aaaa", 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(&agentv1.Credential{DpUUID: "aaaa", 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), "reject") {</p>
|
||||
<p> t.Fatal("冷启动未回退到磁盘 last-good,私有服务已敞开")</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(&agentv1.Credential{DpUUID: "aaaa", 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, &m); err != nil {</p>
|
||||
<p> t.Fatal(err)</p>
|
||||
<p> }</p>
|
||||
<p> if _, ok := m["route"]; ok {</p>
|
||||
<p> t.Error("未配置 ACL 也未启用 WARP,不应产出 route 块")</p>
|
||||
<p> }</p>
|
||||
<p>}</p>
|
||||
<pre><code>
|
||||
在 `acl_test.go` 的 import 块补齐 `"encoding/json"`、`"strings"`,以及 `agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"`。
|
||||
// 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 && acl != nil:
|
||||
s.mu.Lock()
|
||||
s.lastGoodACL = acl
|
||||
s.mu.Unlock()
|
||||
if perr := persistACL(s.cfg.ACLLastGoodPath(), acl); perr != nil {
|
||||
logf("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)
|
||||
}
|
||||
return acl
|
||||
case err != nil:
|
||||
logf("[acl] ERROR load %s failed: %v", s.cfg.ACLConfigPath, err)
|
||||
}
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
</code></pre>
|
||||
<p>cd server && go test ./internal/agentd/ -run 'TestACL_' -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("[acl] falling back to in-memory last-good ACL")
|
||||
return lg
|
||||
}
|
||||
|
||||
- [ ] **Step 3: 写实现**
|
||||
disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())
|
||||
if derr == nil && disk != nil {
|
||||
logf("[acl] falling back to on-disk last-good %s", 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, "", " ")</p>
|
||||
<p> if err != nil {</p>
|
||||
<p> return fmt.Errorf("agentd: marshal acl snapshot: %w", 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("[warp] load %s failed, WARP routing disabled: %v", 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 && 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("[acl] persist last-good to %s failed: %v", s.cfg.ACLLastGoodPath(), perr)</p>
|
||||
<p> }</p>
|
||||
<p> return acl</p>
|
||||
<p> case err != nil:</p>
|
||||
<p> logf("[acl] ERROR load %s failed: %v", 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("[acl] falling back to in-memory last-good ACL")</p>
|
||||
<p> return lg</p>
|
||||
<p> }</p>
|
||||
<p> disk, derr := LoadACLConfig(s.cfg.ACLLastGoodPath())</p>
|
||||
<p> if derr == nil && disk != nil {</p>
|
||||
<p> logf("[acl] falling back to on-disk last-good %s", 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("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+</p>
|
||||
<p> "private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)</p>
|
||||
<p> }</p>
|
||||
<p> return nil</p>
|
||||
<p>}</p>
|
||||
<pre><code>
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
</code></pre>
|
||||
<p>cd server && go test ./internal/agentd/ -run 'TestACL_' -v</p>
|
||||
<pre><code>
|
||||
Expected: PASS,四条全绿。
|
||||
if err != nil {
|
||||
logf("[acl] ALERT acl.json is broken and no last-good snapshot exists — "+
|
||||
"private destinations are UNPROTECTED (path=%s)", s.cfg.ACLConfigPath)
|
||||
}
|
||||
return nil
|
||||
}</code></pre>
|
||||
<div class="step"><span class="box">[ ]</span><b>Step 4: 跑测试确认通过</b></div>
|
||||
<pre><code>cd server && go test ./internal/agentd/ -run 'TestACL_' -v</code></pre>
|
||||
<p>Expected: PASS,四条全绿。</p>
|
||||
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
|
||||
<pre><code>cd server && go build ./... && 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 "feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启"</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 := &fakeRestarter{}
|
||||
sb := NewSingBox(cfg, fr)
|
||||
sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true)
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
</code></pre>
|
||||
<p>cd server && go build ./... && 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 "feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启"</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
|
||||
}, "首次渲染写出配置")
|
||||
|
||||
**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() > before }, "Refresh 触发了热重载")
|
||||
}</code></pre>
|
||||
<p>在 <code>acl_test.go</code> 的 import 块补齐 <code>"context"</code> 与 <code>"time"</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 && go test ./internal/agentd/ -run 'Refresh' -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>,把第 44–45 行替换为:</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 := &fakeRestarter{}</p>
|
||||
<p> sb := NewSingBox(cfg, fr)</p>
|
||||
<p> sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", 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> }, "首次渲染写出配置")</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() > before }, "Refresh 触发了热重载")</p>
|
||||
<p>}</p>
|
||||
<pre><code>
|
||||
在 `acl_test.go` 的 import 块补齐 `"context"` 与 `"time"`。
|
||||
|
||||
> 复用 `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 && go test ./internal/agentd/ -run 'Refresh' -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`,把第 44–45 行替换为:
|
||||
</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 <-ctx.Done():</p>
|
||||
<p> return</p>
|
||||
<p> case <-hup:</p>
|
||||
<p> log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")</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 && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v</p>
|
||||
<pre><code>
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
</code></pre>
|
||||
<p>cd server && go build ./... && 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 "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户"</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> "_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",</p>
|
||||
<p> "enabled": true,</p>
|
||||
<p> "allow_dp_uuids": [</p>
|
||||
<p> "TODO-设备级-dp-uuid",</p>
|
||||
<p> "TODO-账户级-dp-uuid-供-sub-订阅链接用"</p>
|
||||
<p> ],</p>
|
||||
<p> "targets": [</p>
|
||||
<p> {</p>
|
||||
<p> "_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",</p>
|
||||
<p> "domain": ["brain.51yanmei.com", "git.51yanmei.com"]</p>
|
||||
<p> },</p>
|
||||
<p> {</p>
|
||||
<p> "_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",</p>
|
||||
<p> "ip_cidr": ["182.92.213.171/32"],</p>
|
||||
<p> "port": [5001, 3389, 10022, 10023]</p>
|
||||
<p> }</p>
|
||||
<p> ]</p>
|
||||
<p>}</p>
|
||||
<pre><code>
|
||||
> `_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),可安全保留作自文档。样例里的 `TODO-` 值是**样例文件**的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
|
||||
|
||||
- [ ] **Step 2: 验证样例能被解析且渲染出预期规则**
|
||||
</code></pre>
|
||||
<p>cd server && cat > /tmp/acl_example_check.go <<'EOF'</p>
|
||||
<p>package main</p>
|
||||
<p>import (</p>
|
||||
<p> "fmt"</p>
|
||||
<p> "os"</p>
|
||||
<p> "github.com/wangjia/pangolin/server/internal/agentd"</p>
|
||||
<p>)</p>
|
||||
<p>func main() {</p>
|
||||
<p> ac, err := agentd.LoadACLConfig(os.Args[1])</p>
|
||||
<p> if err != nil {</p>
|
||||
<p> fmt.Println("FAIL:", err)</p>
|
||||
<p> os.Exit(1)</p>
|
||||
<p> }</p>
|
||||
<p> fmt.Printf("OK enabled=%v uuids=%d targets=%d\n", 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 && 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 首段(`<h2>8. 名单维护 Runbook</h2>` 之后的第一个 `<p>`)末尾追加一句:
|
||||
</code></pre>
|
||||
<p>样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。</p>
|
||||
<pre><code>
|
||||
- [ ] **Step 4: 提交**
|
||||
</code></pre>
|
||||
<p>cd server && go build ./... && 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 "docs(agent): 私有目的地 ACL 配置样例与 runbook 指向"</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 \
|
||||
"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 = '<我的邮箱>';"
|
||||
```
|
||||
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 "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])"` 确认规则顺序为 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 <-ctx.Done():
|
||||
return
|
||||
case <-hup:
|
||||
log.Printf("[pangolin-agent] SIGHUP: re-reading node-local config (acl.json/warp.json)")
|
||||
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 && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v</code></pre>
|
||||
<p>Expected: PASS。</p>
|
||||
<div class="step"><span class="box">[ ]</span><b>Step 5: 提交</b></div>
|
||||
<pre><code>cd server && go build ./... && 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 "feat(agent): SIGHUP 重读节点本地配置并热重渲染,不踢在线用户"</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>{
|
||||
"_comment": "私有目的地访问控制。复制到 /etc/pangolin-agent/acl.json 并填入自己的 dp_uuid。改完执行 systemctl reload pangolin-agent(不要 restart,restart 会冷启 sing-box 踢掉全部在线用户)。",
|
||||
"enabled": true,
|
||||
"allow_dp_uuids": [
|
||||
"TODO-设备级-dp-uuid",
|
||||
"TODO-账户级-dp-uuid-供-sub-订阅链接用"
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"_comment": "与公开站共用 ali:443 的私有 vhost,只能靠 SNI 区分",
|
||||
"domain": ["brain.51yanmei.com", "git.51yanmei.com"]
|
||||
},
|
||||
{
|
||||
"_comment": "独占端口的服务:DSM 5001 / RDP 3389 / NAS SSH 10022 / Win SSH 10023",
|
||||
"ip_cidr": ["182.92.213.171/32"],
|
||||
"port": [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("..", "..", "..", "deploy", "single-node", "acl.json.example")
|
||||
ac, err := LoadACLConfig(path)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
<p>跑:</p>
|
||||
<pre><code>cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -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><h2>8. 名单维护 Runbook</h2></code> 之后的第一个 <code><p></code>)末尾追加一句:</p>
|
||||
<pre><code>样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。</code></pre>
|
||||
<div class="step"><span class="box">[ ]</span><b>Step 4: 提交</b></div>
|
||||
<pre><code>cd server && go build ./... && 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 "docs(agent): 私有目的地 ACL 配置样例与常驻校验测试"</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> "SELECT d.dp_uuid FROM devices d JOIN users u ON u.id = d.user_id</p>
|
||||
<p> WHERE u.email = '<我的邮箱>' AND d.dp_uuid IS NOT NULL</p>
|
||||
<p> UNION</p>
|
||||
<p> SELECT dp_uuid FROM users WHERE email = '<我的邮箱>';"</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 "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])"</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 签名密钥)与若干无关的本地改动,误提交会泄露密钥。
|
||||
|
||||
---
|
||||
|
||||
## 上线验收(人工,非代码任务)
|
||||
|
||||
Reference in New Issue
Block a user