Files
MeshTender/README.md
T

399 lines
24 KiB
Markdown

# MeshTender
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.
MeshTender holds a single server-wide MeshCore identity (Ed25519 + X25519). A repeater owner grants
it admin by running `setperm <server_pubkey> 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.
## Why it holds full admin, and why you shouldn't host your own
MeshTender asks for the strongest access a MeshCore repeater can grant. That's forced by the
protocol rather than chosen for convenience, and it shapes who should be running the server — and,
in turn, why this is AGPL rather than the MIT license it would otherwise carry. Worth reading before
you deploy a copy of your own.
### The permission problem it exists to solve
MeshCore's repeater ACL has four roles — guest (0), read-only (1), read-write (2), admin (3) — but
only one of them can run commands. A remote CLI command is processed if and only if the sender is
admin (`if (type == PAYLOAD_TYPE_TXT_MSG && … && client->isAdmin())`, `simple_repeater/MyMesh.cpp`).
There is no "may set transmit power but not rewrite the region table" role, and guest is available
to anyone with a blank password. So any tool that runs commands on your repeater on your behalf
holds **full admin**, because nothing weaker can do anything at all.
MeshTender's whole job is to put the missing permission system on top of that single grant: a
per-user command allowlist that denies by default, a per-org ceiling the repeater owner opts into,
an audit log of who ran what and when, and revocation that takes effect immediately without
touching the hardware. An org can share repeater administration under limits MeshCore itself cannot
express.
### Why you shouldn't host your own
Those limits are enforced by the server, which means whoever runs the server is *outside* them:
they hold the key, and every restriction is theirs to lift. That is unavoidable, and it decides
who should be running it.
If an org's admins host their own instance, then that org's members are back to trusting those
admins with unrestricted access to their repeaters — the exact situation MeshTender exists to fix,
now with a UI implying the restrictions are real. The guarantee only holds when whoever runs the
server isn't a party to the trust question the software is meant to settle — which, for an org's
own repeaters, its own admins always are. For most MeshCore projects self-hosting is the obviously
correct answer; here it quietly removes the point of the software.
### Which means trusting me, so here is what that is worth
I'm aware this is a hard sell, and it should be. What I can offer:
- **Distance.** Administering a repeater takes a modem within LoRa range of it. I'm not near your
mesh, so the identity key on my server is not usefully exploitable by me except *through this
app* — where it is logged.
- **The audit log.** Every command carries who sent it and when, per repeater, visible to the
people who granted access.
- **Revocation on the hardware.** You granted access on the device with `setperm`, and you can
withdraw it there the same way, whatever the server does. See the [docs](https://meshtender.com/docs).
- **Open source.** The thing above is only checkable if you can read what's running, which is why
this repository exists.
- **Reproducible builds.** Rebuild the commit `/version` reports and confirm the digest matches, so
"the source is public" and "the source is what's deployed" are separate, checkable claims. See
[Verifying a build](#verifying-a-build).
None of that is proof. Together it's the strongest position I know how to take given a protocol
with no permissions.
AGPL closes the remaining hole. If someone does run a modified copy as a service, section 13
requires that its users can get *its* source — so their instance can be audited exactly the way
this one can, rather than being an unauditable server wearing familiar software's clothes. The
mechanics of that, and what a fork has to change, are in
[License, self-hosting, and forking](#license-self-hosting-and-forking).
## 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:
```
server ──(WebSocket, KISS bytes)──▶ browser ──(WebSerial)──▶ KISS modem ──(LoRa)──▶ repeater
◀────────────────────────── ◀─────────────── ◀────────────
```
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. 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.** Site-wide 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 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 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`.
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.
**Updating one:** fetch the minified build from jsdelivr (mirrors npm exactly), e.g.
`https://cdn.jsdelivr.net/npm/htmx.org@<version>/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 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`
is preconfigured for `leighthaus.dev`; point its subdomains at `127.0.0.1` (real DNS or
`/etc/hosts`), or swap in your own dev domain by editing the host + `RP_ID`/`RP_ORIGIN` vars there.
```sh
docker compose up -d # Postgres
# 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)
mise run dev # migrates on boot; serves HTTPS on :8080
```
Then open <https://app.leighthaus.dev:8080> (dashboard) or <https://leighthaus.dev:8080> (public
root). WebSerial requires a secure context, which the mkcert HTTPS provides — the same reason
meshtender.com is served 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 <regex>` 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)
| Var | Purpose |
|---|---|
| `MESHTENDER_DATABASE_URL` | Postgres DSN (required) |
| `MESHTENDER_MASTER_KEY` | 64 hex chars (32 bytes); AES-GCM key encrypting the identity seed at rest (required) |
| `MESHTENDER_ADDR` | listen address (default `:8080`) |
| `MESHTENDER_ROOT_HOST` / `_AUTH_HOST` | host topology — **required** (root = public discovery, auth = sign-in); the server refuses to start without both |
| `MESHTENDER_PRIMARY_HOST` / `_WWW_HOST` | app host and the www→root redirector (`_WWW_HOST` defaults to `www.` + root) |
| `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) |
| `MESHTENDER_IMAGE_DIGEST` | the image digest this server runs as, reported by `/version` (see [Verifying a build](#verifying-a-build)). Set by the deploy; unset when running from source. A malformed value is a startup error |
> **Note:** `MESHTENDER_MASTER_KEY` is coupled to the stored identity — changing it makes the
> 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
```sh
go test ./...
```
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, 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 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.
MeshTender is itself AGPL-3.0, which sounds like a contradiction and isn't: licensing copyleft
*out* doesn't oblige us to accept copyleft *in*. A GPL or AGPL dependency compiled into the binary
would end the sole copyright holder's ability to release MeshTender under any other terms later,
and LGPL/MPL attach per-file obligations that fit a statically linked, fully embedded Go binary
badly. The full reasoning sits at `AllowedSPDX`.
`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 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)
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 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 check what's actually running, start from **`GET /version`** — unauthenticated, because the
people best placed to check our work are the ones without an account:
```console
$ curl -s https://meshtender.com/version
{
"commit": "62e30036ee0bfb28f6c1a4a3f5ac5f4a52e4b1c9",
"commitTime": "2026-08-06T17:47:49-04:00",
"modified": false,
"go": "go1.26.5",
"os": "linux",
"arch": "amd64",
"executableSHA256": "9f2c…",
"imageDigest": "sha256:a41b…",
"source": "https://github.com/MeshTender/MeshTender",
"license": "AGPL-3.0-only"
}
```
`source` and `license` are the AGPL section 13 source offer in machine-readable form
(the footer of every page carries the same link). Unlike the fields below them they're
compiled-in constants rather than measured facts — an assertion by whoever built the
binary, which `commit` then lets you check.
Then rebuild that commit for that platform and compare digests:
```sh
git clone https://github.com/MeshTender/MeshTender && cd MeshTender
git checkout 62e30036ee0bfb28f6c1a4a3f5ac5f4a52e4b1c9 # the commit /version reported
mise install # installs the pinned Go and ko
mise run image --platform linux/amd64 # the os/arch /version reported
```
The printed `sha256:…` should equal `imageDigest`. The registry name is not part of a digest, so
this works without any access to our registry — you never have to pull anything of ours.
What each field is worth is deliberately different, and worth knowing when you audit:
| Field | Attested by |
|---|---|
| `commit`, `commitTime`, `modified`, `go`, `os`, `arch` | The Go toolchain, stamped at compile time. Our code doesn't choose these. |
| `executableSHA256` | Measured at runtime, by the process itself, over the file it is running from. The only field about the *running* process rather than about a build. To check it, extract `/ko-app/meshtender` from your own build and hash it. |
| `imageDigest` | Our pipeline. A binary can't derive its own image digest — the digest is computed over the binary — so CI captures it at publish time (`ko build --image-refs`) and the deploy passes it in as `MESHTENDER_IMAGE_DIGEST`, deploying by digest in the same patch. Treat it as a claim to check, not as proof. |
A build from a modified tree reports `"modified": true`, and its `commit` does **not** describe the
source it was built from — such a build can't be reproduced from that commit, by anyone. Admins see
the same data plus copy-paste reproduction commands at `/admin/build`.
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 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.
## License, self-hosting, and forking
MeshTender is free software under the **GNU AGPL v3.0** ([`LICENSE`](LICENSE)). Its **name and
logo are not** ([`TRADEMARKS.md`](TRADEMARKS.md)). Why that pairing, rather than the MIT license
this project would otherwise carry, is the argument at the top:
[Why it holds full admin](#why-it-holds-full-admin-and-why-you-shouldnt-host-your-own).
Self-hosting is permitted and this file isn't going to pretend otherwise — the case above is an
argument, not a restriction. If you run a modified copy, the AGPL asks you to publish your source
and offer it to your users — `SourceURL` in `internal/web/version.go` is the constant behind the
footer link and the `/version` field, so point it at your fork. Separately, the MeshTender name and
mark are reserved, so your instance needs its own: [`TRADEMARKS.md`](TRADEMARKS.md) says exactly
what to change.
Contributions aren't being accepted yet — the licensing questions that come with them (inbound
terms, and whether relicensing later stays possible) haven't been settled.
## Layout
```
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`)
```