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
46 KiB
Phase 2 porting spec — v0.1 control-plane → v0.2 (sing-box, in-process)
This is the authoritative map for the Phase 2 control-plane port. Every Phase 2 agent should read it. It records what ports near-verbatim vs. what is rewritten, the v0.2 package layout, and the exact v0.1 internals + sing-box option surface.
Source: a full read-only survey of the
v0.1branch (xrayctl/,shater-core/) and the sing-box option surface onmain. Decisions:DECISIONS.mdD11/D12.
v0.2 package layout (overlay under shater/ and openwrt/)
shater/
cmd/shaterd/ # THE binary: embeds engine (box.New) + control-plane (+ panel later)
cmd/shater-proto/ # Phase-1 embedding prototype (kept as reference)
model/ # UCI desired-state structs (port model.go) + uci reader (uci.go) + migrate
parse/ # share-link + subscription parsers -> model.Node/URI (port sharelink/subformat/sub/subfilter/wireguard)
generate/ # model -> option.Options (NEW; replaces xray-JSON generate.go) + typed DNS
engine/ # box.New lifecycle: build/validate/apply(=close+new)/close; config-hash gate
netplane/ # nft `inet shater` + policy routing + tproxy + kill-switch + mgmt-bypass + dnsnat (port egress/nftstats/apply-nft)
apply/ # orchestration: snapshot, validate, apply engine+netplane, commit-confirm rollback, reconcile under flock
subs/ # subscription fetch/update/reconcile/cache (port sub.go) [Phase 2b]
ruleset/ preset/ schedule/ profile/ backup/ migrate/ # ported subsystems [Phase 2b]
stats/ # lx libbox command-client consumer (SubscribeDNSQueries/URLTest/traffic) [Phase 5]
api/ # command surface the LuCI/rpcd/panel calls (verbs)
openwrt/
shater-core/ # procd init, uci-defaults, hotplug, sysctl, Makefile (port shater-core; deps: shaterd, not xray)
luci-app-shater/ # thin LuCI launcher [Phase 3]
That block is the Phase-2 plan, kept because the wave assignment below reads from it. The tree that shipped is flatter —
shater/holdsalert apply buildtags cmd devices engine generate logsink model netplane panel parse registry stats subscribe— with rulesets, schedules, profiles, backup and migration living insidemodel/andgenerate/rather than as packages of their own, and with nopreset/: v0.2 has no preset subsystem at all (see theconfig presetnote in the schema section).
Build order / waves (parallel agents must own DISJOINT dirs, build only their own
package, and never edit go.mod):
- Wave 1 (contract):
model/(+ uci reader + migrate). Everything imports it. - Wave 2 (parallel):
parse/,netplane/,generate/+engine/. - Wave 3 (integrate):
apply/+cmd/shaterd+openwrt/shater-core, then the VM gate.
Phase-2 GATE: a real client on the VM is proxied E2E through a sing-box outbound, DNS anti-leak holds, kill-switch is honest. Subscriptions/rulesets/ presets/schedules/profiles/stats are Phase 2b / later — not required for the gate.
Wave 3 daemon contract (shater/apply + cmd/shaterd)
The engine runs IN-PROCESS, so the daemon that holds the box IS shaterd run — a
single long-lived process. It owns the engine; other CLI verbs SIGNAL it (they must
not start a second engine). Design:
shaterd run(procd-supervised, the daemon): read UCI (model.ReadUCI); ifGlobals.Enabled→apply.Apply(generate →engine.Applyswap →netplanerender+apply nft+routing); else stay inert. Then block. Signals: SIGTERM →apply.Teardown(engine.Close + netplane teardown) + exit; SIGHUP →apply.Reconcile(re-read UCI, idempotent re-apply). Optionally a small unix control socket for richer verbs later.shaterd reconcile(from hotplug/cron/LuCI): find the running daemon and send SIGHUP (or via control socket). Idempotent; does NOT itself build an engine. Falls back to no-op if the daemon isn't running (fork-storm guard, like v0.1allowColdStart=false).shaterd apply|confirm|rollback: commit-confirm flow (arm a detached watcher that rolls back afterGlobals.ConfirmTimeoutunless confirmed).apply.Rollback=engine.Rollback+netplanerestore of last-good nft/routing.shaterd status|nodes|stats(read-side, JSON for LuCI/panel) — MVP can stub these; full versions in Phase 5 (stats) via the lx command server.shater/applyties it together:Snapshot,Apply(m),Confirm,Rollback,Reconcile,Teardown, all under a cross-process flock (portflock_unix.go; lock e.g./var/lock/shater.lock). It imports model, parse, generate, engine, netplane.ACTIVE_FLAG=/var/run/shater.activegates hotplug/cron (set on successful enabled apply, cleared on teardown).
MVP for the gate: shaterd run (read UCI → apply engine+netplane → block →
SIGTERM teardown / SIGHUP reconcile) + shater/apply.Apply/Reconcile/Teardown under
flock. Commit-confirm/rollback and read verbs can follow, but Teardown must be honest
(fail-closed stays blocked until an explicit disable).
openwrt/shater-core (port from v0.1, swap deps to +shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full, drop dnsmasq since the engine does DNS): procd
init.d/shater supervises /usr/bin/shaterd run (respawn, START=99, no
procd_set_param file watch); reload_service→start/stop; service_triggers
reload-trigger "shater"; stop sends SIGTERM (honest teardown). init.d/shater-cron
(sub/ruleset/schedule due + reconcile). uci-defaults/30_shater-core (seed rt_tables
8192, enable inits, model.Migrate, sysctl from netplane.SysctlConf). hotplug.d/iface/99-shater (debounced shaterd reconcile).
sysctl.d/99-shater.conf (from netplane). Default /etc/config/shater conffile.
What ports near-verbatim (engine-agnostic) vs. rewrites (engine-specific)
Port near-verbatim (not part of the engine config):
- The entire nft / policy-routing / DNS-hijack data plane:
egress.go,nftstats.go, the nft-render + routing parts ofapply.go, sysctl,dnsnat, kill-switch, management-bypass. Only the tproxy port + loop-guard mark wiring is re-pointed. - UCI model + reader (
model.go,uci.go),migrate.go,backup.go. - Share-link / subscription parsers (
sharelink.go,subformat*.go,sub.go,subfilter.go,wireguard.go) — reuse the parsing logic; change only the OUTPUT target (xraymap[string]any→ typedoption.*Options). schedule.go,ruleset.go,preset.go,profile*.go— mostly engine-agnostic.shater-core/procd init, uci-defaults, hotplug, sysctl — port; swap deps (dropxray-core/xrayctl, addshaterd; keepkmod-nft-tproxy/socket,ip-full).
Rewrite (engine-specific):
generate.go→ new generator emittingoption.Options.dns.go→ typedoption.DNSOptions(v1.14 removed flat servers + top-level fakeip).- Engine lifecycle: box has no
Reload— apply = Close old box + New/Start; gate on a config hash so unchanged applies don't churn the tunnel. observatory.go/ stats /conns.go→ lx libbox command server (SubscribeDNSQueries/GetRules/URLTestOutbound/ traffic) instead of xray API.compat.go→ obsolete (build-tag/feature detection of the embedded engine).
sing-box SIMPLIFIES three v0.1 hacks — take the native path:
- Chains (multi-hop): drop the inverted localhost-socks scaffold (
emitChain); use native per-outboundDialerOptions.Detourchaining. - Groups/balancer: drop xray balancer+observatory; use native
urltest(least_test) + lxround_robinbalancer.fallbackTag(fail-closed→block) has no native equivalent → express as a trailing route rule. - resolver.detour: each sing-box DNS server has its own
Detour— maps cleanly.
PART A — v0.1 control-plane (xrayctl/, branch v0.1)
File map (read the file on the v0.1 branch: git show v0.1:xrayctl/<file>)
- model.go — typed desired-state
*Model(see structs below); everything consumes it, nothing downstream touches raw UCI. HelpersparseBool/parseUint32/parseInt/splitTarget. - main.go — CLI dispatch (verbs LuCI/rpcd call):
gen test selftest apply confirm rollback reconcile sub node chain geodata schedule backup restore profile migrate compat wanmode ruleset status conns nodes stats explain. flock on mutating verbs. - generate.go —
Model→ xray JSON.BuildConfig,BuildConfigFromLinks,builder,Outbound. (rewrite target) - sharelink.go —
ParseShareLink,parseVLESS/VMess/Trojan/SS/Wireguard,buildStream,normalizeTransport. - subformat.go / subformat_parsers.go —
DetectSubFormat,ParseSubBody,parseClashProxies/parseXrayOutbounds/parseSingboxOutbounds,synthLink/vmessLink/ssLink,streamOpts(converge all formats to share-link URIs). - sub.go —
Fingerprint,FetchSubscriptionFull,SubUpdate,ReconcileSub,SubCache,CachedNode,SubUserinfo,ImportNodes(HAPP fetch, userinfo, fingerprint reconcile, cache). - subfilter.go —
FilterSpec,subApplyFilters,nodeCountry,flagToISO,parseCountryFilter. - wireguard.go —
parseWireguard,parseWGConf,wgConfigToURI,wgConfig,wgAWGSupported(WG/AWG link +.conf→ model). - egress.go —
buildEgresses,resolveEgressTag,egEgressMark,egEgressOutboundTag,genClassifySrc,genSrcClasses(egress binding + src classification; contract with nft plane). - nftstats.go —
nftSrcFrags,nftRuleCounter/nftInCounter,nftListClients/nftListCounters,nftTableExists; constsnftTable="inet shater",nftClient4="clients". - apply.go —
Apply,Confirm,Rollback,Reconcile,RenderNft,applyNft/applyRouting,TestConfig,GenerateConfig,ifaceDevice(atomic apply + nft + routing + rollback). - backup.go —
Backup/BackupTo,Restore,backupManifest,untarInto(gzip-tar of /etc/config/shater + caches; validate-before-swap). - uci.go —
ReadUCI,ParseUCIExport,parseSections,uciSection(uci -q export shater→ Model). - dns.go —
dnsRender,dnsResolverAddress,dnsQueryStrategy,dnsFakePool(Model → xray dns). (rewrite target) - compat.go —
CompatJSON,xrayVersion,requiredFeatures,featureMinVer. (obsolete) - conns.go —
ConnsJSON,parseConntrack(live flows, proxied/direct). - flock_unix.go — real blocking cross-process flock;
lockPath=/var/lock/xrayctl.lock. - geodata.go —
geoAssetPresent,geoStrip,GeodataStatus/Download/Remove(strip geo matchers when dat absent). - migrate.go —
Migrate,uciRunner, and on the v0.1 branchCurrentSchemaVersion = 1with a single step{0 -> 1}. That 1 is v0.1's number and nothing else's. v0.2'sshater/model/migrate.gois atCurrentSchemaVersion = 2with{0 -> 1, 1 -> 2}—migrate1to2is the one that removeddst_domain/dst_ipfromconfig rule(see the schema subsection below, which is the live document). Both versions refuse a config NEWER than the build; in v0.2 that refusal also covers every config WRITE (model.ErrSchemaTooNew), so a downgraded package cannot quietly rewrite a newer config into the older form. - nodeops.go —
NodeSetEnabled/NodeDelete/NodeAssignGroup/NodeQR. - observatory.go — read xray live state via gRPC API inbound (127.0.0.1:10853). (rewrite → lx command server)
- preset.go —
presetRules,presetDef,builtinPresets(curated rule packs → synthetic Rules). - probe.go —
NodeHTTPProbe/All/ChainHTTPProbe,ProbeResult,runEphemeralXray,socks5Dial. - profile.go — named config snapshots (tar.gz).
profile_wanmode.go— WAN-mode conditional overrides. - ruleset.go —
LoadRuleset,rsResolve,RulesetUpdate(inline/file/url; plain/clash/geosite). - schedule.go —
ruleActiveNow,scheduleSignature,ScheduleDue,timeNow(CP re-applies at window boundaries; engine has no time match). - status.go —
StatusJSON/NodesJSON/StatsJSON/ExplainJSON,NodeTest,sortedRules,matchSrc/matchDst/domainMatch.
model.go — the data model (parsers PRODUCE this; the generator CONSUMES it)
type Model struct {
Globals Globals
Inbounds []Inbound
Subscriptions []Subscription
Nodes []Node // manual + subscription-cache (Node.FromSub != "")
Groups []Group
Chains []Chain
Egresses []Egress
Rulesets []Ruleset
Rules []Rule
Presets []Preset
Profiles []Profile
Resolvers []Resolver
DNSRules []DNSRule
}
type Globals struct {
Enabled bool; LogLevel string; KillSwitch string // closed|open
DNSMode string // nftset|fakeip
IPv6 bool; FwmarkBase uint32; TableBase uint32 // both default 0x2000
ConfirmTimeout int; ResolverDefault string; ResolverFallback string
ProbeURL string; ProbeInterval string; SchemaVersion int; ActiveProfile string
}
type Inbound struct { // Type default "" => tproxy
Name string; Enabled bool; Type string // tproxy|socks|http|dokodemo
Network string; TproxyPort int
Listen string; Port int
Auth,User,Pass string
TargetAddr string; TargetPort int; TargetNetwork string
TCP,UDP,Sniff bool
}
type Node struct {
Name string; Enabled bool; URI string; FromSub string // "" = manual
Fingerprint string; Stale bool
Mux bool; MuxConcurrency int; XUDPConcurrency int; XUDPProxyUDP443 string
Mark uint32; TCPFastOpen string; TCPKeepAliveIdle int
}
type Group struct {
Name string; Source string // subscription|manual
Subscription string; Nodes []string
Strategy string // leastping|random|roundrobin|failover|single
Include,Exclude,FilterProto,FilterCountry []string; Dedup bool
ProbeURL,ProbeInterval string
}
type Chain struct { Name string; Hops []string } // "group:<n>" | "node:<n>", L1..Ln (Ln=exit)
type Egress struct { Name,Type,Interface,Target string } // interface|proxy|direct|block
type Rule struct {
Name string; Enabled bool; Order int
Src []string; DstRuleset []string; DstPort,Proto string // dst = ruleset only (v0.2 schema v2)
Target string // chain:|group:|node:|direct|block
Egress,Kill string
SchedEnabled bool; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
}
type Resolver struct { Name,Type,Address,Detour,Pool string } // doh|dot|plain|local|fakeip
type DNSRule struct { Order int; MatchDomain,MatchSrc []string; Resolver string }
type Profile struct { // WAN-mode override
Name string; Enabled bool; Priority int
MatchIface []string; ProbeURL,ProbeMode string; SchedDays []string; SchedStart,SchedEnd string; SchedUTCOffset int
EnableRules,DisableRules []string; DefaultTarget,DefaultEgress string
}
generate.go — node→xray mapping (rewrite reference)
Emits {log, dns, inbounds[], outbounds[], routing{rules,balancers}, observatory, api/stats/policy}.
loopMark = 255 (0xff)stamped on every real outbound + every tproxy inbound (withMark). API gRPC onapiInboundPort=10853.- baseline outbounds:
direct(freedom, UseIP) +block(blackhole). Kill-switch closed ⇒ trailing catch-all → block (fail-closed). - inbounds: tproxy = dokodemo-door +
followRedirect:true+sockopt{tproxy:"tproxy",mark:255}+ sniff [http,tls,quic]; socks/http = plain listener; dokodemo = fixed target. - protocol emit (via
ParseShareLink): vlesssettings.vnext[0].users[0]{id,encryption:none,flow?}; vmessusers[0]{id,alterId,security}; trojanservers[0]{address,port,password}; ssservers[0]{address,port,method,password}; wireguardsettings{secretKey,address[],peers[0]{publicKey,endpoint,allowedIPs,preSharedKey?,keepAlive?},mtu?,reserved?}+ AWG jc/jmin/jmax/s1/s2/h1-h4 only ifwgAWGSupported(v1 default false).applyNodeOptsadds mux + sockopt. - groups→balancer: member tag
g_<name>__%04d_<node>; balancer{tag,selector,strategy,fallbackTag}; strategies leastPing/random/roundRobin/leastLoad/(failover=leastLoad{expected:1}); single/1-member ⇒ direct route; multi-member requires observatory. - chains: inverted socks pairs (
socks_<chain>_L<i-1>). → v0.2: native Detour. - routing rules: presets prepended, first-match by Order, scheduled/disabled skipped, WAN overrides applied; src IP/CIDR/host → engine
source, MAC/iface:/zone: → nft plane; geo stripped if dat absent.
egress.go + apply.go — the EXACT nft plane (PORT VERBATIM)
Table inet shater. Marks: tproxy divert = FwmarkBase (0x2000); loop-guard = 0xff; per-egress = FwmarkBase + 0x100 + idx. Sets clients(ipv4 dynamic counter), clients6(ipv6). Per-rule c_rule_<id>, per-inbound c_in_<id>.
table inet shater {
set clients { type ipv4_addr; flags dynamic; counter; }
set clients6 { type ipv6_addr; flags dynamic; counter; }
counter c_rule_<name> { } ...
chain prerouting { type filter hook prerouting priority mangle; policy accept;
meta mark 0xff accept # loop-guard
meta mark 0x21xx accept # each interface/tunnel egress mark
fib daddr type local accept # SSH/LuCI/DNS to any router IP
ip daddr { 10/8,172.16/12,192.168/16,127/8,169.254/16,224/4,255.255.255.255 } accept
ip6 daddr { ::1, fc00::/7, fe80::/10, ff00::/8 } accept
<iif> l4proto {tcp,udp} th dport 853 accept # DoT/DoQ out of tproxy (rejected in forward)
<iif> l4proto {tcp,udp} th dport 53 accept # :53 out (redirected by dnsnat)
<iif> l4proto tcp <ip saddr X> update @clients {ip saddr} counter name "c_rule_x" tproxy ip to :12345 meta mark set 0x2000 accept
}
chain forward { type filter hook forward priority filter; policy accept;
<iif> l4proto {tcp,udp} th dport 853 reject
# ipv6-off / closed kill-switch: keep ND/RA/fe80/ff00, then meta nfproto ipv6 drop
}
chain dnsnat { type nat hook prerouting priority dstnat; policy accept;
fib daddr type local accept
<iif> l4proto {tcp,udp} th dport 53 redirect to :53
}
}
Policy routing: ip -4/-6 rule add fwmark 0x2000 lookup 8192 + ip route add local default dev lo table 8192. Per-egress: mark→table TableBase+0x10+idx default via <gw> dev <dev> (or dev <dev> p2p). Never touches fw4.
Apply/rollback: apSnapshot (run→last-good, nft→last-good.nft, route marks) → disabled?teardown : BuildConfig → TestConfig → syncRunJSON (sha256 compare; restart engine only on change) → applyNft (nft -c then -f) → applyRouting → optional commit-confirm (apArmAutoRollback detached watcher). Reconcile = idempotent re-apply, allowColdStart=false (fork-storm guard). Rollback restores nft→routing→run in order.
v0.2: "restart engine only on change" → config-hash gate + Close+New box (no reload).
uci.go — /etc/config/shater schema
This subsection alone describes the CURRENT v0.2 schema, not v0.1 — the shipped
/etc/config/shaterpoints its reader here by name, so it is kept in step withshater/model/uci.go(read) andrender.go(write), which are the only two places a section type or option name exists. Everything else in PART A is the v0.1 survey the port was planned from and is deliberately frozen.An option not listed below is not "undocumented" — it is IGNORED: the parser's type switch drops an unknown section type whole, and an unknown option inside a known section is never read. That is deliberate (
TestUnknownSectionAndOptionIgnoredpins that such a config still parses — the daemon has to come up on whatever it finds), and it is also why a dead knob here is SILENT: setting one changes the file and nothing else, with no error anywhere to say so.
-
config globals— the full option set, with the value used when the option is ABSENT (themodel.DefaultGlobalsseed). Booleans are always written back as'1'/'0'byrender.go, so an explicit value never decays into the seed:option default meaning enabled0as shippedmaster switch; 0⇒Reconciletears the stack down instead of applyingloglevel(aliaslog_level)warningengine + daemon level; none/off/silent/disabled⇒ log disabled, unknown ⇒warn+ a validation warninglog_syslog/log_file/log_persist1/1/0operational log ( shater/logsink): syslog, rotated file, and whether that file lives on flash instead of tmpfslog_max_kb2048size cap of the log file, clamped to 128…8192; 0= "use the default", not "off"kill_switchclosedclosed= fail-closed (block on engine loss, incl. a holding plane when the engine never started);open= plain routingipv610drops LAN IPv6 in the forward chain instead of leaving it unproxiedfwmark_base/table_base0x2000reserved fwmark / routing-table bases (must not collide with fw4 or other apps) confirm_timeout0seconds before an unconfirmed apply auto-rolls back; 0= commit-confirm offresolver_default/resolver_fallback/endpoint_resolverunset config resolvernames: the DNS catch-all, its failover chain, and the bootstrap-direct server that resolves proxy endpoint DOMAINSprobe_url/probe_intervalengine defaults the ONE instrument all health probing uses (D20 — there are no per-group overrides) panel_port0⇒8088admin-panel HTTP port dns_filter0master enable of the blocklist/allowlist filter (D15); needs at least one config resolverdns_intercept1force ALL LAN plaintext :53into the engine, INCLUDING queries addressed to the router itself. See D24 for why this is the default, what preserves.lan, and what happens while the engine is downblock_doh0NXDOMAIN the known public DoH hostnames + the Firefox canary and reject :443to their IPs, so clients fall back to:53(which the engine catches)group_health1OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group untunnelableblockpolicy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): block|icmp(echo out, rest dropped) |direct(all out, bypassing the tunnel)l3_tunnel1Opens the synthetic l3-inTUN so LAN ICMP is routed by the engine instead of dropped/forged; nft marks LANicmp/ipv6-icmpwithfwmark_base+0x80and a scopedip rulesends it to tabletable_base+8. ON by default since the flip (model/l3tunnel_default_test.go): with it off a LAN ping is decided byuntunnelablealone, whose every rung either drops the echo or lets it out of the WAN with the client's real address. An ABSENT option therefore comes back ON; only an explicit0closes it, and that opt-out survives the write→read round-trip. Set through UCI — no panel control writes it (the Networks page reads it to explain whatuntunnelablestill decides). See D25 andARCHITECTURE.md§3auntunnelable_egressunset opt-in, UCI-only. Names a config egress; everything the L3 block did not claim (ESP/AH, GRE, IGMP, SCTP, and ICMP whenl3_tunnel=0) is stamped with that egress's OWN mark and routed out its device by the kernel — no new mark, no new table, engine not in the path. Empty ⇒untunnelableabove stays in sole charge (D26)geo_providerunset = auto sagernet|loyalsoldier|metacubex|custom; auto = country codes from SagerNet, everything else from Loyalsoldiergeosite_url/geoip_urlunset {category}templates, honoured only whengeo_provider=customgeosite_index_url/geoip_index_urlunset git-trees URLs used to SUGGEST categories in the panel; empty = no suggestions stats_backendmemoryoff(no aggregation at all) |memory(RAM, lost on restart) |sqlite(aggregates in RAM + query/connection log on disk). The value NAME is historical: the on-disk store is bbolt, not SQLite, since the migration — a leftover sqlite-erastats.dbis detected by its file magic and replaced (shater/stats/boltring.go)stats_ring_size/stats_timeline_minutes/stats_max_domains200/60/5000live-log length, sparkline minutes, domain-map cap. 0= UNLIMITED (grows with traffic), which is why these three are always emittedstats_disk_limit_mb64on-disk cap of stats.db; only meaningful forstats_backend=sqlite;0= unlimitedstats_retention_disabled0master switch that turns OFF all trimming/pruning — every aggregate then grows unbounded schema_version0= pre-versionedUCI schema revision; shaterd migratewrites2active_profileunset display bookkeeping: the last profile switched to Deleted options still parse (unknown keys are ignored) and drain out on the next render:
dns_mode(D17 — fake-IP is a resolver TYPE),sweep_interval(D19). -
config inbound: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp. Nosniff. Since sing-box 1.11 sniffing is a leading route ACTION rule with no inbound matcher, so every inbound is sniffed always; the flag was read by nothing but its own UCI round-trip. Re-adding it would be a regression, not a restored feature — the hijack-dns rule matches the SNIFFEDdnsprotocol, so a per-inbound toggle is a DNS-leak switch wearing a performance label (model.go,Inbound). -
config subscription: name, enabled, url, update_interval, fetch_via(direct|proxy), fetch_detour, ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days — plus the persistedSubscription-Userinfostate the daemon writes back itself: user_upload, user_download, user_total, user_expire, userinfo_at (absent =0= "not reported", on both the read and the write side).fetch_detouris consulted only whenfetch_via=proxy; it names the outbound the fetch dials through —group:X|node:X|egress:X|chain:X|direct, empty = direct (apply.HTTPClient/resolveVia). -
config node: name, enabled, uri, mux, mux_concurrency, sockopt_mark, tcp_fast_open, tcp_keepalive_idle, egress — the last binds THIS node's own upstream to aconfig egress(multi-WAN), so its exit connection leaves over the chosen device. Noxudp_concurrency/xudp_udp443: xudp was an xray packet-encoding knob with no sing-box counterpart, and the fields went with the generator rewrite.from_sub/fingerprint/staleare READ but never written. Subscription nodes live in per-subscription cache files (model/subcache.go); the options survive only so an old config's cached nodes are imported once on first read, after which they drain out of UCI. -
config group: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, egress — the last binds EVERY member's dialer to that egress (a node's ownegressis more specific and wins). Noprobe_url/probe_interval: the per-group overrides were deleted; probing is configured once, inglobals(D20). Old configs carrying them still parse — the options are ignored and drain out on the next render. -
config chain: name, list hop (group:<n>|node:<n>, L1..Ln, Ln = exit). -
config egress: name, type, interface, dpi. type isinterface|direct;tunnelis an accepted ALIAS ofinterfaceand an empty value meansdirect— both folded to the canonical spelling once, at the config boundary (Model.NormalizeEgressTypes, called byReadUCI), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported byValidateEgresses, every binding to it fail-closed). A type this product REMOVED is a third case with the same fail-closed behaviour and a different sentence —model.RetiredEgressTypesis the closed table bothValidateEgressesand the generator read it from, so an operator whose config was correct for an older build is told what happened rather than that their value is a typo (D29).interfacenames the device and is meaningful for theinterfacetype only;dpiis the native DPI-bypass preset —off|fragment|record|spoof(D13). Noport: no surviving egress kind dials anything, so the option is not parsed and drains out on the next render. It belonged to the removed SOCKS-hop kind (D29). Notarget: v0.1'sproxy/blockegress kinds are gone — where traffic goes is a rule'starget, what device it leaves by is an egress. -
config ruleset: name, type(domain|ipcidr, defaultdomain), source(inline|file|url|geosite|geoip, defaultinline), url, path, format, update_interval, list category, list entry.formatnames the ENGINE rule-set format and has exactly two real values,binary(a compiled.srs) andsource(a sing-box rule-set.json); empty — and the v0.1 leftoverplain, andauto— mean "infer from the file name", which is sing-box's own behaviour. Ignored for inline/geosite/geoip.list categoryis the canonical spelling (one chip per geosite category or geoip country code, each materialised as its own remote.srs); a legacy singleoption categoryis still accepted on read and re-emitted as a list. -
config rule: name, enabled, order, list src, list dst_ruleset, dst_port, proto, target, egress, kill, sched_enabled, list sched_day, sched_start/end, sched_utc_offset. v0.1 carrieddst_domain/dst_ipon the rule itself; schema v2 removed both — a destination is aconfig rulesetand nothing else.shaterd migratefolds each legacy list into a generatedrule-<name>(andrule-<name>-ip) inline ruleset; seeDECISIONS.mdD21 for the entry-by-entry conversion table. -
config profile: name, enabled, priority, list match_iface, list enable_rule, list disable_rule, endpoint_resolver. The condition ismatch_ifaceand nothing else: the active default-route device must be in that set (cmd/shaterd/profilewatch.go). The overrides are the two rule lists plus an optional per-profileendpoint_resolver, which overridesglobals.endpoint_resolverwhile the profile is active (generate/dns.go; the point is keying the bootstrap resolver to the WAN in use). Noprobe_url/probe_mode, and they were deleted rather than documented: they promised "activate while a probe succeeds/fails", nothing ever probed, AND the selector treated a profile carrying aprobe_urlas having an unsatisfiable condition and skipped it — so adding a probe to a working profile silently switched that profile off. Nosched_*, nodefault_target/default_egresseither — a profile enables and disables named rules; it does not carry a routing default of its own. -
config resolver: name, type, address, detour, pool. type isdoh(synonymhttps) |dot(synonymtls) |plain(synonymsudpand the empty default) |tcp|local|fakeip. Anything else is NOT built — the resolver simply does not exist, andgeneratesays so by name. Each type consumes a different subset, and the rest is thrown away (generate.warnIgnoredResolverFieldswarns per field rather than dropping it silently):doh/dot/plain/tcpuse address + detour, ignore pool;localuses detour only (it reads the router's/etc/resolv.conf);fakeipuses pool only (default198.18.0.0/15) — it mints answers locally, so it has nothing to dial and ignores both address and detour.detouris per-SERVER, not global: every sing-box DNS server carries its own. -
config dns_rule: order, list match_domain, list match_src, resolver. It has noname— a DNS rule is identified by its order and selectors. -
config blocklist: name, enabled, source(inline|file|url|geosite), url, path, list category, list entry, response, update_interval.responsehas exactly two values:nxdomain(default — Rcode 3, empty answer) andzero(NOERROR +A 0.0.0.0, andAAAA ::whenglobals.ipv6). There is no third; neither is sing-box'saction:reject, which answers REFUSED. -
config allowlist: the same minusresponse(an allowlist has no verdict to render); it overrides every blocklist, being emitted at higher priority. -
config device: name, mac, ip, enabled, list block, list allow — whereblock/alloware DOMAINS, not targets: a device entry is per-device DNS filtering (block regardless of the globaldns_filterswitch, allow overriding every blocklist). Per-device ROUTING is not a device concern — it is an ordinaryconfig rulewhosesrcnames the device. -
config alert: name, enabled, type(telegram|webhook), token + chat_id (telegram), url (webhook), list event, via, fallback. -
config presetis NOT a section type, and there is no preset subsystem in v0.2.ParseUCIExport's type switch has nopresetbranch, so such a section is parsed by nothing, reaches no part of the model, and settingenabled=1on one changes the file and nothing about the router. It used to appear anyway:30_shater-coreseeded three (block_ads,ru_bypass,private) "so the LuCI Rules page renders their toggles", and v0.2's LuCI app is a launcher with no Rules page. Worse than inert — the panel's first save wiped them, becausewriteUCIWithreplaces the whole package (uci delete shater+uci import), so the placeholder deleted itself and read as a breakage. The seeding is gone, and the script now DELETES anypresetsection an older release left behind (safe by construction: the type is read by no consumer, so there is no setting to lose). This is not a feature waiting to be re-enabled. v0.1's packs were xraygeosite:/geoip:matcher lists materialised into synthetic rules (xrayctl/preset.goon the v0.1 branch); under schema v2 a rule's destination IS aconfig ruleset, so the same pack is an ordinary ruleset + rule — which the panel's Routing page builds today, geosite/geoip sources included. Anything richer needs a section type the parser knows, and that has to land inshater/modelfirst.
Subscriptions & HAPP fetch
Schemes: vless:// vmess:// trojan:// ss:// wireguard:// wg://. Body formats (DetectSubFormat): clash-YAML, xray-JSON, singbox-JSON, base64/plain link list. All converge to URIs re-parsed by ParseShareLink. HAPP fetch: UA default Happ/3.13.0; headers x-hwid (auto UUIDv4/sub), x-device-os, x-ver-os, x-device-model, custom. fetch_via=proxy dials local socks. Quota/expiry from Subscription-Userinfo (upload;download;total;expire). Reconcile by Fingerprint (sha256 of proto|addr|port|id|net|sec|sni|path) → new/keep/stale (drop after 3 stale refreshes).
PART B — v0.1 packaging (shater-core/, branch v0.1)
Pure scripts+config, PKGARCH:=all. v0.1 DEPENDS: +xrayctl +xray-core +dnsmasq-full +kmod-nft-tproxy +kmod-nft-socket +ip-full. → v0.2 deps (authoritative: openwrt/shater-core/Makefile, which annotates each one): +shaterd +kmod-nft-tproxy +kmod-nft-socket +kmod-tun +ip-full +nftables-json +ca-bundle — dnsmasq-full is gone (the engine owns the :53 hijack listener); kmod-tun is /dev/net/tun for the L3 ingress, nftables-json is the nft -j output netplane/stats.go parses, ca-bundle is the cert store a CGO_ENABLED=0 binary has no host fallback for. /etc/config/shater is a conffile.
- init.d/shater (procd, START=99/STOP=10): v0.1 supervised
xray run -c /etc/xray/run.json; → v0.2 supervisesshaterd.respawn 3600 5 0(infinite). Noprocd_set_param filewatch (would bounce tunnel on commit). Inert unlessglobals.enabled=1.ACTIVE_FLAG=/var/run/shater.activegates hotplug/cron.stopclears flag + tears down nft table + reserved routing tables.reload_service→start/stop. triggerprocd_add_reload_trigger "shater". - init.d/shater-cron (START=96): supervised
loop; per-item due-check, runs sub/ruleset update + reconcile + schedule due; watchdog: engine dead 5 ticks ⇒ kill_switch=open stops stack (fail-open), closed logs crit. - init.d/shater-armor (START=21/STOP=89, v0.2-only — no v0.1 counterpart): the fail-closed plane BEFORE the daemon exists.
/etc/init.d/shateris START=99, so from netifd'sifupuntil the daemon's first apply the router forwarded LAN→WAN in the clear. The daemon persists its holding plane to/etc/shater/boot.nfton every apply; this loads it after fw4 (19) and netifd (20),nft -c-validated. Four state checks refuse to arm (no/empty/invalid file, missingshaterd, noS??shaterrc-link, readable UCI sayingenabled≠1) — asked ON THE WAY UP, deliberately not recorded on the way down. Hooksforwardonly, so SSH/LuCI/panel stay reachable.stop()is a NO-OP. Operator-facing writeup:INSTALL.md§4. - uci-defaults/30_shater-core: seed
rt_tables(8192 shater),mkdir /etc/shater, DELETE any leftoverconfig presetsection (a type nothing parses — see the schema note above; the seeding of three of them is gone), seed theshater_l3fw4 zone + a<zone>→shater_l3forwarding for every zone (named sections,list device 'shater-l3*') and migrate a legacy exact-name entry to the wildcard, runshaterd migrate(whose result is classified and reported, not discarded — see below), apply sysctl, then a DETACHED bring-up (enable+restartshater/shater-cron, enableshater-armor, conditionalfirewall reload) — detached because an inline init call inside an apk/opkg transaction deadlocks on procd's flock. shaterd migratereporting (both call sites:uci-defaults/30_shater-coreandinit.d/shater'sstart_service). The verb exits 1 for every failure, so the shell classifies the outcome itself, with a CLOSED positive list —ok/downgrade/unreadable/failed(shater_migrate_class, duplicated in the two scripts because the package installs no shell library they could share;TestMigrateClassifiersAgreefails if they ever diverge).downgradeis recognised by the substringnewer than this build, which bothmodel.migrateWith's refusal andmodel.ErrSchemaTooNewcontain — a contract pinned byTestMigrateDowngradeSignatureIsAContract. An unrecognised failure lands onfailed, which says so and quotes the binary verbatim, rather than being reported as one of the causes we can name. Failures reach the operator on two channels that are not syslog, becauseglobals.log_syslog=0is a deliberate setting about the syslog stream and not a request to be left uninformed: the script's own stderr (the operator's terminal on a hand-typedrestart; the package manager's output insideapk add/opkg install), and/etc/shater/migrate-failedon flash, written on failure and REMOVED on the first success — its absence is the all-clear. syslog gets the same line too whenlog_syslogallows it. A migration that SUCCEEDED is routine and stays on the syslog channel only.30_shater-corestill exits 0 after a failed migration, deliberately: a uci-defaults script that exits non-zero is kept and re-run at every boot, and this one re-runs a detached enable+restart ofshater/shater-cronplus a firewall reload — so one recoverable failure would become permanent boot-time churn, to carry a status nothing reads. The retry that matters already exists:init.d/shaterruns the migration on every start.- hotplug.d/iface/99-shater: ifup/ifdown → debounced (2s)
reconcile(netifd wipes ip rules on reload). Guarded by enabled + ACTIVE_FLAG. - sysctl.d/99-shater.conf:
ip_forward=1,rp_filter=0(all+default),lo.route_localnet=1,lo.accept_local=1,all.src_valid_mark=1,ipv6.all.forwarding=1.
PART C — sing-box option surface (GENERATOR TARGET, on main)
Type consts (constant/proxy.go): TypeVLESS="vless" TypeVMess="vmess" TypeTrojan="trojan" TypeShadowsocks="shadowsocks" TypeHysteria2="hysteria2" TypeTUIC="tuic" TypeShadowTLS="shadowtls" TypeWireGuard="wireguard" TypeTProxy="tproxy" TypeMixed="mixed" TypeDirect="direct" TypeSelector="selector" TypeURLTest="urltest".
Wrappers: option.Inbound/Outbound/Endpoint{Type,Tag,Options any} — Options decoded by a per-type registry from the service context; must be pointers to the concrete struct. Shared: ListenOptions{Listen *badoption.Addr, ListenPort uint16, BindInterface, RoutingMark, TCPFastOpen, ...}; ServerOptions{Server string; ServerPort uint16}; DialerOptions{Detour, BindInterface, RoutingMark FwMark, ConnectTimeout, DomainResolver, ...}. Legacy inbound sniff/domain_strategy are REMOVED — sniff/resolve are route-rule actions now.
Inbounds
- tproxy
option.TProxyInboundOptions{ ListenOptions; Network NetworkList }(option/redir.go) — thin; sniff via a route ruleaction:"sniff". - mixed
MixedInboundOptions, socksSocksInboundOptions(option/simple.go).
Outbounds (struct + key fields)
- VLESS
VLESSOutboundOptions{ DialerOptions; ServerOptions; UUID; Flow; Network; OutboundTLSOptionsContainer; Multiplex *OutboundMultiplexOptions; Transport *V2RayTransportOptions; PacketEncoding *string } - VMess
VMessOutboundOptions{ …; UUID; Security; AlterId; GlobalPadding; AuthenticatedLength; Network; TLS; PacketEncoding; Multiplex; Transport } - Trojan
TrojanOutboundOptions{ …; Password; Network; TLS; Multiplex; Transport } - Shadowsocks
ShadowsocksOutboundOptions{ …; Method; Password; Plugin; PluginOptions; Network; UDPOverTCP; Multiplex }(no TLS/transport) - Hysteria2
Hysteria2OutboundOptions{ …; ServerPorts; HopInterval; UpMbps; DownMbps; Obfs *Hysteria2Obfs; Password; Network; TLS; QUICOptions; BrutalDebug } - TUIC
TUICOutboundOptions{ …; UUID; Password; CongestionControl; UDPRelayMode; UDPOverStream; ZeroRTTHandshake; Heartbeat; Network; TLS; QUICOptions } - ShadowTLS
ShadowTLSOutboundOptions{ …; Version; Password; TLS }
Shared TLS/Reality/uTLS/transport (option/tls.go, option/v2ray_transport.go)
OutboundTLSOptionsContainer{ TLS *OutboundTLSOptions }. OutboundTLSOptions{ Enabled, ServerName, Insecure, ALPN, MinVersion/MaxVersion, Certificate*, Fragment, UTLS *OutboundUTLSOptions{Enabled,Fingerprint}, Reality *OutboundRealityOptions{Enabled,PublicKey,ShortID}, ECH }. Reality = PublicKey + single ShortID; SNI from ServerName, fp from UTLS.Fingerprint (v1 spiderX/multi-shortId don't map).
Transport *V2RayTransportOptions{Type}: "ws"({Path,Headers,MaxEarlyData,EarlyDataHeaderName}), "grpc"({ServiceName,...}), "httpupgrade"({Host,Path,Headers}), "http"({Host,Path,Method,Headers}), "quic", "xhttp"(V2RayXHTTPOptions, lx region).
Endpoints — WireGuard/AmneziaWG (option/wireguard.go, wireguard_awg.go)
WireGuardEndpointOptions{ System, Name, MTU, Address []netip.Prefix, PrivateKey, ListenPort, Peers []WireGuardPeer, UDPTimeout, Workers; AmneziaWGOptions (embedded, promoted to root); DialerOptions }. WireGuardPeer{ Address, Port, PublicKey, PreSharedKey, AllowedIPs []netip.Prefix, PersistentKeepaliveInterval, Reserved []uint8 }. AmneziaWGOptions{ Jc,Jmin,Jmax,S1,S2,S3,S4 uint32; H1..H4 MagicHeader(uint32 or "min-max"); I1..I5 string; Id,Ip,Ib string } — gated by with_awg (set-without-tag = hard error). Endpoints register as route targets by Tag (shared tag namespace with outbounds).
Route (option/route.go, rule.go, rule_action.go)
RouteOptions{ Rules []Rule; RuleSet []RuleSet; Final string; AutoDetectInterface; DefaultMark FwMark; DefaultDomainResolver; ...; LXIdleSuspend }. Rule = default|logical. RawDefaultRule matchers: Inbound, Network, Protocol, Domain, DomainSuffix, DomainKeyword, DomainRegex, Geosite, GeoIP, SourceGeoIP, SourceIPCIDR, IPCIDR, SourcePort(Range), Port(Range), SourceMACAddress, ProcessName, ClashMode, RuleSet, Invert. Action via embedded RuleAction{Action}: "route"→RouteActionOptions{Outbound; OverrideAddress; OverridePort}; plus direct, reject, hijack-dns, sniff, resolve, route-options. Target outbound/endpoint by action:"route", outbound:"<tag>"; default = route.final.
Groups (option/group.go): SelectorOutboundOptions{Outbounds []string; Default; InterruptExistConnections}; URLTestOutboundOptions{Outbounds []string; URL; Interval; Tolerance; IdleTimeout; Mode "least_test"|"round_robin"; Balancer *URLTestBalancerOptions{Pool,PoolTolerance,StickyHash}(lx SPEC 019)}. Replaces xray balancer+observatory.
DNS (option/dns.go)
DNSOptions{ RawDNSOptions{ Servers []DNSServerOptions; Rules []DNSRule; Final string; ReverseMapping; DNSClientOptions{Strategy,Timeout,DisableCache,ClientSubnet} } }. Legacy flat servers + top-level fakeip REMOVED (v1.14). Servers typed via registry DNSServerOptions{Type,Tag,Options}; type strings (constant/dns.go): udp tcp tls https quic h3 local hosts fakeip dhcp mdns tailscale. Structs: RemoteDNSServerOptions(udp/tcp+DialerOptions+addr), RemoteTLSDNSServerOptions(+TLS), RemoteHTTPSDNSServerOptions(+Path/Method/Headers), LocalDNSServerOptions, FakeIPDNSServerOptions{Inet4Range,Inet6Range}. Each server has its own Detour. DNS rules reuse the route-rule shape targeting a DNS server tag.
Observability (lx gRPC, experimental/libbox/command_client_command_lx.go): SubscribeDNSQueries streams DnsQuery{QueryType,DNSServer,DNSServerType,Answers(),Outbound()} (SPEC 018); GetRules(); URLTestOutbound(tag,link,timeout). This is the v0.2 stats/observability surface (replaces xray StatsService/ObservatoryService).
In-process embedding (box.go)
box.New(box.Options{Context, Options}) (*Box, error); PreStart()/Start()/Close() — NO Reload. Apply = Close old + New/Start fresh. ctx = include.Context(service.ContextWith(bg, deprecated.NewStderrManager(log.StdLogger()))). Options must be pointers.
Porting risks (v0.1 features with no clean sing-box equivalent)
- Live reload — box has no Reload; every apply is Close+New (drops connections). Gate on a config hash so unchanged applies don't churn.
- Chains — replace inverted socks scaffold with native
DialerOptions.Detour(rewrite). - Balancer strategies — map onto native urltest(least_test)+lx round_robin; no direct failover/random;
fallbackTagfail-closed → trailing route rule. - AmneziaWG — only under
with_awg; the shater build enables it (router tag set), else awg fields hard-error. - nft/routing/DNS-hijack plane — engine-independent; port verbatim (external nft, not engine config). tproxy loop-guard maps to
ListenOptions.RoutingMark. - Reality outbound — single ShortID; v1 spiderX/multi-shortId don't map.
- share-link parsers output — change from xray
map[string]anyto typedoption.*OutboundOptions(new mapping layer; parsing logic reusable). - DNS block-via-localhost hacks — replace with native typed DNS + per-server detour + reject DNS rule action.
- compat.go — obsolete (build-tag feature detection).
- api 10853 StatsService/ObservatoryService — replace with lx libbox command server.