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
Contributor

What & why

The stack on the Talos VM is currently only reachable through five SSH forwards: PKCE needs
a secure context, so the OIDC origin is pinned to localhost and every viewer has to
forward all five browser-facing ports. This publishes it on real hostnames with real
certificates, terminated in the cluster.

public.domain is the entire switch:

  • Empty (the default) — nothing in templates/edge.yaml renders. Compose, CI and a
    laptop cluster are byte-identical to before.
  • Set — a Caddy deployment gets its own certificates from Let's Encrypt and proxies
    register. / mijn. / behandel. / beheer. / auth.<domain> to the ClusterIP
    services, so a public deployment doesn't use the browser-facing NodePorts at all.

Caddy rather than Traefik/ingress-nginx: the four portals already run caddy:2-alpine
(ADR-0034, whose ceiling note called this exact change out), so it adds no dependency — no
cert-manager, no CRDs, no Ingress objects for five hostnames that never change.

KC_HOSTNAME and the portals' config.json now both come from one helper,
big.keycloakUrl, so the issuer a browser token carries and the authority the BFF discovers
are the same string by construction rather than by two templates agreeing (ADR-0010).

The host keeps a layer-4 forward and nothing else — the public IP is on the Fedora host
and nothing in the cluster can claim it. Two firewall-cmd --add-forward-port rules plus
--add-masquerade, in runbook §10.

Closes #177

Definition of Done

  • Linked Gitea issue (above) — the ADR proposal.
  • Failing test committed first: 88fda30 adds infra/helm/check-issuer.py and it fails
    against the unchanged chart with all seven assertions (KC_HOSTNAME is 'http://192.168.122.100:30180', not https://auth.example.test, five unpublished
    hostnames). 56cba9c makes it pass.
  • Conventional Commits referencing the issue (refs #177), test → feat → docs.
  • CI green — awaiting the run. Locally: make k8s-lint (now including the new check),
    make k8s-drift and infra/check-docs-nav.py all pass.
  • docker compose up unaffected — no compose file, service or image is touched.
  • Docs — ADR-0035, runbook §10 (DNS, the two firewalld rules, first-certificate
    troubleshooting), and §5 now points at §10 as the thing that removes the forwards.
  • ADR added: docs/architecture/adr-0035-public-tls-edge-in-cluster.md, in the nav.
  • Demo note — the demo script's URLs change only once a domain is actually configured;
    happy to add it in this PR if you'd rather it lands together.

Notes for reviewers

  • Read the ADR's third cost first. All five hostnames are published, including behandel
    and beheer, which approve registrations and administer the register — behind synthetic
    accounts with known passwords, and MFA on the medewerker realm (ADR-0031). That was the
    explicit choice for this demo environment; it is in the ADR rather than a comment because
    it is the first decision to revisit if the stack ever holds anything real. The alternative
    is one remote_ip matcher in the Caddyfile.
  • persistence.storageClass stops being optional. Certificates live in /data; on the
    default emptyDir a handful of pod restarts hits Let's Encrypt's five-duplicates-per-week
    limit and the edge then serves an untrusted certificate until it resets. The runbook says
    to install local-path first; the PVC carries resource-policy: keep.
  • This cannot be verified without DNS. Two things outside the repo have to exist before
    a deploy proves anything: five A records to 46.224.220.37, and the firewalld rules
    (which need sudo on the host). Until then the chart change is provable only by rendering,
    which is what the new check does.
  • Touches the same runbook as #176. Whichever merges second needs a trivial rebase on
    docs/runbooks/kubernetes-talos.md (that PR adds §9, this one §10). #176 also needs a
    one-line --set public.domain=… in the deploy workflow to actually switch this on; I've
    put it on that branch rather than duplicating deploy.yaml here.

🤖 Generated with Claude Code

## What & why The stack on the Talos VM is currently only reachable through five SSH forwards: PKCE needs a secure context, so the OIDC origin is pinned to `localhost` and every viewer has to forward all five browser-facing ports. This publishes it on real hostnames with real certificates, terminated **in the cluster**. `public.domain` is the entire switch: - **Empty (the default)** — nothing in `templates/edge.yaml` renders. Compose, CI and a laptop cluster are byte-identical to before. - **Set** — a Caddy deployment gets its own certificates from Let's Encrypt and proxies `register.` / `mijn.` / `behandel.` / `beheer.` / `auth.<domain>` to the **ClusterIP** services, so a public deployment doesn't use the browser-facing NodePorts at all. Caddy rather than Traefik/ingress-nginx: the four portals already run `caddy:2-alpine` (ADR-0034, whose ceiling note called this exact change out), so it adds no dependency — no cert-manager, no CRDs, no Ingress objects for five hostnames that never change. `KC_HOSTNAME` and the portals' `config.json` now both come from one helper, `big.keycloakUrl`, so the issuer a browser token carries and the authority the BFF discovers are the same string by construction rather than by two templates agreeing (ADR-0010). **The host keeps a layer-4 forward and nothing else** — the public IP is on the Fedora host and nothing in the cluster can claim it. Two `firewall-cmd --add-forward-port` rules plus `--add-masquerade`, in runbook §10. Closes #177 ## Definition of Done - [x] Linked Gitea issue (above) — the ADR proposal. - [x] Failing test committed first: `88fda30` adds `infra/helm/check-issuer.py` and it fails against the unchanged chart with all seven assertions (`KC_HOSTNAME is 'http://192.168.122.100:30180', not https://auth.example.test`, five unpublished hostnames). `56cba9c` makes it pass. - [x] Conventional Commits referencing the issue (`refs #177`), test → feat → docs. - [ ] CI green — awaiting the run. Locally: `make k8s-lint` (now including the new check), `make k8s-drift` and `infra/check-docs-nav.py` all pass. - [x] `docker compose up` unaffected — no compose file, service or image is touched. - [x] Docs — ADR-0035, runbook §10 (DNS, the two firewalld rules, first-certificate troubleshooting), and §5 now points at §10 as the thing that removes the forwards. - [x] ADR added: `docs/architecture/adr-0035-public-tls-edge-in-cluster.md`, in the nav. - [ ] Demo note — the demo script's URLs change only once a domain is actually configured; happy to add it in this PR if you'd rather it lands together. ## Notes for reviewers - **Read the ADR's third cost first.** All five hostnames are published, including behandel and beheer, which approve registrations and administer the register — behind synthetic accounts with known passwords, and MFA on the medewerker realm (ADR-0031). That was the explicit choice for this demo environment; it is in the ADR rather than a comment because it is the first decision to revisit if the stack ever holds anything real. The alternative is one `remote_ip` matcher in the Caddyfile. - **`persistence.storageClass` stops being optional.** Certificates live in `/data`; on the default `emptyDir` a handful of pod restarts hits Let's Encrypt's five-duplicates-per-week limit and the edge then serves an untrusted certificate until it resets. The runbook says to install local-path first; the PVC carries `resource-policy: keep`. - **This cannot be verified without DNS.** Two things outside the repo have to exist before a deploy proves anything: five A records to `46.224.220.37`, and the firewalld rules (which need sudo on the host). Until then the chart change is provable only by rendering, which is what the new check does. - **Touches the same runbook as #176.** Whichever merges second needs a trivial rebase on `docs/runbooks/kubernetes-talos.md` (that PR adds §9, this one §10). #176 also needs a one-line `--set public.domain=…` in the deploy workflow to actually switch this on; I've put it on that branch rather than duplicating deploy.yaml here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
not added this to the Iteration 6 — Production Posture milestone 2026-09-18 14:31:20 +00:00
not added 3 commits 2026-09-18 14:31:20 +00:00
Keycloak pins one issuer and each portal is configured with one authority; when
they drift the symptom lands three services away — a login that bounces back
logged out, or a 401 from the BFF (ADR-0010) — so assert they are the same
string. The same check states what publishing the stack has to mean: with
`public.domain` set the five hostnames are served and both halves become
`https://auth.<domain>`, and with it empty nothing of the edge renders, which is
what compose, CI and a laptop cluster depend on.

Red: the chart has no `public.domain`, so setting it changes nothing and no
hostname is published.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`public.domain` is the whole switch. Empty — the default, and what compose, CI
and a laptop cluster use — renders nothing new and leaves every manifest as it
was. Set it and templates/edge.yaml adds a Caddy deployment that gets its own
certificates from Let's Encrypt and proxies the five browser-facing hostnames to
the ClusterIP services, so a public deployment doesn't use their NodePorts at
all.

Caddy rather than an ingress controller because the four portals already run
caddy:2-alpine (ADR-0034, whose ceiling note called exactly this out): no new
dependency, no cert-manager, no CRDs, no Ingress objects for five hostnames that
never change. The Fedora host keeps only a layer-4 forward of 80/443, because
the public IP is there and nothing in the cluster can claim it.

KC_HOSTNAME and the portals' config.json now both come from `big.keycloakUrl`,
so the issuer a token carries and the authority the BFF discovers are one string
by construction (ADR-0010) rather than by two templates agreeing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(arch): ADR-0035 and the runbook section for publishing the stack (refs #177)
CI / k8s (pull_request) Successful in 6s
CI / build (pull_request) Successful in 1m28s
CI / lint (pull_request) Successful in 1m51s
CI / unit (pull_request) Successful in 1m12s
CI / frontend (pull_request) Successful in 2m11s
CI / mutation (pull_request) Successful in 3m45s
CI / verify-stack (pull_request) Successful in 8m15s
399d110663
The ADR records why the edge is in the cluster rather than on the Fedora host —
routing and certificates should be state a `helm upgrade` can see — and the
three costs that buys: the host forward nobody in the cluster can repair, the
Let's Encrypt rate limit that makes `persistence.storageClass` non-optional, and
publishing behandel and beheer to the internet behind synthetic accounts.

Runbook §10 is the operational half: the five DNS records, the two firewalld
rules (including the masquerade that makes the return path work), and the
symptoms each missing piece produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
not added the type:adr-proposalarea:infra labels 2026-09-18 14:31:33 +00:00
All checks were successful
CI / k8s (pull_request) Successful in 6s
CI / build (pull_request) Successful in 1m28s
Required
Details
CI / lint (pull_request) Successful in 1m51s
Required
Details
CI / unit (pull_request) Successful in 1m12s
Required
Details
CI / frontend (pull_request) Successful in 2m11s
Required
Details
CI / mutation (pull_request) Successful in 3m45s
Required
Details
CI / verify-stack (pull_request) Successful in 8m15s
Required
Details
This pull request has changes conflicting with the target branch.
  • docs/runbooks/kubernetes-talos.md
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/177-public-tls-edge:feat/177-public-tls-edge
git checkout feat/177-public-tls-edge
Sign in to join this conversation.