initial public relay build repo
ci / build (push) Successful in 2m3s
ci / image (push) Successful in 1m4s

This commit is contained in:
2026-07-24 13:11:06 -04:00
commit 294616642f
24 changed files with 2957 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
# 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'`.