212 lines
11 KiB
Markdown
212 lines
11 KiB
Markdown
# 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 (39000–39002). 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, 39000–39002)
|
||
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'`.
|