Skip to content

Commit a240b5d

Browse files
Merge pull request #364 from mahil-2040/docs/add-spire-auth-guide
docs: add tutorial for securing internal traffic with SPIRE (mTLS)
2 parents 16e8330 + bd05fe5 commit a240b5d

7 files changed

Lines changed: 339 additions & 8 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# Securing Internal Traffic with SPIRE (mTLS)
2+
3+
This task shows you how to enable mutual TLS (mTLS) between AgentCube's
4+
control-plane components using [SPIRE](https://spiffe.io/docs/latest/spire-about/spire-concepts/).
5+
By the end, every request between the Router and WorkloadManager will be
6+
cryptographically authenticated using short-lived X.509 certificates that rotate
7+
automatically.
8+
9+
## Before you begin
10+
11+
1. Follow the [Getting Started](../getting-started.md) guide to install
12+
AgentCube on your cluster. **Do not** enable SPIRE during the initial
13+
installation - this tutorial walks through that step explicitly.
14+
15+
2. Make sure you have the following tools installed:
16+
- [`kubectl`](https://kubernetes.io/docs/tasks/tools/) (v1.25+)
17+
- [`helm`](https://helm.sh/docs/intro/install/) (v3.12+)
18+
19+
3. Confirm AgentCube is running without SPIRE:
20+
21+
```bash
22+
kubectl get pods -n agentcube
23+
```
24+
25+
You should see the Router and WorkloadManager pods in `Running` state, each
26+
showing `1/1` containers ready (no sidecar yet):
27+
28+
```
29+
NAME READY STATUS RESTARTS AGE
30+
agentcube-router-7fbb7b54c-7khq5 1/1 Running 0 8s
31+
workloadmanager-6c44454f68-zmfcc 1/1 Running 0 8s
32+
```
33+
34+
> **Tip:**
35+
> If you are running on a local [Kind](https://kind.sigs.k8s.io/) or
36+
> [Minikube](https://minikube.sigs.k8s.io/) cluster, you will need to pass two
37+
> extra overrides in the Helm upgrade command shown below. These are already
38+
> included in the instructions, so just keep them in.
39+
40+
41+
## What gets deployed
42+
43+
When you enable SPIRE, the Helm chart creates the following additional resources
44+
inside your cluster:
45+
46+
| Resource | Kind | Purpose |
47+
|---|---|---|
48+
| `spire-server` | StatefulSet (1 replica) | Central certificate authority. Runs the SPIRE Controller Manager as a sidecar. |
49+
| `spire-agent` | DaemonSet | Runs on every node. Attests workloads and delivers certificates. |
50+
| `ClusterSPIFFEID` (×2) | CRD | Declarative identity registration for the Router and WorkloadManager. |
51+
| `spiffe-helper` sidecar | Container (injected) | Fetches and rotates certificates inside the Router and WorkloadManager pods. |
52+
53+
The Router and WorkloadManager pods will each go from `1/1` to `2/2` containers
54+
(the main process + the `spiffe-helper` sidecar).
55+
56+
## Step 1 - Install the SPIRE Controller Manager CRDs
57+
58+
The SPIRE Controller Manager watches `ClusterSPIFFEID` custom resources. These
59+
CRDs must be present in the cluster **before** the Helm upgrade, otherwise the
60+
chart will fail to create them.
61+
62+
```bash
63+
kubectl apply -k "https://github.com/spiffe/spire-controller-manager/config/crd?ref=v0.6.4"
64+
```
65+
66+
Verify the CRD was installed:
67+
68+
```bash
69+
kubectl get crd clusterspiffeids.spire.spiffe.io
70+
```
71+
72+
Expected output:
73+
74+
```
75+
NAME CREATED AT
76+
clusterspiffeids.spire.spiffe.io 2026-06-01T16:22:32Z
77+
```
78+
79+
## Step 2 - Upgrade the Helm release with SPIRE enabled
80+
81+
Run the Helm upgrade with `spire.enabled=true`. Keep `--reuse-values` so your
82+
existing install-time settings (for example Redis, images, RBAC, or service
83+
accounts) are preserved while enabling SPIRE. The extra `--set` flags for
84+
`insecureBootstrap` and `skipKubeletVerification` are needed for local
85+
development clusters (Kind / Minikube). On a production cluster with proper
86+
kubelet certificates, you can omit them.
87+
88+
```bash
89+
helm upgrade agentcube manifests/charts/base \
90+
-n agentcube \
91+
--reuse-values \
92+
--set spire.enabled=true \
93+
--set spire.agent.insecureBootstrap=true \
94+
--set spire.agent.skipKubeletVerification=true
95+
```
96+
97+
This single command deploys the full SPIRE infrastructure **and** injects the
98+
`spiffe-helper` sidecar into the Router and WorkloadManager pods.
99+
100+
Wait for everything to become ready:
101+
102+
```bash
103+
kubectl rollout status statefulset/spire-server -n agentcube --timeout=120s
104+
kubectl rollout status daemonset/spire-agent -n agentcube --timeout=120s
105+
kubectl rollout status deployment/agentcube-router -n agentcube --timeout=120s
106+
kubectl rollout status deployment/workloadmanager -n agentcube --timeout=120s
107+
```
108+
109+
## Step 3 - Verify SPIRE is healthy
110+
111+
Check that the SPIRE Server is up and has registered agents:
112+
113+
```bash
114+
kubectl exec -n agentcube statefulset/spire-server -c spire-server -- \
115+
/opt/spire/bin/spire-server agent list
116+
```
117+
118+
You should see at least one agent entry (one per cluster node):
119+
120+
```
121+
Found 1 attested agent(s):
122+
123+
SPIFFE ID : spiffe://cluster.local/spire/agent/k8s_psat/agentcube-cluster/67790303-3657-42d6-bf4f-c3833ec6dd5e
124+
Attestation type : k8s_psat
125+
...
126+
```
127+
128+
Next, confirm the identity registrations were picked up from the
129+
`ClusterSPIFFEID` resources:
130+
131+
```bash
132+
kubectl exec -n agentcube statefulset/spire-server -c spire-server -- \
133+
/opt/spire/bin/spire-server entry show
134+
```
135+
136+
You should see entries for both the Router and WorkloadManager, with SPIFFE IDs
137+
following the format
138+
`spiffe://cluster.local/ns/agentcube/sa/<service-account>`:
139+
140+
```
141+
Entry ID : bfd507ec-10d8-43e5-b984-861a3ff81167
142+
SPIFFE ID : spiffe://cluster.local/ns/agentcube/sa/agentcube-router
143+
Parent ID : spiffe://cluster.local/spire/agent/k8s_psat/agentcube-cluster/67790303-3657-42d6-bf4f-c3833ec6dd5e
144+
Revision : 0
145+
146+
Entry ID : 21e3ba6f-ad13-4076-9e08-90a2d4ff518f
147+
SPIFFE ID : spiffe://cluster.local/ns/agentcube/sa/workloadmanager
148+
Parent ID : spiffe://cluster.local/spire/agent/k8s_psat/agentcube-cluster/67790303-3657-42d6-bf4f-c3833ec6dd5e
149+
Revision : 0
150+
```
151+
152+
## Step 4 - Verify the sidecar and certificates
153+
154+
Confirm that both the Router and WorkloadManager pods now show `2/2` containers
155+
(the main container + the `spiffe-helper` sidecar):
156+
157+
```bash
158+
kubectl get pods -n agentcube
159+
```
160+
161+
Expected output:
162+
163+
```
164+
NAME READY STATUS RESTARTS AGE
165+
agentcube-router-574d98b76-tr2nr 2/2 Running 5 (2m24s ago) 3m17s
166+
spire-agent-8r9jx 1/1 Running 3 (2m44s ago) 3m17s
167+
spire-server-0 2/2 Running 0 3m17s
168+
workloadmanager-5797888bd4-jm2qj 2/2 Running 3 (118s ago) 3m17s
169+
```
170+
171+
Check the Router logs to confirm mTLS is active. You should see a log line
172+
indicating it is waiting for, and then successfully loading, the certificates:
173+
174+
```bash
175+
kubectl logs -n agentcube deployment/agentcube-router -c agentcube-router | grep -i mtls
176+
```
177+
178+
Expected output:
179+
180+
```
181+
I0601 16:25:21.444099 1 main.go:64] Waiting for Router mTLS cert/key/CA files
182+
I0601 16:25:21.444259 1 wait.go:46] All mTLS cert/key/CA files are present
183+
I0601 16:25:21.445161 1 session_manager.go:84] Using https:// for WORKLOAD_MANAGER_URL because mTLS is configured
184+
I0601 16:25:21.445482 1 session_manager.go:93] Router→WorkloadManager mTLS enabled: expecting server SPIFFE ID spiffe://cluster.local/ns/agentcube/sa/workloadmanager
185+
```
186+
187+
Do the same for the WorkloadManager:
188+
189+
```bash
190+
kubectl logs -n agentcube deployment/workloadmanager -c workloadmanager | grep -i mtls
191+
```
192+
193+
Expected output:
194+
195+
```
196+
I0601 16:25:22.561316 1 main.go:80] Waiting for WorkloadManager mTLS cert/key/CA files
197+
I0601 16:25:22.561931 1 wait.go:46] All mTLS cert/key/CA files are present
198+
I0601 16:25:22.678777 1 server.go:218] WorkloadManager mTLS enabled: accepting clients with valid SPIRE-provisioned certificates
199+
```
200+
201+
## Step 5 - Test it end-to-end
202+
203+
Deploy a simple agent and invoke it through the Router to confirm the full
204+
mTLS-secured path works:
205+
206+
```bash
207+
kubectl apply -f - <<EOF
208+
apiVersion: runtime.agentcube.volcano.sh/v1alpha1
209+
kind: AgentRuntime
210+
metadata:
211+
name: mtls-test
212+
namespace: default
213+
spec:
214+
targetPort:
215+
- pathPrefix: "/"
216+
port: 8000
217+
protocol: "HTTP"
218+
podTemplate:
219+
spec:
220+
containers:
221+
- name: agent
222+
image: python:3.11-slim
223+
command: ["python3", "-m", "http.server", "8000"]
224+
resources:
225+
requests:
226+
cpu: "100m"
227+
memory: "128Mi"
228+
limits:
229+
cpu: "500m"
230+
memory: "512Mi"
231+
EOF
232+
```
233+
234+
Open a new terminal and port-forward the Router:
235+
236+
```bash
237+
kubectl port-forward -n agentcube svc/agentcube-router 8080:8080
238+
```
239+
240+
In your original terminal, send a request to the root path of the sandbox:
241+
242+
```bash
243+
curl -i http://localhost:8080/v1/namespaces/default/agent-runtimes/mtls-test/invocations/
244+
```
245+
246+
If the mTLS handshake between Router and WorkloadManager succeeds, you will see
247+
a `200 OK` response with a directory listing from the python server (or a `502`
248+
while the sandbox is still booting - just retry after a few seconds). A
249+
TLS-related error in the Router logs would indicate a misconfiguration.
250+
251+
## Understanding what changed
252+
253+
Here is how each component is configured behind the scenes. You do **not** need
254+
to set any of these flags manually - the Helm chart handles it when
255+
`spire.enabled=true`.
256+
257+
### Router (mTLS client)
258+
259+
The Helm chart passes these flags to the Router binary:
260+
261+
```
262+
--mtls-cert=/run/spire/certs/svid.pem
263+
--mtls-key=/run/spire/certs/svid_key.pem
264+
--mtls-ca=/run/spire/certs/svid_bundle.pem
265+
```
266+
267+
When all three are present, the Router creates a dedicated HTTPS transport for
268+
its WorkloadManager connection. It verifies that the WorkloadManager's
269+
certificate contains the expected SPIFFE ID
270+
(`spiffe://cluster.local/ns/agentcube/sa/workloadmanager`).
271+
272+
### WorkloadManager (mTLS server)
273+
274+
The Helm chart passes these flags to the WorkloadManager binary:
275+
276+
```
277+
--tls-cert=/run/spire/certs/svid.pem
278+
--tls-key=/run/spire/certs/svid_key.pem
279+
--tls-ca=/run/spire/certs/svid_bundle.pem
280+
```
281+
282+
When the CA file is present, the WorkloadManager starts its HTTP server with
283+
mTLS enabled. It requires every connecting client to present a valid certificate
284+
signed by the trusted CA. Authorization is handled at the application layer, not
285+
at the TLS level.
286+
287+
### Certificate rotation
288+
289+
The `spiffe-helper` sidecar continuously fetches fresh SVIDs from the local
290+
SPIRE Agent and writes them to a shared volume at `/run/spire/certs/`. A
291+
`CertWatcher` inside each component watches that directory using `fsnotify` and
292+
hot-reloads the certificates without dropping any active connections. The default
293+
SVID TTL is **1 hour**.
294+
295+
### What about sandboxes?
296+
297+
mTLS is only used for the control-plane path (Router ↔ WorkloadManager).
298+
The Router→Sandbox connection continues to use the existing JWT-based
299+
authentication. This keeps sandbox startup latency low and avoids injecting
300+
SPIRE dependencies into user-defined runtime containers.
301+
302+
## Cleanup
303+
304+
Remove the test agent:
305+
306+
```bash
307+
kubectl delete agentruntime mtls-test -n default
308+
```
309+
310+
If you want to **disable** SPIRE and go back to plain HTTP between the control
311+
plane components, run the Helm upgrade again with `spire.enabled=false`:
312+
313+
```bash
314+
helm upgrade agentcube manifests/charts/base \
315+
-n agentcube \
316+
--reuse-values \
317+
--set spire.enabled=false
318+
```
319+
320+
This removes all SPIRE workloads (Server, Agent), sidecars, and ClusterSPIFFEID
321+
resources from this Helm release. The Router/WorkloadManager pods will restart
322+
with `1/1` containers.
323+
324+
To also remove the SPIRE Controller Manager CRDs:
325+
326+
```bash
327+
kubectl delete -k "https://github.com/spiffe/spire-controller-manager/config/crd?ref=v0.6.4"
328+
```

pkg/mtls/loader_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func TestLoadServerConfig_InvalidCAPEM(t *testing.T) {
263263
// --- SPIFFE ID verification ---
264264

265265
func TestVerifyServerCert_MatchingID(t *testing.T) {
266-
spiffeID := "spiffe://cluster.local/ns/agentcube-system/sa/workloadmanager"
266+
spiffeID := "spiffe://cluster.local/ns/agentcube/sa/workloadmanager"
267267
certFile, _, caFile := generateTestCertsWithSPIFFEID(t, spiffeID)
268268

269269
rawCert := readRawCert(t, certFile)
@@ -282,7 +282,7 @@ func TestVerifyServerCert_MatchingID(t *testing.T) {
282282
}
283283

284284
func TestVerifyServerCert_UntrustedCA(t *testing.T) {
285-
spiffeID := "spiffe://cluster.local/ns/agentcube-system/sa/workloadmanager"
285+
spiffeID := "spiffe://cluster.local/ns/agentcube/sa/workloadmanager"
286286
certFile, _, _ := generateTestCertsWithSPIFFEID(t, spiffeID)
287287

288288
// Use a DIFFERENT CA — chain verification should fail

pkg/mtls/spiffeid.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ import (
2525
const (
2626
defaultTrustDomain = "cluster.local"
2727
trustDomainEnvVar = "AGENTCUBE_SPIFFE_TRUST_DOMAIN"
28-
defaultNamespace = "agentcube-system"
28+
defaultNamespace = "agentcube"
2929
namespaceEnvVar = "AGENTCUBE_NAMESPACE"
3030
)
3131

3232
// SPIFFE IDs for AgentCube components.
3333
// These follow the Istio-convention format: spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>.
3434
// The trust domain defaults to cluster.local and can be overridden with AGENTCUBE_SPIFFE_TRUST_DOMAIN
3535
// to match the SPIRE trust domain configured by deployment tooling.
36-
// The namespace defaults to agentcube-system and can be overridden with AGENTCUBE_NAMESPACE.
36+
// The namespace defaults to agentcube and can be overridden with AGENTCUBE_NAMESPACE.
3737
var (
3838
// RouterSPIFFEID is the SPIFFE identity for the Router component.
3939
RouterSPIFFEID = componentSPIFFEID(configuredTrustDomain(), configuredNamespace(), "agentcube-router")

pkg/mtls/spiffeid_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ func TestConfiguredNamespace(t *testing.T) {
4343
}
4444

4545
func TestComponentSPIFFEID(t *testing.T) {
46-
got := componentSPIFFEID("example.org", "agentcube-system", "agentcube-router")
47-
want := "spiffe://example.org/ns/agentcube-system/sa/agentcube-router"
46+
got := componentSPIFFEID("example.org", "agentcube", "agentcube-router")
47+
want := "spiffe://example.org/ns/agentcube/sa/agentcube-router"
4848
if got != want {
4949
t.Fatalf("componentSPIFFEID() = %q, want %q", got, want)
5050
}

pkg/mtls/wait.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import (
2121
"os"
2222
"strings"
2323
"time"
24+
25+
"k8s.io/klog/v2"
2426
)
2527

2628
// DefaultCertificateFileWaitTimeout bounds the startup race while spiffe-helper writes the initial SVID files.
@@ -41,6 +43,7 @@ func WaitForCertificateFiles(cfg Config, timeout time.Duration) error {
4143
return fmt.Errorf("failed to access mTLS cert/key/CA files: %w", err)
4244
}
4345
if exist {
46+
klog.Infof("All mTLS cert/key/CA files are present")
4447
return nil
4548
}
4649
missing = currentMissing

0 commit comments

Comments
 (0)