feat(routing): 档案校验 + 规范化(白名单/上限/去重)

This commit is contained in:
wangjia
2026-07-28 07:31:55 +08:00
parent 04b02da78d
commit b98aa2335b
2 changed files with 128 additions and 0 deletions
+78
View File
@@ -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
}