From 87d5afa703c6ad6bec5a24151c60885892964f0e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 23 Jul 2026 02:01:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E6=B8=B2=E6=9F=93=E6=97=B6?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=20ACL,=E5=86=85=E5=AD=98+=E7=A3=81=E7=9B=98?= =?UTF-8?q?=20last-good=20=E4=BF=9D=E8=AF=81=20fail-closed=20=E8=B7=A8?= =?UTF-8?q?=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- server/internal/agentd/acl.go | 9 ++++ server/internal/agentd/acl_test.go | 79 ++++++++++++++++++++++++++++++ server/internal/agentd/singbox.go | 53 +++++++++++++++++++- 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/server/internal/agentd/acl.go b/server/internal/agentd/acl.go index 354b0e0..d9af0d7 100644 --- a/server/internal/agentd/acl.go +++ b/server/internal/agentd/acl.go @@ -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) +} diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go index b849044..390f53e 100644 --- a/server/internal/agentd/acl_test.go +++ b/server/internal/agentd/acl_test.go @@ -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 块") + } +} diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index e2c5b67..dc734ad 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -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.