// Package routing holds the per-user routing profile (可配置分流): the rule // model, persistence (Store), and — in later tasks — validation and sing-box // config rendering. package routing import ( "net" "strings" ) // Rule is a single user-authored routing rule (domain/IP/geosite match → // proxy/direct action). Validation of Type/Action/Value lives in Task 2. type Rule struct { Type string `json:"type"` Value string `json:"value"` Action string `json:"action"` Note string `json:"note,omitempty"` Enabled bool `json:"enabled"` } // Builtin toggles the built-in routing behaviors that previously were // hardcoded into the server's sing-box config rendering. type Builtin struct { ChinaDirect bool `json:"china_direct"` // LanDirect is reserved/always-on: the system layer (route_exclude_address // + the hardcoded LAN direct rule in clientconfig.go) renders unconditionally // regardless of this flag's value — the renderer does not read it. Kept in // the wire contract for a possible future per-user opt-out; today it has no // effect on rendering. LanDirect bool `json:"lan_direct"` // PrivateViaTunnel is reserved/always-on: when PANGOLIN_PRIVATE_SPLIT_DOMAINS // is configured, those domains are always force-routed through the tunnel — // the renderer does not read this flag. Kept in the wire contract for a // possible future per-user opt-out; today it has no effect on rendering. PrivateViaTunnel bool `json:"private_via_tunnel"` } // Profile is a user's full routing configuration, persisted as JSON in // routing_profiles.profile_json. type Profile struct { Mode string `json:"mode"` // rule | global | direct Builtin Builtin `json:"builtin"` Rules []Rule `json:"rules"` Final string `json:"final"` // proxy | direct } // Default is the fail-safe fallback profile, equivalent to today's hardcoded // behavior (smart routing + China direct + no user rules). func Default() *Profile { return &Profile{ Mode: "rule", Builtin: Builtin{ChinaDirect: true, LanDirect: true, PrivateViaTunnel: true}, Rules: []Rule{}, Final: "proxy", } } // FieldError describes a single validation failure. Index is the offending // rule's position in Profile.Rules, or -1 for profile-level fields // (mode/final/rules count). type FieldError struct { Index int `json:"index"` Field string `json:"field"` Reason string `json:"reason"` } // MaxRules is the upper bound on the number of rules a profile may hold. const MaxRules = 200 var validType = map[string]bool{"domain": true, "domain_suffix": true, "domain_keyword": true, "ip_cidr": true, "geoip": true, "geosite": true} var validAction = map[string]bool{"direct": true, "proxy": true, "reject": true} var geoWhitelist = map[string]bool{"cn": true} // geoip/geosite 仅自托管 cn // reservedTunnelNet 是隧道/内部 DNS 保留段(含隧道 DNS 172.19.0.2)。direct 的 // ip_cidr 规则会并入 TUN 入站 route_exclude_address(OS/auto_route 层,位于系统 // 强制层之下),若排除此段会静默旁路 hijack-dns 与整条隧道 —— 见 Validate 里的守卫。 var reservedTunnelNet = mustCIDR("172.16.0.0/12") func mustCIDR(s string) *net.IPNet { _, n, err := net.ParseCIDR(s) if err != nil { panic(err) } return n } // cidrsOverlap 判断两个对齐的 CIDR 块是否相交(其一的网络地址落在另一之内)。 // 家族不匹配(v4 vs v6)时 net.IPNet.Contains 返回 false,故混用安全。 func cidrsOverlap(a, b *net.IPNet) bool { return a.Contains(b.IP) || b.Contains(a.IP) } // SafeToExclude 报告一条 direct 的 ip_cidr 值是否可安全并入 TUN route_exclude_address。 // 与 Validate 的守卫同源:catch-all 或与隧道/DNS 保留段重叠一律拒绝。渲染层(clientconfig) // 在合并 extraExclude 前调用它,作纵深防护——即便有写入闸之前存下的历史坏行,也不会 // 让它静默旁路系统强制层。 func SafeToExclude(cidr string) bool { _, ipnet, err := net.ParseCIDR(cidr) if err != nil { return false } if ones, _ := ipnet.Mask.Size(); ones == 0 { return false } return !cidrsOverlap(ipnet, reservedTunnelNet) } // Validate checks the profile against the type/action whitelist, CIDR // syntax, the geo set whitelist, and the rule count cap. It returns an empty // (non-nil) slice when the profile is valid — so JSON encoding produces `[]` // rather than `null` — and every violation is reported independently (no // short-circuiting) so callers can surface all errors at once. func (p *Profile) Validate() []FieldError { errs := []FieldError{} if p.Mode != "rule" && p.Mode != "global" && p.Mode != "direct" { errs = append(errs, FieldError{-1, "mode", "must be rule|global|direct"}) } if p.Final != "proxy" && p.Final != "direct" { errs = append(errs, FieldError{-1, "final", "must be proxy|direct"}) } if len(p.Rules) > MaxRules { errs = append(errs, FieldError{-1, "rules", "exceeds max 200"}) } for i, r := range p.Rules { if !validType[r.Type] { errs = append(errs, FieldError{i, "type", "invalid type"}) } if !validAction[r.Action] { errs = append(errs, FieldError{i, "action", "invalid action"}) } if r.Value == "" { errs = append(errs, FieldError{i, "value", "empty"}) } switch r.Type { case "ip_cidr": _, ipnet, err := net.ParseCIDR(r.Value) if err != nil { errs = append(errs, FieldError{i, "value", "invalid CIDR"}) } else if r.Action == "direct" { // direct 的 ip_cidr 并入 TUN route_exclude_address(系统层之下)。 // catch-all(/0)会整条旁路隧道;与隧道/DNS 保留段重叠会破坏隧道 DNS。 // 二者都能静默越过系统强制层,拒绝之(proxy/reject 不入排除表,不受限)。 if ones, _ := ipnet.Mask.Size(); ones == 0 { errs = append(errs, FieldError{i, "value", "direct ip_cidr must not be catch-all (0.0.0.0/0 or ::/0)"}) } else if cidrsOverlap(ipnet, reservedTunnelNet) { errs = append(errs, FieldError{i, "value", "direct ip_cidr must not overlap reserved tunnel range 172.16.0.0/12"}) } } case "geoip", "geosite": if !geoWhitelist[strings.ToLower(r.Value)] { errs = append(errs, FieldError{i, "value", "geo set not in whitelist (cn only)"}) } } } return errs } // dedupKey identifies a rule for Normalize's de-duplication. Using a struct // (rather than string-concatenating Type+Value+Action with a separator) // avoids false-collision when a value itself contains the separator // character. type dedupKey struct{ Type, Value, Action string } // Normalize trims rule values and de-duplicates rules by (type, value, // action), keeping the first occurrence's position (and its Note/Enabled). func (p *Profile) Normalize() { seen := map[dedupKey]bool{} out := p.Rules[:0] for _, r := range p.Rules { r.Value = strings.TrimSpace(r.Value) k := dedupKey{r.Type, r.Value, r.Action} if seen[k] { continue } seen[k] = true out = append(out, r) } p.Rules = out }