4155fc8542
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
340 lines
10 KiB
Go
340 lines
10 KiB
Go
package agentd
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 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 之前")
|
||
}
|
||
})
|
||
}
|