You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
533 lines
16 KiB
533 lines
16 KiB
package main |
|
|
|
import ( |
|
"context" |
|
"fmt" |
|
"os" |
|
"os/exec" |
|
"path/filepath" |
|
"runtime" |
|
"strings" |
|
"sync" |
|
"time" |
|
|
|
"github.com/adrg/xdg" |
|
"golang.org/x/term" |
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/log" |
|
"next.orly.dev/app" |
|
"next.orly.dev/app/branding" |
|
"next.orly.dev/app/config" |
|
"git.mleku.dev/mleku/nostr/crypto/keys" |
|
"git.mleku.dev/mleku/nostr/encoders/bech32encoding" |
|
"next.orly.dev/pkg/database" |
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
"next.orly.dev/pkg/relay" |
|
"next.orly.dev/pkg/version" |
|
) |
|
|
|
func main() { |
|
// Handle 'version' subcommand early, before any other initialization |
|
if config.VersionRequested() { |
|
fmt.Println(version.V) |
|
os.Exit(0) |
|
} |
|
|
|
var err error |
|
var cfg *config.C |
|
if cfg, err = config.New(); chk.T(err) { |
|
} |
|
log.I.F("starting %s %s", cfg.AppName, version.V) |
|
|
|
// Handle 'init-branding' subcommand: create branding directory with default assets |
|
if requested, targetDir, style := config.InitBrandingRequested(); requested { |
|
if targetDir == "" { |
|
targetDir = filepath.Join(xdg.ConfigHome, cfg.AppName, "branding") |
|
} |
|
|
|
// Validate and convert style |
|
var brandingStyle branding.BrandingStyle |
|
switch style { |
|
case "orly": |
|
brandingStyle = branding.StyleORLY |
|
case "generic", "": |
|
brandingStyle = branding.StyleGeneric |
|
default: |
|
fmt.Fprintf(os.Stderr, "Unknown style: %s (use 'orly' or 'generic')\n", style) |
|
os.Exit(1) |
|
} |
|
|
|
fmt.Printf("Initializing %s branding kit at: %s\n", style, targetDir) |
|
if err := branding.InitBrandingKit(targetDir, app.GetEmbeddedWebFS(), brandingStyle); err != nil { |
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err) |
|
os.Exit(1) |
|
} |
|
fmt.Println("\nBranding kit created successfully!") |
|
fmt.Println("\nFiles created:") |
|
fmt.Println(" branding.json - Main configuration file") |
|
fmt.Println(" assets/ - Logo, favicon, and PWA icons") |
|
fmt.Println(" css/custom.css - Full CSS override template") |
|
fmt.Println(" css/variables.css - CSS variables-only template") |
|
fmt.Println("\nEdit these files to customize your relay's appearance.") |
|
fmt.Println("Restart the relay to apply changes.") |
|
os.Exit(0) |
|
} |
|
|
|
// Handle 'identity' subcommand: print relay identity secret and pubkey and exit |
|
if config.IdentityRequested() { |
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
var db database.Database |
|
if db, err = database.NewDatabaseWithConfig( |
|
ctx, cancel, cfg.DBType, makeDatabaseConfig(cfg), |
|
); chk.E(err) { |
|
os.Exit(1) |
|
} |
|
defer db.Close() |
|
skb, err := db.GetOrCreateRelayIdentitySecret() |
|
if chk.E(err) { |
|
os.Exit(1) |
|
} |
|
pk, err := keys.SecretBytesToPubKeyHex(skb) |
|
if chk.E(err) { |
|
os.Exit(1) |
|
} |
|
fmt.Printf( |
|
"identity secret: %s\nidentity pubkey: %s\n", hex.Enc(skb), pk, |
|
) |
|
os.Exit(0) |
|
} |
|
|
|
// Handle 'migrate' subcommand: migrate data between database backends |
|
if requested, fromType, toType, targetPath := config.MigrateRequested(); requested { |
|
if fromType == "" || toType == "" { |
|
fmt.Println("Usage: orly migrate --from <type> --to <type> [--target-path <path>]") |
|
fmt.Println("") |
|
fmt.Println("Migrate data between database backends.") |
|
fmt.Println("") |
|
fmt.Println("Options:") |
|
fmt.Println(" --from <type> Source database type (badger, neo4j)") |
|
fmt.Println(" --to <type> Destination database type (badger, neo4j)") |
|
fmt.Println(" --target-path <path> Optional: destination data directory") |
|
fmt.Println(" (default: $ORLY_DATA_DIR/<type>)") |
|
fmt.Println("") |
|
fmt.Println("Examples:") |
|
fmt.Println(" orly migrate --from badger --to neo4j") |
|
fmt.Println(" orly migrate --from badger --to neo4j --target-path /mnt/hdd/orly-neo4j") |
|
os.Exit(1) |
|
} |
|
|
|
// Set target path if not specified |
|
if targetPath == "" { |
|
targetPath = cfg.DataDir + "-" + toType |
|
} |
|
|
|
log.I.F("migrate: %s -> %s", fromType, toType) |
|
log.I.F("migrate: source path: %s", cfg.DataDir) |
|
log.I.F("migrate: target path: %s", targetPath) |
|
|
|
// Open source database |
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
srcCfg := makeDatabaseConfig(cfg) |
|
var srcDB database.Database |
|
if srcDB, err = database.NewDatabaseWithConfig(ctx, cancel, fromType, srcCfg); chk.E(err) { |
|
log.E.F("migrate: failed to open source database: %v", err) |
|
os.Exit(1) |
|
} |
|
|
|
// Wait for source database to be ready |
|
select { |
|
case <-srcDB.Ready(): |
|
log.I.F("migrate: source database ready") |
|
case <-time.After(60 * time.Second): |
|
log.E.F("migrate: timeout waiting for source database") |
|
os.Exit(1) |
|
} |
|
|
|
// Open destination database |
|
dstCfg := makeDatabaseConfig(cfg) |
|
dstCfg.DataDir = targetPath |
|
var dstDB database.Database |
|
if dstDB, err = database.NewDatabaseWithConfig(ctx, cancel, toType, dstCfg); chk.E(err) { |
|
log.E.F("migrate: failed to open destination database: %v", err) |
|
srcDB.Close() |
|
os.Exit(1) |
|
} |
|
|
|
// Wait for destination database to be ready |
|
select { |
|
case <-dstDB.Ready(): |
|
log.I.F("migrate: destination database ready") |
|
case <-time.After(60 * time.Second): |
|
log.E.F("migrate: timeout waiting for destination database") |
|
srcDB.Close() |
|
os.Exit(1) |
|
} |
|
|
|
// Migrate using pipe (export from source, import to destination) |
|
log.I.F("migrate: starting data transfer...") |
|
pr, pw, pipeErr := os.Pipe() |
|
if pipeErr != nil { |
|
log.E.F("migrate: failed to create pipe: %v", pipeErr) |
|
srcDB.Close() |
|
dstDB.Close() |
|
os.Exit(1) |
|
} |
|
|
|
var wg sync.WaitGroup |
|
wg.Add(2) |
|
|
|
// Export goroutine |
|
go func() { |
|
defer wg.Done() |
|
defer pw.Close() |
|
srcDB.Export(ctx, pw) |
|
log.I.F("migrate: export complete") |
|
}() |
|
|
|
// Import goroutine |
|
go func() { |
|
defer wg.Done() |
|
if importErr := dstDB.ImportEventsFromReader(ctx, pr); importErr != nil { |
|
log.E.F("migrate: import error: %v", importErr) |
|
} |
|
log.I.F("migrate: import complete") |
|
}() |
|
|
|
wg.Wait() |
|
|
|
// Sync and close databases |
|
if err = dstDB.Sync(); chk.E(err) { |
|
log.W.F("migrate: sync warning: %v", err) |
|
} |
|
srcDB.Close() |
|
dstDB.Close() |
|
|
|
log.I.F("migrate: migration complete!") |
|
os.Exit(0) |
|
} |
|
|
|
// Handle 'nrc' subcommand: NRC (Nostr Relay Connect) utilities |
|
if requested, subcommand, args := config.NRCRequested(); requested { |
|
handleNRCCommand(cfg, subcommand, args) |
|
os.Exit(0) |
|
} |
|
|
|
// Handle 'serve' subcommand: start ephemeral relay with RAM-based storage |
|
if config.ServeRequested() { |
|
const serveDataDir = "/dev/shm/orlyserve" |
|
log.I.F("serve mode: configuring ephemeral relay at %s", serveDataDir) |
|
|
|
// Delete existing directory completely |
|
if err = os.RemoveAll(serveDataDir); err != nil && !os.IsNotExist(err) { |
|
log.E.F("failed to remove existing serve directory: %v", err) |
|
os.Exit(1) |
|
} |
|
|
|
// Create fresh directory |
|
if err = os.MkdirAll(serveDataDir, 0755); chk.E(err) { |
|
log.E.F("failed to create serve directory: %v", err) |
|
os.Exit(1) |
|
} |
|
|
|
// Override configuration for serve mode |
|
cfg.DataDir = serveDataDir |
|
cfg.Listen = "0.0.0.0" |
|
cfg.Port = 10547 |
|
cfg.ACLMode = "none" |
|
cfg.ServeMode = true // Grant full owner access to all users |
|
|
|
log.I.F("serve mode: listening on %s:%d with ACL mode '%s' (full owner access)", |
|
cfg.Listen, cfg.Port, cfg.ACLMode) |
|
} |
|
|
|
// Handle 'curatingmode' subcommand: start relay in curating mode with specified owner |
|
if requested, ownerKey := config.CuratingModeRequested(); requested { |
|
if ownerKey == "" { |
|
fmt.Println("Usage: orly curatingmode <npub|hex_pubkey>") |
|
fmt.Println("") |
|
fmt.Println("Starts the relay in curating mode with the specified pubkey as owner.") |
|
fmt.Println("Opens a browser to the curation setup page where you must log in") |
|
fmt.Println("with a Nostr extension to configure the relay.") |
|
fmt.Println("") |
|
fmt.Println("Press Escape or Ctrl+C to stop the relay.") |
|
os.Exit(1) |
|
} |
|
|
|
// Parse the owner key (npub or hex) |
|
var ownerHex string |
|
if strings.HasPrefix(ownerKey, "npub1") { |
|
// Decode npub to hex |
|
_, pubBytes, err := bech32encoding.Decode([]byte(ownerKey)) |
|
if err != nil { |
|
fmt.Printf("Error: invalid npub: %v\n", err) |
|
os.Exit(1) |
|
} |
|
if pb, ok := pubBytes.([]byte); ok { |
|
ownerHex = hex.Enc(pb) |
|
} else { |
|
fmt.Println("Error: invalid npub encoding") |
|
os.Exit(1) |
|
} |
|
} else if len(ownerKey) == 64 { |
|
// Assume hex pubkey |
|
ownerHex = strings.ToLower(ownerKey) |
|
} else { |
|
fmt.Println("Error: owner key must be an npub or 64-character hex pubkey") |
|
os.Exit(1) |
|
} |
|
|
|
// Configure for curating mode |
|
cfg.ACLMode = "curating" |
|
cfg.Owners = []string{ownerHex} |
|
|
|
log.I.F("curatingmode: starting with owner %s", ownerHex) |
|
log.I.F("curatingmode: listening on %s:%d", cfg.Listen, cfg.Port) |
|
|
|
// Start a goroutine to open browser after a short delay |
|
go func() { |
|
time.Sleep(2 * time.Second) |
|
url := fmt.Sprintf("http://%s:%d/#curation", cfg.Listen, cfg.Port) |
|
log.I.F("curatingmode: opening browser to %s", url) |
|
openBrowser(url) |
|
}() |
|
|
|
// Start a goroutine to listen for Escape key |
|
go func() { |
|
// Set terminal to raw mode to capture individual key presses |
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd())) |
|
if err != nil { |
|
log.W.F("could not set terminal to raw mode: %v", err) |
|
return |
|
} |
|
defer term.Restore(int(os.Stdin.Fd()), oldState) |
|
|
|
buf := make([]byte, 1) |
|
for { |
|
_, err := os.Stdin.Read(buf) |
|
if err != nil { |
|
return |
|
} |
|
// Escape key is 0x1b (27) |
|
if buf[0] == 0x1b { |
|
fmt.Println("\nEscape pressed, shutting down...") |
|
p, _ := os.FindProcess(os.Getpid()) |
|
_ = p.Signal(os.Interrupt) |
|
return |
|
} |
|
} |
|
}() |
|
|
|
fmt.Println("") |
|
fmt.Println("Curating Mode Setup") |
|
fmt.Println("===================") |
|
fmt.Printf("Owner: %s\n", ownerHex) |
|
fmt.Printf("URL: http://%s:%d/#curation\n", cfg.Listen, cfg.Port) |
|
fmt.Println("") |
|
fmt.Println("Log in with your Nostr extension to configure allowed event kinds") |
|
fmt.Println("and rate limiting settings.") |
|
fmt.Println("") |
|
fmt.Println("Press Escape or Ctrl+C to stop the relay.") |
|
fmt.Println("") |
|
} |
|
|
|
// Start the relay using shared startup logic |
|
if err := relay.RunWithSignals(cfg); err != nil { |
|
log.F.F("relay error: %v", err) |
|
} |
|
} |
|
|
|
// makeDatabaseConfig creates a database.DatabaseConfig from the app config. |
|
// Delegates to the shared relay package implementation. |
|
func makeDatabaseConfig(cfg *config.C) *database.DatabaseConfig { |
|
return relay.MakeDatabaseConfig(cfg) |
|
} |
|
|
|
// openBrowser opens the specified URL in the default browser. |
|
func openBrowser(url string) { |
|
var cmd *exec.Cmd |
|
switch runtime.GOOS { |
|
case "darwin": |
|
cmd = exec.Command("open", url) |
|
case "windows": |
|
cmd = exec.Command("cmd", "/c", "start", url) |
|
default: // linux, freebsd, etc. |
|
cmd = exec.Command("xdg-open", url) |
|
} |
|
if err := cmd.Start(); err != nil { |
|
log.W.F("could not open browser: %v", err) |
|
} |
|
} |
|
|
|
// handleNRCCommand handles the 'nrc' CLI subcommand for NRC (Nostr Relay Connect) utilities. |
|
func handleNRCCommand(cfg *config.C, subcommand string, args []string) { |
|
ctx, cancel := context.WithCancel(context.Background()) |
|
defer cancel() |
|
|
|
switch subcommand { |
|
case "generate": |
|
handleNRCGenerate(ctx, cfg, args) |
|
case "list": |
|
handleNRCList(cfg) |
|
case "revoke": |
|
handleNRCRevoke(args) |
|
default: |
|
printNRCUsage() |
|
} |
|
} |
|
|
|
// printNRCUsage prints the usage information for the nrc subcommand. |
|
func printNRCUsage() { |
|
fmt.Println("Usage: orly nrc <subcommand> [options]") |
|
fmt.Println("") |
|
fmt.Println("Nostr Relay Connect (NRC) utilities for private relay access.") |
|
fmt.Println("") |
|
fmt.Println("Subcommands:") |
|
fmt.Println(" generate [--name <device>] Generate a new connection URI") |
|
fmt.Println(" list List currently configured authorized secrets") |
|
fmt.Println(" revoke <name> Revoke access for a device (show instructions)") |
|
fmt.Println("") |
|
fmt.Println("Examples:") |
|
fmt.Println(" orly nrc generate") |
|
fmt.Println(" orly nrc generate --name phone") |
|
fmt.Println(" orly nrc list") |
|
fmt.Println(" orly nrc revoke phone") |
|
fmt.Println("") |
|
fmt.Println("To enable NRC, set these environment variables:") |
|
fmt.Println(" ORLY_NRC_ENABLED=true") |
|
fmt.Println(" ORLY_NRC_RENDEZVOUS_URL=wss://public-relay.example.com") |
|
fmt.Println(" ORLY_NRC_AUTHORIZED_KEYS=<secret1>:<name1>,<secret2>:<name2>") |
|
} |
|
|
|
// handleNRCGenerate generates a new NRC connection URI. |
|
func handleNRCGenerate(ctx context.Context, cfg *config.C, args []string) { |
|
// Parse device name from args |
|
var deviceName string |
|
for i := 0; i < len(args); i++ { |
|
if args[i] == "--name" && i+1 < len(args) { |
|
deviceName = args[i+1] |
|
i++ |
|
} |
|
} |
|
|
|
// Get relay identity |
|
var db database.Database |
|
var err error |
|
if db, err = database.NewDatabaseWithConfig( |
|
ctx, nil, cfg.DBType, makeDatabaseConfig(cfg), |
|
); chk.E(err) { |
|
fmt.Printf("Error: failed to open database: %v\n", err) |
|
return |
|
} |
|
defer db.Close() |
|
|
|
<-db.Ready() |
|
|
|
relaySecretKey, err := db.GetOrCreateRelayIdentitySecret() |
|
if err != nil { |
|
fmt.Printf("Error: failed to get relay identity: %v\n", err) |
|
return |
|
} |
|
|
|
relayPubkey, err := keys.SecretBytesToPubKeyBytes(relaySecretKey) |
|
if err != nil { |
|
fmt.Printf("Error: failed to derive relay pubkey: %v\n", err) |
|
return |
|
} |
|
|
|
// Get rendezvous URL from config |
|
nrcEnabled, nrcRendezvousURL, _, _ := cfg.GetNRCConfigValues() |
|
if !nrcEnabled || nrcRendezvousURL == "" { |
|
fmt.Println("Error: NRC is not configured. Set ORLY_NRC_ENABLED=true and ORLY_NRC_RENDEZVOUS_URL") |
|
return |
|
} |
|
|
|
// Generate a new random secret |
|
secret := make([]byte, 32) |
|
if _, err := os.ReadFile("/dev/urandom"); err != nil { |
|
// Fallback - use crypto/rand |
|
fmt.Printf("Error: failed to generate random secret: %v\n", err) |
|
return |
|
} |
|
f, _ := os.Open("/dev/urandom") |
|
defer f.Close() |
|
f.Read(secret) |
|
|
|
secretHex := hex.Enc(secret) |
|
|
|
// Build the URI |
|
uri := fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s", |
|
hex.Enc(relayPubkey), nrcRendezvousURL, secretHex) |
|
if deviceName != "" { |
|
uri += fmt.Sprintf("&name=%s", deviceName) |
|
} |
|
|
|
fmt.Println("Generated NRC Connection URI:") |
|
fmt.Println("") |
|
fmt.Println(uri) |
|
fmt.Println("") |
|
fmt.Println("Add this secret to ORLY_NRC_AUTHORIZED_KEYS:") |
|
if deviceName != "" { |
|
fmt.Printf(" %s:%s\n", secretHex, deviceName) |
|
} else { |
|
fmt.Printf(" %s\n", secretHex) |
|
} |
|
fmt.Println("") |
|
fmt.Println("IMPORTANT: Store this URI securely - anyone with this URI can access your relay.") |
|
} |
|
|
|
// handleNRCList lists configured authorized secrets from environment. |
|
func handleNRCList(cfg *config.C) { |
|
_, _, authorizedKeys, _ := cfg.GetNRCConfigValues() |
|
|
|
fmt.Println("NRC Configuration:") |
|
fmt.Println("") |
|
|
|
if len(authorizedKeys) == 0 { |
|
fmt.Println(" No authorized secrets configured.") |
|
fmt.Println("") |
|
fmt.Println(" To add secrets, set ORLY_NRC_AUTHORIZED_KEYS=<secret>:<name>,...") |
|
} else { |
|
fmt.Printf(" Authorized secrets: %d\n", len(authorizedKeys)) |
|
fmt.Println("") |
|
for _, entry := range authorizedKeys { |
|
parts := strings.SplitN(entry, ":", 2) |
|
secretHex := parts[0] |
|
name := "(unnamed)" |
|
if len(parts) == 2 && parts[1] != "" { |
|
name = parts[1] |
|
} |
|
// Show truncated secret for identification |
|
truncated := secretHex |
|
if len(secretHex) > 16 { |
|
truncated = secretHex[:8] + "..." + secretHex[len(secretHex)-8:] |
|
} |
|
fmt.Printf(" - %s: %s\n", name, truncated) |
|
} |
|
} |
|
} |
|
|
|
// handleNRCRevoke provides instructions for revoking access. |
|
func handleNRCRevoke(args []string) { |
|
if len(args) == 0 { |
|
fmt.Println("Usage: orly nrc revoke <device-name>") |
|
fmt.Println("") |
|
fmt.Println("To revoke access for a device:") |
|
fmt.Println("1. Remove the corresponding secret from ORLY_NRC_AUTHORIZED_KEYS") |
|
fmt.Println("2. Restart the relay") |
|
fmt.Println("") |
|
fmt.Println("Example: If ORLY_NRC_AUTHORIZED_KEYS=\"abc123:phone,def456:laptop\"") |
|
fmt.Println("To revoke 'phone', change to: ORLY_NRC_AUTHORIZED_KEYS=\"def456:laptop\"") |
|
return |
|
} |
|
|
|
deviceName := args[0] |
|
fmt.Printf("To revoke access for '%s':\n", deviceName) |
|
fmt.Println("") |
|
fmt.Println("1. Edit ORLY_NRC_AUTHORIZED_KEYS and remove the entry for this device") |
|
fmt.Println("2. Restart the relay") |
|
fmt.Println("") |
|
fmt.Println("The device will no longer be able to connect after the restart.") |
|
}
|
|
|