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