Files
shater/docs-shater/PORTING.md
T
omarandClaude Opus 5 4869d62e02
test / go + panel tests (push) Successful in 1m39s
release / test gate (push) Successful in 1m39s
release / apk aarch64_cortex-a53 (push) Failing after 2m54s
release / apk x86_64 (push) Failing after 2m54s
release / release apk (push) Failing after 1m35s
feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
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
2026-07-27 17:13:50 +03:00

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.1 branch (xrayctl/, shater-core/) and the sing-box option surface on main. Decisions: DECISIONS.md D11/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/ holds alert apply buildtags cmd devices engine generate logsink model netplane panel parse registry stats subscribe — with rulesets, schedules, profiles, backup and migration living inside model/ and generate/ rather than as packages of their own, and with no preset/: v0.2 has no preset subsystem at all (see the config preset note 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); if Globals.Enabled → apply.Apply (generate → engine.Apply swap → netplane render+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.1 allowColdStart=false).
  • shaterd apply|confirm|rollback: commit-confirm flow (arm a detached watcher that rolls back after Globals.ConfirmTimeout unless confirmed). apply.Rollback = engine.Rollback + netplane restore 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/apply ties it together: Snapshot, Apply(m), Confirm, Rollback, Reconcile, Teardown, all under a cross-process flock (port flock_unix.go; lock e.g. /var/lock/shater.lock). It imports model, parse, generate, engine, netplane. ACTIVE_FLAG=/var/run/shater.active gates 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 of apply.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 (xray map[string]any → typed option.*Options).
  • schedule.go, ruleset.go, preset.go, profile*.go — mostly engine-agnostic.
  • shater-core/ procd init, uci-defaults, hotplug, sysctl — port; swap deps (drop xray-core/xrayctl, add shaterd; keep kmod-nft-tproxy/socket, ip-full).

Rewrite (engine-specific):

  • generate.go → new generator emitting option.Options.
  • dns.go → typed option.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-outbound DialerOptions.Detour chaining.
  • Groups/balancer: drop xray balancer+observatory; use native urltest (least_test) + lx round_robin balancer. 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. Helpers parseBool/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; consts nftTable="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 branch CurrentSchemaVersion = 1 with a single step {0 -> 1}. That 1 is v0.1's number and nothing else's. v0.2's shater/model/migrate.go is at CurrentSchemaVersion = 2 with {0 -> 1, 1 -> 2} — migrate1to2 is the one that removed dst_domain/dst_ip from config 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 on apiInboundPort=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): vless settings.vnext[0].users[0]{id,encryption:none,flow?}; vmess users[0]{id,alterId,security}; trojan servers[0]{address,port,password}; ss servers[0]{address,port,method,password}; wireguard settings{secretKey,address[],peers[0]{publicKey,endpoint,allowedIPs,preSharedKey?,keepAlive?},mtu?,reserved?} + AWG jc/jmin/jmax/s1/s2/h1-h4 only if wgAWGSupported (v1 default false). applyNodeOpts adds 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/shater points its reader here by name, so it is kept in step with shater/model/uci.go (read) and render.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 (TestUnknownSectionAndOptionIgnored pins 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 (the model.DefaultGlobals seed). Booleans are always written back as '1'/'0' by render.go, so an explicit value never decays into the seed:

    option default meaning
    enabled 0 as shipped master switch; 0 ⇒ Reconcile tears the stack down instead of applying
    loglevel (alias log_level) warning engine + daemon level; none/off/silent/disabled ⇒ log disabled, unknown ⇒ warn + a validation warning
    log_syslog / log_file / log_persist 1 / 1 / 0 operational log (shater/logsink): syslog, rotated file, and whether that file lives on flash instead of tmpfs
    log_max_kb 2048 size cap of the log file, clamped to 128…8192; 0 = "use the default", not "off"
    kill_switch closed closed = fail-closed (block on engine loss, incl. a holding plane when the engine never started); open = plain routing
    ipv6 1 0 drops LAN IPv6 in the forward chain instead of leaving it unproxied
    fwmark_base / table_base 0x2000 reserved fwmark / routing-table bases (must not collide with fw4 or other apps)
    confirm_timeout 0 seconds before an unconfirmed apply auto-rolls back; 0 = commit-confirm off
    resolver_default / resolver_fallback / endpoint_resolver unset config resolver names: the DNS catch-all, its failover chain, and the bootstrap-direct server that resolves proxy endpoint DOMAINS
    probe_url / probe_interval engine defaults the ONE instrument all health probing uses (D20 — there are no per-group overrides)
    panel_port 0 ⇒ 8088 admin-panel HTTP port
    dns_filter 0 master enable of the blocklist/allowlist filter (D15); needs at least one config resolver
    dns_intercept 1 force ALL LAN plaintext :53 into 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 down
    block_doh 0 NXDOMAIN the known public DoH hostnames + the Firefox canary and reject :443 to their IPs, so clients fall back to :53 (which the engine catches)
    group_health 1 OUR background group probing (the observatory). Does not touch sing-box's own urltest inside a group
    untunnelable block policy for what TPROXY cannot carry (ICMP/IGMP/ESP/AH/GRE/SCTP): block | icmp (echo out, rest dropped) | direct (all out, bypassing the tunnel)
    l3_tunnel 1 Opens the synthetic l3-in TUN so LAN ICMP is routed by the engine instead of dropped/forged; nft marks LAN icmp/ipv6-icmp with fwmark_base+0x80 and a scoped ip rule sends it to table table_base+8. ON by default since the flip (model/l3tunnel_default_test.go): with it off a LAN ping is decided by untunnelable alone, 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 explicit 0 closes 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 what untunnelable still decides). See D25 and ARCHITECTURE.md §3a
    untunnelable_egress unset opt-in, UCI-only. Names a config egress; everything the L3 block did not claim (ESP/AH, GRE, IGMP, SCTP, and ICMP when l3_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 ⇒ untunnelable above stays in sole charge (D26)
    geo_provider unset = auto sagernet | loyalsoldier | metacubex | custom; auto = country codes from SagerNet, everything else from Loyalsoldier
    geosite_url / geoip_url unset {category} templates, honoured only when geo_provider=custom
    geosite_index_url / geoip_index_url unset git-trees URLs used to SUGGEST categories in the panel; empty = no suggestions
    stats_backend memory off (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-era stats.db is detected by its file magic and replaced (shater/stats/boltring.go)
    stats_ring_size / stats_timeline_minutes / stats_max_domains 200 / 60 / 5000 live-log length, sparkline minutes, domain-map cap. 0 = UNLIMITED (grows with traffic), which is why these three are always emitted
    stats_disk_limit_mb 64 on-disk cap of stats.db; only meaningful for stats_backend=sqlite; 0 = unlimited
    stats_retention_disabled 0 master switch that turns OFF all trimming/pruning — every aggregate then grows unbounded
    schema_version 0 = pre-versioned UCI schema revision; shaterd migrate writes 2
    active_profile unset 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. No sniff. 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 SNIFFED dns protocol, 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 persisted Subscription-Userinfo state 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_detour is consulted only when fetch_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 a config egress (multi-WAN), so its exit connection leaves over the chosen device. No xudp_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 / stale are 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 own egress is more specific and wins). No probe_url/probe_interval: the per-group overrides were deleted; probing is configured once, in globals (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 is interface | direct; tunnel is an accepted ALIAS of interface and an empty value means direct — both folded to the canonical spelling once, at the config boundary (Model.NormalizeEgressTypes, called by ReadUCI), so the engine half and the data-plane half cannot disagree about a type name. An unrecognised type stays unrecognised (reported by ValidateEgresses, 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.RetiredEgressTypes is the closed table both ValidateEgresses and 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). interface names the device and is meaningful for the interface type only; dpi is the native DPI-bypass preset — off|fragment|record|spoof (D13). No port: 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). No target: v0.1's proxy/block egress kinds are gone — where traffic goes is a rule's target, what device it leaves by is an egress.

  • config ruleset: name, type(domain|ipcidr, default domain), source(inline|file|url|geosite|geoip, default inline), url, path, format, update_interval, list category, list entry. format names the ENGINE rule-set format and has exactly two real values, binary (a compiled .srs) and source (a sing-box rule-set .json); empty — and the v0.1 leftover plain, and auto — mean "infer from the file name", which is sing-box's own behaviour. Ignored for inline/geosite/geoip. list category is the canonical spelling (one chip per geosite category or geoip country code, each materialised as its own remote .srs); a legacy single option category is 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 carried dst_domain/dst_ip on the rule itself; schema v2 removed both — a destination is a config ruleset and nothing else. shaterd migrate folds each legacy list into a generated rule-<name> (and rule-<name>-ip) inline ruleset; see DECISIONS.md D21 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 is match_iface and 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-profile endpoint_resolver, which overrides globals.endpoint_resolver while the profile is active (generate/dns.go; the point is keying the bootstrap resolver to the WAN in use). No probe_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 a probe_url as having an unsatisfiable condition and skipped it — so adding a probe to a working profile silently switched that profile off. No sched_*, no default_target/default_egress either — 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 is doh (synonym https) | dot (synonym tls) | plain (synonyms udp and the empty default) | tcp | local | fakeip. Anything else is NOT built — the resolver simply does not exist, and generate says so by name. Each type consumes a different subset, and the rest is thrown away (generate.warnIgnoredResolverFields warns per field rather than dropping it silently): doh/dot/plain/tcp use address + detour, ignore pool; local uses detour only (it reads the router's /etc/resolv.conf); fakeip uses pool only (default 198.18.0.0/15) — it mints answers locally, so it has nothing to dial and ignores both address and detour. detour is 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 no name — 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. response has exactly two values: nxdomain (default — Rcode 3, empty answer) and zero (NOERROR + A 0.0.0.0, and AAAA :: when globals.ipv6). There is no third; neither is sing-box's action:reject, which answers REFUSED.

  • config allowlist: the same minus response (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 — where block/allow are DOMAINS, not targets: a device entry is per-device DNS filtering (block regardless of the global dns_filter switch, allow overriding every blocklist). Per-device ROUTING is not a device concern — it is an ordinary config rule whose src names the device.

  • config alert: name, enabled, type(telegram|webhook), token + chat_id (telegram), url (webhook), list event, via, fallback.

  • config preset is NOT a section type, and there is no preset subsystem in v0.2. ParseUCIExport's type switch has no preset branch, so such a section is parsed by nothing, reaches no part of the model, and setting enabled=1 on one changes the file and nothing about the router. It used to appear anyway: 30_shater-core seeded 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, because writeUCIWith replaces 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 any preset section 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 xray geosite:/geoip: matcher lists materialised into synthetic rules (xrayctl/preset.go on the v0.1 branch); under schema v2 a rule's destination IS a config 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 in shater/model first.

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 supervises shaterd. respawn 3600 5 0 (infinite). No procd_set_param file watch (would bounce tunnel on commit). Inert unless globals.enabled=1. ACTIVE_FLAG=/var/run/shater.active gates hotplug/cron. stop clears flag + tears down nft table + reserved routing tables. reload_service→start/stop. trigger procd_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/shater is START=99, so from netifd's ifup until the daemon's first apply the router forwarded LAN→WAN in the clear. The daemon persists its holding plane to /etc/shater/boot.nft on every apply; this loads it after fw4 (19) and netifd (20), nft -c-validated. Four state checks refuse to arm (no/empty/invalid file, missing shaterd, no S??shater rc-link, readable UCI saying enabled≠1) — asked ON THE WAY UP, deliberately not recorded on the way down. Hooks forward only, 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 leftover config preset section (a type nothing parses — see the schema note above; the seeding of three of them is gone), seed the shater_l3 fw4 zone + a <zone>→shater_l3 forwarding for every zone (named sections, list device 'shater-l3*') and migrate a legacy exact-name entry to the wildcard, run shaterd migrate (whose result is classified and reported, not discarded — see below), apply sysctl, then a DETACHED bring-up (enable+restart shater/shater-cron, enable shater-armor, conditional firewall reload) — detached because an inline init call inside an apk/opkg transaction deadlocks on procd's flock.
  • shaterd migrate reporting (both call sites: uci-defaults/30_shater-core and init.d/shater's start_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; TestMigrateClassifiersAgree fails if they ever diverge). downgrade is recognised by the substring newer than this build, which both model.migrateWith's refusal and model.ErrSchemaTooNew contain — a contract pinned by TestMigrateDowngradeSignatureIsAContract. An unrecognised failure lands on failed, 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, because globals.log_syslog=0 is 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-typed restart; the package manager's output inside apk add/opkg install), and /etc/shater/migrate-failed on flash, written on failure and REMOVED on the first success — its absence is the all-clear. syslog gets the same line too when log_syslog allows it. A migration that SUCCEEDED is routine and stays on the syslog channel only. 30_shater-core still 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 of shater/shater-cron plus 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/shater runs 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 rule action:"sniff".
  • mixed MixedInboundOptions, socks SocksInboundOptions (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)

  1. Live reload — box has no Reload; every apply is Close+New (drops connections). Gate on a config hash so unchanged applies don't churn.
  2. Chains — replace inverted socks scaffold with native DialerOptions.Detour (rewrite).
  3. Balancer strategies — map onto native urltest(least_test)+lx round_robin; no direct failover/random; fallbackTag fail-closed → trailing route rule.
  4. AmneziaWG — only under with_awg; the shater build enables it (router tag set), else awg fields hard-error.
  5. nft/routing/DNS-hijack plane — engine-independent; port verbatim (external nft, not engine config). tproxy loop-guard maps to ListenOptions.RoutingMark.
  6. Reality outbound — single ShortID; v1 spiderX/multi-shortId don't map.
  7. share-link parsers output — change from xray map[string]any to typed option.*OutboundOptions (new mapping layer; parsing logic reusable).
  8. DNS block-via-localhost hacks — replace with native typed DNS + per-server detour + reject DNS rule action.
  9. compat.go — obsolete (build-tag feature detection).
  10. api 10853 StatsService/ObservatoryService — replace with lx libbox command server.