Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
89ed4cd
docs: document the Cudy WR3000 v1 as a covered target, with its 16 MB…
felixfelix-bot Sep 27, 2026
84f9cb6
docs(architecture): propose admin LAN subnet collision handling (#615)
felixfelix-bot Sep 27, 2026
54e8c36
feat(ci): router-test workflow (physical + lab router tests) (#614)
felixfelix-bot Sep 28, 2026
aba1947
docs: document the COMFAST CF-WR632AX as a covered target, with its >…
felixfelix-bot Sep 29, 2026
fa414c7
docs(architecture): the br-mgmt ADR's verdict is narrowed — the requi…
felixfelix-bot Sep 29, 2026
d7c36bd
test(router-happy-path): cover the SECOND purchase — buy, spend it, b…
felixfelix-bot Sep 30, 2026
343f183
feat(packaging): the physical LAN port moves onto br-private (minimal…
felixfelix-bot Sep 30, 2026
431b65b
fix(setup): generate admin password without od (#624)
felixfelix-bot Sep 30, 2026
74cd6f9
fix(deps): align golang.org/x/time across the nested modules so the c…
felixfelix-bot Sep 30, 2026
8b9ba86
fix(firewall): answer :2121 on br-private, where the admin board live…
felixfelix-bot Oct 4, 2026
f217197
feat(config,network): the private SSID's credentials and the administ…
felixfelix-bot Oct 5, 2026
be601ff
fix(tls): the derived hop lands before the reload, the operator's ide…
felixfelix-bot Oct 5, 2026
1e9c4f4
fix(session): carry the meter across a MAC rotation (session tickets,…
felixfelix-bot Oct 5, 2026
cec2222
fix(setup): drop the legacy re-brand :8090 writer; uhttpd.admin stays…
felixfelix-bot Oct 6, 2026
19b5609
fix(merchant): make the test log capture concurrency-safe (intermitte…
Amperstrand Oct 6, 2026
bc0ca6e
fix(merchant): a concurrent duplicate of one note is refused before t…
Amperstrand Oct 6, 2026
ae83e28
fix(valve): a "client not found" answer is only evidence about the MA…
felixfelix-bot Oct 6, 2026
0ef5171
ci(ngit): stamp stage 1 from the commit, never the runner clock (#529)
Amperstrand Oct 6, 2026
bf31ad6
fix(packaging): refuse to build a local .ipk without staged portal bu…
Amperstrand Oct 6, 2026
5c64faa
fix(utils): hex-encode the fingerprint salt — raw edge bytes corrupte…
Amperstrand Oct 6, 2026
de5b421
fix(wallet): no same-body retry after an ambiguous mint outcome — rep…
Amperstrand Oct 6, 2026
87ba24d
fix(merchant): a paid purchase whose gate cannot open is an owed enti…
Amperstrand Oct 6, 2026
48c67cf
test(fleet): require credentials from the environment, not public def…
Amperstrand Oct 6, 2026
cd4861e
fix(merchant): bound the fee precheck against wedged mints (#525) (#533)
Amperstrand Oct 6, 2026
b873983
docs(operator-guide): cover the ssl family and upstream known; regen …
Amperstrand Oct 6, 2026
f79de9b
fix(cli): bound fw4 reload on the daemon start path — a wedged firewa…
Amperstrand Oct 6, 2026
3641706
fix(config): config.json writes are atomic and a missing config is an…
Amperstrand Oct 6, 2026
c44b710
fix(config): min_steps floors at 1 — parser accepts the legacy spelli…
Amperstrand Oct 6, 2026
ac1ab49
ci: enroll in org gitleaks scanning; watch main on push (ports fork #…
Amperstrand Oct 6, 2026
abca784
fix(config): refuse an out-of-bounds private_key at config set — a pe…
Amperstrand Oct 6, 2026
c9ebe6e
fix(cli): config get no longer returns the identities' Nostr private …
Amperstrand Oct 6, 2026
2f12b04
test(cloud-lab): conformance fast-subset lane over the PRTA fault pro…
Amperstrand Oct 6, 2026
7676365
make: one release-check gate that orchestrates the existing gates — p…
Amperstrand Oct 6, 2026
bb9c895
release: v0.6.0-rc1 — feature freeze (merge last) (#659)
Amperstrand Oct 6, 2026
41ee4d7
docs(agents): the gonuts bump touches four go.mod files, not three (#…
Amperstrand Oct 6, 2026
7072751
deps(wallet): bump gonuts-tollgate v0.13.0 — NUT-20 cdk-interop signa…
Amperstrand Oct 6, 2026
09a97b4
docs(readme): the WR3000 25.12 install paragraph gains the feed-shape…
Amperstrand Oct 6, 2026
d1612d4
test(packaging): an apk3 dependency-resolution smoke gates the releas…
Amperstrand Oct 6, 2026
6d2277e
fix(build): build-sdk-package.sh compiles package main, not main.go a…
Amperstrand Oct 6, 2026
391b82a
fix(lane): resolve the conflict-marker residue #658's rebase left in …
Amperstrand Oct 6, 2026
f91f3a1
test(merchant): the owed-grant AUTH assertion polls for its own condi…
Amperstrand Oct 6, 2026
9e87467
docs(agents): hardware and VM testing is coordinated through labgrid …
Amperstrand Oct 6, 2026
a1f75b4
docs: decision records rebased — discovery signaling (#621), ADR cita…
Amperstrand Oct 6, 2026
977fa96
feat(setup): the gateway serves NTP pre-auth — time before payment (#…
Amperstrand Oct 6, 2026
8766058
merge main forward (44 commits, through #648): the CHANGELOG conflict…
Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/build-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions .github/workflows/router-test.yml
Original file line number Diff line number Diff line change
@@ -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'"
59 changes: 59 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
13 changes: 13 additions & 0 deletions .ngit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 27 additions & 19 deletions .ngit/act/workflows/build-package-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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" \
Expand Down
Loading