From b8dc5c01a55fc85b35af5a02784bb2f5dec9f809 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sat, 8 Aug 2026 19:09:45 -0400 Subject: [PATCH] Update readme --- README.md | 285 ++++++++++++++++++----------- internal/licenses/licenses_test.go | 24 --- 2 files changed, 176 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 77efee5..30112b5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # MeshTender -A Go web app for tending [MeshCore](https://meshcore.co.uk) meshes. MeshTender holds a single -server-wide MeshCore identity (Ed25519 + X25519). Repeater owners grant it admin by running -`setperm 3` on their repeater. Because every action flows through the one server -identity, MeshTender can mediate *which users* may control a repeater — so you can share a repeater -with other people without ever handing out keys. +A Go web app for tending [MeshCore](https://meshcore.io/) meshes — sharing repeater +administration with other people, keeping a site's documentation and maintenance history with the +node instead of in someone's head, and publishing an organization's recommended configuration so +every repeater in a mesh is set up the same way. -## How the hardware confirm/control path works +MeshTender holds a single server-wide MeshCore identity (Ed25519 + X25519). A repeater owner grants +it admin by running `setperm 3` on their repeater. Because every action flows +through that one server identity, MeshTender can mediate *which users* may control a repeater — so +a repeater can be shared without ever handing out keys, and access can be revoked instantly. + +## How the hardware control path works The server owns all MeshCore crypto, but the radio (a MeshCore **KISS modem**) is plugged into the *user's* computer. So: @@ -18,77 +22,101 @@ server ──(WebSocket, KISS bytes)──▶ browser ──(WebSerial)── The server builds a signed `AnonReq` login packet, KISS-frames it, and streams the bytes to the browser, which writes them to the modem over WebSerial. The reply travels back the same way for the -server to decrypt — proving MeshTender can reach the repeater. Confirmation is optional; the -repeater works either way. This is implemented as a custom `hardware.Transport` -(`internal/wsbridge`) over a WebSocket. +server to decrypt. Nothing is stored on the user's machine and no key ever leaves the server; the +modem is only a transport. This is a custom `hardware.Transport` (`internal/wsbridge`) over a +WebSocket. + +**Mesh-friendly transmission.** Each session uses strictly increasing per-command timestamps (the +repeater dedupes same-timestamp commands) and a 1-message/second rate limit, so a user can't flood +the shared LoRa mesh through their modem. The first contact (login) floods with 3-byte routing path +hashes for reliable propagation; once the repeater's reply reveals the route home, subsequent +commands use **direct routing** along that path — traversing only those repeaters instead of +flooding the whole mesh — with automatic fallback to flood if the path goes stale. Sends, retries, +timestamps, and routing all live in one `mesh.Exchanger`. + +There is also a **serial setup path** for a brand-new node: the browser talks the repeater's own +plain-text CLI directly over USB. The repeater's private key is generated in the browser and never +sent to the server — only its public key is (`internal/core/repeater_setup.go`). + +## What it does + +**Repeaters.** Add a node (over LoRa via a modem, or from scratch over USB serial), send firmware +CLI commands from the **console**, and read the per-repeater audit log of who sent what and when. +Location is opt-in per repeater, as is appearing on an org's public map, as is publishing a +read-only public page at the repeater's stable public ID. + +**Registry.** Each repeater carries site **documentation** (a public section and an internal one) +and a **maintenance history** anyone with access can log. Ownership can be **transferred** to a +designated steward, so the docs, history, and public URL survive the original builder moving on. + +**Sharing.** Owners create single-use, labeled, expiring **invite links** — one per person. The +recipient signs in and accepts, consuming the link; used links stay as an audit trail. There is no +user directory. Each shared person gets an explicit per-command allowlist (deny by default). + +**Organizations.** Any user creates an org and becomes its admin; orgs are publicly listed and any +signed-in user can join and be promoted. A repeater participates in every org its owner belongs to +unless the owner excludes it, and the owner can optionally restrict which commands a given org may +run on a given repeater. Effective permissions = the site catalog's per-tier ceiling ∩ the owner's +optional per-repeater opt-in ∩ the caller's tier (admin ⊇ member). Orgs also get a description, +links, markdown content, a repeater map, and optionally a verified custom domain. + +**Configuration ("desired state").** An org publishes named **profiles** (base settings) plus +**regions** — a named hierarchy of geofences that compile into MeshCore `region def` chains, with +per-region and root flood policy (`region allowf` / `region denyf`). A repeater's coordinates select +every region whose polygon contains it. The console's "apply organization configuration" flow shows +the resulting command list — profile steps plus region commands — marking any line the user can't +run and why. Regions are drawn on a Leaflet/Geoman map editor. + +**Command catalog.** Every repeater CLI command, seeded from the firmware and modeled per-parameter +(`set.tx`, `set.radio`, …) with feature/operation grouping, a `risky` tag, and per-tier org flags. +An owner can always run anything on their own node; the flags are the ceiling for everyone else. + +**Accounts.** Username-only (no required email), an optional public profile at `/u/{username}` +(display name, bio, links — not indexed), timezone, +passkeys (WebAuthn via `go-webauthn`) with a bcrypt password fallback, sessions via `scs`. Email is +optional and used only for recovery (verification + password reset) via Resend; without a +`MAIL_FROM` the whole feature is hidden, and without an API key messages are logged instead of sent. + +**Admin.** Instance capabilities (`cap_manage_users`, `cap_manage_catalog`) — the first registered +account is bootstrapped with both — plus first-party traffic analytics (no third party, no PII; +visitors counted by a daily-rotating salted hash), CSP violation reports, a reverse-proxy test page, +and encrypted export/restore of the server identity. ## Stack -- Go 1.26, [meshcore-go](https://github.com/meshcore-go/meshcore-go) for the MeshCore protocol/crypto -- Postgres (pgx + goose migrations) -- Accounts are username-only (no email/PII), with an optional display name; passkey auth (WebAuthn - via `go-webauthn`) with a password fallback (bcrypt); sessions via `scs` -- Sharing is via **single-use share links** — the owner mints one labeled link per person; the - recipient signs in and accepts (consuming it). No user directory; used links are kept as an audit - trail showing who accepted -- **Mesh-friendly transmission**: each session uses strictly increasing per-command timestamps (the - repeater dedupes same-timestamp commands) and a 1-message/second rate limit, so a user can't flood - the shared LoRa mesh through their modem. The first contact (login) floods with 3-byte routing path - hashes (reliable propagation); once the repeater's reply reveals the route home, subsequent commands - use **direct routing** along that path — traversing only those repeaters instead of flooding the - whole mesh — with automatic fallback to flood if the path goes stale. All sends, retries, timestamps, - and routing are handled by one `mesh.Exchanger` -- **Command console**: authorized users send firmware CLI commands to a repeater over their modem - (same WebSocket↔WebSerial bridge as confirm). Owners run anything; shared users run only the - commands the owner granted them. Every send is recorded in a per-repeater audit log (who/when/ack) -- **Command catalog**: all repeater CLI commands, seeded from the firmware, modeled per-parameter - (`set.tx`, `set.radio`, …) with a `risky` tag and default-set flags. There is no global on/off — - the owner can run anything; the flags seed what *others* are offered -- **Instance capabilities** (not tiers): `cap_manage_users` (grant/revoke capabilities) and - `cap_manage_catalog` (edit the catalog), managed at `/admin`. The first registered account is - bootstrapped with both. These are separate from per-repeater access (sharing) -- **Organizations** (the trust-first tier above sharing): any user creates an org and becomes its - admin; orgs are publicly listed and any signed-in user joins from the org page and can be promoted. An org has a **versioned, - two-tier (admin/member) permission policy**. An owner *contributes* a repeater to an org and - **consents** to the policy version; effective commands = the org's *current* set ∩ the version the - owner *consented to*, per the user's tier (admins ⊇ members). Policy additions require the owner to - re-consent (shown as a diff + changelog); removals apply immediately. Org-admins operate distant - nodes over the mesh from their own modem. Withdraw / leave revokes access instantly. Members reach - contributed repeaters from the org page; the org page also shows a **map** of repeater locations. -- **Repeater location** (opt-in): when adding a repeater the owner may consent to storing its lat/lon, - which is fetched (`get lat`/`get lon`) during the modem test and shown on org maps. Off by default. -- Server-rendered `html/template` + htmx; hand-written JS only for the WebSerial page -- `coder/websocket` +Go single binary, server-rendered `html/template` + htmx, PostgreSQL. No SPA, no bundler, no asset +build step — migrations, templates, and static assets are all `go:embed`ed. + +- Go 1.26.5 (pinned exactly — see "Verifying a build"), [meshcore-go](https://github.com/meshcore-go/meshcore-go) + for the MeshCore protocol/crypto +- Postgres via `pgx` with raw SQL (no ORM) and goose migrations; `chi` router; `coder/websocket` +- Hand-written JS only where the platform demands it (WebSerial, Leaflet maps, small delegated + handlers in `ui.js`) ### Vendored front-end assets -The strict CSP forbids external scripts/styles (`*-src 'self'`), so all third-party -front-end libraries are **self-hosted** in `internal/web/static/` (embedded into the -binary) rather than loaded from a CDN. They're served content-hash fingerprinted, with -a one-year `immutable` Cache-Control, and pre-compressed (gzip + brotli) by the asset -manifest in `internal/web/assets.go`. +The strict CSP forbids external scripts/styles (`*-src 'self'`), so every third-party front-end +library is **self-hosted** in `internal/web/static/` and embedded into the binary. They're served +content-hash fingerprinted with a one-year `immutable` Cache-Control and pre-compressed (gzip + +brotli) by the asset manifest in `internal/web/assets.go`. The only external resource anywhere is +CARTO map tiles, allowlisted in `img-src`. -Current pinned versions: +Which libraries, at which versions, from which upstream artifact, is recorded once in +`internal/licenses/manifest.go` — with a SHA-256 per file — and rendered into +`THIRD-PARTY-NOTICES.md`. That manifest is the list; it isn't duplicated here, because a second +copy would only go stale. Its tests fail if a file in `internal/web/static/` is neither declared +there nor listed as first-party, so a new library can't slip past the audit. -| Library | Version | Files | -|---|---|---| -| [htmx](https://htmx.org) | 2.0.10 | `htmx.min.js` | -| [Leaflet](https://leafletjs.com) | 1.9.4 | `leaflet.js`, `leaflet.css` | -| [Leaflet-Geoman](https://geoman.io) | 2.20.0 | `leaflet-geoman.js`, `leaflet-geoman.css` | -| [Leaflet.markercluster](https://github.com/Leaflet/Leaflet.markercluster) | 1.5.3 | `leaflet.markercluster.js`, `leaflet.markercluster.css` | -| [Tabler](https://tabler.io) | 1.4.0 | `tabler.min.js`, `tabler.min.css` | - -**Updating a vendored library:** fetch the minified build from jsdelivr (mirrors npm -exactly), e.g. `https://cdn.jsdelivr.net/npm/htmx.org@/dist/htmx.min.js`, and -overwrite the file in `internal/web/static/` (keep the existing filename). **Strip any -trailing `sourceMappingURL` comment** (`//# sourceMappingURL=…` / `/*# … */`) — we don't -self-host the `.map` files, so the comment only produces a 404 when devtools are open. -Then update the version above and validate in a browser (`mise run e2e`, which fails on -any CSP violation). +**Updating one:** fetch the minified build from jsdelivr (mirrors npm exactly), e.g. +`https://cdn.jsdelivr.net/npm/htmx.org@/dist/htmx.min.js`, and overwrite the file (keep the +filename). **Strip any trailing `sourceMappingURL` comment** — we don't self-host `.map` files, so +it only 404s with devtools open — and **keep the copyright banner** minifiers like to drop; MIT and +BSD require it. Then update the version and hash in the manifest, run `mise run licenses --update`, +and validate with `mise run e2e`. ## Running locally -One process serves all three hosts (root, auth, app) over TLS. Dev can't use `*.localhost` — +One process serves all hosts (root, www, auth, app) on one port. Dev can't use `*.localhost` — `localhost` is a public suffix, so a WebAuthn RP ID of `localhost` is rejected from a subdomain and passkeys won't work — so dev uses a real registrable domain whose subdomains resolve to `127.0.0.1`, with a locally-trusted [mkcert](https://github.com/FiloSottile/mkcert) certificate. `.env.example` @@ -98,18 +126,32 @@ is preconfigured for `leighthaus.dev`; point its subdomains at `127.0.0.1` (real ```sh docker compose up -d # Postgres -# One-time: trust a local CA and mint a cert for the dev domain + its subdomains. +# One-time: trust a local CA and create a cert for the dev domain + its subdomains. brew install mkcert && mkcert -install mkcert -cert-file ./certs/dev.pem -key-file ./certs/dev-key.pem "*.leighthaus.dev" leighthaus.dev cp .env.example .env # then set MESHTENDER_MASTER_KEY=$(openssl rand -hex 32) -set -a; . ./.env; set +a -go run ./cmd/meshtender # migrates on boot; serves HTTPS on :8080 +mise run dev # migrates on boot; serves HTTPS on :8080 ``` Then open (dashboard) or (public -root). WebSerial requires a secure context, which the mkcert HTTPS above provides — a real deployment -must likewise serve the confirm/control pages over HTTPS. +root). WebSerial requires a secure context, which the mkcert HTTPS provides — a real deployment must +likewise serve the console pages over HTTPS. + +`mise run seed` fills the database with realistic fake data; `mise run reset` truncates everything +except users with credentials, passkeys, sessions, and the server identity. (Both are `go run +./cmd/meshtender --seed|--reset` under the hood.) + +### mise tasks + +| Task | What it does | +|---|---| +| `mise run dev` | run the server | +| `mise run lint` | `golangci-lint run` (staticcheck + gosec, config in `.golangci.yml`) | +| `mise run seed` / `reset` | seed fake data / truncate non-account data | +| `mise run e2e` | browser tests in a throwaway headless-shell container (`--run ` to filter) | +| `mise run licenses` | audit dependency licenses (`--update` rewrites `THIRD-PARTY-NOTICES.md`) | +| `mise run image` | build the OCI image with ko and print its digest (`--load` to run it locally) | ### Configuration (env) @@ -123,9 +165,14 @@ must likewise serve the confirm/control pages over HTTPS. | `MESHTENDER_TLS_CERT` / `_TLS_KEY` | cert/key for in-process HTTPS (see mkcert above); omit only behind a TLS-terminating proxy | | `MESHTENDER_RP_ID` / `_RP_ORIGIN` / `_RP_NAME` | WebAuthn relying-party settings (must line up with the hosts) | | `MESHTENDER_TRUSTED_PROXIES` | proxies whose `X-Forwarded-For`/`X-Real-IP` are trusted when resolving the client IP — comma-separated CIDRs/IPs, or `private` for the RFC1918/link-local/ULA ranges. Loopback is always trusted. Verify with the admin **Reverse proxy test** page. | +| `MESHTENDER_MAIL_FROM` / `_MAIL_REPLY_TO` | enables the optional recovery-email feature; unset ⇒ no email UI at all | +| `MESHTENDER_RESEND_API_KEY` | enables real delivery; unset ⇒ messages are logged to stderr instead (the dev default) | > **Note:** `MESHTENDER_MASTER_KEY` is coupled to the stored identity — changing it makes the -> existing `server_identity` row undecryptable. Keep it stable. +> existing `server_identity` row undecryptable. Keep it stable, and keep a copy of the admin +> identity export (which stays sealed under this key). + +Cross-host cookie and session rules are documented in [`docs/auth-cross-host.md`](docs/auth-cross-host.md). ## Tests @@ -133,27 +180,44 @@ must likewise serve the confirm/control pages over HTTPS. go test ./... ``` -That's it — **just make sure Docker is running.** DB-backed tests (store queries and the -end-to-end confirm/console round-trips) spin up a throwaway `postgres:17` container automatically -via [testcontainers](https://golang.testcontainers.org/). The harness migrates a single template -database once, then clones a fresh database per test (`CREATE DATABASE … TEMPLATE …`), so every -test gets pristine, isolated state and tears it down when it finishes. No env vars, no manual -database setup, nothing to wipe. +That's it — **just make sure Docker is running.** DB-backed tests spin up a throwaway `postgres:17` +container automatically via [testcontainers](https://golang.testcontainers.org/). `internal/testdb` +migrates a single template database once, then clones a fresh database per test +(`CREATE DATABASE … TEMPLATE …`), so every test gets pristine, isolated state and can run in +parallel. No env vars, no manual setup, nothing to wipe. -To run against an **existing** Postgres instead of a container (this is how CI reuses its service -container), set `MESHTENDER_TEST_DATABASE_URL` to a DSN on that server. The connecting role needs -`CREATEDB`, and the harness only ever creates/drops its own `mt_tmpl_*` / `mt_test_*` databases: +To run against an **existing** Postgres instead of a container, set +`MESHTENDER_TEST_DATABASE_URL` to a DSN on that server. The role needs `CREATEDB`, +and the harness only ever creates/drops its own `mt_tmpl_*` / `mt_test_*` databases: ```sh MESHTENDER_TEST_DATABASE_URL="postgres://meshtender:meshtender@localhost:5432/postgres?sslmode=disable" \ - go test ./internal/core/ -run TestConfirmRoundTrip -v + go test ./internal/core/ -run TestConsole -v ``` +**Browser tests** live in `internal/e2e/` behind a `//go:build browser` tag, so `go test ./...` +never needs a browser. `mise run e2e` starts a `chromedp/headless-shell` container (no local Chrome +install), runs the suite against an in-process server, and fails on any CSP violation. If the +container isn't up, they skip rather than fail. + +## Third-party licenses + +Every dependency has to be one we can actually comply with when we ship a binary and an image, so +dependencies are limited to permissive licenses — the allowed set is `AllowedSPDX` in +`internal/licenses/manifest.go`, and copyleft terms (GPL, LGPL, AGPL, MPL, SSPL) are out, including +in the test tree. Attribution obligations are met by `THIRD-PARTY-NOTICES.md` and by keeping the +copyright banners in the vendored front-end files. + +`mise run licenses` enforces it: it scans the whole Go module graph (binary, test, and +`browser`-tagged) with `google/licensecheck`, plus the manifest of things Go tooling can't see — +vendored front-end files, icon artwork, the base image, external services — and fails if anything +falls outside the allowed set or if `THIRD-PARTY-NOTICES.md` has drifted. + ## Building the image -There is no Dockerfile. MeshTender is a pure-Go single binary with migrations, templates, and -static assets embedded via `go:embed`, so [ko](https://ko.build) compiles it and lays it straight -onto a base image — no build context and no Docker daemon. The build is configured in `.ko.yaml`. +There is no Dockerfile. MeshTender is a pure-Go single binary with everything embedded, so +[ko](https://ko.build) compiles it and lays it straight onto a base image — no build context and no +Docker daemon. Configured in `.ko.yaml`. ```sh mise run image # build and print the image digest (pushes nothing) @@ -162,15 +226,13 @@ mise run image --load # load into the local Docker daemon so you can run it ## Verifying a build -The published image is **reproducible**: you can rebuild it yourself and confirm you get the same -digest we shipped, rather than taking our word that the image matches this source. Every build -input is pinned — the Go toolchain (`GOTOOLCHAIN` in `.config/mise/config.toml`), the ko version, -and the base image *by digest* rather than by its floating `:nonroot` tag — and ko zeroes the layer -timestamps. `TestReleasePinsAreConsistent` and `TestBaseImageIsPinnedByDigest` +The published image is **reproducible**: you can rebuild it and confirm you get the digest we +shipped, rather than taking our word that the image matches this source. Every build input is +pinned — the Go toolchain (`GOTOOLCHAIN` in `.config/mise/config.toml`), the ko version, and the +base image *by digest* rather than by its floating `:nonroot` tag — and ko zeroes layer timestamps. +`TestReleasePinsAreConsistent` and `TestBaseImageIsPinnedByDigest` (`internal/licenses/reproducible_test.go`) fail the build if any of those pins drift apart. -To reproduce a release: - ```sh git clone https://github.com/jleight/meshtender && cd meshtender git checkout v1.2.3 # the tag you are verifying @@ -181,28 +243,33 @@ mise run image Compare the printed `sha256:…` against the digest of the published image. The registry name is not part of the digest, so this works without any access to our registry. -Two things will change the digest, and both are intentional: +Two things change the digest, and both are intentional: - **The checkout must be clean and at the exact commit.** Go stamps the commit SHA, the commit - time, and a dirty-tree flag into the binary, so an edited or differently-tagged tree produces a - different digest. That is a feature — it binds the image to a specific commit — but it does mean - `git status` must be empty before you build. -- **The platform must match.** `mise run image` targets `linux/amd64` by default; pass + time, and a dirty-tree flag into the binary, so an edited tree produces a different digest. That + binds the image to a specific commit — but it does mean `git status` must be empty first. +- **The platform must match.** `mise run image` targets `linux/amd64`; pass `--platform linux/arm64` to verify that variant. ## Layout ``` -cmd/meshtender main / wiring -internal/config env config -internal/store Postgres + goose migrations + queries -internal/identity load-or-generate server identity, AES-GCM seal at rest -internal/mesh build AnonReq login packet, decode repeater RESPONSE -internal/auth WebAuthn + password + sessions -internal/wsbridge WebSocket ↔ hardware.Transport bridge -internal/web routing, templates, static assets, confirm orchestration +cmd/meshtender main / wiring; --seed and --reset +cmd/licenses the license auditor behind `mise run licenses` +internal/config env config +internal/store Postgres: goose migrations + raw SQL queries +internal/testdb ephemeral per-test database (template clone) +internal/identity load-or-generate server identity, AES-GCM seal at rest, backup envelope +internal/mesh AnonReq login packet, RESPONSE decode, exchanger (routing, retries, rate limit) +internal/wsbridge WebSocket ↔ hardware.Transport bridge to the browser's KISS modem +internal/geo GeoJSON containment/overlap for region geofences +internal/web HTTP foundation: renderer, middleware, host dispatcher, CSP, static assets +internal/auth auth host: WebAuthn + password + sessions + account management +internal/marketing root host: landing, docs, legal, public org/repeater discovery +internal/core app host: repeaters, console, orgs, configuration, sharing, admin +internal/analytics first-party request tracking + daily rollups +internal/mail Resend delivery, with a logging fallback +internal/licenses dependency license audit + non-Go manifest + reproducibility pins +internal/seed fake data for local testing +internal/e2e chromedp browser tests (build tag `browser`) ``` - -## Known follow-ups - -- (none currently) diff --git a/internal/licenses/licenses_test.go b/internal/licenses/licenses_test.go index 86ef90d..6247413 100644 --- a/internal/licenses/licenses_test.go +++ b/internal/licenses/licenses_test.go @@ -4,7 +4,6 @@ import ( "bytes" "crypto/sha256" "encoding/hex" - "fmt" "os" "path/filepath" "strings" @@ -303,26 +302,3 @@ func TestNoticesUsesUnixLineEndings(t *testing.T) { t.Errorf("%s contains a carriage return — run `mise run licenses --update`", NoticesPath) } } - -// TestReadmeVendoredVersionsMatchManifest keeps the README's version table from -// drifting, since it is the second place a version is written down. -func TestReadmeVendoredVersionsMatchManifest(t *testing.T) { - root := repoRoot(t) - b, err := os.ReadFile(filepath.Join(root, "README.md")) - if err != nil { - t.Fatalf("reading README.md: %v", err) - } - readme := string(b) - - for _, d := range Deps { - if d.Kind != KindAsset || d.Version == "" { - continue - } - // The table renders as: | [Name](url) | version | files | - row := fmt.Sprintf("| %s |", d.Version) - if !strings.Contains(readme, row) { - t.Errorf("README.md has no vendored-assets table row stating version %s for %s; "+ - "the table and the manifest disagree", d.Version, d.Name) - } - } -}