Repository navigation
fix(wireless_gateway_manager): recognize single-'!'-decorated SSIDs — #706's reader half - #839
Conversation
…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
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
|
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 Legacy Findings:
What I ran on this head: the recognition/decoration tests under 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. |
…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
…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
|
Heads-up from the
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 |
|
🏷️ Next action — approved Approved; pairs with #706 (writer+reader halves) and is scheduled post-release — land together after the current cycle. |
…se): feat(cli+packaging): the entry_ui port table has ONE source — the binary (single-source-of-truth endgame)
…se): test(contract): four drift fences — fragment identity, toolchain ceiling, SSID matcher table, clean-container lane
…se): fix(packaging): the NDS pre-auth allowlist executes — emitted at nft priority -2, ahead of the enforce reject (#754)
…se): fix(packaging): the nft guards stop hardcoding the captive bridge — interface defines rendered from the router's own config (#757)
…se): fix(packaging): firewall zone refs resolved from the router's config, and a skipped rule is never silent again (#755)
…se): fix(packaging): the AP rebind is existence-checked and the SSID rewrite is one-way (#756 class 1)
…se): fix(upstream): portal trigger fires only for validated TollGates, once per gateway + the temporary-workaround registry (#768)
…se): docs(agents): VM-lane bench pitfalls and the merchant flake rule
…se): fix(packaging): IPv6 disabled globally until captivity is v6-aware (#783 mitigation)
…#842 merges + cross-family review base): chore(dev-env): guard the tree against dangling replaces and cloud-lab runtime state
…#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)
# Conflicts: # .github/workflows/build-package.yml # CHANGELOG.md # src/wireless_gateway_manager/brands.go # src/wireless_gateway_manager/brands_test.go
Review —
|
* 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…
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
hasTollGateSSIDtrims at most one leading!before the case-insensitive brand-prefix compare — #706's reader half, so a!TollGate-XXXXupstream (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 ascheck-ssid-format.sh); Go source keeps assembling it from non-contiguous bytes.Freeze notes
Verification
src/wireless_gateway_managerfull suite green under-race(fixture-driven recognition test + the independent single-decoration test + the whitelabel assembly pin).check-ssid-naming.shgreen end-to-end (all required classes incl. the!forms and double-!rejections); rebrand gutter green with the fixture-file exemption; changelog assemble green.@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.