Compare commits

...

9 Commits

Author SHA1 Message Date
Anthony Accioly ba6ab0f01e
Merge f8cd233e9d into b316646821 2025-08-19 15:20:32 +00:00
Anthony Accioly f8cd233e9d
docs(readme): add bunker subcommands documentation
- Document new bunker subcommands, including `help`, `info`, `qr`, and `connect`.
- Update examples for displaying bunker QR codes and usage instructions.
2025-08-08 21:39:46 +01:00
Anthony Accioly a60e2d9130
feat(bunker): delay bunker info print after direct connect response
- Introduce a 3-second delay before calling `printBunkerInfo`
2025-08-08 21:39:46 +01:00
Anthony Accioly 9396ee57b1
feat(bunker): add bunker info print after direct connect response
- Call `printBunkerInfo` to display bunker details after sending connect
 response
2025-08-08 21:39:46 +01:00
Anthony Accioly 9c9d86e4f5
feat(bunker): publish bunker connect responses concurrently
- Introduce goroutines to handle concurrent publishing to target relays.
- Use `sync/atomic` for thread-safe success counter tracking
- Add `WaitGroup` to ensure all goroutines complete before proceeding.
2025-08-08 21:39:46 +01:00
Anthony Accioly 9f632412ff
refactor(bunker): manage subscriptions dynamically for relay updates
- Refactor subscription management to allow dynamic relay updates.
- Introduce cancelable context and lock mechanism to safely recreate subscriptions.
- Automatically update subscriptions when relay list changes.
2025-08-08 21:39:46 +01:00
Anthony Accioly fb73ecad75
feat(bunker): add NostrConnect `connect` command support to bunker
- Implement `connect` command to enable processing `nostrconnect://` URIs.
- Update configuration dynamically with new relays and authorized keys.
2025-08-08 21:39:45 +01:00
Anthony Accioly 0db623b1eb
refactor(bunker): remove unused NostrConnect `connect` command 2025-08-08 21:39:45 +01:00
Anthony Accioly e6a0f43e4a
feat(bunker): introduce command interface with QR code and help commands
- Add interactive command interface for the bunker.
- Implement `help`, `info`, `qr`, and `exit` commands for user convenience.
- Display QR code on demand using `qr` command.
- Ensure proper command handling with locking mechanism and safe shutdown using `exit`.
2025-08-08 21:39:45 +01:00
2 changed files with 374 additions and 85 deletions

View File

@ -172,7 +172,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 <nostrconnect://uri> - 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

433
bunker.go
View File

@ -1,9 +1,11 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/hex"
"fiatjaf.com/nostr/nip44"
"fmt"
"net/url"
"os"
@ -11,6 +13,7 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"fiatjaf.com/nostr"
@ -229,6 +232,67 @@ var bunker = &cli.Command{
// static information
pubkey := sec.Public()
npub := nip19.EncodeNpub(pubkey)
signer := nip46.NewStaticKeySigner(sec)
printLock := sync.Mutex{}
exitChan := make(chan bool, 1)
// subscription management
var events chan nostr.RelayEvent
var cancelSubscription context.CancelFunc
subscriptionMutex := sync.Mutex{}
// Function to create/recreate subscription
updateSubscription := func() {
subscriptionMutex.Lock()
defer subscriptionMutex.Unlock()
// Cancel existing subscription if it exists
if cancelSubscription != nil {
cancelSubscription()
}
// Create new context for the subscription
subCtx, cancel := context.WithCancel(ctx)
cancelSubscription = cancel
// Create new subscription with current relay list
events = sys.Pool.SubscribeMany(subCtx, relayURLs, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
}
// Initial subscription to relays
updateSubscription()
handlerWg := sync.WaitGroup{}
// == SUBCOMMANDS ==
// 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 <nostrconnect://uri>"))
log(" %s - Shutdown the bunker\n", color.GreenString("exit, quit, q"))
log("\n")
}
// Create a function to print QR code on demand
printQR := func() {
qs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
printLock.Lock()
log("\nQR Code for bunker URI:\n")
qrterminal.Generate(bunkerURI, qrterminal.L, os.Stdout)
log("\n\n")
printLock.Unlock()
}
// this function will be called every now and then
printBunkerInfo := func() {
@ -301,27 +365,224 @@ 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()
}
}
// handleConnect processes nostrconnect:// URIs for direct connection flow
handleConnect := func(connectURI string) {
if !strings.HasPrefix(connectURI, "nostrconnect://") {
log("Error: URI must start with nostrconnect://\n")
return
}
// Parse the nostrconnect URI
u, err := url.Parse(connectURI)
if err != nil {
log("Error: Invalid nostrconnect URI: %v\n", err)
return
}
// Extract client pubkey from the URI
clientPubkeyHex := u.Host
if clientPubkeyHex == "" {
log("Error: Missing client pubkey in URI\n")
return
}
clientPubkey, err := nostr.PubKeyFromHex(clientPubkeyHex)
if err != nil {
log("Error: Invalid client pubkey: %v\n", err)
return
}
// Extract relays from query parameters
queryParams := u.Query()
newRelayURLs := queryParams["relay"]
if len(newRelayURLs) == 0 {
log("Error: No relays specified in URI\n")
return
}
// Extract secret from query parameters (optional)
var secret string
secrets := queryParams["secret"]
if len(secrets) > 0 {
secret = secrets[0]
} else {
// log error and return if no secret is provided
log("Error: No secret provided in URI\n")
return
}
log("Parsed nostrconnect URI:\n")
log(" Client pubkey: %s\n", color.CyanString(clientPubkey.Hex()))
log(" Relays: %v\n", newRelayURLs)
log(" Secret: %s\n", color.YellowString(secret))
// Normalize relay URLs
for i, relayURL := range newRelayURLs {
newRelayURLs[i] = nostr.NormalizeURL(relayURL)
}
// Update relays in config (add new ones)
relaysAdded := false
for _, relayURL := range newRelayURLs {
if !slices.Contains(config.Relays, relayURL) {
config.Relays = append(config.Relays, relayURL)
log("Added new relay: %s\n", color.MagentaString(relayURL))
relaysAdded = true
}
}
// Add client key to authorized keys
keyAdded := false
if !slices.Contains(config.AuthorizedKeys, clientPubkey) {
config.AuthorizedKeys = append(config.AuthorizedKeys, clientPubkey)
log("Added client key to authorized keys: %s\n", color.GreenString(clientPubkey.Hex()))
keyAdded = true
}
// Persist config if needed and changes were made
if persist != nil && (relaysAdded || keyAdded) {
persist()
log(color.GreenString("Configuration saved\n"))
}
// Connect to new relays if any were added
if relaysAdded {
newRelays := connectToAllRelays(ctx, c, newRelayURLs, nil, nostr.PoolOptions{})
log("Connected to %d new relay(s)\n", len(newRelays))
// Update the relay URLs list with successfully connected relays
for _, relay := range newRelays {
if !slices.Contains(relayURLs, relay.URL) {
relayURLs = append(relayURLs, relay.URL)
}
}
log("Updated subscription to listen on %d relay(s)\n", len(relayURLs))
// Cancel and recreate subscription with updated relay list
updateSubscription()
log("Subscription updated to include new relays\n")
}
// Prepare the response payload
responsePayload := nip46.Response{
ID: secret,
Result: "ack",
}
// Marshal the response payload to JSON
responseJSON, err := json.MarshalIndent(responsePayload, "", " ")
if err != nil {
log("Error: Failed to marshal response payload: %v\n", err)
return
}
log("Sending connect response with:\n%s\n", string(responseJSON))
// Encrypt the response using NIP-44
conversationKey, err := nip44.GenerateConversationKey(clientPubkey, sec)
if err != nil {
log("Error: Failed to generate conversation key: %v\n", err)
return
}
encryptedContent, err := nip44.Encrypt(string(responseJSON), conversationKey)
if err != nil {
log("Error: Failed to encrypt response content: %v\n", err)
return
}
// Create the kind 24133 event
eventResponse := nostr.Event{
Kind: nostr.KindNostrConnect,
PubKey: pubkey,
Content: encryptedContent,
Tags: nostr.Tags{{"p", clientPubkey.Hex()}},
CreatedAt: nostr.Now(),
}
// Sign the event with the signer
if err := eventResponse.Sign(sec); err != nil {
log("Error: Failed to sign connect response: %v\n", err)
return
}
targetRelays := newRelayURLs
if len(targetRelays) == 0 {
targetRelays = relayURLs
}
log("Sending connect response...\n")
successCount := atomic.Uint32{}
handlerWg.Add(len(targetRelays))
for _, relayURL := range targetRelays {
go func(relayURL string) {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err != nil {
log("Failed to publish to %s: %v\n", relayURL, err)
} else {
log("Published connect response to %s\n", color.GreenString(relayURL))
successCount.Add(1)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
}
handlerWg.Wait()
if successCount.Load() == 0 {
log("Error: Failed to publish connect response to any relay\n")
} else {
log(color.GreenString("\nConnect response sent successfully to %d relay(s)!\n"), successCount.Load())
}
// print bunker info again after this
go func() {
time.Sleep(3 * time.Second)
printBunkerInfo()
}()
}
// 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 <nostrconnect://uri>\n")
return
}
handleConnect(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)
}
}
// == END OF SUBCOMMANDS ==
// Print initial bunker information
printBunkerInfo()
// subscribe to relays
events := sys.Pool.SubscribeMany(ctx, relayURLs, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
@ -348,77 +609,81 @@ var bunker = &cli.Command{
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
}
for ie := range events {
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// 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)
}
}()
// handle the NIP-46 request event
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
// 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("--------------------------------------------------------"))
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
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
}
handlerWg.Wait()
// 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
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
}
handlerWg.Wait()
// 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()
}
}()
}
return nil
},
Commands: []*cli.Command{
{
Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>",
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("must be called with a nostrconnect://... uri")
}
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
}
// TODO
return fmt.Errorf("this is not implemented yet")
},
},
},
}