refactor(routing): 暴露 system_locked_domains + 服务端 follow-up 清理(FK/Validate/dedup/exclude单源/共用Store/Builtin保留)
This commit is contained in:
@@ -21,8 +21,17 @@ type Rule struct {
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -64,11 +73,11 @@ 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.
|
||||
// (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 {
|
||||
var errs []FieldError
|
||||
errs := []FieldError{}
|
||||
if p.Mode != "rule" && p.Mode != "global" && p.Mode != "direct" {
|
||||
errs = append(errs, FieldError{-1, "mode", "must be rule|global|direct"})
|
||||
}
|
||||
@@ -102,14 +111,20 @@ func (p *Profile) Validate() []FieldError {
|
||||
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[string]bool{}
|
||||
seen := map[dedupKey]bool{}
|
||||
out := p.Rules[:0]
|
||||
for _, r := range p.Rules {
|
||||
r.Value = strings.TrimSpace(r.Value)
|
||||
k := r.Type + "|" + r.Value + "|" + r.Action
|
||||
k := dedupKey{r.Type, r.Value, r.Action}
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
package routing
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateEmptyErrorsSerializeAsArray guards the JSON shape API clients
|
||||
// depend on: a valid profile's Validate() must marshal to `[]`, not `null`.
|
||||
func TestValidateEmptyErrorsSerializeAsArray(t *testing.T) {
|
||||
raw, err := json.Marshal(Default().Validate())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "[]" {
|
||||
t.Fatalf("want `[]`, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
e := ok.Validate()
|
||||
if len(e) != 0 {
|
||||
t.Fatalf("valid profile got errors %v", e)
|
||||
}
|
||||
if e == nil {
|
||||
t.Fatal("Validate must return a non-nil empty slice (serializes to [] not null)")
|
||||
}
|
||||
|
||||
bad := Default()
|
||||
bad.Mode = "weird" // 非法 mode
|
||||
@@ -48,3 +67,19 @@ func TestNormalizeDedupAndTrim(t *testing.T) {
|
||||
t.Fatalf("want first-occurrence order preserved, got %v", p.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeDedupNoSeparatorCollision guards against the historical
|
||||
// string-concatenation dedup key ("type|value|action"): two distinct rules
|
||||
// whose Value contains "|" could concatenate to the same string even though
|
||||
// (type, value, action) differ. The struct-keyed dedup must tell them apart.
|
||||
func TestNormalizeDedupNoSeparatorCollision(t *testing.T) {
|
||||
p := Default()
|
||||
p.Rules = []Rule{
|
||||
{Type: "domain", Value: "a|b", Action: "proxy", Enabled: true},
|
||||
{Type: "domain", Value: "a", Action: "b|proxy", Enabled: true},
|
||||
}
|
||||
p.Normalize()
|
||||
if len(p.Rules) != 2 {
|
||||
t.Fatalf("want 2 distinct rules preserved (no false collision), got %d: %v", len(p.Rules), p.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,27 @@ func seedU(t *testing.T, db *sql.DB, id int64, uuid string) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSQLiteRoutingStoreGetNoProfileRow isolates the "queried the DB, found
|
||||
// no row" path from TestSQLiteRoutingStoreUpsertGet's combined
|
||||
// empty→upsert→overwrite flow: a store backed by a real (non-nil) *sql.DB,
|
||||
// with the user seeded but no routing_profiles row for them, must return
|
||||
// (nil, nil) — not an error — via the sql.ErrNoRows branch in Store.Get.
|
||||
func TestSQLiteRoutingStoreGetNoProfileRow(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 2, "u2")
|
||||
st := NewStore(db)
|
||||
if st == nil {
|
||||
t.Fatal("NewStore returned nil")
|
||||
}
|
||||
got, err := st.Get(context.Background(), 2)
|
||||
if err != nil {
|
||||
t.Fatalf("want nil error for no-rows, got %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("want nil profile for seeded user with no profile row, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRoutingStoreUpsertGet(t *testing.T) {
|
||||
db := openDB(t) // 内存库 + MigrateUp(sqlite)
|
||||
seedU(t, db, 1, "u1")
|
||||
|
||||
Reference in New Issue
Block a user