Skip to content

给现有 SPIRE 装上管理界面 · Tornjak 实战

SPIRE 本身只有 CLI(spire-server entry ...),没有图形界面。Tornjak 是 SPIFFE 官方的 SPIRE 管理 UI:图形化查看 / 管理注册条目(entry)、agent、集群与 server 信息,让"谁能拿到哪个身份"可以交给运维、安全团队自助操作。

本文是一篇真实复现记录 —— 把 Tornjak 接到一个已经跑了很久的 SPIRE(Kubernetes / kind 部署,单 spire-server + spire-agent)上,不重装、不动数据,只做加法。

🔗 穿越源码分析:Server 详解 · 职责与装配 · SVID 颁发流程 📖 官方配置对照:SPIRE Server 配置参考 · k8s_psat 节点认证

Tornjak 是怎么接进来的

Tornjak 分两个组件,后端必须能访问 SPIRE Server 的管理 API,而 SPIRE Server 的管理 API 走本地 Unix 域套接字(UDS)——所以后端要么和 spire-server 同 Pod(共享 socket),要么就得想别的办法拿到 socket。官方推荐、也是本文采用的做法:把 tornjak-backend 作为 sidecar 塞进 spire-server 的 Pod,两个容器用一个 emptyDir 卷共享 api.sock

组件职责:

组件形态端口作用
spire-server原有容器(不改镜像)8081SPIRE Server,socket 在 /tmp/spire-server/private/api.sock
tornjak-backend新增 sidecar10000读 socket,把 SPIRE 管理 API 封装成 REST
tornjak-frontend独立 Deployment3000React 单页应用,浏览器里直接调后端 REST

关键点:前端是跑在浏览器里的 SPA,它调的后端地址(REACT_APP_API_SERVER_URI)必须是浏览器能到的地址。本地演示用 kubectl port-forward 把后端映射到 localhost:10000,前端默认值恰好就是 http://localhost:10000/,天然对上。

前置条件

  • 一个已经在跑的 SPIRE(本文是 kind 集群 spire-test,命名空间 spire,spire-server 为 StatefulSet)
  • 记下 SPIRE Server 的 socket 路径trust domain(从 server 配置里看):
bash
kubectl -n spire get cm spire-server -o jsonpath='{.data.server\.conf}' | grep -E 'socket_path|trust_domain'
# socket_path = "/tmp/spire-server/private/api.sock"
# trust_domain = "example.org"

第 0 步 · 把镜像弄进集群

Tornjak 镜像在 ghcr.io。如果集群节点因代理 / 离网拉不动镜像(kind 很常见),在能联网的宿主机上拉好,再 kind load 侧载进去,绕开节点的拉取链路:

bash
docker pull --platform linux/amd64 ghcr.io/spiffe/tornjak-backend:latest
docker pull --platform linux/amd64 ghcr.io/spiffe/tornjak-frontend:latest

kind load docker-image ghcr.io/spiffe/tornjak-backend:latest  --name spire-test
kind load docker-image ghcr.io/spiffe/tornjak-frontend:latest --name spire-test

非 kind 环境(EKS/GKE 等)节点能正常拉 ghcr 就跳过这步,把下文 imagePullPolicy 改回 Always 即可。

第 1 步 · Tornjak 后端配置(ConfigMap)

后端要一个 HCL 配置:告诉它 SPIRE socket 在哪、自己 HTTP 监听哪个端口、以及一个存自身元数据的小库。

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: tornjak-config
  namespace: spire
data:
  tornjak.conf: |
    server {
      # 与 spire-server 共享同一个 UDS(通过 emptyDir 卷)
      spire_socket_path = "unix:///tmp/spire-server/private/api.sock"
      http {
        enabled = true
        port = 10000
      }
    }
    plugins {
      # Tornjak 自己的元数据库,与 SPIRE 数据分离
      DataStore "sql" {
        plugin_data {
          drivername = "sqlite3"
          filename = "/run/spire/tornjak-data/tornjak.sqlite3"
        }
      }
    }

第 2 步 · 给 spire-server 加 sidecar(不动原有配置)

strategic merge patch:按 name 合并卷与容器——只给原 spire-server 容器追加一个共享 socket 卷挂载,并新增 tornjak-backend 容器,原有 args、镜像等一概不动。

yaml
# tornjak-patch.yaml
spec:
  template:
    spec:
      volumes:
        - name: spire-server-socket
          emptyDir: {}
        - name: tornjak-config
          configMap:
            name: tornjak-config
        - name: tornjak-data
          emptyDir: {}
      containers:
        - name: spire-server          # 原容器:只加共享 socket 挂载
          volumeMounts:
            - name: spire-server-socket
              mountPath: /tmp/spire-server/private
        - name: tornjak-backend        # 新增 sidecar
          image: ghcr.io/spiffe/tornjak-backend:latest
          imagePullPolicy: IfNotPresent
          args:
            - --spire-config
            - /run/spire/config/server.conf
            - --tornjak-config
            - /run/spire/tornjak/tornjak.conf
          ports:
            - containerPort: 10000
              name: tornjak-http
          volumeMounts:
            - name: spire-server-socket
              mountPath: /tmp/spire-server/private
            - name: spire-config
              mountPath: /run/spire/config
              readOnly: true
            - name: tornjak-config
              mountPath: /run/spire/tornjak
              readOnly: true
            - name: tornjak-data
              mountPath: /run/spire/tornjak-data
bash
kubectl -n spire apply -f tornjak-config.yaml
kubectl -n spire patch statefulset spire-server --type strategic --patch-file tornjak-patch.yaml
kubectl -n spire rollout status statefulset/spire-server

共享 socket 的原理:原来 api.sock 生成在容器自身的 /tmp 里,别的容器看不到。挂一个 emptyDir/tmp/spire-server/private,spire-server 启动时把 socket 建在这个共享卷上,sidecar 挂同一个卷即可读到。

会重启吗? 改 StatefulSet 的 Pod 模板会滚动重启 spire-server-0(约几十秒)。CA 密钥与数据库在持久 PVC(spire-data)上,重启不丢;agent 端凭缓存 SVID 扛过这段,恢复后照常。

第 3 步 · 部署前端 + Service

yaml
apiVersion: v1
kind: Service
metadata: { name: tornjak-backend, namespace: spire }
spec:
  selector: { app: spire-server }   # 指向带 sidecar 的 spire-server Pod
  ports: [{ name: tornjak-http, port: 10000, targetPort: 10000 }]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: tornjak-frontend, namespace: spire, labels: { app: tornjak-frontend } }
spec:
  replicas: 1
  selector: { matchLabels: { app: tornjak-frontend } }
  template:
    metadata: { labels: { app: tornjak-frontend } }
    spec:
      containers:
        - name: tornjak-frontend
          image: ghcr.io/spiffe/tornjak-frontend:latest
          imagePullPolicy: IfNotPresent
          ports: [{ containerPort: 3000 }]
          env:
            - name: REACT_APP_API_SERVER_URI    # 浏览器经 port-forward 访问后端
              value: "http://localhost:10000/"
---
apiVersion: v1
kind: Service
metadata: { name: tornjak-frontend, namespace: spire }
spec:
  selector: { app: tornjak-frontend }
  ports: [{ name: http, port: 3000, targetPort: 3000 }]
bash
kubectl -n spire apply -f tornjak-frontend.yaml

第 4 步 · 访问

前端(SPA)在浏览器里跑,要同时能到前端后端,所以开两个 port-forward:

bash
kubectl -n spire port-forward svc/tornjak-backend  10000:10000 &
kubectl -n spire port-forward svc/tornjak-frontend 3000:3000 &

浏览器打开 http://localhost:3000,即可看到 Tornjak。顶部导航:

  • Agents — 已认证的节点 agent 列表(SPIFFE ID、attestation 类型、selector)
  • Entries — 注册条目管理(增删改查工作负载身份)
  • Clusters / Tornjak ServerInfo / Dashboard — 集群、SPIRE server 信息、总览

验证:确实打通了 SPIRE

后端起来后,几条 curl 就能确认它读到了真实的 SPIRE 数据(不是空壳):

bash
# 健康
curl -s localhost:10000/api/healthcheck
# {"status":1}

# server 信息:直接映射 SPIRE server 的插件与 trust domain
curl -s localhost:10000/api/tornjak/serverinfo | jq '{trustDomain, plugins}'
# { "trustDomain": "example.org",
#   "plugins": { "NodeAttestor": ["k8s_psat"], "KeyManager": ["disk"], ... } }

# 注册条目:SPIRE 里已有的 entry 都在
curl -s localhost:10000/api/entry/list | jq '.entries[].spiffe_id'

后端日志里应看到:

Starting to listen on :10000...

前端注入的运行时配置(react-inject-env 在容器启动时写)可这样确认:

bash
curl -s localhost:3000/env.js
# window.env = { "REACT_APP_API_SERVER_URI": "http://localhost:10000/" }

日常管理操作(真机演示)

装好之后最常用的两件事:管理集群元数据增删注册条目(entry)。下面都是在上面这套 homelab 上真机点出来的。

两者本质不同:Clusters 是 Tornjak 自己的元数据(存在 Tornjak 的库里,给运维分类 / 备注,不影响 SPIRE 签发);而 Entry真正的 SPIRE 注册条目——在 Tornjak 里建 entry = 直接往 SPIRE 数据库写,立刻影响"谁能拿到哪个身份"。

管理集群 · Clusters

顶部 Clusters 看列表;新建走 /cluster/clustermanagementCreate Cluster 标签页:

字段示例值说明
Cluster Name *demo-cluster建议与 k8s_psat 配置里的集群名一致
Cluster Type *Kubernetes下拉选 Kubernetes / VMs,或选自定义后手填
Cluster Domain Name/URLexample.org集群域名 / 入口
Cluster Managed Byplatform-team责任人 / 团队
Assign Agents To Cluster勾选已认证的 agent把节点归类到该集群(可多选)

Create Cluster,页面回显绿色 --CLUSTER SUCCESSFULLY CREATED--。命令行核验:

bash
curl -s localhost:10000/api/tornjak/clusters/list | jq '.clusters[] | {name, platformType, managedBy, agentsList}'
# {
#   "name": "demo-cluster",
#   "platformType": "Kubernetes",
#   "managedBy": "platform-team",
#   "agentsList": ["spiffe://example.org/spire/agent/k8s_psat/demo-cluster/…"]
# }

新建注册条目 · Entry

顶部 Entries 看列表;新建走 /entry/create,两种方式:

方式一 · Custom Entry Form(手动,推荐先用这个理解字段) —— 展开表单填三个必填项:

字段示例值说明
Parent ID *spiffe://example.org/spire/agent/k8s_psat/demo-cluster/…从下拉里选那台 agent;若建节点条目则选 …/spire/server
SPIFFE ID *spiffe://example.org/ns/default/sa/demo-web要签发给工作负载的身份
Selectors *k8s:ns:default,k8s:sa:default逗号分隔的 type:value,决定"谁能领这张证"

可选项:TtlEntry ExpiryFederates WithDNS NamesAdmin FlagDown Stream。点 CREATE ENTRY 即写入 SPIRE。

⚠️ Parent ID 是下拉必选项:必须从下拉里点中一个值(不能只填 SPIFFE ID),否则点 CREATE ENTRY 会静默无反应——这是最容易踩的坑。

方式二 · Upload JSON —— 上传一个 SPIRE entry 格式的 .json 批量创建,适合把 entry 纳入 GitOps / 版本管理。

建完立即写进 SPIRE,Entries 列表能看到,而且和 SPIRE 自己的 CLI 完全对得上(sidecar 与 spire-server 同 Pod,是同一份数据):

bash
# Tornjak API
curl -s localhost:10000/api/entry/list | jq '.entries[].spiffe_id.path'
# "/openclaw-test"
# "/ns/default/sa/demo-web"   ← 刚在 UI 里建的

# SPIRE 官方 CLI(distroless 镜像,用二进制绝对路径)
kubectl -n spire exec spire-server-0 -c spire-server -- \
  /opt/spire/bin/spire-server entry show -spiffeID spiffe://example.org/ns/default/sa/demo-web
# Found 1 entry
# Entry ID     : ba87dc21-…
# SPIFFE ID    : spiffe://example.org/ns/default/sa/demo-web
# Parent ID    : spiffe://example.org/spire/agent/k8s_psat/demo-cluster/…
# Selector     : k8s:ns:default
# Selector     : k8s:sa:default

想彻底搞懂 Parent ID / Selector 怎么决定身份的"有 / 无",配一篇 什么是 SVID(形式 vs 实际) 一起读。

编辑 / 删除注册条目

删除:进 Entries 列表,勾选目标行左侧的复选框——顶部随即弹出批量操作条,点 Delete 即从 SPIRE 移除。真机验证:删掉一条临时条目 spiffe://example.org/tmp-delete-me 后,列表从 3 条变 2 条。

bash
# 删除前后对照
curl -s localhost:10000/api/entry/list | jq '.entries | length'
# 3   →(在 UI 里勾选 + Delete)→   2

本版 Tornjak 没有"编辑 entry"的界面(路由只有 /entry/create/entries)。要改一条 entry,两条路:

  1. 删了重建 —— UI 里删掉旧的,再 Create Entry 一条新的;
  2. SPIRE CLI 原地改 —— spire-server entry update -entryID <id> -selector k8s:ns:prod …(sidecar 与 spire-server 同 Pod,是同一份数据)。

给 VM 发 Join Token · createjointoken

Kubernetes 里的 agent 用 k8s_psat 自动入网;VM / 裸机 则常用 join token:先在 server 端签一个一次性令牌,VM 拿它启动 agent 完成节点认证。Tornjak 把这步也搬进了界面(/agent/createjointoken):

字段示例说明
Time to live (TTL)500令牌有效期(秒),过期即作废
Token留空留空自动生成 UUID;也可自填
SPIFFE IDspiffe://example.org/myvm(可选)给该节点指定身份别名

Create Join Token,页面回显(真机结果):

Success:{
  "value": "2ea3c5b7-322c-4443-ba58-c6dac0214157",
  "expires_at": 1783856814
}

join_token 节点认证器是 SPIRE server 内建的,无需在 plugins 里额外配置——所以哪怕你的 server 只配了 k8s_psat,这个功能照样能用。

拿到令牌,在目标 VM 上启动 agent 完成认证:

bash
spire-agent run -joinToken 2ea3c5b7-322c-4443-ba58-c6dac0214157 \
  -serverAddress <server-host> -serverPort 8081
# 上线后该节点身份:spiffe://example.org/spire/agent/join_token/2ea3c5b7-…

之后给这台 VM 上的工作负载建 entry 时,把 Parent ID 选成这个节点的 SPIFFE ID(即上面《新建注册条目》里选 agent 的那一步)。

安全提醒(重要)

本文这套是本地演示 / 内网自用的最简形态:

  • 后端 HTTP 无鉴权,仅通过 port-forward 暴露在你本机 localhost,不要用 NodePort / Ingress 直接把它挂到公网。
  • 生产环境应启用 Tornjak 的 TLS + 用户管理(如 Keycloak / mTLS),并把后端放在鉴权网关之后。
  • Tornjak 能注册条目 = 能影响谁拿到什么身份,权限要当作 SPIRE Server 管理员对待。

回滚

Tornjak 是纯加法,删掉即可回到原样:

bash
kubectl -n spire delete deploy/tornjak-frontend svc/tornjak-frontend svc/tornjak-backend cm/tornjak-config
# 去掉 sidecar:把 patch 里新增的 container/volumes/volumeMount 从 StatefulSet 移除
kubectl -n spire edit statefulset spire-server

延伸