diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 872797ad8..c4db7a6a5 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -1025,6 +1025,19 @@ jobs: python3 -m pip install --break-system-packages playwright python3 -m playwright install --with-deps chromium + # Dependency-resolution smoke: apk3 (the only tooling that reads 25.12 + # indexes) must be able to select every name the package declares from + # the standard 25.12.x feed set. This is the gate the September #552 + # breakage class would have tripped: a feed rebuild changed how the + # iptables family is provided, and nothing between "artifact builds" + # and "a bench VM cannot install it" noticed. Runs before the + # happy-path suite because it is seconds, not minutes. + - name: Apk dependency-resolution smoke + run: | + set -euo pipefail + export APK=$(ls /var/tmp/hp-artifact/*.apk | head -1) + bash tests/packaging/apk-install-resolution_test.sh + # --strict is deliberate, not decoration: this artifact is built from a tip # that carries upstream #541 (/session-state) and a portal bundle that ships # the in-page renewal CTA (#60), so those surfaces are EXPECTED here and diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 000000000..7ae25f6bb --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,12 @@ +name: gitleaks + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "13 6 * * *" + +jobs: + gitleaks: + uses: Amperstrand/.github/.github/workflows/gitleaks.yml@main diff --git a/.github/workflows/router-test.yml b/.github/workflows/router-test.yml new file mode 100644 index 000000000..3fa7d6a1b --- /dev/null +++ b/.github/workflows/router-test.yml @@ -0,0 +1,108 @@ +# router-test — run the physical/lab router suite on the OpenWrt test fleet. +# +# Runs as an ngit-ci act container (runs-on: ubuntu-latest) on the coordinator, +# then SSHes to the ELECTED `router-bench-gateway` (a fleet host that can reach +# the QEMU lab and the physical bench). The gateway dispatcher +# (hermes-orchestration scripts/fleet/router_bench_test.sh) does the gating, +# target selection and execution. +# +# SECURITY CONTRACT (mirrors physical-router-test-automation's hw-smoke): +# * The physical bench is NEVER reachable from a pull_request: PR runs use the +# isolated QEMU lab only (environment `router-lab`, no paid traffic). +# * Post-merge `main` runs may touch the bench, behind the `bench-hardware` +# environment (required reviewers). Paid-traffic specs are OFF by default. +# * Bench work lives in THIS file only. +name: router-test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + target: + description: auto|lab|physical|both + type: choice + options: [auto, lab, physical, both] + default: auto + lane: + description: readonly|mutating + type: choice + options: [readonly, mutating] + default: readonly + paid: + description: "Enable paid-traffic specs (mutating only)" + type: boolean + default: false + +concurrency: + # Serialise per ref and drop superseded pushes. + group: router-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + proceed: ${{ steps.head.outputs.proceed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Head-check (drop superseded commits) + id: head + # Values reach the shell through the environment, never through `${{ ... }}` + # interpolation: a branch name is contributor-controlled on a fork PR, and + # interpolating it would splice it into the script text. + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + THIS_SHA: ${{ github.sha }} + run: | + set -eu + if [ "$EVENT_NAME" = "push" ]; then + head=$(git ls-remote origin "refs/heads/$REF_NAME" | awk '{print $1}') + if [ -n "$head" ] && [ "$head" != "$THIS_SHA" ]; then + echo "superseded by ${head} — skipping ${THIS_SHA}" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "proceed=true" >> "$GITHUB_OUTPUT" + + router-test: + needs: plan + if: needs.plan.outputs.proceed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + # PR -> isolated lab; push/main -> bench (required reviewers). + # PR -> isolated lab; a manual dispatch that asks for the lab stays on the lab; + # only a push to main (or a dispatch that asks for the physical bench) needs the + # `bench-hardware` environment (required reviewers). Spelled out because GitHub's + # `a && b || c` idiom would send a LAB dispatch to the bench approvers. + environment: ${{ (github.event_name == 'pull_request' || inputs.target == 'lab') && 'router-lab' || 'bench-hardware' }} + steps: + - name: Run via the elected router-bench-gateway + env: + # Per-repo secrets are injected only for maintainer-authored runs; + # a third-party PR runs with empty secrets and is skipped here. + GW_HOST: ${{ secrets.ROUTER_BENCH_GATEWAY }} + GW_KEY_B64: ${{ secrets.ROUTER_BENCH_SSH_KEY_B64 }} + EVENT: ${{ github.event_name == 'pull_request' && 'pr' || 'push' }} + REF: ${{ github.ref_name }} + SHA: ${{ github.sha }} + TARGET: ${{ inputs.target || 'auto' }} + LANE: ${{ inputs.lane || 'readonly' }} + PAID: ${{ inputs.paid || 'false' }} + run: | + set -eu + if [ -z "${GW_HOST:-}" ] || [ -z "${GW_KEY_B64:-}" ]; then + echo "No router-bench credentials for this run (non-maintainer?) — skipping." + exit 0 + fi + install -d -m700 ~/.ssh + printf '%s' "$GW_KEY_B64" | base64 -d > ~/.ssh/gw && chmod 600 ~/.ssh/gw + ssh -i ~/.ssh/gw -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \ + "$GW_HOST" \ + "router_bench_test.sh --event $EVENT --ref '$REF' --commit '$SHA' \ + --target '$TARGET' --lane '$LANE' --paid '$PAID'" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0782fc52e..0193bde4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,6 +117,32 @@ jobs: # negative control fails if the comparison ever goes back to equality. bash tests/uci-defaults-setup-marker-order_test.sh + - name: Exactly one owner of :8090, and LuCI alone on :8080 + run: | + # Offline (no router, no SDK, no network): the portal-staged board + # (uhttpd.admin, written by the feed's 92-tollgate-admin-setup) is the + # ONE section that may claim :8090, and the module writes no :8090 + # listener of its own. It used to: a brand-gated legacy configUI + # writer created a second section on the port, so two admin UIs bound + # the same port and one of them disappeared. The suite drives the + # shipped uci-defaults script end to end through the four install + # scenarios - fresh, upgrade, reinstall-over-marker, and a box whose + # legacy section had its listeners stripped by an older build (which + # is why the stale section must be DELETED, not port-stripped) - and + # carries detector controls so a passing assertion cannot be vacuous. + bash tests/packaging/configui-8090-single-owner_test.sh + + - name: No re-brand literal anywhere in the tree + run: | + # Offline and tree-only: upstream carries no commercial re-brand's + # name - not in the setup script, not in the docs, not in the + # CHANGELOG. The gutter fails the moment a literal reappears, and + # scans itself: the pattern is a bracket expression, so this file does + # not carry the name it bans. A planted-occurrence control proves the + # scan is not inert. This is a working-tree gutter, not a history + # rewrite: the literal legitimately survives in old commits. + bash tests/packaging/rebrand-literal-gutter_test.sh + - name: Admin TLS identity (HTTPS is not inherited from the image) run: | # Offline (no router, no SDK, no network): the install path must @@ -153,6 +179,39 @@ jobs: # both setup paths re-assert it. bash tests/uci-defaults-trusted-entry-80_test.sh + - name: The wired LAN ports move onto the operator's private bridge + run: | + # Offline (no router, no daemon, no network): the wired LAN ports + # must be MOVED off the captive bridge onto br-private - the + # operator's trusted network with internet, the admin board and + # LuCI, no payment step - discovered from the bridge's device + # section (never named per board), idempotently, with the captive + # bridge's port list cleared so a port is never on two layer-2 + # domains. The same-version verify/repair path re-asserts the + # placement (a factory reset is repaired), the module keep-list + # still carries /etc/config/network, and the guard fragments are + # pinned by digest (three byte-identical to main; the :2121 + # backend-firewall guard updated on purpose so the owner network can + # read the admin board's data) and still br-lan-scoped - that literal + # is what keeps a guest off :8090/:8443 and LuCI while a br-private + # client gets in. A neutralised-writer negative control must go red. + bash tests/uci-defaults-lan-private-wired_test.sh + + - name: The backend API answers the owner network (admin board data path) + run: | + # Offline (no router, no daemon, no network): the :2121 API must be + # reachable from br-private as well as br-lan. The admin board is + # deliberately kept OFF the captive bridge, so br-private is the one + # network it is administered from - and the board is a thin shell + # whose every panel reads pricing/whoami/balance/ln-invoice from + # :2121. With the exemption missing, the board rendered on a freshly + # flashed GL-MT3000 (2026-10-04, alpha4-pre21) while each data call + # died with "TypeError: NetworkError when attempting to fetch + # resource" and retried forever. The suite pins the exemption set on + # both protocol families, that the rule is still a drop, that no + # foreign interface is exempted, and that the fragment compiles. + bash tests/packaging/backend-api-owner-network_test.sh + - name: One device code (hostname, captive SSID and private SSID agree) run: | # Offline (no router, no SDK, no network): the router's identity is diff --git a/.gitignore b/.gitignore index 35d876645..e5ac0db9f 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,9 @@ scripts/token-recovery/token-recovery # pytest bytecode from local (possibly root-owned) cloud-lab runs tests/cloud-lab/__pycache__/ +scripts/__pycache__/ +tests/cloud-lab/conformance/__pycache__/ + +# Conformance lane runtime state (tokens stashed between phases, generated +# config, verdict parts, evidence logs) — see tests/cloud-lab/conformance/. +tests/cloud-lab/.conformance/ diff --git a/.ngit/README.md b/.ngit/README.md index 264b52c93..5e84d3b2e 100644 --- a/.ngit/README.md +++ b/.ngit/README.md @@ -148,6 +148,19 @@ DQ05, `embedded-act` runner, `ghcr.io/catthehacker/ubuntu:act-latest`) with falls back to `0` when history is unavailable; tagged releases are unaffected because their version is the tag name. `GOFLAGS=-buildvcs=false` for the same reason. + This also removes the **commit timestamp**, which the reproducible-build pin + (#383) needs for `SOURCE_DATE_EPOCH`. `scripts/ngit-commit-epoch.sh` is the one + derivation: the commit time from local history where there is one, and + otherwise a depth-1 fetch of *exactly that commit* from the ngit mirror the + release is built from — so the value is a property of the commit, not of the + run. Stage 1 (`determine-versioning`, `build-portal`) and the shards' + `resolve-inputs` all call it. When neither source answers, the job **fails** + instead of substituting the runner clock: the earlier cascade had a + `date +%s` last resort, and because the coordinator's synthesized push payload + carries `head_commit` with no timestamp it took that branch on *every* run + (`source: job clock (NOT commit-derived …)` at `ca5d07a2`), so two builds of + one commit stamped different `BuildTime` strings and the published bytes could + never be reproduced or compared. * **Blossom reachability from the runner.** `blossom.primal.net`, `blossom.psbt.me`, `blossom2.orangesync.tech` and `drive.cashu.email` answer; `blossom1.orangesync.tech` times out (25 s). The server list is left as the diff --git a/.ngit/act/workflows/build-package-binaries.yml b/.ngit/act/workflows/build-package-binaries.yml index 3d6552b10..c885dd7a8 100644 --- a/.ngit/act/workflows/build-package-binaries.yml +++ b/.ngit/act/workflows/build-package-binaries.yml @@ -110,31 +110,38 @@ jobs: echo "release_channel=dev" >> "$GITHUB_OUTPUT" fi - - name: Derive SOURCE_DATE_EPOCH from the source commit + - name: Resolve SOURCE_DATE_EPOCH from the source commit # The reproducible-build pin from #383: every embedded timestamp — # BuildTime in the binaries, ipk archive mtimes, portal output — # derives from the source commit, never from the wall clock, so a - # rebuild of one commit yields the same bytes. The act workspace has - # no usable .git, so the derivation is env-first like repro-check.yml: - # triggering commit's timestamp, job-start clock as the last resort - # (which is then recorded in the stage-1 rendezvous record below, so - # stage 2 still packages with exactly the epoch the binaries used). + # rebuild of one commit yields the same bytes. + # + # scripts/ngit-commit-epoch.sh is the single derivation, shared with the + # build-portal job below and with the shards' resolve-inputs: it reads + # the commit time from the local history when there is one, and + # otherwise fetches exactly this commit (depth 1) from the ngit mirror + # the release is built from — an act job checkout has no git metadata, + # `git log` fails there, and the coordinator's synthesized push payload + # carries `head_commit` without a timestamp, so the previous cascade + # landed on `date +%s` on EVERY ngit run and stamped the binaries with + # the runner's clock (observed at ca5d07a2: "source: job clock (NOT + # commit-derived ...)"). That is exactly the failure this lane exists to + # prevent: two builds of one commit produced different BuildTime strings, + # so the published bytes could never be reproduced or compared. + # + # No wall-clock fallback on purpose. When neither source is available + # the script exits non-zero and this step fails with it: one clearly + # red run is better than a release whose artifacts silently cannot be + # rebuilt identically. id: source-epoch shell: bash run: | + set -euo pipefail : ${GITHUB_OUTPUT:=/tmp/github_output} - EPOCH="$(git -C "$GITHUB_WORKSPACE" log -1 --format=%ct HEAD 2>/dev/null || true)" - SOURCE="git" - if [ -z "$EPOCH" ] && [ -n "${{ github.event.head_commit.timestamp }}" ]; then - EPOCH="$(date -u -d "${{ github.event.head_commit.timestamp }}" +%s)" - SOURCE="head_commit.timestamp" - fi - if [ -z "$EPOCH" ]; then - EPOCH="$(date +%s)" - SOURCE="job clock (NOT commit-derived — binaries will not rebuild identically)" - fi + EPOCH=$(bash scripts/ngit-commit-epoch.sh "$GITHUB_SHA") + case "$EPOCH" in ''|*[!0-9]*) echo "ERROR: not an epoch: '$EPOCH'" >&2; exit 1 ;; esac echo "source_date_epoch=$EPOCH" >> "$GITHUB_OUTPUT" - echo "SOURCE_DATE_EPOCH=$EPOCH (source: $SOURCE)" + echo "SOURCE_DATE_EPOCH=$EPOCH ($(date -u -d "@$EPOCH" '+%Y-%m-%d %H:%M:%S UTC'))" - name: Report run: | @@ -303,8 +310,9 @@ jobs: set -euo pipefail : ${GITHUB_OUTPUT:=/tmp/github_output} # The epoch rides the record so stage 2 packages with exactly the - # epoch these binaries were stamped with — even when the last-resort - # clock source produced it. + # epoch these binaries were stamped with. It is commit-derived (see + # the source-epoch step), so this record is stable across re-runs at + # one commit rather than reporting whenever the run happened. RECORD_JSON=$(printf '%s' "$HASHES_JSON" | jq -c --argjson epoch "$SOURCE_DATE_EPOCH" '. + {epoch: $epoch}') out=$(nak event --sec "$NSEC_HEX" -k 30078 \ --tag "d=tollgate-build/${BUILD_ID}/binaries" \ diff --git a/.ngit/act/workflows/router-test.yml b/.ngit/act/workflows/router-test.yml new file mode 100644 index 000000000..3fa7d6a1b --- /dev/null +++ b/.ngit/act/workflows/router-test.yml @@ -0,0 +1,108 @@ +# router-test — run the physical/lab router suite on the OpenWrt test fleet. +# +# Runs as an ngit-ci act container (runs-on: ubuntu-latest) on the coordinator, +# then SSHes to the ELECTED `router-bench-gateway` (a fleet host that can reach +# the QEMU lab and the physical bench). The gateway dispatcher +# (hermes-orchestration scripts/fleet/router_bench_test.sh) does the gating, +# target selection and execution. +# +# SECURITY CONTRACT (mirrors physical-router-test-automation's hw-smoke): +# * The physical bench is NEVER reachable from a pull_request: PR runs use the +# isolated QEMU lab only (environment `router-lab`, no paid traffic). +# * Post-merge `main` runs may touch the bench, behind the `bench-hardware` +# environment (required reviewers). Paid-traffic specs are OFF by default. +# * Bench work lives in THIS file only. +name: router-test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + target: + description: auto|lab|physical|both + type: choice + options: [auto, lab, physical, both] + default: auto + lane: + description: readonly|mutating + type: choice + options: [readonly, mutating] + default: readonly + paid: + description: "Enable paid-traffic specs (mutating only)" + type: boolean + default: false + +concurrency: + # Serialise per ref and drop superseded pushes. + group: router-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + proceed: ${{ steps.head.outputs.proceed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Head-check (drop superseded commits) + id: head + # Values reach the shell through the environment, never through `${{ ... }}` + # interpolation: a branch name is contributor-controlled on a fork PR, and + # interpolating it would splice it into the script text. + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + THIS_SHA: ${{ github.sha }} + run: | + set -eu + if [ "$EVENT_NAME" = "push" ]; then + head=$(git ls-remote origin "refs/heads/$REF_NAME" | awk '{print $1}') + if [ -n "$head" ] && [ "$head" != "$THIS_SHA" ]; then + echo "superseded by ${head} — skipping ${THIS_SHA}" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "proceed=true" >> "$GITHUB_OUTPUT" + + router-test: + needs: plan + if: needs.plan.outputs.proceed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + # PR -> isolated lab; push/main -> bench (required reviewers). + # PR -> isolated lab; a manual dispatch that asks for the lab stays on the lab; + # only a push to main (or a dispatch that asks for the physical bench) needs the + # `bench-hardware` environment (required reviewers). Spelled out because GitHub's + # `a && b || c` idiom would send a LAB dispatch to the bench approvers. + environment: ${{ (github.event_name == 'pull_request' || inputs.target == 'lab') && 'router-lab' || 'bench-hardware' }} + steps: + - name: Run via the elected router-bench-gateway + env: + # Per-repo secrets are injected only for maintainer-authored runs; + # a third-party PR runs with empty secrets and is skipped here. + GW_HOST: ${{ secrets.ROUTER_BENCH_GATEWAY }} + GW_KEY_B64: ${{ secrets.ROUTER_BENCH_SSH_KEY_B64 }} + EVENT: ${{ github.event_name == 'pull_request' && 'pr' || 'push' }} + REF: ${{ github.ref_name }} + SHA: ${{ github.sha }} + TARGET: ${{ inputs.target || 'auto' }} + LANE: ${{ inputs.lane || 'readonly' }} + PAID: ${{ inputs.paid || 'false' }} + run: | + set -eu + if [ -z "${GW_HOST:-}" ] || [ -z "${GW_KEY_B64:-}" ]; then + echo "No router-bench credentials for this run (non-maintainer?) — skipping." + exit 0 + fi + install -d -m700 ~/.ssh + printf '%s' "$GW_KEY_B64" | base64 -d > ~/.ssh/gw && chmod 600 ~/.ssh/gw + ssh -i ~/.ssh/gw -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \ + "$GW_HOST" \ + "router_bench_test.sh --event $EVENT --ref '$REF' --commit '$SHA' \ + --target '$TARGET' --lane '$LANE' --paid '$PAID'" diff --git a/.ngit/act/workflows/test.yml b/.ngit/act/workflows/test.yml index 8a8ebc209..5d5b60f31 100644 --- a/.ngit/act/workflows/test.yml +++ b/.ngit/act/workflows/test.yml @@ -107,6 +107,23 @@ jobs: # both setup paths re-assert it. bash tests/uci-defaults-trusted-entry-80_test.sh + - name: The wired LAN ports move onto the operator's private bridge + run: | + # Offline (no router, no daemon, no network): the wired LAN ports + # must be MOVED off the captive bridge onto br-private - the + # operator's trusted network with internet, the admin board and + # LuCI, no payment step - discovered from the bridge's device + # section (never named per board), idempotently, with the captive + # bridge's port list cleared so a port is never on two layer-2 + # domains. The same-version verify/repair path re-asserts the + # placement (a factory reset is repaired), the module keep-list + # still carries /etc/config/network, and all four guard fragments + # are pinned byte-identical to main and still br-lan-scoped - that + # literal is what keeps a guest off :8090/:8443 and LuCI while a + # br-private client gets in. A neutralised-writer negative control + # must go red. + bash tests/uci-defaults-lan-private-wired_test.sh + - name: One device code (hostname, captive SSID and private SSID agree) run: | # Offline (no router, no daemon, no network): the router's identity is diff --git a/AGENTS.md b/AGENTS.md index a5a8851d9..dca5e7ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,10 @@ than inventing new harnesses. - **gonuts-tollgate is our fork to maintain.** Upstream `elnosh/gonuts` is dead (last release v0.4.2, 2025); we carry ~40 patches. Every wallet-level fix lands in `OpenTollGate/gonuts-tollgate` first, is - tagged, then bumped here via the `replace` directive (three `go.mod` - files). Never fix a wallet bug by patching around the fork locally. + tagged, then bumped here via the `replace` directive — the require lands + in every nested module that carries it: `src/`, `src/cli`, `src/merchant` + and `src/tollwallet`, four `go.mod` files today. Never fix a wallet bug + by patching around the fork locally. - **bbolt persistence.** Keyset records (which own derivation counters) are nested under mint-URL-named buckets; the DB has no transactions spanning "fetch keysets + swap + save proofs". This is why counter @@ -141,6 +143,47 @@ than inventing new harnesses. breaks that (e.g. cdk-go FFI on MIPS) belongs behind the sidecar, not in-process. +## Hardware and VM testing (labgrid) + +All router- and VM-based testing is coordinated through **labgrid** +(coordinator `ai-legion:20408`). Do not drive lab hardware ad hoc: reserve +through places (`labgrid-client -p acquire` … `release`), and treat +a place held by someone else as theirs. Note some hosts carry a stale +`LG_COORDINATOR` pointing at a dead address — use the hostname form: + +```bash +export LG_COORDINATOR=ai-legion:20408 +labgrid-client places # inventory + comments say what each seat is +labgrid-client who # current holders +``` + +The lab's single source of truth is the private +**`Amperstrand/conwrt-bench`** repo (ADR-0005): `registry/` for devices, +`labgrid/` for place seeds and examples, `docs/decisions/` for the why, +sops for secrets. `conwrt-lab` is retired — do not add data there. Rules +that every hardware-touching change follows: + +1. Never hardcode device IPs, MACs or serial paths — resolve from the + registry or a labgrid place. +2. Access hardware through labgrid places (`ssh|console|power`), with + acquire/release for exclusivity during a test. +3. Flashing and adoption go through conwrt tooling + (`dut_recover.py --from-lab`, `bench_net.py`), which updates the + registry. +4. A state change ends with a registry commit — flashed, moved or + adopted devices must be reflected before you walk away. +5. When surprised, reconcile first + (`lab_registry.py reconcile`) before touching anything. + +VM lane: `labgrid/qemu-x86-64.yaml.example` in conwrt-bench is the +pattern — the client runs ON ai-legion (QEMUDriver executes where the +client runs), pristine per-acquire boots via `snapshot=on`. The on-target +package harness is `tests/happy-path/run.sh --artifact `; +the artifact is the published bytes from a kind-`1063` event +(hash-pinned via its `x` tag) or, for pre-tag candidates, a local +`scripts/build-sdk-package.sh` build whose sha256 is recorded in the +evidence. + ## Contributing process Follow [CONTRIBUTING.md](CONTRIBUTING.md). The parts agents most often diff --git a/CHANGELOG.md b/CHANGELOG.md index e490fe7ae..8cd94e1c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,398 @@ and [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [v0.6.0-rc1] - 2026-10-05 +### Added + +- **The gateway now serves time to its own clients, before authentication + (#627).** A client on the open portal joins with whatever clock it boots + with — the ws3915i fleet sat months off — and a wrong clock cannot pay + honestly: Cashu proofs carry timestamps, keysets expire, sessions are + time-boxed, and a downstream TollGate in reseller mode needs accurate time + before it can pay *us*. `setup_ntp_server` enables busybox sysntpd's + listener (`system.ntp.enable_server='1'`, idempotently, creating the + section when the image shipped none), the pre-auth allow list gains + `allow udp port 123` beside the portal and the payment API, and a RUNNING + router gets exactly one cheap restart of the stateless UDP responder — + never a fresh boot, where procd starts sysntpd after uci-defaults with the + config already committed. Bench-side verified on the fleet bring-up + (79b1 answers NTP on the portal face; clients sync from the gateway). + ([#648](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/648)) + +### Fixed + +- **A payment whose gate cannot open is an owed entitlement, not a lost + one (#403).** A successful `Receive` puts the customer's value in the + operator's wallet irreversibly; when `ndsctl auth` then fails, the old + path rolled back the in-memory session and answered a bare + `session-error` — the operator kept the value and the customer had + neither service nor a recoverable claim. The paid purchase is now + recorded as an owed entitlement in a durable, atomically-written, + fsync'd store (`owed-grants.json`, alongside the Lightning quote store) + **before** the response completes, keyed by the receive reference the + customer can already quote; a per-record monitor retries the grant with + backoff and jitter until it succeeds or its window passes (milliseconds + grants expire when their paid time is gone, data grants after 24 h — + both converge to a loud terminal `expired` state with the record kept + for audit, never an infinite retry). A restart reloads the store and + relaunches the monitors, so an unresolved entitlement survives the + process; the grant itself is applied exactly once (in-memory + processing flag plus a persisted `granted` transition), and the + customer is told their access will start automatically and that they do + NOT need to pay again (`payment-received-grant-pending`). The + Lightning-side sibling of this contract (`ErrAccessGrantNotApplied`, + the quote monitor) is unchanged; both now converge through the same + store discipline. Known limit, honestly: refunding (returning the + value) remains out of scope — an expired entitlement is an operator + action, not an automatic refund. + ([#403](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/403), + [#502](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/502)) + +- **An ambiguous Cashu outcome is never retried with the same derivation + outputs, and the customer is told so.** The wallet client re-sent an + identical money-moving POST body up to four more times on a network error — + so a mint that processed a swap and whose response was dropped (timeout, + connection reset, EOF) received the same blinded messages again: the + deterministic-derivation re-exposure that strict mints answer with error + 10002 and that has repeatedly bricked wallets (#257/#266/#480), measured on + main by the #535 conformance lane as the `swap-timeout-retry` row (#640); + the lab mint's duplicate tolerance is why payments kept working while the + invariant was violated. Fixed in `gonuts-tollgate` + v0.13.0 ([fork PR #35](https://github.com/OpenTollGate/gonuts-tollgate/pull/35): + network errors return `*AmbiguousOutcomeError` immediately; the 429 + same-body retry is kept, as a rate-limit answer precedes processing; + checkstate keeps its read-only transport retry as the reconciliation + primitive), repinned here. This repo + gains the release-gate pins: a full-wallet fault test whose fake mint + processes the swap, drops the response, and must sight every blinded output + exactly once (it fails on v0.12.1, passes on v0.13.0), a wallet-usable-after-recovery test, + and the customer-facing classification — an unanswered swap surfaces as + `payment-outcome-unknown` with the do-not-resend guidance and the + operator-quotable reference instead of a retry-flavoured error, and it no + longer condemns the mint in the health tracker (the mint may be healthy and + processing). + ([#640](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/640)) + +- **Lightning top-ups work against deployed cdk mints again — NUT-20 mint + quotes are signed with the message format the deployed ecosystem verifies.** + The wallet fork signed the domain-separated `Cashu_MintQuoteSig_v1` framing; + no deployed cdk mint verifies that form (cashubtc/cdk's + `MintRequest::msg_to_sign` is the plain `quote_id || B_ hex` concatenation), + so every quote → paid → mint top-up failed with "Signature missing or + invalid". gonuts-tollgate now signs the concatenation, and the construction + is pinned to cdk's own cross-implementation test vector (their exact quote + id, five outputs, and valid signature pair) so a framing change on either + side breaks the test that matters. Lands via the fork tag `v0.13.0` + ([gonuts-tollgate#34](https://github.com/OpenTollGate/gonuts-tollgate/pull/34)). +- **A swap whose response is dropped no longer re-sends the same derivation + outputs — ambiguous mint outcomes surface for reconciliation instead of + being blind-retried.** The wallet client re-POSTed the identical body on any + transport error, which re-exposed blinded messages the mint may already have + signed — the #257/#266/#480 brick class, measured live by the #535 + conformance lane (#640). State-changing POSTs are now single-shot when no + answer arrives (returning an explicit `AmbiguousOutcomeError`), a 429 answer + keeps its backoff retry, and checkstate — the reconciliation primitive — + keeps its full retry. This module's `isAmbiguousMintOutcomeError` matches the + new error positively, so the outcome-unknown notice and the late-receive + recorder label the no-answer case exactly. Verified end-to-end on the tagged + fork: the lane's `swap-timeout-retry / no-output-reuse` flips **fail → + pass**; the scenario's remaining `service-or-refund` red is the tracked + #403/#258 refund-vs-late-grant window, deliberately not masked. Lands via + the fork tag `v0.13.0` + ([gonuts-tollgate#35](https://github.com/OpenTollGate/gonuts-tollgate/pull/35), + fixes [#640](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/640)). +- **The module is no longer a second `:8090` writer, and the tree carries no + re-brand literal.** Two defects, one root: a brand-gated legacy configUI + writer in `99-tollgate-setup` created its OWN `uhttpd` section on `:8090` + (home = a branded webroot) whenever a brand file and a branded docroot were + present — a second claimant of the port the portal-staged board + (`uhttpd.admin`, written by the feed's `92-tollgate-admin-setup`, staged by + `packaging/portal-build.sh`) already owns, i.e. a bind fight in which one of + the two admin UIs disappears (`default-ui-and-entry-port-decision.md`, D4); + and the brand was a hard-coded literal in the module, in the docs and in the + tests, so upstream carried a commercial brand's name. The legacy writer and + the brand whitelist are GONE: `load_brand` accepts any single alphanumeric + token from `/etc/tollgate/brand` (default `tollgate`, display spelling + derived, no table), the machine-name recognisers (`code_from_name`, + `captive_ssid_for_code`) match the default prefix or THIS router's own brand + token instead of a hard-coded list, and `setup_hostname` compares against the + build's own `BRAND_HOSTNAME`. A router upgrading from a build that carried the + legacy writer CONVERGES: `purge_foreign_configui_sections` DELETES any + `uhttpd` section that is not one this module owns (`main`, `portal`, + `trusted`, `admin`) — not merely its `:8090` listeners, which the feed's `92` + already strips, leaving a listener-less branded instance behind — on BOTH + setup paths, so a same-version reinstall repairs it too. `sanitize_uhttpd_main_configui_port` + still strips a stray `:8090` from LuCI's instance. Net contract after an + install, an upgrade, a reinstall-over-marker or a legacy-left-behind box: + exactly ONE section owns `:8090` (`uhttpd.admin`, home `/www/tollgate`), LuCI + stays alone on `:8080` (home `/www`), and no build product is claimed or + renamed on the portal's side. Supersedes the module-side writer added in + [#451](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/451) for + the default build: the board is the portal bundle's, and a re-brand ships its + own webroot from its own organisation. Pinned RED-first by + `tests/packaging/configui-8090-single-owner_test.sh` (the four scenarios plus + detector controls) and `tests/packaging/rebrand-literal-gutter_test.sh` (a + gutter that fails if any re-brand literal reappears anywhere in the tracked + tree, with a planted-occurrence control). No history rewrite: the literal + survives in old commits, by design. + ([#649](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/649)) +- **The TLS review of #593, closed: the derived hop is committed before the + reload, the operator's own identity survives an install, and the postinst runs + the order the boot path uses.** Three defects in the change that made setup + provision a router's TLS identity. (1) `tollgate ssl apply` reloaded uhttpd and + only then derived `uhttpd.main.redirect_https`, so the running server was handed + the PREVIOUS value while the command printed `redirect_https=1 (... covers this + router)` — the same defect class as the setup path the rule exists for; the + removal paths already ordered it correctly and the apply paths now match. (2) + Provisioning was idempotent on this module's own output path + (`/etc/tollgate/ssl/server.crt`) instead of on the certificate `uhttpd.main` + actually presents, so a router whose administrator had installed a CA-signed + certificate — or run `tollgate ssl apply ` — had it replaced by a + fresh self-signed identity on the next install: a trusted certificate silently + downgraded, a covering identity re-keyed behind its owner's back. Provisioning + now skips when `tollgate ssl covers "$(uci -q get uhttpd.main.cert)"` is true, + and the identity selection prefers the first candidate that COVERS this router + (configured → provisioned → the image's pair), so the certificate the operator + chose is the one uhttpd keeps serving. (3) `ssl covers` printed its refusal + twice — cobra prints the returned error and `main()` printed it again — which + makes one refusal indistinguishable from two failures in a log; + `SilenceErrors` on the root command leaves the single line (measured on the + built binary: 2 → 1). Regressions pinned by + `TestSSLApplyDerivesTheRedirectBeforeItReloadsUhttpd` (the uhttpd init stub + records what the running service would have read at reload time), + `TestSSLCoversPrintsItsRefusalOnce`, and a new section E of + `tests/uci-defaults-admin-tls-identity_test.sh` — all of them RED on the parent + commit, and the suite can be pointed at another copy of the script with + `TOLLGATE_SETUP_SCRIPT` so that RED stays reproducible. Separately, + `packaging/Makefile`'s postinst now runs the uci-defaults in the numeric order + the boot path uses (`90, 92, 99`, not `90, 99, 92`): the LAST writer of + `uhttpd.main.redirect_https` is the same on the install pass as at boot, so an + install converges to the state the next reboot produces whatever either writer + decides, instead of to a state a reboot silently changes. Pinned by + `tests/packaging/uci-defaults-run-order_test.sh`, with the pre-change order as + its negative control. The feed repository's vendored `92-tollgate-admin-setup` + still decides that option from the readability of `/etc/uhttpd.crt`, and its + recipe still runs `92` last, so the operator-visible defect stands on a + feed-installed router until that repository carries the same premise — recorded + in `docs/architecture/uhttpd-redirect-https-ownership-decision.md`, not fixed + from here. `docs/rc-tester-guide.md` also now leads with `https:///`, + the address that is in the certificate's SANs and needs no resolver, with the + `.lan` alias as the alternative. + ([#612](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/612)) + + +- **`MintConfig` unmarshalling accepts the legacy `min_purchase_steps` + spelling and floors `MinPurchaseSteps` at 1 (#104, ported from the + disposition train).** A purchase of fewer than one step is meaningless + and clients (cashud, wally) reject advertisements with `min_steps=0`; + early FreedomTechFeed configs carried the legacy key, which parsed as + absent and left the field 0. The parser now accepts both spellings + (primary wins), defaults absent/0 to 1, and is pinned by a 6-case table. + The shipped mint templates also carry literal 1s, and the edit path has + kept its schema floor (`Min: 1`). The wire-spec side of the default + (TIP-02's tentative `default 0`) is tracked upstream in the spec repo + (OpenTollGate/tollgate#20) with a proposal to de-tentative to 1 — this + implementation already matches the field evidence that proposal cites. + ([#634](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/634), + [fork #104](https://github.com/Amperstrand/tollgate-module-basic-go/pull/104)) + +- **The atomic config write falls back to a durable in-place write when + the inode is pinned.** A single-file bind mount (the conformance lab, + containerized deploys) cannot have its config renamed over — the + #402-hardened `SaveConfig` answered EBUSY there and brought the lab + daemon down at config migration. Where rename cannot serve, the save + falls back to `O_TRUNC`+write+fsync on the mounted file: the pre-#402 + guarantee, strictly better than refusing to save. + +- **A wedged `fw4` can no longer stall- **A wedged `fw4` can no longer stall the daemon's start path (#637).** + The operator-settings convergence (which runs after the API listener + binds but before `Serve`) called `fw4 reload` with no deadline, so a + wedged firewall reload on exactly the boots where drift exists (first + boot after an upgrade with hand-edited settings, a sysupgrade that + regenerated UCI) stalled the service — and procd respawned it into the + same stall, keeping the payment API down on an unattended router. + `fw4 reload` now runs under a 30-second `CommandContext`; a timeout is + treated exactly like any other reload failure (the fragment file is the + durable half and applies at the next firewall reload or reboot), pinned + by a test with a fw4 that never answers. + ([#637](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/637)) + +- **`config.json` writes are atomic, and a missing config is announced + instead of silently becoming factory defaults (#402 hardening).** A + plain `os.WriteFile` killed mid-write (power loss, a procd respawn in + the write window) left a truncated file that the loader routed into + backup-and-defaults — the operator's accepted mints silently reverting + to the factory set, the config-loss class of the #402 incident. + `SaveConfig` now writes temp + rename in the same directory (a reader + always sees the whole old or the whole new file), and the + file-does-not-exist path — the one default-write path with zero + forensics — logs a loud WARNING naming the backup directory. The full + #402 incident did not reproduce on current main; this closes the class + it came from. + ([#402](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/402)) + +- **A concurrent duplicate of one e-cash note is refused before the mint, not + raced past its spend-state.** The mint's own "already spent" refusal is the + payment path's only duplicate guard, and two concurrent POSTs of the same + note both pass it before either swap settles: measured by the #535 + conformance lane (2026-10-05) as both POSTs answering a kind-1022 session + with a 2x allotment delta and four derivation digests each sighted twice — + one note, two sessions (#639). `PurchaseSession` now marks the note in + flight (keyed by the salted fingerprint already given to the customer as the + outcome-unknown reference) from the moment the money-moving call starts + until its result is consumed — on the outcome-unknown timeout path the late + recorder owns the mark, so a resubmission arriving after the deadline but + before the mint answers is refused exactly like a concurrent one, which is + what the "do not send this note again" notice already promises. The refusal + (`payment-duplicate-inflight`) moves no money and reaches no mint; a note + whose fingerprint cannot be computed is never refused. + ([#641](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/641)) +- **`generate_admin_password()` no longer depends on `od`, which is absent from + the stripped busybox shipped on OpenWrt 25.12.5 base images.** On those + images the old `od -An -N 20 -tu1 /dev/urandom` pipeline produced no output, + so the function returned an empty string, `set_admin_password()` became a + no-op, and the postinst correctly refused to serve the :8090/:8443 admin + board ("root has no usable password"). The generator now uses `hexdump`, + which is present on the same images and already used elsewhere in the setup + script (`mint_device_code`, `random_octet`). The alphabet, length, and + uniform byte-to-character mapping via modulo-32 are unchanged; the password + is still applied through stdin (`printf ... | passwd root`) and never + reaches argv. A hermetic test that shadows `od` with a failing shim is now + part of `tests/packaging/admin-board-requires-credential_test.sh`. + ([#624](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/624)) + +- **The backend API (`:2121`) now answers the operator's private bridge + (`br-private`), not only the captive one — on the one network the admin board + is reachable from, every panel of that board was dead.** The board is kept off + the captive bridge (`31-admin-board-not-guest-reachable.nft`), so `br-private` + is where it is administered, and the board is a thin shell whose data layer + reads pricing, `whoami`, session balance and `ln-invoice` from + `http://:2121/`. `30-backend-firewall.nft` exempted only `br-lan` and + `lo`, so the page rendered while every fetch failed + (`error fetching tollgate data: TypeError: NetworkError` / `lightning + capability probe failed`) and retried forever — measured on a freshly flashed + GL-MT3000 carrying `alpha4-pre21`. The exemption set now names both LAN + bridges; `br-lan` keeps its access because the portal SPA pays through the + same API. New offline test: + `tests/packaging/backend-api-owner-network_test.sh`. + ([#638](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/638)) + +### Changed / Internal + +- **The discovery-signaling decision is recorded: v0.6 ships SSID-prefix + recognition; the richer tiers are documented and deferred.** + `docs/architecture/discovery-signaling-decision.md` separates the candidate + *signal* from *verification*: the SSID prefix (`TollGate-`/`Net4sats-`, + case-insensitive — #618) only selects who to ask, and the signed kind-10021 + advertisement on `:2121` decides — a cloned beacon spoofs nothing but a + wasted probe. 802.11u/ANQP (Passpoint) and vendor-IE beacon stuffing are + researched with their prior art and deferred past v0.6 with explicit reopen + conditions (pre-association price in the WiFi picker, rename-surviving + discovery, a registered OI), and BSSID signaling and beaconed npubs stay + rejected with the bench-measured evidence, so none of it gets re-derived. + ([#621](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/621)) +- **Linux host mode's admin surface is formally deferred to phase 2.** A new + decision record pins the outcome so it is not re-litigated: phase 1 ships + no admin SPA, no admin listener, and no admin port — the `tollgate` CLI is + the only operator interface — and any future admin API must satisfy a + minimal contract (four verbs mapped to existing seams, AF_UNIX + file-permission auth, loopback-only) before it is built. See + [docs/host-mode/admin-surface-decision.md](docs/host-mode/admin-surface-decision.md). +- **The wired LAN ports move onto `br-private`: a cabled client is an + owner-class client with internet, the admin board and LuCI, and no payment + step.** The base image puts the physical LAN ports on the *captive* bridge + (`br-lan`), so a cable was a customer port — it paid at the portal, and the + two administration guards (`31-admin-board-not-guest-reachable.nft`, + `32-luci-not-guest-reachable.nft`, both `iifname "br-lan"`-literal) dropped + the admin board (`:8090`/`:8443`) and LuCI (`:8080`/`:443`) for it exactly + as they do for a stranger on the open guest SSID. A new + `setup_lan_ports_private` writer in `99-tollgate-setup` now **moves** the + port list the base image writes on the captive bridge onto `br-private` — + the operator's own trusted network, whose zone already forwards to the + `wan` and whose clients the admin guards do not drop. The trust change is + deliberate and not hidden: `br-private` is ungated, so a cable-connected + client gets internet and root-capable admin surfaces without paying. The + ports are **discovered, never named** (`eth1` on the MT3000, `lan1…lan5` + elsewhere — the writer moves what the bridge's device section lists), the + move is **idempotent and convergent** (re-asserted on the same-version + verify/repair path, so a factory reset or `sysupgrade -n` that puts the + ports back on the captive bridge is repaired; `/etc/config/network` is + already on the module's keep-list, so a settings-keeping upgrade carries + the placement), and a port never sits on two bridges (the captive section's + list is cleared after the private bridge's is written). Nothing else moves: + the public `TollGate-*` SSIDs stay on the captive bridge behind the portal, + `nodogsplash` stays pinned to `br-lan`, and all four guard fragments + (`20-nds-enforce.nft`, `30-backend-firewall.nft`, `31-*.nft`, `32-*.nft`) + are untouched and still `br-lan`-scoped — pinned byte-identical to `main` + by the new `tests/uci-defaults-lan-private-wired_test.sh`, which also pins + the move, the discovery, the idempotence and the upgrade repair. This is + the minimal release path; the role machinery (`tollgate.lan_ports.role`, + `br-mgmt`) stays in #607 for after the release. + +- **The wired-LAN bridge record no longer overstates the blocker: the + operator's requirement *is* satisfiable by re-keying the admin-port guards.** + The amendment to `docs/architecture/lan-port-management-bridge-decision.md` + records the operator-approved mechanism — key the two admin-port drops on the + guest VAP interfaces instead of the bridge name, so the wired port stops + matching (the administration surfaces answer from the cable) while the + wireless guests keep being dropped and the wired port stays on the gated + `br-lan`, still redirected, still paying — together with the measured + constraint that makes it a port-keyed `bridge`-family rule rather than a + string swap (in an `inet`-family hook `iifname` is the bridge, so the swap + would match nothing and turn both guards into silent no-ops), and the + fail-closed derivation the unstable VAP names need. The nodogsplash + single-gate analysis is unchanged and now explicitly scoped to a second + *gated* bridge; `br-mgmt` stays proposed on its own merit, its Status stays + `Proposed`, and the drifted `99-tollgate-setup` citation is corrected to + `:1228-1247`. Docs only: no code, no packaging, no firewall change. + ([#623](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/623)) + +- **The merchant test log capture is concurrency-safe.** `captureMerchantLog` + handed tests a bare `bytes.Buffer` behind the swapped standard logger, so a + `MintHealthTracker` probe goroutine still winding down from an earlier test + raced a later test's read of the capture — the intermittent "race detected + during execution of test" that turned the battery red on + `TestStartDataUsageMonitoringStopsTheSweepItStarts` during the #619 review. + The capture now returns the package's existing synchronised `syncLogs` type + (plus a locked `Len()`), pinned by a test that reproduced the race under + `-race` before the fix + ([#622](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/622)). + +- **The physical/lab router suite now runs as a CI job.** A new `router-test` + workflow routes through the elected router-bench gateway: pull requests reach + the isolated QEMU lab only, and only post-merge `main` runs can touch the + physical bench, behind the `bench-hardware` environment. Third-party PRs run + without the gateway credentials and skip the job + ([#614](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/614)). + ### Fixed +- **A wedged mint no longer stalls the payment lane for minutes.** The swap-fee + precheck now runs under a 3-second budget: against a mint that accepts + nothing (`docker pause` reproduces it), the wallet client's retry ladder + (30 s per attempt, up to five, chained endpoints) parked the precheck for + 5+ minutes before any deadline applied — the customer's request hung and + each attempt stacked a stuck goroutine, while the token itself stayed + unspent. Past the budget the payment proceeds and Receive's own error + classification (or its receive timeout) governs — the fee check is an + optimization, not a gate. Fixes + [#525](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/525). + ([#533](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/533)) +- **“A client NoDogSplash does not know” now requires the answer to name the + MAC the module asked about.** The zombie-session fix (#595) correctly read + `ndsctl deauth`’s `Client not found.` / rc=1 as a *completed* close — + but its matcher also accepted any “not found” answer containing the word + `client`, and `ndsctl` is only ever asked about **one** MAC. An answer naming + a different MAC, or none at all, is not evidence about the client being + closed, so on that answer the gate was retired (the close reported COMPLETE) + while the client it actually asked about could still be `Authenticated` with + an open gate — the fail-open direction of the very defect the fix closed. The + MAC is now the whole of the match. Found by the third review round on #595 and + carried over as its own change; the measured terminal answer (which names the + MAC) and every failure that carries no client evidence behave exactly as + before — `Socket is not ready for communication : Bad file descriptor` and + `Could not connect to server` are still unconfirmed failures. + - **The repair path's portal banner is now committed, and both setup paths write one identical value.** The verify/repair path a same-version reinstall takes converged `nodogsplash.gatewayname` on a second spelling — `"$GATEWAY_NAME"`, @@ -170,6 +560,67 @@ and [Semantic Versioning](https://semver.org/). ### Changed / Internal +- **Cudy WR3000 v1 documented as a covered target, with its 16 MB-flash limit + stated up front.** The package matrix already builds for + `mediatek/filogic` / `aarch64_cortex-a53`, which is what the WR3000 v1 + (board name `cudy,wr3000-v1`) reports, so this is a documentation change + only — no matrix row was added or removed. + [README.md](README.md) gains a "Supported devices" subsection under + Installation that says how coverage is decided (the target/architecture rows + of the CI + [build matrix](.github/workflows/build-package.yml)) and records the + on-hardware result: on mainline OpenWrt 25.12.5 (`r33051-f5dae5ece4`) the + whole 37-package dependency closure installs and `nodogsplash` runs with the + keepalive contract live. It also states the 16 MB-flash limit and — measured + on the same device — the variant that works around it: the `upx-ultra-brute` + build this repo's CI already produces for `aarch64_cortex-a53` shrinks the + payload from ~20 MB uncompressed (`usr/bin/tollgate-wrt` 12,361,280 B plus + `usr/bin/tollgate` 7,373,632 B) / ~8.5 MB compressed to **5.34 MiB**, and a + real WR3000 v1 installed that `.apk`, rebooted, and came back with + `tollgate-wrt` running and no volatile helper, so a persistent install is + possible on a 16 MB device. The two practical notes from that run are + recorded too: the 1.78 MiB `tollgate` CLI can be dropped after provisioning + to leave room for the `nodogsplash` closure, and the closure must be installed + in one `apk add` transaction because `apk add --force-non-repository ` + world-syncs packages previously installed from files back out. A volatile + (tmpfs) install is documented as the fallback. + ([#613](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/613)) +- **COMFAST CF-WR632AX documented as a covered target, with the OpenWrt + ≥25.12.5 requirement and the absence of a hardware result stated up front.** + The CF-WR632AX (MediaTek MT7981-class SoC) reports the same + `mediatek/filogic` / `aarch64_cortex-a53` target and `DISTRIB_ARCH` as the + Cudy WR3000 v1 above, so the CI + [build matrix](.github/workflows/build-package.yml) already covers it and no + row was added or removed. OpenWrt has supported it since 25.12.0 (device page + [openwrt.org/toh/comfast/cf-wr632ax](https://openwrt.org/toh/comfast/cf-wr632ax)), + and its 128 MiB of SPI NAND means it has **no** flash-capacity caveat — the + default build fits, so the `upx-ultra-brute` variant the 16 MB WR3000 needs + is not required here. The "Supported devices" subsection in + [README.md](README.md) gains a paragraph recording that, the ≥25.12.5 + requirement for the OpenWrt U-Boot layout (a memory-speed stability issue in + 25.12.0–25.12.4, fixed by upstream PRs + [#22929](https://github.com/openwrt/openwrt/pull/22929) / + [#23416](https://github.com/openwrt/openwrt/pull/23416)), and that the device + has **not yet been exercised on real hardware** — no unit is in hand, so this + entry rests on upstream OpenWrt support and the shared target/architecture + row, not on a measured result. + ([#616](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/616)) + +- **ngit stage 1 stamps a commit-derived `SOURCE_DATE_EPOCH`, never the runner + clock.** The `determine-versioning` job's epoch cascade ended in `date +%s` + and, because an ngit-ci `act` checkout has no git history and the + coordinator's push payload carries `head_commit` with no timestamp, that last + resort was taken on **every** run (observed at `ca5d07a2`: `source: job + clock`) — so the Go binaries embedded a wall-clock `BuildTime` and two builds + of one commit could not produce the same bytes, contradicting the + reproducibility pin from #383. The job now calls the same + `scripts/ngit-commit-epoch.sh` the `build-portal` job and the shards' + `resolve-inputs` already use (the commit's own timestamp, from local history + or a depth-1 fetch from the mirror the release is built from) and fails + loudly when neither source answers, instead of silently publishing artifacts + that cannot be rebuilt identically. See + [docs/reproducible-builds.md](docs/reproducible-builds.md) + ([#529](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/529)). - **The board is the default face — decided, with the switch, the cross-link rules, and the reason it is not a one-repo change.** `docs/architecture/default-ui-and-entry-port-decision.md` records the operator @@ -351,11 +802,12 @@ and [Semantic Versioning](https://semver.org/). last — which is why "the other script also writes it" is a live hazard rather than a style note: a writer with the superseded existence-only premise derives `1` for an identity no browser can validate, after the coverage rule derived - `0`. The portal repo's copy of `92` is being aligned to the same rule and now - carries a cross-repo guard over the pair; the pins (this module's - `packaging/build-inputs.json .portal.commit`, the feed's `vendor.lock.json`) - still have to advance for that to reach a router, which the document states - explicitly. + `0`. The portal repo's copy of `92` was aligned to the same rule and carries a + cross-repo guard over the pair, and the module's own pin has since advanced + (`packaging/build-inputs.json .portal.commit` → `4158030`, #609), so this + module's package ships that coverage-checked `92`; the feed's + `vendor.lock.json` still has to advance for it to reach a feed-installed + router, which the document states explicitly. ([#594](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/594)) - **The valve's timeout test asserts the timeout contract, not the host's @@ -410,6 +862,41 @@ and [Semantic Versioning](https://semver.org/). writer that runs LAST on a deployed router) shares this store, this adoption order and this test case table. ([#605](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/605)) +- **The private network's credentials and the administration-path scope are + operator settings, in the config file and on the board.** Two things this + module compiled in are now declared in `/etc/tollgate/config.json`: + `private_ssid`/`private_key`/`private_encryption` (the private SSID's name, + passphrase and encryption mode — the encryption was a literal in + `99-tollgate-setup` that every full setup pass rewrote) and `admin_access` + (`both` | `br-private` | `br-mgmt` | `loopback-only` — which network may + reach the board on `:8090/:8443` and LuCI on `:8080/:443`, where the answer + used to be "whatever is not the captive bridge"). Neither can be honoured by + a file the service merely reads, so one applier + (`src/cli/operator_settings.go`) converges them: UCI `/etc/config/wireless` + for the credentials, and a generated, module-owned + `/etc/nftables.d/34-admin-access-scope.nft` for the scope. It is + **compare-and-converge** and runs after every `config set`/`config save`, on + the new `tollgate config apply`, and at service start, so a hand-edited + `config.json` converges without a second command and a router that already + matches reports `unchanged` without a `fw4 reload` or a wireless bounce. + Defaults are no-ops on the wire: `admin_access=both` writes no fragment (and + removes a stale one), and the SSID/passphrase ship empty, which means "keep + what the router has" — the values `99-tollgate-setup` minted. `br-mgmt` is + refused while that bridge does not exist, because it would drop the private + SSID and leave no network able to reach the board. **The passphrase is + write-only**: the schema marks it `secret`, `config get` blanks it and + reports `secret_set.private_key` instead, `config set` does not echo it, and + a wholesale `config save` of the (blanked) payload the board sends preserves + the stored value instead of clearing it. The guest network the customers pay + on is never an administration path, whatever `admin_access` says. New CLI: + `tollgate config apply` and + `tollgate network private set-encryption `; the three private-network + commands now write **both** radios through one helper, refuse a + non-existent section, and record the value in `config.json` so the two + writers cannot disagree. Decision record: + `docs/architecture/lan-port-management-bridge-decision.md` D9-D12. + ([#604](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/604)) + - **`GET /session-state?mac=…` reports a machine-readable session state per client MAC — `none`, `active` or `expired`.** `/usage` answers `-1/-1` for a device that has never paid *and* for one whose paid session just ran out, so a @@ -450,8 +937,39 @@ and [Semantic Versioning](https://semver.org/). seen failing is decoration), names in its header the ids it cannot break offline, and runs in CI. +- **The happy-path harness now covers the SECOND purchase — the club's main + loop.** `tests/router-happy-path` could only ever buy once, so nothing in it + could see the failure the operator hit on real hardware (pre17 on an MT3000): + after the first allotment was spent, a second purchase restored the balance and + the gate stayed shut, with no OS captive-portal prompt either. `--second-purchase` + (or `RHP_SECOND_PURCHASE=1` + `RHP_CASHU_TOKEN_2`) buys a second time for the + SAME client and then asserts the GATE rather than the balance: `paid2:*` ends + with an HTTP request through the customer's own data path + (`RHP_EGRESS_PROBE_URL`, default the Android 204 probe) that must answer 200/204 + with no redirect, naming the two failure shapes instead of collapsing them into + "no internet" — a `307` to the splash (still intercepted) and no answer at all + (neither redirected nor served). On the failing box `paid2:balance-restored` was + green while `paid2:gate-open` was red, which is the distinction a balance-only + suite cannot make. The lane refuses to run on a live session (a renewal is not a + re-purchase), keeps the same spend-ceiling and sentinel-MAC guards as the first + purchase, and the self-test drives both outcomes offline on a fixture token + (42 cases / 83 check ids, 53 driven red). + ### Fixed +- **The paid lane's token inspection was off by one, so the lane could never + spend anything.** `tests/router-happy-path/lib/cashtoken.py` read the NUT-00 + version character at `token[6]` — the first character of the *payload* — and + sliced the payload at `token[7:]`, so a real `cashuB` token was reported as + `unknown Cashu token version character 'o'` and `paid:token-inspected` failed + before any purchase could be attempted. The lane had been dead since it merged, + and the default run's SKIP is why nothing caught it; it was found on the lane's + first hardware run (a 64-sat testnut token, pre17 on the bench MT3000). The + decode is now `token[5]` / `token[6:]`, and `selftest/cashtoken_selftest.py` + pins the v3 path, the v4 path, the malformed-version path (by the character at + index 5, so the offset itself is pinned) and the missing-prefix path; the + self-test also drives both purchase lanes offline on a non-redeemable fixture + token, which is the control whose absence let this through. - **The startup mint probe no longer walks every accepted mint to its own timeout before the process can do anything else.** `merchant.New()` ran the startup probe to completion before `main()` ever reached @@ -732,6 +1250,18 @@ and [Semantic Versioning](https://semver.org/). loudly; and the self-test's own coverage claim is now measured rather than asserted (31 cases, 49 of 74 live check ids driven red, the 25 that cannot be are listed in the header and the README). +- **Token fingerprints no longer silently change after a restart on some + installs.** The fingerprint salt was persisted as raw random bytes but read + back with whitespace trimming — and random bytes legitimately begin or end + with whitespace-valued bytes (tab, space, CR/LF) about 5% of the time, so on + those installs every restart produced a new salt and the log/journal + correlation keyed on fingerprints silently broke, with no error anywhere. + The salt is now persisted hex-encoded (whitespace-trimmed on read so + hand-edited files keep working), with a raw-bytes fallback for salts written + by earlier builds. Found by the race-enabled go-battery going intermittent + on main; the regression test forces whitespace-valued edge bytes through + write→reload and pins the fingerprint + ([#571](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/571)). - **The captive portal's Lightning lane can sell time again: the module canonicalises the mint URL a client sends before using it as a lookup key.** @@ -1054,8 +1584,20 @@ and [Semantic Versioning](https://semver.org/). sim, happy-path) and names the cloud-lab as the default for logic-level work. ([#570](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/570)) - - +- **A MAC rotation no longer restarts the byte meter or extends paid time.** + Sessions are addressed by a server-signed, memory-only ticket carrying only a + session handle (no allotment, no metric, no MAC), issued and verified by the + module and moved to the client's new address by `POST /session/rebind`. The + session carries the bytes it consumed on the attachments it has already left + (`CustomerSession.Consumed`), so the meter is `consumed + the current + attachment's own baseline` rather than a fresh allotment per rotation, and + `StartTime` is copied verbatim because a rotation is not a renewal. A rebind + is refused (409 `attachment-active`) while the old address is still + authenticated, so one session is never delivered to two live clients. Proof of + possession (Tier 2) is decided in `docs/architecture/session-ticket-decision.md` + but not implemented, so a copy presented after the legitimate device has left + is still accepted — the ADR states that gap explicitly + ([#573](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/573)). - **The module can report the gates it believes it holds, so the ones nothing else examines are reachable.** `valve.TrackedGates()` returns a sorted, read-only view of the gate bookkeeping — a gate whose close is unconfirmed is @@ -1181,6 +1723,15 @@ and [Semantic Versioning](https://semver.org/). channel protocol exists (R4-R6) ([#572](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/572)). +- **`packaging/local-build-ipk.sh` refuses to build without staged portal + bundles.** The guest SPA, admin SPA and rpcd plugin are build products + staged by `make portal-build`; a clean checkout holds none of them + (#335), and the script used to copy whatever was present — silently + producing a local `.ipk` whose captive portal renders nothing. It now + exits with a `make portal-build` instruction before any toolchain work + when the staged files are missing (pinned by + `tests/packaging/local-build-ipk-guard_test.sh`) + ([#556](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/556)). - **`getMacAddress`'s two lookup sources are package-level vars, so `/balance`'s session-bearing branch has unit coverage again.** The DHCP-lease and ARP paths were string literals, so off-router every `/balance` test landed @@ -1581,7 +2132,7 @@ same-version short branch. - **Upgrades move the hostname with the brand, and keep custom ones.** The upgrade path now migrates the system hostname together with the - whitelabel brand (`tollgate.lan` / `net4sats.lan`) instead of leaving a + whitelabel brand (`tollgate.lan` / `.lan`) instead of leaving a stale name behind, while a hostname the operator chose themselves is preserved untouched ([#444](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/444)). @@ -1930,10 +2481,10 @@ same-version short branch. `ERR_TOO_MANY_REDIRECTS` and never reach the payment page. The friendly `.lan` name keeps resolving without the option — dnsmasq serves the system hostname in the `lan` zone. The - whitelabel hostname is now brand-selected (`tollgate`, the default, or - `net4sats`) from a single `/etc/tollgate/brand` file, driving the - system hostname (and thus `tollgate.lan` / `net4sats.lan` DNS), AP - SSIDs, and the NDS gateway name; the two brands differ only in + whitelabel hostname is now brand-selected (`tollgate` by default, or the + whitelabel `brand` value) from a single `/etc/tollgate/brand` file, driving the + system hostname (and thus `tollgate.lan` / `.lan` DNS), AP + SSIDs, and the NDS gateway name; the brands differ only in naming. `tollgate ssl` no longer sets the option either and cleans it up on revert. ([#432](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/432), fixes [#428](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/428)) @@ -1966,10 +2517,10 @@ same-version short branch. `ERR_TOO_MANY_REDIRECTS` and never reach the payment page. The friendly `.lan` name keeps resolving without the option — dnsmasq serves the system hostname in the `lan` zone. The - whitelabel hostname is now brand-selected (`tollgate`, the default, or - `net4sats`) from a single `/etc/tollgate/brand` file, driving the - system hostname (and thus `tollgate.lan` / `net4sats.lan` DNS), AP - SSIDs, and the NDS gateway name; the two brands differ only in + whitelabel hostname is now brand-selected (`tollgate` by default, or the + whitelabel `brand` value) from a single `/etc/tollgate/brand` file, driving the + system hostname (and thus `tollgate.lan` / `.lan` DNS), AP + SSIDs, and the NDS gateway name; the brands differ only in naming. `tollgate ssl` no longer sets the option either and cleans it up on revert. ([#432](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/432), fixes [#428](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/428)) @@ -2118,10 +2669,10 @@ same-version short branch. the shipped `tollgate` binary contained no version at all. ([#383](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/383)) - **Whitelabel config UI on :8090 no longer degrades into a LuCI - redirect.** The net4sats whitelabel admin UI (docroot - `/www/net4sats`, installed by the whitelabel installer) is served by - a dedicated `uhttpd` section on port 8090; first-boot/reinstall - setup now re-ensures that section whenever the branded docroot is + redirect.** The whitelabel admin UI (a branded docroot installed by + the whitelabel installer) was served by a dedicated `uhttpd` + section on port 8090; first-boot/reinstall + setup then re-ensured that section whenever the branded docroot was present, mirroring the known-good deployed layout. Repair attempts that instead added `:8090` to `uhttpd.main` land on LuCI's docroot (`/www`, whose `index.html` meta-refreshes to `cgi-bin/luci` — the @@ -2915,7 +3466,8 @@ Router-to-router autopay ([#77](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/77)) and earlier work. Not documented in this changelog. -[Unreleased]: https://github.com/OpenTollGate/tollgate-module-basic-go/compare/v0.6.0-alpha4...main +[Unreleased]: https://github.com/OpenTollGate/tollgate-module-basic-go/compare/v0.6.0-rc1...main +[v0.6.0-rc1]: https://github.com/OpenTollGate/tollgate-module-basic-go/compare/v0.6.0-alpha4...v0.6.0-rc1 [v0.6.0-alpha4]: https://github.com/OpenTollGate/tollgate-module-basic-go/compare/v0.6.0-alpha1...v0.6.0-alpha4 [v0.6.0-alpha3]: https://github.com/OpenTollGate/tollgate-module-basic-go/compare/v0.6.0-alpha1...v0.6.0-alpha3 [v0.6.0-alpha2]: https://github.com/OpenTollGate/tollgate-module-basic-go/compare/v0.5.0...v0.6.0-alpha2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 16544e5e8..b0e9a4518 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -309,18 +309,21 @@ For implementation questions specific to your PR, ask in the PR itself. For design or roadmap questions that don't have a clear PR home yet, file a GitHub issue. -## Branding, the operator nym, and net4sats +## Branding, the operator nym, and the whitelabel brand Read this before filing a PR or an issue that mentions either of these — they are a recurring source of confusion. - **TollGate is the non-profit, reference implementation of the TollGate - protocol.** It does not adopt net4sats branding. -- **`net4sats` is a commercial re-brand of TollGate.** It is shipped as a - separate whitelabel build (distinct `brand` value — hostname, DNS, AP SSIDs - and the admin webroot all derive from `brand`). Naming and branding decisions - for net4sats are made by the net4sats project, not by this repo. Do not treat - `net4sats` and `tollgate` as interchangeable; they are `$BRAND` variants. + protocol.** It does not adopt the whitelabel brand's naming. +- **The whitelabel brand is a commercial re-brand of TollGate.** It is shipped + as a separate build (distinct `brand` value — hostname, DNS, AP SSIDs and the + admin webroot all derive from `brand`). Naming and branding decisions for that + build are made by that project, not by this repo, and **its name is not a + literal anywhere in this tree**: `tests/packaging/rebrand-literal-gutter_test.sh` + fails if it reappears. Do not treat the re-brand's name and `tollgate` as + interchangeable; they are `$BRAND` variants, and the module reads the brand + from `/etc/tollgate/brand` generically (any single alphanumeric token). - **`c08r4d0r` is a shared pseudonym for "a TollGate operator", not a personal identifier.** Everyone who runs a TollGate may go by the nym `c08r4d0r`. It deliberately names no real person. The codebase uses it as the representative @@ -331,8 +334,8 @@ they are a recurring source of confusion. private (operator-owned) AP. Do not report it or a guard test on it as an "identity leak". -When you see `c08r4d0r`, `net4sats`, or `tollgate` in a diff and suspect a -naming bug, confirm the intent against the `brand` value and this section +When you see `c08r4d0r`, `tollgate`, or a whitelabel brand in a diff and suspect +a naming bug, confirm the intent against the `brand` value and this section before asserting a leak or a rebranding error. ## Further reading diff --git a/Makefile b/Makefile index c7dedaf98..0f0ecc246 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: portal-build reproducibility-test reproducibility-variance go-battery +.PHONY: portal-build reproducibility-test reproducibility-variance go-battery release-check # Canonical pre-PR Go gate across ALL 16 modules (src/ is a multi-module # tree: `go ... ./...` from src/ alone covers only the root module). @@ -6,6 +6,17 @@ go-battery: @bash scripts/go-battery.sh +# One-command pre-release gate (docs/release-process.md): orchestrates the +# existing gates — go-battery, deps/imports, contract, packaging shell +# suites, the three fund-safety invariant tests, the conformance fast +# subset, the release-matrix cross-check, reproducibility and version +# consistency — and prints one unambiguous READY FOR HARDWARE verdict. +# TOLLGATE_RELEASE_CHECK_CONFORMANCE=1 makes the conformance subset +# mandatory; TOLLGATE_RELEASE_CHECK_REPRO=none skips the reproducibility +# leg (default: binaries/x86_64). +release-check: + @bash scripts/release-check.sh "$(VERSION)" + portal-build: @bash packaging/portal-build.sh diff --git a/README.md b/README.md index cd1af0c29..96c0e427a 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Source lives under [src/](src/). Go tooling runs from there | [config_manager](src/config_manager/) | Schema, loading, migrations, validation, backups of `/etc/tollgate/config.json`. | | [tollwallet](src/tollwallet/) | Cashu wallet operations (mint client, balance tracking, melt). | | [lightning](src/lightning/) | LNURL-p / Lightning address resolution and invoice fetching for payouts. | -| [cli](src/cli/) | `tollgate` CLI for service control, wallet, private network, upstream Wi-Fi, config, and health. Entry point: [src/cmd/tollgate-cli](src/cmd/tollgate-cli/). See [docs/operator-guide.md](docs/operator-guide.md). | +| [cli](src/cli/) | `tollgate` CLI for service control, wallet, private network, upstream Wi-Fi, config, health, and SSL/TLS certificates. Entry point: [src/cmd/tollgate-cli](src/cmd/tollgate-cli/). See [docs/operator-guide.md](docs/operator-guide.md). | | [tollgate_protocol](src/tollgate_protocol/) | Wire-type definitions shared across modules. | ## Installation @@ -124,20 +124,110 @@ For local packaging experiments use the target binaries locally, stages the canonical `packaging/` recipe into the OpenWrt SDK, and can produce either `apk` or `ipk` artifacts. +### Supported devices + +Whether a package exists for a router at all is decided by the CI build +matrix in +[.github/workflows/build-package.yml](.github/workflows/build-package.yml): +a router is covered when its OpenWrt *target* and `DISTRIB_ARCH` match one of +the rows in that matrix (check them with `ubus call system board`, or +`cat /etc/openwrt_release`). That is the machine-true definition of +"supported" — there is no per-model board list in the package. + +**Cudy WR3000 v1** (MediaTek MT7981B, 256 MB RAM) matches the matrix on +`mediatek/filogic` / `aarch64_cortex-a53`, board name `cudy,wr3000-v1`, so the +`arm64` package built for that row installs on it. It was exercised on real +hardware against mainline OpenWrt 25.12.5 (`r33051-f5dae5ece4`): the full +dependency closure (37 packages, including `nodogsplash` 5.0.2-r2 and its +kmods) installs and `nodogsplash` runs with the module's keepalive contract +live (trusted MAC plus `allow tcp port 22`). + +**Caveat — reproduce the install against current feeds with care +([#552](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/552)).** +`nodogsplash`'s `iptables-*` dependencies live in the **base target feed** +(`releases/25.12.x/targets//packages/`), not the arch `packages` feed — +a repositories list that omits the target feed (typical of some +ImageBuilder-built images) cannot resolve the closure, and apk-tools 2.x +cannot read the 25.12 index format at all. The bench install above ran with +a complete feed set; if `apk add` reports the `iptables-*` names missing, +check `/etc/apk/repositories` lists the target feed before concluding the +packages are gone. + +**Caveat — 16 MB of flash, and the compressed variant that nonetheless fits.** +The WR3000 v1 has 16 MB of SPI-NOR, which is ~15.1 MB of firmware area and +leaves roughly 4.6 MB of free overlay. The default build does not fit: its +payload is ~20 MB uncompressed (`usr/bin/tollgate-wrt` 12,361,280 B plus +`usr/bin/tollgate` 7,373,632 B) / ~8.5 MB compressed, `apk add` fails with +`failed to extract usr/bin/tollgate-wrt: No space left on device`, and a custom +ImageBuilder image does not fit either. The **`upx-ultra-brute` variant that +this repo's CI already builds for `aarch64_cortex-a53`** does fit: it shrinks +the payload to **5.34 MiB** (`usr/bin/tollgate-wrt` 3,389.5 KiB plus +`usr/bin/tollgate` 1,823.8 KiB, plus ~256 KiB of config and captive-portal +files). A real WR3000 v1 was taken through it on 2026-09-27 — installed from the +compressed `.apk`, rebooted, and came back with `tollgate-wrt` running and no +volatile helper, so a **persistent** install is possible on a 16 MB device with +this variant. Two notes for such devices: + +- the 1.78 MiB `tollgate` CLI is only needed for provisioning, so on this class + of device it can be dropped after the first boot to leave room for the + `nodogsplash` dependency closure; +- install the dependency closure in **one** `apk add` transaction. `apk add + --force-non-repository ` performs a world sync and removes packages that + were previously installed from files, which silently takes `nodogsplash` back + out. + +A volatile (tmpfs) install remains the fallback for bench work that cannot free +the space. (Measured with the `upx-ultra-brute` dev-channel build +`main.200.4469994`, sha256 `29bb68adbb26e67c…`; publishing that variant in the +feed release is tracked with the packaging feed, not here.) + +**COMFAST CF-WR632AX** (MediaTek MT7981-class SoC, compact Wi-Fi 6 travel +router) matches the matrix on the *same* `mediatek/filogic` / +`aarch64_cortex-a53` row as the WR3000 v1, so the `arm64` package built for +that row installs on it unchanged — no matrix row was added for it. OpenWrt +supports it officially since 25.12.0 (upstream keeps +`target/linux/mediatek/dts/mt7981b-comfast-cf-wr632ax.dts` and publishes the +image as `openwrt--mediatek-filogic-comfast_cf-wr632ax-*`; see the +[device page](https://openwrt.org/toh/comfast/cf-wr632ax)). + +**No flash-capacity caveat.** The CF-WR632AX carries 128 MiB of SPI NAND +(Winbond W25N01GV), unlike the 16 MB WR3000 v1 above, so the default build has +room and the `upx-ultra-brute` variant is *not* required for it. + +**Requires OpenWrt 25.12.5 or newer with the OpenWrt U-Boot layout.** A +memory-speed stability issue affected that layout in 25.12.0–25.12.4 and was +fixed in 25.12.5 (upstream PRs +[#22929](https://github.com/openwrt/openwrt/pull/22929) / +[#23416](https://github.com/openwrt/openwrt/pull/23416)); the stock layout is +unaffected. Use 25.12.5 or newer. + +**Not yet exercised on real hardware.** Unlike the WR3000 v1 above, no +CF-WR632AX has been in hand: this paragraph rests on upstream OpenWrt support +and the shared target/architecture row, not on a measured result on this +device. A tester with the unit is being lined up; the text here will be +replaced with results when there are some. There is no persistent-install +caveat for this device — the only known gap is the compressed-variant +publication one, which applies to `aarch64_cortex-a53` generally (only default +builds are released) and is tracked with the packaging feed, not here. + ## Configuration TollGate writes a default `/etc/tollgate/config.json` on first boot. -The current schema version is **`v0.0.8`**. An abridged example: +The current schema version is **`v0.0.9`**. An abridged example: ```json { - "config_version": "v0.0.8", + "config_version": "v0.0.9", "log_level": "info", "metric": "bytes", "step_size": 22020096, "margin": 0.1, "show_setup": true, "reseller_mode": false, + "private_ssid": "", + "private_key": "", + "private_encryption": "psk2+ccmp", + "admin_access": "both", "accepted_mints": [ { "url": "https://mint.coinos.io", @@ -198,6 +288,42 @@ Key fields: are probed. `ignore_interfaces` typically needs to list any wireless interfaces *the router itself serves on* to prevent self-probing. +### Network settings (`v0.0.9`) + +Four fields configure the router's own networks. They are **declared intent**: +the service converges them onto the router (UCI `/etc/config/wireless` for the +credentials, a generated `/etc/nftables.d/34-admin-access-scope.nft` for the +scope) after every `config set`/`config save`, on `tollgate config apply`, and +at service start. All four are also on the admin board's Settings page. + +| Field | Values | Meaning | +|---|---|---| +| `private_ssid` | any SSID, ≤ 32 bytes | Name of the private (management) network, on both private radios. **Empty keeps the SSID the router minted at setup** — `-`, built from your nym and the router's one stored device code, the same code that names the hostname and the captive SSID. | +| `private_key` | 8-63 characters | WPA passphrase of the private network. **Empty keeps the passphrase the router has.** Write-only: no read path ever returns it. | +| `private_encryption` | `psk2+ccmp` (default), `psk2+tkip+ccmp`, `psk-mixed+ccmp` | Encryption mode of the private network. WPA3-SAE is not offered: the shipped `wpad` has no SAE support and selecting it would leave the management network unable to start. | +| `admin_access` | `both` (default), `br-private`, `br-mgmt`, `loopback-only` | Which network may reach the administration surfaces — the board (`:8090`, `:8443`) and LuCI (`:8080`, `:443`). Since the physical LAN ports moved onto it, `br-private` is the private SSID **and the cable**. The guest network the customers pay on is **never** an administration path, whatever this says. | + +Notes that matter when you change them: + +- The default, `admin_access=both`, adds no firewall rule at all: a router that + upgrades onto this release is reachable exactly where it was — the private + bridge (the private SSID and the physical LAN ports, since the wired ports + moved onto `br-private`) plus loopback. +- `br-mgmt` is refused while that bridge does not exist on the router, because + naming it would drop `br-private` — which carries the private SSID and the + cable, i.e. every administration path — and leave no network able to reach + the board. The value stays in `config.json` and takes effect once the bridge + exists. +- `loopback-only` is strict: every interface except `lo` loses the + administration ports, including a VPN or uplink interface you administer + over — and the physical LAN ports. +- Changing the passphrase from the router's shell + (`tollgate network private set-password`) also updates `config.json`, so the + two writers cannot disagree. Setting `private_ssid` to a custom name stops + the setup script's `-` re-derivation for that SSID (a + machine-shaped one is re-derived from the stored code, a custom one is left + alone), so the applier and the setup writer agree on what you chose. + ## Testing Unit tests, from the [src/](src/) directory: diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3f0ae76df..1ac3fc484 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,390 +1,209 @@ -# TollGate v0.6.0-alpha4 (tollgate-wrt) +# TollGate v0.6.0-rc1 (tollgate-wrt) -**Released**: 2026-09-22 -**Channel**: `alpha` — a tester-facing release candidate, not a stable -release. Expect rough edges; report them. +**Released**: 2026-10-05 +**Channel**: `rc` — a feature-frozen release candidate. The feature set is +frozen; only stop-ship fixes will change before `v0.6.0` stable. -`v0.6.0-alpha4` supersedes two never-published predecessors: -`v0.6.0-alpha2` (prepared 2026-09-13, never tagged) and -`v0.6.0-alpha3` (tagged 2026-09-21 but held back before a single -artifact shipped — the ngit mirror had been re-keyed mid-migration and -the portal pin had drifted past its manifest). It is the first TollGate -release **published from the Nostr CI lane** — GitHub Actions has been -down org-wide since 2026-08-27 — and the first whose published -artifacts are reproducible byte-for-byte (pinned toolchains plus a -commit-derived `SOURCE_DATE_EPOCH` on both the local packaging path -and the publishing lane). Publication itself changed shape: the -release matrix is sharded across budgeted workflow runs, and -**announcements are emitted only when every shard of the release -succeeded** — a partial build no longer looks like a finished one. - -The theme is *funds safety and honest failure*: the wallet drain can no -longer destroy tokens when a later mint fails; payments are refused up -front when the gate provably cannot open them; an expired-keyset note -is reported as unrecoverable instead of "mint unreachable"; renewal no -longer double-charges at near-zero usage; the portal works on -nodogsplash 5.0.2; upgrades no longer abort on minimal systems without -`jq`. Alongside the fixes sits one deliberate **feature**: a -process-isolated wallet sidecar architecture (alpha quality, unit -tested, not router-validated) that lets a non-Go wallet back the -module behind the unchanged `WalletPort` contract. - -## What v0.6.0-alpha4 changes - -- **The captive portal is a dependency, not something the package - replaces.** The SDK package definition declared no runtime dependency and - the `.ipk` recipes stamped `Replaces: nodogsplash`, so an install could end - up with no portal manager at all. `DEPENDS` now carries `nodogsplash` and - the `Replaces` line is gone. -- **The pre-auth allow list carries `:443`.** With a cert/key pair in place - `uhttpd.main` answers the `:8080` LuCI port with `307 Location: - https:///`; the client that follows that redirect needs a - nodogsplash rule for `:443` or it dead-ends on a port the gateway still - REJECTs — which is how LuCI became unreachable before authentication. -- **A same-version reinstall re-asserts that list.** The short branch taken - when `/etc/tollgate-setup-done` already equals the installed version now - repairs a stale or absent `users_to_router` list instead of inheriting it, - idempotently (missing entries are added, nothing is duplicated). -- **The management subnet steps aside when the upstream collides.** The - private `/24` is no longer assumed collision-free against a private WAN; a - collision falls back to a random non-overlapping `/24`. - -The entries, tests and links are in [CHANGELOG.md](CHANGELOG.md). - -## At a glance - -- **Wallet drain safety** - ([#375](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/375)): - canonical mint identity, per-mint partial results, an fsync'd token - journal written before the next mint is attempted, `--yes`/`-y`, and - meaningful exit codes. -- **Payments refused when the gate provably cannot open** - ([#412](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/412)): - a read-only `ndsctl` probe rejects the payment up front - (`client-not-registered`) instead of consuming an irreversible token - for a session that can never start. -- **Degraded mode recovers on its own** - ([#400](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/400), - [#401](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/401)): - the runtime downgrade wires its recovery trigger, and a mint outage - observed by real traffic fires the downgrade the periodic probe used - to miss. -- **Renewal no longer double-charges** - ([#430](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/430), - fixed): the renewal check never fires while more than half of the - current allotment remains, and the default `bytes_renewal_offset` is - coherent with what an advertisement can actually sell. -- **Expired keysets are reported honestly** - ([#440](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/440)): - a note on a retired keyset gets a dedicated - `payment-error-keyset-expired` "cannot be recovered" code instead of - being misclassified as mint-unreachable — and no longer poisons the - health tracker. -- **Swap fees explained; only healthy mints advertised** - ([#409](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/409), - [#408](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/408)). -- **Portal on nodogsplash 5.0.2, whitelabel brand** - ([#428](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/428)): - `gatewaydomainname` is no longer set (and is deleted on upgrade and - re-runs); the whitelabel hostname (`tollgate` or `net4sats`) comes - from `/etc/tollgate/brand` and now moves with the brand on upgrade - while custom hostnames are kept. -- **Packaging survives the real world**: upgrades no longer abort on - `jq`-less minimal systems - ([#407](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/407)); - the full nftables ruleset actually ships, so the backend API on - `:2121` is LAN-protected - ([#387](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/387)); - the whitelabel config UI has its own `uhttpd` section on `:8090` - ([#451](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/451)) - and the admin board is reachable from captive clients - ([#458](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/458)); - Wi-Fi scanning addresses the radio's real interfaces instead of the - uci section name - ([#449](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/449)). -- **The package ships the full pinned portal bundle**: the guest SPA, - the admin board SPA and the `tollgate` rpcd plugin all build in-tree - from the manifest pin (#466) — a missing artifact at the pin is now a - hard build error — and apk-based OpenWrt 25.x upgrades run setup - again: the version gate no longer self-matches (#463, fixes #459). -- **Wallet sidecar architecture (alpha feature)** - ([#395](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/395)): - a `WalletPort` client speaking newline-delimited JSON-RPC over an - `AF_UNIX` socket to an out-of-process wallet daemon, per-backend - capability manifests (`gonuts`, `cdk`, `nucula`) and a - `wallet-policy.json` selection policy — the groundwork for backing - the module with a non-Go wallet without linking CGO. Unit tested - only; see the wallet-backend contract and measurement protocol docs - ([#431](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/431)). -- **Reproducible, self-verifying, sharded releases** - ([#383](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/383), - [#410](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/410), - [#435](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/435), - [#445](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/445), - [#441](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/441)): - pinned toolchains; `SOURCE_DATE_EPOCH` stamped end-to-end via the - stage-1 rendezvous record; the matrix sharded into budgeted runs; - and a post-publish gate that requires every declared `(arch, - format)` to be announced and mirrored with a matching sha256. - -## What's new - -### Wallet and payment safety - -`wallet drain cashu` is now safe to run on a wallet that holds funds: -mint URLs are canonicalized (scheme/host case, trailing slashes, -default ports, userinfo/query/fragment) and merged on read, so one -logical mint can no longer appear as two phantom-balanced entries; -per-mint failures produce an explicit partial result -(`success:false`, `partial:true`, `tokens`, per-mint `errors`) instead -of discarding earlier tokens; every produced token is appended to an -fsync'd `/etc/tollgate/wallet-drain-journal.jsonl` **before** the next -mint is attempted; `--yes`/`-y` and distinguishable exit codes round -it out. - -The payment path refuses what it cannot deliver: a read-only -`ndsctl json` probe rejects payment up front with an actionable -`client-not-registered` notice when NDS has no client session for the -MAC — re-probing at the valve's auth-retry cadence so the reseller -flow's asynchronous registration is not refused on first sight, and -failing open on probe errors. A note on a retired keyset gets the -dedicated `payment-error-keyset-expired` code (the mint is healthy; -the note is permanently unspendable) instead of a misleading -retry-flavored error. Swap fees are reported (`WalletPort.SwapFeeSats`) -and pre-checked, and only mints serving a real NUT-01 keyset list are -advertised. The gonuts hardening wave is included (empty-proofs -panic contained, verbatim mint rejections, `ErrTokenAlreadySpent` -reachable, wallet-locked error instead of deadlock). - -The sidecar architecture is the one feature: `src/tollwallet` gains -the RPC client, the three capability manifests and the policy -selector. It ships behind the unchanged `WalletPort` contract with the -in-process backend still the default; treat it as scaffolding for the -wallet migration, not a supported configuration. - -### Sessions and renewal - -Reseller sessions no longer pay twice at startup -([#430](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/430)): -the renewal check never fires while more than half of the current -allotment remains (with a once-per-effect warning when the configured -offset is overridden), and the default `bytes_renewal_offset` no -longer exceeds what a typical advertisement can sell after step -quantization. - -### Captive portal, brand and packaging - -`gatewaydomainname` is gone — set nowhere, deleted on version-changing -upgrades **and** same-version setup re-runs — fixing the NDS 5.0.2 -pre-auth redirect loop -([#428](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/428)). -The whitelabel hostname is selected by `/etc/tollgate/brand` -(`tollgate` default, `net4sats`), drives hostname, DNS, SSIDs and the -NDS gateway name, and moves with the brand on upgrade while -operator-chosen hostnames are preserved -([#444](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/444)). -The whitelabel config UI is served by a dedicated `uhttpd` section on -`:8090` (no more LuCI redirect), the admin board (`:8090`/`:8443`) is -reachable from captive clients exactly like LuCI on `:8080`, and -Wi-Fi scanning resolves real radio interfaces (`phyN-apK`) instead of -uci section names, so the admin page lists networks again. - -### Release engineering - -The publishing lane is deterministic and self-verifying: pinned -toolchains (#383), a commit-derived `SOURCE_DATE_EPOCH` that rides the -stage-1 rendezvous record so every shard packages with the epoch the -binaries were stamped with (#441), and the post-publish gate from -#435. Stage 2 is now eleven budgeted shard workflows rendered from -`packaging/ngit-release-matrix.json` (no leg dropped), and -announcements moved to a dedicated workflow that refuses unless every -shard succeeded and every leg has a build record naming the same -release run — a failed or timed-out shard leaves the release -unannounced instead of half-announced (#445). One `make go-battery` -runs the documented Go gate across all 16 modules (#455). - -## Behavior changes worth flagging - -- **`gatewaydomainname` is gone** (deleted on upgrade and re-runs); - `.lan` keeps resolving via dnsmasq. -- **`/etc/tollgate/brand` selects the whitelabel hostname**, and on - upgrade the hostname moves with the brand; custom hostnames are kept. -- **The admin board (`:8090`, opt-in `:8443`) is reachable from - captive clients** like LuCI — a deliberate `users_to_router` widening. -- **Payments are refused before the token is consumed** when NDS has - no client session for the MAC (`client-not-registered`). -- **`wallet drain cashu` has new output and exit codes**, a journal - file (`/etc/tollgate/wallet-drain-journal.jsonl`, root-only, never - auto-removed), and `--yes`/`-y`. -- **`preinst` is a no-op**; the binary owns `install_time`. -- **The renewal clamp may log a warning** when it overrides a - configured `bytes_renewal_offset` larger than half the allotment. -- **No partial releases are announced anymore**: if a build shard - fails, consumers see *no* kind-1063 events for that version at all — - by design. - -## Notable bug fixes - -- **v4 (`cashuB`) tokens with short keyset ids are accepted again.** The - shipped portal decoded them through a call that requires a keyset - list, so every token from a coinos/minibits-style mint failed with - `#CU102` and the payment could not be made. The portal pin moves to - the keyset-agnostic decode (portal #55, `992cf7f1` → `d699367`) and - the shipped bundle is regenerated from that pin - ([#517](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/517)). - -- A runtime downgrade recovers when a mint comes back (live case: 2 h+ - degraded with a healthy probe) - ([#400](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/400)); - a mint outage observed by real traffic fires the transition the - periodic probe missed - ([#401](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/401)). -- The full nftables ruleset ships; the backend API on `:2121` is - LAN-only on fresh installs as documented - ([#387](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/387)). -- Package license metadata corrected to `GPL-3.0-only` - ([#383](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/383)). - -## Internal changes (CI, tests, dependencies) - -- `gonuts-tollgate` re-pinned to the tagged `v0.11.2` - ([#394](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/394)). -- Wallet-backend architecture docs: contract, integration decision and - the measurement protocol (incl. the INJ-1…INJ-8 fault-injection set) - ([#431](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/431)). -- Merchant tests decoupled from the live wallet via centralized Cashu - token fixtures - ([#396](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/396)). -- Cloud-lab runs isolated per checkout - ([#446](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/446)); - contract fixtures gained a mirror/relay-list sync check - ([#456](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/456)); - `make go-battery` runs the whole Go gate - ([#455](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/455)). -- A renewal-policy simulation study with measured results (the 10-s - throttle cap, the right defaults for ≤400 Mbps uplinks), and the - cloud-lab e2e driver honors the documented compose override - ([#465](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/465)); - the captive-portal bundle-location decision is recorded as an ADR - ([#462](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/462)). -- Tester guide and the single-channel tester intake with S1 stop-ship - rules - ([#381](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/381)); - the ngit mirror is linked from the README - ([#390](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/390)). - -## Known issues - -- **nodogsplash 5.0.2 on OpenWrt 22.03**: iptables-nft translation - strips port matchers, so pre-auth DNAT hijacks all TCP, not just :80 - ([#398](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/398), - open). -- **Runtime-downgrade recovery is proactive-cycle-bound (~13 min)** — - recovery waits for the periodic probe cycle; the aggressive loop is - not armed on runtime downgrades - ([#429](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/429), - open). -- **Payout melts and drain ignore mint input fees** — - `balance_tolerance_percent` acts as accidental compensation - ([#414](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/414), - open). -- **No refund path when gate-open fails after a successful `Receive`** - beyond the L1 pre-check landed in #412 - ([#403](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/403), - remainder open). -- **Config can reset to the factory-default mint** if the daemon exits - during degraded mode - ([#402](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/402), - open). -- **Keyset `final_expiry` silently kills held balances**; wallets must - rotate off inactive keysets — the payment path now at least says so - ([#417](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/417), - open; #440/#447 improved the reporting). -- **NDS still answers HTTP 500 for unmappable clients** (stock NDS - 5.0.2, upstream behaviour; the SPA loads from uhttpd regardless). -- **The wallet sidecar is scaffolding, not a supported backend** — - unit tested only, no router validation, no measurement baseline - published yet. -- **The sharded publish pipeline has never run against a real tag** — - branch/dev runs prove the mechanics; a tag run is first-of-its-kind. -- `tollgate-clientd` is **not** part of this release (its PR remains - open pending its own fixes). - -## Verification status (read this before trusting a build) - -- **Unit/race, this exact tip**: `make go-battery` green across all 16 - modules (gofmt/vet/build + race-enabled `testenv` suite), and the - config-schema and build-purity contract checks pass on the release - commit content. -- **Live/lab evidence** (per fix, before this tag): the degraded-mode - pair was live-reproduced (PRTA #110); the drain regression was - live-validated (PRTA #107); the gate-open refusal triggers were - lab-reproduced; the NDS 5.0.2 redirect loop was observed on real - clients before the fix; the renewal double-charge was measured on - the reseller lab. -- **Not verified on a router from this tag**: everything — router - validation is what this alpha cycle is for. The sidecar specifically - has unit coverage only. +`v0.6.0-rc1` is the release candidate cut after the fund-safety blockers +measured by the conformance lane were fixed. Its theme is the same as +alpha4's — *funds safety and honest failure* — carried to the three +invariants the Go line must hold before it can call itself a payment +gateway: + +1. **One economic payment buys at most one economic session grant** — even + when the same note is submitted twice concurrently (#639). +2. **An ambiguous Cashu outcome never re-exposes deterministic derivation + outputs** — a processed swap whose response was dropped is UNKNOWN, not + FAILED, and the same blinded messages are never re-sent (#640, the + #257/#266/#480 wallet-brick class). +3. **A received payment is owed service or a recoverable claim** — a + gate-open failure after a successful `Receive` becomes a durable, + restart-surviving owed entitlement that converges when NoDogSplash + accepts (#403). + +Everything else in this RC is hardening: test credentials out of the +public tree, a commit-derived release epoch, config validation that +refuses dead credentials, and one secret-redaction extension. + +## Fund-safety fixes (the release blockers) + +- **Concurrent duplicate payment refused before the mint (#639).** The + conformance lane measured one note POSTed twice in parallel granting + **two** sessions (2× allotment). `PurchaseSession` now marks the note + in flight from the moment the money-moving call starts until its result + is consumed; a concurrent second submission is refused locally + (`payment-duplicate-inflight`), before any money moves. Sequential + duplicates are still refused by the mint as already-spent, unchanged. +- **No same-body retry after an ambiguous mint outcome (#640).** The + wallet client re-sent an identical money-moving POST body on network + errors — so a mint that processed a swap and dropped the response + received the same blinded messages again, which a strict mint answers + with error 10002 (wallet brick). Fixed in `gonuts-tollgate` v0.12.2 + (network errors return an ambiguous-outcome error immediately; the 429 + same-body retry is kept, as a rate-limit answer precedes processing). + The customer-facing notice says the outcome is unknown and **not to + resend the note** (`payment-outcome-unknown`), and an unanswered + request no longer condemns a healthy mint in the health tracker. +- **Owed entitlements (#403).** A successful `Receive` followed by an + `ndsctl auth` failure used to leave the value with the operator and the + customer with nothing. The paid purchase is now recorded durably + (`owed-grants.json`, atomic + fsync, written before the response), + retried with backoff until it succeeds or its window passes, reloaded + at startup, and applied exactly once. The customer is told access will + start automatically and **not to pay again** + (`payment-received-grant-pending`). Refund (returning the value) + remains deliberately out of scope: an entitlement that expires ungranted + is a loud operator action, recorded and kept for audit. + +## Release hardening + +- The hardware-fleet tests no longer ship router/Wi-Fi credentials or a + hardcoded artifact URL (#509/#528; rotate previously-exposed values). +- ngit stage 1 stamps `SOURCE_DATE_EPOCH` from the commit, never the + runner clock (#529) — reproducibility on the lane that actually + publishes. +- `config set private_key` refuses out-of-bounds passphrases at write + time instead of persisting credentials the applier refuses forever + (#636). +- `config get` no longer returns the identities' Nostr private keys: + blanked, reported via `secret_set`, preserved on save (#635). +- `make release-check VERSION=v0.6.0-rc1` orchestrates every pre-release + gate (Go battery, contract, packaging, the three invariant groups, the + conformance fast subset, release-matrix cross-check, reproducibility, + version consistency) into one `READY FOR HARDWARE` verdict. + +## Config schema changes + +None versus alpha4 — schema stays `v0.0.9`. Deployed `config.json` files +upgrade unchanged. (The `private_key` field gained enforced bounds; a +valid deployed config was already inside them.) + +## Experimental / unsupported + +- The wallet sidecar (`WalletPort` over AF_UNIX) remains scaffolding: + unit-tested, not router-validated, not a supported backend. +- Reseller/two-router mode and Lightning (LNURL-p) sales are shipped as + before; hardware acceptance for this RC covers the Cashu path first. +- `tollgate-clientd` is not part of this release. + +## Known limitations + +- No refund path for an expired owed entitlement (operator action; the + record and reference are kept). +- Session metering is still process-memory state: a restart loses + in-flight usage accounting (known gap, unchanged). +- Payout melts and drain ignore mint input fees (#414); expiry of + keysets can still strand balances (#417). +- **The conformance lane measures two `service-or-refund` rows as red, + and they are the honest state of the kill/timeout windows**: when the + daemon dies between the mint's acceptance and the session grant + (`pay-kill-post-receive-pre-session`), or the swap response is dropped + after processing (`swap-timeout-retry`), the token is spent at the mint + while the customer has no session. The output-re-use and double-count + invariants PASS on every row; closing the service arm requires the + designed business-transaction record (#502): a new WalletPort + checkstate surface, a durable receive-intent journal, and an explicit + operator policy for granting against value recoverable only via + NUT-09 restore — deliberately not improvised before this release + (#497's research-first rule). The late-outcome journal for a `Receive` + that outlives its deadline likewise records the outcome for the + operator but does not auto-grant (#498/#502). +- **The min_steps wire default is a live spec question**: TIP-02 carries + a tentative `default 0` while this implementation defaults/floors to 1 + (clients reject 0; a crash-reset router advertising 0 bricks + discoverability). Tracked upstream as + [OpenTollGate/tollgate#20](https://github.com/OpenTollGate/tollgate/issues/20) + with a proposal to de-tentative to 1 — this release matches the field + evidence that proposal cites. +- **Wallets cannot initialize on jffs2-overlay devices** (squashfs NOR + targets — ipq40xx, small-NOR ath79, mt76x8 class): bbolt requires a + shared mmap jffs2 has never supported, so the wallet stays in degraded + mode while everything else appears healthy + ([#583](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/583), + open). Devices with NAND/UBI overlays (the tested representatives) are + unaffected; the fix is wallet-backend work, tracked post-0.6. +- **The `.apk` lane cannot install on stock OpenWrt 25.12**: nodogsplash's + `iptables-*` dependencies are absent from every 25.12.5 feed, so an + `apk add` fails unless the operator stages them manually + ([#552](https://github.com/OpenTollGate/tollgate-module-basic-go/issues/552), + open — an upstream feed gap, not fixable in this tree). OpenWrt 24.10 + (`.ipk`) is the installable line for this RC. +- A module restart leaves clients NoDogSplash still holds as + Authenticated outside any session (free access until their NDS + sessiontimeout); the inverse-drift reconciliation that closes this is + written + ([#619](https://github.com/OpenTollGate/tollgate-module-basic-go/pull/619)) + and deliberately deferred past the freeze. +- nodogsplash on OpenWrt 22.03 has the pre-auth DNAT translation issue + (#398); 24.10+ is the tested line. + +## Hardware support + +- **TESTED (this RC's acceptance targets)**: `aarch64_cortex-a53` + (mediatek-filogic class — Cudy WR3000 v1 with the `upx-ultra-brute` + variant on 16 MB flash, COMFAST CF-WR632AX), and `ramips-mt7621` + (`mipsel_24kc`) representatives. Acceptance runs use the release + artifact by hash through the router-happy-path harness. +- **BUILDABLE, not yet verified on hardware for this RC**: the remaining + matrix legs (`aarch64_cortex-a72`, `arm_cortex-a7`, `mips_24kc`, + `x86_64`, apk formats). They are published but must not be called + supported until their acceptance passes. +- Distinguish: **BUILDABLE** = CI produced the package; **TESTED** = + the happy-path suite ran on a real representative; **SUPPORTED** = + TESTED plus the full acceptance list (install, upgrade, same-version + reinstall, downgrade/upgrade round trip, reboot, WAN/mint outage at + startup, real payment, second payment, concurrent duplicate, + usage exhaustion, forced gate-open failure/recovery). ## Upgrade notes -- **Back up the wallet directory and `/etc/tollgate/config.json` - before upgrading.** -- **`.ipk` filenames** are `tollgate-wrt_v0.6.0-alpha4_.ipk` - (UPX variants add a `-upx-…` suffix); the control-file version is - the tag name verbatim. `opkg install` on OpenWrt 24.10 and earlier. -- **`.apk` (OpenWrt 25.x)**: the sharded pipeline is built to let the - SDK legs finish within their budgets; whether they do on a real tag - is exactly what this RC establishes. Check for announcements before - promising 25.x testers anything. -- **Do not reuse `v0.6.0-alpha1/2/3` or any `v0.7.0-alpha*` package** — - none of them published a single artifact: alpha1's prerelease page is - empty, and the alpha2/alpha3 tags were cut but shipped nothing - (alpha3 was held back by release-lane incidents and is superseded by - this release). -- **Expect a setup rerun after installing.** Existing WiFi/portal - configuration is preserved; the hostname moves with the brand unless - you set your own; check `/tmp/tollgate-setup.log` (root-only). - -## Getting v0.6.0-alpha4 - -- Packages are announced as NIP-94 kind-`1063` events - (`n=tollgate-wrt`, `v=v0.6.0-alpha4`, `c=alpha`). Filter by **both** - publisher keys — the ngit CI key publishes now; the historical - Actions key never will again: +- **Back up the wallet directory and `/etc/tollgate/config.json` before + upgrading.** +- Upgrade from `v0.6.0-alpha4` (or any `v0.6.0-alpha*`) is a plain + package upgrade: `opkg install` / `apk add` the new artifact; existing + configuration, identities and wallet are preserved; expect a setup + re-run (`/tmp/tollgate-setup.log`, root-only). +- Same-version reinstall and downgrade/upgrade round trips are exercised + by the acceptance harness (setup-marker ordering protects the + operator's SSID on rollback). +- Do not install artifacts whose kind-1063 event you cannot verify: + download from a `url` tag and check the file's sha256 against the `x` + tag, for BOTH publisher keys. + +## Getting v0.6.0-rc1 + +- Packages are announced as NIP-94 kind-`1063` events (`n=tollgate-wrt`, + `v=v0.6.0-rc1`, `c=rc` — filter by both publisher keys; see + [AGENTS.md](AGENTS.md) for the exact `nak` queries). ```bash nak req -k 1063 \ -a 5075e61f0b048148b60105c1dd72bbeae1957336ae5824087e52efa374f8416a \ -a 6cfc53c04bda7d58dd4dd0471d66f6a4ea7d3e123e78006e0e0c1abc1208ac0d \ - --tag n=tollgate-wrt --tag v=v0.6.0-alpha4 --limit 50 \ + --tag n=tollgate-wrt --tag v=v0.6.0-rc1 --limit 50 \ wss://relay.damus.io wss://nos.lol wss://nostr.mom ``` -- **No events for this version means the release is not published** — - a failed shard suppresses all announcements by design; do not - install stray artifacts from other versions' events. -- Download from any `url` tag (mirrors of the same blob) and **verify - the file's sha256 against the event's `x` tag** before installing: - - ```bash - echo " tollgate-wrt_v0.6.0-alpha4_.ipk" | sha256sum -c - - opkg install tollgate-wrt_v0.6.0-alpha4_.ipk - ``` - +- No events for this version means the release is not published — a + failed shard suppresses all announcements by design. - **Reporting problems**: - [docs/tester-intake.md](docs/tester-intake.md) names the single - intake channel and the report template; + [docs/tester-intake.md](docs/tester-intake.md) names the single intake + channel and the report template; [docs/rc-tester-guide.md](docs/rc-tester-guide.md) documents install/upgrade/rollback. Any wallet/funds symptom is **S1 = stop-ship**. -## Contributors +## Verification status + +- Unit/race across all 16 modules, contract, packaging and pipeline + suites: green on the release commit (`make release-check`). +- The three fund-safety invariants: pinned by dedicated regression tests + (each reproduced red before its fix) and by the conformance fast + subset lane. +- Hardware acceptance: pending for this RC — that is what the RC cycle + is for; see "Hardware support" above for what is claimed and what is + not. -Commits since the never-published `v0.6.0-alpha2` preparation came -from Amperstrand, c03rad0r and Felix (via `felixfelix-bot`), on top of -the `v0.5.0`-era contributor base — c03rad0r, Amperstrand, Origami74, -Matt Van Horn and Alex Xie. The full per-PR record is -[CHANGELOG.md](CHANGELOG.md). +The per-PR engineering record is [CHANGELOG.md](CHANGELOG.md). diff --git a/SECURITY.md b/SECURITY.md index aea29498f..07eb7dc42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,8 +36,8 @@ audit 2026-08-26 (task T7). `deploy-backup-20260730/`) and force-pushed; the legitimate #358/#359 changes are preserved as rewritten commits `5e904a1` / `1e70bca`. - No remote branch or tag other than `main` carried the directory - (verified via `git ls-remote`); `net4sats/tollgate-module-basic-go` - was hard-reset separately by Amperstrand. + (verified via `git ls-remote`); the pre-rename fork of + `tollgate-module-basic-go` was hard-reset separately by Amperstrand. ### Residual exposure diff --git a/VERSION b/VERSION index 22d9ad1b5..dd859a512 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.6.0-alpha4 +v0.6.0-rc1 diff --git a/docs/architecture/admin-lan-subnet-collision-decision.md b/docs/architecture/admin-lan-subnet-collision-decision.md new file mode 100644 index 000000000..8172512e0 --- /dev/null +++ b/docs/architecture/admin-lan-subnet-collision-decision.md @@ -0,0 +1,124 @@ +# Admin LAN Subnet Selection — Detect Collision With the Upstream, Then Let the Operator Decide + +## Status: Proposed (2026-09-28) + +The management (`private`) network already dodges subnet collisions — it is derived +from the LAN when that is safe and drawn at random when it is not, against every +network the router is attached to on the upstream side (see +[`private-subnet-collision-avoidance-decision.md`](private-subnet-collision-avoidance-decision.md)). + +The **admin LAN is not covered by that rule.** `network.lan` is `192.168.1.1/24` by +convention, is not compared against the upstream, and is not operator-configurable. +When the operator's own network is also `192.168.1.0/24`, the box's LAN subnet *is* +its upstream subnet, and the box goes dark from the operator's point of view — the +same failure the private network documents, now on the surface the operator needs to +reach to fix anything. + +This is a **proposal**, not a decision. It describes the problem, the options, and +what would have to be true before it ships. + +## The failure mode + +A box installed into a network whose gateway is `192.168.1.1` — an extremely common +home default — ends up with: + +- **the admin surface unreachable or ambiguous**: both the operator's upstream router + and the box answer `192.168.1.1`; which one a given client reaches depends on + topology nobody inside the box can see; +- **upstream traffic routed back inside**: an address in the upstream subnet is also a + connected route on the box's LAN bridge, so traffic destined upstream can be handed + to the wrong side; +- **a support burden that looks like a brick**: from the operator's side the box is + simply gone, and the fix (change the LAN) is reachable only through the surface that + is gone. + +Who it hurts: an operator (or review-club member) whose home/office LAN is +`192.168.1.0/24`, i.e. a large fraction of first installations. It is invisible in the +lab when the bench upstream is `10.x` or `192.168.2.x`. + +## Why the existing private-network logic does not cover it + +`choose_private_subnet()` consults `upstream_networks()`, which reads both +`network.wan.ipaddr` (+ netmask, for static uplinks) and `ip -4 -o addr show` +(everything that is not `lo`, the LAN device or the private bridge — so DHCP, STA, +PPP and FIPS/mesh uplinks are all covered). That machinery is exactly what the LAN +check needs, and it is already reviewed and tested **for the private bridge**. + +What it does *not* do is apply any of it to `network.lan`. The LAN address is read +and used (`lan_ip=$(uci -q get network.lan.ipaddr … || echo 192.168.1.1)`) but never +validated against the upstream, and the private-network code derives its own candidate +*from* the LAN — so a colliding LAN is inherited rather than questioned. + +## What changes if the LAN subnet moves + +Not free — this is why the change needs its own decision rather than riding along: + +- **TLS certificate SANs.** The admin surface serves a certificate for the LAN + address (`tollgate ssl covers` exists precisely because this is brittle); a moved + LAN changes what the cert must cover. +- **Captive-portal and admin URLs.** Portal pages, redirects and documentation + (`curl http://192.168.1.1:8080/` appears in user-facing prose) name the LAN address. +- **DHCP scope and lease churn.** Moving the bridge re-leases every client on it. +- **Operator expectation.** `192.168.1.1` is the address people type. Silently moving + it is a surprise; moving it because it *collides* is a fix, but it must be legible. +- **Existing boxes.** A box already deployed on a non-colliding LAN must keep its + address; a box whose LAN was deliberately customised must be left alone. + +## Options + +| option | summary | trade-off | +|---|---|---| +| **A** Document only | ship a support note: "if your network is 192.168.1.0/24, change the LAN" | cheapest; leaves the failure in place and unaddressed | +| **B** Detect at setup, pick a non-colliding `/24` | extend the private rule to the LAN, keeping `192.168.1.1` when safe | smallest diff that fixes the common case; blind at first boot if the upstream is not yet known | +| **C** Operator-configurable LAN | LAN address as a setting, on **both** the config file and the admin UI | required by the standing dual-surface rule; the UI that changes the LAN is served over the LAN, so it needs an explicit confirm + reconnect path | +| **D** Runtime detect-and-warn | after the upstream associates, re-check and surface a clear warning | necessary because the upstream may only be known later; warning only — no automatic re-addressing | + +## Proposed rule (B + D-warn, with C as the override) + +1. At setup, check the LAN address against `upstream_networks()` using the existing + `subnet_conflicts()` / `netmask_to_prefixlen()` helpers. Keep `192.168.1.1` when it + is safe; move to a non-overlapping `/24` **only** when it collides. +2. After the uplink associates, re-check and, if the LAN now collides, **warn** + (admin surface + log) rather than move anything by itself. +3. Expose the LAN address as a dual-surface setting (config file **and** admin UI), so + the human has the last word and an intentional customisation is preserved. + +Detection, then explicit override — never a silent surprise re-addressing. + +## Required evidence before implementation + +- a fresh install with the upstream on `192.168.1.0/24` → box reaches the admin + surface, and the LAN address chosen does not overlap the upstream; +- a fresh install with a non-colliding upstream → LAN stays `192.168.1.1` (no + gratuitous change); +- an upgrade of an already-deployed box → address unchanged, no re-lease storm; +- an uplink that only appears *after* setup and collides → warning surfaced, nothing + moved; +- the configured-address path set from the CLI **and** from the admin UI; +- certificate/redirect behaviour on a moved LAN (does `tollgate ssl covers` still hold?). + +## Rollback + +The rule keeps `192.168.1.1` in the safe case, so the common installation is +unchanged. The failure path is a box that moved its LAN and should not have: an +operator-set address wins over detection, and clearing it returns the box to the +deterministic rule. + +## Open questions + +- Should a moved LAN be **reported** in the admin surface as a one-time notice, or + only in the log? (An unexplained different address is confusing; a notice is state.) +- Does the certificate need to cover both the old and new address during transition, + or is regeneration on the next setup pass acceptable? +- Does anything downstream (portal bundle, nsite boards, docs) hard-code + `192.168.1.1` in a way that would need the same treatment? + +## Related + +- [`private-subnet-collision-avoidance-decision.md`](private-subnet-collision-avoidance-decision.md) + — the rule this proposal extends, and the source of `upstream_networks()` / + `subnet_conflicts()`. +- [`lan-port-management-bridge-decision.md`](lan-port-management-bridge-decision.md) — + which ports are on the LAN bridge. +- [`luci-https-pre-auth-reachability-decision.md`](luci-https-pre-auth-reachability-decision.md) + — how the admin surface is reached before payment. diff --git a/docs/architecture/default-ui-and-entry-port-decision.md b/docs/architecture/default-ui-and-entry-port-decision.md index 60ce3708e..66c606947 100644 --- a/docs/architecture/default-ui-and-entry-port-decision.md +++ b/docs/architecture/default-ui-and-entry-port-decision.md @@ -321,11 +321,12 @@ this stack that is a port decision. Considered seriously, because one writer cannot drift. Rejected: the board's webroot is a **build-time** substitution (`__ADMIN_HOME__` by the feed's Makefile, `portal-build.sh:37-38`), so the module would have to keep its own -brand→webroot map — which already exists once by accident -(`setup_uhttpd_configui`, `99-tollgate-setup:689-741`, hardcoding -`/www/net4sats` for one brand) — and would bind `:443` to a docroot it merely -believes is right. A wrong guess there is an entry point that answers nothing. -Two writers that read one switch is the smaller risk, and D4's marker bounds it. +brand→webroot map — a map that already existed once, in the legacy brand-gated +configUI writer this record's D4 forbids (it hardcoded one brand's webroot and +claimed `:8090` behind the board's back) — and would bind `:443` to a docroot it +merely believes is right. A wrong guess there is an entry point that answers +nothing. Two writers that read one switch is the smaller risk, and D4's marker +bounds it. ### A5 — Gate the link per client, by the network the client came from @@ -334,9 +335,16 @@ really about. The board's backend is rpcd over the uhttpd instance's `ubus_prefix`, and its plugin sees the **request payload only** (`openwrt/rpcd/tollgate` pipes it through `cat`), so the router cannot say which network the browser is on. The one place that *can* answer per socket is the module's own identity -resolver, and the admin path cannot reach it: `30-backend-firewall.nft:21-22` -drops `:2121` for `iifname != { "br-lan", "lo" }`, which is deliberate (the -money API is a customer surface). So the design keeps liveness a router-wide +resolver, and at the time of this decision the admin path could not reach it: +`30-backend-firewall.nft` dropped `:2121` for +`iifname != { "br-lan", "lo" }`. **Corrected 2026-10-04:** that exemption set +now names `br-private` too (`iifname != { "br-lan", "br-private", "lo" }`), +because the board is served on `br-private` and reads every value it shows from +`:2121` — with the drop in place the board rendered while all of its data died, +which is a defect, not a deliberate customer-surface boundary. The per-client +mechanism is therefore no longer blocked by the packet filter; the rejection +below rests on the cost of the mechanism itself, not on unreachability. So the +design keeps liveness a router-wide fact and makes invariant 2 stand in for the per-client answer: a client that can load the board at all is a client inside the admin scope, and both UIs share that scope. If the scopes ever need to diverge, that is a change to this record @@ -450,7 +458,7 @@ rules; `br-mgmt` needs PR #601's port move first)** | # | Repo | Change | |---|---|---| -| 1 | `tollgate-module-basic-go` | `entry_ui` in `config.json` + schema + defaults + migration + version bump; `99`'s mapping writer and the D4 marker gate; the identity-follows-listener change in `setup_uhttpd_tls_identity` and `ssl.go`; the mode-aware rewrites of `drop_admin_listeners` / `setup_uhttpd_configui` / `sanitize_uhttpd_main_configui_port`; `tollgate ui links`; offline suite 1-10 above; this record. | +| 1 | `tollgate-module-basic-go` | `entry_ui` in `config.json` + schema + defaults + migration + version bump; `99`'s mapping writer and the D4 marker gate; the identity-follows-listener change in `setup_uhttpd_tls_identity` and `ssl.go`; the mode-aware rewrites of `drop_admin_listeners` / `purge_foreign_configui_sections` / `sanitize_uhttpd_main_configui_port`; `tollgate ui links`; offline suite 1-10 above; this record. | | 2 | `tollgate-captive-portal-site` | `92` becomes mode-aware (entry vs secondary pair, marker, the provisioned identity, the pre-auth block removed, `:8443` no longer keyed off `/etc/uhttpd.crt`); the login page's `http://:8080/` anchor replaced by the `ui links` render; the portal-side guard for invariant 5/7; portal tests. | | 3 | `FreedomTechFeed/packages` | Re-vendor the bundle and the `92` from slice 2 and re-pin the portal commit in the same release that turns `entry_ui=board` on by default (D4) — the atomicity boundary. | | 4 | bench | Card-sized: the bench assertions 11-18, on the artifact that carries slices 1-3. | diff --git a/docs/architecture/discovery-signaling-decision.md b/docs/architecture/discovery-signaling-decision.md new file mode 100644 index 000000000..32c892a28 --- /dev/null +++ b/docs/architecture/discovery-signaling-decision.md @@ -0,0 +1,129 @@ +# Discovery signaling — how an AP says "connect to me to verify" (decision + option record) + +## Status: Decided (2026-09-28) — v0.6 ships SSID-prefix recognition; everything else documented and deferred + +Two questions were separated during the #618 design review, and this record +keeps them separate: + +1. **Verification** — "is this candidate actually a TollGate?" Answered by + capability: a validly-signed kind-10021 advertisement served on `:2121` + (pre-auth-reachable per the [nodogsplash port 2121 + decision](nodogsplash-port-2121-decision.md); the router-to-router probe + already exists as `probeTollGateGateway`). Signed by the responder, so a + cloned beacon cannot spoof it. +2. **Discovery signaling** — "who do we even ask?" An AP needs a cheap, + pre-association way to indicate *"please connect to me to verify that I + am a TollGate."* + +**The v0.6 answer to (2) is the SSID prefix** (`TollGate-` / `Net4sats-`, +case-insensitive — #618). It is the Tier-3 option below: zero client +requirements, zero new dependencies, spoofable-but-harmless because it only +selects candidates for the signed probe. Everything richer is deferred past +v0.6 and recorded here so nobody re-researches it. + +--- + +## The option space (researched 2026-09-28) + +Three tiers, each with prior art. All of them are *signals*: none of them +authenticate anything — authentication stays with the signed advertisement. + +### Tier 1 — 802.11u / Hotspot 2.0 (Passpoint): the standards-track answer + +The industry's solution to exactly this problem, in two layers: + +- **Beacon tier:** Interworking element (network type, "internet" bit) plus a + Roaming Consortium element carrying up to three organization identifiers + (OIs) — registry-assigned 3-byte IDs meaning "this AP participates in + network X". The standardized version of an SSID prefix. +- **Query tier (ANQP over GAS):** any client can query the AP pre-association + — before an IP address exists — for venue, operator friendly name, + **domain name**, NAI realms, WAN metrics, connection capabilities. Android + and iOS implement this natively (Passpoint is in AOSP; the supplicant + provides GAS/ANQP), and `wpa_cli anqp_get` exists on the STA side. + +Why it is attractive for us: ANQP's **Domain Name element is +DNS-namespaced** — a TollGate AP could answer `tollgate.me` + operator name ++ WAN metrics with **no registry, no custom app, no OUI assignment**, and a +stock phone's WiFi picker surfaces that class of network without the user +installing anything. A reseller router could use `anqp_get` instead of +custom `iw scan -u` parsing, sidestepping the tiny-`iw` problem entirely. + +Costs: hostapd interworking configuration per-AP; an OS-level story only as +good as each platform's Passpoint surface; another moving part to test. +Deferred. + +### Tier 2 — vendor-specific IE ("beacon stuffing"): the semi-standard answer + +The research lineage (Chandra et al., *Beacon-Stuffing*, HotMobile 2007 → +Zehl et al., *LoWS*, 2016 → current fog/IoT work) confirms the shape we +already have in `vendor_element_manager.go`, with two useful refinements: + +- The 255-byte limit is **per element, not per beacon**: multiple + vendor-specific IEs are legal up to a ~2320-byte beacon frame, and + fragmentation across successive beacons is an established technique. (The + encoder's per-element overflow policy — truncate the mint TLV, report it — + is #620; multi-IE emission would be its successor if this tier is ever + built.) +- Every paper hits the same wall: **the client side needs driver or app + changes** (LoWS shipped modified Android builds), and SSID-stuffing + variants flood the WiFi picker with bogus entries. + +Net: viable for router-to-router where we control both ends; a dead end for +stock phones. Its prerequisites remain as listed in the #618 review: +production emitter, a raw-IE scan source, hotplug re-apply of the +runtime-only ubus state, a registered OUI (`212121` is unassigned), and the +`VendorIEDiscovery` flag actually being read. + +### Tier 3 — structured SSID: the pragmatic answer (what v0.6 ships) + +Formalized in the literature (Di Sorte et al. 2007) and commercialized by +Boingo, whose client tool recognized partner APs **from the SSID** using an +operator dictionary. Zero client requirements, 32-byte budget, spoofable, +no security — acceptable exactly and only because it selects candidates for +the signed probe rather than authenticating them. `TollGate-` / +`Net4sats-` with the brand-prefix recognition set (`hasTollGateSSID`) +is this tier, done deliberately. + +**Deferred variant — checksum-in-SSID** (tabled 2026-09-28, considered for +v0.6 and left out): appending a truncated key checksum to the captive SSID +(`Net4sats--`) so a reseller with a trust allowlist could +filter scan candidates pre-association without associating to wrongly-keyed +APs. Recognizer-neutral (`hasTollGateSSID` is prefix-only; the shell +convergence would preserve such an SSID as an operator-style name), but +blocked on three things: a consumer (the allowlist pre-filter does not +exist), the one-device-code contract (the captive SSID would deliberately +diverge from the shared code — the exact drift class #605 closed, pinned +cross-repo), and the key-linkability question (a merchant-key-derived +checksum makes the SSID permanently linkable to the operator's money +identity — the milder cousin of the rejected beaconed-npub). Target: next +release, folded into the Tier-2 vendor-IE work where it belongs as one +pre-association signal among several, designed with its consumer. + +### Rejected outright (recorded with evidence so they stay closed) + +- **BSSID signaling** — 48 bits cannot carry a key; truncation destroys + binding; the kernel owns interface addresses; measured on the bench + 2026-09-28: a BSSID cannot be changed while the AP runs, so a price change + would tear down the AP and drop every associated customer; duplicate + BSSIDs violate 802.11 uniqueness and present the evil-twin signature; + `identity.DeriveMAC` would make one sighting reveal an operator's fleet. +- **A beaconed npub** — pre-auth clients have no uplink, beacons are + replayable, and a permanent broadcast of a money-linked identity is a + wardriving archive of operator keys. If a discovery key is ever needed it + must be separate, rotatable, and non-financial. + +Post-association broadcast (mDNS/DNS-SD) is not in the option space: it +cannot answer a pre-association question. + +--- + +## When this reopens + +Signals to revisit this record: (a) customers need pre-association *price* +in the WiFi picker (pulls toward Tier 1/ANQP), (b) reseller discovery needs +to survive SSID renames (ANQP domain name or the vendor IE), (c) a +registered OUI/OI is obtained, (d) reseller allowlist pre-filtering becomes +worth building (the Tier-2 vendor-IE work, with the tabled checksum-in-SSID +variant as one of its signals). Until then: SSID prefix selects, the signed +advertisement on `:2121` verifies. diff --git a/docs/architecture/lan-port-management-bridge-decision.md b/docs/architecture/lan-port-management-bridge-decision.md index 8763f26fe..bdd4208c6 100644 --- a/docs/architecture/lan-port-management-bridge-decision.md +++ b/docs/architecture/lan-port-management-bridge-decision.md @@ -8,6 +8,17 @@ > design that would silently not hold. Acceptance is a maintainer action — the > drafting account may not accept its own proposal, and nothing here is in > force until the rollout below has landed on a router and been measured there. +> +> **Amendment (2026-09-28): the verdict below is narrowed, not withdrawn.** It +> holds for a **second *gated* bridge**, and it does **not** apply to the +> operator's requirement — a wired-port client that still has to pay for WAN +> *and* can reach the administration surfaces is satisfiable by a configuration +> change on the shipped stack: a bridge-port-keyed re-key of the two +> admin-port guards, with no `br-mgmt` and no second gate. Read "the request +> it was written for is not fully satisfiable" as "the *second gated bridge* +> half of that request is not". See "Amendment - the requirement is satisfiable +> by a configuration change" at the end of this record. Status stays Proposed: +> the bridge proposal is neither accepted nor withdrawn here. ## Context @@ -44,15 +55,15 @@ tells an operator in this situation to "use the module CLI or LuCI on `:8080`" The wired port and the open guest SSID are one bridge, deliberately gated as one. The cost of that sharing was already measured in this repo: -`packaging/files/etc/uci-defaults/99-tollgate-setup:974-993` records that bridge -port isolation **cannot** separate a wired host from a guest BSS — netifd's -`isolate` is bilateral (`br_private.h br_skb_isolated`) and was measured on the -bench to block nothing — and names the alternative mechanism in as many words: -"a dedicated br-guest with its own reject-by-default zone, or a bridge-family -nft rule keyed on the VAP ports". A bridge of its own for the wired ports is -therefore not only the operator's preference, it is the fix for an exposure this -repo could not otherwise close: an operator's administration laptop currently -shares a broadcast domain with strangers, ARP, mDNS and all. +`packaging/files/etc/uci-defaults/99-tollgate-setup:1280-1305` records that +bridge port isolation **cannot** separate a wired host from a guest BSS — +netifd's `isolate` is bilateral (`br_private.h br_skb_isolated`) and was +measured on the bench to block nothing — and names the alternative mechanism in +as many words: "a dedicated br-guest with its own reject-by-default zone, or a +bridge-family nft rule keyed on the VAP ports". A bridge of its own for the +wired ports is therefore not only the operator's preference, it is the fix for +an exposure this repo could not otherwise close: an operator's administration +laptop currently shares a broadcast domain with strangers, ARP, mDNS and all. ### The three layers that assume exactly one captive gateway @@ -153,11 +164,15 @@ with two instances: `access-grant-failed`). - **The nft layers are `br-lan`-literal.** `20-nds-enforce.nft:22,25,28,33` (`iifname "br-lan"` for the three marks and - the unmarked-to-WAN reject), `30-backend-firewall.nft:21-22` - (`iifname != { "br-lan", "lo" } tcp dport 2121 drop` — a client that cannot - reach `:2121` cannot pay), `31-*.nft:52-53`, `32-*.nft:54-55`, and the + the unmarked-to-WAN reject), `30-backend-firewall.nft:35-36` + (`iifname != { "br-lan", "br-private", "lo" } tcp dport 2121 drop` — a client + that cannot reach `:2121` cannot pay. Both LAN bridges are exempt: `br-lan` + because the portal pays through this API, and `br-private` because the + owner-facing board served on that network reads every value it shows from it. + Exempting only `br-lan` left the board rendering with all of its data dead), + `31-*.nft:52-53`, `32-*.nft:54-55`, and the zone-scoped `firewall.tollgate_in` rule that allows `:2121` from `lan` - (`99-tollgate-setup:1427-1441`). + (`99-tollgate-setup:1756-1766`). ### Where the wired LAN ports are bound, and therefore who may move them @@ -165,9 +180,9 @@ Not this repository, and not the feed recipe — **the base image**. - This module ships **no** `/etc/config/network` at all (`packaging/files/etc/` has no `config/` directory), only **reads** the LAN device - (`99-tollgate-setup:1517-1519`, `lan_dev=$(uci -q get network.lan.device)`, - defaulting to `br-lan`), and writes only `network.lan.domain` (`:867`) and - `network.lan.ip6assign` (`:1463`). + (`99-tollgate-setup:1837-1840`, `lan_dev=$(uci -q get network.lan.device)`, + defaulting to `br-lan`), and writes only `network.lan.domain` (`:1121`) and + `network.lan.ip6assign` (`:1725`). - The feed recipe vendors the same writer (its copy of `99-tollgate-setup` carries the same `network.lan.domain`/`ip6assign` writes and no port list). - The ports come from `/etc/board.d/02_network` at first boot: for this @@ -183,7 +198,7 @@ Not this repository, and not the feed recipe — **the base image**. Consequence: the ports list is **image-owned**, so the module must write the move itself (it already writes `network` freely, and it already creates `network.private`/`network.private_bridge` and `dhcp.private` the same way, -`99-tollgate-setup:1693-1707`), and it must do so **device-agnostically** — by +`99-tollgate-setup:2015-2025`), and it must do so **device-agnostically** — by reading the port list that is currently on `br-lan` and moving it, never by naming `eth1`. @@ -197,7 +212,7 @@ naming `eth1`. anonymous — `/bin/config_generate:109-119` emits it without a name — so `network.@device[br-lan]` is **not valid UCI addressing**: the writer must find the `@device[N]` whose `option name` is `br-lan` (`uci show network` piped to -`awk`, the idiom already in the script at `99-tollgate-setup:1469`) and move its +`awk`, the idiom already in the script at `99-tollgate-setup:2002`) and move its `ports` list. If `br-lan` has no port list, the writer **fails loudly and changes nothing** (a bridge with no ports would leave the operator with a dead cable and no diagnostic). @@ -206,14 +221,15 @@ cable and no diagnostic). out.** `firewall.mgmt_zone` (`name 'mgmt'`, `network 'mgmt'`, `input 'ACCEPT'`) and **no forwarding to `wan`**. It must **not** join `firewall.private_zone`: that zone is `input/output/forward 'ACCEPT'` and has a -`private → wan` forwarding (`99-tollgate-setup:1734-1743`), i.e. reusing it +`private → wan` forwarding (`99-tollgate-setup:2063-2065`), i.e. reusing it would hand every wired client free internet — the exact hole this record exists to not create. The admin listeners need no change to be reachable: they bind `0.0.0.0`/`[::]` (`uhttpd.main` `:8080`/`:443`, `99-tollgate-setup:337-339,443-448`; `uhttpd.portal` `:2051`, `:364-365`; `uhttpd.trusted` `:80`, `:646-648`; -`:8090` written here on `uhttpd.net4sats`/configUI, `:689-709`, and the opt-in -`:8443` on `uhttpd.admin` written by the feed's `92-tollgate-admin-setup` — -this script only clears that listener, `:804-805`). +`:8090` on `uhttpd.admin`, written by the feed's `92-tollgate-admin-setup` — the +ONE owner of the port; the module's own legacy second writer was removed with +the re-brand purge, and `:8443` is the same instance's opt-in listener — this +script only clears that listener, `:804-805`). **D3 — `br-mgmt` serves DHCP.** `dhcp.mgmt` (`interface 'mgmt'`), so the operator's laptop gets an address and the router has a lease to resolve it by — @@ -246,7 +262,7 @@ pinned to it.** `31-*.nft`, `32-*.nft` and `20-nds-enforce.nft` keep matching `iifname "br-lan"` and must **never** be extended to `br-mgmt`: a `br-mgmt` client is not a guest, and the reason the admin ports may be reachable there is that the bridge holds no stranger. The guest APs stay bound to -`network=lan` (`99-tollgate-setup:1014`), so the open SSID keeps the guards. +`network=lan` (`99-tollgate-setup:1327`), so the open SSID keeps the guards. **D8 — The requirement's paywall half is explicitly not delivered here.** The wired bridge is a management bridge: it reaches the administration surfaces @@ -254,8 +270,215 @@ before any payment, and it reaches nothing else, including the internet. See below for what it would take to make it a second *paywalled* network, and why that is not this change. +### D9-D12 — The two settings this makes operator-configurable (2026-09-26) + +> **Re-derived 2026-10-04 (rebase onto main after #605/#624/#625).** D9-D12 +> were written against a tree where the private SSID was minted from the nym +> alone, the wired LAN ports sat on the captive bridge, and the admin password +> was generated with `od`. All three premises moved; the inline notes below +> state the re-derived reading at each affected point, and the defaults are +> unchanged — each is *more* of a no-op in the new world, not less. The +> citations to `99-tollgate-setup` were re-checked against the post-#605/#625 +> file (line numbers below are the current ones). + +The operator asked for two things to be configurable rather than compiled in: +the private network's credentials, and **which network may reach the +administration surfaces** (today the answer is "whatever is not the captive +bridge", written as a literal in two fragments). Both must be settable in the +config file and from the admin board. D9-D12 record the design; the +implementation is separate PRs, and the release order is at the end. + +**D9 — The private network's SSID, passphrase and encryption become declared +values in `/etc/tollgate/config.json`, and UCI stays the runtime.** The fields +are `private_ssid`, `private_key` and `private_encryption`, flat and top-level +like every other settable scalar (a nested object gets no dot-path round-trip +test — see the settings-surface map). They are declared intent, not +configuration the service reads: hostapd starts from `/etc/config/wireless`, so +nothing consumes these keys directly. One applier +(`src/cli/operator_settings.go`) converges them onto UCI, and it runs from the +three places the value can change: after `config set`/`config save`, on +`tollgate config apply`, and at daemon start (so a hand-edited file — or a +`sysupgrade` that kept the config and regenerated UCI — converges without a +second command). The applier is a **compare-and-converge** writer, not a +write-always one: on a router whose UCI already matches, it reports `unchanged` +and does not commit UCI or bounce the wireless. That is what makes the daemon +start path safe at all — procd respawns this process, and a needless +`wifi reload` on every respawn would drop every associated client on the +private network. + +Two asymmetries are deliberate and load-bearing: + +- **An empty value is not an instruction: it means "keep what the router + has".** `private_ssid` and `private_key` ship empty, because the first values + are *minted*, device-by-device, by `setup_private_network` + (`99-tollgate-setup:1896-2055`). Re-derived for #605: the SSID is now + `-`, built from the operator's stored nym and the **one stored + device code** that also names the hostname and the captive SSID, and the + passphrase is still the urandom-seeded word list (3 words + 2 digits, one + `awk` from a single `/dev/urandom` read). The empty-means-keep default + composes with that derivation rather than fighting it: the applier writes + only declared non-empty values, and `setup_private_network` re-derives the + SSID only while it is *machine-shaped* — a value this applier wrote (or a + `tollgate network private rename`) is not machine-shaped, so the two + writers cannot disagree; conversely an operator who never sets the field + keeps the `-` the setup minted, which is exactly the value the + adoption order in #605 chose for an already-deployed router. An operator + who never opens these settings must not be migrated onto a shared default, + and an upgrade must never write an empty string over a live network — a + router with an empty SSID has no management path at all. So the migration + fills defaults **only** into the two enum-valued fields. +- **`private_encryption` ships with a value (`psk2+ccmp`) rather than empty**, + because the module has always owned that one: `99-tollgate-setup` writes the + literal on every full setup pass (`:1983`, `:1996` — re-checked 2026-10-04), + so there is no operator value to preserve and the declared default is + exactly what the router already has (the applier sees no drift and does + nothing). The consequence, stated rather than implied: a mode changed by + hand in `/etc/config/wireless` is reverted by the applier, which is + stricter than today only between two full setup passes. + +The enum is `psk2+ccmp` (default), `psk2+tkip+ccmp`, `psk-mixed+ccmp` — all +servable by the wpad the image installs. **SAE/WPA3 is deliberately absent**: +the shipped `wpad-basic-*` has no SAE support, so offering it would produce a +private network that does not come up, i.e. a lockout presented as a feature. +An open (`none`) mode is absent for a stronger reason: the private SSID is the +management network, and the board's login on `:8090` is a root-capable login +over cleartext HTTP (`31-*.nft:9-17`), so "encryption: none" would publish it. + +**D10 — `admin_access` selects which network may reach the administration +surfaces.** One flat field, four values, spelled as the operator asked: + +| `admin_access` | Administration ports (`:8090/:8443`, `:8080/:443`) | +|---|---| +| `both` (default) | reachable from `br-private` and `br-mgmt`; no rule is written | +| `br-private` | reachable from the private bridge only; `br-mgmt` is dropped | +| `br-mgmt` | reachable from the wired management bridge only; `br-private` is dropped | +| `loopback-only` | reachable from `lo` only; every other interface is dropped | + +> Re-derived for #625 (2026-10-04): `br-private` is no longer only the private +> SSID — since the wired LAN ports moved onto it (#625, the minimal release +> path that superseded #607), it is the private SSID **and the physical cable**, +> i.e. every administration path the shipped stack has. `both` therefore means +> "the private SSID plus the wired ports plus loopback, exactly where the +> guards already allow" — still no rule written, still a no-op on upgrade. The +> `br-mgmt` column describes #601's future bridge; no shipped image has one, +> which is why the refusal below exists. + +The value is enforced by **one generated fragment**, +`/etc/nftables.d/34-admin-access-scope.nft`, written by the same applier. It +is generated and never shipped: the package owns no file at that path, so an +`apk upgrade` cannot restore a stale scope, and the file's *absence* means +exactly "the default scope, which adds no rule". The number is **34**, not +33, because open #601 ships a static `33-mgmt-bridge-scope.nft`; whoever +lands second takes the later number, and this PR is the one that could +choose (the review note that asked for the coordination). + +Four properties of this design are what the reviewer should check first: + +1. **The default changes nothing on the wire.** `both` renders nothing and + removes a fragment left by a previous non-default value, so shipping this + feature is a no-op until an operator asks for something else. It is also + why `both` — not `br-mgmt` — is the default: `br-mgmt` would drop + `br-private` — since #625 the private SSID *and* the cable — and (on a + `sysupgrade -n` that regenerates the port list, the residual case) that is + every management path gone at once. +2. **It removes reach, never grants it.** The rules are `drop`s on a hook-input + chain at priority `-1` — the same seam and priority `31-*.nft`/`32-*.nft` + use. A drop in an early base chain is not undone by a later accept, so the + setting is honoured even where D4's `br-mgmt` allow list accepts the same + ports. Nothing in the fragment is an `accept`, and nothing widens the + implicit accept side (fw4's zone input policies and loopback), so no value + of this setting can open a surface that is closed today. +3. **The captive bridge is not in the fragment at all.** `br-lan` (or whatever + the captive bridge is named) loses the admin ports unconditionally, by + invariant 2, in `31-*.nft`/`32-*.nft`. Listing it here as well would + double-count every dropped packet in the operator's diagnostics and would + suggest the setting governs the guest block. It does not: **no value of + `admin_access` can make the guest bridge reach the board**, and + `planAdminScope` has no `br-lan` case at all. +4. **`br-mgmt` is refused while that bridge does not exist.** Naming it drops + `br-private`, which since #625 is the private SSID **and the physical LAN + ports** — i.e. every administration path the shipped stack has; before #625 + the same argument ran through "the cable is on the bridge invariant 2 + drops", and a `sysupgrade -n` that regenerates the port list is the one + residual case where that older phrasing still applies. The applier + therefore refuses, leaves the scope in force untouched, and says why; + config.json still records what the operator asked for, so the setting + takes effect as soon as the bridge exists. This is the repo's own lesson + from the `redirect_https` lockout, applied before the fact rather than + after. + +`loopback-only` is the one value expressed as an exception +(`iifname != "lo"`) rather than as a list of names, because it is the one value +that has to cover interfaces the module has never heard of. It is also the one +value that tightens beyond the bridges this repo creates — an operator +administering over a VPN, a `wwan` uplink or a container bridge loses the admin +ports there too. That is what "loopback-only" means; the field's description +says so. + +**D11 — The private passphrase is write-only, and the settings API sits behind +the board's session.** Three invariants, each of which had to be built rather +than asserted: + +- **No read path returns the passphrase.** The schema marks `private_key` + `secret: true`, and `config get` — the payload the board's Settings page + renders and merges into a wholesale save — blanks it and reports + `secret_set.private_key` instead, so the UI can say "set" without being told + what. `config set` does not echo it back either (`Set private_key (value + withheld)`). A read that cannot be trusted with the value is not new here: + `tollgate network private status` has always printed it to a root console + over the `0660` control socket, and that console stays as it was. +- **A wholesale save cannot erase it.** `config save` replaces the whole file, + and the board's payload is *by construction* one with the secret blanked — + so an empty incoming value means "unchanged" for every operator setting + whose empty value already means "keep what the router has". Without that + rule the first unrelated edit made from the board (say, a mint's price) + would silently clear the WPA key of the management network. It is tested, + with the exact payload shape the board sends. +- **The API is post-authentication.** Both settings are carried by + `config_set`/`config_save`, which sit in the `tollgate` ACL group behind a + board session — the `unauthenticated` group still grants exactly + `["auth_status"]`. Nothing about the passphrase is exposed to a pre-auth + caller, and the passphrase is never a parameter of any pre-auth route. The + rpcd method map does not change. + +One consequence of the applier living in the daemon: `tollgate network private +rename|set-password|set-encryption` now also records the value in config.json. +Without that, the two writers would disagree and the applier would put the old +value back at the next daemon start — the CLI's own change reverting itself is +worse than either writer alone. The three commands also now write **both** +private radios through one helper and refuse to write a section that does not +exist (`uci set` on a missing section creates a typeless one), which closes the +radio-drift the settings-surface map recorded. + +**D12 — What this does not deliver.** Stated so it is not read as covered: + +- **Nothing here sells internet on `br-mgmt`.** D8 stands: the wired bridge is + a management bridge. F1/F2 remain the only paths to a paywalled wired + network. +- **The nodogsplash pre-auth allow list is not touched.** The module's + `assert_nodogsplash_allow_entries` still removes `:8090/:8443/:8080/:443` + from `users_to_router`, and the feed's `92-tollgate-admin-setup` still adds + `allow tcp port 8090|8443` on install — a contradiction this change does not + resolve, because the nft guards are the layer that does not depend on the + list. Making the two writers agree is its own change with its own blast + radius (three writers, two repos). +- **The board's WiFi page is repointed in the portal repo**, not here: it edits + one `wifi-iface` section at a time by raw UCI, so editing a private radio + there would be reverted by the applier. That page and the new Settings + surface land together in the portal PR. +- **The feed re-vendors the portal bundle** only after the portal change is + merged: a portal-source change ships nothing until the staged bundle is + rebuilt from the merged commit (the pin-vs-shipped-bytes rule). + ### The half that cannot work as specified +**Scope, amended 2026-09-28.** This section is about a **second *gated*** +bridge — a network that must be paywalled and carry its own pre-auth policy at +the same time. It is not about the operator's requirement, which needs no +second gate and *is* satisfiable by a configuration change; see the amendment +at the end of this record. Everything below stands as written for the +two-gated-bridge case. + **The stack cannot gate two bridges at once, so "admin surfaces pre-auth *and* still captured by the gate and paywall on the same bridge" cannot be delivered by configuration.** One nodogsplash process manages one interface @@ -321,6 +544,26 @@ alone removes the lockout and the L2 exposure; it does not sell anything. `:2121`) is unreachable from it while the stack can gate only one bridge. 5. **Nothing about the guest path changes.** The guest APs stay on the captive bridge, the portal, `:80` stub and pre-auth list are untouched. +6. **No value of `admin_access` can make the captive bridge an administration + path.** `br-lan` (the bridge the guest APs are on) is dropped by + `31-*.nft`/`32-*.nft` unconditionally, and the generated scope fragment + never names it — the setting's enum has no such value. +7. **The private passphrase is write-only.** No read route, no response body + and no log line carries it; a read path reports only whether one is set. +8. **Both shipped defaults are no-ops on the wire.** `admin_access=both` + writes no fragment and removes a stale one; `private_ssid`/`private_key` + ship empty, which the applier treats as "keep what the router has". A + router that upgrades onto this release and is administered exactly as + before behaves exactly as before. + +**Amended 2026-09-28 - how to read invariants 1-5 after the amendment at the +end of this record.** Invariants 1-5 hold unchanged in substance. Where an +invariant names the guards' *present expression* (invariant 2 cites +`31-*.nft:52-53` and `32-*.nft:54-55` as "unchanged"), the amendment replaces +that expression — the drop is re-keyed from the bridge name to the guest VAP +interfaces, in a `bridge`-family rule — while keeping the property the +invariant states: no admin surface is guest-reachable, and neither port set is +added to `users_to_router`. ## Consequences @@ -329,7 +572,7 @@ alone removes the lockout and the L2 exposure; it does not sell anything. - The operator's reported lockout ends, without weakening a single guard: the admin surfaces answer on a bridge that holds only his own devices. - The wired port leaves the guest's L2 domain — the exposure - `99-tollgate-setup:974-993` recorded as unfixable by bridge-port isolation, + `99-tollgate-setup:1280-1305` recorded as unfixable by bridge-port isolation, using the mechanism that comment itself named. - The customer path is untouched: the portal, the pre-auth list, the paywall of the wireless network and the two guards keep their present behaviour, so none @@ -359,7 +602,11 @@ alone removes the lockout and the L2 exposure; it does not sell anything. port between `br-mgmt` / `br-private` / the public-guest bridge — is out of scope here; D1's writer is written as one place that computes "which bridge the wired ports are on", so that field becomes a parameter rather than a - second implementation. + second implementation. (D10 adds a *different* field, `admin_access`, which + decides which network may reach the administration surfaces. It does not + move a port: it only removes reach from the bridges it does not name, with + `both` as the default so that the wired bridge D1 creates keeps the + administration reach D4 gives it.) ## Alternatives rejected, and why @@ -453,7 +700,7 @@ hardware. Both tiers are listed. it stands*: the shim in `tests/uci-defaults-private-subnet_test.sh:74` answers `show` with `:` (a silent no-op), while the repo's own idiom for enumerating `network` sections is `uci show network | awk` - (`99-tollgate-setup:1469`) — a writer using it would see no ports and take + (`99-tollgate-setup:2002`) — a writer using it would see no ports and take the fail-loudly branch on every fixture. The implementing PR must teach the shim `show`/`get`, or seed flat `network.@device[N].ports` keys in the fixture, and prove the negative in the same change (assertion 2). @@ -495,7 +742,7 @@ per the repo's deploy rules)** before any purchase: `:8090` answers (board), `:8080` answers (LuCI) and `:443` completes a TLS handshake. `:8443` answers **only when the opt-in listener exists** — it is written by the feed's `92-tollgate-admin-setup` and - this script only clears it (`99-tollgate-setup:804-805`), so on a router with + this script only clears it (`99-tollgate-setup:1117-1118`), so on a router with no TLS identity there is no listener and a correct build must not be failed for its absence. `:22` answers **only if dropbear listens on the `mgmt` network**: nothing in `99-tollgate-setup` configures SSH, so that dependency @@ -529,6 +776,66 @@ client" and that this is the guard, not TLS. After this change that is true of a **guest-SSID** client and of a `br-lan` client, and false of the wired client, which now reaches the board and LuCI and has no internet. +**For D9-D12 (the operator settings).** 18-22 are offline and must be runnable +with no router, which is where they are: the scope plan and the fragment +renderer are pure functions with two injectable seams (`ifacePresent`, +`nftablesDir`), and the secret contract is exercised through the real config +manager in a temp dir. + +18. **The default is a no-op on the wire.** `admin_access=both` renders the + empty fragment, writes no file, and removes one left by a previous value; + a second run reports `unchanged` (`TestRenderAdminScopeFragmentDefaultIsEmpty`, + `TestApplyAdminAccessWritesThenConverges`). +19. **Every non-default scope renders rules that only drop, at the guards' + own seam** — one rule per address family, `hook input priority -1`, + `policy accept`, the four admin ports, and **no `br-lan` anywhere in a + rule** (`TestRenderAdminScopeFragmentShape`, `TestRenderAdminScopeFragmentLoopback`). +20. **`br-mgmt` is refused while the bridge does not exist, and a refusal + changes nothing** — the fragment in force is byte-identical afterwards + (`TestPlanAdminScope`, `TestApplyAdminAccessRefusalLeavesTheRouterAlone`). + The negative control is the same test with the interface present, which + must be accepted. +21. **The passphrase never comes back.** `config get` returns a `*Config` + whose `private_key` is empty plus `secret_set.private_key=true`; + `config set private_key` does not echo the value in its message or its + data; a wholesale `config save` of the exact payload the board sends + (secret blanked) preserves the stored passphrase and still applies the + unrelated edit in the same payload + (`TestHandleConfigSetWithholdsSecretValue`, + `TestHandleConfigSavePreservesStoredSecret`, `TestRedactSecretFields`). +22. **The config chain still gates, both ways.** The four new keys are in the + schema, flat and editable, with the enum the ADR states; the schema/struct + drift tests, `defaults_parity_test.go` and `tests/contract/js-schema-lint.mjs` + pass; an unknown `admin_access` or `private_encryption` is refused on the + `config set` path **and** on the wholesale `config save` path, which + bypasses per-key validation (`TestSchemaOperatorNetworkFields`, + `TestValidateValueRejectsUnknownEnums`, `TestHandleConfigSaveValidatesOperatorEnums`). + +**Bench, for D9-D12** (same box, same single-owner rules; carried by the same +card that measures 9-17, and required before the settings are documented as +working rather than as designed): + +23. **Setting the scope from the board moves the wire, and unsetting it moves + it back.** Re-derived 2026-10-04 for #625 — the wired client is on + `br-private` now, not `br-mgmt`, so the moving leg must use a value that + drops it: with `admin_access=loopback-only`, `nft list chain inet fw4 + admin_access_scope` shows the `iifname != "lo"` drop and a wired client + cannot reach `:8090` (nor can a private-SSID client); back to `both`, the + chain file and the chain are gone and the wired client reaches it again. + (With `admin_access=br-private` the wired client *keeps* `:8090`: the + drop names `br-mgmt`, which no shipped image has. The pre-#625 reading — + "a wired client cannot reach `:8090`" under `br-private` — described + #601's world and is no longer measurable on main.) +24. **The board reaches the surface it is configuring.** A change made from + the board's Settings page survives a reboot and is still in effect + (i.e. the applier at daemon start agrees with what the board wrote). +25. **The passphrase round-trips without ever being read.** Setting a new + private passphrase from the board associates a client with it on the + private SSID, and the value appears in no response the board receives. +26. **The guest is unaffected.** From a MAC the router has never seen on the + open SSID, `:8090`/`:8443`/`:8080`/`:443` are dropped and the portal still + sells, for every value of `admin_access`. + ## Rollout / PR sequence 1. **This document** (docs-only): the decision, the evidence, the rejected @@ -544,6 +851,20 @@ which now reaches the board and LuCI and has no internet. 4. **Then, separately, F1 or F2** if a *paywalled* wired network is still wanted. That decision is not made here, and `br-mgmt` must not be described as a customer network until it lands. +5. **The two operator settings (D9-D12), module half** (one PR): the four + `config.json` fields with their schema, the applier + (`src/cli/operator_settings.go`), the redaction and preserve-on-save rules, + the `config apply` and `network private set-encryption` verbs, the daemon + start-path convergence, this document's D9-D12 section, and the offline + tests 18-22. The shipped defaults are no-ops, so this PR can land before + anything depends on it. +6. **The board half** (portal repo, one PR): the Settings surface for all four + fields (the secret rendered as a write-only input that says "set", the two + enums as selects), and the WiFi page's private-radio edit repointed from raw + UCI to `config_set` so the two writers cannot disagree. +7. **Bench measurement (23-26), then the release cut**: the feed re-vendors the + portal bundle from the merged portal commit and re-pins the module, and the + settings are exercised on the box before they are documented as working. ## Notes @@ -561,9 +882,11 @@ which now reaches the board and LuCI and has no internet. fixed-chain, single-socket facts above are all v5.0.2 facts. - **Why this is an ADR and not a config change.** The repo's rule is decision first (`docs/architecture/`), and this one has a fact in it the requester did - not have: the request as worded cannot be satisfied by the stack as built. The - right time to learn that is before the change, not from a router whose gate has - been torn down on one bridge and left absent on the other. + not have: a **second *gated* bridge** cannot be delivered by the stack as + built, and the amendment at the end of this record narrows the record to + exactly that. The right time to learn that is before the change, not from a + router whose gate has been torn down on one bridge and left absent on the + other. - **What is unchanged, deliberately**: `users_to_router` (`99-tollgate-setup:1108-1124`), the `:8080`/`:443` removal logic, the `:80` trusted stub (`setup_uhttpd_trusted_entry`), `20-nds-enforce.nft`'s mark values @@ -578,3 +901,168 @@ which now reaches the board and LuCI and has no internet. of their own, that bridge can reach the administration surfaces before paying, and it cannot also be a network that sells internet — not with one nodogsplash, and not with two instances on one router. + +## Amendment - the requirement is satisfiable by a configuration change (2026-09-28) + +**Update 2026-09-28 (operator-approved design; a docs-only amendment to this +record).** The verdict above is **narrowed, not withdrawn**. "…the request it +was written for is not fully satisfiable by a configuration change on the +shipped stack" remains **correct as applied to a second *gated* bridge**, and +everything this record says about that case stands unchanged in substance: the +one-interface nodogsplash (`src/conf.h:146`), the fixed `nds*` chain names in +one network namespace, the name-scoped `iptables_fw_destroy()` on the start +path, the procd respawn loop, alternatives A1/A2/A3 and the F1/F2 follow-ups. +What was wrong was **extending that verdict to the operator's requirement**. + +The requirement recorded in Context — **a wired-port client must still be +required to PAY for WAN access, and must be able to reach LuCI +(`:8080`/`:443`) and the admin/config UI (`:8090`/`:8443`)** — *is* satisfiable +by a configuration change on the shipped stack: no `br-mgmt`, no second +bridge, no second nodogsplash instance, no Go change. What follows is the +mechanism, the measured constraint that shapes it, and what it does and does +not fix. + +**AM-1 - key the two admin-port drops on the guest VAP interfaces, not on the +bridge.** Both guards drop on `iifname "br-lan"` today — +`31-*.nft:52-53` for `{8090, 8443}`, `32-*.nft:54-55` for `{8080, 443}`, both +families, counters kept. The wired port is a member of `br-lan` (see "Where the +wired LAN ports are bound, and therefore who may move them"), so the +operator's own cable matches those rules: that is the reported lockout. +Re-keying the drop to the **guest VAP interfaces** changes the *match +expression* and nothing else: + +- the wired port (`eth1` on this hardware, or whatever port the base image puts + on `br-lan`) stops matching, so `:8090`/`:8443` and `:8080`/`:443` become + reachable from it, pre-auth; +- the wireless guests keep matching — the guest BSSes stay bound to the captive + bridge (`99-tollgate-setup:1327` sets `wireless..network='lan'`), so + both guards keep dropping them, and the #566/#588 behaviour together with its + pre18 bench measurement is unchanged; +- **the wired port stays a member of the gated `br-lan`** + (`99-tollgate-setup:1476` writes + `nodogsplash.@nodogsplash[0].gatewayinterface='br-lan'`), so + `20-nds-enforce.nft:33` and nodogsplash's marks still apply to it: it is + still redirected by the portal, still has to pay, and gets WAN only after + payment. The commerce path, the portal and the pre-auth list are untouched. + +This is the mechanism this repo already names in its own words — "a dedicated +br-guest with its own reject-by-default zone, or a **bridge-family nft rule +keyed on the VAP ports**" (`99-tollgate-setup:1304-1305`). It is also why +`br-mgmt` is not needed *for the requirement*: the guard was over-broad, not +the bridge. + +**AM-2 - the re-key is a port-keyed rule, not a string swap (measured).** +`iifname` does not name the same device in both nftables families, and the +shipped guards live in fw4's `inet` table. Measured in a network namespace on a +Linux host (kernel `7.0.0-34-generic`, `nftables v1.1.6`, `br_netfilter` +loaded with `bridge-nf-call-{iptables,ip6tables,arptables}=1`, a bridge `br0` +with one port and traffic delivered to the bridge's own IP address): + +- in an **`inet`**-family `input` hook, `iifname` is the **bridge**: a rule + keyed on the bridge name matched every packet (2/2), and the same rule keyed + on the bridge *port* matched **0**; +- in a **`bridge`**-family `input` hook, `iifname` is the **bridge port**: the + bridge name matched 0, the port name matched all of them, and a + port-keyed `drop` there blocked the traffic (drop counter incremented, ping + failed). + +Consequences, stated plainly because they invert the naive reading: + +- inside the existing `inet` fragments, `iifname "br-lan"` is what matches + *every* bridged client, wired and wireless alike — not a defect, just the + expression doing what its name says; +- substituting the VAP names into `31-*.nft`/`32-*.nft` as they stand would + match **nothing**: both guards would become silent no-ops and the *guests* + would reach the board and LuCI. That is a worse state than the lockout, and + it fails open with no error anywhere; +- the re-key therefore moves the drops to a **`bridge`-family, port-keyed** + rule, which is where the port name is visible. Same policy, same ports, both + families, counters kept — a different key, in the family that can express it. + +This measurement settles the match semantics of the mechanism, not the shipped +rule: the bench pass is still owed (wired client reaches `:8090`/`:8443` and +`:8080`/`:443` pre-auth **and** is still redirected and pays; a wireless guest +is still dropped; it survives `fw4 reload`, reboot and sysupgrade), exactly as +the assertion list above requires. + +**AM-3 - the interface set is derived, and the rule must fail closed.** The VAP +names are not stable: `phyN-apM` follows radio/PHY enumeration and can change +across firmware or hardware, so a guard keyed on four hardcoded names can stop +matching after an upgrade and become a no-op with no error anywhere. The set is +therefore derived at `fw4 reload` from the module's own source of truth — the +wireless interfaces bound to `network='lan'` (`99-tollgate-setup:1327`) — and +an empty or failed enumeration **falls back to the blanket `br-lan` drop with a +log line**, i.e. to today's behaviour (safe, still gated) rather than to no +drop at all. The derivation, the fallback, its negative control and the +boot-time assertion are tracked and pinned by t_8590499a below; this record +requires that they exist, because they are what keeps AM-1 from decaying into a +silent hole. + +**AM-4 - `br-mgmt` REMAINS PROPOSED, on its own separate merit.** Nothing here +withdraws D1-D8 or the bridge proposal, and re-keying the guards does **not** +close the exposure this record exists for: the wired port keeps sharing one +broadcast domain with strangers — ARP, mDNS, SSDP, broadcast — and it keeps +doing so on the *same* bridge as the open guest SSID. AM-1 narrows *who may +reach the administration surfaces*; it does not separate *who may see whom*. +That separation is what D1 buys, and it is why the two changes are independent: +AM-1 is about the guard's scope, D1 is about the wired port's L2 domain. The +paywall half of a **second** network (D8, F1/F2) is exactly as unreachable as +before — a wired *customer* network still needs a second gate, while under AM-1 +a wired client pays on the same gate the guests use. + +**AM-5 - the title's "half".** Read "the half of that request the shipped stack +cannot deliver" in this record's title as *the second gated bridge*, not as the +operator's requirement. Where the body says the request cannot be satisfied by +the stack as built, it means the two-gated-bridge case; AM-1 is the +configuration change that satisfies the requirement as stated. + +**AM-6 - line references re-checked at the commit this amendment branches from +(`54e8c3683e3b583298410946dc8eb804e846d93d`).** The two guard citations are +still exact (`31-*.nft:52-53`, `32-*.nft:54-55`). The citations to the +isolation comment that names the VAP-keyed alternative had **drifted**: +`99-tollgate-setup:974-993` was that comment when this record merged (#600) and +is now the admin credential gate, so both citations are corrected here to +`99-tollgate-setup:1280-1305` (the comment block that carries the measurement, +with the named mechanism at `:1246-1247`). The re-check was then extended to +every `99-tollgate-setup` citation in this record, and the drift was not +confined to that comment: nineteen further references had been written against +an older revision of the script and now pointed at unrelated content — the +allow-list assert (`:1108-1124` → `:1370-1457`, three sites), the +`gatewayinterface` write (`:1214` → `:1476`), the `:2121` zone rule +(`:1427-1441` → `:1697-1704`), the LAN-device read (`:1517-1519` → +`:1779-1781`), the `network.lan.domain`/`ip6assign` writes (`:867`/`:1463` → +`:1121`/`:1725`), the private network/bridge/dhcp creation (`:1693-1707` → +`:1956-1967`), the `uci show network | awk` idiom (`:1469` → `:1731`, two +sites), the `private → wan` forwarding (`:1734-1743` → `:2004-2006`), the +uhttpd listener citations (`:337-339,443-448` → `:591-593,697-702`; +`:364-365` → `:617-619`; `:646-648` → `:899-902`; `:689-709` → `:956-971`), +the guest-SSID `network=lan` binding (`:1014` → `:1268`), the 802.11-harvest +quote (`:969-972` → `:1224-1225`) and the `:8443` clear (`:804-805` → +`:1055-1059`). All are corrected to the ranges that carry the cited facts at +the branch commit. The external binding claim is +unchanged: `openwrt/openwrt` `openwrt-25.12` +`target/linux/mediatek/filogic/base-files/etc/board.d/02_network:158-166` still +groups `glinet,gl-mt3000` into `ucidef_set_interfaces_lan_wan eth1 eth0`, and +`/bin/config_generate:109-119` still turns that into a `br-lan` device whose +`ports` list carries the wired port. + +**AM-7 - a note folded in at the D9-D12 rebase (2026-10-04).** #625 landed +on main after this amendment was written, as the **minimal release path** for +the same requirement: `setup_lan_ports_private` moves the base image's wired +port list onto `br-private` (guards untouched), superseding #607's full role +machinery for this release and leaving `br-mgmt` (D1-D8, #601) to land after +it. That is a *third* mechanism beside AM-1's re-key and D1's bridge, and it +chose differently on purpose: a cabled client is owner-class (internet, no +payment), with paying-wired scoped post-release. Consequences for this +record: the wired administration path in D10's table is `br-private` (see the +re-derived note there); the re-key of AM-1 remains what it was — a proposal +on card t_8590499a — and nothing here prefers it over what shipped. + +**Tracking.** The implementing work is card **t_8590499a** on the +`tollgate-module-basic-go` board ("Re-key the admin-port guards from br-lan to +the VAP interfaces (wired client: pays + admin, guests: still dropped)"), which +carries AM-1 to AM-3 as its design and the bench verification as its definition +of done. Its PR is open separately and is the place a landed mechanism will be +cited from; this record stays the decision. Status stays **Proposed**: +acceptance is a maintainer action and the drafting account may not accept its +own proposal. diff --git a/docs/architecture/one-device-code.md b/docs/architecture/one-device-code.md index 22cbed663..3a678c556 100644 --- a/docs/architecture/one-device-code.md +++ b/docs/architecture/one-device-code.md @@ -60,8 +60,9 @@ claimed to match (see "The nym charset differs between the two writers below"): 1. `tollgate.device.code` from `/etc/config/tollgate` (validated as exactly four `[A-Z0-9]`; a junk value is re-derived, never trusted). -2. a **machine-shaped hostname** (`tollgate-OQ3Q`, `TollGate-OQ3Q`, - `Net4sats-OQ3Q`) — this is what the installer has always written. +2. a **machine-shaped hostname** (`tollgate-OQ3Q`, `TollGate-OQ3Q`, or a + whitelabel build's `-OQ3Q`) — this is what the installer has always + written. 3. a **machine-shaped captive SSID** (`TollGate-OQ3Q`, `tollgate-0GLK`). 4. **mint** — four characters of `[A-Z0-9]` from `/dev/urandom`, BusyBox `hexdump` idiom (no `od` on the target). @@ -77,7 +78,8 @@ collecting a third one. That is what heals the bench box on the next install. default). It is stored in the same section so both writers agree, and it is adopted from an existing machine-shaped private SSID, so a module deployed under another nym keeps it. It is used for the **private** SSID only — the captive SSID -keeps the brand prefix (`TollGate-` / `Net4sats-`), because reseller-mode +keeps the brand prefix (`TollGate-` / a whitelabel build's `-`), because +reseller-mode upstream discovery in `src/wireless_gateway_manager` matches `"TollGate-*"` **case-sensitively** (`discovery_log.go`, `vendor_element_manager.go`, `upstream_manager.go`). diff --git a/docs/architecture/uhttpd-redirect-https-ownership-decision.md b/docs/architecture/uhttpd-redirect-https-ownership-decision.md index 1a96e6c92..5af7f818c 100644 --- a/docs/architecture/uhttpd-redirect-https-ownership-decision.md +++ b/docs/architecture/uhttpd-redirect-https-ownership-decision.md @@ -99,14 +99,20 @@ login itself was never broken: `POST /ubus session.login` over `:8443` and ## Consequences - Neither script may hardcode this option again: both evaluate the rule above. - **The feed's `92-tollgate-admin-setup` still carries the superseded - existence-only guard and must be updated to the same rule** — this repository - cannot change it. ~~Until it is, correctness depends on install order: `99` - runs after `92` and lands the coverage-checked value last, so the shipped - combination is safe.~~ **Superseded by the measured install order further - down this record: `92` is the *last* writer, so install order does not save - this — the operator-visible defect stands until the feed's copy evaluates the - same premise.** + **This module's `92-tollgate-admin-setup` does since its portal pin advanced to + `4158030`** (portal #64/#65; the pin is resolved and asserted by + `tests/packaging/assert-portal-bundle-contract.sh` CHECK F). **The feed + repository's vendored copy still carries the superseded existence-only guard + and must be updated to the same rule** — this repository cannot change it + (re-verified 2026-09-27: + `net/tollgate-wrt/files/uci-defaults/92-tollgate-admin-setup` still derives + `uhttpd.main.redirect_https` from the readability of `/etc/uhttpd.crt`). + ~~Until it is, correctness depends on install order: `99` runs after `92` and + lands the coverage-checked value last, so the shipped combination is safe.~~ + **Superseded twice: by the measured install order further down this record + (`92` was the *last* writer at install time), and then by the module's postinst + being moved onto the boot order. The operator-visible defect therefore stands + only on the feed's install path, until that copy evaluates the same premise.** - `99-tollgate-setup` **provisions** the router's TLS identity instead of inheriting the image's placeholder: on both the full-setup and the verify/repair path it drives the module's own generator @@ -141,17 +147,27 @@ login itself was never broken: `POST /ubus session.login` over `:8443` and - The feed's companion change adds a fail-open post-restart check that turns the redirect back off when no listen socket exists on `:443`. Both writers must be updated together whenever this rule changes. -- **Install order is NOT a safety net, and here is the measured order.** - `packaging/Makefile`'s postinst runs the uci-defaults explicitly as - `90-tollgate-captive-portal-symlink`, `99-tollgate-setup`, `92-tollgate-admin-setup` - — so on the module's own install/upgrade pass **`92` is the LAST writer of - `uhttpd.main.redirect_https`**. At boot they run numerically (`90, 92, 99`), so - `99` is last there. A writer that kept the superseded existence-only premise - therefore wins on the install pass: a router whose identity cannot be validated - (provisioning refused, or the image's placeholder as the fallback listener - identity) is derived to `0` by `99` and then put back to `1` by `92`, which is - the operator-visible defect this rule was hardened for. The two writers must - evaluate the same rule; they cannot rely on who runs last. +- **Install order is not a safety net — and the module's postinst now uses the + boot path's order.** `/etc/init.d/boot` applies the uci-defaults numerically + (`90, 92, 99`); `packaging/Makefile`'s postinst used to run the same scripts as + `90, 99, 92`, so the LAST writer of `uhttpd.main.redirect_https` differed + between the install pass and the boot pass — an install converged to whatever + that order produced and only the next reboot re-ran them numerically. It now + runs `90, 92, 99`, so the value an install lands is the value the next boot + produces, whatever either script decides, and + `tests/packaging/uci-defaults-run-order_test.sh` pins the order (with the + pre-change order as its negative control). This is convergence, not + correctness: whichever of the two writers runs LAST decides the option, so both + must evaluate the same rule — this document's. The module's pinned + `92-tollgate-admin-setup` does (CHECK F above); the feed's vendored copy does + not, and its recipe still runs `92` last, so the operator-visible defect stands + on the feed's install path until that copy carries the same premise. +- A writer that kept the superseded existence-only premise demonstrated why the + rule is stated as a premise and not as an order: a router whose identity cannot + be validated (provisioning refused, or the image's placeholder as the fallback + listener identity) is derived to `0` by `99` and was then put back to `1` by an + existence-only `92` whenever `92` ran last, which is the operator-visible + defect this rule was hardened for. ## Product decision: what answers the captive side (2026-09-26) diff --git a/docs/host-mode/admin-surface-decision.md b/docs/host-mode/admin-surface-decision.md new file mode 100644 index 000000000..ca6517dfd --- /dev/null +++ b/docs/host-mode/admin-surface-decision.md @@ -0,0 +1,130 @@ +# Admin surface for Linux host mode — decision record + +Linux host mode (the deb lane) needs a written answer to "where is the admin +web UI?" so no later session re-litigates it. This record pins the decision, +the options that were considered, the minimal contract any phase-2 admin API +must satisfy, and the consequences of shipping phase 1 without one. + +Scope: Linux host mode only. Nothing here changes the OpenWrt package. + +## Decision + +**DEFERRED to phase 2, not cancelled.** + +Phase 1 of Linux host mode ships **no admin surface**: no admin SPA, no +admin HTTP listener, no admin port, no rpcd-style shim. The `tollgate` CLI +is the only operator interface. The release design already reserves +`/usr/share/tollgate/admin` as **PHASE 2 ONLY** (RELEASE-linux-deb.md §2.3, +the deb file list), so deferral is the outcome the design itself encodes: +the directory is a reservation, and nothing serves it in phase 1. + +A phase-2 admin surface is deferred behind an explicit operator trigger — +the operator declares phase 2 — and any implementation of it **must** +satisfy the contract in [The phase-2 contract](#the-phase-2-contract) +below. + +> RELEASE-linux-deb.md belongs to the packaging lane and is not yet merged +> in-tree; §2.3 is cited from the design cards that carry it. Re-check this +> section against the merged file when the packaging lane lands. + +## Options + +**Option (a) — deferred (chosen).** Phase 1 ships CLI-only; phase 2 may add +an admin surface under the minimal contract below. + +Why it wins: + +- Zero new attack surface in phase 1: no second listener, no port, no + token store to protect, nothing to harden or audit. +- Every phase-1 operator need is already covered by existing seams — the + CLI over the daemon's Unix socket (config, wallet, private network, + service control) and the ndsctl shim verbs (session grant/revoke, + session counters). +- It contradicts nothing: the design's file list already earmarks the + admin directory for phase 2 rather than omitting it. +- Cost is real but bounded: the headless-box UX described under + [Consequences](#consequences). + +**Option (b) — not planned (rejected).** Cancel the admin surface outright: +CLI is the permanent interface, the reservation is dropped. + +Rejected because it buys nothing today — both options ship no admin surface +in phase 1, so the only difference is option (b) additionally forecloses a +path the design deliberately kept open. Cancelling is a strictly stronger +commitment for zero phase-1 gain. If the operator later wants cancellation +after all, this record is the place to amend; until then the answer is +"deferred, contract below", not "no". + +## The phase-2 contract + +A future admin API is only acceptable if it satisfies all three of these. +Anything broader (more verbs, network exposure, a second service) needs its +own decision record superseding this one. + +**1. Verbs — exactly four at introduction, each mapped to a seam that +already exists:** + +| Verb | Kind | Parity | +|------|------|--------| +| `status` | read | CLI `status` + `wallet balance` / `wallet info`: service health, wallet summary | +| `sessions` | read | ndsctl shim `json` surface: active client sessions with per-MAC counters | +| `session grant/revoke` | write | ndsctl shim `auth` / `deauth`: gate a MAC on demand | +| `config set` | write | CLI `config set`: set and apply one configuration key | + +Money movement (`wallet fund`, drain, Cashu payout) is deliberately **not** +in the initial verb set: irreversible wallet operations stay on the +CLI / AF_UNIX path, behind interactive confirmation. + +**2. Auth model — the CLI's trust boundary, never anonymous.** Default is +an AF_UNIX socket with file-permission auth, exactly the existing CLI +server model (`/var/run/tollgate.sock`, mode 0660: the daemon user owns it, +a group grants CLI access). Any TCP exposure is opt-in and additionally +requires a bearer token minted into `/etc/tollgate/` — the directory the +packaging design keeps across both upgrade and purge. No unauthenticated +admin verb, in any configuration. + +**3. Where it listens — loopback only at introduction.** An AF_UNIX socket +(preferred) or `127.0.0.1` at most; never a new port bound on a +non-loopback interface. The admin SPA bytes, when they exist, are served +from `/usr/share/tollgate/admin` by the same listener that terminates the +API — one socket, not two. Phase 1's only HTTP listeners stay the +client-facing ones: the money-path API on `:2121` and the captive portal +(`:2051` SPA, `:2050` stub, per the portal lane). + +## Consequences + +What phase 1 actually ships with, stated plainly: + +- **SSH plus the CLI is the whole admin story.** Every administrative + action on a host-mode box is `ssh` in, then `tollgate ...`: `config get` + / `config set` (routed through the running daemon over the Unix socket), + `wallet balance|info|fund`, drain, `private enable|disable|rename| + set-password`, `upstream` management, and service start/stop/restart. +- **On a headless box this means:** no browser on the box and no remote + dashboard — a non-technical operator cannot administer the gateway + without a shell account on it. Granting or revoking a client session + outside the payment flow is `ndsctl auth|deauth ` on the box (or an + SSH session running it), not a dashboard button. Diagnostics are + `journalctl` and CLI output, not a status page. +- **Automation has no admin endpoint to scrape.** Monitoring and alerting + must consume CLI `--json` output or journald; there is deliberately no + admin HTTP surface to enumerate in phase 1. +- **The reservation stays empty.** `/usr/share/tollgate/admin` carries no + bytes and no listener in phase 1; nothing may depend on its existence + yet, and its absence is not an error condition. + +These are accepted costs of the deferral, not gaps to quietly fill. + +## Not claimed + +- No SPA was authored, no rpcd shim built, no HTTP listener added, no port + opened — all explicitly out of scope for this record; it changes only + documentation. +- The phase-2 contract is a constraint set for a future implementer. It is + not an implementation, not a schedule, and not operator approval to + build phase 2. The revisit trigger is the operator declaring phase 2. +- No UX research backs the four-verb set beyond mapping them onto seams + that already exist in the codebase and the host-mode design lanes. +- RELEASE-linux-deb.md §2.3 is cited from the design cards; the file + itself is not yet in-tree (packaging lane). This record must be + re-checked against the merged §2.3 when that lane lands. diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 0c72aca25..19f72430d 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -37,12 +37,18 @@ tollgate upstream scan Scan all radios for networks tollgate upstream connect [pass] Connect to an upstream network tollgate upstream list Show configured upstream STAs tollgate upstream remove Remove a disabled upstream STA +tollgate upstream known Show discovered TollGate APs tollgate config get Print current config + identities tollgate config set Set one value by dot-path tollgate config schema Print the full config schema tollgate config save Replace config.json wholesale tollgate config save-identities Replace identities.json wholesale + +tollgate ssl status Admin HTTPS identity and coverage +tollgate ssl apply [ [key]] Install a real cert, or self-signed +tollgate ssl remove Revert an apply (records an opt-out) +tollgate ssl covers [] Does the certificate cover this router? ``` Every command accepts the global `--json` (`-j`) flag for @@ -348,6 +354,31 @@ Private network password changed successfully new_password: Alpha-Bravo-Charlie-42 ``` +### Change the encryption mode + +```sh +tollgate network private set-encryption psk2+tkip+ccmp +``` + +Supported modes: + +| Mode | Meaning | +|---|---| +| `psk2+ccmp` | WPA2-PSK with AES/CCMP. The default and what the router is set up with. | +| `psk2+tkip+ccmp` | Also offers the legacy TKIP cipher, for old clients that cannot do AES. | +| `psk-mixed+ccmp` | Also accepts WPA1 clients. | + +WPA3-SAE is deliberately not offered: the `wpad` the router ships with has no +SAE support, so selecting it would leave the private network unable to start — +a lockout, not a feature. An open (unencrypted) mode is refused for the same +class of reason: the private network is how you reach the admin board, whose +login is a root-capable login over plain HTTP on `:8090`. + +The three private-network commands above (rename, set-password, set-encryption) +also record the new value in `/etc/tollgate/config.json`, which is the file the +service reconciles the router onto at every start. Both radios are written +together, so the 2.4 GHz and 5 GHz SSIDs cannot drift apart. + ## Upstream WiFi management Upstream Wi-Fi is how the router reaches the internet — either from @@ -439,6 +470,28 @@ upstreams cannot be removed — disable or switch away first. This is housekeeping: removing an entry does not affect connectivity, it just stops the daemon from ever considering that SSID again. +### Known TollGates + +```sh +tollgate upstream known +``` + +A summary of the TollGate access points discovered across the scans +the background [wireless gateway +manager](wireless_gateway_manager.md) has logged — the same history a +reseller-mode router uses to recognise upstream TollGates. Prints a +headline (`3 TollGates discovered across 27 scans`) and one entry per +access point: SSID and BSSID, first and last seen, best and worst +signal, sample count, the advertised pricing (`price_per_step`, +`step_size`) when the AP publishes it, and gateway RTT and probe +statistics when probes ran. + +The history lives in `/etc/tollgate/discovery_log.jsonl` and is +reloaded at startup, so the summary also covers scans from before the +last reboot. The plain-text rendering prints the raw fields in no +stable order; `tollgate --json upstream known` returns the same data +with stable field names for scripting. + ## Configuration management TollGate stores its configuration in `/etc/tollgate/config.json` and @@ -481,6 +534,50 @@ Most `config set` changes take effect only after `tollgate restart` (or `/etc/init.d/tollgate-wrt restart`), because the running service reads the file at startup. +Two settings are the exception, and they are the reason `config set` also +reports what it applied: + +```sh +tollgate config set private_ssid c08r4d0r-7F3A +tollgate config set admin_access br-private +``` + +``` +Set private_ssid = c08r4d0r-7F3A; runtime: 1 applied, 1 not applicable here +``` + +The private network's SSID, passphrase and encryption live in UCI +(`/etc/config/wireless`), which hostapd reads, and `admin_access` is an +nftables property. Neither can be honoured by a file the service merely reads, +so `config set`/`config save` write them to the router as well, and say for +each setting whether the runtime was changed (`applied`), already matched +(`unchanged`), could not be applied on this host (`skipped`), or was refused +with the reason (`refused` — e.g. `admin_access=br-mgmt` on a router that has +no `br-mgmt` bridge yet). See the README's +[Network settings](../README.md#network-settings-v009) for the values. + +Setting `private_key` from the CLI never echoes the passphrase back. + +### Make the router match the file + +```sh +tollgate config apply +``` + +Re-applies every declared operator setting — the private-network credentials +and the admin-access scope — and reports per setting what happened. The same +work runs automatically after `config set`/`config save` and at service start, +so this verb is for a `config.json` you edited by hand: + +``` +Operator settings converged (runtime: unchanged, not applicable here) +``` + +It is a converging writer: a router whose runtime already matches the file +reports `unchanged` and nothing on the wire moves. That is what makes it safe +on a service restart — it never drops your private-network clients for a change +that was already in effect. + ### Inspect the schema ```sh @@ -509,6 +606,92 @@ a router from a known-good template. Both commands reload the in-memory config after writing, but a restart is still needed for the change to take full effect. +## SSL / HTTPS management + +These commands manage the HTTPS identity of the **admin path** — the +LuCI interface uhttpd serves on the management network. The captive +portal itself keeps its HTTP interception and is not affected. The +`ssl` commands run locally (they talk to uci and the filesystem), so +unlike most of the CLI they do not need the TollGate service to be +running. + +### Check status + +```sh +tollgate ssl status +``` + +When an identity is configured, prints where it came from +(self-signed or real), the domain, the certificate's subject, issuer, +validity window and SANs, and — the part that decides whether the +`:8080` → `https://` hop is safe — whether the certificate **covers +this router**. When it is not configured, the command distinguishes +the two very different causes: nobody ever provisioned an identity, +or an operator ran `ssl remove` and the install path is holding that +decision. It also names the certificate uhttpd is actually serving +and why it does not cover the router — a stock OpenWrt image ships a +placeholder (`CN=OpenWrt`) that covers no router's own name or +address, which is what a browser shows a hard certificate error for. + +### Apply a certificate + +```sh +tollgate ssl apply # generate a self-signed certificate +tollgate ssl apply combined.pem # one file holding cert + key +tollgate ssl apply cert.pem key.pem # separate certificate and key +``` + +Without arguments, generates a self-signed certificate for the +router's own hostname (RSA 2048, valid ten years, SANs for the +hostname, its `.lan` alias, and the LAN IP). A browser will +still show a warning for it — self-signed is for encryption, not for +trust. With file arguments, installs a real certificate; a combined +PEM is split automatically, and an expired certificate is warned +about but installed if you confirm. + +`apply` prints the plan first (install to `/etc/tollgate/ssl/`, +point uhttpd at the new cert/key, allow TCP 443 through the +pre-authentication firewall for the admin listener) and asks for +confirmation; `-y` skips the prompt. The previous state is backed up +to `/etc/tollgate/ssl/backup/` so `ssl remove` can restore it — if a +backup already exists, `apply` warns and asks before overwriting. +Afterwards uhttpd and nodogsplash are reloaded (dnsmasq too, for a +real certificate), and `redirect_https` is derived from the +[coverage check](#check-whether-a-certificate-covers-this-router) — +the same rule the unattended setup path applies. `--no-restart` +leaves the service reload to the caller; the setup path uses it +because uci-defaults runs before the services start. + +### Remove the identity + +```sh +tollgate ssl remove +``` + +Restores the state from before the last `apply` (uhttpd's previous +certificate, the firewall entry). Removal is also **recorded** in +`/etc/tollgate/ssl/tls-identity-removed`: a reinstall or upgrade will +not silently re-key a router whose operator asked for no HTTPS +identity. Running `ssl apply` ends the opt-out — asking for an +identity is the way back in. + +### Check whether a certificate covers this router + +```sh +tollgate ssl covers # the certificate uhttpd is configured to serve +tollgate ssl covers /path/to/cert.pem # a candidate before installing it +``` + +A certificate covers the router when its SANs validate the configured +hostname, the `.lan` alias, or the LAN IP — the CommonName +alone is not enough, because browsers ignore it once the certificate +carries no SAN extension, and an expired certificate is not coverage +either. The exit status is `0` when it covers and `1` when it does +not, with the reason printed either way, so a shell can branch on it; +the setup path derives `redirect_https` from exactly this verdict. +Under `--json` the verdict is one object whose `success` mirrors the +exit status, so a caller parsing stdout cannot read a "no" as green. + ## JSON output Every command accepts a global `--json` (or `-j`) flag. With it, the diff --git a/docs/rc-tester-guide.md b/docs/rc-tester-guide.md index 641d7934f..5271d730b 100644 --- a/docs/rc-tester-guide.md +++ b/docs/rc-tester-guide.md @@ -440,7 +440,11 @@ identity is self-signed and carries the router's hostname, its `.lan` alias and its LAN address; the `:8080` → `https://` hop is enabled **only** while the certificate uhttpd serves actually covers the address you used. So: -- Log in at **`https://.lan/`** (or `https:///`). Expect the +- Log in at **`https:///`** — the LAN address is in the certificate's SANs + and needs no resolver, so it is the URL to start from. `https://.lan/` + should work as well (setup writes the dnsmasq entry for the alias), but if that + name does not resolve on your network, use the address: an unresolved alias is + a DNS answer, not a certificate problem. Expect the usual browser interstitial for a self-signed certificate — *"Your connection is not private"* / *"Not secure"* — and proceed through it. That is the expected warning, and it is not a defect worth reporting on its own. diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md index d2f6d023c..4a6cbc180 100644 --- a/docs/reproducible-builds.md +++ b/docs/reproducible-builds.md @@ -41,6 +41,16 @@ up in an artifact derives from it: Override by exporting `SOURCE_DATE_EPOCH` before any build script; that also enables strict-inputs mode (`TG_STRICT_INPUTS=1`). +A build with no git metadata — an ngit-ci `act` job checkout, a clean-root copy +— gets the same commit timestamp through `scripts/ngit-commit-epoch.sh`, which +fetches *exactly that commit* (depth 1) from the git transport the build came +from. That script is the only supported CI derivation, and it **fails** rather +than substituting the runner clock: a wall-clock epoch makes two builds of one +commit differ, which is the one thing this document claims cannot happen. The +ngit release lane (`.ngit/act/workflows/build-package-binaries.yml`, +`determine-versioning` → `build-portal`, and the shards' `resolve-inputs`) calls +it for exactly that reason. + ## Where everything lives - `packaging/build-inputs.json` — the manifest of pinned inputs. diff --git a/packaging/Makefile b/packaging/Makefile index 3428f9085..033ee525f 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -95,9 +95,21 @@ wait_for_iface() { return 1 } +# The uci-defaults run in NUMERIC order — the order the boot path uses +# (/etc/init.d/boot applies them 90, 92, 99). The postinst used to run 90, 99, 92 +# instead, so the LAST writer of uhttpd.main differed between the install pass and +# the boot pass: an install converged to whatever that order produced until the +# next reboot re-ran them numerically, which is a divergence nobody sees until the +# reboot. Both scripts derive uhttpd.main.redirect_https — 99-tollgate-setup from +# certificate COVERAGE, and 92-tollgate-admin-setup (staged from the pinned portal +# tree) from the same coverage rule since the pin advanced to 4158030, guarded by +# tests/packaging/assert-portal-bundle-contract.sh CHECK F. Running them in the +# order the boot path uses is what makes an install converge to the state the next +# boot would produce, whatever either script decides. +# Pinned by tests/packaging/uci-defaults-run-order_test.sh. for script in /etc/uci-defaults/90-tollgate-captive-portal-symlink \ - /etc/uci-defaults/99-tollgate-setup \ - /etc/uci-defaults/92-tollgate-admin-setup; do + /etc/uci-defaults/92-tollgate-admin-setup \ + /etc/uci-defaults/99-tollgate-setup; do if [ -x "$$script" ]; then echo "Running $$script ..." "$$script" || echo "Warning: $$script exited with code $$?" diff --git a/packaging/files/etc/nftables.d/30-backend-firewall.nft b/packaging/files/etc/nftables.d/30-backend-firewall.nft index d3e75374f..466554463 100644 --- a/packaging/files/etc/nftables.d/30-backend-firewall.nft +++ b/packaging/files/etc/nftables.d/30-backend-firewall.nft @@ -1,23 +1,37 @@ #!/usr/sbin/nft -f -# Restrict backend API (port 2121) to LAN interfaces only. +# Restrict backend API (port 2121) to the LAN bridges the module serves. # -# The TollGate backend listens on :2121 for payment processing. WiFi -# clients on br-lan need access (NDS users_to_router allows tcp 2121). -# This rule blocks port 2121 from all non-br-lan interfaces, preventing -# WAN-side / upstream clients from directly probing the API. +# The TollGate backend listens on :2121 for payment processing. Two client +# classes legitimately reach it, and both are bridges of this router: +# +# - br-lan — the captive bridge. A guest pays through the portal, and the +# portal SPA calls :2121 directly (nodogsplash's pre-auth list carries +# `allow tcp port 2121`). +# - br-private — the operator's bridge. The owner-facing admin board is +# served on this network (31-admin-board-not-guest-reachable.nft keeps it +# off the captive bridge) and the board is a thin shell over this API: its +# data layer fetches pricing, whoami, session balance and ln-invoice from +# http://:2121/. Without the exemption the board renders and every +# value in it fails ("error fetching tollgate data" / "lightning capability +# probe failed"), which is what the owner network saw. +# +# Everything else is dropped: WAN-side / upstream clients must not probe the +# API directly. That is the point of the rule and the reason the exemption list +# names bridges, never a physical or uplink interface. # # CLI access is unaffected (uses Unix domain socket, not TCP). # Outbound probing of upstream TollGates is unaffected (output, not input). # # See: https://github.com/OpenTollGate/tollgate-module-basic-go/issues/226 -# Note: assumes br-lan interface name. Operators who rename their -# bridge will lose API access (same as all other TollGate firewall rules). +# Note: assumes the shipped bridge names, br-lan and br-private. Operators who +# rename a bridge will lose API access from it (the same assumption 31- and 32- +# make about the captive bridge, and every other TollGate firewall rule makes). chain backend_input_firewall { type filter hook input priority -1; policy accept - meta nfproto ipv4 iifname != { "br-lan", "lo" } tcp dport 2121 counter drop - meta nfproto ipv6 iifname != { "br-lan", "lo" } tcp dport 2121 counter drop + meta nfproto ipv4 iifname != { "br-lan", "br-private", "lo" } tcp dport 2121 counter drop + meta nfproto ipv6 iifname != { "br-lan", "br-private", "lo" } tcp dport 2121 counter drop } diff --git a/packaging/files/etc/uci-defaults/99-tollgate-setup b/packaging/files/etc/uci-defaults/99-tollgate-setup index ac0c4861c..e5130aa22 100755 --- a/packaging/files/etc/uci-defaults/99-tollgate-setup +++ b/packaging/files/etc/uci-defaults/99-tollgate-setup @@ -301,30 +301,68 @@ setup_marker_decision() { # -- setup steps ------------------------------------------------------------ -# load_brand: whitelabel selector — 'tollgate' (default) or 'net4sats'. The -# two brands differ only in naming; the brand file is written by whitelabel -# installers before first boot, and everything derived from it — system -# hostname (and thus .lan DNS), AP SSIDs, NDS gateway name — -# follows this single selector. +# load_brand: the whitelabel selector. `/etc/tollgate/brand` — written by a +# whitelabel installer before first boot — holds ONE token naming the build's +# brand; 'tollgate' when the file is absent. Everything derived from it follows +# this single selector: the system hostname (and thus .lan DNS), the +# AP SSIDs, and the NDS gateway name. +# +# GENERIC ON PURPOSE. The brand is a build INPUT, never a literal in this repo: a +# re-brand ships its own image, docroot and naming from its own organisation and +# passes its token here. Hard-coding a second brand is exactly how this module +# grew a brand-gated SECOND :8090 admin writer (a legacy configUI section that +# fought the portal-staged board for the port), so any single alphanumeric token +# is accepted and nothing below names a particular brand. # # Called AFTER the log truncation in both the short and full paths: anything -# logged before `: > "$LOGFILE"` is wiped, which silently ate the -# coercion line when this ran at the top of the script. +# logged before `: > "$LOGFILE"` is wiped, which silently ate the coercion line +# when this ran at the top of the script. # # read -r strips the trailing newline and leading/trailing IFS whitespace # (verified on BusyBox ash). Do NOT use `tr -d '[:space:]'` here: BusyBox tr # treats the character class as a literal set and deletes every s/p/a/c/e -# from the value — "net4sats" became "nt4t" and coerced to the default. +# from the value — a nine-character brand collapsed to a four-character +# fragment and coerced to the default. load_brand() { read -r BRAND < /etc/tollgate/brand 2>/dev/null || BRAND="" case "$BRAND" in - net4sats) BRAND_HOSTNAME="Net4sats" ;; - *) BRAND_HOSTNAME="TollGate" - [ -n "$BRAND" ] && log "Brand '${BRAND}' not recognized — coercing to default 'tollgate' (expected: net4sats)" - ;; + # A brand reaches a hostname and an SSID: one alphanumeric token only. + # Anything else — an empty file, stray whitespace, punctuation — is not + # a brand and falls back to the default. + ''|*[!A-Za-z0-9]*) + [ -n "$BRAND" ] && log "Brand '${BRAND}' is not a single alphanumeric token — coercing to the default 'tollgate'" + BRAND="tollgate" + ;; + esac + # The display spelling. 'TollGate' is THIS project's own spelling and is + # pinned; any whitelabel token is spelled by capitalising its first letter, + # so a new brand needs no edit here (and this repo carries no other brand's + # name to copy from — a whitelabel build's own spelling is its own build + # input, and the installer and this script both read the same brand file). + case "$BRAND" in + tollgate) BRAND_HOSTNAME="TollGate" ;; + *) BRAND_HOSTNAME="$(printf '%s' "${BRAND%"${BRAND#?}"}" | tr 'a-z' 'A-Z')${BRAND#?}" ;; esac } +# The brand token this router runs, lowercased — the spelling a machine-written +# name carries (hostnames are lowercased; the captive SSID keeps BRAND_HOSTNAME). +# Falls back to reading /etc/tollgate/brand directly, so the adoption helpers +# below also behave when this script is sourced on its own (the offline tests), +# and to 'tollgate' when there is no brand file at all: an absent brand file must +# behave exactly like the default build. +brand_token() { + local brand="${BRAND:-}" + if [ -z "$brand" ]; then + read -r brand < /etc/tollgate/brand 2>/dev/null || brand="" + fi + brand=$(printf '%s' "$brand" | tr 'A-Z' 'a-z') + case "$brand" in + ''|*[!a-z0-9]*) brand="tollgate" ;; + esac + printf '%s' "$brand" +} + # -- the router's device code, minted once and never re-minted ---------------- # # The DEVICE CODE is four characters of [A-Z0-9], minted exactly once, stored in @@ -393,17 +431,18 @@ normalize_device_code() { esac } -# The code carried by a machine-written name: "tollgate-OQ3Q", "TollGate-OQ3Q", -# "Net4sats-OQ3Q". Any other name — an operator's own hostname or SSID — yields -# nothing, which is what keeps custom names out of the adoption path. +# The code carried by a machine-written name: "-", where is +# the default 'tollgate' or THIS router's own brand token (brand_token above) — +# the same prefix the installer and this script write into the name. Any other +# name — an operator's own hostname or SSID — yields nothing, which is what keeps +# custom names out of the adoption path. A whitelabel name is recognized because +# the brand file says so, never because a brand is listed here. code_from_name() { - local name="$1" prefix suffix + local name="$1" prefix suffix brand prefix=$(printf '%s' "${name%%-*}" | tr 'A-Z' 'a-z') suffix=${name#*-} - case "$prefix" in - tollgate|net4sats) : ;; - *) return 0 ;; - esac + brand=$(brand_token) + [ "$prefix" = "tollgate" ] || [ "$prefix" = "$brand" ] || return 0 normalize_device_code "$suffix" } @@ -505,21 +544,19 @@ private_ssid_for_code() { # the operator named. The full path still writes the brand's name, which is what # it has always done. captive_ssid_for_code() { - local current="$1" prefix suffix + local current="$1" prefix suffix brand if [ -z "$current" ]; then printf '%s\n' "$DEVICE_SSID" return 0 fi prefix=$(printf '%s' "${current%%-*}" | tr 'A-Z' 'a-z') suffix=${current#*-} - case "$prefix" in - tollgate|net4sats) - if [ "$suffix" != "$current" ] && is_minted_suffix "$suffix"; then - printf '%s\n' "$DEVICE_SSID" - return 0 - fi - ;; - esac + brand=$(brand_token) + if { [ "$prefix" = "tollgate" ] || [ "$prefix" = "$brand" ]; } && + [ "$suffix" != "$current" ] && is_minted_suffix "$suffix"; then + printf '%s\n' "$DEVICE_SSID" + return 0 + fi printf '%s\n' "$current" } @@ -663,13 +700,26 @@ cert_covers_router() { "$TOLLGATE_CLI" ssl covers "$cert" >/dev/null 2>&1 } +# Is this cert/key pair on disk AND non-empty? Readability alone is satisfied by +# a file uhttpd cannot parse, and empty is what makes uhttpd crash-loop when it +# sees listen_https with no TLS configuration behind it. +tls_pair_readable() { + [ -n "${1:-}" ] && [ -r "$1" ] && [ -s "$1" ] && + [ -n "${2:-}" ] && [ -r "$2" ] && [ -s "$2" ] +} + # uhttpd.main's TLS identity, and the redirect derived from it. # -# The identity is the PROVISIONED certificate when it exists — that is the one -# built to cover this router — and the image's own cert/key pair otherwise, as a -# fallback so a router that cannot provision still gets its :443 listener instead -# of losing it. The image's certificate is never treated as an identity though: -# nothing but coverage turns the redirect on. +# Candidates, best first: the identity uhttpd.main ALREADY presents, the identity +# this script provisions, then the image's own cert/key pair as a bare listener +# fallback. A pair that COVERS this router wins outright, whichever candidate it +# is — but when it is the configured one, it belongs to the OPERATOR: a CA-signed +# certificate installed by hand, or the pair their own +# `tollgate ssl apply ` installed, must survive an install rather than +# be replaced by the self-signed identity generated here. Only when no candidate +# covers the router is the first READABLE pair used, which keeps the :443 listener +# alive on a router that cannot be verified. The image's certificate is never +# treated as an identity: nothing but coverage turns the redirect on. # # uhttpd.main.redirect_https is a DERIVED value, not a configured one: LuCI's # :8080 is redirected to the TLS listener only when that listener can actually @@ -680,18 +730,43 @@ cert_covers_router() { # same value derived from a weaker premise). See # docs/architecture/uhttpd-redirect-https-ownership-decision.md. setup_uhttpd_tls_identity() { - local cert="" key="" + local cert="" key="" covers="0" + local configured_cert configured_key pair candidate_cert candidate_key + + configured_cert="$(uci -q get uhttpd.main.cert 2>/dev/null)" + configured_key="$(uci -q get uhttpd.main.key 2>/dev/null)" + + # Pass 1: the first candidate that covers this router. + for pair in "$configured_cert|$configured_key" \ + "$PROVISIONED_CERT|$PROVISIONED_KEY" \ + "$UHTTPD_IMAGE_CERT|$UHTTPD_IMAGE_KEY"; do + candidate_cert="${pair%%|*}" + candidate_key="${pair#*|}" + tls_pair_readable "$candidate_cert" "$candidate_key" || continue + if cert_covers_router "$candidate_cert"; then + cert="$candidate_cert" + key="$candidate_key" + covers="1" + break + fi + done - # Only enable HTTPS when cert and key are readable and non-empty; otherwise - # uhttpd crash-loops because it sees listen_https but has no TLS config. - if [ -r "$PROVISIONED_CERT" ] && [ -s "$PROVISIONED_CERT" ] && - [ -r "$PROVISIONED_KEY" ] && [ -s "$PROVISIONED_KEY" ]; then - cert="$PROVISIONED_CERT" - key="$PROVISIONED_KEY" - elif [ -r "$UHTTPD_IMAGE_CERT" ] && [ -s "$UHTTPD_IMAGE_CERT" ] && - [ -r "$UHTTPD_IMAGE_KEY" ] && [ -s "$UHTTPD_IMAGE_KEY" ]; then - cert="$UHTTPD_IMAGE_CERT" - key="$UHTTPD_IMAGE_KEY" + # Pass 2: nothing covers this router (no CLI to ask, no identity yet, or only + # the image's placeholder). The first readable pair still arms the :443 + # listener — uhttpd crash-loops on listen_https with no TLS config behind it — + # and the derived value below stays 0, so :8080 is never pointed at an + # identity nobody checked. + if [ -z "$cert" ]; then + for pair in "$configured_cert|$configured_key" \ + "$PROVISIONED_CERT|$PROVISIONED_KEY" \ + "$UHTTPD_IMAGE_CERT|$UHTTPD_IMAGE_KEY"; do + candidate_cert="${pair%%|*}" + candidate_key="${pair#*|}" + tls_pair_readable "$candidate_cert" "$candidate_key" || continue + cert="$candidate_cert" + key="$candidate_key" + break + done fi uci -q delete uhttpd.main.listen_https @@ -704,7 +779,7 @@ setup_uhttpd_tls_identity() { uci set uhttpd.main.key="$key" fi - if [ -n "$cert" ] && cert_covers_router "$cert"; then + if [ "$covers" = "1" ]; then uci set uhttpd.main.redirect_https='1' log "uhttpd.main TLS identity ${cert} covers this router — redirect_https=1" else @@ -733,6 +808,8 @@ setup_uhttpd_tls_identity() { # installed. Neither is an error — the router keeps whatever identity it has and # the derived redirect stays off unless that identity covers it. provision_tls_identity() { + local configured_cert + # An operator who removed the identity asked for a router that serves no # identity. `tollgate ssl remove` records that decision and this honours it: # the uhttpd contract is still repaired below, but the router is not @@ -742,6 +819,19 @@ provision_tls_identity() { return 0 fi + # The identity uhttpd.main already presents is the OPERATOR's when it covers + # this router: a CA-signed certificate installed by hand, or the pair their own + # `tollgate ssl apply ` installed. Re-keying it would replace a + # trusted certificate with a self-signed one on every reinstall — so + # provisioning is idempotent on the CERTIFICATE uhttpd.main serves, not on the + # path this script happens to write. An unverifiable or non-covering identity + # (the image's placeholder included) is not the operator's and is replaced. + configured_cert="$(uci -q get uhttpd.main.cert 2>/dev/null)" + if [ -n "$configured_cert" ] && cert_covers_router "$configured_cert"; then + log "uhttpd.main already serves a covering identity (${configured_cert}) — nothing to provision" + return 0 + fi + if [ -r "$PROVISIONED_CERT" ] && [ -s "$PROVISIONED_CERT" ] && [ -r "$PROVISIONED_KEY" ] && [ -s "$PROVISIONED_KEY" ] && cert_covers_router "$PROVISIONED_CERT"; then @@ -918,57 +1008,57 @@ EOF # # uhttpd.main's docroot is /www, and LuCI's /www/index.html meta-refreshes # to cgi-bin/luci — so ANY port added to uhttpd.main serves "redirect to -# LuCI". Repair attempts that add 8090 there (instead of creating the -# dedicated configUI section below) produce exactly the reported -# ":8090 redirects to LuCI instead of the configUI" symptom. Runs in both -# setup paths so the misconfiguration cannot survive a reinstall. +# LuCI". Repair attempts that add 8090 there produce exactly the reported +# ":8090 redirects to LuCI instead of the board" symptom: the board is reached +# on a section whose docroot IS the board (uhttpd.admin, below), never through +# LuCI's. Runs in both setup paths so the misconfiguration cannot survive a +# reinstall. sanitize_uhttpd_main_configui_port() { uci -q del_list uhttpd.main.listen_http='0.0.0.0:8090' uci -q del_list uhttpd.main.listen_http='[::]:8090' } -# Whitelabel config UI on :8090 (net4sats brand). -# -# The whitelabel admin/config UI lives in /www/net4sats — installed by the -# whitelabel installer, never shipped by this package — and is served by a -# DEDICATED uhttpd section, never by uhttpd.main (see -# sanitize_uhttpd_main_configui_port). Mirrors the known-good deployed -# layout exactly so upgrades of already-configured routers converge -# instead of duplicating sections. Only created when the branded docroot -# actually exists; stock tollgate routers get nothing on :8090. -# -# Deliberately NOT added to nodogsplash users_to_router: the config UI is -# owner-facing and reached over the private network (br-private, not -# NDS-gated). Pre-auth public-SSID guests must not reach an admin UI. -setup_uhttpd_configui() { - # Self-contained brand check: read /etc/tollgate/brand directly (written - # by whitelabel installers) so this repair works regardless of whether - # the surrounding script defines a BRAND variable. - local cfgui_brand - # read (not `tr -d '[:space:]'`): busybox tr on some OpenWrt builds - # lacks character classes and would delete the literal chars - # [ : s p a c e — mangling "net4sats" into "nt4t". POSIX read strips - # leading/trailing IFS whitespace and the trailing newline itself. - read -r cfgui_brand < /etc/tollgate/brand || cfgui_brand="" - [ "$cfgui_brand" = "net4sats" ] || return 0 - [ -d /www/net4sats ] || return 0 - - uci -q get uhttpd.net4sats >/dev/null 2>&1 || uci set uhttpd.net4sats=uhttpd - local cfgui_listen - cfgui_listen=$(uci -q get uhttpd.net4sats.listen_http 2>/dev/null || echo "") - if ! echo "$cfgui_listen" | grep -q "0.0.0.0:8090"; then - uci add_list uhttpd.net4sats.listen_http='0.0.0.0:8090' - fi - if ! echo "$cfgui_listen" | grep -q "\[::\]:8090"; then - uci add_list uhttpd.net4sats.listen_http='[::]:8090' - fi - uci set uhttpd.net4sats.home='/www/net4sats' - uci set uhttpd.net4sats.ubus_prefix='/ubus' - uci set uhttpd.net4sats.script_timeout='60' - uci set uhttpd.net4sats.network_timeout='30' - uci set uhttpd.net4sats.max_requests='3' - uci set uhttpd.net4sats.tcp_keepalive='1' - log "Whitelabel configUI uhttpd section ensured on :8090 (home=/www/net4sats)" +# Is this uhttpd section one this module writes or co-exists with? uhttpd.main +# is LuCI's, uhttpd.portal serves the captive SPA, uhttpd.trusted is the :80 +# entry point, and uhttpd.admin is the portal-staged admin board — the ONLY +# :8090 owner. Anything else on the router was written by another build. +owned_uhttpd_section() { + case "$1" in + main|portal|trusted|admin) return 0 ;; + esac + return 1 +} + +# Exactly ONE uhttpd section owns :8090: the portal-staged admin board +# (uhttpd.admin — home = the build's admin webroot, written by the feed's +# 92-tollgate-admin-setup and staged by packaging/portal-build.sh). This package +# writes NO :8090 listener of its own. +# +# It used to: a legacy whitelabel configUI writer created a DEDICATED second +# section on :8090, gated on the brand file and a branded docroot, for whichever +# brand was installed. That writer is gone — a re-brand ships its board from its +# own organisation now — but a router upgraded from a build that carried it still +# has that section, and two sections claiming :8090 are a bind fight in which one +# of the two admin UIs disappears (D4 of +# docs/architecture/default-ui-and-entry-port-decision.md). +# +# The stale section is DELETED, not merely port-stripped: the portal-staged 92 +# already strips :8090/:8443 from every other section, so a box that once ran the +# legacy writer keeps a branded uhttpd instance with NO listener — invisible to +# any port-based check, and still a docroot this build does not serve. +# +# So a section is judged by SHAPE (a uhttpd instance this module does not own), +# never by a brand literal: this tree carries no brand name, and the same sweep +# catches the next second writer anyone adds. Idempotent — a converged router has +# nothing to delete. Runs in BOTH setup paths, because the router that carries +# the stale section is exactly the one taking the verify/repair path. +purge_foreign_configui_sections() { + local section + for section in $(uci -q show uhttpd 2>/dev/null | sed -n 's/^uhttpd\.\([^.]*\)=uhttpd$/\1/p'); do + owned_uhttpd_section "$section" && continue + uci -q delete "uhttpd.$section" + log "Deleted stale uhttpd section '${section}' — it is not this build's board owner (uhttpd.admin)" + done } # -- admin credential gate --------------------------------------------------- @@ -1027,12 +1117,18 @@ admin_credential_state() { # 20 characters from a 32-character alphabet — no l/o/0/1 look-alikes and # nothing a shell or a password prompt would eat — read straight from the -# kernel CSPRNG. 256 is an exact multiple of 32, so the byte->character -# mapping is uniform. +# kernel CSPRNG. The mapping is uniform: each byte is reduced modulo 32; the +# 32-alphabet size means the bias is exactly 0 because 256 = 32 * 8. +# Historically this called `od`, but OpenWrt 25.12.5 images ship a stripped +# busybox where `od` is not built in (and `hostname` is absent too), so `od` +# produced no output, the generator returned an empty string, and no root +# password could be applied. `hexdump` is present on those images and is +# already used elsewhere in this script (mint_device_code, random_octet). generate_admin_password() { local alphabet="abcdefghijkmnpqrstuvwxyz23456789" # pragma: allowlist secret - od -An -N 20 -tu1 /dev/urandom | awk -v a="$alphabet" \ - '{ for (i = 1; i <= NF; i++) printf "%s", substr(a, ($i % 32) + 1, 1) }' + hexdump -n 20 -e '20/1 " %u"' /dev/urandom 2>/dev/null \ + | awk -v a="$alphabet" \ + '{ for (i = 1; i <= NF; i++) printf "%s", substr(a, ($i % 32) + 1, 1) }' } # BusyBox `passwd` reading the new password twice from stdin is the @@ -1042,13 +1138,13 @@ set_admin_password() { } # Drop every admin-board listener this package can own or repair. uhttpd.admin -# is the portal-staged :8090/:8443 instance, uhttpd.net4sats this package's own -# whitelabel configUI on the same port, and uhttpd.main is repaired because a -# stray :8090 entry there has survived reinstalls before. Nothing else is -# touched: the portal (:2050/:2051), the :80 guest entry point and the backend -# (:2121) are not admin surfaces, and LuCI's own :8080/:443 listeners are a -# separate control — they stay configured (the management path needs them) and -# are kept off the captive bridge by +# is the portal-staged :8090/:8443 instance, a foreign section on :8090 is a +# legacy second writer (purge_foreign_configui_sections DELETES it), and +# uhttpd.main is repaired because a stray :8090 entry there has survived +# reinstalls before. Nothing else is touched: the portal (:2050/:2051), the :80 +# guest entry point and the backend (:2121) are not admin surfaces, and LuCI's +# own :8080/:443 listeners are a separate control — they stay configured (the +# management path needs them) and are kept off the captive bridge by # packaging/files/etc/nftables.d/32-luci-not-guest-reachable.nft plus the # allow-list removal in assert_nodogsplash_allow_entries, not by being dropped # from uhttpd.main here. @@ -1057,8 +1153,7 @@ drop_admin_listeners() { uci -q del_list uhttpd.admin.listen_http='[::]:8090' uci -q del_list uhttpd.admin.listen_https='0.0.0.0:8443' uci -q del_list uhttpd.admin.listen_https='[::]:8443' - uci -q del_list uhttpd.net4sats.listen_http='0.0.0.0:8090' - uci -q del_list uhttpd.net4sats.listen_http='[::]:8090' + purge_foreign_configui_sections sanitize_uhttpd_main_configui_port } @@ -1318,19 +1413,22 @@ setup_public_wifi() { # writing its own. # # #444 still holds: the rewrite is one-way and known-shape-only. A hostname that -# is neither a brand default nor machine-shaped (tollgate-XXXX / Net4sats-XXXX) -# is the operator's own and is never touched. +# is neither a brand default nor machine-shaped (`-XXXX`) is the +# operator's own and is never touched. setup_hostname() { local current_hostname current_hostname=$(uci -q get system.@system[0].hostname) if [ "$current_hostname" = "$DEVICE_HOSTNAME" ]; then log "Hostname '${current_hostname}' already carries this router's device code" - elif [ "$current_hostname" = "OpenWrt" ] || [ "$current_hostname" = "TollGate" ] || [ "$current_hostname" = "Net4sats" ] || \ + elif [ "$current_hostname" = "OpenWrt" ] || [ "$current_hostname" = "$BRAND_HOSTNAME" ] || \ + [ "$current_hostname" = "TollGate" ] || \ [ -n "$(code_from_name "$current_hostname")" ]; then - # Fresh install (OpenWrt), a brand default from either brand, or a name - # a writer already built from a code (the installer's tollgate-OQ3Q) — - # all machine-managed, so they converge on THIS router's code. An - # operator's custom hostname falls to the else branch (#444). + # Fresh install (OpenWrt), a brand default (this build's BRAND_HOSTNAME — + # 'TollGate' for the default brand, kept explicitly because a whitelabel + # build's BRAND_HOSTNAME is its own token), or a name a writer already + # built from a code (the installer's tollgate-OQ3Q) — all machine- + # managed, so they converge on THIS router's code. An operator's custom + # hostname falls to the else branch (#444). uci set system.@system[0].hostname="$DEVICE_HOSTNAME" log "Hostname changed to $DEVICE_HOSTNAME (was '${current_hostname}')" else @@ -1356,8 +1454,12 @@ setup_hostname() { # allow list the previous install left behind. # # The list says what a PRE-AUTHENTICATION client may reach ON THE ROUTER, and -# nothing else belongs on it: the portal itself (:2050 NDS gateway, :2051 SPA) -# and the backend API (:2121). Every ADMIN surface — the :8090 board and LuCI's +# nothing else belongs on it: the portal itself (:2050 NDS gateway, :2051 SPA), +# the backend API (:2121) and the gateway's own NTP service (udp/123 — see +# setup_ntp_server below; a client with a wrong clock cannot pay honestly: +# Cashu proofs carry timestamps, keysets expire, sessions are time-boxed, and a +# downstream TollGate in reseller mode joins the open portal with whatever +# clock it boots with). Every ADMIN surface — the :8090 board and LuCI's # :8080/:443 — is deliberately absent and actively removed below; a guest is # answered by the portal, never by a login form. # @@ -1384,6 +1486,12 @@ assert_nodogsplash_allow_entries() { if ! uci_list_has_port 2051; then uci add_list nodogsplash.@nodogsplash[0].users_to_router='allow tcp port 2051' fi + # The gateway's own time service: the one thing a pre-auth client needs + # that is not the portal or the payment API. sysntpd must be told to + # listen (setup_ntp_server below) for this rule to answer anything. + if ! uci_list_has_port 123; then + uci add_list nodogsplash.@nodogsplash[0].users_to_router='allow udp port 123' + fi # LuCI (:8080 plain HTTP, and the :443 TLS listener uhttpd.main opens # whenever a cert/key pair exists) is an ADMIN surface, the same class as the # :8090 board below: /www/index.html meta-refreshes into /cgi-bin/luci, i.e. @@ -1414,10 +1522,9 @@ assert_nodogsplash_allow_entries() { # is the half that does not depend on this list being in the intended state. # # The admin board (uhttpd.admin: :8090 HTTP, opt-in :8443 HTTPS) is - # OWNER-facing, exactly like the whitelabel configUI in - # setup_uhttpd_configui above: the owner reaches it over the private - # network (br-private, which nodogsplash does not gate). A pre-auth guest - # on the open public SSID must not reach an admin UI, so :8090/:8443 are + # OWNER-facing: the owner reaches it over the private network (br-private, + # which nodogsplash does not gate). A pre-auth guest on the open public SSID + # must not reach an admin UI, so :8090/:8443 are # deliberately NOT part of the customer journey and are kept OUT of # users_to_router — this list is nodogsplash's PRE-AUTHENTICATION allow # list, and :8090 serves the board's root-capable login over plain HTTP @@ -1467,6 +1574,31 @@ uci_list_has_port() { printf '%s\n' "$nds_users" | grep -qE "(^|[[:space:]'])port $1([[:space:]]|'|\$)" } +# The gateway serves time to its own clients, pre-authentication (#627). +# OpenWrt's sysntpd (busybox ntpd) listens when system.ntp.enable_server is +# set — that one option is the whole server half; the client half (polling +# upstream servers) keeps whatever the image shipped. Bench-verified on the +# ws3915i fleet bring-up: units sat months off, and nothing on the open +# portal face could correct them before payment. +# +# Restart discipline follows the script's rule (see converge_nodogsplash_ +# runtime): a FRESH boot never restarts anything — procd starts sysntpd +# after uci-defaults, with this config already committed — and a RUNNING +# router gets the one cheap restart, because sysntpd reads its config once +# at start. Unlike the wireless or firewall services, bouncing sysntpd +# drops no customer: it is a stateless UDP responder. +setup_ntp_server() { + if ! uci -q get system.ntp >/dev/null 2>&1; then + uci set system.ntp=timeserver + fi + if [ "$(uci -q get system.ntp.enable_server 2>/dev/null || echo 0)" != "1" ]; then + uci set system.ntp.enable_server='1' + fi + if pidof sysntpd >/dev/null 2>&1; then + /etc/init.d/sysntpd restart >/dev/null 2>&1 || true + fi +} + setup_nodogsplash() { # The section, its pre-auth allow list and the whole-list repair live in # one place so the same-version path can call exactly the same writer. @@ -2006,6 +2138,163 @@ setup_private_network() { uci set firewall.private_forwarding.dest='wan' } +# -- the wired LAN ports: moved onto the operator's private bridge ---------- +# +# The wired LAN ports are the one network this writer MOVES. The base image +# puts them on the CAPTIVE bridge (network.lan.device, br-lan) — the same +# layer-2 domain as the open guest SSID and the captive portal — so a cabled +# client was a guest: it paid at the portal, and the two administration +# guards (31-admin-board-not-guest-reachable.nft, 32-luci-not-guest-reachable +# .nft, both `iifname "br-lan"`-literal) dropped the admin board +# (:8090/:8443) and LuCI (:8080/:443) for it. The operator's criterion for +# this release is the opposite: the physical LAN port is the operator's own +# trusted access — free internet, the admin board, and LuCI, with no payment +# step. br-private is exactly that network (firewall.private_zone is input/ +# forward ACCEPT and carries a private -> wan forwarding, and both admin +# guards answer there because they only drop on the captive bridge), so the +# ports MOVE onto it: +# +# * internet: firewall.private_forwarding (private -> wan) is written by +# setup_private_network above; +# * the admin board and LuCI answer: the guards' `iifname "br-lan"` scope +# keeps them OFF only for the captive bridge — on br-private a client is +# owner-class, deliberately (see the PR body for the trust discussion); +# * the public TollGate-* SSIDs stay on the captive bridge behind the +# portal, unchanged, and guests still cannot reach the board or LuCI. +# +# DISCOVERED, NEVER NAMED. The port list is IMAGE-OWNED (/bin/config_generate +# writes `list ports` from board.json — `eth1` on the MT3000, `lan1…lan5` +# elsewhere), so no port name appears here: the writer reads the ports the +# captive bridge's device section lists RIGHT NOW and moves that set. The +# section itself is anonymous in a shipped config, so it is located by NAME +# (bridge_device_section), never by a guessed index. +# +# IDEMPOTENT AND CONVERGENT BY CONSTRUCTION. A factory reset or a +# `sysupgrade -n` puts the ports back on the captive bridge; this writer is +# re-run on every setup path (full setup and the same-version verify/repair +# path), so a converged router is a no-op, a reset router is repaired, and a +# port is never added twice (the target's existing list is checked first). +# A port also never sits on two bridges at once: the captive section's port +# list is CLEARED after the private bridge's is written, so the move cannot +# leave a port in two layer-2 domains. +# +# FAIL LOUDLY, CHANGE NOTHING. The preconditions are checked before the +# first `uci set`: no private bridge (setup_private_network skipped — no +# usable radio), or no captive device section to read the ports from, or no +# port listed anywhere (nothing to move). Each logs an ERROR and leaves the +# ports where they are — a writer that guessed would move the operator's +# only cable access onto a network he cannot reach. +# +# UPGRADE SURVIVAL. The placement lives in /etc/config/network, which +# packaging/files/lib/upgrade/keep.d/tollgate already lists, so a settings- +# keeping sysupgrade carries it; the writer's convergence then repairs the +# reset (non-keeping) cases. + +# The device SECTION that carries . Device sections are +# anonymous in a shipped config (/bin/config_generate writes +# `add network device` + `option name 'br-lan'`), so the section is located +# by NAME, never by an index we guess. +bridge_device_section() { # -> the section path, or nothing + local want="$1" idx=0 + while [ "$idx" -lt 64 ]; do + if [ "$(uci -q get "network.@device[$idx].name" 2>/dev/null)" = "$want" ]; then + printf 'network.@device[%s]\n' "$idx" + return 0 + fi + idx=$((idx + 1)) + done + return 1 +} + +# Every port listed on
, in order, each one once. +ports_of_section() { #
+ local p seen="" + for p in $(uci -q get "$1.ports" 2>/dev/null); do + case "$seen" in + *" $p "*) continue ;; + esac + seen="$seen $p" + printf '%s\n' "$p" + done +} + +setup_lan_ports_private() { + local lan_dev private_dev captive_sec ports p target_ports landed missing from="" + + lan_dev=$(uci -q get network.lan.device 2>/dev/null) + [ -n "$lan_dev" ] || lan_dev="br-lan" + private_dev=$(uci -q get network.private_bridge.name 2>/dev/null) + [ -n "$private_dev" ] || private_dev="br-private" + + # Precondition: the private network must exist (setup_private_network + # writes the bridge; it is skipped on a router with no usable radio). + if ! uci -q get network.private_bridge >/dev/null 2>&1; then + log "ERROR: skipping the wired-LAN port move — no private bridge (network.private_bridge is absent; setup_private_network skipped?), so the wired LAN ports stay on ${lan_dev}. Nothing was changed." + return 1 + fi + + # The ports to move are DISCOVERED from the captive bridge's device + # section (image-owned, board-specific names). + captive_sec=$(bridge_device_section "$lan_dev" 2>/dev/null) + if [ -z "$captive_sec" ]; then + log "ERROR: skipping the wired-LAN port move — no device section for the captive bridge ${lan_dev} (the base image writes one from the board definition), so there is no port list to read. Nothing was changed." + return 1 + fi + ports=$(ports_of_section "$captive_sec") + target_ports=$(uci -q get network.private_bridge.ports 2>/dev/null) + if [ -z "$ports" ]; then + # MEASURE, do not infer: an empty source list is a no-op only when the + # target really lists the ports. If neither bridge lists them the + # operator's cable is on no network at all, and reporting success + # there would hide it. + if [ -n "$target_ports" ]; then + log "Wired LAN ports already on ${private_dev} — nothing to move" + return 0 + fi + log "ERROR: the wired LAN ports are on neither bridge — ${lan_dev} and ${private_dev} both list no ports, so there is nothing to move and nothing to repair. Nothing was changed." + return 1 + fi + + # Write the target first (the target is never the section cleared + # below), adding each port exactly once. A write failure is not acted on + # here: the read-back below is the authority, because a silent failure + # and a success are indistinguishable at this point. + for p in $ports; do + case " $(echo $target_ports) " in + *" $p "*) : ;; + *) + uci add_list "network.private_bridge.ports=$p" 2>/dev/null + target_ports="$target_ports $p" + ;; + esac + done + + # VERIFY BEFORE CLEARING. Clearing the source on an unverified write is + # how a port ends up on NO bridge and the operator's only cable access + # dies. Re-read the target and require every port to be present, matched + # on whole words (a target list of lan10 must not satisfy a port lan1). + landed=$(uci -q get network.private_bridge.ports 2>/dev/null) + missing="" + for p in $ports; do + case " $(echo $landed) " in + *" $p "*) : ;; + *) missing="$missing $p" ;; + esac + done + if [ -n "$missing" ]; then + log "ERROR: the wired LAN ports did not land on ${private_dev} (missing:$(printf ' %s' $missing)) — ${lan_dev} keeps its ports and nothing was cleared. The move is retried on the next setup run." + return 1 + fi + + # Then CLEAR the source: a port on two bridges is a port on two + # layer-2 domains. Safe only because the target is verified above. + uci -q delete "$captive_sec.ports" 2>/dev/null + from="$lan_dev" + + log "Wired LAN ports moved from ${from} to ${private_dev} ($(echo $ports | tr '\n' ' '))" + return 0 +} + commit_all() { uci commit system uci commit network @@ -2135,9 +2424,10 @@ if [ "$BRANCH" != "FULL" ]; then setup_uhttpd_trusted_entry # Reinstall must also repair the :8090 layout — a repair that added # 8090 to uhttpd.main (LuCI docroot) survives same-version reinstalls - # otherwise, and the configUI section is never re-ensured. + # otherwise, and a foreign second writer left by an older build is never + # removed. Both are idempotent: a converged router changes nothing. sanitize_uhttpd_main_configui_port - setup_uhttpd_configui + purge_foreign_configui_sections # The board's credential is re-asserted with the rest of the uhttpd # contract: this path runs on every reinstall whose version string is # unchanged, and a credential-less router must not keep serving an admin @@ -2170,6 +2460,21 @@ if [ "$BRANCH" != "FULL" ]; then log "nodogsplash allow list already complete — nothing to commit" fi + # The wired LAN ports' placement is re-asserted here for the same reason + # the uhttpd contract and the allow list are: the port list is IMAGE- + # OWNED, so a factory reset or a `sysupgrade -n` puts the wired ports + # back on the captive bridge — and a router that was merely reinstalled + # would leave the operator's cable back on the guest network. The writer + # is idempotent (a converged router changes nothing), and its config is + # committed only when the export actually differs. + net_before=$(uci export network) + setup_lan_ports_private + net_after=$(uci export network) + if [ -z "$net_before" ] || [ "$net_before" != "$net_after" ]; then + uci commit network + log "network config re-asserted (wired LAN ports re-placed on br-private)" + fi + # The #428 loop guard must also fire here: a device upgrading from a # pre-#432 install can carry a stale gatewaydomainname while its # SETUP_VERSION already matches (postinst and the boot hook re-run @@ -2221,18 +2526,20 @@ setup_uhttpd setup_uhttpd_portal setup_uhttpd_trusted_entry sanitize_uhttpd_main_configui_port -setup_uhttpd_configui +purge_foreign_configui_sections enforce_admin_credential setup_dns_dnsmasq setup_hostname detect_band_radios setup_public_wifi # consumes DEVICE_SSID; exports GATEWAY_NAME +setup_ntp_server # serves time pre-auth (udp/123, issue #627) setup_nodogsplash setup_tollgate_firewall_rules setup_profile_hook setup_disable_ipv6_lan enable_modems setup_private_network # consumes CODE/NYM, R2G/R5G +setup_lan_ports_private # moves the wired LAN ports onto br-private commit_all # The router's TLS identity is provisioned and re-derived LAST on this path, after diff --git a/packaging/files/man/man8/tollgate-completion-bash.8 b/packaging/files/man/man8/tollgate-completion-bash.8 index d0e404bbc..57dfc026a 100644 --- a/packaging/files/man/man8/tollgate-completion-bash.8 +++ b/packaging/files/man/man8/tollgate-completion-bash.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -69,4 +69,4 @@ You will need to start a new shell for this setup to take effect. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-completion-fish.8 b/packaging/files/man/man8/tollgate-completion-fish.8 index ad30f0bba..47132f378 100644 --- a/packaging/files/man/man8/tollgate-completion-fish.8 +++ b/packaging/files/man/man8/tollgate-completion-fish.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -58,4 +58,4 @@ You will need to start a new shell for this setup to take effect. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-completion-powershell.8 b/packaging/files/man/man8/tollgate-completion-powershell.8 index 0a907eee4..b2df46c61 100644 --- a/packaging/files/man/man8/tollgate-completion-powershell.8 +++ b/packaging/files/man/man8/tollgate-completion-powershell.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -51,4 +51,4 @@ to your powershell profile. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-completion-zsh.8 b/packaging/files/man/man8/tollgate-completion-zsh.8 index 6850fd01a..f4d8ab90d 100644 --- a/packaging/files/man/man8/tollgate-completion-zsh.8 +++ b/packaging/files/man/man8/tollgate-completion-zsh.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -74,4 +74,4 @@ You will need to start a new shell for this setup to take effect. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-completion.8 b/packaging/files/man/man8/tollgate-completion.8 index eaac1b96a..6fbb9048f 100644 --- a/packaging/files/man/man8/tollgate-completion.8 +++ b/packaging/files/man/man8/tollgate-completion.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -36,4 +36,4 @@ See each sub-command's help for details on how to use the generated script. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-config-get.8 b/packaging/files/man/man8/tollgate-config-get.8 index 7083586ab..3406f9a1f 100644 --- a/packaging/files/man/man8/tollgate-config-get.8 +++ b/packaging/files/man/man8/tollgate-config-get.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display the current configuration. With --json, outputs structured JSON. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-config-save-identities.8 b/packaging/files/man/man8/tollgate-config-save-identities.8 index 735aacffb..6472cf884 100644 --- a/packaging/files/man/man8/tollgate-config-save-identities.8 +++ b/packaging/files/man/man8/tollgate-config-save-identities.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Replace the entire identities.json with the provided JSON string. Use with cauti .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-config-save.8 b/packaging/files/man/man8/tollgate-config-save.8 index 44d181149..29a744af0 100644 --- a/packaging/files/man/man8/tollgate-config-save.8 +++ b/packaging/files/man/man8/tollgate-config-save.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Replace the entire config.json with the provided JSON string. Use with caution. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-config-schema.8 b/packaging/files/man/man8/tollgate-config-schema.8 index 1a402d5c4..80380595d 100644 --- a/packaging/files/man/man8/tollgate-config-schema.8 +++ b/packaging/files/man/man8/tollgate-config-schema.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Output the configuration schema describing all fields, types, defaults, and vali .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-config-set.8 b/packaging/files/man/man8/tollgate-config-set.8 index 4aecfe75f..53aa4d658 100644 --- a/packaging/files/man/man8/tollgate-config-set.8 +++ b/packaging/files/man/man8/tollgate-config-set.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -40,4 +40,4 @@ Examples: .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-config.8 b/packaging/files/man/man8/tollgate-config.8 index 4fcb3b64e..14f2729b3 100644 --- a/packaging/files/man/man8/tollgate-config.8 +++ b/packaging/files/man/man8/tollgate-config.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ View and manage TollGate configuration. All subcommands route through the runnin .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-health.8 b/packaging/files/man/man8/tollgate-health.8 index 7b80b0558..e2defc238 100644 --- a/packaging/files/man/man8/tollgate-health.8 +++ b/packaging/files/man/man8/tollgate-health.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Check the health of TollGate service components. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-logs.8 b/packaging/files/man/man8/tollgate-logs.8 index ce4258a15..b0a9fbc5a 100644 --- a/packaging/files/man/man8/tollgate-logs.8 +++ b/packaging/files/man/man8/tollgate-logs.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -43,4 +43,4 @@ Display TollGate service logs from logread .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network-private-disable.8 b/packaging/files/man/man8/tollgate-network-private-disable.8 index 4d0c1dfc0..db95f1776 100644 --- a/packaging/files/man/man8/tollgate-network-private-disable.8 +++ b/packaging/files/man/man8/tollgate-network-private-disable.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Disable the private WiFi network on both 2.4GHz and 5GHz radios .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network-private-enable.8 b/packaging/files/man/man8/tollgate-network-private-enable.8 index c78a7c37a..1a37ce424 100644 --- a/packaging/files/man/man8/tollgate-network-private-enable.8 +++ b/packaging/files/man/man8/tollgate-network-private-enable.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Enable the private WiFi network on both 2.4GHz and 5GHz radios .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network-private-rename.8 b/packaging/files/man/man8/tollgate-network-private-rename.8 index 684504470..1aafad942 100644 --- a/packaging/files/man/man8/tollgate-network-private-rename.8 +++ b/packaging/files/man/man8/tollgate-network-private-rename.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Change the SSID of the private WiFi network .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network-private-set-password.8 b/packaging/files/man/man8/tollgate-network-private-set-password.8 index 234275345..bed42e6ed 100644 --- a/packaging/files/man/man8/tollgate-network-private-set-password.8 +++ b/packaging/files/man/man8/tollgate-network-private-set-password.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Change the password for the private WiFi network. If no password is provided, a .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network-private-status.8 b/packaging/files/man/man8/tollgate-network-private-status.8 index f7827d2b2..832e1f053 100644 --- a/packaging/files/man/man8/tollgate-network-private-status.8 +++ b/packaging/files/man/man8/tollgate-network-private-status.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display private network status including SSID, password, and enabled state .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network-private.8 b/packaging/files/man/man8/tollgate-network-private.8 index 4d70bcf16..1e4f34e9e 100644 --- a/packaging/files/man/man8/tollgate-network-private.8 +++ b/packaging/files/man/man8/tollgate-network-private.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Manage your private network - enable/disable, rename, change password .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-network.8 b/packaging/files/man/man8/tollgate-network.8 index cfd7a113d..fc605a699 100644 --- a/packaging/files/man/man8/tollgate-network.8 +++ b/packaging/files/man/man8/tollgate-network.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Manage network settings and configurations .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-restart.8 b/packaging/files/man/man8/tollgate-restart.8 index e6ec6cbf8..30f7621d4 100644 --- a/packaging/files/man/man8/tollgate-restart.8 +++ b/packaging/files/man/man8/tollgate-restart.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Restart NoDogSplash and TollGate services .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-ssl-apply.8 b/packaging/files/man/man8/tollgate-ssl-apply.8 index 06b6c9f2a..873a26a6c 100644 --- a/packaging/files/man/man8/tollgate-ssl-apply.8 +++ b/packaging/files/man/man8/tollgate-ssl-apply.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Sep 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -48,4 +48,4 @@ With two files, uses separate cert and key files. .SH HISTORY .PP -26-Sep-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-ssl-covers.8 b/packaging/files/man/man8/tollgate-ssl-covers.8 index 9cb1b5b13..a4495e891 100644 --- a/packaging/files/man/man8/tollgate-ssl-covers.8 +++ b/packaging/files/man/man8/tollgate-ssl-covers.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Sep 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -49,4 +49,4 @@ identity yet. .SH HISTORY .PP -26-Sep-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-ssl-remove.8 b/packaging/files/man/man8/tollgate-ssl-remove.8 index 088f0f909..1b3024638 100644 --- a/packaging/files/man/man8/tollgate-ssl-remove.8 +++ b/packaging/files/man/man8/tollgate-ssl-remove.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -39,4 +39,4 @@ Revert SSL changes made by 'ssl apply', restoring previous state .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-ssl-status.8 b/packaging/files/man/man8/tollgate-ssl-status.8 index b80a7ec19..602fa7c74 100644 --- a/packaging/files/man/man8/tollgate-ssl-status.8 +++ b/packaging/files/man/man8/tollgate-ssl-status.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display current SSL certificate configuration and status .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-ssl.8 b/packaging/files/man/man8/tollgate-ssl.8 index b0c27e07c..ad058bea9 100644 --- a/packaging/files/man/man8/tollgate-ssl.8 +++ b/packaging/files/man/man8/tollgate-ssl.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Sep 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Manage SSL certificates for the TollGate LuCI admin interface .SH HISTORY .PP -26-Sep-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-start.8 b/packaging/files/man/man8/tollgate-start.8 index 6ab0d300c..bd3bde253 100644 --- a/packaging/files/man/man8/tollgate-start.8 +++ b/packaging/files/man/man8/tollgate-start.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Start NoDogSplash and TollGate services .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-status.8 b/packaging/files/man/man8/tollgate-status.8 index 32b855a32..444586ace 100644 --- a/packaging/files/man/man8/tollgate-status.8 +++ b/packaging/files/man/man8/tollgate-status.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display TollGate service status including uptime, modules, and health .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-stop.8 b/packaging/files/man/man8/tollgate-stop.8 index 8df53809b..9067790a4 100644 --- a/packaging/files/man/man8/tollgate-stop.8 +++ b/packaging/files/man/man8/tollgate-stop.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Stop NoDogSplash and TollGate services .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-upstream-connect.8 b/packaging/files/man/man8/tollgate-upstream-connect.8 index a49d2daee..7c49184d2 100644 --- a/packaging/files/man/man8/tollgate-upstream-connect.8 +++ b/packaging/files/man/man8/tollgate-upstream-connect.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Connect to an upstream WiFi network. Disables the current upstream, preserving i .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-upstream-known.8 b/packaging/files/man/man8/tollgate-upstream-known.8 new file mode 100644 index 000000000..ec501e0c0 --- /dev/null +++ b/packaging/files/man/man8/tollgate-upstream-known.8 @@ -0,0 +1,38 @@ +.nh +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" + +.SH NAME +.PP +tollgate-upstream-known - Show discovered TollGate APs from scan history + + +.SH SYNOPSIS +.PP +\fBtollgate upstream known [flags]\fP + + +.SH DESCRIPTION +.PP +Display a summary of all TollGate access points discovered during scans, including signal range, pricing, and first/last seen timestamps. + + +.SH OPTIONS +.PP +\fB-h\fP, \fB--help\fP[=false] + help for known + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +.PP +\fB-j\fP, \fB--json\fP[=false] + Output results as JSON + + +.SH SEE ALSO +.PP +\fBtollgate-upstream(8)\fP + + +.SH HISTORY +.PP +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-upstream-list.8 b/packaging/files/man/man8/tollgate-upstream-list.8 index a9c6520b2..0212e3800 100644 --- a/packaging/files/man/man8/tollgate-upstream-list.8 +++ b/packaging/files/man/man8/tollgate-upstream-list.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Show all configured upstream STA interfaces with active/disabled status .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-upstream-remove.8 b/packaging/files/man/man8/tollgate-upstream-remove.8 index 15b24263c..2360f92ee 100644 --- a/packaging/files/man/man8/tollgate-upstream-remove.8 +++ b/packaging/files/man/man8/tollgate-upstream-remove.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Remove a disabled upstream STA interface from the wireless configuration. Active .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-upstream-scan.8 b/packaging/files/man/man8/tollgate-upstream-scan.8 index 4cab6ece7..3f3d66847 100644 --- a/packaging/files/man/man8/tollgate-upstream-scan.8 +++ b/packaging/files/man/man8/tollgate-upstream-scan.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Scan all radios and display available WiFi networks .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-upstream.8 b/packaging/files/man/man8/tollgate-upstream.8 index 38e67a95e..7847364e4 100644 --- a/packaging/files/man/man8/tollgate-upstream.8 +++ b/packaging/files/man/man8/tollgate-upstream.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -30,9 +30,9 @@ Manage upstream WiFi connections - scan, connect, list, remove .SH SEE ALSO .PP -\fBtollgate(8)\fP, \fBtollgate-upstream-connect(8)\fP, \fBtollgate-upstream-list(8)\fP, \fBtollgate-upstream-remove(8)\fP, \fBtollgate-upstream-scan(8)\fP +\fBtollgate(8)\fP, \fBtollgate-upstream-connect(8)\fP, \fBtollgate-upstream-known(8)\fP, \fBtollgate-upstream-list(8)\fP, \fBtollgate-upstream-remove(8)\fP, \fBtollgate-upstream-scan(8)\fP .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-version.8 b/packaging/files/man/man8/tollgate-version.8 index e8247c242..667d30942 100644 --- a/packaging/files/man/man8/tollgate-version.8 +++ b/packaging/files/man/man8/tollgate-version.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display TollGate version and build information .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-wallet-balance.8 b/packaging/files/man/man8/tollgate-wallet-balance.8 index 16555b4fd..6e247e247 100644 --- a/packaging/files/man/man8/tollgate-wallet-balance.8 +++ b/packaging/files/man/man8/tollgate-wallet-balance.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display current wallet balance in satoshis .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-wallet-drain-cashu.8 b/packaging/files/man/man8/tollgate-wallet-drain-cashu.8 index c7449cebc..26c67b8c3 100644 --- a/packaging/files/man/man8/tollgate-wallet-drain-cashu.8 +++ b/packaging/files/man/man8/tollgate-wallet-drain-cashu.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -21,6 +21,10 @@ Create Cashu tokens for each mint containing all available balance \fB-h\fP, \fB--help\fP[=false] help for cashu +.PP +\fB-y\fP, \fB--yes\fP[=false] + Assume yes; skip the interactive confirmation prompt (for automation) + .SH OPTIONS INHERITED FROM PARENT COMMANDS .PP @@ -35,4 +39,4 @@ Create Cashu tokens for each mint containing all available balance .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-wallet-drain.8 b/packaging/files/man/man8/tollgate-wallet-drain.8 index a96bc53da..2f12ef2ae 100644 --- a/packaging/files/man/man8/tollgate-wallet-drain.8 +++ b/packaging/files/man/man8/tollgate-wallet-drain.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Transfer wallet funds using different methods .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-wallet-fund.8 b/packaging/files/man/man8/tollgate-wallet-fund.8 index 13d83c8e1..48322f112 100644 --- a/packaging/files/man/man8/tollgate-wallet-fund.8 +++ b/packaging/files/man/man8/tollgate-wallet-fund.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Add funds to the wallet by providing a Cashu token. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-wallet-info.8 b/packaging/files/man/man8/tollgate-wallet-info.8 index de30207e9..30292c35c 100644 --- a/packaging/files/man/man8/tollgate-wallet-info.8 +++ b/packaging/files/man/man8/tollgate-wallet-info.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Display detailed wallet information including balance, addresses, and keys .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate-wallet.8 b/packaging/files/man/man8/tollgate-wallet.8 index 6ff060262..df7bfe4c5 100644 --- a/packaging/files/man/man8/tollgate-wallet.8 +++ b/packaging/files/man/man8/tollgate-wallet.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -35,4 +35,4 @@ Manage your TollGate wallet - check balance, drain funds, view information .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/files/man/man8/tollgate.8 b/packaging/files/man/man8/tollgate.8 index 430719c79..383eecae2 100644 --- a/packaging/files/man/man8/tollgate.8 +++ b/packaging/files/man/man8/tollgate.8 @@ -1,5 +1,5 @@ .nh -.TH "TOLLGATE" "8" "Jul 2026" "tollgate-wrt" "TollGate OpenWrt Module" +.TH "TOLLGATE" "8" "Oct 2026" "tollgate-wrt" "TollGate OpenWrt Module" .SH NAME .PP @@ -34,4 +34,4 @@ You can check status, manage wallet, and control various aspects of the service. .SH HISTORY .PP -5-Jul-2026 Auto generated by spf13/cobra +1-Oct-2026 Auto generated by spf13/cobra diff --git a/packaging/local-build-ipk.sh b/packaging/local-build-ipk.sh index 8c8044145..52c997e58 100644 --- a/packaging/local-build-ipk.sh +++ b/packaging/local-build-ipk.sh @@ -29,6 +29,32 @@ case "$ARCH" in esac GOARM="${GOARM:-}"; GOMIPS="${GOMIPS:-}" +# The portal JS bundles, admin SPA and rpcd plugin are build products +# staged by `make portal-build` (packaging/portal-build.sh); a clean +# checkout keeps only the committed portal shell (splash.html et al.) and +# none of the built bytes (#335). Packaging without them ships an .ipk +# whose captive portal renders nothing — refuse early, before any +# toolchain work. The guest SPA has no index.html on purpose (its pages +# are splash.html/balance.html/404.html), so the staged-content check for +# it is the JS bundle set itself. +if ! ls packaging/files/tollgate-captive-portal-site/assets/*.js >/dev/null 2>&1; then + echo "ERROR: no JS bundles under packaging/files/tollgate-captive-portal-site/assets/ —" >&2 + echo " run 'make portal-build' first (only the committed shell is present; the" >&2 + echo " portal would render nothing)." >&2 + exit 1 +fi +for staged in \ + "tollgate-admin/index.html" \ + "usr/libexec/rpcd/tollgate" +do + if [ ! -e "packaging/files/$staged" ]; then + echo "ERROR: packaging/files/$staged is missing — run 'make portal-build' first." >&2 + echo " A clean checkout has no built portal/admin bundles (#335); without" >&2 + echo " this step the .ipk would ship a captive portal that renders nothing." >&2 + exit 1 + fi +done + GO_BIN="${GO_BIN:-go}" ACTIVE_GO="$("$GO_BIN" version | awk '{print $3}')" if [ "$ACTIVE_GO" != "go$GO_VERSION" ]; then diff --git a/packaging/portal-build.sh b/packaging/portal-build.sh index cee8b17ca..6317e8b43 100755 --- a/packaging/portal-build.sh +++ b/packaging/portal-build.sh @@ -36,7 +36,7 @@ PORTAL_DIR="${PORTAL_DIR:-/tmp/tollgate-captive-portal-site}" OUTPUT_DIR="${OUTPUT_DIR:-packaging/files/tollgate-captive-portal-site}" ADMIN_OUTPUT_DIR="${ADMIN_OUTPUT_DIR:-packaging/files/tollgate-admin}" # Brand webroot the shipped 92-tollgate-admin-setup points at. TollGate is the -# default brand; a net4sats build passes ADMIN_HOME=/www/net4sats. +# default brand; a whitelabel build passes ADMIN_HOME=/www/. ADMIN_HOME="${ADMIN_HOME:-/www/tollgate}" PORTAL_REF="${PORTAL_REF:-$PORTAL_COMMIT}" diff --git a/scripts/__pycache__/ngit-gen-shards.cpython-312.pyc b/scripts/__pycache__/ngit-gen-shards.cpython-312.pyc deleted file mode 100644 index 98089dca1..000000000 Binary files a/scripts/__pycache__/ngit-gen-shards.cpython-312.pyc and /dev/null differ diff --git a/scripts/build-sdk-package.sh b/scripts/build-sdk-package.sh index 683483f27..1c9bdd72d 100755 --- a/scripts/build-sdk-package.sh +++ b/scripts/build-sdk-package.sh @@ -134,8 +134,13 @@ CLI_LDFLAGS="$(cli_ldflags "$PACKAGE_VERSION")" printf '%s\n' 'Building target binaries locally before invoking the OpenWrt SDK.' ( cd "$REPO_ROOT/src" + # Build the whole package (.), not main.go alone: package main grew + # sibling files (startup_gate.go, the api boot ordering) whose symbols + # main.go references — single-file compilation has failed with + # "undefined: requireStarted & co" since they landed, and nothing on + # the SDK-local path had exercised the script since. env CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" GOMIPS="$GOMIPS" GOARM="$GOARM" \ - "$GO_BIN" build -o "$STAGE_DIR/tollgate-wrt" -trimpath -buildvcs=false -ldflags="$LDFLAGS" main.go + "$GO_BIN" build -o "$STAGE_DIR/tollgate-wrt" -trimpath -buildvcs=false -ldflags="$LDFLAGS" . ) ( cd "$REPO_ROOT/src/cmd/tollgate-cli" diff --git a/scripts/release-check.sh b/scripts/release-check.sh new file mode 100755 index 000000000..11a2accc1 --- /dev/null +++ b/scripts/release-check.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# release-check: the one-command pre-release gate (docs/release-process.md). +# +# It orchestrates the existing gates — it must never replace or relax one: +# every check below is the same command CI or the runbook runs, and a failure +# here is a failure of the underlying gate, not of the wrapper. +# +# Usage: make release-check VERSION=v0.6.0-rc1 +# scripts/release-check.sh v0.6.0-rc1 +# +# Environment: +# TOLLGATE_RELEASE_CHECK_CONFORMANCE=1 require the docker conformance +# subset instead of skipping it when docker/PRTA is unavailable (the +# release manager sets this on the machine that owns the lane). +# TOLLGATE_RELEASE_CHECK_REPRO=none skip the reproducibility build +# (default: binaries x86_64 — the cheap leg; run `make +# reproducibility-test T=... ARCH=...` for the full matrix). +set -u + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +VERSION="${1:-}" +if [ -z "$VERSION" ]; then + if [ -f "$ROOT/VERSION" ]; then + VERSION="$(cat "$ROOT/VERSION")" + else + echo "usage: $0 (or run from a tree with a VERSION file)" >&2 + exit 2 + fi +fi + +results=() +overall=0 + +record() { # record