diff --git a/docs/informed-decision-origin.md b/docs/informed-decision-origin.md index 4d6a07b..018d682 100644 --- a/docs/informed-decision-origin.md +++ b/docs/informed-decision-origin.md @@ -38,6 +38,7 @@ placeholder first and the real surface later. | --- | --- | | `manifests/informed-decision-origin.yaml` | Namespace, placeholder nginx ConfigMap/Deployment/Service on `informed-decision:80` | | `manifests/informed-decision-ingress.yaml` | Traefik Ingress + `letsencrypt-prod` certificate for `decisions.coulomb.social` | +| `manifests/informed-decision-http-redirect.yaml` | `redirectScheme` Middleware + `web`-entrypoint Ingress, HTTP → HTTPS | The placeholder is `nginxinc/nginx-unprivileged`, read-only root filesystem, non-root, `noindex`. When the real surface lands it takes over the same Service @@ -78,6 +79,7 @@ Applied with operator approval. Evidence: | Validity | `2026-09-10` → `2026-12-09` (cert-manager renews) | | `GET https://decisions.coulomb.social/` | `HTTP/2 200`, chain verify `0` | | `GET https://decisions.coulomb.social/auth/callback` | `200` — the exact redirect URI resolves | +| `GET http://decisions.coulomb.social/` | `301`/`308` → HTTPS (added 2026-09-10) | `/auth/callback` currently returns the placeholder page via the SPA `try_files` fallback. That is the correct behaviour for now: the origin answers, which is @@ -93,6 +95,7 @@ to `key-cape` and close `KEY-WP-0013-T02`.** export KUBECONFIG=$HOME/.kube/config-hosteurope kubectl apply -f manifests/informed-decision-origin.yaml kubectl apply -f manifests/informed-decision-ingress.yaml +kubectl apply -f manifests/informed-decision-http-redirect.yaml kubectl -n informed-decision get pods,svc,ingress kubectl -n informed-decision get certificate informed-decision-tls -w curl -sSI https://decisions.coulomb.social/ | head -1 @@ -109,8 +112,51 @@ mismatch. ## HTTP → HTTPS -The Ingress is `websecure`-only, following the `forgejo` and `coulomb-social` -pattern, so port 80 stays free for cert-manager HTTP-01 solvers. Plain -`http://decisions.coulomb.social/` will not redirect. `reuse-surface` adds a -separate `-http-redirect` Ingress for this; add one here if a bare-host redirect -is wanted. It is not required for the OIDC flow, which is always `https`. +`manifests/informed-decision-http-redirect.yaml` adds a `redirectScheme` +Middleware plus a `web`-entrypoint Ingress, following the `reuse-surface` +pattern. Applied 2026-09-10. + +`http://decisions.coulomb.social/` now returns `308` to HEAD and `301` to GET. +That split is not a defect in this configuration — `reuse.coulomb.social`, which +uses the identical Middleware, behaves the same way. It is Traefik's own +behaviour on this cluster. + +The main Ingress stays `websecure`-only; this is a second router on port 80 +rather than a change to the first. + +### Router priority is load-bearing here + +cert-manager solves HTTP-01 by creating a temporary solver Ingress on **this +same host and this same `web` entrypoint**, serving +`/.well-known/acme-challenge/`. A catch-all `/` redirect on that +entrypoint competes with it directly. + +Traefik would normally settle this by rule length — the solver's `Path` rule is +much longer than `PathPrefix(/)` — but `reuse-surface`'s 2026-07-07 report is +precisely a case of a specific rule losing to a catch-all when precedence was +left implicit. There the symptom was a 404 someone noticed within the day. Here +it would be a **silently failed renewal about 60 days out**, surfacing as an +expired certificate on the origin backing an OIDC redirect URI — the kind of +failure that presents as a broken login rather than as an expired cert. + +So the redirect router pins `traefik.ingress.kubernetes.io/router.priority: "1"`. +Any solver router, using the default computed priority, outbids it. + +**Verified by probe, twice, on 2026-09-10.** A throwaway Ingress mimicking the +solver shape (`web` entrypoint, `Exact` acme-challenge path, default priority) +was applied and the challenge path was polled: + +| Condition | `/.well-known/acme-challenge/` | `/` | +| --- | --- | --- | +| No solver present | `301` (redirect catches it — correct) | `301` | +| Solver present | `200` (**solver wins**) | `301` | +| Solver removed | `301` | `301` | + +Traefik takes 30–60s to converge on Ingress changes, so the flip is not +instant; poll rather than reading a single response. The probe was deleted after +each run and is not in the repo — recreate it from this table if the redirect +or the priority annotation is ever changed. + +The next real renewal is due around 2026-11-09. Confirm the certificate's +`notAfter` advances then; that is the only test that exercises the real solver +rather than a probe of its shape. diff --git a/manifests/informed-decision-http-redirect.yaml b/manifests/informed-decision-http-redirect.yaml new file mode 100644 index 0000000..02734a2 --- /dev/null +++ b/manifests/informed-decision-http-redirect.yaml @@ -0,0 +1,62 @@ +# HTTP -> HTTPS redirect for decisions.coulomb.social. +# +# manifests/informed-decision-ingress.yaml is websecure-only (forgejo pattern), +# which leaves port 80 unrouted for this host: plain http:// returned Traefik's +# default 404 rather than redirecting. This adds the redirect, following the +# reuse-surface pattern (reuse/reuse-surface-http-redirect + a redirectScheme +# Middleware). +# +# ROUTER PRIORITY IS LOAD-BEARING. cert-manager solves HTTP-01 by creating a +# temporary solver Ingress on this same host and this same `web` entrypoint, +# serving /.well-known/acme-challenge/. A catch-all `/` redirect on the +# same entrypoint competes with it. Traefik would normally resolve that by rule +# length -- the solver's Path rule is far longer than PathPrefix(`/`) -- but +# reuse-surface reported on 2026-07-07 that relying on implicit precedence is +# exactly how a specific rule ends up swallowed by a catch-all, and there the +# symptom was a 404 someone noticed. Here the symptom would be a silently failed +# renewal roughly 60 days out, i.e. an expired certificate on the origin backing +# an OIDC redirect URI. +# +# So priority is pinned explicitly and low. Traefik gives the highest-priority +# matching router the request; any cert-manager solver (which uses the default +# computed priority, an order of magnitude larger) outbids this one. Verified by +# probe on 2026-09-10 -- see docs/informed-decision-origin.md. +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: informed-decision-redirect-https + namespace: informed-decision + labels: + app.kubernetes.io/name: informed-decision + app.kubernetes.io/component: redirect +spec: + redirectScheme: + scheme: https + permanent: true +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: informed-decision-http-redirect + namespace: informed-decision + labels: + app.kubernetes.io/name: informed-decision + app.kubernetes.io/component: redirect + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: informed-decision-informed-decision-redirect-https@kubernetescrd + # Deliberately low: ACME solver routers must win on this entrypoint. + traefik.ingress.kubernetes.io/router.priority: "1" +spec: + ingressClassName: traefik + rules: + - host: decisions.coulomb.social + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: informed-decision + port: + number: 80