From feb94fa40714258e115f80750cd1dd208e99a729 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Jul 2026 13:05:22 -0700 Subject: [PATCH 1/2] multi: add HashiCorp Vault KV v2 secret support In this commit, we add `vault` as a third secret source and target alongside the existing `file` and `k8s` backends, wired into the `init-wallet`, `store-secret` and `load-secret` commands. This lets an lnd deployment keep its wallet seed and password in HashiCorp Vault directly, so the secrets never need to be materialized as a Kubernetes Secret in etcd. The implementation targets the KV v2 secrets engine using the official `vault/api` KV v2 helper: reads and writes go through `client.KVv2(mount)`, which handles the nested `data/` path shaping that KV v2 requires. Writes are read-modify-write merges so that storing one entry (e.g. the seed) never clobbers a sibling entry (e.g. the password) in the same secret. The `errTargetExists` sentinel is preserved on the write path so the existing idempotency contract (`--error-on-existing`) keeps working across restarts, which is important to make sure we never silently rotate a seed out from under an initialized wallet. Authentication uses the Kubernetes auth method via the official `vault/api/auth/kubernetes` login helper. The pod's projected ServiceAccount token is exchanged for a Vault token bound to a role and policy. The auth mount, role and token path are all configurable; the Vault server address is taken from the `VAULT_ADDR` environment variable or the `--vault.addr` flag. A companion `example-init-wallet-vault.sh` and a README section round out the change. --- README.md | 97 ++++++++++++++ cmd_init_wallet.go | 78 +++++++++++- cmd_load_secret.go | 37 +++++- cmd_store_secret.go | 52 +++++++- example-init-wallet-vault.sh | 93 ++++++++++++++ go.mod | 19 ++- go.sum | 51 +++++++- main.go | 5 +- vault.go | 240 +++++++++++++++++++++++++++++++++++ vault_test.go | 235 ++++++++++++++++++++++++++++++++++ 10 files changed, 887 insertions(+), 20 deletions(-) create mode 100755 example-init-wallet-vault.sh create mode 100644 vault.go create mode 100644 vault_test.go diff --git a/README.md b/README.md index b095941..dd8de9e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ initialization, including seed and password generation. - [Example usage](#example-usage) - [Basic setup](#example-use-case-1-basic-setup) - [Kubernetes](#example-use-case-2-kubernetes) + - [HashiCorp Vault](#example-use-case-3-hashicorp-vault) - [Logging and idempotent operations](#logging-and-idempotent-operations) ## Requirements @@ -379,6 +380,102 @@ $ lnd \ --wallet-unlock-password-file=/safe/location/walletpassword.txt ``` +### Example use case 3: HashiCorp Vault + +This example shows how a [HashiCorp Vault](https://www.vaultproject.io/) KV v2 +secrets engine can be used to store the wallet seed and password instead of a +Kubernetes Secret. Unlike the k8s variant, the seed and password never +materialize as a Kubernetes Secret in `etcd`; `lndinit` reads and writes them +directly against Vault. + +The `vault` source/target authenticates against Vault using the [Kubernetes auth +method](https://developer.hashicorp.com/vault/docs/auth/kubernetes): the pod's +projected ServiceAccount token is exchanged for a Vault token bound to a role +and policy. The Vault server address is taken from the `VAULT_ADDR` environment +variable (or the `--vault.addr` flag). + +The following flags are shared by the `init-wallet`, `store-secret` and +`load-secret` commands under the `vault` namespace: + +- `--vault.addr`: The Vault server address (defaults to `VAULT_ADDR`). +- `--vault.auth-mount`: The mount path of the Kubernetes auth method (default + `kubernetes`). +- `--vault.auth-role`: The Vault role to assume (required). +- `--vault.auth-token-path`: The ServiceAccount token file used to authenticate + (defaults to the standard projected token path). +- `--vault.kv-mount`: The mount path of the KV v2 secrets engine (default + `secret`). +- `--vault.secret-path`: The path of the secret within the KV v2 engine, + excluding the engine mount and the `data/` segment (e.g. `lnd/mynode/wallet`). + +#### 1. Store the wallet password and seed in Vault + +Each value is stored as a separate entry (key) within the same KV v2 secret. If +an entry already exists, it is not overwritten and the operation is a no-op. + +```shell +$ lndinit gen-password \ + | lndinit -v store-secret \ + --target=vault \ + --vault.auth-role=lnd \ + --vault.secret-path=lnd/mynode/wallet \ + --vault.secret-key-name=walletpassword + +$ lndinit gen-seed \ + | lndinit -v store-secret \ + --target=vault \ + --vault.auth-role=lnd \ + --vault.secret-path=lnd/mynode/wallet \ + --vault.secret-key-name=walletseed +``` + +#### 2. Initialize the wallet from Vault + +```shell +$ lndinit -v init-wallet \ + --secret-source=vault \ + --vault.auth-role=lnd \ + --vault.secret-path=lnd/mynode/wallet \ + --vault.seed-key-name=walletseed \ + --vault.wallet-password-key-name=walletpassword \ + --init-file.output-wallet-dir=$HOME/.lnd/data/chain/bitcoin/mainnet \ + --init-file.validate-password +``` + +#### 3. Auto unlock lnd from Vault + +As with the Kubernetes example, a named pipe can be used so the password is only +read once during `lnd`'s startup. + +```shell +$ mkfifo /tmp/wallet-password +$ lndinit load-secret \ + --source=vault \ + --vault.auth-role=lnd \ + --vault.secret-path=lnd/mynode/wallet \ + --vault.secret-key-name=walletpassword > /tmp/wallet-password & + +$ lnd \ + --bitcoin.active \ + ... + --wallet-unlock-password-file=/tmp/wallet-password +``` + +A complete startup script that wires these steps together is available in +[`example-init-wallet-vault.sh`](example-init-wallet-vault.sh). + +The Vault role referenced above must be bound to a policy that grants KV v2 +access to the wallet path, for example: + +```hcl +path "secret/data/lnd/*" { + capabilities = ["create", "read", "update"] +} +path "secret/metadata/lnd/*" { + capabilities = ["read"] +} +``` + --- ## Logging and idempotent operations diff --git a/cmd_init_wallet.go b/cmd_init_wallet.go index b3c56c8..23e582e 100644 --- a/cmd_init_wallet.go +++ b/cmd_init_wallet.go @@ -52,6 +52,30 @@ type secretSourceK8s struct { Base64 bool `long:"base64" description:"Encode as base64 when storing and decode as base64 when reading"` } +type secretSourceVault struct { + Addr string `long:"addr" description:"The address of the Vault server; if unset, the VAULT_ADDR environment variable is used"` + AuthMount string `long:"auth-mount" description:"The mount path of the Vault Kubernetes auth method"` + AuthRole string `long:"auth-role" description:"The role to assume when logging into Vault via the Kubernetes auth method"` + AuthTokenPath string `long:"auth-token-path" description:"The full path to the Kubernetes ServiceAccount token file that is used to authenticate against Vault"` + KVMount string `long:"kv-mount" description:"The mount path of the KV v2 secrets engine the secret lives in"` + SecretPath string `long:"secret-path" description:"The path of the secret within the KV v2 engine, excluding the engine mount and the 'data/' segment (e.g. 'lnd/mynode/wallet')"` + SeedKeyName string `long:"seed-key-name" description:"The name of the entry within the secret that contains the seed"` + SeedPassphraseKeyName string `long:"seed-passphrase-key-name" description:"The name of the entry within the secret that contains the seed passphrase"` + WalletPasswordKeyName string `long:"wallet-password-key-name" description:"The name of the entry within the secret that contains the wallet password"` +} + +func (s *secretSourceVault) options(keyName string) *vaultSecretOptions { + return &vaultSecretOptions{ + Addr: s.Addr, + AuthMount: s.AuthMount, + AuthRole: s.AuthRole, + AuthTokenPath: s.AuthTokenPath, + KVMount: s.KVMount, + SecretPath: s.SecretPath, + SecretKeyName: keyName, + } +} + type initTypeFile struct { OutputWalletDir string `long:"output-wallet-dir" description:"The directory in which the wallet.db file should be initialized"` ValidatePassword bool `long:"validate-password" description:"If a wallet file already exists in the output wallet directory, validate that it can be unlocked with the given password; this will try to decrypt the wallet and will take several seconds to complete"` @@ -65,13 +89,14 @@ type initTypeRpc struct { } type initWalletCommand struct { - Network string `long:"network" description:"The Bitcoin network to initialize the wallet for, required for wallet internals" choice:"mainnet" choice:"testnet" choice:"testnet3" choice:"regtest" choice:"simnet"` - SecretSource string `long:"secret-source" description:"Where to read the secrets from to initialize the wallet with" choice:"file" choice:"k8s"` - File *secretSourceFile `group:"Flags for reading the secrets from files (use when --secret-source=file)" namespace:"file"` - K8s *secretSourceK8s `group:"Flags for reading the secrets from Kubernetes (use when --secret-source=k8s)" namespace:"k8s"` - InitType string `long:"init-type" description:"How to initialize the wallet" choice:"file" choice:"rpc"` - InitFile *initTypeFile `group:"Flags for initializing the wallet as a file (use when --init-type=file)" namespace:"init-file"` - InitRpc *initTypeRpc `group:"Flags for initializing the wallet through RPC (use when --init-type=rpc)" namespace:"init-rpc"` + Network string `long:"network" description:"The Bitcoin network to initialize the wallet for, required for wallet internals" choice:"mainnet" choice:"testnet" choice:"testnet3" choice:"regtest" choice:"simnet"` + SecretSource string `long:"secret-source" description:"Where to read the secrets from to initialize the wallet with" choice:"file" choice:"k8s" choice:"vault"` + File *secretSourceFile `group:"Flags for reading the secrets from files (use when --secret-source=file)" namespace:"file"` + K8s *secretSourceK8s `group:"Flags for reading the secrets from Kubernetes (use when --secret-source=k8s)" namespace:"k8s"` + Vault *secretSourceVault `group:"Flags for reading the secrets from HashiCorp Vault (use when --secret-source=vault)" namespace:"vault"` + InitType string `long:"init-type" description:"How to initialize the wallet" choice:"file" choice:"rpc"` + InitFile *initTypeFile `group:"Flags for initializing the wallet as a file (use when --init-type=file)" namespace:"init-file"` + InitRpc *initTypeRpc `group:"Flags for initializing the wallet through RPC (use when --init-type=rpc)" namespace:"init-rpc"` } func newInitWalletCommand() *initWalletCommand { @@ -82,6 +107,11 @@ func newInitWalletCommand() *initWalletCommand { K8s: &secretSourceK8s{ Namespace: defaultK8sNamespace, }, + Vault: &secretSourceVault{ + AuthMount: defaultVaultAuthMount, + AuthTokenPath: defaultK8sServiceAccountTokenPath, + KVMount: defaultVaultKVMount, + }, InitType: typeFile, InitFile: &initTypeFile{}, InitRpc: &initTypeRpc{ @@ -275,6 +305,40 @@ func (x *initWalletCommand) readInput(requireSeed bool) (string, string, string, if err != nil { return "", "", "", err } + + // Read the secrets from a HashiCorp Vault KV v2 secret. + case storageVault: + if requireSeed { + logger.Infof("Reading seed from vault secret %s", + x.Vault.SecretPath) + seed, _, err = readVault( + x.Vault.options(x.Vault.SeedKeyName), + ) + if err != nil { + return "", "", "", err + } + } + + // The seed passphrase is optional. + if x.Vault.SeedPassphraseKeyName != "" { + logger.Infof("Reading seed passphrase from vault "+ + "secret %s", x.Vault.SecretPath) + seedPassPhrase, _, err = readVault( + x.Vault.options(x.Vault.SeedPassphraseKeyName), + ) + if err != nil { + return "", "", "", err + } + } + + logger.Infof("Reading wallet password from vault secret %s", + x.Vault.SecretPath) + walletPassword, _, err = readVault( + x.Vault.options(x.Vault.WalletPasswordKeyName), + ) + if err != nil { + return "", "", "", err + } } // The seed, its passphrase and the wallet password should all never diff --git a/cmd_load_secret.go b/cmd_load_secret.go index db018c9..9839689 100644 --- a/cmd_load_secret.go +++ b/cmd_load_secret.go @@ -7,9 +7,10 @@ import ( ) type loadSecretCommand struct { - Source string `long:"source" short:"s" description:"Secret storage source" choice:"k8s"` - K8s *k8sSecretOptions `group:"Flags for looking up the secret as a value inside a Kubernetes Secret (use when --source=k8s)" namespace:"k8s"` - Output string `long:"output" short:"o" description:"Output format" choice:"raw" choice:"json"` + Source string `long:"source" short:"s" description:"Secret storage source" choice:"k8s" choice:"vault"` + K8s *k8sSecretOptions `group:"Flags for looking up the secret as a value inside a Kubernetes Secret (use when --source=k8s)" namespace:"k8s"` + Vault *vaultSecretOptions `group:"Flags for looking up the secret as an entry inside a HashiCorp Vault KV v2 secret (use when --source=vault)" namespace:"vault"` + Output string `long:"output" short:"o" description:"Output format" choice:"raw" choice:"json"` } func newLoadSecretCommand() *loadSecretCommand { @@ -18,6 +19,11 @@ func newLoadSecretCommand() *loadSecretCommand { K8s: &k8sSecretOptions{ Namespace: defaultK8sNamespace, }, + Vault: &vaultSecretOptions{ + AuthMount: defaultVaultAuthMount, + AuthTokenPath: defaultK8sServiceAccountTokenPath, + KVMount: defaultVaultKVMount, + }, Output: outputFormatRaw, } } @@ -70,6 +76,31 @@ func (x *loadSecretCommand) Execute(_ []string) error { return nil + case storageVault: + content, secret, err := readVault(x.Vault) + if err != nil { + return fmt.Errorf("error reading secret %s from "+ + "vault: %v", x.Vault.SecretPath, err) + } + + if x.Output == outputFormatJSON { + content, err = asJSON(&struct { + *jsonVaultObject `json:",inline"` + Value string `json:"value"` + }{ + jsonVaultObject: secret, + Value: content, + }) + if err != nil { + return fmt.Errorf("error encoding as JSON: %v", + err) + } + } + + fmt.Printf("%s\n", content) + + return nil + default: return fmt.Errorf("invalid secret storage source %s", x.Source) } diff --git a/cmd_store_secret.go b/cmd_store_secret.go index d20ded5..fc75436 100644 --- a/cmd_store_secret.go +++ b/cmd_store_secret.go @@ -22,10 +22,11 @@ type entry struct { } type storeSecretCommand struct { - Batch bool `long:"batch" description:"Instead of reading one secret from stdin, read all files of the argument list and store them as entries in the secret"` - Overwrite bool `long:"overwrite" description:"Overwrite existing secret entries instead of aborting"` - Target string `long:"target" short:"t" description:"Secret storage target" choice:"k8s"` - K8s *targetK8sSecret `group:"Flags for storing the secret as a value inside a Kubernetes Secret (use when --target=k8s)" namespace:"k8s"` + Batch bool `long:"batch" description:"Instead of reading one secret from stdin, read all files of the argument list and store them as entries in the secret"` + Overwrite bool `long:"overwrite" description:"Overwrite existing secret entries instead of aborting"` + Target string `long:"target" short:"t" description:"Secret storage target" choice:"k8s" choice:"vault"` + K8s *targetK8sSecret `group:"Flags for storing the secret as a value inside a Kubernetes Secret (use when --target=k8s)" namespace:"k8s"` + Vault *vaultSecretOptions `group:"Flags for storing the secret as an entry inside a HashiCorp Vault KV v2 secret (use when --target=vault)" namespace:"vault"` } func newStoreSecretCommand() *storeSecretCommand { @@ -39,6 +40,11 @@ func newStoreSecretCommand() *storeSecretCommand { ResourcePolicy: defaultK8sResourcePolicy, }, }, + Vault: &vaultSecretOptions{ + AuthMount: defaultVaultAuthMount, + AuthTokenPath: defaultK8sServiceAccountTokenPath, + KVMount: defaultVaultKVMount, + }, } } @@ -101,6 +107,15 @@ func (x *storeSecretCommand) Execute(args []string) error { return storeSecretsK8s(entries, x.K8s, x.Overwrite) + case storageVault: + // Take the actual entry key from the options if we aren't in + // batch mode. + if len(entries) == 1 && entries[0].key == "" { + entries[0].key = x.Vault.SecretKeyName + } + + return storeSecretsVault(entries, x.Vault, x.Overwrite) + default: return fmt.Errorf("invalid secret storage target %s", x.Target) } @@ -138,3 +153,32 @@ func storeSecretsK8s(entries []*entry, opts *targetK8sSecret, return nil } + +func storeSecretsVault(entries []*entry, opts *vaultSecretOptions, + overwrite bool) error { + + if opts.SecretPath == "" { + return fmt.Errorf("secret path is required") + } + + for _, entry := range entries { + if entry.key == "" { + return fmt.Errorf("secret key name is required") + } + + // Each entry is stored under its own key within the same KV v2 + // secret, so we only vary the key name between iterations. + entryOpts := *opts + entryOpts.SecretKeyName = entry.key + + logger.Infof("Storing entry with name %s to secret %s in vault", + entryOpts.SecretKeyName, entryOpts.SecretPath) + err := saveVault(entry.value, &entryOpts, overwrite) + if err != nil { + return fmt.Errorf("error storing secret %s entry %s: "+ + "%v", opts.SecretPath, entry.key, err) + } + } + + return nil +} diff --git a/example-init-wallet-vault.sh b/example-init-wallet-vault.sh new file mode 100755 index 0000000..1ec79be --- /dev/null +++ b/example-init-wallet-vault.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -e + +# The address of the Vault server. Inside a Kubernetes cluster this is usually +# the in-cluster service DNS name. It can also be provided to lndinit via the +# VAULT_ADDR environment variable instead of the --vault.addr flag. +VAULT_ADDR=${VAULT_ADDR:-http://vault.vault.svc.cluster.local:8200} +export VAULT_ADDR + +# The Vault Kubernetes auth role that binds this pod's ServiceAccount to a +# policy allowing access to the wallet secret path. +VAULT_AUTH_ROLE=${VAULT_AUTH_ROLE:-lnd} + +# The mount path of the KV v2 secrets engine and the path of the wallet secret +# within it (excluding the engine mount and the "data/" segment). +VAULT_KV_MOUNT=${VAULT_KV_MOUNT:-secret} +VAULT_SECRET_PATH=${VAULT_SECRET_PATH:-lnd/mynode/wallet} + +WALLET_DIR=${WALLET_DIR:-~/.lnd/data/chain/bitcoin/mainnet} +WALLET_PASSWORD_FILE=${WALLET_PASSWORD_FILE:-/tmp/wallet-password} +CERT_DIR=${CERT_DIR:-~/.lnd} +UPLOAD_RPC_SECRETS=${UPLOAD_RPC_SECRETS:-0} +RPC_SECRETS_PATH=${RPC_SECRETS_PATH:-lnd/mynode/rpc} + +echo "[STARTUP] Asserting wallet password exists in secret ${VAULT_SECRET_PATH}" +lndinit gen-password \ + | lndinit -v store-secret \ + --target=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.secret-key-name=walletpassword + +echo "" +echo "[STARTUP] Asserting seed exists in secret ${VAULT_SECRET_PATH}" +lndinit gen-seed \ + | lndinit -v store-secret \ + --target=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.secret-key-name=walletseed + +echo "" +echo "[STARTUP] Asserting wallet is created with values from secret ${VAULT_SECRET_PATH}" +lndinit -v init-wallet \ + --secret-source=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.seed-key-name=walletseed \ + --vault.wallet-password-key-name=walletpassword \ + --init-file.output-wallet-dir="${WALLET_DIR}" \ + --init-file.validate-password + +echo "" +echo "[STARTUP] Preparing lnd auto unlock file" + +# To make sure the password can be read exactly once (by lnd itself), we create +# a named pipe. Because we can only write to such a pipe if there's a reader on +# the other end, we need to run this in a sub process in the background. +mkfifo "${WALLET_PASSWORD_FILE}" +lndinit -v load-secret \ + --source=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.secret-key-name=walletpassword > "${WALLET_PASSWORD_FILE}" & + +# In case we want to upload the TLS certificate and macaroons to Vault once lnd +# is ready, we can use the wait-ready and store-secret commands in combination +# to wait until lnd is ready and then batch upload the files to Vault. +if [[ "${UPLOAD_RPC_SECRETS}" == "1" ]]; then + echo "" + echo "[STARTUP] Starting RPC secret uploader process in background" + lndinit -v wait-ready \ + && lndinit -v store-secret \ + --batch \ + --overwrite \ + --target=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${RPC_SECRETS_PATH}" \ + "${CERT_DIR}/tls.cert" \ + "${WALLET_DIR}"/*.macaroon & +fi + +# And finally start lnd. We need to use "exec" here to make sure all signals are +# forwarded correctly. +echo "" +echo "[STARTUP] Starting lnd with flags: $@" +exec lnd "$@" diff --git a/go.mod b/go.mod index a72c527..b868772 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,11 @@ require ( k8s.io/client-go v0.18.3 ) -require github.com/lightningnetwork/lnd/healthcheck v1.2.6 +require ( + github.com/hashicorp/vault/api v1.15.0 + github.com/hashicorp/vault/api/auth/kubernetes v0.8.0 + github.com/lightningnetwork/lnd/healthcheck v1.2.6 +) require ( dario.cat/mergo v1.0.1 // indirect @@ -43,7 +47,7 @@ require ( github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect github.com/btcsuite/winsvc v1.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/continuity v0.3.0 // indirect github.com/coreos/go-semver v0.3.0 // indirect @@ -58,6 +62,7 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -78,8 +83,15 @@ require ( github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect @@ -120,6 +132,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/miekg/dns v1.1.43 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -137,6 +151,7 @@ require ( github.com/prometheus/procfs v0.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/fastuuid v1.2.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sirupsen/logrus v1.9.2 // indirect github.com/soheilhy/cmux v0.1.5 // indirect github.com/spf13/pflag v1.0.6 // indirect diff --git a/go.sum b/go.sum index 68ba9ca..6888531 100644 --- a/go.sum +++ b/go.sum @@ -39,10 +39,12 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -95,8 +97,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -159,6 +161,9 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= @@ -171,6 +176,8 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -190,6 +197,8 @@ github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dp github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -268,12 +277,34 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= +github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/api/auth/kubernetes v0.8.0 h1:6jPcORq7OHwf+MCbaaUmiBvMhETAaZ7+i97WfZtF5kc= +github.com/hashicorp/vault/api/auth/kubernetes v0.8.0/go.mod h1:nfl5sRUUork0ZSfV3xf+pgAFQSD5kSkL0k9axg523DM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= @@ -437,9 +468,13 @@ github.com/ltcsuite/ltcutil v0.0.0-20181217130922-17f3b04680b6/go.mod h1:8Vg/LTO github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-colorable v0.0.6/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -449,6 +484,13 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -502,6 +544,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= @@ -531,6 +574,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= @@ -729,6 +775,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/main.go b/main.go index c4c2cf3..09ef985 100644 --- a/main.go +++ b/main.go @@ -21,8 +21,9 @@ const ( outputFormatRaw = "raw" outputFormatJSON = "json" - storageFile = "file" - storageK8s = "k8s" + storageFile = "file" + storageK8s = "k8s" + storageVault = "vault" errTargetExists = "target exists error" errInputMissing = "input missing error" diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..9a350bc --- /dev/null +++ b/vault.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/vault/api" + auth "github.com/hashicorp/vault/api/auth/kubernetes" +) + +const ( + // defaultK8sServiceAccountTokenPath is the default location of the + // projected Kubernetes ServiceAccount token that is used to + // authenticate against Vault's Kubernetes auth method. + defaultK8sServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/" + + "serviceaccount/token" + + // defaultVaultKVMount is the default mount path of the KV v2 secrets + // engine. + defaultVaultKVMount = "secret" + + // defaultVaultAuthMount is the default mount path of the Vault + // Kubernetes auth method. + defaultVaultAuthMount = "kubernetes" +) + +// vaultSecretOptions holds all the flags that are needed to connect to Vault, +// authenticate against it via the Kubernetes auth method and address a single +// entry within a KV v2 secret. It is shared by the store-secret and +// load-secret commands. +type vaultSecretOptions struct { + Addr string `long:"addr" description:"The address of the Vault server; if unset, the VAULT_ADDR environment variable is used"` + AuthMount string `long:"auth-mount" description:"The mount path of the Vault Kubernetes auth method"` + AuthRole string `long:"auth-role" description:"The role to assume when logging into Vault via the Kubernetes auth method"` + AuthTokenPath string `long:"auth-token-path" description:"The full path to the Kubernetes ServiceAccount token file that is used to authenticate against Vault"` + KVMount string `long:"kv-mount" description:"The mount path of the KV v2 secrets engine the secret lives in"` + SecretPath string `long:"secret-path" description:"The path of the secret within the KV v2 engine, excluding the engine mount and the 'data/' segment (e.g. 'lnd/mynode/wallet')"` + SecretKeyName string `long:"secret-key-name" description:"The name of the key/entry within the secret"` +} + +// jsonVaultObject is the subset of Vault response metadata that we surface when +// the load-secret command is asked for JSON output. +type jsonVaultObject struct { + RequestID string `json:"request_id"` + LeaseID string `json:"lease_id"` + LeaseDuration int `json:"lease_duration"` + Renewable bool `json:"renewable"` +} + +// newJSONVaultObject extracts the response metadata from a KV v2 secret so it +// can be marshalled alongside the secret's value. +func newJSONVaultObject(secret *api.KVSecret) *jsonVaultObject { + if secret == nil || secret.Raw == nil { + return &jsonVaultObject{} + } + + return &jsonVaultObject{ + RequestID: secret.Raw.RequestID, + LeaseID: secret.Raw.LeaseID, + LeaseDuration: secret.Raw.LeaseDuration, + Renewable: secret.Raw.Renewable, + } +} + +// kvMount returns the configured KV v2 mount path, falling back to the default +// if none was set. +func kvMount(opts *vaultSecretOptions) string { + if opts.KVMount == "" { + return defaultVaultKVMount + } + + return opts.KVMount +} + +// saveVault stores a single entry within a KV v2 secret. To avoid clobbering +// sibling entries in the same secret, the existing secret (if any) is read +// first and only the addressed key is updated before the merged data is written +// back. +func saveVault(content string, opts *vaultSecretOptions, overwrite bool) error { + client, err := getClientVault(opts) + if err != nil { + return err + } + + ctx := context.Background() + kv := client.KVv2(kvMount(opts)) + + // Read the current state of the secret so we can merge our entry into + // it. A not-found error just means we'll be creating the secret fresh. + data := make(map[string]interface{}) + existing, err := kv.Get(ctx, opts.SecretPath) + switch { + case err == nil && existing != nil && existing.Data != nil: + data = existing.Data + + case err == nil: + // Secret exists but has no data, we'll initialize it below. + + case errors.Is(err, api.ErrSecretNotFound): + // Secret doesn't exist yet, we'll create it below. + + default: + return fmt.Errorf("error querying secret %s in vault: %v", + opts.SecretPath, err) + } + + if data[opts.SecretKeyName] != nil && !overwrite { + return fmt.Errorf("entry %s in secret %s already exists: %v", + opts.SecretKeyName, opts.SecretPath, errTargetExists) + } + + data[opts.SecretKeyName] = content + + logger.Infof("Attempting to write entry %s of secret %s in vault", + opts.SecretKeyName, opts.SecretPath) + if _, err := kv.Put(ctx, opts.SecretPath, data); err != nil { + return fmt.Errorf("error writing secret %s in vault: %v", + opts.SecretPath, err) + } + + logger.Infof("Stored entry %s of secret %s in vault", + opts.SecretKeyName, opts.SecretPath) + + return nil +} + +// readVault reads a single entry from a KV v2 secret and returns its value +// along with the Vault response metadata. +func readVault(opts *vaultSecretOptions) (string, *jsonVaultObject, error) { + client, err := getClientVault(opts) + if err != nil { + return "", nil, err + } + + kv := client.KVv2(kvMount(opts)) + secret, err := kv.Get(context.Background(), opts.SecretPath) + switch { + case errors.Is(err, api.ErrSecretNotFound): + return "", nil, fmt.Errorf("secret %s does not exist in vault", + opts.SecretPath) + + case err != nil: + return "", nil, fmt.Errorf("error reading secret %s from "+ + "vault: %v", opts.SecretPath, err) + } + + if secret == nil || len(secret.Data) == 0 { + return "", nil, fmt.Errorf("secret %s exists but contains no "+ + "data", opts.SecretPath) + } + + entry, ok := secret.Data[opts.SecretKeyName] + if !ok || entry == nil { + return "", nil, fmt.Errorf("secret %s exists but does not "+ + "contain the entry %s", opts.SecretPath, + opts.SecretKeyName) + } + + stringEntry, isString := entry.(string) + if !isString { + return "", nil, fmt.Errorf("entry %s of secret %s is not a "+ + "string value", opts.SecretKeyName, opts.SecretPath) + } + if len(stringEntry) == 0 { + return "", nil, fmt.Errorf("secret %s exists but the entry %s "+ + "is empty", opts.SecretPath, opts.SecretKeyName) + } + + // Remove any newlines at the end of the value. We never write a newline + // ourselves, but the entry may have been provisioned by another process + // or user. + content := strings.TrimRight(stringEntry, "\r\n") + + return content, newJSONVaultObject(secret), nil +} + +// getClientVault creates a Vault client from the environment and authenticates +// it against the configured Kubernetes auth method. +func getClientVault(opts *vaultSecretOptions) (*api.Client, error) { + logger.Infof("Creating vault config from environment") + cfg := api.DefaultConfig() + if cfg.Error != nil { + return nil, fmt.Errorf("error reading vault config from env: "+ + "%v", cfg.Error) + } + + // An explicit address flag overrides whatever the environment provided. + if opts.Addr != "" { + cfg.Address = opts.Addr + } + + logger.Infof("Creating vault client for server %s", cfg.Address) + client, err := api.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("error creating vault client: %v", err) + } + + if opts.AuthRole == "" { + return nil, fmt.Errorf("the --vault.auth-role flag must be set") + } + + authMount := opts.AuthMount + if authMount == "" { + authMount = defaultVaultAuthMount + } + + tokenPath := opts.AuthTokenPath + if tokenPath == "" { + tokenPath = defaultK8sServiceAccountTokenPath + } + + logger.Infof("Authenticating against vault kubernetes auth method %s "+ + "with role %s", authMount, opts.AuthRole) + k8sAuth, err := auth.NewKubernetesAuth( + opts.AuthRole, + auth.WithMountPath(authMount), + auth.WithServiceAccountTokenPath(tokenPath), + ) + if err != nil { + return nil, fmt.Errorf("error setting up kubernetes auth: %v", + err) + } + + authInfo, err := client.Auth().Login(context.Background(), k8sAuth) + if err != nil { + return nil, fmt.Errorf("error logging into vault: %v", err) + } + if authInfo == nil || authInfo.Auth == nil { + return nil, fmt.Errorf("no authentication info returned after " + + "vault login") + } + + logger.Infof("Authenticated successfully with vault, acquired "+ + "policies %v", authInfo.Auth.Policies) + + logger.Infof("Vault client created successfully") + return client, nil +} diff --git a/vault_test.go b/vault_test.go new file mode 100644 index 0000000..55e873f --- /dev/null +++ b/vault_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// newTestVault spins up an httptest server that emulates the subset of the +// Vault HTTP API that lndinit uses: the Kubernetes auth login endpoint and the +// KV v2 data endpoints. It returns the server URL and the backing in-memory +// store keyed by KV path. +func newTestVault(t *testing.T) (string, map[string]map[string]interface{}) { + t.Helper() + + store := make(map[string]map[string]interface{}) + + const dataPrefix = "/v1/secret/data/" + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Kubernetes auth login: hand back a usable client token. + if strings.HasSuffix(r.URL.Path, "/auth/kubernetes/login") { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "auth": map[string]interface{}{ + "client_token": "test-token", + "policies": []string{"lnd"}, + "lease_duration": 3600, + "renewable": true, + }, + }) + return + } + + if !strings.HasPrefix(r.URL.Path, dataPrefix) { + w.WriteHeader(http.StatusNotFound) + return + } + + path := strings.TrimPrefix(r.URL.Path, dataPrefix) + switch r.Method { + // KV v2 read: nest the values under data.data, as Vault does. + case http.MethodGet: + data, ok := store[path] + if !ok { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode( + map[string]interface{}{ + "errors": []string{}, + }, + ) + return + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "request_id": "req-123", + "data": map[string]interface{}{ + "data": data, + "metadata": map[string]interface{}{ + "version": 1, + "created_time": "2020-01-01T00:00:00Z", + }, + }, + }) + + // KV v2 write: the payload wraps the values under a data key. + case http.MethodPost, http.MethodPut: + var body struct { + Data map[string]interface{} `json:"data"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + store[path] = body.Data + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "version": 1, + "created_time": "2020-01-01T00:00:00Z", + }, + }) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } + + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(srv.Close) + + return srv.URL, store +} + +// testVaultOptions returns a vaultSecretOptions pointed at the test server with +// a temporary ServiceAccount token file. +func testVaultOptions(t *testing.T, addr, path, key string) *vaultSecretOptions { + t.Helper() + + tokenFile := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(tokenFile, []byte("jwt-token"), 0600)) + + return &vaultSecretOptions{ + Addr: addr, + AuthMount: defaultVaultAuthMount, + AuthRole: "lnd", + AuthTokenPath: tokenFile, + KVMount: defaultVaultKVMount, + SecretPath: path, + SecretKeyName: key, + } +} + +// TestVaultSaveAndRead verifies the round trip of storing and reading a single +// entry, and that adding a second entry does not clobber the first. +func TestVaultSaveAndRead(t *testing.T) { + addr, _ := newTestVault(t) + path := "lnd/test/wallet" + + pwOpts := testVaultOptions(t, addr, path, "walletpassword") + require.NoError(t, saveVault("supersecret", pwOpts, false)) + + content, meta, err := readVault(pwOpts) + require.NoError(t, err) + require.Equal(t, "supersecret", content) + require.NotNil(t, meta) + + // Store a second entry in the same secret and make sure the first one + // survives the merge. + seedOpts := testVaultOptions(t, addr, path, "walletseed") + require.NoError(t, saveVault("my sixteen words", seedOpts, false)) + + seed, _, err := readVault(seedOpts) + require.NoError(t, err) + require.Equal(t, "my sixteen words", seed) + + pw, _, err := readVault(pwOpts) + require.NoError(t, err) + require.Equal(t, "supersecret", pw) +} + +// TestVaultOverwriteGuard verifies that an existing entry is only replaced when +// the overwrite flag is set, and that the non-overwrite path surfaces the +// errTargetExists sentinel used for lndinit's idempotency contract. +func TestVaultOverwriteGuard(t *testing.T) { + addr, _ := newTestVault(t) + opts := testVaultOptions(t, addr, "lnd/test/wallet", "walletpassword") + + require.NoError(t, saveVault("first", opts, false)) + + // Storing again without overwrite must fail with the sentinel. + err := saveVault("second", opts, false) + require.Error(t, err) + require.Contains(t, err.Error(), errTargetExists) + + // The original value must be untouched. + content, _, err := readVault(opts) + require.NoError(t, err) + require.Equal(t, "first", content) + + // With overwrite, the value is replaced. + require.NoError(t, saveVault("second", opts, true)) + content, _, err = readVault(opts) + require.NoError(t, err) + require.Equal(t, "second", content) +} + +// TestVaultReadTrimsNewline makes sure trailing newlines on a stored value are +// stripped on read. +func TestVaultReadTrimsNewline(t *testing.T) { + addr, _ := newTestVault(t) + opts := testVaultOptions(t, addr, "lnd/test/wallet", "walletpassword") + + require.NoError(t, saveVault("value-with-newline\r\n", opts, false)) + + content, _, err := readVault(opts) + require.NoError(t, err) + require.Equal(t, "value-with-newline", content) +} + +// TestVaultReadErrors covers the not-found, missing-entry and empty-entry +// error paths. +func TestVaultReadErrors(t *testing.T) { + addr, _ := newTestVault(t) + + // Secret does not exist at all. + missing := testVaultOptions(t, addr, "lnd/missing/wallet", "walletpassword") + _, _, err := readVault(missing) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + + // Secret exists but the requested entry does not. + present := testVaultOptions(t, addr, "lnd/test/wallet", "walletpassword") + require.NoError(t, saveVault("supersecret", present, false)) + + wrongKey := testVaultOptions(t, addr, "lnd/test/wallet", "does-not-exist") + _, _, err = readVault(wrongKey) + require.Error(t, err) + require.Contains(t, err.Error(), "does not contain the entry") + + // Entry exists but is empty. + empty := testVaultOptions(t, addr, "lnd/test/wallet", "empty") + require.NoError(t, saveVault("", empty, false)) + _, _, err = readVault(empty) + require.Error(t, err) + require.Contains(t, err.Error(), "empty") +} + +// TestStoreSecretsVault exercises the batch store helper used by the +// store-secret command. +func TestStoreSecretsVault(t *testing.T) { + addr, _ := newTestVault(t) + opts := testVaultOptions(t, addr, "lnd/test/rpc", "") + + entries := []*entry{ + {key: "tls.cert", value: "cert-bytes"}, + {key: "admin.macaroon", value: "macaroon-bytes"}, + } + require.NoError(t, storeSecretsVault(entries, opts, false)) + + for _, e := range entries { + readOpts := testVaultOptions(t, addr, "lnd/test/rpc", e.key) + content, _, err := readVault(readOpts) + require.NoError(t, err) + require.Equal(t, e.value, content) + } + + // A missing secret path must be rejected. + badOpts := testVaultOptions(t, addr, "", "tls.cert") + require.Error(t, storeSecretsVault(entries, badOpts, false)) +} From cf0ab6e96e1a5164e507aa300112b419557207b0 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 1 Jul 2026 13:05:22 -0700 Subject: [PATCH 2/2] docs: add Vault integration design doc In this commit, we add the design doc that motivates the KV v2 Vault variant: how our production Vault in lightning-infra is actually set up (KV v2, Kubernetes auth, VSO), the gap versus the earlier KV v1 approach, the locked design decisions, and the companion infra wiring needed to roll it out. --- docs/vault-integration-design.md | 275 +++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/vault-integration-design.md diff --git a/docs/vault-integration-design.md b/docs/vault-integration-design.md new file mode 100644 index 0000000..faed3c1 --- /dev/null +++ b/docs/vault-integration-design.md @@ -0,0 +1,275 @@ +# lndinit Vault Integration — Design & Plan (KV v2 variant) + +Status: reviewed — decisions locked, ready to execute +Author: (design pass) +Related: [lightninglabs/lndinit#62](https://github.com/lightninglabs/lndinit/pull/62) (to be superseded) + +### Locked decisions + +1. **Scope:** `lndinit` Go change **and** the `lightning-infra` wiring (policy, + k8s auth role, lnd chart init-script) — two PRs, one per repo. +2. **PR strategy:** open a **fresh PR** that supersedes #62; close #62. +3. **KV version:** **v2 only.** Drop PR #62's v1 behavior; no version toggle. +4. **Auth:** use the official `vault/api/auth/kubernetes` helper (mount + + audience configurable). +5. **Architecture:** Option A — direct-to-Vault for both read and write; the + seed/password never become a k8s Secret. + +Still open (rollout detail, decide before step 5 of §11): whether to migrate +existing k8s-Secret seeds into Vault or adopt Vault only for new nodes. + +## 1. Purpose + +PR #62 adds a `vault` secret source/target to `lndinit` (init-wallet, +store-secret, load-secret). It is a straight port of an old branch from +Oliver's `lnd` fork and was written against a **KV v1** Vault with a +hand-rolled Kubernetes login. Our production Vault in `lightning-infra` looks +different today. This doc specifies a **new variant** of the Vault integration +that targets the Vault we actually run now, so that `lnd` pods can keep their +wallet seed and password in Vault (never in an etcd-backed k8s Secret). + +## 2. What PR #62 does today + +- New `vault.go` with `getClientVault` / `readVault` / `saveVault` / + `storeSecretVault`, mirroring the existing `k8s.go` shape. +- Wires a third choice into three commands: `init-wallet --secret-source=vault`, + `store-secret --target=vault`, `load-secret --source=vault`. +- Auth: hand-rolled `POST /v1/auth/kubernetes/login` with the pod's + ServiceAccount JWT + a role, then sets the returned client token. +- Secret access: `client.Logical().Read(name)` and + `client.Logical().Write(name, flatMap)`, reading `secret.Data[key]` directly. +- Config: `api.DefaultConfig()` (reads `VAULT_ADDR` and friends from env). +- Adds `example-init-wallet-vault.sh` mirroring the k8s example. + +## 3. What our Vault actually is (from `lightning-infra`) + +Concrete facts that drive the design (evidence in `lightning-infra`): + +- **Engine: KV v2**, mount `secret/`. Values live at `secret/data/`, + metadata at `secret/metadata/`. The infra provisioner uses + `client.KVv2("secret")` (`containers/vault-github-provisioner/main.go`), and + VSO manifests declare `type: kv-v2`, `mount: secret`. +- **Auth: Kubernetes auth method** at mount `auth/kubernetes`; roles bind a + ServiceAccount + namespace to a policy, with token `audiences: ["vault"]` + (see `charts/*/templates/static-auth.yaml`, + `charts/vault-secrets-operator/values-*.yaml`). GitHub auth exists too, but + that is for humans, not workloads. +- **Server address (in-cluster):** `http://vault.vault.svc.cluster.local:8200` + (`defaultVaultConnection` in the VSO values). Ingress URLs per env exist for + humans. +- **VSO is the standard read path** for most workloads: `VaultAuth` + + `VaultStaticSecret` sync a Vault path into a k8s Secret. VSO is read-only and + cannot generate/write secrets. +- **Current lnd flow does NOT use Vault.** `lnd` charts run `lndinit + gen-password` / `gen-seed` and `store-secret --target=k8s`, then auto-unlock + by reading the password back from the k8s Secret through a FIFO. The seed and + password therefore sit in etcd as a k8s Secret. +- **Deploy model:** GitOps via ArgoCD; changes land through + `charts//values-.yaml` and app-info files. + +## 4. Gap analysis: PR #62 vs. our Vault + +| Concern | PR #62 | Our Vault | Consequence | +|---|---|---|---| +| KV version | v1 (`Logical().Read/Write`, flat `Data`) | **v2** (nested `data`, `/data/` path) | PR #62 reads/writes the wrong shape; a v2 read returns `{data:{...}, metadata:{...}}`, so `Data[key]` is nil. **Must fix.** | +| Auth | hand-rolled HTTP login | `auth/kubernetes`, mount configurable, audiences `["vault"]` | Works, but brittle; no mount/audience config. Prefer official helper. | +| Path model | single `--secret-name` used as literal path | mount + KV path + key | Need explicit mount + path + key fields. | +| Write semantics | read-modify-`Write` whole map | KV v2 `Patch`/`Put` | Use KVv2 helper to avoid clobbering sibling keys. | +| Server addr | env only | in-cluster DNS default | Keep env-driven; optionally allow `--vault.addr`. | + +## 5. Scope & architecture decision + +The store/generate path (gen-seed → persist) **must** talk to Vault directly — +VSO cannot write. So this remains a direct-to-Vault feature in `lndinit`. The +only real fork is the *consume* (auto-unlock) path: + +- **Option A — direct Vault for read and write (recommended).** `lndinit` + authenticates via k8s auth and reads the seed/password directly at init and + unlock time. The seed **never** materializes as a k8s Secret. This is the + security win and the natural modernization of PR #62. +- **Option B — VSO for the read path.** VSO syncs `secret/data/lnd/` into + a k8s Secret; `lndinit` keeps `--secret-source=k8s` for unlock. Less code, but + the seed lands in etcd, defeating much of the point. Store path still needs + direct Vault. + +This plan assumes **Option A**. + +## 6. Proposed CLI surface + +Rework `vaultSecretOptions` (shared by all three commands) to KV v2 + explicit +auth config. Field/flag names mirror the k8s source where possible. + +``` +# Connection & auth (shared) +--vault.addr (optional; else VAULT_ADDR / api.DefaultConfig) +--vault.auth-mount=kubernetes +--vault.auth-role= (required) +--vault.auth-token-path=/var/run/secrets/kubernetes.io/serviceaccount/token + +# KV v2 location (shared) +--vault.kv-mount=secret +--vault.secret-path=lnd//wallet (KV path, WITHOUT mount or /data/) +--vault.secret-key-name= (store-secret / load-secret) + +# init-wallet only (multiple keys within one secret) +--vault.seed-key-name=walletseed +--vault.seed-passphrase-key-name= +--vault.wallet-password-key-name=walletpassword +``` + +Notes: +- Rename PR #62's `--vault.secret-name` → `--vault.secret-path` to reflect KV v2 + path semantics, and add `--vault.kv-mount` / `--vault.auth-mount`. +- **Audience** is intentionally *not* a flag: the token audience is a property + of the projected ServiceAccount token the pod mounts (set in the pod spec's + `serviceAccountToken` projection and matched by the Vault role), so + `--vault.auth-token-path` just points at that token. The official k8s auth + helper exposes no client-side audience option. +- `main.go`: keep `storageVault = "vault"`; add `vault` to the `choice:` lists + on `--secret-source`, `--source`, `--target`. + +## 7. Go implementation plan (`vault.go` rewrite) + +Dependencies (add to `go.mod`): +- `github.com/hashicorp/vault/api` (already pulled by PR #62). +- `github.com/hashicorp/vault/api/auth/kubernetes` (small helper module) — for + `NewKubernetesAuth(role, WithMountPath(...), WithServiceAccountTokenPath(...))`. + +Client construction: +```go +cfg := api.DefaultConfig() // VAULT_ADDR, TLS, etc. from env +if opts.Addr != "" { cfg.Address = opts.Addr } +client, _ := api.NewClient(cfg) + +k8sAuth, _ := auth.NewKubernetesAuth( + opts.AuthRole, + auth.WithMountPath(opts.AuthMount), // default "kubernetes" + auth.WithServiceAccountTokenPath(opts.AuthTokenPath), +) +if _, err := client.Auth().Login(ctx, k8sAuth); err != nil { ... } +``` +(If we prefer zero extra modules, keep PR #62's hand-rolled login but make the +mount path configurable. Recommendation: use the official helper.) + +Read (`readVault`) — KV v2: +```go +kv := client.KVv2(opts.KVMount) // default "secret" +sec, err := kv.Get(ctx, opts.SecretPath) // handles /data/ automatically +raw, ok := sec.Data[opts.SecretKeyName].(string) +// not-found and empty-entry errors as today; TrimRight "\r\n" as PR #62 does. +``` + +Write (`storeSecretVault` / `saveVault`) — KV v2, no clobber: +```go +kv := client.KVv2(opts.KVMount) +existing, err := kv.Get(ctx, opts.SecretPath) // errors.Is(err, ErrSecretNotFound) -> empty +data := existing.Data (or {}) // read-modify-write merge +if data[key] != nil && !overwrite { return errTargetExists } +data[key] = content +kv.Put(ctx, opts.SecretPath, data) // full write of merged map +``` +- Read-modify-`Put` (rather than `Patch`) so the lnd policy only needs + `create/read/update` on `secret/data/lnd/*` — `Patch` would require the + separate `patch` capability. +- `errTargetExists` sentinel is preserved so `-e/--error-on-existing` and the + existing exit-code contract keep working (see `main.go`). +- `jsonVaultObject` for `load-secret --output=json` can be sourced from + `sec.Raw` (request_id, lease_*, renewable) to keep parity with PR #62. + +`init-wallet` `readInput` switch: the `case storageVault:` block from PR #62 +stays structurally the same, just pointing at the new options +(seed/passphrase/password keys under one `--vault.secret-path`). + +## 8. `lightning-infra` wiring (companion changes) + +These are the infra-side pieces that make Option A run (separate PR in +`lightning-infra`, GitOps): + +1. **Vault policy** (e.g. `lnd`), KV v2 capabilities scoped to lnd paths: + ```hcl + path "secret/data/lnd/*" { capabilities = ["create", "read", "update"] } + path "secret/metadata/lnd/*" { capabilities = ["read"] } + ``` +2. **k8s auth role** binding the lnd ServiceAccount(s)/namespace(s) to the + policy: + ``` + vault write auth/kubernetes/role/lnd \ + bound_service_account_names= \ + bound_service_account_namespaces= \ + token_policies=lnd token_ttl=1h + ``` + Fold this into the vault-provisioner setup job pattern rather than by hand. +3. **lnd chart**: switch `configmap-init-script.yaml` from the `--target=k8s` + calls to the `vault` variant (`store-secret --target=vault`, + `init-wallet --secret-source=vault`, `load-secret --source=vault`), and set + env: `VAULT_ADDR=http://vault.vault.svc.cluster.local:8200`, + `VAULT_ROLE=lnd`, `VAULT_SECRET_PATH=lnd//wallet`. Project the SA token + with `audience: vault` (or rely on the default projected token if the role + accepts it). +4. **Image**: bump the `lndinit` image tag once the new binary is released + (the lnd chart already uses an `lndinit`-bundled image). + +## 9. Security considerations + +- Seed/password never become a k8s Secret under Option A — they live only in + Vault and transit the pod as an in-memory value / FIFO, matching the existing + auto-unlock FIFO trick. +- Least-privilege policy: lnd role gets `create/read/update` on `secret/data/lnd/*` + only; no `delete`, no cross-namespace paths. +- Token audience pinned to `vault`; SA-token path configurable for non-default + projections. +- Idempotency: `errTargetExists` + `-e` preserves "assert exists, don't + overwrite" semantics so a restart won't rotate the seed. **Critical**: seed + overwrite must never happen silently on an existing wallet. + +## 10. Testing plan + +- **Unit**: table tests for KV v2 read/merge-write shaping against a stubbed + `Logical()`/httptest Vault (mirror `k8s_test.go` style). Cover: missing + secret, missing key, empty value, overwrite vs. no-overwrite (`errTargetExists`), + trailing-newline trim. +- **Integration (manual/dev)**: dev-mode Vault (`vault server -dev`) with KV v2 + + k8s auth stubbed via token; run the full `example-init-wallet-vault.sh` + against it. +- **regtest e2e**: in a kind/dev cluster with the real Vault chart, run the lnd + init script end-to-end (gen → store → init → unlock). +- Keep the k8s path tests green; the vault path is additive. + +## 11. Rollout / migration + +1. Land `lndinit` Go change (KV v2 + k8s auth helper), cut a release + image. +2. Land `lightning-infra` policy + role (provisioner) — no workload impact yet. +3. Flip one **staging** lnd node's init script to the vault variant; verify + clean init and unlock across a pod restart. +4. Promote to testnet, then prod, per the usual ArgoCD env progression. +5. For existing nodes with a seed already in a k8s Secret: one-time migrate by + `store-secret --target=vault` from the current values, verify, then remove + the k8s Secret. (Or leave existing nodes as-is and only use Vault for new + nodes — TBD, see open questions.) + +## 12. Execution order + +Two PRs, `lndinit` first (it produces the binary/image the infra depends on): + +**PR 1 — `lndinit` (this repo), fresh branch superseding #62:** +1. Add deps: `vault/api`, `vault/api/auth/kubernetes`. +2. Rewrite `vault.go`: KV v2 (`client.KVv2`) read + merge-write, official k8s + auth login, new `vaultSecretOptions` (mount/path/auth-mount/role/audiences). +3. Update `main.go` `choice:` tags to include `vault` on the three commands. +4. Update `cmd_init_wallet.go` / `cmd_load_secret.go` / `cmd_store_secret.go` + `vault` cases to the new options. +5. Rewrite `example-init-wallet-vault.sh` for KV v2 flags + `VAULT_ADDR`. +6. Unit tests (`vault_test.go`) mirroring `k8s_test.go`. +7. README: document the `vault` source/target. +8. Open fresh PR; close #62 with a pointer. + +**PR 2 — `lightning-infra` (companion, after image is cut):** +1. Vault policy `lnd` (`secret/data/lnd/*` create/read/update) via the + provisioner setup-job pattern. +2. k8s auth role `lnd` binding lnd SA + namespace(s) → policy. +3. lnd chart: init-script → vault variant; env `VAULT_ADDR`, `VAULT_ROLE`, + `VAULT_SECRET_PATH`; SA token `audience: vault`; bump `lndinit` image. +4. Roll staging → testnet → prod per §11. + +Still open (decide before §11 step 5): existing-node seed migration vs. +Vault-only-for-new-nodes.