Compare commits

..
Author SHA1 Message Date
notandClaude Opus 5.5 61730657e6 fix(k8s): keep Keycloak's backchannel URLs https behind the labs Caddy (refs #177)
CI / lint (pull_request) Successful in 1m44s
CI / k8s (pull_request) Successful in 9s
CI / build (pull_request) Successful in 1m21s
CI / unit (pull_request) Successful in 1m31s
CI / frontend (pull_request) Successful in 2m31s
CI / mutation (pull_request) Successful in 4m47s
CI / verify-stack (pull_request) Successful in 20m11s
KC_HOSTNAME_BACKCHANNEL_DYNAMIC builds the token/userinfo/certs URLs from the
request, which reaches Keycloak as plain http through the proxy, so browsers
blocked them as mixed content after login. Trust X-Forwarded-Proto.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 13:35:39 +02:00
not 0074a1bff3 feat(k8s): optionally auto-fill the medewerker OTP step for the public demo (refs #177) (#181)
CI / k8s (push) Successful in 8s
CI / build (push) Successful in 1m38s
CI / lint (push) Successful in 1m55s
CI / mutation (push) Canceled after 0s
CI / verify-stack (push) Canceled after 0s
CI / frontend (push) Canceled after 12s
CI / unit (push) Canceled after 18s
Deploy to Talos / deploy (push) Successful in 2m30s
## What & why

For the public demo on `big-behandel` / `big-beheer`, visitors should see MFA being enforced without needing an authenticator app. This adds an opt-in Keycloak theme that fills in and submits the medewerker OTP code itself.

- **Theme as real files in `infra/keycloak/themes/big-demo/`**, next to the realms:
  - `login/theme.properties`: `keycloak.v2` plus `scripts=js/otp-autofill.js`. I checked the 26.1 source: `keycloak.v2` loads theme `scripts` and sets none of its own.
  - `login/resources/js/otp-autofill.js`: on the OTP page, computes the code (RFC 6238, Keycloak's default policy) from the fixture secret `BIGMEDEWERKEROTPSEED` and submits it.
  - `account`, `admin`, `email`: plain children of Keycloak 26's defaults. Without them the account console returns 500 (see notes).
- **Seeded like every other file input:** `infra/helm/seed-configmaps.sh` creates the `rr-kc-theme` ConfigMap, and the chart mounts it as a directory. The podspec gains `items` so flat ConfigMap keys map to theme paths. Keycloak runs `start-dev` (no theme cache), so edits show up about a minute after a reseed.
- **Switch:** `demo.otpAutofill` only decides whether `KC_SPI_THEME_DEFAULT=big-demo` is set. `big.env` now skips env values that render empty, and no existing env var is empty. **Off, the render is identical to main except for that one missing variable,** so Keycloak keeps its stock theme. The realm JSONs are untouched, so compose and the e2e tests still require a code.
- **Single-use codes:** a second login in the same 30 s window spends the next counter, as `nextUnusedCounter` does in the e2e. Past that it only fills in the field and doesn't submit, so a rejected code can't loop.
- **Deploy workflow:** repo variable `OTP_AUTOFILL=true` → `--set demo.otpAutofill=true`. Flipping it changes the pod's env, so Keycloak restarts.

Refs #177

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(Not done; checks below.)*
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green
- [x] `docker compose up` unaffected (chart only).
- [x] Docs updated (Talos runbook, "Publishing through the labs Caddy").
- [ ] ADR. The fixture-secret trade-off is ADR-0031's; this only automates typing it in.

## Notes for reviewers

- **Tested on the live cluster.** I patched the running Keycloak with the rendered theme (autofill on) and ran real headless Chromium logins against the public hosts:
  - `merel-behandelaar` on big-behandel: only username and password typed. The OTP page loaded the script, submitted by itself, and the user landed in the Werkbak.
  - `jan-burger` on big-mijn still logs in (regression check).
  - `/realms/medewerker/account/` returns 200.
- **Account console 500, found live and fixed in the second commit.** `KC_SPI_THEME_DEFAULT` applies to every theme type, and Keycloak does *not* fall back for a type the theme lacks (`NullPointerException ... "theme" is null`). `big-demo` now declares login, account, admin and email, each a plain child of Keycloak 26's default. It's one ConfigMap mounted as a directory; the podspec gains `items` for that.
- **Keycloak restarts cause about 5 minutes of BFF 401s.** This is not caused by this PR, but you'll see it whenever Keycloak restarts. Dev-mode Keycloak makes new signing keys on each boot, and the BFF refreshes its cached keys at most every 5 minutes. Seen live: 401 right after the restart, 204 about 4½ minutes later. Flipping `OTP_AUTOFILL` restarts Keycloak, so expect this briefly.
- `make k8s-lint` and `make k8s-drift` pass. The rendered script's code matches `infra/keycloak/check_realms.py otp`.
- **Security:** with it on, the public behandel and beheer portals are protected only by the committed password `test123`. That's intentional for synthetic demo data. Never enable it anywhere real.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #181
2026-09-25 11:34:30 +00:00
not 594fdde227 fix(infra): cap celery workers at 2 so the shared node stops OOM-killing CI (closes #182) (#183)
CI / k8s (push) Successful in 9s
CI / build (push) Successful in 1m39s
CI / lint (push) Successful in 2m0s
CI / mutation (push) Canceled after 0s
CI / verify-stack (push) Canceled after 0s
CI / unit (push) Canceled after 1m9s
CI / frontend (push) Canceled after 1m16s
Deploy to Talos / deploy (push) Successful in 2m31s
## What & why

`verify-stack` is being killed by the OOM controller on the shared Talos node, on main (run 827) and on #180 (run 830). The cause is Celery: with `CELERY_WORKER_CONCURRENCY` unset, `oz-celery` and `nrc-celery` each fork one worker per CPU. That's 22 each on the lab node, 49 Celery processes at about 225 MB apiece. Details and the kernel log evidence are in #182.

This sets `CELERY_WORKER_CONCURRENCY: "2"` in the oz and nrc env groups:
- **compose** (`&oz-env`, `&nrc-env`): what `verify-stack` starts inside `dind`.
- **chart** (`envGroups.oz` / `.nrc`): the deployed demo on the same node.

Both images' `/celery_worker.sh` honour the variable; I checked in the running pods. Web, init and beat containers share the anchors and ignore it. `objecten-celery` already defaults to 1.

Closes #182

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(Resource setting; the evidence is the OOM log in #182.)*
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green. This PR's `verify-stack` run is the check.
- [x] `docker compose config` renders the variable into all 7 services on the two anchors.
- [x] Docs: comments next to the setting, following the existing uWSGI notes.

## Notes for reviewers

- `make k8s-lint` and `make k8s-drift` pass.
- **Not applied live.** I couldn't patch the running cluster from my session. After merge, the deploy updates the `oz-env` / `nrc-env` ConfigMaps. The celery pods only pick that up on restart, and the deploy step restarts only this repo's nine services. So run once:
  `kubectl -n big rollout restart deploy/oz-celery deploy/nrc-celery`
- **Why 2 and not 1:** this matches `UWSGI_THREADS: "2"`, and it keeps one notification delivery from blocking behind a slow task. It cuts roughly 40 processes, about 9 GB RSS (less in practice, because forked workers share pages).
- **Longer term:** CI and the demo share one 15 GB VM. Resource requests on the runner, or moving the runner off the node, would stop one from starving the other.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #183
2026-09-25 11:11:45 +00:00
not 804031eeb8 feat(k8s): publish the portals through the labs Caddy (refs #177) (#179)
CI / k8s (push) Successful in 14s
CI / build (push) Successful in 2m2s
CI / lint (push) Successful in 2m32s
CI / unit (push) Successful in 1m36s
CI / frontend (push) Successful in 3m3s
Deploy to Talos / deploy (push) Successful in 3m39s
CI / mutation (push) Successful in 5m31s
CI / verify-stack (push) Failing after 17m26s
## What & why

Makes the portals reachable on real hostnames through the Caddy that already fronts `*.labs.respellion.tech`, instead of five SSH port-forwards:

| URL | Service |
|---|---|
| `https://big-register.labs.respellion.tech` | openbaar |
| `https://big-mijn.labs.respellion.tech` | self-service |
| `https://big-behandel.labs.respellion.tech` | behandel |
| `https://big-beheer.labs.respellion.tech` | beheer |
| `https://big-auth.labs.respellion.tech` | Keycloak (`/admin` blocked) |

Chain: browser → labs Caddy (TLS) → `openssh-server` container → reverse SSH tunnel → Fedora host → Talos NodePorts. The Caddy routes and the tunnel unit are already on `main` in the Infra repo (`infra/development/`).

This repo's part:
- **Chart:** a `keycloakUrl` value. When set it replaces `host` + Keycloak's NodePort as the pinned issuer (`KC_HOSTNAME`) and the portals' OIDC authority. Both now come from one helper, `big.keycloakUrl`, so they can't drift apart (ADR-0010). Empty = rendered output identical to today.
- **Deploy workflow:** passes the `KEYCLOAK_URL` repo variable as `--set keycloakUrl=…`.
- **Runbook:** new section "Publishing through the labs Caddy".

Refs #177

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation. *(Infra/config change, no test added.)*
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [ ] CI green — all Gitea Actions jobs (or `make ci` green while no runner exists).
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes. *(Compose untouched.)*
- [x] Docs updated if behaviour, contracts, or operations changed.
- [ ] ADR added in `docs/architecture/` if a non-obvious decision was made.
- [ ] Demo note in `docs/demo-script.md` if user-visible.

## Notes for reviewers

- **This takes the option #177 rejects.** #177 proposes an in-cluster Caddy edge (branch `feat/177-public-tls-edge`). This PR uses the existing labs Caddy instead, because it already holds 80/443 and the wildcard certificate. So it only *refs* #177. If we go this way, #177's ADR should record the host-Caddy option instead.
- `make k8s-lint` and `infra/check-docs-nav.py` pass. I rendered the chart with and without `keycloakUrl`: empty gives the same output as before; set, it gives `https://big-auth.labs.respellion.tech` for both the issuer and the authority.
- Once `KEYCLOAK_URL` is set, the `localhost` port-forward workflow (runbook §5) no longer logs in, because the issuer is a single string.
- The portals are public, with no Azure `authorize` in front of them the way `marketing` has one. The test users use `test123`.
- Rollout after merge: install `big-portals-tunnel.service` on the Fedora host, run `docker compose up -d caddy` on the labs server, then set the `KEYCLOAK_URL` variable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #179
2026-09-25 08:16:37 +00:00
not 6cfcc4cf83 ci(deploy): deploy the stack to Talos on merge to main (closes #175) (#176)
CI / k8s (push) Successful in 6s
CI / unit (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / mutation (push) Canceled after 0s
CI / verify-stack (push) Canceled after 0s
CI / build (push) Canceled after 9s
CI / lint (push) Canceled after 24s
Deploy to Talos / deploy (push) Successful in 5m1s
## What & why

The chart has been deployable by hand since #25 and linted in CI since #168. This makes a
merged PR actually ship it to the Talos VM on the lab server.

`.gitea/workflows/deploy.yaml` runs on a push to `main` (a squash-merged PR) and on manual
dispatch:

1. **Tunnel** — neither the Kubernetes API nor the in-cluster registry is publicly reachable,
   so 6443, 30500 and 30141 are forwarded over the same SSH hop into the Fedora host that the
   Gitea-runner pipeline uses (`ssh -p 6667 user@labs.respellion.tech`).
2. **Images** — `make k8s-images K8S_REGISTRY=localhost:30500`, pushed *through* the tunnel.
3. **Deploy** — `make k8s-reseed TALOS_HOST=… K8S_REGISTRY=<vm-ip>:30500`, pulled by the node
   from its own NodePort.
4. **Roll** — `rollout restart` + `rollout status` on the nine repo deployments.
5. **Smoke** — `GET /openbaar/register` through the openbaar portal.

Three decisions worth the review:

- **One registry, two names.** The push target (`localhost:30500`, the tunnel) and the pull
  target (`<vm-ip>:30500`, the node's own NodePort) address the same store. The pull name has
  to be the one in the node's registry-mirror patch, which is what makes plain HTTP acceptable.
- **`k8s-reseed`, not `k8s-up`.** A Job's pod template is immutable, so a chart change to any
  bootstrap Job would otherwise fail the upgrade with `cannot patch … with kind Job`. The Jobs
  are idempotent by design, so re-running them every deploy is safe and removes that whole
  class of failure. Cost: a few minutes per deploy, and `seed-zaaktype` needs egress from the VM.
- **No re-run of the checks.** PR CI is the merge gate, so `main` is green by construction.
  Deploys **queue** (`cancel-in-progress: false`) — a `helm upgrade` killed half-way leaves the
  release in `pending-upgrade` and has to be unwedged by hand.

Settings on the repo (already added): secrets `TALOS_SSH_KEY` and `TALOS_KUBECONFIG`
(base64, and its `server:` must be `https://127.0.0.1:6443` — Talos puts `127.0.0.1` in the
apiserver cert SANs, so TLS still verifies through the tunnel); variables `TALOS_VM_IP`
(default `192.168.122.173`) and `TALOS_HOST` (default `localhost`).

Closes #175

## Definition of Done

- [x] Linked Gitea issue (above).
- [ ] Failing test committed before the implementation — **n/a**: this is a deployment
      workflow with no unit under test. Its check is the run itself: `rollout status` and the
      public-register smoke both have to pass or the job fails. `make k8s-lint` / `make k8s-drift`
      (#168) already gate the chart it deploys.
- [x] Implementation — one workflow file, no production code touched.
- [x] Conventional Commits referencing the issue (`refs #175`).
- [ ] CI green — awaiting the run on this PR.
- [x] `docker compose up` unaffected — no service, image or compose file is touched.
- [x] Docs updated — `docs/runbooks/kubernetes-talos.md` §9 (the tunnel, the two registry
      names, the secrets table, the smoke) and a pointer from `docs/runbooks/ci.md`.
- [x] No ADR needed: no new dependency (kubectl/helm/crane are already prerequisites of the
      `k8s-*` targets), no service boundary moved, no CLAUDE.md §8 rule bent.
- [ ] Demo note — not user-visible.

## Notes for reviewers

- **The first deploy is the real test.** It cannot be dry-run: the tunnel, the secrets and the
  registry only exist on the lab server. Merging is how we find out; `Pods on failure` dumps
  `get pods,jobs` if it doesn't.
- **Known gap — the portals still aren't browsable.** PKCE needs a secure context, so a
  NodePort on an IP can't serve them (runbook §5); they need `make k8s-portals` or an SSH
  forward. Giving the server a hostname + TLS is the follow-up, and is where `TALOS_HOST`
  stops defaulting to `localhost`.
- **Databases are `emptyDir`.** Any change to a database pod's template wipes it; the
  `k8s-reseed` in the deploy re-runs the bootstrap, so the stack recovers, but submitted
  registrations do not. Persistence is runbook §6.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #176
2026-09-18 13:53:58 +00:00
17 changed files with 309 additions and 412 deletions
+121
View File
@@ -0,0 +1,121 @@
name: Deploy to Talos
# A merge to main ships the stack to the Talos cluster on the lab server
# (docs/runbooks/kubernetes-talos.md §9). PR CI is the merge gate, so main is
# green by construction — this workflow only deploys.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# Queue deploys, never cancel one: a helm upgrade killed half-way leaves the
# release in `pending-upgrade` and the next run has to be unwedged by hand.
concurrency:
group: deploy-talos
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
env:
# The Talos VM as seen from the Fedora host (libvirt guest IP), and the
# address a browser uses to reach the cluster. `localhost` is deliberate:
# the portals' PKCE needs a secure context, so they are reached over
# `kubectl port-forward` — runbook §5. Override with repo variables.
TALOS_VM_IP: ${{ vars.TALOS_VM_IP }}
TALOS_HOST: ${{ vars.TALOS_HOST }}
# Set it when the labs Caddy publishes the portals: Keycloak's public https
# origin, e.g. https://big-auth.labs.respellion.tech (runbook, "Publishing
# through the labs Caddy").
KEYCLOAK_URL: ${{ vars.KEYCLOAK_URL }}
# `true` fills in the medewerker OTP step for the public demo (chart value
# demo.otpAutofill). The fixture secret is committed: demo only.
OTP_AUTOFILL: ${{ vars.OTP_AUTOFILL }}
steps:
- uses: https://github.com/actions/checkout@v4
# Pinned static binaries, the same URLs the Talos runbook §0 gives a
# developer and the same helm the `k8s` CI job uses — no action to vet.
- name: Install kubectl, helm and crane
run: |
set -euo pipefail
bin="$HOME/.local/bin"; mkdir -p "$bin"
curl -sSLo "$bin/kubectl" https://dl.k8s.io/release/v1.37.0/bin/linux/amd64/kubectl
curl -sSL https://get.helm.sh/helm-v3.16.4-linux-amd64.tar.gz | tar xz -O linux-amd64/helm > "$bin/helm"
curl -sSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xz -O crane > "$bin/crane"
chmod +x "$bin"/{kubectl,helm,crane}
echo "$bin" >> "$GITHUB_PATH"
# The cluster's API and its registry are only reachable through the Fedora
# host, so forward both to the runner. 30141 is the openbaar portal, for
# the smoke at the end.
- name: Tunnel the Talos API + registry through the Fedora host
env:
SSH_KEY: ${{ secrets.TALOS_SSH_KEY }}
run: |
set -euo pipefail
: "${TALOS_VM_IP:=192.168.122.173}"
umask 077
printf '%s\n' "$SSH_KEY" > ~/.ssh_talos
ssh -i ~/.ssh_talos -o StrictHostKeyChecking=no -o IdentitiesOnly=yes \
-o ExitOnForwardFailure=yes -p 6667 -f -N \
-L 6443:$TALOS_VM_IP:6443 \
-L 30500:$TALOS_VM_IP:30500 \
-L 30141:$TALOS_VM_IP:30141 \
user@labs.respellion.tech
# The kubeconfig's server must be https://127.0.0.1:6443 — Talos puts
# 127.0.0.1 in the apiserver cert SANs, so TLS verification still holds
# through the tunnel.
- name: Write the kubeconfig
env:
KUBECONFIG_B64: ${{ secrets.TALOS_KUBECONFIG }}
run: |
set -euo pipefail
umask 077
base64 -d <<< "$KUBECONFIG_B64" > "$RUNNER_TEMP/kubeconfig"
echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"
kubectl --kubeconfig "$RUNNER_TEMP/kubeconfig" get nodes
# Idempotent; also makes a first deploy onto a bare cluster work. The
# registry's storage is an emptyDir, so a replaced pod loses the images —
# which the push in the next step puts back anyway.
- name: Ensure the in-cluster registry
run: make k8s-registry
# Push through the tunnel (localhost), pull from the node's own NodePort
# (the address in the Talos registry-mirror patch) — same registry, two
# names, so the two `make` calls get different K8S_REGISTRY values.
- name: Build and push the images
run: make k8s-images K8S_REGISTRY=localhost:30500
# k8s-reseed = seed configmaps + helm upgrade + re-run the bootstrap jobs.
# The jobs are idempotent, and deleting them first is what keeps a changed
# Job template from wedging the upgrade (`cannot patch … with kind Job`).
- name: Deploy the chart
run: |
make k8s-reseed \
TALOS_HOST=${TALOS_HOST:-localhost} \
K8S_REGISTRY=${TALOS_VM_IP:-192.168.122.173}:30500 \
K8S_SET="${KEYCLOAK_URL:+--set keycloakUrl=$KEYCLOAK_URL} --set demo.otpAutofill=${OTP_AUTOFILL:-false}"
# `dev` is a mutable tag and helm sees an unchanged pod template, so the
# new images only land on a restart (pullPolicy is already Always).
- name: Roll the services onto the new images
run: |
set -euo pipefail
svcs="acl domain bff event-subscriber projection-api self-service openbaar behandel beheer"
kubectl -n big rollout restart deploy $svcs
kubectl -n big rollout status --timeout=300s deploy $svcs
# Proves portal → Caddy → BFF → projection end to end. An empty register is
# a pass; a 502 or a timeout is not.
- name: Smoke the public register
run: curl -fsS --retry 10 --retry-delay 6 --retry-all-errors http://localhost:30141/openbaar/register
- name: Pods on failure
if: failure()
run: kubectl -n big get pods,jobs || true
-1
View File
@@ -352,7 +352,6 @@ K8S_IMAGES := acl domain bff event-subscriber projection-api self-service open
k8s-lint:
helm lint $(K8S_CHART)
helm template big $(K8S_CHART) -n $(K8S_NS) --set images.registry=registry.invalid:5000 >/dev/null
python3 infra/helm/check-issuer.py
## k8s-drift: fail if compose and the Helm chart describe different stacks
# Compose is CI-canonical (ADR-0033) and the chart is a transcription of it; this
@@ -1,102 +0,0 @@
# ADR-0035: The public TLS edge is a Caddy deployment in the cluster
- **Status:** Accepted
- **Date:** 2026-09-18
- **Deciders:** Respellion engineering
- **Slice:** [#177](https://git.labs.respellion.tech/eho/register-referentie/issues/177)
## Context
The stack deploys to a Talos VM on the lab server (ADR-0033, issue #175). Until now it was
only usable through five SSH port-forwards: the portals' OIDC flow uses PKCE, PKCE needs
`crypto.subtle`, and browsers expose that only in a **secure context** — HTTPS or an origin
on `localhost`. A NodePort on the VM's address is neither, so the deployment was pinned to
`host: localhost` and every viewer had to forward all five browser-facing ports (a portal
without Keycloak on the same `localhost:30180` fails on the discovery document).
That is not a demo anyone can be sent a link to. We want public hostnames with real
certificates — and we want the routing and the certificates to be cluster state, not
host-side configuration that no `helm upgrade` can see.
The public IP is on the Fedora host (`46.224.220.37`); the cluster is a libvirt guest
behind it.
## Decision
**Terminate TLS in the cluster, with a Caddy deployment rendered by the chart
(`templates/edge.yaml`), and give the Fedora host nothing but a layer-4 forward.**
- `public.domain` is the single switch. Empty — the default, and what compose and CI use —
renders nothing: the stack is reached on its NodePorts and `host` pins the OIDC origin
exactly as before. Set it, and the edge appears.
- `public.routes` maps a subdomain to an in-cluster `service:port`. Caddy proxies to the
**ClusterIP** services, so a public deployment does not use the browser-facing NodePorts
at all.
- Caddy obtains and renews certificates itself (ACME HTTP-01). There is no cert-manager.
- The host forwards `:80`/`:443` to two NodePorts with two `firewall-cmd
--add-forward-port` rules. No TLS, no routing, no per-service knowledge there — adding a
portal is a chart change, not a host change.
- `KC_HOSTNAME` and the portals' `config.json` stop being `host` + NodePort. Both now come
from one helper, `big.keycloakUrl`, so the issuer Keycloak pins and the authority the
portals are configured with cannot drift apart (ADR-0010).
### Alternatives considered
- **Caddy on the Fedora host.** Fewest moving parts — but the routing table and the
certificates would live outside the cluster, in a file no deployment touches, and adding
a portal would mean editing a host we deploy to over SSH. Rejected on exactly the ground
this ADR exists to record.
- **Traefik or ingress-nginx, plus cert-manager.** The conventional answer, and the right
one for a cluster with many teams and changing hostnames. Here it buys a controller, a
set of CRDs and Ingress objects to describe five hostnames that never change — and
cert-manager to do what Caddy already does unprompted.
- **A `LoadBalancer` service (MetalLB).** Solves address allocation, which is not the
problem; the node has exactly one address and it still is not the public one.
- **Keep the SSH forwards.** Free, and genuinely fine for one developer. It is not a demo
you can send to someone.
## Consequences
**Positive**
- No new dependency: the four portals already run `caddy:2-alpine` (ADR-0034), whose
ceiling note called this out — *"a real hostname makes TLS a one-line `Caddyfile`
change"*. This is that change.
- Routing is cluster state: `kubectl -n big get cm caddy-edge-config -o yaml` is the whole
truth about what is published, and `helm upgrade` is how it changes.
- The secure context is real, so `TALOS_HOST=localhost` and the five forwards disappear —
and with them the class of failure where a mismatched issuer logs the user out silently.
- Nothing changes for compose, CI or a laptop cluster: with `public.domain` empty the
rendered manifests are byte-identical to before.
**Negative / costs**
- The host forward is irreducible. Two firewalld rules, applied by hand once, with `sudo`
on a machine our pipeline reaches only over SSH. If someone rebuilds that host, the stack
is unreachable until they are re-applied, and nothing in the cluster can tell them so.
- **Certificates need a volume.** On the default `emptyDir` every pod restart asks Let's
Encrypt again, and its duplicate-certificate limit is five per week — a handful of
restarts and the edge serves an untrusted certificate for a week. `persistence.storageClass`
stops being optional for anything public (runbook §6).
- **All five hostnames are published, including `behandel` and `beheer`**, which approve
registrations and administer the register. They are protected by synthetic accounts with
well-known passwords, and by MFA on the medewerker realm (ADR-0031). That is a deliberate
choice for a demonstration environment holding synthetic data only, and it is the reason
this bullet is in the ADR rather than in a comment: if this stack ever holds anything
real, this decision is the first one to revisit.
- One more workload in the chart with no counterpart in compose — compose has no edge
because it has no hostname. The drift check (`make k8s-drift`) renders the defaults, so
it does not see it.
- `auth` is load-bearing: `big.keycloakUrl` builds the issuer from that subdomain, so
renaming the key in `public.routes` without the helper breaks every login. Both carry a
comment saying so.
- ponytail ceiling: one replica, no HSTS, no security headers beyond Caddy's defaults, no
rate limiting, and HTTP-01 rather than DNS-01 (so a wildcard certificate is not
available). Upgrade path in that order; DNS-01 first if the subdomain list ever grows.
## Coupling rules touched (CLAUDE.md §8)
None. §8.3 holds — the browser reaches a portal, the portal reverse-proxies its own BFF
group, and the edge is in front of all of it. The edge terminates TLS and routes by
hostname; it does not know what any service does.
+4
View File
@@ -31,6 +31,10 @@ and CI cannot drift:
> services by **container IP** (the runner can't reach published ports — see
> [gitea-actions-gotchas.md §5/§6](gitea-actions-gotchas.md)).
A second workflow, `.gitea/workflows/deploy.yaml`, deploys the stack to the Talos
cluster on the lab server when a PR is merged to `main` — see
[kubernetes-talos.md §9](kubernetes-talos.md) for its secrets and the SSH tunnel it needs.
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`,
`https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
Actions resolves them from GitHub.
+78 -54
View File
@@ -222,9 +222,6 @@ string, so the port the browser uses has to match the one baked into `config.jso
This is the same mechanism `infra/host-browser.yml` uses for the compose stack (which pins
`localhost:8180`); only the addresses differ.
All of this is what §10 removes: with a public domain the portals have real certificates,
so the browser gets its secure context and no forwarding is involved.
### The admin UIs work straight off the NodePorts
These are server-rendered and need no secure context, so they are reachable at the VM's
@@ -363,67 +360,94 @@ immutable, so `helm upgrade` is rejected with `cannot patch "…" with kind Job`
| Pods `Evicted` / `OOMKilled` | the VM is too small (§0) |
| A Job shows `BackoffLimitExceeded` | read it: `kubectl -n big logs job/<name>` |
## 10. Publishing it on a public domain
## 9. Deploying on merge to main
By default the stack has no hostname: it is reached on NodePorts, and §5's secure-context
problem forces `TALOS_HOST=localhost` plus five SSH forwards. Setting `public.domain` puts a
Caddy deployment in front of it that terminates TLS for real hostnames (ADR-0035), and the
forwards go away.
`.gitea/workflows/deploy.yaml` runs the §3–§4 steps against the **lab server's** Talos VM
every time a PR is squash-merged to `main` (and on demand via *Run workflow*). PR CI is the
merge gate, so the workflow deploys without re-running the checks.
### Once, outside the cluster
**DNS** — five A records to the *host's* public address (the cluster is behind it):
The cluster's API and registry are not exposed publicly, so the job forwards them over the
same SSH hop the Gitea-runner pipeline uses:
```
register.<domain> mijn.<domain> behandel.<domain> beheer.<domain> auth.<domain> → 46.224.220.37
ssh -p 6667 user@labs.respellion.tech -L 6443 -L 30500 -L 30141 → <TALOS_VM_IP>
```
**The host's forward** — the public IP is on the Fedora host, so it has to hand 80/443 to
the node. This is the only host-side configuration, and it is dumb layer 4:
Consequences worth knowing:
```bash
sudo firewall-cmd --permanent --zone=public --add-forward-port=port=80:proto=tcp:toaddr=<TALOS_VM_IP>:toport=32080
sudo firewall-cmd --permanent --zone=public --add-forward-port=port=443:proto=tcp:toaddr=<TALOS_VM_IP>:toport=32443
sudo firewall-cmd --permanent --zone=public --add-masquerade
sudo firewall-cmd --reload
- Images are **pushed** to `localhost:30500` (the tunnel) and **pulled** by the node from
`<TALOS_VM_IP>:30500` (its own NodePort, the address in the Talos registry-mirror patch).
Same registry, two names — hence the two `K8S_REGISTRY` values in the workflow.
- It calls `make k8s-reseed`, not `make k8s-up`: the bootstrap Jobs are idempotent, and
deleting them first is what stops a changed Job template from wedging `helm upgrade` (§7).
- `dev` is a mutable tag, so a `rollout restart` of the nine repo deployments is what
actually puts the new images in the pods.
- Deploys **queue** (`cancel-in-progress: false`): a helm upgrade killed half-way leaves the
release in `pending-upgrade`, which has to be unwedged by hand.
Settings, all on the repository in Gitea:
| Kind | Name | What |
|---|---|---|
| Secret | `TALOS_SSH_KEY` | private key for `user@labs.respellion.tech` (the Fedora host) |
| Secret | `TALOS_KUBECONFIG` | base64 of the kubeconfig, **`server: https://127.0.0.1:6443`** — Talos puts `127.0.0.1` in the apiserver cert SANs, so TLS still verifies through the tunnel |
| Variable | `TALOS_VM_IP` | the VM's libvirt address (default `192.168.122.173`) |
| Variable | `TALOS_HOST` | the browser-facing host baked into Keycloak's issuer (default `localhost`, see §5) |
The last step smokes `GET /openbaar/register` through the openbaar portal, which exercises
portal → Caddy → BFF → projection. An empty register passes; a 502 does not.
Not covered: the portals still need `make k8s-portals` (or an SSH forward) to be usable in a
browser, because PKCE needs a secure context (§5). Giving the server a hostname + TLS is the
upgrade path.
## Publishing through the labs Caddy
The portals can be reached on real hostnames through the Caddy that already fronts
`*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain:
```
browser → Caddy (labs server, TLS) → openssh-server:3014x/30180
→ reverse SSH tunnel → Fedora host → <TALOS_VM_IP>:3014x/30180 (NodePorts)
```
`--add-masquerade` is what makes the return path work: without it the node answers the
client's address directly and the reply never goes back through the host.
**A StorageClass.** Caddy's certificates live in `/data`, which is an `emptyDir` unless
`persistence.storageClass` is set (§6). Let's Encrypt allows five duplicate certificates per
week, so on an `emptyDir` a handful of pod restarts leaves the edge serving an untrusted
certificate until the limit resets. Install local-path first (§6).
### Deploy
```bash
make k8s-up TALOS_HOST=<domain-facing name> K8S_REGISTRY=<TALOS_VM_IP>:30500 \
K8S_SET='--set public.domain=<domain> --set public.email=<ops address> --set persistence.storageClass=local-path'
```
`public.domain` is the only switch: with it empty nothing in `templates/edge.yaml` renders
and the stack behaves exactly as §4 describes. With it set, `KC_HOSTNAME` and the portals'
`config.json` both become `https://auth.<domain>` — one helper builds both, so the issuer
and the authority cannot drift (ADR-0010).
Watch the first certificate being issued:
```bash
kubectl -n big logs deploy/caddy-edge -f # "certificate obtained successfully"
curl -sSI https://register.<domain>/openbaar/register | head -1
```
### When it doesn't work
| Symptom | Cause |
| URL | NodePort |
|---|---|
| ACME fails with `connection refused` or a timeout on the HTTP-01 challenge | the host's 80 → 32080 forward is missing, or `--add-masquerade` is |
| ACME fails with `NXDOMAIN` / `no such host` | the A record isn't there yet. Caddy retries with backoff; fix DNS and it recovers |
| An untrusted certificate after several restarts | the Let's Encrypt duplicate limit, from certificates on an `emptyDir` — see above |
| The portal loads but login bounces back logged out | `public.domain` changed without the portals rolling. The chart hashes the issuer into their pod template, so `helm upgrade` should do it — check `kubectl -n big describe deploy/self-service` |
| `404` from the edge on a name that should work | the name isn't in `public.routes`; Caddy answers 404 for a Host it has no site block for |
| `https://big-register.labs.respellion.tech` | 30141 openbaar |
| `https://big-mijn.labs.respellion.tech` | 30140 self-service |
| `https://big-behandel.labs.respellion.tech` | 30142 behandel |
| `https://big-beheer.labs.respellion.tech` | 30143 beheer |
| `https://big-auth.labs.respellion.tech` | 30180 Keycloak (`/admin` blocked) |
HTTPS makes the portals a secure context, so PKCE works without port-forwards — but
Keycloak's issuer must be the public origin. Deploy with it:
```bash
make k8s-up TALOS_HOST=localhost K8S_REGISTRY=<TALOS_HOST>:30500 \
K8S_SET="--set keycloakUrl=https://big-auth.labs.respellion.tech"
```
For deploy-on-merge, set the repository variable `KEYCLOAK_URL` to the same value.
With it set, the `localhost` port-forwards (§5) no longer log in: the issuer is one string.
Staff logins still hit the enforced OTP step. For a demo, set the repository variable
`OTP_AUTOFILL=true` (chart value `demo.otpAutofill`): Keycloak then uses the `big-demo`
theme, which fills in and submits the code from the fixture secret, so the step is visible
but needs no authenticator. Keycloak restarts when the value flips. Demo only — the secret
is committed.
The theme lives in `infra/keycloak/themes/big-demo/` and is seeded as the `rr-kc-theme`
ConfigMap by `infra/helm/seed-configmaps.sh` on every deploy. Keycloak runs `start-dev`,
which doesn't cache themes, so an edit shows up about a minute after the ConfigMap changes.
A *new* theme file also needs a key in the seed script and a path in the keycloak `files`
in `values.yaml`.
One-time setup:
1. Fedora host: install `infra/development/big-portals-tunnel.service` from the Infra repo
(instructions in the file).
2. Labs server: deploy the Infra `Caddyfile` + `compose.yml` (Caddy joins the
`openssh_default` network to reach the tunnel ends).
## What is not ported
+5
View File
@@ -57,6 +57,9 @@ services:
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
# Same lever for oz-celery: unset, the worker forks one process per CPU (22 on the lab node,
# ~225 MB each), which OOM-killed the shared runner mid-verify-stack. Only celery reads it.
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -144,6 +147,8 @@ services:
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
# Two celery workers, not one per CPU — see the oz-env note above.
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
+13 -11
View File
@@ -94,6 +94,10 @@ volumes:
{{- with .defaultMode }}
defaultMode: {{ . }}
{{- end }}
{{- with .items }}
items:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with $w.data }}
- name: data
@@ -125,28 +129,26 @@ volumes:
{{/*
Env list from a map. Every value is run through `tpl`, so values.yaml can name
cluster-internal hosts ({{ .Release.Namespace }}) and the node address
({{ .Values.host }}) without the chart hard-coding either.
({{ .Values.host }}) without the chart hard-coding either. A value that renders
empty is left out, which is how a setting is made conditional on a chart value.
*/}}
{{- define "big.env" -}}
{{- $root := index . 0 -}}
{{- range $k, $v := index . 1 }}
{{- $val := tpl (toString $v) $root }}
{{- if $val }}
- name: {{ $k }}
value: {{ tpl (toString $v) $root | quote }}
value: {{ $val | quote }}
{{- end }}
{{- 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.
The origin a browser reaches Keycloak on: the issuer Keycloak pins and the
authority the portals use, from one place so they cannot drift (ADR-0010).
*/}}
{{- define "big.keycloakUrl" -}}
{{- if .Values.public.domain -}}
https://auth.{{ .Values.public.domain }}
{{- else -}}
http://{{ .Values.host }}:{{ index .Values.nodePorts "keycloak" }}
{{- end -}}
{{- .Values.keycloakUrl | default (printf "http://%s:%v" .Values.host (index .Values.nodePorts "keycloak")) -}}
{{- end -}}
{{- define "big.labels" -}}
@@ -1,136 +0,0 @@
{{- /*
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 }}
+32 -27
View File
@@ -25,6 +25,17 @@
# string, so browser tokens and the BFF's discovered issuer agree.
host: 192.168.122.100
# Set when a TLS proxy outside the cluster publishes Keycloak: the full origin, no
# trailing slash. It replaces `host` + Keycloak's NodePort as the issuer and the
# portals' authority (runbook, "Publishing through the labs Caddy").
keycloakUrl: ""
demo:
# Fill in and submit the medewerker OTP step from the fixture secret, so a public
# demo shows MFA enforced without an authenticator: makes the big-demo theme
# (infra/keycloak/themes/big-demo) Keycloak's default. Demo only: the secret is committed.
otpAutofill: false
# Set when pulling from a private registry (e.g. the Gitea Container Registry).
imagePullSecrets: []
@@ -49,32 +60,6 @@ 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:
@@ -97,6 +82,7 @@ envGroups:
oz:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: oz-db
@@ -119,6 +105,7 @@ envGroups:
nrc:
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
CELERY_WORKER_CONCURRENCY: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: dev-only-not-for-production
DB_HOST: nrc-db
@@ -296,11 +283,29 @@ workloads:
# this issuer back, which is what browser tokens carry (infra/host-browser.yml).
KC_HOSTNAME: '{{ include "big.keycloakUrl" . }}'
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
# Only rendered with demo.otpAutofill (big.env skips empty values); off, Keycloak
# keeps its stock theme and the mounted big-demo theme is unused.
KC_SPI_THEME_DEFAULT: '{{ if .Values.demo.otpAutofill }}big-demo{{ end }}'
# Behind a TLS proxy (keycloakUrl) the dynamic backchannel URLs — token,
# userinfo, certs — take their scheme from the request, which reaches Keycloak
# as plain http; trusting X-Forwarded-Proto keeps them https so the browser
# doesn't block them as mixed content. In-cluster calls send no such header.
KC_PROXY_HEADERS: xforwarded
ports: [{ name: http, port: 8080 }]
# TCP, not /health/ready on the management port: nothing here gates on realm
# import, and a wrong health path would leave the Service with no endpoints.
probe: { tcpSocket: { port: 8080 }, initialDelaySeconds: 15 }
files: [{ configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }]
files:
- { configMap: rr-kc-realms, mountPath: /opt/keycloak/data/import }
# infra/keycloak/themes/big-demo, seeded by infra/helm/seed-configmaps.sh.
- configMap: rr-kc-theme
mountPath: /opt/keycloak/themes/big-demo
items:
- { key: login.properties, path: login/theme.properties }
- { key: otp-autofill.js, path: login/resources/js/otp-autofill.js }
- { key: account.properties, path: account/theme.properties }
- { key: admin.properties, path: admin/theme.properties }
- { key: email.properties, path: email/theme.properties }
# ── Flowable (S-03) ─────────────────────────────────────────────────────────
flowable-db:
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env python3
"""Fail when the pinned issuer and the portals' OIDC authority stop agreeing.
Keycloak pins one issuer (`KC_HOSTNAME`) and each portal is configured with one
authority (`config.json`). A browser token carries the first; the BFF validates
against what it discovers from the second (ADR-0010). When the two drift the
symptom is three services away — a login that bounces back logged out, or a 401
from the BFF — so the chart builds both from one helper and this asserts it.
It also pins the two halves of the public edge (ADR-0035): that setting
`public.domain` actually publishes the hostnames, and that leaving it empty
renders no edge at all, which is what compose, CI and a laptop cluster rely on.
Run it with `make k8s-lint`. No cluster needed.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
CHART = Path(__file__).resolve().parent / "big-reference"
DOMAIN = "example.test"
def render(*sets: str) -> str:
argv = ["helm", "template", "big", str(CHART), "-n", "big"]
for s in sets:
argv += ["--set", s]
proc = subprocess.run(argv, capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"helm template failed:\n{proc.stderr}")
return proc.stdout
def issuer(out: str) -> str:
"""The value of KC_HOSTNAME in the rendered manifests."""
m = re.search(r"name: KC_HOSTNAME\n\s+value: \"(\S+)\"", out)
return m[1] if m else ""
def authorities(out: str) -> set[str]:
"""Every portal's OIDC authority, with the realm path stripped."""
found = set()
for line in re.findall(r'\{ "authority": .* \}', out):
url = json.loads(line)["authority"]
found.add(url.rsplit("/realms/", 1)[0])
return found
def main() -> int:
problems = []
public = render(f"public.domain={DOMAIN}")
if issuer(public) != f"https://auth.{DOMAIN}":
problems.append(f" with public.domain set, KC_HOSTNAME is {issuer(public)!r}, not https://auth.{DOMAIN}")
if authorities(public) != {f"https://auth.{DOMAIN}"}:
problems.append(f" with public.domain set, the portals point at {sorted(authorities(public))}")
for host in (f"register.{DOMAIN}", f"mijn.{DOMAIN}", f"behandel.{DOMAIN}", f"beheer.{DOMAIN}", f"auth.{DOMAIN}"):
if host not in public:
problems.append(f" {host} is not published by the edge")
private = render()
if authorities(private) != {issuer(private)}:
problems.append(f" by default the portals point at {sorted(authorities(private))}, the issuer is {issuer(private)!r}")
if "caddy-edge" in private:
problems.append(" the edge renders with no public.domain — compose, CI and a laptop cluster expect nothing")
if problems:
print("the chart's OIDC origin is inconsistent:\n" + "\n".join(problems))
print("\nBoth halves come from the `big.keycloakUrl` helper — change it, not one caller.")
return 1
print(f"issuer + portal authority agree, with and without a public domain")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -30,6 +30,15 @@ seed() { # name <kubectl --from-file args...>
seed rr-oz-config --from-file="$repo/infra/openzaak/setup_configuration/"
seed rr-nrc-config --from-file="$repo/infra/opennotificaties/setup_configuration/"
seed rr-kc-realms --from-file="$repo/infra/keycloak/realms/"
# The big-demo login theme (demo.otpAutofill). ConfigMap keys are flat, so each
# file gets a key here and its path back in the keycloak `files` in values.yaml.
theme="$repo/infra/keycloak/themes/big-demo"
seed rr-kc-theme \
--from-file=login.properties="$theme/login/theme.properties" \
--from-file=otp-autofill.js="$theme/login/resources/js/otp-autofill.js" \
--from-file=account.properties="$theme/account/theme.properties" \
--from-file=admin.properties="$theme/admin/theme.properties" \
--from-file=email.properties="$theme/email/theme.properties"
seed rr-objecttypen-config --from-file="$repo/infra/objecttypen/setup_configuration/"
seed rr-objecten-config --from-file="$repo/infra/objecten/setup_configuration/"
# register.py + the RegisterRecord JSON schema (the __pycache__ dir is skipped:
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the account page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v3
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the admin page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak.v2
@@ -0,0 +1,4 @@
# The chart makes big-demo the default for every theme type, and Keycloak does not
# fall back for a type a theme lacks (the email page then fails), so each type is
# declared as a plain child of Keycloak 26's own default.
parent=keycloak
@@ -0,0 +1,24 @@
// RFC 6238 with Keycloak's default policy (HmacSHA1, 6 digits, 30 s) over the
// raw bytes of the medewerker fixture secret — same as tests/e2e/keycloak-login.ts.
document.addEventListener('DOMContentLoaded', async () => {
const input = document.querySelector('input[name="otp"]');
if (!input || !input.form) return;
const key = await crypto.subtle.importKey('raw',
new TextEncoder().encode('BIGMEDEWERKEROTPSEED'), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
// A code is single-use, so a second login in the same window spends the next
// counter (Keycloak's look-ahead accepts it). Past that, fill but don't submit,
// so a rejected code can't turn into a submit loop.
const now = Math.floor(Date.now() / 30000);
let last = -1;
try { last = Number(sessionStorage.getItem('big-otp-counter')) || -1; } catch {}
const counter = Math.max(now, last + 1);
const msg = new DataView(new ArrayBuffer(8));
msg.setBigUint64(0, BigInt(counter));
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg.buffer));
const o = mac[19] & 0x0f;
const n = ((mac[o] & 0x7f) << 24 | mac[o + 1] << 16 | mac[o + 2] << 8 | mac[o + 3]) % 1e6;
input.value = String(n).padStart(6, '0');
if (counter > now + 1) return;
try { sessionStorage.setItem('big-otp-counter', String(counter)); } catch {}
input.form.requestSubmit();
});
@@ -0,0 +1,11 @@
# Demo login theme for the public Talos deployment: keycloak.v2 plus a script that
# fills in and submits the medewerker OTP step from the committed fixture secret
# (docs/runbooks/keycloak.md). Only used when the chart's demo.otpAutofill is on —
# it then becomes Keycloak's default theme. Never enable it anywhere real.
#
# Add styles, messages or template overrides here as in any Keycloak theme
# (https://www.keycloak.org/ui-customization/themes); new files must also be
# listed in infra/helm/seed-configmaps.sh and the keycloak `files` in values.yaml.
parent=keycloak.v2
import=common/keycloak
scripts=js/otp-autofill.js
-1
View File
@@ -56,7 +56,6 @@ nav:
- "ADR-0032: Werkbak live refresh": architecture/adr-0032-werkbak-live-refresh.md
- "ADR-0033: Kubernetes via one Helm chart": architecture/adr-0033-kubernetes-via-one-helm-chart.md
- "ADR-0034: Caddy serves the portals": architecture/adr-0034-caddy-serves-the-portals.md
- "ADR-0035: Public TLS edge in the cluster": architecture/adr-0035-public-tls-edge-in-cluster.md
- FDS-architectuur:
- Overzicht: architecture/fds/README.md
- Componentview (L3): architecture/fds/c4-component-view.md