From 1489f687965a8a6c77504fec8aa1f318ae458d56 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 28 Sep 2026 12:30:11 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(arch):=20ADR-0035=20=E2=80=94=20publis?= =?UTF-8?q?h=20the=20stack=20through=20the=20existing=20labs=20Caddy=20(cl?= =?UTF-8?q?oses=20#177)=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why ADR-0035 records the decision issue #177 asked for, which went the other way from its proposal. The stack is published through the **existing labs Caddy** over a reverse SSH tunnel, not through an in-cluster Caddy edge. The deciding facts: the Talos hypervisor sits behind office NAT with no inbound path, and the labs Caddy already holds 80/443 and the `*.labs.respellion.tech` wildcard certificate. The ADR covers the chain (Caddy → `openssh-server` → tunnel → NodePorts), `keycloakUrl` / `big.keycloakUrl`, `KC_PROXY_HEADERS`, the optional demo OTP autofill, the alternatives (including the closed PR #178), and the costs: routing outside the cluster, two SSH hops, a single issuer string, public demo portals, and 401s after a Keycloak restart. - `docs/architecture/adr-0035-public-access-through-the-labs-caddy.md` (new) - `mkdocs.yml`: nav entry (`check-docs-nav.py` passes) - `docs/runbooks/kubernetes-talos.md`: links the ADR from "Publishing through the labs Caddy" Closes #177 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Conventional Commit referencing the issue. - [ ] CI green - [x] ADR added in `docs/architecture/`. ## Notes for reviewers - The number 0035 was used in the unmerged #178 for the in-cluster ADR. That ADR never reached `main`, so the number is free there. - Implementation PRs: #179, #180, #181. Related CI fixes: #183, #184. 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/185 --- ...35-public-access-through-the-labs-caddy.md | 108 ++++++++++++++++++ docs/runbooks/kubernetes-talos.md | 2 + mkdocs.yml | 1 + 3 files changed, 111 insertions(+) create mode 100644 docs/architecture/adr-0035-public-access-through-the-labs-caddy.md diff --git a/docs/architecture/adr-0035-public-access-through-the-labs-caddy.md b/docs/architecture/adr-0035-public-access-through-the-labs-caddy.md new file mode 100644 index 0000000..ecf44aa --- /dev/null +++ b/docs/architecture/adr-0035-public-access-through-the-labs-caddy.md @@ -0,0 +1,108 @@ +# ADR-0035: The deployed stack is published through the existing labs Caddy + +- **Status:** Accepted +- **Date:** 2026-09-25 +- **Deciders:** Respellion engineering +- **Slice:** [#177](https://git.labs.respellion.tech/eho/register-referentie/issues/177) — + that issue proposed the opposite (an in-cluster Caddy edge); this ADR records why the + host-side option won. Implemented in #179, #180 and #181. + +## Context + +The stack deploys to a single-node Talos VM (ADR-0033, #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**, meaning HTTPS or a +`localhost` origin. A NodePort on the VM's address is neither. We want a URL a demo +audience can simply open. + +Three facts about where things run shape the answer: + +- The Talos VM is a libvirt guest on a **Fedora hypervisor in the office**, behind NAT + with no public address. The only way in from outside is an existing reverse SSH tunnel + (`autossh-reverse-tunnel.service`) into an `openssh-server` container on the labs + server. +- The **labs server** (public IP) already runs Caddy for `*.labs.respellion.tech`, with + the wildcard certificate (DNS-01 via Cloudflare) and ports 80/443. Every other labs + service is published there (repo `Infra`, `infra/development/`). +- #177 proposed a Caddy **inside the cluster**, fed by a layer-4 forward on the host, so + that routing and certificates would be cluster state. That assumes the public IP is on + the hypervisor. It isn't: the hypervisor has no inbound path, and 80/443 on the labs + server are already taken by the labs Caddy. + +## Decision + +**Publish the portals and Keycloak through the existing labs Caddy. Carry the traffic to +the cluster over a second reverse SSH tunnel from the hypervisor.** + +``` +browser ─https─▶ labs Caddy ─▶ openssh-server:3014x/30180 + ─reverse SSH tunnel─▶ Fedora hypervisor ─▶ Talos NodePorts +``` + +- **Hostnames** under the existing wildcard: `big-register` (openbaar), `big-mijn` + (self-service), `big-behandel`, `big-beheer`, and `big-auth` (Keycloak, with `/admin*` + answered 404). +- **Tunnel:** `big-portals-tunnel.service` on the hypervisor (repo `Infra`) + reverse-forwards the five browser-facing NodePorts into `openssh-server`. It is + separate from the access tunnel on `:6667`, so a failed forward can't cut SSH access. + Caddy joins the `openssh_default` network to reach the tunnel ends. +- **Keycloak's issuer** is the public origin. The chart value `keycloakUrl` replaces + `host` + NodePort in one helper, `big.keycloakUrl`, which feeds both `KC_HOSTNAME` and + the portals' `config.json` authority, so the two cannot drift (ADR-0010). The deploy + workflow sets it from the `KEYCLOAK_URL` repository variable. +- **`KC_PROXY_HEADERS=xforwarded`:** `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` builds the token, + userinfo and certs URLs from the request. That request reaches Keycloak as plain HTTP, + so the URLs came out `http://` and browsers blocked them as mixed content. Trusting + Caddy's `X-Forwarded-Proto` keeps them HTTPS. In-cluster calls send no such header and + still use `keycloak:8080`. +- **Demo MFA (optional):** `demo.otpAutofill` (`OTP_AUTOFILL`) makes the `big-demo` theme + (`infra/keycloak/themes/big-demo`) Keycloak's default. Its script fills in and submits + the medewerker OTP from the fixture secret (ADR-0031), so the step is visibly enforced + without an authenticator. It is off by default. + +### Alternatives considered + +- **In-cluster Caddy edge (#177, PR #178).** It would keep routes and certificates in + cluster state. But it needs a public inbound path to the hypervisor that doesn't exist, + plus a second certificate authority beside the labs Caddy, which already holds the + wildcard. Closed unmerged. +- **Port-forward on the office router to the hypervisor.** This opens the office network + itself to the internet. Rejected. +- **Move the cluster to a host with a public IP.** It would remove the tunnel, but it's a + bigger change than publishing one demo. It remains the natural step if the stack + outgrows a lab VM. +- **Keep the SSH port-forwards.** Fine for one developer, but not something you can send + to someone. + +## Consequences + +**Positive** + +- Real hostnames and HTTPS, so PKCE works in any browser with no client-side setup. +- No new certificate handling: the labs Caddy's wildcard covers the new hosts. +- The chart stays edge-agnostic. With `keycloakUrl` empty it renders exactly as before, + so compose, CI and the `localhost` workflow are untouched. + +**Negative / costs** + +- **Routing lives outside the cluster**, in the Infra repo's Caddyfile. That is exactly + what #177 wanted to avoid. Adding a portal means changing three places: a NodePort in + the chart, a forward in the tunnel unit, and a host in the Caddyfile. +- **Two SSH hops in the data path.** If the hypervisor or the tunnel is down, the + portals return 502 even though the cluster is healthy. +- **One issuer string.** With `keycloakUrl` set, the `localhost` port-forward workflow + (runbook §5) can no longer log in. +- **Keycloak trusts `X-Forwarded-*`** from anything that reaches it. Today that is only + in-cluster callers and the tunnel. `KC_PROXY_TRUSTED_ADDRESSES` can narrow it if the + NodePort is ever exposed more widely. +- **The portals are public.** Anyone with the link can log in with the committed test + credentials, and with `OTP_AUTOFILL` on, no second factor stands in the way. That is + acceptable for synthetic data. Put the labs Caddy's Azure `authorize` in front of the + `big-*` hosts if the audience must be restricted. + +**Follow-up** + +- Runbook: `docs/runbooks/kubernetes-talos.md`, "Publishing through the labs Caddy". +- Dev-mode Keycloak generates new signing keys on every restart, and the BFF re-fetches + them at most every 5 minutes, so expect a few minutes of 401s after a Keycloak restart. + Persisting Keycloak's database (runbook §6) would remove that. diff --git a/docs/runbooks/kubernetes-talos.md b/docs/runbooks/kubernetes-talos.md index d35a8f4..72091ec 100644 --- a/docs/runbooks/kubernetes-talos.md +++ b/docs/runbooks/kubernetes-talos.md @@ -403,6 +403,8 @@ upgrade path. ## Publishing through the labs Caddy +Why this route and not an in-cluster edge: [ADR-0035](../architecture/adr-0035-public-access-through-the-labs-caddy.md). + The portals can be reached on real hostnames through the Caddy that already fronts `*.labs.respellion.tech` (repo `Infra`, `infra/development/`). The chain: diff --git a/mkdocs.yml b/mkdocs.yml index 2929173..7a3f010 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ 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 access through the labs Caddy": architecture/adr-0035-public-access-through-the-labs-caddy.md - FDS-architectuur: - Overzicht: architecture/fds/README.md - Componentview (L3): architecture/fds/c4-component-view.md From 733ba711734780e1edd93678c376e88defb35588 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 28 Sep 2026 12:56:10 +0000 Subject: [PATCH 2/2] fix(acl): keep the integration tests out of Stryker's solution (closes #174) (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #174 Took fix option 3, cut down: removed `Acl.IntegrationTests` from `services/acl/Acl.slnx`. Only Stryker reads that file. `make build`, `make lint` and `make unit` use the root `register-referentie.slnx`, and `Dockerfile.integration` targets the csproj directly, so nothing else changes. A comment in the slnx and a note in `docs/runbooks/ci.md` explain why the project is left out. **Verified locally** (`cd services/acl && dotnet stryker`): - `Number of tests found: 86` (was 94); the `8 tests are failing` warning is gone. - Final score **90.45 %**, the same as before. Tests that fail their initial run were never used to kill mutants, so the number was not depressed, only unverified. Re-baselined from this clean run: `break: 90` stays (§5, never lower). No test-first commit: this changes build config only. The check is the Stryker initial-run log above. 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/188 --- docs/runbooks/ci.md | 3 +++ services/acl/Acl.slnx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 44ba84f..26ded11 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -60,6 +60,9 @@ make mutation # dotnet tool restore + dotnet stryker on the ACL Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json). It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped. +`Acl.slnx` leaves out `Acl.IntegrationTests`: it needs a live OpenZaak, and Stryker +runs every test project in the solution, so keeping it in makes 8 tests fail in the +initial run (#174). **Baseline (the ratchet):** the ACL is the first service with branching logic, so it sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%** diff --git a/services/acl/Acl.slnx b/services/acl/Acl.slnx index aa02b3b..20c3c10 100644 --- a/services/acl/Acl.slnx +++ b/services/acl/Acl.slnx @@ -1,7 +1,9 @@ + -