Files
helm-charts/stalwart/templates/stalwart.certificate.yaml
T
jklein 780cb8eafd feat(stalwart): new chart for the Stalwart mail server
Cluster-capable from the start, with roles rather than symmetry: one
StatefulSet per entry of the "nodes" map, the key passed in STALWART_ROLE.

Since v0.16 Stalwart keeps its whole configuration inside the data store and
manages it over JMAP, so the chart's scope is deliberately narrow: it renders
config.json (exactly the DataStore object), the workloads, the Services, the
NetworkPolicies, the Ingress and a cert-manager Certificate. Listeners, blob
store, in-memory store, TLS object and cluster roles are operator work in the
WebUI, which is why NOTES.txt carries an eight-step first install.

config.json stays secret-free: authSecret uses the EnvironmentVariable variant
(verified against the serde definition of PostgreSqlStore at v0.16.24), so the
password arrives through secretKeyRef and checksum/config stays meaningful.
The username cannot follow it - authUsername is a plain String with no secret
form - so it is a public value and says so.

Services are per PORT, not per group: a NodePort is cluster-unique, so "full"
and "mx" can only accept SMTP behind one Service, selected through a per-port
pod label. The http port gets a per-group ClusterIP Service instead, so the
Ingress reaches exactly ingress.nodeGroup.

Four traps, documented rather than explained away:

- The role lookup FAILS OPEN. A node whose ClusterRole is missing from the
  database enables every listener and every task while staying Running with a
  green probe, and the log line arrives after the listeners are bound. Hence
  mx at replicas 0, per-port Services, and a NetworkPolicy that is a ceiling
  independent of what was bound.
- NET_BIND_SERVICE is a documented deviation needed to EXEC, not to bind. The
  image sets setcap cap_net_bind_service=+ep, which makes the kernel fail
  execve when the bounding set is empty. The values comment says both why it
  is there and that it is never used, because the second half is what stops
  the next hardening pass from removing it.
- externalTrafficPolicy must be Cluster - no pod runs on a control-plane node
  while the HA proxy delivers to them, so Local would drop every packet - and
  the source address that costs is restored only by the HA proxy's PROXY v2
  header.
- /healthz is rate-limited to 100 requests/60s per source IP, which is a node
  IP for kubelet probes, so all pods on a node share one bucket. The probe
  intervals are calculated (6 req/min/pod, ~16 pods per node) and the sum is a
  comment in values.yaml.

A first install needs bootstrap.enabled: true: the setup wizard appears only
when config.json is ABSENT, and this chart always renders one, so recovery
mode is upstream's documented path for a templated config.json.

Second chart in the repo with a dependency - the Valkey project's own chart,
off by default behind condition:, because Stalwart's cluster coordinator rides
on the in-memory store connection. The render aborts when more than one pod
runs with no in-memory store address anywhere.

Also wired up: publish-stalwart.yml through the shared block in
workflow-util/pipelines, the change-app-version.yml choice list, a Renovate
customManagers block anchored on the v prefix (which excludes the moving
latest / v0.16 / -alpine tags), CI fixtures, and the README and CLAUDE.md
lines that claimed ytdlp-web-player was the only chart with a dependency.

Verified locally: ci/helm-dependencies.sh, helm lint --strict (0 failed),
helm package, helm template against chart defaults and against
ci/stalwart/test-values.yaml, the rendered config.json parsed as JSON with the
field types the serde definition requires, ci/run-quantity-render-test.sh
(0 failed, both nodes.*.resources paths exercised), NOTES.txt rendered through
a scratch ConfigMap, and the null-safety and fail paths (securityContext=null
keeps NET_BIND_SERVICE; a cluster without an in-memory store, an unknown port
name, a group without http and an unknown ingress.nodeGroup all abort with a
named message; the schema rejects timeout, a string timeoutMs and a list-shaped
nodes). NO runtime verification was possible - there is no k3d or Docker daemon
in this workspace.

Part of #65
2026-10-02 20:59:17 +00:00

57 lines
2.8 KiB
YAML

{{- /*
The certificate for the MAIL ports, issued by cert-manager and mounted into
every node group read-only.
WHY THE INGRESS CERTIFICATE IS NOT ENOUGH: the Ingress covers the WebUI and
JMAP only. The HA proxy in front of the cluster terminates no TLS, so Stalwart
itself has to present a certificate for STARTTLS on 25 and implicit TLS on 465
and 993. Those connections never pass through the ingress controller.
WHY NOT STALWART'S OWN ACME: the HA proxy forwards 25, 465 and 993 and not 80,
so an HTTP-01 challenge answered by Stalwart is unreachable from outside.
DNS-01 inside Stalwart would work and would duplicate what cert-manager
already does here.
THIS OBJECT ONLY OBTAINS THE FILES. Pointing Stalwart at them is a separate,
manual step: the TLS object in the DATABASE has to name the two paths below.
Until it does, the mail ports serve whatever default certificate Stalwart
generates for itself, and clients see a name mismatch while every Kubernetes
object here is green.
*/ -}}
{{- if .Values.certificate.enabled }}
{{- $domain := required "A Domain/Host must be provided (ingress.domain) - it is read here for the mail certificate as well as by the Ingress, so it stays required even with ingress.enabled false" .Values.ingress.domain -}}
{{- /* An explicit if, NOT
.Values.certificate.clusterIssuer | default (required "..." .Values.ingress.clusterIssuer)
which is the obvious one-liner and is wrong: Go evaluates a pipeline's
arguments eagerly, so required() fires whenever ingress.clusterIssuer is
null even though certificate.clusterIssuer is set and the fallback is
never needed. Measured: ingress.enabled=false plus
certificate.clusterIssuer=<issuer> aborted the render. */ -}}
{{- $issuer := .Values.certificate.clusterIssuer -}}
{{- if not $issuer -}}
{{- $issuer = required "A Cluster-Issuer must be provided: set certificate.clusterIssuer for the mail certificate (usually a DNS-01 issuer), or ingress.clusterIssuer, which it falls back to" .Values.ingress.clusterIssuer -}}
{{- end -}}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "stalwart.fullname" . }}-mail-certificate
labels:
app: {{ include "stalwart.fullname" . }}
spec:
# Deliberately NOT the Ingress TLS secret name (tls-<release>-stalwart-ingress).
# Two separate certificates on purpose: this one usually comes from a
# different issuer (DNS-01, see below) and covers different names, and
# sharing one secret would make the ingress controller and the mail
# listeners fight over which names the certificate has to carry.
secretName: tls-{{ include "stalwart.fullname" . }}-mail
issuerRef:
name: {{ $issuer }}
kind: ClusterIssuer
group: cert-manager.io
dnsNames:
- {{ $domain | quote }}
{{- range .Values.certificate.extraDnsNames }}
- {{ . | quote }}
{{- end }}
{{- end }}