From 03cebb0aebc05f14ce3d1d08a44c245beef61d1b Mon Sep 17 00:00:00 2001 From: rock Date: Tue, 27 Oct 2026 12:06:00 -0700 Subject: [PATCH] feat(gotify): put the Gotify UI behind ZITADEL single sign-on Gotify has no OIDC support, so it cannot validate a ZITADEL token itself. Put oauth2-proxy in front of it and let nginx ask the proxy for every request. The Gotify REST API keeps its own client-token check. /message, /application, /client, /user, /version, /stream and /health are skipped by the proxy, so Forgejo webhooks, the harness, poimen-sender and the Android client keep working without a browser session. Only the web UI requires SSO. The /oauth2 path needs its own ingress: the auth_request annotation would otherwise also guard /oauth2/start, and the login entry point would demand a login first. The client id and secret come from project role provisioning: `zitadel-provision.py apply` now creates the `gotify` OIDC app, and the secret is SOPS-encrypted under k8s/argocd/secrets. Gotify's own login remains behind the proxy. ZITADEL decides who reaches the service, Gotify decides what that person may do inside it. --- k8s/apps/gotify/ingress.yaml | 6 ++ k8s/apps/gotify/kustomization.yaml | 3 + k8s/apps/gotify/oauth2-proxy-deployment.yaml | 82 +++++++++++++++++++ k8s/apps/gotify/oauth2-proxy-ingress.yaml | 22 +++++ k8s/apps/gotify/oauth2-proxy-service.yaml | 15 ++++ .../gotify-oauth2-proxy-secrets.enc.yaml | 30 +++++++ k8s/argocd/secrets/secret-generator.yaml | 1 + scripts/iam/zitadel-provision.py | 19 +++++ 8 files changed, 178 insertions(+) create mode 100644 k8s/apps/gotify/oauth2-proxy-deployment.yaml create mode 100644 k8s/apps/gotify/oauth2-proxy-ingress.yaml create mode 100644 k8s/apps/gotify/oauth2-proxy-service.yaml create mode 100644 k8s/argocd/secrets/gotify-oauth2-proxy-secrets.enc.yaml diff --git a/k8s/apps/gotify/ingress.yaml b/k8s/apps/gotify/ingress.yaml index d140765..5ae2324 100644 --- a/k8s/apps/gotify/ingress.yaml +++ b/k8s/apps/gotify/ingress.yaml @@ -9,6 +9,12 @@ metadata: # WebSocket support for Gotify client connections (/stream endpoint) # nginx-ingress handles Upgrade/Connection headers natively with http/1.1 nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + # Single sign-on. Gotify has no OIDC, so every request is handed to + # oauth2-proxy for an allow or deny decision. oauth2-proxy skips the token + # API paths itself, so /message keeps Gotify's own X-Gotify-Key check. + nginx.ingress.kubernetes.io/auth-url: "http://gotify-oauth2-proxy.notifications.svc.cluster.local:4180/oauth2/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://gotify.riotpiao.com/oauth2/start?rd=$scheme://$host$request_uri" + nginx.ingress.kubernetes.io/auth-response-headers: "x-auth-request-user,x-auth-request-email" spec: ingressClassName: nginx rules: diff --git a/k8s/apps/gotify/kustomization.yaml b/k8s/apps/gotify/kustomization.yaml index 4c7e0b6..5c1ba7c 100644 --- a/k8s/apps/gotify/kustomization.yaml +++ b/k8s/apps/gotify/kustomization.yaml @@ -5,6 +5,9 @@ resources: - db.yaml - deployment.yaml - service.yaml + - oauth2-proxy-deployment.yaml + - oauth2-proxy-service.yaml + - oauth2-proxy-ingress.yaml - ingress.yaml - igotify-pvc.yaml - igotify-deployment.yaml diff --git a/k8s/apps/gotify/oauth2-proxy-deployment.yaml b/k8s/apps/gotify/oauth2-proxy-deployment.yaml new file mode 100644 index 0000000..efc7346 --- /dev/null +++ b/k8s/apps/gotify/oauth2-proxy-deployment.yaml @@ -0,0 +1,82 @@ +# oauth2-proxy in front of Gotify. Gotify has no OIDC support of its own, so it +# cannot validate a ZITADEL token. This proxy is the front door: nginx asks it +# for an auth decision on every request (auth_request), and it answers from a +# ZITADEL session cookie. +# +# The Gotify REST API keeps its own client-token auth (X-Gotify-Key) and must +# stay reachable without a browser session, or every push sender breaks: +# Forgejo webhooks, the harness, poimen-sender and the ntfy-style relays all post +# to /message. skip-auth-route leaves those paths to Gotify's own token check. +# +# Gotify's own login still exists behind the proxy. ZITADEL decides who may +# reach the service; Gotify decides what that person may do inside it. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gotify-oauth2-proxy + namespace: notifications + labels: + app: gotify-oauth2-proxy +spec: + replicas: 1 + selector: + matchLabels: + app: gotify-oauth2-proxy + template: + metadata: + labels: + app: gotify-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://gotify.riotpiao.com/oauth2/callback + - --http-address=0.0.0.0:4180 + # Browser paths keep SSO. API paths fall through to Gotify's own + # client-token check, so existing senders and the Android client + # keep working without a browser session. + - --skip-auth-route=^/(message|application|client|user|version|stream|health)(/.*)?$ + env: + - name: OAUTH2_PROXY_CLIENT_ID + valueFrom: + secretKeyRef: {name: gotify-oauth2-proxy, key: client-id} + - name: OAUTH2_PROXY_CLIENT_SECRET + valueFrom: + secretKeyRef: {name: gotify-oauth2-proxy, key: client-secret} + - name: OAUTH2_PROXY_COOKIE_SECRET + valueFrom: + secretKeyRef: {name: gotify-oauth2-proxy, key: cookie-secret} + ports: + - name: http + containerPort: 4180 + readinessProbe: + httpGet: {path: /ping, port: http} + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: {path: /ping, port: http} + initialDelaySeconds: 15 + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + resources: + requests: {cpu: 10m, memory: 32Mi} + limits: {cpu: 200m, memory: 128Mi} diff --git a/k8s/apps/gotify/oauth2-proxy-ingress.yaml b/k8s/apps/gotify/oauth2-proxy-ingress.yaml new file mode 100644 index 0000000..c484b86 --- /dev/null +++ b/k8s/apps/gotify/oauth2-proxy-ingress.yaml @@ -0,0 +1,22 @@ +# The proxy needs its own ingress path. nginx auth_request also guards +# /oauth2/start and /oauth2/callback, which would loop: the login entry point +# would itself require a login. A longer path match sends /oauth2 to the proxy +# directly, and the prefix rule on the gotify ingress handles everything else. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gotify-oauth2 + namespace: notifications +spec: + ingressClassName: nginx + rules: + - host: gotify.riotpiao.com + http: + paths: + - path: /oauth2 + pathType: Prefix + backend: + service: + name: gotify-oauth2-proxy + port: + number: 4180 diff --git a/k8s/apps/gotify/oauth2-proxy-service.yaml b/k8s/apps/gotify/oauth2-proxy-service.yaml new file mode 100644 index 0000000..baf4965 --- /dev/null +++ b/k8s/apps/gotify/oauth2-proxy-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: gotify-oauth2-proxy + namespace: notifications + labels: + app: gotify-oauth2-proxy +spec: + type: ClusterIP + selector: + app: gotify-oauth2-proxy + ports: + - name: http + port: 4180 + targetPort: http diff --git a/k8s/argocd/secrets/gotify-oauth2-proxy-secrets.enc.yaml b/k8s/argocd/secrets/gotify-oauth2-proxy-secrets.enc.yaml new file mode 100644 index 0000000..cdae31f --- /dev/null +++ b/k8s/argocd/secrets/gotify-oauth2-proxy-secrets.enc.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gotify-oauth2-proxy + namespace: notifications +type: Opaque +data: + client-id: ENC[AES256_GCM,data:Q+XQAR6cfHKoiuxhPZs6khTvqT0A/JG6,iv:LqhfuhXOYPVB6FQpPpERzWRQ+nxgxHZo4qdJO/g52y8=,tag:V2eK2fyscQKmM3/MmDnNtg==,type:str] + client-secret: ENC[AES256_GCM,data:P7F+83CLDhHnTN+Qx3SeCFbCjVH4JHb2rSteCr3Dywcb1ysMSNvC+3HyBXzsfJJ/EErUrCPWuwlRmV5ySts1oIrWhKyWMJxNrUTzv4eoclat4RyV6SaNDg==,iv:ESO1OUIqZbZPEVUuHrqjskA4tuTX/sc5m+K6Eo319kI=,tag:2isdxjFbtosov88FHZdeaQ==,type:str] + cookie-secret: ENC[AES256_GCM,data:WoWiANSdju+Ep5MuZQNeT0BQdKf5cFvrDUZpe+4B0VeUmQqnvblFi3dciJOeBFom9xYYzUCAI2tUSmX+a6tdAObyoGvjdFmLh+WTfw==,iv:41gYYRkyDIqC3GPuZkoob81Wl2njFTY07j4Puzaq0s4=,tag:x6z33zZnFwvmWUQIVPcxdQ==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2UURWblY4aTBOQjFSNDRL + YkRXVjVHK1QvYW1iR2o0UWVmdE94c2lkWDJjCnlXK1k3cnZBNEExbDU3eDhQZ3FT + TVcyckFiWHdRSjZHQ201Nnl6UFdTN28KLS0tIExQeE90VnM1WHRCYkpycjJtR0ht + ZHFDNWhKeENSQzljZUNmdktDYWloUDQK/fOG8AjTb2TNqLbqNfArOs0mzInYNJVd + iaYxnjH6AjhbLKdnmCpecTQpgx8GeFGIkefV/x5pgrdSUwn0lcvpSQ== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-10-27T19:05:35Z" + mac: ENC[AES256_GCM,data:KZV3JNebQ+DbZZZ+e3fvtOGckwC9fmkIchh8DBJ0DMq9lbrPEhP4Juordmnrrs9vJvGxuFJ/KwUv9EELqBsuSy8c47+z7flEAXJiJCJkvVPL/jMidABsFqMALvNNrCqBPWvMHLZUj762DwxB2gKhSx3GLfRUXhm+ZiNcD+39EVw=,iv:ifUlEq/GhBc/QomJApkCaWc3f4ggXoLsvqjzZElO0fU=,tag:fMo+HBDUZdRXgfck+8AJbA==,type:str] + pgp: [] + encrypted_regex: ^(data|stringData)$ + version: 3.9.3 diff --git a/k8s/argocd/secrets/secret-generator.yaml b/k8s/argocd/secrets/secret-generator.yaml index 77211d2..60574ec 100644 --- a/k8s/argocd/secrets/secret-generator.yaml +++ b/k8s/argocd/secrets/secret-generator.yaml @@ -36,6 +36,7 @@ files: - etcd-defrag-creds.enc.yaml - zitadel-masterkey.enc.yaml - smtp-gotify-secrets.enc.yaml + - gotify-oauth2-proxy-secrets.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 diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index d39861e..4cd967f 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -88,6 +88,20 @@ ROLES = { # permission string, so consumer manifests did not change. "llm:inference": "LLM inference access (api-gw)", "llm-inference": "LLM inference access (dash alias)", + # Per-service capabilities. The gateway derives the required capability from + # the verb: GET/HEAD/OPTIONS -> ":read", anything else -> + # ":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"} @@ -108,6 +122,11 @@ OIDC_APPS = { "https://forgejo.riotpiao.com/user/oauth2/zitadel/callback", "http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]}, + # Gotify has no native OIDC, so oauth2-proxy sits in front of it and this app + # is the proxy's client. The callback path is the proxy's, not Gotify's. + "gotify": {**_WEB, "redirect_uris": [ + "http://localhost:4180/oauth2/callback", + "https://gotify.riotpiao.com/oauth2/callback"]}, "grafana": {**_WEB, "redirect_uris": [ "http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"]},