From f76aa56929498d99b429fab62c478f0b12a3ea6a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 28 Jul 2026 08:01:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(routing):=20BuildClientConfig=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E7=94=A8=E6=88=B7=E8=A7=84=E5=88=99(=E5=B1=82?= =?UTF-8?q?=E7=BA=A7/IP=E7=9B=B4=E8=BF=9E/=E5=9F=9F=E5=90=8D=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E/=E4=B8=89=E6=A8=A1=E5=BC=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientConfigOpts 加 Profile *routing.Profile(nil = 逐字节回退旧行为)。 translateUserRules 把 profile.Rules 翻译成 route.rules,插在系统层3 (私有域名)之后、国内分流之前:action→outbound、type→字段映射;IP 直连 并入 TUN 入站的 route_exclude_address(真正生效的字段,而非顶层 route 对象);域名直连开 dns.reverse_mapping;三模式(rule/global/direct)决定 route.final 与是否跳过用户规则层/国内分流层。 Co-Authored-By: Claude Opus 4.8 --- server/internal/httpapi/clientconfig.go | 180 +++++++++++++- server/internal/httpapi/clientconfig_test.go | 236 +++++++++++++++++++ 2 files changed, 408 insertions(+), 8 deletions(-) diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index af0541d..0db1740 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -6,6 +6,7 @@ import ( "github.com/wangjia/pangolin/server/internal/dpcred" "github.com/wangjia/pangolin/server/internal/nodes" + "github.com/wangjia/pangolin/server/internal/routing" ) // ClientConfigOpts carries per-request rendering options for BuildClientConfig. @@ -22,6 +23,81 @@ type ClientConfigOpts struct { // 必须排在国内分流(geoip-cn)之前:锚点(frps@ali)是国内 IP,否则 smartRoute // 会把它分流成直连,被 frps 侧安全组限源(仅节点出口)拦截。 PrivateSplitDomains []string + // Profile 是当前用户的路由档案(可配置分流,Task 1-3)。nil = 完全回退旧行为 + // (仅由 SplitCN/PrivateSplitDomains 决定),用于灰度/未迁移用户与旧调用点。 + // 非 nil 时: + // - Mode=="rule":按 Profile.Rules(Enabled)插入用户规则层(系统层3之后、 + // 国内分流之前);国内分流条件由 opts.SplitCN 改为 Profile.Builtin.ChinaDirect; + // route.final 由 Profile.Final 决定(direct→"direct"/否则"auto")。 + // - Mode=="global"/"direct":跳过用户规则层与国内分流层,route.final 分别为 + // "auto"/"direct";系统层(hijack-dns/LAN/私有域名)不受影响,恒生效。 + Profile *routing.Profile +} + +// translateUserRules 把 profile 的用户规则翻译成 route.rules 条目。仅 +// Mode=="rule" 时产出内容;其余模式(global/direct)按约定跳过用户规则层, +// 返回全零值。nil profile 由调用方在外层门控,不会传进来。 +// +// 返回值: +// - rules: 按 Profile.Rules 顺序生成的 route.rules 条目(仅 Enabled==true)。 +// - extraExclude: type==ip_cidr && action==direct 的 value,供调用方并入 +// TUN 入站的 route_exclude_address(auto_route 层直连才真正生效, +// 见 tunIn 构造处注释)。 +// - hasDomainDirect: 是否存在 action==direct 的域名类规则(domain/ +// domain_suffix/domain_keyword),供调用方决定是否开 dns.reverse_mapping。 +// - geoSets: 规则引用到的 geoip-/geosite- rule_set tag(去重),供 +// 调用方在国内分流(splitActive)之外也补上 rule_set 定义。 +func translateUserRules(p *routing.Profile) (rules []any, extraExclude []string, hasDomainDirect bool, geoSets []string) { + if p == nil || p.Mode != "rule" { + return nil, nil, false, nil + } + seenGeo := map[string]bool{} + for _, r := range p.Rules { + if !r.Enabled { + continue + } + var outbound string + switch r.Action { + case "direct": + outbound = "direct" + case "proxy": + outbound = "auto" + case "reject": + outbound = "block" + default: + // 未知 action 防御性跳过(Validate 已在写入路径拦截,这里双保险)。 + continue + } + switch r.Type { + case "domain", "domain_suffix", "domain_keyword": + rules = append(rules, map[string]any{r.Type: []string{r.Value}, "outbound": outbound}) + if outbound == "direct" { + hasDomainDirect = true + } + case "ip_cidr": + rules = append(rules, map[string]any{"ip_cidr": []string{r.Value}, "outbound": outbound}) + if outbound == "direct" { + extraExclude = append(extraExclude, r.Value) + } + case "geoip", "geosite": + tag := r.Type + "-" + strings.ToLower(r.Value) + rules = append(rules, map[string]any{"rule_set": []string{tag}, "outbound": outbound}) + if !seenGeo[tag] { + seenGeo[tag] = true + geoSets = append(geoSets, tag) + } + } + } + return rules, extraExclude, hasDomainDirect, geoSets +} + +// ruleSetDef 渲染一个自托管 remote rule_set 定义(与既有 geoip-cn/geosite-cn +// 定义块同构:base + "/v1/rules/.srs",direct 直连下载)。 +func ruleSetDef(tag, base string) map[string]any { + return map[string]any{ + "tag": tag, "type": "remote", "format": "binary", + "url": base + "/v1/rules/" + tag + ".srs", "download_detour": "direct", + } } // BuildClientConfig renders a complete sing-box CLIENT configuration JSON that @@ -161,8 +237,69 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien "outbound": "auto", }) } + + // 用户路由规则(可配置分流,Task 4):插在系统层3(私有域名)之后、 + // 国内分流(splitActive,下方)之前。opts.Profile==nil 时 translateUserRules + // 直接返回全零值,以下每一步都随之短路,保证 nil-profile 渲染逐字节不变。 + var userRules []any + var extraExclude []string + var hasDomainDirect bool + var geoSets []string + if opts.Profile != nil { + userRules, extraExclude, hasDomainDirect, geoSets = translateUserRules(opts.Profile) + if opts.RulesBaseURL == "" && len(geoSets) > 0 { + // 没有 base 就没法渲染 remote rule_set 的下载 URL,引用它的用户规则 + // 会指向未定义的 tag(sing-box FATAL)。与 splitActive 缺 base 时静默 + // 跳过国内分流同一语义:丢弃这些规则,不让配置渲染出无效引用。 + kept := userRules[:0] + for _, ur := range userRules { + if m, ok := ur.(map[string]any); ok { + if _, hasRS := m["rule_set"]; hasRS { + continue + } + } + kept = append(kept, ur) + } + userRules = kept + geoSets = nil + } + } + if len(userRules) > 0 { + routeRules = append(routeRules, userRules...) + } + if len(extraExclude) > 0 { + // IP 直连真生效:并入 TUN 入站的 route_exclude_address(auto_route 层排除, + // 见 tunIn 构造处注释——单靠 route.rules 的 ip_cidr→direct 在 macOS + // strict_route 下不生效)。与既有 LAN 网段去重合并。 + exclude := []string{"192.168.0.0/16", "10.0.0.0/8"} + seen := map[string]bool{"192.168.0.0/16": true, "10.0.0.0/8": true} + for _, e := range extraExclude { + if !seen[e] { + seen[e] = true + exclude = append(exclude, e) + } + } + tunIn["route_exclude_address"] = exclude + } + + // route.final:三模式语义。nil profile 保持旧的硬编码 "auto"。 + final := "auto" + if opts.Profile != nil { + switch opts.Profile.Mode { + case "direct": + final = "direct" + case "global": + final = "auto" + case "rule": + if opts.Profile.Final == "direct" { + final = "direct" + } else { + final = "auto" + } + } + } route := map[string]any{ - "final": "auto", + "final": final, "auto_detect_interface": true, // sing-box 1.12+ 要求显式声明出站域名用哪个 DNS 解析,缺失即 FATAL。 "default_domain_resolver": map[string]any{"server": "local"}, @@ -170,20 +307,44 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien // 国内分流(#5):命中 geoip-cn / geosite-cn → 直连(不走隧道),省流量 + 国内快。 // rule_set 走控制面自托管(国内可达,客户端反正连控制面);download_detour:direct // 让 sing-box 直连下载 .srs(不经隧道,启动期隧道还没起)。 - splitActive := opts.SplitCN && opts.RulesBaseURL != "" + // 有 Profile 时,是否分流改由 Profile 决定(mode==rule 且 Builtin.ChinaDirect); + // nil profile 保持旧的 opts.SplitCN 兜底,与原逻辑逐字节一致。 + chinaDirectWanted := opts.SplitCN + if opts.Profile != nil { + chinaDirectWanted = opts.Profile.Mode == "rule" && opts.Profile.Builtin.ChinaDirect + } + splitActive := chinaDirectWanted && opts.RulesBaseURL != "" + base := strings.TrimRight(opts.RulesBaseURL, "/") if splitActive { - base := strings.TrimRight(opts.RulesBaseURL, "/") routeRules = append(routeRules, map[string]any{ "rule_set": []string{"geoip-cn", "geosite-cn"}, "outbound": "direct", }) route["rule_set"] = []any{ - map[string]any{"tag": "geoip-cn", "type": "remote", "format": "binary", - "url": base + "/v1/rules/geoip-cn.srs", "download_detour": "direct"}, - map[string]any{"tag": "geosite-cn", "type": "remote", "format": "binary", - "url": base + "/v1/rules/geosite-cn.srs", "download_detour": "direct"}, + ruleSetDef("geoip-cn", base), + ruleSetDef("geosite-cn", base), } } + if len(geoSets) > 0 { + // 用户规则用到的 geo rule_set,凡未被上面的国内分流块定义过,在此补上 + // (去重)——即便 SplitCN/Builtin.ChinaDirect 关闭,用户显式规则仍要生效。 + defs, _ := route["rule_set"].([]any) + defined := map[string]bool{} + for _, d := range defs { + if dm, ok := d.(map[string]any); ok { + if tag, ok := dm["tag"].(string); ok { + defined[tag] = true + } + } + } + for _, tag := range geoSets { + if !defined[tag] { + defs = append(defs, ruleSetDef(tag, base)) + defined[tag] = true + } + } + route["rule_set"] = defs + } route["rules"] = routeRules // DNS: remote over tunnel, local for domestic. @@ -212,8 +373,11 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien dnsRules = append(dnsRules, map[string]any{ "domain": opts.PrivateSplitDomains, "server": "dns-system", }) + } + if privateSplit || hasDomainDirect { // 回映射:记住"哪个 IP 是哪个域名解析出来的",给后续按 IP 发起的连接补回 - // 域名元数据——路由层的 domain 规则(私有域名→隧道)靠它才会命中。 + // 域名元数据——路由层的 domain 规则(私有域名→隧道 / 用户域名直连)靠它 + // 才会命中。hasDomainDirect:用户规则含 action==direct 的域名类规则时同样需要。 dns["reverse_mapping"] = true } // 国内分流的 DNS 面(补 #5 数据面之外的 DNS 面):开分流时,命中 geosite-cn 的 diff --git a/server/internal/httpapi/clientconfig_test.go b/server/internal/httpapi/clientconfig_test.go index 5d54d81..b4d7563 100644 --- a/server/internal/httpapi/clientconfig_test.go +++ b/server/internal/httpapi/clientconfig_test.go @@ -1,6 +1,7 @@ package httpapi import ( + "bytes" "encoding/json" "net/http/httptest" "os" @@ -10,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/wangjia/pangolin/server/internal/nodes" + "github.com/wangjia/pangolin/server/internal/routing" ) func testNode() *nodes.NodeRow { @@ -235,3 +237,237 @@ func TestBuildClientConfigLANExclude(t *testing.T) { t.Error("route_exclude_address must NOT contain 172.16.0.0/12 (tunnel DNS 172.19.x lives there)") } } + +// --- Task 4 test helpers --- +// +// NOTE on route_exclude_address: sing-box only honors this key on the TUN +// inbound (see tunIn construction + comment at clientconfig.go:~116-121; +// TestBuildClientConfigLANExclude above already asserts it there) — there is +// no such key under the top-level "route" object. The task brief's sample +// snippet checked cfg["route"]["route_exclude_address"], which does not +// exist in main's rendering and would make "IP 直连真生效" a no-op. Fixed +// here to read it from the tun inbound, consistent with the existing +// mechanism this task explicitly says to reuse ("main clientconfig.go:121"). + +func ruleIndexByDomain(rules []any, domain string) int { + for i, r := range rules { + rm := r.(map[string]any) + for _, key := range []string{"domain", "domain_suffix", "domain_keyword"} { + if v, ok := rm[key]; ok { + for _, s := range toStrings(v) { + if s == domain { + return i + } + } + } + } + } + return -1 +} + +func ruleIndexByRuleSet(rules []any, tag string) int { + for i, r := range rules { + rm := r.(map[string]any) + if v, ok := rm["rule_set"]; ok { + for _, s := range toStrings(v) { + if s == tag { + return i + } + } + } + } + return -1 +} + +func hasHijackDNS(rules []any) bool { + for _, r := range rules { + rm := r.(map[string]any) + if rm["action"] == "hijack-dns" { + return true + } + } + return false +} + +func toStrings(v any) []string { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, x := range arr { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} + +// tunRouteExcludeAddress reads route_exclude_address off the TUN inbound +// (the field that actually has effect — see NOTE above). +func tunRouteExcludeAddress(t *testing.T, cfg map[string]any) []string { + t.Helper() + for _, in := range cfg["inbounds"].([]any) { + im := in.(map[string]any) + if im["type"] == "tun" { + return toStrings(im["route_exclude_address"]) + } + } + return nil +} + +func TestBuildConfigUserRules(t *testing.T) { + node := testNode() + p := routing.Default() + p.Rules = []routing.Rule{ + {Type: "domain_suffix", Value: "github.com", Action: "proxy", Enabled: true}, + {Type: "ip_cidr", Value: "35.190.0.0/16", Action: "direct", Enabled: true}, + {Type: "domain_suffix", Value: "git.51yanmei.com", Action: "direct", Enabled: true}, + } + raw, err := BuildClientConfig(node, "dp", "k", ClientConfigOpts{Profile: p, SplitCN: true, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + rules := cfg["route"].(map[string]any)["rules"].([]any) + // 用户 github→auto 规则应在 geoip-cn 规则之前 + iUser, iCN := ruleIndexByDomain(rules, "github.com"), ruleIndexByRuleSet(rules, "geoip-cn") + if iUser < 0 || iCN < 0 || iUser > iCN { + t.Fatalf("user rule must precede geoip-cn: %d vs %d", iUser, iCN) + } + // IP 直连并入 route_exclude_address(TUN 入站,见上方 NOTE) + excl := tunRouteExcludeAddress(t, cfg) + if !contains(excl, "35.190.0.0/16") { + t.Fatalf("ip direct not in route_exclude_address: %v", excl) + } + // 有域名直连 → reverse_mapping 开 + if cfg["dns"].(map[string]any)["reverse_mapping"] != true { + t.Fatal("reverse_mapping must be on") + } +} + +func TestBuildConfigGlobalMode(t *testing.T) { + p := routing.Default() + p.Mode = "global" + p.Rules = []routing.Rule{{Type: "domain_suffix", Value: "github.com", Action: "direct", Enabled: true}} + raw, err := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: p, SplitCN: true, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + // global:忽略用户规则 + 无 geoip-cn 直连,final=auto,但系统层(hijack-dns/LAN)仍在 + rules := cfg["route"].(map[string]any)["rules"].([]any) + if ruleIndexByDomain(rules, "github.com") >= 0 { + t.Fatal("global must ignore user rules") + } + if cfg["route"].(map[string]any)["final"] != "auto" { + t.Fatal("global final=auto") + } + if !hasHijackDNS(rules) { + t.Fatal("system layer must survive in global") + } +} + +func TestBuildConfigDirectMode(t *testing.T) { + p := routing.Default() + p.Mode = "direct" + p.Rules = []routing.Rule{{Type: "domain_suffix", Value: "github.com", Action: "proxy", Enabled: true}} + raw, err := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: p, SplitCN: true, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + rules := cfg["route"].(map[string]any)["rules"].([]any) + if ruleIndexByDomain(rules, "github.com") >= 0 { + t.Fatal("direct mode must ignore user rules") + } + if cfg["route"].(map[string]any)["final"] != "direct" { + t.Fatal("direct mode final=direct") + } + if !hasHijackDNS(rules) { + t.Fatal("system layer must survive in direct mode") + } +} + +func TestBuildConfigRuleModeFinalDirect(t *testing.T) { + p := routing.Default() + p.Final = "direct" + raw, err := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: p, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + if cfg["route"].(map[string]any)["final"] != "direct" { + t.Fatal("rule mode with Final=direct should render final=direct") + } +} + +func TestBuildConfigUserGeoRuleWithoutSplitCN(t *testing.T) { + // SplitCN 关闭(Builtin.ChinaDirect=false)但用户手动加了 geoip-cn 规则 → + // 仍需补 rule_set 定义,规则本身要生效。 + p := routing.Default() + p.Builtin.ChinaDirect = false + p.Rules = []routing.Rule{{Type: "geoip", Value: "cn", Action: "direct", Enabled: true}} + raw, err := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: p, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + route := cfg["route"].(map[string]any) + rules := route["rules"].([]any) + if ruleIndexByRuleSet(rules, "geoip-cn") < 0 { + t.Fatal("user geoip-cn rule must be present in route.rules") + } + rs, ok := route["rule_set"].([]any) + if !ok || len(rs) == 0 { + t.Fatal("route.rule_set must define geoip-cn even though SplitCN(Builtin.ChinaDirect) is off") + } + found := false + for _, d := range rs { + if dm, ok := d.(map[string]any); ok && dm["tag"] == "geoip-cn" { + found = true + } + } + if !found { + t.Fatal("geoip-cn definition missing from route.rule_set") + } +} + +func TestBuildConfigNilProfileUnchanged(t *testing.T) { + // Profile==nil → 与现有行为逐字节一致(回退默认) + a, err := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{SplitCN: true, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + b, err := BuildClientConfig(testNode(), "dp", "k", ClientConfigOpts{Profile: nil, SplitCN: true, RulesBaseURL: "http://x"}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatal("nil profile must equal no-profile") + } +}