initial public relay build repo
This commit is contained in:
@@ -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 (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'`.
|
||||
Reference in New Issue
Block a user