feat(agent): 渲染时读取 ACL,内存+磁盘 last-good 保证 fail-closed 跨重启

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-23 02:01:45 +08:00
parent 4155fc8542
commit 87d5afa703
3 changed files with 140 additions and 1 deletions
+9
View File
@@ -176,3 +176,12 @@ func (ac *ACLConfig) rules() []any {
}
return out
}
// 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)
}
+79
View File
@@ -1,10 +1,14 @@
package agentd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// writeACL 把 acl.json 写到指定路径。
@@ -337,3 +341,78 @@ func TestBuildRoute_Matrix(t *testing.T) {
}
})
}
// 成功加载后必须把快照落盘,否则 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 块")
}
}
+52 -1
View File
@@ -61,6 +61,10 @@ type SingBox struct {
hy2 *agentv1.Hy2Inbound
configVersion int64
// lastGoodACL 是最近一次成功加载的私有目的地 ACL。acl.json 读坏时回退到它,
// 而不是像 WARP 那样降级为「不启用」—— 对访问控制,降级即敞开。
lastGoodACL *ACLConfig
// started 标记 sing-box 是否已被本 agent 冷启动过:首次走 Restart(冷启动),
// 之后配置变更走 Reload(SIGHUP 热重载)。agent 进程重启后复位为 false,
// 下次渲染做一次冷启动以确保与渲染配置一致。
@@ -323,7 +327,54 @@ func (s *SingBox) RenderConfig() ([]byte, error) {
logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err)
warp = nil
}
return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, 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
}
// writeAndRestart renders, writes the config file and restarts sing-box.