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
@@ -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 }}
|
||||
|
||||
|
||||
Reference in new issue
Block a user