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

+165
View File
@@ -556,3 +556,168 @@ The node group the Ingress routes to, validated. Input: the root context.
{{- end -}}
{{- $group -}}
{{- end -}}
{{/*
MTA-STS: the policy path, RFC 8461, hardcoded in one place.
NOT A VALUE, and that is a decision rather than an omission. RFC 8461 fixes the
location - the policy is retrieved over HTTPS from the well-known path
/.well-known/mta-sts.txt on mta-sts.<domain>. A sending MTA looks there and
nowhere else, so a values key for it would be a key that can only ever be set
WRONG, and the failure is silent on this side (see the mtaSts block in
values.yaml). Both routing modes include this define, so the Ingress path and
the HTTPRoute path cannot drift apart.
*/}}
{{- define "stalwart.mtaSts.path" -}}/.well-known/mta-sts.txt{{- end -}}
{{/*
The validated, normalised mtaSts.hosts list. Input: the root context.
Returns YAML of a list of dicts with the keys host, tlsSecretName and
clusterIssuer (the latter two possibly empty), in the order they were written.
Shared by BOTH routing modes, which is why the host-level validation lives here
and not in either template: an entry that is wrong is wrong in both modes, and
a guard that exists in only one of them is a guard a mode switch removes.
*/}}
{{- define "stalwart.mtaSts.hosts" -}}
{{- $mtaSts := .Values.mtaSts | default dict -}}
{{- $entries := $mtaSts.hosts | default list -}}
{{- $out := list -}}
{{- $seen := dict -}}
{{- range $i, $entry := $entries -}}
{{- $entry = $entry | default dict -}}
{{- $host := required (printf "mtaSts.hosts[%d] has no host - every entry needs the hostname the policy is served on, which RFC 8461 fixes as mta-sts.<domain>" $i) $entry.host -}}
{{- if hasKey $seen $host -}}
{{- /*
An error in BOTH modes, which is why it aborts rather than being de-duplicated.
In Ingress mode two rules on one host and one path are what ingress-nginx's
admission webhook rejects - but only once the two land in DIFFERENT objects,
and whether they do depends on the clusterIssuer of each entry, so the loud
failure would come and go with an unrelated field. In Gateway mode a repeated
entry in spec.hostnames is accepted and does nothing at all.
*/ -}}
{{- fail (printf "mtaSts.hosts[%d] repeats the host %q, which is already entry %d. One hostname serves one MTA-STS policy; a second entry for it either collides in the ingress-nginx admission webhook or is silently ignored by a Gateway, depending on the mode and on the clusterIssuer fields - so it is refused here instead." $i $host (index $seen $host)) -}}
{{- end -}}
{{- $_ := set $seen $host $i -}}
{{- $out = append $out (dict
"host" $host
"tlsSecretName" ($entry.tlsSecretName | default "")
"clusterIssuer" ($entry.clusterIssuer | default "")) -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
MTA-STS in INGRESS MODE: the entries grouped into the Ingress objects that
actually have to exist. Input: the root context. Returns YAML of
order: list of clusterIssuer values, first-appearance order ("" included)
groups: <clusterIssuer> -> dict with
hosts: list of hosts, one rule each
tlsOrder: list of TLS secret names, first-appearance order
tls: <secretName> -> list of hosts sharing it
WHY THIS IS NOT ONE INGRESS OBJECT, AND THE REASON IS MEASURED
The approved plan on Gitea #123 asks for "one Ingress, one rule per host, tls
grouped by secret" (point 4) AND for a per-entry, optional clusterIssuer whose
empty value means no annotation (point 2) AND for an overlay carrying one entry
with an issuer and one without (point 9). Those three cannot all hold, because
of where cert-manager reads each of the two fields:
- cert-manager.io/cluster-issuer is an ANNOTATION, i.e. OBJECT-scoped. One
Ingress object carries exactly one of them, or none - and with the
annotation absent, cert-manager's ingress-shim creates NOTHING for that
object, for every tls entry on it rather than for some.
- secretName is TLS-ENTRY-scoped, and the shim creates one Certificate PER
tls entry.
So an object cannot mix "issued by X" with "comes from somewhere else", and the
split has to be on the issuer. Measured in jk-cluster on 2026-10-13, which is
also where the second half of the rule comes from:
Certificate tls-mta-sts-bekemeyer-klein-de-ingress
secretName: tls-mta-sts-bekemeyer-klein-de-ingress
ownerReferences: Ingress/mta-sts-ingress
Certificate tls-stalwart-stalwart-ingress
secretName: tls-stalwart-stalwart-ingress
ownerReferences: Ingress/stalwart-stalwart-ingress
The shim names the Certificate AFTER THE SECRET and owns it from the Ingress.
Two Ingress objects whose tls entries name the same secret therefore drive the
shim at ONE Certificate name from two owners - so a secret shared by several
hosts must sit in exactly one object, which is what the tls grouping below is
for, and a secret may NOT span two issuer groups. That last case is the one
thing this helper refuses outright.
The grouping itself is rss-bridge.tlsGroups (rss-bridge/templates/_helpers.tpl),
adopted rather than reinvented as #123 point 4 asks; what is new here is the
issuer dimension, which that chart does not have - it sets one chart-wide
ingress.clusterIssuer on its single object.
*/}}
{{- define "stalwart.mtaSts.ingressGroups" -}}
{{- $entries := fromYamlArray (include "stalwart.mtaSts.hosts" .) -}}
{{- $order := list -}}
{{- $groups := dict -}}
{{- $secretIssuer := dict -}}
{{- range $i, $entry := $entries -}}
{{- $host := $entry.host -}}
{{- $issuer := $entry.clusterIssuer -}}
{{- /*
REQUIRED HERE AND NOWHERE ELSE. In Gateway mode TLS sits on the Gateway's
listener and this chart renders no certificate for it, so demanding the field
there would be demanding a value that does nothing (#123, and the same split as
Gitea #122). There is deliberately NO DEFAULT: the plausible one - this
release's own tls-<release>-<chart>-ingress - is the WRONG certificate. It
covers ingress.domain and not mta-sts.<domain>, and a sending MTA would see a
name mismatch, give up on the policy and send in the clear, with nothing
logged on this side.
*/ -}}
{{- $secret := required (printf "mtaSts.hosts[%d] (%s) has no tlsSecretName, and in Ingress mode every entry needs one: MTA-STS is fetched over HTTPS only, so a host without a certificate serves no policy at all. There is no default on purpose - this release's own tls-<release>-<chart>-ingress covers ingress.domain, not this host. Point it at the Secret the certificate for %s lives in, and set clusterIssuer as well if cert-manager is the one to request it." $i $host $host) $entry.tlsSecretName -}}
{{- if hasKey $secretIssuer $secret -}}
{{- if ne (index $secretIssuer $secret) $issuer -}}
{{- fail (printf "mtaSts.hosts[%d] (%s) puts the TLS secret %q under clusterIssuer %q, but an earlier entry already uses that secret under clusterIssuer %q. cert-manager's ingress-shim names the Certificate after the secret and owns it from the Ingress object, while the issuer is an object-level annotation - so the two entries would need two Ingress objects both driving one Certificate name, from two owners. Give the two hosts separate secrets, or put them under the same issuer." $i $host $secret $issuer (index $secretIssuer $secret)) -}}
{{- end -}}
{{- end -}}
{{- $_ := set $secretIssuer $secret $issuer -}}
{{- if not (hasKey $groups $issuer) -}}
{{- $_ := set $groups $issuer (dict "hosts" list "tlsOrder" list "tls" dict) -}}
{{- $order = append $order $issuer -}}
{{- end -}}
{{- $group := index $groups $issuer -}}
{{- $_ := set $group "hosts" (append $group.hosts $host) -}}
{{- if not (hasKey $group.tls $secret) -}}
{{- $_ := set $group.tls $secret list -}}
{{- $_ := set $group "tlsOrder" (append $group.tlsOrder $secret) -}}
{{- end -}}
{{- $_ := set $group.tls $secret (append (index $group.tls $secret) $host) -}}
{{- end -}}
{{- toYaml (dict "order" $order "groups" $groups) -}}
{{- end -}}
{{/*
The name of the MTA-STS Ingress object for one clusterIssuer group. Input: dict
"root" <root context> "issuer" <the group's clusterIssuer, possibly "">.
NOT AN INDEX, and the reason is the one exception the comparable precedent
names itself. homeassistant carries the entry's index in its HTTPRoute names
(Gitea #114, group 3) and writes down that reordering the list therefore
recreates rather than edits the objects - "harmless (a route holds no state or
certificate)". These objects DO hold a certificate: cert-manager owns the
Certificate from the Ingress, so a rename deletes one Certificate and creates
another. The Secret survives that - cert-manager does not delete it with the
Certificate - so no re-issue follows and nothing breaks, but it is churn on an
ACME account in return for a reordered list, and the issuer is a stable key
that costs nothing to use instead.
The issuer-less group takes the unsuffixed name, and that cannot collide with
an issuer's: a clusterIssuer name is never the empty string, so no suffix is
ever empty.
*/}}
{{- define "stalwart.mtaSts.ingressName" -}}
{{- $base := printf "%s-mta-sts" (include "stalwart.fullname" .root) -}}
{{- if .issuer -}}
{{- printf "%s-%s-ingress" $base .issuer -}}
{{- else -}}
{{- printf "%s-ingress" $base -}}
{{- end -}}
{{- end -}}