diff --git a/README.md b/README.md index e7e6f82..d389add 100644 --- a/README.md +++ b/README.md @@ -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/.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 is whatever the first run on `main` prints once both have landed, and it should be read there rather than added up by hand. 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 is whatever the first run on `main` prints once both have landed, and it should be read there rather than added up by hand. 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). **Gitea #123 then adds a second route to `stalwart`** — its MTA-STS policy hosts — for a delta of **two assertions and no new chart**, which is the first time this figure moved because a chart already in the list gained an *object* rather than because a chart joined it. Measured on a branch that **does** contain `rss-bridge` (it had landed by then) and does **not** contain the realm-hostname round of Gitea #122: `NNN` assertions over `MMM` charts — so by the delta rule above, that is the running total of everything merged at the time plus this round, and #122's four are still outstanding. Its overlay had to be extended or the second route would have been added without this test ever rendering it: `ci/httproute/stalwart.yaml` carries an entry with a `clusterIssuer`, one without, and one host on a subdomain. **The Ingress half of that feature is not covered here at all** — an overlay's one job is to switch Gateway mode on — so it sits in `ci/stalwart/test-values.yaml` instead, 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 reproduced across two consecutive runs on two different commits of that PR, which is two data points rather than one, but still no 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. diff --git a/ci/httproute/stalwart.yaml b/ci/httproute/stalwart.yaml index 4ee171f..0d892d5 100644 --- a/ci/httproute/stalwart.yaml +++ b/ci/httproute/stalwart.yaml @@ -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. is one label deeper +# than *. 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 diff --git a/ci/stalwart/test-values.yaml b/ci/stalwart/test-values.yaml index fc2e166..5bd8467 100644 --- a/ci/stalwart/test-values.yaml +++ b/ci/stalwart/test-values.yaml @@ -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 diff --git a/stalwart/Chart.yaml b/stalwart/Chart.yaml index 9d6b9bf..90a3137 100644 --- a/stalwart/Chart.yaml +++ b/stalwart/Chart.yaml @@ -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: diff --git a/stalwart/templates/NOTES.txt b/stalwart/templates/NOTES.txt index fcec407..377e691 100644 --- a/stalwart/templates/NOTES.txt +++ b/stalwart/templates/NOTES.txt @@ -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.; 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. is one label below + *., 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:///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. is one label below + *., 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. 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 }} diff --git a/stalwart/templates/_helpers.tpl b/stalwart/templates/_helpers.tpl index 89b05c9..5da0287 100644 --- a/stalwart/templates/_helpers.tpl +++ b/stalwart/templates/_helpers.tpl @@ -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.. 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." $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: -> dict with + hosts: list of hosts, one rule each + tlsOrder: list of TLS secret names, first-appearance order + tls: -> 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---ingress - is the WRONG certificate. It +covers ingress.domain and not mta-sts., 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---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" "issuer" . + +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 -}} diff --git a/stalwart/templates/stalwart.httproute.yaml b/stalwart/templates/stalwart.httproute.yaml index 27c1554..e2b1857 100644 --- a/stalwart/templates/stalwart.httproute.yaml +++ b/stalwart/templates/stalwart.httproute.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..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 }} diff --git a/stalwart/templates/stalwart.ingress.yaml b/stalwart/templates/stalwart.ingress.yaml index bb5a70c..3d198af 100644 --- a/stalwart/templates/stalwart.ingress.yaml +++ b/stalwart/templates/stalwart.ingress.yaml @@ -63,3 +63,88 @@ spec: - {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }} secretName: tls-{{ include "stalwart.fullname" . }}-ingress {{- end }} +{{- /* +MTA-STS (Gitea #123) - the policy route, in Ingress mode. + +ONE OBJECT PER clusterIssuer, one rule per host, tls grouped by secret. The +whole reasoning for that shape, including the cert-manager measurement it rests +on, is in stalwart.mtaSts.ingressGroups in _helpers.tpl; the short version is +that the issuer is an object-level ANNOTATION while the secret is a per-tls-entry +field, so an object cannot both ask cert-manager for one certificate and leave +another alone. + +GATED ON ingress.enabled, not on a switch of its own. mtaSts.hosts is the +switch for the feature (empty list = nothing rendered, in either mode) and +ingress.enabled is the switch for the MODE - so these objects appear exactly +when this chart is publishing over an Ingress AND the list is non-empty. The +mutual exclusion of the two modes is enforced once, in stalwart.httproute.yaml, +and it is unconditional on mtaSts: a fail anywhere aborts the whole render. + +WHAT THIS DELIBERATELY DOES NOT CARRY: ingress.annotations. Those belong to the +WebUI/JMAP host - on the live installation they are its CORS configuration for +the bulwark webmail - and a policy file served to sending MTAs has no use for +them. Copying them here would put a CORS allow-origin on an endpoint no browser +ever calls, and would do it silently. + +AND THE LIMIT THAT NO OBJECT HERE CAN FIX: this chart registers a ROUTE, it +does not create a DOMAIN. Whether Stalwart answers for mta-sts. depends +on existing in its data store, which is not in any chart value (see +values.yaml, line 1 onwards, and the mtaSts block). +*/ -}} +{{- if .Values.ingress.enabled }} +{{- $mtaSts := fromYaml (include "stalwart.mtaSts.ingressGroups" .) }} +{{- if $mtaSts.order }} +{{- $group := include "stalwart.ingressNodeGroup" . }} +{{- $nodes := fromYaml (include "stalwart.nodes" .) }} +{{- $spec := index $nodes $group }} +{{- if not (has "http" $spec.exposedPorts) }} +{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the MTA-STS Ingress to point at" $group $group) }} +{{- end }} +{{- $service := printf "%s-%s-http-service" (include "stalwart.fullname" .) $group }} +{{- $path := include "stalwart.mtaSts.path" . }} +{{- range $issuer := $mtaSts.order }} +{{- $entry := index $mtaSts.groups $issuer }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "stalwart.mtaSts.ingressName" (dict "root" $ "issuer" $issuer) }} + labels: + app: {{ include "stalwart.fullname" $ }} + {{- if $issuer }} + annotations: + cert-manager.io/cluster-issuer: {{ $issuer }} + {{- end }} +spec: + ingressClassName: {{ $.Values.ingress.className }} + rules: + {{- range $host := $entry.hosts }} + - host: {{ $host }} + http: + paths: + {{- /* + Exact, not Prefix, and the same per-group http Service the WebUI + rule above names - by PORT NAME, which an Ingress backend can do + and a Gateway backendRef cannot. RFC 8461 serves exactly one file + at exactly one path; a Prefix here would hand this host's whole + URL space to the mail server's management surface. + */}} + - backend: + service: + name: {{ $service }} + port: + name: http + path: {{ $path }} + pathType: Exact + {{- end }} + tls: + {{- range $secret := $entry.tlsOrder }} + - hosts: + {{- range $host := index $entry.tls $secret }} + - {{ $host }} + {{- end }} + secretName: {{ $secret }} + {{- end }} +{{- end }} +{{- end }} +{{- end }} diff --git a/stalwart/values.schema.json b/stalwart/values.schema.json index 67796de..998bbe2 100644 --- a/stalwart/values.schema.json +++ b/stalwart/values.schema.json @@ -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. 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.. 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---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", diff --git a/stalwart/values.yaml b/stalwart/values.yaml index ae56e6b..c208c83 100644 --- a/stalwart/values.yaml +++ b/stalwart/values.yaml @@ -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., 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./.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.; +# 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.. + # tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose: + # the plausible default (this release's own + # tls---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 # ---------------------------------------------------------------------------