2026-07-31 21:25:45 -04:00
2026-07-07 22:37:48 -04:00
2026-07-07 22:37:48 -04:00
2026-07-08 18:30:43 -04:00
2026-06-20 13:24:07 -04:00
2026-07-31 21:25:45 -04:00
2026-07-31 21:25:45 -04:00
2026-06-21 19:21:17 -04:00
2026-06-21 19:21:17 -04:00
2026-06-28 19:59:09 -04:00
2026-06-19 10:18:33 -04:00
2026-07-08 18:30:43 -04:00
2026-07-30 20:15:51 -04:00
2026-07-30 20:15:51 -04:00
2026-07-11 22:23:22 -04:00

MeshTender

A Go web app for tending MeshCore meshes. MeshTender holds a single server-wide MeshCore identity (Ed25519 + X25519). Repeater owners grant it admin by running setperm <server_pubkey> 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.

How the hardware confirm/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 — 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.

Stack

  • Go 1.26, 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

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.

Current pinned versions:

Library Version Files
htmx 2.0.10 htmx.min.js
Leaflet 1.9.4 leaflet.js, leaflet.css
Leaflet-Geoman 2.20.0 leaflet-geoman.js, leaflet-geoman.css
Leaflet.markercluster 1.5.3 leaflet.markercluster.js, leaflet.markercluster.css
Tabler 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@<version>/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).

Running locally

One process serves all three hosts (root, auth, app) over TLS. Dev can't use *.localhostlocalhost 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 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.

docker compose up -d                 # Postgres

# One-time: trust a local CA and mint 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

Then open https://app.leighthaus.dev:8080 (dashboard) or https://leighthaus.dev:8080 (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.

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.

Note: MESHTENDER_MASTER_KEY is coupled to the stored identity — changing it makes the existing server_identity row undecryptable. Keep it stable.

Tests

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. 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.

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:

MESHTENDER_TEST_DATABASE_URL="postgres://meshtender:meshtender@localhost:5432/postgres?sslmode=disable" \
  go test ./internal/core/ -run TestConfirmRoundTrip -v

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

Known follow-ups

  • (none currently)
S
Description
No description provided
Readme AGPL-3.0
2.3 MiB
Languages
Go 78.3%
HTML 15.4%
JavaScript 5.4%
CSS 0.6%
PLpgSQL 0.3%