Skip to content

spire-agent 详解(含全部 agent 插件)

分析对象:SPIRE v1.15.2,聚焦 pkg/agent/pkg/agent/plugin/。 所有 路径:行号 相对 SPIRE 仓库根,均经一手代码核对。 配套文档:整体架构 · SVID 颁发全流程 · Server 详解 · K8S Agent 详解

一句话定位:spire-agent 是运行在每个工作负载节点上的本地守护进程——先用 NodeAttestor 向 server 证明"本节点是谁"换取自身 SVID,再用 KeyManager 托管私钥、周期性预签并缓存工作负载 SVID,最后用 WorkloadAttestor 对每个调用进程现算 selectors、通过 SPIFFE Workload API 下发对应身份;需要"离线注入"的场景则用 SVIDStore 把 SVID 写入外部密钥库。这四类插件就是 agent 的全部可插拔能力。


Agent 职责与引导

Agent 的装配中枢是 Agent.Run(pkg/agent/agent.go:63)。相比 server 的纯依赖注入,它多了一段**"入网引导循环"**:节点必须先完成认证拿到 agent SVID,后续所有工作才有资格进行。启动骨架(agent.go:63-324)简化如下:

diskutil.CreateDataDirectory → storage.Open(本地持久化,agent.go:72)
 → telemetry.NewMetrics → catalog.Load(加载 4 类插件,agent.go:132)
 → 选 NodeAttestor:join token 用内置 wrapper,否则用 catalog 插件(agent.go:149-152)
 → 【引导循环 agent.go:190-261】loadBundle → a.attest(节点认证) → 成功 break / 失败 backoff 重试
 → newManager(SVID 缓存与轮换,agent.go:265)
 → newSVIDStoreService(agent.go:270) + workload_attestor.New(agent.go:271)
 → newEndpoints(Workload API,agent.go:277)
 → TaskRunner 并发:metrics / mgr.Run / storeService.Run / agentEndpoints.ListenAndServe / catalog 重配(agent.go:283-289)

NodeAttestor 的选取有一个特例——如果通过 CLI/HCL 提供了 join token,则不走 catalog,而是用内置的 nodeattestor.JoinToken 包装器:

go
// pkg/agent/agent.go:149
nodeAttestor := nodeattestor.JoinToken(a.c.Log, a.c.JoinToken)
if a.c.JoinToken == "" {
    nodeAttestor = cat.GetNodeAttestor()   // 否则取 catalog 中唯一的 NodeAttestor 插件
}

引导循环是 agent 冷启动的心脏(agent.go:190-261):先从本地 storage 取 bootstrap bundle(取不到再从配置的 TrustBundleSources 拉),然后调用 a.attest 完成节点认证;成功即 break,失败按退避策略重试(普通模式最长 1 分钟,rebootstrap 模式最长 24 小时):

go
// pkg/agent/agent.go:190
for {
    bootstrapTrustBundle, err := sto.LoadBundle()
    if errors.Is(err, storage.ErrNotCached) {
        bootstrapTrustBundle, insecureBootstrap, err = a.c.TrustBundleSources.GetBundle()
    }
    if err == nil {
        as, err = a.attest(ctx, sto, cat, metrics, nodeAttestor, bootstrapTrustBundle, insecureBootstrap)
        if err == nil {
            ...
            break                       // 认证成功,携带 AttestationResult 退出循环
        }
        if status.Code(err) == codes.PermissionDenied { return err }   // 被 server 拒绝,直接失败
    }
    nextDuration := attBackoff.NextBackOff()
    if nextDuration == backoff.Stop { return err }
    select {
    case <-ctx.Done(): return ctx.Err()
    case <-attBackoffClock.After(nextDuration): continue
    }
}

a.attest(agent.go:377)只是把配置组装给节点认证子系统 attestor/node,真正的双向流认证过程见 SVID 颁发全流程 · 链路一。引导成功后,AttestationResult(含 agent SVID、私钥、trust bundle、Reattestable 标志)交给 newManager,由 manager 内部的 svid.Rotator 接管 agent SVID 的后续轮换。

Agent 的整体装配与在架构中的位置见 整体架构 · §3 Agent 侧内部模块;本文只聚焦插件


插件框架

Agent 端的插件目录在 pkg/agent/catalog/catalog.go。它对外暴露一个类型化的 Catalog 接口,恰好对应四类 agent 插件:

go
// pkg/agent/catalog/catalog.go:31
type Catalog interface {
    GetKeyManager() keymanager.KeyManager                          // 恰好 1 个
    GetNodeAttestor() nodeattestor.NodeAttestor                    // 恰好 1 个
    GetSVIDStoreNamed(name string) (svidstore.SVIDStore, bool)     // 0..N 个,按名取
    GetWorkloadAttestors() []workloadattestor.WorkloadAttestor     // 至少 1 个,全部
}

注意接口形态已经暗示了各类插件的数量约束与调用方式:NodeAttestor / KeyManager 返回单个;WorkloadAttestor 返回整个切片(要并行全调);SVIDStore 按名字查(因为一条 store 类型的注册条目要精确路由到某一个 store 插件)。

内置与外部插件完全同构,都走 gRPC——这是 SPIRE catalog 的核心设计,agent 端也不例外。catalog.Load(catalog.go:85)把四个插件仓库(keyManagerRepository 等)交给通用框架 pkg/common/catalog 加载:内置插件在进程内起 gRPC server、经内存管道回环连接;外部插件走 hashicorp/go-plugin 子进程。上层拿到的都是同一套 gRPC 客户端 facade,对二者无感知(机制详见 整体架构 · §4)。

每类插件在 agent catalog 里都有一个注册文件,声明三件事:数量约束(Constraints)gRPC facade 版本(Versions)内置实现清单(BuiltIns)。以 NodeAttestor 为例:

go
// pkg/agent/catalog/nodeattestor.go:26
func (repo *nodeAttestorRepository) Constraints() catalog.Constraints { return catalog.ExactlyOne() }
func (repo *nodeAttestorRepository) Versions() []catalog.Version       { return []catalog.Version{nodeAttestorV1{}} }
func (repo *nodeAttestorRepository) BuiltIns() []catalog.BuiltIn {
    return []catalog.BuiltIn{ awsiid.BuiltIn(), azuremsi.BuiltIn(), /* ... */ x509pop.BuiltIn() }
}

四类约束的语义(pkg/common/catalog/constraints.go:7,Constraints{Min,Max},Max==0 表示无上限):

约束助手{Min,Max}含义用于
ExactlyOne(){1,1}必须恰好 1 个NodeAttestor、KeyManager
AtLeastOne(){1,0}至少 1 个,无上限WorkloadAttestor
ZeroOrMore(){0,0}任意个(含 0)SVIDStore
MaybeOne(){0,1}至多 1 个(agent 未用;server 的 UpstreamAuthority)

四类插件一览(约束与内置实现均以 pkg/agent/catalog/*.goConstraints()/BuiltIns() 实测为准):

插件类型Go 接口约束内置实现(数量)调用时机
NodeAttestornodeattestor.NodeAttestorExactlyOnejoin_token, aws_iid, gcp_iit, azure_msi, azure_imds, k8s_psat, x509pop, sshpop, tpm_devid, http_challenge(10)agent 入网(启动 + 重认证)
KeyManagerkeymanager.KeyManagerExactlyOnedisk, memory(2)生成/托管所有私钥
WorkloadAttestorworkloadattestor.WorkloadAttestorAtLeastOneunix, docker, k8s, systemd, windows(5)每次 Workload API 调用
SVIDStoresvidstore.SVIDStoreZeroOrMoreaws_secretsmanager, gcp_secretmanager(2)-storeSVID 的条目

一个额外约束:内置 join_token 不允许被外部插件覆盖(catalog.go:86):if c, ok := ...Find(nodeAttestorType, jointoken.PluginName); ok && c.IsEnabled() && c.IsExternal() 则直接报错。

下面逐类详解。


NodeAttestor 插件

做什么

Agent 入网时,由 NodeAttestor 在 agent 侧生成 attestation 数据向 server 证明"本节点的身份",从而换取一张代表 agent 自己的 SVID。它与 server 侧同名的 NodeAttestor 插件成对工作:agent 侧负责产出证据(读云平台 IID、读 TPM、出示证书……)并应答挑战;server 侧负责验证证据、派生 SPIFFE ID、决定节点 selectors。约束是 ExactlyOne——一个 agent 只能配一种入网方式。

Go 接口

go
// pkg/agent/plugin/nodeattestor/nodeattestor.go:10
type NodeAttestor interface {
    catalog.PluginInfo
    // 用 server 流完成认证;ServerStream 产生的错误原样返回
    Attest(ctx context.Context, serverStream ServerStream) error
}

// ServerStream 是插件向 server 发送 attestation 数据 / 挑战应答的通道
type ServerStream interface {                                    // :21
    SendAttestationData(ctx context.Context, attestationData AttestationData) ([]byte, error)
    SendChallengeResponse(ctx context.Context, response []byte) ([]byte, error)
}
type AttestationData struct { Type string; Payload []byte }      // :28

内置实现

插件名证据来源交互形态说明
join_token一次性预共享令牌无挑战(payload 即 token)特例,由 agent 特殊代码路径注入,见下
aws_iidEC2 实例身份文档 + 签名(RSA2048)单发 payloadiid.go:74 读 IMDS,iid.go:91 发 payload
gcp_iitGCP 实例身份 token(JWT)单发 payloadiit.go:79,audience spire-gcp-node-attestor(iit.go:25)
azure_msiAzure MSI token单发 payload托管身份令牌
azure_imdsAzure IMDS 元数据单发 payload实例元数据服务
k8s_psat投影的 ServiceAccount Token单发 payloadpsat.go:87;详见 K8S Agent 详解
x509popX.509 证书 + 私钥挑战应答(拥有性证明)x509pop.go:81
sshpopSSH 证书 + 私钥挑战应答sshpop.go:39,RespondToChallenge(:64)
tpm_devidTPM DevID 证书 + TPM 密钥挑战应答硬件根信任
http_challengeagent 暴露的 HTTP 端点挑战应答(server 回拉 nonce)httpchallenge.go:110,hostname/advertised_port(:46/:49)

出处:pkg/agent/catalog/nodeattestor.go:36-49BuiltIns()

配置要点

ExactlyOne——agent.confplugins {}必须且只能配一个 NodeAttestor,且必须与 server 端配置了同名插件。若用 join token,则在 agent 命令行 -joinToken 或配置里给出 token 值(此时无需在 plugins 中显式列 join_token,但通常会声明以便文档化)。云平台类(aws_iid/gcp_iit/azure_*)几乎零配置(直接读元数据服务);证书类(x509pop/sshpop/tpm_devid)需配私钥与证书路径。

关键代码走读:三层适配 + 挑战应答流

NodeAttestor 有三层结构,理解它就理解了"内置插件也走 gRPC":

① 插件实现层——以 x509pop 为例,插件实现 gRPC 的 AidAttestation 流:先发证书 payload,收到挑战后用私钥算响应(拥有性证明 PoP):

go
// pkg/agent/plugin/nodeattestor/x509pop/x509pop.go:81
func (p *Plugin) AidAttestation(stream nodeattestorv1.NodeAttestor_AidAttestationServer) error {
    data, _ := p.loadConfigData(stream.Context())
    stream.Send(&...PayloadOrChallengeResponse{Data: &..._Payload{Payload: data.attestationPayload}})  // 发证书
    resp, _ := stream.Recv()                                                    // 收挑战
    challenge := new(x509pop.Challenge); json.Unmarshal(resp.Challenge, challenge)
    response, _ := x509pop.CalculateResponse(data.privateKey, challenge)        // 用私钥算响应
    responseBytes, _ := json.Marshal(response)
    return stream.Send(&...PayloadOrChallengeResponse{Data: &..._ChallengeResponse{ChallengeResponse: responseBytes}})
}

② V1 facade 层(nodeattestor/v1.go:18)——把插件的 gRPC 流"翻译"成上层的 Attest(ctx, ServerStream),在插件流server 流之间来回搬运 payload/challenge:

go
// pkg/agent/plugin/nodeattestor/v1.go:18
func (v1 *V1) Attest(ctx context.Context, serverStream ServerStream) error {
    pluginStream, _ := v1.NodeAttestorPluginClient.AidAttestation(ctx)          // 打开插件流
    payloadOrChallengeResponse, _ := pluginStream.Recv()                        // 从插件取 payload
    payload := payloadOrChallengeResponse.GetPayload()
    challenge, _ := serverStream.SendAttestationData(ctx, AttestationData{Type: v1.Name(), Payload: payload})  // 发给 server
    for {
        if challenge == nil { return nil }                                      // server 直接给了 SVID,结束
        pluginStream.Send(&nodeattestorv1.Challenge{Challenge: challenge})      // 把挑战转给插件
        pcr, _ := pluginStream.Recv()                                           // 取插件的挑战响应
        challenge, _ = serverStream.SendChallengeResponse(ctx, pcr.GetChallengeResponse())  // 回给 server
    }
}

③ ServerStream 层(attestor/node/node.go:301)——SendAttestationData 把 payload 连同不含 URI SAN 的 CSR 打包成 AttestAgentRequest,首次调用时惰性打开到 server 的 gRPC 双向流 AttestAgent;Recv 到的要么是 Challenge(继续循环),要么是最终 Result{Svid, Reattestable}:

go
// pkg/agent/attestor/node/node.go:310
func (ss *ServerStream) SendAttestationData(ctx, attestationData) ([]byte, error) {
    return ss.sendRequest(ctx, &agentv1.AttestAgentRequest{Step: &..._Params_{Params: &..._Params{
        Data:   &types.AttestationData{Type: attestationData.Type, Payload: attestationData.Payload},
        Params: &agentv1.AgentX509SVIDParams{Csr: ss.Csr},                      // CSR 不含 SPIFFE ID
    }}})
}
// sendRequest(:334):ss.stream 为空时 ss.Client.AttestAgent(ctx) 打开双向流;Recv 到 Challenge 则返回挑战,否则解析出 SVID

CSR 的生成在 newSVID(node.go:229)——util.MakeCSRWithoutURISAN(key),SPIFFE ID 由 server 依据认证结果决定,主体不能自证身份。

join_token 的特例:它的插件实现 AidAttestation 只返回错误(jointoken/join_token.go:30:"join token was not provided"),因为真正的逻辑走 agent 的特殊代码路径——nodeattestor.JoinToken 包装器(jointoken.go:11)直接把 token 作为 payload 发出,且不接受任何挑战:

go
// pkg/agent/plugin/nodeattestor/jointoken.go:23
func (plugin joinToken) Attest(ctx context.Context, serverStream ServerStream) error {
    challenge, err := serverStream.SendAttestationData(ctx, AttestationData{Type: plugin.Name(), Payload: []byte(plugin.token)})
    switch {
    case err != nil:        return err
    case challenge != nil:  return plugin.Error(codes.Internal, "server issued unexpected challenge")  // join_token 不该被挑战
    default:                return nil
    }
}

k8s_psat 的 agent 侧只读取投影 token 并作为 payload 单发(psat.go:87AidAttestationloadTokenFromFilejson.Marshal(k8s.PSATAttestationData{Cluster, Token})),无挑战。它与 server 侧 k8s_psat 的配对、以及在 K8s 里如何部署,详见 K8S Agent 详解


KeyManager 插件

做什么

KeyManager 生成并托管 agent 用到的所有私钥——既包括 agent 自身 SVID 的私钥,也包括为每个工作负载 SVID 生成的私钥。核心安全约束:私钥永不出机,插件只对外提供"签名器"(crypto.Signer)句柄,调用方拿不到私钥字节。约束是 ExactlyOne

Go 接口

go
// pkg/agent/plugin/keymanager/keymanager.go:16
type KeyManager interface {
    catalog.PluginInfo
    GenerateKey(ctx context.Context, id string, keyType KeyType) (Key, error)  // 存在则覆盖
    GetKey(ctx context.Context, id string) (Key, error)
    GetKeys(ctx context.Context) ([]Key, error)
}
type Key interface { crypto.Signer; ID() string }                              // :32,只暴露签名器
// KeyType:ECP256 / ECP384 / RSA2048 / RSA4096(:40)

内置实现

插件名私钥持久化适用关键位置
disk落盘到 <directory>/keys.json(PKCS#8,原子写,0600)重启后能恢复 agent SVID / 工作负载密钥,生产默认disk/disk.go:33
memory仅内存,进程退出即失测试 / 无状态短生命周期节点memory/memory.go:19

两者共享同一套内存实现 keymanagerbase.Base(base/keymanagerbase.go),disk 只是额外挂了一个 WriteEntries 回调把条目刷盘。出处:pkg/agent/catalog/keymanager.go:27-32

配置要点

  • disk:directory 必填(disk.go:71,为空报错;启动时会建目录并做写探针 .probe)。密钥写入 keys.json
  • memory:无配置。
  • 全局:agent.confworkload_x509_svid_key_type 决定工作负载私钥类型;agent 自身 SVID 固定用 ECP256(见下)。

关键代码走读:base 生成 + SVID 双槽

base 的 GenerateKey(base/keymanagerbase.go:124)生成密钥条目、写入内存 map,若配了 WriteEntries 回调(disk 才有)则落盘,失败会回滚内存条目:

go
// pkg/agent/plugin/keymanager/base/keymanagerbase.go:124
func (m *Base) generateKey(ctx, req) (*keymanagerv1.GenerateKeyResponse, error) {
    newEntry, _ := m.generateKeyEntry(req.KeyId, req.KeyType)
    m.mu.Lock(); defer m.mu.Unlock()
    oldEntry, hasEntry := m.entries[req.KeyId]
    m.entries[req.KeyId] = newEntry
    if m.config.WriteEntries != nil {                                          // disk:刷盘
        if err := m.config.WriteEntries(ctx, entriesSliceFromMap(m.entries), newEntry); err != nil {
            if hasEntry { m.entries[req.KeyId] = oldEntry } else { delete(m.entries, req.KeyId) }  // 回滚
            return nil, err
        }
    }
    return &keymanagerv1.GenerateKeyResponse{PublicKey: clonePublicKey(newEntry.PublicKey)}, nil
}

disk 的落盘用 PKCS#8 序列化后原子写(disk/disk.go:171 writeEntriesdiskutil.AtomicWritePrivateFile,路径 keys.json,disk.go:195)。

agent SVID 的"双槽"轮换:agent 自身 SVID 的密钥由专门的 SVIDKeyManager(svidkeymanager.go)包装,它在 agent-svid-A / agent-svid-B 两个 slot 间交替,以支持"生成新密钥的同时旧密钥仍可用"的平滑轮换,固定 ECP256:

go
// pkg/agent/plugin/keymanager/svidkeymanager.go:28
func (s svidKeyManager) GenerateKey(ctx context.Context, currentKey Key) (Key, error) {
    keyID := "agent-svid-A"
    if currentKey != nil && currentKey.ID() == keyID { keyID = "agent-svid-B" }  // 交替占槽
    return s.km.GenerateKey(ctx, keyID, ECP256)
}

这个 ForSVID 包装器(svidkeymanager.go:20)在节点认证 loadSVID(attestor/node/node.go:106)时被用来生成/恢复 agent SVID 的密钥。


WorkloadAttestor 插件(重点)

做什么

这是 agent"下发环节"的判定核心。当工作负载通过 Workload API 索要 SVID 时,agent 从 peertracker 拿到调用进程的 PID,交给 WorkloadAttestor 现算一组 selectors(如 unix:uid:1000k8s:ns:default);再用这组 selectors 去匹配缓存中"本 agent 有权代理的注册条目",把命中的、早已预签好的 SVID 递给调用者。约束是 AtLeastOne——至少配一个;所有配置的插件会被并行调用,selectors 聚合

Go 接口

go
// pkg/agent/plugin/workloadattestor/workloadattestor.go:11
type WorkloadAttestor interface {
    catalog.PluginInfo
    Attest(ctx context.Context, pid int) ([]*common.Selector, error)               // PID 型
    AttestReference(ctx context.Context, reference *anypb.Any) ([]*common.Selector, error)  // 引用型(如 K8s 对象)
}

内置实现与产出 selectors

插件名判定依据产出 selector(前缀即插件名)关键位置
unix/proc 进程属性uidusergidgroupsupplementary_gidsupplementary_group;可选 pathsha256unix/unix_posix.go:133
dockerDocker/Podman 容器 inspectlabel:<k>:<v>env:<E>image_idimage_config_digest;可选 sigstoredocker/docker.go:128
k8s经 kubelet 把 PID 映射到 Podnssanode-namepod-uidpod-namepod-label:<k>:<v>pod-ownercontainer-namecontainer-imagek8s/k8s.go:1712
systemdD-Bus 查 unitidfragment_pathsystemd/systemd_posix.go:58
windowsWindows 进程 tokenuser_nameuser_sidgroup_sidgroup_name;可选 pathsha256windows/windows_windows.go:76

出处:pkg/agent/catalog/workloadattestor.go:29-37

selector 示例(工作负载的一次请求可能同时命中多种):

unix:uid:1000
unix:user:app
unix:gid:1000
unix:path:/usr/local/bin/server
docker:label:com.example.team:frontend
docker:image_id:nginx:1.27
k8s:ns:default
k8s:sa:frontend
k8s:pod-label:app:web
systemd:id:myapp.service

配置要点

  • unix:discover_workload_path(bool,默认 false)开启后额外产出 path;workload_size_limit(int64)≥0 时进一步算 sha256(读可执行文件计算摘要,需 agent 有权访问 /proc/<pid>/exe)。
  • docker:docker_socket_pathdocker_versioncontainer_id_cgroup_matcherspodman_socket_path(支持 Podman)、sigstore {}(镜像签名验证)。
  • k8s:kubelet_read_only_portkubelet_secure_port(二选一)、kubelet_ca_pathtoken_pathskip_kubelet_verification 等,详见 K8S Agent 详解
  • systemd:无配置(直接连系统 D-Bus)。
  • windows:discover_workload_pathworkload_size_limitdisable_group_name_selectors

关键代码走读:并行聚合

聚合器 attestor/workload/workload.go 拿到所有 WorkloadAttestor 插件,并行(每插件一个 goroutine)对同一 PID 调 Attest,结果汇入 channel:

go
// pkg/agent/attestor/workload/workload.go:112
plugins := wla.c.Catalog.GetWorkloadAttestors()
sChan := make(chan []*common.Selector, len(plugins))
errChan := make(chan error, len(plugins))
var wg sync.WaitGroup
for _, p := range plugins {
    wg.Go(func() {
        if selectors, err := attestFunc(p); err == nil { sChan <- selectors } else { errChan <- err }
    })
}
defer wg.Wait()
// 收集:成功则 append 到 selectors;某插件失败仅记日志、丢弃其 selectors;
// 只有当所有插件都失败(successes==0)时才返回合并错误(:163)

容错语义很关键(workload.go:56 的注释与 :163 的逻辑):个别插件失败会被记录并丢弃其 selectors,但只要有一个成功就继续;仅当全部失败才向调用方报错。这样在混合环境(比如一个进程既不是容器也不在 k8s 里)不会因为 docker/k8s 插件"没找到"而阻断 unix 插件的结果。

selector 的类型前缀怎么来的:插件本身只返回(如 unix 返回 uid:1000),由 V1 facade 统一贴上 Type = 插件名:

go
// pkg/agent/plugin/workloadattestor/v1.go:96
func (v1 *V1) selectorsFrom(values []string) []*common.Selector {
    selectors := make([]*common.Selector, 0, len(values))
    for _, value := range values {
        selectors = append(selectors, &common.Selector{Type: v1.Name(), Value: value})  // Type=插件名
    }
    return selectors
}

于是 unix 插件的 uid:1000 最终呈现为选择器 unix:uid:1000。V1 facade 还桥接了新旧两种调用风格(v1.go:29):优先调 PID 型 Attest,若插件返回 Unimplemented 则把 PID 打包成 WorkloadPIDReference 改走 AttestReference,反之亦然。

unix 插件的取值细节(unix/unix_posix.go:133)——注意 uid/gid 在有多个值(real/effective)时取 effective(索引 1):

go
// pkg/agent/plugin/workloadattestor/unix/unix_posix.go:150
uid, _ := p.getUID(proc)                                            // getUID(:247):len>1 取 uids[1](effective)
selectorValues = append(selectorValues, makeSelectorValue("uid", uid))
if user, ok := p.getUserName(uid); ok { selectorValues = append(selectorValues, makeSelectorValue("user", user)) }
gid, _ := p.getGID(proc)
selectorValues = append(selectorValues, makeSelectorValue("gid", gid))
// ... supplementary_gid / supplementary_group 遍历附加组(:171)
if config.DiscoverWorkloadPath {                                    // 需显式开启(:182)
    processPath, _ := p.getPath(proc)
    selectorValues = append(selectorValues, makeSelectorValue("path", processPath))
    if config.WorkloadSizeLimit >= 0 {
        sha256Digest, _ := util.GetSHA256Digest(exePath, config.WorkloadSizeLimit)
        selectorValues = append(selectorValues, makeSelectorValue("sha256", sha256Digest))
    }
}

docker / systemd 的取值同理:docker 从容器 Config 提取 label/env/image(docker.go:221 getSelectorValuesFromConfig),再补 image_config_digest;systemd 通过 D-Bus GetUnitByPID(systemd_posix.go:120)拿到 unit 的 IdFragmentPath。k8s 从 Pod 派生大量 selectors(k8s.go:1712 getSelectorValuesFromPodInfo,sa/ns/node-name/pod-uid/pod-name 起于 :1714),细节见 K8S Agent 详解

这组 selectors 之后如何匹配 entry、取出预签的 SVID 并流式下发,见 SVID 颁发全流程 · 链路二


SVIDStore 插件

做什么

有些工作负载不方便直接连 Workload API(如无 sidecar 的 Lambda、纯批处理任务、跨账户的托管服务)。SVIDStore 提供另一条路:把签发好的 SVID(证书链 + 私钥 + bundle)写入外部密钥库,工作负载再自行从密钥库读取。这类身份对应的注册条目带 -storeSVID 标志,不走 Workload API 下发。约束是 ZeroOrMore(可以一个都不配)。

Go 接口

go
// pkg/agent/plugin/svidstore/svidstore.go:13
type SVIDStore interface {
    catalog.PluginInfo
    DeleteX509SVID(ctx context.Context, metadata []string) error
    PutX509SVID(context.Context, *X509SVID) error
}
type X509SVID struct {                                             // :20
    SVID             *SVID
    Metadata         []string                                      // 插件定位/命名密钥所需(来自 entry selectors)
    FederatedBundles map[string][]*x509.Certificate
}
type SVID struct { SPIFFEID spiffeid.ID; CertChain []*x509.Certificate; PrivateKey crypto.PrivateKey; Bundle []*x509.Certificate; ExpiresAt time.Time }  // :31

内置实现

插件名写入目标配置关键位置
aws_secretsmanagerAWS Secrets Manageraccess_key_idsecret_access_keyregionawssecretsmanager/aws.go:25
gcp_secretmanagerGCP Secret Managerservice_account_filegcpsecretmanager/gcloud.go:27

出处:pkg/agent/catalog/svidstore.go:26-31

配置要点:selectors 即"路由 + 元数据"

SVIDStore 的路由机制很特别——注册条目的 selector 类型就是目标插件名,selector 值就是给该插件的元数据。例如一条要存进 AWS 的条目,其 selectors 形如:

aws_secretsmanager:name:my-workload-svid
aws_secretsmanager:kmskeyid:alias/spire
aws_secretsmanager:region:us-west-2

服务层 getStoreNameWithMetadata(svid/store/service.go:256)取 selectors[0].Type 作为插件名(并校验所有 selector 类型一致),把各 Value 收成 metadata 交给插件;插件再用 svidstore.ParseMetadatametadata 解析成键值对(如 aws.go:256 取出 name/arn/kmskeyid)。

关键代码走读:独立的存储循环

-storeSVID 的条目在 agent 同步阶段就被分流到独立的 store 缓存,而非工作负载缓存(manager/sync.go:343):

go
// pkg/agent/manager/sync.go:343
for entryID, entry := range update.Entries {
    switch {
    case entry.StoreSvid: storeEntries[entryID] = entry     // 分流到 SVIDStore 缓存
    default:              cacheEntries[entryID] = entry      // 走常规工作负载缓存
    }
}

SVIDStoreService.Run(svid/store/service.go:82)是一个每 5 秒跑一轮的独立循环,把 store 缓存里"就绪待存"的记录逐条推给对应插件:

go
// pkg/agent/svid/store/service.go:135
func (s *SVIDStoreService) storeSVID(ctx, log, record *storecache.Record) {
    storeName, metadata, _ := getStoreNameWithMetadata(record.Entry.Selectors)  // selector[0].Type = 插件名
    svidStore, ok := s.cat.GetSVIDStoreNamed(storeName)                          // 按名取插件
    if !ok { log.Error("SVIDStore not found"); return }
    req, _ := s.requestFromRecord(record, metadata)                             // 组装 X509SVID(链+私钥+bundle+联邦bundle)
    if err := svidStore.PutX509SVID(ctx, req); err != nil { log.Error("Failed to put X509-SVID"); return }
    s.cache.HandledRecord(record.Entry, record.Revision)                        // 标记已处理,避免重复写
}

requestFromRecord(service.go:209)会把本域 rootCA、按 FederatesWith 收集的联邦 bundle、证书链与本地私钥一起打包。删除逻辑(deleteSVID,service.go:98)在条目被撤销或 selectors 变更时触发,先从密钥库删旧密钥再存新的(processRecords,service.go:175)。

与 Workload API 路径的本质区别:Workload API 是工作负载主动拉、agent 用 attestation 现场判权;SVIDStore 是 agent主动推到密钥库,鉴权发生在密钥库的 IAM/访问控制层。-storeSVID 条目的用法见 SVID 颁发全流程 · §8.4 的 entry 配置项表。


深入

  • Server 详解 —— server 侧配对插件:NodeAttestor(验证证据、派生 ID)、KeyManager、UpstreamAuthority、DataStore、Notifier、BundlePublisher、CredentialComposer。
  • K8S Agent 详解 —— Kubernetes 上的两个专属插件:节点认证 k8s_psat、工作负载认证 k8s(经 kubelet 映射 Pod)。
  • SVID 颁发全流程 —— 本文四类插件在"节点认证 → 工作负载认证 → CA 签名"三条链路里的代码级串联与端到端时序图。
  • 整体架构 —— agent 在双组件架构中的位置、catalog 插件框架的内置/外部同构机制。
  • 实战与部署 —— 把上述插件用起来:配置样例、上游 CA、生产落地。