nak bunker --persist/--profile

This commit is contained in:
fiatjaf 2025-07-01 11:40:34 -03:00
parent 6e4a546212
commit 0aef173e8b
4 changed files with 265 additions and 45 deletions

289
bunker.go
View File

@ -1,10 +1,13 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/hex"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path/filepath"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@ -17,6 +20,8 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
const PERSISTENCE = "PERSISTENCE"
var bunker = &cli.Command{ var bunker = &cli.Command{
Name: "bunker", Name: "bunker",
Usage: "starts a nip46 signer daemon with the given --sec key", Usage: "starts a nip46 signer daemon with the given --sec key",
@ -24,6 +29,18 @@ var bunker = &cli.Command{
Description: ``, Description: ``,
DisableSliceFlagSeparator: true, DisableSliceFlagSeparator: true,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persist",
Usage: "whether to read and store authorized keys from and to a config file",
Category: PERSISTENCE,
},
&cli.StringFlag{
Name: "profile",
Value: "default",
Usage: "config file name to use for --persist mode (implies that if provided) -- based on --config-path, i.e. ~/.config/nak/",
OnlyOnce: true,
Category: PERSISTENCE,
},
&cli.StringFlag{ &cli.StringFlag{
Name: "sec", Name: "sec",
Usage: "secret key to sign the event, as hex or nsec", Usage: "secret key to sign the event, as hex or nsec",
@ -43,34 +60,147 @@ var bunker = &cli.Command{
Aliases: []string{"k"}, Aliases: []string{"k"},
Usage: "pubkeys for which we will always respond", Usage: "pubkeys for which we will always respond",
}, },
&cli.StringSliceFlag{
Name: "relay",
Usage: "relays to connect to (can also be provided as naked arguments)",
Hidden: true,
},
}, },
Action: func(ctx context.Context, c *cli.Command) error { Action: func(ctx context.Context, c *cli.Command) error {
// read config from file
config := struct {
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")...)
for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url)
}
baseAuthorizedKeys := getPubKeySlice(c, "authorized-keys")
var baseSecret plainOrEncryptedKey
{
sec := c.String("sec")
if c.Bool("prompt-sec") {
var err error
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil {
return fmt.Errorf("failed to get secret key: %w", err)
}
}
if strings.HasPrefix(sec, "ncryptsec1") {
baseSecret.Encrypted = &sec
} else {
if sec == "" {
sec = os.Getenv("NOSTR_SECRET_KEY")
if sec == "" {
sec = defaultKey
}
}
if prefix, ski, err := nip19.Decode(sec); err == nil && prefix == "nsec" {
sk := ski.(nostr.SecretKey)
baseSecret.Plain = &sk
} else if sk, err := nostr.SecretKeyFromHex(sec); err != nil {
return fmt.Errorf("invalid secret key: %w", err)
} else {
baseSecret.Plain = &sk
}
}
}
// default case: persist() is nil
var persist func()
if c.Bool("persist") || c.IsSet("profile") {
path := filepath.Join(c.String("config-path"), "bunker")
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
path = filepath.Join(path, c.String("profile"))
persist = func() {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
log(color.RedString("failed to persist: %w\n"), err)
os.Exit(4)
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
log(color.RedString("failed to persist: %w\n"), err)
os.Exit(4)
}
if err := os.WriteFile(path, data, 0600); err != nil {
log(color.RedString("failed to persist: %w\n"), err)
os.Exit(4)
}
}
log(color.YellowString("reading config from %s\n"), path)
b, err := os.ReadFile(path)
if err == nil {
if err := json.Unmarshal(b, &config); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
for i, url := range config.Relays {
config.Relays[i] = nostr.NormalizeURL(url)
}
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
config.Secret = baseSecret
} else if !baseSecret.equals(config.Secret) {
return fmt.Errorf("--sec provided conflicts with stored, you should create a new --profile or omit the --sec flag")
}
} else {
config.Secret = baseSecret
config.Relays = baseRelaysUrls
config.AuthorizedKeys = baseAuthorizedKeys
}
if len(config.Relays) == 0 {
return fmt.Errorf("no relays given")
}
// decrypt key here if necessary
var sec nostr.SecretKey
if config.Secret.Plain != nil {
sec = *config.Secret.Plain
} else {
plain, err := promptDecrypt(*config.Secret.Encrypted)
if err != nil {
return fmt.Errorf("failed to decrypt: %w", err)
}
sec = plain
}
if persist != nil {
persist()
}
// try to connect to the relays here // try to connect to the relays here
qs := url.Values{} qs := url.Values{}
relayURLs := make([]string, 0, c.Args().Len()) relayURLs := make([]string, 0, len(config.Relays))
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 { relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
relays := connectToAllRelays(ctx, c, relayUrls, 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) }
} for _, relay := range relays {
for _, relay := range relays { relayURLs = append(relayURLs, relay.URL)
relayURLs = append(relayURLs, relay.URL) qs.Add("relay", relay.URL)
qs.Add("relay", relay.URL)
}
} }
if len(relayURLs) == 0 { if len(relayURLs) == 0 {
return fmt.Errorf("not connected to any relays: please specify at least one") return fmt.Errorf("not connected to any relays: please specify at least one")
} }
// gather the secret key
sec, _, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
// other arguments // other arguments
authorizedKeys := getPubKeySlice(c, "authorized-keys")
authorizedSecrets := c.StringSlice("authorized-secrets") authorizedSecrets := c.StringSlice("authorized-secrets")
// this will be used to auto-authorize the next person who connects who isn't pre-authorized // this will be used to auto-authorize the next person who connects who isn't pre-authorized
@ -87,9 +217,9 @@ 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(authorizedKeys) != 0 { if len(config.AuthorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:" authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range authorizedKeys { for _, pubkey := range config.AuthorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex()) authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
} }
} }
@ -100,7 +230,7 @@ var bunker = &cli.Command{
} }
preauthorizedFlags := "" preauthorizedFlags := ""
for _, k := range authorizedKeys { for _, k := range config.AuthorizedKeys {
preauthorizedFlags += " -k " + k.Hex() preauthorizedFlags += " -k " + k.Hex()
} }
for _, s := range authorizedSecrets { for _, s := range authorizedSecrets {
@ -121,21 +251,34 @@ var bunker = &cli.Command{
} }
} }
restartCommand := fmt.Sprintf("nak bunker %s%s %s", // only print the restart command if not persisting:
secretKeyFlag, if persist == nil {
preauthorizedFlags, restartCommand := fmt.Sprintf("nak bunker %s%s %s",
strings.Join(relayURLsPossiblyWithoutSchema, " "), secretKeyFlag,
) preauthorizedFlags,
strings.Join(relayURLsPossiblyWithoutSchema, " "),
)
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n", log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
colors.bold(relayURLs), colors.bold(relayURLs),
colors.bold(pubkey.Hex()), colors.bold(pubkey.Hex()),
colors.bold(npub), colors.bold(npub),
authorizedKeysStr, authorizedKeysStr,
authorizedSecretsStr, authorizedSecretsStr,
color.CyanString(restartCommand), color.CyanString(restartCommand),
colors.bold(bunkerURI), colors.bold(bunkerURI),
) )
} else {
// otherwise just print the data
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n bunker: %s\n\n",
colors.bold(relayURLs),
colors.bold(pubkey.Hex()),
colors.bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
colors.bold(bunkerURI),
)
}
} }
printBunkerInfo() printBunkerInfo()
@ -162,7 +305,7 @@ var bunker = &cli.Command{
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool { signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if secret == newSecret { if secret == newSecret {
// store this key // store this key
authorizedKeys = append(authorizedKeys, from) config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, 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
@ -170,9 +313,13 @@ var bunker = &cli.Command{
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
printBunkerInfo() printBunkerInfo()
}() }()
if persist != nil {
persist()
}
} }
return slices.Contains(authorizedKeys, from) || slices.Contains(authorizedSecrets, secret) return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
} }
for ie := range events { for ie := range events {
@ -248,3 +395,71 @@ var bunker = &cli.Command{
}, },
}, },
} }
type plainOrEncryptedKey struct {
Plain *nostr.SecretKey
Encrypted *string
}
func (pe plainOrEncryptedKey) MarshalJSON() ([]byte, error) {
if pe.Plain != nil {
res := make([]byte, 66)
hex.Encode(res[1:], (*pe.Plain)[:])
res[0] = '"'
res[65] = '"'
return res, nil
} else if pe.Encrypted != nil {
return json.Marshal(*pe.Encrypted)
}
return nil, fmt.Errorf("no key to marshal")
}
func (pe *plainOrEncryptedKey) UnmarshalJSON(buf []byte) error {
if len(buf) == 66 {
sk, err := nostr.SecretKeyFromHex(string(buf[1 : 1+64]))
if err != nil {
return err
}
pe.Plain = &sk
return nil
} else if bytes.HasPrefix(buf, []byte("\"nsec")) {
_, v, err := nip19.Decode(string(buf[1 : len(buf)-1]))
if err != nil {
return err
}
sk := v.(nostr.SecretKey)
pe.Plain = &sk
return nil
} else if bytes.HasPrefix(buf, []byte("\"ncryptsec1")) {
ncryptsec := string(buf[1 : len(buf)-1])
pe.Encrypted = &ncryptsec
return nil
}
return fmt.Errorf("unrecognized key format '%s'", string(buf))
}
func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
if a.Plain == nil && b.Plain != nil {
return false
}
if a.Plain != nil && b.Plain == nil {
return false
}
if a.Plain != nil && b.Plain != nil && *a.Plain != *b.Plain {
return false
}
if a.Encrypted == nil && b.Encrypted != nil {
return false
}
if a.Encrypted != nil && b.Encrypted == nil {
return false
}
if a.Encrypted != nil && b.Encrypted != nil && *a.Encrypted != *b.Encrypted {
return false
}
return true
}

View File

@ -17,14 +17,16 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
var defaultKey = nostr.KeyOne.Hex()
var defaultKeyFlags = []cli.Flag{ var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
Name: "sec", Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL", Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL",
DefaultText: "the key '1'", DefaultText: "the key '01'",
Category: CATEGORY_SIGNER, Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_SECRET_KEY"), Sources: cli.EnvVars("NOSTR_SECRET_KEY"),
Value: nostr.KeyOne.Hex(), Value: defaultKey,
HideDefault: true, HideDefault: true,
}, },
&cli.BoolFlag{ &cli.BoolFlag{

10
main.go
View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/textproto" "net/textproto"
"os" "os"
"path/filepath"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/sdk" "fiatjaf.com/nostr/sdk"
@ -52,6 +53,13 @@ var app = &cli.Command{
&cli.StringFlag{ &cli.StringFlag{
Name: "config-path", Name: "config-path",
Hidden: true, Hidden: true,
Value: (func() string {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config/nak")
} else {
return filepath.Join("/dev/null")
}
})(),
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "quiet", Name: "quiet",
@ -125,7 +133,7 @@ func main() {
if err := app.Run(context.Background(), os.Args); err != nil { if err := app.Run(context.Background(), os.Args); err != nil {
if err != nil { if err != nil {
log("%s\n", color.YellowString(err.Error())) log("%s\n", color.RedString(err.Error()))
} }
colors.reset() colors.reset()
os.Exit(1) os.Exit(1)

View File

@ -20,11 +20,6 @@ var (
func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error { func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error {
configPath := c.String("config-path") configPath := c.String("config-path")
if configPath == "" {
if home, err := os.UserHomeDir(); err == nil {
configPath = filepath.Join(home, ".config/nak")
}
}
if configPath != "" { if configPath != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.bg") hintsFilePath = filepath.Join(configPath, "outbox/hints.bg")
} }