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
+11
-8
@@ -18,14 +18,17 @@ type: application
|
||||
# all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds
|
||||
# apart, so either floating name would change the running image with no commit
|
||||
# in this repository and nothing to report it.
|
||||
# MINOR, not major, and the evidence is a diff rather than a judgement: the
|
||||
# per-group spread constraint and the per-group PodDisruptionBudget of Gitea #96
|
||||
# are both gated on the COMPUTED replica count being >= 2, and the chart ships
|
||||
# full: 1 / mx: 0. `helm template` against the previous version therefore renders
|
||||
# byte-for-byte identically for the defaults AND for ci/stalwart/test-values.yaml
|
||||
# - measured, both empty diffs. New optional values with unchanged defaults is
|
||||
# exactly the minor case (README, "Versioning").
|
||||
version: 1.2.0+upv0.16.25
|
||||
# MINOR, not major, and the evidence is a diff rather than a judgement. That
|
||||
# held for the per-group spread constraint and PodDisruptionBudget of Gitea #96
|
||||
# (both gated on the COMPUTED replica count being >= 2, and the chart ships
|
||||
# full: 1 / mx: 0) and it holds again for the MTA-STS routes of Gitea #123: the
|
||||
# list is the switch and it ships EMPTY, so no object is added and no value
|
||||
# becomes required in either routing mode. `helm template` against the previous
|
||||
# version renders byte-for-byte identically for the defaults AND for
|
||||
# ci/stalwart/test-values.yaml as it stands on origin/main - measured, both
|
||||
# empty diffs. New optional values with unchanged defaults is exactly the minor
|
||||
# case (README, "Versioning").
|
||||
version: 1.3.0+upv0.16.25
|
||||
appVersion: "v0.16.25"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 -}}
|
||||
@@ -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 }}
|
||||
@@ -63,3 +63,88 @@ spec:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ include "stalwart.fullname" . }}-ingress
|
||||
{{- end }}
|
||||
{{- /*
|
||||
MTA-STS (Gitea #123) - the policy route, in Ingress mode.
|
||||
|
||||
ONE OBJECT PER clusterIssuer, one rule per host, tls grouped by secret. The
|
||||
whole reasoning for that shape, including the cert-manager measurement it rests
|
||||
on, is in stalwart.mtaSts.ingressGroups in _helpers.tpl; the short version is
|
||||
that the issuer is an object-level ANNOTATION while the secret is a per-tls-entry
|
||||
field, so an object cannot both ask cert-manager for one certificate and leave
|
||||
another alone.
|
||||
|
||||
GATED ON ingress.enabled, not on a switch of its own. mtaSts.hosts is the
|
||||
switch for the feature (empty list = nothing rendered, in either mode) and
|
||||
ingress.enabled is the switch for the MODE - so these objects appear exactly
|
||||
when this chart is publishing over an Ingress AND the list is non-empty. The
|
||||
mutual exclusion of the two modes is enforced once, in stalwart.httproute.yaml,
|
||||
and it is unconditional on mtaSts: a fail anywhere aborts the whole render.
|
||||
|
||||
WHAT THIS DELIBERATELY DOES NOT CARRY: ingress.annotations. Those belong to the
|
||||
WebUI/JMAP host - on the live installation they are its CORS configuration for
|
||||
the bulwark webmail - and a policy file served to sending MTAs has no use for
|
||||
them. Copying them here would put a CORS allow-origin on an endpoint no browser
|
||||
ever calls, and would do it silently.
|
||||
|
||||
AND THE LIMIT THAT NO OBJECT HERE CAN FIX: this chart registers a ROUTE, it
|
||||
does not create a DOMAIN. Whether Stalwart answers for mta-sts.<domain> depends
|
||||
on <domain> existing in its data store, which is not in any chart value (see
|
||||
values.yaml, line 1 onwards, and the mtaSts block).
|
||||
*/ -}}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- $mtaSts := fromYaml (include "stalwart.mtaSts.ingressGroups" .) }}
|
||||
{{- if $mtaSts.order }}
|
||||
{{- $group := include "stalwart.ingressNodeGroup" . }}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) }}
|
||||
{{- $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 Ingress to point at" $group $group) }}
|
||||
{{- end }}
|
||||
{{- $service := printf "%s-%s-http-service" (include "stalwart.fullname" .) $group }}
|
||||
{{- $path := include "stalwart.mtaSts.path" . }}
|
||||
{{- range $issuer := $mtaSts.order }}
|
||||
{{- $entry := index $mtaSts.groups $issuer }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "stalwart.mtaSts.ingressName" (dict "root" $ "issuer" $issuer) }}
|
||||
labels:
|
||||
app: {{ include "stalwart.fullname" $ }}
|
||||
{{- if $issuer }}
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ $issuer }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ $.Values.ingress.className }}
|
||||
rules:
|
||||
{{- range $host := $entry.hosts }}
|
||||
- host: {{ $host }}
|
||||
http:
|
||||
paths:
|
||||
{{- /*
|
||||
Exact, not Prefix, and the same per-group http Service the WebUI
|
||||
rule above names - by PORT NAME, which an Ingress backend can do
|
||||
and a Gateway backendRef cannot. RFC 8461 serves exactly one file
|
||||
at exactly one path; a Prefix here would hand this host's whole
|
||||
URL space to the mail server's management surface.
|
||||
*/}}
|
||||
- backend:
|
||||
service:
|
||||
name: {{ $service }}
|
||||
port:
|
||||
name: http
|
||||
path: {{ $path }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
tls:
|
||||
{{- range $secret := $entry.tlsOrder }}
|
||||
- hosts:
|
||||
{{- range $host := index $entry.tls $secret }}
|
||||
- {{ $host }}
|
||||
{{- end }}
|
||||
secretName: {{ $secret }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -433,6 +433,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mtaSts": {
|
||||
"description": "MTA-STS policy routes (Gitea #123), rendered in BOTH routing modes. THE LIST IS THE SWITCH - there is no mtaSts.enabled, because 'enabled with an empty list' would be a state with no meaning. Empty (the default) renders nothing and makes no value required. Ingress mode renders ONE Ingress PER DISTINCT clusterIssuer, each with one rule per host and its tls: list grouped by secret, because the cert-manager cluster-issuer is an object-level ANNOTATION while secretName is a per-tls-entry field - so one object cannot both request a certificate and leave another host's secret alone. Gateway mode renders ONE HTTPRoute with all hostnames, since nothing differs per host there. The path is NOT a value: RFC 8461 fixes it at /.well-known/mta-sts.txt with pathType Exact. THE LIMIT: this chart 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 yields a 404 at a correctly routed endpoint, and MTA-STS failures are silent on the SENDER's side. Also not done here: the _mta-sts.<domain> TXT record, and the policy's content. See values.yaml, mtaSts block.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"hosts": {
|
||||
"description": "One entry per policy hostname. Validated in the template rather than here where JSON Schema cannot reach: a repeated host aborts the render (an error in both modes), and tlsSecretName is required in INGRESS MODE ONLY - in Gateway mode TLS sits on the Gateway's listener, so requiring it there would demand a value that does nothing.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/mtaStsHost" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.",
|
||||
"type": ["object", "null"],
|
||||
@@ -478,6 +490,27 @@
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"mtaStsHost": {
|
||||
"description": "One MTA-STS policy hostname. Closed against unknown keys: the three fields are the whole surface, and the path is not among them because RFC 8461 fixes it. 'host' is required here AND in the template - the schema catches the missing key, the template catches a null value and a repeated host, which the schema cannot see across entries.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["host"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"description": "The policy hostname, i.e. mta-sts.<domain>. Mind that mta-sts.sub.domain.tld is one label DEEPER than *.domain.tld, so a wildcard certificate for the parent does not cover it (RFC 6125) - which is what tlsSecretName per entry is for, and in Gateway mode what the Gateway's listener has to account for. The symptom of getting it wrong is a TLS error at the SENDING MTA, logged nowhere in this cluster.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tlsSecretName": {
|
||||
"description": "The Secret the certificate for this host lives in. REQUIRED IN INGRESS MODE, enforced by required() in the template rather than here, because in Gateway mode TLS sits on the Gateway's listener and the field is inert - a schema 'required' would demand it in a mode where it does nothing. NO DEFAULT on purpose: the plausible one, this release's own tls-<release>-<chart>-ingress, covers ingress.domain and not this host. Hosts sharing one secret are collected into a single tls: entry, so one certificate with several SANs is requested once instead of once per host.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"description": "Optional, and EMPTY MEANS NO ANNOTATION: the certificate for this host comes from elsewhere and cert-manager is not asked for it - the same deliberate omission as the qBittorrent object in freesailarr and two of the four realm ingresses in Gitea #122. Entries that share a tlsSecretName must agree on this value and the render aborts naming both, because cert-manager's ingress-shim names the Certificate after the secret and owns it from the Ingress, while the issuer itself is an object-level annotation. Inert in Gateway mode.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"exposedPorts": {
|
||||
"description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.",
|
||||
"type": "array",
|
||||
|
||||
@@ -756,6 +756,161 @@ httpRoute:
|
||||
# hostname is reachable without the application agreeing that it is its name.
|
||||
hostnames: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MTA-STS — the policy route, in BOTH routing modes (Gitea #123)
|
||||
# ---------------------------------------------------------------------------
|
||||
# MTA-STS lets a sending mail server find out, over HTTPS, that this domain
|
||||
# expects TLS. Stalwart serves the policy file itself — this block is only
|
||||
# about ROUTING a sending MTA to it, and the route has to exist in whichever
|
||||
# mode this chart is publishing in.
|
||||
#
|
||||
# WHAT IS RENDERED, AND IT IS NOT THE SAME SHAPE ON BOTH SIDES:
|
||||
#
|
||||
# Ingress mode — ONE Ingress object PER DISTINCT clusterIssuer, each with one
|
||||
# rule per host and its tls: list grouped by secret name.
|
||||
# Several objects because cert-manager's cluster-issuer is an
|
||||
# object-level ANNOTATION while secretName is a per-tls-entry
|
||||
# field, so one object cannot both request a certificate from
|
||||
# issuer X and leave another host's secret alone. The full
|
||||
# argument, with the cert-manager behaviour it was measured
|
||||
# against, is in stalwart.mtaSts.ingressGroups in
|
||||
# _helpers.tpl.
|
||||
# Gateway mode — ONE HTTPRoute with all the hostnames. Nothing differs per
|
||||
# host here (same path, same backend, no filter) and
|
||||
# spec.hostnames is a list, so one object says exactly what
|
||||
# is meant. TLS sits on the Gateway's listener and this chart
|
||||
# renders no certificate for it.
|
||||
#
|
||||
# Both point at the same per-group http Service the WebUI route points at
|
||||
# (ingress.nodeGroup), on the one path RFC 8461 allows.
|
||||
#
|
||||
# THE LIST IS THE SWITCH. There is deliberately no mtaSts.enabled: "enabled
|
||||
# with an empty list" would be a state with no meaning that the chart would
|
||||
# then have to catch. Empty list — the default — renders nothing in either
|
||||
# mode, so this block changes nothing about a running installation until it is
|
||||
# filled.
|
||||
#
|
||||
# THE PATH IS HARDWIRED and is not a value. RFC 8461 fixes it: the policy is
|
||||
# fetched from /.well-known/mta-sts.txt on mta-sts.<domain>, with pathType
|
||||
# Exact (type: Exact in the HTTPRoute) because exactly one file lives there. A
|
||||
# values key for a protocol-mandated path is a key that can only ever be set
|
||||
# wrong — and "wrong" here is invisible from this side, see the limit below.
|
||||
# Exact rather than a prefix also matters in its own right: a prefix on these
|
||||
# hostnames would publish the mail server's whole management surface under
|
||||
# them.
|
||||
#
|
||||
# ------------------------------------------------------------------------
|
||||
# THE LIMIT, AND IT HAS TO BE SAID RATHER THAN SUGGESTED AWAY
|
||||
# ------------------------------------------------------------------------
|
||||
# THIS CHART REGISTERS A ROUTE. IT DOES NOT CREATE A DOMAIN. Read the first
|
||||
# paragraph of this file again: since v0.16 the domains live in Stalwart's DATA
|
||||
# STORE, and this chart never sees them. So whether the server answers for
|
||||
# mta-sts.example.com depends on example.com existing in that data store, which
|
||||
# no value here can arrange.
|
||||
#
|
||||
# An entry without its domain in the data store gives a 404 AT A CORRECTLY
|
||||
# ROUTED ENDPOINT — every Kubernetes object green, the Ingress or route
|
||||
# resolving perfectly, and nothing wrong on this side at all. And the other
|
||||
# half of it is worse: MTA-STS failures are silent ON THE SENDER'S SIDE. A
|
||||
# sending MTA that finds no policy does not report anything here; it simply
|
||||
# falls back to delivering in the clear. So the thing this block exists to
|
||||
# achieve can be absent with no symptom in this cluster whatsoever.
|
||||
#
|
||||
# That is the same genus as the OnePasswordItem limit this chart already
|
||||
# documents: a promise the chart cannot keep, and therefore one it has to name.
|
||||
# Check an entry from outside after adding it:
|
||||
# curl https://mta-sts.<domain>/.well-known/mta-sts.txt
|
||||
#
|
||||
# ------------------------------------------------------------------------
|
||||
# THE SUBDOMAIN TRAP, which is the one that will actually catch somebody
|
||||
# ------------------------------------------------------------------------
|
||||
# mta-sts.sub.example.com is ONE LABEL DEEPER than *.example.com, and a
|
||||
# wildcard certificate matches exactly one label (RFC 6125). So a wildcard for
|
||||
# the parent domain does NOT cover it, and the obvious assumption — "the
|
||||
# wildcard already covers that" — is false.
|
||||
#
|
||||
# With a secret per entry that is solved, which is why the field exists. The
|
||||
# trap is written down anyway because the symptom is a TLS error AT THE SENDING
|
||||
# MTA: nothing in this cluster logs it, the endpoint looks healthy from here,
|
||||
# and mail keeps flowing unencrypted. In Gateway mode the same trap moves to
|
||||
# the Gateway's listener, where this chart cannot help at all — a wildcard
|
||||
# listener for the parent domain will not terminate TLS for a deeper name.
|
||||
#
|
||||
# ------------------------------------------------------------------------
|
||||
# WHY A SECRET PER ENTRY AND NOT certificate.extraDnsNames
|
||||
# ------------------------------------------------------------------------
|
||||
# The alternative was real and was rejected. This chart already has
|
||||
# "Additional DNS names for the mail certificate" (certificate.extraDnsNames,
|
||||
# further up this file), so the mta-sts.* names could have been folded into the
|
||||
# mail certificate instead of carrying their own secrets.
|
||||
#
|
||||
# They are not, because that would hang the MAIL certificate on this list. The
|
||||
# mail certificate is what serves STARTTLS on 25 and implicit TLS on 465/993;
|
||||
# a bad entry here — a typo, a name whose DNS-01 challenge cannot be answered —
|
||||
# would then fail the issuance of the certificate the mail ports depend on.
|
||||
# A policy route that is only a nice-to-have would be able to take SMTP and
|
||||
# IMAP with it. Separate secrets keep the blast radius at the entry.
|
||||
#
|
||||
# ------------------------------------------------------------------------
|
||||
# NOT DONE BY THIS CHART, so it is not mistaken for done
|
||||
# ------------------------------------------------------------------------
|
||||
# - THE DNS RECORD. MTA-STS also needs a TXT record at _mta-sts.<domain>;
|
||||
# without it no sending MTA ever looks for the policy. That is DNS, not
|
||||
# Kubernetes, and nothing here creates it.
|
||||
# - THE POLICY'S CONTENT. version/mode/max_age/mx come from Stalwart's data
|
||||
# store, per domain. The running installation serves mode: testing, which
|
||||
# means sending MTAs enforce nothing — also a data-store setting and not a
|
||||
# chart question.
|
||||
# - REMOVING AN EXISTING OBJECT FROM ANOTHER REPOSITORY. Where a route for
|
||||
# one of these hosts already exists elsewhere (jk-cluster-config does serve
|
||||
# mta-sts.bekemeyer-klein.de from a Fleet release of its own), the order is
|
||||
# REMOVE THERE FIRST, then add the entry here. The other way round,
|
||||
# ingress-nginx's admission webhook rejects the second object on the same
|
||||
# host and path — which is the loud failure, but it is a failure.
|
||||
mtaSts:
|
||||
# Empty by default: no object in either mode, and no value becomes required.
|
||||
#
|
||||
# Entry form, and the two TLS fields apply ONLY IN INGRESS MODE:
|
||||
#
|
||||
# host REQUIRED. The policy hostname, i.e. mta-sts.<domain>.
|
||||
# tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose:
|
||||
# the plausible default (this release's own
|
||||
# tls-<release>-<chart>-ingress) is the wrong certificate —
|
||||
# it covers ingress.domain, not this host. Hosts that share
|
||||
# one secret are collected into one tls: entry, so one
|
||||
# certificate with several SANs is asked for once rather
|
||||
# than once per host. Inert in Gateway mode.
|
||||
# clusterIssuer OPTIONAL, and EMPTY MEANS NO ANNOTATION — i.e. "the
|
||||
# certificate for this host comes from somewhere else, do
|
||||
# not ask cert-manager for it". That is what keeps a secret
|
||||
# owned by another release from being fought over, the same
|
||||
# deliberate omission as the qBittorrent object in
|
||||
# freesailarr and two of the four realm ingresses in Gitea
|
||||
# #122. Entries sharing a secret must agree on it (the
|
||||
# render aborts naming both), because cert-manager names the
|
||||
# Certificate after the secret and the issuer is an
|
||||
# object-level annotation. Inert in Gateway mode.
|
||||
#
|
||||
# The chart's own ingress.annotations are NOT copied onto these objects: they
|
||||
# belong to the WebUI/JMAP host (on this installation they are its CORS
|
||||
# configuration), and a policy file served to sending MTAs has no use for
|
||||
# them.
|
||||
#
|
||||
# hosts:
|
||||
# - host: mta-sts.example.com
|
||||
# tlsSecretName: tls-mta-sts-example-com-ingress
|
||||
# clusterIssuer: letsencrypt-issuer
|
||||
# # A deeper name needs its own certificate — the parent's wildcard does
|
||||
# # not reach it. See the subdomain trap above.
|
||||
# - host: mta-sts.sub.example.com
|
||||
# tlsSecretName: tls-mta-sts-sub-example-com-ingress
|
||||
# clusterIssuer: letsencrypt-issuer
|
||||
# # Certificate managed elsewhere: no issuer, so no annotation and no
|
||||
# # Certificate requested by this release.
|
||||
# - host: mta-sts.other.example
|
||||
# tlsSecretName: tls-some-existing-wildcard-ingress
|
||||
hosts: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardening — chart-wide, applied to every node group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user