2 Commits
Author SHA1 Message Date
jklein 9f9620431f fix(stalwart): let the mode conflict, not the MTA-STS entry, name the defect
Validate Helm Charts / Detect changed charts (pull_request) Successful in 6s
Validate Helm Charts / Resource quantities (pull_request) Successful in 17s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 15s
Validate Helm Charts / Build stalwart (pull_request) Successful in 18s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 23s
Install-test / Install-test stalwart (pull_request) Successful in 1m55s
Validate Helm Charts / Install-test (pull_request) Successful in 1m55s
With BOTH routing modes on and an mtaSts entry missing its tlsSecretName,
the render reported

  mtaSts.hosts[0] (mta-sts.example.com) has no tlsSecretName, and in
  Ingress mode every entry needs one

which is true and is the wrong diagnosis: the values file's defect is that
ingress.enabled and httpRoute.enabled are both true, and "in Ingress mode"
is a claim about a configuration that is not coherent at all. Whoever met
it would add a tlsSecretName, re-run, and reach the real message one round
later - or conclude the MTA-STS feature was the problem.

Measured on this chart, not reasoned about. And it is the second occurrence
of a defect the parallel keycloak round of Gitea #122 found on its own
realm list, independently and in the same round, which is what makes it a
rule rather than one chart's story: a specific validation must not be
reachable in a state that a structural validation already rejects.

Ordering is not the fix and could not be - Helm renders templates in an
order a chart cannot pin, so this template may not assume the httproute
template's abort runs first. Scope is: the Ingress-mode MTA-STS block is
now gated on ingress.enabled AND NOT httpRoute.enabled, so in the
incoherent combination it validates nothing and the only fail left is the
one that names the real problem.

The per-entry guard is NOT dead by this - verified reachable with
ingress.enabled alone. Defaults, the unchanged CI fixture and the overlay
all still render byte-identically against origin/main, and the HTTPRoute
render test still holds 58 assertions over 21 charts.
2026-10-13 10:22:10 +00:00
jklein a54bceee23 feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 17s
Validate Helm Charts / Build stalwart (pull_request) Successful in 8s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 22s
Validate Helm Charts / Selector match (pull_request) Successful in 16s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Install-test / Install-test stalwart (pull_request) Successful in 1m44s
Validate Helm Charts / Install-test (pull_request) Successful in 1m44s
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
2026-10-13 10:16:53 +00:00
10 changed files with 696 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -181,7 +181,7 @@ Three things to know before touching it:
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable; that claim is simply not made anywhere in this pipeline. *That any redirect is ever served* — the pairing check proves the rendered **object** carries a `RequestRedirect` rule on the bare prefix and nothing more: whether an implementation honours the `statusCode` or declines it and fails the whole route is outside what a render can see. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart, and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule with no `backendRefs` at all (legal for a filter-only rule) — all three are **reported as "not checked", never counted as passing**. The redirect rule the pairing check demands is itself backend-less, so a chart that satisfies that check also earns one "not checked" line; both verdicts are correct at once.
- **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/<chart>.yaml` is a render-only overlay applied **last** whose one job is to put a chart into Gateway mode — and a chart that *has* one and still renders no route **fails**. On top of that the run fails if **no assertion at all** held anywhere. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode, see below), every other converted chart through its overlay. For a multi-app chart the overlay also has to enter every shape the template has: `ci/httproute/freesailarr.yaml` moves one app onto a host of its own so that the shared-host route *and* the per-app route are both rendered.
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. **Group 3 takes it to `56` assertions over `21` charts**, which is the sweep finished except for the frozen `rss-bridge`. Group 3's 19 come from six charts and three of them contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay is the only one in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit.
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. **Group 3 takes it to `56` assertions over `21` charts**, which is the sweep finished except for the frozen `rss-bridge`. Gitea #123 then takes it to **`58` over `21`**: `stalwart` grows a second route for its MTA-STS policy hosts, so the assertion count moves while the chart count does not — a converted chart gaining an object is the ordinary way this figure grows from here on, and the overlay has to enter the new shape for it to count (`ci/httproute/stalwart.yaml` carries an entry with a `clusterIssuer`, one without, and one on a subdomain). Group 3's 19 come from six charts and three of them contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay is the only one in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit.
### Install-test (PR pipeline stage 2)
+36
View File
@@ -51,6 +51,26 @@
# go through the route; the mail Certificate that serves them keeps being
# rendered in this mode, which is this chart's distinguishing feature in
# the sweep, and no render test looks at whether it was left alone.
# - THE MTA-STS ROUTE (Gitea #123), which is the second HTTPRoute this chart
# renders. Its backendRef is held against the same per-group http Service
# by the same port number, so the one place information travels from the
# Service object into a route template is checked twice rather than once.
# What the list below is shaped for is the three cases the entry form has:
# an entry WITH a clusterIssuer, an entry WITHOUT one, and a host on a
# SUBDOMAIN - the last because mta-sts.sub.<domain> is one label deeper
# than *.<domain> and a parent wildcard does not cover it.
#
# Both TLS fields are INERT IN THIS MODE: TLS sits on the Gateway's
# listener and this chart renders no certificate for an HTTP hostname
# here. They are set anyway, and that is the assertion - the render has to
# ACCEPT them in Gateway mode without requiring them, because the mirror
# image (tlsSecretName required in Ingress mode) is what makes them
# mode-specific rather than optional-everywhere.
#
# The Ingress half of this feature is NOT exercised here - this overlay's
# whole job is to switch the mode off. It is exercised by
# ci/stalwart/test-values.yaml instead, which installs in Ingress mode and
# therefore has the MTA-STS Ingress objects accepted by a real API server.
ingress:
enabled: false
httpRoute:
@@ -59,3 +79,19 @@ httpRoute:
- name: public
namespace: gateway
sectionName: https
mtaSts:
hosts:
- host: mta-sts.ci.example.com
tlsSecretName: tls-mta-sts-ci-example-com-ingress
clusterIssuer: ci-selfsigned
# One label deeper: its own secret, because the parent's wildcard would not
# reach it. In this mode that is the Gateway listener's problem, which is
# exactly why the entry is here and why the values are inert.
- host: mta-sts.sub.ci.example.com
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
clusterIssuer: ci-selfsigned
# No clusterIssuer: "the certificate comes from somewhere else". Nothing in
# this mode reads it either way; the point is that the render accepts the
# entry without it.
- host: mta-sts.other.ci.example
tlsSecretName: tls-some-existing-wildcard-ingress
+31
View File
@@ -83,6 +83,37 @@ ingress:
clusterIssuer: ci-selfsigned
nodeGroup: full
# MTA-STS policy routes (Gitea #123). Set HERE and not only in the render
# overlay, because this is the ONLY stage that puts the INGRESS half of the
# feature in front of a real API server: ci/httproute/stalwart.yaml switches
# the chart into Gateway mode, so it never renders these objects at all.
#
# WHAT THIS ADDS OVER THE RENDER TEST: that two Ingress objects whose names
# differ only by the issuer suffix are both accepted, that a `pathType: Exact`
# on `/.well-known/mta-sts.txt` is valid, and that a tls: list grouping several
# hosts under one secret is accepted as written. None of that is a template
# question any more once the API server has taken it.
#
# THE TWO ENTRIES ARE TWO OBJECTS, and that is the point of the pair: the first
# carries a clusterIssuer and gets the cert-manager annotation, the second has
# none and gets no annotation - which cannot be one object, because the
# annotation is object-scoped. The second is also a SUBDOMAIN, one label deeper
# than the first, carrying the case a parent wildcard certificate would not
# cover.
#
# NOTHING ISSUES EITHER CERTIFICATE here: cert-manager is not installed in the
# k3d cluster, so the annotation is inert and the named Secrets never exist.
# That is the same deliberate trade as certificate: below - the objects are
# asserted, the issuance is not, and no Secret is needed for the install to
# reach Ready because nothing mounts these.
mtaSts:
hosts:
- host: mta-sts.ci.example.com
tlsSecretName: tls-mta-sts-ci-example-com-ingress
clusterIssuer: ci-selfsigned
- host: mta-sts.sub.ci.example.com
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
certificate:
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
# which is the interesting case: the volume is mounted optional, so the pods
+11 -8
View File
@@ -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:
+77
View File
@@ -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 }}
+165
View File
@@ -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 }}
+104
View File
@@ -63,3 +63,107 @@ 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.
AND "not httpRoute.enabled" IS PART OF THAT GATE RATHER THAN REDUNDANT WITH
IT. Without it this block's per-entry required() can fire while BOTH modes are
on, and then the render reports "mtaSts.hosts[0] has no tlsSecretName, and in
Ingress mode every entry needs one" - true, and the wrong diagnosis: the
values file's defect is that both modes are on, and "in Ingress mode" is a
claim about a configuration that is not coherent at all. Whoever met it would
add a tlsSecretName, re-run, and reach the real message one round later, or
conclude this feature was the problem. MEASURED on this chart before the gate
was added, not reasoned about.
Ordering is not the fix and could not be: Helm renders templates in an order a
chart cannot pin, so this template may not assume the httproute template's
structural abort runs first. SCOPE is the fix - with both modes on this block
validates nothing, so the only fail left is the one that names the real
problem. The generalisation (a specific validation must not be reachable in a
state a structural validation already rejects) is the Lessons-Learned entry
that the parallel keycloak round of Gitea #122 bought on its own realm list.
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).
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict }}
{{- if and .Values.ingress.enabled (not $httpRoute.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 }}
+33
View File
@@ -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",
+155
View File
@@ -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
# ---------------------------------------------------------------------------