diff --git a/server/internal/agentd/acl_test.go b/server/internal/agentd/acl_test.go index 53e0fed..b849044 100644 --- a/server/internal/agentd/acl_test.go +++ b/server/internal/agentd/acl_test.go @@ -256,3 +256,84 @@ func TestACLRules_InactiveYieldsNil(t *testing.T) { 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 之前") + } + }) +} diff --git a/server/internal/agentd/render.go b/server/internal/agentd/render.go index ef2966d..a5223ff 100644 --- a/server/internal/agentd/render.go +++ b/server/internal/agentd/render.go @@ -13,6 +13,11 @@ import ( // // Only the opaque dp_uuid is ever written; no account identity touches the node. // +// 例外(私有目的地 ACL):若节点配置了 acl.json,渲染出的 route 规则会含一份享有私有 +// 访问权的 dp_uuid 白名单与对应的私有域名/端口。它仍不含任何账户身份(email/user_id), +// 但确实让节点知道「这几个 dp_uuid 属于同一组权限」—— 知情接受的不变式弱化, +// 设计与权衡见 docs/private-dest-acl-design.html §12。 +// // 用量统计走 v2ray_api StatsService(loopback gRPC):节点 sing-box 编入 // with_v2ray_api,stats.users 列出全部 dp_uuid → 每个用户独立的 // user>>>{dp_uuid}>>>traffic>>>uplink|downlink 计数器,agent 按用户精确读取 @@ -27,7 +32,7 @@ const ( warpOutboundTag = "warp" ) -func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) { +func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig, acl *ACLConfig) ([]byte, error) { cfg := map[string]any{ "log": map[string]any{"level": "warn", "timestamp": true}, "inbounds": buildInbounds(creds, reality, hy2, deriveKey), @@ -47,16 +52,47 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age }, } - // WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。 - // warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。 + // WARP 分流(#29)只贡献 endpoints;route 块由 buildRoute 统一产出,因为它现在要 + // 同时容纳 ACL 规则 —— 原先 cfg["route"] = warp.warpRoute() 是整块覆盖,直接赋值 + // 会把对方的规则干掉。 if warp.active() { cfg["endpoints"] = []any{warp.warpEndpoint()} - cfg["route"] = warp.warpRoute() + } + if route := buildRoute(acl, warp); route != nil { + cfg["route"] = route } return json.MarshalIndent(cfg, "", " ") } +// buildRoute 合并私有目的地 ACL 与 WARP 分流,产出单一 route 块。 +// 两者都未激活时返回 nil —— 不产出 route 字段,与旧配置逐字节一致(向后兼容)。 +// +// 规则顺序是安全语义的一部分: +// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是 +// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。 +// 2. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远 +// 不该还有机会被路由到 warp 出口。 +// 3. final 恒为 direct。 +func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any { + aclRules := acl.rules() + warpActive := warp.active() + if len(aclRules) == 0 && !warpActive { + return nil + } + + rules := make([]any, 0, len(aclRules)+2) + rules = append(rules, map[string]any{"action": "sniff"}) + rules = append(rules, aclRules...) + if warpActive { + rules = append(rules, map[string]any{ + "domain_suffix": warp.cleanDomains(), + "outbound": warpOutboundTag, + }) + } + return map[string]any{"rules": rules, "final": directOutboundTag} +} + // statsUsers 收集所有去重 dp_uuid,供 v2ray_api stats.users 按用户开启流量计数器。 func statsUsers(creds []Cred) []string { seen := make(map[string]struct{}, len(creds)) diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index 9cbf554..e2c5b67 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -323,7 +323,7 @@ func (s *SingBox) RenderConfig() ([]byte, error) { logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err) warp = nil } - return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp) + return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp, nil) } // writeAndRestart renders, writes the config file and restarts sing-box. diff --git a/server/internal/agentd/warp.go b/server/internal/agentd/warp.go index 1aed2fa..d86e97f 100644 --- a/server/internal/agentd/warp.go +++ b/server/internal/agentd/warp.go @@ -100,15 +100,3 @@ func (wc *WarpConfig) warpEndpoint() map[string]any { "peers": []any{peer}, } } - -// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP, -// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。 -func (wc *WarpConfig) warpRoute() map[string]any { - return map[string]any{ - "rules": []any{ - map[string]any{"action": "sniff"}, - map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag}, - }, - "final": directOutboundTag, - } -}