From 5bf75fb5b7e88153e34d2c7133315b654dbe1642 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 21 May 2022 13:04:49 -0700 Subject: vault: add full client with renewal --- vault/client.go | 330 +++++++++++++++++++++++++++++++++++++++++++++++++ vault/go.mod | 7 +- vault/go.sum | 11 +- vault/simple_client.go | 21 ++++ 4 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 vault/client.go diff --git a/vault/client.go b/vault/client.go new file mode 100644 index 0000000..2f645d4 --- /dev/null +++ b/vault/client.go @@ -0,0 +1,330 @@ +package vault + +import ( + "context" + "fmt" + "os" + "path" + "sync" + "time" + + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/api/auth/approle" + "github.com/mitchellh/mapstructure" +) + +type VaultClient interface { + LoginApprole(c context.Context, roleId string, secretId string) error + + DbStaticCredential(c context.Context, suffix string) (*VaultUsernamePassword, error) + DbCredential(c context.Context, suffix string) (*VaultUsernamePassword, error) + + KV(c context.Context, suffix string, out interface{}) (*VaultSecret, error) + KVApiKey(c context.Context, suffix string) (*VaultApiKey, error) + KVCredential(c context.Context, suffix string) (*VaultUsernamePassword, error) + + Destroy(HasSecret) + Run(ctx context.Context, wg *sync.WaitGroup) error +} + +type HasSecret interface { + VaultSecret() *VaultSecret +} + +// VaultSecret is an opaque reference to a secret from Vault. It is +// meant to be given to the Destroy function to check-in and destroy +// unneeded credentials. Everything returned from the client has a +// VaultSecret and implements HasSecret for that purpose. If the +// credential is not renewable then destroying it is a no-op. +type VaultSecret struct { + s *api.Secret + n string +} + +func (s *VaultSecret) VaultSecret() *VaultSecret { + return s +} + +type VaultApiKey struct { + Key string `json:"key"` + s *VaultSecret +} + +func (k *VaultApiKey) VaultSecret() *VaultSecret { + return k.s +} + +type VaultUsernamePassword struct { + Username string `json:"username"` + Password string `json:"password"` + s *VaultSecret +} + +func (k *VaultUsernamePassword) VaultSecret() *VaultSecret { + return k.s +} + +type Renewal struct { + RenewedAt time.Time + Name string +} + +type vaultClient struct { + sync.Mutex + c *api.Client + lc *api.Logical + wg *sync.WaitGroup + watcherDone chan error + watchers map[string]*api.LifetimeWatcher + renewInfo chan *Renewal +} + +// NewApproleClientEnv is a convenience function to create a new +// VaultClient based on the environment, start it, and login using +// Approle authentication. +// +// The following environment variables are used and must be present: +// +// VAULT_ADDR - URL to Vault server (of form https://host:port/) +// VAULT_ROLE_ID - Role ID used for Approle authentication +// VAULT_SECRET_ID - Secret ID used for Approle authentication +// +func NewApproleClientEnv(ctx context.Context, wg *sync.WaitGroup, renewInfo chan *Renewal) (VaultClient, error) { + vaultHost := os.Getenv("VAULT_ADDR") + if vaultHost == "" { + return nil, fmt.Errorf("NewApproleClientEnv: VAULT_ADDR is not set in environment") + } + + roleId := os.Getenv("VAULT_ROLE_ID") + if roleId == "" { + return nil, fmt.Errorf("NewApproleClientEnv: VAULT_ROLE_ID is not set in environment") + } + + secretId := os.Getenv("VAULT_SECRET_ID") + if secretId == "" { + return nil, fmt.Errorf("NewApproleClientEnv: VAULT_SECRET_ID is not set in environment") + } + + vc, err := NewVaultClient(vaultHost, renewInfo) + if err != nil { + return nil, fmt.Errorf("NewApproleClientEnv: error creating client %w", err) + } + + go vc.Run(ctx, wg) + + if err = vc.LoginApprole(ctx, roleId, secretId); err != nil { + return nil, fmt.Errorf("NewApproleClientEnv: error logging in to vault %w", err) + } + + return vc, nil +} + +func NewVaultClient(host string, renewInfo chan *Renewal) (VaultClient, error) { + cfg := api.DefaultConfig() + cfg.Address = host + + c, err := api.NewClient(cfg) + if err != nil { + return nil, err + } + + return &vaultClient{ + c: c, + lc: c.Logical(), + renewInfo: renewInfo, + watcherDone: make(chan error, 10), + watchers: map[string]*api.LifetimeWatcher{}, + }, nil +} + +func (c *vaultClient) watchWatcher(w *api.LifetimeWatcher, name string) { + c.wg.Add(1) + defer c.wg.Done() + + for { + select { + case err := <-w.DoneCh(): + if err != nil { + c.watcherDone <- err + } + return + case r := <-w.RenewCh(): + // Report this so consumers can do their own reporting, if not + // provided we just read this to drain the chan and throw it away. + if c.renewInfo != nil { + c.renewInfo <- &Renewal{ + Name: name, + RenewedAt: r.RenewedAt, + } + } + } + } +} + +func (c *vaultClient) addWatcher(name string, s *api.Secret) error { + w, err := c.c.NewLifetimeWatcher(&api.LifetimeWatcherInput{ + Secret: s, + }) + if err != nil { + return err + } + + c.Lock() + c.watchers[name] = w + c.Unlock() + + go w.Start() + go c.watchWatcher(w, name) + + return nil +} + +func (c *vaultClient) read(ctx context.Context, prefix, suffix string) (*api.Secret, string, error) { + key := path.Join(prefix, suffix) + + s, err := c.lc.ReadWithContext(ctx, key) + if err != nil { + return nil, "", err + } + + if s.Renewable { + return s, key, c.addWatcher(key, s) + } + + return s, key, nil +} + +func (c *vaultClient) stop() { + c.Lock() + defer c.Unlock() + + for _, w := range c.watchers { + w.Stop() + } +} + +func (c *vaultClient) Run(ctx context.Context, wg *sync.WaitGroup) error { + c.Lock() + c.wg = wg + c.Unlock() + + c.wg.Add(1) + defer c.wg.Done() + + for { + select { + case <-ctx.Done(): + c.stop() + return nil + case err := <-c.watcherDone: + c.stop() + return err + } + } +} + +func (c *vaultClient) Destroy(s HasSecret) { + vs := s.VaultSecret() + if vs == nil || vs.n == "" || vs.s == nil { + return + } + + c.Lock() + defer c.Unlock() + + if w, ok := c.watchers[vs.n]; ok { + delete(c.watchers, vs.n) + w.Stop() + } + + // TODO: Delete dynamic credentials like DB sessions from Vault + + // Drop references to the secret so that even if the client holds on to + // it we free the RAM. + vs.s = nil + vs.n = "" +} + +func (c *vaultClient) LoginApprole(ctx context.Context, roleId string, secretId string) error { + a, err := approle.NewAppRoleAuth(roleId, &approle.SecretID{FromString: secretId}) + if err != nil { + return err + } + + s, err := c.c.Auth().Login(ctx, a) + if err != nil { + return err + } + + // This credential can not be destroyed like the others + return c.addWatcher("login", s) +} + +func (c *vaultClient) DbStaticCredential(ctx context.Context, suffix string) (*VaultUsernamePassword, error) { + s, k, err := c.read(ctx, "database/static-creds", suffix) + if err != nil { + return nil, err + } + + var d VaultUsernamePassword + if err = mapstructure.Decode(s.Data, &d); err != nil { + return nil, err + } + + d.s = &VaultSecret{s: s, n: k} + + return &d, nil +} + +func (c *vaultClient) DbCredential(ctx context.Context, suffix string) (*VaultUsernamePassword, error) { + s, k, err := c.read(ctx, "database/creds", suffix) + if err != nil { + return nil, err + } + + var d VaultUsernamePassword + if err = mapstructure.Decode(s.Data, &d); err != nil { + return nil, err + } + + d.s = &VaultSecret{s: s, n: k} + + return &d, nil +} + +func (c *vaultClient) KV(ctx context.Context, suffix string, out interface{}) (*VaultSecret, error) { + s, k, err := c.read(ctx, "kv/data", suffix) + if err != nil { + return nil, err + } + + if err = mapstructure.Decode(s.Data["data"], out); err != nil { + return nil, err + } + + return &VaultSecret{s: s, n: k}, nil +} + +func (c *vaultClient) KVApiKey(ctx context.Context, suffix string) (*VaultApiKey, error) { + var ak VaultApiKey + s, err := c.KV(ctx, suffix, &ak) + if err != nil { + return nil, err + } + + ak.s = s + + return &ak, nil +} + +func (c *vaultClient) KVCredential(ctx context.Context, suffix string) (*VaultUsernamePassword, error) { + var ak VaultUsernamePassword + s, err := c.KV(ctx, suffix, &ak) + if err != nil { + return nil, err + } + + ak.s = s + + return &ak, nil +} diff --git a/vault/go.mod b/vault/go.mod index 73fcdbe..05d59a0 100644 --- a/vault/go.mod +++ b/vault/go.mod @@ -3,7 +3,8 @@ module code.crute.us/mcrute/golib/vault go 1.17 require ( - github.com/hashicorp/vault/api v1.3.0 + github.com/hashicorp/vault/api v1.5.0 + github.com/hashicorp/vault/api/auth/approle v0.1.1 github.com/mitchellh/mapstructure v1.4.2 ) @@ -15,7 +16,7 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-hclog v0.16.2 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -30,7 +31,7 @@ require ( github.com/hashicorp/go-version v1.2.0 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hashicorp/vault/sdk v0.3.0 // indirect + github.com/hashicorp/vault/sdk v0.4.1 // indirect github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb // indirect github.com/mattn/go-colorable v0.1.6 // indirect github.com/mattn/go-isatty v0.0.12 // indirect diff --git a/vault/go.sum b/vault/go.sum index 7bbb973..c034144 100644 --- a/vault/go.sum +++ b/vault/go.sum @@ -89,8 +89,9 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv 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.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +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 v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v0.16.2 h1:K4ev2ib4LdQETX5cSZBG0DVLk1jwGqSPXBjdah3veNs= @@ -130,10 +131,14 @@ github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+l github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 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.3.0 h1:uDy39PLSvy6gtKyjOCRPizy2QdFiIYSWBR2pxCEzYL8= github.com/hashicorp/vault/api v1.3.0/go.mod h1:EabNQLI0VWbWoGlA+oBLC8PXmR9D60aUVgQGvangFWQ= -github.com/hashicorp/vault/sdk v0.3.0 h1:kR3dpxNkhh/wr6ycaJYqp6AFT/i2xaftbfnwZduTKEY= +github.com/hashicorp/vault/api v1.5.0 h1:Bp6yc2bn7CWkOrVIzFT/Qurzx528bdavF3nz590eu28= +github.com/hashicorp/vault/api v1.5.0/go.mod h1:LkMdrZnWNrFaQyYYazWVn7KshilfDidgVBq6YiTq/bM= +github.com/hashicorp/vault/api/auth/approle v0.1.1 h1:R5yA+xcNvw1ix6bDuWOaLOq2L4L77zDCVsethNw97xQ= +github.com/hashicorp/vault/api/auth/approle v0.1.1/go.mod h1:mHOLgh//xDx4dpqXoq6tS8Ob0FoCFWLU2ibJ26Lfmag= github.com/hashicorp/vault/sdk v0.3.0/go.mod h1:aZ3fNuL5VNydQk8GcLJ2TV8YCRVvyaakYkhZRoVuhj0= +github.com/hashicorp/vault/sdk v0.4.1 h1:3SaHOJY687jY1fnB61PtL0cOkKItphrbLmux7T92HBo= +github.com/hashicorp/vault/sdk v0.4.1/go.mod h1:aZ3fNuL5VNydQk8GcLJ2TV8YCRVvyaakYkhZRoVuhj0= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= diff --git a/vault/simple_client.go b/vault/simple_client.go index 4ceb4b5..2bef0a6 100644 --- a/vault/simple_client.go +++ b/vault/simple_client.go @@ -55,6 +55,27 @@ func GetVaultKeyStruct(path string, out interface{}) error { return nil } +// GetVaultApiKey fetches a JSON k/v value from Vault. The JSON document +// must have the format: { "key": "value" } +func GetVaultApiKey(path string) (string, error) { + s, err := loginAndRead(fmt.Sprintf("kv/data/%s", path)) + if err != nil { + return "", err + } + + ret := struct { + Key string `json:"key"` + }{} + if err = mapstructure.Decode(s.Data["data"], &ret); err != nil { + return "", err + } + + return ret.Key, nil +} + +// GetVaultKey fetches a JSON k/v value from vault. The JSON document +// must have the format: +// { "username": "username", "password": "password" } func GetVaultKey(path string) (Credential, error) { s, err := loginAndRead(fmt.Sprintf("kv/data/%s", path)) if err != nil { -- cgit v1.2.3