Merge pull request 'seed default channel(s) on boot' (#1) from feat/default-channels into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -21,6 +21,10 @@ RELAY_CONTACT=relay@nosterm.com
|
|||||||
# the built-in "nerdworks.io" ascii banner.
|
# the built-in "nerdworks.io" ascii banner.
|
||||||
RELAY_MOTD=
|
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).
|
# Retention (chat messages only; membership/metadata are never pruned).
|
||||||
# 0 = unlimited.
|
# 0 = unlimited.
|
||||||
RELAY_RETENTION_DAYS=0
|
RELAY_RETENTION_DAYS=0
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## What it does
|
||||||
|
|
||||||
- Serves NIP-01/11/42 via khatru, with pure-Go [BoltDB](https://github.com/etcd-io/bbolt)
|
- 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 |
|
| 39000 | group metadata | (re)published + signed by the relay |
|
||||||
- Advertises `software: "nostermd"` and `features: ["channels"]` in NIP-11 (adds
|
- Advertises `software: "nostermd"` and `features: ["channels"]` in NIP-11 (adds
|
||||||
`"federation"` when any federation peer is configured).
|
`"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).
|
- **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
|
> **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_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_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_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_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_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_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
|
- `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
|
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
|
**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
|
subscription), **default-channel seeding** (a fresh relay seeds its configured open
|
||||||
relays, and a mirrored post delivered exactly once despite the echo path).
|
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
|
> 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
|
> `checkptr` during any event signing (an upstream library issue, not a relay data race). The
|
||||||
|
|||||||
@@ -5,10 +5,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"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 {
|
func envOr(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -64,6 +65,8 @@ func main() {
|
|||||||
// Optional message-of-the-day shown in the client's relay server window;
|
// Optional message-of-the-day shown in the client's relay server window;
|
||||||
// defaults to the nerdworks.io banner when unset.
|
// defaults to the nerdworks.io banner when unset.
|
||||||
motd: envOr("RELAY_MOTD", defaultMOTD),
|
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,
|
fedCfg: fedCfg,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,6 +76,10 @@ func main() {
|
|||||||
log.Printf("replayed %d group management event(s) from storage", replayed)
|
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.
|
// Retention: prune old chat messages so the store doesn't grow forever.
|
||||||
stopRetention := retention.Start(store, retention.Config{
|
stopRetention := retention.Start(store, retention.Config{
|
||||||
MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour,
|
MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -24,6 +25,9 @@ type relayConfig struct {
|
|||||||
description string
|
description string
|
||||||
contact string
|
contact string
|
||||||
motd string
|
motd string
|
||||||
|
// 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
|
fedCfg federation.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +40,8 @@ type relayApp struct {
|
|||||||
groups *group.State
|
groups *group.State
|
||||||
federator *federation.Federator
|
federator *federation.Federator
|
||||||
pubKey nostr.PubKey
|
pubKey nostr.PubKey
|
||||||
|
secretKey nostr.SecretKey
|
||||||
|
defaultChannels []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildRelay assembles the relay from its internal packages and wires khatru's
|
// buildRelay assembles the relay from its internal packages and wires khatru's
|
||||||
@@ -151,6 +157,37 @@ func buildRelay(cfg relayConfig) *relayApp {
|
|||||||
groups: groups,
|
groups: groups,
|
||||||
federator: fed,
|
federator: fed,
|
||||||
pubKey: pk,
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,3 +192,51 @@ func TestMultiUserAdminOverWebsocket(t *testing.T) {
|
|||||||
t.Fatal("admin did not receive the admitted member's message")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -267,6 +267,14 @@ func (gs *State) Evaluate(evt nostr.Event) (reject bool, msg string) {
|
|||||||
return false, ""
|
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.
|
// IsMember reports whether pk is a member of the group.
|
||||||
func (gs *State) IsMember(id string, pk nostr.PubKey) bool {
|
func (gs *State) IsMember(id string, pk nostr.PubKey) bool {
|
||||||
gs.mu.RLock()
|
gs.mu.RLock()
|
||||||
|
|||||||
Reference in New Issue
Block a user