Files
pangolin/server/internal/routing/profile.go
T
wangjia 6f232d4043
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 19s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 18s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 13m6s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 13m16s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 13m25s
ci-pangolin / Go — build + test (push) Failing after 13m35s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Failing after 13m45s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Failing after 13m54s
ci-pangolin / Flutter — analyze + test (push) Failing after 14m5s
ci-pangolin / OpenAPI Sync Check (push) Failing after 14m16s
ci-pangolin / Lint — shellcheck (push) Failing after 14m27s
fix(routing): 堵住 ip_cidr direct 旁路系统层 + 客户端保存失败可见提示
分支审核发现两处 Important,合并前修复。

① [安全] direct 的 ip_cidr 用户规则可自伤式旁路整条隧道:
   Validate 原先只校 CIDR 语法。用户提交 ip_cidr=0.0.0.0/0 action=direct
   (或 172.16.0.0/12,含隧道 DNS 172.19.0.2)会并入 TUN 入站
   route_exclude_address(OS/auto_route 层,位于系统强制层之下),被排除的
   流量根本不进 sing-box → hijack-dns 与整条隧道被静默旁路,违反「系统层
   用户不可越」铁律。
   - Validate: direct 的 ip_cidr 拒绝 catch-all(/0)及与保留段 172.16.0.0/12
     重叠(写入闸)。
   - clientconfig 渲染层:新增 routing.SafeToExclude 守卫,只有安全的 direct
     ip_cidr 才并入 route_exclude_address(纵深防护,兜底写入闸之前的历史坏行)。
   - 测试 TestValidateDirectIPCIDRReservedGuard 钉死:拒 catch-all/隧道段重叠、
     放行 proxy catch-all 与不重叠 direct。

② [健壮性] 客户端保存失败静默回滚 + 抛未捕获异步异常 + 对话框无字段校验:
   _persist 失败会 rethrow(约定调用方 catch),但屏幕层所有回调
   (setMode/setBuiltin/addRule/removeRule/reorder/resetToDefault)均未 catch,
   规则闪现即消失、无提示,且 rethrow 变 zone 未处理异常。
   - 新增 _guardSave 守卫:await + 失败弹 SnackBar(AuthApiException 显服务端
     双语文案含校验错,其余回退通用「保存失败」),包裹全部变更类回调。
   - 添加规则对话框:_valueError 字段级预校验(ip_cidr 用 InternetAddress
     校验、geo 白名单仅 cn),非法即禁用保存并内联红字提示;语义级(保留段)
     仍由服务端权威判定经 SnackBar 呈现。
   - l10n 单源新增 routingSaveFailed / routingRuleValueInvalid(6 语),regen。

go test ./... 全绿;flutter analyze 无 error;flutter test 265 全过无 golden 回归;
codegen 幂等、原型 i18n 无漂移。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A79VtQA1BwTuQN1ThpvYpo
2026-08-01 00:32:52 +08:00

180 lines
6.8 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 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
}