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.
296 lines
16 KiB
Markdown
296 lines
16 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.
|
||
|
||
## 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 (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), **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'`.
|