给现有 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 | 原有容器(不改镜像) | 8081 | SPIRE Server,socket 在 /tmp/spire-server/private/api.sock |
tornjak-backend | 新增 sidecar | 10000 | 读 socket,把 SPIRE 管理 API 封装成 REST |
tornjak-frontend | 独立 Deployment | 3000 | React 单页应用,浏览器里直接调后端 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 配置里看):
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 侧载进去,绕开节点的拉取链路:
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 监听哪个端口、以及一个存自身元数据的小库。
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、镜像等一概不动。
# 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-datakubectl -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
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 }]kubectl -n spire apply -f tornjak-frontend.yaml第 4 步 · 访问
前端(SPA)在浏览器里跑,要同时能到前端和后端,所以开两个 port-forward:
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 数据(不是空壳):
# 健康
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 在容器启动时写)可这样确认:
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/clustermanagement 的 Create Cluster 标签页:
| 字段 | 示例值 | 说明 |
|---|---|---|
| Cluster Name * | demo-cluster | 建议与 k8s_psat 配置里的集群名一致 |
| Cluster Type * | Kubernetes | 下拉选 Kubernetes / VMs,或选自定义后手填 |
| Cluster Domain Name/URL | example.org | 集群域名 / 入口 |
| Cluster Managed By | platform-team | 责任人 / 团队 |
| Assign Agents To Cluster | 勾选已认证的 agent | 把节点归类到该集群(可多选) |
点 Create Cluster,页面回显绿色 --CLUSTER SUCCESSFULLY CREATED--。命令行核验:
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,决定"谁能领这张证" |
可选项:Ttl、Entry Expiry、Federates With、DNS Names、Admin Flag、Down 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,是同一份数据):
# 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 条。
# 删除前后对照
curl -s localhost:10000/api/entry/list | jq '.entries | length'
# 3 →(在 UI 里勾选 + Delete)→ 2本版 Tornjak 没有"编辑 entry"的界面(路由只有
/entry/create和/entries)。要改一条 entry,两条路:
- 删了重建 —— UI 里删掉旧的,再
Create Entry一条新的;- 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 ID | spiffe://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 完成认证:
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 是纯加法,删掉即可回到原样:
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延伸
- 想理解后端调的是哪套 SPIRE API → Server 详解 · 插件框架与 API
- 想搞懂 entry / selector 怎么决定身份 → 什么是 SVID(形式 vs 实际)
- 想上生产、要高可用 → 高可用与大集群部署
- 需要我们帮你把管理 / 审计 / 可视化一起落地 → 高标准交付 · 联系我们