nak bunker connect 'nostrconnect://...' working.

This commit is contained in:
fiatjaf
2026-01-21 12:44:29 -03:00
parent 4e2c136e45
commit bf19f38996
5 changed files with 224 additions and 156 deletions

258
bunker.go
View File

@@ -5,12 +5,12 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"sync"
"time" "time"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
@@ -73,13 +73,7 @@ var bunker = &cli.Command{
}, },
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
// read config from file // read config from file
config := struct { config := BunkerConfig{}
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...) baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls { for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url) baseRelaysUrls[i] = nostr.NormalizeURL(url)
@@ -110,12 +104,6 @@ var bunker = &cli.Command{
} }
} }
go func() {
for uri := range onSocketConnect(ctx, c) {
log("received nostrconnect URI: %s\n", uri)
}
}()
// default case: persist() is nil // default case: persist() is nil
var persist func() var persist func()
@@ -148,6 +136,15 @@ var bunker = &cli.Command{
if err := json.Unmarshal(b, &config); err != nil { if err := json.Unmarshal(b, &config); err != nil {
return err return err
} }
// convert from deprecated field
if len(config.AuthorizedKeys) > 0 {
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
for i := range config.AuthorizedKeys {
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
}
config.AuthorizedKeys = nil
persist()
}
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return err return err
} }
@@ -156,7 +153,11 @@ var bunker = &cli.Command{
config.Relays[i] = nostr.NormalizeURL(url) config.Relays[i] = nostr.NormalizeURL(url)
} }
config.Relays = appendUnique(config.Relays, baseRelaysUrls...) config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...) for _, bak := range baseAuthorizedKeys {
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
}
if config.Secret.Plain == nil && config.Secret.Encrypted == nil { if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
// we don't have any secret key stored, so just use whatever was given via flags // we don't have any secret key stored, so just use whatever was given via flags
@@ -173,7 +174,9 @@ var bunker = &cli.Command{
} else { } else {
config.Secret = baseSecret config.Secret = baseSecret
config.Relays = baseRelaysUrls config.Relays = baseRelaysUrls
config.AuthorizedKeys = baseAuthorizedKeys for _, bak := range baseAuthorizedKeys {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
} }
// if we got here without any keys set (no flags, first time using a profile), use the default // if we got here without any keys set (no flags, first time using a profile), use the default
@@ -211,8 +214,17 @@ var bunker = &cli.Command{
// try to connect to the relays here // try to connect to the relays here
qs := url.Values{} qs := url.Values{}
relayURLs := make([]string, 0, len(config.Relays)) allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{}) copy(allRelays, config.Relays)
for _, c := range config.Clients {
for _, url := range c.CustomRelays {
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
allRelays = append(allRelays, url)
}
}
}
relayURLs := make([]string, 0, len(allRelays))
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
if len(relays) == 0 { if len(relays) == 0 {
log("failed to connect to any of the given relays.\n") log("failed to connect to any of the given relays.\n")
os.Exit(3) os.Exit(3)
@@ -242,10 +254,22 @@ var bunker = &cli.Command{
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode()) bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := "" authorizedKeysStr := ""
if len(config.AuthorizedKeys) != 0 { if len(config.Clients) != 0 {
authorizedKeysStr = "\n authorized keys:" authorizedKeysStr = "\n authorized clients:"
for _, pubkey := range config.AuthorizedKeys { for _, c := range config.Clients {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex()) authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
name := ""
if c.Name != "" {
name = c.Name
if c.URL != "" {
name += " " + colors.underline(c.URL)
}
} else if c.URL != "" {
name = colors.underline(c.URL)
}
if name != "" {
authorizedKeysStr += " (" + name + ")"
}
} }
} }
@@ -255,8 +279,8 @@ var bunker = &cli.Command{
} }
preauthorizedFlags := "" preauthorizedFlags := ""
for _, k := range config.AuthorizedKeys { for _, c := range config.Clients {
preauthorizedFlags += " -k " + k.Hex() preauthorizedFlags += " -k " + c.PubKey.Hex()
} }
for _, s := range authorizedSecrets { for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s preauthorizedFlags += " -s " + s
@@ -320,28 +344,84 @@ var bunker = &cli.Command{
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}}, Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(), Since: nostr.Now(),
LimitZero: true, LimitZero: true,
}, nostr.SubscriptionOptions{ }, nostr.SubscriptionOptions{Label: "nak-bunker"})
Label: "nak-bunker",
})
signer := nip46.NewStaticKeySigner(sec) signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{} // 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), 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()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
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 response through %s\n", res.Relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
}
}
}()
// just a gimmick // just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx) _, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool { signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) { if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
if slices.Contains(authorizedSecrets, secret) {
return true return true
} }
if secret == newSecret { if secret == newSecret {
// store this key // store this key
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from) config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
// discard this and generate a new secret // discard this and generate a new secret
newSecret = randString(12) newSecret = randString(12)
// print bunker info again after this // print bunker info again after this
@@ -364,34 +444,35 @@ var bunker = &cli.Command{
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event // handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event) req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil { if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error()) log("< failed to handle request from %s: %s\n", from, err.Error())
continue continue
} }
jreq, _ := json.MarshalIndent(req, "", " ") jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq)) log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ") jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp)) log("~ responding with %s\n", string(jresp))
handlerWg.Add(len(relayURLs)) // use custom relays if they are defined for this client
for _, relayURL := range relayURLs { // (normally if the initial connection came from a nostrconnect:// URL)
go func(relayURL string) { relays := relayURLs
defer handlerWg.Done() for _, c := range config.Clients {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil { if c.PubKey == from && len(c.CustomRelays) > 0 {
err := relay.Publish(ctx, eventResponse) relays = c.CustomRelays
printLock.Lock() break
if err == nil { }
log("* sent response through %s\n", relay.URL) }
} else {
log("* failed to send response: %s\n", err) for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
} if res.Error == nil {
printLock.Unlock() log("* sent response through %s\n", res.Relay.URL)
} } else {
}(relayURL) log("* failed to send response: %s\n", err)
}
} }
handlerWg.Wait()
// just after handling one request we trigger this // just after handling one request we trigger this
go func() { go func() {
@@ -433,6 +514,23 @@ var bunker = &cli.Command{
}, },
} }
type BunkerConfig struct {
Clients []BunkerConfigClient `json:"clients"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
// deprecated
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
}
type BunkerConfigClient struct {
PubKey nostr.PubKey `json:"pubkey"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
CustomRelays []string `json:"custom_relays,omitempty"`
}
type plainOrEncryptedKey struct { type plainOrEncryptedKey struct {
Plain *nostr.SecretKey Plain *nostr.SecretKey
Encrypted *string Encrypted *string
@@ -500,3 +598,63 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
return true return true
} }
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
listener, err := net.Listen("tcp", ":22222")
if err != nil {
log(color.RedString("failed to listen on TCP port 22222: %w\n", err))
return res
}
go func() {
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
continue
}
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
for {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
break
}
uri, err := url.Parse(string(buf[:n]))
if err == nil && uri.Scheme == "nostrconnect" {
res <- uri
}
}
}(conn)
}
}()
return res
}
func sendToSocket(c *cli.Command, value string) error {
conn, err := net.DialTimeout("tcp", "127.0.0.1:22222", 5*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to bunker TCP socket at 127.0.0.1:22222: %w", err)
}
defer conn.Close()
_, err = conn.Write([]byte(value))
if err != nil {
return fmt.Errorf("failed to send uri to bunker: %w", err)
}
return nil
}

View File

@@ -1,94 +0,0 @@
package main
import (
"context"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
socketPath := getSocketPath(c)
if _, err := os.Stat(socketPath); err == nil {
// file exists, we must delete it (or not)
os.Remove(socketPath)
} else if !os.IsNotExist(err) {
log(color.RedString("failed to check on unix socket: %w\n", err))
return res
}
// start unix socket listener
os.MkdirAll(filepath.Dir(socketPath), 0755)
listener, err := net.Listen("unix", socketPath)
if err != nil {
log(color.RedString("failed to listen on unix socket: %w\n", err))
return res
}
// handle unix socket connections in background
go func() {
defer listener.Close()
// clean up socket file on exit
// (irrelevant, as we clean it on startup, but just to keep the user filesystem sane)
defer os.Remove(socketPath)
for {
conn, err := listener.Accept()
if err != nil {
continue
}
defer conn.Close()
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if err != nil {
break
}
uri, err := url.Parse(string(buf[:n]))
if err == nil && uri.Scheme == "nostrconnect" {
res <- uri
}
}
}
}()
return res
}
func sendToSocket(c *cli.Command, value string) error {
socketPath := getSocketPath(c)
// connect to unix socket
conn, err := net.Dial("unix", socketPath)
if err != nil {
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
}
defer conn.Close()
// send the uri
_, err = conn.Write([]byte(value))
if err != nil {
return fmt.Errorf("failed to send uri to bunker: %w", err)
}
return nil
}
func getSocketPath(c *cli.Command) string {
profile := "any"
if c.Bool("persist") || c.IsSet("profile") {
profile = c.String("profile")
}
return filepath.Join(c.String("config-path"), "bunkerconn", profile+".sock")
}

2
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25 go 1.25
require ( require (
fiatjaf.com/nostr v0.0.0-20260119010708-31af06f4c7c4 fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4
github.com/AlecAivazis/survey/v2 v2.3.7 github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1 github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6 github.com/btcsuite/btcd/btcec/v2 v2.3.6

4
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q= fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260119010708-31af06f4c7c4 h1:/6AVjHIbbgyuiilcUuoFPMXGNXqialKGQM7uskF0b/0= fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4 h1:DF/4NSbCvXqIIRrwYp7L3S0SqC7/IhQl8mHkmYA5uXM=
fiatjaf.com/nostr v0.0.0-20260119010708-31af06f4c7c4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=

View File

@@ -536,21 +536,25 @@ func decodeTagValue(value string) string {
} }
var colors = struct { var colors = struct {
reset func(...any) (int, error) reset func(...any) (int, error)
italic func(...any) string italic func(...any) string
italicf func(string, ...any) string italicf func(string, ...any) string
bold func(...any) string bold func(...any) string
boldf func(string, ...any) string boldf func(string, ...any) string
error func(...any) string underline func(...any) string
errorf func(string, ...any) string underlinef func(string, ...any) string
success func(...any) string error func(...any) string
successf func(string, ...any) string errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{ }{
color.New(color.Reset).Print, color.New(color.Reset).Print,
color.New(color.Italic).Sprint, color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf, color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint, color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf, color.New(color.Bold).Sprintf,
color.New(color.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint, color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf, color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint, color.New(color.Bold, color.FgHiGreen).Sprint,