main.go raw
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/adrg/xdg"
15 "golang.org/x/term"
16 "next.orly.dev/pkg/lol/chk"
17 "next.orly.dev/pkg/lol/log"
18 "next.orly.dev/app"
19 "next.orly.dev/app/branding"
20 "next.orly.dev/app/config"
21 "next.orly.dev/pkg/nostr/crypto/keys"
22 "next.orly.dev/pkg/nostr/encoders/bech32encoding"
23 "next.orly.dev/pkg/database"
24 "next.orly.dev/pkg/nostr/encoders/hex"
25 "next.orly.dev/pkg/relay"
26 "next.orly.dev/pkg/version"
27 )
28
29 func main() {
30 // Handle 'version' subcommand early, before any other initialization
31 if config.VersionRequested() {
32 fmt.Println(version.V)
33 os.Exit(0)
34 }
35
36 var err error
37 var cfg *config.C
38 if cfg, err = config.New(); chk.T(err) {
39 }
40 log.I.F("starting %s %s", cfg.AppName, version.V)
41
42 // Handle 'init-branding' subcommand: create branding directory with default assets
43 if requested, targetDir, style := config.InitBrandingRequested(); requested {
44 if targetDir == "" {
45 targetDir = filepath.Join(xdg.ConfigHome, cfg.AppName, "branding")
46 }
47
48 // Validate and convert style
49 var brandingStyle branding.BrandingStyle
50 switch style {
51 case "orly":
52 brandingStyle = branding.StyleORLY
53 case "generic", "":
54 brandingStyle = branding.StyleGeneric
55 default:
56 fmt.Fprintf(os.Stderr, "Unknown style: %s (use 'orly' or 'generic')\n", style)
57 os.Exit(1)
58 }
59
60 fmt.Printf("Initializing %s branding kit at: %s\n", style, targetDir)
61 if err := branding.InitBrandingKit(targetDir, app.GetEmbeddedWebFS(), brandingStyle); err != nil {
62 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
63 os.Exit(1)
64 }
65 fmt.Println("\nBranding kit created successfully!")
66 fmt.Println("\nFiles created:")
67 fmt.Println(" branding.json - Main configuration file")
68 fmt.Println(" assets/ - Logo, favicon, and PWA icons")
69 fmt.Println(" css/custom.css - Full CSS override template")
70 fmt.Println(" css/variables.css - CSS variables-only template")
71 fmt.Println("\nEdit these files to customize your relay's appearance.")
72 fmt.Println("Restart the relay to apply changes.")
73 os.Exit(0)
74 }
75
76 // Handle 'identity' subcommand: print relay identity secret and pubkey and exit
77 if config.IdentityRequested() {
78 ctx, cancel := context.WithCancel(context.Background())
79 defer cancel()
80 var db database.Database
81 if db, err = database.NewDatabaseWithConfig(
82 ctx, cancel, cfg.DBType, makeDatabaseConfig(cfg),
83 ); chk.E(err) {
84 os.Exit(1)
85 }
86 defer db.Close()
87 skb, err := db.GetOrCreateRelayIdentitySecret()
88 if chk.E(err) {
89 os.Exit(1)
90 }
91 pk, err := keys.SecretBytesToPubKeyHex(skb)
92 if chk.E(err) {
93 os.Exit(1)
94 }
95 fmt.Printf(
96 "identity secret: %s\nidentity pubkey: %s\n", hex.Enc(skb), pk,
97 )
98 os.Exit(0)
99 }
100
101 // Handle 'migrate' subcommand: migrate data between database backends
102 if requested, fromType, toType, targetPath := config.MigrateRequested(); requested {
103 if fromType == "" || toType == "" {
104 fmt.Println("Usage: orly migrate --from <type> --to <type> [--target-path <path>]")
105 fmt.Println("")
106 fmt.Println("Migrate data between database backends.")
107 fmt.Println("")
108 fmt.Println("Options:")
109 fmt.Println(" --from <type> Source database type (badger, neo4j)")
110 fmt.Println(" --to <type> Destination database type (badger, neo4j)")
111 fmt.Println(" --target-path <path> Optional: destination data directory")
112 fmt.Println(" (default: $ORLY_DATA_DIR/<type>)")
113 fmt.Println("")
114 fmt.Println("Examples:")
115 fmt.Println(" orly migrate --from badger --to neo4j")
116 fmt.Println(" orly migrate --from badger --to neo4j --target-path /mnt/hdd/orly-neo4j")
117 os.Exit(1)
118 }
119
120 // Set target path if not specified
121 if targetPath == "" {
122 targetPath = cfg.DataDir + "-" + toType
123 }
124
125 log.I.F("migrate: %s -> %s", fromType, toType)
126 log.I.F("migrate: source path: %s", cfg.DataDir)
127 log.I.F("migrate: target path: %s", targetPath)
128
129 // Open source database
130 ctx, cancel := context.WithCancel(context.Background())
131 defer cancel()
132
133 srcCfg := makeDatabaseConfig(cfg)
134 var srcDB database.Database
135 if srcDB, err = database.NewDatabaseWithConfig(ctx, cancel, fromType, srcCfg); chk.E(err) {
136 log.E.F("migrate: failed to open source database: %v", err)
137 os.Exit(1)
138 }
139
140 // Wait for source database to be ready
141 select {
142 case <-srcDB.Ready():
143 log.I.F("migrate: source database ready")
144 case <-time.After(60 * time.Second):
145 log.E.F("migrate: timeout waiting for source database")
146 os.Exit(1)
147 }
148
149 // Open destination database
150 dstCfg := makeDatabaseConfig(cfg)
151 dstCfg.DataDir = targetPath
152 var dstDB database.Database
153 if dstDB, err = database.NewDatabaseWithConfig(ctx, cancel, toType, dstCfg); chk.E(err) {
154 log.E.F("migrate: failed to open destination database: %v", err)
155 srcDB.Close()
156 os.Exit(1)
157 }
158
159 // Wait for destination database to be ready
160 select {
161 case <-dstDB.Ready():
162 log.I.F("migrate: destination database ready")
163 case <-time.After(60 * time.Second):
164 log.E.F("migrate: timeout waiting for destination database")
165 srcDB.Close()
166 os.Exit(1)
167 }
168
169 // Migrate using pipe (export from source, import to destination)
170 log.I.F("migrate: starting data transfer...")
171 pr, pw, pipeErr := os.Pipe()
172 if pipeErr != nil {
173 log.E.F("migrate: failed to create pipe: %v", pipeErr)
174 srcDB.Close()
175 dstDB.Close()
176 os.Exit(1)
177 }
178
179 var wg sync.WaitGroup
180 wg.Add(2)
181
182 // Export goroutine
183 go func() {
184 defer wg.Done()
185 defer pw.Close()
186 srcDB.Export(ctx, pw)
187 log.I.F("migrate: export complete")
188 }()
189
190 // Import goroutine
191 go func() {
192 defer wg.Done()
193 if importErr := dstDB.ImportEventsFromReader(ctx, pr); importErr != nil {
194 log.E.F("migrate: import error: %v", importErr)
195 }
196 log.I.F("migrate: import complete")
197 }()
198
199 wg.Wait()
200
201 // Sync and close databases
202 if err = dstDB.Sync(); chk.E(err) {
203 log.W.F("migrate: sync warning: %v", err)
204 }
205 srcDB.Close()
206 dstDB.Close()
207
208 log.I.F("migrate: migration complete!")
209 os.Exit(0)
210 }
211
212 // Handle 'nrc' subcommand: NRC (Nostr Relay Connect) utilities
213 if requested, subcommand, args := config.NRCRequested(); requested {
214 handleNRCCommand(cfg, subcommand, args)
215 os.Exit(0)
216 }
217
218 // Handle 'serve' subcommand: start ephemeral relay with RAM-based storage
219 if config.ServeRequested() {
220 const serveDataDir = "/dev/shm/orlyserve"
221 log.I.F("serve mode: configuring ephemeral relay at %s", serveDataDir)
222
223 // Delete existing directory completely
224 if err = os.RemoveAll(serveDataDir); err != nil && !os.IsNotExist(err) {
225 log.E.F("failed to remove existing serve directory: %v", err)
226 os.Exit(1)
227 }
228
229 // Create fresh directory
230 if err = os.MkdirAll(serveDataDir, 0755); chk.E(err) {
231 log.E.F("failed to create serve directory: %v", err)
232 os.Exit(1)
233 }
234
235 // Override configuration for serve mode
236 cfg.DataDir = serveDataDir
237 cfg.Listen = "0.0.0.0"
238 cfg.Port = 10547
239 cfg.ACLMode = "none"
240 cfg.ServeMode = true // Grant full owner access to all users
241
242 log.I.F("serve mode: listening on %s:%d with ACL mode '%s' (full owner access)",
243 cfg.Listen, cfg.Port, cfg.ACLMode)
244 }
245
246 // Handle 'curatingmode' subcommand: start relay in curating mode with specified owner
247 if requested, ownerKey := config.CuratingModeRequested(); requested {
248 if ownerKey == "" {
249 fmt.Println("Usage: orly curatingmode <npub|hex_pubkey>")
250 fmt.Println("")
251 fmt.Println("Starts the relay in curating mode with the specified pubkey as owner.")
252 fmt.Println("Opens a browser to the curation setup page where you must log in")
253 fmt.Println("with a Nostr extension to configure the relay.")
254 fmt.Println("")
255 fmt.Println("Press Escape or Ctrl+C to stop the relay.")
256 os.Exit(1)
257 }
258
259 // Parse the owner key (npub or hex)
260 var ownerHex string
261 if strings.HasPrefix(ownerKey, "npub1") {
262 // Decode npub to hex
263 _, pubBytes, err := bech32encoding.Decode([]byte(ownerKey))
264 if err != nil {
265 fmt.Printf("Error: invalid npub: %v\n", err)
266 os.Exit(1)
267 }
268 if pb, ok := pubBytes.([]byte); ok {
269 ownerHex = hex.Enc(pb)
270 } else {
271 fmt.Println("Error: invalid npub encoding")
272 os.Exit(1)
273 }
274 } else if len(ownerKey) == 64 {
275 // Assume hex pubkey
276 ownerHex = strings.ToLower(ownerKey)
277 } else {
278 fmt.Println("Error: owner key must be an npub or 64-character hex pubkey")
279 os.Exit(1)
280 }
281
282 // Configure for curating mode
283 cfg.ACLMode = "curating"
284 cfg.Owners = []string{ownerHex}
285
286 log.I.F("curatingmode: starting with owner %s", ownerHex)
287 log.I.F("curatingmode: listening on %s:%d", cfg.Listen, cfg.Port)
288
289 // Start a goroutine to open browser after a short delay
290 go func() {
291 time.Sleep(2 * time.Second)
292 url := fmt.Sprintf("http://%s:%d/#curation", cfg.Listen, cfg.Port)
293 log.I.F("curatingmode: opening browser to %s", url)
294 openBrowser(url)
295 }()
296
297 // Start a goroutine to listen for Escape key
298 go func() {
299 // Set terminal to raw mode to capture individual key presses
300 oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
301 if err != nil {
302 log.W.F("could not set terminal to raw mode: %v", err)
303 return
304 }
305 defer term.Restore(int(os.Stdin.Fd()), oldState)
306
307 buf := make([]byte, 1)
308 for {
309 _, err := os.Stdin.Read(buf)
310 if err != nil {
311 return
312 }
313 // Escape key is 0x1b (27)
314 if buf[0] == 0x1b {
315 fmt.Println("\nEscape pressed, shutting down...")
316 p, _ := os.FindProcess(os.Getpid())
317 _ = p.Signal(os.Interrupt)
318 return
319 }
320 }
321 }()
322
323 fmt.Println("")
324 fmt.Println("Curating Mode Setup")
325 fmt.Println("===================")
326 fmt.Printf("Owner: %s\n", ownerHex)
327 fmt.Printf("URL: http://%s:%d/#curation\n", cfg.Listen, cfg.Port)
328 fmt.Println("")
329 fmt.Println("Log in with your Nostr extension to configure allowed event kinds")
330 fmt.Println("and rate limiting settings.")
331 fmt.Println("")
332 fmt.Println("Press Escape or Ctrl+C to stop the relay.")
333 fmt.Println("")
334 }
335
336 // Start the relay using shared startup logic
337 if err := relay.RunWithSignals(cfg); err != nil {
338 log.F.F("relay error: %v", err)
339 }
340 }
341
342 // makeDatabaseConfig creates a database.DatabaseConfig from the app config.
343 // Delegates to the shared relay package implementation.
344 func makeDatabaseConfig(cfg *config.C) *database.DatabaseConfig {
345 return relay.MakeDatabaseConfig(cfg)
346 }
347
348 // openBrowser opens the specified URL in the default browser.
349 func openBrowser(url string) {
350 var cmd *exec.Cmd
351 switch runtime.GOOS {
352 case "darwin":
353 cmd = exec.Command("open", url)
354 case "windows":
355 cmd = exec.Command("cmd", "/c", "start", url)
356 default: // linux, freebsd, etc.
357 cmd = exec.Command("xdg-open", url)
358 }
359 if err := cmd.Start(); err != nil {
360 log.W.F("could not open browser: %v", err)
361 }
362 }
363
364 // handleNRCCommand handles the 'nrc' CLI subcommand for NRC (Nostr Relay Connect) utilities.
365 func handleNRCCommand(cfg *config.C, subcommand string, args []string) {
366 ctx, cancel := context.WithCancel(context.Background())
367 defer cancel()
368
369 switch subcommand {
370 case "generate":
371 handleNRCGenerate(ctx, cfg, args)
372 case "list":
373 handleNRCList(cfg)
374 case "revoke":
375 handleNRCRevoke(args)
376 default:
377 printNRCUsage()
378 }
379 }
380
381 // printNRCUsage prints the usage information for the nrc subcommand.
382 func printNRCUsage() {
383 fmt.Println("Usage: orly nrc <subcommand> [options]")
384 fmt.Println("")
385 fmt.Println("Nostr Relay Connect (NRC) utilities for private relay access.")
386 fmt.Println("")
387 fmt.Println("Subcommands:")
388 fmt.Println(" generate [--name <device>] Generate a new connection URI")
389 fmt.Println(" list List currently configured authorized secrets")
390 fmt.Println(" revoke <name> Revoke access for a device (show instructions)")
391 fmt.Println("")
392 fmt.Println("Examples:")
393 fmt.Println(" orly nrc generate")
394 fmt.Println(" orly nrc generate --name phone")
395 fmt.Println(" orly nrc list")
396 fmt.Println(" orly nrc revoke phone")
397 fmt.Println("")
398 fmt.Println("To enable NRC, set these environment variables:")
399 fmt.Println(" ORLY_NRC_ENABLED=true")
400 fmt.Println(" ORLY_NRC_RENDEZVOUS_URL=wss://public-relay.example.com")
401 fmt.Println(" ORLY_NRC_AUTHORIZED_KEYS=<secret1>:<name1>,<secret2>:<name2>")
402 }
403
404 // handleNRCGenerate generates a new NRC connection URI.
405 func handleNRCGenerate(ctx context.Context, cfg *config.C, args []string) {
406 // Parse device name from args
407 var deviceName string
408 for i := 0; i < len(args); i++ {
409 if args[i] == "--name" && i+1 < len(args) {
410 deviceName = args[i+1]
411 i++
412 }
413 }
414
415 // Get relay identity
416 var db database.Database
417 var err error
418 if db, err = database.NewDatabaseWithConfig(
419 ctx, nil, cfg.DBType, makeDatabaseConfig(cfg),
420 ); chk.E(err) {
421 fmt.Printf("Error: failed to open database: %v\n", err)
422 return
423 }
424 defer db.Close()
425
426 <-db.Ready()
427
428 relaySecretKey, err := db.GetOrCreateRelayIdentitySecret()
429 if err != nil {
430 fmt.Printf("Error: failed to get relay identity: %v\n", err)
431 return
432 }
433
434 relayPubkey, err := keys.SecretBytesToPubKeyBytes(relaySecretKey)
435 if err != nil {
436 fmt.Printf("Error: failed to derive relay pubkey: %v\n", err)
437 return
438 }
439
440 // Get rendezvous URL from config
441 nrcEnabled, nrcRendezvousURL, _, _ := cfg.GetNRCConfigValues()
442 if !nrcEnabled || nrcRendezvousURL == "" {
443 fmt.Println("Error: NRC is not configured. Set ORLY_NRC_ENABLED=true and ORLY_NRC_RENDEZVOUS_URL")
444 return
445 }
446
447 // Generate a new random secret
448 secret := make([]byte, 32)
449 if _, err := os.ReadFile("/dev/urandom"); err != nil {
450 // Fallback - use crypto/rand
451 fmt.Printf("Error: failed to generate random secret: %v\n", err)
452 return
453 }
454 f, _ := os.Open("/dev/urandom")
455 defer f.Close()
456 f.Read(secret)
457
458 secretHex := hex.Enc(secret)
459
460 // Build the URI
461 uri := fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s",
462 hex.Enc(relayPubkey), nrcRendezvousURL, secretHex)
463 if deviceName != "" {
464 uri += fmt.Sprintf("&name=%s", deviceName)
465 }
466
467 fmt.Println("Generated NRC Connection URI:")
468 fmt.Println("")
469 fmt.Println(uri)
470 fmt.Println("")
471 fmt.Println("Add this secret to ORLY_NRC_AUTHORIZED_KEYS:")
472 if deviceName != "" {
473 fmt.Printf(" %s:%s\n", secretHex, deviceName)
474 } else {
475 fmt.Printf(" %s\n", secretHex)
476 }
477 fmt.Println("")
478 fmt.Println("IMPORTANT: Store this URI securely - anyone with this URI can access your relay.")
479 }
480
481 // handleNRCList lists configured authorized secrets from environment.
482 func handleNRCList(cfg *config.C) {
483 _, _, authorizedKeys, _ := cfg.GetNRCConfigValues()
484
485 fmt.Println("NRC Configuration:")
486 fmt.Println("")
487
488 if len(authorizedKeys) == 0 {
489 fmt.Println(" No authorized secrets configured.")
490 fmt.Println("")
491 fmt.Println(" To add secrets, set ORLY_NRC_AUTHORIZED_KEYS=<secret>:<name>,...")
492 } else {
493 fmt.Printf(" Authorized secrets: %d\n", len(authorizedKeys))
494 fmt.Println("")
495 for _, entry := range authorizedKeys {
496 parts := strings.SplitN(entry, ":", 2)
497 secretHex := parts[0]
498 name := "(unnamed)"
499 if len(parts) == 2 && parts[1] != "" {
500 name = parts[1]
501 }
502 // Show truncated secret for identification
503 truncated := secretHex
504 if len(secretHex) > 16 {
505 truncated = secretHex[:8] + "..." + secretHex[len(secretHex)-8:]
506 }
507 fmt.Printf(" - %s: %s\n", name, truncated)
508 }
509 }
510 }
511
512 // handleNRCRevoke provides instructions for revoking access.
513 func handleNRCRevoke(args []string) {
514 if len(args) == 0 {
515 fmt.Println("Usage: orly nrc revoke <device-name>")
516 fmt.Println("")
517 fmt.Println("To revoke access for a device:")
518 fmt.Println("1. Remove the corresponding secret from ORLY_NRC_AUTHORIZED_KEYS")
519 fmt.Println("2. Restart the relay")
520 fmt.Println("")
521 fmt.Println("Example: If ORLY_NRC_AUTHORIZED_KEYS=\"abc123:phone,def456:laptop\"")
522 fmt.Println("To revoke 'phone', change to: ORLY_NRC_AUTHORIZED_KEYS=\"def456:laptop\"")
523 return
524 }
525
526 deviceName := args[0]
527 fmt.Printf("To revoke access for '%s':\n", deviceName)
528 fmt.Println("")
529 fmt.Println("1. Edit ORLY_NRC_AUTHORIZED_KEYS and remove the entry for this device")
530 fmt.Println("2. Restart the relay")
531 fmt.Println("")
532 fmt.Println("The device will no longer be able to connect after the restart.")
533 }
534