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
561 lines
32 KiB
Plaintext
561 lines
32 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 }}
|
|
|
|
MTA-STS — {{ len $mtaStsHosts }} POLICY ROUTE(S), INGRESS MODE
|
|
All of them → node group "{{ $ingressGroup }}", path {{ $mtaStsPath }} only (pathType Exact)
|
|
{{ range $entry := $mtaStsHosts }}
|
|
https://{{ $entry.host }}{{ $mtaStsPath }}
|
|
secret {{ $entry.tlsSecretName }}{{ if $entry.clusterIssuer }}, requested from the "{{ $entry.clusterIssuer }}" cluster issuer{{ else }}, NO cluster issuer set — this release does not request that certificate, it has to exist already{{ end }}
|
|
{{- end }}
|
|
|
|
One Ingress object PER CLUSTER ISSUER, not one per host: the cert-manager
|
|
issuer is an object-level ANNOTATION while the TLS secret is a per-entry
|
|
field, so one object cannot both request a certificate and leave another
|
|
host's secret alone. Hosts sharing a secret share one tls: entry, so one
|
|
certificate with several names is requested once instead of once per host:
|
|
kubectl -n {{ .Release.Namespace }} get ingress -l app={{ $release }}
|
|
|
|
TWO THINGS THIS RELEASE DOES NOT DO, AND BOTH ARE SILENT WHEN MISSING:
|
|
|
|
1. IT DOES NOT CREATE THE DOMAIN. The domains live in Stalwart's data
|
|
store (step 3), not in any chart value, so a route to a domain that is
|
|
not in the data store answers 404 at a perfectly routed endpoint —
|
|
every object here green.
|
|
2. IT DOES NOT CREATE THE DNS RECORD. MTA-STS also needs a TXT record at
|
|
_mta-sts.<domain>; without it no sending MTA ever looks for the policy.
|
|
|
|
AND THE FAILURE IS ON THE SENDER'S SIDE, NOT HERE: an MTA that finds no
|
|
policy — or hits a certificate that does not match — just delivers in the
|
|
clear and logs nothing in this cluster. So verify each one from outside:
|
|
{{- range $entry := $mtaStsHosts }}
|
|
curl https://{{ $entry.host }}{{ $mtaStsPath }}
|
|
{{- end }}
|
|
|
|
MIND THE DEEPER NAMES: mta-sts.sub.<domain> is one label below
|
|
*.<domain>, and a wildcard certificate matches exactly one label — so a
|
|
parent wildcard does NOT cover it. That is what the per-entry secret is for.
|
|
{{- end }}
|
|
{{- else if $httpRoute.enabled }}
|
|
|
|
WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
|
{{- 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 }}
|