diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go index b4adeb9..b8e9ffe 100644 --- a/server/internal/agentd/acl_test.go +++ b/server/internal/agentd/acl_test.go @@ -299,68 +299,114 @@ func TestBuildRoute_Matrix(t *testing.T) { } }) - t.Run("仅 WARP → sniff + warp 规则(与改动前逐字节一致)", func(t *testing.T) { + t.Run("仅 WARP → sniff + resolve + 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[1].(map[string]any)["action"] != "resolve" { + t.Error("第二条不是 resolve(域名形式的目的地需要先解析才能命中后续 ip_cidr 规则)") } - if rules[2].(map[string]any)["action"] != "reject" { - t.Error("第三条不是拒绝规则") + if rules[2].(map[string]any)["outbound"] != warpOutboundTag { + t.Error("第三条不是 warp 分流") + } + if r["final"] != directOutboundTag { + t.Errorf("final = %v, want %q", r["final"], directOutboundTag) } }) - t.Run("都开 → sniff + ACL(放行,拒绝) + warp,且 sniff 只出现一次", func(t *testing.T) { - r := buildRoute(acl, warp) + t.Run("仅 ACL → sniff + resolve + 放行 + 拒绝", func(t *testing.T) { + r := buildRoute(acl, nil) rules := r["rules"].([]any) if len(rules) != 4 { t.Fatalf("规则数 = %d, want 4", len(rules)) } - sniffs := 0 + if rules[0].(map[string]any)["action"] != "sniff" { + t.Error("首条不是 sniff") + } + if rules[1].(map[string]any)["action"] != "resolve" { + t.Error("第二条不是 resolve") + } + if _, ok := rules[2].(map[string]any)["user"]; !ok { + t.Error("第三条不是放行规则") + } + if rules[3].(map[string]any)["action"] != "reject" { + t.Error("第四条不是拒绝规则") + } + }) + + t.Run("都开 → sniff + resolve + ACL(放行,拒绝) + warp,且 sniff/resolve 各只出现一次", func(t *testing.T) { + r := buildRoute(acl, warp) + rules := r["rules"].([]any) + if len(rules) != 5 { + t.Fatalf("规则数 = %d, want 5", len(rules)) + } + sniffs, resolves := 0, 0 for _, x := range rules { - if x.(map[string]any)["action"] == "sniff" { + switch x.(map[string]any)["action"] { + case "sniff": sniffs++ + case "resolve": + resolves++ } } if sniffs != 1 { t.Errorf("sniff 出现 %d 次, want 1", sniffs) } + if resolves != 1 { + t.Errorf("resolve 出现 %d 次, want 1", resolves) + } if rules[0].(map[string]any)["action"] != "sniff" { t.Error("sniff 必须最先") } + if rules[1].(map[string]any)["action"] != "resolve" { + t.Error("resolve 必须紧跟 sniff 之后") + } // ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口 - if rules[3].(map[string]any)["outbound"] != warpOutboundTag { + if rules[4].(map[string]any)["outbound"] != warpOutboundTag { t.Error("warp 规则必须排在最后") } - if rules[2].(map[string]any)["action"] != "reject" { + if rules[3].(map[string]any)["action"] != "reject" { t.Error("ACL 拒绝规则必须排在 warp 之前") } }) } +// I3:ip_cidr 目的地能被"域名形式"的请求绕过——ip_cidr 匹配的是已解析的目的地 +// 地址,客户端若发送域名形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr 规则 +// 不命中,请求穿透到 final:direct。route 块加 {"action":"resolve"} 让节点自己 +// 解析目的地,使域名形式收敛到 ip_cidr 规则上。resolve 必须紧跟 sniff 之后、且 +// 只在产出 route 块时才出现(与 sniff 同条件)。 +func TestBuildRoute_ResolvePositionedRightAfterSniff(t *testing.T) { + acl := &ACLConfig{ + Enabled: true, + AllowDpUUIDs: []string{"uuid-me"}, + Targets: []ACLTarget{{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}}}, + } + r := buildRoute(acl, nil) + rules := r["rules"].([]any) + if len(rules) < 2 { + t.Fatalf("规则数 = %d, 至少要有 sniff+resolve", len(rules)) + } + if rules[0].(map[string]any)["action"] != "sniff" { + t.Fatal("sniff 必须最先") + } + if rules[1].(map[string]any)["action"] != "resolve" { + t.Fatal("resolve 必须紧跟 sniff 之后") + } +} + +// 未配置 ACL 也未启用 WARP → 仍不产出 route 块(resolve 不该单独出现)。 +func TestBuildRoute_NeverConfigured_NoResolve(t *testing.T) { + if got := buildRoute(nil, nil); got != nil { + t.Errorf("buildRoute(nil,nil) = %v, want nil", got) + } +} + // 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。 func TestACL_PersistsLastGoodOnLoad(t *testing.T) { cfg := testConfig(t) diff --git a/server/internal/agentd/render.go b/server/internal/agentd/render.go index a5223ff..452df8a 100644 --- a/server/internal/agentd/render.go +++ b/server/internal/agentd/render.go @@ -71,9 +71,16 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age // 规则顺序是安全语义的一部分: // 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是 // 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。 -// 2. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远 +// 2. {"action":"resolve"} 紧跟 sniff 之后,同样唯一且只在产出 route 块时才出现。 +// ACL 的 ip_cidr 目的地(DSM/RDP/SSH 等独占端口服务)匹配的是已解析的连接 +// 目的地地址——若客户端发的是域名形式(如 nas.51yanmei.com:5001)而节点不主动 +// 解析,ip_cidr 规则不命中,请求会绕过 ACL 直接落到 final:direct。resolve 让 +// 节点自己解析目的地,使域名形式收敛到 ip_cidr 规则上,堵住这个绕过口子 +// (I3;这也是唯一一处从"节点不知道目的地"这条不变式主动后退的地方,是知情 +// 接受的取舍,见 docs/private-dest-acl-design.html §12)。 +// 3. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远 // 不该还有机会被路由到 warp 出口。 -// 3. final 恒为 direct。 +// 4. final 恒为 direct。 func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any { aclRules := acl.rules() warpActive := warp.active() @@ -81,8 +88,9 @@ func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any { return nil } - rules := make([]any, 0, len(aclRules)+2) + rules := make([]any, 0, len(aclRules)+3) rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, map[string]any{"action": "resolve"}) rules = append(rules, aclRules...) if warpActive { rules = append(rules, map[string]any{