diff --git a/server/internal/routing/profile.go b/server/internal/routing/profile.go index f4fe018..744b7ec 100644 --- a/server/internal/routing/profile.go +++ b/server/internal/routing/profile.go @@ -3,6 +3,11 @@ // 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 { @@ -40,3 +45,76 @@ func Default() *Profile { 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 +} diff --git a/server/internal/routing/profile_test.go b/server/internal/routing/profile_test.go new file mode 100644 index 0000000..22d3ac2 --- /dev/null +++ b/server/internal/routing/profile_test.go @@ -0,0 +1,50 @@ +package routing + +import "testing" + +func TestValidate(t *testing.T) { + ok := Default() + ok.Rules = []Rule{{Type: "domain_suffix", Value: "example.com", Action: "direct", Enabled: true}} + if e := ok.Validate(); len(e) != 0 { + t.Fatalf("valid profile got errors %v", e) + } + + bad := Default() + bad.Mode = "weird" // 非法 mode + bad.Final = "reject" // final 只能 proxy|direct + bad.Rules = []Rule{ + {Type: "ip_cidr", Value: "not-a-cidr", Action: "proxy", Enabled: true}, // CIDR 非法 + {Type: "geosite", Value: "netflix", Action: "direct", Enabled: true}, // 不在白名单(仅 cn) + {Type: "bogus", Value: "x", Action: "direct", Enabled: true}, // type 非法 + } + errs := bad.Validate() + if len(errs) < 5 { + t.Fatalf("want >=5 field errors, got %d: %v", len(errs), errs) + } +} + +func TestValidateCountLimit(t *testing.T) { + p := Default() + for i := 0; i < 201; i++ { + p.Rules = append(p.Rules, Rule{Type: "domain", Value: "a.com", Action: "proxy", Enabled: true}) + } + if e := p.Validate(); len(e) == 0 { + t.Fatal("want count-limit error") + } +} + +func TestNormalizeDedupAndTrim(t *testing.T) { + p := Default() + p.Rules = []Rule{ + {Type: "domain", Value: " a.com ", Action: "proxy", Enabled: true}, + {Type: "domain", Value: "a.com", Action: "proxy", Enabled: false}, + {Type: "domain", Value: "b.com", Action: "proxy", Enabled: true}, + } + p.Normalize() + if len(p.Rules) != 2 { + t.Fatalf("want 2 rules after dedup, got %d: %v", len(p.Rules), p.Rules) + } + if p.Rules[0].Value != "a.com" || p.Rules[1].Value != "b.com" { + t.Fatalf("want first-occurrence order preserved, got %v", p.Rules) + } +}