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
249 lines
14 KiB
YAML
249 lines
14 KiB
YAML
{{/*
|
|
HTTPRoute - the Gateway-API alternative to stalwart.ingress.yaml (Gitea #114).
|
|
|
|
ONE ROUTE, NOT ONE PER NODE GROUP, and that is worth saying because #114
|
|
describes this chart as rendering "per node group". Its SERVICES and
|
|
StatefulSets are per group; the routing object is not and must not be. The
|
|
Ingress targets exactly ONE group (ingress.nodeGroup) through that group's own
|
|
http Service, because the WebUI's setup and management screens are not safe to
|
|
spread over a group that may not run the management tasks. The route keeps that
|
|
property, including the render abort when the named group does not expose
|
|
"http".
|
|
|
|
WHAT STAYS IN THIS MODE, AND IT IS THE ONLY CHART IN THE SWEEP WHERE ANYTHING
|
|
DOES: the MAIL certificate. stalwart.certificate.yaml is not the Ingress
|
|
certificate - it covers STARTTLS on 25 and implicit TLS on 465/993, and those
|
|
connections never pass through an ingress controller or a Gateway. So
|
|
certificate.enabled keeps rendering a Certificate here, ingress.domain stays
|
|
required (the certificate's dnsNames read it) and ingress.clusterIssuer stays
|
|
required unless certificate.clusterIssuer is set. Do NOT "clean up" the
|
|
Certificate when switching to Gateway mode: SMTP and IMAP would fall back to
|
|
whatever self-signed certificate Stalwart generates, and clients would see a
|
|
name mismatch while every Kubernetes object stayed green.
|
|
|
|
WHAT THIS RENDERS, AND WHAT IT MUST NOT
|
|
|
|
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
|
Certificate FOR THE HTTP HOSTNAME, and no implementation-specific policy
|
|
object - the subset that looks identical on nginx-gateway-fabric, Cilium, Envoy
|
|
Gateway and Istio (decision 1 of #114). The HTTP hostname's certificate belongs
|
|
on the Gateway's listener; the mail certificate stays here, which is the
|
|
distinction above.
|
|
|
|
And the Gateway API routes HTTP only, so SMTP, IMAP and ManageSieve are as far
|
|
outside this object as they are outside the Ingress - they keep reaching the
|
|
pods through the per-port NodePort Services. A Gateway could in principle carry
|
|
them as TLSRoute/TCPRoute listeners, which this chart does not render and which
|
|
would be a separate decision.
|
|
|
|
WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND THIS IS THE ONE PLACE
|
|
IN GROUP 3 WHERE THE LOSS BREAKS A SECOND CHART OF THE SAME GROUP
|
|
|
|
On 2026-10-12 the live Ingress (jk-cluster, namespace stalwart,
|
|
mail.bekemeyer-klein.de) carried, all of it OPERATOR-supplied through
|
|
ingress.annotations - this chart sets none of it:
|
|
|
|
nginx.ingress.kubernetes.io/enable-cors: true
|
|
nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
|
|
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
|
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
|
|
|
That origin is BULWARK, the webmail chart in this same group. The CORS
|
|
configuration is the whole reason the browser-side webmail may call this
|
|
server's JMAP endpoint at all, so it is not decoration: without it the webmail
|
|
stops working, with a console error and no server-side symptom.
|
|
|
|
WHAT THE GATEWAY API HAS FOR IT, MEASURED AGAINST THE PINNED CRDs RATHER THAN
|
|
ASSUMED. Both halves exist in the API, and neither is promisable by this chart:
|
|
|
|
- A full CORS FILTER exists. In the standard channel this repository pins
|
|
(gateway-api v1.6.3, ci/gateway-api-crds.sh), HTTPRoute's filters carry a
|
|
`cors` field with allowOrigins, allowMethods, allowHeaders,
|
|
allowCredentials and maxAge - GEP-1767. Grepped out of the downloaded CRD,
|
|
not inferred. Its documented support level, verbatim from that CRD, is
|
|
"Support: Extended".
|
|
- responseHeaderModifier is in the same CRD and is ALSO "Support: Extended",
|
|
not Core.
|
|
|
|
So "no counterpart" would be the wrong thing to write down. The accurate
|
|
statement is that there is a counterpart and this chart still does not render
|
|
it, for three reasons, in order:
|
|
|
|
1. THE CHART HAS NO CORS VALUES AT ALL. The live configuration is the
|
|
operator's: a specific origin, a specific method list. Rendering a CORS
|
|
filter means inventing a values surface in a sweep PR - and one that
|
|
would exist in Gateway mode only, because this chart sets no CORS
|
|
annotations in Ingress mode either. That asymmetry is its own defect and
|
|
its own piece of work.
|
|
2. EXTENDED SUPPORT IS NOT SUPPORT. #119 established the shape of this: an
|
|
implementation that does not support an Extended value must report it in
|
|
the route's status, and a rejected route is rejected WHOLE - here that
|
|
would take the WebUI and JMAP down together. Worse, the observed failure
|
|
is quieter than that: Cilium accepts `filters.type: CORS` and still
|
|
forwards the browser's preflight upstream
|
|
(cilium/cilium#45272), i.e. the object is Accepted and the behaviour is
|
|
absent. A chart cannot check which implementation it landed on.
|
|
3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
|
|
ONE HERE. A responseHeaderModifier can add the three
|
|
Access-Control-Allow-* headers to a response, but the PREFLIGHT is a
|
|
different thing: the browser sends OPTIONS first and the gateway - not the
|
|
backend - has to answer it. The CORS filter's own contract says so (a path
|
|
with a CORS filter MUST NOT pass the preflight upstream), which is exactly
|
|
what a bare header modifier cannot do. And this installation's header list
|
|
includes Authorization, which makes EVERY webmail request preflighted: the
|
|
half that is expressible without the CORS filter is precisely the half
|
|
that does not help. Rendering it would produce a setup that looks
|
|
configured and fails on the first real call.
|
|
|
|
So: named, not rebuilt (decision 2 of #114), in the Gateway branch of
|
|
NOTES.txt, with the instruction to configure CORS at the Gateway - via that
|
|
implementation's CORS support - BEFORE moving this host, and to test it from
|
|
the webmail rather than with curl, because curl sends no preflight.
|
|
*/}}
|
|
{{- /*
|
|
Null-safe like every other block a template here dereferences (#99): an
|
|
override written without children - "httpRoute:" with its keys deleted - is
|
|
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
|
|
*/ -}}
|
|
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
|
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
|
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Stalwart there is a second edge to it: the server's auto-ban sees the proxy's address and bans it, so two data paths mean two addresses to get banned and a WebUI that fails for half the requests. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and note that the server hostname configured IN THE DATABASE decides where the WebUI tells administrators to go, which no chart value can change." -}}
|
|
{{- end -}}
|
|
{{- if $httpRoute.enabled -}}
|
|
{{- $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 HTTPRoute to point at. The value is shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress." $group $group) -}}
|
|
{{- end -}}
|
|
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
|
{{- if not $parentRefs -}}
|
|
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
|
{{- end -}}
|
|
{{- /*
|
|
One source of truth for the hostname: an empty httpRoute.hostnames means
|
|
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
|
|
read outside the routing templates in this chart - the mail Certificate's
|
|
dnsNames - so it already carries a required() there and is mandatory in every
|
|
mode, including with both modes off. This required() is the route's own, so the
|
|
message names the route.
|
|
*/ -}}
|
|
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
|
{{- if not $hostnames -}}
|
|
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
|
{{- end -}}
|
|
apiVersion: gateway.networking.k8s.io/v1
|
|
kind: HTTPRoute
|
|
metadata:
|
|
name: {{ include "stalwart.fullname" . }}-httproute
|
|
labels:
|
|
app: {{ include "stalwart.fullname" . }}
|
|
spec:
|
|
parentRefs:
|
|
{{- toYaml $parentRefs | nindent 4 }}
|
|
hostnames:
|
|
{{- toYaml $hostnames | nindent 4 }}
|
|
rules:
|
|
{{- /*
|
|
The Ingress counterpart is `path: / , pathType: Prefix` on the http
|
|
Service of the "{{ $group }}" group ONLY - never a port-label selector,
|
|
for the reason in the header. The port is the Service's port NUMBER and
|
|
not its name "http": backendRefs takes a PortNumber, so unlike the
|
|
Ingress this reference cannot be made by name. It comes from
|
|
ports.http.containerPort, which is the same value the Service renders its
|
|
port from, so the two cannot drift;
|
|
ci/run-httproute-match-test.sh holds the pair for the case where they do.
|
|
*/}}
|
|
- matches:
|
|
- path:
|
|
type: PathPrefix
|
|
value: /
|
|
backendRefs:
|
|
- 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 }}
|