Compare commits

..
Author SHA1 Message Date
rock 280bbdf600 feat(iam): register the poimen /auth/callback redirect
The frontend's callback path is /auth/callback. The old /callback spelling stays in
the list so an image built before this change still completes a login.
2026-10-27 14:54:30 -07:00
rock 60931fbba1 ci(iam): build and push the key-rotation-controller image
Dockerfile: static binary on distroless, no shell, user 65532.

Workflow runs on the golang runner for a push to main that touches the controller.
It builds two tags, the commit sha and 0.1.0, which is the tag the Deployment asks
for, and runs the image with --help so a broken binary fails the job instead of the
cluster.

The runners were dead before this: every task fetch logged 'lookup
forgejo-gitea-http.cicd.svc.cluster.local: no such host'. DNS resolved from a fresh
pod and the Service had a healthy endpoint, so the runner pods held stale network
state. A rollout restart cleared it and all three poll without error.
2026-10-27 14:50:33 -07:00
rock 306c68e7ea chore(iam): retire the .enc files whose values rotation invalidated
grafana-oidc and comfyui-oauth2-proxy held nothing but the rotated credential, so
both files are gone. oauth2-credentials survives with the two secrets that were not
rotated (api-gw, poimen) and drops the three that were (grafana, minio, paperless).

Leaving them would be worse than untidy: sops-secrets ran with selfHeal, so a stale
file silently rewrites a rotated secret back to a value ZITADEL no longer accepts.
32 files remain, down from 34.
2026-10-27 14:47:48 -07:00
rock 36d6dad956 feat(iam): script that rotates an OIDC client secret end to end
Generates a new secret in ZITADEL, writes it into the Kubernetes Secret, rolls the
consumer and checks it comes back. This is the manual execution of a KeyRotation,
and the reference the controller must match.

Two traps it records: the app resource id differs from the client id, and the
secret endpoint is oidc_config/_generate_client_secret.
2026-10-27 14:42:27 -07:00
8 changed files with 210 additions and 76 deletions
@@ -0,0 +1,41 @@
# 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
@@ -0,0 +1,13 @@
# 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,30 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
type: Opaque
data:
client-id: ENC[AES256_GCM,data:jFLgcrh1tiAU0pijGxy+/mFsFHzI5zSi,iv:7GYL4xtPnzyZWtEA0egPHhzvpXGevkIH3Cvr+oD9hKI=,tag:UeDTIKlfcYnOOOJiPNgN0g==,type:str]
client-secret: ENC[AES256_GCM,data:9QFu5a2EkEMjc/B0moPIkMEChrN0Qh5BQtAzFcvW4liKxzVAPbTKgQOJeND+NQjn5ZoE89kqqRyqNJF3bT3H1pWoDFngk9J2X7BN/9r6Qyubias0Jukydw==,iv:xf2kT0fbnJat3L3xxUYm3ht0YvHptU/RIdt9Eh0D6xU=,tag:8iknaQ2kKP1OZ/1jRCa+1g==,type:str]
cookie-secret: ENC[AES256_GCM,data:Qq+l4go/CFtfsZrgYUcTj9bB4sreu2GEqJnu15H6fxIlD7IgnmrodPoIVNpWcdm6LSYabGjm2jraN6RP,iv:EOoDHgvIydzHCC5POohjPq3Vj2YTpzhIhvdG2FzKKzc=,tag:iDXqzFU1Z79bzpG4I4xfww==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxOUtCUDduMVU5U1J4WXZX
Q1VzaFRVdXlHaEZpeVJNOWprdDhUOEd6Z1hzCi9QZEpXN3NDSENzUE9RTGdpb3Va
eVNHVTFLbXpMU0x5UWlFLzZnVlllWE0KLS0tIDZldVNqR1loNHlXZWovNWZ4WjFD
dVErenJDM3haSStxZEVWZUtBbDBIZ1UK9x6Gy4drjfUz2goAaI+oeUDWdNHQFAJl
JGI+X81r/6Krg5N8QDa/mE96E2M2G0yAO5FS1YbpCKOITUEyheacnQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T21:17:08Z"
mac: ENC[AES256_GCM,data:tTGmWFidEWTWHgKPV7b5ABOJwwlP1yQDK5Jd60c9ko6IsmP2GBBPCth7kJgPXuPcw+NHbJ7yt7bcVto7GkPdtY9daJmjPq0Gt16ZO4Xi4hCk22JVKqi/KJcKD/KB0r/VO9w093/Who5CcYgqifextfca4CuxS5m5dcOn4kRJCfE=,iv:0fDvvQr9wp8IrD16u88Ml4HsPEeMhd8R3ljQJoIpw3E=,tag:AanM7C6vbaBBmus1u1GRoA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
@@ -1,28 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-oidc
namespace: logging
type: Opaque
stringData:
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ENC[AES256_GCM,data:fGqA1ofVme1lVLxFjWIiU7sbvE9ip2Af7C1ua3+HdidlcVgkwW59vVlXZhgBRdoif86C8LKQSyoCGUqiO+740g==,iv:6Kv2ybbJg1GT8DLqz9P5y+E1zr7vcIBDMUErmvxNTaw=,tag:0wK28B3G/owCVPPqb2sLmQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArTXMxMWdVRHIvbWh1bVNk
bEFFNlgzNU9zTjlzY2o3VGdnbFpnU2lkYmxrCjF4aWVGSlJaNkcxODJ6RDkrVFo2
UkV2RUwwVHFCd0xMNG9RWXdTUHZGaXcKLS0tIHVaWjhaNHRtS2c0UkVzRk83ckhj
OCtBVFR0d1F5amVhV2E0emRZWThvYjAKFW46i9brIL9ZoO1CEn+IewIdoUhvYK/A
RqzZ6xV+mhlDj0zC+SpVcEm/Ki83nMYOj4+vQingZlr/Kr3Nwb0iLQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T19:44:32Z"
mac: ENC[AES256_GCM,data:EJew+p/kJrurcNpLlFxJGTl69hQYxn3IkzP7EDtdSriW/WI7X1xNLC2dp/dTiLomGmG1uh6+2JI9Sc8gXmrRAWLELAbiOaYbcoU6gi89BjTCWGDAMX4lNSIuH40rsGs4YtYj5U7SGfgx5qto4ZH7LxjBmxzPWM/Xz2Uq/I5jV4k=,iv:LdhjhwCfPUIa7D0gved8Twf7ujN+dg3lN8lCnFBFs0g=,tag:bjkwUCMtR8IBL9BkbuXcnw==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
+17 -15
View File
@@ -5,23 +5,25 @@ metadata:
name: oauth2-credentials
namespace: iam
data:
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]
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]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- enc: |
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2amZPendIOU9PeStOcmJG
c1dzOFVvd3RoT3dXQlJUNkdCSytEZUVGVEJJCklMUkNjUytBMXh0OVUrNUlpMWw3
aHRFT1hBZTZHNHVtaGJCTEFyT2ZWNWsKLS0tIENxR3FKMEplV0V5Y0dsZzBkODVG
U0ZlT2R0VFdEQmpsakVvZ1FJRkRQYTAKESQcSlMAuCG0/tLWWZaeoPayf1EKxSaf
e4o8TmjmSgPEhepQ/LmNXl6obyuqUxztkh4Mm6pocqDp+gfzt7X/1g==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5WmthakxzRk5aeGJWZnRF
bzdqM1o5Q1h2MFlpNEJvRUFZVU1wWjRaVWowClVKYkp0YUJCbUM0MWduaCtBSExH
MDRWUGZkYTdndTZTeEM3ckxrMURFMUEKLS0tIHF3YUlLNndSWmxGejFPbTVjQnRS
THF4RFBNbDJCdjVKRDJ6cEJlZGVBM0UKEDz1sKDON+lelq1zbCO4CETaK77T4G8y
fly66sR5uIvAXdwXy14L9ssZsM1jOHqdkXnpbo6mzh3toB/ROzoL7w==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-10-27T21:47:38Z"
mac: ENC[AES256_GCM,data:bgZD+/LAPKp6DXyRMnbFWyxmiRjWxYO0ofxeGpO7nyw1DMSTD9s8li43eh/IP7wW4NORwzfIfzxUvOFmVfAiQKfkzpYRDFIKqK+aa/+43zppwpVT0UJ3CcBt2qgdJRHKnv9qeUOx4M8ulY7Xcsoevw8EGdrXCc1KAcDIwoIAp0Q=,iv:djYQ6YnGPrYGRKgpfbdf0beW6QHYTq5CJ3nes7+C4p4=,tag:N9n4VKyMNzGCmuYpwBLPng==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
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
version: 3.9.3
-2
View File
@@ -15,8 +15,6 @@ files:
- forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml
- forgejo-secrets.enc.yaml
- comfyui-oauth2-proxy.enc.yaml
- grafana-oidc-secrets.enc.yaml
- grafana-secrets.enc.yaml
- homelab-ca-secrets.enc.yaml
- loki-secrets.enc.yaml
+133
View File
@@ -0,0 +1,133 @@
#!/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())
+6 -1
View File
@@ -143,8 +143,13 @@ OIDC_APPS = {
"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/callback", "https://poimen.riotpiao.com/callback",
"http://localhost:3000/auth/callback",
"https://poimen.riotpiao.com/auth/callback"]},
"queue-sqs": {**_WEB, "redirect_uris": [
"http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]},
"vault": {**_WEB, "redirect_uris": [