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:
10 files changed
+677
-9
No files matched your search
@@ -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 -}}
|
||||
Reference in new issue
Block a user