feat(agent): ACL 渲染放行/拒绝规则对,空白名单仍拒绝

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-23 01:40:11 +08:00
parent d4156b81e2
commit 597d6ab68b
2 changed files with 118 additions and 0 deletions
+35
View File
@@ -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
}
+83
View File
@@ -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)
}
}