Skip to content

test(contract): pin the shipped AP SSID format - #489

Closed
felixfelix-bot wants to merge 2 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/ssid-format-contract
Closed

felixfelix-bot wants to merge 2 commits into
OpenTollGate:mainfrom
felixfelix-bot:pr/ssid-format-contract

Conversation

@felixfelix-bot

Copy link
Copy Markdown
Contributor

What

tests/contract/check-ssid-format.sh pins the open AP's SSID to <brand>-<4 hex characters> — ^TollGate-[0-9A-F]{4}$ for the default brand — with both radios broadcasting that same one name. It runs as a CI gate in the build-purity job (both .github/workflows/test.yml and .ngit/act/workflows/test.yml) and is wired into hooks/pre-commit next to the other shipped-default checks.

The SSID is product surface: it is in every beacon, the operator reads the 4-character suffix to tell two routers apart, and the module keys on the TollGate- prefix (src/wireless_gateway_manager/discovery_log.go → hasTollGateSSID, vendor_element_manager.go → calculateScore). The exact shape was only a convention in 99-tollgate-setup, so a band suffix (TollGate-A1B2-2.4GHz) or a hardcoded brand could come back unnoticed.

How it checks

Behavioural layer. It sources the real load_brand() / setup_public_wifi() out of 99-tollgate-setup (the driver is stripped, since that part reads and writes /etc, /proc and the setup flag) and runs them against a stub uci that records every write. The assertions are on the SSID the script actually generated, not on its source text:

  • both open radios match ^TollGate-[0-9A-F]{4}$ on the default brand;
  • both radios end up with the same SSID, so a per-band name fails;
  • 6 generations produce distinct 4-hex suffixes — the suffix stays per device;
  • the generated suffix is exactly 4 characters from [0-9A-F].

Structural layer. The assignment sites a checkout cannot execute: the same-version reinstall path recovers its SSID from live config (and re-asserts it after the uhttpd repair), and the whitelabel branch reads /etc/tollgate/brand, which a CI checkout has no way to provide. Those are checked as text, failing on a band token or on an SSID written from anything other than a variable — including the nodogsplash gatewayname, which is derived from the same value.

Evidence

$ bash tests/contract/check-ssid-format.sh
  ok    default_radio0 SSID 'TollGate-CB68' matches ^TollGate-[0-9A-F]{4}$
  ok    default_radio1 SSID 'TollGate-CB68' matches ^TollGate-[0-9A-F]{4}$
  ok    both open radios broadcast one SSID — no per-band naming
  ok    6 generations produced 6 distinct SSIDs (device-unique suffix)
  ok    device suffix 'CB68' is exactly 4 characters from [0-9A-F]
  ... 7 more structural checks
Shipped AP SSIDs match the contract (brand + 4-hex device suffix, one name for both radios).

Red proof, each reverted before the commit:

Injected drift Result
GATEWAY_NAME="${BRAND_HOSTNAME}-${RANDOM_SUFFIX}-2.4GHz" 12 checks FAIL
.ssid="TollGate-$RANDOM_SUFFIX" (hardcoded brand) structural layer FAILS (the behavioural layer alone cannot see this)
.ssid write deleted anti-vacuity guard FAILS

Also green locally: js-schema-lint.mjs, build-purity.sh, check-deps-sync.py, check-mirror-sync.py, check-import-paths.py, check-version-sync.sh, and shellcheck on the new script.

No production code is changed — the test locks the current behaviour rather than altering it. Docs travel in the same commit (CHANGELOG.md, CONTRIBUTING.md, AGENTS.md, docs/release-process.md).

Card: t_9e5f0b8d

The open AP is named `<brand>-<4 hex characters>` (`TollGate-A1B2` for the
default brand) and both radios carry that one name. Nothing enforced it: the
4-character device suffix and the single-name property were conventions in
99-tollgate-setup, so a band suffix ("TollGate-A1B2-2.4GHz") or a hardcoded
brand could come back unnoticed. The Go side keys on the `TollGate-` prefix
(discovery_log.go `hasTollGateSSID`, vendor_element_manager.go
`calculateScore`), and the SSID rides in every beacon, so the drift is both a
client-matching and a naming-honesty bug.

tests/contract/check-ssid-format.sh asserts the contract in two layers:

- behavioural: sources the real load_brand()/setup_public_wifi() out of the
  script (driver stripped) and runs them against a stub `uci` that records
  what they would have written, then checks the SSID actually generated —
  both radios matching ^TollGate-[0-9A-F]{4}$ for the default brand, one
  shared name for both radios, a per-device 4-hex suffix over 6 generations.
- structural: checks the assignment sites a checkout cannot execute — the
  same-version reinstall path recovers its SSID from live config and the
  whitelabel branch reads /etc/tollgate/brand — failing on a band token or on
  an SSID written from anything but a variable.

Red evidence, all reverted before this commit: appending "-2.4GHz" to the
gateway name fails 12 checks; hardcoding the brand at the `.ssid` write fails
the structural layer (which the behavioural layer alone cannot see); deleting
the write fails the anti-vacuity guard.

Wired into both CI test workflows (build-purity job), hooks/pre-commit, and
the release/contribution runbooks.

Refs: t_9e5f0b8d
Public AP is BRAND-XXXX with a 4-hex uppercase suffix from /dev/urandom,
shared across the 2.4GHz and 5GHz radios (band steering). Private
management AP is BRAND-Private-XXXX with a WPA2 word-word-word-NN
passphrase. nodogsplash gatewayname derives as '<gateway> Portal'.
The public format is pinned by tests/contract/check-ssid-format.sh.

Documents the post-fix private SSID (TollGate-Private-XXXX); the
operator-username variant (c08r4d0r-XXXX) is a security leak being
fixed on pr/private-ssid-no-operator-username.
@Amperstrand

Copy link
Copy Markdown
Collaborator

Verification — the pin bites on all four injected drifts; wired into both CI lanes + pre-commit; composes with #487 in both merge orders

Independently verified at head 56b80116 (base = current main tip ca5d07a2 — zero drift, MERGEABLE, two clean commits, no assistant attribution). No src/ file is touched, so the Go battery is not applicable; the relevant battery here is the contract/sync surface, all run at the head.

Where the checker lives and when it runs. tests/contract/check-ssid-format.sh joins the tests/contract family and runs in three places, each verified:

  • Both CI lanes — a Shipped AP SSID format check step directly after build-purity.sh in the build-purity job of .github/workflows/test.yml and identically in .ngit/act/workflows/test.yml (the build-of-record lane). Both files YAML-parse clean with the step confirmed inside the job.
  • hooks/pre-commit — I installed the hook in a scratch clone and staged a touched packaging/files/ file: trigger matches, hook runs the checker, exit 0; staging a non-packaging/files/ path correctly does not trigger.
  • The runbooks that name the other contract gates (AGENTS.md, CONTRIBUTING.md, docs/release-process.md) now name this one too.

Green/Red evidence. bash tests/contract/check-ssid-format.sh at the head → exit 0, 18 ok lines, 0.4 s. It self-locates (green from a foreign CWD), bash -n and shellcheck are clean; sh -n fails (process substitution) which is fine — bash shebang and every invocation path is bash. Negative controls, each on a fresh git archive copy with exactly one mutation:

Mutation Result
GATEWAY_NAME="${BRAND_HOSTNAME}-${RANDOM_SUFFIX}-2.4GHz" (band suffix) exit 1, 12 checks FAIL — behavioural regex ×2, default-brand pin, all 6 generations, plus B2 + B3. Matches the PR's red claim exactly.
.ssid="TollGate-$RANDOM_SUFFIX" (hardcoded brand at the write) exit 1, 1 check FAIL — B1 only. The behavioural layer stays green here because the checkout's default brand is TollGate; this is the drift a whitelabel router would hit, and it proves the structural layer earns its keep.
public .ssid write deleted exit 1 — behavioural <unset> ×2 + downstream.
all .ssid writes deleted exit 1 — plus B1's anti-vacuity guard fires (no '.ssid="…"' write found — this check is observing nothing).

Also green at the head: check-mirror-sync.py (exit 0), check-version-sync.sh (exit 0, v0.6.0-alpha3 consistent).

Coverage of every derivation path. A tree-wide grep shows the only .ssid= writers anywhere in packaging/, hooks/, scripts/ are the three lines B1 already observes (L215, L436, L445 of the setup script) — there is no uncovered write site. Per path: the default TollGate-XXXX (L223) is pinned behaviourally and structurally; the same-version reinstall fallback (L493, brand + fresh hexdump suffix) matches B2's second accepted shape; the whitelabel branch from #432 is covered without over-pinning — B2 requires the variable ${BRAND_HOSTNAME} (an anti-hardcode check, not a literal-brand check), and the behavioural regex is brand-parameterized, mirroring load_brand()'s net4sats → Net4sats, everything-else-coerces-to-default logic so a branded dev machine doesn't false-positive; the nodogsplash gatewayname (L263) is pinned to derive from ${GATEWAY_NAME}. The private SSID is deliberately not format-pinned here — at this head B3 just sees c08r4d0r-${RANDOM_SUFFIX} pass its band-token check, which is correct scope separation: the private format is #487's contract, not this one's.

Is the pattern the minimal invariant? Close to it. What's pinned is exactly the three load-bearing properties: brand prefix from the brand selector (Go-side prefix matching in discovery_log.go / vendor_element_manager.go), exactly-4 uppercase hex suffix (what operators read to tell routers apart; hexdump %02X produces exactly this), and one name across both radios (band steering). The suffix value isn't pinned, the brand isn't literal-pinned anywhere except the default-brand behavioural assertion (which only runs when the host has no brand file), and the randomness tolerance allows one birthday collision in 6 generations. A legitimate future change (say a 6-hex suffix) now requires consciously updating this contract + README — that is the intended friction, not over-pinning. One real coupling to note for maintainers: adding a third brand to load_brand() must also update the checker's prefix case (check-ssid-format.sh L147-150), or the duplicated mapping will drift — deriving BRAND_HOSTNAME from the already-sourced lib would remove the duplication. Optional follow-up.

Compatibility with #487 — verified in this direction too. The #487-thread verification already ran this checker green against #487's tree; I reproduced it independently: fetched #487 head dbca12a8, dropped this checker in, ran it → exit 0, with B3 explicitly observing and passing the new private literal (no band token in SSID value (line 399): TollGate-Private-${RANDOM_SUFFIX}). The two PRs agree on the format family (open TollGate-<4hex>, private TollGate-Private-<4hex>, same suffix source) and neither's checker trips the other's change. Merge order: either works for the gates. The one asymmetry is docs: #489's second commit documents the post-#487 private format in the README's new "SSID conventions" section, so if #489 lands first the README briefly describes a private SSID the shipped script doesn't produce yet (it still broadcasts c08r4d0r-XXXX until #487 lands — though the README text itself flags that variant as a leak being fixed, so it's honest in the window). #487-first is the cosmetically cleaner order; not a blocker either way. The case-sensitivity nit from the #487 review (all-caps C08R4D0R- slipping that PR's identity grep) is a check-identity-leak.sh property — untouched here, still an open optional follow-up on #487's side.

Smaller notes. CHANGELOG entry present under Changed / Internal in the right wrapped bold-lead style. It (like every other [Unreleased] entry — zero PR links in the section today) lacks the ([#N](...)) link that released sections carry; adding ([#489](...)) now that the number exists would match the finalized style, same optional nit as flagged on #487. The GitHub-side CI rollup is empty (no Actions run for this branch); the ngit lane is the build of record per the README, and its wiring is what I parsed and exercised locally. seq/grep -E/sed -E keep it to CI-host tools; the sourced-code sandbox is confined to a mktemp -d with a stub uci and redirected LOGFILE, and only load_brand + setup_public_wifi are invoked — nothing in the harness touches /etc or /proc (the /proc-writing setup_hostname is never called, and the driver below the # -- driver marker is stripped before sourcing).

Disposition: land. The contract is real (four independent red proofs), the wiring is triple-confirmed and idempotent for already-passing trees, coverage matches every SSID derivation path in the repo, and it composes with #487 in either merge order. Optional follow-ups, none blocking: (1) derive the expected brand prefix from the sourced load_brand() instead of duplicating the mapping, (2) add the PR link to the CHANGELOG entry, (3) the already-noted #487-side items (identity-grep -i, private-format migration callout).

@Amperstrand

Copy link
Copy Markdown
Collaborator

Technical review (evidence-gathering pass, not an approval).

What it does. Promotes the open-AP SSID format to a checked contract: <brand>-<4 hex> (TollGate-A1B2), one name shared by both radios, per-device suffix. The gate has two layers — an executable one that sources the real setup_public_wifi() and runs it against a stub uci to assert the SSID it actually writes, and a structural one covering the branches a checkout can't execute (the same-version reinstall path recovering the SSID from live config, the whitelabel branch reading /etc/tollgate/brand). The rationale is concrete: the Go side keys on the TollGate- prefix (discovery_log.go, vendor_element_manager.go), so a band suffix like TollGate-A1B2-2.4GHz would fork the router's network identity. AGENTS.md gains the gate in the pre-PR battery.

Verification on this head (56b80116) — including two negative controls, because a contract gate that can't fail is decoration: the gate passes on the head; mutating the actual SSID assignment (ssid="$GATEWAY_NAME-2.4GHz") makes it exit 1 — the real drift axis is covered; and, a point in the gate's favor, mutating the section label (tollgate_2g_open-2.4GHz, which does not change the beacon) is correctly ignored — the check is precise about what the SSID is, not grep-happy about suffixes anywhere.

Sequencing: same note as #487 — the two PRs add adjacent steps in both test.yml twins and both edit 99-tollgate-setup's SSID region, and #490 carries a rebased copy of that file. Land #487 first, rebase this on top, #490 last. On this head the private-SSID leak line (c08r4d0r-…) is still present — correct, that's #487's scope, and the two gates are complementary (open-AP here, private-SSID there).

Disposition: land — after #487, with the one-round rebase.

@Amperstrand

Copy link
Copy Markdown
Collaborator

Rebase needed against current main (the #506/#515/#448/#478/#468/#475 merge wave): this branch is CONFLICTING — the conflicts are the routine CHANGELOG deconflict class per our earlier verification passes. We attempted the rebase + maintainer-edit push locally (clean replay, check-version-sync green) but the push is gated by the workflow OAuth scope on .github/workflows/ files, which our token lacks — needs the branch owner or a UI-side update. Verification verdicts from the sweep stand: land once rebased.

@Amperstrand

Copy link
Copy Markdown
Collaborator

Superseded by a rebased replacement: main moved (band-aware radio setup, #491's epoch token-emit) and the original head branch was deleted server-side. The replacement carries the same contract + a harness adapted to the band-aware setup (detect_band_radios bootstrap, tollgate_*_open readback), mutation-verified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants