The `byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness endpoint and the panel plate are gone. D13 is not deleted from DECISIONS.md; it is REVERSED there, with the reason, because the reason is the whole point. D13 adopted an external desync process on an observation: the engine's own `tls_fragment`/`tls_record_fragment` were tried against a live ISP and did not get through, so the method was judged too weak for anything past "just fragment the ClientHello". The method was never tried. `common/tlsfragment` dropped a number of labels equal to the number of DOTS in the name, and a name always has one more label than it has dots — so the cut always landed inside the FIRST label. `www.youtube.com` was split inside `www` and `youtube` went to the wire in one piece, which is the word the DPI matches on. Of six blocked names exactly one got through: `youtube.com`, the one whose first label IS the blocked word. That defect is fixed (815011dfb,efb2177f4). With it fixed the built-in presets do the job the external process was brought in to do, and the process is 100 KB of binary, a second procd service, a second UCI file, a port that agreed with our egress by hand-written comment only, a readiness prober, a five-state service model and a panel plate — all to work around fifteen lines of ours. So this is not "ByeDPI turned out to be bad". It is a good tool that turned out not to be needed, and the reason we thought it was needed was ours. A CONFIG THAT STILL SAYS `type 'byedpi'` IS THE PART THAT NEEDED WORK. Nothing is migrated and nothing is rewritten: the kind stays unbuildable, therefore fail-closed — no outbound, no mark, no `ip rule`, no routing table, so every node, group and rule bound to it is blocked rather than released onto the plain WAN. A migration to `direct` was considered and rejected: it is the only rewrite that leaves the egress routing at all, and it would silently turn a blocked egress into a live plain-WAN path with the router's real address — by an upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore not bumped either: no stored field changes meaning, and a bump would only make this build's configs unreadable to an older daemon for no gain. What changes is what the operator is TOLD. `model.RetiredEgressTypes` is a closed, positive table read by BOTH `ValidateEgresses` and the generator (one copy of the sentence, because two copies drift). It names the removal, denies that it is a typo, says nothing is built and that the traffic is blocked rather than leaked, names the replacement (`direct`/`interface` with `dpi 'record'`), refuses to promise which preset defeats a given ISP, and says `apk del byedpi`. The generic "unknown type" is still there and still says something different, on purpose: "we took this kind away" and "you mistyped something" send an operator to different places, and a value that was correct on the day it was written must not be reported as a spelling mistake. The type list stays closed and positive — `interface`, `direct`, the alias `tunnel` — and `EgressTypeKnown` does NOT admit the retired kind: being told it was removed and having it work anyway is worse than either alone. `Egress.Port` goes with the kind: no surviving egress dials anything, so the option is no longer parsed and drains out of /etc/config/shater on the next render, the same way the deleted per-group probe_url/probe_interval did. Tests, verified by mutation, each failing by name: - drop the retired branch in `ValidateEgresses` -> the retired kind is reported as "is not one of interface/direct" and TestRetiredEgressTypeIsReportedByTheValidator fails on both spellings; - drop it in the generator -> "unknown type \"byedpi\"" and TestRetiredEgressTypeIsReportedByTheGenerator fails; - the FAIL-OPEN mutation, which is the one that matters: let `byedpi` fall into the `direct` arm and be a known type -> four tests fail, including the two that check no outbound is emitted. A removal that quietly starts routing the traffic it used to block, under a reassuring message, is the failure with the worst consequence; - the panel half: empty RETIRED_EGRESS_TYPES -> two egressEdit tests fail. Controls beside the claims: `interface`, `direct`, the `tunnel` alias and the empty synonym must still resolve, warn about nothing and emit an outbound (TestSupportedEgressTypesAreUntouched), and never-supported values — `proxy`, `block`, `wireguard`, `byedpi2`, `bye dpi`, `sorcery` — must NOT draw the removal sentence, which names a replacement for something that never existed. CI and docs: the feed loses its fourth package everywhere the four were named — `apk upgrade shaterd shater-core luci-app-shater`, in CLAUDE.md, both READMEs, INSTALL.md, the release body and `shaterd`'s own diag bundle. The version exception (byedpi carried upstream's version, ours come from the git tag) is gone with it, so ci/version.sh and ci/sdk-build-apk.sh no longer have an exception to remember and the "expected >=4 of OUR .apk" collect check is now 3. INSTALL.md §5.3 gains the half a feed cannot do: dropping the package from the feed does not take it off a router it is already on, so `apk del byedpi` is written down, with what it removes and why it is safe. Panel: 368 tests -> 339. Deleted with the mechanism they covered: byedpiReady.test.ts, byedpiAge.test.ts, byedpiRefusal.test.ts (34 tests); egressEdit.test.ts gains 5 for the retired-type sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
681 lines
25 KiB
Go
681 lines
25 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// richModel is a fully-populated fixture covering EVERY section type, list
|
|
// fields, bools (both true and false), ints/uints, hex bases, and an embedded
|
|
// single quote in several values. Every field the parser fills with a non-empty/
|
|
// non-zero default is populated to that concrete value so the round-trip is exact
|
|
// (see RenderUCIExport's doc comment). It deliberately contains NO
|
|
// subscription-cache node (FromSub!="") because those are not persisted — that
|
|
// skip is asserted separately in TestRenderSkipsSubCacheNodes.
|
|
func richModel() *Model {
|
|
return &Model{
|
|
Globals: Globals{
|
|
Enabled: false, // exercise false via always-emit bool
|
|
LogLevel: "debug",
|
|
KillSwitch: "open",
|
|
Untunnelable: "direct",
|
|
L3Tunnel: false, // opt-out; exercise the non-default via round-trip
|
|
UntunnelableEgress: "frag", // exercise the non-default via round-trip
|
|
IPv6: false,
|
|
FwmarkBase: 0x2000,
|
|
TableBase: 0x3000,
|
|
ConfirmTimeout: 30,
|
|
ResolverDefault: "cf",
|
|
ResolverFallback: "fake",
|
|
ProbeURL: "http://gstatic.com/generate_204",
|
|
ProbeInterval: "60s",
|
|
SchemaVersion: 1,
|
|
ActiveProfile: "home",
|
|
PanelPort: 8090,
|
|
DNSFilter: true,
|
|
DNSIntercept: true,
|
|
BlockDoH: true,
|
|
StatsBackend: "off",
|
|
},
|
|
Inbounds: []Inbound{
|
|
{
|
|
Name: "lan", Enabled: true, Type: "tproxy", Network: "br-lan",
|
|
TproxyPort: 12345, Listen: "127.0.0.1", Auth: "noauth",
|
|
TargetNetwork: "udp", TCP: true, UDP: true,
|
|
},
|
|
{
|
|
// socks listener with password auth (embedded single quote in pass).
|
|
Name: "sock", Enabled: true, Type: "socks", TproxyPort: 12345,
|
|
Listen: "127.0.0.1", Port: 1080, Auth: "password", User: "u",
|
|
Pass: "p'wd", TargetNetwork: "udp", TCP: true, UDP: false,
|
|
},
|
|
},
|
|
Subscriptions: []Subscription{{
|
|
Name: "qomar", Enabled: true, URL: "https://pro.example.com/sub/x",
|
|
UpdateInterval: "24h", FetchVia: "proxy", FetchDetour: "group:auto", UA: "Happ/3.13.0", HWID: "auto",
|
|
DeviceOS: "ios", VerOS: "17.0", DeviceModel: "iPhone",
|
|
Headers: []string{"x-key: val", "y: it's"},
|
|
Format: "clash", Include: []string{"US"}, Exclude: []string{"ads"},
|
|
FilterProto: []string{"vless", "vmess"}, FilterCountry: []string{"US", "!CN"},
|
|
Dedup: true, ExpireAlertDays: 7,
|
|
}},
|
|
Nodes: []Node{{
|
|
Name: "reality-nl", Enabled: true,
|
|
URI: "vless://uuid@a.example.com:443?security=reality#it's-me",
|
|
Mux: true, MuxConcurrency: 8,
|
|
Mark: 0x2000, TCPFastOpen: "1", TCPKeepAliveIdle: 30, Egress: "frag",
|
|
}},
|
|
Groups: []Group{{
|
|
Name: "main", Source: "manual", Subscription: "qomar",
|
|
Nodes: []string{"reality-nl"}, Strategy: "leastping",
|
|
Include: []string{"US"}, Exclude: []string{"cn"},
|
|
FilterProto: []string{"vless"}, FilterCountry: []string{"US"},
|
|
Dedup: true,
|
|
}},
|
|
Chains: []Chain{{
|
|
Name: "triple", Hops: []string{"group:main", "node:reality-nl"},
|
|
}},
|
|
Egresses: []Egress{{
|
|
Name: "frag", Type: "direct", Interface: "wg0", DPI: "fragment",
|
|
}},
|
|
Rulesets: []Ruleset{{
|
|
Name: "ads", Type: "domain", Source: "url", URL: "http://list.local/ads",
|
|
Path: "/tmp/ads.lst", Format: "plain", UpdateInterval: "24h",
|
|
Categories: []string{"youtube", "telegram"}, Entries: []string{"ads.com", "tracker.com"},
|
|
}},
|
|
Rules: []Rule{{
|
|
Name: "pc", Enabled: true, Order: 10,
|
|
Src: []string{"192.168.1.1/32"}, DstRuleset: []string{"ads"},
|
|
DstPort: "443", Proto: "tcp,udp", Target: "chain:triple", Egress: "frag",
|
|
Kill: "default", SchedEnabled: true, SchedDays: []string{"mon", "tue"},
|
|
SchedStart: "08:00", SchedEnd: "22:00", SchedUTCOffset: 180,
|
|
}},
|
|
Profiles: []Profile{{
|
|
Name: "home", Enabled: true, Priority: 10,
|
|
MatchIface: []string{"wwan0", "usb0"},
|
|
EnableRules: []string{"pc"}, DisableRules: []string{"other"},
|
|
}},
|
|
Resolvers: []Resolver{
|
|
{Name: "cf", Type: "doh", Address: "https://cloudflare-dns.com/dns-query", Detour: "group:main"},
|
|
{Name: "fake", Type: "fakeip", Pool: "198.18.0.0/15"},
|
|
},
|
|
DNSRules: []DNSRule{{
|
|
Order: 5, MatchDomain: []string{"geosite:cn"},
|
|
MatchSrc: []string{"192.168.1.0/24"}, Resolver: "cf",
|
|
}},
|
|
Blocklists: []Blocklist{
|
|
{
|
|
Name: "ads-inline", Enabled: true, Source: "inline",
|
|
Entries: []string{"ads.example", ".doubleclick.net", "keyword:tracker"},
|
|
Response: "nxdomain", UpdateInterval: "24h",
|
|
},
|
|
{
|
|
// url source with the zero response (round-trip only; not fetched here).
|
|
Name: "stevenblack", Enabled: false, Source: "url",
|
|
URL: "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts.srs",
|
|
Response: "zero", UpdateInterval: "168h",
|
|
},
|
|
{
|
|
// geosite source keyed by categories (round-trip only; not fetched here).
|
|
Name: "ad-cat", Enabled: true, Source: "geosite",
|
|
Categories: []string{"category-ads-all", "malware"}, Response: "nxdomain", UpdateInterval: "24h",
|
|
},
|
|
},
|
|
Allowlists: []Allowlist{{
|
|
Name: "safe", Enabled: true, Source: "geosite", Categories: []string{"youtube"},
|
|
Entries: []string{"good.example", "cdn.example.com"},
|
|
}},
|
|
Devices: []Device{
|
|
{
|
|
// identified by MAC, per-device block+allow.
|
|
Name: "kid-laptop", MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.50",
|
|
Enabled: true,
|
|
Block: []string{"blocked.example", ".ads.example"},
|
|
Allow: []string{"good.example"},
|
|
},
|
|
{
|
|
// identified by IP only, disabled, embedded quote.
|
|
Name: "guest'phone", IP: "192.168.1.51", Enabled: false,
|
|
},
|
|
},
|
|
Alerts: []Alert{
|
|
{
|
|
// telegram channel subscribed to every emitted event.
|
|
Name: "tg", Enabled: true, Type: "telegram",
|
|
Token: "123456:AA'BB", ChatID: "-1001234567890",
|
|
Events: []string{"killswitch", "new_device", "apply_fail"},
|
|
Via: "group:auto", Fallback: true,
|
|
},
|
|
{
|
|
// disabled webhook, single subscription, url with a token-ish path.
|
|
Name: "hook", Enabled: false, Type: "webhook",
|
|
URL: "https://hooks.example.com/services/T00/B11/xyz",
|
|
Events: []string{"apply_fail"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestRenderRoundTrip is THE invariant: ParseUCIExport(RenderUCIExport(m)) deep-
|
|
// equals m for a fixture covering every section type, list/bool/int fields, and
|
|
// an embedded single quote. If this ever breaks, the renderer and parser have
|
|
// drifted apart field-by-field.
|
|
func TestRenderRoundTrip(t *testing.T) {
|
|
m := richModel()
|
|
text := RenderUCIExport(m)
|
|
|
|
got, err := ParseUCIExport(text)
|
|
if err != nil {
|
|
t.Fatalf("ParseUCIExport(RenderUCIExport(m)) error: %v\n---rendered---\n%s", err, text)
|
|
}
|
|
if !reflect.DeepEqual(got, m) {
|
|
t.Fatalf("round-trip mismatch\n--- want ---\n%#v\n--- got ---\n%#v\n--- rendered ---\n%s", m, got, text)
|
|
}
|
|
|
|
// The embedded single quote must have survived through render+parse.
|
|
if got.Inbounds[1].Pass != "p'wd" {
|
|
t.Fatalf("embedded-quote pass mangled: %q", got.Inbounds[1].Pass)
|
|
}
|
|
if !strings.Contains(text, `'p'\''wd'`) {
|
|
t.Fatalf("rendered text did not uci-escape the embedded quote:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestRenderDetourFieldsRoundTrip pins the feedback #1/#8 fields: a subscription's
|
|
// fetch_detour and an alert's via/fallback survive render+parse verbatim.
|
|
func TestRenderDetourFieldsRoundTrip(t *testing.T) {
|
|
m := &Model{
|
|
Subscriptions: []Subscription{{
|
|
Name: "s", Enabled: true, URL: "https://x/sub",
|
|
FetchVia: "proxy", FetchDetour: "egress:wg0",
|
|
}},
|
|
Alerts: []Alert{{
|
|
Name: "a", Enabled: true, Type: "telegram",
|
|
Token: "t", ChatID: "c", Events: []string{"killswitch"},
|
|
Via: "node:us-1", Fallback: true,
|
|
}},
|
|
}
|
|
text := RenderUCIExport(m)
|
|
got, err := ParseUCIExport(text)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v\n%s", err, text)
|
|
}
|
|
if got.Subscriptions[0].FetchDetour != "egress:wg0" {
|
|
t.Fatalf("fetch_detour = %q, want egress:wg0\n%s", got.Subscriptions[0].FetchDetour, text)
|
|
}
|
|
if got.Alerts[0].Via != "node:us-1" {
|
|
t.Fatalf("alert via = %q, want node:us-1", got.Alerts[0].Via)
|
|
}
|
|
if !got.Alerts[0].Fallback {
|
|
t.Fatalf("alert fallback = false, want true\n%s", text)
|
|
}
|
|
// fallback defaults to false when the key is absent.
|
|
m2 := &Model{Alerts: []Alert{{Name: "b", Enabled: true, Type: "webhook", URL: "https://h", Events: []string{"apply_fail"}}}}
|
|
got2, err := ParseUCIExport(RenderUCIExport(m2))
|
|
if err != nil {
|
|
t.Fatalf("parse2: %v", err)
|
|
}
|
|
if got2.Alerts[0].Fallback {
|
|
t.Fatalf("absent fallback should parse false")
|
|
}
|
|
}
|
|
|
|
// TestStatsSizeKnobsRoundTrip pins the 0=UNLIMITED stats-size semantics: an EXPLICIT 0
|
|
// (unlimited) must survive WriteUCI->ReadUCI just like any positive limit. This is the
|
|
// reason render.go emits these three via intOptAlways (not the omit-zero intOpt) and
|
|
// DefaultGlobals seeds them to 200/60/5000: an absent option falls back to the bounded
|
|
// default, but a written 0 round-trips as 0.
|
|
func TestStatsSizeKnobsRoundTrip(t *testing.T) {
|
|
for _, v := range []int{0, 64, 200, 5000} {
|
|
m := &Model{Globals: Globals{
|
|
StatsRingSize: v,
|
|
StatsTimelineMinutes: v,
|
|
StatsMaxDomains: v,
|
|
StatsDiskLimitMB: v,
|
|
}}
|
|
got, err := ParseUCIExport(RenderUCIExport(m))
|
|
if err != nil {
|
|
t.Fatalf("v=%d parse: %v", v, err)
|
|
}
|
|
if got.Globals.StatsRingSize != v || got.Globals.StatsTimelineMinutes != v || got.Globals.StatsMaxDomains != v {
|
|
t.Fatalf("v=%d round-trip: ring=%d timeline=%d maxdom=%d, want all %d",
|
|
v, got.Globals.StatsRingSize, got.Globals.StatsTimelineMinutes, got.Globals.StatsMaxDomains, v)
|
|
}
|
|
if got.Globals.StatsDiskLimitMB != v {
|
|
t.Fatalf("v=%d round-trip: disk_limit_mb=%d, want %d", v, got.Globals.StatsDiskLimitMB, v)
|
|
}
|
|
}
|
|
|
|
// An ABSENT option (no globals section at all) must fall back to the bounded default
|
|
// (200/60/5000, disk cap 64), NOT to 0/unlimited.
|
|
got, err := ParseUCIExport("package shater\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Globals.StatsRingSize != 200 || got.Globals.StatsTimelineMinutes != 60 || got.Globals.StatsMaxDomains != 5000 {
|
|
t.Fatalf("absent stats knobs: ring=%d timeline=%d maxdom=%d, want 200/60/5000",
|
|
got.Globals.StatsRingSize, got.Globals.StatsTimelineMinutes, got.Globals.StatsMaxDomains)
|
|
}
|
|
if got.Globals.StatsDiskLimitMB != 64 {
|
|
t.Fatalf("absent stats_disk_limit_mb=%d, want 64 (default)", got.Globals.StatsDiskLimitMB)
|
|
}
|
|
}
|
|
|
|
// TestStatsBackendRoundTrip pins the pluggable-stats backend switch: each of
|
|
// off|memory|sqlite survives WriteUCI->ReadUCI, and an ABSENT option falls back to
|
|
// the DefaultGlobals seed "memory" (never accidentally "off").
|
|
func TestStatsBackendRoundTrip(t *testing.T) {
|
|
for _, v := range []string{"off", "memory", "sqlite"} {
|
|
m := &Model{Globals: Globals{StatsBackend: v}}
|
|
got, err := ParseUCIExport(RenderUCIExport(m))
|
|
if err != nil {
|
|
t.Fatalf("v=%q parse: %v", v, err)
|
|
}
|
|
if got.Globals.StatsBackend != v {
|
|
t.Fatalf("v=%q round-trip: stats_backend=%q, want %q", v, got.Globals.StatsBackend, v)
|
|
}
|
|
}
|
|
// Absent globals section => bounded default "memory".
|
|
got, err := ParseUCIExport("package shater\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Globals.StatsBackend != "memory" {
|
|
t.Fatalf("absent stats_backend = %q, want \"memory\"", got.Globals.StatsBackend)
|
|
}
|
|
}
|
|
|
|
// TestGroupHealthRoundTrip pins the group_health master-switch: an EXPLICIT false
|
|
// (background probing off) survives WriteUCI->ReadUCI, and an ABSENT option falls
|
|
// back to the DefaultGlobals seed true (opt-out), never accidentally off.
|
|
func TestGroupHealthRoundTrip(t *testing.T) {
|
|
for _, v := range []bool{true, false} {
|
|
// group_health is only meaningful over an otherwise-default globals section, so
|
|
// start from DefaultGlobals and override just the field under test.
|
|
g := DefaultGlobals()
|
|
g.GroupHealth = v
|
|
got, err := ParseUCIExport(RenderUCIExport(&Model{Globals: g}))
|
|
if err != nil {
|
|
t.Fatalf("v=%v parse: %v", v, err)
|
|
}
|
|
if got.Globals.GroupHealth != v {
|
|
t.Fatalf("v=%v round-trip: group_health=%v, want %v", v, got.Globals.GroupHealth, v)
|
|
}
|
|
}
|
|
// Absent globals section => the DefaultGlobals seed true (opt-out).
|
|
got, err := ParseUCIExport("package shater\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Globals.GroupHealth {
|
|
t.Fatalf("absent group_health = %v, want true (default, opt-out)", got.Globals.GroupHealth)
|
|
}
|
|
}
|
|
|
|
// TestL3TunnelRoundTrip pins the l3_tunnel round-trip: BOTH spellings survive
|
|
// WriteUCI->ReadUCI, and an ABSENT option comes back ON — the option is opt-OUT
|
|
// now, so the interesting direction is that an explicit '0' is not silently
|
|
// re-enabled. The default itself, the shipped config and the warnings that go
|
|
// with the off state are pinned in l3tunnel_default_test.go.
|
|
func TestL3TunnelRoundTrip(t *testing.T) {
|
|
for _, v := range []bool{true, false} {
|
|
g := DefaultGlobals()
|
|
g.L3Tunnel = v
|
|
got, err := ParseUCIExport(RenderUCIExport(&Model{Globals: g}))
|
|
if err != nil {
|
|
t.Fatalf("v=%v parse: %v", v, err)
|
|
}
|
|
if got.Globals.L3Tunnel != v {
|
|
t.Fatalf("v=%v round-trip: l3_tunnel=%v, want %v", v, got.Globals.L3Tunnel, v)
|
|
}
|
|
}
|
|
// Absent option => ON (the DefaultGlobals seed, opt-out).
|
|
got, err := ParseUCIExport("package shater\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.Globals.L3Tunnel {
|
|
t.Fatalf("absent l3_tunnel = %v, want true (default, opt-out)", got.Globals.L3Tunnel)
|
|
}
|
|
}
|
|
|
|
// TestUntunnelableEgressRoundTrip pins the untunnelable_egress option: an
|
|
// EXPLICIT name survives WriteUCI->ReadUCI (else the kernel carrier for
|
|
// ESP/AH/GRE/IGMP/SCTP silently switches off on the next re-render), and an
|
|
// ABSENT option stays "" so the Untunnelable policy remains in sole charge.
|
|
func TestUntunnelableEgressRoundTrip(t *testing.T) {
|
|
g := DefaultGlobals()
|
|
g.UntunnelableEgress = "wan2"
|
|
got, err := ParseUCIExport(RenderUCIExport(&Model{Globals: g}))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got.Globals.UntunnelableEgress != "wan2" {
|
|
t.Fatalf("round-trip: untunnelable_egress=%q, want %q", got.Globals.UntunnelableEgress, "wan2")
|
|
}
|
|
// Absent option => "" (default: no kernel carrier, policy alone decides).
|
|
got, err = ParseUCIExport("package shater\n")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Globals.UntunnelableEgress != "" {
|
|
t.Fatalf("absent untunnelable_egress = %q, want \"\" (default, opt-in)", got.Globals.UntunnelableEgress)
|
|
}
|
|
}
|
|
|
|
// TestNodeEgressRoundTrip pins the node-level egress binding (multi-WAN): a
|
|
// node's `egress` option survives WriteUCI->ReadUCI so the generator can bind the
|
|
// node's own upstream to that egress outbound. An absent option parses to "".
|
|
func TestNodeEgressRoundTrip(t *testing.T) {
|
|
m := &Model{
|
|
Egresses: []Egress{{Name: "wan2", Type: "interface", Interface: "wan2"}},
|
|
Nodes: []Node{
|
|
{Name: "bound", URI: "vless://u@h:443#bound", Enabled: true, Egress: "wan2"},
|
|
{Name: "free", URI: "ss://x", Enabled: true},
|
|
},
|
|
}
|
|
got, err := ParseUCIExport(RenderUCIExport(m))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got.Nodes[0].Egress != "wan2" {
|
|
t.Fatalf("bound node egress = %q, want wan2", got.Nodes[0].Egress)
|
|
}
|
|
if got.Nodes[1].Egress != "" {
|
|
t.Fatalf("free node egress = %q, want empty", got.Nodes[1].Egress)
|
|
}
|
|
}
|
|
|
|
// TestRenderConvention checks the shipped section convention: globals is the one
|
|
// NAMED section, everything else is anonymous with `option name` first.
|
|
func TestRenderConvention(t *testing.T) {
|
|
text := RenderUCIExport(richModel())
|
|
if !strings.Contains(text, "config globals 'globals'") {
|
|
t.Fatalf("globals not rendered as a named section:\n%s", text)
|
|
}
|
|
if !strings.Contains(text, "config node\n\toption name 'reality-nl'") {
|
|
t.Fatalf("node not rendered anonymous with option name first:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "config node 'reality-nl'") {
|
|
t.Fatalf("node rendered as a named section (should be anonymous):\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestRenderSkipsSubCacheNodes pins the persistence SPLIT: subscription-cache
|
|
// nodes (FromSub!="") are NOT emitted to UCI at all — they live in the
|
|
// per-subscription JSON cache files (subcache.go) and are merged back in by
|
|
// ReadUCI. Only manual nodes become `config node` sections, and a legacy
|
|
// from_sub section therefore drains out of /etc/config/shater on the next
|
|
// write. Manual nodes never gain the cache-only options.
|
|
func TestRenderSkipsSubCacheNodes(t *testing.T) {
|
|
m := &Model{Nodes: []Node{
|
|
{Name: "manual1", URI: "ss://x", Enabled: true},
|
|
{Name: "cached1", URI: "ss://y", Enabled: true, FromSub: "qomar", Fingerprint: "fp", Stale: true},
|
|
}}
|
|
text := RenderUCIExport(m)
|
|
if !strings.Contains(text, "option name 'manual1'") {
|
|
t.Fatalf("manual node missing from render:\n%s", text)
|
|
}
|
|
for _, forbidden := range []string{"cached1", "from_sub", "fingerprint", "stale"} {
|
|
if strings.Contains(text, forbidden) {
|
|
t.Fatalf("sub-cache node leaked into UCI (%q):\n%s", forbidden, text)
|
|
}
|
|
}
|
|
got, err := ParseUCIExport(text)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Nodes) != 1 || got.Nodes[0].Name != "manual1" {
|
|
t.Fatalf("expected only the manual node to round-trip, got %+v", got.Nodes)
|
|
}
|
|
}
|
|
|
|
// TestWriteUCIReplaces proves WriteUCI drives the seam correctly: it DELETEs the
|
|
// package first (so import replaces, never appends), IMPORTs the rendered text,
|
|
// and COMMITs — and the imported text re-parses to the original Model.
|
|
func TestWriteUCIReplaces(t *testing.T) {
|
|
m := richModel()
|
|
f := newFakeUCI("")
|
|
if err := writeUCIWith(f, m); err != nil {
|
|
t.Fatalf("writeUCIWith: %v", err)
|
|
}
|
|
// delete-before-import so a re-run cannot duplicate sections.
|
|
if len(f.deleted) != 1 || f.deleted[0] != "shater" {
|
|
t.Fatalf("expected a single delete of shater, got %v", f.deleted)
|
|
}
|
|
if f.commits != 1 {
|
|
t.Fatalf("expected exactly one commit, got %d", f.commits)
|
|
}
|
|
if f.imported != RenderUCIExport(m) {
|
|
t.Fatal("imported text != rendered text")
|
|
}
|
|
got, err := ParseUCIExport(f.imported)
|
|
if err != nil {
|
|
t.Fatalf("re-parse imported text: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(got, m) {
|
|
t.Fatalf("WriteUCI'd model does not round-trip:\n want %#v\n got %#v", m, got)
|
|
}
|
|
|
|
// Idempotence: a second write starts by deleting again — no duplication.
|
|
if err := writeUCIWith(f, m); err != nil {
|
|
t.Fatalf("second writeUCIWith: %v", err)
|
|
}
|
|
got2, err := ParseUCIExport(f.imported)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got2.Nodes) != len(m.Nodes) || len(got2.Rules) != len(m.Rules) {
|
|
t.Fatalf("second write duplicated sections: nodes=%d rules=%d", len(got2.Nodes), len(got2.Rules))
|
|
}
|
|
}
|
|
|
|
// --- write-path guard: an unmigrated config may not be overwritten ------------
|
|
|
|
// unmigratedOnDisk is a v1 config as it sits on the router when `shaterd migrate`
|
|
// never got to commit: two rules whose destination is still an inline list, plus
|
|
// a subscription whose quota counters a refresh wants to update.
|
|
const unmigratedOnDisk = `package shater
|
|
|
|
config globals 'globals'
|
|
option enabled '1'
|
|
option schema_version '1'
|
|
|
|
config subscription
|
|
option name 'qomar'
|
|
option url 'https://example.invalid/sub'
|
|
|
|
config rule
|
|
option name 'bank'
|
|
option enabled '1'
|
|
list dst_domain 'bank.ru'
|
|
option target 'direct'
|
|
|
|
config rule
|
|
option name 'default'
|
|
option enabled '1'
|
|
option target 'group:auto'
|
|
`
|
|
|
|
// A write that would change the rules of an unmigrated config is REFUSED, and the
|
|
// on-disk config is byte-identical afterwards. Without this the renderer (which
|
|
// emits no dst_domain/dst_ip) simply dropped the operator's lists on the first
|
|
// save from the panel.
|
|
func TestWriteUCIRefusesToOverwriteAnUnmigratedConfig(t *testing.T) {
|
|
f := newFakeUCI(unmigratedOnDisk)
|
|
before, _ := f.Export("shater")
|
|
|
|
m, err := ParseUCIExport(before)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
m.Rules[0].Order = 42 // any rule edit at all
|
|
|
|
err = writeUCIWith(f, m)
|
|
if err == nil {
|
|
t.Fatal("the write was allowed to erase the legacy destination lists")
|
|
}
|
|
if !errors.Is(err, ErrUnmigratedConfig) {
|
|
t.Fatalf("error is not ErrUnmigratedConfig: %v", err)
|
|
}
|
|
for _, want := range []string{`"bank"`, "dst_domain", "shaterd migrate", "REFUSED"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("message does not mention %q: %s", want, err)
|
|
}
|
|
}
|
|
if after, _ := f.Export("shater"); after != before {
|
|
t.Fatalf("the config on disk changed despite the refusal:\n--- before\n%s\n--- after\n%s", before, after)
|
|
}
|
|
if f.commits != 0 || len(f.deleted) != 0 {
|
|
t.Fatalf("the refused write still touched uci (commits=%d deleted=%v)", f.commits, f.deleted)
|
|
}
|
|
}
|
|
|
|
// The guard reads the DISK, not the submitted model — so a body that simply omits
|
|
// LegacyDst and sets Enabled cannot get a destination-less rule written with
|
|
// `enabled '1'`. That rule would parse back as a catch-all and become route Final
|
|
// for the whole router, which is the leak the parser guard closes on the read
|
|
// side and this closes on the write side.
|
|
func TestWriteUCIRefusesACraftedModelWithoutLegacyDst(t *testing.T) {
|
|
f := newFakeUCI(unmigratedOnDisk)
|
|
before, _ := f.Export("shater")
|
|
|
|
// Exactly what a hand-rolled PUT (or an older panel build) sends: the rule is
|
|
// there, enabled, and the field that marks it unmigrated is gone.
|
|
crafted := &Model{
|
|
Globals: DefaultGlobals(),
|
|
Rules: []Rule{
|
|
{Name: "bank", Enabled: true, Target: "direct"},
|
|
{Name: "default", Enabled: true, Target: "group:auto"},
|
|
},
|
|
}
|
|
if err := writeUCIWith(f, crafted); !errors.Is(err, ErrUnmigratedConfig) {
|
|
t.Fatalf("crafted body was accepted (err = %v)", err)
|
|
}
|
|
after, _ := f.Export("shater")
|
|
if after != before {
|
|
t.Fatalf("disk changed:\n--- before\n%s\n--- after\n%s", before, after)
|
|
}
|
|
if strings.Contains(after, "config rule\n\toption name 'bank'\n\toption enabled '1'\n\toption target") {
|
|
t.Fatal("a destination-less enabled rule reached the disk")
|
|
}
|
|
// And re-reading the disk still yields the protected shape.
|
|
m, err := ParseUCIExport(after)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if IsCatchAll(m.Rules[0]) || m.Rules[0].Enabled {
|
|
t.Fatal("the unmigrated rule lost its protection")
|
|
}
|
|
}
|
|
|
|
// A writer that does NOT touch the rules keeps working on an unmigrated box, and
|
|
// the legacy options survive the write. This is the subscription-refresh path
|
|
// (apply.UpdateSubscription / `shaterd sub update`) and the profile watcher: they
|
|
// build their model with ReadUCI, change a counter or globals.active_profile, and
|
|
// re-render the WHOLE package — so a blanket refusal would break a cron job, and
|
|
// a blanket allow would let that cron job erase the operator's lists.
|
|
func TestWriteUCIPreservesLegacyDstOnANonRuleWrite(t *testing.T) {
|
|
f := newFakeUCI(unmigratedOnDisk)
|
|
text, _ := f.Export("shater")
|
|
m, err := ParseUCIExport(text)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
// Exactly what subscribe.StoreUserInfo does.
|
|
m.Subscriptions[0].UserDownload = 1 << 40
|
|
m.Subscriptions[0].UserInfoAt = 1700000000
|
|
m.Globals.ActiveProfile = "home"
|
|
|
|
if err := writeUCIWith(f, m); err != nil {
|
|
t.Fatalf("a non-rule write was refused: %v", err)
|
|
}
|
|
|
|
after, _ := f.Export("shater")
|
|
got, err := ParseUCIExport(after)
|
|
if err != nil {
|
|
t.Fatalf("re-parse: %v", err)
|
|
}
|
|
if got.Subscriptions[0].UserDownload != 1<<40 || got.Globals.ActiveProfile != "home" {
|
|
t.Fatalf("the write did not land: %+v / %q", got.Subscriptions[0], got.Globals.ActiveProfile)
|
|
}
|
|
if !eqStrings(got.Rules[0].LegacyDst, []string{"dst_domain=bank.ru"}) {
|
|
t.Fatalf("the legacy destination list was erased: %q", got.Rules[0].LegacyDst)
|
|
}
|
|
if got.Rules[0].Enabled || IsCatchAll(got.Rules[0]) {
|
|
t.Fatal("the rule lost its unmigrated protection across the write")
|
|
}
|
|
// The config is still exactly as unmigrated as it was, so `shaterd migrate`
|
|
// can still fold the list into a rule-set afterwards.
|
|
if err := migrate1to2(f); err != nil {
|
|
t.Fatalf("migrate after the write: %v", err)
|
|
}
|
|
if f.ruleset("rule-bank") == nil {
|
|
t.Fatalf("migration found nothing to fold; rulesets = %q", f.rulesetNames())
|
|
}
|
|
}
|
|
|
|
// A fabricated LegacyDst in the submitted model must never reach the disk. On a
|
|
// MIGRATED config the guard has nothing to protect and returns early, so without
|
|
// withDiskLegacyDst the renderer happily wrote the client's `list dst_domain`
|
|
// into /etc/config/shater — and from there every downstream lock fired on a lie:
|
|
// the rule the client named went (and stayed) disabled, further rule writes were
|
|
// refused, and the warning blamed a migration that had never been involved.
|
|
func TestWriteUCIIgnoresAFabricatedLegacyDst(t *testing.T) {
|
|
const migratedOnDisk = `package shater
|
|
|
|
config globals 'globals'
|
|
option enabled '1'
|
|
option schema_version '2'
|
|
|
|
config rule
|
|
option name 'bank'
|
|
option enabled '1'
|
|
list dst_ruleset 'rule-bank'
|
|
option target 'direct'
|
|
`
|
|
f := newFakeUCI(migratedOnDisk)
|
|
text, _ := f.Export("shater")
|
|
m, err := ParseUCIExport(text)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(m.Rules[0].LegacyDst) != 0 {
|
|
t.Fatal("fixture is not migrated")
|
|
}
|
|
// Exactly what a crafted PUT body carries.
|
|
m.Rules[0].LegacyDst = []string{"dst_domain=example.com", "dst_ip=203.0.113.0/24"}
|
|
|
|
if err := writeUCIWith(f, m); err != nil {
|
|
t.Fatalf("writeUCIWith: %v", err)
|
|
}
|
|
after, _ := f.Export("shater")
|
|
for _, forbidden := range []string{"dst_domain", "dst_ip"} {
|
|
if strings.Contains(after, forbidden) {
|
|
t.Fatalf("a fabricated %s reached the disk:\n%s", forbidden, after)
|
|
}
|
|
}
|
|
got, err := ParseUCIExport(after)
|
|
if err != nil {
|
|
t.Fatalf("re-parse: %v", err)
|
|
}
|
|
if len(got.Rules[0].LegacyDst) != 0 {
|
|
t.Fatalf("rule came back unmigrated: %q", got.Rules[0].LegacyDst)
|
|
}
|
|
// Enabled is whatever was submitted — the fabrication must not have switched
|
|
// the rule off either.
|
|
if !got.Rules[0].Enabled {
|
|
t.Fatal("the fabricated field disabled a healthy rule")
|
|
}
|
|
// And the caller's model was not mutated behind its back.
|
|
if len(m.Rules[0].LegacyDst) != 2 {
|
|
t.Fatalf("writeUCIWith mutated the caller's model: %q", m.Rules[0].LegacyDst)
|
|
}
|
|
// The config is still writable: nothing latched.
|
|
if err := writeUCIWith(f, got); err != nil {
|
|
t.Fatalf("the config became unwritable: %v", err)
|
|
}
|
|
}
|