Files
nostermd-relay/README.md
Robert Goodall 8e9235a820
ci / build (pull_request) Successful in 23s
ci / image (pull_request) Skipped
seed default channel(s) on boot
fresh relay seeds configured open channels (RELAY_DEFAULT_CHANNELS,
default general) so a client always has a room. relay-signed 9007
through the normal pipeline; idempotent; never reopens a closed group.

also adds mission/roadmap and separation rationale to the readme.
2026-07-25 08:11:39 -04:00

296 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# nostermd
A minimal **NIP-29 managed-group** relay for
[Nosterm](https://www.nosterm.com), built on
[`khatru`](https://fiatjaf.com/nostr/khatru) (part of the `fiatjaf.com/nostr` monorepo),
plus the **Nosterm capability handshake** — a `features` array in its NIP-11 document so a
Nosterm client can feature-gate its UI and fall back gracefully on plain relays.
This is the "home relay" in Nosterm's hybrid model: the client connects to this relay for
its own communities while still connecting to public relays for general Nostr content.
## Mission
nostermd aims to be the premier self-hosted **home relay** for the Nosterm
[Nostr Relay Chat (NRC)](https://git.nerdworks.io/nerdworks/nosterm-client) client — a
complete, standards-based **NIP-29** group-chat backend anyone can run. We believe private
communication is a right, not a feature, so the relay is built to advance **anonymity,
end-to-end encryption, and censorship resistance** by default rather than as opt-in extras.
While we build features specifically to support the Nosterm client — the Nosterm capability
handshake being the first — we are committed to **standards over lock-in**. We are more than
happy to fold our developments into existing NIPs (or propose new ones) wherever the
protocol can absorb them, so that what we build stays interoperable with the wider Nostr
ecosystem rather than fragmenting into a Nosterm-only dialect.
Three principles guide every decision:
- **Small footprint.** A single pure-Go static binary with no cgo, running on a
`scratch`/distroless image. It should stay auditable, fast to start, and cheap to
self-host — no telemetry, no lock-in, no heavyweight dependencies.
- **Easy to deploy, anywhere.** One prebuilt image, configured at runtime, that drops
cleanly onto different **overlay network protocols** (Tor, I2P, Yggdrasil, Nym, and
WireGuard-style meshes) so operators can run it wherever they trust the transport.
- **Privacy as the default.** Anonymity, encryption, and anti-censorship are the baseline
behavior. The relay should make the private path the easy path for the client that talks
to it.
## Roadmap
The roadmap turns the mission into concrete direction. Items are aspirational and
unordered — contributions that move any of them forward are welcome.
- **Full NIP-29 implementation** — grow beyond the MVP subset into complete relay-based
groups: private/closed groups, roles, invites, the 39001/39002 admin/member lists, and
richer moderation so a self-hosted relay is a complete NRC backend.
- **Overlay-network first** — first-class, documented deployment recipes for Tor hidden
services, I2P, and other overlay transports, keeping the same single runtime-configured
image.
- **Encryption everywhere** — support modern encrypted group and direct messaging (e.g.
NIP-17 / gift-wrapped events) end to end, with no plaintext fallbacks that surprise
operators or their users.
- **Anonymity by design** — minimize metadata retained and leaked, support ephemeral and
disposable identities, and avoid any relay behavior that fingerprints or phones home.
- **Censorship resistance** — deepen **federation** so a channel survives any single relay
disappearing and can be distributed across operators, with graceful reconnection and
replay.
- **Client-driven, standards-first** — build the features the Nosterm client needs, but
upstream them: contribute our extensions back into existing NIPs (or propose new ones)
rather than growing a Nosterm-only dialect, keeping the relay useful to any Nostr client.
- **Stay small** — hold the line on binary size, dependency count, and container footprint
as features grow.
## Why a separate repository
The Nosterm client and the nostermd relay ship as two independent repositories — and two
independent container images — on purpose. Nostr is a client-side protocol: the browser
talks directly to relays over WebSocket, so there is no shared backend binding the two
together. Keeping them separate keeps that boundary honest and the architecture **modular**:
- **Mix and match.** The client is just a Nostr client — it works against any relay, not
only this one. The relay is just a Nostr relay — it serves any NIP-29 client, not only
Nosterm. Neither depends on the other's internals, so you can run one without the other.
- **Deploy each where it belongs.** A static SPA served by Caddy and a stateful Go relay
with a BoltDB store have different runtime, scaling, and persistence needs. Separate
images let operators place, scale, and secure each independently — a shared home relay
behind one client, one relay per client, many clients against a public relay, or the
same-origin `/relay` proxy setup.
- **Standards over coupling.** Because the split forces everything across the boundary to
travel as plain Nostr events, it keeps us honest about being **standards-first** (see
Mission) — the relay can never quietly grow a private, client-only channel that a
non-Nosterm client couldn't use.
- **Independent lifecycles.** Each repo has its own issues, releases, and contribution flow.
A relay change ships without rebuilding the client and vice versa, so the two can evolve
(and be audited) at their own pace.
If you want the batteries-included experience, the client's Caddy service can proxy `/relay`
to this relay on a shared Docker network — modular pieces, one deployment. But that's a
composition choice at deploy time, not a coupling baked into the code.
## What it does
- Serves NIP-01/11/42 via khatru, with pure-Go [BoltDB](https://github.com/etcd-io/bbolt)
storage (no cgo → a fully static binary that runs on `scratch`/distroless).
- Enforces a focused subset of **NIP-29 managed groups**:
| Kind | Event | Behavior |
| ----- | ---------------- | ------------------------------------------- |
| 9007 | create group | registers a group |
| 9021 | join request | adds the sender as a member (auto-creates) |
| 9022 | leave request | removes the sender |
| 9000 | put user (admin) | admin adds tagged members |
| 9001 | remove user | admin removes tagged members |
| 9/10 | chat / reply | accepted for members (open groups: anyone) |
| 39000 | group metadata | (re)published + signed by the relay |
- Advertises `software: "nostermd"` and `features: ["channels"]` in NIP-11 (adds
`"federation"` when any federation peer is configured).
- **Seeds a default channel on boot** so a fresh relay is never empty — a client always has
at least one open room (`#general` by default) to join. Configure with
`RELAY_DEFAULT_CHANNELS`.
- **Optional federation** — mirror/ingest chat with peer relays (see below).
> **MVP scope.** Groups are auto-created as **open** on first join so the client flow works
> without an invite system. Membership is authoritative in-memory; richer moderation,
> private/closed groups, roles, and the 39001/39002 admin/member lists are future work.
## Project layout
Standard Go layout — the command is a thin composition root over focused,
independently-testable packages:
```
nostermd/
cmd/nostermd/ # main: env config, key loading, khatru wiring, NIP-11 serving
internal/
group/ # NIP-29 managed-group state machine (membership, admin, metadata)
federation/ # optional chat mirroring/ingest with peer relays
retention/ # background pruning of old chat messages
relayinfo/ # Nosterm capability handshake (NIP-11 features array)
Dockerfile # static binary → distroless nonroot image
docker-compose.yml
```
## Run it
```bash
# Local (Go 1.25+)
go run ./cmd/nostermd # listens on :3334, ephemeral identity + ./data
# Configurable via env
RELAY_ADDR=:3334 \
RELAY_DATA_DIR=./data \
RELAY_NAME="My Relay" \
RELAY_SECRET_KEY=<64-char-hex> \ # stable identity (signs group metadata)
go run ./cmd/nostermd
# Docker (single image)
docker build -t nostermd .
docker run -p 3334:3334 -v nostermd-data:/data nostermd
# Or pull the prebuilt public image (no auth):
docker run -p 3334:3334 -v nostermd-data:/data public.ecr.aws/k3k1z1x5/nostermd:latest
```
Merges to `main` build and publish that image to ECR Public; `v*` tags publish a
matching versioned image. This repo builds the image only — it does not deploy.
### Docker Compose (recommended)
```bash
cp .env.example .env
# For production, generate a stable signing identity and set RELAY_ENV:
echo "RELAY_SECRET_KEY=$(openssl rand -hex 32)" >> .env
echo "RELAY_ENV=production" >> .env
docker compose up -d --build
```
The relay then listens on `ws://localhost:3334` and persists its BoltDB event store to a
named volume so rooms/messages survive restarts.
Point a Nosterm client at it via `PUBLIC_DEFAULT_RELAYS` — see the
[nosterm-client](https://git.nerdworks.io/nerdworks/nosterm-client) repository.
### Production TLS
The relay speaks plain WebSocket on `:3334`. For public `wss://` access, put it behind a
TLS-terminating reverse proxy (e.g. Caddy, or the
[nosterm-client](https://git.nerdworks.io/nerdworks/nosterm-client) Caddy service, which
can proxy `/relay` to this relay on a shared Docker network).
## Environment
| Variable | Default | Meaning |
| --------------------------------- | -------------------- | -------------------------------------------------------------- |
| `RELAY_ADDR` | `:3334` | Listen address |
| `RELAY_DATA_DIR` | `./data` | BoltDB storage directory |
| `RELAY_NAME` | `Nosterm Home Relay` | NIP-11 name |
| `RELAY_DESCRIPTION` | _(default text)_ | NIP-11 description |
| `RELAY_CONTACT` | `relay@nosterm.com` | NIP-11 contact (NIP-05-style operator address) |
| `RELAY_MOTD` | _(nerdworks.io banner)_ | Message-of-the-day shown in the client's relay server window (defaults to the built-in ascii banner) |
| `RELAY_ENV` | _(dev)_ | Set to `production` to require a stable key (fail-fast if unset) |
| `RELAY_DEFAULT_CHANNELS` | `general` | Comma-separated channel ids seeded as open groups on boot (leading `#` ok) |
| `RELAY_SECRET_KEY` | _(dev: persisted)_ | 64-char hex identity that signs group metadata/rosters |
| `RELAY_RETENTION_DAYS` | `0` | Prune chat messages older than N days (0 = unlimited) |
| `RELAY_RETENTION_MAX_MESSAGES` | `0` | Keep at most N chat messages, newest first (0 = unlimited) |
| `RELAY_RETENTION_INTERVAL_MINUTES`| `60` | How often the retention sweep runs |
| `RELAY_FEDERATION_PEERS` | _(empty: disabled)_ | Peer relays to mirror/ingest chat with (see Federation) |
### Relay identity (`RELAY_SECRET_KEY`)
The relay's key signs the group metadata/admins/members events (3900039002). A **stable**
identity is required so those signatures stay valid across restarts:
- **Production** (`RELAY_ENV=production`): `RELAY_SECRET_KEY` is **required** — the relay
refuses to start without it. Generate one with `openssl rand -hex 32`.
- **Development**: if unset, a key is generated and **persisted** to
`$RELAY_DATA_DIR/relay.key` (mode `0600`) and reused on every restart, so even local dev
keeps one identity.
### Retention
By default nothing is pruned. Set `RELAY_RETENTION_DAYS` and/or
`RELAY_RETENTION_MAX_MESSAGES` to cap growth. Retention **only** deletes chat messages
(kinds 9/10); group-management and metadata events (9007/9000/9001/9021/9022, 3900039002)
are never pruned so membership always survives and can be replayed on boot.
## Federation (optional)
Federation lets a channel live on more than one relay, so it survives any single relay going
down and can be distributed across operators. It is **off by default** and **chat-only** by
design: only kind-9/10 messages cross the relay boundary. Membership, admins, bans, and
metadata stay **local to each relay** — every operator moderates their own instance, which
sidesteps cross-relay moderation conflicts entirely.
Configure peers with `RELAY_FEDERATION_PEERS`. Entries are separated by `;`; each is:
```
<wss-url>[|<mode>:<channels>]
```
- **mode** — `mirror` (bidirectional: chat posted on either relay is forwarded to the other)
or `ingest` (read-only: pull chat _from_ the peer, never push back). Defaults to `mirror`.
- **channels** — a comma-separated list of group ids, or `*` for all. Defaults to `*`. A
leading `#` is tolerated.
Examples:
```bash
# Mirror two channels with peer A, ingest everything from peer B (read-only):
RELAY_FEDERATION_PEERS="wss://a.example|mirror:general,dev;wss://b.example|ingest:*"
# Read-only announcement feed pulled from an upstream relay:
RELAY_FEDERATION_PEERS="wss://announce.example|ingest:announcements"
# Simplest: fully mirror every channel with one peer:
RELAY_FEDERATION_PEERS="wss://peer.example"
```
**How it works.** The relay opens a client subscription to each peer for the federated chat
kinds and injects received events through its own add pipeline (`OnEvent` → store →
`OnEventSaved`), then broadcasts them to local subscribers. Locally-saved chat is forwarded
to `mirror` peers from the `OnEventSaved` hook. Incoming events are **re-verified** (a peer
can't inject forged messages) and scoped to the configured channels.
**Loop prevention is structural, not tag-based.** A re-received event is a duplicate in the
eventstore, and `AddEvent` stops on `ErrDupEvent` _before_ the egress hook runs — so an event
is never forwarded twice and `A↔B↔A` cycles die on the second sight. There is no "seen" set to
tune or get wrong. Peers reconnect with capped backoff; on reconnect the peer replays stored
events so nothing missed during an outage is lost (deduped on arrival).
**Trust model.** Federate only with relays you trust to authorize their own posters — a
federated channel behaves as open on every participating relay (federated chat bypasses
_local_ membership because the author is a member on the peer that accepted it). Federation is
strictly chat; it never imports another relay's membership, admin, or ban state.
## Tests
```bash
go test ./...
```
Tests live beside the package they cover:
- `internal/group` — the group state machine: join → chat allowed, chat-before-join rejected,
missing `h`-tag rejected, **multi-user administrator moderation** in a closed group (creator
is admin; non-admins cannot add/remove users; admin-approved members can post; removed
members are blocked; open groups allow anyone), and **state survives a rebuild** from the
event store.
- `internal/retention` — prunes old/surplus chat by age and count but never
management/metadata events.
- `internal/federation` — config parsing (modes, channel scoping, invalid-URL/empty-list
rejection), ingress signature + scope enforcement, and the loop-prevention contract.
- `cmd/nostermd` — integration over a real in-process khatru server: **relay-key
handling** (explicit key used verbatim; dev key persisted and stable across restarts), a
**cross-user** WebSocket round-trip (one client joins and publishes, another receives via
subscription), **default-channel seeding** (a fresh relay seeds its configured open
channels, is idempotent, and never reopens a pre-existing closed channel), and two
end-to-end **federation** two-relay tests (a message ingested across relays, and a mirrored
post delivered exactly once despite the echo path).
> Run without `-race`: go-nostr's `unsafe`-based JSON serializer trips the race detector's
> `checkptr` during any event signing (an upstream library issue, not a relay data race). The
> relay's own group-state locking is race-clean — verify with the state-machine tests that
> don't sign events:
> `go test ./internal/group/ -race -run 'TestAdminModerationInClosedGroup|TestOpenGroup'`.