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