Files
jklein 978e6fbaa8
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
refactor(stalwart): name the MTA-STS Ingress objects by index, per the corrected rule
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

586 lines
33 KiB
Plaintext

{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $release := include "stalwart.fullname" . -}}
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
the paragraphs below that asked ingress.enabled were really asking whether the
WebUI and JMAP are reachable from outside at all (Gitea #114).
*/ -}}
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- 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
Since v0.16 Stalwart keeps its whole configuration inside the data store and
manages it over JMAP. The only file on disk is config.json, which names the
data store and nothing else. So this release has given you the workloads, the
Services, the NetworkPolicies, the Ingress and a certificate — but no
domains, no listeners, no blob store, no in-memory store and no cluster
roles. Those are the steps below, and without them this is not yet a mail
server.
NODE GROUPS IN THIS RELEASE
{{- range $group, $spec := $nodes }}
{{- $replicas := include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas) }}
{{ printf "%-10s" $group }} replicas {{ $replicas }}, reachable on: {{ $spec.exposedPorts | join ", " }}
{{- end }}
{{- if .Values.scaleDown }}
scaleDown: true — every group is at replicas 0 and nothing is running. The
Services, Ingress, Secrets and the Certificate stay in place, so setting it
back to false brings the release up without re-issuing the certificate.
{{- end }}
{{- if .Values.bootstrap.enabled }}
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
RECOVERY MODE IS ON — THIS RELEASE SERVES NO MAIL
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
bootstrap.enabled is true, so every pod runs with STALWART_RECOVERY_MODE=1:
the management HTTP endpoint on {{ (index $ports "http").containerPort }} is the ONLY thing listening and
every mail port is closed. The Services and the Ingress look exactly as
healthy as they do in normal operation, which is why this banner exists.
This is correct for a first install (step 1 below). When the database is
provisioned, set bootstrap.enabled: false and upgrade again.
{{- end }}
THE 1PASSWORD ITEMS MUST CARRY THE FIELDS THIS CHART READS
One Secret key is created per FIELD of the item, and these templates
reference the keys by name — an item missing a field leaves the container in
CreateContainerConfigError. An EMPTY field fails the same way as a missing
one: Stalwart's secret resolver treats an empty environment variable as "not
found".
{{- if .Values.secrets.dataStoreSecretRef }}
secrets.dataStoreSecretRef → {{ .Values.secrets.dataStoreSecretRef }}
field: password (the data store password)
The USERNAME is not in this item and cannot be: authUsername is a plain
String on the DataStore object with no secret form. It is
dataStore.postgres.username = "{{ .Values.dataStore.postgres.username }}" and is rendered into the
ConfigMap.
{{- else }}
secrets.dataStoreSecretRef is NOT SET, so no data store password reaches the
pods and config.json references an environment variable that will not exist.
Stalwart fails to open the store with
Environment variable '{{ include "stalwart.env.dbPassword" . }}' not found
Set it unless the database genuinely takes no password.
{{- end }}
{{- if .Values.secrets.recoveryAdminSecretRef }}
secrets.recoveryAdminSecretRef → {{ .Values.secrets.recoveryAdminSecretRef }}
field: recovery-admin (the COMPLETE value "admin:<password>")
One field, not two. The upstream parser splits on the first ":" and drops
the entry when either half is empty, so a half-filled item would silently
mean "no recovery admin" instead of failing — and this credential logs in
regardless of directory state.
REMOVE IT WHEN YOU ARE DONE. It is a back door. Once a permanent
administrator exists in the database, set secrets.recoveryAdminSecretRef
to null and upgrade; the variable then disappears from the pods.
{{- else }}
secrets.recoveryAdminSecretRef is NOT SET. That is the right end state once a
permanent administrator exists — but during a first install or a recovery
start it means Stalwart generates a temporary administrator password and
prints it to STDOUT, i.e. into the pod log, freshly on every restart and
visible to anyone who can read logs. Catch it with
kubectl -n {{ .Release.Namespace }} logs sts/{{ $release }}-{{ $ingressGroup | default "<group>" }}-sfs | grep -A3 bootstrap
{{- end }}
{{- if .Values.secrets.s3SecretRef }}
secrets.s3SecretRef → {{ .Values.secrets.s3SecretRef }}
fields: access-key, secret-key
Exposed to the containers as {{ include "stalwart.env.s3AccessKey" . }} and
{{ include "stalwart.env.s3SecretKey" . }}. The chart does NOT configure the blob
store; point the BlobStore object in the WebUI at these names:
accessKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3AccessKey" . }}"}
secretKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3SecretKey" . }}"}
{{- end }}
CHANGING WHAT IS INSIDE AN ITEM RESTARTS NOTHING. Helm never sees a secret's
content, so there is nothing to hash. Repointing one of the values above at a
different vault item DOES roll the pods (checksum/secret-ref covers the
op:// path). After rotating a password inside an item:
{{- range $group, $spec := $nodes }}
kubectl -n {{ $.Release.Namespace }} rollout restart statefulset/{{ $release }}-{{ $group }}-sfs
{{- end }}
FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
1. bootstrap.enabled: true, nodes.mx.replicas: 0.
The setup wizard everyone reaches for appears only when config.json is
ABSENT, and this chart always renders one — so the wizard never runs here.
Recovery mode is the documented path for a templated config.json: it
opens the management endpoint and keeps the mail ports shut. With a second
group running, the wizard would be unusable anyway: each request would
land on a random pod.
2. Open the management UI and create the permanent administrator.
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $release }}-{{ $ingressGroup | default "<group>" }}-http-service {{ (index $ports "http").containerPort }}:{{ (index $ports "http").containerPort }}
then http://localhost:{{ (index $ports "http").containerPort }}/admin
3. In the WebUI, create: the blob store (S3), the in-memory store
({{ if $valkeyHost }}{{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }}{{ else }}no address configured — see externalServices.valkey{{ end }}),
the coordinator (Default — it reuses the in-memory store connection), the
search store (Default — it uses the data store, so no extra service is
needed), the TLS object pointing at the certificate from step 5, the
NetworkListener objects, and one ClusterRole per node group below.
THE LISTENER PORTS MUST MATCH THIS RELEASE EXACTLY. The chart cannot
create listener objects, so these numbers are a second copy of a decision
made in the WebUI, and a mismatch is silent — the Service points at a port
nothing listens on, every pod stays Ready, nothing reports it:
{{- range $portName, $port := $ports }}
{{ printf "%-12s" $portName }} bind {{ printf "%-5s" (printf "%v" $port.containerPort) }}{{ if $port.public }} (published on node port {{ $port.nodePort }}){{ else }} (not published){{ end }}
{{- end }}
AND ONE ClusterRole PER GROUP, NAMED EXACTLY:
{{- range $group, $spec := $nodes }}
{{ $group }}
{{- end }}
A node whose role name is not in the database does not fail. It becomes a
FULL node — every listener and every task — while staying Running with a
green readiness probe. The only trace is one log line,
'Cluster role "<name>" not found in registry', and it is printed AFTER the
listeners are already bound.
4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}.
Stalwart sees the {{ if $httpRoute.enabled }}Gateway's{{ else }}ingress controller's{{ end }} pod IP for every WebUI and JMAP
request, not the client's. Sooner or later the auto-ban bans that one
address and the WebUI and JMAP go dark for everyone at once. The PROXY
protocol is not the fix for HTTP: enable "forwarded" handling with
X-Forwarded-For evaluation, and discard forwarding headers the client
brought itself. The outage arrives weeks later and looks like a server
fault, which is why this is a step and not a tip.
{{ if $httpRoute.enabled }}Switching to the Gateway API does not avoid this and does not
reset it: it is one proxy address instead of another, and whether the
Gateway sends an X-Forwarded-For at all is that implementation's business,
not something this route can state.{{ end }}
5. Point the TLS object at the mounted certificate.
{{- if .Values.certificate.enabled }}
certificate: {{ .Values.certificate.mountPath }}/tls.crt
privateKey: {{ .Values.certificate.mountPath }}/tls.key
cert-manager writes these into tls-{{ $release }}-mail. Watch it with
kubectl -n {{ .Release.Namespace }} get certificate {{ $release }}-mail-certificate
Until it is issued the mail ports serve a self-signed certificate: the
volume is mounted optional so that a pending challenge does not hold every
pod in ContainerCreating.
{{- else }}
certificate.enabled is false, so this chart issues no mail certificate.
The Ingress certificate does NOT cover the mail ports — the HA proxy
terminates no TLS, so Stalwart has to present one itself for STARTTLS on
25 and implicit TLS on 465/993. Provide it another way or clients will see
a name mismatch.
{{- end }}
6. Set send-proxy-v2 on the HA proxy. ALL OR NOTHING WITH STEP 3.
externalTrafficPolicy is {{ .Values.service.externalTrafficPolicy }}, which it has to be — no Stalwart
pod runs on a control-plane node, and the HA proxy delivers to the
control-plane nodes, so Local would drop every packet. The cost is that
kube-proxy masquerades the source address, and on a mail server a lost
source address switches off SPF, takes DMARC with it, makes every
blocklist report "clean", collapses per-IP rate limits onto one address,
removes greylisting's key, and makes the auto-ban lock out every sender at
once. The Received: chain becomes worthless — the one item there that
destroys evidence retroactively rather than just failing.
The PROXY v2 header restores it, for port 25 as well, because the HA proxy
opens its own connection to the node. But a trusted network makes the
header MANDATORY: without it a connection from there hangs until timeout,
and with it from an untrusted network Stalwart reads "PROXY TCP4 ..." as
an SMTP command. Test on a spare port with its own frontend, and move 25
only once a Received: line shows the real sender address.
7. Restart the pods — "a newly added listener binds only on the next start" —
and set bootstrap.enabled: false in the same round.
8. Raise nodes.mx.replicas only after the "mx" ClusterRole exists (step 3),
and publish DNS (below).
DNS A MAIL SERVER CANNOT RUN WITHOUT
None of this is rendered by the chart; all of it is required.
MX {{ .Values.ingress.domain | default "<your mail domain>" }}. → this deployment's public name
SPF TXT "v=spf1 ..." naming the HA proxy's address as a sender
DKIM TXT at <selector>._domainkey — the key is generated IN Stalwart
(Domains → DKIM), so this record is published after step 3
DMARC TXT at _dmarc — start at p=none with rua= and tighten only once
the reports are clean
REVERSE DNS PTR for the HA PROXY's public address, matching its forward
name. Not the node's and not the pod's: the HA proxy is what
other mail servers see connecting. Many receivers reject mail
outright when it is missing, and nothing in Kubernetes can set
it — it belongs to whoever owns the IP range.
WHAT A GREEN POD PROVES HERE, AND WHAT IT DOES NOT
Liveness asks /healthz/live, readiness and startup ask /healthz/ready.
Read off the source at v0.16.24: /healthz/ready returns 200 as soon as a data
store HANDLE exists — it does not query the database. So Ready does not mean
the database is reachable, and it does not catch a pod that fell back to an
ephemeral in-memory store because config.json was missing. A green pod here
means the process is answering HTTP.
The probe intervals are calculated, not chosen: Stalwart rate-limits
anonymous /healthz requests to 100 per 60s PER SOURCE IP, probes arrive from
the node IP, and so every pod on a node shares one bucket. The defaults cost
6 requests/min/pod and leave room for about 16 pods per node. Tightening them
produces 429s, and a 429 on liveness is a restart loop.
{{- $anySpread := false }}
{{- range $group, $spec := $nodes }}
{{- $r := int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas)) }}
{{- $s := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
{{- if and $s.spreadAcrossNodes (ge $r 2) }}{{ $anySpread = true }}{{ end }}
{{- end }}
DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
Both objects are conditional, so this is the list rather than something to
infer from `kubectl get pdb` and an `-o yaml`.
{{- range $group, $spec := $nodes }}
{{- $r := int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas)) }}
{{- $s := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
{{ $group }} — {{ $r }} effective replica(s)
{{- if and $s.spreadAcrossNodes (ge $r 2) }}
SPREAD ENFORCED. maxSkew 1 on kubernetes.io/hostname, whenUnsatisfiable
DoNotSchedule, nodeAffinityPolicy Ignore, selecting this group's
own pods only — a pod of "{{ $group }}" sharing a node with a pod of
another group stays fine. This is a PROMISE, so the price is a Pending pod
rather than a co-located one: if fewer nodes are available than
this group has replicas, the surplus replica does not schedule and
there is NO scheduling event to read. Check with
kubectl -n {{ $.Release.Namespace }} get pod -l stalwart.jk-dev.de/node-group={{ $group }} -o wide
{{- else if $s.spreadAcrossNodes }}
SPREAD NOT RENDERED, because this group has {{ $r }} effective replica(s) and the
rule needs two. {{ if eq $r 0 }}With no pod at all there is nothing to
distribute{{ else }}With one pod every placement satisfies it, so the object
would describe nothing{{ end }}. Raise nodes.{{ $group }}.replicas to 2 to get it —
and note that doing so changes the POD TEMPLATE, so {{ if eq $r 0 }}the group
starts with the constraint already in place{{ else }}the pod running now is
replaced in the same rollout round, not merely joined{{ end }}.
{{- else }}
SPREAD OFF — nodes.{{ $group }}.spreadAcrossNodes is false. Nothing keeps two
pods of this group off the same node, so its replicas are
redundancy on paper only unless your own
nodes.{{ $group }}.topologySpreadConstraints or affinity does the job.
{{- end }}
{{- if and $s.podDisruptionBudget.enabled (ge $r 2) }}
PDB {{ $release }}-{{ $group }}-pdb, minAvailable {{ $s.podDisruptionBudget.minAvailable | int64 }}. A node drain cannot take
all {{ $r }} replicas of this group at once.
{{- else if $s.podDisruptionBudget.enabled }}
PDB NONE, because the effective replica count is {{ $r }} and a budget needs
two. Deliberate: {{ if eq $r 0 }}with no pod at all the object would only
describe a promise about nothing{{ else }}minAvailable 1 with a single replica
forbids every voluntary eviction, so `kubectl drain` would block
forever on a pod nothing may move — and the error would name the
PDB rather than the replica count{{ end }}.
{{- else }}
PDB NONE — nodes.{{ $group }}.podDisruptionBudget.enabled is false. A node
drain may take every replica of this group at once.
{{- end }}
{{- end }}
{{- if $anySpread }}
ONE VERSION CAVEAT, AND IT FAILS SILENTLY: nodeAffinityPolicy is a beta field
— on by default from Kubernetes 1.26, GA with the feature gate
NodeInclusionPolicyInPodTopologySpread in 1.33. An older API server, or one
with the gate off, PRUNES IT WITHOUT A WORD, and then a nodeSelector or
affinity that narrows this group onto a single node no longer produces a
Pending pod but two CO-LOCATED ones, with the constraint reported as
satisfied. Compare the live object against what was rendered:
kubectl -n {{ .Release.Namespace }} get sts -l stalwart.jk-dev.de/release={{ $release }} \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.topologySpreadConstraints[*].nodeAffinityPolicy}{"\n"}{end}'
An empty column where "Ignore" should be is the whole symptom.
AND A CEILING THAT IS A RULE, NOT A NUMBER: this chart leaves
nodeTaintsPolicy at its API default of Ignore, so every node in the cluster
counts as a domain — control-plane nodes included, where no Stalwart pod ever
runs. They hold the minimum at 0 permanently, so with maxSkew 1 a group can
never place more replicas than there are SCHEDULABLE WORKERS. It moves with
the cluster: add a worker and it rises, remove one and it falls. Past that
point a group needs spreadAcrossNodes: false or an own constraint with
ScheduleAnyway. This ceiling comes from nodeTaintsPolicy and existed before
nodeAffinityPolicy was set — the two fields are not a package.
{{- end }}
{{- if .Values.ingress.enabled }}
WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}"
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE. Upstream moves
administration to https://<hostname>/admin once setup is complete, so this
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)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
→ node group "{{ $ingressGroup }}", one route, not one per group
This release renders ONE HTTPRoute ({{ $release }}-httproute) and nothing
else of the HTTP routing path — no Gateway, no class, no certificate for this
hostname, no policy object. Three things therefore belong to the cluster:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows only in
the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment; an empty status means no
controller has looked at the route at all.
3. The TLS certificate for the HTTP hostname, on the Gateway's listener.
ingress.clusterIssuer is not needed FOR THAT — but read the next
paragraph before removing it.
THE MAIL CERTIFICATE STAYS, AND MUST NOT BE CLEANED UP.
{{ if .Values.certificate.enabled }}certificate.enabled is true, so this release still renders
{{ $release }}-mail-certificate.{{ else }}certificate.enabled is false, so no mail certificate is rendered here.{{ end }}
That object has nothing to do with the Ingress or the Gateway: it covers
STARTTLS on the SMTP port and implicit TLS on the submission and IMAPS
ports, and those connections pass through no HTTP proxy at all. So in this mode
ingress.domain is STILL REQUIRED (the certificate's dnsNames read it) and
ingress.clusterIssuer is still required unless certificate.clusterIssuer is
set. Deleting the Certificate as part of a migration drops SMTP and IMAP back
to Stalwart's self-signed certificate: clients see a name mismatch while
every Kubernetes object in this release stays green.
SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they were not
routed by the Ingress. They keep arriving through the per-port NodePort
Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; this
chart renders neither.
AND NOW THE ONE THAT WILL BITE: CORS, AND THE WEBMAIL.
This installation's live Ingress carries, set by the operator through
ingress.annotations and NOT by this chart:
nginx.ingress.kubernetes.io/enable-cors: true
nginx.ingress.kubernetes.io/cors-allow-origin: https://<the webmail host>
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
That is what lets the browser-side webmail (the bulwark chart) call this
server's JMAP endpoint at all. IN THIS MODE IT IS GONE, and the failure is
entirely on the client side: a console error in the browser, and nothing
whatsoever in this server's logs.
The Gateway API does have a CORS filter — HTTPRoute filters carry a `cors`
field in the CRDs this repository pins — so this is not a missing feature of
the API. THIS CHART STILL DOES NOT RENDER IT, and knowingly:
- the chart has no CORS values to render it from (the origin and the method
list are the operator's, and the chart sets no CORS annotations in
Ingress mode either),
- that filter's support level is "Extended", so an implementation may
reject the route — and rejection is whole-route, which would take the
WebUI and JMAP down together — or, worse, accept it and not honour it,
- and the part a plain header filter COULD do is the part that does not
help here: the browser's PREFLIGHT has to be answered by the gateway
rather than by Stalwart, and because the allowed headers include
Authorization, EVERY request from the webmail is preflighted.
So: configure CORS AT THE GATEWAY, with that implementation's own support,
BEFORE you move this host. Then TEST IT FROM THE WEBMAIL IN A BROWSER — curl
sends no preflight, so it will pass while the webmail fails.
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE, and the hostname
the WebUI sends administrators to lives IN THE DATABASE, not in any value
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
ingress.enabled and httpRoute.enabled are both false, so there is no Ingress,
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 }}
THE VALKEY SUBCHART IS ENABLED
Reachable at {{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }} — this repo's own valkey chart,
pulled from the published index. Enter that address as the in-memory store in
step 3; the coordinator's Default variant reuses the same connection.
ITS 1PASSWORD ITEM IS A SEPARATE ONE, AND ITS FIELD IS NOT A PASSWORD:
valkey.secrets.authSecretRef points at
{{ .Values.valkey.secrets.authSecretRef | default "<NOT SET - the subchart's own required() will abort the render>" }}
and that item carries ONE field, "auth-conf", whose value is the complete
configuration line
requirepass <password>
not a bare password. The valkey chart mounts the file and includes it, and
an init container refuses to start the pod unless the line is really there —
because the alternative failure is an open server that looks healthy.
IT IS CONFIGURED AS A CACHE, NOT A QUEUE
persistence.enabled false + maxmemoryPolicy allkeys-lru. Everything Stalwart
keeps here — rate-limiter counters, auth tokens, sessions, auto-ban entries,
the coordination pub/sub — is reconstructible after a restart; nothing in it
is the system of record.
The cost of allkeys-lru, so it is not a surprise: under memory pressure keys
are discarded, so the rate limiters and the auto-ban FAIL OPEN. Size memory
for the working set rather than switching to noeviction — with noeviction
Stalwart's writes to the store start erroring, which takes out
authentication instead of just the limits.
scaleDown and checksum/config stop at the subchart boundary, so scaling this
release to zero does NOT stop the Valkey, and a new valkey chart release
reaches you only when stalwart itself is republished.
{{- end }}
{{- if not .Values.networkPolicy.enabled }}
NETWORKPOLICY IS OFF
networkPolicy.enabled is false, so nothing limits reachability except the
Services. That removes the second line of defence against the fail-open in
step 3: a group that accidentally became a full node is then reachable on
every port it bound.
{{- else if not .Values.networkPolicy.probeSources }}
NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
networkPolicy.probeSources is empty, so the rule for port {{ (index $ports "http").containerPort }} renders with
no "from" selector. Deliberate: the kubelet probes from the worker node's IP,
which this chart cannot know, and a wrong guess blocks every probe — a pod
that never becomes Ready reads as a broken application rather than as a
policy. The port carries no nodePort, so this is open inside the cluster, not
from outside. Narrow it with networkPolicy.probeSources, and then add the
{{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the
traffic arrives from the Gateway's data-plane pods, which usually live in a
namespace of their own){{ else }}ingress controller's namespace{{ end }} to networkPolicy.allowedNamespaces or the
{{ if $httpRoute.enabled }}route{{ else }}Ingress{{ end }} goes dark.
{{- end }}