From d5487c708aa288fce098bb5e21a8e367ed5f2fde Mon Sep 17 00:00:00 2001 From: rock Date: Sun, 1 Nov 2026 00:01:05 +0000 Subject: [PATCH] feat(talos): authenticate kube-apiserver against ZITADEL (#87) Co-authored-by: rock --- terraform/main.tf | 3 ++ terraform/templates/controlplane.tftpl | 37 +++++++++++++++++++++--- terraform/variables.tf | 39 ++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/terraform/main.tf b/terraform/main.tf index 43bd6b4..05a0f6b 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -39,6 +39,9 @@ resource "local_file" "controlplane_configs" { dns_servers = var.cluster_config.dns_servers forgejo_registry_ip = var.forgejo_registry_ip forgejo_hostname = var.forgejo_hostname + oidc_issuer_hostname = var.oidc_issuer_hostname + oidc_issuer_ip = var.oidc_issuer_ip + oidc_client_id = var.oidc_client_id zone = each.value.zone allow_scheduling = each.value.allow_scheduling coredns_corefile = file("${path.module}/files/coredns/Corefile") diff --git a/terraform/templates/controlplane.tftpl b/terraform/templates/controlplane.tftpl index 40b01ba..b5d15fa 100644 --- a/terraform/templates/controlplane.tftpl +++ b/terraform/templates/controlplane.tftpl @@ -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 the + # OIDC issuer through THIS node's DNS, not cluster CoreDNS. Pin the issuer + # to the ingress LoadBalancer so discovery stays on the LAN and presents + # the publicly-trusted Let's Encrypt wildcard certificate. Without this + # entry the lookup leaves via public DNS and Cloudflare. + - ip: ${oidc_issuer_ip} + aliases: + - ${oidc_issuer_hostname} kubelet: image: ${kubelet_image} defaultRuntimeSeccompProfileEnabled: true @@ -120,10 +128,31 @@ 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. + # OIDC authentication against ZITADEL. Additive: client-certificate auth + # (this session's own admin@homelab-cluster kubeconfig, system:masters) + # keeps working unchanged, so a bad value here degrades OIDC logins rather + # than locking anyone out. + # + # The issuer has NO trailing slash and NO /application/o// suffix. + # That suffix belonged to the Authentik scheme; the apiserver compares `iss` + # in the id_token by exact string equality, so the stale value made every + # OIDC login fail once Authentik stopped running. + # + # Groups: ZITADEL asserts roles as a nested urn claim that the apiserver + # cannot read. The `groupsClaim` action in ZITADEL mirrors the bare role + # names into a flat `groups` claim, which is what oidc-groups-claim consumes. + # Verified end to end: a PKCE id_token for this client carries + # groups=[homelab-admins, ...], email and preferred_username. + # + # Prefixes keep OIDC identities from colliding with built-in system:* users + # and groups. k8s RBAC bindings therefore target `oidc:homelab-admins`. + extraArgs: + oidc-issuer-url: https://${oidc_issuer_hostname} + oidc-client-id: "${oidc_client_id}" + oidc-username-claim: email + oidc-groups-claim: groups + oidc-username-prefix: "oidc:" + oidc-groups-prefix: "oidc:" admissionControl: - name: PodSecurity configuration: diff --git a/terraform/variables.tf b/terraform/variables.tf index 2faf212..c068e21 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -198,3 +198,42 @@ variable "forgejo_hostname" { default = "forgejo.riotpiao.com" description = "Forgejo external hostname" } + +# ── kube-apiserver OIDC (ZITADEL) ──────────────────────────────────────────── +# Authenticates `kubectl` against ZITADEL instead of client certificates only. +# Client-certificate auth keeps working in parallel, so a wrong value here +# degrades OIDC logins without locking anyone out. +# +# The issuer is ZITADEL's external host with NO trailing slash and NO +# /application/o// suffix — a leftover of the Authentik scheme. The +# apiserver validates `iss` in the id_token for exact string equality. +variable "oidc_issuer_hostname" { + type = string + default = "zitadel.riotpiao.com" + description = "Hostname of the OIDC issuer (ZITADEL). Becomes https:// as the issuer." +} + +variable "oidc_issuer_ip" { + type = string + default = "192.168.1.160" + description = <<-EOT + Address the nodes resolve the OIDC issuer to, via extraHostEntries. + + kube-apiserver's static pod runs on the host network, so it resolves the + issuer through the node's own DNS, not cluster CoreDNS. Pointing it at the + ingress-nginx LoadBalancer keeps OIDC discovery on the LAN and serves the + publicly-trusted Let's Encrypt *.riotpiao.com certificate. Without this the + lookup leaves via public DNS and Cloudflare, and discovery either hairpins + or fails. + EOT +} + +variable "oidc_client_id" { + type = string + default = "396757803089330177" + description = <<-EOT + ZITADEL client id of the `kubernetes` OIDC application (PKCE, no secret). + Must be a STRING: the id is an 18-digit number and an unquoted YAML scalar + would be parsed as a float and rejected. + EOT +}