spire

package
v0.0.0-...-7c0b2e9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 17, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package spire provides utilities for building mutual-TLS configurations backed by the SPIRE Workload API. The X.509 SVID and trust bundle are fetched in memory over the Workload API socket (mounted by the csi.spiffe.io driver) and kept up to date automatically — no certificate files are read from disk.

Index

Constants

View Source
const DefaultComponent = "agent"

DefaultComponent is the component name a WaitingSource is namespaced under when the caller passes no WithComponent: the node agent, which is where the wait shipped first (#741) and whose metric names are already on dashboards.

View Source
const (
	// DefaultWaitWarnAfter is how long the wait for the first SVID stays at INFO
	// before escalating to WARN. Two minutes comfortably covers a cold boot
	// (SPIRE server + agent attesting a whole fleet at once) while still making a
	// real outage loud. Also the readiness dwell — see NotReadyDwell.
	DefaultWaitWarnAfter = 2 * time.Minute
)
View Source
const NotReadyDwell = DefaultWaitWarnAfter

NotReadyDwell is how long a workload may be waiting for its first SVID before the spire-svid readiness check starts failing.

It is deliberately EQUAL to the warn threshold, and the value is the non-obvious decision in issue #740. The controller's node-taint guard (proposal 033) re-arms aether.io/agent-not-ready:NoSchedule on any agent that has been NotReady for longer than its 30s grace (controller/internal/nodetaint/ guard.go), and spire-server does NOT tolerate that taint. A gate that failed from t=0 would therefore turn a 40s SPIRE hiccup into a fleet-wide taint that prevents spire-server itself from being rescheduled — the outage would fence out its own cure. The dwell keeps a routine cold boot invisible (every agent on the 2026-09-07 boot had its SVID inside 2m15s of SPIRE recovering) while a genuine outage still escalates to NotReady, which is what an operator wants: no new pods scheduled onto a node that cannot give them an identity.

View Source
const ReadyCheckName = "spire-svid"

ReadyCheckName is the /readyz check name every binary registers its identity gate under, so `readyz?verbose` reads the same on an agent, the controller, the registrar and the edge — and one runbook line covers all four.

View Source
const ReconnectWindow = 5 * time.Second

ReconnectWindow is how long after this workload's own identity arrives a failed mTLS attempt is still attributed to the connection catching up rather than to either party's identity.

It exists because the error text cannot answer the question. Every attempt made before the SVID landed failed the handshake, so the gRPC ClientConn has a cached transport failure and a backoff of its own; the next RPC is answered from that cache — with the PRE-identity error string — for as long as it takes the connection to redial. A short capped redial backoff on the ClientConn (the registrar client's registrarConnectParams, #1137; it replaced #740's ResetConnectBackoff call, which raced grpc-go's subchannel creation) makes that a short wait, but not an instant one: on the rev211 deploy roll (2026-09-07 20:47Z) the reset landed at 20:47:27.911 and the watch stream connected at 20:47:29.017, and every failure in between carried an error written before identity existed.

A few seconds, therefore: long enough to cover a redial plus a handshake, short enough that a registrar which is genuinely down is misreported for one log line and then escalates normally.

View Source
const RootCATrustDomain = "ROOTCA"

RootCATrustDomain is the sentinel trust-domain value meaning "authorize any peer presenting an SVID that chains to the SPIRE root CA bundle", without restricting the SPIFFE ID's trust domain. An empty trust domain is treated the same way.

View Source
const ServiceNotReadyDwell time.Duration = 0

ServiceNotReadyDwell is the dwell for a workload that sits behind a Service: the registrar, the controller and the edge. It is ZERO — NotReady from t=0 until BOTH halves of the identity exist — and that is the opposite of the agent's NotReadyDwell for a reason that is specific to the DaemonSet.

The agent's dwell is not about the agent. It exists solely because of the node-taint guard interaction described above: NotReady on a DaemonSet pod is read by the controller as "taint this node", and a taint is a fleet-level action that a 40s SPIRE hiccup must not trigger. A Deployment behind a Service has no such coupling. There, NotReady means exactly one thing — take this replica out of the endpoint set — and that is precisely the correct handling of a pod that cannot complete an mTLS handshake yet.

Observed on the rev210 upgrade roll (2026-09-07 20:03:45Z), which is what this constant fixes: a registrar pod carried the agent's 2m dwell, so it was Ready and IN the registrar Service's endpoints while it still had no SVID. An agent on node A dialled it and got

transport: authentication handshake failed: x509svid: could not get X509 bundle

A replica that cannot handshake should never have been in the endpoint set to be dialled; with dwell 0 it is not, and (with more than one replica) the caller lands on one that can serve. A single-replica Deployment is no worse off: the dial fails either way, and now it fails against an endpoint list that is honest about it.

Variables

View Source
var ErrNoSVIDYet = errors.New("no SPIRE SVID yet: still waiting for the Workload API to issue this workload's first SVID")

ErrNoSVIDYet is what every SVIDSource method of a WaitingSource returns until the Workload API has issued the first SVID. It is a WAITING state, not a failure: callers that can degrade (the registrar watch stream, Envoy's SDS, the readiness gate inside its dwell) treat it as "not yet" rather than as an error to report or to die on.

Functions

func ClientTLSConfig

func ClientTLSConfig(src SVIDSource, trustDomain string) (*tls.Config, error)

ClientTLSConfig returns a mutual-TLS client config that presents the workload SVID from src and authorizes peers belonging to trustDomain (or any valid peer when trustDomain is empty / RootCATrustDomain).

func IsPeerIdentityUnavailable

func IsPeerIdentityUnavailable(err error) bool

IsPeerIdentityUnavailable reports whether err is a TLS handshake that failed because the PEER has no mesh identity yet.

It is deliberately about the peer, not about us: a caller that has not got its own SVID yet knows that locally (it holds the source) and should answer that question first — see the registrar client's identityPending. What is left for this to classify is the other side's startup, which is a transient of exactly the same kind: not a fault of either party, self-healing within seconds, and wrong to report as an error or to count against an error budget.

func Jitter

func Jitter(d time.Duration) time.Duration

Jitter returns d plus up to waitJitterFraction (20%) of random jitter, so a fleet restarting together does not retry in lockstep. Shared with the node agent's SPIRE bridge, which de-synchronizes stream re-subscribes the same way.

func NextBackoff

func NextBackoff(d, maxBackoff time.Duration) time.Duration

NextBackoff doubles d, capped at maxBackoff. Shared with the node agent's SPIRE bridge (stream re-subscribe), which follows the same policy.

func ReadyChecker

func ReadyChecker(w *WaitingSource, dwell time.Duration) func(*http.Request) error

ReadyChecker returns a readiness check (assignable to controller-runtime's healthz.Checker) that fails once this workload has been waiting for its first SVID for longer than dwell.

dwell is a PARAMETER, not the wait's warn threshold, because the two questions are different and only happen to share a value on the agent. warnAfter asks "how long before this wait is worth waking someone for" and is the same everywhere; dwell asks "how long before NotReady is the right answer", and that depends on what NotReady does to the workload's consumers. Pass NotReadyDwell on the node agent (a DaemonSet whose NotReady arms a node taint) and ServiceNotReadyDwell on anything behind a Service. A non-positive dwell fails from the first poll.

A nil source (SPIRE disabled) always passes: with --spire-enabled=false there is no identity to wait for and the check must disappear entirely, exactly as the CNI chaining check disappears with its kill switch.

func ServerTLSConfigForOwnTrustDomain

func ServerTLSConfigForOwnTrustDomain(src SVIDSource) *tls.Config

ServerTLSConfigForOwnTrustDomain returns a mutual-TLS server config that presents the workload SVID from src and authorizes peers belonging to src's OWN trust domain — resolved at HANDSHAKE time, not at construction.

It is the deferred form of the old construction-time authorizer, for a src that may not hold an SVID yet (a WaitingSource, issue #740). Nothing is lost by deferring: the peer authorizer is only ever consulted after the server has presented a certificate, and a server with no SVID cannot present one — GetCertificate returns ErrNoSVIDYet and the handshake fails there. So until the SVID lands this config authorizes NOTHING, and once it lands the trust domain it authorizes against is the one SPIRE actually issued into, re-read on every handshake and therefore still correct after a rotation. That is strictly more accurate than resolving the trust domain once at startup, which is what forced the process to own an SVID before it could serve at all.

func TrustBundlePEM

func TrustBundlePEM(src SVIDSource) ([]byte, error)

TrustBundlePEM returns the PEM-encoded X.509 trust bundle for the SVID's own trust domain, suitable for use as a webhook/CRD caBundle. It reflects the current bundle in src and should be re-read after each rotation (src.Updated()).

func TrustDomainFromSource

func TrustDomainFromSource(src SVIDSource) (string, error)

TrustDomainFromSource returns the SPIFFE trust domain name of the workload SVID served by src (e.g. "example.org"). Use it to construct SPIFFE IDs and SDS resource names from the real trust domain SPIRE issues into, rather than from a configured value (which may be the RootCATrustDomain authorization sentinel).

func WebhookServerCert

func WebhookServerCert(src SVIDSource) func(*tls.Config)

WebhookServerCert returns a tls.Config mutator (suitable for controller-runtime's webhook.Options.TLSOpts) that makes the server present the workload SVID from src via GetCertificate, with no client-certificate requirement. The caller of a webhook is the kube-apiserver — not a SPIFFE peer — so this is one-way TLS: the apiserver verifies the SVID against the SPIRE trust bundle (injected as the webhook caBundle) and the Service DNS name, which the SVID must carry as a DNS SAN (set dnsNames on the SPIRE registration entry). Setting GetCertificate here makes controller-runtime skip its CertDir file watcher entirely.

Types

type HandshakeClass

type HandshakeClass int

HandshakeClass says why an mTLS attempt against a mesh peer failed, in the only order that is sound (see ClassifyHandshake).

const (
	// HandshakeFailure is a real failure: log it, count it, escalate it. The
	// zero value, so an unclassifiable error keeps the alarm it had before.
	HandshakeFailure HandshakeClass = iota
	// HandshakeOwnIdentityPending: this workload has no SVID yet, so it cannot
	// present a certificate or verify the peer's. Nothing about the peer is
	// known from such an attempt.
	HandshakeOwnIdentityPending
	// HandshakeReconnecting: identity arrived moments ago and the connection
	// has not finished re-establishing itself on top of it.
	HandshakeReconnecting
	// HandshakePeerIdentityPending: our identity is in hand and settled, and
	// the handshake still fails the way a peer without an SVID makes it fail.
	HandshakePeerIdentityPending
)

func ClassifyHandshake

func ClassifyHandshake(err error, identityReady bool, sinceIdentity time.Duration) HandshakeClass

ClassifyHandshake classifies a failed mTLS attempt from what the caller knows LOCALLY — whether its own identity is ready, and how long it has been ready — before falling back to the error text.

The order is the whole point, and it is the order in which the answers are trustworthy:

  1. Our own identity. A workload without an SVID fails every handshake for a reason that has nothing to do with the far side.
  2. The reconnect window. Right after identity arrives the transport is still carrying the failure it collected before, so an Unavailable here is the connection catching up (see ReconnectWindow).
  3. The peer. Only once our identity is in hand AND settled does a handshake failure say anything about the other side.

Why local state and not the error text: the marker `x509svid: could not get X509 bundle` that IsPeerIdentityUnavailable matches is raised by go-spiffe's own verifier — svid/x509svid/verify.go, `bundleSource.GetX509BundleForTrustDomain` — against the LOCAL bundle source, so the identical string is produced whether it was our source or the peer's process that was short. (A peer that cannot present a certificate at all reaches us as a `remote error: tls: …` alert instead.) The text names no party, and after a failed pre-identity attempt it is not even current. That leaves local state as the only sound discriminator, which is what this function keys on.

sinceIdentity is how long ago this workload's identity became usable; pass a negative duration when that is unknown (SPIRE disabled, or a client never told about identity) to disable the reconnect window. A nil err is not a failure and classifies as HandshakeFailure — callers only call this on an error.

type SVIDSource

type SVIDSource interface {
	x509svid.Source
	x509bundle.Source

	// Updated returns a channel that is sent on whenever the SVID or the bundles
	// change, including the first time one arrives. Same contract as
	// workloadapi.X509Source.Updated.
	Updated() <-chan struct{}
}

SVIDSource is the narrow view of a Workload API source that every helper in this package needs: the workload's own SVID, the bundles peers are verified against, and a signal that either changed.

It exists so that a source no longer has to be a *Source that ALREADY holds an SVID. WaitingSource implements it while the first SVID is still being waited for (returning ErrNoSVIDYet meanwhile), which is what lets a binary finish starting up — and answer /readyz — before SPIRE is serving (issue #740). *Source satisfies it too, so existing callers are unaffected.

type Source

type Source = workloadapi.X509Source

Source is an auto-rotating X.509 SVID source backed by the Workload API. Callers own the returned source and must Close it on shutdown.

func NewSourceWithTimeout

func NewSourceWithTimeout(ctx context.Context, socketPath string, timeout time.Duration) (*Source, error)

NewSourceWithTimeout connects to the SPIRE Workload API at socketPath and returns an X.509 SVID source that watches for rotations, bounding the wait for the first SVID by timeout. A non-positive timeout waits as long as ctx allows, which is what WaitingSource passes (issue #740: the wait moved off the startup path, so it no longer needs a deadline below the liveness budget). socketPath may be a filesystem path to the agent UDS (e.g. /run/secrets/workload-spiffe-uds/socket) or a full endpoint address (unix://… / tcp://…).

The bound applies to the initial wait only: go-spiffe derives the rotation watcher's context from context.Background() (workloadapi.newWatcher), so the returned source keeps refreshing SVIDs for its whole lifetime regardless of this timeout. Callers still own the source and must Close it.

type WaitOption

type WaitOption func(*waitOptions)

WaitOption customises a WaitingSource. It exists so the four binaries that wait for an SVID share one implementation and one set of log lines while still reporting under their own names.

func WithComponent

func WithComponent(component string) WaitOption

WithComponent names the binary doing the waiting ("agent", "controller", "registrar", "edge"). It selects the metric namespace — aether.<component>.spire.wait_seconds and friends — so a wait can be attributed to the workload that is stuck without the instruments being redefined per binary. An empty name keeps DefaultComponent.

type WaitingSource

type WaitingSource struct {
	// contains filtered or unexported fields
}

WaitingSource is an SVIDSource that acquires the underlying Workload API source in the background, retrying forever, and NEVER fails its caller.

It replaces the one-shot, fatal creation that made every binary's startup a hard dependency on SPIRE being up (issue #740): a 25s bounded wait that expired exited the process, so a SPIRE server that was still coming up during a cold boot crash-looped the whole fleet — 2-4 restarts per node — for a condition that resolves itself in under a minute.

Nothing downstream needs the process to die: the SPIRE->SDS bridge dials lazily and retries forever, the xDS cache skips upstream-mTLS injection until SetNodeIdentity lands, and Envoy tolerates the absent secret (#715). So the process starts, answers /healthz and /readyz, programs everything that does not need identity, and folds identity in the moment it arrives.

Start is a controller-runtime Runnable. Until the first SVID lands, every source method returns ErrNoSVIDYet; afterwards they delegate to the real source, which go-spiffe keeps rotating for the process's lifetime.

func NewWaitingSource

func NewWaitingSource(socketPath string, warnAfter time.Duration, log *slog.Logger, opts ...WaitOption) *WaitingSource

NewWaitingSource returns a WaitingSource for the Workload API at socketPath. warnAfter is how long the wait stays at INFO before escalating to WARN (and the readiness dwell); a non-positive value means DefaultWaitWarnAfter. Nothing is dialled until Start runs.

func (*WaitingSource) Close

func (w *WaitingSource) Close() error

Close releases the underlying source. It is safe to call before the source has been acquired (and safe on a nil receiver, i.e. with SPIRE disabled).

func (*WaitingSource) GetX509BundleForTrustDomain

func (w *WaitingSource) GetX509BundleForTrustDomain(td spiffeid.TrustDomain) (*x509bundle.Bundle, error)

GetX509BundleForTrustDomain implements x509bundle.Source, returning ErrNoSVIDYet until the first SVID has been issued.

func (*WaitingSource) GetX509SVID

func (w *WaitingSource) GetX509SVID() (*x509svid.SVID, error)

GetX509SVID implements x509svid.Source, returning ErrNoSVIDYet until the first SVID has been issued.

func (*WaitingSource) HasSVID

func (w *WaitingSource) HasSVID() bool

HasSVID reports whether this workload's identity is complete: the first SVID has arrived AND the bundle for its trust domain is servable (see firstIdentity — the source is only published once both are). It is what the readiness gate, the xDS hold and the registrar client's identity check read, so all three mean the same thing an mTLS handshake means.

func (*WaitingSource) NeedLeaderElection

func (w *WaitingSource) NeedLeaderElection() bool

NeedLeaderElection reports false: every replica needs its own identity.

func (*WaitingSource) Ready

func (w *WaitingSource) Ready() <-chan struct{}

Ready returns a channel closed once the first SVID has been obtained. Any number of goroutines may wait on it.

func (*WaitingSource) Start

func (w *WaitingSource) Start(ctx context.Context) error

Start acquires the Workload API source, retrying with bounded backoff until it succeeds or ctx ends, then relays the source's rotation signal for the rest of the process's life. It implements controller-runtime's Runnable and returns nil on shutdown: a SPIRE outage must never take the manager down with it.

func (*WaitingSource) Updated

func (w *WaitingSource) Updated() <-chan struct{}

Updated implements SVIDSource. The channel is sent on when the first SVID arrives and on every rotation after it. Each send wakes exactly ONE receiver; waiters that just want the first SVID should use Ready.

Directories

Path Synopsis
Package spiretest provides a fake SPIRE Workload API server for tests.
Package spiretest provides a fake SPIRE Workload API server for tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL