feat(k8s): terminate TLS in the cluster for a public domain (closes #177) #178

Open
not wants to merge 3 commits from feat/177-public-tls-edge into main
5 changed files with 179 additions and 3 deletions
Showing only changes of commit 56cba9c340 - Show all commits
@@ -135,6 +135,20 @@ cluster-internal hosts ({{ .Release.Namespace }}) and the node address
{{- end }}
{{- end -}}
{{/*
The origin a browser reaches Keycloak on, and so the issuer its tokens carry and
the authority the portals are configured with (ADR-0010). With a public edge that
is the `auth` hostname on `public.domain` which must stay in step with the `auth`
key in `public.routes`; without one it is the node address plus Keycloak's NodePort.
*/}}
{{- define "big.keycloakUrl" -}}
{{- if .Values.public.domain -}}
https://auth.{{ .Values.public.domain }}
{{- else -}}
http://{{ .Values.host }}:{{ index .Values.nodePorts "keycloak" }}
{{- end -}}
{{- end -}}
{{- define "big.labels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
@@ -40,5 +40,5 @@ metadata:
{{- include "big.labels" (dict "root" $ "name" (printf "portal-config-%s" $realm)) | nindent 4 }}
data:
config.json: |
{ "authority": "{{ printf "http://%s:%v" $.Values.host (index $.Values.nodePorts "keycloak") }}/realms/{{ $realm }}" }
{ "authority": "{{ include "big.keycloakUrl" $ }}/realms/{{ $realm }}" }
{{- end }}
@@ -28,7 +28,7 @@ spec:
{{- range $w.files }}
{{- if hasPrefix "portal-config-" .configMap }}
annotations:
checksum/portal-config: {{ printf "%s|%v" $.Values.host (index $.Values.nodePorts "keycloak") | sha256sum }}
checksum/portal-config: {{ include "big.keycloakUrl" $ | sha256sum }}
{{- end }}
{{- end }}
labels:
@@ -0,0 +1,136 @@
{{- /*
The public TLS edge (ADR-0035). Rendered only when `public.domain` is set; with it
empty the stack is reached on the NodePorts below and nothing here exists.
Caddy rather than an ingress controller: the four portals already run caddy:2-alpine,
so this adds no dependency, and it does ACME itself — no cert-manager, no CRDs, no
Ingress objects for five hostnames that never change. It proxies to the ClusterIP
services, so the browser-facing NodePorts are not involved in a public deployment.
The public IP lives on the Fedora host, which forwards 80/443 to the two NodePorts
below. That forward is dumb L4 — no TLS, no routing — see the runbook.
*/}}
{{- if .Values.public.domain }}
{{- $pub := .Values.public }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: caddy-edge-config
labels:
{{- include "big.labels" (dict "root" $ "name" "caddy-edge") | nindent 4 }}
data:
Caddyfile: |
{
{{- with $pub.email }}
email {{ . }}
{{- end }}
}
{{- range $sub, $target := $pub.routes }}
{{ $sub }}.{{ $pub.domain }} {
reverse_proxy {{ $target }}
}
{{- end }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: caddy-edge
labels:
{{- include "big.labels" (dict "root" $ "name" "caddy-edge") | nindent 4 }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: caddy-edge
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
annotations:
# A ConfigMap mounted with subPath never updates in place, so a changed
# Caddyfile has to roll the pod.
checksum/caddyfile: {{ printf "%s|%v|%v" $pub.domain $pub.email $pub.routes | sha256sum }}
labels:
{{- include "big.labels" (dict "root" $ "name" "caddy-edge") | nindent 8 }}
spec:
containers:
- name: caddy-edge
image: {{ $pub.image }}
ports:
- name: http
containerPort: 80
- name: https
containerPort: 443
# TCP, not HTTP: a GET with no matching Host gets a 404 from Caddy, which
# would fail an httpGet probe for a perfectly healthy edge.
readinessProbe:
tcpSocket: { port: 443 }
volumeMounts:
- name: config
mountPath: /etc/caddy/Caddyfile
subPath: Caddyfile
readOnly: true
- name: data
mountPath: /data
- name: run
mountPath: /config
volumes:
- name: config
configMap:
name: caddy-edge-config
- name: run
emptyDir: {}
- name: data
{{- if .Values.persistence.storageClass }}
persistentVolumeClaim:
claimName: caddy-edge-data
{{- else }}
# Certificates live here. On an emptyDir every pod restart asks Let's
# Encrypt again, and its duplicate-certificate limit is five per week —
# set persistence.storageClass for anything that stays up.
emptyDir: {}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: caddy-edge
labels:
{{- include "big.labels" (dict "root" $ "name" "caddy-edge") | nindent 4 }}
spec:
type: NodePort
selector:
app.kubernetes.io/name: caddy-edge
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: 80
targetPort: 80
nodePort: {{ $pub.nodePorts.http }}
- name: https
port: 443
targetPort: 443
nodePort: {{ $pub.nodePorts.https }}
{{- if .Values.persistence.storageClass }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: caddy-edge-data
labels:
{{- include "big.labels" (dict "root" $ "name" "caddy-edge") | nindent 4 }}
# Keep the certificates when the release is uninstalled — re-issuing them on
# every reinstall is what burns the rate limit.
annotations:
helm.sh/resource-policy: keep
spec:
accessModes: [ReadWriteOnce]
storageClassName: {{ .Values.persistence.storageClass }}
resources:
requests:
storage: 128Mi
{{- end }}
{{- end }}
+27 -1
View File
@@ -49,6 +49,32 @@ persistence:
# the data across pod restarts.
storageClass: ""
# The public TLS edge (ADR-0035). Empty `domain` = no edge at all: nothing in
# templates/edge.yaml is rendered and the stack is reached on the NodePorts below,
# with `host` above pinning the OIDC origin.
#
# Set it and an in-cluster Caddy terminates TLS for `<sub>.<domain>`, gets its own
# certificates from Let's Encrypt and proxies to the ClusterIP services. The node
# only has to be reachable on the two NodePorts here — the Fedora host forwards
# 80/443 to them (see docs/runbooks/kubernetes-talos.md).
public:
domain: ""
# ACME registration address; Let's Encrypt uses it for expiry warnings.
email: ""
image: docker.io/library/caddy:2-alpine
# <subdomain>: <in-cluster service:port>. `auth` is not free-form — big.keycloakUrl
# builds the pinned issuer from it.
routes:
register: openbaar:80
mijn: self-service:80
behandel: behandel:80
beheer: beheer:80
auth: keycloak:8080
# Where the host's 80/443 forward lands. Not 30080/30443: 30080 is the BFF.
nodePorts:
http: 32080
https: 32443
# The only place a port is published outside the cluster. A workload listed here
# gets a NodePort on its single port; everything else stays ClusterIP.
nodePorts:
@@ -268,7 +294,7 @@ workloads:
# Pin the issuer to the address the browser uses, and let backchannel calls
# keep using keycloak:8080 — the BFF discovers metadata in-cluster and gets
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
KC_HOSTNAME: "http://{{ .Values.host }}:{{ index .Values.nodePorts \"keycloak\" }}"
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports: [{ name: http, port: 8080 }]
# TCP, not /health/ready on the management port: nothing here gates on realm