The posture was inverted. A client using the DHCP-supplied resolver — the router itself — was NOT intercepted: dnsmasq answered and forwarded to the ISP in the clear, so the filter, the blocklists, the per-device rules and BlockDoH were all inert for exactly the clients that did nothing wrong. A client that hardcoded 8.8.8.8 to route around us WAS intercepted, by the catch-all. Meanwhile the docs promised no DNS leaks. The default now matches the promise. Turning it on crosses a threshold that was already dangerous for anyone with two resolvers. Above one transport, a node's domain server address stops being resolved by the transport directly and goes through the client DNS plane instead — so a blocklist entry, a block_doh NXDOMAIN or any dns_rule can answer your own node's hostname, and one sloppy line in an ad list stops being an ad that got through and becomes a tunnel that never comes up. So the fix is gated on having two or more transports, not on the intercept toggle: resolver_default plus resolver_fallback always reached that threshold, long before this change. When no endpoint_resolver is configured the plane now carries a bootstrap server — the default resolver cloned with its detour dropped, keeping its type, so a DoH default stays DoH and only the tunnel hop goes. An explicit endpoint_resolver still wins. This is not a restore of the previous behaviour and the comment says so: at one transport the dialer used the default resolver WITH its detour, so a lone DoH-through-the-tunnel resolver was already a bootstrap loop. It is strictly better than what came before. Existing installs keep whatever they set — the config file is a conffile and is never replaced — and an explicit dns_intercept '0' survives the render-parse round trip, which a default-true bool otherwise makes easy to lose. The no-resolver warning stays, and no default resolver is shipped to silence it: a placeholder would remove the sentence without moving a single query, and the panel would then say a resolver was configured while nothing was filtered. Its wording is corrected instead — .lan keeps working through the built-in local transport, which the old text denied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
32 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— 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)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)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, 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.