Files
pangolin/server/internal/agentd/acl_test.go
T
wangjia 63f4325e51 fix(agent): 空/手抖 acl.json 不得静默关闸或覆盖 last-good
loadACL 的成功分支此前无条件把解析出的 ACL 存为内存 last-good 并落盘,不管
它是否真的产出任何规则。encoding/json 忽略未知字段(必须如此,样例文件靠
_comment 自文档)——所以 "targetz"(手抖打错 "targets")或 "targets": [] 都能
解析通过,Targets 变 nil,active() 为 false,规则消失(gate 被静默关闭),且同一次
调用还会拿这份空配置覆盖 acl.last-good.json,销毁唯一的恢复快照。

修复:
- enabled=true 但 cleanTargets() 为空 → 视为加载失败,走 last-good 兜底,
  绝不返回、也绝不持久化这份空配置。
- 只有 active() 为真的配置才更新 last-good;active()=false 只可能是显式
  enabled:false(合法关闭),此时原样返回(不产出规则)但不覆盖磁盘上的
  last-good,保留后续误删/损坏时的恢复能力。
- 白名单为空但 target 有效(拒绝所有人的合法配置)不受影响,仍持久化为
  last-good、仍产出拒绝规则。

TDD:acl_test.go 新增三条 —— 手抖字段名(RED:拒绝规则消失且 last-good 被覆盖)、
显式 enabled:false(RED:last-good 被覆盖)、空白名单但有效 target(基线即绿,
防止修复过度矫正)。三条现全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:37:47 +08:00

607 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
)
// writeACL 把 acl.json 写到指定路径。
func writeACL(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
const validACL = `{
"enabled": true,
"allow_dp_uuids": ["uuid-me-1", "uuid-me-sub"],
"targets": [
{ "domain": ["brain.51yanmei.com", "git.51yanmei.com"] },
{ "ip_cidr": ["182.92.213.171/32"], "port": [5001, 3389, 10022, 10023] }
]
}`
func TestLoadACLConfig(t *testing.T) {
dir := t.TempDir()
t.Run("文件不存在返回 nil,nil(未配置,不是错误)", func(t *testing.T) {
ac, err := LoadACLConfig(filepath.Join(dir, "missing.json"))
if err != nil {
t.Fatalf("want nil error, got %v", err)
}
if ac != nil {
t.Fatalf("want nil config, got %+v", ac)
}
})
t.Run("坏 JSON 返回 error(绝不静默降级)", func(t *testing.T) {
p := filepath.Join(dir, "bad.json")
writeACL(t, p, `{"enabled": true,`)
if _, err := LoadACLConfig(p); err == nil {
t.Fatal("want error for malformed JSON, got nil")
}
})
t.Run("合法配置解析出全部字段", func(t *testing.T) {
p := filepath.Join(dir, "acl.json")
writeACL(t, p, validACL)
ac, err := LoadACLConfig(p)
if err != nil {
t.Fatal(err)
}
if !ac.Enabled {
t.Error("Enabled = false, want true")
}
if len(ac.AllowDpUUIDs) != 2 {
t.Errorf("AllowDpUUIDs len = %d, want 2", len(ac.AllowDpUUIDs))
}
if len(ac.Targets) != 2 {
t.Fatalf("Targets len = %d, want 2", len(ac.Targets))
}
if len(ac.Targets[0].Domain) != 2 {
t.Errorf("Targets[0].Domain len = %d, want 2", len(ac.Targets[0].Domain))
}
if len(ac.Targets[1].Port) != 4 {
t.Errorf("Targets[1].Port len = %d, want 4", len(ac.Targets[1].Port))
}
})
}
// active() 的语义与 WARP 相反:空白名单不等于「关闭」,而等于「谁都不许进」。
func TestACLActive_FailClosed(t *testing.T) {
cases := []struct {
name string
ac *ACLConfig
want bool
}{
{"nil 配置 → 未启用", nil, false},
{"enabled=false → 未启用(唯一的合法关闭途径)", &ACLConfig{
Enabled: false,
AllowDpUUIDs: []string{"u"},
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
}, false},
{"无 target → 未启用(无从拒起)", &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"u"},
}, false},
{"target 全为空条件 → 未启用", &ACLConfig{
Enabled: true,
Targets: []ACLTarget{{}},
}, false},
{"白名单为空但有 target → 仍启用(拒绝所有人)", &ACLConfig{
Enabled: true,
AllowDpUUIDs: nil,
Targets: []ACLTarget{{Domain: []string{"a.com"}}},
}, true},
{"完整配置 → 启用", &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"u"},
Targets: []ACLTarget{{IPCIDR: []string{"1.2.3.4/32"}, Port: []int{443}}},
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.ac.active(); got != tc.want {
t.Errorf("active() = %v, want %v", got, tc.want)
}
})
}
}
func TestACLCleanHelpers(t *testing.T) {
ac := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{" uuid-a ", "", "uuid-b"},
Targets: []ACLTarget{
{Domain: []string{" BRAIN.51yanmei.com ", ""}},
{},
{IPCIDR: []string{"1.2.3.4/32"}},
},
}
uuids := ac.cleanUUIDs()
if len(uuids) != 2 || uuids[0] != "uuid-a" || uuids[1] != "uuid-b" {
t.Errorf("cleanUUIDs() = %v, want [uuid-a uuid-b]", uuids)
}
targets := ac.cleanTargets()
if len(targets) != 2 {
t.Fatalf("cleanTargets() len = %d, want 2 (空 target 应被丢弃)", len(targets))
}
if targets[0].Domain[0] != "brain.51yanmei.com" {
t.Errorf("域名未规范化为小写去空白: %q", targets[0].Domain[0])
}
}
func TestACLTargetMatchFields(t *testing.T) {
tgt := ACLTarget{
Domain: []string{"a.com"},
IPCIDR: []string{"1.2.3.4/32"},
Port: []int{443, 5001},
}
m := tgt.matchFields()
if _, ok := m["domain"]; !ok {
t.Error("缺 domain 字段")
}
if _, ok := m["ip_cidr"]; !ok {
t.Error("缺 ip_cidr 字段")
}
if _, ok := m["port"]; !ok {
t.Error("缺 port 字段")
}
if _, ok := m["domain_suffix"]; ok {
t.Error("空的 domain_suffix 不应出现在输出里")
}
// matchFields 必须每次返回新 map,否则放行/拒绝两条规则会共享同一对象,
// 给其中一条加 "user"/"action" 会污染另一条。
m2 := tgt.matchFields()
m2["user"] = []string{"x"}
if _, ok := m["user"]; ok {
t.Error("matchFields 返回了共享 map,放行与拒绝规则会互相污染")
}
}
func TestConfigACLPaths(t *testing.T) {
c := Config{StateDir: "/etc/pangolin-agent"}.withDefaults()
if want := "/etc/pangolin-agent/acl.json"; c.ACLConfigPath != want {
t.Errorf("ACLConfigPath = %q, want %q", c.ACLConfigPath, want)
}
if want := "/etc/pangolin-agent/acl.last-good.json"; c.ACLLastGoodPath() != want {
t.Errorf("ACLLastGoodPath() = %q, want %q", c.ACLLastGoodPath(), want)
}
}
// rules() 必须产出「先全部放行、再全部拒绝」,且同一 target 两侧目的地条件逐字相同。
func TestACLRules_AllowThenDeny(t *testing.T) {
ac := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"uuid-me"},
Targets: []ACLTarget{
{Domain: []string{"brain.51yanmei.com"}},
{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}},
},
}
rules := ac.rules()
if len(rules) != 4 {
t.Fatalf("规则数 = %d, want 4 (2 target × 放行+拒绝)", len(rules))
}
// 前两条是放行:带 user + outbound,不带 action
for i := 0; i < 2; i++ {
r := rules[i].(map[string]any)
if _, ok := r["user"]; !ok {
t.Errorf("rules[%d] 放行规则缺 user", i)
}
if r["outbound"] != directOutboundTag {
t.Errorf("rules[%d] outbound = %v, want %q", i, r["outbound"], directOutboundTag)
}
if _, ok := r["action"]; ok {
t.Errorf("rules[%d] 放行规则不应带 action", i)
}
}
// 后两条是拒绝:带 action=reject,不带 user(对所有人生效)
for i := 2; i < 4; i++ {
r := rules[i].(map[string]any)
if r["action"] != "reject" {
t.Errorf("rules[%d] action = %v, want reject", i, r["action"])
}
if _, ok := r["user"]; ok {
t.Errorf("rules[%d] 拒绝规则不应带 user,否则会漏掉名单外的人", i)
}
}
// 对称性:target[0] 的放行(rules[0])与拒绝(rules[2])目的地条件必须逐字相同
allow0 := rules[0].(map[string]any)
deny0 := rules[2].(map[string]any)
if fmt.Sprint(allow0["domain"]) != fmt.Sprint(deny0["domain"]) {
t.Errorf("target0 放行/拒绝的 domain 不一致: %v vs %v", allow0["domain"], deny0["domain"])
}
allow1 := rules[1].(map[string]any)
deny1 := rules[3].(map[string]any)
if fmt.Sprint(allow1["ip_cidr"]) != fmt.Sprint(deny1["ip_cidr"]) ||
fmt.Sprint(allow1["port"]) != fmt.Sprint(deny1["port"]) {
t.Error("target1 放行/拒绝的 ip_cidr/port 不一致")
}
}
// 空白名单 → 不产出放行规则,但拒绝规则照出(fail-closed 的核心断言)。
func TestACLRules_EmptyAllowlistStillDenies(t *testing.T) {
ac := &ACLConfig{
Enabled: true,
AllowDpUUIDs: nil,
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
}
rules := ac.rules()
if len(rules) != 1 {
t.Fatalf("规则数 = %d, want 1 (仅拒绝)", len(rules))
}
r := rules[0].(map[string]any)
if r["action"] != "reject" {
t.Errorf("action = %v, want reject", r["action"])
}
}
// 未 active(含 nil / enabled=false)→ 无规则。
func TestACLRules_InactiveYieldsNil(t *testing.T) {
var nilACL *ACLConfig
if got := nilACL.rules(); got != nil {
t.Errorf("nil ACL rules() = %v, want nil", got)
}
off := &ACLConfig{Enabled: false, Targets: []ACLTarget{{Domain: []string{"a.com"}}}}
if got := off.rules(); got != nil {
t.Errorf("enabled=false rules() = %v, want nil", got)
}
}
// buildRoute 四态矩阵:ACL×WARP 开关的四种组合。
func TestBuildRoute_Matrix(t *testing.T) {
acl := &ACLConfig{
Enabled: true,
AllowDpUUIDs: []string{"uuid-me"},
Targets: []ACLTarget{{Domain: []string{"brain.51yanmei.com"}}},
}
warp := &WarpConfig{
Enabled: true, PrivateKey: "k", PeerPublicKey: "pk",
Endpoint: "162.159.192.1:2408", Address: []string{"172.16.0.2/32"},
Domains: []string{"reddit.com"},
}
t.Run("都关 → 不产出 route(向后兼容)", func(t *testing.T) {
if got := buildRoute(nil, nil); got != nil {
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
}
})
t.Run("仅 WARP → sniff + warp 规则(与改动前逐字节一致)", func(t *testing.T) {
r := buildRoute(nil, warp)
rules := r["rules"].([]any)
if len(rules) != 2 {
t.Fatalf("规则数 = %d, want 2", len(rules))
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Error("首条不是 sniff")
}
if rules[1].(map[string]any)["outbound"] != warpOutboundTag {
t.Error("次条不是 warp 分流")
}
if r["final"] != directOutboundTag {
t.Errorf("final = %v, want %q", r["final"], directOutboundTag)
}
})
t.Run("仅 ACL → sniff + 放行 + 拒绝", func(t *testing.T) {
r := buildRoute(acl, nil)
rules := r["rules"].([]any)
if len(rules) != 3 {
t.Fatalf("规则数 = %d, want 3", len(rules))
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Error("首条不是 sniff")
}
if _, ok := rules[1].(map[string]any)["user"]; !ok {
t.Error("第二条不是放行规则")
}
if rules[2].(map[string]any)["action"] != "reject" {
t.Error("第三条不是拒绝规则")
}
})
t.Run("都开 → sniff + ACL(放行,拒绝) + warp,且 sniff 只出现一次", func(t *testing.T) {
r := buildRoute(acl, warp)
rules := r["rules"].([]any)
if len(rules) != 4 {
t.Fatalf("规则数 = %d, want 4", len(rules))
}
sniffs := 0
for _, x := range rules {
if x.(map[string]any)["action"] == "sniff" {
sniffs++
}
}
if sniffs != 1 {
t.Errorf("sniff 出现 %d 次, want 1", sniffs)
}
if rules[0].(map[string]any)["action"] != "sniff" {
t.Error("sniff 必须最先")
}
// ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口
if rules[3].(map[string]any)["outbound"] != warpOutboundTag {
t.Error("warp 规则必须排在最后")
}
if rules[2].(map[string]any)["action"] != "reject" {
t.Error("ACL 拒绝规则必须排在 warp 之前")
}
})
}
// 成功加载后必须把快照落盘,否则 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.json 被删除 → 规则不能消失(内存 last-good 兜底)。
func TestACL_DeletedFileKeepsInMemoryLastGood(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.Remove(cfg.ACLConfigPath); err != nil {
t.Fatalf("failed to remove acl.json: %v", err)
}
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("acl.json 被删除后拒绝规则消失了 —— 这是 fail-open,私有服务已敞开")
}
}
// 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 触发了热重载")
}
// 新 agent 实例(模拟进程重启) + 被删的 acl.json → 磁盘 last-good 兜底,规则仍在。
func TestACL_ColdStartAfterDeletedFileFallsBackToDisk(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)
}
if err := os.Remove(cfg.ACLConfigPath); err != nil {
t.Fatalf("failed to remove acl.json: %v", err)
}
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("冷启动(acl.json 删除)未回退到磁盘 last-good,私有服务已敞开")
}
}
// 样例文件必须始终可被解析且产出预期规则,防止改了 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)
}
}
}
// ─── C2: 手抖字段名(如 targets → targetz)不得静默关闸,也不得覆盖 last-good ──────
// 字段名手抖(targetz)→ encoding/json 忽略未知字段,Targets 变 nil,active()=false。
// 若不做特殊处理,旧代码会把这份"看似合法"的空配置当成新的 last-good 落盘,
// 把恢复用的快照也一起冲掉。正确行为:当作加载失败处理,回退 last-good,不覆盖磁盘。
func TestACL_TypoFieldWithEnabledTrue_FallsBackWithoutOverwritingLastGood(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)
}
before, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatalf("last-good 未在健康加载后落盘: %v", err)
}
// "targets" 手抖成 "targetz":JSON 仍能解析,但 Targets 字段收不到值。
writeACL(t, cfg.ACLConfigPath, `{"enabled":true,"allow_dp_uuids":["x"],"targetz":[{"domain":["a.com"]}]}`)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("字段名手抖(targetz)后拒绝规则消失了 —— 这是静默关闸,私有服务已敞开")
}
after, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("手抖配置覆盖了 acl.last-good.json —— 恢复用的快照被销毁了")
}
}
// 显式 enabled:false 是唯一合法的关闭方式,渲染结果必须真的没有 reject 规则;
// 但它不该覆盖磁盘上已有的 last-good 快照——否则日后 acl.json 意外损坏/丢失时,
// 回退到的将是这份"已关闭"的快照而不是最近一次真正 active 的配置。
func TestACL_ExplicitDisableDoesNotOverwriteLastGood(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)
}
before, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatalf("last-good 未在健康加载后落盘: %v", err)
}
writeACL(t, cfg.ACLConfigPath, `{"enabled": false, "allow_dp_uuids": ["x"], "targets": [{"domain":["a.com"]}]}`)
data, err := sb.RenderConfig()
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "reject") {
t.Fatal("enabled=false 应真正关闭 ACL,不应再产出 reject 规则")
}
after, err := os.ReadFile(cfg.ACLLastGoodPath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("显式 enabled=false 不该覆盖 last-good 快照,否则日后 acl.json 损坏就回不到真正 active 的配置了")
}
}
// 白名单为空但 target 有效 → 是合法的"拒绝所有人"配置,不是手抖,必须仍被当作
// last-good 持久化,且仍产出拒绝规则。防止 C2 修复对"零 target"的特判过度矫正到
// "零白名单"上。
func TestACL_EnabledWithEmptyAllowlistStillPersistsAndDenies(t *testing.T) {
cfg := testConfig(t)
writeACL(t, cfg.ACLConfigPath, `{"enabled": true, "allow_dp_uuids": [], "targets": [{"domain": ["brain.51yanmei.com"]}]}`)
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)
}
if !strings.Contains(string(data), "reject") {
t.Fatal("空白名单+有效 target 应仍产出拒绝规则(拒所有人),不该被当成手抖")
}
if _, err := os.Stat(cfg.ACLLastGoodPath()); err != nil {
t.Fatalf("空白名单(但有效 target)的配置应被当作合法 active 配置持久化为 last-good: %v", err)
}
}