`config rule` carried THREE ways to say where traffic is going: `dst_domain`
(an inline domain list), `dst_ip` (an inline CIDR list) and `dst_ruleset` (a
reference to a `config ruleset`). Three mechanisms meant three sets of
semantics to keep straight, and the inline pair was the worse half of the
trade: re-parsed per rule instead of compiled once into a .srs, unshareable
between rules, and — invisibly — already disagreeing with the rule-set
vocabulary about what a bare entry means.
`dst_domain` and `dst_ip` are removed (schema v2). `dst_ruleset` is the only
destination matcher. `Src` (the client side), `dst_port` and `proto` are
untouched: they are not lists of destinations and have no rule-set form.
THE BARE-ENTRY TRAP, and why the migration is not a copy
A bare `example.com` was an EXACT host in a routing rule (classified with
bareIsSuffix=false) and is the host AND its subdomains inside a rule-set
(bareIsSuffix=true). Copying entries across verbatim would silently widen
every such rule to every subdomain, so migrate1to2 rewrites a bare entry as
`full:example.com`. Everything else already means the same on both sides and
is copied byte-for-byte: `full:`, `suffix:`, `keyword:`, `regexp:` and a
leading dot (a synonym of `suffix:`).
`geosite:`/`geoip:` entries are copied UNCHANGED rather than promoted to a
`source=geosite` rule-set. They have been inert since the engine dropped the
route-rule geosite/geoip fields, and an unrecognised marker is equally inert
inside a rule-set — so their meaning is preserved exactly, and a dead matcher
does not start routing traffic because someone upgraded. The text is kept so
the operator can see it and convert it deliberately.
`regexp:` had no rule-set form at all, which would have made the move lossy,
so inline rule-sets learn it: peelDomainRegexes validates each pattern with
regexp.Compile before it reaches DomainRegex, because
route/rule.NewDomainRegexItem errors on an uncompilable one and that aborts
box.New for the whole config. A bare `regexp:` is dropped too — it compiles
fine and matches every host.
THE MIGRATION (schema v1 -> v2, run by `shaterd migrate` on service start and
at package install)
Per rule still carrying a legacy list: create an inline `config ruleset`
named `rule-<rule name>` (domains) and/or `rule-<rule name>-ip` (addresses),
move the entries across with the conversion above, append the new name to
`dst_ruleset`, delete the old option LAST. It is idempotent; it resumes an
interrupted run by reusing a rule-set the rule already references; and it
never overwrites a hand-written list that owns the generated name (it takes
`rule-<name>-2`). The uci sequence — `uci add` capturing the section id, then
set/add_list/delete — was verified against BananaWRT 25.12.1 in a throwaway
package.
Verified against the live router's config (4 rules, 26 entries, all
`suffix:`): every entry lands in its rule-set, every rule gains exactly one
reference, the `default` rule stays condition-less so B1's RuleReachability
still reads it as the catch-all.
ONE DELIBERATE SEMANTIC CHANGE, stated out loud: a rule that used BOTH lists
matched them with AND (an engine route rule ANDs its matcher fields), which
is almost never what "these sites and these networks" meant. The two
generated rule-sets are ORed, because `rule_set: [a, b]` matches when either
matches. Only configs that used both fields at once are affected.
Also fixed here, because schema v2 routes EVERY destination list through
inlineRulesetRule and the gap widens accordingly: a marker-only entry (".",
"full:", "keyword:") was dropped by the shared classifier SILENTLY on that
path, where the routing rule used to warn. An empty domain token aborts
box.New and an empty keyword is strings.Contains(host, "") — every host — so
the drop is right and the silence was not.
untunnelable stays honest: buildUntunnelablePlan already resolves `rule_set`
addresses through the running engine (inline sets are LocalRuleSets and
implement ExtractIPSet), and apply runs eng.Apply before building the plan.
A migrated `dst_ip` therefore resolves exactly as before; with the engine
down the walk truncates and denies, which is the conservative direction and
the state in which the netplane is fail-closed anyway.
Tests: migration coverage (real-router fixture, mixed prefixes, CIDRs,
idempotence, interrupted-run resume, name collision, geo markers stay inert,
absent config), and every matcher-classification test that used to live on
`dst_domain`/`dst_ip` moved to the inline rule-set rather than deleted —
including the new `regexp:` path and the inverted bare-entry convention. The
model tests grow a real in-memory uci emulator so a second migration run
actually sees its own writes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29 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]
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, seed disabled presets, 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,CurrentSchemaVersion=1,uciRunner(UCI schema migration; refuses newer). - 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
config globals: enabled, loglevel, kill_switch, dns_mode, ipv6, fwmark_base, table_base, confirm_timeout, resolver_default, resolver_fallback, probe_url, probe_interval, schema_version, active_profile.config inbound: name, enabled, type, network, tproxy_port(12345), listen, port, auth, user, pass, target_addr, target_port, target_network, tcp, udp, sniff.config subscription: name, enabled, url, update_interval, fetch_via(direct|proxy), ua, hwid, device_os, ver_os, device_model, list header, format, list include/exclude/filter_proto/filter_country, dedup, expire_alert_days.config node: name, enabled, uri, mux, mux_concurrency, xudp_concurrency, xudp_udp443, sockopt_mark, tcp_fast_open, tcp_keepalive_idle.config group: name, source, subscription, list node, strategy, include/exclude/filter_proto/filter_country, dedup, probe_url, probe_interval.config chain: name, list hop.config egress: name, type, interface, target.config ruleset: name, type(domain|ipcidr), source(inline|file|url|geosite|geoip), url, path, format, update_interval, list category, list entry.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 preset: name, enabled, order, target.config profile: name, enabled, priority, list match_iface, probe_url, probe_mode, sched_*, list enable_rule/disable_rule, default_target, default_egress.config resolver: name, type, address, detour, pool.config dns_rule: order, list match_domain/match_src, resolver.
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: +shaterd +kmod-nft-tproxy +kmod-nft-socket +ip-full (engine does DNS in-process, so dnsmasq-full may be droppable — confirm the :53 listener is our engine). /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. - uci-defaults/30_shater-core: seed
rt_tables(8192 shater), enable both inits, seed preset packs (disabled), run migrate, apply sysctl. - 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.