rgoodall 294616642f
ci / build (push) Successful in 2m3s
ci / image (push) Successful in 1m4s
initial public relay build repo
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00
2026-07-24 13:11:06 -04:00

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" 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

# 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.

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 (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>]
  • modemirror (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:

# 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, 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'.

S
Description
Public build+publish repo for the nostermd relay image (ECR Public)
Readme
75 KiB
Languages
Go 98.5%
Dockerfile 1.5%