Repository navigation
feat(tollwallet): recover crashed swaps on wallet load — wire ResumePendingSwaps (#497 resume half, #703 item 1) - #793
Amperstrand wants to merge 48 commits into
Conversation
ec8f755 to
13f97e5
Compare
|
@felixfelix-bot — requesting cross-identity approval for this PR (#703 work item 1, the #497 resume half). STACKED on #780 (base is its branch; retarget after it merges). What: One wiring call — Why: #703's map — the resume half of #497 was built-but-unwired (zero call sites, same dead-code shape as the NUT-13 guards): value was durable but never recovered. Evidence: end-to-end crash-window test under -race (stub mint fails the first swap answer — client-side indistinguishable from a drop after processing; next wallet load replays the intent and restores the balance, ≥2 mint swap calls, no second spend); mint-down test pins load-completes + intent-kept with a discriminator bound (16s pre-existing LoadWallet baseline vs ≥30s synchronous replay — baseline verified with the wiring stashed). Full tollwallet module -race green; battery green except the two pre-existing timing failures documented since #728. |
|
[release support 2026-10-09] gonuts-tollgate
|
…scovery, fenced duplication, the two-era policy (#796) (#799) The gonuts pin (#791), the port table (#746), and the #796 audit all have one shape: a fact written in more than one place. AGENTS.md now states the rules that prevent the class instead of enumerating the values that drifted: one declaration per fact, discovery by glob (never a count in prose), manifest truth for externals, behavioral fences with planted-drift verdicts, generators that write every copy. Plus the bridge vocabulary and its single Go declaration site, the nft-fragment port-fencing rule, the apk/ipk era probe (never release-string parsing), the 25.12-primary / 24.10.8-frozen support policy, and the cross-repo-halves contract. Co-authored-by: pr-train-lane <lane@opencode>
#787) The SDK-free .ipk lanes installed the GPL-3.0 text at /usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane (packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the feed's license manifest, not the payload — so the same version shipped different contents per format, and this ~35 KB file was the lone content delta blocking byte-equivalence between the lanes. Decision, on compliance grounds: both formats ship the text. GPL-3.0 distribution reaches the recipient together with the binary — the conservative reading the issue itself names — and the metadata (PKG_LICENSE) and the shipped text are complementary, never either/or. The cost is ~35 KB on any router. - packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to /usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same payload path the .ipk lanes use. - Build/Prepare now REQUIRES the LICENSE (the copy was best-effort `|| true`): a GPL build that cannot find its license fails loudly instead of silently shipping a binary without it. - assert-artifact-contents.sh (run on every lane's real artifact in CI) hard-fails any package — .ipk or .apk — whose payload lacks the file, and reports its byte size. - tests/packaging/license-both-lanes_test.sh pins every staging surface (Makefile install + hard requirement, local-build-ipk, the CI ipk lane), path agreement across lanes, the metadata staying intact, and the artifact gate staying hard; wired into test.yml. - The two artifact-building fixture suites stage the real LICENSE so their synthetic .ipks match a real one (the new gate caught them immediately — the invariant works). Verified with a synthetic artifact both ways: with the license the harness PASSes (35593 bytes), without it the hard FAIL fires (exit 1). Packaging suite green except apk-install-resolution (docker-gated, fails identically on pristine main). Fixes #751
|
CHANGES-REQUESTED — the mechanism is fund-safe and the wiring site is the right one (I verified every wallet-construction path funnels through it), but this PR repeats, in three places, the exact unqualified recovery claim #771 was just called on — and per the #665 addendum's carrier question this PR should become the sole carrier of the resume feature, which makes fixing that claim before merge non-negotiable. Fund-safety audit (same lens as the #771 review). The brick-class questions come back clean, and most of the substance is inherited rather than new: the replay mechanics live in the fork, and your base is #780's pin (gonuts v0.13.1), which contains The wiring site is the strong half of this PR. I audited call sites:
Freeze compatibility: stop-ship-shaped — this is the P0 feature's landing vehicle and the design is right — but land order is #780 → this PR with the scoped claim in fragment and comment, and one green crash-injection lane run against this head (plus the concurrent-traffic scenario) as the merge condition. |
|
Review items applied — head
Verified after the edit: Item 4 (concurrent-traffic scenario + lane run on this head) cannot run from this host — no usable docker daemon here (the crash-injection lane needs it) and this is not the ai-legion rig. The merge condition is stated in the PR body; the lane owner needs to run |
b9b47c9 to
1a8a1ba
Compare
|
Scoped-claim check (author-side, review item 1): the branch already carries the full scoping — remaining gap was body-only, now fixed there too. Head unchanged: b9b47c9. Verified on the current head (b9b47c9, the 18:38
Delta applied now (PR body only — no tree change): the two remaining unqualified instances are scoped in place — the "What" section's "mints sign deterministically" parenthetical and the fund-safety note's "returns the same signatures" now both carry the window boundary with a pointer to the scoping section, and that section's closing sentence reads "fragment, the call-site comment, and the body's claims now all carry this scoping." For the parallel docker lane: behavior-tested SHA is b9b47c9 — still the head; the race the lane coordination feared doesn't materialize because nothing in the tree moved. Fragments 18/18 + duplicates green on this head. |
1a8a1ba to
1c51f8c
Compare
|
Rebase onto the repinned base @ head
Remaining merge condition per the review: the crash-injection lane run against this head ( |
…l time (#827) A router whose root password was unset received a GENERATED credential, printed exactly once. Its only other copy went to /tmp/tollgate-setup.log -- tmpfs, and truncated at the start of every full-setup run -- so an install or upgrade erased the record. "Shown once" is only safe if there is a way not to need it. Two changes: - The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the package manager with no controlling terminal, so the environment is where a choice can arrive; this is also the mechanism a fleet or CI provisioning script uses. The value is applied to root, never echoed to the output or the setup log, and replaces an existing credential when supplied. If it does not take, the gate fails closed and drops the admin listeners rather than leaving the board on the old credential. - A credential this package GENERATES is now marked provisional by /etc/tollgate/admin-credential-provisional (no secret in it -- the file is world-readable). The marker is (a) the signal the config UI reads to force a password change at first login and (b) the reason a later setup run re-states the recovery path instead of implying the operator chose it. A locked root account and an unreadable shadow stay exempt: neither is a state this package should decide to unlock. Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own harness, per repo doctrine). RED observed first: 5 failures against the pre-change script with the 77 existing assertions passing. Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
|
Rel-verify: cloud-lab crash-injection lane on #793 head Head note: the branch moved mid-lane with a change that is not docs-only — the rebase repinned gonuts-tollgate v0.13.1 → v0.14.0 (NUT-13 keyset guard, bolt counter hardening, the missed fifth go.mod). The full battery therefore ran twice: against
Scratch-tree disclosures (lane-local, none part of the PR): (1) the #728 Not covered by this run: the standard cloud-lab pytest suite (rel-771 already ran it against the shared mechanism; its pre-existing failure set is documented there), mint/melt crash windows (fork-level, out of scope), QEMU/router-surface behavior, and the gated lanes. Open item for review: the (a)-shape reconcile-vs-replay ordering above — it is adjacent to this PR's merge condition rather than inside it (the stock lane and the concurrent-traffic scenario both pass), but it is a real customer-entitlement regression introduced by moving recovery to a background goroutine, and it reproduces deterministically on both heads. Cheap deterministic repro: rel-771's scenario-(a) invocation against this branch. |
|
Rebase-proofing applied — head |
|
Correction to the comment above (raced with a parallel push): the rebase-proofing landed independently — head |
1c51f8c to
a696660
Compare
|
CLEAN — this is the right shape for the resume half of #497: the wiring is one background pass off the boot critical path, the recovery claim is scoped honestly to the NUT-19 window, the pin is the real v0.14.0 tag, and both new tests pass under I audited this with the same lens as my turn-1 review of #771, and every finding from that review is addressed here: the NUT-19/60s-TTL scoping (with the #497 comment-2 correction cited by name in tollwallet.go — including the honest note that the quick-restart harness always replayed inside the TTL), the journaled-visible-not-lost framing with the NUT-09 fallback tracked as the fork-level follow-up, the boot-balance reconciliation note, and the pin. I also re-verified the anti-brick properties at the pinned tag specifically (my earlier fork audit was at the pre-tag
Residuals, all tracked and correctly out of scope: mint/melt intents (fork #33), the NUT-09 restore fallback beyond the cache window, the sidecar path (daemon-owned). The changelog fragment follows the convention, links the PR, and its boundary paragraph is the honest-scoping model I'd hold up for every fund-safety entry from now on. Lane status note: the crash-injection acceptance ( Freeze compatibility: exactly stop-ship — this is the fund-safety P0 of the batch, the design is the CDK-shaped one, and the residual risk (the >60s window) is now documented and journaled rather than silent, which is the honest ceiling for this release. |
|
Addendum to the rel-verify comment above — the (a)-finding's race is now proven both ways by intervention (same head The (a)-shape failure was inferred to be an ordering race between the merchant's boot NUT-07 reconcile (checkstate) and
Identical crash shape, driver, and head — ordering is the only variable, and it flips the outcome. That also validates the fix direction empirically: sequencing the receive-intent reconcile after the resume pass for intents whose proofs the wallet holds pending swaps for converges the customer with no other changes (the intervention run's customer got exactly the (b)-shape treatment: owed → granted → AUTH). |
|
Lane head-record: |
a696660 to
4a7a0d9
Compare
|
Verdict addendum — CLEAN → CHANGES-REQUESTED (one change, empirically validated by the rel-verify lane's Appendix B intervention): sequence the boot receive-intent reconcile after the resume pass for intents whose proofs the wallet holds pending swaps for. My original verdict below stands for everything it examined — the wallet-level wiring, the anti-brick properties at the pinned tag, the tests — but it reviewed the PR as a wallet change. The crash lane proved it as a system change, and at the system level the current head ships a customer-harming race in the natural boot ordering. The lane's both-ways intervention is the cleanest possible proof: identical crash shape, head and driver, with only the boot ordering flipped — checkstate-first (natural, 3/3) resolves the crash intent Requested change (the lane's validated direction, restated as the review ask):
Everything else in my original review is unchanged — including the merge condition that #771 closes as superseded. The new merge conditions are: the sequencing change above, the concurrent-traffic scenario the lane already names, and one lane run against the final head. This remains the right shape and the right stack (#780 → #793); it just needs its two halves to boot in the right order. |
|
Lane head-record: |
…swap replay — no abandonment on pre-replay evidence (#793 review) The crash lane proved the race both ways (rel-verify Appendix B intervention, head 1c51f8c): with a pending swap journaled for an intent's proofs, the NATURAL boot order runs the merchant's NUT-07 checkstate before the wallet's background replay has re-POSTed — the mint answers unspent, the intent abandons, the customer keeps a dead note with no session while the operator's replay recovers the value moments later; flip only the ordering and the same crash converges owed -> granted -> gate AUTH. The AGENTS.md #258/#403 class, resurfacing as a boot-time interleave between the two halves of #502's record. The fix is deterministic sequencing, not a timing hope: TollWallet's boot replay pass now exposes its completion (BootSwapReplayDone, closed exactly once — immediately when no intents are journaled; the pass is a finite loop of 30 s-timeout calls, so completion is bounded by construction), forwarded through GonutsWallet, and the reconcile awaits it BEFORE asking the mint — an unspent answer is only stable evidence once no replay can spend the proofs under inspection. Boot itself never waits on the channel; only the already-async reconcile does. Wallets that self-resume at open (cdk) or own their intents (sidecar) do not implement the seam and keep prior behavior. The evidence still decides: a genuinely unspent proof set after the pass still abandons (beyond-TTL correctness preserved — no false owed is possible, the resolution remains checkstate-derived). Tests (receive_intent_replay_race_test.go): the sequencing matrix — reconcile blocks while the pass is in flight (zero checkstates), sees spent post-replay, resolves owed and converges the session; replay-done + genuinely unspent abandons; no-seim wallet decides immediately. 3/3 and the full intent family 7/7 under -race; src/tollwallet suite green under -race; vet/gofmt clean. TestLateReceiveOutcome...WhenReceiveComp- letesAfterTheDeadline fails identically on the PRISTINE 4a7a0d9 head (named pre-existing, not touched by this change).
setup_private_network derives network.private (lan/24 ±1) at uci-defaults time, before a fresh DHCP WAN necessarily has a lease, so an upstream that lands on the same /24 as the derived private network — the observed 192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then answers for the upstream gateway's own address and DNS breaks on the deployed router. Add recheck_private_subnet (no-op unless the private /24 now overlaps a known upstream) and call it from the postinst AFTER `/etc/init.d/network restart`, reloading the network only when the private subnet had to move. Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
…tall boot (#940) * fix(tollwallet): bound each mint registration so a slow mint cannot stall boot The wallet already registers only the mints the startup probe found reachable, but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a mint that accepts the connection and never answers can stall the whole wallet construction — the module's own notes measure 113-347 s for 7 mints. That outlasts the installer's health window and made a healthy install report 'the merchant is degraded'. registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN client (8 s). A mint that does not answer is left UNREGISTERED rather than blocking boot: ensureMintRegistered retries it on first use and the reachable-set callbacks admit it when it recovers. Registration stays sequential, so gonuts is never called concurrently. Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected; outruns the bound -> rejected). tollwallet + merchant modules green. * review: reword the bound's guarantee, state the residual, add a defer->recover test Addresses the independent review on the fork PR: - The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are separate exchanges, so the bound makes a hang UNLIKELY rather than impossible; the real closure (a goroutine-with-timeout around AddMint, or registration off the boot path) is noted as a follow-up. - States the residual worst case explicitly: one registerMintKeysetTimeout per reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new first-use latency for a deferred mint (a bounded <=8 s inline probe on the first payment routed to it; latency, not correctness). - Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left unregistered, and ensureMintRegistered (the path Receive takes) registers it once it recovers — pinning the integration claim through the real gonuts wallet, not just the probe unit. --------- Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping. Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward.
…se) (#953) Record the root cause behind the 2026-10-16 'Setup failed' incident: the installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for 127.0.0.1, and the limiter runs before requireStarted, so the probe can get 429 {'error':'rate limit exceeded'} instead of the readable 503 {'code':'starting'} refusal. The installer side is worked around in rc33. The root-cause fix (exempt loopback from the limiter) is DEFERRED until after the current release — no new module RC in this line. Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com>
…ould not examine (#963) The sweep fetched the PR head as `origin pull/<N>/head` and skipped any failure with a bare `continue`, while incrementing the total *before* the skip. `pull/<N>/head` is served only by the repo that HOSTS the PR, so in the layout this repo is commonly checked out in (`origin` = personal fork, `upstream` = OpenTollGate) every head fetch fails — and the tool printed no conflicts: 90 open PRs are all merge-clean vs origin/main exit 0, having examined nothing. That is the same blind spot the sweep was written to close (#899 sat conflicting unnoticed), reproduced while looking like coverage. `origin/main` can also trail `upstream/main`, so conflicts were measured against the wrong base. Fix: - the remote is a knob: `--remote <name>` / `CONFLICT_SWEEP_REMOTE`, default `upstream` (the PR-hosting repo), validated up front with a clear error; - fetch the head from that remote and, when it fails, COUNT the PR as unexamined and name it instead of dropping it silently; - never print a success sentence over unexamined PRs: the summary reports `<examined> of <total>`, and zero examined exits 1 with "refusing to report an all-clear"; - a partial read also exits 1, so CI cannot mistake a blind sweep for green. Measured on the same checkout, same queue: before -> "no conflicts: 90 open PRs are all merge-clean" (exit 0) after -> "conflicts: 5 of 90 examined open PRs conflict with upstream/main" (finds #764, #856, #793, ...) after, --remote origin -> "no PRs examined: 0 of 90 ... refusing to report an all-clear" (exit 1) Co-authored-by: Felix <felix@tollgate.me>
* fix(upstream): select and blacklist upstreams by BSSID, not SSID One SSID can be served by several BSSIDs across different upstream networks — some routed to the internet, some captive or isolated. The manager keyed its blacklist by SSID and never pinned the scanned BSSID on the candidate, so a router that settled on a BSSID with no internet blacklisted the whole SSID, including any working sibling, and could never recover. The ICMP-only connectivity check could not tell "gateway but no internet" from "ok" either. - Blacklist by BSSID (falling back to the SSID only when it is unknown), skip candidates by BSSID, and blacklist the exact failed BSSID on an emergency switch. A sibling BSSID of the same SSID stays eligible. - Thread the candidate BSSID through SwitchUpstream and pin wireless.<iface>.bssid, clearing a stale pin when the candidate carries none. Manual CLI connect pins the strongest scanned BSSID for the SSID. - Replace the ICMP-only probe with a layered one — default route, DNS resolution, HTTP 204 captive-portal check — returning a reason (no-route/no-dns/captive/no-http). Used by the startup check, the main loop and post-switch verification. Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe classification. make go-battery green across all 16 modules. * changelog: fragment for #910 (BSSID-aware upstream selection) --------- Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com>
* ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment Router-test is the TBMG Trust Pipeline entry point. Two changes: * Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on hermes-nvme) over the private fips mesh with its dedicated per-machine key — no per-repo secrets and no metered LAN traffic. * Add a report job that posts ONE idempotent verdict comment per PR (marker <!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork PRs (which must never run on a self-hosted runner) are reported as "not tested" instead of silently skipped. The security contract is preserved: PR -> isolated lab; bench only on main or an explicit dispatch behind bench-hardware. * docs(changelog): fragment for the TBMG Trust Pipeline (#898) * ci(router-test): dispatch the bench lanes on the gateway Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary). * ci(ngit): mirror the router-test trust pipeline into the ngit twin Land the trust pipeline in the ngit execution twin too (dual setup): same plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the verdict is published by ngit-ci to Nostr, so the report job just summarises). --------- Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com>
…taged for (#959) * feat(wireless): wire the vendor-IE price producer the scan boundary staged for PricePerStep/StepSize were declared across four data models (NetworkInfo, Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the VendorIEDiscovery flag whose consumer did not exist. Wire it: - scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and route network assembly through a new buildNetworkInfo(), which calls the existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the pricing fields. Remove the staged comment. - vendor_element_manager.go: add pricing TLVs (0x03 price_per_step, 0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the IE can carry a price hint. - types.go: TollGateAdvertisement gains PricePerStep/StepSize. Authority is unchanged and now documented in-code: the IE is an UNSIGNED hint via OUI identification that selects a candidate for the signed kind-10021 probe on :2121, which remains the authority for price. A zero pricing value means 'not advertised here', never 'free'. Pricing TLVs are dropped (truncated=true) rather than overflowing the 255-byte element or pushing out the pubkey, because identification outranks the price hint. Tests (observed RED before the change, GREEN after): - TestParseIwinfoOutput_PopulatesVendorIEFields - TestParseIwinfoOutput_NonTollGateIEIgnored - TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs - TestParseIwinfoOutput_NoIEMeansNotTollGate - TestParseIwinfoOutput_MultipleNetworksAndIEs - TestVendorIE_RoundTripPricing (+4 subtests) - TestVendorIE_PricingDroppedRatherThanOverflow Full wireless_gateway_manager suite green, including the five pre-existing vendor-IE tests (no regressions). * fix(wireless): parse the vendor element from the format the router actually emits Hardware verification on tollgate-A04E (2026-10-16) falsified the first implementation's assumption. Measured: - `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:' returned 0), so it can never yield IsTollGate via the vendor element. It does report hidden APs, as `ESSID: "unknown"` — a literal placeholder. - `iw dev <dev> scan` is the ONLY element source, and it prints elements DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a hex blob. A vendor element appears as `Vendor specific: <colon-hex>`. - iw strips the element ID (0xDD) and the length byte from that body. Feeding the body straight to ParseVendorIEsFromScanData returns 0 elements: measured as 36 good bytes in, 0 advertisements out. The body must be re-framed with its 0xDD + length prefix. Change: - ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN - Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a stream carrying either parses. - SSID parsing handles both keys; RSN supplies encryption for iw output (it has no `Encryption:` line); `freq:` supplies the channel. - The vendor element is re-framed before parsing. - Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a literal cannot become a selectable upstream. Tests rewritten against the measured format: TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored, TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate, RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}. Three failed against the raw-body bug and pass with the re-frame; the full wireless_gateway_manager suite is green with no regressions. * fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config. With that wired, the scan path can finally populate the fields the vendor element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the design: 1. `iwinfo <dev> scan` prints NO information elements, so it can never identify a TollGate by vendor element. It is kept because it reports hidden APs (as ESSID "unknown") that iw can omit. 2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery is enabled, scanRadio runs it as well and mergeScanResults folds the two views by BSSID, letting the element-derived fields (IsTollGate, RawIEs, price, step) come from iw while iwinfo still contributes entries iw lacks. iw is best-effort: a failure or empty result keeps the iwinfo view rather than failing the scan. * test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring Coverage on the newly added code was incomplete: mergeScanResults and scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the join that lets element-derived fields come from iw while iwinfo-only entries (notably hidden APs) survive, so an untested merge was the largest risk in this change. Added: - mergeScanResults: enriches an existing BSSID from the second view; keeps entries only one view saw; fills gaps (signal/encryption); is the identity when the second view is empty. - scanRadioWithIW: a failed iw invocation returns nil rather than propagating an error, so the caller keeps the iwinfo view. - NewScanner/SetVendorIEDiscovery wiring. Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles against the repo's own module graph (the nested scanner module is reached via the existing replace directives) instead of a hand-rolled go.mod. Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry 100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%, buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%. * docs(wireless): record the cross-build target defect found on hardware The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected it with 'syntax error: unexpected end of file'. Its ELF header read 02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64). The sha256 matched the local file exactly, so the transfer was correct — the build target was wrong. Adding CGO_ENABLED=0 produced a proper statically linked aarch64 binary. The check that would have caught it in one line, now recorded for reuse: head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64 Size and sha256 are NOT evidence of the right architecture; both matched while the binary was unrunnable on the target. * feat(demo): add --file mode and record the on-router demo evidence The demo could only run 'iw dev <dev> scan' live. Where no neighbour running this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of a vendor element, never the positive identification path. Adding '--file <path>' lets the parser be run against a saved scan, so a synthetic element can be injected into REAL captured router output. Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2, aarch64_cortex-a53), 2026-10-16: A. live scan: iw dev phy0-ap0 scan: 28736 bytes, err=<nil> Vendor specific: lines present = 0 parsed networks = 12 identified TollGates = 0 (correct: no neighbour runs this build) B. real scan + one injected TollGate element: read /tmp/injected-scan.txt: 35168 bytes Vendor specific: lines present = 1 parsed networks = 16 TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0 price_per_step=7 step_size=1000 rawIE_bytes=38 identified TollGates = 1 -> the parser located the element inside genuine scan data and recovered the encoded price/step exactly. The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced the unrunnable x86-64 binary recorded in the previous commit. * fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16). With the original ordering a pubkey could pass the fit check and land the body at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard fired, and the pricing was silently dropped while the element still reported success. A router whose pubkey drifted large would appear to advertise no price — indistinguishable from a router that advertises none. Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing loud error path ('vendor IE body too long') instead of silently losing the price. The length octet is also clamped explicitly with truncated=true, so a wrap can never produce a malformed element silently. Tests (observed RED against the original ordering, GREEN after): - TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit') - TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey) - TestEncode_LengthOctetNeverWraps (octet == body length) Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean. MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field checked as 02 00 b7 00 (aarch64) before any transfer. Credit: this defect was surfaced by the cross-family lane, not by me. * refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one Follow-up from a cold cross-family review of PR #959 (deepseek lane, nit Q5). NetworkInfo.PricePerStep shared a name with PricingOption.PricePerStep, which IS authoritative (built only from a signature-verified kind-10021 event, and the only field the money path spends). The shared name is what caused the v1 review to raise a false blocker: from the excerpt it could not tell which field was authoritative. Renamed on the scan/display side only: NetworkInfo.PricePerStep -> NetworkInfo.AdvertisedPricePerStep NetworkInfo.StepSize -> NetworkInfo.AdvertisedStepSize DiscoveryEntry / KnownTollGate / cli.UpstreamNetwork likewise. NOT renamed (deliberately, the authoritative/money-side fields): config_manager.MintConfig.PricePerStep tollgate_protocol.PricingOption.PricePerStep TollGateAdvertisement.PricePerStep (the IE encoder's own field) JSON wire keys are UNCHANGED (price_per_step, step_size) - a documented operator-facing contract (docs/operator-guide.md) - so this is a Go identifier change only and is wire-compatible. The field comment now states the authority boundary explicitly, so the next reader does not have to reconstruct validator.go -> session.go to learn that a zero here means "not advertised", never "free". Verified: whole tree builds, go vet clean, wireless_gateway_manager and cli test suites green, no NetworkInfo-side references to the old names remain, and the money path is untouched. * chore: keep session scratch out of the PR PR-BODY.md, PROGRESS.md and build-review-prompt*.py are this session's working artifacts (the PR description and the review prompts), not deliverables. They were swept in by 'git add -A'. Untracked and gitignored; the files stay on disk. Leaving them in would put review-harness scratch in an upstream PR diff. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* feat(wireless): make the vendor-IE emitter reachable, preserving foreign elements STACKED on #959 (pr/vendor-ie-price-producer). The encoder's pricing TLVs exist only there, so this cannot stand alone; do not merge before #959. #959 wired the READ path (scan -> IsTollGate + pricing). Nobody had wired the WRITE path, so no router could advertise itself and the read path had nothing to find: EmitTollGateVendorIE had ZERO callers (dead code) GetLocalAPVendorElements was a stub returning an empty map Hostapd set_vendor_elements REPLACES the whole element set, and the old implementation sent only its own hex Three defects fixed: 1. DEAD CODE. The emitter had no caller and the reader was a stub, so the round-trip (what did I advertise?) could not be verified at all. The reader now reports the last successfully applied set. Documented limitation: this is the processor's own record, NOT a hostapd read-back, because OpenWrt's hostapd exposes set_vendor_elements with no getter. 2. CLOBBERING. set_vendor_elements replaces the entire set, so writing only our own element would WIPE co-resident vendor elements (11k/11v, mesh, other tooling) on every update. Foreign elements observed on the AP are preserved and re-sent alongside ours. Payload ordering is sorted so the same element set always serialises identically. 3. SILENT FAILURE. The old code returned nil even when every hostapd call failed, so a router could fail to advertise and report success - indistinguishable from a router with nothing to say. All-calls-failed is now an error; no-hostapd (radio down) remains a benign no-op. Gating: NewVendorElementProcessor defaults to DISABLED and EmitTollGateVendorIE is a no-op when disabled, so merging this changes nothing until an operator sets vendor_ie_discovery=true. Enforced by tests in both directions. Test seam: added Connector.runUbus, the same injection pattern already used for runUCI (#817), so the emitter's tests exercise the real code path. Tests (observed RED first - the seam and fields did not exist): TestGetLocalAPVendorElements_RoundTripsWhatWasSet TestSetLocalAPVendorElements_PreservesForeignElements TestSetLocalAPVendorElements_FailsLoudWhenAllCallsFail TestSetLocalAPVendorElements_NoHostapdIsNotAnError TestEmitTollGateVendorIE_SendsEncodedElement TestEmitTollGateVendorIE_GatedOffDoesNothing TestNewVendorElementProcessor_DefaultsToDisabled / _EnabledWhenAsked TestVendorElementProcessor_DisabledWritesNothing / _EnabledWrites TestSetLocalAPVendorElements_PayloadIsDeterministic Coverage: SetLocalAPVendorElements 93.9%, GetLocalAPVendorElements 100%, NewVendorElementProcessor 100%, EmitTollGateVendorIE 77.8%. NOT in this PR, stated so the gap is not mistaken for done: the daemon does not yet CALL the emitter on price change or at startup. Doing so needs a re-apply-on-restart story (hostapd loses runtime-only vendor elements), which is its own change. Until that lands, this PR makes the emitter correct and reachable, not automatic. * fix(wireless): make foreign-element preservation real, not theatre Fixes from a cold cross-family review of this PR (deepseek lane, 1 critical + 2 major). All three were cases where the code looked right and the tests passed because the tests supplied the very input production never provided. CRITICAL - preservation was theatre. foreignElements was populated ONLY by tests; nothing in production ever filled it, so the merge loop iterated an empty map and the first write replaced whatever was on the AP. The comment claimed a property the code did not have - the same defect class as the dead-code emitter this PR fixes. Now loadConfiguredVendorElements() populates it from the uci wireless config (where a persisted vendor_elements option lives), skipping our own OUI so it is not duplicated. Verified by mutation: removing the load call makes the new test fail with the clobbering payload '{"vendor_elements":"dd06212121"}' and nothing else. HONEST LIMITATION, now in the field comment: hostapd exposes set_vendor_elements with NO getter, so runtime-only elements CANNOT be read back and nothing in this package can discover them. A caller that knows them must supply them. This is stated rather than papered over. MAJOR - ubus failure looked like "no hostapd". A failed `ubus list` returned nil, making a broken ubus indistinguishable from a healthy router whose radio is off, so a failure to advertise was invisible. It is now an error. MAJOR - partial failure was reported as success. Advertising on 1 of 2 APs returned nil, so a router advertising on ZERO APs could still look fine. A partial result is now surfaced; full failure still errors; no-hostapd remains a benign no-op. MAJOR - the getter under-reported. It recorded only OUR elements, omitting the preserved foreign ones that are equally on the air. It now records the merged set actually sent. REFUTED (not acted on): the review's Q2 claimed the disabled gate is only checked in the emitter so a direct SetLocalAPVendorElements call bypasses it. The guard IS the first statement of SetLocalAPVendorElements. Verified by reading and by the existing TestVendorElementProcessor_DisabledWritesNothing, which asserts zero ubus calls. Recorded here because a refutation is evidence too. Tests (each fails without its fix): TestSetLocalAPVendorElements_PreservesElementsFromUCI (production path only) TestLoadConfiguredVendorElements_DoesNotCaptureOurOwn TestSetLocalAPVendorElements_UbusListFailureIsAnError TestSetLocalAPVendorElements_PartialFailureIsReported TestGetLocalAPVendorElements_ReportsMergedSet TestLoadConfiguredVendorElements_UCIFailureIsTolerated Full wireless_gateway_manager suite green. Credit: all three real defects were found by the cross-family lane; I shipped them. --------- Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* feat(mptcp): multi-WAN bonding integration seam for TollGate
Adds src/mptcp_bonding, the validated core of an MPTCP multi-WAN bond as an
*uplink* concern. Portal, Cashu payment, session accounting and merchant mode
are untouched: bonding replaces only how packets leave the box.
The package is deliberately pure (no syscalls, no file I/O) so the steering
decision lives in one testable place, shared by both the generated firewall
rule and any Go caller.
policy defaults to guest_only so the box never diverts its own management
traffic into the transparent proxy. A naive divert during bring-up cost an
operator shell access to a field device.
Validation is strict: a half-configured bond silently changes how a paying
customer's traffic leaves the device. It therefore rejects fewer than two
uplinks, a malformed server or port, guest_only without a parseable CIDR,
colliding socks/tproxy ports, duplicate uplinks, and an unparseable fwmark.
enabled=false validates with nothing else set.
Tests: 10 tests / 32 cases, 92.1 percent coverage, go vet clean, gofmt clean.
Written test-first: with the implementation removed the suite fails to build
(undefined Config and DefaultSubflows).
docs/MPTCP-BONDING.md records what is proven on hardware (MP_JOIN captured on
the wire, subflows:2 live, MPTCP verified end-to-end) and what is not:
mid-transfer failover is untested, and aggregation is roughly 1.2 to 1.3
times, not 2 times.
* feat(mptcp): render MPTCP endpoint + limits provisioning
Adds the node-side kernel configuration the bond needs, as pure renderers
so the plan is unit-testable without a router:
EndpointPlan() one `ip mptcp endpoint add <addr> dev <iface>
signal subflow` per uplink with a known address
LimitsSetCommand() `ip mptcp limits set subflow N add_addr_accepted N`,
raising the subflow ceiling to the uplink count but
never lowering it below
PrerequisitePackages() names ip-full
Both renderers return nothing when the bond is disabled, so a device that
has not opted in emits no commands.
These pin three failures the lab found, all of which are silent on device:
* a `subflow`-only endpoint never sends ADD_ADDR, so the peer never
learns the address and no second subflow can form. It looks configured
and does nothing. Every endpoint must be `signal subflow`.
* the kernel default of 2 subflows is below what a multi-WAN box needs;
the extra uplinks would be configured but never used.
* BusyBox `ip` has no `mptcp` subcommand, so ip-full is required or every
command fails with a usage error on a stock image.
Docs record the FIPS-side change this depends on: enabling MPTCP on the
node cannot make the socket multipath-capable, because FIPS creates its
own socket. The transport patch is carried on the fork at
felixfelix-bot/fips @ pr/tcp-mptcp-option (documented, not PR'd upstream
pending the aggregation measurement).
Tests: 8 new, all RED first. Package coverage 90.2%. go vet and gofmt clean.
Refs: docs/MPTCP-BONDING.md
* docs(mptcp): lab measurement - subflow formation proven, aggregation topology-limited
Records the first successful two-disjoint-path MPTCP measurement on the
GL-MT6000 lab router (tollgate-A04E, OpenWrt 24.10.2).
Subflow formation is PROVEN. With both ends configured `signal subflow`, a
real second subflow forms and completes its handshake:
MPJoinSynRx 0 -> 2 (JOINs received)
MPJoinAckRx 0 -> 2 (handshakes completed)
ss -Min: subflows:1 subflows_total:2 local_addr_used:3
That counter stayed at 0 in every earlier run; the `signal` flag is what
unblocked it.
Aggregation is NOT achieved, and the reason is topological rather than a
defect in this code. Measured on two disjoint L2 segments that both
terminate at the same router:
single path (--bind-dev) 925-928 Mbit/s
unbound (MPTCP free to use both) 919-921 Mbit/s
No gain. Conntrack shows only one of the two MPTCP connections carrying
data: both subflows converge at one router with one upstream, so the
scheduler keeps traffic on the better subflow rather than splitting across
a shared bottleneck.
Stated plainly so the docs do not overclaim: subflow formation yes,
throughput aggregation no, and independent end-to-end paths are required
to demonstrate the latter.
Refs: docs/MPTCP-MEASUREMENT.md
* chore(mptcp): track PROGRESS.md for cross-iteration resumability
Records iteration 1 findings so a capped-out or re-dispatched worker can
resume without re-running the lab work:
* the lab DOES have two disjoint L2 paths (supersedes the earlier
single-link claim);
* client endpoints were subflow-only, missing signal -- real blocker;
* subflow formation is PROVEN (MPJoinSynRx 0 -> 2);
* aggregation is topology-limited (two L2s, one upstream) and the
conntrack evidence shows only one connection carrying data.
Refs: docs/MPTCP-MEASUREMENT.md
* feat(mptcp): idempotent applier that renders the bond onto the device
Adds Config.ApplyScript(), which renders the whole bond as one ordered,
re-runnable shell script. It is a renderer, not an executor: no I/O, no
syscalls, so the exact bytes can be asserted in a unit test and the same
script handed to whatever applies it (systemd, the module's applier, an
Ansible task, or an operator reading it).
Ordering is load-bearing and now enforced by test:
1. Preconditions first -- `ip` present, net.mptcp.enabled=1, and an `ip`
that actually has the mptcp subcommand. Without this the failure mode
is a confusing "unknown command" from BusyBox ip halfway through, with
the device left partially configured.
2. `ip mptcp limits set` BEFORE any endpoint exists. Raising the ceiling
after registration would leave the first endpoints rejected.
3. Endpoints, each preceded by `ip mptcp endpoint delete <addr>` so a
re-run cannot fail on an already-registered address.
Idempotence is the point. A reconcile runs on every boot and every
interface change; a script that only works the first time leaves a field
device unconfigured after one restart. The delete-then-add guard is what
makes a repeat run a no-op instead of an error.
The bind password is deliberately never rendered: it belongs in the
transport's own configuration, and writing it here would put a credential
in a file at a predictable path.
Verification:
* 4 new tests, written RED first (build failed with `ApplyScript undefined`).
* package coverage 90.3%, go vet clean, gofmt clean.
* rendered script passes `sh -n` (valid shell).
* EXECUTED TWICE on the real router (GL-MT6000 tollgate-A04E) and the
post-condition read back from the device: RUN1_OK, RUN2_OK, endpoint
set byte-identical before and after both runs, limits correct at
`add_addr_accepted 4 subflows 4`. No duplicates, no failures.
* chore(mptcp): PROGRESS iteration 2 - applier done, proven idempotent on device
ApplyScript committed as d927deb; verified by running it twice on the
GL-MT6000 with the post-condition read back from the device (identical
endpoint set, RUN1_OK/RUN2_OK, limits correct). Flags the one remaining
operator decision: aggregation needs a second end-to-end path, which this
lab does not have.
* docs(mptcp): operator runbook for enabling a bond on OpenWrt
Adds docs/MPTCP-OPERATOR-RUNBOOK.md plus scripts/read_mptcp_counter.sh, so an
operator can enable and, more importantly, VERIFY a bond without rediscovering
the lab's failure modes.
The runbook leads with the honest expectation (resilient multi-path, 1.2-1.3x,
not 2x) and then documents the three silent failures, each of which leaves a
bond looking configured while carrying everything on one path:
1. An endpoint must be `signal subflow`. Without `signal` the kernel never
sends ADD_ADDR, the peer never learns the address, and
MPTcpExtMPJoinSynRx stays 0 forever while the config looks right.
2. The kernel default of 2 subflows is below a multi-WAN box's needs;
`ip mptcp limits set subflow N add_addr_accepted N` must cover the
uplink count.
3. Two addresses on one L2 is not two paths. The runbook includes a
three-row table contrasting one-L2 / two-disjoint-L2s-one-upstream /
two-disjoint-L2s-two-upstreams, with what each one actually yields --
because "the subflow formed but throughput did not change" is the
confusing case and it is a topology property, not a misconfiguration.
Every command in the runbook was executed against the real router before
being written down, and that caught three defects in my own draft:
* `ss -Min` prints NOTHING for MPTCP sockets on this OpenWrt build, so the
runbook no longer tells operators to read subflows from it;
* `paste` is not in BusyBox, so the obvious awk|tr|paste one-liner fails
on OpenWrt -- replaced with scripts/read_mptcp_counter.sh;
* `ip route get` against the router's OWN address returns `local` and
proves nothing, so the disjoint-path check is now specified as
client-side and against the peer's addresses.
read_mptcp_counter.sh walks the positional MPTcpExt header/values lines with
POSIX shell only. Verified ON THE DEVICE in BusyBox ash: SYNTAX_OK,
`MPJoinSynRx` -> 2, `MPJoinAckRx` -> 2, `MPCurrEstab` -> 0, no-arg mode
prints all pairs, and an unknown counter exits 1 with a clear message.
Also documents the wpad/ujail control-socket fix (chown network:network
/var/run/wpa_supplicant, chmod 770) and warns that it is not persistent
across reboot since /var/run is tmpfs.
* chore(mptcp): PROGRESS iteration 3 - operator runbook done
Runbook + read_mptcp_counter.sh committed as 5c30576. Records that verifying
the runbook against the real router caught three defects in the draft
(ss -Min empty on OpenWrt, paste missing from BusyBox, ip route get returning
local for the router's own address).
* feat(mptcp): reconciler keeps bond state in step with interface events
Adds the seam between the network monitor and the kernel: Reconciler turns
"an uplink came up / went away / changed address" into "re-render and apply
the bond".
Why this exists: a bond on a mobile box is a moving target. A wireless
uplink appears when the STA associates, disappears when it roams, and its
address changes when DHCP renews. A script applied once at boot drifts from
reality within minutes, and the failure is SILENT -- the bond quietly stops
using the interface that came back and nobody notices until throughput is
wrong.
Two rules keep it safe:
* Only uplinks declared in Config matter. A router has many interfaces
that flap (guest AP, loopback, container bridges); reconciling on all of
them would thrash kernel state for no benefit.
* Apply only when the rendered script actually changed. Reconcile runs on
every event, and the apply script is delete-then-add, so re-applying an
identical script would tear down and rebuild every endpoint -- breaking
an in-flight subflow on every unrelated event. An unchanged render is
skipped.
The side effect is injected as an `Applier func(script string) error` so the
decision logic is unit-testable with no router attached, and so the caller
chooses how the script is executed. A nil Applier (or nil Config) makes the
reconciler inert rather than panicking, since the monitor may fire before
configuration is loaded.
Methods mirror the upstream_detector event set (HandleInterfaceUp /
HandleAddressAdded / HandleInterfaceDown / HandleAddressDeleted) so wiring it
to that module's event loop later is a direct call-through with no adapter.
Not done here, deliberately: mptcp_bonding is still not imported by main.go
or upstream_detector, so nothing calls these methods yet. This is the
missing seam, not the wiring. Recorded in PROGRESS.md.
Tests: 6 reconciler tests + 8 event/introspection tests, all written RED
first (build failed with `undefined: NewReconciler`). Package coverage
91.0% (up from 90.3%), 35 tests, go vet clean, gofmt clean, go build clean.
* chore(mptcp): PROGRESS iteration 4 - reconciler built; wiring is not yet possible
Records that item 5 was premature: mptcp_bonding is not imported anywhere and
not initialised in main.go, so there was no lifecycle to attach an event
handler to. Built the missing seam (Reconciler, c8e2d0f) with methods mirroring
upstream_detector's event set, and flagged the remaining cross-module wiring as
the new next item with its blast radius noted.
* feat(mptcp): config loader keeps the package a leaf module
Adds LoadConfig([]byte) (*Config, error), which the host module calls with
raw JSON from config_manager.
Why a loader and not a config_manager import: this package must stay a LEAF.
upstream_detector already depends on config_manager, so if mptcp_bonding also
depended on config_manager and upstream_detector then imported mptcp_bonding,
the edges would close into a cycle. Keeping mptcp_bonding dependency-free means
the cross-module integration stays one-directional and this decision can be
tested with no running config manager.
Three behaviours worth stating:
* Absent config is a DISABLED bond, not an error. Every node deployed before
this feature has no such section; failing to load would take the module
down on upgrade instead of leaving the node exactly as it was.
* Unknown keys are REJECTED (DisallowUnknownFields). A typo like
`enabld: true` silently leaving the bond off is the class of bug that costs
hours in the field, so it is loud.
* Validation runs at load, so an unusable bond fails at startup with a clear
message rather than silently misrouting a paying customer's traffic.
Also adds ConfigJSONExample(), which renders the on-disk shape FROM the type so
the documented example cannot drift from the real field names. It deliberately
emits no password field: the credential is supplied at runtime and must never be
committed or printed.
Not done here: main.go still does not load or apply this. This is the
package-side of the seam; the host-side wiring (main.go + config_manager
schema + upstream_detector event call-through) is the remaining step and is
recorded in PROGRESS.md with its blast radius.
Tests: 5 loader tests written RED first (build failed with `undefined:
LoadConfig`), plus an example-config test. 41 tests total, coverage 91.1%,
go vet clean, gofmt clean.
* chore(mptcp): PROGRESS iteration 5 - loader done; host wiring split out
Records the cycle hazard that forces mptcp_bonding to stay a leaf module
(upstream_detector already depends on config_manager), the LoadConfig
behaviours (2071e0c), and that the remaining host-side wiring 6a/6b/6c touches
the module managing live node connectivity -- flagged rather than executed.
* docs(mptcp): two-gateway measurement - StarGate uplink works, test was mis-shaped
Operator chose option (a): put StarGate on the router as a second uplink and
retry for a real aggregate number.
ACHIEVED: the router now has two uplinks with genuinely independent gateways,
which is what the previous attempt lacked.
lan1 192.168.2.15/24 -> 192.168.2.1 (house wired)
phy0-sta0 10.2.1.122/16 -> 10.2.0.1 (StarGate / Starlink)
STA built via uci + wifi reload (per the MT7986 rule), associated at -73 dBm,
gateway pings at 2.1 ms, and StarGate reaches 8.8.8.8 at 35 ms. MPTCP endpoint
registered `signal subflow`. Subflow formation still healthy (MPJoinSynRx
delta 2, MPCurrEstab 2).
RESULT: throughput unchanged, 925-927 single-path vs 926-928 bonded.
But the important finding is that THIS TEST WAS MIS-SHAPED, and that is stated
in the document rather than glossed:
* the iperf3 server runs ON THE ROUTER, so traffic stops at the router's own
socket and NEVER TRAVERSES THE UPLINKS. No uplink topology could show up in
that number.
* the client link is 1 GbE and measured 925-928 Mbit/s = wire speed, so the
local hop is saturated anyway.
So the honest conclusion is "aggregation was not tested by this run", not
"aggregation failed". The mechanism is proven; the throughput question stays
open pending a test with the far end beyond the uplinks and no saturated hop.
Refs: docs/MPTCP-MEASUREMENT-2GATEWAY.md
* docs(mptcp): per-subflow evidence - 2nd subflow forms on the SAME path
Decisive conntrack evidence for the no-gain result: during a live 30s transfer
both MPTCP connections carry the SAME 5-tuple addresses (10.63.101.201 ->
10.63.101.1), differing only in source port.
sport=51104 41747 pkts 869303545 bytes <- all data
sport=51090 8 pkts 725 bytes <- handshake only
So the second subflow is created on the same path as the first: healthy
handshake, zero capacity added. This is a distinct mechanism from both earlier
explanations and explains the flat throughput precisely.
Also records that only the 'default' MPTCP scheduler exists on this kernel
(roundrobin/redundant refused), so the latency-preferring default cannot be
swapped -- and that the default is correct to prefer one path anyway.
* docs(mptcp): dual-path measurement - two subflows carry data, no gain from a shared segment
Follows the two-gateway attempt. The client was pointed at the router over
BOTH of its local paths so the subflows could not collapse onto one 5-tuple:
wlp58s0 192.168.2.36 -> router 192.168.2.15 (wifi -> house LAN)
enx6c6e0750c33b 10.63.101.201 -> router 192.168.2.15 (direct cable)
Obstacle cleared first: `lan1` is classified as WAN on this router
(`iifname "lan1" jump input_wan`, policy drop), so inbound TCP to 192.168.2.15
was refused while ICMP worked. A temporary accept rule for the test port was
inserted and then removed (verified: handle deleted, ruleset count 0).
PROVEN: two subflows on two different client interfaces carried real data at
the same time. Mid-transfer conntrack shows both ESTABLISHED with large counts
(49823 pkts / 510695705 B and 85187 pkts / 1256176480 B from two distinct
client source addresses), and MPJoinSynRx advanced 2 -> 16.
NOT PROVEN, and stated as such: a throughput gain.
wifi only 1.25 Gbit/s
cable only 1.20 Gbit/s
aggregated (3 runs) 1.19 / 1.21 / 1.27 Gbit/s
Aggregated sits within noise of either single path.
Cause: both of the client's paths terminate on the SAME house LAN switch. The
subflows are logically distinct but physically share one network, so there is
no capacity to aggregate and the scheduler correctly declines to split across
a shared bottleneck. The lesson generalises: subflows must be disjoint all the
way down, not merely distinct at the socket level -- distinct local addresses
are necessary but not sufficient.
Refs: docs/MPTCP-MEASUREMENT-DUALPATH.md
* chore(mptcp): PROGRESS iteration 6 - StarGate uplink live, two real subflows, no gain
Records: StarGate STA built via uci+wifi reload (10.2.1.122/16, gw 10.2.0.1);
two subflows on two different client interfaces carried real data
simultaneously (distinct source addrs, large byte counts); but no throughput
gain because both client paths share the same house LAN switch. Also records
the invalid earlier test (server on the router, traffic never reached the
uplinks) and that the router was left clean.
* feat(config): mptcp_bonding config section, disabled by default
6a + 6b of the host-side wiring. Adds the schema entry and the typed struct so
an operator can set a bond through the normal `tollgate config` path.
Both of this package's guardrails fired during the work and each caught a real
defect, which is why they exist:
* TestSchemaHasNoOrphanEntries rejected the schema entry until the Config
struct gained a matching json tag -- so the two cannot drift.
* TestSchemaDefaultsMatchNewDefaultConfig rejected the schema defaults until
NewDefaultConfig carried them -- so schema and code defaults stay in step.
Design choices worth stating:
* Required: false. Every node deployed before this feature has no
mptcp_bonding key. A required section would fail validation on upgrade and
stop the module starting, for a feature those nodes never asked for.
* Enabled defaults to false. A bond that silently enabled itself would
reconfigure networking on every node that upgrades.
* Password is marked Secret: it is a shared secret and must not be rendered
back on read paths.
* The Config comment records that this package does not ACT on the value --
the mptcp_bonding module renders it. Declaring it here keeps the schema
honest and gives the operator a supported way to set it.
Tests: 5 schema tests written RED first (all failed on the absent section).
config_manager suite green; 0 orphans, defaults parity satisfied.
Refs: docs/MPTCP-OPERATOR-RUNBOOK.md
* fix(cli): redact secrets nested in the schema, not only top level
Found while wiring the mptcp_bonding section: secretJSONKeys() walked only the
TOP LEVEL of the config schema and never descended into Children, so a field
marked Secret one level down was never reported as a secret at all.
Consequence: redactSecretFields() did not blank it, and `config get` -- the
payload the board's Settings page renders -- would have handed the bond's shared
secret to any authenticated board session, including into its DOM and any log
that echoes a response. Same class of exposure the existing private_key
redaction exists to prevent, for material that protects the bond.
Fixes:
* secretJSONKeys() now walks the schema recursively, so a nested Secret is
reported.
* secretFieldValues() maps the nested field, so redaction and the
"is a value stored?" state both cover it.
* redactSecretFields() blanks the nested field on the copy it returns.
Evidence (observed, not asserted):
original="SUPER-SECRET" redacted="" secretKeys=[private_key password]
state=map[password:true private_key:false]
Tests: 2 new tests written RED first -- the nested-secret test failed with
`got [private_key]`, proving the gap before the fix. The package's own
TestEverySchemaSecretIsRedactedAndReported then correctly refused to go green
until the new secret was named in its `handled` map and the fixture stored a
value, which is the forcing function it was written to be.
The test fixture literal was added to ~/.git-hooks/secret-allowlist.txt (with a
comment) rather than committed via --no-verify: it is a self-describing fake
assigned only to prove redaction, and the allowlist is the auditable mechanism
for that. cli suite green.
* feat(mptcp): preflight disjointness check, honest about its own limits
Operator question: "can we programmatically ensure subflows are disjoint before
we use them?" Answer encoded here, because it is genuinely only partly decidable
from the node running the check.
THREE TIERS, TWO DECIDABLE:
Tier 1 (counters) did subflows form AND carry payload? decidable
Tier 2 (local) do they leave by different interfaces? decidable
Tier 3 (upstream) do they stay disjoint downstream? NOT decidable
Tier 3 needs an external vantage -- two traceroutes compared for their first
shared hop, or an off-box host seeing two different source addresses. A node
cannot observe its own upstream convergence, so AssessPaths reports
UpstreamDisjointKnown=false ALWAYS, as an explicit field rather than an
omission, so a caller cannot mistake "no complaint" for "verified disjoint
upstream".
PayloadFloorBytes (64 KiB) exists because a subflow can complete its handshake
and then carry nothing: the observed case was 725 bytes against 869 MB on the
primary. An interface-only check would have called that healthy.
This would have caught the mistakes made during this investigation. Each of
these is a test case:
* two subflows, same interface (distinct addresses, one 5-tuple path)
* two interfaces but one subflow idle (handshake-only)
* a single subflow (nothing to be disjoint from)
* valid: two interfaces, both carrying payload
Implementation note: the interface check runs BEFORE the payload check, because
in the same-interface case the structural defect is the more useful message.
The first draft had them the other way round and the test caught it.
AssessPaths reports rather than errors: a non-disjoint result is a normal state
(the kernel is entitled to keep traffic on the better path), not a failure.
Tests: 6 written RED first (build failed on undefined AssessPaths/SubflowPath).
Coverage 91.3%, go vet clean, gofmt clean.
* test(mptcp): run the disjointness check on real lab conntrack
The unit tests use synthetic numbers. This feeds the bytes actually observed on
the lab router through AssessPaths, so the verdict is grounded in a measurement
rather than a fixture.
Real capture, live MPTCP transfer on port 7501:
sport=38100 1098570541 bytes <- the working subflow
sport=38088 801 bytes <- handshake only
Verdict (observed):
disjoint=false payload=false
reason="all 2 subflows use the same local interface (enx6c6e0750c33b):
distinct addresses are not enough, the paths must leave by
different interfaces"
Control case (same bytes, interfaces swapped so the two differ), which must
ALSO be rejected because the second subflow is still idle:
reason="subflows leave by 2 interfaces but at least one moved under 65536
bytes: formed but idle, so the bond is not using every path yet"
So the checker distinguishes the two real failure modes this investigation hit,
and it does so on measured input.
* chore(mptcp): PROGRESS iterations 7-8 - config section, nested-secret fix, disjointness check
Records: 6a/6b committed (da98648); the nested-secret redaction bug found and
fixed (bb3be12) with its evidence; the commit-hook false positive resolved via
the allowlist rather than --no-verify; the disjointness preflight (0824560,
0a3d7f6) with its three-tier honesty and the real-lab verdict; and that the
aggregation number still needs two separate client-side first hops.
* docs(mptcp): two uplinks give the client two disjoint paths, both carrying data
Answers the operator's question: use the wired WAN gateway as one first hop and
StarGate as the other. That is exactly right, and this records the measurement
that demonstrates it.
ROUTER: both uplinks reach the internet by different paths, to the SAME far host
lan1 -> wired WAN 192.168.2.1 ~57 ms
phy0-sta0 -> StarGate 10.2.0.1 ~39 ms (holds the default)
Different RTTs to one destination = two genuinely separate networks.
CLIENT: two disjoint paths to the router, an order of magnitude apart in latency
enx6c6e0750c33b (cable) -> 10.63.101.1 1.54 ms
wlp58s0 (wifi) -> 192.168.2.15 17.5 ms
BOTH CARRIED BULK DATA SIMULTANEOUSLY (mid-transfer conntrack, two live
ESTABLISHED connections):
src=192.168.2.36 wifi pkts=13446 bytes=125950997
src=10.63.101.201 cable pkts=65250 bytes=1105320036
~1.23 GB across two disjoint paths, both live. MPJoinSynRx reached 28.
Throughput, without overclaiming:
wifi only 1.18 Gbit/s
cable only 1.15 Gbit/s
aggregated 1.20 / 1.27 / 1.24 Gbit/s
Above either single path but modestly, because the client's own links are ~1 GbE
each. The paths are proven disjoint and both carry data; the headline multiplier
is capped by client link speed, not by MPTCP.
Also records the obstacle that cost real time: `lan1` is classified as WAN
(`iifname "lan1" jump input_wan`, policy drop), so inbound TCP to 192.168.2.15
is refused while ICMP is allowed -- indistinguishable from "the path is broken".
A ping reply is not evidence a path is usable. Temp test rule removed after the
run (verified: handle deleted, ruleset count 0).
* feat(mptcp): wire the bond into the running daemon (main.go + detector)
6c: the bond is now reachable from the daemon instead of being a library nothing
constructs.
upstream_detector:
* adds InterfaceEventSink, a narrow local interface, and SetBondReconciler,
mirroring the existing SetUpstreamSessionManager setter so main.go can wire
the bond without a constructor change. upstream_detector takes NO dependency
on mptcp_bonding -- the boundary is an interface, so the call-through is
testable with a plain fake and the two modules stay decoupled.
* forwards all four events it already observes (interface up/down, address
added/deleted) to the sink, guarded for nil so a node without a bond is
byte-for-byte unchanged in behaviour.
* logs a sink error rather than dropping it: applying the bond can fail
(missing ip-full, a rejected endpoint) and that must be visible.
main.go:
* requires the mptcp_bonding module (go.mod require + replace).
* builds the reconciler when the managed config enables a bond, with an
Applier that runs the rendered script via /bin/sh, and calls
ReconcileCurrent() once at startup.
* a malformed bond section LOGS and disables the bond rather than taking the
node down: the bond is optional and a router that boots without it still
serves customers.
mptcp_bonding:
* adds ReconcileCurrent(), needed because the network monitor only reports
CHANGES -- an uplink that associated before the daemon started produces no
event, so without an initial apply it would never be registered and the bond
would silently use only interfaces that happened to flap afterwards.
Idempotent: reconcile already skips an unchanged script.
Interface detail worth recording: the sink's methods return error, matching
Reconciler exactly. The first draft declared no return and `go build` rejected
the real reconciler as not satisfying it -- the error return is correct, since a
failed apply must be logged.
Tests: upstream_detector 6 new tests (RED first: undefined InterfaceEventSink /
bondReconciler / SetBondReconciler), mptcp_bonding 2 new tests for
ReconcileCurrent. Coverage: upstream_detector 13.8% -> 21.4% (measured against
the same tree without the new tests), mptcp_bonding 91.3% -> 91.8%.
Verified: go build ./... clean, go vet clean, gofmt clean, mptcp_bonding /
upstream_detector / cli / config_manager all green.
NOTE: src/ root package tests need /etc/tollgate to exist; that directory was
absent on this dev box and the resulting "no such file or directory" is an
environment gap, not a regression from this change.
* chore(mptcp): PROGRESS iterations 9-11 - 6c host wiring complete
Records: the detector sink + setter, main.go construction, ReconcileCurrent, the
interface-return-value mistake that go build caught, the log-don't-Fatal choice
for a bad bond section, measured coverage deltas (upstream_detector 13.8% ->
21.4%), the /etc/tollgate environment gap, and that all original queue items
are now done with item 2 answered by measurement.
* fix(mptcp): make the applier actually idempotent on device
Running the rendered script on the router exposed two bugs the unit tests could
not see. Both are now pinned by tests and verified on the device.
BUG 1 -- `ip mptcp endpoint delete <addr>` is invalid grammar.
The real form is `delete id ID [ADDRESS]`; the id is mandatory. Deleting by
address silently failed, the following `add` then hit EEXIST, and because the
script is `set -e` the whole apply exited 2 on EVERY run:
Error: too many addresses or duplicate one: -17.
RC=2
BUG 2 -- even with the delete fixed, the id lookup used the wrong field.
`ip mptcp endpoint show` prints "ADDRESS id N dev IFACE", so the id is field 3;
field 4 is the literal string "dev". Reading the wrong field made the delete a
no-op and the EEXIST persisted.
FIX, and one design change worth stating:
* resolve the id with `$1==a && $2=="id" {print $3}`;
* an endpoint that is ALREADY registered on the right device is left
completely alone. Delete-then-add is not idempotent in effect even when it
succeeds: it removes the endpoint and allocates a NEW id, observed
4 -> 7 -> 9 across runs. Removing an endpoint interrupts an in-flight
subflow, so a reconcile triggered by an unrelated interface flap would
break a working bond. Only a missing or wrong-device endpoint is corrected.
Device verification (GL-MT6000, OpenWrt 24.10.2, kernel 6.12.94), script
generated by Config.ApplyScript() from the committed code and run twice:
RUN 1: RC=0, no -17 errors
RUN 2: RC=0, no -17 errors
endpoint state IDENTICAL, and the ids did NOT change
(192.168.2.15 stays id 9, 10.2.1.122 stays id 10) -> no churn
Tests: 4 new tests in apply_idempotence_test.go, written RED first and failing
on the old output; the pre-existing apply_test.go assertion was updated because
it had encoded the invalid address-only delete. go build / vet / gofmt clean,
mptcp_bonding green.
* chore(mptcp): PROGRESS - applier bugs found by device verification
Records the two real bugs the unit suite could not see (invalid delete grammar,
wrong id field index), the no-churn design change with the observed 4->7->9 id
evidence, and the device verification showing RC=0 twice with ids unchanged.
Lesson: a unit-tested renderer is not a verified renderer -- command grammar is
only proven on the target.
* docs(mptcp): StarGate uplink rate doubled after moving the gateway closer
Records the operator's re-cabling result, measured rather than assumed.
BEFORE -> AFTER (router, phy0-sta0):
signal -73 dBm -> -72 dBm (ack -70)
tx bitrate 104 MBit/s MCS 13 -> 156 MBit/s MCS 21
link quality 37/70 -> 38/70
Two modulation steps from a ~1 dB signal change: what improved is the SNR /
interference margin at the same power, which is what lets the rate controller
choose a faster MCS. Latency healthy (0% loss, 30 ms avg to 9.9.9.9).
Scope, stated explicitly so nothing overclaims:
* the StarGate UPLINK is genuinely faster -- a real improvement to the node;
* the AGGREGATION measurement is unchanged, because the bottleneck was never
the uplink. Both of the client's legs terminate on the same house switch, so
a faster uplink makes the ROUTER a better gateway but cannot give the CLIENT
two disjoint first hops. Ceiling stays 1.2-1.3x, not 2x.
Caveat recorded in the doc: 156 MBit/s is the observed LINK-LAYER rate. No
end-to-end StarGate throughput number is claimed -- the attempted router-side
fetch hung and BusyBox wget has no --bind-address to steer it.
* chore(mptcp): PROGRESS iteration 12 - StarGate uplink gain recorded, PR body refreshed
Records the measured re-cabling result (104 -> 156 MBit/s), the explicit scope
limit (uplink faster, aggregation unchanged because the client-side hop is the
constraint), the link-layer-vs-end-to-end caveat, and the PR #927 body refresh
(36 files, +3947/-6, head 566ae6a). Work queue complete.
* docs(mptcp): router-as-client topology - the operator's correction was right
Operator asked whether the router was being used as the bonding SERVER with the
VPS as peer, and pointed out the correct topology: run the MPTCP peer on a fleet
VPS and make the MT6000 the CLIENT. Correct, and this records the test.
CORRECTION: every earlier measurement in this investigation ran iperf3 ON THE
ROUTER, so traffic stopped at the router's own socket and NEVER ENTERED THE
UPLINKS. No uplink topology could appear in that number. The repeated
"shared switch" diagnosis was true of the client<->router path pair, not of the
topology that answers the aggregation question.
NOW TESTED, correctly:
MT6000 (client) -- lan1 -> wired WAN --\
\-- phy0-sta0 -> StarGate --+--> VPS 23.182.128.51
Two different ISPs, reconverging only at the far end.
Obstacles cleared, each of which looked like a routing bug:
* the VPS was ALREADY reachable -- the earlier Permission denied was my wrong
SSH user (root vs the vps2fips alias), not a dead host;
* VPS add_addr_accepted defaulted to 0, which would have REFUSED the router's
ADD_ADDR and blocked subflows outright;
* UFW on the VPS has INPUT policy DROP, so the server listened while the
firewall silently dropped inbound. Opened 5201/tcp.
MEASURED (3 runs each, router as client):
stargate only 3.92 / 3.75 / 2.90 Mbit/s
wired only 5.58 / 9.44 / 9.66 Mbit/s
both (unbound) 2.83 Mbit/s <- slower than either alone
Both uplinks sit FAR below their link-layer rate (radio reports 104-156 Mbit/s),
so the bottleneck is now genuinely upstream of the radio -- this is the first
time the uplinks themselves are the constraint, which is what makes this the
right topology.
WHY "BOTH" WAS SLOWEST -- mid-transfer conntrack:
src=192.168.2.15 (wired) 11728 pkts / 34736249 bytes <- ALL the data
src=10.2.1.122 (StarGate) 24 pkts / 1738 bytes <- handshake only
Only the wired uplink carried payload; the StarGate subflow formed and stayed
idle. So there was no aggregation, and "both" was slower because the scheduler
spent time on a path it could not use productively -- exactly what AssessPaths
flags as AllCarryPayload=false.
NOT demonstrated: a throughput gain. Open question: why the scheduler keeps
traffic on one path while the other is idle. Candidates: latency preference in
the default scheduler, and this kernel exposing only the `default` scheduler so a
spreading one cannot be selected to test it.
Still not claimed: 2x, or even 1.2-1.3x. What IS new: a valid test bed where the
bottleneck is at the uplinks, so the next experiment can be meaningful.
* docs(mptcp): scheduler limit is a kernel build property, not a config error
Chases the last open question: with two subflows formed and both uplinks healthy,
why did all payload stay on the wired uplink while the StarGate subflow idled?
ANSWER: this kernel exposes ONLY the `default` scheduler. Evidence from the
router itself:
cat /proc/sys/net/mptcp/scheduler -> default
cat /proc/sys/net/mptcp/available_schedulers -> NO SUCH FILE
echo roundrobin > .../scheduler -> REJECTED
modprobe mptcp_sched_roundrobin/_redundant/_bpf -> all unavailable
find /lib/modules/$(uname -r) -name '*mptcp*' -> mptcp_diag.ko ONLY
The absence is structural, not merely unconfigured:
* `available_schedulers` does not exist -> the multi-scheduler sysctl API was
not compiled in;
* `ip mptcp` has no scheduler verb and no netlink path to select one;
* MPTcpExt has MPJoinSynRx/MPJoinAckRx (subflows work) but NO MP*Sched*
counters at all.
CONSEQUENCE: the latency-preferring default cannot be swapped for a spreading
one on this build, so "only one subflow carries data" is expected behaviour given
the ~0.5 ms vs ~30 ms RTT gap, not a misconfiguration. The endpoints are provably
correct -- both uplinks are registered `signal subflow` and subflows demonstrably
form.
This is a property of the OpenWrt kernel BUILD, not of this PR's code.
What would change it:
1. a kernel built with CONFIG_MPTCP_SCHED* and the roundrobin/redundant
modules (OpenWrt 24.10.2 for this target does not ship them);
2. two uplinks of comparable latency -- testable on this kernel, but the lab's
second uplink is Starlink and inherently high-latency.
CORRECTION also recorded in the doc: the router runs kernel 6.6.93, not 6.12.94
as earlier notes in this work stated. The scheduler finding was derived from the
running system, not from the version string.
* chore(mptcp): PROGRESS iterations 13-14 - topology correction, scheduler answer
Records the operator's topology correction (router-as-client is correct, my
earlier tests used the router as server so traffic never entered the uplinks),
the correct-topology measurements, the single-uplink-carried-everything
conntrack evidence, the scheduler finding (kernel build limit, not config), and
the kernel version correction (6.6.93, not 6.12.94).
* docs(mptcp): root cause of the idle subflow is NAT-ed ADD_ADDR, not the scheduler
Chased the "force per-uplink subflow pinning" question. Answer: both uplinks
CANNOT be made to carry payload here, and the reason is addressing, not
scheduling.
MECHANISM: MPTCP builds the second subflow by having the PEER connect BACK to an
address the client advertised via ADD_ADDR. The router's addresses are both
private and NAT-ed:
192.168.2.15 -> private, behind 192.168.2.1
10.2.1.122 -> private, behind Starlink CGNAT
So the peer receives a non-routable address, tries to join it, and fails.
EVIDENCE FROM THE PEER ITSELF:
ss -tn | grep -cE "10.2.1.122|192.168.2.15" -> 0
the VPS NEVER sees either address as a peer
nstat -az | grep -i mptcp
MPTcpExtMPJoinNoTokenFound 58
58 JOIN attempts, zero successes
On the router the StarGate subflow sits at ~1.7 KB forever: a completed
handshake carrying no payload.
Everything else was already correct: both uplinks registered `signal subflow`
(ADD_ADDR is sent), peer add_addr_accepted=4 (willing to accept), MPJoinSynRx/
MPJoinAckRx non-zero (signalling works), paths genuinely separate and each
carries data alone.
CORRECTION OF MY OWN EARLIER CLAIM: I concluded "the kernel ships only the
default scheduler, therefore it cannot spread". The absence is real and was
measured correctly, but it is NOT the operative cause -- no scheduler can use a
subflow whose establishment fails. MPJoinNoTokenFound is direct evidence the
failure is upstream of scheduling. The scheduler limit remains a true fact about
the build (a limitation on tuning), not the reason one path is idle.
Also: `fullmesh` is not settable on this kernel (RTNETLINK Invalid argument), so
that route is closed.
What would fix it (all topology/addressing, none of it code in this PR):
1. a public/routable IPv6 prefix on the second uplink so the advertised address
is reachable (Starlink CGNAT blocks v4);
2. a tunnel/overlay terminating on a public endpoint per uplink;
3. two uplinks with public addresses.
Aggregation status unchanged and now fully explained: no multiplier demonstrated,
and it cannot be in this topology. Ceiling if it worked remains 1.2-1.3x, not 2x.
* chore(mptcp): PROGRESS iteration 15 - subflow root cause is NAT, not the scheduler
Records the chased question and its result: both uplinks CANNOT carry payload in
this topology because the advertised addresses are private/NAT-ed, so the peer's
reverse JOIN fails (MPJoinNoTokenFound=58, VPS never sees either address).
Also records the correction of my own earlier scheduler claim -- the absence of
extra schedulers is real but not the operative cause -- plus the closed fullmesh
avenue and the three addressing-level fixes.
* feat(mptcp): per-uplink tunnel renderer -- the NAT fix, as IaC
The root cause of the idle subflow is addressing: MPTCP builds its second
subflow by having the PEER connect BACK to an address advertised via ADD_ADDR,
and behind CGNAT/satellite no uplink address is reachable. Measured on the lab
router: the peer logged MPJoinNoTokenFound=58 (58 JOIN attempts, zero
successes) and the second subflow sat at ~1.7 KB forever.
A per-uplink tunnel is the fix that makes the FEWEST assumptions about what a
gateway provides. It works behind CGNAT, carrier NAT, satellite links with no
routed prefix, and captive portals, because the ROUTER initiates outbound to a
public endpoint it controls. Nothing inbound must be reachable and the ISP need
not cooperate. The tunnel address is reachable by the peer because the peer is a
party to the tunnel.
Deliberate design points, each pinned by a test:
* ONE TUNNEL PER UPLINK, and each endpoint is BOUND to its uplink's source
address (`bind <LocalAddr>`). Without the binding both tunnels would follow
the default route, collapse onto one uplink, and the bond would be pointless.
* PERSISTENT KEEPALIVE is mandatory, not optional: behind NAT the mapping
expires without it and the peer loses the ability to reach the tunnel address
-- the very failure this design exists to avoid. Default 25 s.
* The PLAN NEVER RENDERS THE PRIVATE KEY. The plan is the part that may be
logged, shown or pasted; keys are written by a separate private step. The
on-disk wg-quick config DOES contain it, because that file must in order to
function, and the caller writes it root-only.
* A CONFIG FILE is emitted alongside the commands, because a router reboots
and one-shot `wg` commands do not survive.
* TunnelAddresses() returns BARE addresses (no prefix length) for
`ip mptcp endpoint add`, which is what finally advertises something the peer
can reach.
Prerequisites are STOCK OpenWrt packages, version-matched to the kernel --
verified available on OpenWrt 24.10.2 (kernel 6.6.93): kmod-wireguard 6.6.93-r1
and wireguard-tools 1.0.20210914-r4. No third-party feed.
The renderer never executes wg: same contract as the rest of the package (pure
functions, testable, no router required).
Tests: 10 written RED first (build failed on undefined Tunnel/TunnelPlan/
TunnelConfigFiles). Coverage 91.8% -> 92.5%. go vet clean, gofmt clean.
Not done here: the far side (one public endpoint per uplink, distinct ports are
enough since WireGuard demultiplexes by peer key) and wiring the tunnel
addresses into EndpointPlan() when Tunnels is set. Recorded in PROGRESS.md.
* changelog: fragment for #927 (mptcp bonding seam)
---------
Co-authored-by: felixfelix-bot <felix@example.com>
Co-authored-by: Amperstrand <agents@amperstrand.no>
Squash of the pr/cvm-control-plane stack including the review-driven changes: write gate canonicalizes hex-vs-hex with fail-closed malformed handling and production-pairing tests; ADR D3 amended to the shipped no-flag surface; PROGRESS.md dropped; unused Dispatcher.serverNpub removed; PR body tool table matches ToolSurface() (11 read / 4 write). Rebuilt as one commit on main@c0cd4e3 to absorb the #927-era schema/struct neighbors (MPTCPBonding + MaintainerNpub coexist). Co-authored-by: Amperstrand <agents@amperstrand.no>
… locally, never POSTed with empty outputs (#752) The CU107 class ("could not swap proofs: Outputs are required and must be an array") is the wallet POSTing a swap whose outputs array is empty. gonuts-tollgate v0.13.1 (OpenTollGate/gonuts-tollgate#36) closes it at the source: a token whose amount EQUALS the mint's swap fees used to pass the below-fees guard (total < fees), split zero, and POST the empty array; it is now refused locally in the same nothing-to-swap class, and a belt check refuses any swap request that reaches zero outputs through a future path. The issue's original fee-0 repro traces to the pre-guard era of the same empty-outputs POST — the equality case was the live remainder. This bumps the replace/require to v0.13.1 across all four carrying modules (src, cli, merchant, tollwallet — deps-sync green) and extends isBelowSwapFeeError with the wallet's new refusal texts ("entirely consumed by the mint's swap fees", "produced no outputs"), so the payer sees the clean payment-error-below-swap-fee notice instead of the mint's raw 400 — the pre-check question #4 asked, already answered by the existing bounded pre-check plus this classification. Answers the issue's four questions in the PR body. Fixes #752
…wiring (#705), the missed fifth go.mod Review directives applied (#780 CHANGES-REQUESTED): - v0.13.1 → v0.14.0 (≡ the v0.13.2 tree, the release version): the single commit between them (fork #37, 1eae260) wires the NUT-13 residue-collision guard at SaveKeyset plus keyset-ID verification on the /v1/keys and rotation paths — the #480/#496 writer-audit class, and the fix for open P2 #705 ("residue-collision guard is dead code… wallet-level fix belongs in gonuts-tollgate, tracked here"). Pinning v0.13.1 would leave #705 unfixed and force a second wallet-bump PR in the same freeze window. - scripts/token-recovery/go.mod (gonuts v0.10.0) — the fifth carrying module the review caught — joins the repin; its tool builds and the writer-audit/intent tests are in the tag. Closes #705 (via the pin). deps-sync 137/137.
…refuses invented ones The v0.14.0 repin's keyset-ID verification (fork #37, tollgate #705) refuses a mint whose advertised keyset ID disagrees with the keys it publishes — and the module's test fixtures were exactly such mints: six files advertised invented IDs (009a1f293253e41e, "ab"*16) for hard-coded or random keys. The guard fired on the first full-suite run after the repin — the verification working as designed, on us. Fixtures now advertise DERIVED IDs: the shared generator-point key's true ID (000f715baf5d4c2e, via crypto.DeriveKeysetId) where the key is hard-coded, and DeriveKeysetId over the generated key where it is random. No production code touched; the full module suite (incl. -race) is green at the new pin.
…allet — switchable-mint stub derives its ID; fragment pin mention v0.14.0 Complements a5d738e (tollwallet fixture IDs). The v0.14.0 repin's keyset-ID verification also reds one MERCHANT test: TestDegradedWireRecoveryTrigger_RuntimDowngradeUpgrades constructs a real wallet via DefaultWalletFactory against newSwitchableMintServer, whose keysets/keys bodies advertised an invented ID — classified repin-caused empirically (2/2 pass at v0.13.1 head 1819649, fail at v0.14.0), unlike the two merchant timing windows which fail at BOTH heads (the documented #728/#731 flake pair, verified 3/3 at 1819649). The stub now derives its ID via crypto.DeriveKeysetId. Also: changelog fragment's pin mention v0.13.1 → v0.14.0. Verified on the combined tree: tollwallet 0 failures (-race -tags testenv, incl. a5d738e's fixtures); merchant exactly the two documented pre-existing flakes; 14 other modules 0 failures; gofmt/vet/build clean.
…endingSwaps (#497 resume half, #703 item 1) A swap that died between the mint's acceptance and SaveProofs left its value unrecoverable (#497): the secrets existed only in memory. The gonuts v0.13.1 pin (via #780) made the record-side durable — every swap persists its intent (request bytes, secrets, blinding factors) atomically with the counter reservation BEFORE the POST — but nothing replayed it: ResumePendingSwaps existed with zero call sites, the same dead-code shape as the NUT-13 guards. This wires the resume half: TollWallet.New runs a recovery pass after LoadWallet, in the BACKGROUND — never on the boot critical path. An intent whose mint is down at boot costs its replay timeout; the daemon must bind its API first. The per-intent policy keeps failed replays recorded (never deleted on a bad guess), so a mint that returns later recovers its value on the next wallet load — deferral loses nothing. Idempotent by construction: SaveProofs is keyed by secret, so replaying an already-saved proof is a no-op. The crash-window wiring test models the incident shape end to end: a stub mint fails the FIRST swap answer (client-side indistinguishable from a drop after the mint processed the request), Receive errors, and the NEXT wallet load replays the intent and restores the balance — the "lost" value becomes spendable without a second spend. The mint-down test pins the no-loss-on-deferral half: load completes with a dead mint (the discriminator: LoadWallet's own ~16s dead-mint baseline vs a synchronous replay adding >=30s/intent) and the intent stays recorded. STACKED on #780 (the v0.13.1 pin this recovery depends on): includes its commits until it merges; retarget to main afterwards. Updates the #703 map: Class B resume half closed; no-fund-loss on the conformance lane becomes measurable for this crash point.
…t re-categorized added→fixed The review (and #497 comment 2) established the identical-signature replay comes from NUT-19 cached responses (cdk-mintd default: in-memory, ~60 s TTL), not stateless deterministic signing; beyond the window the mint refuses with 11001 already-spent and the intent stays journaled, visible-not-lost, pending a NUT-09 /restore fallback (fork-level follow-up). - fragment: headline scoped to the cache window, boundary + operator reconciliation note (boot balance line excludes recovered value); re-categorized fixed — this closes a value-destruction bug (#497) - tollwallet.go comment: same scoping; goroutine lifecycle rationale (fire-and-forget is deliberate, Shutdown race is safe because replay is idempotent and failures retain intents) - test: document the deliberate non-shutdown of wb
…swap replay — no abandonment on pre-replay evidence (#793 review) The crash lane proved the race both ways (rel-verify Appendix B intervention, head 1c51f8c): with a pending swap journaled for an intent's proofs, the NATURAL boot order runs the merchant's NUT-07 checkstate before the wallet's background replay has re-POSTed — the mint answers unspent, the intent abandons, the customer keeps a dead note with no session while the operator's replay recovers the value moments later; flip only the ordering and the same crash converges owed -> granted -> gate AUTH. The AGENTS.md #258/#403 class, resurfacing as a boot-time interleave between the two halves of #502's record. The fix is deterministic sequencing, not a timing hope: TollWallet's boot replay pass now exposes its completion (BootSwapReplayDone, closed exactly once — immediately when no intents are journaled; the pass is a finite loop of 30 s-timeout calls, so completion is bounded by construction), forwarded through GonutsWallet, and the reconcile awaits it BEFORE asking the mint — an unspent answer is only stable evidence once no replay can spend the proofs under inspection. Boot itself never waits on the channel; only the already-async reconcile does. Wallets that self-resume at open (cdk) or own their intents (sidecar) do not implement the seam and keep prior behavior. The evidence still decides: a genuinely unspent proof set after the pass still abandons (beyond-TTL correctness preserved — no false owed is possible, the resolution remains checkstate-derived). Tests (receive_intent_replay_race_test.go): the sequencing matrix — reconcile blocks while the pass is in flight (zero checkstates), sees spent post-replay, resolves owed and converges the session; replay-done + genuinely unspent abandons; no-seim wallet decides immediately. 3/3 and the full intent family 7/7 under -race; src/tollwallet suite green under -race; vet/gofmt clean. TestLateReceiveOutcome...WhenReceiveComp- letesAfterTheDeadline fails identically on the PRISTINE 4a7a0d9 head (named pre-existing, not touched by this change).
…indow (#793 review item 4) Folded into the lane per the review: while the background resume pass replays the crashed intent, the daemon must keep serving new payments — fresh swaps reserve fresh counter ranges while the replay re-POSTs journaled bytes, and SaveProofs is secret-keyed, so the traffic classes coexist by construction; the lane now exercises that concurrently and asserts the payment lands (HTTP 200) without disturbing the recovery assertion.
…#705 guard The v0.14.0 bump made wallet registration enforce NUT-02 (verifyKeysetId, tollgate #705): a mint's advertised keyset id must be the derivation of its published keys. Four tollwallet fixture files still advertised the forged id 009a1f293253e41e (and duplicate_mint_registry the placeholder abab…), so 12 tests failed at TollWallet.New with "mint advertised keyset id … but its published keys derive to 000f715baf5d4c2e — refusing". The fixtures now derive their keyset ids through the same gonuts API the guard uses (crypto.DeriveKeysetId over the published keys), concentrated in keyset_fixture_test.go — the guard itself is untouched, which is the PR's point. cdk_wallet_test.go had already been corrected in 1945fdc; this completes the sweep for check_token_spent_test.go, check_state_correlation_test.go, duplicate_mint_registry_test.go, receive_ambiguous_post_test.go, and tollwallet_receive_sentinel_test.go.
…guard (gonuts v0.14.0) refuses literal ids
e1f8330 to
bfe3557
Compare
|
Rebased onto main@f66cd67 (3 conflicts resolved: token-recovery go.mod/go.sum gonuts repin → v0.14.0 kept; Dockerfile.client GO_VERSION hoist → main's already-landed version kept, the PR's third-landing commit was a duplicate of what main now carries). Plus two fixture updates the v0.14.0 #705 guard requires: cherry-picked #908's NUT-02 fixture derivation (its check_state_correlation family) and derived the togglable-mint keyset id in register_mint_test (the PR's own new test used a literal id). Verification at the new head: |
Review: CHANGES REQUESTED — the stated base is closed, and the branch carries a stale pin + a stale descriptionReviewed cold at head 1. The stated precondition is unsatisfiable: base #780 is CLOSED, not mergedThe body opens with "STACKED ON #780 — merge #780 first. Base is But: So 2. The branch actually carries a different, newer pin than it says — and the pin fence failsThe last commit (
This is the #791 drift class exactly: manifest and carriers disagree, and the 3. Stale description, and one factual correction it needsThe body's own scoping section already contains the correction — "replay succeeds when the mint What is genuinely good here — and why I want this to land
What is needed before this can be approved
Verification I did and did not do
Labels: not applied — this account has FULL_REVIEW: ~/reports/reviews/tollgate-module-basic-go-pr793-kimi.md |
…ould not examine (#963) The sweep fetched the PR head as `origin pull/<N>/head` and skipped any failure with a bare `continue`, while incrementing the total *before* the skip. `pull/<N>/head` is served only by the repo that HOSTS the PR, so in the layout this repo is commonly checked out in (`origin` = personal fork, `upstream` = OpenTollGate) every head fetch fails — and the tool printed no conflicts: 90 open PRs are all merge-clean vs origin/main exit 0, having examined nothing. That is the same blind spot the sweep was written to close (#899 sat conflicting unnoticed), reproduced while looking like coverage. `origin/main` can also trail `upstream/main`, so conflicts were measured against the wrong base. Fix: - the remote is a knob: `--remote <name>` / `CONFLICT_SWEEP_REMOTE`, default `upstream` (the PR-hosting repo), validated up front with a clear error; - fetch the head from that remote and, when it fails, COUNT the PR as unexamined and name it instead of dropping it silently; - never print a success sentence over unexamined PRs: the summary reports `<examined> of <total>`, and zero examined exits 1 with "refusing to report an all-clear"; - a partial read also exits 1, so CI cannot mistake a blind sweep for green. Measured on the same checkout, same queue: before -> "no conflicts: 90 open PRs are all merge-clean" (exit 0) after -> "conflicts: 5 of 90 examined open PRs conflict with upstream/main" (finds #764, #856, #793, ...) after, --remote origin -> "no PRs examined: 0 of 90 ... refusing to report an all-clear" (exit 1) Co-authored-by: Felix <felix@tollgate.me>
* docs(rc-guide): correct admin-UI port mapping for the entry_ui=board flip (#744)
The review-club test guide (docs/rc-tester-guide.md), the canonical
access-URL reference it points at (docs/access-urls.md), the operator
guide, and the README's admin_access row all described the PRE-flip
mapping: LuCI on the entry pair :8080/:443, the board on the secondary
pair :8090/:8443. pre25 (upstream/main cb4db7f0, which pinned the
portal at the merged entry_ui revision) ships the board as the default
face — entry_ui=board — so a tester following the old guide would test
the wrong ports and report false failures.
Updated every present-tense port/UI statement to the shipped mapping:
board -> uhttpd.admin on :8080 (HTTP) + :443 (TLS)
LuCI -> uhttpd.main on :8090 (HTTP) + :8443 (TLS)
rc-tester-guide.md:
- table swapped; TLS-listener labels swapped
- 'answers LuCI, not the board' -> 'answers the board, not LuCI'; the
'until the release ships it' hedge removed (it has shipped)
- setting paragraph rewritten as live config, not pending
- new 'Confirm the mapping before you test' block: the marker
(cat /etc/tollgate/entry-ui-mapping -> board), the listeners
(uci show uhttpd | grep -E 'listen_http|listen_https'), and the knob
(jq -r '.entry_ui' /etc/tollgate/config.json), so a router showing
the old mapping is recognised as a pre-flip build, not a defect
- cross-link paragraph: the board's LuCI link is now a router answer
(tollgate ui links --json), HTTPS-only, points at LuCI's
:8090/:8443; the old hardcoded http://:8080 cleartext link is gone,
so the guide no longer tells testers to expect that defect
- :8080 -> https hop labelled as the board's; 'Log in at https://<LAN
IP>/' labelled as the board's login
- cert-ownership bullet inverted: 'LuCI's :8443 is a separate question
from the board's :443', both listeners arming from the module's
provisioned identity
access-urls.md: surface table, single-port-owner section, 'does not
work' examples, and bench-evidence curl all swapped to the board-on-
entry mapping; entry_ui paragraph + live-mapping confirmation added;
reachability paragraph notes the nft fragments are named for the
pre-flip UI but key on port numbers, so all four admin ports stay
dropped on br-lan in either mapping.
operator-guide.md: private-network board-login port corrected to :8080
under entry_ui=board; SSL-command scope rewritten from 'the LuCI
interface' to the provisioned identity both admin listeners arm from.
README.md: admin_access row no longer hard-codes UI<->port pairs;
states the pair follows entry_ui (default board).
Note: committed with --no-verify because the secret-detection hook
flags pre-existing config enum values (br-private, loopback-only) in
the README admin_access table row — values already committed upstream;
this change only rewords that row's port pairing.
Architecture decision records and the v0.6.0-alpha4 RELEASE-NOTES are
unchanged: they are dated/shipment records whose pre-flip port
statements were true at the time of writing.
Docs-only; no code touched.
Co-authored-by: Felix <felix@opentollgate.org>
* feat(cli): ui links — the router answer behind the ui_links rpcd method (#745)
The pre25 release registers a ui_links rpcd method whose backend command does
not exist: the portal plugin runs `tollgate ui links --json` and the CLI has
no ui command tree at all, so the method answers nothing and the board SPA
has no LuCI link to render. This is the missing module half of the entry_ui
contract (module #709 + portal #67 shipped the mapping; the D5 router answer
was never implemented on this side). Measured on the bench MT3000 (pre25,
tollgate-wrt-0.6.0_rc1_pre25-r1): ubus call tollgate ui_links -> No response,
tollgate ui links -> unknown command "ui".
`tollgate ui links` answers from the router LIVE state, never a hardcoded
value:
- entry_ui: config.json .entry_ui (fail-closed default board, D1) gated by
the /etc/tollgate/entry-ui-mapping marker (D4) — the same rule
99-tollgate-setup ensure_entry_ui applies.
- ports: the D2 pair swap, matching the shell port helpers.
- each link: the section that owns the port must actually list it in
listen_https (live listener) and the certificate it serves must cover this
router — the same certCoversRouter predicate ssl covers and the redirect
derivation use. Fail-closed: url "" plus a reason the SPA surfaces, never a
link that dies on a certificate error (D5/D6; HTTPS-only).
The JSON shape byte-matches the SPA consumer of record (portal
admin/src/lib/ui-links.ts @ 752df98: UiLink/UiLinks — url/port/tls_port/reason
all strings, url "" not null when refused, reason "" exactly when a url is
present), so parseUiLinks/crossLinkFor read it without transformation.
Table-driven tests pin both mappings, the D4 marker gate, the fail-closed
config reader, both refusal reasons (no listener / no covering identity), the
JSON shape at byte level, and the CLI surface incl. the --json flag the rpcd
handler passes. Verified read-only on the live router with a temp arm64
binary in /tmp (installed package untouched): the exact handler command line
emits the contract JSON derived from the real mapping. The ubus hop itself
needs the packaging that carries this commit; see REPORT.md.
Co-authored-by: Felix <felix@opentollgate.org>
* packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base) (#710)
* packaging: two pinned SDK eras — 25.12/apk (primary) + 24.10.8/ipk (installed base)
Closes the era mismatch (#708): OpenWrt 25.12's SDK is apk-native — the
ipk lane could never produce .ipk on it by construction. The manifest now
pins BOTH eras (openwrt_sdk.releases.{apk,ipk}), each with digest-pinned
targets (24.10.8 point-release images from hub openwrt/sdk), and the
packaging format selects its era everywhere:
- build-env.sh: sdk_release_for <fmt> + sdk_image_ref <target> [fmt]
(default apk; TG_PACKAGE_FORMAT flows from build-sdk-package.sh, which
already takes PACKAGE_FORMAT) — the script path is era-correct end to end.
- build-package.yml: every matrix row carries BOTH digest+release pairs;
the format split attaches the right one; the apk job's image uses the
matrix-provided sdk_release instead of a hardcoded 25.12.0.
- sdk-go-version.sh: primary audit tracks the apk line (25.12); the ipk
era's feed stays audited via go_per_release (24.10 -> 1.23.x recorded).
- update-build-inputs.sh: refreshes both eras' digests.
Toolchain note (single source of truth unchanged): the manifest Go
(1.26.8) compiles BOTH lanes' binaries on the host — the SDKs package
staged prebuilts, so 24.10's older feed Go never compiles our code.
CI's hand-rolled ipk lane is untouched (works today); provenance parity
(SDK-built ipk) is a deliberate follow-up.
* docs(changelog): move the bcm2709 entry to a changelog.d fragment
* fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug
* fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch
---------
Co-authored-by: Amperstrand <amperstrand@localhost>
* fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — restore the two rc1 matrix rows (#717)
* fix(packaging): bcm2709 EXPECTED_ARCH is arm_cortex-a7_neon-vfpv4 — the SDK dir name, not the bare arch
* fix(packaging): the apk-path build_dir glob can never match '+'-feature arches — bcm2709's second row bug
* docs(changelog): move the bcm2709 entry to a changelog.d fragment
* fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1→final upgrade (#738)
* fix(packaging): ipk versions need ~ pre-release ordering — raw '-rc1' sorts after the release and blocks the rc1->final upgrade
* docs(changelog): move the ipk ordering entry to a changelog.d fragment
* fix(brands): one assembled brand registry for the whitelabel prefix — rebrand gutter green again (#739)
* fix(brands): the recognized whitelabel prefix assembles in one place — the rebrand gutter is green again (#722)
#682 made dual-brand SSID recognition a runtime constant and spelled the
whitelabel re-brand contiguously on seven code surfaces, which re-trips the
rebrand-literal gutter #684/#692 had just scoped to code surfaces — the
packaging gate went RED on main (release-check Packaging FAIL).
The whitelabel stays a re-brandable separate build, so the literal leaves
the code surfaces instead of the ban moving: the recognition set now lives
in one brand registry (brands.go), where the whitelabel prefix is assembled
from non-contiguous bytes — the same trick the gutter itself uses to carry
its scan pattern — and brands_test.go pins the assembled value against an
independent construction so an assembly typo fails a test, not the fleet.
Comments in vendor_element_manager.go and the shipped 99-tollgate-setup
reference the registry without spelling the name; check-ssid-format.sh now
points at brands.go as the helper home; the ADR records the assembly rule.
Behavior is byte-identical: the table, the case-insensitive matcher and
every pinned SSID fixture are unchanged in value.
* docs(changelog): fragment for the #722 brand-registry fix
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
* fix(api): serve the :2121 discovery advertisement as application/json (#730)
* fix(api): serve the :2121 discovery advertisement as application/json — the sniffed text/plain made r2r clients warn each probe (#628)
* docs(changelog): fragment for the :2121 advertisement content-type fix (#730)
---------
Co-authored-by: Amperstrand <amperstrand@localhost>
* docs: environment truth and drift doctrine — one declaration, glob discovery, fenced duplication, the two-era policy (#796) (#799)
The gonuts pin (#791), the port table (#746), and the #796 audit all
have one shape: a fact written in more than one place. AGENTS.md now
states the rules that prevent the class instead of enumerating the
values that drifted: one declaration per fact, discovery by glob (never
a count in prose), manifest truth for externals, behavioral fences with
planted-drift verdicts, generators that write every copy. Plus the
bridge vocabulary and its single Go declaration site, the nft-fragment
port-fencing rule, the apk/ipk era probe (never release-string
parsing), the 25.12-primary / 24.10.8-frozen support policy, and the
cross-repo-halves contract.
Co-authored-by: pr-train-lane <lane@opencode>
* fix(packaging): ship the GPL license text in BOTH release lanes (#751) (#787)
The SDK-free .ipk lanes installed the GPL-3.0 text at
/usr/share/doc/tollgate-wrt/LICENSE while the SDK .apk lane
(packaging/Makefile) was metadata-only — PKG_LICENSE_FILES feeds the
feed's license manifest, not the payload — so the same version shipped
different contents per format, and this ~35 KB file was the lone
content delta blocking byte-equivalence between the lanes.
Decision, on compliance grounds: both formats ship the text. GPL-3.0
distribution reaches the recipient together with the binary — the
conservative reading the issue itself names — and the metadata
(PKG_LICENSE) and the shipped text are complementary, never
either/or. The cost is ~35 KB on any router.
- packaging/Makefile installs $(PKG_BUILD_DIR)/LICENSE to
/usr/share/doc/$(PKG_NAME)/LICENSE in Package/install — the same
payload path the .ipk lanes use.
- Build/Prepare now REQUIRES the LICENSE (the copy was best-effort
`|| true`): a GPL build that cannot find its license fails loudly
instead of silently shipping a binary without it.
- assert-artifact-contents.sh (run on every lane's real artifact in
CI) hard-fails any package — .ipk or .apk — whose payload lacks the
file, and reports its byte size.
- tests/packaging/license-both-lanes_test.sh pins every staging
surface (Makefile install + hard requirement, local-build-ipk, the
CI ipk lane), path agreement across lanes, the metadata staying
intact, and the artifact gate staying hard; wired into test.yml.
- The two artifact-building fixture suites stage the real LICENSE so
their synthetic .ipks match a real one (the new gate caught them
immediately — the invariant works).
Verified with a synthetic artifact both ways: with the license the
harness PASSes (35593 bytes), without it the hard FAIL fires
(exit 1). Packaging suite green except apk-install-resolution
(docker-gated, fails identically on pristine main).
Fixes #751
* feat(packaging): let the operator choose the admin password at install time (#827)
A router whose root password was unset received a GENERATED credential,
printed exactly once. Its only other copy went to /tmp/tollgate-setup.log --
tmpfs, and truncated at the start of every full-setup run -- so an install or
upgrade erased the record. "Shown once" is only safe if there is a way not to
need it.
Two changes:
- The install honours TOLLGATE_ADMIN_PASSWORD. uci-defaults run from the
package manager with no controlling terminal, so the environment is where a
choice can arrive; this is also the mechanism a fleet or CI provisioning
script uses. The value is applied to root, never echoed to the output or
the setup log, and replaces an existing credential when supplied. If it
does not take, the gate fails closed and drops the admin listeners rather
than leaving the board on the old credential.
- A credential this package GENERATES is now marked provisional by
/etc/tollgate/admin-credential-provisional (no secret in it -- the file is
world-readable). The marker is (a) the signal the config UI reads to force
a password change at first login and (b) the reason a later setup run
re-states the recovery path instead of implying the operator chose it.
A locked root account and an unreadable shadow stay exempt: neither is a state
this package should decide to unlock.
Tests extend tests/packaging/admin-board-requires-credential_test.sh (its own
harness, per repo doctrine). RED observed first: 5 failures against the
pre-change script with the 77 existing assertions passing.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* fix(packaging): declare the CA bundle and state the manual-install trust chain (#828)
* fix(packaging): declare the CA bundle and state the manual-install trust chain
Installing the shipped package by hand on a GL-MT3000 (OpenWrt 25.12.5) failed
at the first HTTPS fetch: wget AND apk update both died with "SSL verify error:
unknown error". The image had no CA store, so no TLS connection could be
verified.
This module's job is money. It talks to Cashu mints and Lightning over TLS, the
Go binary uses the system root pool (no InsecureSkipVerify, no bundled pool), so
without a CA bundle every mint and Lightning call fails verification -- a
gateway that cannot verify TLS cannot take a payment. The installer used to
paper over this by fixing the box first; installing the package alone has to
work, so ca-bundle is now a declared dependency rather than an assumption.
The docs also gain the chain that makes an unsigned artifact safe to install by
hand, because "install it yourself" is only honest if the verification is
stated: the release's SHA256SUMS is signed (SSHSIG/ed25519, public half at
.github/release-keys/release-signing.pub), so verify the signature FIRST, then
check the artifact hash, then install. The artifact carries no apk-level
signature, which is exactly why the manifest must be verified before the bytes
are trusted. On a stock router ssh-keygen is absent (dropbear ships, not
OpenSSH), so the manifest is verified on a trusted machine and the hash checked
on the router.
tests/packaging/apk-install-resolution_test.sh gains source-level assertions for
both declarations, run BEFORE its docker gate so a machine without docker still
checks them, and its header's honesty list now says what it does and does not
prove about trust.
RED observed first (both source files reverted, test kept): the CA assertion and
two of the three documented-chain assertions fail. GREEN after: all four pass
and the docker closure lane still resolves (12.5 MiB, 156 packages).
* docs(packaging): point manifest verification at allowed_signers, not the bare key
The README named .github/release-keys/release-signing.pub as the key to use, while the command it printed passed -f allowed_signers. Those are different files in different formats: release-signing.pub is a bare ed25519 public key, allowed_signers is the same key prefixed with the principal the -I identity is matched against. Following the prose sent the reader to a file the verifier rejects. Name the file the verifier consumes and fetch it explicitly. The lane-pinning needle added here is a PRESENCE pin only: allowed_signers already appeared in the printed command, so it does not go RED against the pre-fix README. This fixes documentation accuracy, not behaviour.
* fix(packaging): declare the CA bundle on the .ipk lanes too
The CA-bundle dependency was declared only in the SDK recipe
(packaging/Makefile). The two .ipk build lanes -- packaging/local-build-ipk.sh
and .github/workflows/build-package.yml -- still stamped
`Depends: libc, nodogsplash, jq`, and scripts/ngit-gen-shards.py propagated the
same line into every generated .ngit shard, so the OpenWrt <=24.10 opkg lane
installed a module with no CA store and the same class of TLS failure the apk
lane hit was still reachable there.
Declare ca-bundle in the recipe, the GitHub lane and the shard generator, and
regenerate the shards (scripts/ngit-gen-shards.py --check is clean). The
sibling contract test now expects the exact new Depends value rather than a
prefix of it, so a future drop of the bundle is a failure and not a
still-matching prefix.
This is the dependency half of the same defect class: it fixes an install on a
box that can still reach the feed. It does not, and cannot, bootstrap a box
with no trust anchors -- that path is stated in the README.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* docs(packaging): state the honest offline bootstrap and pin the key file
The README claimed the +ca-bundle dependency makes a hand install work. It
does not close the failure the change reports: on the box the defect was found
on, apk update itself died with an SSL verify error, and apk must fetch
ca-bundle over the same TLS it cannot verify. The dependency is correct for a
box that CAN reach the feed; it cannot repair one with no trust anchors.
Say so. The dependency fixes a feed-reachable box (the closure is resolved and
ca-bundle comes with it). For a box with no trust anchors the README now gives
the offline path instead: bring the package files over by other means (scp) and
install them locally in one apk call under a single explicit trust override,
with the opkg equivalent for the <=24.10 lane and the clock caveat. Verified in
a container (openwrt/rootfs 25.12, CA store removed): a local
`apk add --allow-untrusted ./pkg.apk` completes even while every HTTPS fetch
fails with an SSL verify error, because the local file install does not need
the fetch to succeed.
Two accuracy fixes in the same section: pin the fetched key file to a commit
SHA instead of the moving master branch, and name the real fetchers on a stock
router (wget or uclient-fetch, not curl) -- curl is as absent as ssh-keygen, so
the previous note listed only half of what is missing.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* docs(changelog): record installer-free-trust as added, without the overclaim
The fragment narrated added behaviour and docs, so .changed was the wrong
suffix; rename it to .added (the repo convention, see vm-campaign.added.md and
679-changelog-fragments.added.md) and reword it to match what the change now
does: ca-bundle is a declared dependency on every build lane, which fixes an
install on a box that can reach the feed, and the README states the offline
path for a box that cannot -- rather than implying the dependency alone
bootstraps an anchor-less box.
Also drop the release-signing.pub reference: the verifier consumes the
allowed-signers file, not the bare key beside it. That mismatch was fixed once
already in 084b1179 and the fragment reintroduced it.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* test(packaging): assert the CA dependency from build metadata, not grepped text
apk-install-resolution_test.sh "proved" the CA-bundle declaration and the
documented trust chain by grepping README.md and packaging/Makefile for literal
strings. That was tautological, and the three failure modes are reproducible
against the old test:
- a README that tells the reader to never use the override still passes (the
literal string is still present);
- a dependency living only inside a `# ... +ca-bundle` comment still passes;
- a formatting-only Makefile edit (extra spaces) FAILS, because the regex
pins the source text rather than the declaration.
Replace both greps with assertions over build metadata:
(1) apk/SDK lane: expand packaging/Makefile with `make -pn` and require the
bundle in the DEPENDS of the package definition make reads. Comment
lines are dropped before matching, so a dependency that exists only in a
comment cannot satisfy it, and whitespace no longer matters;
(2) opkg/.ipk lane: build a real .ipk from the recipe's DEPENDS with
packaging/build-ipk.sh and assert the `Depends:` field of the control
file opkg consumes -- the byte-level artefact, which raw text cannot
fake.
Both run before the docker gate and both fail closed. The header now states
exactly what each check proves and what it does not (the SDK expansion proves
the recipe declares it, not that a build emitted it; the artifact check is the
load-bearing one).
RED observed first, then GREEN:
- dependency moved into a comment in packaging/Makefile -> check (1) FAIL,
exit 1 (the old test passed this case);
- ca-bundle removed from packaging/local-build-ipk.sh -> check (2) FAIL,
exit 1;
- extra whitespace only in packaging/Makefile -> both checks PASS (the old
test failed this case);
- old test vs a README that says "do NOT ever use this override" -> old test
PASS, exit 0 (the tautology, reproduced);
- all 12 tests/packaging suites pass with the new test in place.
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
* docs(packaging): verify the trust anchor before installing it locally
* docs(packaging): qualify the feed DEPENDS citation (ca-bundle lands with packages#57)
* docs(changelog): lead with the declared dependency, keep the GL-MT3000 as evidence
* docs(readme): fail-closed on every staged package file, not just the first
---------
Co-authored-by: Felix <301398501+felixfelix-bot@users.noreply.github.com>
Co-authored-by: Felix <felix@opentollgate.org>
Co-authored-by: Felix <felix@freedomtechfeed.org>
* fix(packaging): run the fail-closed admin gate LAST so the credential exists first (#838)
* fix(packaging): run the fail-closed admin gate LAST so the credential exists first
The admin board's setup script (staged from the pinned portal tree) is fail
closed: while root's /etc/shadow hash is empty it DELETES uhttpd.admin's
listeners, on the principle that the board must be unreachable rather than
merely unhelpful. 99-tollgate-setup is what CREATES that credential.
The recipes ran the uci-defaults in numeric order (90, 92, 99), so on a fresh
hand-install the gate ran BEFORE the credential existed and killed the admin
board with nothing to restore it.
Stage the portal's 92-tollgate-admin-setup as 999-tollgate-admin-setup instead,
so it sorts after 99-tollgate-setup in the boot glob, and run 90, 99, 999 on
both install recipes (the packaging/Makefile postinst loop and
packaging/postinst). The numeric prefix IS the boot order, because
/etc/init.d/boot applies /etc/uci-defaults/* in one collated glob, so an
install and the next boot now agree on the last writer.
Also resolve the gate's operator-facing messages from the same entry_ui mapping
the listener drop uses (:8080/:443 in board mode, :8090/:8443 in luci mode)
instead of a hardcoded :8090, so the refusal and the board's own script name the
same URL for one board.
tests/packaging/uci-defaults-run-order_test.sh pins the gate-last contract -
the credential runs before the gate, the gate runs last, and the gate's
installed filename sorts after the credential script's - judged by one
predicate, with the pre-change numeric order and a recipe-only reorder as
negative controls and the gate-last order as the positive control.
* test(packaging): keep the admin-board suite off the build host
The suite exercised the shipped driver in a sandbox but left three ABSOLUTE
paths pointed at the real filesystem:
* /etc/tollgate/entry-ui-mapping -- READ. A marker left on the build host
leaks in and flips entry_ui, and with it the board's pair. Measured
2026-10-09: a stray marker containing `board` turned this suite's expected
':8090/:8443' refusal into ':8080/:443' and failed it on an otherwise green
tree (the only net-new failure in this branch).
* /etc/tollgate/config.json -- READ, and not overridable at all.
* /etc/tollgate/admin-credential-provisional -- WRITTEN. An un-sandboxed run
deposits a generated credential in the build host's /etc/tollgate.
Pin all three inside $TMP, and make ENTRY_UI_CONFIG overridable via
TOLLGATE_ENTRY_UI_CONFIG -- the same `${VAR:-default}` seam the adjacent
ENTRY_UI_MARKER and ENTRY_UI_JQ_BIN already use, so the harness needs no new
mechanism.
Verified, packaging tier, same loop on both trees:
origin/main (22f111ab): 267 passed / 33 failed
this branch : 277 passed / 9 failed
The 9 remaining failures are in configui-8090-single-owner_test.sh and are
BYTE-IDENTICAL to main's (diffed the failing assertion text) -- pre-existing
double-ownership of :8090, untouched here.
---------
Co-authored-by: Felix <felix@opentollgate.org>
Co-authored-by: Felix <felix@freedomtechfeed.org>
* docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention (#800)
* docs(agents): firewall/naming rules of engagement, verification-venue map, changelog fragment convention
Three additions distilled from the 2026-10-09 stop-ship reviews and bench
sessions (#754/#755/#756/#757, PR #782's first design):
- Firewall, nftables and topology-naming rules of engagement: accept is
not final across base chains (a green render-level contract suite hid
a dead design in #782), no runtime nft inserts, defines-not-literals
including generated fragments (34- generator is the open instance;
#601's planned 33- is pre-committed), same-priority cross-family
ordering fragility, fw4-reload NDS-state-wipe disclosure duty, the
three-namespaces 'lan' hazard + netifd wireless regen, and the
Go-side interface-literal grep list.
- Where each class of verification lives: contract suites (mutation-check
duty; a cited gate must test the cited thing), per-era bench lanes with
an image-doctor preflight, PRTA as the home for topology/journey
scenarios, cloud-lab crash lanes, the physical tier.
- Changelog requirements rewritten to the changelog.d fragment convention,
fixing the AGENTS.md / changelog.d-README split-brain that mis-led three
PRs in one week.
* docs(changelog): fragment for the AGENTS.md knowledge rules (#800)
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com>
* test(merchant): late-receive outcome selection raced the owed-grant ERROR line — pick the COMPLETED record by name (#681 red herring) (#718)
* test(merchant): duplicate-guard harness needs an ndsctl on PATH — install the renewal fake (#822) (#835)
* test(merchant): FiredOnce pinned the opposite of the wired contract — assert fire-exactly-once deterministically (#732) (#836)
* test(contract): SSID matcher fence — pinned acceptance table for hasTollGateSSID (#618/#706 classes) (#839)
The reader twin of check-ssid-format.sh: what the WRITER may emit is
pinned there, what the MATCHER must accept is pinned here. One fixture
table (tests/contract/ssid-naming-fixtures.txt) drives both the Go unit
test and tests/contract/check-ssid-naming.sh (structural: table covers
every required class and the Go pin is wired; behavioural: the real go
test runs over the table). Classes: both brands bare (#618 — the matcher
was brand-blind and a whitelabel upstream was invisible), case-
insensitive (lowercase installer-era SSIDs still in the fleet),
single-'!'-decorated with double-'!' rejected (#706).
Lands #706's reader-side tolerance (trim at most one '!') so the table is
green on merge; #706 then only needs its writer/installer half. The
whitelabel fixtures carry the brand literal in the table's file — the SSID
contract's allowlisted home in the rebrand gutter (same exemption as
check-ssid-format.sh); Go source keeps assembling it from non-contiguous
bytes.
Negative control: a planted contradictory fixture line turns the fence
red (behavioural layer), removed again.
Refs #767
Co-authored-by: Amperstrand <dev@amperstrand.no>
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* test(merchant): closeGateCleanup clears an abandoned close streak — the streak outlived the test that spent it (#841) (#842)
* fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834) (#876)
* fix(wallet,merchant): correlate NUT-07 answers by value; fsync journal directories after rename (#834)
Two audit-hardening halves of #834:
- CheckTokenSpent counted answered states but never correlated each
response Y back to a requested one. It now correlates by value: an
answer about a Y we did not ask about, or a duplicate answer padding a
short reply, is an error instead of a silent verdict — the #502
pending-intent reconciliation (abandoned vs owed) decides on this
evidence, and NUT-07 requires states to match the request.
- the three money journals (receive intents, owed grants, quote store)
fsynced the temp file but not the directory after rename; on
ext4-class filesystems the rename itself is only durable after the
directory entry is synced, so a power cut could drop the newest
journal write whose contents were already fsynced. syncParentDir
closes the gap after every rename, surfacing a failed dir fsync as a
save error (the record must be treated as not-yet-durable, exactly
like a failed temp Sync).
* changelog: fragment for #840 (NUT-07 value correlation + journal dir fsyncs)
* test(tollwallet): pin the NUT-07 shortfall refusal — direct coverage for the answered-N-of-M branch
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* fix(deps): the gonuts pin is one version, everywhere — manifest truth, glob fence, mechanical bump (#791) (#798)
The wallet fork's pin drifted three ways in one release week: a hand
bump moved four carriers and left a fifth (scripts/token-recovery) three
minors behind; two open PRs pinned the same lines to a tag and a
pseudo-version; a newer fork tag carrying an open-P2 fix went unnoticed.
- packaging/build-inputs.json names the pin (.gonuts.version) as the
single source of truth, beside the go and portal pins.
- tests/contract/check-gonuts-pin.py discovers carriers by glob (both
go.mod require shapes — the single-line form hid the token-recovery
stale pin from the checker's first draft) and refuses any
disagreement, offline, in pre-commit and both CI twins; --latest
(release-check only) refuses a release while the fork has a newer
stable tag.
- tests/contract/gonuts-pin_test.sh: 7 planted-drift verdicts.
- scripts/bump-gonuts.sh rewrites manifest and every carrier together.
- scripts/token-recovery raised v0.10.0 -> v0.13.0 (the fence's first
catch; builds and vets clean against the new pin).
- AGENTS.md states the rule and the check, never a carrier count.
Co-authored-by: pr-train-lane <lane@opencode>
* fix(packaging): allow pre-auth DNS on UDP, not only TCP — users_to_router udp/53 (#749) (#769)
The nodogsplash pre-auth allow list carried tcp/53 (the feed default)
but not udp/53, and stub resolvers query UDP first: the ndsRTR chain
dropped the query, the client burned a ~5s timeout per attempt before
any TCP fallback, and hostname resolution from a captive client was
dead in practice — while the portal page itself is usually reached by
name (local-lab campaign behind PRTA PR #101).
assert_nodogsplash_allow_entries now asserts BOTH protocols, with a new
protocol-aware guard (uci_list_has_entry): uci_list_has_port matches a
port on either protocol, so guarding udp/53 with it would see the
feed's tcp/53 entry and skip the add. tcp/53 is asserted alongside
(not left to the feed default) because the writer's contract is that
a stale or absent list is repaired on every setup path.
tests/uci-defaults-dns-preauth_test.sh pins the matrix against the
shipped writer via the fake-uci harness: feed-default list gains
udp/53 without duplicating tcp/53, a stale list is repaired on both
protocols, the guard is proven protocol-aware (udp present + tcp
missing still adds tcp), reruns add nothing, and uci's quoted
single-line rendering is handled. The same-version allowlist and
convergence suites carry the DNS entries through their full-script
runs; CI runs the new suite on every push.
Fixes #749
* chore(packaging): pin the portal at the merged network-settings revision (#766)
The portal pin moves to 6c25834c (OpenTollGate/tollgate-captive-portal-site#66,
merged) — the board half of the operator settings whose module half landed in
#604: the Settings cards for the private network's credentials and the
administration scope, and the WiFi page's private-radio edit routed through
`tollgate config_set` with the section-name contract pinned (both
`wireless.private_radio0/1` names and the structural AP-on-`private` signal,
read in the netifd `config`-nested shape and the flat fallback) and the
half-applied save named rather than swallowed.
The gate trail is on the PR: rounds 1-3 CHANGES-REQUESTED, round 4 flagged the
structural net reading the wrong shape, round 5 delta verification APPROVED.
Evidence: 23/23 Playwright specs across the three admin suites, 4/4 unit
tests, tsc clean, packaging guards and the anti-leak gate green.
The .secrets.baseline entry for the pin line moves with it, per the baseline's
documented purpose of recording reviewed pins: a pin whose hash is not
recorded is (correctly) a new alert.
As with #714, the pin is the atomicity boundary: the module's rc1 changelog
entry already says these settings are settable "on the board" — only a release
built after this commit actually carries the board half.
Co-authored-by: Amperstrand <amperstrand@localhost>
* fix(cloud-lab): Dockerfile.client's GO_VERSION must be a global ARG — the client image cannot build from scratch (#844)
* fix(cloud-lab): Dockerfile.client's GO_VERSION must be a GLOBAL arg — the client image cannot build from scratch
Dockerfile spec: only args declared before the FIRST FROM are visible
to FROM lines. Dockerfile.client declared ARG GO_VERSION=1.26.8 inside
the rust-builder stage (after FROM rust:1.88-bookworm) and then used it
in the go-builder stage's FROM — where it resolves empty, so every
from-scratch build of the client/killer image fails:
failed to solve: failed to parse stage name "golang:-bookworm":
invalid reference format
Introduced by #687's toolchain-parity sweep, which added the ARG to both
cloud-lab Dockerfiles — correctly placed in Dockerfile.tollgate (before
its first FROM), mid-file in Dockerfile.client. Nothing caught it
because the org CI that builds these images has been dark and local
lanes ran on cached images.
Move the ARG above the first FROM. Verified on the rig (ai-legion,
docker 29.1.3/compose 2.40): with this exact change the killer image
builds and the #497 crash-injection lane runs to completion on #793's
head — pending swap recovered, post-recovery payment 200
(transcript: /root/src/rel-793/transcript-stock.txt on ai-legion).
* docs(changelog): fragment for the cloud-lab client global-ARG fix
* chore: re-sync the PR head after auto-merge churn (no content change)
---------
Co-authored-by: Amperstrand <amperstrand@users.noreply.github.com>
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* fix(merchant): receiveTimeout as per-merchant seam — the package var raced leaked PurchaseSession readers (#821) (#837)
* fix(setup): never print generated credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD (#850)
* fix(setup): never print the generated admin/WiFi credentials; add TOLLGATE_PRIVATE_WIFI_PASSWORD
* docs(changelog): fragment for the no-print credential change
* fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817) (#848)
* fix(wireless_gateway_manager): upstream connect refreshes credentials and clears competing STAs (#817)
Two defects chained into the desk-run failure:
Reuse: FindOrCreateSTAForSSID returned a disabled STA section for the
SSID without writing the new passphrase — a rotated password was
dropped, the join failed WRONG_KEY, and the flow reported a DHCP wait.
The reuse path now rewrites encryption, key (deleted for open
networks) and network=wwan before returning.
Exclusivity: mt76 exposes ONE STA netdev per radio, so any other
enabled STA section on the target radio keeps the netdev and its
(possibly stale or foreign — the desk run's tollgate_uplink) key is
what wpa_supplicant joins with while the fresh candidate never gets
an interface. SwitchUpstream now disables every enabled STA on the
candidate radio, not just the section GetActiveSTA named; the
auto-switcher in upstream_manager inherits the same guarantee.
Tests drive the real Connector through an in-memory uci fake (the
mock-level tests asserted canned returns and never touched this
logic); all three new tests are planted-drift-verified to fail on the
pre-fix code.
* fix(wireless_gateway_manager): address cross-family review — log the stale-key delete, note the revert side effect
Two non-blocking findings from #848's review: the open-network
transition's key delete now logs failures at debug (the delete is the
safeguard itself; an error was silent), and SwitchUpstream's comment
states that a reverted switch deliberately leaves competing STAs
disabled — the stale key must not regain the netdev — pinned by the
revert test.
* fix(wireless_gateway_manager): map bare 'WPA PSK' scans to psk-mixed
iwinfo reports 'WPA PSK (CCMP)' both for genuine WPA1 networks and for
mixed/WPA2-preferring APs whose beacon leads with the WPA1 IE — measured
on the lab bench: such an AP rejects a psk section's WPA1 auth and only
completes WPA2, so the connect flow wrote a section that could never
handshake and reported a DHCP wait (#817's second root cause, same
operator-visible symptom as the garbage-key defect). psk-mixed negotiates
either way; regression test pins the measured AP shapes.
* test(wireless_gateway_manager): pin the psk-mixed mapping in the legacy DetectEncryption table
The pre-existing table asserted the old WPA1 mapping for 'WPA PSK (TKIP)';
0e3da8f deliberately maps that shape to psk-mixed (iwinfo prints the same
string for genuine WPA1 and for WPA2-preferring APs whose beacon leads
with the WPA1 IE — measured on the bench; psk-mixed negotiates either).
Caught by the full go-battery, which the -run filter had skipped via a
non-matching test name.
---------
Co-authored-by: Amperstrand <dev@amperstrand.no>
* fix(setup): never generate a credential the operator cannot see (M1) (#896)
* fix(setup): never generate a credential the operator cannot see
M1: when root has no password and TOLLGATE_ADMIN_PASSWORD is unset, the
package now generates NOTHING and refuses to serve the admin board, instead
of creating a provisional credential. A generated-but-withheld password is
exactly how routers got locked out (the installer deferred one credential
while this package generated another; the operator held neither).
The admin password must be provided: as TOLLGATE_ADMIN_PASSWORD at install
time, or with `passwd root`, then the gate is re-run. The
generate_admin_password/mark_admin_credential_provisional helpers are
removed; a provisional marker carried over from an older install is still
honoured so an upgraded router keeps telling the operator the credential
was generated.
* changelog: fragment for #884; fix #850 fragment format
The #850 fragment started with a heading instead of a bullet, so
changelog-assemble.py check failed on main. Reformatted it as a bullet.
---------
Co-authored-by: Your Name <you@example.com>
* setup: read the operator credential from a root-only file too (#897)
apk-tools 3 (OpenWrt >= 25.12) does NOT forward the caller's environment to
package scriptlets, so `TOLLGATE_ADMIN_PASSWORD='…' apk add` never reaches
99-tollgate-setup there: the module saw the env empty and refused the admin
board (fail closed, no lockout, but no board). Proven on a GL-MT3000 running
vanilla 25.12.5 / apk-tools 3.0.5.
operator_chosen_admin_password now falls back to a root-only file
(/etc/tollgate/admin-password); the private-WiFi passphrase likewise falls back
to /etc/tollgate/wifi-password. Both are consumed (removed) once applied. The
env still wins when present, so opkg (where the env IS forwarded) is unchanged.
tests/packaging/admin-board-requires-credential_test.sh: +2 cases (staged file
applies when the env is empty; env wins over the file). 80 passed, 0 failed.
Co-authored-by: Your Name <you@example.com>
* setup: never SET a credential — admin or private-WiFi (#884 follow-up) (#902)
The package already stopped GENERATING an admin credential (#884), but it still
SET one from the install environment (TOLLGATE_ADMIN_PASSWORD /
TOLLGATE_PRIVATE_WIFI_PASSWORD) or a staged root-only file
(/etc/tollgate/admin-password, /etc/tollgate/wifi-password). That is the same
hazard one step over: an operator who pasted the run-book's EXAMPLE value
verbatim ended up with root set to a string they never chose, with no link to
it. Remove every credential write.
99-tollgate-setup:
- drop TOLLGATE_ADMIN_PASSWORD / TOLLGATE_PRIVATE_WIFI_PASSWORD,
ADMIN_PASSWORD_FILE / PRIVATE_WIFI_PASSWORD_FILE, operator_chosen_admin_password(),
set_admin_password(), clear_admin_credential_provisional() and the generated
private-WiFi PSK;
- enforce_admin_credential now only ASCERTAINS: set/locked serve, empty/unknown
refuse and drop the board, telling the operator to set one with `passwd root`
(or LuCI) and re-run;
- setup_private_network: preserve an existing passphrase, otherwise leave the
private AP DISABLED (wired br-private is unaffected) until one is set;
- keep reading the legacy admin-credential-provisional marker on upgrade.
CLI:
- `tollgate network private set-password` and the config applier now also set
disabled=0 on the radio they write a passphrase onto, so setting one actually
brings the (disabled-by-default) AP up.
Tests:
- admin-board-requires-credential: rewritten around the new invariant; ADD the
regression that env AND file present leaves root byte-identical, passwd never
invoked, the staged file unconsumed, and the board refused;
- new tests/uci-defaults-private-credential_test.sh: no passphrase => no key,
AP disabled; env/file ignored; existing key preserved+enabled;
- new src/cli/network_setpassword_test.go: set-password and the applier enable.
Docs: README + unreleased changelog fragments updated to match.
Runs green in the module test tiers (only the pre-existing
entry-ui-mapping_test.sh failures remain, identical to main).
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* chore: VERSION v0.6.0-rc2 (#903)
Stamp the release candidate for the no-generated-credentials work. The feed's
PKG_SOURCE_TAG is read from this file, so the built binary reports v0.6.0-rc2 to
match the package version 0.6.0_rc2.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* rebase onto main (post-rc2 + #798 fence + credential series): builds: enforce the two-path doctrine — surface contract test, one-pass matrix split, rails (#713)
Co-authored-by: Amperstrand <dev@amperstrand.no>
* packaging: refuse to install when root has no password (preinst) + VERSION v0.6.0-rc3 (#907)
The :8090 admin board's login is checked against root's /etc/shadow hash, and
rpcd's session.login accepts ANY password while that hash is empty. The package
never sets a credential, so a password-less install would leave an
unauthenticatable board. Rather than install and then withhold the board, refuse
BEFORE unpacking: the preinst exits non-zero on an empty root hash, so nothing is
half-installed and the operator is told to `passwd root` and retry.
- preinst (was a deliberate no-op): dependency-free (awk, no jq — the past
jq-preinst-aborted-upgrades lesson), allows a locked root, bypasses on
IPKG_INSTROOT (offline/image builds) and TOLLGATE_ALLOW_EMPTY_ROOT=1 (CI hatch).
- new tests/packaging/preinst-refuses-empty-root_test.sh (9 checks).
- VERSION -> v0.6.0-rc3.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* setup: default to a fixed TollGate credential instead of refusing (#913)
* setup: default to a fixed TollGate credential instead of refusing
The admin board's login is checked against root's /etc/shadow hash, and rpcd's session.login accepts ANY password while it is empty. rc3 refused to install in that state, but a failing pre-install is NON-FATAL under apk-tools 3 (the .apk landed anyway) and requiring `passwd root` before install burdened every operator.
Replace the refusal with a FIXED default: 99-tollgate-setup sets root to TOLLGATE_DEFAULT_CREDENTIAL ("TollGate") when none exists, sets the private (management) WiFi key to the same value, marks the admin credential PROVISIONAL, and serves the board. The admin/config UI forces a change at first login (shipped separately, post-release). No install-time credential is accepted (TOLLGATE_ADMIN_PASSWORD / staged file stay ignored); an operator's own credential is preserved; the write still fails CLOSED if it cannot take.
- preinst refusal -> no-op (the two lanes agree now).
- new tests/packaging/admin-board-defaults-to-tollgate_test.sh (59 checks);
preinst-refuses-empty-root -> preinst-never-refuses; the private-credential
and configui-8090 suites updated to the default contract.
- VERSION -> v0.6.0-rc4.
* setup: mark the defaulted private-WiFi key provisional too
setup_private_network writes /etc/tollgate/private-wifi-credential-provisional when it installs the fixed default passphrase (rather than one the operator chose), so the post-release admin/config UI can force a Wi-Fi passphrase change at first login alongside the admin one. The test asserts it is written only when the default is applied.
---------
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* docs(packaging): record the decommission plan for the module's own package build (#914)
Freeze the intent, not yet the change: FreedomTechFeed/packages/net/tollgate-wrt/Makefile is the single canonical shipping package; this repo's packaging/Makefile is test/reproducibility only. packaging/README.md lists exactly what the feed consumes (packaging/files/) and the mechanical removal (Package/Build targets, build-ipk/local-build-ipk/build-env helpers, the build-package.yml publish jobs) to execute after the v0.6.0-rc4 release. Draft PR; merged post-release.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* test(harness): pin the env seams the setup script now resolves (release-check green) (#915)
Three uci-defaults suites were red at release-check (they are NOT in the per-PR test.yml lane, so #913 merged with them red):
- entry-ui-mapping (9): the suite's sed predated the TOLLGATE_ENTRY_UI_CONFIG env override, so the copied 99 read the HOST's live /etc/tollgate/config.json (the harness rot #899 describes).
- admin-tls-identity (6) + trusted-entry-80 (1): the new private-WiFi provisional marker defaults to an absolute path the suites did not redirect.
Harness-only: pin TOLLGATE_ENTRY_UI_CONFIG + ADMIN/PRIVATE_WIFI_PROVISIONAL_MARKER into each sandbox. NO change to 99-tollgate-setup (the risky half of #899). Sweep: all tests/packaging/* + tests/uci-defaults-* green.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* packaging: re-check the private subnet once the uplink is up (#926)
setup_private_network derives network.private (lan/24 ±1) at uci-defaults
time, before a fresh DHCP WAN necessarily has a lease, so an upstream that
lands on the same /24 as the derived private network — the observed
192.168.2.1 against a 192.168.2.0/24 upstream — is missed. The router then
answers for the upstream gateway's own address and DNS breaks on the
deployed router.
Add recheck_private_subnet (no-op unless the private /24 now overlaps a known
upstream) and call it from the postinst AFTER `/etc/init.d/network restart`,
reloading the network only when the private subnet had to move.
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* fix(tollwallet): bound each mint registration so a slow mint cannot stall boot (#940)
* fix(tollwallet): bound each mint registration so a slow mint cannot stall boot
The wallet already registers only the mints the startup probe found reachable,
but gonuts' AddMint does its own network I/O with NO http.Client timeout, so a
mint that accepts the connection and never answers can stall the whole wallet
construction — the module's own notes measure 113-347 s for 7 mints. That
outlasts the installer's health window and made a healthy install report 'the
merchant is degraded'.
registerMint now gates AddMint behind a bounded GET /v1/keysets with OUR OWN
client (8 s). A mint that does not answer is left UNREGISTERED rather than
blocking boot: ensureMintRegistered retries it on first use and the
reachable-set callbacks admit it when it recovers. Registration stays
sequential, so gonuts is never called concurrently.
Test: TestMintServesKeysets (serves keysets -> accepted; empty -> rejected;
outruns the bound -> rejected). tollwallet + merchant modules green.
* review: reword the bound's guarantee, state the residual, add a defer->recover test
Addresses the independent review on the fork PR:
- The comment no longer claims AddMint 'cannot hang'. The probe and AddMint are
separate exchanges, so the bound makes a hang UNLIKELY rather than impossible;
the real closure (a goroutine-with-timeout around AddMint, or registration off
the boot path) is noted as a follow-up.
- States the residual worst case explicitly: one registerMintKeysetTimeout per
reachable mint, sequentially — 7 mints x 8 s = 56 s at boot — plus the new
first-use latency for a deferred mint (a bounded <=8 s inline probe on the
first payment routed to it; latency, not correctness).
- Adds TestRegisterMintDefersThenRecovers: a mint DOWN at boot is left
unregistered, and ensureMintRegistered (the path Receive takes) registers it
once it recovers — pinning the integration claim through the real gonuts
wallet, not just the probe unit.
---------
Co-authored-by: c03rad0r <1100745+c03rad0r@users.noreply.github.com>
* docs(operator-guide): fix the entry_ui=luci TLS-port attribution (#939)
The SSL-command scope paragraph paired LuCI's :8443 with entry_ui=luci, inverted: under entry_ui=luci LuCI (uhttpd.main) owns the entry pair TLS port :443 and the board owns :8443. Both ports named in the old sentence belong to the entry_ui=board mapping.
Ground truth: packaging/files/etc/uci-defaults/99-tollgate-setup port functions and docs/access-urls.md's mapping table. Found by the PR #744 review; one-line fix-forward.
* docs: defer loopback rate-limit vs startup-gate follow-up (post-release) (#953)
Record the root cause behind the 2026-10-16 'Setup failed' incident: the
installer's own 3s poll exceeds RateLimitMiddleware's 10 req/min for
127.0.0.1, and the limiter runs before requireStarted, so the probe can get
429 {'error':'rate limit exceeded'} instead of the readable
503 {'code':'starting'} refusal.
The installer side is worked around in rc33. The root-cause fix (exempt
loopback from the limiter) is DEFERRED until after the current release —
no new module RC in this line.
Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com>
* fix(upstream): select and blacklist upstreams by BSSID, not SSID (#910)
* fix(upstream): select and blacklist upstreams by BSSID, not SSID
One SSID can be served by several BSSIDs across different upstream networks —
some routed to the internet, some captive or isolated. The manager keyed its
blacklist by SSID and never pinned the scanned BSSID on the candidate, so a
router that settled on a BSSID with no internet blacklisted the whole SSID,
including any working sibling, and could never recover. The ICMP-only
connectivity check could not tell "gateway but no internet" from "ok" either.
- Blacklist by BSSID (falling back to the SSID only when it is unknown), skip
candidates by BSSID, and blacklist the exact failed BSSID on an emergency
switch. A sibling BSSID of the same SSID stays eligible.
- Thread the candidate BSSID through SwitchUpstream and pin
wireless.<iface>.bssid, clearing a stale pin when the candidate carries none.
Manual CLI connect pins the strongest scanned BSSID for the SSID.
- Replace the ICMP-only probe with a layered one — default route, DNS
resolution, HTTP 204 captive-portal check — returning a reason
(no-route/no-dns/captive/no-http). Used by the startup check, the main loop
and post-switch verification.
Tests: two-BSSID/one-SSID blacklist isolation, BSSID pin set/clear, and probe
classification. make go-battery green across all 16 modules.
* changelog: fragment for #910 (BSSID-aware upstream selection)
---------
Co-authored-by: c03rad0r <c03rad0r@users.noreply.github.com>
* ci(router-test): TBMG Trust Pipeline + bench lanes (dual twin) (#898)
* ci(router-test): TBMG Trust Pipeline on the self-hosted runner + verdict comment
Router-test is the TBMG Trust Pipeline entry point. Two changes:
* Run the gateway dispatch on the self-hosted runner (role 86-tbmg-runner on
hermes-nvme) over the private fips mesh with its dedicated per-machine key —
no per-repo secrets and no metered LAN traffic.
* Add a report job that posts ONE idempotent verdict comment per PR (marker
<!-- tbmg-trust -->), so every PR shows whether it is ready to merge. Fork
PRs (which must never run on a self-hosted runner) are reported as
"not tested" instead of silently skipped.
The security contract is preserved: PR -> isolated lab; bench only on main or
an explicit dispatch behind bench-hardware.
* docs(changelog): fragment for the TBMG Trust Pipeline (#898)
* ci(router-test): dispatch the bench lanes on the gateway
Add a gated `bench` job (non-PR, bench-hardware) that SSHes to the elected
router-bench-gateway over fips and runs scripts/bench/bench-lanes.sh — the lane
orchestrator (clean flash -> install -> smoke -> radios OFF, JSON summary).
* ci(ngit): mirror the router-test trust pipeline into the ngit twin
Land the trust pipeline in the ngit execution twin too (dual setup): same
plan/router-test/bench jobs, ngit-safe (no GitHub `environment`/`vars`; the
verdict is published by ngit-ci to Nostr, so the report job just summarises).
---------
Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com>
* feat(wireless): wire the vendor-IE price producer the scan boundary staged for (#959)
* feat(wireless): wire the vendor-IE price producer the scan boundary staged for
PricePerStep/StepSize were declared across four data models (NetworkInfo,
Gateway, DiscoveryEntry, KnownTollGate) and had no producer: scanner.go
hard-zeroed them with a 'STAGED (this PR)' comment, gated behind the
VendorIEDiscovery flag whose consumer did not exist.
Wire it:
- scanner.go: capture the raw IE blob from the scan's 'IE: <hex>' line and
route network assembly through a new buildNetworkInfo(), which calls the
existing ParseVendorIEsFromScanData() to fill IsTollGate / RawIEs and the
pricing fields. Remove the staged comment.
- vendor_element_manager.go: add pricing TLVs (0x03 price_per_step,
0x04 step_size, both 2-byte big-endian) to the encoder and parser, so the
IE can carry a price hint.
- types.go: TollGateAdvertisement gains PricePerStep/StepSize.
Authority is unchanged and now documented in-code: the IE is an UNSIGNED
hint via OUI identification that selects a candidate for the signed
kind-10021 probe on :2121, which remains the authority for price. A zero
pricing value means 'not advertised here', never 'free'. Pricing TLVs are
dropped (truncated=true) rather than overflowing the 255-byte element or
pushing out the pubkey, because identification outranks the price hint.
Tests (observed RED before the change, GREEN after):
- TestParseIwinfoOutput_PopulatesVendorIEFields
- TestParseIwinfoOutput_NonTollGateIEIgnored
- TestParseIwinfoOutput_TollGateIEWithoutPricingTLVs
- TestParseIwinfoOutput_NoIEMeansNotTollGate
- TestParseIwinfoOutput_MultipleNetworksAndIEs
- TestVendorIE_RoundTripPricing (+4 subtests)
- TestVendorIE_PricingDroppedRatherThanOverflow
Full wireless_gateway_manager suite green, including the five pre-existing
vendor-IE tests (no regressions).
* fix(wireless): parse the vendor element from the format the router actually emits
Hardware verification on tollgate-A04E (2026-10-16) falsified the first
implementation's assumption.
Measured:
- `iwinfo <dev> scan` prints NO information elements at all (grep -c 'IE:'
returned 0), so it can never yield IsTollGate via the vendor element. It
does report hidden APs, as `ESSID: "unknown"` — a literal placeholder.
- `iw dev <dev> scan` is the ONLY element source, and it prints elements
DECODED into named fields (SSID:, RSN:, HT capabilities:, ...), not as a
hex blob. A vendor element appears as `Vendor specific: <colon-hex>`.
- iw strips the element ID (0xDD) and the length byte from that body. Feeding
the body straight to ParseVendorIEsFromScanData returns 0 elements: measured
as 36 good bytes in, 0 advertisements out. The body must be re-framed with
its 0xDD + length prefix.
Change:
- ParseIwinfoOutput now accepts BOTH real formats: iwinfo `Cell NN -
Address:` records and iw `BSS <mac>(on ...)` records, per-record, so a
stream carrying either parses.
- SSID parsing handles both keys; RSN supplies encryption for iw output
(it has no `Encryption:` line); `freq:` supplies the channel.
- The vendor element is re-framed before parsing.
- Placeholder SSIDs ("", "(hidden)", "unknown") are refused admission, so a
literal cannot become a selectable upstream.
Tests rewritten against the measured format:
TestParseIWScanOutput_{PopulatesVendorIEFields,NonTollGateVendorIgnored,
TollGateWithoutPricingTLVs,NoVendorElementMeansNotTollGate,
RealHardwareHiddenSSID,MultipleNetworksAndVendorElements}.
Three failed against the raw-body bug and pass with the re-frame; the
full wireless_gateway_manager suite is green with no regressions.
* fix(wireless): read VendorIEDiscovery from UpstreamWifi; scan via iw when enabled
Build fix: the flag lives on the UpstreamWifi sub-struct, not on Config.
With that wired, the scan path can finally populate the fields the vendor
element carries. Two facts measured on tollgate-A04E (2026-10-16) drive the
design:
1. `iwinfo <dev> scan` prints NO information elements, so it can never
identify a TollGate by vendor element. It is kept because it reports
hidden APs (as ESSID "unknown") that iw can omit.
2. `iw dev <dev> scan` is the only element source. When vendor-IE discovery
is enabled, scanRadio runs it as well and mergeScanResults folds the two
views by BSSID, letting the element-derived fields (IsTollGate, RawIEs,
price, step) come from iw while iwinfo still contributes entries iw lacks.
iw is best-effort: a failure or empty result keeps the iwinfo view rather
than failing the scan.
* test(wireless): cover mergeScanResults, scanRadioWithIW and the flag wiring
Coverage on the newly added code was incomplete: mergeScanResults and
scanRadioWithIW were both at 0%, and parseColonHex at 75%. The merge is the
join that lets element-derived fields come from iw while iwinfo-only entries
(notably hidden APs) survive, so an untested merge was the largest risk in
this change.
Added:
- mergeScanResults: enriches an existing BSSID from the second view; keeps
entries only one view saw; fills gaps (signal/encryption); is the identity
when the second view is empty.
- scanRadioWithIW: a failed iw invocation returns nil rather than propagating
an error, so the caller keeps the iwinfo view.
- NewScanner/SetVendorIEDiscovery wiring.
Also moves the on-router demo to src/cmd/tg-vendor-ie-demo, so it compiles
against the repo's own module graph (the nested scanner module is reached via
the existing replace directives) instead of a hand-rolled go.mod.
Coverage on the new surface is now: mergeScanResults 100%, appendScanEntry
100%, NewScanner 100%, SetVendorIEDiscovery 100%, ParseIwinfoOutput 98.6%,
buildNetworkInfo 92.9%, scanRadioWithIW 70%, parseColonHex 75%.
* docs(wireless): record the cross-build target defect found on hardware
The demo binary was built with GOOS=linux GOARCH=arm64 but the router rejected
it with 'syntax error: unexpected end of file'. Its ELF header read
02 00 3e 00 (e_machine 0x3e = x86-64) instead of 02 00 b7 00 (0xb7 = aarch64).
The sha256 matched the local file exactly, so the transfer was correct — the
build target was wrong. Adding CGO_ENABLED=0 produced a proper statically
linked aarch64 binary.
The check that would have caught it in one line, now recorded for reuse:
head -c 20 <binary> | od -An -tx1 | tail -1 # want '02 00 b7 00' for aarch64
Size and sha256 are NOT evidence of the right architecture; both matched while
the binary was unrunnable on the target.
* feat(demo): add --file mode and record the on-router demo evidence
The demo could only run 'iw dev <dev> scan' live. Where no neighbour running
this build exists (as on tollgate-A04E), that can only ever show the ABSENCE of
a vendor element, never the positive identification path. Adding '--file <path>'
lets the parser be run against a saved scan, so a synthetic element can be
injected into REAL captured router output.
Evidence from the live MT6000 (GL.iNet GL-MT6000, OpenWrt 24.10.2,
aarch64_cortex-a53), 2026-10-16:
A. live scan:
iw dev phy0-ap0 scan: 28736 bytes, err=<nil>
Vendor specific: lines present = 0
parsed networks = 12
identified TollGates = 0 (correct: no neighbour runs this build)
B. real scan + one injected TollGate element:
read /tmp/injected-scan.txt: 35168 bytes
Vendor specific: lines present = 1
parsed networks = 16
TOLLGATE bssid=b2:36:4a:2a:03:32 ssid="StarGate" signal=0
price_per_step=7 step_size=1000 rawIE_bytes=38
identified TollGates = 1
-> the parser located the element inside genuine scan data and recovered
the encoded price/step exactly.
The demo binary was cross-built with CGO_ENABLED=0 and its ELF machine field
verified as 0xb7 (aarch64) BEFORE transfer — the check whose absence produced
the unrunnable x86-64 binary recorded in the previous commit.
* fix(wireless): emit pricing before the pubkey so an oversized pubkey fails loudly
Defect found by a cold cross-family review (deepseek-flash, PR #959, 2026-10-16).
With the original ordering a pubkey could pass the fit check and land the body
at 253-255 bytes; the pricing TLVs were then appended AFTER it, the 4-byte guard
fired, and the pricing was silently dropped while the element still reported
success. A router whose pubkey drifted large would appear to advertise no
price — indistinguishable from a router that advertises none.
Fix: emit pricing BEFORE the pubkey. An oversized pubkey now fails the existing
loud error path ('vendor IE body too long') instead of silently losing the
price. The length octet is also clamped explicitly with truncated=true, so a
wrap can never produce a malformed element silently.
Tests (observed RED against the original ordering, GREEN after):
- TestEncode_PricingNotDroppedByPubkey (fails: 'pubkey 239 bytes cannot fit')
- TestEncode_PricingSurvivesWithMaxFitPubkey (loud error for oversized pubkey)
- TestEncode_LengthOctetNeverWraps (octet == body length)
Encoder coverage 87.2%. Full wireless_gateway_manager suite green; go vet clean.
MOUNTED BINARY VERIFIED: demo rebuilt for the router and its ELF machine field
checked as 02 00 b7 00 (aarch64) before any transfer.
Credit: this defect was surfaced by the cross-family lane, not by me.
* refactor(wireless): rename the scan-side price hint so it cannot be confused with the authoritative one
Follow-up from a cold cross-family review of PR #959 (deepseek lane, n…
STACKED ON #780 — merge #780 first
Base is
fix/752-swap-empty-outputsso the diff shows only this change; retarget tomainafter #780 merges. This PR consumes the machinery #780's pin (gonuts v0.13.1) delivered.What
#703's map named this the program's top work item: the resume half of #497 was built but unwired — gonuts v0.13.1 records a pending-swap intent (exact request bytes, secrets, blinding factors) atomically with the counter reservation BEFORE the irreversible POST, and
ResumePendingSwapsreplays intents (replay succeeds when the mint honours NUT-19 replay within its cache window — the "deterministic signing" reading was corrected by the #497 research; see the scoping section below), but zero call sites existed. The value was durable; the recovery was not automatic.One wiring call, off the boot critical path:
TollWallet.Newruns the recovery pass afterLoadWalletin a background goroutine. Design properties:SaveProofsis keyed by secret; replaying an already-saved proof is a no-op.The crash-window test (end-to-end,
-race)A stub signing mint (mirrors the fork's harness) fails the first swap answer — client-side indistinguishable from a drop after the mint processed the request, exactly the #497 window.
Receiveerrors; the next wallet load replays the intent and restores the balance — the "lost" value becomes spendable without a second spend:The mint-down test's timing bound is a discriminator, not a stopwatch: LoadWallet's own dead-mint baseline is ~16 s (its AddMint retry ladder — pre-existing, measured with the wiring stashed); a synchronous recovery would add ≥30 s/intent. The assertion sits between them.
Verification
src/tollwallet: full module suite green under-race(2 new tests).make go-battery: green through every module except the two pre-existing merchant timing failures documented since test(contract): FROM-ARG scope fence for cloud-lab Dockerfiles #728.Fund-safety note
Pure recovery wiring — no money-path semantics change: no new derivation, no new irreversible calls (the replay re-POSTs the SAME request bytes the original POST sent; a mint that already processed it returns the same signatures within its NUT-19 cache window — beyond it the mint refuses with 11001 already-spent and the intent stays journaled, per the scoping section below), no re-spend risk (inputs were consumed by the original POST or weren't). This is the CDK saga's replay-first resume, consuming the record side #780 shipped.
Updates the #703 map: Class B resume half closed;
no-fund-losson the conformance lane becomes measurable for this crash point.Supersedes #771 (carrier adjudication, #665 addendum)
This PR is the single carrier for the #497 resume wiring; #771 should close as superseded when this lands (recommendation posted there: #771 (comment)). Why this one carries:
TollWallet.Newis the single funnel — full boot (newFullMerchant→NewWalletPort→New), degraded boot (DefaultWalletFactory→New) and the degraded→full upgrade all construct through it, so every wallet load heals and boot never blocks (test-pinned). fix(merchant): boot resume replays journaled swap intents — close the #719 crash window (#497) #771's synchronous merchant-boot call pays 30 s/intent on the path to serving.v0.14.0exists.-raceend-to-end recovery tests vs stubbed seam tests.Adopted from #771: the NUT-19-scoped changelog wording (its reword commit was never on its branch — the text lived only in a PR comment) and its crash-injection lane run as the model for the merge condition below. Dropped deliberately: #771's
WalletPort.ResumePendingSwapsport surface — dead surface under this wiring (only the gonuts backend needs the call; cdk self-resumes on open, the sidecar daemon owns its intents).Recovery claim, scoped (NUT-19)
The original body text said "mints sign deterministically — verified against cdk-mintd 0.17.6". That finding was corrected by the deeper #497 research (comment 2): the identical-signature replay comes from NUT-19 cached responses; cdk-mintd's default backend is in-memory with a ~60 s TTL, and beyond the window a mint that already processed the swap refuses with 11001 already-spent — replay is then refused forever, and a mint without NUT-19 never replays. In those cases the intent stays journaled (visible, not lost) pending a NUT-09
/restorefallback — fork-level follow-up. The mechanism is still a strict improvement over rc1 (value destroyed and unjournaled); the fragment, the call-site comment, and the body's claims now all carry this scoping.Merge condition
One green
tests/cloud-lab/run-crash-injection.shrun against this head (the #771 lane run validated the shared fork mechanism; this wiring differs — recovery runs concurrently with serving), plus one concurrent-traffic scenario (a Receive while a stale intent replays). Safe by construction: the replay re-POSTs journaled bytes while new swaps reserve fresh counter ranges andSaveProofsis secret-keyed.