Files
shater/docs-shater/PORTING.md
T
omarandClaude Opus 5 a8ef887c56 feat(routing)!: a rule's destination is a rule-set, and nothing else
`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>
2026-07-25 13:57:16 +03:00

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