5 Commits
Author SHA1 Message Date
jlab-renovate-bot 9251872947 Update dependency kubernetes/kubernetes to v1.37.1
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Build ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 17s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 15s
2026-10-13 11:35:36 +00:00
jklein 978e6fbaa8 refactor(stalwart): name the MTA-STS Ingress objects by index, per the corrected rule
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 29s
Install-test / Install-test stalwart (pull_request) Successful in 1m42s
Validate Helm Charts / Install-test (pull_request) Successful in 1m42s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 13s
SonarQube Analysis / SonarQube Analysis (push) Successful in 50s
Maintainer's decision, and it reverses this chart's naming rather than the
style guide's rule. The objects become

  <release>-stalwart-mta-sts-ingress, -1, -2, ...

in this repo's idiom ({{ if $i }}-{{ $i }}{{ end }}), the same as
homeassistant's routes and keycloak's realm Ingresses, inlined in the
template instead of behind a helper of its own.

WHY IT GOES THIS WAY. This chart named by issuer, reading the style guide's
index rule as resting on "a route holds no state and no certificate" and
observing that these objects hold one. That reading was right and the
rule's STATED REASON was wrong: the parallel #122 round named by index,
cited the same rule as binding, and entered keycloak into it - whose realm
Ingresses hold certificates too. Two charts did opposite things for one
shape, and the rule had been extended onto cases its own precondition
excluded. #125 is merged and published, so the reason is repaired and the
naming unified on index; keycloak is not touched.

What actually carries the index rule, in three steps that have different
standing and are written as such in the template header and the style guide:
cert-manager here runs WITHOUT --enable-certificate-owner-ref (MEASURED by
the maintainer - absent from the Deployment's args and from the
ControllerConfiguration its --config names, which carries only
kind: ControllerConfiguration and enableGatewayAPI: true); by that default
the Certificate is collected with its Ingress but the SECRET IS NOT
(DOCUMENTED behaviour, not measured here), so a rename recreates the
Certificate object against a surviving secret and issues nothing; and NO
RENAME WAS APPLIED against a cluster. The promise therefore hangs on a
cluster-level flag this repository does not control - with it on, a reorder
would cost a fresh ACME request per renumbered object.

UNCHANGED: the structure. One Ingress per distinct clusterIssuer, one rule
per host, tls grouped by secret, and the abort when one secret spans two
issuers - grouping is a cert-manager question and naming is a style
question, and they are independent.

TWO THINGS THE RENAME OBLIGES.

1. NOTES.txt now prints the mapping - index, object name, issuer, secrets
   and hosts per object, the way keycloak's does for its realms - because
   the name no longer carries the issuer and nothing else in the cluster
   says which object holds which.
2. The reordering hazard is documented as SHARPER than homeassistant's:
   there an index names a host, here it names an issuer GROUP derived from
   the list, so moving one entry can change the grouping and renumber
   objects that were not themselves moved.

AN ASYMMETRY WITH keycloak, named in the template header: there both modes
render one object per entry, so the index question arises twice. Here it
arises on the Ingress side ONLY - Gateway mode renders one HTTPRoute
carrying every hostname, because TLS is none of the route's business, so
there is no index and no name to go stale.

Version stays 1.3.0+upv0.16.25: nothing is published yet, so nothing is
being renamed in a release.

Re-measured after the rename, not assumed: three empty helm template diffs
against origin/main (defaults, CI fixture, overlay), helm lint --strict
clean three ways, every abort path still firing with the structural one
still winning over the per-entry one, the schema still rejecting
mtaSts.enabled, and the HTTPRoute render test still at 64 assertions over
22 charts - object names are not assertions.
2026-10-13 11:03:36 +00:00
jklein 71b757bf9f docs(README): the MTA-STS delta on top of both landed rounds, measured at 64/22
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 24s
Install-test / Install-test stalwart (pull_request) Successful in 1m36s
Validate Helm Charts / Install-test (pull_request) Successful in 1m36s
Second rebase of this branch, and the last one the parallel rounds force.
Both have now merged - rss-bridge plus its documentation corrections, and
the realm hostnames of #122 - so line 184 carries the chain
56/21 -> 58/22 -> 62/22 with the delta convention #124 introduced.

That chain is kept WORD FOR WORD and this round's delta is appended to it;
the only change to the line is an append, verified line by line against
origin/main rather than asserted.

THE NUMBER IS READ, NOT COMPUTED, and that distinction is this paragraph's
own rule. 62 + 2 = 64 is arithmetic over a branch that had a different
substructure when its 2 were measured; the figure committed here comes from
ci/run-httproute-match-test.sh on THIS branch, rebased onto the main that
already holds both other rounds: 64 assertions over 22 charts, 0 failed,
with stalwart contributing 4. It is therefore the first figure in that
sentence that is a genuine running total rather than a delta - the first run
that contains all three rounds, which is exactly where the convention says
to read it.

The earlier 58/21 and 60/22 in this branch's history were each correct for
the substructure they were measured on and are both superseded. The commit
that set 60/22 was dropped in this rebase rather than carried, since its
only content was a number that no longer holds.
2026-10-13 10:42:18 +00:00
jklein 771c26500c fix(stalwart): let the mode conflict, not the MTA-STS entry, name the defect
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:41:10 +00:00
jklein b4c9823e01 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
2026-10-13 10:41:10 +00:00
10 changed files with 784 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 took it to `56` assertions over `21` charts, from six charts of which three 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 was the first in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. **`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — read on this branch, which does **not** contain the realm-hostname round of Gitea #122 (measured separately at `60` over `21`, on a branch that did not contain `rss-bridge`). **Neither total includes the other, so the figure to trust from either is the delta**; the running total was therefore read off the first branch that contained both — the realm-hostname branch rebased onto this one — and it is **`62` assertions over `22` charts**, `0` failed. `keycloak` contributes `8` of them: one `parentRefs` and one `backendRefs` assertion for each of its four routes, the two realm routes adding one "not checked" line each for their backend-less redirect rule (`keycloak: ok (8 assertion(s) held, 2 not checked)`). The #118 prefix/redirect pairing deliberately does **not** fire on those two: it is scoped to rules that strip a prefix with a `ReplacePrefixMatch` rewrite, and a realm route strips nothing — it redirects the bare root of a hostname that serves one realm. The delta here is one route, so two assertions, and its overlay is the **second** that has to supply hostnames — for a different reason than `homeassistant`'s, which is worth keeping apart. `homeassistant` has no `httpRoute.hostnames` key at all and its overlay supplies the names through `ingress.hosts`; `rss-bridge` *has* the key and its overlay fills it, because this chart has no `ingress.domain` to fall back to either but its hostnames are unambiguous (one Service, one path).
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 took it to `56` assertions over `21` charts, from six charts of which three 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 was the first in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. **`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — read on this branch, which does **not** contain the realm-hostname round of Gitea #122 (measured separately at `60` over `21`, on a branch that did not contain `rss-bridge`). **Neither total includes the other, so the figure to trust from either is the delta**; the running total was therefore read off the first branch that contained both — the realm-hostname branch rebased onto this one — and it is **`62` assertions over `22` charts**, `0` failed. `keycloak` contributes `8` of them: one `parentRefs` and one `backendRefs` assertion for each of its four routes, the two realm routes adding one "not checked" line each for their backend-less redirect rule (`keycloak: ok (8 assertion(s) held, 2 not checked)`). The #118 prefix/redirect pairing deliberately does **not** fire on those two: it is scoped to rules that strip a prefix with a `ReplacePrefixMatch` rewrite, and a realm route strips nothing — it redirects the bare root of a hostname that serves one realm. The delta here is one route, so two assertions, and its overlay is the **second** that has to supply hostnames — for a different reason than `homeassistant`'s, which is worth keeping apart. `homeassistant` has no `httpRoute.hostnames` key at all and its overlay supplies the names through `ingress.hosts`; `rss-bridge` *has* the key and its overlay fills it, because this chart has no `ingress.domain` to fall back to either but its hostnames are unambiguous (one Service, one path). **The MTA-STS routes of Gitea #123 add `2`**, and they are the entry that shows what this figure means once the sweep is over: `stalwart` goes from `2` assertions to `4` — one `parentRefs` and one `backendRefs` assertion for its new MTA-STS route — and **the chart count does not move at all**. Every earlier entry in this history grew because a chart *joined* the list; this is the first that grew because a chart already in it gained an **object**, which is the ordinary shape of the next one too. Following the same delta rule, and read off the first run that contains all three rounds — this branch, rebased onto a `main` that already held `rss-bridge` and the realm hostnames — the figure is **`64` assertions over `22` charts**, `0` failed. The delta counts only because the overlay was extended to enter the new shape: `ci/httproute/stalwart.yaml` carries an entry with a `clusterIssuer`, one without, and one host on a **subdomain**. An overlay left alone would have added a second route this test never rendered — the anti-vacuum rule biting on a *second* object inside a chart that already passes, which no per-chart "has an overlay" check can see. **The Ingress half of that feature is not covered here at all**, because an overlay's one job is to switch Gateway mode on; it sits in `ci/stalwart/test-values.yaml`, where the install-test has a real API server accept it.
**And in the same PR that chart's install-test went green, which is the event Gitea #51 was waiting for.** It had failed on the Gitea runner for months (uid 33 unable to read `/app/index.php`, startup probe 403 through its whole budget), and the chart was deliberately kept *out* of `ci/excluded-charts.txt` precisely so that the day it passed would be noticed rather than silenced. It passed: k3d cluster up, `helm install` in Ingress mode, pod `1/1 Ready` in 22 s with 0 restarts, and no `Permission denied` anywhere in the job. **The chart cannot be the reason, and that is measured rather than argued** — the rendered output for the fixture values is byte-identical to `origin/main`, so the pod spec that installed is exactly the one `main` produces. The implicated layer is the runner, which is where #51 had already put the burden of proof. Two cautions belong with the result: **nobody has identified what changed on the runner**, and a green test does not explain itself — it has reproduced on every run of that branch since, across several commits, so it is not a fluke - but that is still a colour and not a cause; and the install covers **Ingress** mode, since that is what the fixture installs — so this chart's Gateway mode stays render-only, exactly like its 21 siblings rather than worse than them.
+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:
+102
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,67 @@ 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 }}
{{- $mtaStsGroups := fromYaml (include "stalwart.mtaSts.ingressGroups" .) }}
MTA-STS — {{ len $mtaStsHosts }} POLICY HOST(S) IN {{ len $mtaStsGroups.order }} INGRESS OBJECT(S), INGRESS MODE
All of them → node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (pathType Exact)
ONE OBJECT PER CLUSTER ISSUER, NAMED BY INDEX — so the name does not say
which issuer it carries, and this is the mapping. Read it here rather than
guessing from the object names:
{{- range $i, $issuer := $mtaStsGroups.order }}
{{- $grp := index $mtaStsGroups.groups $issuer }}
{{ printf "%s-mta-sts-ingress%s" $release (ternary "" (printf "-%d" $i) (eq $i 0)) }}
cluster issuer: {{ if $issuer }}"{{ $issuer }}" — cert-manager requests these certificates{{ else }}NONE — no annotation, so this release requests nothing; every secret below has to exist already{{ end }}
{{- range $secret := $grp.tlsOrder }}
secret {{ $secret }}
{{- range $host := index $grp.tls $secret }}
https://{{ $host }}{{ $mtaStsPath }}
{{- end }}
{{- end }}
{{- end }}
Why one object per issuer rather than one for everything: the cert-manager
issuer is an object-level ANNOTATION while the TLS secret is a per-tls-entry
field, so a single 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 }}
AND MIND THE ORDER OF mtaSts.hosts. The index above runs over the issuer
GROUPS, which are derived from the list — so reordering entries can change
how many objects there are and which index carries which issuer, renumbering
objects that were not themselves moved. Kubernetes then deletes and recreates
them, which takes their Certificate objects with them. No certificate is
RE-ISSUED by that, because the Secrets survive it (cert-manager leaves them
alone unless it runs with --enable-certificate-owner-ref, which this cluster
does not set) — but that is a property of the CLUSTER, not of this chart. If
that flag is ever switched on, a reorder means a fresh ACME request per
renumbered object.
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 +480,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 +519,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 }}
+159
View File
@@ -556,3 +556,162 @@ 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 -}}
{{/*
NOTE ON THE OBJECT NAMES, because this is where an earlier draft of this chart
disagreed with the rest of the repository and lost.
The MTA-STS Ingress objects are named BY INDEX over the issuer groups -
"<release>-stalwart-mta-sts-ingress", then "-1", "-2" - inlined in
stalwart.ingress.yaml in the same idiom as homeassistant's routes and
keycloak's realm Ingresses ({{ if $i }}-{{ $i }}{{ end }}), rather than by a
helper of their own.
An earlier draft named them after the clusterIssuer, arguing that the style
guide's index rule rested on "a route holds no state and no certificate" and
that these objects hold one. The reading of the rule was right and the rule's
STATED REASON was wrong - that sentence has been corrected in the style guide
(section 4) rather than the naming of two charts, because keycloak's realm
Ingresses hold certificates too and are already published. What actually
carries the index rule is that THE SECRET SURVIVES a rename, so no certificate
is re-issued; the full three-step version, including which step is measured and
which is only documented behaviour, is in that section and in the header of
stalwart.ingress.yaml.
*/}}
@@ -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 }}
+154
View File
@@ -63,3 +63,157 @@ 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).
THE NAMES ARE INDEXED OVER THE ISSUER GROUPS, AND THE INDEX IS ONLY AN INGRESS
QUESTION
"<release>-stalwart-mta-sts-ingress", then "-1", "-2" - the repo's idiom
({{ if $i }}-{{ $i }}{{ end }}), as homeassistant's routes and keycloak's realm
Ingresses use it (style guide section 4). The index runs over the ISSUER
GROUPS, not over mtaSts.hosts entries: the grouping is a separate decision,
made for cert-manager's sake and argued in stalwart.mtaSts.ingressGroups, and
it is untouched by how the objects are named.
AN ASYMMETRY WITH keycloak WORTH KNOWING: there, both modes render one object
per entry, so the index question arises twice. HERE IT ARISES ONLY ON THIS
SIDE. Gateway mode renders exactly ONE HTTPRoute carrying every hostname,
because TLS is not involved there at all - no certificate, no secret, nothing
to split an object over - so there is no index and no name to go stale. The
whole of the naming question below is an Ingress-mode question.
THE REORDERING HAZARD IS SHARPER HERE THAN AT homeassistant, so read this
before shuffling mtaSts.hosts. There, an index names a host, and reordering
changes which index serves which host. Here an index names an ISSUER GROUP
derived from the list, so reordering can change THE GROUPING ITSELF - how many
objects exist, and which index carries which issuer and which hosts. Moving
one entry can therefore renumber objects that were not themselves moved. What
makes it bearable rather than a reason to avoid indices:
1. MEASURED (by the maintainer, in jk-cluster, on this round): cert-manager
runs WITHOUT --enable-certificate-owner-ref - the flag appears neither in
the cert-manager Deployment's args nor in the ControllerConfiguration its
--config points at (which carries only kind: ControllerConfiguration and
enableGatewayAPI: true). So the controller's default applies.
2. DOCUMENTED BEHAVIOUR of that default, not measured here: the Certificate
is garbage-collected with the Ingress it is owner-referenced from, but the
SECRET is not - cert-manager only adds an owner reference to the Secret
when that flag is on. A rename therefore deletes and recreates the
Certificate object while the Secret stays, so the new Certificate finds a
valid secret already present and ISSUES NOTHING. No ACME request, no rate
limit touched.
3. NOT APPLIED: no rename was actually performed against a cluster. The two
steps above are the mechanism, not a trial of it.
And note what the promise hangs on: a CLUSTER-LEVEL FLAG this repository does
not control. With --enable-certificate-owner-ref switched on, the Secret would
be collected with the Certificate and a reorder WOULD mean a re-issue for every
renumbered object. That is worth checking before anyone turns it on, and it is
not something a chart can defend itself against.
Because the name no longer carries the issuer, NOTES.txt prints the mapping -
index, issuer and hosts per object - the way keycloak's does for its realms.
Without it, nothing in the cluster says which object holds which issuer.
*/ -}}
{{- $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 $i, $issuer := $mtaSts.order }}
{{- $entry := index $mtaSts.groups $issuer }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: "{{ include "stalwart.fullname" $ }}-mta-sts-ingress{{ if $i }}-{{ $i }}{{ end }}"
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",
+174
View File
@@ -756,6 +756,180 @@ 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.
# Named BY INDEX over those groups —
# <release>-stalwart-mta-sts-ingress, then -1, -2 — in this
# repo's idiom (README/style guide section 4, as
# homeassistant's routes and keycloak's realm Ingresses do
# it). So the NAME DOES NOT SAY WHICH ISSUER it carries:
# NOTES.txt prints that mapping, and it is the only place the
# association is visible.
# REORDERING THIS LIST IS NOT FREE. The index runs over the
# issuer groups, which are derived from the list, so moving
# one entry can change how many objects exist and which index
# holds which issuer — renumbering objects that were not
# themselves moved. Kubernetes recreates them, which takes
# their Certificate objects along; nothing is RE-ISSUED,
# because the Secrets survive (cert-manager only
# owner-references a Secret with
# --enable-certificate-owner-ref, which this cluster does not
# set). That last part is a property of the CLUSTER and not
# of this chart: with that flag on, a reorder costs a fresh
# ACME request per renumbered object.
# 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
# ---------------------------------------------------------------------------