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