From aaa3384e3b1c99c5c5fe1cd964fc7f0579c3fe90 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 23 Jul 2026 07:44:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20route=20=E5=8A=A0=20resolve=20ac?= =?UTF-8?q?tion,=E5=A0=B5=E4=BD=8F=20ip=5Fcidr=20=E7=9B=AE=E7=9A=84?= =?UTF-8?q?=E5=9C=B0=E7=9A=84=E5=9F=9F=E5=90=8D=E5=BD=A2=E5=BC=8F=E7=BB=95?= =?UTF-8?q?=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sing-box route 的 ip_cidr 匹配的是已解析的连接目的地地址;客户端若发的是域名 形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr + port 的拒绝规则不命中, 请求会绕过 ACL 直接落到 final:direct——DSM/RDP/SSH 因而对任何持有有效 dp_uuid 的 pangolin 用户可达,不受白名单约束。攻击者只需让客户端连接目的地时使用域名 而非 IP 即可绕过。 用户已拍板方案:在 sniff 之后紧跟加一条 {"action":"resolve"},让节点自己解析 目的地,使域名形式收敛到 ip_cidr 规则上(已用本机 sing-box 1.13.13 确认接受 [{"action":"sniff"},{"action":"resolve"},...] 这个形状,TestRenderedConfig_ PassesSingBoxCheck 也覆盖了这条产物)。route 规则顺序变为: sniff → resolve → ACL 放行 → ACL 拒绝 → WARP。resolve 与 sniff 同条件——只在 产出 route 块时才出现,唯一且紧跟 sniff。 代价(用户已知情接受):节点从此会对连接目的地做 DNS 解析,是对"节点只见不透明 dp_uuid、不知道目的地"这条隐私姿态的一次主动后退,详见设计文档 §12。 TDD:更新 TestBuildRoute_Matrix 的四态断言(规则数 2/3/4 → 3/4/5,新增 resolve 计数/位置断言),新增 TestBuildRoute_ResolvePositionedRightAfterSniff(RED:此前 resolve 不存在)与 TestBuildRoute_NeverConfigured_NoResolve(未配置态不受影响)。 确认既有 TestRender_Warp_InjectsEndpointAndRoute(只断言首条 sniff、末条 warp, 不断言精确条数)未受影响,原样绿。 Co-Authored-By: Claude Opus 4.8 --- server/internal/agentd/acl_test.go | 102 +++++++++++++++++++++-------- server/internal/agentd/render.go | 14 +++- 2 files changed, 85 insertions(+), 31 deletions(-) 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{