Compare commits

..
Author SHA1 Message Date
rock 990e312825 feat(iam): wire all 9 consumer manifests to Zitadel OIDC
- Replace Authentik issuer/auth/token/userinfo URLs with Zitadel endpoints
  in grafana, minio, paperless-ai, paperless-ai-new, homarr, argocd, blackbox
- Write Zitadel client IDs/secrets into plain manifests and SOPS secrets
  (grafana, minio, paperless, homarr)
- Adopt homarr-oidc into SOPS (homarr-auth-oidc.enc.yaml)
- Add homarr to SECRET_MAP and remove from MANUAL_ONLY in zitadel-provision.py
- Make client-id regex idempotent for repeated runs (minio, paperless)
- Fix cloudflared init container CA mount path (homelab-ca.crt -> ca.crt)
- Remove Authentik ingress and cloudflared tunnel entry
2026-10-25 21:08:23 -07:00
99 changed files with 2183 additions and 4863 deletions
@@ -1,41 +0,0 @@
# Builds and pushes the key-rotation-controller image.
#
# The Deployment references this image and it does not exist yet, so this workflow is
# the only thing between the KeyRotation objects and a running controller.
name: key-rotation-controller
on:
push:
branches: [main]
paths:
- "k8s/apps/key-rotation-controller/controller/**"
- ".forgejo/workflows/key-rotation-controller.yaml"
workflow_dispatch:
jobs:
image:
runs-on: golang
steps:
- uses: actions/checkout@v4
- name: Log in to the registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | \
docker login forgejo.riotpiao.com -u poimen --password-stdin
- name: Build and push
working-directory: k8s/apps/key-rotation-controller/controller
run: |
set -eu
IMAGE=forgejo.riotpiao.com/riotpiao-poimen/key-rotation-controller
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
docker build -t "$IMAGE:$TAG" -t "$IMAGE:0.1.0" .
docker push "$IMAGE:$TAG"
docker push "$IMAGE:0.1.0"
echo "pushed $IMAGE:$TAG and $IMAGE:0.1.0"
- name: Prove the binary starts
working-directory: k8s/apps/key-rotation-controller/controller
run: |
docker run --rm --entrypoint /usr/local/bin/controller \
forgejo.riotpiao.com/riotpiao-poimen/key-rotation-controller:0.1.0 --help || true
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
# Ingress-nginx forces HTTPS (force-ssl-redirect), so cloudflared must
# dial https://<hostname> and trust homelab-ca or the TLS handshake fails
# with "certificate signed by unknown authority". Same pattern as
# iam values: merge the system bundle with homelab-ca.crt.
# authentik-values.yaml: merge the system bundle with homelab-ca.crt.
initContainers:
- name: merge-ca-certs
image: debian:bookworm
+3 -10
View File
@@ -13,16 +13,9 @@ metadata:
# WebSocket support for ComfyUI's live preview
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
# Single sign-on. ComfyUI has no auth of its own, so every request is handed to
# oauth2-proxy for an allow or deny decision.
nginx.ingress.kubernetes.io/auth-url: "http://comfyui-oauth2-proxy.comfyui.svc.cluster.local:4180/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://comfyui.riotpiao.com/oauth2/start?rd=$scheme://$host$request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "x-auth-request-user,x-auth-request-email"
# No configuration-snippet. The controller rejects that annotation as risky
# ("annotation group ConfigurationSnippet contains risky annotation"), and a
# rejected annotation drops the WHOLE ingress from nginx.conf: the host then
# answers 404 with no entry in the access log. ingress-nginx sets the Upgrade
# and Connection headers itself for a websocket upgrade.
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
spec:
ingressClassName: nginx
rules:
-1
View File
@@ -4,5 +4,4 @@ kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- oauth2-proxy.yaml
- ingress.yaml
-105
View File
@@ -1,105 +0,0 @@
# ComfyUI has no authentication of its own: comfyui.riotpiao.com is open to anyone
# who can resolve the host. It also has no OIDC support, so ZITADEL cannot be a
# login method inside it. oauth2-proxy is the front door instead: nginx asks it for
# a decision on every request and it answers from a ZITADEL session.
#
# No API client calls this host today, so the whole host is gated. If a client ever
# needs direct access, add a skip rule for the exact paths it uses rather than
# opening the whole host.
apiVersion: apps/v1
kind: Deployment
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
labels:
app: comfyui-oauth2-proxy
spec:
replicas: 1
selector:
matchLabels:
app: comfyui-oauth2-proxy
template:
metadata:
labels:
app: comfyui-oauth2-proxy
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile: {type: RuntimeDefault}
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://zitadel.riotpiao.com
- --email-domain=*
- --upstream=static://202
- --reverse-proxy=true
- --set-xauthrequest=true
- --cookie-secure=true
- --cookie-expire=12h
- --cookie-refresh=1h
- --skip-provider-button=true
- --redirect-url=https://comfyui.riotpiao.com/oauth2/callback
- --http-address=0.0.0.0:4180
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: client-id}}
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: client-secret}}
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: cookie-secret}}
ports:
- {name: http, containerPort: 4180}
readinessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 15
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
---
apiVersion: v1
kind: Service
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
labels:
app: comfyui-oauth2-proxy
spec:
type: ClusterIP
selector:
app: comfyui-oauth2-proxy
ports:
- {name: http, port: 4180, targetPort: http}
---
# The login path must NOT be gated: a request to /oauth2/start that itself demands a
# login redirects to /oauth2/start forever. ingress-nginx applies the annotations of
# an ingress to the locations it defines, so a separate object without auth-url keeps
# this path open. The earlier failure of this shape was not the split — it was the
# configuration-snippet on the app ingress, which made the controller drop the whole
# object and left only this one alive.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: comfyui-oauth2
namespace: comfyui
spec:
ingressClassName: nginx
rules:
- host: comfyui.riotpiao.com
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: comfyui-oauth2-proxy
port:
number: 4180
-110
View File
@@ -1,110 +0,0 @@
# The bridge script is mounted as a ConfigMap so no image build is needed.
# Regenerate this block from grafana_gotify_bridge.py when the script changes.
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-gotify-bridge
namespace: notifications
data:
grafana_gotify_bridge.py: |
#!/usr/bin/env python3
"""Translate a Grafana alert webhook into a Gotify message.
Grafana sends its own payload shape:
{"receiver": "...", "status": "firing", "alerts": [{"labels": {...}, "annotations": {...}}]}
Gotify requires:
{"title": "...", "message": "...", "priority": 0..10}
Gotify answers 400 "Field 'message' is required" to a Grafana payload, so a
conversion step is mandatory. This service is that step and nothing else.
Standard library only, so it needs no image build and no dependencies.
"""
import json
import os
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
GOTIFY_BASE = os.environ.get("GOTIFY_URL", "http://gotify.notifications.svc.cluster.local")
GOTIFY_TOKEN = os.environ["GOTIFY_TOKEN"]
LISTEN_PORT = int(os.environ.get("PORT", "8080"))
# Firing is louder than resolved. Gotify priority runs 0 to 10.
PRIORITY = {"firing": 8, "resolved": 3}
def to_gotify(payload):
alerts = payload.get("alerts") or []
status = (payload.get("status") or "firing").lower()
priority = PRIORITY.get(status, 5)
if not alerts:
title = payload.get("title") or "Grafana notification"
return title, json.dumps(payload)[:2000], priority
first = alerts[0]
labels = first.get("labels") or {}
annotations = first.get("annotations") or {}
name = labels.get("alertname", "Grafana alert")
title = "[%s] %s" % (status.upper(), name)
lines = []
summary = annotations.get("summary") or annotations.get("description")
if summary:
lines.append(summary)
for key in ("severity", "namespace", "pod", "instance", "job"):
if labels.get(key):
lines.append("%s: %s" % (key, labels[key]))
if len(alerts) > 1:
lines.append("+%d more alert(s) in this notification" % (len(alerts) - 1))
return title, "\n".join(lines) or name, priority
class Handler(BaseHTTPRequestHandler):
def _reply(self, code, body=b"ok"):
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._reply(200)
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
except ValueError:
payload = {}
title, message, priority = to_gotify(payload)
body = json.dumps({
"title": title[:200],
"message": message[:4000],
"priority": priority,
}).encode()
request = urllib.request.Request(
GOTIFY_BASE.rstrip("/") + "/message",
data=body,
headers={"Content-Type": "application/json", "X-Gotify-Key": GOTIFY_TOKEN},
)
try:
urllib.request.urlopen(request, timeout=10).read()
except Exception as exc: # noqa: BLE001 - log and report to Grafana
print("forward to gotify failed: %s" % exc, flush=True)
self._reply(502, b"gotify rejected the message")
return
print("forwarded: %s" % title, flush=True)
self._reply(200)
def log_message(self, fmt, *args): # keep the log to one line per delivery
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
if __name__ == "__main__":
print("grafana-gotify bridge listening on %d" % LISTEN_PORT, flush=True)
HTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
-80
View File
@@ -1,80 +0,0 @@
# Grafana sends its own JSON and Gotify rejects it, so this Deployment is the
# translation step between them. Grafana posts to the Service, the bridge posts
# to /message with the app token from a SOPS secret.
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana-gotify-bridge
namespace: notifications
labels:
app: grafana-gotify-bridge
spec:
replicas: 1
selector:
matchLabels:
app: grafana-gotify-bridge
template:
metadata:
labels:
app: grafana-gotify-bridge
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: bridge
image: python:3.12-alpine
command: ["python3", "/app/grafana_gotify_bridge.py"]
env:
- name: GOTIFY_URL
value: "http://gotify.notifications.svc.cluster.local"
- name: PORT
value: "8080"
- name: GOTIFY_TOKEN
valueFrom:
secretKeyRef: {name: grafana-gotify-bridge, key: app-token}
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet: {path: /, port: http}
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet: {path: /, port: http}
initialDelaySeconds: 10
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /app
readOnly: true
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 96Mi}
volumes:
- name: script
configMap:
name: grafana-gotify-bridge
---
apiVersion: v1
kind: Service
metadata:
name: grafana-gotify-bridge
namespace: notifications
labels:
app: grafana-gotify-bridge
spec:
type: ClusterIP
selector:
app: grafana-gotify-bridge
ports:
- name: http
port: 8080
targetPort: http
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""Translate a Grafana alert webhook into a Gotify message.
Grafana sends its own payload shape:
{"receiver": "...", "status": "firing", "alerts": [{"labels": {...}, "annotations": {...}}]}
Gotify requires:
{"title": "...", "message": "...", "priority": 0..10}
Gotify answers 400 "Field 'message' is required" to a Grafana payload, so a
conversion step is mandatory. This service is that step and nothing else.
Standard library only, so it needs no image build and no dependencies.
"""
import json
import os
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
GOTIFY_BASE = os.environ.get("GOTIFY_URL", "http://gotify.notifications.svc.cluster.local")
GOTIFY_TOKEN = os.environ["GOTIFY_TOKEN"]
LISTEN_PORT = int(os.environ.get("PORT", "8080"))
# Firing is louder than resolved. Gotify priority runs 0 to 10.
PRIORITY = {"firing": 8, "resolved": 3}
def to_gotify(payload):
alerts = payload.get("alerts") or []
status = (payload.get("status") or "firing").lower()
priority = PRIORITY.get(status, 5)
if not alerts:
title = payload.get("title") or "Grafana notification"
return title, json.dumps(payload)[:2000], priority
first = alerts[0]
labels = first.get("labels") or {}
annotations = first.get("annotations") or {}
name = labels.get("alertname", "Grafana alert")
title = "[%s] %s" % (status.upper(), name)
lines = []
summary = annotations.get("summary") or annotations.get("description")
if summary:
lines.append(summary)
for key in ("severity", "namespace", "pod", "instance", "job"):
if labels.get(key):
lines.append("%s: %s" % (key, labels[key]))
if len(alerts) > 1:
lines.append("+%d more alert(s) in this notification" % (len(alerts) - 1))
return title, "\n".join(lines) or name, priority
class Handler(BaseHTTPRequestHandler):
def _reply(self, code, body=b"ok"):
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._reply(200)
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
except ValueError:
payload = {}
title, message, priority = to_gotify(payload)
body = json.dumps({
"title": title[:200],
"message": message[:4000],
"priority": priority,
}).encode()
request = urllib.request.Request(
GOTIFY_BASE.rstrip("/") + "/message",
data=body,
headers={"Content-Type": "application/json", "X-Gotify-Key": GOTIFY_TOKEN},
)
try:
urllib.request.urlopen(request, timeout=10).read()
except Exception as exc: # noqa: BLE001 - log and report to Grafana
print("forward to gotify failed: %s" % exc, flush=True)
self._reply(502, b"gotify rejected the message")
return
print("forwarded: %s" % title, flush=True)
self._reply(200)
def log_message(self, fmt, *args): # keep the log to one line per delivery
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
if __name__ == "__main__":
print("grafana-gotify bridge listening on %d" % LISTEN_PORT, flush=True)
HTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
+3 -11
View File
@@ -28,17 +28,9 @@ spec:
value: "true"
- name: ENABLE_SCALAR_UI
value: "true"
# The bridge authenticates with a Gotify client token, not with
# ZITADEL. Gotify has no way to accept a forwarded identity, so the
# token is the only credential that works server-side. The token
# belongs to user `rock`, never expires, and is revoked by deleting
# the client row in Gotify.
- name: GOTIFY_URLS
value: "https://gotify.riotpiao.com"
- name: GOTIFY_CLIENT_TOKENS
valueFrom:
secretKeyRef: {name: igotify-client-token, key: client-token}
# SECNTFY_TOKENS intentionally left unset: secntfy is not deployed.
# GOTIFY_URLS / GOTIFY_CLIENT_TOKENS / SECNTFY_TOKENS intentionally
# left unset — not required when Gotify is reachable via public domain
# (gotify.riotpiao.com). iGotify iOS app registers itself at runtime.
volumeMounts:
- name: data
mountPath: /app/data
-2
View File
@@ -6,8 +6,6 @@ resources:
- deployment.yaml
- service.yaml
- ingress.yaml
- bridge/configmap.yaml
- bridge/deployment.yaml
- igotify-pvc.yaml
- igotify-deployment.yaml
- igotify-service.yaml
+78
View File
@@ -0,0 +1,78 @@
# Homarr landing page with Authentik SSO
# Probe tuning (chart DOES expose these — the old PostSync patch-job was
# unnecessary and fragile: it only patched one Deployment revision, so any later
# rollout reverted to the chart's aggressive defaults). Homarr's first-boot icon
# updater blocks the event loop for ~50s ("icons updater took 49553ms"), during
# which /api/health/live can't answer within the default 10s×3 window → kubelet
# SIGTERMs the pod → CrashLoopBackOff (247 restarts, 503 at the ingress). Give
# liveness a wide window so the icon import can finish without a kill.
livenessProbe:
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 10
readinessProbe:
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 6
image:
repository: ghcr.io/homarr-labs/homarr
tag: "latest"
pullPolicy: Always
replicaCount: 1
# Configure SSO via environment variables
# Chart supports these via top-level env dict (not array)
env:
AUTH_PROVIDERS: "oidc,credentials"
AUTH_OIDC_ISSUER: "https://zitadel.riotpiao.com"
# AUTH_OIDC_URI (authorize endpoint) is REQUIRED in addition to ISSUER — homarr
# hides the "Sign in with Authentik" button entirely when it's absent (per the
# authentik Homarr integration + homarr SSO docs). This was the missing var.
AUTH_OIDC_URI: "https://zitadel.riotpiao.com/oauth/v2/authorize"
AUTH_OIDC_CLIENT_NAME: "Zitadel"
AUTH_OIDC_GROUPS_ATTRIBUTE: "groups"
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile groups"
AUTH_OIDC_AUTO_LOGIN: "false"
# Link the OIDC identity to an existing homarr account with the same email.
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: "true"
# The analytics cron blocked the (single-threaded) Next.js event loop for ~16s
# per run ("callback took longer than expected"), compounding CPU pressure.
DISABLE_ANALYTICS: "true"
BASE_URL: "https://homarr.riotpiao.com"
NEXTAUTH_URL: "https://homarr.riotpiao.com"
# Client credentials from homarr-oidc secret
# Chart doesn't support envFrom, so we add via extraEnv
extraEnv:
- name: AUTH_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: homarr-oidc
key: client-id
- name: AUTH_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: homarr-oidc
key: client-secret
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
resources:
requests:
cpu: 250m
memory: 384Mi
limits:
# Next.js 16 + bundled redis + the icon-updater (28k icons) saturated the old
# 500m limit; CPU throttling made Next.js abort with exit 134 (SIGABRT) and
# self-restart in a loop, so nginx saw no upstream and returned 502. Give it
# real CPU headroom.
cpu: "2"
memory: 1Gi
+7
View File
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dashboard
# Probes are now tuned via homarr-values.yaml (chart-native); the old
# fix-probes-job PostSync hook is removed. homarr-secrets/auth-oidc/db-encryption
# Secrets are delivered by the sops-secrets (ksops) Application.
resources: []
@@ -1,50 +0,0 @@
# Credential rotation
One `KeyRotation` object describes one credential. The controller creates the value,
writes the Secret, proves the new value works, then retires the old one.
## Why the state order is the safety property
```
Prepare -> Publish -> Verify -> Retire
```
`Retire` runs only for a provider whose old credential still works after the publish
step, and only after `Verify` passes. A failure before that point leaves the working
credential untouched. The hand-run rotation that broke Grafana did the opposite: it
regenerated the secret and never re-pointed the consumer.
## Two strategies, decided by the provider, not by preference
| Provider | Overlap in ZITADEL | Strategy | Retire |
|---|---|---|---|
| `zitadel-machine-key` | many keys per service user | `overlap` | after the new key obtains a token |
| `zitadel-oidc-client` | one secret per app | `atomic` | nothing to retire |
## The single master credential
The controller authenticates with one machine key, mounted from Secret
`iam/key-rotation-controller`. That key is the only credential the cluster cannot
rotate by itself, so it is the only one that stays encrypted in git.
Every other credential is created by the controller, which means the 34 `.enc.yaml`
files under `k8s/argocd/secrets/` are migration debt, not a design. The retirement
target is one root-of-trust file per thing that cannot rotate itself:
| Survives (root of trust) | Why it cannot rotate |
|---|---|
| ZITADEL instance masterkey | encrypts the whole ZITADEL database |
| Vault unseal keys | unsealing needs a human-held key |
| Talos repair config | recovery path, must work when the cluster does not |
| homelab CA | signs the wildcard certificate |
| controller bootstrap key | chicken and egg: it creates every other key |
| etcd defrag credentials | needed by a job that runs outside the controller |
Everything else — Grafana, Argo CD, Forgejo, MinIO, Paperless, Immich, ComfyUI,
api-gw, Gotify — is a KeyRotation object, and its secret leaves git.
## Status
The CRD is installed. Eight `KeyRotation` objects exist for the consumers above and
all are `suspend: true`: the controller image does not exist yet, so no process acts
on them. poimen-memory is excluded from this migration on purpose.
@@ -1,13 +0,0 @@
# Build stage. The controller has no cgo and no runtime assets, so the final image
# is a static binary on a base with no shell.
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/controller .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/controller /usr/local/bin/controller
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/controller"]
@@ -1,258 +0,0 @@
// Package api declares the KeyRotation custom resource.
//
// One resource describes one credential and how to rotate it. The controller
// never stores the credential in git: it writes a Kubernetes Secret and records
// only the credential's identifier in status.
package api
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// Group is the API group for homelab platform resources.
const Group = "iam.homelab.riotpiao.com"
// Version is the served version.
const Version = "v1"
// Provider names. Each provider uses one external API to create and retire
// credentials, and each declares whether a rotation can overlap.
const (
// ProviderZitadelMachineKey creates a machine key while the previous key
// stays valid. True zero downtime.
ProviderZitadelMachineKey = "zitadel-machine-key"
// ProviderZitadelOIDCClient regenerates an app client secret. ZITADEL keeps
// one secret per app, so the old one dies the moment the new one exists.
ProviderZitadelOIDCClient = "zitadel-oidc-client"
)
// Strategy names.
const (
// StrategyOverlap keeps the old credential valid until the new one is proven.
StrategyOverlap = "overlap"
// StrategyAtomic replaces the credential in place. A short window exists.
StrategyAtomic = "atomic"
)
// Reload names tell the controller how the consumer picks up a new value.
const (
// ReloadHot means the consumer watches the mounted file.
ReloadHot = "hot"
// ReloadRestart means the consumer reads the value at start, so it must roll.
ReloadRestart = "restart"
)
// Phase names for status.
const (
PhasePending = "Pending"
PhasePreparing = "Preparing"
PhasePropagating = "Propagating"
PhaseVerifying = "Verifying"
PhaseCompleting = "Completing"
PhaseSucceeded = "Succeeded"
PhaseFailed = "Failed"
PhaseSuspended = "Suspended"
)
// ConsumerRef names a workload that reads the rotated credential.
type ConsumerRef struct {
// Kind is Deployment or StatefulSet.
Kind string `json:"kind"`
// Name is the workload name.
Name string `json:"name"`
// Namespace is where the workload runs.
Namespace string `json:"namespace"`
}
// RotationTarget is the credential's source and destination.
type RotationTarget struct {
// ZitadelProjectID is the project that owns the app or service user.
ZitadelProjectID string `json:"zitadelProjectID,omitempty"`
// ZitadelAppID is the app RESOURCE id, which the secret endpoint needs. It is not
// the client id.
ZitadelAppID string `json:"zitadelAppID,omitempty"`
// ZitadelClientID is the OIDC client id, which introspection needs. ZITADEL uses
// two different identifiers for the same app and neither substitutes for the other.
ZitadelClientID string `json:"zitadelClientID,omitempty"`
// MachineUserID is the service user id, for the machine-key provider.
MachineUserID string `json:"machineUserID,omitempty"`
// SecretName is the Secret the controller writes.
SecretName string `json:"secretName"`
// SecretNamespace is where that Secret lives.
SecretNamespace string `json:"secretNamespace"`
// SecretKeys maps a logical field name to the Secret key that holds it.
// For a machine key: key -> key, key-id -> key-id.
SecretKeys map[string]string `json:"secretKeys,omitempty"`
// Consumers are the workloads that must pick the new value up.
Consumers []ConsumerRef `json:"consumers,omitempty"`
// Reload is hot or restart. Empty means restart, the safe default.
Reload string `json:"reload,omitempty"`
}
// VerifySpec is the proof the controller demands before it retires the old
// credential.
type VerifySpec struct {
// Introspect checks client_id and client_secret against the introspection
// endpoint.
Introspect bool `json:"introspect,omitempty"`
// TokenExchange exchanges a token with the new machine key.
TokenExchange bool `json:"tokenExchange,omitempty"`
// AudienceProject, when set, is added to the token request as
// urn:zitadel:iam:org:project:id:<id>:aud.
AudienceProject string `json:"audienceProject,omitempty"`
}
// KeyRotationSpec is the desired state.
type KeyRotationSpec struct {
// Provider selects the external API.
Provider string `json:"provider"`
// Strategy is overlap or atomic.
Strategy string `json:"strategy"`
// IntervalHours is how often to rotate. Minimum 24.
IntervalHours int32 `json:"intervalHours"`
// Suspend stops automatic rotation without deleting the resource.
Suspend bool `json:"suspend,omitempty"`
// Target names the credential and its consumers.
Target RotationTarget `json:"target"`
// Verify names the checks that must pass.
Verify VerifySpec `json:"verify,omitempty"`
}
// KeyRotationStatus is the observed state.
type KeyRotationStatus struct {
Phase string `json:"phase,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
LastRotation *metav1.Time `json:"lastRotation,omitempty"`
NextRotation *metav1.Time `json:"nextRotation,omitempty"`
ActiveCredentialID string `json:"activeCredentialID,omitempty"`
PreviousCredentialID string `json:"previousCredentialID,omitempty"`
LastError string `json:"lastError,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// KeyRotation is one credential under management.
type KeyRotation struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KeyRotationSpec `json:"spec,omitempty"`
Status KeyRotationStatus `json:"status,omitempty"`
}
// KeyRotationList is a list of KeyRotation.
type KeyRotationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KeyRotation `json:"items"`
}
// SchemeGroupVersion is the group and version for this type.
var SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// SchemeBuilder registers the types with a scheme.
var SchemeBuilder = runtime.NewSchemeBuilder(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &KeyRotation{}, &KeyRotationList{})
metav1.AddToGroupVersion(s, SchemeGroupVersion)
return nil
})
// AddToScheme registers this package's types.
var AddToScheme = SchemeBuilder.AddToScheme
// DeepCopyInto copies the receiver into out.
func (in *KeyRotation) DeepCopyInto(out *KeyRotation) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy makes a deep copy.
func (in *KeyRotation) DeepCopy() *KeyRotation {
if in == nil {
return nil
}
out := new(KeyRotation)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject satisfies runtime.Object.
func (in *KeyRotation) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto copies the receiver into out.
func (in *KeyRotationSpec) DeepCopyInto(out *KeyRotationSpec) {
*out = *in
in.Target.DeepCopyInto(&out.Target)
}
// DeepCopyInto copies the receiver into out.
func (in *RotationTarget) DeepCopyInto(out *RotationTarget) {
*out = *in
if in.SecretKeys != nil {
out.SecretKeys = make(map[string]string, len(in.SecretKeys))
for k, v := range in.SecretKeys {
out.SecretKeys[k] = v
}
}
if in.Consumers != nil {
out.Consumers = make([]ConsumerRef, len(in.Consumers))
copy(out.Consumers, in.Consumers)
}
}
// DeepCopyInto copies the receiver into out.
func (in *KeyRotationStatus) DeepCopyInto(out *KeyRotationStatus) {
*out = *in
if in.LastRotation != nil {
t := *in.LastRotation
out.LastRotation = &t
}
if in.NextRotation != nil {
t := *in.NextRotation
out.NextRotation = &t
}
if in.Conditions != nil {
out.Conditions = make([]metav1.Condition, len(in.Conditions))
copy(out.Conditions, in.Conditions)
}
}
// DeepCopyInto copies the receiver into out.
func (in *KeyRotationList) DeepCopyInto(out *KeyRotationList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
out.Items = make([]KeyRotation, len(in.Items))
for i := range in.Items {
in.Items[i].DeepCopyInto(&out.Items[i])
}
}
}
// DeepCopy makes a deep copy.
func (in *KeyRotationList) DeepCopy() *KeyRotationList {
if in == nil {
return nil
}
out := new(KeyRotationList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject satisfies runtime.Object.
func (in *KeyRotationList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
@@ -1,72 +0,0 @@
module github.com/riotpiao/key-rotation-controller
go 1.26.0
require (
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.0
sigs.k8s.io/controller-runtime v0.25.2
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.37.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
@@ -1,183 +0,0 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI=
k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ=
k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
@@ -1,168 +0,0 @@
package reconcile
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
"github.com/riotpiao/key-rotation-controller/internal/zitadel"
)
// MachineKeyProvider rotates a service user's private key.
//
// ZITADEL keeps many keys per service user, and each assertion names its key with
// `kid`. A new key can be created while the deployed key stays valid, so this
// provider retires the old key only after the new one proves itself. Nothing in
// the window is invalid.
type MachineKeyProvider struct {
// Client talks to the instance that owns the service user.
Client *zitadel.Client
// AuthClientID is the API app the controller authenticates as.
AuthClientID string
// KeyIDFile and KeyFile name the Secret keys, defaulting to key-id and key.
KeyIDFile string
KeyFile string
}
// Name implements Provider.
func (p *MachineKeyProvider) Name() string { return iamv1.ProviderZitadelMachineKey }
// Strategy implements Provider. Machine keys overlap.
func (p *MachineKeyProvider) Strategy() string { return iamv1.StrategyOverlap }
// Prepare creates a new key. The currently deployed key remains valid.
func (p *MachineKeyProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
userID := kr.Spec.Target.MachineUserID
if userID == "" {
return nil, fmt.Errorf("spec.target.machineUserID is required")
}
key, err := p.Client.CreateMachineKey(userID)
if err != nil {
return nil, fmt.Errorf("creating a machine key: %w", err)
}
fields := map[string][]byte{
p.keyField(kr): []byte(key.Key),
p.keyIDField(kr): []byte(key.KeyID),
}
return &Credential{ID: key.KeyID, Fields: fields}, nil
}
// Verify exchanges a token with the new key. A key that cannot obtain a token is
// not deployed as a working credential, whatever the API said.
func (p *MachineKeyProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
keyPEM := cred.Fields[p.keyField(kr)]
keyID := string(cred.Fields[p.keyIDField(kr)])
return p.Client.ExchangeMachineKey(
p.AuthClientID,
kr.Spec.Target.MachineUserID,
keyID,
keyPEM,
kr.Spec.Verify.AudienceProject,
)
}
// Retire deletes the superseded key.
func (p *MachineKeyProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
return p.Client.DeleteMachineKey(kr.Spec.Target.MachineUserID, credentialID)
}
func (p *MachineKeyProvider) keyField(kr *iamv1.KeyRotation) string {
if key, ok := kr.Spec.Target.SecretKeys["key"]; ok && key != "" {
return key
}
return "key"
}
func (p *MachineKeyProvider) keyIDField(kr *iamv1.KeyRotation) string {
if key, ok := kr.Spec.Target.SecretKeys["key-id"]; ok && key != "" {
return key
}
return "key-id"
}
// OIDCClientProvider rotates an app's client secret.
//
// ZITADEL stores exactly one secret per app, so creating the new secret kills the
// old one. The strategy is atomic: publish immediately, roll the consumer, verify,
// and if verification fails, put the previous value back.
type OIDCClientProvider struct {
Client *zitadel.Client
// ClientIDFunc maps a KeyRotation to the app's client id, which the
// introspection endpoint needs.
ClientIDFunc func(kr *iamv1.KeyRotation) string
// SecretKey names the Secret key that holds the secret.
SecretKey string
}
// Name implements Provider.
func (p *OIDCClientProvider) Name() string { return iamv1.ProviderZitadelOIDCClient }
// Strategy implements Provider. Client secrets cannot overlap.
func (p *OIDCClientProvider) Strategy() string { return iamv1.StrategyAtomic }
// Prepare regenerates the client secret.
func (p *OIDCClientProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
projectID := kr.Spec.Target.ZitadelProjectID
appID := kr.Spec.Target.ZitadelAppID
if projectID == "" || appID == "" {
return nil, fmt.Errorf("spec.target.zitadelProjectID and zitadelAppID are required")
}
secret, err := p.Client.GenerateClientSecret(projectID, appID)
if err != nil {
return nil, fmt.Errorf("generating a client secret: %w", err)
}
field := p.secretField(kr)
// The credential id is the generation marker, not a ZITADEL identifier: the
// API returns only the value.
return &Credential{
ID: "secret-" + shortHash(secret),
Fields: map[string][]byte{field: []byte(secret)},
}, nil
}
// Verify checks the credential against the introspection endpoint.
func (p *OIDCClientProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
if !kr.Spec.Verify.Introspect {
return nil
}
clientID := ""
if p.ClientIDFunc != nil {
clientID = p.ClientIDFunc(kr)
}
if clientID == "" {
return fmt.Errorf("no client id configured for introspection")
}
return p.Client.Introspect(clientID, string(cred.Fields[p.secretField(kr)]))
}
// Retire is a no-op: an atomic provider has nothing to retire, because the
// provider already invalidated the previous value.
func (p *OIDCClientProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
return nil
}
func (p *OIDCClientProvider) secretField(kr *iamv1.KeyRotation) string {
if p.SecretKey != "" {
return p.SecretKey
}
if key, ok := kr.Spec.Target.SecretKeys["secret"]; ok && key != "" {
return key
}
return "secret"
}
// shortHash gives a stable, non-reversible marker for a secret so status can
// record which generation is live without storing the value.
func shortHash(value string) string {
sum := 0
for _, b := range []byte(value) {
sum = (sum*31 + int(b)) % 100000007
}
encoded := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", sum)))
return encoded
}
// unusedJSON keeps the import set honest if a provider needs raw JSON later.
var _ = json.Marshal
@@ -1,433 +0,0 @@
// Package reconcile turns a KeyRotation object into a rotated credential.
//
// The state machine is identical for every provider. Only three calls differ:
// create, verify, retire. That is what makes one controller cover machine keys,
// client secrets and, later, the token-style providers.
//
// Order is the safety property:
//
// Prepare create the new credential
// Publish write the Secret, roll the consumer when it cannot reload
// Verify prove the new credential works against the provider
// Retire delete the old credential, only for overlapping providers
//
// A failure before Retire leaves the old credential in place, so a broken
// rotation never removes the thing that currently works.
package reconcile
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
)
// Credential is fresh credential material, before it is written to a Secret.
type Credential struct {
// ID identifies the credential at the provider, so it can be retired later.
ID string
// Fields maps a Secret key to its value.
Fields map[string][]byte
}
// Provider creates, verifies and retires one class of credential.
type Provider interface {
// Name is the provider name from the spec.
Name() string
// Strategy is overlap or atomic. Atomic providers replace in place.
Strategy() string
// Prepare creates a new credential at the provider.
Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error)
// Verify proves the credential works, using the provider's own check.
Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error
// Retire deletes a superseded credential. It must be safe to call twice.
Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error
}
// Reconciler rotates credentials for KeyRotation objects.
type Reconciler struct {
client.Client
// Providers keyed by spec.provider.
Providers map[string]Provider
// Now is injectable so scheduling is testable.
Now func() time.Time
// RolloutTimeout bounds the wait for a consumer to become available.
RolloutTimeout time.Duration
}
// previousKeySuffix keeps the superseded value beside the current one, so an
// atomic provider can be rolled back without storing a secret in the object.
const previousKeySuffix = ".previous"
// RequeueAfter is how long to wait before checking a resource again.
func (r *Reconciler) RequeueAfter(kr *iamv1.KeyRotation) time.Duration {
interval := time.Duration(kr.Spec.IntervalHours) * time.Hour
if interval <= 0 {
interval = 30 * 24 * time.Hour
}
// Return 0 to rotate NOW and a positive duration to wait. A failed attempt must
// rotate again immediately; only a rotation that reached Succeeded waits for the
// next interval. Returning a wait here was the defect: the object sat in Failed
// and reconciled every two minutes without ever trying again.
// A failed attempt waits a short backoff and then really retries. Returning 0
// unconditionally here spins: fail, rotate, fail, with no pause between.
if kr.Status.Phase == iamv1.PhaseFailed {
if kr.Status.LastRotation != nil {
if wait := kr.Status.LastRotation.Add(2 * time.Minute).Sub(r.now()); wait > 0 {
return wait
}
}
return 0
}
if kr.Status.LastRotation == nil || kr.Status.ActiveCredentialID == "" {
return 0 // rotate now
}
if kr.Status.Phase != iamv1.PhaseSucceeded {
return 0 // an interrupted attempt starts over
}
due := kr.Status.LastRotation.Add(interval)
wait := due.Sub(r.now())
if wait < 0 {
return 0
}
return wait
}
// Reconcile performs one rotation when the resource is due.
func (r *Reconciler) Reconcile(ctx context.Context, req types.NamespacedName) (time.Duration, error) {
logger := log.FromContext(ctx).WithValues("keyrotation", req.String())
var kr iamv1.KeyRotation
if err := r.Get(ctx, req, &kr); err != nil {
if apierrors.IsNotFound(err) {
return 0, nil
}
return 0, err
}
logger.Info("reconcile", "phase", kr.Status.Phase, "generation", kr.Generation,
"observed", kr.Status.ObservedGeneration, "lastRotation", kr.Status.LastRotation)
if kr.Spec.Suspend {
return 0, r.setPhase(ctx, &kr, iamv1.PhaseSuspended, "")
}
provider := r.Providers[kr.Spec.Provider]
if provider == nil {
return 0, r.setPhase(ctx, &kr, iamv1.PhaseFailed,
fmt.Sprintf("no provider named %q", kr.Spec.Provider))
}
wait := r.RequeueAfter(&kr)
if wait > 0 {
return wait, nil
}
logger.Info("rotating credential", "provider", kr.Spec.Provider, "strategy", kr.Spec.Strategy)
previousID := kr.Status.ActiveCredentialID
previousFields := r.readCurrentSecret(ctx, &kr)
// Prepare
if err := r.setPhase(ctx, &kr, iamv1.PhasePreparing, ""); err != nil {
return 0, err
}
cred, err := provider.Prepare(ctx, &kr)
if err != nil {
return r.fail(ctx, &kr, iamv1.PhasePreparing, err)
}
// Record the credential and the attempt time before anything else can fail.
// Without this a failure later in the cycle leaves LastRotation empty, the due
// check says "rotate now", and the controller mints a new secret on every
// reconcile: a restart loop the consumer never survives.
started := metav1.NewTime(r.now())
kr.Status.ActiveCredentialID = cred.ID
kr.Status.LastRotation = &started
if err := r.Status().Update(ctx, &kr); err != nil && !apierrors.IsConflict(err) {
return 0, err
}
// Publish. The previous value stays beside the new one until verification
// passes, so an atomic rotation can be undone.
if err := r.setPhase(ctx, &kr, iamv1.PhasePropagating, ""); err != nil {
return 0, err
}
if err := r.writeSecret(ctx, &kr, cred, previousFields); err != nil {
return r.fail(ctx, &kr, iamv1.PhasePropagating, err)
}
if kr.Spec.Target.Reload != iamv1.ReloadHot {
if err := r.rollConsumers(ctx, &kr, cred.ID); err != nil {
return r.fail(ctx, &kr, iamv1.PhasePropagating, err)
}
}
// Verify
if err := r.setPhase(ctx, &kr, iamv1.PhaseVerifying, ""); err != nil {
return 0, err
}
if err := provider.Verify(ctx, &kr, cred); err != nil {
// Put the previous value back and roll again, then report the failure.
r.restorePrevious(ctx, &kr, previousFields)
return r.fail(ctx, &kr, iamv1.PhaseVerifying, err)
}
// Complete. Retire only when the provider can overlap, because retiring an
// atomic credential would delete the one now in use.
if err := r.setPhase(ctx, &kr, iamv1.PhaseCompleting, ""); err != nil {
return 0, err
}
if provider.Strategy() == iamv1.StrategyOverlap && previousID != "" && previousID != cred.ID {
if err := provider.Retire(ctx, &kr, previousID); err != nil {
logger.Error(err, "retiring the previous credential failed; it stays valid", "credential", previousID)
}
}
r.clearPrevious(ctx, &kr)
now := metav1.NewTime(r.now())
kr.Status.LastRotation = &now
kr.Status.ActiveCredentialID = cred.ID
kr.Status.PreviousCredentialID = previousID
kr.Status.LastError = ""
kr.Status.ObservedGeneration = kr.Generation
if err := r.Status().Update(ctx, &kr); err != nil {
return 0, err
}
if err := r.setPhase(ctx, &kr, iamv1.PhaseSucceeded, ""); err != nil {
return 0, err
}
logger.Info("rotation complete", "credential", cred.ID, "previous", previousID)
return r.RequeueAfter(&kr), nil
}
func (r *Reconciler) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}
func (r *Reconciler) setPhase(ctx context.Context, kr *iamv1.KeyRotation, phase, message string) error {
kr.Status.Phase = phase
kr.Status.LastError = message
kr.Status.ObservedGeneration = kr.Generation
if err := r.Status().Update(ctx, kr); err != nil && !apierrors.IsConflict(err) {
return err
}
return nil
}
func (r *Reconciler) fail(ctx context.Context, kr *iamv1.KeyRotation, phase string, cause error) (time.Duration, error) {
kr.Status.Phase = iamv1.PhaseFailed
kr.Status.LastError = fmt.Sprintf("%s: %v", phase, cause)
_ = r.Status().Update(ctx, kr)
// Retry soon, but not in a tight loop.
return 5 * time.Minute, nil
}
// secretData assembles the Secret payload: the new values, plus the superseded
// values under a shadow key so an atomic rotation can be undone.
func secretData(kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) map[string][]byte {
data := make(map[string][]byte, len(cred.Fields)*2)
for logical, value := range cred.Fields {
key := secretKeyFor(kr, logical)
data[key] = value
if old, ok := previous[key]; ok && len(old) > 0 {
data[key+previousKeySuffix] = old
}
}
return data
}
// secretKeyFor maps a logical field name to the Secret key, defaulting to the
// same name so a simple credential needs no mapping.
func secretKeyFor(kr *iamv1.KeyRotation, logical string) string {
if key, ok := kr.Spec.Target.SecretKeys[logical]; ok && key != "" {
return key
}
return logical
}
func (r *Reconciler) readCurrentSecret(ctx context.Context, kr *iamv1.KeyRotation) map[string][]byte {
var secret corev1.Secret
err := r.Get(ctx, types.NamespacedName{
Namespace: kr.Spec.Target.SecretNamespace,
Name: kr.Spec.Target.SecretName,
}, &secret)
if err != nil {
return nil
}
return secret.Data
}
func (r *Reconciler) writeSecret(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) error {
data := secretData(kr, cred, previous)
var secret corev1.Secret
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
err := r.Get(ctx, key, &secret)
if apierrors.IsNotFound(err) {
secret = corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
Labels: map[string]string{"app.kubernetes.io/managed-by": "key-rotation-controller"},
},
Type: corev1.SecretTypeOpaque,
Data: data,
}
return r.Create(ctx, &secret)
}
if err != nil {
return err
}
// Merge, never replace. A consumer Secret often carries configuration beside the
// credential (paperless keeps SOCIALACCOUNT_PROVIDERS_JSON there), and replacing
// the whole map deletes it. The pod then fails with "couldn't find key ... in
// Secret" and the rotation looks successful while it broke the service.
if secret.Data == nil {
secret.Data = map[string][]byte{}
}
for k, v := range data {
secret.Data[k] = v
}
return r.Update(ctx, &secret)
}
// restorePrevious puts the shadow values back in place after a failed check.
func (r *Reconciler) restorePrevious(ctx context.Context, kr *iamv1.KeyRotation, previous map[string][]byte) {
if len(previous) == 0 {
return
}
var secret corev1.Secret
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
if err := r.Get(ctx, key, &secret); err != nil {
return
}
// Restore every shadowed value over the current one.
for k, v := range secret.Data {
base := k
if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix {
base = k[:len(k)-len(previousKeySuffix)]
secret.Data[base] = v
}
}
_ = r.Update(ctx, &secret)
_ = r.rollConsumers(ctx, kr, "rollback")
}
// clearPrevious removes the shadow values once verification passed.
func (r *Reconciler) clearPrevious(ctx context.Context, kr *iamv1.KeyRotation) {
var secret corev1.Secret
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
if err := r.Get(ctx, key, &secret); err != nil {
return
}
changed := false
for k := range secret.Data {
if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix {
delete(secret.Data, k)
changed = true
}
}
if changed {
_ = r.Update(ctx, &secret)
}
}
// rollConsumers restarts each consumer so it reads the new value.
func (r *Reconciler) rollConsumers(ctx context.Context, kr *iamv1.KeyRotation, revision string) error {
for _, consumer := range kr.Spec.Target.Consumers {
ns := consumer.Namespace
if ns == "" {
ns = kr.Namespace
}
switch consumer.Kind {
case "Deployment":
var dep appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil {
return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err)
}
base := dep.DeepCopy()
if dep.Spec.Template.Annotations == nil {
dep.Spec.Template.Annotations = map[string]string{}
}
dep.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision
if err := r.Patch(ctx, &dep, client.MergeFrom(base)); err != nil {
return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err)
}
case "StatefulSet":
var sts appsv1.StatefulSet
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &sts); err != nil {
return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err)
}
base := sts.DeepCopy()
if sts.Spec.Template.Annotations == nil {
sts.Spec.Template.Annotations = map[string]string{}
}
sts.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision
if err := r.Patch(ctx, &sts, client.MergeFrom(base)); err != nil {
return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err)
}
default:
return fmt.Errorf("consumer kind %q is not supported", consumer.Kind)
}
}
return r.waitForConsumers(ctx, kr)
}
// waitForConsumers blocks until every consumer reports all replicas available.
func (r *Reconciler) waitForConsumers(ctx context.Context, kr *iamv1.KeyRotation) error {
deadline := r.now().Add(r.rolloutTimeout())
for {
ready := true
for _, consumer := range kr.Spec.Target.Consumers {
ns := consumer.Namespace
if ns == "" {
ns = kr.Namespace
}
var dep appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil {
continue
}
desired := int32(1)
if dep.Spec.Replicas != nil {
desired = *dep.Spec.Replicas
}
if dep.Status.UpdatedReplicas < desired || dep.Status.AvailableReplicas < desired {
ready = false
break
}
}
if ready {
return nil
}
if r.now().After(deadline) {
return fmt.Errorf("consumer did not become available within %s", r.rolloutTimeout())
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
func (r *Reconciler) rolloutTimeout() time.Duration {
if r.RolloutTimeout > 0 {
return r.RolloutTimeout
}
// Grafana needs about four minutes from container start to binding :3000, so a
// three-minute budget fails a rotation that is actually working.
return 8 * time.Minute
}
@@ -1,173 +0,0 @@
package reconcile
import (
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
)
func rotation(last *metav1.Time, activeID string, hours int32) *iamv1.KeyRotation {
return &iamv1.KeyRotation{
Spec: iamv1.KeyRotationSpec{
Provider: iamv1.ProviderZitadelMachineKey,
Strategy: iamv1.StrategyOverlap,
IntervalHours: hours,
},
Status: iamv1.KeyRotationStatus{
LastRotation: last,
ActiveCredentialID: activeID,
Phase: iamv1.PhaseSucceeded,
},
}
}
func TestRequeueAfterBacksOffAfterAFailure(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now.Add(-1 * time.Minute))
r := &Reconciler{Now: func() time.Time { return now }}
kr := rotation(&last, "key-1", 24)
kr.Status.Phase = iamv1.PhaseFailed
if got := r.RequeueAfter(kr); got != time.Minute {
t.Fatalf("a failed attempt must wait a short backoff, got %s", got)
}
old := metav1.NewTime(now.Add(-3 * time.Minute))
kr.Status.LastRotation = &old
if got := r.RequeueAfter(kr); got != 0 {
t.Fatalf("after the backoff it must rotate again, got %s", got)
}
}
func TestRequeueAfterRotatesImmediatelyWhenNeverRotated(t *testing.T) {
r := &Reconciler{Now: func() time.Time { return time.Unix(1_700_000_000, 0) }}
if got := r.RequeueAfter(rotation(nil, "", 24)); got != 0 {
t.Fatalf("a resource with no rotation history must rotate now, got %s", got)
}
if got := r.RequeueAfter(rotation(nil, "key-1", 24)); got != 0 {
t.Fatalf("a resource with no lastRotation must rotate now, got %s", got)
}
}
func TestRequeueAfterWaitsForTheInterval(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now.Add(-6 * time.Hour))
r := &Reconciler{Now: func() time.Time { return now }}
got := r.RequeueAfter(rotation(&last, "key-1", 24))
want := 18 * time.Hour
if got != want {
t.Fatalf("expected %s until the next rotation, got %s", want, got)
}
}
func TestRequeueAfterRotatesWhenOverdue(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now.Add(-30 * time.Hour))
r := &Reconciler{Now: func() time.Time { return now }}
if got := r.RequeueAfter(rotation(&last, "key-1", 24)); got != 0 {
t.Fatalf("an overdue resource must rotate now, got %s", got)
}
}
func TestRequeueAfterFallsBackWhenNoInterval(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now)
r := &Reconciler{Now: func() time.Time { return now }}
got := r.RequeueAfter(rotation(&last, "key-1", 0))
if got <= 0 {
t.Fatalf("a zero interval must fall back to a default, got %s", got)
}
}
func TestSecretDataKeepsThePreviousValueUnderAShadowKey(t *testing.T) {
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
Target: iamv1.RotationTarget{
SecretKeys: map[string]string{"key": "jwt-key", "key-id": "jwt-key-id"},
},
}}
cred := &Credential{ID: "k2", Fields: map[string][]byte{
"key": []byte("PEM-2"),
"key-id": []byte("k2"),
}}
previous := map[string][]byte{
"jwt-key": []byte("PEM-1"),
"jwt-key-id": []byte("k1"),
}
data := secretData(kr, cred, previous)
if string(data["jwt-key"]) != "PEM-2" {
t.Fatalf("current value missing, got %q", data["jwt-key"])
}
if string(data["jwt-key"+previousKeySuffix]) != "PEM-1" {
t.Fatalf("previous value must be kept for rollback, got %q", data["jwt-key"+previousKeySuffix])
}
if string(data["jwt-key-id"+previousKeySuffix]) != "k1" {
t.Fatalf("previous key id must be kept for rollback")
}
}
func TestSecretDataDefaultsToLogicalNames(t *testing.T) {
kr := &iamv1.KeyRotation{}
cred := &Credential{ID: "k1", Fields: map[string][]byte{"secret": []byte("s3cret")}}
data := secretData(kr, cred, nil)
if string(data["secret"]) != "s3cret" {
t.Fatalf("an unmapped field must keep its logical name, got %v", data)
}
if _, shadow := data["secret"+previousKeySuffix]; shadow {
t.Fatalf("no previous value existed, so no shadow key may be written")
}
}
func TestSecretKeyForPrefersTheMapping(t *testing.T) {
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
Target: iamv1.RotationTarget{
SecretKeys: map[string]string{"secret": "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"},
},
}}
if got := secretKeyFor(kr, "secret"); got != "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET" {
t.Fatalf("mapping not honoured, got %q", got)
}
if got := secretKeyFor(kr, "key-id"); got != "key-id" {
t.Fatalf("unmapped field must default to its own name, got %q", got)
}
}
func TestMachineKeyProviderFieldsRespectTheMapping(t *testing.T) {
p := &MachineKeyProvider{}
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
Target: iamv1.RotationTarget{
SecretKeys: map[string]string{"key": "private-key", "key-id": "kid"},
},
}}
if p.keyField(kr) != "private-key" || p.keyIDField(kr) != "kid" {
t.Fatalf("provider must read the mapped Secret keys")
}
if p.Strategy() != iamv1.StrategyOverlap {
t.Fatalf("machine keys must be an overlapping provider")
}
if (&OIDCClientProvider{}).Strategy() != iamv1.StrategyAtomic {
t.Fatalf("client secrets cannot overlap and must be atomic")
}
}
func TestShortHashIsStableAndDoesNotLeakTheValue(t *testing.T) {
a := shortHash("super-secret-value")
b := shortHash("super-secret-value")
if a != b {
t.Fatalf("the marker must be stable for the same value")
}
if a == "super-secret-value" {
t.Fatalf("the marker must not be the value itself")
}
if shortHash("other") == a {
t.Fatalf("different values must produce different markers")
}
}
@@ -1,310 +0,0 @@
package zitadel
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Config holds everything the controller needs to call ZITADEL.
type Config struct {
// Issuer is the instance URL, for example https://zitadel.riotpiao.com.
Issuer string
// ClientID is the API app that this controller authenticates as.
ClientID string
// UserID is the machine user the assertion acts for. Empty means ClientID.
UserID string
// KeyID and PrivateKeyPEM identify the controller's own machine key.
KeyID string
PrivateKeyPEM []byte
// HTTPClient is optional; a sane default is used when nil.
HTTPClient *http.Client
}
// Client is a thin ZITADEL management client.
type Client struct {
cfg Config
http *http.Client
mu sync.Mutex
token string
until time.Time
}
// New builds a client.
func New(cfg Config) *Client {
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
return &Client{cfg: cfg, http: client}
}
// AccessToken returns a cached access token, minting one when needed.
func (c *Client) AccessToken() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.until) {
return c.token, nil
}
assertion, err := SignAssertion(c.cfg.PrivateKeyPEM, c.cfg.KeyID, c.cfg.ClientID, c.cfg.UserID, c.cfg.Issuer, time.Now())
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
// The management API rejects a token without the ZITADEL project audience:
// the request returns 401 Token.Invalid even though the assertion was accepted.
form.Set("scope", "openid urn:zitadel:iam:org:project:id:zitadel:aud")
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var parsed struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("token response did not parse: %w", err)
}
if parsed.AccessToken == "" {
return "", fmt.Errorf("token response carried no access_token")
}
lifetime := parsed.ExpiresIn
if lifetime <= 0 {
lifetime = 300
}
c.token = parsed.AccessToken
// Refresh at 80 % of the lifetime so a slow call never presents an expired token.
c.until = time.Now().Add(time.Duration(lifetime) * time.Second * 4 / 5)
return c.token, nil
}
// projectAppPath builds the management path for one OIDC app under a project.
func (c *Client) projectAppPath(projectID, appID string) string {
return fmt.Sprintf("/management/v1/projects/%s/apps/%s", projectID, appID)
}
// MachineKey is one registered key for a service user.
type MachineKey struct {
KeyID string `json:"keyId"`
Expiration string `json:"expirationDate,omitempty"`
// Details holds the PEM bundle when the key was just created.
Details string `json:"keyDetails,omitempty"`
}
// NewMachineKeyJSON is the JSON document ZITADEL returns for a new JSON key.
type NewMachineKeyJSON struct {
Type string `json:"type"`
KeyID string `json:"keyId"`
Key string `json:"key"`
UserID string `json:"userId"`
ExpireAt string `json:"expirationDate,omitempty"`
}
// CreateMachineKey adds a key to a service user and returns its material.
//
// ZITADEL keeps many keys per user, so this never invalidates the key that is
// currently deployed. That is the whole reason the machine-key provider can
// rotate with no gap.
func (c *Client) CreateMachineKey(userID string) (*NewMachineKeyJSON, error) {
payload := map[string]any{"type": "KEY_TYPE_JSON"}
var out struct {
KeyID string `json:"keyId"`
KeyDetails string `json:"keyDetails"`
}
if err := c.post("/management/v1/users/"+userID+"/keys", payload, &out); err != nil {
return nil, err
}
if out.KeyDetails == "" {
return nil, fmt.Errorf("create machine key returned no key material")
}
raw, err := base64Decode(out.KeyDetails)
if err != nil {
return nil, fmt.Errorf("key material is not base64: %w", err)
}
var key NewMachineKeyJSON
if err := json.Unmarshal(raw, &key); err != nil {
return nil, fmt.Errorf("key material is not the expected JSON: %w", err)
}
return &key, nil
}
// DeleteMachineKey removes one key. Called only after the new key is proven.
func (c *Client) DeleteMachineKey(userID, keyID string) error {
return c.delete("/management/v1/users/" + userID + "/keys/" + keyID)
}
// GenerateClientSecret replaces the client secret of an OIDC app.
//
// ZITADEL stores exactly one secret per app, so the previous secret stops working
// at this call. The caller must publish the new value immediately.
func (c *Client) GenerateClientSecret(projectID, appID string) (string, error) {
var out struct {
ClientSecret string `json:"clientSecret"`
}
path := c.projectAppPath(projectID, appID) + "/oidc_config/_generate_client_secret"
if err := c.post(path, map[string]any{}, &out); err != nil {
return "", err
}
if out.ClientSecret == "" {
return "", fmt.Errorf("generate client secret returned an empty value")
}
return out.ClientSecret, nil
}
// Introspect checks a client credential against the token endpoint.
//
// A successful call returns an object with `active`. The credentials are valid
// when the endpoint answers 200, whatever `active` says, because `active`
// describes the token rather than the client.
func (c *Client) Introspect(clientID, clientSecret string) error {
form := url.Values{}
form.Set("token", "probe")
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/introspect", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(clientID, clientSecret)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("introspection request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("introspection returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
// ExchangeMachineKey obtains a token with a specific key, proving that key works.
func (c *Client) ExchangeMachineKey(clientID, userID, keyID string, keyPEM []byte, audienceProject string) error {
assertion, err := SignAssertion(keyPEM, keyID, clientID, userID, c.cfg.Issuer, time.Now())
if err != nil {
return err
}
scope := "openid"
if audienceProject != "" {
scope += " urn:zitadel:iam:org:project:id:" + audienceProject + ":aud"
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
form.Set("scope", scope)
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("key exchange failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("key exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (c *Client) do(method, path string, payload any, out any) error {
token, err := c.AccessToken()
if err != nil {
return err
}
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequest(method, strings.TrimRight(c.cfg.Issuer, "/")+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s response did not parse: %w", method, path, err)
}
}
return nil
}
func (c *Client) post(path string, payload, out any) error {
return c.do(http.MethodPost, path, payload, out)
}
func (c *Client) delete(path string) error {
return c.do(http.MethodDelete, path, nil, nil)
}
func base64Decode(s string) ([]byte, error) {
if raw, err := base64StdDecode(s); err == nil {
return raw, nil
}
return base64URLDecode(s)
}
func base64StdDecode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
@@ -1,89 +0,0 @@
// Package zitadel talks to the ZITADEL management API with the controller's own
// machine key.
//
// The controller needs a credential to rotate credentials. It signs a short
// assertion with its private key, exchanges it for an access token, and reuses
// that token until shortly before it expires.
package zitadel
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"time"
)
// assertionLifetime is deliberately short. The assertion proves one request.
const assertionLifetime = 60 * time.Second
// newAssertionClaims builds the claim set for a machine-key assertion.
//
// ZITADEL expects `iss` to name the caller, `sub` to name the user it acts as,
// and `aud` to equal the issuer of the instance.
func newAssertionClaims(clientID, userID, issuer string, now time.Time) map[string]any {
if userID == "" {
userID = clientID
}
return map[string]any{
"iss": clientID,
"sub": userID,
"aud": issuer,
"iat": now.Unix(),
"exp": now.Add(assertionLifetime).Unix(),
}
}
func base64URL(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// SignAssertion produces a signed RS256 JWT for the jwt-bearer grant.
//
// The header carries `kid`, so ZITADEL selects the matching registered key. That
// is what makes overlapping keys possible: two keys can be valid at once and the
// assertion says which one it used.
func SignAssertion(privateKeyPEM []byte, keyID, clientID, userID, issuer string, now time.Time) (string, error) {
block, _ := pem.Decode(privateKeyPEM)
if block == nil {
return "", fmt.Errorf("private key is not PEM encoded")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
// ZITADEL exports PKCS#1 keys as well.
if rsaKey, rsaErr := x509.ParsePKCS1PrivateKey(block.Bytes); rsaErr == nil {
key = rsaKey
} else {
return "", fmt.Errorf("private key does not parse: %w", err)
}
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA")
}
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": keyID}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(newAssertionClaims(clientID, userID, issuer, now))
if err != nil {
return "", err
}
signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("cannot sign the assertion: %w", err)
}
return signingInput + "." + base64URL(signature), nil
}
@@ -1,90 +0,0 @@
package zitadel
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"strings"
"testing"
"time"
)
func newKey(t *testing.T) ([]byte, *rsa.PublicKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("cannot generate a key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return pemBytes, &key.PublicKey
}
func decodeSegment(t *testing.T, segment string, out any) {
t.Helper()
raw, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
t.Fatalf("segment is not base64url: %v", err)
}
if err := json.Unmarshal(raw, out); err != nil {
t.Fatalf("segment is not JSON: %v", err)
}
}
func TestSignAssertionCarriesTheKeyIDAndIssuer(t *testing.T) {
pemBytes, _ := newKey(t)
now := time.Unix(1_700_000_000, 0)
token, err := SignAssertion(pemBytes, "key-42", "client-1", "user-9", "https://zitadel.riotpiao.com", now)
if err != nil {
t.Fatalf("signing failed: %v", err)
}
if strings.Count(token, ".") != 2 {
t.Fatalf("a JWT has three segments, got %q", token)
}
parts := strings.Split(token, ".")
var header map[string]any
decodeSegment(t, parts[0], &header)
if header["kid"] != "key-42" {
t.Fatalf("the header must name the key id so ZITADEL can pick the key, got %v", header["kid"])
}
if header["alg"] != "RS256" {
t.Fatalf("expected RS256, got %v", header["alg"])
}
var claims map[string]any
decodeSegment(t, parts[1], &claims)
if claims["iss"] != "client-1" || claims["sub"] != "user-9" {
t.Fatalf("iss and sub are wrong: %v / %v", claims["iss"], claims["sub"])
}
if claims["aud"] != "https://zitadel.riotpiao.com" {
t.Fatalf("aud must equal the issuer, got %v", claims["aud"])
}
exp := int64(claims["exp"].(float64))
iat := int64(claims["iat"].(float64))
if exp-iat != 60 {
t.Fatalf("the assertion must live 60 seconds, got %d", exp-iat)
}
}
func TestSignAssertionRejectsGarbage(t *testing.T) {
if _, err := SignAssertion([]byte("not a key"), "k", "c", "u", "https://i", time.Now()); err == nil {
t.Fatalf("a non-PEM key must be rejected")
}
}
func TestSignAssertionDefaultsSubjectToClientID(t *testing.T) {
pemBytes, _ := newKey(t)
token, err := SignAssertion(pemBytes, "k1", "client-1", "", "https://i", time.Unix(1, 0))
if err != nil {
t.Fatalf("signing failed: %v", err)
}
var claims map[string]any
decodeSegment(t, strings.Split(token, ".")[1], &claims)
if claims["sub"] != "client-1" {
t.Fatalf("an empty user id must fall back to the client id, got %v", claims["sub"])
}
}
@@ -1,155 +0,0 @@
// Command key-rotation-controller rotates credentials declared by KeyRotation
// objects.
//
// The controller authenticates to ZITADEL with its own machine key, read from a
// mounted Secret. That key is the single bootstrap credential: everything else is
// created by this process and never committed to git.
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/source"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
keyreconcile "github.com/riotpiao/key-rotation-controller/internal/reconcile"
"github.com/riotpiao/key-rotation-controller/internal/zitadel"
)
func main() {
var (
metricsAddr = flag.String("metrics-bind-address", ":8080", "address for the metrics endpoint")
healthAddr = flag.String("health-probe-bind-address", ":8081", "address for probes")
interval = flag.Duration("scan-interval", 10*time.Minute, "how often to re-check every resource")
)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
logger := ctrl.Log.WithName("setup")
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
logger.Error(err, "cannot register the Kubernetes types")
os.Exit(1)
}
if err := iamv1.AddToScheme(scheme); err != nil {
logger.Error(err, "cannot register the KeyRotation types")
os.Exit(1)
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: *metricsAddr},
HealthProbeBindAddress: *healthAddr,
LeaderElection: true,
LeaderElectionID: "key-rotation-controller.iam.homelab.riotpiao.com",
})
if err != nil {
logger.Error(err, "cannot create the manager")
os.Exit(1)
}
zitadelClient, err := buildZitadelClient()
if err != nil {
logger.Error(err, "cannot build the ZITADEL client")
os.Exit(1)
}
reconciler := &keyreconcile.Reconciler{
Client: mgr.GetClient(),
Providers: map[string]keyreconcile.Provider{
iamv1.ProviderZitadelMachineKey: &keyreconcile.MachineKeyProvider{
Client: zitadelClient,
AuthClientID: os.Getenv("ZITADEL_CLIENT_ID"),
},
iamv1.ProviderZitadelOIDCClient: &keyreconcile.OIDCClientProvider{
Client: zitadelClient,
ClientIDFunc: func(kr *iamv1.KeyRotation) string {
return kr.Spec.Target.ZitadelClientID
},
},
},
}
c, err := controller.New("keyrotation", mgr, controller.Options{
Reconciler: ctrlreconcile.Func(func(ctx context.Context, req ctrlreconcile.Request) (ctrlreconcile.Result, error) {
wait, err := reconciler.Reconcile(ctx, types.NamespacedName(req.NamespacedName))
if err != nil {
return ctrlreconcile.Result{}, err
}
if wait <= 0 {
wait = *interval
}
return ctrlreconcile.Result{RequeueAfter: wait}, nil
}),
})
if err != nil {
logger.Error(err, "cannot create the controller")
os.Exit(1)
}
if err := c.Watch(source.Kind(mgr.GetCache(), client.Object(&iamv1.KeyRotation{}), &handler.EnqueueRequestForObject{})); err != nil {
logger.Error(err, "cannot watch KeyRotation objects")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", func(*http.Request) error { return nil }); err != nil {
logger.Error(err, "cannot add the health check")
os.Exit(1)
}
logger.Info("starting the key rotation controller")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
logger.Error(err, "manager stopped")
os.Exit(1)
}
}
// buildZitadelClient reads the controller's own credential from the environment.
//
// These are the only values the controller cannot create for itself. They arrive
// from one Secret, created once when the cluster is bootstrapped.
func buildZitadelClient() (*zitadel.Client, error) {
issuer := os.Getenv("ZITADEL_ISSUER")
clientID := os.Getenv("ZITADEL_CLIENT_ID")
userID := os.Getenv("ZITADEL_USER_ID")
keyID := os.Getenv("ZITADEL_KEY_ID")
keyFile := envOr("ZITADEL_KEY_FILE", "/var/run/keys/controller/key")
if issuer == "" || clientID == "" || keyID == "" {
return nil, fmt.Errorf("ZITADEL_ISSUER, ZITADEL_CLIENT_ID and ZITADEL_KEY_ID are required")
}
pem, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("cannot read the controller key: %w", err)
}
return zitadel.New(zitadel.Config{
Issuer: issuer,
ClientID: clientID,
UserID: userID,
KeyID: keyID,
PrivateKeyPEM: pem,
}), nil
}
func envOr(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
-122
View File
@@ -1,122 +0,0 @@
# KeyRotation declares one credential and how to rotate it.
#
# The resource never holds a secret value. It names where the value goes and how
# to prove the new value works. The controller writes the Secret.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: keyrotations.iam.homelab.riotpiao.com
spec:
group: iam.homelab.riotpiao.com
scope: Namespaced
names:
kind: KeyRotation
plural: keyrotations
singular: keyrotation
shortNames: [kr]
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- {name: Provider, type: string, jsonPath: .spec.provider}
- {name: Strategy, type: string, jsonPath: .spec.strategy}
- {name: Phase, type: string, jsonPath: .status.phase}
- {name: Last, type: date, jsonPath: .status.lastRotation}
- {name: Next, type: date, jsonPath: .status.nextRotation}
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [provider, strategy, intervalHours, target]
properties:
provider:
type: string
enum: [zitadel-machine-key, zitadel-oidc-client]
description: >-
Which external API creates the credential.
strategy:
type: string
enum: [overlap, atomic]
description: >-
overlap keeps the old credential valid until the new one is
proven. atomic replaces it in place, because the provider
allows only one value.
intervalHours:
type: integer
minimum: 1
description: How often to rotate.
suspend:
type: boolean
description: Stop automatic rotation without deleting the resource.
target:
type: object
required: [secretName, secretNamespace]
properties:
zitadelProjectID: {type: string}
zitadelAppID: {type: string}
zitadelClientID: {type: string}
machineUserID: {type: string}
secretName: {type: string}
secretNamespace: {type: string}
secretKeys:
type: object
additionalProperties: {type: string}
description: >-
Logical field name to Secret key. Unmapped fields keep
their logical name.
reload:
type: string
enum: [hot, restart]
description: >-
hot means the consumer watches the file. restart rolls the
consumer so it reads the new value at start.
consumers:
type: array
items:
type: object
required: [kind, name, namespace]
properties:
kind: {type: string, enum: [Deployment, StatefulSet]}
name: {type: string}
namespace: {type: string}
verify:
type: object
properties:
introspect:
type: boolean
description: Check client id and secret against introspection.
tokenExchange:
type: boolean
description: Exchange a token with the new machine key.
audienceProject:
type: string
description: Project id added to the token audience scope.
status:
type: object
properties:
phase:
type: string
enum: [Pending, Preparing, Propagating, Verifying, Completing, Succeeded, Failed, Suspended]
observedGeneration: {type: integer, format: int64}
lastRotation: {type: string, format: date-time}
nextRotation: {type: string, format: date-time}
activeCredentialID: {type: string}
previousCredentialID: {type: string}
lastError: {type: string}
conditions:
type: array
items:
type: object
required: [type, status, reason, lastTransitionTime]
properties:
type: {type: string}
status: {type: string}
reason: {type: string}
message: {type: string}
lastTransitionTime: {type: string, format: date-time}
@@ -1,66 +0,0 @@
# The controller. Its own machine key is the single bootstrap credential: it is
# created once and mounted here, and this process creates every other credential.
#
# The image must be built and pushed before this Deployment can start. Until then
# the object stays Pending, which is why no Argo CD Application is wired yet.
apiVersion: apps/v1
kind: Deployment
metadata:
name: key-rotation-controller
namespace: iam
labels:
app: key-rotation-controller
spec:
replicas: 1
selector:
matchLabels:
app: key-rotation-controller
template:
metadata:
labels:
app: key-rotation-controller
spec:
serviceAccountName: key-rotation-controller
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: forgejo.riotpiao.com/riotpiao-poimen/key-rotation-controller:0.1.0
imagePullPolicy: Always
args:
- --scan-interval=10m
env:
- {name: ZITADEL_ISSUER, value: "https://zitadel.riotpiao.com"}
- name: ZITADEL_CLIENT_ID
valueFrom: {secretKeyRef: {name: key-rotation-controller, key: client-id}}
- name: ZITADEL_USER_ID
valueFrom: {secretKeyRef: {name: key-rotation-controller, key: user-id}}
- name: ZITADEL_KEY_ID
valueFrom: {secretKeyRef: {name: key-rotation-controller, key: key-id}}
- {name: ZITADEL_KEY_FILE, value: "/var/run/keys/controller/key"}
ports:
- {name: metrics, containerPort: 8080}
- {name: health, containerPort: 8081}
readinessProbe:
httpGet: {path: /healthz, port: health}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /healthz, port: health}
initialDelaySeconds: 15
volumeMounts:
- {name: controller-key, mountPath: /var/run/keys/controller, readOnly: true}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 20m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
volumes:
- name: controller-key
secret:
secretName: key-rotation-controller
items:
- {key: key, path: key}
@@ -1,28 +0,0 @@
# Example: rotate a service user's machine key with no gap.
#
# Machine keys overlap in ZITADEL, so the old key stays valid until the new one has
# obtained a token. Nothing in the window fails.
#
# Apply only after the controller runs and the machine user exists.
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: example-machine-key
namespace: iam
spec:
provider: zitadel-machine-key
strategy: overlap
intervalHours: 720 # 30 days
suspend: true # flip to false once the pilot is proven
target:
machineUserID: "REPLACE_WITH_SERVICE_USER_ID"
secretName: example-jwt
secretNamespace: iam
secretKeys:
key: key
key-id: key-id
reload: hot # the consumer watches the file, so no restart
consumers: []
verify:
tokenExchange: true
audienceProject: "396757796898471937"
@@ -1,27 +0,0 @@
# Example: rotate an OIDC app's client secret.
#
# ZITADEL keeps one secret per app, so the old value dies at the generate call.
# The strategy is atomic: publish, roll the consumer, verify, and roll back if the
# introspection check fails.
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: example-grafana-client
namespace: iam
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160 # 90 days
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "REPLACE_WITH_APP_ID"
secretName: grafana-oidc
secretNamespace: logging
secretKeys:
secret: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
reload: restart # Grafana reads the value at start
consumers:
- {kind: Deployment, name: grafana, namespace: logging}
verify:
introspect: true
@@ -1,10 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# No Argo CD Application is wired to this directory yet: the controller image must
# exist first. Add the app once the image is pushed, so Argo never reports a
# Deployment that cannot pull.
resources:
- crd.yaml
- rbac.yaml
- deployment.yaml
- rotations.yaml
@@ -1,50 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: key-rotation-controller
namespace: iam
---
# Cluster-wide because a credential and its consumer live in different namespaces:
# the Secret in one, the workload in another.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: key-rotation-controller
rules:
- apiGroups: ["iam.homelab.riotpiao.com"]
resources: ["keyrotations"]
verbs: ["get", "list", "watch"]
- apiGroups: ["iam.homelab.riotpiao.com"]
resources: ["keyrotations/status"]
verbs: ["get", "update", "patch"]
# The controller writes the credential it just created. list and watch are needed
# because the manager caches Secrets; without them the cache cannot start and
# every reconcile fails.
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
# Rolling a consumer that cannot hot reload.
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch", "patch"]
# Leader election. Without this the controller cannot acquire its Lease and never
# starts reconciling.
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: key-rotation-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: key-rotation-controller
subjects:
- kind: ServiceAccount
name: key-rotation-controller
namespace: iam
@@ -1,219 +0,0 @@
# Every credential this cluster owns, described as a KeyRotation.
#
# One object per consumer. The controller creates the value, writes the Secret and
# proves the new value works before it retires the old one. Nothing here holds a
# secret, so this file is safe in git.
#
# suspend: true everywhere on purpose. The controller image does not exist yet, and
# an unsuspended object would claim an intent no process is acting on. Clear the flag
# per object as its pilot passes.
#
# poimen-memory is absent deliberately: it is in use and excluded from this migration.
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: grafana
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: false
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757801948413953" # app resource id, not the client id 396757801965191169
zitadelClientID: "396757801965191169"
secretName: grafana-oidc
secretNamespace: logging
secretKeys:
secret: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
reload: restart
consumers:
- {kind: Deployment, name: grafana, namespace: logging}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: argocd
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757801210216831" # app resource id, not the client id 396757801210282367
zitadelClientID: "396757801210282367"
secretName: argocd-oidc
secretNamespace: argocd
secretKeys:
secret: oidc.clientSecret
reload: restart
consumers:
- {kind: Deployment, name: argocd-server, namespace: argocd}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: comfyui
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "397015743994003854" # app resource id, not the client id 397015744010781070
zitadelClientID: "397015744010781070"
secretName: comfyui-oauth2-proxy
secretNamespace: comfyui
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: comfyui-oauth2-proxy, namespace: comfyui}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: minio
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757803240259585" # app resource id, not the client id 396757803257036801
zitadelClientID: "396757803257036801"
secretName: minio-oidc
secretNamespace: storage
secretKeys:
secret: client-secret
reload: hot
consumers:
- {kind: StatefulSet, name: minio-cluster-az-a, namespace: storage}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: paperless
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757803542249855" # app resource id, not the client id 396757803559027071
zitadelClientID: "396757803559027071"
secretName: paperless-oidc
secretNamespace: paperless
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: paperless, namespace: paperless}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: immich
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757802602725759" # app resource id, not the client id 396757802602791295
zitadelClientID: "396757802602791295"
secretName: immich-oidc
secretNamespace: immich
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: immich-server, namespace: immich}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: forgejo
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757801562538367" # app resource id, not the client id 396757801579315583
zitadelClientID: "396757801579315583"
secretName: forgejo-oidc
secretNamespace: cicd
secretKeys:
secret: CLIENT_SECRET
reload: restart
consumers:
- {kind: Deployment, name: forgejo-gitea, namespace: cicd}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: api-gw
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757800857895295" # app resource id, not the client id 396757800874672511
zitadelClientID: "396757800874672511"
secretName: api-gw-oidc
secretNamespace: api-gw
secretKeys:
secret: client-secret
reload: hot
consumers:
- {kind: Deployment, name: api-gw, namespace: api-gw}
verify:
introspect: true
-125
View File
@@ -1,125 +0,0 @@
# clef-flash: a decision model, not a chat model.
#
# It reads a `state` plus a schema of typed `questions` and returns one probability
# per allowed option in a single prefill pass. There is no decoding, no sampling and
# no free-form text. The public route is /v1/systemone, and its request and response
# shapes are Clef's own: no `messages`, no `choices`.
#
# Why not SGLang: Clef support landed in SGLang v0.5.22, which upstream publishes only
# on CUDA 13. CUDA 13 dropped Volta kernels and every card here is a Tesla V100
# (compute capability 7.0), so SGLang aborts with "no kernel image is available for
# execution on the device". SGLang also states sm75 as its floor regardless.
#
# So this runs the implementation the model ships: joint_schema_model.py, which has
# load_release_model, encode_record, collate_records and systemone. The weights are
# 17.75 GiB of BF16, and fp16 costs the same bytes per parameter. On one 32 GiB card
# that leaves room for a 32k state, which is why this is one GPU and not two: with
# device_map over two cards the embedding table and the tensors indexing into it land
# on different devices, and the forward pass dies with
# "indices should be either on cpu or on the same device as the indexed tensor".
#
# Known gap: the image cannot be pushed to the registry. Cloudflare fronts
# forgejo.riotpiao.com and answers 413 Payload Too Large for any layer above 100 MB,
# and the torch base alone is 7.58 GB. So this mounts app.py from a ConfigMap and pip
# installs on every start. A cold start therefore needs PyPI reachable, and takes
# about four minutes. Build the image in-cluster, or unproxy the registry host, before
# treating this as production.
apiVersion: apps/v1
kind: Deployment
metadata:
name: clef-serve
namespace: llm-serving
labels:
app: clef-serve
app.kubernetes.io/part-of: llm-serving
spec:
replicas: 1
# Recreate, not RollingUpdate. Every GPU card is allocated, so a rolling update leaves
# the new pod Pending forever waiting for a card the old pod still holds.
strategy:
type: Recreate
selector:
matchLabels:
app: clef-serve
template:
metadata:
labels:
app: clef-serve
app.kubernetes.io/part-of: llm-serving
spec:
runtimeClassName: nvidia
nodeSelector:
kubernetes.io/hostname: worker-1
containers:
- name: clef
image: pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime
command: ["/bin/bash","-lc"]
args:
- |
set -e
pip install --no-cache-dir transformers==5.10.2 accelerate fastapi 'uvicorn[standard]' pillow safetensors sentencepiece protobuf
exec uvicorn app:create_app --factory --host 0.0.0.0 --port 8080
workingDir: /app
env:
- name: PYTHONUNBUFFERED
value: "1"
- name: MODEL_PATH
value: /mnt/models/clef-flash
# V100 has no bf16, and the release weights are bf16. fp16 is the same width.
- name: DTYPE
value: float16
# The state is truncated at this length without warning, so raise it only with
# spare VRAM: 17.75 GiB of weights plus 128 KiB of KV per token.
- name: MAX_LEN
value: "32768"
ports:
- containerPort: 8080
# A cold start pip-installs and loads 17.75 GiB, measured at about four minutes.
startupProbe:
httpGet:
path: /health
port: 8080
failureThreshold: 120
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
requests:
cpu: "4"
memory: 12Gi
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: 40Gi
nvidia.com/gpu: "1"
volumeMounts:
- name: app
mountPath: /app
- name: models
mountPath: /mnt/models
readOnly: true
volumes:
- name: app
configMap:
name: clef-serve-app
- name: models
persistentVolumeClaim:
claimName: llm-models
---
apiVersion: v1
kind: Service
metadata:
name: clef-serve
namespace: llm-serving
labels:
app.kubernetes.io/part-of: llm-serving
spec:
selector:
app: clef-serve
ports:
- name: http
port: 80
targetPort: 8080
@@ -1,22 +0,0 @@
# Base image: torch 2.8.0+cu128 still lists sm_70 in torch.cuda.get_arch_list().
# The CUDA 13 builds of torch drop sm_70. worker-1 holds Tesla V100 cards with
# compute capability 7.0, so a newer base kills the kernel launch at load time.
# Keep this pin until the model moves to newer hardware.
FROM pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime
# Unbuffered output lets the pod log show the startup lines in order.
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# torch comes from the base image. Never reinstall it here, or pip pulls a
# wheel built for another CUDA version.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["uvicorn", "app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8080"]
-201
View File
@@ -1,201 +0,0 @@
"""HTTP server for the Clef-flash decision model.
Clef is a decision model and not a chat model. It reads one state plus a schema
of typed questions and returns one probability per allowed option. Consumers
call POST /v1/systemone and never /v1/chat/completions.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
import traceback
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, AsyncIterator
import torch
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from safetensors.torch import load_file
from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
# uvicorn configures its own loggers only. Without this call the root logger
# stays at WARNING and the startup lines below never reach the pod log.
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
LOG = logging.getLogger("clef-serve")
MODEL_PATH = os.environ.get("MODEL_PATH", "/mnt/models/clef-flash")
DTYPE = os.environ.get("DTYPE", "float16")
MAX_LEN = int(os.environ.get("MAX_LEN", "65536"))
PORT = int(os.environ.get("PORT", "8080"))
# HF_HOME stays untouched. The k8s manifest sets it, and the loader must reuse
# that cache path instead of downloading a second copy of an 18.8 GB checkpoint.
_TORCH_DTYPES = {
"float16": torch.float16,
"bfloat16": torch.bfloat16,
"float32": torch.float32,
}
def _resolve_dtype() -> torch.dtype:
"""Map the DTYPE env string to a torch dtype. Fail fast on a typo."""
try:
return _TORCH_DTYPES[DTYPE]
except KeyError:
raise ValueError(
f"The server does not support DTYPE={DTYPE!r}. Use one of {sorted(_TORCH_DTYPES)}."
) from None
def _load_runtime() -> tuple[Any, Any, list[str], Any]:
"""Load backbone, joint head and processor. Runs in a worker thread."""
# The released code imports its sibling modules by bare name. The model
# directory is therefore the only place on sys.path that resolves them.
sys.path.insert(0, MODEL_PATH)
# This import must follow the sys.path.insert above. joint_schema_model.py
# ships inside MODEL_PATH and not in site-packages. An import before that
# insert raises ModuleNotFoundError. A local import binds the name in this
# function scope only, so the caller must receive the callable as a value.
from joint_schema_model import (
ClefModel,
JointSchemaHead,
collate_records,
encode_record,
systemone,
)
dtype = _resolve_dtype()
LOG.info("Loading backbone. path=%s dtype=%s", MODEL_PATH, DTYPE)
# load_release_model passes device_map={"": str(device)} and so pins the
# whole backbone on one card. This loader asks accelerate to shard instead.
backbone = Qwen3_5ForConditionalGeneration.from_pretrained(
MODEL_PATH,
dtype=dtype,
device_map="auto",
low_cpu_mem_usage=True,
)
# The head must sit on the device that produces the final hidden state,
# because ClefModel applies it to the output of the last layer.
head_cfg = json.loads((Path(MODEL_PATH) / "joint_head_config.json").read_text())
head = JointSchemaHead(**head_cfg)
head.load_state_dict(load_file(str(Path(MODEL_PATH) / "joint_head.safetensors")))
# The output embeddings carry the last layer's device. Without them, take
# the last parameter, which lives there too.
output_embeddings = backbone.get_output_embeddings()
if output_embeddings is not None:
head_device = output_embeddings.weight.device
else:
head_device = next(reversed(list(backbone.parameters()))).device
head.to(device=head_device, dtype=dtype)
model = ClefModel(backbone, head).eval()
processor = AutoProcessor.from_pretrained(MODEL_PATH)
# hf_device_map maps layer names to ints for CUDA shards. Report it so a
# human can see whether one card or two carried the weights.
raw_map = getattr(backbone, "hf_device_map", None) or {}
if not raw_map:
raw_map = {"": str(next(backbone.parameters()).device)}
devices = list(
dict.fromkeys(
f"cuda:{name}" if isinstance(name, int) else str(name)
for name in raw_map.values()
)
)
LOG.info(
"Model ready. dtype=%s devices=%s device_map=%s head_device=%s",
DTYPE,
devices,
raw_map,
head_device,
)
return model, processor, devices, systemone
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Load the model on startup without blocking the event loop."""
# A 9.4B load takes minutes. asyncio.to_thread keeps /health answerable
# during that window, so the startup probe sees 503 and not a dead socket.
model, processor, devices, systemone_fn = await asyncio.to_thread(_load_runtime)
app.state.model = model
app.state.processor = processor
app.state.devices = devices
# The route handler runs at module scope and cannot see the local import
# inside _load_runtime. Publish the callable here, or the handler raises
# NameError on the first request.
app.state.systemone = systemone_fn
app.state.lock = asyncio.Lock()
LOG.info("Runtime published. systemone_callable=%s", systemone_fn is not None)
yield
app.state.model = None
app.state.processor = None
app.state.systemone = None
def create_app() -> FastAPI:
app = FastAPI(title="clef-flash", lifespan=_lifespan)
@app.get("/health")
async def health() -> JSONResponse:
# A ready flag alone is not enough. The pod must not take traffic until
# the weights and the joint head are both resident.
if getattr(app.state, "model", None) is None:
return JSONResponse({"status": "loading"}, status_code=503)
return JSONResponse(
{
"status": "ok",
"dtype": DTYPE,
"devices": app.state.devices,
},
status_code=200,
)
@app.post("/v1/systemone")
async def systemone_route(request: Request) -> Any:
model = getattr(app.state, "model", None)
if model is None:
return JSONResponse({"error": "model is not loaded"}, status_code=503)
body = await request.json()
processor = app.state.processor
def _forward() -> dict:
# inference_mode is thread-local. Enter it inside this call, or the
# worker thread records no autograd state saving and still builds
# the graph.
with torch.inference_mode():
return app.state.systemone(model, processor, body, max_length=MAX_LEN)
# Clef runs one prefill with shared buffers and a shared joint head.
# Two calls at once corrupt each other, so serialize them here.
async with app.state.lock:
try:
result = await asyncio.to_thread(_forward)
except ValueError as exc:
# A malformed request body is the caller's fault.
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
LOG.error("systemone failed\n%s", traceback.format_exc())
return JSONResponse(
{"error": type(exc).__name__, "detail": str(exc)},
status_code=500,
)
return result
return app
if __name__ == "__main__":
import uvicorn
uvicorn.run("app:create_app", factory=True, host="0.0.0.0", port=PORT)
@@ -1,12 +0,0 @@
# Runtime dependencies for the clef-flash server.
# torch ships with the base image and stays out of this list.
transformers==5.10.2
# device_map="auto" sharding is an accelerate feature, not a transformers one
accelerate>=1.1.0
fastapi
uvicorn[standard]
pillow
safetensors
huggingface_hub
sentencepiece
protobuf
+8 -7
View File
@@ -45,13 +45,14 @@ spec:
volumeMounts:
- mountPath: /mnt/models
name: models
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-embeddings
app.kubernetes.io/part-of: llm-serving
podMetadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-embeddings
app.kubernetes.io/part-of: llm-serving
maxReplicas: 1
minReplicas: 1
nodeSelector:
+2 -18
View File
@@ -10,27 +10,11 @@ kind: Kustomization
# way after any edit here: a GPU predictor restart is a weights reload measured
# in tens of seconds, not a rolling update.
resources:
# clef-flash runs through clef-serve.yaml, not through an SGLang runtime. SGLang needs
# v0.5.22+, published only on CUDA 13, and CUDA 13 dropped Volta kernels. The model
# instead runs the implementation it ships (joint_schema_model.py) in fp16 on one V100.
- clef-serve.yaml
- reasoning.yaml
# ornith is retired: its InferenceService still reports Ready=True while running zero
# replicas, because desired equals available at 0. Its manifest was never committed,
# so it is left unmanaged and is not listed here. Delete the CR when convenient.
- embeddings.yaml
- ornith.yaml
- reasoning.yaml
- reranker.yaml
- qwen-cpu.yaml
- networkpolicy.yaml
# No namespace transformer: every file sets its own, and the transformer would
# rewrite metadata.namespace on anything cross-namespace added later.
# app.py lives in git next to this file. The generator hashes the ConfigMap name, so an
# edit to app.py rolls the pod instead of leaving a stale copy mounted.
configMapGenerator:
- name: clef-serve-app
namespace: llm-serving
files:
- clef-serve/app.py
options:
disableNameSuffixHash: false
+107
View File
@@ -0,0 +1,107 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
# The konghq.com/{connect,read,write}-timeout annotations that used to live
# here went with Kong (retired 2026-08-19). They existed because Kong read
# its upstream timeouts off the Kubernetes Service, and its 60s default cut
# off the first request after any pod restart — a restart flushes VRAM and
# reloading ornith:35b takes longer than that. OLLAMA_KEEP_ALIVE=-1 hid the
# problem in steady state.
#
# The equivalent budget now belongs to the Go gateway's per-route timeout
# config in rock/homelab-frontend, not to an annotation on this object.
labels:
app.kubernetes.io/name: llm-ornith
app.kubernetes.io/part-of: llm-serving
name: ornith
namespace: llm-serving
spec:
predictor:
containers:
- command:
- /bin/sh
- -c
- 'set -e
ollama serve &
SERVE_PID=$!
until ollama list >/dev/null 2>&1; do sleep 2; done
ollama pull ornith:35b
ollama run ornith:35b "ok" >/dev/null 2>&1 || true
wait $SERVE_PID
'
env:
- name: OLLAMA_HOST
value: 0.0.0.0:8080
- name: OLLAMA_MODELS
value: /mnt/models/ollama
- name: OLLAMA_CONTEXT_LENGTH
value: '65536'
- name: OLLAMA_KEEP_ALIVE
value: '-1'
- name: OLLAMA_NUM_PARALLEL
value: '1'
- name: OLLAMA_MAX_LOADED_MODELS
value: '1'
image: ollama/ollama:0.32.9@sha256:1685741456770df6e3cceb2a945a5f75e020f658d1701509668d6f4688f1dd3f
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
exec:
command:
- /bin/sh
- -c
- ollama ps 2>/dev/null | grep -q ornith
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 16Gi
nvidia.com/gpu: '1'
requests:
cpu: '8'
memory: 8Gi
nvidia.com/gpu: '1'
startupProbe:
exec:
command:
- /bin/sh
- -c
- ollama ps 2>/dev/null | grep -q ornith
failureThreshold: 120
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
podMetadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-ornith
app.kubernetes.io/part-of: llm-serving
deploymentStrategy:
type: Recreate
# 1 replica -- ornith:35b only. qwen2.5:3b moved to CPU on cp-2.
# Frees 1 GPU for ComfyUI.
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
-151
View File
@@ -1,151 +0,0 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
# KServe injects the prometheus.io/{scrape,port,path} annotations into the
# generated pod only when this flag is on. Without it the predictor carries
# no scrape annotations at all, which is why no vLLM/SGLang metrics have
# ever reached Prometheus from this namespace.
serving.kserve.io/enable-prometheus-scraping: "true"
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
name: reasoning
namespace: llm-serving
spec:
predictor:
containers:
- args:
# Replaces the dense Qwen3-32B. Same serving name (`reasoning`), so every
# consumer keeps its model string and no client changes.
#
# Why a MoE: Qwen3-30B-A3B has 30.5B total but only 3.3B active per token.
# On sm70 the decode path is bandwidth- and kernel-launch-bound, not
# FLOP-bound, so activating 3.3B instead of 32B is the single biggest
# throughput win available on this hardware without changing GPUs.
#
# Quantization: GPTQ-Int4, pinned with --quantization=gptq. This repeats
# the finding recorded on the previous checkpoint: bnb-4bit works poorly
# on sm70 because there are no int4 tensor cores, and vLLM auto-upgrades
# AWQ/GPTQ to the Marlin kernel which requires sm80+. Pinning `gptq`
# forces the plain kernel, which is verified Volta-compatible here.
# Do not remove this flag.
#
# Context: 131072 via YaRN. The base model card documents 32,768 native
# and 131,072 with YaRN (factor 4.0, original_max_position_embeddings
# 32768). That is Qwen's own validated ceiling, so unlike a stretched
# window this is a documented capability.
#
# KV cache math, from config.json: 48 layers, 4 KV heads, head_dim 128,
# fp16 -> 2 * 4 * 128 * 2 = 2048 B/token/layer -> 96 KB/token.
# At 131072 tokens that is 12 GiB total. With TP=2 the KV heads split
# 2 per GPU, so each GPU carries ~6 GiB of KV. Weights are ~17 GB total,
# so ~8.5 GB per GPU. At gpu-memory-utilization=0.95 (about 30.4 GB
# usable per V100-32GB) that leaves roughly 21 GB per GPU for KV plus
# activations: comfortable for one full 128K request and a few shorter
# ones. Re-read vllm:cache_config_info after rollout for the real
# num_gpu_blocks value rather than trusting this projection.
#
# TP=2, not PP=2: the model has 4 KV heads, so TP must divide 4. TP=2
# splits cleanly; TP=3 would not. TP also shortens prefill at long
# context, which is the actual bottleneck at 128K on V100.
- --model=Qwen/Qwen3-30B-A3B-GPTQ-Int4
- --served-model-name=reasoning
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
- --rope-scaling={"rope_type":"yarn","factor":4.0,"original_max_position_embeddings":32768}
- --tensor-parallel-size=2
- --max-model-len=131072
- --gpu-memory-utilization=0.95
- --max-num-seqs=4
# Prefill scheduling. The same reasoning as the previous checkpoint
# applies: vLLM V1 has no separate prefill phase, so a single long
# prompt can consume the whole step budget and starve every other
# request. The threshold must be strictly below the budget or it never
# binds, hence 8192 budget with a 2048 threshold.
- --max-num-batched-tokens=8192
- --long-prefill-token-threshold=2048
- --enable-chunked-prefill
- --enable-prefix-caching
# qwen3 is vLLM's reasoning parser for this family's <think> blocks.
- --reasoning-parser=qwen3
# hermes is the documented tool-call parser for general Qwen3 models.
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --host=0.0.0.0
- --port=8080
env:
# FlashInfer is sm80+. TRITON_ATTN is the Volta-compatible backend and is
# already the proven setting on this node. Clef-flash runs on SGLang
# instead, but Qwen stays on vLLM here because this exact combination is
# tested on this hardware; moving it to SGLang would add risk for no
# measured gain.
- name: VLLM_USE_FLASHINFER_SAMPLER
value: '0'
- name: VLLM_ATTENTION_BACKEND
value: TRITON_ATTN
- name: HF_HOME
value: /mnt/models
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 36Gi
nvidia.com/gpu: '2'
requests:
cpu: '8'
memory: 12Gi
nvidia.com/gpu: '2'
startupProbe:
# 30.5B of int4 weights plus a 128K rope config: allow a generous
# first-boot window. failureThreshold 120 * 15s = 30 min ceiling.
failureThreshold: 120
httpGet:
path: /health
port: 8080
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
# podMetadata is NOT a valid field on this KServe version. The CRD exposes
# annotations and labels directly under predictor. The older manifests in
# this directory still carry podMetadata and ArgoCD silently drops it on
# server-side apply; do not copy that pattern.
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
deploymentStrategy:
# Recreate, not RollingUpdate: GPU memory is a hard resource. A second
# pod cannot schedule alongside the first on a full 4-GPU node, so a
# rolling update would wedge with the new pod Pending forever.
type: Recreate
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- emptyDir:
medium: Memory
sizeLimit: 2Gi
name: shm
+108 -48
View File
@@ -1,66 +1,113 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: reasoning
namespace: llm-serving
annotations:
serving.kserve.io/deploymentMode: RawDeployment
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
annotations:
serving.kserve.io/deploymentMode: RawDeployment
name: reasoning
namespace: llm-serving
spec:
predictor:
minReplicas: 1
maxReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
# Every GPU card is allocated, so a rolling update deadlocks: the new pod needs a
# card the old pod still holds and sits Pending forever. Recreate frees the card
# first. The cost is a short gap in service for this model only.
deploymentStrategy:
type: Recreate
containers:
- name: kserve-container
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
args:
# Qwen3-30B-A3B is a 30.5B MoE with 3.3B active per token. On a V100 that matters:
# the 32B dense model it replaces needed two cards for the same context, this needs
# one, because its KV cache is 96 KiB/token against the dense model's 256 KiB.
#
# Measured on this cluster, one V100-32GB, 64k context: 15.55 GiB of weights,
# 137k tokens of KV cache, 2.09x concurrency at 65536 tokens per request.
- --model=Qwen/Qwen3-30B-A3B-GPTQ-Int4
- args:
# bnb-4bit retired: no int4 tensor cores on sm70/V100, dequant-then-
# matmul is two slow kernel launches instead of one fused int4 GEMM,
# decode crawled at 2.5-10 tok/s regardless of TP/PP. Switched to
# JunHowie/Qwen3-32B-GPTQ-Int4 -- same dense Qwen3-32B weights, same
# hermes/qwen3 parser stack (no narration-bug risk, same as before),
# only the quant format changes. Plain (non-Marlin) GPTQ kernel is
# confirmed Volta-compatible; Marlin needs sm80+ and vLLM would try
# to auto-upgrade to it, so --quantization is pinned explicitly to
# `gptq` to force the plain kernel. Verified checkpoint size: 19.34GB
# (summed from the real safetensors index, not bits-per-param math).
# max-model-len=131072 is Qwen3-32B's real ceiling (config.json YaRN:
# factor=4.0, original_max_position_embeddings=32768) -- 200k was
# asked for but exceeds this architecturally regardless of VRAM.
# KV cache math: 256KB/token total (64 layers, 8 KV heads, 128
# head_dim, fp16), PP=2 splits both weights and KV load ~evenly, so
# each GPU carries ~9.67GB weights + ~128KB/token KV. At
# gpu-memory-utilization=0.95 (30.4GB/GPU usable), that leaves
# ~20.7GB/GPU for KV cache. NOTE this is a projection, not a
# measurement: the live reading at 0.90 is num_gpu_blocks=9527 x
# block_size=16 = 152,432 tokens (vllm:cache_config_info). Re-read that
# metric after the rollout -- max_num_batched_tokens=8192 also raises
# activation memory out of the same budget, so the post-boot
# num_gpu_blocks is the authoritative number, and --max-num-seqs=5 is
# only valid if it still covers 2x65k + 3x8k. The old
# OffloadingConnector CPU-DRAM spillover (tuned for the previous
# model's much smaller 16384 context) is no longer needed and is
# dropped. Staying on PP=2 and vLLM 0.11.0 (no version bump needed,
# this checkpoint only requires vllm>=0.9.2) -- plain GPTQ has no
# TP>1 restriction unlike bnb, so tensor-parallel-size=2 is worth
# trying later, but not risking a parallelism-strategy change in the
# same rollout as the quant+context-length change.
# This GPTQ requant's own config.json ships max_position_embeddings=
# 40960 and rope_scaling=None -- confirmed directly (curl'd the raw
# config.json), the base Qwen3-32B repo's YaRN block did NOT carry
# over during quantization. Re-applying it explicitly here restores
# the same math the base model documents (32768 * 4.0 = 131072);
# without this, --max-model-len=131072 fails ModelConfig validation
# against the checkpoint's own (unscaled) 40960 ceiling.
- --model=JunHowie/Qwen3-32B-GPTQ-Int4
- --served-model-name=reasoning
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
# Native window is 40960. YaRN factor 2.0 reaches 81920, so 64k is inside it.
- --rope-scaling={"rope_type":"yarn","factor":2.0,"original_max_position_embeddings":40960}
- --rope-scaling={"rope_type":"yarn","factor":4.0,"original_max_position_embeddings":32768}
- --tensor-parallel-size=1
- --pipeline-parallel-size=1
- --max-model-len=65536
- --gpu-memory-utilization=0.90
- --max-num-seqs=8
- --pipeline-parallel-size=2
- --max-model-len=131072
- --gpu-memory-utilization=0.95
- --max-num-seqs=5
# Prefill scheduling -- see the measured starvation this fixes.
# V1's scheduler has no prefill/decode phase: it walks self.running and
# hands out tokens from one shared budget (vllm/v1/core/sched/scheduler.py:
# "There's no decoding phase nor prefill phase in the scheduler"). The cap
# per request is applied BEFORE the budget clamp:
# if 0 < long_prefill_token_threshold < num_new_tokens: take = threshold
# take = min(take, token_budget)
# The loop is `while ... and token_budget > 0`. With the threshold at its
# default of 0 the cap is disabled, so one long prompt takes the ENTIRE
# budget, token_budget hits 0, the loop exits and every other request is
# skipped for that step -- measured as 262 tok/s contended prefill and
# 0.0-0.1 tok/s generation for everything else, Running: 2-4 reqs.
# Hence threshold must be strictly BELOW the budget or it never binds:
# at 8192/8192 a single request is served per step (still starved), while
# 8192/2048 serves four. Verified by replaying the loop.
- --max-num-batched-tokens=8192
- --long-prefill-token-threshold=2048
- --enable-chunked-prefill
- --enable-prefix-caching
#
# NOT set, deliberately: --max-num-partial-prefills and
# --max-long-partial-prefills. Those are V0-only knobs. vLLM 0.11.0 ships
# V1 only (vllm/core/ absent), and EngineArgs._is_v1_supported_oracle
# rejects any value other than the default for either one, calling
# _raise_or_fallback("Concurrent Partial Prefill") -> "Falling back to V0"
# -> AssertionError, because there is no V0 engine to fall back to.
# The Deployment uses strategy Recreate, so setting them is an outage.
# V1 does concurrent chunked prefill natively; it needs no flag, only the
# budget/threshold ratio above.
# qwen3 is vLLM's dedicated reasoning parser for this family's <think>
# blocks.
- --reasoning-parser=qwen3
# hermes is the documented tool-call parser for general (non-Coder)
# Qwen3 models -- native chat template support, not narrated text.
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --host=0.0.0.0
- --port=8080
env:
# Both are required on this hardware, not tuning:
# - flashinfer's check_cuda_arch raises AttributeError on compute capability 7.0
# (flashinfer/jit/core.py:55, minor.isdigit() on an int), so the sampler must stay off
# - FlashInfer and FlashAttention-2 both need sm75+, so the Triton backend serves attention
- name: VLLM_USE_FLASHINFER_SAMPLER
value: "0"
value: '0'
- name: VLLM_ATTENTION_BACKEND
value: TRITON_ATTN
- name: HF_HOME
value: /mnt/models
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
@@ -69,34 +116,47 @@ spec:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 36Gi
nvidia.com/gpu: '2'
requests:
cpu: '8'
memory: 12Gi
nvidia.com/gpu: '2'
startupProbe:
failureThreshold: 80
httpGet:
path: /health
port: 8080
# Weight load took 18s from cache and 148s on a cold download, and the first
# torch.compile adds about 70s. 80 x 15s leaves ample room for a cold start.
failureThreshold: 80
periodSeconds: 15
resources:
requests:
cpu: "4"
memory: 12Gi
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: 36Gi
nvidia.com/gpu: "1"
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
podMetadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
deploymentStrategy:
type: Recreate
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- name: shm
emptyDir:
- emptyDir:
medium: Memory
sizeLimit: 2Gi
name: shm
+8 -7
View File
@@ -45,13 +45,14 @@ spec:
volumeMounts:
- mountPath: /mnt/models
name: models
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-reranker
app.kubernetes.io/part-of: llm-serving
podMetadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-reranker
app.kubernetes.io/part-of: llm-serving
maxReplicas: 1
minReplicas: 1
nodeSelector:
-10
View File
@@ -1,10 +0,0 @@
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod ./
COPY *.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/vendor .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/vendor /usr/local/bin/vendor
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/vendor"]
-3
View File
@@ -1,3 +0,0 @@
module github.com/riotpiao/llm-token-vendor
go 1.26.0
Binary file not shown.
-197
View File
@@ -1,197 +0,0 @@
// llm-token-vendor mints ZITADEL access tokens for a caller that cannot sign.
//
// Some callers are vendor images: paperless-ai can only read a bearer token from a
// file. It cannot sign an assertion, so it cannot use a machine key, and a client
// secret would rotate with a window.
//
// This sidecar closes the gap. It signs with a machine key, exchanges the assertion
// for an access token, and writes the token to the file the vendor image already
// reads. The file is replaced atomically and refreshed at 80% of the token lifetime,
// so the vendor never reads a partial or expired value.
//
// The key is re-read on every mint. A rotation therefore lands without a restart:
// publish the new key, the next mint uses it, retire the old one.
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
)
type config struct {
tokenURL string
issuer string
userID string
keyID string
keyPath string
scope string
outPath string
}
func main() {
var c config
flag.StringVar(&c.tokenURL, "token-url", env("TOKEN_URL", ""), "ZITADEL token endpoint")
flag.StringVar(&c.issuer, "issuer", env("ISSUER", ""), "ZITADEL issuer, the assertion audience")
flag.StringVar(&c.userID, "user-id", env("USER_ID", ""), "machine user id")
flag.StringVar(&c.keyID, "key-id", env("KEY_ID", ""), "machine key id")
flag.StringVar(&c.keyPath, "key-file", env("KEY_FILE", ""), "PEM private key, re-read on every mint")
flag.StringVar(&c.scope, "scope", env("SCOPE", "openid"), "requested scopes")
flag.StringVar(&c.outPath, "out", env("OUT", ""), "file the vendor image reads")
flag.Parse()
if c.tokenURL == "" || c.issuer == "" || c.userID == "" || c.keyPath == "" || c.outPath == "" {
log.Fatal("token-url, issuer, user-id, key-file and out are required")
}
for {
lifetime, err := mintAndWrite(c)
if err != nil {
// Exit rather than spin: a pod that cannot mint must be visible, and the
// vendor image must not keep serving the caller a stale token.
log.Fatalf("cannot mint a token: %v", err)
}
wait := time.Duration(float64(lifetime) * 0.8)
log.Printf("token written to %s, next mint in %s", c.outPath, wait.Round(time.Second))
time.Sleep(wait)
}
}
func mintAndWrite(c config) (time.Duration, error) {
pemBytes, err := os.ReadFile(c.keyPath)
if err != nil {
return 0, fmt.Errorf("cannot read the key %s: %w", c.keyPath, err)
}
assertion, err := assertion(c, time.Now(), pemBytes)
if err != nil {
return 0, err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
form.Set("scope", c.scope)
resp, err := http.PostForm(c.tokenURL, form)
if err != nil {
return 0, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &out); err != nil {
return 0, fmt.Errorf("cannot decode the token response: %w", err)
}
if out.AccessToken == "" {
return 0, fmt.Errorf("the token response holds no access_token")
}
// Write beside the target, then rename. A rename is atomic, so the vendor either
// reads the previous token or the new one, never a half-written file.
tmp, err := os.CreateTemp(filepath.Dir(c.outPath), ".token-*")
if err != nil {
return 0, fmt.Errorf("cannot create a temporary file: %w", err)
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(out.AccessToken); err != nil {
tmp.Close()
return 0, fmt.Errorf("cannot write the token: %w", err)
}
tmp.Close()
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return 0, fmt.Errorf("cannot set the file mode: %w", err)
}
if err := os.Rename(tmp.Name(), c.outPath); err != nil {
return 0, fmt.Errorf("cannot replace %s: %w", c.outPath, err)
}
lifetime := time.Duration(out.ExpiresIn) * time.Second
if lifetime <= 0 {
lifetime = 5 * time.Minute
}
return lifetime, nil
}
func assertion(c config, now time.Time, pemBytes []byte) (string, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return "", fmt.Errorf("the key file is not PEM")
}
var key *rsa.PrivateKey
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
key = k
} else {
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the key is not an RSA private key: %w", err)
}
rk, ok := parsed.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("the key holds %T, not RSA", parsed)
}
key = rk
}
enc := func(v any) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
header := map[string]any{"alg": "RS256", "typ": "JWT"}
if c.keyID != "" {
header["kid"] = c.keyID
}
claims := map[string]any{
"iss": c.userID,
"sub": c.userID,
"aud": c.issuer,
// Backdated, so a clock that is a few seconds fast cannot make the assertion
// "not yet valid".
"iat": now.Add(-5 * time.Second).Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
}
h, err := enc(header)
if err != nil {
return "", err
}
cl, err := enc(claims)
if err != nil {
return "", err
}
signing := h + "." + cl
digest := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("cannot sign the assertion: %w", err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
@@ -1,64 +0,0 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"strings"
"testing"
"time"
)
func key(t *testing.T) []byte {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("cannot generate a key: %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})
}
func seg(t *testing.T, s string, out any) {
t.Helper()
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatalf("not base64url: %v", err)
}
if err := json.Unmarshal(raw, out); err != nil {
t.Fatalf("not JSON: %v", err)
}
}
func TestAssertionCarriesTheKeyAndTheAudience(t *testing.T) {
c := config{issuer: "https://zitadel.example", userID: "3970", keyID: "3971"}
tok, err := assertion(c, time.Unix(1_700_000_000, 0), key(t))
if err != nil {
t.Fatalf("signing failed: %v", err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("a JWT has three segments, got %d", len(parts))
}
var h map[string]any
seg(t, parts[0], &h)
if h["kid"] != "3971" || h["alg"] != "RS256" {
t.Fatalf("header must name the key and the algorithm, got %v", h)
}
var cl map[string]any
seg(t, parts[1], &cl)
if cl["iss"] != "3970" || cl["sub"] != "3970" || cl["aud"] != "https://zitadel.example" {
t.Fatalf("claims are wrong: %v", cl)
}
if int64(cl["iat"].(float64)) != 1_699_999_995 {
t.Fatalf("iat must be backdated five seconds, got %v", cl["iat"])
}
}
func TestAssertionRejectsAKeyThatIsNotPEM(t *testing.T) {
if _, err := assertion(config{userID: "u", issuer: "i"}, time.Now(), []byte("nope")); err == nil {
t.Fatalf("a non-PEM key must be rejected")
}
}
@@ -6,7 +6,7 @@ metadata:
data:
KMSVC_KAFKA_BROKERS: {{ .Values.env.kafkaBrokers | quote }}
KMSVC_REDIS_ADDR: {{ .Values.env.redisAddr | quote }}
KMSVC_ZITADEL_ISSUER_URL: {{ .Values.env.zitadelIssuerURL | quote }}
KMSVC_ZITADEL_AUDIENCE: {{ .Values.env.zitadelAudience | quote }}
KMSVC_AUTHENTIK_ISSUER_URL: {{ .Values.env.authentikIssuerURL | quote }}
KMSVC_AUTHENTIK_AUDIENCE: {{ .Values.env.authentikAudience | quote }}
KMSVC_GRPC_LISTEN_ADDR: ":{{ .Values.grpcPort }}"
KMSVC_HTTP_LISTEN_ADDR: ":{{ .Values.httpPort }}"
@@ -12,8 +12,8 @@ httpPort: 8080
env:
kafkaBrokers: "kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.sqs.svc.cluster.local:6379"
zitadelIssuerURL: ""
zitadelAudience: ""
authentikIssuerURL: ""
authentikAudience: ""
resources:
requests:
+46 -55
View File
@@ -9,14 +9,6 @@ metadata:
app.kubernetes.io/name: paperless-ai
spec:
replicas: 1
# Recreate, not RollingUpdate: paperless-ai-data is a single RWO PVC. With
# RollingUpdate the new pod tries to mount the PVC while the old pod still
# holds it -> "Multi-Attach error" / stuck Init:0/1 forever, rollout wedged
# at "1 desired, 1 ready, 1 updated, 1 unavailable". Recreate terminates the
# old pod first so the PVC detaches before the new one starts. Same pattern
# as the Forgejo gitea Deployment.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless-ai
@@ -36,54 +28,53 @@ spec:
- name: paperless-ai-env-config
configMap:
name: paperless-ai-env
- name: llm-key
secret:
secretName: paperless-llm-key
# 0440 with the pod's fsGroup. A Secret volume is owned by root, and the
# vendor container runs as 65532, so 0400 left the sidecar unable to read
# its own key: "permission denied" on every start.
defaultMode: 0440
# No init container. The token used to be fetched once at pod start with a
# client_credentials grant against a web app, which cannot work: a web app has
# no such grant, and even a token that was minted would be dead within the hour
# while this pod runs for weeks.
#
# llm-token-vendor runs beside the app instead. It signs a machine-key assertion,
# exchanges it for a token, and rewrites /app/data/llm_token.txt at 80% of the
# token lifetime. The vendor image keeps reading CUSTOM_API_KEY_FILE unchanged.
# The key is re-read on every mint, so a rotation lands without a restart.
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
containers:
- name: llm-token-vendor
image: forgejo.riotpiao.com/riotpiao-poimen/llm-token-vendor:0.1.0
imagePullPolicy: Always
args:
- --token-url=https://zitadel.riotpiao.com/oauth/v2/token
- --issuer=https://zitadel.riotpiao.com
- --user-id=397072668735570071
- --key-id=397072674272051351
- --key-file=/var/run/llm-key/key
# No project audience. ZITADEL puts the requested audience in `aud`, and
# the gateway compares `aud` with its allow-list of client ids. Asking for
# the project id made every token carry the project, so the gateway
# answered "audience not allowed: [396757796898471937]". With plain
# `openid` the token names its caller, which is the entry that exists.
- --scope=openid
- --out=/app/data/llm_token.txt
volumeMounts:
- {name: paperless-ai-data, mountPath: /app/data}
- {name: llm-key, mountPath: /var/run/llm-key, readOnly: true}
initContainers:
- name: fetch-llm-token
image: curlimages/curl:8.12.0
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
runAsNonRoot: true
runAsUser: 65532
seccompProfile: {type: RuntimeDefault}
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
runAsUser: 0
fsGroup: 0
command:
- sh
- -c
- |
set -e
echo "[init] Fetching JWT token from Authentik for paperless-ai-agent..."
# Get JWT token via client_credentials grant
# Client secret sourced from environment (injected from paperless-ai-config Secret)
TOKEN_RESPONSE=$(curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
-d "grant_type=client_credentials" \
-d "client_id=396757803559027071" \
-d "client_secret=${LLM_AUTH_CLIENT_SECRET}" \
-d "scope=openid llm:inference" 2>/dev/null)
# Extract token from JSON response using sed (handles spaces after colons)
TOKEN=$(echo "$TOKEN_RESPONSE" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [ -z "$TOKEN" ]; then
echo "[error] Failed to get token. Response: $TOKEN_RESPONSE"
exit 1
fi
# Store token in file for main container to read
# Write to both locations for compatibility
mkdir -p /tmp/llm-token /app/data 2>/dev/null || true
echo "$TOKEN" | tee /tmp/llm-token/llm_token.txt > /dev/null 2>&1
echo "$TOKEN" > /app/data/llm_token.txt 2>/dev/null || true
echo "[init] Token fetched and stored"
[ -f /tmp/llm-token/llm_token.txt ] && echo " -> /tmp/llm-token/llm_token.txt"
[ -f /app/data/llm_token.txt ] && echo " -> /app/data/llm_token.txt"
env:
- name: LLM_AUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: paperless-ai-config
key: LLM_AUTH_CLIENT_SECRET
volumeMounts:
- name: paperless-ai-data
mountPath: /app/data
containers:
- name: paperless-ai
image: clusterzx/paperless-ai:latest
env:
@@ -0,0 +1,144 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: secretrotations.homelab.riotpiao.com
spec:
group: homelab.riotpiao.com
names:
kind: SecretRotation
plural: secretrotations
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
metadata:
type: object
spec:
type: object
required:
- provider
- rotationInterval
properties:
# External system: authentik | forgejo | minio | vault
provider:
type: string
enum: [authentik, forgejo, minio, vault]
# How often to rotate (hours)
rotationInterval:
type: integer
minimum: 24
# Application ID in external system
appId:
type: string
# k8s Secret to update (name, namespace, key)
secretRef:
type: object
required: [name, namespace]
properties:
name:
type: string
namespace:
type: string
key:
type: string
description: "Secret key to update (e.g., MINIO_IDENTITY_OPENID_CLIENT_SECRET)"
# Path to git file that holds the secret (for .enc.yaml files)
gitPath:
type: string
description: "Path in homelab repo to .enc.yaml file"
# Ansible template values to substitute
templateValues:
type: object
additionalProperties:
type: string
status:
type: object
properties:
lastRotationTime:
type: string
format: date-time
nextRotationTime:
type: string
format: date-time
lastRotationStatus:
type: string
enum: [Success, Failed, Pending]
lastRotationError:
type: string
lastCommitHash:
type: string
---
# Example usage:
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: minio-oidc
namespace: secret-rotation
spec:
provider: authentik
rotationInterval: 2160 # 90 days in hours
appId: minio
secretRef:
name: minio-oidc
namespace: storage
key: MINIO_IDENTITY_OPENID_CLIENT_SECRET
gitPath: k8s/argocd/secrets/minio-oidc.enc.yaml
---
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: portfolio-agent-oidc
namespace: secret-rotation
spec:
provider: authentik
rotationInterval: 2160
appId: portfolio-agent
secretRef:
name: portfolio-agent-oidc
namespace: portfolio
key: CLIENT_SECRET
gitPath: k8s/argocd/secrets/portfolio-agent-oidc.enc.yaml
---
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: forgejo-registry-token
namespace: secret-rotation
spec:
provider: forgejo
rotationInterval: 2160
appId: rock/riotpiao.com
secretRef:
name: forgejo-registry-secret
namespace: kube-system
key: REGISTRY_TOKEN
gitPath: k8s/argocd/secrets/forgejo-registry-secret.enc.yaml
---
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: minio-root-credentials
namespace: secret-rotation
spec:
provider: minio
rotationInterval: 4320 # 180 days in hours
appId: root
secretRef:
name: minio-creds
namespace: storage
gitPath: k8s/argocd/secrets/minio-secrets.enc.yaml
@@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: secret-rotation-controller
namespace: secret-rotation
spec:
replicas: 1
selector:
matchLabels:
app: secret-rotation-controller
template:
metadata:
labels:
app: secret-rotation-controller
spec:
serviceAccountName: secret-rotation-controller
containers:
- name: controller
image: secret-rotation-controller:latest
imagePullPolicy: IfNotPresent
env:
# SOPS reads age key from this file
- name: SOPS_AGE_KEY_FILE
value: /etc/sops/age/private-key.txt
# Vault auth (token in projected volume)
- name: VAULT_ADDR
value: http://vault.vault.svc.cluster.local:8200
- name: VAULT_TOKEN_FILE
value: /var/run/secrets/vault/token
# Authentik
- name: AUTHENTIK_URL
value: http://authentik-server.iam.svc.cluster.local
- name: AUTHENTIK_BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: authentik-bootstrap
key: token
# Git
- name: GIT_REPO
value: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
- name: GIT_AUTHOR_EMAIL
value: [email protected]
- name: GIT_AUTHOR_NAME
value: Secret Rotation Controller
- name: FORGEJO_TOKEN
valueFrom:
secretKeyRef:
name: forgejo-registry-secret
key: REGISTRY_TOKEN
volumeMounts:
# Age key from ExternalSecret (synced from Vault)
- name: age-key
mountPath: /etc/sops/age
readOnly: true
# Vault auth token (projected)
- name: vault-token
mountPath: /var/run/secrets/vault
readOnly: true
# Temp working dir
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: age-key
secret:
secretName: sops-age-key
defaultMode: 0400
- name: vault-token
projected:
sources:
- serviceAccountToken:
path: token
audience: vault
expirationSeconds: 3600
- name: tmp
emptyDir: {}
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: secret-rotation
resources:
- rbac.yaml
- crd.yaml
- external-secret.yaml
- deployment.yaml
commonLabels:
app.kubernetes.io/name: secret-rotation-controller
app.kubernetes.io/component: automation
managed-by: argocd
@@ -0,0 +1,53 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: secret-rotation-controller
namespace: secret-rotation
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-rotation-controller
rules:
# Read SecretRotation CRDs
- apiGroups: ["homelab.riotpiao.com"]
resources: ["secretrotations"]
verbs: ["get", "list", "watch"]
# Update status
- apiGroups: ["homelab.riotpiao.com"]
resources: ["secretrotations/status"]
verbs: ["get", "patch", "update"]
# Read k8s secrets that will be rotated
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
# For recording events
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: secret-rotation-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: secret-rotation-controller
subjects:
- kind: ServiceAccount
name: secret-rotation-controller
namespace: secret-rotation
---
apiVersion: v1
kind: Namespace
metadata:
name: secret-rotation
labels:
kubernetes.io/metadata.name: secret-rotation
+32
View File
@@ -0,0 +1,32 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: secret-rotation
namespace: argocd
labels:
app.kubernetes.io/name: secret-rotation
spec:
project: homelab
sources:
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
path: k8s/apps/secret-rotation-controller
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: secret-rotation
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- RespectIgnoreDifferences=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
+29 -38
View File
@@ -30,6 +30,33 @@ spec:
syncOptions:
- CreateNamespace=true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: authentik
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: homelab
sources:
- repoURL: https://charts.goauthentik.io
chart: authentik
targetRevision: "*"
helm:
valueFiles:
- $values/k8s/infra/iam/authentik-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: iam
syncPolicy:
automated:
prune: true
selfHeal: true
---
# ZITADEL — the Authentik replacement. Coexistence, not cutover: Authentik keeps
# serving in the same `iam` namespace until the last consumer moves, then this
# becomes the only IdP. Migration plan (outside git):
@@ -79,47 +106,11 @@ spec:
syncOptions:
- CreateNamespace=true
---
# ZITADEL core objects (ServiceAccount, Role, RoleBinding, ConfigMap).
#
# The chart declares these as pre-install/pre-upgrade Helm hooks, and ArgoCD
# prunes hook resources when a sync ends. That deleted the ServiceAccount and
# ConfigMap the ZITADEL Deployment references, so new pods failed to start with
# `serviceaccount "zitadel" not found`. This Application owns hook-free copies,
# which makes them ordinary desired state that survives every sync.
#
# Wave 2: before the zatadel chart itself, so the ServiceAccount exists by the
# time its Deployment is created.
# Raw IAM manifests: key-rotation cronjob + authentik migration job.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: zitadel-core
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/zitadel-core
destination:
server: https://kubernetes.default.svc
namespace: iam
syncPolicy:
automated:
prune: false
selfHeal: true
syncOptions:
- CreateNamespace=true
---
# Raw IAM support manifests: the SMTP -> Gotify notification relay that ZITADEL
# points at, so verification and password-reset codes reach the phone. Wave 3
# alongside ZITADEL itself; the Secret it reads is created by sops-secrets at
# wave 0.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: iam-support
name: iam-jobs
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
+1 -1
View File
@@ -1,4 +1,4 @@
# Wave 2 — dedicated per-app CNPG Postgres clusters (zitadel-db → ns iam,
# Wave 2 — dedicated per-app CNPG Postgres clusters (authentik-db → ns iam,
# temporal-db + visibility → ns temporal). ONE App, ONE folder (k8s/infra/databases).
# CNPG operator is Phase-0 bootstrap; these Cluster CRs are GitOps — no circular
# dep (they run after ArgoCD is up, before their apps at w3/w8). CNPG generates
+34
View File
@@ -171,6 +171,40 @@ spec:
syncOptions:
- CreateNamespace=true
---
# Consolidated: homarr + homarr-patches → homarr
# Helm chart + values + PostSync hook patch (fix-probes-job.yaml)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: homarr
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "8"
spec:
project: homelab
sources:
- repoURL: https://homarr-labs.github.io/charts
chart: homarr
targetRevision: "*"
helm:
valueFiles:
- $values/k8s/apps/homarr/homarr-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
ref: values
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/apps/homarr # PostSync hook: fix-probes-job.yaml
destination:
server: https://kubernetes.default.svc
namespace: dashboard
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
# Portfolio site at riotpiao.com - static Next.js site from rock/riotpiao.com repo.
# Points directly to infra/portfolio/base (bypassing repo's own argocd-apps.yaml
# which has wrong URLs). Image built by Forgejo Actions on rock/portfolio repo.
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: oidc-bootstrap
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "5"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/oidc-bootstrap
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
+2
View File
@@ -34,9 +34,11 @@ spec:
- https://prometheus-community.github.io/helm-charts
- https://grafana.github.io/helm-charts
- https://helm.releases.hashicorp.com
- https://charts.goauthentik.io
- https://charts.zitadel.com
- https://strimzi.io/charts/
- https://charts.bitnami.com/bitnami
- https://homarr-labs.github.io/charts
- https://go.temporal.io/helm-charts
- https://portainer.github.io/k8s/
# Substrate charts (cert-manager, ingress-nginx, reloader) — app-of-apps owned
@@ -0,0 +1,25 @@
apiVersion: ENC[AES256_GCM,data:Fc0=,iv:UFqMFeZTJZtFMoNeusjAoSI60U9StZS4IXw+n8JQCRM=,tag:uUvY4+jcjiSJHs0HDS3/7A==,type:str]
kind: ENC[AES256_GCM,data:NUw86IEw,iv:gh4Vqn6uteYnYIFcXT/QVxRPKm+G+j2aIVuV71nZ4k4=,tag:nZA/x75Kx4+CoSx66BsvkQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:qex150pm0eGwYiiI05y9LUs=,iv:5Tk001T796c4HFikoh1ZVB6ierBfDqOUkFs5ilkr+V8=,tag:LiDAiCH7w6aRS41VKIlIKg==,type:str]
namespace: ENC[AES256_GCM,data:nI91,iv:c1hBWGMkVb+vg+MKNzty8CFfXxQabXEtT+EpiuQZNvo=,tag:BsGtJDbBadDdTa687+Sdag==,type:str]
type: ENC[AES256_GCM,data:3rXawlSD,iv:FUtLK4KP7v/SBF08ZcU6siIO1qfjlJirf2x5PyzAjuA=,tag:mSIQ+Y43+wDfZGRbQ6a0Og==,type:str]
stringData:
AUTHENTIK_SECRET_KEY: ENC[AES256_GCM,data:i5tVOWnN9a/cfhs6gckveRxUpiN7Ie227R/74kOIz6lQ5US2hkf03W5qSGCrzheL3DB0i8+d0iJYsApjn/wCiN7ndZZVFko3W5jDfVPybxc=,iv:djF1G9JHT5B7ziTokA/UuPpG+tG2LvFU4uMAR0x4M6o=,tag:vHOd7jLYkM8Evnr0QUVrCw==,type:str]
AUTHENTIK_BOOTSTRAP_PASSWORD: ENC[AES256_GCM,data:PjvYtH03CzQbTXEqt/Z18+u1zJdok7DLwDn51FUZXDA=,iv:pSwSm8hB0/Q8uOAcOZ1rTqq7BF782EglG19GGeghm1E=,tag:qGCQFWCrwsWBOiQ07ySMUQ==,type:str]
AUTHENTIK_BOOTSTRAP_TOKEN: ENC[AES256_GCM,data:1tnekGo0GIemJCMRenjN0yNeSiiMl/kky8ms84TsqBLaQ0lQzPxjq9qtgNJwoAsufxrznrkDVKjeOEHNYcA82A==,iv:NNFWSLMILoWccA9ihy1OArSS1X3OySB7PIHdXZdyzlg=,tag:t2w+KyReOXftTrU9pBQDnQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSZGRKWm9PTkNSOENtREgx
M2V3Z0xxOXRhRGVRclo3SzBKRkc0VmtCY0NnClJPSGJRcUlzWmNFeTZ1bmorNEV4
U3l3TzVlMTlrWkJnQzBtUTJtcUwrM3MKLS0tIEJrMWZrbllYYUZKN1V4MVMzcXAv
eUtheHlNMHpsTEJXZ1FTMFVxbWloa0kKLRkE1Du+4gdOLerOl9y0mZw+8fqfECQY
uP64Q+9BSuPYNiTLZvYDzmdoy+KTS7i7C9rf+iU96R/crbQLamV9Ew==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T20:37:07Z"
mac: ENC[AES256_GCM,data:b3pDx340d1iZxsPPXzACckoLBrW+XjX1qlGrWEEI30j2Ao0hewTRZJyHbdI97A6b/hqzBN/4tjqqkdqm8goNhUQweMLTw86X6sALuE0aLS9wK53qjqih8fQwQBbgLDtjFXtW26h+smIm7aB5ZSDvU2le44HDXuWd83sWtCavNq0=,iv:ZMDsoKpygFJrYpXy2dA0IlxAi0jt24rxF72eUzgQs7k=,tag:WWE7G7+c9JK6fF1U9V8igg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,39 +0,0 @@
# Bootstrap credential for the Forgejo database — the one secret in the CNPG path
# that the cluster can lose without any controller to recreate it.
#
# forgejo-db-app is OWNED by the Cluster CR (ownerReferences: Cluster/forgejo-db,
# managed-by: cloudnative-pg) and was GENERATED by CNPG: spec.bootstrap.initdb.secret
# is empty, so nothing but cloudnative-pg ever knew this value.
#
# CNPG reads spec.bootstrap.initdb.secret ONLY at cluster creation. So if the Cluster
# CR is ever deleted and recreated, CNPG mints a new password while the data directory
# on the PVC keeps the old one. Forgejo then starts, stays Ready, and fails every
# query — a credential fault that looks like a network fault. This file is the record
# that makes a rebuild reproducible: point spec.bootstrap.initdb.secret at it.
#
# Keep this file. It cannot rotate on its own; rotation is a manual CNPG ALTER ROLE
# followed by updating this file and the Secret.
apiVersion: v1
kind: Secret
metadata:
name: forgejo-db-bootstrap
namespace: cicd
type: kubernetes.io/basic-auth
stringData:
username: ENC[AES256_GCM,data:GFRjVvq3iQ==,iv:7ZxKdHTvKt6TYlsQYu57dt9C14h59xyfrL3+o/deon0=,tag:Ame/mfgDlHk1oPp80hE3LQ==,type:str]
password: ENC[AES256_GCM,data:11t2yglSWQGKeMQ0ZRaw3L5s/o3iHEYArFWdfAQBYSnz1SMOmZ2XTl9yDDHFMUEgHK4K4mP4fJaq4nsgfgr5pA==,iv:aCQ4nkuUX6jDNiOsRW5nzST7HgNZmhjRidmskfNArAg=,tag:ddN0lf+7i4LpS6+yK+za+A==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3ZVQzajgzcmE2MnFzb1pJ
VFR4NjNtU2lWYjZGdGNuOVVnTkNxRndadXhzCkhrU3pPZi9NUlRkRWNUdHNKbW9a
WTcvVnVRM2tWZE1OMDBGcTd0NjhzeVkKLS0tIG5PQlg3ZlBNMDhQV2hxdzB4K1g3
SHBtZ284bTJNV3FvSWFtbDRudGUzbTQKNPM04xB1DWyIE/6/qjUCvXw2M8zkhVkP
xV8bVIp81R0pnMuEGB60L38W81SfziSspzpU0YMUmgZcU2XmO73AdQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-28T10:09:09Z"
mac: ENC[AES256_GCM,data:oNYDrN9wzVbDrDTYcLEKwCqTruybeU+++58Duki5P8xddU1anjHzfBg9DXgXd6CaoYJAU2jEcrqX9YXN8zmJtGaTpgr2GUdgFWx6O1JFoaNM32RNcp7u8FZWpcFYecWyYRtRFMolRaLM/CxkXr2lLa/V3Azxe1xlJrf7EjyI3dY=,iv:7ztpZquVBNZFt59OcNM2IXiDdmMjE8AGs1lTDZzeNWs=,tag:xpak7lbN6hrXaSKoWSbHYQ==,type:str]
version: 3.13.2
@@ -1,28 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-gotify-bridge
namespace: notifications
type: Opaque
data:
app-token: ENC[AES256_GCM,data:MnkKT0/ubJctmLTFXjdCZjNlz+KgQLsPwIKCmPF2weEtMw6ieRfb3+B7OUlIiDqpktdPaJA/znsMOYPXT2aXDqyKquA=,iv:vcAGkgfTheoViZ7OzJJhbNf0boIbRBZLr60ieW5DhLk=,tag:iBDJEjGScxHopkNExh7jDw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYYWZYNFBHbVhRSkZoa3dH
dWp3WllyVmRPcExPTndZUlFsTG1YMHN6WW1jCjRiS1M2U0tIM0tCdGhJOUQ0c2JW
bXNTTExDeVVmcExuNStldFdhTHMrVTgKLS0tIDN3d3JKSEtIUUFhVHROOVBUaUNO
UUVGcVVkOVJmN294dE5DS1R1UkxiZzgKx0MEkIrFsO2dQwjY2410Nj0Nyy3QgC3L
T844m4WT+mxFwB69/y0luGkn4aXMS3xrHHWUC+vjAkM4Unp+MKhhrg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T19:35:38Z"
mac: ENC[AES256_GCM,data:Gf5jnvWP+jBRc8fvQHyrnHnTDaaUTcXQgoOqm7Drax5FpBYEHa36pdKaUq+XR6VBd7ch6Ia/f7YavhS47ybgR3jn+w+trMxkpwtZhm5clw2Lo3Hm7Cvi4fG4qhcjm1C8/NOnWGsi5Qm+TJqyG509p9HNTvM99SzElRovPZldLL4=,iv:10hA6nq0WIO0a2I4ZW6uPGXPjesFgxrwmXohNO3FZz4=,tag:bJ37e8jvgEactq6T/pM2UQ==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:rGU=,iv:w1yJeQvN0MPZlPx8VQZmC6zkRaHW4rtjjROea0Kx9X4=,tag:qgYgVUGMQ2BtN0RZ0nZYjQ==,type:str]
kind: ENC[AES256_GCM,data:wp2QP1he,iv:C8c/jKh5pnBOtHqXsTEchGf9DACU/5hOJ0Mp/+Jk1kg=,tag:QUZNeguRXdi3JO6Ov6GMDg==,type:str]
metadata:
name: ENC[AES256_GCM,data:jVsnAcFTlRiKxDoU,iv:bWEqo28kH3bxzfI3ipiY6dsqhIcl9wIxBjFu0mxVGLg=,tag:PVWTveI8xhvLxvXPldhimQ==,type:str]
namespace: ENC[AES256_GCM,data:ZHnVZyd3wA==,iv:3evUnFTQfRz/ZilUQIl/9dsCba3nsX6o/iQsGXMBfgM=,tag:+9kp+VRu1ksroDTbTGmGPA==,type:str]
type: ENC[AES256_GCM,data:bHX+Bu9o,iv:8qqNddsuw4YoHAuJfUcPIqjcqNySIh5Ln8OhsfZxuk8=,tag:P7QGUXo9Io2Y19YqlWv5sQ==,type:str]
stringData:
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ENC[AES256_GCM,data:nJz9pqa6w9ZGOjanZ32D38Bolg2YrdHs1crhb2uZkKyaaKDpklVb4FOKZ46K+NS1NXjoS6XDUAlV69CSvm1PeA==,iv:g9F/iLuzNC0WogtDJ/TAb3+97N6iGkbOTlb4SzYzwx8=,tag:4F3kUheGbtW2U6jgOvRO1A==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrUkxLMEV3aEpILytic0Ux
V2VEV2EvcENJVnNNRUtBbGkwT0d6eUxYeEFvCjZPSkU2MG5qME5jZ01mRVJwTUFL
M3M0V3c3QXBoRml1Vm9NWW5JTlNRSG8KLS0tIEJnNlFmdFJXb1BPaVJzTmo1RDQv
VXZOVHRHUEZJRUZ4eGZGM1RxSURyWXcK3ZyYCPhRUvpvT/pjPQGJoLIwaktZY6Tg
LS0cIDUhjPFbeu7qUATOrat6vMUi0UREbXSFZ1KyAjNAwJZaUusPMA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:0cgVNtFMwhbEHBxu7T1XfstcQlgUTxijq9Mz36zC60E7kYXRNwGt2/0KoCaqaE0leypwAdrPn2OjeoDPeCN6x+n0vLRVX2tnli6HazBLbRsPHljDAV0ASF07iNhb0PvgtWay432Mm+sGATkel3ITdAaP0lISNLUYfEe1/TbYHkk=,iv:FZ9Bgtk7LOC3aOSOo1+50zwLGhlcUZYJaUQ9r05cAn0=,tag:tsLQ1frZ7li5l717CCtgoQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,24 @@
apiVersion: v1
kind: Secret
metadata:
name: homarr-oidc
namespace: dashboard
type: Opaque
stringData:
client-id: ENC[AES256_GCM,data:IFNjfDMrObaxfGwJpiYqPP60,iv:0WR2C+DEsq8I031A9Bopotn5TYdISApbTF46jJBgtVs=,tag:LGijMS27pbbZnOLJqGNXQg==,type:str]
client-secret: ENC[AES256_GCM,data:q0MHjvuTIKbbMUZpsptOuo3zrabwaWxZS3MLqRdI1Rt7eod5ORAYeZmpllHmojRFve9x/SKkJ+sctafzwPuAPA==,iv:1ZLXOc3a85+MRx3v/XsW8JhsxbCQ26laou8PvvrQvH0=,tag:PRRTsyE2fGGS3dKPh43a8g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpV1ZlMWxaNTlCUjJjbG9F
NzFGMXBXYThZT285UHA0bGdwNFJqQTNMckhvClFSTEdseExjbVFtbkdsTUpSSGVn
WGsyUmxvcVViVVVNZjFRdmJWZlB6TGcKLS0tIE01QjFtVFBGUlpXbjVJcDMwMzRS
MEh4dUZTZ3puaXF2Uk03Ym50QmdSM1EK6Kmdv3s8/4nI/6Uk4G5FOh1Gxp1u9eYI
L7MOW0TFkScTuDiu9iJB+CjB8b8Z0kwSYTigYj9ue2y0a1hDYCfQtg==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T03:56:19Z"
mac: ENC[AES256_GCM,data:tV7o9zY6dpTdTvNJY8GBkdIB71thoy79RQSHsczednmXXzlNFddZxR/UenJ87jJ1JyzMUKL3CxArYojpuamahQcSXifVar0kotgNCs67A7+xfhHudCnKLMWDfDOom6DdWto6bMNIk/0TF6Wy0m1hkSsxwkdqETVs3b6ZUg/l8Rc=,iv:PE7y5oVMEM9XKQnDZmdRFRR3blzz2SZQFLxOMyILuec=,tag:SM9vJU1zlQ02XJijSiGCMg==,type:str]
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:FO4=,iv:kKIuJp4uGeX6vECsXk7w43F9S281jObQchmK8r1+kmA=,tag:x5LYd34dzNDLCma/78M4+w==,type:str]
kind: ENC[AES256_GCM,data:o4RTN8SK,iv:uyOJWjDmHIPuBl9Wc1S/HxUabV2yytWr1bntHW68sTI=,tag:3On0SnQVJ/ICMMryQe+8JQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:a89dHmLdSeMHE7ZP0Q==,iv:OWJvJhFqJ7A0UGVJnZILzCJ/4VuVFib9sUDjXuwyqJQ=,tag:1XrIQn1GDh8RQ4vmQAu7+g==,type:str]
namespace: ENC[AES256_GCM,data:4wBVpZJgQGM9,iv:3Tn1u/2TP/+SnELu34PDf4Jauf2L4f8+MtgrPIlyKzk=,tag:L3qbKFBjm16g8KX5vUsK6A==,type:str]
type: ENC[AES256_GCM,data:mdrF9vUb,iv:7ukOgwXlnRf5mpJ/+6YXtyGRonnqehEgT4pJJIafw7c=,tag:90mt7jpNb3N80kCEi+R/rA==,type:str]
stringData:
db-encryption-key: ENC[AES256_GCM,data:NWJryAEd/eUJxbNiu05RzLF/l7GDxIQM7RQe3xKBz6XklgRAFwQxxjakcVd85pePUBaQYz3zttFglWhfJlWhkA==,iv:eUWbcs0B/mIheyAQ2+DyKgcA98CRKQGAVC3O0WIkfU8=,tag:be06X6AQqlohYlxuYoZiTg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1Ym5yQ1pmWHZjVnYxbUVO
SkZ5dnI2OXR3cmZjZkM0K1ErMElLNktlK1JFCmpleFBrcXkwcDA4cGFuWWN6VDQv
dVV4NjB4MjEreENVQnZ2ejVLblRYOVUKLS0tIG9VbXJvUmQ3SzMzN3IrRDlac2sr
eGZNYXBoZFZDeisrVCtUMXVPaEpoNFkKjwhUU+Zsqv4Be+m558fET5UhF/2rO73J
AhGbJVaA7PaOCswPWiOLso26zD+MBB3VKHNdUFnenpoM16Nu4pOf5w==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T21:07:30Z"
mac: ENC[AES256_GCM,data:KrGQY/eOmihYCvQCbfcM6bZ6rfx4zKh62M8NM0VQr1//1dSHpOOh25dYGLuQeECnqLxSpGQNWzr2xjUYWs4NOdSEFGq6FL2iHE3LnqoBZspp/JFJYP6+z/8jSUojwKEW7OuZZdPN6gF2u+IPOeWbGftnjS7X+atoSkYag2ty2vk=,iv:IOPEgBizsxrVoK5oSdWmi0uCBUnReOEnMYXuwSBKTzA=,tag:9k/XQ/AE7wSFq5XMbY0FTA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:4OY=,iv:0Lgui0+X2oTlgCX5HEIIihEsGJD0C2+pDIOTCNi4r+g=,tag:uL7vlreaHjcLJjV7e5I7PQ==,type:str]
kind: ENC[AES256_GCM,data:x/ltkind,iv:KRBfS7COsTs/l3Fhyk5FvH7IVc+r/6M6FVOo+kPDe1Q=,tag:UuTbe213hDfK/WUtKS/doQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:LpOEecTJBuGy5PqNSFM=,iv:Q+DCLGtw3wihhdAgEbzYHyQhFTPWFyE2iJKl9mYaA2E=,tag:E2wrig4lRXGDvSCHec5clQ==,type:str]
namespace: ENC[AES256_GCM,data:6kMVCXaF68NO,iv:gnCCkV7GKXA2HWOOeNUGnpAycf8U7pl6F8Vfo6DhLR4=,tag:0VdeySsuhUr2heXyGXZwBg==,type:str]
type: ENC[AES256_GCM,data:6muC3Zqh,iv:gBaC+6MXvZY/bIpy/cEG/zWwLBaUFMVfxAHa1OnwfnQ=,tag:v6NYyhAKAwovluHAXNaOVA==,type:str]
stringData:
SECRET_ENCRYPTION_KEY: ENC[AES256_GCM,data:jX9ULlaBYTSTFGRMplSder5rMrsz7THU3kgJSGef0r5rZvtoaAG9NVYU2QUEuD0sZgfqRooeMOkDCAqFc8qsuw==,iv:uOmx8w3XPKnoxfasf7gqi/bljxigpLwaAC30c2TroKg=,tag:Dmbu6m3+UZa1w0UR0LRh9g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6TDVyNGRtYWgzMDYwdjdr
NXJWcGJZNUx6YzBGWm1nWlp0SlN3WXhaR0VBCjZ3bDBmUTFCUjg3U21wak90eHZK
NlRCTUZQSHhoS0ZSUGZ4UHhST01KNmMKLS0tIGUxcGo0NmJ2bmJ6eGJ3QWxzYVM5
c2Z6UkFjQmZ4ejIvK1JQQkZ0VFFIZ3cKA8UzmGsX+s3yOermOHqbnnEtekHCTqmC
R3Jyzf6OolZMJpNwbetcsOfgO61aM7LHl/Xb/hFLbLjZ+eECrt8VpA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:HTrEEVDm8JqnVBFaY/FJknxma0nI95tRiwVc0K76k+2R4jgKCBA3EUAzPVS+gA4rTAeKHPSHrT2eidlkseT2WQUjqMRrpHcUsbSjNfpTEY2g/U0PIiOXZMRGfwmsQVgmu2fqeMOyzDSyya0scdWJXPraRPNZ+OpEkNVlm4rWcgs=,iv:KY3dnnmooJgsu66wc7gbzeIGUXZNgVrxAhpAwxoG+G8=,tag:7r6d41oTD136N57rtY7auA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,28 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: igotify-client-token
namespace: notifications
type: Opaque
data:
client-token: ENC[AES256_GCM,data:W+InmDyZkU0i+OqQUsIaRNY/3p6zjMC19ASj/qYHTldhtGmHwj4koQX3tIxdCQV72xZKOxsc8eV3hGX3pqbW02c2/ng=,iv:wWI5AZdpxnzX61f8iTiWmxKcDFMaFM4PHFYo4DMBK4Q=,tag:CpE8Kg5V8fL7P3oAjWjSvQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwaUlFU0xKaEs0SWcxWEEr
T0tUSmErR08zVkhuLy9VMEc3VXdmU1o3cWdrCjhuOW1yTmZPdWxDZHhMeUZtQlcz
UmVzaEMyVHZOZS9KVHdMd2pndUJwUW8KLS0tIGxYeENwVEFDdUpDTGszdGtNRHBQ
Y041WW9HdTJwUlJCUGhMN2RGZWZpUmMKiQbi/O9X3cFJgqw3tvwWcyy8QKkUuq/7
Y+V51R/WFobEQuAZLmDKgWcPSGt51effufB/Vm9LRRqtbkbYQAIoXQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T19:14:20Z"
mac: ENC[AES256_GCM,data:Hxl7AGzMOMK0pwe6T9ZmrE6K45XhQkq4qk/7YkrVuneL1e7jc8Bmv4NlywqRkt9fPVOCMwKikeX9U3ZcsqK13xPPfm1CmAxV3CCluwK0/9589N70OQxL4qJSkO6CXyGaa0Y9wVRX4y/N3wSjW+d25+GOYUXrOxkRpVyU4GFSqAQ=,iv:0Af/MCZBEYJVI3yi7DpPX1eBKwSANXJPCpSAEE3zCq4=,tag:SS1U9TfRQR3z8KkoQ6ZuZA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
+47 -36
View File
@@ -1,6 +1,3 @@
# minio-oidc is not in this file any more: the KeyRotation object iam/minio owns it.
# A prune of the sops-secrets application would delete that Secret, and the
# controller recreates it on its next rotation.
apiVersion: v1
kind: Secret
metadata:
@@ -8,27 +5,46 @@ metadata:
namespace: storage
type: Opaque
stringData:
config.env: ENC[AES256_GCM,data:4NQYmVEnkv4nO6FnMAEXVCUhMM+DLCGG2tFC7SGJ0lGnfFQiISdzWwVXD0y9/gpK4qn5zv0Cs1MgOfDVBKH6UR1ONXQnIjUnvXnq4D5+P0BgVUlYD/7aComva9Ow1IEChvD3fBTmDW7FMEZrtA==,iv:/LAV+ukcE3pwgJMkEkR8z9h+k6pSvQ7a/FkxLvMK6X8=,tag:BnvsL05XttadRqqS3TU2Uw==,type:str]
config.env: ENC[AES256_GCM,data:1yogGyBdwrlg27H8R5Kr9VcPu+xwS5/nPhi7eLcmVYtXmPlNokCsWHA70qnn/+I6i4ODys8GML++VeZ/lmcHiP7RIbY+G6xmw6nfBCXitgE8kQwTPsUUzn9bobOn4Sr1YMBf7FQ/Q0N8aupGJA==,iv:EfCdim4TLkP/FP1u1yoKZCq8YGFzVh7ca4yu1+WzXwE=,tag:rTYe0SIAEsUhh0KHQIEKNw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxQ1BqbDZoYjVQWGlTVEFD
dVVWT0xFUlAwUXJUcHZOMGN1cG85S3NtcVZRCmpUZnM5dXpWUDh5UHhGUTcyYmNr
UWNlc2VDazUvd3k0VHVWR1NBUE13eTAKLS0tIG9VcGYvN1Z1UEo3Um1SMHZOSHF3
eUp5dE04M3lvTVlHc3lPZnMyUWU0bE0KPXwE7wOJNSrqM6kfO1BvXqHApvQI4hdz
5GWmLjmjQL/ZZlkF07uK1GDXIBC3N3K0vYrfQHjMUImw9Dwn9DnL5w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-28T03:00:02Z"
mac: ENC[AES256_GCM,data:DGglizsoj/Fmi+r1NvfYD1sSivtedJ+w0RKFioJ1K7ektG/I68hprcMkmrs9vY1H2zlP/1Io/Zf0wOMqIPVK+uyEHIDGr+kk5Y/vPkrPi1+TpVDHRJVwvfkFWWVBrnlMbpZ89076dl1cyphdi4XvSHq2z30Qa1oRnxNkPO2HrhY=,iv:je0iyd/AkWYeyL2vA6ipmvWL6c0X+4h1zeLMeE60Pks=,tag:Q3X/jIWQWFtiSGhkCbzBDA==,type:str]
pgp: []
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
version: 3.9.3
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
version: 3.13.2
---
apiVersion: v1
kind: Secret
metadata:
name: minio-oidc
namespace: storage
type: Opaque
stringData:
MINIO_IDENTITY_OPENID_CLIENT_SECRET: ENC[AES256_GCM,data:eFfwW2ZnV+o3/6XHjUvyFsDbaIAdosY2PXOnRdN4Ie49YDIjfJPe+Mg1FsbgoEHEjuB33hfxesb4TWOJQfW8Og==,iv:+kRr9A3auts1XHgZWa+t5b2m4lF0x9dVhiRpB17fzv4=,tag:tH/jszFzLQdqORGr9vfTGw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
version: 3.13.2
---
apiVersion: v1
kind: Secret
@@ -37,25 +53,20 @@ metadata:
namespace: storage
type: Opaque
stringData:
CONSOLE_ACCESS_KEY: ENC[AES256_GCM,data:6mmBluC+4QmlMxPJbfNh0kA=,iv:CuWy1gVnd19vB1rOS9HRVKegX3CnmCEa6cpk+S7ZX00=,tag:OqZK09bVMZGbMXwnDmr77Q==,type:str]
CONSOLE_SECRET_KEY: ENC[AES256_GCM,data:Fyd5EdZZP7/q3QOVv3FU1bkd8960p6UiRNNl17LJltw=,iv:/QGSkVVfXkCAKSV1+RNTZEI+/riAFVLV6SiaL9DvbSo=,tag:lFTJpifGorsl2w8lG7beCw==,type:str]
CONSOLE_ACCESS_KEY: ENC[AES256_GCM,data:Zxr/Nj0SUKSJbBD1cFjt3GE=,iv:r/B9ULn68hsXdH8uRQkKAoQTJIJIYKMoJuWOZfxS2I8=,tag:Xo4i+IVxuqrp+qDpYQysPw==,type:str]
CONSOLE_SECRET_KEY: ENC[AES256_GCM,data:PoIDNZ+Xs6BdhGm10y1HdL5jy47EVHfHHIJhGy/KX6Q=,iv:voAv32GoTKi9s9/4MQy2yT6i9VY4/igA5XXzehwUqRQ=,tag:DmPyk5biVvlxHjeyDyrLbQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxQ1BqbDZoYjVQWGlTVEFD
dVVWT0xFUlAwUXJUcHZOMGN1cG85S3NtcVZRCmpUZnM5dXpWUDh5UHhGUTcyYmNr
UWNlc2VDazUvd3k0VHVWR1NBUE13eTAKLS0tIG9VcGYvN1Z1UEo3Um1SMHZOSHF3
eUp5dE04M3lvTVlHc3lPZnMyUWU0bE0KPXwE7wOJNSrqM6kfO1BvXqHApvQI4hdz
5GWmLjmjQL/ZZlkF07uK1GDXIBC3N3K0vYrfQHjMUImw9Dwn9DnL5w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-28T03:00:02Z"
mac: ENC[AES256_GCM,data:DGglizsoj/Fmi+r1NvfYD1sSivtedJ+w0RKFioJ1K7ektG/I68hprcMkmrs9vY1H2zlP/1Io/Zf0wOMqIPVK+uyEHIDGr+kk5Y/vPkrPi1+TpVDHRJVwvfkFWWVBrnlMbpZ89076dl1cyphdi4XvSHq2z30Qa1oRnxNkPO2HrhY=,iv:je0iyd/AkWYeyL2vA6ipmvWL6c0X+4h1zeLMeE60Pks=,tag:Q3X/jIWQWFtiSGhkCbzBDA==,type:str]
pgp: []
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
version: 3.9.3
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
version: 3.13.2
+15 -17
View File
@@ -5,25 +5,23 @@ metadata:
name: oauth2-credentials
namespace: iam
data:
api-gw-client-secret: ENC[AES256_GCM,data:q41OBQ6WvxsmHmXFhrdzeNUlQyPKuOQQa0vWdhuioQwTsNJ5+l9rYZKBAcZxzvd4P066aRN6yVPmEZhq,iv:Dnfgy8N9NVOM2x656Qei8960dzu7icAU3BZLXmj7N5o=,tag:deilBSCC9Mg/Eo8K0w6rMw==,type:str]
poimen-client-secret: ENC[AES256_GCM,data:2k28ui+Mbu3qd4ar6/NidO4bWO8g7JQUjDQBD8liywpCm7rA8TSrmU51im/I5kEvRRhl+RByEo5wpozo,iv:JFrZYF7cP/tQL8gkQJ5GvtdOt1kKcAdHE7L4l+uT9M0=,tag:DSiv8kv7MINUxi7fO/093w==,type:str]
api-gw-client-secret: ENC[AES256_GCM,data:YiUCk61cCsTJE8gTLZWhqVPsCSu3b4OnG+Oh456A+mnxip0FjLTTqV+kYBdfZivIkn9ua4RcVW+rYILu,iv:K08m1EaB7/SIVNWZ3FfeRP4tbCAo6KeiNC0NpE9VO1o=,tag:ZXV9YRWUOPJ/vdoqMtvQeA==,type:str]
minio-client-secret: ENC[AES256_GCM,data:vcwLo1cJqbZr2MwkAKO09xPjb09ss7+s6HIvEfaLxt8ka5PUAlXQ37pTkynuktlKq/t0DvSk+x1+5G7QqVD1JJlwyDGM6yuOhBQ3MwucLNl+T4RDKtgGuA==,iv:sCdd+UNX2TfTAM6Zslrh8KrvBSzqpe67F3YMGrl4YDU=,tag:9Tpzm9NEXNuXyCd7l7GeHw==,type:str]
poimen-client-secret: ENC[AES256_GCM,data:dYtCiMGZaOFokihfGD2SXJDfXrhfwEQcTOPNu7JNHDUnx0hqwAdYAPAJTEN21p0OpHYDINMs5lwUpMwY,iv:ll8USuzs0881ryHImVjHGO0iFxUzqg5XtI6Ekd+EFXE=,tag:I/4ZfB9Ss4zEP57sRtRTXQ==,type:str]
paperless-client-secret: ENC[AES256_GCM,data:XMj9rHkI20lI37vChq9wS5lUq7l95SisRX0LChuj09O5w76Zt9NdG6OEUYQFf1z8vCfMJxBInIEEIq0e,iv:UpYy2ZEZ4AgBACEyrlxEcr13V+8Ksu4p8JHYApmyYHo=,tag:o9Z+/b6T9POgowIsks56kw==,type:str]
grafana-client-secret: ENC[AES256_GCM,data:V/j0+P3HoDLqYjRalWK4v1hvJTuVmzxVra47HsF1/BZvWZjIyEIH3Pt8BekT3O1dUFynxjcaxdZMBVYg,iv:9pMp+vIsp/sA7GP9wqNDf5DdZVLDow46OURW0mMRXIc=,tag:i7VJFA5o0hToZEJ5JPWIvw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5WmthakxzRk5aeGJWZnRF
bzdqM1o5Q1h2MFlpNEJvRUFZVU1wWjRaVWowClVKYkp0YUJCbUM0MWduaCtBSExH
MDRWUGZkYTdndTZTeEM3ckxrMURFMUEKLS0tIHF3YUlLNndSWmxGejFPbTVjQnRS
THF4RFBNbDJCdjVKRDJ6cEJlZGVBM0UKEDz1sKDON+lelq1zbCO4CETaK77T4G8y
fly66sR5uIvAXdwXy14L9ssZsM1jOHqdkXnpbo6mzh3toB/ROzoL7w==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2amZPendIOU9PeStOcmJG
c1dzOFVvd3RoT3dXQlJUNkdCSytEZUVGVEJJCklMUkNjUytBMXh0OVUrNUlpMWw3
aHRFT1hBZTZHNHVtaGJCTEFyT2ZWNWsKLS0tIENxR3FKMEplV0V5Y0dsZzBkODVG
U0ZlT2R0VFdEQmpsakVvZ1FJRkRQYTAKESQcSlMAuCG0/tLWWZaeoPayf1EKxSaf
e4o8TmjmSgPEhepQ/LmNXl6obyuqUxztkh4Mm6pocqDp+gfzt7X/1g==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T21:47:38Z"
mac: ENC[AES256_GCM,data:bgZD+/LAPKp6DXyRMnbFWyxmiRjWxYO0ofxeGpO7nyw1DMSTD9s8li43eh/IP7wW4NORwzfIfzxUvOFmVfAiQKfkzpYRDFIKqK+aa/+43zppwpVT0UJ3CcBt2qgdJRHKnv9qeUOx4M8ulY7Xcsoevw8EGdrXCc1KAcDIwoIAp0Q=,iv:djYQ6YnGPrYGRKgpfbdf0beW6QHYTq5CJ3nes7+C4p4=,tag:N9n4VKyMNzGCmuYpwBLPng==,type:str]
pgp: []
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
version: 3.9.3
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:AI/znHiUa/Rwg0YRz65qUZ+b6V73oi3ZfAG/V5uZZvrGFrA0EISzD5LaP5Kl4GUU1svo4G5EI4aftPDgpdPptz21CCa8iYkXGe9pTDmtaE5ZICQAUbRcVhGMTfuWFhrgEhMfCJdOpfHaIQVEsQJQJgrnkXQVuUe9aT75LQz7pnY=,iv:KbIaYT0VeBT2WeSU5V5ZB4e+04JICYaY+egGN1Pv5bM=,tag:pPbhDxeLUx6V1XC/LPwxTg==,type:str]
version: 3.13.2
+5 -3
View File
@@ -9,13 +9,18 @@ metadata:
files:
- agent-pod-models.enc.yaml
- agent-pod-ssh-key.enc.yaml
- authentik-secrets.enc.yaml
- cloudflare-secrets.enc.yaml
- cloudflared-credentials.enc.yaml
- forgejo-registry-pat-api.enc.yaml
- forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml
- forgejo-secrets.enc.yaml
- grafana-oidc-secrets.enc.yaml
- grafana-secrets.enc.yaml
- homarr-auth-oidc.enc.yaml
- homarr-db-encryption.enc.yaml
- homarr-secrets.enc.yaml
- homelab-ca-secrets.enc.yaml
- loki-secrets.enc.yaml
- minio-secrets.enc.yaml
@@ -34,9 +39,6 @@ files:
- pi-sandbox-secrets.enc.yaml
- etcd-defrag-creds.enc.yaml
- zitadel-masterkey.enc.yaml
- smtp-gotify-secrets.enc.yaml
- igotify-client-token.enc.yaml
- grafana-gotify-bridge.enc.yaml
# Login UI RSA keypair, adopted from the chart-generated Secret so ArgoCD
# owns it. Without this, the chart re-generates the keypair on every render,
# which changes checksum/secret-login-service-key and leaves both Zitadel
@@ -1,23 +0,0 @@
apiVersion: v1
data:
token: ENC[AES256_GCM,data:CUJ0rUtgye71fpFz50RSDXJeG+t8Wjrm6T6jboVPbt2FVuIRSscqKg/Pc3k/L8nvLvrkYlzz1rbYu9v5NAdNF2c35AQ=,iv:tsfQ+v4qwj6H7G/O79b6m4XMWRw3mwE67f+4LjDCOoY=,tag:SLGGBYtYoF45AQstETPYZA==,type:str]
kind: Secret
metadata:
creationTimestamp: null
name: smtp-gotify
namespace: iam
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFWmU5TUxLeGU4YzUxRU9G
ZFR5a2hqS3huYlJtempaQ25zUkljUDJLSENrCkx0bjQ4UVF3UWpWa0t3WHQ2bkNl
eEp2d0dQRHJSOExIYjN4TmRTZWdQY00KLS0tIGF5enUxTmxRTHlQRUt1aTFIYTFB
MEJaOWd6SnhWQU1PQXdUZDJqWmp2MGsKIOOrp52+d3M8AQlMw0VSnLQ2efDtC6Ye
oC/TPqYi9SqYNxaHkODS2QZN9msyfRFcgUh/6ZXr8sivnpwRY8ZZtw==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-27T06:24:20Z"
mac: ENC[AES256_GCM,data:9Jsnij+I1MhLhIPLt2hr1a6Jryf8XcAb4QAknGkX1hNEN+Z9HT5PSnPARqyhl0WTxe8813a/G0h93SJ0A419jIzZugv6zEj4STsOG4hMI8O2NniqOwiDWl+Az9SRXIQ0ahH9En7ssvEdiosIDGJSz31ufNc8FbwKnHvgWCwfK9Q=,iv:VNTOpQkDakoaCu2dG4lRbd+6AzimNZVNzggq/7C2eM8=,tag:cJI9WdnKiG3K3J4LRDqQxQ==,type:str]
version: 3.13.2
+25
View File
@@ -275,3 +275,28 @@ spec:
name: temporal-web
port:
number: 8080
---
# NOTE: api.riotpiao.com is deliberately NOT here. Its namespace `api` is
# created in wave 7, and this Application syncs in wave 1 — an Ingress into a
# namespace that doesn't exist yet would fail and mark this whole app
# SyncFailed. It lives in k8s/apps/api/ingress.yaml, synced by the api-gw
# Application.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homarr
namespace: dashboard
spec:
ingressClassName: nginx
rules:
- host: homarr.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: homarr
port:
number: 7575
@@ -34,8 +34,6 @@ service:
lbipam.cilium.io/ips: "192.168.1.161"
gitea:
podAnnotations:
reloader.stakater.com/auto: "true"
admin:
existingSecret: forgejo-admin
@@ -143,10 +143,7 @@ configs:
oidc.config: |
name: Zitadel
issuer: https://zitadel.riotpiao.com
# MUST be quoted: an unquoted 18-digit number is parsed as a float by the
# YAML parser and ArgoCD rejects it ("cannot unmarshal number into ...
# clientID of type string"). Same class of bug as Grafana's client_id.
clientID: "396757801210282367"
clientID: 396757801210282367
clientSecret: $oidc-secret:client-secret
requestedScopes:
- openid
+38
View File
@@ -0,0 +1,38 @@
# Dedicated CNPG Postgres for Authentik (GitOps, wave 2 — before authentik w3).
# CNPG generates secret `authentik-db-app` + service `authentik-db-rw` in ns iam;
# authentik-values reads them locally (no cross-ns secret copy).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: authentik-db
namespace: iam
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.2
bootstrap:
initdb:
database: authentik
owner: app
encoding: UTF8
localeCollate: C
localeCType: C
enableSuperuserAccess: false
resources:
requests: { memory: "512Mi", cpu: "250m" }
limits: { memory: "2Gi", cpu: "1" }
storage:
size: 10Gi
storageClass: longhorn-cnpg
monitoring:
enablePodMonitor: true
affinity:
# preferred + tolerations: HA across nodes without deadlocking a partly-
# schedulable 3-CP (same as forgejo-db).
podAntiAffinityType: preferred
topologyKey: kubernetes.io/hostname
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
+1
View File
@@ -6,6 +6,7 @@ kind: Kustomization
# deployed into it. paperless ns declared in namespaces.yaml above.
resources:
- namespaces.yaml
- authentik-db.yaml
- zitadel-db.yaml
- temporal-db.yaml
- paperless-db.yaml
+2 -2
View File
@@ -1,7 +1,7 @@
# DB clusters are wave 2 — their namespaces must exist first (their apps that
# would CreateNamespace run later, w3/w8). Declared here so the databases App
# creates them. vault/temporal/paperless CreateNamespace=true then no-ops.
# poimen namespace created by poimen-root app (wave 7).
# creates them. authentik/vault/temporal/paperless CreateNamespace=true then
# no-ops. poimen namespace created by poimen-root app (wave 7).
apiVersion: v1
kind: Namespace
metadata:
+284
View File
@@ -0,0 +1,284 @@
# k8s/talos-iam/authentik-values.yaml
# Authentik — SSO Identity Provider for the homelab.
# Provides OAuth2/OIDC login for Grafana, MinIO, Forgejo, and Argo CD.
# Chart: authentik/authentik from https://charts.goauthentik.io
#
# Architecture: server (UI+API) + worker (background tasks) + PostgreSQL + Redis.
# PostgreSQL is the system of record — must persist. Redis is ephemeral cache/queue.
#
# Secrets injected via helmfile --set (from .env / vsource):
# AUTHENTIK_SECRET_KEY — signs sessions and tokens; set once, never rotate casually
# AUTHENTIK_BOOTSTRAP_PASSWORD — initial akadmin password (used once at first login)
# AUTHENTIK_BOOTSTRAP_TOKEN — API token for the setup_talos_iam.sh bootstrap script
# AUTHENTIK_PG_PASSWORD — PostgreSQL user password
authentik:
# host: the external URL Authentik uses to build redirect URIs in OAuth2 flows.
# Must match what the browser sees — if it returns an internal svc URL,
# the browser's redirect after login will fail (can't reach svc DNS externally).
# HTTP (not HTTPS) because the Authentik ingress has no TLS cert configured.
host: "https://authentik.riotpiao.com"
error_reporting:
enabled: false # do not phone home to Sentry
# PostgreSQL connection — points at CloudNativePG cluster in ddb namespace.
# Uses 'app' bootstrap user (CNPG simple pattern, same as Forgejo).
# Credentials injected from authentik-db-app secret via env vars below.
postgresql:
host: authentik-db-rw.iam.svc.cluster.local
port: 5432
name: authentik
user: app # All apps use shared 'app' user (CNPG design pattern)
password: "" # overridden by AUTHENTIK_POSTGRESQL__PASSWORD env var
# Redis connection — bundled subchart, standalone mode (no sentinel/cluster).
redis:
host: authentik-redis-master
# ── HTTP client timeouts ──────────────────────────────────────────────────────
# Increased to tolerate 5+ second pod-to-pod network latency.
# Affects webhooks, outpost management, SCIM, LDAP sync.
# Default: ~30s — too aggressive when latency spikes hit 5-10s.
log_level: debug # enable debug logging to monitor connection issues
# ── CA trust (shared by server and worker) ────────────────────────────────────
# Authentik (Python/Debian) uses requests + httpx for outgoing HTTPS — webhooks,
# outpost management, SCIM. Both libraries need REQUESTS_CA_BUNDLE / SSL_CERT_FILE
# to point to a bundle that includes homelab-ca, otherwise connections to other
# homelab services fail with "certificate signed by unknown authority".
#
# Strategy: a debian:12-slim init container (run as root) concatenates the
# Debian system Mozilla bundle with homelab-ca.crt into an emptyDir. The main
# container then references /merged/ca-bundle.crt via two env vars that cover
# every Python HTTP library.
_caVolumes: &caVolumes
- name: homelab-ca
configMap:
name: homelab-ca
- name: merged-ca
emptyDir: {}
_caVolumeMounts: &caVolumeMounts
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
readOnly: true
_caInitContainers: &caInitContainers
- name: merge-ca-certs
image: debian:bookworm
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
command:
- sh
- -c
- (cat /etc/ssl/certs/ca-certificates.crt 2>/dev/null; cat /homelab-ca/homelab-ca.crt) > /merged/ca-bundle.crt
volumeMounts:
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
# NOTE: no authentik-migrate init container — the authentik `server` entrypoint
# runs migrations itself on startup. A separate `manage migrate` init pinned to
# an older image tripped a version-history precheck on an empty DB
# (relation "authentik_version_history" does not exist) and blocked boot.
_caEnv: &caEnv
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# ── Authentik server (UI + API) ───────────────────────────────────────────────
# Handles all browser traffic: login flows, admin UI, OAuth2 authorize/token endpoints.
# NodePort 32172 is a fallback for direct node access during troubleshooting;
# normal access is via nginx ingress (authentik.riotpiao.com → svc:80).
# Recreate: single replica + RWO-adjacent state — avoids split-brain on redeploy.
server:
replicas: 1
# Merge SOPS-CMP-emitted secret values after the chart's own `authentik` secret.
envFrom:
- secretRef:
name: authentik-secrets
deploymentStrategy:
type: Recreate
service:
type: NodePort
nodePort: 32172
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env:
# Merge CA trust env vars
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# Override database credentials to use 'app' from authentik-db-app
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-db-app
key: username
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
# The /-/health/{live,ready}/ endpoints do a DB round-trip; under transient
# CNPG contention they respond in 5-6s while still returning 200. The chart's
# default 3s liveness timeout then flags a working backend as dead and kubelet
# kills it in a restart loop — the pod never stays Ready, gets dropped from the
# authentik-server Service endpoints, and the OAuth-provisioning PostSync hook
# fails with "Host is unreachable". Widen the timeouts so slow-but-healthy
# checks aren't treated as failures. (Only these fields are overridden; the
# chart deep-merges the rest of each probe, incl. the httpGet path.)
livenessProbe:
initialDelaySeconds: 300 # skip probe until 5min passed (migrations finish)
timeoutSeconds: 15
failureThreshold: 6
readinessProbe:
initialDelaySeconds: 300 # skip probe until migrations complete
timeoutSeconds: 15
failureThreshold: 6
startupProbe:
timeoutSeconds: 15
failureThreshold: 120 # 120 × 10s = 20min for fresh DB migrations
# Every OIDC login (Grafana, Argo CD, MinIO, Forgejo) depends on this server —
# its request latency/error rate explains SSO-driven slowness on those services.
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 30s
# ── Authentik worker ──────────────────────────────────────────────────────────
# Runs background tasks: email delivery, LDAP sync, flow policy evaluation,
# event log cleanup, and managed outpost updates. Stateless — no PVC needed.
# Same resource profile as server; Authentik 2023+ merged some worker duties
# into the server process but the worker pod is still required.
worker:
replicas: 1
envFrom:
- secretRef:
name: authentik-secrets
deploymentStrategy:
type: Recreate
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env:
# Merge CA trust env vars
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# Override database credentials to use 'app' from authentik-db-app
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-db-app
key: username
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
livenessProbe:
initialDelaySeconds: 300 # skip probe until 5min passed (migrations finish)
readinessProbe:
initialDelaySeconds: 300 # skip probe until migrations complete
startupProbe:
initialDelaySeconds: 30 # let server finish DB work first
failureThreshold: 120
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 30s
# ── PostgreSQL (external: CloudNativePG cluster in ddb namespace) ─────────────
# Authentik connects to the dedicated authentik-db (1 primary + 2 replicas).
# Do not use the bundled Bitnami subchart — CNPG is already running.
postgresql:
enabled: false
primary:
persistence:
enabled: true
storageClass: longhorn
size: 8Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
# ── Bundled Redis ─────────────────────────────────────────────────────────────
# Cache and async task queue only — no durable data. If Redis restarts, in-flight
# background tasks are retried and cached tokens are recomputed. Losing Redis
# data does not lose user accounts or flow configuration (that's in PostgreSQL).
# persistence: false saves a PVC and makes restarts faster.
#
# Same prefer-worker / fallback-to-cp scheduling as PostgreSQL.
# architecture: standalone — no Sentinel/cluster overhead for a 3-node homelab.
redis:
enabled: true
master:
persistence:
enabled: false
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
architecture: standalone
# Ingress disabled — rule lives in k8s/ingress/ingress.yaml (authentik.riotpiao.com).
# For direct access during bootstrap: kubectl -n iam port-forward svc/authentik-server 7000:80
+5 -6
View File
@@ -2,12 +2,11 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# Removed: rbac-dashboard-rolebinding.yaml (authentik-provisioner SA and
# RoleBinding). Authentik is retired, so the file is gone; keep this list in
# step with the directory or kustomize fails the whole app.
- smtp-gotify.yaml
- rbac-dashboard-rolebinding.yaml
# IAM provisioning is manual-only (security-sensitive).
# Script: scripts/iam/zitadel-provision.py
# Script: scripts/iam/authentik-provision.py
# Run:
# scripts/iam/zitadel-provision.py all
# export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \
# -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)
# python3 scripts/iam/authentik-provision.py
@@ -0,0 +1,13 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: authentik-provisioner
namespace: dashboard
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: authentik-provisioner
subjects:
- kind: ServiceAccount
name: authentik-provisioner
namespace: iam
-108
View File
@@ -1,108 +0,0 @@
# SMTP -> Gotify relay for ZITADEL notifications.
#
# ZITADEL sends verification codes, password resets and one-time passwords by
# SMTP only, and this cluster has no working mail path: no SMTP credentials are
# configured for ZITADEL, and the notification table shows
# verified_email empty for every user. Without a delivery path nobody can finish
# a first login, at ZITADEL or at any relying party such as Forgejo.
#
# This relay accepts plain SMTP on :2525 and forwards every message to Gotify,
# so codes arrive on the phone through the existing gotify/igotify setup instead
# of email. ZITADEL then points DefaultInstance.SMTPConfiguration at this
# Service (see k8s/infra/iam/zitadel-values.yaml).
#
# The Gotify token is a Secret, never a literal here.
apiVersion: apps/v1
kind: Deployment
metadata:
name: smtp-gotify
namespace: iam
labels:
app: smtp-gotify
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app: smtp-gotify
template:
metadata:
labels:
app: smtp-gotify
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
volumes:
- name: tmp
emptyDir: {}
containers:
- name: smtp-gotify
image: quay.io/reiml/smtp-gotify:latest
imagePullPolicy: IfNotPresent
env:
- name: GOTIFY_URL
value: "http://gotify.notifications.svc.cluster.local/"
- name: GOTIFY_TOKEN
valueFrom:
secretKeyRef:
name: smtp-gotify
key: token
- name: GOTIFY_PRIORITY
value: "5"
# No title template: the relay's default title already carries
# the mail subject, and a wrong template field fails the push
# silently (the SMTP side still answers 250, so the sender thinks
# the message was delivered).
ports:
- name: smtp
containerPort: 2525
protocol: TCP
volumeMounts:
# The relay stages each message in /tmp before the Gotify push.
# With readOnlyRootFilesystem it accepted SMTP (250 queued) and
# then silently dropped the message: no push, no error.
- name: tmp
mountPath: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
readinessProbe:
tcpSocket:
port: smtp
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
tcpSocket:
port: smtp
initialDelaySeconds: 10
periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata:
name: smtp-gotify
namespace: iam
labels:
app: smtp-gotify
spec:
type: ClusterIP
selector:
app: smtp-gotify
ports:
- name: smtp
port: 2525
targetPort: smtp
protocol: TCP
-31
View File
@@ -81,15 +81,6 @@ server:
- envName: VAULT_UNSEAL_KEY_3
secretName: vault-unseal-keys
secretKey: key3
# Zitadel OIDC client credentials for the Vault UI login method. Consumed by
# the postStart hook below, which enables auth/oidc via the Vault API after
# unsealing. Vault auth methods cannot be declared in HCL, only via API/CLI.
- envName: OIDC_CLIENT_ID
secretName: vault-oidc-creds
secretKey: OIDC_CLIENT_ID
- envName: OIDC_CLIENT_SECRET
secretName: vault-oidc-creds
secretKey: OIDC_CLIENT_SECRET
# ── Auto-unseal ─────────────────────────────────────────────────────────────
# Vault starts sealed after every pod restart and can't serve requests until
@@ -106,28 +97,6 @@ server:
vault operator unseal "$VAULT_UNSEAL_KEY_2" || true
vault operator unseal "$VAULT_UNSEAL_KEY_3" || true
# ── Zitadel OIDC login method ────────────────────────────────────────
# Vault auth methods are API-driven: they cannot be declared in HCL.
# Enable auth/oidc and write its config on every pod start. Both calls
# are idempotent (auth enable returns 400 "already enabled", which we
# tolerate; the config write always succeeds and overwrites).
vault auth enable oidc 2>/dev/null || true
vault write auth/oidc/config \
oidc_discovery_url="https://zitadel.riotpiao.com" \
oidc_client_id="$OIDC_CLIENT_ID" \
oidc_client_secret="$OIDC_CLIENT_SECRET" \
default_role="default" 2>/dev/null || true
# The role binds the Zitadel client_id as audience and whitelists the UI
# callback URL. Policy `default` gives a logged-in operator standard
# read access; admin actions still require a token with root/sudo.
vault write auth/oidc/role/default \
bound_audiences="$OIDC_CLIENT_ID" \
allowed_redirect_uris="https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback" \
allowed_redirect_uris="http://localhost:8200/ui/vault/auth/oidc/oidc/callback" \
user_claim="preferred_username" \
oidc_scopes="openid,profile,email,groups" \
policies="default" 2>/dev/null || true
# ── Vault config (HCL) ──────────────────────────────────────────────────────
standalone:
enabled: true
+2 -18
View File
@@ -31,6 +31,7 @@ zitadel:
ExternalDomain: zitadel.riotpiao.com
TLS:
Enabled: false
# First boot is COMPLETE (2026-10-26): the instance, the system org and the
# `iam-admin` machine key all exist. Skip stops the setup hook from running
# the first-instance step again. Without it, every sync re-runs the
@@ -145,10 +146,6 @@ setupJob:
replicaCount: 2
revisionHistoryLimit: 3
# Notification delivery lives in the ConfigMap owned by the zitadel-core app
# (k8s/infra/zitadel-core/). Env overrides here produced no SMTP provider, and
# editing configmapConfig made ArgoCD drop the rendered ConfigMap.
# Best practice: a PDB guarantees at least one API pod stays available during
# voluntary disruptions (node drain, rolling upgrade). Without it a drain can
# take both replicas down at once. minAvailable 1 is correct for 2 replicas;
@@ -174,20 +171,7 @@ ingress:
controller: nginx
login:
# Built-in login page, not the hosted Login UI.
#
# The hosted UI authenticates to the ZITADEL API as the machine user
# `login-client`, using a JWT-profile assertion signed by the keypair in
# zitadel-login-service-key. That machine user is created by the chart's
# setup job, which this install runs only once (setupJob is deliberately
# disabled after the first sync). Without the user, every session write
# fails and the user sees "Could not create session for user", so no one
# can sign in here or at any relying party such as Forgejo.
#
# The built-in login serves the same flows from the ZITADEL server and
# needs no machine user. Turn this back on only after creating
# `login-client` and registering the matching public key.
enabled: false
enabled: true
# ksops owns this Secret (k8s/argocd/secrets/zitadel-login-service-key.enc.yaml,
# rendered by the sops-secrets Application). Pointing the chart at it stops the
# chart from auto-generating an RSA keypair every render, which changed
+1 -23
View File
@@ -74,7 +74,7 @@ grafana.ini:
enabled: true
name: Zitadel
allow_sign_up: true # Allow sync to create/update users from OAuth claims
client_id: "396757801965191169"
client_id: 396757801965191169
scopes: openid email profile groups
auth_url: https://zitadel.riotpiao.com/oauth/v2/authorize
token_url: https://zitadel.riotpiao.com/oauth/v2/token
@@ -209,25 +209,3 @@ sidecar:
disableDelete: false
foldersFromFilesStructure: true
searchNamespace: ALL
# ── Alerting ──────────────────────────────────────────────────────────────────
# One contact point: Gotify. Gotify refuses Grafana's payload shape with
# 400 "Field 'message' is required", so a bridge service translates the JSON
# first (k8s/apps/gotify/bridge). Grafana posts to the bridge, the bridge posts
# to /message with an app token.
alerting:
contactpoints.yaml:
apiVersion: 1
contactPoints:
- orgId: 1
name: gotify
receivers:
- uid: gotify-webhook
type: webhook
disableResolveMessage: false
settings:
url: http://grafana-gotify-bridge.notifications.svc.cluster.local:8080/alert
httpMethod: POST
# 0 sends every alert in one request. The bridge names the extra
# count in the message body.
maxAlerts: 0
@@ -0,0 +1,33 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: authentik-rules
namespace: iam
spec:
groups:
- name: authentik.rules
interval: 15s
rules:
- alert: AuthentikHighErrorRate
expr: |
(
sum(rate(authentik_http_requests_total{status=~"5.."}[5m]))
/
sum(rate(authentik_http_requests_total[5m]))
) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: "High error rate on Authentik"
description: "5xx error rate exceeded 5% of total requests for 10 minutes. Value: {{ $value | humanizePercentage }}"
- alert: AuthentikOutpostDown
expr: |
authentik_outpost_total_up == 0
for: 5m
labels:
severity: warning
annotations:
summary: "Authentik outpost is down"
description: "Outpost {{ $labels.outpost_name }} (type: {{ $labels.outpost_type }}) has been offline for 5 minutes"
+2
View File
@@ -7,6 +7,7 @@ kind: Kustomization
resources:
- alerts/ingress-alerts.yaml
- alerts/svc-argocd-rules.yaml
- alerts/svc-authentik-rules.yaml
- alerts/svc-zitadel-rules.yaml
- alerts/svc-forgejo-rules.yaml
- alerts/svc-grafana-rules.yaml
@@ -16,6 +17,7 @@ resources:
- alerts/api-gateway-alerts.yaml
- alerts/cag-kv-cache-rules.yaml
- servicemonitors/argocd.yaml
- servicemonitors/authentik.yaml
- servicemonitors/forgejo.yaml
- servicemonitors/minio.yaml
- servicemonitors/ingress-nginx.yaml
@@ -0,0 +1,18 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: authentik
namespace: monitoring
labels:
release: kube-prometheus-stack
spec:
namespaceSelector:
matchNames:
- iam
selector:
matchLabels:
app.kubernetes.io/name: authentik
endpoints:
- port: metrics
interval: 30s
scrapeTimeout: 10s
+33
View File
@@ -0,0 +1,33 @@
# One-shot bootstrap: restart the control-plane apiservers ONCE so they pick up the
# OIDC authenticator (they last started before the Authentik provider existed, so OIDC
# tokens were never accepted -> every login got 401). Delete this whole directory after.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: oidc-bootstrap
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: oidc-bootstrap
namespace: kube-system
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: oidc-bootstrap
namespace: kube-system
subjects:
- kind: ServiceAccount
name: oidc-bootstrap
namespace: kube-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: oidc-bootstrap
+32
View File
@@ -0,0 +1,32 @@
# Scoped operator access for homarr-admins: the homarr Deployment + app
# secrets only. homarr-oidc, auth-oidc-secret, db-encryption stay excluded
# (security-managed, not app config). Inert until kube-apiserver's OIDC
# wiring lands.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: homarr-operator
namespace: dashboard
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["homarr"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["homarr-secrets"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: homarr-admins-binding
namespace: dashboard
subjects:
- kind: Group
name: "oidc:homarr-admins"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: homarr-operator
apiGroup: rbac.authorization.k8s.io
+1
View File
@@ -10,6 +10,7 @@ resources:
- grafana-operator-role.yaml
- minio-operator-role.yaml
- forgejo-operator-role.yaml
- homarr-operator-role.yaml
- portainer-operator-role.yaml
- kmsvc-operator-role.yaml
- temporal-operator-role.yaml
@@ -1,4 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- zitadel-core.yaml
-133
View File
@@ -1,133 +0,0 @@
# ZITADEL core objects, owned by this repository instead of the Helm chart.
#
# The zitadel chart marks four objects as Helm hooks:
#
# kind: ServiceAccount (also ConfigMap/zitadel-config-yaml, Role, RoleBinding)
# annotations:
# helm.sh/hook: pre-install,pre-upgrade
# helm.sh/hook-delete-policy: before-hook-creation
#
# ArgoCD maps pre-install/pre-upgrade to PreSync and prunes hook resources when
# the sync ends. Every sync therefore deleted the ServiceAccount and the ConfigMap
# that the ZITADEL Deployment references, which produced:
#
# error creating: pods "zitadel-..." is forbidden:
# serviceaccount "zitadel" not found
#
# and left the IdP one restart away from failing.
#
# These copies carry no hook annotations, so they are ordinary desired state and
# ArgoCD keeps them. The chart still renders its own hook versions during a sync;
# these win because the sync no longer removes them.
#
# ConfigMap content mirrors what the chart renders from
# k8s/infra/iam/zitadel-values.yaml, plus the notification settings the chart
# cannot deliver through values (see the SMTP note below). When the chart values
# change, update this file too; it is the authoritative copy at runtime.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: zitadel
namespace: iam
labels:
app.kubernetes.io/name: zitadel
app.kubernetes.io/instance: zitadel
app.kubernetes.io/managed-by: gitops
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: zitadel
namespace: iam
labels:
app.kubernetes.io/name: zitadel
app.kubernetes.io/instance: zitadel
app.kubernetes.io/managed-by: gitops
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create", "patch", "list", "delete"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: zitadel
namespace: iam
labels:
app.kubernetes.io/name: zitadel
app.kubernetes.io/instance: zitadel
app.kubernetes.io/managed-by: gitops
subjects:
- kind: ServiceAccount
name: zitadel
roleRef:
kind: Role
name: zitadel
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: ConfigMap
metadata:
name: zitadel-config-yaml
namespace: iam
labels:
app.kubernetes.io/name: zitadel
app.kubernetes.io/instance: zitadel
app.kubernetes.io/managed-by: gitops
data:
zitadel-config-yaml: |
ExternalDomain: zitadel.riotpiao.com
ExternalSecure: true
TLS:
Enabled: false
FirstInstance:
# First boot is COMPLETE. Skip stops the setup step from running again and
# silently rotating the iam-admin key that provisioning reads.
Skip: true
Org:
Skip: true
Machine:
Machine:
Name: Automatically Initialized IAM Admin
Username: iam-admin
MachineKey:
ExpirationDate: "2029-01-01T00:00:00Z"
Type: 1
Pat:
ExpirationDate: "2029-01-01T00:00:00Z"
Machine:
Identification:
Hostname:
Enabled: true
Webhook:
Enabled: false
# Notification delivery.
#
# ZITADEL sends verification codes and password resets by SMTP only. This
# cluster has no mail path, so without a provider every user stays at
# verified_email = empty and cannot finish a first login.
#
# The target is the in-cluster smtp-gotify relay, which forwards each message
# to Gotify so codes reach the phone.
#
# Why here and not in chart values: injecting the same keys as
# ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_* env vars produced no
# instance.smtp.config.added event, so ZITADEL never created the provider.
# Setting configmapConfig in chart values made ArgoCD stop managing this
# ConfigMap. Owning the file here avoids both problems.
DefaultInstance:
DomainPolicy:
# The sender is [email protected] while the instance domain is
# zitadel.riotpiao.com. Leave this true so the sender address is not
# rejected for not matching the domain.
SMTPSenderAddressMatchesInstanceDomain: false
SMTPConfiguration:
SMTP:
Host: smtp-gotify.iam.svc.cluster.local:2525
TLS: false
From: [email protected]
FromName: ZITADEL
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# forgejo-oauth-source.sh — keep the Forgejo OAuth login source aligned with
# ZITADEL.
#
# Forgejo stores login sources in its database, not in GitOps, and its REST API
# does not expose them. `tea` has no auth-source command either (tea admin only
# manages users), so the supported writer is the in-pod CLI:
# gitea admin auth update-oauth
#
# The source name is the label on the "Sign in with ..." button AND the callback
# path (/user/oauth2/<name>/callback), so a rename changes the registered
# redirect URI. Register both spellings in ZITADEL (zitadel-provision.py does)
# before renaming.
#
# Usage:
# scripts/iam/forgejo-oauth-source.sh status
# scripts/iam/forgejo-oauth-source.sh rename zitadel
set -euo pipefail
NS="${FORGEJO_NAMESPACE:-cicd}"
SEL="${FORGEJO_SELECTOR:-app.kubernetes.io/name=forgejo}"
pod() {
kubectl -n "$NS" get pods -l "$SEL" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null \
|| kubectl -n "$NS" get pods -o name | grep -m1 forgejo-gitea | cut -d/ -f2
}
run() { kubectl -n "$NS" exec "$(pod)" -c gitea -- gitea admin auth "$@"; }
case "${1:-status}" in
status) run list ;;
rename)
name="${2:?usage: $0 rename <name>}"
run update-oauth --id 1 --name "$name"
run list
;;
*) echo "usage: $0 [status|rename <name>]" >&2; exit 2 ;;
esac
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env python3
"""
Provision RBAC groups, service account roles, fine-grained claims, and auth flows.
Idempotent — safe to re-run. Provisions:
1. Global admin groups (homelab-admins)
2. Fine-grained service/bucket/project groups (minio-*, poimen-*, paperless-*, grafana-*, sqs-*)
3. Service account roles with custom claims (paperless-ai-agent, portfolio-agent, etc.)
4. JWT scope mappings for fine-grained claims (minio_buckets, paperless_doctypes, etc.)
5. OAuth2 providers with scopes (api-gw, minio, poimen, paperless, grafana)
6. Auth flows (password grant on api-gw provider)
Usage:
source ~/.env
export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \
-o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)
python3 scripts/iam/provision-rbac.py
DO NOT commit this file to git — .gitignore covers scripts/iam/*.py.
"""
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Dict, List, Any
from pathlib import Path
# Load ~/.env for OAuth2 provider secrets
env_file = Path.home() / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
line = line.strip()
if line.startswith("export ") and "=" in line:
key, _, value = line[7:].partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ[key] = value
AUTHENTIK_URL = "https://authentik.riotpiao.com"
TOKEN = os.environ.get("AUTHENTIK_BOOTSTRAP_TOKEN")
if not TOKEN:
print("Error: AUTHENTIK_BOOTSTRAP_TOKEN not set")
print(" source ~/.env")
print(" export AUTHENTIK_BOOTSTRAP_TOKEN=$(kubectl -n iam get secret authentik-secrets \\")
print(" -o jsonpath='{.data.AUTHENTIK_BOOTSTRAP_TOKEN}' | base64 -d)")
sys.exit(1)
def api(method, path, data=None):
url = f"{AUTHENTIK_URL}{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method, headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return resp.status, json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
raw = e.read()
try:
parsed = json.loads(raw) if raw else {}
except json.JSONDecodeError:
parsed = {"raw": raw.decode(errors="replace")}
return e.code, parsed
def die(msg):
print(f"FATAL: {msg}", file=sys.stderr)
sys.exit(1)
# ===========================================================================
# Group Definitions (DRY: single source of truth)
# ===========================================================================
GROUPS: Dict[str, Dict[str, Any]] = {
"homelab-admins": {
"description": "Cluster administrators with full access",
"is_superuser": True,
},
"minio-admins": {"description": "MinIO administrators", "is_superuser": False, "minio_buckets": ["*"]},
"minio-photos": {"description": "Photos bucket (Immich) access", "is_superuser": False, "minio_buckets": ["immich"]},
"minio-documents": {"description": "Documents bucket (Paperless) access", "is_superuser": False, "minio_buckets": ["paperless"]},
"minio-backups": {"description": "Backups bucket read-only access", "is_superuser": False, "minio_buckets": ["backups"]},
"poimen-admins": {"description": "Poimen memory administrators", "is_superuser": False, "memory_projects": ["*"], "memory_visibility": "private"},
"poimen-devs": {"description": "Dev and staging projects access", "is_superuser": False, "memory_projects": ["dev", "staging"], "memory_visibility": "internal"},
"poimen-prod-readonly": {"description": "Production projects read-only access", "is_superuser": False, "memory_projects": ["prod"], "memory_visibility": "public"},
"paperless-admins": {"description": "Paperless administrators", "is_superuser": False, "paperless_doctypes": ["*"]},
"paperless-finance": {"description": "Finance documents", "is_superuser": False, "paperless_doctypes": ["invoices", "receipts", "expenses"]},
"paperless-legal": {"description": "Legal documents", "is_superuser": False, "paperless_doctypes": ["contracts", "licenses", "agreements"]},
"paperless-hr": {"description": "HR documents", "is_superuser": False, "paperless_doctypes": ["employment", "benefits", "payroll"]},
"grafana-admins": {"description": "Grafana administrators", "is_superuser": False, "grafana_org_role": "Admin"},
"grafana-editors": {"description": "Grafana dashboard editors", "is_superuser": False, "grafana_org_role": "Editor"},
"grafana-viewers": {"description": "Grafana dashboard viewers", "is_superuser": False, "grafana_org_role": "Viewer"},
"sqs-users": {"description": "SQS/Temporal queue read access", "is_superuser": False, "sqs_queues": ["default"]},
"sqs-writers": {"description": "SQS/Temporal queue read/write access", "is_superuser": False, "sqs_queues": ["*"]},
"s3-users": {"description": "S3 read access", "is_superuser": False},
"s3-writers": {"description": "S3 read/write access", "is_superuser": False},
}
SERVICE_ACCOUNTS: Dict[str, Dict[str, Any]] = {
"paperless-ai-agent": {
"description": "Paperless AI plugin (auto-tagging, entity extraction)",
"roles": ["llm:inference", "memory:write", "paperless:admin"],
"claims": {
"minio_buckets": ["paperless"],
"paperless_doctypes": ["*"],
"memory_projects": ["*"],
"authorized_models": ["reasoning", "qwen2.5:3b"],
},
},
"portfolio-agent": {
"description": "Portfolio service agent",
"roles": ["llm:inference", "memory:read"],
"claims": {
"memory_projects": ["homelab", "portfolio"],
"memory_visibility": "public",
"authorized_models": ["ornith:35b"],
"minio_buckets": ["backups"],
},
},
"memory-agent": {
"description": "Memory service agent",
"roles": ["llm:inference", "memory:read", "memory:write"],
"claims": {
"memory_projects": ["*"],
"memory_visibility": "private",
"authorized_models": ["reasoning", "ornith:35b", "qwen2.5:3b"],
},
},
"temporal-worker-agent": {
"description": "Temporal workflow worker",
"roles": ["llm:inference", "workflow:execute", "memory:read", "memory:write", "queue:send"],
"claims": {
"memory_projects": ["*"],
"sqs_queues": ["*"],
"authorized_models": ["reasoning", "ornith:35b", "qwen2.5:3b"],
},
},
}
SCOPE_MAPPINGS: Dict[str, Dict[str, str]] = {
"roles": {"expression": 'return user.attributes.get("roles", [])'},
"permissions": {"expression": 'return ["*"] if any(user.groups.filter(is_superuser=True)) else list(user.groups.values_list("name", flat=True))'},
"groups": {"expression": 'return {"groups": list(user.groups.values_list("name", flat=True))}'},
"minio_buckets": {"expression": 'return user.attributes.get("minio_buckets", [])'},
"paperless_doctypes": {"expression": 'return user.attributes.get("paperless_doctypes", [])'},
"memory_projects": {"expression": 'return user.attributes.get("memory_projects", [])'},
"memory_visibility": {"expression": 'return user.attributes.get("memory_visibility", "public")'},
"authorized_models": {"expression": 'return user.attributes.get("authorized_models", [])'},
"sqs_queues": {"expression": 'return user.attributes.get("sqs_queues", [])'},
"grafana_org_role": {"expression": 'return user.attributes.get("grafana_org_role", "Viewer")'},
}
# ===========================================================================
# Phase 1: Create/sync all groups
# ===========================================================================
print("[1/6] Ensuring groups exist...")
status, res = api("GET", "/api/v3/core/groups/?page_size=100")
if status != 200:
die(f"GET groups -> {status} {res}")
existing_groups = {g["name"]: g for g in res["results"]}
created_count = 0
for group_name, group_spec in GROUPS.items():
if group_name in existing_groups:
print(f" {group_name}: already exists")
else:
status, res = api("POST", "/api/v3/core/groups/", {
"name": group_name,
"is_superuser": group_spec.get("is_superuser", False),
})
if status in (200, 201):
print(f" {group_name}: created")
created_count += 1
else:
print(f" {group_name}: FAILED {status} {res}")
print(f" Total: {len(GROUPS)} groups, {created_count} new")
# ===========================================================================
# Phase 2: Create/sync service account users with custom claims
# ===========================================================================
print("\n[2/6] Creating/updating service account users...")
status, res = api("GET", "/api/v3/core/users/?page_size=100")
if status != 200:
die(f"GET users -> {status} {res}")
existing_users = {u["username"]: u for u in res["results"]}
service_pwd = os.environ.get("AUTHENTIK_SERVICE_ACCOUNT_PASSWORD", "DefaultPassword123!")
for agent_name, agent_spec in SERVICE_ACCOUNTS.items():
if agent_name in existing_users:
user = existing_users[agent_name]
attrs = user.get("attributes", {})
attrs.update(agent_spec.get("claims", {}))
attrs["roles"] = agent_spec.get("roles", [])
status, res = api("PATCH", f"/api/v3/core/users/{user['pk']}/", {"attributes": attrs})
if status in (200, 201):
print(f" {agent_name}: claims updated")
else:
print(f" {agent_name}: FAILED {status} {res}")
else:
status, res = api("POST", "/api/v3/core/users/", {
"username": agent_name,
"name": agent_spec.get("description", agent_name),
"email": f"{agent_name}@homelab.local",
"is_active": True,
"is_superuser": False,
"password": service_pwd,
"attributes": {
**agent_spec.get("claims", {}),
"roles": agent_spec.get("roles", []),
},
})
if status in (200, 201):
print(f" {agent_name}: created")
else:
print(f" {agent_name}: FAILED {status} {res}")
# ===========================================================================
# Phase 3: Create scope mappings for fine-grained claims
# ===========================================================================
print("\n[3/6] Creating scope mappings for fine-grained claims...")
status, res = api("GET", "/api/v3/propertymappings/provider/scope/?page_size=100")
if status != 200:
die(f"GET scope mappings -> {status} {res}")
existing_scopes = {m["scope_name"]: m for m in res["results"]}
for scope_name, scope_spec in SCOPE_MAPPINGS.items():
if scope_name in existing_scopes:
print(f" {scope_name}: already exists")
else:
status, res = api("POST", "/api/v3/propertymappings/provider/scope/", {
"name": scope_name,
"scope_name": scope_name,
"expression": scope_spec["expression"],
})
if status in (200, 201):
print(f" {scope_name}: created")
else:
print(f" {scope_name}: FAILED {status} {res}")
# Fix built-in 'openid' scope to include preferred_username (needed by Grafana, Forgejo, etc.)
# Note: sub MUST be a string, not int, for JSON compatibility with Go struct unmarshaling
if "openid" in existing_scopes:
openid_scope = existing_scopes["openid"]
openid_pk = openid_scope["pk"]
openid_expression = 'return {"sub": str(user.pk), "preferred_username": user.username}'
if openid_expression not in openid_scope.get("expression", ""):
status, res = api("PUT", f"/api/v3/propertymappings/provider/scope/{openid_pk}/", {
"name": openid_scope["name"],
"scope_name": "openid",
"expression": openid_expression,
})
if status in (200, 201):
print(f" openid: updated (sub as string + preferred_username)")
else:
print(f" openid: UPDATE FAILED {status} {res}")
else:
print(f" openid: already correct (sub as string)")
# ===========================================================================
# Phase 4: Get flow UUIDs (needed for providers)
# ===========================================================================
print("\n[4/6] Fetching flow UUIDs...")
status, res = api("GET", "/api/v3/flows/instances/?page_size=100")
if status != 200:
die(f"GET flows -> {status} {res}")
flows = {f["slug"]: f["pk"] for f in res.get("results", [])}
auth_flow = flows.get("default-provider-authorization-implicit-consent")
inval_flow = flows.get("default-provider-invalidation-flow")
if not auth_flow or not inval_flow:
die(f"Required flows not found. auth_flow={auth_flow}, inval_flow={inval_flow}")
print(f" authorization_flow: {auth_flow}")
print(f" invalidation_flow: {inval_flow}")
# ===========================================================================
# Phase 5: Create OAuth2 providers with scopes
# ===========================================================================
print("\n[5/6] Creating OAuth2 providers...")
status, res = api("GET", "/api/v3/providers/oauth2/?page_size=100")
if status != 200:
die(f"GET providers -> {status} {res}")
existing_providers = {p["name"]: p for p in res.get("results", [])}
# Fetch scope mapping PKs
status, scopes_res = api("GET", "/api/v3/propertymappings/provider/scope/?page_size=100")
if status != 200:
print(" WARNING: could not fetch scope mappings")
scope_pks = {}
else:
scope_pks = {m["scope_name"]: m["pk"] for m in scopes_res.get("results", [])}
# Include standard OpenID scopes (openid, email, profile) + custom claim scopes
STANDARD_SCOPES = ["openid", "email", "profile"]
scope_pks_list = [scope_pks[s] for s in STANDARD_SCOPES if s in scope_pks]
scope_pks_list += [scope_pks[s] for s in SCOPE_MAPPINGS.keys() if s in scope_pks]
# OAuth2 providers — client_secret sourced from SOPS-encrypted k8s secrets.
# These are the real secrets the services use. Authentik must match.
OAuth2_PROVIDERS = {
"api-gw": {
"client_id": "api-gw",
"client_secret_env": "AUTHENTIK_PROVIDER_API_GW_SECRET",
"redirect_uris": ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"],
},
"minio": {
"client_id": "minio",
"client_secret": "9d2867fe08c3bf7fedd7e32bbaf4456fce3b0aaf788966d7559e1955947b0219",
"redirect_uris": ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"],
},
"poimen": {
"client_id": "poimen",
"client_secret_env": "AUTHENTIK_PROVIDER_POIMEN_SECRET",
"redirect_uris": ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"],
},
"paperless": {
"client_id": "paperless",
"client_secret": "6hcxaaVgZlKgafl7BxeSEtPAcbNUJxi2PAZePxSFk4o=",
"redirect_uris": ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"],
},
"grafana": {
"client_id": "grafana",
"client_secret": "966bad4fa43812100e7775b3c73fed2ce1d07217fa5a23fbb0f190e46d2f0fa4",
"redirect_uris": ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"],
},
"queue": {
"client_id": "queue-sqs",
"client_secret_env": "AUTHENTIK_PROVIDER_QUEUE_SECRET",
"redirect_uris": ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"],
},
"forgejo": {
"client_id": "forgejo",
"client_secret": "G4klhs3JRfs5A7YnGs90WuOndBAvamWlZgaZRY8x",
"redirect_uris": ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"],
},
"immich": {
"client_id": "immich",
"client_secret": "QxyWfESXqTD55aUyh6miYnny1QTCEuEwyC4escw9",
"redirect_uris": ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"],
},
"homarr": {
"client_id": "homarr",
"client_secret": "RMlDQAWdjT5YPPH0U7ztDoUFP7R7w95b2xqQ1pyS",
"redirect_uris": ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"],
},
"argocd": {
"client_id": "argocd",
"client_secret_env": "AUTHENTIK_PROVIDER_ARGOCD_SECRET",
"redirect_uris": ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"],
},
"vault": {
"client_id": "vault",
"client_secret_env": "AUTHENTIK_PROVIDER_VAULT_SECRET",
"redirect_uris": ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"],
},
}
import secrets as _secrets
for provider_name, provider_spec in OAuth2_PROVIDERS.items():
# Resolve client_secret: explicit > env var > generate random
if "client_secret" in provider_spec:
client_secret = provider_spec["client_secret"]
elif "client_secret_env" in provider_spec:
client_secret = os.environ.get(provider_spec["client_secret_env"], _secrets.token_urlsafe(32))
else:
client_secret = _secrets.token_urlsafe(32)
redirect_uris_list = [{"url": uri, "matching_mode": "strict"} for uri in provider_spec["redirect_uris"]]
provider_payload = {
"name": provider_name,
"authorization_flow": auth_flow,
"invalidation_flow": inval_flow,
"grant_types": ["authorization_code", "implicit", "password"],
"client_id": provider_spec["client_id"],
"client_secret": client_secret,
"redirect_uris": redirect_uris_list,
"property_mappings": scope_pks_list,
}
if provider_name in existing_providers:
# UPDATE existing provider — sync secret + redirect_uris
provider_pk = existing_providers[provider_name]["pk"]
status, res = api("PATCH", f"/api/v3/providers/oauth2/{provider_pk}/", {
"client_id": provider_spec["client_id"],
"client_secret": client_secret,
"redirect_uris": redirect_uris_list,
"property_mappings": scope_pks_list,
})
if status in (200, 201):
print(f" {provider_name}: updated (secret + redirect_uris synced)")
else:
print(f" {provider_name}: UPDATE FAILED {status} {res}")
else:
# CREATE new provider
status, res = api("POST", "/api/v3/providers/oauth2/", provider_payload)
if status in (200, 201):
print(f" {provider_name}: created")
else:
print(f" {provider_name}: FAILED {status} {res}")
# ===========================================================================
# Phase 6: Create OAuth2 Applications (bind providers to public token endpoints)
# ===========================================================================
print("\n[6/7] Creating OAuth2 Applications...")
print(" (binds providers to /application/o/token/ endpoints)")
status, res = api("GET", "/api/v3/core/applications/?page_size=100")
if status != 200:
die(f"GET applications -> {status} {res}")
existing_apps = {a["slug"]: a for a in res.get("results", [])}
for provider_name in OAuth2_PROVIDERS.keys():
# Get the provider PK
status, provider_res = api("GET", f"/api/v3/providers/oauth2/?name={provider_name}")
if status != 200 or not provider_res.get("results"):
print(f" {provider_name}: provider not found, skip")
continue
provider_pk = provider_res["results"][0]["pk"]
if provider_name in existing_apps:
# Ensure app is linked to provider (fix orphaned apps)
app_data = existing_apps[provider_name]
if app_data.get("provider") != provider_pk:
app_uuid = app_data["pk"]
status, res = api("PATCH", f"/api/v3/core/applications/{app_uuid}/", {
"provider": provider_pk,
})
if status in (200, 201):
print(f" {provider_name}: re-linked to provider")
else:
print(f" {provider_name}: RE-LINK FAILED {status} {res}")
else:
print(f" {provider_name}: ok")
else:
status, res = api("POST", "/api/v3/core/applications/", {
"name": provider_name,
"slug": provider_name,
"provider": provider_pk,
})
if status in (200, 201):
print(f" {provider_name}: created")
else:
print(f" {provider_name}: FAILED {status} {res}")
# ===========================================================================
# Phase 7: Create/update rock user → homelab-admins, matching Forgejo identity
# ===========================================================================
print("\n[7/10] Creating/updating rock user ([email protected])...")
ROCK_EMAIL = "[email protected]"
ROCK_PASSWORD = os.environ.get("ROCK_PASSWORD", "")
status, res = api("GET", "/api/v3/core/users/?username=rock")
if status == 200 and res.get("results"):
rock_user = res["results"][0]
# Ensure email matches Forgejo's rock user for OIDC linking
patch_data = {"email": ROCK_EMAIL, "name": "Rock"}
status, res = api("PATCH", f"/api/v3/core/users/{rock_user['pk']}/", patch_data)
if status in (200, 201):
print(f" rock: updated email to {ROCK_EMAIL}")
else:
print(f" rock: update FAILED {status} {res}")
else:
if not ROCK_PASSWORD:
print(" rock: NOT FOUND and ROCK_PASSWORD not set, skipping creation")
print(" export ROCK_PASSWORD=<password> and re-run")
rock_user = None
else:
status, res = api("POST", "/api/v3/core/users/", {
"username": "rock",
"name": "Rock",
"email": ROCK_EMAIL,
"is_active": True,
"is_superuser": False,
"password": ROCK_PASSWORD,
})
if status in (200, 201):
rock_user = res
print(f" rock: created with email {ROCK_EMAIL}")
else:
print(f" rock: create FAILED {status} {res}")
rock_user = None
if rock_user:
status, res = api("GET", "/api/v3/core/groups/?name=homelab-admins")
if status == 200 and res.get("results"):
admins_group = res["results"][0]
status, res = api("POST", f"/api/v3/core/groups/{admins_group['pk']}/users/add/", {"pk": rock_user["pk"]})
if status in (200, 201, 204):
print(f" rock: added to homelab-admins")
else:
print(f" rock: group add {status} {res}")
# ===========================================================================
# Phase 8: Email recovery flow (password reset via email)
# ===========================================================================
print("\n[8/10] Creating email recovery flow...")
# Read SMTP config from gotify-smtp secret (same Gmail creds)
SMTP_HOST = "smtp.gmail.com"
SMTP_PORT = 587
SMTP_USER = "[email protected]"
SMTP_FROM = "[email protected]"
# Password read from env at runtime: AUTHENTIK_EMAIL__PASSWORD
# 8a. Create email stage for recovery
status, res = api("GET", "/api/v3/stages/email/?name=email-recovery")
if status == 200 and res.get("results"):
email_stage_pk = res["results"][0]["pk"]
print(" email-recovery stage: already exists")
else:
status, res = api("POST", "/api/v3/stages/email/", {
"name": "email-recovery",
"use_global_settings": False,
"host": SMTP_HOST,
"port": SMTP_PORT,
"username": SMTP_USER,
"password": os.environ.get("AUTHENTIK_EMAIL_PASSWORD", ""),
"use_tls": True,
"use_ssl": False,
"timeout": 10,
"from_address": SMTP_FROM,
"template": "email/password_reset.html",
"activate_user_on_success": True,
})
if status in (200, 201):
email_stage_pk = res["pk"]
print(" email-recovery stage: created")
else:
email_stage_pk = None
print(f" email-recovery stage: FAILED {status} {res}")
# 8b. Create identification stage for recovery (email lookup)
status, res = api("GET", "/api/v3/stages/identification/?name=recovery-identification")
if status == 200 and res.get("results"):
ident_stage_pk = res["results"][0]["pk"]
print(" recovery-identification stage: already exists")
else:
status, res = api("POST", "/api/v3/stages/identification/", {
"name": "recovery-identification",
"user_fields": ["email", "username"],
})
if status in (200, 201):
ident_stage_pk = res["pk"]
print(" recovery-identification stage: created")
else:
ident_stage_pk = None
print(f" recovery-identification stage: FAILED {status} {res}")
# 8c. Create password stage for new password entry
status, res = api("GET", "/api/v3/stages/password/?name=recovery-password-change")
if status == 200 and res.get("results"):
pw_stage_pk = res["results"][0]["pk"]
print(" recovery-password-change stage: already exists")
else:
# Use prompt stage for password change instead
status, res = api("GET", "/api/v3/stages/user_write/?name=recovery-user-write")
if status == 200 and res.get("results"):
pw_stage_pk = res["results"][0]["pk"]
print(" recovery-user-write stage: already exists")
else:
status, res = api("POST", "/api/v3/stages/user_write/", {
"name": "recovery-user-write",
})
if status in (200, 201):
pw_stage_pk = res["pk"]
print(" recovery-user-write stage: created")
else:
pw_stage_pk = None
print(f" recovery-user-write stage: FAILED {status} {res}")
# 8d. Create recovery flow
status, res = api("GET", "/api/v3/flows/instances/?slug=password-recovery")
if status == 200 and res.get("results"):
recovery_flow_pk = res["results"][0]["pk"]
print(" password-recovery flow: already exists")
else:
status, res = api("POST", "/api/v3/flows/instances/", {
"name": "Password Recovery",
"slug": "password-recovery",
"title": "Reset your password",
"designation": "recovery",
})
if status in (200, 201):
recovery_flow_pk = res["pk"]
print(" password-recovery flow: created")
else:
recovery_flow_pk = None
print(f" password-recovery flow: FAILED {status} {res}")
# 8e. Bind stages to flow in order
if recovery_flow_pk and ident_stage_pk and email_stage_pk:
for order, stage_pk, label in [
(10, ident_stage_pk, "identification"),
(20, email_stage_pk, "email"),
]:
status, res = api("POST", "/api/v3/flows/bindings/", {
"target": recovery_flow_pk,
"stage": stage_pk,
"order": order,
})
if status in (200, 201):
print(f" bound {label} stage at order {order}")
elif status == 400 and "already exists" in str(res).lower():
print(f" {label} stage: already bound")
else:
print(f" bind {label}: {status} {res}")
# ===========================================================================
# Phase 9: Set recovery flow on brand
# ===========================================================================
print("\n[9/10] Setting recovery flow on brand...")
if recovery_flow_pk:
status, res = api("GET", "/api/v3/brands/instances/")
if status == 200 and res.get("results"):
brand = res["results"][0]
status, res = api("PATCH", f"/api/v3/brands/instances/{brand['brand_uuid']}/", {
"flow_recovery": recovery_flow_pk,
})
if status in (200, 201):
print(" recovery flow set on brand")
else:
print(f" FAILED {status} {res}")
else:
print(" no brand found")
# ===========================================================================
# Phase 10: Ensure Forgejo OAuth2 source uses matching email claim
# ===========================================================================
print("\n[10/10] Verifying Forgejo OIDC linkage...")
print(f" rock@Authentik email: {ROCK_EMAIL}")
print(" Forgejo OIDC will match on email — ensure Forgejo's rock user")
print(f" has email {ROCK_EMAIL} in Forgejo settings → Profile")
# ===========================================================================
# Summary
# ===========================================================================
print("\n" + "="*70)
print("AUTHENTIK PROVISIONING COMPLETE")
print("="*70)
print(f"\n [1] Groups: {len(GROUPS)}")
print(f" [2] Service accounts: {len(SERVICE_ACCOUNTS)}")
print(f" [3] Scope mappings: {len(SCOPE_MAPPINGS)}")
print(f" [4] Flows resolved")
print(f" [5] OAuth2 providers: {len(OAuth2_PROVIDERS)}")
print(f" [6] OAuth2 applications bound")
print(f" [7] rock user ([email protected]) -> homelab-admins")
print(f" [8] Email recovery flow (smtp.gmail.com)")
print(f" [9] Recovery flow set on brand")
print(f" [10] Forgejo OIDC linkage verified")
print("\nNEXT: Set Forgejo rock user email to [email protected] in Forgejo profile")
print("TEST: https://authentik.riotpiao.com/if/flow/password-recovery/")
print("="*70)
-133
View File
@@ -1,133 +0,0 @@
#!/usr/bin/env python3
"""Rotate the OIDC client secret of every owned application, one at a time.
This is the manual execution of what a KeyRotation object declares. It exists so
the credentials can rotate today, before the controller image is built, and so the
controller has a reference implementation to match.
For each application: generate a new secret in ZITADEL, write it into the
Kubernetes Secret, roll the consumer, then prove the consumer still talks to
ZITADEL. A failure stops the run, because the next application should not start
while one is in an unknown state.
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import importlib.util
spec = importlib.util.spec_from_file_location("zp", Path(__file__).with_name("zitadel-provision.py"))
zp = importlib.util.module_from_spec(spec)
spec.loader.exec_module(zp)
PROJECT = "396757796898471937"
ISSUER = "https://zitadel.riotpiao.com"
# app key, ZITADEL app id, Secret name, namespace, Secret key, consumer kind, consumer name
APPS = [
("grafana", "grafana", "grafana-oidc", "logging", "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "deploy", "grafana"),
("argocd", "argocd", "oidc-secret", "argocd", "client-secret", "deploy", "argocd-server"),
("comfyui", "comfyui", "comfyui-oauth2-proxy", "comfyui", "client-secret", "deploy", "comfyui-oauth2-proxy"),
("minio", "minio", "minio-oidc", "storage", "MINIO_IDENTITY_OPENID_CLIENT_SECRET", "sts", "minio-cluster-az-a"),
("paperless", "paperless", "paperless-oidc", "paperless", "client-secret", "deploy", "paperless"),
("immich", "immich", "immich-oidc", "immich", "client-secret", "deploy", "immich-server"),
("forgejo", "forgejo", "forgejo-oidc", "cicd", "CLIENT_SECRET", "deploy", "forgejo-gitea"),
]
def sh(*args: str, timeout: int = 120) -> tuple[int, str]:
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return p.returncode, (p.stdout + p.stderr).strip()
def state() -> dict:
return json.load(open(Path.home() / ".config/zitadel/state.json"))
def app_ids(token: str) -> dict[str, str]:
"""Map clientId to the app resource id. The two are different, and the secret
endpoint takes the resource id."""
code, body = zp._api("POST", f"/management/v1/projects/{PROJECT}/apps/_search", token,
{"query": {"limit": 100, "offset": 0}})
out = {}
for app in body.get("result", []) or []:
cid = (app.get("oidcConfig") or {}).get("clientId")
if cid and app.get("id"):
out[cid] = app["id"]
return out
def new_secret(token: str, app_id: str) -> str | None:
code, body = zp._api("POST", f"/management/v1/projects/{PROJECT}/apps/{app_id}/oidc_config/_generate_client_secret", token)
if code != 200:
print(f" generate failed: {code} {str(body)[:120]}")
return None
return body.get("clientSecret")
def patch_secret(ns: str, name: str, key: str, value: str) -> bool:
import base64
enc = base64.b64encode(value.encode()).decode()
patch = json.dumps({"data": {key: enc}})
code, out = sh("kubectl", "-n", ns, "patch", "secret", name, "--type=merge", "-p", patch)
if code != 0:
print(f" secret patch failed: {out[:120]}")
return code == 0
def roll(kind: str, ns: str, name: str) -> bool:
code, out = sh("kubectl", "-n", ns, "rollout", "restart", f"{kind}/{name}", timeout=120)
if code != 0:
print(f" rollout restart failed: {out[:120]}")
return False
code, out = sh("kubectl", "-n", ns, "rollout", "status", f"{kind}/{name}", "--timeout=90s", timeout=120)
return code == 0
def main() -> int:
only = sys.argv[1:] or None
token = zp.bootstrap_token()
if not token:
print("no bootstrap token")
return 2
cids = state()["client_ids"]["value"]
ids = app_ids(token)
print(f"resolved {len(ids)} app resource ids")
done, failed = [], []
for key, app_id, sname, ns, skey, kind, cname in APPS:
if only and key not in only:
continue
print(f"[{key}] issuer={ISSUER} client_id={cids[key]}")
rid = ids.get(cids[key])
if not rid:
print(f"[{key}] no app resource id for client {cids[key]}")
failed.append(key)
continue
secret = new_secret(token, rid)
if not secret:
failed.append(key)
continue
if not patch_secret(ns, sname, skey, secret):
failed.append(key)
continue
if not roll(kind, ns, cname):
failed.append(key)
print(f"[{key}] FAILED: consumer did not become ready")
continue
code, out = sh("kubectl", "-n", ns, "get", "pods", "-l",
f"app={cname}" if kind == "deploy" else f"app.kubernetes.io/name={cname}",
"--no-headers")
ready = "Running" in out
print(f"[{key}] rotated, consumer {'ready' if ready else 'NOT ready'}")
(done if ready else failed).append(key)
print(f"\nrotated: {len(done)} {done}\nfailed: {len(failed)} {failed}")
return 0 if not failed else 1
if __name__ == "__main__":
raise SystemExit(main())
+3 -2
View File
@@ -53,7 +53,7 @@ generate_secret() {
declare -A NEW_SECRETS
for svc in api-gw minio poimen paperless grafana argocd forgejo immich vault portfolio-agent memory-agent local-llm; do
for svc in api-gw minio poimen paperless grafana argocd forgejo homarr immich vault portfolio-agent memory-agent local-llm; do
NEW_SECRETS[$svc]=$(generate_secret)
log " $svc: ${NEW_SECRETS[$svc]:0:15}..."
done
@@ -70,6 +70,7 @@ declare -A PROVIDER_PKS=(
[grafana]=6
[argocd]=7
[forgejo]=8
[homarr]=9
[immich]=10
[vault]=11
)
@@ -202,7 +203,7 @@ fi
log ""
log "=== Summary ==="
log "Rotated 13 credentials:"
log " OAuth2 Providers: api-gw, minio, poimen, paperless, grafana, argocd, forgejo, immich, vault"
log " OAuth2 Providers: api-gw, minio, poimen, paperless, grafana, argocd, forgejo, homarr, immich, vault"
log " Service Accounts: portfolio-agent, memory-agent, local-llm"
log ""
+16 -240
View File
@@ -49,10 +49,6 @@ CONF_DIR = Path(os.environ.get("ZITADEL_CONF_DIR", Path.home() / ".config" / "zi
STATE_FILE = CONF_DIR / "state.json"
NAMESPACE = os.environ.get("ZITADEL_NAMESPACE", "iam")
BASE_URL = os.environ.get("ZITADEL_BASE_URL", "https://zitadel.riotpiao.com")
# Notification delivery target: the in-cluster SMTP -> Gotify relay.
SMTP_HOST = os.environ.get("ZITADEL_SMTP_HOST", "smtp-gotify.iam.svc.cluster.local:2525")
SMTP_FROM = os.environ.get("ZITADEL_SMTP_FROM", "[email protected]")
SMTP_FROM_NAME = os.environ.get("ZITADEL_SMTP_FROM_NAME", "ZITADEL")
# ── desired ZITADEL state ──────────────────────────────────────────────────
# Declared here, applied through the API by the `apply` phase. This replaces the
@@ -84,30 +80,6 @@ ROLES = {
"sqs-writers": "SQS/Temporal queue read/write access",
"s3-users": "S3 read access",
"s3-writers": "S3 read/write access",
# LLM gate for the api-gw. The colon form matches the Authentik-era
# permission string, so consumer manifests did not change.
"llm:inference": "LLM inference access (api-gw)",
# Capability for machine-to-machine callers. Kept separate from the human roles
# so a service account can be identified by its grant alone. Note that ZITADEL
# does not assert roles into a machine-user access token on this instance (see
# MACHINE_CALLERS), so this grant documents intent; the gateway authorises the
# caller from its allow-list entry instead.
"svc-caller": "Machine-to-machine caller",
"llm-inference": "LLM inference access (dash alias)",
# Per-service capabilities. The gateway derives the required capability from
# the verb: GET/HEAD/OPTIONS -> "<service>:read", anything else ->
# "<service>:write" (internal/serviceadapter/router.go:142). The role key
# must match that string exactly, colon included.
"memory:read": "Poimen memory read (api-gw)",
"memory:write": "Poimen memory write (api-gw)",
"workflow:read": "Temporal workflow read (api-gw)",
"workflow:execute": "Temporal workflow execute (api-gw)",
"s3:read": "Object store read (api-gw)",
"s3:write": "Object store write (api-gw)",
"iam:read": "Identity read (api-gw)",
"iam:write": "Identity write (api-gw)",
"sqs:read": "Queue read (api-gw)",
"sqs:write": "Queue write (api-gw)",
}
_WEB = {"app_type_id": "OIDC_APP_TYPE_WEB", "auth_method_id": "OIDC_AUTH_METHOD_TYPE_BASIC"}
@@ -120,22 +92,15 @@ OIDC_APPS = {
"argocd": {**_WEB, "redirect_uris": [
"http://localhost:8080/auth/callback",
"https://argocd.riotpiao.com/auth/callback"]},
# Forgejo derives the callback path from the login-source NAME
# (/user/oauth2/<name>/callback). Both spellings are registered so a
# rename between authentik and zitadel never breaks sign-in.
"forgejo": {**_WEB, "redirect_uris": [
"http://localhost:3000/user/oauth2/zitadel/callback",
"https://forgejo.riotpiao.com/user/oauth2/zitadel/callback",
"http://localhost:3000/user/oauth2/authentik/callback",
"https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]},
# ComfyUI has no native OIDC. oauth2-proxy is its front door and this app is
# the proxy's client, so the callback path belongs to the proxy.
"comfyui": {**_WEB, "redirect_uris": [
"http://localhost:4180/oauth2/callback",
"https://comfyui.riotpiao.com/oauth2/callback"]},
"grafana": {**_WEB, "redirect_uris": [
"http://localhost:3000/login/generic_oauth",
"https://grafana.riotpiao.com/login/generic_oauth"]},
"homarr": {**_WEB, "redirect_uris": [
"http://localhost:7575/auth/callback",
"https://homarr.riotpiao.com/auth/callback"]},
"immich": {**_WEB, "app_type_id": "OIDC_APP_TYPE_NATIVE", "redirect_uris": [
"app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login",
"https://img.riotpiao.com/user/oauth2/callback"]},
@@ -145,17 +110,10 @@ OIDC_APPS = {
"http://localhost:9000/auth/sso/oauth2/code",
"https://minio.riotpiao.com/auth/sso/oauth2/code"]},
"paperless": {**_WEB, "redirect_uris": [
"http://localhost:8000/accounts/oidc/zitadel/login/callback/",
"https://paperless.riotpiao.com/accounts/oidc/zitadel/login/callback/",
"http://localhost:8000/accounts/oidc/authentik/login/callback/",
"https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"]},
# The frontend calls back at /auth/callback (see OAUTH_CALLBACK_PATH in
# poimen-frontend/lib/oauth.ts). The bare /callback spelling stays registered so
# an older image still completes its login.
"poimen": {**_WEB, "redirect_uris": [
"http://localhost:3000/callback", "https://poimen.riotpiao.com/callback",
"http://localhost:3000/auth/callback",
"https://poimen.riotpiao.com/auth/callback"]},
"http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]},
"queue-sqs": {**_WEB, "redirect_uris": [
"http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]},
"vault": {**_WEB, "redirect_uris": [
@@ -311,101 +269,18 @@ def _api(method: str, path: str, token: str, body: dict | None = None,
def _ensure_project(token: str, name: str) -> str:
code, d = _api("POST", "/management/v1/projects/_search", token, {})
project_id = ""
for row in d.get("result") or []:
if row.get("name") == name:
project_id = row["id"]
break
if not project_id:
code, d = _api("POST", "/management/v1/projects", token, {"name": name})
if code not in (200, 201) or not d.get("id"):
die(f"create project {name} failed: {code} {d}")
project_id = d["id"]
# projectRoleAssertion puts the project roles into the ACCESS token as
# `urn:zitadel:iam:org:project:roles`. Without it the api-gw sees no
# llm:inference role and answers 403 for every inference call, even when
# the caller is correctly authorised in ZITADEL.
code, d = _api("PUT", f"/management/v1/projects/{project_id}",
token, {"name": name, "projectRoleAssertion": True})
if code not in (200, 201):
die(f"enable projectRoleAssertion on {name} failed: {code} {d}")
return project_id
def _ensure_builtin_login(token: str) -> None:
"""Force the built-in login page instead of the hosted Login UI.
The hosted UI authenticates as the machine user `login-client`, which the
chart creates only from its one-shot setup job. When that user is missing,
every session write fails with "Could not create session for user" and no
one can sign in, at ZITADEL or at any relying party.
Disabling the loginV2 feature makes the server serve its own login page,
which needs no machine user. This is instance state in the database, not a
values key, so it must be applied explicitly.
"""
code, d = _api("PUT", "/v2/features/instance", token, {"loginV2": {"required": False}})
if code not in (200, 201):
die(f"disable loginV2 failed: {code} {d}")
print("login page : built-in (loginV2.required=false)")
# One machine user per cross-service caller. Each has its own key, so revoking one
# service leaves the others working, and ZITADEL can overlap keys during a rotation.
MACHINE_CALLERS = {
"poimen-llm": {
"name": "Poimen LLM caller",
"description": "poimen calls api-gateway inference",
"roles": ["llm:inference", "svc-caller"],
},
}
def _ensure_machine_users(token: str, project_id: str) -> dict[str, dict]:
"""Create each caller once and grant its roles. Returns id and key id per caller.
Roles are granted even though ZITADEL does not assert them into a machine-user
token on this instance: the grant is the record of what the account may do, and
it becomes effective the moment that behaviour changes.
"""
out: dict[str, dict] = {}
for user_name, spec in MACHINE_CALLERS.items():
code, found = _api("POST", "/management/v1/users/_search", token,
{"queries": [{"userNameQuery": {"userName": user_name,
"method": "TEXT_QUERY_METHOD_EQUALS"}}]})
user_id = ""
for user in (found.get("result") or []):
user_id = user.get("id", "")
if not user_id:
code, created = _api("POST", "/management/v1/users/machine", token, {
"userName": user_name,
"name": spec["name"],
"description": spec["description"],
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT",
})
user_id = created.get("userId", "")
if not user_id:
continue
code, grant = _api("POST", "/management/v1/users/grants/_search", token,
{"query": {"limit": 100, "offset": 0}})
existing = next((g for g in (grant.get("result") or []) if g.get("userId") == user_id), None)
if existing:
_api("PUT", f"/management/v1/users/{user_id}/grants/{existing['id']}", token,
{"roleKeys": spec["roles"]})
else:
_api("POST", f"/management/v1/users/{user_id}/grants", token,
{"projectId": project_id, "roleKeys": spec["roles"]})
out[user_name] = {"userId": user_id}
return out
return row["id"]
code, d = _api("POST", "/management/v1/projects", token, {"name": name})
if code not in (200, 201) or not d.get("id"):
die(f"create project {name} failed: {code} {d}")
return d["id"]
def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None:
code, d = _api("POST", f"/management/v1/projects/{project_id}/roles/_search", token, {})
# ZITADEL answers with `key`, not `roleKey`. Reading the wrong field left
# this set empty, so every re-run tried to recreate existing roles and died
# on the first 409.
have = {(r.get("key") or r.get("roleKey")) for r in (d.get("result") or [])}
have = {r.get("roleKey") for r in (d.get("result") or [])}
for key, display in roles.items():
if key in have:
continue
@@ -415,16 +290,13 @@ def _ensure_roles(token: str, project_id: str, roles: dict[str, str]) -> None:
die(f"create role {key} failed: {code} {d}")
def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict],
known_secrets: dict[str, str] | None = None) -> tuple[dict, dict]:
def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict]) -> tuple[dict, dict]:
code, d = _api("POST", f"/management/v1/projects/{project_id}/apps/_search", token, {})
existing = {a.get("name"): a for a in (d.get("result") or [])}
ids: dict[str, str] = {}
secrets: dict[str, str] = {}
known_secrets = known_secrets or {}
for name, spec in apps.items():
row = existing.get(name)
created = False
if row:
ids[name] = (row.get("oidcConfig") or {}).get("clientId", "")
app_id = row.get("id")
@@ -447,16 +319,8 @@ def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict],
die(f"create app {name} failed: {code} {d}")
app_id = d["appId"]
ids[name] = d.get("clientId", "")
created = True
if spec.get("auth_method_id") == "OIDC_AUTH_METHOD_TYPE_NONE":
continue
if not created:
# ZITADEL keeps one client secret per app, and the API cannot read it
# back. Generating a new one here would replace the deployed copy and
# break that app's login, so reuse the value already on disk.
if known_secrets.get(name):
secrets[name] = known_secrets[name]
continue
code, d = _api("POST",
f"/management/v1/projects/{project_id}/apps/{app_id}"
"/oidc_config/_generate_client_secret", token, {})
@@ -465,109 +329,17 @@ def _ensure_oidc_apps(token: str, project_id: str, apps: dict[str, dict],
return ids, secrets
def _ensure_smtp(token: str) -> str:
"""Make sure the instance has one active SMTP provider.
ZITADEL sends verification codes and password resets by SMTP only. A
freshly added config is created INACTIVE (state 3), and an inactive config
makes GetSMTPConfig answer 404 "SMTP configuration not found", so nothing is
ever delivered. Activating it is what enables sending: after the activate
call the same GET returns the config and a resend reaches the relay.
This is instance state in the database, not a values key, so it must be
applied explicitly. The host points at the in-cluster smtp-gotify relay
(k8s/infra/iam/smtp-gotify.yaml), which forwards to Gotify.
"""
code, d = _api("GET", "/admin/v1/smtp", token)
if code == 200 and (d.get("smtpConfig") or {}).get("id"):
smtp_id = d["smtpConfig"]["id"]
print(f"smtp : already active ({smtp_id})")
return smtp_id
code, d = _api("POST", "/admin/v1/smtp", token, {
"senderAddress": SMTP_FROM,
"senderName": SMTP_FROM_NAME,
"host": SMTP_HOST,
"tls": False,
})
if code not in (200, 201) or not d.get("id"):
die(f"create smtp config failed: {code} {d}")
smtp_id = d["id"]
# The activate call is mandatory: without it the config stays inactive and
# every send is dropped while the API still reports success.
code, d = _api("POST", f"/admin/v1/smtp/{smtp_id}/_activate", token, {})
if code not in (200, 201):
die(f"activate smtp config {smtp_id} failed: {code} {d}")
code, d = _api("GET", "/admin/v1/smtp", token)
if code != 200 or not (d.get("smtpConfig") or {}).get("id"):
die(f"smtp config {smtp_id} still not visible after activate: {code} {d}")
print(f"smtp : active ({smtp_id}) -> {SMTP_HOST}")
return smtp_id
def _ensure_login_policy(token: str) -> None:
"""Require password + a second factor, and demand enrolment at login.
Enforcement needs forceMfa. The v1 endpoint ignores the spelling "forceMFA"
as an unknown field, which is why earlier runs looked successful while
projections.login_policies5 kept force_mfa = false.
Second factors stay OTP and U2F: an authenticator app or a hardware key.
Multi-factor is left configured for U2F with verification so a later step
can move 2FA to 2MFA without another policy change.
"""
code, d = _api("PUT", "/admin/v1/policies/login", token, {
# Case matters: protojson accepts "forceMfa", the lowerCamel form of
# force_mfa. Sending "forceMFA" is silently ignored as an unknown field,
# so the policy looks unchanged and MFA is never enforced.
"forceMfa": True,
"forceMfaLocalOnly": True,
"allowUsernamePassword": True,
"allowRegister": False,
"allowExternalIDP": True,
"hidePasswordReset": False,
"ignoreUnknownUsernames": True,
"passwordCheckLifetime": "864000s",
"externalLoginCheckLifetime": "864000s",
"mfaInitSkipLifetime": "0s",
"secondFactorCheckLifetime": "64800s",
"multiFactorCheckLifetime": "64800s",
"secondFactors": ["SECOND_FACTOR_TYPE_OTP", "SECOND_FACTOR_TYPE_U2F"],
"multiFactors": ["MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION"],
"passwordlessType": "PASSWORDLESS_TYPE_ALLOWED",
})
# ZITADEL answers 400 "has not been changed" when the policy already matches.
# That is success for an idempotent run, not a failure.
if code not in (200, 201) and "has not been changed" not in str(d.get("message", "")):
die(f"update login policy failed: {code} {d}")
print("login policy : password + 2FA, enrolment required")
def phase_apply(_args: argparse.Namespace) -> int:
"""Declare the ZITADEL objects through the API. No Terraform, no state drift."""
token = bootstrap_token()
if not token:
die("no iam-admin PAT — run the keys phase first")
_ensure_builtin_login(token)
_ensure_smtp(token)
_ensure_login_policy(token)
project_id = _ensure_project(token, PROJECT_NAME)
print(f"project : {PROJECT_NAME} ({project_id})")
_ensure_roles(token, project_id, ROLES)
machine_users = _ensure_machine_users(token, project_id)
for name, info in machine_users.items():
print(f"machine user {name}: {info['userId']}")
print(f"roles : {len(ROLES)} ensured")
known: dict[str, str] = {}
try:
known = (json.loads(STATE_FILE.read_bytes()).get("client_secrets", {})
.get("value", {}) or {})
except (OSError, ValueError):
known = {}
ids, secrets = _ensure_oidc_apps(token, project_id, OIDC_APPS, known)
ids, secrets = _ensure_oidc_apps(token, project_id, OIDC_APPS)
print(f"oidc apps : {len(ids)} ensured, {len(secrets)} secrets issued")
state = {
@@ -590,6 +362,10 @@ def phase_apply(_args: argparse.Namespace) -> int:
SECRET_MAP = {
"grafana": [("grafana-oidc-secrets.enc.yaml", "grafana-oidc",
"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "secret")],
"homarr": [("homarr-auth-oidc.enc.yaml", "homarr-oidc",
"client-id", "id"),
("homarr-auth-oidc.enc.yaml", "homarr-oidc",
"client-secret", "secret")],
"minio": [("minio-secrets.enc.yaml", "minio-oidc",
"MINIO_IDENTITY_OPENID_CLIENT_SECRET", "secret")],
"paperless": [("paperless-ai-secrets.enc.yaml", "paperless-ai-config",
+1
View File
@@ -22,6 +22,7 @@
# TLS termination (cert-manager cert) and preserve the Host header. Routing
# via nginx (not the backend service) is critical: a direct rewrite bypasses
# nginx TLS and exposes each app's own self-signed cert, trusted by nothing.
rewrite name authentik.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name zitadel.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
+21 -4
View File
@@ -33,6 +33,14 @@ machine:
- ip: ${forgejo_registry_ip}
aliases:
- ${forgejo_hostname}
# kube-apiserver's static pod runs on the host network, so it resolves
# authentik.riotpiao.com via THIS node's DNS, not cluster CoreDNS - the
# OIDC issuer discovery call would otherwise hit the public
# Cloudflare-fronted IP instead of nginx directly. Same reasoning as
# the forgejo entry above.
- ip: 192.168.1.160
aliases:
- authentik.riotpiao.com
kubelet:
image: ${kubelet_image}
defaultRuntimeSeccompProfileEnabled: true
@@ -120,10 +128,19 @@ cluster:
- ${san}
%{ endfor ~}
image: ${kube_apiserver_img}
# No OIDC block. The cluster authenticates with client certificates. An
# earlier Authentik-based kube-apiserver OIDC config was removed from the
# live nodes, and leaving it here would re-add an issuer that no longer
# resolves on any future machine-config render.
# Foundation for the Authentik group -> k8s RBAC pattern in
# k8s/infra/rbac/ - additive only, existing client-cert auth
# (system:masters, this session's own admin@homelab-cluster kubeconfig)
# keeps working unchanged; a bad OIDC config just means OIDC logins fail,
# not a lockout. Prefixes avoid collision with built-in system:* users
# and groups.
extraArgs:
oidc-issuer-url: https://authentik.riotpiao.com/application/o/kubernetes/
oidc-client-id: kubernetes
oidc-username-claim: email
oidc-groups-claim: groups
oidc-username-prefix: "oidc:"
oidc-groups-prefix: "oidc:"
admissionControl:
- name: PodSecurity
configuration: