11 KiB
nostermd
A minimal NIP-29 managed-group relay for
Nosterm, built on
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
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"andfeatures: ["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
# 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)
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 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 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_KEYis required — the relay refuses to start without it. Generate one withopenssl rand -hex 32. - Development: if unset, a key is generated and persisted to
$RELAY_DATA_DIR/relay.key(mode0600) 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) oringest(read-only: pull chat from the peer, never push back). Defaults tomirror. - channels — a comma-separated list of group ids, or
*for all. Defaults to*. A leading#is tolerated.
Examples:
# 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
go test ./...
Tests live beside the package they cover:
internal/group— the group state machine: join → chat allowed, chat-before-join rejected, missingh-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'sunsafe-based JSON serializer trips the race detector'scheckptrduring 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'.