From 0aef173e8be4dec1a597457a8b358beb7fbc2e21 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 1 Jul 2025 11:40:34 -0300 Subject: [PATCH] nak bunker --persist/--profile --- bunker.go | 289 ++++++++++++++++++++++++++++++++++++++++++------- helpers_key.go | 6 +- main.go | 10 +- outbox.go | 5 - 4 files changed, 265 insertions(+), 45 deletions(-) diff --git a/bunker.go b/bunker.go index ff5169c..af02e1f 100644 --- a/bunker.go +++ b/bunker.go @@ -1,10 +1,13 @@ package main import ( + "bytes" "context" + "encoding/hex" "fmt" "net/url" "os" + "path/filepath" "slices" "strings" "sync" @@ -17,6 +20,8 @@ import ( "github.com/urfave/cli/v3" ) +const PERSISTENCE = "PERSISTENCE" + var bunker = &cli.Command{ Name: "bunker", Usage: "starts a nip46 signer daemon with the given --sec key", @@ -24,6 +29,18 @@ var bunker = &cli.Command{ Description: ``, DisableSliceFlagSeparator: true, 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{ Name: "sec", Usage: "secret key to sign the event, as hex or nsec", @@ -43,34 +60,147 @@ var bunker = &cli.Command{ Aliases: []string{"k"}, 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 { + // 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 qs := url.Values{} - relayURLs := make([]string, 0, c.Args().Len()) - if relayUrls := c.Args().Slice(); len(relayUrls) > 0 { - relays := connectToAllRelays(ctx, c, relayUrls, nil, nostr.PoolOptions{}) - if len(relays) == 0 { - log("failed to connect to any of the given relays.\n") - os.Exit(3) - } - for _, relay := range relays { - relayURLs = append(relayURLs, relay.URL) - qs.Add("relay", relay.URL) - } + relayURLs := make([]string, 0, len(config.Relays)) + relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{}) + if len(relays) == 0 { + log("failed to connect to any of the given relays.\n") + os.Exit(3) + } + for _, relay := range relays { + relayURLs = append(relayURLs, relay.URL) + qs.Add("relay", relay.URL) } if len(relayURLs) == 0 { 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 - authorizedKeys := getPubKeySlice(c, "authorized-keys") authorizedSecrets := c.StringSlice("authorized-secrets") // 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()) authorizedKeysStr := "" - if len(authorizedKeys) != 0 { + if len(config.AuthorizedKeys) != 0 { authorizedKeysStr = "\n authorized keys:" - for _, pubkey := range authorizedKeys { + for _, pubkey := range config.AuthorizedKeys { authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex()) } } @@ -100,7 +230,7 @@ var bunker = &cli.Command{ } preauthorizedFlags := "" - for _, k := range authorizedKeys { + for _, k := range config.AuthorizedKeys { preauthorizedFlags += " -k " + k.Hex() } for _, s := range authorizedSecrets { @@ -121,21 +251,34 @@ var bunker = &cli.Command{ } } - restartCommand := fmt.Sprintf("nak bunker %s%s %s", - secretKeyFlag, - preauthorizedFlags, - strings.Join(relayURLsPossiblyWithoutSchema, " "), - ) + // only print the restart command if not persisting: + if persist == nil { + restartCommand := fmt.Sprintf("nak bunker %s%s %s", + 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", - colors.bold(relayURLs), - colors.bold(pubkey.Hex()), - colors.bold(npub), - authorizedKeysStr, - authorizedSecretsStr, - color.CyanString(restartCommand), - colors.bold(bunkerURI), - ) + 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(pubkey.Hex()), + colors.bold(npub), + authorizedKeysStr, + authorizedSecretsStr, + color.CyanString(restartCommand), + 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() @@ -162,7 +305,7 @@ var bunker = &cli.Command{ signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool { if secret == newSecret { // store this key - authorizedKeys = append(authorizedKeys, from) + config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from) // discard this and generate a new secret newSecret = randString(12) // print bunker info again after this @@ -170,9 +313,13 @@ var bunker = &cli.Command{ time.Sleep(3 * time.Second) 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 { @@ -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 +} diff --git a/helpers_key.go b/helpers_key.go index 7f3285b..c578f77 100644 --- a/helpers_key.go +++ b/helpers_key.go @@ -17,14 +17,16 @@ import ( "github.com/urfave/cli/v3" ) +var defaultKey = nostr.KeyOne.Hex() + var defaultKeyFlags = []cli.Flag{ &cli.StringFlag{ Name: "sec", 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, Sources: cli.EnvVars("NOSTR_SECRET_KEY"), - Value: nostr.KeyOne.Hex(), + Value: defaultKey, HideDefault: true, }, &cli.BoolFlag{ diff --git a/main.go b/main.go index 9545804..8e683ef 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "net/http" "net/textproto" "os" + "path/filepath" "fiatjaf.com/nostr" "fiatjaf.com/nostr/sdk" @@ -52,6 +53,13 @@ var app = &cli.Command{ &cli.StringFlag{ Name: "config-path", 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{ Name: "quiet", @@ -125,7 +133,7 @@ func main() { if err := app.Run(context.Background(), os.Args); err != nil { if err != nil { - log("%s\n", color.YellowString(err.Error())) + log("%s\n", color.RedString(err.Error())) } colors.reset() os.Exit(1) diff --git a/outbox.go b/outbox.go index 37db434..bbec2f7 100644 --- a/outbox.go +++ b/outbox.go @@ -20,11 +20,6 @@ var ( func initializeOutboxHintsDB(c *cli.Command, sys *sdk.System) error { configPath := c.String("config-path") - if configPath == "" { - if home, err := os.UserHomeDir(); err == nil { - configPath = filepath.Join(home, ".config/nak") - } - } if configPath != "" { hintsFilePath = filepath.Join(configPath, "outbox/hints.bg") }