Skip to content

fix(wireless_gateway_manager): recognize single-'!'-decorated SSIDs — #706's reader half - #839

Merged
Amperstrand merged 2 commits into
mainfrom
fix/706-ssid-reader-tolerance
Oct 11, 2026
Merged

Amperstrand merged 2 commits into
mainfrom
fix/706-ssid-reader-tolerance

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

Split out of #775 per its review's freeze call: the reader tolerance is feature-shaped runtime behavior and gets its own PR, adjudicated on its own merits. The fence machinery stays in #775 pinned to what main already satisfies.

What

hasTollGateSSID trims at most one leading ! before the case-insensitive brand-prefix compare — #706's reader half, so a !TollGate-XXXX upstream (the decoration #706's writer half will emit so guest SSIDs sort first) is recognized by reseller mode, and the decorated/bare spellings of one router converge on the same recognition. A double !! is explicitly not a TollGate name — a pinned fixture class, not an accident. Strict widening: every SSID recognized before is recognized still; #706's writer half remains open and unchanged by this PR.

The acceptance table — both brands, bare and decorated, both cases, anchored negatives — becomes a committed fixture file driven by BOTH the Go unit test and tests/contract/check-ssid-naming.sh (structural classes + wiring + the behavioural go test), which is the fence for the reader/writer disagreement class that broke recognition twice (#618 brand-blind matcher, #706 the decoration). Whitelabel fixtures carry the brand literal in the table's allowlisted home (rebrand gutter, same exemption as check-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes.

Freeze notes

Verification

  • src/wireless_gateway_manager full suite green under -race (fixture-driven recognition test + the independent single-decoration test + the whitelabel assembly pin).
  • check-ssid-naming.sh green end-to-end (all required classes incl. the ! forms and double-! rejections); rebrand gutter green with the fixture-file exemption; changelog assemble green.
  • Planted-contradiction negative control inherited from the fence work (a fixture line contradicting the matcher turns it red).
  • No router-visible surface exercised — logic-level change verified by unit/fence only, per AGENTS.md honesty.

@felixfelix-bot — requesting cross-identity approval: what = the #706 reader half as a standalone strict-widening fix (single-! trim, double-! rejected, both brands, case-insensitive) plus the pinned matcher-acceptance table shared by the Go test and the contract fence; why = the SSID recognition contract broke twice from reader/writer drift (#618, #706), and per #775's review this behavior change must be freeze-adjudicated separately from the fences that pin it.

…ollGateSSID (#618/#706 classes)

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
Amperstrand pushed a commit that referenced this pull request Oct 10, 2026
The reader twin of check-ssid-format.sh: hasTollGateSSID's acceptance
table becomes a committed fixture file (tests/contract/
ssid-naming-fixtures.txt) driven by both the Go unit test and
tests/contract/check-ssid-naming.sh (structural class coverage + wiring +
the behavioural go test). This fence pins the CONTRACT AS MAIN SATISFIES
IT — both brands bare, case-insensitive (lowercase installer-era SSIDs
still in the fleet), the double-'!' rejections, the anchored negatives —
so it is green on main by construction and red on any regression.

The single-'!'-decorated POSITIVE classes and the ssidDecoration wiring
check land with #706's reader half (PR #839) — a feature-shaped runtime
change split out of this PR per its review's freeze call. When #839
merges, the '!'-positive fixture lines and require_class entries return
with it.

Whitelabel fixtures carry the brand literal in the table's allowlisted
home (rebrand gutter, same exemption as check-ssid-format.sh); Go source
keeps assembling it from non-contiguous bytes.

Refs #767, split per #775 review
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Verdict: land post-0.6.0 with #706's writer half (the freeze default you state), after #775; one small in-PR item (changelog fragment).

Reader/writer symmetry — verified against the draft. The reader strips at most one leading ! (strings.TrimPrefix once, then the unchanged case-insensitive brand-prefix compare); #706's writer emits exactly one ! on both brands and its shell readers (strip_ssid_decoration) strip at most one — the reader is a strict superset of every spelling any writer in flight can emit, bare and decorated, and the double-! rejection is pinned twice (table rows + the independent TestSSIDDecorationIsSingleOptional). If the writer ever lands emitting something the reader rejects, check-ssid-naming.sh's Layer B goes red — that is the reader/writer disagreement fence working.

Legacy ! SSIDs. No TollGate writer ever emitted a leading ! before #706, so the recognition set widens only over third-party names: a foreign AP called !TollGate-XXXX becomes an upstream candidate where it previously was not. Recognition feeds candidate selection and the vendor-element score, with probe + signature validation behind it (require_valid_signature), so this is the same spoof class as the bare prefix that was already matchable — no trust-boundary change. The honest cross-version caveat (an older reseller router won't recognize a decorated upstream until it takes this upgrade) is correctly carried in the body.

Findings:

  1. Small: missing changelog.d fragment. This is feature-shaped runtime behavior (reseller recognition widens), which is exactly why it was split out of test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane #775 — it should carry its own fragment when it lands, even post-0.6.0.
  2. Merge-order note (agree, with one refinement): the overlap with test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane #775 is three files, and I diffed the two copies directly — test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane #775's fence is this same table minus the four decorated match rows and the ssidDecoration wiring check, and its closing line literally says "the single-! classes arrive with feat(ssid)!: the captive SSID carries a leading '!' so it sorts first #706 reader PR fix(wireless_gateway_manager): recognize single-'!'-decorated SSIDs — #706's reader half #839". So the conflict is textual and fully anticipated. Your stated order (775 first, yours second) is right; note the reverse order is also behaviorally safe (test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane #775's fence doesn't require the decorated rows, yours does), so a rebase either way is trivial and nobody should hold either PR on the other.

What I ran on this head: the recognition/decoration tests under go test (green, gofmt/vet clean), check-ssid-naming.sh end-to-end (all structural classes + Layer B green), check-ssid-format.sh (unaffected — correct, it pins the writer), and the rebrand gutter suite (6/0 — the fixture file's allowlisted home works in both the git and non-git scan paths). The one-table-two-readers design with the rebrand gutter holding the whitelabel literal in exactly one allowlisted place is a clean application of the single-declaration doctrine.

Freeze/merge-order call: hold for post-0.6.0 per the #775 review's default — rc1 testers validated the matcher without it, and landing reader-only widening inside the freeze buys nothing the writer half doesn't bring with it. Owner stop-ship override available if reseller-mode testers on decorated SSIDs surface during RC. Merge after #775.

@c03rad0r
c03rad0r self-requested a review October 10, 2026 17:09
felixfelix-bot added a commit to felixfelix-bot/tollgate-module-basic-go that referenced this pull request Oct 10, 2026
…llGate#839

The reader tolerance (hasTollGateSSID trimming at most one leading '!' before
the case-insensitive brand-prefix compare) is carried by OpenTollGate#839
(fix/706-ssid-reader-tolerance, split out of OpenTollGate#775). Both PRs modify
src/wireless_gateway_manager/brands.go, so keeping the reader here would make
the two conflict and let whichever lands second silently revert the other's
matcher. One writer, one reader, no overlap.

Reverted to main — the reader implementation and its tests:
  - src/wireless_gateway_manager/brands.go
  - src/wireless_gateway_manager/brands_test.go
  - src/wireless_gateway_manager/reseller_mode_test.go

The last one is not a "brands*.go" file but is reader-side all the same: it
adds a `!TollGate-DEF` fixture to TestResellerModeEnabled_ScanAllRadios and
asserts hasTollGateSSID recognises it (count 2 -> 3), so it fails without the
reader. Note for OpenTollGate#839: it does not carry that reseller-mode coverage, so the
decorated-SSID-selected-as-upstream assertion still has to land somewhere.

Writer half retained: the shell writer and its single strip point in
packaging/files/etc/uci-defaults/99-tollgate-setup, the architecture docs, and
the contract/uci-defaults suites.

Net diff is now writer-only (no src/ changes at all).

Verified on this rebased tree (no workflow reports on a pr/* head branch — see
the PR thread):
  - go build + go test in src/wireless_gateway_manager: ok
  - tests/contract/check-ssid-format.sh: pass
  - tests/uci-defaults-device-code_test.sh: 89 passed, 0 failed
  - tests/uci-defaults-gatewayname-banner_test.sh: 21 passed, 0 failed
  - tests/uci-defaults-setup-marker-order_test.sh: pass
felixfelix-bot added a commit to felixfelix-bot/tollgate-module-basic-go that referenced this pull request Oct 10, 2026
…llGate#839

The reader tolerance (hasTollGateSSID trimming at most one leading '!' before
the case-insensitive brand-prefix compare) is carried by OpenTollGate#839
(fix/706-ssid-reader-tolerance, split out of OpenTollGate#775). Both PRs modify
src/wireless_gateway_manager/brands.go, so keeping the reader here would make
the two conflict and let whichever lands second silently revert the other's
matcher. One writer, one reader, no overlap.

Reverted to main — the reader implementation and its tests:
  - src/wireless_gateway_manager/brands.go
  - src/wireless_gateway_manager/brands_test.go
  - src/wireless_gateway_manager/reseller_mode_test.go

The last one is not a "brands*.go" file but is reader-side all the same: it
adds a `!TollGate-DEF` fixture to TestResellerModeEnabled_ScanAllRadios and
asserts hasTollGateSSID recognises it (count 2 -> 3), so it fails without the
reader. Note for OpenTollGate#839: it does not carry that reseller-mode coverage, so the
decorated-SSID-selected-as-upstream assertion still has to land somewhere.

Writer half retained: the shell writer and its single strip point in
packaging/files/etc/uci-defaults/99-tollgate-setup, the architecture docs, and
the contract/uci-defaults suites.

Net diff is now writer-only (no src/ changes at all).

Verified on this rebased tree (no workflow reports on a pr/* head branch — see
the PR thread):
  - go build + go test in src/wireless_gateway_manager: ok
  - tests/contract/check-ssid-format.sh: pass
  - tests/uci-defaults-device-code_test.sh: 89 passed, 0 failed
  - tests/uci-defaults-gatewayname-banner_test.sh: 21 passed, 0 failed
  - tests/uci-defaults-setup-marker-order_test.sh: pass
@felixfelix-bot

Copy link
Copy Markdown
Contributor

Heads-up from the #706 side: narrowing #706 to its writer half dropped three files, and one of them carried coverage this PR does not have.

src/wireless_gateway_manager/reseller_mode_test.go — TestResellerModeEnabled_ScanAllRadios gained a !TollGate-DEF fixture and switched its counter from a literal net.SSID[:9] == "TollGate-" slice to the shared hasTollGateSSID() matcher, asserting the decorated name is selected as an upstream (count 2 -> 3).

That is the only assertion anywhere that a decorated SSID is actually selected in reseller mode, as opposed to recognised in isolation. With the reader now living here, this is the PR where it belongs — otherwise the behaviour ships unpinned. Your call whether it wants to ride with the ssid-naming-fixtures.txt table or as its own case.

@c03rad0r c03rad0r added overlap Overlaps/supersedes another PR (named in the next-action comment) post-release Decided: lands AFTER the current release cycle status:approved Review passed; no code blockers (a gate may still be open) wave:4 Covered by the wave-4 review pass labels Oct 10, 2026
@c03rad0r

Copy link
Copy Markdown
Contributor

🏷️ Next action — approved

Approved; pairs with #706 (writer+reader halves) and is scheduled post-release — land together after the current cycle.

@c03rad0r c03rad0r removed the wave:4 Covered by the wave-4 review pass label Oct 10, 2026
@Amperstrand
Amperstrand merged commit 0424b1e into main Oct 11, 2026
@Amperstrand
Amperstrand deleted the fix/706-ssid-reader-tolerance branch October 11, 2026 04:14
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): feat(cli+packaging): the entry_ui port table has ONE source — the binary (single-source-of-truth endgame)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(merchant): de-flake the suite trio — late-outcome picker, first-reachable re-arm, owed-grant retry clock (#731, #732, #733)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(packaging): the NDS pre-auth allowlist executes — emitted at nft priority -2, ahead of the enforce reject (#754)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(packaging): the nft guards stop hardcoding the captive bridge — interface defines rendered from the router's own config (#757)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(packaging): firewall zone refs resolved from the router's config, and a skipped rule is never silent again (#755)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(packaging): the AP rebind is existence-checked and the SSID rewrite is one-way (#756 class 1)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(upstream): portal trigger fires only for validated TollGates, once per gateway + the temporary-workaround registry (#768)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): docs(agents): VM-lane bench pitfalls and the merchant flake rule
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
…se): fix(packaging): IPv6 disabled globally until captivity is v6-aware (#783 mitigation)
Amperstrand pushed a commit to Amperstrand/tollgate-module-basic-go that referenced this pull request Oct 11, 2026
…#842 merges + cross-family review base): chore(dev-env): guard the tree against dangling replaces and cloud-lab runtime state
Amperstrand pushed a commit to Amperstrand/tollgate-module-basic-go that referenced this pull request Oct 11, 2026
…#842 merges + cross-family review base): test(contract): entry_ui port tables — 99-tollgate-setup's shell helpers vs Go's uiPortPair must agree (OpenTollGate#746)
Amperstrand pushed a commit that referenced this pull request Oct 11, 2026
# Conflicts:
#	.github/workflows/build-package.yml
#	CHANGELOG.md
#	src/wireless_gateway_manager/brands.go
#	src/wireless_gateway_manager/brands_test.go
@felixfelix-bot

Copy link
Copy Markdown
Contributor

Review — fe4d8d08170d9b9f46b99a726b1cc4602f845f19 (deepseek-v4.1-flash, gate-1 cross-family)

CHANGES-REQUESTED. ⚠️ This head is already merged (27baf22, 2026-10-11T04:14:31Z), so these are post-merge follow-ups, not a pre-merge gate. The Go reader half is correct and well-fenced; the defect is a scope claim that the module's own shell reader contradicts.

Verified on this head (clean clone of main, which contains this merge)

  • bash tests/contract/check-ssid-naming.sh → 18/18 green (17 Layer A + Layer B go test).
  • go test -count=1 -run 'TestHasTollGateSSID|TestWhitelabel|TestSSIDDecoration' . → ok (go1.26.0); gofmt -l and go vet clean.
  • Fixture table parses as the Go test parses it: 13 match / 11 nomatch, 0 malformed → the anti-thinning floor (match>=10 && reject>=8) is satisfied, not vacuous.
  • Layer B's -run regex selects 3 of 3 funcs in brands_test.go — no silent under-selection.
  • hasTollGateSSID call sites vendor_element_manager.go:179, upstream_manager.go:670, discovery_log.go:223 all inherit the tolerance through the one helper. Right shape.
  • One table, two readers, with the rebrand-gutter allowlist entry keeping the whitelabel literal in exactly one place — clean application of the single-declaration doctrine.

[BLOCK] src/wireless_gateway_manager/brands.go:36-41 — the reader/writer symmetry claim is false for the module's own shell reader

The comment (and the PR body) assert the reader is "a strict superset of every spelling any writer in flight can emit". True of hasTollGateSSID; false of packaging/files/etc/uci-defaults/99-tollgate-setup on main, which was not touched here:

  • code_from_name() (:527-534) — ${name%%-*} on !TollGate-7F3A gives the prefix !tollgate, so the compare at :532 fails and the function returns nothing.
  • captive_ssid_for_code() (:636-651) — same ${current%%-*} at :642, same failure at :645; :650 then prints $current verbatim, so the decorated spelling is treated as an operator-chosen name and preserved.

Reproduced against the exact main function bodies:

installer #82 writes:  !TollGate-7F3A
module code_from_name('!TollGate-7F3A')        -> []                (want 7F3A)
module captive_ssid_for_code('!TollGate-7F3A') -> [!TollGate-7F3A]  (want TollGate-7F3A)
--- control: bare spelling ---
module code_from_name('TollGate-7F3A')         -> [7F3A]
module captive_ssid_for_code('TollGate-7F3A')  -> [TollGate-7F3A]

Reachable, not theoretical. tollgate-installer#82 (8115426, merged 2026-10-10T01:12:26Z) writes it bang-prefixed — internal/app/deploy.go:740 SSID="!TollGate-$CODE" — and its own reader strips it (deploy.go:661-664: p=${p#\!}, "That '!' is OPTIONAL decoration"), with docs pinning that a router already in the field under the bare spelling "still adopts the SAME code". The module's shell reader never got that strip.

Consequence: a router deployed by installer #82 carries !TollGate-7F3A. A later tollgate-module-basic-go package upgrade (newer SETUP_VERSION → full-setup path 99-tollgate-setup:2700) reaches setup_device_identity (:2707). If tollgate.device.code is empty — wiped store, or an install predating the store — step 3 of the documented adoption order (:686-691) calls code_from_name("!TollGate-7F3A"), gets nothing, and mints a new code (:694), overwriting DEVICE_SSID (:700) and re-painting the guest AP bare via setup_public_wifi → setup_band_ap (:1540/:2718). An upgrade renames the router and silently reverts the ! — defeating the sort-first property the feature exists for. Ordering does not save it: the module is the writer that runs after the installer.

[REASON] the ! handling was mirrored into the Go helper but not into the module's shell reader, while both are described as one contract.
[ACT] add the same single-occurrence strip to both shell readers — name=${name#\!} at the top of code_from_name(), and current=${current#\!} before the ${current%%-*} at :642 — and pin !TollGate-7F3A / !tollgate-7f3a as code sources in tests/uci-defaults-device-code_test.sh, mirroring installer#82's decorated rows. If the intent is instead that the module's shell writer stays bare, then the brands.go:36-41 doc comment is the wrong claim — but the mint-and-rename above wants fixing either way.

[RISK] src/wireless_gateway_manager/reseller_mode_test.go (on main) — the coverage this PR was scoped to own is absent

#706 was narrowed to its writer half and dropped reseller_mode_test.go; the reader now lives here, so that coverage belonged here. Verified against main: the file exists (d9d5548, 4213 B) and was not among this PR's 8 changed files. Its TestResellerModeEnabled_ScanAllRadios still counts with the literal slice if len(net.SSID) >= 9 && net.SSID[:9] == "TollGate-", on bare TollGate-ABC/TollGate-XYZ fixtures — no decorated fixture.

So the effective production predicate (upstream_manager.go:670) is exercised by the unit table but not by the counter that decides selection. Nothing anywhere asserts a decorated SSID is selected in reseller mode — exactly the gap the earlier bot comment on this PR flagged. The behaviour ships unpinned.

[REASON] the table tests recognition in isolation, not selection.
[ACT] follow-up PR adding the !TollGate-DEF fixture and switching the counter to hasTollGateSSID(net.SSID) — or an explicit note that the coverage is accepted as absent.

[NIT] tests/contract/check-ssid-naming.sh:113-116 — Layer B is a grep-able fence

The -run regex matches all three funcs today, but nothing fails if a future _test.go file is added that the table must also be run against, and it cannot distinguish "matcher broke" from "table emptied past its floor" (the Go test's own matches<10 || rejects<8 guard at brands_test.go:22 is the real anti-thinning check). Informational: if a genuinely independent witness is wanted, run go test . rather than a name-filtered subset.

[INFO] Not verifiable

  • No CI evidence: gh pr checks 839 → "no checks reported on the 'fix/706-ssid-reader-tolerance' branch", and merge head fe4d8d08 has zero check-runs. The workflow job this PR adds was never observed green by me — I ran the fence locally (18/18) instead.
  • No hardware: the [BLOCK] mint-and-rename path is reproduced as shell function bodies against a stub, not on a router.

FULL_REVIEW: /home/c03rad0r/reports/reviews/tollgate-module-basic-go-t_f8028b10-pr839-deepseek-flash.md

Amperstrand added a commit that referenced this pull request Oct 24, 2026
* 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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

overlap Overlaps/supersedes another PR (named in the next-action comment) post-release Decided: lands AFTER the current release cycle status:approved Review passed; no code blockers (a gate may still be open)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants