From b269ff92c772ca96d4bf094d961a8736a59e3d13 Mon Sep 17 00:00:00 2001 From: Anthony Accioly <1591739+aaccioly@users.noreply.github.com> Date: Fri, 4 Jul 2025 12:40:31 +0100 Subject: [PATCH] feat(bunker): add interactive NostrConnect `connect` command support to bunker --- README.md | 26 ++++- bunker.go | 280 ++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 224 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 5e22f10..fe3a553 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,31 @@ listening at [wss://relay.damus.io wss://nos.lol wss://relay.nsecbunker.com]: bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Fnos.lol&relay=wss%3A%2F%2Frelay.nsecbunker.com&secret=XuuiMbcLwuwL ``` -you can also display a QR code for the bunker URI by adding the `--qrcode` flag: +#### Bunker subcommands + +Bunker has a few subcommands that you can use to manage it, type `help` to see them all: +```shell +~> ./nak bunker relay.nsec.app +wss://relay.nsec.app... ok. +listening at [wss://relay.nsec.app]: + pubkey: f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a + npub: npub17kv3rdtpcd7fpvq7newz24eswwqgxhyr8xt4daxk9kqkwgn7gg9q4gy8vf + to restart: nak bunker relay.nsec.app + bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.nsec.app&secret=cAMoUOddVMla + +--------------- Bunker Command Interface --------------- +Type 'help' for available commands or 'exit' to quit. +-------------------------------------------------------- +help +Available Commands: + help, h, ? - Show this help message + info, i - Display current bunker information + qr - Generate and display QR code for the bunker URI + connect, c - Connect to a remote client using nostrconnect:// URI + exit, quit, q - Shutdown the bunker +``` + +You can also display a QR code for the bunker URI by adding the `--qrcode` flag: ```shell ~> nak bunker --qrcode --sec ncryptsec1... relay.damus.io diff --git a/bunker.go b/bunker.go index 2f4211f..a344b7d 100644 --- a/bunker.go +++ b/bunker.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/hex" @@ -249,6 +250,15 @@ var bunker = &cli.Command{ pubkey := sec.Public() npub := nip19.EncodeNpub(pubkey) + // printQR generates and prints the QR code for the bunker URI + printQR := func() { + qs.Set("secret", newSecret) + bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode()) + log("\nQR Code for bunker URI:\n") + qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout) + log("\n\n") + } + // this function will be called every now and then printBunkerInfo := func() { qs.Set("secret", newSecret) @@ -332,9 +342,7 @@ var bunker = &cli.Command{ // print QR code if requested if c.Bool("qrcode") { - log("QR Code for bunker URI:\n") - qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout) - log("\n\n") + printQR() } } printBunkerInfo() @@ -350,40 +358,51 @@ var bunker = &cli.Command{ signer := nip46.NewStaticKeySigner(sec) signer.DefaultRelays = config.Relays - // unix socket nostrconnect:// handling - go func() { - for uri := range onSocketConnect(ctx, c) { - clientPublicKey, err := nostr.PubKeyFromHex(uri.Host) - if err != nil { - continue - } - log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String()) - - relays := uri.Query()["relay"] - - // pre-authorize this client since the user has explicitly added it - if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { - return c.PubKey == clientPublicKey - }) { - config.Clients = append(config.Clients, BunkerConfigClient{ - PubKey: clientPublicKey, - Name: uri.Query().Get("name"), - URL: uri.Query().Get("url"), - Icon: uri.Query().Get("icon"), - CustomRelays: relays, - }) - } + // common help to handle nostrconnect:// URIs + handleNostrConnect := func(uri *url.URL) { + clientPublicKey, err := nostr.PubKeyFromHex(uri.Host) + if err != nil { + log("* invalid nostrconnect:// URI: %s\n", err) + return + } + log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String()) + + relays := uri.Query()["relay"] + + // pre-authorize this client since the user has explicitly added it + if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { + return c.PubKey == clientPublicKey + }) { + config.Clients = append(config.Clients, BunkerConfigClient{ + PubKey: clientPublicKey, + Name: uri.Query().Get("name"), + URL: uri.Query().Get("url"), + Icon: uri.Query().Get("icon"), + CustomRelays: relays, + }) + } - if persist != nil { - persist() - } + if persist != nil { + persist() + } - resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri) - if err != nil { - log("* failed to handle: %s\n", err) - continue + resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri) + if err != nil { + log("* failed to handle: %s\n", err) + return + } + + // compute new custom relays to avoid duplicate subscriptions + newCustomRelays := make([]string, 0, len(relays)) + for _, r := range relays { + if !slices.Contains(allRelays, r) { + newCustomRelays = append(newCustomRelays, r) + allRelays = append(allRelays, r) } + } + if len(newCustomRelays) > 0 { + log("subscribing to %d new relays: %s\n", len(newCustomRelays), strings.Join(newCustomRelays, ",")) go func() { for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{ Kinds: []nostr.Kind{nostr.KindNostrConnect}, @@ -396,16 +415,24 @@ var bunker = &cli.Command{ }() time.Sleep(time.Millisecond * 25) - jresp, _ := json.MarshalIndent(resp, "", " ") - log("~ responding with %s\n", string(jresp)) - for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) { - if res.Error == nil { - log("* sent through %s\n", res.Relay.URL) - } else { - log("* failed to send through %s: %s\n", res.RelayURL, res.Error) - } + } + + jresp, _ := json.MarshalIndent(resp, "", " ") + log("~ responding with %s\n", string(jresp)) + for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) { + if res.Error == nil { + log("* sent through %s\n", res.Relay.URL) + } else { + log("* failed to send through %s: %s\n", res.RelayURL, res.Error) } } + } + + // unix socket nostrconnect:// handling + go func() { + for uri := range onSocketConnect(ctx, c) { + handleNostrConnect(uri) + } }() // just a gimmick @@ -449,58 +476,149 @@ var bunker = &cli.Command{ return false } - for ie := range events { - cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks + // == SUBCOMMANDS == + + exitChan := make(chan bool, 1) + + // printHelp displays available commands for the bunker interface + printHelp := func() { + log("%s\n", color.CyanString("Available Commands:")) + log(" %s - Show this help message\n", color.GreenString("help, h, ?")) + log(" %s - Display current bunker information\n", color.GreenString("info, i")) + log(" %s - Generate and display QR code for the bunker URI\n", color.GreenString("qr")) + log(" %s - Connect to a remote client using nostrconnect:// URI\n", color.GreenString("connect, c ")) + log(" %s - Shutdown the bunker\n", color.GreenString("exit, quit, q")) + log("\n") + } + + // handleConnectCommand processes nostrconnect:// URIs for interactive connection flow + handleConnectCommand := func(connectURI string) { + if !strings.HasPrefix(connectURI, "nostrconnect://") { + log("Error: URI must start with nostrconnect://\n") + return + } - // handle the NIP-46 request event - from := ie.Event.PubKey - req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event) + // Parse the nostrconnect URI + u, err := url.Parse(connectURI) if err != nil { - if errors.Is(err, nip46.AlreadyHandled) { - continue + log("Error: Invalid nostrconnect URI: %v\n", err) + return + } + + handleNostrConnect(u) + } + + // handleBunkerCommand processes user commands in the bunker interface + handleBunkerCommand := func(command string) { + parts := strings.Fields(command) + if len(parts) == 0 { + return + } + + switch strings.ToLower(parts[0]) { + case "help", "h", "?": + printHelp() + case "info", "i": + printBunkerInfo() + case "qr": + printQR() + case "connect", "c": + if len(parts) < 2 { + log("Usage: connect \n") + return } + handleConnectCommand(parts[1]) + case "exit", "quit", "q": + log("Exit command received.\n") + exitChan <- true + case "": + // Ignore empty commands + default: + log("Unknown command: %s. Type 'help' for available commands.\n", command) + } + } - log("< failed to handle request from %s: %s\n", from.Hex(), err.Error()) - continue + // Start command input handler in a separate goroutine + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + command := strings.TrimSpace(scanner.Text()) + handleBunkerCommand(command) + } + if err := scanner.Err(); err != nil { + log("error reading command: %v\n", err) } + }() - jreq, _ := json.MarshalIndent(req, "", " ") - log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq)) - jresp, _ := json.MarshalIndent(resp, "", " ") - log("~ responding with %s\n", string(jresp)) + // Print initial command help + log("%s\nType 'help' for available commands or 'exit' to quit.\n%s\n", + color.CyanString("--------------- Bunker Command Interface ---------------"), + color.CyanString("--------------------------------------------------------")) - // use custom relays if they are defined for this client - // (normally if the initial connection came from a nostrconnect:// URL) - relays := relayURLs - for _, c := range config.Clients { - if c.PubKey == from && len(c.CustomRelays) > 0 { - relays = c.CustomRelays - break + // == END OF SUBCOMMANDS == + + for { + // Check if exit was requested first + select { + case <-exitChan: + log("Shutting down bunker...\n") + return nil + case ie := <-events: + cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks + + // handle the NIP-46 request event + from := ie.Event.PubKey + req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event) + if err != nil { + if errors.Is(err, nip46.AlreadyHandled) { + continue } - } - for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) { - if res.Error == nil { - log("* sent response through %s\n", res.Relay.URL) - } else { - log("* failed to send response through %s: %s\n", res.RelayURL, res.Error) + log("< failed to handle request from %s: %s\n", from.Hex(), err.Error()) + continue } - } - // just after handling one request we trigger this - go func() { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - cancelPreviousBunkerInfoPrint = cancel - // the idea is that we will print the bunker URL again so it is easier to copy-paste by users - // but we will only do if the bunker is inactive for more than 5 minutes - select { - case <-ctx.Done(): - case <-time.After(time.Minute * 5): - log("\n") - printBunkerInfo() + jreq, _ := json.MarshalIndent(req, "", " ") + log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq)) + jresp, _ := json.MarshalIndent(resp, "", " ") + log("~ responding with %s\n", string(jresp)) + + // use custom relays if they are defined for this client + // (normally if the initial connection came from a nostrconnect:// URL) + relays := relayURLs + for _, c := range config.Clients { + if c.PubKey == from && len(c.CustomRelays) > 0 { + relays = c.CustomRelays + break + } } - }() + + for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) { + if res.Error == nil { + log("* sent response through %s\n", res.Relay.URL) + } else { + log("* failed to send response through %s: %s\n", res.RelayURL, res.Error) + } + } + + // just after handling one request we trigger this + go func() { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + cancelPreviousBunkerInfoPrint = cancel + // the idea is that we will print the bunker URL again so it is easier to copy-paste by users + // but we will only do if the bunker is inactive for more than 5 minutes + select { + case <-ctx.Done(): + case <-time.After(time.Minute * 5): + log("\n") + printBunkerInfo() + } + }() + case <-time.After(100 * time.Millisecond): + // Continue to check for exit signal even when no events + continue + } } return nil