Files
helm-charts/stalwart/templates/stalwart.httproute.yaml
T
jklein b4c9823e01 feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes
The policy route for mta-sts.<domain> belonged to another repository
(jk-cluster-config) while pointing at this chart's own Service. It moves
here as mtaSts.hosts, a list, so several domains - subdomains included -
are maintainable in one place and in whichever routing mode is active.

THE LIST IS THE SWITCH. No mtaSts.enabled: "enabled with an empty list"
would be a state with no meaning that the chart would then have to catch.
The schema rejects the key outright, so the rule is enforced rather than
documented. Default [] renders nothing in either mode - the defaults and
the unchanged CI fixture both produce a byte-identical helm template
against origin/main.

Entry form {host, tlsSecretName, clusterIssuer?}. The two TLS fields are
read ONLY in Ingress mode; in Gateway mode TLS sits on the listener, so
requiring them there would demand a value that does nothing.

THE PATH IS HARDWIRED. RFC 8461 fixes /.well-known/mta-sts.txt, rendered
with pathType Exact (type: Exact in the route). A values key for a
protocol-mandated path can only ever be set wrong, and wrong is invisible
from this side. Exact also matters in itself: a prefix on these hostnames
would publish the mail server's whole management surface under them.

ONE INGRESS PER CLUSTER ISSUER, NOT ONE OBJECT - and the plan's own three
requirements forced that. cert-manager reads the two fields at different
scopes: cluster-issuer is an ANNOTATION (object-scoped, and absent means
nothing is issued for that object at all), while secretName is
tls-entry-scoped and yields one Certificate per entry. So an object cannot
both request a certificate and leave another host's secret alone, which is
what an empty per-entry issuer has to mean. Measured in jk-cluster:
ingress-shim names the Certificate AFTER THE SECRET and owns it from the
Ingress, so a shared secret must sit in exactly one object (hence grouping
the tls list by secret, reusing rss-bridge.tlsGroups' shape) and a secret
may not span two issuer groups (hence the abort). Objects are named by
issuer, not by index: an index is only free where the object holds no
certificate, which is the precondition homeassistant's route naming states
for itself.

Gateway mode renders ONE HTTPRoute with every hostname - nothing differs
per host there - attached to the same Gateway(s) as the WebUI route.

WHAT THE CHART CANNOT PROMISE, AND THEREFORE NAMES: it registers a route,
it does not create a domain. The domains live in Stalwart's data store, so
an entry whose domain is missing there answers 404 at a correctly routed
endpoint with every object green - and MTA-STS failures are silent on the
SENDER's side, which delivers in the clear and logs nothing here. Same for
the _mta-sts.<domain> TXT record and the policy's content. The subdomain
trap is documented too: mta-sts.sub.<domain> is one label below
*.<domain>, so a parent wildcard certificate does not cover it.

Not folded into certificate.extraDnsNames deliberately - that would hang
the MAIL certificate on this list, letting a bad policy entry take STARTTLS
and IMAPS with it.

Minor, additive, empty default: 1.2.0+upv0.16.25 -> 1.3.0+upv0.16.25.

CI: ci/httproute/stalwart.yaml enters all three shapes of the entry form
(with issuer, without, and a subdomain), taking the HTTPRoute render test
from 56 assertions over 21 charts to 58 over 21 - stalwart from 2 to 4.
The Ingress half cannot be covered there, since an overlay's whole job is
to switch the mode off, so ci/stalwart/test-values.yaml carries two
entries and the install-test has a real API server accept them.

Fixes #123
2026-10-13 10:41:10 +00:00

249 lines
14 KiB
YAML

{{/*
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 }}
{{- /*
MTA-STS (Gitea #123) - the policy route, in Gateway mode.
ONE OBJECT WITH ALL THE HOSTNAMES, and here that is right where the Ingress
side needs several objects. spec.hostnames applies to the whole object and
nothing differs per host: same path, same backend, no filter. So one object
with a hostname list says exactly what is meant. (The contrast worth knowing:
Gitea #122 needs one object per host for keycloak, because its app-root
redirect differs per realm and a rule's matches are path-based - there is no
way to tell hostnames apart inside one route. And the Ingress side here needs
several objects for a third, unrelated reason: the cert-manager annotation is
object-scoped. Three different shapes, three different causes.)
NO TLS HERE, and therefore no use for either TLS field of an entry.
tlsSecretName and clusterIssuer are accepted and INERT in this mode - the
listener on the Gateway carries the certificate, and this chart renders no
certificate for an HTTP hostname in Gateway mode. Which is why tlsSecretName is
required only on the Ingress path: a value demanded in a mode where it does
nothing is a value that teaches the wrong thing.
AND THE HOSTNAMES ARE A TRAP WORTH NAMING AT THE LISTENER. mta-sts.sub.domain.tld
is one label deeper than *.domain.tld, so a wildcard listener for the parent
does NOT terminate TLS for it (RFC 6125: a wildcard matches one label). The
Gateway needs a listener that really covers each hostname below, and the
symptom of getting it wrong is a TLS error at the SENDING MTA - nothing in this
cluster logs it.
NO SEPARATE parentRefs. The route attaches to the same Gateway(s) as the WebUI
route above, which is the honest default: these hostnames point at the same
Service in the same namespace. If they ever need a Gateway of their own, the
shape to copy is keycloak's httpRoute.admin.parentRefs (Gitea #114, group 3) -
an own list falling back to httpRoute.parentRefs - and not a second required
value. The abort on an empty httpRoute.parentRefs above covers this object too:
it runs whenever httpRoute.enabled is true, before anything here is rendered.
*/ -}}
{{- if $httpRoute.enabled }}
{{- $mtaStsHosts := fromYamlArray (include "stalwart.mtaSts.hosts" .) }}
{{- if $mtaStsHosts }}
{{- $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 MTA-STS HTTPRoute to point at" $group $group) }}
{{- end }}
{{- $parentRefs := $httpRoute.parentRefs | default list }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "stalwart.fullname" . }}-mta-sts-httproute
labels:
app: {{ include "stalwart.fullname" . }}
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- range $entry := $mtaStsHosts }}
- {{ $entry.host }}
{{- end }}
rules:
{{- /*
type: Exact, not PathPrefix - RFC 8461 serves exactly one file at
exactly one path, and a prefix here would publish the mail server's
whole management surface under these hostnames.
The port is the Service's port NUMBER, from ports.http.containerPort,
exactly as in the WebUI route above: backendRefs takes a PortNumber, so
the name "http" the Ingress uses is not available here. Note that this
chart's NodePort Services publish a DIFFERENT number
(ports.<name>.nodePort) than the container binds, so the wrong one is a
plausible mistake - ci/run-httproute-match-test.sh holds this reference
against the Services of the same render.
*/}}
- matches:
- path:
type: Exact
value: {{ include "stalwart.mtaSts.path" . }}
backendRefs:
- name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
port: {{ int (index $ports "http").containerPort }}
{{- end }}
{{- end }}