From 8765ee8706b76e41f6d108f4622d676c9f5a51e7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 19 Feb 2026 19:18:23 -0800 Subject: [PATCH 1/4] Deploy Dex OIDC identity provider on ringtail with Grafana SSO Adds Dex as a central OIDC identity provider running on ringtail's k3s cluster. Grafana is integrated as the first SSO client via generic_oauth. Dex uses Kubernetes CRD storage and ExternalSecrets for all sensitive config (bcrypt hash, client secrets from 1Password). Co-Authored-By: Claude Opus 4.6 --- ansible/roles/caddy/defaults/main.yml | 3 ++ argocd/apps/dex.yaml | 18 +++++++ argocd/manifests/dex/clusterrole.yaml | 12 +++++ argocd/manifests/dex/clusterrolebinding.yaml | 13 +++++ argocd/manifests/dex/deployment.yaml | 50 +++++++++++++++++++ argocd/manifests/dex/external-secret.yaml | 48 ++++++++++++++++++ argocd/manifests/dex/ingress-tailscale.yaml | 26 ++++++++++ argocd/manifests/dex/kustomization.yaml | 12 +++++ argocd/manifests/dex/service.yaml | 13 +++++ argocd/manifests/dex/serviceaccount.yaml | 6 +++ .../external-secret-dex-oauth.yaml | 22 ++++++++ .../grafana-config/kustomization.yaml | 1 + argocd/manifests/grafana/values.yaml | 14 ++++++ containers/dex/default.nix | 28 +++++++++++ docs/changelog.d/feature-dex-oidc.feature.md | 1 + 15 files changed, 267 insertions(+) create mode 100644 argocd/apps/dex.yaml create mode 100644 argocd/manifests/dex/clusterrole.yaml create mode 100644 argocd/manifests/dex/clusterrolebinding.yaml create mode 100644 argocd/manifests/dex/deployment.yaml create mode 100644 argocd/manifests/dex/external-secret.yaml create mode 100644 argocd/manifests/dex/ingress-tailscale.yaml create mode 100644 argocd/manifests/dex/kustomization.yaml create mode 100644 argocd/manifests/dex/service.yaml create mode 100644 argocd/manifests/dex/serviceaccount.yaml create mode 100644 argocd/manifests/grafana-config/external-secret-dex-oauth.yaml create mode 100644 containers/dex/default.nix create mode 100644 docs/changelog.d/feature-dex-oidc.feature.md diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index ce2235a..f7d44fe 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -79,6 +79,9 @@ caddy_services: - name: nvr host: "nvr.{{ caddy_domain }}" backend: "https://nvr.tail8d86e.ts.net" + - name: dex + host: "dex.{{ caddy_domain }}" + backend: "https://dex.tail8d86e.ts.net" - name: ntfy host: "ntfy.{{ caddy_domain }}" backend: "https://ntfy.tail8d86e.ts.net" diff --git a/argocd/apps/dex.yaml b/argocd/apps/dex.yaml new file mode 100644 index 0000000..2da0939 --- /dev/null +++ b/argocd/apps/dex.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: dex + namespace: argocd +spec: + project: default + source: + repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git + targetRevision: main + path: argocd/manifests/dex + destination: + server: https://ringtail.tail8d86e.ts.net:6443 + namespace: dex + syncPolicy: + syncOptions: + - CreateNamespace=true diff --git a/argocd/manifests/dex/clusterrole.yaml b/argocd/manifests/dex/clusterrole.yaml new file mode 100644 index 0000000..76811df --- /dev/null +++ b/argocd/manifests/dex/clusterrole.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: dex +rules: + - apiGroups: ["dex.coreos.com"] + resources: ["*"] + verbs: ["*"] + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["create"] diff --git a/argocd/manifests/dex/clusterrolebinding.yaml b/argocd/manifests/dex/clusterrolebinding.yaml new file mode 100644 index 0000000..53ffefa --- /dev/null +++ b/argocd/manifests/dex/clusterrolebinding.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: dex +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: dex +subjects: + - kind: ServiceAccount + name: dex + namespace: dex diff --git a/argocd/manifests/dex/deployment.yaml b/argocd/manifests/dex/deployment.yaml new file mode 100644 index 0000000..afddb09 --- /dev/null +++ b/argocd/manifests/dex/deployment.yaml @@ -0,0 +1,50 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dex + namespace: dex +spec: + replicas: 1 + selector: + matchLabels: + app: dex + template: + metadata: + labels: + app: dex + spec: + serviceAccountName: dex + containers: + - name: dex + image: registry.ops.eblu.me/blumeops/dex:v1.0.0-nix + ports: + - name: http + containerPort: 5556 + volumeMounts: + - name: config + mountPath: /etc/dex/cfg + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: 5556 + initialDelaySeconds: 5 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: 5556 + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "100m" + volumes: + - name: config + secret: + secretName: dex-config diff --git a/argocd/manifests/dex/external-secret.yaml b/argocd/manifests/dex/external-secret.yaml new file mode 100644 index 0000000..432e9d3 --- /dev/null +++ b/argocd/manifests/dex/external-secret.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: dex-config + namespace: dex +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: dex-config + creationPolicy: Owner + template: + data: + config.yaml: | + issuer: https://dex.ops.eblu.me + storage: + type: kubernetes + config: + inCluster: true + web: + http: 0.0.0.0:5556 + oauth2: + skipApprovalScreen: true + enablePasswordDB: true + staticPasswords: + - email: "blume.erich@gmail.com" + hash: "{{ .staticPasswordHash }}" + username: "eblume" + userID: "eblume-001" + staticClients: + - id: grafana + name: Grafana + secret: "{{ .grafanaClientSecret }}" + redirectURIs: + - "https://grafana.ops.eblu.me/login/generic_oauth" + - "https://grafana.tail8d86e.ts.net/login/generic_oauth" + data: + - secretKey: staticPasswordHash + remoteRef: + key: "Dex (blumeops)" + property: static-password-hash + - secretKey: grafanaClientSecret + remoteRef: + key: "Dex (blumeops)" + property: grafana-client-secret diff --git a/argocd/manifests/dex/ingress-tailscale.yaml b/argocd/manifests/dex/ingress-tailscale.yaml new file mode 100644 index 0000000..4fc1958 --- /dev/null +++ b/argocd/manifests/dex/ingress-tailscale.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dex-tailscale + namespace: dex + annotations: + tailscale.com/proxy-class: "default" + tailscale.com/proxy-group: "ingress" + gethomepage.dev/enabled: "true" + gethomepage.dev/name: "Dex" + gethomepage.dev/group: "Infrastructure" + gethomepage.dev/icon: "mdi-shield-key" + gethomepage.dev/description: "OIDC identity provider" + gethomepage.dev/href: "https://dex.ops.eblu.me" + gethomepage.dev/pod-selector: "app=dex" +spec: + ingressClassName: tailscale + defaultBackend: + service: + name: dex + port: + number: 5556 + tls: + - hosts: + - dex diff --git a/argocd/manifests/dex/kustomization.yaml b/argocd/manifests/dex/kustomization.yaml new file mode 100644 index 0000000..fea126f --- /dev/null +++ b/argocd/manifests/dex/kustomization.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: dex +resources: + - serviceaccount.yaml + - clusterrole.yaml + - clusterrolebinding.yaml + - external-secret.yaml + - deployment.yaml + - service.yaml + - ingress-tailscale.yaml diff --git a/argocd/manifests/dex/service.yaml b/argocd/manifests/dex/service.yaml new file mode 100644 index 0000000..f29a20d --- /dev/null +++ b/argocd/manifests/dex/service.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: dex + namespace: dex +spec: + selector: + app: dex + ports: + - name: http + port: 5556 + targetPort: 5556 diff --git a/argocd/manifests/dex/serviceaccount.yaml b/argocd/manifests/dex/serviceaccount.yaml new file mode 100644 index 0000000..70fc335 --- /dev/null +++ b/argocd/manifests/dex/serviceaccount.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: dex + namespace: dex diff --git a/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml b/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml new file mode 100644 index 0000000..c2c4261 --- /dev/null +++ b/argocd/manifests/grafana-config/external-secret-dex-oauth.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: grafana-dex-oauth + namespace: monitoring +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: onepassword-blumeops + target: + name: grafana-dex-oauth + creationPolicy: Owner + template: + data: + GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "{{ .clientSecret }}" + data: + - secretKey: clientSecret + remoteRef: + key: "Dex (blumeops)" + property: grafana-client-secret diff --git a/argocd/manifests/grafana-config/kustomization.yaml b/argocd/manifests/grafana-config/kustomization.yaml index 18deacc..59e4e19 100644 --- a/argocd/manifests/grafana-config/kustomization.yaml +++ b/argocd/manifests/grafana-config/kustomization.yaml @@ -6,6 +6,7 @@ namespace: monitoring resources: - ingress-tailscale.yaml - external-secret-admin.yaml + - external-secret-dex-oauth.yaml - external-secret-teslamate-datasource.yaml # Dashboard ConfigMaps - discovered by Grafana sidecar via label grafana_dashboard=1 - dashboards/configmap-borgmatic.yaml diff --git a/argocd/manifests/grafana/values.yaml b/argocd/manifests/grafana/values.yaml index f38cd03..d7c183f 100644 --- a/argocd/manifests/grafana/values.yaml +++ b/argocd/manifests/grafana/values.yaml @@ -12,6 +12,8 @@ admin: envFromSecrets: - name: grafana-teslamate-datasource optional: true + - name: grafana-dex-oauth + optional: true # Persistence with PVC for SQLite database persistence: @@ -28,6 +30,18 @@ grafana.ini: security: # Embedding disabled - iframe approach didn't work well for Homepage allow_embedding: false + auth.generic_oauth: + enabled: true + name: Dex + client_id: grafana + client_secret: $__env{GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET} + scopes: openid profile email + auth_url: https://dex.ops.eblu.me/auth + token_url: https://dex.ops.eblu.me/token + api_url: https://dex.ops.eblu.me/userinfo + allow_sign_up: true + role_attribute_path: "'Admin'" + auto_login: false analytics: check_for_updates: false reporting_enabled: false diff --git a/containers/dex/default.nix b/containers/dex/default.nix new file mode 100644 index 0000000..08f6926 --- /dev/null +++ b/containers/dex/default.nix @@ -0,0 +1,28 @@ +# Nix-built Dex OIDC identity provider +# Uses nixpkgs dex-oidc package with Kubernetes CRD storage backend +# Built with dockerTools.buildLayeredImage for efficient layer caching +{ pkgs ? import { } }: + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/dex"; + tag = "latest"; + + contents = [ + pkgs.dex-oidc + pkgs.cacert + pkgs.tzdata + ]; + + config = { + Entrypoint = [ "${pkgs.dex-oidc}/bin/dex" ]; + Cmd = [ "serve" "/etc/dex/cfg/config.yaml" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + ExposedPorts = { + "5556/tcp" = { }; + }; + User = "65534"; + }; +} diff --git a/docs/changelog.d/feature-dex-oidc.feature.md b/docs/changelog.d/feature-dex-oidc.feature.md new file mode 100644 index 0000000..a441fad --- /dev/null +++ b/docs/changelog.d/feature-dex-oidc.feature.md @@ -0,0 +1 @@ +Deploy Dex OIDC identity provider on ringtail with Grafana as first SSO client. -- 2.50.1 (Apple Git-155) From fe1c92f7023782f4507f15f03f0acfe28985dfe4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 19 Feb 2026 19:35:26 -0800 Subject: [PATCH 2/4] Switch Dex from static passwords to Forgejo OAuth2 connector Users authenticate via Forgejo at forge.ops.eblu.me instead of a hardcoded password list. This makes user management scale through Forgejo's existing account system and enables future collaborator onboarding via Forgejo accounts. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/dex/external-secret.yaml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/argocd/manifests/dex/external-secret.yaml b/argocd/manifests/dex/external-secret.yaml index 432e9d3..e654e77 100644 --- a/argocd/manifests/dex/external-secret.yaml +++ b/argocd/manifests/dex/external-secret.yaml @@ -24,12 +24,15 @@ spec: http: 0.0.0.0:5556 oauth2: skipApprovalScreen: true - enablePasswordDB: true - staticPasswords: - - email: "blume.erich@gmail.com" - hash: "{{ .staticPasswordHash }}" - username: "eblume" - userID: "eblume-001" + connectors: + - type: gitea + id: forgejo + name: Forgejo + config: + baseURL: https://forge.ops.eblu.me + clientID: "{{ .forgejoClientID }}" + clientSecret: "{{ .forgejoClientSecret }}" + redirectURI: https://dex.ops.eblu.me/callback staticClients: - id: grafana name: Grafana @@ -38,10 +41,14 @@ spec: - "https://grafana.ops.eblu.me/login/generic_oauth" - "https://grafana.tail8d86e.ts.net/login/generic_oauth" data: - - secretKey: staticPasswordHash + - secretKey: forgejoClientID remoteRef: key: "Dex (blumeops)" - property: static-password-hash + property: forgejo-client-id + - secretKey: forgejoClientSecret + remoteRef: + key: "Dex (blumeops)" + property: forgejo-client-secret - secretKey: grafanaClientSecret remoteRef: key: "Dex (blumeops)" -- 2.50.1 (Apple Git-155) From 80698e499a66ed0787fe64c8d41f94f140927fe7 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 19 Feb 2026 20:16:55 -0800 Subject: [PATCH 3/4] Switch Dex storage from Kubernetes CRD to sqlite3 The Kubernetes CRD storage backend crashes on k3s due to a Go URL parsing bug with the in-cluster API address. sqlite3 with emptyDir avoids the k8s API entirely and is sufficient for single-replica Dex. Also removes now-unnecessary RBAC resources (ServiceAccount, ClusterRole, ClusterRoleBinding). Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/dex/clusterrole.yaml | 12 ------------ argocd/manifests/dex/clusterrolebinding.yaml | 13 ------------- argocd/manifests/dex/deployment.yaml | 5 ++++- argocd/manifests/dex/external-secret.yaml | 4 ++-- argocd/manifests/dex/kustomization.yaml | 3 --- argocd/manifests/dex/serviceaccount.yaml | 6 ------ 6 files changed, 6 insertions(+), 37 deletions(-) delete mode 100644 argocd/manifests/dex/clusterrole.yaml delete mode 100644 argocd/manifests/dex/clusterrolebinding.yaml delete mode 100644 argocd/manifests/dex/serviceaccount.yaml diff --git a/argocd/manifests/dex/clusterrole.yaml b/argocd/manifests/dex/clusterrole.yaml deleted file mode 100644 index 76811df..0000000 --- a/argocd/manifests/dex/clusterrole.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: dex -rules: - - apiGroups: ["dex.coreos.com"] - resources: ["*"] - verbs: ["*"] - - apiGroups: ["apiextensions.k8s.io"] - resources: ["customresourcedefinitions"] - verbs: ["create"] diff --git a/argocd/manifests/dex/clusterrolebinding.yaml b/argocd/manifests/dex/clusterrolebinding.yaml deleted file mode 100644 index 53ffefa..0000000 --- a/argocd/manifests/dex/clusterrolebinding.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: dex -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: dex -subjects: - - kind: ServiceAccount - name: dex - namespace: dex diff --git a/argocd/manifests/dex/deployment.yaml b/argocd/manifests/dex/deployment.yaml index afddb09..ba02856 100644 --- a/argocd/manifests/dex/deployment.yaml +++ b/argocd/manifests/dex/deployment.yaml @@ -14,7 +14,6 @@ spec: labels: app: dex spec: - serviceAccountName: dex containers: - name: dex image: registry.ops.eblu.me/blumeops/dex:v1.0.0-nix @@ -25,6 +24,8 @@ spec: - name: config mountPath: /etc/dex/cfg readOnly: true + - name: data + mountPath: /var/dex livenessProbe: httpGet: path: /healthz @@ -48,3 +49,5 @@ spec: - name: config secret: secretName: dex-config + - name: data + emptyDir: {} diff --git a/argocd/manifests/dex/external-secret.yaml b/argocd/manifests/dex/external-secret.yaml index e654e77..3b9e685 100644 --- a/argocd/manifests/dex/external-secret.yaml +++ b/argocd/manifests/dex/external-secret.yaml @@ -17,9 +17,9 @@ spec: config.yaml: | issuer: https://dex.ops.eblu.me storage: - type: kubernetes + type: sqlite3 config: - inCluster: true + file: /var/dex/dex.db web: http: 0.0.0.0:5556 oauth2: diff --git a/argocd/manifests/dex/kustomization.yaml b/argocd/manifests/dex/kustomization.yaml index fea126f..cffcba8 100644 --- a/argocd/manifests/dex/kustomization.yaml +++ b/argocd/manifests/dex/kustomization.yaml @@ -3,9 +3,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: dex resources: - - serviceaccount.yaml - - clusterrole.yaml - - clusterrolebinding.yaml - external-secret.yaml - deployment.yaml - service.yaml diff --git a/argocd/manifests/dex/serviceaccount.yaml b/argocd/manifests/dex/serviceaccount.yaml deleted file mode 100644 index 70fc335..0000000 --- a/argocd/manifests/dex/serviceaccount.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: dex - namespace: dex -- 2.50.1 (Apple Git-155) From 8cd2a19ea3575ca01a701fedf60498abf32ecb07 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Thu, 19 Feb 2026 20:21:50 -0800 Subject: [PATCH 4/4] Fix Grafana root_url to match ops.eblu.me access domain OAuth state cookie is set on the domain users visit (grafana.ops.eblu.me) but Grafana was constructing callbacks from root_url (grafana.tail8d86e.ts.net), causing "Missing saved oauth state" errors. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/grafana/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argocd/manifests/grafana/values.yaml b/argocd/manifests/grafana/values.yaml index d7c183f..24c406c 100644 --- a/argocd/manifests/grafana/values.yaml +++ b/argocd/manifests/grafana/values.yaml @@ -26,7 +26,7 @@ persistence: # Grafana configuration via grafana.ini grafana.ini: server: - root_url: https://grafana.tail8d86e.ts.net + root_url: https://grafana.ops.eblu.me security: # Embedding disabled - iframe approach didn't work well for Homepage allow_embedding: false -- 2.50.1 (Apple Git-155)