Files
shater/docs-shater/PORTING.md
T
omarandClaude Opus 5 a0de597d69
test / go + panel tests (push) Successful in 4m56s
feat(dns): intercept by default, and bootstrap node addresses off the tunnel
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>
2026-07-26 04:54:15 +03:00

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.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]

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, 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 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, 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 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

  • 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)
    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)
    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, 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 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 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 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.
  • 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 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.