diff --git a/ci/httproute/stalwart.yaml b/ci/httproute/stalwart.yaml new file mode 100644 index 0000000..4ee171f --- /dev/null +++ b/ci/httproute/stalwart.yaml @@ -0,0 +1,61 @@ +# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST. +# +# Its one job is to put this chart into Gateway mode, because the convention +# ships with httpRoute.enabled: false and the test would otherwise be green +# over nothing. A chart that has an overlay here and still renders no +# HTTPRoute FAILS the test - that is the whole point of the file existing. +# +# An overlay and not a change to ci/stalwart/test-values.yaml: the install-test +# runs exactly ONE configuration per chart, and this chart's fixture brings up +# the node groups against a throwaway Postgres. Trading its Ingress for a CRD +# schema check that cyberchef's fixture already performs against the real +# Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the +# route is held here, at render level, which is where its silent failure modes +# live. +# +# ingress.enabled: false is not optional here - ingress.enabled is this chart's +# HTTP routing switch and defaults to TRUE, so without this line the chart +# aborts its own render with both modes on. +# +# WHAT THIS OVERLAY COVERS: +# +# - ingress.domain comes from the fixture, so the route renders with its +# hostname from that single source of truth. httpRoute.hostnames stays +# empty on purpose: the fallback is the path every installation will +# actually use. +# - ingress.nodeGroup comes from the fixture too, so the route is checked to +# point at THAT GROUP'S http Service and no other. This chart is the one in +# group 3 where the backend is group-scoped, and the ONE-route-not-one- +# per-group property is exactly what would be easy to get wrong on a copy. +# - the backendRef is checked against the per-group http Service of the same +# render, by PORT NUMBER (ports.http.containerPort) rather than by the name +# "http" the Ingress uses, because backendRefs takes a PortNumber. Note +# that this chart's NodePort Services publish a DIFFERENT number +# (ports..nodePort) than the container binds, so naming the wrong one +# here would be a plausible mistake - this overlay is what catches it. +# +# No URLRewrite filter is rendered by this chart in either mode, so the #118 +# prefix/redirect assurance has no precondition to meet here. +# +# NOT covered here, and none of it can be: +# +# - that the hostname answers. No Gateway controller runs in CI and no route +# reaches "Accepted: True". +# - THE CORS CONFIGURATION, which is the thing this chart actually loses in +# this mode and the one that breaks the bulwark webmail. It lives in the +# operator's ingress.annotations, the chart has no values for it, and the +# Gateway API's own CORS filter is Extended-support with observed +# implementation variance - all of which is documented in values.yaml and +# printed by NOTES.txt. Documentation is not something a test can assert. +# - that SMTP/IMAP still work. They never went through the Ingress and do not +# go through the route; the mail Certificate that serves them keeps being +# rendered in this mode, which is this chart's distinguishing feature in +# the sweep, and no render test looks at whether it was left alone. +ingress: + enabled: false +httpRoute: + enabled: true + parentRefs: + - name: public + namespace: gateway + sectionName: https diff --git a/stalwart/Chart.yaml b/stalwart/Chart.yaml index a450048..9d6b9bf 100644 --- a/stalwart/Chart.yaml +++ b/stalwart/Chart.yaml @@ -25,7 +25,7 @@ type: application # byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml # - measured, both empty diffs. New optional values with unchanged defaults is # exactly the minor case (README, "Versioning"). -version: 1.1.1+upv0.16.25 +version: 1.2.0+upv0.16.25 appVersion: "v0.16.25" maintainers: diff --git a/stalwart/templates/NOTES.txt b/stalwart/templates/NOTES.txt index 050ab4f..fcec407 100644 --- a/stalwart/templates/NOTES.txt +++ b/stalwart/templates/NOTES.txt @@ -1,9 +1,27 @@ +{{- /* +NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no +workflow or script under ci/ reads it, so every claim below carries +review-only assurance - it is checked by being read, not by a stage. A +rendering ERROR here does fail `helm template`, which is all the pipeline can +see of it. +*/ -}} {{- $release := include "stalwart.fullname" . -}} {{- $nodes := fromYaml (include "stalwart.nodes" .) -}} {{- $ports := fromYaml (include "stalwart.ports" .) -}} {{- $valkeyHost := include "stalwart.valkeyHost" . -}} +{{- $httpRoute := .Values.httpRoute | default dict -}} +{{- /* +"Published" rather than "has an Ingress": the two modes are alternatives, and +the paragraphs below that asked ingress.enabled were really asking whether the +WebUI and JMAP are reachable from outside at all (Gitea #114). +*/ -}} +{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}} +{{- $hostnames := $httpRoute.hostnames | default list -}} +{{- if not $hostnames -}} +{{- $hostnames = list .Values.ingress.domain -}} +{{- end -}} {{- $ingressGroup := "" -}} -{{- if .Values.ingress.enabled }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}} +{{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}} Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP @@ -140,14 +158,18 @@ FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL 'Cluster role "" not found in registry', and it is printed AFTER the listeners are already bound. - 4. Configure HTTP forwarding before the auto-ban notices the Ingress. - Stalwart sees the ingress controller's pod IP for every WebUI and JMAP + 4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}. + Stalwart sees the {{ if $httpRoute.enabled }}Gateway's{{ else }}ingress controller's{{ end }} pod IP for every WebUI and JMAP request, not the client's. Sooner or later the auto-ban bans that one address and the WebUI and JMAP go dark for everyone at once. The PROXY protocol is not the fix for HTTP: enable "forwarded" handling with X-Forwarded-For evaluation, and discard forwarding headers the client brought itself. The outage arrives weeks later and looks like a server fault, which is why this is a step and not a tip. + {{ if $httpRoute.enabled }}Switching to the Gateway API does not avoid this and does not + reset it: it is one proxy address instead of another, and whether the + Gateway sends an X-Forwarded-For at all is that implementation's business, + not something this route can state.{{ end }} 5. Point the TLS object at the mounted certificate. {{- if .Values.certificate.enabled }} @@ -295,7 +317,7 @@ DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT {{- end }} {{- if .Values.ingress.enabled }} -WEB UI AND JMAP +WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false) https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}" Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer: kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress @@ -305,10 +327,101 @@ WEB UI AND JMAP Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3). Between finishing setup and creating that listener the Ingress resolves to a port that answers nothing. +{{- else if $httpRoute.enabled }} + +WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false) +{{- range $hostnames }} + https://{{ . }} +{{- end }} + → node group "{{ $ingressGroup }}", one route, not one per group + + This release renders ONE HTTPRoute ({{ $release }}-httproute) and nothing + else of the HTTP routing path — no Gateway, no class, no certificate for this + hostname, no policy object. Three things therefore belong to the cluster: + + 1. The Gateway-API CRDs. Without them this install would have failed, so + they are present — nothing to check. + 2. The Gateway named in httpRoute.parentRefs +{{- range $httpRoute.parentRefs }} + {{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} +{{- end }} + must exist AND its listener must allow routes from this namespace + ({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows only in + the route's status: + + kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-httproute -o yaml + + Read status.parents[].conditions — "Accepted: True" and + "ResolvedRefs: True" is a working attachment; an empty status means no + controller has looked at the route at all. + 3. The TLS certificate for the HTTP hostname, on the Gateway's listener. + ingress.clusterIssuer is not needed FOR THAT — but read the next + paragraph before removing it. + + THE MAIL CERTIFICATE STAYS, AND MUST NOT BE CLEANED UP. + {{ if .Values.certificate.enabled }}certificate.enabled is true, so this release still renders + {{ $release }}-mail-certificate.{{ else }}certificate.enabled is false, so no mail certificate is rendered here.{{ end }} + That object has nothing to do with the Ingress or the Gateway: it covers + STARTTLS on the SMTP port and implicit TLS on the submission and IMAPS + ports, and those connections pass through no HTTP proxy at all. So in this mode + ingress.domain is STILL REQUIRED (the certificate's dnsNames read it) and + ingress.clusterIssuer is still required unless certificate.clusterIssuer is + set. Deleting the Certificate as part of a migration drops SMTP and IMAP back + to Stalwart's self-signed certificate: clients see a name mismatch while + every Kubernetes object in this release stays green. + + SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they were not + routed by the Ingress. They keep arriving through the per-port NodePort + Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; this + chart renders neither. + + AND NOW THE ONE THAT WILL BITE: CORS, AND THE WEBMAIL. + + This installation's live Ingress carries, set by the operator through + ingress.annotations and NOT by this chart: + + nginx.ingress.kubernetes.io/enable-cors: true + nginx.ingress.kubernetes.io/cors-allow-origin: https:// + nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS + nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type + + That is what lets the browser-side webmail (the bulwark chart) call this + server's JMAP endpoint at all. IN THIS MODE IT IS GONE, and the failure is + entirely on the client side: a console error in the browser, and nothing + whatsoever in this server's logs. + + The Gateway API does have a CORS filter — HTTPRoute filters carry a `cors` + field in the CRDs this repository pins — so this is not a missing feature of + the API. THIS CHART STILL DOES NOT RENDER IT, and knowingly: + + - the chart has no CORS values to render it from (the origin and the method + list are the operator's, and the chart sets no CORS annotations in + Ingress mode either), + - that filter's support level is "Extended", so an implementation may + reject the route — and rejection is whole-route, which would take the + WebUI and JMAP down together — or, worse, accept it and not honour it, + - and the part a plain header filter COULD do is the part that does not + help here: the browser's PREFLIGHT has to be answered by the gateway + rather than by Stalwart, and because the allowed headers include + Authorization, EVERY request from the webmail is preflighted. + + So: configure CORS AT THE GATEWAY, with that implementation's own support, + BEFORE you move this host. Then TEST IT FROM THE WEBMAIL IN A BROWSER — curl + sends no preflight, so it will pass while the webmail fails. + + ingress.annotations is inert in this mode in general: the Gateway API has no + annotation channel for controller behaviour. + + AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE, and the hostname + the WebUI sends administrators to lives IN THE DATABASE, not in any value + here. Upstream moves administration to https:///admin once setup is complete, so this route only works while an + HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3). {{- else }} -NO INGRESS - ingress.enabled is false, so there is no Ingress and no WebUI certificate. +NEITHER INGRESS NOR HTTPROUTE + ingress.enabled and httpRoute.enabled are both false, so there is no Ingress, + no HTTPRoute and no WebUI certificate. ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its dnsNames, and without it the certificate would carry no name at all. {{- end }} @@ -363,6 +476,8 @@ NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER that never becomes Ready reads as a broken application rather than as a policy. The port carries no nodePort, so this is open inside the cluster, not from outside. Narrow it with networkPolicy.probeSources, and then add the - ingress controller's namespace to networkPolicy.allowedNamespaces or the - Ingress goes dark. + {{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the + traffic arrives from the Gateway's data-plane pods, which usually live in a + namespace of their own){{ else }}ingress controller's namespace{{ end }} to networkPolicy.allowedNamespaces or the + {{ if $httpRoute.enabled }}route{{ else }}Ingress{{ end }} goes dark. {{- end }} diff --git a/stalwart/templates/stalwart.httproute.yaml b/stalwart/templates/stalwart.httproute.yaml new file mode 100644 index 0000000..27c1554 --- /dev/null +++ b/stalwart/templates/stalwart.httproute.yaml @@ -0,0 +1,165 @@ +{{/* +HTTPRoute - the Gateway-API alternative to stalwart.ingress.yaml (Gitea #114). + +ONE ROUTE, NOT ONE PER NODE GROUP, and that is worth saying because #114 +describes this chart as rendering "per node group". Its SERVICES and +StatefulSets are per group; the routing object is not and must not be. The +Ingress targets exactly ONE group (ingress.nodeGroup) through that group's own +http Service, because the WebUI's setup and management screens are not safe to +spread over a group that may not run the management tasks. The route keeps that +property, including the render abort when the named group does not expose +"http". + +WHAT STAYS IN THIS MODE, AND IT IS THE ONLY CHART IN THE SWEEP WHERE ANYTHING +DOES: the MAIL certificate. stalwart.certificate.yaml is not the Ingress +certificate - it covers STARTTLS on 25 and implicit TLS on 465/993, and those +connections never pass through an ingress controller or a Gateway. So +certificate.enabled keeps rendering a Certificate here, ingress.domain stays +required (the certificate's dnsNames read it) and ingress.clusterIssuer stays +required unless certificate.clusterIssuer is set. Do NOT "clean up" the +Certificate when switching to Gateway mode: SMTP and IMAP would fall back to +whatever self-signed certificate Stalwart generates, and clients would see a +name mismatch while every Kubernetes object stayed green. + +WHAT THIS RENDERS, AND WHAT IT MUST NOT + +Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no +Certificate FOR THE HTTP HOSTNAME, and no implementation-specific policy +object - the subset that looks identical on nginx-gateway-fabric, Cilium, Envoy +Gateway and Istio (decision 1 of #114). The HTTP hostname's certificate belongs +on the Gateway's listener; the mail certificate stays here, which is the +distinction above. + +And the Gateway API routes HTTP only, so SMTP, IMAP and ManageSieve are as far +outside this object as they are outside the Ingress - they keep reaching the +pods through the per-port NodePort Services. A Gateway could in principle carry +them as TLSRoute/TCPRoute listeners, which this chart does not render and which +would be a separate decision. + +WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND THIS IS THE ONE PLACE +IN GROUP 3 WHERE THE LOSS BREAKS A SECOND CHART OF THE SAME GROUP + +On 2026-10-12 the live Ingress (jk-cluster, namespace stalwart, +mail.bekemeyer-klein.de) carried, all of it OPERATOR-supplied through +ingress.annotations - this chart sets none of it: + + nginx.ingress.kubernetes.io/enable-cors: true + nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de + nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS + nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type + +That origin is BULWARK, the webmail chart in this same group. The CORS +configuration is the whole reason the browser-side webmail may call this +server's JMAP endpoint at all, so it is not decoration: without it the webmail +stops working, with a console error and no server-side symptom. + +WHAT THE GATEWAY API HAS FOR IT, MEASURED AGAINST THE PINNED CRDs RATHER THAN +ASSUMED. Both halves exist in the API, and neither is promisable by this chart: + + - A full CORS FILTER exists. In the standard channel this repository pins + (gateway-api v1.6.3, ci/gateway-api-crds.sh), HTTPRoute's filters carry a + `cors` field with allowOrigins, allowMethods, allowHeaders, + allowCredentials and maxAge - GEP-1767. Grepped out of the downloaded CRD, + not inferred. Its documented support level, verbatim from that CRD, is + "Support: Extended". + - responseHeaderModifier is in the same CRD and is ALSO "Support: Extended", + not Core. + +So "no counterpart" would be the wrong thing to write down. The accurate +statement is that there is a counterpart and this chart still does not render +it, for three reasons, in order: + + 1. THE CHART HAS NO CORS VALUES AT ALL. The live configuration is the + operator's: a specific origin, a specific method list. Rendering a CORS + filter means inventing a values surface in a sweep PR - and one that + would exist in Gateway mode only, because this chart sets no CORS + annotations in Ingress mode either. That asymmetry is its own defect and + its own piece of work. + 2. EXTENDED SUPPORT IS NOT SUPPORT. #119 established the shape of this: an + implementation that does not support an Extended value must report it in + the route's status, and a rejected route is rejected WHOLE - here that + would take the WebUI and JMAP down together. Worse, the observed failure + is quieter than that: Cilium accepts `filters.type: CORS` and still + forwards the browser's preflight upstream + (cilium/cilium#45272), i.e. the object is Accepted and the behaviour is + absent. A chart cannot check which implementation it landed on. + 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS + ONE HERE. A responseHeaderModifier can add the three + Access-Control-Allow-* headers to a response, but the PREFLIGHT is a + different thing: the browser sends OPTIONS first and the gateway - not the + backend - has to answer it. The CORS filter's own contract says so (a path + with a CORS filter MUST NOT pass the preflight upstream), which is exactly + what a bare header modifier cannot do. And this installation's header list + includes Authorization, which makes EVERY webmail request preflighted: the + half that is expressible without the CORS filter is precisely the half + that does not help. Rendering it would produce a setup that looks + configured and fails on the first real call. + +So: named, not rebuilt (decision 2 of #114), in the Gateway branch of +NOTES.txt, with the instruction to configure CORS at the Gateway - via that +implementation's CORS support - BEFORE moving this host, and to test it from +the webmail rather than with curl, because curl sends no preflight. +*/}} +{{- /* +Null-safe like every other block a template here dereferences (#99): an +override written without children - "httpRoute:" with its keys deleted - is +YAML null, and .Values.httpRoute.enabled would abort with a nil pointer. +*/ -}} +{{- $httpRoute := .Values.httpRoute | default dict -}} +{{- if and $httpRoute.enabled .Values.ingress.enabled -}} +{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Stalwart there is a second edge to it: the server's auto-ban sees the proxy's address and bans it, so two data paths mean two addresses to get banned and a WebUI that fails for half the requests. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and note that the server hostname configured IN THE DATABASE decides where the WebUI tells administrators to go, which no chart value can change." -}} +{{- end -}} +{{- if $httpRoute.enabled -}} +{{- $group := include "stalwart.ingressNodeGroup" . -}} +{{- $nodes := fromYaml (include "stalwart.nodes" .) -}} +{{- $ports := fromYaml (include "stalwart.ports" .) -}} +{{- $spec := index $nodes $group -}} +{{- if not (has "http" $spec.exposedPorts) -}} +{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the HTTPRoute to point at. The value is shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress." $group $group) -}} +{{- end -}} +{{- $parentRefs := $httpRoute.parentRefs | default list -}} +{{- if not $parentRefs -}} +{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}} +{{- end -}} +{{- /* +One source of truth for the hostname: an empty httpRoute.hostnames means +[ingress.domain]. Two values that have to agree drift apart. ingress.domain is +read outside the routing templates in this chart - the mail Certificate's +dnsNames - so it already carries a required() there and is mandatory in every +mode, including with both modes off. This required() is the route's own, so the +message names the route. +*/ -}} +{{- $hostnames := $httpRoute.hostnames | default list -}} +{{- if not $hostnames -}} +{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}} +{{- end -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "stalwart.fullname" . }}-httproute + labels: + app: {{ include "stalwart.fullname" . }} +spec: + parentRefs: + {{- toYaml $parentRefs | nindent 4 }} + hostnames: + {{- toYaml $hostnames | nindent 4 }} + rules: + {{- /* + The Ingress counterpart is `path: / , pathType: Prefix` on the http + Service of the "{{ $group }}" group ONLY - never a port-label selector, + for the reason in the header. The port is the Service's port NUMBER and + not its name "http": backendRefs takes a PortNumber, so unlike the + Ingress this reference cannot be made by name. It comes from + ports.http.containerPort, which is the same value the Service renders its + port from, so the two cannot drift; + ci/run-httproute-match-test.sh holds the pair for the case where they do. + */}} + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service + port: {{ int (index $ports "http").containerPort }} +{{- end }} diff --git a/stalwart/values.schema.json b/stalwart/values.schema.json index 44d1879..67796de 100644 --- a/stalwart/values.schema.json +++ b/stalwart/values.schema.json @@ -390,7 +390,7 @@ "additionalProperties": false, "properties": { "enabled": { - "description": "Gates the Ingress template only. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1). clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.", + "description": "Gates the Ingress template only. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1) - and the HTTPRoute reads it as its hostname as well. clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.", "type": "boolean" }, "clusterIssuer": { @@ -405,16 +405,34 @@ "type": ["string", "null"] }, "nodeGroup": { - "description": "Which node group the Ingress routes to, through that group's own http Service. Must be a key of nodes: and must expose http; both are checked in the template. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.", + "description": "Which node group the routing object - Ingress or HTTPRoute - points at, through that group's own http Service. Shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress. Must be a key of nodes: and must expose http; both are checked in both templates. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.", "type": "string", "minLength": 1 }, "annotations": { - "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.", + "description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. INERT IN GATEWAY MODE, and for this chart that is the consequential one: this installation's live Ingress carries its CORS configuration here (enable-cors plus allow-origin/methods/headers for the bulwark webmail). See values.yaml, httpRoute block - the Gateway API does have a CORS filter, and the chart still does not render it.", "type": "object" } } }, + "httpRoute": { + "description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; the HTTP hostname's certificate sits on the Gateway's listener and belongs to the platform. ONE route, not one per node group - it targets ingress.nodeGroup's http Service, with the same two guards the Ingress has. THE MAIL CERTIFICATE STAYS in this mode: it covers STARTTLS on 25 and implicit TLS on 465/993, which pass through no HTTP proxy, so ingress.domain stays required and the Certificate must not be removed as part of a migration. SMTP, IMAP and ManageSieve are not routed here any more than by the Ingress. NOT COVERED IN THIS MODE: the CORS configuration this installation relies on for the bulwark webmail. A CORS filter DOES exist in the pinned gateway-api CRDs (v1.6.3, GEP-1767) but is 'Support: Extended', implementations vary (Cilium accepts it and forwards the preflight upstream anyway), the chart has no CORS values to render it from, and a bare responseHeaderModifier cannot answer a preflight - which is every request here, since the allowed headers include Authorization. Configure CORS at the Gateway and test from a browser, not with curl. See values.yaml, httpRoute block.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "parentRefs": { + "description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.", + "type": "array", + "items": { "$ref": "#/definitions/parentRef" } + }, + "hostnames": { + "description": "Empty means [ingress.domain], so the HTTP hostname has a single source of truth. Note that the server's own hostname lives in the DATABASE, not in any chart value: after bootstrap the WebUI is reached at https:///admin, so a second hostname can be reachable without the application agreeing that it is its name.", + "type": "array", + "items": { "type": "string" } + } + } + }, "securityContext": { "description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.", "type": ["object", "null"], @@ -455,6 +473,11 @@ } }, "definitions": { + "parentRef": { + "description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.", + "type": "object", + "required": ["name"] + }, "exposedPorts": { "description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.", "type": "array", diff --git a/stalwart/values.yaml b/stalwart/values.yaml index 37ce67b..ae56e6b 100644 --- a/stalwart/values.yaml +++ b/stalwart/values.yaml @@ -625,8 +625,137 @@ ingress: nodeGroup: full # Extra annotations for the Ingress; merged with the cert-manager # cluster-issuer annotation the chart always sets. + # + # INERT IN GATEWAY MODE - the Gateway API has no annotation channel for + # controller behaviour. For THIS chart that is the most consequential line in + # the whole block: read the httpRoute section below before switching modes, + # because what lives in here on the live installation is the CORS + # configuration the webmail depends on. annotations: {} +# --------------------------------------------------------------------------- +# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114) +# --------------------------------------------------------------------------- +# Mutually exclusive with ingress.enabled - the render aborts when both are +# true, and the message names both value paths. The chart renders ONLY an +# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP +# hostname, and no implementation-specific policy object. +# +# ONE ROUTE, NOT ONE PER NODE GROUP. #114 describes this chart as rendering +# "per node group"; its SERVICES and StatefulSets are, the routing object is +# not and must not be. The route targets exactly the ingress.nodeGroup group, +# through that group's own http Service, and aborts the render if that group +# does not expose "http" - the same two guards the Ingress has, for the same +# reason: the WebUI's setup and management screens are not safe to spread over +# a group that may not run the management tasks. +# +# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where +# anything does. certificate.enabled renders a Certificate for STARTTLS on 25 +# and implicit TLS on 465/993; those connections pass through neither an +# ingress controller nor a Gateway. So in Gateway mode: +# - ingress.domain is still REQUIRED (the certificate's dnsNames read it), +# - ingress.clusterIssuer is still required unless certificate.clusterIssuer +# is set, +# - and the Certificate must NOT be "cleaned up" as part of the migration. +# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed +# certificate: clients see a name mismatch while every Kubernetes object in the +# release stays green. +# +# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not +# routed by the Ingress. They keep reaching the pods through the per-port +# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; +# this chart renders neither, and doing so would be a separate decision. +# +# WHAT THIS MODE COSTS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP. +# Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried, +# all of it OPERATOR-supplied through ingress.annotations (this chart sets none +# of it): +# +# nginx.ingress.kubernetes.io/enable-cors: true +# nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de +# nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS +# nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type +# +# That origin is BULWARK, the webmail chart in this same group. The CORS +# configuration is what allows the browser-side webmail to call this server's +# JMAP endpoint at all. Lose it and the webmail stops working, with a console +# error in the browser and NO server-side symptom whatsoever. +# +# AND "no counterpart" would be the wrong thing to claim here. Measured against +# the CRDs this repository pins (gateway-api v1.6.3 standard channel, +# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by +# inference: +# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with +# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge. +# Its documented support level, verbatim from that CRD: "Support: +# Extended". +# - `responseHeaderModifier` is in the same CRD and is ALSO "Support: +# Extended", not Core. +# +# There IS a counterpart, then, and this chart still does not render it. Three +# reasons, in order: +# +# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's +# - a specific origin, a specific method list. Rendering a filter means +# inventing a values surface in a sweep PR, and one that would exist in +# Gateway mode only, since the chart sets no CORS annotations in Ingress +# mode either. That asymmetry is its own defect and its own piece of work. +# 2. EXTENDED SUPPORT IS NOT SUPPORT. An implementation that does not support +# an Extended value reports it in the route's status, and a rejected route +# is rejected WHOLE - which here would take the WebUI and JMAP down +# together (the reasoning #119 arrived at for statusCode). The observed +# failure is quieter still: Cilium accepts filters.type: CORS and +# forwards the browser's preflight upstream anyway (cilium/cilium#45272), +# i.e. Accepted: True with the behaviour absent. A chart cannot know which +# implementation it landed on. +# 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS +# ONE HERE. A responseHeaderModifier can add the three +# Access-Control-Allow-* headers to a response. The PREFLIGHT is a +# different thing: the browser sends OPTIONS first and the GATEWAY, not +# the backend, has to answer it - the CORS filter's own contract says a +# path carrying it MUST NOT pass the preflight upstream. A bare header +# modifier cannot do that. And this installation's header list includes +# Authorization, which makes EVERY webmail request preflighted: the half +# that is expressible without the CORS filter is precisely the half that +# does not help. +# +# So configure CORS AT THE GATEWAY, using that implementation's own CORS +# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with +# curl: curl sends no preflight, so it passes while a browser fails. +# +# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them +# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute +# without them fails at install, so this is a value and not a guess. +httpRoute: + enabled: false + + # The Gateway(s) the route attaches to - a platform object, usually in + # another namespace, whose listener must allow this namespace in + # allowedRoutes. Entries are the Gateway API's ParentReference (name, and + # optionally namespace, sectionName, port, group, kind), passed through + # unchanged: + # + # parentRefs: + # - name: public + # namespace: gateway + # sectionName: https + # + # REQUIRED while httpRoute.enabled is true - the render aborts on an empty + # list, because a route without a parent is created, is valid and routes + # nothing, with no event to say so. + parentRefs: [] + + # Empty means [ingress.domain], so the HTTP hostname has one source of truth. + # Set explicitly only to publish under a different or an additional name - + # which is how a migration from the Ingress runs: the HTTPRoute takes a + # second hostname while the Ingress keeps ingress.domain. + # + # NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After + # bootstrap the WebUI is reached at https:///admin, and no value of this chart changes that - so a second + # hostname is reachable without the application agreeing that it is its name. + hostnames: [] + # --------------------------------------------------------------------------- # Hardening — chart-wide, applied to every node group # ---------------------------------------------------------------------------