Skip to content

fix(packaging): stop broadcasting the operator username in the default private SSID - #531

Closed
Amperstrand wants to merge 5 commits into
mainfrom
pr/private-ssid-no-operator-username
Closed

Amperstrand wants to merge 5 commits into
mainfrom
pr/private-ssid-no-operator-username

Conversation

@Amperstrand

@Amperstrand Amperstrand commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Replaces #527 (same branch, fresh PR — the old one's head index wedged after force-push churn).

The private network's default SSID was c08r4d0r-${RANDOM_SUFFIX} — a maintainer's handle broadcast in every default-configured router's AP beacon. Now ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX} (#540: derived from the brand selector like every other shipped name, so a net4sats build broadcasts Net4sats-Private-XXXX on its private network rather than another brand's product name), with tests/contract/check-identity-leak.sh (CI gate in both twins + pre-commit) failing if an identity literal reappears under packaging/files/ or the default stops being device-unique and brand-derived. Deployed networks untouched (default only generates when unset).

Verified at commit level on this head: fix present, gate script wired in both workflow twins, identity-leak gate green; negative control (pre-fix tree → exit 1) verified during review.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Verification run (worktree at head 9dee4bab, as-is; replacement for fork-blocked #487):

Content parity with #487. Same fix at 99-tollgate-setup:729 (c08r4d0r-${RANDOM_SUFFIX} → TollGate-Private-${RANDOM_SUFFIX}, under the unchanged uci -q get preserve-credentials guard), and git diff pr/487:tests/contract/check-identity-leak.sh pr/531:... is empty — the gate is byte-identical to what #487 carried. CI wiring present in both workflow twins, hooks/pre-commit gating packaging/files/ staging, docs/release-process.md gate list — all four hooks from #487. The rebase correctly absorbed the intervening main (#490 band-aware radios, #513 allowlist re-assert); MERGEABLE, no conflict class this time.

Gate + negative control. bash tests/contract/check-identity-leak.sh → rc=0 (no identity literal under packaging/files/; default is device-unique, TollGate--prefixed, names no identity). Mutation control — reverting the one line to c08r4d0r- makes it FAIL with 3 findings, rc=1 (file restored after). sh -n clean on the setup script; shellcheck's single SC2016 on the gate is the intentional literal-${RANDOM_SUFFIX} match.

Behavioral harness (real lines lifted from the shipped script, stub uci, method per the #487 pass):

case result
fresh install TollGate-Private-04EC — product prefix + device-unique suffix; exit 0
upgrade, SSID preseeded c08r4d0r-9F3A preserved verbatim — no churn, no re-pairing; exit 0
upgrade, SSID preseeded custom preserved verbatim; exit 0
same harness on upstream main's script c08r4d0r-04EC — the leak, exit 1

Composition with #524. Ran #524's check-ssid-format.sh (from its head bcc70166) against this tree: green, exit 0 — its structural layer sees TollGate-Private-${RANDOM_SUFFIX} at line 729 and passes it; its behavioral layer pins the open AP (^TollGate-[0-9A-F]{4}$, both radios one name). Merge order independent, as on #487.

Two changelog defects to fix before merge (both in CHANGELOG.md):

  1. The rebase left a duplicated ### Changed / Internal heading — lines 198 and 200 are the same section header back-to-back (view); delete one.
  2. The entry (line 184) still lacks the ([#N](...)) PR link every other entry carries — this was flagged on fix(packaging): stop broadcasting the operator username in the default private SSID #487 when the number didn't exist yet; it's 531 now.

Carried-over follow-ups from the #487 review, all still applicable on this tree (re-checked: hasTollGateSSID at discovery_log.go:282-284 still matches the loose TollGate- prefix, so TollGate-Private-* now sits inside the Go-side "known TollGate" heuristic — benign today, same reasoning as before): field migration/release-notes callout for already-leaking SSIDs, the whitelabel asymmetry note, optional -i on the gate grep. No Go source touched, so no Go battery needed; contract gates re-run green.

Disposition: request-changes — only for the two CHANGELOG fixes (duplicate heading + missing PR link); the fix itself, the gate, deployed-SSID preservation, and #524 composition are all verified land-quality.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Wave-3 follow-up fixup pushed (217b0a88, explicit-SHA lease, replacing 58a24bc5): the changelog entry now sits under a fresh [Unreleased] → Fixed (it had landed inside the frozen [v0.6.0-alpha4] section, whose tag already exists — the repo rule keeps new entries in [Unreleased]), and the blank-separated duplicate ### Changed / Internal heading at the section tail is gone (7 → 6). No code touched.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Review at head 217b0a88d79692e87bf3cf86acc97e897c63675f — Changes requested: 3 [RISK], no [BLOCK] (fleet gate 1 GLM + gate 2.5 cross-family cold audit, both run at this commit).

The core fix is correct and verified at this head: default private SSID is now TollGate-Private-${RANDOM_SUFFIX} (packaging/files/etc/uci-defaults/99-tollgate-setup:729), generated only when unset (:724-728), so no deployed network changes; the old literal is gone from the shipped tree; tests/contract/check-identity-leak.sh passes here (exit 0), fails correctly on the pre-fix tree (exit 1, 3 FAILs — negative control re-run locally), shellcheck -S warning clean, wired into both workflow twins (.github/workflows/test.yml:97-98, .ngit/act/workflows/test.yml:88-89) and hooks/pre-commit:26-29; changelog entry sits once under [Unreleased] → Fixed with the frozen v0.6.0-alpha4 section intact.

Three changes before merge:

  1. [RISK] 99-tollgate-setup:729 + check-identity-leak.sh:74-79 — the new default and its gate hardcode TollGate-, ignoring the brand selector. load_brand() (:57-66) makes BRAND_HOSTNAME the single source of shipped names ("AP SSIDs … follow this single selector", :45-49): net4sats builds broadcast Net4sats-XXXX on the public APs (:336, :301) but will now broadcast TollGate-Private-XXXX on the private AP — a shipped default naming the wrong product, the exact rebrand scenario the changelog invokes. And the gate's prefix case (TollGate-*|TollGate) would fail the brand-correct value, so CI now enforces the wrong name. Use ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX} and derive the gate's expected prefix the same way (or state why the private SSID is deliberately brand-invariant).

  2. [RISK] CONTRIBUTING.md:324-334 — stale guidance now contradicts this PR. It still documents c08r4d0r-<suffix> as the default private SSID prefix and instructs reviewers: "Do not report it or a guard test on it as an 'identity leak'." A reviewer following CONTRIBUTING today must reject this PR and call check-identity-leak.sh a bug. The PR already edits docs (release-process.md, CHANGELOG.md); update this section in the same change.

  3. [RISK] check-identity-leak.sh:1-2,13 + CHANGELOG.md:20-25 — the gate and changelog claim more than they enforce. The script asserts "nothing the package ships advertises who built it" and calls packaging/files/ "the tree the .ipk/.apk payload is staged from" — but the payload also installs the compiled binaries (packaging/Makefile:159-160 → /usr/bin/tollgate-wrt, /usr/bin/tollgate), and those embed three of the five forbidden identities as shipped defaults: src/config_manager/config_manager_config.go:286-294 (c08r4d0r, amperstrand, origami74 at 0.07 profit-share) and src/config_manager/config_manager_identities.go:46-56 (same names plus personal Lightning addresses, e.g. c3e23eb5e3d00f18b2f4f588@coinos.io). The gate passes while the shipped binary carries exactly what its header forbids, and the changelog's "free of operator-identity literals" is false as written. Either narrow the claim to the private-SSID default and packaging/files/, or extend the scan to src/ — noting that the honest version of the scan fails today and forces the real decision on the profit-share defaults.

[INFO] No CI has reported on this branch (gh pr checks → none); all claims above were verified at the commit level locally instead.

The person-name removal, the preserve-existing-SSID semantics, and the guard-test idea are all right. These three are cheap fixes in the scope the PR already claims; happy to re-review.

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at head 217b0a88d79692e87bf3cf86acc97e897c63675f — Changes requested: 3 [RISK], no [BLOCK] (fleet gate 1 GLM + gate 2.5 cross-family cold audit, both run at this commit).

The core fix is correct and verified at this head: default private SSID is now TollGate-Private-${RANDOM_SUFFIX} (packaging/files/etc/uci-defaults/99-tollgate-setup:729), generated only when unset (:724-728), so no deployed network changes; the old literal is gone from the shipped tree; tests/contract/check-identity-leak.sh passes here (exit 0), fails correctly on the pre-fix tree (exit 1, 3 FAILs — negative control re-run locally), shellcheck -S warning clean, wired into both workflow twins (.github/workflows/test.yml:97-98, .ngit/act/workflows/test.yml:88-89) and hooks/pre-commit:26-29; changelog entry sits once under [Unreleased] → Fixed with the frozen v0.6.0-alpha4 section intact.

Three changes before merge:

  1. [RISK] 99-tollgate-setup:729 + check-identity-leak.sh:74-79 — the new default and its gate hardcode TollGate-, ignoring the brand selector. load_brand() (:57-66) makes BRAND_HOSTNAME the single source of shipped names ("AP SSIDs … follow this single selector", :45-49): net4sats builds broadcast Net4sats-XXXX on the public APs (:336, :301) but will now broadcast TollGate-Private-XXXX on the private AP — a shipped default naming the wrong product, the exact rebrand scenario the changelog invokes. And the gate's prefix case (TollGate-*|TollGate) would fail the brand-correct value, so CI now enforces the wrong name. Use ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX} and derive the gate's expected prefix the same way (or state why the private SSID is deliberately brand-invariant).

  2. [RISK] CONTRIBUTING.md:324-334 — stale guidance now contradicts this PR. It still documents c08r4d0r-<suffix> as the default private SSID prefix and instructs reviewers: "Do not report it or a guard test on it as an 'identity leak'." A reviewer following CONTRIBUTING today must reject this PR and call check-identity-leak.sh a bug. The PR already edits docs (release-process.md, CHANGELOG.md); update this section in the same change.

  3. [RISK] check-identity-leak.sh:1-2,13 + CHANGELOG.md:20-25 — the gate and changelog claim more than they enforce. The script asserts "nothing the package ships advertises who built it" and calls packaging/files/ "the tree the .ipk/.apk payload is staged from" — but the payload also installs the compiled binaries (packaging/Makefile:159-160 → /usr/bin/tollgate-wrt, /usr/bin/tollgate), and those embed three of the five forbidden identities as shipped defaults: src/config_manager/config_manager_config.go:286-294 (c08r4d0r, amperstrand, origami74 at 0.07 profit-share) and src/config_manager/config_manager_identities.go:46-56 (same names plus personal Lightning addresses, e.g. c3e23eb5e3d00f18b2f4f588@coinos.io). The gate passes while the shipped binary carries exactly what its header forbids, and the changelog's "free of operator-identity literals" is false as written. Either narrow the claim to the private-SSID default and packaging/files/, or extend the scan to src/ — noting that the honest version of the scan fails today and forces the real decision on the profit-share defaults.

[INFO] No CI has reported on this branch (gh pr checks → none); all claims above were verified at the commit level locally instead.

The person-name removal, the preserve-existing-SSID semantics, and the guard-test idea are all right. These three are cheap fixes in the scope the PR already claims; happy to re-review.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Follow-up: the three Changes requested findings are implemented, tested and pushed as #540.

Head 6de27542 on felixfelix-bot:pr/private-ssid-brand-aware, based on this PR's 217b0a88 (2 commits, 4 files, +91/−47). Merge or cherry-pick — whichever suits; the findings stand on their own otherwise.

  1. Brand selector (finding 1) — the default is now ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}, and the gate derives the expectation the same way: it reads the brand → BRAND_HOSTNAME mapping out of load_brand(), rejects a mapping that is an identity literal or is not a product name, then requires the generated default to be exactly the brand-composed form. Hardcoding TollGate-Private-${RANDOM_SUFFIX} now fails the gate — that mutation passed the previous gate, which is the finding.
  2. CONTRIBUTING.md (finding 2) — the operator-nym section draws the line the code draws: a nym in documentation or an example is fine (the reader replaces it), a nym in a shipped default is a leak worth reporting. It names ${BRAND_HOSTNAME}-Private-<suffix> as the default and cites the gate, so a reviewer following it no longer has to reject this change.
  3. Over-claiming (finding 3) — the gate header and the changelog now claim only what is enforced: packaging/files/ and the defaults a device broadcasts. The compiled binaries built from src/ carry the documented representative operator identities and are named as explicitly out of scope, with the reason, in the gate header.

Reproduction at 6de27542 (each mutation on a copy of the tree): default back to c08r4d0r-${RANDOM_SUFFIX} → exit 1, 3 FAILs; default hardcoded to TollGate-Private-${RANDOM_SUFFIX} → exit 1, 1 FAIL; net4sats) BRAND_HOSTNAME="c08r4d0r" → exit 1, 2 FAILs. upstream/main's script still fails the new gate (3 FAILs). Behavioural harness over the shipped guard's real lines with a stub uci: TollGate-Private-04EC (default brand), Net4sats-Private-04EC (brand=net4sats), and a preseeded SSID is preserved verbatim on both. #524's check-ssid-format.sh runs green against the new tree.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Re-review at head 8808d7a13acf28b67501f051cf45dedac6d15f7b (was 217b0a88d796), covering the fix commit "derive the default private SSID from the brand selector". APPROVED — all three round-1 [RISK] findings are resolved and re-verified at this head.

Finding 1 — hardcoded TollGate- prefix ignoring the brand selector: RESOLVED.
99-tollgate-setup:733 now reads private_ssid="${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}". Ordering is sound: load_brand() runs at :916, before setup_private_network at :931 in the full-setup path; the same-version path calls it at :839 and exits at :908 before the private-network step. Composed defaults verified for both selector values: net4sats build → Net4sats-Private-XXXX (no collision with the public Net4sats-XXXX), default/unknown brand → TollGate-Private-XXXX. The new gate invariant C (check-identity-leak.sh:106-110) pins the exact literal — re-introducing the hardcoded prefix fails the gate (verified by negative control, exit 1).

Finding 2 — stale CONTRIBUTING.md contradicting the PR: RESOLVED.
CONTRIBUTING.md:324-342 now draws the line cleanly: the shared nym may appear in documentation and examples, but a shipped default never carries an operator handle and always derives from the brand selector — "a handle in a documented example is not a leak, a handle in a shipped default is." A reviewer following this section today reaches the same verdict as this PR instead of being instructed to reject it.

Finding 3 — gate/changelog claiming more than they enforce: RESOLVED by narrowing honestly.
The gate header (check-identity-leak.sh:2-11) now states its scope is packaging/files/, names the unscanned compiled-binary identities (c08r4d0r, amperstrand, origami74 — the documented 0.07 profit-share examples, README.md:150 / docs/merchant.md:600) and explicitly leaves whether those stay as "its own decision". The closing message (:125) and the CHANGELOG entry now claim only packaging/files/ defaults. No false scope claims remain.

Verification re-run at head (fresh clone at 8808d7a; no CI reports on this branch — gh pr checks → none, 0 check-runs and 0 workflow runs on the head SHA, and the repo's Actions queue has every run queued 14h+ including on main, so the absence is repo-wide, not branch-specific; everything below ran locally at the exact head):

  • gate exit 0 at head — 6 checks pass (A once, B for both brand literals, C×3), including the new invariant B
  • negative controls all fire: hardcoded TollGate-Private- prefix → exit 1; BRAND_HOSTNAME="c08r4d0r" → exit 1 (invariants A + B both trip); identity literal planted under packaging/files/ → exit 1; the round-1 head itself now fails tightened invariant C, as it should
  • shellcheck -S warning clean on the gate; bash -n clean on both scripts; the setup script's SC3043 (×23)/SC2046 (×1) warnings are byte-identical on base a99d983, round-1 head and this head — pre-existing, none in the changed region
  • wiring intact: .github/workflows/test.yml:98, .ngit/act/workflows/test.yml:89, hooks/pre-commit:28
  • CHANGELOG entry sits once under [Unreleased] → Fixed; frozen v0.6.0-alpha4 section intact
  • preserve-existing-SSID semantics unchanged (:724-728): default only generated when unset — no deployed network changes
  • no other shipped file generates a private SSID (repo-wide grep: only 99-tollgate-setup); sibling contract suites green at head (private-subnet 12/12, same-version-allowlist 43/43, band 22/22, nodogsplash-443 19/19)

Non-blocking follow-ups (none hold approval):

  • [NIT] CHANGELOG.md:32-34 still has a 3-blank-line run before ## [v0.6.0-alpha4] (carried from round 1).
  • [NIT] check-identity-leak.sh:43,55,68-79 — invariant matching is case-sensitive: a capitalized BRAND_HOSTNAME="Amperstrand" passes both A and B today (verified: gate exit 0). grep -iE on the identity patterns would close it.
  • [NIT] check-identity-leak.sh:90 — sed … | head -1 reads "the first private_ssid= literal in the file", not specifically the one inside setup_private_network(); a decoy literal earlier in the file would mask a hardcoded real default. Scoping the extraction to the function body would make invariant C match what actually runs.
  • [INFO] check-identity-leak.sh:68-71 — under set -euo pipefail, a zero-match grep at :68 aborts before the advisory :70-71 can print; the check still fails closed, only the message is dead code.
  • [INFO] The narrowed gate leaves src/config_manager/config_manager_config.go:286-294 / config_manager_identities.go:46-58 (operator identities + personal Lightning addresses compiled into the shipped binaries) uncovered by any gate — pre-existing, outside this diff, but worth a maintainer decision of its own.

APPROVED.

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 8808d7a13acf28b67501f051cf45dedac6d15f7b (was 217b0a88d796), covering the fix commit "derive the default private SSID from the brand selector". APPROVED — all three round-1 [RISK] findings are resolved and re-verified at this head.

Finding 1 — hardcoded TollGate- prefix ignoring the brand selector: RESOLVED.
99-tollgate-setup:733 now reads private_ssid="${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}". Ordering is sound: load_brand() runs at :916, before setup_private_network at :931 in the full-setup path; the same-version path calls it at :839 and exits at :908 before the private-network step. Composed defaults verified for both selector values: net4sats build → Net4sats-Private-XXXX (no collision with the public Net4sats-XXXX), default/unknown brand → TollGate-Private-XXXX. The new gate invariant C (check-identity-leak.sh:106-110) pins the exact literal — re-introducing the hardcoded prefix fails the gate (verified by negative control, exit 1).

Finding 2 — stale CONTRIBUTING.md contradicting the PR: RESOLVED.
CONTRIBUTING.md:324-342 now draws the line cleanly: the shared nym may appear in documentation and examples, but a shipped default never carries an operator handle and always derives from the brand selector — "a handle in a documented example is not a leak, a handle in a shipped default is." A reviewer following this section today reaches the same verdict as this PR instead of being instructed to reject it.

Finding 3 — gate/changelog claiming more than they enforce: RESOLVED by narrowing honestly.
The gate header (check-identity-leak.sh:2-11) now states its scope is packaging/files/, names the unscanned compiled-binary identities (c08r4d0r, amperstrand, origami74 — the documented 0.07 profit-share examples, README.md:150 / docs/merchant.md:600) and explicitly leaves whether those stay as "its own decision". The closing message (:125) and the CHANGELOG entry now claim only packaging/files/ defaults. No false scope claims remain.

Verification re-run at head (fresh clone at 8808d7a; no CI reports on this branch — gh pr checks → none, 0 check-runs and 0 workflow runs on the head SHA, and the repo's Actions queue has every run queued 14h+ including on main, so the absence is repo-wide, not branch-specific; everything below ran locally at the exact head):

  • gate exit 0 at head — 6 checks pass (A once, B for both brand literals, C×3), including the new invariant B
  • negative controls all fire: hardcoded TollGate-Private- prefix → exit 1; BRAND_HOSTNAME="c08r4d0r" → exit 1 (invariants A + B both trip); identity literal planted under packaging/files/ → exit 1; the round-1 head itself now fails tightened invariant C, as it should
  • shellcheck -S warning clean on the gate; bash -n clean on both scripts; the setup script's SC3043 (×23)/SC2046 (×1) warnings are byte-identical on base a99d983, round-1 head and this head — pre-existing, none in the changed region
  • wiring intact: .github/workflows/test.yml:98, .ngit/act/workflows/test.yml:89, hooks/pre-commit:28
  • CHANGELOG entry sits once under [Unreleased] → Fixed; frozen v0.6.0-alpha4 section intact
  • preserve-existing-SSID semantics unchanged (:724-728): default only generated when unset — no deployed network changes
  • no other shipped file generates a private SSID (repo-wide grep: only 99-tollgate-setup); sibling contract suites green at head (private-subnet 12/12, same-version-allowlist 43/43, band 22/22, nodogsplash-443 19/19)

Non-blocking follow-ups (none hold approval):

  • [NIT] CHANGELOG.md:32-34 still has a 3-blank-line run before ## [v0.6.0-alpha4] (carried from round 1).
  • [NIT] check-identity-leak.sh:43,55,68-79 — invariant matching is case-sensitive: a capitalized BRAND_HOSTNAME="Amperstrand" passes both A and B today (verified: gate exit 0). grep -iE on the identity patterns would close it.
  • [NIT] check-identity-leak.sh:90 — sed … | head -1 reads "the first private_ssid= literal in the file", not specifically the one inside setup_private_network(); a decoy literal earlier in the file would mask a hardcoded real default. Scoping the extraction to the function body would make invariant C match what actually runs.
  • [INFO] check-identity-leak.sh:68-71 — under set -euo pipefail, a zero-match grep at :68 aborts before the advisory :70-71 can print; the check still fails closed, only the message is dead code.
  • [INFO] The narrowed gate leaves src/config_manager/config_manager_config.go:286-294 / config_manager_identities.go:46-58 (operator identities + personal Lightning addresses compiled into the shipped binaries) uncovered by any gate — pre-existing, outside this diff, but worth a maintainer decision of its own.

APPROVED.

felixfelix-bot and others added 4 commits September 24, 2026 14:18
…t private SSID

The private network's SSID is generated by 99-tollgate-setup when neither
radio has one yet, and it was written as "c08r4d0r-${RANDOM_SUFFIX}" - a
maintainer's own handle. That string is broadcast in the AP beacon of every
router that took the default, so any WiFi scanner in range read it: flashing
a router published the operator's username.

The default is now "TollGate-Private-${RANDOM_SUFFIX}" - a product name
plus the same device-unique suffix. Routers that already have a private SSID
keep it, because the default is only generated when the option is unset, so
no deployed network changes.

tests/contract/check-identity-leak.sh encodes the invariant: no maintainer
identity literal anywhere under packaging/files/, and the generated default
private SSID stays device-unique and product-prefixed. It is wired into the
build-purity CI job (both lanes), hooks/pre-commit, and the release-process
gate list.
…not privacy

The old handle named the operator of that router, on that operator's
own premises — not a privacy leak. What the change actually buys: the
default describes the network's purpose (whitelisted devices, no
payment) and no shipped default embeds a specific operator's identity,
which matters for rebranded or resold routers.
… the duplicate section heading

The wave-3 rebase artifact: the entry landed inside the frozen
[v0.6.0-alpha4] section (whose tag already exists) and left two
blank-separated '### Changed / Internal' headings at its end. The entry
moves to a fresh [Unreleased] -> Fixed; one duplicate heading goes.
…or (#540)

* fix(packaging): derive the default private SSID from the brand selector

The private network's default SSID was made `TollGate-Private-${RANDOM_SUFFIX}`,
which named the product but ignored `load_brand()`: a net4sats build broadcast
`Net4sats-XXXX` on its open APs and `TollGate-Private-XXXX` on its own private
network, and the identity-leak gate enforced that wrong name by pinning a
literal `TollGate-` prefix.

The default is now `${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}`, and
check-identity-leak.sh derives the expectation the same way: it reads the
brand -> BRAND_HOSTNAME mapping out of the setup script, rejects a mapping that
names an identity literal or is not a product name, and requires the default to
be exactly the brand-composed form. A hardcoded product name now fails the gate
instead of passing it.

The gate's own claim is narrowed to what it enforces: packaging/files/ (the
config/script tree the payload stages from) and the defaults a device
broadcasts. The compiled binaries built from src/ carry the documented
representative operator identities (README.md, docs/merchant.md) and are out of
scope here — that is a separate decision, stated in the header rather than
implied.

* docs(contributing): a shipped default never carries an operator handle

The operator-nym section still documented `c08r4d0r-<suffix>` as the default
private SSID prefix and told reviewers not to report it as an identity leak, so
following CONTRIBUTING meant rejecting the change that removes it.

The section now draws the line the code draws: the shared nym may appear in
documentation and examples (the reader replaces them), never in a shipped
default — anything a device broadcasts reads to anyone in radio range — and
`tests/contract/check-identity-leak.sh` enforces that for packaging/files/.

---------

Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com>
@Amperstrand
Amperstrand force-pushed the pr/private-ssid-no-operator-username branch from 8808d7a to 324c16d Compare September 24, 2026 12:19
@felixfelix-bot

Copy link
Copy Markdown
Contributor

Card-level re-verification at head 8808d7a13acf28b67501f051cf45dedac6d15f7b (fleet review loop, re-dispatch check — the standing APPROVED review [#5302978724, 2026-09-24 10:12] is at this exact head, so this note re-derives its evidence rather than re-reviewing).

Independently re-verified first-hand in a fresh clone at 8808d7a (no CI reports on this branch — repo Actions queue wedged repo-wide, known structural):

  • tests/contract/check-identity-leak.sh exit 0 — all three invariants pass (A: no identity literal under packaging/files/; B: brand selector maps BRAND_HOSTNAME to product names for both net4sats and tollgate; C: default is exactly ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}).
  • 99-tollgate-setup:724-734: default generated only when unset (preserve-existing semantics intact), composed from the brand selector; the c08r4d0r- literal is gone from packaging/files/ entirely (grep clean).
  • CONTRIBUTING.md identity section and the gate script's narrowed scope header match what the APPROVED review describes — no claims beyond what the gate enforces.
  • shellcheck -S warning: 26 findings at head vs 26 at merge-base a99d983a — all pre-existing (local under #!/bin/sh), zero introduced by this PR.
  • gitleaks over the full merge-base→head diff: no leaks.
  • Provenance of head: fix(packaging): derive the default private SSID from the brand selector #540 (same fix, same author pipeline) merged into this branch at 2026-09-23T14:50Z — this PR carries the reviewed fix; nothing unreviewed sits at head.

Verdict unchanged: APPROVED stands at 8808d7a.

One new fact since that review posted — non-blocking for the review, blocking for landing:

  • [INFO] Branch now conflicts with main (mergeable_state: dirty). main advanced to 2226a04c (LN mint-URL canonicalisation #5xx and neighbors); a local test-merge shows a single-file content conflict in CHANGELOG.md only — trivially resolvable. Worth noting urgency: main's 99-tollgate-setup:726 still ships c08r4d0r-${RANDOM_SUFFIX} as the default private SSID, i.e. the leak this PR closes is still live on main every day this sits unmergeable. A quick rebase lands it.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Re-review at head 992f4f4cbba99ac616757744bf1dfc40de7eeaaf (branch rebased onto main since the last APPROVED at 8808d7a; history rewritten, so every claim below was re-derived first-hand at the new head, full diff vs main @ 2226a04c, 8 files +177/−109).

APPROVED — no change requests.

The default private SSID (packaging/files/etc/uci-defaults/99-tollgate-setup:733) is now ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}, replacing c08r4d0r-${RANDOM_SUFFIX}. BRAND_HOSTNAME comes from load_brand() (line 916), which runs before setup_private_network() (line 931), and the uci -q get preserve-guard at line 724 keeps any existing SSID — no deployed network changes.

All three round-1 [RISK] findings are resolved at this head:

  1. Hardcoded TollGate- prefix → resolved by fix(packaging): derive the default private SSID from the brand selector #540's brand-selector derivation. A net4sats build now broadcasts Net4sats-Private-XXXX, not another brand's product name; both brand literals pass the new gate's product-name check.
  2. Stale CONTRIBUTING.md → rewritten (CONTRIBUTING.md:324-341). The "do not report it as an identity leak" instruction is gone; the correct line now stands: a handle in a documented example is not a leak, a handle in a shipped default is.
  3. Gate/changelog overclaim → honestly scoped. tests/contract/check-identity-leak.sh and the CHANGELOG entry scope the claim to packaging/files/ (what a device broadcasts); the src/config_manager profit-share example identities are explicitly named as documented examples, deliberately unscanned. This PR touches zero .go files.

Evidence re-derived first-hand at 992f4f4:

  • bash tests/contract/check-identity-leak.sh → exit 0 (all three invariants pass)
  • identity-literal grep under packaging/files/ → 0 hits
  • shellcheck delta vs merge-base → 0 new findings (24 pre-existing SC3043, untouched)
  • gitleaks over the full main...992f4f4 diff → no leaks found
  • CHANGELOG carries exactly one private-SSID entry, under [Unreleased] → Fixed

[INFO] check-identity-leak.sh is wired into .github/workflows/test.yml, .ngit/act/workflows/test.yml, and hooks/pre-commit, but the Actions queue is wedged repo-wide (every run since 2026-09-16 queued, including main; 0 check-runs on this head) — the CI step has not executed on GitHub; it is verified locally above.

[INFO] The intermediate CHANGELOG commits (58cbd71, ce7f9d0) were superseded in-branch by 992f4f4 dropping the rebase-resurrected duplicate — final tree is clean, no action needed.

[INFO] private_radio0/private_radio1 share private_ssid (lines 773/785) — pre-existing behavior, unchanged by this PR.

Gate 2.5 cold audit (DeepSeek, cross-family): AGREE — 8/8 claims verified first-hand at a fresh detached worktree of this head (branch state, SSID assignment + preserve-guard, load_brand ordering, zero identity literals, gate script exit 0 + CI/pre-commit wiring, single CHANGELOG entry, zero .go changes, CI-wedge/audit notes). Auditor additions, both non-blocking: [NIT] check-identity-leak.sh:98,106 carry deliberate SC2016 single-quote literals (script is clean at -S warning); [INFO] docs/release-process.md:111 adds the gate to the manual release checklist.

(Fleet review loop: gate 1 GLM, gate 2.5 cold audit DeepSeek — cross-family vs author.)

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at head 992f4f4cbba99ac616757744bf1dfc40de7eeaaf (branch rebased onto main since the last APPROVED at 8808d7a; history rewritten, so every claim below was re-derived first-hand at the new head, full diff vs main @ 2226a04c, 8 files +177/−109).

APPROVED — no change requests.

APPROVED at head 992f4f4 — no change requests. Full re-review evidence: see the head-anchored comment (issuecomment-5815205259). All three round-1 [RISK] findings resolved; identity-leak gate exit 0; gitleaks clean; shellcheck delta 0; zero Go changes. Gate 2.5 cold audit (DeepSeek): AGREE, 8/8 claims verified.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Technical review (evidence pass) — PR head 992f4f4cbba99ac616757744bf1dfc40de7eeaaf (base 24 commits behind main; GitHub reports the branch CONFLICTING).

The fix is right, and I verified it works. The one-line behavior change in 99-tollgate-setup replaces the c08r4d0r-${RANDOM_SUFFIX} default with ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}. The composition is reachable and correct on the only path that gets there: the full-setup flow runs load_brand (line 916; assigns TollGate/Net4sats) and setup_public_wifi (line 925; exports RANDOM_SUFFIX at line 335) before setup_private_network (line 931). The result mirrors GATEWAY_NAME composition, stays within the 32-byte SSID limit (21 chars worst case), feeds both radios from one variable (lines 773/785), and — because the default only generates when the option is unset — leaves every deployed network untouched, including SETUP_VERSION-bump reruns. git blame puts the old line at 70ec4e6db (2026-08-19) and the previous CONTRIBUTING text explicitly blessed it, so this is a deliberate policy reversal rather than a bug hunt — and the PR carries its half of that bargain by rewriting the "Branding, the operator nym, and net4sats" section that would otherwise instruct a reviewer to reject the change.

The gate is real and catches the old world. tests/contract/check-identity-leak.sh runs green on this head (all seven assertions), and I ran it as a negative control against the merge-base tree (2226a04c): exit 1 with three failures naming the c08r4d0r- literal — the claim in the PR body reproduces. The script uses set -euo pipefail, fails closed on structural drift (missing setup script or a changed assignment shape becomes an explicit "update this check" failure, not a silent pass), and handles no untrusted input, so the new gate adds no attack surface while the change itself removes one. Wiring is complete everywhere a gate lives in this repo: the build-purity job in both workflow twins (test.yml and the ngit twin), hooks/pre-commit, and the release-process gate list. No new dependency surface — bash/grep/sed/sort on CI and dev machines only, nothing shipped to the router.

What blocks the merge is the CHANGELOG, not the change. As pushed, the head's CHANGELOG state is a rebase casualty in two ways:

  1. It silently deletes six merged entries that main still carries — fix(ln): canonicalise the client-supplied mint URL so the Lightning lane can sell time #553's Lightning-lane canonicalisation (origin/main line 475), four #PRNUM-placeholder entries (main lines 544, 560, 575, 589), and fix(gate): a failed gate close is not a close — keep the gate tracked and retried until ndsctl confirms it (audit C1-2) #545's gate-close entry (main line 670) — all inside the frozen [v0.6.0-alpha4] section of a tagged release.
  2. It introduces a duplicate ### Changed / Internal heading at head lines 304–306 — again inside frozen alpha4; main has a single heading there (origin/main line 771).

Commit 3's message claims the wave-3 rebase artifact was cleaned and commit 5 claims the resurrected duplicate was dropped, but the diff against the merge-base still carries both problems, so the cleanup was incomplete. Concrete fix: rebase onto current main (the conflict GitHub already reports is precisely this file), take main's side wholesale for everything below [Unreleased], and land your delta as exactly one addition — the new entry under [Unreleased] → Fixed. While there, add the required ([#531](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/531)) link to the entry (head lines 29–45); every sibling entry carries one. Then squash the three docs(changelog): … commits into the single change — the current 5-commit series spends three commits chasing its own rebase artifacts.

Smaller items, none blocking on their own:

Disposition: request-changes — for the CHANGELOG restoration, the duplicate heading, and the rebase onto current main (plus the PR-link and body touch-up while in there). The substantive change — the uci-defaults fix, the gate script, the wiring, and the CONTRIBUTING policy reversal — is verified correct and ready; once the changelog delta is regenerated as a pure addition, this should land without another review round.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Re-review at head 992f4f4cbba99ac616757744bf1dfc40de7eeaaf — CHANGES REQUESTED.

The code fix is correct and unchanged at this head. The blockers are in CHANGELOG.md: this branch does not merely add one entry — it deletes 101 lines that its own merge-base and main still carry. Everything below was re-derived first-hand at this head (glm-5.2, gate-1). This supersedes the standing APPROVED review [#5305199472, same head], posted before the damage was surfaced by Amperstrand's evidence pass.

Verified correct at this head — no objection to the change itself:

  • packaging/files/etc/uci-defaults/99-tollgate-setup:733 — default private SSID is ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX}; reachable on the real path (setup_public_wifi :925 exports RANDOM_SUFFIX :335, then setup_private_network :931 composes :733 and feeds both radios :773/:785). Guarded by "only when unset" (:724-725), so deployed networks are untouched.
  • tests/contract/check-identity-leak.sh → exit 0, 7/7 assertions; packaging/files/ carries no maintainer-identity literal.
  • main still ships c08r4d0r-${RANDOM_SUFFIX} (origin/main:.../99-tollgate-setup:1673), so this fix is still needed.

[BLOCK] CHANGELOG.md — five merged entries are deleted. Relative to the merge-base 2226a04c (verified: it is a descendant of head AND git merge-base pr531 origin/main), the head tree has zero occurrences of:

  • #553's Lightning-lane canonicalisation entry (present at merge-base :29 and origin/main:457);
  • the four #PRNUM placeholder entries (merge-base :63+, origin/main:544+).

git diff --numstat 2226a04c..pr531 -- CHANGELOG.md → 19 added / 101 deleted. Because 2226a04c is the merge-base, this deletion applies cleanly on merge and the entries vanish from main. Fix: rebase onto current main (the CONFLICTING GitHub reports is this file), take main's changelog wholesale below [Unreleased], and land exactly one addition under [Unreleased] → Fixed.

[RISK] CHANGELOG.md:304,306 — duplicate empty ### Changed / Internal heading inside the frozen [v0.6.0-alpha4] section: line 304's heading has no content; the real section starts at 306. Rebase artifact — main has a single heading there.

[NIT] CHANGELOG.md:27-45 — the new [Unreleased] → Fixed entry has no ([#531](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/531)) link, unlike every sibling entry (e.g. the #541 entry directly above it).

[INFO]

  • PR body is stale: it says the default is now TollGate-Private-${RANDOM_SUFFIX}, but #540 (landed in this branch) made it brand-derived precisely so a net4sats build doesn't broadcast "TollGate" on its own private network.
  • Branch is CONFLICTING, base 24 commits behind main; overlap with test(contract): pin the shipped AP SSID format (replaces #489) #524 (same four wiring points) — land order worth choosing deliberately.
  • Three docs(changelog): … commits chase their own rebase artifacts; squash them into the single change.

Once the changelog delta is regenerated as a pure addition (plus the #531 link), the substantive change is ready and needs no further review round on the fix itself.

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseding the standing APPROVED review [#5305199472] at this head (992f4f4cbba99ac616757744bf1dfc40de7eeaaf): the branch's rebased CHANGELOG deletes five entries its own merge-base and main still carry (19 added / 101 deleted), plus a duplicate empty ### Changed / Internal heading. The uci-defaults fix itself is verified correct and unchanged. Full evidence: #531 (comment)

Amperstrand pushed a commit that referenced this pull request Sep 27, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand pushed a commit that referenced this pull request Sep 27, 2026
TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Fix-and-rebase run against the review's blocking list — body refreshed; CHANGELOG/rebase deferred on a main change that postdates every review here. Head unchanged: 992f4f4c (nothing pushed).

Per finding:

  1. CHANGELOG pure-addition + duplicate heading + [#531] link + rebase onto main — deferred, blocked by a semantic conflict the reviews could not have seen. Between the newest review here (2026-09-26) and now, main landed feat(packaging): one device code — hostname, captive SSID and private SSID agree #605 "one device code" (3c4fd64a, 2026-09-27 12:31 +02:00), which rewrote the exact region this PR changes:

    • 99-tollgate-setup now derives the private SSID via private_ssid_for_code(): default <nym>-<code> with DEVICE_CODE_DEFAULT_NYM="c08r4d0r" (line 377); RANDOM_SUFFIX no longer exists in the script at all (0 hits).
    • docs/architecture/one-device-code.md (Status: Decided, 2026-09-27) makes the handle-in-private-SSID deliberate: "<nym> is the operator's own prefix for the private SSID (c08r4d0r by default)" — the opposite policy from this PR, decided after the reviews here were written.
    • Deployed-network semantics contradict directly: this branch preserves a preseeded c08r4d0r-9F3A verbatim (reviewer-verified harness); feat(packaging): one device code — hostname, captive SSID and private SSID agree #605's convergence deliberately rewrites machine-shaped <nym>-<suffix> SSIDs onto <nym>-<code>.
    • The nym/code contract is pinned cross-repo (installer branding_test.go ↔ tests/uci-defaults-device-code_test.sh).

    Cherry-picking this branch onto current origin/main (afc86b3f) conflicts in 99-tollgate-setup beyond any mechanical resolution, and this PR's own gate fails against main's tree: bash tests/contract/check-identity-leak.sh on a pristine main checkout → rc=1, two FAILs (invariant A: DEVICE_CODE_DEFAULT_NYM="c08r4d0r" is an identity literal under packaging/files/; invariant C: the setup script "no longer assigns private_ssid a literal default" — the format is ${NYM}-${CODE} now). Landing this PR as reviewed means either redesigning feat(packaging): one device code — hostname, captive SSID and private SSID agree #605's nym default, gate invariants, convergence semantics and the installer-side pins — or letting feat(packaging): one device code — hostname, captive SSID and private SSID agree #605's identity model supersede the private-SSID half of this PR. That is a maintainer design decision the reviews here did not make (they predate feat(packaging): one device code — hostname, captive SSID and private SSID agree #605 by ~15 hours), so this run stopped at the conflict per its no-improvisation rule instead of shipping a guess.

  2. PR body refreshed (gh pr edit): the stale TollGate-Private-${RANDOM_SUFFIX} claim now reads ${BRAND_HOSTNAME}-Private-${RANDOM_SUFFIX} — brand-derived per fix(packaging): derive the default private SSID from the brand selector #540, so a net4sats build broadcasts Net4sats-Private-XXXX. The rest of the body was still accurate at head and is unchanged.

Verification run this pass: gate at head 992f4f4c → rc=0, 7/7 assertions; gate against current main's tree → rc=1, 2 FAILs (the demonstration above, not a regression of this branch); bash -n clean on check-identity-leak.sh at head. No Go files touched, so no Go battery.

Head: 992f4f4cbba99ac616757744bf1dfc40de7eeaaf — unchanged, nothing pushed. The changelog rewrite itself is fully specified by the review (one addition under [Unreleased] → Fixed carrying ([#531](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/531)), duplicate heading gone, the three docs(changelog) artifact commits folded into the substantive change) and lands mechanically the moment the #605 direction is decided.

Amperstrand pushed a commit that referenced this pull request Sep 28, 2026
TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Decision: closing as superseded by #605 — and the default nym stays.

#605's "one device code" ADR (docs/architecture/one-device-code.md, status: Decided) made the private SSID <nym>-<code> with DEVICE_CODE_DEFAULT_NYM="c08r4d0r" deliberate architecture, which reverses this PR's premise: the rename to TollGate-Private-XXXX will not happen, and this branch's own gate fails against the decided invariants (check-identity-leak.sh rc=1 on pristine main).

Why that's the right outcome, on the merits. This PR's defect was real when it was filed: the default SSID broadcast what was effectively the operator's personal handle, with no decision behind it and no operator awareness. #605's model removes exactly that defect by a different route: c08r4d0r is the project's generic default nym, shared by every installation — it names the product, not any operator. There is no personal handle in the default anymore; an operator who personalizes the nym does so as a visible, deliberate choice. A per-device randomized nym was considered and deliberately rejected: generic product branding on the management AP is more useful (recognizable, supportable, consistent with the public AP's TollGate- prefix) than per-device noise, and the privacy concern it would address does not exist when the default is generic.

What survives from this PR for whoever next touches SSID defaults: the harness and gate-script wiring (the #524 thread carries the follow-on work of updating the SSID contract to the #605 device-code model), and the CONTRIBUTING policy discussion. The uci-defaults change itself is dead by decision.

Amperstrand pushed a commit that referenced this pull request Sep 28, 2026
TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).
Amperstrand pushed a commit that referenced this pull request Oct 5, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
felixfelix-bot pushed a commit to felixfelix-bot/tollgate-module-basic-go that referenced this pull request Oct 6, 2026
…ollGate#531's

TollGate-Private-XXXX is the post-OpenTollGate#531 state and OpenTollGate#531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at OpenTollGate#531 for the rename (PR OpenTollGate#524 review follow-up).
c03rad0r pushed a commit that referenced this pull request Oct 6, 2026
* test(contract): pin the shipped AP SSID format

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

* docs(readme): document SSID format conventions

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.

* test(contract): follow the band-aware setup in the SSID harness

Main's setup now resolves radios by band before assigning APs
(detect_band_radios -> setup_band_ap), so the harness bootstraps a
banded first-boot wireless state, calls detect_band_radios before
setup_public_wifi, and reads the SSIDs back from the tollgate_*_open
sections the band-aware setup writes (adopting or creating them per
radio). The asserted contract is unchanged: one shared
`^TollGate-[0-9A-F]{4}$` name on both open radios, per-device suffix,
no band tokens.

* docs(changelog): move the SSID-format contract entry to [Unreleased]

The wave-3 catch: the entry sat inside the frozen [v0.6.0-alpha4]
section (the tag exists); new entries belong in [Unreleased].

* docs(readme): the private-AP bullet documents today's SSID, not #531's

TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).

* test(contract): assert the acceptance domain [A-Z0-9], not hex-only codes

Codes minted by the current writers are hex, but the contract's acceptance
domain is [A-Z0-9] (adopted codes from earlier writers, e.g. the
pre-device-code installer's full-alphanumeric mints). Per #618's ADR
clarification.

* test(contract): follow the one-device-code model in the SSID harness

#605 changed the derivation this gate pins: the SSID is no longer a
per-boot RANDOM_SUFFIX but DEVICE_SSID, built by setup_device_identity
from a code minted once and stored in /etc/config/tollgate. The harness
still modelled the pre-#605 world — setup_public_wifi died on
'DEVICE_SSID: unbound variable' after load_brand's brand read, and the
checker was 13 checks red at head.

The behavioural layer now runs the driver's own order (load_brand →
setup_device_identity → detect_band_radios → setup_public_wifi) with
DEVICE_CODE_STORE redirected into the per-run state (the env seam the
device-code tests use), so a wiped state is a fresh device that really
mints, and a new keep-mode run pins the other half of the contract: a
same-store rerun adopts the stored code and never re-mints.

The stub uci now renders section types bare in show output like the real
uci (the band-test shim's rule): with quoted section types band
detection and AP adoption were silently disabled, so the harness
exercised the fallback create path while claiming to follow the banded
first-boot state. The readback follows whichever sections the APs
actually landed on (the adopted default_radioN today) instead of keying
on tollgate_*_open, so the labels cannot lie about the path that ran.

Assertion domain is the full [A-Z0-9] code alphabet (this side's mint
emits hex; the installer's emits all of [A-Z0-9]; the contract is the
shared alphabet), and B2 accepts the $DEVICE_SSID derivation in place
of the removed RANDOM_SUFFIX/hexdump forms.

Verified: green at head (18 ok / 0 fail, 5 consecutive runs); red on
fresh-archive mutations — band suffix on the derivation (exit 1),
hardcoded literal at GATEWAY_NAME (exit 1: distinctness + store/SSID
disagreement + B2), deleted public ssid write (exit 1); band suite
22/22, device-code suite 72/72.

* docs: the SSID contract wording follows the device-code alphabet

The README public-AP bullet still called the XXXX a '4-hex uppercase
device code' and the changelog entry still pinned ^TollGate-[0-9A-F]{4}$
and a 'per-device 4-hex suffix' — both stale against the #605 decision
(docs/architecture/one-device-code.md): the code is four characters of
[A-Z0-9] (this side's mint emits hex, the installer's emits the full
alphabet), minted once and reused from the store. The changelog entry
now also describes the harness as it works after the one-device-code
adaptation: the identity chain is sourced and run, the stub renders
uci show section types bare, and the code-reuse property is asserted.

---------

Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com>
Co-authored-by: Amperstrand <amperstrand@localhost>
Amperstrand pushed a commit that referenced this pull request Oct 6, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand pushed a commit that referenced this pull request Oct 6, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand pushed a commit that referenced this pull request Oct 6, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand added a commit that referenced this pull request Oct 6, 2026
…581)

Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.

Co-authored-by: Amperstrand <amperstrand@localhost>
c03rad0r pushed a commit that referenced this pull request Oct 6, 2026
…nflicts stop existing (#679)

* feat(changelog): fold changelog.d fragments into CHANGELOG.md

Two PRs that change operator-visible behaviour both append one bullet to the
same anchor of CHANGELOG.md, so the second merge conflicts every time — about a
dozen hand-resolved hunks on the 0.6.0 train, and two of those resolutions
(#524, #531) resurrected an entry a branch had already moved past a
keep-both-sides resolution. 15 open PRs carry a CHANGELOG.md hunk at once as
this lands, so the merge order was also a changelog-editing order.

A fragment is one file per change — changelog.d/<pr>-<slug>.<type>.md, the body
being the bullet verbatim — so unique filenames cannot conflict and a merge
never touches the changelog at all. `scripts/changelog-assemble.py fold` writes
the accumulated entries into the release section in one deterministic step (one
wave under the release heading, newest PR first, subsections in canonical
order) and deletes the folded fragments; `check` validates names and bodies;
`--dry-run` prints the wave and writes nothing.

The contract test went red first — 21 failing cases with the tool absent — and
green after: 18 cases covering where entries land, the order they land in, what
is deleted, the dry run, and each verdict of check. shellcheck clean; the fold
was also run against the real 3,500-line CHANGELOG.md in a copy to see the wave
it produces (folded into `## [Unreleased]`, everything else untouched).

Decision and the rejected alternatives: docs/architecture/changelog-fragments-decision.md

* chore(changelog): validate fragments in the pre-commit hook and CI

`check` runs where the other contract checks run: the pre-commit hook refuses a
malformed fragment (wrong filename, unknown type, a body that is not the bullet)
before it is committed, and the `contract-lint` job runs the check plus the
tool's contract test on every PR. Wired into `.github/workflows/test.yml` and
its ngit port, which is the lane that actually executes.

* docs(changelog): record the fragment decision and the workflow

The decision record states the problem (one shared anchor, a dozen hand-resolved
hunks a train, two resurrections), the decision (fragments + one fold), and the
alternatives it rejects — a `merge=union` gitattributes driver (hides the shared
anchor and silently keeps both sides of a bullet that was rewritten) and
generating the changelog from commit subjects (loses the prose this changelog
is worth reading for). CONTRIBUTING sends authors to changelog.d/ instead of
CHANGELOG.md, and the release process folds the accumulated fragments in its own
commit before the release commit is cut.

* docs(changelog): this PR's own entry is a fragment

The rule this PR adds applies to it: the changelog entry lives in
changelog.d/679-changelog-fragments.added.md and is not folded here, so the
first fold the maintainer runs produces it.

---------

Co-authored-by: Felix <felix@opentollgate.org>
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