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
This commit is contained in:
jklein committed 2026-10-13 10:41:10 +00:00
1 parent 649417b428
commit b4c9823e01
10 files changed
+677 -9

No files matched your search

@@ -163,3 +163,86 @@ spec:
- 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 }}