// Command nostermd is a minimal NIP-29 managed-group relay built on khatru, // plus the Nosterm capability handshake (a `features` array in its NIP-11 doc). // // It is intentionally small: it enforces group membership for chat writes, // auto-creates open channels on first join, and advertises itself as a // "nostermd" so a Nosterm client can feature-gate its UI. The relay is // assembled from focused internal packages (group, federation, retention, // relayinfo); this file is the composition root that wires them to env config // and the network. package main import ( "log" "net/http" "os" "path/filepath" "time" "fiatjaf.com/nostr/eventstore/boltdb" "github.com/nosterm/relay/internal/federation" "github.com/nosterm/relay/internal/retention" ) // defaultMOTD is the built-in message-of-the-day: a "nerdworks.io" ascii banner // (figlet, standard font) the client shows when RELAY_MOTD is unset. const defaultMOTD = " _ _ _\n" + " _ __ ___ _ __ __| |_ _____ _ __| | _____ (_) ___\n" + "| '_ \\ / _ \\ '__/ _` \\ \\ /\\ / / _ \\| '__| |/ / __| | |/ _ \\\n" + "| | | | __/ | | (_| |\\ V V / (_) | | | <\\__ \\_| | (_) |\n" + "|_| |_|\\___|_| \\__,_| \\_/\\_/ \\___/|_| |_|\\_\\___(_)_|\\___/\n" func main() { addr := envOr("RELAY_ADDR", ":3334") dataDir := envOr("RELAY_DATA_DIR", "./data") // Embedded, pure-Go storage (no cgo → clean static Docker builds). if err := os.MkdirAll(dataDir, 0o755); err != nil { log.Fatalf("cannot create data dir: %v", err) } // Relay identity: signs the 39000-series group metadata/roster events. A // stable key is required so those signatures survive restarts (see below). sk := loadOrGenerateKey(dataDir) store := &boltdb.BoltBackend{Path: filepath.Join(dataDir, "events.bolt")} if err := store.Init(); err != nil { log.Fatalf("cannot open eventstore: %v", err) } // Parse optional federation config up front so warnings surface at boot. fedCfg, fedWarnings := federation.ParseConfig(os.Getenv("RELAY_FEDERATION_PEERS")) for _, w := range fedWarnings { log.Printf("federation config: %s", w) } app := buildRelay(relayConfig{ secretKey: sk, store: store, name: envOr("RELAY_NAME", "Nosterm Home Relay"), description: envOr("RELAY_DESCRIPTION", "A Nosterm NIP-29 managed-group relay."), // NIP-11 contact: a NIP-05-style identifier / address for the relay operator. 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, }) // Reconstruct membership/metadata from persisted events so groups survive // restarts (without this, every restart silently drops all group state). if replayed := app.groups.Rebuild(store); replayed > 0 { log.Printf("replayed %d group management event(s) from storage", replayed) } // 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, MaxMessages: envIntOr("RELAY_RETENTION_MAX_MESSAGES", 0), Interval: time.Duration(envIntOr("RELAY_RETENTION_INTERVAL_MINUTES", 60)) * time.Minute, }) defer stopRetention() // Start federation last, once the relay pipeline is fully wired. if app.federator != nil { app.federator.Start() defer app.federator.Stop() } log.Printf("nostermd pubkey: %s", app.pubKey.Hex()) log.Printf("listening on %s (%s; %s)", addr, app.groups.Summary(), fedCfg.Summary()) if err := http.ListenAndServe(addr, app.handler); err != nil { log.Fatal(err) } }