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:31:36 +00:00
1 parent 8896fc8d7d
commit ee477288fc
10 files changed
+677 -9

No files matched your search

+77
View File
@@ -22,6 +22,8 @@ WebUI and JMAP are reachable from outside at all (Gitea #114).
{{- end -}}
{{- $ingressGroup := "" -}}
{{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
{{- $mtaStsHosts := fromYamlArray (include "stalwart.mtaSts.hosts" .) -}}
{{- $mtaStsPath := include "stalwart.mtaSts.path" . -}}
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
@@ -327,6 +329,42 @@ WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: fals
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.
{{- if $mtaStsHosts }}
MTA-STS — {{ len $mtaStsHosts }} POLICY ROUTE(S), INGRESS MODE
All of them → node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (pathType Exact)
{{ range $entry := $mtaStsHosts }}
https://{{ $entry.host }}{{ $mtaStsPath }}
secret {{ $entry.tlsSecretName }}{{ if $entry.clusterIssuer }}, requested from the "{{ $entry.clusterIssuer }}" cluster issuer{{ else }}, NO cluster issuer set — this release does not request that certificate, it has to exist already{{ end }}
{{- end }}
One Ingress object PER CLUSTER ISSUER, not one per host: the cert-manager
issuer is an object-level ANNOTATION while the TLS secret is a per-entry
field, so one object cannot both request a certificate and leave another
host's secret alone. Hosts sharing a secret share one tls: entry, so one
certificate with several names is requested once instead of once per host:
kubectl -n {{ .Release.Namespace }} get ingress -l app={{ $release }}
TWO THINGS THIS RELEASE DOES NOT DO, AND BOTH ARE SILENT WHEN MISSING:
1. IT DOES NOT CREATE THE DOMAIN. The domains live in Stalwart's data
store (step 3), not in any chart value, so a route to a domain that is
not in the data store answers 404 at a perfectly routed endpoint —
every object here green.
2. IT DOES NOT CREATE THE DNS RECORD. MTA-STS also needs a TXT record at
_mta-sts.<domain>; without it no sending MTA ever looks for the policy.
AND THE FAILURE IS ON THE SENDER'S SIDE, NOT HERE: an MTA that finds no
policy — or hits a certificate that does not match — just delivers in the
clear and logs nothing in this cluster. So verify each one from outside:
{{- range $entry := $mtaStsHosts }}
curl https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
MIND THE DEEPER NAMES: mta-sts.sub.<domain> is one label below
*.<domain>, and a wildcard certificate matches exactly one label — so a
parent wildcard does NOT cover it. That is what the per-entry secret is for.
{{- end }}
{{- else if $httpRoute.enabled }}
WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
@@ -417,6 +455,38 @@ WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: fals
here. Upstream moves administration to https://<the configured server
hostname>/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).
{{- if $mtaStsHosts }}
MTA-STS — ONE ROUTE, {{ len $mtaStsHosts }} HOSTNAME(S), GATEWAY MODE
{{- range $entry := $mtaStsHosts }}
https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
→ node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (type: Exact)
ONE HTTPRoute ({{ $release }}-mta-sts-httproute) for all of them, attached to
the same Gateway(s) as the WebUI route above. Nothing differs per host here —
same path, same backend, no filter — so a hostname list is the whole object:
kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-mta-sts-httproute -o yaml
THE TLS FIELDS OF EACH ENTRY ARE INERT IN THIS MODE. tlsSecretName and
clusterIssuer are read only on the Ingress path; here the certificate belongs
to the Gateway's LISTENER, and this release renders none for these hostnames.
So before switching these hosts over, check the listener really covers every
one of them — AND MIND THE DEPTH: mta-sts.sub.<domain> is one label below
*.<domain>, and a wildcard matches exactly one label, so a wildcard listener
for the parent domain does NOT terminate TLS for it. The symptom is a TLS
error at the SENDING MTA, which nothing in this cluster logs.
TWO THINGS THIS RELEASE STILL DOES NOT DO, both silent when missing: it does
not create the DOMAIN in Stalwart's data store (step 3) — a route to a domain
that is not there answers 404 at a correctly routed endpoint — and it does
not create the _mta-sts.<domain> TXT record, without which no sending MTA
looks for the policy at all. An MTA that finds no policy delivers in the
clear and reports nothing here, so verify each one from outside:
{{- range $entry := $mtaStsHosts }}
curl https://{{ $entry.host }}{{ $mtaStsPath }}
{{- end }}
{{- end }}
{{- else }}
NEITHER INGRESS NOR HTTPROUTE
@@ -424,6 +494,13 @@ NEITHER INGRESS NOR HTTPROUTE
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.
{{- if $mtaStsHosts }}
AND mtaSts.hosts IS SET ({{ len $mtaStsHosts }} entr{{ if eq (len $mtaStsHosts) 1 }}y{{ else }}ies{{ end }}) BUT RENDERS NOTHING. Those routes need a
routing mode: they follow the one that is on, and with both off there is no
object to carry them. Nothing is wrong with the list — switch on
ingress.enabled or httpRoute.enabled and the routes appear with it.
{{- end }}
{{- end }}
{{- if .Values.valkeySubchart.enabled }}