121 lines
3.9 KiB
Go
121 lines
3.9 KiB
Go
// 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
|
|
}
|