diff --git a/server/internal/agentd/acl.go b/server/internal/agentd/acl.go index ee8f336..354b0e0 100644 --- a/server/internal/agentd/acl.go +++ b/server/internal/agentd/acl.go @@ -141,3 +141,38 @@ func cleanStrings(in []string) []string { } return out } + +// rules 产出 ACL 的 sing-box route 规则:每个 target 一对 —— 先放行白名单、再兜底拒绝。 +// +// 顺序是安全性的一部分,不可重排: +// 1. 全部放行规则排在全部拒绝规则之前。不能按 target 交错(放行A/拒绝A/放行B/拒绝B), +// 因为 target 之间可能重叠,交错会让 B 的成员被 A 的拒绝规则先命中。 +// 2. 拒绝规则不带 user 维度 —— 它要对「白名单之外的所有人」生效。 +// 3. 同一 target 的放行与拒绝,目的地条件由同一个 matchFields() 生成,保证逐字相同。 +// 任何不对称都会造成「我自己也被拒」或「有人漏网」。 +// +// 白名单为空时只产出拒绝规则(谁都不许进),这是 fail-closed 的核心:空名单的语义是 +// 「没有人」而非「所有人」。 +func (ac *ACLConfig) rules() []any { + if !ac.active() { + return nil + } + uuids := ac.cleanUUIDs() + targets := ac.cleanTargets() + + out := make([]any, 0, len(targets)*2) + if len(uuids) > 0 { + for _, t := range targets { + r := t.matchFields() + r["user"] = uuids + r["outbound"] = directOutboundTag + out = append(out, r) + } + } + for _, t := range targets { + r := t.matchFields() + r["action"] = "reject" + out = append(out, r) + } + return out +} diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go index b0bdee2..53e0fed 100644 --- a/server/internal/agentd/acl_test.go +++ b/server/internal/agentd/acl_test.go @@ -1,6 +1,7 @@ package agentd import ( + "fmt" "os" "path/filepath" "testing" @@ -173,3 +174,85 @@ func TestConfigACLPaths(t *testing.T) { 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) + } +}