// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package nomad import ( "context" "errors" "fmt" "math/rand" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/hashicorp/nomad/helper" "github.com/hashicorp/nomad/helper/useragent" tomb "gopkg.in/tomb.v2" metrics "github.com/armon/go-metrics" log "github.com/hashicorp/go-hclog" multierror "github.com/hashicorp/go-multierror" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs/config" vapi "github.com/hashicorp/vault/api" "golang.org/x/sync/errgroup" "golang.org/x/time/rate" ) const ( // vaultTokenCreateTTL is the duration the wrapped token for the client is // valid for. The units are in seconds. vaultTokenCreateTTL = "60s" // minimumTokenTTL is the minimum Token TTL allowed for child tokens. minimumTokenTTL = 5 * time.Minute // defaultTokenTTL is the default Token TTL used when the passed token is a // root token such that child tokens aren't being created against a role // that has defined a TTL defaultTokenTTL = "72h" // requestRateLimit is the maximum number of requests per second Nomad will // make against Vault requestRateLimit rate.Limit = 500.0 // maxParallelRevokes is the maximum number of parallel Vault // token revocation requests maxParallelRevokes = 64 // vaultRevocationIntv is the interval at which Vault tokens that failed // initial revocation are retried vaultRevocationIntv = 5 * time.Minute // vaultCapabilitiesLookupPath is the path to lookup the capabilities of // ones token. vaultCapabilitiesLookupPath = "sys/capabilities-self" // vaultTokenRenewPath is the path used to renew our token vaultTokenRenewPath = "auth/token/renew-self" // vaultTokenLookupPath is the path used to lookup a token vaultTokenLookupPath = "auth/token/lookup" // vaultTokenRevokePath is the path used to revoke a token vaultTokenRevokePath = "auth/token/revoke-accessor" // vaultRoleLookupPath is the path to lookup a role vaultRoleLookupPath = "auth/token/roles/%s" // vaultRoleCreatePath is the path to create a token from a role vaultTokenRoleCreatePath = "auth/token/create/%s" ) var ( // vaultCapabilitiesCapability is the expected capability of Nomad's Vault // token on the the path. The token must have at least one of the // capabilities. vaultCapabilitiesCapability = []string{"update", "root"} // vaultTokenRenewCapability is the expected capability Nomad's // Vault token should have on the path. The token must have at least one of // the capabilities. vaultTokenRenewCapability = []string{"update", "root"} // vaultTokenLookupCapability is the expected capability Nomad's // Vault token should have on the path. The token must have at least one of // the capabilities. vaultTokenLookupCapability = []string{"update", "root"} // vaultTokenRevokeCapability is the expected capability Nomad's // Vault token should have on the path. The token must have at least one of // the capabilities. vaultTokenRevokeCapability = []string{"update", "root"} // vaultRoleLookupCapability is the the expected capability Nomad's Vault // token should have on the path. The token must have at least one of the // capabilities. vaultRoleLookupCapability = []string{"read", "root"} // vaultTokenRoleCreateCapability is the the expected capability Nomad's Vault // token should have on the path. The token must have at least one of the // capabilities. vaultTokenRoleCreateCapability = []string{"update", "root"} ) // VaultClient is the Servers interface for interfacing with Vault type VaultClient interface { // SetActive activates or de-activates the Vault client. When active, token // creation/lookup/revocation operation are allowed. SetActive(active bool) // SetConfig updates the config used by the Vault client SetConfig(config *config.VaultConfig) error // GetConfig returns a copy of the config used by the Vault client, for // testing GetConfig() *config.VaultConfig // CreateToken takes an allocation and task and returns an appropriate Vault // Secret CreateToken(ctx context.Context, a *structs.Allocation, task string) (*vapi.Secret, error) // LookupToken takes a token string and returns its capabilities. LookupToken(ctx context.Context, token string) (*vapi.Secret, error) // RevokeTokens takes a set of tokens accessor and revokes the tokens RevokeTokens(ctx context.Context, accessors []*structs.VaultAccessor, committed bool) error // MarkForRevocation revokes the tokens in background MarkForRevocation(accessors []*structs.VaultAccessor) error // Stop is used to stop token renewal Stop() // Running returns whether the Vault client is running Running() bool // Stats returns the Vault clients statistics Stats() map[string]string // EmitStats emits that clients statistics at the given period until stopCh // is called. EmitStats(period time.Duration, stopCh <-chan struct{}) } // VaultStats returns all the stats about Vault tokens created and managed by // Nomad. type VaultStats struct { // TrackedForRevoke is the count of tokens that are being tracked to be // revoked since they could not be immediately revoked. TrackedForRevoke int // TokenTTL is the time-to-live duration for the current token TokenTTL time.Duration // TokenExpiry is the recorded expiry time of the current token TokenExpiry time.Time // LastRenewalTime is the time since the token was last renewed LastRenewalTime time.Time TimeFromLastRenewal time.Duration // NextRenewalTime is the time the token will attempt to renew NextRenewalTime time.Time TimeToNextRenewal time.Duration } // PurgeVaultAccessorFn is called to remove VaultAccessors from the system. If // the function returns an error, the token will still be tracked and revocation // will retry till there is a success type PurgeVaultAccessorFn func(accessors []*structs.VaultAccessor) error // vaultClient is the Servers implementation of the VaultClient interface. The // client renews the PeriodicToken given in the Vault configuration and provides // the Server with the ability to create child tokens and lookup the permissions // of tokens. type vaultClient struct { // limiter is used to rate limit requests to Vault limiter *rate.Limiter // client is the Vault API client used for Namespace-relative integrations // with the Vault API (anything except `/v1/sys`). If this server is not // configured to reference a Vault namespace, this will point to the same // client as clientSys client *vapi.Client // clientSys is the Vault API client used for non-Namespace-relative integrations // with the Vault API (anything involving `/v1/sys`). This client is never configured // with a Vault namespace, because these endpoints may return errors if a namespace // header is provided clientSys *vapi.Client // auth is the Vault token auth API client auth *vapi.TokenAuth // config is the user passed Vault config config *config.VaultConfig // connEstablished marks whether we have an established connection to Vault. connEstablished bool // connEstablishedErr marks an error that can occur when establishing a // connection connEstablishedErr error // token is the raw token used by the client token string // tokenData is the data of the passed Vault token tokenData *structs.VaultTokenData // revoking tracks the VaultAccessors that must be revoked revoking map[*structs.VaultAccessor]time.Time purgeFn PurgeVaultAccessorFn revLock sync.Mutex // active indicates whether the vaultClient is active. It should be // accessed using a helper and updated atomically active int32 // running indicates whether the vault client is started. running bool // renewLoopActive indicates whether the renewal goroutine is running // It should be accessed and updated atomically // used for testing purposes only renewLoopActive int32 // childTTL is the TTL for child tokens. childTTL string // currentExpiration is the time the current token lease expires currentExpiration time.Time currentExpirationLock sync.Mutex lastRenewalTime time.Time nextRenewalTime time.Time renewalTimeLock sync.Mutex tomb *tomb.Tomb logger log.Logger // l is used to lock the configuration aspects of the client such that // multiple callers can't cause conflicting config updates l sync.Mutex // setConfigLock serializes access to the SetConfig method setConfigLock sync.Mutex // consts as struct fields for overriding in tests maxRevokeBatchSize int revocationIntv time.Duration entHandler taskClientHandler } type taskClientHandler interface { clientForTask(v *vaultClient, namespace string) (*vapi.Client, error) } // NewVaultClient returns a Vault client from the given config. If the client // couldn't be made an error is returned. func NewVaultClient(c *config.VaultConfig, logger log.Logger, purgeFn PurgeVaultAccessorFn, delegate taskClientHandler) (*vaultClient, error) { if c == nil { return nil, fmt.Errorf("must pass valid VaultConfig") } if logger == nil { return nil, fmt.Errorf("must pass valid logger") } if purgeFn == nil { purgeFn = func(accessors []*structs.VaultAccessor) error { return nil } } if delegate == nil { delegate = &VaultNoopDelegate{} } v := &vaultClient{ config: c, logger: logger.Named("vault"), limiter: rate.NewLimiter(requestRateLimit, int(requestRateLimit)), revoking: make(map[*structs.VaultAccessor]time.Time), purgeFn: purgeFn, tomb: &tomb.Tomb{}, maxRevokeBatchSize: maxVaultRevokeBatchSize, revocationIntv: vaultRevocationIntv, entHandler: delegate, } if v.config.IsEnabled() { if err := v.buildClient(); err != nil { return nil, err } // Launch the required goroutines v.tomb.Go(wrapNilError(v.establishConnection)) v.tomb.Go(wrapNilError(v.revokeDaemon)) v.running = true } return v, nil } func (v *vaultClient) Stop() { v.l.Lock() running := v.running v.running = false v.l.Unlock() if running { v.tomb.Kill(nil) v.tomb.Wait() v.flush() } } func (v *vaultClient) Running() bool { v.l.Lock() defer v.l.Unlock() return v.running } // SetActive activates or de-activates the Vault client. When active, token // creation/lookup/revocation operation are allowed. All queued revocations are // cancelled if set un-active as it is assumed another instances is taking over func (v *vaultClient) SetActive(active bool) { if active { atomic.StoreInt32(&v.active, 1) } else { atomic.StoreInt32(&v.active, 0) } // Clear out the revoking tokens v.revLock.Lock() v.revoking = make(map[*structs.VaultAccessor]time.Time) v.revLock.Unlock() } // flush is used to reset the state of the vault client func (v *vaultClient) flush() { v.l.Lock() defer v.l.Unlock() v.revLock.Lock() defer v.revLock.Unlock() v.client = nil v.clientSys = nil v.auth = nil v.connEstablished = false v.connEstablishedErr = nil v.token = "" v.tokenData = nil v.revoking = make(map[*structs.VaultAccessor]time.Time) v.childTTL = "" v.tomb = &tomb.Tomb{} } // GetConfig returns a copy of this vault client's configuration, for testing. func (v *vaultClient) GetConfig() *config.VaultConfig { v.setConfigLock.Lock() defer v.setConfigLock.Unlock() return v.config.Copy() } // SetConfig is used to update the Vault config being used. A temporary outage // may occur after calling as it re-establishes a connection to Vault func (v *vaultClient) SetConfig(config *config.VaultConfig) error { if config == nil { return fmt.Errorf("must pass valid VaultConfig") } v.setConfigLock.Lock() defer v.setConfigLock.Unlock() v.l.Lock() defer v.l.Unlock() // If reloading the same config, no-op if v.config.Equal(config) { return nil } // Kill any background routines if v.running { // Kill any background routine v.tomb.Kill(nil) // Locking around tomb.Wait can deadlock with // establishConnection exiting, so we must unlock here. v.l.Unlock() v.tomb.Wait() v.l.Lock() // Stop accepting any new requests v.connEstablished = false v.tomb = &tomb.Tomb{} v.running = false } // Store the new config v.config = config // Check if we should relaunch if v.config.IsEnabled() { // Rebuild the client if err := v.buildClient(); err != nil { return err } // Launch the required goroutines v.tomb.Go(wrapNilError(v.establishConnection)) v.tomb.Go(wrapNilError(v.revokeDaemon)) v.running = true } return nil } // buildClient is used to build a Vault client based on the stored Vault config func (v *vaultClient) buildClient() error { // Validate we have the required fields. if v.config.Token == "" { return errors.New("Vault token must be set") } else if v.config.Addr == "" { return errors.New("Vault address must be set") } // Parse the TTL if it is set if v.config.TaskTokenTTL != "" { d, err := time.ParseDuration(v.config.TaskTokenTTL) if err != nil { return fmt.Errorf("failed to parse TaskTokenTTL %q: %v", v.config.TaskTokenTTL, err) } if d.Nanoseconds() < minimumTokenTTL.Nanoseconds() { return fmt.Errorf("ChildTokenTTL is less than minimum allowed of %v", minimumTokenTTL) } v.childTTL = v.config.TaskTokenTTL } else { // Default the TaskTokenTTL v.childTTL = defaultTokenTTL } // Get the Vault API configuration apiConf, err := v.config.ApiConfig() if err != nil { return fmt.Errorf("Failed to create Vault API config: %v", err) } // Create the Vault API client client, err := vapi.NewClient(apiConf) if err != nil { v.logger.Error("failed to create Vault client and not retrying", "error", err) return err } useragent.SetHeaders(client) // Store the client, create/assign the /sys client v.client = client if v.config.Namespace != "" { v.logger.Debug("configuring Vault namespace", "namespace", v.config.Namespace) v.clientSys, err = vapi.NewClient(apiConf) if err != nil { v.logger.Error("failed to create Vault sys client and not retrying", "error", err) return err } useragent.SetHeaders(v.clientSys) client.SetNamespace(v.config.Namespace) } else { v.clientSys = client } // Set the token v.token = v.config.Token client.SetToken(v.token) v.auth = client.Auth().Token() return nil } // establishConnection is used to make first contact with Vault. This should be // called in a go-routine since the connection is retried until the Vault Client // is stopped or the connection is successfully made at which point the renew // loop is started. func (v *vaultClient) establishConnection() { // Create the retry timer and set initial duration to zero so it fires // immediately retryTimer := time.NewTimer(0) initStatus := false OUTER: for { select { case <-v.tomb.Dying(): return case <-retryTimer.C: // Retry validating the token till success if err := v.parseSelfToken(); err != nil { // if parsing token fails, try to distinguish legitimate token error from transient Vault initialization/connection issue if !initStatus { if _, err := v.clientSys.Sys().Health(); err != nil { v.logger.Warn("failed to contact Vault API", "retry", v.config.ConnectionRetryIntv, "error", err) retryTimer.Reset(v.config.ConnectionRetryIntv) continue OUTER } initStatus = true } v.logger.Error("failed to validate self token/role", "retry", v.config.ConnectionRetryIntv, "error", err) retryTimer.Reset(v.config.ConnectionRetryIntv) v.l.Lock() v.connEstablished = true v.connEstablishedErr = fmt.Errorf("failed to establish connection to Vault: %v", err) v.l.Unlock() continue OUTER } break OUTER } } // Set the wrapping function such that token creation is wrapped now // that we know our role v.client.SetWrappingLookupFunc(v.getWrappingFn()) // If we are given a non-root token, start renewing it if v.tokenData.Root() && v.tokenData.CreationTTL == 0 { v.logger.Debug("not renewing token as it is root") } else { v.logger.Debug("starting renewal loop", "creation_ttl", time.Duration(v.tokenData.CreationTTL)*time.Second) v.tomb.Go(wrapNilError(v.renewalLoop)) } v.l.Lock() v.connEstablished = true v.connEstablishedErr = nil v.l.Unlock() } func (v *vaultClient) isRenewLoopActive() bool { return atomic.LoadInt32(&v.renewLoopActive) == 1 } // renewalLoop runs the renew loop. This should only be called if we are given a // non-root token. func (v *vaultClient) renewalLoop() { atomic.StoreInt32(&v.renewLoopActive, 1) defer atomic.StoreInt32(&v.renewLoopActive, 0) // Create the renewal timer and set initial duration to zero so it fires // immediately authRenewTimer := time.NewTimer(0) // Backoff is to reduce the rate we try to renew with Vault under error // situations backoff := 0.0 for { select { case <-v.tomb.Dying(): return case <-authRenewTimer.C: // Renew the token and determine the new expiration recoverable, err := v.renew() v.currentExpirationLock.Lock() currentExpiration := v.currentExpiration v.currentExpirationLock.Unlock() // Successfully renewed if err == nil { // Attempt to renew the token at half the expiration time durationUntilRenew := time.Until(currentExpiration) / 2 v.renewalTimeLock.Lock() now := time.Now() v.lastRenewalTime = now v.nextRenewalTime = now.Add(durationUntilRenew) v.renewalTimeLock.Unlock() v.logger.Info("successfully renewed token", "next_renewal", durationUntilRenew) authRenewTimer.Reset(durationUntilRenew) // Reset any backoff backoff = 0 break } metrics.IncrCounter([]string{"nomad", "vault", "renew_failed"}, 1) v.logger.Warn("got error or bad auth, so backing off", "error", err, "recoverable", recoverable) if !recoverable { return } backoff = nextBackoff(backoff, currentExpiration) if backoff < 0 { // We have failed to renew the token past its expiration. Stop // renewing with Vault. v.logger.Error("failed to renew Vault token before lease expiration. Shutting down Vault client", "error", err) v.l.Lock() v.connEstablished = false v.connEstablishedErr = err v.l.Unlock() return } durationUntilRetry := time.Duration(backoff) * time.Second v.renewalTimeLock.Lock() v.nextRenewalTime = time.Now().Add(durationUntilRetry) v.renewalTimeLock.Unlock() v.logger.Info("backing off renewal", "retry", durationUntilRetry) authRenewTimer.Reset(durationUntilRetry) } } } // nextBackoff returns the delay for the next auto renew interval, in seconds. // Returns negative value if past expiration // // It should increase the amount of backoff each time, with the following rules: // // - If token expired already despite earlier renewal attempts, // back off for 1 minute + jitter // - If we have an existing authentication that is going to expire, // // never back off more than half of the amount of time remaining // until expiration (with 5s floor) // * Never back off more than 30 seconds multiplied by a random // value between 1 and 2 // * Use randomness so that many clients won't keep hitting Vault // at the same time func nextBackoff(backoff float64, expiry time.Time) float64 { maxBackoff := time.Until(expiry) / 2 if maxBackoff < 0 { // expiry passed return 60 * (1.0 + rand.Float64()) } switch { case backoff >= 24: backoff = 30 default: backoff = backoff * 1.25 } // Add randomness backoff = backoff * (1.0 + rand.Float64()) if backoff > maxBackoff.Seconds() { backoff = maxBackoff.Seconds() } if backoff < 5 { backoff = 5 } return backoff } // renew attempts to renew our Vault token. If the renewal fails, an error is // returned. The boolean indicates whether it's safe to attempt to renew again. // This method updates the currentExpiration time func (v *vaultClient) renew() (bool, error) { // Track how long the request takes defer metrics.MeasureSince([]string{"nomad", "vault", "renew"}, time.Now()) // Attempt to renew the token secret, err := v.auth.RenewSelf(v.tokenData.CreationTTL) if err != nil { // Check if there is a permission denied recoverable := !structs.VaultUnrecoverableError.MatchString(err.Error()) return recoverable, fmt.Errorf("failed to renew the vault token: %v", err) } if secret == nil { // It's possible for RenewSelf to return (nil, nil) if the // response body from Vault is empty. return true, fmt.Errorf("renewal failed: empty response from vault") } // these treated as transient errors, where can keep renewing auth := secret.Auth if auth == nil { return true, fmt.Errorf("renewal successful but not auth information returned") } else if auth.LeaseDuration == 0 { return true, fmt.Errorf("renewal successful but no lease duration returned") } v.extendExpiration(auth.LeaseDuration) v.logger.Debug("successfully renewed server token") return true, nil } // getWrappingFn returns an appropriate wrapping function for Nomad Servers func (v *vaultClient) getWrappingFn() func(operation, path string) string { createPath := "auth/token/create" role := v.getRole() if role != "" { createPath = fmt.Sprintf("auth/token/create/%s", role) } return func(operation, path string) string { // Only wrap the token create operation if operation != "POST" || path != createPath { return "" } return vaultTokenCreateTTL } } // parseSelfToken looks up the Vault token in Vault and parses its data storing // it in the client. If the token is not valid for Nomads purposes an error is // returned. func (v *vaultClient) parseSelfToken() error { // Try looking up the token using the self endpoint secret, err := v.lookupSelf() if err != nil { return err } // Read and parse the fields var data structs.VaultTokenData if err := structs.DecodeVaultSecretData(secret, &data); err != nil { return fmt.Errorf("failed to parse Vault token's data block: %v", err) } v.tokenData = &data v.extendExpiration(data.TTL) // The criteria that must be met for the token to be valid are as follows: // 1) If token is non-root or is but has a creation ttl // a) The token must be renewable // b) Token must have a non-zero TTL // 2) Must have update capability for "auth/token/lookup/" (used to verify incoming tokens) // 3) Must have update capability for "/auth/token/revoke-accessor/" (used to revoke unneeded tokens) // 4) If configured to create tokens against a role: // a) Must have read capability for "auth/token/roles/" // c) Role must: // 1) Must allow tokens to be renewed // 2) Must not have an explicit max TTL // 3) Must have non-zero period // 5) If not configured against a role, the token must be root var mErr multierror.Error role := v.getRole() if !data.Root() { // All non-root tokens must be renewable if !data.Renewable { _ = multierror.Append(&mErr, fmt.Errorf("Vault token is not renewable or root")) } // All non-root tokens must have a lease duration if data.CreationTTL == 0 { _ = multierror.Append(&mErr, fmt.Errorf("invalid lease duration of zero")) } // The lease duration can not be expired if data.TTL == 0 { _ = multierror.Append(&mErr, fmt.Errorf("token TTL is zero")) } // There must be a valid role since we aren't root if role == "" { _ = multierror.Append(&mErr, fmt.Errorf("token role name must be set when not using a root token")) } } else if data.CreationTTL != 0 { // If the root token has a TTL it must be renewable if !data.Renewable { _ = multierror.Append(&mErr, fmt.Errorf("Vault token has a TTL but is not renewable")) } else if data.TTL == 0 { // If the token has a TTL make sure it has not expired _ = multierror.Append(&mErr, fmt.Errorf("token TTL is zero")) } } // Check we have the correct capabilities if err := v.validateCapabilities(role, data.Root()); err != nil { _ = multierror.Append(&mErr, err) } // If given a role validate it if role != "" { if err := v.validateRole(role); err != nil { _ = multierror.Append(&mErr, err) } } return mErr.ErrorOrNil() } // lookupSelf is a helper function that looks up latest self lease info. func (v *vaultClient) lookupSelf() (*vapi.Secret, error) { // Get the initial lease duration auth := v.client.Auth().Token() secret, err := auth.LookupSelf() if err == nil && secret != nil && secret.Data != nil { return secret, nil } // Try looking up our token directly, even when we get an empty response, // in case of an unexpected event - a true failure would occur in this lookup again secret, err = auth.Lookup(v.client.Token()) switch { case err != nil: return nil, fmt.Errorf("failed to lookup Vault periodic token: %v", err) case secret == nil || secret.Data == nil: return nil, fmt.Errorf("failed to lookup Vault periodic token: got empty response") default: return secret, nil } } // getRole returns the role name to be used when creating tokens func (v *vaultClient) getRole() string { if v.config.Role != "" { return v.config.Role } return v.tokenData.Role } // validateCapabilities checks that Nomad's Vault token has the correct // capabilities. func (v *vaultClient) validateCapabilities(role string, root bool) error { // Check if the token can lookup capabilities. var mErr multierror.Error _, _, err := v.hasCapability(vaultCapabilitiesLookupPath, vaultCapabilitiesCapability) if err != nil { // Check if there is a permission denied if structs.VaultUnrecoverableError.MatchString(err.Error()) { // Since we can't read permissions, we just log a warning that we // can't tell if the Vault token will work msg := fmt.Sprintf("can not lookup token capabilities. "+ "As such certain operations may fail in the future. "+ "Please give Nomad a Vault token with one of the following "+ "capabilities %q on %q so that the required capabilities can be verified", vaultCapabilitiesCapability, vaultCapabilitiesLookupPath) v.logger.Warn(msg) return nil } else { _ = multierror.Append(&mErr, err) } } // verify is a helper function that verifies the token has one of the // capabilities on the given path and adds an issue to the error verify := func(path string, requiredCaps []string) { ok, caps, err := v.hasCapability(path, requiredCaps) if err != nil { _ = multierror.Append(&mErr, err) } else if !ok { _ = multierror.Append(&mErr, fmt.Errorf("token must have one of the following capabilities %q on %q; has %v", requiredCaps, path, caps)) } } // Check if we are verifying incoming tokens if !v.config.AllowsUnauthenticated() { verify(vaultTokenLookupPath, vaultTokenLookupCapability) } // Verify we can renew our selves tokens verify(vaultTokenRenewPath, vaultTokenRenewCapability) // Verify we can revoke tokens verify(vaultTokenRevokePath, vaultTokenRevokeCapability) // If we are using a role verify the capability if role != "" { // Verify we can read the role verify(fmt.Sprintf(vaultRoleLookupPath, role), vaultRoleLookupCapability) // Verify we can create from the role verify(fmt.Sprintf(vaultTokenRoleCreatePath, role), vaultTokenRoleCreateCapability) } return mErr.ErrorOrNil() } // hasCapability takes a path and returns whether the token has at least one of // the required capabilities on the given path. It also returns the set of // capabilities the token does have as well as any error that occurred. func (v *vaultClient) hasCapability(path string, required []string) (bool, []string, error) { caps, err := v.client.Sys().CapabilitiesSelf(path) if err != nil { return false, nil, err } for _, c := range caps { for _, r := range required { if c == r { return true, caps, nil } } } return false, caps, nil } // validateRole contacts Vault and checks that the given Vault role is valid for // the purposes of being used by Nomad func (v *vaultClient) validateRole(role string) error { if role == "" { return fmt.Errorf("Invalid empty role name") } // Validate the role rsecret, err := v.client.Logical().Read(fmt.Sprintf("auth/token/roles/%s", role)) if err != nil { return fmt.Errorf("failed to lookup role %q: %v", role, err) } if rsecret == nil { return fmt.Errorf("Role %q does not exist", role) } // Read and parse the fields var data structs.VaultTokenRoleData if err := structs.DecodeVaultSecretData(rsecret, &data); err != nil { return fmt.Errorf("failed to parse Vault role's data block: %v", err) } // Validate the role is acceptable var mErr multierror.Error if !data.Renewable { _ = multierror.Append(&mErr, fmt.Errorf("Role must allow tokens to be renewed")) } if data.ExplicitMaxTtl != 0 || data.TokenExplicitMaxTtl != 0 { _ = multierror.Append(&mErr, fmt.Errorf("Role can not use an explicit max ttl. Token must be periodic.")) } if data.Period == 0 && data.TokenPeriod == 0 { _ = multierror.Append(&mErr, fmt.Errorf("Role must have a non-zero period to make tokens periodic.")) } return mErr.ErrorOrNil() } // ConnectionEstablished returns whether a connection to Vault has been // established and any error that potentially caused it to be false func (v *vaultClient) ConnectionEstablished() (bool, error) { v.l.Lock() defer v.l.Unlock() return v.connEstablished, v.connEstablishedErr } // Enabled returns whether the client is active func (v *vaultClient) Enabled() bool { v.l.Lock() defer v.l.Unlock() return v.config.IsEnabled() } // Active returns whether the client is active func (v *vaultClient) Active() bool { return atomic.LoadInt32(&v.active) == 1 } // CreateToken takes the allocation and task and returns an appropriate Vault // token. The call is rate limited and may be canceled with the passed policy. // When the error is recoverable, it will be of type RecoverableError func (v *vaultClient) CreateToken(ctx context.Context, a *structs.Allocation, task string) (*vapi.Secret, error) { if !v.Enabled() { return nil, fmt.Errorf("Vault integration disabled") } if !v.Active() { return nil, structs.NewRecoverableError(fmt.Errorf("Vault client not active"), true) } // Check if we have established a connection with Vault if established, err := v.ConnectionEstablished(); !established && err == nil { return nil, structs.NewRecoverableError(fmt.Errorf("Connection to Vault has not been established"), true) } else if err != nil { return nil, err } // Track how long the request takes defer metrics.MeasureSince([]string{"nomad", "vault", "create_token"}, time.Now()) // Retrieve the Vault block for the task vaultBlocks := a.Job.Vault() if vaultBlocks == nil { return nil, fmt.Errorf("Job does not require Vault token") } tg, ok := vaultBlocks[a.TaskGroup] if !ok { return nil, fmt.Errorf("Task group does not require Vault token") } taskVault, ok := tg[task] if !ok { return nil, fmt.Errorf("Task does not require Vault token") } // Set namespace for task namespaceForTask := v.config.Namespace if taskVault.Namespace != "" { namespaceForTask = taskVault.Namespace } // Build the creation request req := &vapi.TokenCreateRequest{ Policies: taskVault.Policies, Metadata: map[string]string{ "AllocationID": a.ID, "JobID": a.JobID, "TaskGroup": a.TaskGroup, "Task": task, "NodeID": a.NodeID, "Namespace": namespaceForTask, }, TTL: v.childTTL, DisplayName: fmt.Sprintf("%s-%s", a.ID, task), } // Ensure we are under our rate limit if err := v.limiter.Wait(ctx); err != nil { return nil, err } // Make the request and switch depending on whether we are using a root // token or a role based token var secret *vapi.Secret var err error role := v.getRole() // Fetch client for task taskClient, err := v.entHandler.clientForTask(v, namespaceForTask) if err != nil { return nil, err } if v.tokenData.Root() && role == "" { req.Period = v.childTTL secret, err = taskClient.Auth().Token().Create(req) } else { // Make the token using the role secret, err = taskClient.Auth().Token().CreateWithRole(req, role) } // Determine whether it is unrecoverable if err != nil { err = fmt.Errorf("failed to create an alloc vault token: %v", err) if structs.VaultUnrecoverableError.MatchString(err.Error()) { return secret, err } // The error is recoverable return nil, structs.NewRecoverableError(err, true) } // Validate the response var validationErr error if secret == nil { validationErr = fmt.Errorf("Vault returned nil Secret") } else if secret.WrapInfo == nil { validationErr = fmt.Errorf("Vault returned Secret with nil WrapInfo. Secret warnings: %v", secret.Warnings) } else if secret.WrapInfo.WrappedAccessor == "" { validationErr = fmt.Errorf("Vault returned WrapInfo without WrappedAccessor. Secret warnings: %v", secret.Warnings) } if validationErr != nil { v.logger.Warn("failed to CreateToken", "error", validationErr) return nil, structs.NewRecoverableError(validationErr, true) } // Got a valid response return secret, nil } // LookupToken takes a Vault token and does a lookup against Vault. The call is // rate limited and may be canceled with passed context. func (v *vaultClient) LookupToken(ctx context.Context, token string) (*vapi.Secret, error) { if !v.Enabled() { return nil, fmt.Errorf("Vault integration disabled") } if !v.Active() { return nil, fmt.Errorf("Vault client not active") } // Check if we have established a connection with Vault if established, err := v.ConnectionEstablished(); !established && err == nil { return nil, structs.NewRecoverableError(fmt.Errorf("Connection to Vault has not been established"), true) } else if err != nil { return nil, err } // Track how long the request takes defer metrics.MeasureSince([]string{"nomad", "vault", "lookup_token"}, time.Now()) // Ensure we are under our rate limit if err := v.limiter.Wait(ctx); err != nil { return nil, err } // Lookup the token return v.auth.Lookup(token) } // RevokeTokens revokes the passed set of accessors. If committed is set, the // purge function passed to the client is called. If there is an error purging // either because of Vault failures or because of the purge function, the // revocation is retried until the tokens TTL. func (v *vaultClient) RevokeTokens(ctx context.Context, accessors []*structs.VaultAccessor, committed bool) error { if !v.Enabled() { return nil } if !v.Active() { return fmt.Errorf("Vault client not active") } // Track how long the request takes defer metrics.MeasureSince([]string{"nomad", "vault", "revoke_tokens"}, time.Now()) // Check if we have established a connection with Vault. If not just add it // to the queue if established, err := v.ConnectionEstablished(); !established && err == nil { // Only bother tracking it for later revocation if the accessor was // committed if committed { v.storeForRevocation(accessors) } // Track that we are abandoning these accessors. metrics.IncrCounter([]string{"nomad", "vault", "undistributed_tokens_abandoned"}, float32(len(accessors))) return nil } // Attempt to revoke immediately and if it fails, add it to the revoke queue err := v.parallelRevoke(ctx, accessors) if err != nil { // If it is uncommitted, it is a best effort revoke as it will shortly // TTL within the cubbyhole and has not been leaked to any outside // system if !committed { metrics.IncrCounter([]string{"nomad", "vault", "undistributed_tokens_abandoned"}, float32(len(accessors))) return nil } v.logger.Warn("failed to revoke tokens. Will reattempt until TTL", "error", err) v.storeForRevocation(accessors) return nil } else if !committed { // Mark that it was revoked but there is nothing to purge so exit metrics.IncrCounter([]string{"nomad", "vault", "undistributed_tokens_revoked"}, float32(len(accessors))) return nil } if err := v.purgeFn(accessors); err != nil { v.logger.Error("failed to purge Vault accessors", "error", err) v.storeForRevocation(accessors) return nil } // Track that it was revoked successfully metrics.IncrCounter([]string{"nomad", "vault", "distributed_tokens_revoked"}, float32(len(accessors))) return nil } func (v *vaultClient) MarkForRevocation(accessors []*structs.VaultAccessor) error { if !v.Enabled() { return nil } if !v.Active() { return fmt.Errorf("Vault client not active") } v.storeForRevocation(accessors) return nil } // storeForRevocation stores the passed set of accessors for revocation. It // captures their effective TTL by storing their create TTL plus the current // time. func (v *vaultClient) storeForRevocation(accessors []*structs.VaultAccessor) { v.revLock.Lock() now := time.Now() for _, a := range accessors { if _, ok := v.revoking[a]; !ok { v.revoking[a] = now.Add(time.Duration(a.CreationTTL) * time.Second) } } v.revLock.Unlock() } // parallelRevoke revokes the passed VaultAccessors in parallel. func (v *vaultClient) parallelRevoke(ctx context.Context, accessors []*structs.VaultAccessor) error { if !v.Enabled() { return fmt.Errorf("Vault integration disabled") } if !v.Active() { return fmt.Errorf("Vault client not active") } // Check if we have established a connection with Vault if established, err := v.ConnectionEstablished(); !established && err == nil { return structs.NewRecoverableError(fmt.Errorf("Connection to Vault has not been established"), true) } else if err != nil { return err } g, pCtx := errgroup.WithContext(ctx) // Cap the handlers handlers := len(accessors) if handlers > maxParallelRevokes { handlers = maxParallelRevokes } // Revoke the Vault Token Accessors input := make(chan *structs.VaultAccessor, handlers) for i := 0; i < handlers; i++ { g.Go(func() error { for { select { case va, ok := <-input: if !ok { return nil } err := v.auth.RevokeAccessor(va.Accessor) if err != nil && !strings.Contains(err.Error(), "invalid accessor") { return fmt.Errorf("failed to revoke token (alloc: %q, node: %q, task: %q): %v", va.AllocID, va.NodeID, va.Task, err) } case <-pCtx.Done(): return nil } } }) } // Send the input go func() { defer close(input) for _, va := range accessors { select { case <-pCtx.Done(): return case input <- va: } } }() // Wait for everything to complete return g.Wait() } // maxVaultRevokeBatchSize is the maximum tokens a revokeDaemon should revoke // and purge at any given time. // // Limiting the revocation batch size is beneficial for few reasons: // - A single revocation failure of any entry in batch result into retrying the whole batch; // the larger the batch is the higher likelihood of such failure // - Smaller batch sizes result into more co-operativeness: provides hooks for // reconsidering token TTL and leadership steps down. // - Batches limit the size of the Raft message purging tokens. Due to bugs // pre-0.11.3, expired tokens were not properly purged, so users upgrading from // older versions may have huge numbers (millions) of expired tokens to purge. const maxVaultRevokeBatchSize = 1000 // revokeDaemon should be called in a goroutine and is used to periodically // revoke Vault accessors that failed the original revocation func (v *vaultClient) revokeDaemon() { ticker := time.NewTicker(v.revocationIntv) defer ticker.Stop() for { select { case <-v.tomb.Dying(): return case now := <-ticker.C: if established, err := v.ConnectionEstablished(); !established || err != nil { continue } v.revLock.Lock() // Fast path if len(v.revoking) == 0 { v.revLock.Unlock() continue } // Build the list of accessors that need to be revoked while pruning any TTL'd checks toRevoke := len(v.revoking) if toRevoke > v.maxRevokeBatchSize { v.logger.Info("batching tokens to be revoked", "to_revoke", toRevoke, "batch_size", v.maxRevokeBatchSize, "batch_interval", v.revocationIntv) toRevoke = v.maxRevokeBatchSize } revoking := make([]*structs.VaultAccessor, 0, toRevoke) ttlExpired := []*structs.VaultAccessor{} for va, ttl := range v.revoking { if now.After(ttl) { ttlExpired = append(ttlExpired, va) } else { revoking = append(revoking, va) } // Batches should consider tokens to be revoked // as well as expired tokens to ensure the Raft // message is reasonably sized. if len(revoking)+len(ttlExpired) >= toRevoke { break } } if err := v.parallelRevoke(context.Background(), revoking); err != nil { v.logger.Warn("background token revocation errored", "error", err) v.revLock.Unlock() continue } // Unlock before a potentially expensive operation v.revLock.Unlock() // purge all explicitly revoked as well as ttl expired tokens // and only remove them locally on purge success revoking = append(revoking, ttlExpired...) // Call the passed in token revocation function if err := v.purgeFn(revoking); err != nil { // Can continue since revocation is idempotent v.logger.Error("token revocation errored", "error", err) continue } // Track that tokens were revoked successfully metrics.IncrCounter([]string{"nomad", "vault", "distributed_tokens_revoked"}, float32(len(revoking))) // Can delete from the tracked list now that we have purged v.revLock.Lock() for _, va := range revoking { delete(v.revoking, va) } v.revLock.Unlock() } } } // purgeVaultAccessors creates a Raft transaction to remove the passed Vault // Accessors func (s *Server) purgeVaultAccessors(accessors []*structs.VaultAccessor) error { // Commit this update via Raft req := structs.VaultAccessorsRequest{Accessors: accessors} _, _, err := s.raftApply(structs.VaultAccessorDeregisterRequestType, req) return err } // wrapNilError is a helper that returns a wrapped function that returns a nil // error func wrapNilError(f func()) func() error { return func() error { f() return nil } } // setLimit is used to update the rate limit func (v *vaultClient) setLimit(l rate.Limit) { v.l.Lock() defer v.l.Unlock() v.limiter = rate.NewLimiter(l, int(l)) } func (v *vaultClient) Stats() map[string]string { stat := v.stats() expireTimeStr := "" if !stat.TokenExpiry.IsZero() { expireTimeStr = stat.TokenExpiry.Format(time.RFC3339) } lastRenewTimeStr := "" if !stat.LastRenewalTime.IsZero() { lastRenewTimeStr = stat.LastRenewalTime.Format(time.RFC3339) } nextRenewTimeStr := "" if !stat.NextRenewalTime.IsZero() { nextRenewTimeStr = stat.NextRenewalTime.Format(time.RFC3339) } return map[string]string{ "tracked_for_revoked": strconv.Itoa(stat.TrackedForRevoke), "token_ttl": stat.TokenTTL.Round(time.Second).String(), "token_expire_time": expireTimeStr, "token_last_renewal_time": lastRenewTimeStr, "token_next_renewal_time": nextRenewTimeStr, } } func (v *vaultClient) stats() *VaultStats { // Allocate a new stats struct stats := new(VaultStats) v.revLock.Lock() stats.TrackedForRevoke = len(v.revoking) v.revLock.Unlock() v.currentExpirationLock.Lock() stats.TokenExpiry = v.currentExpiration v.currentExpirationLock.Unlock() v.renewalTimeLock.Lock() stats.NextRenewalTime = v.nextRenewalTime stats.LastRenewalTime = v.lastRenewalTime v.renewalTimeLock.Unlock() if !stats.TokenExpiry.IsZero() { stats.TokenTTL = time.Until(stats.TokenExpiry) } if !stats.LastRenewalTime.IsZero() { stats.TimeFromLastRenewal = time.Since(stats.LastRenewalTime) } if !stats.NextRenewalTime.IsZero() { stats.TimeToNextRenewal = time.Until(stats.NextRenewalTime) } return stats } // EmitStats is used to export metrics about the blocked eval tracker while enabled func (v *vaultClient) EmitStats(period time.Duration, stopCh <-chan struct{}) { timer, stop := helper.NewSafeTimer(period) defer stop() for { timer.Reset(period) select { case <-timer.C: stats := v.stats() metrics.SetGauge([]string{"nomad", "vault", "distributed_tokens_revoking"}, float32(stats.TrackedForRevoke)) metrics.SetGauge([]string{"nomad", "vault", "token_ttl"}, float32(stats.TokenTTL/time.Millisecond)) metrics.SetGauge([]string{"nomad", "vault", "token_last_renewal"}, float32(stats.TimeFromLastRenewal/time.Millisecond)) metrics.SetGauge([]string{"nomad", "vault", "token_next_renewal"}, float32(stats.TimeToNextRenewal/time.Millisecond)) case <-stopCh: return } } } // extendExpiration sets the current auth token expiration record to ttLSeconds seconds from now func (v *vaultClient) extendExpiration(ttlSeconds int) { v.currentExpirationLock.Lock() v.currentExpiration = time.Now().Add(time.Duration(ttlSeconds) * time.Second) v.currentExpirationLock.Unlock() } // VaultVaultNoopDelegate returns the default vault api auth token handler type VaultNoopDelegate struct{} func (e *VaultNoopDelegate) clientForTask(v *vaultClient, namespace string) (*vapi.Client, error) { return v.client, nil }