feat(routing): BuildClientConfig 翻译用户规则(层级/IP直连/域名直连/三模式)

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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-28 08:01:10 +08:00
parent 49f62d9b14
commit f76aa56929
2 changed files with 408 additions and 8 deletions
@@ -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")
}
}