Files
nostermd-relay/README.md
T
rgoodall 294616642f
ci / build (push) Successful in 2m3s
ci / image (push) Successful in 1m4s
initial public relay build repo
2026-07-24 13:11:06 -04:00

212 lines
11 KiB
Markdown
Raw 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.
## 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).
- **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_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), 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'`.