2026-07-02 19:19:16 -04:00
2026-06-20 13:24:07 -04:00
2026-06-24 23:26:44 -04:00
2026-06-24 20:01:22 -04:00
2026-07-02 19:19:16 -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-06-21 13:53:40 -04:00
2026-07-02 12:47:03 -04:00
2026-07-02 12:47:03 -04:00
2026-06-23 19:11:54 -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

Running locally

docker compose up -d                 # Postgres
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 http://localhost:8080

WebSerial requires a secure context: it works on http://localhost for dev, but a real deployment must 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_RP_ID / _RP_ORIGIN / _RP_NAME WebAuthn relying-party settings
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

  • An abandoned passkey signup leaves an orphan account holding that username (can't log in, blocks re-signup). Add cleanup or upsert-on-begin.
  • Passkey navigator.credentials create/get is only verifiable in a real browser / virtual authenticator — not covered by automated tests.
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%