// 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 bool `json:"lan_direct"` 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 // Validate checks the profile against the type/action whitelist, CIDR // syntax, the geo set whitelist, and the rule count cap. It returns an empty // (nil) slice when the profile is valid; every violation is reported // independently (no short-circuiting) so callers can surface all errors at // once. func (p *Profile) Validate() []FieldError { var 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": if _, _, err := net.ParseCIDR(r.Value); err != nil { errs = append(errs, FieldError{i, "value", "invalid CIDR"}) } case "geoip", "geosite": if !geoWhitelist[strings.ToLower(r.Value)] { errs = append(errs, FieldError{i, "value", "geo set not in whitelist (cn only)"}) } } } return errs } // 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[string]bool{} out := p.Rules[:0] for _, r := range p.Rules { r.Value = strings.TrimSpace(r.Value) k := r.Type + "|" + r.Value + "|" + r.Action if seen[k] { continue } seen[k] = true out = append(out, r) } p.Rules = out }