Compare commits

...
3102 Commits
Author SHA1 Message Date
omarandClaude Opus 5 8c0ea55054 feat(dns,fetch): resolvers and list fetches follow the uplink; the node cache survives the reboot it exists for
test / go + panel tests (push) Successful in 1m42s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 2m55s
release / apk x86_64 (push) Successful in 2m56s
release / release apk (push) Successful in 9s
The carrier behind this router's SIM refuses TCP/443 to 9.9.9.9 and 1.1.1.1 while
carrying everything else — measured with a positive control (ya.ru:443 and
77.88.8.8:53 connect, every sim-bypass node connects, those two are refused). The
configured resolvers go out DIRECT, not through the tunnel, so on that uplink DNS
resolved nothing: the vless server names did not resolve, the hop in front of
awgout never came up, and the whole chain died with it. One pair of global scalars
cannot be right for two uplinks; the object that knows which uplink is live is the
profile.

  * config profile gains resolver_default, resolver_fallback and fetch_detour
    beside endpoint_resolver. Empty = inherit, PER FIELD.
  * globals.fetch_detour replaces `const filterFetchDetour = tagDirect`. Behind a
    carrier whitelist `direct` is not the safe path, it is the path where the
    source is refused forever and the list never loads.
  * A subscription's fetch_via becomes an OVERRIDE, which gives it a third state.
    ReadUCI used to parse an absent option as the literal "direct", so "chose
    clear-text" and "never touched this row" were the same value. migrate2to3
    performs the reinterpretation ONCE, in the open. Schema 2 -> 3.
  * An unusable override falls back (resolvers to globals, fetch_detour to direct)
    and says so at critical, naming profile, field, value and what is in force.

The panel was displaying globals while the engine used the profile's value; the
owner caught it. The field now keeps the STORED value with a separate line naming
what is in force, and the rule that answers "what is in force" moved to the daemon
(GET /api/config/effective) so it stops existing in two languages.

Cold start, by owner's requirement: rule-sets are read from the cache when the
source is unreachable instead of being dropped, and the subscription cache reader
is fixed. Its first fix was wrong and only Linux said so — mtime ties to the digit
because the kernel caches the stamp per tick, and this board has no RTC, so the
ordering can invert across a reboot. Replaced by a generation counter in the file.

Woke and closed a LAN-dark defect: wgdedup read only the deprecated, always-empty
DownloadDetour, never HTTPClient.Detour, so fetch_detour=node:<awg> made a node
used, the dedup pass did not know, merged it away, and left the rule-set pointing
at a tag box.Start could not resolve. Reproduced through a real box.New.

Also: ValidateProfiles had no caller; "applied from the cache" graded critical
though the list is in force; the auth matrix never walked /api/log or
/api/rules/reachability; the CLI and daemon disagreed about where a subscription
is fetched.

NOT fixed, stated rather than implied: the R5 preflight still probes direct, so a
list never yet fetched cannot bootstrap over the detour alone; the router's own
DNS on the SIM stays dead (dnscrypt-proxy bootstraps via blocked addresses).

Gate: bash scripts/run-tests.sh green, 7/7, privileged tests really ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:40:58 +03:00
omarandClaude Opus 5 625942834b fix(gate): [5/7] hid the runner's exit code exactly when it explained everything
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 5m25s
release / apk x86_64 (push) Successful in 3m16s
release / release apk (push) Successful in 8s
The line naming a nonzero `go test` status was printed only when every
privileged test had produced a verdict — on the reasoning that a named FAILED
already explains the status. The case that actually happens is the opposite
one: the run dies at package level, so it names no test, so the loop above
prints MISSING for all of them, and the one line pointing at the real cause was
the one suppressed. A reader then goes hunting for three vanished tests instead
of at the build error above.

To be exact about what was and was not broken, because the framing matters: the
exit status was never SWALLOWED. priv_bad is set by the MISSING branch, so
FAILED is set and the gate fails either way — this was a diagnosis bug, not a
correctness one. What changes is whether the log says why.

Verified on the branch a green run never reaches, by driving the edited block
with all four (priv_rc, priv_bad) combinations: the new message appears only for
(1,1), the old one only for (1,0), and priv_bad/FAILED come out 1 in both. The
full gate is green with the change in, which covers the (0,0) path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-28 08:43:08 +03:00
omarandClaude Opus 5 4bf4ad8aa2 test(engine): the observatory tests were racing their own engine's loop
CI's `[4/7] go test -race -shuffle=on` failed the v0.2.23 gate on
TestRefreshObservatoryForcesOnePass ("force flag survived the forced pass").
The product is NOT at fault, and this was established rather than assumed.

WHAT ACTUALLY BROKE. ConfigureObservatory starts the ticker goroutine and its
first tick fires immediately — by contract, so an applied config gets its first
verdicts in seconds — and a plan change additionally nudges the loop into a
pass on purpose. That tick advances the cursor and consumes the force flag.
Two tests then read exactly those fields straight after a Configure, i.e. read
values another goroutine is entitled to rewrite in the same instant. Four
assertions, all racy:

  observatory_test.go:78   identical-plan reconfigure reset the cursor to 2
  observatory_test.go:86   changed-plan reconfigure kept the cursor at 2
  observatory_test.go:176  after refresh: cursor=2 force=false
  observatory_test.go:186  force flag survived the forced pass

The last one is the busy guard: with the loop's first tick still in flight the
test's hand-driven observatoryTickOnce is a silent no-op, so nothing clears the
flag it just raised.

NOT a cross-test dependency, and not a leaked goroutine — the direction was
measured, not guessed. Each test reproduces ALONE in the CI container at
`-count=3000`: 16/3000 and 7/3000, with all four messages. The earlier
`-count=80` in isolation was simply too few iterations; a loaded `-shuffle=on`
package run widens the window, which is why CI saw it and a laptop did not.

THE FIX is isolation, not a weakened assertion. detachObservatoryLoop stops the
goroutine and leaves a PLACEHOLDER stop channel behind, so the reconfigures
these tests make still run the whole state machine — plan rebuild, cursor
policy, nudge — with no second writer (ConfigureObservatory starts a loop only
when e.obs.stop is nil; e.obs.nudge is left nil and every send to it has a
default). quiesceObservatoryLoop, which four chain tests already used for the
same reason, is now that plus a cursor rewind.

Mutation-checked: with detachObservatoryLoop neutered the flake returns at
18/3000 and 6/3000 with the same four messages; restored, 20 consecutive
`-race -count=1 -shuffle=on` runs of the package are clean, as is the full
`scripts/run-tests.sh`.

TWO TESTS GAINED THE ABILITY TO FAIL. TestObservatoryTickStoppedEngine and
TestObservatoryTicksDuringManualRun assert `cursor != 0` after a hand-driven
tick — which the loop's own first pass had already satisfied for them, so they
held whether or not the tick under test did anything. The second one is the
worse case: it exists to forbid the tick deferring to a manual run, and the
busy guard could make the tick do nothing while its assertion still passed.
Both now quiesce first.

TWO NEW TESTS, for the contract the flake kept stumbling into without ever
asserting it — a refresh raised while a tick is in flight:

  - TestRefreshDuringInFlightTickRunsAFullForcedPass parks the loop's first
    pass inside a stub probe, so "in flight" is a fact rather than a hope,
    raises force there, and requires a second full pass over jobs the polite
    freshness gate would skip. TWO independent wakeups carry the request across
    — the buffered nudge and the tick's deferred re-nudge — and that is
    measured: disabling EITHER leaves the test green, disabling BOTH makes it
    fail with "force is still raised" and 2 attempts instead of 4. So it
    asserts the observable contract, not a mechanism, and says so.
  - TestForcedPassChainsItsBatchesWithoutWaitingForTheTick pins what
    observatoryTickOnce's defer claims and nothing held: a forced pass chains
    its batches instead of spending a 10s tick each. THREE batches, because two
    prove nothing — the loop's unconditional first tick pays for one and the
    refresh's still-unconsumed nudge pays for the second, so a two-batch plan
    finishes even with the chaining removed. Measured that way round first;
    at three, removing the defer leaves 48 of 54 targets undialled.

obsSelectorFixture/obsWideSelectorFixture exist because obsFixture's urltest
members are SelfChecked and the observatory does not dial them at all — a stub
waiting on that plan would hang, not fail.

No product file is touched: shater/engine/observatory.go is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-28 08:42:50 +03:00
omarandClaude Opus 5 be1cdbfc63 feat(netplane): an explicitly named private subnet is routed, not silently swallowed
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Failing after 1m38s
release / apk aarch64_cortex-a53 (push) Has been skipped
release / apk x86_64 (push) Has been skipped
release / release apk (push) Has been skipped
Measured on the production router: a `config ruleset` of type=ipcidr holding
10.10.10.0/24, a rule pointing it at node:awghome, config_applied=true,
tunnel_rules=1, engine_running=true, ZERO warnings — and from a LAN client,
100% packet loss and no TCP. The rule was accepted, applied, reported healthy,
and could not fire.

The cause is one line of ordering. `ip daddr { 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ... } accept` sits ABOVE every
divert line in the prerouting chain, so the packet is accepted and handed to
plain routing before the engine — which holds the rule — ever sees it. That
default is right and stays: LAN-to-LAN, the router's own services and every
local plane must not be dragged through a tunnel, and a catch-all rule must
never quietly acquire them. What was wrong is that naming a subnet OUTRIGHT
could not override it, and that nothing said so.

So the divert for NAMED private destinations is emitted one line higher, and
"named" is deliberately narrow (netplane/coverage.go, privateRoutedPlan):

  - the CIDR must be an ENTRY of an INLINE type=ipcidr rule-set — the only
    destination list this stage can read;
  - it must be CONTAINED in 10/8, 172.16/12 or 192.168/16. A prefix that merely
    overlaps one (0.0.0.0/0, 10.0.0.0/7) is a catch-all that happens to include
    private space, and does not acquire it;
  - the referencing rule must be enabled and target node:/group:/chain:/egress:
    or block. `direct` is not an override: it asks for what the bypass already
    does, and diverting into the engine to reach the same verdict would be
    strictly worse, because the engine's direct outbound follows the DEFAULT
    route and LAN-to-LAN could be pushed out the WAN;
  - it must not overlap a network this router itself carries;
  - 127/8, 169.254/16, 224/4 and 255.255.255.255 are never taken.

THE SELF-AMPUTATION GUARD DISTINGUISHES A LAN FROM AN UPLINK, and that
distinction is the difference between a safety device and an obstacle. A
collision with one of our OWN networks (any zone that is not a WAN zone, plus
any interface whose zone is unknown) is refused by name — diverting it takes
the LAN away from the LAN and the operator finds out over the console. A
collision with an UPLINK subnet routes and discloses: ISPs hand out RFC1918
WANs routinely — this router's own gateway is 10.0.0.1 — and on a /8 uplink
every private subnet on earth "collides", so refusing there would disable the
feature on precisely the routers that want it, for a reason that would read as
a bug. Nothing of ours lives on the uplink subnet: `fib daddr type local`
already accepts the router's own addresses above these lines, and every divert
line is scoped to LAN ingress, so router-originated traffic never meets them.

PING IS HOW ANYONE CHECKS A ROUTE, and a TPROXY divert carries TCP and UDP
only — the kernel needs a socket and ICMP has not got one. Stopping there would
rebuild this same defect one protocol down: TCP succeeds, ping reports 100%
loss, and the operator concludes the route is broken. So with l3_tunnel on, the
L3 mark is stamped on ICMP bound for these destinations (again above the
bypass, which is the only reason it was not already happening) and the existing
`ip rule` delivers it into the engine's TUN, where the SAME route rules pick
the outbound and a WireGuard/AmneziaWG one carries it. The forward chain's
fail-closed drop excludes that mark, because unlike the tproxy legs the LAN-to-TUN
leg really does traverse forward and the `oifname "shater-l3*"` accept that
would rescue it sits four steps lower. With l3_tunnel OFF nothing is emitted,
nothing is claimed, and the rule is told so by name.

THE DOUBT ALWAYS FALLS BACK TO THE BYPASS. Failing to route a named subnet
costs a feature and shows up the moment it is tested; routing one we should not
have touched can take the router's own management network into a tunnel that
may not even be up. So an unreadable list, an inventory we could not enumerate,
and an address family we cannot check the router's own addresses in (IPv6 —
`ubus call network.interface dump` reports IPv4 only) all resolve to "leave it
on the bypass", and every one of them says so. Seven distinct sentences now
exist where there was silence: refused-for-our-own-network, refused-for-no-
inventory, reserved space, catch-all-does-not-acquire, IPv6-not-checkable,
uplink-overlap-disclosed, and ping-does-not-reach-with-l3_tunnel-off. The
eighth is the blind spot itself: an address list this plan never reads
(url/file type=ipcidr, or geoip whose category is not an ISO country code)
might contain private destinations, and that is disclosed unconditionally —
"warn on suspicion" is not available, because suspicion would mean reading the
list. It is graded `warning` rather than critical through a named marker in
apply/warnings.go: it describes a maybe, and a red that means "probably fine"
is how the next red stops being read.

generate.ruleSetTypeIsIPCIDR now delegates to netplane.IsIPCIDRRulesetType.
Two packages asking the same question of the same field must not each carry
their own list of spellings.

VERIFIED
  - `bash scripts/run-tests.sh` green in full ("OK: the shipped tag set, on
    linux, passes every test we own", exit 0), with the three privileged
    ^TestIntegration tests RAN by name.
  - Every new test mutation-checked: 17 reverts, each failing the test that
    covers it, by name.
  - BOTH CONTROLS. Without an explicit naming, private space is still bypassed
    (TestPrivateDestinationBypassIsStillTheDefault) and a catch-all still does
    not take it; with it, the divert appears above the bypass. A test green in
    both states would prove nothing.
  - BYTE-FOR-BYTE. Two goldens, plain and L3, captured from a git worktree at
    the PARENT commit — not from this code, which would only prove
    self-consistency. A config that names no private subnet renders the
    identical text, so the applier's idempotence check still sees no work.
  - REAL NFTABLES. The rendered plane (both the tproxy and the ICMP/L3 shapes)
    loads with `nft -f` on nftables 1.0.9 and the kernel holds the lines as
    written; the instrument was shown able to REJECT a deliberately broken copy
    of the same file.

NOT VERIFIED
  - Nothing here has been run on the testbed or the router. Whether the packet
    that now reaches the engine actually comes out of awghome is the owner's
    acceptance test, not this commit's claim.
  - Whether a named IPv6 ULA could be handled safely was not investigated
    beyond establishing that the inventory cannot check it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 22:44:43 +03:00
omarandClaude Opus 5 bbb493ea91 fix(ci): the package-count assertion lives in two scripts and only one was updated
test / go + panel tests (push) Successful in 1m40s
release / test gate (push) Successful in 1m40s
release / apk aarch64_cortex-a53 (push) Successful in 8m55s
release / apk x86_64 (push) Successful in 2m51s
release / release apk (push) Successful in 8s
D29 removed byedpi, so the feed carries three packages. sdk-build-apk.sh was
changed to >=3; build-feed-apk.sh still demanded >=4 and killed both arch lanes
of v0.2.22 with `expected >=4 .apk … found 3`. Nothing was published from that
run. The comment now says the count is duplicated, because reading one script
was what made this look done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:31:35 +03:00
omarandClaude Opus 5 4869d62e02 feat(egress)!: remove byedpi — what it replaced was not weak, it was broken (D29)
test / go + panel tests (push) Successful in 1m39s
release / test gate (push) Successful in 1m39s
release / apk aarch64_cortex-a53 (push) Failing after 2m54s
release / apk x86_64 (push) Failing after 2m54s
release / release apk (push) Failing after 1m35s
The `byedpi` egress kind, the `openwrt/byedpi` package (`ciadpi`), the readiness
endpoint and the panel plate are gone. D13 is not deleted from DECISIONS.md; it
is REVERSED there, with the reason, because the reason is the whole point.

D13 adopted an external desync process on an observation: the engine's own
`tls_fragment`/`tls_record_fragment` were tried against a live ISP and did not
get through, so the method was judged too weak for anything past "just fragment
the ClientHello". The method was never tried. `common/tlsfragment` dropped a
number of labels equal to the number of DOTS in the name, and a name always has
one more label than it has dots — so the cut always landed inside the FIRST
label. `www.youtube.com` was split inside `www` and `youtube` went to the wire
in one piece, which is the word the DPI matches on. Of six blocked names exactly
one got through: `youtube.com`, the one whose first label IS the blocked word.
That defect is fixed (815011dfb, efb2177f4). With it fixed the built-in presets
do the job the external process was brought in to do, and the process is 100 KB
of binary, a second procd service, a second UCI file, a port that agreed with
our egress by hand-written comment only, a readiness prober, a five-state
service model and a panel plate — all to work around fifteen lines of ours.

So this is not "ByeDPI turned out to be bad". It is a good tool that turned out
not to be needed, and the reason we thought it was needed was ours.

A CONFIG THAT STILL SAYS `type 'byedpi'` IS THE PART THAT NEEDED WORK. Nothing
is migrated and nothing is rewritten: the kind stays unbuildable, therefore
fail-closed — no outbound, no mark, no `ip rule`, no routing table, so every
node, group and rule bound to it is blocked rather than released onto the plain
WAN. A migration to `direct` was considered and rejected: it is the only rewrite
that leaves the egress routing at all, and it would silently turn a blocked
egress into a live plain-WAN path with the router's real address — by an
upgrade, on a config nobody touched. `CurrentSchemaVersion` is therefore not
bumped either: no stored field changes meaning, and a bump would only make this
build's configs unreadable to an older daemon for no gain.

What changes is what the operator is TOLD. `model.RetiredEgressTypes` is a
closed, positive table read by BOTH `ValidateEgresses` and the generator (one
copy of the sentence, because two copies drift). It names the removal, denies
that it is a typo, says nothing is built and that the traffic is blocked rather
than leaked, names the replacement (`direct`/`interface` with `dpi 'record'`),
refuses to promise which preset defeats a given ISP, and says `apk del byedpi`.
The generic "unknown type" is still there and still says something different, on
purpose: "we took this kind away" and "you mistyped something" send an operator
to different places, and a value that was correct on the day it was written must
not be reported as a spelling mistake. The type list stays closed and positive —
`interface`, `direct`, the alias `tunnel` — and `EgressTypeKnown` does NOT admit
the retired kind: being told it was removed and having it work anyway is worse
than either alone.

`Egress.Port` goes with the kind: no surviving egress dials anything, so the
option is no longer parsed and drains out of /etc/config/shater on the next
render, the same way the deleted per-group probe_url/probe_interval did.

Tests, verified by mutation, each failing by name:
  - drop the retired branch in `ValidateEgresses` -> the retired kind is
    reported as "is not one of interface/direct" and
    TestRetiredEgressTypeIsReportedByTheValidator fails on both spellings;
  - drop it in the generator -> "unknown type \"byedpi\"" and
    TestRetiredEgressTypeIsReportedByTheGenerator fails;
  - the FAIL-OPEN mutation, which is the one that matters: let `byedpi` fall
    into the `direct` arm and be a known type -> four tests fail, including the
    two that check no outbound is emitted. A removal that quietly starts routing
    the traffic it used to block, under a reassuring message, is the failure with
    the worst consequence;
  - the panel half: empty RETIRED_EGRESS_TYPES -> two egressEdit tests fail.
Controls beside the claims: `interface`, `direct`, the `tunnel` alias and the
empty synonym must still resolve, warn about nothing and emit an outbound
(TestSupportedEgressTypesAreUntouched), and never-supported values — `proxy`,
`block`, `wireguard`, `byedpi2`, `bye dpi`, `sorcery` — must NOT draw the
removal sentence, which names a replacement for something that never existed.

CI and docs: the feed loses its fourth package everywhere the four were named —
`apk upgrade shaterd shater-core luci-app-shater`, in CLAUDE.md, both READMEs,
INSTALL.md, the release body and `shaterd`'s own diag bundle. The version
exception (byedpi carried upstream's version, ours come from the git tag) is
gone with it, so ci/version.sh and ci/sdk-build-apk.sh no longer have an
exception to remember and the "expected >=4 of OUR .apk" collect check is now 3.
INSTALL.md §5.3 gains the half a feed cannot do: dropping the package from the
feed does not take it off a router it is already on, so `apk del byedpi` is
written down, with what it removes and why it is safe.

Panel: 368 tests -> 339. Deleted with the mechanism they covered:
byedpiReady.test.ts, byedpiAge.test.ts, byedpiRefusal.test.ts (34 tests);
egressEdit.test.ts gains 5 for the retired-type sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:13:50 +03:00
omarandClaude Opus 5 efb2177f43 fix(tlsfragment): one cut, in the label a blocklist keys on — and a budget for it
Follow-up to 815011dfb, which fixed WHICH label is cut but left "a cut in every
candidate label" as an unconditional rule. Measured on this tree, loopback peer,
product default fallbackDelay, one ClientHello per row:

    cuts   tls_fragment (*net.TCPConn)   tls_fragment (proxy conn)   tls_record_fragment
       1                        502 ms                      500 ms                 <1 ms
       2                       1.004 s                     1.001 s                 <1 ms
       4                       2.008 s                     2.002 s                 <1 ms
       8                       4.015 s                     4.003 s                  539 us
      21                      10.540 s                    10.509 s                  525 us

So a cut in the PACKET modes costs half a second of connection setup, and it
costs that on BOTH branches — not only on the sleep path. writeAndWaitAck sleeps
the whole fallbackDelay whenever the ACK returns inside 20 ms (its "under
transparent proxy" case), and N.UnwrapReader reaches the *net.TCPConn only when
nothing in the chain transforms the stream, which a proxy protocol conn always
does. A proxied egress — every subscription node — therefore takes the flat
500 ms branch regardless of RTT. The number of labels is chosen by whoever picked
the hostname, and a 253-byte SNI is 85 of them: ~42 s of one connection's setup,
bought from the LAN.

In tls_record_fragment nothing waits: the ClientHello leaves in ONE write, split
into more records. 21 cuts cost 525 us and 105 bytes of record headers, and
1.1.1.1 completed the handshake with the ClientHello in 22 records in the same
77 ms it took with 2. That is the mode the field measurement was taken in, and
the mode where cutting every label was always affordable.

Hence two budgets rather than one rule: 1 cut for the packet modes, 4 for
record-only — the latter not a cost limit but a shape limit, since real names
carry one to three labels outside the public suffix and a hostile one must not
turn a ClientHello into 85 records no ordinary client emits.

One cut is enough because of WHERE it goes. Candidates are now ordered, most
worth cutting first, and first is the REGISTRABLE label — the one immediately
left of the public suffix. That is what a name-based blocklist keys on
("youtube" of youtube.com, www.youtube.com and studio.youtube.com alike,
"ytimg" of i9.ytimg.com, "example" of a.b.example.co.uk), and severing it also
breaks any match on the whole FQDN, so one cut covers both matchers. It is
chosen by STRUCTURE, from the public suffix list — not by length, which is the
same trap from the other side: in cdn-static-assets.youtube.com the longest
label is not the blocked one. The rest follow longest-first, on the argument
that among labels with no structural ranking a long one is likelier to be a
distinctive token than "www", "m" or "tv"; they are reached only when the budget
allows more, or when the registrable label is too short to cut.

The offset now comes from the label's MIDDLE THIRD. Every interior offset severs
the label, but one byte in leaves "outube" of "youtube" and a matcher keyed on a
substring still reads it. The draw stays random inside that third: a fixed point
would be a constant a middlebox vendor can special-case in one line, and this
whole family of tricks lives on making reassembly the only counter.

Also in this commit, and the reason it is not merely a tuning change: the panic
that shipped in v0.2.21 now has an instrument of its own.
TestWriteDoesNotPanicOnAServerNameChosenFromTheLAN drives real ClientHellos
carrying ".youtube.com", "youtube.com." (a legitimate FQDN with the root dot,
which curl and every browser will send), "..", an IP literal and non-ASCII bytes
through all three modes, and FuzzCutOffsets does the open half — 25.7 million
executions found nothing, and the fuzzer is shown able to find a planted defect
its seed corpus cannot reach, in one second. A hand-built ClientHello reaches
the shapes crypto/tls refuses to emit: a zero-length name, a 253-byte name, and
a server_name_list with a SECOND entry, which is why planning runs on
MyServerName.Length rather than on everything left in the extension.

Nine mutations, each failing by name with the numbers: the old dot arithmetic,
the old rand.Intn offset, the exact original expression (panic: invalid argument
to Intn, conn.go:208 <- Write conn.go:67), the empty-plan guard, the budget, the
priority order, the sort back into wire order, the first-entry truncation, the
middle third, and a one-byte corruption of a segment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 17:02:06 +03:00
omarandClaude Opus 5 815011dfb0 fix(tlsfragment): the SNI was cut in exactly one label — always the first one
`splits[:len(splits)-strings.Count(serverName.ServerName, ".")]` is identically
`splits[:1]`: labels are always one more than dots, so the subtraction cancels
for EVERY name in existence. One label was ever cut, and it was the leftmost
one. On the provider measured from this router — which blocks by the name in
the handshake, proved by the same address answering for SNI www.google.com and
going silent for www.youtube.com — that is the whole observed table:

    youtube.com     cut inside "youtube"  -> 301
    m.youtube.com   cut inside "m"        -> blocked
    tv.youtube.com  cut inside "tv"       -> blocked
    www.youtube.com cut inside "www"      -> blocked
    music/studio.*  cut inside the label in front -> blocked

The one name that worked is the one whose first label IS the blocked word. The
count subtracted must be the labels of the PUBLIC SUFFIX, not the dots of the
whole name: "com" is one, "co.uk" and "com.br" and "pp.ru" are two.

Second half of the same defect, and the reason the table above shows a cut
"inside m" at all: the offset was `rand.Intn(len(label))`, whose 0 is the
label's own boundary — the label goes out whole in the next segment, which is
not a cut, it is a segment boundary that happens to touch a label. For a
one-byte label 0 is the ONLY value it can take. Offsets are now drawn from
[1, len-1], so a cut always leaves a non-empty piece of the label on both
sides, and a label too short to have an interior offset carries no cut instead
of a fake one. That also closes the 1-in-7 hole in the case that WAS working:
youtube.com drew offset 0 once every seven connections and handed the name over
intact.

Two panics went with it, both reachable from the LAN, because route/conn.go
wraps the outbound with this and the ClientHello it fragments is the client's:
an empty label (SNI ".youtube.com" or the perfectly ordinary FQDN
"youtube.com.", where the suffix list declines to answer and the trailing empty
label survives) reached rand.Intn(0) — "panic: invalid argument to Intn", the
daemon and with it the router's proxying. And a plan with no cuts at all would
have indexed b[:splitIndexes[0]] on an empty slice; Write now writes the
ClientHello unchanged in that case, which is the only honest thing to do for a
name of one byte.

The classification is closed and errs toward MORE cutting: narrowing the label
set needs proof (a public suffix that really is a tail of the name), widening
needs none, so a trailing dot, an unmanaged TLD, a name that IS a public suffix
("com", "co.uk", "localhost") and an IP literal all keep every label rather
than fall silently into "cut nothing". When no label is long enough to cut, the
name itself is cut once — a matcher looking for the whole FQDN still fails
across that split.

Dropped with it: `splits[0] == "..."`, unreachable since strings.Split on "."
cannot produce a token containing a dot. And the plan now runs over the FIRST
entry of the server_name_list (MyServerName.Length) instead of everything left
in the extension, so a second entry cannot be fed to the public suffix list as
if it were part of the name.

Tests (cutplan_test.go, package-internal so the plan itself is visible) are
verified by mutation five ways: the old dot arithmetic, the old rand.Intn
offset, the removed empty-label guard, the removed empty-plan guard, and a
one-byte corruption of a segment. Each fails by name and with the numbers. The
controls: youtube.com — the case that already worked — must still be severed;
the reassembled segments must be byte-identical to the ClientHello in all three
modes (tls_fragment, tls_record_fragment, both), with the record framing
re-parsed rather than assumed; and Write must report len(b).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 16:33:04 +03:00
omarandClaude Opus 5 0a34e64c2c fix(panel): byedpi off the status poll, and disabled stops speaking for two situations
test / go + panel tests (push) Successful in 1m39s
release / test gate (push) Successful in 1m36s
release / apk aarch64_cortex-a53 (push) Successful in 6m0s
release / apk x86_64 (push) Successful in 2m52s
release / release apk (push) Successful in 8s
GET /api/status no longer carries the readiness report — the daemon dropped it
with the cache behind it, after one probe was measured at 6.4 s on 16 enabled
instances behind a black hole while the panel polled that endpoint every 5 s
from every open tab and read the field NOWHERE. The Status type, the mock
fixture and every comment describing a cache, a background refresh or a 20 s
staleness rule now say what the daemon does: one endpoint, and it connects when
a human asks.

`disabled` covers two situations with opposite next actions: no instance is
enabled — how the package ships — and an instance that IS written and looks
enabled while /etc/init.d/byedpi refuses it (`port 'auto'`, `port '99999'`,
`enabled ' 1'`, `enabled 'TRUE'` — all four measured on the 25.12.1 testbed
against validate_data). The editor's fixed sentence said "that is how the
package ships" about a section the operator had typed themselves. The daemon
keeps its `problems` list off the wire, so `detail` is the ONLY carrier: the
refusal now shows that sentence verbatim plus a tail that says only what is
true of both — the consequence, never the fix.

byedpiRefusal moves to byedpiReady.ts beside the gate it explains, and its
table now EXCLUDES `disabled` from the type, so re-adding a fixed sentence for
it does not compile. `?mock&byedpi=rejected` reaches the second case in a
browser; `?mock&byedpi=noanswer` reaches "nothing has been measured", which is
now only a failed fetch — the fabricated cold-cache body is gone.

Also: two comments about `config_applied` that the daemon's pointer+omitempty
change made false — the removed "positively phrased so a naive client falls the
alarming way" rationale, and "absent means a daemon too old", which now also
means the offline `shaterd status` stub.

Tests (byedpiRefusal.test.ts, +10) verified by mutation both ways: a fixed
"that is how it ships" and a fixed "your typo" each fail, and the control
asserts the factory state still reads as the factory state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:54:21 +03:00
omarandClaude Opus 5 1708159ecf fix(apply,shaterd): the offline stub alarmed about an apply nobody attempted
`config_applied: false` means "/etc/config/shater was read and REFUSED — what is
running is the PREVIOUS configuration, your edit is not in effect", and the panel
draws a critical band saying exactly that. The field was a plain bool, so that
alarm was the ZERO VALUE OF THE TYPE — and `shaterd status`'s offline stub, built
by a process that never applied anything, over a data plane that may have been
installed and enforcing for weeks, published it by simply never mentioning the
field. It is the config_readable defect returning in a new field, with the one
difference that decides the fix: config_readable can be MEASURED by the stub and
now is, while this one cannot be measured at all without a daemon.

So the field says nothing when nobody measured it. ConfigApplied becomes a *bool
with omitempty; the live Applier.Status() assigns a verdict on BOTH arms, so an
absent key can only come from something that is not a live status. That is the
same closed-set-plus-unknown shape `plane`, `traffic` and `daemon_answered`
already have, and the one panel/src/appliedConfig.ts already implements
(=== true / === false / else unknown). The Go doc claiming absence should read as
false is gone: it contradicted the only consumer, and the consumer was right.

The four fields around it (apply_error, apply_error_stage, apply_attempts,
apply_failed_since_unix) stay plain: they are qualified by config_applied the way
enabled/kill_switch/panel_port are qualified by config_readable, and their zero
values point at "nothing was refused" — the quiet side, not the alarm.

Also: the stub shipped `warnings: null` on its happy path while apply.Status
documents Warnings as always non-nil so a consumer can map over it
unconditionally.

Three states, distinguishable ON THE WIRE through one `shaterd status`, with the
control that would catch the opposite break (a build that omitted the key for a
real refusal, deleting the alarm from the product).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:43:49 +03:00
omarandClaude Opus 5 9d7f0dc92f fix(panel): the byedpi report had a five-second timer and no reader, and its parser could forge "listening"
Two defects found by looking at both sides of the byedpi readiness check at once.

1. GET /api/status carried the whole readiness report from a cache that a poll
   refreshed in the background once the copy passed byedpiRefreshAfter = 3 s.
   The panel shell polls that endpoint every 5 s, so EVERY poll started a
   refresh: a PATH lookup, a read of /etc/config/byedpi, and one connect per
   enabled instance, forever, per open tab, hidden ones included. The design
   note rejected a background ticker because "a closed panel costs nothing" —
   true, and silent about the open one it had become.

   Measured, one enabled instance, twelve polls five seconds apart:
     before  12 connects, 13 ciadpi PATH lookups per minute per tab
     after    0 connects, 12 PATH lookups (one per poll, for byedpi_installed)

   And nothing read it: `grep -rn '\.byedpi\b' panel/src` finds no consumer —
   the readiness plate, the per-egress cross-check and the egress-type gate all
   come from GET /api/byedpi. So the field is gone from the status response, and
   with its only cached reader gone the cache went too, together with the
   background goroutine, the staleness rules, the negative-age contract and
   Server.Close's duty to wait for a probe. GET /api/byedpi still connects, on
   the goroutine of the request that asked.

2. readByeDPIInstances claimed to mirror /etc/init.d/byedpi "exactly" and did
   not. The init script validates each section with
   'enabled:bool:0' 'port:port:1080' and refuses to start one whose validation
   failed. Go read the port with strconv.Atoi and, on failure, KEPT the 1080
   default — so `option port 'auto'` on an enabled instance became "an enabled
   instance on 1080", and anything else accepting there produced state
   "listening": the one state that unlocks the byedpi egress type, handed out
   for a proxy that does not exist. `port '99999'` produced the second half:
   "unknown" with a sentence asserting a connection attempt that never happened.

   The same shape lived in `enabled`: strings.ToLower+TrimSpace read ' 1' and
   'TRUE' as on, while the router starts neither (measured — the first is
   refused by validation, the second normalises to an empty value so
   `[ "$enabled" -eq 1 ]` never fires).

   The parse is now a closed positive list, and its expectations were MEASURED
   on the 25.12.1 testbed against /sbin/validate_data with the init script's own
   spec rather than inferred from libvalidate's source:

     enabled: absent/"" -> off; exactly 1|on|true|yes|enabled -> starts;
              exactly 0|off|false|no|disabled -> off; anything else -> does not
              start, and is REPORTED by section, option and value.
     port:    absent/"" -> 1080; plain decimal digits 1..65535 -> that port;
              anything else -> NO port is assumed, the section is not counted as
              a listener and nothing is dialled for it.

   Deliberately narrower than libvalidate's `port` (which also takes a sign,
   leading whitespace and, through an overflow, twenty digits): narrow declines
   to call a working instance a listener and prints why, wide hands out a green
   apply onto a port nothing is on.

Two further sentences that asserted actions that never happened, found while
fixing the above and not reported by the review: instances past
byedpiMaxInstances were never dialled yet fell into the "the connection attempt
neither succeeded nor was refused" clause, and that clause listed their ports
alongside genuinely inconclusive ones. "Not dialled" is now its own tally with
its own sentence, and each sentence names only the ports its own claim covers.

Every test here was checked by mutation, and each carries its control:
byedpi_initparity_test.go proves the instrument BOTH accepts a valid section
(state listening, against a real socket, in a world where every connect is
accepted) AND refuses every value the init script would not start, dialling
nothing for them; byedpi_pollcost_test.go measures the poll cost with a meter
shown counting a real probe in the same test, and keeps the probe-cost control
(16 black-holed ports = 6.4 s) that explains why it is off the poll path.

Gate: bash scripts/run-tests.sh green, including -race; ok shater/panel by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:32:26 +03:00
omarandClaude Opus 5 c407771cf2 fix(panel): four cards, one log row and a status the daemon knew and nobody saw
The board is not one row per name. carryForward supersedes by (name, KIND) and
appends carried rows LAST, so a chain `x` and a node `x` both live on it — and
`new Map(results.map(r => [r.group, r]))` kept the last. The chain card showed
the node's milliseconds, exit address and verdict as its own end-to-end
measurement, unmarked. Attribution is now by kind (targetResult.ts), with
kind:'' and a missing kind as ordered last resorts.

A connection routed to the engine's `block` outbound was drawn as plain mono
text, indistinguishable from `nl-reality-1` — on the page where a DNS row about
the same host gets a crit rail and a BLOCK mark. It is the kill-switch's own
Final and a legitimate rule target, so the connection log now carries the same
outcome axis the DNS log has: killed / carried / no exit recorded, a crit rail
and a mark that survives the width where the exit column is dropped.

Insights.tsx held a raw NUL at byte 36359 — a template separator written as the
byte instead of the escape. `file` called the source binary and ripgrep, git grep
and every tree-wide search skipped it in silence. It is the escape now, and the
whole of panel/src is free of control bytes.

Three contract texts had drifted from the daemon: the searched-field list did not
mention `error` (fixed on the Go side, and there were two copies), the connection
hint named neither `proto` nor the chain hops, and rowMatches folded case with
toLowerCase() — Unicode-aware, where the daemon folds ASCII only, so a needle
could find rows in the panel that the router would never return.

And the four status fields the daemon started publishing: config_applied,
apply_error, apply_error_stage, apply_attempts, apply_failed_since_unix. A
refused configuration retried on a widening interval while `engine_running` was
true, the hash was the OLD config's and every warning described the OLD config.
engine_running is TRUE there and is not contradicted — the band says WHICH
configuration is running, and the hash row, the traffic default and the findings
list each say they are about that older one. Absent is not false: a daemon
without the field is `unknown` and raises nothing, because there is no evidence
its hash is stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:28:40 +03:00
omarandClaude Opus 5 26e1d38924 test(bridge): make the fragment sweep test assert the property it names
TestBridgeFragmentSweepIsPerCall claimed its probe used "an EXISTING key, not a
new one: the sweep must still run". It did not: the stale datagram carried IPv4
id 61 and the probe id 62, and fragKey includes the identification, so the probe
opened a NEW key — the one arrangement in which the sweep runs even when it runs
only on new keys. Moving r.sweep(now) inside the `entry == nil` branch left the
test green.

The probe is now the SECOND fragment of a datagram whose first fragment is
already cached, with the two entries opened half a fragTimeout apart so the
stale one is past its deadline and the live one is not (deadlines are set at
creation and never refreshed). Two assertions before the probe pin the setup:
the stale entry must still be there, and the live key must already exist — if a
later edit breaks either, the test says so instead of quietly proving nothing.
The released bytes are checked too, which is the half of the timeout this test
is about (the correctness half is already caught by TestBridgeFragmentTimeout).

Same sweep of TestBridgeFragmentMalformed, which had the same shape of hole: a
FIRST fragment carries MF=1 and can never complete a datagram, so `got != nil`
is unreachable whether the packet was refused or accepted, and "truncated
header" asserted only that. Every subtest now asserts on the cache, and a case
for the classic overread — a header claiming TotalLength 276 in a 28-byte
buffer — is added; its control is the aligned subtest already at the bottom.

Mutations (linux, -race): sweep moved into the new-key branch fails
SweepIsPerCall by name; clamping TotalLength to the buffer instead of refusing
fails the new malformed subtest — and, as predicted, leaves its `got != nil`
assertion silent. Control: moving the sweep after the entry lookup while keeping
it unconditional keeps every test green, so the test discriminates "per call",
not "the line moved". No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:25:35 +03:00
omarandClaude Opus 5 42d84ac74c fix(model): a failed backup may stop a config write only when the filesystem is the reason
backupBeforeChange was added with "any failure aborts the write", justified by
"the uci commit that follows writes the same filesystem, so whatever stops one
stops the other". That holds for a full or read-only /overlay and for nothing
else — and the existence probe is a stat, which also returns ENOTDIR (something
dropped a file where /etc/shater should be), EACCES, ELOOP. In that state
PUT /api/config answered 500, `sub update` exited non-zero and the profile
watcher stopped saving, PERMANENTLY: none of those causes clears itself. A
convenience added this wave must not be able to take the product away.

Two changes, both about not inferring what can be measured:

- The probe is not evidence. stat(dest) answers "is this transition already
  captured?"; when it cannot answer, the copy is now ATTEMPTED and the attempt
  is the measurement. Only "the filesystem will not take bytes" short-circuits
  it.

- The failure is classified. filesystemRefusesWrites is a positive, CLOSED list
  — ENOSPC, EROFS, EDQUOT, EIO — each a condition under which the uci commit
  would fail too, so aborting only changes which error the operator reads and
  ours names the cause. Everything else is about the backup's PATH and falls to
  the recoverable side: the config is saved, and the missing undo is NAMED
  through reportBackupProblem (same shape as subCacheLogf; model cannot import
  logsink, which imports model) rather than skipped in silence.

TestWriteAbortsWhenTheBackupCannotBeWritten used a FILE where the backup
directory should be — that is ENOTDIR, the exact case that must no longer veto —
so it now injects ENOSPC at the copy, and the ENOTDIR case moved to
TestBackupPathFailureDoesNotVetoTheWrite. statBackup/writeBackupFile are seams
because the two deciding failures are the two a temp directory cannot produce.

Mutation-checked (linux, -race), each with the other half green: restoring "any
failure aborts" fails only the two carry-on tests; "nothing aborts" fails only
the two abort tests; restoring the old stat handling fails only the test that
pins "attempt the copy"; dropping ENOSPC from the list or adding ENOTDIR to it
fails the classifier test and the end-to-end tests that depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:25:20 +03:00
omarandClaude Opus 5 a05ad21b39 fix(apply,shaterd): four states the daemon was in and could not say
1. A SWITCHED-OFF SUBSCRIPTION CAN STILL GO OUT ON THE PLAIN WAN (blocker).
   Three places had to agree about `enabled=0` and did not: UpdateSubscription
   resolves by name and never reads it; cmdSubUpdate reads it only when no name
   was given; warnings.go skipped disabled subscriptions entirely on the stated
   premise that one "is never fetched". The premise was the false one, and the
   per-row Fetch-now button added this wave posts exactly the named request.

   Kept the behaviour, dropped the premise. Enabled means "include in the
   automatic refresh" everywhere else in the system — MergeSubCaches loads a
   disabled subscription's cached nodes unconditionally and they route traffic —
   and a refusal here is worked around by enable/fetch/disable, which enrols the
   sub in the 6-hourly sweep and is strictly worse. The automatic paths still
   honour it (the nameless sweep, and shater-cron's own `en = 1` check). The
   named path now says so on stderr and in the daemon log, and the leak finding
   fires for disabled subscriptions with the WHEN clause corrected — "every
   scheduled refresh" is false of a subscription no schedule touches.

2. refreshBootArmor DISARMED THE NEXT BOOT FROM A CONFIG THE DAEMON REFUSES.
   Every call site is gated on readErr == nil and nothing else; ParseUCIExport
   drops unknown options silently, so a config written by a newer build reads
   clean, and with the divert set emptied by the parse RenderHoldNft returns ""
   and the armor was REMOVED — with no log line at all, unlike the disarm one
   branch above it. Measured: with the new gate removed, the armor really is
   deleted. Now gated on the schema, and both removal paths are announced.

3. THE FIRST-BOOT DEADLOCK IS NAMED. Every subscription pulled through the
   tunnel, the tunnel built from nodes only a fetch supplies, the caches gone:
   the fetch waits for the tunnel and the tunnel waits for the fetch, forever,
   with the LAN dark. The CLI refusal goes to /dev/null (shater-cron) and the
   daemon line to a syslog `log_syslog='0'` switches off. It is now a critical
   finding in /api/status, which survives both, with the state named and two
   escapes — the free one first, the costly one priced.

4. A REJECTED CONFIGURATION WAS INVISIBLE, AND THE ENGINE CHURNED. Measured on
   the stand: with a config the engine cannot accept on disk, cron retries every
   60s and every attempt is a full engine swap, while status showed
   engine_running=true, the OLD hash, the OLD warnings, and `grep -ci` for the
   broken element returned 0. Invisible by construction: everything published
   about a config is published by a SUCCESSFUL apply, and engineDownCause is
   gated on the engine being down — here it is up.

   Status gains config_applied / apply_error / apply_error_stage /
   apply_attempts / apply_failed_since_unix, and a critical finding that says
   the running configuration is a DIFFERENT one and names the reason. Reconcile
   paces an identical retry (three free attempts, then doubling to a 15m cap);
   any change to the configuration cancels the wait, and POST /api/apply is
   deliberately not paced. The post-swap abort is deliberately NOT recorded —
   it is already loud and its retry costs no swap.

Also, from review-by-seams:

 - The netplane channel was graded critical wholesale over three distinguishable
   states. `udp '0'` + closed is the kill switch doing what it was told and may
   be exactly what was asked for; the leak and the total cut-off are not. The
   first is now `warning` (not `info`: attentionFindings drops info, and the
   blast radius is wider than the switch's name). Default stays critical, the
   exception is a closed list, and netplaneprotoseverity_test.go pins it against
   the REAL renderer so a rewording fails by name instead of drifting.

 - devicefilter_severity_test.go carried a FOURTH unlinked copy of
   DEVICE-FILTER-NOT-APPLIED and compared it with itself — the same shape as the
   noGatewayFinding fixture this wave removed. apply's copies are one constant
   now, and the real coupling is a test that runs generate and grades what comes
   back. Mutation: renaming the tag in generate fails it by name; the two old
   fixture tests survive that untouched, which is the whole point.

 - The history-write failure was logged ABOVE the deduplication gate its own
   call site documents eight lines below. At one cron reconcile a minute a
   standing cause (full /overlay, an entry over the 128 KiB ceiling) wrote 1440
   identical lines a day, and under log_persist=1 that many appends to flash —
   the exact wear the history ring's own dedup exists to prevent. Now gated on
   the message changing, cleared by a success. The Warning is still returned
   every time; only the log had a repetition problem.

Every fix mutation-checked with the failure text recorded, and every one has a
control showing the instrument can still give the opposite answer: an enabled
subscription still fetches and keeps the scheduled wording; a legitimate disarm
still happens and is still logged; a healthy box raises no rejected state; an
ordinary netplane finding is still critical; a DIFFERENT history failure still
prints. One mutation (the history-dedup latch) SURVIVED its first test — the
counter matched the success path's Info line too — and the test was fixed.

shater/apply is green. shater/cmd/shaterd was green when run 20 minutes ago and
now fails to BUILD on shater/panel/byedpi.go, a neighbour's in-flight refactor;
the full gate run for the same reason cannot be completed on this tree right now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 14:08:57 +03:00
omarandClaude Opus 5 43bb8913ea fix(diag): the bundle printed a DNS account id, and scrubbed the wrong file
Two holes, both in the direction the verb cannot afford: `shaterd diag` produces
the one text block a person SENDS somewhere.

1. resolver.address was on the allow-list, printed verbatim. For a DoH resolver
   that field is a URL, and generate/dns.go's parseDoHAddress keeps and USES
   u.Path — which is exactly where NextDNS, AdGuard and Control D carry the
   account identifier. Whoever holds it reads and rewrites this household's DNS,
   so it is a credential. The panel had always read it that way (DNS.tsx's
   resolverAddr shows u.host and flags the rest); the disagreement was resolved
   in favour of the side whose output goes to a stranger. Now: scheme and host
   survive, userinfo/path/query/fragment do not, and a BARE address
   ("1.1.1.1", "dns.adguard.com:853") is still printed in full because it is
   host and port and it is what the fault is read from.

   The fix could not be "delete the key from the list": TestDiagMasking-
   IsClosedOverTheWholeModel asserted the allow-listed fields come out
   UNMASKED, so it actively pinned the leak. The transform lives in a second
   closed table (diagMaskedForm), and the sweep now compares the masked render
   against the raw one line by line, expecting either the plain mask or exactly
   what that table declares.

2. The second layer collected its literals from `uci export shater` alone, and
   that file does not hold this router's credentials. model/render.go never
   writes a FromSub node; the several hundred subscription nodes live in
   /etc/shater/subs/*.json, which keep.d/shater-core describes in its own words
   as carrying "every node's credentials". The reachable path is not
   hypothetical: parse/sharelink.go quotes a rejected node's USERINFO into its
   error, generate/outbound.go warns it, apply/warnings.go logs it, and the last
   32 KiB of that log is section six of the bundle — with LogToFile on by
   default. diagSubCacheSecrets now reads those files by the same closed
   positive-list rule (unknown JSON key => collected, so a field added to
   model.Node tomorrow is covered), and a file it cannot read is NAMED in the
   bundle instead of silently reducing the scrub.

   Fixing the first half exposed the second: the log carried the userinfo, not
   the whole URI, so a literal scrub of the URI walked past it. diagSecretParts
   expands every refused value into its userinfo, username, password, query
   values (encoded and decoded) and path. Not the fragment — in a share link
   that is the node's display name, which is on the printable side.

The banner no longer says secrets are masked "throughout". It says what is
masked, and then names what is still in there: values under 8 characters (masked
in the config, not scrubbed elsewhere), list/ruleset URLs, and the limits of a
literal scrub.

Mutation-checked, each with the control that the instrument SEES the planted
secret in the unfixed output:
  resolver.address back on the allow-list      -> resolver test fails on the id
  diagMaskAddress made the identity function   -> transform test names the field
  sub-cache literals withheld from the scrub   -> log-scrub test fails
  diagSecretParts reduced to the whole value   -> log-scrub test fails
  sub-cache safe list turned into a blocklist  -> closure test fails
  unreadable cache file swallowed              -> honesty test fails
  nil collector seam read as "nothing to do"   -> honesty test fails
  transform applied per section, not per key   -> new-field test fails
  one section given an open default            -> sweep fails, by name
  an allow-listed value over-masked            -> sweep fails, by name
  masked lines dropped entirely                -> sweep's vacuity guard fires

scripts/run-tests.sh green (all 7 steps, -race included).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:49:08 +03:00
omarandClaude Opus 5 89571bcdb1 fix(netplane,generate): a network with option udp '0' had its UDP dropped in silence
Two defects of the same family: a state the plane produces and nobody names.

1. The divert is written per PROTOCOL, the fail-closed drop per INTERFACE.
   A tproxy inbound with `option udp '0'` puts its device in the drop scope
   (nftDivertRefs does not look at the flags, and must not: the drop is the
   backstop for ESP/GRE/SCTP too) while emitting no UDP TPROXY line for it.
   With kill_switch=closed every outbound UDP packet from that network is
   dropped; with kill_switch=open the same packets leave the WAN in the clear.
   TCP works, DNS works (dnsmasq answers it past the fib-local bypass), so it
   presents as "some sites do not load", not as a firewall. Verified by
   rendering: no second LAN is required, the shipped one-inbound shape does it.

   The drop is NOT narrowed to match the divert. Doing so would turn
   `option udp '0'` — which is how you kill QUIC so the engine can route by SNI
   — into "UDP now bypasses the proxy", i.e. it would convert a QUIC-blocking
   config into a QUIC-leaking one, and it would open a per-protocol hole in the
   kill switch through a knob whose name says nothing about leaking. The plane
   already takes the other decision one field over: with ipv6 off no v6 divert
   is emitted and closed mode drops v6 anyway, deliberately and in writing.
   So the state stays and is named instead, in three shapes (protocol dropped /
   protocol leaked / both flags off), each naming the network, the option, the
   kill-switch state and the concrete traffic that dies.

   coverage.go could not have caught this: it skips covered[i.Device], and the
   device IS covered. The new check is derived from the model alone and so runs
   outside that file's Interfaces() gate.

2. networkList's open `default:` sent (tcp=0, udp=0) to "" — which the engine
   reads as BOTH — so an inbound the plane feeds nothing acquired a listener for
   everything. The four cases are now named and closed, "neither" is a second
   return value rather than a synonym for "both", and a tproxy inbound that
   carries no protocol is refused with a warning that also names the netplane
   half: switching both flags off does not remove the network from the plane, it
   removes the way out of it, so closed mode cuts that network off completely.

generate_test.go: the three linux fixtures that built a tproxy inbound with
model.Inbound's zero-value flags now spell TCP/UDP out. UCI defaults both to
true; only a Go-built model gets false, and only that fixture relied on it.

Gate green (bash scripts/run-tests.sh, exit 0). Every new test mutation-checked
in both directions: suppressing the warning fails 6 tests by name, and making it
fire unconditionally fails the controls. Rendered ruleset text is byte-identical
for all nine shapes dumped before/after — the only diff is added warning lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:48:54 +03:00
omarandClaude Opus 5 6ced96fafa fix(panel): a switched-off subscription is not "never fetched", and an inline list is not "empty"
Two things the panel asserted that the code does not do.

1. THE OFF SWITCH IS NOT A GATE ON FETCHING. The row badge for a disabled
subscription with `fetch_via=proxy` and no detour was drawn quiet and said
"Nothing is disclosed yet — this subscription is switched off … so it is never
fetched". False in all three places that could have contradicted it:
Applier.UpdateSubscription resolves a subscription BY NAME and has never read
Enabled; `shaterd sub update` consults Enabled only when no name is given; and
this panel's own per-row "Fetch now" — new in this wave, previously buried in the
collapsed Options panel — is disabled on `busy || fetching` and nothing else. One
click sent the router's real address to the feed host under a badge saying
nothing was disclosed.

The two halves of the old condition are not alike, so they stopped being one
state. NO URL is real and refused at the bottom (subscribe/fetch.go rejects an
empty URL before it builds a request) — that branch keeps its quiet badge. OFF is
amber, and its sentence says what the switch actually does: it stops the
scheduled refresh, and the button on the row asks for a fetch whatever the switch
says.

The daemon reached the same conclusion from its side in this wave — the fetch is
deliberately allowed and logged, and its finding now varies on Enabled — so the
badge's own summary over that finding varies the same way. "On every scheduled
refresh" printed over a switched-off row is the same lie inverted: it sends the
reader hunting a cron job that is not running.

2. AN INLINE LIST HAS NO ENTRY COUNT, AND "NOT PUBLISHED" IS NOT "EMPTY".
engine.go fills RuleSetStat.RuleCount from (*rule.RemoteRuleSet).RuleCount(), and
LocalRuleSet.RuleCount does not exist in the tree at all, so an inline list always
arrives with rule_count 0. The chip called a working parental-control list
"empty — nothing matches". It reads "size unknown" now: unlit, never green and
never the amber that says something is wrong. A mixed group is counted as a floor
("1,284+") instead of presenting a partial sum as the whole.

BOTH INSTRUMENTS WERE HOLDING THE LIE UP. subFetch.test.ts pinned the sentence
verbatim, and deviceLists.test.ts fixed `{remote:false, rule_count:3}` — a record
no router can produce, so its green light was wired to nothing. The mock carried
the same impossible state on three local rule-sets and fabricated a count on
update. All of them now match what the daemon sends.

Verified in ?mock (new `?subleak=paused|pausedapplied|nourl`), 390 and 1280, both
themes. Each fix reverted in turn with the failure text; controls both ways — a
remote list that really is empty still says "empty", and the two leaking states
are still told apart from the one that is genuinely quiet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:35:53 +03:00
omarandClaude Opus 5 42536675e2 fix(panel): read the age, the scope and the hop — three fields the panel was ignoring
The daemon changed under the panel in three places, and in each one the panel
kept drawing a screen that was right only by accident.

byedpi readiness carries `age_seconds` now, because GET /api/status stopped
probing: sixteen enabled instances on ports that neither accept nor refuse cost
6.41 s per poll, measured, and the Apply page polls up to 27 times a minute. The
report is served from a cache and every sentence in it is present tense, so the
panel stamps it. Negative is not an age — zero is the common answer (a loopback
connect finishes in microseconds) — so "not a measurement" is its own reading,
and a daemon too old to send the field is a third one: the reading is real, its
age is not reported. The cold first poll after a start says "not measured yet"
rather than "not determined": nobody has looked is a normal state of a router
that booted ten seconds ago, and it calls for a different sentence than an
instrument that looked and failed. Both keep the unlit lamp and both keep the
egress type locked.

A test run no longer wipes the board, so a card can show a reading from twenty
minutes ago beside one from a second ago. Which is which comes from `scope`, not
from comparing timestamps — the router has no RTC and a computed "n minutes ago"
would be fiction. A carried row says "earlier run" and is drawn as a qualifier;
an empty scope is "cannot attribute", never "everything is carried", because a
real run always covers at least one target.

A chain blocked at a hop was kept red by matching a fragment of the daemon's
error sentence — the last place prose decided anything here. It arrives as
`blocked_by` now, so the match is gone and the row names the hop.

The mock carried the old contract: it emptied the board on every run while a
comment claimed the daemon did too. It carries forward now, by (name, kind),
capped at 64, and `?mock&board=carried` lands on a finished board holding both
kinds of row. `?mock&byedpi=cold` and `?mock&byedpiage=N` reach the two states
the freshness rendering exists for.

Verified in ?mock at 390 and 1280, both themes, no horizontal scroll. Each of the
three fixes was reverted in turn and the tests named the failure; the controls
run the other way too — a helper that marked every row carried, or reddened every
row, fails just as loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 13:35:25 +03:00
omarandClaude Opus 5 a4ea5dba44 fix(tests): the last two packages that wrote to the router's own /etc/shater
17843be5a measured six packages damaging the machine that runs the suite and
fixed four; model and generate were left because another agent held those trees.
Measured again on 2026-07-27 with the same instrument, scoped to the two
packages, and the diagnosis held EXACTLY:

    CREATED   /etc/shater/config.pre-unreadable.bak
    CREATED   /etc/shater/config.pre-v0.bak
    CREATED   /etc/shater/config.pre-v1.bak
    CREATED   /etc/shater/config.pre-v2.bak
    MODIFIED  /etc/shater/cache.db

model. Every test that reaches writeUCIWith or migrateWith goes through a
fakeUCI, and that seam is what makes the config they read and write a fake.
backupBeforeChange is the one part of the package that does NOT use it: it
os.ReadFile's liveConfigPath and writes into configBackupDir directly. On a dev
box neither exists and the function returns "nothing to copy"; on the testbed and
the router both exist, so the suite planted four bogus copies in the product's
state directory. Worse than litter: the function is create-ONCE per schema and
never overwrites, so a copy planted by a test SILENTLY PREVENTS the real
pre-migration copy that box was going to take.

generate. generate.go emits experimental.cache_file with Path: cacheFilePath(),
and every *_linux_test.go that hands a generated config to engine.Apply/box.New
opens that bbolt DB for writing. Per cache.go's own file comment that DB is a
SAFETY device, not an optimisation: with it, RemoteRuleSet.StartContext skips the
start-time fetch, so the daemon can come up before the WAN does. Rewriting it
from a test is rewriting the thing that keeps a reboot from taking the LAN down.

THE FIX is the one the other four packages already use, not a third one: a
TestMain per package pointing the product paths at a private os.MkdirTemp, plus a
test that still pins the SHIPPED value — because an isolation that leaves the
real decision untested has only moved the defect.

  model:    liveConfigPath/configBackupDir -> a private dir; liveConfigPath is
            pointed at a path that does NOT exist, which is exactly the dev-box
            case the function already documents, so every test that does not opt
            into backupSandbox behaves precisely as before.
            New TestConfigBackupPathsAreTheShippedOnes.
  generate: cacheDirPersistent/cacheFilePersistent/cacheFileFallback -> a private
            dir, and the persistent one is CREATED so the package keeps
            exercising the branch the ROUTER takes. The fallback had to move too:
            on a host without /etc/shater the decision lands on
            /tmp/shater-cache.db, which is just as hardcoded and just as much the
            product's. New TestCachePathsAreTheShippedOnes, which also pins that
            the DB lives inside the directory the free-space checks measure —
            path.Dir, not filepath.Dir, since the gate also runs on Windows.

No waiver was needed at shater/testguard: it follows
`cacheDirPersistent = filepath.Join(dir, ...)` back to os.MkdirTemp on its own.

Verified:
  - the sweep, scoped to the two packages: the five paths above BEFORE, "CLEAN"
    AFTER. Then the FULL scripts/check-test-fs-isolation.sh: 48 package
    verdicts, "CLEAN: the whole suite ran and not one path under /etc /var /usr
    /root /home /opt /srv /run /tmp changed."
  - positive control: a planted test in shater/model that restores the real
    paths and calls backupBeforeChange -> the sweep names
    "CREATED /etc/shater/config.pre-v9.bak", then bisects to "PACKAGE
    .../shater/model" and "TEST ....TestPlantedViolatorWritesTheRealBackup".
    shater/testguard stayed GREEN with the violator in the tree, which is the
    documented blind spot and the reason the dynamic half exists.
    NOTE, learned from the first attempt: a create-ONCE violator is named by the
    verdict but NOT by the bisect — seed_canaries only creates what is missing,
    so the file the whole-suite run left behind makes the per-package re-run a
    no-op ("no single package reproduced it"). The bisect can only name defects
    that repeat.
  - mutation, model: liveConfigPath -> /tmp/shater-live and configBackupDir ->
    /tmp each fail the new test by name; dropping the "keep the older copy"
    return fails TestBackupBeforeChangeKeepsTheFirstCopy ("the first copy was
    overwritten by a later write"); removing the ErrNotExist early return fails
    TestBackupBeforeChangeSkipsWhenThereIsNothingToCopy; removing the
    backupBeforeChange call from writeUCIWith fails
    TestWriteTakesTheBackupBeforeReplacingTheConfig ("the write took no backup").
  - mutation, generate: cacheDirPersistent -> /tmp/shater and cacheFilePersistent
    -> /etc/shater-cache/cache.db each fail the new test, the second one also on
    the dir/file mismatch; cacheFilePath forced to tmpfs fails
    TestCacheFallsBackWhenDirMissing's CONTROL, forced to persistent fails its
    first half; cache_file Enabled=false fails TestCacheFileEmittedAndEnabled.
    Green again after every revert.
  - counts, declared vs executed (go test -list vs top-level verdicts, shipped
    tags, linux): model 160/160, generate 395/395, 0 failures. generate's 3 skips
    are the pre-existing CAP_NET_ADMIN TestIntegrationL3* trio, which [5/7] runs
    and passes.
  - scripts/run-tests.sh: GREEN end to end, exit 0, including [4/7] under -race
    ("OK [race] in 43s") and [5/7] RAN all three privileged tests. The four
    TestByeDPI* races reported earlier no longer fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:56:30 +03:00
omarandClaude Opus 5 ea34e744bd fix(panel): the status poll stops dialling — a readiness cache that carries its age and has an owner
GET /api/status called byedpiProbe on every request. On a healthy loopback that
is nothing, but the probe's cost lives in exactly the state it was written to
report honestly: a port that neither accepts nor refuses burns the full
byedpiDialTimeout, and byedpiMaxInstances of them burn 6.4 s. Measured, on this
tree:

  1 enabled instance, live listener   0.45 ms
  1 enabled instance, refused         0.33 ms
  16 enabled, refused                 3.6  ms
  1 enabled, black-holed            400    ms
  16 enabled, black-holed             6.41 s

The panel shell polls /api/status every 5 s on every page and the Apply page
adds its own 4 s poll, so the pathological state hung the panel for seconds at a
time precisely while an operator was trying to find out what was wrong. A check
that gets slow exactly when it matters is worse than one that is always slow.

The poll now reads a cache (byedpiReadiness.cached), refreshed asynchronously off
the same path: 15 ns per call, primed, and 20 back-to-back polls against 16
black-holed ports cost less than one probe. A background ticker was rejected —
it would dial on a router whose panel nobody has open — and so was blocking the
first poll to fill a cold cache, since that is the same 6.4 s hang, just rarer.

The cache is not allowed to lie:

  - every served report carries age_seconds. Detail is written in the present
    tense, and a present-tense sentence about a measurement taken some seconds
    ago is a claim nobody checked;
  - a report older than byedpiCacheMaxAge is NOT SERVED. It is replaced by an
    explicit unknown with a negative age, so a panel that ignores the age fails
    to an unlit lamp rather than to a stale "listening" unlocking an egress type
    onto a port nothing is on;
  - GET /api/byedpi still really connects. A re-check button answered from a copy
    is a button that does nothing.

And it has an OWNER. The refresh runs a goroutine that dials; left as a package
variable it belonged to nobody, could not be awaited, and — as the race detector
showed — went on reading byedpiConfigPath / byedpiInstalled / byedpiDial after
whatever started it believed it was finished. The cache is now per-Server, with
stop() that forbids further refreshes and does not return while one is dialling,
called from Server.Close. The daemon already defers that Close, so the probe
cannot outlive the server.

byedpiDial became a seam alongside byedpiInstalled and byedpiConfigPath: the
timeout branch is the expensive one and the one a real loopback cannot be
provoked into, so without it neither the cost nor its removal could be shown.

Ten mutations, each killed by a named test: the probe back on the request path;
the cached copy claiming age 0; an over-age reading quoted anyway; a cold cache
returning a blank instead of an explicit unknown; a refresh that is not
single-flight; a late older probe overwriting a newer one; /api/byedpi answering
from the cache; the unmeasured report claiming the binary is absent; stop() not
waiting; Server.Close not stopping. The last one survived its first test — which
asserted a poll straight after Close did not dial, and passed with the stop
removed entirely because the reading was fresh and no poll was due — so the test
now advances the clock to make it due.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:55:34 +03:00
omarandClaude Opus 5 e5dfd74b61 fix(engine): testing one node stops wiping the group board; the blocked hop becomes a field
Two separate honesty defects in the shared test board, both surfaced while
closing the byedpi readiness fix.

1. startTestRun replaced the results slice outright, so pressing Test on one
   NODE blanked every group and chain card on the Targets screen, and testing a
   group blanked the nodes. Nothing on screen explained it, because nothing had
   happened to those targets — the daemon had thrown their readings away.

   Earlier results are now carried forward for every target the new run does not
   itself re-measure. The alternative, one board wiped per run, is simpler and
   has no staleness question at all; it was rejected because it destroys
   information the daemon still has. These are the OBSERVATORY's numbers, taken
   by a prober that never stopped, and a group's reading does not become false
   because somebody tested a node afterwards.

   The staleness question it does raise was already answered: every result
   carries tested_unix, the instant the OBSERVATION was taken, and GroupTestStatus
   publishes this run's scope — so a carried row is identifiable as carried
   without comparing timestamps, and drawn with its age. The board is capped at
   groupTestCarryMax, evicting the oldest first; that cap is the only way a row
   can leave without a newer one taking its place, and it is documented as such.
   done/total still describe this run's targets only.

2. A chain whose exit was never dialled, because an earlier hop was probed and
   did not answer, shipped that fact as prose only: source="" (correct — nothing
   measured the exit) plus a sentence naming the hop. A client reading source
   strictly filed it under "nobody looked", which is the wrong colour, so the
   panel kept the row loud by matching a fragment of our error message — the
   last place it read our prose to decide anything.

   GroupTestResult now carries blocked_by: the 1-based hop index, 0 everywhere
   else. It does NOT set source; nothing measured this target's own path, and
   stamping an instrument on a measurement that never happened is exactly the lie
   source was added to prevent. blocked_by>0 beside source="" is the complete
   statement. Field and sentence are produced together in chainBlockedResult so
   they cannot come to disagree.

Tests (grouptest_board_test.go), each verified by mutation:
  - carrying forward is asserted WITH its control, that a run does replace the
    rows it covers — "nothing disappeared" alone is also satisfied by a board
    that stopped updating;
  - target identity is (name, kind), so a group and a node of one name do not
    evict each other, with the empty-kind wildcard pinned both ways;
  - the cap drops the oldest end;
  - blocked_by carries the hop, keeps source empty, and every other result
    carries 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:55:11 +03:00
omarandClaude Opus 5 17843be5ad fix(tests): running the suite deleted the router's own state — four packages did it
shater/stats/store_test.go ended with

    _ = os.Remove(statsFilePath())

and statsFilePath() is not a test path. It is THE product path: /etc/shater/stats.db
on every host where that directory exists, which is the testbed and the router. So
`go test ./shater/...` deleted the accumulated query and connection log of whatever
machine ran it. The test passed. It had always passed — damage done by a test is a
side effect, not a wrong answer, and no instrument in this tree could see one.

A filesystem sweep (the new scripts/check-test-fs-isolation.sh: seed a router-shaped
canary tree in a container, run the whole gated suite, diff) found it was not alone.
Six packages, by measurement, not by reading:

  shater/stats    DELETED  /etc/shater/stats.db        (the line above; also
                           TestComboBackendSwitchSequence opened and pruned the
                           live DB, which the delete had been hiding)
  shater/logsink  DELETED  /etc/shater/shaterd.log and /var/log/shaterd.log —
                           New()/Reconfigure() purge BOTH product locations when
                           the file toggle is off, so Config.Path (which every test
                           here already set) never protected them. The daemon's own
                           log, the one an operator reads after an outage.
  shater/apply    DELETED  /var/run/shater.active — the ONE token hotplug and cron
                           check before touching the data plane. Clearing it on a
                           live router makes both stand down on a box that is up.
                           holdstate_test.go's `t.Cleanup(os.Remove(ActiveFlag))`
                           was not a cleanup; it was the delete.
  shater/panel    REWROTE  /etc/shater/stats.db — stats.NewStore("sqlite") from
                           TestStatsEndpointsAcrossBackends resolves the product
                           path too.
  shater/model    CREATED  /etc/shater/config.pre-v{0,1,2}.bak, config.pre-unreadable.bak
  shater/generate REWROTE  /etc/shater/cache.db

The last two are NOT fixed here — another agent is working in those trees. Both are
one TestMain away: model already has liveConfigPath/configBackupDir as vars, and
generate already has cacheFilePersistent; what leaks is product code (backupBeforeChange,
the engine's cache_file) called from tests that do not redirect them.

THE FIX is the seam generate/cache.go and generate/ruleset.go already use — the path
becomes a package-level var that only tests assign — plus, in each case, a test that
still pins the SHIPPED value, because an isolation that leaves the real decision
untested has only moved the defect:

  stats:   statsDirPersistent/statsFilePersistent/statsFileFallback + the exported
           SetPathsForTest (exported because shater/panel needs it from outside).
           New TestStatsFilePathPrefersPersistentDir covers both branches.
  logsink: PersistPath/TmpfsPath + a TestMain, since the hazard is in New(), which
           every test calls. New TestLogPathsAreTheShippedOnes.
  apply:   ActiveFlag + the existing TestMain. New TestActiveFlagIsTheShippedPath,
           which also records WHY /var/run: tmpfs, so a reboot clears it.

TestNewStoreSelection got stronger rather than weaker. Its "sqlite" case used to
accept "sqlite" OR "memory" because the real path might not open on this host — an
expected value that depended on the machine. At a private path there is no excuse:
a writable directory MUST report "sqlite", and a new control at an unopenable path
MUST report "memory" (the honest "persistence is not active" signal) without a crash.

TWO GUARDS, because one of them cannot see half of it:

  shater/testguard/fsisolation_test.go — parses every _test.go under shater/ and
  fails BY NAME when a filesystem-mutating call gets a path that is not PROVABLY
  temp-rooted. Positive and closed: what it cannot prove is a failure, not a
  default, which is the only rule that catches a path built by a function call.
  It follows local vars, closures, filepath.Join/Sprintf/+, helper parameters via
  their call sites, helper return values, and the save/override/restore idiom.
  Four waivers, each keyed on file+function+callee, each with the reason printed on
  every run, each a struct field traced by hand; a waiver that stops matching fails
  the test as STALE. Runs inside [2/7] and [4/7] — no new gate step, no new minute.
  Blind spot, stated: damage done by PRODUCT code a test merely calls (which is
  exactly logsink, model and generate above).

  scripts/check-test-fs-isolation.sh — the dynamic half, for that blind spot. It
  refuses to run outside a container unless told twice, because its method is to
  let the damage happen and then look, and it seeds/unseeds only what was missing.

Verified:
  - mutation, task 1: statsFilePath forced to the fallback -> the new path test
    fails ("with ... present = .../fallback-stats.db, want the persistent ...");
    newPersistent forced to memory -> "Backend = \"memory\", want \"sqlite\"";
    the fallback made to report "sqlite" -> "Backend = \"sqlite\", want \"memory\"".
    Green again after each revert.
  - mutation, the guard: the original os.Remove(statsFilePath()) put back -> named
    at store_test.go:154 with "the path comes out of statsFilePath(), which this
    check cannot follow"; a planted test writing "/etc/config/network" -> named as
    a literal path; the walk pointed at one package -> its own <150-file control
    fires ("reading a blank page"); a waiver matching nothing -> STALE WAIVER.
  - control, the sweep: with a planted violator it reports DELETED /etc/shater/stats.db
    and MODIFIED /etc/config/network; without it, those are gone and only the two
    foreign packages remain. Its bisect named shater/stats.TestComboBackendSwitchSequence
    on its own.
  - counts, declared vs executed (go test -list against top-level verdicts):
    stats 112/112, panel 121/121, apply 122/122, logsink 26/26, testguard 1/1,
    0 skips, 0 failures.
  - scripts/run-tests.sh: [1/7][2/7][3/7][5/7][6/7][7/7] green. [4/7] -race fails on
    four TestByeDPI* in shater/panel — a data race between byedpi.go's background
    probe and byedpi_test.go's forceByeDPIBinary cleanup, in another agent's
    uncommitted work (shater/panel/byedpi_cache_test.go is untracked). Proven not
    ours: a pristine HEAD tree carrying ONLY this commit's files passes -race over
    all 35 packages, and the same run with -skip ^TestByeDPI is green on the live
    tree too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:33:45 +03:00
omarandClaude Opus 5 dac2f85c84 fix(insights): a failed DNS lookup stops being the healthiest row in the log
The DNS log drew every row from `action`, which answers WHICH WAY the lookup
went — so a query that left through a detour and then timed out came back as an
accent-coloured `proxy` tag, blocked=false, nothing else said. The row that
describes the exact moment the tunnel broke was the most reassuring line on the
page. `actionTag()` also fell open (`return 'pass'`), so every value the panel
did not recognise — including every value a future daemon might add — rendered
green.

The daemon now carries the outcome as its own axis (LogEntry.Status/Error,
a794fbe37). This brings it to the screen.

Two axes, and the outcome leads. logRoute.dnsRowMark decides both in one place:

  status  → answered | failed | '' (not recorded), POSITIVE and CLOSED, with the
            fallback on the recoverable side. `blocked` refines a recorded answer
            into the fourth situation and is never allowed to invent one on a row
            whose outcome was never written.
  action  → block | proxy | pass | unknown, the same discipline. The path stays
            VISIBLE on a failed row and muted, because "it failed" and "it failed
            in the tunnel" are different reports and the second one closes tickets.

Four situations, four looks: a plain answer has no rail; a filter block keeps its
crit rail and BLOCK tag; a failure takes an amber rail, an amber wash, a filled
FAILED chip, and its cause verbatim beside the rcode reading (-1 renders "no
response", anything else the code the server really sent); a not-recorded outcome
is dashed and faint and claims nothing. A failure with no recorded cause says
"cause not recorded" rather than showing an empty cell that reads as fine.

`error` joins the searched fields (the daemon searches it — q=timeout works) and
the hint under the box now names it. `status` stays out: q=failed must not sweep
up every failure while somebody is looking for a domain by that name.

Verified in ?mock at 390 and 1280, both themes, no horizontal scroll: the four
states are pairwise distinct in computed border/background/colour, and the two
chips take their own line on a narrow screen so the domain keeps 92px instead of
being pinned at its 30px minimum. 19 new tests, each shown to fail under 16
mutations of the code it covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 12:25:09 +03:00
omarandClaude Opus 5 e065786a32 feat(panel): unlock byedpi on a listener, test one node, and stop two badges arguing
Three things the panel was saying that it had not established.

BYEDPI. The egress type unlocked on `status.byedpi_installed`, which is
LookPath("ciadpi") — "is the package installed", while the operator is asking
"will traffic sent here go anywhere". They come apart on the SHIPPED config: the
packaged /etc/config/byedpi is inert, so installing the package unlocked the
type, the egress went on 127.0.0.1:1080, the apply was green and nobody was
listening. The gate is now `byedpi.state === 'listening'` and nothing else
(byedpiReady.ts, the only place that decides it). GET /api/byedpi also carries
the per-egress port cross-check, so a mismatch is drawn on the row that has it,
naming both ports, in crit — the state where every other signal reads healthy.

`unknown` is neither answer. It keeps the type locked (a control that opens on
nothing established is the same defect wearing a new word) and it is never
painted as a refusal: dashed border, unlit lamp, "not determined", plus a
Re-check button so a dropped request is not a dead end.

ONE NODE. A freshly pasted node had no instrument — the group test reads the
observatory's board and the observatory only probes what the rules route
through, so the first question anyone asks answered "not routed by any enabled
rule". Every node row now has Test, over the same singleton run and the same
GET poll the Targets page uses.

The reading is classified on `source`, not on prose: measured-and-failed is red,
`source:""` is an unlit lamp and the faintest text on the row, because a
negative result that cannot be told from a check that never ran answers nothing.
One escalation survives, documented and narrow: a chain whose exit was never
reached because a hop it runs through WAS probed and failed. Targets keeps its
exact previous appearance while its instrument changes underneath.

The three refusals stay three facts — 404 the node is not in the saved config,
503 the config could not be read (an unknown, never a verdict about the node),
400 no name — with three tones and three sentences.

SUBSCRIPTION FETCH ROUTE. The panel's draft predicate drew amber "proxy · no
route" while the daemon now grades the same fact critical on the same row, so a
saved leaking subscription wore both, at two severities, about one thing.
subFetch.ts reconciles them: where the daemon has spoken it outranks the
prediction, in BOTH directions — including the dangerous one, where the
predicate is content ("via group:auto") and the daemon reports the leak anyway.
The prediction still speaks for a draft nothing has applied yet, and a
subscription that is switched off is not accused of a disclosure the daemon
deliberately does not report for it.

Fixtures reach every state: ?byedpi=<five states>|mismatch, ?nodetest=ok|dead|
unmeasured|400|404|503, ?subleak=draft|applied|divergent. The group-test fixture
also stopped being kinder than the daemon — engine.startTestRun replaces the
whole board, so refreshing one target really does blank the others.

42 tests, 8 mutations each killed by name, and both controls: the gate is shown
to open on `listening` and to stay shut on the other four, and "not checked" is
shown to be drawn differently from "did not answer" — the assertions fail if
either pair is ever drawn alike. Verified in the browser at 390 and 1280, both
themes, no horizontal scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:55:42 +03:00
omarandClaude Opus 5 46a2d4aaad test(model): pin the wire contract the panel's PUT actually sends
handleConfigPut decodes model.Model with DisallowUnknownFields, so this is the
layer the missing fields bit at: not "the attachment is ignored" but "the whole
save is rejected with json: unknown field \"Blocklists\"", losing every
unrelated edit batched into the same PUT. Mutating the JSON name reproduces
that message exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:40:32 +03:00
omarandClaude Opus 5 67290ec2b6 feat(devices): attach named block/allow lists to a device, without a dangling tag
The panel half already ships Device.Blocklists/Device.Allowlists and PUT
/api/config decodes with DisallowUnknownFields, so until now the first
attachment rejected the WHOLE save with `json: unknown field "Blocklists"`,
losing every other edit in it. This is the engine half.

A device now references `config blocklist` / `config allowlist` sections by
name (UCI: `list blocklist` / `list allowlist`, since `block`/`allow` already
mean the typed domains), which brings geosite categories and url-sourced lists
to parental control for free.

Two things here are constructions, not checks.

The tag a device's rule references comes from the accumulator that emitted the
rule-set, never from the list name. Rule-set tags resolve at engine START
(RuleSetItem.Start), so a name-derived tag passes box.New and fails box.Start —
and because both configs share one cache_file path, every apply on a live
engine takes the close-old-then-start-new branch, so the old box is already
gone when the new one refuses. That is no engine, a closed kill switch and a
dark LAN, from one mistyped list name. A reference that yields no tag emits no
rule at all; the emptiness is warned, tagged DEVICE-FILTER-NOT-APPLIED so the
panel grades it critical rather than guessing from prose.

Materialisation is a single memoised point shared by both consumers. Devices
are built before the network-wide filter, so materialising a shared list twice
would hand dedupeRuleSetTags an already-claimed tag — which it DROPS, silently
switching the network-wide filter off for that list. The mutation test for this
reproduces exactly that: DNS-FILTER-NOT-APPLIED, filtering nothing.

Order is the feature: typed allow, typed block, attached allow, attached block,
then the network filter. Otherwise a parent who types youtube.com into a
child's Block loses to whatever an attached geosite category permits, and the
panel draws a "Blocked" chip over a rule that does nothing. Typed and attached
matchers stay SEPARATE rules — rule_set AND-gates over the domain matchers, so
merging them would mean "the domain AND the list".

Attaching a list is itself the switch for that device: Enabled=0 means "does
not participate in the network-wide filter", not "dead", so the list still
loads and filters here — and generate says so instead of leaving it to be
discovered. A blocked name's reply comes from the LIST (Blocklist.Response),
so tier 4 is up to two rules; the typed tier keeps NXDOMAIN, having no owning
object to say otherwise.

Purely additive: an old config has neither list, parses to nil, and the
generated engine config is byte-for-byte what it was. No schema bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:37:33 +03:00
omarandClaude Opus 5 9339e8e70d fix(generate): the cache fallback test ran or skipped depending on its neighbours
TestCacheFallsBackWhenDirMissing guarded itself with

    if fi, err := os.Stat(cacheDirPersistent); err == nil && fi.IsDir() {
        t.Skipf("%s exists on this machine; ...")
    }

i.e. its subject was the machine it happened to run on. The gate caught it as an
UNDECLARED SKIP in one container run and not in the next, with no change to the
code — and BOTH outcomes were green. Only the undeclared-skip check saw it at
all; every other instrument here reports `ok shater/generate` either way.

An order-dependent test proves nothing on the runs where it does run either,
because nobody can tell afterwards which runs those were.

The three cache paths become vars (production never assigns them, same seam
generate/ruleset.go already uses for listsDirOverride) and the test points them
at a temp tree. It now covers BOTH branches with no skip: an absent dir must
choose tmpfs, and — the control — a present one must choose the persistent
path. Without that second half the test is satisfied by a cacheFilePath that
returns the fallback unconditionally, which is exactly the regression the
persistent branch exists to prevent (a cache that never survives a reboot, so a
reboot before the WAN is up fails to start the engine and takes the LAN with it).

Verified:
  - both halves killed by mutation (force persistent -> the first assertion
    fails; force fallback -> the control fails), green again after revert;
  - 5 x `go test -shuffle=on ./shater/generate/`: 474 verdicts and 3 skips
    every time, TestCacheFallsBackWhenDirMissing PASS on all five, never SKIP;
  - shater/generate: 385 declared func Test*, 385 top-level verdicts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:27:40 +03:00
omarandClaude Opus 5 a67f51c22c docs(panel): the q= field list did not mention error, which the filter searches
shater/stats/filter.go:156 searches ConnLogEntry.Error along with the six
fields the doc names, so `q=timeout` works and the contract said it did
not. Verified against the predicate, not against a report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:27:36 +03:00
omarandClaude Opus 5 56c9ea56e6 fix(gate): [4/7] reported a failure that did not exist — 664 s of pure sleep
The -race step failed with

    FAIL shater/netplane 600.019s
    panic: test timed out after 10m0s
      running tests: TestApplyIfaceSysctlsCoversRuleDivertedIface

over code that was neither hung nor wrong. Measured (golang:1.26, 32 cores):
shater/netplane is 1.971 s without -race and 663.762 s with it. A 337x factor
is not "-race is slower".

Nine of netplane's test files intercept nft/ip/ubus/uci/sysctl by re-exec'ing
the test binary as a no-op helper — the standard os/exec trick. Under -race
that child is ThreadSanitizer-instrumented, and TSan's atexit_sleep_ms DEFAULTS
TO 1000: every -race process sleeps a flat second before exiting, on no CPU.
~660 intercepted commands, one second each. The per-test times said so out
loud — 12.17 / 13.18 / 14.17 / 129.62 s — they were counting, not measuring.

Isolated, five runs each, of a `func main() {}` with nothing in it:

    built plain                       0.0014 s/run
    built with -race                  1.010  s/run
    built with -race, sleep disabled   0.008  s/run

So the children now run with GORACE=atexit_sleep_ms=0, set once in a package
TestMain rather than in each of the nine fakes (they all build the child env as
append(os.Environ(), ...), so one assignment covers the ones written later too).
TSan reads GORACE at process init, long before TestMain, so the detector of the
test process itself is untouched; only the children see it, and they do nothing
but write a canned string and exit. Proven, not assumed: a deliberate data race
in netplane is still reported under -race with this in place.

    shater/netplane   663.762 s -> 10.625 s   (203 === RUN and 128 top-level
                                               verdicts on both sides)
    shater/devices     28.412 s ->  0.358 s   (same disease, same cure)
    gate [4/7] end to end: was a 600 s timeout, now 56 s

WHAT THE GATE ITSELF WAS MISSING. A deadline and a failed assertion both exit
non-zero, and this script printed the same "FAILED [race]: go test exited 1"
for both — so the reader could not tell "the product is wrong" from "nobody
knows yet". [2/7]/[4/7] now name a timeout as a TIMED OUT, list the tests that
were still running, print only the goroutine dump instead of a quarter megabyte
of PASS lines, and spell out the two opposite fixes (a block, or slowness that
must be MEASURED first). Verified both ways: a sleeping test reads TIMED OUT, a
t.Fatal still reads FAILED.

The deadline stays at go test's own 10m, now written down with the measurement
beside it, and stays there as the hang detector — the slowest package under
-race is 18.9 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:27:23 +03:00
omarandClaude Opus 5 cae5655dbe fix(panel): the hazard band said DNS leaves and that nothing leaves
Measured on the stand, real LAN clients in netns behind veth, counters in a
separate nft table: in the state this band predicts, 0 packets left the WAN
across the whole run, against 27 in the control that differs only by one added
catch-all rule. Except for exactly 2 — both plaintext UDP/53. So the band's
detail ("nothing reaches the internet") was wrong by those two packets, and its
own DNS step, which calls that lookup the one thing that still leaves, was
right. One word: nothing ELSE reaches the internet.

The DNS step was also behind reality. It named only the lookups devices send to
the ROUTER, but the shipped dns_intercept='1' pulls a query aimed at a resolver
the device picked for itself into the engine too, answers it there, and it
leaves in the same clear UDP/53 — measured both ways, each producing its own
plaintext packet on the WAN. Encrypted DNS is not the way out either: :853 out
of the LAN measured connects=0, because the plan rejects it. The generator's own
critical warning (generate/dns.go) has said all of this for as long as it has
existed; only the panel had fallen behind it.

"takes ... and drops it" is untouched, and measured: the engine accepts on the
local tproxy socket in ~100 us even for an unreachable address and then closes,
so the client gets an immediate ECONNRESET rather than a hang. "Blocks" and
"ignores" would both be less accurate. Nothing is added about ping: the stand's
ICMP probe was 100% loss in BOTH states, so it proved nothing either way.

The test is the point. The two halves live fifteen lines apart and each reads
fine alone, so a wording fix does not survive the next editor. The new test
checks the INVARIANT instead: the band is flattened to clauses and no clause may
claim that nothing leaves while another names something that does. Its detector
is proved on a fabricated band first (a prior that cannot fire measures
nothing), and it asserts the no-resolver band really does contain a clause
admitting the leak, so the check cannot pass by finding neither half.

Mutations, all caught: detail back to "nothing reaches" -> the invariant fails
and prints both clauses verbatim; DNS step back to the router-only wording ->
the resolver test fails; DNS step stops admitting the leak -> two tests fail.
Control: with one resolver configured the DNS step is absent and no clause
claims anything leaves; emitting the step unconditionally fails that control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:23:52 +03:00
omarandClaude Opus 5 fbcf211d19 test(stats): pin that the log filter does NOT search status
matchLog's field list is documented as positive and closed, and the new
`status` is deliberately outside it for the same reason `outbound_kind` is: it
is a fixed vocabulary word, so q=failed would silently match every failed row
while the operator was looking for text. The test row now carries a Status, so
the assertion is not vacuous — a filter block IS an answer, which is also the
Status/Error invariant this row models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:18:10 +03:00
omarandClaude Opus 5 419eaf7bbe docs(porting): the schema number on line 146 was v0.1's, read as v0.2's
PART A is the frozen v0.1 survey, so `CurrentSchemaVersion=1` was archaeology
that happened to be correct about the branch it describes — and directly
contradicted the live schema subsection thirty lines below, which says
`shaterd migrate` writes 2. Anyone skimming the file map for "what is the schema
version" got 1. Say whose number it is, name v0.2's (2, steps {0->1, 1->2}), and
name what migrate1to2 did, since that is what the reader is usually after.

Also documents the `shaterd migrate` reporting contract in PART B: the closed
classification, the two non-syslog channels a failure reaches the operator on
with globals.log_syslog=0, and why 30_shater-core still exits 0 after one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:13:13 +03:00
omarandClaude Opus 5 a794fbe374 fix(stats): a failed DNS lookup no longer reaches the log as a healthy row
dnstrack.QueryEvent carries Failed and Error; stats.LogEntry carried neither.
A SERVFAIL, a timeout, a loopback or a rejected-cached lookup was therefore
written into the query log with action "pass" — or, when the resolver that
timed out had a detour, with the flow-coloured "proxy" — blocked=false, and
nothing anywhere saying no answer was produced. The daemon already knew, one
event at a time: TotalStats.Failed is counted from that very fact in the same
function. The row threw it away, so the aggregate said "N failed" while every
row said everything was fine.

LogEntry gains two fields:

  Status — closed vocabulary, "answered" | "failed" | "" (NOT RECORDED), same
    discipline as OutboundKind/RuleKind. It is a separate axis rather than a
    fourth Action value because Action says WHICH PATH the lookup took: a query
    that went out through a detour and then timed out is action=proxy AND
    status=failed, and folding the two would erase the one fact that says
    whether the tunnel is what broke. It is also what an old panel would have
    silently mapped back onto "pass" through its own open fallback.
  Error — the producer's own cause text, verbatim, meaningful only when
    Status=="failed". No grading is invented on top: three of the four causes
    are fixed literals ("loopback", "rejected (cached)", "rejected") and the
    fourth is the transport's err.Error(), which cannot be classified without
    guessing. "failed" with an empty Error is honest and reachable — the lookup
    failed and the cause was not recorded. What IS derivable stays derivable:
    Rcode separates "no response at all" (-1) from "the server refused".

queryStatus is a closed POSITIVE list over the sources a producer emits; an
unlisted or zero Source falls to "" (not recorded), never to "answered". The
aggregate is untouched: blocked/failed are computed once in handleEvent and the
row is labelled from those same two values, so the counter and the row can
never disagree and nothing is counted twice.

Cost: LogEntry 152 -> 184 B on 64-bit (+6.4 KB at the default 200-row ring).
Status is a package constant, so its body costs nothing; Error is interned in
its OWN table (maxErrKeys=128, clamped to 160 B) rather than the rule table,
because the transport's error text embeds the queried name and a flood of
distinct causes would otherwise keep clearing the routing-text table.

Tests: every assertion mutation-checked, and the control is three-state — the
same instrument separates answered from blocked from failed, with the
aggregate pinned to identical totals across the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:12:59 +03:00
omarandClaude Opus 5 735aa5428f fix(shater-core): name which of the four ways shaterd migrate ended
Both call sites swallowed the result. /etc/uci-defaults/30_shater-core ran
`shaterd migrate >/dev/null 2>&1` — stdout, stderr AND the exit status gone, so
a refusal was indistinguishable from a success on the one screen the operator
who caused it was reading. /etc/init.d/shater logged, but with a single sentence
that described only one of the outcomes: "routing rules that still carry the
removed dst_domain/dst_ip options stay DISABLED until this succeeds. Free space
on /overlay and re-run". On a DOWNGRADE every clause of that is false — nothing
is disabled, /overlay is not the problem, and re-running never helps, because
the fix is to put the newer package back. A confident wrong diagnosis costs more
than no diagnosis.

The outcome is now classified with a CLOSED positive list — ok / downgrade /
unreadable / failed — and the last rung is the point of it: an unrecognised
failure says it is unrecognised and quotes the binary verbatim instead of being
reported as one of the causes we can name. `downgrade` is recognised by the
substring "newer than this build", which both model.migrateWith's refusal and
model.ErrSchemaTooNew contain; that seam is a contract and is now pinned.

log_syslog=0 is honoured, not worked around. It is a statement about the syslog
stream, not a request to be left uninformed, so failures go to two channels that
are not syslog: the script's own stderr (the operator's terminal on a hand-typed
restart; the package manager's output inside `apk add`), and
/etc/shater/migrate-failed on flash — written on failure, REMOVED on the first
success, so its absence is the honest all-clear. syslog gets the same line when
log_syslog allows it. A migration that SUCCEEDED stays routine.

uci-defaults still exits 0, deliberately: a uci-defaults script that does not is
kept and re-run at every boot, and this one re-runs a detached enable+restart of
shater/shater-cron plus a firewall reload — one recoverable failure would become
permanent boot-time churn, to carry a status nothing reads. The retry that
matters already exists in start_service, which runs the migration every start.

Found by mutation while writing the tests: reverting start_service's call site
left every other test green, because they all call shater_migrate directly. The
reporter would have been perfect and unreachable. TestInitScriptStartServiceUses-
TheReporter closes that.

Verified: sh -n and busybox ash -n on the target (ImmortalWrt 25.12.1 r37978),
the classifier exercised there under busybox ash against the real uci; six
mutations rolled back one at a time, each caught by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:12:54 +03:00
omarandClaude Opus 5 d1f43dbbe6 fix(shaterd): diag printed constant.Version instead of the version it was handed
renderDiag took a version through its seam and then ignored it, reading
constant.Version directly — so the one field the dead-daemon test could have
pinned was the one field it could not see change. The bundle now prints what it
was given, and the test asserts the value and not just the heading.

Also names the cost the schema gate adds: model.readDiskState's own comment says
"this runs once per write", and it now also runs once per apply, i.e. once a
minute from shater-cron — one `uci export shater` fork and two parses of a few
kilobytes. It reads the DISK rather than m.Globals.SchemaVersion deliberately:
m need not have come from disk (rollbackTo hands in an in-memory snapshot), and
the question is about the file this build would have to live with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:11:06 +03:00
omarandClaude Opus 5 67c4829f03 fix(apply): name the fetch_via=proxy that is not going through anything
`fetch_via=proxy` with an empty `fetch_detour` is not "proxy, details to
follow". Applier.HTTPClient hands "" to resolveVia, which passes it through
(it is not a `chain:` selector), engine.ViaToTag maps "" to the tag `direct`,
and the feed is dialled through the box's direct outbound — over the ordinary
WAN, with the router's real address, merely from inside the daemon process
rather than from the CLI. Nothing fails. The subscription provider, the party
`fetch_via=proxy` is chosen to hide from, sees that address on every
scheduled refresh.

The picker exists and defaults to Direct, so the state is not "unconfigured";
it is "configured, and silently equal to direct". The message opens on that.

Critical, by this file's own rule at the top — protection the operator
CONFIGURED is not in effect — and by consistency: criticalMarkers already
grades the identical disclosure critical when generate says it about DNS
("in the clear", "your provider sees", "leaves over the plain WAN with your
real IP address").

A detour that names nothing is a SEPARATE finding at `warning`, because it
has the opposite consequence: resolveVia or the engine refuse by name and
UpdateSubscription returns the error rather than falling back, so nothing is
disclosed — what breaks is the refresh, loudly. One sentence for both would
send the operator to fix the wrong thing. A bare name that is really an
egress or a chain gets its own text giving the spelling that resolves, rather
than a false "nothing answers to that name".

Filed under section `subscription` + the sub's own name, which the panel
already routes to that row (Nodes.tsx entityFindings/findingsByName) and to
Overview. The severity is part of that binding, not just the volume: `info`
is filtered out of entity routing on purpose, so it would never reach the
row — recorded at the constant.

Also completes the FetchDetour contract in model.go, which listed neither
`chain:X` — the form apply.resolveVia has a dedicated branch for — nor what
"" actually does.

Verified: 9 mutations, each reverting one part, each caught by a named test;
controls show the same instrument silent for a resolved detour, for
fetch_via=direct, and for a subscription that is disabled or has no URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:09:30 +03:00
omarandClaude Opus 5 b43f673ad2 fix(apply,shaterd): a downgraded build may not RUN a config it can only half-read
model.WriteUCI already refuses to write a config whose schema is newer than the
build, so a downgrade can no longer eat the file. What was still open was
RUNNING one. ParseUCIExport ignores options it does not recognise — silently —
so a v3 config read by a v2 build yields a Model with the v3 settings simply
absent. The engine starts perfectly happily and routes traffic by a policy
nobody wrote. Nothing said so: cmdRun never called Migrate(), /etc/init.d/shater
calls it, logs one daemon.err line on failure and starts us anyway, and that log
defaults to a tmpfs file globals.log_syslog='0' can switch off entirely.

REFUSE OR START — and why refuse. Both sides, weighed by "the default falls to
the recoverable side":

  * REFUSE. With kill_switch=closed the fail-closed plane goes up and LAN->WAN
    forwarding stops. Loud, immediate, impossible to miss. SSH, LuCI and the
    panel stay reachable, nothing on disk changes, and reinstalling the build
    the router ran ten minutes ago puts everything back exactly as it was. The
    damage is an outage the operator caused themselves and can undo.
  * START ANYWAY. Traffic the missing rules were meant to tunnel leaves through
    the plain WAN with the router's real address on it, and nothing announces
    it. That is not recoverable in the sense that matters — the disclosure has
    already happened. It is the same choice `sub update` made when it was given
    FAIL over a silent direct fetch.

So: refuse. But the daemon does NOT exit and does not crash-loop — a refusal
nobody can see would be the third bad option. It stays up, keeps serving the
panel and the control socket, and says why in three places:

  1. apply.schemaDowngradeGate refuses every apply (step 0 of applyLocked), with
     the engine-swap failure policy of step 2: a previous engine that IS running
     a config this build understood is left alone; with no engine, holdLocked
     installs the fail-closed plane — and honours kill_switch=open, which is the
     operator's documented fail-open choice and may not be quietly overridden.
     This is in applyLocked and not only in cmdRun on purpose: cron reconciles
     once a minute, so a gate that only ran at startup would be bypassed sixty
     seconds later.
  2. Status carries the PAIR: schema_version (disk) and schema_supported
     (model.CurrentSchemaVersion). Either alone is unreadable — the panel
     already showed the disk version, and "v3" next to a build that understands
     v2 looks entirely normal. The difference IS the fault. schema_supported is
     a compile-time constant and is therefore set even on the offline stub, i.e.
     on the daemon most likely not to be answering. A critical warning naming
     the downgrade is computed at READ time, because in this state no apply can
     succeed and "the warnings of the last successful apply" would be empty.
  3. cmdRun consults model.Migrate() before reading the config (so a bare
     `shaterd run` gets the gate too) and classifies the outcome with
     CheckConfigWritable: ErrSchemaTooNew is the downgrade, anything else is an
     ordinary migration failure and is NOT reported as one.

Only ErrSchemaTooNew blocks. ErrUnmigratedConfig — schema-v1 dst_domain/dst_ip
leftovers — must not: the init script documents starting anyway with those rules
disabled, and turning that into a blackout would be a regression.

Also in this pass, reported by the coordinator: standing_state_test.go's
noGatewayFinding claimed to be quoted from netplane "so the test breaks if that
warning is ever reworded". It cannot — the string never leaves this package and
netplane.noGatewayWarning is never called — and the claim was already false when
it was read: netplane's text has since gained "over IPv4" and an IPv6 clause
while every test here stayed green. A fixture that advertises a guarantee it
does not provide is worse than one that advertises nothing. The comment now says
what it is, and netplanechannel_test.go pins the two couplings that are real:
the severity comes from the CHANNEL (warningFromText(t, "interface",
SeverityCritical), no classify pass), so no rewording can demote it — with the
control that the same texts on the generate channel are NOT critical — while
Section/Name DO come from the `kind "name": ` prefix, asserted in both
directions. A genuine text link is one exported helper in netplane away and is
left to whoever owns that file.

Verified: 13 seeded mutations. Twelve killed by named assertions; the
thirteenth SURVIVED — the guard in schemaWriteVerdict could not be seen, because
on a build host model.CheckConfigWritable answers nil for everything, so the
test reported success whether the guard was there or not. The checker is now
injected and both directions of that guard are killed. Every schema assertion is
walked over all three relations (disk newer / equal / older), so nothing here
passes by always answering the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:05:10 +03:00
omarandClaude Opus 5 da7411e29a feat(panel): attach named block/allow lists to a device, and show whether they loaded
A device could only carry hand-typed domains. It can now also reference the
`config blocklist` / `config allowlist` sections by name, which brings geosite
categories and url lists to parental control for free (Device.Blocklists /
Device.Allowlists — the Go half is landing separately).

The composition problem was the order. The engine decides a name in five steps —
allow typed, block typed, allow attached, block attached, network filter — so the
typed lane and the attached lane of ONE control are two steps apart, with the
other control's lane in between. Two controls therefore cannot show the order by
position. The card draws it instead: a five-stop rail, lit per step where this
device actually has something, and the same step number stamped on each lane
inside the two pickers.

ListPicker is a new component rather than a generalised SrcPicker: that one is
welded to useSrcOptions(), to CIDR validation, and to an empty state reading
"everyone · all LAN clients", which on a block list means the opposite of the
truth. It reuses SrcPicker.css and its whole interaction language.

Honesty, in three places it would otherwise have lied:

  - a list chip reports what /api/ruleset/status says, not that someone attached
    it. Never fetched reads "not loaded" in crit, an empty one "empty", one the
    engine has not mentioned "load unknown" — dim, never green. A name the config
    no longer has reads "no such list".
  - attaching a list is itself the switch for that device, so a list with
    Enabled=0 is NOT drawn as dead, and the DNS page's "configured but inactive"
    is replaced by a sentence naming the devices still running it. A row for such
    a list now reads "N devices only" instead of "off"/"inactive".
  - an attached allow list is terminal, so it lifts the network blocklists off
    everything it covers. Said in the picker at the moment of choosing and again
    on the card.

cleanDomain demanded /^[a-z0-9.-]+$/, so a colon could not be typed and the
engine's own full: / suffix: / keyword: vocabulary was unreachable from the
panel. parseDomainEntry accepts them from a closed positive list and refuses, by
name, the three shapes the engine silently discards: an unknown `word:` prefix, a
marker with no value, and an IP. The keyword case gets its own message — an empty
keyword is strings.Contains(host, "") and would take the device off the internet.

The logic lives in src/deviceLists.ts with tests, since `node --test` cannot load
a .tsx. Each test was mutation-checked, and the load reading is shown giving both
a positive and a negative result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 11:01:56 +03:00
omarandClaude Opus 5 73bd02dd71 feat(byedpi,nodetest): check the listener, not the file; and let one node be tested
A2 — the panel unlocked the byedpi egress type on LookPath("ciadpi"), which
answers "is the package installed" while the operator is asking "will traffic
sent here go anywhere". Those come apart on the SHIPPED configuration: the
packaged /etc/config/byedpi is inert (enabled='0'), and the port is coordinated
between the two packages by comment only — nothing in the daemon had ever read
that file. Result: type unlocked, egress on 127.0.0.1:1080, apply green, nobody
listening.

shater/panel/byedpi.go now decides on three separate facts (binary, enabled
instances + their ports read from the conffile, a TCP connect to each) and
reports a CLOSED state: unknown | not_installed | disabled | not_listening |
listening. Only "listening" may gate the egress type. GET /api/byedpi adds the
per-egress port reconciliation, so a mismatch is NAMED with both numbers instead
of going quiet. Nothing overclaims: the check is a connect, not a SOCKS5
handshake, and every sentence says so. A connect that is neither accepted nor
refused is "unknown", never "no".

C4 — a just-added node had no instrument: the group test reads the observatory's
board, and the observatory only probes what the rules route through, so the one
question a fresh node exists to ask ("is it alive?") answered "no rule routes
through it". POST /api/groups/test now takes {"kind":"node"} and runs the SAME
instrument — same singleton, same runner, same result type, same status poll,
same exit_ip through the target's own outbound with the same refusal to answer
from `direct`. The only addition is one fallback: a node the observatory does not
cover is measured once, here, through probeOneInto (the observatory's own
dialler), recorded under its own tag alone. A node whose base tag is a plan STORE
ALIAS — the egress-bound-group case — is NOT dialled: the board already holds its
egress-path number, and a bare-WAN measurement filed there would be the same
poisoning one layer down.

Results gained kind (group|chain|node|"") and source (observatory|on-demand|""),
so "nobody measured this" is distinguishable from "measured and dead".

Also: PUT /api/config maps model.ErrSchemaTooNew to 409 beside ErrUnmigratedConfig.
A downgrade refusal is the guard working, fixed by the operator, not by us; 500
sent the reader to the daemon log.

Every new test was verified by mutation (14 mutations, each killed by name), and
each detector has a control: the byedpi probe is shown seeing a real loopback
listener AND its absence with nothing else changed, and the node test is shown
telling a live node from a dead one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:37:48 +03:00
omarandClaude Opus 5 ffe4d8726f feat(apply,shaterd): a configuration history on disk, and a support bundle that outlives the daemon
Two holes from the operations audit, and I can confirm both of its readings.

CONFIGURATION HISTORY. Applier.lastGood and Applier.snapshot are fields in
this process. A daemon restart or a reboot loses both, and Rollback with no
snapshot goes to rollbackEngineAndPlane, which re-reads the CURRENT
/etc/config/shater — that is, it re-asserts the config that broke. With
confirm_timeout at 0 by the owner's choice there is no auto-rollback either,
so "what did the working config look like?" had no answer at all once the
daemon had restarted. Nothing on this router kept one.

Every successful apply now files RenderUCIExport(m) — the existing pure
function, not a second serializer — into /etc/shater/history/<unix>-<version>.uci.

  * DEDUPLICATED against the newest entry. shater-cron reconciles once a
    minute and every reconcile runs applyLocked to completion, change or no
    change, so a file per apply would be ~1440 identical writes a day onto
    overlay flash and would fill the ring with twenty copies of one config
    twenty minutes after the last real edit. One file is now one change.
  * 20 files / 512 KiB total / 128 KiB per entry, hard ceilings, not defaults.
    The shipped /etc/config/shater is 12.6 KB of which 459 bytes are actual
    configuration; a loaded one renders to a few KiB up to low tens of KiB, so
    twenty entries normally cost 50-200 KiB and the byte cap binds only for
    inline entry lists. Against what this product already grants itself on the
    same overlay — 4 MiB of compiled lists, an 8 MiB rule-set cache, a stats.db
    defaulting to 64 MB — 512 KiB is a rounding error. An entry over the
    per-entry ceiling is REFUSED rather than allowed to evict the whole ring,
    and the refusal is reported.
  * 0700 dir / 0600 files. Checked, not assumed: the Makefile installs
    /etc/config/shater with INSTALL_CONF, i.e. 0600 root:root, and these files
    carry the same node credentials and subscription URLs.
  * A failure NEVER fails the apply, and is never swallowed: it becomes a
    Warning folded into the set Status publishes (gather + append + finalize,
    the seam abortAfterSwap already uses), so the panel says the history has
    stopped instead of the directory quietly going stale.
  * NOT kept across sysupgrade. The audit's premise that /etc/shater is in
    keep.d is wrong — keep.d/shater-core lists four specific paths, not the
    directory. Excluding it follows model.backupBeforeChange's existing
    precedent for config.pre-v*.bak: the archive is held in RAM across the
    flash and routinely ends up in cloud storage, and this is a local undo for
    changes made on THIS box.

`shaterd diag`. The only thing this product could hand over was
GET /api/log?range=, served by the daemon — so in a crash loop the one channel
that does not need ssh dies with the process. `shaterd diag` prints version,
our packages from `apk list -I`, status, `nft list table inet shater`,
`ip rule`, the log tail and the configuration, as one block, collected entirely
by the short-lived process.

  * It works with a DEAD daemon, which is the case it exists for. The status
    section falls back to the same offline stub `shaterd status` prints and
    LEADS with the fact that no daemon answered, so an empty-looking section
    can never read as a healthy one. No section is ever silently absent: a
    missing nft/ip/apk produces "NOT COLLECTED: <reason>", and `uci export`
    failing falls back to the raw file and says so.
  * Masking is a POSITIVE, CLOSED list of the fields that may be PRINTED
    (diagSafeUCI), keyed by section type. Everything it does not name is
    masked — an unknown option, an unknown section, and every field added to
    model.Model after this build. That is the direction the open `default:`
    lesson demands: the recoverable side is "hidden", not "shown".
    TestDiagMaskingIsClosedOverTheWholeModel proves it by reflection over every
    string the model can render, with the control that the same instrument sees
    those values in the unmasked text.
  * A second layer scrubs the refused literals from the WHOLE document, because
    masking the config alone would only move the leak: the daemon prints a
    subscription URL into its own log on a fetch failure.
  * node.uri keeps its scheme and nothing else — "is this node vless or
    wireguard" is most of the diagnosis and a protocol name is not a secret.

Verified: 14 seeded mutations, every one killed by a named assertion (dedupe
removed, prune removed, ceiling removed, 0600->0644, 0700->0755, failure
swallowed, warning not folded, version not sanitized; allow-list defaulting to
ALLOWED, uri scheme dropped, scrub removed, failed sections made absent, stub
banner removed, masking removed). Every check is paired with its control — the
ring tests assert the newest entry is present and correct, so "the old one is
gone" cannot be satisfied by a ring that silently stopped writing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:34:39 +03:00
omarandClaude Opus 5 7f84ea9496 feat(panel): the logs answer why it went there, where it went out, and where the search stopped
Four fixes on one path — the one a person actually walks when a site does not
open: Insights -> DNS log -> Connections. Three of them were fields the daemon
already put on the wire and the panel dropped on the floor.

1. Connections shows the routing record. ConnLogEntry gains rule_kind/rule/chain,
   with the discipline stats.go wrote them under: "" is NOT RECORDED and can
   never be drawn as "no rule matched". Three states, three readings, and a
   CONTROL test that fails if any two of them render alike. The outbound path is
   printed rule-named-tag first, dialling-outbound last (the wire order is the
   reverse).

2. The DNS log says where the lookup left. outbound_kind is a closed four:
   detour (tag named) / default (the resolver names no detour -> the query went
   out the plain WAN, past the tunnel; marked amber) / local (cache, optimistic
   answer, filter block: nothing egressed) / "" (not recorded). An unrecognised
   value falls to "not recorded", the recoverable side, not to one of the answers.

3. Both logs take q=. The daemon filters inside the store on the same walk as the
   cursor, so limit counts MATCHING rows. The searched fields are named under the
   box, because a POSITIVE CLOSED list is also a statement about what is NOT
   searched: no ports, no rule_kind, no outbound_kind — q=default matching every
   default-egress row would be a trap wearing a filter costume. logRoute mirrors
   filter.go exactly so the ?mock backend finds and misses what hardware does.

4. A TRUNCATED page is not the end of the log. A filtered walk is budgeted
   (MaxFilterScan); a page that ended on that budget is short for a reason that
   has nothing to do with how much data exists. X-Stats-Log-Truncated is now read
   and the state is NAMED — an amber "Scan stopped" plate, the empty text saying
   "not the end of the log" instead of "nothing found", the count line refusing
   to say "all loaded", and the daemon resume cursor behind a button. The cursor
   matters twice: a truncated page can have ZERO rows, so there is no row seq to
   page from, and the live tail now advances on rows EXAMINED rather than rows
   matched — a filtered after= poll that matched nothing used to rescan the same
   window every tick forever.

Also: .fp-select gets max-width:100% + min-width:0. A <select> shrink-wraps to
its widest option and, as a flex item, refuses to shrink below it: the geo
provider label measured 501px in a 375px viewport and gave the PAGE a horizontal
scrollbar (scrollWidth 559 vs clientWidth 375, measured). Settings.css and
Networks.css each carried a narrow copy of this fix; the component is the right
place. Verified on an isolated harness with no page-local CSS: bare select
overflows a 320px row at 438px, adding the class alone brings it to 320/320.

Tests: 34 new, every one mutation-verified — unrecorded folded into default /
into local, rowMatches returning true unconditionally, outbound_kind added to the
searched fields, historyExhausted ignoring truncated, logEndNote drawing both
situations with one sentence, logCountLabel saying "all loaded" on an incomplete
scan. Each revert reproduced its own failure text. Both search directions are
covered (finds / does not find), which is what catches a filter that matches
everything. Browser-checked at 390 and 1280 in both themes, no horizontal scroll;
the six-click resume walk from "scan stopped" to "Nothing in the log matches" was
exercised live in ?mock.

NOT verified: no hardware or VM run — the truncated state was exercised against
the mock backend, whose scan budget is 60 rows where the daemon uses 20000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:33:20 +03:00
omarandClaude Opus 5 fd8b424d5a fix(netplane): a missing IPv6 gateway is not an outage, and it never was one for IPv4
Measured on the production BPI-R3: egress `ewan` was carrying the entire
household's traffic (chain default, plane full, verdict tunnel, 15 hours up)
while the panel showed, at CRITICAL, "this egress CANNOT REACH ANYTHING outside
its own subnet — every node, group and rule bound to it will fail to connect".

table 8208 held `default via 10.0.0.1 dev eth1`; the IPv4 half was perfect. eth1
holds one address, fe80::.../64, and the ISP publishes no IPv6, so
`ip -6 route show default` is empty router-wide. The -6 pass found no nexthop
and one family-agnostic text declared the whole egress dead.

Two defects in one line. A per-family fact was stated as an absolute, and the
absence of an optional ISP feature was graded as an outage — in the loudest
register this codebase has, on a channel apply grades critical wholesale. Red
that stands for fifteen hours over a healthy router is not a warning.

IPv4 stays loud and unchanged in substance: an uplink with no IPv4 nexthop
carries nothing. It now scopes its consequence to IPv4 and says outright that
it is not describing IPv6.

IPv6 splits on one piece of evidence — does the device hold a global IPv6
address? If it does not, IPv6 is simply not provisioned on this link: nothing
is broken, nothing leaks (the v6 mark keeps its own table and its unreachable
floor, so it cannot fall through to main), and there is nothing the operator
can do because the missing thing is upstream. Silent. If it does, IPv6 is
configured and the nexthop is missing anyway — a real fault, still critical,
now scoped to IPv6. Silence requires positive evidence: a failed address read
makes us louder, never quieter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:27:50 +03:00
omarandClaude Opus 5 a9ec36e053 fix(netplane): a second LAN's inbound options meant nothing, and rule counters were structurally blind
Three things the divert plane got wrong once more than one tproxy inbound
exists, and one it got wrong all along.

Per-rule diverts read `tcp`/`udp`/`tproxy_port` off the FIRST enabled tproxy
inbound and applied them to every device the plan touches. With one LAN — the
shipped shape — first and owner are the same section and nothing showed. With
two, `option udp '0'` on the second inbound was ignored (UDP diverted anyway,
into another section's listener), `option udp '1'` was ignored the other way
(no per-rule UDP line at all, so the rule's counter never ticked for UDP and
Insights showed a rule that appeared never to match), and `option tproxy_port`
pointed at the wrong listener. Same for the dns_intercept :53 lines, which sit
above the fib-local bypass. Each ingress device now resolves to the inbound
that OWNS it; a device no inbound claims still falls back to the primary,
because that is the only listener its traffic can reach. Verified
byte-identical output for every single-inbound shape against the pre-change
renderer.

Rule counters: a counter exists only for a rule the plane emitted a divert
line for, and it only emits them from SOURCE selectors — so a rule written by
domain or ruleset never appears in RuleTraffic at all, and absence there could
not be told apart from "carried nothing". It cannot be measured: which rule a
packet matches is decided inside the engine after the divert, where nftables
cannot see it. So no counter is invented. Instead the plane says which rules it
can measure (RuleMeasures) and what the numbers it does have actually mean —
an upper bound, not the rule's traffic — and the two are pinned to the rendered
ruleset in both directions. Counters are now declared BY the emitting line, so
a rule whose fragments were all dropped no longer leaves a counter attached to
nothing, reading a confident, permanent, false 0 B.

untunnelable_egress could resolve, pass validation and still mark nothing when
the plan has no LAN ingress device — while apply's note, gated on the same
binding succeeding, told the operator that IPsec/GRE/SCTP now leave through it.
The gate is right (the marking rule has no safe unscoped form), the silence was
not; bound-but-inert is now named.

stats/panel do NOT consult RuleMeasures yet — wiring it is a change outside
this package, and the gap is still visible to an operator today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:27:33 +03:00
omarandClaude Opus 5 d40eeede0c fix(model): refuse a config newer than the build, and never write the version back down
A downgrade ate the configuration silently, and permanently. Migrate() refuses a
newer schema, but nothing on the write paths calls it: the daemon starts
regardless, ParseUCIExport reads the options it knows and drops the rest, and
WriteUCI replaces the WHOLE package. So an older build rewrote /etc/config/shater
with only what it understood. The second half is what made it unrecoverable —
schema_version round-tripped through the Model, so the rewritten file claimed the
OLDER version, and a newer build put back afterwards saw cur == CurrentSchemaVersion
and migrated nothing. Nobody had to be at the keyboard for any of it: the profile
watcher looks every 25 s and `sub update` runs from cron every 6 h, and both
persist through WriteUCI.

The mechanism for refusing already existed and already worked in the other
direction (ErrUnmigratedConfig + CheckConfigWritable); this is its second caller,
not new machinery.

- guardSchemaDowngrade refuses the write and the panel's pre-flight when the
  config on disk is newer than this build, naming both versions and the way back
  (put the newer package on again — the config is untouched). ErrSchemaTooNew so
  a caller can answer 409 instead of 500.
- withDiskSchema takes schema_version from the DISK, never from the caller. A PUT
  body that omits it sends 0, and a rendered 0 is an OMITTED option: the version
  would have vanished and the next `shaterd migrate` would replay every step. A
  body claiming 99 would have locked the box out of its own panel.
- backupBeforeChange copies the live config to /etc/shater/config.pre-v<schema>.bak
  before the first migration and before the first write — once per schema version,
  write-then-rename. A failed backup aborts: the `uci commit` that follows writes
  to the same filesystem, so refusing costs nothing that was not already lost, and
  best-effort-and-carry-on is the silent skip we keep paying for.
- The reverse direction is fenced by a test: an unmigrated v1 config still refuses
  a rule-changing write as ErrUnmigratedConfig, still allows one that leaves the
  rules alone, and still keeps its `list dst_domain` and its v1 stamp.

The shipped /etc/config/shater now says what "conffile" actually buys — values
across a package upgrade, not comments across the first write, which happens
without an operator — and the annotated file is installed a second time as
/usr/share/shater/config.sample, where nothing rewrites it.

INSTALL.md gains the downgrade procedure. Measured on the testbed VM (ImmortalWrt
25.12.1 r37978, apk-tools 3.0.5) against the real apk-v0.2.9/v0.2.10 feeds in an
isolated --root sandbox: `apk upgrade <named>` does not downgrade at all;
`apk add <pkg>=<ver>` does, and leaves a pin in world that a later upgrade obeys;
`apk upgrade -a` downgrades too but took four unrelated packages with it.

Tests in shater/model/schemadowngrade_test.go; every assertion checked by mutation
(8 mutations, each killed a named test) and every refusal paired with a control
that accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:21:01 +03:00
omarandClaude Opus 5 7c93019e81 fix(panel): draw the settings that decide whether traffic leaks
Five things the panel knew and did not say, each one a state where the
screen read healthier than the router was.

Rule.Kill was typed, round-tripped and drawn nowhere. `open` sends a
rule's traffic out direct — around the kill-switch, with the real
address — when its target cannot be built, and such a rule looked
exactly like one that fails closed. It now has an editor beside Target
and an amber mark on the row; the fail-closed default draws nothing, so
the two states are not priced alike. An unreadable value is its own
state: it blocks, like the daemon, and the picker re-surfaces it
verbatim rather than rewriting a value it never showed.

Alert channels were write-once for Type/Token/ChatID/URL/Events, so
fixing a typo meant deleting the channel and going back to BotFather for
a token you already owned. Add and edit are now one form. The token box
starts empty and the caption says what empty means — keep, never clear —
because the panel refuses to show the secret and a save may only clear a
field the editor could show. Same rule covers a type switch: the other
kind's settings stay stored and unused.

The add-rule form pre-filled Target=direct. An untouched form is a rule
with no matchers, i.e. the default route, so one press put the whole LAN
on the plain WAN. `block` would only have swapped the leak for an
outage; the recoverable default here is no default, so the form refuses
and asks.

The empty state said "all traffic follows the default route" without
naming it. On a fresh install that route is `block` — the LAN has no
internet — and this is the page the kill-switch alarm sends people to.
Both it and the lead now name the route in force.

The interception board was computed from the config alone and lit `lan`
green over a stopped engine. Green now needs the engine up AND the full
plane; a hold plane blocks rather than carries, and unknown is an unlit
socket.

Also: four rungs of the untunnelable copy claimed traceroute works. It
prints `* * *` and no hops on every setting — the wording is now
apply/warnings.go's own udpTracerouteFacts, said once.

Tests: killPolicy / alertEdit / defaultRoute / intercept, 38 cases, each
mutation-checked (16 mutants, all caught). Browser-verified at 390 and
1280, no horizontal overflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:10:32 +03:00
omarandClaude Opus 5 bc7ea359c8 feat(panel): the settings that only /etc/config/shater could reach
Five groups of real daemon settings had no control in the panel, so the
only way to change them was to edit the config over SSH. Each one is now
editable where it belongs, and each editor is built so it cannot lose a
field it declines to display.

DNS list refresh intervals. Blocklist.UpdateInterval was hard-coded to
"24h" in two places and shown nowhere, while the row displayed the
interval the ENGINE reported — a readout dressed as a knob.
Allowlist.UpdateInterval did not exist in the panel at all. It matters
because an allowlist is how a blocklist false positive gets corrected: one
pinned to a day delivers the fix up to a day after the site broke.

Geo data. GeoProvider and the four URL fields are consumed for real
(generate.SetGeoProvider, /api/ruleset/categories) and the panel USES the
data they pick, while offering no way to choose it. New Settings group
with the closed five-provider list, the custom {category} templates and
the two category indexes. Only `custom` reads the templates, so only
`custom` renders them; an unknown provider is preserved and marked rather
than silently rewritten to auto on page load.

Subscription filters. Include/Exclude/FilterProto/FilterCountry/Dedup,
Format, ExpireAlertDays and the three device-identity headers are now
editable — the same five filters a group already offered over its members,
applied one step earlier. 376 nodes can become the four Dutch ones without
SSH. ExpireAlertDays keeps its three states (blank = the 3-day default,
"off" = -1) instead of being flattened.

Edit-after-create. Blocklists, allowlists and resolvers could be
configured only at creation; a typo in a URL meant delete and rebuild, and
deleting a resolver clears whichever global slot it filled. Every one now
has a row editor. `file` and `geosite` sources are offered when a list
already IS one, so opening a list the panel cannot create never becomes a
way to destroy it.

Stale local type copies. DNS.tsx and Settings.tsx carried local
Blocklist/Allowlist/Globals extensions whose comments claimed api.ts did
not type those fields; api.ts had typed them for a long time. Deleted —
the note was an invitation to declare the next field twice. (Egress.Target
was already gone.)

Along the way, three defects the work surfaced:

  * parseDomains cut comments per TOKEN, so pasting "# ads and trackers"
    contributed ads, and, trackers as three real blocked domains. Cut per
    line now.
  * FetchVia=proxy with no FetchDetour resolves to the tag `direct`
    (engine.ViaToTag), so the feed is pulled over the plain WAN and the
    provider logs the router real address — the one thing `proxy` is
    chosen to hide. The row said "via proxy" for it. It now says
    "proxy - no route" and the editor carries an amber explanation. The
    picker also gained chains, which apply.resolveVia supports for real
    and the picker excluded with a comment that misdescribed the contract.
  * Adding a subscription only saved it. apply does not fetch, and
    shater-cron is inert unless globals.enabled=1 AND the service is live,
    so on a router not yet switched on nothing would ever fill it — and
    the only Update button sat at the bottom of a collapsed panel. Adding
    now fetches, reported separately from the save, and every row carries
    Fetch now. A row with no nodes says what to press.

Nodes also gained the forward link nothing had: a node is not something a
routing rule can point at, and no page said so.

The merges live in subEdit.ts / dnsListEdit.ts / geoProvider.ts because
`node --test` cannot mount JSX. The rebuilt shapes return Complete<T>, so
a field added to api.ts fails the build in the function that has to decide
about it; the subscription merge extends instead, because five of its
fields are provider-reported state no control can show.

Verified: npm run build green; 173 tests pass; 18 mutations each killed a
named test and a probe field added to Allowlist broke the build inside
nextAllowlist; zero Cyrillic in panel/src; Chromium at 390 and 1280 with
no horizontal overflow (the detector caught a real 559px select spill at
390 before the fix).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:07:00 +03:00
omarandClaude Opus 5 447cd8cf7d fix(panel): a switched-off service is not a fault, and say so before the apply that is
The shipped config is `enabled '0'` + `kill_switch 'closed'` with nothing
applied. Every lamp in the panel was derived from what is INSTALLED and none
from whether anything was MEANT to be, so a package that installed exactly as
designed showed a crit master lamp ("Engine down"), a crit kill-switch module
("NOT IN EFFECT") and three crit pips on Apply — at a person who had not done
anything yet. Red that fires on a correct installation is red nobody reads by
the time something is actually wrong.

serviceIntent() is the missing question, and every readout that used to answer
from the installed state now asks it first: off ⇒ unlit socket and a word that
says why; on ⇒ every alarm exactly as before. A positive `off` only — an
unreadable configuration stays `unknown` and keeps its crit, because that is
the state where the LAN really is cut off.

applyRisk() is the other half. Applying an empty config with the service on
and the kill-switch closed sets route.final = block, and the tproxy divert for
the shipped `lan` inbound is installed — so every TCP connection and UDP flow
from the LAN is handed to the engine and dropped. The panel read that state
perfectly once it existed and said nothing before, with confirm_timeout at 0,
so the most dangerous apply this router does ran with no auto-rollback. The
band names the outcome, the missing rollback and the fix, and does not block
the apply.

Insights had a short-circuit for this exact job that never fired: it was gated
on logging being off, and the shipped backend is memory. Ten sections drew ten
well-mannered "nothing yet" states and not one named the switch.

Every test is mutation-checked, and each one is paired with the control that
proves the instrument can still produce the alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 10:04:59 +03:00
omarandClaude Opus 5 65db309e3c fix(sub): fetch_via=proxy went out on the plain WAN from cron and at boot
`fetch_via=proxy` on a subscription means "pull this feed through the tunnel",
and it is set for exactly one reason: the provider is blocked, or the owner does
not want the provider (and every hop to it) learning the router's real address.

The panel honoured it — Applier.UpdateSubscription resolves fetch_detour against
the running engine — so testing it once from the browser showed it working. The
CLI verb did not: it logged one daemon.warn line and fetched DIRECT.
/etc/init.d/shater-cron calls exactly that verb, so every scheduled refresh and
the fetch-at-boot went out unproxied, and the only trace was a syslog line in a
log globals.log_syslog='0' switches off.

The CLI cannot do this fetch itself — only one process may own the engine — so
it now DELEGATES: a new control-socket verb `sub update <name>` runs the very
same Applier.UpdateSubscription the panel's Refresh button calls. One
implementation, so the two paths cannot drift again.

With no daemon to ask, the subscription FAILS (exit 1) instead of falling back.
The refusal is recoverable — shater-cron does not stamp the item, so it retries
after RETRY_SECS and the already-cached nodes keep working — where a silent
direct fetch is not: the disclosure has already happened. Direct subscriptions
are untouched and still need no daemon at all.

Order is load-bearing: the direct pass and its UCI write run first, then the
delegated ones, because the daemon re-reads UCI and writes back userinfo
counters a later write from this process would silently drop.

Also in this file, reported by the LuCI agent: the offline stub of `shaterd
status` published config_readable=false after a SUCCESSFUL read, telling every
consumer to disbelieve three values it had just read correctly (LuCI worked
around it by reading the field only when a daemon answered), and swallowed a
FAILED read with no trace — the inverted lie apply.Status() was fixed for, in
the one situation that matters most: a full /overlay where "not enabled" tells
the owner they switched it off themselves while the fail-closed plane holds the
LAN shut. Both halves now mirror the live path exactly.

Tests are mutation-verified in both directions, with an instrument that gives a
positive reading for BOTH "went through the tunnel" and "went direct" — a live
origin server and a live control socket in every case, so neither zero is an
artifact of the other endpoint being absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:53:19 +03:00
omarandClaude Opus 5 d63f1d896d fix(bridge): reassemble return-path IP fragments — the bridge dropped them
sing-tun's classifyReturn answers `returnPass` for any IP fragment, so the l3
return path never judges one. On the WireGuard endpoint a passed packet still
reaches the endpoint's own tun stack; the bridge has no second consumer — both
deliverReturn and the batch read loops offer a packet to each attached return
path and then drop whatever nobody claimed. A fragmented answer coming back
through a bridge outbound was therefore lost outright, 100% of the time.

Fragments do arrive: the return direction is fragmented by the LOCAL kernel
(conntrack defragments at PREROUTING for the NAT lookup, the output path
re-fragments to the bridge TUN's 1500-byte MTU honouring IPCB frag_max_size).
packet.go's fixReturnChecksum already recognises a fragment and declines to
touch it — the path was known to carry them.

frag_reassembly.go is a deliberate sibling of transport/wireguard/
frag_reassembly.go: same algorithm, same ceilings (64 datagrams, 1 MiB, 5 s,
non-refreshed deadline, partial overlap poisons the key), so collapsing the two
into one shared package later is mechanical. They are not shared today only
because the seam that would host the shared type — transport/wireguard/port.go
and its test suite — is owned by other work in flight.

Windows is deliberately untouched: there a fragment never reaches deliver() at
all, because classifyInbound needs a transport header to decide ours/not-ours
and WinDivert reinjects the rest into the host stack. Different function,
different defect, platform we do not ship.

protocol/tailscale gets a comment, not a fix: the one ReturnPackets call that
package makes carries BuildUnreachable replies, which are synthesised whole and
can never be fragments, and the real tunnel return path is upstream
tstun.Wrapper.Write, ahead of every seam this tree owns.

Verified: 23 tests, all 14 seeded mutations killed (including "seam removed" on
both the portable and the Linux batch loop), -race clean on linux/amd64 in
docker and on windows/amd64. The darwin seam is compile- and vet-checked only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:53:03 +03:00
omarandClaude Opus 5 55dea4e729 feat(stats): the DNS log says where it went out, and both logs can be searched
C1. handleEvent had QueryEvent.Outbound in its hand, used it only to compute
action(), and dropped it. The query log could name the resolver that answered
and not the channel that resolver's own packets took — the one fact an
anti-leak `detour` on a resolver exists to control.

LogEntry now carries Outbound + OutboundKind, on the ConnLogEntry.RuleKind
discipline: "" is reserved for NOT RECORDED, so the three states that all have
an empty tag stay distinct — "detour" (tag recorded), "default" (the resolver
names none, so its packets take the plain WAN), "local" (cache/optimistic/
filter block: nothing egressed at all). An unrecognised Source falls to
unrecorded, the recoverable side. Rows from older builds decode to unrecorded
and are therefore still distinguishable from a recorded no-detour row.

No rule name is attached, and that is deliberate: the DNS path has strictly
less to work with than the connection path did. A DNS *rule* picks a SERVER,
not an outbound, and the event carries no rule identity at all — only the
transport's tag. Inventing one would be a forgery.

Cost, measured: LogEntry 120 -> 152 B (+32 B/row, two string headers on
aarch64). +6.4 KB at the default ring of 200, +160 KB at 5000. Tag bodies go
through the existing intern table (maxRuleKeys=512, shared with the rule text).

C2. /api/stats/log and /api/stats/conns take q=<substring>, applied INSIDE the
store on the same walk as the seq cursor. It has to be there: Limit is applied
by the store, so post-filtering a returned page would hand back 3 rows of a
50-row page and call it a page. Substring, not regex — nothing a client can
type costs more than a linear scan.

Pagination stays honest. A filtered walk must examine rows it will not return,
so it is bounded (MaxFilterScan=20000) — and a page that stopped on that bound
is short for a reason that has nothing to do with how much data exists. That is
reported: LogPage.Truncated + ScanCursor, surfaced as X-Stats-Log-Truncated and
X-Stats-Log-Cursor. Unfiltered requests are untouched: no budget, never
truncated, same walk as before.

The logRing seam now returns LogPage/ConnPage instead of (rows, pending) so the
truncation state cannot be dropped on the floor between the ring and the API.

Tests: all mutation-verified (7 reverts, each reproduced with its message),
including the copying-variant control for the intern table — strings.Clone
passes an equality check and fails the identity check the test actually makes.
Filter coverage is both-directions (finds / does not find) on both backends,
with mem-vs-bolt parity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:51:17 +03:00
omarandClaude Opus 5 2dfd7caf2b fix(apply): the untunnelable note may not describe an egress the plane never bound
untunnelablePolicyWarnings opened its egress branch on
`Globals.UntunnelableEgress != ""` alone. netplane refuses far more than a
typo: UntunnelableEgressBinding fails CLOSED for any name that does not
resolve to an interface/tunnel egress WITH a device — nothing is marked in
prerouting, no forward-chain accept is rendered, addEgressRouting installs
no rule and no table, and the `untunnelable` policy decides everything by
itself. The note nevertheless opened with "...now leave through egress
"x": the kernel routes them out that interface", about a carrier that does
not exist; it even printed `(device )` once the device was interpolated.
The tail hedged the case thirty lines later, and the first sentence is what
gets read.

The branch is now gated on netplane's OWN verdict, called rather than
re-derived (apply imports netplane, so unlike model.ValidateUntunnelableEgress
there is no copy to keep in lockstep). That needs the whole model, so
collectWarnings/gatherWarnings/untunnelablePolicyWarnings take *model.Model
instead of model.Globals.

When the option is set and unbound, the note now LEADS with that fact and
then gives the ordinary policy text, because that is exactly what the router
is doing. The bound branch drops "a name that matches no interface/tunnel
egress" from its failure list — that case can no longer arrive there — and
names the device it resolved to.

Two further claims found while checking the rest of the file against the code:

- the `icmp` rung promised "IPTV and VPN passthrough work only toward
  addresses your rules route directly". Multicast crosses this router under
  NO setting (the stream is WAN-side inbound; a client's outbound multicast
  is UDP, which untunnelableFilter structurally cannot match), and the other
  three rungs all say so. One true clause was carrying one false one — the
  same sentence the `block` note records having removed for being false.
- "\"icmp\" drops it, excepting only ping/echo" understated a leak. `icmp` is
  the one rung that walks the destination plan and it ACCEPTS raw ESP/AH/GRE
  toward provably-direct destinations, so the operator was told it was
  contained while it left with the router's real address.

Ratcheted by untunnelable_egress_honesty_test.go, each assertion with a
control: the bound and unbound halves are walked in one pass, and the IPTV
and `icmp` checks fail if the matrix ever stops producing the notes they
read. The traceroute matrix grew a third egress value (set-and-bound,
set-and-unbound) so the bound branch keeps being walked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:45:24 +03:00
omarandClaude Opus 5 6f84d0ca7b fix(luci): read daemon_answered, and stop reading a config nobody could read
The dashboard told a live daemon from a dead one by `plane: ""` — a side
effect of the offline stub being a zero value, not a promise anyone made.
`shaterd status` now states it: daemon_answered, true on the live branch and
false on the stub. The detector reads the field first and keeps the plane test
only as the fallback for the non-atomic update window (new luci-app-shater,
old shaterd). When the two disagree the field wins; a stub carrying a plane
word must still read as "no daemon answered".

Both lists are positive and closed. A daemon_answered that is not exactly
true/false is not a verdict and falls through; a plane word this build does
not know lands in unknown. Nothing lights green or amber on a guess, and the
launcher button is still never disabled.

config_readable was already on the wire and nothing here read it. With it
false, enabled/kill_switch/panel_port are zero values: "inert (disabled)" and
a green "closed (fail-closed)" were being rendered out of placeholders, in the
one situation — a full /overlay, an interrupted commit — where the fail-closed
plane has the LAN cut off and the owner is told they did it to themselves.
Those rows now say "not known", a Configuration row carries the daemon's own
reason and its don't-switch-anything-off warning, and an absent nft table is
no longer softened to amber by an `enabled` nobody could read.

The field is consulted ONLY when a daemon answered: the offline stub reads UCI
directly and never sets ConfigReadable, so its false is a zero value while its
enabled/panel_port ARE real reads. Taking it at face value would put "could
not be read" on screen for a readable file. Same reasoning drops plane,
traffic and hash on the stub branch — the contract calls them placeholders.

panel_port is CONFIGURED, not bound: shaterd logs a panel bind failure and
carries on, and SHATER_PANEL_ADDR can switch the server off while the port is
still reported. Nothing measures a listener, so the hint, the tooltip and the
new Panel port row say the port is configured rather than checked, and its
lamp stays unlit even on a healthy router.

tests/status-readout.test.js grows the new cases and now runs under gate step
[7/7]. Mutation-checked four ways against copies: dropping the
daemon_answered branches fails 7 assertions by name, dropping the plane
fallback 5, reading config_readable without the daemon gate 5, and rendering
the placeholders as readings 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 09:40:24 +03:00
omarandClaude Opus 5 e38108a7c4 test(gate): install iproute2 in the docker lane — without ip every slot is free
test / go + panel tests (push) Successful in 15m18s
release / test gate (push) Successful in 10m57s
release / apk aarch64_cortex-a53 (push) Successful in 5m52s
release / apk x86_64 (push) Failing after 28s
release / release apk (push) Successful in 6s
This change was already in the working tree when this session started; it is
committed here because it is load-bearing and an uncommitted load-bearing file
is a trap.

netplane.L3SlotFor asks the kernel through `ip link show` and reclaims through
`ip link del`. golang:1.26 ships no iproute2, so in the docker re-exec lane
every slot read as FREE, TestIntegrationL3StaleSlotIsReclaimed stood itself
down rather than pass while proving the opposite of what it claims, and [5/7]
then failed the gate — correctly, since this environment HAS root and
/dev/net/tun and the capability guard is therefore not what skipped it.

Installing it is also what made the concurrent-namespace defect visible at all
(see 06c04c157): with no `ip` on PATH, no `ip link del` was ever issued and the
two test binaries that were destroying shater/generate's TUN looked innocent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 03:25:58 +03:00
omarandClaude Opus 5 06c04c157d fix(gate): a unit test in one package was deleting another package's TUN
`go test` runs package binaries CONCURRENTLY and every one of them shares the
host's network namespace. netplane.L3SlotFor is destructive by design — it
DELETES a candidate slot it finds occupied rather than waiting for it — and
netplane.removeL3Devices deletes both slots unconditionally. Two test binaries
reached those for real:

  shater/engine  l3slot_test.go calls l3RetargetForNext for its return value
  shater/apply   Applier.Teardown -> netplane.TeardownRouting -> removeL3Devices

Measured with an `ip` shim on PATH inside the gate container: apply.test issued
9 `ip link del shater-l3a` + 9 `ip link del shater-l3b` per run, engine.test one
per l3slot test — into the namespace where shater/generate's privileged tests
were holding a live TUN. From the other side that is

  post-start inbound/tun[l3-in]: starting TUN interface: find tun interface: Link not found
  no [shater-l3a shater-l3b] device exists after a successful Start

i.e. an intermittently red [2/7]/[4/7] in a package that did nothing wrong,
while [5/7] — which runs only `^TestIntegration`, so neither binary reaches the
slot code — passed the very same test seconds later. It only became visible when
iproute2 was installed into the gate container: without `ip` every slot read as
free and no deletion was ever issued.

Not a product defect. shaterd is one process with one engine; the running
generation's slot is excluded before anything is deleted, and nothing else on
the router calls L3SlotFor.

The kernel is faked rather than the CHOICE: making the engine's tests stub the
slot answer would delete the only place the ENGINE checks that the running
generation's slot is excluded, which is the invariant the production outage
violated. netplane.L3StubKernelForTest points the two kernel operations at an
in-memory set; engine and apply install it from TestMain (forget-proof, unlike a
per-test helper whose omission fails in a different package on some runs only).
netplane's TestL3StubKernelTakesTheSlotChoiceOffTheKernel is the control, in
both directions: stubbed, nothing reaches the exec seam; restored, the same call
does.

Mutation: with the engine TestMain reverted, the generate binary's
TestIntegrationL3* failed 8 of 8 runs beside a loop of the engine binary; with
it, 0 of 8. With L3StubKernelForTest degraded to a no-op, the control fails
naming the three escaped `ip` calls.

Also: the DoH3 ownership test's control now retries.
requireInstrumentFindsPackedQuery packed a query into a pooled buffer, released
it and demanded the scan find it — but under -race sync.Pool.Put drops one
object in four on purpose, so the control failed 18 of 60 measured runs and took
the whole -race pass down with it. Its sibling control in the same file already
retried for exactly this reason. The claim is existential ("this instrument CAN
find a released buffer"), so one success out of 32 proves it and nothing is
diluted; 0 of 60 after. What it does not buy is stated in the code: the VERDICT
is still a 3-in-4 detector under -race, which is the safe direction, and the
non-race pass runs the same test as a certainty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 03:25:46 +03:00
omarandClaude Opus 5 3654acf7fb fix(egress): tunnel was a device to the router and an unknown type to the engine
An egress type was read by two halves that never call each other. netplane's
EgressDevice accepted `tunnel`, so addEgressRouting gave it a mark, an `ip rule`,
a routing table with an unreachable floor and a prerouting mark bypass, and
`untunnelable_egress` (D26) carried ESP/AH/GRE/IGMP/SCTP out of it by kernel
routing with the engine nowhere in the path. generate's outbound switch had never
heard of `tunnel`: default arm, no outbound, so every node, group and rule bound
to the same egress was fail-closed. One name, two answers.

Refusing `tunnel` would have broken the half that works to match the half that
does not — D26's kernel egress is shipped and verified, and the generator's
refusal is already loud and fail-closed. `tunnel` is not a distinct kind either:
the data plane treats it identically to `interface` in every line that mentions
it, and the panel's own `interface` label already reads "out a specific WAN or
tunnel". So it is an ALIAS, and it is folded to `interface` ONCE, at the config
boundary (Model.NormalizeEgressTypes, called by ParseUCIExport/ReadUCI). Teaching
the generator a second string would have left two strings for the next consumer
to forget; after the fold there is one.

- model: CanonicalEgressType / EgressTypeKnown / KnownEgressTypes — a closed,
  positive registry, plus NormalizeEgressTypes on the load path. An unrecognised
  type is left as written, never defaulted: substituting `direct` for a typo
  would send traffic somewhere nobody asked for.
- model: ValidateEgresses now NAMES an unknown type at validate time. Until now
  the only notice was a generator warning raised while building an engine config,
  which said nothing about the data plane — and the two disagreed anyway.
- netplane: EgressDevice and the prerouting mgmt-bypass consult the registry
  instead of carrying their own copies of the rule. The bypass now keys off
  EgressDevice, so a device-kind egress with no interface no longer gets an
  accept for a mark addEgressRouting never installs.
- panel: the egress editor cleared Interface/Port/DPI for every type it had no
  branch for — including types it renders no field for — so opening an egress it
  labels "(unknown)", changing only the NAME and saving deleted its `interface`.
  On a `tunnel` egress that silently unbound untunnelable_egress and dropped the
  ESP/GRE carrier back to policy. A save may now only clear a field the editor
  was in a position to show.
- panel: the unknown-type hint said "This engine builds no outbound for that
  type", which was false for the one unknown type anybody had — the data plane
  was building it a routing table at that moment. It now names both halves and
  states what saving does.

Tests: TestEgressTypeMeansTheSameInBothHalves runs one table of written types
through the real boundary and then asks netplane AND generate, requiring one
verdict (external test package: generate imports netplane, so nothing inside
netplane can import generate). Mutation-checked both ways — dropping the fold
fails on `tunnel`; restoring the old EgressDevice string test reproduces the
historical split with "generate emitted outbound egress-probe = false ... want
true". Panel: egressEdit.test.ts, mutation-checked by restoring the
unconditional clear (Interface undefined, want 'wg0').

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:52:56 +03:00
omarandClaude Opus 5 3a9b3f523d fix(l3): a test that is not about the TUN must not open one
The l3_tunnel default flip (164b703a7) turned 32 ORDINARY tests in
shater/generate red — the whole CI — because every fixture with a tproxy inbound
now generates the `l3-in` TUN and engine.Apply then wants /dev/net/tun, which the
act_runner LXC guest does not have. Three PRIVILEGED tests failed too, on a host
that DOES have the device.

The proposed fix was to move the TUN inbound out of generate and have the engine
add it at apply time. Refuted, on three grounds:

- it does not fix the 32. Thirty of them fail inside engine.Apply, not box.New;
  the engine adding the inbound leaves them exactly as red, unless the l3_tunnel
  signal travels OUTSIDE option.Options — and then
- the hash gate stops seeing it. Apply's fast path is a hash of the options; a
  decision that is not in them makes toggling l3_tunnel a no-op reconcile, i.e.
  the device stays up with the option off, or never comes up with it on;
- and the `icmp "tunnel"` warning cannot move. It needs the model, and the panel
  reads it out of GenerateWithWarnings. Leaving it in a package that no longer
  makes the decision it explains is a lie generator by construction.

What the failures actually were was contention. Measured under `docker run
--cap-add NET_ADMIN --device /dev/net/tun`: run alone, all three privileged tests
PASS; run as a package, all three FAIL — and one fails by finding a `shater-l3`
device that a DNS-filter test created. There are two L3 slots and they are global
to the process. So the fix is that the engine instrument in this suite does not
open a kernel device it does not own: withoutL3Ingress, one helper, applied at
applyAndClose and at the six other call sites.

Nothing is skipped, and the ingress does not lose coverage — it gains some:

- TestL3TunnelChangesNothingButTheTunInbound (ordinary, portable) proves the
  default config MINUS the l3-in inbound is byte-identical, through the engine's
  own marshaller, to the l3_tunnel=0 config. That is what lets the 32 Starts keep
  speaking for the default config instead of merely for a config near it;
- TestL3TunInboundIsAcceptedByBoxNew (ordinary) puts the registry half of the
  privileged test on a gate that can actually run it: a slim registry that loses
  tun.RegisterInbound now fails on EVERY CI run with `type not found: tun`
  instead of only where /dev/net/tun exists. That regression changes no generated
  byte and costs a LAN-wide outage on the router;
- TestIntegrationL3StaleSlotIsReclaimed (privileged) covers what a RESTART finds:
  an engine with l3Device == "" next to a device it did not open. It must take
  the other slot, leave that one alone, and RECLAIM it on the next apply. The
  occupied slot is held by a second live engine, not planted with `ip tuntap
  add` — a planted device is PERSISTENT and therefore attachable, and the
  planted version of this test passed with netplane.L3SlotFor's reclaim loop
  deleted, i.e. proved nothing.

generate's placeholder device name is now longer than IFNAMSIZ allows. box.New
accepts it (measured), so the emitted config is still one the engine can
validate; Start refuses it and creates NO device. A caller that builds a box from
generate's output without going through engine.Apply therefore fails at once and
visibly, instead of quietly creating `shater-l3` — the one name every generation
wants, and the intermittent TUNSETIFF EBUSY that netplane/l3.go exists to refuse.

The "leaked TUN" in the sentinel's message was not a leak. Instrumented: Close
returns in ~300 µs with ZERO open /dev/net/tun fds (control: 1 fd immediately
before Close), and the device survives 3.8-4.6 s longer purely as the kernel's
deferred unregister_netdevice. On the stand (ImmortalWrt 25.12.1 r37978, kernel
6.12.94 — the router's revision) the same test takes 0.10 s, so the lag is a
nested-netns container artefact. l3GoneTimeout goes 5s -> 20s: a leak is
unbounded, so the longer budget costs one slow failure and gives up no
sensitivity.

Verification. CONTROL, the criterion that matters: without /dev/net/tun
`ok shater/generate` (was 32 failures). With `--device /dev/net/tun --cap-add
NET_ADMIN`: green, privileged tests really ran. On local_openwrt, cross-built
with the shipped tags: the WHOLE package green with every privileged test
executed, no contamination. `go build ./...`, `go vet ./shater/...` clean.

Mutation-verified, each reverted after: shortening the placeholder fails
TestL3PlaceholderCannotBecomeAKernelDevice by name; making withoutL3Ingress a
no-op brings back exactly 32 failures; gating a second config change on
l3_tunnel, and stripping nothing in the comparison, each fail
TestL3TunnelChangesNothingButTheTunInbound; removing tun.RegisterInbound fails
TestL3TunInboundIsAcceptedByBoxNew with the right hint; deleting L3SlotFor's
reclaim loop fails TestIntegrationL3StaleSlotIsReclaimed with the production
error verbatim (`TUNSETIFF: device or resource busy`); l3GoneTimeout at 1ms still
fires the leak sentinel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:49:40 +03:00
omarandClaude Opus 5 201aa7c168 fix(panel): traceroute never printed a hop — stop saying it works
Five untunnelable notes told the operator that a plain `traceroute` works,
"still follows your rules", or that the hops it prints are the tunnel's path.
Measured on the production router: it prints `* * *` and nothing else, under
every rung of the ladder — `direct` included — with the L3 ingress on or off.

There is no mechanism that could print a hop. The UDP probe is diverted by
tproxy and delivered LOCALLY to the engine's socket; local delivery is not
forwarding, so the TTL is never decremented and no router on the path is
provoked into a time-exceeded. The engine opens its own connection with a
fresh TTL, and an ICMP error raised against that has no way back to the
client's datagram. `traceroute -I` and Windows `tracert` are ICMP echo and do
work — that half of the text was true and is kept.

One shared udpTracerouteFacts now carries the symptom, the cause and the way
out, so the panel cannot fork the claim; netplane/untunnelable.go states the
same fact in the same terms.

Second correction in the same notes: the outbounds that carry an echo are not
just WireGuard/AmneziaWG. generate/route.go's l3Target is exhaustive by
adapter registration — a wireguard/AWG node AND the direct outbound behind
`direct` or an interface egress. In the commonest configuration here that is
most of the address space, and those pings answer out of the ordinary uplink
with its real address. The old text let an operator conclude either
"tunnelled" or "dropped"; it was neither.

traceroute_honesty_test.go is the ratchet: an exhaustive matrix over policy x
kill switch x L3 x egress, asserting the retired sentences never return and
that any note mentioning a trace carries the shared facts verbatim — with a
control that fails if the matrix stopped mentioning tracing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:20:41 +03:00
omarandClaude Opus 5 78bb6a1be8 fix(netplane): a read that fails, a floor nobody checked, a flow that predates the plane
Four defects, all of the same family: something the plane relies on stops being
true and nothing says so.

1. One failed `uci -q export firewall` opened a hole AND switched off the alarm
   for it. nftZoneDevices answered nil on a read failure — the same answer as an
   empty zone — so a rule with `src: zone:lan` produced no divert line, no
   fail-closed drop and no accept_local; and uncoveredNetworkWarnings, whose job
   is to report exactly that, ran the same command, got the same nil and stayed
   silent. The read now carries its error: renderNft refuses under a closed
   kill-switch (same contract as an unusable device name) and warns under an
   open one, and the coverage check names the blindness itself.

2. RoutingPresent did not check the fail-closed floor its Apply twin installs.
   addEgressRouting/addL3Routing install three things per binding; the presence
   checks knew two. A floor that failed to install once was never retried, and
   the table fell through to `main` the first time its device went down. The
   checklist test grows clause (e) so the next mark cannot repeat it.

3. A flow established before the divert plane existed bypassed it for life:
   confirmed by conntrack while nothing diverted it, offloaded to fw4's
   flowtable, steered by netdev-ingress ahead of our prerouting hook and
   refreshed by its own packets. On the divert going from ABSENT to PRESENT —
   not on every apply — the TCP/UDP entries of flows forwarded from the divert
   devices' subnets are dropped, so they re-derive their path. Not a flush: the
   router's own addresses and LAN-to-LAN are excluded, so SSH, LuCI and the panel
   survive. Measured on the stand: 3 client flows cut, the live SSH session and
   the router's own connections untouched; `conntrack` CLI confirmed absent
   there, which is why this is ctnetlink.

4. The untunnelable text claimed Linux/macOS traceroute "still prints hops". It
   prints none, under any policy: the UDP probe is delivered locally by tproxy,
   local delivery does not decrement TTL, and no router raises time-exceeded.
   `traceroute -I` is what works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 01:01:00 +03:00
omarandClaude Opus 5 ea3a4c518e test(generate): the L3 ingress is the default now — say so in the fixtures, not in 51 rewrites
The l3_tunnel default flip (164b703a7) turned 51 tests in shater/generate red.
Two premises had changed, and each is repaired where it broke rather than at the
assertion:

- ~43 fixtures build an engine-topology model with no inbounds at all and assert
  "this config produces no diagnostics". On the seeded-ON default such a model
  earns an honest `icmp "tunnel"` warning: the L3 ingress is fed only by the
  tproxy divert plane, and a model with no tproxy inbound raises none. The
  warning is TRUE of those fixtures — they are not routers. So they now say they
  run neither router-wide plane (nonDNSGlobals became plainGlobals, and gained
  the same treatment for l3_tunnel that D24 gave dns_intercept), and every
  "no warnings" assertion keeps its original strength instead of being loosened
  to "no warnings except this one".

- 8 assertions counted len(opts.Inbounds). The subject of every one of them is
  how many TPROXY LISTENERS survive a guard, and a total that also counts a
  synthetic inbound answers a different question — one whose right number
  changes whenever an unrelated global flips. They count tproxy listeners now,
  and while there they gained the assertion the count was standing in for: that
  the SURVIVOR of the clash guard is the first-declared listener, and that two
  distinct ports keep the ports their nft diverts aim at.

TestL3TunnelOffEmitsNoTunInbound had lost its meaning rather than its fixture.
It read the default and asserted "off", so after the flip it was pinning
DefaultGlobals, not l3_tunnel. It now sets the opt-out explicitly and says why
the opt-out has to keep working, and TestL3TunnelOnByDefaultEmitsTunInbound
pins the other direction — that a model which never mentions l3_tunnel gets the
ingress — which nothing in this package did.

TestSniffIsNotAnInboundField asserted "exactly 1 inbound" purely so it could
index ins[0]. It checks every emitted listener now and counts what it checked,
so the guarantee that assertion was really providing (the loop ran) survives
without a count that any future synthetic inbound breaks for no reason.

The warning text is rewritten. "l3_tunnel is on but no tproxy inbound is
enabled" accused the reader of a choice they no longer made: since the flip it
is the default, and a message that reads as "you turned this on" sends them
hunting for a switch they never touched. It now says what is not happening, that
the ingress is on by default, and names BOTH exits — a tproxy inbound restores
it, `option l3_tunnel '0'` says the router does not want it — because which one
is right is a fact about their router the generator cannot know.

model/dnsintercept_test.go had the blindness its l3 twin documented: a plain
strings.Contains is satisfied by `#option dns_intercept '1'`, and the parse half
cannot tell either, because a commented option falls back to the seed, which
since D24 is also true. A config shipping the option commented out would have
passed both halves while giving a fresh install no visible option to flip. The
check is line-wise and comment-aware now, and its "config unreadable" branch is
a Fatal instead of a Skip — a guard that skips itself is how one ends up
reporting ok while guarding nothing.

Mutation-verified, each reverted after: seeding L3Tunnel=false fails the
default test by name; removing the l3_tunnel guard fails the opt-out test;
stripping either exit from the warning fails TestL3TunnelWithoutTproxySkipped;
setting a legacy SniffEnabled on the tproxy listener fails the sniff test;
disabling the listen-clash guard fails TestDuplicateTproxyPortSkipped; freezing
the tproxy port at the default fails TestMultiLanDistinctTproxyPortsBothKept;
commenting out the shipped dns_intercept fails the shipped-config test (and the
parse half stayed silent, which is the blindness).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:54:48 +03:00
omarandClaude Opus 5 0f69880150 test(gate): a skipped test is a test that did not run — name it, or fail
Three holes, one shape: work that reads as coverage and is not.

1. shater/apply's TestApplyInstallsHoldWhenEngineFailsToStart — the only
   end-to-end test between "the engine died" and "the LAN forwards to the
   WAN in the clear" — asserted nothing. It broke the engine by pointing a
   rule-set at /nonexistent/nope.srs and stood itself down with t.Skip when
   that failed to break anything; it stopped breaking anything once
   LocalRuleSet.reloadFile began treating an unreadable file as empty.
   Measured in golang:1.26: the skip fired unconditionally and the package
   still printed `ok shater/apply`.

   It now injects the failure at the engineApply seam — the branch under
   test is applyLocked's, and a particular cause that stops causing retires
   the test silently — and COUNTS the seam calls, so applyLocked ceasing to
   go through it fails by name instead of quietly asserting something else.
   Everything else stays real: the model, generate, the kill-switch
   decision, netplane.RenderHoldNft, the latch, Status. New companion
   TestEngineApplyReallyFailsWithoutStarting is the control that the real
   engine.Apply can fail with the engine left stopped, so the simulated
   state is one this fork can be in.

   Mutation-checked both ways: drop the holdLocked call from applyLocked and
   the test fails with "0 holding planes were installed, want 1"; bypass the
   seam and it fails with "the engine-swap seam ran 0 times, want exactly 1".

2. warnings_test.go had two of the same genre. The len(genWarnings)==0
   t.Skip is now a t.Fatal — an unloadable blocklist must always warn, and a
   generate that stops saying so is the W7 regression, not a reason to stand
   down. TestStatusWarningsAlwaysNonNil pins readConfig itself: its
   "zero warnings" assertion was true on a build host only because the
   config read failed SILENTLY, so once that failure started publishing a
   critical warning the same line meant two different things in two
   environments.

3. The gate could not see any of it. It now runs the suites with -v and
   matches every `--- SKIP` against SKIP_DECLARED; an undeclared skip fails
   BY NAME, a declared one prints its reason on every run. check_skips
   proves its own instrument first (no `=== RUN` line => the check was
   reading a blank page), and it also reports on a suite that failed
   elsewhere, so a red tree cannot become a hiding place. -v costs no test
   time (38/25/24 s plain vs 38/24/24 s, warm) — only output, which is
   filtered on a green run.

Also closes the same hole one language over: [6/7] requires every non-Go
test file in the tree to be claimed by a named runner, and [7/7] runs the
ones this gate owns with a verdict by name. openwrt/luci-app-shater/tests/
status-readout.test.js — 24 assertions over the one screen an operator
reaches while the LAN is cut off — was executed by nothing at all, and
[1/7] could not report it because `go list` is its instrument. The non-Go
suites run on the HOST before the docker re-exec, so the local loop really
executes them rather than printing "did not run" every time; where there is
no node at all they are named and the notice replaces the closing banner.

Controls, all run and reverted: a planted t.Skip is caught and named; a
planted failing .test.js is caught and named; an unclaimed test file is
caught and named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:30:39 +03:00
omarandClaude Opus 5 164b703a7d feat(l3): ping travels the tunnel by default, and every LAN zone can reach it
l3_tunnel was opt-in, and "off" had no honest win left in it. Off, a LAN ping
is decided by `untunnelable` alone and every rung is a drop (block) or a
disclosure (icmp/direct send the echo out of the WAN with the client's real
address). "Ping works" was never the state where ping was tunnelled — it was
the state where ping was leaking. On, an L3-capable outbound carries the echo
and one that is not drops it honestly: adapter.JudgeFlow returns ActionDrop for
an ICMP flow whose outbound is not a tun.Port, so no reply is forged. The price
is a standing TUN + gVisor netstack, ~2 MB RSS, and it is stated where the
option is.

The switch stays. It is a real answer on a 32/64 MB device and when bisecting
whether the L3 ingress is what broke a box — but it is now a WARNED answer:
ValidateGlobals says what the off state does to ping and names the policy that
takes over. Two combinations also changed meaning and are now reported:
untunnelable=icmp is no longer "block plus working ping" (the prerouting L3
mark claims every ICMP packet before the forward chain the echo accept lives
in, and a LAN host's ICMP errors are marked in with them and dropped in the
TUN), and the existing =direct report gains a sibling rather than standing
alone.

The fw4 seeding was the second half of the same problem. The divert set spans
every LAN inbound and every iface:/zone: rule source, but 30_shater-core seeded
a forwarding into shater_l3 for `lan` only — so on a multi-zone router ICMP
from the other zones is marked, routed, accepted by `inet shater`, and dropped
by fw4's zone policy with nothing in any log. Every zone gets a forwarding now,
guarded by a scan of the actual src/dest pairs so a re-run adds nothing. Every
zone including an uplink, because guessing which zones hold clients is wrong
somewhere and a superfluous entry authorises nothing: accept_to_shater_l3 is
`oifname "shater-l3*" accept`, and the only thing that routes a packet into
that device is our own fwmark rule.

scripts/testbed-lao.sh builds the second LAN zone this needs to be visible at
all. It is not installed by the package — that is the whole opt-in mechanism.

Verified on local_openwrt (ImmortalWrt 25.12.1 r37978): three runs of the
seeder leave exactly one forwarding per zone (lan/wan/lao) and no existing
section altered; deleting the lao forwarding removes `jump accept_to_shater_l3`
from chain forward_lao and re-seeding restores it; with the idempotency guard
disabled two runs produce nine forwardings instead of three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:26:00 +03:00
omarandClaude Opus 5 de6fa8ebf4 fix(doh3): Close is not an ownership handoff — stop pooling the query buffer
Review found the hole and it is real. My previous fix gave the pooled buffer to
the transport and released it when the transport closed the body, on the grounds
that "http3.Transport closes the request body on every path, hence the Once".
That sentence is true about how many times the body is closed and says nothing
about when — the failure mode this project keeps writing down.

Verified against the pinned quic-go: on every error path RoundTripOpt
(http3/transport.go:167-173) closes the body the moment doRequest returns, and
doRequest (http3/client.go:338-341) waits only on the request-CANCELLATION
watchdog — close(reqDone); <-done — never on the goroutine writing the body.
Nothing in quic-go joins that goroutine. So Close is not a handoff point, and
the sync.Once stopped a double Release while doing nothing about a read after
one.

One correction to the review's severity, since it changes what we tell people:
on the failure path the bytes do not reach the resolver. Every ReadResponse
error branch (http3/stream.go:325, :336, :343, :363) calls str.CancelWrite
BEFORE RoundTripOpt closes the body, so what the writer reads out of the
recycled buffer is thrown at a cancelled stream. The disclosure primitive is the
success path only; the failure path is a read of somebody else's memory, which
is undefined behaviour and a -race finding, and not shippable either.

Fixed by not sharing at all: Pack() into memory the body owns. The alternative —
a lock around Read and Close — would also be correct and was rejected because it
keeps a released-but-referenced object alive, and that is now twice in one day
that an assumption about quic-go's internal lifetimes has been wrong.

The cost is negative, measured rather than assumed: Pack is 87 ns/op at 64 B and
1 alloc against 108 ns/op at 64 B and 1 alloc for the pooled version, because
buf.NewSize allocates the Buffer struct itself — the same 64 bytes — and then
adds Get/Put on top. The pool was never saving an allocation here.

The failure path cannot be caught on the wire, so the new test pins the cause:
a query tagged with a random needle, an exchange that fails (server never
answers; context already cancelled), then the pool drained on the goroutine
RoundTripOpt ran on, demanding the needle is not there. Mutations run without
-race: restoring pooledRequestBody fails both subtests 5/5, and blunting the
scan trips its control. -race is a separate pass, green at -count=3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:12:15 +03:00
omarandClaude Opus 5 b91fba1295 fix(l3): a covered last fragment must complete; say what the timeout really does
Two review findings on the fragment reassembler.

1. A whole datagram could vanish. entry.total was assigned before addRange
   was asked, so a last fragment (MF=0) whose range was already covered by
   MF=1 fragments answered fragInsertDuplicate and returned nil — while the
   entry was already complete(). Nothing re-examined it, because every later
   fragment is a duplicate too, so it died at its deadline with all its bytes
   present. A duplicate now falls through to the completion check: it
   contributes no bytes (held bytes still win) but it does contribute the
   total length. This is what the documented first-wins policy always
   implied; the code just did not do it.

   The sender needed is non-conforming, so the old behaviour was safe rather
   than exploitable — but it contradicted the comment three screens up, and
   that comment is the next reader's only defence.

   Also closed positively: a last fragment declaring an end BELOW the bytes
   already held now poisons the datagram instead of quietly never completing.

2. The 5 s timeout was not a memory ceiling and the comment said it was.
   sweep ran only when a NEW key was created, so once fragmented traffic
   stopped, up to fragMaxEntries entries stayed resident indefinitely.

   Both halves are fixed, and the honest one is the comment. sweep now runs
   on EVERY fragment — an O(64) scan on a path that is already the rare one —
   which releases residue as soon as any fragment arrives instead of waiting
   for an unrelated new datagram. That still does not cover total silence, so
   fragTimeout now documents the guarantee the code actually keeps: bounded
   by fragMaxEntries/fragMaxTotalBytes at all times, released on the next
   fragment, NOT "freed within 5 s".

   No timer, deliberately: it would need a goroutine with a lifecycle tied to
   something returnDeviceWrapper has no teardown hook for, and a goroutine
   that must be stopped and might not be is a failure this project has
   already paid for — to reclaim at most ~1.1 MiB that only exists after
   fragmented traffic has already happened. What bounds growth is the byte
   and entry ceiling; this timeout's job is correctness, and for that a
   check driven by the arriving fragment is exact.

   The now-unreachable per-key deadline check is removed rather than left as
   dead defence in depth.

16 mutations, all red. M15 (duplicate returns early again) reds only the
buggy case while the control and the poison case stay green, so the test is
shown able to see both an assembled datagram and a lost one. M17 (sweep back
inside the new-key branch) reds the new test while both old timeout subtests
stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:08:25 +03:00
omarandClaude Opus 5 df078c3205 fix(model): the write rollback may not swallow its own failure
The rollback added for the "failed import commits the deletion" defect went
through migrate.go's staged(), which drops the revert's error on the floor
(`_ = u.Revert("shater")`). That is defensible where staged() lives — a
migration that cannot revert leaves a half-migrated config, wrong but visible —
and it is not defensible here, because the delta this path stages STARTS WITH A
DELETE OF THE WHOLE PACKAGE. A revert that silently does not take leaves that
delete in /tmp/.uci, the caller is told only "import failed" and believes
nothing happened, and the next `uci commit shater` from any process publishes
an EMPTY /etc/config/shater. The guard reintroduced the exact loss it was
added to prevent.

writeUCIWith now uses its own revertStagedWrite, which reports both failures.
migrate.go's staged() is untouched: changing its signature to suit this caller
would rewrite a contract three migration paths depend on, for a hazard those
paths do not have.

The wrapped error names the CONSEQUENCE and the one command that clears it
("a staged DELETE ... will publish it ... run `uci revert shater` NOW"), not
just the fact — "revert failed" tells an operator nothing about what it costs.
ErrStagedWriteStuck makes it machine-detectable, so a caller can tell "your
change did not happen" from "your change did not happen and this router is one
unrelated `uci commit` away from an empty config".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:06:52 +03:00
omarandClaude Opus 5 d0471b2418 build(shater-core): ship the keep.d entry, or the node inventory dies at the next flash
files/ is not installed wholesale — every path in Package/shater-core/install is
explicit — so the keep.d file added alongside it would never have reached a
router. sysupgrade's "keep settings" walks /lib/upgrade/keep.d/*, and without
this entry /etc/shater/subs does not survive a flash: the restored box has its
rules and its groups and no nodes for them to point at, and the only repair is
`sub update`, which needs the internet the tunnel was going to provide.

/etc/config/shater needs no entry — it is a package conffile and sysupgrade
already keeps it that way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:02:14 +03:00
omarandClaude Opus 5 3314927bef fix(panel): stop the readouts claiming things the daemon never said
Nine places where the panel asserted more than it could know. Each was
checked against the daemon before being changed, and the two that a test
can reach are pinned by tests proven with a mutation.

MULTICAST IPTV WAS AN INSTRUCTION, AND IT WAS WRONG. The `direct` rung
said "Ping, multicast IPTV, and connecting to a VPN ... all work", so
someone who wanted IPTV read it and moved to the most open setting on the
ladder — the one that also lets a client's ESP/GRE past the proxy — and
still had no IPTV. The stream is UDP; every rule the policy emits carries
`meta l4proto != { tcp, udp }`, and the fail-closed forward chain accepts
only the RFC1918/link-local daddr sets, with no 224.0.0.0/4 among them.
The daemon says so itself in the note drawn a few pixels below. IPTV is
now stated once, and it says it does not work.

THE `block` COST LINE WAS UNCONDITIONAL, and three settings contradict
it: an open kill-switch (no drops are emitted at all), Globals.L3Tunnel
(ICMP is marked into the engine's TUN before the forward chain) and
Globals.UntunnelableEgress (ESP/AH/GRE/SCTP are routed out a named
device). The last two were not in the panel's `Globals` type, so the page
could not have been honest about them even in principle; they were added
rather than papered over with a vaguer sentence, and the copy is now
derived from all three.

THE KILL-SWITCH WAS READ WITH `=== 'closed'`. The daemon decides with
!EqualFold(TrimSpace(v), "open") and `Status.kill_switch` is the raw UCI
string, so `'Closed'`, `' closed '` and `''` — all of which BLOCK on the
router — drew OPEN, amber, "Nothing is meant to be blocked", and through
protectionState downgraded a plane-less router from crit to amber. One
normaliser now, `planeState.killSwitchClosed`, used by all five callers
that had their own spelling of it.

AN UNREADABLE CONFIG IS NOT "TURNED OFF". `enabled`, `kill_switch` and
`panel_port` are sourced from the config and are placeholders when it
could not be read (new `config_readable`). That happens on a full
/overlay or an interrupted `uci commit` — exactly when the fail-closed
plane has the LAN cut off on purpose — and the daemon publishes
plane:"hold" with enabled:false. Checking `!enabled` first rendered
"Turned off", amber, no alarm, and pointed at a Settings page backed by
the same unreadable file. The check now comes first, carries the daemon's
"do not turn anything off to fix it", and the kill-switch readout refuses
to name a policy it could not read instead of printing ARMED from "".

Also: the holding plane promises "no client TRAFFIC reaches the WAN", not
"nothing" — DNS to the router still goes to the ISP in the clear, by
design, so the daemon can recover; the stats backend is bbolt, not SQLite,
and reclaims space by rebuilding the file, not by a VACUUM that does not
exist (and skips it when the disk cannot fit the copy); the lock screen
sent people to System → shater when the menu entry is admin/services/shater,
which is the one instruction the product gives to someone who has just
lost access; and the panel port is configured, not confirmed — a failed
listen is only a log line.

RULESET.FORMAT WAS DESTROYED BY RENAMING A LIST. The edit form rebuilt
the object from its own controls and has no control for `Format`, so the
value could only be restored over SSH. It decides how a `file` list is
parsed and stops a `url` .srs being read as text; without it the list
matches nothing, the rule stops firing, and the traffic falls silently
through to the next rule. Carried now for the two sources the generator
consults it for. The same class of loss is made loud elsewhere: the two
other rebuild sites return `Complete<T>`, so adding a field to `Inbound`
or `DNSRule` fails the build in the function that has to decide.

Egress.Target is deleted: it is not in the Go model, so the "which egress
points at this node" branches could never fire, and had anything ever put
a string on it PUT would have rejected the whole write under
DisallowUnknownFields.

One layout fix on the way past: at 390px the policy plate's grid column
was sized by the select's longest option, so the sentence beside it was
clipped mid-word — which is how a line about what leaks loses its second
half.

Verified: npm run build + tsc clean; 57 tests pass; mutation-checked by
restoring the old comparison, the old check order and the old rebuild in
turn, each time watching the matching tests fail with the exact inverted
reading; browser-checked at 390 and 1280 against the mock, which now
reproduces `?ks=Closed` and `?cfg=unreadable` verbatim instead of
normalising them out of existence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-27 00:01:08 +03:00
omarandClaude Opus 5 6801146240 feat(core): back up the product state, and let the watchdog see a crash loop
Two things the box could not survive, both silent.

BACKUPS CARRIED NOTHING. No shater package put a single entry in
/lib/upgrade/keep.d, so "keep settings" and LuCI Backup took /etc/config/shater
(a conffile) and nothing else. Everything the product knows besides UCI lives in
/etc/shater: the entire node inventory (subs/*.json, hundreds of nodes on the
live router), the boot-armor arm token, the compiled blocklists. Restored onto a
new router the config looked complete and had no nodes to route to — and the
repair, `sub update`, needs the internet the tunnel was supposed to provide.

keep.d/shater-core keeps subs/, boot.nft, lists/ and alert-state.json, and names
what it refuses and why: stats.db is history bounded only by stats_disk_limit_mb
(0 = unlimited) and the archive is built in RAM; cache.db is sing-box's cache and
a stale one is worse than none; shaterd.log is a log carrying the query history
of the box it came from.

THE WATCHDOG COULD NOT SEE A CRASH LOOP. /etc/init.d/shater respawns every 5s,
forever; shater-cron escalated only after five consecutive ticks where `pidof`
found nothing. A daemon dying seconds into startup is back before the next
60s sample, so the counter reset every time — while the fail-closed plane held
the LAN shut and the panel, served by that daemon, never came up.

The tick's sleep is now spent sampling the daemon's identity (via its pidfile,
not `pidof`, which also matches the CLI verbs this loop runs) every 5s. A tick in
which 3 different daemons lived is churn; two such ticks in a row is the verdict.
A legitimate bounce replaces the daemon once and is announced twice over
(RESTART_FLAG up, ACTIVE_FLAG down), either of which discards the tick.

The action is the one the operator already chose: kill_switch=open stops the
stack, exactly as the dead-daemon path does; kill_switch=closed — and an absent
or unrecognised value, which is the documented default — reports at daemon.crit
and leaves the decision to the person, naming the command that opens the LAN.

Also drops the ruleset loop from shater_run_due. `shaterd ruleset update` has
never existed; it exited 0, so the loop stamped every url rule-set as freshly
updated and fired a reconcile for work that never happened. Now that it exits
non-zero the same loop would emit ~288 syslog lines a day per rule-set instead.
The comment says who does own the refresh, and where the gap that is left is.

Verified: sh -n and busybox `ash -n`; the pure detector driven with synthetic
sample streams under busybox ash (13 cases); shater_sample_pid against a real
/proc with a live process named shaterd as the positive control; and the whole
chain end to end against a real 2s-lifetime crash loop. Each threshold and each
veto is pinned by a mutation that makes the gate fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:58:14 +03:00
omarandClaude Opus 5 2f8c692c39 fix(l3): the TUN is a reclaimable slot — one fixed name made every apply fatal
On the production router every configuration change with l3_tunnel=1 killed the
engine and held the LAN down, three times in a row:

  19:10:33  reconcile failed: start inbound/tun[l3-in]: open tun: TUNSETIFF: device or resource busy
  19:14:02  start instance failed and could not restore previous config; engine stopped
  19:14:38  reconcile failed: TUNSETIFF: device or resource busy

A new generation had to open the device the outgoing one still held. That alone
is a failed apply; what made it an outage is that the recovery path rebuilds the
PREVIOUS config, which named the same device — so the rescue failed for exactly
the reason it was needed. A recovery path must not depend on the resource whose
contention it is recovering from.

The device is now one of two slots, chosen by the ENGINE at box-build time, on a
copy of the options taken AFTER the hash — so the stored config stays canonical
and a no-op reconcile is still a no-op. It cannot be chosen in generate: generate
runs every minute and its output is what Apply hashes, so an alternating name
there would rebuild the engine once a minute forever.

Rotation alone was NOT enough, and that was measured, not reasoned: the two-slot
build survived five applies of five kinds and then failed on 4 of 10 back-to-back
changes with the original outage in full, because a retired generation keeps its
TUN until its budgeted Close finishes. So an occupied non-current slot is now
DELETED rather than waited for — the running generation's slot is excluded first
and never touched, every other slot belongs to a box that is carrying nothing.
No bounded wait: waiting on an asynchronous kernel teardown is the race this
design removes.

The firewall never learns which slot is live — our accepts and the fw4 zone match
`shater-l3*`, verified to validate AND load on ImmortalWrt 25.12.1 / nftables
1.1.6, so the ruleset is byte-identical across a swap. Routers seeded by a
pre-slot build are migrated in place, or fw4 would silently resume dropping the
forward.

A2: turning the feature off left the device, the ip rule and table 8200 behind —
addL3Routing returned early instead of tearing down, and nothing else owns that
device. The disabled branch and TeardownRouting now remove all three.

Two smaller lies found while proving this, both measured: `ip -6 route flush`
does not take a non-unicast route, so the fail-closed floor survived and the next
add answered `File exists` — reported as a CRITICAL "this table has no floor,
traffic can leave over the plain WAN" on every apply, about a floor that was
right there; and teardown left it behind. Fixed both.

Verified on local_openwrt (ImmortalWrt 25.12.1, kernel 6.12.94 — the router's
revision) before and after, with binaries built from the same tree: the pre-fix
binary reproduces the outage and the leftovers; the fixed one survives all five
apply kinds and 12 back-to-back changes and leaves nothing behind. Ten reverted
mutations, each shown failing. See D28.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:55:44 +03:00
omar c788425cad feat(stats): the connection log now says which rule sent it there
The tracker has carried the matched route rule and the outbound chain since
upstream (common/trafficcontrol/tracker.go Rule/Chain); nothing in shater/ ever
read them, so "why did this connection go out that exit" was unanswerable from
the log and cost hours per report.

ConnLogEntry gains RuleKind/Rule/Chain. Rule is the engine rule text, not the
model rule name: nothing survives generation that ties an emitted option.Rule
back to the /etc/config/shater rule it came from, and a guessed name would be
worse than none. RuleKind keeps the two empty cases apart — "default" is a
recorded fact (nothing matched, took route.Final), "" means not recorded at all,
which is what an old persisted row decodes to.

Both fields are interned, so the ring pays 56 B/row of headers instead of a
private copy of text that is identical across every connection one rule matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
@
2026-07-26 23:47:49 +03:00
omarandClaude Opus 5 0144282f5e fix(apply): a finding that is still true may not erase itself
Three ways this package published calm over a router that was not doing what
its config said. All three are the inverted failure: not an error raised when
things are fine, but silence when they are not.

1. Critical policy-routing findings were erased by the next no-op reconcile.
   applyDataPlaneLocked set routeWarnings only on the full path; applyLocked
   published the set unconditionally, so a minute later the fast path replaced
   it with one that no longer contained the finding. Neither surviving finding
   ("this egress CANNOT REACH ANYTHING outside its own subnet", "table could
   not be given a fail-closed floor") makes RoutingPresent false, so nothing
   brought it back: zero findings, plane full, green, over an egress carrying
   nothing. The comment on the gate claimed the previous set stood; it did not.

   planeOutcome now distinguishes "nothing was found" from "nothing was
   checked" (routeMeasured, written only by measuredRouting), and applyLocked
   carries the last MEASUREMENT forward across the fast path. A re-measurement
   still retires a finding, so this is not a latch.

2. An unreadable configuration was published as enabled=false. The panel tests
   !enabled before plane and renders "Turned off", amber, no alarm, "turn it on
   in Settings" — over a LAN the boot armor had cut off, pointing at a settings
   page backed by the same unreadable file. Status now carries config_readable
   and config_error, plus a critical finding in section "config".

3. The reason the engine failed to start existed nowhere. holdLocked logged it
   and called no publisher, and Warnings carries the last SUCCESSFUL apply — so
   plane="hold" with an empty findings list was a normal state of the product.
   The cause is recorded and published at read time while the engine is down,
   so it self-clears when the engine comes up; the boot-time arm is a warning,
   a real failure is critical.

Each fix is mutation-checked, and the route-warning test carries its control:
it sees a live finding, sees it survive the fast path, and sees a re-measured
clean state retire it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:45:28 +03:00
omarandClaude Opus 5 b642e5d8fe fix(egress): an interface egress with no interface was bound to br-lan
generate/outbound.go resolved the bind device with netplane.IfaceDevice,
whose empty-name fallback is "br-lan" — correct for an INBOUND with no
network, a black hole for an egress. netplane.EgressDevice returns "" for
the same egress on purpose (it calls br-lan "catastrophic here"), so
addEgressRouting installed no `ip rule` and no routing table for that
egress's mark, and the prerouting marking and the forward-chain accept
skipped it too.

The outbound was therefore emitted with SO_BINDTODEVICE=br-lan and a
routing mark nothing routed: every node, group and rule bound to that
egress dialled public addresses out of the LAN bridge. Not a leak — the
bind pins the socket to the LAN — but a total, silent black hole, with the
panel showing a configured, applied egress and no findings at all. The
`if dev == "" { dev = eg.Interface }` line that stood there read as a
guard against exactly this and could never execute: IfaceDevice never
returns "".

- generate now calls netplane.EgressDevice — the data plane's own
  resolution — so a bind can no longer name a device the routing was never
  installed for, and ` eth1 ` binds what the netplane routes. A device-less
  egress emits NO outbound and is reported; every reference to it then
  resolves through egressDetourOrBlock to tagBlock, so the traffic is
  blocked rather than sent out over the plain WAN.
- model.ValidateEgresses reports the same egress on the config channel
  (netplane's own skip is silent), built on model.EgressHasDevice — the
  model-side twin of EgressDevice, which ValidateUntunnelableEgress now
  shares so the two model resolutions cannot drift either.
- TestEgressDeviceResolutionParity runs one table through
  netplane.EgressDevice and model.EgressHasDevice and requires one verdict,
  the same treatment TestUntunnelableEgressResolutionLockstep gave the
  earlier validator/data-plane divergence.

Also: the UntunnelableEgress comment claimed "the panel says which, at
apply time, from whether the device is point-to-point". It does not. The
operator-facing text states both possibilities and declines to claim
either, there is no UI for the option, and isPointToPoint is consulted
only to warn that a gateway-less device can reach nothing. Said so, so the
next implementer does not read a described feature as a built one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:44:35 +03:00
omarandClaude Opus 5 35e4900769 fix(shaterd): three success reports for work that was not done
`shaterd status` fabricated a status when the daemon was unreachable and
exited 0. The stub is the same struct, printed by the same marshaller, so the
only thing that distinguished it was `plane` being "" — a value a live
Applier.Status() cannot emit. luci-app-shater was forced to key its "daemon
down" verdict off exactly that side effect, and filling `plane` in the stub for
any reason would have silently turned "dead" into "fine" on that page.

Both branches now carry an explicit "daemon_answered" boolean, and the offline
branch exits 1. The field is ADDITIVE and spliced in, not re-marshalled: every
existing key keeps its name, value and position (including plane:"" — still
emitted deliberately so dashboard.js keeps working until it moves onto the new
field), and a newer daemon's unknown fields are relayed untouched.

model.writeUCIWith committed the staged package DELETION when the import that
was supposed to refill it failed: /etc/config/shater came out empty, the caller
saw only "WriteUCI: import: ...", the next ReadUCI reported Enabled=false and
the next reconcile tore the plane down. Both error paths now revert through
migrate.go's staged() instead — the same idiom, for the same reason.

`shaterd ruleset update` printed a note and exited 0. shater-cron runs it with
output discarded and, on a zero exit, stamps the ruleset as freshly updated and
sets changed=1, so every source=url ruleset was permanently "just updated" by a
verb that fetched nothing. notImpl now exits 1 (not 2 — a caller must be able to
tell an unimplemented verb from an unknown one).

pidfilePath becomes a var so the daemon-answered / daemon-absent split is
testable without writing to the real /var/run, mirroring ctlPath.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:39:17 +03:00
omarandClaude Opus 5 d3e33294c1 fix(l3): reassemble return-path IP fragments — classifyReturn drops them
sing-tun's forwardReturn.classifyReturn refuses to judge a fragment
(flow_parse.go sets `fragment` for IPv4 MF/offset and for an IPv6
fragment extension header; flow_dispatch.go:703 answers returnPass), so
a fragmented answer coming back through a WireGuard/AmneziaWG endpoint
falls through to the endpoint's own tun stack instead of the l3 return
path, and the LAN client never sees it.

Measured on the live router: `ping -c3 -s 1400` through an AWG tunnel
with MTU 1280 is 100% loss while the WAN capture shows 3 x (1312 + 208)
in both directions — the far host answers, the peer fragments the answer
to fit the tunnel, the fragments die in classifyReturn. `-s 56` is 3/3
and PMTUD with DF works end to end, so only the fragmented return is
broken.

sing-tun is pinned upstream with no `replace`, but the fix does not need
to live there: every decrypted packet passes returnDeviceWrapper.Write
before it is offered to ReturnPackets. Reassemble there and
classifyReturn gets a whole datagram.

Hard ceilings, because this runs on a 128-256 MB router: 64 concurrent
datagrams, 1 MiB of held bytes, 64 disjoint ranges per datagram, 65535
bytes per datagram, 5 s to complete (timer starts at the first fragment
and is never refreshed). Over any ceiling evicts oldest-first.

Overlap policy: a range contained in one already held is a duplicate and
is ignored (first-wins, deterministic) because benign networks do
retransmit; any PARTIAL overlap poisons the datagram until its deadline.
No conforming fragmenter emits one, and every historical hole in this
area comes from a reassembler that tried to resolve the conflict.

The MTU of shater-l3 is untouched (65535 on purpose) and sing-tun is
untouched.

14 mutations run against the tests; each turns at least one test red,
including the two that first survived (a stale-head reuse the sweep was
covering for, and a fast-path copy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:38:07 +03:00
omarandClaude Opus 5 32aac89139 docs: stop the docs promising a safety net that ships disarmed
Every install recipe walked the reader through `shaterd apply` + `shaterd
confirm` as if commit-confirm were armed. It is not: DefaultGlobals() never
seeds ConfirmTimeout, the shipped config carries confirm_timeout '0', and
ArmRollback returns at once on a non-positive timeout. A reader following the
README believed an apply that cut their SSH would undo itself. It would not.
README/README.en/INSTALL now arm it in the recipe and say what 0 means; the
apply-flow diagram gained the edge it always took on a stock box.

The boot armor was documented nowhere at all (`grep -rli armor --include=*.md`
returned zero) while shipping enabled and blocking LAN->WAN on every boot.
INSTALL 4 now says what it is, why SSH/LuCI stay up on purpose, every condition
under which it refuses to arm, and how to switch it off.

Also removed or corrected, each checked against the code, not inherited:

* MASQUE/CONNECT-IP is advertised in both READMEs and absent from parse,
  generate and model -- registry names it among the types deliberately left
  unregistered. Dropped, with the fork-vs-product distinction spelled out.
  The inverse too: Hysteria2/TUIC/XHTTP were tagged [T1] while shipped under
  with_quic/with_xhttp; ShadowTLS is generate+registry only, no parser.
* `direct (flow-offload on)` -- no offload/flowtable/flow_offloading anywhere
  in openwrt/, shater/ or panel/src. The product does not do this.
* shater-core deps were two releases stale in two places, one of which vouched
  for a config.buildinfo check that never covered kmod-tun. Ruling narrowed to
  what was actually checked.
* PORTING's "Full schema" -- the shipped config points at it -- was missing
  l3_tunnel and untunnelable_egress (UCI is their only path; the panel does not
  show them) and the blocklist/allowlist/device/alert sections, while listing a
  `config preset` that ReadUCI has no branch for.
* ARCHITECTURE had no L3 ingress and no kernel egress at all, though both are
  [MVP] and one creates an fw4 zone in the user's firewall config. New 3a.
* nftset-for-routing in the DNS diagram: that is the v0.1 mechanism, gone in v0.2.
* CONTEXT described a pre-Phase-1 repo and a 24.10.3 testbed. The testbed is
  ImmortalWrt 25.12.1 r37978-cd0a06bfd3fd (read off the box), which is not a
  detail: .apk does not install on 24.10 at all.
* The gate existed and no .md mentioned it. README/README.en/CONTEXT now do.
* release.yml's header still described publishing as either/or after the rolling
  pointer became unconditional. Comment only.
* Shipped /etc/config/shater: schema_version '1' against CurrentSchemaVersion=2;
  a pointer to a dns_filter line that was not in the globals block (added, '0');
  and `option sniff '1'` on the inbound -- an option the model deliberately does
  not have, which the first panel save would have silently washed out.
* lx-changelog pointed at a D25 heading that does not exist.
* ROADMAP 2b and 5 were done and unmarked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:35:06 +03:00
omarandClaude Opus 5 9e6dda22b3 fix(http3,doh3): stop releasing what is still being read
Two suspicions, both put to a test rather than to a reading. Both were real, and
neither was the leak the suspicion named — both are objects released while still
in use.

roundTripHTTP3Race ran both racers on one cancellable context and cancelled it
before returning the WINNER. quic-go and net/http reset a request's stream when
its context dies, so the caller got a response whose body stopped mid-read:
H3_REQUEST_CANCELLED (local) (read 2687 of 65536 bytes). That path is taken
whenever there is no cached HTTP/3 connection and the request is replayable —
the first request to every host, and every one after an idle close. Each racer
now has a context of its own; losers are cancelled where everything used to be,
and the winner's cancel travels with its body.

DoH3's Exchange packed the query into a POOLED buffer and released it the moment
RoundTrip returned. But http3 writes the request body on a goroutine of its own
and returns as soon as the response HEADERS arrive — the body is still being
read. With the window held open the query on the wire diverges from the query we
packed at exactly offset 8192, quic-go's copy-buffer size: everything past that
was the next pool user's memory, sent to the resolver. Not a slowdown — a data
race and a small memory-disclosure primitive. The buffer now goes back when the
transport closes the body, which http3 does on every path, and can do twice.

Both files diverge from upstream again, hours after 0a6689b29 made them
byte-identical on purpose. Upstream carries the second defect in
dns/transport/https.go too; that file is outside this audit and is named in D27
so the next person finds it instead of rediscovering it.

sing-quic moves v0.6.2-0.20260525051024 -> v0.6.4-0.20260709034545. quic.go is
byte-identical across the two, so this neither duplicates nor retires the
packet-conn ownership fix — quic-go still does not own the socket. What it does
carry is the other half of the family we took only half of: clientConn.Close in
tuic/, hysteria/ and hysteria2/ now sets a past write deadline, word for word
the fix v2rayquic already had. We ship tuic and hysteria2. Cost, measured:
+256 KiB exactly on the stripped aarch64 binary and six indirect modules for a
realm port-mapping path nothing we generate can reach.

Tests are mutation-checked: reverting each fix makes them fail, with the text
quoted above. The DoH3 test carries its own control — it first proves the pool
does hand a released buffer back and that poisoning it lands, because a clean
result from an instrument that cannot produce a dirty one proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:26:03 +03:00
omarandClaude Opus 5 fde4bed571 fix(luci): stop calling the daemon dead when only the engine is
`running` changed meaning on 2026-07-26 (a8970b8ac): it was a hardcoded
true and is now the ENGINE's liveness (apply.go `Running: engineUp`).
dashboard.js was last touched on 15 July and stayed in the old epoch, so
a dead engine made the page report "Daemon (shaterd): not running" in
red, advise "start the Shater service first" — the service was running —
and DISABLE the button to the panel, which is the one place the config
can be fixed. The holding plane keeps management reachable on purpose
(netplane/nft.go: "The operator can always get in to fix the config");
LuCI was the only thing taking that guarantee away.

Daemon liveness is now derived from the wire, not from `running`. "The
ubus call returned" is not enough either: `shaterd status` EXITS 0 WITH
A FABRICATED STATUS when the daemon is unreachable (cmdStatus offline
stub), and that stub is the apply.Status zero value plus a UCI read — so
it carries enabled/table/kill_switch but leaves `plane` at "", a value
no live daemon emits. A known plane word is the positive proof a daemon
answered; an explicit empty one is proof none did. Everything else —
{} from a failed call, {"error":...} from the plugin (also what a live
but WEDGED daemon produces), a pre-`plane` daemon — is unknown, and
unknown is an unlit lamp, never green. The launcher button is never
disabled again: a mint that fails already reports itself.

"Interception: active" is gone. apply.go says of `active`, verbatim:
"Never render it as 'we are proxying'" — it is the run latch that gates
hotplug and cron, it stays raised while the engine is down and the LAN
is blocked, and this page painted it green next to two more green lamps
in exactly that state. It is now "Service latch", and its lamp reports
only whether the latch agrees with globals.enabled. The row that was
missing is `plane`: full / hold (LAN->WAN BLOCKED) / none. `traffic` is
shown too, because plane=full is not "tunnelled" — a `default -> direct`
router has a full plane and no tunnel at all.

The rpcd plugin's status docstring listed five fields of fourteen and
had done since before half of them existed; it now describes the real
shape and the two fields that are easy to misread.

tests/status-readout.test.js runs the derivation against six recorded
status shapes with no browser and no router. Mutation-checked: reverting
to `st.running` fails 14 assertions including the operator-visible
"not responding - start the Shater service" over a live daemon;
restoring the "Interception: active" row fails 9; putting
openBtn.disabled back fails 1 by name; opening the closed plane list
fails 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:22:56 +03:00
omarandClaude Opus 5 cb26936ebf fix(wgdedup): merge identical WireGuard copies instead of blocking one
A rule pointing at node:awgout, which was already the first hop of the
default-route chain, took the house off the internet for two minutes.
The pass saw one private key materialised twice, kept the copy that
sorted first alphabetically, and fail-closed everything that routed
through the other one — which happened to be the default route for all
traffic.

The mechanism was right and the framing was wrong. The physical limit is
one DEVICE per key, not one mention per key. Two copies that build the
same device — same key, same peers, same address/MTU/AWG parameters and
the same dialer — are one device written down twice, and there is nothing
for them to fight over. Those are now MERGED: one survives and every
reference to the others is rewritten to it, silently. That makes the
shape the owner wanted expressible: one chain using awgout as an
intermediate hop and another using it as a terminal, both entering over
the same egress, coexisting on one device.

Identity is the marshalled options blob rather than a hand-picked field
list, so a field added to WireGuardEndpointOptions or DialerOptions later
reads as "different" instead of being silently merged.

Only a real incompatibility — different detour, different peers,
different device parameters — is still two devices, and then:

  - the survivor is chosen by WEIGHT, not by tag order: reachability from
    route.Final (the default route) dominates, breadth of use breaks
    ties, tag order only settles a true tie;
  - the warning names the consequence. "Everything that routed through X
    is fail-closed" is equally true of a stray test rule and of the whole
    house's default route, and that is what the operator read it as. It
    now says which of the three it is, measured on the finished config:
    the default route is dead, or it survives via another path, or it
    never touched the lost copy.

A merge must not rename away the subscription fetch detour: that
reference lives in the model and is resolved against the running box, so
this pass cannot rewrite it. Such tags win the survivor slot outright,
which costs nothing since every copy in a class is the same device.

Tests: identical copies coexist on one device; a real incompatibility
keeps the default-route copy even when it sorts last and says so; the
warning does not announce an outage when the default route survives
through a group, and does announce one when it dead-ends behind a
surviving exit; no duplication at all is a no-op. All seven mutations
(merge off, weight off, member-dedup off, pin off, detour-following off,
consequence collapsed, plus a positive control) fail the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:16:12 +03:00
omarandClaude Opus 5 8db29b6267 fix(apply): say when shaterd apply armed no safety net
`shaterd apply` exists for one reason: snapshot the last-good, apply, and arm
an automatic rollback so a change that costs you access to the router undoes
itself. It answered `{"changed":false}` and not one word about that.

On the live router (2026-07-26) that was a trap. The operator edited UCI, ran
`uci commit`, the `config.change` reload trigger had already restarted the
daemon, and the fresh daemon applied the new config on startup. By the time
`apply` ran there was nothing left to apply — and the last-good it snapshotted
as the ROLLBACK TARGET was the newly applied config itself. The watcher was
armed onto the very configuration it was meant to protect against: firing it
would have restored exactly what was already loaded. No safety net, no word
said, house offline.

The verb now answers the question it exists to answer, in a closed vocabulary:

  rollback_armed  true ONLY when a window was armed AND its target differs
                  from what is running. An armed watcher pointing at the
                  running config is not a net and is not reported as one.
  reason          applied | already-applied | nothing-to-apply | disabled |
                  commit-confirm-off | config-unreadable | apply-failed
  message         the same thing in the operator's words, never empty.

The two "nothing moved" cases are told apart where they CAN be: an
/etc/config/shater mtime later than this daemon's start, with the running
config already matching it, can only mean a reconcile beat this command to it
(reason=already-applied). Where they cannot — the `uci commit` reload trigger
is stop+start, so it moves the daemon's start past the edit — the text says
so instead of reading as success: no net, harmless if you changed nothing,
unprotected if you did, and shaterd cannot tell which.

Two silent holes surface as a side effect, both previously reported as plain
success: `confirm_timeout=0` (the SHIPPED DEFAULT in
openwrt/shater-core/files/etc/config/shater) makes ArmRollback a no-op, and a
failed post-apply ReadUCI skips the arming entirely.

Arming behaviour is byte-for-byte unchanged — this only makes its absence
visible. A real safeguard for the already-applied case is separate work.

Tests are mutation-verified three ways: reverting classifyApply to the old
{changed,error} fails 11 tests; blinding the mtime discriminator fails exactly
the discriminating one (and falls back to the honest ambiguous text); making
sameConfig always report "different" fails every invariant that forbids
claiming a net over an identical target.

NOT verified on hardware: local_openwrt was held by another agent, so the
control-socket round trip and the real mtime/daemon-start comparison have not
been exercised on a router.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:12:38 +03:00
omarandClaude Opus 5 564033cd10 fix(apply): chain: as a subscription fetch detour resolved to a name nothing answers to
`fetch_detour=chain:<X>` never worked. engine.ViaToTag maps "chain:X" to the
bare tag "X", but the generator materialises a chain as one wrapper per hop —
chain-<X>-h1..chain-<X>-hN — and routes into the LAST one. The lookup missed and
the update failed with "unknown outbound tag".

It failed CLOSED, so the feed was never pulled over the plain WAN by this path.
But the miss had a sharp edge: when a node or group happened to share the
chain's name, the lookup HIT it, and the subscription was fetched through a
completely different outbound with nothing said.

Applier.HTTPClient now resolves chain: before the engine sees it, against the
tags the RUNNING box actually holds (outbounds unioned with endpoints — a WG hop
is an endpoint and Outbounds() does not list those), mirroring the generator:
the highest-indexed chain-<X>-h<i> wrapper is the entry, and a chain that
flattens to one hop IS that hop. Every other via form is passed through
untouched.

The case the generator cannot serve is named rather than papered over: chains
are built lazily, only for a chain some enabled rule/egress/DNS detour targets,
and a fetch detour is not one of those references — so a chain nothing else
points at has no outbounds at all. That, and every other miss, is an explicit
refusal wrapping engine.ErrOutboundUnknown (the panel already maps it to 400).
Never a fall back to direct: that would put the feed and the owner's real
address on the plain WAN, which is the thing fetch_via=proxy is set to avoid.

Tests are mutation-checked. Pre-fix behaviour resolves "work"/"solo" and kills
every chain case; first-hop-instead-of-last, member-copies-count-as-hops,
dropped pass-through, and a silent direct fallback each kill their own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:09:35 +03:00
omarandClaude Opus 5 add90b5b2f fix(panel): the DNS footnote was a grid item nobody placed
`.dns-filter-note` under the endpoint-resolver readout is a DIRECT child of
`.dns-filter-card`, so it is a grid item. With no explicit span it auto-placed
into column 1 — the toggle's `auto` track — and sized that track to its own
max-content: 237px at 390px, 322px at 1280px. That left the `1fr` copy column
with 0px, so "Network-wide ad & tracker blocking" laid out one word per line
and spilled 2px past the viewport, scrolling the whole page sideways on a
phone. On desktop the same cause parked the 52px toggle in a 322px column,
270px away from the copy it labels.

Measured at 390px: documentElement.scrollWidth 377 vs clientWidth 375. With
`grid-column: 1 / -1` on the footnote: 375/375, and the track list goes from
`237px 0px` to `52px 185px`. Cancelling just that one declaration in the live
DOM puts 377/375 and `237px 0px` straight back, so nothing else contributes.

Verified with playwright over 320/360/375/390/414/430/480/560/640/720/768/
1024/1280/1440: zero horizontal overflow at every width, with every rule
editor open, all three master toggles flipped, every source tab, and every
resolver type. No `overflow-x: hidden` anywhere — the page does not scroll
sideways because nothing overflows, not because the symptom is hidden.
Focus rings and prefers-reduced-motion re-checked and unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 23:06:30 +03:00
omarandClaude Opus 5 a571bd0e1a docs(claude): model is the executor's call, skills are mandatory, standards that earned their place
The old file pinned every subagent to fable — which broke the moment that
quota ran out mid-session — and spent half its length on panel scaffolding
that has been done for weeks. It said nothing about the test gate, the
testbed, or the hardware router, so none of that reached a subagent unless
it was retyped by hand into the brief.

What is new is not advice, it is the list of things whose absence cost a
day each: a test must be mutation-checked or it is decoration; an
instrument with no control proves nothing; a subagent must be told it may
refute the orchestrator, because the best results this project has had
arrived exactly that way; a formally-true sentence that reads as "it works"
is still a lie.

Skills are now a table mapping this project's areas to the skills that
cover them, with the rule that they are invoked BEFORE the work rather
than after something failed to run, and that every brief must name them —
a subagent cannot see this conversation and will not guess they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 22:48:05 +03:00
omarandClaude Opus 5 1267d20fb8 docs: drop the L3 handoff note — it is merged, and it said to
test / go + panel tests (push) Successful in 8m33s
release / test gate (push) Successful in 8m8s
release / apk aarch64_cortex-a53 (push) Successful in 6m33s
release / apk x86_64 (push) Successful in 3m45s
release / release apk (push) Successful in 8s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 20:00:46 +03:00
omarandClaude Opus 5 35f697ed08 docs(openwrt): say why mtu_fix is inert instead of claiming an MTU we no longer set
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:52:39 +03:00
omarandClaude Opus 5 d0fb6befb1 fix(l3): the l3-in MTU is not a tunnel budget — 1420 was a forgery generator
shater-l3 was created at 1420, the WireGuard payload budget, copied one
layer too far out. It bought nothing: what actually goes into the tunnel
is sized by sing-tun's forwardToPort against Port.PortMTU(), which
already fragments to the outbound MTU without DF and answers a
well-formed `fragmentation needed` quoting it with DF. All 1420 did was
make the KERNEL split every packet above 1392 bytes of payload on its
way into the device -- and a fragment is the one thing sing-tun will not
judge. Dispatch returns on parsed.fragment before calling JudgeFlow, the
fragments reach the gVisor stack, it reassembles them, and the ICMP
forwarder's installFlow demands an unspecified port address that a
WireGuard endpoint never has. So it declined and answered the echo
itself. `ping -s 1392` honest, `ping -s 1393` a lie, and only for the
outbounds the feature exists for.

65535 rather than merely "large": no IP datagram can exceed it, so the
kernel cannot fragment at this device for any packet ever. Anything
smaller leaves a band open and re-opens the class. It is also sing-box's
own default TUN MTU on Linux.

Memory was measured, not argued. Three paired runs of the integration
test under -test.memprofilerate=1 allocate 5.41/5.48/5.47 MB at 65535
against 5.76/5.46/5.70 MB at 1420, and a -diff_base profile puts every
difference in netlink interface enumeration. Nothing in the read path
scales with the MTU: gVisor reads through fdbased.BufConfig, which
sing-tun pins to one 65535-byte view regardless. I predicted a ~1.8 MB
saving from GSO switching off above 49152 and was wrong -- protocol/tun
turns GSO back on at StartStateStart whenever a FlowOutbound exists, so
the GRO scaffolding is there at both values. The corrected reasoning is
in the constant's comment so the next reader does not redo the mistake.

The integration test now reads the MTU back off the real kernel device,
which is the assertion the value exists for: a kernel that clamped it
would restore the forgery without changing a generated byte.

D25's KNOWN HOLE block is replaced with what is genuinely left. Chiefly:
a big non-DF ping does not start WORKING, it starts failing HONESTLY --
classifyReturn declines fragments on the way back too, so the packet
really leaves, the far host really answers, and the reply is not NAT'd
home. And a client that fragments on the wire itself is still uncovered;
that is the nft carve-out's job, with a warning that conntrack defrag
may reassemble in prerouting and leave such a rule unable to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:50:08 +03:00
omarandClaude Opus 5 81c96019b5 fix(panel): let a routing rule say ICMP, instead of calling one broken
The Proto picker was a closed list of the two transports and the ten
sniffed L7 labels, and anything else drew "<value> — never matches".
The engine now routes ICMP by rule (Rule.Proto accepts icmp, icmpv4,
icmpv6), so a working ping rule was rendered as a dead one and could not
be created here at all — the operator had to hand-edit /etc/config/shater
and then watch the panel call the result broken.

Adds a third group, "Layer 3". All three spellings are offered: they are
not synonyms — icmpv4/icmpv6 pin the rule's ip_version — so hiding the
narrowing would both strand a capability outside the UI and silently
widen such a rule the first time someone edited it here.

The doc comment no longer claims the list IS generate/route.go's
sniffedProtocols; only the middle group is. ICMP goes to the emitted
rule's `network`, never to `protocol`, which is the whole reason it never
matched as a sniffed label.

An unknown value is still kept and offered as written, but the
never-matches flag is now judged on the lower-cased value, the way the
engine judges it — a hand-written `ICMP` is a live rule, not an inert one.

Verified: npm run build clean (tsc --noEmit + vite build); an icmp rule
added through the panel renders as a plain "PROTO icmp" chip; no
horizontal overflow at 360px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:26:07 +03:00
omarandClaude Opus 5 baed8ff8f2 fix(model): fwmark_base 0x7f routes the engine's own traffic into its own TUN
The panel offers fwmark_base and table_base as free hex fields under
"Advanced" and nothing has ever checked them. What makes that more than a
footgun is that the derived values are invisible from the number typed: the
L3 mark is base+0x80, so 0x7f lands it exactly on 0xff — the loop-guard mark
the engine stamps on its OWN traffic — and `ip rule fwmark 0xff lookup 8200`
then captures everything the engine sends and routes it into the engine's
TUN. The router loses the internet the moment l3_tunnel is switched on, for
a reason nothing on screen connects to a collapsed section. fwmark_base 0xff
had produced the same failure since long before the L3 offset existed.

table_base is worse and got the same treatment: its derived values can land
on the kernel's own table ids, and teardown does `ip route flush table <n>`.
It is count-sensitive (egress #i uses base+0x10+i), so the check takes the
egresses rather than living in ValidateGlobals.

Written as "derive every value this layout produces, then look for
duplicates and reserved ids" rather than as a blacklist, so a future offset
is covered by construction. The layout constants are duplicated from
netplane (the import only runs one way) and pinned by netplane's
TestMarkLayoutConstantsLockstep.

Warn-only, like every check in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:23:54 +03:00
omarandClaude Opus 5 f80fb4dd1b fix(netplane): give every mark-driven table a floor, and check the L3 pair
Two halves of the same omission.

1. A fwmark lookup that finds an empty table does not fail — it falls
   through to main. Every mark-driven table now gets an `unreachable
   default` at the maximum metric: it loses to any real default route while
   one exists, it has no device so the kernel never garbage-collects it, and
   it turns "lookup failed, try main" into "lookup succeeded: unreachable".
   The fallthrough stops depending on somebody reading a warning at the
   moment an interface goes down. Deliberately not gated on the kill-switch:
   that switch decides whether traffic may escape the tunnel, while an egress
   binding is a statement about WHICH UPLINK, and silently substituting a
   different one is not what "fail open" was meant to permit.

   RoutingPresent's "does this table have a default route" test is tightened
   in the same breath, or the floor would answer it and turn the safety net
   into a blindfold.

2. RoutingPresent had never heard of addL3Routing. This is the same defect
   its own comment describes as already caught twice ("a presence check must
   cover everything its Apply counterpart installs"), committed a third time
   — and its trigger needs no interface to go down: editing a node URI
   restarts the engine, the kernel destroys shater-l3 and takes `default dev
   shater-l3 table 8200` with it, the rendered nft text is unchanged, so the
   fast-path skipped ApplyRouting forever and LAN ping stayed dead until
   someone restarted the daemon.

TestRoutingPresentSeesL3Table, TestEgressTableGetsFailClosedFloor and
TestEveryStampedMarkIsRoutedAndVerified all fail on the code they replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:23:54 +03:00
omarandClaude Opus 5 b71b793681 fix(netplane): a mark says where a packet was sent, not where it went
The forward chain let untunnelable-egress traffic past the kill-switch on
the strength of its fwmark alone. `ip rule fwmark X lookup N` does not
deliver the packet to table N, it delivers the LOOKUP there — and a lookup
that finds nothing falls through to main. So when the egress interface goes
down and the kernel garbage-collects its default route, every non-TCP/UDP
packet from the LAN is still stamped, still accepted here (above the
fail-closed drop), and leaves out the plain WAN with the router's real
address. Nothing we render changes, so no apply runs and nothing notices.

Ordinary egress traffic never had this hole: the engine binds those sockets
to the device, and a dead device fails the socket. The untunnelable-egress
path is made of nothing but a mark, so the accept now carries the second
opinion instead — `meta mark X oifname "dev"`, strictly narrower than either
half, true only when the routing did what the mark asked. The comment being
replaced argued correctly that oifname ALONE would be too loose, then drew
from that the conclusion that oifname should be dropped rather than added.

Same conjunction in the holding plane, where it is theory (that plane stamps
nothing) but where a bare mark accept has no business sitting.

Also folds the egress device resolution into one EgressDevice(), because the
binding and model.ValidateUntunnelableEgress had already drifted: the
validator trimmed the interface name and the binding did not, so `option
interface '   '` gave a panel saying "the option is ignored" over a data
plane that was marking packets for a table nobody built.

TestUntunnelableEgressAcceptIsBoundToItsDevice and
TestUntunnelableEgressResolutionLockstep fail on the code they replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:23:32 +03:00
omarandClaude Opus 5 61c87ad1d9 fix(l3): guard the ICMP honest-drop at PreMatch, not inside the walk
The drop that keeps a ping from reading as tunnelled lived in
preMatchFlow, overriding the pre-declared continueResult. That covered
every exit of THAT function and none of the walk above it: the
prepareMatchMetadata error return (which arrived later, with the shared
metadata refactor), the sniff bail-outs, and the default: arm of the
rule-action switch all returned PreMatchContinue on their own.
adapter.JudgeFlow maps Continue to tun.ActionAccept, and sing-tun answers
Accept by rewriting Echo into EchoReply itself -- the exact forgery this
delta exists to remove. Narrow paths, but paths.

PreMatch is now a funnel over the renamed preMatch walk, so the guard
sits on the single return value and cannot be outgrown by a new exit.
PreMatchBypass joins the drop: sing-tun implements ActionBypass on the
nfqueue plane only, so on the TUN path it lands in the same default: arm
as Accept and forges too.

Every ICMP case has an explicit TCP/UDP twin; the JudgeFlow mapping
table is pinned outright, including the one fix that must NOT be made
there -- refusing ActionFlow for a port whose address is not unspecified
would drop every ping through WireGuard/AWG, because the forward
dispatcher and the ICMP forwarder share that function with identical
arguments and only the latter needs an unspecified address.

That leaves a real hole open, now named in D25 rather than papered over:
a FRAGMENTED echo to a WireGuard/AWG outbound is still answered by the
router. The dispatcher returns before asking for a verdict at all when
the packet is a fragment, and the reassembled packet reaches the ICMP
forwarder, whose installFlow demands the unspecified address a WireGuard
endpoint never has. The two fixes that would close it both live outside
pre-match and are written down; the Consequence paragraph is scoped
until one lands.

The stack comment in generate/inbound.go repeated the "only gvisor
really forwards ICMP" argument that D25 itself retracts -- both stacks
run the same ForwardDispatcher first. Brought in line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 19:21:29 +03:00
omar 4dee508e12 fix(route): let a rule say "icmp", and say when saying it is a lie
`icmp` fell through ruleMatchers' proto switch into RawDefaultRule.Protocol —
the SNIFFED-L7 field, compared against what the sniffers labelled a connection.
Nothing ever labels a flow "icmp" (PreMatch skips the sniff action for an ICMP
flow outright), so the rule was structurally valid and permanently dead. That
made the whole L3 ingress unusable on a real config: with no way to write "ICMP
goes here", every ping fell to the catch-all, which resolves to the chain's last
hop — a group of VLESS nodes that cannot carry layer 3 at all.

icmp is a NETWORK. NetworkItem.Match is a map lookup over metadata.Network, and
adapter.JudgeFlow sets that to N.NetworkICMP for BOTH ICMPv4 and ICMPv6 (one
case covers both protocol numbers), so there is exactly one network value and it
covers both families. `icmpv4`/`icmpv6` narrow that same network with an
ip_version item instead of inventing a second one: metadata.IPVersion comes from
the destination address, and an ICMPv6 packet always has an IPv6 destination —
no false positives, no false negatives.

An ICMP rule that cannot fire is not a dead setting: ICMP has no fall-through,
so route.preMatchFlow DROPS it. Four ways to get that silently are now reported:
l3_tunnel off (nothing enters the engine at all), icmpv6 with ipv6 off (neither
the nft mark nor the TUN address exists), a port matcher next to it (JudgeFlow
zeroes both ports), and a target that cannot carry layer 3 — decidable from the
model, because the capability is fixed by the outbound TYPE: only wireguard/AWG
endpoints and the direct outbound behind direct/interface egresses declare
N.NetworkICMP. A mixed group gets its own text (the answer follows group.Now()),
`block` gets none (dropping the ping IS the policy), and an unresolved target
gets none either (ruleKillFallback already said the louder thing).

Wording stays clear of shater/apply's criticalMarkers on purpose: a failed ping
is fail-CLOSED, and a cosmetic alarm is how the real one stops being read.
2026-07-26 19:18:06 +03:00
omarandClaude Opus 5 76da5134ef test(gate): the two tests that need a kernel may not skip in silence
The L3 branch adds TestIntegrationL3TunInboundStarts and
TestIntegrationL3EgressICMPIsAFlow — the only tests that prove the engine
really opens shater-l3 and that the egress outbound really is a FlowOutbound.
Both need root plus /dev/net/tun, both guard themselves with t.Skip, and the
gate could not see either: `go test` prints `ok <pkg>` whether a test ran or
skipped, so [2/5]'s per-package `ok` check is satisfied and the gate closes by
claiming it "passes every test we own". That is this script's own founding
failure (115 of 116 test files never running while CI stayed green) one level
down, and it would have shipped invisibly.

Two halves.

Where the capability CAN be granted, grant it. From a non-linux host the gate
re-execs into a container; that container now gets --cap-add NET_ADMIN and
--device /dev/net/tun, probed rather than assumed, so a plain
`scripts/run-tests.sh` on a dev box actually exercises the kernel path instead
of quietly stepping over it.

Where it cannot, say so where it cannot be missed. The act_runner is an LXC
guest whose kernel has no tun module at all (checked on 10.10.10.211:
`modprobe tun` -> "Module tun not found", /dev/net does not exist, act_runner
runs job containers with privileged:false and no container.options), so the
device cannot be handed down without reconfiguring the Proxmox host. New step
[5/5] therefore DISCOVERS every ^TestIntegration under the fork's trees — no
hand-kept list, so a privileged test written next month joins on the day it is
named — runs them with -v, and demands a verdict for each BY NAME: RAN, or
FAILED/MISSING (fatal), or SKIPPED while the environment could have run it
(fatal, because the capability guard cannot be what skipped it), or skipped for
a reason this box genuinely has — which replaces the closing banner, so the
last line of the gate can never claim coverage it does not have.
SHATER_REQUIRE_PRIVILEGED=1 makes that last case fatal for runs that can.

The discovery call carries -ldflags for the same reason every other call does:
`go test -list` links each test binary, and without -checklinkname=0 every
package pulling common/badtls fails to link. The first cut of this step omitted
it, swallowed the error, and printed "none declared" — a check against silent
skipping that was itself silently skipping. Its exit status is now inspected
and an empty list is only ever reported after a successful enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 18:50:46 +03:00
omar 4c630c9a13 docs: handoff note for the L3 branch
Transient, to be deleted when omp/work merges. Everything meant to outlive the
merge is already in D25/D26 and the lx changelog; this file is the part that is
only useful while the branch is still a branch — the verification commands, the
testbed recipe, what was proven on hardware and what was not, and the six files
that will conflict on rebase.
2026-07-26 18:31:59 +03:00
omar d8dbefcd07 docs: record the AWG site-to-site path as declined, not impossible
D26's "no port-like selector" line disposes of NAT-based forwarding and nothing
else, and read alone it says "impossible" — which is false and would be
re-derived at the cost of another research pass. The endpoint is protocol-blind
in both directions, so ESP could ride it untouched with the client's own source
address and no NAT whatsoever. That was declined for two reasons worth naming:
lx-owned code in the forward hot path, and a server-side AllowedIPs prerequisite
that turns a router option into a deployment contract.
2026-07-26 18:31:59 +03:00
omar 974208fc05 docs: record the kernel egress, and retract the reason D25 gave for the ceiling
D26 writes down where the engine's boundary actually is, because the intuitive
answer is wrong and someone will look for it again: the WG/AWG forward path
never consults gVisor in either direction, so the limit is sing-tun's
ForwardDispatcher — its parser and its port-shaped NAT — and the kernel egress
was chosen because it clears that limit without a line of new hot-path code, not
because userspace "cannot". Tailscale documents the same boundary for their
userspace mode and is quoted as corroboration, with the caveat that ours sits at
the dispatcher rather than the stack.

D25 said two things that do not survive checking, and both are corrected in
place rather than left for the next reader to trip over. It blamed the netstack
for the ICMP-echo ceiling; that was the dispatcher. And it called `stack: gvisor`
mandatory because the system stack fakes ping — the system stack runs the very
same dispatcher first and only forges an echo for packets the dispatcher
declined, so gvisor is a deliberate choice (already linked via with_wireguard,
and the combination the integration test exercises), not a necessity.

The operator note says what the option buys and refuses to call an egress a
tunnel on its own say-so: with a WireGuard device it is one, with a second WAN
the destination sees that uplink's address. It also says what the option does
not fix — multicast IPTV stays broken — and that IPsec through NAT-T is ordinary
UDP that never needed any of this.
2026-07-26 18:31:59 +03:00
omar 2eb71e8244 feat(netplane,model): hand the protocols the engine will not dispatch to the kernel
ESP, AH, GRE, IGMP and SCTP cannot enter the engine, and the reason is not the
one that looks obvious. A WireGuard or AmneziaWG endpoint forwards straight past
its gVisor stack — WritePackets reads the IP version and the destination address
and hands the raw bytes to the device, and the return path offers every
decrypted packet back before the stack sees it. WireGuard would carry ESP today
if anything handed it one. What refuses is sing-tun's ForwardDispatcher: its
parser recognises TCP, UDP and ICMP echo, and its NAT wants a port-shaped
selector that ESP, AH and GRE do not have. The retracted rationale is corrected
where it was written down, not quietly dropped.

So these protocols go to the kernel instead. untunnelable_egress names an
interface or tunnel egress; prerouting stamps that egress's OWN mark on
everything that is not TCP or UDP, and addEgressRouting has already bound that
mark to a table whose default route leaves via the device. Every protocol works
because nothing in the path has to understand any of them. No new mark, no new
table, no new code in the hot path.

Whether that is a tunnel depends on the device, and nothing here claims
otherwise: a WireGuard interface is one, a second WAN is a different uplink
whose real address the far end sees.

The wide `!= { tcp, udp }` filter is safe here and stays banned for the L3
ingress, for the same reason stated in both places: there the receiver is a
dispatcher that knows four protocols, here it is the kernel. ICMP is claimed by
the L3 ingress first when both are on. The local plane keeps its exclusions —
router-addressed traffic, private destinations, ICMPv6 ND/RA — and with IPv6 off
the marking is scoped to v4, because addEgressRouting installs no v6 rule then
and a marked v6 packet would fall into the main table.

An interface egress with an empty `interface` no longer resolves: IfaceDevice
defaults to br-lan, so it passed the binding while addEgressRouting skipped it —
mark set, no rule, straight past a closed kill switch and out the default WAN.
2026-07-26 18:31:59 +03:00
omar 668cccbf24 test(generate): the L3 device name is a singleton, so wait for the kernel to take it back
Both gated tests stand an engine up on shater-l3. Run together, the second met
`TUNSETIFF: device or resource busy` and failed for a reason that had nothing to
do with what it asserts — the first had closed its box and yielded while
unregister_netdevice was still catching up. Each passed alone, which is the
shape of a fixture bug that gets rediscovered rather than fixed.

The poll that already guarded the first test is now a shared helper both call.
It stays a poll rather than a sleep for the reason it always was: the removal is
usually immediate and a fixed wait would be either flaky or slow.
2026-07-26 18:31:59 +03:00
omar 4ea4585402 test(generate): pin that ping through an interface egress is real, and byedpi's is not
An interface egress is a direct outbound carrying BindInterface and a routing
mark, and direct builds its ICMP port from the very same dialer control — so
ping routed at that egress leaves through that device, marked, like every other
packet bound to it. Nothing said so. Both halves of that sentence are one
`common.Cast[*dialer.DefaultDialer]` away from being false: if the dialer ever
stops being a DefaultDialer, icmpPort is nil, PreMatchFlow declines, and ping
through the egress degrades to a drop without a single generated byte changing.
The gated test asserts the live outbound, not the config, because that is where
the cast happens.

The failure the codegen half guards is worse than a broken ping: losing
BindInterface or the mark does not stop the echo, it sends it out the main table
over the plain WAN with the real address, which is the one thing an egress
exists to prevent.

byedpi is a SOCKS outbound and cannot be a tun.Port, so ICMP aimed at it is
dropped. That is the honest end of l3-honest-drop and it is pinned too, because
the alternative the TUN stack offers is a forged reply.
2026-07-26 18:31:59 +03:00
omar dc6d102473 docs: put a number on the second netstack, and say what it does not bound
Measured on a throwaway harness in a container: peak RSS of a process that
brought the engine up went from ~26 MB to ~28 MB with l3_tunnel on, three
paired runs. It is x86_64, idle, with an empty ICMP NAT table, so it stays
listed as unverified for the router — an indicative figure is more useful than
silence only if it says loudly what it is not.
2026-07-26 18:31:58 +03:00
omar 683afc0a47 docs: record how ping got through the tunnel, and where it stops
D25 writes down the reasoning that is expensive to reconstruct: why a TUN rather
than TPROXY, why the interface is its own with auto_route off, why gvisor is
mandatory rather than preferred, and why the ceiling is ICMP echo — a boundary
in sing-tun's flow parser and gVisor's protocol set, not an unfinished edge of
ours. It also records what carries layer 3 and what does not, that masque could
and does not, and the two things still unproven: the live-router path end to
end, and what a second gVisor NIC costs in memory on the hardware.

D17 gains one line: its claim that TPROXY cannot carry ICMP is still true, and
is no longer the end of the story.
2026-07-26 18:31:58 +03:00
omar 2c3e20512e feat(openwrt): let fw4 know the L3 tunnel device before it exists
Both nft tables run and a drop in either one wins, so our forward accept for
shater-l3 decides nothing on its own: fw4 sees a device in no zone and drops the
forward, and the feature fails with exactly the symptom it was built to fix —
ping does not work, and nothing says why.

The zone names the device directly rather than a network. fw4 resolves a zone's
networks through netifd, and a proto-none interface for a device the daemon
creates is never up and contributes nothing, so list network would compile to an
empty device set. list device compiles to a plain iifname/oifname match that is
valid before the TUN exists and starts matching the moment shaterd creates it,
with no firewall reload at enable time.

It is seeded unconditionally, not gated on l3_tunnel: uci-defaults run once, and
a zone naming an absent device is inert. Gating it would mean the option could
be switched on and never take effect. The sections are named so a re-run is a
no-op instead of a second zone, and kmod-tun joins DEPENDS because /dev/net/tun
is not on a stock image.
2026-07-26 18:31:58 +03:00
omar 51b2f04672 feat(netplane,generate): carry LAN ping through the tunnel, on a TUN of its own
Kernel TPROXY needs a socket to hand a packet to, so it moves TCP and UDP and
nothing else. Everything else reached the forward chain and met the untunnelable
policy, whose best answer was "let it out with your real address" and whose
default was "drop it" — so on a stock install ping simply did not work, and the
setting that fixed it did so by leaking.

The engine has been able to do better for a while: sing-tun's ForwardDispatcher
does real ICMP forwarding with NAT on the echo id, and a WireGuard or AmneziaWG
endpoint is a tun.Port that carries the packet for real. What was missing was a
way in, because nothing on the router could hand it an IP packet.

l3_tunnel (opt-in, off by default) adds one: the generator emits an "l3-in" TUN
inbound and prerouting fwmarks LAN ICMP into it. The interface is its own and
auto_route is off, so the main routing table is never touched and the fwmark
plus addL3Routing's ip rule are the only entrance — the TPROXY plane is byte for
byte what it was. gvisor is not a preference: the system stack forges echo
replies locally, which is the very thing this is meant to end.

Only icmp and ipv6-icmp are ever marked, and only after the local plane is out
of the way — the router itself, private destinations, and ICMPv6 ND/RA, which
mean nothing off-link and take v6 down if one neighbour probe is tunnelled.
ESP, AH, GRE, IGMP and SCTP are deliberately left alone: sing-tun's parser and
gVisor's stack know no such protocol, so marking them would black-hole the
traffic while looking like a feature. They stay with the untunnelable policy,
which also keeps its say over what happens if the ip rule fails to install.

Ping and Windows tracert now cross the tunnel; IPv6 traceroute shows only the
destination, because the return path recognises TimeExceeded for v4 alone.
2026-07-26 18:31:58 +03:00
omar f190c8251e feat(lx): stop answering ping on behalf of a tunnel that never saw it
PreMatchContinue is not "fall back to the ordinary route" the way it is for TCP
and UDP. An ICMP flow has no ordinary route: the TUN stack takes the packet back
and answers the echo itself, swapping the addresses and writing a reply
(sing-tun stack_gvisor_icmp.go). So a ping routed to any outbound that cannot
carry layer 3 — every proxy protocol; only adapter.FlowOutbound can — came back
successful, and the operator read a working tunnel off a packet that was never
sent.

That is worse than the packet loss it replaced. Loss is a fault the operator can
see and chase; a forged reply is a fault that reports itself as health, and it
reports it on the one tool anyone reaches for first.

preMatchFlow now overrides continueResult once, at the top, for
N.NetworkICMP. One hunk covers every exit that used to fall through — no such
outbound, a group whose selection is gone, an outbound whose Network() omits
icmp, an outbound that is not a FlowOutbound — and keeps the diff to three lines
against a function upstream will keep editing. JudgeFlow carries the same
verdict in its !isPort branch, because FlowOutbound and tun.Port are separate
interfaces and drift between them must not reopen the forgery.

TCP and UDP are untouched, and the test pins that as hard as it pins the drop.
2026-07-26 18:31:58 +03:00
omarandClaude Opus 5 1945404eaa fix(armor): a reboot is not someone switching the product off
test / go + panel tests (push) Successful in 5m24s
release / test gate (push) Successful in 5m24s
release / apk aarch64_cortex-a53 (push) Successful in 3m9s
release / apk x86_64 (push) Successful in 3m9s
release / release apk (push) Successful in 8s
The boot armor never armed on the router it shipped to. procd runs the
K-links on the way down with the action `shutdown`, and stop_service
classified actions with an OPEN default:

    case $action in restart|reload) keep;; *) DISARM;; esac

`shutdown` matched nobody, fell into `*`, and deleted the arm token. The
mechanism erased itself at exactly the transition it exists for, so every
boot found nothing to load. Measured on the live router, one minute apart
across a reboot:

    13:28  /etc/shater/boot.nft present
    ----   reboot
    18s    at_S22: NO_TABLE  armor_file=NO_FILE

It did not fail every time, which is worse than failing always: on the way
down `rm` from this script raced a `SaveBootArmor` driven by the ifdown
hotplug storm, and whichever landed second won. Two reboots on the same box
an hour apart gave opposite outcomes.

Both lists are now positive and CLOSED. Only `stop` disarms; only
`restart`/`reload` hand off. An action nobody thought of changes nothing,
so the default now fails toward a boot that arms when it need not have --
recoverable in the second before the daemon applies, and still gated by
shater-armor's four state refusals. The old default failed toward the
plaintext window the feature was built to close.

Also closed, found while proving the above:

  * Every restart left the LAN in the clear for 80-90ms. The exit path was
    `Teardown(); armOnExit()`, and TeardownNft DELETES the table -- two nft
    transactions with no `inet shater` between them, leaving fw4's
    `lan -> wan ACCEPT` as the only policy. Every restart, every LuCI Save
    & Apply. TeardownExiting arms first under the apply lock and skips the
    delete iff a plane actually went in; RenderHoldNft is one `nft -f` that
    REPLACES the table, so the kernel never observes its absence.
    35k-sample instrument: 7 and 6 no-table hits before, 0 across three
    runs after.

  * SaveBootArmor fsynced the payload but not the directory, so a power cut
    could lose the rename that publishes it -- a boot with no armor and no
    error anywhere.

`stop` now also reads rc.d state, so a package transaction that stops the
service is not mistaken for a person switching it off. This one does not
reproduce on apk (it runs no pre-upgrade script and never calls prerm on an
upgrade; verified with apk adbdump and 245k samples across a real reinstall)
-- it is one returning opkg lane away from being live, and the removal case
is now stated rather than implicit.

Both new tests are mutation-checked: reverting the predicate fails naming
`shutdown`; reverting the teardown fails with `did [arm delete], want [arm]`.
initscript_test.go sources the SHIPPED shell and calls the real predicates
with every action procd uses -- a comment claiming `shutdown` was handled is
what shipped last time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
2026-07-26 17:33:53 +03:00
omarandClaude Opus 5 6476722372 fix(panel): stop shipping a fabricated router in the binary
test / go + panel tests (push) Successful in 5m26s
release / test gate (push) Successful in 5m28s
release / apk aarch64_cortex-a53 (push) Successful in 6m7s
release / apk x86_64 (push) Successful in 3m5s
release / release apk (push) Successful in 7s
mock.ts was a static import and the mock switch was read from the query string at
runtime, so the bundle that ships inside the daemon carried a complete fictional
router and a link ending in ?dev rendered it: protected, 119 of 122 nodes alive,
without a single request to the daemon. The only tell was a line in the footer.
That is worse than any wrong number — there is no data at all and nothing says
so. It is out of the production bundle now, which is 21 kB smaller for it.

Unknown state stopped reading as good news in two more places. The kill-switch
tile treated an absent plane as armed, because the check was "not none" and
undefined satisfies it — the contract in the API types says the opposite. And the
apply page announced "daemon auto-rolled back" from its own timer, while the
daemon, seeing the state generation move, disarms and says it is NOT rolling back
in the log only.

Alerts moved to Settings. They are about the kill switch, apply failures, new
devices and subscription expiry, and they lived at the bottom of the DNS page,
while Settings mentioned them in prose with nothing to click.

Findings truncation is visible now: the notice that says how many were suppressed
arrives as info, and the attention list keeps only critical and warning, so past
fifty findings the operator saw forty-nine and no hint of the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:40:39 +03:00
omarandClaude Opus 5 4078334d85 fix(stats,alert,panel): put a ceiling on everything that only grew
Four maps had no bound on a box with 512 MB that runs for months. The health
board only ever inserted — the delete exists but no path in this fork calls it —
and it lives on the engine context, so it outlives every generation. Its keys are
node tags, and providers rename nodes on each subscription refresh: about 440k
keys a year, some 88 MB. Alert dedup keyed on MAC with no delete at all. The
stats aggregator's server and outbound counters were the only ones with no cap,
no prune and no top-N, and one of them was handed to the panel whole on every
poll.

They are bounded now, evicting least-recently-seen, with numbers argued from this
box rather than round: the board holds 4096 against a live generation of about
1200 tags, so a rename day cannot evict a tag still in use. Nothing is dropped
silently — the same rule the log sink already follows — and a new Dropped section
in the snapshot reports all six bounded aggregates, including the three that had
been evicting without saying so.

Snapshot did O(devices × domains) under the aggregator lock, sorting five
thousand entries to show fifteen, and could read the DHCP lease file from inside
it. Meanwhile the event subscribers have 64-slot buffers that drop without a
counter, so an open Overview page cost the query log real rows. Selection is
top-K now — proven byte-identical to the old sort over 200 random trials — and
both the lease read and the row ordering happen outside the lock.

The panel server had one timeout, on headers. An unauthenticated client could
hold a goroutine, a socket and a descriptor forever by sending its body one byte
at a time; a stopped reader on the log stream held the handler, the pipe and a
child process that outlived the request. Every phase is bounded now, with the
unauthenticated route on a tighter budget than the rest, and the log stream
renewing its deadline per chunk so a slow-but-reading client is never truncated.

And the last of the detour transports: each call built a fresh one, and the alert
delivery path dropped it, pinning keep-alive sessions through the engine's own
outbounds for 90 seconds — eighteen times the budget a retiring generation gets.

The race skip is gone from the gate. The test it existed for raced in its own
clock, not in the product; that is fixed, so nothing is excluded under -race any
more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:40:21 +03:00
omarandClaude Opus 5 0a6689b29e fix(quic,v2ray): close the sockets quic-go was never going to close
DialEarly with a packet conn the caller made sets a flag that means quic-go does
not own it: closing the transport only stops reading from the socket. Neither DNS
transport closed it. On the QUIC one it was closed on a failed handshake and
never on success, so every redial — idle timeout, retry error, engine reload —
left a UDP socket for the life of the process. On the HTTP/3 one the library
drives its own reconnects, so the leak compounds without anything in our code
looking wrong.

That is the same shape as v2rayquic's, where offerNew overwrote the raw conn on
every reconnect without closing the previous one. Both are now owned by a watcher
tied to the connection's own context, so the socket lives exactly as long as the
connection does.

This matters more than it did last week: the shipped resolvers are DoH, and DNS
is intercepted by default now, so the whole network's query stream rides this
path on a router with 512 MB.

The same upstream commit fixes both halves. We had taken the v2ray half and not
the DNS one — the third time this session a paired fix arrived half-applied, and
the first of those cost a day of debugging. These two files are now byte-identical
to upstream so a rebase cannot reopen it.

Also from that family: websocket and httpupgrade leaked their conn on failed
handshakes, and a QUIC stream's Close did not release a blocked write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:59 +03:00
omarandClaude Opus 5 ef22167b1a fix(apply): report the hold when the plane was armed by someone else
Booting with the armor loaded, or restarting through the handoff, left the status
saying the LAN was not being held while it was being dropped. Transient after a
successful apply, but permanent on the unreadable-config path — and there the
apply-failure alert words itself "traffic is NOT being blocked" at the exact
moment it is. That sends the operator to fix something that is not broken, past
the protection that is holding.

The table cannot be identified from here — netplane exposes no read-back and nft
does not keep comments — but identifying it is the wrong question. Holding does
not claim the holding plane is the object in the kernel; it claims the engine is
down and forwarded traffic is being dropped. A leftover full ruleset does that
too: with no engine socket the tproxy statement breaks its own rule before the
accept, so the packet reaches the forward chain unmarked and meets the primary
drop. What decides it is whether the last applied config was enabled and
fail-closed, which is exactly what the boot armor's presence already means.

So it is derived at read time rather than latched. A latch set from an inference
would have to be remembered in order to be cleared, which is the trap the active
flag already taught us. ArmHold also stops deferring to a table it cannot
inspect and installs its own render instead — the honest answer to "do not claim
a foreign table blindly" is to make it ours, and a fresh render beats a snapshot
that predates an interface rename.

Also closes the last of the detour transports: the subscription fetch took a
client and dropped it, and the exits that leak are the error ones, retried by
cron forever against a broken feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:39 +03:00
omarandClaude Opus 5 cbda0fee0a fix(netplane): arm the fail-closed plane before the daemon can
The plane only ever existed while the daemon did. It starts at 99, after fw4 has
already loaded lan→wan ACCEPT, and only reaches ArmHold after waiting out its
predecessor, migrating the schema, building the engine and reading UCI — with a
UPX-compressed binary decompressing off flash first. Every boot therefore had a
window with no protection at all, landing exactly when Wi-Fi comes up and every
client reconnects. A restart, a reload or a package upgrade opened the same
window on purpose: Teardown does not consult the kill switch, and the init script
guarantees the interval is non-empty.

The holding plane is now persisted to /etc/shater/boot.nft on every apply and
loaded by a small service at 21, right after fw4 and netifd. Its presence is the
arm token: it exists only while the last applied config was enabled AND
fail-closed, and goes away the moment either stops being true. Writes are
content-gated — the cron reconcile runs a minute — and atomic, because the one
boot that reads this file is the boot after a power cut.

The service refuses to arm four ways so it can never brick a box, and its
enabled-check reads /etc/rc.d directly rather than asking rc.common, which would
take a blocking flock in the middle of boot. On exit the daemon re-arms only for
restart and reload, read from a snapshot of rc.common's action; anything else,
including an unknown one, degrades to a real stop that also disarms.

An unreadable config used to leave the router bare forever: the arm call sat in
the branch that requires a successful read, and nothing downstream could recover
it. It now arms from the same path.

A network nobody named was neither diverted nor blocked — the divert set is built
from inbounds and rule sources, and the same set scopes the fail-closed drops. It
is now enumerated from the interfaces whose firewall zone the operator forwards
to a WAN zone — their own statement that those clients reach the internet through
this box — and reported critically, by name, with both resolutions. Deliberately
not closed automatically: this router cannot know a guest SSID was meant to be
off the tunnel, and guessing is an outage. A device name that resolved to nothing
is reported the same way, for the same reason: there is no fail-closed action
available for a device we cannot name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:16 +03:00
omarandClaude Opus 5 7234817adb fix(panel): flush the log before serving it
test / go + panel tests (push) Successful in 4m59s
release / test gate (push) Successful in 4m58s
release / apk aarch64_cortex-a53 (push) Successful in 3m5s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 7s
Splitting the log sink made its writes asynchronous, so a download could miss
the last lines still in the queue — silently, with a successful response. Those
are the lines the operator came for: a log is downloaded to find out what just
happened.

The panel is handed a barrier, not the sink: a func() set once at startup, the
same shape as the reconfigure hook and the stats setter already in the tree. It
cannot write, reconfigure or close, so it stays a consumer, and nothing about
the sink's type reaches it.

The wait is bounded at the sink's own control budget and enforced on the panel
side, so a wedged writer cannot turn the download into the new place the daemon
gets stuck — the very thing the async split was for. Past the bound the handler
serves what is on disk. With no barrier installed the path behaves as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:27:42 +03:00
omarandClaude Opus 5 f1c36d6eea fix(panel): say when the engine is down, and ask before the irreversible
The header could not render "offline": it keyed on a field the daemon pinned to
true, so a dead engine behind a fail-closed plane showed a pulsing green lamp.
Health now needs both signals to agree before it reads as up, and a negative
from either is enough to say down — which is honest against the field that was
already honest, and stays honest now that the other one is too.

Deleting the last catch-all rule was described as "traffic will fall through to
the next rule" on the very row the page badges as the default route. What
happens instead is the kill switch: closed, the network loses the internet;
open, it leaves with the real address. The dialog now says which, by reading the
saved setting, and the toggle asks the same question — the generator only emits
enabled rules, so switching it off is the same event.

The master switch tore the whole plane down without a word, while deleting a
rule-set got a confirmation. Deleting a node or a resolver claimed to remove it
"from the config" without mentioning what still points at it, though the
reference finder was already there and used for renames.

Every Apply button armed the auto-rollback, and only one page said so. The
window is now recorded where all of them pass through, carried in a band under
the nav on every route, and persisted — so the countdown and the keep button
survive a reload, which is what made the window unconfirmable before. Overview's
Confirm button is gone rather than gated: Confirm cannot fail, so a permanently
live button could only ever report success.

Blocklists printed "filtering" from two config checkboxes without asking whether
the list had ever loaded — while the daemon grades a failed load critical. They
now show what the rule-set rows already showed, and say "not loaded — nothing
blocked" when that is the truth.

Also: the clock read UTC while every timestamp rendered in the browser's zone,
so the router appeared to have started in the future; the rule counter on
Overview counted saved rules rather than the ones in force, unlike the routing
page; and the hop badge counted the entry egress the rail below it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:27:28 +03:00
omarandClaude Opus 5 bb21ceb7f5 fix(lifecycle): a busy file is not a broken one, and three ways to lose state
Changing any stats knob on the persistent backend deleted months of history.
The replacement store was opened before the outgoing one was closed, so it hit
the first one's flock, timed out — and the open path treated ANY error as
corruption and unlinked the file. Unlink of an open file succeeds on Linux, so
the new ring opened an empty database while the panel was still told the
backend had not changed. The store now hands its resources over before asking
for them again, and deletion is gated on an allow-list of real corruption
signals; a busy, unreadable or read-only file degrades to the in-RAM ring and is
left alone.

The holder's reads were unguarded in a subtler way, caught only after the gate
failed twice: the accessor took the read lock, returned the pointer and released
it, so the call ran outside. A reader could hold a store the swap then closed and
be served its empty answer — an empty page presented as data. The accessor is
gone entirely, along with the possibility of handing out an unguarded reference.
Readers still do not block each other; the swap now waits out reads already in
flight, which is a page at most.

The urltest group published its chosen node through two plain fields written by
the prober and read on every dial and every panel poll — while the selector next
door does the same job atomically. They are one value now, so TCP and UDP can no
longer be read as a mismatched pair. Nothing had ever dialled through a group
while it was probing, which is why the detector had never seen it; a test now
does, and reproduces it deterministically against the old shape.

Close on a group whose ticker had already stopped returned before closing its
channel, and Touch would then arm a fresh loop nothing could stop. Reached by
pressing Test in the panel and applying a config within the next two minutes: the
orphan kept failing probes against a cancelled context and writing forged dead
verdicts into the board the live generation selects from. Close is now final.

The log sink held one mutex across a blocking write. Under procd stderr is a
pipe, so a reader that stopped draining wedged everything that logs — engine,
panel handlers, signal loop — while the process still answered a signal. It is
split: a front that assembles lines and a writer that owns the destinations,
joined by a bounded queue that drops and counts rather than blocking. Proven by
restoring the old shape: the package deadlocks for the full ten-minute timeout,
parked exactly where the field symptom said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:27:06 +03:00
omarandClaude Opus 5 a8970b8ace fix(apply): stop the status from reporting a state the daemon is not in
Running was the constant true. The panel builds its header from it, so the
"offline" branch was unreachable code: with the engine dead and the LAN behind
a fail-closed hold, the operator saw a pulsing green lamp and, on the page
people open to fix things, "engine: running". The honest field sat beside it,
documented as the honest answer to are-we-proxying, and was read nowhere.

running now means shater is running: the daemon answered and its engine has a
started instance. active stays what it always was and is documented as such —
the "meant to be running" latch that gates hotplug and cron, not a health
signal. It is deliberately not cleared on hold, because the cron loop gates on
it and clearing it would switch off the reconcile that brings the engine back.

Two paths published nothing and so left the previous config's verdict standing
for as long as the fault lasted. A rollback with no snapshot re-applied the
engine and the plane and never touched the traffic verdict, so a router rolled
back to a direct default kept reporting the tunnel. And an apply that failed in
the netplane stage had already swapped the engine, then returned before every
publisher, so status described the config that was no longer running — and the
next reconcile, seeing an unchanged hash, failed the same way and published
nothing again. Both now publish, with an unknown verdict: after a no-snapshot
rollback the engine runs options this process does not hold, and guessing from
UCI would describe the config we rolled away from.

The severity classifier had drifted from the texts production emits. Markers
were compared case-sensitively against wording that had since changed, and the
entity pattern could not match a message beginning with an upper-case tag —
so a blocklist that failed to load graded as a warning while a typo in its URL
graded critical, and the panel's banner, which only lights for criticals, stayed
dark for the outage. RULESET-NOT-APPLIED and DNS-FILTER-NOT-APPLIED are now read
as the structural markers their producer documents them to be, so severity no
longer depends on wording at all. Five markers that matched no living text are
deleted; three protection-section texts drop to warning, because a blocklist
that is stale but still blocking lights the alarm on most reconciles behind a
flaky link, and an alarm that is always on is how the real one goes unread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:26:40 +03:00
omarandClaude Opus 5 4996bc0984 fix(untunnelable): make block actually block
The block policy collapsed into direct whenever routing's final target was
direct — the common "tunnel only what is blocked, everything else direct"
shape. So ICMP, ESP, AH, GRE, IGMP and SCTP left with the client's real
address under the setting whose own field doc promises "nothing ever leaves
with the client's real IP", including a standing VPN on the real address,
which is exactly what the middle rung exists to separate out.

Both ends of the ladder now short-circuit before the plan is consulted and
neither may consult it: direct accepts everything, block emits no line at all
and lets the fail-closed drops the caller writes next do the work.

A rule scoped by source could also widen the other family: emit() skipped a
family whose destination list was empty but not one whose source list was, so
a rule carrying only IPv6 source prefixes rendered an IPv4 line with no
ip saddr clause — an accept for every IPv4 host on the LAN. The two halves now
read "scoped" the same way the catch-all collapse already did.

No destination plan is built for block at all now. It is the shipped default,
and a geoip-backed plan is ~159 000 prefixes pushed into kernel memory and the
ruleset text for a policy that cannot use them.

The operator-facing texts said IPTV works. It does not, on any of the three
rungs: inbound multicast is never matched by these rules and a client's
outbound multicast UDP dies at the fail-closed guard regardless. Saying
otherwise invited trading the ESP/GRE block away for nothing. What actually
stops working under block is stated instead, and precisely: raw ESP/AH and
GRE, but not IPsec through NAT or any UDP VPN, which are ordinary tunnelled
traffic.

TestOnlyPinnedAddressIsTunnelled is how this hid: it asserted, on the default
policy, that an exception line was emitted, and read that as the feature
working. It was block rendering direct. Its render assertions move to icmp,
where they mean something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:26:07 +03:00
omarandClaude Opus 5 a0de597d69 feat(dns): intercept by default, and bootstrap node addresses off the tunnel
test / go + panel tests (push) Successful in 4m56s
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
omarandClaude Opus 5 544da29863 fix(dns,netplane): close three paths that sent traffic out in the clear
A resolver whose detour no longer resolved fell back to "the default outbound",
which is not a default at all — it is a plain system socket. Every other place
in this generator fails such a reference closed, with an essay explaining why,
and wgdedup rewrites the very same field to block when it drops an endpoint. One
field, two opposite policies, and which one applied depended on whichever code
noticed the breakage first. A resolver detoured through a node the operator
switched off therefore handed the whole network's query stream to the ISP in the
clear, while the kill switch held the traffic itself.

It now fails closed, and the warning says what that means: the resolver answers
nothing, and if it is the default one, name resolution stops network-wide until
the target is restored. A dns_rule naming a missing resolver used to be dropped
whole, sending exactly the names the operator singled out to a resolver they did
not choose; it keeps its matchers and answers NXDOMAIN instead. Not a reject
action — one built in Go with an unset Method panics the engine at match time.

RoutingPresent never looked at per-egress rules or tables, and applyLocked skips
the whole routing stage on its word. So an egress table wiped by an ifdown was
never restored: the marked traffic fell through to main and left over the plain
WAN, permanently, with plane full and no warnings. It now verifies each binding
it installed, recording intent rather than outcome so a broken egress keeps the
plane reported absent and heals when the interface returns.

addEgressRouting discarded every ip error, so an egress that failed to install
reported success and the panel drew it green. Failures are now critical warnings
naming the egress, the device and what ip said — but still warnings, because
returning would abort the apply and punish the household for one bad uplink.

Also anchors the fwmark check: with a small fwmark_base the main mark is a
literal prefix of the first egress mark, so a substring match could answer "the
main rule is installed" while looking at an egress rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:53:53 +03:00
omarandClaude Opus 5 daaa0fda41 fix(wireguard): stop holding AmneziaWG down behind a WireGuard hop
The guard refused to start an AmneziaWG endpoint whose detour chain reached a
WireGuard one, and refused silently: not an error, just started=false, after
which every dial failed with "WireGuard is not ready yet". A selector hook went
further and suspended an already-working node the moment its group switched to a
WireGuard member.

It existed because AmneziaWG inside WireGuard hung the kernel on Android. We do
not ship Android, upstream dropped the guard once the cause was gone, and the
cure landed here yesterday — the ClientBind reserved-gate plus the submodule pin
that carries its twin. So the tree held both the cure and the prohibition on
using it, and the configuration simply did not come up while looking like a node
that "just does not work".

Also takes the two fixes that belong with it. ClientBind.conn was read on a
lock-free fast path and written under a mutex; upstream found that race with the
same end-to-end test we wrote yesterday, so we had taken one half of a pair
again. And the outer WireGuard UDP socket forced DF, unlike direct, hysteria and
tuic — with encapsulation the datagram regularly exceeds the path MTU and the
kernel drops it instead of fragmenting, a symptom indistinguishable from the bug
we spent yesterday on.

The race needed its own test: the existing e2e run did not flag it under -race
even at -count=15. Eight goroutines over both connect branches reproduce it
deterministically, naming the lock-free read and the guarded write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:53:32 +03:00
omarandClaude Opus 5 56a276bcc1 ci: make the tests a gate instead of a decoration
The fork had a full suite and no CI that ran it. Upstream's test workflows
trigger on stable/testing/unstable; this repo only has main. And Gitea does not
read .github/workflows at all once .gitea/workflows exists, so those files were
decoration here. 115 of the 116 test files under shater/** had never executed in
CI even once, which is how TestDNSFilterRemoteBlocklistHTTPClient stayed red
across two published releases without anyone noticing.

The gate is a job inside release.yml that build-apk needs, because a separate
workflow cannot block another one. It runs the suite under the shipped tag set,
on Linux — 6 of 7 test files in transport/wireguard and 12 in shater/generate
compile only there or only under those tags, and those are exactly the files
covering AmneziaWG.

Three guards stop it from passing by running nothing, which is the failure this
whole change is about. The tag set may only ADD test files, never remove one.
Every package go list says has tests must appear as "ok <pkg>" in the output, so
a suite that collapses to "no test files" fails instead of passing. And the
panel run counts its test files first, because node --test exits 0 with "pass 0"
when the glob matches nothing.

The publish step used to exit 0 having published nothing: its assertions all
live inside a loop over artifacts, so an empty directory ran the body zero times
and reported success. It now counts what it published and fails on zero.

Verified by extracting the shipped step text and running it against stubs: empty
artifacts gives exit 0 before and exit 10 after; the rolling-release readback
still fires its own exit 14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 04:53:14 +03:00
omarandClaude Opus 5 754bbcf1fa fix(submodule): point .gitmodules at the line the pin is actually on
The wireguard-go submodule is pinned to 7d15f33, which lives on lx-awg2-v005.
.gitmodules named `lx` — a separate line, 42 commits one way and 131 the other,
with no common recent history.

That is a loaded gun rather than a cosmetic mismatch. `lx` has no hasReserved()
gate in conn/bind_std.go at all, so a single `git submodule update --remote`
would move the pin there and silently restore the defect fixed yesterday: the
bind shreds the AmneziaWG magic header of every transport packet, handshakes
complete, no data moves, and no chain containing an AmneziaWG node carries
traffic. It would also drop the padding-overrun fix and the v0.0.5 re-graft.

Nothing about the checked-out tree changes — the pin is untouched. Only the
branch a --remote update would follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 03:18:21 +03:00
omarandClaude Opus 5 63e6b709f8 fix(health): a chain blocked at a hop is dead on the board, unknown on the card
release / apk aarch64_cortex-a53 (push) Successful in 3m4s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 7s
The short-circuit left the chain's exit tag untested, because the exit is itself
a hop and every hop behind the break was rewritten that way. A stand run caught
it — the test lives in a file that does not compile on the dev host, so nothing
local could have.

That is not neutral silence. selectExcluding ranks untested ABOVE dead and says
so in its own comment: with no fresh-alive member, an untested one is a better
bet than a known-dead one. Leaving a provably broken path untested is therefore
a positive preference for it over a path we merely know is dead.

The two readings answer different questions and now differ on purpose. Is this
hop's own node alive — unknown behind a break, so the card keeps untested and
blocked_by. Can this chain carry traffic — known, no, because the hop in front
of it was probed and did not answer. The board carries that second answer, which
is the one selection, the freshness gate and the manual test all read.

The exit verdict is derived, not dialled: it records the consequence of a probe
that did happen one hop earlier, and it is re-derived every pass, so the moment
the blocker answers the walk reaches the exit again and the next verdict there is
a real measurement.

Also keeps a routed group warm. Its checker used to stop on the idle timeout and
nothing filled in behind it, so a rule that fires rarely would show untested
while being in force and pay a cold probe on the first real request. The gate
that adds this work answers false when it does not know — the mirror of the one
that withholds work, so plain sing-box keeps the lifecycle it always had.

And the tls-spoof suite now skips without tcpdump instead of failing sixteen
times: a missing tool is not measured, not broken. The same distinction this
commit is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:48:26 +03:00
omarandClaude Opus 5 8612b0a9e9 fix(health): one dialler per target, and it is the group's own checker
A member of a chain hop wrapper was reachable by two probers: ours, from the
observatory plan, and sing-box's, from the urltest group the wrapper actually
is. Two independent readings of one node can disagree, and then neither can be
trusted — which is worse than the wasted dial.

The group's own checker is the right owner. A hop wrapper's members are the
per-chain copies, each carrying the previous hop as its detour, so that checker
already travels the chain prefix — the path the traffic takes. The plan now
records who dials each target and the observatory skips the ones a live checker
owns, keeping only what no group covers: node hops, the AmneziaWG endpoint,
selector members, and the members of groups that have been stood down.

The jobs stay in the plan rather than being deleted, and that is load-bearing:
the short-circuit reads the plan as the map of which tags measure which hop, so
deleting a urltest hop's members would erase that hop from the map and quietly
stop it blocking anything — on exactly the chains the feature exists for.

The short-circuit therefore moves to the group as well, through a ProbeGate the
engine implements: a scheduled check asks whether the path in front of it is up
before dialling, while an explicit check is never refused. Nothing is stored —
the gate recomputes from the live board every call — and Touch still arms the
ticker even while blocked, because a hop that refuses to tick has nothing left
to notice its own recovery. The gate answers yes whenever it does not know:
refusing on missing information is how a system talks itself into silence.

Two grounds now exist for a group not to probe and they must not be merged:
stood down means no rule reaches it at all, blocked means the path in front is
down right now. Both doc comments say so and name the chain hop wrapper as the
case where the difference bites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:27:00 +03:00
omarandClaude Opus 5 96d9cfaa63 fix(health): stop probing a chain below a hop that is already down
Hop probes were independent, so every hop was dialled whether or not the path
to it existed. A hop is dialled THROUGH the hops above it, so when hop 2 had no
live member left, the probe for hop 3 failed at hop 2 and hop 3 was recorded
dead. Dead means "we tested this and it did not work" — but nothing was learnt
about hop 3 at all. One broken hop painted the whole chain dead and pointed the
operator at the wrong place, and every one of those probes was a dial with a
timeout down a path already known to be broken.

Chain jobs now run in path order and the walk stops at the first hop that reads
dead. Hops below it are not dialled at all and are reported untested with
blocked_by naming the hop that stopped the walk — the honest answer, since
nothing was measured.

Nothing latches. There is no blocked flag: the gate is a fresh read of the
health board at every hop of every pass, and the cursor rewinds to the top each
cycle, so the first dead hop is never behind a break and is always retried. The
moment it answers, the rest of the chain runs in that same pass. Only a positive
dead blocks; untested never does, or a cold start would never open.

Blocked hops are rewritten rather than annotated, because board records do not
vanish when the prober stops dialling — they age out on their own TTL, and the
worst version of that is a stale dead pointing at a hop that may be fine.

The exit tag is exactly what stops being dialled, so the group test would have
waited out its full deadline and then reported "not reached yet" about a chain
it already knew was down. It now names the blocking hop immediately, gated on
the same freshness watermark so a break seen before the request cannot
short-circuit a pass that may be about to find that hop alive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:00:38 +03:00
omarandClaude Opus 5 3c7536dba0 fix(panel): show the chain hop by hop, and stop reading unused as broken
release / apk aarch64_cortex-a53 (push) Successful in 3m5s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 8s
The chain card gave a single verdict, so a dead hop was invisible: the operator
saw "the chain is unhealthy" and had to guess which of four hops to look at.
Meanwhile a group used only inside a chain showed "unused" next to a live
alive/dead count, which reads as a diagnosis when it only means nothing measures
it on that path.

Render the hops as a rail that severs below the first dead one, so which hop is
answered before a word is read, and split the two "not routed" messages into the
routing fact and the explicit non-fact. The group one names the case directly: a
group used only as a hop inside a chain reads unused here on purpose, and its
real health is on that chain's card.

Also fixes a bug this would otherwise have shipped: the readout painted every
ok:false in the critical colour, so "not routed" would have rendered as a fault
— the exact lie being removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:09:42 +03:00
omarandClaude Opus 5 3c92e1cbfd fix(health): one prober, on the path the rules actually use
A node reached only as a chain hop was being measured twice, and the reading
the panel showed was the wrong one. On a router in Russia that is not a cosmetic
difference: a node the chain carries fine behind a WireGuard hop is dead when
dialled straight out of the WAN, so the group card read "0 of 2 alive" while
that very group was carrying every packet.

Two dial paths existed outside the observatory plan. URLTestGroup.PostStart
warmed up every urltest group at box start whether or not any rule reached it,
and the panel's Test button reached URLTest.DialContext, whose first act is
Touch() — arming a ticker that re-swept those groups directly every probe
interval for the next thirty minutes. Both wrote under the BASE node tag, and
both dialled the base outbound, which carries no chain detour at all.

The observatory was never the liar: its plan roots come from the rules, and a
chain hop copy is stored only under its own tag, so no plan job could ever
write under a base tag. The fix is therefore to remove the other two paths, not
to touch the plan.

TestGroups now asks the observatory for an out-of-turn pass and reports what it
measured; a target no enabled rule routes to is not dialled at all and says so.
Unused urltest groups stand down their own self-check via a new SelfCheck option
(nil keeps today's behaviour, so every existing config is unchanged). The one
direct dial left is the exit-address lookup, which has no other possible source
— it now runs only for a target that is both routed and already read alive, so
it travels the routed path and never touches an unused group.

Chain hop wrappers are probed as measurements of their own and surfaced as
chains[].hops[], because "which hop is dead" is the question an operator has and
the chain-level verdict cannot answer it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:09:42 +03:00
omarandClaude Opus 5 bcc9df9282 test(wireguard): drive a real AmneziaWG tunnel through ClientBind
release / apk aarch64_cortex-a53 (push) Successful in 3m25s
release / apk x86_64 (push) Successful in 3m14s
release / release apk (push) Successful in 8s
The unit tests pin the reserved-byte gate on each side in isolation, which
would still pass if the two halves disagreed about when to apply it. This wires
two real wireguard-go devices together over loopback UDP through ClientBind on
both ends — the bind the detour path actually uses — configures ranged h1-h4
plus s4 and junk, and asserts an inner IP packet reaches the peer's TUN.

It is red against the unconditional clear and green with the gate, so it covers
the failure the field hit rather than the code we happened to write. Tagged
with_awg, so it runs under the shipped router tag set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:25:05 +03:00
omarandClaude Opus 5 ee3641fe45 fix(logsink): collapse interleaved floods, not just consecutive lines
The previous suppression compared each line with the one before it, which the
field never obliges. A dead chain makes the engine cycle the same message
across three outbound tags, so no two identical lines are adjacent: on the
router it produced 854 daemon lines in a ~760-line syslog ring and exactly one
summary, all while claiming "repeated 1 time". The rest of the system's log —
netifd, dnsmasq, the kernel — was evicted anyway.

Track a bounded table of open series keyed by the existing repeat key instead.
The first copy of a key prints; further copies inside its window are counted
whatever arrives in between; the window end emits one summary per key. The
summary now names its message, because several can close at once and "last
message" would simply be false under interleaving.

The table holds 256 keys and evicts the least recently seen, never silently: an
evicted series with a pending count prints its summary on the way out, marked
so the truncation is visible. Close, Reconfigure and any fatal flush every open
series first — a dying daemon may never reach Close.

TestRepeatAlternatingNotSuppressed asserted that A B A B must never be
collapsed. That assertion was the bug. It is replaced by a stronger one: the
messages get separate series, separate summaries and separate counts, so
distinct events still never fold into a single number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:23:24 +03:00
omarandClaude Opus 5 439f62238f fix(engine): retire the superseded instance instead of leaving it running
Every config apply built a new box and left the old one alive. The engine's own
log gives it away: inside a single shaterd process, lines carried uptime
counters half an hour apart in the same second, and a live router was found
running four generations at once. A process restart cleared it, so the leak
accrued purely on re-apply.

That is not just wasted memory on a 512 MB box. Each surviving generation keeps
its WireGuard devices up, and two devices sharing one private key evict each
other at the peer — so the leak reproduced the duplicate-device defect between
generations, underneath the deduplication that only reasons about one config.

Retirement now has a hard budget: 5s, which is exactly sing-box's own
C.StopTimeout (past which upstream already calls a stop excessive) and stays
under C.FatalStopTimeout. It is paid after the replacement is serving and only
on an apply that changed something, so a no-op reconcile stays free.

A close that blows the budget is ABANDONED, not waited on, and the apply is
still reported as the success it is — the new box is built, started and
carrying traffic, and failing there would abort the netplane stage and leave a
stale ruleset over a healthy engine. The stuck instance is surfaced through
PendingCloses() into `shaterd status` and the panel, and clears itself if the
shutdown ever completes. Repeated applies over a stuck close no longer stack:
the abandoned generation is remembered, not re-created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:23:24 +03:00
omarandClaude Opus 5 d971eb85ee fix(wireguard): stop ClientBind from shredding the AmneziaWG magic header
An AmneziaWG node worked standalone and died the moment it was placed behind
an egress or a chain hop: the handshake completed, the peer answered, and then
not one byte of data ever arrived. The peer never confirmed the session, so it
re-handshook every 15 seconds, forever.

ClientBind cleared bytes 1-3 of every datagram on receive and stamped them on
send, unconditionally. Those bytes are Cloudflare's "reserved" field. They are
also where AmneziaWG puts the upper three bytes of its little-endian uint32
magic header, so zeroing them collapses the value to its low byte, which falls
outside every h1-h4 range and makes the peer classify the packet as an unknown
type and drop it silently.

Handshakes survived because s1/s2 padding pushes their magic past byte 3 — the
clear only scribbled on the random junk prefix. Transport packets have s4 = 0,
so their magic starts at byte 0 and took the hit. That asymmetry is the whole
signature: session up locally, zero data through.

Only the detour path was affected, because Endpoint.Start picks StdNetBind when
the dialer exposes WireGuardControl (no detour) and ClientBind otherwise. The
gate had already landed in StdNetBind; ClientBind was its untouched twin. The
two implement one contract and are now commented as the pair they are, so the
next fix cannot again land on one side only.

Measured on the box: h4 spans 0x60728123-0x60728155, so zeroing bytes 1-3
leaves 35..85 — the captured transport packet began with 56, while a node
without a detour carried a correct 0x6b039798 at the same moment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:23:24 +03:00
omarandClaude Opus 5 a0f6083e28 fix(panel): show whether a rule is in force, not just what was saved
release / apk aarch64_cortex-a53 (push) Successful in 3m7s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 8s
With two catch-all rules both enabled in UCI and a WAN profile enabling one
and disabling the other, the panel drew BOTH switches on while the engine
ran only one chain. GET /api/config is right to return the raw model — that
is the desired state the panel PUTs back — but Routing.tsx read the row
state and the active count from it too, so the interface claimed a setting
was in force when it was not. Same defect class as the Protected badge.

/api/rules/reachability now carries the effective flag and, where the active
profile changed the outcome, its name and direction. The annotation is a
DIFF of ApplyProfileRuleOverrides output against desired state rather than a
second reading of the profiles name lists, so profile logic is not
duplicated and cannot drift — an unmigrated rule the profile is forbidden to
enable produces no diff and gets no badge, with nothing here needing to know
about LegacyDst.

In the UI the two states stay separate: the switch remains the only carrier
of desired state and still writes UCI, while the effective state drives the
dimmed row, the badge, the banner and the header count. Mirroring the
effective state into the switch would be worse than the original bug — the
operator would be toggling someone elses control, and the profiles decision
would be written back as their own choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 77369aedfe fix(logsink): collapse repeated lines instead of erasing the routers syslog
A broken outbound makes the engine repeat one line about once a second —
370 copies in six minutes. The routers syslog ring holds ~760 lines, so
within minutes it evicts the history of every other subsystem and our own
startup lines with it. Diagnosing the WireGuard duplication above required
restarting the service purely to catch the first seconds of a boot.

Collapse runs into "last message repeated N times". The comparison key is
level + text with the uptime field dropped: comparing whole lines would
suppress only same-second bursts, because that counter ticks. The per
connection "[id duration]" group is deliberately KEPT in the key — those ids
are distinct connections, and folding "50 connections failed" into one count
would be a worse lie than the flood. Window 5s, so a standing fault keeps
being reported instead of looking like a frozen log. fatal/panic are never
suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 515ae6d1b7 test(generate): make the remote-blocklist test exercise the remote path
TestDNSFilterRemoteBlocklistHTTPClient has failed on every Linux run for two
releases, which made the whole package exit non-zero no matter what the code
did — a real regression would have drowned in the familiar red.

The cause is not the packages no-network fetcher stub, as it first appears.
ruleSetURLIsEngineNative decides remote-vs-compiled-local by URL EXTENSION
alone, and httptest.NewServers bare "http://127.0.0.1:<port>" has none, so
the fixture fell into the TEXT-list path: downloaded by generates own
fetcher, parsed as a hosts file, compiled into a LOCAL rule-set — which
every assertion below then contradicted. No stub content could fix that; the
stub decides the lists contents, not the rule-sets type.

Give the URL the .srs suffix the test always meant it to have, so the engine
fetches the compiled set itself through the direct outbound. No assertion is
weakened and the no-network stub stays in place.

Verified on the stand (ImmortalWrt 25.12.1 x86_64, shipped build tags):
338 PASS / 0 FAIL / 1 SKIP, exit 0 — against 327/1/1 on pristine HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 a2ffbb1292 fix(generate): one WireGuard device per private key
A node may be copied freely by this package: a per-chain hop copy and a
per-group egress copy are rebuilt from the share-link so each can carry its
own Detour. For vless that is right — a copy is another TCP client. For
WireGuard it is not: each emitted endpoint is a real device holding the
nodes private key, and a peer keeps exactly ONE session per public key.
Two devices from one key evict each other continuously, and with keepalive
on both the loop never settles: NEITHER passes traffic.

buildOutboundsAndEndpoints emits the base endpoint for every enabled node
whether or not anything references it, so a WG node used only as a chain hop
always produced two devices. That is what any chain containing a WG node
looks like — every such chain was permanently dead.

Observed on the box: two UDP sockets from shaterd to the same peer port, the
servers peer endpoint flapping between them, +32 bytes/min through the
tunnel and every hop failing with "context deadline exceeded".

Deduplicate once on the assembled options, which catches all three producer
paths by construction. Duplicates are DELETED, not merely unreferenced:
box.New starts every endpoint regardless of reachability, so a leftover
would still bring its device up and still fight for the session. Dangling
references go to block, never to direct — a consumer whose tunnel just
disappeared must stop, not fall out onto the plain WAN.

Subscription fetch detours seed the reachability walk (they are direct
references like any rule), mirroring engine.ViaToTag exactly, with a
tripwire test against drift. A config that genuinely needs two devices for
one key keeps one and fail-closes the rest with a critical warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 21:35:06 +03:00
omarandClaude Opus 5 1746d4d0ef fix: stop the panel and the shipped binary from lying about what works
release / apk aarch64_cortex-a53 (push) Successful in 9m13s
release / apk x86_64 (push) Successful in 3m4s
release / release apk (push) Successful in 7s
Four defects, all found by the owner on the live router, all of the same
family: something declared itself working while it was not.

WIREGUARD WAS DEAD IN THE SHIPPED BINARY (B17). Setting up WireGuard gave
"create WireGuard device: gVisor is not included in this build". The router
tag set carried with_wireguard and with_awg but not with_gvisor, so
sing-tun compiled its stub instead of the netstack every WireGuard device
needs. FEATURES.md marks WireGuard [MVP] and AmneziaWG "a driving
requirement", so this was a broken promise, not a trim.

The tag itself was the small half. The tag set was the ONE build
configuration nothing in the repo tested: TestAmneziaWGEndpoint passes
because tests build with the full upstream tags. So the set now lives in
one file (scripts/router-tags.sh) and two guards hold it to the feature
list -- a static check that needs no tags, no Linux and no network (so the
next such gap fails on the developer's machine), and a behavioural one that
constructs every declared protocol through box.New UNDER THE SHIPPED TAGS,
where skipping is forbidden. Removing the tag now fails with the feature
name, the missing tag, and why: "Either add the tag back, or stop declaring
the feature -- those are the only two honest options." Cost: +2.8 MB raw,
+0.6-0.7 MB packed per arch. D23; D9 corrected.

THE PANEL CALLED A DIRECT-ONLY ROUTER "PROTECTED" (B16). The headline came
from plane === 'full', which reports whether the data plane is installed --
nft table, policy routing, live engine -- and says nothing about where the
traffic goes. On a config with one `default -> direct` rule and no groups
the plane is fully installed and every packet leaves in the clear, so the
worst possible state rendered as the reassuring one.

The verdict is now computed on the daemon FROM THE GENERATED OPTIONS at the
moment they reach the engine, not from the model: buildRoute changes the
answer (a scheduled rule outside its window is never emitted, only the last
condition-less rule reaches Final, an unresolved target is rewritten by
ruleKillFallback), and re-deriving it anywhere else is a second
implementation that will drift -- model/reachability.go exists because two
already did. Four verdicts, not three: `blocked` is separate because under
a closed kill-switch with no catch-all nothing leaks, and calling that
"going out directly" is a lie in the alarm direction. Rider: Overview's
defaultTarget printed the highest-Order enabled rule as the default; a rule
becomes Final by having no conditions, whatever its Order.

"PREVENT THIS PAGE FROM CREATING ADDITIONAL DIALOGS" KILLED EVERY DELETE
(B15). Once the browser suppresses dialogs, window.confirm returns false
immediately, so all 15 confirmations across 7 pages read as "cancelled" and
silently did nothing, with no way to recover from inside the panel. Replaced
with an in-app dialog the browser cannot mute: focus trapped and parked on
Cancel, Esc and veil cancel, focus returned to the opener, crit styling for
destructive commits. useConfirm() throws if the provider is missing rather
than falling back to a quiet false -- the failure mode being fixed.

HYSTERIA2 AND TUIC NODES WERE DROPPED (B6). No share-link parser existed,
so a feed's nodes of those types vanished. The real landmine was one layer
up: ParseSubscriptionBody splits a feed by scheme prefix before parsing, so
without schemePrefixes the links were gone before any parser ran and the
fix would have looked complete. Undeliverable parameters are refused when
the node cannot work or would be less secure than the link asked (obfs,
pinSHA256, tuic v4/non-UUID) and flagged via Proxy.Warnings when it
survives -- shaterd nodes shows both. uTLS is dropped for QUIC: it cannot
produce a QUIC TLS config, and that fails at dial time, not at box.New.

Also: nodes added by hand can be named and renamed. The name is the
outbound tag, so a rename rewrites every reference in one PUT -- rule
targets, group members, chain hops, detours -- in the spelling each already
uses, and is refused outright when a group answers to the same bare name.
Subscription nodes state why they cannot be renamed instead of hiding the
control.

go build, go vet, go test ./shater/... (13 packages), panel npm run build
and npm test (13/13) all green. NOT yet verified on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 20:08:58 +03:00
omarandClaude Opus 5 f86501bf77 ci!: drop the opkg lane — apk only, and fix the stale rolling release
Both routers are past opkg: mini_router runs ImmortalWrt 25.12.1 and
main_router OpenWrt 25.12.0, both with apk-tools 3.0.5, and main_router has
no `opkg` binary at all. The 24.10 lane was building and signing a feed no
device could consume.

Removed jobs `build` and `release` with the scripts only they called
(ci/build-feed.sh, ci/sdk-build.sh, ci/make-index.sh, ci/install-usign.sh)
and the usign trust anchor dist/shater-feed.pub. A committed public key is
an instruction: it invites the old install path for a feed that is no longer
produced. The key is retired, not revoked -- git history keeps it, KEY_BUILD
still holds the secret half, and a usign secret contains its own public half,
so the identity is reconstructible if a 24.10 device ever needs serving.
D7 is marked SUPERSEDED by the new D22 rather than deleted.

Separately: the rolling `apk-latest-<arch>` release was frozen at 0.2.0 from
2026-07-24 while every tag run published its versioned release correctly.
The publish loop was an either/or -- `TAG=apk-latest-<arch>` when VER=latest
(workflow_dispatch only), ELSE `TAG=apk-<ver>-<arch>` -- so a `v*` tag run
never touched the rolling pointer. Asset replacement was never the problem;
ci/gitea-release.sh already deletes before recreating. A router pinned to
the rolling URL sat on 0.2.0 while `apk update` reported success: silent
staleness, the failure mode this repo keeps having to close.

The rolling pointer is now published on EVERY run, tag runs included, and a
new assert reads the release back over the API afterwards: our three
tag-versioned packages at the built version plus the index and the key must
be present (exit 13), and no package asset at any other version may survive
(exit 14). Same class of check as sdk-build-apk.sh's package-version assert,
added for the same reason -- the previous failure mode was silent.

KEY_BUILD can now be deleted from the Gitea repo secrets; nothing references
it. Docs state plainly that mini_router is deliberately pinned to a
versioned URL and that the hand-edit per release is the price of pinning.

Known consequence: the x86_64 QEMU testbed is still OpenWrt 24.10.3 and can
no longer install our packages. Its 25.12 rebuild is in flight separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 18:46:21 +03:00
omarandClaude Opus 5 eccfc6136c fix(routing)!: make the v1->v2 destination migration fail safe
release / aarch64_cortex-a53 (push) Successful in 3m56s
release / x86_64 (push) Successful in 3m25s
release / apk aarch64_cortex-a53 (push) Successful in 2m44s
release / apk x86_64 (push) Successful in 2m43s
release / release (push) Successful in 9s
release / release apk (push) Successful in 7s
Code review of a8ef887c5 + 244b7c419 ("a rule's destination is a rule-set,
and nothing else") found that the change rested on a comment that was not
true. ParseUCIExport dropped dst_domain/dst_ip on the strength of "the
migration is re-run on every load"; model.Migrate() actually runs only from
`shaterd migrate`, i.e. the service init and uci-defaults. The daemon's run
path, the SIGHUP reconcile and the panel's config write never migrate.

So an uncommitted migration (a full /overlay is the documented way that
happens) turned `list dst_domain 'bank.ru'` + `target direct` into a rule
with NO matchers, which IS the spelling of a catch-all: generate points
route.Final at it and the LAST such rule wins. One failed `uci commit` sent
every packet on the router out the plain WAN, silently.

Rule.LegacyDst is the tripwire. It is non-empty exactly when the config
still carries the removed options, and three locks hang off it:
  - ParseUCIExport holds such a rule DISABLED. Chosen over "make IsCatchAll
    false" alone, which only covers matcher-less rules: `dst_domain` plus a
    `src` was never a catch-all, and routing it without its destination
    would still have sent a whole subnet direct.
  - IsCatchAll returns false for it, so it can never own route.Final even
    if something hands its Enabled bit back.
  - ApplyProfileRuleOverrides refuses to enable it (a profile with
    `list enable_rule` would otherwise have defeated the parser).
ValidateRules reports it through the existing warning channel, before the
Enabled gate, so the one message explaining the outage is not suppressed by
the fact that caused it. The init script logs a failed migration to syslog
instead of discarding its exit code and stderr.

The write path had none of this. PUT /api/config decodes a Model straight
from the request body and render.go wrote `enabled` from it, so a panel
save erased the operator's lists (as did the subscription cron, which
re-renders the whole package), and a crafted body with Enabled:true and no
LegacyDst put a live matcher-less rule on disk -- the same whole-router
leak, re-entered from the other side. WriteUCI now reads DISK state and
refuses a rule-changing write over an unmigrated config (409, not 500);
non-rule writers pass and legacyDstOpts carries the options across so cron
preserves them; withDiskLegacyDst takes the field from disk so a fabricated
one can never reach the renderer.

Migration hardening: an entry list that migrates to nothing no longer has
its legacy option deleted (that made "matches nothing" silently become
"matches everything"); a hand-written rule-set whose name collides is no
longer allowed to swallow the entries; delete failures propagate instead of
bumping schema_version past them forever; every error path reverts the
staged uci delta so another process's commit cannot flush a half-migration.

untunnelable.go follows the destination out of the rule: a rule whose
rule-sets are known to match by name is still skipped by the ping/IPTV/VPN
plan, as its v1 form was. D21 documents the AND->OR widening for the
engine's TCP/UDP path; it does not follow that a leak-guard should widen
itself during an upgrade, and with target=direct that meant previously
tunnelled ICMP leaving with the client's real address. Inline rule-sets are
now read from the options, so an engine that has not started yet no longer
costs the operator their ping.

Rule-set vocabulary: `full:`/`suffix:`/`keyword:`/`regexp:` in a text list
fetched by URL were dropped with no diagnostic at all (normaliseListDomain
rejects any token with a colon) -- not "reported as an unknown prefix".
Unifying was rejected: published filter lists are full of colon-bearing
syntax, and a third-party `regexp:` is compiled into the router's matcher
and run per query. The difference stands and is paid for in diagnostics,
per list, on every generate. D21 gains the source/vocabulary table.

Panel: the add form warns about a matcher-less rule exactly as the edit
form does, from one shared predicate; its isCatchAll matches the daemon's
new one; an unmigrated rule reads as held-off rather than merely switched
off. The comment promising a "New list" button that D21 rejected is gone.

go build ./..., go vet ./shater/..., go test ./shater/... (13 packages) and
panel `npm run build` are green. NOT yet verified on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
2026-07-25 18:04:21 +03:00
omarandClaude Opus 5 244b7c4199 feat(panel): a rule's destination is a ruleset picker, nothing else
release / aarch64_cortex-a53 (push) Successful in 3m21s
release / x86_64 (push) Successful in 3m19s
release / apk aarch64_cortex-a53 (push) Successful in 2m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 9s
release / release apk (push) Successful in 6s
Follows the schema-v2 model change: `Rule.DstDomain` and `Rule.DstIP` are
gone from api.ts, so the Routing page loses the two controls that wrote them.

The add form's Match picker (rulesets / ip / port) collapses to a plain
Port(s) field beside the ruleset checkboxes — with no inline address list
there was nothing left to choose between. The edit form drops its "Domain(s)
— legacy" and "IP / CIDR(s)" fields; it now shows exactly what the add form
shows, which is the honest shape of a rule that carries one destination
mechanism.

The destination picker renders even when the config has no rulesets yet, and
says where to get one. Hiding it (the old behaviour when the list was empty)
would leave the rule form with no destination control at all, at precisely
the moment the user needs to know one exists. It is checkboxes and nothing
more: creating and filling a list stays in the Rulesets panel, so a list is
authored in one place and its naming and entry rules cannot drift between two
editors.

isCatchAll() drops the same two fields as model.IsCatchAll, so the "never
applies" badge and the daemon's apply warning keep agreeing about which rule
is the default; the matcher chips lose their `dns` and `ip` rows for the same
reason. The mock backend's reachability shim follows.

Rendered against `?mock` in both themes; `.rt-field-wide`, the only rule the
removed wide inputs used, is deleted rather than left dangling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:16 +03:00
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
omarandClaude Opus 5 8fd5c52488 fix(tproxy): connect the UDP write-back socket + release NAT sessions on close
release / aarch64_cortex-a53 (push) Successful in 3m29s
release / x86_64 (push) Successful in 3m21s
release / apk aarch64_cortex-a53 (push) Successful in 2m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 8s
release / release apk (push) Successful in 6s
B3, real root cause. On the live BPi-R3 Mini `netstat -lnup` showed shaterd
holding 33 sockets on the router's own LAN address 10.67.0.1:53, next to
dnsmasq's single socket, several with a growing Recv-Q. Reproduced read-only on
the box: 5 host queries to 10.67.0.1 -> 0 answers and total Recv-Q on those
sockets 0 -> 19200 (5 x 3840, one datagram parked in each, never read); 3
control queries to 127.0.0.1 -> all answered.

Where they come from: protocol/redirect/tproxy.go, tproxyPacketWriter.
WritePacket. The TPROXY UDP write-back socket must carry the ORIGINAL
DESTINATION as its source address, so upstream binds it there — but leaves it
UNCONNECTED (net.ListenPacket + WriteToUDPAddrPort) and sets SO_REUSEADDR AND
SO_REUSEPORT (sing's control.ReuseAddr sets both). An unconnected bound socket
is a RECEIVER as far as the kernel is concerned, so each one silently joins the
UDP demultiplex/reuseport set for that address:port. Nothing ever reads them —
this writer only sends.

With dns_intercept the original destination IS the router's LAN address, so
every intercepted DNS session parks another silent receiver on <lan-ip>:53. The
host's own queries to that address take the loopback path, are never diverted by
the nft plane (iifname is scoped to LAN devices), and are therefore spread across
that set by the reuseport 4-tuple hash: they land in a silent socket at random
and time out. Hence "2 restarts of 3 fine, the third dead", and hence a failure
that no ruleset rebuild or reconcile can touch. The stale [UNREPLIED] conntrack
entry seen alongside is a CONSEQUENCE of the unanswered query, not the cause.

Fix (upstream file, lx:tproxy_writeback_connect):
  * CONNECT the write-back socket to the one peer it ever talks to. The kernel's
    compute_score() rejects a connected socket for any other peer, and a
    connected UDP socket (sk_state == TCP_ESTABLISHED) is excluded from
    reuseport selection outright — so it can no longer be handed a datagram it
    will not read. Nothing about the reply changes: same spoofed source, same
    single peer, Write instead of WriteTo. The unconnected path is kept verbatim
    for a destination that cannot be bound (domain socksaddr).
  * A failed cached write now CLOSES the socket instead of only dropping the
    reference (upstream left the fd to the GC finalizer).
  * TProxy.Close() purges the UDP NAT cache. Closing the listener stops ingress
    but the cache evicts lazily, so after the inbound is gone nothing wakes the
    live sessions and each strands its write-back socket. Invisible upstream
    (one close at shutdown); on this fork the engine is rebuilt on every apply,
    so it was one stranded generation per apply.

Measured on the live box: the socket count is steady-state (22-40, fds 55-66),
i.e. bounded by the udpnat session lifetime rather than an unbounded leak — the
count itself is inherent to per-session write-back sockets and is harmless once
they are connected. The Close() purge removes the per-apply generations on top
of it.

The netplane UDP:53 conntrack flush from 32e8f8ff0 is KEPT, with its comment
corrected: it is hygiene on plane transitions, not the cure for B3.

Regression tests fail on the pre-fix code (verified by reverting each half):
TestWriteBackUsesConnectedSocket / TestWriteBackReusesOneSocket /
TestWriteBackClosesSocketOnWriteFailure ("use of WriteTo with pre-connected
connection") and TestTProxyCloseReleasesNatSessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:41:02 +03:00
omarandClaude Opus 5 32e8f8ff0b fix(restart): serialise stop->start and flush stale DNS conntrack (B3)
release / aarch64_cortex-a53 (push) Successful in 6m27s
release / x86_64 (push) Successful in 3m21s
release / apk aarch64_cortex-a53 (push) Successful in 5m38s
release / apk x86_64 (push) Successful in 2m35s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
`/etc/init.d/shater restart` left DNS to the router's own LAN address dead
and never recovering, while `stop` + pause + `start` was fine — with the
status still reporting plane=full / engine_running=true and `shaterd
reconcile` fixing nothing.

Cause: `restart` is not synchronised end to end.

  * procd's `stop` is ASYNCHRONOUS. rc.common's `restart` is literally
    `stop; start`, and the `service delete` ubus call returns as soon as
    SIGTERM has been SENT. `start_service` therefore re-adds the instance
    (and runs `shaterd migrate`) while the outgoing `shaterd run` is still
    executing its honest teardown.
  * The successor's only defence was `daemonAlive()` -> exit(1), leaning on
    procd's `respawn 3600 5 0` to try again five seconds later. That is a
    blind retry, not synchronisation: it neither knows nor waits for the
    teardown, and it turns every restart into a logged crash plus a
    five-second hole with no data plane.
  * `term_timeout 10` SIGKILLs a predecessor whose teardown outlives it —
    engine.Close of a several-hundred-outbound box flushes cache.db to
    flash before the netplane teardown even starts — aborting the teardown
    at an arbitrary point and leaving the plane HALF removed.
  * Nothing in the tree ever touched conntrack, so flows that crossed one
    of those windows kept entries formed against a plane that no longer
    exists. For UDP there is no handshake to resynchronise on and every
    retry merely refreshes the entry, so the flow stays wedged for as long
    as the client keeps asking — a flow-scoped, permanent failure that no
    ruleset rebuild can reach.
  * RoutingPresent() reported "plane intact" from the ip RULE alone, while
    ApplyRouting installs a rule AND a `local default dev lo` route removed
    by two independent commands. A teardown interrupted between them was
    therefore invisible, applyLocked's fast-path skipped ApplyRouting
    forever, and no reconcile could repair it.

Fix (fail-closed posture unchanged — no new window in which LAN traffic can
reach the WAN; teardown still removes the table LAST and the forward-chain
drop is untouched):

  * init: `start_service` waits for a live predecessor pidfile to clear
    before opening the instance, so restart == stop + pause + start. Zero
    cost at boot. term_timeout 10 -> 30 so an honest teardown is never
    killed halfway.
  * daemon: the single-owner guard WAITS for the predecessor (bounded,
    60s) instead of exiting 1; it still refuses if the budget expires.
  * netplane: new FlushDNSConntrack() (ctnetlink, UDP orig-dport 53 only —
    a blanket flush would drop the admin's own SSH/LuCI sessions) called
    on every plane transition: after a ruleset loads, after the table is
    removed, and once more in applyLocked when the whole plane (table +
    policy routing + sysctls) is assembled.
  * netplane: RoutingPresent() now verifies both halves it installs.

Regression tests fail on the pre-fix code (verified by reverting each fix):
TestApplyNftFlushesDNSConntrack, TestTeardownNftFlushesDNSConntrack,
TestRoutingPresentRequiresLocalDefaultRoute, TestWaitForPredecessor*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:51:19 +03:00
omarandClaude Opus 5 c562579ef3 docs(report): correct the B1 diagnosis — last catch-all wins, not the first
The report claimed the order=20 `default` shadowed the order=100 one and sent all
unspecific traffic past the proxy. That is wrong. generate/route.go:buildRoute
does not emit a condition-less rule as a match-all route rule: it sets
route.Final and continues, so the LAST condition-less rule by order wins, and it
can never shadow a rule that has conditions (those are emitted ahead of Final
regardless of order).

For the config on the router this inverts the conclusion: traffic IS going
through the proxy (order=100 -> group:auto is the live default) and the dead knob
is the order=20 `direct` one. Severity downgraded from high to medium
accordingly — a dead setting, not a leak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:41:58 +03:00
omarandClaude Opus 5 6f89acbae7 feat(panel): badge routing rules that never apply
The Routing page drew every condition-less rule as "default route · final",
so a config with two of them showed two identical claims and no hint that
only the last one is the default the router uses.

A superseded rule now loses those marks — it keeps its real Order in the rail
instead of the "·" that means final — and gains a "never applies" badge plus
a line naming the rule that beat it and what to do about it: give this one a
condition, or delete one of the two. Warn semantics throughout (--amber,
dashed frame, dimmed target chip): orange is the ACTIVE state on this
faceplate, and a rule the router ignores is the opposite of active.

Verdicts come from GET /api/rules/reachability and are keyed by the rule's
index in Rules, never by name — the config that prompted this had two rules
both called `default`. They are re-fetched after every save, and a verdict
whose echoed name/order no longer matches the row is dropped rather than
shown, so the window between an optimistic edit and the refetch cannot badge
a working rule.

Rule rows were also keyed by name in React, which silently collapses two rows
that share one; the key now carries the model index.

The mock fixture gains a second condition-less rule so `?mock` renders the
state, and mock.getRulesReachability derives its verdicts from the live
fixture config rather than hard-coding them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:36:36 +03:00
omarandClaude Opus 5 88a82c7297 feat(routing): report rules that can never fire (B1)
A routing rule with no conditions at all is not matched in sequence — it
becomes the engine's route Final (generate/route.go buildRoute points Final
at it and moves on). Two consequences were invisible everywhere:

  * two condition-less rules retire each other, and the LAST one by Order
    wins, so an earlier "default -> direct" is dead while looking live;
  * a condition-less rule can NEVER retire a rule that HAS conditions —
    those are emitted ahead of Final whatever their Order.

A config in the field had two rules both named `default`, both with zero
conditions, order 20 -> direct and order 100 -> group:auto. One of the two
did nothing, the log was clean, and the panel drew both rows with the same
"default route · final" badge.

model.RuleReachability is the one implementation of the verdict, in the
stdlib-only leaf both consumers import, so the warning and the panel badge
cannot drift. generate.isCatchAll / effectiveRuleTarget / sortedRuleIndices
now delegate to it — three copies of "what is a default and what order do
rules run in" was how this would come back.

Scope is deliberately narrow: only condition-less over condition-less, which
is certain from the config. Whether one conditional rule's matchers subsume
another's is not decidable here, and a false "never fires" badge on a working
rule is worse than no badge.

Profiles are honoured: the analysis runs on the EFFECTIVE rules
(Model.EffectiveRules applies the active WAN profile's enable/disable), so a
rule the profile switched off is not blamed for retiring anything, and one it
switched on is. A SCHEDULED default never retires anything — outside its
window the rule above it is the default again — but can itself be retired by
an unscheduled one below it, which makes its schedule pure decoration.

Apply-time this reaches the operator through the existing status warnings,
graded by consequence rather than by "a setting is dead": critical when the
surviving default is `direct` while the retired one asked for a tunnel or a
block (the operator's default policy is not in effect and everything
unmatched leaves on the plain WAN); warning otherwise. The field config's own
shape — a dead `direct` under a live tunnel — is the warning case.

GET /api/rules/reachability serves the same verdict to the panel, the routing
analogue of the per-chain `used` flag on /api/groups/health. Keyed by index
into Rules, not by name: this config has two rules called `default`.

Diagnosis only — nothing is renamed, reordered, disabled or dropped, and
apply keeps working on a config that already has two defaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:36:36 +03:00
omarandClaude Opus 5 a8f2b0f068 ci: derive package versions from the git tag (B4)
PKG_VERSION/PKG_RELEASE were hand-written literals nobody bumped, so
v0.2.2 … v0.2.6 all shipped as `shaterd 0.2.0-r3` with different binaries
inside (v0.2.6's ELF is 5 491 616 B against r2's 5 488 336 B). Both opkg
and apk offer an upgrade only when the feed's version string differs from
the installed one, so `apk update` saw nothing new and the routers could
not be updated through the normal path at all.

ci/version.sh is now the single source of truth. It derives the version
from `git describe`:

    tag `vX.Y.Z`   -> PKG_VERSION=X.Y.Z  PKG_RELEASE=1
    off-tag build  -> nearest tag + PKG_RELEASE=<commits since it> + 1
    no tag/no git  -> 0.0.0-r1 (below everything ever published)

Ordering verified with the real tools, not from memory — apk-tools 3.0.3
(`apk version -t`) and opkg 38eccbb1 (`opkg compare-versions`) agree that
0.2.0-r3 < 0.2.6-r2 < 0.2.6-r10 < 0.2.6-r12 < 0.2.7-r1 < 0.3.0-r1, so a
release always outranks the rolling builds that preceded it and rolling
builds grow monotonically between releases.

The value travels as SHATER_PKG_VERSION/SHATER_PKG_RELEASE in the SDK
build environment of BOTH lanes; the Makefiles keep a literal fallback so
a manual/offline build still works with no CI and no git. Because the
hand-off crosses docker, `su` and make's env import, ci/sdk-build.sh and
ci/sdk-build-apk.sh now ASSERT that the produced .ipk/.apk really carries
that version — the B4 failure mode was a stale version shipping silently,
and that can no longer happen quietly.

The binary agrees with the package: scripts/build-shaterd.sh takes
constant.Version from the same ci/version.sh (vX.Y.Z-rR[-g<sha>]) instead
of its own `git describe`, and the workflow computes it once per job.
Both build jobs now check out with fetch-depth: 0 — `git describe` needs
tags and ancestry, which the default shallow checkout has neither of.

byedpi is deliberately left alone: PKG_VERSION:=0.17.3 is upstream
ByeDPI's own version, what PKG_HASH pins and what tells an operator which
ByeDPI is installed. Stamping our tag on it would also be a downgrade —
every comparator reads 0.2.7 < 0.17.3 (component-wise, 2 < 17), verified.

Docs: INSTALL.md gains §2.1 (the scheme + the ordering evidence), and the
update sections of §5/§6 now explicitly warn against a bare `opkg upgrade`
/ `apk upgrade` and give the targeted form instead, quoting apk-tools 3:
"If list of packages is provided, only those packages are upgraded along
with needed dependencies". README.md and the release bodies match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:32:38 +03:00
omarandClaude Opus 5 0b32a6d58b fix(log): no ANSI colour outside a TTY — syslog and the log file stay grep-clean (B5)
Every log line the daemon produced carried aurora escapes, and under procd
stderr is not a screen, it is syslog:

  daemon.err shaterd[27540]: ...Z ESC[31mERRORESC[0m[0026]
  [ESC[38;5;193m1728741629ESC[0m 70ms] dns: exchange failed ...

`logread | grep ERROR` misses that line — the level word has invisible
bytes inside it — external collectors store the escapes forever, and a
captured log reads as mojibake.

Both producers defaulted to colour, and both are fixed at the producer,
because colour is a property of the DESTINATION and should never be
generated for a destination that cannot render it:

  * control plane (cmd/shaterd): log.Formatter{BaseTime: ...} left
    DisableColors at its false zero value. It now comes from
    controlLogFormatter(), gated on logsink.IsTTY(os.Stderr). The helper
    lives in an untagged file (same split as profilewatch.go) so it is
    unit-testable off the linux target.
  * engine (shater/generate): the generated option.LogOptions never set
    DisableColor, so box.New built a colouring formatter over the shared
    sink. logOptions() now sets it from the same TTY gate (seam:
    logColorAllowed).

logsink.IsTTY is the single source of the decision: a character-device
check, so no cgo, no termios and no new dependency on a CGO_ENABLED=0
musl-static binary. Under procd stderr is a pipe => no colour; an
interactive `shaterd run` from a shell keeps it.

The file half already stripped ANSI on the way out (emitLocked ->
stripANSI); that stays as the belt to this new braces, and the leak it
never covered — the syslog half — is now closed at the source.

Tests: the syslog half of the sink carries no 0x1b for any level with a
context ID set (the connection id is coloured by a separate branch of
log/format.go, so a level-only fix would still leak); the same for the
control-plane formatter and for a factory built from the REAL generated
log block. Each has a teeth check that a colouring formatter does emit
0x1b, so the guards cannot rot into passing for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:31:01 +03:00
omarandClaude Opus 5 893fdc500c fix(shaterd): nodes reports the real inventory instead of an empty list (B2)
`shaterd --help` promised "print nodes as JSON"; the verb answered `[]`
unconditionally — `cmdReadStub("nodes", "[]")` on the CLI side and a
hard-coded `writeLine(conn, "[]")` in the daemon's control-socket handler.
The data was never missing: on the live router /etc/shater/subs/*.json
held 315 subscription nodes and GET /api/config reported 340. An empty
array is indistinguishable from a truthful "nothing is configured", so
the verb did not fail loudly, it lied quietly — the same inverted-lie
class as 9dc954029 / aec82d444.

`nodes` now reads model.ReadUCI() — `uci export shater` merged with the
per-subscription JSON caches — which is literally the call GET
/api/config serves and generate builds the engine from, so the verb
cannot drift from the panel or from the running engine: there is no
second assembly here to drift. Both ends use the same nodesJSON():
the daemon answers over the control socket (like `stats`), and the CLI
falls back to reading the same on-disk state when no daemon is running
(like `status`). A read failure goes to stderr with a non-zero exit
instead of printing `[]`, so an empty list on stdout now means one thing.

Output is a purpose-built view rather than raw model.Node: the share-link
URI is a credential and CLI output ends up in tickets and cron mail, so
the view reports what the link decodes to (protocol/server/port) plus the
model's own facts (enabled/sub/egress/stale/fingerprint). Nodes whose URI
does not parse are still listed, with the reason in `parse_error` — the
engine skips exactly those, and hiding them would be the same lie smaller.

cmdReadStub keeps `stats`, where the default IS the truth (nothing was
counted without an engine), and now says so in its doc comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:30:27 +03:00
omarandClaude Opus 5 02c266188f docs: live test report for v0.2.6 on mini_router (79 checks, 5 findings)
Full cycle on real hardware (BPi-R3 Mini, ImmortalWrt 25.12-linkup): purge the
previous install, install from the signed apk feed, verify the default state,
restore a working config with 315 subscription nodes, then exercise the data
plane, panel API, config lifecycle, resilience and DNS.

74 PASS. Findings (detailed separately): two catch-all `default` rules where the
first sends all unspecific traffic direct and makes the second unreachable;
`shaterd nodes` is a stub returning [] while usage promises the node list; DNS to
the router LAN address dies after `service shater restart` (stop+pause+start is
fine); PKG_RELEASE unchanged since v0.2.1 so v0.2.2..v0.2.6 all ship as r3; ANSI
colour codes reach syslog.

Also records the four-iteration CI hunt that ended in the green apk lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 12:09:52 +03:00
omarandClaude Opus 5 024e9308c9 fix(ci/apk): strip the SDK's generated per-package default m blocks
release / aarch64_cortex-a53 (push) Successful in 3m21s
release / x86_64 (push) Successful in 3m12s
release / apk aarch64_cortex-a53 (push) Successful in 5m32s
release / apk x86_64 (push) Successful in 2m32s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
Run 60 settled what runs 58/59 left open. The second pass wrote an explicit
`# CONFIG_PACKAGE_kmod-x is not set` for all 1126 selected kmods and re-ran
defconfig; the count came back 1078, unchanged. The same explicit form DID hold
for CONFIG_ALL/ALL_KMODS/ALL_NONSHARED in the same run.

The difference is prompts. kconfig honours a user value only for symbols that
have one — sym_calc_value ignores S_DEF_USER for a promptless symbol and falls
back to its `default`. ALL* carry prompts in the SDK's Config.in; the blocks
convert-config.pl generates are bare:

    config PACKAGE_kmod-mlx5-core
            tristate
            default m

No value written into .config can turn those off, so remove the `default m`
itself: drop every generated `config PACKAGE_*` block from Config-build.in
before the first defconfig. Nothing is lost — those blocks only replay which
packages the buildbot built. The packages stay declared, with prompts, by the
package tree (tmp/.config-package.in), which is what makes our four selectable
and what `select` acts on; KERNEL_*/LIBC/TOOLCHAIN blocks are untouched, so the
SDK still reproduces its own toolchain settings.

The .config second pass is kept as a cheap backstop (it no-ops once the count
is 0), as are both tripwires.

Verified: bash -n on the file and on the extracted INNER body; the paragraph
delete tested on a synthetic Config-build.in (3 PACKAGE blocks -> 0, KERNEL_*,
LIBC and TOOLCHAINOPTS preserved); the missing-file path exercised under set -eu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:27:37 +03:00
omarandClaude Opus 5 eab1db2c3f fix(ci/apk): second defconfig pass — deselect the SDK's per-kmod default m
release / aarch64_cortex-a53 (push) Successful in 3m19s
release / x86_64 (push) Successful in 3m14s
release / apk aarch64_cortex-a53 (push) Failing after 1m4s
release / apk x86_64 (push) Failing after 1m3s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
Turning ALL/ALL_KMODS/ALL_NONSHARED off (22d7161c0) provably worked — run 59
logs all three as `is not set` after defconfig — and changed the kmod count by
exactly zero, 1078 both times. The kmods never came from ALL_KMODS.

They come from the SDK itself. target/sdk/Makefile generates the SDK's
Config-build.in by running convert-config.pl over the BUILDBOT's .config, in
which ALL_KMODS=y had already expanded into one `CONFIG_PACKAGE_kmod-*=m` line
per module. convert-config.pl turns every `CONFIG_X=<val>` line into a symbol
with an unconditional `default <val>`; its `next if /^(# )?CONFIG_PACKAGE/`
filter sits in the `else` branch, which a line containing `=` never reaches.
The SDK therefore ships ~1078 verbatim blocks of `config PACKAGE_kmod-x /
tristate / default m`, none of which consult ALL_KMODS.

Fix: a second pass. The names only exist after kconfig has expanded the tree,
so after the first defconfig rewrite every selected kmod to `is not set` and
re-run defconfig. Two documented kconfig rules make this exact:
  - an explicit value in .config beats a `default` (same rule that kept our
    `# CONFIG_ALL* is not set` lines alive in run 59) -> the ~1078 stay off;
  - `select` is OR-ed in after the user value, so shater-core's
    `DEPENDS:=+kmod-nft-tproxy +kmod-nft-socket` brings those (and their
    transitive kmods) back on their own.

Also correct the tripwire message, which still blamed CONFIG_ALL_KMODS: it now
prints the ALL* state AND the first few surviving kmods, so the two failure
modes are distinguishable at a glance.

Verified: bash -n on the file and on the extracted INNER heredoc body; the
rewrite simulated against a run-59-shaped .config (1078 -> 0 selected, our 4
packages, LOCALMIRROR and the ALL* lines untouched).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 01:16:05 +03:00
omarandClaude Opus 5 22d7161c08 fix(ci/apk): disable the SDK's ALL/ALL_KMODS mass-select
release / aarch64_cortex-a53 (push) Successful in 3m25s
release / x86_64 (push) Successful in 3m14s
release / apk aarch64_cortex-a53 (push) Failing after 1m3s
release / apk x86_64 (push) Failing after 1m3s
release / release (push) Successful in 8s
release / release apk (push) Successful in 6s
Run 58 proved the previous commit aimed at the wrong thing, and the
diagnostics it added are what showed it: "0 lines carried over" plus a
`grep: .config: No such file or directory`, then 1078 kmods selected
anyway (1109 on x86_64). So an SDK tarball ships no top-level .config at
all — there was never a buildbot config for us to be appending to.

The real source is the SDK's OWN top-level Config.in, target/sdk/files/
Config.in, which it carries instead of the main tree's:

    config ALL_NONSHARED ... default ALL
    config ALL_KMODS     ... default ALL
    config ALL           ... default y

In the main tree all three default to n; the SDK flips ALL to y so that
`make world` in a bare SDK builds something. `make defconfig` therefore
selects the whole kernel from ANY .config, empty or not. This is stock
OpenWrt rather than an ImmortalWrt quirk — openwrt/openwrt's copy is
identical, which also means the awg-openwrt reference builds every kmod
too; it just never meets a disk quota on GitHub's runners.

Fix: write all three out as `# CONFIG_X is not set` before defconfig.
They have prompts in the SDK's Config.in, so they are user-settable and
an explicit value beats the default; `CONFIG_X=n` is not reliably
honoured for bools, hence the `is not set` form. Setting all three, not
just the root ALL, keeps this working whichever symbol roots the chain
in a future SDK.

Drops the hand-rolled CONFIG_TARGET_*/CONFIG_KERNEL_* carry-over as
redundant: target/sdk/convert-config.pl bakes the buildbot's non-package
settings into the SDK's generated Config-build.in as kconfig defaults,
so defconfig reproduces them by itself. A soft branch keeps target
identity and CONFIG_USE_APK if some future SDK does ship a .config.

Diagnostics gain a post-defconfig readout of the three mass-select
symbols and, while the list is short, the actual kmods selected — a
count of 0 is not fatal (the router's base feed carries them) but is
worth seeing. Guards and the 200 threshold are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 00:57:19 +03:00
omarandClaude Opus 5 24c5a1615d fix(ci/apk): build .config from scratch — stop packing all 3593 kmods
release / aarch64_cortex-a53 (push) Successful in 3m19s
release / x86_64 (push) Successful in 3m18s
release / apk aarch64_cortex-a53 (push) Failing after 1m4s
release / apk x86_64 (push) Failing after 1m2s
release / release (push) Successful in 7s
release / release apk (push) Successful in 5s
Both apk jobs of v0.2.2 died with `Disk quota exceeded`. The SDK was
running `apk mkpkg` on 3593 kmod-* packages (mlx5, amdgpu, ata, isdn —
none of which we ship) before it ever got near our four.

Root cause: ci/sdk-build-apk.sh APPENDED our package selections to the
.config that ships inside the ImmortalWrt SDK tarball. That file is the
buildbot's fully-expanded config and carries CONFIG_ALL_KMODS=y plus
CONFIG_ALL_NONSHARED=y (see config.buildinfo next to the SDK), so
`make defconfig` re-selected every kernel module of the target as =m and
package/kernel/linux/compile — pulled in via shater-core's nft kmod
deps — packed the lot.

Fix, modelled on Slava-Shchipunov/awg-openwrt's "Setup SDK and feeds":
start the .config EMPTY so kconfig can only pull in what our packages
actually select. Carried over from the SDK's .config, nothing more:
the target choice and its BOARD/SUBTARGET/ARCH_PACKAGES identities (a
wrong guess here means silently cross-compiling for another arch),
CONFIG_USE_APK (decides .apk vs .ipk — the point of this lane), and
CONFIG_KERNEL_* verbatim (they generate the kernel .config; dropping one
makes the buildsystem reconfigure and rebuild the SDK's prebuilt kernel).

Also adds the diagnostics this lane never had, since a failed run leaves
a 27 MB log: the carried-over identity lines, the post-defconfig kmod
count and target readout, a hard check that all four of our packages
survived defconfig, an abort if the kmod count is back in the hundreds,
and du/df after compile.

opkg lane (ci/sdk-build.sh, ci/make-index.sh) untouched. LOCALMIRROR,
CONFIG_DOWNLOAD_FOLDER and every cache path are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 00:31:28 +03:00
omarandClaude Fable 5 4492f0599c ci: harden feed/packaging shell scripts
release / aarch64_cortex-a53 (push) Successful in 7m3s
release / x86_64 (push) Successful in 3m13s
release / apk aarch64_cortex-a53 (push) Failing after 4m34s
release / apk x86_64 (push) Failing after 2m35s
release / release (push) Successful in 8s
release / release apk (push) Successful in 5s
- ci/make-index.sh: set -e → set -euo pipefail so a failing sha256sum|cut in
  the signed Packages index can't mask an empty SHA256. Script survives -u
  (all vars use :? or :- defaults).
- .github/deb2ipk.sh: quote $2/$DEB_NAME/output, derive the deb name from the
  copied file via basename instead of parsing `ls *.deb` (glob-fragile), add a
  trap-based tmpdir cleanup, and set -euo pipefail.

bash -n clean on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:34:50 +03:00
omarandClaude Fable 5 bc2b53069a fix(security): audit remediation — file perms, const-time auth, leaks, CSPRNG
Backend audit fixes (upstream-file edits wrapped in // lx: markers):

- experimental/libbox oom_report.go/report.go: OOM reports + configuration.json
  (server secrets/keys) were written world-writable — 0o777 dirs / 0o666 files
  → 0o700 / 0o600. [sec-perms]
- daemon/server.go + experimental/libbox/command_server.go: gRPC auth secret
  compared with != (timing oracle) → crypto/subtle.ConstantTimeCompare.
  [sec-consttime]
- service/oomkiller/timer.go: network-extension cleanupTriggered logic was
  inverted, so FreeOSMemory was never called after a trigger; flip both
  assignments so a trigger schedules the deferred free and the next poll runs +
  clears it. [sec-oomcleanup]
- transport/v2rayxhttp/client.go (lx-native file): session id used math/rand →
  crypto/rand, matching Xray's uuid.New() entropy and removing the spoof surface.
- daemon/started_service_tailscale_ssh.go: forwardSSHAgentChannel leaked a
  goroutine + the ssh-agent fd on every closed session (second io.Copy blocked
  on an idle agent Read forever); tie both copies + the session ctx to a
  cancel that closes both ends. [sec-sshagent]
- daemon/managed_service.go: TriggerOOMReport had no gate — rate-limit to
  1/min so an authenticated client can't spin secret-bearing dumps. [sec-oomgate]
- route/reachability_lx.go (lx idle-suspend file): idle tick read r.idleStop in
  select while stopIdleSuspend niled it after close (race + goroutine leak on
  Close-during-tick); pass the stop channel to the loop by value.

go build ./... (default) and the D9 shaterd linux build (tags
with_quic,with_wireguard,with_utls,badlinkname,tfogo_checklinkname0,with_xhttp,
with_awg,with_lx_command) are green; go vet clean (2 pre-existing unsafe.Pointer
warnings in TriggerDebugCrash/debug.go, untouched); go test ./route/...
./daemon/... ./service/oomkiller/... green incl. -race with with_lx_idle_suspend
and v2rayxhttp with with_xhttp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:34:50 +03:00
omarandClaude Fable 5 c257d6c5cc docs(readme): rewrite root README as Russian shater product face
- README.md: new Russian product README (what/features/architecture
  mermaid/install both feeds/build/repo layout/CI/upstream/docs/license)
- README.en.md: concise English mirror (root readme was previously English)
- README.ru.md: demoted to a pointer stub (was the sing-box-lx fork readme,
  a competing Russian README) -> points to README.md + engine-fork docs
- docs-shater/README.md: folder index

Install commands copied verbatim from docs-shater/INSTALL.md; all links
verified against existing files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:31:06 +03:00
omarandClaude Fable 5 225cce5397 chore: purge working-session junk from tree; gitignore recurrence
Remove untracked-quality artifacts accidentally committed during work
sessions (all authored downstream, unreferenced anywhere in code/docs/CI):
- 5 session screenshots in repo root (devices-after-copy-fix.png,
  live-final-groups.png, profiles-*-active.png, profiles-final-vm-wan0.png)
- tmp/gen_linux_test (29 MB throwaway traffic-gen binary)

Guard against repeats: ignore /*.png (root screenshots) and /tmp/.
Upstream files (mkdocs.yml, .fpm_*) and the SPECS-020 research .log are
left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:23:04 +03:00
omarandClaude Fable 5 84d2766592 chore: remove stray committed test binary, ignore nested .idea, bump PKG_RELEASE
release / aarch64_cortex-a53 (push) Successful in 6m26s
release / x86_64 (push) Successful in 3m32s
release / apk aarch64_cortex-a53 (push) Successful in 4m58s
release / release (push) Has been cancelled
release / release apk (push) Has been cancelled
release / apk x86_64 (push) Has been cancelled
- drop c/Users/.../gen_linux_test (28MB binary accidentally committed in 129e31fbd)
- .gitignore: ignore .idea/ at any depth (shater/.idea from IDE)
- CLAUDE.md: orchestrator delegates to model fable
- bump shaterd/shater-core r2->r3, luci-app-shater r1->r2 for v0.2.1 release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:18:01 +03:00
omar 33f940c2fb ci(release-apk): publish available arch feeds even if one build fails
release-apk now runs with if: !cancelled() so an unrelated arch build
failure (e.g. x86_64) does not block publishing the aarch64 apk feed.
download-artifact only fetches existing artifacts and the publish loop
already skips missing apkfeed-* dirs.
2026-07-24 22:52:17 +03:00
omar a57717dabb health plan S7: docs, contract comments, SPEC 019 update
release / aarch64_cortex-a53 (push) Successful in 3m43s
release / x86_64 (push) Successful in 3m30s
release / apk aarch64_cortex-a53 (push) Successful in 5m11s
release / apk x86_64 (push) Failing after 5m8s
release / release apk (push) Has been skipped
release / release (push) Successful in 12s
- lx-changelog: health board + observatory + global probe + sub cache entry
- DECISIONS.md: D18 board vs delete-and-overlay, D19 observatory vs sweep, D20 global probe settings
- contract comments: urltest.go CheckOutbounds (fast circuit) + observatory.go loop (background circuit + freshness gate) document the two-circuit split
- SPEC 019: dial-error section updated - slots still not moved, but board verdict demotes dead slot on next pick + retry (§5.B); sticky/replace-in-slot/never-shrink invariants preserved
2026-07-24 18:30:48 +03:00
omar 129e31fbdc health plan wave 3: integration tests + chain unused-badge
- S6: integration tests in shater/generate (health_retry, chain_health, observatory_reach); portable tests pass -race; linux-tagged tests vet+build under GOOS=linux for OpenWrt CI
- §5.E fix: unused-badge extended to chains; observatory used-set (chain exit tags) inverted via parseChainExitTag -> usedChainNames -> ChainHealth(names) -> /api/groups/health chains field -> ChainRow renders gh--unused badge (same pattern as groups)
2026-07-24 18:24:05 +03:00
omar 4f0618515e health plan wave 2: alive-only selection+retry, observatory replaces sweep
- S3: Select() alive-only by verdict; dial failure marks board + retries <=3 within ctx; ListenPacket retries to first send; balancer slot liveness reads board verdict; testNodes marks-fail instead of delete; SPEC 019 slot invariants preserved; selector untouched
- S4: new observatory.go (reachability plan from rules, batch<=24/concurrency<=12/timeout 5s, freshness gate, cursor preserved on identical plan); probeplan BuildObservatoryPlan; health.go on board verdicts (TTL=max(3*interval,10min)); engine.dead overlay removed; sweep.go+probeall.go+TestAllNodes+/api/nodes/test removed (->404); GroupHealth.Used published; exit-test extended to chains; panel unused-badge + chain Test button; stats on board
2026-07-24 16:33:28 +03:00
omar fd698162c9 health plan wave 1: urltest health board, global probe settings, sub cache out of UCI
- S1: History gains LastOK/Delay/LastFail; MarkFailed/Verdict on storage (common/urltest/board_lx.go); StoreURLTestHistory preserves LastFail
- S2: per-group ProbeURL/ProbeInterval removed, globals only; sweep_interval drained as dead option; panel fields dropped
- S5: subscription nodes cached in /etc/shater/subs/<name>.json; UCI keeps manual nodes only; sub update writes cache file; panel PUT split; legacy from_sub migration
2026-07-24 13:33:23 +03:00
omar ffa78d67bc feat(panel): drop the query log from Overview
The filtered-query-log block (SegMeter + top blocked + live QueryLog) is
redundant with Insights. The stats poll stays - it still feeds the DNS
filtering and Groups modules.
2026-07-24 01:09:48 +03:00
omar 61e51495c3 feat(panel): prune subscription editor to essentials with a key-value header list
The subscription form now carries exactly: name, URL, update interval,
fetch via (+detour when proxied), User-Agent, HWID, and extra headers.
Format, device identity, regex/proto/country filters, dedup and expiry-alert
knobs are gone from the form (still honoured from UCI; a save carries them
through untouched). Headers are edited as key-value rows and serialize to
the existing `Headers: []string` "Key: value" contract. Name is editable:
a rename rewrites FromSub on the sub's cached nodes and refuses collisions.
2026-07-24 01:09:48 +03:00
omar bfe71cd1dd feat(generate): fail closed on unresolved rule targets, never fall back to the default route
Rule.Kill ""/"default" used to drop the rule, letting its traffic fall
through to the broader rules below and finally the default route - a silent
leak of exactly the traffic the operator singled out. ruleKillFallback now
always returns an outbound: ""/"default"/"closed"/unrecognised block the
rule's traffic in place; only an explicit kill=open goes direct. The default
route exists solely for traffic no rule matched.
2026-07-24 01:09:48 +03:00
omar 9b3644becb fix(alert): don't repeat the IP as the device name in new-device alerts
With no DHCP hostname the alert read "10.67.0.223 (mac) at 10.67.0.223".
Lead with the MAC instead: "aa:bb:cc:dd:ee:ff at 10.67.0.223".
2026-07-24 01:09:47 +03:00
omar 0a8bdbaf46 feat(devices): merge discovered devices by MAC
One physical device with several addresses (v4+v6, multiple leases) used to
show as several devices. Discover now folds addresses sharing a MAC into a
single row: new `ips` field lists every address primary-first, `ip` stays
the primary (most recent lease), state is the best among addresses.
MAC-less hosts remain one-per-IP. The panel shows the extra addresses as
secondary chips; naming keys the config entry by MAC whenever it is known.
2026-07-24 01:09:47 +03:00
omar ebe2e7807b feat(stats): drop router-originated DNS queries from insights
The engine resolves domains for itself (node server names, urltest probes,
subscription/DoH fetches). Those queries carried an invalid client address
and still landed in every insights surface. Gate them out at the single
ingestion point (Aggregator.handleEvent): an event with an invalid or
loopback client is dropped before totals, top domains, per-server counts,
the timeline, and the query-log rings. Only LAN-client traffic is collected.
2026-07-24 01:09:47 +03:00
omar c1e1b17a61 feat(profiles): remove preset packs
The built-in block-ads / ru-bypass / private rule bundles are gone:
model.Preset, Model.Presets, the `config preset` UCI section, its render,
the panel Preset type, and every fixture. The generate-side expansion was
already removed with the profile rewrite in the previous commit.
2026-07-24 01:09:47 +03:00
omar 782770f306 feat(profiles): drop default route/egress/schedule overrides; pick uplink from UCI interfaces
A profile is now a pure uplink-conditional rule switch: Name/Enabled/
Priority/MatchIface/Enable-DisableRules/EndpointResolver. The per-profile
DefaultTarget/DefaultEgress overrides and the profile-level schedule window
(SchedDays/SchedStart/SchedEnd/SchedUTCOffset) are removed from the model,
UCI parse/render, the generator, the WAN watcher, and the panel. Rule-level
scheduling is untouched.

The panel's uplink condition is now picked from a dropdown of the router's
UCI interfaces (GET /api/interfaces, same source as the egress picker);
stored interfaces missing from the live list render as stale chips.

generate/profile.go is rewritten here (applyProfilesAndPresets ->
applyProfiles), which also drops the generate-side preset-pack expansion;
the preset model/UCI/panel surface is removed in the next commit.
2026-07-24 01:09:46 +03:00
omarandClaude Opus 4.8 8980a25a59 perf(ci): cache SDK feeds checkouts — the biggest recurring build cost
Audit of the run-51 logs showed actions/cache@v3.3.2 works on the act_runner
(cold: "Cache saved" x4; next job: "Cache restored" in ~2s, npm --fast skip,
usign/dl reused) and the sdk-cache mirror seeds correctly — but the single
biggest recurring cost was NOT cached: `scripts/feeds update -a` re-cloned
base+packages+luci+routing+telephony every run (~7.8 min warm x 4 SDK jobs on
the serial runner ≈ ~28 min/run wasted; github ~1 MB/s from this host).

Cache .cache/feeds/{opkg,apk} (workspace dir, actions/cache-persisted, visible
in the SDK container via --volumes-from) symlinked over the SDK's empty feeds/:
`feeds update` now git-fetches deltas (seconds) instead of full clones, always
checking out feeds.conf's pins. Fail-safe: any error on the cached checkouts
wipes the cache and clones fresh. Key by SDK release (feeds-opkg-24.10.4 /
feeds-apk-25.12.1) — stable across runs, invalidates on an SDK bump; both arch
jobs of a lane share one entry (identical pins, serial runner).

Steady-state warm run: ~60+ min -> ~20-22 min. Also documented in the workflow
header: never key a cache on github.sha — each cache SAVE stalls the act_runner
~3 min, so per-run-changing keys would add +3 min/entry every run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:41:53 +03:00
omarandClaude Opus 4.8 cb983afee1 perf(ci): stall-proof SDK fetch + caching (SDK/dl/go/npm/apt/usign) + concurrency
Builds were dominated by re-fetching the ImmortalWrt 25.12 SDK tarball
(~300 MB) every run, and a stalled downloads.immortalwrt.org transfer wedged
the apk job for 40+ min (plain `wget -q`, no timeout — same class as the
elfutils hang).

- New ci/fetch-sdk.sh (runner-side): cache -> our durable `sdk-cache` release
  mirror -> upstream with a stall-kill (curl --speed-limit 64K --speed-time 60
  --max-time 1800) + 3 retries + zstd-magic/size validation; seeds the mirror
  best-effort (github.token, non-fatal) so cold runs never touch upstream again.
  A 40-min hang is now impossible; the in-container fallback wget also gets
  --timeout=60 --tries=3.
- actions/cache@v3.3.2 (last release on the OLD cache API that Gitea act_runner
  implements; v4/v3.4.x use the new GitHub cache service) for: SDK tarball, SDK
  dl/ sources (hash of package Makefiles; PKG_HASH re-verified so a stale cache
  can't leak a wrong source), Go mod+build (go.sum), npm node_modules
  (package-lock.json) with build-shaterd.sh --fast, apt archives, built usign.
  Degrades safely if the cache server is off — the SDK mirror is independent.
- concurrency group release-${github.ref} cancel-in-progress so a re-dispatch
  cancels the stale run instead of piling up (tags stay isolated).

Signing (usign/apk), both keys, per-arch publish, manual triggers, LOCALMIRROR
and the scoped 4-package collection are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:24:49 +03:00
omarandClaude Opus 4.8 6d2a729eaa feat(panel): gate byedpi egress on package presence
byedpi (ciadpi) ships as a separate optional package; the panel offered the
`byedpi` egress type regardless, so selecting it without the package installed
created a dead, fail-closed egress. Now GET /api/status reports
`byedpi_installed` (exec.LookPath("ciadpi"), os.Stat fallback), and the egress
type picker disables the ByeDPI option with a hint when it's absent. Existing
byedpi egresses are never hidden or rewritten (config is sacred) — shown with an
amber warning and still round-trip on save; only NEW selection is blocked.
Unknown status (older daemon / fetch fail) => no gating.

Bump shaterd PKG_RELEASE 1 -> 2 (the SPA is embedded in the daemon binary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:26:23 +03:00
omarandClaude Opus 4.8 4a1542b30a fix(shater-core): release procd flock so install can't deadlock the pkg manager
On a live `apk add` / `opkg install`, shater-core's post-install hung forever
(observed on BananaWRT 25.12 at "Executing shater-core...post-install", child
`flock 1000` in locks_lock_inode_wait). Root cause: a USE_PROCD init sources
/lib/functions/procd.sh on every rc.common action, whose procd_lock takes a
BLOCKING exclusive flock on /var/lock/procd_<svc>.lock held until the process
exits. shater-cron re-execs itself as the eternal `loop`, so it held that lock
forever; base-files' default_postinst then ran `/etc/init.d/shater-cron enable`
synchronously inside the transaction, blocking on the flock while the package
manager waited on the postinst — a permanent deadlock.

Fix (two layers):
- shater-cron `loop()`: `exec 1000>&-` closes fd 1000 up front so the eternal
  loop never holds the rc.common flock (no-op when procd_lock is absent).
- 30_shater-core: defer enable/restart into a detached (setsid + bounded)
  background block that waits for apk/opkg to finish before touching init.d,
  with all fds to /dev/null (a held stdout pipe would hang apk on EOF too).

Bump PKG_RELEASE 1 -> 2 so existing installs pick up the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:26:22 +03:00
omarandClaude Opus 4.8 b7070ad7e8 fix(ci): install python3-distutils for the ImmortalWrt 25.12 apk-SDK
The apk lane runs the SDK on a bare debian:bookworm host, and the ImmortalWrt
25.12 SDK prerequisite check requires python3-distutils ("Checking
'python3-distutils'... failed. Prerequisite check failed." ->
.prereq-build Error 1), aborting before any package built. The opkg lane was
unaffected because the openwrt/sdk image ships the prereqs. Add
python3-distutils (and python3-setuptools defensively) to the host deps. apk
lane only; opkg untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:31:15 +03:00
omarandClaude Opus 4.8 ea24bad232 fix(ci): make $OUT writable for the SDK user and collect only our 4 packages
Two build-harness bugs surfaced once the SDK builds actually ran:

1. Permission denied writing the feed. ci/build-feed.sh creates $OUT as root on
   the runner, but the openwrt/sdk container runs as the unprivileged `buildbot`
   (uid 1000) — so `cp` of the .ipk into $OUT failed ("Permission denied"),
   yielding 0 packages and then "usign signing failed" (nothing to sign). Set
   `chmod 0777 "$OUT"` on the runner before docker run (a chmod from inside the
   container, as buildbot, cannot fix a root-owned dir). The apk lane already
   chmods $OUT from its root debian container, so it was unaffected.

2. Collecting the whole SDK. ci/sdk-build.sh did `find bin -name '*.ipk'`, which
   swept up the hundreds of prebuilt kmod/base .ipk shipped in the SDK image —
   bloating the feed and signing foreign kmods under our key. Collect strictly
   our four by name (`<pkg>_*.ipk`) and require >=4. Applied the same narrowing
   to ci/sdk-build-apk.sh (apk names carry no arch: `<pkg>-*.apk`), keeping the
   "wrong SDK produced only .ipk" guard.

No change to the feed format/signing (usign/KEY_BUILD/shater-feed.pub, apk EC
key), the package set, or triggers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:09:07 +03:00
omarandClaude Opus 4.8 1d285cf34f fix(ci): route SDK source downloads through OpenWrt CDN mirror
The apk (and opkg) SDK builds intermittently hung fetching build-time
sources like elfutils-0.192.tar.bz2 from sourceware.org: curl's
--connect-timeout covers only the TCP handshake, not a stalled mid-transfer,
so a slow upstream hangs the whole job (no --max-time in OpenWrt download.mk).

Set CONFIG_LOCALMIRROR=https://sources.cdn.openwrt.org in .config before
`make defconfig` in both ci/sdk-build-apk.sh and ci/sdk-build.sh so the SDK
tries the fast OpenWrt source CDN before each package's own PKG_SOURCE_URL —
fixes elfutils and any other flaky upstream. Mirror verified to hold the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:50:42 +03:00
omarandClaude Opus 4.8 5092004b40 fix(ci): init wireguard-go submodule in the opkg build job too
Same root cause as the apk jobs: scripts/build-shaterd.sh builds through a
go.mod `replace => ./submodules/wireguard-go` (AmneziaWG fork, bumped in
16a47b596), and actions/checkout does not fetch submodules by default, so
`go build` died with "reading submodules/wireguard-go/go.mod: no such file or
directory" in the opkg build jobs (x86_64 + aarch64_cortex-a53) as well. Init
only that one submodule — build-harness only, no change to the opkg feed
format/signing (usign/KEY_BUILD/shater-feed.pub) or package set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:22:55 +03:00
omarandClaude Opus 4.8 bcdea8f04c fix(ci): init wireguard-go submodule in apk build jobs
scripts/build-shaterd.sh builds via a go.mod `replace => ./submodules/
wireguard-go` (the AmneziaWG-patched fork), so that submodule must exist or
`go build` dies with "reading submodules/wireguard-go/go.mod: no such file or
directory". actions/checkout does not fetch submodules by default. Init only
that one submodule (public GitHub URL; clients/apple+android are large and
unused) in the additive build-apk jobs — the opkg build jobs are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:16:23 +03:00
omarandClaude Opus 4.8 cd598b0fe2 feat(ci): add apk (ImmortalWrt/BananaWRT 25.12) release lane
Additive next to the opkg/24.10 lane — nothing existing changed. The same 4
packages (shaterd, shater-core, luci-app-shater, byedpi) are built through the
official ImmortalWrt 25.12 apk-SDK and published as per-arch rolling releases
apk-latest-<arch> / apk-<tag>-<arch> (x86_64, aarch64_cortex-a53).

- ci/sdk-build-apk.sh: drives the 25.12 SDK inside debian:bookworm, compiles
  .apk, then `apk mkndx --root T --keys-dir T/keys --allow-untrusted
  --sign KEY --output packages.adb *.apk` — the exact form the OpenWrt 25.12
  buildsystem uses (unsigned members, signed index).
- ci/build-feed-apk.sh: per-arch runner entrypoint (same --volumes-from and
  artifact-order contract as ci/build-feed.sh).
- ci/gen-apk-key.sh: one-shot EC (prime256v1) keypair generator; private half
  -> Gitea secret KEY_APK, public dist/shater-apk.pem committed.
- release.yml: additive build-apk / release-apk jobs; `on:` triggers untouched
  (v* tags + workflow_dispatch); apk release tags deliberately non-`v*`.
- docs-shater/INSTALL.md section 6, .gitignore (out-apk/), dist/shater-apk.pem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:11:23 +03:00
omarandClaude Opus 4.8 16a47b596b chore(awg): bump wireguard-go submodule 1adc4c7 -> 7d15f33
Fast-forward of the AmneziaWG 2.0 fork (submodules/wireguard-go,
tracked via go.mod replace). Brings 3 commits:
  - fix transport padding buffer overrun + harden AWG config guards
  - gate reserved-byte clear on receive so AmneziaWG magic survives
  - re-graft egress-provider API onto AWG2 base (upstream 6f5e8b1947ae)

Verified: native go build with with_awg compiles clean; sing-box check
passes for awg2_basic / awg2_ranged / xhttp_reality.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:10:15 +03:00
omar 44cfe342d3 feat(panel): gray out whole log-file plaques while the file is off
The three dependent plaques (Keep file on flash / File size cap /
Download) only had their inner controls disabled — the rows still
looked live and hoverable. Field gains a disabled prop rendering
set-field--off: pointer-events none, opacity .45, grayscale, flattened
background — the whole plaque reads and behaves as switched off
(aria-disabled included). Wired to !logToFile on all three.

Verified live on the testbed: with the toggle off all three plaques are
inert and dimmed; flipping it back on restores them.
2026-07-23 12:34:17 +03:00
omar 98533fe471 fix(panel): explicit Cache-Control on static responses
embed.FS carries no timestamps, so SPA responses went out with neither
Last-Modified nor ETag and browsers fell back to HEURISTIC caching — a
stale index.html kept showing the previous panel after a daemon upgrade
(user saw pre-c61cfe3a download buttons enabled with the toggle off).

index.html / SPA fallback / favicon / 404s => Cache-Control: no-cache
(revalidate every load); a HIT under assets/ (content-hashed by Vite)
=> public, max-age=31536000, immutable. Guarded by TestStaticCacheHeaders.

Verified live on the testbed: / and /settings no-cache, hashed asset
immutable, missing asset 404 no-cache; a plain reload now picks up the
new SPA.
2026-07-23 12:27:51 +03:00
omar c61cfe3ac4 feat(logsink): turning the log file off erases the saved segments
Operator decision (supersedes ad9781bf): "Log file (downloadable)" off
must leave NO trace — delete the saved log files outright, and gray the
download buttons out while the file is off.

logsink: New and Reconfigure purge the active segment and the rotated
.1 whenever ToFile is off — at the old and new configured locations AND
both standard paths (a Persist flip must not leave a stale copy). A
daemon booting with the toggle off sweeps leftovers from a previous
life too.

panel: /api/log reverts to the pre-ad9781bf precedence (toggle off =>
syslog scrape / '# logging disabled'; segments are never served while
the file is off, even if a leftover exists). SPA: the three download
buttons are disabled when LogToFile is off; note/flash texts and the
?mock fixture say the files were deleted.

Verified on the docker-OpenWrt testbed via the panel: off+apply deletes
/var/log/shaterd.log* (and /etc/shater), buttons gray out; on+apply
starts a fresh file and downloads work again.
2026-07-23 11:59:50 +03:00
omar ad9781bf82 fix(panel): /api/log serves the retained file even with file logging off
Flipping "Log file (downloadable)" off looked like it deleted the logs:
the file stayed on disk, but GET /api/log switched to the logread scrape
and the collected history became undownloadable (user report). The
toggle stops WRITING — it must not disown what was already collected.

New precedence: retained segments are streamed whenever they exist,
prefixed with a '# note: file logging is off …' line when the toggle is
off (even with syslog off too); the syslog-scrape and '# logging
disabled' fallbacks now speak only when nothing is retained. Settings
note/flash texts and the ?mock fixture updated to match.

Verified on the docker-OpenWrt testbed: with log_file=0 the download
returns the note + full history; re-enabling via the panel resumes
appending to the same file with nothing lost.
2026-07-23 11:43:50 +03:00
omar 1482fdf543 feat(stats): persistent log store migrates from sqlite to bbolt
modernc.org/sqlite is the only pure-Go SQLite and costs ~3.5 MB in the
static shaterd link; the stats store never used anything SQL-specific —
it is a ring of two append-only streams with a monotonic seq cursor.
bbolt is already linked via experimental/cachefile, so the swap is free.

sqlitering.go -> boltring.go: buckets queries/conns keyed by 8-byte
big-endian seq (bbolt key order == cursor order), rows as JSON of the
existing LogEntry/ConnLogEntry structs, meta bucket carries the durable
per-stream HWM (same max-only monotonic semantics). The async writer
contract is untouched (writeCh 4096, drop counters, 256-row/500ms
batches, 30s retention tick). Disk cap: chunked oldest-first deletes
with the same hysteresis, then at most one bbolt Compact per pass
(sagernet/bbolt exports Compact) behind the same 110%+1MiB free-space
guard that gated VACUUM. A legacy SQLite-format stats.db (or any
unreadable file) is replaced in place with one warning; open failure
still falls back to the in-memory ring.

Zero user-visible change: the "sqlite" backend selector value and the
Snapshot.Backend string are kept verbatim. Tests ported assert-for-
assert plus new coverage: legacy-file replacement, overflow drops,
memRing parity round-trip, disk-cap convergence.

Router shaterd (linux/amd64): 28,004,478 -> 24,428,670 bytes (-3.58 MB);
modernc.org/* gone from go.mod/go.sum and the dep graph.
2026-07-23 10:57:03 +03:00
omar 53cbdc75f1 build(shaterd): drop with_dhcp from the router tag set
shater resolver types are udp/tcp/doh/dot/local/fakeip; a dhcp:// DNS
transport is never generated, and the slim shater/registry never
registers the transport, so the tag gated nothing in this binary.

D9 in DECISIONS.md and the INSTALL.md tag block updated to match.
2026-07-23 10:36:59 +03:00
omar 057fee8f96 fix(tls): gate the zap-backed ACME log bridge behind with_acme
Upstream defect: acme.go is behind with_acme but acme_logger.go was not,
so go.uber.org/zap linked into every build even with ACME disabled. Only
acme.go references ACMELogWriter/ACMEEncoderConfig, so the twin gate is
behaviour-preserving; a with_acme build still compiles.

Marked lx:acme_logger_gate; upstream-PR candidate (drop the lx block on
rebase once merged). -94 KB on the router shaterd link.
2026-07-23 09:32:38 +03:00
omar 0e5b1afb80 build(shaterd): drop with_clash_api from the router tag set
The admin panel is shater's own web server and generate never emits a
clash_api service (shater/engine/engine.go pre-registers its own
dnstrack.Manager precisely because no api/clash_api observer exists on
the router). With include.Context gone the Clash server was already out
of the link; dropping the tag records the decision. Desktop/CLI LX_TAGS
keeps with_clash_api for external dashboards.

D9 in DECISIONS.md and the INSTALL.md tag block updated to match.
2026-07-23 09:31:42 +03:00
omar d291c90cab build(shaterd): drop with_gvisor from the router tag set
The shater data plane is tproxy/redirect (netplane); generate never emits
a tun inbound, so the userspace gvisor netstack is unreachable code. With
the slim registry it was already dead-code eliminated by the linker —
dropping the tag makes the intent explicit and stops compiling ~3.6 MB of
gvisor sources into the build at all. A future tun inbound would fall
back to the system stack; re-add the tag if that ever lands.

D9 in DECISIONS.md and the INSTALL.md tag block updated to match.
2026-07-23 09:31:14 +03:00
omar cfe87daee3 feat(registry): shater-owned slim protocol registry replaces include.Context
include.Context registers upstream's entire zoo — tor (bine), ssh, snell,
anytls, naive, masque, mdns/resolved, and the api service whose daemon
bridge links grpc+protobuf — none of which shater/generate ever emits.

shater/registry registers exactly what the generator can produce (tproxy/
redirect/direct/socks/http/mixed inbounds; direct/block/selector/urltest/
socks/http/ss/vmess/trojan/vless/shadowtls outbounds + hysteria2/tuic
behind with_quic; wireguard endpoint behind with_wireguard; tcp/udp/tls/
https/hosts/local/fakeip + DoQ/DoH3 DNS transports; xhttp + v2rayquic
transport blank imports), with build-tag stub twins so a tag-less
'go build ./...' stays green. Zero upstream diff.

Measured on linux/amd64 with the D9 router tag set: 47.05 MB -> 31.07 MB
raw (-34%); the unreachable gvisor stack and grpc/protobuf are dead-code
eliminated even before any tag changes. UPX --lzma artifact: 12.49 MB ->
~8.8 MB. Since a UPX-packed binary unpacks fully into anonymous pages,
the same ~16 MB comes off resident RAM on the router.
2026-07-23 09:30:11 +03:00
omarandClaude Fable 5 cb4ab3b6a1 feat(panel): daemon-log settings + download UI
New "Daemon log" group on Settings (Faceplate): the LogLevel verbosity
select (relocated, honest note — "none" is a turn-down to panic-only, not
a true off; failures still alert), LogToFile / LogToSyslog / LogPersist
toggles, a validated LogMaxKB editor (128–8192), and three download
buttons (day / 3 days / everything) → downloadLog() fetches
GET /api/log?range=… with the session cookie, filename from
Content-Disposition, blob save. Honest warn plates: file-off = only a
slice of the syslog ring (ranges approximate); both-off = nothing is
written anywhere; flash vs tmpfs (lost on reboot, wears flash, ~33 MB
budget). Globals type gains LogToSyslog/LogToFile/LogPersist/LogMaxKB
1:1 with the backend; mock.ts mirrors the honesty contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 01:18:56 +03:00
omarandClaude Fable 5 f573af9a5f feat(logsink): shaterd's own operational log — download, size cap, real disable
The daemon's own log (engine + control-plane) went only to os.Stderr →
procd → the logread RAM ring: no file, no wall-clock timestamps, no size
cap, and "LogLevel=none" silenced ONLY the engine while the control-plane
kept writing at trace. So "download last day/3d/all", "limit the size" and
"fully turn it off" were all unmet.

New shater/logsink: one long-lived, atomically-reconfigurable Sink that
receives BOTH halves' byte streams, stamps every complete line with a UTC
RFC3339 wall clock (what makes date ranges real), and fans each line to a
size-capped 2-segment rotated file (ToFile) and/or the real os.Stderr
(ToSyslog). Both off = the line is dropped — the only true full silence.
Persistent path sits behind a stats-style disk-free guard (suspend+warn
once, auto-resume); tmpfs path is bounded by the cap itself. ANSI stripped
from the file copy only.

Wiring: control-plane via log.SetStdLogger over the sink; engine via a new
box.Options.DefaultLogWriter threaded into all three box.New sites
(apply/close-then-start/restore) by engine.SetDefaultLogWriter; live
reconfigure on every apply.Reconcile (SIGHUP / control socket / panel
apply) so panel changes take effect without a daemon restart.
controlLogLevel now makes the control-plane respect Globals.LogLevel
(silent vocab → panic-only; unknown → warn, mirroring generate).

Globals: LogToSyslog/LogToFile (default true), LogPersist (default false =
/var/log tmpfs; true = /etc/shater flash), LogMaxKB (default 2048, clamped
[128,8192]; 0 = default, not off — LogToFile is the off switch). UCI
parse/render/aliases + ValidateGlobals clamp-warn.

Endpoint GET /api/log?range=1d|3d|all (session-gated): streams the log line
by line, oldest segment first, filtered by the timestamp prefix; UTC
attachment filename. Honest fallbacks — file off + syslog on → a
"# note: … syslog ring only, ranges approximate" comment then a
`logread -e shater` scrape; both off → "# logging disabled". Unknown range
→ 400.

init.d: shater/shater-cron gate their `logger -t` status lines on
log_syslog so "logread off" is honest at the shell layer too.

Tests: logsink rotation-cap/timestamp/toggle-gating/engine→sink,
model round-trip + validate, endpoint session-gate/range/fallbacks.
VM-verified on QEMU (x86_64, OpenWrt 24.10): download+ranges, size-cap
rotation, file-off/full-off, persistent path, live reconfigure — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 01:18:45 +03:00
omarandClaude Fable 5 278e50aa61 fix(netplane): divert plan honors profile rule overrides (no more silent leak)
A rule disabled in UCI but force-enabled by the active profile, with an
iface:/zone: source outside the tproxy-inbound set, got its engine route
rule but no nft divert — its traffic never entered the engine, and the
fail-closed forward drop and accept_local sysctls skipped the device too.

Root cause: generate applied profile enable/disable in its own
effectiveRules while netplane read raw Rule.Enabled. Fixed with one shared
resolver in the leaf model package (ResolveActiveProfile +
ApplyProfileRuleOverrides) that both the engine route plan and the nft
divert plan consult, so they can never disagree about which rules are in
force. applyLocked now threads a single now through generate + nft render +
sysctls, closing the schedule-boundary race between the two planes.

Verified: a profile-enabled iface rule now joins the divert set, the
per-rule tproxy emit, the fail-closed drop and the accept_local sysctl;
the inverse (profile-disabled) drops the device. Parity regression on the
existing generate profile tests stays green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:51:57 +03:00
omarandClaude Fable 5 693db39642 fix(schedule+profiles): evaluate windows at a captured UTC offset; stop claiming schedules are ignored
The schedule evaluator called time.LoadLocation, but the router binary
embeds no tzdata and OpenWrt ships none — so LoadLocation always failed
and windows silently ran in UTC while the panel promised local time.

- Windows now anchor to SchedUTCOffset (minutes east of UTC), which the
  panel captures from the editing browser on every schedule save; the
  daemon evaluates now.UTC()+offset with no location database. This
  sidesteps the weekly-recurring day-shift that a full local<->UTC
  conversion cannot express in one window. SchedTZ is deleted (documented
  in the removed-options list; old configs parse and drain it). DST is a
  stated limitation (followed on re-save). generate/schedule.go collapses
  from a second copy of the evaluator to a thin adapter over the model one.
- The iface-profile schedule was honored by the WAN watcher since
  08d5d6cc, but generate warned "the watcher does not look at the schedule"
  and the panel muted the editor with "the router ignores the schedule" —
  both false. Warning and lie removed; the editor is live and labelled
  "applies together with the uplink match".
- Stale fictions: FEATURES.md nftset/FakeIP-mode MVP line and the shipped
  conffile's dead `option dns_mode 'nftset'` corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:30:21 +03:00
omarandClaude Fable 5 b50a9cc660 fix(dns): honor DNSRule.Order, warn on dropped match_src, widen BlockDoH IPs
- The generator iterated dns_rules in raw slice order and never read
  DNSRule.Order; first-match "top to bottom" was true only because the
  panel pre-sorts. Now sorted by (Order, index) like route rules, so a
  hand-edited UCI or any API client gets the declared order.
- dns_rule match_src silently dropped zone:/iface:/MAC entries (the
  in-engine DNS plane matches source IPs only), which could widen a rule
  to ALL clients or skip it entirely. Each dropped entry now warns, with
  the consequence spelled out.
- BlockDoH :443 IP list was incomplete (no NextDNS anycast, no actual
  cloudflare-dns.com 104.16.x, sparse v6). Extended across all listed
  providers, now accepts anycast CIDRs, with a maintenance note that the
  list is manual. The hostname NXDOMAIN + canary layers already cover
  resolve-by-name; UI still says "well-known providers only".
- Panel: intercept-OFF copy no longer overstates the bypass (plaintext to
  external resolvers is already hijacked by the D14 catch-all); allowlist
  note gains the per-device-Block-wins caveat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:30:06 +03:00
omarandClaude Fable 5 aec82d4444 fix(insights): honest DNS classification, real timeline axis, LAN-attributed device stats
Audit found the Insights numbers were real but mislabelled:

- "Blocked" counted every NXDOMAIN, upstream timeout and zero-answer as a
  block. Now blocked = strictly the engine's own filter verdict
  (dnstrack.SourceFiltered: D15 blocklist + BlockDoH predefined-NXDOMAIN).
  Failures (timeout/SERVFAIL-reject) become their own `failed` category;
  the three counters are mutually exclusive and sum to Queries. The DNS
  log "block" tag follows the same signal.
- Per-minute sparkline positioned buckets evenly by index over a sparse
  slice, so "60 min" could span hours. Now points sit at their real
  Bucket.Minute, gaps render as gaps, and the label states the actual
  span + active-minute count instead of a fictional "last N min".
- Per-device domains skipped the LAN filter every other view applies, so
  the router's own urltest/sub-fetch dials appeared as a phantom WAN-IP
  device. Now folded into the `router` pseudo-device like the DNS log.
- Honest labels: "Outbounds/exits" -> "DNS lookups per exit"; top
  domains/hosts meta "N tracked" -> "top N shown". Overview query log
  shows the real per-device attribution, not the resolver tag; stale
  "DNS events have no client IP" comments removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:29:53 +03:00
omarandClaude Fable 5 04bf624131 fix(generate): the unknown-strategy hint lists random too
The whitelist accepted random but the human-readable "Supported:" tail
still named only four strategies — caught live on the VM where the model
and generate warnings disagreed about the supported set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:43:46 +03:00
omarandClaude Fable 5 238f42e6d5 feat(model+panel): delete the node_down fiction; validate Group.Strategy; retire stale doc-comments
- node_down is gone from AlertEventNames: nothing ever fired it, so a
  channel subscribed to it was silence dressed as monitoring. An old
  config's `list event 'node_down'` now warns as an unknown event and is
  dropped. The accepted and emitted sets now coincide; the reserved-event
  branch of ValidateAlerts stays as the guard against future divergence.
- model.ValidateGroups + KnownGroupStrategies: a typo'd strategy is
  warned at validation time (was: silently built as least_test with only
  a generate-time warning). Mirrors generate's warnGroupStrategy list.
- doc-comment honesty: random is a real engine mode (api.ts), sqlite
  stats backend is a real persistent store (api.ts + model.go), resolver
  type list gains tcp, pages/index.ts no longer claims Placeholder pages,
  failover doc says fail-back exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:31:33 +03:00
omarandClaude Fable 5 e60ad231f4 feat(group): failover fail-back — a revived higher-priority node re-takes the slot
New balancer flag priority (option.URLTestBalancerOptions.Priority): the
pool is re-derived from CONFIG ORDER every health-check tick via
balancePoolPriority/planPriorityPool — the first live member owns slot 0,
so when the top node answers probes again traffic returns to it on the
next tick (30s failover interval). Probing walks top-down and stops at
the first live node, so the steady-state cost stays one probe per tick.
Replace-in-slot deliberately does not apply here: failover forces sticky
["none"], so relocating nodes across slots breaks no flow keys. Plain
round_robin/random paths are untouched.

failoverBalancer() now emits Priority:true; the KNOWN LIMITATION note and
the panel's "nothing brings it back" blurb are gone because the
limitation is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:31:17 +03:00
omarandClaude Fable 5 026bba904f feat: real random strategy over the live pool; sweep becomes an honest knob; multi-WAN egress gateway
- random is a REAL urltest mode (lx SPEC 019 v2): uniform draw over LIVE
  slots only, pool sized to every member; dead slots keep their place
  (never-shrink) but are never picked, for random AND round_robin AND
  sticky (degrade-to-live). All-dead pools fall back to Select.
- Globals.SweepInterval + Globals.GroupHealth master switch, resolved by
  one pure function (model.SweepSchedule) shared by validator and apply;
  unparseable is warned-and-ON, never silently off. ConfigureSweep no
  longer resets the cursor on every cron reconcile (release blocker:
  a ~6-min cycle was restarted every 60s and never completed).
- multi-WAN egress gateway: ubus netifd status -> uci static -> main
  table; a gatewayless non-P2P egress warns CRITICAL instead of silently
  blackholing the second uplink.
- endpoint resolver (route.default_domain_resolver): bootstrap-direct
  clone of a named resolver, profile override beats globals.
- chains are composable: chain: hops flatten recursively, cycle-guarded,
  entry egress lifts only at position 0 (fail-closed mid-path).
- group test publishes its scope so "measuring" lights only the cards a
  run covers; health run is explicitly global (all_nodes).
- panel: biased-sample honesty (no ratio until a failure CAN be on
  record), profiles auto-pin plate, sweep/GroupHealth settings UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:20:26 +03:00
omar 08d5d6ccb8 feat: node health belongs to groups; delete the options that never existed
Two threads, both from the same question: does this setting do what it says?

## Health is per-group, because a dial path is per-group

Overview reported "119 up / 179 untested" over all nodes, and Nodes showed a
per-row ping. Both measured the wrong object. A group with an egress binding does
not dial the base node outbound at all — generate materialises per-member copies
(group-<name>-m<i>-<member>) and the group balances over those. So a node can be
alive direct and dead through the tunnel a group is bound to, and the panel said
"up". The same node in two groups with different egresses is two states that were
being collapsed into one number.

No new prober was needed: the engine already keeps a process-wide
urltest.HistoryStorage keyed by outbound tag, groups already probe their own
members into it, and stats already reads it — we simply projected it onto base
tags only. The copy tag carries the member NAME, so recovery needs no change to
generate. GET /api/groups/health now reports alive/dead/untested per group, with
an opt-in member list; the same summaries ride /api/stats so Overview needs no
extra poll.

Presentation is "alive / tested" with the untested remainder as a quiet aside,
never folded into dead: groups probe lazily and only while in use, so on a fresh
boot with a 376-node subscription almost everything is legitimately unmeasured,
and calling that "down" would scream catastrophe exactly when nothing is wrong.

Three things this exposed, all fixed here:

- TestAllNodes enumerated only om.Outbounds(), which by design excludes
  endpoints. Every WireGuard/AmneziaWG node read "untested" forever no matter how
  often the button was pressed — on a product whose driving requirement is AWG.
- Our ProbeFailDelay sentinel is gone from the engine's history entirely. It was
  safe for least_test (slowest wins last) but round_robin's pool planner treats
  any entry as alive, so a dead node could occupy the single slot of a failover
  group — pinning failover to a corpse, which is the one thing it exists to
  prevent. Failures now live in an engine-side overlay, invalidated by timestamp
  against any later success; the engine's history holds measurements only.
- Writers now measure with the probe URL of the group that owns the tag. A manual
  run used the global URL and overwrote a group's own measurement, leaving
  least_test comparing latencies to different servers. Where one tag is claimed by
  two groups with different URLs the ambiguity is inherent to the engine's keying,
  so we use the neutral global URL and say so rather than picking a silent winner.

A scheduled sweep (engine/sweep.go, on by default) fills what nobody probes:
24 measurements per 10s tick, 12 in flight, skipping anything fresher than 5
minutes — ~5 min per full cycle on the production config. The freshness gate is
load-bearing beyond cost: testNodes skips a member whose history is younger than
the group's interval, so a sweep that kept refreshing would starve a failover
group's own 30s check. It is a layer under group-local probing, never a
replacement.

## Options that did not exist are deleted, not decorated

Audited every enumerated choice the panel offers against what this fork actually
implements (constant/, option/, protocol/group/, dns/), and split the results into
works / synonym / fiction. Fictions are removed outright — pre-release, so no
legacy path is kept for values nobody has.

Deleted: Group.Strategy random and leastload (both silently became least_test);
Egress.Type proxy and block (emitted no outbound at all — every binding dangled
and the traffic left over the plain WAN with the real IP); alert event node_down
(no emitter anywhere); Globals.DNSMode, Inbound.Sniff, Profile.ProbeURL/ProbeMode,
Node.XUDPConcurrency/XUDPProxyUDP443, Egress.Target.

Repaired instead of removed, because the engine could do them all along:
LogLevel "none" (asked for silence, got default verbosity — now LogOptions.Disabled);
Subscription.Format (the hint was stored, badge-rendered and ignored — the sniffing
parser always ran); Ruleset.Format (never read; the extension decided);
Group.Strategy failover (urltest + round_robin + pool 1 / tolerance 0 is exactly
"first working node in order" — verified through box.New with a sensitivity control).

Relabelled where the words lied: Single promised "first up" but a selector never
checks liveness; inbound "http" opens Mixed and answers SOCKS5 on the same port.

An unresolvable egress binding no longer fails open. It resolves to block, so the
bound traffic stops visibly instead of leaving with the real IP. Refusing the
config was the alternative and is worse: a dead engine under a closed kill-switch
blackholes the whole LAN over one mistyped name.

Also: Egress.Port no longer defaults to 1080 for every type. The parser invented
it, render persisted it, and the new "port is ignored" warning then fired on a
correctly written config — a warning on a healthy install is how a findings list
gets ignored.

## Geo data is no longer hardwired to one publisher

sing-geoip publishes country codes and nothing else — 238 files, all two-letter.
So "route Netflix around the tunnel" meant loading geoip-us: 159,125 prefixes and
~20 MB of kernel memory for something the netflix list does in 108 prefixes and
~14 KB. Provider selection is now a chain (generate/geosource.go): country codes
still resolve to SagerNet byte-identically, everything else to Loyalsoldier, and
metacubex adds AS<number> routing. Third-party .srs was verified to load with our
own reader (v1/v2 against our v5 ceiling) before any of this was built.

The ruleset preflight reads four header bytes over a ranged GET instead of HEAD,
so a rule-set whose format version we cannot parse degrades like an unreachable
one — that case would otherwise abort engine start, which is how the LAN goes down.
2026-07-21 14:52:24 +03:00
omar f6cc117781 fix(panel): Overview reports state, not internal flags
The status strip carried five pips — ENGINE active, UPTIME, CONFIG enabled,
DATA PLANE installed, KILL-SWITCH — and the user had to AND three of them
together to learn whether they were protected. `plane` and `engine_running`
already encode that, and more precisely than the booleans did. UPTIME duplicated
the Engine module's "running for"; KILL-SWITCH duplicated the module directly
below it. Collapsed to one derived line phrased in terms of traffic:

  Protected — traffic from your network is going through the tunnel
  Traffic blocked — the tunnel is down        (hold, amber)
  Not protected — traffic is going out directly (none + fail-closed, crit)
  Not protected — running direct               (none + fail-open, amber)

hold and none stay distinct: one is the kill-switch catching it, the other is
no safety net at all. Nothing was lost — every removed value still lives in the
module that owns it.

Findings are now routed by severity instead of all landing on the front page
(panel/src/findings.ts):

  critical / warning -> Overview. Something needs attention.
  info               -> the page that owns the setting.

An info finding is a statement about the configuration: it never clears and asks
for nothing, so a permanent front-page entry only teaches people to skim the
list — which is how a real critical finding gets missed. The untunnelable note
now renders inside the Networks "Other traffic" section, beside the control it
describes. With nothing needing attention the section renders nothing at all.

Also fixed, found while auditing the rest of the labels: the Kill-switch module
read ARMED / policy: fail-closed with a green lamp even at plane=none — a
reassuring light directly beneath a readout saying nothing is protected. A
fail-closed setting is only armed if something is installed to enforce it, so it
now reads NOT IN EFFECT with a crit lamp and a "blocking now: no — nothing
installed" row; policy -> setting.

planeState.ts became the single source of the wording, and the plane banner was
dropped from Overview — it exists to carry the alarm to pages with no status
readout, and stacked under the new line it just said the same thing twice.

Verified against the live daemon on the bench: healthy, critical and hold states
all render correctly, console clean, note present on Networks and absent from
Overview.

.gitignore: MemPalace per-project files, added by the tooling.
2026-07-21 11:13:48 +03:00
omar 7d5d724bba feat(netplane+panel): scope the untunnelable drop by destination; theme switch
Traffic TPROXY cannot carry (ICMP, IGMP, ESP/AH, GRE) was dropped for the whole
LAN regardless of routing. A box configured to tunnel only 8.8.8.8/32 still lost
ping to the entire internet, and with the shipped config RU addresses were
unpingable even though `ru-direct` sends them out unproxied — the very path where
TCP already exposes the real IP, so the drop prevented no leak at all.

The drop is now scoped to destinations the rules actually tunnel:

  iifname "br-lan" meta l4proto != { tcp, udp } ip daddr @unt_d4_1 accept
  iifname "br-lan" meta nfproto ipv4 drop

Destination sets come from the engine's already-parsed rule-sets via
ExtractIPSet(), so no .srs parsing and no second read of the bbolt cache the
engine holds locked. Rules are taken from the generated route rules, not the raw
model, so preset packs, WAN-profile overrides and schedules are all included.
Domain/geosite matchers are skipped when classifying: a packet with no stream
carries no domain, so such a rule can never apply to it.

Every policy line carries `l4proto != { tcp, udp }`, so no destination decision
can ever accept TCP/UDP — fail-closed is structurally untouched. Anything the
walk cannot prove direct (list not yet fetched, logical rule, unknown action,
inverted match) falls through to the drop and says so via an info finding.

No element cap: a continent-scale list loads in full. Measured on the bench with
geoip-us — 4s apply, 1.25 MB ruleset in 29.5k lines, ~27 MB RSS growth, engine
healthy. Cost is reported, not enforced; `untunnelable=direct` loads no sets.

Also fixed here, found while building it:
- plan warnings were computed and dropped, never reaching the operator; routing
  them through the netplane channel was wrong (it marks everything critical by
  construction), so they get their own info-level path
- nft ran with no timeout while holding the apply flock: one wedged invocation
  would have deadlocked every later apply, reconcile and teardown. 60s cap; the
  ruleset commits as a single netlink transaction, so killing it is safe
- set elements were emitted as one 3.1 MB line the lexer would hold as a single
  token; now wrapped at 8 per line (identical to nft, readable when debugging)
- untunnelable copy still claimed ping never works; rewritten for the new
  semantics across all three modes

panel: the theme switch read as a power toggle — it reused the component that
turns features on and off and sat inside the status cluster next to the ONLINE
lamp, so in light theme it looked like a switched-off appliance. Now a two-key
sun/moon selector, both states always visible (neither theme is an "off"), the
engaged key raised and lit by shading rather than accent colour, separated from
the indicators by a groove.

Verified on the OpenWrt bench: RU addresses ping, non-RU stay blocked, TCP routes
unchanged through the tunnel, DNS filtering and Block-DoH unaffected.
2026-07-21 10:44:36 +03:00
omarandClaude Opus 4.8 020dedf589 feat(daemon+panel): per-group egress binding, group test, service uptime
Group egress — for the case where the protocols themselves are DPI-blocked:
every node in the group dials ITS OWN server through the chosen egress (an
AmneziaWG tunnel, say), so the provider sees tunnel traffic instead of a VLESS
handshake. It binds the outgoing dial, not post-proxy traffic.

The binding is per-group, and that is the whole difficulty: group members are
SHARED outbounds, so two groups built from one subscription — one bound, one not
— would either leak the binding into the unbound group or fail to apply it. The
members of a bound group are therefore materialised as per-group copies
(group-<g>-m<i>-<member>), reusing the same rebuildNode the chain builder uses
for per-hop copies. Copies are made only when Egress is set, so an unbound group
over a 331-node subscription does not double the engine config. Copy tags are
checked against the node/group/egress/copy namespaces; a collision skips the
member with a warning rather than shadowing a real node. Precedence is chain hop
-> Node.Egress -> Group.Egress: a node pinned to a particular uplink was pinned
for a reason the group cannot know. A member whose copy cannot be built is
dropped rather than falling back to its unbound tag — falling back would leak
exactly the traffic the binding exists to hide.

Group test answers "what am I exiting through, and how fast": selected member,
latency, exit IP and country, via cloudflare.com/cdn-cgi/trace (country comes
free, so no GeoIP database on the router) with api.ipify.org as fallback. The
probe is pinned to the group's own outbound and refuses the direct outbound — a
direct answer would print the ISP's address and claim the tunnel works when it
does not. Measuring latency but failing to resolve the address stays ok=true
with an empty exit_ip; that is a working tunnel, not an error.

Uptime: /api/status gains started_unix + uptime_seconds, measured from process
start over a monotonic seam so an NTP step on an RTC-less router cannot be
reported as uptime. It is the daemon's uptime, not time since the last apply.

Also fixes: renaming an egress did not rewrite Group.Egress, silently dropping
the group back to the default route.

Verified on the testbed with the real 331-node subscription: two groups over one
subscription, one bound, one not — the bound group selected
group-auto-egress-m130-IE-trojan-141 while the unbound one selected the shared
IE-trojan-141, exit IP and country resolved for both, no group warnings.
Measured cost of binding a 331-node group: engine outbounds 335 -> 666, config
50 KB -> 114 KB, daemon RSS 62 MB -> 75 MB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 20:13:17 +03:00
omarandClaude Opus 4.8 ecb9d8e6ec docs(decisions): correct D17 — url blocklists were already fixed, not left open
I wrote the gap up as open while reviewing an agent report I had not yet seen;
the hosts/plain/AdBlock parse-and-compile path had in fact landed in the same
commit. Records the measurement that settles the disk question: StevenBlack's
2.4 MB of text compiles to 80873 domains in a 491 KB .srs, so it ships in the
production posture instead of being traded away for the 8 KB geosite list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 17:24:37 +03:00
omarandClaude Opus 4.8 a1ac74f735 fix(apply): log config warnings only when the set changes
On a healthy router the warning set is reprinted by every reconcile — once a
minute from cron plus every hotplug event — so logread filled with the same
line forever and buried the warnings that matter (a blocklist that failed to
load, an interface the kill-switch does not cover, a missing data plane). On a
router logread is an in-memory ring buffer, so this also evicted the history
needed to investigate an incident.

Warnings are still returned in full by GET /api/status on every request; only
the logging is deduplicated, keyed on a fingerprint of the set. Message texts
carry volatile parts (free MiB on /overlay, compiled domain counts, the address
inside a network error), so digits are normalised for comparison only — the
logged and API-returned text is untouched. A restart reprints the full set, and
clearing the last warning logs one line saying so.

Also fixes the severity-to-syslog mapping: an [info] warning was being emitted
at WARN, so anyone filtering on WARN saw noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 17:09:51 +03:00
omarandClaude Opus 4.8 1b4ed3e3da docs(decisions): D17 — audit outcomes that constrain future work
Records the positions the audit changed: fail-closed must cover "engine never
started" (holding plane), engine start must not depend on the network (remote
rule-set preflight, with the deferred cache-seed fix noted), BlockDoH needs no
route-plane upstream exclusion (engine dials bypass route rules), DNSMode is
unimplementable and its control was removed, TPROXY's inability to carry
ICMP/IGMP/ESP/GRE is now an explicit 3-way policy, and fail-open degradations
must surface in the panel rather than only in logread.

Also flags the contradiction left open: D15 promises seeding StevenBlack/OISD/
AdGuard while blocklist source=url accepts only compiled .srs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 16:59:49 +03:00
omarandClaude Opus 4.8 0e899a5170 audit(v0.2): full-stack hardening pass — release blockers, silent failures, dead knobs
A ground-up audit of the whole v0.2 stack by 8 parallel agents (DNS generate,
routing generate, model/parse/subscribe, netplane/apply/engine/alert, stats +
panel API, panel frontend, OpenWrt packaging), with every finding reproduced or
verified on the OpenWrt QEMU testbed. ~60 defects fixed, each with a regression
test that was checked to FAIL against the old behaviour.

RELEASE BLOCKERS
* Engine-start failure left the data plane ABSENT: with kill_switch=closed the
  router silently degraded to a plain OpenWrt box — no tunnel, no filtering, no
  kill-switch — while the panel looked healthy. Reproduced live. Now any
  engine-start failure installs a fail-closed holding plane (forward blocked,
  LAN-to-LAN and management preserved) and reports plane=hold/none.
* An unreachable remote rule-set aborted engine start entirely, so a router that
  booted before its ISP link came up ended with a dead LAN and no way to recover.
  Remote lists are now preflighted and skipped with a loud warning instead.
* `geosite:` in a routing rule hard-errored box.New — one legacy rule took the
  whole LAN down. Same class: unvalidated CIDR / port / regexp, and marker-only
  list entries ("." / "keyword:"). A lone `keyword:` also silently NXDOMAINed
  the entire internet.
* Fail-closed drop only covered tproxy inbounds, not interfaces diverted by rule
  sources — engine down leaked those networks to WAN in plaintext (4f618140 redux).
* UCI injection: a newline in a subscription-supplied node name broke out of the
  line-oriented config and wrote attacker-controlled sections.
* Bootstrap deadlock: the daemon refused to start while disabled, but the panel
  IS the daemon — a fresh install could never be configured from the UI.

SILENT FAILURES (the audit's main theme)
* per-device DNS block ignored the `suffix:` prefix — parental control that
  quietly didn't block. Unknown `word:` prefixes now warn instead of vanishing.
* sqlite reused `seq` after retention wiped rows, stalling the live log forever.
* `after=` cursor returned the NEWEST rows, permanently skipping bursts.
* Stats emitted null arrays on a freshly booted router, blanking Overview.
* Alerts fired twice per incident; new_device alerts swallowed all but the first
  device in a 60s window.
* Disabled subscription nodes were silently re-enabled on every refresh.
* Invalid Include/Exclude regexes failed OPEN, disabling the whole filter.

DEAD KNOBS — wired or honestly removed
  ru-bypass preset (emitted an unsupported geoip: matcher) -> real geoip rule-set
  Globals.ResolverFallback  -> implemented via evaluate + match_response chain
  Globals.DNSMode           -> unimplementable by design; control removed, fake-IP
                               documented via a type=fakeip resolver instead
  Rule.Kill                 -> implemented (default | closed | open)
  Rule.Egress               -> was read by nobody; multi-WAN binding silently no-op
  ExpireAlertDays + quota   -> subscription-userinfo parsed, persisted, alerted
  StatsBackend hot-switch   -> store is re-created on change
  Inbound.Sniff             -> documented as vestigial (sniffing is a route action)

NEW
* Globals.Untunnelable (block | icmp | direct): TPROXY can only carry TCP/UDP, so
  ICMP/IGMP/ESP/GRE were dropped with no explanation — ping simply didn't work.
  Now an explicit policy, defaulting to the previous behaviour, and explained in
  the UI by consequence rather than by protocol.
* apply now surfaces its warnings through /api/status (severity/section/name), so
  fail-open degradations are visible in the panel instead of only in logread.
* Panel: Networks page (which LAN networks are intercepted + inbound editor),
  DNS-rules editor, subscription quota/expiry, plane banner and findings list.
* Control-socket client got per-verb timeouts — a wedged daemon used to pile up
  one stuck `shaterd status` per minute until OOM.
* cache.db is now bounded (8 MiB, tmpfs fallback below 24 MiB free): on a 98 MB
  rootfs with ~33 MB free it could otherwise grow past what an upgrade needs.
* OpenWrt packaging: nftables-json + ca-bundle deps, postinst restart on binary
  upgrade, idempotent rt_tables seeding, cron gated correctly.

VERIFIED ON THE TESTBED
  fail-closed holds with the engine frozen; offline boot now starts the engine;
  RU destinations go direct while the rest goes through a node (per-connection
  proof); ads NXDOMAIN with allowlist override; DoH blocked while the configured
  upstream still resolves; sqlite history survives a daemon restart; a failed
  apply restores the previous config without dropping the engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 16:43:56 +03:00
omarandClaude Fable 5 cd6721155a feat(daemon+panel): device policy simplification, inline rename, Block-DoH
Devices lose per-device Proxy/Target (routing is expressed with ordinary
routing rules whose Source picker targets a device); a device is now pure
DNS policy: identity + Enabled + Block/Allow. The panel drops the
"Route through proxy" toggle and Exit picker, and gains inline rename
(pencil -> input; renaming an unmanaged device upserts it into managed).

New Globals.BlockDoH (uci block_doh, default off): engine-level block of
known public DoH resolvers so clients fall back to plaintext :53 that the
engine intercepts. DNS layer answers the DoH hostnames + the Firefox
canary use-application-dns.net with NXDOMAIN; route layer rejects :443
(tcp+udp, HTTP/3 covered) to the hostnames and dedicated resolver IPs.
Hostnames/IPs that are themselves configured upstream resolvers are
excluded with a warning (never the canary). Reject rules set
Method=default explicitly - a directly constructed "" bypasses the
UnmarshalJSON normalisation and panics the engine at first match
(found live on the VM, regression-tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-20 09:43:50 +03:00
omarandClaude Opus 4.8 e3aebcefec chore: gitignore the throwaway trafgen dir so it stops being committed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 21:30:20 +03:00
omarandClaude Opus 4.8 0062859478 feat(daemon+panel): "Intercept all DNS" — force every client through the engine
Per-device DNS block/allow and the DNS filter only caught DNS that the
tproxy forward-divert steals (client -> external resolver). A client using
the router itself as DNS hit dnsmasq directly (fib daddr type local bypass)
and slipped every filter. New Globals.DNSIntercept (uci dns_intercept):
when set, nft diverts all LAN :53 (tcp+udp, v4+v6, source-IP preserved)
into the engine ABOVE the fib-local bypass, so even DNS addressed to the
router is hijacked and per-device rules apply to everyone. DoT/DoQ :853
stays rejected (clients fall back to plaintext); DoH :443 can't be
intercepted (stated in the UI). .lan + private reverse zones are forwarded
back to dnsmasq (127.0.0.1:53, direct detour) so local names still resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 21:28:55 +03:00
omarandClaude Opus 4.8 b1f432e307 fix(daemon+panel): Source picker lists LAN networks by firewall zone, not IP
A WAN uplink on a private DHCP address (provider double-NAT) was wrongly
offered as a LAN source. Interfaces() now tags each interface with its
firewall zone (one `uci export firewall` pass, reusing the zone scanner),
and the picker treats an interface as a LAN network when it has a subnet
and its zone is not wan* — falling back to the private-subnet heuristic
only when the zone is unknown. VPN tunnels self-filter (no subnet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 20:59:34 +03:00
omarandClaude Opus 4.8 31d77244f4 feat(panel): rule Source becomes a picker — LAN networks, devices, custom
The blind free-text Source input in both rule forms is now SrcPicker: a
chip slot whose popover offers the router's real private subnets (from
/api/interfaces, host bits normalized to the network address), the
discovered devices by name (the bare IP is what's stored), and a
validated custom IP/CIDR input. Empty = "everyone · all LAN clients".
Existing hand-typed Src values classify back into device/network/custom
chips by value, never rewritten. Shared module cache: one
interfaces+devices fetch per session across all open forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 20:50:30 +03:00
omarandClaude Opus 4.8 2711225712 feat(panel): New-rule form drops the free-text Domain(s) matcher
Domain matching belongs to rulesets (that's what they are for) — the Match
picker is now rulesets only / ip-cidr / port, with the value input hidden
for rulesets-only. The edit form keeps a "Domain(s) — legacy" field ONLY
when a rule already carries free-text domains, so old rules stay visible
and clearable instead of silently preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 18:24:48 +03:00
omarandClaude Opus 4.8 0cb5f8643a chore: drop trafgen — a throwaway VM traffic generator, not part of the product
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 18:22:17 +03:00
omarandClaude Opus 4.8 e14dd96aaf feat(daemon+panel): multi-category geo rule-sets with chip input
- model: Ruleset/Blocklist/Allowlist Category -> Categories []string
  (uci `list category`; a legacy lone `option category` still reads as a
  one-element list and migrates to the list form on the next write)
- generate: one remote .srs per category, tag rs-<name>-<category>
  (bl-/al- for the DNS filter); a rule referencing the ruleset matches
  every category's set; non-geo sources keep their old single tags
- panel status: rows gain `category`; tag->(name,category) resolved from
  the model, not string parsing (names/categories may contain dashes)
- CatSuggest is now a chip multi-select: pick from the SagerNet base ->
  chip with a status LED (green = from base/verified, amber = added
  offline "anyway"), duplicates flash the existing chip, Backspace/×
  remove, and free unpicked text never survives blur or save
- Routing/DNS forms save Categories (>=1 chip required); ruleset rows
  show `geosite · youtube +2`, freshness groups per name (oldest wins,
  Update now refreshes every category's tag)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 18:21:16 +03:00
omarandClaude Opus 4.8 24b3fafb89 feat(panel): Faceplate-native CatSuggest replaces the OS datalist
The native <datalist> popup is an unstyleable browser widget that clashed
with the panel. CatSuggest is an instrument-styled readout docked flush
under the Category input: SAGERNET BASE · n shelf label, sunken dense mono
list, matched substring lit in the accent, LED bar on the active row,
green exact-match footer, amber not-in-base warning. Keyboard: arrows /
Enter / Esc; combobox ARIA; both themes via tokens; reduced-motion safe.

Fix along the way: the row is a flex container with a gap, so bare text
nodes around <mark> became separate flex items and the gap split the
category name itself — the name now renders inside one span.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 18:04:10 +03:00
omarandClaude Opus 4.8 14bf7124c7 feat(daemon+panel): geo category auto-suggest + routing rule editing
- GET /api/ruleset/categories?source=geosite|geoip — daemon lists the real
  SagerNet rule-set branch via the GitHub git-trees API (UA set, 24h in-memory
  cache, stale-on-error); panel drives a native <datalist> on the Category
  inputs (Routing ruleset form + DNS geosite blocklist), lazy one fetch per
  source per session
- Routing rules gained Edit: inline form (all three matchers shown at once —
  domains/IPs/port — so nothing is silently dropped), preserves Order/Enabled/
  Kill/Egress and off-form fields verbatim, reorder/toggle/delete frozen while
  editing, "no matchers — matches everything" hint for catch-alls

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 17:26:53 +03:00
omarandClaude Opus 4.8 490834f3a4 feat(daemon+panel): verify geosite/geoip categories before save
- generate.GeoRuleSetURL(source, category) — single source of truth for the
  SagerNet .srs URL (routing rulesets, DNS filter, and the checker)
- POST /api/ruleset/check: daemon-side HEAD (GET+Range fallback) existence
  probe of the exact URL the engine would fetch; {ok} / {not_found} /
  {network} — plain client, router-own output is never tproxy-diverted
- Panel: geosite/geoip Save now checks first (Checking…); not_found blocks
  with a form error; network failure offers explicit "Save anyway" so an
  offline router can still be configured; url/inline/file flows untouched

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-19 17:07:35 +03:00
omarandClaude Opus 4.8 bffb62fc7c feat(daemon+panel): multi-WAN egress binding, test-all-nodes probe, live geosite/geoip
Three features from the second feedback pass:

- Node.Egress: a node can dial its OWN upstream through a named egress
  (DialerOptions.Detour on the node outbound/WG endpoint; inherited by
  groups/chains/rules; fail-open on unknown egress). Panel: per-row "via"
  expander + "via <name>" chip on Nodes.
- Chains: egress:<name> allowed as the ENTRY hop only (hop 0) — lifted into
  the first hop's detour; mid/last egress hops warn+drop. Panel: entry-hop
  optgroup + "entry" badge in the chain editor (Targets).
- Test all nodes: engine.TestAllNodes force-probes every node outbound
  (concurrency 16, 5s timeout) into the shared urltest history; failures
  stored as ProbeFailDelay=0xFFFF sentinel (slowest, never poisons
  least_test) and surfaced as DOWN, not untested. POST/GET /api/nodes/test;
  "Test all" button with N/M progress on Nodes.
- geosite/geoip rule-sets are LIVE: source=geosite|geoip + category emit
  official SagerNet remote .srs rule-sets (24h auto-update, direct fetch),
  for routing rulesets AND DNS block/allowlists; freshness + Update now UI
  applies to them; "inert" badge removed. New Category field (model+UCI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 21:28:11 +03:00
omarandClaude Opus 4.8 9dc9540296 fix(panel): honest node counters, egress-findable target picker, one log timezone
- Nodes/Overview: counters now tally live probe health (up/down/untested/off)
  instead of counting Enabled as "up" (was: 329/329 up with 5 tested)
- Routing: Target select bucketed into optgroups (Groups/Chains/Interfaces-
  egresses/Nodes-last) so egress:* is no longer buried under 300+ nodes
- Insights/Overview logs: single fmtClock(unix) helper, browser-local time in
  BOTH logs (DNS log was server-UTC next to local-time connections)
- Overview query-log badge no longer says "waiting for engine stats" while
  persisted rows are on screen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 20:56:46 +03:00
omarandClaude Opus 4.8 e84166ab8a feat(daemon+panel): SQLite persistent stats backend (Phase 3)
The stats log rings move behind a logRing seam: memRing (extracted RAM ring,
byte-identical to before) + sqliteRing (new, modernc.org/sqlite v1.38.2 pure-Go,
CGO-free musl-static). backend=sqlite persists the query/conn LOG rows to
/etc/shater/stats.db (WAL, tmpfs fallback /tmp/shater-stats.db, own lock) via an
async batched writer off the DNS/conn hot path; seq = the PK (monotonic, resumes
from the persisted max after restart). Cursor reads = WHERE seq</> ? ORDER BY
seq DESC LIMIT. Retention: keep <= StatsRingSize rows/table + a StatsDiskLimitMB
disk cap (0=unlimited) with prune + wal_checkpoint/VACUUM. Aggregates
(top-domains/timeline/hosts/devices/node-health) stay in RAM (bounded, rebuild
fast) — only the unbounded LOGS persist. Open failure → warn + memRing fallback.
Globals.StatsDiskLimitMB (0=unlimited, intOptAlways round-trip); Settings shows
it when backend=sqlite. Size: +1.2MB UPX (10.5->11.7MB), static/musl OK.

Verified: build (router tags)/vet 0, go test + -race ok (sqliteRing cursor
parity, retention, disk-cap, PERSIST-across-reopen, async no-loss, memory
regression); panel tsc/build clean. VM: backend=sqlite → /etc/shater/stats.db
created, 75 conns logged, **survive daemon restart** (75 rows intact, seq
resumes 76->79), disk cap set 16MB. box.New applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 19:47:38 +03:00
omarandClaude Opus 4.8 28cff360d5 feat(daemon+panel): seq cursor — true pagination + live logs (Phase 2)
Log rows gain a monotonic Seq (uint64, per-log counters on the Aggregator,
stamped under mu on append; survives box swaps + ring wrap). StatsStore read
API becomes cursor-based: Queries(LogQuery{Limit,Before,After})/Conns(...) —
neither cursor = newest Limit; Before=<seq> = next older page (seq<before);
After=<seq> = new rows (seq>after); always newest-first. Endpoints
/api/stats/{log,conns} accept limit/before/after (legacy n = limit, so Overview
is unchanged); bare array, rows carry seq (client derives newest/oldest).

Panel: Insights logs (Connections + DNS) now accumulate a seq-desc deduped
list — Load more APPENDS the next older page (not refetch-all, scroll
preserved, hides when exhausted), a ~1.5s after=<newest> poll PREPENDS new rows
(slide-in keyed by seq), a Pause/Live toggle buffers arrivals into an 'N new'
pill, ~3000-row DOM cap re-arms Load more, poll gated on backend!=off + tab
visible. Stable seq keys.

Verified: build (router tags)/vet 0, go test ok (seq monotonic bounded+
unlimited, before/after paging no overlap/gap, wrapped-ring, clamp), panel
tsc/build clean, ?mock drive (paginate+live+pause). VM live-verify pending
(ssh-manager MCP disconnected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 19:02:03 +03:00
omarandClaude Opus 4.8 594a602ffe feat(daemon+panel): StatsStore interface + OFF/MEMORY logging backend switch (Phase 1)
First phase of a pluggable stats-storage backend. New stats.StatsStore
interface (Start/Close/Resubscribe/Snapshot/RecentQueries/RecentConns); the
existing in-memory *Aggregator implements it unchanged, plus a noopStore for
OFF that never subscribes (so the HasSubscribers-gated DNS emit path skips all
per-query work). stats.NewStore(backend,...) selects off->noop, memory->agg,
sqlite->agg+warn (persistent backend lands in Phase 3). Snapshot gains a
'backend' field (off|memory|sqlite = effective). Globals.StatsBackend
(off|memory|sqlite, default memory) via the KillSwitch string-enum pattern
(model/uci/render + round-trip). Daemon + panel decouple from *Aggregator to
the interface. Settings gets a 3-way Logging-backend Select; Insights shows an
honest 'logging is off' state when backend=off.

Verified: build (router tags)/vet 0, go test ok (noopStore contract, NewStore
selection, StatsBackend round-trip), VM box.New PASS (stats verb reports
backend=memory); panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 18:38:07 +03:00
omarandClaude Opus 4.8 9c01ae18c3 fix(stats): raise log-page cap 200->5000 so unlimited/large rings are pageable
maxStatsLogN capped /api/stats/log and /api/stats/conns responses at 200, so an
unlimited (StatsRingSize=0) or large ring still returned only 200 rows. Raised
the safety ceiling to 5000 (RecentQueries/RecentConns still return only what's
buffered) and lifted the Insights Load-more ceiling 500->5000 (step +200) to
match, so a big/unlimited log can actually be paged out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 17:58:49 +03:00
omarandClaude Opus 4.8 ee04900f2a feat(daemon+panel): stats log/aggregate sizes support 0 = unlimited
Retention size knobs (StatsRingSize / StatsTimelineMinutes / StatsMaxDomains)
now mean: 0 = UNLIMITED (no trim, grows with RAM), N = fixed limit. Query-log
AND connection-log rings gain a growable append-only mode when RingSize==0
(fixed-ring modulo path kept for N>0). Per-field 0 skips that aggregate's prune
(domains) / trim (timeline); RetentionDisabled stays the master switch.

Absent-vs-explicit-0 round-trip fixed: DefaultGlobals seeds safe bounded
defaults (200/60/5000) so an unset UCI option is never accidentally unlimited;
render intOptAlways writes these three fields even at 0 so an explicit 0
survives WriteUCI->ReadUCI; daemon passes no Config on a read error (→ bounded
defaults, not a zero-value=unlimited Config). Settings reframes the three
inputs as '0 = unlimited' with a per-field grows-with-memory warning.

Verified: build (router tags)/vet 0, go test ok (round-trip 0/200/5000;
RingSize=0 grows to 500 q+conn; bounded at 200; MaxDomains=0 keeps 6000;
Timeline=0 no trim), VM box.New PASS; panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 17:51:52 +03:00
omarandClaude Opus 4.8 53db1c14ce fix(panel/insights): hide Load more when there's nothing more to load
The Connections/DNS LogShell only DISABLED 'Load more' when the page wasn't
full — so it stayed visible (and looked clickable) even with e.g. 9 rows. Now
the button is HIDDEN unless a full page came back (rows.length >= n && n < 500),
so it only appears when there may actually be more; disabled only while busy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 17:08:13 +03:00
omarandClaude Opus 4.8 2a656312d2 fix(panel/insights): stat tiles overflowing into the sparkline in the 3-col band
The real 600-905px 'crossing' was NOT the SegMeter dots (fixed in e24a2c1c) but
the 3 QUERIES/BLOCKED/ALLOWED tiles forced 3-across in the ~227px first column
of the 3-col overview: each value's min-content (~86px) → 277px overflowed the
cell by ~50px, painting the 3rd tile's number ~17-30px into the sparkline.
(Round 1 missed it: Playwright's 15px scrollbar turned physical 901 into an
886 single-col layout, so the tight 3-col band was never measured.) Fix:
.ins-tiles flex-wrap + .ins-tile{flex:1 1 90px;min-width:0} → reflow 2+1 when
narrow, 3-across when wide, robust to any digit count, no viewport breakpoint.
Also: per-device header total wraps to its own line; Connections/DNS rows fit
their scroll box at <=560px. Scoped to Insights; verified 0 crossings 360-1440.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 16:11:49 +03:00
omarandClaude Opus 4.8 e24a2c1c77 fix(panel/insights): Filtered SegMeter dots no longer overflow the module border
The prior min-width:0/max-width:100% capped the .segs BOX but the 28 flex
segments still painted outside it (their ~305px min-content spilled past the
right border at nearly every width). Now .ins-filter .segs uses overflow:hidden
(drops the min-content contribution + clips sub-pixel) + tighter gap, and the
Filtered meter passes segments={20} so dots fit their column without
compressing past the ~8px floor. Also fixed a secondary body h-scroll ≤404px:
.ins-overview single-col → minmax(0,1fr), .ins-tiles reflow to 2-col ≤400px,
.ins-grid minmax(min(100%,320px),1fr). Scoped to Insights — shared SegMeter
(Overview) untouched. Verified 0 overflow + no body h-scroll across 360–1440px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 15:39:12 +03:00
omarandClaude Opus 4.8 28c85a497c feat(daemon+panel): DNS log shows the source device (LAN client or 'router')
LogEntry.Device was always '' — but the client address IS on the DNS
resolution context. dnstrack.QueryEvent gains Client netip.Addr, populated at
all three dns/client_log.go emit sites from adapter.ContextFrom(ctx).Source.Addr
(same context processInfoFromContext already reads). stats deviceLabel: LAN
source (a.lanNets.isLAN) -> DHCP hostname or IP; loopback/non-LAN/unknown ->
'router' (the appliance's own urltest/sub/DoH lookups). Insights DNS log now
shows device -> domain · resolver · action (mirrors the Connections log), with
a dimmed 'router' chip for router-originated lookups; falls back to '—' on
older data.

Verified: root build (dns tree + box, router tags)/vet 0, go test ok
(deviceLabel: LAN+lease/LAN+IP/loopback->router), panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 15:12:43 +03:00
omarandClaude Opus 4.8 3a08387ef0 polish(panel/insights): tidy log action bars, unify DNS log with Connections, fix toggle clip + Filtered meter overflow
User frontend polish pass: (1) Connections/DNS action buttons were glued —
now a left-grouped .ins-log-btns (gap) + right-aligned count + separating
groove. (2) DNS log now shares a LogShell (scroll body + actions) with
Connections so they look 1:1 (differing only in columns: time·domain·resolver·
action); dropped the old <QueryLog> ticker here (still used on Overview).
(3) 'Blocked' toggle was clipped to 'Blocke' — .ins-toggle overflow:hidden
collapsed its flex min-width; added flex:none/nowrap + header flex-wrap.
(4) Filtered SegMeter's 28 segments overflowed the module's right edge —
scoped min-width:0/max-width:100% under .ins-filter (SegMeter elsewhere
untouched). (5) tabular-nums, consistent spacing, removed dead .ins-logwrap/
.qrows + unused imports. tsc/build clean; verified ?mock at 1280 & 380px, no
horizontal body scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 14:39:48 +03:00
omarandClaude Opus 4.8 ddb29c662d fix(stats): filter connections by LAN-subnet source, not Metadata.Inbound
The conn-log/top-hosts LAN filter required a non-empty Metadata.Inbound, but
tproxy connection events carry an empty Inbound on this engine — so it dropped
every client connection (live: 0 conns / empty top_hosts despite real traffic).
Now filters by source IP being inside a LAN subnet: devices.LANNets() (new
exported helper reusing the #10 /etc/config/network parser) + a 30s-cached
lanNetCache. Keeps LAN clients (192.168.1.77) and drops the router's own
WAN-side node dials (Source 10.0.2.x) — which are both RFC1918, so only the
iface config distinguishes them. Fallback (no config): private routable
sources. Loopback/unspecified/multicast always dropped.

Verified: build (router tags)/vet 0, go test ok (KEEP 192.168.1.77 / DROP
10.0.2.15 subnet test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 13:47:57 +03:00
omarandClaude Opus 4.8 cb2b66bdd0 feat(daemon+panel): Insights connections log + top hosts by IP (device->dest, UDP/TCP)
DNS events carry no client IP, so the query log couldn't show which device
went where. Now the stats aggregator folds connection events (trafficcontrol)
into: a connection ring (RecentConns → GET /api/stats/conns: src device ->
dest domain|IP + tcp/udp + sniffed proto + exit) and a top-hosts map keyed by
domain-else-IP (Snapshot.top_hosts) so raw-IP UDP/TCP destinations surface
(host==ip = the by-IP case). LAN-source filter (non-empty inbound + routable
src) keeps the router's own node/probe dials out. Insights gains a scrollable
Connections log (device->dest+proto, Refresh/Load more) + a Top-hosts section
(net/proto badge, IP tag for domain-less); the DNS log is relabelled
'DNS log · decisions'.

Verified: build (router tags)/vet 0, go test ok (LAN fold, IP-only host,
non-LAN skip, Closed adds bytes), panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 13:38:55 +03:00
omarandClaude Opus 4.8 39738d6dd7 fix(panel/insights): make the query log scroll (was clipped at 210px)
The shared <QueryLog> is a fixed-height ticker (overflow:hidden) for Overview,
but on Insights it's a full paginated log — Load more fetched rows that were
clipped and invisible. Scoped override: .ins-logwrap .qrows now scrolls
(max-height min(60vh,540px), overflow-y:auto). Overview ticker unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 12:56:44 +03:00
omarandClaude Opus 4.8 233a9cd985 feat(shater/daemon): WAN-based profile auto-switch watcher (Wave D2, #4.1)
A 25s watchActiveProfile loop (mirrors watchNewDevices) reads the active
default-route dev (ip route show default, lowest metric), matches it against
enabled profiles' MatchIface (via netplane.IfaceDevice, so UCI-name OR device
lists work), and pins the highest-Priority match into Globals.ActiveProfile +
Reconcile — generate already applies an explicit ActiveProfile, so no generate
change. Anti-flap (write only on change), no-op when no MatchIface profiles
exist, releases a stale iface-pin but preserves a manual non-iface pin,
fail-safe on every error. Pure helpers pickIfaceProfile/desiredActiveProfile/
parseDefaultRouteDev unit-tested (failover-flip, tie-break, stale-release).
Schedule-window check deferred (unexported in generate). VM: build + matcher
tests PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 03:41:48 +03:00
omarandClaude Opus 4.8 da8d2b638c feat(daemon+panel): detour-HTTP — alerts via proxy/egress + fetch-via-tunnel (Wave D1, #1+#8)
Shared foundation: Engine.HTTPClient(via) dials through a running-box outbound
(OutboundManager.Outbound(tag).DialContext); via->tag map direct/group:/node:/
egress:/chain:. #1: Alert gains Via + Fallback — notifier sends through the
chosen detour via an injected client factory (daemon wires eng.HTTPClient);
on detour failure retries direct iff Fallback (else surfaces error). Direct
stays the default + the always-available safety path (killswitch/apply_fail
should keep Via empty or set Fallback). #8: Subscription gains FetchDetour;
new POST /api/subscription/update {name} makes the DAEMON fetch a sub through
the tunnel (FetchVia=proxy → Fetch(sub, HTTPClient(FetchDetour))) → update →
WriteUCI → reconcile; panel gets a per-sub Detour picker (under Proxy) + an
Update-now button. CLI 'sub update' stays direct.

Verified: root+shater build (router tags)/vet 0, go test ok (via->tag map,
alert Via+Fallback fallback-to-direct, detour-fail-no-fallback drops, model
round-trip w/ via/fallback/fetch_detour), VM box.New + engine tests PASS.
panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 03:29:54 +03:00
omarandClaude Opus 4.8 265fb5efaa feat(daemon+panel): rule-set management — status + manual update (Wave C, #9)
Remote .srs rule-sets already auto-update (24h, cache.db, ETag) but the panel
showed nothing. Now: RemoteRuleSet exports LastUpdated()/UpdateInterval()/
RuleCount()/Update(ctx) (an updateMu serialises manual refresh vs the 24h loop;
lastUpdated writes moved under access lock). route.Router.RuleSets() enumerates
live sets. Engine.RuleSetStatus()/UpdateRuleSet(tag) via Instance().Router();
Applier exposes both (engine stays private). New GET /api/ruleset/status
([{tag,name,kind,remote,last_updated,interval_seconds,rule_count}]) + POST
/api/ruleset/update {tag|name+kind}. Tag map: ruleset X<->rs-X, blocklist
Y<->bl-Y, allowlist Z<->al-Z. Routing rulesets rows show relative last-update +
'every 24h' + rule count + an Update-now button (in-flight state, toast); inline
sets show nothing extra; install/remove unchanged (config PUT).

Verified: root+route+shater build (router tags)/vet 0, go test ok (tag-map,
status projection, engine nil-safety + real box.New apply), panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 03:04:45 +03:00
omarandClaude Opus 4.8 a968795229 feat(daemon+panel): per-device domains from connection events + retention (Wave B2b, #2)
DNS events carry no client IP, so per-device DOMAIN stats need connection
events. box.go now builds+registers the trafficcontrol.Manager + AppendTracker
UNCONDITIONALLY (moved out of the needObservable gate) — an in-process
connection observable with NO clash/api port opened (Emit is non-blocking, so
an unsubscribed tracker never stalls the hot path). Engine.ConnManager()
exposes it (box-owned; pointer changes each Apply swap). stats connLoop
subscribes (pointer-identity resubscribe like dnsLoop), folding
{Source.Addr, Domain||Destination.Fqdn} into deviceDomains (bounded 512
clients / 200 domains-each). Snapshot gains device_domains
[{ip,name,domains:[{domain,count}]}]; Insights shows a per-device domain view.

Configurable retention: Globals StatsRingSize/StatsTimelineMinutes/
StatsMaxDomains/StatsRetentionDisabled (0=built-in defaults 200/60/5000);
stats.New resolves them, RetentionDisabled skips all pruning (RAM-bounded);
Settings gains a Statistics-retention section with a disable-trim toggle.

Verified: root+shater build (router tags)/vet 0, go test ok (conn-event fold,
retention, TestEngineConnManagerWired drives a real proxied conn + asserts a
live ConnectionEventNew + pointer-change-on-swap), VM box.New still Applies
with the tracker wired. panel tsc/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 02:44:58 +03:00
omarandClaude Opus 4.8 d0d224642c fix(model): accept log_level as alias for the canonical loglevel UCI key
Globals reads the canonical 'loglevel' key; a natural 'log_level' misspelling
was silently ignored, so 'log_level=debug' produced no debug output (found
while diagnosing node-health telemetry on the VM). applyGlobals now accepts
log_level as an alias, loglevel keeping priority. +TestLogLevelAlias.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 02:10:55 +03:00
omarandClaude Opus 4.8 7d22ac711f feat(panel): Insights page — traffic & DNS stats (Wave B2a, #2 + per-rule/endpoint)
New Insights nav page surfacing the /api/stats Snapshot that was collected but
never shown: traffic overview (queries/blocked/allowed + timeline sparkline +
filtered%), top domains ranked with blocked portion (all/blocked toggle —
replaces the anemic 'top blocked = none'), per-rule traffic bars (bytes/packets
per routing rule), per-endpoint (outbounds + resolvers by count), per-device
bytes, and the query log (block/proxy/pass) via getStatsLog pagination. Answers
the user's ask: how often & how much traffic goes to which domain/IP, per rule
and per endpoint. Pure frontend — all data already in the aggregator. Polls
getStats every 3s; honest empty states; responsive (no horizontal body scroll).

Reuses SegMeter/QueryLog/Led/Button. Wired via router ROUTES + App Page switch
+ pages/index. Verified: tsc --noEmit clean, npm build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 01:31:52 +03:00
omarandClaude Opus 4.8 37fa520c88 feat(panel+daemon): real per-node health (alive+latency) from urltest (Wave B1, #3)
The Overview 'N/N up' was cosmetic (enabled-count/total). Now the engine
pre-registers a shared urltest.HistoryStorage in the box ctx (mirrors the
dnstrack.Manager pattern; box.go reuses a ctx-provided store), exposes it via
Engine.URLTestHistory(), and stats collectNodeHealth() reads per-node
Delay/alive into a new Snapshot.NodeHealth ([]{tag,delay_ms,alive,tested,
age_seconds}, tag==node name). Panel joins it by name: Overview shows an
honest alive/tested/total readout + status LED; Nodes rows get a latency chip
+ alive/down/untested LED (untested = node not in any probing group, shown
'—' not 'down'). Falls back to the old count when node_health is absent.

Only urltest/least_test groups populate history (selector/single/manual do
not); a failed probe deletes the entry, so tested=false conflates never-probed
and last-probe-failed — both reported untested, never a false 'down'.

Verified: go build (router tags)/vet 0, go test engine+stats ok (new
TestNodeHealthFromURLTestHistory + nil-safe test), panel tsc/build clean.
VM live-verify pending (ssh-manager MCP disconnected mid-session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 01:21:22 +03:00
omarandClaude Opus 4.8 a6148b82ac feat(panel+daemon): Wave A UX fixes (rollback-hide, devices, iface picker, WG import, nodes search)
Post-test feedback batch, 4 parallel Opus agents:

#6 rollback-hide: apply.Status gains can_rollback (armed commit-confirm
snapshot OR engine.HasLastGood(), a new non-mutating engine probe). Apply/
Overview hide the Roll back button when nothing to revert; the 'Nothing to
roll back' dead-end is gone.

#10 devices: parseNeigh now captures the 'dev' token and drops non-LAN
rows (WAN device + a fail-open 10.0.2.0/24 slirp guard), so QEMU WAN IPs
10.0.2.2/.3 no longer masquerade as devices. Discovered gains network/iface
labels (IP matched against /etc/config/network subnets); UI shows 'LAN·br-lan'.

#5 egress iface picker: new GET /api/interfaces (netplane.Interfaces via
ubus network.interface dump); Targets EGRESS interface field is now a select
of real UCI interfaces (degrades to free-text when empty).

#11 WG/AWG import: parse.WGToURI serializes a *Proxy back to a canonical
wireguard:// URI (round-trips ParseWGConf, all AmneziaWG knobs); new
POST /api/import-wg converts a pasted .conf; Nodes add-node accepts a
multi-line [Interface] config and imports it as a node.

#7 nodes search/grouping: live search (name/proto/host/sub) + collapsible
per-subscription and Manual groups (large groups collapsed by default,
search auto-expands matches).

Verified: go build (router tags)/vet/test 0; panel tsc/build clean; new
tests TestCanRollback, TestEngineHasLastGood, TestDiscoverDropsWANNeigh,
TestWGToURIRoundTrip, import-wg + interfaces api tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-18 01:01:53 +03:00
omarandClaude Opus 4.8 b5049a82c6 feat(panel/profiles): WAN-mode/failover profiles + preset-pack toggles
Profiles page (was a Placeholder): active-profile selector (Globals.
ActiveProfile, manual pin vs auto-by-condition), profiles CRUD with
conditions (uplink iface / probe up|down / schedule) and overrides
(enable/disable rules matrix, default target + egress pickers), plus the
three built-in preset packs (block-ads/ru-bypass/private) as fixed toggle
rows with target overrides. Reuses the DNS/Settings save->apply banner,
toasts, target-picker and schedule idioms; masks probe-URL hosts. Wired in
App.tsx <Page> + pages/index.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 05:27:19 +03:00
omarandClaude Opus 4.8 ff3965a0c3 feat(shater/generate): evaluate profiles + preset packs at gen-time
Profiles/presets were modelled but ignored. Now buildRoute applies them to
an EFFECTIVE rule set (input model never mutated). Active profile: explicit
Globals.ActiveProfile (existing+enabled) always wins; else auto-select the
highest-Priority enabled profile whose schedule window holds (via b.now,
sharing scheduleWindowActive with rule schedules); iface/probe-conditioned
profiles are skipped by auto-select (warn, control-plane Phase-2b) but
honored when pinned. Overrides: EnableRules/DisableRules (disable wins),
DefaultTarget/DefaultEgress on Final (target wins = leak-safe). Preset packs
block-ads(15 domains->block)/ru-bypass(geoip:ru->direct, inert w/o geodata)/
private(RFC1918+ll+lo->direct) inject rules through the SAME rule loop,
Preset.Order/Target overridable. Fail-open throughout; profile/preset-free
models byte-identical (regression-guarded). Verified build/vet 0, host tests,
VM box.New (TestProfilePresetAppliesCleanly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 05:27:19 +03:00
omarandClaude Opus 4.8 0faac4c3d5 feat(panel/routing): rulesets management + dst_ruleset rule picker
Routing page now manages rule-sets (domain/ipcidr match sources): add/edit/
delete with source inline(entries)|url|file|geosite, and a dst_ruleset
checkbox picker in the rule form so a rule matches one or more rulesets
(matcher chip shows 'ruleset: ..'). Deleting a ruleset strips it from every
referencing rule. Reuses the existing save->apply machinery; URL tokens
masked; honest empty state. Backend materialisation landed in 57343693.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 05:10:41 +03:00
omarandClaude Opus 4.8 6a63503d2b feat(shater/generate): real multi-hop chains (router->L1->..->Ln->exit)
resolveChainExit collapsed a chain to its exit hop, losing the multi-hop.
Now buildChain materialises per-chain hop-outbound copies Detour-linked
backward (h_n exits, h_n.Detour=h_{n-1}, .. h1.Detour=direct) so traffic
traverses L1..Ln and egresses at Ln; a rule/egress routing chain:<name>
targets the chain ENTRY tag. Per-chain copies keep base node/group
outbounds standalone and preserve the 0xff loop-guard mark. Group hops =
chain-local urltest over detoured member copies; WireGuard hops = detoured
endpoint copies. 1-hop == that hop; undefined/empty/unresolvable warns +
rule skipped (never aborts box.New); only referenced chains materialise.
Verified: build/vet 0, host tests + VM box.New (TestChainMultiHopApplies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 05:10:41 +03:00
omarandClaude Opus 4.8 5c1547d03a test(shater/generate): fix imports for the device-DNS block-rule helper
Move dnsBlockRuleForSrc to devices_test.go (imports option/C, untagged) and
drop the now-unused constant import from devices_linux_test.go, fixing the
cross-compile of the previous test fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 04:53:37 +03:00
omarandClaude Opus 4.8 c383f20cf4 test(shater/generate): fix device-DNS test to assert the block rule, not the first
TestDeviceRulesValidate used dnsRuleForSrc (first source match) which
returned the device's ALLOW rule (route action, emitted first) while
asserting Predefined — a false failure. The generation was correct
(Phase-6 verified block works E2E). Now asserts the predefined-NXDOMAIN
block rule for the src specifically via dnsBlockRuleForSrc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 04:52:30 +03:00
omarandClaude Opus 4.8 5734369356 feat(shater/generate): materialize routing rule-sets (dst_ruleset) — domain/geo split
A routing rule's dst_ruleset now matches (config ruleset was never
materialized — referencing one aborted box.New). Mirrors the DNS-filter
rule-set builder.

- ruleset.go: for each ruleset referenced by an enabled rule's DstRuleset,
  materialize an option.RuleSet tagged rs-<name> -> Route.RuleSet. inline
  (domain -> DomainSuffix/Domain/DomainKeyword classification; ipcidr ->
  IPCIDR), url -> remote (http_client detour=direct + UpdateInterval),
  file -> local, geosite/geoip -> inert+warn (no geodata). Unused rulesets
  not emitted.
- route.go ruleMatchers: rule.DstRuleset -> raw.RuleSet=[rs-<name>], counts
  as an engine matcher (a dst_ruleset-only rule is now emitted). Undefined
  ruleset -> warn + skip (never aborts box.New).
- rs-/bl-/al- prefixes keep routing + DNS-filter rule-sets collision-free
  (test asserts a blocklist 'ads' and a ruleset 'ads' coexist).

Verified: generate tests (inline domain+ipcidr, undefined skip, coexistence)
+ box.New validation on the OpenWrt VM. (Flagged separately: a pre-existing
Phase-6 device-DNS test failure, unrelated — investigating next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 04:50:54 +03:00
omarandClaude Opus 4.8 5f3b8ac0bf feat(panel): Targets + Settings pages + full subscription options (v0.1 parity, wave 1)
Reaches v0.1's config-surface parity for the backend-ready features. Nav
gains Targets, Profiles, Settings (Profiles is a placeholder until its
backend lands).

- Targets page (groups/chains/egresses — was UCI-only): GROUPS editor
  (source subscription|manual, subscription/member-node pickers, strategy,
  include/exclude/proto/country filters + dedup, probe url/interval); CHAINS
  editor (ordered hop list from group:/node:, signal-path viz); EGRESSES
  editor (type interface|proxy|direct|block|byedpi, interface/target/port +
  native DPI preset off|fragment|record|spoof). All pickers derive from live
  config.
- Settings page (globals — was UCI-only): enabled, log level, kill-switch,
  DNS mode, IPv6, confirm timeout, panel port, health probe url/interval,
  fwmark/table base (hex, advanced), read-only schema/active-profile.
- Nodes page: per-subscription options expander — update interval, fetch-via,
  format, UA, HWID + device fields, extra headers, include/exclude/proto/
  country filters, dedup, expire-alert days (secrets masked, reuses save
  machinery).
- api.ts: full types (Subscription/Group filters, Chain, Ruleset, Preset,
  Profile, Inbound, Globals.PanelPort) + Model slices; router nav.

All save→apply like the other pages. tsc clean; build ok (82 kB gzip).
Remaining for full parity (next waves): generate for rulesets/chains(real
multi-hop)/profiles/presets, and the Profiles + Rulesets panel pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 04:39:45 +03:00
omarandClaude Opus 4.8 478b450b21 feat(panel): editable DNS resolvers + DNS-path (detour) picker
Lets the operator choose which proxy path DNS goes through — a group
(balancer/urltest), a chain, an interface/egress, a specific node, or
direct. The backend already resolved resolver.Detour via resolveTarget
(group:/chain:/egress:/node:/direct); this exposes it in the panel (the
RESOLVERS section was read-only).

- Per-resolver detour <select> built live from the Model: Direct + a
  Groups optgroup (balancer) + Chains + Interfaces/egresses (with type) +
  a Nodes optgroup. Current path rendered as 'via group/chain/node/
  interface <name>' or 'direct'.
- Add resolver (name + type doh/dot/plain/tcp/local/fakeip + conditional
  address + fakeip pool + detour), edit, delete (repoints/clears default+
  fallback), and editable default/fallback role selects (were read-only).
- Stale/missing detour target stays selectable + flagged '(missing)';
  legacy bare-name detours normalized against the catalog. Secrets masked.
  save->apply banner like the other sections.

Verified: tsc --noEmit clean; npm run build ok (71 kB gzip JS); Playwright
(mock) — the detour select shows Direct/Group auto (balancer)/egresses/
Nodes optgroup, changing it + add/delete + default/fallback all work with
the save->apply banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 03:24:25 +03:00
omarandClaude Opus 4.8 0263a4e507 fix(shater/generate): resolve source=subscription group members from FromSub
A group with source=subscription produced NO members (groupMembers only
read the explicit g.Nodes list, empty for sub-backed groups), so the group
was skipped and any rule targeting it never routed — the whole proxy path
was dead on a subscription setup.

Fix: for source=subscription, gather members from all nodes where
FromSub==g.Subscription (enabled + emitted), in config order, deduped; apply
Include/Exclude name regexes (case-insensitive, bad pattern warns+ignored)
and FilterProto/FilterCountry/Dedup via parse.ParseShareLink +
FilterSpecFromGroup + ApplyFilters. Manual/single/'' sources unchanged.

Verified: generate tests (sub group gathers exactly its FromSub nodes not
others; include/exclude; bad-regex-ignored; proto filter; manual unchanged).
Found live on the VM: 376-node subscription group 'auto' was 'no usable
members, skipped' before this fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 01:41:25 +03:00
omarandClaude Opus 4.8 f489a9dc8f feat(shater): subscription fetch — real 'sub update' verb
Makes shaterd sub update real (was a Phase-2b stub): fetch a subscription
URL, parse+filter into nodes, persist them, reconcile.

- shater/subscribe: Fetch(sub, client) — HTTP GET with UA (sub.UA or
  Shater/0.2 default), HAPP-style x-hwid/x-device-* headers, raw Headers
  override, 20s timeout, 8MiB cap, non-2xx/empty = error. UpdateSubscription
  (pure): parse.ParseSubURIs re-serializes ALL formats (clash/xray/sing-box/
  links) to canonical share-links, pairs each with its *Proxy, ApplyFilters
  (Include/Exclude/proto/country/dedup), builds []Node FromSub=<name> (name
  from #fragment, collision-suffixed), REPLACES only that sub's cache. Zero
  usable nodes -> error + leave the cache intact (a provider hiccup never
  empties the config).
- cmd/shaterd: 'sub update [<name>]' — fetch each enabled sub (or one),
  fold in, WriteUCI, best-effort SIGHUP reconcile; works with/without the
  daemon; fetch_via=proxy warns + falls back to direct (MVP).
- model: sub-cache nodes now persist in UCI (config node + from_sub/
  fingerprint/stale) so the fetched set survives restarts and the panel sees
  them; ReadUCI reads them back; manual nodes unaffected. (v0.1 used a
  separate JSON cache; UCI persistence matches v0.2's model<->UCI design.)

Verified: subscribe+model unit tests (FromSub tagging, filters, zero-node
safety, cache replace-keep-others, name collisions, UA/header/non-2xx); VM
E2E with the real feed https://pro.qomar.pw/sub/... -> 'sub update' fetched
376 nodes (261 vless/71 ss/29 vmess/15 trojan), all from_sub='default',
persisted to UCI, and box.New/Apply accepted all 376 (status running/active).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 01:27:43 +03:00
omarandClaude Opus 4.8 7258922fa0 docs(roadmap): Phase 8 (ship) DONE — v0.2 feature-complete through the roadmap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:29:48 +03:00
omarandClaude Opus 4.8 be07e3ba57 ci(shater): Gitea feed build + usign-signed opkg release (Phase 8 ship — complete)
Ports the v0.1 Gitea release flow to the v0.2 single-binary + 4-package
layout, so a tag publishes a signed opkg feed the routers install from.

- .gitea/workflows/release.yml: on tag v* (+ dispatch), matrix over
  {x86_64, aarch64_cortex-a53}. Per arch: setup Go 1.24/Node 20/UPX ->
  scripts/build-shaterd.sh (SPA-embedded shaterd, stages the .upx) ->
  ci/build-feed.sh (OpenWrt SDK container builds all 4 packages ->
  usign-signed Packages index). A release job merges both arches into one
  signed feed + publishes the rolling 'latest'/tag release via the Gitea API.
- ci/sdk-build.sh: in-SDK build — add openwrt/ as the 'shater' feed, feeds
  update/install, make package/{shaterd,shater-core,byedpi,luci-app-shater}/
  compile (shaterd validates+installs the staged prebuilt; byedpi cross-
  compiles from source). ci/make-index.sh: opkg Packages(.gz) + usign sign
  with KEY_BUILD (keyfile umask 077, no secret hardcoded), verifiable by
  dist/shater-feed.pub. ci/install-usign.sh + ci/gitea-release.sh ported.
- INSTALL.md: add the signed feed src/gz line + import dist/shater-feed.pub
  to /etc/opkg/keys; apk (25.12) path noted.

Key kept: usign feed key 5ac4b177689cb8e0 (public dist/shater-feed.pub,
secret Gitea repo secret KEY_BUILD). Decision: opkg (24.10 uses opkg; apk
is 25.12) — matches the existing usign trust anchor.

Verified structurally (no live runner here): release.yml is valid YAML, all
ci/*.sh are bash -n clean, no hardcoded secrets, and every package name/
path/arch/artifact/secret reference cross-checks against openwrt/, scripts/
build-shaterd.sh, and dist/shater-feed.pub. Live-runner unknowns (full SDK
compile of the 4 packages, router-side signature verify) flagged in-agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:29:35 +03:00
omarandClaude Opus 4.8 a5c74209e4 feat(openwrt/shaterd): release build + prebuilt shaterd package (Phase 8 ship)
Makes the whole product installable — shater-core DEPENDS +shaterd, and
this is what resolves it.

- scripts/build-shaterd.sh: the release build. Builds the panel SPA
  (npm ci && npm run build), copies panel/dist -> shater/panel/webroot
  (the go:embed dir), cross-builds shaterd for amd64 + arm64 with the D9
  router tag set (CGO_ENABLED=0, -checklinkname=0 -s -w, static ET_EXEC no
  PT_INTERP), then UPX --lzma --best (D10) and stages the .upx into
  openwrt/shaterd/files. Version from arg/SHATER_VERSION/git-describe.
  Measured: amd64 40.3MB->10.4MB, arm64 37.6MB->8.5MB.
- openwrt/shaterd: prebuilt-binary package (npm+embed+UPX don't reproduce
  cleanly in the SDK, so CI stages the artifact). Maps OpenWrt ARCH
  (x86_64->amd64, aarch64->arm64 = both BPI routers) to files/shaterd-<a>.upx,
  installs /usr/bin/shaterd. RSTRIP/STRIP disabled (the SDK strip would
  corrupt the UPX binary); DEPENDS empty (static); errors clearly when no
  artifact is staged. GPL-3.0-or-later.
- docs-shater/INSTALL.md: build + install order (shaterd -> shater-core ->
  luci-app-shater, optional byedpi) + enable/apply.
- gitignore: dist/shaterd-*, openwrt/shaterd/files/*.upx, panel webroot.

Verified on the OpenWrt musl VM: dist/shaterd-amd64.upx (10.4MB) decompresses
into RAM + runs (shaterd status OK), serves the REAL embedded Faceplate SPA
at :8088 ('SPA embedded=true', real Vite index.html + assets — not the
placeholder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:18:39 +03:00
omarandClaude Opus 4.8 c18298f74c docs(roadmap): Phases 6 (per-device) + 7 (schedules/alerts) DONE
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:04:10 +03:00
omarandClaude Opus 4.8 d0fd39ba32 feat(shater): alerts — Telegram/webhook notifications (Phase 7 alerts)
Out-of-band notifications on key events, delivered DIRECT to the internet
(plain net/http, never via the proxy) so a kill-switch/engine-down alert
reaches Telegram even when the tunnel is down.

- model: Alert{Name,Enabled,Type(telegram|webhook),Token,ChatID,URL,
  Events[]} + Model.Alerts; uci (case alert, list event) + render +
  round-trip fixture.
- shater/alert: Notifier — 8s-timeout default-transport client, per-alert
  async delivery (telegram sendMessage / webhook JSON POST), (event,title)
  dedup within 60s so a flapping engine can't spam, panic-safe. Update()
  swaps config on reconcile; TestFire() for the test verb.
- cmd/shaterd: builds the Notifier in run, Update()s it after each
  reconcile; fires apply_fail+killswitch on an apply/reconcile error while
  enabled (initial + SIGHUP paths); watchNewDevices polls devices.Discover
  every 45s and fires new_device on an unseen MAC (skips the startup
  baseline). 'alert test' verb sends a test to every enabled alert (reads
  UCI, no live daemon needed). Events emitted now: killswitch|new_device|
  apply_fail; node_down|sub_expiry reserved.
- panel: Alerts section in DNS.tsx — list (name/type/events, enable, delete)
  + add form; Token/URL NEVER shown in clear (masked, round-tripped). api.ts
  Alert type + Model.Alerts.

Verified: model round-trip incl. Alert; notifier unit tests (subscribed
delivers correct JSON, unsubscribed/disabled deliver nothing, dedup
suppresses rapid dup); build/vet; panel tsc+build; VM — 'shaterd alert test'
with a webhook alert delivered a POST to a local sink.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:03:46 +03:00
omarandClaude Opus 4.8 1f10e6aa99 test(shater): add missed Phase-6 device tests (box.New + /api/devices)
devices_linux_test.go (generate box.New validation of per-device rules) and
panel/devices_test.go (/api/devices gating) were authored with the Phase-6
backend (677a1dde) but not staged in that commit. No source change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-16 00:03:46 +03:00
omarandClaude Opus 4.8 bee5d9bd57 feat(shater): time-scheduled rules (bedtime/school-hours) — Phase 7 schedules
Scheduled rules are now evaluated by the control-plane at gen/reconcile
time (the engine has no time match), so a rule is only active inside its
window.

- generate: builder gains an injectable 'now' (defaults time.Now); a
  SchedEnabled rule outside its window is SKIPPED (was emitted
  unconditionally with a deferred warning). schedule.go: scheduleActive
  parses SchedDays (mon..sun, empty=all), SchedStart/End HH:MM in SchedTZ
  (LoadLocation, fallback local), handles overnight windows (end<start ->
  now>=start || now<end), all-day (empty/equal end). Invalid HH:MM ->
  fail-OPEN (emit + warn) so a typo never silently drops protection.
- cmd/shaterd: real 'schedule due' verb -> SIGHUP reconcile (no-op when
  down); generate re-evaluates + the config-hash gate rebuilds the engine
  only when a window boundary was actually crossed (no churn between
  boundaries — verified reconcile changed=false per tick).
- shater-cron: calls 'shaterd schedule due' each tick (cheap, hash-gated).
- panel Routing: add-rule form gains schedule controls (enable + mon..sun
  day toggles + From/To time inputs); scheduled rules show a days+time chip.

Verified: schedule unit tests (weekday window emitted/skipped, overnight
active across midnight, all-day weekend, invalid-time fail-open); build/
vet; panel tsc+build; VM (box.New accepts a scheduled config; 'schedule
due' reconciles changed=false = no churn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 21:06:10 +03:00
omarandClaude Opus 4.8 1871befcc3 feat(panel): Devices page — per-device control (Phase 6 frontend, panel complete)
The parental/per-device page, wired to the Phase-6 backend. Completes the
panel — all 6 nav pages now live (Overview/Nodes/Routing/DNS/Devices/Apply).

- getDevices() consumes GET /api/devices (discovered LAN clients merged with
  per-device config). Per-device policy lives in Model.Devices.
- Device rows: online/idle/offline Led, name (config name|hostname|ip),
  IP+MAC+state, managed tag. MANAGE expands inline controls that edit the
  matching Model.Devices entry: proxy Toggle, exit picker (from the config's
  nodes/groups/egresses + direct/block; missing target flagged), per-device
  blocked-domain chips (add/remove), policy Enabled toggle, 'stop managing'.
  Create keyed by MAC (stable), else IP; match MAC-first case-insensitive.
- Live-poll (5s) merges online status without clobbering unsaved edits
  (liveness from getDevices, policy from config). save->apply split + banner
  like Nodes/DNS. Honest empty/error/paused states; no secrets.
- api.ts: Device + DiscoveredDevice types, Model.Devices, getDevices();
  mock fixture devices. Wired into App.tsx + pages/index.ts.

Verified: tsc --noEmit clean; npm run build ok (67 kB gzip); Playwright
screenshot (mock) confirms Faceplate fidelity + device list/LEDs/summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 20:48:35 +03:00
omarandClaude Opus 4.8 677a1dde37 feat(shater): per-device control + device discovery (Phase 6 backend)
Parental/per-device policy: route or block per device by client IP.

- model: Device{Name,MAC,IP,Enabled,Proxy,Target,Block[],Allow[]} +
  Model.Devices; uci parse (case device, list block/allow) + render +
  round-trip fixture. Identity MAC-first, else IP.
- generate: resolveDevices() maps each enabled device to a source CIDR
  (explicit IP, else MAC->IP via /tmp/dhcp.leases; unresolvable skipped).
  Routing: device rules spliced after sniff/hijack-dns but BEFORE general
  rules (device overrides win first-match) — !Proxy->direct, Proxy+Target->
  target, Proxy no-target->global default. DNS: per-device allow-then-block
  with source_ip_cidr=<deviceIP> -> predefinedNXDOMAIN (reuses the D15
  action); device Block is NXDOMAIN for that device even with the global
  filter off; allow overrides.
- shater/devices (new leaf pkg): ParseLeases/MACToIP/Discover — merges
  /tmp/dhcp.leases with 'ip neigh' (REACHABLE->online) via the execCommand
  seam, cross-refs configured devices (MAC/IP), appends offline configured
  rows. panel GET /api/devices (session-gated) serves it.

Verified: round-trip + generate codegen tests (src=IP route rule,
source_ip_cidr NXDOMAIN DNS rule, off-cases emit nothing, allow-before-
block) + box.New; devices merge test; panel endpoint test. VM gate PASSED
with TWO netns clients: deviceA nslookup example.com -> NXDOMAIN while
deviceB resolves (per-device block); engine routes .50->direct vs .51->
block (per-device exit); GET /api/devices lists both online, deviceA
configured:true blockCount:1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 20:38:07 +03:00
omarandClaude Opus 4.8 a59d2c0d15 feat(shater): live statistics — DNS-query aggregator + /api/stats + Overview (Phase 5)
In-process stats fed by the engine's DNS-query event stream + nft counters.

- engine: DNSQueryManager() accessor; engine.New pre-registers a stable
  *dnstrack.Manager into e.ctx (box.New only creates one when an api/
  clash_api observable is present, which the router config has none of, so
  the manager would be nil — pre-registering keeps the DNS stream alive).
- shater/stats: Aggregator subscribes to dnstrack QueryEvents and maintains
  bounded top-domains, allowed-vs-blocked (blocked = NXDOMAIN / 0.0.0.0 /
  failed), a 60-min timeline, a 200-entry live query-log ring, per-server
  counts; polls netplane.ListClients/ListCounters for per-device + per-rule
  traffic (client IP -> DHCP hostname). Snapshot()/RecentQueries(); re-subs
  on box swap; resilient when the engine is down.
- daemon: creates+starts the aggregator, Resubscribe() after each reconcile,
  Close on SIGTERM; control-socket 'stats' verb returns the real snapshot.
- panel: GET /api/stats (snapshot) + GET /api/stats/log?n= (live log),
  session-gated; Stats type + getStatsLog() in api.ts; Overview QueryLog now
  polls the live log, plus a DNS-filtering module + blocked SegMeter + top-
  blocked list. Honest empty states, no fabricated data.
- upstream (minimal, marked // lx/D15): dnstrack SourceFiltered +
  emitFilteredResponse at the two DNS-filter predefined-block sites in
  dns/router.go — filter blocks now feed the query stream (were invisible).

Verified: stats+panel unit tests; panel tsc+build; VM E2E — DNS traffic
from a netns client produced /api/stats totals (queries 16, blocked 6),
top_domains[blocked-ad.example blocked 6], per-device row, and /api/stats/log
rows with correct block/allow; stream survived a box swap (SIGHUP). Overview
screenshot shows the live query log + blocked stats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 20:11:45 +03:00
omarandClaude Opus 4.8 5c931db538 docs(roadmap): Phase 4 DNS filter DONE (gate PASSED)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 19:33:02 +03:00
omarandClaude Opus 4.8 980c9360d4 fix(shater/generate): DNS block returns NXDOMAIN/0.0.0.0 (not REFUSED); http_client detour
The DNS filter blocked via reject/default, which sing-box answers with
REFUSED — non-standard for ad-blocking and contradicting the Blocklist
Response field (nxdomain|zero) + the code comments. Switch to sing-box's
predefined DNS action:
- Response nxdomain (default) -> predefined Rcode NXDOMAIN (RcodeNameError).
- Response zero -> predefined Answer A 0.0.0.0 (+ AAAA :: when ipv6), owner
  '*.' so one record serves every domain in a many-domain rule-set. This
  finally implements 'zero'; the old 'not supported' warning/fallback is
  gone.
- Remote rule-set fetch: DownloadDetour (deprecated in sing-box 1.14) ->
  HTTPClient{DialerOptions{Detour: direct}} — no deprecation at box.New.

Verified on the VM via the live in-engine DNSRouter.Exchange: an nxdomain
blocklist -> NXDOMAIN(3); a zero blocklist -> NOERROR(0) + A 0.0.0.0 (not
REFUSED, not NXDOMAIN); a url remote blocklist with http_client validates
with zero deprecation notices. box.New tests cover nxdomain/zero/zero-no-
ipv6/remote/live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 19:32:35 +03:00
omarandClaude Opus 4.8 66f9225189 feat(shater): DNS-filter caching + blocklist update verb + close-first for cache_file (Phase 4 complete, D16)
Completes Phase 4 and passes its gate.

- generate: emit experimental.cache_file (enabled, /etc/shater/cache.db,
  /tmp fallback) so remote rule-sets persist + auto-update and megalists
  stay RAM-sane. Daemon + uci-defaults create /etc/shater.
- cmd/shaterd: real 'blocklist update' verb — SIGHUP-reconcile the running
  daemon so url/file rule-sets re-fetch (cache_file updates); no-op when
  down. shater-cron fires it on the blocklist interval.
- engine (D16): cache_file's bbolt EXCLUSIVE lock broke the apply-swap —
  the new box couldn't take the lock the old held, so every live reconcile
  stalled ~10s then failed 'cache-file timeout' (edits silently ignored).
  Apply now treats the cache-lock timeout as a swap conflict AND proactively
  goes close-old-then-start-new when the incoming config shares the running
  cache_file (sharesCacheFileLock), no stall. Regression test added.

Phase-4 gate PASSED on the OpenWrt VM (netns client, dns_filter on):
blocked-ad.example + doubleclick.net -> blocked (reject, no answer);
example.com -> resolves via the engine resolver; enabling an allowlist
entry + 'blocklist update' -> doubleclick.net resolves (allow overrides
block); a real geosite ads megalist (.srs) loaded and its domains blocked
with shaterd RSS ~39 MB (sane); 'blocklist update' reconciles cleanly.

Follow-ups (flagged, not blocking): sing-box reject returns REFUSED not
NXDOMAIN (comment says NXDOMAIN — a predefined-NXDOMAIN action would match
the usual ad-block convention); remote rule-set download_detour is
deprecated in sing-box 1.14 (works, rename before 1.16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 19:13:58 +03:00
omarandClaude Opus 4.8 b94305912a docs(roadmap): Phase 3 DONE; Phase 4 DNS-filter foundation + panel page landed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:46:55 +03:00
omarandClaude Opus 4.8 adbe4c042b feat(panel): DNS / Blocklists page (Phase 4)
The DNS-filter control surface, wired to the Phase-4 backend.

- DNS FILTER master Toggle (Globals.DNSFilter) with a network-wide
  ad/tracker-blocking label + DNS mode / default+fallback resolver readout.
- BLOCKLISTS: per-list source badge (inline/url/file/geosite), URL host
  (token masked) / inline entry count, NXDOMAIN|0.0.0.0 reply, enable
  toggle + delete, 'filter off' badge when a list is on but the master is
  off. Add form (name + domains-textarea|URL + reply). Quick-add chips seed
  StevenBlack / OISD / AdGuard with canonical URLs (dedup-guarded).
- ALLOWLISTS: same pattern (overrides blocklists).
- RESOLVERS: read-only display (name, type, host masked, detour) — editing
  deferred, noted on-screen.
- save->apply split (putConfig -> toast + banner -> apply) like the other
  pages; secrets never rendered; honest empty states.

api.ts promoted: Blocklist/Allowlist interfaces, Model.Blocklists/Allowlists,
Globals.DNSFilter (from the page's local decls). Wired into App.tsx + index.

Verified: tsc --noEmit clean; npm run build ok (62.8 kB gzip); Playwright
screenshot (mock) confirms Faceplate fidelity + the add/quick-add/empty
states. DNS is no longer a placeholder — 5 of 6 nav pages are live (Devices
awaits Phase 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:46:22 +03:00
omarandClaude Opus 4.8 8ae3fc3a28 feat(shater): DNS filter via sing-box rule-sets + reject rules (Phase 4 foundation, D15)
In-engine DNS blocklist/allowlist filtering built on sing-box's compiled
rule-set matcher (no custom megalist matcher, per D5/D15). Rides the same
in-engine DNS plane as the hijack-dns funnel (D14).

- model: Blocklist{Name,Enabled,Source(inline|file|url|geosite),URL,Path,
  Entries,Response(nxdomain|zero),UpdateInterval} + Allowlist; Model gains
  Blocklists/Allowlists; Globals.DNSFilter master enable (opt-in, default
  off). uci parse + render; round-trip fixture extended.
- generate/dnsfilter.go: each enabled list -> a rule-set (inline for
  Entries as domain_suffix so subdomains match; remote for url w/
  DownloadDetour=direct so fetches don't blackhole under kill-switch;
  local for file; geosite skipped inert when no geodata). DNS rules
  prepended: allow FIRST (rule_set:[al-*] -> route to default resolver,
  terminal, so allowlist overrides), block SECOND (rule_set:[bl-*] ->
  reject NXDOMAIN). zero-response -> NXDOMAIN + warn (predefined answer
  needs a per-query name a many-domain rule-set can't carry). Off/empty ->
  emits nothing.
- shater-core config: commented StevenBlack/OISD/AdGuard blocklists +
  allowlist examples + dns_filter note, all inert.

Verified: model round-trip + generate tests; VM box.New (Apply+Start) of
DNSFilter=true + inline blocklist + allowlist + tproxy + doh resolver ->
valid sing-box config. List fetch/compile + 'blocklist update' verb + the
block-a-domain-E2E gate are the next task (remote rule-sets self-fetch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:37:24 +03:00
omarandClaude Opus 4.8 b4376fe8c9 docs(decisions): D15 — DNS filter via sing-box rule-sets + reject rules, not a custom matcher
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:24:45 +03:00
omarandClaude Opus 4.8 8291d01ab5 feat(shater): configurable panel_port + kill-switch/panel_port in status (Phase 3 polish)
Closes two gaps flagged by the LuCI launcher:
- panel port was hard-coded :8088 on both sides. Now globals.panel_port
  (0 = default 8088): model parses/renders it; cmd/shaterd binds
  :<panel_port> when set, else falls back to SHATER_PANEL_ADDR (env can
  still disable). apply.Status + shaterd status now report panel_port so
  LuCI builds the Open-panel redirect from it (fallback 8088), not a
  constant.
- apply.Status gains kill_switch (from globals) so the readout/LuCI can
  show fail-closed vs open. LuCI dashboard adds a kill-switch LED
  (closed=green, open=amber).

api.ts Status type + mock updated to the new json keys (kill_switch,
panel_port). Round-trip fixture updated (PanelPort) and still passes.

Verified: build+vet, model+panel tests, panel tsc, node --check dashboard;
VM — with option panel_port '8090' the daemon binds :8090 (not 8088),
status reports panel_port:8090 + kill_switch:closed, /api/status on :8090
returns 401 without a session (listening).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:22:40 +03:00
omarandClaude Opus 4.8 b12ccfe9af feat(openwrt/luci-app-shater): thin LuCI launcher + shaterd mint-token verb (Phase 3)
The reachability layer for the admin panel (ARCHITECTURE §2): LuCI — an
already-authenticated, ACL-checked session — mints a single-use handoff
token and opens the embedded panel with a session, so the panel needs no
login of its own.

- shaterd: new 'mint-token' verb — dials the running daemon's control
  socket, prints its {token} JSON. Robust bridge for the rpcd plugin
  (stock OpenWrt has no AF_UNIX client: busybox nc lacks -U, no socat).
  No existing verb touched.
- luci-app-shater: client-JS LuCI app under Services —
  * rpcd exec plugin /usr/libexec/rpcd/shater: ubus object 'shater' with
    status (shaterd status passthrough) + mint_token (shaterd mint-token).
  * acl.d: least-privilege (status=read.ubus, mint_token=write.ubus,
    scoped to the shater object).
  * view dashboard.js: 5s-poll LED status grid + prominent 'Open panel'
    button — mint_token via rpc.declare, then open http://<host>:8088/?t=
    <token>; button disabled + reason when the daemon is down; tab opened
    inside the click gesture so popup blockers don't kill it.
  * menu.d entry, luci.mk Makefile (LUCI_DEPENDS +shater-core +rpcd,
    PKGARCH all, GPL-3.0-or-later), uci-defaults (chmod plugin +x, reload
    rpcd, clear luci cache).

Verified on the OpenWrt VM: shaterd status/mint-token JSON; rpcd plugin
list/call paths; and the full handoff — GET /?t=<token> -> 303 + HttpOnly
SameSite=Strict cookie -> /api/status 200; no cookie -> 401; token reuse
-> no second cookie (single-use). sh -n clean, ACL/menu JSON valid.

Known gap (flagged): panel port is hard-coded :8088 (SHATER_PANEL_ADDR
default) — a future globals.panel_port UCI field would let both sides
share the source. apply.Status has no kill-switch field yet (view shows
the available fields, no fabrication).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 18:13:20 +03:00
omarandClaude Opus 4.8 1e2b405faa feat(panel): Nodes, Routing, and Apply pages (Phase 3 MVP)
Three parallel-built Faceplate pages wired into the shell (Overview was
already live; DNS/Devices stay placeholders until Phases 4/6):

- Nodes.tsx: node + subscription management. Node list with enable toggles,
  protocol pills (from the share-link scheme), MANAGED/STALE tags, add-from-
  share-link, delete; subscriptions add/toggle/delete. Secrets NEVER shown —
  protocol + masked host only; sub URLs show host with 'token hidden'. Sub
  on-demand-refresh is wired-but-disabled (needs a backend endpoint).
- Routing.tsx: first-match rule list on a 'signal bus' rail with order
  steppers, matcher-summary chips, target chips (group/node/egress/direct/
  block), enable toggles; add-rule form with a target picker derived from the
  live config; catch-all rule visually distinguished as route Final.
- Apply.tsx: commit-confirm control room — live LEDs + config hash, Apply
  with a ConfirmTimeout countdown + Confirm (else honest auto-rollback note),
  Rollback with a consequences confirm, before->after hash readout.

All three consume shater/panel's API (getConfig/putConfig/apply/confirm/
rollback) with the save->apply separation, honest empty/error states, no
fabricated data. api.ts Rule widened with the full field set (Src/Dst*/Proto/
Kill/Sched*) so pages share the type; wired into App.tsx's page switch +
pages/index.ts.

Verified: tsc --noEmit clean; npm run build ok (59.5 kB gzip JS, still
react+react-dom only); Playwright screenshots of Nodes/Routing/Apply (mock
backend) confirm Faceplate fidelity + real API-driven rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 17:58:07 +03:00
omarandClaude Opus 4.8 2f852b175f feat(panel): frontend frame — API client, session bootstrap, app shell, live Overview (Phase 3)
Replaces the static showcase with the real wired SPA on the fixed
Faceplate design. Stack stays react+react-dom only (52 kB gzip JS).

- src/api.ts: typed same-origin client (Status + Model sections from
  shater/model), credentials:include, ApiError w/ 401 -> unauth state.
- src/session.ts: ?t=<token> handoff -> POST /api/session -> scrub token,
  preserve hash route (matches ARCHITECTURE §2 / the panel server bridge).
- src/router.ts: ~25-line hash router (useSyncExternalStore), no deps.
- src/App.tsx: Faceplate shell — header (master status LED from /api/status
  + Clock + ThemeSwitch), engraved 6-tab nav (Overview live; Nodes/Routing/
  DNS/Devices/Apply placeholders for the next page-agents), footer statusbar
  (nft LED + engine hash), loading/ready/unauth/error state machine + 5s
  status poll. Reuses the existing Faceplate components.
- src/pages/Overview.tsx: REAL data from /api/status + /api/config — engine/
  config/data-plane/kill-switch LEDs, module cards with live counts, a
  QueryLog polling /api/stats that degrades to an honest empty state (no
  fabricated stream), and an Apply/Confirm/Rollback control row wired to the
  endpoints with inline result + toast.
- src/mock.ts: ?mock dev fixture backend (no daemon needed); vite /api proxy
  to 127.0.0.1:8088 otherwise.

Contract for the remaining pages: add pages/<Name>.tsx, export from
pages/index.ts, switch in App.tsx; read/write via api.ts; shell owns
header/nav/footer/auth.

Verified: tsc --noEmit clean; npm run build ok (52 kB gzip); Playwright
screenshots of Overview in light + dark + mobile confirm Faceplate fidelity,
real API-driven counts/LEDs, visible focus, reduced-motion respected, and
the apply flow (inline 'reconciled' + toast + hash refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 17:45:32 +03:00
omarandClaude Opus 4.8 e6eb0a8eef feat(shater): config-mutation API — model->UCI writer + PUT /api/config (Phase 3)
Lets the panel EDIT config, not just read it.

- model.RenderUCIExport(m): pure inverse of ParseUCIExport (Model ->
  'uci export shater' text), field-for-field on the same uci keys, shipped
  anonymous-section + option-name convention, uci single-quote escaping.
  Round-trip invariant ParseUCIExport(RenderUCIExport(m))==m holds for a
  rich fixture (every section type, lists, bools both ways, ints/hex,
  embedded quote). Bools always emitted (missing != false for default-true
  fields); sub-cache nodes (FromSub!='') skipped (runtime state, not UCI).
- model.WriteUCI(m): delete-then-import ('uci delete shater' -> 'uci import
  shater' <text> -> 'uci commit shater') so it REPLACES rather than appends
  (busybox uci import merges). Via the uciRunner seam (extended with
  Import); only WriteUCI touches uci, Render is pure.
- panel PUT /api/config: session-gated, decodes a Model, light validation
  (manual node must carry a URI -> 400), WriteUCI, returns {ok,applied:false}
  — editing does NOT auto-apply; client calls POST /api/apply after.
  GET/PUT method-dispatch on the same path.

Verified: round-trip + write-replace-idempotence + PUT handler unit tests,
and VM E2E (mint->session->GET config->PUT adds a node->200; uci export
shows the new anonymous config node, +1 exactly no duplication, migrate
re-parses cleanly; original config restored).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 17:30:04 +03:00
omarandClaude Opus 4.8 e401a0be72 feat(shater/panel): in-daemon control API + embedded SPA server (Phase 3 foundation)
The HTTP server the admin panel + thin LuCI consume, running INSIDE the
shaterd daemon and sharing its single *apply.Applier (no second engine).

- shater/panel: net/http server (no framework deps). JSON API under /api:
  GET /api/status (apply.Status + version), GET /api/config (current
  model.ReadUCI), POST /api/apply (snapshot -> reconcile -> arm-rollback),
  POST /api/confirm, POST /api/rollback, GET /api/stats (Phase-5 stub).
- Auth per ARCHITECTURE §2: LuCI mints a single-use short-TTL token over
  the daemon's unix control socket (new 'mint-token' verb -> MintToken);
  POST /api/session {token} validates+consumes it and sets an HttpOnly,
  SameSite=Strict session cookie; all other /api routes require it (401
  otherwise). Also a GET /?t= redirect bridge matching the §2 diagram.
  In-memory token/session stores with expiry; no external deps.
- Serves the embedded Faceplate SPA (go:embed all:webroot; build copies
  panel/dist -> shater/panel/webroot, gitignored w/ .gitkeep so it compiles
  on a fresh checkout, placeholder page when unbuilt). SPA fallback; unknown
  /api/* -> JSON 404, never index.html.
- cmd/shaterd: cmdRun starts the panel server in a goroutine (bind failure
  log-and-continue like the control socket), closed on SIGTERM. Gated by
  SHATER_PANEL_ADDR (default :8088; off=disabled) to avoid widening the UCI
  contract now.

Note: config MUTATION endpoints are intentionally NOT added yet (uci/LuCI
is the writer); /api/apply operates on current UCI. Router build uses the
D9 tag set (with_purego forces a glibc PT_INTERP, unusable on musl).

Verified: unit tests (401 no-session, session->cookie, single-use replay
401, expired/invalid 401, SPA serve) + VM E2E on the OpenWrt VM (mint ->
session -> cookie -> /api/status 200 -> replay 401 -> / serves SPA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 17:10:29 +03:00
omarandClaude Opus 4.8 18bcf5fee3 fix(shater/netplane): set accept_local on tproxy ingress ifaces (silent LAN blackout)
shater routes marked packets via 'ip route add local default dev lo table
<N>'. Local-delivery of a packet whose source is on a directly-connected
subnet requires accept_local=1 on the LAN INGRESS interface — rp_filter=0
alone is not enough. Sysctls() set lo.accept_local=1 but never the LAN
iface, so with the shipped set a real LAN client behind br-lan is BLOCKED
(engine up, table applied, yet the tproxy'd packet never reaches :12345 —
it escapes to the fail-closed forward drop and the LAN goes dark). The
Phase-2 gate only passed because a stale accept_local was left set on the
test VM.

Fix (scoped, least-privilege — not a global 'all' change): new
netplane.ApplyIfaceSysctls(m) sets, per enabled tproxy ingress device
(nftEnabledInboundDevs), net.ipv4.conf.<dev>.accept_local=1 and
.rp_filter=0 (rp_filter is MAX(all,iface), so the static all=0 can't
override an iface value of 1). Called from apply.applyLocked after
ApplySysctl, fail-closed. Static Sysctls() drop-in can't know device
names, so this is dynamic at apply time; hotplug reconcile re-applies.

Verified on the OpenWrt VM from the buggy baseline (br-lan.accept_local=0):
shater's own apply flips it to 1 and a netns LAN client that was blocked
(rc=4 'Operation not permitted') now reaches the internet (egress WAN IP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 16:43:05 +03:00
omarandClaude Opus 4.8 8298fe7d2a feat(openwrt/byedpi): ciadpi desync-proxy package for byedpi egresses (D13 Phase 2b-ii)
The process behind a shater egress of type='byedpi': an optional, separate
OpenWrt package that ships ByeDPI (ciadpi) + a procd supervisor. shaterd's
generate emits a SOCKS5 outbound egress-<name> -> 127.0.0.1:<port> (Phase
2b-i); a ciadpi instance from this package listens on that port, applies
TCP/TLS desync, and goes DIRECT (no tunnel).

- Makefile: package byedpi, pinned upstream v0.17.3 (real PKG_HASH), MIT,
  per-target (compiled C via SDK toolchain calling ciadpi's own make).
- init.d/byedpi: procd multi-instance (one ciadpi per enabled config
  instance, 127.0.0.1:<port> + desync args), inert by default, respawn,
  config-change reload, validation. sh -n clean.
- config/byedpi: default instance disabled, port 1080, a documented desync
  preset. uci-defaults/40_byedpi enables the init.
- Kept SEPARATE from shater-core (byedpi egress is opt-in).

Verified E2E on the OpenWrt VM: musl-static ciadpi (146 KB, no PT_INTERP,
Alpine-built) proxies + desyncs (log: DESYNC_DISORDER); a netns LAN client
routed through a type='byedpi' egress reaches the internet direct via
ciadpi; kill-switch stays honest (SIGKILL shaterd -> client blocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 16:28:21 +03:00
omarandClaude Opus 4.8 f396acc101 feat(shater): ByeDPI SOCKS egress wiring (D13 Phase 2b-i)
Model ciadpi (ByeDPI) as 'just another egress' per D13: an egress of
type 'byedpi' emits a SOCKS5 outbound to 127.0.0.1:<port> (loop-guard
mark so ciadpi's own upstream isn't re-diverted), which a routing rule
targets. The desync happens inside ciadpi, so no native tls_* flags apply.

- model: Egress gains Port int (ciadpi listen port, default 1080); uci
  parses option port; Type doc now lists byedpi.
- generate: type 'byedpi' egress -> C.TypeSOCKS outbound (version 5,
  127.0.0.1:port) tagged egress-<name>.

The ciadpi binary + procd package + E2E is Phase 2b-ii (separate). Pure
Go half verified: unit tests + box.New validation of a byedpi egress on
the OpenWrt VM (router tags) PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 15:51:08 +03:00
omarandClaude Opus 4.8 ab4e864864 feat(shater): native DPI-bypass presets on egress (D13 Tier 1)
An egress gains an optional 'dpi' preset that surfaces sing-box's already-
compiled route-action desync fields, so a ruleset can go DIRECT + desynced
with no tunnel and no extra binary (the DPI-blocked-but-not-IP-blocked case):
  fragment -> tls_fragment        (split the TLS ClientHello record)
  record   -> tls_record_fragment (alternative; mutually exclusive w/ fragment)
  spoof    -> tls_spoof           (decoy ClientHello; wrong-sequence default)

- model: Egress gains DPI string; uci.go parses option dpi.
- generate: a type 'direct' egress now emits a real 'egress-<name>' direct
  outbound (loop-guard mark) so it resolves as a rule target at all — before
  this a direct egress target referenced a non-existent outbound (latent bug).
  buildRoute's applyDPI stamps the matching route-action flag on every rule
  routed to a DPI egress; fragment<->record mutual exclusion enforced; a DPI
  preset on a default/catch-all egress warns (route Final carries no action).
  byedpi reserved for Phase-2b (external SOCKS egress, D13); unknown -> warn+off.
- openwrt example config + ROADMAP updated.

Verified: unit tests (fragment/record/spoof/none/unknown) + box.New validation
of fragment and spoof configs on the OpenWrt VM (router tag set) all PASS.
tls_spoof validates at box.New time on the linux/router build (raw sockets are
only touched at dial time).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 15:42:53 +03:00
omarandClaude Opus 4.8 be15820266 docs(roadmap): Phase 2 control-plane port DONE — E2E gate PASSED on VM
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 01:00:57 +03:00
omarandClaude Opus 4.8 86194ce683 fix(shater): LAN DNS anti-leak via hijack-dns route action (D14)
The netplane dnsnat chain redirected LAN :53 to the router's :53 assuming
the engine answered there, but generate/engine create no :53 DNS server —
so on the VM the redirect landed on dnsmasq, which resolved via its WAN
upstream OUTSIDE the tunnel (DNS leak), leaving the engine's own resolvers
(built with anti-leak detours) unused.

Per D14, use the sing-box-native hijack instead of an nft redirect:
- generate/route.go: hijackDNSRule() after sniffRule — matches the sniffed
  DNS protocol and steals the query into the engine's internal resolver
  (C.RuleActionTypeHijackDNS), which routes each query through its detour.
- netplane/nft.go: drop the prerouting :53 accept and the whole dnsnat
  chain so LAN :53 is diverted by the normal tproxy catch-all into the
  engine, where hijack-dns answers it. :853 DoT reject kept (forces :53).
  With :53 now diverted, DNS also fails closed when the engine is down.

Verified E2E on the OpenWrt VM: with dnsmasq STOPPED, the netns LAN client
still resolves public names (only the engine's hijack-dns could answer);
a WAN :53 forward counter stayed at 0 while the shater divert counter
climbed — real egress was the engine's encrypted DoH to 1.1.1.1:443. No
plaintext client DNS reaches the WAN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 00:56:11 +03:00
omarandClaude Opus 4.8 9b6b94060a fix(shater/engine): close-first fallback when apply-swap hits EADDRINUSE
A transparent proxy pins its tproxy inbound to a FIXED port, identical
across every apply, so the start-new-then-close-old swap ALWAYS collided
with the still-running old box on a live config change:
  reconcile failed: start instance: start inbound/tproxy[in-lan]:
    listen tcp4 0.0.0.0:12345: bind: address already in use
=> the new config silently never took effect. Only initial apply and
disabled->enabled worked (no old listener to clash with).

Fix: keep start-new-first (zero-downtime + old-instance protection when
ports don't clash), but on a listener bind conflict against a running old
instance, fall back to close-old-then-start-new (applyCloseFirst): close
the old box to free the port, build+start a fresh box for the new opts;
the fail-closed nft kill-switch covers the brief gap. If the fresh box
can't come up, restore the previous config; if restore also fails, leave
the engine stopped (instance=nil, never a closed box) — kill-switch keeps
the LAN safe. isAddrInUse matches both errors.Is(EADDRINUSE) and the
error text (box.New may flatten the errno); compiles cross-platform.

Verified on the OpenWrt VM: edit a running config + SIGHUP now logs
'reconcile OK (changed=true)' with the engine hash advancing and NO
'address already in use'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 00:42:32 +03:00
omarandClaude Opus 4.8 2550f745d3 docs(decisions): D14 — LAN DNS anti-leak via hijack-dns route action, not a :53 listener
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 00:33:17 +03:00
omarandClaude Opus 4.8 4f61814048 fix(shater/netplane): fail-closed forward drop when kill_switch=closed
The inet-shater data plane relied entirely on TPROXY delivering to the
engine socket. nftables `tproxy` with NO listening socket returns
NFT_BREAK: it aborts its own rule before the trailing `meta mark set
0x2000 accept`, so the packet is left UNMARKED, falls through prerouting
policy accept, reaches the forward hook, and fw4 masquerades it to WAN.
Result: with the engine dead and kill_switch=closed, LAN clients LEAKED
straight out the WAN (confirmed on the VM: wget succeeded, conntrack
showed the flow SNAT'd to the WAN IP). kill_switch=closed only set the
engine's internal route.Final=block, which is moot when the engine is
down — there was no nft-level fail-closed layer.

Fix: in closed mode the forward chain now drops LAN-ingress traffic that
reaches it bound for a public dst (an escape, since diverted traffic is
delivered locally and never traverses forward), after accepting mgmt /
interface+tunnel egress marks and LAN-to-LAN / link-local so the LAN and
router keep working. Open mode still falls through (documented fail-open).
Folds in the old ipv6-off-closed case. Regression test asserts the v4/v6
drop is present in closed mode and absent in open mode.

Verified E2E on the OpenWrt VM (netns LAN client -> tproxy -> engine ->
AmneziaWG WARP exit): engine up, client egresses 104.28.212.73 warp=on
(direct WAN = 45.131.214.140 warp=off); SIGKILL the engine -> client is
BLOCKED (no WAN leak, no SNAT'd conntrack), whereas before this fix the
same scenario leaked to WAN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-15 00:30:24 +03:00
omarandClaude Opus 4.8 979dfcb8b4 fix(shater/model): UCI section name falls back for node/inbound/group/etc
ParseUCIExport ignored the UCI section name for inbound/subscription/node/
group/chain/egress/ruleset/rule, honoring only 'option name'. A named
section like `config node 'ss1'` therefore yielded an EMPTY name, so
generate could not resolve a rule target 'node:ss1' and silently skipped
the rule — under kill_switch closed that BLOCKS the traffic instead of
proxying it. Now every named section falls back to the section name
(firstNonEmpty(option name, section name)); an explicit 'option name'
still wins. Matches preset/profile/resolver, which already did this.
Regression test covers all three forms for node/inbound/group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-14 22:14:47 +03:00
omarandClaude Opus 4.8 1c3a6949a1 feat(shater/cmd/shaterd + apply): in-process daemon that owns the engine (Wave 3a)
shaterd run is the single procd-supervised process that holds the one
box.New engine + the inet-shater data plane; every other verb is a
short-lived process that SIGNALS it (SIGHUP or the unix control socket)
and never builds a second engine.

- shater/apply: Applier drives model->generate->engine swap->netplane
  (nft+routing+sysctl) under a cross-process flock, fail-closed (engine
  error aborts before netplane; netplane error keeps the kill-switch up).
  Reconcile (SIGHUP), honest Teardown (SIGTERM), commit-confirm
  Snapshot/Confirm/ArmRollback/Rollback, ACTIVE_FLAG gating. flock.go
  no-op default + flock_unix.go syscall.Flock override (cross-platform).
- shater/cmd/shaterd: run/migrate/reconcile/apply/confirm/rollback/
  status/nodes/stats + sub|ruleset|schedule Phase-2b no-op stubs.
  Pidfile single-owner guard; reconcile cold-start no-op (fork-storm
  guard); control socket at /var/run/shaterd.ctl for reply-bearing verbs.

Verified on the OpenWrt x86_64 musl VM with the D9 router tag set
(static ET_EXEC, no PT_INTERP): daemon stays inert while globals.enabled=0
(no inet-shater table), applies on SIGHUP (table + fwmark 0x2000->shater +
loop-guard mark 0xff + tproxy divert appear, status all true, engine
hash set), and does an honest teardown on SIGTERM (table/rule/active-flag/
pidfile all gone, process exits 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-14 22:11:56 +03:00
omarandClaude Opus 4.8 8580577afa feat(shater/generate): Model -> sing-box option.Options generator (Wave 2)
Generate(m)/GenerateWithWarnings(m) build a full option.Options from the
neutral model: outbounds (share-link + AWG-endpoint via shater/parse),
urltest/selector groups, tproxy/mixed inbounds, route rules with the
kill-switch Final gate (closed->block, open->direct), and DNS. Every
inbound/egress carries the netplane loop-guard RoutingMark.

Verified on the OpenWrt x86_64 musl VM: engine.New().Apply (box.New +
Start) accepts AND starts all 7 cases — ss+tproxy+killswitch-closed,
AmneziaWG endpoint, 2-node urltest group, killswitch-open->direct, all
reachable share-link protocols, white-box hy2/tuic/shadowtls mapping, and
bad-node-skipped. RoutingMark validates only on Linux, so the engine
suite is //go:build linux and runs on the VM; Windows/macOS get build+vet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-14 21:51:44 +03:00
omarandClaude Opus 4.8 03706893ce docs(decisions): D13 — pick ByeDPI over zapret as the DPI-bypass egress
DPI-bypass stays a per-ruleset egress choice, never a global toggle.
Evaluated zapret (NFQUEUE packet plane) vs ByeDPI (local SOCKS desync
proxy); chose ByeDPI because it *is* an egress and composes with our
routing model with zero conflict against the verified inet-shater TPROXY
plane. zapret explicitly rejected. Native tls_fragment/spoof (already
compiled in) stay as free complementary egress presets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-14 21:47:20 +03:00
omar 8fdba4fbf9 feat(openwrt/shater-core): procd glue for shaterd (Phase 2 packaging)
Ports v0.1 shater-core to the v0.2 single-binary daemon: init.d/shater supervises
'shaterd run' (inert unless globals.enabled; no file-watch to avoid tunnel churn;
SIGTERM=honest teardown owned by the daemon; ACTIVE_FLAG gates hotplug/cron),
shater-cron (sub/ruleset/schedule due + dead-daemon watchdog), uci-defaults (seed
rt_tables 8192, migrate, sysctl), hotplug (debounced 'shaterd reconcile'), sysctl
matched to netplane.Sysctls(), inert default /etc/config/shater. Deps: +shaterd
+kmod-nft-tproxy +kmod-nft-socket +ip-full (dropped xray/dnsmasq). sh -n clean.

Daemon verbs the scripts require: run, migrate, reconcile, sub update, ruleset
update, schedule due (contract for cmd/shaterd).
2026-07-14 16:34:08 +03:00
omar 95cac3ff41 docs(porting): add Wave 3 daemon contract (shaterd run + shater/apply + shater-core)
Defines the in-process daemon model: 'shaterd run' owns the box, CLI verbs signal
it (SIGHUP=reconcile, SIGTERM=teardown); shater/apply orchestrates generate+engine+
netplane under flock; openwrt/shater-core supervises shaterd. MVP scope for the gate.
2026-07-14 16:25:52 +03:00
omar 5436e1aeb8 feat(shater/parse): port share-link + subscription parsers -> neutral Proxy (Wave 2)
Mark stale issues and pull requests / stale (push) Successful in 4s
Ports v0.1 sharelink/subformat/subfilter/wireguard, retargeted to emit a neutral
Proxy struct (the contract generate maps to sing-box option.*) instead of xray maps.
- ParseShareLink (vless/vmess/trojan/ss/wireguard/wg/awg), ParseWGConf (wg-quick/AWG),
  DetectSubFormat + ParseSubBody (clash/xray/singbox/base64, hand-rolled YAML no dep),
  ApplyFilters (include/exclude/proto/country/dedup), Proxy.Fingerprint.
- Full AWG-2.0 param set (S3/S4, I1-I5, Id/Ip/Ib, H1-H4 as ranges); fixes a latent
  v0.1 YAML nesting bug that dropped ws headers.Host.
Leaf package (model + stdlib only), tests green, no go.mod changes.

mux/xudp/sockopt/mark stay on model.Node; generate combines *Proxy + Node.
2026-07-14 16:22:46 +03:00
omar 2d99ae3b64 feat(shater/netplane): port nft inet-shater plane + policy routing (Wave 2)
Engine-agnostic data plane ported from v0.1 egress.go/nftstats.go/apply.go:
- RenderNft: inet shater ruleset (tproxy divert + loop-guard 0xff + mgmt-bypass +
  private-range/DoT/:53 exemptions + dnsnat :53 redirect + fail-closed/ipv6 drop),
  validated with 'nft -c' on the VM (v4 + v6, exit 0).
- ApplyNft/TeardownNft/ApplyRouting/TeardownRouting (fwmark 0x2000->table, per-egress
  mark+table), stats (nft -j clients/counters), sysctl knobs. Mockable exec seam.
Loop-guard mark 0xff, egress tag egress-<name>+EgressMark: contract for generate.
No go.mod changes.
2026-07-14 16:14:13 +03:00
omar bd620d8e4b feat(shater/engine): in-process box.New lifecycle manager (Wave 2)
Engine drives sing-box in-process (D11): Apply builds+validates via box.New,
swaps atomically (start new, close old), gates on a config hash (no churn on
unchanged apply), keeps the old instance running if a new config fails validation,
and supports Rollback to last-good. Instance()/Hash() expose state for stats later.
Integration test verifies apply/no-op/swap/invalid-keeps-old/rollback/close.
No go.mod changes.
2026-07-14 16:10:22 +03:00
omar 1cf03fec5f feat(panel): Faceplate component library + Overview showcase
Token-driven, accessible components: Faceplate/FaceplateHeader, Module, Toggle,
Led, SegMeter, QueryLog, Button, Clock, ThemeSwitch + reduced-motion hook. App
is a realistic Overview showcase (throughput VU meters, module grid, streaming
query log, apply->toast, theme switch). Built to match the north-star prototype:
instrument-panel language, orange only as accent, semantic good/warn/crit.

Verified (Playwright): dark+light, desktop+mobile (3->1 col), :focus-visible ring,
reduced-motion honored, no console errors. dist ~162KB (49KB gzip), no heavy deps.
2026-07-14 16:03:54 +03:00
omar 2309759d9b feat(shater/model): port UCI desired-state model + reader + migrate (Wave 1)
Leaf package (stdlib only) ported near-1:1 from v0.1 xrayctl model.go/uci.go/
migrate.go. Types: Model/Globals/Inbound/Subscription/Node/Group/Chain/Egress/
Ruleset/Rule/Preset/Profile/Resolver/DNSRule. ParseUCIExport is pure (testable);
ReadUCI runs 'uci -q export shater'. Exposes DefaultGlobals/EffectiveType/
IsTproxyInbound/SplitTarget for Wave-2 reuse. 11 tests pass, no go.mod changes.

The shared contract every Phase 2 package imports.
2026-07-14 16:03:16 +03:00
omar 481eebc8de docs(porting): Phase 2 porting spec — v0.1 control-plane -> sing-box options
Full map of the v0.1 xrayctl/shater-core internals + the sing-box option surface,
what ports verbatim (nft/routing plane, UCI model, parsers, subsystems) vs. what is
rewritten (generator, DNS, box lifecycle, stats), the v0.2 package layout, and the
wave plan. Authoritative reference for all Phase 2 agents.
2026-07-14 15:57:25 +03:00
omar 676e87cbf4 docs(decisions): D11 (in-process box.New engine) + D12 (shaterd entrypoint)
Records the Phase 2 architecture: embed the engine in-process (apply = atomic
instance swap), rewrite only the generator (xray JSON -> sing-box options),
port parsers + nft/routing near-verbatim. Ship one binary shaterd.
2026-07-14 15:48:24 +03:00
omar 876fe392f9 docs(roadmap): Phase 1 gate PASSED — fork+embed+AWG2 E2E+size all green
AmneziaWG 2.0 proven E2E vs live Cloudflare WARP (warp=off->on through tunnel);
embedding via box.New verified on VM; router musl build ~9-11MB UPX. Next: Phase 2.
2026-07-14 15:46:06 +03:00
omar 34a8c7f869 feat(shater): Phase 1 embedding prototype (box.New)
shater/cmd/shater-proto: drives the engine via the library API (box.New /
Start / Close) from our own Go main — the pattern the control-plane reuses.
Builds an option.Options in code (mixed inbound + direct outbound), proves the
data path with an in-process socks5 probe, and validates a tproxy+shadowsocks
variant through box.New. Verified musl-static on the x86_64 OpenWrt VM (egress
via the embedded engine), arm64 build proof, go vet clean. No go.mod changes.

Key API notes captured for Phase 2:
- ctx = include.Context(service.ContextWith(bg, deprecated.NewStderrManager(...)))
- option.Inbound/Outbound.Options MUST be a POINTER to the concrete struct.
- box.New constructs+validates every adapter; a single non-special outbound is
  auto-selected as default route.
2026-07-14 14:46:43 +03:00
omar 30568870e0 feat(panel): scaffold Faceplate admin SPA (Vite + React + TS)
Orchestrator-laid foundation per CLAUDE.md: lightweight single-bundle SPA to be
embedded in the forked binary and served by the daemon on its own port.
- tokens.css: Faceplate tokens ported verbatim from docs-shater/DESIGN.md
  (light/dark, prefers-color-scheme default + data-theme override both ways),
  mono instrument voice, tabular-nums, focus-visible, reduced-motion floor.
- Placeholder App shell (component library <Faceplate>/<Module>/<Toggle>/<Led>/
  <SegMeter>/<QueryLog> + pages land in Phase 3, delegated).
Builds clean: 145K dist (46KB gzip JS), tsc --noEmit passes.
2026-07-14 14:46:10 +03:00
omar 0cbf8931a9 docs(decisions): add D9 (router musl-static tag set) + D10 (UPX ship)
Phase 1 VM findings: canonical LX_TAGS links glibc (naive/cronet/purego dlopen)
and won't run on musl OpenWrt; router build drops with_naive_outbound,with_purego
for a fully-static binary. Ship UPX-lzma (~9-11MB from ~40MB raw).
2026-07-14 14:38:47 +03:00
omar d41a685d9b chore: relocate shater meta-docs to docs-shater/ (avoid upstream docs/ collision)
Upstream sing-box-lx already ships a docs/ mkdocs site; keep our project docs
separate and unambiguous in docs-shater/ (parallels upstream's docs-lx/).
Updated all references in README.md, CLAUDE.md, CONTEXT.md, ARCHITECTURE.md.
2026-07-14 14:19:34 +03:00
omar 2c7b1ff9d8 merge: fork sing-box-lx v1.14.0-lx.3 as v0.2 engine base
Fork upstream github.com/Leadaxe/sing-box-lx (tag v1.14.0-lx.3) into main as the
v0.2 engine, per docs/DECISIONS.md D3 (main IS the fork; additive overlay).

Conflict resolution:
- README.md  -> ours (shater v0.2 project readme)
- LICENSE    -> upstream (canonical GPL-3.0; identical license)
- .gitignore -> union of both; CLAUDE.md kept tracked

Go module stays github.com/sagernet/sing-box so our overlay (shater/, panel/)
can import engine packages directly. submodules/wireguard-go pinned at
1adc4c71 provides AmneziaWG 2.0 (populated next).
2026-07-14 14:18:14 +03:00
omar d68b8e7116 docs: add CLAUDE.md (orchestrator rules) + DESIGN.md (Faceplate spec) 2026-07-14 14:17:00 +03:00
omarandClaude Opus 4.8 903f2345f3 chore: reset main for v0.2 (sing-box fork) — full context docs
Foundation pivot. The complete, working, VM-verified xray-based project is
preserved on the `v0.1` branch; `main` is reset to a docs-first scaffold for
v0.2, which will be built as a FORK of sing-box-lx with our control-plane,
DNS filter, stats and admin panel embedded in the one binary.

- Preserve everything on branch v0.1 (pushed).
- Remove the v0.1 implementation + old design docs from main (recoverable from
  v0.1); keep LICENSE, .gitignore, .gitattributes, dist/shater-feed.pub (feed
  signing key 5ac4b177689cb8e0 carries over).
- License -> GPL-3.0 (sing-box is GPL-3.0).
- Add full project context so it survives compaction:
  docs/CONTEXT.md (start here), DECISIONS.md, ARCHITECTURE.md, ROADMAP.md,
  FEATURES.md, and a new README.

Engine/UI decisions (see docs/DECISIONS.md): fork sing-box-lx (AmneziaWG 2.0 +
broad protocols, GPL-3.0, library-first) and embed the whole product for tight
integration; keep the fork maintainable via an additive overlay (shater/, panel/,
openwrt/) rebased on upstream tags. UI = thin LuCI launcher + a separate admin
panel served by the daemon, entered via a short-lived token minted in the
authenticated LuCI session. Do NOT write a proxy engine from scratch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLthkP2S8WAfxu7fcYbPfE
2026-07-14 13:53:15 +03:00
Leadaxe 929909652e docs(lx-changelog): v1.14.0-lx.3 — stable promotion of rc.2 (device-verified)
Promote v1.14.0-lx.3-rc.2 to a stable (Latest) release. Functionally
identical — no runtime change since the rc, only this changelog entry.
Payload: DNS command-multiplex (rc.1) + AWG re-graft onto wireguard-go
v0.0.5 with upstream merge (rc.2). Device-verified by owner.
2026-07-08 16:23:54 +03:00
Leadaxe 9dc758e43c Merge upstream/testing (L3-forwarding, snell, bridge) into lx-1.14
Merges 14 upstream commits including L3-forwarding support (which bumped
wireguard-go v0.0.3->v0.0.5, already re-grafted in the prior commit),
snell protocol, bridge outbound, flow-tracking/sniff improvements, and
DNS/dialer fixes.

lx conflict resolutions:
- protocol/wireguard/endpoint.go: took upstream's new flow API
  (PreMatchFlow/PortAddresses/PortMTU/AttachReturn/DetachReturn/JudgeFlow),
  dropped our old PrepareConnection/NewDirectRouteConnection. SPEC 020
  idle-suspend wake guard (resumeOnDial) moved to WritePackets — the single
  point every L3-forwarded packet transits, incl. established flows that
  bypass DialContext.
- adapter/outbound.go: kept lx IdleSuspendable/ReachabilityInvalidator,
  restored 'time' import dropped by auto-merge.
- go.mod/go.sum + test/: took upstream dependency bumps (tailscale, sing,
  sing-tun); wireguard-go stays v0.0.5 with local submodule replace.

Green: full sing-box CLI with LX_TAGS (Go 1.24.7), libbox, wireguard/
adapter/dns/daemon packages, transport+protocol/wireguard tests, AWG
config validation.
2026-07-08 15:10:38 +03:00
Leadaxe 689399cc9d docs(spec-003,spec-020): rewrite to current-state methodology + HISTORY
SPEC 003 (AWG): rewritten top-down as current architecture (vendored
wireguard-go graft on v0.0.5, MessageEncapsulatingTransportSize=0
invariant, MTU policy, re-graft procedure). Chronology (graft base
evolution 27290b6->e5feca7->1adc4c7, upstream v0.0.3->v0.0.5 delta,
MTU/EMSGSIZE finding) -> HISTORY.md.

SPEC 020 (idle-suspend): rewritten current-state (reachability /
cache-invalidation / endpoint Down-Up, v0.0.5 compat section:
Down/Up/BindUpdate present, SetSinglePeerMode not in our path).
Chronology (the idle-tick-blind bug, rejected GRO-off experiment,
path A/B/Hybrid, RESEARCH GC-source correction) -> HISTORY.md.

Per CONSTITUTION 3.2 / SPECS README: SPEC.md = current state top-down,
no diary markers; history in HISTORY.md.
2026-07-08 14:41:53 +03:00
Leadaxe cdc9eaeda6 build(awg): re-graft AmneziaWG onto wireguard-go v0.0.5
Submodule bumped e5feca7 -> 1adc4c7 (branch lx-awg2-v005): AWG 2.0
obfuscation graft rebased from sagernet/wireguard-go v0.0.3 onto v0.0.5
(2c27bbf4f97f, L3-forwarding). 15/16 graft files applied clean via
3-way; send.go conflicted on one line (upstream queuedOutboundPackets
backpressure vs graft blank line — took upstream). Invariant
MessageEncapsulatingTransportSize=0 preserved; upstream InputPacket/
InputPackets and size-based outbound buffer pool taken verbatim.

go.mod pin: v0.0.3 -> v0.0.5-0.20260706153856-2c27bbf4f97f (matches
upstream/testing; replace stays local submodule).

Builds device/conn/tun on linux/android/windows/darwin; full sing-box
CLI with LX_TAGS; transport/wireguard + protocol/wireguard tests green.
2026-07-08 14:41:32 +03:00
Leadaxe 34e9ef262b docs(lx-changelog): v1.14.0-lx.3-rc.1 — DNS stream to command multiplex (SPEC 018 v2) 2026-07-08 13:48:57 +03:00
世界 c9690acdf1 Add windows bridge 2026-07-08 18:22:36 +08:00
Leadaxe 8c76ad2648 docs(spec-018): rewrite as multiplex arch; HISTORY.md; spec-structure rule
SPEC.md rewritten to current (multiplex) architecture, no chronology.
HISTORY.md captures v1 standalone class-error, the field bug, rejected paths.
New project rule (README + CONSTITUTION 3.2): SPEC.md = current state first,
chronology/rationale of architecture changes go to HISTORY.md.
2026-07-08 11:50:37 +03:00
Leadaxe 13d311cc27 feat(spec-018): handleDNSStream replaces standalone DnsQuerySubscription
DNS stream now runs on the shared c.ctx via dispatchCommands (CommandDNS),
mirroring handleConnectionsStream: auto-reconnects with Connect(), dies with
the client, no per-stream Close()/OnError. Removes DnsQueryHandler,
DnsQuerySubscription and the standalone SubscribeDNSQueries client method.
DnsQuery/DnsAnswer/dnsQueryFromGRPC unchanged.
2026-07-08 11:50:37 +03:00
Leadaxe 825d3c1c3e feat(spec-018): CommandDNS multiplex seams in upstream libbox
Add DNS as a first-class multiplexed command, uniform with CommandConnections:
- command.go: CommandDNS constant (next in iota)
- command_client.go: case CommandDNS in dispatchCommands; DNSIncludeAnswers
  option field (like StatusInterval); WriteDNSQuery in CommandClientHandler

All three upstream touch-points wrapped in // lx:begin dns / // lx:end dns
(CONSTITUTION 3.3). SPEC 018 v2.
2026-07-08 11:50:37 +03:00
世界 9ec7cc8cbe Improve bridge 2026-07-08 16:30:06 +08:00
世界 aaa1289e5c Bump version 2026-07-08 00:34:32 +08:00
世界 c7fe778cae Add bridge outbound 2026-07-08 00:34:26 +08:00
世界 f617dc42aa daemon: Release memory to OS on service reload 2026-07-07 19:37:05 +08:00
世界 f2dd4bfd75 Imrpove flow tracking & sniff action 2026-07-07 18:37:18 +08:00
世界 c4c11b1b1b Bump version 2026-07-07 18:37:18 +08:00
世界 bf3688861b Fix local DNS transport on darwin 2026-07-07 18:37:18 +08:00
Leadaxe 6b99cf805f docs(spec-024): RUNTIME_LOOP_GUARD design record — DEFERRED, guard moves to UI
Runtime detour/selector rings crash the core (fatal stack overflow via
unbounded DialContext recursion); static rings are already rejected at start
by lintOutbound. Worked out the full event model (E1-E5) and topologyMu race
linearization, then adversarially verified it (7-agent workflow): deadlock and
false-positive attacks HOLD, but TOCTOU BREAKS — even a correct core guard is
not airtight without also covering the endpoint manager, Manager.Remove,
history side-channels, and the pointer-vs-tag graph divergence after a runtime
Create. Owner decision (2026-07-06): protection lives at the UI level (LxBox
validates before SelectOutbound); core stays a minimal delta to upstream.

No core code changed. SPEC is a design record + Roadmap row (status DEFERRED).
2026-07-07 00:33:49 +03:00
Leadaxe 8dc7e2ff6a ci(lx-ci): scope -unsafeptr=false to the two upstream crash packages
Split the lx go vet step into two passes so every lx-owned package keeps
the full analyzer set; only daemon/ and experimental/libbox/ (upstream
TriggerDebugCrash/TriggerGoPanic) drop the unsafeptr check.
2026-07-07 00:28:45 +03:00
世界 e6419b945f Add L3 forwarding support 2026-07-06 21:13:33 +08:00
世界 585d3e639e Bump version 2026-07-05 12:47:42 +08:00
世界 5af56d2cfd Add snell protocol 2026-07-05 12:47:42 +08:00
世界 19bdedec29 dialer: Dial directly when only one interface is available 2026-07-04 22:51:38 +08:00
世界 2c2828cedc Fix cloudflared crash 2026-07-04 14:53:18 +08:00
世界 0a11d45471 tls: Fix read wait buffer sizing 2026-07-04 14:53:03 +08:00
Leadaxe 9b1ae9e93e docs(lx-changelog): v1.14.0-lx.2 — stable promotion of rc.1 (device-verified) 2026-07-02 20:04:13 +03:00
Leadaxe e5a4b3e457 docs(spec-023): close MUSL_TOOLCHAIN_MIRROR — mirror filled, restore verified
Producer run populated musl-toolchain-cache with 4 arch assets; restore path
validated locally (asset name, gh download, tar layout under naiveproxy/src).
Status -> C, Roadmap updated.
2026-07-02 19:50:57 +03:00
Leadaxe 5e345ffe48 ci(lx-release): durable musl-toolchain mirror as release asset (SPEC 023)
snapshot.debian.org intermittently 503s during the musl sysroot build and
blocks releases (v1.14.0-lx.2-rc.1 failed twice on it). actions/cache also
misses across tag builds (ref-scoping). Add a producer workflow that uploads
the built toolchain to a musl-toolchain-cache release, and a restore step in
lx-release.yml that pulls it on cache-miss before falling back to
snapshot.debian.org. Both workflows are lx-owned; zero upstream diff.
2026-07-02 19:34:07 +03:00
Leadaxe c3db66f295 docs(lx-changelog): v1.14.0-lx.2-rc.1 — SPEC 022 audit remediation 2026-07-02 18:53:01 +03:00
Leadaxe 604155409e docs(spec): add SPEC 022 deep-audit report + remediation record
Full audit of the LX delta (10 axes, adversarial verification): 32 findings,
27 confirmed, 24 fixed on branch lx-spec022-audit-fixes, 3 skipped by design
(#12/#17/#18). Records #19 resolution (SPEC 013 test kept — upstream ships none).
2026-07-02 18:31:33 +03:00
Leadaxe c129308bb1 docs(lx-config): move no_grpc_header to accepted-but-ignored (SPEC 022 #23)
The client emits no gRPC-style headers, so no_grpc_header has no effect. Move it
out of the main options table into the ignored-knobs list (EN + RU).
2026-07-02 18:29:25 +03:00
Leadaxe 022d91358d lx(audit): P3 hygiene — comments, dead code, markers, micro-opt (SPEC 022)
Nit-level cleanups verified against source:
- #9/#10 correct GetGroups/poolProvider error-convention + mode comments
- #11 mark the SPEC 018 transport-arg edits in dns/client.go with // lx:
- #13/#14/#15 option/route.go: named // lx:end idle-suspend, drop (XX) placeholder
- #16/#24/#25 fix misleading xhttp comments (sc_stream_up_server_secs, metaOptions, applyMeta)
- #20 compute ipVersion once in handleIncomingProxiedPacket
- #21 document the 1232 ICMPv6 quoted-data bound
- #23 drop dead Client.noGRPCHeader field (option stays accepted, no effect)
- #26 add s3/s4 to device_awg format-example comment
- #27 STUN ufrag buffer 9->8 bytes (only 8 consumed)
2026-07-02 18:29:25 +03:00
Leadaxe 1176b216d2 docs(lx-config): correct ip=sip decoy shape (SPEC 022 #4)
The ip=sip masquerade emits a body-less INVITE (Content-Length: 0) plus a
matching 100 Trying, not an SDP offer with m=audio. Fix EN + RU.
2026-07-02 18:18:34 +03:00
Leadaxe 641519f222 lx(masque): match h3 login-failure alert as substring (SPEC 022 #22)
Exact-string match on quic-go's formatted CRYPTO_ERROR silently disabled the
Cloudflare Access hint on any error-text reformat. Match the inner
"tls: access denied" alert as a substring instead.
2026-07-02 18:18:34 +03:00
Leadaxe 5a84a2538f lx(awg): MTU/junk overhead is s4 only, not max(s3,s4) (SPEC 022 #8)
s3 pads only cookie-reply messages (paddings.cookie); s4 pads every transport
data packet (paddings.transport). Folding s3 into the MTU budget dropped MTU and
warned spuriously for an atypical s3>s4 config. Fix calc, comment, warning, docs.
2026-07-02 18:17:03 +03:00
Leadaxe 844ae9361d lx(xhttp): remove racy reader fast path in streamConn.Read (SPEC 022 #7)
The 'if c.reader == nil' fast path read reader without synchronising against the
RoundTrip goroutine's write (a data race -race flagged). Always receive on
created first; on an already-closed channel that is effectively free.
2026-07-02 18:17:03 +03:00
Leadaxe 8998728296 lx(masque): recompute IPv4 checksum over full header incl. options (SPEC 022 #6)
calculateIPv4Checksum summed a fixed 20 bytes, producing a wrong checksum after
TTL decrement when the header carried options (IHL>5). Take the real IHL*4 span;
validate IHL against the buffer before the read. Adds an IHL=6 test.
2026-07-02 18:17:02 +03:00
Leadaxe 8e4db706ed lx(urltest): read pool history under RealTag in Pool() (SPEC 022 #5)
Pool() keyed URL-test history by the raw slot tag; history is stored under
RealTag(detour), so a nested-group member always reported Delay=0 in GetPool.
Resolve the slot tag and read under RealTag, matching seedPool/rebuildPool.
2026-07-02 18:17:02 +03:00
Leadaxe 1b4c681d3b lx(dns): emit DnsQueryEvent on fresh cache hit in Lookup path (SPEC 022 #3)
questionCache returned a fresh cached response without logging/emitting; only
the stale (optimistic) branch emitted. Mirror the Exchange path: fresh->cached,
stale->optimistic. Gated by HasSubscribers, so zero cost with no profiler.
2026-07-02 18:17:02 +03:00
Leadaxe c67bb9d8d4 lx(awg): keep guard-suspended AmneziaWG endpoint down (SPEC 022 #2)
SuspendAmneziaWG left idleAsleep untouched, so an endpoint idle-suspended
BEFORE the guard fired could be resurrected by the next dial (resumeOnDial
keys only on idleAsleep) — reintroducing the AmneziaWG-over-WireGuard kernel
hang the guard exists to prevent. Now clears idleAsleep under resumeMu so
the guard is ordered against a concurrent wake.
2026-07-02 18:06:58 +03:00
Leadaxe 5065f6b0b3 lx(masque): bound h2 CONNECT handshake by ctx (SPEC 022 #1)
sendConnect's ReadFrame loop blocked forever on a peer that completes
TCP+TLS but never returns the CONNECT HEADERS, wedging the outbound under
o.runMu (Close hangs too). A ctx watcher now trips tlsConn's deadline on
timeout/cancel and is joined before the long-lived readLoop starts.
2026-07-02 18:06:58 +03:00
Leadaxe d11e5930c3 docs(readme): add MASQUE to the feature table + config section (EN + RU)
The main README's 'Features & status' table and Feature-configuration section
listed XHTTP/AWG/observability/round_robin but not the SPEC 021 MASQUE
(CONNECT-IP / WARP) outbound. Add it in both README.md and README.ru.md:
intro line, a Features table row (device-verified on Wi-Fi + LTE, h3/h2), and
a short config example with the network=transport footgun and the h2 fallback
for UDP:443-filtered networks. Links to docs-lx/lx-config §4 and SPECS/021.
2026-07-02 14:29:42 +03:00
Leadaxe 3f25c29f1f docs(lx-config): add MASQUE outbound section (EN + RU)
The user-facing config guide covered XHTTP/AWG/urltest but not the SPEC 021
MASQUE (CONNECT-IP / WARP) outbound — only the internal SPECS/021 had it.
Add a full section (§4, renumbering Observability→§5, Validate→§6): feature
table row, field table, h3/h2 example, the network=transport footgun, dns-block
requirement, h3-vs-h2 guidance (UDP:443 filtering, cold-start), device-verified
status, and a link to SPECS/021/CONFIG.md. RU mirror kept in sync.
2026-07-02 11:37:55 +03:00
Leadaxe e83bd131af ci(lx-ci): vet with -unsafeptr=false — upstream debug-crash trips unsafeptr
The 1.14 merge (6b63cee4) brought daemon/managed_service.go and
experimental/libbox/debug.go into vet scope; both crash Go ON PURPOSE via
*(*int)(unsafe.Pointer(uintptr(0)))=0 (TriggerDebugCrash/TriggerGoPanic),
which vet's unsafeptr analyzer flags. They are upstream files we don't edit,
and no lx-owned file uses unsafe at all, so disable just that analyzer.
Fixes the red lint job on every push since the merge.
2026-07-02 11:36:33 +03:00
Leadaxe 49cd317980 Merge lx-masque-fail-warn: MASQUE dial failure logged at WARN 2026-07-02 11:18:09 +03:00
Leadaxe ad84c06699 fix(masque): log tunnel dial failure at WARN, not DEBUG
A failed dial is an actionable error and should be visible where the success
(tunnel established, INFO) is — the LxBox core-log forwarder only surfaces
INFO+, so a DEBUG failure was invisible on-device. WARN makes established/
failed a symmetric, forwardable pair. The detailed dial phases (establishing,
udp-socket-up) stay DEBUG.
2026-07-02 11:17:59 +03:00
Leadaxe 0759fce433 docs(lx-changelog): v1.14.0-lx.1 — first full release of the 1.14 lx line
Promotion of the rc.1..rc.22 series to a non-prerelease tag (publishes as
Latest). Functionally rc.22 + the linux-mips-softfloat asset (#6). Section
header matches the tag exactly so lx-release.yml extracts it into the notes.
2026-07-02 11:11:16 +03:00
Leadaxe 2ba69f0634 Merge lx-ci-musl-retry: retry flaky Debian steps in musl build
Rides out transient snapshot.debian.org 503s in the linux-musl toolchain
download/keyring steps (rc.22 failure cause). No code change; publish still
requires all builds.
2026-07-02 11:04:19 +03:00
Leadaxe 1c5a0eb64b ci(lx-release): retry Debian-dependent musl toolchain steps (flaky snapshot.debian.org)
rc.22 linux-musl jobs failed on 'get-clang.sh: 503 No healthy backends' from
snapshot.debian.org (transient mirror outage). get-clang.sh retries internally
but back-to-back, so a whole outage window fails all attempts. Wrap the two
Debian-fetching steps with an external retry + growing backoff:
- Download Chromium musl toolchain: 5 attempts, 30→60→120→240s
- Regenerate Debian keyring: 4 attempts, 20→40→80s

Conservative: publish still needs all builds (a real musl breakage still blocks
the release, only transient mirror flakes are ridden out). No code change.
2026-07-02 11:03:39 +03:00
Leadaxe d5864bddd4 feat(release): linux-mips-softfloat asset (big-endian MIPS, OpenWrt mips_24kc)
Requested in #6 (Atheros AR9344). Chromium/cronet has no big-endian MIPS
toolchain, so the musl+naive path is impossible for this target; add it to
the plain cross-compile matrix instead as a pure-Go CGO_ENABLED=0 build —
statically linked (runs on musl/OpenWrt as-is), with with_naive_outbound
and with_purego dropped (purego has no mips port either). Everything else
matches the desktop tag set.

Verified locally: GOOS=linux GOARCH=mips GOMIPS=softfloat build with the
reduced tag set compiles clean; `file` reports ELF 32-bit MSB MIPS32,
statically linked.
2026-07-02 10:50:36 +03:00
Leadaxe 16c4193fe1 docs(lx-changelog): rc.22 — MASQUE transport logging; release branch → lx
Also point lx-release.yml notes (base-branch label + changelog fallback link)
at the lx branch, since rc-tags are now cut from lx.
2026-07-02 10:46:58 +03:00
Leadaxe 99ae0ddc5f Merge lx-masque-transport-logs: MASQUE transport-phase debug logging
Adds establish/handshake/success debug logs to the MASQUE dial path so a
stuck tunnel is diagnosable from /logs/core alone (motivated by the LxBox
§130 device case: h3 hung in QUIC handshake because inbound UDP:443 was
2026-07-02 10:44:06 +03:00
Leadaxe bae1d95333 feat(masque): transport-phase debug logging (dial diagnostics)
Log the tunnel-establish phases so a stuck dial is diagnosable from
/logs/core alone, without a goroutine dump:
- "establishing <h3|h2> tunnel to <server> (sni=...)" on start
- "udp socket up, starting QUIC handshake" (h3) — pinpoints whether a
  hang is the socket or the handshake (inbound UDP:443 filtered → our
  ClientHello left but no ServerHello came back)
- "tunnel established" on success, "tunnel failed: <err>" on failure

Motivated by a live device case (LxBox §130): h3 hung in the QUIC
handshake because inbound UDP:443 was filtered by the network while AWG
(UDP on a non-443 port) worked — invisible in logs before this, required
a pprof dump to locate. h2 (TCP:443) is the fix there.

Refs: SPEC 021
2026-07-02 04:10:53 +03:00
Leadaxe fbbf0368f9 docs(lx-changelog): rc.21 — absolute link to CONFIG.md (relative breaks release notes) 2026-07-02 03:16:44 +03:00
Leadaxe 6b63cee4ff Merge lx-1.14 into lx: upstream 1.14 base + SPEC 015-021 (MASQUE rc.21)
Brings the lx branch up to the current 1.14 line: migration to upstream
v1.14.0-alpha base, SPEC 015-020, and SPEC 021 MASQUE CONNECT-IP outbound
2026-07-02 03:12:09 +03:00
Leadaxe 1bff2338d4 docs(lx-changelog): rc.21 — MASQUE CONNECT-IP outbound (Cloudflare WARP) 2026-07-02 02:53:49 +03:00
Leadaxe d2704bb3d8 docs(spec-021): add CONFIG.md — full outbound template + parameter reference
Complete masque outbound config reference verified against code:
full JSONC template (all params), minimal config, per-field table
(masque-specific + inherited DialerOptions), profile matrix, value
formats (duration/keys/ip), start-time validation, and common footguns
(network=transport not tcp/udp, dns block required, exit-IP changes on
reconnect, keepalive vs idle_timeout).
2026-07-02 02:46:04 +03:00
Leadaxe 5266e58ccc docs(spec-021): sync SPEC/TEST_PLAN to shipped design + audit results
- Remove phantom 'cwnd' (broke strict JSON decode) and dead
  'congestion_control' from the config example/table.
- Add idle_timeout / keep_alive_period; document network=transport (h3/h2)
  vs network_list=L4 footgun; note h2 mtu<=16000.
- Refresh Files / Data-flow / Risks sections to the actually-shipped design
  (session lifecycle, idle-suspend, h3 0x276 quirk, manual h2 framer, plain
  CONNECT); mark risks 1/2/3 closed.
- Record audit RESULTS: dial/idle-suspend/reconnect device-verified on both
  h3 and h2.

Refs: SPEC 021 audit group D
2026-07-02 02:38:39 +03:00
Leadaxe 986d7658f7 feat(masque): self-healing tunnel + idle-suspend (stateless idle)
Rework the tunnel lifecycle around a *session (device + ipConn + closer +
ctx + activity counter), guarded by runMu with a generation guard.

- C1 (was HIGH): a dropped or suspended tunnel is now rebuilt on the next
  dial. Previously 'running' latched true, so after the tunnel died every
  DialContext short-circuited and dialed into a dead stack — permanent
  blackhole. teardownSession clears o.sess so ensureSession rebuilds.
- C2: teardownSession closes ipConn, which unblocks the paired pump parked in
  a blocking read (context cancellation alone can't interrupt it); no leaked
  goroutine, no zombie half-open tunnel. Idempotent via sync.Once.
- B1: idleWatcher suspends the whole tunnel (gVisor netstack, pumps, QUIC
  keepalive) after idle_timeout of no traffic; the next dial rebuilds it —
  near-zero resident cost when idle.
- B4: idle_timeout (default 5m) and keep_alive_period (default 30s) are config
  options; negative disables. A5: fail-fast mtu<=16000 on h2.
- D2: drop the dead congestion_control option field.

lifecycle_test.go covers the generation guard, idempotent teardown and
close-guard under -race.

Refs: SPEC 021 audit B1/B4/C1/C2/A5/D2
2026-07-02 02:38:28 +03:00
Leadaxe df5388b14f harden(masque): h2 bounds + fewer allocs
- A4/A5/B5: cap peer-declared capsule payloadLen at maxCapsulePayload (64KiB)
  to prevent int-overflow/OOM on a hostile length; shrink recvCh 64->8 and the
  receive window 1GiB->8MiB so real HTTP/2 flow-control provides backpressure
  instead of unbounded RAM.
- B2: reuse a per-conn scratch for the outgoing capsule frame (tx pump is the
  sole writer; writeData flushes before returning) instead of allocating per
  packet.

Refs: SPEC 021 audit A4/A5/B2/B5
2026-07-02 02:38:03 +03:00
Leadaxe b91f4b9fe9 fix(masque): h3 correctness — no blackhole on bad datagram, faithful ICMP, tidy status
- A1: a single malformed/empty inbound datagram (unparseable context-ID
  varint) no longer tears the tunnel down — drop-and-continue like the
  sibling context-ID!=0 / bad-payload cases.
- A3: snapshot the IP header before composeDatagram mutates TTL/checksum, so
  the ICMP 'packet too big' reply quotes the original datagram (RFC 1191/792).
- A2: drop the redundant second status check in ConnectTunnelH3 (2xx already
  validated in dialCONNECTIP); removes the unused responseInfo carrier.
- B3: reuse a per-Conn scratch for the outgoing datagram (contextID + packet)
  instead of allocating per packet — safe, quic-go copies the slice before
  SendDatagram returns and the tx pump is the sole writer.

Refs: SPEC 021 audit A1/A2/A3/B3
2026-07-02 02:37:53 +03:00
Leadaxe ac3d25b845 feat(masque): working h2 CONNECT-IP on WARP via manual x/net/http2 framer
h2 (network: h2) now works on live Cloudflare WARP (warp=on, http/2).

The high-level HTTP/2 clients can't drive WARP's CONNECT-IP: stdlib
http.Client.Do(CONNECT) uses classic tunnel semantics (400), and
x/net/http2's RoundTrip refuses because WARP never advertises
SETTINGS_ENABLE_CONNECT_PROTOCOL ("extended connect not supported by
peer") — the same RFC-noncompliance it shows on h3.

Drive the h2 connection manually with x/net/http2's public Framer + hpack
(both already deps): own client preface, SETTINGS, WINDOW_UPDATE, one
HEADERS frame, DATA frames carrying capsule DATAGRAM frames. This skips
the peer-settings gate. WARP h2 is a *plain* CONNECT (:method+:authority)
keyed off the cf-connect-proto header, NOT an extended CONNECT with
:protocol (that got PROTOCOL_ERROR). No http fork, no new dependency.

Also resolve domains before L3 dial was already in; this commit adds the
h2 framer, capsule-reassembly unit tests (across DATA-frame boundaries),
and updates SPEC/TEST_PLAN — risk #1 now closed.

Refs: SPEC 021 TEST_PLAN.md
2026-07-02 01:55:26 +03:00
Leadaxe bd5d1e513b fix(masque): resolve domains before L3 dial; device-verified h3 on WARP
The gVisor userspace stack operates at L3 and panicked ("As4 called on IP
zero value") when handed a domain destination. Resolve via DNSRouter before
dialing (as the WireGuard endpoint does): DialContext/ListenPacket now do
Lookup + N.DialSerial/ListenSerial for domain destinations, and reject
invalid non-domain destinations.

Live-tested against Cloudflare WARP with real registration key material:
- h3 (CONNECT-IP/QUIC): WORKS — cdn-cgi/trace returns warp=on, Cloudflare
  edge IP, clean connection teardown, tunnel reuse.
- h2 (CONNECT-IP/HTTP2): WARP responds 400 — stdlib net/http CONNECT
  semantics differ from WARP's expected extended-CONNECT authority/headers
  (SPEC risk #1, materialized). Deferred to phase 2. Documented in TEST_PLAN.

Refs: SPEC 021 TEST_PLAN.md
2026-07-02 01:04:43 +03:00
Leadaxe 0f41d00ac6 feat(masque): SPEC 021 — MASQUE CONNECT-IP outbound (Cloudflare WARP)
CONNECT-IP (RFC 9484) outbound tunnelling whole IP packets over HTTP/3 and
HTTP/2, targeting Cloudflare WARP. One `type: masque` outbound with a
`profile` field (cloudflare default | standard) and `network` (h3 | h2).

- transport/masque/connectip: vendored connect-ip-go (client subset) ported
  onto sagernet/quic-go — no second quic-go, no external dependency.
- transport/masque: cloudflare/standard profiles (ECDSA pubkey pinning), h3
  ConnectTunnel (Extended CONNECT cf-connect-ip + advertiseDefaultRoute), and
  h2 capsule-DATAGRAM over stdlib net/http (no http fork needed).
- protocol/masque: adapter.Outbound reusing transport/wireguard gVisor
  stackDevice via NewDevice(System:false); lazy tunnel + two IP pumps;
  DialContext/ListenPacket/Close.
- constant/option/include wiring (with_quic + with_gvisor); graceful
  ErrGVisorNotIncluded without gvisor.

Key material (ECDSA priv/pub, ip/ipv6) is taken ready from config — WARP
device registration is done client-side (Dart), out of core scope.

Unit-tested (profiles, TLS pinning, EC key round-trip, capsule round-trip,
IPv4 checksum vector, prefix parsing, config decode via registry). NOT yet
device-verified against live WARP (needs real key material).

Refs: SPEC 021, SagerNet/sing-box#4000
2026-07-02 00:24:00 +03:00
Leadaxe dbd219027c docs(lx-changelog): rc.20 — xhttp GET fallback, no-GRO experiment closed, upstream sync 2026-07-01 17:55:09 +03:00
Leadaxe b545cc2717 Merge upstream/testing: udpnat2 buffer fix + apple version script
Trivial upstream sync (2 commits): "Fix udpnat2 buffer size" (go.mod/go.sum bump)
and "release: Fix update apple version script". No lx zones touched (no submodule /
pb.go / box.go / route / adapter changes).
2026-07-01 17:52:50 +03:00
Leadaxe 2c98e1370b docs(spec-020): finalize no-GRO device report — both runs, CLOSED 2026-07-01 17:49:13 +03:00
Leadaxe 191ddc9f4c docs(spec-020): record no-GRO experiment result — rejected
The "GRO off + batch 8" idea (a global alternative to Down/Up) was measured on-device
and REJECTED, for three independent reasons (SPEC.md §14):

1. Wrong holder — the main android RAM holder is device.pool.messageBuffers
   (PreallocatedBuffersPerPool=4096 × ~64KB ≈ 100MB), which does NOT depend on
   BatchSize; the batch-sized bufsArrs held only ~14MB. Shrinking batch wouldn't
   have touched the ~100MB.
2. Not deliverable — the LX_WG_NO_GRO env switch never reaches Go's os.Getenv on
   Android (wrap.<pkg> prop shows in /proc/environ but not in the runtime's env
   snapshot), forcing a hardcode.
3. Fragile — hardcoded batch=8 crashed at start (SIGABRT): device.BatchSize()=
   max(bind,tun) clamped back to 128 via the TUN offload while msgsPool was 8, so
   Send sliced out of range. Coherent only by also gating TUN offload across three
   submodule layers.

Down/Up (rc.19) stays the only viable mechanism. Brings the experiment folder
(protocol + device heap snapshots + RESULT) into lx-1.14 for the record; the
experiment CODE stays on the lx-1.14-nogro-* branches, not merged.
2026-07-01 17:43:51 +03:00
Leadaxe 28746ab25c docs(spec-002): uplink_http_method GET outside packet-up is soft-fallback, not error
Sync SPEC 002 with the code (commit c0bbb1c5): GET on a non-packet-up node no
longer hard-errors — it falls back to POST + WARN so one bad subscription node
doesn't fail the whole config. Updated the mode-gate wording in SPEC.md §verif,
PARAM_MAP.md (full rationale + the old error text it replaces), URL_PARSING.md
table, and IMPLEMENTATION_REPORT.md. header/cookie uplink outside packet-up
stays a hard error (no safe default).
2026-07-01 15:41:26 +03:00
Leadaxe c0bbb1c550 fix(xhttp): uplink_http_method=GET outside packet-up falls back to POST, not fatal
A subscription node sometimes ships uplink_http_method=GET on a non-packet-up
node (auto/stream-up/stream-one). GET can only carry the uplink in packet-up
(other modes put the body in the request, which GET has none of), so the strict
check rejected it — failing the ENTIRE config over one bad outbound in a large
subscription (observed: initialize outbound[361] ... can be GET only in
packet-up mode → whole tunnel won't start).

Fall back to POST (the safe default that works in every mode) and log a WARN
instead of erroring, so the rest of the config still loads. POST is what the
node should have used; the fallback just makes one malformed remote node
self-healing rather than fatal. Kept strict for packet-up (GET honoured there).

Uses log.StdLogger() for the warning (the client transport layer gets no
logger in its constructor signature; threading one through would touch upstream
signatures). Test: TestUplinkGetFallsBackToPostOutsidePacketUp; removed the GET
case from TestValidationRejections. Verified on a real binary — check on a
stream-one+GET config now warns and passes (exit 0) instead of FATAL.
2026-07-01 15:34:59 +03:00
世界 b3c1634d08 Fix udpnat2 buffer size 2026-07-01 19:30:49 +08:00
Leadaxe cb6f7f44fe fix(lx-release): pin gh release create to this repo with --repo
The base-version step (c4fd73cd) adds an `upstream` remote (SagerNet/sing-box)
so git-describe can see the v1.14.0-alpha.* tags. But `gh release create` without
--repo resolves the target repo from the remotes and picked `upstream` →
HTTP 403 "Resource not accessible by integration" against
api.github.com/repos/SagerNet/sing-box/releases (the token has no rights there).
This is why rc.19's builds all succeeded but publish failed, while rc.18 (before
the upstream remote existed) published fine.

Pin --repo "${{ github.repository }}" so publish always targets this fork
regardless of what remotes the earlier steps added.
2026-07-01 12:55:27 +03:00
Leadaxe b1e3ecfcd4 docs(spec-020): rc.19 changelog + on-device before/after baseline
rc.19 gates idle-suspend behind with_lx_idle_suspend (mobile-only) and records the
on-device Android verification: suspending 8 idle+unreachable WG endpoints freed
134MB of bufsArrs live heap (223.9→89.9MB, recv-workers 18→2), matching the
~8.4MB/worker model — ~10x the desktop delta, on the platform the feature targets.

Adds ANDROID_RESEARCH/live-baseline/ — a full pprof snapshot of a real production
config with the feature OFF (263MB bufsArrs, 56% CPU on GC at idle) and its ON
"after" counterpart, closing the RESEARCH.md device gap end-to-end (buffer pool
and GC cost measured together, not inferred). Credentials scrubbed.
2026-07-01 11:38:49 +03:00
Leadaxe 1cbd3deb4f feat(spec-020): gate idle-suspend behind with_lx_idle_suspend (mobile-only)
Idle-suspend frees the recv-worker bufsArrs, which are ~8MB each only where
BatchSize=128 (Android/Linux) — on desktop BatchSize is small and the feature
saves almost nothing. Make that platform scope explicit in the build instead of
running the tick everywhere.

The idle-suspend tick now compiles only with the new `with_lx_idle_suspend` tag,
baked into the mobile AAR (build_libbox sharedTags) but NOT the desktop LX_TAGS.
Without the tag, a config that sets route.lx_idle_suspend fails fast at start
("rebuild with -tags with_lx_idle_suspend (mobile-only feature)") rather than a
silent no-op. The gate is a single function: reachability_lx.go carries the tick
under the tag, idle_suspend_stub_lx.go is the no-tag stub that errors, and
reachability_common_lx.go keeps InvalidateReachability (needed by the group
interface in every build). The dial hot path (resumeOnDial/stampActivity) and the
upstream group files are untouched — without the tick, idleAsleep is never set, so
resumeOnDial always takes its fast path.

Adds stub unit tests (option set → error, unset → no-op). Both build variants and
the full route/wireguard/group suites are green; gofmt/vet clean; desktop lx-check
passes without the tag. Docs (lx-config.md + ru, SPEC.md §3/§10) describe the tag.
2026-07-01 11:38:36 +03:00
Leadaxe c4fd73cdce ci(lx-release): fetch upstream alpha tags so git-describe base works in CI
The base-version derivation used `git describe --match v1.14.0-alpha.*` as the
primary source, but actions/checkout only fetches THIS repo's tags — the alpha
tags are SagerNet/sing-box (upstream) tags, absent in the CI clone. So `git
describe` found nothing and silently fell to the subject-grep fallback, which
resolves alpha.36 (alpha.37 was merged in a commit whose subject omits the
number). That's why rc.17/rc.18 notes shipped "base alpha.36" while a local
clone with upstream tags gets 37.

Fetch just the upstream v1.14.0-alpha.* tags before git describe so the primary
graph-based path works in CI. Both hand-fixed on the published releases; this
makes the next tag correct automatically.
2026-07-01 10:18:32 +03:00
Leadaxe 3ae3bbf203 docs(spec-020): ANDROID_RESEARCH — on-device idle-suspend report + artifacts
Full write-up of the Android device run (CPH2411, Android 15, rc.18) in a
dedicated ANDROID_RESEARCH/ subfolder: README (report), METHOD (reproducible
procedure), RESULTS (per-scenario + heap A/B), and artifacts/ (raw evidence:
lx idle log lines, goroutine dumps, pprof heap .pb + top renders). No access
credentials anywhere.

Headline, now measured on the target platform: PopulatePools.func3 (the
bufsArrs holder from RESEARCH.md) inuse_space 223.93 -> 89.89 MB (-134 MB /
-60%), recv-workers 18 -> 2, on suspending 8 of 9 WG endpoints. = 16 workers x
~8.4 MB (BatchSize=128), matching the source model, ~10x the desktop RSS delta.

RESEARCH.md status + SPEC.md §12/§13 updated: the Android heap A/B gap is
closed (only the battery A/B remains deferred).
2026-07-01 09:22:30 +03:00
Leadaxe 23193f5408 docs(spec-020): Android device RESULTS — heap A/B closes RESEARCH gap
Device run on CPH2411 (Android 15, rc.18) via the LxBox app Debug API.
9 WG endpoints (1 real WARP reachable + 8 synthetic unreachable),
lx_idle_suspend=30s. All behaviors confirmed on-device: suspend fires,
reachable final stays up, wake-by-dial, no-flap, kill-switch.

Headline: PopulatePools.func3 (the bufsArrs holder from RESEARCH.md)
inuse_space 223.93 to 89.89 MB (-134 MB / -60%), recv-workers 18 to 2.
= 16 freed workers x ~8.4 MB (BatchSize=128), matching the model, ~10x
the desktop RSS delta. Closes the Android device-verification gap.
2026-07-01 04:08:01 +03:00
Leadaxe 702f4dc0d4 docs(lx-changelog): rc.18 — SPEC 020 idle-suspend for WG/AWG endpoints
Device-verified idle-suspend: idle AND unreachable WG/AWG endpoints go Down to
free their recv-worker bufsArrs (the Android GC-heat holder), waking on next dial.
Opt-in via route.lx_idle_suspend; off by default. Notes cover the reachability
walk, the GRO reason for Down-over-smaller-batch, the concurrency fixes, and the
2026-07-01 live-run results (recv-workers 16→0, RSS −31%).
2026-07-01 03:16:16 +03:00
Leadaxe 937fc7f498 Merge SPEC 020: idle-suspend for WG/AWG endpoints (Down/Up)
Selectively brings idle + unreachable WireGuard/AmneziaWG endpoints Down,
freeing their recv-worker bufsArrs (the measured GC-scan heat holder on
Android) and stopping their per-peer timers (battery), then wakes them lazily
on the next dial. Off by default (lx_idle_suspend absent/0 = zero overhead).

Includes the fix for the shipped tick iterating the wrong manager (it never
reached any endpoint — the feature was inert on a live box), the full
reachability walk (final/rule/selector Now/urltest pool/detour, event-driven
cached), and the rewritten as-built spec (SPEC.md) + research doc (RESEARCH.md)
+ test plan.

Live-verified: suspend/wake/probe-wake/re-sleep/no-flap/kill-switch across
selector, urltest pool, nested groups, AWG-guard, and the real production
config; resource A/B recv-workers 16->0, RSS -31% on desktop. 29 unit tests,
adversarially checked.
2026-07-01 03:13:28 +03:00
Leadaxe d70d0d26b2 docs(spec-020): rewrite as-built spec, split research from implementation
The idle-suspend feature is implemented, the tick bug is fixed, and every
reachability node type plus suspend/wake/probe/no-flap/kill-switch and the
resource A/B (recv-workers 16→0, RSS -31%) are live-verified. Reflect that in
the docs and give the folder clean roles:

- SPEC.md (was SPEC_idle_suspend_lever.md): rewritten from scratch in Russian
  as the as-built implementation spec — Down/Up model, reachability walk +
  event-driven cache, endpoint-side suspend/wake, the tick bug and its fix
  (§11), full test coverage (§12, 29 units named), and what is deliberately
  deferred (§13: Tier B netstack teardown, keys-safe BindUpdate path, on-device
  battery/heap measurement). Old Tier-A "light sleep" design (never shipped)
  removed.
- RESEARCH.md (was SPEC.md): the diagnostic root-cause doc keeps its unique
  on-device heap A/B proof (holder = recv-worker bufsArrs) — renamed so its
  role (research, not implementation spec) is unambiguous.
- TEST_PLAN_idle_suspend.md: all pass criteria checked, §RESULTS + edge-case
  matrix + wake-latency series (cold ~50ms / warm ~36ms, +14-21ms ≈ 1 handshake;
  far-server caveat) + production-config run.

Cross-references and section numbers updated across all three files.
2026-07-01 03:08:23 +03:00
Leadaxe 66116ac089 fix(spec-020): idle tick must iterate the endpoint manager
The shipped idle-suspend tick (c55cf11e) iterated r.outbound.Outbounds(),
which never lists WG/AWG endpoints — they live in the endpoint manager.
outbound.Manager.Outbounds() returns only m.outbounds; the endpoint
fallback exists for Outbound(tag) lookups, not the iteration. So the tick
never reached a single IdleSuspendable and the feature was inert on a live
box (0 suspends over minutes idle), despite green unit tests that exercised
the walk and the per-endpoint decision only in isolation.

Fix: Router pulls adapter.EndpointManager from ctx (service.FromContext, no
box.go change — it is already registered there) and the tick body moves into
suspendIdleEndpoints(), which scans both r.endpoint.Endpoints() (where the
IdleSuspendables actually are) and r.outbound.Outbounds() (kept for a future
non-endpoint IdleSuspendable). Nil-guarded for the stub case.

Tests: new route/idle_tick_endpoints_lx_test.go drives the tick through a
stub endpoint manager — fails pre-fix (wg-1=0 wg-2=0, tick blind to
endpoints), passes after. Adds reachability walk tests for the production
topology this fix enables (nested selector→urltest pool, dual-path dedup,
dormant nested subtree) and the AWG-guard idle invariant. All adversarially
checked. See SPECS/020-MULTI_WG_IDLE_BUFFER_HEAT/SPEC.md §11.
2026-07-01 03:08:09 +03:00
Leadaxe c341d1286c docs(spec-020): reject lever 1 (smaller BatchSize) — breaks GRO; PRIMARY = Down (lever 3)
Code recon of the wireguard-go submodule disproved SPEC.md's original PRIMARY
lever (shrink StdNetBind.BatchSize() 128→8). It cannot be done without breaking
GRO receive:
- GRO-rx is ENABLED on android (UDP_GRO set with no android gate,
  controlfns_linux.go:90-104; SPEC 010 gated only GSO-tx, not GRO-rx) → rxOffload=true.
- The GRO path splits one coalesced packet into up to 64 datagrams; readAt =
  len(msgs) - IdealBatchSize/udpSegmentMaxDatagrams (bind_std.go:269) HARDCODES
  IdealBatchSize=128, and getMessages() allocs a 128-slot array. Shrinking bufsArrs
  to 8 either desyncs bufs(8) vs array(128) → OOB panic, or overflows the split
  ("splitting coalesced packet resulted in overflow", bind_std.go:565). GRO can't
  be disabled (needed for download throughput, §010).

So the old claim "packet loss excluded, array just shorter" was wrong for the GRO
path. Lever 1 (and lever 2, which inherits the same idle-socket GRO problem) are
rejected. PRIMARY becomes lever 3 — Down idle+unreachable devices: BindClose ends
the recv-workers and frees bufsArrs whole, while the active node keeps batch=128 so
its GRO is intact. The "most expensive fallback" is in fact the only viable lever.

Updated: status line, the lever-candidates section (struck lever 1, promoted lever 3),
the fix-logic section (renamed + rewritten around Down), verification, and residual
risks (the fast-channel risk is gone; the new risk is handshake-on-wake). Implemented
on lx-spec020-idle-suspend; see SPEC_idle_suspend_lever.md §13 + TEST_PLAN.
2026-07-01 00:50:59 +03:00
Leadaxe fc085bc838 docs(spec-020): live test plan for idle-suspend (Down/Up) + link from lever doc
Add TEST_PLAN_idle_suspend.md — build/config/commands/pass-criteria to
device-verify the shipped idle-suspend on a real run: suspend fires for
idle+unreachable WG/AWG endpoints, reachable ones never suspend, wake-on-dial,
the bufsArrs memory drop (pprof heap), and no flapping. Uses the user's
WARP/AWG + plain-WG nodes. Link it from SPEC_idle_suspend_lever.md §13.
2026-07-01 00:33:36 +03:00
Leadaxe 239c0515ad test(spec-020): unit tests for reachability walk, idle logic, event cache 2026-07-01 00:25:04 +03:00
Leadaxe ace08f7aca feat(spec-020): event-driven reachability cache (replace per-tick walk)
Reachability is now recomputed ONLY when the active routing tree changes, not
every idle tick. Per user direction: events decide WHO is reachable; the timer
only checks WHEN (last-activity comparison).

- adapter.ReachabilityInvalidator: narrow interface (not folded into the large
  adapter.Router), registered into ctx in box.go, pulled by groups via
  service.FromContext — no route<-group import.
- Router: reachMu/reachCache/reachDirty. InvalidateReachability() is a lock-free
  atomic store (safe under any group lock — no lock-order cycle). reachableOutbounds()
  recomputes the walk OUTSIDE the cache lock (the walk calls into groups that hold
  their own locks), clears dirty BEFORE the walk so a concurrent event re-dirties
  for next tick rather than being lost, publishes under RWMutex. Starts dirty so
  the first tick (and every reload = fresh Router) computes.
- 4 invalidation sources: selector switch (selector.go after selected.Store),
  legacy urltest auto-switch (urltest.go performUpdateCheck), and a balancer
  onChange hook fired from setSlots — one hook covers all pool-rebuild call sites.
- idle tick: now one cached-map lookup + atomic idle compare per endpoint, no walk.

Design independently verified against source (no data race, no import cycle, no
deadlock — walk runs outside the lock). Builds + go vet + race-build clean.
2026-06-30 22:16:49 +03:00
Leadaxe 4dd9b4ffc9 docs(spec-020): implementation log §13 — Down shipped, bind-swap/keys, 3 paths
Record the as-built decision so it is not re-derived:
- §13.1 what shipped (c55cf11e): idle-suspend via Down/Up, not light sleep
  (source-verified holder is recv-worker bufsArrs; timersStop does not free it).
- §13.2 bind-swap investigation (the promised comment): BindUpdate resizes the
  bind WITHOUT zeroing keys; key-zeroing lives only in Down/peer.Stop. So a
  keys-safe wake is possible only while still Up — the shipped Down path can't.
- §13.3 the three reduced-bind paths (B=Down shipped / A=BindUpdate keys-safe /
  Hybrid) with the GRO-off + max(bind,tun) gotchas.
- §13.4 recommendation: ship B, escalate to A/Hybrid only if device INFO logs
  show handshake flapping hurts. Reduced-bind urltest wake deferred (low value
  on path B since keys are already zeroed).
2026-06-30 21:53:46 +03:00
Leadaxe c55cf11efa feat(spec-020): idle WG/AWG endpoint suspend via Down/Up
Selectively bring Down any WG/AWG endpoint that is idle past a threshold AND
unreachable from the active routing tree — freeing its recv-worker bufsArrs
(the dominant per-endpoint GC-scan holder), cutting the multi-WG heat. The next
dial through the endpoint wakes it (device.Up); wake pays a fresh handshake.

- option: route.lx_idle_suspend (Duration, 0/absent = off, kill-switch).
- route/reachability_lx.go: ReachableOutbounds walk — seeds = final + rule
  outbounds, descend via selector Now(), urltest active pool (ActiveTags), and
  static detour deps. Fresh walk per tick (no gen-cache: graph is tiny, tick is
  ~XX/2; a cache would need upstream-body invalidation hooks — not worth it yet).
- protocol/wireguard/endpoint.go: lastActivity/IdleSince, SuspendIfIdle (Down on
  live->asleep CAS), resumeOnDial (stamp + lazy Up on dial). idleAsleep is kept
  distinct from started so a guard-suspended endpoint is never idle-woken.
- transport/wireguard/endpoint.go: Resume() = device.Up() alongside Suspend().
- adapter: IdleSuspendable interface so the router tick iterates endpoints
  without importing protocol/wireguard.
- route/router.go: idle tick (period max(XX/2, 5s)) started in PostStart,
  stopped in Close.
- INFO log on each state transition only (edge-triggered): suspend / wake.
- group: URLTest.ActiveTags() exposes the whole active pool to the walk.

Builds clean, go vet clean. Reduced-bind urltest wake + bind-swap/keys
investigation land next.
2026-06-30 21:21:53 +03:00
Leadaxe dcc964dd9d docs(lx-config): add full Russian translation (lx-config.ru.md)
Complete RU translation of docs-lx/lx-config.md, mirroring its structure:
the §0 exhaustive "every field at a glance" example, all per-section field
tables (XHTTP v1+v2, AmneziaWG 2.0, id/ip/ib masquerade, urltest balancer),
examples and the build section. JSONC code is preserved; only prose and
inline comments are translated. Intra-doc #anchors are re-pointed to the
Russian heading slugs (all 6 verified to resolve); the §0 example validates
as JSON.

Cross-link both ways (en ↔ ru) and re-point README.ru.md's four lx-config
links to the Russian version. File name follows the README.ru.md convention
(.ru.md, not -ru.md).
2026-06-30 20:19:52 +03:00
Leadaxe 0134fcfc45 docs(lx-config): exhaustive "every field at a glance" example + fill XHTTP v2 fields
Add a §0 kitchen-sink config carrying ALL 52 lx-added fields in one place —
XHTTP transport (26), AmneziaWG 2.0 endpoint incl. id/ip/ib (21), urltest
round_robin balancer (5) — each with its default and allowed values inline,
and mutually-exclusive / server-ignored fields flagged. Sourced by reading
option/*.go directly (not the prior doc), so it is complete.

This also surfaced that §1 documented only 7 of the 26 XHTTP fields (the v1
set); fill in the 19 missing v2 fields (session/seq placement, uplink-data
placement, X-Padding obfs family, packet-up tuning, accepted-but-ignored)
as grouped tables. Fix three code-vs-doc disagreements the extraction found:
- `mode: auto` resolves to stream-one on Reality (not always packet-up);
- s3/s4 are AWG 2.0 junk-size params (not "cookie-reply/transport" junk);
- h1-h4 unset spelling includes "" as well as 0; x_padding_bytes framing.

The default wire shape is unchanged; all v2 fields are opt-in. §0 example
validated as JSON. Russian translation (lx-config.ru.md) to follow.
2026-06-30 20:12:39 +03:00
Leadaxe 38bda198ff docs: move lx docs out of upstream docs/ into docs-lx/
docs/ is an upstream-owned tree (it arrives wholesale from SagerNet on
every rebase). Our three downstream docs lived inside it — lx-config.md,
lx-changelog.md, lx-release-runbook.md — mixing fork files into the
upstream surface against CONSTITUTION principle #1 (thin layer / minimal
diff). Move them to a dedicated root-level docs-lx/ so the boundary
between our docs and upstream's is explicit.

- git mv preserves history.
- Updated every reference (docs/lx-* -> docs-lx/lx-*): README.md/.ru.md,
  SPECS/{003,004,005,009,020,README}, transport/wireguard/endpoint.go
  comments, lx-ci.yml, and lx-release.yml (the release-notes extractor +
  fallback URL now read docs-lx/lx-changelog.md).
- Fixed the now-relative links inside the moved files that pointed at
  upstream docs/ siblings: lx-config.md -> ../docs/configuration/outbound/
  urltest.md; lx-changelog.md -> ../docs/changelog.md (x2).

Verified: all relative + external links resolve, both workflows are valid
YAML, the release-notes awk path is docs-lx/, go vet clean on the touched
package. No release feature — folds into the next tag naturally.
2026-06-30 18:55:26 +03:00
Leadaxe ac94dcd4b6 docs(spec-020): add companion design for lever 3 (idle WG light-suspend)
Secondary design doc alongside the authoritative SPEC.md, NOT a replacement.
SPEC.md has on-device proof (heap A/B) that the GC-scan holder is bufsArrs of
recv-workers and its primary lever is shrinking StdNetBind.BatchSize() — that
stays authoritative.

This companion works out SPEC.md's 'lever 3' (suspend inactive devices) in
detail: a light variant (per-peer timersStop, keypairs/socket kept live, cheap
wake without handshake) plus a reachability walk (final + rules + active
selector/pool choices, generation-cached) that decides which devices are idle
AND unreachable from the active routing tree.

Banner up top flags where this doc's source-reading diverged from SPEC.md's
measurements (it guessed gvisor netstack; SPEC.md measured bufsArrs) and notes
light-suspend does NOT free bufsArrs — only Down/BatchSize does. Use as the
fallback design for lever 3, not a competing primary.

Spec only — no code changes.
2026-06-30 17:58:41 +03:00
Leadaxe e7b0bcdc75 ci(lx-release): derive base from git graph, not merge-subject text
The subject-grep base-detection missed alpha.37: it was merged in a commit
titled "Merge upstream/testing (bump version, fix linux ping)" with no
"alpha.37" in the subject (upstream tagged it after we merged), so the grep
found only alpha.36 and rc.17 notes shipped a stale base.

Make `git describe --match v1.14.0-alpha.*` the primary source — it reads
HEAD's ancestry in the commit graph, independent of merge-message wording —
and keep the subject-grep as the fallback for a fork checkout without
upstream tags. Verified locally: now resolves v1.14.0-alpha.37.
2026-06-30 13:38:04 +03:00
Leadaxe c35ccd0ea7 fix(build): restore with_clash_api on desktop/CLI — drop is AAR-only
SPEC 014 dropped with_clash_api because LxBox (Android) drives the core
over the native libbox CommandClient, making the Clash REST server dead
weight in the AAR. But the drop landed in the shared Makefile.lx LX_TAGS,
which also feeds every desktop/CLI release build (mac/windows/linux-musl
via `make -s lx-print-tags`). A CLI binary has no CommandClient channel —
it is managed by external dashboards (yacd/MetaCubeXD) over the Clash REST
API — so every desktop release since rc.1 shipped with no way to manage
the core; a config with experimental.clash_api failed fast. CI stayed
green (lx-ci BASE_TAGS kept the tag), so it was invisible in CI.

Restore with_clash_api to the desktop LX_TAGS; leave build_libbox (AAR)
unchanged. The two tag sets now diverge by design: desktop = with Clash
API, AAR = without.

Verified: desktop binary builds with with_clash_api in Tags; `check`
accepts an experimental.clash_api config; the Clash REST server comes up
live (endpoints answer 401 security-middleware, not the stub's fail-fast).

Docs: Makefile.lx comment, SPEC 014 (§2/§3.1 scoped to AAR + new §3.4),
lx-release.yml tag comment + notes line, changelog rc.17.
2026-06-30 13:23:53 +03:00
Leadaxe 51b3bd07a8 ci(lx-release): derive upstream base version for notes, stop hardcoding alpha.35
The release-notes template hardcoded "base v1.14.0-alpha.35"; it went stale and
had to be hand-edited on rc.14, rc.15 and rc.16 (each was actually on alpha.36).
Resolve the base dynamically in the "Resolve tag" step: take the highest alpha.NN
named in any "Merge upstream" commit subject (robust on a fork without upstream
tags fetched), falling back to git describe against upstream alpha tags, then a
generic v1.14.x label. The notes line now interpolates steps.ver.outputs.base.
2026-06-30 11:04:22 +03:00
Leadaxe e0f8d6d9bb docs(xhttp): add golden fixture to URL_PARSING for Android round-trip tests
§8: validated transport JSON with all 14 new fields at non-default values,
the equivalent flat-camelCase vless:// URL, and a defaults table for the
toUri() omitempty logic. Fixture verified with sing-box check; mirrors
lx-test/config/xhttp_obfs_full.json.
2026-06-30 02:15:37 +03:00
Leadaxe ab29eb1bb0 docs(lx-changelog): rc.16 — SPEC 002 v2 full XHTTP param support 2026-06-30 01:59:57 +03:00
Leadaxe 6dc83fc109 Merge upstream/testing (bump version, fix linux ping) into lx-1.14 2026-06-30 01:57:50 +03:00
Leadaxe 8851eec001 merge: SPEC 002 v2 — full client-side XHTTP param support
Merge lx-1.14-xhttp-full: full client-side support of extended XHTTP params
(session/seq/uplink-data placement+keys, uplink method, X-Padding obfs incl
tokenish/HPACK), accept-but-ignore for server-only + legacy scMaxConcurrentPosts,
URL-parsing guide for Android/launcher teams.

Verified: 16 unit tests, sing-box check on 3 configs, build/vet/gofmt clean,
default path live-confirmed on 4 real XHTTP nodes (packet-up + stream-one/reality,
2026-06-30 01:19:56 +03:00
Leadaxe 1330837400 docs(xhttp): move URL parsing guide into SPEC 002 folder
Relocate docs/lx-xhttp-url-parsing.md -> SPECS/002-XHTTP_CLIENT_TRANSPORT/URL_PARSING.md
so all XHTTP docs live together with the spec. Fix internal/back links.
2026-06-30 01:19:27 +03:00
Leadaxe 61c9301d13 docs(xhttp): live-verify default path on 4 real nodes (stream-one TODO closed)
Scanned igareck/vpn-configs-for-russia, extracted+deduped 10 unique XHTTP
nodes, ran each through our with_xhttp binary. 4 alive — all downloaded 1MB,
traffic egressed via the server IP:
- 2x plain  -> packet-up
- 2x reality -> stream-one (hu99.bearbeer.digital, bez3.stream-room.com)

The two reality nodes resolve auto->stream-one and work live, closing the
open stream-one live-verification TODO from task 011 (previously synthetic-only).
Other 6 nodes dead for server-side reasons (504, HTTP/1.1-not-H2, reset,
TLS hang) — our transport errored cleanly in every case.

Remaining live TODO: obfs/placement modes (no public node is configured for them).
2026-06-30 00:42:16 +03:00
Leadaxe c3e54a8141 docs(xhttp): note HTTP/3 (alpn=h3) limitation in URL parsing guide
Our XHTTP client is HTTP/2 only (http2.Transport); Xray supports H1/H2/H3.
h3-only nodes won't connect — flag for the link parser. Out of SPEC 002 scope
(separate future 'XHTTP over HTTP/3' task).
2026-06-30 00:36:06 +03:00
Leadaxe 7527467965 feat(xhttp): accept sc_max_concurrent_posts (legacy, ignored)
scMaxConcurrentPosts is a removed Xray knob (grep + GitHub code search
total:0 in current XTLS/Xray-core and sing-box-extended). Current Xray
serializes to one upload POST body in flight at a time, which our sequential
packet-up Write already matches, so the field is accepted for config/link
symmetry but ignored by the client.

- option: V2RayXHTTPOptions.ScMaxConcurrentPosts (json sc_max_concurrent_posts)
- PARAM_MAP: document as legacy/ignore tier with the real concurrency mechanism
  (bounded pipe + WroteRequest serialization, server-side seq reorder)
- url-parsing doc: scMaxConcurrentPosts -> accept-but-ignore
- xhttp_obfs_full.json: include the field so check covers it

Verified: build/gofmt/vet clean, 16 unit tests pass, sing-box check passes
on all 3 xhttp configs incl the field.
2026-06-30 00:35:31 +03:00
Leadaxe 31926aebd4 docs(xhttp): vless:// XHTTP URL parsing guide for Android/launcher teams
Self-contained reference for the link parser: maps every vless://...type=xhttp
URL param (flat query + extra={...} JSON) to sing-box transport snake_case fields.
Covers TLS/Reality mapping, the extra-JSON number→"min-max" coercion, mode=auto
pass-through, path-with-query-tail, and ignored fields (scMaxConcurrentPosts,
server-only). Examples validated with sing-box check.
2026-06-29 15:39:08 +03:00
Leadaxe 33ee291b17 feat(xhttp): full client-side support of extended XHTTP params (SPEC 002 v2)
Implement all 12 client-relevant Xray/sing-box-extended XHTTP params on the
existing lean-native client (no Xray vendoring):

- session/seq placement (path|query|header|cookie) + keys
- uplink-data placement (body|auto|header|cookie, chunked base64) + key + chunk size
- uplink_http_method (upper-cased; GET only in packet-up)
- X-Padding obfs mode: placement (cookie|header|query|queryInHeader) + key/header +
  method repeat-x | tokenish (HPACK-Huffman-tuned via golang.org/x/net/http2/hpack)
- packet-up tuning: sc_max_each_post_bytes (split), sc_min_posts_interval_ms (throttle)

4 server-only fields (server_max_header_bytes/no_sse_header/sc_max_buffered_posts/
sc_stream_up_server_secs) accepted but ignored by the client.

New files: transport/v2rayxhttp/{meta.go,xpadding.go}, xhttp_test.go.
Range fields use the "min-max" string form (no badoption.Range in sing).
Default (non-obfs) wire shape kept byte-identical to the live-verified v1
(x_padding='0' in Referer, session/seq on path, payload in body).

Verified: 16/16 unit tests, sing-box check on 3 configs incl full obfs,
go vet/gofmt/build (tagged+untagged) clean, negative (no with_xhttp) rejects.
Adversarial wire-protocol review against PARAM_MAP found no bugs.
Live test of a non-default mode against an Xray server remains an open TODO.
2026-06-29 15:23:57 +03:00
Leadaxe cafbe54603 docs(SPEC 002): full XHTTP param spec + PARAM_MAP (audit of Xray-core + sing-box-extended)
- Rename minimal v1 spec to SPEC_v1.md (history kept)
- New SPEC.md: full client-side support of 12 client-relevant XHTTP params
  (session/seq/uplink-data placement+keys, uplink method, X-Padding obfs incl tokenish)
- PARAM_MAP.md: per-param map of all 16 NekoBox+ fields (+2 client tuning extras),
  client-vs-server verdict, defaults, on-wire effect, impl notes
- 4 server-only fields (serverMaxHeaderBytes/noSSEHeader/scMaxBufferedPosts/
  scStreamUpServerSecs) documented as accept-but-ignore
- Verification corrections folded in (no Access-Control-Allow-Credentials;
  mode-gates + method upper-case live in extended option layer only)
2026-06-29 15:02:47 +03:00
世界 ecaa397834 release: Fix update apple version script 2026-06-29 15:39:14 +08:00
世界 b2d51f6ec3 Bump version 2026-06-29 11:43:26 +08:00
世界 3d734792ca Fix linux ping 2026-06-29 11:43:26 +08:00
Leadaxe a26f2a6c3f docs: mark round_robin (SPEC 019) device-verified end to end
The rc.15 domain fix was confirmed on a real device: with the default
sticky_hash ["process","domain"] and no dest_ip workaround, browser traffic
spreads across the pool (on-device per-domain uniformity ~0.27 -> 0.95+).
Update README + lx-config.md status from "not yet device-verified" to
device-verified.
2026-06-29 02:43:41 +03:00
Leadaxe 6ad8ec91fb docs(SPEC 020): add fix logic — single-point StdNetBind.BatchSize() shrink on android
PRIMARY lever spelled out: bufsArrs size = bind.BatchSize() (128 on StdNetBind/android);
shrink it at one point (conn/bind_std.go:322, android branch -> 8/16). BindUpdate
(device.go:558) and getMessages() (bind_std.go:260) follow automatically, both recv
goroutines (v4+v6) covered, no packet loss (array stays full, just shorter), MaxSegmentSize
untouched (GRO intact). Effect 8MB->~0.5MB/recv = 176MB->~11MB at 11 devices. Only risk:
gigabit channel may lose throughput -> fall back to dynamic batch.
2026-06-29 02:43:09 +03:00
Leadaxe 3ef5717890 docs(SPEC 020): throughput measured — batch not the limit on mobile/WARP, primary lever = global smaller BatchSize
On-device throughput A/B (static arm64 curl, download via tunnel):
- baseline batch=128 = 10.7 MB/s median (~86 Mbps), stable 9.1-11.2.
- CPU under load: Syscall6 36%, scanobject 6%, crypto ~3%. The bottleneck is the
  WARP channel + syscall overhead, NOT batch processing. GRO/batch only matters at
  hundreds-of-Mbps/gigabit, so shrinking batch does NOT cost throughput on a typical
  mobile/WARP channel (where the heat is reported).

Re-ranked the levers: PRIMARY is now the global smaller StdNetBind.BatchSize()
(128->8-16) — one point, no activity detection, cuts bufsArrs 8MB->~1MB/recv (176MB->
~11-22MB at 11 devices). Dynamic-batch and Down-idle drop to secondary. Caveat: verify
on a fast Wi-Fi/gigabit channel before release (batch may matter there). Also confirmed
heap scales linearly with live device count (11->269MB, 4->104MB, 1->0). No code changed.
2026-06-29 02:25:55 +03:00
Leadaxe b28c689cb9 docs: document observability (SPEC 014-018) + round_robin (SPEC 019) across lx docs
The lx feature docs had drifted: README (en/ru) and docs/lx-config.md still said
"currently XHTTP + AWG2" and covered only SPEC 002/003/009 — the observability
layer (SPEC 014-018) and round_robin load balancing (SPEC 019) were undocumented
in the lx overview, and urltest.md still described the pre-rc.15 domain behaviour.

- docs/lx-config.md: new "## 3. round_robin load balancing" (mode/balancer,
  pool/pool_tolerance/sticky_hash, ["none"] sentinel + badjson-[] caveat, slot-hash
  binding, example, status) and "## 4. Observability (CommandClient extensions)"
  (URLTestOutbound/GetRules/GetGroups/GetOutbounds/GetPool/SubscribeDNSQueries +
  Connection.detourList, all behind with_lx_command); Validate&build -> ## 5.
- README.md / README.ru.md: broaden the stale "XHTTP + AWG2" framing; add feature
  rows for observability and round_robin with honest status.
- docs/configuration/outbound/urltest.md: reconcile sticky_hash "domain" with the
  rc.15 fix — domain reads metadata.Domain (survives domain->IP resolve), so it
  works for normal sniffed domain traffic, not only literal-IP destinations; the
  warning is reframed (domain works; dest_ip is an alternative).

Docs-only; no code change.
2026-06-29 02:23:49 +03:00
Leadaxe 3ceca12b98 docs(SPEC 020): holder PROVEN on repro — bufsArrs of live recv-workers, not channels
On-device A/B (Debug API /diag/pprof) settles it with high confidence:
- RoutineReceiveIncoming holds 180MB (61% cum); peek = 100% via sync.Pool.Get (in
  worker hands, not the pool, not the channels).
- 11 live wireguard endpoints, 22 RoutineReceiveIncoming ALL on StdNetBind (batch=128),
  0 on ClientBind. 22 x bufsArrs[128] x 64KB = 176MB ~= 180MB.
- batch=128 because WARP/AWG endpoints use a WireGuardListener dialer => StdNetBind
  (endpoint.go:200-202), whose BatchSize()=128 on android.
- A/B: switching the active node WARP->home does NOT free memory (buffers do not sleep);
  config 11 ep -> 1 ep gives 269MB -> 0 (= Iliya's workaround, reproduced via profile).

Both prior diagnoses were wrong: batch=1 (no, StdNetBind=128) and drain-on-Suspend of
channels/sync.Pool (misses; bufsArrs of live recv-workers holds it). MaxSegmentSize
2200->65535 is the volume trigger (x30 bytes), not the holder (downLocked/pools/channels/
batch identical 1.13<->1.14).

Fix = shrink batch for INACTIVE devices (naive lazy-bufsArrs impossible: StdNetBind
getMessages() is a fixed 128). Levers + a required download-throughput measurement
documented; pending lever choice.
2026-06-29 02:07:46 +03:00
Leadaxe 0d19fe2c66 docs(SPEC 020): rewrite — corrected heap holder (sync.Pool+channels, not bufsArrs/128), drain-on-Suspend fix
On the client path BatchSize()=1 (client_bind/stackDevice/systemDevice all return 1),
so the old bufsArrs=128 => 8MB/device claim is wrong; bufsArrs is ~64KB. The 224MB
pprof attributes to PopulatePools is the sync.Pool.New alloc SITE, not the holder.
Real holders: (A) device.pool messageBuffers sync.Pool local+victim cache, (B) the 3
buffered device channels. scanobject 52% comes from the pointer-dense element/container
wrappers (4 of 5 WaitPools are scan-type), not the noscan [65535]byte arrays.

Fix rewritten to drain-on-Suspend: park RoutineReadFromTUN via a stackDevice.suspended
seam in Read (the lockless pool writer surviving Down), drain the 3 device channels
after Down, then ONE runtime.GC()+FreeOSMemory() per selector transition. PopulatePools
swap rejected (WaitPool.count underflow -> cond.Wait deadlock). slim-batch and the
route-graph refcount are dropped (not needed for heat). Added in-repo verification
(device/suspenddrain_test.go + HeapInuse bench) since on-device A/B is impossible.
2026-06-29 00:40:05 +03:00
Leadaxe 7a1e61e047 docs(SPEC 020): multi-WG idle-buffer heat — root cause + slim-device fix plan
Android 100% CPU / heat on configs with many WG/AWG endpoints in a
selector. Diagnosed via on-device pprof (§207): scan-bound GC over a
224MB live heap = wireguard-go Device.PopulatePools buffers, held by
~10 idle WG devices. Suspend()=Down() marks the device idle but does
NOT release pools/workers (only Close() does). A/B on device: dropping
spare WG endpoints removes the heat.

SPEC 020: SLIM idle devices (shrink maxBatchSize 128->1-4, do not Close
— keepalives + shared-node safety) gated by a route-reachability
refcount (rules + final + Now, not just selector).

SPEC 010: note our GRO split-brain patch is now upstream-native on
v0.0.3 (commit 24ea133); MaxSegmentSize=65535 must stay (GRO fuel) —
heat is fixed by device count/slimming, never by shrinking the buffer.
2026-06-28 23:39:22 +03:00
Leadaxe a531879e02 fix(SPEC 019 v2): three sticky/pool bugs found by device verification
Device verification of round_robin on a real 51-node pool surfaced three bugs,
all fixed here. Listed by impact.

1. sticky key 'domain' was always empty -> all traffic collapsed to one node.
   The router resolves a domain destination to an IP and overwrites
   metadata.Destination before a group's DialContext runs, so destination.Fqdn
   is empty when the balancer builds the key. stickyComponent("domain") read
   that empty Fqdn, so a single process's key was process+NUL for every site
   -> one fixed slot. On device this measured 28/1/1 across a 3-node pool
   (uniformity 0.27). Fix: read metadata.Domain (survives the resolve), fall
   back to destination.Fqdn only for a direct dial. After: spread 0.95+.

2. living pool nodes could change slot index during a health-check, moving
   sticky keys. balancePoolFirstLive compacted with a filtering append (a
   transiently-dead slot shifted every later live node left); planTolerantPool
   did delete(inPool, occupant) (an evicted-but-living node re-entered a later
   slot, cascading); manual URLTest rebuild ran the tolerant planner even at
   pool_tolerance==0. All now replace-in-slot (fixed-length copy(current), only
   dead/empty slots rewritten by index; dedicated planFirstLivePool for the
   tolerance==0 rebuild).

3. stickiness could not be disabled via sticky_hash: [] -- the config decoder
   (badjson.UnmarshallExcludedContext) re-marshals the struct and collapses an
   empty array to nil, indistinguishable from omitted, so the default always
   applied. Disabling now uses the explicit sentinel sticky_hash: ["none"].

Tests: domain-from-metadata + fallback, replace-in-slot survivor/cascade/
first-live regressions (fail against pre-fix code), ["none"] disable + []
defaults + none-mixed error. All green under -race; gofmt clean.
2026-06-28 21:48:31 +03:00
Leadaxe ebf5c2d048 docs(SPEC 019): collapse to a single canonical SPEC.md (v2)
v2 superseded v1; keeping both as separate files (SPEC.md + SPEC_V2.md + the v1
TEST_REPORT) was just confusing. Delete the v1 SPEC and its TEST_REPORT (they remain in
git history) and rename SPEC_V2.md → SPEC.md as the one canonical doc. Drop the "v2"
suffix and stale "design not started" status from the header.
2026-06-28 18:42:21 +03:00
Leadaxe 50efd8f335 fix(SPEC 019 v2): balancer.pool 0 = default, not error (rc.14)
Desktop smoke-test of the rc.13 binary surfaced this: a Go int with omitempty can't tell
`pool: 0` from an omitted field, so `pool: 0` hit the `< 1` validation and rejected a
config that should have defaulted. Now pool 0/omitted → default 3; only a negative pool
errors. Added TestBalancerZeroPoolIsDefault; renamed the negative-pool test. SPEC_V2,
urltest.md, changelog rc.14 updated.

Verified on the rc.13 desktop binary: round_robin pool fill (pool_tolerance:0 tests only
pool-many nodes, >0 tests all), config fail-fast (balancer+least_test, unknown sticky_hash,
unknown mode, negative pool), and live routing through the group.
2026-06-28 18:11:55 +03:00
Leadaxe 08d1fecd5f Merge upstream/testing (dhcp dns, age report, go mod) into lx-1.14 2026-06-28 17:51:17 +03:00
Leadaxe 5997b1812a lx(1.14): SPEC 019 v2 — round_robin pool, lazy health-check, slot-hash sticky, GetPool
Reworks urltest round_robin to scale to large node lists. v1 rotated over ALL live nodes,
which meant URL-testing every node each interval (unworkable at 1000 nodes). v2:

- Fixed-size pool of slots (balancer.pool, default 3). Slot indices never move; a
  replacement takes the exact slot it evicts. round_robin rotates only within the pool.
- Lazy health-check: pool_tolerance=0 tests no more nodes than needed to keep the pool
  full of live nodes, then stops; pool_tolerance>0 tests all and keeps the fastest with a
  per-slot eviction threshold. Dead pool node keeps its slot until a live replacement is
  found (pool never empties). A dial error never changes the pool — only the health-check.
- sticky = slot-hash (slot[hash(key)%pool], FNV-64a). Binds to a fixed slot index, so a
  living node keeps ALL its keys when other slots churn: strict zero reconnects, zero
  per-key state. Default sticky_hash ["process","domain"]; explicit [] disables.
- Removes v1 jumphash (broke on mid-list eviction), ttl_map, and least_connection (dropped
  from the roadmap — round_robin is statistically even).
- GetPool RPC: CommandClient.GetPool(tag) -> []PoolSlot{slot,tag,delay} so clients can show
  the N nodes actually in rotation. delay clamped 0->1 for live nodes; non-round_robin
  group -> empty. Additive proto/daemon/libbox, behind with_lx_command.

Config moved under a `balancer` object (breaking for the rc.11/12 round_robin shape; no
prod configs, tests only). least_test (default) is byte-for-byte unchanged.

Tests: newBalancer validation/defaults, rotation distribution, slot-hash stable +
living-node-keeps-keys-across-other-slot-churn, empty-key fixed slot, planTolerantPool
top-N / keep-in-tolerance / evict-beyond / dead-slot-replace. go build (+with_lx_command),
go test -race ./protocol/group/, gofmt all clean. Not yet device-verified.
2026-06-28 17:50:43 +03:00
世界 b70df9668e Improve DHCP DNS server initialize
Avoid querying DHCP DNS servers on cellular networks
2026-06-28 16:51:41 +08:00
Leadaxe 6139a6e305 docs(SPEC 019): record variant B (cache fallback) as considered-and-rejected
Clarify the Now() cold-start tradeoff: variant B (write the fallback node straight
into selectedOutbound*) would eliminate the micro-gap entirely — Now() and DialContext
would read one field, so they can't diverge — at the cost of touching upstream's
selection logic (stub in the choice field + one extra Interrupt() on the first real
switch, which is no worse than any later latency switch). Variant A (Now() stays a
reader) was chosen purely for minimal upstream intrusion; its only cost is a negligible
micro-gap from two separate Select() calls racing on the first seconds. Documents the
path to B if the feature outgrows upstream's selectedOutbound* later.

Doc-only; rc.12 already shipped variant A, no retag.
2026-06-28 11:25:34 +03:00
Leadaxe a40f0b64e4 Merge upstream/testing (oom report logs) into lx-1.14 2026-06-28 11:00:33 +03:00
Leadaxe feab497fe4 lx(1.14): SPEC 019 Now() cold-start fallback to Select()
Before the first URL-test fills the delay history, urltest's selectedOutbound* is
nil but traffic already flows via the Select() fallback (first usable outbound).
Now() returned "" in that window, so the UI showed no server while connections were
live. Now() now falls through to Select(tcp)/Select(udp) and reports the exact node
the next DialContext will pick — same source of truth as the dial path, not a guess.

Only least_test (default) affected; round_robin/ttlmap already report the last-picked
tag (lastSelected) and are untouched. Added TestSelectColdStartFallback /
TestSelectColdStartNoOutbounds. SPEC + changelog rc.12. go build (+with_lx_command),
go test -race ./protocol/group/, gofmt all clean.
2026-06-28 11:00:17 +03:00
世界 53e87a7ea5 Add age support for report export 2026-06-28 14:17:41 +08:00
世界 440d1cfa0d Fix go mod format 2026-06-28 14:17:10 +08:00
世界 8b9238fea4 Write logs to oom report 2026-06-28 11:10:29 +08:00
Leadaxe 5954e50b58 docs(SPEC 019): rc.11 changelog — drop "not device-verified", note live run
The TEST_REPORT landed after the tag was cut, so the as-tagged notes still said
"not device-verified" and base alpha.35. Feature was live-verified on 5 vless nodes;
base is alpha.36 after the pre-rc merge. Published GitHub release notes edited to match.
2026-06-28 01:28:06 +03:00
Leadaxe 061b5f4cca docs(SPEC 019): add TEST_REPORT (live-verified) + dest_ip stickiness caveat
Live run on 5 vless nodes (3 instances, one per mode): round_robin rotates strictly
across the live set and skips dead nodes; both sticky strategies pin deterministically;
bad config is rejected at start; -race clean on units and live. Feature is now
device-verified, not just isolated.

The run surfaced a config caveat (not a bug, by design): dest_ip is empty until the
destination is resolved, so a sticky key of only source_ip/dest_ip/dest_port collapses
to "" for domain traffic and pins everything to one node. Documented in urltest.md —
use `domain` in `hash` for domain-based traffic.
2026-06-28 01:23:45 +03:00
Leadaxe ffa3b65427 lx(1.14): rename urltest_balance -> _lx suffix for CI gofmt coverage
The lx-ci gofmt-lint step only checks files matching the lx-owned glob
(_xhttp|_awg|_lx.go|_command_lx); the new SPEC 019 files fell outside it. Rename
to the _lx.go convention so CI gofmt-checks them, and fix the changelog reference.
No code change.
2026-06-28 01:15:58 +03:00
Leadaxe 645b8253cb Merge upstream/testing (v1.14.0-alpha.36) into lx-1.14
Pre-rc.11 sync. Upstream changes: darwin local DNS refactored to a raw
mDNSResponder call, iOS deb upload fix, version bump. No overlap with lx files
(protocol/group, option, constant untouched).
2026-06-28 01:13:25 +03:00
Leadaxe 5ebff914fc lx(1.14): SPEC 019 urltest mode + sticky load-balancing
Add a `mode` to the urltest group so it can distribute traffic instead of only
picking the lowest-delay node, with optional per-flow stickiness.

- mode: least_test (default, unchanged) | round_robin (rotate across live nodes)
  | least_connection (reserved, phase 2 — rejected at config time).
- round_robin selects once per connection over the tag-sorted live set (nodes with
  a fresh URL-test result supporting the network); UDP/QUIC sessions stay on one
  node; first usable outbound is the fallback when nothing is live. The legacy
  selectedOutbound* cache path is untouched — balancing is a separate branch in
  DialContext/ListenPacket.
- sticky {mode, timeout, cap, hash}: binds one flow to one node. hash components
  process|domain|source_ip|dest_ip|dest_port concatenate in order; absent -> "",
  all-empty key -> one fixed node (keyless flows never rotate). mode jumphash
  (default, stateless consistent hash — ~1/n remap on node-set change) or ttlmap
  (key->node table, lazy + ticker eviction, 2000 LRU cap, 10m TTL, dead-node re-pin).

Reuses the existing urltest health ticker/history as the single liveness source;
no new probing. Now() reports the last-picked tag in balanced modes.

Tests (go test -race, 15 cases): distribution, dead-node skip, all-dead fallback,
jumphash stability + empty-key fixed node, ttlmap stick/expire/cap/dead-repick,
key building, validation. The race detector caught a real bug in the sticky
sweeper (read t.ticker unlocked while close() nilled it) — fixed by passing the
channels into the goroutine, mirroring URLTestGroup.loopCheck.

Also folds the SPEC 016 connections-map mutex (ebf9cc07) into the rc.11 changelog
section, which had not yet shipped in a release.
2026-06-28 01:10:17 +03:00
世界 156c4f5ceb Bump version 2026-06-27 17:48:32 +08:00
世界 d0b52036cb Refactor darwin local DNS to raw mDNSResponder call 2026-06-27 17:14:54 +08:00
Leadaxe ebf9cc0768 fix(SPEC 016): sync.Mutex on Connections — fix concurrent map fatal (§170)
Connections is the client-side CommandConnections accumulator. With 2+
subscribers (LxBox screenClient + profilerClient) one goroutine writes
connectionMap in ApplyEvents while another ranges it in Iterator →
"concurrent map iteration and map write" fatal error → SIGABRT of the
whole process (reproduced in ~20s under traffic, CPH2411/Android15).

Add access sync.Mutex; lock every public method touching
connectionMap/input/filtered: ApplyEvents, FilterState, SortBy*, Iterator.

- FilterState split into public (locks) + private filterState (no lock);
  ApplyEvents calls the private one under its already-held lock
  (sync.Mutex is not reentrant). Field filterState -> filterStateValue to
  free the name for the method.
- evictClosedConnections stays lock-free: private, only called from
  ApplyEvents under lock.
- Iterator returns a COPY of filtered — the gomobile caller walks it
  after the Go call returns (lock released), so it must not read the
  live slice a concurrent ApplyEvents/SortBy is rewriting.

This is the UI/command channel, not the data plane — uncontended lock
~20ns. LxBox per-client accumulators (§170) stay as the consumer scheme;
the mutex is class-correctness insurance against a 3rd consumer.

Verified: go test -race TestConnectionsConcurrentAccess (writer || 3
readers, 2000 rounds) green; go build ./... and -tags with_lx_command
green; gofmt clean.
2026-06-27 02:05:39 +03:00
Leadaxe 1e86fdd52b docs(release): runbook — check upstream drift + merge before any release tag
Codify the rule: before cutting any lx release/prerelease tag, check whether
upstream/testing moved ahead of our last merge and, by default, merge it in
first — then build/gofmt/lx-check, then changelog, then tag.

- docs/lx-release-runbook.md: pre-release gate checklist, drift-check commands,
  the manual `git merge upstream/testing` flow (replaces SPECS/004 auto-rebase
  while upstream is v1.14.*-alpha), conflict zones (.pb.go, wireguard-go submodule,
  build_libbox marker, observability files), and the one-liner sequence.
- SPECS/004 SPEC.md: pointer to the runbook + note that manual merge superseded
  auto-rebase on this branch.
2026-06-27 01:18:17 +03:00
Leadaxe 3505beb6a4 fix(SPEC 018): cleanliness audit — comment helper name + dead SourceRejected
Two nits surfaced by the lx-vs-upstream cleanliness audit (no runtime impact):

- box.go: the dnstrack registration comment said "service.FromContext" — the
  §180 dead-stream signature. The actual readers use PtrFromContext (pairs with
  MustRegisterPtr). Fixed the comment + noted why FromContext[*T] returns nil,
  so a future debugger doesn't "fix" the readers back into §180.
- common/dnstrack/manager.go: removed the unused SourceRejected constant —
  rejected resolutions are folded into SourceFailed at the emit site, so
  "rejected" never reaches the wire. Replaced with a comment to prevent re-adding
  an unreachable client case.

Audit verdict: code clean — no concurrency/wire/behaviour issues; dns/client.go
byte-identical to upstream, emits additive and subscriber-gated.
2026-06-27 01:13:53 +03:00
Leadaxe 3e4c178339 lx(1.14): SPEC 018 — DNS server + outbound in stream, subscriber-gated
LxBox feedback: DnsQuery lacked which DNS server / outbound channel the query went
through. A DNS rule selects a server (matchDNS by action.Server), not an outbound;
the channel is the server's own detour, fixed at config time. Add to DnsQueryEvent:
- dnsServer/dnsServerType = transport.Tag()/Type() (transport is the Exchange param,
  so available on all emit paths incl. failures);
- outbound = the server's detour tag (TransportAdapter.OutboundTag() from
  DialerOptions.Detour), with a selector expanded to its live node via Now()
  server-side (like Connection.Detour), empty on cached/optimistic.

Also gate event construction on HasSubscribers(): with no profiler attached the DNS
hot path builds nothing (no event/answers/outbound lookup) — previously every
resolution built an event just to be dropped for lack of a listener. The Now()
resolution therefore never touches the hot path.

Wire: additive proto fields + OutboundTag() on DNSTransport (embedded adapter
satisfies it). libbox DnsQuery.DNSServer/DNSServerType/Outbound(). Changelog rc.10.
2026-06-27 00:42:29 +03:00
Leadaxe 26469ee657 fix(SPEC 018): attribute DNS queries + bare rdata (§180-2)
DNS attribution was empty (0/119 on device): TUN+DNS hijack returns on a fast-path
(route.go:91/226) BEFORE matchRule, and searchProcessInfo — which fills
metadata.ProcessInfo — lives inside matchRule (:416). So fast-path DNS (most DNS on
a VPN) reached the SubscribeDNSQueries emit with nil ProcessInfo. Fix: call
r.searchProcessInfo(ctx, &metadata) before both fast-path hijacks (stream+packet);
idempotent + cached, one lookup per flow. Corrects SPEC 018 пункт 3 (the earlier
'cached attribution correct' claim checked ctx consistency, not that ProcessInfo
was populated before the resolve).

Also: DnsAnswer.rdata was the full RR string ('google.com. 29 IN A 1.2.3.4'); strip
the header prefix so clients get the bare value ('1.2.3.4' / CNAME target).

No proto/wire change. LxBox §180 needs no client change. Changelog rc.9.
2026-06-26 20:32:50 +03:00
Leadaxe 0a8311cb92 fix(SPEC 018): dnstrack Manager registry key mismatch — DNS stream was dead (§180)
SubscribeDNSQueries returned Unimplemented on device and emitted nothing: the
dnstrack.Manager is registered via MustRegisterPtr (key *dnstrack.Manager) but
read via service.FromContext[*dnstrack.Manager] (key **dnstrack.Manager), so the
lookup always found nil. Server -> Unimplemented; emit sites -> silent drop.

Fix all three readers to service.PtrFromContext[dnstrack.Manager] (the pair of
MustRegisterPtr, as trafficManager does in daemon/instance.go). Verified the
manager resolves to the exact pointer box.go registered. No proto/wire change;
rc.7 contract intact. LxBox §180 needs no client change.

Changelog rc.8.
2026-06-26 19:34:46 +03:00
Leadaxe 96bef7419a ci(lx-release): generate release notes from lx-changelog, not a static template
The release-notes heredoc hardcoded 'base v1.13.13' and only AWG+XHTTP — stale
since the 1.14 migration, identical for every rc, and never reflecting what a tag
actually shipped (SPEC 014/015/017/018 were invisible). Now the 'What's new'
section is extracted from docs/lx-changelog.md for the current version (awk between
'#### vX' and the next '#### '), spliced via 'sed r' so changelog backticks/$()
stay inert (no command injection from doc prose). Base line fixed to alpha.35;
standing-features list updated with the CommandClient extensions.
2026-06-26 16:56:37 +03:00
Leadaxe 64bbcdfc2a docs(SPEC 018): mark draft section illustrative + rcode=-1 client note
Two doc-hygiene fixes from LxBox review (no logic change):
- Point the реализатор at 'Согласованная форма' as the binding contract; the
  earlier 'Решение' proto sketch (Empty input, no failed/answers) is illustrative.
- Note that rcode=-1 ships as signed int32 (distinct on the wire from 65535,
  verified); client must map -1 -> 'no answer' before any .toUInt().
2026-06-26 16:30:31 +03:00
Leadaxe cffbfcbfce lx(1.14): SPEC 018 SubscribeDNSQueries — structured DNS-query stream
Hijacked DNS (the norm on an Android VPN) is answered before a connection becomes
a traffic tracker, so DNS queries never reach the connections stream — the only
egress was the text log, which carries no app attribution. Add common/dnstrack
(a Subscriber[QueryEvent] mirror of trafficcontrol) emitting one event per
resolution from dns/client.go, attributed via adapter.ContextFrom(ctx).ProcessInfo
(same ctx on cache-hit and miss, so cached queries are attributed too).

Failures are first-class: timeout/loopback/rejected-cached/SERVFAIL-reject emit
failed=true + error + rcode=-1 (no response) — without this the stream is blind to
DNS failures, the primary throttling signal. CNAME chains preserved: with
includeAnswers, each event carries the full response.Answer in wire order (CNAME
hops + final A/AAAA, not filtered to IPs).

Wire: rpc SubscribeDNSQueries(SubscribeDNSQueriesRequest) returns (stream
DnsQueryEvent) + DnsAnswer; event-driven server stream (no ticker); libbox
SubscribeDNSQueries(includeAnswers, handler). Tag-less core -> Unimplemented.
Detour/Chain and other streams unchanged.

Docs: SPECS/018, lx-changelog rc.7.
2026-06-26 16:26:09 +03:00
Leadaxe b8ff5c7836 docs(lx-changelog): rc.6 also carries upstream alpha.35 merge + device-verify notes 2026-06-26 15:00:06 +03:00
Leadaxe 7804bf9d85 Merge remote-tracking branch 'upstream/testing' into lx-1.14
# Conflicts:
#	box.go
#	cmd/internal/build_libbox/main.go
#	common/certificate/store.go
#	common/trafficcontrol/tracker.go
#	daemon/managed_service.pb.go
#	daemon/managed_service_grpc.pb.go
#	daemon/started_service.pb.go
#	daemon/started_service.proto
#	daemon/started_service_grpc.pb.go
#	docs/changelog.md
#	go.mod
#	go.sum
#	service/oomkiller/service.go
#	service/oomkiller/service_darwin.go
2026-06-26 14:27:59 +03:00
Leadaxe c12ee663b1 lx(1.14): SPEC 017 Connection.Detour — transport detour tail of final outbound
chain omits the final outbound's own detour by design (upstream loop only
unwinds OutboundGroup via Now() and breaks on the first non-group), so a node
detouring through e.g. WARP never shows in the routing chain. Add Detour
[]string to TrackerMetadata, unwound from the final outbound's Dependencies()
(= its detour for a non-group outbound), descending into groups via Now()
against the same atomic snapshot, with a seen-guard against cycles.

Wire: additive 'repeated string detourList = 23' on the Connection proto
message (hand-applied to keep the generated diff minimal — no toolchain churn),
mapped in connectionToProto, surfaced on libbox Connection as Detour()
StringIterator. Chain / Clash-API unchanged.

Docs: SPECS/017, lx-changelog rc.6.
2026-06-26 14:14:34 +03:00
Leadaxe 9c55f96aa9 lx(1.14): SPEC 015 §3.6 URLTestOutbound cancel-fix (ctx-bind) + docs
Parent the per-node delay test to the gRPC per-call ctx instead of the
long-lived boxService.ctx, so cancelling the call aborts the in-flight
dial before C.TCPTimeout without tearing down the connection. Restores
the granular per-node cancel the Clash API had implicitly via r.Context()
(there was never a cancelDelays endpoint).

Mass-cancel is unblocked client-side on the existing gomobile binding
via a separate ping CommandClient + Disconnect() (no native-surface
change, no server batch RPC) — closes the LxBox feedback.

Docs: SPEC 015 §3.6 (cancellation), SPEC 014 (#4240 deleted upstream →
seam-removal criterion switched to upstream-code), lx-changelog rc.5.
Ignore test/cache.db.
2026-06-26 11:03:14 +03:00
Leadaxe 7ff9d06839 docs(SPEC 016): Connections map race — нет мьютекса в ApplyEvents/Filter/Iterator
Connections (command_types.go:115) держит connectionMap/input/filtered без
синхронизации; ApplyEvents/evictClosedConnections/FilterState/SortBy*/Iterator
зовутся из разных gRPC-горутин (по одной на подписчика handleConnectionsStream).
≥2 подписчика CommandConnections → concurrent map iteration and map write →
fatal error → SIGABRT всего процесса.

Всплыло после CommandClient-миграции (раньше connections слушал ≤1 потребитель).
Обойдено клиент-стороной в LxBox §170 (per-client accumulator), но в ядре не
починено — третий потребитель вернёт краш. Фикс: sync.Mutex вокруг состояния.
Референс: LxBox docs/spec/tasks/170.
2026-06-26 10:28:15 +03:00
世界 40f2b6eb42 release: Fix upload ios deb 2026-06-25 19:47:32 +08:00
世界 ecc4776034 Bump version 2026-06-25 17:39:58 +08:00
世界 ab6d97d947 Fix oom draft flood 2026-06-25 17:39:16 +08:00
世界 8e9c61afa3 platform: Fix missing api version check for usb/ip 2026-06-25 17:39:16 +08:00
世界 64a0e0ce6b certificate: Replace platform bridge with CGO JNI 2026-06-25 17:39:16 +08:00
世界 0794d645af Add iOS jailbreak release 2026-06-25 17:39:16 +08:00
世界 b010759f45 Fix Cloudflared edge discovery ignoring configured resolver 2026-06-25 17:39:15 +08:00
世界 4d5c2a03d6 documentation: Add USB/IP server and client 2026-06-25 17:39:08 +08:00
世界 9ac1399094 Add USB/IP support for macOS 2026-06-25 17:39:07 +08:00
世界 36e92fc7a9 Add USB/IP service 2026-06-25 17:39:02 +08:00
世界 f4061770bb Fix remote control when Clash server is unavailable 2026-06-25 17:38:56 +08:00
世界 3035af688e Add dashboard support for API service 2026-06-25 17:38:56 +08:00
世界 9be5bbcd24 Improve remote rule-set update 2026-06-25 17:38:55 +08:00
世界 6ffb744881 dns: Remove unused files 2026-06-25 17:38:55 +08:00
世界 4d24bc46a6 release: Fix apple release 2026-06-25 17:38:55 +08:00
世界 7e96370229 Fix group status updates broken by API service
The URL test history update hook and the Clash mode update hook were
single-slot: the API service's attached service overwrote the hook set
by the daemon, so clients stopped receiving group updates. Replace both
with multicast hook lists.

Also share a single URL test history storage via context: Clash API
looked it up under a key nobody registered and fell back to its own
empty storage, so dashboards showed no delay once an API service was
configured. Selector changes now notify through the shared storage,
covering selections made from any API surface.
2026-06-25 17:38:54 +08:00
世界 48d04817d7 Update Go to 1.25.11 2026-06-25 17:38:54 +08:00
世界 65b90e6f4b tailscale: Fix auth URL not refreshed after logout 2026-06-25 17:38:53 +08:00
世界 95c37c138a Add sing-box API service 2026-06-25 17:38:53 +08:00
世界 65aeb3c8f4 daemon: Split host operations into ManagedService 2026-06-25 17:38:52 +08:00
世界 c2da94f252 platform: Add shell support for iOS 2026-06-25 17:38:52 +08:00
世界 dcbb8271f8 platform: Add tailscale device name and logout 2026-06-25 17:38:52 +08:00
世界 1ee172f6af tailssh: fix platform SFTP session teardown 2026-06-25 17:38:52 +08:00
世界 f18b01b7fe tailscale: Add tailssh server 2026-06-25 17:38:51 +08:00
世界 775a05162c tools: Fix mising cleanup 2026-06-25 17:38:51 +08:00
世界 d0ef5c028d hysteria2: Add gecko obfs 2026-06-25 17:38:51 +08:00
世界 6a8071d1ce daemon: Add Tailssh 2026-06-25 17:38:50 +08:00
世界 db0e33d74b Fix tailscale dns 2026-06-25 17:38:28 +08:00
世界 e97a1bec41 tailscale: Expose more peer info fields 2026-06-25 17:38:27 +08:00
世界 03499260df tailscale: Add runtime exit node API 2026-06-25 17:38:27 +08:00
世界 eb4cef4eab Fix tailscale 2026-06-25 17:38:27 +08:00
世界 eacfa36ff6 realm: Open separate v4 and v6 packet conns on client 2026-06-25 17:38:26 +08:00
世界 ab08bac1d1 gvisor: Fix dialing to self addresses 2026-06-25 17:38:26 +08:00
世界 5d0b87d9bf tailscale: Fix handle peer DNS query 2026-06-25 17:38:26 +08:00
世界 23a676ac8a tailscale: Revert dialer deprecation and remove control_http_client 2026-06-25 17:38:25 +08:00
世界 8218042258 process: Fix panic when package manager is unavailable on Android 2026-06-25 17:38:25 +08:00
世界 529d3d9b23 route: Refetch rule-set when cache restore fails 2026-06-25 17:38:25 +08:00
世界 a12b7e9417 oom-killer: Remove log "OOM draft discarded" 2026-06-25 17:38:25 +08:00
世界 9815feae0a Fix shadowtls handshake 2026-06-25 17:38:24 +08:00
世界 2a6837dd1a Rebase wireguard-go to official 2026-06-25 17:38:13 +08:00
世界 d8ca4b1a67 dns: Fix DHCP reset 2026-06-25 17:38:13 +08:00
世界 2c2e08e608 Fix lint errors 2026-06-25 17:38:13 +08:00
世界 4b55717612 Fix hysteria2 realm server 2026-06-25 17:38:12 +08:00
世界 41a2c8cc50 realm: Add stun retry and lazy server start 2026-06-25 17:38:12 +08:00
世界 d99fc94fbc Fix TLS server close 2026-06-25 17:38:12 +08:00
世界 d8f461cc5a Update hysteria2 realm 2026-06-25 17:38:12 +08:00
世界 094808a4fa Add hysteria2 realm service and support 2026-06-25 17:38:11 +08:00
世界 89d83aa6d2 Fix reset network 2026-06-25 17:38:11 +08:00
macronut c768f248d9 Add more spoof method
Signed-off-by: macronut <4027187+macronut@users.noreply.github.com>
2026-06-25 17:38:11 +08:00
世界 e8643485d2 Allow customizing TUN DNS mode and hijack interface DNS by default 2026-06-25 17:38:10 +08:00
世界 ce360eece8 dns: Add mDNS server 2026-06-25 17:38:10 +08:00
世界 511e72bcfb dns: Add preferred_by rule item 2026-06-25 17:38:10 +08:00
世界 9b277bd690 dns: Add neighbor-based hostname resolution to local server 2026-06-25 17:38:09 +08:00
世界 08e8910cee Fix tailscale start dependencies 2026-06-25 17:38:09 +08:00
世界 dd454bf0c6 dns: Add timeout configuration 2026-06-25 17:38:08 +08:00
世界 1541bbc91d ssh: Add cipher, MAC, and key exchange configuration 2026-06-25 17:38:08 +08:00
世界 0426c17121 Improve oom-killer 2026-06-25 17:38:08 +08:00
世界 7d13cabb6a Preserve comments between formatting 2026-06-25 17:38:07 +08:00
nekohasekai a37fcd59bb Add Windows TLS engine 2026-06-25 17:38:07 +08:00
世界 4cdacb3e63 Improve UDP batch support 2026-06-25 17:38:07 +08:00
世界 686b9b08df Fix stderr deprecated manager 2026-06-25 17:38:06 +08:00
世界 5012ad3cb5 Fix darwin cgo DNS again 2026-06-25 17:38:06 +08:00
世界 8bce66d952 platform: Improve oom-killer 2026-06-25 17:38:06 +08:00
世界 fb1ddc1702 Fix ACME HTTP-01 challenge for IPv6 literal addresses 2026-06-25 17:38:05 +08:00
世界 6e2c0fa249 Add ACME profile support for IP address certificates 2026-06-25 17:38:05 +08:00
世界 da6ab370df Fix goroutine leak in networkquality tool
Serialize probe rounds in startProber to eliminate unbounded fan-out of
fire-and-forget probe goroutines (up to 100/sec per direction), and close
HTTP/3 transports via transport.Close() in addition to CloseIdleConnections.
2026-06-25 17:38:05 +08:00
世界 e2d860ac77 Fix Tailscale search domain response name mismatch 2026-06-25 17:38:05 +08:00
世界 051e8fb4b8 Log DNS optimistic background refresh outcomes 2026-06-25 17:38:05 +08:00
世界 157eb993de Add search domain support for Tailscale DNS 2026-06-25 17:38:04 +08:00
世界 4f279bc1e7 Fix tls-spoof 2026-06-25 17:38:04 +08:00
世界 6b07ec3aff Fix Apple TLS metadata capture 2026-06-25 17:38:04 +08:00
世界 c092abe2ff Strip EDNS padding from upstream DNS responses 2026-06-25 17:38:03 +08:00
世界 6e4f70f155 Defer implicit default HTTP client fallback to first use 2026-06-25 17:38:03 +08:00
世界 16fe1e7ee0 Scope HTTP/2 fallback and HTTP/3 broken state per authority 2026-06-25 17:38:03 +08:00
世界 5ca971aa4d Fix macOS tlsspoof 2026-06-25 17:38:03 +08:00
世界 146f35483d Reject IP literal server name with TLS spoof 2026-06-25 17:38:02 +08:00
世界 043fdfad1d Fix use-after-free of pooled value buffers in bbolt Batch writes 2026-06-25 17:38:02 +08:00
世界 b5ec033a57 Reject pure-IP rule-set references without match_response
DNS rules referencing rule-sets that contain only ip_cidr predicates
silently stopped matching when legacy DNS mode was disabled, because the
IP-CIDR branch cannot match against an in-flight DNS query. The existing
validation intentionally let every rule_set through on the premise that
mixed sets still work via their non-IP branches, which is only true when
such a branch exists. Track whether a rule-set carries any non-IP-CIDR
predicate and reject pure-IP references the same way bare ip_cidr fields
are already rejected.
2026-06-25 17:38:02 +08:00
世界 b04b235661 Fix legacy rule-set download_detour blocked by empty direct check 2026-06-25 17:38:01 +08:00
世界 3d1d6acbe3 Add TLS spoof support 2026-06-25 17:38:01 +08:00
世界 590b176c47 Standardize hosts path 2026-06-25 17:38:00 +08:00
世界 cfe83a5dcc Refactor: HTTP clients, unified HTTP2/QUIC options, Apple engines 2026-06-25 17:37:59 +08:00
世界 23ebdba5d0 oom-killer: Record report before reset network 2026-06-25 17:37:59 +08:00
世界 ecd07e2f85 Add optimistic DNS cache 2026-06-25 17:37:59 +08:00
世界 ce9502122b Fix tailscale error 2026-06-25 17:37:59 +08:00
世界 97aad6394e Fix darwin cgo DNS again 2026-06-25 17:37:58 +08:00
世界 2f62a35e1b Fix stun test 2026-06-25 17:37:58 +08:00
世界 2255e1f80b documentation: Fix missing update for ip_version and query_type 2026-06-25 17:37:58 +08:00
世界 fd7bc21175 Add cloudflared inbound 2026-06-25 17:37:08 +08:00
世界 cfe5fdcb79 Fix lint errors 2026-06-25 17:35:31 +08:00
世界 df27819403 platform: Wrap command RPC error returns with E.Cause 2026-06-25 17:35:31 +08:00
世界 dec919e0ca Add package_name_regex route, DNS and headless rule item 2026-06-25 17:35:30 +08:00
世界 db1e08a07f documentation: Fixes 2026-06-25 17:35:30 +08:00
世界 d8b7e92cc2 Un-deprecate ip_accept_any DNS rule item 2026-06-25 17:35:29 +08:00
世界 6792fd18e1 tools: Tailscale status 2026-06-25 17:35:29 +08:00
世界 47929493b9 Fix darwin local DNS transport 2026-06-25 17:35:29 +08:00
世界 5b4628a5fb Fix rules lock 2026-06-25 17:35:28 +08:00
世界 be866108cd Revert "Also enable certificate store by default on Apple platforms"
This reverts commit 62cb06c02fc569beb7b2ffa3f0a10ef23e136748.
2026-06-25 17:35:28 +08:00
世界 83d681b7fd tools: Tailscale status 2026-06-25 17:35:27 +08:00
世界 d991b1533b platform: Fix darwin signal handler 2026-06-25 17:35:27 +08:00
世界 7bac8dc815 tools: Network Quality & STUN 2026-06-25 17:35:27 +08:00
世界 2ae0fc6ab1 oom-killer: Free memory on pressure notification and use gradual interval backoff 2026-06-25 17:35:26 +08:00
世界 38bb6e9b11 Fix deprecated warning double-formatting on localized clients 2026-06-25 17:35:26 +08:00
世界 3bdae735bc platform: Fix set local 2026-06-25 17:35:26 +08:00
nekohasekai 92118b594c Add evaluate DNS rule action and related rule items 2026-06-25 17:35:25 +08:00
世界 d8fd90d956 Also enable certificate store by default on Apple platforms
`SecTrustEvaluateWithError` is serial
2026-06-25 17:35:25 +08:00
世界 666bfdbe4e platform: Add OOM Report & Crash Report 2026-06-25 17:35:25 +08:00
世界 f96582c5d1 Add BBR profile and hop interval randomization for Hysteria2 2026-06-25 17:35:24 +08:00
nekohasekai 1d523907cb Refactor ACME support to certificate provider 2026-06-25 17:35:24 +08:00
世界 306ed3e496 documentation: Update descriptions for neighbor rules 2026-06-25 17:35:24 +08:00
世界 49349e2e5c Add macOS support for MAC and hostname rule items 2026-06-25 17:35:23 +08:00
世界 22f683e5a3 Add Android support for MAC and hostname rule items 2026-06-25 17:35:23 +08:00
世界 6dd01016da Add MAC and hostname rule items 2026-06-25 17:33:05 +08:00
世界 25a600db24 Bump version 2026-06-25 16:36:07 +08:00
世界 ef2a012301 Fix http proxy authentication 2026-06-25 16:13:35 +08:00
Leadaxe c1662250a8 docs(spec015): sync §4 criteria + handler/client details with shipped code
- §4 acceptance criteria 3-5 → ✅ rc.4 (matched the status table/headers)
- §3.3/§3.4 handlers: RLock + STARTED check (codes.FailedPrecondition), not
  waitForStarted — match the actual GetGroups/GetOutbounds impl
- GetGroups client signature → OutboundGroupIterator (was GroupIterator)
2026-06-25 09:58:15 +03:00
Leadaxe 30c2a76848 docs(spec015): mark GetGroups/GetOutbounds/len<2 done (rc.4) + changelog 2026-06-25 04:11:56 +03:00
Leadaxe 40e4733638 lx(1.14): SPEC 015 GetGroups/GetOutbounds pull-getters + len<2 group-drop fix
Three command-protocol additions completing the Clash-API -> CommandClient
migration (SPEC 015, behind with_lx_command):

- GetGroups / GetOutbounds: unary pull-snapshots over the existing readGroups()
  and the SubscribeOutbounds builder. The CommandClient is push-only; if the
  SubscribeGroups stream never opened (service not STARTED at subscribe) or broke,
  the client had no cheap way to re-read group state and the main screen stayed
  empty (tunnel connected, groups=[]). These getters close that gap without
  recreating the whole client. Both needed: SubscribeGroups covers only in-group
  nodes, endpoints (WG/AWG) + standalone outbounds appear only via the flat list.
  Errors via status.Error (unary read convention, like GetRules).

- len<2 fix: readGroups() silently dropped groups with < 2 items (upstream commit
  5bc0dfa9), hiding single-node selectors -- a regression vs Clash, whose /proxies
  returned group.All() unfiltered. readGroups() is the single source feeding both
  SubscribeGroups (startup broadcast) and GetGroups, so the fix covers both.

Handlers in started_service_command_lx{,_stub}.go behind with_lx_command;
client methods in command_client_command_lx.go reuse the existing gRPC->libbox
iterators. proto seam under // lx: marker, regenerated via pinned lx-proto.
E2E tests (test/command_lx_test.go) drive the public daemon API: single-node
group survives, flat list returned, not-started rejected. Both tag/no-tag builds
green; no-tag answers Unimplemented.
2026-06-25 04:11:16 +03:00
Leadaxe eb59e161f0 docs(specs): re-split 014 into 014 (migration) + 015 (command-RPC extensions)
Split the original SPEC 014 by NATURE of change:
- 014 CLASH_API_TO_COMMANDCLIENT_MIGRATION (dir renamed) — the migration itself:
  with_clash_api drop (rc.1) + box.go Android-start fix (rc.3). No RPC tech-spec.
- 015 COMMAND_PROTOCOL_RPC_EXTENSIONS — single home of all command-RPC work:
  URLTestOutbound + GetRules (DONE, rc.2) + GetGroups + GetOutbounds + the len<2
  readGroups bugfix (TODO, rc.4). All §3.6 class, with_lx_command.

Docs-only; shipped rc.2 code unchanged. 015 documents the pull-vs-push gap
(GetGroups/GetOutbounds) and the upstream len<2 group-drop defect, plus an
upstream-candidacy plan (§7): pull-getters + len<2 are clean upstream defects;
RPCs currently ship in lx-form, an upstream PR would need upstream-form (future).
2026-06-25 03:57:16 +03:00
Leadaxe 3c5d318151 docs(spec014): §3.8 follow-on box.go fix (rc.3) + WATCH upstream #4240
Records the Android start fatal fixed in rc.3 (commit 029acd11): PlatformLogWriter
no longer forces the Clash server; observability served by the native
CommandClient. Self-contained in the feature SPEC — fix + WATCH
SagerNet/sing-box#4240 + the obligation to drop the // lx: box.go seam on the
next rebase if upstream resolves it.
2026-06-24 20:14:49 +03:00
Leadaxe 029acd1199 lx(1.14): fix Android start fatal from with_clash_api drop (box.go)
Upstream box.go forced needClashAPI whenever PlatformLogWriter is set (always
on Android/libbox), because the Clash server was historically the only log/
traffic observer. With with_clash_api dropped (rc.1), that made every Android
start fatal: 'clash api is not included in this build' — even with no clash_api
in the config.

Split the concern behind a // lx: seam: PlatformLogWriter now requests
observability (Observable log factory + connection/traffic tracker), served by
the native CommandClient (SubscribeLog/SubscribeConnections), NOT the Clash
server. Only an explicit experimental.clash_api block still creates the Clash
server (and still fails fast without the tag). daemon is already nil-safe to a
missing clashServer, so Clash-mode degrades gracefully. Desktop unaffected.

Verified: core starts with no clash_api config; still fail-fast with one.
2026-06-24 20:01:27 +03:00
Leadaxe 3801a84fd9 docs(spec014): channel map for URLTestOutbound delay (RPC / SubscribeOutbounds / SubscribeGroups)
Clarify where a URLTestOutbound result surfaces, since the original §3.2 wording
only named OutboundGroupItem ("for nodes in groups") and omitted SubscribeOutbounds
— the actual channel that carries endpoint (WG/AWG/Tailscale) delay.

- §3.2: add a 3-row channel-map table (synchronous RPC response = any node;
  SubscribeOutbounds = all outbounds AND all endpoints; SubscribeGroups = only
  OutboundGroup members). All three share urlTestObserver via urlTestHistoryStorage.
- §3.2: sync the client signature to what shipped — (*URLTestOutboundResult, error)
  with int32 timeout (gomobile can't bind the draft (uint16,string,error)); note why.
- §3.7 / §5: replace the narrow "history flows to OutboundGroupItem" line with the
  SubscribeOutbounds + SubscribeGroups split.

Docs-only; no code or artifact change (rc.2 binaries unchanged).
2026-06-24 13:39:19 +03:00
Leadaxe 8a56852d7e lx(1.14): SPEC 014 libbox command-protocol extensions (URLTestOutbound + GetRules)
Restore over the native libbox CommandClient what upstream only exposed through
the dropped Clash API: per-node delay testing and a route+DNS rule-table snapshot.
Both RPCs are a pure bridge (CONSTITUTION §3.6) gated by the with_lx_command tag.

- daemon/started_service.proto: URLTestOutbound + GetRules RPCs and messages under
  the // lx:begin/end lx_command marker; regenerated .pb.go/_grpc.pb.go.
- daemon/started_service_command_lx.go (+ _stub.go): handlers behind with_lx_command,
  stub twin returns codes.Unimplemented. URLTestOutbound resolves an outbound OR an
  endpoint (no OutboundGroup assert), honours link+timeout, error-in-payload Variant B
  (delay==0 && error=="" is success 0ms), history Store/Delete via group.RealTag.
  GetRules returns route + DNS rules split by isDNS.
- adapter/dns.go + dns/router.go: new adapter.DNSRouter.Rules() getter (route Router
  already had one), read under rulesAccess; both under // lx: markers.
- experimental/libbox/command_client_command_lx.go: CommandClient.URLTestOutbound
  (*URLTestOutboundResult, error) and GetRules (RuleIterator, error) — gomobile-bindable
  shapes (the SPEC's bare (uint16,string,error) does not bind); Variant B preserved.
- cmd/internal/build_libbox/main.go: with_lx_command into sharedTags (AAR).
- Makefile.lx: with_lx_command in LX_TAGS; pinned lx-proto/lx-proto-install targets
  (protoc-gen-go v1.36.11, protoc-gen-go-grpc v1.5.1) for reproducible regeneration.
- lx-ci.yml: vet+gofmt cover the lx files; build-check proves both builds toggle the
  stub marker.
- Collateral one-time pin normalisation of managed_service/v2rayapi/v2raygrpc .pb.go
  (audited in SPEC §3.5).

docs(lx-changelog): v1.14.0-lx.1-rc.2. SPEC 014 → accepted.
2026-06-24 03:52:44 +03:00
Leadaxe 3a6dbfa7f3 docs(specs): amend CONSTITUTION (§3.6 libbox command extensions) + SPEC 014
CONSTITUTION: three owner principles (thin layer / follow upstream / build
what we+users need) wired into §1–2 as a priority hierarchy with a
necessary-not-sufficient lock; §3.1(а) rewritten from binary 'not in upstream'
to a three-prong test (needed by us/users / absent from OUR built channel /
cheaper than the alternative, with an auditable touched-file count); new §3.6
legalizes the 'libbox command-protocol extensions' change-class — handlers
gated by with_lx_command behind the proven started_service_usbip{,_stub}.go
pattern, .proto seam under a // lx: marker, .pb.go regenerated (never
hand-edited). §3.5 version bumped 1.13.13-lx.N → 1.14.0-lx.N.

SPEC 014: two CommandClient RPCs restoring what was lost when with_clash_api
was dropped — URLTestOutbound (per-node delay for outbound OR endpoint, custom
url + timeout, synchronous {delay,error}, all errors in payload) and GetRules
(route + DNS rule table snapshot). DNS-rules need a marked getter on
adapter.DNSRouter/dns.Router (route-only doesn't). Deterministic proto
regeneration (pinned protoc in Makefile.lx) is a mandatory deliverable. No
separate history RPC, no cancel-handle, no batch — those live in the client.
2026-06-24 03:24:10 +03:00
Leadaxe 57b5b5e5fc lx(1.14): drop with_clash_api, prerelease tags, v1.14.0-lx.1-rc.1
LxBox is moving to manage the core over the native libbox CommandClient
(group/url-test/select/connections streams), so the Clash REST API is dead
weight on the client. Drop with_clash_api from both the Android AAR
(build_libbox sharedTags) and the desktop LX_TAGS. A config referencing
experimental.clash_api now fails fast (no silent fallback); lx configs won't.

lx-release.yml: tags with an -rc.N / -alpha.N / -beta.N suffix now publish as
GitHub pre-releases (--prerelease), so an unverified build never displaces the
stable lx release as Latest.

First build on the upstream 1.14 base. The WG-endpoint GRO fix (010) lands at
the AmneziaWG v0.0.3 submodule source (no downstream guard), but the Android
download-stall path is NOT yet re-verified on hardware -- hence the -rc.1 tag.
2026-06-24 00:11:20 +03:00
Leadaxe c7f21672ad lx(1.14): bump wireguard-go submodule to AmneziaWG re-graft on v0.0.3
Step 2 of 2 of the 1.14 migration. Points the submodule at e5feca7
(AmneziaWG 2.0 obfuscation re-grafted onto sagernet/wireguard-go v0.0.3).

Verification on lx-1.14:
- full sing-box build with lx tags (with_gvisor/quic/wireguard/utls/clash_api/xhttp/awg): OK
- submodule builds clean for linux/android/windows/darwin (library packages)
- transport/wireguard, protocol/wireguard, protocol/group, route/rule tests: green
- broad test (option/route/transport/common): green; gofmt + go vet clean
- binary runs on 1.14; package_name_regex config validates; awg2_basic + awg2_ranged validate

§010 android UDP_GRO guard dropped (v0.0.3 fixes split-brain at source) — pending
on-device re-verification before any release tag.
2026-06-23 02:34:15 +03:00
Leadaxe 514a9e74ff lx(1.14): merge upstream v1.14.0-alpha.33 into lx (sing-box layer)
Full 1.14 migration, step 1 of 2 (sing-box repo layer). Three conflicts
resolved, all as predicted by the feasibility analysis:

- route/rule/rule_item_package_name_regex.go (add/add): took upstream's
  canonical version (slices.ContainsFunc) — our lx.15 backport collapses
  back into upstream, so the file no longer diverges going forward.
- route/rule_conds.go: kept our package_name_regex in isProcess{,DNS}Rule
  and took upstream's new isNeighbor{,DNS}Rule additions.
- cmd/internal/build_libbox/main.go: kept lx with_xhttp/with_awg append and
  the no-tailscale block; deliberately dropped upstream's new with_usbip
  (server-side USB/IP, contradicts client-trim).

go.mod auto-merged: wireguard-go require bumped to v0.0.3, lx replace block
(=> ./submodules/wireguard-go) preserved. Submodule pointer unchanged here —
the AmneziaWG graft rebase onto v0.0.3 is step 2 (next commit). This commit
does NOT build yet (submodule still on the old wireguard-go base).
2026-06-23 02:09:53 +03:00
Leadaxe c7a2592e75 docs(lx-changelog): v1.13.13-lx.15 (package_name_regex) + backfill lx.14 (WG GRO) 2026-06-23 01:37:23 +03:00
Leadaxe 7f45b7ea6b lx(013): backport package_name_regex rule item (route/DNS/headless)
Backport upstream 1.14 feature 941ce58b onto the 1.13.13 base without the
full migration. Adds the package_name_regex rule item (regex match over
ProcessInfo.AndroidPackageNames) to route, DNS and headless rules.

- new route/rule/rule_item_package_name_regex.go (verbatim upstream) + unit test
- PackageNameRegex option field in RawDefaultRule/RawDefaultDNSRule/DefaultHeadlessRule
- item registration in NewDefault{,DNS,Headless}Rule with E.Cause(err, package_name_regex)
- package_name_regex added to isProcess{,DNS,Headless}Rule conds

The commit's RuleSetVersion5 hunk is intentionally NOT ported (that is 1.14
rule-set v5, unrelated; base is RuleSetVersion4). Full 1.14 migration deferred
to v1.14.0 stable. SPEC 013 + Roadmap entry.

builds (no-tags + lx-tags), go vet, gofmt and rule tests all green.
2026-06-23 01:32:46 +03:00
Leadaxe 09944c0103 docs(specs): drop type/status from folder names — header + Roadmap only
Folder names were NNN-T-S-NAME, so the status (S) letter forced a rename on
every status change — and refs to the full name went stale each time. One was
already broken in-tree (client.go pointed at 002-F-O-… while the folder was
002-F-C), and the submodule needed a cosmetic commit once already (010 O→C).

Make the number the only stable anchor:
- Rename all 12 folders NNN-T-S-NAME → NNN-NAME (git mv, history preserved).
- Type/status now live in a table header at the top of each SPEC.md (canon),
  aggregated by the Roadmap in SPECS/README.md (added missing 010, 012).
- Fix every ref to the old full name: README(.ru), docs/lx-changelog,
  docs/lx-config, intra-SPECS cross-links, PROBE.md git-apply path,
  TASKS.md titles, and transport/v2rayxhttp/client.go:5 (also un-stales O).
- Rewrite the convention + Workflow in SPECS/README.md and the DoD ritual in
  IMPLEMENTATION_PROMPT.md ("rename folder to …-C-…" → "set status in header
  + Roadmap").
- Bump submodule wireguard-go (0c0c10b): fix comments point at the new folder
  name SPECS/010-WG_ENDPOINT_GRO_SPLIT_BRAIN. Comment-only, no behavior change.

Not touched: gro-probe.patch (historical diagnostic diff artifact; §010 closed,
no longer applied).
2026-06-22 11:36:37 +03:00
Leadaxe 78fc9189e6 docs(012): correct closure — §010 is UDP/WG-only, can't explain non-WG nodes
The symptom was seen on DIFFERENT nodes including WG, so "↑/↓0 download stall" is
an umbrella over the symptom, not one bug. Correcting the overconfident closure:

- §010 GRO fix lives entirely in the wireguard-go submodule (imported only by
  transport/wireguard/) and gates UDP_GRO — it physically cannot affect VLESS/
  reality (TCP) nodes. It closes the WG share of the symptom only.
- In the lx.12→lx.14 window, route/conn.go (shared relay) and the sing copy path
  were unchanged; the only non-WG-relevant change is §011 (xhttp stream-one), which
  applies only if the node uses xhttp. For VLESS+reality-direct there is NO code
  change that explains the disappearance.
- "also hangs on VLESS" was never strictly confirmed (wlan0 encrypted), so the
  non-WG share has no confirmed root cause — it currently just doesn't reproduce.

Status stays C (not reproducible). Refs SPECS/012.
2026-06-22 11:15:11 +03:00
Leadaxe e652d95dd9 docs(012): close — not reproducible on lx.14 (likely fixed by §010 GRO)
On the same exit node (VLESS NL 154.83.159.64:8443) and same network where the
baseline zombie download-stall was caught, the bug no longer reproduces on core
1.13.13-lx.14 — neither normally nor under the probe with LX_CONN_TRACE=0 (code =
release, env set via Android wrap.<pkg> prop, verified in /proc/<pid>/environ).

Likely cause: the §010 GRO split-brain fix landed in lx.14 (lx.12→lx.14 bumped the
wireguard-go submodule 27290b6d→6513629). §010 was literally "no-detour WG-endpoint
killed download on android" via receive-side coalescing — the same symptom class.
The original "also hangs on VLESS" note (→ "not a §010 dup") was never strictly
confirmed (wlan0 encrypted, core-log silent on direction), so §012 is most likely
a manifestation of §010 rather than a separate VLESS bug.

Honest caveats: the counter-proof (run baseline on a pre-lx.14 core on the same
node) was not done; the bug was intermittent. Status is "not reproducible", not
"root cause proven". Folder renamed 012-B-O → 012-B-C.

Probe stays as history on branch lx-conn-trace-probe (b6d8c40a, not deleted) — if
the symptom returns, activate via wrap.<pkg> LX_CONN_TRACE=… per RUN-PLAN.md, but
first make the wrapper transparent (it currently silences ReadWaiter/copyDirect on
reality-download and could mask the bug).

Closes SPECS/012.
2026-06-22 11:08:48 +03:00
Leadaxe 12ef3404a0 docs(012): TCP download stall on zombie conns — investigation + probe spec
Synchronous dual tcpdump (tun0+wlan0, single phone clock, from SYN) localized the
stall inside the kernel on the download direction (remoteConn→conn): 777B arrived
on wlan0 but never reached the app on tun0. Root cause not yet confirmed from
inside the kernel — pcap + code-reading only.

SPEC documents symptom, the synchronous-pcap proof, ruled-out causes (MSS, exit
protocol, server/edge/node, RST storm, §010 GRO), and strictness caveats. Adds
22.06 corrections that retire false leads: conn.go:262 pointed at the UDP
canceler not the copy; run_core.log was a launcher error (no macOS `timeout`),
not a kernel log; the device ran the release core without lx changes (the
LX_TCP_RESPONSE_TIMEOUT prototype was never on it).

PROBE.md describes the LX_CONN_TRACE probe (byte read/write counters per copy
direction, periodic tick + final snapshot) that splits the fork: read=0 → above
copy (proxy decrypt); read>0,write=0 → tun write stall. instrumentation.patch is
the self-contained probe diff; RUN-PLAN.md is the on-device run procedure.

Probe code lands on a separate branch (lx-conn-trace-probe) for CI builds, not on
lx — it forces the buffered copy path (loses splice) and is diagnostic-only.

Refs SPECS/012 (status O — probe written, not yet run on device).
2026-06-22 05:08:43 +03:00
Leadaxe 4d4027e4f4 fix(010): WG-endpoint GRO split-brain on android (bump submodule + spec)
No-detour WireGuard-endpoint killed download on android: UDP_GRO was enabled and
rxOffload read true, but the GRO receive dispatcher in bind_std.go is gated on
GOOS=="linux" (android is not "linux") → a coalesced super-packet was read as one
datagram and corrupted the WG stream. Gate UDP_GRO + rxOffload behind !android
(TX/GSO untouched; non-android linux unchanged).

Confirmed on device (CPH2411/Android-15): pre-fix probe rxoffload=true+dispatch=
single; post-fix rxoffload=false, download 0.44→20.7 Mbps, on par with a control
node on the same LTE cell. Candidate #2 (silent handover) not needed.

Bumps wireguard-go submodule pin to 6513629 (fix, no probe). Probe instrumentation
was never on lx — it lived only on the temporary gro-probe-010/*-verify branches.

Closes SPECS/010.
2026-06-21 17:53:26 +03:00
Leadaxe 10d12e21a7 lx(xhttp): close 011 on synthetic validation (live deferred)
No reality+xhttp node available; per owner decision the fix is accepted on
synthetic evidence (line-by-line Xray contract match, issue #5635, hiddify
parity, green unit tests + check + builds). Live against a real Xray server
remains an open TODO documented in the 011 REPORT — re-open if it diverges.

- SPECS/011 → status C (folder 011-B-C); REPORT carries an honest live caveat.
- 002 REPORT: stream-one marked fixed-by-011 (was 'known bug').
- SPECS/README roadmap: add 011 row, update 002.

Branch lx-xhttp-streamone; NOT merged into lx.
2026-06-21 15:28:18 +03:00
Leadaxe 4df8cf1977 lx(xhttp): tests + auto-reality check config + SPEC 011
- url_test.go: stream-one→bare /xhttp; stream-up/packet-up keep sessionId/seq.
- reality_detect_test.go: reality/ktls(reality)→true; utls/std/nil→false.
- lx-test/config/xhttp_auto_reality.json: mode=auto reality config for check.
- SPECS/011-B-W: spec/plan/tasks/report (status W — awaiting live validation).
2026-06-21 15:17:34 +03:00
Leadaxe f2654e6af2 lx(xhttp): fix stream-one downlink (bare path, no sessionId) + auto→stream-one on reality
stream-one was sending <path>/<sessionId>; Xray's splithttp server routes the
bidirectional stream-one handler only on an empty sessionId, so the request must
target the bare normalized path. With the sessionId present the server took the
stream-down branch and the response body carried non-VLESS bytes → VLESS
'unknown version'. Now dialStreamOne uses requestURL() (bare <path>, no trailing
slash); stream-up/packet-up keep their sessionId/seq.

mode=auto now mirrors Xray: reality → stream-one, otherwise packet-up. Reality is
detected by runtime type name (reality_detect.go) with kTLS unwrapping, avoiding a
with_xhttp→with_utls compile dependency so with_xhttp builds without with_utls.

Refs SPECS/011. Synthetic-validated; live pending.
2026-06-21 15:17:12 +03:00
Leadaxe 58820797ee ci(lx): on-demand build workflow (target + branch inputs)
Manual workflow_dispatch builder for any branch/tag: target ∈
{android-aar, apple-xcframework, binary, linux-musl, all}, branch = any ref.
Mirrors lx-release build jobs but uploads artifacts instead of releasing.
Lives on lx (default branch) so `gh workflow run` can find it; each job
checks out the requested branch, so lx source is never required to build it.

  gh workflow run lx-build.yml -f target=android-aar -f branch=<branch>

No project code touched — CI file only.
2026-06-21 11:22:46 +03:00
世界 f27d0e38f4 Bump version 2026-06-21 12:16:32 +08:00
世界 cb70a068c5 Add iOS jailbreak release 2026-06-21 12:16:31 +08:00
世界 cf60f76562 Fix Cloudflared edge discovery ignoring configured resolver 2026-06-21 00:13:11 +08:00
世界 5f6e5a8ed0 documentation: Add USB/IP server and client 2026-06-20 22:25:00 +08:00
世界 38786cf640 Add USB/IP support for macOS 2026-06-20 22:25:00 +08:00
世界 0c7707ca9e Add USB/IP service 2026-06-20 22:24:55 +08:00
世界 e4e8af4755 Fix remote control when Clash server is unavailable 2026-06-20 22:24:49 +08:00
世界 79ee5b3cc0 Add dashboard support for API service 2026-06-20 22:24:49 +08:00
世界 a14fd97b5c Improve remote rule-set update 2026-06-20 22:24:43 +08:00
世界 a3e3f61920 dns: Remove unused files 2026-06-20 22:24:43 +08:00
世界 72482de3d2 release: Fix apple release 2026-06-20 22:24:43 +08:00
世界 f0f2617b79 Fix group status updates broken by API service
The URL test history update hook and the Clash mode update hook were
single-slot: the API service's attached service overwrote the hook set
by the daemon, so clients stopped receiving group updates. Replace both
with multicast hook lists.

Also share a single URL test history storage via context: Clash API
looked it up under a key nobody registered and fell back to its own
empty storage, so dashboards showed no delay once an API service was
configured. Selector changes now notify through the shared storage,
covering selections made from any API surface.
2026-06-20 22:24:42 +08:00
世界 6a351d3366 Update Go to 1.25.11 2026-06-20 22:24:42 +08:00
世界 7b07703b1c tailscale: Fix auth URL not refreshed after logout 2026-06-20 22:24:42 +08:00
世界 3a5d654463 Add sing-box API service 2026-06-20 22:24:42 +08:00
世界 1ee3596db9 daemon: Split host operations into ManagedService 2026-06-20 22:23:46 +08:00
世界 65069bdaef platform: Add shell support for iOS 2026-06-20 22:23:45 +08:00
世界 09ff1a767c platform: Add tailscale device name and logout 2026-06-20 22:23:45 +08:00
世界 36c8786df1 tailssh: fix platform SFTP session teardown 2026-06-20 22:23:45 +08:00
世界 8ba1057c10 tailscale: Add tailssh server 2026-06-20 22:23:45 +08:00
世界 dfed1b2e6a tools: Fix mising cleanup 2026-06-20 22:23:44 +08:00
世界 940fb6d8c7 hysteria2: Add gecko obfs 2026-06-20 22:23:44 +08:00
世界 f5e118c3b4 daemon: Add Tailssh 2026-06-20 22:23:33 +08:00
世界 7ac2cac7ae sing: Fix comment loop 2026-06-20 22:23:32 +08:00
世界 369c428424 Fix tailscale dns 2026-06-20 22:23:32 +08:00
世界 0403ae6c92 tailscale: Expose more peer info fields 2026-06-20 22:23:31 +08:00
世界 a3620561cc tailscale: Add runtime exit node API 2026-06-20 22:23:31 +08:00
世界 4559faaaa4 Fix tailscale 2026-06-20 22:23:31 +08:00
世界 6acb02c0fd realm: Open separate v4 and v6 packet conns on client 2026-06-20 22:23:31 +08:00
世界 a2894ffb31 gvisor: Fix dialing to self addresses 2026-06-20 22:23:19 +08:00
世界 67c7243a92 tailscale: Fix handle peer DNS query 2026-06-20 22:23:19 +08:00
世界 f29bdd26e4 tailscale: Revert dialer deprecation and remove control_http_client 2026-06-20 22:23:19 +08:00
世界 c31db962ee process: Fix panic when package manager is unavailable on Android 2026-06-20 22:23:19 +08:00
世界 273b6ca388 route: Refetch rule-set when cache restore fails 2026-06-20 22:23:19 +08:00
世界 423e97bc1c oom-killer: Remove log "OOM draft discarded" 2026-06-20 22:23:19 +08:00
世界 2ede989dff Fix shadowtls handshake 2026-06-20 22:23:10 +08:00
世界 3bb9fe630e Rebase wireguard-go to official 2026-06-20 22:23:02 +08:00
世界 2200e13ade dns: Fix DHCP reset 2026-06-20 22:23:02 +08:00
世界 806da5f845 Fix lint errors 2026-06-20 22:23:01 +08:00
世界 981393d256 Fix hysteria2 realm server 2026-06-20 22:23:01 +08:00
世界 22cae36fc6 realm: Add stun retry and lazy server start 2026-06-20 22:23:01 +08:00
世界 5b412f19e4 Fix TLS server close 2026-06-20 22:22:52 +08:00
世界 4f69e5f067 Update hysteria2 realm 2026-06-20 22:22:52 +08:00
世界 d776e2db4f Add hysteria2 realm service and support 2026-06-20 22:22:43 +08:00
世界 cfb2f8b652 Fix reset network 2026-06-20 22:22:34 +08:00
macronut 3609cf660f Add more spoof method
Signed-off-by: macronut <4027187+macronut@users.noreply.github.com>
2026-06-20 22:22:33 +08:00
世界 6e7301dd61 Allow customizing TUN DNS mode and hijack interface DNS by default 2026-06-20 22:22:33 +08:00
世界 a33c92a2de dns: Add mDNS server 2026-06-20 22:22:32 +08:00
世界 a553f3302f dns: Add preferred_by rule item 2026-06-20 22:22:32 +08:00
世界 594e6aaff6 dns: Add neighbor-based hostname resolution to local server 2026-06-20 22:22:31 +08:00
世界 5386bbf63e Fix tailscale start dependencies 2026-06-20 22:22:30 +08:00
世界 d880835ac4 dns: Add timeout configuration 2026-06-20 22:22:30 +08:00
世界 3a5304b910 ssh: Add cipher, MAC, and key exchange configuration 2026-06-20 22:22:29 +08:00
世界 4bf782a768 Improve oom-killer 2026-06-20 22:22:29 +08:00
世界 dbe4b5de60 Preserve comments between formatting 2026-06-20 22:22:28 +08:00
nekohasekai 1226d744b7 Add Windows TLS engine 2026-06-20 22:22:28 +08:00
世界 1c3a335d99 Improve UDP batch support 2026-06-20 22:22:28 +08:00
世界 80f9aeddae Fix stderr deprecated manager 2026-06-20 22:22:28 +08:00
世界 a1372b5922 Fix darwin cgo DNS again 2026-06-20 22:22:28 +08:00
世界 88fa70b9e8 platform: Improve oom-killer 2026-06-20 22:22:27 +08:00
世界 715d07651e Fix ACME HTTP-01 challenge for IPv6 literal addresses 2026-06-20 22:22:27 +08:00
世界 bfd9f9ab03 Add ACME profile support for IP address certificates 2026-06-20 22:22:27 +08:00
世界 c5e37e69ce Fix goroutine leak in networkquality tool
Serialize probe rounds in startProber to eliminate unbounded fan-out of
fire-and-forget probe goroutines (up to 100/sec per direction), and close
HTTP/3 transports via transport.Close() in addition to CloseIdleConnections.
2026-06-20 22:22:26 +08:00
世界 71fd71729a Fix Tailscale search domain response name mismatch 2026-06-20 22:22:26 +08:00
世界 bc0ae035ad Log DNS optimistic background refresh outcomes 2026-06-20 22:22:26 +08:00
世界 39b8d37ecf Add search domain support for Tailscale DNS 2026-06-20 22:22:26 +08:00
世界 82d49e8250 Fix tls-spoof 2026-06-20 22:22:25 +08:00
世界 7b7d95a1da Fix Apple TLS metadata capture 2026-06-20 22:22:25 +08:00
世界 098849e481 Strip EDNS padding from upstream DNS responses 2026-06-20 22:22:25 +08:00
世界 380845ad9e Defer implicit default HTTP client fallback to first use 2026-06-20 22:22:25 +08:00
世界 e6f0d1f704 Scope HTTP/2 fallback and HTTP/3 broken state per authority 2026-06-20 22:22:24 +08:00
世界 23046880db Fix macOS tlsspoof 2026-06-20 22:22:24 +08:00
世界 8020972c9d Reject IP literal server name with TLS spoof 2026-06-20 22:22:24 +08:00
世界 8272845859 Fix use-after-free of pooled value buffers in bbolt Batch writes 2026-06-20 22:22:23 +08:00
世界 e8b53265d4 Reject pure-IP rule-set references without match_response
DNS rules referencing rule-sets that contain only ip_cidr predicates
silently stopped matching when legacy DNS mode was disabled, because the
IP-CIDR branch cannot match against an in-flight DNS query. The existing
validation intentionally let every rule_set through on the premise that
mixed sets still work via their non-IP branches, which is only true when
such a branch exists. Track whether a rule-set carries any non-IP-CIDR
predicate and reject pure-IP references the same way bare ip_cidr fields
are already rejected.
2026-06-20 22:22:23 +08:00
世界 0cac33ceaf Fix legacy rule-set download_detour blocked by empty direct check 2026-06-20 22:22:22 +08:00
世界 98b0d7eda3 Add TLS spoof support 2026-06-20 22:22:22 +08:00
世界 2ad83c52ae Standardize hosts path 2026-06-20 22:22:22 +08:00
世界 716541b61c Refactor: HTTP clients, unified HTTP2/QUIC options, Apple engines 2026-06-20 22:22:22 +08:00
世界 f7b8eed2e4 oom-killer: Record report before reset network 2026-06-20 22:22:13 +08:00
世界 997a64e014 Add optimistic DNS cache 2026-06-20 22:22:05 +08:00
世界 56d0b40020 Fix tailscale error 2026-06-20 22:21:10 +08:00
世界 6926e5178c Fix darwin cgo DNS again 2026-06-20 22:21:10 +08:00
世界 798066d65e Fix stun test 2026-06-20 22:21:10 +08:00
世界 87d3e177e5 documentation: Fix missing update for ip_version and query_type 2026-06-20 22:21:10 +08:00
世界 31bbb536b4 Add cloudflared inbound 2026-06-20 22:21:09 +08:00
世界 58706b0aef Fix lint errors 2026-06-20 22:20:58 +08:00
世界 8b653b9461 platform: Wrap command RPC error returns with E.Cause 2026-06-20 22:20:58 +08:00
世界 941ce58b8e Add package_name_regex route, DNS and headless rule item 2026-06-20 22:20:58 +08:00
世界 d92729daa1 documentation: Fixes 2026-06-20 22:20:57 +08:00
世界 3a307e71f7 Un-deprecate ip_accept_any DNS rule item 2026-06-20 22:20:57 +08:00
世界 674752a0d8 tools: Tailscale status 2026-06-20 22:20:57 +08:00
世界 dd10328e78 Fix darwin local DNS transport 2026-06-20 22:20:33 +08:00
世界 c927896818 Fix rules lock 2026-06-20 22:20:33 +08:00
世界 d403880de3 Revert "Also enable certificate store by default on Apple platforms"
This reverts commit 62cb06c02fc569beb7b2ffa3f0a10ef23e136748.
2026-06-20 22:20:33 +08:00
世界 4074c3e7ec tools: Tailscale status 2026-06-20 22:20:33 +08:00
世界 601608666a platform: Fix darwin signal handler 2026-06-20 22:20:33 +08:00
世界 5aba37bd35 tools: Network Quality & STUN 2026-06-20 22:20:32 +08:00
世界 a393f3e24c oom-killer: Free memory on pressure notification and use gradual interval backoff 2026-06-20 22:20:32 +08:00
世界 087060f14e Fix deprecated warning double-formatting on localized clients 2026-06-20 22:20:32 +08:00
世界 d10dd4c2f9 platform: Fix set local 2026-06-20 22:20:32 +08:00
nekohasekai 31bea69a23 Add evaluate DNS rule action and related rule items 2026-06-20 22:20:31 +08:00
世界 46b86d1c56 Also enable certificate store by default on Apple platforms
`SecTrustEvaluateWithError` is serial
2026-06-20 22:20:31 +08:00
世界 98b2ecd7e0 platform: Add OOM Report & Crash Report 2026-06-20 22:20:31 +08:00
世界 5692f638e6 Add BBR profile and hop interval randomization for Hysteria2 2026-06-20 22:20:31 +08:00
nekohasekai 3b36eeab84 Refactor ACME support to certificate provider 2026-06-20 22:20:22 +08:00
世界 5b8bb39122 documentation: Update descriptions for neighbor rules 2026-06-20 22:20:22 +08:00
世界 14e1e7e95e Add macOS support for MAC and hostname rule items 2026-06-20 22:20:22 +08:00
世界 05e226a1d9 Add Android support for MAC and hostname rule items 2026-06-20 22:20:22 +08:00
世界 ff9fced367 Add MAC and hostname rule items 2026-06-20 22:20:17 +08:00
世界 0b7ffbaafa tun: Fix system stack TCP NAT collision & route address set polluted by appended default element 2026-06-20 22:17:44 +08:00
世界 6bae28b771 cronet: Fix arm build 2026-06-20 22:16:37 +08:00
世界 72a8723e13 Fix DNS query loopback deadlock from shared dedup lock 2026-06-20 20:36:19 +08:00
世界 4e1af0600b Fix UDP sniff fragment timeout treated as fatal error 2026-06-20 19:45:36 +08:00
世界 6397675574 Fix clash log API not respecting subscribed level 2026-06-20 19:27:17 +08:00
世界 7d847c0583 Fix check vless packet encoding 2026-06-19 23:27:29 +08:00
世界 8f006316b2 Fix sing-mux udp write 2026-06-19 21:56:40 +08:00
世界 627912a788 Fix naive inbound QUIC ALPN race condition 2026-06-19 20:57:18 +08:00
Leadaxe 37e0e8511d docs(lx-changelog): v1.13.13-lx.13 (ib uTLS fingerprint, quic single Initial, dns optional id, sip dialog) 2026-06-19 00:42:13 +03:00
Leadaxe 043588ea25 lx(awg): quic single Initial (drop i1+i2); sip INVITE+100 Trying dialog (009)
QUIC — revert to ONE Initial. The earlier i1+i2 "developing session" was
conceptually wrong: each DCID is a distinct QUIC connection, so two
Initials with different DCIDs read as two ABANDONED connections (more
anomalous to a DCID-tracking DPI, not less), and a real same-DCID
continuation is impossible (short header is device-blocked; a 1-RTT
packet before the server's reply is an invalid QUIC state). So ip=quic
now emits a single fragmented Initial; realism comes from the
browser-accurate ClientHello (ib → uTLS, device-confirmed working), not
from packet count. masqueI1I2 quic branch returns i2=""; the dead
masqueQUICSecondInitialCPS is removed; the i2-conflict guard is now
sip-only.

SIP — INVITE (i1) + matching 100 Trying (i2), one dialog (separate work):
both whole valid SIP messages sharing Via branch / From tag / Call-ID /
CSeq from a single newSIPDialog pass; pseudo user/host names. (Device
result: still times out on the WARP DPI — see memory; kept for other
providers.)

Both build tags (with_utls / no-utls) build & test green; gofmt/vet clean;
lx-build ok. Docs: SPEC §9 rewritten (multi-packet QUIC considered &
rejected), §10 scope, IMPLEMENTATION_REPORT R11 marked rejected; tests
updated (TestAwgIpcLinesQUICSingleInitial, NonSIPNoI2, SIPExplicitI2Conflict).
2026-06-18 04:53:09 +03:00
Leadaxe 2086aeb4be lx(awg): ib drives real browser JA3 via uTLS for ip=quic (009)
The Ib hint finally affects the wire: ip=quic + ib=chrome|firefox builds
the ClientHello with uTLS (github.com/metacubex/utls, the same lib Reality
uses) so the decoy carries a genuine browser JA3/JA4 instead of our
generic ClientHello.

- buildClientHello is now a dispatcher: ib=""/curl → buildGenericClientHello
  (the ~294B device-proven CH, unchanged default; uTLS has no curl-QUIC fp),
  ib=chrome/firefox → buildBrowserClientHello.
- quic_clienthello_utls_awg.go (with_awg && with_utls): UQUICClient in QUIC
  mode with HelloChrome_120 / HelloFirefox_120, ALPN forced to h3, and the
  PQ hybrid key_share (X25519MLKEM768, ~1.2KB) stripped so the CH fits one
  Initial (reality_client.go pattern). TLSVersMin/Max pinned to 1.3 (QUIC
  requirement). Result ~510-620B — a real late-2023 browser JA3.
- quic_clienthello_utls_stub_awg.go (with_awg && !with_utls): graceful
  fallback to the generic CH when uTLS isn't built.
- The larger CH re-shapes fragmentation, but planFragmentsN cuts any length
  and I1–I4 hold (verified). i2 (multi-packet) uses the same browser too.

WHY ib is optional / forward-looking: on the target DPI ip=quic already
passes on fragmentation alone (no fingerprint check), so the default ib=""
keeps the device-proven generic path; the uTLS CH is a knob against a
future JA3/JA4-classifying DPI and is not itself device-verified. Honest
caveats: JA3 matches a pre-PQ browser (no MLKEM key_share), and JA4 (sorts
+ ignores GREASE) is not fooled by it.

Test (quic_clienthello_utls_awg_test.go, with_utls): chrome/firefox yield
distinct larger ClientHellos that still decrypt + carry SNI + offset≠0
(I1); chrome has GREASE ciphers, firefox doesn't; ""/curl stay generic.
Both tag combos build & test green; gofmt/vet clean; lx-build ok;
sing-box check passes for ib=chrome. SPEC §4/§6/§7 + IMPLEMENTATION_REPORT
R12 + TASKS updated.
2026-06-18 03:38:01 +03:00
Leadaxe 7fab15f1fa lx(awg): make id optional for ip=dns (generated pseudo-domain)
ip=dns no longer requires id: when absent, the QNAME is a generated
pronounceable pseudo-domain — consistent with ip=sip's pseudo-host
fallback, and it removes the hardcoded-default-beacon problem (every
default user would otherwise share one QNAME).

- pgDomainHost (pseudo_gen_awg.go): domain-only pseudo name (2-/3-level
  LDH), NEVER an IP or a "sip." subdomain — a DNS query for a bare IP or
  a sip-prefixed name is implausible, unlike pgHost (which sip uses and
  where an IP host is fine). Per-build (baked into the <b> blob), not
  per-packet: fresh between users/regenerations, removing the cross-user
  signature; the QNAME is fixed within one node's packets (CPS can't do a
  pronounceable variable-length name per packet).
- masque_awg.go dispatch: ip=dns with empty id → pgDomainHost(); a set id
  is still LDH-validated. id is now REQUIRED only for quic (SNI).

Tests: TestMasqueI1DomainRequiredForQUICOnly (only quic errors on empty
id); TestMasqueI1DomainOptionalForNonQUIC now also checks dns-without-id
produces a valid query whose QNAME is a multi-label pseudo-domain (no IP).
Docs (SPEC/EXAMPLES/IMPLEMENTATION_REPORT/TASKS/README/lx-config): id
required only for quic. sing-box check: ip=dns without id now passes.
2026-06-18 02:24:12 +03:00
Leadaxe e964bc0bf7 docs(009): drop internal/AI-voice phrasing from spec & comments
Rephrase imperative/self-directed notes into neutral prose without changing
any technical claim:

- SPEC.md §4: "Заявлять JA3-имитацию запрещено" → "JA3-имитации в этом
  профиле нет".
- SPEC.md §8: "держать как непроверенное условие" / "нельзя заявлять как
  факт" / "не выдаём за установленный факт" / "не дублируем" → plain
  statements ("условие, а не факт"; "остаётся гипотезой"; cross-reference).
- SPEC.md §9: "(ортогональность — verified)" heading and "воскрешение
  сожжённого байта" → neutral wording.
- Go comments: "HONEST STATUS" → "Device status"; "HONESTY NOTE:" → "Note:".
- lx-config.md: "Notes & honest limitations" → "Notes & limitations".

Wording only; no code, no technical content change. Tests/gofmt green.
2026-06-18 01:59:23 +03:00
Leadaxe b600698b03 docs: refresh project docs for lx.12 (i1+i2, device results, lx-changelog)
Bring the user-facing docs in line with the as-built 009 masquerade after
the lx.12 release:

- README.md / README.ru.md: feature table + masquerade section rewritten —
  quic is the only device-proven profile on a real LTE/WARP DPI (~330 ms),
  now multi-packet (i1+i2) with a randomized per-call layout; dns/stun/sip
  are correct client-initiated requests but blocked as a protocol class to
  the WARP edge (kept for other providers). id required for quic/dns only.
- docs/lx-config.md: quic = i1+i2 + randomized; stun = Binding Request (was
  "Binding Success Response"); sip = INVITE+SDP (was "200 OK response");
  profiles framed as client-initiated, not WireSock server responses.
- docs/lx-changelog.md (new): fork changelog (lx.11, lx.12). Kept separate
  from upstream changelog.md so a rebase onto upstream stays conflict-free.

Docs only.
2026-06-18 01:55:58 +03:00
Leadaxe ed46376181 lx(awg): multi-packet QUIC — ip=quic fills i1+i2 (009)
ip=quic now emits TWO independent fragmented QUIC Initials (i1 + i2), so
the decoy flow reads as a developing QUIC session (two session starts)
instead of a single opener — lowering the single-packet signature.
Device-verified: an explicit i1+i2 config brings the WARP tunnel up with
NO latency regression vs i1-only (~340ms), confirming the multi-packet
form is safe for the handshake.

- masqueQUICSecondInitialCPS (quic_initial_awg.go): a second full
  fragmented Initial with its OWN fresh DCID. NOT a short-header (that
  was device-blocked, commit 64ce4a47) and NOT a DCID-reuse 1-RTT (an
  impossible QUIC state that reads anomalous) — two independent Initials
  just look like two QUIC sessions starting, which a browser does
  routinely.
- masqueI2 (masque_awg.go): dispatch — only ip=quic fills i2; dns/stun/sip
  return "" (single-packet decoys).
- awgIpcLines (device_awg.go): wires masque i2 into the i2 slot; guards an
  explicit user i2 alongside id/ip/ib as a conflict (mirrors the i1 guard).
  Safe by construction: i1/i2 are separate UDP datagrams sent before the
  independently-built MessageInitiation (send.go), so neither touches the
  real handshake.

Tests (device_awg_test.go): ip=quic fills both i1 and i2 as valid
independent Initials (different DCID, both carry the SNI, first CRYPTO
offset≠0); non-quic leaves i2 empty; explicit-i2 conflict rejected.
Spec §9 updated from hypothesis to implemented + device-verified; §9.2
residual risks (retry head-of-line budget — i3..i5 kept empty), §9.3
what's verified vs deferred. IMPLEMENTATION_REPORT R11 added.

Full package green; gofmt/vet clean; lx-build ok; sing-box check passes
for ip=quic and rejects explicit-i2 conflict.
2026-06-18 01:38:30 +03:00
Leadaxe 8a389007cd docs(009): active-probing + multi-packet QUIC hypotheses; rename §146→009
Add two analysis sections to the 009 spec and normalize naming to 009
(the feature lives here; the LxBox task 146 is only the upstream source
of requirements/device facts, not "our" number).

SPEC.md:
- §8 "Active probing — граница односторонней маскировки (гипотезы)":
  H3 (high confidence) a one-sided client decoy is only as strong as
  what the TARGET SERVER genuinely serves on that port; the dns/stun/sip
  timeouts are consistent with three DPI models (passive
  destination-reputation / protocol allowlist / active probing) — H1/H2
  (active-probe wording) are medium-confidence, with the honest caveat
  that ":2408 answers QUIC" is unverified (it's the WG port, not :443).
  Falsifiable device tests T1–T3 (incl. T3: point ip=quic at a
  non-QUIC-serving host → should time out, isolating the borrowed
  responder from the QUIC bytes).
- §9 "Многопакетная QUIC-последовательность (гипотеза усиления)":
  i1..i5 multi-packet design, with a line-by-line send.go proof it CANNOT
  break the WARP handshake (decoys are separate UDP datagrams before the
  independently-built MessageInitiation). Flags the critical regression
  trap: a short-header i2 is exactly the construct commit 64ce4a47
  deleted as device-blocked; DCID-reuse is likely a fingerprint, not a
  win; retry amplifies head-of-line bytes. Hypothesis to device-test
  (bar: i2 must be no worse than i1-only), NOT a shipping decision.

Naming: drop "§146 §N" cross-refs to the LxBox spec from kernel comments
(they don't resolve inside 009); keep the two honest external source
refs (the task file path + "источник device-фактов — LxBox-задача 146").
Also refresh the masque_awg.go header (profiles are now Initial / query /
Binding Request / INVITE, not the old short-header/response list).

Docs only; no code-logic change (build/tests/gofmt/vet green).
2026-06-18 01:22:16 +03:00
Leadaxe 3750ca9052 lx(awg): SIP INVITE request with SDP instead of 200 OK response (146)
ip=sip emitted a `SIP/2.0 200 OK` response as the client's first,
unsolicited packet — a server-role packet in the client's slot, the same
wrong-direction anomaly the old STUN/DNS profiles had, and it was missing
the Contact/Max-Forwards a flow opener needs. Replace it with a SIP
INVITE request carrying an SDP offer — what a UA legitimately sends first
to start a call.

New sip_invite_awg.go (masqueSIPInviteCPS):
- request-line INVITE sip:<user>@<host> SIP/2.0 (method, not a status).
- Via(branch=z9hG4bK)/Max-Forwards:70/From(tag)/To(no tag yet)/Call-ID/
  CSeq:N INVITE/Contact, Content-Type: application/sdp, exact
  Content-Length, SDP body (v=0, m=audio, rtpmap PCMU/PCMA/telephone-event).
- Hybrid randomization (no cross-user signature): pronounceable user names
  and (when id is empty) the host come from PseudoGen and are baked into
  <b> at build time (unique between users); volatile tokens (branch /
  From-tag / Call-ID / CSeq / SDP session-id+version) are per-packet
  <rc>/<rd> of fixed width, so Content-Length stays exact.

New pseudo_gen_awg.go: pronounceable pseudo names / hosts / public IPs
(ported from the LxBox PseudoGen §127) — plausible without being a
hardcoded RFC beacon (bob@biloxi.com) or obvious garbage, and never a
private IP. crypto/rand, not seeded.

id is now OPTIONAL for sip (empty → pgHost()); required only for quic
(SNI) and dns (QNAME); stun ignores it. Removed masqueSIPResponseCPS.

HONEST STATUS: not device-tested on WARP, but expected to time out like
dns/stun — SIP to the datacenter WARP edge :2408 is the same
destination-class anomaly (SIP lives on :5060 / a SIP server). The INVITE
form fixes the direction anomaly of the old 200 OK but not the
destination one. QUIC remains the only proven WARP mechanism; sip is the
strictly-better, direction-clean form kept for other providers.

Tests: TestMasqueSIPResponseStructure → TestMasqueSIPInviteStructure +
TestMasqueSIPInviteNoID (request-line, To-without-tag, exact
Content-Length, names not hardcoded, no-id → pseudo-host). Validation
test updated (id required for quic/dns only). Docs updated.
2026-06-18 01:05:05 +03:00
Leadaxe 481bdfe093 lx(awg): DNS query instead of response for ip=dns (146)
ip=dns emitted an EDNS OPT *response* (QR=1) as the client's first,
unsolicited packet — a wrong-direction anomaly (a response is a
server-role packet), the same defect STUN had. Replace it with a client
DNS *query* (QR=0, QTYPE HTTPS/65): what a client legitimately sends
first. Only two wire changes from the old code — FLAGS 0x8180→0x0100 and
QTYPE 0x0001→0x0041 — everything else (encodeDNSName, OPT RR, 0xFDE9
cover option) reused. Renamed masqueDNSResponseCPS → masqueDNSQueryCPS;
TXID/cover stay fresh per packet (<r 2>/<r 40>).

DEVICE RESULT (honest): the DNS query also TIMED OUT on the target
LTE/WARP DPI, as the design predicted. Confirms the fundamental finding:
packet quality and direction (request vs response) are secondary — the
blocker is the (protocol + destination) pair. The DPI cuts DNS/STUN/SIP
to the WARP edge 162.159.x:2408 as a protocol class, because raw
DNS/STUN/SIP to a datacenter IP is itself anomalous (DNS lives on :53, a
resolver — not a datacenter edge). QUIC alone bypasses the destination
check: QUIC/HTTP3 legitimately goes anywhere (the whole HTTP/3 web), so
QUIC to a Cloudflare IP is expected traffic.

So QUIC remains the only proven mechanism on this provider. The DNS query
is committed as the strictly-better (direction-correct, RFC-clean) form
and kept — like stun/sip — for other providers whose DPI only checks
well-formedness, NOT protocol-to-destination. Marked not-confirmed-on-WARP
in SPEC / EXAMPLES / IMPLEMENTATION_REPORT / lx-config.

Test: TestMasqueDNSResponseStructure → TestMasqueDNSQueryStructure
(QR=0, QNAME round-trips, QTYPE HTTPS, OPT to end). Docs updated.
2026-06-18 00:44:01 +03:00
Leadaxe 5c550322a8 lx(awg): randomize QUIC layout + robustness knobs; STUN request (146)
QUIC (the proven mechanism) hardened, and the dud STUN profile rebuilt
as the strongest possible shape for other providers — guided by device
A/B on the target LTE/WARP DPI (only QUIC passes there; STUN is blocked
as a protocol class regardless of packet quality).

QUIC — randomize the fragment layout per call + robustness knobs:
- planFragmentsN: random cut points (was the fixed etalon offsets).
- randomizedWirePlan: random out-of-order CRYPTO permutation, repaired so
  the offset-0 fragment is never first; PING/PADDING woven into random
  gaps; one flex PADDING run pins the payload to the length field. I1–I4
  hold by construction (stress test: 300 random packets).
- quicGenParams knobs (default 6 frags / 2 PING / 1250B): fragment count,
  PING count, datagram-size range — escalation without a code change if a
  DPI ever starts keeping a reassembly buffer. Length field / payload are
  recomputed from the chosen size.
- Removed the now-dead etalonWirePlan / etalonCutpoints / planFragments.

STUN — Binding Request instead of Success Response:
- New stun_request_awg.go: a full WebRTC connectivity check (USERNAME,
  ICE-CONTROLLING, PRIORITY, SOFTWARE=libwebrtc, MESSAGE-INTEGRITY
  HMAC-SHA1, FINGERPRINT CRC-32), fresh txn/ufrag/key per call.
- A response sent unsolicited as the client's first packet is a
  wrong-direction anomaly; a request is what an ICE client sends first.
- Removed masqueSTUNResponseCPS (+ orphaned be32/stunSoftwareLen).
- HONEST: this did NOT pass the target DPI (Timeout, like the old
  response) — that DPI blocks STUN to a datacenter Cloudflare IP as a
  class. Kept as the best shape in case another provider's DPI only
  checks well-formedness. QUIC stays the only proven mechanism.

Tests: TestQUICInitialRandomizedInvariants (80 samples, I1–I4 + offsets
differ), TestQUICInitialRobustnessKnobs (4/10/12 frags, variable size),
TestMasqueSTUNRequestStructure (type 0x0001, FINGERPRINT verifies,
USERNAME+MESSAGE-INTEGRITY present), TestMasqueSTUNRequestUniqueness.
Docs: SPEC.md / IMPLEMENTATION_REPORT.md updated incl. the device record.
2026-06-17 23:27:55 +03:00
Leadaxe 30591eb57f docs(009): rewrite spec to as-built; drop short-header history
QUIC ip=quic now generates an out-of-order fragmented Initial (146,
commit 64ce4a47), not a 1-RTT short header. Rewrite the 009 spec docs to
describe the as-built state only — no design history, no superseded
short-header rationale, no open forks.

- SPEC.md: rewritten — I1 CPS mechanism, fragmented QUIC Initial with
  I1–I4 invariants, crypto, validation, file map; drops the revert note,
  the S1–S4 fork, and the short-header design.
- IMPLEMENTATION_REPORT.md: rewritten as a register of decisions R1–R7,
  each with rationale and code refs (file:line) + commits.
- TASKS.md: clean checklist of the current state (no amendment block,
  no strikethrough); device-smoke on DPI marked passed.
- EXAMPLES.md: drop §146-amendment blocks and the dangling masque_quic_awg.go
  reference; fix the wrong "ib selects a ClientHello profile" claim (ib does
  not affect the bytes); drop the dead PLAN.md link.
- Delete PLAN.md and HANDOFF_PROMPT.md (process docs, obsolete post-impl).

Docs only; no code change.
2026-06-17 21:54:27 +03:00
Leadaxe 64ce4a47fe lx(awg): out-of-order fragmented QUIC Initial for ip=quic (146)
The ip=quic masquerade emitted a QUIC 1-RTT short header, which was
empirically BLOCKED by a real LTE-operator DPI (device-proven A/B, LxBox
task §146). Replace it with a full out-of-order fragmented QUIC Initial
(RFC 9001): a realistic browser-shaped ClientHello (id as SNI) split
across 6 CRYPTO frames in a permuted wire order — first frame offset≠0,
offset-0 frame near the end, PING/PADDING interleaved. A line-rate DPI
grabs the first frame, assumes offset 0, parses garbage and fails open;
a real QUIC server reorders the frames normally. This reverses the old
short-header rationale (the ≥1200-byte Initial it called impossible is
exactly what RFC 9000 §14.1 mandates, and the short header lost on DPI).

- New: quic_initial_awg.go (varint encoder, fragment plan + I1–I4
  invariants, RFC 9001 Initial assembly), quic_clienthello_awg.go
  (realistic ~294B TLS 1.3 ClientHello), quic_crypto_awg.go (HKDF /
  AES-128-GCM-XOR-nonce / header protection, mirrored byte-for-byte from
  common/sniff qtls so the keys match the live sniffer).
- id is now REQUIRED for ip=quic (it becomes the ClientHello SNI):
  required for quic/dns/sip, optional only for stun.
- A flex PADDING run pins the payload to the length field for any SNI
  length, so a long (≤253B) valid domain no longer overflows generation.
- Deleted masque_quic_awg.go (masqueQUICShortHeaderCPS / quicFirstByte).
- CPS transport unchanged: whole encrypted Initial emitted as one <b>
  blob; fresh DCID + TLS random + ephemeral x25519 baked in per call.
- Tests reverse-parse our own output (decrypt, frame-walk, reassemble,
  SNI) — §5 control vectors, I1–I4, uniqueness, long-SNI regression.
  Cross-checked: the live common/sniff QUIC sniffer parses our Initial
  and classifies it as chromium.
- Docs: README/README.ru/lx-config + SPECS/009 updated (id required for
  quic, fragmented-Initial mechanism, short-header marked superseded).

Device-smoke on the blocked LTE network is a manual gate (not CI):
tunnel up + real traffic through DPI, control node alongside.
2026-06-17 21:25:57 +03:00
Leadaxe 619f8305f2 docs: document id/ip/ib masquerade (009) in README + README.ru
Add a Masquerade id/ip/ib row to the feature table and an AmneziaWG sugar
subsection (quic/dns/stun/sip, id required only for dns/sip, ib quic-only) in
both English and Russian READMEs. Link SPECS/009 report + examples.
2026-06-17 02:09:27 +03:00
Leadaxe 81eebf5a40 docs(009): mark Closed — live-verified (tunnel+traffic), release v1.13.13-lx.11
Status O->C: id/ip/ib masquerade mechanism verified live (tunnel up, traffic
flows on a 009 build). Rename spec folder 009-F-O- -> 009-F-C-, refresh roadmap
row / IMPLEMENTATION_REPORT / TASKS, fix the EXAMPLES.md link path.
2026-06-17 01:49:38 +03:00
Leadaxe 51d5cff116 lx(awg): WireSock-style id/ip/ib masquerade — declarative I1 CPS (009)
Add declarative masquerade fields id (domain) / ip (protocol) / ib (browser)
on a wireguard endpoint — WireSock-style sugar over the AmneziaWG I1 CPS string.
Profiles quic/dns/stun/sip generate a protocol-shaped decoy packet, ported in
structure from the open-source WireSock reference (amneziawg-proxy/src/
transform.rs, MIT).

Mechanism: I1 CPS only (S1-S4 padding is impossible against Cloudflare WARP,
the target this eases connecting to); the vendored wireguard-go submodule is
untouched. QUIC is a 1-RTT short header (no SNI/ClientHello/JA3), matching
WireSock — no false "byte-perfect"/"fingerprint" claims. id is required only for
dns/sip (it lands on the wire as QNAME / SIP host) and optional for quic/stun;
when set it is always LDH-validated (mirror of is_valid_sni_hostname) as a
security boundary against SIP/DNS injection. ip is mandatory whenever any of
id/ip/ib is set; id/ip/ib are mutually exclusive with an explicit i1.

Gated by with_awg (rejected with a clear error otherwise); empty id/ip/ib leave
the config byte-identical to upstream. Tests assert each profile parses back as
its protocol (not tautologies); every CPS spec was verified against the real
amneziawg-go newObfChain. sing-box check passes for all four profiles and
rejects the conflict/injection/bad-value cases. See SPECS/009.
2026-06-17 00:51:48 +03:00
Leadaxe d77a24a175 docs(007): roadmap — two guards (Start + selector), dialer-guard reverted 2026-06-16 02:18:19 +03:00
Leadaxe 28dc6f89c1 docs(007): record selector-guard (runtime switch) — two complementary guards
SPEC/REPORT/TASKS updated: selector-in-the-middle is now covered by a
runtime selector-guard (suspend AWG consumers before the switch), no longer
an uncovered case. Two complementary guards: Start-guard (static chain) +
selector-guard (runtime selector switch).
2026-06-16 02:17:04 +03:00
Leadaxe e1a96eabe4 lx(awg): suspend AmneziaWG consumers when a selector switches to WireGuard
Start-guard covers a static detour chain but stops at a selector (its
chosen member is runtime-resolved). This adds the runtime half: in
Selector.SelectOutbound, BEFORE committing the switch, if the new member
reaches a wireguard endpoint, walk up the reverse-dependency ledger
(OutboundManager.ConsumersOf) and SuspendAmneziaWG() every AmneziaWG
consumer of the group — device down, started=false. Suspending before
s.selected.Store closes the race: by the time the group points at the WG
member, the consumer is down and a reconnect fails with "not ready"
instead of sending a junk handshake into WireGuard.

New adapter.AmneziaWGSuspendable marker + OutboundManager.ConsumersOf let
protocol/group act without importing protocol/wireguard. Plain-WG and
non-AWG consumers are left untouched. Variant B throughout.

Refs #2
2026-06-16 02:16:51 +03:00
Leadaxe 7aebc0236d docs(007): record dialer-guard removal — Start-guard only
SPEC/REPORT/memory updated: lazy dialer-guard removed (unverifiable +
sync.Once stale), selector-in-the-middle is a known uncovered case pending
a reliable selector-switch hook.
2026-06-16 01:38:15 +03:00
Leadaxe 51360ece38 lx(awg): drop lazy dialer detour-guard, keep Start-guard only
The lazy DetourDialer guard (lx.8) never fired on device — the hang is in
Endpoint.Start, before any dial — and it is unverifiable in the LxBox UI
(detour targets real servers, not groups) and sync.Once-caches its verdict
so it can't catch a selector changing at runtime. Revert
common/dialer/{detour,dialer}.go to upstream and remove its test.

The Start-guard in protocol/wireguard (field-verified on lx.9) stays as the
sole guard. Selector-in-the-middle is now a known uncovered case.

Refs #2
2026-06-16 01:38:15 +03:00
Leadaxe 786d06bc7a docs(007): record lx.9 field-verify pass + message reword
AWG->AWG on Android lx.9: kernel comes up, node refuses with the guard
error, others unaffected — approach change verified on device.
2026-06-16 01:26:40 +03:00
Leadaxe f2488dedc4 lx(awg): reword detour-guard error as architecture limit, not platform
Field feedback: the user-facing message named Android / kernel hang, but
the restriction is architectural — amneziawg over wireguard is not
supported, period. Reword both guards (Start + dialer) to that; keep the
why (Android hang) in code comments for developers.

Refs #2
2026-06-16 01:26:40 +03:00
Leadaxe da6cb1ac59 docs(007): record approach change — Start-guard + dialer-guard two echelons
lx.8 lazy-only guard didn't fire on device (hang in Start before dial,
logcat-proven). SPEC/REPORT updated: two echelons (Start-guard for direct
transitive detour chain, dialer-guard for selector-in-the-middle).
2026-06-16 01:05:49 +03:00
Leadaxe 030200877c lx(awg): guard AWG-over-WireGuard detour at Start, not just lazily
The lazy DetourDialer guard (lx.8) never fired on Android: an AWG node
whose detour reaches a wireguard endpoint hangs synchronously in
Endpoint.Start (peer-domain resolve over the detour + junk handshake),
before any dial. Proven by logcat — kernel stuck in Starting, no guard
error logged.

Add a Start-guard in protocol/wireguard.Endpoint.Start: walk the
transitive detour chain (OutboundManager + Dependencies); if it reaches a
type=wireguard endpoint, log and skip device startup (started stays false)
so the instance comes up and other outbounds keep working — variant B,
never abort start. Stops at selector/urltest groups (runtime target),
leaving that case to the lazy dialer guard, which stays as the second
echelon.

Refs #2
2026-06-16 01:05:43 +03:00
Leadaxe ea6c46a436 docs(007,008): spec kit + roadmap for awg detour guard and junk validation
- 007 AWG_OVER_WIREGUARD_DETOUR_GUARD: SPEC/PLAN/TASKS/REPORT, status C
- 008 AWG_JUNK_PARAM_VALIDATION: SPEC/PLAN/TASKS/REPORT, status C
- SPECS/README roadmap rows for 007 (#2) and 008 (#3)
2026-06-16 00:02:29 +03:00
Leadaxe 9ed6946462 lx(awg): reject amneziawg detour into wireguard endpoint
An AmneziaWG node with detour into any wireguard-based endpoint (plain WG
or AWG) ends up tunnelling AWG traffic inside WireGuard, which hangs the
kernel on Android. Guard it in DetourDialer.init() like the empty-direct
check: lazy error, so the instance still starts and other outbounds keep
working while this node fails every dial (variant B).

Owner-is-AWG flows in via dialer.Options.IsAmneziaWG; the target is matched
by Type()==wireguard, expanding selector/urltest groups recursively. Detour
into a non-wireguard outbound (vless, …) and WG->AWG stay allowed.

Fixes #2
2026-06-16 00:02:23 +03:00
Leadaxe 2adaac46a5 lx(awg): validate jmin<=jmax to prevent rand.Int panic
amneziawg-go sizes junk packets rand(0..jmax-jmin)+jmin before each
handshake; jmin>jmax makes rand.Int's argument <=0 and panics in the
retransmit-timer goroutine. validateJunk rejects it in awgIpcLines so the
config fails at endpoint build / sing-box check instead of crashing later.

Only the crash case is guarded; jc/size inconsistency stays allowed
(harmless, keeps the diff minimal, avoids rejecting working configs).

Fixes #3
2026-06-16 00:02:14 +03:00
Leadaxe fc1db4d25b docs(006): record field verification on RT-AX + naming note
Issue reporter confirmed lx.7 installs/starts/runs on AsusWRT Merlin RT-AX
(linux/arm64, with_naive_outbound,with_musl, CGO enabled) — hardware
acceptance beyond CI. Note that -musl suffix is non-critical per the
consumer's script, while mipsle -softfloat (FP-ABI) stays mandatory.
2026-06-12 18:12:49 +03:00
Leadaxe c34f589907 docs(006): remove orphaned N-status folder left by the C rename
The 006 spec folder was committed under its -N- name, then renamed to -C-
on disk without removing the old paths from git, leaving a duplicate
SPEC/PLAN/TASKS under 006-F-N-. Drop it; 006-F-C- is the canonical set.
Cosmetic (SPECS docs only) — does not affect release artifacts.
2026-06-12 13:25:58 +03:00
Leadaxe b54cda76ee docs(constitution): feature set may grow, thin-fork discipline is the invariant
Replace the hard 'exactly two features and nothing else' with a small
client-side feature set (currently XHTTP + AWG2). New features are
allowed over time if they meet the constitution criteria: not planned
upstream, isolated per §3.2-3.3 (new files, own build tag, marked
seams), full Spec Kit cycle. Mirrored in both READMEs and lx-config.md.
2026-06-12 13:21:11 +03:00
Leadaxe 52a56d8454 docs(006): CI acceptance passed (4/4 arches static), status C
lx-ci linux_musl smoke green for amd64/arm64/armv7/mipsle-softfloat —
all statically linked, no libdl.so.2, naive preserved. mipsle+naive
built with musl static, no fallback needed.
2026-06-12 12:47:43 +03:00
Leadaxe e24bda8df2 fix(awg): gofmt struct tag alignment in AmneziaWGOptions
Widening H1..H4 from uint32 to MagicHeader (005) changed the longest type
in the struct, so gofmt re-aligns the json tags. go vet didn't catch it;
the lx-ci gofmt check did (red since lx.6). Format-only, no behavior change.
2026-06-12 12:41:46 +03:00
Leadaxe 1453166fe3 lx(ci): static musl Linux router builds with naive preserved
lx-release.yml: new build_linux_musl job (amd64/arm64/armv7/mipsle) that
clones cronet-go, fetches the Chromium musl toolchain via cmd/build-naive,
and builds CGO_ENABLED=1 with with_musl (swapping with_purego) so libcronet
is linked statically — no libdl.so.2, runs on musl routers, naive kept.
Linux moves out of the desktop build job. Artifact names mirror upstream
arch suffixes (armv7, mipsle-softfloat) without the -musl suffix since
Linux ships a single (musl) variant.

lx-ci.yml: dispatch-only linux_musl smoke job runs the same pipeline
(build + verify statically-linked / no libdl) without publishing.
2026-06-12 12:39:07 +03:00
Leadaxe bd9dcb0331 docs(006): spec for static musl router builds (linux armv7/mipsle/arm64/amd64)
Keep NaiveProxy (upstream feature) by mirroring upstream build.yml's musl
path instead of dropping it. Closes the libdl.so.2 failure on AsusWRT
Merlin + adds linux-armv7. CI-only, no Go code. See issue #1.
2026-06-12 12:39:07 +03:00
Leadaxe a325d257ae lx(005): mark release tag done (v1.13.13-lx.6 pushed, lx-release running) 2026-06-11 01:45:30 +03:00
Leadaxe ce5bd91b1f docs(awg): document ranged h1-h4 + SPECS/005
h1-h4 are now int | "min-max"; note the no-overlap rule and the
awg.conf mapping. Add SPECS/005-F-C-AWG2_RANGED_MAGIC_HEADERS and the
roadmap row.
2026-06-11 01:43:43 +03:00
Leadaxe 83d6b371fe lx(awg): ranged-header check fixture + CI wiring
awg2_ranged.json (fake keys) exercises ranged H1-H4 through sing-box
check; wire it into the positive and negative CI checks alongside
awg2_basic.json.
2026-06-11 01:43:37 +03:00
Leadaxe 4140da6da5 lx(awg): emit h1-h4 as magic-header specs to IpcSet
Route h1..h4 through the writeStr path as canonical spec strings
("N" or "N-M") instead of writeUint, re-validating each with the key
name in the error. Unset headers are omitted, so a plain WireGuard
endpoint still yields a byte-identical device config.
2026-06-11 01:43:25 +03:00
Leadaxe 23ad189034 lx(awg): support ranged magic headers (H1-H4) in option model
Add option.MagicHeader (string-based, comparable): a single uint32 or an
inclusive "N-M" range (AWG 2.0 ranged headers from awg2 exports).

- UnmarshalJSON accepts a JSON number (backward compatible with the prior
  uint32 field) and a JSON string "N"/"N-M"; canonicalizes, 0 -> unset.
- MarshalJSON keeps type fidelity: single value -> number, range -> string.
- Spec() re-validates for options built in code (libbox/launcher) bypassing
  JSON. string base keeps AmneziaWGOptions comparable so IsSet() still works.

H1..H4 change from uint32 to MagicHeader.
2026-06-11 01:43:15 +03:00
Leadaxe ee7e270378 docs: note Win7 (32-bit) build drops with_naive_outbound
README claimed naive/cronet builds CGO-free on "every target"; that is now
inaccurate — the windows/386 legacy (Win7) build drops with_naive_outbound
because cronet-go has no windows/386. Corrected README EN/RU and added the
caveat to docs/lx-config.md §3.
2026-06-10 03:31:56 +03:00
Leadaxe 19b505ef8f lx(release): add Windows 7 (32-bit) legacy build via patched Go
Mirrors upstream build.yml: the windows/386 leg uses a Win7-patched Go
(.github/setup_go_for_windows7.sh — MetaCubeX/go reverts of the Win7
removals) so the binary runs on Windows 7. Drops with_naive_outbound for
this leg (cronet-go has no windows/386 build); the rest of LX_TAGS
compiles for 386. Archive: sing-box-<ver>-windows-386-legacy-windows-7.zip,
matching the launcher's singbox-launcher-win7-32 (also 386).
2026-06-10 02:47:43 +03:00
Leadaxe f806e24fdb core(awg): default unset MTU to 1280 + warn on too-high MTU vs s3/s4
AmneziaWG s3/s4 prepend junk to every transport message, so a plain-WG
MTU overflows the path and data packets fail with EMSGSIZE while the
handshake still succeeds. On an AWG endpoint (max(s3,s4) > 0):
- when mtu is unset, default to the recommended 1280 (not upstream 1408)
- when mtu is set too high for a conservative 1492-byte (PPPoE) budget,
  log an advisory warning: mtu <= 1492 - 28 - 32 - max(s3,s4)
Plain WireGuard is untouched. Docs: lx-config.md §2 + SPECS/003 report.
2026-06-10 02:04:51 +03:00
Leadaxe 34afa21431 lx(004): close BUILD_CI_RELEASE → Complete (IMPLEMENTATION_REPORT, folder N→C, SPECS index) 2026-06-09 23:17:20 +03:00
Leadaxe 4c1b37e22b lx(004): mark auto-rebase + release end-to-end + AAR/CI tasks done (lx.3 shipped, rebase demo green) 2026-06-09 23:07:51 +03:00
Leadaxe 3b5887e9b4 lx(ci): add lx-rebase workflow — auto-rebase onto newest stable upstream tag → PR or issue 2026-06-09 23:05:43 +03:00
Leadaxe 21861fade9 lx(release): remove throwaway release-test probe; fix XHTTP release-notes (live-validated) 2026-06-09 22:49:49 +03:00
Leadaxe 4e588fcef9 lx(ci): add throwaway lx-release-test probe (ubuntu build + publish) to test release token 2026-06-09 22:29:34 +03:00
Leadaxe 20f2850247 lx(ci): split into cheap lint+build-check (push/PR) vs manual cross/android (workflow_dispatch only) 2026-06-09 22:14:54 +03:00
Leadaxe dacd96a540 lx(ci): trim per-commit CI cost — skip docs, gate heavy cross/AAR to PR/dispatch, add concurrency cancel 2026-06-09 21:59:36 +03:00
Leadaxe 94c7702cdc lx(docs): document Android libbox AAR (distribution + build)
CONSTITUTION §3.5: AAR as an Android distribution artifact (gomobile, with_xhttp/
with_awg baked in). lx-config.md: make lib_android command + AAR in CI/release.
README (en/ru): Android build note, lx-release row + AAR in layout, Android consumer.
2026-06-09 21:53:11 +03:00
Leadaxe cdf02b35c0 lx(docs): explain LX_TAGS rationale (minus acme/tailscale/ccm/ocm, plus purego/xhttp/awg) 2026-06-09 21:36:07 +03:00
Leadaxe ffba5cb9d4 lx(004): trim lx-ci BASE_TAGS to client set (drop tailscale/acme)
Match Makefile.lx LX_TAGS: CI now builds/cross-checks the same client feature set
(no tailscale/ccm/ocm/acme). android AAR job was already in place.
2026-06-09 21:29:51 +03:00
Leadaxe c8c5737d3d lx(004): drop tailscale from libbox AAR
Upstream-file edit (// lx:no-tailscale marker): remove with_tailscale (+ts_omit_*)
from build_libbox sharedTags. Client fork has no tailscale endpoints and it is the
largest dependency in the APK; keeps the AAR aligned with the desktop LX_TAGS set.
2026-06-09 21:29:23 +03:00
Leadaxe 59b9e87ffb lx(004): trim LX_TAGS to client set (drop tailscale/acme); sync spec + README
Desktop LX_TAGS = upstream feature set minus tags irrelevant to a VPN client
(tailscale, ccm/ocm AI-proxy services, acme server cert issuance). AAR drops
tailscale too. README + SPECS/004 (SPEC/PLAN/TASKS) updated to match.
2026-06-09 21:28:00 +03:00
Leadaxe f18c2a5627 lx(docs): note pqv (post-quantum Reality) + spiderX are unsupported (Reality layer, deferred to upstream) 2026-06-09 21:13:32 +03:00
Leadaxe 19e3abcb36 lx(004): LX_TAGS = full upstream set minus server-only ccm/ocm; libbox + release AAR
- desktop LX_TAGS / CI BASE_TAGS: drop with_ccm/with_ocm (server-only services:
  user mgmt + usage + websocket — useless for a client); keep everything else
  (acme/tailscale/naive via with_purego, badtls, our with_xhttp/with_awg)
- libbox sharedTags already exclude ccm/ocm; with_xhttp+with_awg baked into both AARs
- lx-release.yml builds desktop ×6 + android AAR; lx-ci.yml adds android job
2026-06-09 21:12:03 +03:00
Leadaxe d94f064c59 lx(002): XHTTP live-validated against Xray — mark Complete
- IMPLEMENTATION_REPORT: live test vs real Xray (3x-ui) — packet-up/auto pass
  (handshake + DNS + HTTPS + 2 MB download); padding fix (x_padding in Referer);
  stream-one has a known downlink-framing bug
- README (EN/RU), docs/lx-config.md, SPECS roadmap updated; 002 folder -O- -> -C-
2026-06-09 18:32:45 +03:00
Leadaxe 5a398a5ee9 lx(xhttp): fix padding placement (x_padding in Referer) + auto→packet-up — live-validated
Verified live against a real Xray (3x-ui) XHTTP server (VLESS + Reality):
- padding must be carried as x_padding=<zeros> inside the Referer header
  (Xray default PlacementQueryInHeader), not a standalone X-Padding header —
  the server validates x_padding length (default 100-1000) and replies 400 Bad
  Request when it is missing/out of range.
- auto now maps to packet-up (validated working); stream-one has a known
  downlink-framing bug ("unknown version") and must be selected explicitly.

packet-up/auto: handshake + DNS + HTTPS + 2 MB download all flow through the tunnel.
2026-06-09 18:29:53 +03:00
Leadaxe bb38d0ef6b lx(004): release workflow — cross-build all platforms + publish GitHub Release on v*-lx.* tag 2026-06-09 18:08:21 +03:00
Leadaxe 76eb4d1a63 lx(docs): point wireguard-go submodule + links to renamed wireguard-go-awg2-lx
Repo Leadaxe/wireguard-go renamed to Leadaxe/wireguard-go-awg2-lx (clearer: it's
the AWG2 merge, not vanilla wireguard-go). Update .gitmodules URL and README
links (EN + RU). Module path and submodule path unchanged; pinned commit unchanged.
2026-06-09 17:58:41 +03:00
Leadaxe c801fdc256 lx(docs): bilingual README (EN primary + RU); link to upstream README
- README.md is now the lx README in English (GitHub renders it → an arriving
  visitor immediately sees this is a thin sing-box fork with XHTTP + AmneziaWG 2.0)
- README.ru.md: Russian version; mutual language switcher in both
- drop the static README.sing-box.md copy (it would go stale) in favor of a link
  to the live upstream sing-box README on GitHub
2026-06-09 17:46:40 +03:00
Leadaxe 72e3e59052 lx(004): feature-toggle + cross-platform CI; docs/lx-config.md
- docs/lx-config.md: config reference for XHTTP transport and AmneziaWG 2.0
  endpoint (field tables + examples with placeholder keys)
- .github/workflows/lx-ci.yml: matrix over the two features
  (baseline / with_xhttp / with_awg / full) + negative check that feature-off
  rejects its config; vet job; cross-platform matrix {linux,darwin,windows}x
  {amd64,arm64} building the full lx set with the merged AWG fork (submodules)
- link the config doc from SPECS/README
2026-06-09 17:04:35 +03:00
Leadaxe 04ecdf6404 lx(docs): 003 AWG2 complete — live-validated against real AmneziaWG 2.0 server
- IMPLEMENTATION_REPORT: dependency activated via merged Leadaxe/wireguard-go fork
  (3-way merge, MessageEncapsulatingTransportSize=0); handshake+keepalive+traffic confirmed
- README roadmap 003 -> C; folder -> -C-
2026-06-09 16:53:21 +03:00
Leadaxe a487671f39 lx(awg): activate AmneziaWG 2.0 — real obfuscation device via merged fork
- replace github.com/sagernet/wireguard-go => ./submodules/wireguard-go
  (Leadaxe/wireguard-go @27290b6: sagernet base + AmneziaWG obfuscation, 3-way merge)
- add S3/S4 padding to option.AmneziaWGOptions + device_awg.go IpcSet emitter
  (AWG 2.x; server config carries s1/s2/s3/s4)

LIVE-VALIDATED against a real AmneziaWG 2.0 server: handshake initiation ->
received handshake response -> keepalive -> traffic egresses via the server.
AWG is now functional, not just config-valid. Secrets never committed.
2026-06-09 16:51:43 +03:00
Leadaxe 7bec034efb lx(xhttp): align sessionId to dashed UUID; document padding placement vs Xray
Verified against XTLS/Xray-core splithttp source (no live server):
- sessionId now formatted as dashed UUID (was 32-char hex) to match uuid.New().String()
- documented version-dependent padding placement (current Xray: x_padding query
  param in Referer; older: standalone X-Padding) for live-test reconciliation
build + check + vet green.
2026-06-09 15:20:37 +03:00
Leadaxe ebc7174c55 lx(docs): 002/003 implementation reports + statuses (O)
- 002 XHTTP: lean-native client merged, build+check green; wire unverified vs Xray, live test deferred
- 003 AWG2: scaffold merged, build+check green; amneziawg-go replace inactive (API drift) — activation plan recorded
- README roadmap statuses; 003 folder -> -O-
2026-06-09 15:17:28 +03:00
Leadaxe 0e8894d2ef lx(awg): AmneziaWG 2.0 client endpoint 2026-06-09 15:12:26 +03:00
Leadaxe d1b434fcef lx(xhttp): lean-native XHTTP client transport 2026-06-09 15:12:26 +03:00
Leadaxe 3291c40394 lx(002): registry done; record XHTTP port reconnaissance + A/B approach
- mark registry refactor + xhttp constant complete (pushed earlier)
- SPEC §7: hiddify port pulls vendored common/xray/* + quic-go/http3; record
  faithful-vendor (A) vs lean-native (B) decision; recommend A
- status N -> O (in progress)
2026-06-09 14:43:17 +03:00
Leadaxe 2d97ff5684 lx(xhttp): wire registry into NewClientTransport + xhttp constant
// lx: edits to upstream files (isolated for clean rebases):
- transport/v2ray/transport.go: NewClientTransport switch -> registry lookup
- constant/v2ray.go: V2RayTransportTypeXHTTP = "xhttp"
Behavior identical for built-in transports; build+vet+check green.
2026-06-09 14:40:35 +03:00
Leadaxe e111f80070 lx(xhttp): add client transport registry (registry.go)
New file, zero upstream-conflict surface. Holds clientTransportRegistry +
RegisterClient + init() that reproduces upstream's client switch exactly
(incl. QUIC TLS-required check). Lets downstream transports register via init().
2026-06-09 14:40:35 +03:00
Leadaxe da56518e3f lx(001): fork bootstrap — Makefile.lx, -lx version via ldflags, CI skeleton, sample config
- Makefile.lx (new, zero upstream touch): LX_TAGS, ldflags -lx version, lx-build → sing-box
- .github/workflows/lx-ci.yml: build(lx tags)+version+vet+check
- lx-test/config/minimal.json (avoid upstream test/ go module)
- relocate spec refs test/config → lx-test/config
- 001 complete: builds, version 1.13.13-lx.1, check OK; mark folder -C-
2026-06-09 14:36:12 +03:00
Leadaxe 60a94bf0d7 lx(specs): bootstrap Spec Kit — constitution, prompt, roadmap, specs 001-004
Establish sing-box-lx as a thin downstream of SagerNet/sing-box:
upstream + exactly two client-side features (XHTTP transport, AmneziaWG 2.0
endpoint), gated behind build tags, kept rebaseable onto upstream tags.

- SPECS/CONSTITUTION.md, IMPLEMENTATION_PROMPT.md, README.md
- SPECS/001 FORK_BOOTSTRAP, 002 XHTTP_CLIENT_TRANSPORT,
  003 AWG2_CLIENT_ENDPOINT, 004 BUILD_CI_RELEASE
2026-06-09 14:31:11 +03:00
世界 78b2e12fbd Bump version 2026-06-03 16:33:44 +08:00
世界 0f4d38bf6c release: Fix extract-lib 2026-06-03 16:33:34 +08:00
世界 e03346c671 Update Go to 1.25.10 2026-06-03 12:56:36 +08:00
世界 fca766704a release: Fix android build 2026-06-03 12:56:36 +08:00
世界 da0cd68115 Fix ping timeout 2026-06-03 12:56:36 +08:00
世界 3d4616746c tailscale: Use upstream version format 2026-06-03 12:56:36 +08:00
世界 90522ddcb9 release: Fix upload_android 2026-06-03 12:56:36 +08:00
世界 761b7f4e12 Handle TUN loopback in direct outbound 2026-06-03 12:56:36 +08:00
世界 1086ab2563 Bump version 1.13.12 2026-05-14 15:11:09 +08:00
世界 2b995ea10a Run lint for all platforms in workflow 2026-05-13 23:39:27 +08:00
世界 5e7fd7ad78 Fix lint errors 2026-05-13 23:39:27 +08:00
世界 338aa551d1 Update golangci-lint configuration 2026-05-13 16:38:08 +08:00
世界 b6f7d62bb6 sing: Update contextjson 2026-05-13 16:28:50 +08:00
世界 76880eb46b Update naiveproxy to v148.0.7778.96-1 2026-05-13 16:28:49 +08:00
世界 b4c625543a Fix tailscale crash at start 2026-05-13 16:28:49 +08:00
世界 2f139af2d1 Fix naive inbound close 2026-05-13 16:28:49 +08:00
世界 383a1824c1 dns: Refactor reordered pool 2026-05-13 16:28:49 +08:00
世界 d166f0da8b dns: Fix conn pool leak 2026-05-13 16:28:49 +08:00
世界 6475a5e036 Skip kickWriteHandshake for server first protocols 2026-05-13 16:28:49 +08:00
世界 6548c17110 dns: Fix deadline 2026-05-13 16:28:49 +08:00
世界 d1c53d21fe release: Add replace_macos_standalone make target 2026-05-13 13:37:40 +08:00
世界 ddb757a25c Reduce built-in certificate store memory 2026-04-28 07:44:20 +08:00
世界 553cfa1f9f Bump version 2026-04-23 07:30:34 +08:00
世界 f102ef1d94 Fix process search skipped for Android again 2026-04-23 05:52:22 +08:00
世界 3312b8da50 Clean up DNS transports 2026-04-23 02:30:32 +08:00
世界 a3fc14f35f Bump version 2026-04-22 13:40:36 +08:00
世界 8947cb243e sing: Fix UoT write race 2026-04-21 18:54:52 +08:00
世界 83d3a6d4e1 Hide lifecycle logs for fast operations 2026-04-21 17:15:16 +08:00
世界 71f6a2ab4e Fix process search skipped for TUN 2026-04-21 15:45:05 +08:00
世界 d942ecc904 Bump version 1.13.9 2026-04-20 09:49:39 +08:00
世界 c3de6a25fb documentation: Remove warp ads 2026-04-20 09:49:39 +08:00
世界 b3523abad5 tun: Fix multi include/exclude interfaces 2026-04-20 09:49:39 +08:00
世界 60dd7ea5c9 Simplify lifecycle logs 2026-04-20 09:49:39 +08:00
世界 e4bc459975 Skip process search for non-local source addresses 2026-04-20 09:49:39 +08:00
世界 3124cdd661 Fix windows bssid matching 2026-04-20 09:49:39 +08:00
世界 b3606e33a6 release: fix apk package file ownership 2026-04-20 09:49:39 +08:00
世界 9b72b352d5 sing: Fix UoT connect race 2026-04-20 00:10:46 +08:00
世界 fb61987d93 tun: memmod: be more resilient toward weird PE files 2026-04-19 21:54:20 +08:00
世界 a80ef94f09 Fix tailscale endpoint early-start close panic 2026-04-19 21:15:54 +08:00
世界 3a236d9c3c fswatch: Fix close 2026-04-19 20:41:33 +08:00
世界 ca76c56377 daemon: Fix registry leak 2026-04-19 20:39:38 +08:00
世界 0bd109d7bc sing: Fix interface finder 2026-04-19 20:38:52 +08:00
世界 9b155ba467 Fix rdrc cache 2026-04-16 16:45:50 +08:00
世界 7ed5ef6da4 sing: Fix udpnat2 timeout 2026-04-16 16:45:38 +08:00
世界 bb3ad9c694 documentation: Fix typo 2026-04-14 16:00:47 +08:00
世界 d5adb54bc6 Bump version 2026-04-14 14:33:19 +08:00
世界 1cfcea769f Update Go to 1.25.9 2026-04-14 14:26:59 +08:00
世界 f43fc797d4 Update naiveproxy to v147.0.7727.49-1 2026-04-14 14:24:21 +08:00
世界 8e3176b789 Fix FakeIP returning error for unconfigured address family
Return SUCCESS with empty answers instead of an error when the
queried address family has no range configured. Reject configurations
where neither inet4_range nor inet6_range is set.
2026-04-14 14:15:20 +08:00
世界 025b947a24 Bump version 2026-04-10 16:23:45 +08:00
世界 76fa3c2e5e tun: Fixes 2026-04-10 14:13:06 +08:00
世界 53db1f178c Fix tailscale crash 2026-04-10 14:09:03 +08:00
世界 55ec8abf17 Fix local DNS server for Android 2026-04-10 14:08:57 +08:00
Berkay Özdemirci 5a957fd750 Fix EDNS OPT record corruption in DNS cache
The TTL computation and assignment loops treat OPT record's Hdr.Ttl
as a regular TTL, but per RFC 6891 it encodes EDNS0 metadata
(ExtRCode|Version|Flags). This corrupts cached responses causing
systemd-resolved to reject them with EDNS version 255.

Also fix pointer aliasing: storeCache() stored raw *dns.Msg pointer
so subsequent mutations by Exchange() corrupted cached data.

- Skip OPT records in all TTL loops (Exchange + loadResponse)
- Use message.Copy() in storeCache() to isolate cache from mutations
2026-04-10 14:08:24 +08:00
TargetLocked 7c3d8cf8db Fix disable tcp keep alive 2026-04-10 13:29:15 +08:00
世界 813b634d08 Bump version 2026-04-06 23:09:11 +08:00
hdrover d9b435fb62 Fix naive inbound padding bytes 2026-04-06 22:33:11 +08:00
世界 354b4b040e sing: Fix vectorised readv iovec length calculation
This does not seem to affect any actual paths in the sing-box.
2026-04-01 16:16:58 +08:00
世界 7ffdc48b49 Bump version 2026-03-30 23:03:43 +08:00
世界 e15bdf11eb sing: Minor fixes 2026-03-30 22:58:11 +08:00
世界 e3bcb06c3e platform: Add HTTPResponse.WriteToWithProgress 2026-03-30 22:42:36 +08:00
世界 84d2280960 quic: Fix protocol client close & Sync hysteria bbr fix 2026-03-30 22:42:36 +08:00
世界 4fd2532b0a Fix naive quic error message 2026-03-30 22:42:36 +08:00
Zhengchao DingandHyper 02ccde6c71 fix(rpm): add vendor field to fpm config to avoid (none) vendor
Co-authored-by: Hyper <hypar@disroot.org>
2026-03-30 22:09:54 +08:00
世界 e98b4ad449 Fix WireGuard shutdown race crashing
Stop peer goroutines before closing the TUN device to prevent
RoutineSequentialReceiver from calling Write on a nil dispatcher.
2026-03-26 16:33:21 +08:00
世界 d09182614c Bump version 2026-03-26 13:28:33 +08:00
世界 6381de7bab route: Fix query_type never matching in rule_set headless rules 2026-03-26 13:26:18 +08:00
世界 b0c6762bc1 route: merge rule_set branches into outer rules
Treat rule_set items as merged branches instead of standalone boolean
sub-items.

Evaluate each branch inside a referenced rule-set as if it were merged
into the outer rule and keep OR semantics between branches. This lets
outer grouped fields satisfy matching groups inside a branch without
introducing a standalone outer fallback or cross-branch state union.

Keep inherited grouped state outside inverted default and logical
branches. Negated rule-set branches now evaluate !(...) against their
own conditions and only reapply the outer grouped match after negation
succeeds, so configs like outer-group && !inner-condition continue to
work.

Add regression tests for same-group merged matches, cross-group and
extra-AND failures, DNS merged-branch behaviour, and inverted merged
branches. Update the route and DNS rule docs to clarify that rule-set
branches merge into the outer rule while keeping OR semantics between
branches.
2026-03-25 14:00:29 +08:00
世界 7425100bac release: Refactor release tracks for Linux packages and Docker
Support 4 release tracks instead of 2:
- sing-box / latest (stable release)
- sing-box-beta / latest-beta (stable pre-release)
- sing-box-testing / latest-testing (testing branch)
- sing-box-oldstable / latest-oldstable (oldstable branch)

Track is detected via git branch --contains and git tag,
replacing the old version-string hyphen check.
2026-03-24 15:03:43 +08:00
世界 d454aa0fdf route: formalize nested rule_set group-state semantics
Before 795d1c289, nested rule-set evaluation reused the parent rule
match cache. In practice, this meant these fields leaked across nested
evaluation:

- SourceAddressMatch
- SourcePortMatch
- DestinationAddressMatch
- DestinationPortMatch
- DidMatch

That leak had two opposite effects.

First, it made included rule-sets partially behave like the docs'
"merged" semantics. For example, if an outer route rule had:

  rule_set = ["geosite-additional-!cn"]
  ip_cidr  = 104.26.10.0/24

and the inline rule-set matched `domain_suffix = speedtest.net`, the
inner match could set `DestinationAddressMatch = true` and the outer
rule would then pass its destination-address group check. This is why
some `rule_set + ip_cidr` combinations used to work.

But the same leak also polluted sibling rules and sibling rule-sets.
A branch could partially match one group, then fail later, and still
leave that group cache set for the next branch. This broke cases such
as gh-3485: with `rule_set = [test1, test2]`, `test1` could touch
destination-address cache before an AdGuard `@@` exclusion made the
whole branch fail, and `test2` would then run against dirty state.

795d1c289 fixed that by cloning metadata for nested rule-set/rule
evaluation and resetting the rule match cache for each branch. That
stopped sibling pollution, but it also removed the only mechanism by
which a successful nested branch could affect the parent rule's grouped
matching state.

As a result, nested rule-sets became pure boolean sub-items against the
outer rule. The previous example stopped working: the inner
`domain_suffix = speedtest.net` still matched, but the outer rule no
longer observed any destination-address-group success, so it fell
through to `final`.

This change makes the semantics explicit instead of relying on cache
side effects:

- `rule_set: ["a", "b"]` is OR
- rules inside one rule-set are OR
- each nested branch is evaluated in isolation
- failed branches contribute no grouped match state
- a successful branch contributes its grouped match state back to the
  parent rule
- grouped state from different rule-sets must not be combined together
  to satisfy one outer rule

In other words, rule-sets now behave as "OR branches whose successful
group matches merge into the outer rule", which matches the documented
intent without reintroducing cross-branch cache leakage.
2026-03-24 15:03:43 +08:00
世界 a3623eb41a tun: Fix system stack rewriting TUN subnet destinations to loopback 2026-03-23 19:38:55 +08:00
世界 72bc4c1f87 Fix DNS transport returning error for empty AAAA response
Closes #3925
2026-03-23 19:21:55 +08:00
世界 9ac1e2ff32 Match package_name in process_path rule on Android 2026-03-23 18:57:35 +08:00
世界 0045103d14 Fix package_name shared uid matching 2026-03-23 18:57:35 +08:00
世界 d2a933784c Optimize Darwin process finder 2026-03-23 18:57:35 +08:00
世界 3f05a37f65 Optimize Linux process finder 2026-03-23 18:57:35 +08:00
世界 b8e5a71450 Add process information cache to avoid duplicate lookups
PreMatch and full match phases each created a fresh InboundContext,
causing process search (expensive OS syscalls) to run twice per
connection. Use a freelru ShardedLRU cache with 200ms TTL to serve
the second lookup from cache.
2026-03-23 14:26:45 +08:00
世界 c13faa8e3c tailscale: Only set ProcessLocalIPs/ProcessSubnets for fake TUN 2026-03-23 14:16:40 +08:00
世界 7623bcd19e Fix DialerForICMPDestination 2026-03-23 13:58:55 +08:00
世界 795d1c2892 Fix nested rule-set match cache isolation 2026-03-23 12:26:19 +08:00
世界 6913b11e0a Reject removed legacy inbound fields instead of silently ignoring 2026-03-21 17:16:10 +08:00
世界 1e57c06295 daemon: Allow StartOrReloadService to recover from FATAL state 2026-03-21 13:37:14 +08:00
世界 ea464cef8d daemon: Fix CloseService leaving instance non-nil on close error 2026-03-21 13:23:57 +08:00
Andrew Novikov a8e3cd3256 tun: Fix nfqueue not working in prerouting 2026-03-17 11:05:40 +08:00
世界 686cf1f304 documentation: Fix Chinese link anchors 2026-03-16 12:24:10 +08:00
世界 9fbfb87723 documentation: Fix unicode heading anchors 2026-03-16 12:10:32 +08:00
世界 d2fa21d07b Deprecate Socksaddr.IsFqdn: do not reject potentially valid domain names 2026-03-16 09:37:59 +08:00
世界 d3768cca36 Bump version 2026-03-15 17:56:37 +08:00
世界 0889ddd001 Fix connector canceled dial cleanup 2026-03-15 17:56:37 +08:00
深鸣 f46fbf188a documentation: Minor fixes 2026-03-15 17:56:37 +08:00
世界 f2d15139f5 tun: Fix nftables single include_uid not working 2026-03-15 16:58:34 +08:00
世界 041646b728 Fix kTLS crash 2026-03-14 21:38:38 +08:00
世界 b990de2e12 tun: Fix "Fix auto_redirect dropping SO_BINDTODEVICE traffic" 2026-03-14 21:38:38 +08:00
世界 fe585157d2 Bump version 2026-03-14 21:38:38 +08:00
世界 eed6a36e5d tun:Fix auto_redirect dropping SO_BINDTODEVICE traffic 2026-03-14 21:38:38 +08:00
世界 eb0f38544c tailscale: Fix system interface rules 2026-03-14 21:38:38 +08:00
世界 54468a1a2a platform: Add f-droid update helpers 2026-03-11 20:41:29 +08:00
世界 8289bbd846 Add Alpine APK packaging to CI build
Add fpm-based Alpine APK packaging alongside existing DEB/RPM/Pacman
packages. Alpine APKs use `linux` in the filename to distinguish from
OpenWrt APKs which use the `openwrt` prefix.
2026-03-11 20:41:29 +08:00
世界 49c450d942 ccm/ocm: Fix missing metering for 1M context and /fast mode
CCM: Fix 1M context detection - use prefix match for versioned
beta strings (e.g. "context-1m-2025-08-07") and include cache
tokens in the 200K threshold check per Anthropic billing docs.

OCM: Add GPT-5.4 family pricing (standard/priority/flex) with
extended context (>272K) premium pricing support. Add context
window tracking to usage combinations, mirroring CCM's pattern.
Update normalizeGPT5Model defaults to latest known models.
2026-03-11 20:41:29 +08:00
世界 a7ee943216 Fix tailscale connections 2026-03-11 00:27:15 +08:00
世界 8bb4c4dd32 documentation: Update ocm/ccm examples 2026-03-10 22:04:12 +08:00
世界 67621ee6ba Fix OCM websocket proxy lifecycle and headers 2026-03-10 22:04:11 +08:00
世界 a09ffe6a0f ccm/ocm: Add by_user_and_week cost summary 2026-03-10 22:04:11 +08:00
世界 e0be8743f6 ocm: Add Responses WebSocket API proxy and fix client config docs
Support the OpenAI Responses WebSocket API (`wss://.../v1/responses`)
for bidirectional frame proxying with usage tracking.
Fix Codex CLI client config examples to use profiles and correct flags.

Update openai-go v3.24.0 → v3.26.0.
2026-03-10 22:04:11 +08:00
世界 0b04528803 tailscaile: Fix using TUN auto redirect with tailscale system interface 2026-03-10 22:04:11 +08:00
世界 65875e6dac tailscale: Use system dialer for system interface
* Revert "Fix netstack TCP connections with system interface
2026-03-10 19:50:16 +08:00
世界 4d6fb1d38d Fix legacy DNS client_subnet options not working 2026-03-09 20:18:47 +08:00
世界 305b930d90 release: Fix default config 2026-03-09 20:18:43 +08:00
世界 bc3884ca91 release: Add openwrt apk build 2026-03-09 20:18:40 +08:00
世界 df0bf927e4 Fix missing with_gvisor build tag for tailscale 2026-03-09 20:18:28 +08:00
世界 efe20ea51c release: Backport Go 1.25 to macOS 10.13 2026-03-09 20:13:36 +08:00
世界andtraitman e21a72fcd1 Fix websocket connection and goroutine leaks in Clash API
Co-authored-by: traitman <112139837+traitman@users.noreply.github.com>
2026-03-09 20:06:34 +08:00
世界 e1477bd065 documentation: Update cronet-go descriptions 2026-03-09 20:06:34 +08:00
世界 aa495fce38 Fix local DNS transport CNAME chain broken with systemd-resolved
Replace D-Bus ResolveRecord API with direct raw DNS queries to upstream
servers obtained from systemd-resolved's per-interface link properties.
2026-03-09 20:06:34 +08:00
世界 9cd60c28c0 tailscale: Fix inbound UDP packet connection 2026-03-09 20:06:34 +08:00
Heng lu 2ba896c5ac Fix netns fd leak in ListenNetworkNamespace 2026-03-09 20:06:34 +08:00
Oleg Artyomov 1d388547ee service/ccm: strip Accept-Encoding before forwarding to avoid untracked usage
When clients (e.g. Node.js Anthropic SDK) explicitly set Accept-Encoding: gzip,
Go's http.Transport does not transparently decompress the response body, because
it only does so when it added the header itself. This causes CCM's json.Unmarshal
to receive raw gzip bytes, silently failing to parse usage data and leaving the
usage counter unchanged.

Fix: remove Accept-Encoding from the outgoing proxy request. Transport adds it
automatically and transparently decompresses response.Body before CCM reads it.

Wire compression (CCM→Anthropic) is preserved — Transport still negotiates gzip.
Only CCM→localhost path is affected; compression on loopback has no practical
benefit.
2026-03-09 20:06:34 +08:00
世界 e343cec4d5 Fix legacy DNS defaults on final transport 2026-03-09 20:06:34 +08:00
世界 d58efc5d01 cronet-go: Fix library search path 2026-03-09 20:06:34 +08:00
世界 4b26ab16fb Bump version 2026-03-07 16:13:23 +08:00
世界 0e27312eda Update Go to 1.25.8 2026-03-07 16:13:23 +08:00
世界 4e0a953b98 sing: Revert "Relax domain name validation to support non-standard characters" 2026-03-07 15:44:40 +08:00
世界andeveryx 27c5b0b1af Fix DNS exchange failure and recursion deadlock in connector
Co-authored-by: everyx <lunt.luo@gmail.com>
2026-03-06 15:31:22 +08:00
dyhkwong 84019b06d9 Fix v2ray HTTP transport server 2026-03-06 10:13:39 +08:00
世界 7fd21f8bf4 Bump version 2026-03-05 21:46:27 +08:00
世界 88695b0d1f Rename branches and update release workflows
stable-next → oldstable, main-next → stable, dev-next → testing, new unstable
2026-03-05 21:12:02 +08:00
世界 fb269c9032 tun: Fix darwin batch loop not exit on EBADF 2026-03-05 20:38:19 +08:00
世界 e62dc7bfa2 Fix rule_set_ip_cidr_accept_empty not working 2026-03-04 11:48:22 +08:00
世界 f295e195b5 tailscale: Fix netstack TCP connections with system interface 2026-03-03 22:06:54 +08:00
世界 ab76062a41 Fix fake-ip address allocation 2026-03-03 21:37:24 +08:00
世界 d14417d392 Fix naive client close 2026-03-03 21:21:09 +08:00
世界 96c5c27610 sing: reject IP literals in IsDomainName 2026-03-03 21:21:09 +08:00
世界 91f92bee49 release: Unify default build tags and linker flags into shared files
Move hardcoded build tags and ldflags from Makefile, Dockerfile, CI
workflows, and local build scripts into canonical files under release/:

- release/DEFAULT_BUILD_TAGS (Linux common archs, Darwin, Android)
- release/DEFAULT_BUILD_TAGS_WINDOWS (includes with_purego)
- release/DEFAULT_BUILD_TAGS_OTHERS (no with_naive_outbound)
- release/LDFLAGS (shared linker flags)
2026-03-03 21:21:09 +08:00
世界 1803471e02 endpoint: Fix UDP resolved destination 2026-03-02 13:55:26 +08:00
世界 3de56d344e Update external dependencies 2026-03-02 06:53:10 +08:00
世界 c71abbdfb8 Update dependencies 2026-03-02 06:52:35 +08:00
世界 ed15121e95 sing: Relax domain name validation to support non-standard characters 2026-03-01 19:45:19 +08:00
世界 46c6945da5 documentation: Update mkdcos-material 2026-03-01 18:37:31 +08:00
1beb4cb002 clash-api: Fix websocket connection not closed after config reload via SIGHUP
Co-authored-by: TraitMan <traitman@maildog.top>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-01 12:30:43 +08:00
dyhkwong 4c65fea1ac Fix IPv6 local DNS on Windows 2026-03-01 12:30:43 +08:00
世界 8ae93a98e5 Remove overdue deprecated features 2026-03-01 12:30:43 +08:00
世界 6da7e538e1 Bump version 2026-02-28 14:42:39 +08:00
世界 13e6ba4cb2 Update tfo-go 2026-02-27 19:55:32 +08:00
世界 93b7328c3f Fix missing Tailscale in ProxyDisplayName 2026-02-27 19:39:52 +08:00
世界 11dc5bcbe1 Fixes in cronet-go 2026-02-27 19:39:52 +08:00
世界 fa3ab87b11 platform: Fix gorelease build 2026-02-27 15:07:16 +08:00
世界 9bd9e9a58b dialer: use KeepAliveConfig for TCP keepalive 2026-02-27 14:58:06 +08:00
世界 9d6dee7451 release: Fix pacman package 2026-02-27 14:58:06 +08:00
世界 9c2cdc7203 Fix per-outbound bind_interface 2026-02-27 14:58:06 +08:00
世界 65150f5cc3 platform: Improve OOM killer for iOS 2026-02-27 14:58:06 +08:00
世界 21a1512e6c tailscale: Fix AdvertiseTags 2026-02-27 14:58:06 +08:00
世界 cf4791f1ad platform: Improve iOS OOM killer 2026-02-26 14:13:32 +08:00
世界 0bc66e5a56 service/ccm,ocm: Fixes and improvements 2026-02-26 13:36:46 +08:00
世界 d48236da94 Fix wireguard reserved 2026-02-24 15:49:52 +08:00
世界 4c05d7b888 Add advertise tags support for Tailscale endpoint 2026-02-24 15:31:57 +08:00
世界 94ed42caf1 Bump version 2026-02-23 18:17:47 +08:00
世界 e0c18cc3d4 tun: Fix nftablesCreateLocalAddressSets 2026-02-23 18:17:47 +08:00
世界 0817c25f4c release: Fix Docker build for loong64 and mipsle 2026-02-23 16:31:19 +08:00
世界 7745a97cca daemon: Fix started service leak 2026-02-23 14:49:58 +08:00
世界 9bcd715d31 Bump version 2026-02-21 13:55:31 +08:00
世界 6a95c66bc7 Pin Go version to 1.25.7 2026-02-21 13:55:31 +08:00
世界 b5800847ae More linux builds for naive 2026-02-21 13:55:31 +08:00
世界 aa85cbb86e Treat H3 RequestCanceled as closed 2026-02-21 09:31:11 +08:00
世界 c59991420e Minor fixes for naive 2026-02-18 01:26:29 +08:00
世界 c0304b8362 Bump version 2026-02-16 12:46:43 +08:00
世界 d1f1271a02 quic-go: Minor fixes 2026-02-16 12:46:29 +08:00
世界 de4fdbe553 platform: Add semver helper 2026-02-16 11:28:54 +08:00
世界 804606042f Bump version 2026-02-15 21:13:55 +08:00
世界 53f2db3f97 platform: Add windows build 2026-02-15 21:10:44 +08:00
世界 1f2fdec89d release: Fix update_apple_version command 2026-02-15 21:09:14 +08:00
世界 8714c157c9 Fix matching multi predefined 2026-02-15 21:09:06 +08:00
世界 657fba4ca5 Fix matching rule-set invert 2026-02-15 21:08:33 +08:00
世界 0a69621207 wireguard: Fix missing fallback for gso 2026-02-15 21:08:26 +08:00
世界 58ccf82e0b Bump version 2026-02-09 15:50:51 +08:00
世界 ceab244329 tuic: Fix udp context 2026-02-09 15:50:51 +08:00
世界 58fcdceca2 Fix naive padding 2026-02-09 15:50:51 +08:00
世界 98af3c0ad6 experimental: New FFI 2026-02-09 15:50:51 +08:00
世界 172a9d5e4e Standardize gomobile usages 2026-02-07 15:52:26 +08:00
世界 aba8346bd6 Fix DNS cache lock goroutine leak
The cache deduplication in Client.Exchange uses a channel-based lock
per DNS question. Waiting goroutines blocked on <-cond without context
awareness, causing them to accumulate indefinitely when the owning
goroutine's transport call stalls. Add select on ctx.Done() so waiters
respect context cancellation and timeouts.
2026-02-06 22:28:35 +08:00
世界 d8e269e0ac socks: Fix "Fix missing UDP timeout" 2026-02-06 22:26:45 +08:00
世界 c45ea8dfac Recover from bbolt panics on corrupted database
When bbolt encounters corrupted page data at runtime, it panics
instead of returning an error. Wrap all DB transactions with
recover to catch these panics, delete the corrupted database
file, and reopen a fresh one.
2026-02-06 19:35:32 +08:00
世界 a2d313c59b Bump version 2026-02-05 20:28:25 +08:00
世界 15722b06dd Update Go to 1.25.7 2026-02-05 17:49:06 +08:00
世界 d230dae0a5 Fix vmess crash 2026-02-05 17:23:49 +08:00
世界 e11dbf3a8e bufio: Refactor copy 2026-02-05 12:03:03 +08:00
世界 baa9f29f0d documentation: Update release changelog 2026-02-05 12:03:03 +08:00
世界 55b6e7dbfe socks: Fix missing UDP timeout 2026-02-05 12:03:03 +08:00
世界 a05e05a47c Fix random iproute2 table index was incorrectly removed 2026-02-02 14:15:55 +08:00
世界 c1dc6cb0fb Bump version 2026-02-01 12:29:57 +08:00
世界 432fe1b3c9 Disable rp filter atomically 2026-02-01 10:49:12 +08:00
世界 8dd8897fd8 Fix varbin serialization 2026-02-01 10:48:05 +08:00
世界 ff58edb1c1 Bump version 2026-01-30 14:05:29 +08:00
世界 79bab39502 Fix auto_redirect fallback rule 2026-01-30 11:42:56 +08:00
世界 a4d5d59901 Minor fixes 2026-01-29 13:40:34 +08:00
世界 1af14a0237 Remove varbin usages 2026-01-29 13:40:34 +08:00
世界 944a9986d9 release: Always build tailscale for iOS and tvOS 2026-01-29 13:40:34 +08:00
Balthild 60a1e4c866 Add acmedns support 2026-01-17 20:52:43 +08:00
世界 5d67c131fa documentation: Bump version 2026-01-17 19:21:19 +08:00
世界 b9cc87d35a Skip strict routing in Windows versions below Windows 10 2026-01-17 19:21:19 +08:00
世界 490d501257 Fix trafficontrol Manager 2026-01-17 19:16:56 +08:00
世界 725e4adc46 release: Update android command 2026-01-17 19:16:56 +08:00
世界 4a14d39cad release: Log build ID during TestFlight publishing 2026-01-17 19:16:56 +08:00
世界 8ec58c96f5 Fix naive outbound on iOS 2026-01-17 19:15:56 +08:00
世界 e8450b2e61 platform: Refactor CommandClient & Connections 2026-01-17 05:50:39 +08:00
世界 30c3855e4b Fix logic issues with BBR impl 2026-01-17 05:50:16 +08:00
世界 ccf90aee8a release: Improve publish_testflight 2026-01-17 05:50:08 +08:00
世界 e6c03fd448 Update quic-go to v0.59.0 2026-01-17 05:50:07 +08:00
世界 e0f1cdf464 platform: Uniq network interfaces 2026-01-17 05:49:57 +08:00
世界 8d88c6532f Add dial option bind_address_no_port 2026-01-17 05:49:56 +08:00
世界 3890bd2be7 platform: Display k based bytes 2026-01-17 05:49:45 +08:00
世界 6cd1eb9b94 Fix tailscale endpoint 2026-01-17 05:49:35 +08:00
世界 f196b7a583 tailscale: Add system interface support 2026-01-17 05:49:24 +08:00
世界 bd9935eebb platform: Fix gomobile build 2026-01-17 05:49:13 +08:00
世界 0e0e838ff5 platform: Update apple build comamnds 2026-01-17 05:49:13 +08:00
世界 0caebd3171 platform: Improve interface 2026-01-17 05:49:12 +08:00
世界 7d2944eba9 Downgrade quic-go to v0.57.1 2026-01-17 05:49:12 +08:00
世界 a5db2feb5e Fix linux musl builds 2026-01-17 05:48:59 +08:00
世界 708ceb3d29 Fix openwrt builds 2026-01-17 05:48:59 +08:00
世界 157e33f2a4 Add kmod-nft-queue dependency for openwrt package 2026-01-17 05:48:59 +08:00
世界 1d4fb83313 Fix nfqueue fallback 2026-01-17 05:48:58 +08:00
世界 85f5f6cebb Disable multipath TCP by default via GODEBUG 2026-01-17 05:48:58 +08:00
世界 6a750f4522 Fix missing relay support for Tailscale 2026-01-17 05:48:57 +08:00
世界 46c2cc37c3 cronet: Fix windows DNS hijack 2026-01-17 05:48:42 +08:00
世界 aa8dd6e44f Fix DNS transports 2026-01-17 05:48:41 +08:00
世界 4e94a64dcc platform: Expose process info 2026-01-17 05:48:41 +08:00
世界 494990f914 Update bypass action behavior for auto redirect 2026-01-17 05:48:41 +08:00
世界 95ccb837d3 platform: Add GetStartedAt for StartedService 2026-01-17 05:48:40 +08:00
世界 24b33a43fc documentation: Format changes header 2026-01-17 05:48:40 +08:00
世界 8ae16aa452 Add format_docs command for documentation trailing space formatting 2026-01-17 05:48:39 +08:00
世界 bf4a9edc89 Fix panic when closing Box before Start with file log output 2026-01-17 05:48:39 +08:00
世界 78b4eac974 Add pre-match support for auto redirect 2026-01-17 05:48:39 +08:00
世界 a34868468f Fix cronet on iOS 2026-01-17 05:48:38 +08:00
世界 e392c70b6f Ignore darwin IP_DONTFRAG error when not supported 2026-01-17 05:48:37 +08:00
世界 511d1bb3fa Update tailscale to v1.92.4 2026-01-17 05:48:28 +08:00
世界 4273ffa77e Update cronet-go to v143.0.7499.109-1 2026-01-17 05:48:17 +08:00
世界 f5ccf746ea platform: Split library for Android SDK 21 and 23 2026-01-17 05:48:16 +08:00
世界 b2d90b7d86 Fix missing RootPoolFromContext and TimeFuncFromContext in HTTP clients 2026-01-17 05:48:16 +08:00
世界 e0a78fde07 documentation: Minor fixes 2026-01-17 05:48:16 +08:00
世界 203f4134b0 documentation: Add Wi-Fi state shared page 2026-01-17 05:48:16 +08:00
世界 c2b697a778 Fix missing build constraints for linux wifi state monitor 2026-01-17 05:48:15 +08:00
世界 ddec2ab282 Update dependencies 2026-01-17 05:48:15 +08:00
世界 35ff7d1fb4 Update quic-go to v0.58.0 2026-01-17 05:48:04 +08:00
世界 cba18635c8 Add Chrome Root Store certificate option
Adds `chrome` as a new certificate store option alongside `mozilla`.
Both stores filter out China-based CA certificates.
2026-01-17 05:47:54 +08:00
世界 0d8c7a9c5d Fix cronet-go crash 2026-01-17 05:47:54 +08:00
世界 faff3174a3 Add trace logging for lifecycle calls
Log start/close operations with timing information for debugging.
2026-01-17 05:47:54 +08:00
世界 2fc1b672cc documentation: Minor fixes 2026-01-17 05:47:54 +08:00
世界 143983b585 Remove certificate_public_key_sha256 for naive 2026-01-17 05:47:54 +08:00
世界 4afdf4153a platform: Use new crash log api 2026-01-17 05:47:54 +08:00
世界 750dc9c3e0 Fix naive network 2026-01-17 05:47:53 +08:00
世界 48b7adde7d Add QUIC support for naiveproxy 2026-01-17 05:47:52 +08:00
世界 0585f6d065 Add ECH support for NaiveProxy outbound and tls.ech.query_server_name option
- Enable ECH for NaiveProxy outbound with DNS resolver integration
- Add query_server_name option to override domain for ECH HTTPS record queries
- Update cronet-go dependency and remove windows_386 support
2026-01-17 05:47:42 +08:00
世界 8101a7b0bd Fix naiveproxy build 2026-01-17 05:47:42 +08:00
世界 e8620587dd Add OpenAI Codex Multiplexer service 2026-01-17 05:47:42 +08:00
世界 a89680fa2d Update pricing for CCM service 2026-01-17 05:47:42 +08:00
世界 b919039c43 release: Upload only other apks 2026-01-17 05:47:42 +08:00
世界 9b0960bb5a Fix bugs and add UoT option for naiveproxy outbound 2026-01-17 05:47:41 +08:00
世界 ad7b982242 Add naiveproxy outbound 2026-01-17 05:47:41 +08:00
世界 7e68013b05 Apply ping destination filter for Windows 2026-01-17 05:47:33 +08:00
世界 ac427b98f4 platform: Add UsePlatformWIFIMonitor to gRPC interface
Align dev-next-grpc with wip2 by adding UsePlatformWIFIMonitor()
to the new PlatformInterface, allowing platform clients to indicate
they handle WIFI monitoring themselves.
2026-01-17 05:47:32 +08:00
世界 a5fb467db2 daemon: Add clear logs 2026-01-17 05:47:32 +08:00
世界 a930356b04 Revert "Stop using DHCP on iOS and tvOS" 2026-01-17 05:47:32 +08:00
世界 5bc0dfa9dd platform: Refactoring libbox to use gRPC-based protocol 2026-01-17 05:47:32 +08:00
世界 743b460e51 Add Windows WI-FI state support 2026-01-17 05:47:27 +08:00
世界 8d8ca282a1 Add Linux WI-FI state support
Support monitoring WIFI state on Linux through:
- NetworkManager (D-Bus)
- IWD (D-Bus)
- wpa_supplicant (control socket)
- ConnMan (D-Bus)
2026-01-17 05:47:04 +08:00
世界 cd56eaaba2 Add more tcp keep alive options
Also update default TCP keep-alive initial period from 10 minutes to 5 minutes.
2026-01-17 05:47:04 +08:00
世界 e92938364d Update quic-go to v0.57.1 2026-01-17 05:46:52 +08:00
世界 1c4614318e Fix read credentials for ccm service 2026-01-17 05:46:24 +08:00
世界 0f5cda4169 Add claude code multiplexer service 2026-01-17 05:46:23 +08:00
世界 d87c9fd242 Fix compatibility with MPTCP 2026-01-17 05:46:23 +08:00
世界 fce21607bd Use a more conservative strategy for resolving with systemd-resolved for local DNS server 2026-01-17 05:46:23 +08:00
世界 3dc285be8c Fix missing mTLS support in client options 2026-01-17 05:46:23 +08:00
世界 79bbce3db3 Add curve preferences, pinned public key SHA256 and mTLS for TLS options 2026-01-17 05:46:22 +08:00
世界 dfd95b2615 Fix WireGuard input packet 2026-01-17 05:46:22 +08:00
世界 ab0869c972 Update tfo-go to latest 2026-01-17 05:46:21 +08:00
世界 9ac0539ffd Remove compatibility codes 2026-01-17 05:46:14 +08:00
世界 cb4deb0c20 Do not use linkname by default to simplify debugging 2026-01-17 05:46:14 +08:00
世界 6b90b61358 documentation: Update chinese translations 2026-01-17 05:46:14 +08:00
世界 ed1ee4c3a4 Update quic-go to v0.55.0 2026-01-17 05:46:13 +08:00
世界 7f3ea8dbd8 Update WireGuard and Tailscale 2026-01-17 05:46:02 +08:00
世界 12b055989b Fix preConnectionCopy 2026-01-17 05:46:01 +08:00
世界 49056b5060 Fix ping domain 2026-01-17 05:46:01 +08:00
世界 c530995832 release: Fix linux build 2026-01-17 05:46:01 +08:00
世界 60d81a73d9 Improve ktls rx error handling 2026-01-17 05:46:01 +08:00
世界 e9c46cc359 Improve compatibility for kTLS 2026-01-17 05:46:00 +08:00
世界 9110851af3 ktls: Add warning for inappropriate scenarios 2026-01-17 05:44:43 +08:00
世界 107f92381b Add support for kTLS
Reference: https://gitlab.com/go-extension/tls
2026-01-17 05:44:42 +08:00
世界 f84129ca79 Add proxy support for ICMP echo request 2026-01-17 05:44:41 +08:00
世界 44fafcef73 Fix resolve using resolved 2026-01-17 05:44:29 +08:00
世界 a5e09fcd43 documentation: Update behavior of local DNS server on darwin 2026-01-17 05:44:29 +08:00
世界 387b42c9c2 Remove use of ldflags -checklinkname=0 on darwin 2026-01-17 05:44:29 +08:00
世界 044eb728cb Fix legacy DNS config 2026-01-17 05:44:29 +08:00
世界 2be8a45f14 Fix rule-set format 2026-01-17 05:44:29 +08:00
世界 1336987756 documentation: Remove outdated icons 2026-01-17 05:44:29 +08:00
世界 e3473d3de0 documentation: Improve local DNS server 2026-01-17 05:44:28 +08:00
世界 bba92146b1 Stop using DHCP on iOS and tvOS
We do not have the `com.apple.developer.networking.multicast` entitlement and are unable to obtain it for non-technical reasons.
2026-01-17 05:44:28 +08:00
世界 48f84b31d6 Improve local DNS server on darwin
We mistakenly believed that `libresolv`'s `search` function worked correctly in NetworkExtension, but it seems only `getaddrinfo` does.

This commit changes the behavior of the `local` DNS server in NetworkExtension to prefer DHCP, falling back to `getaddrinfo` if DHCP servers are unavailable.

It's worth noting that `prefer_go` does not disable DHCP since it respects Dial Fields, but `getaddrinfo` does the opposite. The new behavior only applies to NetworkExtension, not to all scenarios (primarily command-line binaries) as it did previously.

In addition, this commit also improves the DHCP DNS server to use the same robust query logic as `local`.
2026-01-17 05:44:28 +08:00
世界 1c846df903 Use resolved in local DNS server if available 2026-01-17 05:44:28 +08:00
xchacha20-poly1305 0bd98a300f Fix rule set version 2026-01-17 05:44:27 +08:00
世界 87eaf3ce6e documentation: Add preferred_by route rule item 2026-01-17 05:44:27 +08:00
世界 239e6ec701 Add preferred_by route rule item 2026-01-17 05:44:27 +08:00
世界 5be1887f92 documentation: Add interface address rule items 2026-01-17 05:44:27 +08:00
世界 65264afdf9 Add interface address rule items 2026-01-17 05:44:26 +08:00
世界 fecdbf20de Fix ECH retry support 2026-01-17 05:44:26 +08:00
neletor 1f03080540 Add support for ech retry configs 2026-01-17 05:44:26 +08:00
Zephyruso 737162e75a Add /dns/flush-clash meta api 2026-01-17 05:44:26 +08:00
世界 51ce402dbb Bump version 2026-01-17 05:10:56 +08:00
世界 8b404b5a4c Update Go to 1.25.6 2026-01-17 05:10:56 +08:00
世界 3ce94d50dd Update uTLS to v1.8.2 2026-01-17 04:54:18 +08:00
世界 29d56fca9c Update smux to v1.5.50 & Fix h2mux RST_STREAM on half-close 2026-01-17 04:17:14 +08:00
世界 ab18010ee1 Bump version 2026-01-12 20:38:21 +08:00
世界 e69c202c79 Fix logic issues with BBR impl 2026-01-12 20:34:04 +08:00
世界 0a812f2a46 Bump version 2026-01-07 15:13:35 +08:00
Gavin Luo fffe9fc566 Fix reset buffer in dhcp response loop
Previously, the buffer was not reset within the response loop. If a packet
handle failed or completed, the buffer retained its state. Specifically,
if `ReadPacketFrom` returned `io.ErrShortBuffer`, the error was ignored
via `continue`, but the buffer remained full. This caused the next
read attempt to immediately fail with the same error, creating a tight
busy-wait loop that consumed 100% CPU.

Validates `buffer.Reset()` is called at the start of each iteration to
ensure a clean state for 'ReadPacketFrom'.
2026-01-05 17:46:59 +08:00
世界 6fdf27a701 Fix Tailscale endpoint using wrong source IP with advertise_routes 2026-01-04 22:14:54 +08:00
Bruce Wayne 7fa7d4f0a9 ducumentation: update Shadowsocks inbound documentation for SSM API 2026-01-02 19:18:52 +08:00
世界 f511ebc1d4 Fix lint errors 2026-01-02 19:17:53 +08:00
世界 84bbdc2eba Revert "Pin gofumpt and golangci-lint versions"
This reverts commit d9d7f7880d.
2026-01-02 19:14:13 +08:00
世界 568612fc70 Fix duplicate tag detection for empty tags
Closes https://github.com/SagerNet/sing-box/issues/3665
2026-01-02 19:14:13 +08:00
世界 d78828fd81 Fix quic sniffer 2026-01-02 19:14:13 +08:00
世界 f56d9ab945 Bump version 2025-12-25 14:47:10 +08:00
世界 86fabd6a22 Update Mozilla certificates 2025-12-25 14:42:18 +08:00
世界 24a1e7cee4 Ignore darwin IP_DONTFRAG error when not supported 2025-12-25 14:40:48 +08:00
世界 223dd8bb1a Fix TCP DNS response buffer 2025-12-22 13:51:00 +08:00
世界 68448de7d0 Fix missing RootPoolFromContext and TimeFuncFromContext in HTTP clients 2025-12-22 13:50:57 +08:00
世界 1ebff74c21 Fix DNS cache not working when domain strategy is set
The cache lookup was performed before rule matching, using the caller's
strategy (usually AsIS/0) instead of the resolved strategy. This caused
cache misses when ipv4_only was configured globally but the cache lookup
expected both A and AAAA records.

Remove LookupCache and ExchangeCache from Router, as the cache checks
inside client.Lookup and client.Exchange already handle caching correctly
after rule matching with the proper strategy and transport.
2025-12-21 16:59:10 +08:00
世界 f0cd3422c1 Bump version 2025-12-14 00:09:19 +08:00
世界 e385a98ced Update Go to 1.25.5 2025-12-13 20:11:29 +08:00
世界 670f32baee Fix naive inbound 2025-12-12 21:19:28 +08:00
世界 2747a00ba2 Fix tailscale destination 2025-12-01 15:02:04 +08:00
世界 48e76038d0 Update Go to 1.25.4 2025-11-16 09:53:10 +08:00
世界 6421252d44 release: Fix windows7 build 2025-11-16 09:09:34 +08:00
世界 216c4c8bd4 Fix adapter handler 2025-11-16 08:34:46 +08:00
世界 5841d410a1 ssm-api: Fix save cache 2025-11-04 11:00:43 +08:00
Kumiko as a Service 63c8207d7a Use --no-cache --upgrade option in apk add
No need for separate upgrade / cache cleanup steps.

Signed-off-by: Kumiko as a Service <Dreista@users.noreply.github.com>
2025-11-04 11:00:41 +08:00
世界 54ed58499d Bump version 2025-10-27 18:04:24 +08:00
世界 b1bdc18c85 Fix socks response 2025-10-27 18:03:05 +08:00
世界 a38030cc0b Fix memory leak in hysteria2 2025-10-24 10:52:08 +08:00
世界 4626aa2cb0 Bump version 2025-10-21 21:39:28 +08:00
世界 5a40b673a4 Update dependencies 2025-10-21 21:39:28 +08:00
世界 541f63fee4 redirect: Fix compatibility with /product/bin/su 2025-10-21 21:27:15 +08:00
世界 5de6f4a14f Fix tailscale not enforcing NoLogsNoSupport 2025-10-16 22:30:08 +08:00
世界 5658830077 Fix trailing dot handling in local DNS transport 2025-10-16 21:43:12 +08:00
世界 0e50edc009 documentation: Add appreciate for Warp 2025-10-16 21:43:12 +08:00
世界 444f454810 Bump version 2025-10-14 23:43:36 +08:00
世界 d0e1fd6c7e Update Go to 1.25.3 2025-10-14 23:43:36 +08:00
世界 17b4d1e010 Update uTLS to v1.8.1 2025-10-14 23:40:19 +08:00
世界 06791470c9 Fix DNS reject panic 2025-10-14 23:40:19 +08:00
世界 ef14c8ca0e Disable TCP slow open for anytls
Fixes #3459
2025-10-14 23:40:19 +08:00
世界 36dc883c7c Fix DNS negative caching to comply with RFC 2308 2025-10-09 23:45:23 +08:00
Mahdi 6557bd7029 Fix dns cache in lookup 2025-10-09 23:45:23 +08:00
世界 41b30c91d9 Improve HTTPS DNS transport 2025-10-09 23:45:23 +08:00
世界 0f767d5ce1 Update .gitignore 2025-10-07 13:37:11 +08:00
世界 328a6de797 Bump version 2025-10-05 17:58:21 +08:00
Mahdi 886be6414d Fix dns truncate 2025-10-05 17:58:21 +08:00
世界 9362d3cab3 Attempt to fix leak in quic-go 2025-10-01 11:59:17 +08:00
世界 ced2e39dbf Update dependencies 2025-10-01 11:55:33 +08:00
anytlsandanytls 2159d8877b Update anytls v0.0.11
Co-authored-by: anytls <anytls>
2025-10-01 10:23:15 +08:00
世界 cb7dba3eff release: Improve publish testflight 2025-10-01 10:22:44 +08:00
世界 d9d7f7880d Pin gofumpt and golangci-lint versions
As we don't want to remove naked returns
2025-09-23 16:33:42 +08:00
世界 a031aaf2c0 Do not reset network on sleep or wake 2025-09-23 16:17:44 +08:00
世界 4bca951773 Fix adguard matcher 2025-09-23 16:12:29 +08:00
世界 140735dbde Fix websocket log handling 2025-09-23 16:12:29 +08:00
世界 714a68bba1 Update .gitignore 2025-09-23 16:12:29 +08:00
世界 573c6179ab Bump version 2025-09-13 13:16:13 +08:00
世界 510bf05e36 Fix UDP exchange for local/dhcp DNS servers 2025-09-13 12:26:48 +08:00
世界 ae852e0be4 Bump version 2025-09-13 03:09:10 +08:00
世界 1955002ed8 Do not cache DNS responses with empty answers 2025-09-13 03:04:08 +08:00
世界 44559fb7b9 Bump version 2025-09-13 00:07:57 +08:00
世界 0977c5cf73 release: Disable Apple platform CI builds, since `-allowProvisioningUpdates is broken by Apple 2025-09-13 00:07:57 +08:00
世界 07697bf931 release: Fix xcode build 2025-09-12 22:57:44 +08:00
世界 5d1d1a1456 Fix TCP exchange for local/dhcp DNS servers 2025-09-12 21:58:48 +08:00
世界 146383499e Fix race codes 2025-09-12 21:58:48 +08:00
世界 e81a76fdf9 Fix DNS exchange 2025-09-12 18:05:02 +08:00
世界 de13137418 Fix auto redirect 2025-09-12 11:04:12 +08:00
世界 e42b818c2a Fix dhcp fetch 2025-09-12 11:03:13 +08:00
世界 fcde0c94e0 Bump version 2025-09-10 22:57:24 +08:00
世界 1af83e997d Update Go to 1.25.1 2025-09-10 22:57:24 +08:00
世界 59ee7be72a Fix SyscallVectorisedPacketWriter 2025-09-10 22:46:41 +08:00
世界 c331ee3d5c Fix timeout check 2025-09-10 22:42:40 +08:00
世界 36babe4bef Fix hysteria2 handshake timeout 2025-09-09 18:03:18 +08:00
世界 c5f2cea802 Prevent panic when wintun dll fails to load 2025-09-09 14:49:00 +08:00
世界 8a200bf913 Fix auto redirect output 2025-09-09 14:29:40 +08:00
世界 f16468e74f Fix ipv6 tproxy listener 2025-09-09 14:16:40 +08:00
世界 79c0b9f51d Fix tls options ignored in mixed inbounds 2025-09-08 19:45:52 +08:00
世界 f98a3a4f65 Treat requests with OPT extra but no options as simple requests 2025-09-08 09:12:30 +08:00
世界 b14cecaeb2 Fix DNS packet size 2025-09-08 09:12:30 +08:00
世界 2594745ef8 Fix DNS client 2025-09-08 09:12:30 +08:00
世界 cc3041322e Fix DNS cache 2025-09-08 09:12:30 +08:00
世界 f352f84483 Fix read address 2025-09-05 15:16:14 +08:00
世界 cbf48e9b8c Fix multiple sniff 2025-09-03 20:09:05 +08:00
世界 0ef7e8eca2 Fix route.default_interface not taking effect 2025-09-02 18:00:02 +08:00
世界 1a18e43a88 Fix linux icmp routes 2025-09-02 17:55:48 +08:00
世界 6849288d6d Fix typo in TestSniffUQUICChrome115 2025-09-02 17:55:26 +08:00
世界 2edfed7d91 Improve DHCP DNS server 2025-09-02 17:55:26 +08:00
世界 30c069f5b7 Fix local DNS server on legacy windows 2025-09-02 17:55:26 +08:00
世界 649163cb7b Fix domain strategy not taking effect 2025-09-02 17:35:27 +08:00
世界 980e96250b Bump version 2025-08-28 12:11:30 +08:00
世界 963bc4b647 Enforce Tailscale NoLogsNoSupport 2025-08-28 10:30:13 +08:00
世界 031f25c1c1 Deprecate common/atomic 2025-08-25 19:49:12 +08:00
世界 b40f642fa4 Bump version 2025-08-21 09:43:47 +08:00
世界 22782ca6fc Fix outbound start 2025-08-21 09:41:31 +08:00
世界 1468d83895 Make realityClientConnWrapper replaceable 2025-08-20 16:26:27 +08:00
世界 97f0dc8a60 Bump version 2025-08-20 09:20:41 +08:00
dyhkwong ee02532ab5 Fix tlsfragment fallback writeAndWaitAck 2025-08-20 09:20:41 +08:00
世界 f1dd0dba78 Make ReadWaitConn reader replaceable 2025-08-20 09:18:03 +08:00
wwqgtxx f4ed684146 Update cast using in sing-vmess 2025-08-20 08:45:09 +08:00
wwqgtxx 83f02d0bfb Make utlsConnWrapper replaceable 2025-08-20 08:45:09 +08:00
wwqgtxx 52fa5f20a3 Make realityConnWrapper replaceable 2025-08-20 08:45:09 +08:00
世界 f462ce5615 Update tfo-go 2025-08-19 21:56:05 +08:00
世界 cef3e538ba Fix failed DNS responses being incorrectly rejected 2025-08-19 11:14:46 +08:00
世界 acda4ce985 Fix bind_interface not working with auto_redirect 2025-08-17 14:48:01 +08:00
世界 354ece2bdf Fix resolved service 2025-08-16 00:09:29 +08:00
世界 de10bb00a9 Fix ssm-api 2025-08-15 15:05:37 +08:00
世界 fdc181106d Fix atomic pointer usages 2025-08-15 15:05:34 +08:00
renovate[bot] 8752b631bd [dependencies] Update golang Docker tag to v1.25 (#3276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 12:45:43 +08:00
世界 378e39f70c Update golangci-lint to v2 2025-08-13 23:37:40 +08:00
renovate[bot] 043a2e7a07 [dependencies] Update github-actions 2025-08-13 22:12:19 +08:00
世界 7e190e92ca Fix build with Go 1.25 2025-08-13 22:08:35 +08:00
世界 5eb318ba06 Update Go to 1.25 2025-08-13 22:08:35 +08:00
世界 4a209f1afb Fix h2mux close check 2025-08-13 21:04:01 +08:00
世界 c0ac3c748c Reduce default MTU for android 2025-08-13 11:48:44 +08:00
世界 a65d3e040a platform: Fix context 2025-08-13 11:26:32 +08:00
世界 2358efe44a release: Fix android build 2025-08-11 22:11:14 +08:00
世界 09d3b8f2c2 release: Fix repo 2025-08-11 22:11:14 +08:00
yu 531de77124 documentation: Fix tun address format 2025-08-11 22:11:13 +08:00
Kismet 44981fd803 documentation: Fix typo 2025-08-11 22:11:13 +08:00
世界 4fb5ac292b Bump version 2025-08-10 20:06:28 +08:00
Sentsuki 0e23a3d7c2 documentation: Fix Rcode's migration guide
Signed-off-by: Sentsuki <52487960+Sentsuki@users.noreply.github.com>
2025-08-10 20:06:28 +08:00
Oleksandr Redko 76ee64ae50 Simplify slice to array conversion 2025-08-10 20:06:28 +08:00
Me0wo e1dbcccab5 documentation: Fix typo
Signed-off-by: Me0wo <152751263+Sn0wo2@users.noreply.github.com>
2025-08-10 20:06:28 +08:00
Youfu Zhang fba802effd Fix libresolv initialization
Fixes: 9533031891 ("Update libresolv usage")

Signed-off-by: Youfu Zhang <zhangyoufu@gmail.com>
2025-08-10 20:06:28 +08:00
世界 9495b56772 Update Go to 1.24.6 2025-08-08 17:07:56 +08:00
世界 a8434b176f Fix SyscallVectorisedWriter 2025-08-08 16:08:47 +08:00
世界 ef0004400d Fix legacy domain resolver deprecated warning incorrectly suppressed for direct outbound 2025-08-07 13:56:35 +08:00
世界 0a63049845 android: Add workaround for tailscale pidfd crash 2025-08-07 12:54:19 +08:00
世界 2dcb86941f Bump version 2025-08-04 08:54:00 +08:00
世界 5c6eb89cfb Fix udp listener write back 2025-08-04 08:54:00 +08:00
世界 5b92eeb3bf Fix auto redirect panic 2025-08-01 16:50:54 +08:00
wwqgtxx 3518ce083b Fix packetaddr panic 2025-08-01 16:50:54 +08:00
世界 f13c54afc1 Fix vless write 2025-07-28 08:01:52 +08:00
世界 3388efe65a Fix ssm-api 2025-07-28 08:01:52 +08:00
世界 a11384b286 Fix time service wrapper 2025-07-24 09:21:08 +08:00
dyhkwong 9dd9fb27cd Fix disable_sni nil time func 2025-07-24 09:21:08 +08:00
世界 0f2035149c Remove dependency on circl 2025-07-23 11:26:06 +08:00
世界 cba364204a Fix VectorisedReadWaiter on windows 2025-07-23 11:26:06 +08:00
世界 4e17788549 Update dependencies 2025-07-23 11:26:06 +08:00
世界 18a6719893 Fix IndexTLSServerName 2025-07-23 11:26:06 +08:00
dyhkwong 687343f6ca Fix disable_sni not working with custom RootCAs 2025-07-22 19:20:09 +08:00
世界 e061538c30 Update Go to 1.24.5 2025-07-21 10:10:02 +08:00
世界 a6375c7530 Fix data corruption in direct copy 2025-07-21 10:09:59 +08:00
世界 45fa18a2e3 Fix vision crash 2025-07-20 18:31:05 +08:00
世界 534cccce91 Fix DNS upgrade 2025-07-18 21:46:03 +08:00
世界 72dbcd3ad4 Improve darwin tun performance 2025-07-18 21:23:04 +08:00
世界 5533094984 Fix UDP DNS buffer size 2025-07-18 12:20:33 +08:00
世界 ae2ecd6002 Increase default mtu to 65535 2025-07-12 14:48:04 +08:00
世界 0098a2adc5 Improve direct copy 2025-07-12 14:48:04 +08:00
世界 c0dd4a3f07 Fix DNS reject check 2025-07-08 13:14:46 +08:00
世界 497ddb5829 Improve copy 2025-07-08 13:14:46 +08:00
世界 811ff93549 Increase default mtu under network extension to 4064 2025-07-08 13:14:46 +08:00
世界 96df69bcdc release: Fix publish testflight 2025-07-08 13:14:46 +08:00
世界 6cfa2b8b86 Improve darwin tun performance 2025-07-08 13:14:46 +08:00
世界 eea1e701b7 Improve nftables rules for openwrt 2025-07-08 13:14:46 +08:00
世界 455e5de74d Fixed DoH server recover from conn freezes 2025-07-08 13:14:45 +08:00
世界 9533031891 Update libresolv usage 2025-07-08 13:14:45 +08:00
yu 80f8ea6849 documentation: Update client configuration manual 2025-07-08 13:14:45 +08:00
yanwo 50eadb00c7 documentation: Fix typo
Signed-off-by: yanwo <ogilvy@gmail.com>
2025-07-08 13:14:45 +08:00
anytinz d4012bd0b2 documentation: Fix wrong SideStore loopback ip 2025-07-08 13:14:45 +08:00
世界 a902e9f9f6 Revert "release: Add IPA build"
After testing, it seems that since extensions are not handled correctly, it cannot be installed by SideStore.
2025-07-08 13:14:45 +08:00
世界 da3ba573d8 release: Add IPA build 2025-07-08 13:14:45 +08:00
世界 bea9048cfe Add API to dump AdGuard rules 2025-07-08 13:14:44 +08:00
Sukka fc0f5ed83a Improve AdGuard rule-set parser 2025-07-08 13:14:44 +08:00
Restia-Ashbell c0588c30d7 Add ECH support for uTLS 2025-07-08 13:14:44 +08:00
世界 24c940c51c Improve TLS fragments 2025-07-08 13:14:44 +08:00
世界 407ee08d8a Add cache support for ssm-api 2025-07-08 13:14:44 +08:00
世界 756585fb2a Fix service will not be closed 2025-07-08 13:14:44 +08:00
世界 5662784afb Add loopback address support for tun 2025-07-08 13:14:44 +08:00
世界 3801901726 Fix tproxy listener 2025-07-08 13:14:43 +08:00
世界 7d58174f1f Fix systemd package 2025-07-08 13:14:43 +08:00
世界 d339f85087 Fix missing home for derp service 2025-07-08 13:14:43 +08:00
Zero Clover b6a114f7f4 documentation: Fix services 2025-07-08 13:14:43 +08:00
世界 e586ef070e Fix dns.client_subnet ignored 2025-07-08 13:14:43 +08:00
世界 71a76e9ecb documentation: Minor fixes 2025-07-08 13:14:42 +08:00
世界 1d66474022 Fix tailscale forward 2025-07-08 13:14:42 +08:00
世界 3934e53476 Minor fixes 2025-07-08 13:14:42 +08:00
世界 0146fbfc40 Add SSM API service 2025-07-08 13:14:42 +08:00
世界 6ee3117755 Add resolved service and DNS server 2025-07-08 13:14:41 +08:00
世界 e2440a569e Add DERP service 2025-07-08 13:14:41 +08:00
世界 7a1eee78df Add service component type 2025-07-08 13:14:41 +08:00
世界 e3c8c0705f Fix tproxy tcp control 2025-07-08 13:14:40 +08:00
愚者 886d427337 release: Fix build tags for android
Signed-off-by: 愚者 <11926619+FansChou@users.noreply.github.com>
2025-07-08 13:14:40 +08:00
世界 d5432b4c27 prevent creation of bind and mark controls on unsupported platforms 2025-07-08 13:14:40 +08:00
PuerNya 42064fe7ec documentation: Fix description of reject DNS action behavior 2025-07-08 13:14:40 +08:00
Restia-Ashbell 7cee76f9a6 Fix TLS record fragment 2025-07-08 13:14:39 +08:00
世界 ed5b2f2997 Add missing accept_routes option for Tailscale 2025-07-08 13:14:39 +08:00
世界 3b480de38a Add TLS record fragment support 2025-07-08 13:14:38 +08:00
世界 f990630ccc Fix set edns0 client subnet 2025-07-08 13:14:38 +08:00
世界 d33614d6a0 Update minor dependencies 2025-07-08 13:14:38 +08:00
世界 b3866bcea0 Update certmagic and providers 2025-07-08 13:14:38 +08:00
世界 26ec73c71b Update protobuf and grpc 2025-07-08 13:14:38 +08:00
世界 c3403c5413 Add control options for listeners 2025-07-08 13:14:38 +08:00
世界 3b6ddcae37 Update quic-go to v0.52.0 2025-07-08 13:14:19 +08:00
世界 dbdcce20a8 Update utls to v1.7.2 2025-07-08 13:12:35 +08:00
世界 e7ef1b2368 Handle EDNS version downgrade 2025-07-08 13:12:35 +08:00
世界 ce32d1c2c3 documentation: Fix anytls padding scheme description 2025-07-08 13:12:34 +08:00
安容 596b66f397 Report invalid DNS address early 2025-07-08 13:12:34 +08:00
世界 d4fd43cf6f Fix wireguard listen_port 2025-07-08 13:12:34 +08:00
世界 6c377f16e7 clash-api: Add more meta api 2025-07-08 13:12:34 +08:00
世界 349db7baec Fix DNS lookup 2025-07-08 13:12:33 +08:00
世界 1f3097da00 Fix fetch ECH configs 2025-07-08 13:12:33 +08:00
reletor 0b4b5e6f0f documentation: Minor fixes 2025-07-08 13:12:33 +08:00
caelansar 245273e6c1 Fix callback deletion in UDP transport 2025-07-08 13:12:32 +08:00
世界 54a0004de6 documentation: Try to make the play review happy 2025-07-08 13:12:32 +08:00
世界 6a211f6ed6 Fix missing handling of legacy domain_strategy options 2025-07-08 13:12:32 +08:00
世界 aadb44ebd6 Improve local DNS server 2025-07-08 13:12:32 +08:00
anytlsandanytls 9b0db6ab15 Update anytls
Co-authored-by: anytls <anytls>
2025-07-08 13:12:31 +08:00
世界 5b363c347f Fix DNS dialer 2025-07-08 13:12:31 +08:00
世界 cdea3f63d4 release: Skip override version for iOS 2025-07-08 13:12:31 +08:00
iikira 40a6260f6e Fix UDP DNS server crash
Signed-off-by: iikira <i2@mail.iikira.com>
2025-07-08 13:12:31 +08:00
ReleTor a5e47f4e0f Fix fetch ECH configs 2025-07-08 13:12:30 +08:00
世界 ac7bc587cb Allow direct outbounds without domain_resolver 2025-07-08 13:12:30 +08:00
世界 4e11a3585a Fix Tailscale dialer 2025-07-08 13:12:30 +08:00
dyhkwong 63d3e9f6e5 Fix DNS over QUIC stream close 2025-07-08 13:12:30 +08:00
anytlsandanytls d115e36ed8 Update anytls
Co-authored-by: anytls <anytls>
2025-07-08 13:12:30 +08:00
Rambling2076 af56b1a950 Fix missing with_tailscale in Dockerfile
Signed-off-by: Rambling2076 <Rambling2076@proton.me>
2025-07-08 13:12:29 +08:00
世界 f9999a76fe Fail when default DNS server not found 2025-07-08 13:12:28 +08:00
世界 42eb3841a1 Update gVisor to 20250319.0 2025-07-08 13:12:28 +08:00
世界 fb622ccbdf Explicitly reject detour to empty direct outbounds 2025-07-08 13:12:28 +08:00
世界 d2dc3ddf72 Add netns support 2025-07-08 13:12:28 +08:00
世界 e8499452f8 Add wildcard name support for predefined records 2025-07-08 13:12:27 +08:00
世界 e0a6b31c03 Remove map usage in options 2025-07-08 13:12:27 +08:00
世界 7c923209ad Fix unhandled DNS loop 2025-07-08 13:12:27 +08:00
世界 bca2bd2fa1 Add wildcard-sni support for shadow-tls inbound 2025-07-08 13:12:26 +08:00
k9982874 fa99ca2757 Add ntp protocol sniffing 2025-07-08 13:12:26 +08:00
世界 7073f2a272 option: Fix marshal legacy DNS options 2025-07-08 13:12:26 +08:00
世界 390e30ae7b Make domain_resolver optional when only one DNS server is configured 2025-07-08 13:12:26 +08:00
世界 23cf8c49e0 Fix DNS lookup context pollution 2025-07-08 13:12:25 +08:00
世界 b17a024f6c Fix http3 DNS server connecting to wrong address 2025-07-08 13:12:25 +08:00
Restia-Ashbell 1ed21085bb documentation: Fix typo 2025-07-08 13:12:25 +08:00
anytlsandanytls 56409ff269 Update sing-anytls
Co-authored-by: anytls <anytls>
2025-07-08 13:12:24 +08:00
k9982874 0c523980ff Fix hosts DNS server 2025-07-08 13:12:24 +08:00
世界 32873d06bc Fix UDP DNS server crash 2025-07-08 13:12:24 +08:00
世界 4accaccf77 documentation: Fix missing ip_accept_any DNS rule option 2025-07-08 13:12:23 +08:00
世界 ff416aacaf Fix anytls dialer usage 2025-07-08 13:12:23 +08:00
世界 b97947e8ac Move predefined DNS server to rule action 2025-07-08 13:12:23 +08:00
世界 dfcd9fb8c3 Fix domain resolver on direct outbound 2025-07-08 13:12:22 +08:00
Zephyruso 803811568e Fix missing AnyTLS display name 2025-07-08 13:12:22 +08:00
anytlsandanytls 50b0bd5c39 Update sing-anytls
Co-authored-by: anytls <anytls>
2025-07-08 13:12:22 +08:00
Estel 2d02b2b1cf documentation: Fix typo
Signed-off-by: Estel <callmebedrockdigger@gmail.com>
2025-07-08 13:12:22 +08:00
TargetLocked 456fbecf16 Fix parsing legacy DNS options 2025-07-08 13:12:21 +08:00
世界 668923c392 Fix DNS fallback 2025-07-08 13:12:21 +08:00
世界 c51e9cbe06 documentation: Fix missing hosts DNS server 2025-07-08 13:12:20 +08:00
anytlsandanytls 60b451e6cf Add MinIdleSession option to AnyTLS outbound
Co-authored-by: anytls <anytls>
2025-07-08 13:12:20 +08:00
ReleTor 3e35390d8f documentation: Minor fixes 2025-07-08 13:12:20 +08:00
libtry486 f2dad289fb documentation: Fix typo
fix typo

Signed-off-by: libtry486 <89328481+libtry486@users.noreply.github.com>
2025-07-08 13:12:20 +08:00
Alireza Ahmadi b4a8fa59f5 Fix Outbound deadlock 2025-07-08 13:12:19 +08:00
世界 73de2a7d07 documentation: Fix AnyTLS doc 2025-07-08 13:12:19 +08:00
anytls 1699a7ce33 Add AnyTLS protocol 2025-07-08 13:12:19 +08:00
世界 7743c6e881 Migrate to stdlib ECH support 2025-07-08 13:12:19 +08:00
世界 9a5f69f435 Add fallback local DNS server for iOS 2025-07-08 13:12:18 +08:00
世界 5c4211e849 Get darwin local DNS server from libresolv 2025-07-08 13:12:18 +08:00
世界 c1189e2a7b Improve resolve action 2025-07-08 13:12:18 +08:00
世界 f18889369f Add back port hopping to hysteria 1 2025-07-08 13:12:17 +08:00
xchacha20-poly1305 91c7b638e8 Remove single quotes of raw Moziila certs 2025-07-08 13:12:17 +08:00
世界 6f793a0273 Add Tailscale endpoint 2025-07-08 13:12:16 +08:00
世界 0f6c417c3c Build legacy binaries with latest Go 2025-07-08 13:12:16 +08:00
世界 c830e9a634 documentation: Remove outdated icons 2025-07-08 13:12:16 +08:00
世界 e809623ec9 documentation: Certificate store 2025-07-08 13:12:16 +08:00
世界 061276902b documentation: TLS fragment 2025-07-08 13:12:15 +08:00
世界 fa6f7d396e documentation: Outbound domain resolver 2025-07-08 13:12:15 +08:00
世界 23666a9230 documentation: Refactor DNS 2025-07-08 13:12:15 +08:00
世界 17576e9f66 Add certificate store 2025-07-08 13:12:14 +08:00
世界 90ec9c8bcb Add TLS fragment support 2025-07-08 13:12:14 +08:00
世界 988ac62a1b refactor: Outbound domain resolver 2025-07-08 13:12:14 +08:00
世界 3016338e34 refactor: DNS 2025-07-08 13:12:14 +08:00
世界 bc35aca017 Bump version 2025-07-08 13:11:13 +08:00
世界 281d52a1ea Fix hy2 server crash 2025-07-08 13:11:13 +08:00
世界 b8502759b5 Fix DNS reject check 2025-07-07 13:57:37 +08:00
世界 6f804adf39 Fix v2rayhttp crash 2025-07-03 21:48:10 +08:00
Kysonandchenqixin 36db31c55a documentation: Fix typo
Co-authored-by: chenqixin <chenqixin@bytedance.com>
2025-06-29 18:54:05 +08:00
世界 4dbbf59c82 Fix logger for acme 2025-06-29 18:44:40 +08:00
世界 832eb4458d release: Fix xcode version 2025-06-29 18:44:40 +08:00
dyhkwong 2cf989d306 Fix inbound with v2ray transport missing InboundOptions 2025-06-25 13:20:00 +08:00
世界 7d3ee29bd0 Also skip duplicate sniff for TCP 2025-06-21 12:57:27 +08:00
世界 cba0e46aba Fix log for rejected connections 2025-06-21 12:57:26 +08:00
世界 9b8ab3e61e Bump version 2025-06-19 11:57:44 +08:00
dyhkwong 47f18e823a Fix: macOS udp find process should use unspecified fallback
https://github.com/Dreamacro/clash/commit/be8d63ba8f70a6f4e7b240899cd2903a77243c24
2025-06-18 08:34:59 +08:00
世界 2d1b824b62 Fix gLazyConn race 2025-06-17 14:24:11 +08:00
世界 d511698f3f Fix slowOpenConn 2025-06-12 08:05:04 +08:00
世界 cb435ea232 Fix default network strategy 2025-06-12 08:05:04 +08:00
世界 43a9016c83 Fix leak in hijack-dns 2025-06-06 14:28:09 +08:00
世界 255068fd40 Bump version 2025-06-04 23:32:10 +08:00
世界 098a00b025 Fix v2ray websocket transport 2025-06-04 23:23:36 +08:00
世界 dba0b5276b Bump version 2025-06-04 20:06:38 +08:00
Sentsuki 78ae935468 documentation: Fix typo
Signed-off-by: Sentsuki <52487960+Sentsuki@users.noreply.github.com>
2025-06-04 20:06:38 +08:00
Mahdi 3ea5f76470 Fix nil logger at v2rayhttp server 2025-06-04 20:06:20 +08:00
世界 b4d294c05e Fix TUIC read buffer 2025-06-04 20:03:51 +08:00
世界 83cf5f5c6a Fix ws closed error message 2025-05-27 14:30:07 +08:00
世界 e7b3a8eebe Fix vmess read request 2025-05-27 14:11:05 +08:00
世界 ee3a42a67e Fix none method read buffer 2025-05-27 14:03:48 +08:00
世界 50227c0f5f Fix sniff action 2025-05-26 18:24:35 +08:00
世界 bc5eb1e1a5 Fix RoutePacketConnectionEx 2025-05-24 08:14:43 +08:00
世界 995267a042 Remove wrong ALPNs in DOH/DOH3 2025-05-24 08:00:13 +08:00
世界 41226a6075 Fix interface finder 2025-05-23 10:57:38 +08:00
世界 81d32181ce Fix update route address set 2025-05-20 19:46:54 +08:00
世界 c5ecca3938 Bump version 2025-05-18 16:48:44 +08:00
世界 900888731c Fix DNS reject response 2025-05-13 18:05:31 +08:00
世界 13e648e4b1 Fix set edns0 subnet 2025-05-07 15:12:17 +08:00
世界 aff12ff671 Bump version 2025-05-05 09:37:47 +08:00
世界 101fb88255 Fix allocator put 2025-05-05 09:37:44 +08:00
世界 8b489354e4 Undeprecate the block outbound 2025-05-04 18:45:53 +08:00
世界 7dea6eb7a6 Fix missing read waiter for cancelers 2025-05-04 18:14:21 +08:00
世界 af1bfe4e3e Make rule_set.format optional 2025-05-04 18:14:21 +08:00
世界 d574e9eb52 Update smux to v1.5.34 2025-04-30 19:39:15 +08:00
世界 2d7df1e1f2 Fix hysteria bytes format 2025-04-29 20:45:19 +08:00
世界 1c0ffcf5b1 Fix counter position in auto redirect dnat rules 2025-04-28 11:20:23 +08:00
世界 348cc39975 Bump version 2025-04-27 21:33:31 +08:00
世界 987899f94a Fix usages of wireguard listener 2025-04-27 21:29:23 +08:00
世界 d8b2d5142f Fix panic on some stupid input 2025-04-25 16:03:58 +08:00
世界 134802d1ee Fix ssh outbound 2025-04-25 16:03:57 +08:00
世界 e5e81b4de1 Fix wireguard listening 2025-04-25 16:03:57 +08:00
世界 300c961efa option: Fix listable again and again 2025-04-25 16:03:57 +08:00
世界 7c7f512405 option: Fix omitempty reject method 2025-04-25 16:03:57 +08:00
世界 03e8d029c2 release: Fix apt-get install 2025-04-25 16:03:57 +08:00
世界 787b5f1931 Fix set wireguard reserved on Linux 2025-04-25 16:03:57 +08:00
世界 56a7624618 Fix vmess working with zero uuids 2025-04-25 16:03:57 +08:00
世界 3a84acf122 Fix hysteria1 server panic 2025-04-25 16:03:57 +08:00
世界 f600e02e47 Fix DNS crash 2025-04-25 16:03:57 +08:00
世界 e6d19de58a Fix overriding address 2025-04-22 14:55:44 +08:00
dyhkwong f2bbf6b2aa Fix sniffer errors override each others
* Fix sniffer errors override each others

* Do not return ErrNeedMoreData if header is not expected
2025-04-22 14:44:55 +08:00
dyhkwongandtrimgop c54d50fd36 Fix websocket detour
Signed-off-by: trimgop <20010323+trimgop@users.noreply.github.com>
Co-authored-by: trimgop <20010323+trimgop@users.noreply.github.com>
2025-04-22 14:44:34 +08:00
世界 6a051054db release: Fix packages 2025-04-19 19:12:01 +08:00
世界 49498f6439 Bump version 2025-04-18 08:54:40 +08:00
世界 144a890c71 release: Add openwrt packages 2025-04-18 08:54:40 +08:00
世界 afb4993445 Fix urltest outbound 2025-04-18 08:54:40 +08:00
世界 4c9455b944 Fix wireguard endpoint 2025-04-18 08:54:40 +08:00
世界 5fdc051a08 Fix override_port in direct inbound 2025-04-16 17:04:13 +08:00
世界 cb68a40c43 documentation: Update actual behaviors of auto_redirect and strict_route 2025-04-12 13:06:16 +08:00
纳西妲 · Nahida 023218e6e7 Fix build will fail when use space to split each tag 2025-04-12 13:06:16 +08:00
世界 2a24b94b8d Minor fixes 2025-04-12 13:06:15 +08:00
世界 c6531cf184 Fix NTP service 2025-04-12 13:06:15 +08:00
世界 d4fa0ed349 Improve auto redirect 2025-04-12 13:06:10 +08:00
世界 10874d2dc4 Bump version 2025-04-08 14:34:09 +08:00
Fei1Yang 5adaf1ac75 Mark config file as noreplace for rpm 2025-04-08 14:21:08 +08:00
世界 9668ea69b8 Fix windows process searcher 2025-04-08 14:16:27 +08:00
testing ae9bc7acf1 documentation: Fix typo
Signed-off-by: testing <58134720+testing765@users.noreply.github.com>
2025-04-08 14:16:23 +08:00
世界 594ee480a2 option: Fix listable 2025-04-08 14:16:23 +08:00
世界 a15b5a2463 Fix no_drop not work 2025-04-08 14:16:23 +08:00
Mahdi 991e755789 Fix conn copy 2025-04-08 14:16:22 +08:00
世界 97d41ffde8 Improve pause management 2025-04-08 14:16:22 +08:00
世界 24af0766ac Fix uTP sniffer 2025-04-08 14:16:22 +08:00
世界 af17eaa537 Improve sniffer 2025-04-08 14:16:22 +08:00
世界 3adc10a797 Fix hysteria2 close 2025-04-08 14:16:22 +08:00
xchacha20-poly1305 5eeef6b28e Fix multiple trackers 2025-04-08 14:16:22 +08:00
世界 f4c29840c3 Fix DNS sniffer 2025-03-31 20:45:04 +08:00
世界 47fc3ebda4 Add duplicate tag check 2025-03-29 23:10:22 +08:00
世界 9774a659b0 Fix DoQ / truncate DNS message 2025-03-29 17:41:22 +08:00
世界 2e4a6de4e7 release: Fix read tag 2025-03-27 20:30:57 +08:00
世界 a530e424e9 Bump version 2025-03-27 18:17:39 +08:00
世界 0bfd487ee9 Fix udpnat2 handler again 2025-03-27 18:17:39 +08:00
世界 6aae834493 release: Fix workflow 2025-03-27 18:17:39 +08:00
世界 f56131f38e Make linter happy 2025-03-24 20:38:42 +08:00
世界 273a11d550 Fix crash on udpnat2 handler 2025-03-24 18:14:32 +08:00
世界 ae8ce75e41 Fix websocket crash 2025-03-24 17:44:14 +08:00
世界 d6d94b689f release: Replace goreleaser build with scripts 2025-03-24 13:48:37 +08:00
世界 30d785f1ee release: Use fake goreleaser key 2025-03-21 22:25:51 +08:00
世界 db5ec3cdfc Fix connectionCopyEarly 2025-03-21 10:51:16 +08:00
世界 9aca54d039 Fix socks5 UDP 2025-03-16 14:46:44 +08:00
世界 d55d5009c2 Fix processing multiple sniffs 2025-03-16 09:21:54 +08:00
世界 4f3ee61104 Fix copy early conn 2025-03-15 08:09:04 +08:00
世界 96eb98c00a Fix httpupgrade crash 2025-03-14 17:17:28 +08:00
世界 68ce9577c6 Fix context in v2ray http transports 2025-03-14 17:07:17 +08:00
世界 3ae036e997 Downgrade goreleaser to stable since nfpm fixed 2025-03-13 18:53:19 +08:00
世界 5da2d1d470 release: Fix goreleaser version 2025-03-12 16:15:50 +08:00
世界 8e2baf40f1 Bump version 2025-03-11 20:18:34 +08:00
世界 c24c40dfee platform: Fix android start 2025-03-11 20:18:34 +08:00
世界 32e52ce1ed Fix udp nat for fakeip 2025-03-11 19:09:27 +08:00
世界 ed46438359 release: Use nightly goreleaser to fix rpm bug 2025-03-11 13:29:08 +08:00
世界 0b5490d5a3 Fix resolve domain for WireGuard 2025-03-11 12:02:25 +08:00
Tal Rashaandtalrasha007 2d73ef511d Fix grpclite memory leak
Co-authored-by: talrasha007 <talrasha007@gmail.om>
2025-03-10 14:48:02 +08:00
Mahdi 63e6c85f6f Fix shadowsocks UoT 2025-03-10 14:47:59 +08:00
世界 8946a6d2d0 release: Use latest goreleaser 2025-03-09 15:27:04 +08:00
世界 d3132645fb documentation: Fix description of the UoT protocol 2025-03-09 15:26:42 +08:00
世界 373f158fe0 Fix download external ui with query params 2025-03-09 15:26:36 +08:00
世界 ce36835fab Fix override destination 2025-03-09 15:25:06 +08:00
世界 619fa671d7 Skip binding to the default interface as it will fail on some Android devices 2025-02-26 07:25:35 +08:00
世界 eb07c7a79e Bump version 2025-02-24 07:27:55 +08:00
Gavin Luo 7eb3535094 release: Fix systemd permissions 2025-02-24 07:27:55 +08:00
世界 93b68312cf platform: Add update WIFI state func 2025-02-23 08:35:30 +08:00
世界 97ce666e43 Fix http.FileServer short write 2025-02-23 08:35:30 +08:00
世界 4000e1e66d release: Fix update android version 2025-02-23 08:35:30 +08:00
世界 270740e859 Fix crash on route address set update 2025-02-23 08:35:30 +08:00
世界 6cad142cfe Bump Go to go1.24 2025-02-23 08:35:30 +08:00
世界 093013687c Fix sniff QUIC hidden in three or more packets 2025-02-18 18:14:59 +08:00
世界 ff31c469a0 Override version 2025-02-11 15:55:15 +08:00
世界 fbe390268c Bump version 2025-02-11 01:32:14 +08:00
世界 07ac01dcb7 platform: Update NDK to r28 2025-02-11 01:32:14 +08:00
ReleTor badfdb62cd documentation: Fixes 2025-02-11 01:32:14 +08:00
printfer 986a410b30 documentation: Fix migration links 2025-02-11 01:32:14 +08:00
世界 9db2d58545 Fix override address 2025-02-11 01:32:14 +08:00
世界 4eed46ac59 Fix respond ICMP echo 2025-02-10 15:12:10 +08:00
世界 abc38d1dab Fix udpnat2 crash 2025-02-10 15:11:26 +08:00
世界 8d6c4f1289 release: Skip testflight when another build in review 2025-02-06 12:02:47 +08:00
世界 a2d40eb8b8 Fix override UDP destination 2025-02-06 11:20:35 +08:00
世界 17b502bb4b Update dependencies 2025-02-06 09:08:52 +08:00
世界 a0d4421085 Update quic-go to v0.49.0 2025-02-06 08:50:21 +08:00
世界 0d443072d1 Fix panic in auto-redirect initialize 2025-02-06 08:49:25 +08:00
世界 c9fb99b799 Fix missing ENOTCONN in IsClosed check 2025-02-06 08:48:49 +08:00
世界 92d245ad04 Bump version 2025-02-05 09:59:52 +08:00
世界 0908627297 Fix crash on remote rule-set stop 2025-02-05 08:58:10 +08:00
世界 7f79458b4f Minor updates 2025-02-01 19:49:33 +08:00
世界 9b4c11ba95 Fix rule-set not closed 2025-02-01 19:49:33 +08:00
世界 27c31eac5d Fix local rule-set not updated 2025-02-01 19:42:21 +08:00
世界 bab8dc0b82 Fix missing handshake for early conn 2025-01-31 12:57:35 +08:00
世界 d09d2fb665 Bump version 2025-01-30 15:09:54 +08:00
世界 e64cf3b7df Do not set address sets to routes on Apple platforms
Network Extension was observed to stop for unknown reasons
2025-01-27 13:40:39 +08:00
世界 9b73222314 documentation: Bump version 2025-01-27 10:53:14 +08:00
世界 3923b57abf release: Update NDK to r28-beta3 2025-01-27 10:53:14 +08:00
世界 4807e64609 documentation: Fix typo 2025-01-27 10:04:07 +08:00
世界 eeb37d89f1 Fix rule-set upgrade command 2025-01-27 09:50:27 +08:00
世界 08c1ec4b7e Fix legacy routes 2025-01-27 09:50:27 +08:00
HystericalDragon 6b4cf67add Fix endpoints not close 2025-01-27 09:50:27 +08:00
世界 e65926fd08 Fix tests 2025-01-13 15:14:30 +08:00
世界 f2ec319fe1 Fix system time 2025-01-13 15:14:30 +08:00
世界 32377a61b7 Add port hopping for hysteria2 2025-01-13 15:14:30 +08:00
世界 7aac801ccd tun: Set address sets to routes 2025-01-13 15:14:30 +08:00
世界 96fdf59ee4 Fix default dialer on legacy xiaomi systems 2025-01-13 15:14:30 +08:00
世界 50b8f3ab94 Add rule-set merge command 2025-01-13 15:14:30 +08:00
世界 ff7aaf977b Fix DNS match 2025-01-13 15:14:30 +08:00
世界 9a1efbe54d Fix domain strategy 2025-01-13 15:14:30 +08:00
世界 906c21f458 Fix time service 2025-01-13 15:14:30 +08:00
世界 d5e7af7a7e Fix socks5 UDP implementation 2025-01-13 15:14:30 +08:00
世界 4d41f03bd5 clash-api: Fix missing endpoints 2025-01-13 15:14:30 +08:00
世界 30704a15a7 hysteria2: Add more masquerade options 2025-01-13 15:14:30 +08:00
世界 83889178ed Improve timeouts 2025-01-13 15:14:30 +08:00
世界 1d2720bf5e Add UDP timeout route option 2025-01-13 15:14:30 +08:00
世界 c4b6d0eadb Make GSO adaptive 2025-01-13 15:14:30 +08:00
世界 0c66888691 Fix lint 2025-01-13 15:14:30 +08:00
世界 68781387fe refactor: WireGuard endpoint 2025-01-13 15:14:30 +08:00
世界 fd299a0961 refactor: connection manager 2025-01-13 15:14:30 +08:00
世界 285a82050c documentation: Fix typo 2025-01-13 15:14:30 +08:00
世界 2dbb8c55c9 Add override destination to route options 2025-01-13 15:14:30 +08:00
世界 effcf39469 Add dns.cache_capacity 2025-01-13 15:14:30 +08:00
世界 9db9484863 Refactor multi networks strategy 2025-01-13 15:14:30 +08:00
世界 ca813f461b documentation: Remove unused titles 2025-01-13 15:14:30 +08:00
世界 bb46cdb2b3 Add multi network dialing 2025-01-13 15:14:30 +08:00
世界 dcb10c21a1 documentation: Merge route options to route actions 2025-01-13 15:14:29 +08:00
世界 05ea0ca00e Add network_[type/is_expensive/is_constrained] rule items 2025-01-13 15:14:29 +08:00
世界 c098f282b1 Merge route options to route actions 2025-01-13 15:14:29 +08:00
世界 ecf82d197c refactor: Platform Interfaces 2025-01-13 15:14:29 +08:00
世界 9afe75586a refactor: Extract services form router 2025-01-13 15:14:29 +08:00
世界 a1be455202 refactor: Modular network manager 2025-01-13 15:14:29 +08:00
世界 19fb214226 refactor: Modular inbound/outbound manager 2025-01-13 15:14:29 +08:00
世界 28ec898a8c documentation: Add rule action 2025-01-13 15:14:29 +08:00
世界 467b1bbeeb documentation: Update the scheduled removal time of deprecated features 2025-01-13 15:14:29 +08:00
世界 02ab8ce806 documentation: Remove outdated icons 2025-01-13 15:14:29 +08:00
世界 ce69e620e9 Migrate bad options to library 2025-01-13 15:14:29 +08:00
世界 1133cf3ef5 Implement udp connect 2025-01-13 15:14:29 +08:00
世界 59a607e303 Implement new deprecated warnings 2025-01-13 15:14:29 +08:00
世界 313be3d7a4 Improve rule actions 2025-01-13 15:14:29 +08:00
世界 4fe40fcee0 Remove unused reject methods 2025-01-13 15:14:29 +08:00
世界 e233fd4fe5 refactor: Modular inbounds/outbounds 2025-01-13 15:14:29 +08:00
世界 9f7683818f Implement dns-hijack 2025-01-13 15:14:29 +08:00
世界 179e3cb2f5 Implement resolve(server) 2025-01-13 15:14:29 +08:00
世界 41b960552d Implement TCP and ICMP rejects 2025-01-13 15:14:29 +08:00
世界 8304295c48 Crazy sekai overturns the small pond 2025-01-13 15:14:29 +08:00
世界 253b41936e Bump version 2025-01-11 20:58:00 +08:00
世界 ce5b4b06b5 auto-redirect: Fix fetch interfaces 2025-01-07 21:24:52 +08:00
世界 50f5006c43 Fix leak in reality server 2025-01-07 17:27:10 +08:00
世界 e42ff22c2e quic: Fix source not unwrapped 2025-01-07 17:27:10 +08:00
世界 578571b972 Bump version 2025-01-02 15:21:13 +08:00
世界 935beca45d Fix check interface 2025-01-01 12:19:56 +08:00
世界 3e246f1173 Fix vmess mux leak 2025-01-01 12:18:56 +08:00
世界 1bc27a32c2 Fix linter configuration 2025-01-01 12:18:56 +08:00
世界 bc2e3960e4 Enable fix stack for Android 7 and 9 2024-12-28 15:06:57 +08:00
世界 9c4ab0bf33 Bump version 2024-12-21 17:20:56 +08:00
世界 27bdef34c7 release: Fix create app store version 2024-12-21 17:20:56 +08:00
世界 3c00099ed4 Fix process rule check 2024-12-21 17:15:41 +08:00
世界 2babf07f9a Fix wireguard 2024-12-21 16:07:19 +08:00
世界 4795ed712b release: Fix android version 2024-12-21 16:07:19 +08:00
世界 d4cd564dbe release: Fix check tag 2024-12-21 16:07:19 +08:00
世界 1676e13d3e Bump version 2024-12-17 13:43:17 +08:00
世界 50576084c6 release: Add publish testflight 2024-12-17 13:43:17 +08:00
世界 3a94e792a2 documentation: Fix uid range example 2024-12-15 13:56:25 +08:00
世界 9f69f41f68 Update http file server usage 2024-12-15 13:56:25 +08:00
世界 e6847ff50e Add locale support ford deprecated messages 2024-12-15 02:57:08 +08:00
世界 2ac2589d14 release: Fix publish testflight 2024-12-15 02:57:08 +08:00
世界 64a94e8144 release: Fix UpdateBuildForAppStoreVersion 2024-12-14 20:18:36 +08:00
世界 3ed8a5c5d1 wireguard: Fix windows tun read 2024-12-14 20:18:36 +08:00
世界 0a922c6fe3 release: Fix Xcode version 2024-12-14 20:18:36 +08:00
世界 52f3a4226c release: Add app store connect actions 2024-12-14 20:18:36 +08:00
世界 483d9fa503 release: Fix check prerelease 2024-12-14 20:18:36 +08:00
世界 dd9de694f8 release: Update macOS project version atomically 2024-12-14 20:18:36 +08:00
世界 5cdf5c1d9e release: Fix play release 2024-12-14 20:18:36 +08:00
世界 cec7e47086 Add workaround for bulkBarrierPreWrite: unaligned arguments panic 2024-12-14 20:18:36 +08:00
世界 1e6a3f1f0b release: Update debug iOS library build 2024-12-12 14:51:47 +08:00
世界 f0b6818b4c Add workaround for golang/go#68760 2024-12-10 10:30:42 +08:00
世界 3032317918 release: Add workflow build 2024-12-10 09:25:11 +08:00
世界 db22f61846 Update NDK to r28-rc1 2024-12-09 15:11:38 +08:00
世界 8c3a98faa2 wireguard: Fix set reserved 2024-12-05 17:58:09 +08:00
世界 1e787cb607 Fix initial traffic value 2024-12-03 21:43:56 +08:00
世界 558585b01d release: Set upload threads to 5 2024-12-03 17:36:19 +08:00
世界 6e7ecbd4f5 Fix wireguard listen 2024-11-30 12:20:48 +08:00
世界 5a661cde67 clashapi: Remove traffic loop 2024-11-28 12:57:56 +08:00
世界 3cc0e87cfb Bump version 2024-11-27 10:45:24 +08:00
世界 effea5a2b3 Update quic-go to v0.48.2 2024-11-27 10:37:00 +08:00
世界 7f168c5ec6 release: Clean before iOS build 2024-11-27 10:35:20 +08:00
Zephyruso 0e9129ee3f clashapi: Add mode list 2024-11-27 10:34:47 +08:00
世界 1086d5e665 Fix test tags 2024-11-23 12:20:12 +08:00
世界 d9102ba599 Fix build on bsd systems 2024-11-23 12:16:46 +08:00
世界 17019f1729 Bump version 2024-11-20 12:03:42 +08:00
世界 6be07ed51f Fix start watcher 2024-11-20 11:32:53 +08:00
世界 af58e3bec0 Fix debug listener 2024-11-20 11:32:53 +08:00
世界 e58b549d0f Fix "Fix reloading of tls.certificate_path, tls.key_path and tls.ech.key_path" 2024-11-18 19:03:24 +08:00
zeetex 1d81996ceb Fix reloading of tls.certificate_path, tls.key_path and tls.ech.key_path 2024-11-18 14:09:54 +08:00
世界 97c47e72c4 Update dependencies 2024-11-18 14:07:00 +08:00
世界 122be275b0 release: Notarize macos standalone manually with --no-s3-acceleration 2024-11-18 14:07:00 +08:00
世界 0bb1132034 selector: Fix crash before start 2024-11-18 14:07:00 +08:00
世界 de14337b4b Fix deprecated check 2024-11-18 14:07:00 +08:00
世界 1e07633914 Downgrade NDK to 26.2.11394342 2024-11-18 13:10:06 +08:00
世界 e3e203844e Fix decompile rule-set 2024-11-18 13:10:06 +08:00
世界 84a102a6ef Fix mux stream accept 2024-11-09 12:26:49 +08:00
世界 f1c76c4dde Fix deprecated version check 2024-11-08 20:31:29 +08:00
世界 8df0aa5719 Downgrade NDK to r26d 2024-11-07 19:58:53 +08:00
世界 21faadb992 Uniq deprecated notes 2024-11-07 19:58:53 +08:00
世界 88099a304a platform: Add SendNotification 2024-11-06 12:53:32 +08:00
世界 f504fb0d46 Revert "platform: Add openURL event"
This reverts commit 718cffea9a.
2024-11-05 20:03:39 +08:00
世界 1d517b6ca5 platform: Add link flags 2024-11-05 18:28:13 +08:00
世界 b702d0b67a Update dependencies 2024-11-05 18:28:03 +08:00
世界 a001e30d8b platform: Remove SetTraceback("all") 2024-11-05 18:28:02 +08:00
世界 cdb93f0bb2 Fix "Fix metadata context" 2024-11-05 18:27:51 +08:00
世界 718cffea9a platform: Add openURL event 2024-11-05 18:27:51 +08:00
世界 9585c53e9f release: Add upload dSYMs 2024-10-30 15:35:20 +08:00
世界 d66d5cd457 Add deprecated warnings 2024-10-30 14:01:28 +08:00
世界 8c143feec8 Increase timeouts 2024-10-30 14:01:28 +08:00
世界 419058f466 Update NDK version 2024-10-30 14:01:13 +08:00
世界 1a6047a61b Fix metadata context 2024-10-30 14:01:13 +08:00
世界 327bb35ddd Rename HTTP start context 2024-10-30 14:01:13 +08:00
世界 6ed9a06394 Fix rule-set format 2024-10-25 22:12:47 +08:00
世界 b80ec55ba0 Bump version 2024-10-16 21:11:31 +08:00
世界 08718112ae Retry system forwarder listen 2024-10-16 20:47:26 +08:00
TsingShuiandx_123 956ee361df Fix corrected improper use of reader and bReader
Co-authored-by: x_123 <x@a>
2024-10-16 20:45:18 +08:00
世界 e93d0408be documentation: Fix release notes 2024-10-16 20:45:13 +08:00
世界 137832ff3e Bump version 2024-10-13 21:17:59 +08:00
世界 3ede29fb6d documentation: Improve theme 2024-10-13 13:07:18 +08:00
世界 82ab68b542 build: Fix find NDK 2024-10-13 13:07:18 +08:00
renovate[bot] e55723d84d [dependencies] Update actions/checkout digest to eef6144 2024-10-13 13:07:18 +08:00
世界 2f4d2d97f9 auto-redirect: Let fw4 take precedence over prerouting 2024-10-13 13:07:18 +08:00
世界 926d6f769e Update utls to v1.6.7 2024-10-13 13:07:02 +08:00
srk24 846777cd0c Add process_path_regex rule type 2024-10-13 13:07:02 +08:00
世界 06533b7a3b clash-api: Add PNA support 2024-10-13 13:07:02 +08:00
世界 4a95558c53 Add RDP sniffer 2024-10-13 13:07:02 +08:00
世界 e39a28ed5a Add SSH sniffer 2024-10-13 13:07:02 +08:00
世界 b2c708a3e6 Write close error to log 2024-10-13 13:07:02 +08:00
世界 a9209bb3e5 Add AdGuard DNS filter support 2024-10-13 13:07:02 +08:00
世界 9dc3bb975a Improve QUIC sniffer 2024-10-13 13:07:02 +08:00
世界 3a7acaa92a Add inline rule-set & Add reload for local rule-set 2024-10-13 13:07:02 +08:00
世界 6bebe2483b Unique rule-set names 2024-10-13 13:07:02 +08:00
世界 93cf134995 Add accept empty DNS rule option 2024-10-13 13:07:02 +08:00
世界 ff7d8c9ba8 Add custom options for TUN auto-route and auto-redirect 2024-10-13 13:07:02 +08:00
世界 50f07b42f6 Improve base DNS transports & Minor fixes 2024-10-13 13:07:02 +08:00
世界 db3a0c636d Add auto-redirect & Improve auto-route 2024-10-13 13:07:02 +08:00
世界 fec38f85cd Add rule-set decompile command 2024-10-13 13:07:02 +08:00
世界 dcb0141646 Add IP address support for rule-set match match 2024-10-13 13:07:02 +08:00
世界 f4f5a3c925 Improve usages of json.Unmarshal 2024-10-13 13:07:02 +08:00
世界 9b8d6c1b73 Bump rule-set version 2024-10-13 13:07:02 +08:00
世界 2f776168de Implement read deadline for QUIC based UDP inbounds 2024-10-13 13:07:02 +08:00
世界 923d3222b0 WTF is this 2024-10-13 13:07:01 +08:00
世界 bda93d516b platform: Fix clash server reload on android 2024-10-13 13:06:57 +08:00
世界 7eec3fb57a platform: Add log update interval 2024-10-13 13:06:57 +08:00
世界 b1d75812c5 platform: Prepare connections list 2024-10-13 13:06:55 +08:00
世界 d44e7d9834 Drop support for go1.18 and go1.19 2024-10-07 04:58:48 +08:00
世界 369bc7cea3 Add DTLS sniffer 2024-10-07 04:58:48 +08:00
iosmanthusand世界 4b7a83da16 Introduce bittorrent related protocol sniffers
* Introduce bittorrent related protocol sniffers

including, sniffers of
1. BitTorrent Protocol (TCP)
2. uTorrent Transport Protocol (UDP)

Signed-off-by: iosmanthus <myosmanthustree@gmail.com>
Co-authored-by: 世界 <i@sekai.icu>
2024-10-07 04:58:48 +08:00
世界 0f7154afbd Update workflow to go1.23 2024-10-07 04:58:47 +08:00
世界 a06d10c3bc Bump version 2024-10-07 04:34:48 +08:00
世界 63cc6cc76c Fix Makefile 2024-10-07 04:34:48 +08:00
世界 d55c5b5cab documentation: Update package status 2024-10-07 04:34:48 +08:00
世界 b624c2dcc7 Fix context used by DNS outbounds 2024-10-07 04:34:48 +08:00
世界 9415444ebd Fix base path not applied to local rule-sets 2024-10-07 04:34:48 +08:00
世界 95606191d8 Add completions for linux packages 2024-10-07 04:34:48 +08:00
世界 e586d9e9bc Bump version 2024-09-20 23:37:06 +08:00
世界 8c7eaa4477 Fix docker build 2024-09-20 23:37:06 +08:00
世界 8464c8cb7c Fix version script 2024-09-20 21:10:15 +08:00
世界 39d7127651 Revert "Fix stream sniffer" 2024-09-20 20:40:02 +08:00
世界 e2077009c4 documentation: Update client status 2024-09-20 20:13:55 +08:00
世界 700a8eb425 Minor fixes 2024-09-20 20:13:14 +08:00
世界 3b0cba0852 Fix wireguard start 2024-09-20 20:12:52 +08:00
世界 f5554dd8b8 Bump version 2024-09-18 07:04:29 +08:00
世界 4d0362d530 Update macOS build workflow 2024-09-17 22:01:05 +08:00
世界 97ccd2ca04 documentation: Add sponsors page 2024-09-17 18:47:33 +08:00
世界 1ed6654ad4 Add mips64 build 2024-09-15 12:12:25 +08:00
世界 5385f75f53 documentation: Update build requirements 2024-09-15 12:10:00 +08:00
世界 ad97d4e11f Fix disconnected interface selected as default in windows 2024-09-15 11:59:32 +08:00
世界 09d4e91b77 Fix cached conn eats up read deadlines 2024-09-15 11:56:04 +08:00
Monica 3dbdda9555 documentation: Fix dial.zh.md
The Chinese documentation incorrectly stated that the default value for the domain_strategy field in the direct outbound module is dns.strategy. The correct value should be inbound.domain_strategy, as specified in the English documentation. This commit corrects the Chinese documentation to align with the accurate behavior described in the English version.

Signed-off-by: Monica <1379531829@qq.com>
2024-09-15 11:53:03 +08:00
世界 1f4ed6ff8f documentation: Update client status 2024-09-13 10:09:08 +08:00
世界 6ddbe19bc0 platform: Update bundle id 2024-09-12 17:55:53 +08:00
世界 d7205ecc60 Fix Makefile 2024-09-12 17:55:53 +08:00
世界 9e243e0ff9 gomobile: Fix go mod version 2024-09-10 23:05:13 +08:00
世界 02bc3e0a0a Update quic-go to v0.47.0 2024-09-09 14:45:46 +08:00
世界 87be6dc235 Update README 2024-09-09 08:48:35 +08:00
世界 c1c30976dc Improve docker workflow 2024-09-08 11:11:47 +08:00
世界 9bac18bcd1 wireguard: Fix events chan leak 2024-09-08 10:07:12 +08:00
世界 ceda5cc95d clash-api: Fix bad redirect 2024-09-08 10:07:07 +08:00
世界 27d6b63e71 Fix stream sniffer 2024-09-08 10:07:07 +08:00
世界 b57abcc73c tfo: Fix build with go1.23 2024-08-27 11:24:51 +08:00
世界 f1147965dd documentation: Fix missing zh headline 2024-08-21 18:52:38 +08:00
世界 45f3234c73 documentation: Update package status 2024-08-21 11:39:07 +08:00
Mingye Wang aae3fded32 documentation: Two updates
* Copyedit documentation

Close #1378

* remove yum, go full on dnf

fixes #2049
2024-08-21 11:32:43 +08:00
世界 090494faf5 Do not close bug and enhancement issues 2024-08-21 08:09:15 +08:00
世界 db5719e22f Fix no error return when empty DNS cache retrieved 2024-08-20 23:23:48 +08:00
世界 064fb9b873 Fix direct dialer not resolving domain 2024-08-20 21:02:52 +08:00
世界 f6a1e123fc Make linter happy 2024-08-19 18:42:02 +08:00
世界 3066dfe3b3 Bump version 2024-08-19 10:43:09 +08:00
Liu Bingyan 1128fdd8c7 documentation: Update injectable description 2024-08-19 07:40:31 +08:00
世界 cfd9879b17 documentation: Remove unused 2024-08-19 06:39:33 +08:00
世界 9ceb660c57 documentation: Announce that our apps on Apple platforms are no longer available 2024-08-19 06:39:33 +08:00
世界 7d00d7df28 Update quic-go to v0.46.0 2024-08-19 06:25:15 +08:00
世界 21b1ac26b9 Fix UDP conn stuck on sniff
This change only avoids permanent hangs. We need to implement read deadlines for UDP conns in 1.10 for server inbounds.
2024-08-18 11:27:12 +08:00
世界 7fec8d842e Update golangci-lint configuration 2024-08-18 11:17:01 +08:00
世界 07c678fb85 Fix crash on Android when using process rules 2024-08-18 10:23:28 +08:00
世界 baecfc7778 Update quic-go to v0.45.2 2024-08-18 10:23:17 +08:00
世界 07de36ecdb Fix tests 2024-08-03 12:46:38 +08:00
世界 2c8a8303cd dns: Minor fixes 2024-07-27 11:04:57 +08:00
ruokeqx e5991cae0b Use unix.SysctlRaw for macOS 2024-07-22 12:42:22 +08:00
世界 1349acfd5a Fix panic caused by possible generation of duplicate keys for domain_suffix 2024-07-17 16:02:17 +08:00
世界 98ff897f35 Fix reset outbounds 2024-07-13 17:46:22 +08:00
ayooh 6144c8e340 Fix default end value of the rangeItem 2024-07-13 17:45:32 +08:00
HystericalDragon c8caac9f67 Fix v2rayquic default NextProtos (#1934) 2024-07-11 23:34:09 +08:00
printfer 81e9eda357 documentation: Fix typo 2024-07-07 16:09:41 +08:00
世界 7cba3da108 shadowsocks: Fix packet process for AEAD multi-user server 2024-07-05 17:09:09 +08:00
世界 82d06b43e7 vless: Fix missing deadline interfaces 2024-07-05 17:08:10 +08:00
世界 a7ac91f573 Move legacy vless implemetation to library 2024-07-03 20:17:32 +08:00
世界 0540a95a43 Fix v2ray-plugin 2024-07-02 14:08:17 +08:00
世界 94707dfcdd Add name to errors from v2ray HTTP transports 2024-07-02 14:08:17 +08:00
世界 8a17043502 Fix command output for rule-set match 2024-06-26 12:26:35 +08:00
世界 b0aaa86806 Minor fixes 2024-06-25 13:14:45 +08:00
世界 8a2d3fbb28 Update quic-go to v0.45.1 & Filter HTTPS ipv4/6hint 2024-06-24 11:07:32 +08:00
renovate[bot] 4652019608 [dependencies] Update docker/build-push-action action to v6 2024-06-24 10:24:13 +08:00
世界 06fa5abf63 Fix non-IP queries accepted by address filter rules 2024-06-24 10:13:16 +08:00
世界 996fbbf0c3 docs: Switch to venv 2024-06-24 10:10:49 +08:00
renovate[bot] 142ff1b455 [dependencies] Update actions/checkout digest to 692973e 2024-06-17 13:05:54 +08:00
世界 74d662f7a3 Fix always create package manager 2024-06-11 21:16:57 +08:00
世界 085f603377 Bump version 2024-06-09 13:20:56 +08:00
世界 460fae83dc Fix quic-go is caching dialErr since v0.43.0 2024-06-09 13:15:40 +08:00
世界 bb9bd9bff6 Fix package manager start order 2024-06-09 07:21:50 +08:00
世界 c2354ebf25 Bump version 2024-06-08 22:00:46 +08:00
世界 c1f4755c4e Fix parse UDP DNS server with addr:port address 2024-06-08 22:00:46 +08:00
世界 0ca5909b06 Stop passing device sleep events on Android and Apple platforms 2024-06-08 22:00:46 +08:00
世界 e77a8114c5 Fix rule-set start order 2024-06-08 22:00:46 +08:00
renovate[bot] f1393235ff [dependencies] Update actions/checkout digest to a5ac7e5 2024-06-08 22:00:46 +08:00
renovate[bot] bdba2365de [dependencies] Update goreleaser/goreleaser-action action to v6 2024-06-08 18:58:22 +08:00
世界 ce0da5b557 Fix typo 2024-06-08 18:58:14 +08:00
世界 3853201412 Bump version 2024-06-07 19:07:46 +08:00
世界 7003ef40a3 Fix wireguard start 2024-06-07 19:07:46 +08:00
世界 59ec92228c Fix repeatedly no route logs 2024-06-07 15:03:48 +08:00
世界 0eeb2da323 Fix reset HTTP3 DNS transport 2024-06-06 22:28:43 +08:00
世界 977b0fac02 Fix get source address from X-Forwarded-For 2024-06-06 22:21:37 +08:00
世界 51964801ff Fix enforced power listener on windows 2024-06-06 22:20:23 +08:00
世界 e08c052fc9 Fix wireguard client bind 2024-06-06 22:20:21 +08:00
世界 53927d8bbd Remove logs in router initialize 2024-06-06 22:20:19 +08:00
世界 968b9bc217 Fix crash on *bsd 2024-06-06 22:20:17 +08:00
lgjint 69dc87aa6d Fix set KDE6 system proxy 2024-06-06 22:20:14 +08:00
Fei1Yang 4193df375f build: Remove vendor in RPM packages 2024-05-27 19:28:15 +08:00
世界 5ff7006326 Bump version 2024-05-25 11:30:43 +08:00
世界 a89107ea9d documentation: Bump version 2024-05-23 14:57:45 +08:00
世界 9ffdbba2ed documentation: Add manuel for mitigating tunnelvision attacks 2024-05-23 14:57:27 +08:00
世界 65c71049ea documentation: Update DNS manual 2024-05-23 14:57:26 +08:00
世界 7d4e6a7f4e dialer: Allow nil router 2024-05-23 14:57:26 +08:00
世界 d612620c5d Add rule-set match command 2024-05-23 14:57:26 +08:00
世界 8a9a77a438 Add bypass_domain and search_domain platform HTTP proxy options 2024-05-23 14:57:26 +08:00
世界 a2098c18e1 Handle includeAllNetworks 2024-05-23 14:57:26 +08:00
世界 cf2181dd3a Update gVisor to 20240422.0 2024-05-23 14:57:15 +08:00
世界 5899e95ff1 Update quic-go to v0.43.1 2024-05-21 15:12:05 +08:00
世界 d7160c19cf Fixed order for Clash modes 2024-05-21 15:12:05 +08:00
世界 da9e22b4e6 Add custom prefix support in EDNS0 client subnet options 2024-05-21 15:12:05 +08:00
气息 0e120f8a44 Fix DNS exchange index
Signed-off-by: 气息 <qdshizh@gmail.com>
2024-05-21 15:12:05 +08:00
dyhkwong d918863ac5 Always disable cache for fake-ip servers 2024-05-21 15:12:04 +08:00
PuerNya 2ae192305c Always disable cache for fake-ip DNS transport if independent_cache disabled 2024-05-21 15:12:03 +08:00
世界 71d1879bd6 Fix missing rule_set_ipcidr_match_source item in DNS rules 2024-05-21 15:12:03 +08:00
世界 917514e09f Improve DNS truncate behavior 2024-05-21 15:12:03 +08:00
世界 5327aeaea4 Fix DNS fallthrough incorrectly 2024-05-21 15:12:03 +08:00
世界 93ae3f7a1e Add rejected DNS response cache support 2024-05-21 15:12:03 +08:00
世界 f24a2aed7d Add support for client-subnet DNS options 2024-05-21 15:12:03 +08:00
世界 0517ceef76 Add address filter support for DNS rules 2024-05-21 15:12:02 +08:00
世界 830ea46932 Fix timezone for Android and iOS 2024-05-21 15:11:52 +08:00
世界 cd0fcd5ddc Improve loopback detector 2024-05-21 15:11:52 +08:00
世界 003176f069 Remove unused fakeip packet conn 2024-05-21 15:11:52 +08:00
世界 71d92518c1 Set the default TCP keep alive period 2024-05-21 15:11:52 +08:00
世界 b5dcd6bf59 Migrate ntp service to library 2024-05-21 15:11:52 +08:00
世界 11c7b4a866 Handle Windows power events 2024-05-21 15:11:52 +08:00
世界 ee14135298 Improve domain suffix match behavior
For historical reasons, sing-box's `domain_suffix` rule matches literal prefixes instead of the same as other projects.

This change modifies the behavior of `domain_suffix`: If the rule value is prefixed with `.`,
the behavior is unchanged, otherwise it matches `(domain|.+\.domain)` instead.
2024-05-21 15:11:41 +08:00
世界 cbcf005f37 Remove PROCESS_NAME_NATIVE dwFlag in process query output
The `process_path` rule of sing-box is inherited from Clash,
the original code uses the local system's path format (e.g. `\Device\HarddiskVolume1\folder\program.exe`),
but when the device has multiple disks, the HarddiskVolume serial number is not stable.

This change make QueryFullProcessImageNameW output a Win32 path (such as `C:\folder\program.exe`),
which will disrupt the existing `process_path` use cases in Windows.
2024-05-18 17:22:14 +08:00
世界 daee0b154e badtls: Support uTLS and TLS ECH for read waiter 2024-05-18 17:22:14 +08:00
世界 d530c724c0 Bump version 2024-05-18 16:53:05 +08:00
世界 7f698c1104 Fix hysteria2 panic 2024-05-18 16:20:32 +08:00
renovate[bot] 7a4a44c6d2 [dependencies] Update golangci/golangci-lint-action action to v6 2024-05-10 19:49:20 +08:00
世界 44277e5dd2 Fix urltest logic 2024-05-10 17:41:20 +08:00
世界 1f470c69c4 Update docker workflow 2024-05-03 19:33:20 +08:00
世界 742adacce7 Bump version 2024-05-03 17:38:26 +08:00
世界 32e1d5a5e2 documentation: Update package status 2024-05-03 17:38:26 +08:00
世界 cb9f4ce597 Minor fixes 2024-05-03 15:34:47 +08:00
世界 4b1a6185ba Fix DNF repo 2024-04-29 23:13:04 +08:00
世界 8d85c92356 Fix multiplex client dialer context 2024-04-29 11:58:20 +08:00
世界 c6164c9eca Fix usage of github actions 2024-04-29 11:58:20 +08:00
dyhkwong 3c85b8bc48 Fix fake-ip mapping 2024-04-29 11:58:20 +08:00
HystericalDragon 8b8fb4344c Remove unused encoder 2024-04-29 11:58:20 +08:00
renovate[bot] e85a38e059 [dependencies] Update golangci/golangci-lint-action action to v5 2024-04-29 11:58:20 +08:00
renovate[bot] f3ac91673a [dependencies] Update actions/checkout digest to 0ad4b8f 2024-04-29 11:58:20 +08:00
世界 0f1e58b917 documentation: Update TestFlight 2024-04-29 11:58:20 +08:00
世界 c4cfe24aef documentation: Fix strict_route description 2024-04-29 11:58:20 +08:00
世界 3d73b159ba Fix linux workflow 2024-04-29 11:58:20 +08:00
世界 0ae1afef44 Bump version 2024-04-23 14:14:40 +08:00
世界 a5e2a4073b Update dependencies 2024-04-23 14:03:55 +08:00
世界 b6cb3948a3 Fix initial packet MTU for QUIC protocols 2024-04-23 11:12:31 +08:00
世界 7b0f5061dc Fix loopback detector 2024-04-17 21:45:54 +08:00
世界 76f20482f7 Fix linux repo 2024-04-12 22:52:37 +08:00
世界 e735a5bdc8 Update dependencies 2024-04-12 22:52:37 +08:00
世界 70381e93c8 Bump version 2024-04-08 11:55:41 +08:00
世界 07a40716e8 Update dependencies 2024-04-08 11:45:00 +08:00
世界 5fea5956db Fix linux network monitor 2024-04-08 11:45:00 +08:00
世界 d20a389043 Fix timer usage 2024-04-08 11:45:00 +08:00
世界 4a4180bde5 Fixes for QUIC protocols 2024-04-08 11:44:55 +08:00
世界 7ecb6daabb Update dependencies 2024-03-29 04:52:06 +00:00
世界 712bdd9ae5 Fix fury release 2024-03-25 10:44:57 +08:00
世界 a3b74591a7 Fix syscall packet read waiter 2024-03-25 01:49:59 +08:00
世界 2f4abc6523 Fix canceler 2024-03-24 19:12:07 +08:00
dyhkwong 965ab075d9 Fix source_ip_is_private matching 2024-03-24 19:06:02 +08:00
世界 ed2f8b9637 Bump version 2024-03-23 20:50:45 +08:00
世界 0f71ce5120 tun: Fix GSO batch size 2024-03-22 14:54:57 +08:00
世界 f8085ab111 Fix Makefile 2024-03-21 23:32:02 +08:00
世界 f61b272cbf Fix WireGuard client bind 2024-03-20 10:52:24 +08:00
世界 59d437b9d2 Use local legacy compiler 2024-03-19 13:53:34 +08:00
世界 a7338fdc2b Fix DNS panic 2024-03-19 12:17:32 +08:00
Puqns67 d88860928e Fix the installation location of service files in prebuilded package 2024-03-19 12:13:59 +08:00
世界 20a2e38f47 Fix build for F-Droid 2024-03-17 21:43:22 +08:00
世界 acd438be23 Fix fury workflow 2024-03-17 21:43:22 +08:00
世界 e27fb51b54 Bump version 2024-03-16 12:05:58 +08:00
世界 adc38b26eb Fix Makefile 2024-03-15 18:06:37 +08:00
世界 7e943e743a Fix darwin interface monitor 2024-03-15 18:06:37 +08:00
世界 ceffcc0ad2 platform: Improve stop on apple platforms 2024-03-15 18:06:37 +08:00
世界 fdc451f7c6 platform: Fix missing log on apple platforms 2024-03-15 18:06:37 +08:00
世界 b48c471e6a Add repo release 2024-03-15 18:06:37 +08:00
世界 4b1fabd007 Fix lint workflow 2024-03-13 19:39:59 +08:00
世界 2b5eb1c59e Add sponsor link to issue template 2024-03-13 19:39:59 +08:00
世界 e2d3862e64 platform: reset network on invalid power events 2024-03-13 19:39:59 +08:00
世界 4f5e7b974d Fix crash in HTTP proxy server again 2024-03-10 16:53:58 +08:00
世界 21dedddd93 Update dependencies 2024-03-08 23:36:33 +08:00
世界 e02502bec0 Update go 1.20 2024-03-08 23:31:42 +08:00
世界 ba67633ee8 Fix missing source address in inbound logs in QUIC inbounds 2024-03-07 10:47:16 +08:00
世界 7fd9abe802 Bump version 2024-03-05 13:14:38 +08:00
世界 78a5f59202 documentation: Add link to F-Droid 2024-03-05 13:13:31 +08:00
世界 8d0da685d2 Update dependencies 2024-03-02 14:29:28 +08:00
世界 e6644f784e Fix crash in HTTP proxy server 2024-03-02 14:28:47 +08:00
世界 2b93b74d38 Fix SO_BINDTOIFINDEX usage 2024-02-29 13:03:05 +08:00
世界 dd52c26ae1 Don't return error in WireGurad client bind 2024-02-28 15:28:38 +08:00
世界 f288e3898b Bump version 2024-02-28 15:10:28 +08:00
世界 1bc893a73a documentation: Update privacy policy to make Play Store happy 2024-02-28 15:10:28 +08:00
世界 7359fdf195 platform: Upgrade NDK to the latest LTS version 2024-02-28 15:10:28 +08:00
世界 02b7041de6 Update dependencies 2024-02-26 22:53:31 +08:00
世界 96ac931b11 Fix network/interface monitor 2024-02-26 22:53:31 +08:00
世界 3077a82650 Fix reproducible builds 2024-02-24 23:19:31 +08:00
世界 de998c5119 Fix docker workflow 2024-02-24 22:49:07 +08:00
世界 d32c30c4b7 documentation: Bump version 2024-02-24 13:20:43 +08:00
世界 4823023806 Remove invalid archlinux packages from release 2024-02-24 13:20:43 +08:00
Aleksandr Razumov bb355d17b2 Add riscv64 to platform list in release 2024-02-24 13:20:43 +08:00
hiddify aaf30bf92b platform: Fix group update interval not taking effect 2024-02-24 13:20:43 +08:00
hiddify f8c400cffc platform: Remove duplicated close 2024-02-24 13:20:43 +08:00
hatune-miku 3c24411e14 documentation: Fix navigation menu 2024-02-24 13:20:42 +08:00
世界 4a44aa3c21 Fix HTTP inbound 2024-02-24 13:20:42 +08:00
世界 8db2ae0c83 Fix documentation 2024-02-24 13:20:42 +08:00
世界 80d1aebcb7 platform: Unify client versions 2024-02-24 13:20:27 +08:00
世界 5583e01c99 platform: Export NeedWIFIState for Android 2024-02-18 14:24:21 +08:00
世界 bca0b86549 Copy DNS message struct instead of deep copy 2024-02-10 23:49:09 +08:00
世界 8332878cdc Fix destination IP CIDR match in DNS 2024-02-10 22:37:42 +08:00
世界 d0ba69ad22 Fix TUN unaligned panic on windows 2024-02-10 21:22:02 +08:00
世界 31b8834427 documentation: Fix description for with_ech 2024-02-10 12:01:09 +08:00
renovate[bot] d0f7a59e9b [dependencies] Update golang Docker tag to v1.22 2024-02-10 11:54:40 +08:00
renovate[bot] 71e7d517a8 [dependencies] Update github-actions 2024-02-10 11:54:31 +08:00
世界 e6885e9967 platform: Ignore momentary pause on iOS 2024-02-09 13:57:47 +08:00
世界 e2090923db Bump Go version 2024-02-08 20:31:40 +08:00
世界 46be319976 Fix external controller crash before started 2024-02-08 20:31:40 +08:00
renovate[bot] b27bc45cf2 [dependencies] Update actions/cache action to v4 2024-02-03 15:08:41 +08:00
世界 3d735281f4 documentation: Bump version 2024-02-02 17:51:40 +08:00
世界 8760a0d94d Fix android interface monitor 2024-02-02 17:51:40 +08:00
世界 2239b59933 Remove duplicated rules 2024-02-02 14:30:27 +08:00
世界 425a63f59d Fix rawConn not closed for hy/hy2 2024-02-02 14:30:27 +08:00
世界 b85725c009 urltest: Remember the last choice when there is no valid result 2024-02-02 11:27:36 +08:00
世界 17aebc56c1 Fix UDP DNS response not truncated 2024-02-01 14:43:59 +08:00
Devman f76b21b02c Fix mobile build on windows
gobind executable name is not exactly `gobind` on windows it's `gobind.exe`

Signed-off-by: Devman <85770917+amir-devman@users.noreply.github.com>
2024-02-01 12:07:39 +08:00
kkocdko 704545a2ec Fix rule description header of domain_suffix
I read other rule_item_xxx.go files, they are all snake case. This description is showed on dashboard like yacd.

Signed-off-by: kkocdko <31189892+kkocdko@users.noreply.github.com>
2024-02-01 10:42:12 +08:00
dyhkwong dc7b7afc06 Fix loop back detector 2024-02-01 10:41:38 +08:00
世界 e478d3c2dc Update workflow 2024-01-24 12:21:18 +08:00
世界 c8318058bb documentation: Bump version 2024-01-23 11:57:28 +08:00
世界 abca2118e7 documentation: Update geosite usage 2024-01-23 11:57:28 +08:00
世界 a8ee41715a platform: Add service error wrapper for macOS system extension 2024-01-22 19:00:09 +08:00
世界 94f76d6671 Update dependencies 2024-01-22 14:37:21 +08:00
世界 bf6cc8903c Fix missing write result in TFO open 2024-01-19 11:22:13 +08:00
世界 1b15e1692a Add sponsor button 2024-01-19 11:22:13 +08:00
世界 017372db25 Fix missing loopback detect 2024-01-19 11:22:12 +08:00
世界 216a0380fe documentation: Bump version 2024-01-16 05:50:07 +08:00
Noob Zhang 71b9e4ff17 Add network-online.target in .service files
This helps the daemon work better on IoT devices
like RaspberryPi.
According to systemd's documentation,
`network.target` means there has already been
a network manager started, but the network may
not be "up". On most PCs this does not matter
because the network will turn to "up" almost
immidiately. The IoT devices' network interface
may not be set up quickly enough, so they may
meet that the sing-box daemon is started before
network is ready, which results that sing-box
cannot find a working route. The workaround
of this is restarting sing-box daemon but it
absolutely is not the perfect solution.
As `network-online.target` must be triggered by
network manager after you configured it, I keep
`network.target` so there will be no change to
those who do not enabled proper trigger service
like `NetworkManager-wait-online.service`.

See also: https://systemd.io/NETWORK_ONLINE/
2024-01-16 05:50:07 +08:00
printfer 9b7deb5246 Enhanced light/dark mode support in mkdocs configuration 2024-01-16 05:50:07 +08:00
世界 a850a73e1a Fix missing upstream func for read wait conn 2024-01-16 05:50:07 +08:00
世界 c4d9be9e0d Fix rule match 2024-01-14 13:01:57 +08:00
世界 f31c604b3d documentation: Bump version 2024-01-09 12:22:22 +08:00
世界 4c8a50a52b Fix TLS conn cast for vision 2024-01-09 12:22:22 +08:00
世界 b326e60998 Update dependencies 2024-01-09 12:22:22 +08:00
世界 11bec79a06 documentation: Bump version 2024-01-05 11:17:49 +08:00
世界 16eff06c37 documentation: remove usages of category-companies@cn since merged into cn 2024-01-03 12:21:53 +08:00
世界 2911eba236 documentation: Update package managers 2024-01-03 12:21:48 +08:00
世界 2e607118c3 Remove unnecessary context wrappers 2024-01-03 12:21:48 +08:00
世界 89c723e3e4 Improve read wait interface &
Refactor Authenticator interface to struct &
Update smux &
Update gVisor to 20231204.0 &
Update quic-go to v0.40.1 &
Update wireguard-go &
Add GSO support for TUN/WireGuard &
Fix router pre-start &
Fix bind forwarder to interface for systems stack
2024-01-03 12:21:47 +08:00
世界 35fd9de3ff Make generated files have SUDO_USER's permissions if possible. 2024-01-03 12:21:47 +08:00
世界 6ddcd3954d Refactor inbound/outbound options struct 2024-01-03 12:21:47 +08:00
世界 36b0f2e91a Improve configuration merge 2024-01-03 12:21:47 +08:00
世界 fe053e26b5 Update uTLS to 1.5.4 2024-01-03 12:21:47 +08:00
世界 269434cfe6 Update tfo-go 2024-01-03 12:21:47 +08:00
世界 88495a24dc Update gomobile and add tag 2024-01-03 12:21:46 +08:00
世界 d131a7c10a Update cloudflare-tls to go1.21.5 2024-01-03 12:21:46 +08:00
世界 744a5d703b Make type check strict 2024-01-03 12:21:46 +08:00
世界 09421b6378 Remove comparable limit for Listable 2024-01-03 12:21:46 +08:00
世界 21283b554a Avoid opening log output before start &
Replace tracing logs with task monitor
2024-01-03 12:21:46 +08:00
世界 25810b50c1 Update documentation 2024-01-03 12:21:37 +08:00
世界 f1e3a59db3 Add idle_timeout for URLTest outbound 2024-01-03 12:21:37 +08:00
世界 a99deb2cb5 Skip internal fake-ip queries 2024-01-03 12:21:37 +08:00
世界 38d28e0763 Migrate contentjson and badjson to library &
Add omitempty in format
2024-01-03 12:21:37 +08:00
世界 e09a94bb9e Update documentation 2024-01-03 12:21:36 +08:00
世界 a21c5324fd Independent source_ip_is_private and ip_is_private rules 2024-01-03 12:21:36 +08:00
世界 4b43acfec0 Add rule-set 2024-01-03 12:21:36 +08:00
世界 7df151e820 Update buffer usage 2024-01-03 12:21:36 +08:00
世界 5948ffb965 Allow nested logical rules 2024-01-03 12:21:36 +08:00
世界 bf4e556f67 Migrate to independent cache file 2024-01-03 12:21:36 +08:00
世界 e3f8567690 documentation: Bump version 2024-01-02 14:31:53 +08:00
世界 40c7f3e170 Fix geoip close 2024-01-02 14:31:23 +08:00
世界 c506255e0f Fix grpc lite transport encoding 2024-01-01 16:16:58 +08:00
世界 87c6fd4c0f Fix h2mux request context 2024-01-01 16:15:49 +08:00
世界 19c445d28e documentation: Bump version 2023-12-29 18:00:40 +08:00
世界 9119a5209b dcoumentation: Fix description of cipher_suites 2023-12-29 18:00:40 +08:00
世界 46c8d6e61f Fix pprof URL path 2023-12-29 18:00:40 +08:00
世界 ea17c2786d Update dependencies 2023-12-27 10:37:18 +08:00
世界 12ababd911 Fix mux test 2023-12-27 10:30:19 +08:00
世界 0523845833 Update issue reporting templates
Enhanced the issue reporting templates for both English and Chinese versions by adding more structured and comprehensive guideline checkboxes. This aims to ensure contributors provide sufficient and beneficial information for reproducing and resolving issues, thereby improving the quality of reports and making issue tracking more efficient.
2023-12-26 19:05:19 +08:00
renovate[bot] 57794919fa [dependencies] Update actions/upload-artifact action to v4 2023-12-26 19:04:51 +08:00
世界 f5bb5cf343 Fix missing marshal for udp_timeout 2023-12-26 10:52:46 +08:00
世界 3eed614dea Fix ACME ALPN conflict 2023-12-26 09:02:58 +08:00
世界 76a295a660 Fix missing nil check for URLTest 2023-12-26 09:02:58 +08:00
世界 082e3fb8df Fix V2Ray transport path validation behavior 2023-12-26 09:02:58 +08:00
世界 a0cab4f563 Fix websocket client initialize 2023-12-22 20:38:06 +08:00
世界 aeb7308e81 documentation: Bump version 2023-12-21 15:25:19 +08:00
世界 bb1ebfda83 documentation: Fix link format 2023-12-21 15:24:05 +08:00
世界 c05c798221 Fix missing UDP timeout for QUIC protocols 2023-12-21 15:16:36 +08:00
世界 55b1bcc6a5 Migrate udp_timeout from seconds to duration format 2023-12-21 14:50:33 +08:00
世界 d6eddce420 Fix missing handshake timeout for multiplex 2023-12-21 14:21:59 +08:00
世界 4bf057139b Fix DNS dial context 2023-12-21 14:19:27 +08:00
世界 a1b28b8282 Try to fix HTTP server leak again 2023-12-21 14:16:16 +08:00
世界 d0aaf71770 Fix direct UDP override 2023-12-18 21:48:59 +08:00
世界 2f31202c6b documentation: Update shadowsocks example
Remove the information on password generation for `2022-blake3-aes-128-gcm` cipher from the Server Example section in the shadowsocks.md file as it is no longer needed.
2023-12-14 21:05:41 +08:00
renovate[bot] e4cc510712 [dependencies] Update github-actions 2023-12-14 15:14:22 +08:00
世界 e329bf6865 Update dependencies 2023-12-14 15:09:03 +08:00
世界 2badcec765 platform: Fix check config 2023-12-12 20:26:41 +08:00
世界 e71c13b1a2 documentation: Bump version 2023-12-12 13:54:23 +08:00
世界 a959a67ed3 FIx error handling for netlink banned in Android 2023-12-12 13:54:23 +08:00
世界 a1044af579 platform: Fix VPN route 2023-12-11 21:59:59 +08:00
世界 a64b57451a Update dependencies 2023-12-11 21:40:11 +08:00
世界 f0e2318cbd Fix auto-route IPv6 on darwin 2023-12-11 21:39:29 +08:00
世界 ebec308fd8 documentation: Bump version 2023-12-09 00:22:27 +08:00
世界 ca094587be Fix incorrect dependency 2023-12-09 00:22:27 +08:00
世界 ca3b86c781 Update bug report template 2023-12-08 21:56:30 +08:00
世界 5a1d0047b9 documentation: Bump version 2023-12-08 21:38:34 +08:00
世界 4669854039 Fix method check for v2ray HTTP transport 2023-12-08 21:12:52 +08:00
世界 2eecdc38a4 Fix gun conn setup 2023-12-08 21:10:36 +08:00
世界 83581b7c1a Update dependencies 2023-12-08 11:18:52 +08:00
世界 d346f0023d Fix HTTP connect client again 2023-12-08 11:18:52 +08:00
世界 47b7a29cbd Fix fallback packet conn 2023-12-06 18:47:50 +08:00
世界 cffc07579d Fix missing omitempty for NTP server fields 2023-12-05 18:32:21 +08:00
世界 0ef268637e Prevent nil LocalAddr or RemoteAddr 2023-12-05 15:11:09 +08:00
世界 50f5a76380 Update dependencies 2023-12-04 21:06:15 +08:00
世界 20ca05dd36 Fix crash on websocket concurrent request 2023-12-04 20:42:01 +08:00
世界 5a792b186a documentation: Bump version 2023-12-03 16:54:49 +08:00
世界 3f458064a3 Fix not set Host header for HTTP outbound 2023-12-03 16:54:49 +08:00
世界 5269231df0 Fix URLTest outbound 2023-12-02 17:55:58 +08:00
世界 fc8e49994c documentation: Bump version 2023-12-01 20:39:01 +08:00
世界 e911d4aa4b Fix URLTest group early start 2023-12-01 20:39:01 +08:00
世界 01f6e70bc5 Fix deadline usage 2023-12-01 20:39:01 +08:00
世界 5f1e39a42c documentation: Bump version 2023-11-29 21:01:28 +08:00
世界 4f7770e254 Update dependencies 2023-11-29 21:01:19 +08:00
世界 e8c4c942c0 documentation: Bump version & Refactor docs 2023-11-28 11:21:57 +08:00
世界 253976d6c0 Add wifi_ssid and wifi_bssid route and DNS rules 2023-11-28 11:21:44 +08:00
世界 f0571b4122 Update quic-go to v0.40.0 2023-11-28 11:21:44 +08:00
世界 1b71e52e90 Migrate multiplex and UoT server to inbound & Add tcp-brutal support for multiplex 2023-11-28 11:21:44 +08:00
世界 6d24be23da Add support for v2ray http upgrade transport 2023-11-28 11:21:44 +08:00
世界 2a45c178fa Add exclude route support for tun &
Update gVisor to 20231113.0
2023-11-28 11:21:43 +08:00
世界 81e214812f Add udp_disable_domain_unmapping inbound listen option 2023-11-28 11:21:43 +08:00
世界 4d23773a25 Migrate to gobwas/ws 2023-11-28 11:21:43 +08:00
世界 40a0b69918 Fix dhcp reset 2023-11-28 11:20:48 +08:00
世界 a7b37c5953 documentation: Bump version 2023-11-24 20:58:48 +08:00
世界 03663a5093 Fix cachefile permission 2023-11-24 20:58:48 +08:00
世界 b08226a850 Fix "Fix HTTP server leak" 2023-11-24 19:58:31 +08:00
世界 edbae5dc4d Fix missing UDP user context on TUIC/Hysteria2 inbounds 2023-11-24 19:56:43 +08:00
世界 0f8ad0234b Remove unused code 2023-11-24 19:55:53 +08:00
世界 661eadc3bd documentation: Bump version 2023-11-21 10:22:44 +08:00
世界 50c1290567 Update dependencies 2023-11-21 10:22:44 +08:00
世界 eaccc9759a Fix platform API check 2023-11-21 10:22:44 +08:00
世界 925214869b Add test for ss2022 EIH 2023-11-20 18:36:44 +08:00
世界 6a2bfd26d0 Fix QUIC sniffer 2023-11-16 22:48:16 +08:00
世界 72a81afb76 Fix "Fix Linux IPv6 auto route rules" 2023-11-16 18:25:07 +08:00
世界 240abe204c Fix zero TTL was incorrectly reset 2023-11-16 18:25:07 +08:00
世界 7c49196792 build: Fix bad environment key 2023-11-16 01:42:24 +08:00
嫦悅 3a2808cff6 documentation: Fix typo
The old meaning is wrong. Correct the meaning according to the English documentation and the actual effect of the option.

Signed-off-by: 嫦悅 <lomombwlo@gmail.com>
2023-11-16 01:12:26 +08:00
guangwu 005d6cf4cf chore: unnecessary use of fmt.Sprintf 2023-11-16 01:10:52 +08:00
世界 36dff630d6 documentation: Bump version 2023-11-15 14:10:37 +08:00
世界 1825869124 platform: Refactor log interface 2023-11-15 14:10:37 +08:00
世界 3cadc90375 Fix TUIC authentication failed error message 2023-11-14 20:15:41 +08:00
renovate[bot] 2c6967d7f9 dependencies: Update actions/checkout digest to b4ffde6
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-14 20:15:41 +08:00
世界 fe866b123a Fix sing-tun version 2023-11-14 20:15:41 +08:00
世界 cbef1b1e59 Remove apk build due to missing openrc configuration 2023-11-14 20:15:41 +08:00
世界 e21f84932c build: Fix missing linux/386 2023-11-14 10:35:27 +08:00
0x7d274284 7a679bc328 Fix Dockerfile
Keep the .git folder at compile time to get the correct version

Signed-off-by: 0x7d274284 <112329548+0x7d274284@users.noreply.github.com>
2023-11-14 10:34:38 +08:00
世界 6635dd9abc documentation: Bump version 2023-11-13 21:50:36 +08:00
世界 ce164724ea build: Add apk and archlinux package builds 2023-11-13 15:28:28 +08:00
世界andmaskedeken a3ef7a7d88 Fix trojan-go mux context
Co-authored-by: maskedeken <maskedeken@yahoo.com>
Co-authored-by: 世界 <i@sekai.icu>
2023-11-13 14:12:35 +08:00
世界 71218ef0d3 build: Unify build tags 2023-11-13 14:12:35 +08:00
世界andMahdi-zarei e777b4c6dc Fix not closing outConn
Co-authored-by: Mahdi-zarei  <mahdi.zrei@gmail.com>
Co-authored-by: 世界 <i@sekai.icu>
2023-11-13 13:54:03 +08:00
世界 6815f94180 build: Update Go to 1.20.11 for legacy builds 2023-11-13 13:47:15 +08:00
世界 b013acd89d tun: Fix broadcast filter not applied to mixed stack 2023-11-13 13:35:10 +08:00
世界 f7c2eb6e76 Fix v2ray ws crash 2023-11-13 13:34:31 +08:00
世界 3ef9b1b343 Update protobuf generated binary 2023-11-10 10:56:25 +08:00
SakuraWald 2224c68959 Fix issue template 2023-11-10 10:36:46 +08:00
Kumiko as a Service bb7d03d1db Use golang's cross-compilation capabilities 2023-11-10 10:36:32 +08:00
世界 50036924e8 documentation: Bump version 2023-11-10 10:24:18 +08:00
世界 c2c3f7284f Revert "Fix Host ignored in v2ray websocket transport"
This reverts commit aaa6702863.
2023-11-09 16:55:47 +08:00
世界 f6fee53676 Fix mux client close 2023-11-09 16:55:47 +08:00
世界 63b8e8ed23 platform: Increase HTTP timeout to 15s 2023-11-09 16:55:47 +08:00
世界 6ae86eda98 build: Update gradle command 2023-11-09 16:55:47 +08:00
世界 267d9617b7 build: Fix tag calculate 2023-11-07 22:27:37 +08:00
世界 0a06ccae50 platform: Fix legacy code 2023-11-07 22:14:23 +08:00
世界 8de0fad9f5 documentation: Bump version 2023-11-07 10:20:05 +08:00
世界 e05bf6308e Fix build script 2023-11-07 10:20:05 +08:00
世界 a20a0cb455 Add broadcast filter 2023-11-07 10:20:05 +08:00
世界 d29f7475d2 documentation: Bump version 2023-11-06 19:37:45 +08:00
世界 aaa6702863 Fix Host ignored in v2ray websocket transport 2023-11-05 23:27:11 +08:00
世界 bb928f096a Fix missing default next proto in hysteria2 2023-11-05 23:11:40 +08:00
johnthecoderpro 9f01d5c5b4 Fix download geo resources 2023-11-05 16:03:15 +08:00
世界 11629a931b Update release script 2023-11-05 16:02:18 +08:00
世界 126f825241 Update dependencies 2023-11-05 16:02:10 +08:00
世界 998cc7bd22 Add multicast filter for tun 2023-11-04 08:04:17 +08:00
世界 3efccaa8f5 Update dependencies 2023-10-31 18:24:58 +08:00
世界 d57b35ec30 documentation: Add privacy policy for android 2023-10-31 17:32:18 +08:00
世界 e82dab027d documentation: Bump version 2023-10-30 13:59:49 +08:00
世界 9350f3983b docs: Remove obsolete fields 2023-10-30 13:59:49 +08:00
世界 53b123241f android: Add build info tools for debug 2023-10-30 12:41:24 +08:00
世界 97286eea1e Add TLS self sign generate command 2023-10-30 12:41:23 +08:00
世界 343e24969d Add brutal debug option for Hysteria2 2023-10-30 12:41:23 +08:00
世界 31c294d998 Update BBR and Hysteria congestion control & Migrate legacy Hysteria protocol to library 2023-10-30 12:41:22 +08:00
世界 3b161ab30c Fix netip.Prefix usage 2023-10-30 12:41:22 +08:00
septs 41fd1778a7 Improve HTTP headers option 2023-10-30 12:41:22 +08:00
septs ac930cf1aa Improve naive auth logical 2023-10-30 12:41:22 +08:00
世界 e143fc510d Update gVisor to 20230814.0 2023-10-30 12:41:21 +08:00
世界 bea177a4cd Improve linux bind interface 2023-10-30 12:41:21 +08:00
世界 aa05a4d050 Remove deprecated features 2023-10-30 12:41:21 +08:00
世界 a8112ff824 Update workflows 2023-10-30 12:40:52 +08:00
世界 a7710c3845 documentation: Bump version 2023-10-30 10:42:42 +08:00
世界 cb2e15f8a7 Fix UDP domain NAT 2023-10-28 21:41:39 +08:00
世界 23aa8a0543 Add legacy builds for old Windows and macOS versions 2023-10-26 14:02:24 +08:00
世界 edf7d046eb Fix outbound not found message 2023-10-26 14:02:19 +08:00
世界 de0b5cc1c2 Fix Linux IPv6 auto route rules 2023-10-26 12:02:00 +08:00
世界 2686e8afea Fix TUIC server TLS config not started 2023-10-26 12:01:28 +08:00
世界 d9853ca2be Update dependencies 2023-10-26 11:57:18 +08:00
世界 b617eb5adf documentation: Bump version 2023-10-23 14:09:09 +08:00
世界 ddf38799e2 makefile: Fix release command 2023-10-23 14:09:06 +08:00
世界 5291d43dc8 makefile: Add -allowProvisioningUpdates to Apple build commands 2023-10-21 17:51:00 +08:00
世界 a634830d85 Fix invalid address check in UoT conn 2023-10-21 17:23:30 +08:00
世界 e5d191ca73 Add retry for bbolt open 2023-10-14 19:24:05 +08:00
世界 2371f0fd51 Update dependencies 2023-10-14 17:36:35 +08:00
世界 cfdce7a96f Fix bbolt panic on arm32 2023-10-14 17:36:13 +08:00
世界 dc8ac01dec documentation: Bump version 2023-10-11 12:05:31 +08:00
世界 5f18738b2b Fix task cancel context 2023-10-11 12:05:27 +08:00
世界 7b4e4ca2d0 Fix compatibility with Android 14 2023-10-10 15:09:49 +08:00
世界 01ba4668b6 platform: Also reset connections on wake 2023-10-10 15:08:18 +08:00
dyhkwong e782d21806 Fix connect domain for IP outbound 2023-10-10 15:08:16 +08:00
世界 00155d61fc documentation: Bump version 2023-10-07 10:04:10 +08:00
世界 8f2273a2b4 documentation: Bump version 2023-10-06 17:10:53 +08:00
世界 0d0526afa2 Update dependencies 2023-10-06 17:10:31 +08:00
世界 ac2d07b61a Fix UDP dialer network 2023-10-03 11:08:31 +08:00
世界 d35487f422 Fix ip_version does not take effect 2023-10-03 11:01:25 +08:00
世界 2749f4a013 documentation: Bump version 2023-10-03 09:22:29 +08:00
世界 45c679648e platform: Fix log server 2023-10-03 09:22:29 +08:00
世界 5f2f7fc8b9 Fix HTTP inbound leak 2023-10-01 14:39:58 +08:00
世界 83c79102cf Update xcode version script 2023-10-01 14:05:51 +08:00
世界 8b95292e53 Update dependencies 2023-09-30 22:34:54 +08:00
世界 3de7a2ddd3 Fix concurrent access on task returnError 2023-09-30 21:52:11 +08:00
Shanoa Ice 8437a6cb4e Fix set KDE system proxy 2023-09-30 16:44:58 +08:00
世界 9c4d08c6e1 documentation: Bump version 2023-09-28 16:02:54 +08:00
世界 e26096085e Remove invalid code 2023-09-28 15:52:05 +08:00
世界 2f1b2199c5 Fix DHCPv4 listen address 2023-09-27 18:25:23 +08:00
世界 af791db01f documentation: Bump version 2023-09-27 14:16:04 +08:00
世界 abcf030d89 Update dependencies 2023-09-27 14:16:04 +08:00
世界 7840dc73e3 Fix resolve dialer 2023-09-27 13:17:16 +08:00
世界 df9050400e android: Fix netlink check 2023-09-26 17:41:07 +08:00
世界 fdd38d6cf8 documentation: Bump version 2023-09-25 21:56:32 +08:00
世界 9891fd672f Reject SOCKS4 unauthenticated request 2023-09-25 21:16:14 +08:00
世界 92a84ee112 Update dependencies 2023-09-25 17:56:21 +08:00
世界 992331f17e documentation: Bump version 2023-09-24 14:40:03 +08:00
世界 4fb227ed86 Fix payload not return to pool 2023-09-24 14:40:03 +08:00
世界 5a1ddea100 Fix dns log 2023-09-24 12:38:33 +08:00
世界 fbaa2f9de9 Fix merge command 2023-09-24 12:12:35 +08:00
世界 97ab9bb194 Fix shadow-tls user context 2023-09-24 12:12:35 +08:00
世界 61ac141124 Improve URLTest delay calculate 2023-09-23 20:27:43 +08:00
世界 d4d49d9df5 Fix ACME DNS01 DNS challenge 2023-09-23 20:26:46 +08:00
世界 c60a944aac Fix missing context in geo resources download 2023-09-23 20:26:46 +08:00
世界 17584c245f documentation: Bump version 2023-09-22 12:08:09 +08:00
世界 6e84b694a4 Minor fixes 2023-09-22 12:07:39 +08:00
世界 34a93171f0 Update dependencies 2023-09-20 22:20:40 +08:00
世界 678f6ef72f Fix debug.SetMemoryLimit usage 2023-09-20 22:01:58 +08:00
世界 ae8187ed15 documentation: Bump version 2023-09-20 14:15:54 +08:00
世界 12dd1ac87f Improve conntrack 2023-09-20 14:15:00 +08:00
世界 85c8f00885 documentation: Bump version 2023-09-19 19:59:29 +08:00
世界 e7b7ae811f Add merge command 2023-09-19 19:59:07 +08:00
世界 a9743b77f6 Don't return success as an error when the lookup result is empty 2023-09-19 19:05:23 +08:00
世界 4068871d97 Update quic-go 2023-09-19 18:33:44 +08:00
世界 f05afcea39 Update dependencies 2023-09-19 18:33:06 +08:00
renovate[bot] 688e9daef4 [dependencies] Update github-actions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-17 13:12:03 +08:00
世界 64edacffb7 Bump version 2023-09-17 01:09:48 +08:00
世界 743df5373b Fix hysteria2 mbps calculation 2023-09-17 01:09:48 +08:00
世界 e80084316d Fix panic on create system proxy failed 2023-09-17 01:09:41 +08:00
世界 9dcd427743 Fix v2ray websocket transport 2023-09-17 01:09:41 +08:00
世界 d17e93384b Add ACME DNS01 challenge support via libdns 2023-09-17 01:09:41 +08:00
世界 c1ffcf365e Fix test 2023-09-16 23:45:47 +08:00
世界 3040e97222 Fix gomobile build on macOS 2023-09-16 00:09:45 +08:00
世界 5f063fb0b5 Update Makefile 2023-09-16 00:09:41 +08:00
世界 a7dadd8671 documentation: Update Clash default_mode description 2023-09-16 00:09:41 +08:00
世界 c320be75a7 Add interrupt support for outbound groups 2023-09-16 00:09:41 +08:00
世界 bd7adcbb7e Migrate QUIC wrapper and protocol implementations to library 2023-09-16 00:09:41 +08:00
世界 1d6d3edec5 documentation: Update changelog for stable versions 2023-09-15 17:34:39 +08:00
世界 46bfeb574c documentation: Bump version 2023-09-12 15:57:33 +08:00
世界 a1449ee40e Mark deprecated features 2023-09-12 13:26:23 +08:00
世界 8cb41b5fa6 documentation: Add hysteria2 2023-09-12 13:26:22 +08:00
世界 53475c7390 Add hysteria2 protocol 2023-09-12 13:26:21 +08:00
世界 5d8af150a7 Improve system proxy API 2023-09-12 13:26:21 +08:00
HiddifyandHiddify 69499a51a5 Add KDE set system proxy support
Co-authored-by: Hiddify <114227601+hiddify1@users.noreply.github.com>
2023-09-12 13:26:20 +08:00
世界 4c050d7f4b Add ECH support for QUIC based protocols 2023-09-12 13:26:20 +08:00
世界 533fca9fa3 documentation: Update TLS ECH struct 2023-09-12 13:26:20 +08:00
世界 187bf2f7bc Enable with_ech by default 2023-09-12 13:26:19 +08:00
世界 983a4222ad Add ECH keypair generator 2023-09-12 13:26:19 +08:00
世界 2ea506aeb8 Remove legacy NTP usages 2023-09-12 13:26:19 +08:00
世界 5b343d4c72 Improve ECH support 2023-09-12 13:26:19 +08:00
世界 be61ca64d4 Fix SOCKS outbound 2023-09-12 13:26:03 +08:00
世界 efe33cf48d Fix QUIC DNS 2023-09-12 13:14:21 +08:00
世界 fe8d46cce5 Fix TFO async write 2023-09-09 19:52:13 +08:00
世界 b1f289bce5 Fix TUIC context 2023-09-09 11:41:04 +08:00
世界 a8beb80876 Update Makefile 2023-09-07 22:37:55 +08:00
世界 ff209471d8 Fix QUIC defragger 2023-09-07 21:35:25 +08:00
unknown 806f7d0a2b Fix QUIC stream usage 2023-09-07 21:34:36 +08:00
世界 6b943caf37 Reject invalid connection 2023-09-07 21:34:36 +08:00
世界 4ea2d460f4 Fix router close 2023-09-07 21:34:35 +08:00
世界 c84c18f960 platform: Fix crash on android 2023-09-07 21:34:35 +08:00
世界 1402bdab41 Fix connect domain for IP outbounds 2023-09-07 21:34:35 +08:00
renovate[bot] 7082cf277e [dependencies] Update actions/checkout action to v4 2023-09-07 21:34:35 +08:00
世界 b9310154a7 clash-api: Move default mode to first 2023-09-07 21:34:35 +08:00
世界 55c34e3fb0 platform: Improve client 2023-09-07 21:34:35 +08:00
世界 68f2202eec documentation: Bump version 2023-08-31 23:32:44 +08:00
renovate[bot] 5057e50bb8 [dependencies] Update actions/stale action to v8
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-31 23:32:44 +08:00
世界 23e1a69955 Update Makefile 2023-08-31 23:32:44 +08:00
世界 b83c6c9d20 Fix return nil addr in conn 2023-08-30 21:28:03 +08:00
世界 67deac6d44 Fix hysteria packet write 2023-08-30 18:23:17 +08:00
世界 ea3731162b Update Makefile 2023-08-30 18:23:17 +08:00
世界 c75e32e722 documentation: Update changelog 2023-08-29 10:26:11 +08:00
世界 e7b35be5f6 Update Makefile 2023-08-29 10:26:11 +08:00
世界 5a309266f0 Fix TUIC client 2023-08-28 19:58:02 +08:00
世界 05669eaaad documentation: Update changelog 2023-08-25 20:33:16 +08:00
世界 e91a6e5439 Update dependencies 2023-08-25 20:21:14 +08:00
世界 43f72a6419 Add store_mode and platform Clash mode selector 2023-08-25 20:21:14 +08:00
世界 6dcacf3b5e Fix sniffer 2023-08-24 21:53:06 +08:00
世界 edad4d1ce7 Update dependencies 2023-08-24 11:31:29 +08:00
世界 262842c87d Fix test 2023-08-22 11:22:13 +08:00
世界 376f527742 documentation: Bump version 2023-08-21 18:24:31 +08:00
世界 c0bbb3849d Fix TUIC UDP 2023-08-21 18:16:38 +08:00
世界 738c25d818 Fix Makefile 2023-08-21 18:14:27 +08:00
dyhkwong 027af4d4ee Fix SOCKS5 UDP sniffing accidentially enabled 2023-08-20 19:06:37 +08:00
世界 6011f4483a Remove bad scripts 2023-08-20 17:57:39 +08:00
世界 fc22466e3b documentation: Bump version 2023-08-20 17:29:22 +08:00
世界 975e13a313 Add [include/exclude]_interface iproute2 options 2023-08-20 17:29:22 +08:00
世界 f46732bc0e Add udp over stream support for TUIC 2023-08-20 14:15:22 +08:00
世界 5c5c25e3ad Update tfo-go 2023-08-20 13:32:07 +08:00
世界 53a0bf2d11 minor: Remove unused parameter 2023-08-20 13:32:07 +08:00
renovate[bot] 7b79d98f59 [dependencies] Update golang Docker tag to v1.21 2023-08-20 13:31:56 +08:00
世界 1dd2c26f31 Fix UDP route 2023-08-20 13:31:55 +08:00
armv9 d14170348d Fix process search with fakeip 2023-08-20 13:31:55 +08:00
世界 9f94b21687 platform: Add group expand status 2023-08-20 13:31:55 +08:00
世界 cf57e46d69 Add new issue template 2023-08-20 13:31:49 +08:00
世界 b459001600 Update documentation 2023-08-20 13:31:48 +08:00
世界 73267fd6ad Fix ci build 2023-08-20 13:31:48 +08:00
世界 1019ecfdcf Add TCP MultiPath support 2023-08-20 13:31:48 +08:00
世界 81b847faca Pause recurring tasks when no network 2023-08-20 13:31:48 +08:00
世界 ce4c76cdd2 documentation: Add TUIC 2023-08-20 13:31:47 +08:00
世界 917420e79a Add TUIC protocol 2023-08-20 13:31:40 +08:00
世界 0b14dc3228 Update quic-go 2023-08-20 13:31:40 +08:00
世界 cbdaf3272b Update dependencies 2023-08-20 13:31:36 +08:00
世界 d51ab2b0a7 Fix missing HandshakeConn interface 2023-08-20 13:31:31 +08:00
世界 1363e16312 platform: Enable Clash API support by default 2023-08-20 13:31:29 +08:00
世界 f43d0141f3 Save fakeip metadata immediately 2023-08-20 13:31:27 +08:00
世界 90b3aad83a Fix network monitor 2023-08-20 13:30:50 +08:00
世界 2675aff98a Fix tag detect 2023-08-07 22:07:24 +08:00
世界 09ffa2c66e documentation: Update changelog 2023-08-05 21:37:08 +08:00
世界 9fba4f02b6 Update dependencies 2023-08-05 21:37:08 +08:00
世界 59987747e5 platform: Improve status 2023-08-04 21:10:32 +08:00
世界 c40140bbae Fix UDP async write 2023-08-04 12:38:51 +08:00
世界 2123b216c0 Fix http proxy server again 2023-08-04 12:38:51 +08:00
世界 1983f54907 platform: Add sleep support for NetworkExtension 2023-08-04 12:38:51 +08:00
世界 8d629ef323 documentation: Update changelog 2023-07-31 09:49:07 +08:00
世界 f57bee2f4b Update quic-go 2023-07-31 09:49:07 +08:00
世界 679739683e Update dependencies 2023-07-31 09:07:32 +08:00
世界 4fcce1f073 Fix http proxy server 2023-07-31 09:04:55 +08:00
世界 ff14220e08 platform: Update profile binary format 2023-07-30 20:55:27 +08:00
世界 a7b7a5c3c5 documentation:Add tvOS page 2023-07-29 21:09:15 +08:00
世界 b054441f34 platform: Add support for tvOS 2023-07-29 21:01:43 +08:00
世界 1e31d26e03 documentation: Update installation 2023-07-29 21:01:43 +08:00
世界 ffe515d0e0 documentation: Update changelog 2023-07-25 13:21:15 +08:00
世界 aad021f521 Fix gVisor UDP 2023-07-25 08:28:24 +08:00
世界 4a986459ee documentation: Update changelog 2023-07-24 17:56:39 +08:00
世界 9532d0cba4 Fix cache file 2023-07-24 16:51:03 +08:00
世界 cadc34f3ad Add support for macOS system extension 2023-07-24 16:51:03 +08:00
世界 db23a48b36 Update dependencies 2023-07-23 14:23:51 +08:00
世界 407cf68e59 Fix certmagic usage 2023-07-20 20:03:20 +08:00
世界 e0058ca9c5 Fix fakeip unsaved state 2023-07-20 19:59:04 +08:00
世界 8140af01aa Fix download geo resources 2023-07-20 19:41:22 +08:00
世界 98bf696d01 Fix cache file 2023-07-20 19:41:22 +08:00
世界 e075bb5c8d Update changelog 2023-07-19 14:59:30 +08:00
世界 c6baabedef Update dependencies 2023-07-19 14:45:30 +08:00
世界 6e6998dab7 Improve platform status 2023-07-16 14:08:45 +08:00
世界 1a29c23263 documentation: Update changelog 2023-07-15 14:53:48 +08:00
世界 0f87396ab6 Fix abx reader for some malformed formats 2023-07-15 14:46:15 +08:00
世界 ffde948860 Update support page 2023-07-15 14:46:09 +08:00
世界 69b5dbdcc3 Build memory limiter for android 2023-07-15 14:46:06 +08:00
世界 1121517755 Fix hysteria inbound context 2023-07-11 20:58:20 +08:00
世界 6879def619 Fix test 2023-07-11 15:44:03 +08:00
世界 5c0f6d0a6f Fix vmess legacy server 2023-07-11 15:44:03 +08:00
世界 d74abbd20e Fix v2ray websocket transport 2023-07-11 15:44:03 +08:00
世界 120dae4eed Fix fakeip lookup 2023-07-11 15:44:02 +08:00
世界 bb651db2d2 platform: Fix output format 2023-07-09 12:24:43 +08:00
世界 e929dde13e documentation: Update changelog 2023-07-09 07:54:08 +08:00
世界 9d75385bbb Fix async FakeIP save 2023-07-08 16:21:11 +08:00
世界 1c526feec1 Update dependencies 2023-07-08 16:19:28 +08:00
世界 7df26986de documentation: Update changelog 2023-07-07 14:23:45 +08:00
世界 5f2d23a12d Fix multi error 2023-07-07 13:57:28 +08:00
世界 d9e65c0969 Fix syscall copy packet 2023-07-07 13:42:42 +08:00
世界 ec1160924f Fix save FakeIP cache 2023-07-07 12:51:38 +08:00
世界 230e8f895d Fix close quic.Listener 2023-07-07 12:43:22 +08:00
世界 af79378734 Update dependencies 2023-07-03 21:47:32 +08:00
世界 07ce5e0d22 Remove stack buffer usage 2023-07-03 21:45:32 +08:00
世界 9c8565cf21 platform: Add group interface 2023-07-02 18:46:41 +08:00
世界 5ad0ea2b5a Try fix StreamDomainNameQuery 2023-07-02 16:38:38 +08:00
世界 e482053c8a documentation: Update changelog 2023-06-27 11:31:24 +08:00
世界 945713d886 Update dependencies 2023-06-27 11:13:30 +08:00
世界 9bb62ad6b5 Check duplicated outbound tag 2023-06-26 18:47:52 +08:00
世界 c2bda9fbde Fix DNS rewrite_ttl logic 2023-06-23 16:12:25 +08:00
世界 1d1db62a44 Prevent write packet IPv6 packet to tun IPv4 connection 2023-06-21 13:29:24 +08:00
世界 39405373f8 documentation: Update changelog 2023-06-19 14:11:41 +08:00
世界 22a7988d3f Update dependencies 2023-06-19 14:11:41 +08:00
世界 b2092fafb7 Fix ios build
Cannot use errno as method and variable due to conflict with objc
2023-06-19 14:11:40 +08:00
世界 cc7b5d8280 Unwrap 4in6 address received by client packet conn 2023-06-19 13:29:41 +08:00
世界 702d96a738 platform: Improve local DNS transport 2023-06-18 10:00:32 +08:00
世界 b9f34f1309 documentation: Update changelog 2023-06-17 12:21:18 +08:00
世界 07724a0ddd Update dependencies 2023-06-17 12:17:43 +08:00
世界 83c3454685 Update quic-go 2023-06-15 14:52:25 +08:00
世界 7d263eb733 documentation: Update changelog 2023-06-14 16:28:44 +08:00
世界 222687d9c5 Update goreleaser usage 2023-06-14 09:39:13 +08:00
世界 07d3652e30 Build with_dhcp by default 2023-06-14 09:14:45 +08:00
世界 8d5b9d240a Fix outbound start sequence 2023-06-13 22:38:05 +08:00
世界 4f12eba944 Fix hysteria outbound 2023-06-13 21:41:33 +08:00
世界 a7f77d59c1 Update documentation 2023-06-11 22:38:37 +08:00
shadow750d6 597248130f Reconnect once if hysteria request fails
This allows graceful recovery when network isn't good enough.

[Original hysteria source
code](https://github.com/apernet/hysteria/blob/13d46da99876c2c9feb1083ff5f2da201d9d0a1e/core/cs/client.go#L182)
has similar mechanism.
2023-06-11 22:21:32 +08:00
世界 3c2c9cf317 Migrate gVisor to fork 2023-06-11 22:07:36 +08:00
世界 e572b9d0cd Update dependencies 2023-06-11 20:58:59 +08:00
世界 52e9059a8d Fix fakeip routing 2023-06-11 20:58:59 +08:00
世界 0cb9cff690 Fix shadowsocks none client 2023-06-08 10:23:39 +08:00
世界 c0669cb2a5 Fix TLS 1.2 support for shadow-tls client 2023-06-07 21:03:39 +08:00
世界 c5902f2473 Fix using v2ray websocket transport with detour 2023-06-07 21:03:21 +08:00
世界 22028602e8 Improve read waiter interface 2023-06-07 21:03:11 +08:00
世界 bd54608473 Fix DNS outbound 2023-06-07 21:03:08 +08:00
世界 3741394269 Add cache_id option for Clash cache file 2023-06-07 21:03:06 +08:00
世界 6266d2df7e Fix shadowsocks AEAD UDP server 2023-06-07 21:02:52 +08:00
世界 01dfba722a Use API to create windows firewall rule 2023-06-07 21:01:29 +08:00
世界 f8d5f01665 Reimplemented shadowsocks client 2023-06-07 20:57:07 +08:00
Hellojack ad999d4791 Fix UVariantLen usage 2023-06-07 20:56:57 +08:00
世界 6f1b258501 Improve DNS caching 2023-06-07 20:56:55 +08:00
世界 f949ddc0ab Set TCP keepalive for WireGuard gVisor TCP connections 2023-06-07 20:53:26 +08:00
Weltolk f53007cbf3 documentation: Fix fakeip link broken 2023-06-07 20:53:13 +08:00
世界 c287731df9 Improve direct copy 2023-06-07 20:53:00 +08:00
世界 bc32c78d03 Improve multiplex 2023-06-07 20:46:34 +08:00
世界 daee0db7bb clash-api: Reset outbounds in DELETE /connections 2023-06-07 20:45:39 +08:00
世界 91fbf4c79b Add multiplexer for VLESS outbound 2023-06-07 20:45:39 +08:00
世界 54d9ef2f2a Update gVisor to 20230417.0 2023-06-07 20:45:25 +08:00
世界 e056d4502b Add debug http server 2023-06-07 20:45:25 +08:00
世界 98c2c439aa Add filemanager api 2023-06-07 20:45:25 +08:00
世界 b6068cea6b Update wireguard-go 2023-06-07 20:38:30 +08:00
世界 9c9affa719 Ignore system tun stack bind interface error 2023-06-07 20:35:02 +08:00
世界 8eb7dd0059 Improve VLESS request 2023-06-07 20:35:00 +08:00
世界 a62ad44883 Add deadline interface 2023-06-07 20:34:56 +08:00
世界 2850354070 shadowsocks: Multi-user support for legacy AEAD inbound
Signed-off-by: wwqgtxx <wwqgtxx@gmail.com>
2023-06-07 20:34:54 +08:00
世界 0a4abcbbc8 Add headers option for HTTP outbound 2023-06-07 20:34:52 +08:00
世界 b491c350ae URLTest improvements 2023-06-07 20:33:56 +08:00
世界 1fbe7c54bf Fix wireguard reconnect 2023-06-07 20:33:53 +08:00
世界 9d32fc9bd1 Use HTTPS URLTest source 2023-06-07 20:33:50 +08:00
世界 542612129d clash-api: Add Clash.Meta APIs 2023-06-07 20:33:41 +08:00
世界 750f87bb0a clash api: download clash-dashboard if external-ui directory is empty 2023-06-07 20:33:06 +08:00
世界 e168de79c7 Add multi-peer support for wireguard outbound 2023-06-07 20:33:04 +08:00
世界 9bca5a517f Add fakeip support 2023-06-07 20:31:26 +08:00
世界 aa94cfb876 Refactor rules 2023-06-07 20:28:21 +08:00
世界 52b776b561 Add dns reverse mapping 2023-06-07 20:19:46 +08:00
世界 c74d3a53d4 documentation: Update changelog 2023-05-19 15:48:35 +08:00
世界 fe7ac80a6c Update dependencies 2023-05-19 15:48:35 +08:00
世界 e50b334b9a Fix uTLS ALPN 2023-05-19 15:41:18 +08:00
XYenon a0d8e374fb Fix incorrect use of sort.Slice 2023-05-19 15:40:48 +08:00
Larvan2 d3a67cb5ae Enable mkdocs search in documentation
Signed-off-by: Larvan2 <78135608+Larvan2@users.noreply.github.com>
2023-05-19 15:40:42 +08:00
世界 e69e98b185 Fix documentation 2023-05-19 15:40:13 +08:00
世界 5e1499d67b Update badtls 2023-05-19 15:39:40 +08:00
世界 e8dad1afeb Fix grpc request 2023-04-22 19:51:04 +08:00
世界 6ce4e31fc8 documentation: Update changelog 2023-04-22 08:26:09 +08:00
世界 d2d4faf520 Revert LRU cache changes 2023-04-22 08:23:49 +08:00
世界 438de36749 Make v2ray http2 conn public 2023-04-22 08:14:55 +08:00
世界andarmv9 df0eef770e Fix http response check
Co-authored-by: armv9 <48624112+arm64v8a@users.noreply.github.com>
2023-04-22 08:14:55 +08:00
世界andarmv9 bbdd495ed5 Fix v2ray-plugin TLS server name
Co-authored-by: armv9 <48624112+arm64v8a@users.noreply.github.com>
2023-04-21 17:48:36 +08:00
世界andarmv9 d686172854 Fix grpc lite request host
Co-authored-by: armv9 <48624112+arm64v8a@users.noreply.github.com>
2023-04-21 17:48:36 +08:00
H1JK e1d96cb64e Add BaseContext to http servers 2023-04-21 17:48:29 +08:00
H1JK d5f94b65b7 Fix gRPC service name escape 2023-04-21 17:48:29 +08:00
Hellojack ec2d0b6b3c Remove TLS requirement for gRPC client 2023-04-21 17:48:29 +08:00
世界 3a92bf993d platform: Add UsePlatformAutoDetectInterfaceControl 2023-04-21 17:47:17 +08:00
armv9 ec13965fd0 Fix HTTP sniffer 2023-04-19 21:58:58 +08:00
世界 ddf747006e Update to uuid v5 2023-04-19 21:58:58 +08:00
世界 4382093868 Prepare deadline interface 2023-04-19 21:58:58 +08:00
世界 a5322850b3 documentation: Update client notes 2023-04-19 21:58:58 +08:00
世界 407b08975c Remove legacy warnings 2023-04-19 21:58:58 +08:00
世界 c7067ff5e8 Fix default interface monitor for darwin 2023-04-19 21:58:58 +08:00
世界 9b2384b296 Add udpnat test 2023-04-19 21:43:13 +08:00
世界 b498a22972 Fix interface monitor for android 2023-04-19 21:42:40 +08:00
世界 20e9da5c67 Fix udp timeout 2023-04-19 21:42:10 +08:00
世界 ec8974673b Fix platform interface monitor & Fix system tun stack for ios 2023-04-19 21:40:03 +08:00
世界 5e6e7923e4 Fix shadowsocksr build 2023-04-17 18:06:43 +08:00
世界 de1b5971e1 Update documentation 2023-04-16 16:28:41 +08:00
世界 5c20d0b4d5 Update dependencies 2023-04-16 16:28:41 +08:00
世界 9df96ac7f1 Fix deadline usage on websocket conn 2023-04-16 16:28:40 +08:00
世界 87cd925144 Fix conntrack return pointer 2023-04-14 21:00:45 +08:00
世界 fecb796000 android: Remove Seq.Delete warning 2023-04-14 21:00:40 +08:00
世界 cfb6c804aa Print sniff result 2023-04-14 21:00:01 +08:00
世界 11c50c7558 Fix processing domain address in packet 2023-04-14 20:59:57 +08:00
世界 34cc7f176e Fix parsing query in http path 2023-04-14 20:59:16 +08:00
Xiaokang Wang (Shelikhoo) b54da9c6af Fix '?' at end of WebSocket path get escaped
This fix align sing-box's behaviour with V2Ray when it comes to processing ? at the end of WebSocket's path.
2023-04-14 20:58:12 +08:00
世界 f44f86b832 Fix workflows 2023-04-14 20:57:08 +08:00
世界 4ebf40f582 Fix find process user 2023-04-14 20:56:58 +08:00
世界 53e4302143 Fix set HTTP TLS ALPN 2023-04-14 20:56:55 +08:00
世界 cf778eda4f Fix v2ray http transport server read request 2023-04-14 20:56:51 +08:00
世界 bb63429079 Update cancel context usage 2023-04-14 20:56:16 +08:00
世界 f7f9a7ae20 Fix write log to stderr 2023-04-14 20:55:57 +08:00
世界 8699412a4c platform: Add stderr redirect 2023-04-14 20:55:45 +08:00
世界 0d7aa19cd1 Fix write http status after response sent 2023-04-14 20:55:20 +08:00
世界 50a7295360 Replace usages of uber/atomic 2023-04-14 20:55:05 +08:00
世界 e57b6ae98d Update dependencies 2023-04-14 20:54:56 +08:00
世界 6843970536 Add loopback check 2023-04-08 09:13:50 +08:00
世界 62425ad3e4 Add close monitor 2023-04-08 08:10:03 +08:00
世界 e1e217854e Add start and close track message 2023-04-08 08:09:28 +08:00
世界 5bf177b021 platform: Fix build on windows 2023-04-07 21:10:16 +08:00
世界 72dbf2e2b4 documentation: Update changelog 2023-04-07 19:18:26 +08:00
世界 46c318c6fe Fix v2ray HTTP/1.1 transport compatibility 2023-04-07 18:20:07 +08:00
世界 05bb1b88c3 dns: Fix rewrite TTL 2023-04-07 16:19:34 +08:00
世界 5176ea9fe0 Update dependencies 2023-04-07 16:19:34 +08:00
世界 36d349acd2 dns: Fix calculate TTL 2023-04-07 13:12:16 +08:00
世界 4feee983b5 Update reality protocol 2023-04-06 19:05:05 +08:00
世界 9b12e3e389 Update client documentation 2023-04-06 12:51:26 +08:00
世界 afd3464216 Minor fixes 2023-04-05 21:41:06 +08:00
世界 8b64446274 platform: Fixes and improvements 2023-04-05 19:54:20 +08:00
世界 28aa4c4d1f Refactor log factory constructor 2023-04-03 20:24:13 +08:00
世界 0be3cdc8fb platform: Add http client 2023-04-03 15:12:44 +08:00
armv9 f8be484019 conntrack: Fix missing tracking for udp conn 2023-04-02 12:06:03 +08:00
世界 35f03f092d Improve UDP domain destination NAT 2023-04-02 12:05:59 +08:00
世界 c3d7401ead platform: Add check config func 2023-04-02 10:35:03 +08:00
世界 4db7eb9d9e documentation: Update changelog 2023-03-31 16:29:08 +08:00
世界 fd4efd6104 Fix dns transport read 2023-03-31 14:31:35 +08:00
世界 19a35ec6a4 Fix http2 transport close 2023-03-31 14:31:35 +08:00
世界 2012c0ca1e Update release scripts 2023-03-31 14:31:35 +08:00
世界 187421c754 Append time to session log 2023-03-31 14:31:35 +08:00
世界 b3fb86d415 Accept "any" outbound in dns rule 2023-03-31 14:31:35 +08:00
世界 88fafd4e30 Fix dns routing context 2023-03-31 09:14:04 +08:00
世界 8056932f9c Update documentation 2023-03-27 08:23:01 +08:00
世界 c8af003bfc Update dependencies 2023-03-27 08:22:56 +08:00
世界 4999441a85 Fix missing default host in v2ray http transport`s request 2023-03-27 08:20:59 +08:00
世界 09b001e795 Revert remove install shell 2023-03-27 08:20:55 +08:00
世界 3b3a251008 Update LICENSE 2023-03-27 08:20:51 +08:00
世界 2e4eb9aa39 Update dockerfile 2023-03-24 08:29:11 +08:00
世界 77fd284703 documentation: Update changelog 2023-03-24 08:04:36 +08:00
世界 0a4517f4b7 Update dependencies 2023-03-24 07:06:45 +08:00
世界 4395db3206 documentation: Update set_system_proxy usage 2023-03-23 21:27:50 +08:00
世界 dd5b0abc67 Fix slow open 2023-03-23 17:14:38 +08:00
世界 466800aa3a Fix wireguard mutex 2023-03-23 15:43:17 +08:00
世界 4328c535a9 Improve timeout canceler 2023-03-23 15:39:12 +08:00
世界 f9516709da Update documentation 2023-03-23 07:54:24 +08:00
世界 5dce722879 Update dependencies 2023-03-23 07:49:14 +08:00
世界 9324a39d4e Fix import format 2023-03-20 23:01:54 +08:00
世界 84904c5206 Create working directory if not exists 2023-03-20 19:33:00 +08:00
世界 fe4b429fc2 hysteria: Accept inbound configuration without users 2023-03-20 19:22:46 +08:00
世界 f680d0acaf Add with_reality_server to release build tags 2023-03-20 17:36:59 +08:00
世界 4baff5aeb1 documentation: Update changelog 2023-03-20 17:32:59 +08:00
世界 f25296fb23 Update dependencies 2023-03-20 17:27:48 +08:00
世界 e717852c73 Fix optional listen address 2023-03-19 20:46:22 +08:00
世界 13dc70f649 Fix make build 2023-03-19 16:57:07 +08:00
世界 46040a71c3 Fix vision padding overflow 2023-03-19 10:25:35 +08:00
世界 0558b3fc5c ntp: Add write_to_system service option 2023-03-18 23:11:40 +08:00
世界 99b2ab5526 Add command to fetch a URL 2023-03-18 21:02:29 +08:00
世界 e5f3bb6344 Add command to connect an address 2023-03-18 20:27:38 +08:00
世界 c7f89ad88e Add multiple configuration support 2023-03-18 20:27:38 +08:00
世界 e0d9f79445 Fix test 2023-03-18 17:02:55 +08:00
世界 b6dbb69fc4 Fix write nil in buffered vectorised writer 2023-03-18 16:32:28 +08:00
世界 b76fabee65 documentation: Fix broken link 2023-03-18 16:31:15 +08:00
世界 872bcfd1c0 readme: Add packaging status 2023-03-17 17:58:08 +08:00
世界 b033c13ca2 documentation: Update stable changelog 2023-03-17 17:58:08 +08:00
renovate[bot] 2db188f3a1 dependencies: Update actions/setup-go action to v4
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-17 17:58:08 +08:00
世界 11de271c8f Add experimental debug options 2023-03-17 17:58:08 +08:00
世界 40c800c57c documentation: Fix typo 2023-03-17 13:34:36 +08:00
世界 91b0540e95 documentation: Update UoT application support status 2023-03-17 13:28:05 +08:00
世界 ce6d186345 Update documentation 2023-03-17 13:07:22 +08:00
世界 32bc4450a7 Update dependencies 2023-03-17 12:59:12 +08:00
世界 43f31b40ba Update UoT protocol 2023-03-17 12:57:48 +08:00
世界 a3a5185b15 platform: Fix bytes format 2023-03-16 11:28:54 +08:00
世界 14a0f180c8 ios: Add with_quic tag in build 2023-03-16 11:28:54 +08:00
世界 cc9cb0b477 platform: Add oom killer 2023-03-16 11:28:54 +08:00
世界 2cb0e37f50 platform: Add low memory interface 2023-03-16 00:36:04 +08:00
世界 dbd5be55b0 tun: Create gVisor stack by default in Apple Network Extension 2023-03-15 21:50:18 +08:00
世界 f674b4fbd5 Fix build embed tor for mobile 2023-03-15 20:59:45 +08:00
世界 5a4e8fea81 Fix lint 2023-03-15 14:56:06 +08:00
世界 78e02b52ca Update UoT protocol 2023-03-15 14:52:32 +08:00
世界 ffdaae90d7 Update dependencies 2023-03-15 11:59:15 +08:00
世界 c77681ea17 Fix close platform tun 2023-03-13 19:47:00 +08:00
世界 d824390167 Fix cross make build 2023-03-13 19:46:20 +08:00
世界 70cf681ff2 Remove length limit on short_id for reality TLS config 2023-03-13 19:46:16 +08:00
wwqgtxx b004b9ec81 Fix stack wireguard device returning non-nil interface containing nil pointer 2023-03-13 19:46:16 +08:00
世界 657b05fd96 Print command to shell error 2023-03-13 19:46:16 +08:00
世界 caad60da45 Apply --disable-color to global logger 2023-03-13 11:23:00 +08:00
世界 7d22cf9b45 Support $schema in configuration file 2023-03-13 10:58:29 +08:00
世界 5cb178ca93 Update documentation 2023-03-12 23:07:38 +08:00
世界 16788008b6 Update dependencies 2023-03-12 23:07:24 +08:00
世界 6ec7a33046 Fix make install 2023-03-11 19:24:19 +08:00
世界 6af9c2b3ca Add health check support for http-based v2ray transport 2023-03-11 15:49:02 +08:00
世界 bdc620dab1 Fix http server usage 2023-03-11 15:05:07 +08:00
世界 a88820af31 Fix missing default shadowtls version 2023-03-11 10:12:46 +08:00
世界 3688f2e114 Update documentation 2023-03-10 11:15:00 +08:00
世界 a183958d53 Update dependencies 2023-03-09 23:24:05 +08:00
世界 1c8a9e91b7 Generate version during compilation 2023-03-09 23:24:05 +08:00
世界 325f6c71ff Fix windows interface monitor 2023-03-09 16:00:57 +08:00
世界 6c6c0792ad Update reality and uTLS 2023-03-09 10:51:01 +08:00
世界 9264f2307c Fix link broken in installation documentation page 2023-03-08 15:56:41 +08:00
世界 87dd328700 Fix build check 2023-03-08 15:23:46 +08:00
世界 0cec92dd0f Update documentation 2023-03-08 14:59:09 +08:00
世界 e88afa9665 Fix vmess server buffer 2023-03-07 17:07:37 +08:00
世界 3883a81315 Check constant.Version before build release 2023-03-06 16:34:44 +08:00
Dmitry R c919ad079a systemd: Add reload command 2023-03-06 16:32:54 +08:00
世界 83593aee70 Fix vless read cache 2023-03-06 11:19:38 +08:00
世界 ac7cc09694 Update documentation 2023-03-05 23:37:12 +08:00
世界 d032e3568b Update dependencies 2023-03-05 21:38:02 +08:00
世界 c24df037ac Add documentation for tun platform options 2023-03-05 15:19:13 +08:00
世界 a2d43b3746 Fix open cache file 2023-03-05 14:57:50 +08:00
世界 5b3b74bd0f Fix vision read 2023-03-05 14:57:50 +08:00
世界 d24d3b26dc Fix uTLS randomized fingerprint 2023-03-05 14:57:50 +08:00
seiuneko 5db3cd7781 Fix documentation typo 2023-03-05 14:57:50 +08:00
世界 c88af8b081 Fix documentation 2023-03-05 14:57:50 +08:00
世界 45852ca3e7 Fix check config 2023-03-05 14:57:50 +08:00
Hellojack 03ce555104 Add generate commands 2023-03-05 11:21:32 +08:00
世界 dd0a07624e Add stop platform command 2023-03-04 00:40:47 +08:00
世界 b9b2b77814 Add reload platform command 2023-03-03 21:59:54 +08:00
世界 2366835121 Fix close conn 2023-03-03 19:27:30 +08:00
database64128 42e1dea7d2 Update .gitignore 2023-03-03 18:51:33 +08:00
Ella Hollywood 13d7716b02 Fix documentation typo 2023-03-03 16:35:06 +08:00
世界 7ecb9fc738 Minor fixes 2023-03-03 16:31:07 +08:00
世界 19b15e0d10 Fix UoT UDP address 2023-03-03 11:34:51 +08:00
database64128 0b15de461b Update tfo-go 2023-03-03 10:16:38 +08:00
世界 27aba99e6c Fix command client connect 2023-03-02 16:40:28 +08:00
世界 8151bcfd6b Add ios memory limit 2023-03-02 15:04:59 +08:00
世界 e8802357e1 Fix vless tests 2023-03-02 00:31:56 +08:00
世界 6e22c004f6 Improve server error handling 2023-03-02 00:18:35 +08:00
世界 20e1caa531 Fix custom tls server listener 2023-03-02 00:01:40 +08:00
世界 32ad3c3db3 Remove okhttp form modern fingerprint list 2023-03-01 21:17:30 +08:00
世界 1f5f8a7dde Fixed user flow in vless server 2023-03-01 20:28:40 +08:00
世界 6da1460795 Fix geo resource download path 2023-03-01 19:09:21 +08:00
世界 b14ae51f71 Fix create badhttp2 server 2023-03-01 19:09:21 +08:00
世界 5af8d001ae Refactor platform command api 2023-03-01 19:09:21 +08:00
世界 0ca344df5f Fix uTLS ALPN 2023-02-28 21:16:31 +08:00
世界 49f568abbd Separate uTLS random fingerprint 2023-02-28 21:10:11 +08:00
世界 3b4e811907 Add reality client fallback 2023-02-28 20:55:14 +08:00
世界 d0e9443031 Enable XUDP by default in VLESS 2023-02-28 20:52:26 +08:00
世界 f7e9d9ab1f Fix check early conn 2023-02-28 20:16:15 +08:00
世界 7834d6bca7 Add tun platform options 2023-02-28 19:02:27 +08:00
世界 ed50257735 Add custom TLS server support for http based v2ray transports 2023-02-28 13:03:44 +08:00
世界 f15f525c5c Merge tls interface to library 2023-02-28 11:30:46 +08:00
世界 e4bff0460d Update vision protocol 2023-02-27 15:07:15 +08:00
世界 5ce3ddee9b Add early conn interface 2023-02-26 23:08:20 +08:00
世界 22bf7a9509 Update reality server 2023-02-26 20:55:36 +08:00
世界 842730707c Update TUN creation 2023-02-26 20:55:15 +08:00
世界 a8f13bd956 Fix documentation 2023-02-25 17:25:56 +08:00
世界 cd5c2a7999 Update documentation 2023-02-25 16:28:39 +08:00
世界 fbc94b9e3e Add VLESS server, vision flow and reality TLS 2023-02-25 16:24:08 +08:00
zakuwaki e766f25d55 Fix private ip will never be matched 2023-02-24 13:31:49 +08:00
世界 140ed9a4cb Fix platform wrapper 2023-02-24 13:00:49 +08:00
世界 60094884cd Update documentation 2023-02-22 11:45:31 +08:00
H3arn 0e8a4d141a Fix incorrect NTP server address 2023-02-21 23:08:05 +08:00
世界 17b78a6339 Fix documentation 2023-02-21 22:06:12 +08:00
世界 e99741159b Update documentation 2023-02-21 20:54:25 +08:00
世界 6b9603227b Add strict mode support for shadowtls v3 2023-02-21 20:51:26 +08:00
世界 23e8d282a3 Add multiple server names and multi-user support for shadowtls 2023-02-21 16:09:06 +08:00
世界 611d6bbfc5 Add NTP service 2023-02-21 16:09:06 +08:00
世界 f26785c0ba Add uTLS support for shadowtls v3 2023-02-20 21:04:07 +08:00
世界 5bcfb71737 Update dependencies 2023-02-20 21:04:07 +08:00
世界 4135c4974f Merge shadowtls to library 2023-02-20 13:53:06 +08:00
世界 222196b182 Add libbox wrapper 2023-02-20 11:07:49 +08:00
世界 86e55c5c1c Fix tproxy inbound 2023-02-19 18:56:38 +08:00
世界 73c068b96f Update documentation 2023-02-19 17:49:05 +08:00
世界 f516026540 Fix shadowtls in go versiojns below 1.20 2023-02-19 12:02:11 +08:00
dyhkwong 3c5bc842ed Update QUIC v2 version number and initial salt 2023-02-18 23:51:55 +08:00
世界 d8270a66f4 Update release script 2023-02-18 21:14:17 +08:00
世界 123c383eae Fix documentation 2023-02-18 19:33:35 +08:00
世界 67814faf92 Remove TLS min version for shadowtls v3 2023-02-18 19:26:05 +08:00
世界 ec4a0c8497 Update documentation 2023-02-18 15:02:27 +08:00
世界 21cb227bc2 Add ShadowTLS protocol v3 2023-02-18 14:55:47 +08:00
世界 1610bdc5dd Update workflow 2023-02-18 14:28:21 +08:00
世界 3296a2f7b2 Update dependencies 2023-02-18 14:28:21 +08:00
世界 2bd91baad0 Add fallback support for v2ray transport 2023-02-18 14:28:21 +08:00
世界 a624cd9b49 Disable vmess header protection if transport enabled 2023-02-13 05:15:26 +08:00
Tim Xylon 02afba132f Replace deprecated 'set-output' 2023-02-09 22:07:00 +08:00
世界 99890a1af0 Fix socks connect response 2023-02-09 21:25:00 +08:00
世界 437f1f819c Fix lint 2023-02-09 21:01:48 +08:00
世界 92a79e6158 Remove cancel-workflow-action 2023-02-09 18:04:49 +08:00
renovate[bot] c9efd0a74f [dependencies] Update golang Docker tag to v1.20
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-09 18:04:16 +08:00
renovate[bot] 9da349748a [dependencies] Update github-actions
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-09 17:42:06 +08:00
世界 2423cbbbfe Add renovate config 2023-02-09 17:39:15 +08:00
Gavin Luo 4833f6d5db Fix systemd service caps for process sniffing 2023-02-09 13:32:31 +08:00
世界 9db3cb5cb7 Update scripts 2023-02-09 13:27:05 +08:00
shadow750d6 c14b353a29 Fix parse hysteria UDP message 2023-02-09 13:20:16 +08:00
世界 19d08b55c8 Update documentation 2023-02-08 17:35:50 +08:00
世界 39514b3ca0 Add v2ray user stats api 2023-02-08 16:50:15 +08:00
世界 7ea9d48987 Add DHCP DNS server support 2023-02-08 16:43:29 +08:00
lyc8503 df3a982141 Add SSH outbound host key validation 2023-02-08 16:20:48 +08:00
世界 687b4509df Add query_type DNS rule item 2023-02-08 16:18:40 +08:00
世界 41ec2e7944 Add support for clash DNS query API 2023-02-08 14:13:33 +08:00
世界 1bd3a9144d Upgrade tfo-go 2023-02-08 14:13:33 +08:00
世界 6e852cc99b Update dependencies 2023-02-08 14:13:15 +08:00
世界 8320dd0b51 Improve vmess request 2023-02-07 14:53:08 +08:00
世界 960d04d172 Fix match geoip 2023-02-07 12:21:00 +08:00
世界 86ea035bdd Fix ipv6 redir port 2023-02-05 14:48:02 +08:00
世界 9b6449dcf4 Fix find NDK on macOS 2023-02-02 16:30:50 +08:00
世界 4e22ac1a35 Update documentation 2023-02-02 16:11:29 +08:00
世界 8a779f6e94 Update dependencies 2023-02-02 15:38:48 +08:00
世界 d461768ffb Fix build with go1.20 2023-02-02 15:25:34 +08:00
Dmitry R 5d41e328d4 ignore domain case in route rules 2023-02-02 15:25:34 +08:00
世界 fe492904e9 Fix auth_user route for naive inbound 2023-01-19 10:47:22 +08:00
世界 168253b851 Fix inbound default DF 2023-01-19 10:36:25 +08:00
Hellojack 05620a369e Fix gRPC lite header
Manually set the first byte to 0x00 (No Compression) since we can not ensure that the buffer is not polluted before.
2023-01-16 16:23:06 +08:00
世界 8e0fe55363 Fix wireguard events 2023-01-15 19:48:50 +08:00
世界 59e521c1db Fix convert netaddr 2023-01-14 20:04:15 +08:00
世界 f32c149738 Bump version 2023-01-14 16:01:07 +08:00
世界 23a35b3c06 Fix create UDP DNS transport from plain IPv6 address 2023-01-13 11:51:20 +08:00
世界 044f9c5d4f Fix write to h2 conn after closed 2023-01-08 15:43:12 +08:00
世界 54f9625bdc Fix DNS log 2023-01-03 17:04:10 +08:00
世界 ff0693be32 Bump version 2023-01-03 10:53:38 +08:00
世界 53d9ad93e3 Improve DNS log 2023-01-03 10:36:18 +08:00
世界 f5c5570bec Skip set windows system proxy bypass list 2022-12-26 12:33:08 +08:00
世界 53f19a6ead Fix override packet conn 2022-12-21 21:58:03 +08:00
世界 cfaf15f429 Fix DNS response TTL 2022-12-19 13:10:57 +08:00
世界 9e67f3b4a5 Fix user from stream packet conn 2022-12-18 16:08:49 +08:00
isnowly 4d2185a2d4 Fix http proxy auth 2022-12-18 16:08:49 +08:00
世界 33f22263ca Update dependencies 2022-12-18 15:40:19 +08:00
世界 d09aa07d21 Fix android i686 compiler 2022-12-11 15:01:54 +08:00
世界 8afb8ca7eb Update documentation 2022-12-11 14:40:03 +08:00
世界 80ed5bf8fb Fix android package 2022-12-11 14:38:01 +08:00
世界 81e7b0b320 Fix linux package 2022-12-11 11:51:25 +08:00
世界 a828c3b5da Fix acme config 2022-12-06 13:36:42 +08:00
世界 c95e4a13a1 Fix vmess packet conn 2022-12-06 13:02:28 +08:00
世界 726a7e19eb Suppress quic-go set DF error 2022-12-06 12:51:52 +08:00
世界 8953ddc6e0 Update workflow 2022-12-03 17:03:04 +08:00
世界 7ebbd58b00 Update documentation 2022-12-03 14:38:52 +08:00
世界 d0095fd0f4 Fix close clash cache 2022-12-03 13:29:37 +08:00
世界 66d8d563eb Add WorkingDirectory for systemd service 2022-11-28 18:30:50 +08:00
世界 4bf96c7eb5 Fix quic stub 2022-11-28 16:06:54 +08:00
世界 f687c25fa9 Update documentation 2022-11-28 13:11:07 +08:00
世界 a92412ecac Fix router 2022-11-28 13:11:07 +08:00
世界 8dcafa5b33 Add trojan-go multiplex support for trojan inbound 2022-11-28 12:51:23 +08:00
世界 7a02cb83a7 Revert "Fix listen packet on address"
This reverts commit d1fe17a4db.
2022-11-28 12:51:23 +08:00
世界 51ce672076 Fix crash when input bad method in shadowsocks multi-user inbound 2022-11-28 12:51:23 +08:00
世界 7734afc40c Update dependencies 2022-11-28 12:51:23 +08:00
世界 ee3cd49aa5 Fix tls config for h2 server 2022-11-26 14:55:51 +08:00
世界 bf20ff84b5 Fix lint 2022-11-26 11:21:24 +08:00
世界 c58302554c Fix documentation 2022-11-26 11:06:57 +08:00
世界 05ed88aba8 Update documentation 2022-11-25 22:59:30 +08:00
世界 9f5cc0442b Fix dockerfile 2022-11-25 22:59:30 +08:00
世界 2641a43ad8 Remove test on pull request 2022-11-25 21:12:45 +08:00
世界 4a6ab5e9fd Fix cancel on start 2022-11-25 21:12:45 +08:00
世界 d1fe17a4db Fix listen packet on address 2022-11-25 21:12:45 +08:00
世界 7c910165ef Cleanup code 2022-11-24 12:37:29 +08:00
世界 8c1fddcf8d Remove connect packet conn 2022-11-24 12:01:25 +08:00
Hellojack 01b4769852 Cleanup gun conn code 2022-11-23 14:56:31 +08:00
世界 a401828ed5 Fix shadowtls server detection 2022-11-22 22:15:38 +08:00
世界 ffd54eef6c Update documentation 2022-11-21 21:20:44 +08:00
世界 c16e4316d6 Fix shadowtls server 2022-11-21 21:20:44 +08:00
世界 8b7fe20b7f Include uTLS in release 2022-11-21 15:25:49 +08:00
世界 696c1065b6 Update stable documentation 2022-11-21 14:57:22 +08:00
世界 5d690f4147 Update documentation 2022-11-21 13:18:04 +08:00
世界 f906641a82 Add uTLS to makefile default tags 2022-11-21 13:18:04 +08:00
世界 89913dfa8c Improve shadowtls server 2022-11-21 13:18:04 +08:00
世界 468778f67f Update dependencies 2022-11-21 13:18:04 +08:00
世界 22a22aebe2 Fix default dns transport strategy 2022-11-21 13:18:04 +08:00
世界 a2d2ec9b45 Update documentation 2022-11-15 17:36:42 +08:00
世界 2695b3516e Update issue template 2022-11-13 11:45:24 +08:00
世界 3a9ef8fac0 Remove unused 2022-11-13 11:30:48 +08:00
世界 ebad363201 Fix create TLS config 2022-11-13 11:24:37 +08:00
世界 11076d52cd Fix dns buffer & quic retry 2022-11-13 11:16:10 +08:00
世界 5eb132063e Fix connect packet connection for mux client 2022-11-12 03:53:42 +08:00
世界 13ab5d3348 Remove follow in update script 2022-11-11 22:32:24 +08:00
世界 ce1ddc400f Support x/h2 v0.2.0 deadline 2022-11-11 22:08:20 +08:00
arm64v8a 2c9d25e853 Fix websocket alpn 2022-11-11 20:01:49 +08:00
世界 3d76777760 Fix tor geoip 2022-11-10 22:42:05 +08:00
世界 24f4dfea04 Fix hysteria test 2022-11-10 21:10:18 +08:00
世界 2fc1a0a9dd Update documentation 2022-11-10 16:33:10 +08:00
世界 617aba84e4 Add multi user support for hysteria inbound 2022-11-09 21:00:08 +08:00
世界 5510c474c7 Fix h2c transport 2022-11-09 12:15:14 +08:00
世界 eb2e8a0b40 Add custom tls client support for std grpc 2022-11-09 11:46:29 +08:00
世界 972491c19d Fix default local DNS server behavior 2022-11-09 10:35:16 +08:00
世界 7358ca4a52 Fix vmess request buffer 2022-11-09 10:16:22 +08:00
世界 61c274045a Update install go script 2022-11-08 23:19:53 +08:00
世界 f205140b04 Fix smux keep alive 2022-11-08 16:45:38 +08:00
世界 1db8e03c86 Fix format 2022-11-08 14:54:19 +08:00
世界 2ecf86c2bc Update patched quic-go 2022-11-08 13:54:01 +08:00
世界 999a847e86 Add custom wireguard worker size option 2022-11-08 13:48:14 +08:00
世界 1f63ce5dee Fix reset outbound 2022-11-06 10:36:19 +08:00
世界 0ad1bbea11 Fix wireguard close 2022-11-06 10:20:23 +08:00
世界 b2cd78d279 Move WFP manipulation to strict route 2022-11-06 10:16:07 +08:00
世界 d5bb58a0b4 Update documentation 2022-11-06 10:16:07 +08:00
世界 7f84936050 Split bind_address 2022-11-06 10:16:07 +08:00
Dreamacro 6adfea0a72 Fix macOS Ventura process name match 2022-11-06 10:16:07 +08:00
Hellojack 10f213bf3d Adjust uTLS wrapper 2022-11-06 10:16:07 +08:00
世界 6e8c4f6576 Update documentation 2022-10-31 13:59:52 +08:00
世界 9779dc0154 Fix test 2022-10-31 13:59:52 +08:00
世界 a2abe31298 Fix uTLS config 2022-10-31 13:59:52 +08:00
世界 930d177dd0 Update dependencies 2022-10-31 13:59:52 +08:00
Fei1Yang f3d1b59173 Update container action 2022-10-29 18:01:32 +08:00
世界 14452f3049 Update documentation 2022-10-29 18:00:05 +08:00
世界 4119c8647b Update dependencies 2022-10-29 17:56:21 +08:00
世界 90a94a8c63 Improve local dns transport 2022-10-29 17:37:11 +08:00
世界 b0c39ac7ff Suppress no network error 2022-10-28 09:54:04 +08:00
世界 8703e1ff98 Fix decrypt xplus packet 2022-10-28 09:53:57 +08:00
世界 35886b88d7 Add option for custom wireguard reserved bytes 2022-10-28 09:53:57 +08:00
永雏塔菲 d583b35717 Add s390x architecture support
* Update debug.yml

Signed-off-by: 永雏塔菲 <108621198+taffychan@users.noreply.github.com>
2022-10-28 09:53:57 +08:00
Hellojack 217ffb2f95 Update uTLS usage
* Update new uTLS fingerprints

* Update documentation
2022-10-28 09:53:57 +08:00
世界 22f06f582b Fix v2ray api 2022-10-26 20:06:13 +08:00
世界 f2b5098fa0 Update documentation 2022-10-25 21:26:28 +08:00
世界 0ca3290364 Add go1.18 debug build 2022-10-25 21:25:55 +08:00
世界 43d5b8598b Fix shadowtls conn 2022-10-25 21:25:42 +08:00
世界 f3e1d1defc Fix h3 dns transport 2022-10-20 11:04:03 +08:00
世界 95c03c9373 Fix copy pipe 2022-10-20 10:57:57 +08:00
世界 7e0958b4ac Update documentation 2022-10-19 10:55:06 +08:00
Skyxim 6a26737508 Check destination before udp connect 2022-10-19 10:22:46 +08:00
世界 92a92f39c5 Fix naive overflow 2022-10-18 17:52:52 +08:00
世界 fc533cd38d Fix DF for hysteria 2022-10-18 17:27:50 +08:00
世界 68e286499d Update dependencies 2022-10-18 17:27:50 +08:00
世界 f5c1900aad Add message for tfo error 2022-10-18 17:27:50 +08:00
世界 6591dd58ca Remove strict route on windows
replaced by custom route
2022-10-12 16:24:45 +08:00
XYenon 54af113363 Add custom route support (#147) 2022-10-12 16:20:17 +08:00
世界 3f1fe814ef Fix sniff fragmented quic client hello 2022-10-12 16:11:42 +08:00
世界 5a2cebebd1 Remove unused 2022-10-10 14:23:34 +08:00
世界 b8009d61b2 Fix tfo headroom 2022-10-10 13:33:48 +08:00
世界 a61a64bf9e Add shadowtls inbound test 2022-10-10 11:31:03 +08:00
世界 7d17c52fea Add more messages to darwin route error 2022-10-09 21:22:07 +08:00
世界 f5b15b392b Fix ssh outbound 2022-10-09 20:43:01 +08:00
世界 8a53846efd Fix uTLS handshake 2022-10-08 20:31:01 +08:00
世界 badc454452 Fix test 2022-10-08 20:30:52 +08:00
世界 a01bb569d1 Fix websocket headroom 2022-10-08 20:09:36 +08:00
世界 89ff9f8368 Fix interface monitor 2022-10-08 20:09:36 +08:00
世界 7f816a2ebc Add sniff_timeout 2022-10-08 20:09:36 +08:00
世界 39c141651a Update documentation 2022-10-06 23:33:57 +08:00
世界 b0ad9bb6f1 Add shadowtls v2 support 2022-10-06 22:47:11 +08:00
世界 d135d0f287 Update tfo-go usage 2022-10-06 21:58:50 +08:00
世界 b183ccf23d Fix wfp filter weight 2022-10-05 20:24:27 +08:00
世界 c2969bc186 Update documentation 2022-10-03 04:36:54 +08:00
世界 bd86bfcd22 Fix check system stack packet 2022-10-03 04:36:54 +08:00
世界 8aec64b855 Add v2ray mux support for all connections 2022-10-03 04:34:59 +08:00
世界 1445bdba37 Fix trojan fallback 2022-10-01 11:41:15 +08:00
世界 29d08e63b5 Fix clash tracker 2022-10-01 11:29:46 +08:00
世界 1173fdea64 Improve tls writer 2022-10-01 11:29:46 +08:00
世界 968430c338 Minor fixes 2022-09-30 21:08:07 +08:00
世界 3e5bee6faf Fix windows route 2022-09-30 00:36:42 +08:00
世界 aa613cba73 Fix dns close 2022-09-29 09:12:13 +08:00
世界 1e510511ae Fix random seed 2022-09-29 08:49:34 +08:00
世界 1b44faed17 Add v2ray stats api 2022-09-29 08:49:34 +08:00
世界 c7a485815c Add binary to .gitignore 2022-09-26 19:36:51 +08:00
世界 7f9c870bba Add direct io option for clash api 2022-09-26 15:31:02 +08:00
世界 b5564ef3d3 Fix bind control 2022-09-26 13:50:54 +08:00
世界 8ce244dd04 Fix documentation
Signed-off-by: 世界 <i@sekai.icu>
Signed-off-by: unknowndevQwQ <unknowndevQwQ@pm.me>
2022-09-26 12:25:18 +08:00
世界 0f57b93925 Update documentation 2022-09-25 22:29:18 +08:00
世界 c90a77a185 Refine 4in6 processing 2022-09-25 22:29:18 +08:00
世界 c6586f19fa Fix read source address from grpc-go 2022-09-25 22:29:18 +08:00
世界 cbab86ae38 Refine tproxy write back 2022-09-25 22:29:18 +08:00
世界 17b5f031f1 Fix shadowsocks plugins 2022-09-25 16:43:12 +08:00
世界 b00b6b9e25 Fix fqdn socks5 outbound connection 2022-09-25 14:42:39 +08:00
世界 fb6b3b0401 Fix missing source address from transport connection 2022-09-23 18:55:28 +08:00
世界 22ea878fe9 Improve websocket writer 2022-09-23 18:55:07 +08:00
世界 abe3dc6039 Add self sign cert support 2022-09-23 17:13:18 +08:00
世界 852829b9dc Add VMess benchmark result 2022-09-23 16:13:29 +08:00
世界 407509c985 Fix leaks and add test 2022-09-23 13:14:31 +08:00
世界 9856b73cb5 Update documentation 2022-09-23 10:30:07 +08:00
世界 f42356fbcb Fix system stack ipv4 overflow 2022-09-23 10:29:15 +08:00
世界 d0b467671a Merge VLESS to library 2022-09-23 10:28:51 +08:00
世界 c18c545798 Add stdio test 2022-09-23 10:28:24 +08:00
世界 693ef293ac Update buffer usage 2022-09-23 10:27:48 +08:00
世界 a006627795 Disable DF on direct outbound by default 2022-09-23 10:27:46 +08:00
世界 0738b184e4 Fix url test interval 2022-09-23 10:27:42 +08:00
世界 42524ba04e Fix dns sniffer 2022-09-17 16:59:28 +08:00
世界 63fc95b96d Add mux server and XUDP client for VMess 2022-09-17 11:54:04 +08:00
世界 ab436fc137 Update documentation 2022-09-16 15:48:31 +08:00
世界 1546770bfd Skip bind on local addr 2022-09-16 15:35:29 +08:00
世界 f4b2099488 Fix tun log 2022-09-16 15:32:50 +08:00
世界 a2c4d68031 Fix create UDP transport 2022-09-15 16:46:53 +08:00
世界 cfe14f2817 Suppress bad http2 error 2022-09-15 15:34:52 +08:00
世界 a5402ffb69 Add back urltest outbound 2022-09-15 15:22:08 +08:00
世界 4d24cf5ec4 Update documentation 2022-09-15 13:25:51 +08:00
世界 668d354771 Make gVisor optional 2022-09-15 12:24:08 +08:00
世界 ad14719b14 Fix clash api proxy type 2022-09-14 23:02:11 +08:00
世界 d9aa0a67d6 Fix port rule match logic 2022-09-14 22:03:26 +08:00
世界 92bf784f4f Move shadowsocksr implementation to clash 2022-09-14 21:57:40 +08:00
世界 395b13103a Fix test 2022-09-14 18:02:51 +08:00
世界 628cf56d3c Fix close grpc conn 2022-09-14 18:02:37 +08:00
世界 ac5582537f Add back test workflow 2022-09-14 18:02:37 +08:00
世界 9aa7a20d96 Print tags in version command 2022-09-14 18:02:37 +08:00
世界 189f02c802 Refactor bind control 2022-09-14 18:02:37 +08:00
世界 2373281c41 Fix clash store-selected 2022-09-13 17:34:37 +08:00
世界 e8f4c2d36f Redirect clash hello to external ui 2022-09-13 17:29:57 +08:00
世界 07b6db23c1 Update install go script 2022-09-13 16:24:44 +08:00
世界 9a3360e5d0 Fix build on go1.18 2022-09-13 16:23:20 +08:00
世界 007a278ac8 Refactor to miekg/dns 2022-09-13 16:18:39 +08:00
世界 1db7f45370 Update documentation 2022-09-13 11:24:33 +08:00
世界 b271e19a23 Fix concurrent write 2022-09-13 10:41:10 +08:00
世界andarm64v8a 79b6bdfda1 Skip wait for hysteria tcp handshake response
Co-authored-by: arm64v8a <48624112+arm64v8a@users.noreply.github.com>
2022-09-13 10:40:26 +08:00
世界 38088f28b0 Add vless outbound and xudp 2022-09-12 21:59:27 +08:00
世界 dfb8b5f2fa Fix hysteria inbound 2022-09-12 18:35:36 +08:00
世界 9913e0e025 Add shadowsocksr outbound 2022-09-12 18:35:36 +08:00
世界 ce567ffdde Add obfs-local and v2ray-plugin support for shadowsocks outbound 2022-09-12 14:55:00 +08:00
世界 5a9913eca5 Fix socks4 client 2022-09-12 11:33:38 +08:00
世界 eaf1ace681 Update documentation 2022-09-11 22:48:42 +08:00
世界 a2d1f89922 Add custom tls client support for v2ray h2/grpclite transports 2022-09-11 22:44:35 +08:00
世界 7e09beb0c3 Minor fixes 2022-09-11 22:44:35 +08:00
世界 ebf5cbf1b9 Update documentation 2022-09-10 23:31:07 +08:00
世界 d727710d60 Run build on main branch 2022-09-10 22:54:53 +08:00
世界 0e31aeea00 Fix socks4 request 2022-09-10 22:54:50 +08:00
世界 2f437a0382 Add uTLS client 2022-09-10 22:10:45 +08:00
世界 3ad4370fa5 Add ECH TLS client 2022-09-10 22:10:45 +08:00
世界 a3bb9c2877 Import cloudflare tls 2022-09-10 22:10:45 +08:00
世界 ee7e976084 Refactor TLS 2022-09-10 22:10:45 +08:00
世界 099358d3e5 Add clash persistence support 2022-09-10 14:42:14 +08:00
世界 5297273937 Add clash mode support 2022-09-10 14:15:11 +08:00
世界 80cfc9a25b Fix processing empty dns result 2022-09-10 14:15:11 +08:00
世界 2ae4da524e Fix tun documentation 2022-09-10 10:21:42 +08:00
世界 bbe7f28545 Fix system stack crash 2022-09-09 19:44:13 +08:00
世界 78ddd497ee Fix no_gvisor build 2022-09-09 19:44:13 +08:00
世界 8d044232af Update documentation 2022-09-09 15:42:33 +08:00
世界 aa7e85caa7 Update dependencies
Add half close for smux
Update gVisor to 20220905.0
2022-09-09 14:44:18 +08:00
zakuwaki 46a8f24400 Optional proxyproto header 2022-09-09 14:44:18 +08:00
世界 87bc292296 Add comment filter for config 2022-09-09 14:44:18 +08:00
世界 ac539ace70 Add system tun stack 2022-09-09 14:44:18 +08:00
世界 a15b13978f Set default tun mtu to 9000 like clash
IDK why, maybe faster in a local speed test?
2022-09-09 14:44:18 +08:00
世界 0c975db0a6 Set udp dontfrag by default 2022-09-09 14:44:18 +08:00
世界 cb4fea0240 Refactor wireguard & add tun support 2022-09-09 14:44:18 +08:00
世界 8e7957d440 Add support for use with android VPNService 2022-09-09 14:44:18 +08:00
世界 f7bed32c6f Bump version 2022-09-09 14:43:42 +08:00
世界 ef7f2d82c0 Fix match 4in6 address in ip_cidr 2022-09-09 14:07:02 +08:00
世界 7aa97a332e Fix documentation 2022-09-09 13:54:02 +08:00
世界 7c30dde96b Minor fixes 2022-09-08 18:33:59 +08:00
GyDi 9cef2a0a8f Fix clashapi log level format error 2022-09-08 18:04:06 +08:00
世界 f376683fc3 Update documentation 2022-09-07 23:10:36 +08:00
世界 4b61d6e875 Fix hysteria stream error 2022-09-07 19:16:20 +08:00
世界 7d83e350fd Refine test 2022-09-07 19:16:20 +08:00
世界 500ba69548 Fix processing vmess termination signal 2022-09-07 19:16:20 +08:00
世界 9a422549b1 Fix json format error message 2022-09-07 13:23:26 +08:00
世界 3b48fa455e Fix naive inbound temporary 2022-09-07 12:30:54 +08:00
zakuwaki ef013e0639 Suppress accept proxyproto failed #65 2022-09-06 23:16:31 +08:00
世界 8f8437a88d Fix wireguard reconnect 2022-09-06 00:11:43 +08:00
世界 1b091c9b07 Update documentation 2022-09-04 13:15:10 +08:00
世界 4801b6f057 Fix DNS routing 2022-09-04 12:49:38 +08:00
世界 9078bc2de5 Fix write trojan udp 2022-09-03 16:58:55 +08:00
世界 b69464dfe9 Update documentation for dial fields 2022-09-03 13:02:41 +08:00
世界 62fa48293a Merge dialer options 2022-09-03 12:55:10 +08:00
世界 b206d0889b Fix dial parallel in direct outbound 2022-09-03 12:01:48 +08:00
世界 ee691d81bf Fix write zero 2022-09-03 09:25:30 +08:00
void aire() 56876a67cc Fix documentation typo (#60) 2022-09-02 19:04:03 +08:00
世界 4a0df713aa Add ws compatibility test 2022-09-01 20:32:47 +08:00
世界 ef801cbfbe Fix server install script 2022-09-01 20:32:47 +08:00
世界 9378fc88d2 Add with_wireguard to default server tag 2022-09-01 20:16:20 +08:00
世界 f46bfcc3d8 Move unstable branch to dev-next 2022-08-31 23:45:42 +08:00
0x7d274284 ccdb238843 Fix documentation typo (#57) 2022-08-31 23:42:36 +08:00
世界 f1f61b4e2b Fix install documentation 2022-08-31 23:37:30 +08:00
世界 a44cb745d9 Fix write log timestamp 2022-08-31 23:35:43 +08:00
世界 f5f5cb023c Update documentation 2022-08-31 14:34:32 +08:00
世界 5813e0ce7a Add shadowtls (#49)
* Add shadowtls outbound

* Add shadowtls inbound

* Add shadowtls example

* Add shadowtls documentation
2022-08-31 14:21:53 +08:00
dyhkwong 5a9c2b1e80 darwin pf support (#52) 2022-08-31 14:21:37 +08:00
世界 bda34fdb3b Refactor outbound documentation 2022-08-31 13:42:30 +08:00
世界 426b677eb8 Fix process_name rule item 2022-08-31 12:51:38 +08:00
世界 67c7e9fd86 Refactor inbound documetation 2022-08-31 12:50:26 +08:00
世界 d8028a8632 Fix smux session status 2022-08-31 10:00:15 +08:00
dyhkwong 374743d022 Add process_path rule item (#51)
* process matching supports full path
* Remove strings.ToLower
2022-08-30 10:44:40 +08:00
世界 cd98ea5008 Fix socksaddr type condition 2022-08-29 19:58:58 +08:00
世界 dbda0ed98a Add chained inbound support 2022-08-29 19:50:28 +08:00
世界 f5e0ead01c Fix inject conn 2022-08-29 19:02:41 +08:00
0x7d274284 44818701bc Fix issue template (#48)
The correct command to get the version is `sing-box version`
2022-08-29 16:52:15 +08:00
世界 e0f7387dff Fix search android package in non-owner users 2022-08-29 12:02:29 +08:00
世界 d440a01792 Add grpc compatibility test 2022-08-29 10:15:25 +08:00
世界 665c84ee42 Fix log item on document menu 2022-08-28 12:47:23 +08:00
Hellojack e0de96eb4c Minor fixes (#45)
* Cleanup code
* Fix documentation typo
2022-08-28 12:40:44 +08:00
世界 c6ef276811 Update dependencies 2022-08-28 12:21:22 +08:00
世界 1701aaf78c Add docker image 2022-08-28 00:23:41 +08:00
世界 122daa4bfb Simplify server installation 2022-08-28 00:23:41 +08:00
世界 561a9e5275 Update documentation 2022-08-28 00:23:41 +08:00
Hellojack de2453fce9 Add gun-lite gRPC implementation (#44) 2022-08-27 21:05:15 +08:00
世界 d59d40c118 Fix sniff override destination 2022-08-27 14:37:14 +08:00
rand0mgh0st 3469df001f Fix documentation for socks inbound (#42) 2022-08-27 13:16:04 +08:00
世界 0d8cfa3031 Add vmess packetaddr option 2022-08-27 11:28:01 +08:00
世界 0289586880 Add documentation for strict_route 2022-08-27 09:31:17 +08:00
rand0mgh0st e46427c7fc docs-zh-CN: use English for License section (#40) 2022-08-26 23:21:32 +08:00
世界 3ea59d9a8e Move documentation branch to main 2022-08-26 21:53:46 +08:00
世界 e85dfc6adf Add strict_route option 2022-08-26 21:53:08 +08:00
世界 d0703b78fa Fix dns hijack on android
iproute2 on android does not support port rules
2022-08-26 21:05:45 +08:00
世界 432e6adf3e Fix TLS documentation 2022-08-26 18:36:56 +08:00
世界 a057754035 Revert linux process searcher 2022-08-26 17:36:06 +08:00
世界 0348ace253 Initial release 2022-08-26 16:40:37 +08:00
世界 c5e38203eb Fix read DNS message 2022-08-26 13:35:27 +08:00
世界 9ac31d0233 Fix ipv6 route on linux 2022-08-26 12:30:31 +08:00
世界 9d8d1cd69d Update documentation 2022-08-26 11:10:02 +08:00
世界 07a0381f8b Cleanup vmessws 2022-08-26 10:22:29 +08:00
世界 f841459004 Cleanup vmesshttp 2022-08-26 08:41:45 +08:00
世界 78a26fc139 Update documentation 2022-08-25 22:49:23 +08:00
世界 9f6628445e Improve ip_cidr rule 2022-08-25 22:23:26 +08:00
世界 fa017b5977 Add contributing documentation 2022-08-25 21:08:29 +08:00
世界 58f4a970f2 Fix route connections 2022-08-25 20:48:59 +08:00
世界 021aa8faed Fix ipv6 route on linux 2022-08-25 18:57:36 +08:00
世界 83f6e037d6 Fix http proxy with compressed response 2022-08-25 18:40:13 +08:00
世界 baf153434d Fix issue template 2022-08-25 18:40:13 +08:00
世界 d481bd7993 Fix bind_address 2022-08-25 14:50:10 +08:00
Steven Tang e859c0a6ef Fix typo in features.md (#32) 2022-08-25 13:42:22 +08:00
zakuwaki 59a39e66b1 Add trojan fallback for ALPN #31 2022-08-25 13:37:32 +08:00
世界 fd5ac69a35 Let vmess use zero instead of auto if TLS enabled 2022-08-25 11:51:17 +08:00
世界 a940703ae1 Suppress expected error 2022-08-25 11:02:27 +08:00
世界 350729cde8 Remove TLS requirement on gRPC server 2022-08-25 10:52:16 +08:00
世界 2e14cd6d66 Close websocket conn gracefully 2022-08-25 10:46:14 +08:00
世界 f703524f04 Add stale workflow 2022-08-25 10:24:11 +08:00
世界 aa4435c775 Update documentation 2022-08-25 10:04:51 +08:00
Reece 31a2e368cc Fix zh-CN document symbol and format (#29) 2022-08-25 09:45:22 +08:00
世界 97e284e65e Initial zh-CN document translation: outbound 2022-08-24 21:02:28 +08:00
世界 a6baab92f3 Fix early close on windows and catch any 2022-08-24 19:03:15 +08:00
世界 7c76e0c3ee Initial zh-CN document translation: inbound 2022-08-24 18:43:39 +08:00
世界 591a4fcf8e Initial zh-CN document translation: shared 2022-08-24 17:39:37 +08:00
世界 71dac85600 Add ACME EAB support 2022-08-24 17:06:28 +08:00
世界 ad90ddd327 Initial zh-CN document translation: route 2022-08-24 16:56:29 +08:00
世界 03f457f3d0 Initial zh-CN document translation: DNS 2022-08-24 16:37:06 +08:00
Hellojack a878256367 Fix TLS insecure (#27) 2022-08-24 16:11:41 +08:00
世界 553f78ed55 Fix close non-duplex connections 2022-08-24 14:32:18 +08:00
世界 1bc7d2237e Initial zh-CN document translation: examples 2022-08-24 13:14:12 +08:00
世界 132222013b Initial zh-CN document translation: FAQ 2022-08-24 13:04:47 +08:00
世界 2008fb552a Initial zh-CN document translation 2022-08-24 12:45:51 +08:00
世界 236c034c62 Fix unix search path 2022-08-24 12:27:36 +08:00
世界 f87baf08d3 Fix naive padding 2022-08-24 10:21:56 +08:00
世界 22aa0c2f40 Update documentation 2022-08-24 00:39:25 +08:00
世界 88469d4aaa Check configuration before reload 2022-08-23 23:44:44 +08:00
世界 1413c5022a Add proxy protocol support 2022-08-23 21:07:35 +08:00
世界 aa8cdaee22 Handle SIGHUP signal 2022-08-23 19:56:28 +08:00
世界 9f6ff54a76 Parse X-Forward-For in HTTP requests 2022-08-23 19:53:04 +08:00
世界 e750c747c6 Fix test naive inbound with nginx 2022-08-23 14:41:31 +08:00
世界 9edfe7d9d3 Accept HTTP1 in naive inbound 2022-08-23 13:25:03 +08:00
世界 c9b7acd22c Add v2ray transport to trojan 2022-08-23 13:24:52 +08:00
世界 2ba2f0298c Free memory after start 2022-08-22 23:17:08 +08:00
世界 a24a2b475a Allow http1 in v2ray HTTP transport 2022-08-22 23:02:25 +08:00
世界 4005452772 Add v2ray HTTP transport 2022-08-22 22:20:19 +08:00
世界 d4b7e221f0 Add v2ray QUIC transport 2022-08-22 22:20:19 +08:00
世界 77c98fd042 Add v2ray WebSocket transport 2022-08-22 22:20:18 +08:00
世界 082872b2f3 Prepare v2ray client/server transport 2022-08-22 18:57:05 +08:00
世界 6253e2e24c Fix clash server early close 2022-08-22 16:33:33 +08:00
世界 4216afe62f Minor fixes 2022-08-22 16:14:53 +08:00
世界 8fec78a5cd Apply bind address to udp connect 2022-08-22 14:35:05 +08:00
世界 7ba0a14e97 Add bind address to outbound options 2022-08-22 14:28:23 +08:00
世界 3a442347a5 Update documentation 2022-08-22 14:19:32 +08:00
世界 c4f4fd97d6 Fix tests 2022-08-22 12:02:16 +08:00
世界 ac0ead1473 Add strategy setting for each dns server 2022-08-22 12:01:50 +08:00
世界 83cea9475d Fix vectorised writer 2022-08-21 22:35:58 +08:00
世界 dc6bb7ab1b Add ssh outbound 2022-08-21 22:30:48 +08:00
世界 c71f6ba377 Add FAQ page 2022-08-21 22:26:08 +08:00
世界 b1b1ab5350 Update release config 2022-08-21 13:03:19 +08:00
世界 7613b8dbfe Fix gvisor udp write back 2022-08-21 11:40:04 +08:00
世界 e4cece6095 Add tor outbound 2022-08-21 01:06:34 +08:00
世界 bcefe8716f Fix typo in documentation 2022-08-20 21:16:14 +08:00
世界 746b5d8be0 Add trojan connection fallback 2022-08-20 21:08:53 +08:00
世界 f13ecbd9bb Wait a second before check route update 2022-08-20 13:42:28 +08:00
世界 e839beb73b Skip bind connection with private destination to interface 2022-08-20 13:31:15 +08:00
世界 b797cdf91e Fix write socks5 username password auth request 2022-08-20 13:26:49 +08:00
世界 84e4677a94 Improve process searcher 2022-08-20 12:11:27 +08:00
世界 0377a11719 Fix route on android 2022-08-20 10:27:13 +08:00
世界 d0fa79044a Start outbounds before router 2022-08-20 09:13:00 +08:00
世界 f381f8d35a Fix read packages in android 13 2022-08-20 03:05:50 +08:00
世界 92e1e5b893 Attempt to unwrap ip-in-fqdn socksaddr 2022-08-20 00:01:08 +08:00
世界 8e8b4dba22 Update documentation 2022-08-19 22:30:12 +08:00
世界 767cd55817 Fix acme issuer 2022-08-19 18:42:12 +08:00
世界 eb0ef439d6 Add with_acme to server scripts 2022-08-19 17:48:56 +08:00
世界 0bf78c0a8a Update gVisor to 20220815.0 2022-08-19 17:47:54 +08:00
世界 12d7e19f32 Allow read config from stdin 2022-08-19 15:43:13 +08:00
世界 d1c3dd0ee1 Add hysteria and acme TLS certificate issuer (#18)
* Add hysteria client/server
* Add acme TLS certificate issuer
2022-08-19 15:42:57 +08:00
世界 3dfa99efe1 Add back dns concurrent write lock 2022-08-19 10:51:26 +08:00
世界 d7bd221a47 Fix darwin tun 2022-08-19 08:35:08 +08:00
世界 1b7a3b4a74 Fix log to file 2022-08-19 08:26:26 +08:00
世界 c8424ed8fd Fix format 2022-08-19 08:26:26 +08:00
世界 150df1ae8e Add write lock to shadowsocks aead writer 2022-08-19 08:26:26 +08:00
世界 5ca9d77176 Fix close shadowsocks server conn 2022-08-18 23:16:05 +08:00
世界 aa89fcc29d Fix find process with lwip stack 2022-08-18 10:10:30 +08:00
Tianling Shen 7ead0de26b Fix geosite path (#17)
`geoIPOptions` -> `geositeOptions`

Signed-off-by: Tianling Shen <i@cnsztl.eu.org>
2022-08-18 10:00:56 +08:00
世界 f22c2690ec Fix lint 2022-08-17 20:15:35 +08:00
世界 738bb0eabc Improve async dns transports 2022-08-17 20:10:59 +08:00
世界 002a519a17 Update documentation 2022-08-17 15:19:10 +08:00
世界 f51128f772 Add ip_version rule item 2022-08-16 23:47:14 +08:00
世界 d6a0aa7ccf Add wireguard outbound and test 2022-08-16 23:39:11 +08:00
世界 ca94a2ddcb Improve tproxy udp write back 2022-08-16 18:37:37 +08:00
世界 835ae1217b Update exec/control usage 2022-08-16 18:19:48 +08:00
世界 c165969399 Fix include_android_user option 2022-08-16 12:16:59 +08:00
世界 88c69a06dc Fix copy stream 2022-08-15 16:53:12 +08:00
世界 cd5e7055d2 Add android package rules support in tun routing 2022-08-15 11:44:59 +08:00
世界 3157593b6b Add uid and android user rules support in tun routing 2022-08-15 11:41:00 +08:00
世界 c8399a297e Improve cmd 2022-08-13 18:37:51 +08:00
Hellojack 529cfe2d9a Fix documentation typo (#13) 2022-08-13 11:01:22 +08:00
世界 50869c6cd2 Fix dns concurrent write 2022-08-13 11:00:15 +08:00
世界 44fcfab9aa Improve build 2022-08-12 22:58:28 +08:00
世界 340fce9f1c Add UoT option to socks outbound too 2022-08-12 17:55:52 +08:00
世界 51bbf93ff2 Improve smux write 2022-08-12 16:49:25 +08:00
Hellojack f8d13d79c7 Add CGO hint in version cmd (#12) 2022-08-12 15:46:26 +08:00
世界 b0ed1dc106 Minor fixes 2022-08-12 12:13:57 +08:00
世界 ef8dde2b70 Update dependencies 2022-08-12 10:58:16 +08:00
世界 97870c9288 Refactor bufio 2022-08-11 23:59:22 +08:00
世界 c9226aeaaf Show memory stats in debug 2022-08-11 12:42:17 +08:00
世界 7b30815938 Enable QUIC in server scripts 2022-08-11 11:45:33 +08:00
世界 517a89fa9c Add back UoT support 2022-08-11 10:36:28 +08:00
Reece cf80073f27 Fix documentation typo (#10) 2022-08-11 10:06:10 +08:00
世界 43353ca5a4 Update documentation 2022-08-10 21:21:02 +08:00
世界 b79b19c470 Add naive inbound and test 2022-08-10 20:36:29 +08:00
iKirby ccdfab378a Fix default dns server option (#9) 2022-08-10 12:18:03 +08:00
世界 0e6b4df8f1 Remove incorrect warning 2022-08-09 17:27:22 +08:00
世界 79a4b18ec7 Update dependencies 2022-08-09 15:55:16 +08:00
世界 e05ee55545 Improve cmd 2022-08-09 14:49:17 +08:00
世界 ecaddd897e Fix direct connect 2022-08-09 14:48:08 +08:00
世界 8ca6c246a8 Improve wintun read 2022-08-08 21:35:59 +08:00
世界 55d9a0ef2f Update documentation 2022-08-08 21:10:37 +08:00
世界 4067e0f25c Fix copy early conn 2022-08-08 20:56:53 +08:00
世界 6d78cf6b58 Add trojan inbound/outbound 2022-08-08 09:08:12 +08:00
世界 df6635c620 Simplify vmess test 2022-08-08 09:08:12 +08:00
世界 2c6d239525 Fix mux overflow 2022-08-08 08:09:17 +08:00
世界 0f74f6cd60 Add optional LWIP tun stack support 2022-08-07 17:21:49 +08:00
世界 6ee10c03d1 Update gVisor to 20220801.0 2022-08-07 14:58:07 +08:00
世界 587739e95c Fix linux unprivileged tun usage 2022-08-07 11:21:10 +08:00
世界 01bace7769 Fix wininet wrapper 2022-08-06 17:28:51 +08:00
世界 dfa10d4ebe Add tun support for macOS 2022-08-05 17:02:51 +08:00
世界 f691bd5ce1 Add set system proxy support for macOS 2022-08-05 16:55:59 +08:00
世界 64dbac8138 Add multiplexer for vmess 2022-08-04 10:38:20 +08:00
世界 1c3c154d6d Improve multiplex log 2022-08-04 09:12:57 +08:00
世界 32201cacda Add unsafe tag to multiplexer writer 2022-08-04 00:00:22 +08:00
世界 5f566b140f Add documentation for simple linux installation 2022-08-03 22:23:40 +08:00
世界 03890151d7 Improve multiplexer 2022-08-03 22:01:18 +08:00
世界 8e4de29409 Improve dns log 2022-08-03 20:17:05 +08:00
世界 f6f3390490 Update WriteToUDPAddrPort usage since fixed by go1.18.5 2022-08-03 17:49:04 +08:00
世界 d4cce1b5b9 Update task usage 2022-08-03 17:44:19 +08:00
世界 44068500bf Replace netlink with fork 2022-08-03 12:02:22 +08:00
世界 8ce263669e Fix windows tun leak memory 2022-08-03 10:14:00 +08:00
世界 6b4824ffba Minor fixes 2022-08-02 18:47:23 +08:00
世界 9a8918cb9e Improve mux server 2022-08-02 15:11:51 +08:00
世界 b1c2440371 Fix buffer usage 2022-08-02 13:43:25 +08:00
世界 37b11e614c Fix dns sniff 2022-08-02 13:42:31 +08:00
世界 14b6200fd8 Add documentation for vmess 2022-08-01 17:02:13 +08:00
世界 7966b80476 Add vmess legacy server and test 2022-08-01 12:23:34 +08:00
世界 b526ab2746 Fix netlink subscription leak 2022-07-31 19:58:32 +08:00
世界 0e0a892b7e Make socks request buffered 2022-07-31 19:58:32 +08:00
世界 fe3649cd27 Close tun endpoint faster 2022-07-31 19:58:32 +08:00
世界 e014220508 Add retry for mux stream open 2022-07-31 18:08:24 +08:00
世界 d06fd03dd8 Fix tcp keep alive 2022-07-31 09:53:29 +08:00
世界 0eed0ca11a Fix dns outbound 2022-07-31 08:48:56 +08:00
世界 c57ea9e47c Add omitempty for listen_port 2022-07-30 22:05:06 +08:00
世界 70b4577dbe Add TLS certificate reload 2022-07-30 22:04:01 +08:00
世界 5f1f55fbe7 Add documentation for shadowsocks multiplexer 2022-07-30 21:23:45 +08:00
世界 2d3d46eb34 Fix copy with src buffer to headroom writer 2022-07-30 21:23:45 +08:00
世界 2ce09b6ffd Minor fixes 2022-07-30 14:50:33 +08:00
世界 d3378a575c Improve error processing 2022-07-30 09:57:02 +08:00
世界 9b9b5ebb72 Add linux server scripts 2022-07-30 09:04:01 +08:00
世界 0f7f800e95 Use connect for quic/dns udp connections 2022-07-30 09:04:01 +08:00
世界 457de86819 Add multiplexer 2022-07-30 09:04:01 +08:00
世界 83154eadd3 Hide dns outbound in clash-dashboard 2022-07-28 16:40:06 +08:00
世界 c0a2f77258 Remove urltest outbound 2022-07-28 16:36:31 +08:00
世界 c240f1b359 Add shadowsocks-multiuser control api 2022-07-27 21:59:40 +08:00
世界 aa074a2063 Update documentation 2022-07-27 12:13:31 +08:00
世界 f008d0bde3 Add endpoint independent nat support for tun inbound 2022-07-27 11:59:36 +08:00
世界 0347a7c038 Remove go-json 2022-07-26 13:53:25 +08:00
世界 75508bccb5 Add optional DoT, DoH3 support 2022-07-26 12:48:10 +08:00
世界 593799a988 Fix clash tracker timeout 2022-07-26 06:56:24 +08:00
世界 816d7b734c Improve udp timeout 2022-07-25 22:15:16 +08:00
世界 5491895a60 Print stack if no context 2022-07-25 16:18:22 +08:00
世界 9394674b63 Fix format command 2022-07-25 16:18:11 +08:00
世界 146008a631 Fix direct udp outbound 2022-07-25 16:18:11 +08:00
世界 cc78f0347d Improve config struct 2022-07-25 16:18:11 +08:00
世界 1f05420745 Improve tls dialer and listener 2022-07-25 16:18:11 +08:00
世界 32e2730ec6 Add runtime warnings 2022-07-25 07:19:15 +08:00
世界 29c329dc52 Add retry for linux process search 2022-07-24 21:43:05 +08:00
世界 7d8a7c5c7d Add default tcp keep alive settings 2022-07-24 21:29:39 +08:00
世界 9e9e6f7ee6 Improve udp dns close 2022-07-24 21:25:41 +08:00
世界 fde33fbb30 Add lint workflow 2022-07-24 21:22:19 +08:00
世界 67edc163cb Add with_std_json build tag 2022-07-24 21:22:19 +08:00
世界 fe8e984608 Add route.default_mark option 2022-07-24 17:46:25 +08:00
世界 7d340e7ef9 Add source_port_range/port_range rule item 2022-07-24 16:21:35 +08:00
世界 af19ba6119 Add disable_cache option to dns rule 2022-07-24 16:21:31 +08:00
世界 8666631732 Add rcode dns transport 2022-07-24 16:21:31 +08:00
世界 ce4b7231e2 Add invert rule item 2022-07-24 16:21:31 +08:00
世界 21641be9d1 Fix direct inbound 2022-07-24 13:42:58 +08:00
世界 5f6f33c464 Add process_name/package_name/user/user_id rule item 2022-07-23 21:28:13 +08:00
世界 4abf669d09 Fix urltest log 2022-07-23 10:28:32 +08:00
世界 187de6c738 Update documentations 2022-07-23 09:29:37 +08:00
世界 884c0cf595 Deprecate dns_hijack and dns inbound 2022-07-23 09:15:47 +08:00
世界 9f8978bbcf Improve 4in6 processing 2022-07-22 17:57:23 +08:00
世界 bcdf71deb3 Fix tproxy inbound 2022-07-22 16:18:56 +08:00
世界 7bc7b72c61 Fix log format 2022-07-22 14:28:29 +08:00
世界 e531e89b4b Fix dns inbound 2022-07-22 14:25:04 +08:00
世界 e5e24ef51d Add documentation for selector/urltest outbound 2022-07-22 14:07:27 +08:00
世界 c4e46c35b5 Add urltest outbound 2022-07-22 13:51:08 +08:00
世界 139127f1e4 Expand env in clash external-ui 2022-07-22 09:49:35 +08:00
世界 3838d3070d Fix integration with clash-dashboard 2022-07-22 09:41:12 +08:00
世界 8004ff51f0 Add selector outbound 2022-07-21 21:13:16 +08:00
世界 385c42e638 Improve socksaddr 2022-07-20 22:05:11 +08:00
世界 6327c4b40c Minor fixes 2022-07-20 13:46:47 +08:00
世界 45643fbed1 Add documentation for clash_api 2022-07-20 07:37:50 +08:00
世界 6ac1b395cf Make clash api and gVisor optional 2022-07-20 07:12:40 +08:00
世界 c5b3e8b042 Add basic clash api 2022-07-19 22:45:55 +08:00
世界 c7fabe40ed Improve connection timeout 2022-07-18 20:53:13 +08:00
世界 3fb011712b Add vmess compatibility test 2022-07-18 20:14:53 +08:00
世界 6b1a68908d Add vmess inbound/outbound 2022-07-18 12:32:31 +08:00
世界 d1e83882e5 Add user rule item 2022-07-17 17:38:11 +08:00
世界 cf845d946e Exclude gVisor for unsupported arch 2022-07-17 11:16:13 +08:00
世界 4094c94971 Disable gVisor log 2022-07-17 11:15:56 +08:00
世界 ae36e35de7 Improve workflow build 2022-07-17 09:54:34 +08:00
世界 a4b5c61e25 Update dependencies 2022-07-17 09:18:36 +08:00
世界 9caa37c12a Add debug build for all arch 2022-07-17 09:11:23 +08:00
世界 9871ed955b Fix build 2022-07-17 08:48:57 +08:00
世界 afc2f509a4 Minor fixes 2022-07-16 22:03:35 +08:00
世界 fe5618c35d Add stun sniffer 2022-07-16 12:01:02 +08:00
世界 8619e07d66 Fix private addr check 2022-07-16 10:19:18 +08:00
世界 eb35d6793e Fix linux tun 2022-07-15 22:40:42 +08:00
世界 a25db09aac Fix set_system_proxy when using sudo 2022-07-15 18:30:01 +08:00
世界 8969a15858 Fix tun auto_route on android 2022-07-15 18:29:29 +08:00
世界 377f3f83a2 Add route.default_interface option 2022-07-15 15:00:54 +08:00
世界 5a3de62c50 Add ss aead tests 2022-07-15 11:18:19 +08:00
世界 2c2eb31e18 Add redir tproxy and dns inbound 2022-07-15 08:42:02 +08:00
世界 e13b72afca Improve bind interface 2022-07-14 23:11:53 +08:00
世界 b9086b31e6 Add set_system_proxy for linux and android 2022-07-14 20:32:17 +08:00
世界 de2db7c2d2 Add documentation for set_system_proxy 2022-07-14 14:41:35 +08:00
世界 238afda9da Add set_system_proxy option for windows 2022-07-14 14:41:35 +08:00
世界 e7d557fd9e Add tun inbound for windows 2022-07-13 22:41:21 +08:00
世界 4fc763cfa2 Refactor log 2022-07-12 17:44:12 +08:00
世界 b47f3adbb3 Improve copy early conn 2022-07-11 20:55:14 +08:00
世界 862e3c430c Fix tcp sniffing 2022-07-11 20:37:57 +08:00
世界 dc127e2994 Migrate components to library 2022-07-11 18:44:59 +08:00
世界 3c1190e2c3 Make dns.strategy take effect in dns exchange 2022-07-11 13:21:17 +08:00
世界 a104d18277 Fix hijack_dns 2022-07-11 12:56:57 +08:00
世界 6048b1e270 Remove ToString0[T] usage to fix golangci-lint 2022-07-10 22:00:28 +08:00
世界 0ef2e330e3 Fix create gVisor endpoint 2022-07-10 19:17:44 +08:00
世界 b417bd5be4 Add documentation and example for linux tun 2022-07-10 16:44:12 +08:00
世界 7f84191748 Minor fixes 2022-07-10 16:19:42 +08:00
世界 29f78248dc Add hijack_dns for tun 2022-07-10 10:14:45 +08:00
世界 638f8a52d1 Add auto_route and auto_detect_interface for linux 2022-07-10 08:18:52 +08:00
世界 4432cc2253 Fix quic sniff irl 2022-07-10 07:52:33 +08:00
世界 730144cc26 Add tun inbound for linux 2022-07-09 22:44:18 +08:00
世界 c463d0cf80 Fix pages 2022-07-09 15:09:50 +08:00
世界 8eecae92b2 Add benchmark page 2022-07-09 15:00:31 +08:00
世界 6832451561 Fix direct udp 2022-07-09 10:28:22 +08:00
世界 41925b6606 Add bug template 2022-07-09 09:34:16 +08:00
世界 211d97ff8a Declare required fields in the documentation 2022-07-09 09:28:54 +08:00
世界 8fa953a516 Add debug workflow 2022-07-09 09:02:06 +08:00
世界 7f8c9ffa30 Add shadowsocks tests 2022-07-09 08:59:34 +08:00
世界 f448b6b977 Update gci format 2022-07-08 23:03:57 +08:00
世界 e0cfc33fe2 Fix handle missing err on quic sniff 2022-07-08 22:49:17 +08:00
世界 76236a0b75 Add examples 2022-07-08 22:45:06 +08:00
世界 0cb9d79044 Add route documentation 2022-07-08 20:20:38 +08:00
世界 eeaee94e5e Add outbound documentation 2022-07-08 19:58:56 +08:00
世界 ddbdac7d97 Add inbound documentation 2022-07-08 18:42:29 +08:00
世界 b44a1cd823 Add README 2022-07-08 18:41:18 +08:00
世界 d6d02b9924 Add log and dns documentation 2022-07-08 18:41:18 +08:00
世界 3699a57847 Add dial parallel for outbound dialer 2022-07-08 13:08:29 +08:00
世界 d45007b501 Improve load geosite 2022-07-08 11:00:46 +08:00
世界 9c256afc1a Add resolver for inbound 2022-07-07 23:45:05 +08:00
世界 538a1f5909 Add resolver for outbound dialer 2022-07-07 21:47:21 +08:00
世界 ecac383477 Add disableCache/disableExpire option for dns client 2022-07-06 23:43:20 +08:00
世界 8a761d7e3b Add dns client 2022-07-06 23:14:19 +08:00
世界 651c4b539a Add dns transports 2022-07-06 19:01:46 +08:00
世界 81330d32e0 Remove lint action 2022-07-06 15:45:13 +08:00
世界 3696e81eb4 Update gci format 2022-07-06 15:01:09 +08:00
世界 46f28a9de9 Add protect path dialer option 2022-07-06 14:45:56 +08:00
世界 dcd7ca78fc Rename find path 2022-07-06 14:44:51 +08:00
世界 86a38a1c7e Add domain sniffer 2022-07-06 12:39:44 +08:00
世界 2d9203ee74 Refactor geo resources 2022-07-05 13:23:47 +08:00
世界 8392567962 Add geosite 2022-07-05 09:05:35 +08:00
世界 f76102dab5 Add geosite protocol 2022-07-04 19:39:58 +08:00
世界 43cf0441db Fix route 2022-07-04 19:34:45 +08:00
世界 792dc83778 Add version command 2022-07-04 17:14:15 +08:00
世界 718b4afbf3 Add check/format command 2022-07-04 16:59:27 +08:00
世界 13f41f59d6 Refine log output 2022-07-04 16:45:32 +08:00
世界 ca5b782106 Add domain_regex rule 2022-07-04 15:51:41 +08:00
世界 8e7f215514 Shadowsocks multi-user/relay inbound 2022-07-04 15:34:43 +08:00
世界 4fc4eb09b0 Add http/block outbound & Improve route 2022-07-04 08:16:25 +08:00
世界 18e3f43df3 Refactor struct & Add override dialer options 2022-07-03 20:59:25 +08:00
世界 28b865acf0 Refactor json 2022-07-03 19:43:27 +08:00
世界 dd56b2584b Add internal private geoip rule 2022-07-03 15:57:09 +08:00
世界 70c0812606 Add socks outbound 2022-07-03 13:14:49 +08:00
世界 ef5cfd59d4 Ordered json output & Disallow unknown fields 2022-07-03 11:28:15 +08:00
世界 85a695caa1 Add format config support 2022-07-03 03:42:57 +08:00
世界 30444057bd Invalid config check 2022-07-03 01:57:04 +08:00
世界 6eae8e361f Implement route rules 2022-07-02 23:19:14 +08:00
世界 7c57eb70e8 Inbound rule support 2022-07-02 14:07:50 +08:00
世界 9f4c0ff624 Refactor adapter 2022-07-01 19:37:41 +08:00
世界 60691819b1 Init commit 2022-07-01 16:58:18 +08:00
2134 changed files with 412547 additions and 19732 deletions
+31
View File
@@ -0,0 +1,31 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--maintainer "nekohasekai <contact-git@sekai.icu>"
--no-deb-generate-changes
--config-files /etc/config/sing-box
--config-files /etc/sing-box/config.json
--depends ca-bundle
--depends kmod-inet-diag
--depends kmod-tun
--depends firewall4
--depends kmod-nft-queue
--before-remove release/config/openwrt.prerm
release/config/config.json=/etc/sing-box/config.json
release/config/openwrt.conf=/etc/config/sing-box
release/config/openwrt.init=/etc/init.d/sing-box
release/config/openwrt.keep=/lib/upgrade/keep.d/sing-box
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE
+23
View File
@@ -0,0 +1,23 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--maintainer "nekohasekai <contact-git@sekai.icu>"
--config-files etc/sing-box/config.json
--after-install release/config/sing-box.postinst
release/config/config.json=/etc/sing-box/config.json
release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service
release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service
release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf
release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules
release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE
+26
View File
@@ -0,0 +1,26 @@
-s dir
--name sing-box
--category net
--license GPL-3.0-or-later
--description "The universal proxy platform."
--url "https://sing-box.sagernet.org/"
--vendor SagerNet
--maintainer "nekohasekai <contact-git@sekai.icu>"
--deb-field "Bug: https://github.com/SagerNet/sing-box/issues"
--no-deb-generate-changes
--config-files /etc/sing-box/config.json
--after-install release/config/sing-box.postinst
release/config/config.json=/etc/sing-box/config.json
release/config/sing-box.service=/usr/lib/systemd/system/sing-box.service
release/config/sing-box@.service=/usr/lib/systemd/system/sing-box@.service
release/config/sing-box.sysusers=/usr/lib/sysusers.d/sing-box.conf
release/config/sing-box.rules=usr/share/polkit-1/rules.d/sing-box.rules
release/config/sing-box-split-dns.xml=/usr/share/dbus-1/system.d/sing-box-split-dns.conf
release/completions/sing-box.bash=/usr/share/bash-completion/completions/sing-box.bash
release/completions/sing-box.fish=/usr/share/fish/vendor_completions.d/sing-box.fish
release/completions/sing-box.zsh=/usr/share/zsh/site-functions/_sing-box
LICENSE=/usr/share/licenses/sing-box/LICENSE
+538
View File
@@ -0,0 +1,538 @@
# Shater v0.2 — build the 4-package signed **apk** feed and publish it as
# per-arch Gitea releases consumable as an apk repository.
#
# WHAT WE SHIP
# ONE forked binary plus its OpenWrt glue, 4 packages, all built the canonical
# SDK way:
# - shaterd PREBUILT static-musl + SPA-embedded + UPX binary. Built
# OUT OF TREE by scripts/build-shaterd.sh (Go + Node + UPX)
# and staged into openwrt/shaterd/files/ BEFORE the SDK
# build; the openwrt/shaterd package just $(INSTALL_BIN)s
# the arch-matched artifact. (arch-specific .apk)
# - shater-core data glue, PKGARCH=all
# - luci-app-shater LuCI thin launcher, PKGARCH=all (uses feeds/luci/luci.mk)
#
# TARGET HARDWARE / ARCH MATRIX
# x86_64 -> the QEMU testbed VM (generic x86-64).
# aarch64_cortex-a53 -> BOTH production routers (BPI-R3 mini + BPI-R4,
# mediatek/filogic), both on 25.12 with apk-tools 3.
# Only shaterd is arch-specific; shater-core + luci-app-shater are
# PKGARCH=all, so one build of each covers every device — but the RELEASES
# are still per-arch (see the release-apk job for why).
#
# FORMAT: apk ONLY (25.12+)
# The fleet runs OpenWrt/ImmortalWrt 25.12, where opkg is replaced by Alpine
# apk (.apk files, binary packages.adb index, EC keys in /etc/apk/keys/). The
# old .ipk lane was removed in 2026-07 (docs-shater/DECISIONS.md D22): no
# device we serve has an opkg binary at all, so building and signing a second
# feed served nobody.
#
# FEED SIGNING (EC / apk)
# packages.adb is signed with the EC (prime256v1) SECRET key in the Gitea repo
# secret KEY_APK; routers verify it with the committed public key
# dist/shater-apk.pem (ci/gen-apk-key.sh). Do NOT regenerate the key — that
# invalidates every deployed router's trust.
#
# AUTO-RELEASE
# The rolling per-arch `apk-latest-<arch>` is published on EVERY run — tag runs
# included — and then read back over the API to assert it really serves the
# version just built. A tag push `vX.Y.Z` publishes the pinnable per-arch
# `apk-vX.Y.Z-<arch>` IN ADDITION. It is not an either/or: it used to be, and
# the rolling pointer then froze at 0.2.0 while v0.2.9/v0.2.10 shipped (see the
# long comment above the `release-apk` job). Publish uses the Gitea API via curl
# (ci/gitea-release.sh) — no external action needed. NOTE: the apk release tags
# deliberately do NOT start with `v` so publishing them cannot re-trigger this
# workflow's `v*` filter.
#
# PACKAGE VERSIONING (bug B4)
# PKG_VERSION/PKG_RELEASE are NOT hand-written in the Makefiles any more. They
# used to be, and nobody bumped them: v0.2.2…v0.2.6 all shipped as
# `shaterd 0.2.0-r3` with different binaries inside, so `apk update` never saw
# a new version and routers could not be updated at all. Now `ci/version.sh`
# derives them from the git tag ONCE per job (the "Compute version" step,
# exported via $GITHUB_ENV):
# tag `vX.Y.Z` -> X.Y.Z-r1
# anything else -> <nearest tag>-r<commits since it + 1>
# and hands them to the SDK build as SHATER_PKG_VERSION/SHATER_PKG_RELEASE;
# $SHATER_VERSION (the same numbers, plus the short sha off-tag) is stamped
# into the binary's constant.Version. ci/sdk-build-apk.sh then ASSERTS that the
# built .apk really carry that version, so the failure can never be silent
# again. This is also why the build job checks out with fetch-depth: 0
# — `git describe` needs tags and ancestry. Every package this repo ships is
# versioned from the tag; there is no longer an exception to remember.
# CACHING (T3 — fast CI)
# All caches use actions/cache pinned to v3.3.2: the LAST release speaking the
# OLD cache API that Gitea's act_runner cache server implements. v4 (and the
# v3.4.x backports) moved to GitHub's new cache service and fail on act_runner
# — the same reason upload-artifact is pinned to v3 here. If the runner's
# cache server is disabled, actions/cache degrades to a warning and the build
# proceeds uncached (correct, just slower).
# What is cached, and why each key is safe:
# - ImmortalWrt SDK tarball (.cache/sdk) — key = tarball basename (carries
# release + target + gcc), exact-only. Cold-miss fallback chain lives in
# ci/fetch-sdk.sh: own Gitea release-asset mirror (tag `sdk-cache`) ->
# upstream with stall-kill + retries; upstream success re-seeds the mirror.
# - SDK dl/ sources (.cache/dl) — key = hash of openwrt/*/Makefile
# (PKG_VERSION/PKG_HASH live there). Stale-safe: the buildroot verifies
# PKG_HASH on every dl/ file and re-downloads on mismatch, so restore-keys
# prefix fallback is allowed.
# - Go module + build cache — key = hash of go.sum; shared by both build
# jobs (each builds both GOARCHes).
# - panel/node_modules — key = hash of panel/package-lock.json, exact-only
# (a lockfile change MUST miss); on hit build-shaterd.sh gets --fast.
# - apt .deb archives for the debian:bookworm host-deps of the apk SDK
# container (.cache/apt) — key = hash of ci/sdk-build-apk.sh (the apt list
# is in it).
# - SDK feeds/ git checkouts (.cache/feeds) — the single biggest recurring
# cost: `scripts/feeds update -a` cloned base+packages+luci+routing+
# telephony EVERY run (~7 min/job; github.com is ~1 MB/s from this
# runner — run 51 evidence). The feeds dir is symlinked into the SDK
# container from the workspace cache; `feeds update` on an existing clone
# is a fast fetch+checkout of the pinned revs. Correctness-safe: update
# always checks out feeds.conf's pins, and ci/sdk-build-apk.sh wipes the
# cache + re-clones fresh if update ever fails on a cached checkout.
# Key = lane + SDK release (shared across the two arch jobs — the same
# release pins identical feed revs; the sequential runner means the second
# arch restores what the first saved). restore-keys lets an SDK version
# bump start from the old clones (git fetch delta, not re-clone).
# Act_runner facts this design leans on (verified in run 51 logs):
# - the cache backend works: restores/saves confirmed, hashFiles() works;
# - docker images (debian:bookworm, runner-images) live on the PERSISTENT
# host daemon — "Image is up to date" each run, no re-download;
# - each actions/cache SAVE is followed by an exact 3-minute act_runner
# stall (node process lingers; hit→no-save→no stall). Steady state saves
# nothing, so adding cache entries is fine, but keys that change every
# run (e.g. github.sha) would cost +3 min/entry/run — do NOT do that.
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
# A re-dispatch supersedes the still-running build of the same ref: cancel it
# instead of piling up parallel runs (the user re-triggers often). Tag builds
# are safe: each tag is its own group, so a release build is only ever cancelled
# by a re-run of the SAME tag (which supersedes it by definition). Gitea
# versions without concurrency support ignore this block harmlessly.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# THE TEST GATE (2026-07-26). Everything below `needs:` this job, so a red test
# stops the release instead of shipping with it.
#
# WHY IT IS A JOB HERE AND NOT JUST .gitea/workflows/test.yml: a separate
# workflow cannot block another one — they run side by side and a red `test`
# workflow would have published anyway. Only a `needs:` edge inside THIS
# workflow is a gate. test.yml exists too, for fast feedback on `main`; both
# call the same scripts/run-tests.sh so they cannot drift.
#
# WHAT WAS BROKEN: the release tract ran two `go test` invocations in total —
# build-shaterd.sh's one-package buildtags check and check-router-tags.sh's
# three named tests. 115 of the 116 test files under shater/** had never run in
# CI (upstream's .github/workflows/test.yml triggers on branches this fork does
# not have, and Gitea ignores .github/workflows entirely once .gitea/workflows
# exists). TestDNSFilterRemoteBlocklistHTTPClient shipped red twice.
#
# WHAT IT COVERS: the whole suite under the SHIPPED build tags
# (scripts/router-tags.sh) on linux — the two dimensions that were missing.
# transport/wireguard compiles 1 test file without the tag set and 7 with it
# (the AmneziaWG ones); shater/generate has 44 test files on linux against 32
# elsewhere. Plus a -race pass and the panel's TypeScript tests. Details and
# the named, reasoned exclusions are in scripts/run-tests.sh.
test:
name: test gate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# go.mod `replace`s wireguard-go to ./submodules/wireguard-go, so without
# this even `go list` fails. Same step/reason as in build-apk below.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
# Same cache key as build-apk: this job runs first, so it warms the module
# + build cache the SDK-lane build then restores. (v3.3.2 pin: see header.)
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
# Node 24, NOT the 20 build-apk uses for the SPA: panel's tests are
# TypeScript run directly by `node --test`, and type stripping only exists
# from 22.6 — on node 20 `npm test` dies before running a single case.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Panel tests
run: bash scripts/run-panel-tests.sh
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
# ---------------------------------------------------------------------------
# Build the 4 packages through the ImmortalWrt 25.12 apk-SDK for the 25.12/apk
# fleet (BPI-R3 mini on BananaWRT 25.12-mtk-vendor, BPI-R4 on OpenWrt 25.12,
# and the testbed VM). Produces a per-arch apk repo dir: *.apk + EC-signed
# packages.adb + shater-apk.pem, uploaded as the artifact `apkfeed-<arch>`.
build-apk:
name: apk ${{ matrix.arch }}
# THE GATE EDGE. A red test skips this job, which leaves no artifact, which
# (with the guards in release-apk) leaves nothing published.
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# ImmortalWrt 25.12.1 official SDK tarballs (no immortalwrt/sdk docker
# tag exists for mediatek-filogic 25.12 — see ci/build-feed-apk.sh).
- arch: x86_64 # testbed VM
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/x86/64/immortalwrt-sdk-25.12.1-x86-64_gcc-14.3.0_musl.Linux-x86_64.tar.zst
- arch: aarch64_cortex-a53 # BPI-R3 mini (BananaWRT 25.12-mtk-vendor) + BPI-R4
sdk_url: https://downloads.immortalwrt.org/releases/25.12.1/targets/mediatek/filogic/immortalwrt-sdk-25.12.1-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
steps:
# fetch-depth: 0 — the package version is DERIVED from the git tag
# (ci/version.sh: nearest `vX.Y.Z` + commits since it). The default
# shallow checkout has neither tags nor ancestry, so `git describe` would
# fail and every dispatch build would fall back to 0.0.0.
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# scripts/build-shaterd.sh builds the AmneziaWG-patched wireguard-go via a
# go.mod `replace => ./submodules/wireguard-go`, so that submodule must be
# present. actions/checkout does not fetch submodules by default; init ONLY
# this one (the clients/apple+android submodules are large and unneeded).
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
# THE version step (bug B4). One computation, used by both the binary
# (constant.Version) and the three tag-versioned packages, exported to
# every later step of this job:
# tag vX.Y.Z -> X.Y.Z-r1 ; off-tag -> <last tag>-r<commits+1>
- name: Compute version from git tag
run: bash ci/version.sh --env >> "$GITHUB_ENV"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# ---- caches (see the header comment for keys + version pin rationale) ----
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
- name: Cache panel node_modules
id: npm-cache
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Cache SDK dl/ (package sources)
uses: actions/cache@v3.3.2
with:
path: .cache/dl
key: dl-${{ hashFiles('openwrt/*/Makefile') }}
restore-keys: |
dl-
- name: Cache apt archives (bookworm host-deps)
uses: actions/cache@v3.3.2
with:
path: .cache/apt
key: apt-bookworm-${{ hashFiles('ci/sdk-build-apk.sh') }}
restore-keys: |
apt-bookworm-
# The ~300 MB SDK tarball was re-downloaded EVERY run from
# downloads.immortalwrt.org, which flakes/stalls (>40 min hangs). Cache it
# by tarball basename (exact key: an SDK version bump = clean miss); on a
# cold miss ci/fetch-sdk.sh falls back to our own `sdk-cache` release-asset
# mirror, then to upstream with stall-kill + retries (and re-seeds the
# mirror) — so even with a dead cache server this never wedges the run.
- name: Compute SDK cache key
id: sdkkey
run: |
echo "tarball=$(basename '${{ matrix.sdk_url }}')" >> "$GITHUB_OUTPUT"
echo "relver=$(echo '${{ matrix.sdk_url }}' | sed -n 's#.*/releases/\([^/]*\)/.*#\1#p')" >> "$GITHUB_OUTPUT"
- name: Cache ImmortalWrt SDK tarball
uses: actions/cache@v3.3.2
with:
path: .cache/sdk
key: sdk-${{ steps.sdkkey.outputs.tarball }}
# feeds git checkouts (see header) — one entry shared by both apk arch
# jobs of one ImmortalWrt release (identical feeds.conf.default pins).
- name: Cache SDK feeds checkouts
uses: actions/cache@v3.3.2
with:
path: .cache/feeds
key: feeds-apk-${{ steps.sdkkey.outputs.relver }}
restore-keys: |
feeds-apk-
# D23 — the shipped tag set is a TRIMMED subset (scripts/router-tags.sh);
# everything else in CI builds with the full upstream set, so without this
# step the one combination we actually ship is never exercised. That is how
# `with_gvisor` was trimmed while `with_wireguard` stayed and every shipped
# binary answered a WireGuard node with "gVisor is not included in this
# build" (2026-07-25). The check runs the declared-feature/tag comparison
# and then constructs one node of every declared protocol through box.New
# UNDER THE SHIPPED TAGS. It runs before the artifact build so a tag trim
# that breaks a feature fails the release instead of shipping.
- name: Verify the shipped build-tag set (D23)
run: bash scripts/check-router-tags.sh
- name: Install UPX
run: sudo apt-get update -qq && sudo apt-get install -y -qq upx-ucl
# Artifact-order contract: the SPA-embedded shaterd binary is built OUT of
# the SDK and staged into openwrt/shaterd/files/ BEFORE the package build
# (the openwrt/shaterd package only installs the staged artifact).
# $SHATER_VERSION (from the version step above) is stamped into
# constant.Version, so the binary and the package agree. On an exact
# node_modules cache hit, --fast skips the redundant `npm ci`.
- name: Build & stage shaterd artifact
env:
NPM_CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }}
run: |
set -eu
FAST=""
if [ "${NPM_CACHE_HIT:-}" = "true" ]; then FAST="--fast"; fi
echo "shaterd version: $SHATER_VERSION / package ${SHATER_PKG_VERSION}-r${SHATER_PKG_RELEASE} (npm cache hit: ${NPM_CACHE_HIT:-false})"
bash scripts/build-shaterd.sh $FAST
# Compile the 4 packages as .apk through the ImmortalWrt 25.12 SDK and
# sign the per-arch packages.adb with the EC key (secret KEY_APK).
# MIRROR_TOKEN lets ci/fetch-sdk.sh seed the `sdk-cache` mirror release
# after a (rare) upstream download — best-effort, never fails the build.
- name: Build signed apk feed (ImmortalWrt 25.12 SDK)
env:
KEY_APK: ${{ secrets.KEY_APK }}
MIRROR_TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
run: bash ci/build-feed-apk.sh "${{ matrix.arch }}" "${{ matrix.sdk_url }}" "out-apk/${{ matrix.arch }}"
- name: Show apk feed
run: ls -l "out-apk/${{ matrix.arch }}"
- name: Upload apk feed artifact
uses: actions/upload-artifact@v3
with:
name: apkfeed-${{ matrix.arch }}
path: out-apk/${{ matrix.arch }}/*
if-no-files-found: error
# ---------------------------------------------------------------------------
# Publish: ONE release PER ARCH (apk package filenames carry no arch, and apk
# fetches `<name>-<ver>.apk` relative to the packages.adb URL — a flat
# multi-arch release would collide). Every run refreshes the ROLLING pointer
# `apk-latest-<arch>`; a `vX.Y.Z` tag run ALSO publishes the pinnable
# `apk-vX.Y.Z-<arch>`. The tags do NOT match the workflow's `v*` trigger, so
# publishing them cannot re-trigger the build.
#
# WHY THE ROLLING RELEASE IS PUBLISHED ON TAG RUNS TOO (fixed 2026-07-25):
# it used to be an either/or — `TAG=apk-latest-<arch>` on dispatch, ELSE
# `TAG=apk-<ver>-<arch>` — so once releases moved to tag pushes the rolling
# pointer was never written again. It froze at 0.2.0 (published 2026-07-24)
# while v0.2.9/v0.2.10 published fine, and every router whose
# /etc/apk/repositories.d/shater.list points at the rolling URL kept getting a
# successful, silent `apk update` with nothing new. Rolling is the whole point
# of that URL, so it is now written unconditionally and asserted afterwards.
release-apk:
name: release apk
needs: [test, build-apk]
# Publish whatever arch feeds succeeded — do NOT block the aarch64 release
# when an unrelated arch (e.g. x86_64) fails. download-artifact only fetches
# artifacts that exist, and the publish loop skips missing apkfeed-* dirs.
#
# `needs.test.result == 'success'` is the second half of the gate. Without
# it, `!cancelled()` is true when the test job FAILS (build-apk is then
# skipped), this job runs with no artifacts at all, and — see the guard at
# the end of the publish step — used to exit 0 having published nothing. Red
# tests must SKIP this job, not "succeed" through it.
if: ${{ !cancelled() && needs.test.result == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all arch apk feeds
uses: actions/download-artifact@v3
with:
path: artifacts
# Identity of the VERSIONED release only. The rolling pointer is published
# on every run with fixed prerelease=true/rolling=true, so it needs nothing
# from here.
- name: Determine release identity
id: rel
run: |
set -eu
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
echo "ver=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
echo "rolling=false" >> "$GITHUB_OUTPUT"
else
echo "ver=latest" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
echo "rolling=true" >> "$GITHUB_OUTPUT"
fi
- name: Publish per-arch apk releases
env:
TOKEN: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }}
VER: ${{ steps.rel.outputs.ver }}
PRERELEASE: ${{ steps.rel.outputs.prerelease }}
ROLLING: ${{ steps.rel.outputs.rolling }}
run: |
set -euo pipefail
# Counted, and asserted non-zero at the end. Until 2026-07-26 this loop
# was the step's whole body: with no artifacts the glob stayed
# unexpanded, `[ -d ... ]` was false, `continue` ran once, the loop
# ended and the step exited 0 — "release apk" went GREEN having
# published absolutely nothing. Any upstream failure (all arches
# failing to build, an artifact-name change, a download-artifact
# hiccup) therefore looked like a successful release.
published=0
for d in artifacts/apkfeed-*; do
[ -d "$d" ] || continue
arch="${d#artifacts/apkfeed-}"
ROLL="apk-latest-$arch"
# The version we just built, read straight off the artifact
# (`shaterd-<ver>-r<rel>.apk`). NOT recomputed with ci/version.sh:
# this job checks out shallow, so it has no tags to describe from.
pkg=""
for a in "$d"/shaterd-*.apk; do
if [ -f "$a" ]; then pkg="$(basename "$a")"; fi
done
[ -n "$pkg" ] || { echo "[release-apk] ERROR: no shaterd-*.apk in $d"; exit 11; }
want="${pkg#shaterd-}"; want="${want%.apk}"
echo "[release-apk] arch=$arch built version=$want"
BODY="Automated apk (OpenWrt/ImmortalWrt 25.12+) package repo for \`$arch\`.
Packages: shaterd (per-arch), shater-core + luci-app-shater (arch=all).
This build: \`$want\`.
The index \`packages.adb\` is EC-signed; trust anchor \`shater-apk.pem\` (also in \`dist/\`).
── Add as an apk repository (rolling — install once, then just update) ──
wget -O /etc/apk/keys/shater-apk.pem https://git.qomar.pw/omar/shater/releases/download/$ROLL/shater-apk.pem
echo \"https://git.qomar.pw/omar/shater/releases/download/apk-latest-\$(cat /etc/apk/arch)/packages.adb\" > /etc/apk/repositories.d/shater.list
apk update
apk add luci-app-shater # pulls shater-core + shaterd too
\`apk-latest-<arch>\` is a MOVING pointer: every release run replaces its
assets, so the same repo line keeps serving the newest build. To pin a
version instead, point the repo line at
\`.../download/apk-vX.Y.Z-\$(cat /etc/apk/arch)/packages.adb\` — then the
file must be edited by hand for each upgrade.
── Update — ALWAYS name the packages, NEVER a bare \`apk upgrade\` ──
apk update
apk upgrade shaterd shater-core luci-app-shater
A bare \`apk upgrade\` reconciles EVERY installed package against every
configured repo and can downgrade unrelated system packages; naming them
upgrades only those (apk-tools 3: \"If list of packages is provided, only
those packages are upgraded along with needed dependencies\").
Full guide: docs-shater/INSTALL.md §5."
# 1) the pinnable versioned release (tag runs only)
if [ "$VER" != latest ]; then
echo "[release-apk] publishing apk-$VER-$arch from $d"
TAG="apk-$VER-$arch" NAME="shater apk $VER ($arch)" BODY="$BODY" \
PRERELEASE="$PRERELEASE" ROLLING="$ROLLING" \
bash ci/gitea-release.sh "$d"/*
fi
# 2) the rolling pointer — ALWAYS, tag run included. ci/gitea-release.sh
# deletes the existing release before recreating it, so the old
# version's assets are REPLACED, never accumulated (two versions of
# one package in one index would let apk choose, not us).
echo "[release-apk] publishing $ROLL from $d"
TAG="$ROLL" NAME="shater apk latest ($arch)" BODY="$BODY" \
PRERELEASE=true ROLLING=true \
bash ci/gitea-release.sh "$d"/*
# 3) ASSERT the rolling release really serves THIS build — same class
# of check as ci/sdk-build-apk.sh's package-version assert, and for
# the same reason: the previous failure mode was silent. Reads the
# published release back over the API and requires our three
# tag-versioned packages at $want, the index, the key — and NO
# left-over package asset at any other version.
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$ROLL"
got="$(curl -fsS -H "Authorization: token $TOKEN" "$api" \
| tr '{},' '\n\n\n' \
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sort -u)" || {
echo "[release-apk] ERROR: cannot read back $ROLL from the API"; exit 12; }
echo "[release-apk] $ROLL assets: $(printf '%s ' $got)"
# here-string, NOT `printf | grep -q`: under `pipefail` the early
# exit of grep -q can SIGPIPE the writer and fail a passing check.
for f in "shaterd-$want.apk" "shater-core-$want.apk" \
"luci-app-shater-$want.apk" packages.adb shater-apk.pem; do
grep -qxF "$f" <<<"$got" || {
echo "[release-apk] ERROR: $ROLL does not contain '$f' after publish."
echo " A router pinned to the rolling URL would have silently"
echo " stayed on its old version with a successful apk update."
exit 13; }
done
stale="$(grep -E '^(shaterd|shater-core|luci-app-shater)-.*\.apk$' <<<"$got" \
| grep -vxF -e "shaterd-$want.apk" -e "shater-core-$want.apk" \
-e "luci-app-shater-$want.apk" || true)"
[ -z "$stale" ] || {
echo "[release-apk] ERROR: $ROLL still holds stale package assets:"
printf ' %s\n' $stale
echo " Two versions of one package in one feed = apk picks by its"
echo " own rules, not by our intent."
exit 14; }
echo "[release-apk] OK — $ROLL serves $want"
published=$((published + 1))
done
# The assert the loop above never had. Zero feeds published is a failed
# release, not a quiet success — say so with a non-zero exit.
if [ "$published" -eq 0 ]; then
echo "[release-apk] ERROR: no apkfeed-* artifact reached this job, so"
echo " NOTHING was published. Downloaded tree:"
ls -la artifacts 2>&1 | sed 's/^/ /' || echo " (no artifacts/ dir at all)"
exit 10
fi
echo "[release-apk] published $published arch feed(s)"
+85
View File
@@ -0,0 +1,85 @@
# Shater — the test gate, on every push to `main`.
#
# WHY THIS FILE EXISTS (2026-07-26)
# The fork had a full suite and no CI that ran it. Upstream's
# .github/workflows/test.yml triggers on `stable`/`testing`/`unstable`; this
# repo only has `main`. And Gitea does not read .github/workflows AT ALL once
# .gitea/workflows exists — so those files are decoration here. Result: 115 of
# the 116 test files under shater/** had never once executed in CI, and
# TestDNSFilterRemoteBlocklistHTTPClient stayed red across two published
# releases.
#
# RELATIONSHIP TO release.yml
# This workflow is the FAST FEEDBACK loop on `main`. It is NOT the release
# gate: a separate workflow cannot block another one. The gate is the `test`
# JOB inside .gitea/workflows/release.yml, which build-apk `needs:` — see the
# comment there. Both run the very same scripts/run-tests.sh, so they cannot
# drift apart.
name: test
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs-shater/**'
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: go + panel tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# go.mod has `replace github.com/sagernet/wireguard-go => ./submodules/
# wireguard-go`, so WITHOUT this every `go list`/`go test` fails before it
# starts. Same step, same reason, as in release.yml's build job.
- name: Init wireguard-go submodule (awg)
run: git submodule update --init --depth 1 submodules/wireguard-go
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # explicit actions/cache@v3.3.2 below
# v3.3.2 is the last release speaking the cache API act_runner implements
# (see the header of release.yml). Same key as the release build job, so
# whichever runs first warms the other.
- name: Cache Go modules + build cache
uses: actions/cache@v3.3.2
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: |
go-
# Node 24, NOT the 20 the SPA build uses: panel's tests are TypeScript run
# through `node --test`, and type stripping only exists from 22.6. On
# node 20 `npm test` dies with a syntax error before running anything.
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Cache panel node_modules
uses: actions/cache@v3.3.2
with:
path: panel/node_modules
key: npm-${{ hashFiles('panel/package-lock.json') }}
- name: Panel tests
run: bash scripts/run-panel-tests.sh
- name: Go tests (shipped tags, linux, + race)
run: bash scripts/run-tests.sh
+1
View File
@@ -0,0 +1 @@
98d539ce67568fb911654e66a14cf4247ed833ec
+1
View File
@@ -0,0 +1 @@
github: nekohasekai
+88
View File
@@ -0,0 +1,88 @@
name: Bug report
description: "Report sing-box bug"
body:
- type: dropdown
attributes:
label: Operating system
description: Operating system type
options:
- iOS
- macOS
- Apple tvOS
- Android
- Windows
- Linux
- Others
validations:
required: true
- type: input
attributes:
label: System version
description: Please provide the operating system version
validations:
required: true
- type: dropdown
attributes:
label: Installation type
description: Please provide the sing-box installation type
options:
- Original sing-box Command Line
- sing-box for iOS Graphical Client
- sing-box for macOS Graphical Client
- sing-box for Apple tvOS Graphical Client
- sing-box for Android Graphical Client
- Third-party graphical clients that advertise themselves as using sing-box (Windows)
- Third-party graphical clients that advertise themselves as using sing-box (Android)
- Others
validations:
required: true
- type: input
attributes:
description: Graphical client version
label: If you are using a graphical client, please provide the version of the client.
- type: textarea
attributes:
label: Version
description: If you are using the original command line program, please provide the output of the `sing-box version` command.
render: shell
- type: textarea
attributes:
label: Description
description: Please provide a detailed description of the error.
validations:
required: true
- type: textarea
attributes:
label: Reproduction
description: Please provide the steps to reproduce the error, including the configuration files and procedures that can locally (not dependent on the remote server) reproduce the error using the original command line program of sing-box.
validations:
required: true
- type: textarea
attributes:
label: Logs
description: |-
In addition, if you encounter a crash with the graphical client, please also provide crash logs.
For Apple platform clients, please check `Settings - View Service Log` for crash logs.
For the Android client, please check the `/sdcard/Android/data/io.nekohasekai.sfa/files/stderr.log` file for crash logs.
render: shell
- type: checkboxes
id: supporter
attributes:
label: Supporter
options:
- label: I am a [sponsor](https://github.com/sponsors/nekohasekai/)
- type: checkboxes
attributes:
label: Integrity requirements
description: |-
Please check all of the following options to prove that you have read and understood the requirements, otherwise this issue will be closed.
Sing-box is not a project aimed to please users who can't make any meaningful contributions and gain unethical influence. If you deceive here to deliberately waste the time of the developers, you will be permanently blocked.
options:
- label: I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
required: true
- label: I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
required: true
- label: I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
required: true
- label: I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.
required: true
+88
View File
@@ -0,0 +1,88 @@
name: 错误反馈
description: "提交 sing-box 漏洞"
body:
- type: dropdown
attributes:
label: 操作系统
description: 请提供操作系统类型
options:
- iOS
- macOS
- Apple tvOS
- Android
- Windows
- Linux
- 其他
validations:
required: true
- type: input
attributes:
label: 系统版本
description: 请提供操作系统版本
validations:
required: true
- type: dropdown
attributes:
label: 安装类型
description: 请提供该 sing-box 安装类型
options:
- sing-box 原始命令行程序
- sing-box for iOS 图形客户端程序
- sing-box for macOS 图形客户端程序
- sing-box for Apple tvOS 图形客户端程序
- sing-box for Android 图形客户端程序
- 宣传使用 sing-box 的第三方图形客户端程序 (Windows)
- 宣传使用 sing-box 的第三方图形客户端程序 (Android)
- 其他
validations:
required: true
- type: input
attributes:
description: 图形客户端版本
label: 如果您使用图形客户端程序,请提供该程序版本。
- type: textarea
attributes:
label: 版本
description: 如果您使用原始命令行程序,请提供 `sing-box version` 命令的输出。
render: shell
- type: textarea
attributes:
label: 描述
description: 请提供错误的详细描述。
validations:
required: true
- type: textarea
attributes:
label: 重现方式
description: 请提供重现错误的步骤,必须包括可以在本地(不依赖与远程服务器)使用 sing-box 原始命令行程序重现错误的配置文件与流程。
validations:
required: true
- type: textarea
attributes:
label: 日志
description: |-
此外,如果您遭遇图形界面应用程序崩溃,请附加提供崩溃日志。
对于 Apple 平台图形客户端程序,请检查 `Settings - View Service Log` 以导出崩溃日志。
对于 Android 图形客户端程序,请检查 `/sdcard/Android/data/io.nekohasekai.sfa/files/stderr.log` 文件以导出崩溃日志。
render: shell
- type: checkboxes
id: supporter
attributes:
label: 支持我们
options:
- label: 我已经 [赞助](https://github.com/sponsors/nekohasekai/)
- type: checkboxes
attributes:
label: 完整性要求
description: |-
请勾选以下所有选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。
sing-box 不是讨好无法作出任何意义上的贡献的最终用户并获取非道德影响力的项目,如果您在此处欺骗以故意浪费开发者的时间,您将被永久封锁。
options:
- label: 我保证阅读了文档,了解所有我编写的配置文件项的含义,而不是大量堆砌看似有用的选项或默认值。
required: true
- label: 我保证提供了可以在本地重现该问题的服务器、客户端配置文件与流程,而不是一个脱敏的复杂客户端配置文件。
required: true
- label: 我保证提供了可用于重现我报告的错误的最简配置,而不是依赖远程服务器、TUN、图形界面客户端或者其他闭源软件。
required: true
- label: 我保证提供了完整的配置文件与日志,而不是出于对自身智力的自信而仅提供了部分认为有用的部分。
required: true
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -e -o pipefail
prepare_apk_root() {
# apk mkpkg resolves owner/group names through --root/etc/{passwd,group}.
APK_ROOT_DIR=$(mktemp -d)
mkdir -p "$APK_ROOT_DIR/etc"
cat > "$APK_ROOT_DIR/etc/passwd" <<EOF
root:x:$(id -u):$(id -g):root:/root:/sbin/nologin
EOF
cat > "$APK_ROOT_DIR/etc/group" <<EOF
root:x:$(id -g):root
EOF
}
ARCHITECTURE="$1"
VERSION="$2"
BINARY_PATH="$3"
OUTPUT_PATH="$4"
if [ -z "$ARCHITECTURE" ] || [ -z "$VERSION" ] || [ -z "$BINARY_PATH" ] || [ -z "$OUTPUT_PATH" ]; then
echo "Usage: $0 <architecture> <version> <binary_path> <output_path>"
exit 1
fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d)
prepare_apk_root
trap 'rm -rf "$ROOT_DIR" "$APK_ROOT_DIR"' EXIT
# Binary
install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box"
# Config files
install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json"
install -Dm755 "$PROJECT/release/config/sing-box.initd" "$ROOT_DIR/etc/init.d/sing-box"
install -Dm644 "$PROJECT/release/config/sing-box.confd" "$ROOT_DIR/etc/conf.d/sing-box"
# Service files
install -Dm644 "$PROJECT/release/config/sing-box.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box.service"
install -Dm644 "$PROJECT/release/config/sing-box@.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box@.service"
# Completions
install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash"
install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish"
install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box"
# License
install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE"
# APK metadata
PACKAGES_DIR="$ROOT_DIR/lib/apk/packages"
mkdir -p "$PACKAGES_DIR"
# .conffiles
cat > "$PACKAGES_DIR/.conffiles" <<'EOF'
/etc/conf.d/sing-box
/etc/init.d/sing-box
/etc/sing-box/config.json
EOF
# .conffiles_static (sha256 checksums)
while IFS= read -r conffile; do
sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1)
echo "$conffile $sha256"
done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# .list (all files, excluding lib/apk/packages/ metadata)
(cd "$ROOT_DIR" && find . -type f -o -type l) \
| sed 's|^\./|/|' \
| grep -v '^/lib/apk/packages/' \
| sort > "$PACKAGES_DIR/.list"
# Build APK
apk --root "$APK_ROOT_DIR" mkpkg \
--info "name:sing-box" \
--info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \
--info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later with name use or association addition" \
--info "origin:sing-box" \
--info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--files "$ROOT_DIR" \
--output "$OUTPUT_PATH"
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -e -o pipefail
prepare_apk_root() {
# apk mkpkg resolves owner/group names through --root/etc/{passwd,group}.
APK_ROOT_DIR=$(mktemp -d)
mkdir -p "$APK_ROOT_DIR/etc"
cat > "$APK_ROOT_DIR/etc/passwd" <<EOF
root:x:$(id -u):$(id -g):root:/root:/sbin/nologin
EOF
cat > "$APK_ROOT_DIR/etc/group" <<EOF
root:x:$(id -g):root
EOF
}
ARCHITECTURE="$1"
VERSION="$2"
BINARY_PATH="$3"
OUTPUT_PATH="$4"
if [ -z "$ARCHITECTURE" ] || [ -z "$VERSION" ] || [ -z "$BINARY_PATH" ] || [ -z "$OUTPUT_PATH" ]; then
echo "Usage: $0 <architecture> <version> <binary_path> <output_path>"
exit 1
fi
PROJECT=$(cd "$(dirname "$0")/.."; pwd)
# Convert version to APK format:
# 1.13.0-beta.8 -> 1.13.0_beta8-r0
# 1.13.0-rc.3 -> 1.13.0_rc3-r0
# 1.13.0 -> 1.13.0-r0
APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/')
APK_VERSION="${APK_VERSION}-r0"
ROOT_DIR=$(mktemp -d)
prepare_apk_root
trap 'rm -rf "$ROOT_DIR" "$APK_ROOT_DIR"' EXIT
# Binary
install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box"
# Config files
install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json"
install -Dm644 "$PROJECT/release/config/openwrt.conf" "$ROOT_DIR/etc/config/sing-box"
install -Dm755 "$PROJECT/release/config/openwrt.init" "$ROOT_DIR/etc/init.d/sing-box"
install -Dm644 "$PROJECT/release/config/openwrt.keep" "$ROOT_DIR/lib/upgrade/keep.d/sing-box"
# Completions
install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash"
install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish"
install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box"
# License
install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE"
# APK metadata
PACKAGES_DIR="$ROOT_DIR/lib/apk/packages"
mkdir -p "$PACKAGES_DIR"
# .conffiles
cat > "$PACKAGES_DIR/.conffiles" <<'EOF'
/etc/config/sing-box
/etc/sing-box/config.json
EOF
# .conffiles_static (sha256 checksums)
while IFS= read -r conffile; do
sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1)
echo "$conffile $sha256"
done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static"
# .list (all files, excluding lib/apk/packages/ metadata)
(cd "$ROOT_DIR" && find . -type f -o -type l) \
| sed 's|^\./|/|' \
| grep -v '^/lib/apk/packages/' \
| sort > "$PACKAGES_DIR/.list"
# Build APK
apk --root "$APK_ROOT_DIR" mkpkg \
--info "name:sing-box" \
--info "version:${APK_VERSION}" \
--info "description:The universal proxy platform." \
--info "arch:${ARCHITECTURE}" \
--info "license:GPL-3.0-or-later" \
--info "origin:sing-box" \
--info "url:https://sing-box.sagernet.org/" \
--info "maintainer:nekohasekai <contact-git@sekai.icu>" \
--info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \
--info "provider-priority:100" \
--script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \
--files "$ROOT_DIR" \
--output "$OUTPUT_PATH"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# mod from https://gist.github.com/pldubouilh/c5703052986bfdd404005951dee54683
set -euo pipefail
ARCH=$1
DEB_SRC=$2
OUT_IPK=$3
PROJECT=$(dirname "$0")/../..
TMP_PATH=$(mktemp -d)
trap 'rm -rf "$TMP_PATH"' EXIT
cp "$DEB_SRC" "$TMP_PATH"/
pushd "$TMP_PATH" >/dev/null
# Derive the name from the file we copied — do not glob-parse `ls *.deb`.
DEB_NAME=$(basename "$DEB_SRC")
ar x "$DEB_NAME"
mkdir control
pushd control >/dev/null
tar xf ../control.tar.gz
rm -f md5sums
sed "s/Architecture:\\ \w*/Architecture:\\ $ARCH/g" ./control -i
cat control
tar czf ../control.tar.gz ./*
popd >/dev/null
DEB_NAME=${DEB_NAME%.deb}
tar czf "$DEB_NAME.ipk" control.tar.gz data.tar.gz debian-binary
popd >/dev/null
cp "$TMP_PATH/$DEB_NAME.ipk" "$OUT_IPK"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
branches=$(git branch -r --contains HEAD)
if echo "$branches" | grep -q 'origin/stable'; then
track=stable
elif echo "$branches" | grep -q 'origin/testing'; then
track=testing
elif echo "$branches" | grep -q 'origin/oldstable'; then
track=oldstable
else
echo "ERROR: HEAD is not on any known release branch (stable/testing/oldstable)" >&2
exit 1
fi
if [[ "$track" == "stable" ]]; then
tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true)
if [[ -n "$tag" && "$tag" == *"-"* ]]; then
track=beta
fi
fi
case "$track" in
stable) name=sing-box; docker_tag=latest ;;
beta) name=sing-box-beta; docker_tag=latest-beta ;;
testing) name=sing-box-testing; docker_tag=latest-testing ;;
oldstable) name=sing-box-oldstable; docker_tag=latest-oldstable ;;
esac
echo "track=${track} name=${name} docker_tag=${docker_tag}" >&2
echo "TRACK=${track}" >> "$GITHUB_ENV"
echo "NAME=${name}" >> "$GITHUB_ENV"
echo "DOCKER_TAG=${docker_tag}" >> "$GITHUB_ENV"
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"commitMessagePrefix": "[dependencies]",
"extends": [
"config:base",
":disableRateLimiting"
],
"baseBranches": [
"unstable"
],
"golang": {
"enabled": false
},
"packageRules": [
{
"matchManagers": [
"github-actions"
],
"groupName": "github-actions"
},
{
"matchManagers": [
"dockerfile"
],
"groupName": "Dockerfile"
}
]
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.25.11"
PATCH_COMMITS=(
"afe69d3cec1c6dcf0f1797b20546795730850070"
"1ed289b0cf87dc5aae9c6fe1aa5f200a83412938"
)
CURL_ARGS=(
-fL
--silent
--show-error
)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
mkdir -p "$HOME/go"
cd "$HOME/go"
wget "https://dl.google.com/go/go${VERSION}.darwin-arm64.tar.gz"
tar -xzf "go${VERSION}.darwin-arm64.tar.gz"
#cp -a go go_bootstrap
mv go go_osx
cd go_osx
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/SagerNet/go/commits/release-branch.go1.25/
# revert:
# 33d3f603c1: "cmd/link/internal/ld: use 12.0.0 OS/SDK versions for macOS linking"
# 937368f84e: "crypto/x509: change how we retrieve chains on darwin"
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/SagerNet/go/commit/${patch_commit}.diff" | patch --verbose -p 1
done
# Rebuild is not needed: we build with CGO_ENABLED=1, so Apple's external
# linker handles LC_BUILD_VERSION via MACOSX_DEPLOYMENT_TARGET, and the
# stdlib (crypto/x509) is compiled from patched src automatically.
#cd src
#GOROOT_BOOTSTRAP="$HOME/go/go_bootstrap" ./make.bash
#cd ../..
#rm -rf go_bootstrap "go${VERSION}.darwin-arm64.tar.gz"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.25.11"
PATCH_COMMITS=(
"466f6c7a29bc098b0d4c987b803c779222894a11"
"1bdabae205052afe1dadb2ad6f1ba612cdbc532a"
"a90777dcf692dd2168577853ba743b4338721b06"
"f6bddda4e8ff58a957462a1a09562924d5f3d05c"
"bed309eff415bcb3c77dd4bc3277b682b89a388d"
"34b899c2fb39b092db4fa67c4417e41dc046be4b"
)
CURL_ARGS=(
-fL
--silent
--show-error
)
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
fi
mkdir -p "$HOME/go"
cd "$HOME/go"
wget "https://dl.google.com/go/go${VERSION}.linux-amd64.tar.gz"
tar -xzf "go${VERSION}.linux-amd64.tar.gz"
mv go go_win7
cd go_win7
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/
# revert:
# 693def151adff1af707d82d28f55dba81ceb08e1: "crypto/rand,runtime: switch RtlGenRandom for ProcessPrng"
# 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7"
# 48042aa09c2f878c4faa576948b07fe625c4707a: "syscall: remove Windows 7 console handle workaround"
# a17d959debdb04cd550016a3501dd09d50cd62e7: "runtime: always use LoadLibraryEx to load system libraries"
# fixes:
# bed309eff415bcb3c77dd4bc3277b682b89a388d: "Fix os.RemoveAll not working on Windows7"
# 34b899c2fb39b092db4fa67c4417e41dc046be4b: "Revert \"os: remove 5ms sleep on Windows in (*Process).Wait\""
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/MetaCubeX/go/commit/${patch_commit}.diff" | patch --verbose -p 1
done
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
PROJECTS=$(dirname "$0")/../..
function updateClient() {
pushd clients/$1
git fetch
git reset FETCH_HEAD --hard
popd
git add clients/$1
}
updateClient "apple"
updateClient "android"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e -o pipefail
SCRIPT_DIR=$(dirname "$0")
PROJECTS=$SCRIPT_DIR/../..
git -C $PROJECTS/cronet-go fetch origin main
git -C $PROJECTS/cronet-go fetch origin go
go get -x github.com/sagernet/cronet-go/all@$(git -C $PROJECTS/cronet-go rev-parse origin/go)
go get -x github.com/sagernet/cronet-go@$(git -C $PROJECTS/cronet-go rev-parse origin/go)
go mod tidy
git -C $PROJECTS/cronet-go rev-parse origin/go > "$SCRIPT_DIR/CRONET_GO_VERSION"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e -o pipefail
SCRIPT_DIR=$(dirname "$0")
PROJECTS=$SCRIPT_DIR/../..
git -C $PROJECTS/cronet-go fetch origin dev
git -C $PROJECTS/cronet-go fetch origin go_dev
go get -x github.com/sagernet/cronet-go/all@$(git -C $PROJECTS/cronet-go rev-parse origin/go_dev)
go get -x github.com/sagernet/cronet-go@$(git -C $PROJECTS/cronet-go rev-parse origin/go_dev)
go mod tidy
git -C $PROJECTS/cronet-go rev-parse origin/dev > "$SCRIPT_DIR/CRONET_GO_VERSION"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
PROJECTS=$(dirname "$0")/../..
go get -x github.com/sagernet/$1@$(git -C $PROJECTS/$1 rev-parse HEAD)
go mod tidy
+1011 -170
View File
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
name: Publish Docker Images
on:
#push:
# branches:
# - stable
# - testing
release:
types:
- published
workflow_dispatch:
inputs:
tag:
description: "The tag version you want to build"
env:
REGISTRY_IMAGE: ghcr.io/sagernet/sing-box
jobs:
build_binary:
name: Build binary
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
# Naive-enabled builds (musl)
- { arch: amd64, naive: true, docker_platform: "linux/amd64" }
- { arch: arm64, naive: true, docker_platform: "linux/arm64" }
- { arch: "386", naive: true, docker_platform: "linux/386" }
- { arch: arm, goarm: "7", naive: true, docker_platform: "linux/arm/v7" }
- { arch: mipsle, gomips: softfloat, naive: true, docker_platform: "linux/mipsle" }
- { arch: riscv64, naive: true, docker_platform: "linux/riscv64" }
- { arch: loong64, naive: true, docker_platform: "linux/loong64" }
# Non-naive builds
- { arch: arm, goarm: "6", docker_platform: "linux/arm/v6" }
- { arch: ppc64le, docker_platform: "linux/ppc64le" }
- { arch: s390x, docker_platform: "linux/s390x" }
steps:
- name: Get commit to build
id: ref
run: |-
if [[ -z "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.ref_name }}"
else
ref="${{ github.event.inputs.tag }}"
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.11
- name: Clone cronet-go
if: matrix.naive
run: |
set -xeuo pipefail
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-musl-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain
- name: Set version
run: |
set -xeuo pipefail
VERSION=$(go run ./cmd/internal/read_tag)
echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
- name: Set Chromium toolchain environment
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> $GITHUB_ENV
- name: Set build tags
run: |
set -xeuo pipefail
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="$(cat release/DEFAULT_BUILD_TAGS),with_musl"
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (naive)
if: matrix.naive
run: |
set -xeuo pipefail
go build -v -trimpath -o sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
- name: Build (non-naive)
if: ${{ ! matrix.naive }}
run: |
set -xeuo pipefail
go build -v -trimpath -o sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${VERSION}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
- name: Prepare artifact
run: |
platform=${{ matrix.docker_platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
# Rename binary to include arch info for Dockerfile.binary
BINARY_NAME="sing-box-${{ matrix.arch }}"
if [[ -n "${{ matrix.goarm }}" ]]; then
BINARY_NAME="${BINARY_NAME}v${{ matrix.goarm }}"
fi
mv sing-box "${BINARY_NAME}"
echo "BINARY_NAME=${BINARY_NAME}" >> $GITHUB_ENV
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: binary-${{ env.PLATFORM_PAIR }}
path: ${{ env.BINARY_NAME }}
if-no-files-found: error
retention-days: 1
build_docker:
name: Build Docker image
runs-on: ubuntu-latest
needs:
- build_binary
strategy:
fail-fast: true
matrix:
include:
- { platform: "linux/amd64" }
- { platform: "linux/arm/v6" }
- { platform: "linux/arm/v7" }
- { platform: "linux/arm64" }
- { platform: "linux/386" }
# mipsle: no base Docker image available for this platform
- { platform: "linux/ppc64le" }
- { platform: "linux/riscv64" }
- { platform: "linux/s390x" }
- { platform: "linux/loong64", base_image: "ghcr.io/loong64/alpine:edge" }
steps:
- name: Get commit to build
id: ref
run: |-
if [[ -z "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.ref_name }}"
else
ref="${{ github.event.inputs.tag }}"
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Download binary
uses: actions/download-artifact@v5
with:
name: binary-${{ env.PLATFORM_PAIR }}
path: .
- name: Prepare binary
run: |
# Find and make the binary executable
chmod +x sing-box-*
ls -la sing-box-*
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.platform }}
context: .
file: Dockerfile.binary
build-args: |
BASE_IMAGE=${{ matrix.base_image || 'alpine' }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
if: github.event_name != 'push'
runs-on: ubuntu-latest
needs:
- build_docker
steps:
- name: Get commit to build
id: ref
run: |-
if [[ -z "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.ref_name }}"
else
ref="${{ github.event.inputs.tag }}"
fi
echo "ref=$ref"
echo "ref=$ref" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: ${{ steps.ref.outputs.ref }}
fetch-depth: 0
- name: Detect track
run: bash .github/detect_track.sh
- name: Download digests
uses: actions/download-artifact@v5
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
if: github.event_name != 'push'
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t "${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}" \
-t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}" \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
if: github.event_name != 'push'
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}
+79
View File
@@ -0,0 +1,79 @@
name: Lint
on:
push:
branches:
- oldstable
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/lint.yml'
pull_request:
branches:
- oldstable
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
build:
name: Lint ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: windows
goarch: amd64
- goos: windows
goarch: '386'
- goos: windows
goarch: arm64
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: arm
- goos: linux
goarch: '386'
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
- goos: android
goarch: arm64
# - goos: freebsd
# goarch: amd64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25
- name: Cache go module
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
key: go-${{ hashFiles('**/go.sum') }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
with:
version: latest
args: --timeout=30m
install-mode: binary
verify: false
+243
View File
@@ -0,0 +1,243 @@
name: Build Linux Packages
on:
#push:
# branches:
# - stable
# - testing
workflow_dispatch:
inputs:
version:
description: "Version name"
required: true
type: string
release:
types:
- published
jobs:
calculate_version:
name: Calculate version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.outputs.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.11
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
echo "version=${{ inputs.version }}"
echo "version=${{ inputs.version }}" >> "$GITHUB_ENV"
- name: Calculate version
if: github.event_name != 'workflow_dispatch'
run: |-
go run -v ./cmd/internal/read_tag --ci --nightly
- name: Set outputs
id: outputs
run: |-
echo "version=$version" >> "$GITHUB_OUTPUT"
build:
name: Build binary
runs-on: ubuntu-latest
needs:
- calculate_version
strategy:
matrix:
include:
# Naive-enabled builds (musl)
- { os: linux, arch: amd64, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64 }
- { os: linux, arch: arm64, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64 }
- { os: linux, arch: "386", naive: true, debian: i386, rpm: i386 }
- { os: linux, arch: arm, goarm: "7", naive: true, debian: armhf, rpm: armv7hl, pacman: armv7hl }
- { os: linux, arch: mipsle, gomips: softfloat, naive: true, debian: mipsel, rpm: mipsel }
- { os: linux, arch: riscv64, naive: true, debian: riscv64, rpm: riscv64 }
- { os: linux, arch: loong64, naive: true, debian: loongarch64, rpm: loongarch64 }
# Non-naive builds (unsupported architectures)
- { os: linux, arch: arm, goarm: "6", debian: armel, rpm: armv6hl }
- { os: linux, arch: mips64le, debian: mips64el, rpm: mips64el }
- { os: linux, arch: s390x, debian: s390x, rpm: s390x }
- { os: linux, arch: ppc64le, debian: ppc64el, rpm: ppc64le }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ~1.25.11
- name: Clone cronet-go
if: matrix.naive
run: |
set -xeuo pipefail
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
if: matrix.naive
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
if: matrix.naive
id: cache-chromium-toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-${{ matrix.arch }}-musl-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium toolchain
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain
- name: Set Chromium toolchain environment
if: matrix.naive
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> $GITHUB_ENV
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
- name: Set build tags
run: |
set -xeuo pipefail
if [[ "${{ matrix.naive }}" == "true" ]]; then
TAGS="$(cat release/DEFAULT_BUILD_TAGS),with_musl"
else
TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Set shared ldflags
run: |
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}"
- name: Build (naive)
if: matrix.naive
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
GOMIPS64: ${{ matrix.gomips }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build (non-naive)
if: ${{ ! matrix.naive }}
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \
./cmd/sing-box
env:
CGO_ENABLED: "0"
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set mtime
run: |-
TZ=UTC touch -t '197001010000' dist/sing-box
- name: Detect track
run: bash .github/detect_track.sh
- name: Set version
run: |-
PKG_VERSION="${{ needs.calculate_version.outputs.version }}"
PKG_VERSION="${PKG_VERSION//-/\~}"
echo "PKG_VERSION=${PKG_VERSION}" >> "${GITHUB_ENV}"
- name: Package DEB
if: matrix.debian != ''
run: |
set -xeuo pipefail
sudo gem install fpm
sudo apt-get install -y debsigs
cp .fpm_systemd .fpm
fpm -t deb \
--name "${NAME}" \
-v "$PKG_VERSION" \
-p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.debian }}.deb" \
--architecture ${{ matrix.debian }} \
dist/sing-box=/usr/bin/sing-box
curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff'
sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff'
rm -rf $HOME/.gnupg
gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <<EOF
${{ secrets.GPG_KEY }}
EOF
debsigs --sign=origin -k ${{ secrets.GPG_KEY_ID }} --gpgopts '--pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}"' dist/*.deb
- name: Package RPM
if: matrix.rpm != ''
run: |-
set -xeuo pipefail
sudo gem install fpm
cp .fpm_systemd .fpm
fpm -t rpm \
--name "${NAME}" \
-v "$PKG_VERSION" \
-p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.rpm }}.rpm" \
--architecture ${{ matrix.rpm }} \
dist/sing-box=/usr/bin/sing-box
cat > $HOME/.rpmmacros <<EOF
%_gpg_name ${{ secrets.GPG_KEY_ID }}
%_gpg_sign_cmd_extra_args --pinentry-mode loopback --passphrase ${{ secrets.GPG_PASSPHRASE }}
EOF
gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <<EOF
${{ secrets.GPG_KEY }}
EOF
rpmsign --addsign dist/*.rpm
- name: Cleanup
run: rm dist/sing-box
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.legacy_go && '-legacy' || '' }}
path: "dist"
upload:
name: Upload builds
runs-on: ubuntu-latest
needs:
- calculate_version
- build
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV"
- name: Download builds
uses: actions/download-artifact@v5
with:
path: dist
merge-multiple: true
- name: Publish packages
if: github.event_name != 'push'
run: |-
ls dist | xargs -I {} curl -F "package=@dist/{}" https://${{ secrets.FURY_TOKEN }}@push.fury.io/sagernet/
+192
View File
@@ -0,0 +1,192 @@
# On-demand builder — builds any artifact this repo knows how to build, from ANY branch.
# Lives on the default branch (lx) so `gh workflow run` can find it; each job checks
# out whatever `branch` you pass, so lx source is never required for the build.
#
# gh workflow run lx-build.yml -f target=android-aar -f branch=<branch>
# gh workflow run lx-build.yml -f target=apple-xcframework -f branch=<branch>
# gh workflow run lx-build.yml -f target=binary -f branch=<branch>
# gh workflow run lx-build.yml -f target=linux-musl -f branch=<branch>
# gh workflow run lx-build.yml -f target=all -f branch=<branch>
#
# Mirrors the build jobs in lx-release.yml but is manual-only and uploads each
# result as a workflow artifact instead of cutting a release.
name: lx build (on-demand)
on:
workflow_dispatch:
inputs:
target:
description: "What to build"
required: true
type: choice
default: android-aar
options:
- android-aar
- apple-xcframework
- binary
- linux-musl
- all
branch:
description: "Branch/tag of this repo to build from"
required: true
default: lx
jobs:
# ---- Android AAR (libbox.aar + libbox-legacy.aar) -------------------------
android-aar:
name: android-aar (${{ inputs.branch }})
if: ${{ inputs.target == 'android-aar' || inputs.target == 'all' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with:
ndk-version: r28
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Ensure a describe-able tag
run: git tag -f "build-aar"
- name: Build (make lib_android)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Package
run: |
mkdir -p dist
cp libbox.aar dist/
cp libbox-legacy.aar dist/
- uses: actions/upload-artifact@v4
with:
name: android-aar-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
# ---- Apple xcframework (Libbox.xcframework) ------------------------------
apple-xcframework:
name: apple-xcframework (${{ inputs.branch }})
if: ${{ inputs.target == 'apple-xcframework' || inputs.target == 'all' }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Ensure a describe-able tag
run: git tag -f "build-apple"
- name: Build (make lib_apple)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_apple
- name: Package
run: |
mkdir -p dist
# xcframework is a directory bundle — zip it for the artifact.
ditto -c -k --sequesterRsrc --keepParent Libbox.xcframework dist/Libbox.xcframework.zip
- uses: actions/upload-artifact@v4
with:
name: apple-xcframework-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
# ---- Drop-in sing-box binary (host: linux/amd64) -------------------------
binary:
name: binary linux/amd64 (${{ inputs.branch }})
if: ${{ inputs.target == 'binary' || inputs.target == 'all' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Ensure a describe-able tag
run: git tag -f "build-binary"
- name: Build (Makefile.lx lx-build)
run: make -f Makefile.lx lx-build LX_OUTPUT=sing-box
- name: Package
run: |
mkdir -p dist
cp sing-box dist/sing-box-linux-amd64
- uses: actions/upload-artifact@v4
with:
name: binary-linux-amd64-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
# ---- Static musl router binary (linux/amd64) -----------------------------
# Mirrors build_linux_musl in lx-release.yml: cronet-go + Chromium musl
# toolchain, CGO_ENABLED=1, with_purego swapped to with_musl.
linux-musl:
name: linux-musl amd64 (${{ inputs.branch }})
if: ${{ inputs.target == 'linux-musl' || inputs.target == 'all' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Clone cronet-go (musl toolchain provider)
run: |
CRONET_REF="$(cat .github/CRONET_GO_VERSION 2>/dev/null || echo main)"
git clone --depth=1 --branch "$CRONET_REF" https://github.com/sagernet/cronet-go ~/cronet-go \
|| git clone --depth=1 https://github.com/sagernet/cronet-go ~/cronet-go
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Download Chromium musl toolchain
run: |
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/amd64 --libc=musl download-toolchain
go run ./cmd/build-naive --target=linux/amd64 --libc=musl env >> "$GITHUB_ENV"
- name: Ensure a describe-able tag
run: git tag -f "build-musl"
- name: Build (musl + naive, static)
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: "1"
run: |
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
go build -v -trimpath -tags "$TAGS" \
-ldflags "-X github.com/sagernet/sing-box/constant.Version=$(git describe --tags --always) -checklinkname=0 -s -w -buildid=" \
-o sing-box-musl-amd64 ./cmd/sing-box
if ldd sing-box-musl-amd64 2>&1 | grep -q 'libdl.so.2'; then
echo "FAIL: dynamic libdl reference present — not a static musl build"; exit 1
fi
- name: Package
run: |
mkdir -p dist
cp sing-box-musl-amd64 dist/
- uses: actions/upload-artifact@v4
with:
name: linux-musl-amd64-${{ inputs.branch }}
path: dist/*
if-no-files-found: error
+306
View File
@@ -0,0 +1,306 @@
name: lx-ci
# Fast per-commit gate for sing-box-lx. See docs-lx/lx-config.md and SPECS/004.
#
# Trigger policy (keep per-commit cost low — see SPECS/004 §2.2):
# * Doc-only commits (md/docs/SPECS/LICENSE) skip CI entirely (paths-ignore).
# * push / pull_request → only the cheap jobs:
# - `lint` : go vet (lx packages) + gofmt on lx-owned files
# - `build-check` : one native build (with_xhttp,with_awg) + sing-box check,
# plus a tagless baseline build for the negative check.
# * `cross` (6 platforms) and `android` (gomobile AAR) are HEAVY → they run only
# on a manual `workflow_dispatch` (Actions → lx-ci → Run workflow, or
# `gh workflow run lx-ci.yml --ref lx`). They are NEVER on push.
# * A release tag `v*-lx.*` builds all 6 desktop targets + both AARs and publishes
# them via lx-release.yml — so the full cross/AAR proof always runs before shipping.
# * Rapid successive pushes cancel superseded runs (concurrency).
on:
push:
branches: [lx]
paths-ignore: [ '**.md', 'docs/**', 'SPECS/**', 'LICENSE', '.gitignore' ]
pull_request:
branches: [lx]
paths-ignore: [ '**.md', 'docs/**', 'SPECS/**', 'LICENSE', '.gitignore' ]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: lx-ci-${{ github.ref }}
cancel-in-progress: true
env:
# Client feature set: upstream release/DEFAULT_BUILD_TAGS minus tailscale/ccm/ocm/acme
# (irrelevant to a VPN client), + with_purego for the CGO-free cross matrix. The two
# lx features (with_xhttp/with_awg) toggle on top. Mirrors Makefile.lx LX_TAGS minus
# the features. -checklinkname=0 is supplied by LX_LDFLAGS (jobs build via Makefile.lx).
BASE_TAGS: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0
jobs:
# ---- Cheap (runs on every push / PR) -------------------------------------
lint:
name: lint (vet + gofmt)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: go vet (lx packages, full tags)
run: |
# Two passes so -unsafeptr=false is scoped to the offenders only:
# upstream's TriggerDebugCrash/TriggerGoPanic (daemon/managed_service.go,
# experimental/libbox/debug.go — came in with the 1.14 merge) crash Go ON
# PURPOSE via *(*int)(unsafe.Pointer(uintptr(0)))=0, which trips vet's
# unsafeptr check. Those are upstream files we don't edit (CONSTITUTION
# §zero-diff), so only those two packages drop the analyzer; every other
# lx package keeps the full set.
go vet \
-tags "${BASE_TAGS},with_xhttp,with_awg,with_lx_command" \
./option/ ./constant/ ./transport/v2ray/ ./transport/v2rayxhttp/ \
./protocol/wireguard/ ./transport/wireguard/
go vet -unsafeptr=false \
-tags "${BASE_TAGS},with_xhttp,with_awg,with_lx_command" \
./daemon/ ./experimental/libbox/
- name: gofmt (lx-owned files only)
run: |
# Only our downstream files — never the whole upstream tree.
# Generated *.pb.go are excluded (machine output, not hand-edited).
files=$(git ls-files '*.go' | grep -E 'v2rayxhttp|_xhttp|_awg|_lx\.go|_command_lx' | grep -v '\.pb\.go' || true)
echo "checking:"; echo "$files"
if [ -n "$files" ]; then
bad=$(gofmt -l $files)
if [ -n "$bad" ]; then
echo "gofmt needs running on:"; echo "$bad"; exit 1
fi
fi
echo "OK: lx files are gofmt-clean"
build-check:
name: build-check (native build + check + negative)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # with_awg needs submodules/wireguard-go
fetch-depth: 0 # tags for the -lx version (git describe)
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Build (full lx — with_xhttp,with_awg,with_lx_command)
run: make -f Makefile.lx lx-build LX_TAGS="${BASE_TAGS},with_xhttp,with_awg,with_lx_command"
- name: Version
run: ./sing-box version
- name: Check feature configs (must pass)
run: |
./sing-box check -c lx-test/config/xhttp_reality.json
./sing-box check -c lx-test/config/awg2_basic.json
./sing-box check -c lx-test/config/awg2_ranged.json
- name: Build baseline (no lx features) for the negative check
run: make -f Makefile.lx lx-build LX_TAGS="${BASE_TAGS}" LX_OUTPUT=sing-box-baseline
- name: Baseline accepts a plain config
run: ./sing-box-baseline check -c lx-test/config/minimal.json
- name: Negative check (feature configs rejected without their tags)
run: |
set +e
./sing-box-baseline check -c lx-test/config/xhttp_reality.json; x=$?
./sing-box-baseline check -c lx-test/config/awg2_basic.json; a=$?
./sing-box-baseline check -c lx-test/config/awg2_ranged.json; r=$?
if [ $x -eq 0 ] || [ $a -eq 0 ] || [ $r -eq 0 ]; then
echo "FAIL: a feature config was accepted without its build tag"; exit 1
fi
echo "OK: xhttp/awg configs correctly rejected without their tags"
# SPEC 014 §3.6 pt.7 — prove both libbox command-protocol builds compile.
# The handler is gated by with_lx_command (real) / its absence (stub →
# codes.Unimplemented). Compile both daemon variants; the with-tag build must
# NOT carry the stub's "rebuild with -tags with_lx_command" string, the tagless
# build must. This is the cheap usbip-style proof that the seam toggles.
- name: lx_command — both builds compile (with + without the tag)
run: |
set -euo pipefail
go build -tags "${BASE_TAGS},with_lx_command" ./daemon/ ./experimental/libbox/
go build -tags "${BASE_TAGS}" ./daemon/ ./experimental/libbox/
echo "OK: daemon + libbox compile with and without with_lx_command"
- name: lx_command — stub string present in baseline, absent with the tag
run: |
set -euo pipefail
marker="rebuild with -tags with_lx_command"
if ! strings -a sing-box-baseline | grep -qF "$marker"; then
echo "FAIL: tagless baseline is missing the Unimplemented stub marker"; exit 1
fi
if strings -a sing-box | grep -qF "$marker"; then
echo "FAIL: full lx build still carries the Unimplemented stub marker"; exit 1
fi
echo "OK: with_lx_command toggles the handler (stub only in baseline)"
# ---- Heavy (manual workflow_dispatch only — never on push) ----------------
# Cross-platform: prove the merged AWG fork + XHTTP compile everywhere.
# On a release tag this is covered by lx-release.yml (all 6 desktop targets).
cross:
if: github.event_name == 'workflow_dispatch'
name: cross ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Cross-build (full lx)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
make -f Makefile.lx lx-build \
LX_TAGS="${BASE_TAGS},with_xhttp,with_awg" \
LX_OUTPUT="sing-box-${{ matrix.goos }}-${{ matrix.goarch }}"
- uses: actions/upload-artifact@v4
with:
name: sing-box-${{ matrix.goos }}-${{ matrix.goarch }}
path: sing-box-${{ matrix.goos }}-${{ matrix.goarch }}*
if-no-files-found: error
retention-days: 7
# Android libbox AAR: prove libbox builds with the lx features (gomobile).
# build_libbox bakes with_xhttp+with_awg into both AAR variants (see its lx: block).
# On a release tag this is covered by lx-release.yml (both AARs published).
android:
if: github.event_name == 'workflow_dispatch'
name: android libbox.aar
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with: { ndk-version: r28 }
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Build libbox.aar + libbox-legacy.aar
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- uses: actions/upload-artifact@v4
with:
name: libbox-aar
path: |
libbox.aar
libbox-legacy.aar
if-no-files-found: error
retention-days: 7
# Router musl builds: prove the static-musl + naive pipeline compiles and links
# statically (no libdl.so.2) for the router arches. Build + verify only, no
# publish — the release is lx-release.yml's build_linux_musl. Keep the toolchain
# steps here in sync with that job. See SPECS/006.
linux_musl:
if: github.event_name == 'workflow_dispatch'
name: linux-musl ${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Cache Chromium toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-musl-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }}
- name: Download Chromium musl toolchain
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain
- name: Set Chromium toolchain environment
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> "$GITHUB_ENV"
- name: Build (musl + naive, static)
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
run: |
set -xeuo pipefail
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
go build -v -trimpath -tags "$TAGS" \
-ldflags "-checklinkname=0 -s -w -buildid=" \
-o "sing-box-${{ matrix.asset }}" ./cmd/sing-box
- name: Verify static (no libdl)
run: |
set -xeuo pipefail
file "sing-box-${{ matrix.asset }}"
file "sing-box-${{ matrix.asset }}" | grep -q "statically linked"
if strings -a "sing-box-${{ matrix.asset }}" | grep -q "libdl.so.2"; then
echo "FAIL: libdl.so.2 reference present — not a static musl build"; exit 1
fi
echo "OK: statically linked, no libdl.so.2"
- uses: actions/upload-artifact@v4
with:
name: sing-box-${{ matrix.asset }}
path: sing-box-${{ matrix.asset }}
if-no-files-found: error
retention-days: 7
@@ -0,0 +1,117 @@
name: lx-musl-toolchain-mirror
# Builds the Chromium musl toolchain (clang + gn + pgo + per-arch Debian sysroot)
# once and uploads it as a release asset in the `musl-toolchain-cache` release, so
# lx-release.yml can restore it on an actions/cache miss instead of depending on
# snapshot.debian.org (which intermittently 503s and blocks releases). See SPEC 023.
#
# Run this MANUALLY (workflow_dispatch) whenever .github/CRONET_GO_VERSION changes —
# the asset name embeds the cronet-go version, so a stale mirror simply misses and
# the release falls back to snapshot.debian.org until this is re-run.
on:
workflow_dispatch:
permissions:
contents: write
jobs:
build_mirror:
name: mirror linux-musl/${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
# Same toolchain-fetch sequence as lx-release.yml build_linux_musl, so the
# produced tree matches exactly what the release job expects to restore.
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
n=0; max=4; delay=20
until GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "keyring regen failed after $max attempts"; exit 1
fi
echo "keyring regen attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Download Chromium musl toolchain (from snapshot.debian.org)
run: |
set -xeuo pipefail
cd ~/cronet-go
# This is the ONE place the mirror producer depends on snapshot.debian.org.
# Retry generously; run this workflow when snapshot.debian.org is healthy.
n=0; max=8; delay=30
until go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "toolchain download failed after $max attempts (snapshot.debian.org may be down — retry later)"; exit 1
fi
echo "toolchain download attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Pack toolchain
id: pack
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
ASSET="toolchain-${{ matrix.arch }}-${CRONET_GO_VERSION}.tar.zst"
SRC=~/cronet-go/naiveproxy/src
# Archive rooted at naiveproxy/src so lx-release.yml restores with a plain
# `tar -C <src> -xf`. Only pack dirs that exist (pgo_profiles may be absent).
DIRS=""
for d in third_party/llvm-build gn/out chrome/build/pgo_profiles out/sysroot-build; do
[ -d "$SRC/$d" ] && DIRS="$DIRS $d"
done
tar --zstd -C "$SRC" -cf "$ASSET" $DIRS
ls -lh "$ASSET"
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
- name: Ensure mirror release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
gh release view musl-toolchain-cache --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
gh release create musl-toolchain-cache --repo "$GITHUB_REPOSITORY" \
--prerelease --title "musl toolchain cache" \
--notes "Prebuilt Chromium musl toolchains for lx-release (SPEC 023). Not a software release — do not use as 'Latest'."
- name: Upload toolchain asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
gh release upload musl-toolchain-cache "${{ steps.pack.outputs.asset }}" \
--repo "$GITHUB_REPOSITORY" --clobber
+162
View File
@@ -0,0 +1,162 @@
name: lx-rebase
# Auto-rebase the lx feature commits onto the newest STABLE upstream sing-box tag.
# See SPECS/004 §2.3. Outcomes:
# * already based on the newest stable tag → no-op (logs "up to date").
# * clean rebase that still builds + `sing-box check` passes → push lx-rebase/<tag> + open a PR.
# * rebase conflict OR build/check fails → open an issue listing the // lx seams to fix.
# NEVER force-pushes `lx` — a human reviews the PR and updates `lx` themselves.
#
# Requirements:
# * Settings → Actions → General → Workflow permissions = "Read and write".
# * For the auto-PR step: enable "Allow GitHub Actions to create and approve pull requests".
# (If it's off, the workflow falls back to an issue pointing at the ready branch.)
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch:
inputs:
tag:
description: 'Upstream tag to rebase onto (blank = newest stable)'
required: false
default: ''
permissions:
contents: write
pull-requests: write
issues: write
env:
UPSTREAM: https://github.com/SagerNet/sing-box.git
jobs:
rebase:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: lx
fetch-depth: 0
submodules: recursive
- uses: actions/setup-go@v5
with: { go-version-file: go.mod, check-latest: true }
- name: Configure git + upstream
run: |
git config user.name "lx-rebase-bot"
git config user.email "247031499+Leadaxe@users.noreply.github.com"
git remote add upstream "$UPSTREAM" 2>/dev/null || git remote set-url upstream "$UPSTREAM"
git fetch --tags --quiet upstream
- name: Pick target tag
id: pick
run: |
INPUT="${{ github.event.inputs.tag }}"
if [ -n "$INPUT" ]; then
TARGET="$INPUT"
else
# newest STABLE upstream tag: vMAJOR.MINOR.PATCH only (excludes -alpha/-beta/-rc and our -lx.N)
TARGET=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
fi
if [ -z "$TARGET" ]; then echo "No stable upstream tag found"; exit 1; fi
if ! git rev-parse -q --verify "refs/tags/$TARGET" >/dev/null; then
echo "Tag $TARGET does not exist after fetch"; exit 1
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "Target upstream tag: $TARGET"
- name: Up to date?
id: check
run: |
TARGET="${{ steps.pick.outputs.target }}"
if git merge-base --is-ancestor "$TARGET" HEAD; then
echo "uptodate=true" >> "$GITHUB_OUTPUT"
echo "::notice::lx already contains $TARGET — nothing to rebase."
else
echo "uptodate=false" >> "$GITHUB_OUTPUT"
fi
- name: Attempt rebase
id: rebase
if: steps.check.outputs.uptodate == 'false'
run: |
TARGET="${{ steps.pick.outputs.target }}"
BRANCH="lx-rebase/${TARGET}"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
git checkout -b "$BRANCH"
if git rebase "$TARGET"; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "::notice::clean rebase onto $TARGET"
else
git diff --name-only --diff-filter=U > /tmp/conflicts.txt || true
git rebase --abort || true
echo "result=conflict" >> "$GITHUB_OUTPUT"
echo "::warning::rebase conflict onto $TARGET"
fi
- name: Build + check (clean rebase only)
id: verify
if: steps.rebase.outputs.result == 'clean'
run: |
git submodule update --init --recursive
if make -f Makefile.lx lx-build \
&& ./sing-box check -c lx-test/config/xhttp_reality.json \
&& ./sing-box check -c lx-test/config/awg2_basic.json; then
echo "build=pass" >> "$GITHUB_OUTPUT"
else
echo "build=fail" >> "$GITHUB_OUTPUT"
fi
- name: Push branch + open PR (clean + builds)
if: steps.rebase.outputs.result == 'clean' && steps.verify.outputs.build == 'pass'
env:
GH_TOKEN: ${{ github.token }}
run: |
TARGET="${{ steps.pick.outputs.target }}"
BRANCH="${{ steps.rebase.outputs.branch }}"
git push -f origin "$BRANCH"
cat > /tmp/pr.md <<EOF
Automated rebase of the lx feature commits onto upstream \`$TARGET\`.
- clean rebase (no conflicts)
- \`make -f Makefile.lx lx-build\` succeeds
- \`sing-box check\` passes for XHTTP + AWG2 sample configs
Review the \`// lx\` seams (\`grep -rn "// lx"\`), then update \`lx\` (rebase / fast-forward) **manually** — this workflow never force-pushes \`lx\`.
EOF
if gh pr create --base lx --head "$BRANCH" \
--title "lx-rebase: onto upstream $TARGET" --body-file /tmp/pr.md; then
echo "::notice::PR opened for $BRANCH"
else
# auto-PR likely blocked (the "Allow Actions to create PRs" toggle is off) — fall back to an issue
gh issue create --title "lx-rebase: branch ready for $TARGET (open PR manually)" \
--body "Branch \`$BRANCH\` is rebased onto \`$TARGET\`, builds, and passes \`check\`. Auto-PR was blocked — enable Settings → Actions → General → \"Allow GitHub Actions to create and approve pull requests\", or open the PR by hand."
fi
- name: Open issue (conflict or build failure)
if: steps.rebase.outputs.result == 'conflict' || steps.verify.outputs.build == 'fail'
env:
GH_TOKEN: ${{ github.token }}
run: |
TARGET="${{ steps.pick.outputs.target }}"
if [ "${{ steps.rebase.outputs.result }}" = "conflict" ]; then
REASON="rebase conflict"
else
REASON="build/check failed after a clean rebase (likely a semantic conflict)"
fi
{
echo "Automated rebase onto upstream \`$TARGET\` needs manual attention: **$REASON**."
echo
if [ -s /tmp/conflicts.txt ]; then
echo "Conflicted files:"; echo '```'; cat /tmp/conflicts.txt; echo '```'; echo
fi
echo "Resolve locally:"
echo '```'
echo "git fetch upstream --tags"
echo "git checkout -b lx-rebase/$TARGET lx && git rebase $TARGET"
echo "# fix the // lx: seams, then: git push origin lx-rebase/$TARGET and open a PR into lx"
echo '```'
} > /tmp/issue.md
gh issue create --title "lx-rebase: needs manual attention for $TARGET" --body-file /tmp/issue.md
+474
View File
@@ -0,0 +1,474 @@
name: lx-release
# Cross-builds the drop-in `sing-box` binary for all platforms and publishes a
# GitHub Release with archives + checksums. Triggered by pushing a tag like
# v1.13.13-lx.1, or manually via workflow_dispatch. See SPECS/004.
on:
push:
tags: ['v*-lx.*']
workflow_dispatch:
inputs:
tag:
description: 'Release tag, e.g. v1.13.13-lx.1 (created at the current ref if missing)'
required: true
permissions:
contents: write
# Build tags are owned by Makefile.lx (single source of truth). The desktop/CLI build
# uses its LX_TAGS default (which KEEPS with_clash_api — CLI binaries are driven by
# external dashboards over the Clash REST API); the Android AAR uses build_libbox's own
# tag set (which DROPS with_clash_api — LxBox uses the native CommandClient). The two
# sets diverge by design. `make -f Makefile.lx -s lx-print-tags` prints the desktop set
# for the release notes so nothing is duplicated here.
jobs:
build:
name: build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Linux lives in the build_linux_musl job (static musl + naive, for
# routers). See SPECS/006. This job covers the purego/native targets
# where libdl is a non-issue — plus targets cronet can't reach at all
# (no_naive: pure-Go static, naive/purego dropped).
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: windows, goarch: amd64, ext: .exe }
- { goos: windows, goarch: arm64, ext: .exe }
# Windows 7 (32-bit): built with a Win7-patched Go, and without
# with_naive_outbound (cronet-go has no windows/386 build). See SPECS/004.
- { goos: windows, goarch: "386", ext: .exe, legacy_win7: true, legacy_name: windows-7 }
# Big-endian MIPS routers (OpenWrt mips_24kc, e.g. Atheros AR93xx) — issue #6.
# Chromium has no big-endian MIPS toolchain, so the musl/naive path is
# impossible here; a pure-Go CGO_ENABLED=0 build is statically linked anyway
# (runs on musl), it just drops naive/cronet (with_purego doesn't compile on
# mips either — purego has no mips port).
- { goos: linux, goarch: mips, gomips: softfloat, no_naive: true }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
if: ${{ ! matrix.legacy_win7 }}
with:
go-version-file: go.mod
check-latest: true
# Win7 needs a Go toolchain that still targets Windows 7: setup_go_for_windows7.sh
# fetches stock Go and applies MetaCubeX/go patches reverting the Win7 removals.
- name: Cache Win7 Go toolchain
if: matrix.legacy_win7
id: cache-go-win7
uses: actions/cache@v4
with:
path: ~/go/go_win7
key: go_win7_${{ hashFiles('.github/setup_go_for_windows7.sh') }}
- name: Build Win7 Go toolchain
if: matrix.legacy_win7 && steps.cache-go-win7.outputs.cache-hit != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash .github/setup_go_for_windows7.sh
- name: Use Win7 Go toolchain
if: matrix.legacy_win7
run: |
echo "PATH=$HOME/go/go_win7/bin:$PATH" >> "$GITHUB_ENV"
echo "GOROOT=$HOME/go/go_win7" >> "$GITHUB_ENV"
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOMIPS: ${{ matrix.gomips }}
run: |
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
if [ "${{ matrix.legacy_win7 }}" = "true" ]; then
# cronet-go (with_naive_outbound) has no windows/386 build — drop it.
TAGS="${TAGS/with_naive_outbound,/}"
fi
if [ "${{ matrix.no_naive }}" = "true" ]; then
# No cronet for this target at all: drop naive AND its purego loader.
TAGS="${TAGS/with_naive_outbound,/}"
TAGS="${TAGS/with_purego,/}"
fi
make -f Makefile.lx lx-build \
LX_TAGS="$TAGS" \
LX_VERSION="${{ steps.ver.outputs.version }}" \
LX_OUTPUT="sing-box${{ matrix.ext }}"
- name: Package
run: |
NAME="sing-box-${{ steps.ver.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}"
if [ -n "${{ matrix.gomips }}" ]; then
NAME="${NAME}-${{ matrix.gomips }}"
fi
if [ -n "${{ matrix.legacy_name }}" ]; then
NAME="${NAME}-legacy-${{ matrix.legacy_name }}"
fi
mkdir -p "stage/$NAME" dist
cp "sing-box${{ matrix.ext }}" "stage/$NAME/"
cp LICENSE LICENSING.md README.md "stage/$NAME/" 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd stage && zip -qr "../dist/$NAME.zip" "$NAME")
else
tar -C stage -czf "dist/$NAME.tar.gz" "$NAME"
fi
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
# Static musl Linux builds for routers (AsusWRT Merlin, OpenWrt, Keenetic).
# The desktop `build` job ships Linux via with_purego, which pulls a dynamic
# libdl.so.2 dependency (purego's //go:cgo_import_dynamic) and won't load on
# musl. Here we mirror upstream build.yml: clone cronet-go, fetch the Chromium
# musl toolchain via its cmd/build-naive, and build CGO_ENABLED=1 with
# `with_musl` — libcronet.a is linked statically and naive is preserved.
# See SPECS/006.
build_linux_musl:
name: build linux-musl/${{ matrix.asset }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# cronet-go carries the build-naive tool + the naiveproxy/src Chromium
# toolchain sources. Pin to the same commit go.mod depends on.
- name: Clone cronet-go
run: |
set -xeuo pipefail
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
# generate_keyring.sh fetches Debian archive keys from keyservers,
# which can also be flaky — retry with backoff. (lx CI)
n=0; max=4; delay=20
until GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "keyring regen failed after $max attempts"; exit 1
fi
echo "keyring regen attempt $n failed; retrying in ${delay}s"
sleep "$delay"; delay=$((delay*2))
done
- name: Cache Chromium toolchain
uses: actions/cache@v4
with:
path: |
~/cronet-go/naiveproxy/src/third_party/llvm-build/
~/cronet-go/naiveproxy/src/gn/out/
~/cronet-go/naiveproxy/src/chrome/build/pgo_profiles/
~/cronet-go/naiveproxy/src/out/sysroot-build/
key: chromium-toolchain-musl-${{ matrix.arch }}-${{ hashFiles('.github/CRONET_GO_VERSION') }}
# Second source, between actions/cache and snapshot.debian.org: our own
# durable mirror (release `musl-toolchain-cache`, produced by
# lx-musl-toolchain-mirror.yml). Restores on an actions/cache miss —
# including the common ref-scoping miss where a tag build can't see another
# tag's cache — so a snapshot.debian.org outage no longer blocks releases.
# SPEC 023. If the mirror also misses, the download step below falls back to
# snapshot.debian.org exactly as before.
- name: Restore musl toolchain from lx mirror
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -xeuo pipefail
SRC=~/cronet-go/naiveproxy/src
# actions/cache hit already populated the tree — nothing to do.
if [ -d "$SRC/out/sysroot-build" ] && [ -d "$SRC/third_party/llvm-build" ]; then
echo "actions/cache hit — skipping lx mirror"; exit 0
fi
CRONET_GO_VERSION="$(cat .github/CRONET_GO_VERSION)"
ASSET="toolchain-${{ matrix.arch }}-${CRONET_GO_VERSION}.tar.zst"
if gh release download musl-toolchain-cache --repo "$GITHUB_REPOSITORY" \
--pattern "$ASSET" --dir /tmp/lxtc 2>/dev/null; then
tar --zstd -C "$SRC" -xf "/tmp/lxtc/$ASSET"
echo "restored toolchain from lx mirror ($ASSET)"
else
echo "lx mirror miss ($ASSET) — falling back to snapshot.debian.org"
fi
- name: Download Chromium musl toolchain
run: |
set -xeuo pipefail
cd ~/cronet-go
# The toolchain download pulls sysroot .deb packages from
# snapshot.debian.org, which intermittently 503s ("No healthy
# backends"). get-clang.sh already retries internally but back-to-back,
# so a whole outage window fails all attempts. Retry the step with a
# growing backoff to ride out a transient mirror outage. (lx CI)
n=0; max=5; delay=30
until go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl download-toolchain; do
n=$((n+1))
if [ "$n" -ge "$max" ]; then
echo "toolchain download failed after $max attempts"; exit 1
fi
echo "toolchain download attempt $n failed; retrying in ${delay}s (snapshot.debian.org may be flaky)"
sleep "$delay"; delay=$((delay*2))
done
- name: Set Chromium toolchain environment
run: |
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.arch }} --libc=musl env >> "$GITHUB_ENV"
- name: Build (musl + naive, static)
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
GOMIPS: ${{ matrix.gomips }}
run: |
set -xeuo pipefail
# LX_TAGS is the single source of truth (Makefile.lx). Swap the purego
# cronet loader for the static musl one; with_naive_outbound stays.
TAGS="$(make -f Makefile.lx -s lx-print-tags)"
TAGS="${TAGS/with_purego/with_musl}"
mkdir -p dist
go build -v -trimpath -tags "$TAGS" \
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ steps.ver.outputs.version }}' -checklinkname=0 -s -w -buildid=" \
-o dist/sing-box ./cmd/sing-box
- name: Verify static (no libdl)
run: |
set -xeuo pipefail
file dist/sing-box
file dist/sing-box | grep -q "statically linked"
if strings -a dist/sing-box | grep -q "libdl.so.2"; then
echo "FAIL: libdl.so.2 reference present — not a static musl build"; exit 1
fi
echo "OK: statically linked, no libdl.so.2"
- name: Package
run: |
NAME="sing-box-${{ steps.ver.outputs.version }}-${{ matrix.asset }}"
mkdir -p "stage/$NAME"
cp dist/sing-box "stage/$NAME/"
cp LICENSE LICENSING.md README.md "stage/$NAME/" 2>/dev/null || true
tar -C stage -czf "dist/$NAME.tar.gz" "$NAME"
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.asset }}
path: dist/*.tar.gz
if-no-files-found: error
build_android:
name: build android (libbox.aar)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: true
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
with:
ndk-version: r28
- name: Setup OpenJDK 17
run: sudo apt-get update && sudo apt-get install -y openjdk-17-jdk-headless
- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# build_libbox stamps Libbox.version() from `git describe` — ensure the tag exists.
git tag "$TAG" -f
- name: Build libbox.aar (with_xhttp + with_awg baked in by build_libbox)
run: |
make lib_install
export PATH="$PATH:$(go env GOPATH)/bin"
make lib_android
env:
JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Package AARs
run: |
mkdir -p dist
V="${{ steps.ver.outputs.version }}"
cp libbox.aar "dist/libbox-$V.aar"
cp libbox-legacy.aar "dist/libbox-legacy-$V.aar"
- uses: actions/upload-artifact@v4
with:
name: dist-android
path: dist/*
if-no-files-found: error
release:
name: publish release
needs: [build, build_linux_musl, build_android]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Resolve tag
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
# Derive the upstream base version for the release notes instead of hardcoding it
# (it drifted to alpha.35 across rc.14/15/16 and had to be hand-fixed each time).
# Primary source: the nearest reachable upstream alpha tag in HEAD's ancestry
# (`git describe`). This reads the commit GRAPH, not commit-message text, so it's
# correct even when a merge subject omits the alpha number — e.g. alpha.37 was
# merged in a commit titled "Merge upstream/testing (bump version, fix linux ping)"
# with no "alpha.37" in it, which the subject-grep fallback misses (shipped rc.17/18
# notes as alpha.36). Fallback: scan "Merge upstream" subjects for the highest
# alpha.NN. Last resort: a generic label so notes never carry a stale hardcoded version.
#
# NOTE: actions/checkout only fetches THIS repo's tags, not upstream's. The
# v1.14.0-alpha.* tags are SagerNet/sing-box tags, so without this fetch `git
# describe` finds nothing in CI and silently drops to the weaker subject-grep (which
# is why CI kept resolving alpha.36 while a local clone with upstream tags gets 37).
# Fetch the upstream alpha tags first so the primary `git describe` path works.
git remote add upstream https://github.com/SagerNet/sing-box.git 2>/dev/null || true
git fetch --no-tags upstream 'refs/tags/v1.14.0-alpha.*:refs/tags/v1.14.0-alpha.*' 2>/dev/null || true
BASE="$(git describe --tags --match 'v1.14.0-alpha.*' --abbrev=0 HEAD 2>/dev/null || true)"
if [ -z "$BASE" ]; then
BASE_N="$(git log --grep='Merge upstream' --pretty=%s | grep -oE 'alpha\.[0-9]+' | sed 's/alpha\.//' | sort -n | tail -1)"
[ -n "$BASE_N" ] && BASE="v1.14.0-alpha.${BASE_N}"
fi
[ -z "$BASE" ] && BASE="v1.14.x"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "Resolved upstream base: $BASE"
- name: Checksums
run: (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS)
- name: Release notes
run: |
# What's-new for THIS tag comes from docs-lx/lx-changelog.md (single source of
# truth, kept per release) — extract the section for this version, between its
# "#### vX" header and the next "#### ", into a SEPARATE file. It must NOT be
# interpolated through the heredoc below: changelog prose contains backticks and
# $(...) that the shell would execute (command injection from doc text). We splice
# the file in with sed after the heredoc instead.
VERSION="${{ steps.ver.outputs.version }}"
awk -v v="#### v${VERSION}" '
$0==v {f=1; next} /^#### / {f=0} f' docs-lx/lx-changelog.md > changes.md
if [ -z "$(tr -d '[:space:]' < changes.md)" ]; then
echo "See [docs-lx/lx-changelog.md](https://github.com/Leadaxe/sing-box-lx/blob/lx/docs-lx/lx-changelog.md)." > changes.md
fi
cat > notes.md <<EOF
**sing-box-lx ${{ steps.ver.outputs.version }}** — a thin downstream of [sing-box](https://github.com/SagerNet/sing-box) (base **${{ steps.ver.outputs.base }}**, branch \`lx\`).
### What's new in this release
__CHANGES__
### Standing features
- **AmneziaWG 2.0** (\`with_awg\`) — \`wireguard\` endpoint with \`jc/jmin/jmax\`, \`s1\`–\`s4\`, \`h1\`–\`h4\`, \`i1\`–\`i5\`.
- **XHTTP** transport (\`with_xhttp\`) — Xray-compatible "splithttp", composes with Reality (use \`auto\`; \`stream-one\` has a known framing bug).
- **CommandClient extensions** (\`with_lx_command\`) — native libbox gRPC parity for the Clash API dropped from the **Android AAR**: URLTestOutbound, GetRules, GetGroups/GetOutbounds, Connection.Detour, SubscribeDNSQueries. (Desktop/CLI binaries keep \`with_clash_api\` for external dashboards.)
### Binaries
Drop-in \`sing-box\` for **darwin / windows** × {amd64, arm64}, plus a **Windows 7 (32-bit)** legacy build (\`sing-box-${{ steps.ver.outputs.version }}-windows-386-legacy-windows-7.zip\` — built with a Win7-patched Go; without naive/cronet, which has no windows/386 target).
**Linux — static musl builds for routers** (AsusWRT Merlin, OpenWrt, Keenetic): \`linux-amd64\`, \`linux-arm64\`, \`linux-armv7\`, \`linux-mipsle-softfloat\`. These are statically linked (no \`libdl.so.2\`/glibc dependency) and **keep NaïveProxy** — they run on musl routers where the previous dynamic builds failed with \`libdl.so.2: cannot open shared object file\`. See SPECS/006.
**Linux — big-endian MIPS** (OpenWrt \`mips_24kc\`, e.g. Atheros AR93xx): \`linux-mips-softfloat\` — pure-Go static build **without NaïveProxy** (Chromium/cronet has no big-endian MIPS toolchain); everything else matches the desktop tag set.
Each archive contains the \`sing-box\` binary (\`sing-box version\` reports \`${{ steps.ver.outputs.version }}\`). Verify downloads against \`SHA256SUMS\`.
### Android
\`libbox-${{ steps.ver.outputs.version }}.aar\` (+ \`libbox-legacy-…\` for SDK 21) — gomobile build of \`experimental/libbox\` with \`with_xhttp\`+\`with_awg\` enabled, for embedding in an Android app. \`Libbox.version()\` reports the lx version.
### Build tags
Desktop binary: \`$(make -f Makefile.lx -s lx-print-tags)\`
Config reference: [docs-lx/lx-config.md](https://github.com/Leadaxe/sing-box-lx/blob/lx/docs-lx/lx-config.md).
EOF
# Splice the changelog section in place of the placeholder. `sed r` inserts the
# file verbatim (no shell/sed interpretation of its contents), so backticks and
# $(...) in the prose are inert. Then drop the placeholder line itself. Written
# via a temp file (not sed -i, whose flag syntax differs BSD vs GNU).
sed -e '/__CHANGES__/r changes.md' -e '/__CHANGES__/d' notes.md > notes.final.md
mv notes.final.md notes.md
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
# Pre-release tags (-rc.N / -alpha.N / -beta.N) publish as GitHub
# pre-releases so an unverified build never becomes "Latest". A plain
# vX.Y.Z-lx.N tag (no such suffix) publishes as a normal release.
EXTRA=""
case "${{ steps.ver.outputs.tag }}" in
*-rc.*|*-alpha.*|*-beta.*) EXTRA="--prerelease" ;;
esac
# --repo is REQUIRED: the base-version step adds an `upstream` remote
# (SagerNet/sing-box) so git-describe can see the alpha tags, and without
# --repo `gh` resolves the target repo from the remotes and picks upstream
# → HTTP 403 (the token has no rights there). Pin it to this repo.
gh release create "${{ steps.ver.outputs.tag }}" dist/* \
--repo "${{ github.repository }}" \
--title "sing-box-lx ${{ steps.ver.outputs.version }}" \
--notes-file notes.md \
--target "$GITHUB_SHA" \
$EXTRA
+16
View File
@@ -0,0 +1,16 @@
name: Mark stale issues and pull requests
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 5 days'
days-before-stale: 60
days-before-close: 5
exempt-issue-labels: 'bug,enhancement'
+55
View File
@@ -0,0 +1,55 @@
name: Test
on:
push:
branches:
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/test.yml'
pull_request:
branches:
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
go:
- ~1.24
- ~1.25
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Set build tags and ldflags
shell: bash
run: |
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
- name: Test (unix)
if: matrix.os != 'windows-latest'
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
- name: Test (windows)
if: matrix.os == 'windows-latest'
shell: bash
run: go test -v -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
+60 -2
View File
@@ -1,3 +1,4 @@
# -- shater overlay -----------------------------------------------
# testbed downloads / VM images / SDKs (agent-managed, large, machine-local)
testbed/
*.img
@@ -13,6 +14,12 @@ testbed/
*.apk
bin/
node_modules/
out/
out-apk/
# CI caches (SDK tarballs, dl/ sources, apt archives, prebuilt tools) —
# persisted between runs by actions/cache, never committed
.cache/
# editor / os
.DS_Store
@@ -21,12 +28,63 @@ Thumbs.db
# go build artifacts
*.exe
/package/xrayctl/xrayctl
# windows reserved-name junk from stray > NUL redirects
NUL
nul
out/
# playwright MCP screenshots/snapshots
.playwright-mcp/
# working-session screenshots dropped in the repo root (not shipped docs)
/*.png
# throwaway build binaries / scratch staged under tmp/
/tmp/
# -- upstream sing-box-lx -----------------------------------------
/.idea/
.idea/
/vendor/
/*.json
/*.srs
/*.db
/site/
/build/
/*.jar
/*.aar
/*.xcframework/
/experimental/libbox/*.aar
/experimental/libbox/*.xcframework/
/experimental/libbox/*.nupkg
/sing-box
/sing-box.exe
/config.d/
/venv/
/test/cache.db
# feed artifacts (the tracked apk trust anchor dist/shater-apk.pem is force-added)
/dist/
# local agent config (CLAUDE.md is deliberately tracked; .claude local settings are not)
/.claude/
# panel SPA embedded into shaterd: build copies panel/dist/* into
# shater/panel/webroot/; those artifacts are gitignored, the .gitkeep marker
# (which keeps the dir embeddable when the SPA isn't built) stays tracked.
/shater/panel/webroot/*
!/shater/panel/webroot/.gitkeep
# prebuilt shaterd binaries staged for the openwrt/shaterd package by
# scripts/build-shaterd.sh — release artifacts, not source. The .gitkeep marker
# (which keeps the dir present so the package build can stage into it) stays tracked.
/openwrt/shaterd/files/shaterd-*
!/openwrt/shaterd/files/.gitkeep
# throwaway VM traffic generator (never shipped)
shater/cmd/trafgen/
errors.log
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
+16
View File
@@ -0,0 +1,16 @@
[submodule "clients/apple"]
path = clients/apple
url = https://github.com/SagerNet/sing-box-for-apple.git
[submodule "clients/android"]
path = clients/android
url = https://github.com/SagerNet/sing-box-for-android.git
[submodule "submodules/wireguard-go"]
path = submodules/wireguard-go
url = https://github.com/Leadaxe/wireguard-go-awg2-lx
# The pin lives on lx-awg2-v005, NOT on lx: the two are separate lines (42
# commits apart one way, 131 the other). `lx` has no hasReserved() gate in
# conn/bind_std.go at all, so a `git submodule update --remote` against it
# would silently restore the bug where ClientBind/StdNetBind shred the
# AmneziaWG magic header and no chain carries traffic. Keep this pointing at
# the line the pin is actually on.
branch = lx-awg2-v005
+55
View File
@@ -0,0 +1,55 @@
version: "2"
run:
go: "1.24"
build-tags:
- with_gvisor
- with_quic
- with_dhcp
- with_wireguard
- with_utls
- with_acme
- with_clash_api
- with_tailscale
- with_ccm
- with_ocm
- badlinkname
- tfogo_checklinkname0
linters:
default: none
enable:
- ineffassign
- staticcheck
- unused
- modernize
settings:
modernize:
disable:
- omitzero # nested struct omitempty -> omitzero changes JSON output semantics
staticcheck:
checks:
- all
- -QF1008 # could remove embedded field "<interface>" from selector
- -ST1003 # should not use ALL_CAPS in Go names; use CamelCase instead
- -QF1001 # could apply De Morgan's law
exclusions:
generated: lax
presets:
- comments
- common-false-positives
paths:
- transport/simple-obfs
- \.pb\.go$
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofumpt
settings:
gci:
sections:
- standard
- prefix(github.com/sagernet/)
- default
custom-order: true
+38
View File
@@ -0,0 +1,38 @@
# Руководство для AI-агентов — sing-box-lx
`sing-box-lx` — **тонкий downstream** апстрима [SagerNet/sing-box](https://github.com/SagerNet/sing-box): upstream **плюс ровно две фичи** и ничего больше:
1. **XHTTP** — клиентский v2ray-транспорт (совместимость с Xray XHTTP).
2. **AmneziaWG 2.0 (AWG2)** — клиентский endpoint поверх WireGuard.
Главная ценность проекта — **согласованность с upstream**. Любое изменение оценивается по тому, насколько легко оно переживёт ребейз на следующий тег upstream.
---
## Что читать в первую очередь
| Документ | Назначение |
|----------|------------|
| **SPECS/CONSTITUTION.md** | Неизменяемые принципы: приоритеты, build-tag изоляция, минимальный дифф, запреты |
| **SPECS/IMPLEMENTATION_PROMPT.md** | DoD, git/ребейз-ритуал, команды сборки и тестов, контракт выхода |
| **SPECS/README.md** | Формат задач `NNN-T-S-NAME` (Spec Kit), workflow |
Перед реализацией задачи из `SPECS/` обязательно прочитай её **SPEC.md → PLAN.md → TASKS.md** и применяй **IMPLEMENTATION_PROMPT.md**.
---
## Жёсткие границы (детали — в CONSTITUTION)
- **Только две фичи.** Любая фича вне XHTTP/AWG2 — вне скоупа, спросить пользователя.
- **Go module path остаётся `github.com/sagernet/sing-box`** (для чистых ребейзов).
- **Всё новое — за build-tag** (`with_xhttp`, `with_awg`) и **в новых файлах/пакетах**.
- **Правки upstream-файлов** — только помеченными `// lx:` блоками, атомарными коммитами.
- **Никаких merge с upstream — только rebase.** `origin/lx` всегда ребейзится на тег.
- **Имя бинаря — `sing-box`** (drop-in для лаунчера); идентичность `-lx` — в версии.
- **Scope — client-only**: outbound/endpoint. Server/inbound отложены.
---
## Язык
Спеки, отчёты и ответы в чате — **русский**. Код, комментарии, коммиты — английский, в стиле upstream sing-box.
+160
View File
@@ -0,0 +1,160 @@
# Режим работы: оркестратор + исполнители
Ты (основная модель) — архитектор и тимлид. Ты НЕ пишешь код сам.
Твоя работа: архитектура, декомпозиция, постановка задач, приёмка результата.
## Правила делегирования
1. ЛЮБАЯ реализация (код, тесты, конфиги, рефакторинг, отладка) выполняется
субагентами через инструмент Agent. Сам ты правишь файлы только в одном
случае: тривиальная правка в 1–2 строки, где постановка задачи дороже самой
правки.
2. **Модель выбирает исполнитель задачи, а не привычка.** `fable` — быстрый и
дешёвый, годится для механической работы с ясным контрактом. `opus` — для
всего, где нужно рассуждение: поиск причины, аудит, дизайн, работа в чужом
коде. Если у `fable` кончилась квота — молча переходи на `opus`, это не повод
останавливать работу. Не спрашивай владельца, какую модель брать.
3. Перед делегированием ты сам исследуешь код настолько, чтобы написать точное
ТЗ. В каждом задании субагенту обязательно указывай:
- контекст: что за проект и над чем идёт работа;
- конкретные файлы и функции (пути, а не «найди сам»);
- контракт: сигнатуры, форматы данных, инварианты, что менять НЕЛЬЗЯ;
- definition of done: какие команды прогнать и какой ждать результат;
- что вернуть: изменённые файлы, результаты проверок, найденные проблемы,
принятые решения.
4. **Скиллы использовать по максимуму — и тебе, и агентам.** Это не
формальность: в них лежит выстраданное знание по ровно тем предметным
областям, в которых мы работаем, и игнорировать их — значит переоткрывать
чужие грабли. См. раздел «Скиллы» ниже.
5. Независимые задачи запускай ПАРАЛЛЕЛЬНО — несколько вызовов Agent в одном
сообщении. Зависимые — последовательно, передавая результаты предыдущего.
**Делишь файлы между параллельными агентами явно** и пишешь каждому, кто ещё
работает в дереве и что трогать нельзя. Запрещай им `git stash`,
`git checkout <файл>`, `git reset` — в этом проекте агент уже сносил правки
соседа через `git stash push`.
6. Приёмка: результат каждого субагента ты проверяешь сам — читаешь diff
ключевых мест, гоняешь проверки из definition of done. Не принимай отчёт на
слово: сегодня отчёт «тесты зелёные» дважды сопровождался тестом, который
ничего не прибивал. Если результат не принят — не переделывай сам, а верни
задачу тому же агенту через SendMessage (у него сохранён контекст).
7. Финальный отчёт владельцу: что сделано, сколько агентов, что проверено,
**что осталось непроверенным и почему** — последнее так же важно.
## Инженерные стандарты
Это не пожелания. Каждый пункт здесь появился после того, как его отсутствие
стоило рабочего дня.
- **Тест обязан быть проверен мутацией.** Откатить фикс → показать, что тест
падает, и с каким текстом → вернуть фикс. Тест, не падающий на сломанном коде,
не тест, а украшение.
- **Прибор без контроля не доказывает ничего.** Отрицательный результат чего-то
стоит, только если показано, что этот же прибор умеет дать положительный.
«Утечки не нашли» прибором, который не мог её увидеть, — это не результат.
- **Опровержение ценнее согласия.** В каждом ТЗ прямо разрешай субагенту
сказать «твоя версия неверна» и требуй доказательства, а не вежливости.
Лучшие результаты этого проекта приходили именно так.
- **Не обещать непроверенного.** Комментарий, предупреждение и текст в панели —
это утверждения о поведении. Если поведение не проверено, так и писать.
Формально верная фраза, которая читается как «работает», — тоже ложь.
- **Умолчание падает в восстановимую сторону.** Открытый `default:` в разборе
вариантов — источник целого класса дефектов: неучтённое значение уходит туда,
где дороже всего ошибиться. Списки делать положительными и закрытыми.
- **Проверка присутствия обязана покрывать всё, что ставит её Apply-двойник.**
Иначе идемпотентный быстрый путь становится ловушкой: «всё на месте» при
отсутствующем маршруте.
- **Никакого молчаливого скипа.** Тест, который не выполнился, обязан быть
назван поимённо в выводе гейта. Однажды CI гонял два теста из 116 файлов, и
все считали, что покрыто.
## Скиллы
**Правило: если задача касается области, по которой есть скилл, — скилл
вызывается ДО начала работы, а не после того, как что-то не заработало.**
Это относится и к тебе, и к каждому субагенту.
Субагент не видит наш диалог и сам не догадается, что скиллы существуют.
Поэтому **в каждом ТЗ перечисляй поимённо**, какие скиллы он обязан вызвать
через инструмент Skill: «сначала вызови Skill "openwrt-nftables" и Skill
"openwrt-networking", следуй им». Требуй в отчёте сказать, что именно из скилла
он применил, — так видно, вызвал он его или упомянул.
Соответствие областей этого проекта и скиллов:
| Трогаешь | Обязательные скиллы |
|---|---|
| `/etc/config/*`, `uci`, uci-defaults, парсер модели | `openwrt-uci` |
| nftables, fw4, зоны, метки, tproxy, kill-switch | `openwrt-nftables` |
| интерфейсы, мосты, VLAN, policy routing, `ip rule`, sysctl, dnsmasq | `openwrt-networking` |
| init-скрипты, procd, respawn, service triggers, boot armor | `openwrt-procd-services` |
| перехват трафика целиком (tproxy + маршрутизация + DNS) | `openwrt-transparent-proxy` |
| сборка пакетов, SDK, фид, CI, подпись, `apk`/`opkg` | `openwrt-package-build-ci`, `openwrt-native-packages` |
| LuCI-приложение, ubus/rpcd, ucode | `openwrt-luci-plugin`, `openwrt-ubus-rpcd`, `openwrt-ucode` |
| панель (React/TS) | `react-expert`, `frontend-design:frontend-design` |
| Go: конкурентность, каналы, профилирование, идиоматика | `fullstack-dev-skills:golang-pro` |
| TypeScript | `fullstack-dev-skills:typescript-pro` |
| стратегия тестирования, покрытие, тестовые данные | `fullstack-dev-skills:test-master` |
| поиск причины по логам и трассам | `fullstack-dev-skills:debugging-wizard` |
| проверка в браузере, скриншоты | `fullstack-dev-skills:playwright-expert` |
| ревью | `review`, `fullstack-dev-skills:code-reviewer` |
| безопасность | `security-review`, `fullstack-dev-skills:security-reviewer` |
| графики и визуализация данных | `dataviz` |
Список неполный — **смотри доступные скиллы под задачу**, а не только в эту
таблицу. Если скилл выглядит смежным, дешевле вызвать его и не воспользоваться,
чем не вызвать и потом отлаживать то, что там уже описано.
## Проверки
- **Гейт:** `bash scripts/run-tests.sh` — Linux в Docker, боевой набор тегов,
`-race`, и шаг, требующий вердикта по имени для привилегированных тестов.
Зелёный гейт — необходимое условие, но не достаточное: он не видит стыков с
ядром, procd и nftables.
- **Стенд:** сервер `local_openwrt` в ssh-manager — ImmortalWrt 25.12.1 той же
ревизии, что боевой роутер. Сюда — всё, что касается init-скриптов, nft,
policy routing, TUN.
- **Боевой роутер:** `mini_router` (BPI-R3), через него идёт весь домашний
трафик. Перед изменением конфигурации — резервная копия. Проверять приборно,
а не по логу: лог может печатать одно и то же в честном и в ложном случае.
## Релиз и деплой
- Тег → CI (Gitea Actions) → apk-фид → установка на роутер.
- **Обновлять только поимённо**, никогда не `apk upgrade` целиком:
`apk upgrade shaterd shater-core luci-app-shater`.
- **Не трогать кеш CI-раннера** — сборка растянется на часы.
- Число тегов на порцию работы — на твоё усмотрение, если владелец не сказал
иначе.
## Фронтенд (admin panel)
Дизайн-направление ЗАФИКСИРОВАНО: **Faceplate** (панель сетевого железа).
Спека, токены и компоненты — в [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md).
Эталон: https://claude.ai/code/artifact/9f7c07e8-d8ac-4ae1-b113-5b25d0ba5dd2
- **Стек:** Vite + React + TypeScript в `panel/`. SPA встраивается в бинарь —
тяжёлые зависимости недопустимы.
- **Панель целиком на английском.** Ни одного символа кириллицы в `panel/src`.
- **В КАЖДОМ ТЗ на панель:** ссылка на `DESIGN.md` и на эталон; требование
сначала вызвать Skill `react-expert` и Skill
`frontend-design:frontend-design`; список существующих компонентов, которые
надо ПЕРЕИСПОЛЬЗОВАТЬ (`<Faceplate> <Module> <Toggle> <Led> <SegMeter>
<QueryLog>` и кнопки), а не изобретать заново; какие токены и семантические
цвета применять; DoD — совпадение с языком эталона, адаптив, фокус,
`prefers-reduced-motion`.
- Оранжевый — только акцент; семантика good/warn/crit — отдельно.
- **Панель не должна врать про состояние.** Значение, которое движок примет,
не может рисоваться как «never matches»; настройка, которой управляет другая
подсистема, не может описываться так, будто управляет ею.
+26
View File
@@ -0,0 +1,26 @@
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
COPY . /go/src/github.com/sagernet/sing-box
WORKDIR /go/src/github.com/sagernet/sing-box
ARG TARGETOS TARGETARCH
ARG GOPROXY=""
ENV GOPROXY ${GOPROXY}
ENV CGO_ENABLED=0
ENV GOOS=$TARGETOS
ENV GOARCH=$TARGETARCH
RUN set -ex \
&& apk add git build-base \
&& export COMMIT=$(git rev-parse --short HEAD) \
&& export VERSION=$(go run ./cmd/internal/read_tag) \
&& export TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) \
&& export LDFLAGS_SHARED=$(cat release/LDFLAGS) \
&& go build -v -trimpath -tags "$TAGS" \
-o /go/bin/sing-box \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" $LDFLAGS_SHARED -s -w -buildid=" \
./cmd/sing-box
FROM --platform=$TARGETPLATFORM alpine AS dist
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
RUN set -ex \
&& apk add --no-cache --upgrade bash tzdata ca-certificates nftables
COPY --from=builder /go/bin/sing-box /usr/local/bin/sing-box
ENTRYPOINT ["sing-box"]
+14
View File
@@ -0,0 +1,14 @@
ARG BASE_IMAGE=alpine
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG TARGETVARIANT
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
RUN set -ex \
&& if command -v apk > /dev/null; then \
apk add --no-cache --upgrade bash tzdata ca-certificates nftables; \
else \
apt-get update && apt-get install -y --no-install-recommends bash tzdata ca-certificates nftables \
&& rm -rf /var/lib/apt/lists/*; \
fi
COPY sing-box-${TARGETARCH}${TARGETVARIANT} /usr/local/bin/sing-box
ENTRYPOINT ["sing-box"]
+13 -334
View File
@@ -1,338 +1,17 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Preamble
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
In addition, no derivative work may use the name or imply association
with this application without prior consent.
+288
View File
@@ -0,0 +1,288 @@
NAME = sing-box
COMMIT = $(shell git rev-parse --short HEAD)
TAGS ?= $(shell cat release/DEFAULT_BUILD_TAGS_OTHERS)
GOHOSTOS = $(shell go env GOHOSTOS)
GOHOSTARCH = $(shell go env GOHOSTARCH)
VERSION=$(shell CGO_ENABLED=0 GOOS=$(GOHOSTOS) GOARCH=$(GOHOSTARCH) go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest)
LDFLAGS_SHARED = $(shell cat release/LDFLAGS)
PARAMS = -v -trimpath -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$(VERSION)' $(LDFLAGS_SHARED) -s -w -buildid="
MAIN_PARAMS = $(PARAMS) -tags "$(TAGS)"
MAIN = ./cmd/sing-box
PREFIX ?= $(shell go env GOPATH)
SING_FFI ?= sing-ffi
LIBBOX_FFI_CONFIG ?= ./experimental/libbox/ffi.json
.PHONY: test release docs build
build:
export GOTOOLCHAIN=local && \
go build $(MAIN_PARAMS) $(MAIN)
race:
export GOTOOLCHAIN=local && \
go build -race $(MAIN_PARAMS) $(MAIN)
ci_build:
export GOTOOLCHAIN=local && \
go build $(PARAMS) $(MAIN) && \
go build $(MAIN_PARAMS) $(MAIN)
generate_completions:
go run -v --tags "$(TAGS),generate,generate_completions" $(MAIN)
install:
go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN)
fmt:
@golangci-lint fmt
fmt_docs:
go run ./cmd/internal/format_docs
lint:
GOOS=linux golangci-lint run ./...
GOOS=android golangci-lint run ./...
GOOS=windows golangci-lint run ./...
GOOS=darwin golangci-lint run ./...
# GOOS=freebsd golangci-lint run ./...
lint_install:
go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
proto:
@go run ./cmd/internal/protogen
@gofumpt -l -w .
@gofumpt -l -w .
proto_install:
go install -v google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install -v google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
update_certificates:
go run ./cmd/internal/update_certificates
release:
go run ./cmd/internal/build goreleaser release --clean --skip publish
mkdir dist/release
mv dist/*.tar.gz \
dist/*.zip \
dist/*.deb \
dist/*.rpm \
dist/*_amd64.pkg.tar.zst \
dist/*_arm64.pkg.tar.zst \
dist/release
ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release
rm -r dist/release
release_repo:
go run ./cmd/internal/build goreleaser release -f .goreleaser.fury.yaml --clean
release_install:
go install -v github.com/tcnksm/ghr@latest
update_android_version:
go run ./cmd/internal/update_android_version
build_android:
cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease :app:assembleOtherLegacyRelease && ./gradlew --stop
upload_android:
mkdir -p dist/release_android
cp ../sing-box-for-android/app/build/outputs/apk/other/release/*.apk dist/release_android
cp ../sing-box-for-android/app/build/outputs/apk/otherLegacy/release/*.apk dist/release_android
VERSION_CODE=$$(grep VERSION_CODE ../sing-box-for-android/version.properties | cut -d= -f2); \
VERSION_NAME=$$(grep VERSION_NAME ../sing-box-for-android/version.properties | cut -d= -f2); \
printf '{\n "version_code": %s,\n "version_name": "%s"\n}\n' "$$VERSION_CODE" "$$VERSION_NAME" > dist/release_android/SFA-version-metadata.json
ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist/release_android
rm -rf dist/release_android
release_android: build_android upload_android
publish_android:
cd ../sing-box-for-android && ./gradlew :app:publishPlayReleaseBundle && ./gradlew --stop
# TODO: find why and remove `-destination 'generic/platform=iOS'`
# TODO: remove xcode clean when fix control widget fixed
build_ios:
cd ../sing-box-for-apple && \
rm -rf build/SFI.xcarchive && \
xcodebuild clean -scheme SFI -derivedDataPath build/SFI.dd && \
xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -derivedDataPath build/SFI.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_ios_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
build_ios_deb:
$(MAKE) -C ../sing-box-for-apple build_ios_deb
upload_ios_deb:
ghr --replace --draft --prerelease "v${VERSION}" ../sing-box-for-apple/build/jailbreak/"SFI-${VERSION}-iphoneos-arm64.deb"
release_ios: build_ios upload_ios_app_store
release_ios_deb: build_ios_deb upload_ios_deb
build_macos:
cd ../sing-box-for-apple && \
rm -rf build/SFM.xcarchive && \
xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -derivedDataPath build/SFM.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_macos_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFM.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
release_macos: build_macos upload_macos_app_store
build_macos_standalone:
$(MAKE) -C ../sing-box-for-apple archive_macos_standalone
build_macos_dmg:
$(MAKE) -C ../sing-box-for-apple build_macos_dmg
build_macos_pkg:
$(MAKE) -C ../sing-box-for-apple build_macos_pkg
notarize_macos_dmg:
$(MAKE) -C ../sing-box-for-apple notarize_macos_dmg
notarize_macos_pkg:
$(MAKE) -C ../sing-box-for-apple notarize_macos_pkg
upload_macos_dmg:
mkdir -p dist/SFM
cp ../sing-box-for-apple/build/SFM-Apple.dmg "dist/SFM/SFM-${VERSION}-Apple.dmg"
cp ../sing-box-for-apple/build/SFM-Intel.dmg "dist/SFM/SFM-${VERSION}-Intel.dmg"
cp ../sing-box-for-apple/build/SFM-Universal.dmg "dist/SFM/SFM-${VERSION}-Universal.dmg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.dmg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.dmg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.dmg"
upload_macos_pkg:
mkdir -p dist/SFM
cp ../sing-box-for-apple/build/SFM-Apple.pkg "dist/SFM/SFM-${VERSION}-Apple.pkg"
cp ../sing-box-for-apple/build/SFM-Intel.pkg "dist/SFM/SFM-${VERSION}-Intel.pkg"
cp ../sing-box-for-apple/build/SFM-Universal.pkg "dist/SFM/SFM-${VERSION}-Universal.pkg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.pkg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.pkg"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.pkg"
replace_macos_pkg:
mkdir -p dist/SFM
cp ../sing-box-for-apple/build/SFM-Apple.pkg "dist/SFM/SFM-${VERSION}-Apple.pkg"
cp ../sing-box-for-apple/build/SFM-Intel.pkg "dist/SFM/SFM-${VERSION}-Intel.pkg"
cp ../sing-box-for-apple/build/SFM-Universal.pkg "dist/SFM/SFM-${VERSION}-Universal.pkg"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}-Apple.pkg"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}-Intel.pkg"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}-Universal.pkg"
upload_macos_dsyms:
mkdir -p dist/SFM
cd ../sing-box-for-apple/build/SFM.System-universal.xcarchive && zip -r SFM.dSYMs.zip dSYMs
cp ../sing-box-for-apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM/SFM-${VERSION}.dSYMs.zip"
ghr --replace --draft --prerelease "v${VERSION}" "dist/SFM/SFM-${VERSION}.dSYMs.zip"
replace_macos_dsyms:
mkdir -p dist/SFM
cd ../sing-box-for-apple/build/SFM.System-universal.xcarchive && zip -r SFM.dSYMs.zip dSYMs
cp ../sing-box-for-apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM/SFM-${VERSION}.dSYMs.zip"
ghr --replace "v${VERSION}" "dist/SFM/SFM-${VERSION}.dSYMs.zip"
release_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg upload_macos_dsyms
replace_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg upload_macos_dsyms
build_tvos:
cd ../sing-box-for-apple && \
rm -rf build/SFT.xcarchive && \
xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -derivedDataPath build/SFT.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_tvos_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath "build/SFT.xcarchive" -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
export_tvos_ipa:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath "build/SFT.xcarchive" -exportOptionsPlist SFI/Export.plist -allowProvisioningUpdates -exportPath build/SFT && \
cp build/SFT/sing-box.ipa dist/SFT.ipa
upload_tvos_ipa:
cd dist && \
cp SFT.ipa "SFT-${VERSION}.ipa" && \
ghr --replace --draft --prerelease "v${VERSION}" "SFT-${VERSION}.ipa"
release_tvos: build_tvos upload_tvos_app_store
update_apple_version:
go run ./cmd/internal/update_apple_version
update_macos_version:
MACOS_PROJECT_VERSION=$(shell go run -v ./cmd/internal/app_store_connect next_macos_project_version) go run ./cmd/internal/update_apple_version
release_apple: lib_apple update_apple_version release_ios release_macos release_tvos release_macos_standalone
release_apple_beta: update_apple_version release_ios release_macos release_tvos
publish_testflight:
go run -v ./cmd/internal/app_store_connect publish_testflight $(filter-out $@,$(MAKECMDGOALS))
prepare_app_store:
go run -v ./cmd/internal/app_store_connect prepare_app_store
publish_app_store:
go run -v ./cmd/internal/app_store_connect publish_app_store
test:
@go test -v ./... && \
cd test && \
go mod tidy && \
go test -v -tags "$(TAGS_TEST)" .
test_stdio:
@go test -v ./... && \
cd test && \
go mod tidy && \
go test -v -tags "$(TAGS_TEST),force_stdio" .
lib_android:
go run ./cmd/internal/build_libbox -target android
lib_apple:
go run ./cmd/internal/build_libbox -target apple
lib_windows:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type csharp
lib_android_new:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type android
lib_apple_new:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple
lib_install:
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.13
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.13
docs:
venv/bin/mkdocs serve
publish_docs:
venv/bin/mkdocs gh-deploy -m "Update" --force --ignore-version --no-history
docs_install:
python3 -m venv venv
source ./venv/bin/activate && pip install --force-reinstall mkdocs-material=="9.7.2" mkdocs-static-i18n=="1.2.*"
clean:
rm -rf bin dist sing-box
rm -f $(shell go env GOPATH)/sing-box
update:
git fetch
git reset FETCH_HEAD --hard
git clean -fdx
%:
@:
+70
View File
@@ -0,0 +1,70 @@
# Makefile.lx — sing-box-lx downstream build helpers.
# New file (zero edits to upstream Makefile) — see SPECS/CONSTITUTION.md §3.2.
# Usage: make -f Makefile.lx lx-build
# Canonical lx build-tag set for the desktop/CLI binaries — single source of truth
# (mirror changes in SPECS/004). = upstream feature set (release/DEFAULT_BUILD_TAGS)
# minus tags irrelevant to a VPN client — with_tailscale (no tailscale endpoints),
# with_ccm/with_ocm (Claude Code / OpenAI Codex proxy services), with_acme (server-side
# TLS cert issuance) — plus with_purego (CGO-free cross-compile covers
# with_naive_outbound via cronet prebuilts) and our downstream features.
#
# with_clash_api IS kept here: the desktop/CLI binary is driven through the Clash REST
# API by external dashboards (yacd / MetaCubeXD / clash-dashboard); there is no native
# CommandClient channel outside the gomobile/libbox binding, so dropping it would leave
# a CLI user with no way to manage the core (a config using experimental.clash_api would
# fail fast). It is dropped ONLY from the Android AAR (cmd/internal/build_libbox/main.go),
# where LxBox manages the core over the native libbox CommandClient and the Clash server
# is dead weight. So the two tag sets diverge by design — do NOT blindly mirror the AAR
# set here.
#
# with_purego/badlinkname need -checklinkname=0 in LX_LDFLAGS, otherwise the linker
# rejects badtls' go:linkname into crypto/tls.
LX_TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg,with_lx_command
# lx build counter over a given upstream base (override in CI/release: make -f Makefile.lx lx-build LX_BUILD=3).
LX_BUILD ?= 1
# Upstream base version from the nearest stable tag, excluding our own -lx tags (e.g. 1.13.13).
UPSTREAM_VERSION := $(shell git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*lx*' 2>/dev/null | sed 's/^v//')
LX_VERSION ?= $(UPSTREAM_VERSION)-lx.$(LX_BUILD)
# Stamp the version via ldflags only — constant/version.go stays untouched (zero upstream diff).
# -checklinkname=0: required by badlinkname/tfogo_checklinkname0 (Go 1.24 blocks the
# crypto/tls go:linkname in common/badtls otherwise) — mirrors upstream build_libbox.
LX_LDFLAGS = -X 'github.com/sagernet/sing-box/constant.Version=$(LX_VERSION)' -checklinkname=0 -s -w -buildid=
LX_OUTPUT ?= sing-box
# Pinned proto toolchain (SPEC 014 §3.5). Upstream `make proto` installs the codegen
# plugins at @latest, so .pb.go cannot be reproduced byte-for-byte across a rebase. We
# pin both plugins to versions matching go.mod (protobuf v1.36.11) and a gRPC codegen
# release compatible with the generated SupportPackageIsVersion9. `protoc` itself is an
# external dependency (install via your package manager, e.g. `brew install protobuf`);
# the generator at cmd/internal/protogen drives it and strips its version banner, so the
# protoc build number does not leak into the output. gofumpt normalises imports to match
# the committed style. Regenerate, never hand-edit, the .pb.go / _grpc.pb.go files.
LX_PROTOC_GEN_GO_VERSION ?= v1.36.11
LX_PROTOC_GEN_GO_GRPC_VERSION ?= v1.5.1
.PHONY: lx-build lx-version lx-print-tags lx-check lx-proto-install lx-proto
lx-proto-install: ## Install the pinned protoc-gen-go / protoc-gen-go-grpc plugins.
go install google.golang.org/protobuf/cmd/protoc-gen-go@$(LX_PROTOC_GEN_GO_VERSION)
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(LX_PROTOC_GEN_GO_GRPC_VERSION)
go install mvdan.cc/gofumpt@latest
lx-proto: lx-proto-install ## Regenerate *.pb.go reproducibly from the merged .proto (needs system protoc on PATH).
@command -v protoc >/dev/null 2>&1 || { echo "protoc not found on PATH — install it (e.g. brew install protobuf)"; exit 1; }
go run ./cmd/internal/protogen
gofumpt -w .
lx-build: ## Build the drop-in `sing-box` binary with lx features.
CGO_ENABLED=0 go build -v -trimpath -tags "$(LX_TAGS)" -ldflags "$(LX_LDFLAGS)" -o "$(LX_OUTPUT)" ./cmd/sing-box
lx-version: ## Print the computed lx version string (e.g. 1.13.13-lx.1).
@echo "$(LX_VERSION)"
lx-print-tags: ## Print the canonical LX_TAGS set (so CI/release don't duplicate it).
@echo "$(LX_TAGS)"
lx-check: lx-build ## Validate sample configs with the freshly built binary.
./$(LX_OUTPUT) check -c lx-test/config/minimal.json
+130
View File
@@ -0,0 +1,130 @@
<!-- Language: [Русский](README.md) · **English** -->
# shater
**A self-hosted internet-control appliance for OpenWrt routers.** One box turns a
home or office network into a transparent VPN gateway, a network-wide
ad/tracker/malware blocker, per-device parental control, and a live traffic
dashboard — all local, all configured from a rich built-in web panel.
> The primary README is Russian — [README.md](README.md). This is a condensed
> English mirror.
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
## What it is
shater is a network proxy stack for **OpenWrt / ImmortalWrt / BananaWRT** routers
(Banana Pi BPI-R3, BPI-R4 and compatible). It transparently routes all LAN traffic
through a proxy (split by domain/geo/client), filters DNS, gathers statistics, and
is managed from a built-in web panel.
The engine is a **fork of [sing-box](https://github.com/SagerNet/sing-box) via
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)**, compiled into a single Go
binary `shaterd` together with the control plane, DNS filter, stats aggregator and
the web panel itself. Broad protocol set: VLESS/VMess/Trojan/Shadowsocks,
Reality/XTLS, WireGuard, **AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — exactly what
`shater/parse` can read and `shater/registry` registers in the engine.
A thin **LuCI launcher** (mini-dashboard + "Open panel" button) hands the browser a
single-use token into the standalone SPA the daemon serves on its own port
(default `:8088`).
## Highlights
- Transparent **TPROXY** data plane (TCP + UDP), SNI/Host/QUIC sniffing, no DNS leaks
— `:53` interception is on by default and covers the queries a client sends to the
router itself, not just the ones aimed around it (`globals.dns_intercept`, D24).
- First-match routing by source / destination / list / geo / client → outbound /
selector / chain / direct / block; node groups with balancer/observatory;
multi-hop chains; per-rule egress.
- **Fail-closed kill-switch** (dead group → block, never a silent direct leak); own
`inet shater` nft table; atomic apply with `nft -c` validation. Commit-confirm
auto-rollback exists but **ships OFF** (`confirm_timeout=0`) — arm it yourself.
- **DNS filtering & blocklists** with flexible sources (inline / file / url /
geosite), compiled `.srs` matcher; Block-DoH/DoT to stop filter bypass.
- Subscriptions (Clash / sing-box / Xray-JSON) and manual nodes; node health board.
- Per-device control (proxy/blocklist toggles, exit country, per-device block/allow,
schedules) and per-domain/client/device statistics from in-process DNS events.
Full list with MVP/T1/T2 tags — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
## Install
One signed **apk** feed (OpenWrt / ImmortalWrt / BananaWRT **25.12+**), one
release per arch. Verbatim commands, the manual `.apk` install and the
rolling-vs-pinned choice are in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
```sh
wget -O /etc/apk/keys/shater-apk.pem "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" > /etc/apk/repositories.d/shater.list
apk update && apk add luci-app-shater # -> shater-core -> shaterd
```
`apk-latest-<arch>` is a moving pointer refreshed by every release run — install
once and `apk update && apk upgrade shaterd shater-core luci-app-shater`
keeps the router current. Point the repo line at `apk-vX.Y.Z-<arch>` instead to
pin a build; that file then has to be edited by hand for every upgrade.
shater ships **inert** (globals off) so install never breaks connectivity. After
configuring nodes/rules:
```sh
uci set shater.globals.enabled=1
uci set shater.globals.confirm_timeout=120 # commit-confirm ships OFF — arm it
uci commit shater
shaterd apply && shaterd confirm
```
Without that middle line `shaterd apply` arms no auto-rollback (and says so), so an
apply that costs you SSH/LuCI access has to be undone by hand.
Once an enabled, fail-closed config has been applied, `/etc/init.d/shater-armor`
loads a saved fail-closed plane at **boot**, before the daemon exists: LAN→WAN
forwarding is blocked until `shaterd` applies, while SSH/LuCI/the panel stay
reachable on purpose (the chain hooks `forward` only). What arms it, what refuses
to arm, and how to switch it off — `INSTALL.md` §4.
## Build from source
`scripts/build-shaterd.sh [VERSION] [--fast]` builds the SPA (Vite), embeds it via
`//go:embed`, cross-builds musl-static `{amd64, arm64}` and UPX-packs the artifact
into `openwrt/shaterd/files/`. Details in
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
`bash scripts/run-tests.sh` is the test gate: the whole suite under the **shipped**
build tags (`scripts/router-tags.sh`), on linux (it re-execs in Docker from a
non-linux host), with `-race`, plus three machine checks against a silent skip —
the tag set may only add test files, every package with tests must report `ok` by
name, and every `TestIntegration*` must produce a verdict by name.
`scripts/check-router-tags.sh` separately proves no feature declared in
`FEATURES.md` lost a build tag it needs. A green gate is necessary but not
sufficient: it does not see the kernel, procd or nftables seams.
## Repository layout
| Path | What |
|------|------|
| `shater/` | Go control plane, DNS filter, stats aggregator, engine host |
| `panel/` | Admin SPA (Vite + React + TS) and its Go server |
| `openwrt/` | Packages: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Product documentation |
| `scripts/`, `ci/`, `.gitea/workflows/` | Build script, apk feed/release scripts, CI |
| `SPECS/`, `docs-lx/` | Engine-fork constitution/specs and feature-config reference |
| `docs/`, `mkdocs.yml` | **Upstream** sing-box docs (mkdocs) — kept as-is |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | sing-box-lx engine tree |
## CI, upstream & license
CI (`.gitea/workflows/release.yml`) builds all 4 packages and publishes a signed
per-arch apk repo (EC key `shater-apk.pem`). A `vX.Y.Z` tag → the pinnable
`apk-vX.Y.Z-<arch>`; every run also refreshes the rolling `apk-latest-<arch>` and
asserts over the API that it really serves the version just built.
The engine is the **sing-box-lx** fork — a thin downstream of upstream sing-box that
lives by **rebase, never merge**; its constitution is
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md). Licensed under
[GPL-3.0](LICENSE), like upstream sing-box. Unofficial fork, not affiliated with
SagerNet.
+332 -103
View File
@@ -1,135 +1,364 @@
<!-- Язык: **Русский** · [English](README.en.md) -->
# shater
**A transparent xray proxy manager for OpenWrt** — subscriptions, policy routing,
a fail-closed kill-switch, and a modern LuCI UI. Think passwall2, but cleaner,
faster, and honest about its failure modes.
**Управляемый интернет-шлюз для роутеров на OpenWrt.** Одна коробка превращает
домашнюю или офисную сеть в прозрачный VPN-шлюз, сетевой блокировщик рекламы,
трекеров и вредоносных доменов, средство родительского контроля по устройствам
и живую панель аналитики трафика — всё локально, всё self-hosted, всё
настраивается из богатой веб-панели.
[![License: GPL-2.0-or-later](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE)
![OpenWrt 24.10](https://img.shields.io/badge/OpenWrt-24.10-informational.svg)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
> 🇷🇺 [Русская версия — README.ru.md](README.ru.md)
shater turns an OpenWrt router into a whole-network proxy gateway: your LAN
traffic (TCP **and** UDP) is transparently routed through xray via TPROXY, split
by domain / geo / client, with no DNS leaks — configured entirely from a LuCI web
UI, and applied atomically with automatic rollback so a bad config can never
strand the router.
The engine is **xray-core** (unmodified — a runtime dependency). All the logic
lives in `xrayctl`, a small Go control-plane that reads the UCI desired-state and
renders the live xray JSON, an nftables table, and policy routing.
![feed: apk 25.12+](https://img.shields.io/badge/feed-apk%2025.12%2B-orange.svg)
---
## Features
## Что это
**Proxying & routing**
- TPROXY transparent proxy for multiple LAN interfaces (TCP + UDP).
- Subscriptions (VLESS / VMess / Trojan / Shadowsocks / WireGuard·AmneziaWG) with
Clash / sing-box / Xray-JSON formats, HAPP-style fetch, and stable per-node
identity across refreshes.
- Node groups with a load-balancer + observatory (least-ping / failover / …),
multi-hop chains (L1→Ln), and per-rule egress selection.
- First-match routing rules by source (IP/CIDR/MAC/interface/zone), destination
(domain / suffix / keyword / geosite), reusable domain/IP lists, port and proto.
**shater** — это сетевой прокси-стек для роутеров на **OpenWrt / ImmortalWrt /
BananaWRT** (Banana Pi BPI-R3, BPI-R4 и совместимые). Он прозрачно (без настройки
клиентов) заворачивает весь LAN-трафик через прокси с маршрутизацией по домену,
гео и клиенту, фильтрует DNS, собирает статистику и управляется из встроенной
веб-панели.
**Reliability (the "железно" part)**
- Fail-closed kill-switch: a dead or unparsed node group resolves to `block`,
never a silent direct leak; IPv6 is dropped when disabled.
- Atomic apply with `xray -test` + `nft -c` validation and commit-confirm
auto-rollback to the last-good config.
- Idempotent reconcile from hotplug/boot, serialized by a flock, that only
restarts the engine when the rendered config actually changed.
- Management bypass (SSH/LuCI/LAN) is always exempt — you can't lock yourself out.
Ядро — **форк движка [sing-box](https://github.com/SagerNet/sing-box) через
[sing-box-lx](https://github.com/Leadaxe/sing-box-lx)** — вкомпилировано в один
Go-бинарь `shaterd` вместе с control-plane, DNS-фильтром, агрегатором статистики и
самой веб-панелью. За счёт sing-box поддерживается широкий и актуальный набор
протоколов: VLESS/VMess/Trojan/Shadowsocks, Reality/XTLS, WireGuard,
**AmneziaWG 2.0**, Hysteria2, TUIC, XHTTP — ровно то, что умеет разобрать
`shater/parse` и что регистрирует `shater/registry` в движке.
**DNS**
- :53 hijack through dnsmasq, per-domain resolver selection, DoH/DoT resolvers,
FakeIP mode, nftset population for routing, and client DoT/DoH blocking to stop
filter bypass.
**UI & ops**
- A custom "instrument panel" LuCI app: a live Signal Path on the Overview, a
Simple/Advanced toggle, and a one-click quick-start wizard (paste a link → done).
- Live per-client / per-node / per-rule traffic stats.
- Config backup/restore, named profiles, and WAN-mode profiles (conditional
overrides, e.g. SIM uplink → different egress).
Интеграция в OpenWrt — тонкий **LuCI-лаунчер**: мини-дашборд и кнопка «Открыть
панель», которая по одноразовому токену передаёт браузер в полноценную SPA-панель,
поднятую демоном на собственном порту (по умолчанию `:8088`).
---
## Install
## Ключевые возможности
Every push publishes a **signed opkg feed** on the release, so a router adds it
once and then upgrades with plain `opkg`. opkg filters by architecture, so the
same lines work on every device (BPI-R3/R4 → `aarch64_cortex-a53`, x86-64 → `x86_64`):
**Прозрачный прокси и маршрутизация**
- TPROXY data-plane для нескольких LAN-интерфейсов (TCP + UDP), сниффинг
SNI/Host/QUIC, без утечек DNS.
- Правила маршрутизации по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
(domain/suffix/keyword/geosite), спискам, порту, протоколу →
outbound / selector / chain / direct / block.
- Группы узлов с балансировщиком/обсерваторией (least-ping / failover /
round-robin), **мульти-хоп цепочки** и выбор egress по правилу.
**Надёжность («железно»)**
- **Fail-closed kill-switch**: мёртвая группа → block, а не тихая утечка мимо
прокси; собственная nft-таблица `inet shater` и свои марки/таблицы, fw4 не
трогаем.
- Атомарный apply с валидацией движком и `nft -c`. **Commit-confirm** с
авто-откатом к последней рабочей конфигурации есть, но **на стоковой установке
выключен**: `confirm_timeout` поставляется нулём, и apply не вооружает ничего,
пока вы не зададите окно (см. «Включение»).
- Идемпотентный reconcile из hotplug/boot под flock; management-bypass
(SSH/LuCI/LAN) всегда в обход.
**DNS, фильтрация, блокировки**
- Перехват `:53`, DNS движка sing-box в процессе; резолверы DoH/DoT/plain/FakeIP,
выбор резолвера по домену.
- **Блок-листы с гибкими источниками**: `inline` / `file` / `url` (авто-обновление) /
категория `geosite`; hosts-файл, plain-список или AdBlock-стиль `||domain^`
компилируются в локальный `.srs`. Эффективный компилированный матчер вместо
dnsmasq-мегасписков.
- **Block-DoH/DoT** — не даёт устройствам обходить фильтр через свой шифрованный DNS.
**Подписки и узлы**
- Подписки (VLESS/VMess/Trojan/SS/WG/AmneziaWG), форматы Clash/sing-box/Xray-JSON,
интервал обновления + вручную + на загрузке; стабильная идентичность узла между
обновлениями; квоты/срок из `subscription-userinfo`.
- Ручные узлы: share-ссылки, импорт файла, `wg-quick`/AmneziaWG `.conf`.
- Health board: TCP + реальная проба через прокси-путь, exit-IP, «протестировать
все».
**Контроль по устройствам**
- Авто-обнаружение устройств (dhcp.leases + `ip neigh`), имена, живой статус/трафик.
- Тумблеры на устройство: прокси on/off, блок-листы on/off, страна/узел выхода;
блок/allow домена для одного устройства или для всех; расписания.
**Статистика и видимость**
- Топ доменов (запрошенные/заблокированные), allowed-vs-blocked, разбивка по
устройствам, таймлайны — из DNS-событий движка в процессе (без скрейпинга логов).
- Трафик по клиенту/узлу/правилу (байты) из nft-счётчиков; живой query-log.
**Панель и профили**
- Встроенная SPA-панель (собственный порт, вшита в бинарь): overview, узлы и
подписки, правила маршрутизации, DNS/блок-листы, устройства, apply/rollback.
- Именованные профили/сцены и WAN-профили (условные оверрайды).
Полный список с тегами MVP/T1/T2 — [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md).
---
## Архитектура
Один бинарь `shaterd` держит движок, control-plane, DNS-фильтр и веб-сервер панели
в одном процессе; OpenWrt-обвязка (тонкий LuCI + procd/system glue) оборачивает его.
Конфиг — UCI desired-state; демон рендерит его в конфиг движка и применяет;
телеметрия течёт обратно в панель.
```mermaid
flowchart TB
subgraph BIN["shaterd — один бинарь (форк sing-box-lx)"]
ENG["движок sing-box\nпротоколы · Reality · AmneziaWG 2.0 · DNS · routing · stats"]
CTRL["control-plane (shater/)\nUCI-модель · генерация конфига · apply/rollback · nft/routing"]
FILT["DNS-фильтр + блок-листы + политика по устройствам (shater/)"]
STAT["агрегатор статистики (shater/)"]
PANEL["веб-сервер панели + вшитая SPA (свой порт, токен-auth)"]
end
subgraph WRT["OpenWrt-обвязка (openwrt/)"]
LUCI["тонкий LuCI — мини-дашборд + кнопка «Открыть панель»"]
PROCD["procd init · hotplug · uci-defaults · fw4/routing"]
end
LUCI -->|"ubus: mint token"| PANEL
PROCD --> BIN
CTRL --> ENG
FILT --> ENG
ENG --> STAT
STAT --> PANEL
```
Путь трафика: LAN-клиент → `nft tproxy` (mark → tproxy-порт) → tproxy-inbound
sing-box (сниффинг SNI/Host/QUIC) → маршрут по правилу → outbound/selector/chain
(проксировано) · direct (обычный маршрут, без туннеля) · block. TPROXY несёт
только TCP и UDP; ICMP и остальные протоколы — через отдельные опциональные
механизмы (`l3_tunnel`, `untunnelable_egress`, ARCHITECTURE §3a). Подробные
диаграммы (auth-handoff, data-plane, DNS-flow, apply-flow) — в
[`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md).
---
## Установка
shater поставляется одним подписанным **apk-фидом** (OpenWrt / ImmortalWrt /
BananaWRT **25.12+**: `.apk`, индекс `packages.adb`, EC-ключ в `/etc/apk/keys/`).
Старый opkg-фид (`.ipk`, 24.10) снят — оба наших роутера на 25.12 с apk-tools 3,
бинаря `opkg` там просто нет (`docs-shater/DECISIONS.md` D22).
Пакеты ставятся по зависимостям: `shaterd` → `shater-core` → `luci-app-shater`.
`shaterd` подтягивается автоматически как зависимость.
### Фид apk
`/etc/apk/arch` сам выбирает нужный per-arch релиз (apk-релизы раздельны по арке):
```sh
# 1. trust the feed's public key (one time; filename = key fingerprint)
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
# 2. add the feed and install
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
opkg update
opkg install luci-app-shater # pulls xrayctl + shater-core
# 1) доверяем ключу apk-фида (любое имя *.pem под /etc/apk/keys подходит).
wget -O /etc/apk/keys/shater-apk.pem \
"https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/shater-apk.pem"
# 2) добавляем репозиторий — строка указывает на сам ФАЙЛ-ИНДЕКС packages.adb.
echo "https://git.qomar.pw/omar/shater/releases/download/apk-latest-$(cat /etc/apk/arch)/packages.adb" \
> /etc/apk/repositories.d/shater.list
# 3) обновляемся и ставим (shaterd подтянется как зависимость).
apk update
apk add luci-app-shater # -> shater-core -> shaterd
```
`xray-core`, `dnsmasq-full` and the `kmod-nft-*` bits come from the router's own
package feed (they must match its kernel). Full guide — key install, signing,
pinning to a version, apk (OpenWrt 25.x) notes, troubleshooting — in
[`docs/FEED.md`](docs/FEED.md).
Обновление — **перечисляйте пакеты явно, голый `apk upgrade` не запускайте**: без
аргументов apk пересобирает состояние ВСЕХ установленных пакетов по ВСЕМ
подключённым репозиториям и может задеть (в т.ч. откатить) посторонние системные
пакеты.
## First run
```sh
apk update
apk upgrade shaterd shater-core luci-app-shater
# эквивалент, дополнительно закрепляющий пакеты в world:
# apk add -u shaterd shater-core luci-app-shater
```
Open **LuCI → Services → Shater (xray)** and run the quick-start wizard: paste
your subscription link, pick "everything" or "everything except local/RU", click
once. It fetches nodes, picks the fastest server, routes your LAN through it, and
starts the engine — then probes the exit IP to confirm the tunnel is live.
Документация apk-tools 3 про `apk upgrade`: *«If list of packages is provided,
only those packages are upgraded along with needed dependencies»*. Проверить
установленные версии: `apk list -I shaterd shater-core luci-app-shater`.
> **Роллинг или фиксация — это выбор URL в `shater.list`.** `apk-latest-<arch>`
> — движущийся указатель: каждый релизный прогон заменяет его ассеты, поэтому
> «поставил и забыл»: `apk update` сам видит новую сборку. `apk-vX.Y.Z-<arch>` —
> фиксация на конкретной сборке: роутер не получит ничего нового, пока
> `/etc/apk/repositories.d/shater.list` не отредактируют руками — на каждом
> роутере и на каждый релиз. На `mini_router` сознательно прописан
> версионированный URL, и ручная правка — его цена. Подробнее —
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §5.1.
> Версии пакетов CI берёт из git-тега (`vX.Y.Z` → `X.Y.Z-r1`, сборка вне тега →
> `X.Y.Z-r<коммитов+1>`), поэтому каждая новая сборка действительно видна
> менеджеру пакетов как новая. Подробности — `docs-shater/INSTALL.md` §2.1.
> Полные инструкции — ручная установка из `.apk`, фиксация версии
> (`apk-vX.Y.Z-<arch>`), совместимость с BananaWRT `25.12-mtk-vendor` — в
> [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
### Включение
shater ставится **инертным** (globals выключены), чтобы установка не рвала связь.
Настройте узлы/правила (через панель или `uci`), затем включите и примените:
```sh
uci set shater.globals.enabled=1
# Предохранитель: commit-confirm поставляется ВЫКЛЮЧЕННЫМ (confirm_timeout=0),
# и без этой строки apply ничем не подстрахован. 120 с — окно на проверку связи.
uci set shater.globals.confirm_timeout=120
uci commit shater
shaterd apply # применить и вооружить авто-откат на 120 с
shaterd confirm # подтвердить в пределах окна (отменяет авто-откат)
```
`shaterd apply` печатает, вооружил ли он что-нибудь, и почему нет: при
`confirm_timeout=0` он прямо говорит, что автоматического отката НЕТ. Оставить
ноль — сознательный выбор: тогда apply, отрезавший вам SSH/LuCI, придётся
откатывать руками.
`/etc/init.d/shater enable && /etc/init.d/shater start` поднимает демона под procd.
Кнопка «Открыть панель» в LuCI чеканит одноразовый токен и передаёт браузер в
панель (`:8088` по умолчанию).
После первого же применённого включённого fail-closed конфига появляется
**загрузочная защита**: `/etc/init.d/shater-armor` (START=21) грузит сохранённый
fail-closed план ещё до старта демона, закрывая те секунды между поднятием LAN и
первым apply, когда роутер форвардил трафик в WAN открытым. Форвардинг LAN→WAN
заблокирован, пока `shaterd` не применит конфиг; SSH, LuCI и панель при этом
доступны **намеренно** — цепочка вешается только на `forward`. Чем защита
вооружается, когда отказывается вооружаться и как её снять —
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) §4.
---
## The model in one line
## Сборка из исходников
`Node → Group (+balancer) → Chain (L1..Ln) → Egress` ·
`Rule (src / dst / list / geo → target + egress)` ·
`Inbound (multi-LAN tproxy)` · `Profile (WAN-mode)` · `List (domain/ip, auto-update)`.
Ship-артефакт — бинарь `shaterd` со вшитой SPA. Собирается вне дерева SDK скриптом
`scripts/build-shaterd.sh`:
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the diagrams (data flow,
traffic path, DNS, node lifecycle, the reliability state machine).
## Documentation
| Doc | What |
|-----|------|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Control-plane vs data-plane, data model, traffic/DNS flow, reliability state machine, roadmap |
| [`docs/CONFIG.md`](docs/CONFIG.md) | Full UCI schema of `/etc/config/shater` + the `xrayctl` CLI and ubus interface |
| [`docs/BUILD.md`](docs/BUILD.md) | Build the `.ipk`s (SDK / CI) and install on a router |
| [`docs/FEED.md`](docs/FEED.md) | Add the signed package feed; install / update / sign / pin |
## Repository layout
```
xrayctl/ Go control-plane: parse subscriptions, render xray JSON + nft + routing, apply
shater-core/ system package: procd init, hotplug, sysctl, fw4/routing glue, default config
luci-app-shater/ LuCI web app: client-side JS views + ucode/rpcd ubus backend
ci/ feed build + signing + Gitea release scripts
dist/ feed public key (shater-feed.pub)
examples/ sample /etc/config/shater + share-link fixtures
docs/ documentation
```sh
scripts/build-shaterd.sh [VERSION] [--fast]
```
## Building from source
Что он делает: (1) собирает панель — `cd panel && npm ci && npm run build` (Vite →
`panel/dist`); (2) копирует `panel/dist/*` в `shater/panel/webroot/`, откуда
`//go:embed` вшивает **реальную** SPA в бинарь; (3) кросс-собирает под `{amd64,
arm64}` с musl-static набором тегов (`CGO_ENABLED=0 GOOS=linux`), stripped/trimmed;
(4) прогоняет UPX `--lzma --best` (~42 МБ → ~8–11 МБ); (5) стейджит артефакт в
`openwrt/shaterd/files/` для пакета.
Cross-compiled through the OpenWrt SDK; CI builds a signed multi-arch feed on
every push. See [`docs/BUILD.md`](docs/BUILD.md). To ship a new version, bump
`PKG_RELEASE` in the relevant `Makefile` (or push a `vX.Y.Z` tag) and CI republishes.
Затем OpenWrt-пакеты из `openwrt/` собираются каноническим путём SDK. Детали
(набор build-тегов, почему `shaterd` — prebuilt-пакет, порядок CI) — в
[`docs-shater/INSTALL.md`](docs-shater/INSTALL.md).
## Hardware
### Проверка
`aarch64_cortex-a53` covers both target routers — Banana Pi **BPI-R3**
(MT7986 / Filogic 830) and **BPI-R4** (MT7988 / Filogic 880), both the OpenWrt
`mediatek/filogic` target. `x86_64` is the QEMU test VM.
```sh
bash scripts/run-tests.sh # полный гейт
bash scripts/run-tests.sh --no-race # без -race, для локального цикла
```
## License
Гейт гоняет весь набор **под теми же build-тегами, с которыми собирается
роутерный бинарь** (`scripts/router-tags.sh`), на Linux (с не-Linux хоста — сам
перезапускается в Docker), с `-race`, и содержит три машинные проверки против
молчаливого скипа: набор тегов может только ДОБАВЛЯТЬ тест-файлы; каждый пакет с
тестами обязан отчитаться `ok` поимённо; каждый `TestIntegration*` обязан выдать
вердикт по имени. Причина такая: до 2026-07 релизный тракт не гонял почти ничего
— 115 тест-файлов из 116 под `shater/**` в CI не исполнялись ни разу.
[GPL-2.0-or-later](LICENSE). The xray-core engine is a separate, unmodified
runtime dependency with its own license.
Отдельно `scripts/check-router-tags.sh` проверяет, что ни одна заявленная в
`FEATURES.md` фича не потеряла нужный ей build-тег.
Зелёный гейт — необходимое, но не достаточное условие: он не видит стыков с
ядром, procd и nftables. Это проверяется на стенде (см.
[`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md)).
---
## Структура репозитория
Репозиторий — это оверлей продукта **shater** поверх дерева форка движка
**sing-box-lx** (конфликт-фри: движок в апстрим-каталогах, продукт в своих).
| Путь | Что это |
|------|---------|
| `shater/` | Go: control-plane, DNS-фильтр, агрегатор статистики, хост движка |
| `panel/` | Админ-SPA (Vite + React + TS) и её Go-сервер |
| `openwrt/` | Пакеты: `shaterd`, `shater-core`, `luci-app-shater` |
| `docs-shater/` | Документация продукта (см. таблицу ниже) |
| `scripts/` | `build-shaterd.sh` — сборка ship-артефакта |
| `ci/` | Скрипты сборки apk-фида и релизов (SDK, EC-подпись, Gitea API) |
| `.gitea/workflows/` | `release.yml` — CI: сборка пакетов + подписанный apk-фид |
| `SPECS/` | Конституция форка движка и спеки (Spec Kit) |
| `docs-lx/` | Справочник конфигурации фич движка (`lx-config.md`, `.ru.md`) |
| `lx-test/`, `submodules/` | Примеры конфигов движка и submodule AmneziaWG-рантайма |
| `docs/`, `mkdocs.yml` | **Апстрим** документация sing-box (mkdocs) — как есть |
| `adapter/ cmd/ dns/ route/ option/ protocol/ transport/ …` | Дерево движка sing-box-lx |
---
## CI и релизы
CI на **Gitea Actions** (`.gitea/workflows/release.yml`) собирает все 4 пакета и
публикует **подписанные фиды**:
- **apk (25.12+)** — единственный формат: **по релизу на арку**, индекс
`packages.adb` подписан EC-ключом (публичный `dist/shater-apk.pem`; секрет — в
Gitea-secret `KEY_APK`).
Триггеры: push тега **`vX.Y.Z`** → версионный релиз `apk-vX.Y.Z-<arch>`;
`workflow_dispatch` → только роллинг. Роллинг `apk-latest-<arch>` обновляется
**на каждом прогоне**, включая теговый, и после публикации проверяется через API:
в нём обязаны лежать наши три пакета ровно собранной версии и ни одного ассета
другой версии. Публикация — через Gitea API (`ci/gitea-release.sh`). Ключ
**никогда не перегенерируется** — это инвалидировало бы доверие на всех
развёрнутых роутерах.
---
## Связь с upstream и движок
shater вкомпилирует **форк движка sing-box-lx** — тонкий downstream апстрима
[SagerNet/sing-box](https://github.com/SagerNet/sing-box), добавляющий набор
клиентских фич (XHTTP, AmneziaWG 2.0, MASQUE, расширения наблюдаемости) за
build-тегами и живущий **ребейзом на каждый upstream-тег, а не merge**. Это набор
самого форка, а не shater: MASQUE/CONNECT-IP мы намеренно **не регистрируем** —
`shater/generate` его не порождает, а отказ от него и остального незадействованного
зоопарка экономит ~6 МБ бинаря и столько же RAM на роутере (`shater/registry`). Форк
разрабатывается по Spec Kit; неизменяемые принципы — в
[`SPECS/CONSTITUTION.md`](SPECS/CONSTITUTION.md), справочник фич движка — в
[`docs-lx/lx-config.ru.md`](docs-lx/lx-config.ru.md).
История: **v0.1** (движок на xray-core, полностью рабочая и VM-проверенная версия)
сохранена на ветке **[`v0.1`](../../src/branch/v0.1)**. v0.2 схлопнула runtime в
один форкнутый бинарь.
---
## Документация
| Документ | О чём |
|----------|-------|
| [`docs-shater/CONTEXT.md`](docs-shater/CONTEXT.md) | **Начните здесь** — контекст проекта, история v0.1→v0.2, testbed/инфра |
| [`docs-shater/INSTALL.md`](docs-shater/INSTALL.md) | Сборка ship-артефакта и установка apk-фида (роллинг/фиксация) |
| [`docs-shater/ARCHITECTURE.md`](docs-shater/ARCHITECTURE.md) | One-binary дизайн, auth-handoff, data/DNS/apply-потоки (диаграммы) |
| [`docs-shater/FEATURES.md`](docs-shater/FEATURES.md) | Полный список фич с тегами MVP/T1/T2 |
| [`docs-shater/ROADMAP.md`](docs-shater/ROADMAP.md) | Фазовый план |
| [`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) | Почему sing-box, почему форк, split панели, лицензия |
| [`docs-shater/DESIGN.md`](docs-shater/DESIGN.md) | Визуальная система панели — «Faceplate», токены, компоненты |
| [`docs-shater/PORTING.md`](docs-shater/PORTING.md) | Порт проверенных кусков из v0.1 |
Индекс папки — [`docs-shater/README.md`](docs-shater/README.md).
---
## Оборудование
Арка `aarch64_cortex-a53` покрывает Banana Pi **BPI-R3** (MT7986/Filogic 830) и
**BPI-R4** (MT7988/Filogic 880) — оба таргет OpenWrt `mediatek/filogic`. `x86_64` —
QEMU-стенд для тестов.
---
## Лицензия
[GPL-3.0](LICENSE) — как у upstream sing-box. Подробности — в
[`docs-shater/DECISIONS.md`](docs-shater/DECISIONS.md) (D6). Неофициальный форк, не
аффилирован с SagerNet.
+15 -132
View File
@@ -1,136 +1,19 @@
# shater
# shater — этот файл переехал
**Менеджер прозрачного xray-прокси для OpenWrt** — подписки, policy-routing,
fail-closed kill-switch и современный LuCI-интерфейс. Как passwall2, только чище,
быстрее и честнее в отказах.
Лицо этого репозитория — продукт **shater** (управляемый интернет-шлюз для
роутеров на OpenWrt). Основной README на русском — **[README.md](README.md)**;
краткая английская версия — **[README.en.md](README.en.md)**.
[![License: GPL-2.0-or-later](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE)
![OpenWrt 24.10](https://img.shields.io/badge/OpenWrt-24.10-informational.svg)
![targets: x86_64 · aarch64_cortex-a53](https://img.shields.io/badge/targets-x86__64%20%C2%B7%20aarch64__cortex--a53-brightgreen.svg)
Раньше здесь лежал README форка движка **sing-box-lx**, который shater
вкомпилирует в свой бинарь. Документация именно движка-форка живёт в его слое:
> 🇬🇧 [English version — README.md](README.md)
- **[docs-lx/lx-config.ru.md](docs-lx/lx-config.ru.md)** — справочник конфигурации
фич движка (XHTTP, AmneziaWG 2.0, MASQUE).
- **[SPECS/CONSTITUTION.md](SPECS/CONSTITUTION.md)** — конституция тонкого форка
(принципы, build-tag изоляция, ребейз-модель).
- **[SPECS/README.md](SPECS/README.md)** — формат задач Spec Kit.
- Апстрим-README самого sing-box —
[на GitHub](https://github.com/Leadaxe/sing-box-lx).
shater превращает роутер на OpenWrt в сетевой прокси-шлюз: трафик LAN (TCP **и**
UDP) прозрачно заворачивается в xray через TPROXY, разделяется по домену / гео /
клиенту, без DNS-утечек — всё настраивается из веб-интерфейса LuCI и применяется
атомарно с авто-откатом, так что кривой конфиг не оставит роутер без связи.
Движок — **xray-core** (немодифицированный, внешняя зависимость). Вся логика в
`xrayctl` — небольшом control-plane на Go, который читает desired-state в UCI и
рендерит боевой xray JSON, таблицу nftables и policy-routing.
---
## Возможности
**Проксирование и маршрутизация**
- Прозрачный TPROXY-прокси для нескольких LAN-интерфейсов (TCP + UDP).
- Подписки (VLESS / VMess / Trojan / Shadowsocks / WireGuard·AmneziaWG), форматы
Clash / sing-box / Xray-JSON, HAPP-эмуляция при загрузке, стабильная
идентичность нод между обновлениями.
- Группы нод с балансировщиком и observatory (least-ping / failover / …),
multi-hop цепочки (L1→Ln), выбор egress на уровне правила.
- Правила first-match по источнику (IP/CIDR/MAC/интерфейс/зона), назначению
(домен / суффикс / keyword / geosite), переиспользуемым спискам доменов/IP,
порту и протоколу.
**Надёжность («железно»)**
- Fail-closed kill-switch: мёртвая или нераспарсенная группа резолвится в `block`,
а не в тихую прямую утечку; IPv6 дропается, когда выключен.
- Атомарный apply с валидацией `xray -test` + `nft -c` и commit-confirm
авто-откатом к последнему рабочему конфигу.
- Идемпотентный reconcile из hotplug/boot под flock, который перезапускает движок
только при реальном изменении сгенерированного конфига.
- Management-bypass (SSH/LuCI/LAN) всегда в обход — заблокировать себе доступ нельзя.
**DNS**
- Перехват :53 через dnsmasq, выбор резолвера по домену, DoH/DoT-резолверы,
режим FakeIP, populate nftset для маршрутизации и блокировка клиентского
DoT/DoH, чтобы не обходили фильтрацию.
**Интерфейс и эксплуатация**
- Кастомный LuCI-апп в стиле «приборной панели»: живой Signal Path на Overview,
переключатель Simple/Advanced и мастер быстрой настройки (вставил ссылку → готово).
- Живая статистика трафика по клиентам / нодам / правилам.
- Бэкап/восстановление конфига, именованные профили и WAN-mode профили
(условные оверрайды, напр. SIM-аплинк → другой egress).
---
## Установка
Каждый push публикует **подписанный opkg-фид** в релизе, поэтому роутер добавляет
его один раз и дальше обновляется обычным `opkg`. opkg фильтрует по архитектуре,
так что одни и те же команды работают на любом устройстве (BPI-R3/R4 →
`aarch64_cortex-a53`, x86-64 → `x86_64`):
```sh
# 1. один раз — доверяем публичному ключу фида (имя файла = отпечаток ключа)
wget -O /etc/opkg/keys/5ac4b177689cb8e0 \
https://git.qomar.pw/omar/shater/releases/download/latest/shater-feed.pub
# 2. добавляем фид и ставим
echo "src/gz shater https://git.qomar.pw/omar/shater/releases/download/latest" >> /etc/opkg/customfeeds.conf
opkg update
opkg install luci-app-shater # тянет xrayctl + shater-core
```
`xray-core`, `dnsmasq-full` и `kmod-nft-*` берутся из собственного фида роутера
(должны совпадать с его ядром). Полный гайд — установка ключа, подпись, пиннинг
версии, заметки про apk (OpenWrt 25.x), разбор проблем — в [`docs/FEED.md`](docs/FEED.md).
## Первый запуск
Открой **LuCI → Services → Shater (xray)** и запусти мастер: вставь ссылку
подписки, выбери «всё» или «всё кроме локального/RU», нажми один раз. Он загрузит
ноды, выберет самый быстрый сервер, завернёт LAN через него и запустит движок,
после чего проверит exit-IP, что туннель жив.
---
## Модель в одну строку
`Node → Group (+balancer) → Chain (L1..Ln) → Egress` ·
`Rule (src / dst / list / geo → target + egress)` ·
`Inbound (multi-LAN tproxy)` · `Profile (WAN-mode)` · `List (домены/ip, автообновление)`.
Схемы (потоки данных, путь трафика, DNS, жизненный цикл ноды, state machine
надёжности) — в [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Документация
| Документ | О чём |
|----------|-------|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Control-plane vs data-plane, модель данных, потоки трафика/DNS, state machine надёжности, роадмап |
| [`docs/CONFIG.md`](docs/CONFIG.md) | Полная UCI-схема `/etc/config/shater` + CLI и ubus-интерфейс `xrayctl` |
| [`docs/BUILD.md`](docs/BUILD.md) | Сборка `.ipk` (SDK / CI) и установка на роутер |
| [`docs/FEED.md`](docs/FEED.md) | Подключение подписанного фида; install / update / подпись / пиннинг |
## Структура репозитория
```
xrayctl/ control-plane на Go: парсинг подписок, рендер xray JSON + nft + routing, apply
shater-core/ системный пакет: procd-init, hotplug, sysctl, glue fw4/routing, дефолт-конфиг
luci-app-shater/ LuCI-апп: клиентские JS-views + ucode/rpcd ubus-бэкенд
ci/ сборка фида + подпись + скрипты релиза Gitea
dist/ публичный ключ фида (shater-feed.pub)
examples/ примеры /etc/config/shater + фикстуры share-link
docs/ документация
```
## Сборка из исходников
Кросс-компиляция через OpenWrt SDK; CI собирает подписанный мультиарк-фид на
каждый push. См. [`docs/BUILD.md`](docs/BUILD.md). Чтобы выпустить новую версию —
бампни `PKG_RELEASE` в нужном `Makefile` (или запушь тег `vX.Y.Z`), CI
перевыпустит.
## Железо
`aarch64_cortex-a53` покрывает оба целевых роутера — Banana Pi **BPI-R3**
(MT7986 / Filogic 830) и **BPI-R4** (MT7988 / Filogic 880), оба target OpenWrt
`mediatek/filogic`. `x86_64` — тестовая QEMU-VM.
## Лицензия
[GPL-2.0-or-later](LICENSE). Движок xray-core — отдельная немодифицированная
зависимость со своей лицензией.
> Файл оставлен как указатель, чтобы у репозитория был один основной русский
> README (`README.md`), а не два конкурирующих.
@@ -0,0 +1,45 @@
# IMPLEMENTATION_REPORT — 001 FORK_BOOTSTRAP
**Дата:** 2026-06-09 · **Статус:** Complete · **База:** upstream `v1.13.13`
## Что сделано
Заложен скелет downstream'а `sing-box-lx`: репозиторий, ветка, воспроизводимая сборка drop-in бинаря с версией `-lx`, CI-скелет. Фич-кода (XHTTP/AWG) нет — это 002/003.
## Изменённые / новые файлы
**Новые (зона касания upstream = ноль):**
- `Makefile.lx` — `LX_TAGS` (канонический набор), `LX_VERSION = <upstream>-lx.<N>`, цели `lx-build` / `lx-version` / `lx-check`. Версия штампуется **только ldflags** (`-X …constant.Version`), output — `sing-box`.
- `.github/workflows/lx-ci.yml` — build(lx tags) → version → `go vet` → `sing-box check` (linux/amd64; полная матрица — в 004).
- `lx-test/config/minimal.json` — валидный конфиг для `check` (mixed-in + direct-out). Положен в `lx-test/`, **не** в upstream `test/` (там отдельный Go-модуль).
- `AGENTS.md` — указатель для агентов (force-add: upstream его `.gitignore`-ит; новый файл → нулевой конфликт при ребейзе).
- `SPECS/**` — Spec Kit (CONSTITUTION, IMPLEMENTATION_PROMPT, README, задачи 001–004).
**Правок upstream-файлов: 0.** `constant/version.go`, `Makefile`, `.gitignore` — не тронуты.
## Проверки (DoD)
```
$ make -f Makefile.lx lx-version → 1.13.13-lx.1
$ make -f Makefile.lx lx-build → ./sing-box (28 MB)
$ ./sing-box version → 1.13.13-lx.1
Tags: …,with_xhttp,with_awg (пока no-op — кода нет)
$ ./sing-box check -c lx-test/config/minimal.json → OK
$ go vet ./constant/... → OK
```
## Решения по ходу
- **`Makefile.lx` вместо правки upstream `Makefile`** — отдельный файл = нулевая зона касания (CONSTITUTION § 3.2). Цели вызываются `make -f Makefile.lx <target>`.
- **Версия через ldflags** — upstream и сам так делает (`PARAMS = -ldflags "-X …constant.Version=…"`), поэтому `constant/version.go` не правим.
- **Email privacy** — коммиты переведены на `247031499+Leadaxe@users.noreply.github.com` (репо-локальный `user.email`), иначе GitHub отклоняет push.
- **Sample-конфиги** — каталог `lx-test/config/` (upstream `test/` — обособленный Go-модуль со своим `config/`).
## Зона касания upstream для будущих ребейзов
**Нулевая** (всё в новых файлах). Первый реальный `// lx:` дифф появится в 002 (диспетчер транспортов) и 003 (`go.mod`, wireguard-endpoint).
## Вне скоупа (передано дальше)
- Полная CI-матрица, авто-ребейз, релизы → **004**.
- Прунинг зеркальных веток origin — опционально, отложено.
+41
View File
@@ -0,0 +1,41 @@
# PLAN: 001 — FORK_BOOTSTRAP
## 1. Канонический набор build-тегов lx
Единый источник истины (использовать в Makefile, CI, DoD):
```
with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_xhttp,with_awg
```
Хранить в `Makefile` (переменная `LX_TAGS`) и продублировать в `SPECS/CONSTITUTION.md` при изменениях.
> **Обновлено в §004:** набор расширен до полного upstream feature-set (`release/DEFAULT_BUILD_TAGS`) + `with_purego` + наши две фичи, с обязательным `-checklinkname=0` в `LX_LDFLAGS`. Актуальный источник истины — `Makefile.lx` (`make -f Makefile.lx lx-print-tags`) и `SPECS/004`.
## 2. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `Makefile` | new (или дополнение) | Цель `lx-build`: `go build -tags "$(LX_TAGS)" -ldflags "$(LX_LDFLAGS)" -o sing-box ./cmd/sing-box`; переменные `LX_TAGS`, `VERSION=…-lx.$(LX_BUILD)` |
| `.github/workflows/lx-ci.yml` | new | Скелет: checkout → setup-go → `make lx-build` → `go vet` → `./sing-box check -c lx-test/config/xhttp_smoke.json` (заглушка появится в 002) |
| `lx-test/config/*.json` | new | Sample-конфиги для `sing-box check` (минимальный валидный, без фич — для 001) |
| `SPECS/001-.../IMPLEMENTATION_REPORT.md` | new | Отчёт |
> Версия: upstream хранит строку версии в `constant/version.go` (или собирается через ldflags в `cmd/sing-box`). Проверить фактический механизм и **задавать `-lx` суффикс через `-ldflags -X`**, не правя `constant/version.go` напрямую (иначе лишний `// lx:` дифф на каждый ребейз). Если upstream не поддерживает ldflags-override — тогда минимальная `// lx:` правка в `constant/version.go`.
## 3. Зона касания upstream
- В идеале **ноль** правок upstream-файлов (всё через новые файлы + ldflags).
- Допустимый минимум: одна `// lx:` строка в `constant/version.go`, если ldflags-override невозможен.
## 4. Порядок работ
1. Проверить механизм версии upstream (`constant/version.go`, `cmd/sing-box`).
2. `Makefile` с `LX_TAGS`/`LX_LDFLAGS`/`lx-build`.
3. Sample-конфиг + CI-скелет.
4. Прогнать DoD, заполнить отчёт.
## 5. Риски
- Версионный механизм upstream может не принимать ldflags-override — fallback на `// lx:` правку.
- `with_xhttp`/`with_awg` как несуществующие теги не ломают сборку (Go игнорирует неизвестные build-теги) — но файлов с этими тегами пока нет, это нормально.
+49
View File
@@ -0,0 +1,49 @@
# SPEC: 001 — FORK_BOOTSTRAP
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Заложить скелет downstream'а `sing-box-lx`: remotes, рабочая ветка, build-теги, версия с `-lx`, конвенция маркеров `// lx:` и шаблон гейтинга. После задачи репозиторий — корректный «upstream + ноль фич», готовый принимать XHTTP (002) и AWG2 (003).
---
## 1. Проблема / контекст
`Leadaxe/sing-box-lx` — форк-зеркало upstream (родословная `SagerNet/sing-box` сохранена). Нужна повторяемая инфраструктура downstream'а, при которой каждое будущее изменение изолировано и ребейзопригодно (см. CONSTITUTION § 3).
## 2. Требования
### 2.1 Git
- `origin = Leadaxe/sing-box-lx`, `upstream = SagerNet/sing-box`. **(сделано)**
- Ветка `lx` базируется на стабильном теге `v1.13.13`. **(сделано)**
- Default branch на GitHub = `lx`; шумные зеркальные ветки (`dependabot/*`, `dev-*`, `copilot/*`) — вне внимания (можно удалить с origin, не обязательно).
### 2.2 Build-теги
- Ввести **`with_xhttp`** и **`with_awg`** как опознаваемые теги проекта (фактический код — в 002/003). Зафиксировать **канонический набор тегов сборки lx** в одном месте (см. PLAN), переиспользуемый в DoD и CI.
- Инвариант: без `with_xhttp`/`with_awg` бинарь ведёт себя как upstream.
### 2.3 Версия
- `sing-box version` должен печатать суффикс **`-lx.N`** (напр. `1.13.13-lx.1`).
- Суффикс задаётся при сборке (ldflags), не хардкодом в исходниках upstream (минимальный дифф).
### 2.4 Конвенции
- Документировать и применять маркеры правок upstream-файлов: `// lx:begin <feat>` … `// lx:end <feat>`.
- Принять шаблон гейтинга `include/<feat>.go` (+ `<feat>_stub.go`), как у upstream `include/wireguard.go`.
### 2.5 CI-скелет
- Минимальный workflow: сборка `lx` с каноническим набором тегов под linux/amd64 + `go vet` + `sing-box check` на sample-конфиге. (Полная матрица и авто-ребейз — в 004.)
## 3. Критерии приёмки
- `go build ./...` (без тегов) — ок.
- `go build -tags "<канон lx>" ./cmd/sing-box` — ок (теги пока no-op).
- Собранный бинарь: `./sing-box version` содержит `-lx.`.
- Имя выходного файла — `sing-box`.
- CI-скелет зелёный на push в `lx`.
## 4. Вне скоупа
- Любой код XHTTP/AWG (это 002/003).
- Полная CI-матрица, релизы, авто-ребейз (это 004).
+26
View File
@@ -0,0 +1,26 @@
# TASKS — 001-FORK_BOOTSTRAP
## Git / GitHub
- [x] `origin` = Leadaxe/sing-box-lx, `upstream` = SagerNet/sing-box
- [x] Ветка `lx` от тега `v1.13.13`
- [x] Default branch на GitHub → `lx`, push `lx`
- [x] Коммиты переведены на GitHub noreply-email (email privacy)
- [ ] (опц., отложено) Удалить шумные зеркальные ветки с origin (`dependabot/*`, `copilot/*`, `dev-*`)
## Build / Version
- [x] Изучён механизм версии upstream: `constant/version.go` = `var Version = "unknown"`, штампуется через `-ldflags -X …constant.Version`
- [x] **`Makefile.lx`** (новый файл, ноль правок upstream-Makefile): `LX_TAGS`, `LX_LDFLAGS`, цель `lx-build` (output `sing-box`)
- [x] Версия печатает `-lx.N` через ldflags (`1.13.13-lx.1`); `constant/version.go` **не тронут**
## Конвенции
- [x] Маркеры `// lx:begin/end` зафиксированы в CONSTITUTION § 3.3
- [x] Шаблон `include/<feat>.go` + `<feat>_stub.go` подтверждён на upstream `include/wireguard.go`
## CI-скелет
- [x] `.github/workflows/lx-ci.yml`: build(lx tags) + version + vet + `sing-box check`
- [x] `lx-test/config/minimal.json` (валидный конфиг без фич)
## Закрытие
- [x] DoD: `lx-build` ок, `version` → `-lx.1`, `check` OK, `go vet` OK; имя бинаря `sing-box`
- [x] IMPLEMENTATION_REPORT.md
- [x] Папка → статус `C`
@@ -0,0 +1,142 @@
# IMPLEMENTATION_REPORT — 002 XHTTP_CLIENT_TRANSPORT
---
## v2 — полная клиентская поддержка параметров (2026-06-29)
**Статус:** реализация code-complete + все проверки зелёные; **дефолтный путь лайв-подтверждён на реальных нодах** (4 живых XHTTP-сервера, packet-up + stream-one/reality, скачивание 1 МБ); **лайв obfs/placement** — остаётся открытым TODO (нужен сервер с такой настройкой).
**База:** upstream v1.14.0-alpha.* (`lx-1.14`) · **Ветка реализации:** `lx-1.14-xhttp-full` · **Спека:** [SPEC.md](SPEC.md) + [PARAM_MAP.md](PARAM_MAP.md).
### Что сделано
Расширил клиент до **полной клиентской поддержки** расширенных XHTTP-параметров Xray / sing-box-extended,
оставаясь client-only и без вендоринга Xray. Основание — глубокий аудит исходников XTLS/Xray-core
(`transport/internet/splithttp`) и shtorm-7/sing-box-extended (`transport/v2rayxhttp` + `option`), с
adversarial-верификацией каждого факта. Карта всех 16 полей — в [PARAM_MAP.md](PARAM_MAP.md).
**Реализованы (12 клиентских + 2 tuning-бонуса):**
- session/seq **placement** (path/query/header/cookie) + ключи (`session_key`/`seq_key`);
- uplink-data **placement** (body/auto/header/cookie, chunked base64) + `uplink_data_key` + `uplink_chunk_size`;
- `uplink_http_method` (upper-case; GET только в packet-up — вне auto-fallback на POST + warning, не ошибка);
- **X-Padding obfs**: `x_padding_obfs_mode` + placement (cookie/header/query/queryInHeader) +
`x_padding_key`/`x_padding_header` + method (`repeat-x` и `tokenish` с HPACK-Huffman-тюнингом);
- packet-up tuning: `sc_max_each_post_bytes` (разбиение), `sc_min_posts_interval_ms` (троттлинг).
**Server-only (accept-but-ignore, в struct, не используются клиентом):** `server_max_header_bytes`,
`no_sse_header`, `sc_max_buffered_posts`, `sc_stream_up_server_secs`. Клиент не инспектирует Content-Type
ответа, поэтому корректен и с SSE-заголовком, и без него.
**Файлы:**
- `option/v2ray_xhttp.go` — +20 полей в `V2RayXHTTPOptions` (всё новый код, нулевое касание upstream).
- `transport/v2rayxhttp/meta.go` (новый) — placement-движок (`applyMeta`), нормализация/валидация
(`normalizeMeta`, mode-gate'ы, дефолты по placement), uplink-data сборка (`applyUplinkData`/`chunkEncoded`).
- `transport/v2rayxhttp/xpadding.go` (новый) — obfs-движок (`applyXPadding`), генераторы
`repeat-x`/`tokenish` (`generateTokenishPaddingBase62` на `crypto/rand` + `golang.org/x/net/http2/hpack`).
- `transport/v2rayxhttp/client.go` — `Client.meta`/`paddingRange`; `NewClient` зовёт `normalizeMeta`;
`requestURL`/`padding` заменены на `baseURL` + `newRequest(ctx, method, sessionID, seqStr, body)`.
- `transport/v2rayxhttp/conn.go` — dial-функции под новую сигнатуру; `packetConn.Write` разбивает по
`sc_max_each_post_bytes`, троттлит по `sc_min_posts_interval_ms`, раскладывает payload по placement.
- `transport/v2rayxhttp/xhttp_test.go` (новый) + обновлённый `url_test.go` — 16 тест-функций.
- `lx-test/config/xhttp_obfs_full.json` — VLESS+Reality+xhttp со всеми 20 новыми полями.
### Архитектурное решение: range-поля строкой
`uplink_chunk_size`, `sc_*` (range) представлены строкой `"min-max"` (как существующий `x_padding_bytes`),
а **не** `*badoption.Range[int]`: в нашем `sing` badoption нет типа `Range`, а строковая форма уже есть и
парсится одним хелпером (`parseRange`). Это отступление от proto-типа Xray зафиксировано в SPEC §4.1.
### Проверки (все зелёные)
- `make -f Makefile.lx lx-build` → ок.
- `./sing-box check -c` на `xhttp_reality.json`, `xhttp_auto_reality.json`, `xhttp_obfs_full.json` → **все PASS**
(полный obfs-конфиг проходит реальный `box.New`-путь — проверка против badjson-схлопывания слайсов).
- `go test -tags with_xhttp ./transport/v2rayxhttp/` → **16/16 PASS** (включая нулевую регрессию дефолта,
все placement'ы, uplink-data сборку/разборку, 4×2 obfs-комбинации, tokenish HPACK-длину, валидацию).
- `go vet -tags with_xhttp ./...` → чисто (2 предсуществующих unsafe.Pointer в daemon/libbox — не наши).
- `gofmt -l` → чисто. `go build ./...` (tagged/untagged) → ок. `go test` (untagged) → ок.
- Негатив: бинарь **без** `with_xhttp` отвергает obfs-конфиг (`unknown transport type: xhttp`).
### Нулевая регрессия дефолта
Все новые поля имеют дефолты, сохраняющие v1-поведение байт-в-байт: obfs off → `x_padding` в Referer;
session/seq в path; payload в body; метод POST. Тест `TestDefaultLegacyPadding` это фиксирует.
### Лайв-верификация на реальных нодах (2026-06-30)
Просканировал публичные подписки `igareck/vpn-configs-for-russia` (6 файлов), извлёк и
дедуплицировал **10 уникальных XHTTP-нод**, прогнал каждую через наш бинарь (`with_xhttp`)
с mixed-inbound и реальным трафиком. **4 ноды живые** — все скачали 1 МБ, трафик вышел через IP
сервера:
| Нода | Режим (резолв) | 1 МБ | Exit-IP |
|------|----------------|------|---------|
| 92.38.139.63:9891 (plain) | packet-up | ✅ 0.85s | 144.31.218.159 |
| hu99.bearbeer.digital:443 (reality) | **stream-one** | ✅ 0.38s | 37.221.210.58 |
| 82.202.142.216:9881 (plain) | packet-up | ✅ 8.0s | 85.158.57.210 |
| bez3.stream-room.com:8080 (reality) | **stream-one** | ✅ 0.22s | 144.31.178.8 |
**Главное:** две reality-ноды резолвятся `auto`→**stream-one** и работают живьём — это **закрывает
открытый с задачи 011 TODO** (stream-one ранее был принят только на синтетике). Подтверждено на двух
независимых серверах с реальной загрузкой.
Остальные 6 нод мертвы **по причинам на стороне сервера**, не нашего кода: `504 Gateway Timeout`,
`frame too large / HTTP/1.1 header` (нода не H2/XHTTP), `connection reset`, TLS/reality handshake hang.
Во всех случаях наш транспорт корректно строит и шлёт запрос и выдаёт диагностируемую ошибку.
### Остаточные пробелы
1. **Лайв obfs/placement-режима** — дефолтный путь (packet-up + stream-one) лайв-подтверждён выше, но
не-дефолтные obfs/placement-комбинации (`x_padding_obfs_mode=true`, header/cookie placement, tokenish)
требуют сервера, *настроенного* на них; ни одна публичная нода так не настроена. Покрыто unit-тестами +
`check`, лайв — остаётся TODO.
2. **HTTP/3 (`alpn=h3`)** — клиент HTTP/2-only; h3-only ноды не обслуживаются (вне SPEC 002, отдельная
задача). Задокументировано в [URL_PARSING.md](URL_PARSING.md).
3. Зависимость `golang.org/x/net/http2/hpack` для tokenish — уже транзитивно в go.mod (HTTP/2 transport).
4. xmux/переиспользование соединений — вне скоупа (см. SPEC §8).
---
## v1 (история)
**Дата:** 2026-06-09 · **Статус:** Complete — lean-native клиент, **проверен живым Xray/3x-ui сервером** (packet-up/auto) · **База:** `v1.13.13`
## Что сделано
Клиентский XHTTP-транспорт, подход **lean-native** (на примитивах sing-box, минимум зависимостей) — реализован многоагентным workflow в изолированном worktree, влит в `lx` (коммиты `2d97ff56` registry/const + `d1b434fc` транспорт).
**Файлы (новые, если не указано иное):**
- `transport/v2ray/registry.go`, `// lx` в `transport/v2ray/transport.go`, константа в `constant/v2ray.go` — registry-рефактор (ранее).
- `option/v2ray_xhttp.go` — тип `V2RayXHTTPOptions` (Host, Path, Mode, Headers, padding).
- `option/v2ray_transport.go` — **единственная upstream-правка** (// lx): поле `XHTTPOptions` + xhttp-case в Marshal/Unmarshal.
- `transport/v2rayxhttp/{client,conn,register}.go` — клиент; `register.go` под `//go:build with_xhttp`.
- `include/v2rayxhttp.go` (`//go:build with_xhttp`) — blank-import для запуска `init()`.
- `lx-test/config/xhttp_reality.json` — VLESS+xhttp+reality для `check`.
## Проверки (DoD = compiles + check)
- `make -f Makefile.lx lx-build` → ок; `./sing-box check -c lx-test/config/xhttp_reality.json` → **pass**.
- `go vet` (lx-теги) по `transport/v2rayxhttp`, `option`, `transport/v2ray` → чисто; `go build ./...` без тегов → ок; `gofmt` чисто.
- Негатив: бинарь **без** `with_xhttp` отвергает xhttp-конфиг (`unknown transport type: xhttp`). Невалидный mode → `v2ray-xhttp: unknown mode`. Все 4 mode конструируются.
## Зона касания upstream (ребейз)
Ровно **1 файл**: `option/v2ray_transport.go` (3 правки в // lx-маркерах). Реестр и весь пакет `v2rayxhttp` — новые файлы, конфликтов не дают.
## Лайв-тест (реальный Xray/3x-ui XHTTP-сервер)
Проверено против VLESS + Reality + `type=xhttp` ноды (панель 3x-ui):
- ✅ **packet-up** (и `auto` → packet-up): handshake + DNS + HTTPS (example.com 200) + скачивание 2 МБ @ ~2.1 МБ/с — трафик выходит через IP сервера.
- ❌ **stream-one**: `unknown version` — баг при чтении downlink-ответа (выбирается только явно). → **Исправлено в задаче 011** (корень: stream-one должен слать голый путь без sessionId; auto+reality → stream-one). Принято на синтетике, лайв отложен.
**Ключевой фикс (по исходникам Xray hub.go/config.go + лайв):** padding кладётся как `x_padding=<нули>` в **query внутри заголовка `Referer`** (Xray default `PlacementQueryInHeader`, key `x_padding`), а **не** отдельным `X-Padding`. Сервер валидирует длину `x_padding` (дефолт 100–1000) и без неё отвечает **400 Bad Request**. Плюс `mode=auto` переключён на **packet-up**. Коммит `5a398a5e`. Также ранее: `sessionId` → UUID-формат, path-layout `<path>/<sessionId>[/<seq>]` сверены.
## Остаточные пробелы
1. ~~**stream-one** — баг framing downlink (`unknown version`)~~ → **исправлено в 011** (голый путь без sessionId; `auto`+reality → stream-one). Лайв-подтверждение — открытый TODO в 011.
2. **packet-up** без xmux/переиспользования соединений; **stream-up** не лайв-тестился.
3. `x_padding_bytes` — строка «min-max» (нет Range-типа в badoption); дефолт 100–1000.
## Дальше
- Лаунчер: маппинг `type=xhttp` (его задача 023 сейчас маппит в `httpupgrade`) → реальный xhttp-транспорт.
- Опционально: починить stream-one, добавить xmux.
@@ -0,0 +1,364 @@
# XHTTP PARAM_MAP — карта параметров Xray XHTTP («splithttp»)
> Сопровождающий документ к [SPEC.md](SPEC.md) (002 — XHTTP_CLIENT_TRANSPORT).
> Детальная карта **«какой параметр что делает в Xray»** по всем расширенным полям XHTTP,
> собранная глубоким аудитом исходников **XTLS/Xray-core** (`transport/internet/splithttp/*`,
> ветка `main`) и форка **shtorm-7/sing-box-extended** (`transport/v2rayxhttp/*` +
> `option/v2ray_transport.go`, ветка `extended`), с adversarial-верификацией каждой карточки
> против исходника.
## 0. Как читать эту карту
- **Xray-поле** — имя в `config.proto` / сгенерированном `config.pb.go` (camelCase) и Go-поле.
- **JSON (наш)** — рекомендованное `snake_case` имя в конфиге sing-box-lx (как в `sing-box-extended`).
- **Клиент?** — потребляет ли поле **клиентская** сторона. Мы строим **client-only** транспорт,
поэтому это главный столбец: server-only поля документируются, но **не реализуются**.
- **Тир** — `core` (нужно для базовой совместимости), `obfs` (анти-DPI), `tuning` (производительность
packet-up), `server-only` (нереализуемо на клиенте).
- Цитаты дают **функцию/символ**, а не номер строки: номера строк в обоих апстримах дрейфуют между
ревизиями (верификация это подтвердила), а имена функций стабильны.
### Сводная таблица (16 полей из списка NekoBox+/sing-box-extended)
| # | Параметр (camelCase) | JSON (наш snake_case) | Клиент? | Тир | Одной строкой |
|---|----------------------|------------------------|:-------:|-----|----------------|
| 1 | `sessionPlacement` | `session_placement` | ✅ | core | Куда класть session id: path/query/header/cookie |
| 2 | `sessionKey`* | `session_key` | ✅ | core | Имя ключа для session id (когда не path) |
| 3 | `seqPlacement` | `seq_placement` | ✅ | core | Куда класть номер пакета (packet-up): path/query/header/cookie |
| 4 | `seqKey` | `seq_key` | ✅ | core | Имя ключа для seq (когда не path) |
| 5 | `uplinkDataPlacement` | `uplink_data_placement` | ✅ | obfs | Куда класть payload upload (packet-up): body/header/cookie/auto |
| 6 | `uplinkDataKey` | `uplink_data_key` | ✅ | obfs | Базовое имя header/cookie для chunked-payload |
| 7 | `uplinkChunkSize` | `uplink_chunk_size` | ✅ | tuning | Размер чанка (в base64-символах) для header/cookie-payload |
| 8 | `uplinkHTTPMethod` | `uplink_http_method` | ✅ | core | HTTP-метод upload-запросов (default POST) |
| 9 | `xPaddingObfsMode` | `x_padding_obfs_mode` | ✅ | obfs | Главный переключатель: legacy (Referer) vs configurable obfs |
| 10 | `xPaddingKey` | `x_padding_key` | ✅ | obfs | Имя cookie/query-параметра для padding (obfs-режим) |
| 11 | `xPaddingHeader` | `x_padding_header` | ✅ | obfs | Имя заголовка для padding (obfs-режим) |
| 12 | `xPaddingPlacement` | `x_padding_placement` | ✅ | obfs | Куда класть padding: cookie/header/query/queryInHeader |
| 13 | `xPaddingMethod` | `x_padding_method` | ✅ | obfs | Алгоритм генерации padding: repeat-x / tokenish |
| 14 | `serverMaxHeaderBytes`| `server_max_header_bytes`| ❌ | server-only | Лимит размера заголовков на **сервере** |
| 15 | `noSSEHeader` | `no_sse_header` | ❌ | server-only | Сервер не шлёт `Content-Type: text/event-stream` |
| 16 | `scMaxBufferedPosts` | `sc_max_buffered_posts` | ❌ | server-only | Глубина буфера переупорядочивания upload на сервере |
| 17 | `scStreamUpServerSecs`| `sc_stream_up_server_secs`| ❌ | server-only | Интервал keepalive-padding в ответе stream-up (сервер) |
| 18 | `scMaxConcurrentPosts`| `sc_max_concurrent_posts`| ❌ | legacy/ignore | Legacy-лимит параллельных upload-POST; **удалён из текущего Xray** |
\* `sessionKey` не входил в исходный список NekoBox+ из 16, но это парный к `sessionPlacement`
ключ (так же как `seqKey` парен к `seqPlacement`); без него placement query/header/cookie для session
неполон. Считаем его частью core-набора.
### Бонус: клиентские upload-tuning поля (вне списка 16, но клиент их читает)
Аудит вскрыл два поля, которые **читает клиент** в packet-up, но которых нет в исходном списке:
| Параметр | JSON | Клиент? | Тир | Что делает |
|----------|------|:-------:|-----|------------|
| `scMaxEachPostBytes` | `sc_max_each_post_bytes` | ✅ | tuning | Макс. размер одного upload-POST (порог разбиения) |
| `scMinPostsIntervalMs` | `sc_min_posts_interval_ms` | ✅ | tuning | Мин. интервал между upload-POST (анти-burst) |
Включаем их в реализацию для полноты packet-up (детали в §6 SPEC).
---
## 1. Текущая база sing-box-lx ↔ дефолт Xray (важно!)
**Наша уже существующая реализация (`transport/v2rayxhttp`) совместима с дефолтным Xray-сервером**
без новых полей, потому что:
| Аспект | Наш текущий код | Эквивалент в терминах этих полей |
|--------|------------------|----------------------------------|
| Padding | `x_padding=<нули>` в query внутри заголовка `Referer` | `xPaddingObfsMode=false` (legacy-ветка Xray) |
| Session id | path-сегмент `<path>/<sessionId>` | `sessionPlacement=path` |
| Seq | path-сегмент `<path>/<sessionId>/<seq>` | `seqPlacement=path` |
| Upload payload | тело POST | `uplinkDataPlacement=body` |
| Upload-метод | `POST` (`GET` для download) | `uplinkHTTPMethod=POST` |
То есть **расширение = добавление альтернативных режимов поверх рабочей base-линии**, а не
переписывание. Дефолты всех новых полей выбраны так, чтобы поведение «из коробки» осталось байт-в-байт
прежним.
---
## 2. Группа: session / seq placement (core)
### `sessionPlacement` + `sessionKey`
- **Xray-поле:** `Config.SessionIDPlacement` (proto `sessionIDPlacement=20`), `Config.SessionIDKey`
(`sessionIDKey=21`). ⚠️ В Xray поле называется `sessionID*`, а sing-box-extended экспонирует JSON
как `session_placement`/`session_key` (нормализатор `GetNormalizedSessionPlacement`). **Несовпадение
имён** — учитываем при реализации.
- **Назначение:** где разместить **session id** на каждом запросе (и GET-download, и POST-upload), чтобы
сервер мог демультиплексировать логические соединения, разделяющие один HTTP-origin, и сшить
upload-POST с соответствующим download-GET.
- **Значения:** `path` | `query` | `header` | `cookie`. (В отличие от uplink-data — **нет** `body`/`auto`.)
Иное значение отвергается: `unsupported session placement: …`.
- **Default:** `path` (пустое → `path`).
- **On-wire:**
- `path` → первый path-сегмент после base-path: `<path>/<sessionId>` (session **перед** seq).
- `query` → `?<sessionKey>=<sessionId>`.
- `header` → `<sessionKey>: <sessionId>`.
- `cookie` → `Cookie: <sessionKey>=<sessionId>`.
- **Ключ (`sessionKey`):** `GetNormalizedSessionKey` — default `X-Session` для header, `x_session` для
cookie/query, `""` (не используется) для path. Регистр асимметричен: header — каноничный `X-Session`,
cookie/query — нижний `x_session`. Клиент и сервер обязаны совпасть.
- **Генерация id:** `GenerateSessionID` — N случайных символов из `SessionIDTable`/`SessionIDLength`,
иначе UUID-строка. Наш текущий `newSessionID()` уже даёт UUID-формат → совместимо.
- **Сервер:** `ExtractMetaFromRequest`. Пустой sessionId → HTTP 400, **кроме** режимов
`""`/`auto`/`stream-one`/`stream-up` (там допустима одна неявная сессия).
- **Источник:** Xray `config.go` `GetNormalizedSessionPlacement`/`…Key`/`ApplyMetaToRequest`/
`ExtractMetaFromRequest`; extended `transport/v2rayxhttp/dialer.go` `ApplyMetaToRequest`,
`utils.go` `GenerateSessionID`, `option/v2ray_transport.go` валидация + нормализаторы.
### `seqPlacement` + `seqKey`
- **Xray-поле:** `Config.SeqPlacement` (`seqPlacement=22`), `Config.SeqKey` (`seqKey=23`).
- **Назначение:** где разместить **номер пакета** (`seqStr`) на каждом uplink-POST в **packet-up**. seq —
монотонный `int64` с 0, формат — десятичная ASCII-строка (`strconv.FormatInt(seq,10)`), по одному на
чанк, чтобы сервер переупорядочил пришедшие не по порядку POST в корректный поток. **Только packet-up**;
в stream-up/stream-one seqStr = `""` и не отправляется.
- **Значения:** `path` | `query` | `header` | `cookie`. Иначе — `unsupported seq placement: …`.
- **Default:** `path` (пустое → `path`).
- **On-wire:**
- `path` → **второй** path-сегмент: `<path>/<sessionId>/<seq>` (session первый, seq второй — **порядок
нагруженный**, сервер разбирает сегменты позиционно).
- `query` → `?<seqKey>=<seq>`.
- `header` → `<seqKey>: <seq>`.
- `cookie` → `Cookie: <seqKey>=<seq>`.
- Значение — всегда сырая десятичная строка; placement только переносит её, не кодирует.
- **Ключ (`seqKey`):** `GetNormalizedSeqKey` — default `X-Seq` (header) / `x_seq` (cookie/query) / `""` (path).
- **Сервер:** читает обратно симметрично; парсит `strconv.ParseUint(seqStr,10,64)`; ошибка парсинга →
HTTP **500**. GET с непустым seq = uplink; GET с пустым seq = downlink.
- ⚠️ **Коррекция верификации:** заголовок `Access-Control-Allow-Credentials: true` **НЕ выставляется**
ни для cookie/query placement, ни где-либо ещё (в исходниках обоих апстримов его нет — была
галлюцинация в черновике аудита).
- **Источник:** extended `dialer.go` `ApplyMetaToRequest` + `appendToPath` (вставляет `/`-разделитель),
`client.go` (`var seq int64` / `seqStr := strconv.FormatInt(seq,10)` / `seq += 1`); Xray `config.go`
одноимённые методы; сервер `hub.go`/`server.go` `strconv.ParseUint`.
---
## 3. Группа: uplink-data (obfs + tuning)
### `uplinkDataPlacement`
- **Xray-поле:** `Config.UplinkDataPlacement` (`uplinkDataPlacement=24`).
- **Назначение:** где нести **payload upload** для одного packet-up POST/GET-запроса. Влияет **только**
на packet-up (stream-up/stream-one всегда стримят body).
- **Значения:** `body` | `auto` | `header` | `cookie`.
- `body`/`auto` → payload = сырое тело запроса, `Content-Length` выставлен. (На **клиенте** `auto` ведёт
себя как `body`; различие `auto` есть только на сервере, который при `auto` конкатенирует header+cookie+body.)
- `header` → payload `base64.RawURLEncoding`, нарезается на чанки, каждый → заголовок `<uplinkDataKey>-<i>`
(i с 0, по возрастанию).
- `cookie` → то же, но cookie `<uplinkDataKey>_<i>` (разделитель `_`, не `-`).
- **Default:** после валидации — `auto` (пустое → `auto`); чистый `GetNormalizedUplinkDataPlacement`
возвращает `body` для пустого. Чистое клиентское поведение по умолчанию = payload в body.
- **Mode-gate:** `header`/`cookie` **отвергаются**, если `mode != packet-up`
(`UplinkDataPlacement can be <x> only in packet-up mode`). ⚠️ Этот gate живёт **только в
sing-box-extended** option-слое, не в Xray-core core.
- **Сервер:** переразбирает, итерируя индексы `i=0..` пока есть keyed header/cookie, `strings.Join` без
разделителя, затем `base64.RawURLEncoding.DecodeString`. Битый base64 → 400; превышение
`scMaxEachPostBytes` → 413.
- **Источник:** extended `utils.go` `FillPacketRequest` + `GetRequestHeaderWithPayload`/
`GetRequestCookiesWithPayload`; сервер `server.go`; Xray `config.go`/`hub.go`.
### `uplinkDataKey`
- **Xray-поле:** `Config.UplinkDataKey` (`uplinkDataKey=25`).
- **Назначение:** базовое **имя** header/cookie для chunked-payload (placement header/cookie). Это
**имя/обфускационный ключ, не криптографический** — payload base64url-кодируется, не шифруется.
- **On-wire:** header `fmt.Sprintf("%s-%d", key, i)` (`X-Data-0`, `X-Data-1`, …); cookie
`fmt.Sprintf("%s_%d", key, i)` (`x_data_0`, …).
- **Default:** при placement≠body и пустом ключе — `X-Data` (header/auto) / `x_data` (cookie). Для body —
пусто (не используется).
- **Источник:** extended `utils.go` тех же функций; default — `checkV2RayXHTTPBaseOptions`.
### `uplinkChunkSize`
- **Xray-поле:** `Config.UplinkChunkSize` (`uplinkChunkSize=26`, тип `RangeConfig`).
- **Назначение:** `Range[int]` (From..To) — размер **в base64-символах** каждого чанка при header/cookie
payload. Для каждого чанка клиент берёт `min(Range.Rand(), остаток)`. Не влияет на body-placement
(там размер регулирует `scMaxEachPostBytes`).
- **Default (зависит от placement):** cookie → `[2048, 3072]`; header → `[3000, 4000]`; иначе → значение
`scMaxEachPostBytes` (default `[1_000_000, 1_000_000]`). Пол: `From < 64` → подтягивается к 64.
- **Сервер:** **не использует** для переразбора — итерирует индексы вслепую и join'ит. Значит чанк-сайз —
чисто клиентская emission-политика; любой валидный сплит, который сервер сможет собрать, работает.
- **Источник:** extended `option/v2ray_transport.go` `GetNormalizedUplinkChunkSize`; usage в `utils.go`.
### `uplinkHTTPMethod`
- **Xray-поле:** `Config.UplinkHTTPMethod` (`uplinkHTTPMethod=19`).
- **Назначение:** HTTP-метод client→server **upload**-запросов (packet-up POST и stream-up/stream-one
upstream-запрос с телом). Download (stream-down) — всегда `GET`, не затрагивается. Позволяет замаскировать
upload под не-POST глагол.
- **On-wire:** метод запроса (request-line / `:method`). `GET` при body==nil (download), иначе настроенный
метод.
- **Default:** `POST`. ⚠️ Upper-casing значения — **только** в sing-box-extended option-слое; Xray-core
`GetNormalizedUplinkHTTPMethod` возвращает значение как есть.
- **Mode-gate (lx: soft-fallback):** `GET` осмыслен **только** при `mode=packet-up`, т.к. stream-up/
stream-one нужен запрос с телом, а GET-с-телом сервер трактует как stream-down. Раньше `GET` вне
packet-up был **жёсткой ошибкой** (`uplink_http_method can be GET only in packet-up mode`) — но это
роняло **весь** конфиг из-за одной кривой ноды подписки (наблюдалось: `initialize outbound[N] … can
be GET only in packet-up mode`). Теперь вместо ошибки — **fallback на `POST`** (безопасный дефолт,
валиден во всех режимах) + `WARN` в лог; в `packet-up` `GET` сохраняется. Gate — в extended
(`normalizeMeta`, `meta.go`). См. `SPEC.md` §поведение и тест `TestUplinkGetFallsBackToPostOutsidePacketUp`.
- **Сервер:** маршрутизирует по методу, не по равенству настроенному значению: `GET` + непустой seq =
uplink; любой не-GET = uplink. Явной проверки настроенного метода нет.
- **Источник:** extended `dialer.go` `OpenStream`/`PostPacket`; `option/v2ray_transport.go`
`GetNormalizedUplinkHTTPMethod`; Xray `config.go`/`client.go`/`hub.go`.
---
## 4. Группа: X-Padding obfs (obfs)
### `xPaddingObfsMode`
- **Xray-поле:** `Config.XPaddingObfsMode` (bool, `xPaddingObfsMode=14`).
- **Назначение:** **главный переключатель** схемы padding.
- `false` (default/legacy): padding **всегда** как `queryInHeader` в `Referer` — запрос несёт
`Referer: <scheme>://<host><path>?x_padding=<padding>`. Ключ жёстко `x_padding`, заголовок жёстко
`Referer`, метод неявно repeat-x.
- `true` (obfs): padding по настраиваемым `xPaddingKey`/`xPaddingHeader`/`xPaddingPlacement`/
`xPaddingMethod` — можно перенести в произвольный cookie/header/query/queryInHeader и выбрать алгоритм.
- **Padding обязателен в обоих режимах** — `x_padding_bytes` нельзя отключить. Меняется только форма и где
сервер ищет/валидирует.
- **Default:** `false` (JSON `x_padding_obfs_mode`, omitempty).
- **On-wire:**
- `false` → `Referer: …?x_padding=XXXX…` (наш **текущий** код).
- `true` → padding по `xPaddingPlacement` в header (default `X-Padding`)/cookie/query/queryInHeader.
- Ответ сервера зеркалит: obfs → той же placement; non-obfs → `header` с именем `X-Padding`
(default ответа **отличается** от Referer-дефолта запроса).
- **Сервер:** `ExtractXPaddingFromRequest(request, obfsMode)` → `IsPaddingValid` против
`GetNormalizedXPaddingBytes`. Невалидно → HTTP **400**. Также гейтит
`obfsPaddingAccepted := XPaddingObfsMode && paddingValue != ""`, что (вместе с `scStreamUpServerSecs.To>0`
**или** legacy-Referer-маркером) включает периодический серверный keepalive-padding.
- ⚠️ **Коррекция верификации:** серверный keepalive-тикер гейтится
`(legacyRefererCompatMarker || obfsPaddingAccepted) && scStreamUpServerSecs.To > 0` — `obfsPaddingAccepted`
это **одно из двух** условий, не единственное.
- **Out-of-band:** этот bool **не согласуется по проводу** — client и server должны иметь одинаковую
настройку в конфиге.
- **Источник:** Xray `config.go` `FillStreamRequest`/`FillPacketRequest`; `hub.go` `ServeHTTP`;
extended `utils.go` тех же + `xpadding.go` `ExtractXPaddingFromRequest`.
### `xPaddingKey`
- **Xray-поле:** `Config.XPaddingKey` (`xPaddingKey=15`).
- **Назначение:** **имя** для несения padding в obfs-режиме — имя cookie (cookie), имя query-параметра
(query), или имя query-параметра внутри header-URL (queryInHeader). **Не используется** при
`xPaddingPlacement=header` (там значение = весь заголовок). Это **идентификатор-строка, не крипто-ключ**:
нигде нет keyed-hash/шифрования padding.
- **On-wire:** cookie `Cookie: <key>=<pad>; Path=/`; query `?<key>=<pad>`; queryInHeader — URL
`<RawURL>?<key>=<pad>` в заголовке.
- **Default:** в proto пусто; extended при пустом → `x_padding`. В non-obfs режиме литерал `x_padding`
жёстко зашит независимо от поля.
- **Тонкость:** Xray в queryInHeader присваивает `u.RawQuery = key + "=" + paddingValue` **без**
URL-escaping. Безопасно, т.к. padding — только `[A-Za-z0-9]` (base62) или `X`.
- **Источник:** Xray `xpadding.go` `ExtractXPaddingFromRequest`/`ApplyXPaddingToHeader`/cookie/query;
default — extended `checkV2RayXHTTPBaseOptions`.
### `xPaddingHeader`
- **Xray-поле:** `Config.XPaddingHeader` (`xPaddingHeader=16`).
- **Назначение:** **имя заголовка** для padding в obfs+header-based placement.
- `PlacementHeader`: всё значение заголовка = padding.
- `PlacementQueryInHeader`: значение = полный URL с `?<key>=<padding>`.
- Игнорируется при cookie/query placement.
- **Default:** пусто в proto; extended → `X-Padding`. Non-obfs серверный ответ тоже жёстко `X-Padding`.
- **HTTP/2:** имя заголовка в проводе lowercase'ится (HPACK) — нормально, матчинг case-insensitive.
- **Источник:** Xray `xpadding.go` `ApplyXPaddingToHeader`/`ExtractXPaddingFromRequest`; default — extended.
### `xPaddingPlacement`
- **Xray-поле:** `Config.XPaddingPlacement` (`xPaddingPlacement=17`).
- **Назначение:** **где** физически разместить padding в obfs-режиме: `cookie` | `header` | `query` |
`queryInHeader`. (Константы `path`/`body`/`auto` существуют для других полей, но для padding
**невалидны** — extended-валидатор отвергает.) Только при `xPaddingObfsMode=true`; в non-obfs принудительно
queryInHeader(Referer) для запросов, header(X-Padding) для ответа.
- **On-wire:** cookie `Cookie: <key>=<pad>`; header `<XPaddingHeader>: <pad>`; query `?<key>=<pad>`;
queryInHeader `<XPaddingHeader>: <reqURL>?<key>=<pad>`. `ApplyXPaddingToResponse` обрабатывает только
header/queryInHeader/cookie (query-on-response нет).
- **Default:** пусто → `queryInHeader` (extended).
- ⚠️ **Коррекция верификации:** никакого `Access-Control-Allow-Credentials` для cookie-placement нет
(была галлюцинация). CORS-логика в `config.go WriteResponseHeader` касается других заголовков.
- **Источник:** Xray `xpadding.go` `ApplyXPaddingToRequest`/`…ToResponse`/`Extract…`; константы + валидация —
extended `option/v2ray_transport.go`.
### `xPaddingMethod`
- **Xray-поле:** `Config.XPaddingMethod` (`xPaddingMethod=18`).
- **Назначение:** **алгоритм** генерации байт padding:
- `repeat-x`: N литеральных `X` (длина == целевому числу байт точно).
- `tokenish`: случайная base62-строка (`[0-9A-Za-z]`), чья **HPACK/QPACK-Huffman-кодированная** длина
итеративно подгоняется в пределах ±2 байт от цели, чтобы после HTTP/2-сжатия заголовков размер в
проводе попадал в диапазон. `tokenish` делает padding похожим на случайный токен, а не на ряд `X`.
- **On-wire:** repeat-x — `strings.Repeat("X", length)`. tokenish — base62-токен через
`hpack.HuffmanEncodeLength`; `X`/`Z` берутся как filler (у них 8-битные Huffman-коды → не сжимаются),
чтобы длина была стабильной.
- **Default:** пусто → `repeat-x` (extended).
- **Сервер:** `IsPaddingValid` с тем же методом. repeat-x → `len(value) ∈ [from,to]`; tokenish →
`hpack.HuffmanEncodeLength(value) ∈ [from-2, to+2]`. **Client и server обязаны использовать один метод** —
tokenish-значение, проверенное как repeat-x, сравнит сырую длину, не Huffman.
- **Реализация tokenish:** не нужен вендоринг Xray — нужен `golang.org/x/net/http2/hpack` (уже транзитивно
в go.mod). Порт `GenerateTokenishPaddingBase62` (~40 строк): `crypto/rand` base62 длины ≈ `ceil(target/0.8)`,
затем тюнинг `HuffmanEncodeLength` в ±2, чередуя filler `X`/`Z`, лимит 150 итераций.
- **HTTP/1.1:** сырая длина = длине в проводе → repeat-x и tokenish по длине эквивалентны.
- **Источник:** Xray `xpadding.go` `GeneratePadding`/`GenerateTokenishPaddingBase62`/`IsPaddingValid`;
extended `xpadding.go` байт-в-байт зеркало.
---
## 5. Группа: server-only (документируем, НЕ реализуем)
Все 4 поля **не читаются клиентом** ни в Xray-core, ни в sing-box-extended. Ключевой факт: **клиент вообще
не инспектирует `Content-Type` ответа** (`stream-down` → `wrc.Set(resp.Body)`; `stream-up` →
`io.Copy(io.Discard, resp.Body)`), поэтому корректно работает и с SSE-заголовком, и без него, без какого-либо
кода.
| Параметр | Что делает (на сервере) | Default | Почему клиенту не нужно |
|----------|--------------------------|---------|--------------------------|
| `serverMaxHeaderBytes` | `http.Server{MaxHeaderBytes}` — лимит размера заголовков входящего запроса | `8192` | У client-only транспорта нет `http.Server` |
| `noSSEHeader` | Сервер не шлёт `Content-Type: text/event-stream` на stream-down GET | `false` (SSE шлётся) | Клиент не читает Content-Type — обрабатывает оба случая без кода |
| `scMaxBufferedPosts` | Ёмкость серверной очереди переупорядочивания upload-POST (packet-up) | `30` | Клиент не знает о глубине буфера сервера |
| `scStreamUpServerSecs` | Интервал (сек, Range) периодической записи `X`-padding в ответ stream-up | `{20,80}` | Клиент тихо отбрасывает эти байты (`io.Discard`) |
**Решение для реализации:** не реализуем. В конфиге — `expose-but-ignore` (принимаем поля, чтобы
server-образные конфиги не падали на парсинге), помечены как inbound-only. Альтернатива (просто
document-and-skip без полей в struct) тоже допустима; финальный выбор зафиксирован в SPEC §6.
- **Источник:** Xray `config.go` `GetNormalizedServerMaxHeaderBytes`/`…ScMaxBufferedPosts`/
`…ScStreamUpServerSecs`; `hub.go` `ListenXH`/`ServeHTTP`/`upsertSession`; extended `server.go` зеркало.
`noSSEHeader` — без нормализатора, читается как сырой bool.
### `scMaxConcurrentPosts` — legacy, удалено из upstream (accept-but-ignore)
- **Статус:** **поля НЕТ** в текущих Xray-core и sing-box-extended. Подтверждено: `grep` пусто,
GitHub code search `scMaxConcurrentPosts repo:XTLS/Xray-core` → `total: 0`, нет
`GetNormalizedScMaxConcurrentPosts`. Это knob **старого** релиза Xray, удалённый при редизайне
upload-пути. Встречается в реальных `vless://`-ссылках (в `extra={...}`) как legacy-артефакт клиента,
сгенерировавшего ссылку.
- **Что было:** когда-то ограничивал число параллельных upload-POST в packet-up.
- **Текущий механизм Xray (вместо него):** **не семафор**, а (a) bounded pipe (backpressure) +
`scMaxBufferedPosts` (server reorder window, default 30) и (b) сериализация на
`<-wroteRequest.Wait()` для `DefaultDialerClient` — следующий POST не диспетчится, пока тело текущего
не дописано в провод. Фактически **1 POST-тело в полёте за раз**. Seq инкрементируется per-packet
синхронно в writer-goroutine; переупорядочивание — server-side по seq (`upload_queue.go`, heap).
- **Наш клиент:** `packetConn.Write` шлёт upload-POST **последовательно** (один `sendPacket` за раз) —
это уже соответствует текущему поведению Xray (1 тело за раз). Истинная bounded-concurrency (N в
полёте) была бы **улучшением над upstream**, не совместимостью.
- **Решение:** поле `sc_max_concurrent_posts` принимается в опциях (чтобы legacy-конфиги/ссылки не
падали на парсинге), но **игнорируется**. Тир — `legacy/ignore`.
- **Источник:** Xray `dialer.go` writer-loop (нет concurrency-cap), `upload_queue.go` (heap по `Seq`);
отсутствие символа подтверждено code search.
---
## 6. Что из этого реализуем в sing-box-lx (резюме)
- **Реализуем (12 клиентских + 2 tuning-бонуса):** session/seq placement+key, uplink-data
placement/key/chunk/method, полный X-Padding obfs (placement/key/header/method, включая `tokenish`),
плюс `sc_max_each_post_bytes` / `sc_min_posts_interval_ms` для packet-up.
- **Не реализуем (4 server-only):** `server_max_header_bytes`, `no_sse_header`, `sc_max_buffered_posts`,
`sc_stream_up_server_secs` — accept-but-ignore в опциях.
- **Не реализуем (1 legacy):** `sc_max_concurrent_posts` — удалено из upstream; наш последовательный
upload = текущий Xray; accept-but-ignore.
- **Зависимости:** только `golang.org/x/net/http2/hpack` для tokenish (уже есть). Без вендоринга Xray.
- **Совместимость:** все дефолты сохраняют текущее (рабочее, лайв-проверенное) поведение байт-в-байт.
Детали маппинга в Go-структуры, точки касания upstream и план реализации — в [SPEC.md](SPEC.md) §5–§7.
+49
View File
@@ -0,0 +1,49 @@
# PLAN: 002 — XHTTP_CLIENT_TRANSPORT
## 1. Архитектура
**Было (upstream):** `transport/v2ray/transport.go::NewClientTransport` — `switch options.Type { case … }`.
**Станет:** реестр конструкторов.
```go
// transport/v2ray/registry.go (new)
var clientRegistry = map[string]ClientConstructor{}
func RegisterClient(typ string, ctor ClientConstructor) { clientRegistry[typ] = ctor }
```
- Встроенные типы регистрируются в `init()` (в `transport.go` или соседнем файле) — поведение для http/ws/quic/grpc/httpupgrade без изменений.
- `NewClientTransport` → `ctor, ok := clientRegistry[options.Type]`; нет — прежняя ошибка.
- XHTTP-конструктор регистрируется из пакета `v2rayxhttp` через `init()` **только** под `//go:build with_xhttp` (через проводящий файл, чтобы импорт пакета подтягивался лишь с тегом).
Конструктор XHTTP должен соответствовать сигнатуре `ClientConstructor` (см. upstream `transport.go`): `(ctx, dialer, serverAddr, options, tlsConfig) → (adapter.V2RayClientTransport, error)`. Опции достаются из `options.XHTTPOptions`.
## 2. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `transport/v2ray/registry.go` | **new** | `clientRegistry`, `RegisterClient`, `init()` встроенных типов |
| `transport/v2ray/transport.go` | `// lx:` | `NewClientTransport`: `switch` → lookup в реестре (минимальная правка) |
| `transport/v2rayxhttp/*.go` | **new** | Клиент XHTTP: `client.go`, `conn.go`, `dialer.go`, `http.go`, `mux.go`, `upload_queue.go`, `writer.go` |
| `transport/v2rayxhttp/register.go` | **new** | `//go:build with_xhttp` — `init(){ v2ray.RegisterClient(C.V2RayTransportTypeXHTTP, New) }` |
| `constant/v2ray.go` | `// lx:` | `V2RayTransportTypeXHTTP = "xhttp"` |
| `option/v2ray_transport.go` | `// lx:` | одна строка: поле `XHTTPOptions` в `_V2RayTransportOptions` |
| `option/v2ray_xhttp.go` | **new** | тип `XHTTPOptions` (mode, path, host, headers, padding…) |
| `include/v2rayxhttp_stub.go` | **new** | `//go:build !with_xhttp` — понятная ошибка/нет регистрации |
| `lx-test/config/xhttp_*.json` | **new** | Конфиги для `sing-box check` |
## 3. Зона касания upstream (для ребейза)
Только: `transport/v2ray/transport.go`, `constant/v2ray.go`, `option/v2ray_transport.go`. Все — с `// lx:` маркерами, атомарными коммитами. Реестр (`registry.go`) — новый файл, конфликтов не даёт.
## 4. Порядок работ
1. `registry.go` + рефактор `NewClientTransport` (поведение идентично — прогнать существующие тесты транспорта).
2. Константа + опции (`v2ray_xhttp.go` + `// lx:` поле).
3. Пакет `v2rayxhttp` (портировать клиент, сверить с Xray).
4. `register.go` под тегом + `_stub.go` без тега.
5. Конфиги, `sing-box check`, ручной коннект.
## 5. Риски
- **XHTTP — движущаяся цель** в Xray; нужна периодическая сверка параметров (`mode`, padding).
- `mode=auto` в sing-box-портах исторически падает в `packet-up`, что ломало, напр., аплоад в Telegram ([hiddify#2082](https://github.com/hiddify/hiddify-app/issues/2082)) — задокументировать фактический выбор режима.
- Рефактор `switch`→registry должен **точно** сохранить семантику ошибок и nil-обработку (`options.Type == ""` → `nil, nil`).
+187
View File
@@ -0,0 +1,187 @@
# SPEC: 002 — XHTTP_CLIENT_TRANSPORT (full)
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | A (active) — расширение до полной клиентской поддержки |
| База | upstream v1.14.0-alpha.* (ветка `lx-1.14`) |
| Реализация | ветка `lx-1.14-xhttp-full` |
| История | v1 (минимальный lean-native клиент, Complete) → см. [SPEC_v1.md](SPEC_v1.md) |
Расширить **клиентский XHTTP-транспорт** (`transport/v2rayxhttp`, build-тег `with_xhttp`) до
**полной клиентской поддержки** расширенных параметров Xray XHTTP / sing-box-extended: настраиваемые
placement'ы session/seq/uplink-data, ключи, метод upload и полноценный **X-Padding obfs-режим**
(включая `tokenish`/HPACK). Сохранить байт-в-байт текущее (лайв-проверенное) поведение по умолчанию.
> **Карта параметров** (что каждое поле делает в Xray, клиент/сервер, дефолты, on-wire) вынесена в
> отдельный документ: **[PARAM_MAP.md](PARAM_MAP.md)** — читать его первым.
---
## 1. Проблема / контекст
- v1 (см. [SPEC_v1.md](SPEC_v1.md)) дал рабочий lean-native клиент с 6 полями (`host`, `path`, `mode`,
`headers`, `x_padding_bytes`, `no_grpc_header`) и проверенным коннектом к Xray/3x-ui (packet-up/auto).
- Xray и форки (sing-box-extended, NekoBox+) ушли далеко вперёд: добавлены настраиваемые **placement'ы**
(path/query/header/cookie/body) для session id, seq, payload; **obfs-режим X-Padding** с произвольными
ключами/заголовками и алгоритмами (`repeat-x`/`tokenish`); метод upload; tuning packet-up. Сервера,
настроенные на эти режимы, нашим v1-клиентом **не обслуживаются**.
- Цель — закрыть **весь клиентский** surface, оставаясь client-only и не вендоря Xray-внутренности.
## 2. Цель
VLESS/VMess/Trojan outbound с `transport.type=xhttp` поднимает рабочее соединение к XHTTP-серверу Xray
в **любой** из поддерживаемых сервером клиентских конфигураций placement/obfs, поверх TLS/Reality.
Дефолты идентичны v1 (нулевая регрессия). Без `with_xhttp` тип `xhttp` отвергается как прежде.
## 3. Аудит (основание спеки)
Глубокий аудит исходников **XTLS/Xray-core** (`transport/internet/splithttp/*`, `main`) и
**shtorm-7/sing-box-extended** (`transport/v2rayxhttp/*`, `option/v2ray_transport.go`, `extended`),
с adversarial-верификацией каждого факта против исходника. Полные карточки — в [PARAM_MAP.md](PARAM_MAP.md).
**Итог по 16 полям из списка NekoBox+:**
- **12 клиентских** (реализуем): `sessionPlacement`(+`sessionKey`), `seqPlacement`(+`seqKey`),
`uplinkDataPlacement`, `uplinkDataKey`, `uplinkChunkSize`, `uplinkHTTPMethod`, `xPaddingObfsMode`,
`xPaddingKey`, `xPaddingHeader`, `xPaddingPlacement`, `xPaddingMethod`.
- **4 server-only** (НЕ реализуем, accept-but-ignore): `serverMaxHeaderBytes`, `noSSEHeader`,
`scMaxBufferedPosts`, `scStreamUpServerSecs`. Ни одно не читается клиентом; клиент даже не инспектирует
`Content-Type` ответа.
- **+2 клиентских tuning-поля** вне списка, которые клиент реально читает в packet-up:
`scMaxEachPostBytes`, `scMinPostsIntervalMs` — добавляем для полноты.
**Ключевой факт совместимости:** текущий v1-код = дефолтный Xray (obfs off → x_padding в Referer;
session/seq в path; payload в body; метод POST). Расширение — это **добавление альтернативных режимов**,
а не переписывание. Все новые поля имеют дефолты, сохраняющие текущее поведение.
**Коррекции верификации, влияющие на реализацию:**
1. `Access-Control-Allow-Credentials` **не выставляется** нигде (cookie/query placement его не ставят).
2. Mode-gate'ы (`header`/`cookie` uplink только в packet-up; `GET` метод только в packet-up) живут **только**
в extended option-слое — переносим как нашу валидацию. **Исключение (lx):** `GET` вне packet-up — НЕ
ошибка, а **soft-fallback на `POST` + `WARN`** (чтобы одна кривая нода подписки не роняла весь конфиг);
`header`/`cookie` uplink вне packet-up остаётся жёсткой ошибкой (нет безопасного дефолта). См. журнал.
3. Upper-casing `uplink_http_method` — только в extended option-слое.
4. Серверный keepalive-padding гейтится `(legacyMarker || obfsAccepted) && scStreamUpServerSecs.To>0` —
нас (клиент) не касается, но учтено в карте.
## 4. Требования
### 4.1 Опции (`option/v2ray_xhttp.go` — новый код, нулевое касание upstream)
Расширить `V2RayXHTTPOptions`, **сохранив** 6 существующих полей. Добавить (JSON snake_case, как в
sing-box-extended; все `omitempty`):
**Placement / keys (core):**
- `session_placement` (string: `path`|`query`|`header`|`cookie`; default `path`)
- `session_key` (string; default `X-Session`/`x_session` по placement)
- `seq_placement` (string: `path`|`query`|`header`|`cookie`; default `path`)
- `seq_key` (string; default `X-Seq`/`x_seq` по placement)
**Uplink data (obfs/tuning):**
- `uplink_data_placement` (string: `body`|`auto`|`header`|`cookie`; default `auto`≈body)
- `uplink_data_key` (string; default `X-Data`/`x_data` по placement)
- `uplink_chunk_size` (`*badoption.Range[int]`; default зависит от placement)
- `uplink_http_method` (string; default `POST`; upper-case)
**X-Padding obfs:**
- `x_padding_obfs_mode` (bool; default false)
- `x_padding_key` (string; default `x_padding`)
- `x_padding_header` (string; default `X-Padding`)
- `x_padding_placement` (string: `cookie`|`header`|`query`|`queryInHeader`; default `queryInHeader`)
- `x_padding_method` (string: `repeat-x`|`tokenish`; default `repeat-x`)
**Packet-up tuning (бонус):**
- `sc_max_each_post_bytes` (`*badoption.Range[int]`; default `[1000000,1000000]`)
- `sc_min_posts_interval_ms` (`*badoption.Range[int]`; default `[30,30]`)
**Server-only (accept-but-ignore — присутствуют в struct, помечены `// server-only, ignored by client`):**
- `server_max_header_bytes` (int), `no_sse_header` (bool), `sc_max_buffered_posts` (int64),
`sc_stream_up_server_secs` (`*badoption.Range[int]`)
Нормализация и валидация (mode-gate'ы, дефолты по placement, upper-case метода, отказ на неизвестных
значениях с понятными ошибками) — реплицируем семантику extended `checkV2RayXHTTPBaseOptions` +
`GetNormalized*`.
### 4.2 Транспорт (`transport/v2rayxhttp/*` — новый код)
- **placement-движок:** единая функция `applyMeta(req, sessionID, seqStr)` раскладывающая session id и seq
по настроенным placement/key (path/query/header/cookie). Path сохраняет порядок «session первый, seq
второй». Заменяет нынешнюю жёсткую path-логику в `requestURL`.
- **uplink-data:** для packet-up — `body`/`auto` (тело, как сейчас) или header/cookie chunked
(`base64.RawURLEncoding`, чанки `<key>-<i>`/`<key>_<i>`, размер по `uplink_chunk_size`).
- **uplink-метод:** upload-запросы используют `uplink_http_method` (download — всегда GET).
- **X-Padding движок (`xpadding.go`, новый файл):**
- non-obfs (default): как сейчас — `x_padding=<pad>` в query внутри `Referer`.
- obfs: `applyXPadding(req)` по `x_padding_placement` (cookie/header/query/queryInHeader) с именами
`x_padding_key`/`x_padding_header`.
- генератор: `repeat-x` (`strings.Repeat`) и `tokenish` (порт `GenerateTokenishPaddingBase62` на
`crypto/rand` + `golang.org/x/net/http2/hpack.HuffmanEncodeLength`, ±2 тюнинг, filler `X`/`Z`, ≤150 итер).
- **packet-up tuning:** разбиение upload по `sc_max_each_post_bytes`, троттлинг по `sc_min_posts_interval_ms`.
- Конструктор `NewClient` принимает расширенные опции, нормализует, валидирует, кэширует разобранные
placement/method.
### 4.3 Зона касания upstream
Без изменений против v1: ровно `option/v2ray_transport.go` (уже прокидывает весь `XHTTPOptions`),
`constant/v2ray.go`, `transport/v2ray/transport.go` (registry). Все новые поля и логика — в новых файлах
(`option/v2ray_xhttp.go`, `transport/v2rayxhttp/*`).
### 4.4 TLS/Reality
Без изменений — `tlsConfig` прокидывается как прежде; XHTTP+Reality работает. (XHTTP+XTLS-Vision
несовместимы — ограничение протокола.)
## 5. Маппинг в Go (точки реализации)
| Слой | Файл | Изменение |
|------|------|-----------|
| Опции | `option/v2ray_xhttp.go` | +20 полей в `V2RayXHTTPOptions`; новый файл нормализации/валидации (можно `option/v2ray_xhttp_normalize.go`) |
| Placement | `transport/v2rayxhttp/meta.go` (новый) | `applyMeta` + резолверы ключей/дефолтов |
| Padding | `transport/v2rayxhttp/xpadding.go` (новый) | obfs-движок + `repeat-x`/`tokenish` |
| Клиент | `transport/v2rayxhttp/client.go` | приём опций, ветвление newRequest на obfs/non-obfs |
| Conn | `transport/v2rayxhttp/conn.go` | uplink-data placement, packet-up tuning |
| Тесты | `transport/v2rayxhttp/*_test.go` | юнит-тесты на каждый placement/метод/генератор |
## 6. Критерии приёмки
- `sing-box check -c` принимает VLESS + `transport.type=xhttp` со всеми новыми полями (валидные конфиги
под каждый placement/obfs-режим в `lx-test/config/`).
- **Нулевая регрессия дефолта:** конфиг без новых полей даёт байт-в-байт тот же on-wire запрос, что v1
(тест сравнения с золотым образцом Referer/path/body).
- Юнит-тесты зелёные на каждый:
- placement session/seq (path/query/header/cookie) — корректные URL/заголовки/cookie;
- uplink-data (body/header/cookie) — корректная сборка base64-чанков;
- X-Padding obfs (4 placement × 2 метода) — корректное размещение и длина;
- `tokenish` — HPACK-Huffman-длина в [from-2, to+2];
- валидация — отказ на невалидных значениях и mode-gate'ах с правильными ошибками.
- Сборка `-tags with_xhttp` — ок; сборка без тега — `xhttp` отвергается.
- `go test ./transport/v2rayxhttp/`, `go vet` (lx-теги), `gofmt -l` — зелёные.
- Ребейз-проверка: зона касания upstream не расширилась против v1.
- **Лайв (если есть сервер):** коннект к Xray-серверу хотя бы в одном не-дефолтном режиме (например
`x_padding_obfs_mode=true` + `x_padding_placement=header`) — иначе помечается как открытый TODO, как в v1.
## 7. План реализации (ветка `lx-1.14-xhttp-full`)
1. Расширить `option/v2ray_xhttp.go` (+ нормализация/валидация). `gofmt`, компиляция.
2. `transport/v2rayxhttp/meta.go` — placement-движок + резолверы. Юнит-тесты.
3. `transport/v2rayxhttp/xpadding.go` — obfs + repeat-x/tokenish. Юнит-тесты (включая HPACK-длину).
4. Прошить в `client.go`/`conn.go`: ветвление obfs/non-obfs, uplink-data placement, метод, tuning.
5. Конфиги `lx-test/config/xhttp_*.json` под новые режимы; `sing-box check`.
6. Тест нулевой регрессии дефолта.
7. `go test` + `go vet` + `gofmt` + сборка с тегом/без. `make -f Makefile.lx lx-build`.
8. IMPLEMENTATION_REPORT, TASKS, статус папки.
## 8. Вне скоупа
- **XHTTP server/inbound** (отдельная задача) — server-only поля только accept-but-ignore.
- **xmux** (мультиплексирование соединений) — отдельная оптимизация, не входит в параметры.
- Маппинг `vless://…type=xhttp` в лаунчере (его репозиторий).
## 9. Ссылки
- [PARAM_MAP.md](PARAM_MAP.md) — детальная карта всех параметров (основной справочник).
- [SPEC_v1.md](SPEC_v1.md) — исходная минимальная спека (история).
- Xray-core splithttp: https://github.com/XTLS/Xray-core/tree/main/transport/internet/splithttp
- sing-box-extended (ветка `extended`): https://github.com/shtorm-7/sing-box-extended
- [V2Ray Transport — sing-box](https://sing-box.sagernet.org/configuration/shared/v2ray-transport/)
@@ -0,0 +1,74 @@
# SPEC: 002 — XHTTP_CLIENT_TRANSPORT
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Добавить **клиентский XHTTP-транспорт** (совместимость с Xray XHTTP) для VLESS/VMess/Trojan, встроив его через **registry-рефактор** диспетчера v2ray-транспортов, за build-тегом `with_xhttp`.
---
## 1. Проблема / контекст
- Upstream sing-box XHTTP не поддерживает и не планирует ([#3550](https://github.com/SagerNet/sing-box/issues/3550)). Сервера на Xray всё чаще только XHTTP (после депрекации части транспортов в Xray).
- В sing-box диспетчер v2ray-транспортов — **хардкод-`switch`** по `options.Type` в `transport/v2ray/transport.go`. Добавлять `case` на каждый ребейз — точка постоянных конфликтов.
## 2. Цель
VLESS/VMess/Trojan outbound с `transport.type = "xhttp"` поднимают рабочее соединение к XHTTP-серверу Xray, в т.ч. поверх **TLS/Reality**. Без тега `with_xhttp` тип `xhttp` отвергается с понятной ошибкой.
## 3. Требования
### 3.1 Registry-рефактор диспетчера (точка касания upstream)
- Превратить выбор клиентского транспорта в **реестр**: `transport.RegisterClient(type, ClientConstructor)` + `map[string]ClientConstructor`, заполняемый при `init()`.
- Встроенные транспорты (`http`, `ws`, `quic`, `grpc`, `httpupgrade`) регистрируются как раньше (поведение идентично upstream).
- `NewClientTransport` ищет конструктор в реестре вместо `switch` (поведение для известных типов — без изменений; для неизвестных — та же ошибка `unknown transport type`).
- **Серверный** диспетчер (`NewServerTransport`) — **не трогаем** (scope client-only); xhttp-сервер отложен.
### 3.2 Пакет `transport/v2rayxhttp` (новый код)
- Клиентская реализация XHTTP (референс — [`hiddify/hiddify-sing-box`](https://github.com/hiddify/hiddify-sing-box) `transport/v2rayxhttp`, сверка параметров с Xray-core).
- Поддержать режимы Xray: `auto`, `packet-up`, `stream-up`, `stream-one`; параметры `path`, `host`, `headers`, и padding-расширения (`x_padding_bytes` и т.п.) — в объёме, нужном для совместимости.
- Регистрация конструктора через `init()` в файле за `//go:build with_xhttp`.
### 3.3 Опции и константа
- `constant/v2ray.go`: `V2RayTransportTypeXHTTP = "xhttp"` (внутри `// lx:` маркера).
- `option/v2ray_transport.go`: поле `XHTTPOptions XHTTPOptions` в `_V2RayTransportOptions` + тип `XHTTPOptions` (в новом файле `option/v2ray_xhttp.go`, чтобы минимизировать дифф основного файла; в `_V2RayTransportOptions` — одна `// lx:` строка).
### 3.4 TLS/Reality
- `tlsConfig` прокидывается в конструктор как у прочих транспортов → связка **XHTTP + Reality** работает без доп. кода. (XHTTP + XTLS-Vision несовместимы — ограничение протокола, не наше.)
## 4. Критерии приёмки
- `sing-box check -c` принимает VLESS + `transport.type=xhttp` + `tls.reality`.
- Реальный коннект к XHTTP-серверу Xray (ручная проверка), хотя бы `mode=stream-one` и `packet-up`.
- Сборка **без** `with_xhttp`: конфиг с `xhttp` → ошибка `unknown transport type: xhttp` (или эквивалент реестра).
- `go test ./transport/...`, `go vet ./...` зелёные.
- Ребейз-проверка: при следующем upstream-теге конфликты возможны **только** в `transport/v2ray/transport.go`, `constant/v2ray.go`, `option/v2ray_transport.go`.
## 5. Вне скоупа
- **XHTTP server/inbound** (отдельная будущая задача).
- Маппинг `vless://…type=xhttp` в самом лаунчере (репозиторий `singbox-launcher`, follow-up к его задаче 023).
- 100% паритет всех Xray-расширений XHTTP — только то, что нужно для рабочего коннекта.
## 6. Ссылки
- [V2Ray Transport — sing-box](https://sing-box.sagernet.org/configuration/shared/v2ray-transport/)
- [hiddify-sing-box (референс XHTTP)](https://github.com/hiddify/hiddify-sing-box)
- [XHTTP overview (Habr)](https://habr.com/en/articles/990208/)
---
## 7. Разведка порта и выбор подхода (добавлено по ходу)
**Что показал референс `hiddify/hiddify-sing-box` (`transport/v2rayxhttp/client.go`):** XHTTP в hiddify реализован НЕ поверх примитивов sing-box, а через **вендорённое поддерево Xray** под `common/xray/{buf,net,pipe,signal/done,uuid}` + зависимости `quic-go`, `http3`, `golang.org/x/net/http2`, абстракция `DialerClient`/`XmuxClient` и опции `option.V2RayXHTTPOptions{ V2RayXHTTPBaseOptions }`. Целевой интерфейс прост — `adapter.V2RayClientTransport = { DialContext(ctx) (net.Conn, error); Close() error }` — но реализация тянет много транзитивного кода и завязана на старую версию sing-box hiddify.
**Развилка подхода (зафиксировать перед кодом порта):**
- **(A) Faithful-vendor.** Перенести hiddify `common/xray/*` + пакет `v2rayxhttp` как **новые файлы** (namespaced), адаптировать импорты под v1.13.13. Плюс: максимальная совместимость с реальными XHTTP-серверами, проверенный код. Минус: больший footprint (но всё — новые файлы → **нулевая зона касания upstream**, что согласуется с CONSTITUTION). Тащит `quic-go`/`http3` (часть уже в go.mod sing-box).
- **(B) Lean-native.** Написать компактный XHTTP-клиент на примитивах sing-box (по образцу in-tree `transport/v2rayhttpupgrade`). Плюс: меньше кода, меньше зависимостей. Минус: больше оригинальной работы и риск несовпадения с Xray по краям (`mode=auto`, padding, xmux).
**Рекомендация:** **(A)** — приоритет проекта №2 (корректность/совместимость) важнее объёма, а изоляция в новых файлах сохраняет ребейзопригодность. Footprint велик, но не увеличивает конфликтность ребейза.
**Обязательно для приёмки:** живой XHTTP-сервер (Xray) для end-to-end проверки — синтетического `sing-box check` недостаточно (XHTTP под активной разработкой, версии client↔server должны совпадать).
+54
View File
@@ -0,0 +1,54 @@
# TASKS — 002-XHTTP_CLIENT_TRANSPORT
## v2 — полная клиентская поддержка (ветка `lx-1.14-xhttp-full`)
### Аудит (основание спеки) — ✅ сделано
- [x] Аудит Xray-core `transport/internet/splithttp` + sing-box-extended `transport/v2rayxhttp`/`option`
- [x] Adversarial-верификация каждого из 16 параметров против исходника
- [x] Карта параметров → [PARAM_MAP.md](PARAM_MAP.md) (клиент/сервер, дефолты, on-wire, impl-заметки)
- [x] Классификация: 12 клиентских + 2 tuning-бонуса реализуем; 4 server-only — accept-but-ignore
### Спека (ветка `lx-1.14`) — ✅ сделано
- [x] `SPEC.md` (минимальная) → `SPEC_v1.md` (история сохранена)
- [x] Новая полная `SPEC.md` + `PARAM_MAP.md` — коммит `cafbe546`
### Опции — ✅ сделано
- [x] `option/v2ray_xhttp.go`: +20 полей в `V2RayXHTTPOptions` (placement/key/obfs/tuning + server-only)
- [x] Range-поля строкой `"min-max"` (нет `badoption.Range` в `sing`; решение в SPEC §4.1)
- [x] `option/v2ray_transport.go` уже прокидывает весь `XHTTPOptions` (без новых правок)
### Транспорт — ✅ сделано
- [x] `meta.go`: placement-движок `applyMeta`, `normalizeMeta` (mode-gate'ы, дефолты), uplink-data сборка
- [x] `xpadding.go`: obfs-движок `applyXPadding`, `repeat-x` + `tokenish` (HPACK-Huffman-тюнинг)
- [x] `client.go`: `meta`/`paddingRange`, `NewClient`→`normalizeMeta`, `baseURL`+`newRequest(...)`
- [x] `conn.go`: dial-функции под новую сигнатуру; `packetConn.Write` (разбиение + троттлинг + placement)
### Проверки — ✅ сделано
- [x] `xhttp_test.go` + `url_test.go`: 16 тест-функций (placement/uplink/obfs/tokenish/валидация/регрессия)
- [x] `go test -tags with_xhttp ./transport/v2rayxhttp/` → 16/16 PASS
- [x] `lx-test/config/xhttp_obfs_full.json` + `./sing-box check` → PASS (все 3 xhttp-конфига)
- [x] `make -f Makefile.lx lx-build` → ок; `go vet`/`gofmt` → чисто; tagged/untagged build → ок
- [x] Негатив: без `with_xhttp` → `unknown transport type: xhttp`
### Лайв-верификация
- [x] **Дефолтный путь лайв-подтверждён** на реальных нодах (igareck/vpn-configs): 4 живых XHTTP-сервера,
packet-up + stream-one(reality), скачивание 1 МБ через IP сервера — закрывает stream-one-TODO из §011
- [ ] **Лайв obfs/placement** против сервера, *настроенного* на obfs (публичные ноды на дефолте — не закрывают)
### Закрытие
- [x] IMPLEMENTATION_REPORT.md (секция v2)
- [ ] Merge ветки `lx-1.14-xhttp-full` → `lx-1.14` (по решению пользователя)
---
## v1 (история) — Complete
### Registry-рефактор (касание upstream) — ✅
- [x] `transport/v2ray/registry.go`: реестр + `RegisterClient` — коммит `e111f800`
- [x] `// lx:` правка `NewClientTransport` — lookup вместо `switch` — коммит `2d97ff56`
### Опции / константа / клиент — ✅
- [x] `V2RayTransportTypeXHTTP="xhttp"` — `2d97ff56`
- [x] lean-native клиент (`client.go`/`conn.go`/`register.go`) — `d1b434fc`
- [x] padding в Referer, sessionId path-layout, stream-one bare-path fix (задача 011) — `5a398a5e`
- [x] Лайв packet-up/auto против Xray/3x-ui (см. IMPLEMENTATION_REPORT v1)
@@ -0,0 +1,343 @@
# Разбор `vless://…type=xhttp` → sing-box transport (для парсера ссылок)
> Справочник для команд **Android-клиента** и **лаунчера**: как превратить VLESS-ссылку
> с XHTTP-транспортом в `outbound.transport` конфига sing-box-lx (тег сборки `with_xhttp`).
> Источник полей — [PARAM_MAP.md](PARAM_MAP.md) (в этой же папке спеки).
> Версия транспорта: SPEC 002 v2 (полная клиентская поддержка).
---
## 0. TL;DR
Ссылка вида:
```
vless://<uuid>@<host>:<port>?type=xhttp&<params...>[&extra=<urlencoded-json>]#<remark>
```
даёт `outbound`:
```jsonc
{
"type": "vless",
"server": "<host>",
"server_port": <port>,
"uuid": "<uuid>",
"flow": "", // XHTTP несовместим с xtls-rprx-vision → flow всегда пустой
"tls": { ... }, // из security/sni/fp/pbk/sid/alpn (см. §3)
"transport": {
"type": "xhttp",
... // из xhttp-параметров и extra (см. §2)
}
}
```
**Два источника XHTTP-полей в URL:**
1. Плоские query-параметры (`path`, `mode`, `host`, …).
2. Параметр **`extra`** — это **URL-encoded JSON** с дополнительными полями (`scMaxEachPostBytes`,
`xPaddingBytes`, `noGRPCHeader`, …). Его надо: `urldecode` → `JSON.parse` → влить в transport.
---
## 1. Алгоритм парсера (по шагам)
1. Срезать схему `vless://`, отделить `#remark` (фрагмент) — это только подпись ноды.
2. `userinfo@host:port` → `uuid` / `server` / `server_port`.
3. Разобрать query-string в map. **Все значения percent-decoded.**
4. Если `type` (он же может прийти как `transport`/`net` в других форматах) != `xhttp` — это не наш транспорт, парсить по другой ветке.
5. Если есть `extra` → `JSON.parse(urldecode(extra))` и слить ключи в ту же map (extra имеет приоритет для своих ключей).
6. Собрать `tls` (§3) и `transport` (§2) по таблицам ниже.
7. Поля, которых нет в URL, **не выставлять** — у транспорта корректные дефолты (см. колонку «дефолт»).
---
## 2. Маппинг XHTTP-параметров → `transport`
JSON-ключи sing-box — **snake_case**. Источник в URL — camelCase (как в Xray/extended).
### 2.1 Базовые (приходят как плоские query ИЛИ в `extra`)
| URL-параметр | → transport JSON | Тип | Дефолт | Примечание |
|--------------|------------------|-----|--------|------------|
| `host` | `host` | str | SNI/server | HTTP Host header |
| `path` | `path` | str | `/` | префикс пути; **обрезать `?…` хвост** (см. §4) |
| `mode` | `mode` | str | `auto` | `auto`\|`packet-up`\|`stream-up`\|`stream-one` |
| `xPaddingBytes` | `x_padding_bytes` | str | `100-1000` | формат `"min-max"` или одиночное число |
| `noGRPCHeader` | `no_grpc_header` | bool | `false` | |
| (headers) | `headers` | obj | — | произвольные доп. заголовки (если клиент их хранит) |
### 2.2 Placement / keys (расширенные, v2)
| URL-параметр | → transport JSON | Тип | Дефолт | Допустимые |
|--------------|------------------|-----|--------|------------|
| `sessionPlacement` | `session_placement` | str | `path` | path\|query\|header\|cookie |
| `sessionKey` | `session_key` | str | `X-Session`/`x_session` | |
| `seqPlacement` | `seq_placement` | str | `path` | path\|query\|header\|cookie |
| `seqKey` | `seq_key` | str | `X-Seq`/`x_seq` | |
| `uplinkDataPlacement`| `uplink_data_placement` | str | `auto` | body\|auto\|header\|cookie |
| `uplinkDataKey` | `uplink_data_key` | str | `X-Data`/`x_data` | |
| `uplinkChunkSize` | `uplink_chunk_size` | str | (зависит от placement) | `"min-max"` |
| `uplinkHTTPMethod` | `uplink_http_method` | str | `POST` | upper-case; `GET` только в packet-up (вне — auto-fallback на `POST` + warning, не ошибка) |
### 2.3 X-Padding obfs (расширенные, v2)
| URL-параметр | → transport JSON | Тип | Дефолт | Допустимые |
|--------------|------------------|-----|--------|------------|
| `xPaddingObfsMode` | `x_padding_obfs_mode` | bool | `false` | |
| `xPaddingKey` | `x_padding_key` | str | `x_padding` | |
| `xPaddingHeader` | `x_padding_header` | str | `X-Padding` | |
| `xPaddingPlacement` | `x_padding_placement` | str | `queryInHeader` | cookie\|header\|query\|queryInHeader |
| `xPaddingMethod` | `x_padding_method` | str | `repeat-x` | repeat-x\|tokenish |
### 2.4 Packet-up tuning (обычно приходят в `extra`)
| URL-параметр (extra) | → transport JSON | Тип | Дефолт |
|----------------------|------------------|-----|--------|
| `scMaxEachPostBytes` | `sc_max_each_post_bytes` | str (`"min-max"`) | `1000000-1000000` |
| `scMinPostsIntervalMs` | `sc_min_posts_interval_ms` | str (`"min-max"`) | `30-30` |
> ⚠️ В `extra` эти значения часто приходят **числом** (`"scMaxEachPostBytes":"1000000"`,
> `"scMinPostsIntervalMs":30.0`). Транспорт sing-box-lx ждёт **строку `"min-max"`** — превратить
> одиночное число `N` в строку `"N-N"` (или просто `"N"` — парсер примет и то, и то). Дробную часть
> у `30.0` отбросить → `"30"`.
### 2.5 Игнорируемые / серверные
| URL-параметр | Действие |
|--------------|----------|
| `scMaxConcurrentPosts` | **Accept-but-ignore.** Legacy-поле старого Xray (в текущем Xray/extended его нет — там 1 POST-тело за раз). Клиент sing-box-lx шлёт upload-POST последовательно (= текущий Xray). Можно влить как `sc_max_concurrent_posts` (принято, но не используется) — или опустить (см. §6). |
| `serverMaxHeaderBytes`, `noSSEHeader`, `scMaxBufferedPosts`, `scStreamUpServerSecs` | server-only. Можно влить как `server_max_header_bytes`/`no_sse_header`/`sc_max_buffered_posts`/`sc_stream_up_server_secs` (клиент их принимает, но игнорирует) — или просто опустить. |
| `fragment`, `fm`, `fragment=...` | TLS-фрагментация (Xray-специфика). **Не часть XHTTP.** Маппить в свою TLS-fragment-фичу, если есть; иначе опустить. |
| `flow` | Для XHTTP всегда пустой (vision несовместим). |
---
## 3. TLS / Reality (из общих VLESS-параметров)
| URL-параметр | → JSON | Примечание |
|--------------|--------|------------|
| `security=tls` | `tls.enabled=true` | |
| `security=reality` | `tls.enabled=true` + `tls.reality.enabled=true` | |
| `security=none` / отсутствует | без `tls` (plaintext h2c) | редкие plain-XHTTP ноды |
| `sni` | `tls.server_name` | |
| `fp` | `tls.utls.fingerprint` (+ `tls.utls.enabled=true`) | `chrome`/`firefox`/… |
| `alpn` | `tls.alpn` (split по `,`) | напр. `h2,http/1.1` → `["h2","http/1.1"]` |
| `pbk` | `tls.reality.public_key` | только при reality |
| `sid` | `tls.reality.short_id` | только при reality |
| `spx` | (Xray spiderX) — у sing-box нет аналога, опустить | |
| `allowInsecure` / `insecure=1` | `tls.insecure=true` | |
---
## 4. Подводные камни (обязательно учесть)
1. **`path` с query-хвостом.** Реальные ноды дают `path=/GaMeOpTiMiZeR?ed=2048`. Часть после `?` — это
НЕ путь; либо отрезать (`path` = `/GaMeOpTiMiZeR`), либо сохранить как есть, если ваш клиент это умеет.
sing-box-lx сам нормализует путь, но `?` внутри `path` лучше срезать на стороне парсера.
2. **`extra` — это JSON, не query.** Сначала `urldecode`, потом `JSON.parse`. Не пытаться парсить как `&k=v`.
3. **Числа vs строки в `extra`.** `scMaxEachPostBytes`/`scMinPostsIntervalMs` приходят числами →
привести к строке `"min-max"` (см. §2.4).
4. **`mode=auto` сам резолвится в транспорте** (reality→stream-one, иначе→packet-up). Парсеру **не нужно**
подменять `auto` на конкретный режим — передавать `auto` как есть.
5. **`flow` всегда пустой** для XHTTP. Если в ссылке `flow=xtls-rprx-vision` — это ошибка ноды для XHTTP;
ставить `flow=""`.
6. **camelCase → snake_case** — не передавать camelCase-ключи в JSON sing-box, он их не поймёт.
---
## 5. Готовые примеры (из реальных подписок)
### Пример A — Reality + auto (минимальный целевой кейс)
URL:
```
vless://4b5cdcab-289e-4d9a-8ebd-f70a4f49db6a@sup.le3service.ir:443?mode=auto&path=/&security=reality&encryption=none&pbk=cmPAZWGaEWFOPF92El1peuQFoScxyS6XsGADu8nhjVc&host=sup.le3service.ir&fp=chrome&spx=/w22l0muhE4dqe8u&type=xhttp&sni=varzesh3.com&sid=5f14f1185c#France
```
→ sing-box:
```jsonc
{
"type": "vless",
"server": "sup.le3service.ir",
"server_port": 443,
"uuid": "4b5cdcab-289e-4d9a-8ebd-f70a4f49db6a",
"flow": "",
"tls": {
"enabled": true,
"server_name": "varzesh3.com",
"utls": { "enabled": true, "fingerprint": "chrome" },
"reality": {
"enabled": true,
"public_key": "cmPAZWGaEWFOPF92El1peuQFoScxyS6XsGADu8nhjVc",
"short_id": "5f14f1185c"
}
},
"transport": {
"type": "xhttp",
"host": "sup.le3service.ir",
"path": "/",
"mode": "auto"
}
}
```
### Пример B — TLS + packet-up с `extra` (tuning-поля)
URL (фрагмент с `extra`):
```
vless://c59eb5ed-…@199.232.244.214:443?type=xhttp&mode=packet-up&security=tls&sni=manage.fastly.com&host=oh6.global.ssl.fastly.net&path=%2F&alpn=h3&fp=chrome&encryption=none&extra=%7B%22scMaxEachPostBytes%22%3A%221000000%22%2C%22scMaxConcurrentPosts%22%3A100.0%2C%22scMinPostsIntervalMs%22%3A30.0%2C%22xPaddingBytes%22%3A%22100-1000%22%2C%22noGRPCHeader%22%3Afalse%7D#France
```
`extra` после `urldecode` + `JSON.parse`:
```json
{
"scMaxEachPostBytes": "1000000",
"scMaxConcurrentPosts": 100.0,
"scMinPostsIntervalMs": 30.0,
"xPaddingBytes": "100-1000",
"noGRPCHeader": false
}
```
→ sing-box transport (числа из extra приведены к `"min-max"`-строкам; `scMaxConcurrentPosts` отброшен):
```jsonc
{
"type": "xhttp",
"host": "oh6.global.ssl.fastly.net",
"path": "/",
"mode": "packet-up",
"x_padding_bytes": "100-1000",
"sc_max_each_post_bytes": "1000000-1000000",
"sc_min_posts_interval_ms": "30-30",
"no_grpc_header": false
}
```
(плюс `tls.enabled=true`, `tls.server_name="manage.fastly.com"`, `tls.utls.fingerprint="chrome"`, `tls.alpn=["h3"]`)
### Пример C — obfs-режим (расширенный, как настраивают анти-DPI ноды)
Если нода-сервер настроена на obfs (поля в URL приходят плоскими или в `extra`):
```
...&type=xhttp&mode=packet-up&xPaddingObfsMode=true&xPaddingPlacement=header&xPaddingMethod=tokenish&sessionPlacement=header&seqPlacement=query&uplinkDataPlacement=header...
```
→
```jsonc
{
"type": "xhttp",
"mode": "packet-up",
"x_padding_obfs_mode": true,
"x_padding_placement": "header",
"x_padding_method": "tokenish",
"session_placement": "header",
"seq_placement": "query",
"uplink_data_placement": "header"
}
```
---
## 6. Известные ограничения клиента (что НЕ маппить)
- `scMaxConcurrentPosts` — legacy-поле (удалено из текущего Xray-core и sing-box-extended; там upload сериализован в 1 POST-тело за раз). Наш клиент тоже шлёт последовательно = текущий Xray. Поле принимается (`sc_max_concurrent_posts`), но игнорируется.
- `downloadSettings` (асимметричный download-транспорт) — не поддержан; `mode=auto`+reality+downloadSettings
у нас всё равно даст stream-one, не stream-up.
- `spx` (spiderX), Xray browser-dialer — нет аналога.
- **HTTP/3 (`alpn=h3` / QUIC).** Наш XHTTP-клиент работает поверх **HTTP/2** (`http2.Transport`). Xray
умеет H1/H2/H3. Ноды, помеченные `alpn=h3`, мы обслуживаем по H2 (если сервер допускает); если сервер
**требует строго h3** — коннект не встанет. Это архитектурное ограничение транспорта, вне SPEC 002
(отдельная будущая задача «XHTTP over HTTP/3»). Парсеру: `alpn` маппить как есть, но `h3`-only ноды
помечать как потенциально неработающие.
- `fragment` / `fm` (TLS-фрагментация Xray) — не часть XHTTP; маппить в свою TLS-fragment-фичу (если есть)
или опускать.
---
## 7. Чек-лист для интегратора
- [ ] `type=xhttp` распознаётся как XHTTP-транспорт.
- [ ] `extra` декодируется как URL-encoded JSON и вливается в transport.
- [ ] Числовые `sc*`-поля из `extra` → строка `"min-max"`.
- [ ] camelCase → snake_case по таблицам §2.
- [ ] `path` с `?`-хвостом обрезается/обрабатывается.
- [ ] `security=reality` → `tls.reality.{public_key,short_id}` из `pbk`/`sid`.
- [ ] `mode=auto` передаётся как есть (резолвится в ядре).
- [ ] `flow=""` для XHTTP.
- [ ] `scMaxConcurrentPosts` и server-only поля игнорируются (или приняты-но-неактивны).
- [ ] Результат проходит `sing-box check -c`.
---
## 8. Эталон (golden fixture для round-trip / маппинг-тестов)
Готовая фикстура: все **14 новых полей** в **не-дефолтных** значениях (чтобы тест ловил перепутанные
имена/значения, а не просто отсутствие ключа). ✅ **Проверено `sing-box check -c` на текущем lx-бинаре
(`with_xhttp`).**
> Реальный аналог в репозитории: [lx-test/config/xhttp_obfs_full.json](../../lx-test/config/xhttp_obfs_full.json)
> (та же фикстура + server-only/legacy поля для покрытия accept-but-ignore).
### 8.1 Эталонный `transport`-блок (sing-box JSON)
```jsonc
{
"type": "xhttp",
"host": "www.example.com",
"path": "/xhttp",
"mode": "packet-up",
"headers": { "User-Agent": "Mozilla/5.0" },
"x_padding_bytes": "100-1000",
"no_grpc_header": true,
"session_placement": "header", // != default "path"
"session_key": "X-Session",
"seq_placement": "query", // != default "path"
"seq_key": "x_seq",
"uplink_data_placement": "header", // != default "auto" (требует mode=packet-up)
"uplink_data_key": "X-Data",
"uplink_chunk_size": "3000-4000",
"uplink_http_method": "POST",
"x_padding_obfs_mode": true, // != default false
"x_padding_key": "x_padding",
"x_padding_header": "X-Padding",
"x_padding_placement": "header", // != default "queryInHeader"
"x_padding_method": "tokenish", // != default "repeat-x"
"sc_max_each_post_bytes": "1000000-1000000",
"sc_min_posts_interval_ms": "30-30"
}
```
(Полный outbound с этим блоком: vless + tls/utls + этот transport → проходит `sing-box check`.)
### 8.2 Эквивалентная `vless://`-ссылка (плоский camelCase — то, что пишет `toUri()`)
Тот же transport в URL-форме (для round-trip-теста `parseUri` → `toSingbox`):
```
vless://b831381d-6324-4d53-ad4f-8cda48b30811@www.example.com:443?type=xhttp&security=tls&sni=www.example.com&fp=chrome&encryption=none&host=www.example.com&path=%2Fxhttp&mode=packet-up&xPaddingBytes=100-1000&noGRPCHeader=true&sessionPlacement=header&sessionKey=X-Session&seqPlacement=query&seqKey=x_seq&uplinkDataPlacement=header&uplinkDataKey=X-Data&uplinkChunkSize=3000-4000&uplinkHTTPMethod=POST&xPaddingObfsMode=true&xPaddingKey=x_padding&xPaddingHeader=X-Padding&xPaddingPlacement=header&xPaddingMethod=tokenish&scMaxEachPostBytes=1000000&scMinPostsIntervalMs=30#golden
```
(`scMaxEachPostBytes`/`scMinPostsIntervalMs` тут даны одиночным числом `1000000`/`30` — транспорт
принимает и `"N"`, и `"N-N"`; на выходе `toSingbox` нормализуйте в строку.)
### 8.3 Таблица дефолтов (для omitempty в `toUri()` — НЕ писать, если == дефолт)
| Поле (camelCase) | Дефолт | Писать в toUri только если |
|------------------|--------|----------------------------|
| `sessionPlacement` | `path` | != path |
| `seqPlacement` | `path` | != path |
| `uplinkDataPlacement`| `auto` | != auto |
| `uplinkHTTPMethod` | `POST` | != POST |
| `xPaddingObfsMode` | `false` | == true |
| `xPaddingPlacement` | `queryInHeader`| != queryInHeader |
| `xPaddingMethod` | `repeat-x` | != repeat-x |
| `xPaddingBytes` | `100-1000` | != 100-1000 |
| `*Key` / `*Header` | placement-зависимый (`X-Session`/`x_session`, `X-Seq`/`x_seq`, `X-Data`/`x_data`, `x_padding`/`X-Padding`) | задан явно != дефолта |
| `scMaxEachPostBytes` | `1000000` | != 1000000 |
| `scMinPostsIntervalMs` | `30` | != 30 |
> Записывая в `toUri()` только не-дефолтные поля, вы сохраняете инвариант `parseUri(toUri(spec)) ≈ spec`
> без раздувания URI: на входе отсутствующее поле и поле-с-дефолтом дают одну и ту же `spec`.
+59
View File
@@ -0,0 +1,59 @@
# HISTORY — SPEC 003 AWG2 client endpoint
Хронология вендоренного wireguard-go: базы графта, миграции, что менял upstream. Актуальное состояние — в [SPEC.md](SPEC.md); здесь только «как было раньше и почему переделали».
---
## Почему граф, а не прямой `replace` на amneziawg-go
Первая идея — подключить `amnezia-vpn/amneziawg-go` напрямую через `replace`. **Не работает:** amneziawg-go основан на *upstream* wireguard-go и не имеет sagernet-добавок (`Send(offset)`, `InputPacket`, `conn` reserved/control), на которых держится `transport/wireguard` sing-box. Прямой replace ломает сборку.
Решение — **3-way graft**: обфускация Amnezia накладывается поверх `sagernet/wireguard-go` (а не наоборот). Так контракт sing-box↔device остаётся sagernet'овским, обфускация аддитивна. Форк-модуль — `Leadaxe/wireguard-go-awg2-lx`.
## База графта: эволюция
| Дата | Submodule commit | Sagernet-база | wireguard-go версия | Контекст |
|------|------------------|---------------|---------------------|----------|
| 2026-06-09 | `27290b6` | `506b7631853c` | (pre-v0.0.3) | Первый граф на `v1.13.13`. 3-way merge `amnezia/master` (`f4f4c99`, AWG2 + S4-keepalive) поверх sagernet. |
| ~2026-07-02 | `e5feca7` | `19b0d35` (v0.0.3) | v0.0.3 | Миграция форка на ветку `lx-1.14` (upstream `v1.14.0-alpha.*`). Re-graft на v0.0.3. §010-GRO-guard из графа выброшен (v0.0.3 фиксит на источнике). |
| 2026-07-08 | `4b3a6c9` | `2c27bbf4f97f` (v0.0.5) | v0.0.5 | Re-graft на v0.0.5 вслед за upstream/testing. См. ниже. |
## Re-graft v0.0.3 → v0.0.5 (2026-07-08)
**Триггер:** upstream `sagernet/wireguard-go` двинулся `v0.0.3 → v0.0.5` (коммит-пин `2c27bbf4f97f`) ради **L3-forwarding**. sing-box upstream/testing забампил pin; форк нужно догнать.
**Что upstream изменил (v0.0.3 → v0.0.5), 5 коммитов, 8 файлов:**
- `9de6dc3 Add batched InputPackets` + `2c27bbf FIx batched InputPackets` — новый батч-вход `InputPackets([]*InputPacketRef) []*InputPacketRef` (возвращает unmatched refs — для L3-forward, где нет пира → вызывающий строит ICMP-unreachable). **`InputPacket` (singular) НЕ удалён** — переписан на size-based буфер + backpressure-кап `maxQueuedInputPackets`.
- `8403cdb Rework outbound buffer management` — **`QueueOutboundElement.buffer` сменил тип `*[MaxMessageSize]byte` → `[]byte`** (size-based пул через `GetOutboundBuffer(n)`/`PutOutboundBuffer` из sing-аллокатора, вместо фиксированного `messageBuffers`-пула). Элемент-пулы `outboundElements*` перешли с `WaitPool` на `sync.Pool`. Добавлен `peer.queuedOutboundPackets atomic.Int32` (backpressure-счётчик).
- `57baac9 Add batched UDP I/O on Darwin` + `fcbb7c4 Coalesce UDP GSO segments` — новый `conn/msgx_darwin.go` (sendmsg_x/recvmsg_x), GSO-iovec coalescing в `bind_std.go`.
**Оценка риска для графа ДО работы** (по памяти) была завышена: «`buffer`-type change ломает все AWG-хуки в send.go — основная работа». **По факту оказалось иначе:**
**Итог re-graft (`git apply --3way` граф-diff'а на v0.0.5):**
- **15 из 16** граф-файлов легли **чисто**. Конфликт — **только `send.go`**, и **на одной строке**: upstream добавил `peer.queuedOutboundPackets.Add(-…)` там, где граф добавил пустую строку. Взяли upstream (backpressure нужен).
- **Почему `buffer`-type change НЕ сломал граф:** AWG-хуки уже везде работают с `elem.buffer` как со **срезом** (`buffer[:MessageTransportHeaderSize]`, сдвиг `buffer[i+padding]`), а не как с массивом-указателем. Переход `*[N]byte → []byte` для них прозрачен.
- **Почему upstream `InputPacket`/`InputPackets` встали verbatim:** граф `send.go` их **не трогает** (junk-логика графа — в `SendHandshakeInitiation`, а не в input-пути), поэтому конфликта не было — upstream-версии сохранились.
- **Почему `RoutineEncryption` сшилась без ручного weave:** при `MessageEncapsulatingTransportSize = 0` upstream-offset `buffer[METS:METS+HeaderSize]` схлопывается к графовому `buffer[:HeaderSize]`. Граф-версия (заголовок в начале, без финального encapsulating re-slice) наложилась как есть.
**Вывод:** несущий инвариант `MessageEncapsulatingTransportSize = 0` — то, что делает re-graft дешёвым: он нейтрализует единственную точку, где upstream и граф расходятся по layout буфера.
Сборка после re-graft: device/conn/tun на linux/android/windows/darwin ✅; полный sing-box CLI с LX_TAGS (Go 1.24.7) ✅; тесты `transport/wireguard` + `protocol/wireguard` зелёные ✅.
## MTU / EMSGSIZE — находка 2026-06-10
При лайв-тесте AWG2-узла рукопожатие проходило, но трафик не шёл: `sendmsg: message too long` (**EMSGSIZE**). Причина — `S3`/`S4`: junk дописывается к **каждому** transport-сообщению, и обфусцированный data-пакет перерастает path MTU (1500, DF). Handshake маленький — проходит; transport — нет. Plain WG к тому же серверу с `mtu 1420` работает (S-junk нет).
Эмпирика (тот же узел, менялся только `mtu`, `S3=S4=60`):
| mtu | результат |
|----:|-----------|
| 1420 | ❌ EMSGSIZE |
| 1380 | ✅ ~58 ms |
| 1280 | ✅ ~55 ms |
| 1200 | ✅ ~60 ms |
Результат — MTU-политика в текущем SPEC.md (auto-default 1280 + warn при превышении бюджета). Источник находки — заметка агента лаунчера (`singbox-launcher`). Это не баг ядра, а размерный оверхед S-junk.
## Безопасность
Секреты живого AWG-сервера **никогда** не попадали в репозитории — лайв-конфиг держался только в `/tmp` и затирался (`shred`). Репо-конфиг `lx-test/config/awg2_basic.json` — с фейк-ключами.
@@ -0,0 +1,69 @@
# IMPLEMENTATION_REPORT — 003 AWG2_CLIENT_ENDPOINT
**Дата:** 2026-06-09 · **Статус:** Complete — **функционален и проверен живым AWG2-сервером** · **База:** `v1.13.13`
## Итог
Полноценный клиент **AmneziaWG 2.0**: конфиг валидируется, собирается `with_awg`, и **реально подключается** к серверу AmneziaWG 2.0 с обфускацией (junk + S1–S4 + H1–H4 + CPS I1–I5).
## Архитектура (две части)
**1. sing-box-lx (скаффолдинг + S3/S4):**
- `option/wireguard_awg.go` — `AmneziaWGOptions`: `Jc/Jmin/Jmax`, **`S1/S2/S3/S4`**, `H1–H4` (uint32), `I1–I5` (string, регистр сохраняется), встроены (promoted) в `WireGuardEndpointOptions`.
- `transport/wireguard/device_awg.go` (`//go:build with_awg`) — `awgIpcLines()` шлёт IpcSet-ключи `jc=/jmin=/jmax=/s1..s4=/h1..h4=/i1..i5=` в device; `device_stub_awg.go` без тега даёт явную ошибку при заданных AWG-полях.
- Проброс опций: `option.WireGuardEndpointOptions` → `protocol/wireguard/endpoint.go` → `transport/wireguard/endpoint.go` (всё `// lx`).
**2. amneziawg-go активирован через merged-форк (главное достижение):**
- amneziawg-go основан на *upstream* wireguard-go и не имеет sagernet-добавок (`Send(offset)`, `InputPacket`, `conn` reserved/control), на которых держится `transport/wireguard`. Прямой `replace` ломает сборку.
- Решение **(A)**: `Leadaxe/wireguard-go` = **git 3-way merge** (`merge-base 469159e`) обфускации `amnezia/master` (тип `f4f4c99`, AWG2 + S4-keepalive) поверх `sagernet/wireguard-go@506b7631853c`. Контракт sing-box сохранён (база — sagernet), обфускация добавлена.
- Ключевое упрощение: **`MessageEncapsulatingTransportSize = 0`** — нейтрализует 8-байтный headroom sagernet (sing-box-lx его не использует), и обфускация Amnezia встаёт чисто без weave-конфликтов в send-пути.
- `conn/tun/ipc` оставлены **чисто sagernet** (обфускация только в `device/`: новые `obf*.go`+`magic-header.go` + графты в `send/receive/device/uapi`).
- Подключение: git submodule `submodules/wireguard-go` (Leadaxe/wireguard-go @`27290b6`) + `// lx` `replace github.com/sagernet/wireguard-go => ./submodules/wireguard-go`. Воспроизводимо для CI.
## Проверки
- `make -f Makefile.lx lx-build` → ок; `check awg2_basic.json` → pass; сборка без `with_awg` → обычный WG; gofmt/синтаксис merge чисты.
- **Лайв-тест (реальный сервер AmneziaWG 2.0):**
```
peer - sending handshake initiation
peer - received handshake response ← обфусцированный хендшейк прошёл
peer - receiving keepalive packet ← keepalive 25s
curl --socks5 через туннель → <server IP> ← трафик идёт через сервер
```
Параметры: Jc=10/Jmin=50/Jmax=100, S1=S2=20/S3=S4=60, H1–H4, I1–I3 (I1/I2 — мимикрия под STUN `0x2112a442`, I3 random).
## MTU при ненулевых S3/S4 (EMSGSIZE) — дополнение 2026-06-10
При лайв-тесте AWG2-узла рукопожатие проходило, но трафик не шёл: ядро спамило `failed to send data packets: … sendmsg: message too long` (**EMSGSIZE**). Причина — прямое следствие `s3`/`s4`: junk дописывается к **каждому transport-сообщению**, и обфусцированный data-пакет перерастает path MTU физического интерфейса (1500, DF). Handshake маленький — проходит; transport — нет. Plain WG к тому же серверу с `mtu 1420` работает (S-junk нет).
Бюджет: `mtu ≤ 1500 − 28 (UDP/IP) − 32 (WireGuard) − max(S3, S4)`. Для `S3=S4=60` → `mtu ≤ 1380`; рекомендуемый клиентский MTU AmneziaWG — **1280** (запас на PPPoE/вложенные туннели). Эмпирика (тот же узел/сервер, менялся только `mtu`):
| mtu | результат |
|----:|-----------|
| 1420 | ❌ EMSGSIZE, данные не уходят |
| 1380 | ✅ ~58 ms, 0 ошибок |
| 1280 | ✅ ~55 ms |
| 1200 | ✅ ~60 ms |
Сделано:
- `docs-lx/lx-config.md` §2 — подраздел **MTU** (механика, формула, симптом, рекомендация 1280, держать `jmax` ниже path MTU) + пример понижен `1420 → 1280`.
- `transport/wireguard/endpoint.go` (`// lx`, gated `max(s3,s4) > 0` — plain WG нетронут):
- **auto-default**: при незаданном `mtu` на AWG-эндпоинте ставим рекомендованный **1280** вместо upstream-дефолта `1408` (который сам бы превышал бюджет и триггерил наш же warn).
- **warn**: при явно заданном `mtu` выше бюджета — предупреждение (handshake пройдёт, данные — нет). Path MTU зашит консервативно **1492** (PPPoE): `mtu ≤ 1492 − 28 − 32 − max(s3,s4)` → для `s3=s4=60` это `1372`. Эмпирический потолок выше (1380), т.к. тест шёл по реальному 1500-Ethernet; 1492 — запас под узкие пути.
- Проверено (`check`): AWG `s3=s4=60` без `mtu` → тихо (default 1280); `mtu=1420` → `WARN … consider mtu <= 1372`; plain WG без `mtu` → тихо (1408).
Подтверждение (amneziawg-go docs): рекомендуемый клиентский MTU 1280; если `Jmax` ≥ системного MTU — junk-пакет фрагментируется и теряется на узких путях. Это не баг ядра, а размерный оверхед S-junk. Источник находки — заметка агента лаунчера (`singbox-launcher`, 2026-06-10).
## Безопасность
Секреты сервера **никогда** не попадали в репозитории — лайв-конфиг держался только в `/tmp` и затёрт (`shred`). Репо `lx-test/config/awg2_basic.json` — с фейк-ключами.
## Зона касания upstream (ребейз)
sing-box-lx: `go.mod` (replace), `option/wireguard*`, `protocol/wireguard/endpoint.go`, `transport/wireguard/*` — всё `// lx`. Форк wireguard-go ребейзится отдельно на новый тег sagernet (повтор 3-way merge амнезии).
## Остаточное / дальше
- reserved-feature не применяет reserved-байты в obfuscated send (для plain-AWG не нужно — карта пуста).
- Можно перевести `replace` с submodule на pinned-pseudoversion (submodule достаточно).
- Лаунчер: AWG-поля (S1–S4, I1–I5) в визард + парсер `.conf`/awg-quick; рассматривает кламп MTU для AWG-узлов (первичная истина про оверхед `s3`/`s4` — здесь, см. раздел MTU).
+47
View File
@@ -0,0 +1,47 @@
# PLAN: 003 — AWG2_CLIENT_ENDPOINT
## 1. Архитектура
AmneziaWG = WireGuard-девайс с расширенным конфигом. В sing-box девайс создаётся в `transport/wireguard` поверх `github.com/sagernet/wireguard-go`. Стратегия: **подменить модуль на `amneziawg-go`** (API-совместим с wireguard-go) и **под `with_awg`** прокидывать AWG-поля в строку конфигурации девайса; endpoint остаётся типом `wireguard`.
## 2. Зависимость
- Submodule: `submodules/amneziawg-go` → `amnezia-vpn/amneziawg-go` (pin commit).
- `patches/` — для локальных фиксов поверх amneziawg-go (применяются скриптом сборки; пусто, если не нужны).
- `go.mod` `// lx:` replace `github.com/sagernet/wireguard-go => ./submodules/amneziawg-go`.
> Проверить: совпадает ли публичный API amneziawg-go (пакеты `device`, `conn`, `tun`) с тем, что импортирует `transport/wireguard`. Если расходится — минимальные `patches/` или адаптерный слой в новом файле.
## 3. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `.gitmodules`, `submodules/amneziawg-go` | **new** | Submodule, pinned commit |
| `patches/*.patch` | **new** | (опц.) патчи поверх amneziawg-go |
| `go.mod` / `go.sum` | `// lx:` | `replace` wireguard-go → submodule |
| `option/wireguard_awg.go` | **new** | Под-структура/поля `Jc,Jmin,Jmax,S1,S2,H1..H4,I1..I5` + парс/валидация |
| `option/…wireguard endpoint options` | `// lx:` | встроить AWG-поля в `WireGuardEndpointOptions` (минимум строк) |
| `transport/wireguard/device_awg.go` | **new** (`//go:build with_awg`) | Формирование AWG-строки конфига девайса |
| `transport/wireguard/device_stub_awg.go` | **new** (`//go:build !with_awg`) | Ошибка «awg not built», если AWG-поля заданы |
| `protocol/wireguard/endpoint.go` | `// lx:` | Прокинуть AWG-опции в создание девайса (1 ветка под флагом) |
| `include/awg.go` / правка `include/wireguard.go` | new/`// lx:` | Проводка под тегом (если нужно) |
| `lx-test/config/awg2_*.json` | **new** | Конфиги для `sing-box check` |
## 4. Зона касания upstream (для ребейза)
`go.mod`/`go.sum`, файл опций wireguard-endpoint, `protocol/wireguard/endpoint.go`, `transport/wireguard/*` (минимально). Девайс-логика и опции AWG — в **новых** файлах под тегом → основной конфликт только в `go.mod` и одной ветке endpoint.
## 5. Порядок работ
1. Submodule + `go.mod` replace; собрать обычный WG (без `with_awg`) — поведение upstream.
2. Сверить API amneziawg-go vs `transport/wireguard`; при необходимости `patches/`.
3. Опции AWG (`wireguard_awg.go` + `// lx:` поля).
4. `device_awg.go` (формат `jc=/h1=/i1=…`) под `with_awg`; stub без тега.
5. Прокидка в endpoint; конфиги; `check`; ручной коннект к AWG2-серверу.
## 6. Риски
- **API-дрейф** amneziawg-go относительно версии wireguard-go, на которую завязан upstream (`v0.0.2-beta.1.0.20260224…`). Возможен лаг — фиксировать совместимый коммит сабмодуля, не «latest».
- **Регистр I1–I5** (uppercase) — silent ignore при ошибке; валидировать.
- Взаимодействие junk/CPS с `persistent_keepalive` и MTU — проверять на реальном сервере.
- Доменный `server` + FakeIP: может потребоваться override резолва (референс hoaxisr) — добавлять только при подтверждённой необходимости.
+98
View File
@@ -0,0 +1,98 @@
# SPEC 003 — AmneziaWG 2.0 клиентский endpoint
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) — функционален, проверен живым AWG2-сервером |
| Тег сборки | `with_awg` (обфускация) поверх `with_gvisor` (стек) |
Клиентский **AmneziaWG 2.0** endpoint: обычный WireGuard-endpoint sing-box плюс обфускация DPI (junk-пакеты, магические заголовки, размерный padding, CPS-пакеты `I1–I5`). Без тега `with_awg` — обычный WireGuard upstream; AWG-поля в конфиге дают явную ошибку «не собрано».
---
## Что это
AmneziaWG обходит DPI, маскируя WireGuard-трафик:
- **Junk** (`Jc`/`Jmin`/`Jmax`) — `Jc` случайных пакетов размером `rand(Jmin..Jmax)` перед handshake initiation.
- **Магические заголовки** (`H1–H4`) — подменяют 4-байтный тип сообщения (init/response/cookie/transport); в AWG 2.0 — диапазоны `"N-M"`, из которых значение генерируется на лету.
- **Размерный padding** (`S1/S2` — на handshake, `S3/S4` — на **каждый** transport-пакет).
- **CPS-пакеты** (`I1–I5`) — снимки реального протокола (напр. QUIC Initial, STUN), которые уходят вперемешку с handshake, имитируя посторонний трафик. `I1` — центральный (см. [SPEC 009](../009-WIRESOCK_MASQUERADE_PROFILES/SPEC.md) — декларативные masquerade-профили `ip=quic/sip/dns`, которые генерируют `I1`).
Upstream sing-box AWG не принимает ([#4045](https://github.com/SagerNet/sing-box/issues/4045), closed not-planned) — реализовано в форке.
## Архитектура (два слоя)
Обфускация живёт **в вендоренном wireguard-go** (submodule), а sing-box только пробрасывает параметры. Это ключевое разделение: контракт `transport/wireguard` ↔ device остаётся sagernet'овским, обфускация — аддитивна.
### Слой 1 — вендоренный wireguard-go (`submodules/wireguard-go`)
Форк `Leadaxe/wireguard-go-awg2-lx` = **3-way graft** обфускации AmneziaWG 2.0 поверх `sagernet/wireguard-go`. Подключён как git submodule + `// lx` `replace github.com/sagernet/wireguard-go => ./submodules/wireguard-go` в `go.mod`; pin на конкретный graft-коммит.
**Что граф добавляет** (16 файлов в `device/`):
- **10 net-new**: `magic-header.go` (генератор `H1–H4` из спеки `"N"`/`"N-M"`) + `obf*.go` (CPS-цепочки `I1–I5`, junk-байты, timestamp/datasize кодеки).
- **6 modified**: `device.go` (AWG-state: `junk`, `headers`, `paddings`, `ipackets [5]*obfChain`), `send.go` (junk + CPS + padding в handshake/transport-путях), `receive.go` (детект magic-header на входе), `cookie.go`/`noise-protocol.go`/`uapi.go` (типы сообщений через генератор, парсинг AWG-ключей в IpcSet).
**Ключевой инвариант — `MessageEncapsulatingTransportSize = 0`** ([device/noise-protocol.go](../../submodules/wireguard-go/device/noise-protocol.go)). Upstream держит 8-байтный headroom перед transport-заголовком (для `conn.Bind.Send()`-префикса). Граф его **обнуляет**: AWG-обфускация формирует префикс сама (junk/CPS уходят отдельными буферами через `SendBuffers`, а не через encapsulating-space). При `= 0` upstream-выражения вида `buffer[MessageEncapsulatingTransportSize+MessageTransportHeaderSize:]` схлопываются к графовому виду `buffer[MessageTransportHeaderSize:]` — поэтому большинство upstream-функций компонуются с графом **без ручного weave**. Это несущий инвариант re-graft (§ ниже).
**Что граф НЕ трогает:** `conn/`, `tun/` — чисто sagernet (берутся из upstream verbatim). Обфускация замкнута в `device/`.
### Слой 2 — sing-box (проброс параметров, всё `// lx`)
- **`option/wireguard_awg.go`** — `AmneziaWGOptions`: `Jc/Jmin/Jmax`, `S1–S4`, `H1–H4` (тип `MagicHeader` — строка `"N"` или диапазон `"N-M"`, JSON-совместим с прежним uint32), `I1–I5` (string, регистр сохраняется). Promoted-встроены в `WireGuardEndpointOptions`.
- **`transport/wireguard/device_awg.go`** (`//go:build with_awg`) — `awgIpcLines()` рендерит IpcSet-ключи `jc=/jmin=/jmax=/s1..s4=/h1..h4=/i1..i5=`, дописываемые к WireGuard-конфигу устройства. `device_stub_awg.go` (`//go:build !with_awg`) даёт явную ошибку при заданных AWG-полях.
- **`transport/wireguard/endpoint.go`** — MTU-политика для AWG (см. ниже).
- **`validateJunk`** — отвергает `jmin > jmax` до старта: `amneziawg-go` считает `rand(0..jmax-jmin)+jmin`, и `jmax < jmin` даёт `rand.Int` с аргументом `≤ 0` → **паника ядра**. Гардим только этот crash-кейс.
Регистрация endpoint остаётся `C.TypeWireGuard` (AWG = WG + доп. поля, отдельный тип не вводим).
## MTU-политика (следствие S3/S4)
`S3`/`S4` дописывают junk к **каждому** transport-сообщению → обфусцированный data-пакет перерастает path MTU физического интерфейса (1500, DF) → ядро спамит `sendmsg: message too long` (**EMSGSIZE**), handshake проходит, а трафик — нет.
Бюджет: `mtu ≤ pathMTU − 28 (UDP/IP) − 32 (WireGuard) − max(S3, S4)`.
Логика в [transport/wireguard/endpoint.go](../../transport/wireguard/endpoint.go) (gated `max(s3,s4) > 0`, plain WG нетронут):
- **auto-default**: при незаданном `mtu` на AWG-эндпоинте — рекомендованный **1280** вместо upstream-дефолта 1408.
- **warn**: при явном `mtu` выше бюджета — предупреждение (`pathMTU = 1492`, консервативно под PPPoE). Для `s3=s4=60` → `mtu ≤ 1372`.
Держать `Jmax` ниже системного MTU (иначе junk-пакет фрагментируется и теряется на узких путях). Подробности: `docs-lx/lx-config.md` §2 (MTU).
## Процедура re-graft (при бампе upstream wireguard-go)
Когда upstream `sagernet/wireguard-go` двигает версию, граф переносится на новую базу. **Не merge, а controlled 3-way apply** граф-diff'а:
1. **База**: submodule → новый sagernet-коммит.
2. **Apply graft**: `git diff <старая-база> <старый-graft> | git apply --3way`. По практике 15/16 файлов ложатся чисто; конфликтует обычно только `send.go` (плотный upstream-путь).
3. **Разрешить конфликты вручную**, порядок по риску: `cookie`→`device`→`noise-protocol`→`uapi`→`receive`→**`send.go`** (высший — junk/padding-хуки в hot-path).
4. **Сверить несущие инварианты**: `MessageEncapsulatingTransportSize = 0`; графовый `RoutineEncryption` (заголовок в начале буфера, без финального encapsulating re-slice); AWG-state поля в `device.go`.
5. **Проверки**: сборка `device/conn/tun` на linux/android/windows/**darwin** (darwin особо — там upstream добавляет платформенный batch-send), затем полный `sing-box` с LX_TAGS, `go test ./transport/wireguard/ ./protocol/wireguard/`, **device-verify** живого AWG-туннеля (junk/handshake/трафик).
История конкретных re-graft'ов (какие базы, что менял upstream) — в [HISTORY.md](HISTORY.md).
## Критерии готовности
- `sing-box check -c` принимает wireguard-endpoint c `jc/h1/i1…` под `with_awg`.
- Реальный коннект к AmneziaWG 2.0 (device-verify): `sending handshake initiation` → `received handshake response` → keepalive → трафик через сервер, с непустыми `Jc` и хотя бы одним `I1`.
- Сборка **без** `with_awg`: обычный WG как upstream; AWG-поля → явная ошибка.
- `gofmt -l` чист на граф-файлах; `go vet`, тесты `transport/wireguard` + `protocol/wireguard` — зелёные.
## Изоляция и merge-зона
- **sing-box-lx**: `go.mod` (replace + pin), `option/wireguard_awg.go` + `// lx`-поля в основной struct, `transport/wireguard/device_awg*.go`, MTU-блок в `transport/wireguard/endpoint.go`, проброс в `protocol/wireguard/endpoint.go` — всё `// lx`.
- **submodule wireguard-go**: ребейзится отдельно (см. процедуру re-graft), не входит в merge-зону основного репо кроме pin в `go.mod`.
## Смежные фичи
- [SPEC 009](../009-WIRESOCK_MASQUERADE_PROFILES/SPEC.md) — декларативные masquerade-профили (`ip=quic/sip/dns`), генерирующие `I1`/`I2`.
- [SPEC 020](../020-MULTI_WG_IDLE_BUFFER_HEAT/SPEC.md) — idle-suspend WG/AWG-устройств (Down/Up); опирается на стабильный device-API той же вендоренной базы.
## Вне скоупа
- AWG inbound/server — форк client-focused.
- Парсинг `awg-quick`/`.conf` — забота лаунчера/UI.
- AmneziaWG 1.x как отдельный режим (2.0 обратно совместима по базовым полям).
## Ссылки
- [AmneziaWG 2.0 — Amnezia Docs](https://docs.amnezia.org/documentation/instructions/new-amneziawg-selfhosted/)
- [amneziawg-go](https://github.com/amnezia-vpn/amneziawg-go) · [hoaxisr/amnezia-box (референс интеграции)](https://github.com/hoaxisr/amnezia-box)
+28
View File
@@ -0,0 +1,28 @@
# TASKS — 003-AWG2_CLIENT_ENDPOINT
## Зависимость
- [ ] Submodule `submodules/amneziawg-go` (pin коммит, совместимый с wireguard-go upstream)
- [ ] `// lx:` `replace` в `go.mod`; `go mod tidy`; сборка обычного WG без `with_awg` = upstream
- [ ] Сверить API amneziawg-go vs `transport/wireguard`; при расхождении — `patches/`
## Опции
- [ ] `option/wireguard_awg.go`: `Jc,Jmin,Jmax,S1,S2,H1..H4` (int), `I1..I5` (string, регистр)
- [ ] `// lx:` встроить AWG-поля в `WireGuardEndpointOptions`
- [ ] Без `with_awg` + заданы AWG-поля → явная ошибка «awg not built»
## Девайс
- [ ] `transport/wireguard/device_awg.go` (`//go:build with_awg`): строка конфига `jc=/jmin=/jmax=/s1=/s2=/h1..h4=/i1..i5=`
- [ ] `device_stub_awg.go` (`//go:build !with_awg`)
- [ ] `// lx:` прокидка опций в `protocol/wireguard/endpoint.go`
- [ ] Проводка под тегом (`include/awg.go` или правка `include/wireguard.go`)
## Проверки
- [ ] `lx-test/config/awg2_basic.json` + `sing-box check`
- [ ] Ручной коннект к серверу AmneziaWG 2.0 (непустой `Jc`, хотя бы `I1`)
- [ ] Сборка без тега: обычный WG ок; AWG-поля → ошибка
- [ ] `go vet ./...`, тесты затронутых пакетов
## Закрытие
- [ ] DoD-чеклист
- [ ] IMPLEMENTATION_REPORT.md (зафиксировать pin-коммит сабмодуля, формат конфиг-строки)
- [ ] Папка → `C`
@@ -0,0 +1,53 @@
# IMPLEMENTATION_REPORT — 004 BUILD_CI_RELEASE
**Дата:** 2026-06-09 · **Статус:** Complete — конвейер сборки/CI/релиза/ребейза рабочий, **релиз `v1.13.13-lx.3` опубликован** · **База:** `v1.13.13`
## Итог
Воспроизводимый downstream-конвейер: кросс-платформенный бинарь `sing-box` + Android `libbox.aar`, дешёвый per-commit CI, авто-ребейз на upstream-теги и публикация релизов. End-to-end доказано релизом **`v1.13.13-lx.3`** (6 desktop-архивов + 2 AAR + `SHA256SUMS`, весь прогон зелёный).
## Сборка
**Desktop** — `Makefile.lx` (`lx-build`, output `sing-box`); единый источник тегов — `make -f Makefile.lx lx-print-tags`:
`with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_clash_api,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0,with_xhttp,with_awg`
= upstream-клиент **− acme/tailscale/ccm/ocm** **+ `with_purego`** (CGO-free кросс-сборка `with_naive_outbound`/cronet при CGO=0) **+ `with_xhttp,with_awg`**. `LX_LDFLAGS` += **`-checklinkname=0`** (badtls `go:linkname` в `crypto/tls`, Go 1.24).
**Android AAR** — `cmd/internal/build_libbox/main.go` (`// lx`-блок): `with_xhttp+with_awg` зашиты в `sharedTags`, `with_tailscale` снят (`// lx:no-tailscale`) → `libbox.aar` (SDK23) + `libbox-legacy.aar` (SDK21) через `make lib_install && make lib_android` (NDK r28 + OpenJDK 17 + gomobile). `Libbox.version()` → `-lx.N`.
## CI — `lx-ci.yml` (политика «дёшево на коммит»)
- Триггеры: `push`/`pull_request` (`paths-ignore`: `**.md`/`docs/**`/`SPECS/**`/`LICENSE`) + `workflow_dispatch`; `concurrency: cancel-in-progress`.
- **push/PR → только дешёвое:** `lint` (go vet lx-пакетов + `gofmt` lx-файлов `v2rayxhttp|_xhttp|_awg`) + `build-check` (1 нативный build `full` + `sing-box check` XHTTP/AWG2 + tagless baseline → negative-check, что фичеконфиги без тегов отвергаются).
- **`workflow_dispatch` → тяжёлое (вручную):** `cross` `{linux,darwin,windows}×{amd64,arm64}` (полный `LX_TAGS`, CGO=0 — проверка `with_purego` кросс-сборки) + `android` AAR. На push **не** запускаются.
- doc-only коммиты CI не триггерят.
## Релизы — `lx-release.yml`
- on tag `v*-lx.*` → `build` (6 desktop, tar.gz/zip) + `build_android` (2 AAR) → `release`: `SHA256SUMS` + GitHub Release с notes (база `v1.13.13` + фичи + `lx-print-tags` + строка про AAR). Версия из тега, `sing-box version` → `-lx.N`.
- **`v1.13.13-lx.3` опубликован** (Latest): 6 архивов + `libbox-1.13.13-lx.3.aar` + `libbox-legacy-1.13.13-lx.3.aar` + `SHA256SUMS` — всё зелёное. Этот прогон впервые вживую подтвердил тяжёлый путь (cross ×6 с naive/cronet/purego + gomobile AAR + publish).
- **Windows 7 (32-bit)** legacy-таргет: `windows/386` собирается **пропатченным Go** (`.github/setup_go_for_windows7.sh` — реверты удаления Win7 из `MetaCubeX/go`, как в upstream `build.yml`) и **без `with_naive_outbound`** (`cronet-go` не имеет windows/386 — build constraints исключают всё). Артефакт `sing-box-<ver>-windows-386-legacy-windows-7.zip` — под лаунчер-сборку `singbox-launcher-win7-32` (она тоже 386). Остальной `LX_TAGS` (gvisor/quic/xhttp/awg/…) под 386 компилируется — проверено.
## Авто-ребейз — `lx-rebase.yml`
- `schedule` (Пн 06:00 UTC) + `workflow_dispatch` (опц. `tag`).
- fetch upstream → новейший **стабильный** тег (`^v[0-9]+\.[0-9]+\.[0-9]+$` — отсекает `-alpha/-beta/-rc` и наши `-lx.N`).
- **up-to-date** → no-op; **чистый ребейз + build + `check`** → ветка `lx-rebase/<tag>` + **PR**; **конфликт / build-fail** → **issue** с конфликтными файлами и рецептом ручного ребейза.
- **Никогда не force-push'ит `lx`** — только новая ветка + PR/issue на ревью.
- Демо (`workflow_dispatch tag=v1.13.13`): `Pick target` → `Up to date?` → success, остальное skipped, **0 side-effects** (ни веток, ни PR, ни issue).
## Операционные настройки репозитория (критично для CI)
`gh api repos/OWNER/REPO/actions/permissions/workflow`:
- **`default_workflow_permissions: write`** — иначе `gh release create` падает с `403 Resource not accessible by integration` (это и был корень падений первых релизных прогонов lx.2). NB: «релиз для тега уже существует» — **другая** ошибка (`already exists`), не 403.
- **`can_approve_pull_request_reviews: true`** («Allow GitHub Actions to create and approve pull requests») — иначе авто-PR ребейза ботом блокируется (есть fallback в issue).
- Оба включены 2026-06-09.
## Зона касания upstream (ребейз)
Все lx-артефакты — **новые файлы**: `.github/workflows/lx-{ci,release,rebase}.yml`, `Makefile.lx`, `lx-test/config/`. Единственная правка upstream-файла — `// lx`-блок в `cmd/internal/build_libbox/main.go` (теги AAR). При ребейзе новые файлы переносятся как есть, блок в `build_libbox` — вручную по маркеру.
## Остаточное / дальше
- Старый релиз `v1.13.13-lx.1` можно удалить (предшествует XHTTP-фиксу / полным тегам / libbox; `lx.3` его замещает).
- Лаунчер (репо `singbox-launcher`, отдельно): маппинг `type=xhttp` → реальный xhttp (его задача 023 сейчас в httpupgrade); AWG-поля (Jc/S1–S4/H1–H4/I1–I5) в визард + парсер `awg.conf`; замена бандлового `bin/sing-box` на lx-релиз.
- (опц.) XHTTP `stream-one` framing-баг (`auto`/`packet-up` работают, не блокер).
+48
View File
@@ -0,0 +1,48 @@
# PLAN: 004 — BUILD_CI_RELEASE
## 1. Файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `Makefile.lx` | дополнение (из 001) | `lx-build` + новый `lx-print-tags`; `LX_TAGS` = клиентский feature-set (upstream минус `tailscale`/`ccm`/`ocm`/`acme`) + `with_purego` + наши 2; `LX_LDFLAGS` += `-checklinkname=0` |
| `cmd/internal/build_libbox/main.go` | upstream-правка (lx:-маркер, §3.3) | `with_xhttp,with_awg` в `sharedTags` → попадают в `libbox.aar` (SDK23) и `libbox-legacy.aar` (SDK21) |
| `.github/workflows/lx-ci.yml` | расширение (из 001) | full-tag матрица OS×ARCH (CGO=0) + feature-toggle + `go vet` + job **`android`** (`make lib_android`) |
| `.github/workflows/lx-rebase.yml` | **new** | schedule/dispatch: fetch upstream tags → rebase → build → PR/issue |
| `.github/workflows/lx-release.yml` | расширение | on tag `v*-lx.*`: cross-build desktop (через `Makefile.lx`, без дублирования тегов) + job **`build_android`** (AAR) → zip/checksums/GitHub Release |
| `lx-test/config/xhttp_reality.json`, `awg2_basic.json` | из 002/003 | Используются в CI `check` |
## 2. Версия / ldflags
`LX_LDFLAGS = -X github.com/sagernet/sing-box/constant.Version=<upstream>-lx.<N> -checklinkname=0 -s -w -buildid=`. `<N>` — счётчик lx-релизов поверх upstream-тега. **`-checklinkname=0` обязателен** для полного набора тегов (`badlinkname` → `go:linkname` в `crypto/tls` через `common/badtls`; Go 1.24 блокирует без флага). AAR версионируется отдельно — `build_libbox` берёт `git describe`, поэтому в обоих workflow перед сборкой AAR создаётся/обновляется тег.
**Наборы тегов (два разных, по типу сборки):**
- Desktop (cross, `CGO_ENABLED=0`): `release/DEFAULT_BUILD_TAGS` **минус `tailscale`/`ccm`/`ocm`/`acme`** + `with_purego` + `with_xhttp,with_awg` → `Makefile.lx` `LX_TAGS`.
- AAR (gomobile, NDK/CGO): upstream `build_libbox` mobile-set **минус `with_tailscale`** (`// lx:no-tailscale`, как desktop) + `with_xhttp,with_awg`. `with_purego`/`with_acme` не добавляем — это desktop/server-теги.
## 3. Авто-ребейз (lx-rebase.yml) — логика
```
fetch upstream --tags
latest = max(stable tags)
if base(lx) == latest: exit "up to date"
git checkout -b lx-rebase/$latest lx
git rebase $latest # конфликты → собрать дифф // lx: зон, открыть issue, stop
make lx-build && go vet && sing-box check ... # упало → issue
push origin lx-rebase/$latest ; gh pr create
```
## 4. Порядок работ
1. `Makefile.lx`: полный `LX_TAGS` + `-checklinkname=0` + `lx-print-tags`; `build_libbox` — теги фич в AAR.
2. `lx-ci.yml`: full-tag матрица OS×ARCH + feature-toggle + job `android`.
3. `lx-release.yml`: desktop cross-build (через `Makefile.lx`) + job `build_android` → публикация desktop-архивов + AAR.
4. `lx-rebase.yml` (сначала `workflow_dispatch`, потом cron).
5. Демо-прогон ребейз-workflow на текущем теге.
## 5. Риски
- **`-checklinkname=0`** (РЕШЕНО): без него полный набор не линкуется (`badtls`/`crypto/tls`). Локально подтверждено для linux/amd64 и windows/arm64; остальные 4 таргета верифицирует CI-матрица.
- **`with_naive_outbound` через cronet** тянет prebuilt `cronet-go/lib/<os>_<arch>` — если под какой-то таргет prebuilt отсутствует, naive там не соберётся → дропнуть naive на этой платформе (или из набора целиком). Проверяет CI-матрица.
- **AAR-сборка**: требует NDK r28 + OpenJDK 17 + gomobile (`make lib_install`); `build_libbox.checkJavaVersion()` ждёт строго `openjdk 17`. Версия AAR = `git describe`, поэтому тег должен существовать в чекауте.
- Авто-ребейз на **alpha/beta** теги нежелателен — фильтровать только стабильные (`vX.Y.Z` без суффиксов).
- `git submodule` в CI — не забыть `--init --recursive` и pin (нужно и для `with_awg`, и для AAR).
+65
View File
@@ -0,0 +1,65 @@
# SPEC: 004 — BUILD_CI_RELEASE
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Собрать воспроизводимый конвейер сборки/CI/релизов `sing-box-lx`: кросс-платформенные бинари `sing-box` **и Android `libbox.aar`** с клиентским feature-set (полный upstream минус серверные/AI-теги) + lx-фичами (`with_xhttp`/`with_awg`), версия `-lx.N`, и **авто-ребейз на новый upstream-тег**.
> **Процедура выпуска → [docs-lx/lx-release-runbook.md](../../docs-lx/lx-release-runbook.md).**
> Главное правило: **перед любым тегом проверить дрейф upstream и обычно смержить его себе, и только
> потом резать релиз/пререлиз.** На ветке `lx-1.14` авто-ребейз на стабильный тег (ниже) заменён
> ручным `git merge upstream/testing` — пока upstream на `v1.14.*-alpha`, стабильного тега нет, а
> rc-линия `vX-lx.1-rc.N` сама является форматом поставки.
---
## 1. Проблема / контекст
Реальная стоимость downstream'а — не первичная разработка, а N ребейзов в год и регулярные сборки на 3 платформы. Нужен конвейер, который ловит «фичи поломались об новый upstream» раньше пользователя и выпускает drop-in бинарь для лаунчера.
## 2. Требования
### 2.1 Сборка
- **Desktop-бинарь `sing-box`** (drop-in для лаунчера): цель `make -f Makefile.lx lx-build`, output `sing-box`.
- **Набор `LX_TAGS`** — upstream feature-set (`release/DEFAULT_BUILD_TAGS`) **минус нерелевантные клиенту**: `with_tailscale` (нет tailscale-endpoint'ов), `with_ccm`/`with_ocm` (прокси Claude Code / OpenAI Codex — серверные AI-сервисы), `with_acme` (серверный выпуск TLS-сертов). Итог = `gvisor/quic/dhcp/wireguard/utls/clash_api/naive_outbound + badlinkname/tfogo_checklinkname0` **+ `with_purego`** (CGO-free кросс-сборка `with_naive_outbound` через prebuilt cronet) **+ `with_xhttp,with_awg`**. `Makefile.lx` — единственный источник истины (`make -f Makefile.lx lx-print-tags`).
- **`LX_LDFLAGS` обязан содержать `-checklinkname=0`** — иначе `badlinkname`/`tfogo_checklinkname0` ломают линк (`common/badtls` использует `go:linkname` в `crypto/tls`, который Go 1.24 блокирует). Зеркалит upstream `build_libbox`.
- **Android `libbox.aar`**: `make lib_install && make lib_android` (gomobile, NDK r28 + OpenJDK 17). `with_xhttp`/`with_awg` зашиты в `cmd/internal/build_libbox` (lx:-блок) → попадают в `libbox.aar` (SDK 23) и `libbox-legacy.aar` (SDK 21). Набор тегов AAR = upstream mobile-set **минус `with_tailscale`** (как desktop — самая тяжёлая либа в APK; правка обёрнута `// lx:no-tailscale`) + наши две фичи (NDK/CGO-сборка, `with_purego` не нужен).
- Версия `vX.Y.Z-lx.N` через ldflags (из 001); для AAR — через `git describe` внутри `build_libbox`.
### 2.2 CI-матрица
- **Политика триггеров (стоимость per-commit ↓).** Doc-only коммиты (`**.md`/`docs/**`/`SPECS/**`/LICENSE) **не запускают CI** (`paths-ignore`). На каждый push/PR — **только дешёвые** job'ы `lint` + `build-check`. Тяжёлые `cross` (6 таргетов) и `android` (gomobile AAR) — **только вручную, на `workflow_dispatch`** (`gh workflow run lx-ci.yml --ref lx` или кнопка Actions → Run workflow); на push их нет. Полную кросс-сборку + обе AAR на каждый релиз-тег и так гарантирует `lx-release.yml`. Серия быстрых пушей отменяет устаревшие прогоны (`concurrency: cancel-in-progress`).
- **`lint`** (push/PR): `go vet` по lx-пакетам с полными тегами + `gofmt` только по lx-файлам (`v2rayxhttp|_xhttp|_awg`, не по всему дереву upstream).
- **`build-check`** (push/PR): один нативный build `with_xhttp,with_awg` + `sing-box check` XHTTP/AWG2-конфигов (должны пройти); затем tagless baseline-бинарь → `check minimal.json` (проходит) + negative-check (XHTTP/AWG2-конфиги без тегов отвергаются).
- **`cross`** (dispatch): `{linux, darwin, windows} × {amd64, arm64}`, full `LX_TAGS`, CGO=0 — проверка, что полный набор + `with_purego` кросс-собирается везде.
- **`android`** (dispatch): `make lib_android` (NDK r28 + JDK17 + gomobile) — libbox AAR собирается с lx-фичами.
- Все job'ы — с submodule (`submodules: recursive`) и `fetch-depth: 0` (для `-lx` версии через `git describe`).
### 2.3 Авто-ребейз на upstream-тег
- Workflow по расписанию/`workflow_dispatch`:
1. `git fetch upstream --tags`, определить новейший стабильный тег `> текущей базы`.
2. Попытка `git rebase <tag>` ветки `lx` в CI.
3. Успех + сборка/`check` зелёные → пуш ветки `lx-rebase/<tag>` и **PR**; конфликт → **issue** с диффом `// lx:` зон.
- Никогда не пушить силой в `lx` автоматически — только через PR с ревью.
### 2.4 Релизы
- Тег `vX.Y.Z-lx.N` → артефакты: desktop-архивы (`sing-box` × 6 платформ) **+ `libbox-<ver>.aar` и `libbox-legacy-<ver>.aar`**, общий `SHA256SUMS`.
- Release notes: upstream-база + состояние фич (`with_xhttp`/`with_awg`) + полный `LX_TAGS` desktop-бинаря (через `lx-print-tags`) + строка про AAR.
## 3. Критерии приёмки
- CI зелёный: на push/PR — дешёвые `lint` + `build-check`; полная матрица (`cross` ×6 с полным `LX_TAGS` + `android` AAR) — вручную на `workflow_dispatch`. Doc-only коммиты CI не триггерят; релиз-тег собирает всё через `lx-release.yml`.
- Артефакты собираются, бинарь называется `sing-box`, `version` → `-lx.N`.
- **libbox AAR собирается в CI (job `android`) и публикуется в Release**; `Libbox.version()` → `-lx.N`; конфиг с AWG2/XHTTP не падает с «support not built».
- Авто-ребейз workflow отрабатывает на `workflow_dispatch` (демо на текущем теге → «уже актуально» или PR).
## 4. Вне скоупа
- Подпись кода/нотаризация; публикация AAR в Maven/jitpack (отдаём только GitHub Release asset).
- Интеграция AAR в приложение-потребитель (LxBox) — задача на стороне приложения.
- Полностью автоматический мёрж ребейза (всегда ревью).
## 5. Ссылки
- [Build from source — sing-box](https://sing-box.sagernet.org/installation/build-from-source/)
+36
View File
@@ -0,0 +1,36 @@
# TASKS — 004-BUILD_CI_RELEASE
## Сборка (desktop)
- [x] `Makefile.lx`: `lx-build` (output `sing-box`), `LX_TAGS`, `LX_LDFLAGS`, версия `-lx.N`
- [x] `LX_TAGS` → клиентский feature-set (`DEFAULT_BUILD_TAGS` минус tailscale/ccm/ocm/acme) + `with_purego` + `with_xhttp,with_awg`
- [x] `LX_LDFLAGS` += `-checklinkname=0` (иначе линк падает на `badtls`/`crypto/tls`)
- [x] `lx-print-tags` — единый источник истины для CI/release (без дублирования строки тегов)
## Сборка (Android libbox AAR)
- [x] `cmd/internal/build_libbox/main.go`: `with_xhttp,with_awg` в `sharedTags` + дроп `with_tailscale` (lx:-маркеры) → обе AAR-варианта
- [x] CI собирает `libbox.aar` + `libbox-legacy.aar` зелёным (NDK r28 + OpenJDK 17 + gomobile) — подтверждено в релизе v1.13.13-lx.3 (job `build android` = success)
- [x] `Libbox.version()` отдаёт `-lx.N`; AAR (libbox-1.13.13-lx.3.aar + legacy) опубликованы в Release
## CI
- [x] `lx-ci.yml` триггеры: push/PR (paths-ignore docs) + `workflow_dispatch`; `concurrency: cancel-in-progress`
- [x] **push/PR — дёшево:** `lint` (go vet lx-пакетов + gofmt lx-файлов) + `build-check` (1 нативный build `full` + `check`, baseline-бинарь + negative-check)
- [x] **`workflow_dispatch` — тяжёлое (вручную):** `cross` `{linux,darwin,windows}×{amd64,arm64}` на полном `LX_TAGS` + `android` (`make lib_android`); на push не запускаются
- [x] submodule init (`submodules: recursive`) + `fetch-depth: 0` во всех job'ах
- [x] Зелёный прогон `lint`+`build-check` на push (подтверждено); `cross` ×6 + `android` AAR — зелёные в релизном прогоне v1.13.13-lx.3 (cronet/naive/purego на всех 6)
## Авто-ребейз
- [x] `lx-rebase.yml`: fetch upstream tags → выбрать новейший **стабильный** (`^v[0-9]+\.[0-9]+\.[0-9]+$`) → rebase в CI
- [x] Успех+билд → ветка `lx-rebase/<tag>` + PR; конфликт/билд-фейл → issue с диффом `// lx:` зон; up-to-date → no-op
- [x] Запрет авто-force-push в `lx` (пушим только в `lx-rebase/<tag>`); PR/issue; `schedule` (еженедельно) + `workflow_dispatch`
- [x] Демо-прогон `workflow_dispatch` (tag=v1.13.13 → «up to date», 0 side-effects). PR-путь требует чекбокс «Allow Actions to create PRs» (иначе fallback в issue)
## Релизы
- [x] `lx-release.yml`: on tag `v*-lx.*` → cross-build desktop, zip, checksums, GitHub Release
- [x] job `build_android` → `libbox-<ver>.aar` + `libbox-legacy-<ver>.aar` в Release
- [x] Release notes: upstream-база + `with_xhttp`/`with_awg` + `LX_TAGS` (через `lx-print-tags`) + AAR
- [x] Проверить релиз end-to-end — **v1.13.13-lx.3 опубликован** (6 desktop + 2 AAR + SHA256SUMS, всё зелёное). Корень 403: Workflow permissions = Read/write
## Закрытие
- [x] DoD: зелёная CI-матрица (desktop ×6 + AAR) — релиз v1.13.13-lx.3
- [x] IMPLEMENTATION_REPORT.md
- [x] Статус → `C` (шапка SPEC.md + Roadmap)
@@ -0,0 +1,71 @@
# IMPLEMENTATION_REPORT — 005 AWG2_RANGED_MAGIC_HEADERS
**Дата:** 2026-06-11 · **Статус:** Complete — **функционален, проверен живым awg2-сервером с ranged-конфигом** · **База:** `v1.13.13`
## Итог
`H1`–`H4` в конфиге wireguard-endpoint теперь принимают **диапазон** AWG 2.0 (`"43613244-384550127"`) наряду с одиночным числом (`1234567890`, обратная совместимость). Spec-строка доезжает до IpcSet, vendored `submodules/wireguard-go` (который уже умел диапазоны) поднимает обфусцированный handshake. Реальный awg2-экспорт (`seliv_for_awg2.conf`) импортируется без правок.
Изменения — **только в lx-собственных файлах** (`option/wireguard_awg.go`, `transport/wireguard/device_awg.go`, оба созданы в 003). Ноль новых касаний upstream, vendored wireguard-go не тронут.
## Архитектура
**Тип `option.MagicHeader` (string-based, comparable):**
- `UnmarshalJSON` принимает JSON number (back-compat с прежним `uint32`) и JSON string `"N"`/`"N-M"`. Канонизация: пробелы обрезаются, `N-N` → `N`, `0`/`"0"`/`""` → `""` (unset — прежняя zero-value семантика `omitempty`).
- `MarshalJSON` сохраняет type-fidelity: одиночное значение → JSON number, диапазон → JSON string.
- База `string` ⇒ `AmneziaWGOptions` остаётся comparable, `IsSet()` (`o != AmneziaWGOptions{}`) работает.
- `Spec()` повторно валидирует и отдаёт канон для IpcSet — ловит опции, собранные в коде (libbox/лаунчер) мимо JSON.
- Валидация (uint32, start ≤ end) и в `UnmarshalJSON`, и в `Spec()`; имя поля в ошибке даёт contextjson (`h1: invalid magic header …`) на парсе и `E.Cause(err, "h1")` в `awgIpcLines` на сборке device.
**Эмит (`transport/wireguard/device_awg.go`):**
- `h1..h4` через `writeStr`-путь (spec-строка), unset → не эмитится.
- Plain WG (без AWG-полей) по-прежнему даёт `""` → byte-identical конфиг.
## Изменённые / новые файлы
| Файл | Тип | Изменение |
|------|-----|-----------|
| `option/wireguard_awg.go` | lx-own | тип `MagicHeader` + Unmarshal/Marshal/Spec/normalize; `H1..H4 uint32` → `MagicHeader` |
| `option/wireguard_awg_test.go` | **new** | unmarshal number/string/range/ошибки; marshal round-trip; IsSet; field-name; Spec |
| `transport/wireguard/device_awg.go` | lx-own | `writeUint("h1"…)` → `writeMagic` (spec + валидация с ключом); unset не эмитится |
| `transport/wireguard/device_awg_test.go` | **new** (`with_awg`) | ipc: диапазон, одиночные, plain WG `""`, unset-omit, невалидный header |
| `lx-test/config/awg2_ranged.json` | **new** | check-фикстура с ranged H1–H4 (фейк-ключи) |
| `.github/workflows/lx-ci.yml` | wiring | `awg2_ranged.json` в позитивную + негативную проверку |
| `docs-lx/lx-config.md` | docs | `h1`–`h4`: `int \| "min-max"`, no-overlap, пример, маппинг awg.conf |
| `SPECS/README.md` | docs | строка 005 в roadmap |
## DoD
- ✅ `go build ./...` без тегов — ок (поведение upstream).
- ✅ `make -f Makefile.lx lx-build` (full tags) — собирается; `sing-box version` → `1.13.13-lx.N`.
- ✅ `go vet` (с тегами и без) для `option`, `transport/wireguard` — чисто.
- ✅ `go test ./option/ ./transport/wireguard/` (и `-tags with_awg`) — зелёные.
- ✅ `sing-box check` — `awg2_basic.json` (одиночные H), `awg2_ranged.json` (диапазоны), `minimal.json` — приняты.
- ✅ baseline (без `with_awg`) — `awg2_ranged.json` отклонён явной ошибкой «awg support not built».
- ✅ Правки только в lx-own файлах; новые `// lx:`-зоны не добавлялись.
## Приёмка (живой сервер)
Лайв-тест против awg2-сервера из `seliv_for_awg2.conf` (ranged H1–H4):
```
peer - sending handshake initiation
peer - received handshake response ← uapi принял ranged-строки, обфусцированный handshake прошёл
```
- `curl --socks5-hostname 127.0.0.1:21080 https://api.ipify.org` → `{"ip":"64.188.69.128"}` (выходной IP = endpoint сервера): трафик идёт сквозь туннель.
- 0 ошибок `message too long` / EMSGSIZE (MTU не задан → дефолт `1280` под s3/s4 из 003 / `f806e24f`).
- IpcError при выставлении h1..h4-строк — **нет**.
Секреты в репозиторий не попадали: лайв-конфиг и лог держались в `/tmp`, затёрты после теста. `lx-test/config/awg2_ranged.json` — с фейк-ключами.
## Зона касания при следующем ребейзе
Без изменений относительно 003: `option/wireguard_awg.go` и `transport/wireguard/device_awg.go` — **lx-собственные** файлы, в upstream их нет, конфликтов на ребейзе не дают. Vendored `submodules/wireguard-go` не тронут.
## Вне скоупа
- Перепин `app/android/libbox.version` в лаунчере — задача на стороне LxBox.
- AWG inbound/server — отдельная будущая задача (как в 003).
## Релиз
Тег `v1.13.13-lx.6` → `lx-release.yml` (6 desktop + 2 AAR + Win7-386).
@@ -0,0 +1,43 @@
# PLAN: 005 — AWG2_RANGED_MAGIC_HEADERS
## 1. Архитектура
Диапазон уже понимает нижний слой (`submodules/wireguard-go`: `device/magic-header.go` + `device/uapi.go` case `"h1".."h4"` → `newMagicHeader("N"/"N-M")`). Задача — донести spec-строку от JSON-конфига до IpcSet. Меняются **только lx-собственные файлы** (`option/wireguard_awg.go`, `transport/wireguard/device_awg.go`) — ноль новых касаний upstream, ребейз-стоимость не растёт.
Тип `option.MagicHeader` — `string` с канонизацией при парсе:
- `UnmarshalJSON`: JSON number → канон `"N"`; JSON string → парс `"N"`/`"N-M"` (uint32, start ≤ end), канонизация (`N-N` → `N`, обрезка пробелов); number `0` / строка `""`/`"0"` → unset (`""`) — сохраняет прежнюю omitempty-семантику нуля.
- `MarshalJSON`: одиночное значение → JSON number (type-fidelity со старым `uint32`), диапазон → JSON string.
- База string ⇒ тип comparable, `IsSet()` (`o != AmneziaWGOptions{}`) работает; zero value `""` = unset.
- Повторная валидация в `awgIpcLines` с именем ключа в ошибке (`E.Cause(err, "h1")`) — покрывает и программно собранные опции (libbox/лаунчер мимо JSON).
## 2. Изменяемые / новые файлы
| Файл | Тип | Изменения |
|------|-----|-----------|
| `option/wireguard_awg.go` | lx-own | `H1..H4 uint32` → `MagicHeader`; тип + Unmarshal/Marshal/Validate |
| `option/wireguard_awg_test.go` | **new** | unmarshal number / string / диапазон / ошибки; marshal round-trip; IsSet |
| `transport/wireguard/device_awg.go` | lx-own | `writeUint("h1"…)` → write magic-spec с валидацией; unset → не эмитить |
| `transport/wireguard/device_awg_test.go` | **new** (`//go:build with_awg`) | ipc-строки: одиночные, диапазон, plain WG = `""` |
| `lx-test/config/awg2_ranged.json` | **new** | check-фикстура с ranged H1–H4 (фейк-ключи) |
| `docs-lx/lx-config.md` | docs | `h1`–`h4`: `int \| "min-max"`, пример, маппинг awg.conf |
| `SPECS/README.md` | docs | строка 005 в roadmap |
## 3. Зона касания upstream (для ребейза)
**Ничего нового.** Оба изменяемых Go-файла — lx-собственные (созданы в 003), upstream-файлы не трогаются, `// lx:`-зоны не расширяются. Vendored `submodules/wireguard-go` не меняется.
## 4. Порядок работ
1. `option`: тип `MagicHeader` + замена полей + тесты.
2. `transport/wireguard`: эмит spec-строки + тесты (под `with_awg`).
3. Фикстура `awg2_ranged.json`; `go build` (с тегами и без), `go vet`, `go test`, `sing-box check` обеих AWG-фикстур.
4. Docs + roadmap.
5. Лайв/handshake-приёмка (конфиг с реальными ключами — только во временных файлах, как в 003).
6. REPORT, статус C, тег `v1.13.13-lx.6`.
## 5. Риски
- **Совместимость marshal**: код, который сериализует опции обратно в JSON (`sing-box format`, экспорт из лаунчера), должен получить number для одиночных значений — закрыто MarshalJSON-логикой + тестом round-trip.
- **`"h1": 0` / `"0"`**: прежняя семантика «0 = не задано» (omitempty по zero value uint32) сохраняется канонизацией в `""`.
- **Ошибка без имени поля** при парсе JSON — закрыто дублирующей валидацией в `awgIpcLines` с ключом и тестом текста ошибки.
@@ -0,0 +1,60 @@
# SPEC: 005 — AWG2_RANGED_MAGIC_HEADERS
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Поддержать **диапазонные magic headers** AmneziaWG 2.0 (`H1`–`H4` вида `N-M`) в конфиге sing-box-lx. Только прослойка option → IpcSet; протокольный слой (vendored `Leadaxe/wireguard-go`) уже умеет диапазоны.
---
## 1. Проблема / контекст
- Лаунчер (LxBox) начал импортировать реальные awg2-экспорты. Живые конфиги содержат H-поля в новом формате AWG 2.0 — **диапазон** вместо числа:
```ini
H1 = 43613244-384550127
H2 = 826869626-2105069164
```
- Vendored merged-форк `submodules/wireguard-go` **уже умеет** диапазоны: `device/magic-header.go` — `newMagicHeader(spec)` принимает `"N"` и `"N-M"` (uint32, start ≤ end); `device/uapi.go` case `"h1".."h4"` парсит value через `newMagicHeader`.
- Но прослойка ядра не пропускает диапазон:
- `option/wireguard_awg.go`: `H1..H4 uint32` — диапазон не выразить, JSON-строка `"h1": "N-M"` не анмаршалится;
- `transport/wireguard/device_awg.go`: `writeUint("h1", o.H1)` — в IpcSet уходит только одиночное число.
## 2. Цель
Конфиг wireguard-endpoint с `"h1": "43613244-384550127"` (и одиночными `"h1": 1234567890` как раньше) парсится, проходит `sing-box check`, и диапазонная spec-строка доезжает до uapi девайса без IpcError.
## 3. Требования
### 3.1 Опции (`option/wireguard_awg.go`)
- `H1..H4` → тип «число-или-диапазон» (`MagicHeader` на базе string):
- `UnmarshalJSON`: принимает **JSON number** (обратная совместимость — существующие конфиги с `"h1": 1234567890` читаются без изменений) и **JSON string** `"N"` / `"N-M"`;
- валидация: обе части — uint32, start ≤ end; мусор → явная ошибка с именем поля;
- `MarshalJSON`: одиночное значение → number (type-fidelity как раньше), диапазон → string;
- тип comparable — `IsSet()` (`o != AmneziaWGOptions{}`) продолжает работать.
### 3.2 Девайс (`transport/wireguard/device_awg.go`)
- `h1..h4` эмитятся как spec-строка (`writeStr`-путь), unset → не эмитить.
- Гарантия «plain WG даёт byte-identical конфиг» сохраняется.
### 3.3 Документация (`docs-lx/lx-config.md`)
- `h1`–`h4`: `int | "min-max"` + пример с диапазоном; обновить таблицу и маппинг awg.conf.
## 4. Критерии приёмки
- Тесты: unmarshal number / string-число / диапазон / ошибки (start > end, > uint32, мусор); ipc-строки с диапазоном (`\nh1=43613244-384550127`); существующие AWG-фикстуры не ломаются.
- `sing-box check` принимает конфиг с ranged H1–H4.
- Лайв-тест против awg2-сервера с ranged-конфигом (инфраструктура lx-test из 003), либо хотя бы handshake-проверка, что uapi принимает выставленные строки без IpcError.
- Сборка без `with_awg`: поведение upstream, AWG-поля → явная ошибка (как раньше).
- `go vet`, тесты затронутых пакетов — зелёные.
## 5. Вне скоупа
- Vendored `submodules/wireguard-go` — **не трогать**, он уже умеет диапазоны.
- Перепин `app/android/libbox.version` в лаунчере — задача на стороне LxBox.
- Диапазоны для S1–S4/Jc — в формате AWG 2.0 их нет (только H1–H4).
## 6. Релиз
Тег `v1.13.13-lx.6`, AAR через `lx-release.yml`.
@@ -0,0 +1,24 @@
# TASKS — 005-AWG2_RANGED_MAGIC_HEADERS
## Опции
- [x] `option/wireguard_awg.go`: тип `MagicHeader` (string-based, comparable) — UnmarshalJSON (number + `"N"`/`"N-M"`), MarshalJSON (number ↔ string), валидация uint32 / start ≤ end
- [x] `H1..H4` → `MagicHeader`; `IsSet()` работает как раньше
- [x] `option/wireguard_awg_test.go`: number / string-число / диапазон / ошибки (start > end, > uint32, мусор, отрицательное); marshal round-trip; `0` → unset
## Девайс
- [x] `transport/wireguard/device_awg.go`: `h1..h4` → spec-строка через writeStr-путь; валидация с именем ключа; unset → не эмитить
- [x] `device_awg_test.go` (`with_awg`): `\nh1=43613244-384550127`; одиночные значения как раньше; plain WG → `""`
## Проверки
- [x] `lx-test/config/awg2_ranged.json` (фейк-ключи) + `sing-box check` обеих AWG-фикстур
- [x] Сборка с тегами и без; `go vet`; `go test` затронутых пакетов
- [x] Существующие AWG-фикстуры (`awg2_basic.json`) не ломаются
## Приёмка
- [x] Лайв/handshake-тест против awg2-сервера с ranged-конфигом (uapi принял строки без IpcError, handshake + трафик прошли); секреты — только в temp-файлах, затёрты
## Документация и закрытие
- [x] `docs-lx/lx-config.md`: `h1`–`h4` `int | "min-max"`, no-overlap, пример с диапазоном, маппинг awg.conf
- [x] `SPECS/README.md`: строка 005 в roadmap
- [x] IMPLEMENTATION_REPORT.md, DoD-чеклист, статус → `C` (шапка SPEC.md + Roadmap)
- [x] Тег `v1.13.13-lx.6` → `lx-release.yml` (desktop + AAR)
@@ -0,0 +1,56 @@
# IMPLEMENTATION_REPORT — 006 LINUX_MUSL_STATIC_ROUTER_BUILDS
**Дата:** 2026-06-12 · **Статус:** Complete — **приёмка CI пройдена (4/4 арки статикой)** · **База:** `v1.13.13`
## Итог
Релизные Linux-бинари переведены на **статическую musl-сборку с сохранением NaïveProxy**, добавлены роутерные арки. Закрывает [issue #1](https://github.com/Leadaxe/sing-box-lx/issues/1): `libdl.so.2: cannot open shared object file` на AsusWRT Merlin + отсутствие `linux-armv7`.
**Go-кода нет** — задача чисто инфраструктурная (CI). Единственная Go-правка в этой ветке — hotfix gofmt-выравнивания в `option/wireguard_awg.go` (хвост 005, см. ниже).
## Диагноз (эмпирически подтверждён)
`with_naive_outbound` тянет `cronet-go`. Прежний релиз собирался в режиме `with_purego` (`CGO_ENABLED=0`): `purego` на Linux содержит `//go:cgo_import_dynamic … "libdl.so.2"` → бинарь **динамический**, требует `libdl.so.2`. На glibc ок, на musl (роутеры) — падает до старта. Кросс-сборкой проверено: `file` → `dynamically linked`, `strings|grep libdl.so.2` → 1; без naive/purego → `statically linked`, 0.
naive **сохраняем** — это upstream-фича (`release/DEFAULT_BUILD_TAGS` содержит `with_naive_outbound`; `protocol/naive/outbound.go` без `lx:`-маркеров). Поэтому не дропаем, а используем третий режим cronet-go — `with_musl` (статический `libcronet.a` + musl-toolchain), как делает upstream `build.yml`.
## Что сделано
**`.github/workflows/lx-release.yml`** — новый job `build_linux_musl` (зеркало upstream musl-pipeline):
- матрица: `linux-amd64`, `linux-arm64`, `linux-armv7` (`GOARM=7`), `linux-mipsle-softfloat` (`GOMIPS=softfloat`);
- clone `cronet-go` по pin `.github/CRONET_GO_VERSION` (`2faf34666c2c`, = go.mod) + submodules; regenerate keyring; cache + download Chromium **musl** toolchain через `cmd/build-naive --libc=musl`; set env;
- `CGO_ENABLED=1 go build` с тегами `LX_TAGS` (с заменой `with_purego`→`with_musl`, `with_naive_outbound` сохранён);
- verify-шаг: `file → statically linked`, `! grep libdl.so.2`;
- Linux убран из desktop-job `build` (остаются darwin/windows/win7); `release.needs += build_linux_musl`; release-notes обновлены.
**`.github/workflows/lx-ci.yml`** — dispatch-only smoke-job `linux_musl` (те же 4 арки): полный musl-pipeline + build + verify static, **без публикации** — безопасная приёмка. Помечен «keep in sync with build_linux_musl».
**Нейминг** — по upstream-схеме арочных суффиксов (`armv7` = arm+`v`+GOARM; `mipsle-softfloat` = arch+GOMIPS), но **без суффикса `-musl`**: upstream добавляет его, т.к. собирает и glibc, и musl на арку; у нас Linux — единственный (musl) вариант, и `linux-arm64`/`linux-armv7` совпадают с ожиданием скриптов потребителей.
## Приёмка
- ✅ YAML валиден (`python yaml`), `actionlint` чист (rc=0) для обоих workflow.
- ✅ CI smoke (`lx-ci` workflow_dispatch, job `linux_musl` ×4, run [27407702652](https://github.com/Leadaxe/sing-box-lx/actions/runs/27407702652)) — все 4 **success**, `file` → `statically linked`, `libdl.so.2=0`:
- `linux-amd64`: ELF 64-bit x86-64, statically linked;
- `linux-arm64`: ELF 64-bit ARM aarch64, statically linked;
- `linux-armv7`: ELF 32-bit ARM EABI5, statically linked;
- `linux-mipsle-softfloat`: ELF 32-bit MIPS32 rel2, statically linked — **mipsle+naive собрался musl-статикой**, fallback не понадобился.
- ✅ Боевой релиз [v1.13.13-lx.7](https://github.com/Leadaxe/sing-box-lx/releases/tag/v1.13.13-lx.7) опубликован — 4 musl-арки + desktop (darwin/win/win7) + 2 AAR + SHA256SUMS.
- ✅ **Field-verified** репортером issue #1 на AsusWRT Merlin RT-AX (`linux/arm64`): ядро устанавливается, стартует, работает; `sing-box version` → `1.13.13-lx.7`, теги включают `with_naive_outbound,with_musl`, `CGO: enabled`. Подтверждение на реальном устройстве, не только в CI.
> **Нейминг — апдейт от потребителя:** репортер подтвердил, что суффикс `-musl` для его скрипта **некритичен** (берёт архив с суффиксом или без). То есть наш выбор «без `-musl`» валиден без оглядки на чужой скрипт — это просто следствие единственного варианта на арку. `-softfloat` у mipsle при этом **обязателен** (FP-ABI, не линковка): softfloat запускается на любом MIPS-роутере, hardfloat — только на чипах с FPU.
## Побочный hotfix (хвост 005)
`option/wireguard_awg.go`: расширение `H1..H4 uint32 → MagicHeader` сменило самый длинный тип в struct, gofmt перевыровнял json-теги. `go vet` это не ловит, поэтому ушло в lx.6 с красным дешёвым CI (`lint` job). Исправлено `gofmt -w`, format-only. Урок: прогонять `gofmt -l` на lx-owned файлах перед коммитом.
## Зона касания upstream (для ребейза)
`lx-release.yml` / `lx-ci.yml` — **lx-собственные** файлы (в upstream их нет) → конфликтов на ребейзе не дают. `.github/CRONET_GO_VERSION` — upstream-файл, **только читаем**. Паттерн musl-pipeline заимствован из upstream `build.yml` как референс.
## Вне скоупа
- Экзотика (`386`/`riscv64`/`loong64`/`mips64le`) — точечно по запросу; cronet-musl под `mips64le` нет.
- DEB/RPM/Pacman/OpenWrt-пакеты — не публикуем (только `.tar.gz`).
- naive на Win7 (windows/386) — физически невозможен (нет `cronet-go/lib/windows_386`).
- Перепин `libbox.version` в лаунчере — на стороне LxBox.
@@ -0,0 +1,67 @@
# PLAN: 006 — LINUX_MUSL_STATIC_ROUTER_BUILDS
## 1. Архитектура
Один новый job в `lx-release.yml` — `build_linux_musl` — повторяющий upstream `build.yml` musl-секцию, но с нашим `LX_TAGS`. Существующий desktop-путь не ломаем: из старого `build` job убираем строки `linux/*`, остальное (darwin/windows/win7) остаётся на `Makefile.lx`/purego.
```
build (desktop, как сейчас минус linux): darwin×2, windows×2, win7-386
build_linux_musl (NEW): linux musl-static + naive: amd64, arm64, armv7, mipsle
build_android (без изменений): libbox.aar ×2
release (как сейчас): собирает артефакты всех job
```
## 2. `build_linux_musl` — шаги (зеркало upstream)
Матрица:
```yaml
- { arch: amd64, asset: linux-amd64 }
- { arch: arm64, asset: linux-arm64 }
- { arch: arm, goarm: "7", asset: linux-armv7 }
- { arch: mipsle, gomips: softfloat, asset: linux-mipsle-softfloat }
```
Шаги:
1. `checkout` (submodules: recursive — нужен `submodules/wireguard-go` для with_awg).
2. `setup-go` (go-version-file: go.mod).
3. **Clone cronet-go**: `CRONET=$(cat .github/CRONET_GO_VERSION)`; `git init ~/cronet-go`; remote `sagernet/cronet-go`; `fetch --depth=1 $CRONET`; checkout; `submodule update --init --recursive --depth=1` (тянет naiveproxy/src — Chromium toolchain source).
4. **Regenerate Debian keyring**: `…/sysroot_scripts/generate_keyring.sh` (для sysroot download).
5. **Cache Chromium toolchain** (`actions/cache`): пути `llvm-build/`, `gn/out/`, `pgo_profiles/`, `out/sysroot-build/`; key по arch+`CRONET_GO_VERSION`. Гигабайты — кеш обязателен.
6. **Download toolchain**: `cd ~/cronet-go && go run ./cmd/build-naive --target=linux/<arch> --libc=musl download-toolchain`.
7. **Set toolchain env**: `… --libc=musl env >> $GITHUB_ENV` (выставляет `CC`/`CXX`/CGO_*).
8. **Build tags**: `TAGS=$(make -f Makefile.lx -s lx-print-tags)`; `TAGS="${TAGS/with_purego/with_musl}"`.
9. **Build (musl)**: `CGO_ENABLED=1 GOOS=linux GOARCH=<arch> GOARM=<goarm> GOMIPS=<gomips> go build -trimpath -tags "$TAGS" -ldflags "<LX_LDFLAGS>" -o dist/sing-box ./cmd/sing-box`.
10. **Verify static** (приёмка в логах): `file dist/sing-box` → `statically linked`; `! strings dist/sing-box | grep -q libdl.so.2`.
11. **Package**: `sing-box-<ver>-<asset>.tar.gz` (binary + LICENSE/README), как в desktop job.
12. `upload-artifact`.
> `LX_LDFLAGS` берём из Makefile.lx (`-checklinkname=0 -s -w -buildid=` + Version). Тот же набор, что у desktop-сборки.
## 3. Изменяемые / новые файлы
| Файл | Тип | Изменение |
|------|-----|-----------|
| `.github/workflows/lx-release.yml` | lx-own CI | новый job `build_linux_musl`; из `build` убрать linux-строки; `release.needs` += новый job; notes.md — роутерные арки |
| `SPECS/006/*`, `SPECS/README.md` | docs | спека + roadmap |
**Go-кода нет.** `Makefile.lx` править не обязательно (теги берём из него же; musl-логика живёт в CI, т.к. требует Chromium-toolchain env, которого в Makefile не выразить переносимо).
## 4. Зона касания upstream (для ребейза)
`lx-release.yml` — **lx-собственный** файл (создан в 004), в upstream его нет → конфликтов на ребейзе не даёт. Паттерн заимствован из upstream `build.yml`, но как референс, не как правка upstream-файла. `.github/CRONET_GO_VERSION` — upstream-файл, мы его **только читаем** (pin уже совпадает с go.mod), не меняем.
## 5. Порядок работ
1. SPEC/PLAN/TASKS (done).
2. Реализовать `build_linux_musl` + почистить linux из `build` + `release.needs` + notes.
3. Локально: валидация YAML (actionlint/python-yaml). Полную musl-сборку локально не проверить — Chromium toolchain только в CI.
4. Прогон `workflow_dispatch` (dev-тег) → читать логи, чинить toolchain/линковку итеративно.
5. На зелёном — verify-шаги (static/libdl) в логах; по возможности запуск armv7 под qemu-user.
6. REPORT, статус C, ответ в issue #1. Боевой релиз — тегом `v1.13.13-lx.7`.
## 6. Риски
- **Локально не верифицируемо** — отладка только через CI; закладываем несколько прогонов. Кеш toolchain критичен для скорости.
- **Время/размер**: Chromium toolchain — гигабайты; musl-бинарь крупнее (вшит libcronet, +неск. МБ). Приемлемо для релиза по тегу (не на каждый push).
- **mipsle softfloat**: проверить, что toolchain build-naive поддерживает target `linux/mipsle` + `GOMIPS=softfloat`. Если cronet-musl/mipsle не соберётся — fallback: mipsle через `DEFAULT_BUILD_TAGS_OTHERS` (без naive, `CGO_ENABLED=0`, статика) как делает upstream для арок без cronet. Зафиксировать в REPORT.
- **keyring/sysroot download** может флапать (внешняя Chromium infra) — ретраи.
@@ -0,0 +1,68 @@
# SPEC: 006 — LINUX_MUSL_STATIC_ROUTER_BUILDS
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Публиковать **статические musl-бинари** `sing-box` под роутерные Linux-арки, **сохраняя NaïveProxy-outbound**. Закрывает [issue #1](https://github.com/Leadaxe/sing-box-lx/issues/1): нужен `linux-armv7`, и текущий `linux-arm64` не запускается на AsusWRT Merlin (`libdl.so.2: cannot open shared object file`).
---
## 1. Проблема / контекст
- Лаунчер-аудитория ставит ядро на роутеры (AsusWRT Merlin, OpenWrt, Keenetic) — это **musl**-окружения.
- Текущие релизные `linux-amd64/arm64` собраны в режиме `with_purego` (`CGO_ENABLED=0`). `purego` через `//go:cgo_import_dynamic … "libdl.so.2"` делает бинарь **динамическим** и вешает зависимость от `libdl.so.2`. На glibc-десктопе ок, на musl — загрузчик падает до старта. Проверено эмпирически: `file` → `dynamically linked`, `strings | grep libdl.so.2` → 1 совпадение.
- `linux-armv7` в релизе **отсутствует** вовсе.
- **NaïveProxy-outbound — штатная upstream-фича** (`release/DEFAULT_BUILD_TAGS` содержит `with_naive_outbound`; `protocol/naive/outbound.go` — upstream-код без `lx:`-маркеров). По CONSTITUTION (upstream + ровно 2 фичи, из upstream ничего не выкусываем) её **нельзя** дропать ради статики.
## 2. Цель
Релиз публикует статические, самодостаточные (без `libdl`) musl-бинари под 4 роутерные арки, **с работающим naive**:
| Арка | Параметры | Покрытие |
|------|-----------|----------|
| `linux-amd64` | musl | x86 софт-роутеры, контейнеры, универсальный |
| `linux-arm64` | musl | современные роутеры (ASUS AX/AXE, GL.iNet, Xiaomi), arm64-роутер автора |
| `linux-armv7` | musl, `GOARM=7` | ASUS на старых SoC — прямой запрос issue |
| `linux-mipsle` | musl, `GOMIPS=softfloat` | классика OpenWrt (MediaTek/Atheros) |
Без `with_awg`/`with_xhttp` поведение не меняется — это чисто сборочная задача (CI), **Go-кода нет**.
## 3. Требования
### 3.1 Механизм — по подобию upstream `build.yml`
- Сборка musl-варианта повторяет upstream: clone `cronet-go` по pin `.github/CRONET_GO_VERSION` (уже совпадает с `go.mod`: `2faf34666c2c`), regenerate Debian keyring, download Chromium **musl** toolchain через `go run ./cmd/build-naive --target=linux/<arch> --libc=musl download-toolchain`, выставить env (`… env >> $GITHUB_ENV`), затем `CGO_ENABLED=1 go build` с тегом `with_musl` — `libcronet.a` линкуется статически.
- **zig не используется** — официальный путь cronet-go (Chromium toolchain) надёжнее и совпадает с upstream.
### 3.2 Теги
- Брать `LX_TAGS` (Makefile.lx, single source of truth), заменить `with_purego` → `with_musl`. `with_naive_outbound` **остаётся**. Остальные фичи (`with_xhttp,with_awg,…`) без изменений.
### 3.3 Нейминг артефактов
- По upstream-схеме арочных суффиксов: `armv7` (= `arm` + `v` + `GOARM`), `mipsle-softfloat` (= arch + `GOMIPS`).
- **Без суффикса `-musl`**: upstream добавляет его, т.к. собирает и glibc, и musl на арку; у нас на Linux единственный вариант — musl, поэтому суффикс избыточен и сломал бы ожидание скриптов (`linux-arm64`/`linux-armv7`).
- Итог: `sing-box-<ver>-linux-amd64.tar.gz`, `…-linux-arm64.tar.gz`, `…-linux-armv7.tar.gz`, `…-linux-mipsle-softfloat.tar.gz`.
### 3.4 Не-Linux платформы — без изменений
- darwin amd64/arm64, windows amd64/arm64 — текущий `with_purego` путь (libdl чисто Linux-glibc, проблемы нет).
- Win7-386 — без naive (нет cronet под windows/386 — отдельная физическая причина, не линковка).
- Android AAR — отдельный job, без изменений.
## 4. Критерии приёмки
- Релизные `linux-{amd64,arm64,armv7,mipsle}` — `file` → **statically linked**, `strings | grep libdl.so.2` → **0**.
- naive присутствует (тег `with_naive_outbound` в сборке; по возможности — функциональная проверка).
- armv7/mipsle запускаются на реальном/эмулированном musl-роутере (`sing-box version` без ошибки загрузчика).
- darwin/windows/win7/android-ассеты не изменились по составу.
- **Верификация — через CI** (`workflow_dispatch`): Chromium musl-toolchain (гигабайты) недоступен локально на macOS, поэтому локальной сборки musl нет — приёмка по прогону workflow.
## 5. Вне скоупа
- Экзотические арки (`386`, `riscv64`, `loong64`, `mips64le`) — добавляются точечно строкой матрицы по запросу; cronet-musl под `mips64le` вообще нет.
- DEB/RPM/Pacman/OpenWrt-пакеты (upstream их делает) — нам не нужны, публикуем `.tar.gz`.
- naive на Win7 — физически невозможен (нет `cronet-go/lib/windows_386`).
## 6. Ссылки
- [issue #1](https://github.com/Leadaxe/sing-box-lx/issues/1)
- upstream `.github/workflows/build.yml` (v1.13.13) — референс musl-pipeline.
@@ -0,0 +1,25 @@
# TASKS — 006-LINUX_MUSL_STATIC_ROUTER_BUILDS
## CI: musl-linux job
- [x] `lx-release.yml`: новый job `build_linux_musl`, матрица amd64/arm64/armv7(GOARM=7)/mipsle(GOMIPS=softfloat)
- [x] Clone cronet-go по `.github/CRONET_GO_VERSION` + submodules; regenerate keyring
- [x] Cache + download Chromium **musl** toolchain (`cmd/build-naive … --libc=musl`); set env
- [x] Build: `CGO_ENABLED=1`, теги `LX_TAGS` с `with_purego`→`with_musl`, `with_naive_outbound` сохранён
- [x] Из старого `build` job убрать строки `linux/*`; `release.needs` += `build_linux_musl`
## Нейминг + notes
- [x] Ассеты: `linux-amd64`, `linux-arm64`, `linux-armv7`, `linux-mipsle-softfloat` (без `-musl` суффикса)
- [x] `notes.md`: секция про роутерные musl-static арки + что naive сохранён
## Приёмка
- [x] Валидация YAML (actionlint/синтаксис)
- [x] Прогон `workflow_dispatch`; зелёные musl-сборки
- [x] В логах: `file` → statically linked, `strings|grep libdl.so.2` → 0
- [x] (по возможности) запуск armv7/arm64 под qemu-user — `sing-box version` без ошибки загрузчика
- [x] mipsle: подтвердить musl+naive; иначе fallback `_OTHERS` без naive (зафиксировать)
## Закрытие
- [x] IMPLEMENTATION_REPORT.md, DoD
- [x] `SPECS/README.md` roadmap-строка → C
- [x] Ответ в issue #1 (Dr4tez): armv7+arm64 musl-static с naive, имена ассетов; Win7-naive невозможен
- [ ] Боевой релиз `v1.13.13-lx.7` (ждёт ОК пользователя)
@@ -0,0 +1,158 @@
# IMPLEMENTATION_REPORT — 007 AWG_OVER_WIREGUARD_DETOUR_GUARD
**Дата:** 2026-06-15, ревизия 2026-06-16 · **Статус:** Complete (код + тесты + DoD; смена подхода после field-теста) · **База:** `v1.13.13`
## Итог
AmneziaWG-нода, чей `detour` (прямо или по транзитивной цепочке) ведёт на
**любой WireGuard-based** endpoint (плоский WG или AWG), больше не вешает ядро на
Android: такая связка отвергается (**вариант B** — ядро и остальные узлы живут,
этот узел не поднимается, ошибка в лог). `detour` AWG-ноды на не-WireGuard (VLESS
и т.д.) — разрешён.
## ⚠️ Смена подхода (2026-06-16) — два эшелона вместо одного
**Первая реализация (lx.8) ловила только лениво в `DetourDialer.init()` — и на
устройстве не сработала.** Field-тест AWG→AWG-конфига на Android (lx.8, logcat
`/tmp/logcat_brokennode.txt`): ядро виснет в `Starting` — последняя строка
`LxBoxNet: defaultNetwork`, затем 27 c тишины, `Libbox.newService()` не
возвращает управление; нашей guard-ошибки `connect to server: amneziawg…` в логе
**нет вовсе**.
**Почему ленивый guard не сработал:** он живёт в `DetourDialer.init()`, который
вызывается из `ClientBind.connect()` — то есть **при первом dial**, из bind-горутин.
А зависание происходит **синхронно в `Endpoint.Start`**, раньше dial:
`transport/wireguard/endpoint.go` `Start(resolve=true)` для peer с доменным
адресом (в конфиге — `mfi.tribukvy.ltd`) синхронно зовёт `ResolvePeer` **через
detour** (нижний туннель), + device запускает junk-handshake. До `connect()` дело
не доходит → ленивый guard архитектурно мёртв для этого сценария.
**Решение — Start-guard** (`protocol/wireguard.Endpoint.Start`): статический обход
транзитивного замыкания `detour` через `OutboundManager` + `Dependencies()`; при
достижении `Type()==wireguard` — device не поднимается, `started=false`, лог,
`return nil`. **Вариант B**: ядро и прочие узлы живут. На группе (selector/urltest)
обход **останавливается** (цель рантайм-зависима). Field-verified на Android lx.9.
> **Ревизия после lx.9: ленивый dialer-guard (lx.8) удалён.** Изначально его
> оставляли «вторым эшелоном» для случая «селектор по середине». Но: (1) непроверяем
> — в UI LxBox detour наводится только на реальный сервер, не на группу;
> (2) кэширует через `sync.Once` → **не ловит** смену селектора в рантайме; (3) в
> field-тесте вообще не сработал (виснет в `Start` до dial). Файлы
> `common/dialer/{detour,dialer}.go` возвращены к upstream.
**Решение «селектор по середине» — selector-guard** (`protocol/group.Selector.SelectOutbound`):
при переключении селектора на член, ведущий к WireGuard, **до** коммита выбора
(`s.selected.Store`) идём вверх по `OutboundManager.ConsumersOf` (reverse-deps,
транзитивно) и для каждого AmneziaWG-потребителя зовём `SuspendAmneziaWG()`
(`device.Down`, `started=false`). Гашение **до** переключения закрывает гонку: к
моменту, когда группа укажет на WG, AWG-потребитель уже опущен, его reconnect →
«not ready» → junk в WG не уйдёт. Маркер `adapter.AmneziaWGSuspendable` и метод
`OutboundManager.ConsumersOf` добавлены, чтобы `protocol/group` действовал без
импорта `protocol/wireguard`.
**Итог: два дополняющих guard'а** — Start-guard (статическая прямая цепь,
field-verified) + selector-guard (рантайм-переключение селектора). Оба — вариант B.
Главный инвариант: **ядро поднимать, коннект не стартовать, ошибку в лог.**
Баг **нашей** дельты (фича 003 AWG2), не upstream → в скоупе (CONSTITUTION §3.1).
## Диагноз (по данным с устройства)
Матрица из тестов автора:
| Источник (`detour`-ит) | Цель | Результат |
|---|---|---|
| **AWG** | **WG** | ❌ беда |
| **AWG** | **AWG** | ❌ беда |
| **AWG** | **VLESS** | ✅ работает |
| **WG** | AWG | ✅ работает |
⇒ триггер — **источник AWG + цель = WireGuard-туннель** (по пакетам: AWG внутри
WG). Не «два junk-слоя», как предполагалось вначале, и не сам `detour`.
Механика (статич. разбор `submodules/wireguard-go/device/send.go`):
`SendHandshakeInitiation` синхронно генерирует junk и зовёт `SendBuffers` →
`bind.Send()` **без таймаута**, держа `device.net.RLock()`. Когда AWG-трафик
инкапсулируется в WireGuard-устройство, запись блокируется на нижнем туннеле; на
Android (нет watchdog/перезапуска) — зависание. Первопричину статикой **не
доказывали** — задача намеренно про **guard**, не про лечение блокировки.
## Что сделано
### Итерация 1 (lx.8) — ленивый dialer-guard, **откачена**
Поведение копировало ядровой запрет `detour to an empty direct outbound`
(upstream `fb622ccb`) — guard в `DetourDialer.init()` (`common/dialer/detour.go`),
флаг владельца через `dialer.Options.IsAmneziaWG`. **Не сработал на устройстве**
(см. выше) и удалён ревизией 2026-06-16: `common/dialer/{detour,dialer}.go`
возвращены к upstream, тест `awg_detour_guard_test.go` удалён. Описание оставлено
как история — в текущем коде этого guard нет.
### Итерация 2 (lx.9) — Start-guard (актуальное)
**`protocol/wireguard/endpoint.go`** (`// lx:` AWG-шов):
- поля `Endpoint.awgActive` (= `AmneziaWGOptions.IsSet()`), `detour`,
`awgChainBlocked`;
- в `Start` (стадия `StartStateStart`), если `awgActive` и есть `detour`:
`awgDetourChainReachesWireGuard(outboundManager, detour, …)` обходит
**транзитивную** цепочку detour (через `OutboundManager.Outbound` +
`Dependencies()`), и если достигает `Type()==wireguard` — логирует ошибку, ставит
`awgChainBlocked=true`, **не вызывает** `w.endpoint.Start` и возвращает `nil`
(вариант B: `started` остаётся false, dial узла → «WireGuard is not ready yet»,
device с junk не поднимается → нет зависания). `PostStart` тоже пропускается.
- `awgDetourChainReachesWireGuard` **останавливается на группе** (selector/urltest)
— её рантайм-цель ловит selector-guard (ниже). Защита от циклов — set посещённых.
**`protocol/wireguard/awg_start_guard_test.go`** (новый lx-файл): direct WG,
транзитив через vless→WG, цепь без WG, селектор-в-середине (пропуск), цикл,
неизвестный тег.
### Итерация 3 — selector-guard (рантайм-переключение)
- **`adapter/outbound.go`** (`// lx:`): метод `OutboundManager.ConsumersOf(tag)`
(reverse-deps) + интерфейс-маркер `AmneziaWGSuspendable {IsAmneziaWG; SuspendAmneziaWG}`.
- **`adapter/outbound/manager.go`** (`// lx:`): реализация `ConsumersOf` из
`dependByTag` (копия под RLock).
- **`protocol/wireguard/endpoint.go`** (`// lx:`): `IsAmneziaWG()` и
`SuspendAmneziaWG()` (CAS `started`→false + лог при реальном гашении; `Suspend()`
device).
- **`transport/wireguard/endpoint.go`** (`// lx:`): `Suspend()` → `device.Down()`
без Close (идемпотентно).
- **`protocol/group/selector.go`** (`// lx:`): в `SelectOutbound` — `Swap`→`Load`+`Store`,
вызов `suspendAmneziaWGConsumersOnWireGuardSwitch` **до** `Store`.
- **`protocol/group/awg_selector_guard.go`** (новый lx-файл): `chainReachesWireGuard`
(член ведёт к WG?) + `suspendAmneziaWGConsumers` (обход вверх по `ConsumersOf`).
- **`protocol/group/awg_selector_guard_test.go`** (новый lx-файл): тесты.
## Приёмка (DoD)
- ✅ `go build ./...` без тегов — ок (для плоского WG `awgActive=false`/`IsAmneziaWG()==false` → guard'ы no-op).
- ✅ `go build -tags "with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api,with_xhttp,with_awg" ./cmd/sing-box` — ок.
- ✅ `go test ./protocol/wireguard/...` — `TestAwgDetourChainReachesWireGuard` (Start-guard, 6 кейсов).
- ✅ `go test ./protocol/group/...` — `chainReachesWireGuard` + `suspendAmneziaWGConsumers`
+ `…SkippedForNonWireGuardSwitch` (selector-guard: прямой/транзитивный AWG suspend,
не-AWG не трогается, не-WG переключение — no-op).
- ✅ `gofmt -l` изменённых файлов — пусто (урок 006/005 учтён).
- ✅ `go vet ./protocol/wireguard/... ./protocol/group/... ./adapter/...` — чисто.
- ✅ **Field-verified на lx.9 (Android, 2026-06-15 22:21)** — Start-guard: AWG→AWG
конфиг, ядро поднимается, узел не встаёт, остальное работает. selector-guard —
юнит-тестами (в UI LxBox недостижим: detour только на реальные серверы).
- Текст ошибок — единый, по архитектуре: «amneziawg over wireguard is not supported».
## Зона касания upstream (для ребейза)
- `protocol/wireguard/endpoint.go`, `transport/wireguard/endpoint.go`,
`protocol/group/selector.go`, `adapter/outbound.go`, `adapter/outbound/manager.go`
— upstream-файлы, правки **только** в `// lx:` блоках. Конфликт на ребейзе — лишь
если upstream перепишет `Endpoint.Start`/`Endpoint.Close`, `Selector.SelectOutbound`,
интерфейс `OutboundManager` или конструкторы.
- `common/dialer/{detour,dialer}.go` — ревизией 2026-06-16 **возвращены к upstream**.
- `awg_start_guard_test.go`, `awg_selector_guard.go`, `awg_selector_guard_test.go`
— lx-собственные, конфликтов не дают.
## Вне скоупа
- **Лечение первопричины** в `submodules/wireguard-go` (таймауты/неблокирующая
отправка junk; смежный баг `jmin>jmax` закрыт задачей 008) — отдельная задача.
- Цепочки AWG-over-WireGuard через route-rule action, а не `detour`.
@@ -0,0 +1,72 @@
# PLAN: 007 — AWG_OVER_WIREGUARD_DETOUR_GUARD
## Архитектура решения
Guard живёт в **ленивом резолве detour** (`common/dialer/detour.go`
`DetourDialer.init()`), рядом с upstream-проверкой `empty direct outbound`. Это
даёт **вариант B** тем же механизмом, что и empty-direct: ошибка кэшируется в
`initErr` (через `sync.Once`), `DialContext`/`ListenPacket` возвращают её вместо
соединения. Ядро стартует без ошибок, прочие узлы работают, AWG-нода фейлит
каждый dial. Не fatal на старте.
### Откуда «источник AWG»
`DetourDialer` сам по себе не знает тип владельца. Прокидываем флаг:
`dialer.Options.IsAmneziaWG` → `NewDetour(..., ownerIsAmneziaWG)`.
`protocol/wireguard.NewEndpoint` выставляет его из `options.AmneziaWGOptions.IsSet()`.
### Как «цель WireGuard»
Резолвленная detour-цель приводится к `adapter.Outbound`; триггер —
`Type() == C.TypeWireGuard` (покрывает и плоский WG, и AWG — тип один). Группы
(`adapter.OutboundGroup`) раскрываются рекурсивно через `All()` +
`OutboundManager.Outbound(tag)`, с set'ом посещённых против циклов.
## Изменённые файлы
| Файл | Зона | Что |
|------|------|-----|
| `common/dialer/detour.go` | `// lx:` upstream | поле `ownerIsAmneziaWG`, guard в `init()`, рекурсивный `detourTargetIsWireGuard`, новый параметр `NewDetour` |
| `common/dialer/dialer.go` | `// lx:` upstream | поле `Options.IsAmneziaWG`, проброс в `NewDetour` |
| `protocol/wireguard/endpoint.go` | `// lx:` upstream (AWG-шов) | `IsAmneziaWG: options.AmneziaWGOptions.IsSet()` в `dialer.Options` |
| `common/dialer/awg_detour_guard_test.go` | **новый lx-файл** | unit-тест матрицы + init-пути |
## Логика guard (как реализовано)
```go
// detour.go init(), после empty-direct:
if d.ownerIsAmneziaWG && detourTargetIsWireGuard(mgr, dialer, map[string]bool{}) {
d.initErr = E.New("amneziawg endpoint cannot detour through a wireguard-based "+
"endpoint (detour: ", d.detour, "): AmneziaWG inside a WireGuard tunnel "+
"hangs the kernel on Android; use a non-wireguard detour (e.g. vless)")
return
}
func detourTargetIsWireGuard(mgr, ob, visited) bool {
if o, ok := ob.(adapter.Outbound); ok && o.Type() == C.TypeWireGuard { return true }
if g, ok := ob.(adapter.OutboundGroup); ok {
for _, tag := range g.All() {
if visited[tag] { continue }
visited[tag] = true
if m, loaded := mgr.Outbound(tag); loaded && detourTargetIsWireGuard(mgr, m, visited) { return true }
}
}
return false
}
```
## DoD (IMPLEMENTATION_PROMPT §2) — факт
- [x] `go build ./...` без тегов — ок.
- [x] `go build -tags "...,with_awg" ./cmd/sing-box` — ок.
- [x] `go test ./common/dialer/...` — зелёный (8 подтестов).
- [x] `gofmt -l` изменённых файлов — пусто.
- [x] `go vet ./common/dialer/... ./protocol/wireguard/...` — чисто.
## Зона касания upstream (для ребейза)
- `common/dialer/detour.go`, `common/dialer/dialer.go`,
`protocol/wireguard/endpoint.go` — upstream-файлы, правки только в `// lx:`
блоках. Конфликт на ребейзе — лишь если upstream перепишет `DetourDialer.init`
/ сигнатуру `NewDetour` / `dialer.Options` / конструктор endpoint.
- `awg_detour_guard_test.go` — lx-собственный, конфликтов не даёт.
- Сигнатура `NewDetour` расширена 4-м параметром — единственный внешний вызов в
`dialer.go` обновлён; других вызовов в дереве нет.
@@ -0,0 +1,173 @@
# SPEC: 007 — AWG_OVER_WIREGUARD_DETOUR_GUARD
| Поле | Значение |
|------|----------|
| Тип | B (bug) |
| Статус | C (complete) — guard **снят** (см. баннер ниже) |
> ## ⛔️ Guard снят (2026-07-26) — первопричина к shater не относится
> **Оба guard'а (Start-guard в `protocol/wireguard/endpoint.go` и
> selector-guard в `protocol/group/awg_selector_guard.go`) удалены**, вместе с
> их adapter-хуками (`OutboundManager.ConsumersOf`, `AmneziaWGSuspendable`).
> Апстрим снял их коммитом `5fa3a0a17`; сюда снятие приехало отдельно.
>
> **Почему.** Зависание было **Android-специфичным** (`Libbox.newService` не
> возвращал управление). Android для shater не платформа и ей не станет —
> мы собираем роутерный бинарь под OpenWrt/aarch64. При этом лекарство для
> самой AWG-за-detour связки у нас уже есть: reserved-clear gate в
> `ClientBind` (`d971eb85e` + пин сабмодуля `7d15f33`), без которого AWG не
> поднимался вообще ни за каким detour'ом. Мы носили и лекарство, и запрет
> на его применение.
>
> **Чем это было плохо на практике.** Guard отказывал **молча**: не ошибкой,
> а `started=false`, после чего каждый дозвон падал с «WireGuard is not ready
> yet». Конфигурация «AmneziaWG за WireGuard-хопом» выглядела не как
> отклонённая, а как «нода почему-то не работает».
>
> **Регрессия:** `protocol/wireguard/awg_over_wireguard_start_lx_test.go`
> (`with_gvisor && with_awg`) — AWG-эндпоинт с `detour` на outbound типа
> `wireguard` доходит до PostStart и поднимает `started`. До снятия guard'а
> тест краснел.
>
> **Осталось:** сквозной прогон на железе (AWG поверх реального WG-хопа).
Отклонять (по образцу ядрового запрета «empty direct detour») конфигурацию, где
AmneziaWG-endpoint (источник с AWG-полями) имеет `detour` на **любой
WireGuard-based** endpoint — плоский WireGuard **или** AmneziaWG. По пакетам это
AWG-трафик, инкапсулированный внутри WireGuard-туннеля; на Android такая связка
**вешает ядро**. `detour` AWG-ноды на не-WireGuard outbound (VLESS, Trojan,
direct, …) — рабочий сценарий и остаётся разрешённым.
Баг в фиче [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT) **нашей**
дельты (AWG-проводка + merged-форк wireguard-go), не upstream → в скоупе по
CONSTITUTION §3.1.
---
## 1. Проблема / контекст
Матрица из тестов на устройстве (автор):
| Источник (`detour`-ит) | Цель | Результат |
|---|---|---|
| **AWG** | **WG** (плоский WireGuard) | ❌ беда (ядро виснет / handshake не уходит) |
| **AWG** | **AWG** | ❌ беда |
| **AWG** | **VLESS** | ✅ работает |
| **WG** (без AWG-полей) | AWG | ✅ работает |
Вывод: триггер — **источник AWG + цель = любой WireGuard-туннель**, а не «два junk-слоя».
По пакетам — AWG внутри WG. По конфигу — «у ноды AWG прописан `detour` на WG/AWG».
Механика (статический разбор `submodules/wireguard-go`):
`SendHandshakeInitiation` (device/send.go) синхронно генерирует junk и зовёт
`SendBuffers` → `bind.Send()` **без таймаута на запись**, удерживая
`device.net.RLock()`. Когда AWG-трафик заворачивается в WireGuard-устройство,
запись блокируется на нижнем туннеле; на Android (нет watchdog) это проявляется
как зависание. Точную первопричину статикой **не доказали** — для guard это и не
нужно: цель — **быстро отклонять** заведомо опасную связку, пока (отдельной
задачей) не вылечена сама блокировка в wireguard-go.
## 2. Цель
AWG-нода с `detour` на WireGuard-based цель **не поднимает соединение**.
Поведение — **вариант B** (согласовано с автором и
[LxBox §128](https://github.com/Leadaxe/LxBox/blob/develop/docs/spec/tasks/128-force-direct-out-detour.md)):
ядро стартует, остальные узлы работают, эта нода не встаёт, ошибка в логе. **Не
крашимся.**
> **Ревизия 2026-06-16 (см. IMPLEMENTATION_REPORT):** реализация прошла итерации.
>
> 1. **lx.8 — ленивый guard в `DetourDialer.init()`** (на первом dial). Field-тест
> показал: **не срабатывает** — AWG→WG виснет синхронно в `Endpoint.Start`
> (резолв peer-домена через detour + junk-handshake), **до** первого dial.
> **Удалён** (непроверяем в UI, `sync.Once`-кэш не ловит смену селектора).
> 2. **lx.9 — Start-guard в `protocol/wireguard.Endpoint.Start`** (статический обход
> транзитивной detour-цепи; device не поднимается). **Field-verified на Android.**
> 3. **selector-guard в `protocol/group` (`SelectOutbound`)** — закрывает случай
> «селектор по середине», который Start-guard статически пропускает: при
> переключении селектора на член, ведущий к WireGuard, **до** коммита выбора
> гасятся (suspend → `device.Down`, `started=false`) все AmneziaWG-потребители,
> что detour-ят на эту группу (транзитивно вверх через `ConsumersOf`).
>
> **Итог: два дополняющих guard'а — Start-guard (статический, прямая цепь) +
> selector-guard (рантайм, переключение селектора).** Оба дают вариант B и не
> крашатся. Гашение selector-guard'ом — **до** переключения, поэтому AWG-потребитель
> опущен раньше, чем группа укажет на WG → гонки нет.
## 3. Требования
### 3.1 Критерий «источник»
- Источник-триггер — AWG-endpoint: `option.AmneziaWGOptions.IsSet()` (любое
AWG-поле). Плоский WG источником-триггером **не** является (WG→AWG разрешён).
### 3.2 Критерий «цель»
- Цель-триггер — **любой WireGuard-based outbound**: `Type() == C.TypeWireGuard`
(один тип `"wireguard"` покрывает и плоский WG, и AWG — AWG отличается лишь
набором полей, тип тот же). Решение автора: детектировать **по типу**.
- Цепочка обходится **транзитивно** по `detour` (через `OutboundManager.Outbound`
+ `Dependencies()`): AWG→X→…→WG ловится на любой глубине. Защита от циклов — set
посещённых тегов.
- На группе (selector/urltest) Start-guard обход **останавливается** — выбранный
член рантайм-зависим; этот случай ловит selector-guard (§3.3). Не-WireGuard цели
(VLESS и т.д.) — **не** триггер.
### 3.3 Где ловить — два guard'а
**(a) Start-guard** — `protocol/wireguard.Endpoint.Start` (стадия `StartStateStart`),
**до** `w.endpoint.Start()`. Зависание происходит синхронно в `Start` (резолв
peer-домена через detour + junk-handshake), до первого dial — ленивый guard туда не
успевает (доказано на lx.8). Источник — `Endpoint.awgActive`
(`AmneziaWGOptions.IsSet()`); цель — `awgDetourChainReachesWireGuard(...)`
(транзитивный обход detour, **на группе останавливается**). Поведение — **вариант B**:
device не поднимается, `started=false`, `return nil` (НЕ error — иначе abort
инстанса), ошибка в лог. Все outbound'ы зарегистрированы до любого `Start`.
**(b) selector-guard** — `protocol/group.Selector.SelectOutbound`, **до** коммита
выбора (`s.selected.Store`). Если новый член ведёт к WireGuard
(`chainReachesWireGuard`: сам тип / detour вниз / вложенные группы), идём **вверх**
по `OutboundManager.ConsumersOf` (reverse-deps, транзитивно: AWG→vless→group) и для
каждого потребителя с `IsAmneziaWG()` зовём `SuspendAmneziaWG()` (`device.Down`,
`started=false`). Гашение **до** `Store` → к моменту переключения AWG уже опущен,
его reconnect вернёт «not ready» → junk в WG не уйдёт (**гонки нет**). Лог — при
реальном гашении (`CompareAndSwap(true,false)`), без спама на повторных переключениях.
### 3.4 Изоляция (CONSTITUTION §3.2–3.3)
- Start-guard — `// lx:` блоки в `protocol/wireguard/endpoint.go` +
`transport/wireguard/endpoint.go` (`Suspend()`); тест `awg_start_guard_test.go`.
- selector-guard — новый файл `protocol/group/awg_selector_guard.go` + один вызов в
`selector.go`; маркер `adapter.AmneziaWGSuspendable` и `OutboundManager.ConsumersOf`
в `adapter/outbound.go` + `adapter/outbound/manager.go` (`// lx:`); тест
`awg_selector_guard_test.go`.
- `common/dialer/{detour,dialer}.go` ревизией 2026-06-16 **возвращены к upstream**.
- Поведение **без** `with_awg` не меняется: AWG-конфиг отвергается раньше; для
плоского WG `awgActive=false`/`IsAmneziaWG()==false` → guard'ы no-op.
## 4. Критерии приёмки
- AWG `detour`→WG и AWG `detour`→AWG: узел не поднимается, ошибка в лог; ядро и
прочие узлы живут (вариант B). ✅ field-verified на Android lx.9.
- AWG `detour`→VLESS и WG `detour`→AWG: проходит.
- AWG→X→…→WG (транзитивно по detour): ловится Start-guard'ом; циклы не виснут.
- Переключение селектора на WG-член при AWG-потребителе: AWG suspend **до** выбора;
не-AWG потребители не трогаются; переключение на не-WG ничего не гасит.
- Юнит-тесты зелёные: `awgDetourChainReachesWireGuard` (Start-guard) +
`chainReachesWireGuard`/`suspendAmneziaWGConsumers` (selector-guard).
- `go build ./...` без тегов — ок; сборка с `with_awg` — ок; `gofmt -l` пусто.
## 5. Вне скоупа
- **Гонка selector-guard:** закрыта порядком (гасим до `Store`). Остаётся
теоретический случай, если потребитель переподключается строго между нашим
suspend и его собственным dial в том же тике — практически невозможен, т.к. suspend
синхронен и до коммита выбора.
- **Лечение первопричины** (таймауты/неблокирующая отправка junk в
`submodules/wireguard-go`) — отдельная будущая задача.
- Цепочки через route-rule action, а не `detour` — вне скоупа.
## 6. Ссылки
- Фича [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT)
- LxBox §128 — образец поведения «вариант B» (ленивая detour-ошибка, ядро живёт):
`Leadaxe/LxBox/docs/spec/tasks/128-force-direct-out-detour.md`
- `submodules/wireguard-go/device/send.go` (junk-генерация в SendHandshakeInitiation)
- Образец detour-проверки: `common/dialer/detour.go` (empty-direct, upstream `fb622ccb`)
@@ -0,0 +1,35 @@
# TASKS — 007-B-AWG_OVER_WIREGUARD_DETOUR_GUARD
## Итерация 1 — ленивый dialer-guard (lx.8) — ОТКАЧЕНА
- [x] guard в `common/dialer/detour.go` `init()` + проброс через `dialer.Options`
- [x] **Удалено** (не сработало на устройстве; `common/dialer/{detour,dialer}.go` → upstream)
## Итерация 2 — Start-guard (lx.9) — field-verified
- [x] `protocol/wireguard/endpoint.go`: поля `awgActive`/`detour`/`awgChainBlocked`,
`awgDetourChainReachesWireGuard` (транзитивный обход detour, стоп на группе),
вариант B в `Start` (device не поднимается, `started=false`, лог, `return nil`)
- [x] `awg_start_guard_test.go`: direct/транзитив/нет-WG/селектор-пропуск/цикл/unknown
- [x] Текст ошибки — «amneziawg over wireguard is not supported» (архитектура, не платформа)
## Итерация 3 — selector-guard (рантайм-переключение)
- [x] `adapter/outbound.go`: `OutboundManager.ConsumersOf` + маркер `AmneziaWGSuspendable`
- [x] `adapter/outbound/manager.go`: реализация `ConsumersOf` (reverse-deps под RLock)
- [x] `protocol/wireguard/endpoint.go`: `IsAmneziaWG()` + `SuspendAmneziaWG()` (CAS+лог)
- [x] `transport/wireguard/endpoint.go`: `Suspend()` (device.Down, идемпотентно)
- [x] `protocol/group/selector.go`: гашение **до** `Store` (Swap→Load+Store)
- [x] `protocol/group/awg_selector_guard.go`: `chainReachesWireGuard` + `suspendAmneziaWGConsumers`
- [x] `awg_selector_guard_test.go`: прямой/транзитивный AWG suspend, не-AWG не трогается, не-WG → no-op
## Приёмка (DoD)
- [x] `go build ./...` без тегов — ок
- [x] `go build -tags "...,with_awg" ./cmd/sing-box` — ок
- [x] `go test ./protocol/wireguard/... ./protocol/group/...` — зелёные
- [x] `gofmt -l` изменённых lx-файлов — пусто
- [x] `go vet` затронутых пакетов — чисто
- [x] **Field-verified** Start-guard на Android lx.9; selector-guard — юнит-тестами
## Закрытие
- [x] IMPLEMENTATION_REPORT.md, SPEC, DoD
- [x] `SPECS/README.md` roadmap-строка 007
- [x] GH issue #2: комментарии со ссылками на коммиты + закрыт
- [x] Статус → `C` (шапка SPEC.md + Roadmap)
@@ -0,0 +1,63 @@
# IMPLEMENTATION_REPORT — 008 AWG_JUNK_PARAM_VALIDATION
**Дата:** 2026-06-16 · **Статус:** Complete (код + тесты + DoD) · **База:** `v1.13.13`
## Итог
AmneziaWG junk-диапазон с `jmin > jmax` теперь отвергается **при построении
endpoint'а** (`awgIpcLines` → `wireguard.NewEndpoint` → `check`/старт) понятной
ошибкой, вместо рантайм-**паники** в горутине таймера ретрансмита handshake.
Баг **нашей** дельты (merged-форк wireguard-go + AWG-проводка), найден при разборе
[007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD) / [issue #2](https://github.com/Leadaxe/sing-box-lx/issues/2)
→ в скоупе (CONSTITUTION §3.1).
## Диагноз
`submodules/wireguard-go/device/send.go:147-149` перед handshake:
```go
nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(jmax-jmin+1)))
```
При `jmin > jmax` аргумент `jmax-jmin+1 <= 0` → `rand.Int` паникует
(`crypto/rand: argument to Int is <= 0`) в timer-горутине → краш. `device/uapi.go`
проверяет `jc/jmin/jmax > 0` по отдельности, но не их связь.
## Решение (узкое — по согласованию с автором)
Валидируем **только** `jmin <= jmax` — ровно краш-кейс. jc-несогласованность
(`jc>0` без размеров → пустой junk; размеры без `jc` → junk не шлётся) **осознанно
не ловим**: безвредна (туннель встаёт, не паникует), а строгое правило (а) сломало
бы существующий тест `TestAwgIpcLinesUnsetHeadersOmitted` (`jc=4` без размеров) и
(б) рисковало бы отклонить рабочий awg2-экспорт — против приоритета совместимости
с реальными серверами (CONSTITUTION §2.2) и минимального диффа.
**`transport/wireguard/device_awg.go`** (lx, под `with_awg`):
- `validateJunk(o)` — `if o.Jmin > o.Jmax { return error }`;
- вызов в начале `awgIpcLines` (после `IsSet()`), до рендера IpcSet-строк.
**`transport/wireguard/device_awg_test.go`** (lx, под `with_awg`):
- `TestAwgIpcLinesJminGreaterThanJmax` — `jmin=70 jmax=40` → ошибка (`jmin`/`jmax`
в тексте) + `require.NotPanics`;
- `TestAwgIpcLinesValidJunkRange` — `jc4/jmin40/jmax70` ок, junk-off ок.
## Приёмка (DoD)
- ✅ `go build ./...` без тегов — ок (без `with_awg` AWG отвергается раньше).
- ✅ `go build -tags "...,with_awg" ./cmd/sing-box` — ок.
- ✅ `go test -tags with_awg ./transport/wireguard/...` — зелёный, 7 ipc-тестов
(5 прежних + 2 новых), `TestAwgIpcLinesUnsetHeadersOmitted` не сломан.
- ✅ `gofmt -l` изменённых файлов — пусто.
## Зона касания upstream (для ребейза)
- `device_awg.go` / `device_awg_test.go` — lx-собственные файлы под `with_awg`,
в upstream их нет → конфликтов на ребейзе не дают.
- `submodules/wireguard-go` **не трогали** — guard в `awgIpcLines` ловит раньше,
до того как значения уйдут в устройство.
## Вне скоупа
- Правка панического `rand.Int` в самом `submodules/wireguard-go` (наш guard
делает её ненужной для конфигов, проходящих через `awgIpcLines`).
- jc/размеры-согласованность (см. «Решение» — осознанно не делаем).
- s1–s4 / h1–h4 (h уже валидируются `MagicHeader.Spec()`) / i1–i5.
@@ -0,0 +1,49 @@
# PLAN: 008 — AWG_JUNK_PARAM_VALIDATION
## Архитектура
Одна функция-валидатор `validateJunk(o)` в `transport/wireguard/device_awg.go`,
вызывается в начале `awgIpcLines` (сразу после `IsSet()`-проверки). `awgIpcLines`
уже возвращает `error` и уже на пути `wireguard.NewEndpoint` → endpoint build →
`check`/старт, поэтому ошибка fail-fast доходит до пользователя без паники.
Под тег `with_awg` (файл целиком под ним). Без тега — стаб `device_stub_awg.go`
уже даёт «awg support not built», поведение upstream не меняется.
## Изменённые файлы
| Файл | Зона | Что |
|------|------|-----|
| `transport/wireguard/device_awg.go` | lx (под `with_awg`) | `validateJunk` + вызов в `awgIpcLines` |
| `transport/wireguard/device_awg_test.go` | lx (под `with_awg`) | тесты правил + «не паникует при jmin>jmax» |
## Логика (узкое правило — только краш-кейс)
```go
func validateJunk(o option.AmneziaWGOptions) error {
if o.Jmin > o.Jmax {
return E.New("amneziawg: jmin (", F.ToString(o.Jmin), ") must be <= jmax (", F.ToString(o.Jmax), ")")
}
return nil
}
```
Одна проверка — ровно тот случай, что паникует в `send.go`. jc-несогласованность
осознанно **не** ловим (см. SPEC §3.1): безвредна, и строгое правило сломало бы
существующий тест `jc=4`-без-размеров и рисковало бы отклонить рабочий awg2-конфиг.
`jmin=0,jmax=0` (junk off) → `0>0` false → ок. `jmin>0,jmax=0` → ловится (это либо
краш-риск при jc>0, либо явная опечатка).
## DoD
- [ ] `go build ./...` без тегов — ок
- [ ] `go build -tags "...,with_awg" ./cmd/sing-box` — ок
- [ ] `go test -tags with_awg ./transport/wireguard/...` — зелёный (вкл. no-panic)
- [ ] `gofmt -l` — пусто
- [ ] существующие `lx-test/config/awg2_*.json` остаются валидными
## Зона касания upstream (для ребейза)
- `device_awg.go` / `device_awg_test.go` — lx-собственные файлы под `with_awg`,
в upstream их нет → конфликтов на ребейзе не дают.
- `submodules/wireguard-go` — **не трогаем** (guard ловит раньше).
+108
View File
@@ -0,0 +1,108 @@
# SPEC: 008 — AWG_JUNK_PARAM_VALIDATION
| Поле | Значение |
|------|----------|
| Тип | B (bug) |
| Статус | C (complete) |
Отклонять невалидную junk-связку AmneziaWG (`jc`/`jmin`/`jmax`) **на уровне
конфига** (при построении endpoint, до handshake), а не падать рантайм-паникой в
горутине таймера. Главный мотив: при `jmin > jmax` вендоренный amneziawg-go
**паникует** (`rand.Int` с аргументом ≤ 0) — это краш ядра, а не управляемая
ошибка.
Баг в фиче [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT).
Найден при разборе [007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD) /
[issue #2](https://github.com/Leadaxe/sing-box-lx/issues/2). Баг **нашей** дельты
(merged-форк wireguard-go + наша AWG-проводка) → в скоупе (CONSTITUTION §3.1).
---
## 1. Проблема / контекст
Семантика junk (из `submodules/wireguard-go/device/send.go:143-154`):
```go
jc := peer.device.junk.count // сколько junk-пакетов слать перед handshake
jmin := peer.device.junk.min
jmax := peer.device.junk.max
for i := 0; i < jc; i++ {
nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(jmax-jmin+1))) // ← паника при jmax<jmin
n := int(nBig.Int64()) + jmin
buf := make([]byte, n); rand.Read(buf)
sendBuffer = append(sendBuffer, buf)
}
```
- **`jmin > jmax`**: `jmax-jmin+1 <= 0` → `rand.Int` паникует
(`crypto/rand: argument to Int is <= 0`). Происходит в горутине таймера
ретрансмита handshake → **краш**, не ловится валидацией. uapi (`device/uapi.go`)
проверяет только `jc/jmin/jmax > 0` по отдельности, связь — нет.
- **`jc > 0`, но `jmin`/`jmax` не заданы**: цикл шлёт `jc` пустых (нулевого
размера) пакетов — junk фактически не работает, тихая деградация обфускации.
- **`jmin`/`jmax` заданы, `jc == 0`**: цикл не исполняется — размеры заданы
впустую, junk не шлётся. Вероятная ошибка конфига.
Реальные awg2-экспорты задают триаду **всегда вместе** (тест-конфиги:
`jc=4 jmin=40 jmax=70`; `jc=5 jmin=10 jmax=50`), так что правило согласованности
рабочие конфиги не ломает.
## 2. Цель
Невалидная junk-триада отвергается при построении endpoint'а с **понятной
ошибкой** (`./sing-box check` / старт фейлится управляемо), вместо рантайм-паники
позже. Это **fail-fast на уровне конфига** — в отличие от [007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD)
(связка узлов, ловится лениво в dialer): здесь невалидно **одно** поле-сочетание
одного endpoint'а, видно сразу.
## 3. Требования
### 3.1 Правило (узкое — только краш-кейс)
- **`jmin <= jmax`** — единственная проверка. При `jmin > jmax` амнезия-форк
паникует (`rand.Int` arg ≤ 0) → краш; это и чиним.
**Осознанно НЕ валидируем** (решение автора, рекомендация — минимальный дифф):
- `jc > 0` без jmin/jmax — шлёт пустые junk-пакеты: бесполезно, но **не
паникует**, туннель поднимается. Расширять guard на это — навязывать мнение о
конфиге и рисковать отклонить рабочий чужой awg2-экспорт (CONSTITUTION §2.2,
совместимость с реальными серверами).
- размеры без `jc` — junk не шлётся, безвредно.
Существующий тест `TestAwgIpcLinesUnsetHeadersOmitted` (`jc=4` без размеров) при
узком правиле **остаётся валиден** — чужое осознанное решение не ломаем.
### 3.2 Точка проверки
- `transport/wireguard/device_awg.go` `awgIpcLines` — она уже возвращает `error`
и уже вызывается из `wireguard.NewEndpoint` (→ `NewEndpoint` outbound →
`check`/старт). Ошибка доходит до пользователя **до** handshake, без паники.
- Под тег `with_awg` (как и весь `awgIpcLines`). Без тега AWG-конфиг и так
отвергается раньше («awg support not built») — поведение upstream не меняется.
### 3.3 Изоляция
- Правка — в существующем lx-файле `device_awg.go` (он целиком lx, под тегом).
Новых upstream-швов не добавляем. Тест — в `device_awg_test.go` (уже lx).
## 4. Критерии приёмки
- `jmin > jmax` → endpoint не строится, ошибка с упоминанием `jmin`/`jmax`; **нет
паники** (`require.NotPanics`).
- Валидная триада (`jc=4 jmin=40 jmax=70`), `jc=4` без размеров, и junk-off —
проходят. Тест-конфиги `awg2_basic`/`awg2_ranged` валидны; существующий тест
`TestAwgIpcLinesUnsetHeadersOmitted` не сломан.
- Юнит-тест зелёный; сборка с `with_awg` ок; `go build ./...` без тегов ок;
`gofmt -l` пусто.
## 5. Вне скоупа
- Правка самого `submodules/wireguard-go` (добавить проверку в uapi/убрать
панику) — наш guard в `awgIpcLines` ловит раньше, форк не трогаем (его правка —
отдельный осознанный шаг, CONSTITUTION §4 про сабмодуль).
- Валидация s1–s4 / h1–h4 / i1–i5 (h-поля уже валидируются `MagicHeader.Spec()`).
- MTU/размерные предупреждения — уже есть в `wireguard.NewEndpoint`.
## 6. Ссылки
- `submodules/wireguard-go/device/send.go:143-154` (junk-цикл, паника)
- `submodules/wireguard-go/device/uapi.go:310-341` (uapi: только `>0`, связи нет)
- `transport/wireguard/device_awg.go` `awgIpcLines` (точка проверки)
- [007](../007-AWG_OVER_WIREGUARD_DETOUR_GUARD) / [issue #2](https://github.com/Leadaxe/sing-box-lx/issues/2) — где баг найден
@@ -0,0 +1,21 @@
# TASKS — 008-B-AWG_JUNK_PARAM_VALIDATION
## Код
- [x] `device_awg.go`: `validateJunk(o)` — единственное правило `jmin <= jmax`
- [x] Вызвать `validateJunk` в начале `awgIpcLines` (после IsSet)
## Тест
- [x] `device_awg_test.go`: jmin>jmax → ошибка + `require.NotPanics`; валидная триада `jc4/jmin40/jmax70` ок; junk-off (только header) ок
- [x] Существующий `TestAwgIpcLinesUnsetHeadersOmitted` (`jc=4` без размеров) не сломан
## Приёмка (DoD)
- [x] `go build ./...` без тегов — ок
- [x] `go build -tags "...,with_awg" ./cmd/sing-box` — ок
- [x] `go test -tags with_awg ./transport/wireguard/...` — зелёный (7 ipc-тестов)
- [x] `gofmt -l` изменённых lx-файлов — пусто
## Закрытие
- [x] IMPLEMENTATION_REPORT.md, DoD
- [ ] `SPECS/README.md` roadmap-строка 008
- [ ] Коммит (со ссылкой), затем GH issue + комментарий с ссылкой на коммит + закрыть
- [ ] Папка `008-B-O-…` → `008-B-C-…`
@@ -0,0 +1,242 @@
# EXAMPLES — WireSock-style маскировка `id` / `ip` / `ib`
Практический how-to по полям маскировки фичи 009. Это сахар над AmneziaWG `i1`:
вместо ручной CPS-строки `i1=<b 0x...>` пишешь домен/протокол/браузер, а движок
сам собирает пакет-приманку нужного протокола и шлёт его как `i1` перед handshake.
> Требуется сборка с `with_awg`. Без тега любой `id`/`ip`/`ib` отвергается:
> `AmneziaWG (awg) support is not included in this build, rebuild with -tags with_awg`.
---
## 1. Три поля
| Поле | Имя | Значения | Обязательно |
|------|-----|----------|-------------|
| `id` | домен | LDH-хост (`www.google.com`, `ozon.ru`, `_dmarc.example.com`) | **обязателен только для `quic`** (SNI); опционален для `dns` (QNAME или псевдо-домен), `sip` (host или псевдо-host) и `stun` (игнорируется) |
| `ip` | протокол | `quic` \| `dns` \| `stun` \| `sip` | да |
| `ib` | браузер | `chrome` \| `firefox` \| `curl` | нет (только при `ip=quic`) |
Минимум: `ip` всегда; плюс `id` — для `quic`. Для `dns`/`sip`/`stun` хватает одного `ip`
(`id` опционален: для `sip` без него генерируется псевдо-host, для `stun` он не идёт в пакет).
Если `id` задан — он всегда валидируется (LDH).
### Куда `id` реально попадает на провод
| `ip` | пакет-приманка | `id` виден цензору? |
|------|----------------|---------------------|
| `dns` | EDNS OPT query (QR=0), `id` = **QNAME** | **да**, открытым текстом |
| `sip` | SIP INVITE request, `id` = **host** в URI (или псевдо-host) | **да** (если задан), открытым текстом |
| `quic` | фрагментированный QUIC Initial, `id` = **SNI** в ClientHello | **да** — цензор выводит ключи из DCID и читает SNI (если соберёт фрагменты по порядку) |
| `stun` | STUN Binding Success Response | **нет** — в STUN нет поля под домен |
**Вывод:** `ip=dns`/`ip=sip`/`ip=quic` несут домен на провод (`id` как QNAME /
SIP-host / SNI соответственно). `stun` даёт приманку без домена (маскировка по
*форме* протокола, а не по имени хоста).
---
## 2. Примеры по профилям
Во всех примерах опущены `log`/`outbounds`/`route` — оставлены только поля
endpoint'а. Подставь свои `private_key` / `peers` / `address`.
### 2.1 QUIC (под Cloudflare WARP)
```jsonc
{
"type": "wireguard",
"tag": "warp",
"mtu": 1280,
"address": ["172.16.0.2/32", "2606:4700:110:8000::2/128"],
"private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "www.google.com",
"ip": "quic",
"ib": "chrome",
"peers": [
{
"address": "engage.cloudflareclient.com",
"port": 2408,
"public_key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=",
"allowed_ips": ["0.0.0.0/0", "::/0"],
"persistent_keepalive_interval": 25
}
]
}
```
Генерирует **out-of-order фрагментированный QUIC Initial** (RFC 9001): реалистичный
ClientHello, где `id` (`www.google.com`) — это **SNI**, разбитый на CRYPTO-фреймы,
которые идут на провод **не по порядку** — первый CRYPTO-фрейм имеет offset≠0, а
offset-0 фрейм лежит ближе к концу, с интерливом PING/PADDING. Line-rate DPI хватает
первый фрейм, считает его offset 0, парсит мусор и пропускает (fail-open). `ib`
валидируется (`chrome|firefox|curl`), но на байты пакета не влияет — bypass держится
на фрагментации, а не на TLS-fingerprint (JA3 не имитируется). Генератор —
`quic_initial_awg.go`, `quic_clienthello_awg.go`, `quic_crypto_awg.go`.
`id` для `quic` **обязателен** (он становится SNI в ClientHello). Минимальный
вариант — `"id": "<домен>", "ip": "quic"` (плюс опциональный `"ib"`):
```jsonc
{ /* ...endpoint... */ "jc": 4, "jmin": 40, "jmax": 70, "id": "www.google.com", "ip": "quic", "ib": "chrome" }
```
### 2.2 DNS (домен виден на проводе)
```jsonc
{
"type": "wireguard",
"tag": "awg-dns",
"mtu": 1280,
"address": ["10.0.0.2/32"],
"private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "www.google.com",
"ip": "dns",
"peers": [
{ "address": "192.0.2.1", "port": 51820,
"public_key": "<server-public-key-base64>",
"allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25 }
]
}
```
Генерирует клиентский DNS **query** (~87 байт): DNS-заголовок (QR=0, RD=1), вопрос с
QNAME = `www.google.com` и QTYPE HTTPS (тип 65), OPT RR (TYPE 41, UDP-size 1232) и
случайные cover-байты как opaque-данные неизвестной EDNS-опции `0xFDE9`. Парсится как один
валидный DNS-запрос. **`id` тут — QNAME, виден цензору.**
> **Не подтверждён на WARP-DPI.** На тестовом LTE/WARP DPI `ip=dns` — Timeout (как `stun`):
> DPI режет DNS/STUN к WARP-edge `:2408` как класс протокола (raw DNS живёт на :53, а не на
> дата-центровом IP). Для WARP используй `ip=quic`. `ip=dns` оставлен для других провайдеров,
> чей DPI проверяет только корректность пакета.
### 2.3 STUN
```jsonc
{
"type": "wireguard", "tag": "awg-stun", "mtu": 1280,
"address": ["10.0.0.2/32"], "private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "stun.l.google.com",
"ip": "stun",
"peers": [
{ "address": "192.0.2.1", "port": 51820,
"public_key": "<server-public-key-base64>",
"allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25 }
]
}
```
Генерирует STUN Binding Success Response (52 байта): type `0x0101`, magic cookie
`0x2112A442`, случайный transaction ID, XOR-MAPPED-ADDRESS + SOFTWARE. **`id`
валидируется, но в STUN-пакет не попадает** (поля под домен нет).
### 2.4 SIP (домен виден на проводе)
```jsonc
{
"type": "wireguard", "tag": "awg-sip", "mtu": 1280,
"address": ["10.0.0.2/32"], "private_key": "<client-private-key-base64>",
"jc": 4, "jmin": 40, "jmax": 70,
"id": "pbx.example.com",
"ip": "sip",
"peers": [
{ "address": "192.0.2.1", "port": 51820,
"public_key": "<server-public-key-base64>",
"allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25 }
]
}
```
Генерирует начало SIP-звонка из **двух пакетов** одного диалога: **i1 — полный INVITE**
(`INVITE sip:<user>@pbx.example.com SIP/2.0`, Via(branch)/To/From(tag)/Call-ID/CSeq:N INVITE/
Max-Forwards:70/Contact, `Content-Length: 0`, без SDP) и **i2 — `SIP/2.0 100 Trying`** (тот же
диалог: общий branch/tag/Call-ID/CSeq). Каждый пакет валиден сам по себе (UDP не реассемблируется).
Имена пользователей — произносимые псевдо-строки (не захардкожены, не RFC-маяк alice/bob).
**`id` — host в URI, виден цензору.** `id` для sip **опционален**: без него генерируется
правдоподобный псевдо-host.
> **Нужен `junk`.** Профиль рассчитан на `jc/jmin/jmax > 0` (в примере выше заданы) — SIP-декои
> уходят вместе с junk-пакетами в одном пред-handshake-залпе.
>
> **Статус на WARP-DPI: ожидает проверки.** Почему прежде `dns`/`stun`/`sip` упирались в Timeout —
> точно не установлено. По подсказке `sip` переведён на пару INVITE + 100 Trying (стандартный
> call-setup) с junk; заработает ли против WARP — проверяется на устройстве. `ip=quic` остаётся
> подтверждённо рабочим.
---
## 3. Что выбрать
- **Коннект к WARP под реальным DPI** → `ip=quic`, `id=<популярный домен>`,
`ib=chrome`. Фрагментированный QUIC Initial с `id` как SNI; device-proven против
реального LTE-DPI.
- **Нужно, чтобы DPI увидел «разрешённый» домен** → `ip=quic`/`ip=dns`/`ip=sip` с
региональным популярным `id` (SNI / QNAME / SIP-host).
- **STUN** — нишево (выглядит как ответ STUN-сервера); домен не несёт.
---
## 4. Проверка
```sh
# сборка со всеми нужными тегами
go build -tags "with_wireguard with_gvisor with_awg" -o ./sing-box ./cmd/sing-box
# проверка конфига
./sing-box check -c config.json # пусто = ок
```
Соответствие `awg.conf` (WireSock/awg-quick): поля `id`/`ip`/`ib` — это аналог
`I1` из `[Interface]`, только в декларативной форме. Нельзя задавать `i1` и
`id`/`ip`/`ib` одновременно.
---
## 5. Частые ошибки (дословные сообщения)
| Конфиг | Ошибка |
|--------|--------|
| `i1` **и** `id`/`ip`/`ib` вместе | `amneziawg: id/ip/ib masquerade conflicts with an explicit i1; use one or the other` |
| `id` есть, `ip` нет | `amneziawg: ip (masquerade protocol) is required when id/ib is set; one of quic\|dns\|stun\|sip` |
| `ip` не из набора | `amneziawg: unknown masquerade protocol "ftp"; one of quic\|dns\|stun\|sip` |
| `ip=quic` без `id` | `amneziawg: id (masquerade domain) is required for ip=quic (it becomes the ClientHello SNI)` |
| `ip=dns` без `id` | **не ошибка** — генерится псевдо-домен (QNAME) |
| `ip=sip` без `id` | **не ошибка** — `id` опционален, генерируется псевдо-host |
| `ip=stun` без `id` | **не ошибка** — `id` опционален, decoy без домена |
| домен с `\r\n`/`;`/`@`/пробелом | `amneziawg: invalid masquerade domain "...": illegal character (only a-z A-Z 0-9 - _ allowed)` |
| `ib` не из набора | `amneziawg: unknown masquerade browser "safari"; one of chrome\|firefox\|curl` |
| `ib` с `ip≠quic` | `amneziawg: ib (browser) is only meaningful with ip=quic, got ip="dns"` |
| без `with_awg` | `AmneziaWG (awg) support is not included in this build, rebuild with -tags with_awg` |
Домен проходит **строгую LDH-валидацию** (как SNI у WireSock): метки из
`a-z A-Z 0-9 - _`, ≤63 байта, без дефиса по краям; всё имя ≤253, без точки в начале;
одна точка в конце допускается. Это security-граница — домен идёт в текст SIP и в
DNS QNAME, поэтому control-байты и метасимволы отвергаются (защита от инъекции).
---
## 6. Ограничения
- Это **decoy перед handshake**, не полноценная протокол-сессия. Один пакет нужной
формы, дальше — обычный AWG-трафик.
- **QUIC раскрывает SNI** (фрагментированный Initial): `id` идёт на провод как
SNI в ClientHello. `stun` домен не несёт (см. §1). DPI-обход — за счёт out-of-order
фрагментации CRYPTO-фреймов (line-rate DPI парсит первый фрейм как offset 0, видит
мусор и пропускает), не за счёт сокрытия SNI.
- **`ib` валидируется, но на байты пакета не влияет.** JA3/JA4-fingerprint не
имитируется: DPI-обход держится на out-of-order фрагментации CRYPTO-фреймов, а не на
TLS-fingerprint. `ib` принимается для совместимости синтаксиса с WireSock-конфигами.
- Байт-в-байт replay именно WireSock-трафика **не** делается: энтропия наша
(криптослучайная `<r>`, для QUIC — свежие DCID/random/x25519 на вызов), а не
payload-seeded PRNG (это standalone I1, а не серверный S1–S4 padding).
- Полевая проверка: `ip=quic` (фрагментированный Initial) **device-proven против
реального LTE-DPI**. Для `dns`/`stun`/`sip` систематическая полевая A/B-проверка
против конкретного DPI не проводилась — подтверждены приём движком, структурная
валидность и `sing-box check`.
См. также: [SPEC.md](SPEC.md),
[IMPLEMENTATION_REPORT.md](IMPLEMENTATION_REPORT.md),
краткая версия — `docs-lx/lx-config.md` (секция «Masquerade id/ip/ib»).
@@ -0,0 +1,180 @@
# IMPLEMENTATION_REPORT — 009 WIRESOCK_MASQUERADE_PROFILES
**Статус:** Closed. Код + тесты + DoD; device-smoke на активном DPI пройден (туннель + трафик).
**Коммиты:** `51d5cff1` (id/ip/ib + dns/stun/sip + QUIC), `64ce4a47` (QUIC → out-of-order
фрагментированный Initial).
---
## Принятые решения
### Р1. Механизм — I1 CPS, не S1–S4
`Id/Ip/Ib` разворачиваются в `i1` CPS-строку в option/transport-слое; сабмодуль
`submodules/wireguard-go` не трогается. S1–S4 padding отвергнут: init/response должны
оставаться бит-в-бит как plain WG, иначе Cloudflare WARP отвергает handshake.
Код: `masqueI1` — `transport/wireguard/masque_awg.go:64`; проводка в `awgIpcLines`
(`device_awg.go`); decoy шлётся `Obfuscate(buf, nil)` (`submodules/wireguard-go/device/send.go:135`).
### Р2. QUIC — out-of-order фрагментированный Initial
`ip=quic` эмитит полный QUIC Initial (RFC 9001) с реалистичным ClientHello (SNI=`Id`),
нарезанным на 6 CRYPTO-фреймов в перемешанном порядке: первый wire-фрейм `offset≠0`,
`offset=0` почти в конце, PING/PADDING между ними (инварианты I1–I4). Line-rate DPI берёт
первый фрейм как `offset 0`, парсит середину ClientHello как начало → мусор → fail-open;
настоящий сервер реассемблирует по offset. Причина выбора: 1-RTT short header был
эмпирически заблокирован реальным LTE-DPI; фрагментированный Initial device-proven против
того DPI.
Код: диспетч `masque_awg.go:97`; `masqueQUICInitialCPS` — `quic_initial_awg.go:384`;
`buildInitialPacket` — `quic_initial_awg.go:319`; frame-план `etalonWirePlan` —
`quic_initial_awg.go:139`; cutpoints `etalonCutpoints` — `quic_initial_awg.go:128`.
### Р3. Крипта — копия RFC 9001-примитивов из qtls, не своя реализация
HKDF-Expand-Label / QUIC v1 salt / AES-128-GCM-XOR-nonce AEAD скопированы байт-в-байт из
`common/sniff/internal/qtls/qtls.go` (этот `internal/` не импортируется из `transport/`,
поэтому копия, а не импорт) → выводимые ключи совпадают с боевым снифером. Единственный
недостающий примитив — varint-энкодер `appendQUICVarint` (RFC 9000 §16), написан вручную.
Код: `transport/wireguard/quic_crypto_awg.go` (`quicHKDFExpandLabel`, `quicAEADAESGCMTLS13`,
`quicSaltV1`); `deriveInitialKeys` — `quic_initial_awg.go:269`; `encryptInitial` (шифрование +
header protection) — `quic_initial_awg.go:287`.
### Р4. `Id` обязателен только для quic
`Id` идёт на провод как SNI (quic) → обязателен только для `quic`; пустой при quic
отвергается на `sing-box check`. Опционален для `sip` (пуст → псевдо-host) и `stun`
(hostname-less). Заданный `Id` всегда LDH-валидируется.
dns/sip при пустом `Id` генерируют псевдо-имя (PseudoGen), stun его игнорирует. Код: guard `masque_awg.go` (quic ветка); LDH-валидатор `validateMasqueDomain`
(security-граница, зеркало `is_valid_sni_hostname`).
### Р5. flex-PADDING — payload пинится к length-полю при любой длине SNI
Один PADDING-run в frame-плане помечен `padFlex` и вычисляется как остаток до payload-таргета
→ payload всегда ровно 1232 (length-поле) для любой длины ClientHello (длинный домен удлиняет
CH и укорачивает flex-run). Без этого валидный домен >77 символов переполнял фиксированную
раскладку и ронял генерацию (находка адверсариального ревью).
Код: `planEntry.padFlex` — `quic_initial_awg.go:115`; `buildInitialPayload` — `quic_initial_awg.go:212`;
ClientHello добивается до ≥294б padding-расширением (`quicCHTargetLen=294`, `quicCHMinLen=291`) —
`quic_clienthello_awg.go:31,35`.
### Р6. ClientHello — реалистичный, без JA3-имитации
TLS 1.3 ClientHello: непустой cipher_suites, key_share (реальный ephemeral x25519,
`ecdh.X25519().GenerateKey` — `quic_initial_awg.go:330`), ALPN "h3", quic_transport_params,
supported_versions, GREASE `0x0a0a` (RFC 8701 — `quic_clienthello_awg.go:117`). Конкретный
браузерный JA3/JA4 не имитируется: bypass держится на фрагментации, не на fingerprint. `Ib`
валидируется (`normalizeMasqueBrowser` — `masque_awg.go:183`), но на байты пакета не влияет.
Код: `buildClientHello` — `quic_clienthello_awg.go:67`.
### Р7. sip — начало звонка: INVITE (i1) + 100 Trying (i2), один диалог
`ip=sip` эмитит **начало SIP-звонка из двух самостоятельных пакетов** одного диалога (RFC 3261
§17 call-setup), `sip_invite_awg.go`: i1 = полный INVITE **request** (`masqueSIPInviteCPS`):
request-line `INVITE sip:<user>@<host> SIP/2.0`, Via(branch=z9hG4bK)/To(без tag)/From(tag)/Call-ID/
CSeq:N INVITE/Max-Forwards:70/Contact/Content-Type: application/sdp/`Content-Length: 0`, пустая
строка — **без SDP-тела**; i2 = полный `SIP/2.0 100 Trying` provisional response
(`masqueSIPTryingCPS`): статус-строка + те же Via/To/From/Call-ID/CSeq + `Content-Length: 0`.
Каждый пакет — валидное самодостаточное SIP-сообщение: UDP не реассемблируется, пакетный DPI
смотрит каждую датаграмму отдельно. **Один диалог**: Via branch / From tag / Call-ID / CSeq
идентичны в i1 и i2 — общий диалог строит `newSIPDialog(domain)` одним проходом, оба слота
наполняет диспетчер `masqueI1I2`. Значения запечены в `<b>` на сборке (НЕ per-packet `<rc>`/`<rd>`),
уникальны между юзерами. Имена пользователей (display+local) и host (при пустом `Id`) —
произносимые псевдо-строки через `PseudoGen` (`pseudo_gen_awg.go`, портирован из LxBox §127),
свежие на генерацию; **не** хардкод RFC-примера `alice@atlanta.com`/`bob@biloxi.com` (публичный
DPI-маяк). Профиль **требует junk** (`jc/jmin/jmax > 0`): декои уходят вместе с junk-пакетами в
одном пред-handshake-залпе. `Id` опционален для sip (пуст → `pgHost()`). Прежняя одиночная форма
(один INVITE с SDP, затем короткая версия «фрагментация INVITE на head→i1 + SDP→i2») — заменена.
(DNS см. Р10.)
### Р8. Рандомизация QUIC-раскладки + robustness-ручки
Раскладка фрейм-плана генерится на каждый вызов: случайные точки разреза
(`planFragmentsN`) и случайный out-of-order порядок (`randomizedWirePlan`), при этом I1–I4
держатся по построению (перестановка фрагментов + ремонт «offset-0 не первый» + flex-PADDING).
Убирает фиксированную межюзерную сигнатуру (раньше был зашит `etalonWirePlan`, удалён).
`quicGenParams` (`defaultQUICGenParams` — 6 фрагментов, 2 PING, 1250б) — ручки эскалации без
правки кода: число фрагментов/PING и диапазон размера датаграммы; length-поле/payload
пересчитываются от размера. Код: `quic_initial_awg.go` (`quicGenParams`, `planFragmentsN`,
`randomizedWirePlan`, `pickTotalLen`). Стресс-тест: 300 случайных пакетов держат I1–I4.
### Р9. STUN — Binding Request вместо Response
`ip=stun` теперь эмитит WebRTC Binding **Request** (`0x0001`): USERNAME, ICE-CONTROLLING,
PRIORITY, SOFTWARE=`libwebrtc`, MESSAGE-INTEGRITY (HMAC-SHA1 по случайному ICE-ключу),
FINGERPRINT (CRC-32). Причина: Success Response (`0x0101`), посланный клиентом первым и без
запроса — аномалия направления; Request — то, что ICE-клиент шлёт первым. Свежий
txn/ufrag/ключ на вызов. Код: `stun_request_awg.go` (`buildSTUNBindingRequest`,
`masqueSTUNRequestCPS`); диспетч `masque_awg.go:103`. Старый `masqueSTUNResponseCPS` удалён.
**Device-результат:** ни Binding Request, ни полный WebRTC-вариант с MESSAGE-INTEGRITY **не
прошли** тестовый LTE/WARP DPI (Timeout, тогда как `quic` ✅ ~340 мс). См. общий вывод после Р10.
### Р10. DNS — query вместо response
`ip=dns` теперь эмитит клиентский DNS **query** (`masqueDNSQueryCPS`, `masque_awg.go`): flags
`0x0100` (QR=0, RD=1), QNAME=`Id`, QTYPE **HTTPS** (`0x0041`), OPT RR с cover-байтами в опции
`0xFDE9`. Причина: прежний response (`0x8180`, QR=1) — аномалия направления (ответ без запроса в
слоте клиента), как у STUN. Правка от прежнего кода — только flags и QTYPE; `encodeDNSName`/OPT/
cover переиспользованы. TXID/cover свежие на пакет (`<r 2>`/`<r 40>`).
**Device-результат:** DNS query — **Timeout** (как STUN). SIP (Р7, двухпакетный INVITE+100 Trying)
**ожидает проверки на устройстве** — причина прошлых таймаутов точно не установлена, сейчас
проверяем гипотезу с multi-packet-формой и junk.
**Общий вывод (Р9+Р10).** Качество пакета и направление (request vs response) вторичны; решает
триплет **(протокол + назначение)**. DPI режет STUN/DNS/SIP к WARP-edge `162.159.x:2408` как
класс протокола — raw STUN/DNS/SIP к дата-центровому IP сами по себе аномальны (DNS живёт на
:53, STUN — на STUN-сервере). `quic` обходит проверку назначения: QUIC/HTTP3 легитимно идёт
куда угодно, поэтому QUIC к Cloudflare-IP — ожидаемый трафик. `quic` — единственный проверенный
рабочий механизм здесь; `dns`/`stun`/`sip` реализованы в правильной клиент-инициированной форме
и сохранены для других провайдеров (DPI без проверки протокол-к-назначению).
### Р11. Многопакетный QUIC (i1+i2) — рассмотрен и ОТКЛОНЁН
Был реализован вариант «два независимых Initial» (i1+i2) и device-проверен как безопасный для
WARP-handshake (туннель без регресса латентности). Затем **отклонён** как концептуально неверный:
каждый DCID — отдельное QUIC-соединение, поэтому два Initial с разными DCID читаются как два
*брошенных* соединения, что для DPI с DCID-tracking более аномально, не менее. Настоящее
«продолжение» невозможно (short-header device-blocked; 1-RTT до ответа сервера — невозможное
состояние). Итог: `ip=quic` = **один** фрагментированный Initial с браузер-точным ClientHello (§4);
`masqueI1I2` для quic возвращает i2="", `masqueQUICSecondInitialCPS` удалён. Безопасность slots-механизма
(send.go: decoy перед независимым `MessageInitiation`) остаётся актуальной для sip i1+i2 (Р7).
### Р12. `Ib` → реальный браузерный JA3 через uTLS
`ib=chrome`/`firefox` теперь строит ClientHello через uTLS (`github.com/metacubex/utls`, тот же,
что у Reality) в QUIC-режиме (`UQUICClient` + `HelloChrome_120`/`HelloFirefox_120`) → настоящий
браузерный JA3/JA4. ALPN форсируется в `h3`, PQ-гибрид key_share (`X25519MLKEM768`) удаляется (не
влез бы в один Initial; паттерн из `reality_client.go`). Решение «`ib` опционален»: `ib=""`/`curl`
→ generic device-proven ~294б CH (дефолт не трогаем); `ib=chrome`/`firefox` → uTLS (~510–620б).
Build-tag split: `quic_clienthello_utls_awg.go` (`with_utls`) / `…_stub_awg.go` (`!with_utls` →
fallback на generic). Фрагментация (`planFragmentsN`) режет CH любой длины — I1–I4 держатся.
Это задел против будущего JA3/JA4-DPI; на текущем DPI `ip=quic` проходит на фрагментации, поэтому
uTLS-вариант сам по себе device не верифицирован, а дефолт `ib=""` остаётся проверенным.
---
## Верификация
- **§5-векторы обратным разбором** (`quic_initial_awg_test.go`): AEAD-тег сходится; ≥6 CRYPTO
+ ≥1 PING + PADDING; первый offset≠0; реассембл в валидный ClientHello, SNI=`Id`; размер
1250 / length 1232; уникальность DCID+random; длинный домен генерируется.
- **Cross-check боевым снифером:** сгенерированный Initial парсится `common/sniff/quic.go`
(SNI извлечён, классификация chromium).
- **Рандомизация QUIC** (`quic_initial_awg_test.go`): `TestQUICInitialRandomizedInvariants`
(80 сэмплов, I1–I4 + offset'ы различаются) и `TestQUICInitialRobustnessKnobs` (4/10/12
фрагментов, переменный размер).
- **STUN Request** (`masque_awg_test.go`): `TestMasqueSTUNRequestStructure` (тип `0x0001`,
атрибуты тайлят сообщение, FINGERPRINT CRC-32 сходится, USERNAME+MESSAGE-INTEGRITY есть),
`TestMasqueSTUNRequestUniqueness`.
- **dns/sip + валидация** (`masque_awg_test.go`, `masque_cps_test.go`): `TestMasqueDNSQueryStructure`
(QR=0, QNAME round-trips, QTYPE HTTPS, OPT до конца); `TestMasqueSIPInviteStructure` +
`TestMasqueSIPInviteNoID` проверяют пару i1 INVITE (`Content-Length: 0`, без SDP) + i2
`100 Trying` и согласованность диалога (`assertSIPInvite`/`assertSIPTrying`/`assertSameSIPDialog`:
request-line INVITE, To без tag, общий branch/tag/Call-ID/CSeq, имена не захардкожены, пустой
id → псевдо-host); инъекция домена отвергается; конфликт с `i1` /
неизвестный ip/ib / пустой id для quic — ошибки.
- **Адверсариальный ревью** (workflow): подтверждённые находки исправлены (flex-PADDING для
длинного SNI — Р5; GREASE `0x4469`→`0x0a0a` — Р6; response→request для STUN — Р9; SIP missing
Contact / static caller@ — закрыто request-формой с Contact + PseudoGen-именами в Р7).
- `go build` (с тегами и без) ок; `go test -tags with_awg ./transport/wireguard/...` зелёный;
`gofmt -l` lx-файлов пусто; `sing-box check` на quic/dns/stun/sip ок (dns/sip/stun без id тоже), пустой id для quic
отвергнут; gating без `with_awg` → «awg support not built».
- **Device-smoke:** узел `ip=quic` с фрагментированным Initial поднимает туннель и проводит
реальный трафик через активный DPI.
---
## Зона касания upstream (для ребейза)
Все новые файлы под `with_awg` (в upstream их нет) → конфликтов на ребейзе не дают.
`option/wireguard_awg.go` — lx-файл целиком. `device_awg.go` — lx-файл под тегом. Сабмодуль
`submodules/wireguard-go` не трогали.
@@ -0,0 +1,376 @@
# SPEC: 009 — WIRESOCK_MASQUERADE_PROFILES
| Поле | Значение |
|------|----------|
| Тип | F (feature) |
| Статус | C (complete) |
Декларативные поля маскировки **`Id` / `Ip` / `Ib`** (домен / протокол / браузер) —
из [WireSock Secure Connect](https://www.wiresock.net/) — которые **на уровне конфига
разворачиваются в AmneziaWG `I1` CPS-строку**. Вместо ручного `i1=<b 0x...>` пользователь
пишет осмысленные поля, а движок собирает пакет-приманку нужного протокола.
Расширение [003 AWG2_CLIENT_ENDPOINT](../003-AWG2_CLIENT_ENDPOINT)
и [005 AWG2_RANGED_MAGIC_HEADERS](../005-AWG2_RANGED_MAGIC_HEADERS).
Тег `with_awg`; новые файлы в зонах lx; сабмодуль не трогается.
---
## 1. Что это
`Id/Ip/Ib` — декларативная обёртка над `I1`. На корне endpoint'а пользователь задаёт:
```jsonc
{ "type": "wireguard", /* ... */ "id": "www.google.com", "ip": "quic", "ib": "chrome" }
```
и получает сгенерированную `i1`-строку, как если бы вписал её руками. Новый рантайм в
device не добавляется — генерация целиком в option/transport-слое.
| Поле | Имя | Значение |
|------|-----|----------|
| `Id` | **Domain** | домен для маскировки (массовый легитимный: `www.google.com`, `ozon.ru`…). Идёт на провод как SNI / QNAME / SIP-host |
| `Ip` | **Protocol** | протокол маскировки: **quic** \| **dns** \| **stun** \| **sip** |
| `Ib` | **Browser** | `chrome` \| `firefox` \| `curl`. Валидируется; на сгенерированный пакет не влияет (нет JA3-имитации — см. §4) |
> Нейминг проприетарный WireSock (`i`nterface **d**omain/**p**rotocol/**b**rowser); `ip` —
> это «protocol», НЕ IP-адрес. Эти ключи понимают только WireSock и это ядро; меняться
> не могут (контракт на входе). Результат же — стандартный AmneziaWG `i1` CPS-тег.
---
## 2. Механизм — I1 CPS
Генерируется CPS-строка в option/transport-слое; device-стек не меняется, сабмодуль
`submodules/wireguard-go` не трогается. Путь: option → `masqueI1` → `awgIpcLines` →
vendored `obf.go` (`newObfChain`). `I1`-пакет шлётся приманкой перед handshake с
`Obfuscate(buf, nil)` (src=nil, реальных данных нет — `send.go:135`).
Семантика CPS-движка (`submodules/wireguard-go/device/obf.go`): `<b 0xHEX>` статичные
байты · `<r N>` N криптослучайных байт · `<rc N>` ASCII-буквы · `<rd N>` цифры. Decoy
самодостаточен — это `<b>`-скелет плюс, где нужно, `<r>/<rc>/<rd>`-энтропия.
S1–S4 padding не используется: он невозможен против Cloudflare WARP (init/response
должны оставаться бит-в-бит как plain WG, иначе сервер отвергает handshake), ради
упрощения коннекта к которому фича и существует.
---
## 3. Профили
Все профили — собственные клиент-инициированные генераторы: `quic` — фрагментированный QUIC
Initial (§3.1), `stun` — WebRTC Binding **Request** (§3.2), `dns` — клиентский DNS query (§3.3),
`sip` — начало звонка: INVITE (i1) + 100 Trying (i2), два самостоятельных пакета одного диалога
(§3.2). Структура — стандартный SIP call-setup (RFC 3261 §17). LDH-валидатор домена совпадает с
WireSock-референсом ([`amneziawg-install`](https://github.com/wiresock/amneziawg-install), MIT),
`quic_handshake.rs::is_valid_sni_hostname`.
> **Device-результат (тест-телефон, LTE с активным DPI):** на момент прошлых прогонов проходил
> **только `quic`** (~340 мс); `stun` (Binding Request и полный WebRTC-вариант с
> MESSAGE-INTEGRITY) и `dns` (query QR=0, QTYPE HTTPS) — **Timeout**. `sip` тогда был одиночным
> пакетом и не проверялся отдельно.
>
> **Гипотеза, которую мы сейчас проверяем.** Почему `dns`/`stun`/`sip` упирались в Timeout,
> точно **не установлено**. Рабочая гипотеза была «DPI режет STUN/DNS/SIP к WARP-edge
> `162.159.x:2408` как класс протокола» (raw STUN/DNS/SIP к дата-центровому IP аномальны по
> назначению), но это не доказано. По подсказке `sip` переведён на **multi-packet i1+i2**:
> i1 = полный INVITE, i2 = полный `100 Trying` того же диалога (стандартный call-setup), оба —
> самостоятельные валидные SIP-пакеты, и профиль рассчитан на работу с `junk`. Так поток читается
> как начало реального звонка, а не одиночный опенер. Заработает ли это против WARP —
> **ожидает device-проверки**; `quic` остаётся подтверждённо рабочим механизмом, `dns`/`stun`
> реализованы в правильной клиент-инициированной форме и сохранены для проверки/других провайдеров.
### 3.1 QUIC — out-of-order фрагментированный Initial
`ip=quic` эмитит полный **QUIC Initial (RFC 9001)** с реалистичным браузерным
ClientHello, где `Id` идёт как **SNI**. ClientHello нарезан на CRYPTO-фреймы, выложенные в
payload в **перемешанном (out-of-order) порядке**: первый CRYPTO-фрейм на проводе имеет
`offset≠0`, фрейм с `offset=0` — не первый, между CRYPTO-фреймами вставлены PING и PADDING.
**Раскладка рандомизируется на каждый вызов** (случайные точки разреза + случайный
out-of-order порядок), при сохранении инвариантов I1–I4 — так нет фиксированной
межюзерной сигнатуры. Параметры robustness (`quicGenParams`: число фрагментов, число PING,
диапазон размера датаграммы) — «ручки» для эскалации обфускации без правки кода, если DPI
поумнеет (напр. начнёт держать reassembly-буфер → больше фрагментов). По умолчанию —
device-проверенная база: 6 фрагментов, 2 PING, 1250б.
**Почему так.** Настоящий QUIC-сервер реассемблирует CRYPTO-фреймы по offset до TLS-парсинга;
line-rate DPI reassembly-буфер не держит — берёт первый CRYPTO-фрейм, считает, что он с
offset 0, и парсит TLS оттуда. При первом фрейме `offset≠0` DPI парсит середину ClientHello
как начало → длины TLS-записи не сходятся → парс прерывается → DPI пропускает (fail-open:
настоящий Chrome тоже легитимно фрагментирует большие ClientHello). Сервер фреймы
переупорядочит, DPI — нет.
`i1` — decoy (src=nil, шлётся перед WG-handshake); реальный TLS-handshake он не завершает,
его задача — чтобы первый пакет потока выглядел как легитимный старт QUIC-сессии к CDN.
Инварианты (проверяются обратным разбором в тестах):
- **I1.** первый CRYPTO-фрейм в wire-порядке имеет `offset≠0`;
- **I2.** фрейм с `offset=0` выложен не первым;
- **I3.** между CRYPTO-фреймами есть PADDING-runs и ≥1 PING;
- **I4.** объединение CRYPTO-фреймов по offset = непрерывный валидный ClientHello `[0..N)`,
без дыр/перекрытий, SNI на месте.
Крипта — RFC 9001 §5 (HKDF-Extract по DCID → `client in` → `quic key/iv/hp`,
AES-128-GCM, header protection). Свежие DCID + TLS random + ephemeral x25519 на каждый
вызов → разный ciphertext (нет общей сигнатуры между юзерами). Пакет ≈1250б, length-поле
1232 (padded ≥1200, RFC 9000 §14.1).
### 3.2 DNS / STUN / SIP
- **dns** — клиентский DNS **query**. Flags `0x0100` (QR=0, RD=1; byte2 ноль), QDCOUNT=1,
ARCOUNT=1; QNAME из `Id`, QTYPE **HTTPS** (`0x0041`, RR-type 65 — самый частый запрос
современного браузера), QCLASS IN; OPT RR (TYPE `0x0029`, CLASS=1232, TTL=0, DO=0) с одной
неизвестной EDNS-опцией код `0xFDE9` (IANA local-use), OPTION-LENGTH покрывает cover-байты →
весь датаграм парсится как один DNS query. TXID `<r 2>` и cover `<r 40>` свежие на пакет.
Query, а не response: клиент первым шлёт запрос. Генератор — `masqueDNSQueryCPS`.
- **stun** — WebRTC Binding **Request**. type `0x0001`, magic cookie `0x2112A442`, свежий
txn; атрибуты USERNAME (`0x0006`), ICE-CONTROLLING (`0x802a`), PRIORITY (`0x0024`),
SOFTWARE (`0x8022` = `libwebrtc`), MESSAGE-INTEGRITY (`0x0008`, HMAC-SHA1), FINGERPRINT
(`0x8028`, CRC-32). Request, а не response: клиент первым шлёт именно запрос.
MESSAGE-INTEGRITY структурно валиден, но по произвольному ICE-ключу (реального пароля у
decoy нет — on-path DPI HMAC всё равно не проверит). Свежая энтропия на вызов; hostname не
несёт. Генератор — `stun_request_awg.go`.
- **sip** — начало SIP-звонка (call setup, RFC 3261 §17) — **два самостоятельных пакета** одного диалога:
**i1 = полный INVITE** (request-line + Via(branch=z9hG4bK)/To(без tag)/From(tag)/Call-ID/CSeq:N
INVITE/Max-Forwards:70/Contact/Content-Type/`Content-Length: 0`, **без SDP-тела**), **i2 = полный
`SIP/2.0 100 Trying`** (статус-строка + те же Via/To/From/Call-ID/CSeq + `Content-Length: 0`).
**Почему два целых пакета, а не фрагментация.** i1/i2 уходят как **независимые UDP-датаграммы**
(amneziawg-go `send.go`, `src=nil`), а у UDP нет потоковой реассемблеризации — пакетный DPI
смотрит каждую датаграмму отдельно. INVITE и 100 Trying валидны **каждый сам по себе**; вместе —
каноническое начало вызова (UAC шлёт INVITE → сервер сразу отвечает 100 Trying). Прежняя
фрагментация одного INVITE (head→i1, SDP→i2) оставляла каждую датаграмму битой и заменена.
**Один диалог.** Via branch / From tag / Call-ID / CSeq **идентичны** в i1 и i2 — поэтому строятся
**одним проходом** (`newSIPDialog` → `masqueSIPInviteCPS` + `masqueSIPTryingCPS`) и запекаются в
`<b>` обеих половин (не per-packet `<rc>`/`<rd>`, иначе токены разошлись бы между слотами).
Имена пользователей (display + local) и (если `Id` пуст) host — произносимые `PseudoGen`-строки,
свежие на генерацию; это **не** хардкод RFC-примера `alice@atlanta.com`/`bob@biloxi.com` (он —
публичный DPI-маяк). `Id` опционален: задан → host, пуст → `pgHost()`. Явный `i2` рядом с
`id/ip/ib` отвергается как конфликт (зеркало гарда `i1`). Генератор — `sip_invite_awg.go`,
диспетчер обоих слотов — `masqueI1I2`.
**Требует junk** (`jc/jmin/jmax > 0`): профиль рассчитан на отправку вместе с junk-пакетами в
том же пред-handshake-залпе.
---
## 4. Браузер (`Ib`)
`Ib` валидируется (`chrome|firefox|curl`, только при `ip=quic`) и **управляет JA3/JA4
ClientHello** (build с `with_utls`):
- **`ib=""` / `ib=curl`** → собственный generic ClientHello (~294б, device-proven; §3.1). uTLS не
имеет curl-QUIC-fingerprint, поэтому curl деградирует на generic.
- **`ib=chrome` / `ib=firefox`** → ClientHello строится через **uTLS** (`github.com/metacubex/utls`,
тот же, что у Reality): `UQUICClient` с fingerprint `HelloChrome_120` / `HelloFirefox_120` →
настоящий браузерный JA3/JA4 (cipher_suites, supported_groups, порядок extensions, GREASE у
Chrome). ALPN форсируется в `h3` (это QUIC, не TCP-TLS). PQ-гибрид key_share
(`X25519MLKEM768`, ~1.2КБ) удаляется — он не влез бы в один Initial; следствие: JA3 как у
конца-2023 браузера, не у текущего PQ-включённого. CH крупнее (~510–620б), фрагментация
адаптируется (planFragmentsN режет любую длину, I1–I4 держатся).
- Без тега `with_utls` `ib=chrome/firefox` грациозно деградируют на generic CH (stub-файл).
**Назначение `Ib` — задел против будущего JA3/JA4-классифицирующего DPI.** На текущем целевом DPI
`ip=quic` проходит на фрагментации (fingerprint не проверяется), поэтому дефолт `ib=""` сохраняет
device-proven generic-путь; uTLS-вариант крупнее и сам по себе на устройстве не верифицирован.
Код: `quic_clienthello_utls_awg.go` (+ stub `…_utls_stub_awg.go`).
---
## 5. Валидация (fail-fast)
- **Взаимоисключение с `I1`** — задан и `i1`, и `id/ip/ib` → ошибка.
- **`Ip ∈ {quic,dns,stun,sip}`** (lower); пусто при заданном `Id`/`Ib` → ошибка.
- **`Id` обязателен только для `quic`** (SNI); **опционален для `dns`** (QNAME или псевдо-домен) и для
`sip`** (задан → SIP host, пуст → генерируется псевдо-host) и **`stun`** (hostname-less).
- **Строгий LDH-чек** применяется **всегда, когда `Id` задан** (метки alnum+hyphen+`_`,
без edge-hyphen, ≤63, всего ≤253, трейлинг-дот ок). Это security-граница: домен идёт в
SIP-текст / DNS QNAME / TLS SNI — control-байты (`\r\n\0\t`) и SIP/URI-метасимволы
(`> ; @ "`) дали бы инъекцию. Совпадает с `is_valid_sni_hostname`.
- **`Ib` ∈ {chrome,firefox,curl}** и только при `ip=quic`; иначе ошибка.
---
## 6. Файлы (зоны)
| Файл | Зона | Что |
|------|------|-----|
| `option/wireguard_awg.go` | lx | поля `Id/Ip/Ib` |
| `transport/wireguard/masque_awg.go` | lx, `with_awg` | диспетчер `masqueI1` + валидация + DNS query + `cpsBuilder` |
| `transport/wireguard/quic_initial_awg.go` | lx, `with_awg` | QUIC Initial: varint, рандомизированный frame-план (I1–I4) + `quicGenParams`, сборка RFC 9001 |
| `transport/wireguard/quic_clienthello_awg.go` | lx, `with_awg` | generic TLS 1.3 ClientHello (SNI=`Id`) + диспетч по `Ib` |
| `transport/wireguard/quic_clienthello_utls_awg.go` | lx, `with_awg && with_utls` | uTLS браузерный ClientHello (chrome/firefox JA3, §4) |
| `transport/wireguard/quic_clienthello_utls_stub_awg.go` | lx, `with_awg && !with_utls` | fallback на generic, когда uTLS не собран |
| `transport/wireguard/quic_crypto_awg.go` | lx, `with_awg` | HKDF / AES-128-GCM / header protection |
| `transport/wireguard/stun_request_awg.go` | lx, `with_awg` | STUN WebRTC Binding Request (FINGERPRINT + MESSAGE-INTEGRITY) |
| `transport/wireguard/sip_invite_awg.go` | lx, `with_awg` | начало SIP-звонка: INVITE (i1) + `100 Trying` (i2), один диалог, без SDP |
| `transport/wireguard/pseudo_gen_awg.go` | lx, `with_awg` | произносимые псевдо-имена/host/IP (для SIP) |
| `transport/wireguard/device_awg.go` | lx, `with_awg` | вызов `masqueI1` в `awgIpcLines` |
| `transport/wireguard/masque_awg_test.go`, `quic_initial_awg_test.go` | lx, `with_awg` | тесты |
Сабмодуль `submodules/wireguard-go` не трогается.
---
## 7. Критерии приёмки
- **Структурная валидность каждого профиля** (обратным разбором, не тавтология): QUIC —
собственный вывод AEAD-расшифровывается (тег сходится), frame-walk даёт ≥6 CRYPTO + ≥1
PING + PADDING, первый CRYPTO `offset≠0` (I1), CRYPTO реассемблируются в валидный
ClientHello с SNI=`Id` (I4); DNS — валидный EDNS-OPT **query** (QR=0, QNAME=`Id`, QTYPE
HTTPS, опция `0xFDE9`, без хвостов); STUN — Binding **Request** (cookie, атрибуты тайлят сообщение, FINGERPRINT
CRC-32 сходится, USERNAME + MESSAGE-INTEGRITY присутствуют); SIP — **два самостоятельных
пакета** одного диалога: i1 — валидный INVITE **request** (request-line `INVITE ... SIP/2.0`,
Via/Max-Forwards/From/To-без-tag/Call-ID/CSeq/Contact, `Content-Length: 0`, без SDP-тела),
i2 — валидный `SIP/2.0 100 Trying` (статус-строка + те же Via/To/From/Call-ID/CSeq,
`Content-Length: 0`); branch/tag/Call-ID/CSeq **идентичны** в i1 и i2 (один диалог), имена
не захардкожены.
- **Рандомизация QUIC:** раскладка фрейм-плана и точки разреза свежие на каждый вызов;
инварианты I1–I4 держатся на каждом сэмпле (стресс-тест), две генерации → разные offset'ы
фрагментов (нет фикс-сигнатуры). Robustness-ручки (`quicGenParams`: 4–12 фрагментов,
переменный размер) тоже держат I1–I4.
- **Уникальность:** два вызова QUIC с одним SNI → разные DCID/TLS random → разный
ciphertext; два вызова STUN → разный txn/ufrag/ключ → разный blob.
- **`Ib` JA3 (build с `with_utls`):** `ib=chrome`/`firefox` → uTLS-ClientHello, расшифровывается,
SNI=`Id`, первый CRYPTO offset≠0 (I1), пакет 1250б; chrome содержит GREASE cipher, firefox нет;
длины chrome≠firefox≠generic. `ib=""`/`curl` → generic ~294б. Без `with_utls` chrome/firefox
деградируют на generic (stub компилируется и тестируется).
- **Длинный домен:** валидный LDH-домен любой длины (≤253) генерируется без ошибки (payload
пинится к length-полю flex-PADDING-run; CH растёт с длиной SNI, инварианты сохраняются).
- **CPS принят реальным движком:** прогон через `newObfChain` из `submodules/wireguard-go`.
- **Валидация:** конфликт с `I1`, неизвестный `Ip`, пустой `Id` для quic,
control-байт/метасимвол в домене, `Ib` вне набора / не при quic — ошибки; нет паники.
- **Gating:** `Id/Ip/Ib` без `with_awg` → «awg support not built».
- **Регресс:** плоский WG и явный `I1` без masquerade — байт-в-байт.
- `go build` (без тегов и `-tags with_awg`) ок; `go test -tags with_awg ./transport/wireguard/...`
зелёный; `gofmt -l` lx-файлов пусто.
- **Device-smoke:** узел `ip=quic` с фрагментированным Initial поднимает туннель и проводит
реальный трафик через активный DPI — проверено вживую.
---
## 8. Active probing — граница односторонней маскировки (гипотезы)
Этот раздел — **гипотезы**, не device-факты. Device-факты у нас ровно те, что в §3: `quic`
проходит (~340 мс), `dns`/`stun`/`sip` — Timeout даже после исправления направления на
клиент-инициированное. Здесь — *почему* (механизм под эмпирическим выводом §3), и почему это
механистически ограничивает односторонний (client-only) decoy. Внутреннюю логику DPI мы не
наблюдаем, поэтому всё ниже — модель, а не измерение.
> **H3 (тезис, высокая уверенность). Односторонний decoy силён ровно настолько, насколько то,
> что ЦЕЛЕВОЙ СЕРВЕР реально отдаёт на этом порту.** Клиент эмитит только клиентскую сторону
> протокола; ответить на проверку он не может. Это структурно неизбежно (см. §2 и `send.go:135`:
> decoy шлётся как `Obfuscate(buf, nil)`, src=nil — самодостаточный датаграм без ciphertext-хвоста;
> единственный серверный ответ в device — `SendHandshakeResponse`, и тот лишь на валидный
> WG-handshake, не на произвольную протокол-проверку). Это «(протокол + назначение)» из §3,
> доведённое до механизма. H3 робастна при любой из трёх причинных моделей ниже.
Конкретная причина, по которой `dns`/`stun`/`sip` к WARP-edge `162.159.x:2408` падают, нами **не
наблюдаема** и совместима как минимум с тремя моделями DPI:
1. **Пассивная репутация назначения (модель, которую сейчас прямо поддерживает §3).** raw
DNS/STUN/SIP к дата-центровому IP сам по себе аномален (DNS живёт на `:53`-резолвере, STUN — на
STUN-сервере) и режется как класс протокол-к-назначению, **без всякой проверки и без ответа**.
При этой модели `quic` проходит не потому, что кто-то ответил, а потому что QUIC/HTTP3-к-CDN —
ожидаемая по форме трафика картина (responder не нужен вообще).
2. **Пассивный allowlist протоколов на класс назначения** — частный случай (1): на дата-центровый
IP разрешён ожидаемый набор, QUIC в нём есть, raw DNS/STUN/SIP — нет.
3. **Active probing (сильнейшая, но наименее подтверждённая форма).** Увидев decoy, DPI сам шлёт
проверочный пакет на тот же 5-tuple (свой QUIC Initial / version-negotiation-триггер, DNS-query,
STUN Binding Request, SIP OPTIONS) и ждёт протокол-корректного ответа. Тогда:
> **H1 (гипотеза активной проверки, средняя уверенность).** Если DPI активно проверяет
> `quic`-decoy и Cloudflare-edge **действительно отвечает QUIC на этом 5-tuple**, проба
> удовлетворяется и поток классифицируется как легитимный QUIC. Decoy «одалживает» чужой
> настоящий responder, который сам не держит.
> **H2 (гипотеза активной проверки, средняя уверенность).** На том же `162.159.x:2408` нет
> DNS-резолвера, STUN-сервера и SIP-UA. Активная проба по этим протоколам не получит
> протокол-корректного ответа никогда — как бы хорошо ни был сделан клиентский decoy.
> **Слабое звено H1 — непроверенное условие.** `:2408` — это **WireGuard**-порт WARP. Отвечает ли
> Cloudflare-edge QUIC/HTTP3 **именно на этом UDP-порту** (а не на штатном `:443`) — пакетным
> захватом не подтверждено. «Edge говорит QUIC по всему флоту» не равно «этот порт отвечает на QUIC
> Initial». Поэтому «Cloudflare отвечает QUIC на `:2408`» — условие, а не факт; его проверяет
> тест T3.
**Общий вывод и его условность.** При активной модели для `dns`/`stun`/`sip` нужен
**контролируемый сервер с probe-responder** (двусторонняя модель WireSock `amneziawg-proxy`, см.
§9), и они применимы только к **self-hosted AmneziaWG**, не к WARP. Но если DPI **пассивный**
(модель §3), responder ничего не чинит: проблема не в отсутствии ответа, а в том, что raw
DNS/STUN/SIP к дата-центровому IP аномальны по назначению — помогает только **протокол-уместное
назначение** (DNS на `:53` и т.д.), не co-located responder на том же WG-порту. То есть вывод
«не-QUIC нужен self-hosted responder» **корректен только при активной модели** и остаётся
гипотезой. Поэтому код заполняет только `i1`/`i2` (QUIC для WARP), а `i3..i5` оставлены свободными
под self-hosted/мульти-decoy — это реальный задел.
Репутация назначения подробнее описана в `transport/wireguard/masque_awg.go` (DNS-генератор) и в
§3; этот раздел обобщает их в probe-модель. Серверная сторона / probe-response — вне скоупа (§10).
Источник device-фактов — LxBox-задача 146; статья habr 1047080 описывает двустороннюю модель
WireSock архитектурно (без байт-спек и без измерений) — подтверждает
**механизм** H3, но не служит device-доказательством.
### 8.1 Фальсифицируемые device-тесты
Те же телефон + LTE/WARP-DPI, что дали §3. Все decoy в `i1`, если не сказано иное; `jc`/junk и
реальный WG-handshake — константа.
- **T1 — назначение vs протокол (проверяет H2/H3).** Направить `ip=dns` (и отдельно `stun`,
`sip`) на **self-hosted AmneziaWG**, хост которого реально держит соответствующий responder на
том же UDP-порту. *Предсказание:* проходят, тогда как к WARP `:2408` — Timeout → подтверждает
H2+H3. Если падают даже с co-located responder → H2/H3 (в активной форме) опровергнуты (блокер
иной — напр. сигнатура raw-протокол-к-любому-дата-центру).
- **T2 — активная проба vs пассивная репутация.** На контролируемом сервере логировать входящий
UDP на WARP-5-tuple после каждого decoy. *При активной модели:* после QUIC-decoy виден непрошеный
входящий QUIC-образный проб (не от WG-сервера). Если проба нет, а `dns`/`stun`/`sip` всё равно
падают → активная проба опровергнута, механизм — пассивная репутация назначения (H3 держится в
слабой форме).
- **T3 — контроль «бесплатного responder» QUIC (проверяет H1).** Направить `ip=quic`-decoy на
IP/порт **без** QUIC-responder (plain UDP-echo или `:2408` не-Cloudflare хоста). *Предсказание:*
`ip=quic` начинает Timeout, как dns/stun/sip — значит успех нёс настоящий Cloudflare-responder, а
не сами байты QUIC. Если `quic` всё равно проходит → H1 опровергнута. T3 — единственное, что
снимает слабое звено H1 (`:2408` vs `:443`).
---
## 9. QUIC — один Initial (multi-packet рассмотрен и отклонён)
`ip=quic` эмитит **ОДИН** фрагментированный Initial (`i1`; `i2..i5` пусты). Один Initial — это
ровно то, что реальный клиент шлёт, открывая одну QUIC-сессию; правдоподобие даёт
браузер-точный ClientHello (`Ib` → uTLS, §4), а не число пакетов.
**Отклонённая альтернатива — два независимых Initial (i1+i2).** Идея «развивающейся сессии» была
реализована и device-проверена как безопасная для WARP-handshake (туннель встаёт без регресса
латентности), но **концептуально неверна**: каждый DCID — отдельное QUIC-соединение, поэтому два
Initial с разными DCID читаются как **два брошенных соединения**, а не одна развивающаяся сессия —
для DPI с отслеживанием по DCID это *более* аномально, не менее. Настоящее «продолжение» (1-RTT
short-header с тем же DCID) невозможно: short-header device-blocked (коммит `64ce4a47`), а 1-RTT до
ответа сервера — невозможное QUIC-состояние. Вывод: один чистый Initial честнее любого
двухпакетного варианта. (`masqueQUICSecondInitialCPS` удалён.)
> **Замечание про send.go (валидно для sip i1+i2, §3.2).** Decoy-слоты `i1..i5` шлются как
> независимые UDP-датаграмы ПЕРЕД подлинным `MessageInitiation`: `CreateMessageInitiation` считается
> до цикла по `ipackets`, каждый decoy — `Obfuscate(buf, nil)` отдельным элементом `sendBuffer`,
> подлинный пакет добавляется последним и байт-идентичен стоковому WG; Cloudflare реагирует только
> на валидный `MessageInitiation`, decoy отбрасывает как не-WG-шум. Поэтому любой decoy (в т.ч.
> sip-i2) не может изменить handshake — это и делало multi-packet безопасным.
---
## 10. Вне скоупа
- `dns`/`stun` фрагментация (отдельная таска при необходимости).
- Серверная сторона / probe-response (client-only) — но см. §8: non-QUIC профили осмысленны
только со своим сервером-ответчиком (это и есть серверная сторона, вне скоупа 009).
- Byte-identical имитация конкретного снимка трафика (рандомизация снижает сигнатуру).
- Многопакетный QUIC (i1+i2) — рассмотрен и отклонён (§9).
- Поведенческая плоскость (timing / вариативность размеров между подключениями) — отдельное
направление, если passive-shape станет недостаточно.
---
## 9. Ссылки
- RFC 9000 §16 (varint), §14.1 (Initial ≥1200), §17.2.2 (Initial), §19.6 (CRYPTO), §19.7 (PING), §19.1 (PADDING)
- RFC 9001 §5 (Initial secrets), §5.4 (header protection) · RFC 6891 (EDNS OPT) · RFC 5389 (STUN) · RFC 3261 (SIP)
- WireSock open-source (dns/stun/sip структура): <https://github.com/wiresock/amneziawg-install> (`amneziawg-proxy/src/transform.rs`, `quic_handshake.rs`)
- CPS-движок: `submodules/wireguard-go/device/obf.go`, `send.go:135`
- Проводка: `transport/wireguard/device_awg.go`, `option/wireguard_awg.go`
- Память: [[wiresock-id-ip-ib-feasibility]], [[qtls-helpers-reuse-for-quic-initial]]
@@ -0,0 +1,52 @@
# TASKS — 009-WIRESOCK_MASQUERADE_PROFILES
## Решения
- [x] Механизм: **I1 CPS только** (S1–S4 невозможен против WARP; сабмодуль не трогаем)
- [x] QUIC: **out-of-order фрагментированный QUIC Initial** (RFC 9001) с SNI=`id`
- [x] Структуры dns/stun/sip: порт из WireSock `transform.rs`
- [x] `Ib`: chrome/firefox → uTLS браузерный ClientHello (реальный JA3); ""/curl → generic; build-tag split utls/stub
## Код
- [x] `option/wireguard_awg.go`: `Id/Ip/Ib string` (json `id`/`ip`/`ib`)
- [x] `transport/wireguard/masque_awg.go`: `masqueI1` диспетчер + валидация + `cpsBuilder`
- [x] `validateMasqueDomain` (LDH, зеркало `is_valid_sni_hostname`) — security-граница
- [x] `normalizeMasqueBrowser` (chrome|firefox|curl; только quic)
- [x] DNS → EDNS OPT query (`masqueDNSQueryCPS`, QR=0, QTYPE HTTPS, QNAME из `Id`)
- [x] STUN → WebRTC Binding Request (`stun_request_awg.go`, FINGERPRINT + MESSAGE-INTEGRITY)
- [x] SIP → INVITE (i1) + `100 Trying` (i2), один диалог, без SDP, требует junk (`sip_invite_awg.go`: `newSIPDialog`/`masqueSIPInviteCPS`/`masqueSIPTryingCPS`, диспетч `masqueI1I2`, PseudoGen-имена, `Id`/псевдо-host)
- [x] `transport/wireguard/quic_initial_awg.go`: varint, рандомизированный frame-план (I1–I4) + `quicGenParams`, сборка Initial
- [x] `transport/wireguard/quic_clienthello_awg.go`: реалистичный TLS 1.3 ClientHello (SNI=`Id`)
- [x] `transport/wireguard/quic_crypto_awg.go`: HKDF / AES-128-GCM / header protection
- [x] `device_awg.go`: вызов `masqueI1` в `awgIpcLines`, подстановка как `i1`
## Тест
- [x] `masque_awg_test.go`: структурная валидность dns/stun/sip обратным парсингом + валидация
- [x] `quic_initial_awg_test.go`: обратный разбор QUIC (decrypt, frame-walk, reassembly, SNI)
- [x] QUIC §5-векторы: AEAD-тег сходится (§5.1); ≥6 CRYPTO/≥1 PING, первый offset≠0 (I1/I2/I3);
реассембл в валидный ClientHello, SNI=`id` (I4); размер 1250/length 1232; уникальность DCID+random
- [x] QUIC рандомизация: раскладка свежая на вызов, I1–I4 на каждом сэмпле, offset'ы различаются; robustness-ручки
- [x] длинный валидный домен (>77 симв.) генерируется без ошибки (flex-PADDING)
- [x] DNS: парсится как EDNS OPT query (QR=0, QTYPE HTTPS), QNAME=`Id`, RDLENGTH/OPTION-LENGTH до конца
- [x] STUN: парсится как Binding Request (0x0001), FINGERPRINT CRC-32 сходится, USERNAME+MESSAGE-INTEGRITY есть
- [x] SIP: i1 INVITE (request-line, обязательные заголовки + To без tag, `Content-Length: 0`, без SDP) + i2 `100 Trying`, согласованный диалог (общий branch/tag/Call-ID/CSeq), имена не захардкожены; пустой id → псевдо-host
- [x] валидация: конфликт с I1, неизвестный ip/ib, пустой id (только quic), ib без quic — ошибки
- [x] инъекция домена (CRLF/метасимволы) — отвергается
- [x] `masque_cps_test.go`: верный реплей CPS-парсера (зеркало `newObfChain`)
- [x] cross-check: сгенерированный QUIC Initial парсится боевым снифером `common/sniff/quic.go`
(SNI извлечён, классификация chromium)
## Приёмка (DoD)
- [x] `go build ./...` без тегов — ок
- [x] `go build -tags "with_wireguard with_gvisor with_awg" ./cmd/sing-box` — ок
- [x] `go test -tags with_awg ./transport/wireguard/...` — зелёный
- [x] `sing-box check` на конфигах id/ip/ib (quic/dns/stun/sip); конфликт с i1 отвергнут; пустой id для quic отвергнут
- [x] gating: id/ip/ib без `with_awg` → «awg support not built»
- [x] `gofmt -l` lx-файлов — пусто
## Закрытие
- [x] адверсариальный ревью генераторов (workflow) — проведён, подтверждённые findings учтены
- [x] `docs-lx/lx-config.md` — секция id/ip/ib (+ссылка на EXAMPLES.md)
- [x] `EXAMPLES.md` — how-to с примерами (прогнаны через `sing-box check`)
- [x] `IMPLEMENTATION_REPORT.md`
- [x] Device-результат (LTE/WARP DPI): **только `quic` проходит** (~340 мс); `dns`/`stun` — Timeout
(DPI режет к WARP-edge `:2408` как класс протокола; `quic` обходит проверку назначения). `sip` — по аналогии.
@@ -0,0 +1,47 @@
# 010 — Верификация фикса (GRO split-brain) на железе
Фикс из SPEC шаг 1 применён. Этот `.aar` = **фикс + probe** (probe оставлен
специально, чтобы прогон показал, что фикс взвёл `rxoffload=false`).
## Что в фиксе
Гейт за `!android` (TX/GSO не тронуты):
- `controlfns_linux.go`: `setsockopt(UDP_GRO)` пропускается на android.
- `features_linux.go`: `rxOffload` не читается на android → всегда `false`.
submodule `wg-gro-android-fix` @ `fb8d8d8` (чистый фикс) →
verify-вариант `wg-gro-android-fix-verify` @ `08947cc` (фикс + probe-геттер).
## Артефакт (verify)
| Файл | SDK | sha256 |
|------|-----|--------|
| `dist/lx-wg-gro-fix-verify/libbox-gro-fix-verify.aar` | 23 | `b454c35b08aa7ada6634a41278fa6caf281a7a5a64488231f757c4df48d3d098` |
| `dist/lx-wg-gro-fix-verify/libbox-legacy-gro-fix-verify.aar` | 21 | `6eacc5b2b6c2166d35531a58404964e3e61404a8d458b631958b55b680cbe2c1` |
Собрано из `lx-wg-gro-fix-verify` @ `9996dc0a`; submodule `08947cc` — подтверждено
по логу checkout CI. probe-маркер вкомпилирован.
## Прогон (тот же, что для probe)
1. Вложить `.aar`, `core_logs_enabled=true`.
2. WARP-endpoint **без `detour`** (та же нода, где download был мёртв).
3. Дать трафик, снять core-log: `GET /logs?source=core&q=gro-probe`.
4. **Прогнать реальный download** (плотный поток) и сравнить с предыдущим прогоном.
## Критерий приёмки
| Сигнал | Вывод |
|---|---|
| `rxoffload4=false rxoffload6=false` (+ `dispatch=single`) | фикс взвёлся — GRO на android выключен |
| **download ожил** (сопоставим с `detour: direct`), upload жив | **фикс подтверждён на железе** |
Если `rxoffload` стал `false`, **но download всё ещё мёртв** → GRO был не единственной
причиной; открываем кандидат №2 (тихий хэндовер, `monitor.go`) — см. `SPEC.md`.
## После подтверждения
- probe удаляется (endpoint.go Errorf-строка + OffloadState-геттер) — он временный.
- на merge в `lx` идёт чистый фикс: submodule `wg-gro-android-fix` (`fb8d8d8`),
main — бамп pin без probe.
- временные ветки (`*-verify`, `gro-probe-010`, `lx-gro-probe-010`) удаляются.
@@ -0,0 +1,206 @@
# 010 — Задание команде ядра: probe v2 (вывод не в stderr, а в core-log)
**Кому:** команда `sing-box-lx` (правка submodule `wireguard-go` + `transport/wireguard/endpoint.go`).
**От кого:** прогон probe v1 на реальном устройстве (LxBox, 2026-06-21).
**Зачем:** probe v1 на тест-телефоне **немой** — не из-за бага в логике, а из-за
канала вывода. Нужна v2 с выводом в канал, который на Android реально виден.
---
## ⚠️ ОБНОВЛЕНИЕ 2026-06-21 (после прогона v2): нужна v2.1 — `Errorf`, не `Verbosef`
Probe **v2** (`endpoint.go:260-264`, `logger.Verbosef("LX-GRO-PROBE …")`) прогнан на
том же CPH2411/Android 15 — и **снова немой**. Причина установлена на железе и
финальна:
**На Android уровень DEBUG не доходит до platform-forwarding вообще.** Факты прогона:
- `endpoint.go:228-229` `Verbosef` → `e.options.Logger.**Debug**(strings.ToLower(...))`.
- Поднял `log.level` конфига до `debug` (`PUT /config`, подтверждено
`{"level":"debug"}`, ядро переподняло endpoint) → в LxBox core-log
(`GET /logs?source=core`, 500 записей) **ноль записей уровня `debug`** — только
`info`/`error`. Т.е. весь DEBUG-класс отсекается в libbox-тракте до наблюдателя,
несмотря на то что `log/observable.go:140-141` выглядит как безусловный
`platformWriter.WriteMessage` (в реальной .aar-сборке debug всё равно не проходит).
- Контроль: `endpoint/wireguard[…]: outbound connection` (это **INFO**) — доходит
пачками; `error`-записи — доходят. Значит канал core-log жив, не проходит именно
**уровень**.
- Дополнительно: формат прогоняется через `strings.ToLower` (`endpoint.go:229`) →
строка пришла бы как `lx-gro-probe …` (нижний регистр) — учесть при поиске, но это
вторично: при DEBUG её всё равно нет.
**Что сделать (v2.1) — минимальная правка `endpoint.go`:** поднять уровень
probe-строки с `Verbosef` (Debug) на **`Errorf`** (Error) — Error на Android
**подтверждённо доходит** до core-log. Т.е. строки 262-263:
```go
// было:
logger.Verbosef("LX-GRO-PROBE: GOOS=%s txOffload4=%v rxOffload4=%v txOffload6=%v rxOffload6=%v dispatch=%s",
goos, tx4, rx4, tx6, rx6, dispatch)
// стало (v2.1):
logger.Errorf("LX-GRO-PROBE: GOOS=%s txOffload4=%v rxOffload4=%v txOffload6=%v rxOffload6=%v dispatch=%s",
goos, tx4, rx4, tx6, rx6, dispatch)
```
`Errorf` → `e.options.Logger.Error` (`endpoint.go:231-232`). NB: там тоже
`strings.ToLower` — итоговая строка будет `lx-gro-probe: goos=…`; снимать по
`q=gro-probe` (lowercase). Это диагностический probe, временный — Error-уровень для
него приемлем (на проде строки нет).
**Что НЕ нужно (проверено впустую на стороне LxBox):**
- Поднимать `log.level` конфига — DEBUG всё равно не проходит на Android.
- Bypass trace/DEBUG-фильтра в `BoxService.writeDebugMessage` (LxBox Kotlin) — строка
до него не доходит, отсекается раньше уровнем.
Канал (core-log) и место лога (после `IpcSet`, каст `*conn.StdNetBind`) из v2 —
**правильные, не трогать**. Меняется ровно одно: `Verbosef` → `Errorf`.
---
## Что произошло на устройстве (факты прогона)
Probe v1 (`gro-probe.patch`, ветка submodule `gro-probe-010` @ `21423f6`) собран,
вложен в LxBox, установлен и прогнан:
| Параметр | Значение |
|---|---|
| Устройство | OnePlus **CPH2411**, **Android 15** (SDK 35), arm64-v8a, ColorOS |
| Сборка | release-APK с probe-`.aar` (`libbox-gro-probe-010.aar`, sha `4006811a…`), vc 2714 |
| Профиль | WARP-endpoint **без `detour`** (`🔥⛈️ WARP (AWG 1.5)`, peer `162.159.192.6:854`, single-peer → `isConnect=true`) |
| Что подтверждено | **`StdNetBind.Open` вызывался** — endpoint поднялся, был handshake и трафик (`down_total` доходил до 5.8 МБ; core-log: `endpoint/wireguard[🔥⛈️ WARP (AWG 1.5)]: outbound connection to …`) |
| Что НЕ получилось | **probe-строка `LX-GRO-PROBE` не появилась нигде** — ни в logcat, ни в `stderr.log`, ни в core/app-логах |
Маркер в бинаре есть (проверено: `strings libbox.so | grep LX-GRO-PROBE` = 2 в
arm64). То есть код probe в сборке, путь исполнялся — **молчит именно вывод**.
---
## Корень немоты: Go-stderr на этом Android уходит в `/dev/null`
Probe v1 пишет через `fmt.Fprintf(os.Stderr, …)`. На стороне LxBox stderr ловится
через `Libbox.redirectStderr(File(filesDir, "stderr.log"))` (best-effort,
`BoxApplication.initializeLibbox`). На **CPH2411/ColorOS/Android 15** этот редирект
по факту **не наполняет файл**:
- `stderr.log` **отсутствует/пуст** — Debug API `GET /files/local?name=stderr.log`
стабильно отдаёт `not_found` (читает `getApplicationDocumentsDirectory()/stderr.log`
= тот же `filesDir`, куда пишет redirectStderr — путь верный, файла просто нет).
- В logcat probe тоже нет (Android по умолчанию направляет stdout/stderr приложения
в `/dev/null`; `setprop log.redirect-stdio true` — **запрещён** non-root adb на
ColorOS, `Failed to set property … See dmesg`).
- `run-as` — **запрещён** (release-APK не debuggable → sandbox недоступен).
- Warn `redirectStderr failed` в logcat **нет** → редирект не падает явно, но и не
работает (dup2 на закрытый fd 2 — типовое поведение Android-приложения; молча
no-op).
Вывод: **канал stderr на реальном целевом устройстве для probe непригоден.**
Инструкция из `PROBE.md` («`adb logcat | grep LX-GRO-PROBE`») исходила из допущения
«stderr → logcat», которое на этом OEM неверно.
---
## Что РАБОТАЕТ как канал: sing-box core-log (PlatformInterface)
Единственный канал из ядра, который на устройстве **подтверждённо доходит** до
наблюдателя — штатный лог sing-box. Он виден через LxBox Debug API
`GET /logs?source=core` (живые записи `endpoint/wireguard[…]: outbound connection`,
`router: …` и т.п. снимались в реальном времени).
В `wireguard-go` этот канал уже подключён рядом с bind'ом
(`transport/wireguard/endpoint.go`):
```go
// endpoint.go ~227
logger := &device.Logger{
Verbosef: func(format string, args ...any) {
e.options.Logger.Debug(fmt.Sprintf(strings.ToLower(format), args...))
},
Errorf: func(format string, args ...any) {
e.options.Logger.Error(fmt.Sprintf(strings.ToLower(format), args...))
},
}
wgDevice := device.NewDevice(e.options.Context, deviceInput, bind, logger, e.options.Workers)
```
`device.Logger.Verbosef/Errorf` → `options.Logger.Debug/Error` → **core-log → виден
в `/logs?source=core`.** Это целевой канал для probe v2.
---
## Задание: probe v2
Цель та же, что v1 (см. `SPEC.md` шаг 0): **снять `txOffload`/`rxOffload`/`dispatch`
для WG-endpoint-сокета на android**. Меняется только транспорт вывода.
**Требование:** probe-строка должна уходить в `device.Logger` (→ core-log), НЕ в
`os.Stderr`.
Сложность: `StdNetBind` создаётся в `endpoint.go` (развилка ~200-215) **раньше**, чем
`device.Logger` (~227), и логгер в bind не передаётся — поэтому v1 и взял `os.Stderr`
(в `Open` логгера нет под рукой). Варианты, на выбор команды ядра (любой допустим):
1. **Логировать на стороне `endpoint.go` после `bind.Open`/после `NewDevice`.**
`StdNetBind` уже хранит `ipv4TxOffload/ipv4RxOffload/ipv6TxOffload/ipv6RxOffload`
как поля (см. v1-патч — они проставляются в `Open`). Добавить геттер на bind
(напр. `OffloadState() (tx4, rx4, tx6, rx6 bool)`) и сразу после поднятия device
вызвать `logger.Verbosef("LX-GRO-PROBE: GOOS=%s tx=%v rx=%v dispatch=%s", …)`.
Чисто, не тащит logger внутрь conn-пакета.
2. **Пробросить лёгкий лог-хук в `StdNetBind`.** Поле-функция
`OnProbe func(string)` на bind, выставляемое из `endpoint.go` до `Open`; внутри
`Open` дёргать его вместо `fmt.Fprintf(os.Stderr, …)`. Хук замыкается на
`e.options.Logger.Debug`.
Формат строки оставить как в v1 (по строке на v4/v6 сокет), маркер `LX-GRO-PROBE`,
поля `GOOS / txOffload / rxOffload / dispatch`. `dispatch` — `single` на android,
`split` на linux (логика `groProbeDispatch()` из v1 переносится без изменений).
**Уровень:** через `Verbosef` (→ `options.Logger.Debug`). На устройстве снимем
`GET /logs?source=core` — debug-уровень в core-log проходит (проверено: INFO/router
видны; LxBox core-log не режет debug на этом канале при включённом core_logs).
> NB на стороне LxBox: чтобы debug-строки точно дошли, на устройстве включим
> `core_logs_enabled=true` (App Settings → Diagnostics) перед прогоном — это наша
> зона, отдельного действия от ядра не требует.
**Не трогать:** саму offload-логику (`controlfns_linux.go` / `features_linux.go` /
`bind_std.go` dispatch). Это по-прежнему **только замер**, не фикс. Фикс (гейт
`UDP_GRO` за `!android`) — отдельным шагом после того, как v2 даст
`rxOffload`-факт.
---
## Сборка и передача (как с v1)
```bash
# в submodule wireguard-go: ветка gro-probe-010, новый commit поверх 21423f6
# в main repo: ветка lx-gro-probe-010 бампит pin submodule
gh workflow run lx-build.yml -f target=android-aar -f branch=lx-gro-probe-010
gh run download <run-id> -D dist/lx-gro-probe-010
```
Отдать обновлённый `libbox-gro-probe-010.aar` (+ обновить sha в `PROBE.md`). LxBox-сторона
готова: APK-обвязка, профиль WARP-без-detour, прогон-плейбук уже отлажены — повторный
прогон = вложить новый `.aar`, пересобрать release-APK, поднять WARP-endpoint, снять
`GET /logs?source=core | grep LX-GRO-PROBE`.
---
## Как читать результат (без изменений к SPEC.md)
| core-log строка | Вывод | Дальше |
|---|---|---|
| `rxOffload=true` (+ `dispatch=single`) | GRO взведён, не разбирается — **корень №1 подтверждён** | фикс: гейт `UDP_GRO` за `!android` |
| `rxOffload=false` | GRO не активируется — **корень №1 мёртв** | кандидат №2 (тихий хэндовер, `monitor.go`) |
---
## Статус LxBox-стороны (готово, переиспользуемо)
- Probe-`.aar` кладётся в `app/android/app/libs/libbox.aar` (бэкап реального ядра
`v1.13.13-lx.12` в `/tmp/libbox-real-lx12.aar.bak`). **Восстановить перед релизом.**
- Сборка: `flutter build apk --release --split-per-abi --target-platform android-arm64`
**напрямую** (НЕ через `build-local-apk.sh` — его `fetch-libbox.sh` затрёт probe).
Подпись `CN=BoxVPN` → встаёт `adb install -r` поверх без uninstall; vc 2714 > 2702.
- Прогон через Debug API (token, порт — в LxBox `project_dev_endpoints`):
`POST /action/start-vpn` → `POST /action/switch-node?tag=<WARP urlenc>` →
трафик → `GET /logs?source=core&q=LX-GRO-PROBE`.
@@ -0,0 +1,109 @@
# 010 — Probe-ядро для LxBox: замер `rxOffload` на Android
Тестовое (НЕ релизное) ядро с временным диагностическим логом. Цель — один факт:
**взводит ли ядро Android `rxOffload` для WG-endpoint-сокета**. От этого зависит,
какой из двух кандидатов чинить (см. `SPEC.md`, шаг 0).
---
## Что в ядре (probe v2.1)
> **Эволюция канала:** v1 писал в `os.Stderr` → на CPH2411/ColorOS он уходит в
> `/dev/null` (нем). v2 перешёл на `device.Logger` → core-log, но через `Verbosef`
> (DEBUG-уровень) — а **DEBUG отсекается внутри libbox-тракта на Android** (проверено
> эмпирически: при `log.level=debug` в core-log ноль debug-записей, INFO/Error идут).
> **v2.1 пишет через `Errorf` (Error-уровень), который на Android до core-log
> доходит.** Полный разбор — в `PROBE-V2-TASK.md`.
- `submodules/wireguard-go/conn/bind_std.go`: `StdNetBind.Open` больше **не** печатает
в stderr; вместо этого геттер `OffloadState()` — отдаёт `ipv4/ipv6 Tx/RxOffload` +
`dispatch`. Offload-логика не тронута.
- `transport/wireguard/endpoint.go`: сразу после `IpcSet` (device поднят, `bind.Open`
отработал) логирует строку через `logger.Errorf` (→ `options.Logger.Error` →
core-log). Только no-detour путь (`*conn.StdNetBind`); detour-путь (`ClientBind`)
offload не имеет и строку не печатает.
Формат строки (одна; обёртка `Errorf` прогоняет формат через `strings.ToLower`,
поэтому строка приходит в **нижнем регистре**, маркер — `lx-gro-probe`):
```
lx-gro-probe: goos=android txoffload4=<bool> rxoffload4=<bool> txoffload6=<bool> rxoffload6=<bool> dispatch=<split|single>
```
- `rxoffload4`/`rxoffload6` — главное поле: включился ли GRO на приёме.
- `dispatch` — какую RX-ветку берёт диспетчер: `single` = одиночный `ReadMsgUDP` без
разбора GRO (баг), `split` = разбор коалесированных сообщений.
- На Android ожидаем `goos=android` и `dispatch=single` всегда. Вопрос только в
`rxoffload*`.
---
## Готовый artefact (собран CI)
Probe-`.aar` собран через on-demand CI (`gh workflow run lx-build.yml
-f target=android-aar -f branch=lx-gro-probe-010`) и лежит в локальном дереве:
| Файл | SDK | sha256 (v2.1) |
|------|-----|--------|
| `dist/lx-gro-probe-010/libbox-gro-probe-010.aar` | 23 (main) | `9f609cac40782065bcc0d3c9ebde6f6ad969246a177b010c4bb13cff61cd2ad1` |
| `dist/lx-gro-probe-010/libbox-legacy-gro-probe-010.aar` | 21 (legacy) | `432d3e0a6f8200214b3c99a486ee2d32872df18836b9cd93efa657763c9befcb` |
Собрано из `lx-gro-probe-010` @ `6177c6b2` (v2.1, `Errorf`) — подтверждено по логу
checkout CI. Маркер `LX-GRO-PROBE` вкомпилирован в `libbox.so`.
Подложить `.aar` в `app/libs/` приложения, собрать debug-APK — и переходить к прогону.
## Откуда берётся probe
- **submodule** `wireguard-go`: ветка `gro-probe-010`, commit `21423f6` (probe-лог в
`conn/bind_std.go`). Базовый pin lx — `27290b6`.
- **main repo**: ветка `lx-gro-probe-010` бампит pin submodule на probe-commit. `lx`
не тронута (на ней — только CI-файл `lx-build.yml`).
- Голый diff probe рядом: `gro-probe.patch`.
Пересобрать в любой момент:
```bash
gh workflow run lx-build.yml -f target=android-aar -f branch=lx-gro-probe-010
gh run download <run-id> -D dist/lx-gro-probe-010
```
## Как прогнать (команда LxBox)
1. Подложить готовый `.aar` (см. выше) в сборку приложения.
2. Включить core-log: **App Settings → Diagnostics → `core_logs_enabled=true`**.
(Уровень `log.level` менять НЕ нужно — probe идёт через Error, а Error в core-log
на Android проходит независимо от `log.level`.)
3. Поднять профиль с **WG-endpoint без `detour`** (тот самый конфиг, где download
мёртв).
4. Запустить туннель, дать пройти трафику, снять core-log через Debug API
(строка в **нижнем регистре** — ищем по `gro-probe`):
```bash
GET /logs?source=core&q=gro-probe
```
5. Скопировать строку `lx-gro-probe: …` и вернуть её нам.
> Замечание: строка пишется один раз при поднятии device (старт туннеля), после
> `IpcSet`. Если в core-log пусто — либо путь пошёл в `ClientBind` (проверьте, что
> endpoint **без** `detour`), либо не включён `core_logs_enabled`.
---
## Как читать результат
| core-log строка | Вывод | Дальше |
|---|---|---|
| `rxoffload4/6=true` (+ `dispatch=single`) | GRO взведён, но не разбирается — **корень №1 подтверждён** | чиним submodule (гейт `UDP_GRO` за `!android`) |
| `rxoffload4/6=false` | GRO на этом ядре не активируется — **корень №1 мёртв** | переключаемся на кандидат №2 (тихий хэндовер, `monitor.go`) |
Бонус для физического repro (по желанию): прогнать на **стабильном Wi-Fi** и на
**сотовой** — если download мёртв только на сотовой/при переключениях, это в пользу
кандидата №2 независимо от `rxOffload`.
---
## После замера
Probe — временный. Удалить из `bind_std.go`: две `fmt.Fprintf(... "LX-GRO-PROBE" ...)`
строки, функцию `groProbeDispatch()`, импорт `os` (если он добавлялся только под
probe). Это submodule-правка — не должна попасть в релизное ядро.
@@ -0,0 +1,189 @@
# 010 — WG-endpoint без `detour` режет download на Android (GRO split-brain)
| Поле | Значение |
|------|----------|
| Тип | B (bug) — расследование |
| Статус | **C (closed)** — корень подтверждён на железе (probe v2.1: `rxoffload=true`+`dispatch=single`), фикс верифицирован (download 0.44→20.7 Mbps, вровень с контрольной нодой), вмержен в `lx` (submodule `fb8d8d8`). Кандидат №2 не понадобился. **Обновление 1.14:** наш патч БОЛЬШЕ НЕ НУЖЕН — при миграции на v0.0.3 (re-graft submodule) фикс стал upstream-родным: коммит upstream `24ea133 «conn: harmonize GOOS checks between "linux" and "android"»` добавил `\|\| runtime.GOOS == "android"` в gейты приёмного пути `conn/bind_std.go` (строки 206/215/267/323/458). Наш §010-guard при миграции DROPPED (memory `wg-1.14-migration-is-submodule-rebase`). **Следствие:** GRO на Android теперь полностью рабочий (включается в `controlfns_linux.go:104` без android-guard + разбирается upstream-кодом) → большой `MaxSegmentSize=65535` (`device/queueconstants_android.go`) ему нужен как топливо. ⚠️ Откат `MaxSegmentSize→2200` ради экономии памяти задушит GRO-производительность download (то самое, что §010 чинил). Память от multi-WG нагрева лечить числом устройств, НЕ размером буфера. |
| Зона | ядро `sing-box-lx` + submodule `wireguard-go` (`conn/`) |
---
## Симптом
WireGuard-**endpoint** на Android без `detour`: download почти мёртв при живом
upload. Тот же конфиг с `"detour": "direct"` на endpoint'е — download нормальный.
Асимметрия (download убит, upload жив) указывает на дефект **только на приёме (RX)**.
---
## Развилка путей — что определяет всё
`transport/wireguard/endpoint.go:200-215`:
```go
wgListener, isWgListener := common.Cast[dialer.WireGuardListener](e.options.Dialer)
if isWgListener {
bind = conn.NewStdNetBind(wgListener.WireGuardControl()) // ← NO-DETOUR
} else {
bind = NewClientBind(..., e.options.Dialer, ...) // ← DETOUR
}
```
- `WireGuardListener` (`common/dialer/wireguard.go`) реализует **только**
`DefaultDialer` (`common/dialer/default.go:374`).
- `common.Cast` рекурсивна по `Upstream()` (`sing/common/upstream.go:13`).
- **No-detour:** dialer оборачивает `DefaultDialer` → каст успешен → **`StdNetBind`**
(путь wireguard-go **с** UDP-offload GSO/GRO).
- **Detour:** dialer = `DetourDialer`; его `Upstream()` (`detour.go:82`) возвращает
резолвнутый `direct *Outbound`, который **не** реализует `WireGuardControl()`/
`Upstream()` → каст проваливается → **`ClientBind`** (через `direct`, **без**
offload, пакет-в-пакет).
`detour: direct` **физически меняет реализацию UDP-bind**, а не «добавляет ума тому
же сокету». Единственная функциональная разница на приёме между `StdNetBind` и
`ClientBind` — **GSO/GRO offload**.
---
## Корень — build-tag / runtime-GOOS split-brain в `StdNetBind` на Android
На Android `runtime.GOOS == "android"`, **не** `"linux"`. Отсюда расщепление:
1. **GRO потенциально включается (`rxOffload` зависит от ядра).** Файлы
`conn/*_linux.go` (`controlfns_linux.go`, `features_linux.go`) компилируются под
android — имя `*_linux.go` относит их к linux-семейству, **куда входит и
android**. В `controlfns_linux.go` android-guard'ы (`runtime.GOOS != "android"`,
строки 40, 47) уже есть, но **только вокруг `IP_PKTINFO`/`IPV6_PKTINFO`** —
`setsockopt(IPPROTO_UDP, UDP_GRO, 1)` (`controlfns_linux.go:64`) выполняется
**без** android-guard'а. Затем `supportsUDPOffload` (`features_linux.go:32-36`)
читает `UDP_GRO` назад: `rxOffload = (opt == 1)`. Взведётся ли `rxOffload` —
**рантайм-вопрос к ядру android**, а не гарантия кода: если ядро отдаёт
`opt == 1`, `rxOffload` ставится `true` и ядро начинает коалесить входящие UDP в
GRO-«суперпакет». Уже существующие PKTINFO-guard'ы в этом же файле — прямое
свидетельство, что хрупкость android здесь точечно правилась; на `UDP_GRO` этот
guard пока не распространён.
2. **GRO не разбирается.** Весь приёмный диспетчер в `conn/bind_std.go` гейтится на
`runtime.GOOS == "linux"` (строки 212, 221, 272, 328, 463) → на android **ложно**.
`receiveIP` идёт в else-ветку (`bind_std.go:289`) — одиночный `ReadMsgUDP`,
`numMsgs=1`; `splitCoalescedMessages` **никогда не вызывается**. Настоящий парсер
`getGSOSize` (`control_linux.go`) имеет тег `//go:build linux && !android` → на
android берётся stub из `control_default.go`.
3. **Данные рушатся.** Склеенный GRO-блоб (несколько WG-транспорт-пакетов в одном
recv, до ~64 КБ) трактуется как **один** пакет: `device/receive.go` читает только
первый заголовок, хвост ломает AEAD → пакет дропается → **download деградирует**.
`ClientBind` (detour-путь) не вызывает `controlFns`/`supportsUDPOffload`, читает по
одной датаграмме (`BatchSize()=1`) — offload-машинерии нет вообще, поэтому путь
иммунен. Это и объясняет «`detour: direct` лечит».
**Детерминирована только мёртвая RX-ветка разбора** (split-путь на android никогда
не зовёт `splitCoalescedMessages`). А вот **активация** offload — нет: `rxOffload`
взводится, лишь если ядро android вернуло `UDP_GRO == 1` (см. п.1), и даже при
взведённом `rxOffload` баг **проявляется**, только когда ядро коалесит в моменте —
нужен плотный входящий поток (download). На редком трафике ядро отдаёт по пакету —
склейки нет — работает. Поэтому «баг в коде» = неразбираемый GRO **при условии**, что
GRO вообще включился; первое детерминировано, второе — рантайм-зависимо и требует
repro (или прямого замера `rxOffload`, см. шаг 0).
---
## Опровергнутые гипотезы (по коду — не повторять)
| # | Гипотеза | Почему отвергнута |
|---|----------|-------------------|
| 1 | MTU / фрагментация | Симптом асимметричный (RX-only); при MTU резало бы симметрично. |
| 2 | У no-detour нет network-strategy / умного выбора интерфейса | Endpoint и direct зовут **один** конструктор `dialer.NewWithOptions → NewDefault`; `networkStrategy` гейтится только на `AutoDetectInterface`/`platformInterface`/`!disableDefaultBind` — одинаково для обоих. На Android оба привязаны к интерфейсу через `ProtectFunc == AutoDetectInterfaceFunc` (`route/network.go:340,368`). Разница не в выборе интерфейса. |
| 3 | Флаг `DirectOutbound` влияет на dialer | `DirectOutbound` — write-only поле; в `NewDefault` не передаётся и нигде не читается. |
| 4 | «Голый `ListenPacket` без стратегии» (`client_bind.go:89`) — корень | Эта ветка — multi-peer / без явного endpoint. Single-peer + валидный endpoint даёт `isConnect=true` (`endpoint.go:208`) → `DialContext`, не `ListenPacket`. А на no-detour `ClientBind` вообще не используется. |
---
## Открытый второй кандидат (если фикс GRO не лечит полностью)
**Тихий хэндовер / смена IP без смены интерфейса.** Re-bind сокета на смене сети идёт
через `onPauseUpdated` → `device.Up()` → `BindUpdate()`. Но событие `NetworkWake`
эмитится только из `notifyInterfaceUpdate`, а мобильный монитор
(`experimental/libbox/monitor.go:95-98`) дедуплицирует по **Name+Index, игнорируя
Addresses**. Смена source-IP на том же интерфейсе → событие подавляется → сокет не
переоткрывается. Не путь `StdNetBind`-vs-`ClientBind`, но самостоятельный сетевой
кандидат — держать открытым.
> Замечание против самой GRO-версии, которое надо снять repro: если баг чисто в
> GOOS-логике, он должен бить download и на стабильной сети, не только на сотовой.
> Если на стабильной сети download жив — либо GRO коалесит по-разному на разных
> интерфейсах, либо GRO не единственная причина (тогда вес смещается к кандидату №2).
---
## План проверки (эксперимент, не релиз)
Один дискриминирующий замер, изолирующий именно RX:
0. **(Бесплатно, без сборки ядра) Сначала снять факт `rxOffload`.** Залогировать
фактический возврат `supportsUDPOffload(conn)` → `(txOffload, rxOffload)` на
целевом Android. Это дискриминирует всю GRO-гипотезу до любого патча:
- `rxOffload == false` → GRO на этом ядре не активируется, корень №1 **мёртв** без
repro; вес немедленно уходит на кандидат №2 (тихий хэндовер).
- `rxOffload == true` → GRO взведён, переходим к шагу 1 (изолировать именно RX).
1. Запатчить `conn/controlfns_linux.go`: пропускать `setsockopt(UDP_GRO)` при
`runtime.GOOS == "android"` (TX/GSO **не трогать**, чтобы `txOffload` оставался
`true` и эксперимент проверял только RX). Тогда `rxOffload` читается `false` и
приём идёт обычным путём.
2. Собрать ядро и воспроизвести no-detour WG-endpoint на **любом** Android
(эмулятор/устройство — баг детерминирован в коде, оператор не нужен), снять
download под плотным потоком.
3. Желательно — пакетная проверка: реально ли `recvmsg` отдаёт >MTU датаграммы на
этом сокете (подтверждает, что GRO коалесит).
Исходы:
- download починился при живом upload → корень = GRO-на-android **подтверждён**, и
минимальный фикс найден тем же шагом;
- не починился → переходим к кандидату №2 (тихий хэндовер).
---
## Решение
Пока **нет** — это таска-расследование. Код в ядро/submodule — только после repro.
**Кандидат на фикс (когда подтверждён):** гейтить `UDP_GRO`-setsockopt
(`controlfns_linux.go`) и/или чтение `rxOffload` (`features_linux.go`) за `!android`,
чтобы `StdNetBind` на android не объявлял offload, который не умеет разбирать. Это
откатывает android на «без offload» — поведение, идентичное рабочему detour-пути.
> NB: фикс «добавить RX self-disable в linux-ветку» был бы **no-op** — эта ветка на
> android мёртвый код. Корень = split-brain GOOS, а не отсутствие fallback.
---
## Acceptance (для будущего фикса)
- [ ] No-detour WG-endpoint на Android даёт download, сопоставимый с `detour: direct`.
- [ ] Фикс не ломает offload/производительность на «настоящем» Linux (не-android) —
гейт за `!android`, а не глобальное отключение.
- [ ] Регресс: plain WG **и** AmneziaWG endpoint; single-peer (`isConnect`) и
multi-peer (`ListenPacket`) пути.
- [ ] Юнит/интеграционный тест на coalesced-receive (сейчас отсутствует — поэтому
дефект и проскочил).
---
## Источники (проверено по живому коду)
- `transport/wireguard/endpoint.go:200-215` — развилка StdNetBind vs ClientBind.
- `common/dialer/wireguard.go`, `default.go:374` — `WireGuardListener` / `WireGuardControl`.
- `common/dialer/detour.go:82` — `DetourDialer.Upstream()` → direct.
- `sing/common/upstream.go:13` — `common.Cast` рекурсия по `Upstream()`.
- `transport/wireguard/client_bind.go:53-99` — `isConnect` Dial vs Listen, кэш `c.conn`.
- `conn/controlfns_linux.go:40,47` — PKTINFO под `runtime.GOOS != "android"` guard;
`controlfns_linux.go:64` — `setsockopt(UDP_GRO,1)` **без** android-guard'а.
- `conn/features_linux.go:32-36` — `supportsUDPOffload` читает `UDP_GRO` назад,
`rxOffload = (opt == 1)` (рантайм-зависимо от ядра).
- `conn/bind_std.go:212,221,272,328,463` — диспетчер offload под `runtime.GOOS=="linux"`.
- `conn/control_linux.go` (`linux && !android`) vs `control_default.go` (stub на android).
- `device/receive.go` — coalesced-блоб трактуется как один пакет.
- `route/network.go:340,368` — `ProtectFunc == AutoDetectInterfaceFunc` на android.
- `experimental/libbox/monitor.go:95-98` — моб. монитор дедупит по Name+Index.
@@ -0,0 +1,52 @@
diff --git a/conn/bind_std.go b/conn/bind_std.go
index 6bf5978..f39cf9d 100644
--- a/conn/bind_std.go
+++ b/conn/bind_std.go
@@ -11,6 +11,7 @@ import (
"fmt"
"net"
"net/netip"
+ "os"
"runtime"
"strconv"
"sync"
@@ -171,6 +172,17 @@ func listenNet(externalControl control.Func, network string, port int) (*net.UDP
// it's at least non-nil.
var errEADDRINUSE error = errors.New("")
+// groProbeDispatch reports which receive path the dispatcher takes (LX-GRO-PROBE,
+// 010). "split" = the linux coalesced-message path (splitCoalescedMessages);
+// "single" = one ReadMsgUDP per recv (no GRO parsing). On android this must read
+// "single" — the split-brain the spec describes. Temporary; remove after step 0.
+func groProbeDispatch() string {
+ if runtime.GOOS == "linux" {
+ return "split"
+ }
+ return "single"
+}
+
func (s *StdNetBind) Open(uport uint16) ([]ReceiveFunc, uint16, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -209,6 +221,11 @@ again:
var fns []ReceiveFunc
if v4conn != nil {
s.ipv4TxOffload, s.ipv4RxOffload = supportsUDPOffload(v4conn)
+ // LX-GRO-PROBE (010): temporary diagnostic — confirm whether the android
+ // kernel actually reports rxOffload. Remove after measurement. See
+ // SPECS/010-B-C-WG_ENDPOINT_GRO_SPLIT_BRAIN/SPEC.md (step 0).
+ fmt.Fprintf(os.Stderr, "LX-GRO-PROBE v4: GOOS=%s txOffload=%v rxOffload=%v dispatch=%s\n",
+ runtime.GOOS, s.ipv4TxOffload, s.ipv4RxOffload, groProbeDispatch())
if runtime.GOOS == "linux" {
v4pc = ipv4.NewPacketConn(v4conn)
s.ipv4PC = v4pc
@@ -218,6 +235,9 @@ again:
}
if v6conn != nil {
s.ipv6TxOffload, s.ipv6RxOffload = supportsUDPOffload(v6conn)
+ // LX-GRO-PROBE (010): see comment above.
+ fmt.Fprintf(os.Stderr, "LX-GRO-PROBE v6: GOOS=%s txOffload=%v rxOffload=%v dispatch=%s\n",
+ runtime.GOOS, s.ipv6TxOffload, s.ipv6RxOffload, groProbeDispatch())
if runtime.GOOS == "linux" {
v6pc = ipv6.NewPacketConn(v6conn)
s.ipv6PC = v6pc
@@ -0,0 +1,68 @@
# IMPLEMENTATION_REPORT — 011 XHTTP_STREAM_ONE_DOWNLINK
**Дата:** 2026-06-21 · **Статус:** Complete (синтетика) · **Лайв:** ⚠️ НЕ ПРОГОНЯЛСЯ — нет доступа к reality+xhttp ноде; приёмка на синтетике по решению владельца · **База:** ветка `lx` (`1.13.13-lx.13`)
> **Honest caveat.** Фикс принят на основании: построчной сверки с исходниками Xray (контракт stream-one = голый путь / пустой sessionId), независимого подтверждения [issue #5635](https://github.com/XTLS/Xray-core/issues/5635), совпадения с портом hiddify, и зелёной синтетики (юнит-тесты URL-layout + reality-детект, `check`, сборки). Это **не** заменяет лайв против реального Xray-сервера. Лайв остаётся открытым TODO — при первом доступе к reality+xhttp ноде прогнать сценарии из раздела «Дальше» и, если что-то не так, переоткрыть задачу.
## Проблема (из жалобы)
`vless + reality + xhttp`, `mode:auto`, `path:/` работает на стороннем (Xray-логика) ядре, **не работает** на нашем. Две связанные первопричины, сверены построчно с исходниками Xray-core `transport/internet/splithttp` (`main`) и подтверждены [issue #5635](https://github.com/XTLS/Xray-core/issues/5635) + референс-портом hiddify:
1. **stream-one слал `sessionId` в пути.** Xray-сервер (`hub.go`) роутит stream-one (двунаправленный) ТОЛЬКО при пустом sessionId. Наш `dialStreamOne` строил `<path>/<sessionId>` → сервер уходил в stream-down ветку → downlink не-VLESS → VLESS `unknown version`. (Зафиксировано как known bug ещё в 002.)
2. **`mode=auto` всегда → packet-up.** Xray: auto + Reality → stream-one. У нас auto лип к packet-up (т.к. stream-one был сломан).
## Что сделано
Изменения **только** в пакете `transport/v2rayxhttp` (новый код — ребейз-зона = ∅, upstream не тронут).
**Фикс stream-one (главный):**
- `client.go` `requestURL`: ветка `len(elem)==0` → голый `c.path` (без trailing slash). Избегает ловушки `strings.Join([],"/")==""` → `<path>/`.
- `conn.go` `dialStreamOne`: `c.requestURL(sessionID)` → `c.requestURL()` — голый `<path>`, sessionId на провод не идёт.
- stream-up/packet-up URL не тронуты (sessionId там законен).
**`mode=auto` как Xray:**
- `client.go` `Client`: поле `realityEnabled bool`, проставляется в `NewClient`.
- `client.go` `DialContext`: `case modeAuto` — Reality→`dialStreamOne`, иначе→`dialPacketUp`; `modePacketUp` отдельной веткой.
- `reality_detect.go` (новый): `tlsConfigIsReality` — детект Reality по **имени типа** через рефлексию (с разворачиванием kTLS-обёртки), **без** импорта `with_utls`-only типов. Это сохраняет изоляцию build-tag: `with_xhttp` без `with_utls` собирается.
**Тесты (новые):**
- `reality_detect_test.go`: reality→true, uTLS/STD→false, nil→false, kTLS(reality)→true, kTLS(utls)→false.
- `url_test.go`: stream-one→`/xhttp` (голый), stream-up/packet-up сохраняют sessionId/seq.
**Конфиги:**
- `lx-test/config/xhttp_auto_reality.json` (новый, `mode:auto`) для `check`.
**Файлы:**
- new: `transport/v2rayxhttp/reality_detect.go`, `reality_detect_test.go`, `url_test.go`, `lx-test/config/xhttp_auto_reality.json`
- edit (lx new-pkg): `transport/v2rayxhttp/client.go`, `transport/v2rayxhttp/conn.go`
## Проверки (DoD)
- ✅ `gofmt -l` затронутых файлов — пусто.
- ✅ `go test -tags with_xhttp,with_utls ./transport/v2rayxhttp/` — PASS (reality-детект + URL-layout).
- ✅ `go build -tags with_xhttp ./transport/v2rayxhttp/` **без** `with_utls` — компилируется (изоляция тега сохранена).
- ✅ `go vet -tags with_xhttp,with_utls ./transport/v2rayxhttp/` — чисто.
- ✅ `go build ./...` без тегов — ок (xhttp отсутствует, upstream-эквивалент).
- ✅ `make -f Makefile.lx lx-build` — бинарь собран.
- ✅ `./sing-box check -c lx-test/config/xhttp_reality.json` (stream-one) и `xhttp_auto_reality.json` (auto) — pass.
- ⚠️ **Лайв против реального Xray reality+xhttp сервера** — НЕ выполнен (нет доступа к ноде). По решению владельца задача принята на синтетике (статус → **C**), лайв остаётся открытым TODO (см. «Дальше»). При первом доступе к ноде — прогнать и при расхождении переоткрыть.
## Ребейз-зона
**∅.** Все изменения — в новых файлах пакета `v2rayxhttp` и его правках (новый код фичи). Upstream-файлы (`transport/v2ray/transport.go`, `constant`, `option/v2ray_transport.go`) — не тронуты.
## Остаточные риски
- **Матч Reality по имени типа** (`reality_detect.go`) хрупок к переименованию `RealityClientConfig` в sing/upstream. Митигировано юнит-тестом (двойники с теми же именами) — но тест останется зелёным при переименовании реального типа, а лайв сломается. При ребейзе сверять имя типа в `common/tls/reality_client.go`.
- **stream-up** по-прежнему не лайв-тестился (как и в 002).
## Дальше (открытый TODO — лайв)
При первом доступе к реальной Xray reality+xhttp ноде:
1. `make -f Makefile.lx lx-build`; собрать аутбаунд с параметрами рабочей подписки (server/uuid/sni/pbk/sid/path), локальный socks/mixed на `127.0.0.1:2080`.
2. `mode:stream-one` — `curl -x socks5h://127.0.0.1:2080 https://api.ipify.org` должен вернуть IP сервера (handshake+DNS+HTTPS+download).
3. `mode:auto` на той же ноде — идентичный результат (резолв в stream-one).
4. Регрессия: `mode:packet-up` на packet-up-ноде по-прежнему работает.
5. Если ок — оставить как есть (уже C). Если расхождение — переоткрыть задачу.
Слияние в `lx` и релиз `-lx.N` — на усмотрение владельца (фикс на ветке `lx-xhttp-streamone`, в `lx` не влит).
@@ -0,0 +1,91 @@
# PLAN: 011 — XHTTP_STREAM_ONE_DOWNLINK
## 1. Суть фикса (по разведке, построчно сверено с Xray `main` + hiddify)
Два изменения в пакете `transport/v2rayxhttp` (новый код — нулевая ребейз-зона):
1. **stream-one → голый путь без sessionId** (главный баг).
2. **`mode=auto` → stream-one при Reality** (иначе packet-up).
ALPN и padding — **не трогаем** (доказано benign / совпадает; см. SPEC §3.4).
---
## 2. Изменяемые файлы (все — новые/lx-файлы; upstream не трогаем)
| Файл | Тип | Изменение |
|------|-----|-----------|
| `transport/v2rayxhttp/client.go` | lx (new pkg) | `requestURL` bare-path ветка; `Client` +поле reality; `NewClient` детект reality; `DialContext` auto-резолв |
| `transport/v2rayxhttp/conn.go` | lx (new pkg) | `dialStreamOne`: `requestURL(sessionID)` → `requestURL()` (голый путь) |
| `option/v2ray_xhttp.go` | lx (new file) | doc-комментарий про auto/stream-one (косметика, не обязательно) |
| `lx-test/config/xhttp_reality.json` + (новый) auto-конфиг | lx (test) | проверка `check` для stream-one и auto |
**Upstream-файлов не касаемся.** Ребейз-зона 011 = ∅ (всё в новом пакете).
---
## 3. Детали изменений
### 3.1 `requestURL` — bare-path ветка (client.go:179-192)
Сейчас:
```go
fullPath := c.path + "/" + strings.Join(elem, "/")
```
При `len(elem)==0` → `strings.Join` = `""` → `c.path + "/"` = `<path>/` (trailing slash — отличается от Xray/hiddify `<path>`).
**Правка:** если `len(elem)==0` → `fullPath = c.path` (голый путь, c.path уже без trailing slash, client.go:129). Иначе — как было.
### 3.2 `dialStreamOne` (conn.go:21)
```go
u, err := c.requestURL(sessionID) // было: <path>/<sessionId>
→
u, err := c.requestURL() // стало: <path>
```
sessionID в stream-one больше нигде не используется (можно убрать его генерацию для этой ветки, но проще оставить — он безвреден, в URL не идёт). Остальное (POST, pipe-body, streamConn late-binding) — без изменений: разведка подтвердила, что late-binding корректен, баг был только в URL.
### 3.3 `mode=auto` резолв (client.go DialContext:152-167)
Сейчас `case modeAuto, modePacketUp:` → `dialPacketUp`.
**Правка:** выделить `modeAuto` в отдельную ветку:
```go
case modeAuto:
if c.realityEnabled {
return c.dialStreamOne(ctx, sessionID)
}
return c.dialPacketUp(ctx, sessionID)
case modePacketUp:
return c.dialPacketUp(ctx, sessionID)
```
`c.realityEnabled` — новое bool-поле на `Client`, проставляется один раз в `NewClient`.
### 3.4 REALITY-детект в `NewClient` — БЕЗ межтеговой зависимости (РАЗВИЛКА)
`*tls.RealityClientConfig` / `*tls.KTLSClientConfig` — под `//go:build with_utls`; `v2rayxhttp` — под `with_xhttp`. Прямой `tlsConfig.(*tls.RealityClientConfig)` введёт жёсткую связь `with_xhttp → with_utls` и сломает сборку `with_xhttp` без `with_utls` (нарушение CONSTITUTION §3.2). Варианты:
- **(A) Матч по имени типа (рекомендуется).** В `NewClient`:
```go
realityEnabled := tlsConfigIsReality(tlsConfig)
// helper: reflect.TypeOf(unwrap(tlsConfig)).String() содержит "RealityClientConfig"
```
Разворачивать KTLS-обёртку: у `*KTLSClientConfig` встроено поле `Config Config` → если имя типа = KTLS, взять inner и проверить снова. Делать через рефлексию по имени поля/типа, **без** импорта with_utls-типов. Плюс: нулевая межтеговая связь, работает и для kTLS. Минус: матч по строке имени типа — хрупковато к переименованию upstream (митигируется тестом).
- **(B) Проброс флага из вызывающего слоя.** Добавить признак reality в `option.V2RayXHTTPOptions` или в сигнатуру конструктора. Минус: правка upstream-сигнатуры `ClientConstructor`/диспетчера — расширяет ребейз-зону, противоречит «новый код в новых файлах». Отклонено.
- **(C) Эвристика по ServerName/NextProtos.** Ненадёжно (reality неотличим от обычного uTLS по этим полям). Отклонено.
**Решение: (A)** — изолированный helper в `client.go` (или соседнем lx-файле пакета), детект по имени типа с разворачиванием KTLS, покрытый юнит-тестом. Если по ходу выяснится, что рефлексия по приватному полю KTLS недоступна — fallback: матчить и `RealityClientConfig`, и `KTLSClientConfig` по суффиксу имени (для не-Linux kTLS не используется, риск низкий).
---
## 4. Порядок работ
1. Ветка `lx/xhttp` от `lx` (по git-дисциплине; сейчас HEAD на `lx-gro-probe-010`).
2. `requestURL` bare-path ветка + `dialStreamOne` голый путь (фикс 3.1 главный — проверяем stream-one лайв сразу).
3. auto-резолв + reality-детект helper (3.3, 3.4) + юнит-тест детекта.
4. Тест-конфиги, `sing-box check`, лайв-проверка (stream-one + auto на reality-ноде; packet-up регрессия).
5. DoD, IMPLEMENTATION_REPORT, статус (шапка SPEC.md + Roadmap) → C.
---
## 5. Риски
- **Лайв-сервер обязателен.** Синтетического `check` мало — XHTTP под активной разработкой, нужна reality-нода Xray. stream-one лайв-валидируем явно; auto — на той же ноде убеждаемся, что резолвится в stream-one.
- **Матч по имени типа (3.4-A)** хрупок к переименованию `RealityClientConfig` в upstream/sing. Митигировать юнит-тестом, который при ребейзе сразу покраснеет.
- **h2 для stream-one обязателен** — наш h2-only транспорт это обеспечивает; не регрессируем packet-up (он тоже h2 поверх reality).
- Не сломать stream-up/packet-up URL (оставляют sessionId) — правим только пустую-elem ветку `requestURL` и только `dialStreamOne`.
@@ -0,0 +1,90 @@
# SPEC: 011 — XHTTP_STREAM_ONE_DOWNLINK
| Поле | Значение |
|------|----------|
| Тип | B (bug) — баг в фиче 002 |
| Статус | C (complete) |
Починить XHTTP-режим **`stream-one`** (сломан с момента 002) и привести **`mode=auto`** к поведению Xray, чтобы конфиги `vless + reality + xhttp + mode:auto` поднимались на нашем ядре «как есть».
Build-tag: `with_xhttp`. Scope: **client-only**.
---
## 1. Проблема / контекст
Жалоба (2026-06-21): простейший `vless + reality + xhttp`, `mode:auto`, `path:/` работает на стороннем ядре (Xray-логика), **не работает на нашем**. Это типовой конфиг из панелей/подписок.
Две связанные первопричины (обе сверены построчно с исходниками Xray-core `transport/internet/splithttp` ветки `main` и подтверждены [issue #5635](https://github.com/XTLS/Xray-core/issues/5635), а также референс-портом hiddify):
### 1.1 `stream-one` шлёт `sessionId` в пути — главный баг
В Xray `stream-one` — это **один POST на голый путь без sessionId**; сервер (`hub.go`) роутит режим по наличию sessionId:
- `sessionId == ""` → bidirectional **stream-one** ветка (один `httpServerConn`: `request.Body` = uplink, `ResponseWriter` = downlink);
- `sessionId != ""` → stream-up / packet-up.
Наш `dialStreamOne` ([transport/v2rayxhttp/conn.go:21](../../transport/v2rayxhttp/conn.go)) строит URL как `c.requestURL(sessionID)` → `<path>/<sessionId>`. Сервер парсит **непустой** sessionId, уходит в stream-down ветку (ждёт парный stream-up POST, которого нет), и в response.Body летят **не VLESS-байты**. VLESS-парсер читает первый байт как версию → `unknown version` (часто `0x58='X'` из HTTP-обвязки). Зафиксировано как «known bug» ещё в [002 IMPLEMENTATION_REPORT](../002-XHTTP_CLIENT_TRANSPORT/IMPLEMENTATION_REPORT.md).
### 1.2 `mode=auto` всегда → packet-up
Xray (`dialer.go`): `auto` → packet-up по умолчанию, **но если REALITY → stream-one** (если ещё и `downloadSettings` → stream-up). Решает только наличие REALITY/downloadSettings, не h2/h3.
Наш `DialContext` ([client.go:155](../../transport/v2rayxhttp/client.go)) намеренно лепит `auto` к packet-up (т.к. stream-one был сломан). После фикса 1.1 `auto` должен резолвиться как Xray: **REALITY → stream-one**, иначе packet-up. Тогда конфиг из жалобы работает без правок пользователя.
---
## 2. Цель
`vless/vmess/trojan` outbound с `transport.type=xhttp`, `mode:stream-one` — поднимает рабочее соединение к Xray XHTTP-серверу (handshake + DNS + HTTPS + загрузка), в т.ч. поверх Reality. `mode:auto` при включённом Reality резолвится в `stream-one` (как Xray). `mode:packet-up`/`stream-up` — без регрессий.
---
## 3. Требования
### 3.1 Фикс `stream-one` (главное)
- `stream-one` шлёт запрос на **голый нормализованный путь** (`<path>`), **без** `sessionId` в URL (и нигде — ни query, ни header). Метод — `POST` (как сейчас), тело — uplink-pipe, downlink — response.Body того же запроса (late-binding уже корректен — `streamConn.created`).
- `requestURL()` при **пустом** наборе элементов обязан вернуть голый `<path>` **без** trailing-slash. Сейчас `requestURL()` с пустым elem даёт `<path>/` (ловушка `strings.Join([], "/")==""` → `c.path + "/"`), что отличается от Xray/hiddify (`<path>`).
- `stream-up` и `packet-up` URL **не трогаем** — они законно используют sessionId (`conn.go:48,52,86,236`).
### 3.2 `mode=auto` как Xray
- `auto` + **Reality включён** → `stream-one`.
- `auto` + Reality выключен → `packet-up` (текущая совместимая ветка; download-settings у нас нет — stream-up в auto не выбираем).
- REALITY-признак определяется **в конструкторе** (`NewClient`), один раз, и сохраняется на `Client` (в `DialContext` tlsConfig вне области видимости).
### 3.3 Изоляция REALITY-детекта (граница build-tag)
- `*tls.RealityClientConfig`/`*tls.KTLSClientConfig` объявлены под `//go:build with_utls`, а пакет `v2rayxhttp` — под `with_xhttp`. **Запрещён прямой type-assert** на with_utls-типы из v2rayxhttp: это введёт жёсткую зависимость `with_xhttp → with_utls` и сломает сборку с `with_xhttp` без `with_utls` (нарушение §3.2 CONSTITUTION — фича за своим тегом).
- Детект REALITY делать **без прямой ссылки на with_utls-тип**: по имени конкретного типа (`reflect`/`fmt %T`, сопоставление с суффиксом `RealityClientConfig`) либо иным способом, не вводящим импорт-связь между тегами. Способ фиксируется в PLAN.
### 3.4 Что НЕ трогаем (доказано в разведке)
- **ALPN.** Наш форсинг `["h2"]` → uTLS добавляет `http/1.1` → `["h2","http/1.1"]`. Xray `decideHTTPVersion` для списка длиной ≠1 → h2; reality всегда h2. Форсинг benign, а h2 для bidirectional stream-one **обязателен**. Оставляем как есть.
- **Padding.** `x_padding` в query внутри `Referer` совпадает с Xray (client request direction). Не трогаем.
- **Submodule, server/inbound** — вне scope.
---
## 4. Критерии приёмки
- `sing-box check -c` принимает `vless + reality + xhttp + mode:stream-one` и `mode:auto` (есть/будет тест-конфиг в `lx-test/config`).
- **Лайв:** реальный Xray XHTTP-сервер (reality-нода) — `mode:stream-one` поднимает соединение (handshake + DNS + HTTPS-страница + загрузка); `mode:auto` на той же ноде даёт идентичный результат (резолвится в stream-one). packet-up/stream-up — без регрессий.
- Сборка **с** `with_xhttp` **без** `with_utls` — компилируется (изоляция тега не нарушена).
- Сборка без `with_xhttp` = поведение upstream (xhttp отвергается).
- `go vet` (lx-теги) и `gofmt -l` по затронутым файлам — чисто. `go build ./...` без тегов — ок.
- Ребейз-зона не расширяется: правки только в новых файлах пакета `v2rayxhttp` и (возможно) комментарий в `option/v2ray_xhttp.go`. Upstream-файлы — не трогаем.
---
## 5. Вне скоупа
- XHTTP **server/inbound**, xmux/мультиплекс, `downloadSettings`/asymmetric transport.
- Переключение HTTP-версии (h1/h3) и отказ от h2-only — our транспорт остаётся h2-only (для stream-one это и нужно).
- Маппинг `type=xhttp` в лаунчере (`singbox-launcher`) — отдельный репозиторий.
- ALPN-рефактор (benign, см. §3.4).
---
## 6. Ссылки
- [Xray-core splithttp dialer.go / client.go / hub.go](https://github.com/XTLS/Xray-core/tree/main/transport/internet/splithttp) — проводной контракт.
- [Xray issue #5635](https://github.com/XTLS/Xray-core/issues/5635) — «auto+reality → unexpected response version; stream-one работает».
- [XHTTP: Beyond REALITY (#4113)](https://github.com/XTLS/Xray-core/discussions/4113) — семантика auto.
- [002 SPEC / IMPLEMENTATION_REPORT](../002-XHTTP_CLIENT_TRANSPORT/) — исходная фича и зафиксированный stream-one bug.
@@ -0,0 +1,38 @@
# TASKS — 011-XHTTP_STREAM_ONE_DOWNLINK
## Подготовка
- [x] Ветка `lx-xhttp-streamone` от `lx` (имя `lx/xhttp` невозможно — ref `lx` уже лист)
## Фикс stream-one (главный баг)
- [x] `client.go` `requestURL`: ветка `len(elem)==0` → `fullPath = c.path` (голый путь, без trailing slash)
- [x] `conn.go` `dialStreamOne`: `c.requestURL(sessionID)` → `c.requestURL()` (голый `<path>`, без sessionId)
- [x] Не трогать stream-up/packet-up URL — sessionId там законен (покрыто `url_test.go`)
## mode=auto как Xray
- [x] `client.go` `Client`: добавлено поле `realityEnabled bool`
- [x] `reality_detect.go`: детект REALITY по имени типа tlsConfig (с разворачиванием KTLS), без импорта with_utls-типов → проставляется в `NewClient`
- [x] `client.go` `DialContext`: выделен `case modeAuto` — reality→`dialStreamOne`, иначе→`dialPacketUp`; `modePacketUp` отдельно
- [x] Юнит-тест детекта REALITY (`reality_detect_test.go`): reality/ktls(reality)→true; uTLS/STD/nil→false
## Изоляция / build-tags
- [x] Сборка `with_xhttp` БЕЗ `with_utls` компилируется (нет жёсткой связи тегов)
- [x] Сборка полного LX_TAGS — ок (`lx-build`)
## Конфиги / проверки
- [x] `lx-test/config/xhttp_reality.json` (`mode:stream-one`) — `sing-box check` зелёный
- [x] Новый `lx-test/config/xhttp_auto_reality.json` (`mode:auto`) — `check` зелёный
- [x] `go vet` (lx-теги) по `transport/v2rayxhttp` — чисто
- [x] `gofmt -l` по затронутым файлам — пусто
- [x] `go build ./...` без тегов — ок (xhttp отсутствует)
## Лайв — ⚠️ ОТЛОЖЕН (нет доступа к ноде; принято на синтетике, открытый TODO)
- [ ] Реальная Xray reality-XHTTP-нода: `mode:stream-one` — handshake + DNS + HTTPS + загрузка
- [ ] Та же нода `mode:auto` — идентичный результат (резолвится в stream-one)
- [ ] Регрессия: `mode:packet-up` на packet-up-ноде по-прежнему работает
## Закрытие
- [x] TASKS отражают факт (`[x]`)
- [x] IMPLEMENTATION_REPORT.md (корень бага, фикс, синтетика; лайв — honest caveat)
- [x] Обновить [002 IMPLEMENTATION_REPORT](../002-XHTTP_CLIENT_TRANSPORT/IMPLEMENTATION_REPORT.md): stream-one fixed на синтетике (ссылка на 011)
- [x] Статус → `C` (шапка SPEC.md + Roadmap; принято на синтетике, лайв — открытый TODO в REPORT)
- [ ] (опц.) Закрыть GH-issue по жалобе — комментарий со ссылкой на коммит (lx-память)
@@ -0,0 +1,126 @@
# 012 — PROBE: инструментовка download-копирования (`LX_CONN_TRACE`)
Цель зонда — развести **изнутри ядра** место застревания download для зомби-соединения,
которое синхронный pcap локализовал, но не смог разложить (см. [SPEC.md](SPEC.md),
раздел «Оговорки о строгости»). Pcap показал: 777B пришли в ядро на `wlan0`, до
приложения на `tun0` не дошли. Зонд отвечает на вопрос **где именно** на пути
`remoteConn → conn`.
Артефакт целиком: [instrumentation.patch](instrumentation.patch).
---
## Что зондируем
`route/conn.go` → `connectionCopy(ctx, source, destination, direction, …)`:
- download-горутина запускается строкой `go m.connectionCopy(ctx, remoteConn, conn, true, …)`
→ `source = remoteConn` (upstream, уже **расшифрованный** reality/vless),
`destination = conn` (gVisor tun-сокет приложения), `direction = true`.
- тело: `bufio.CopyWithIncreateBuffer(destination, source, …)` (sing v0.8.10) —
голый Read→Write без read-deadline.
Зонд считает **раздельно**:
- `read` — байты, отданные `source.Read` (для download: plaintext, который proxy успел расшифровать и выдать);
- `write` — байты, принятые `destination.Write` (для download: что реально ушло в tun-сокет приложения).
---
## Как читать вывод
Лог на уровне **INFO** (виден при любом боевом log.level). Две формы:
```
lx-trace download tick#<k>: read=<N> write=<M> ← периодически, ПОКА copy жив
lx-trace download final: read=<N> write=<M> err=<...> timeout=<bool> ← один раз, при возврате copy
```
`tick#k` печатается раз в период (см. ниже) — это снимок ВО ВРЕМЯ зависания, ради
висящего зомби. `final` — итог при закрытии/отвисании. Обе строки читаются по одной
таблице. `upload`-направление логируется симметрично.
Разбор для зомби-соединения (download, ↓0 на tun0):
| Снимок | Где застряло | Трактовка |
|---|---|---|
| `read=0 write=0` | **выше** copy | proxy (reality/vless) не отдал НИ ОДНОГО расшифрованного байта, хотя pcap показал зашифрованные 777B → дефект расшифровки/фрейминга, НЕ в `connectionCopy` |
| `read>0 write=0` | **запись в tun** | байты из upstream прочитаны, но не записаны/не флашатся в gVisor tun-сокет → подтверждает гипотезу SPEC «застряло в `remoteConn→conn`» |
| `read>0 write>0` | copy шёл | смотреть на `err`/`timeout` — копирование двигалось, причина в завершении/лаге, не в самом stuck |
Ключ к развилке pcap: обёртка стоит на **расшифрованном** `remoteConn`, поэтому
`read` — это plaintext. `read=0` при наличии зашифрованного трафика в pcap снимает
с `connectionCopy` подозрение и переводит расследование выше по стеку (proxy-слой).
---
## Механика (почему обёртка перехватывает, а не обходится)
Обёртка `lxTraceConn` (`route/conn_trace_lx.go`) встраивает `net.Conn`, считает байты
в `Read`/`Write`, и **намеренно НЕ реализует** `Upstream()` / `ReaderReplaceable()` /
`WriterReplaceable()` / `SyscallConn()`. Это критично — иначе её обойдут:
1. **`N.UnwrapCountReader`** (sing `common/network/counter.go`) разворачивает обёртку
только если она реализует `ReaderWithUpstream` **и** `ReaderReplaceable()==true`.
Без них разворот останавливается на `lxTraceConn` → её `Read` в цепочке.
2. **`copyDirect`** (sing `common/bufio/copy_direct.go`) включает zero-copy `splice`
только при `SyscallAvailableForRead/Write` (нужен `syscall.Conn`). Go-проброс
встроенного `net.Conn` НЕ даёт обёртке `SyscallConn()` → `copyDirect` возвращает
`handed=false` → копирование идёт буферным путём через `Read`/`Write` обёртки.
Итог: под трейсом счётчики **гарантированно** видят весь поток. Цена — на время
диагностики теряется zero-copy splice (копирование принудительно буферное). Поэтому
зонд под env-гейтом и НЕ для постоянной работы.
---
## Гейт и оверхед
`LX_CONN_TRACE` (`route/conn_trace_lx.go`, `sync.OnceValue` → `time.Duration` = период тика):
- `""`, `"0"`, `"false"` → выключено: обёртки в copy-цепочку **не ставятся вовсе**,
тикер не запускается, путь копирования и поведение закрытия не меняются, оверхед нулевой;
- `"1"`, `"true"` → включено с дефолтным периодом тика **5s** (`lxConnTraceDefaultTick`);
- длительность (`"3s"`, `"500ms"`, `"10s"`) → включено с этим периодом тика;
- нераспознанное непустое значение → включено с дефолтным периодом (fail-open).
Тик запускается отдельной горутиной на каждое copy-направление; останавливается
через `defer close(stop)` при возврате `connectionCopy`. Счётчики атомарны
(проверено `go test -race`).
---
## Как собрать и прогнать
1. Применить артефакт к чистому релизному дереву (`route/conn.go` + новый файл):
```
git apply SPECS/012-TCP_DOWNLINK_STALL_ZOMBIE_CONNS/instrumentation.patch
# либо вручную: diff для conn.go + создать route/conn_trace_lx.go из хвоста патча
```
(на момент написания зонд уже лежит в рабочем дереве: `M route/conn.go`,
`?? route/conn_trace_lx.go`.)
2. `gofmt -l route/conn.go route/conn_trace_lx.go` → должно быть пусто (lx-правило).
`go build ./...` и `go vet ./route/` → проходят.
3. Собрать lx-ядро для устройства (обычный build-флоу LxBox/gomobile).
4. На CPH2411 выставить env ядра `LX_CONN_TRACE=1`, прогнать репро WhatsApp/Telegram.
5. Снять core-лог через `/logs?source=core` (на CPH2411 stderr→/dev/null), искать
строки `lx-trace download:` для зомби-соединения.
---
## Оговорки
1. Зонд меняет тайминги (буферный путь вместо splice) — он диагностический, не для
постоянной работы. Вне гейта код нетронут.
2. `lxTraceConn` (как `net.Conn`-обёртка) не пробрасывает `N.WriteCloser.CloseWrite()`
→ под трейсом half-close деградирует в полный `Close()`. На диагностику не влияет;
ещё одна причина не держать `LX_CONN_TRACE` включённым в проде.
3. **Момент логирования — две формы, и почему обе нужны.** Лог `final` стоит в хвосте
`connectionCopy`, ПОСЛЕ возврата `CopyWithIncreateBuffer`. А для висящего зомби
copy НЕ возвращается — он заблокирован на `Read` (в этом и суть бага). Поэтому:
- `final` для висящего зомби **не выйдет**, пока соединение не отвиснет (наблюдали
↓0→↓2820) или его не разорвут (ручной close / `DELETE /connections` / переоткрытие).
- чтобы видеть stuck В МОМЕНТ зависания, добавлен **периодический `tick#k`** (раз в
период `LX_CONN_TRACE`, дефолт 5s): он печатает текущие `read`/`write`, пока copy
жив. Для висящего зомби увидим серию `tick#1 read=… write=0`, `tick#2 …` — это и
есть прямое доказательство застревания во времени, без необходимости рвать связь.
- на развилку `read=0` vs `read>0,write=0` обе формы отвечают одинаково; `tick`
просто показывает её раньше и без ручного вмешательства.
@@ -0,0 +1,86 @@
# 012 — RUN-PLAN: прогон зонда `LX_CONN_TRACE` на устройстве
План для **нашей** стороны (LxBox/диагностика) после того, как команда ядра отдаст
lx-ядро, собранное с зондом из [PROBE.md](PROBE.md). Цель прогона — снять развилку:
`read=0` (дефект выше copy, в proxy-расшифровке) vs `read>0, write=0` (застряла
запись в gVisor tun) vs `read>0, write>0` (двигалось, смотреть err/лаг).
## Предусловия
- ✅ **Тестовая сборка готова (22.06.2026):** APK с зондом собран —
`app/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk` (29.3 МБ).
Зонд подтверждён в `lib/arm64-v8a/libbox.so` (строки `LX_CONN_TRACE`,
`conn_trace_lx.go`). AAR-зонд подменён в `app/android/app/libs/libbox.aar`
(sha `221c0e35…`), version-метка сбита на `lx-conn-trace-probe`, собрано в обход
`fetch-libbox.sh` (иначе перекачал бы релиз). **Бэкап релиза:**
`/tmp/libbox-release-backup.aar` (sha `c50786a6…`, =`v1.13.13-lx.14`) +
`/tmp/.libbox.version.backup` — для отката после прогона.
- ⚠️ **Доставка env `LX_CONN_TRACE=1` в процесс ядра — НЕ решена кодом.** `Libbox` API
не имеет `Setenv`; `os.Getenv` в Go читает env процесса. Кандидат для рут-устройства:
Android wrap-property — `adb shell su -c 'setprop wrap.com.leadaxe.lxbox "LX_CONN_TRACE=1 "'`
затем перезапуск приложения (Zygote стартует процесс с этой env). **Проверить на
железе, что зонд активировался** (в core-логе при висящем зомби должны пойти
`lx-trace download tick#k`). Если wrap-prop не сработает — запросить у команды ядра
правку Kotlin (`os.Setenv`/JNI перед `Libbox.setup`) или чтение флага из конфига.
- CPH2411: USB нестабилен при 100% заряда; wifi-adb IP плавает (.181/.219) — сверять,
`ensure-wifi-adb.sh`. На момент готовности APK устройство было отключено — переткнуть кабель.
- Debug API жив: `adb forward tcp:9269 tcp:9269`, token из памяти `project_dev_endpoints`.
## Установка сборки
```
adb install -r app/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
adb shell am start -n com.leadaxe.lxbox/.MainActivity # стартануть, чтобы Debug API слушал
```
versionCode авто-согласован (§125, vc=2xxx) → встанет поверх релиза без downgrade.
## Шаги
1. **Поднять стрим core-лога В ФАЙЛ заранее** (не через `timeout` — на macOS его нет,
это сломало прошлый прогон, см. SPEC уточнение №2):
```
curl -s -N -H "Authorization: Bearer $TOKEN" "http://localhost:9269/logs?source=core" \
| tee /tmp/lx_trace_run.log
```
(фоном; перенаправление в файл, без `timeout`-обёртки.)
2. **Параллельно — синхронный двойной pcap** (как в успешном прогоне): tun0 (WhatsApp-эджи
:443) + wlan0 (порт сервера выхода), ОДНОЙ adb-командой (единые часы), от момента SYN.
3. **Спровоцировать зомби** (пользователь — живой WhatsApp/Telegram; либо быстрый
триггер `PUT /proxies/<selector>` сменой ноды, помня что в проде баг «не на переключение»).
4. **Поймать зомби** 1-Hz поллингом `/connections` — зафиксировать `destinationIP` + `sourcePort`.
5. **Снять лог зонда.** Зонд логирует ДВЕ формы (PROBE №3 + раздел «Гейт»):
- `lx-trace download tick#k: read=… write=…` — периодически (дефолт 5s), **пока зомби
висит**. Для висящего зомби это и есть прямой снимок: серия `tick#1 write=0`,
`tick#2 write=0`… показывает застревание в реальном времени, разрывать НЕ нужно.
- `lx-trace download final: read=… write=… err=…` — один раз, при отвисании
(↓0→↓2820) или разрыве (`DELETE /connections/<id>`).
Искать в `/tmp/lx_trace_run.log` строки `lx-trace download` для нужного conn (сверить
с `destinationIP`/`sourcePort` из шага 4).
6. **Сопоставить** найденную строку с pcap-потоком по времени/объёму (тот же conn).
## Чтение результата (развилка)
| Снимок | Вывод | Следующий шаг расследования |
|---|---|---|
| `read=0 write=0` | proxy (reality/vless) не отдал plaintext, хотя pcap видел зашифрованные 777B | копать **выше** `connectionCopy`: расшифровка/фрейминг vless/reality на download |
| `read>0 write=0` | байты прочитаны из upstream, не записаны в tun | подтверждение гипотезы SPEC; копать запись в gVisor tun-сокет (флаш/блокировка) |
| `read>0 write>0` | copy двигался | смотреть `err`/`timeout`; причина в завершении/лаге, не в чистом stuck |
## Период тика
Тик уже встроен. Период задаётся значением `LX_CONN_TRACE`: `1`/`true` → 5s (дефолт);
`3s`/`1s`/`500ms` → этот период. Для висящего зомби 5s достаточно; если нужен более
плотный снимок прогресса — поставить `LX_CONN_TRACE=1s`. Помнить: чем чаще тик, тем
больше строк в core-логе.
## Не забыть
- `LX_CONN_TRACE` — только на время диагностики (теряется zero-copy splice, half-close
деградирует в Close). После прогона — выключить / поставить релизное ядро обратно.
- Метод, который сработал и который НЕ менять: синхронный tun0+wlan0 ОДНОЙ командой
(единые часы) + 1-Hz поллинг. Точечные несинхронные срезы врут (рассинхрон по времени).
@@ -0,0 +1,199 @@
# 012 — TCP download застревает в ядре: зомби-соединения ↑517 ↓0 (WhatsApp/Telegram «висят»)
| Поле | Значение |
|------|----------|
| Тип | B (bug) — расследование |
| Статус | **C (closed) — НЕ удалось воспроизвести на lx.14.** Симптом наблюдался на РАЗНЫХ нодах, включая WG → это зонтик над «↓0»-сталлом, не один баг. WG-долю закрыл фикс **§010 GRO** (вошёл в lx.14, доказан). Для не-WG нод (VLESS/reality) §010 не применим, отдельного код-фикса нет — там симптом сейчас не воспроизводится без подтверждённого объяснения. См. раздел «Закрытие 22.06» ниже. |
| Зона | ядро `sing-box-lx` — `route/conn.go` (`connectionCopy`, общий relay); фикс WG-доли — submodule `wireguard-go` (GRO, §010, UDP/WG-only) |
| Связь | WG-доля симптома = [010-WG_ENDPOINT_GRO_SPLIT_BRAIN](../010-WG_ENDPOINT_GRO_SPLIT_BRAIN/SPEC.md) (closed, фикс в lx.14). §010 это UDP/WG-only → НЕ объясняет не-WG (VLESS) случаи; «виснет и на VLESS» строго не подтверждено (см. «Закрытие») |
| Артефакты | [PROBE.md](PROBE.md) — зонд (env-гейт `LX_CONN_TRACE`, не прогнан в боевом режиме); [instrumentation.patch](instrumentation.patch) — диф зонда; [RUN-PLAN.md](RUN-PLAN.md) — процедура прогона (на случай повторного появления) |
---
## Симптом
Жалоба: WhatsApp/Telegram «висят» — чаты/медиа не грузятся. В клиенте (LxBox
Conns) у приложения одно TCP-соединение, оно **зомби**: `↑517/629 ↓0` — ClientHello
ушёл, download=0, висит десятки–сотни секунд. Ручной разрыв → приложение
переоткрывает → новое соединение часто **снова зомби**.
**Строгая корреляция (подтверждена пользователем + непрерывным 1-Hz поллингом
`/connections`): зомби-↓0 ↔ зависание приложения.** Когда приложение висит — у него
ровно одно соединение, и оно ↓0; рабочих рядом нет. Приложение заперто на этом
сокете (TCP для него «установлен», keepalive ходят), ждёт прикладной ответ.
Воспроизведено на CPH2411 (Android 15) 21–22.06.2026, WhatsApp и Telegram.
---
## Доказательство — синхронный двойной tcpdump (tun0 + wlan0, единые часы телефона), от SYN
Зомби `63.181.55.136:443` (WhatsApp edge), sport 51514, выход = VLESS-нода
Нидерланды (`154.83.159.64:8443`):
| Время (телефон) | iface | Событие |
|---|---|---|
| 318.124 | tun0 | app→ядро **SYN** |
| 318.129 | tun0 | ядро→app **SYN-ACK** (4 мс — установка inbound OK) |
| 318.155 | tun0 | app→ядро **ClientHello 517B**, ядро `ack 518` (принял) |
| **318.165** | **wlan0** | ядро открывает **upstream** к серверу выхода: SYN (**+10 мс** после ClientHello) |
| 318.204 | wlan0 | ядро→сервер **666B** (зашифрованный ClientHello) |
| **318.243** | **wlan0** | **сервер выхода ОТВЕЧАЕТ 777B** (download физически пришёл в ядро, +40 мс) |
| 318.246 | wlan0 | ядро→сервер ещё 1104B |
| ∞ | tun0 | **приложению — НИЧЕГО, ↓0** |
**Вывод:** каждое звено работает, кроме последнего. ClientHello доставлен наружу за
10 мс, сервер выхода ответил 777B за 40 мс — но эти данные **не дошли от
upstream-recv до tun-сокета приложения**. Download застрял **внутри ядра**, на
направлении `remoteConn → conn`.
**Застревание временное:** часть зомби «отвисает» сама (данные доезжают с большим
лагом, наблюдали ↓0→↓2820), часть приложение бросает и переоткрывает.
---
## Подозреваемое место
`route/conn.go`:
- `NewConnection` (стр. ~94): после успешного dial (`TCPConnectTimeout=5s`
покрывает ТОЛЬКО установку) запускаются 2 copy-горутины (стр. 140-141).
- `connectionCopy` (стр. 262): `bufio.CopyWithIncreateBuffer(destination, source, …)`
(sing v0.8.10) — голый Read→Write **без read-deadline**.
- В `route/*.go` **нет TCP idle/response-timeout** (есть только `UDPTimeout=5min`).
→ соединение без прогресса download ядро не закрывает и не переподнимает.
Download-горутина (`source=remoteConn`, `destination=conn` — gVisor tun-сокет):
данные из upstream получены (видно на wlan0), но в tun-сокет приложения не
записаны/не флашатся вовремя. Гипотезы для проверки: (а) буферизация/нефлаш
download в tun-стек; (б) голодание download-горутины (общий мьютекс gVisor-стека,
пока активна upload-сторона); (в) batch/coalesce-правка форка на RX.
---
## Что НЕ причина (отметено измерениями на железе, не догадками)
- **НЕ MSS/MTU** — MSS зажат gVisor до 1240, ClientHello доходит целым (сервер ACKает).
- **НЕ протокол выхода** — виснет на VLESS(reality+xtls-vision) И на WARP(AWG 1.5)
одинаково (пользователь: «на любом протоколе»).
- **НЕ сервер/эдж** — сервер выхода отвечает 777B; эджи меняются (54.179/63.181/54.116) — симптом тот же.
- **НЕ конкретная нода** — Италия/Нидерланды/Германия; соседние conns через ту же ноду качают.
- **НЕ серверный RST** — наблюдавшийся RST-шторм был артефактом `DELETE /connections` (close all), ложный след.
- **НЕ переключение ноды** — пользователь подтвердил «не на переключение» (хотя `PUT /proxies/<selector>` — удобный быстрый триггер для репро).
- **НЕ §010 GRO split-brain** — тот closed/верифицирован, чисто WG-AEAD на download; здесь виснет и на VLESS (нет WG/GRO/AEAD).
---
## Оговорки о строгости (НЕ выдавать за абсолют)
1. wlan0 зашифрован → 777B связаны с зомби **по времени** (upstream-SYN +10 мс после
ClientHello), не по содержимому. Корреляция сильная, но косвенная.
2. core-log изнутри НЕ подтвердил: ядро на текущем уровне per-conn события не пишет
(1 строка за прогон; на CPH2411 stderr→/dev/null, вывод только через `/logs?source=core`).
3. «Застревание именно в `connectionCopy`» = вывод из pcap + чтения кода, НЕ из лога ядра.
4. **Зонд `lx-trace` логирует двумя формами.** `final` — в хвосте `connectionCopy`
(после возврата `CopyWithIncreateBuffer`); для висящего зомби copy не возвращается
→ `final` выйдет только при отвисании/разрыве. Чтобы видеть stuck В МОМЕНТ зависания,
зонд печатает периодический `tick#k` (раз в период `LX_CONN_TRACE`, дефолт 5s) с
текущими `read`/`write`, пока copy жив. На развилку `read=0` vs `read>0,write=0` обе
формы отвечают одинаково. Детали — [PROBE.md](PROBE.md), оговорки №3 и раздел «Гейт».
---
## Уточнения 22.06.2026 (при подготовке зонда — отменяют прежние гипотезы)
Три факта, установленные при подготовке инструментовки. Каждый снимает ложный след,
которого в первой редакции SPEC не было:
1. **`route/conn.go:262` из первого анализа указывал не туда.** Стр. 262 в одной из
рабочих ревизий — это `canceler.NewPacketConn` (UDP-таймаут), НЕ download-копирование.
Реальная download-горутина запускается строкой `go m.connectionCopy(ctx, remoteConn,
conn, true, …)`, а сам цикл — `CopyWithIncreateBuffer` внутри `connectionCopy`.
Зонд ставится именно туда. Номера строк в SPEC ориентировочны — дерево сдвигалось
(см. п. 3); опора — на имена функций, не на номера.
2. **`run_core.log` оказался НЕ логом ядра.** 47-байтовый `run_core.log` от прогона
содержал `/bin/bash: line 37: timeout: command not found` — лаунчер пытался
запустить ядро через `timeout`, которого нет на macOS, и ядро через эту обёртку не
стартовало. То есть «1 строка за прогон» (оговорка о строгости №2) — частично
артефакт сломанного лаунчера, а не только скудность per-conn логов. **Лога ядра
именно того прогона у нас нет вообще** — вывод про застревание держится на pcap +
чтении кода, как и оговорено.
3. **На устройстве работало РЕЛИЗНОЕ ядро без lx-изменений.** В ходе сессии в рабочем
дереве жил несвязанный прототип `LX_TCP_RESPONSE_TIMEOUT` (`conn_response_timeout_lx.go`
+ диф `conn.go`), который оборачивал `remoteConn` на download-пути. Это породило
гипотезу «наш first-byte timeout рвёт relay». **Гипотеза снята:** на телефоне крутилось
последнее релизное ядро, прототипа в нём не было; к моменту написания зонда прототип
из дерева убран, дерево чистое релизное. Зонд `LX_CONN_TRACE` ставится поверх чистого
релиза — без постороннего прототипа в цепочке.
---
## Закрытие 22.06.2026 — не воспроизводится на lx.14
**Факты (не догадки):**
| Дата | Ядро | Фикс §010 (GRO) |
|---|---|---|
| 16–17.06 | lx.10/lx.11 (app v2.3.3-dev.9) | нет |
| 18–19.06 | lx.12 / lx.13 | нет |
| 21.06 вечер | **lx.14** (`4d4027e4`, submodule wireguard-go `27290b6d`→`6513629`) | **да** |
| 22.06 | lx.14 (+ зонд на отдельной ветке) | да |
- Текущее ядро на устройстве = `sing-box 1.13.13-lx.14` (подтверждено через clash_api `/version`).
- Baseline §012 ловился 21–22.06; app стоит с 17.06 → baseline **вероятно был на до-lx.14 ядре**
(фикс §010 вышел только вечером 21-го).
- На **той же ноде** (VLESS Нидерланды `154.83.159.64:8443`) **и той же сети** на lx.14 баг
download-сталла **не воспроизводится** — подтверждено пользователем, в т.ч. под `LX_CONN_TRACE=0`
(зонд выкл, код = чистый релиз lx.14; env задан через Android `wrap.<pkg>`-prop, проверено
в `/proc/<pid>/environ`).
**Ключевой факт: симптом был на РАЗНЫХ нодах, включая WG (подтверждено пользователем).**
Значит «↑/↓0 download-сталл» — это ЗОНТИК над симптомом, под который попало как минимум два
разных корня на разных транспортах, а НЕ один баг. Это меняет разбор причин:
**Что менялось lx.12 → lx.14 в download-пути, по транспортам:**
1. **WG-ноды (WARP/AWG, UDP):** **§010 GRO split-brain (`4d4027e4`)** — прямой фикс, доказан на
железе (§010 closed). Бамп submodule `wireguard-go`. Закрывает WG-долю симптома.
2. **VLESS+xhttp+reality (TCP):** **§011 stream-one downlink (`f2654e6a`)** — менял
`v2rayxhttp/{client,conn}.go` + `reality_detect.go`. Кандидат, ЕСЛИ нода идёт через xhttp.
3. **VLESS+reality напрямую (xtls-vision, TCP):** **НИ ОДНО изменение lx.12→lx.14 не влияет** —
§010 это UDP/WG-only (submodule `wireguard-go`, импортируется только `transport/wireguard/`);
§011 это xhttp-only; `route/conn.go` (общий relay) и `sing` (copy) в окне НЕ менялись. Для этой
доли симптома **код-объяснения нет** — либо это был тот же визуальный «↓0», который по
зашифрованному pcap не отличили от WG-случая, либо внешний фактор.
§009 AWG masquerade (QUIC/SIP/STUN Initial) — handshake-маскировка, к relay download нерелевантно.
**Честные оговорки (почему «вероятно», а не «точно»):**
- §010 чинит ТОЛЬКО WG-долю. Для не-WG нод (VLESS/reality) §010 физически не применим (UDP/WG-only,
проверено по импортам submodule). «Виснет и на VLESS» опиралось на корреляцию по времени
(wlan0 зашифрован, core-log молчал о направлении) — **не строго подтверждено**; возможно, на
VLESS был отдельный корень или визуальный артефакт того же «↓0».
- Контр-доказательство (прогнать baseline на до-lx.14 ядре на той же ноде → вернётся ли баг)
**не выполнялось**. Поэтому статус — «не удалось воспроизвести», а не «корень доказан».
- Баг был перемежающимся (часть зомби отвисала сама ↓0→↓2820) — отрицательный результат на одной
сессии не гарантирует, что причина устранена навсегда, особенно для не-WG нод.
**Если баг вернётся** — зонд готов: ветка `lx-conn-trace-probe` (коммит `b6d8c40a`,
**оставлена на `origin` намеренно как история — не удалять**), активация через `wrap.<pkg>`
`LX_CONN_TRACE=…` (RUN-PLAN.md). ВАЖНО до боевого прогона: текущая обёртка зонда глушит
`ReadWaiter`/`copyDirect` на reality-download (не реализует `ReaderReplaceable`/`SyscallConn`) →
может **замаскировать** баг этого класса. Переделать на transparent перед использованием.
---
## Критерии приёмки (не достигнуты — закрыто как не-репро, фикс не в этом расследовании)
1. ✅ ~~Инструментировать `connectionCopy`~~ — зонд написан, собирается, корректность перехвата
разобрана. Боевой прогон НЕ состоялся: баг исчез до прогона зонда.
2. ~~Прочитать снимок зонда~~ — неактуально, нечего читать (нет репро).
3. ~~Подтвердить точку застревания~~ — не подтверждена изнутри ядра.
4. ~~Фикс в `route/conn.go`~~ — не потребовался; симптом устранён фиксом §010 в lx.14 (вероятно).
5. ✅ Верификация на железе: на lx.14 на той же ноде/сети «висяков» нет.
## Метод диагностики (что сработало)
Синхронный двойной tcpdump `tun0`+`wlan0` ОДНОЙ командой (единые часы) от момента
SYN + 1-Hz поллинг `/connections`. Точечные несинхронные срезы вводят в заблуждение
(рассинхрон по времени — источник многих ложных выводов в ходе расследования).
@@ -0,0 +1,159 @@
diff --git a/route/conn.go b/route/conn.go
index 9fdc6cda..4914324e 100644
--- a/route/conn.go
+++ b/route/conn.go
@@ -259,6 +259,24 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial
}
func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn, destination net.Conn, direction bool, done *atomic.Bool, onClose N.CloseHandlerFunc) {
+ // lx ПРОТОТИП: под LX_CONN_TRACE считаем байты read(source)/write(destination)
+ // этого направления, чтобы развести место застревания (см. conn_trace_lx.go).
+ // Обёртки ставятся в copy-цепочку ТОЛЬКО под гейтом — вне его поведение и путь
+ // копирования не меняются.
+ var traceSrc, traceDst *lxTraceConn
+ if tick := lxConnTrace(); tick > 0 {
+ traceSrc = newLxTraceConn(source)
+ traceDst = newLxTraceConn(destination)
+ source = traceSrc
+ destination = traceDst
+ // Периодический тик: пока copy жив, раз в tick логируем текущие счётчики.
+ // Нужен для ВИСЯЩЕГО зомби — финальный снимок ниже выйдет только при
+ // возврате copy (т.е. при закрытии/отвисании), а тик показывает stuck
+ // в реальном времени. Останавливается при завершении горутины.
+ stopTick := make(chan struct{})
+ defer close(stopTick)
+ go m.lxTraceTicker(ctx, traceSrc, traceDst, direction, tick, stopTick)
+ }
_, err := bufio.CopyWithIncreateBuffer(destination, source, bufio.DefaultIncreaseBufferAfter, bufio.DefaultBatchSize)
if err != nil {
common.Close(source, destination)
@@ -293,6 +311,42 @@ func (m *ConnectionManager) connectionCopy(ctx context.Context, source net.Conn,
m.logger.TraceContext(ctx, "connection download closed")
}
}
+ // lx ПРОТОТИП: снимок счётчиков под LX_CONN_TRACE. read=байты, отданные
+ // source (для download — расшифрованный remoteConn); write=байты, принятые
+ // destination (для download — gVisor tun клиента). read=0 → застряло выше
+ // copy (proxy-расшифровка); read>0,write=0 → застряла запись приложению.
+ if traceSrc != nil {
+ dirName := "upload"
+ if direction {
+ dirName = "download"
+ }
+ m.logger.InfoContext(ctx, "lx-trace ", dirName, " final",
+ ": read=", traceSrc.lxRead(), " write=", traceDst.lxWrite(),
+ " err=", err, " timeout=", err != nil && os.IsTimeout(err))
+ }
+}
+
+// lxTraceTicker (lx ПРОТОТИП) логирует текущие счётчики раз в tick, пока copy
+// жив. Завершается, когда connectionCopy закрывает stop (defer close). Читает
+// атомарные счётчики обёртки — гонок нет. См. conn_trace_lx.go.
+func (m *ConnectionManager) lxTraceTicker(ctx context.Context, src, dst *lxTraceConn, direction bool, tick time.Duration, stop <-chan struct{}) {
+ dirName := "upload"
+ if direction {
+ dirName = "download"
+ }
+ ticker := time.NewTicker(tick)
+ defer ticker.Stop()
+ var n int
+ for {
+ select {
+ case <-stop:
+ return
+ case <-ticker.C:
+ n++
+ m.logger.InfoContext(ctx, "lx-trace ", dirName, " tick#", n,
+ ": read=", src.lxRead(), " write=", dst.lxWrite())
+ }
+ }
}
func (m *ConnectionManager) kickWriteHandshake(ctx context.Context, source net.Conn, destination net.Conn, serverFirst bool, direction bool, done *atomic.Bool, onClose N.CloseHandlerFunc) bool {
# ---- new file: route/conn_trace_lx.go (apply manually or copy) ----
package route
import (
"net"
"os"
"sync"
"sync/atomic"
"time"
)
// lxConnTraceDefaultTick — период тика по умолчанию, когда LX_CONN_TRACE задан
// булевым значением ("1"/"true"), а не длительностью.
const lxConnTraceDefaultTick = 5 * time.Second
// lxConnTrace читает LX_CONN_TRACE один раз и возвращает ПЕРИОД ТИКА:
// - "", "0", "false" → 0 (выключено: обёрток в copy-цепочке нет вовсе)
// - "1", "true" → дефолтный период (lxConnTraceDefaultTick)
// - длительность ("3s","500ms") → этот период
//
// Период > 0 включает и финальный снимок, и периодический тик во время
// копирования (см. lxTraceConn.runTicker) — чтобы видеть висящий зомби в момент
// зависания, а не только постфактум при закрытии.
//
// Назначение (lx, ПРОТОТИП): развести три места застревания download, когда
// pcap показывает "данные пришли в ядро, но не дошли до приложения":
//
// read=0 — source (remoteConn, уже расшифрованный reality/vless)
// не отдал НИ ОДНОГО байта → проблема выше по стеку
// (расшифровка / фрейминг proxy), а не в копировании.
// read>0, written=0 — байты прочитаны из remote, но запись в client (gVisor
// tun) заблокирована/упала → застряло именно тут.
// read>0, written>0 — копирование шло; смотреть на err/таймаут.
//
// Гейт env-флагом, чтобы в обычных сборках обёртки в цепочке не было вовсе
// (нулевой оверхед, путь copyDirect/splice не ломается).
var lxConnTrace = sync.OnceValue(func() time.Duration {
raw := os.Getenv("LX_CONN_TRACE")
switch raw {
case "", "0", "false":
return 0
case "1", "true":
return lxConnTraceDefaultTick
}
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
return d
}
// нераспознанное непустое значение → включаем с дефолтным периодом
return lxConnTraceDefaultTick
})
// lxTraceConn — прозрачная обёртка, считающая байты Read/Write на одном конце
// соединения. НАМЕРЕННО не реализует Upstream()/ReaderReplaceable()/
// WriterReplaceable(): иначе CopyWithCounters развернёт её через
// Unwrap*Reader/Writer и скопирует мимо счётчиков (zero-copy direct-путь), и
// мы потеряем как раз ту видимость, ради которой обёртка ставится. Цена —
// принудительно буферный путь копирования на время диагностики; для прототипа
// это приемлемо и включается только под LX_CONN_TRACE.
type lxTraceConn struct {
net.Conn
readBytes atomic.Int64
writeBytes atomic.Int64
}
func newLxTraceConn(conn net.Conn) *lxTraceConn {
return &lxTraceConn{Conn: conn}
}
func (c *lxTraceConn) Read(p []byte) (int, error) {
n, err := c.Conn.Read(p)
if n > 0 {
c.readBytes.Add(int64(n))
}
return n, err
}
func (c *lxTraceConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
if n > 0 {
c.writeBytes.Add(int64(n))
}
return n, err
}
func (c *lxTraceConn) lxRead() int64 { return c.readBytes.Load() }
func (c *lxTraceConn) lxWrite() int64 { return c.writeBytes.Load() }
@@ -0,0 +1,73 @@
# SPEC: 013 — PACKAGE_NAME_REGEX_RULE_ITEM
| Поле | Значение |
|------|----------|
| Тип | F (feature) — бэкпорт апстрим-фичи |
| Статус | C (complete) |
Добавить rule-item **`package_name_regex`** (route / DNS / headless) — матчинг имени Android-пакета по регулярному выражению. Точечный бэкпорт апстрим-фичи 1.14 на стабильную базу 1.13.13 **без** полной миграции на 1.14.
Scope: **все платформы** (фича активна там, где заполняется `ProcessInfo.AndroidPackageNames`, т.е. Android). Build-tag: нет — встроена в ядро роутинга.
> **Обновление (2026-07-02, база 1.14.0-alpha.35, аудит SPEC 022 #19):** после миграции базы на 1.14 сам impl (`route/rule/rule_item_package_name_regex.go` + поле `option.RawDefaultRule.PackageNameRegex`) стал **нативным upstream** — бэкпорт-дельта по коду больше не нужна и растворилась в базе. НО LX-тест `route/rule/rule_item_package_name_regex_test.go` **сохраняется намеренно**: upstream своего теста для этого item не поставляет (проверено на базе и на `upstream/testing`), так что это единственное покрытие фичи. Тест изолирован в своём `_test.go`, тестирует стабильный публичный API (`NewPackageNameRegexItem`/`Match`) и ребейз-конфликтов не несёт.
---
## 1. Проблема / контекст
Запрос (2026-06-23): нужен `package_name_regex` в проекте. У апстрима поле существует **только с sing-box 1.14.0** (commit [`941ce58b`](https://github.com/SagerNet/sing-box/commit/941ce58b) «Add `package_name_regex` route, DNS and headless rule item»), в ветке 1.13.x его нет. На нашей базе уже есть `package_name` (точное совпадение, map-lookup) — но не regex-вариант.
Полная миграция 1.13.13→1.14 оценена отдельным feasibility-разбором как ~1,5–2 дня работы с главным риском в ребейзе AmneziaWG-подмодуля `wireguard-go` (база 506b763 → v0.0.3, ветки diverged 52/51, ручная переинсерция §010 android-GRO fix). Сама же фича `package_name_regex` — изолированный add в `route/rule`, **не трогает** ни один awg/xhttp/selector/build-tag файл и **не гейтится** новым build-тегом. Поэтому выбран точечный бэкпорт, а полная миграция отложена до выхода **v1.14.0 stable** (её штатно подхватит существующий `lx-rebase.yml`, который по дизайну исключает alpha/beta/rc).
Апстрим-коммит `941ce58b` дополнительно содержит хунк про `C.RuleSetVersion5` в `option/rule_set.go` — это часть отдельного rule-set v5 (1.14), **не относится** к фиче и **не переносится** (на нашей базе `RuleSetVersionCurrent = RuleSetVersion4`).
---
## 2. Цель
Правило роутинга / DNS-правило / headless-правило (rule-set) с полем `package_name_regex: ["^com\\.termux.*", ...]` матчит соединение, если хотя бы одно из имён пакетов в `metadata.ProcessInfo.AndroidPackageNames` удовлетворяет хотя бы одному из выражений. Семантика и сообщения об ошибках — идентичны апстрим-1.14.
---
## 3. Требования
### 3.1 Новый rule-item
- Файл `route/rule/rule_item_package_name_regex.go` — дословно апстрим-версия из `941ce58b`: `PackageNameRegexItem` с `[]*regexp.Regexp`, конструктор `NewPackageNameRegexItem([]string) (*PackageNameRegexItem, error)` (компиляция через `regexp.Compile`, ошибка `parse expression <i>`), `Match` по `AndroidPackageNames`, человекочитаемый `String()` (усечение до 3 выражений в описании).
### 3.2 Option-поля
- `PackageNameRegex badoption.Listable[string]` с тегом `json:"package_name_regex,omitempty"` — сразу после `PackageName` в трёх структурах: `option.RawDefaultRule` ([option/rule.go](../../option/rule.go)), `option.RawDefaultDNSRule` ([option/rule_dns.go](../../option/rule_dns.go)), `option.DefaultHeadlessRule` ([option/rule_set.go](../../option/rule_set.go)). Выравнивание struct-тегов — под существующий столбец каждого файла (gofmt-чисто).
### 3.3 Регистрация item в правилах
- В `NewDefaultRule` ([route/rule/rule_default.go](../../route/rule/rule_default.go)), `NewDefaultDNSRule` ([route/rule/rule_dns.go](../../route/rule/rule_dns.go)), `NewDefaultHeadlessRule` ([route/rule/rule_headless.go](../../route/rule/rule_headless.go)) — блок `if len(options.PackageNameRegex) > 0 { ... }` сразу после `PackageName`-блока, с проброской ошибки `E.Cause(err, "package_name_regex")`. Все три конструктора уже возвращают `error` на нашей базе — сигнатуры не меняются.
### 3.4 Cond-функции
- `isProcessRule` / `isProcessDNSRule` ([route/rule_conds.go](../../route/rule_conds.go)) и `isProcessHeadlessRule` ([route/rule/rule_set.go](../../route/rule/rule_set.go)) — добавить `|| len(rule.PackageNameRegex) > 0`, чтобы правило с одним лишь `package_name_regex` корректно классифицировалось как process-rule.
### 3.5 Что НЕ трогаем
- Хунк `RuleSetVersion5` из апстрим-коммита (см. §1) — **не переносим**.
- Документацию апстрима (`docs/`) бэкпортить не обязательно; референс — официальная страница route/rule.
---
## 4. Критерии приёмки
- `go build ./...` без тегов и сборка с lx-тегами (`with_gvisor with_quic with_wireguard with_utls with_clash_api with_xhttp with_awg`) — ок. ✅
- `go vet ./route/... ./option/...` и `gofmt -l` по затронутым файлам — чисто. ✅
- Юнит-тест `route/rule/rule_item_package_name_regex_test.go` зелёный: матч префикса/якоря `$`, матч одного из нескольких пакетов, no-match, nil `ProcessInfo` (без паники), ошибка на невалидном выражении. ✅
- Ребейз-зона: фича — это новый файл + точечные правки в 6 файлах роутинга/опций; коллизий с awg/xhttp/selector/CI-кластерами нет (подтверждено feasibility-разбором).
---
## 5. Вне скоупа
- Полная миграция на 1.14 (отложена до v1.14.0 stable; отдельный feasibility-отчёт).
- Прочие 1.14-матчеры (`source_mac_address`, `source_hostname`, `preferred_by`, DNS response-matching), DNS-экшены `evaluate`/`respond`, rule-set v5, `tls_spoof`.
- Заполнение `AndroidPackageNames` на не-Android платформах (определяется upstream-логикой process-resolver).
---
## 6. Ссылки
- [Upstream commit 941ce58b](https://github.com/SagerNet/sing-box/commit/941ce58b) — исходная апстрим-фича (route/DNS/headless + docs + rule-set v5 хунк).
- [Документация route/rule#package_name_regex](https://sing-box.sagernet.org/configuration/route/rule/#package_name_regex) — «Match android package name using regular expression», since 1.14.0.
- Feasibility-разбор миграции 1.13.13→1.14 (этой сессии) — обоснование точечного бэкпорта вместо полного перехода.
@@ -0,0 +1,119 @@
# SPEC: 014 — CLASH_API_TO_COMMANDCLIENT_MIGRATION
| Поле | Значение |
|------|----------|
| Тип | F (feature) — смена канала управления ядром (client-side) |
| Статус | A (accepted) — `with_clash_api` drop из AAR в `v1.14.0-lx.1-rc.1`; box.go-фикс в `rc.3`; десктоп-регрессия исправлена в `rc.17` (§3.4) |
**Переезд управления ядром с Clash API на нативный libbox CommandClient — на Android.** LxBox перестаёт использовать Clash REST API и переходит на нативный gRPC-канал `StartedService` (поверх unix-сокета). Из **AAR-сборки** убирается `with_clash_api` — отпадает HTTP-сервер Clash и связанный attack surface. **Десктоп/CLI сохраняют `with_clash_api`** (внешние дашборды ходят по Clash REST API; нативного CommandClient-канала у CLI нет) — см. §3.4.
Этот SPEC фиксирует **сам переезд и его последствия**. Доработки command-протокола, понадобившиеся, чтобы CommandClient заменил Clash API по функциональности (per-node delay, таблица правил, pull-снапшоты групп, фикс потери групп), вынесены в отдельный **[SPEC 015 — COMMAND_PROTOCOL_RPC_EXTENSIONS](../015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md)**.
Scope: **client/command-сторона only** (§3.1 client-only).
---
## 1. Проблема / контекст
Графические клиенты sing-box исторически управляли ядром через два канала: Clash REST API (`experimental.clash_api`, под build-tag `with_clash_api`) и нативный libbox **CommandClient**. Clash API — это слой совместимости со сторонними дашбордами; для **своего** клиента на устройстве разработчики предполагают именно CommandClient (gRPC поверх локального unix-сокета).
LxBox переходит на CommandClient как единственный канал управления, потому что:
- это нативный, более богатый интерфейс (closed-connections история, per-event дельты соединений, ProcessInfo раздельными полями, NQ/STUN/Tailscale-инструменты) — то, что Clash REST не покрывает;
- Clash API — это лишний HTTP-сервер в процессе и открытый локальный порт (attack surface), не нужный, когда клиент ходит по нативному каналу;
- убрав `with_clash_api`, мы уменьшаем дифф и размер AAR.
---
## 2. Цель
LxBox (Android) управляет ядром **только** через CommandClient; `with_clash_api` не входит в **AAR-сборку**. Конфиг, ссылающийся на `experimental.clash_api`, fail-fast с понятной ошибкой (а не молчаливо деградирует). Функциональный паритет с Clash API по нужным UI возможностям достигается доработками CommandClient — см. [SPEC 015](../015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md).
> **Важно (исправлено):** дроп `with_clash_api` относится **только к Android AAR**. Десктоп/CLI-бинари (mac/windows/linux) управляются внешними дашбордами (yacd/MetaCubeXD) **именно через Clash REST API** — нативного CommandClient-канала вне gomobile/libbox у них нет. Поэтому `with_clash_api` **остаётся** в десктоп `LX_TAGS`. Изначально (rc.1) тег был ошибочно убран и из десктоп-набора тоже — см. §3.4.
---
## 3. Требования
### 3.1 Дроп `with_clash_api` — **только Android AAR**
- Убрать `with_clash_api` из `sharedTags` ([cmd/internal/build_libbox/main.go](../../cmd/internal/build_libbox/main.go), `// lx:`-блок). **Десктоп `LX_TAGS` (`Makefile.lx`) тег сохраняет** — см. §3.4.
- Без тега (в AAR) подключается `include/clashapi_stub.go` — конфиг с `experimental.clash_api` получает `clash api is not included in this build, rebuild with -tags with_clash_api` (fail-fast, **не** молчаливый отказ).
- lx-конфиги на Android `clash_api` не используют — управление идёт через CommandClient.
- Сделано в `v1.14.0-lx.1-rc.1` (commit `57b5b5e5`) — но изначально ошибочно срезано и с десктопа, исправлено в §3.4.
### 3.2 Доработки CommandClient → SPEC 015
Нативный CommandClient беднее Clash API по ряду возможностей, нужных UI (per-node delay-тест, таблица правил, pull-снапшоты групп/узлов, баг потери одно-узловых групп). Все эти доработки — **в [SPEC 015](../015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md)** (класс §3.6, build-tag `with_lx_command`). `URLTestOutbound` и `GetRules` уже зашиплены (rc.2); `GetGroups`/`GetOutbounds` + фикс `len<2` — target rc.4. Здесь они только упоминаются как часть полного перехода; тех-спека — в 015.
### 3.3 Follow-on фикс — Android start fatal от `with_clash_api`-дропа (rc.3)
Удаление `with_clash_api` (§3.1) сделало **любой старт на Android фатальным**:
`create clash-server: clash api is not included in this build` — **даже без**
`clash_api` в конфиге. Корень в апстрим-`box.go`: `PlatformLogWriter != nil`
(всегда на Android/libbox) форсил `needClashAPI = true` (Clash-сервер исторически
был единственным наблюдателем логов/трафика), а `needClashAPI` ведёт к
`NewClashServer()`. Десктоп не затронут (`PlatformLogWriter == nil`).
**Фикс (`box.go`, `// lx:` шов, commit `029acd11`, пререлиз `v1.14.0-lx.1-rc.3`):**
расщепить заботы — `PlatformLogWriter` взводит новый `needObservable`
(`= needClashAPI || needAPIService || PlatformLogWriter != nil`) для Observable
log factory + traffic/connection-tracker; **только** явный `experimental.clash_api`
по-прежнему взводит `needClashAPI` → `NewClashServer`. daemon уже nil-safe к
отсутствию `clashServer` → Clash-mode деградирует мягко. Проверено: стартует без
`clash_api`; всё ещё fail-fast с ним. **Device-verified** на реальном устройстве
(Android-старт работает на настоящем rc.3).
**WATCH — апстрим issue [SagerNet/sing-box#4240](https://github.com/SagerNet/sing-box/issues/4240)**
(подан как чистый upstream-репро, форк НЕ упомянут). Когда апстрим починит —
**снять наш `// lx:` шов в `box.go`** на следующем ребейзе.
> ⚠️ **#4240 УДАЛЁН** (по состоянию на 2026-06-26 GitHub API отдаёт `HTTP 410
> "This issue was deleted"`). Удаление ≠ fix и ≠ closed/not-planned — это НЕ
> сигнал «можно снимать шов». Issue-ссылка как критерий снятия больше
> непроверяема. **Новый критерий снятия шва:** сверять не статус issue, а сам
> upstream-код — починили ли `PlatformLogWriter`-путь, который форсил
> Clash-сервер (Android-старт без `with_clash_api`). До подтверждения в коде —
> живём на своём `// lx:` фиксе. (Аккаунт НЕ забанен: #3858/#3806 closed как
> `completed`, #4093 + PR #4094 живут; удаление #4240 — отдельная история, не
> бан.)
### 3.4 Фикс — `with_clash_api` ошибочно срезан и с десктопа (rc.17)
§3.1 в rc.1 убрал `with_clash_api` из **обоих** наборов тегов: и из `sharedTags`
AAR, и из десктоп `LX_TAGS` (`Makefile.lx`). Для AAR это верно (LxBox ходит по
CommandClient). **Для десктопа — ошибка:** mac/windows/linux-бинарь запускается как
CLI и управляется внешними дашбордами (yacd, MetaCubeXD, clash-dashboard)
**исключительно через Clash REST API** — нативного CommandClient-канала вне
gomobile/libbox у CLI нет. Без `with_clash_api` десктоп-юзер остался **без способа
управлять ядром**: конфиг с `experimental.clash_api` падает fail-fast.
Все релизные desktop/linux-musl сборки берут теги из
`make -f Makefile.lx -s lx-print-tags` ([lx-release.yml](../../.github/workflows/lx-release.yml)),
поэтому баг ушёл во все desktop-артефакты rc.1…rc.16 молча (CI-проверка
`lx-ci.yml BASE_TAGS` clash_api держала, так что компиляция была зелёной — баг
не виден в CI, только в релизном артефакте).
**Фикс:** вернуть `with_clash_api` в десктоп `LX_TAGS` (`Makefile.lx`). AAR-набор
(`build_libbox`) **не трогаем** — там дроп остаётся в силе. Так два набора тегов
расходятся **по дизайну**: desktop = с Clash API, AAR = без. Десктоп-сборки снова
управляются через Clash REST API из коробки.
---
## 4. Критерии приёмки
1. (✅ rc.1) Сборка без `with_clash_api`: компилируется; конфиг с `experimental.clash_api` → fail-fast с понятной ошибкой; lx-конфиги стартуют.
2. (✅ rc.3) Android стартует на сборке без `with_clash_api` (`PlatformLogWriter` не форсит Clash-сервер); десктоп не затронут; device-verified.
3. Функциональный паритет с Clash по нужным UI возможностям — критерии в [SPEC 015](../015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md).
4. Тронутые файлы переезда: `cmd/internal/build_libbox/main.go` (`// lx:`, дроп тега), `Makefile.lx` (LX_TAGS), `box.go` (`// lx:` шов §3.3).
---
## 5. Вне скоупа
- Тех-спека RPC-доработок CommandClient — в [SPEC 015](../015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md).
- Возврат `with_clash_api` ради delay-тестов (гибрид) — отклонён: паритет достигается нативно (SPEC 015).
---
## 6. Ссылки
- [SPEC 015 — COMMAND_PROTOCOL_RPC_EXTENSIONS](../015-COMMAND_PROTOCOL_RPC_EXTENSIONS/SPEC.md) — доработки CommandClient до уровня Clash-паритета.
- CONSTITUTION §3.5 (дистрибуция, `with_lx_command` в AAR), §3.6 (класс command-расширений).
- `include/clashapi_stub.go` (fail-fast при отсутствии тега); `box.go` (needClashAPI/needObservable).
- Память: `lx-commandclient-extensions-spec014`.
@@ -0,0 +1,144 @@
# Обратная связь клиента (LxBox) — §3.6 per-call cancel упирается в gomobile-биндинг
> **СТАТУС: РЕШЕНО ✅** (ответ ядра в конце файла). Слой 2 разблокирован на текущем
> биндинге `v1.14.0-lx.1` через **отдельный ping-`CommandClient` + `Disconnect()`** —
> правок нативной поверхности НЕ требуется. Файл сохранён как переписка запрос↔ответ;
> action item остаётся за LxBox: завести pingClient-инстанс и провести on-device verify.
**От:** LxBox (клиентская сторона)
**К:** команда ядра sing-box-lx
**Контекст:** SPEC 015 §3.6 «Отмена URLTestOutbound», слой 2 (отмена на клиенте)
**Статус:** ~~блокер слоя 2 — нужен caller-механизм отмены в биндинге~~ → решено (вариант #2)
## Спасибо за слой 1
Фикс ctx-bind (gRPC per-call `ctx` вместо `boxService.ctx`) принят и понятен:
отмена вызова теперь обрывает один in-flight тест на dial, не трогая остальные
стримы. Развенчание мифа про `cancelDelays` — полезно, сверились по proxies.go.
## Проблема: слой 2 не реализуем на текущем биндинге
§3.6 говорит: «per-call отмена должна приходить со стороны caller'а (LxBox/Dart —
per-call `CancelToken` / `call.cancel()`)». Но в **gomobile-биндинге AAR**
(`v1.14.0-lx.1`, проверено `javap` по `libbox.aar`) у `CommandClient` ровно одна
сигнатура теста, **без какого-либо cancel-параметра/handle**:
```
public native URLTestOutboundResult urlTestOutbound(String tag, String link, int timeoutMs) throws Exception
```
Других перегрузок нет; класса `CancelToken`/per-call-context в `io.nekohasekai.libbox`
нет (есть только `ExchangeContext` — для другого). Единственный рычаг отмены,
доступный caller'у — `client.disconnect()`, который рвёт **весь** CommandClient
(и все его подписки-стримы).
То есть «per-call `call.cancel()`» из §3.6 **на стороне клиента вызвать нечем** —
gomobile-обёртка не экспонирует отмену отдельного вызова. Слой 1 (per-call ctx)
готов в ядре, но «дёрнуть» этот ctx со стороны Java/Kotlin/Dart нельзя.
## Что это значит для нас прямо сейчас
Наша текущая масс-отмена — **epoch-гейт**: при отмене бампаем счётчик, воркеры
перестают применять результаты. Но `urlTestOutbound` — **синхронный блокирующий**
gomobile-вызов; in-flight dial в ядре **продолжается до своего таймаута**
(до ~10 «зомби»-тестов при concurrency=10). UI реагирует мгновенно, ядро — нет.
Это ровно тот разрыв, что слой 1 призван закрыть, но мы не можем его задействовать.
## Запрос к ядру — экспонировать отмену в биндинге
Любой из вариантов разблокирует слой 2 (в порядке нашего предпочтения):
1. **Cancellable per-call handle.** `urlTestOutbound` возвращает/принимает
объект с `cancel()` (или отдельный метод `cancelURLTest(callID)`), который
на Go-стороне отменяет per-call `ctx` именно этого вызова. Самый точный —
per-node cancel без сноса клиента.
2. **Отдельный «one-shot» CommandClient под ping.** Поддержать дешёвый
short-lived client (или гарантировать, что `disconnect()` на ОТДЕЛЬНОМ
ping-client рвёт только его вызовы, не общий `c.ctx`). Тогда клиент держит
pingClient ≠ statusClient/screenClient/profilerClient, и `disconnect()` его
= массовая отмена всех ping-dial без вреда другим стримам. Работает без
per-call гранулярности, но снимает «зомби». **Это наш fallback, если #1
дорог** — мы можем поднять отдельный ping-client сами, нужна лишь гарантия,
что его `disconnect()` доходит до per-call `ctx` тестов (а не виснет на
уже-ушедшем в Go dial).
3. **`CancelToken`-параметр** у `urlTestOutbound` (gomobile-совместимый тип),
который Go оборачивает в дочерний `ctx`. Симметрично Clash `r.Context()`.
Вопрос к ядру: **рвёт ли `CommandClient.disconnect()` уже-ушедшие в dial
per-call тесты** (через отмену их `ctx`), или dial доживает до timeout
независимо от disconnect? От этого зависит, годится ли вариант #2 без правок
ядра. Если `disconnect()` корректно отменяет per-call ctx — мы реализуем #2
сами (отдельный ping-client), и доработка биндинга не нужна.
## Сводка
- Слой 1 (ctx-bind) ✅ — спасибо.
- Слой 2 (client-side cancel) ⛔ — заблокирован: gomobile-биндинг не даёт
caller'у отменить вызов кроме `disconnect()` всего клиента.
- Нужно от ядра: либо per-call cancel-handle (#1/#3), либо подтверждение, что
`disconnect()` отдельного ping-client отменяет per-call ctx тестов (#2 — тогда
делаем сами).
Референс клиента: `app/lib/controllers/home_controller/ping_orchestration.dart`
(`runMassUrltest`, epoch-гейт), `BoxCommandClient.kt:265` (`urlTestOutbound`
синхронный), `cc_channel.dart:177` (`ccUrlTestOutbound` через MethodChannel).
---
# Ответ ядра — вариант #2 работает БЕЗ правок биндинга
**От:** команда ядра sing-box-lx
**К:** LxBox
**Дата проверки:** против `experimental/libbox/command_client.go` + `common/urltest/urltest.go` (HEAD ветки lx-1.14)
**Итог:** ваш блокер снимается вариантом #2 — отдельный ping-`CommandClient` + его `Disconnect()`. Правка биндинга (#1/#3) НЕ нужна для устранения «зомби». Разбор ниже.
## Прямой ответ на главный вопрос
> «Рвёт ли `CommandClient.disconnect()` уже-ушедшие в dial per-call тесты?»
**Да.** Цепочка проверена по коду:
1. `Disconnect()` ([command_client.go:295](../../experimental/libbox/command_client.go)) делает ДВЕ вещи: `c.cancel()` (отменяет общий `c.ctx`) **и** `c.grpcConn.Close()`.
2. После слоя 1 серверный хэндлер привязал тест к gRPC **per-call** `ctx` (`testCtx := ctx`, [started_service_command_lx.go](../../daemon/started_service_command_lx.go)). Обрыв клиентского вызова/транспорта → gRPC-Go рантайм отменяет серверный stream-ctx этого вызова (стандартный `grpc.NewServer`, никакой обёртки, отвязывающей ctx, нет — [daemon/server.go:16](../../daemon/server.go), [command_server.go:164](../../experimental/libbox/command_server.go)).
3. Этот ctx течёт в `urltest.URLTest(testCtx, …)` → в **оба** ctx-aware этапа: `detour.DialContext(ctx, …)` (TCP/proxy connect+handshake) и `client.Do(req.WithContext(ctx))` (HTTP HEAD) — [urltest.go:99,127](../../common/urltest/urltest.go). Отмена ctx обрывает уже-ушедший dial, не дожидаясь `C.TCPTimeout`.
Итог: dial **НЕ** доживает до timeout независимо от disconnect — он падает по отмене ctx. «Зомби» закрываются.
## Но: рвите ОТДЕЛЬНЫЙ ping-client, не общий
Нюанс, который надо учесть. `Disconnect()` через `c.cancel()` отменяет **общий** `c.ctx` ([command_client.go:32-33](../../experimental/libbox/command_client.go)) — один на ВСЕ вызовы этого инстанса. Если дёрнуть `Disconnect()` на вашем основном client'е, оборвутся и Connections/Groups/Status-стримы. Поэтому:
**Держите ОТДЕЛЬНЫЙ `CommandClient`-инстанс под масс-пинг.** Каждый `NewCommandClient` ([command_client.go](../../experimental/libbox/command_client.go)) поднимает СВОЙ `c.ctx`/`c.cancel` и СВОЙ `grpcConn` (`Connect()`/`ConnectWithFD` — [:239](../../experimental/libbox/command_client.go), [:266](../../experimental/libbox/command_client.go)). Значит:
- `pingClient ≠ statusClient/screenClient/profilerClient`;
- `pingClient.disconnect()` отменяет только per-call ctx ping-тестов, рвёт только ping-conn;
- остальные стримы целы.
Это **ровно ваш fallback #2**, и он реализуем на текущем `v1.14.0-lx.1`-биндинге как есть: `urlTestOutbound` уже экспонирован, `disconnect()` уже экспонирован. Новой нативной поверхности не требуется.
## Почему epoch-гейт сам по себе не закрывал зомби (и почему теперь закроет)
Ваш epoch-гейт гасит **применение** результатов на стороне UI, но `urlTestOutbound` — синхронный блокирующий gomobile-вызов, и до слоя 1 серверный тест был привязан к `boxService.ctx` (жил, пока жив сервис) — отменить его было нечем, кроме сноса всего client'а. Теперь, при отдельном ping-client: epoch-бамп (мгновенный UI) **+** `pingClient.disconnect()` (рвёт серверные dial'ы) = и UI, и ядро реагируют. Воркер-пул после disconnect просто получит ошибки на оставшихся вызовах — их и так гасит epoch-гейт.
Практически: при «отмене» зовите `pingClient.disconnect()` и поднимайте свежий `pingClient` под следующий прогон (либо реконнект того же). Стоимость — один short-lived conn на прогон масс-пинга, дёшево.
## Про варианты #1 / #3 (per-call handle / CancelToken)
Не отвергаем, но считаем **избыточными** для вашей задачи:
- #1/#3 дают гранулярность «отменить ОДИН узел из батча, оставив остальные». Для масс-отмены («отменить весь прогон») это не нужно — disconnect ping-client'а гасит весь батч разом.
- Цена #1/#3 — новый stateful слой в gomobile-поверхности (handle-реестр / `CancelToken`-тип, биндинг, версионирование AAR). Это та самая «новая подсистема», которую SPEC 015 §3.6 просит не плодить.
- Если позже появится UX «перепинговать только этот узел с отменой» — вернёмся к #1. Пока YAGNI.
## Что нужно от вас для подтверждения
Проверьте на устройстве сценарий: запустить масс-пинг (concurrency=10) на медленных/недоступных узлах → нажать «отмена» → убедиться, что (а) серверные dial'ы рвутся в пределах ~момента, не висят до `C.TCPTimeout`; (б) Connections/Groups-стримы основного client'а не мигают/не пересоздаются. Если (а) не подтвердится — пришлите лог, копнём транспортный слой конкретного outbound (теоретически отдельный outbound мог бы игнорировать ctx в своём dial — но `urltest.URLTest` зовёт его правильно).
## Сводка ответа
- Вопрос #2 (disconnect рвёт per-call ctx тестов) — **ДА**, проверено по коду. Реализуйте #2 сами.
- Условие: **отдельный** ping-`CommandClient`-инстанс (свой `c.ctx`/`c.cancel`/conn — уже так устроено), чтобы disconnect не задел другие стримы.
- Правки биндинга (#1/#3) — не требуются; держим #1 в запасе под будущий per-node UX.
- Слой 2 разблокирован на текущем биндинге. ✅

Some files were not shown because too many files have changed in this diff Show More