From 8e9235a820e41442caaee865abee802260b74c4c Mon Sep 17 00:00:00 2001 From: Robert Goodall Date: Sat, 25 Jul 2026 08:11:39 -0400 Subject: [PATCH] seed default channel(s) on boot 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. --- .env.example | 4 ++ README.md | 88 +++++++++++++++++++++++++++++++++++++- cmd/nostermd/config.go | 17 ++++++++ cmd/nostermd/main.go | 11 ++++- cmd/nostermd/relay.go | 59 ++++++++++++++++++++----- cmd/nostermd/relay_test.go | 48 +++++++++++++++++++++ internal/group/group.go | 8 ++++ 7 files changed, 220 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 4763160..3336e19 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ RELAY_CONTACT=relay@nosterm.com # the built-in "nerdworks.io" ascii banner. RELAY_MOTD= +# Channels seeded as open groups on boot so a fresh relay is never empty. +# Comma-separated group ids (leading '#' tolerated). Unset = "general". +RELAY_DEFAULT_CHANNELS=general + # Retention (chat messages only; membership/metadata are never pruned). # 0 = unlimited. RELAY_RETENTION_DAYS=0 diff --git a/README.md b/README.md index 51b01fc..2bfea0a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,84 @@ 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) @@ -25,6 +103,9 @@ its own communities while still connecting to public relays for general Nostr co | 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 @@ -108,6 +189,7 @@ can proxy `/relay` to this relay on a shared Docker network). | `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) | @@ -201,8 +283,10 @@ Tests live beside the package they cover: - `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). + 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 diff --git a/cmd/nostermd/config.go b/cmd/nostermd/config.go index 9c06bea..4df39ae 100644 --- a/cmd/nostermd/config.go +++ b/cmd/nostermd/config.go @@ -5,10 +5,27 @@ import ( "os" "path/filepath" "strconv" + "strings" "fiatjaf.com/nostr" ) +// parseChannels splits a comma-separated channel list into cleaned group ids: a +// leading `#` is tolerated and blanks/dupes are dropped. Order is preserved. +func parseChannels(s string) []string { + var out []string + seen := map[string]bool{} + for _, part := range strings.Split(s, ",") { + id := strings.TrimPrefix(strings.TrimSpace(part), "#") + if id == "" || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out +} + func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/cmd/nostermd/main.go b/cmd/nostermd/main.go index e81d5cf..77bfbc9 100644 --- a/cmd/nostermd/main.go +++ b/cmd/nostermd/main.go @@ -10,6 +10,7 @@ package main import ( + "context" "log" "net/http" "os" @@ -63,8 +64,10 @@ func main() { contact: envOr("RELAY_CONTACT", "relay@nosterm.com"), // Optional message-of-the-day shown in the client's relay server window; // defaults to the nerdworks.io banner when unset. - motd: envOr("RELAY_MOTD", defaultMOTD), - fedCfg: fedCfg, + motd: envOr("RELAY_MOTD", defaultMOTD), + // Seed at least one open channel so a fresh relay is never empty. + defaultChannels: parseChannels(envOr("RELAY_DEFAULT_CHANNELS", "general")), + fedCfg: fedCfg, }) // Reconstruct membership/metadata from persisted events so groups survive @@ -73,6 +76,10 @@ func main() { log.Printf("replayed %d group management event(s) from storage", replayed) } + // Seed default channel(s) after replay so a fresh relay always has a room to + // join; existing channels are left untouched. + app.SeedDefaultChannels(context.Background()) + // Retention: prune old chat messages so the store doesn't grow forever. stopRetention := retention.Start(store, retention.Config{ MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour, diff --git a/cmd/nostermd/relay.go b/cmd/nostermd/relay.go index c7da43f..9d31248 100644 --- a/cmd/nostermd/relay.go +++ b/cmd/nostermd/relay.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "log" "net/http" "fiatjaf.com/nostr" @@ -24,18 +25,23 @@ type relayConfig struct { description string contact string motd string - fedCfg federation.Config + // defaultChannels are group ids seeded as open channels on boot so a fresh + // relay is never empty — a client always has at least one room to join. + defaultChannels []string + fedCfg federation.Config } // relayApp is a fully-wired relay: the khatru relay, its HTTP handler, the group // state authority, and (when configured) the federator. main starts/stops these; // tests assert against them. type relayApp struct { - relay *khatru.Relay - handler http.Handler - groups *group.State - federator *federation.Federator - pubKey nostr.PubKey + relay *khatru.Relay + handler http.Handler + groups *group.State + federator *federation.Federator + pubKey nostr.PubKey + secretKey nostr.SecretKey + defaultChannels []string } // buildRelay assembles the relay from its internal packages and wires khatru's @@ -146,11 +152,42 @@ func buildRelay(cfg relayConfig) *relayApp { }) return &relayApp{ - relay: relay, - handler: mux, - groups: groups, - federator: fed, - pubKey: pk, + relay: relay, + handler: mux, + groups: groups, + federator: fed, + pubKey: pk, + secretKey: cfg.secretKey, + defaultChannels: cfg.defaultChannels, + } +} + +// SeedDefaultChannels creates any configured default channel that doesn't +// already exist, so a fresh relay always has at least one room to join. Each +// missing channel is created by the relay identity via a signed kind-9007 +// create-group event pushed through the normal add pipeline (OnEvent → store → +// OnEventSaved), so it persists, replays on restart, and publishes its 39000/ +// 39001/39002 lists exactly like a client-created group. Existing channels are +// left untouched. Call after Rebuild so it only seeds what's genuinely missing. +func (app *relayApp) SeedDefaultChannels(ctx context.Context) { + for _, id := range app.defaultChannels { + if id == "" || app.groups.Has(id) { + continue + } + evt := nostr.Event{ + Kind: nostr.KindSimpleGroupCreateGroup, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{{"h", id}}, + } + if err := evt.Sign(app.secretKey); err != nil { + log.Printf("could not sign default-channel create for %q: %v", id, err) + continue + } + if _, err := app.relay.AddEvent(ctx, evt); err != nil { + log.Printf("could not seed default channel %q: %v", id, err) + continue + } + log.Printf("seeded default channel %q", id) } } diff --git a/cmd/nostermd/relay_test.go b/cmd/nostermd/relay_test.go index 7d2d9c7..e81bf87 100644 --- a/cmd/nostermd/relay_test.go +++ b/cmd/nostermd/relay_test.go @@ -192,3 +192,51 @@ func TestMultiUserAdminOverWebsocket(t *testing.T) { t.Fatal("admin did not receive the admitted member's message") } } + +// SeedDefaultChannels creates configured channels that don't exist yet, signed +// by the relay, and is idempotent: seeding an already-present channel is a +// no-op. A seeded open channel accepts chat without an explicit create. +func TestSeedDefaultChannels(t *testing.T) { + store := &slicestore.SliceStore{} + if err := store.Init(); err != nil { + t.Fatalf("store init: %v", err) + } + app := buildRelay(relayConfig{ + secretKey: nostr.Generate(), + store: store, + name: "test-relay", + defaultChannels: []string{"general", "general", ""}, // dupes/blanks ignored + }) + + ctx := context.Background() + app.SeedDefaultChannels(ctx) + if !app.groups.Has("general") { + t.Fatal("expected default channel 'general' to be seeded") + } + // The seed event is signed by the relay identity. + if !app.groups.IsAdmin("general", app.pubKey) { + t.Fatal("expected the relay to be admin of the seeded channel") + } + + // Idempotent: a second seed doesn't error or duplicate. + app.SeedDefaultChannels(ctx) + + // Seeding never clobbers an existing channel: pre-create one closed, then a + // seed of the same id must leave it closed. + closed := nostr.Event{Kind: nostr.KindSimpleGroupCreateGroup, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}, {"closed"}}} + mustSign(t, app.secretKey, &closed) + if _, err := app.relay.AddEvent(ctx, closed); err != nil { + t.Fatalf("pre-create closed group: %v", err) + } + app.defaultChannels = []string{"ops"} + app.SeedDefaultChannels(ctx) + // A non-member posting to the still-closed 'ops' must be rejected. + stranger := nostr.Generate() + if reject, _ := app.groups.Evaluate(func() nostr.Event { + e := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}}} + mustSign(t, stranger, &e) + return e + }()); !reject { + t.Fatal("seed must not reopen a pre-existing closed channel") + } +} diff --git a/internal/group/group.go b/internal/group/group.go index 4cb2e0e..7e766c5 100644 --- a/internal/group/group.go +++ b/internal/group/group.go @@ -267,6 +267,14 @@ func (gs *State) Evaluate(evt nostr.Event) (reject bool, msg string) { return false, "" } +// Has reports whether a group with the given id is tracked. +func (gs *State) Has(id string) bool { + gs.mu.RLock() + defer gs.mu.RUnlock() + _, ok := gs.groups[id] + return ok +} + // IsMember reports whether pk is a member of the group. func (gs *State) IsMember(id string, pk nostr.PubKey) bool { gs.mu.RLock() -- 2.54.0