diff --git a/infra/helm/big-reference/templates/_helpers.tpl b/infra/helm/big-reference/templates/_helpers.tpl index 5b24ded..626ad64 100644 --- a/infra/helm/big-reference/templates/_helpers.tpl +++ b/infra/helm/big-reference/templates/_helpers.tpl @@ -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 }} diff --git a/infra/helm/big-reference/templates/config.yaml b/infra/helm/big-reference/templates/config.yaml index 8771b2d..74a1a44 100644 --- a/infra/helm/big-reference/templates/config.yaml +++ b/infra/helm/big-reference/templates/config.yaml @@ -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 }} diff --git a/infra/helm/big-reference/templates/deployments.yaml b/infra/helm/big-reference/templates/deployments.yaml index 02ac150..64fd8fb 100644 --- a/infra/helm/big-reference/templates/deployments.yaml +++ b/infra/helm/big-reference/templates/deployments.yaml @@ -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: diff --git a/infra/helm/big-reference/templates/edge.yaml b/infra/helm/big-reference/templates/edge.yaml new file mode 100644 index 0000000..1ede26a --- /dev/null +++ b/infra/helm/big-reference/templates/edge.yaml @@ -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 }} diff --git a/infra/helm/big-reference/values.yaml b/infra/helm/big-reference/values.yaml index f2e0f55..4a11c82 100644 --- a/infra/helm/big-reference/values.yaml +++ b/infra/helm/big-reference/values.yaml @@ -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 `.`, 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 + # : . `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