diff --git a/docs/private-dest-acl-plan.html b/docs/private-dest-acl-plan.html index 390a184..b4f020e 100644 --- a/docs/private-dest-acl-plan.html +++ b/docs/private-dest-acl-plan.html @@ -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。 +

⑤ 修改 server/internal/agentd/singbox.go 第 326 行,补一个 nil 实参(Task 4 会替换成真实 ACL):

+
	return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v
+

Expected: PASS。特别确认既有的 TestRender_NoWarp_NoRouteSectionTestRender_Warp_InjectsEndpointAndRoute 仍绿——这两条守着向后兼容。

+
[ ]Step 5: 提交
+
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"
+
+

Task 4: 渲染时读取 ACL,last-good 内存 + 落盘兜底

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.go

+
// 成功加载后必须把快照落盘,否则 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):
-
-

return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil)

-

-- [ ] **Step 4: 跑测试确认通过**
-
-

cd server && go test ./internal/agentd/ -run 'BuildRoute|Warp|Render' -v

-

-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: 提交**
-
-

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"

-

----
+// 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 块")
+	}
+}
+

acl_test.go 的 import 块补齐 "encoding/json""strings",以及 agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"

+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+

Expected: TestACL_PersistsLastGoodOnLoad 失败(last-good 未落盘),TestACL_BrokenFile... / TestACL_ColdStart... 失败(渲染结果里没有 reject,因为 Task 3 传的是 nil)。

+
[ ]Step 3: 写实现
+

① 在 server/internal/agentd/acl.go 末尾追加:

+
// 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)
+}
+

② 在 server/internal/agentd/singbox.goSingBox 结构体中,configVersion int64 之后新增字段:

+
	// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
+	// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
+	lastGoodACL *ACLConfig
+

③ 在 server/internal/agentd/singbox.goRenderConfig 中,把 WARP 加载之后的部分替换为:

+
	// 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`:
-
-

// 成功加载后必须把快照落盘,否则 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)

-

if _, err := sb.RenderConfig(); err != nil {

-

t.Fatal(err)

-

}

-

if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {

-

t.Fatalf("last-good 未落盘: %v", err)

-

}

-

}

-

// 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)

-

}

-

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,私有服务已敞开")

-

}

-

}

-

// 新 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)

-

}

-

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,私有服务已敞开")

-

}

-

}

-

// 从未配置过(无 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 块")

-

}

-

}

-

-在 `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: 跑测试确认失败**
-
-

cd server && go test ./internal/agentd/ -run 'TestACL_' -v

-

-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` 末尾追加:
-
-

// 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)

-

}

-

-② 在 `server/internal/agentd/singbox.go` 的 `SingBox` 结构体中,`configVersion int64` 之后新增字段:
-
-

// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,

-

// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。

-

lastGoodACL *ACLConfig

-

-③ 在 `server/internal/agentd/singbox.go` 的 `RenderConfig` 中,把 WARP 加载之后的部分替换为:
-
-

// 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())

-

}

-

// 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)

-

}

-

s.mu.Lock()

-

lg := s.lastGoodACL

-

s.mu.Unlock()

-

if lg != nil {

-

logf("[acl] falling back to in-memory last-good ACL")

-

return lg

-

}

-

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

-

}

-

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

-

}

-

-- [ ] **Step 4: 跑测试确认通过**
-
-

cd server && go test ./internal/agentd/ -run 'TestACL_' -v

-

-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
+}
+
[ ]Step 4: 跑测试确认通过
+
cd server && go test ./internal/agentd/ -run 'TestACL_' -v
+

Expected: PASS,四条全绿。

+
[ ]Step 5: 提交
+
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 跨重启"
+
+

Task 5: SIGHUP 触发重渲染(避免重启踢掉全部在线用户)

+

Files:

+ +

Interfaces:

+ +

为什么不用 systemctl restart pangolin-agent 新进程内 s.started == falsesingbox.go:64-67),首次渲染会走 restarter.Restart()systemctl restart sing-box全部在线用户瞬断。SIGHUP 走的是既有的 debounce → writeAndRestartReload()(SIGHUP 热重载)路径,不断线。

+
[ ]Step 1: 写失败测试
+

追加到 server/internal/agentd/acl_test.go

+
// 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: 提交**
-
-

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 跨重启"

-

----
+	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 触发了热重载")
+}
+

acl_test.go 的 import 块补齐 "context""time"

+

复用 singbox_test.go:17-47 既有的 fakeRestartercount() 返回 Restart 次数、reloadCount() 返回 Reload 次数)与 integration_test.go:248eventually(t *testing.T, timeout time.Duration, fn func() bool, msg string)

+
[ ]Step 2: 跑测试确认失败
+
cd server && go test ./internal/agentd/ -run 'Refresh' -v
+

Expected: 编译失败,sb.Refresh undefined

+
[ ]Step 3: 写实现
+

① 在 server/internal/agentd/singbox.gomarkDirty 之后追加:

+
// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件
+// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走
+// 冷启动(Restart),把全部在线用户踢下线。
+func (s *SingBox) Refresh() { s.markDirty() }
+

② 修改 server/cmd/agent/main.go,把第 44–45 行替换为:

+
	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`:
-
-

// 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)

-

ctx, cancel := context.WithCancel(context.Background())

-

defer cancel()

-

go sb.Run(ctx)

-

// 等首次渲染落地(ApplyConfig 已 markDirty)

-

eventually(t, 2*time.Second, func() bool {

-

_, err := os.Stat(cfg.SingboxConfigPath)

-

return err == nil

-

}, "首次渲染写出配置")

-

// 断言 reloadCount 而非 count:首次渲染已冷启动过(started=true),此后的重渲染

-

// 一律走 Reload(SIGHUP 热重载),Restart 计数不会再增加。断言错计数器会假失败。

-

before := fr.reloadCount()

-

sb.Refresh()

-

eventually(t, 2*time.Second, func() bool { return fr.reloadCount() > before }, "Refresh 触发了热重载")

-

}

-

-在 `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: 跑测试确认失败**
-
-

cd server && go test ./internal/agentd/ -run 'Refresh' -v

-

-Expected: 编译失败,`sb.Refresh undefined`。
-
-- [ ] **Step 3: 写实现**
-
-① 在 `server/internal/agentd/singbox.go` 的 `markDirty` 之后追加:
-
-

// Refresh 请求一次重渲染。供 agent 收到 SIGHUP 时调用,使编辑节点本地配置文件

-

// (acl.json / warp.json)后无需重启进程即可生效 —— 重启 agent 会让 sing-box 走

-

// 冷启动(Restart),把全部在线用户踢下线。

-

func (s *SingBox) Refresh() { s.markDirty() }

-

-② 修改 `server/cmd/agent/main.go`,把第 44–45 行替换为:
-
-

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

-

defer stop()

-

// 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()

-

}

-

}

-

}()

-

-`os` 与 `log` 已在该文件 import 中(`os` 见 `env()` 用到的 `os.LookupEnv`);若 `go build` 报缺失,按提示补齐。
-
-- [ ] **Step 4: 跑测试确认通过**
-
-

cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v

-

-Expected: PASS。
-
-- [ ] **Step 5: 提交**
-
-

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 重读节点本地配置并热重渲染,不踢在线用户"

-

----
-
-### 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`:
-
-

{

-

"_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]

-

}

-

]

-

}

-

-> `_comment` 字段会被 `encoding/json` 忽略(`ACLConfig` 无对应字段且未启用 `DisallowUnknownFields`),可安全保留作自文档。样例里的 `TODO-` 值是**样例文件**的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。
-
-- [ ] **Step 2: 验证样例能被解析且渲染出预期规则**
-
-

cd server && cat > /tmp/acl_example_check.go <<'EOF'

-

package main

-

import (

-

"fmt"

-

"os"

-

"github.com/wangjia/pangolin/server/internal/agentd"

-

)

-

func main() {

-

ac, err := agentd.LoadACLConfig(os.Args[1])

-

if err != nil {

-

fmt.Println("FAIL:", err)

-

os.Exit(1)

-

}

-

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`
-
-- [ ] **Step 3: 更新设计文档的 runbook 指向**
-
-在 `docs/private-dest-acl-design.html` 的 §8 首段(`<h2>8. 名单维护 Runbook</h2>` 之后的第一个 `<p>`)末尾追加一句:
-
-

样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。

-

-- [ ] **Step 4: 提交**
-
-

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 指向"

-

----
-
-## 上线验收(人工,非代码任务)
-
-代码合并后在 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,届时渲染层可原样保留)
-
+ // 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() + } + } + }() +

oslog 已在该文件 import 中(osenv() 用到的 os.LookupEnv);若 go build 报缺失,按提示补齐。

+
[ ]Step 4: 跑测试确认通过
+
cd server && go build ./... && go test ./internal/agentd/ -run 'Refresh' -v
+

Expected: PASS。

+
[ ]Step 5: 提交
+
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 重读节点本地配置并热重渲染,不踢在线用户"
+
+

Task 6: 节点配置样例与运维文档

+

Files:

+ +

Interfaces:

+ +
[ ]Step 1: 写样例文件
+

创建 deploy/single-node/acl.json.example

+
{
+  "_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]
+    }
+  ]
+}
+

_comment 字段会被 encoding/json 忽略(ACLConfig 无对应字段且未启用 DisallowUnknownFields),可安全保留作自文档。样例里的 TODO- 值是样例文件的占位,不是计划的占位——真实 uuid 属 PII 级标识,不入 git,由运维按 Step 2 的 SQL 现取现填。

+
[ ]Step 2: 加一条测试,锁死「样例文件永远可解析」
+

样例文件会随需求漂移(改了 target 形状却忘了同步样例),靠人工 go run 检查一次没有约束力。改为一条常驻测试。追加到 server/internal/agentd/acl_test.go

+
// 样例文件必须始终可被解析且产出预期规则,防止改了 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)
+		}
+	}
+}
+

跑:

+
cd server && go test ./internal/agentd/ -run 'ExampleFileStaysValid' -v
+

Expected: PASS。

+

注:_comment 字段会被 encoding/json 忽略(ACLConfig 无对应字段且未启用 DisallowUnknownFields),故样例里的自文档注释不影响解析——这条测试同时守着这个前提。

+
[ ]Step 3: 更新设计文档的 runbook 指向
+

docs/private-dest-acl-design.html 的 §8 首段(<h2>8. 名单维护 Runbook</h2> 之后的第一个 <p>)末尾追加一句:

+
样例文件见 <code>deploy/single-node/acl.json.example</code>,复制到 <code>/etc/pangolin-agent/acl.json</code> 后按下方 SQL 填入 uuid。
+
[ ]Step 4: 提交
+
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 配置样例与常驻校验测试"
+

git add 只列具体路径,切勿 git add -A:仓库里有未跟踪的 pangolin-release.jks(Android 签名密钥)与若干无关的本地改动,误提交会泄露密钥。

+
+

上线验收(人工,非代码任务)

+

代码合并后在 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 订阅链接用,漏了会把自己锁在外面):
  2. +
+

```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 = '<我的邮箱>';"

+

```

+
    +
  1. /etc/pangolin-agent/acl.json(权限 0600,属主 pangolin),systemctl reload pangolin-agent
  2. +
  3. journalctl -u pangolin-agent -n 30 确认已重渲染、无 [acl] ERROR / [acl] ALERT
  4. +
  5. sudo python3 -c "import json;print(json.load(open('/etc/sing-box/config.json'))['route'])" 确认规则顺序为 sniff → 放行 → 拒绝 → warp。
  6. +
  7. 我的设备:brain 首页 200、DSM 5001 可登录、ssh nas-r 通。
  8. +
  9. 另一账号的设备:以上全部被拒(连接被 reject,不是超时)。
  10. +
  11. 公开站不受影响:jiu / travel / sudoku / pay 在两个账号下均正常。
  12. +
  13. WARP 未被破坏:reddit 仍走 warp 出口。
  14. +
  15. fail-closed 实证:把 acl.json 改坏 → systemctl reload pangolin-agent → 规则仍在、日志有 ERROR;恢复文件。
  16. +
  17. 不断线实证:reload 期间另一台设备保持连接不掉。
  18. +
+

不在本计划范围内

+ diff --git a/docs/superpowers/plans/2026-07-23-private-dest-acl.md b/docs/superpowers/plans/2026-07-23-private-dest-acl.md index 5aec700..eee0c4e 100644 --- a/docs/superpowers/plans/2026-07-23-private-dest-acl.md +++ b/docs/superpowers/plans/2026-07-23-private-dest-acl.md @@ -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 签名密钥)与若干无关的本地改动,误提交会泄露密钥。 + --- ## 上线验收(人工,非代码任务)