Files
homelab/terraform/main.tf
T
rock 04af9f15c3 feat(talos): authenticate kube-apiserver against ZITADEL
The apiserver's live OIDC config still pointed at
https://authentik.riotpiao.com/application/o/kubernetes/. Authentik now runs
zero pods, so that issuer cannot be discovered and every attempt to login with
a token instead of a client certificate fails. The template itself carried no
OIDC block at all, so a render would have removed the stale config rather than
replace it.

Add the ZITADEL issuer, the `kubernetes` client id, and the groups/username
claims. Three details had to be verified against a real token first:

1. The issuer is https://zitadel.riotpiao.com with no trailing slash and no
   /application/o/<app>/ suffix. That suffix belonged to the Authentik scheme;
   the apiserver compares `iss` by exact string equality.
2. ZITADEL does NOT put profile/email claims in the id_token by default, so
   `oidc-username-claim: email` resolved to nothing. idTokenUserinfoAssertion is
   now enabled on the `kubernetes` application.
3. ZITADEL asserts roles as a nested urn claim the apiserver cannot read. The
   `groupsClaim` action (created alongside this change) mirrors the bare role
   names into a flat `groups` claim.

Verified with a full PKCE authorization-code flow against this client: the
id_token carries iss=https://zitadel.riotpiao.com, email, preferred_username and
groups=[homelab-admins, ...]. With oidc-groups-prefix=oidc: that maps to the
existing ClusterRoleBinding homelab-admin-oidc, which grants cluster-admin.

The issuer hostname is pinned in extraHostEntries because the apiserver's static
pod resolves through the node's DNS, not cluster CoreDNS; the ingress
LoadBalancer serves the publicly-trusted Let's Encrypt wildcard certificate.

Client-certificate auth is unchanged, so a wrong value degrades OIDC logins
without locking anyone out.
2026-10-31 14:27:36 -07:00

149 lines
6.3 KiB
Terraform

# Generate Talos machine configurations
locals {
pod_cidr = var.cluster_config.pod_subnets[0]
service_cidr = var.cluster_config.service_subnets[0]
controlplane_ip = var.cluster_config.controlplane_ip
cluster_dns_ip = "10.96.0.10"
kubelet_image = "ghcr.io/siderolabs/kubelet:${var.kubernetes_version}"
kube_apiserver_img = "registry.k8s.io/kube-apiserver:${var.kubernetes_version}"
controller_mgr_img = "registry.k8s.io/kube-controller-manager:${var.kubernetes_version}"
kube_proxy_img = "registry.k8s.io/kube-proxy:${var.kubernetes_version}"
scheduler_img = "registry.k8s.io/kube-scheduler:${var.kubernetes_version}"
# Use Image Factory generated URL with Longhorn extensions (iscsi-tools, util-linux-tools)
factory_image = data.talos_image_factory_urls.longhorn_installer.urls.installer
}
# Control plane machine configurations
resource "local_file" "controlplane_configs" {
for_each = var.controlplane_configs
filename = "${path.module}/../cluster-config/${each.key}.yaml"
content = templatefile("${path.module}/templates/controlplane.tftpl", {
version = "v1alpha1"
hostname = each.value.hostname
token = var.machine_token
ca_crt = var.machine_ca_crt
ca_key = var.machine_ca_key
lan_ip = each.value.lan_ip
lan_subnet = each.value.lan_subnet
lan_gateway = each.value.lan_gateway
kubelet_image = local.kubelet_image
cluster_dns_ip = local.cluster_dns_ip
install_disk = each.value.install_disk
factory_image = local.factory_image
talos_version = var.talos_version
longhorn_disks = each.value.longhorn_disks
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")
cilium_lb_ippool = file("${path.module}/files/cilium/lb-ippool.yaml")
cilium_l2_announcement = file("${path.module}/files/cilium/l2-announcement.yaml")
# Cloudflare Tunnel cert SANs (talos :50000 and kube-apiserver :6443)
cloudflare_talos_sans = each.value.cloudflare_talos_sans
cloudflare_apiserver_sans = each.value.cloudflare_apiserver_sans
# Cluster config
cluster_id = var.cluster_id
cluster_secret = var.cluster_secret
controlplane_ip = local.controlplane_ip
cluster_name = var.cluster_name
pod_subnets = var.cluster_config.pod_subnets
service_subnets = var.cluster_config.service_subnets
dns_domain = var.cluster_config.dns_domain
bootstrap_token = var.bootstrap_token
# Kubernetes certs
kubernetes_ca_crt = var.kubernetes_ca_crt
kubernetes_ca_key = var.kubernetes_ca_key
etcd_ca_crt = var.etcd_ca_crt
etcd_ca_key = var.etcd_ca_key
aggregator_ca_crt = var.aggregator_ca_crt
aggregator_ca_key = var.aggregator_ca_key
service_account_key = var.service_account_key
secretbox_encryption_secret = var.secretbox_encryption_secret
# Component images
kube_apiserver_img = local.kube_apiserver_img
controller_mgr_img = local.controller_mgr_img
kube_proxy_img = local.kube_proxy_img
scheduler_img = local.scheduler_img
})
}
# Worker machine configurations
resource "local_file" "worker_configs" {
for_each = var.worker_configs
filename = "${path.module}/../cluster-config/worker-${each.key}.yaml"
content = templatefile("${path.module}/templates/worker.tftpl", {
version = "v1alpha1"
hostname = each.value.hostname
token = var.machine_token
ca_crt = var.machine_ca_crt
ca_key = var.machine_ca_key
lan_ip = each.value.lan_ip
lan_subnet = each.value.lan_subnet
lan_gateway = each.value.lan_gateway
network_interface = each.value.network_interface
kubelet_image = local.kubelet_image
cluster_dns_ip = local.cluster_dns_ip
install_disk = each.value.install_disk
factory_image = each.value.factory_image != null ? each.value.factory_image : (each.value.gpu_count > 0 ? data.talos_image_factory_urls.gpu_installer.urls.installer : local.factory_image)
talos_version = var.talos_version
dns_servers = var.cluster_config.dns_servers
forgejo_registry_ip = var.forgejo_registry_ip
forgejo_hostname = var.forgejo_hostname
zone = each.value.zone
gpu_count = each.value.gpu_count
node_labels = each.value.node_labels
node_taints = each.value.node_taints
extra_disks = each.value.extra_disks
swap_size = each.value.swap_size
ephemeral_max_size = each.value.ephemeral_max_size
# Cluster config
cluster_id = var.cluster_id
cluster_secret = var.cluster_secret
controlplane_ip = local.controlplane_ip
cluster_name = var.cluster_name
pod_subnets = var.cluster_config.pod_subnets
service_subnets = var.cluster_config.service_subnets
dns_domain = var.cluster_config.dns_domain
bootstrap_token = var.bootstrap_token
# Kubernetes certs
kubernetes_ca_crt = var.kubernetes_ca_crt
kubernetes_ca_key = var.kubernetes_ca_key
aggregator_ca_crt = var.aggregator_ca_crt
aggregator_ca_key = var.aggregator_ca_key
service_account_key = var.service_account_key
secretbox_encryption_secret = var.secretbox_encryption_secret
})
}
# Output paths for reference
output "controlplane_config_paths" {
value = {
for k, v in local_file.controlplane_configs : k => v.filename
}
description = "Paths to generated controlplane configs"
}
output "worker_config_paths" {
value = {
for k, v in local_file.worker_configs : k => v.filename
}
description = "Paths to generated worker configs"
}