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
1167 lines
63 KiB
YAML
1167 lines
63 KiB
YAML
# Stalwart — a cluster-capable mail server (SMTP, IMAP, POP3, JMAP, ManageSieve).
|
|
#
|
|
# READ THIS FIRST: THIS CHART CONFIGURES ALMOST NOTHING.
|
|
#
|
|
# Since v0.16 Stalwart keeps its entire configuration — over 2400 settings in
|
|
# 117 object types, including every listener, the blob store, the in-memory
|
|
# store, TLS objects, domains and the cluster roles — INSIDE the data store,
|
|
# managed over the JMAP API (WebUI or stalwart-cli). The only thing left on
|
|
# disk is a small config.json that names the data store and nothing else:
|
|
# "It only needs to contain the DataStore object"
|
|
# (https://stalw.art/docs/configuration/declarative-deployments).
|
|
#
|
|
# So this chart renders config.json, the workloads with their STALWART_ROLE,
|
|
# the Services, the NetworkPolicies, the Ingress and a cert-manager
|
|
# Certificate. Everything else is operator work in the WebUI, and the values
|
|
# below say for each block whether it configures Stalwart or only Kubernetes.
|
|
# A value that only describes something to Kubernetes is marked as such rather
|
|
# than left to look like application configuration.
|
|
|
|
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
|
# every StatefulSet with replicas: 0 and takes precedence over every
|
|
# nodes.<group>.replicas. Services, Ingress, Secrets and the Certificate stay
|
|
# in place, so no certificate is re-issued when you scale back up.
|
|
scaleDown: false
|
|
|
|
# Chart-wide default for the limit factor of every node group. A group may
|
|
# override it as nodes.<group>.resources.limitFactor, which always wins. Memory
|
|
# and ephemeral-storage limits are computed as limitFactor x the request; a CPU
|
|
# limit is only set when configured explicitly.
|
|
limitFactor: 3
|
|
|
|
secrets:
|
|
# op:// path of the 1Password item carrying the data-store password in the
|
|
# field "password".
|
|
#
|
|
# ONLY the password. The username cannot live here, and that is an upstream
|
|
# constraint rather than a choice: on the DataStore object authSecret has the
|
|
# type SecretKeyOptional (which has an EnvironmentVariable variant, and that
|
|
# is what this chart uses) while authUsername is a plain String with no
|
|
# secret form at all. So the username is the public value
|
|
# dataStore.postgres.username below and ends up in the ConfigMap, while the
|
|
# password never does.
|
|
dataStoreSecretRef: null
|
|
|
|
# op:// path of the 1Password item carrying the field "recovery-admin" with
|
|
# the COMPLETE value of STALWART_RECOVERY_ADMIN, i.e. "admin:<password>" in
|
|
# one string — the same shape as valkey's auth-conf field. The chart does not
|
|
# assemble it from two fields, because a half-built credential ("admin:" with
|
|
# an empty password) is accepted by the server as a working back door.
|
|
#
|
|
# Why it is worth pinning at all: without it Stalwart generates a temporary
|
|
# administrator password on every bootstrap or recovery start and prints it to
|
|
# stdout — in Kubernetes that means a fresh password in the pod log on every
|
|
# restart, visible to anyone who can read logs, and lost as soon as it
|
|
# scrolls. Set to null to switch the variable off entirely once a permanent
|
|
# administrator exists in the database; that is the documented end state, see
|
|
# NOTES.txt.
|
|
recoveryAdminSecretRef: null
|
|
|
|
# op:// path of the 1Password item for the S3 blob store, carrying the fields
|
|
# "access-key" and "secret-key".
|
|
#
|
|
# This chart does NOT configure the blob store — that object lives in the
|
|
# database. What it does is expose both fields to the container as the
|
|
# environment variables STALWART_S3_ACCESS_KEY and STALWART_S3_SECRET_KEY, so
|
|
# that the BlobStore object you create in the WebUI can reference them
|
|
# instead of storing the credentials in the database:
|
|
# accessKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_ACCESS_KEY"}
|
|
# secretKey: {"@type":"EnvironmentVariable","variableName":"STALWART_S3_SECRET_KEY"}
|
|
# Both fields accept that variant (accessKey is PublicStringOptional,
|
|
# secretKey is SecretKeyOptional; both offer Value, EnvironmentVariable and
|
|
# File). Leave null when the blob store is not S3 or when you prefer to keep
|
|
# the credentials in the database — the variables are then not set and
|
|
# nothing references them.
|
|
s3SecretRef: null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config.json — the ONLY part of Stalwart this chart actually configures
|
|
# ---------------------------------------------------------------------------
|
|
dataStore:
|
|
postgres:
|
|
# REQUIRED, no default (enforced by required() in the template).
|
|
host: null
|
|
port: 5432
|
|
database: stalwart
|
|
# Public on purpose — see secrets.dataStoreSecretRef above for why this is
|
|
# not a secret reference. It is rendered into the ConfigMap.
|
|
username: stalwart
|
|
tls:
|
|
# Rendered as the object's "useTls" field. The names differ because this
|
|
# chart groups the two TLS switches that upstream keeps flat; the mapping
|
|
# is tls.enable -> useTls and tls.allowInvalidCerts -> allowInvalidCerts.
|
|
#
|
|
# Note the default is the OPPOSITE of upstream's (false). A mail server's
|
|
# data store carries every message's metadata, and this chart would
|
|
# rather fail to connect to a plaintext-only Postgres than reach it
|
|
# silently unencrypted.
|
|
enable: true
|
|
# Accepting an invalid certificate turns TLS into obfuscation: the
|
|
# connection is encrypted against a passive listener and wide open to an
|
|
# active one. Only ever a temporary diagnostic.
|
|
allowInvalidCerts: false
|
|
poolMaxConnections: 10
|
|
# MILLISECONDS, as an integer — not "15s".
|
|
#
|
|
# This is not a styling preference. Stalwart's object encoding states it
|
|
# outright: "A duration is an integer count of milliseconds, and a size is
|
|
# an integer count of bytes. Neither accepts a string."
|
|
# (https://stalw.art/docs/configuration/object-encoding). The DataStore
|
|
# reference gives this field as "Duration? default: 15000". A config.json
|
|
# carrying "15s" is rejected by the server at startup, so the key is named
|
|
# for its unit instead of accepting a suffix the file cannot hold.
|
|
timeoutMs: 15000
|
|
# Extra libpq connection options, passed through verbatim as the object's
|
|
# "options" field. null omits the field.
|
|
options: null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# First start: Stalwart needs recovery mode when config.json is provided
|
|
# ---------------------------------------------------------------------------
|
|
bootstrap:
|
|
# Sets STALWART_RECOVERY_MODE=1 on every node group.
|
|
#
|
|
# WHY THIS EXISTS, AND WHY IT IS NOT OPTIONAL ON A FIRST INSTALL. The setup
|
|
# wizard everyone reaches for runs in BOOTSTRAP mode, and bootstrap mode is
|
|
# entered only when there is no config.json: "The first time Stalwart is
|
|
# started without a config.json, it enters bootstrap mode"
|
|
# (https://stalw.art/docs/configuration/bootstrap-mode). This chart always
|
|
# renders one, so the wizard never appears. Against an empty database that
|
|
# leaves a server with no listeners, no domain and no administrator, and
|
|
# nothing to fix it with.
|
|
#
|
|
# The documented path for exactly this case is recovery mode: "template
|
|
# config.json onto the host, start Stalwart once with STALWART_RECOVERY_MODE=1
|
|
# and a STALWART_RECOVERY_ADMIN supplied from a secrets manager, run
|
|
# stalwart-cli apply against the recovery listener to load the initial
|
|
# configuration, and then restart Stalwart without the recovery variables"
|
|
# (https://stalw.art/docs/configuration/declarative-deployments). Recovery
|
|
# mode brings up the management HTTP endpoint on port 8080 — the WebUI
|
|
# included — and keeps every mail port closed.
|
|
#
|
|
# So: true for the first install, then false and upgrade again. Leaving it on
|
|
# is not a small untidiness — the mail ports stay shut while the Services and
|
|
# the Ingress look perfectly healthy, which is the loudest-looking silent
|
|
# failure this chart has. NOTES.txt prints a warning for as long as it is on.
|
|
enabled: false
|
|
# Log verbosity while recovery mode is on (STALWART_RECOVERY_MODE_LOG_LEVEL).
|
|
# Has no effect once bootstrap.enabled is false.
|
|
logLevel: info
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ports. KUBERNETES ONLY — these numbers do not reach Stalwart.
|
|
# ---------------------------------------------------------------------------
|
|
# THE DUPLICATION BELOW CANNOT BE AVOIDED, SO IT IS WRITTEN DOWN INSTEAD.
|
|
#
|
|
# Every listener Stalwart binds is a NetworkListener object in the database.
|
|
# This chart cannot create those objects, so containerPort here is a second
|
|
# copy of a decision that is really made in the WebUI. If the two disagree, the
|
|
# Service points at a port nothing listens on: connections are refused or time
|
|
# out, every pod stays Ready, and no object reports a problem. Check both sides
|
|
# whenever you touch one.
|
|
#
|
|
# TWO NUMBERS PER ENTRY, AND THEY ARE NOT INTERCHANGEABLE:
|
|
# containerPort — what the application binds inside the pod, and what the
|
|
# NetworkListener object in the database must say.
|
|
# nodePort — what the cluster publishes on every node, and what the HA
|
|
# proxy in front of the cluster connects to.
|
|
# The Service maps one onto the other: nodePort 30025 -> targetPort 25.
|
|
#
|
|
# WHY THE APPLICATION BINDS THE ORDINARY 25/465/993. An earlier draft had it
|
|
# bind 30025/30465/30993 so that nothing privileged was ever bound and the
|
|
# NET_BIND_SERVICE capability could be dropped. That reasoning collapsed: the
|
|
# image ships "setcap cap_net_bind_service=+ep" on the binary, which makes it a
|
|
# capability-dumb binary, and the kernel then fails the execve ITSELF when the
|
|
# bounding set does not carry the capability — before the program binds
|
|
# anything. The capability has to be granted either way (see securityContext
|
|
# below), so high ports bought no hardening at all. What the ordinary numbers
|
|
# buy instead is predictability: the listener objects an operator creates in
|
|
# the WebUI carry the numbers from every page of Stalwart's documentation and
|
|
# from the image's own EXPOSE list, which takes most of the surprise out of the
|
|
# duplication above.
|
|
#
|
|
# The nodePorts must be inside the API server's --service-node-port-range
|
|
# (default 30000-32767). 30025/30465/30993 are, with the default — UNVERIFIED
|
|
# against this cluster's actual setting.
|
|
#
|
|
# Only these three plus HTTP are published. 587, 143, 110, 995 and 4190 are
|
|
# deliberately absent and can be added later without a breaking change: add an
|
|
# entry here and list it in the groups that should answer on it.
|
|
ports:
|
|
smtp:
|
|
containerPort: 25
|
|
nodePort: 30025
|
|
protocol: TCP
|
|
public: true
|
|
submissions:
|
|
containerPort: 465
|
|
nodePort: 30465
|
|
protocol: TCP
|
|
public: true
|
|
imaps:
|
|
containerPort: 993
|
|
nodePort: 30993
|
|
protocol: TCP
|
|
public: true
|
|
http:
|
|
# The management/JMAP/WebUI port, and the port the probes use. Never
|
|
# published as a NodePort: it is reached through the Ingress (ClusterIP
|
|
# Service) and by the kubelet.
|
|
containerPort: 8080
|
|
protocol: TCP
|
|
public: false
|
|
|
|
# The init container that seeds /etc/stalwart/config.json out of the ConfigMap.
|
|
# Pinned here rather than hardcoded in the template, and reviewed on every
|
|
# app-update round, because it is an auxiliary image and its tag does not come
|
|
# from appVersion (README, guideline 3).
|
|
initImage:
|
|
repository: docker.io/library/busybox
|
|
tag: "1.38.0-uclibc"
|
|
|
|
service:
|
|
# Cluster, and Local is not an option here — this is a property of the
|
|
# deployment's topology, not a preference.
|
|
#
|
|
# externalTrafficPolicy: Local only forwards to pods on the node that
|
|
# received the packet. Mail enters through an HA proxy that distributes
|
|
# 25/465/993 across the three control-plane nodes, and no Stalwart pod is
|
|
# ever scheduled on a control-plane node — so under Local EVERY packet would
|
|
# be dropped, not some.
|
|
#
|
|
# The price of Cluster is that kube-proxy masquerades the source address to
|
|
# the receiving node's IP (it has to: the reply would otherwise leave through
|
|
# a different node than the one the sender holds its connection with). For a
|
|
# mail server a lost source address is not cosmetic; it 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 by banning the node IP. The
|
|
# Received: chain becomes worthless, which is the one item on that list that
|
|
# destroys evidence retroactively instead of just failing.
|
|
#
|
|
# What pays that price back is the PROXY protocol, and only because of this
|
|
# topology: the HA proxy terminates the inbound connection and opens its OWN
|
|
# to the node, so it can prepend a PROXY v2 header on that connection — for
|
|
# port 25 as well, where no remote mail server would ever send one. The real
|
|
# client address then travels in the header and the masquerading stops
|
|
# mattering. This is a MANDATORY companion step, not an optimisation; the
|
|
# wiki page has the all-or-nothing warning that goes with it.
|
|
externalTrafficPolicy: Cluster
|
|
# Extra annotations for every Service this chart renders (ClusterIP and
|
|
# NodePort alike). Free-form by nature.
|
|
annotations: {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node groups. One StatefulSet, one headless Service and one NetworkPolicy each.
|
|
# ---------------------------------------------------------------------------
|
|
# A MAP, NOT A LIST, and on purpose: with a list every override would be
|
|
# index-based (--set nodes[1].replicas=2), so inserting a group later would
|
|
# silently repoint every existing override at the wrong group.
|
|
#
|
|
# The key is the cluster role name and goes into STALWART_ROLE. A ClusterRole
|
|
# object of exactly that name has to exist in the database — see the fail-open
|
|
# warning below, which is the most important paragraph in this file.
|
|
#
|
|
# THERE IS NO nodes.<group>.tasks KEY. Which tasks and listeners a role runs is
|
|
# a property of the ClusterRole object in the database, and this chart cannot
|
|
# write it. A values key that looked like it set them and did not would be
|
|
# worse than its absence.
|
|
#
|
|
# FAIL-OPEN: A NODE WHOSE ROLE IS MISSING FROM THE DATABASE BECOMES A FULL NODE.
|
|
#
|
|
# From the source, not the documentation. The bootstrap registry looks a role
|
|
# up by name; when it finds none it records a non-fatal error and leaves the
|
|
# role unset, and the listener check then reads
|
|
# bp.role.as_ref().is_none_or(|r| ...)
|
|
# which yields true for EVERY listener when the role is None. The same holds
|
|
# for the task set. So a typo in a group name here, or a rename here without
|
|
# the matching rename in the database, turns that group into a node that binds
|
|
# everything — the mx group would serve IMAP and the admin interface. The pod
|
|
# is Running, the log holds one line ('Cluster role "mx" not found in
|
|
# registry'), and the readiness probe is GREEN. Kubernetes cannot detect it.
|
|
#
|
|
# Three things in this chart contain that failure, and they are the reason the
|
|
# layout looks the way it does: mx ships with replicas: 0, the per-port
|
|
# Services publish only what was asked for, and the NetworkPolicy is a ceiling
|
|
# on reachability that holds no matter which ports the process actually bound.
|
|
# Documented as a trap rather than explained away.
|
|
nodes:
|
|
# Everything: all tasks, all listeners. The group the Ingress points at.
|
|
full:
|
|
# 0 is allowed and scales this group down; the chart-global scaleDown wins.
|
|
replicas: 1
|
|
# Which ports this group may be reached on. NOT a copy of the listener
|
|
# configuration in the database but a statement of its own: "this group is
|
|
# reachable here and nowhere else". That is why a disagreement in either
|
|
# direction is harmless — a role that binds more is unreachable on the
|
|
# extra ports, and a policy that permits more has nothing listening — and
|
|
# it is exactly what takes the edge off the fail-open above.
|
|
#
|
|
# Every group needs http, including mx: it is the port all three probes
|
|
# use. It is not published for mx.
|
|
exposedPorts: [smtp, submissions, imaps, http]
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Distribute THIS GROUP's pods over distinct nodes (Gitea #96)
|
|
# -----------------------------------------------------------------------
|
|
# Renders a topologySpreadConstraints entry on topologyKey
|
|
# kubernetes.io/hostname with maxSkew: 1 and a labelSelector the CHART
|
|
# fills in — the two labels it puts on its own pods anyway. You never
|
|
# write that selector, and that is the point: a spread constraint whose
|
|
# selector matches no pod is TRIVIALLY SATISFIED. It distributes nothing,
|
|
# the rollout is green, and nothing anywhere says so.
|
|
#
|
|
# ONLY WITHIN THIS GROUP. The selector names the release AND the node
|
|
# group, so "full" pods are spread against "full" pods and "mx" pods
|
|
# against "mx" pods. A "full" and an "mx" pod on the same node is
|
|
# deliberately fine — they are different roles, not redundant copies.
|
|
#
|
|
# RENDERED ONLY FROM TWO EFFECTIVE REPLICAS UPWARDS. With one pod any
|
|
# placement satisfies it, so it would describe nothing; the count used is
|
|
# the computed one, so scaleDown: true removes it too.
|
|
#
|
|
# whenUnsatisfiable IS DoNotSchedule, i.e. THIS IS A PROMISE AND NOT AN
|
|
# EFFORT — and the price is a pod that stays Pending rather than one that
|
|
# lands somewhere useless. ScheduleAnyway was the original proposal and was
|
|
# rejected: with one worker at 97 % requested CPU the scheduler may put both
|
|
# replicas of a group on the same remaining node, entirely within the rule
|
|
# and with the redundancy gone. The cases where the promise costs something:
|
|
#
|
|
# - TWO OF THREE WORKERS GONE: the second replica stays Pending instead
|
|
# of joining the survivor — but only WHILE THE Node OBJECTS STILL
|
|
# EXIST. A NotReady node still counts as an (empty) domain, so the
|
|
# third replica would raise the skew to 2. Delete the Node objects and
|
|
# the domains disappear with them and the pod schedules. Same
|
|
# mechanism, opposite outcome, one step apart.
|
|
# - A NARROWING nodeSelector/affinity BELOW: see nodeSelector further
|
|
# down. That combination blocks itself, deliberately and loudly.
|
|
#
|
|
# A Pending pod with no scheduling event reads like a different problem
|
|
# than a distribution rule, so NOTES.txt names this rule whenever it is
|
|
# rendered.
|
|
#
|
|
# THE FIELD THAT CAN BE DROPPED WITHOUT A WORD: the constraint carries
|
|
# nodeAffinityPolicy: Ignore, and that field is BETA — on by default since
|
|
# Kubernetes 1.26, graduated to GA with the feature gate
|
|
# NodeInclusionPolicyInPodTopologySpread in 1.33. On an API server older
|
|
# than 1.26, or one with the gate switched off, the field is pruned
|
|
# SILENTLY, and the gap it was set against comes straight back (the
|
|
# nodeSelector case below). Nothing reports it: the object still looks
|
|
# correct in `kubectl get sts -o yaml`, minus one line. So 1.26 is the
|
|
# minimum version this chart can make the promise on at all. NOT MEASURED
|
|
# against an older server — this is the documented pruning behaviour for a
|
|
# field behind a disabled gate, not something tried here. Check the
|
|
# rendered StatefulSet against the live one if the server is near that
|
|
# boundary.
|
|
#
|
|
# WHAT IS *NOT* A CONSEQUENCE OF THAT FIELD, because the two
|
|
# node-inclusion policies are not a package: the ceiling on replicas
|
|
# described at nodeSelector below comes from nodeTaintsPolicy, which this
|
|
# chart leaves at its own default. It was there before nodeAffinityPolicy
|
|
# was set and is not its price.
|
|
#
|
|
# ONE PRECONDITION, AND IT IS NOT IN THIS FILE: this works because the
|
|
# StatefulSet has NO volumeClaimTemplates. Open measurement 1 on #65 asks
|
|
# whether anything under /var/lib/stalwart has to survive a restart; if
|
|
# that answer ever becomes yes, a PVC per group appears, and with a
|
|
# node-local or topology-bound storage class the pod is pinned to wherever
|
|
# its volume was first provisioned. Distribution is then decided at
|
|
# provisioning time rather than by this rule, and DoNotSchedule can keep a
|
|
# returning pod Pending for good. This rule holds only while the chart
|
|
# stays PVC-free.
|
|
#
|
|
# AND THE COST OF TURNING IT ON BY SCALING UP: going from 1 to 2 replicas
|
|
# changes the POD TEMPLATE (the constraint appears in it), so the existing
|
|
# pod is replaced in the same rollout round rather than merely joined by a
|
|
# second one. Expect a restart of the running node, not just an addition.
|
|
spreadAcrossNodes: true
|
|
|
|
# A PodDisruptionBudget for this group, rendered only while the group's
|
|
# COMPUTED replica count is >= 2 — the spread constraint's companion: that
|
|
# one decides the two pods sit on different nodes, this one decides only
|
|
# one of those nodes may be emptied at a time.
|
|
#
|
|
# The condition is not tidiness. minAvailable: 1 with exactly one replica
|
|
# forbids every voluntary eviction, so `kubectl drain` blocks forever on a
|
|
# pod nothing may move — and the message names the PDB rather than the
|
|
# replica count, which sends the reader to the wrong file. Precedent and
|
|
# full argument: keycloak (README, guideline 11).
|
|
podDisruptionBudget:
|
|
enabled: true
|
|
# minAvailable, not maxUnavailable: the promise is about the service —
|
|
# at least one pod of this group keeps answering through a drain.
|
|
minAvailable: 1
|
|
|
|
# Generic pass-through into this group's pod spec. Rendered verbatim with
|
|
# toYaml; the chart reads none of their contents, which is why they are
|
|
# loosely typed in values.schema.json — Kubernetes owns their schema. An
|
|
# empty value renders no field at all.
|
|
#
|
|
# THE ONE COMBINATION THAT BLOCKS ITSELF, and it does so on purpose:
|
|
# narrowing the eligible nodes below this group's replica count while
|
|
# spreadAcrossNodes is on leaves a replica permanently Pending. With
|
|
# nodeAffinityPolicy: Ignore on the chart's constraint, a nodeSelector
|
|
# down to one node gives 2,0,0 — skew 2 — rather than the one eligible
|
|
# domain and skew 0 it would give under the API default of Honor. The
|
|
# second shape is what we do NOT want: both pods co-located, nothing
|
|
# Pending, no event, and a selector that is perfectly correct, so no check
|
|
# in CI can see it either. Loud beats silent; that is the whole trade.
|
|
#
|
|
# AND THE CEILING, WHICH IS A RULE AND NOT A NUMBER. The chart leaves
|
|
# nodeTaintsPolicy at its API default of Ignore, so EVERY node counts as a
|
|
# domain — control-plane nodes included, and no Stalwart pod ever runs
|
|
# there. Those domains hold the minimum at 0 permanently, so with
|
|
# maxSkew: 1 a group can never place more replicas than there are
|
|
# SCHEDULABLE WORKERS. Add a worker and the ceiling rises with it; remove
|
|
# one and it falls. Do not write today's count down here — it would be
|
|
# wrong at the next cluster rebuild and nobody would come back for it.
|
|
# Raising a group past that ceiling needs spreadAcrossNodes: false, or an
|
|
# own topologySpreadConstraints entry with ScheduleAnyway.
|
|
affinity: {}
|
|
nodeSelector: {}
|
|
tolerations: []
|
|
# Appended AFTER the chart's own constraint and really AND-ed with it, so
|
|
# a second key — topology.kubernetes.io/zone, say — works as expected.
|
|
#
|
|
# An entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule
|
|
# (or omitted, which IS DoNotSchedule) ABORTS THE RENDER naming both this
|
|
# value and spreadAcrossNodes. Kubernetes keys a constraint on exactly
|
|
# that pair: `kubectl create` rejects the duplicate with a message about a
|
|
# list index instead of a value, and an apply-based path merges the two
|
|
# over patchMergeKey: topologyKey SILENTLY — one labelSelector is dropped
|
|
# and the result is green. A named abort is better than either.
|
|
topologySpreadConstraints: []
|
|
resources:
|
|
requests:
|
|
cpu: 200m
|
|
memory: 512Mi
|
|
ephemeral-storage: 64Mi
|
|
# Additive environment entries for this group's container, appended to the
|
|
# ones the chart sets (STALWART_ROLE, STALWART_HOSTNAME and the secret-fed
|
|
# ones). Supports the value, secretKeyRef and configMapKeyRef forms and is
|
|
# rendered through tpl.
|
|
#
|
|
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
|
|
# does not replace it. At install both reach the pod and the later one wins;
|
|
# on an EXISTING release Kubernetes merges env by name, the pair collapses
|
|
# to the chart's own value, and helm upgrade still reports success. Do not
|
|
# use this to override STALWART_ROLE.
|
|
env: []
|
|
# SMTP intake only. Add the matching ClusterRole in the database first:
|
|
# {"name":"mx","tasks":{"@type":"DisableAll"},"listeners":{"@type":"EnableSome", ...}}
|
|
mx:
|
|
# Zero until the role "mx" exists in the database. Raising this before the
|
|
# role is there does not fail — it produces a second full node (see
|
|
# fail-open above), which is the opposite of what this group is for.
|
|
replicas: 0
|
|
exposedPorts: [smtp, http]
|
|
# Same six keys as nodes.full, same defaults, and argued in full there —
|
|
# including the three things that are easy to get wrong: that
|
|
# nodeAffinityPolicy can be pruned silently below Kubernetes 1.26, that the
|
|
# replica ceiling is "as many as there are schedulable workers" rather than
|
|
# a number, and that the ceiling comes from nodeTaintsPolicy and not from
|
|
# the field this chart sets.
|
|
#
|
|
# Neither object is rendered for this group while replicas is 0, and the
|
|
# condition is on the computed count: raising it to 2 (after the "mx"
|
|
# ClusterRole exists — see above) brings both the spread constraint and the
|
|
# PodDisruptionBudget with it, independently of what "full" does.
|
|
spreadAcrossNodes: true
|
|
podDisruptionBudget:
|
|
enabled: true
|
|
minAvailable: 1
|
|
affinity: {}
|
|
nodeSelector: {}
|
|
tolerations: []
|
|
topologySpreadConstraints: []
|
|
resources:
|
|
requests:
|
|
cpu: 100m
|
|
memory: 256Mi
|
|
ephemeral-storage: 64Mi
|
|
env: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# NetworkPolicy — the ceiling on reachability, independent of what bound what
|
|
# ---------------------------------------------------------------------------
|
|
networkPolicy:
|
|
# On by default. Only a real boundary where the cluster's CNI enforces
|
|
# NetworkPolicies; an unenforced policy is accepted by the API server and
|
|
# filters nothing.
|
|
enabled: true
|
|
# Source CIDRs allowed to reach the http port, in addition to
|
|
# allowedNamespaces. This is where the kubelet's probe traffic has to be
|
|
# permitted: probes come from the IP of the WORKER node running the pod, an
|
|
# address this chart cannot know.
|
|
#
|
|
# EMPTY MEANS OPEN, not blocked. With no entry the http rule is rendered
|
|
# without a "from" selector, so the port is reachable from anywhere the CNI
|
|
# routes to. That is the deliberate choice between two bad defaults: a
|
|
# guessed CIDR that excludes the real node addresses blocks every probe, and
|
|
# a pod that never becomes Ready reads as a broken application rather than as
|
|
# a policy. http is not a NodePort, so "open" here means open inside the
|
|
# cluster, not from the internet. Narrow it by listing the worker node IPs
|
|
# (e.g. ["10.0.1.11/32", "10.0.1.12/32"]).
|
|
#
|
|
# And mind the companion trap: once this list is non-empty the http rule is
|
|
# restricted, which cuts off the ingress controller too unless its namespace
|
|
# is in allowedNamespaces below.
|
|
probeSources: []
|
|
# Namespaces allowed to reach the http port, matched on the
|
|
# kubernetes.io/metadata.name label Kubernetes sets on every namespace
|
|
# itself. The release's own namespace is always allowed. Put the ingress
|
|
# controller's namespace here (e.g. ["ingress-nginx"]) as soon as
|
|
# probeSources is set. A name that does not exist matches nothing and is
|
|
# silently ineffective.
|
|
allowedNamespaces: []
|
|
# Egress is restricted BY PORT, not by destination, and the reason is a limit
|
|
# of the object rather than laziness: a NetworkPolicy matches ipBlock CIDRs
|
|
# and pod/namespace selectors, never hostnames — so dataStore.postgres.host
|
|
# and externalServices.*.host cannot appear in it. Each rule below therefore
|
|
# permits its port to any destination. What it still buys is real: a
|
|
# compromised pod cannot open arbitrary ports outbound.
|
|
#
|
|
# Outbound SMTP is the one that must stay wide: delivering mail means
|
|
# connecting to port 25 on any host on the internet.
|
|
egress:
|
|
# DNS (UDP and TCP 53) to any destination. Switching this off breaks
|
|
# everything, including MX lookups and SPF.
|
|
dns: true
|
|
# Outbound SMTP on port 25 to any destination — required for delivery.
|
|
# Deliberately NOT derived per group from the outboundMta task: this chart
|
|
# does not know which tasks a role runs, the value would drift away from
|
|
# the database, and the failure mode would be mail silently not leaving.
|
|
# So it is uniform for every group.
|
|
smtp: true
|
|
# Additional outbound TCP ports, beyond the data store, S3 and Valkey ports
|
|
# the chart already derives from the values above. Add 443 here if the
|
|
# deployment needs outbound HTTPS (ACME over DNS-01 through a provider API,
|
|
# an AI model endpoint, a remote lookup list).
|
|
extraPorts: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# External services — KUBERNETES ONLY. These do not configure Stalwart.
|
|
# ---------------------------------------------------------------------------
|
|
# The blob store and the in-memory store are objects in the database and are
|
|
# created in the WebUI. What the chart uses these values for is exactly three
|
|
# things, and nothing else:
|
|
# - the port goes into the egress rule of every NetworkPolicy,
|
|
# - the valkey host decides whether the in-memory store has an address at all
|
|
# (see the fail below), and
|
|
# - both hosts are printed in NOTES.txt as the addresses to enter in the
|
|
# WebUI, so the installation checklist is not a separate document.
|
|
# Setting a host here does not point Stalwart at it.
|
|
externalServices:
|
|
s3:
|
|
host: ""
|
|
port: 443
|
|
valkey:
|
|
# Empty plus valkey.enabled: true resolves to the subchart's Service,
|
|
# "<release>-valkey". Empty with the subchart off and more than one
|
|
# Stalwart pod ABORTS THE RENDER with a message naming both values, because
|
|
# that combination is a cluster without an in-memory store: from v0.16 the
|
|
# coordinator's Default variant "reuses the Redis connection already
|
|
# configured on the in-memory store", so a multi-node install has no
|
|
# coordination path at all. Failing the render is the loud version of a
|
|
# cluster that comes up and then does not agree with itself.
|
|
host: ""
|
|
port: 6379
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TLS for the mail ports — cert-manager, not Stalwart's own ACME
|
|
# ---------------------------------------------------------------------------
|
|
# Why not Stalwart's built-in 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 but would duplicate what
|
|
# cert-manager already does in this cluster.
|
|
#
|
|
# Why a certificate is needed at all when there is an Ingress: the Ingress
|
|
# covers only the WebUI and JMAP. The HA proxy terminates no TLS, so Stalwart
|
|
# itself has to present the certificate for STARTTLS on 25 and implicit TLS on
|
|
# 465 and 993.
|
|
certificate:
|
|
enabled: true
|
|
# Cluster issuer for the MAIL certificate. null falls back to
|
|
# ingress.clusterIssuer.
|
|
#
|
|
# MIND THE CHALLENGE TYPE. The same reasoning that rules out Stalwart's ACME
|
|
# rules out an HTTP-01 issuer here: port 80 does not reach this cluster for
|
|
# the mail hostname. A DNS-01 issuer is usually the right answer, and it is
|
|
# usually a DIFFERENT issuer from the one the Ingress uses — which is why
|
|
# this is its own value instead of reusing ingress.clusterIssuer silently.
|
|
clusterIssuer: null
|
|
# Additional DNS names for the mail certificate. ingress.domain is always
|
|
# included, so this is for the hostnames clients and other MTAs actually
|
|
# connect to when they differ from the WebUI host — typically the MX name and
|
|
# the autoconfig names (mail., smtp., imap.).
|
|
extraDnsNames: []
|
|
# Mount path of the issued certificate inside the container. A separate
|
|
# top-level path rather than something under /etc/stalwart, so there is no
|
|
# mount nested inside the config emptyDir.
|
|
#
|
|
# These two files are what the TLS object in the DATABASE has to point at:
|
|
# certificate: /etc/stalwart-tls/tls.crt
|
|
# privateKey: /etc/stalwart-tls/tls.key
|
|
mountPath: /etc/stalwart-tls
|
|
|
|
ingress:
|
|
# Gates the whole Ingress template. ingress.domain stays REQUIRED either way:
|
|
# it is read by the Certificate's dnsNames as well, so switching the Ingress
|
|
# off would otherwise render a certificate with no name in it. Only
|
|
# ingress.clusterIssuer becomes unnecessary — and only if
|
|
# certificate.clusterIssuer is set, since that falls back to it.
|
|
enabled: true
|
|
# REQUIRED, no default.
|
|
clusterIssuer: null
|
|
# REQUIRED, no default.
|
|
domain: null
|
|
className: nginx
|
|
# Which node group the Ingress routes to. Must be a key of nodes: above, and
|
|
# that group must list http in its exposedPorts — both are checked, and the
|
|
# render aborts naming the value. It matters that this is a single group: the
|
|
# WebUI's setup and management screens are not safe to spread over a group
|
|
# that may not run the management tasks, and a Service selecting several
|
|
# groups would send every request to a random one of them.
|
|
nodeGroup: full
|
|
# Extra annotations for the Ingress; merged with the cert-manager
|
|
# cluster-issuer annotation the chart always sets.
|
|
#
|
|
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
|
|
# controller behaviour. For THIS chart that is the most consequential line in
|
|
# the whole block: read the httpRoute section below before switching modes,
|
|
# because what lives in here on the live installation is the CORS
|
|
# configuration the webmail depends on.
|
|
annotations: {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114)
|
|
# ---------------------------------------------------------------------------
|
|
# Mutually exclusive with ingress.enabled - the render aborts when both are
|
|
# true, and the message names both value paths. The chart renders ONLY an
|
|
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP
|
|
# hostname, and no implementation-specific policy object.
|
|
#
|
|
# ONE ROUTE, NOT ONE PER NODE GROUP. #114 describes this chart as rendering
|
|
# "per node group"; its SERVICES and StatefulSets are, the routing object is
|
|
# not and must not be. The route targets exactly the ingress.nodeGroup group,
|
|
# through that group's own http Service, and aborts the render if that group
|
|
# does not expose "http" - the same two guards the Ingress has, for the same
|
|
# reason: the WebUI's setup and management screens are not safe to spread over
|
|
# a group that may not run the management tasks.
|
|
#
|
|
# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
|
|
# anything does. certificate.enabled renders a Certificate for STARTTLS on 25
|
|
# and implicit TLS on 465/993; those connections pass through neither an
|
|
# ingress controller nor a Gateway. So in Gateway mode:
|
|
# - ingress.domain is still REQUIRED (the certificate's dnsNames read it),
|
|
# - ingress.clusterIssuer is still required unless certificate.clusterIssuer
|
|
# is set,
|
|
# - and the Certificate must NOT be "cleaned up" as part of the migration.
|
|
# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed
|
|
# certificate: clients see a name mismatch while every Kubernetes object in the
|
|
# release stays green.
|
|
#
|
|
# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not
|
|
# routed by the Ingress. They keep reaching the pods through the per-port
|
|
# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners;
|
|
# this chart renders neither, and doing so would be a separate decision.
|
|
#
|
|
# WHAT THIS MODE COSTS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
|
|
# Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
|
|
# all of it OPERATOR-supplied through ingress.annotations (this chart sets none
|
|
# of it):
|
|
#
|
|
# nginx.ingress.kubernetes.io/enable-cors: true
|
|
# nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
|
|
# nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
|
# nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
|
#
|
|
# That origin is BULWARK, the webmail chart in this same group. The CORS
|
|
# configuration is what allows the browser-side webmail to call this server's
|
|
# JMAP endpoint at all. Lose it and the webmail stops working, with a console
|
|
# error in the browser and NO server-side symptom whatsoever.
|
|
#
|
|
# AND "no counterpart" would be the wrong thing to claim here. Measured against
|
|
# the CRDs this repository pins (gateway-api v1.6.3 standard channel,
|
|
# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by
|
|
# inference:
|
|
# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with
|
|
# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge.
|
|
# Its documented support level, verbatim from that CRD: "Support:
|
|
# Extended".
|
|
# - `responseHeaderModifier` is in the same CRD and is ALSO "Support:
|
|
# Extended", not Core.
|
|
#
|
|
# There IS a counterpart, then, and this chart still does not render it. Three
|
|
# reasons, in order:
|
|
#
|
|
# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's
|
|
# - a specific origin, a specific method list. Rendering a filter means
|
|
# inventing a values surface in a sweep PR, and one that would exist in
|
|
# Gateway mode only, since the chart sets no CORS annotations in Ingress
|
|
# mode either. That asymmetry is its own defect and its own piece of work.
|
|
# 2. EXTENDED SUPPORT IS NOT SUPPORT. An implementation that does not support
|
|
# an Extended value reports it in the route's status, and a rejected route
|
|
# is rejected WHOLE - which here would take the WebUI and JMAP down
|
|
# together (the reasoning #119 arrived at for statusCode). The observed
|
|
# failure is quieter still: Cilium accepts filters.type: CORS and
|
|
# forwards the browser's preflight upstream anyway (cilium/cilium#45272),
|
|
# i.e. Accepted: True with the behaviour absent. A chart cannot know which
|
|
# implementation it landed on.
|
|
# 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
|
|
# ONE HERE. A responseHeaderModifier can add the three
|
|
# Access-Control-Allow-* headers to a response. The PREFLIGHT is a
|
|
# different thing: the browser sends OPTIONS first and the GATEWAY, not
|
|
# the backend, has to answer it - the CORS filter's own contract says a
|
|
# path carrying it MUST NOT pass the preflight upstream. A bare header
|
|
# modifier cannot do that. And this installation's header list includes
|
|
# Authorization, which makes EVERY webmail request preflighted: the half
|
|
# that is expressible without the CORS filter is precisely the half that
|
|
# does not help.
|
|
#
|
|
# So configure CORS AT THE GATEWAY, using that implementation's own CORS
|
|
# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with
|
|
# curl: curl sends no preflight, so it passes while a browser fails.
|
|
#
|
|
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
|
|
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
|
|
# without them fails at install, so this is a value and not a guess.
|
|
httpRoute:
|
|
enabled: false
|
|
|
|
# The Gateway(s) the route attaches to - a platform object, usually in
|
|
# another namespace, whose listener must allow this namespace in
|
|
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
|
|
# optionally namespace, sectionName, port, group, kind), passed through
|
|
# unchanged:
|
|
#
|
|
# parentRefs:
|
|
# - name: public
|
|
# namespace: gateway
|
|
# sectionName: https
|
|
#
|
|
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
|
# list, because a route without a parent is created, is valid and routes
|
|
# nothing, with no event to say so.
|
|
parentRefs: []
|
|
|
|
# Empty means [ingress.domain], so the HTTP hostname has one source of truth.
|
|
# Set explicitly only to publish under a different or an additional name -
|
|
# which is how a migration from the Ingress runs: the HTTPRoute takes a
|
|
# second hostname while the Ingress keeps ingress.domain.
|
|
#
|
|
# NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After
|
|
# bootstrap the WebUI is reached at https://<the configured server
|
|
# hostname>/admin, and no value of this chart changes that - so a second
|
|
# 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.<domain>, with pathType
|
|
# Exact (type: Exact in the HTTPRoute) because exactly one file lives there. A
|
|
# values key for a protocol-mandated path is a key that can only ever be set
|
|
# wrong — and "wrong" here is invisible from this side, see the limit below.
|
|
# Exact rather than a prefix also matters in its own right: a prefix on these
|
|
# hostnames would publish the mail server's whole management surface under
|
|
# them.
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# THE LIMIT, AND IT HAS TO BE SAID RATHER THAN SUGGESTED AWAY
|
|
# ------------------------------------------------------------------------
|
|
# THIS CHART REGISTERS A ROUTE. IT DOES NOT CREATE A DOMAIN. Read the first
|
|
# paragraph of this file again: since v0.16 the domains live in Stalwart's DATA
|
|
# STORE, and this chart never sees them. So whether the server answers for
|
|
# mta-sts.example.com depends on example.com existing in that data store, which
|
|
# no value here can arrange.
|
|
#
|
|
# An entry without its domain in the data store gives a 404 AT A CORRECTLY
|
|
# ROUTED ENDPOINT — every Kubernetes object green, the Ingress or route
|
|
# resolving perfectly, and nothing wrong on this side at all. And the other
|
|
# half of it is worse: MTA-STS failures are silent ON THE SENDER'S SIDE. A
|
|
# sending MTA that finds no policy does not report anything here; it simply
|
|
# falls back to delivering in the clear. So the thing this block exists to
|
|
# achieve can be absent with no symptom in this cluster whatsoever.
|
|
#
|
|
# That is the same genus as the OnePasswordItem limit this chart already
|
|
# documents: a promise the chart cannot keep, and therefore one it has to name.
|
|
# Check an entry from outside after adding it:
|
|
# curl https://mta-sts.<domain>/.well-known/mta-sts.txt
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# THE SUBDOMAIN TRAP, which is the one that will actually catch somebody
|
|
# ------------------------------------------------------------------------
|
|
# mta-sts.sub.example.com is ONE LABEL DEEPER than *.example.com, and a
|
|
# wildcard certificate matches exactly one label (RFC 6125). So a wildcard for
|
|
# the parent domain does NOT cover it, and the obvious assumption — "the
|
|
# wildcard already covers that" — is false.
|
|
#
|
|
# With a secret per entry that is solved, which is why the field exists. The
|
|
# trap is written down anyway because the symptom is a TLS error AT THE SENDING
|
|
# MTA: nothing in this cluster logs it, the endpoint looks healthy from here,
|
|
# and mail keeps flowing unencrypted. In Gateway mode the same trap moves to
|
|
# the Gateway's listener, where this chart cannot help at all — a wildcard
|
|
# listener for the parent domain will not terminate TLS for a deeper name.
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# WHY A SECRET PER ENTRY AND NOT certificate.extraDnsNames
|
|
# ------------------------------------------------------------------------
|
|
# The alternative was real and was rejected. This chart already has
|
|
# "Additional DNS names for the mail certificate" (certificate.extraDnsNames,
|
|
# further up this file), so the mta-sts.* names could have been folded into the
|
|
# mail certificate instead of carrying their own secrets.
|
|
#
|
|
# They are not, because that would hang the MAIL certificate on this list. The
|
|
# mail certificate is what serves STARTTLS on 25 and implicit TLS on 465/993;
|
|
# a bad entry here — a typo, a name whose DNS-01 challenge cannot be answered —
|
|
# would then fail the issuance of the certificate the mail ports depend on.
|
|
# A policy route that is only a nice-to-have would be able to take SMTP and
|
|
# IMAP with it. Separate secrets keep the blast radius at the entry.
|
|
#
|
|
# ------------------------------------------------------------------------
|
|
# NOT DONE BY THIS CHART, so it is not mistaken for done
|
|
# ------------------------------------------------------------------------
|
|
# - THE DNS RECORD. MTA-STS also needs a TXT record at _mta-sts.<domain>;
|
|
# without it no sending MTA ever looks for the policy. That is DNS, not
|
|
# Kubernetes, and nothing here creates it.
|
|
# - THE POLICY'S CONTENT. version/mode/max_age/mx come from Stalwart's data
|
|
# store, per domain. The running installation serves mode: testing, which
|
|
# means sending MTAs enforce nothing — also a data-store setting and not a
|
|
# chart question.
|
|
# - REMOVING AN EXISTING OBJECT FROM ANOTHER REPOSITORY. Where a route for
|
|
# one of these hosts already exists elsewhere (jk-cluster-config does serve
|
|
# mta-sts.bekemeyer-klein.de from a Fleet release of its own), the order is
|
|
# REMOVE THERE FIRST, then add the entry here. The other way round,
|
|
# ingress-nginx's admission webhook rejects the second object on the same
|
|
# host and path — which is the loud failure, but it is a failure.
|
|
mtaSts:
|
|
# Empty by default: no object in either mode, and no value becomes required.
|
|
#
|
|
# Entry form, and the two TLS fields apply ONLY IN INGRESS MODE:
|
|
#
|
|
# host REQUIRED. The policy hostname, i.e. mta-sts.<domain>.
|
|
# tlsSecretName REQUIRED IN INGRESS MODE, and with NO DEFAULT on purpose:
|
|
# the plausible default (this release's own
|
|
# tls-<release>-<chart>-ingress) is the wrong certificate —
|
|
# it covers ingress.domain, not this host. Hosts that share
|
|
# one secret are collected into one tls: entry, so one
|
|
# certificate with several SANs is asked for once rather
|
|
# than once per host. Inert in Gateway mode.
|
|
# clusterIssuer OPTIONAL, and EMPTY MEANS NO ANNOTATION — i.e. "the
|
|
# certificate for this host comes from somewhere else, do
|
|
# not ask cert-manager for it". That is what keeps a secret
|
|
# owned by another release from being fought over, the same
|
|
# deliberate omission as the qBittorrent object in
|
|
# freesailarr and two of the four realm ingresses in Gitea
|
|
# #122. Entries sharing a secret must agree on it (the
|
|
# render aborts naming both), because cert-manager names the
|
|
# Certificate after the secret and the issuer is an
|
|
# object-level annotation. Inert in Gateway mode.
|
|
#
|
|
# The chart's own ingress.annotations are NOT copied onto these objects: they
|
|
# belong to the WebUI/JMAP host (on this installation they are its CORS
|
|
# configuration), and a policy file served to sending MTAs has no use for
|
|
# them.
|
|
#
|
|
# hosts:
|
|
# - host: mta-sts.example.com
|
|
# tlsSecretName: tls-mta-sts-example-com-ingress
|
|
# clusterIssuer: letsencrypt-issuer
|
|
# # A deeper name needs its own certificate — the parent's wildcard does
|
|
# # not reach it. See the subdomain trap above.
|
|
# - host: mta-sts.sub.example.com
|
|
# tlsSecretName: tls-mta-sts-sub-example-com-ingress
|
|
# clusterIssuer: letsencrypt-issuer
|
|
# # Certificate managed elsewhere: no issuer, so no annotation and no
|
|
# # Certificate requested by this release.
|
|
# - host: mta-sts.other.example
|
|
# tlsSecretName: tls-some-existing-wildcard-ingress
|
|
hosts: []
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hardening — chart-wide, applied to every node group
|
|
# ---------------------------------------------------------------------------
|
|
securityContext:
|
|
pod:
|
|
# uid/gid 2000 is the image's own "stalwart" user: the image already runs
|
|
# as non-root, so nothing has to be bent here.
|
|
runAsNonRoot: true
|
|
runAsUser: 2000
|
|
runAsGroup: 2000
|
|
# In the pod context so it also covers the init container that places
|
|
# config.json; a container-level profile would have to be repeated for
|
|
# every container and would be missed on the next one added.
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
# No fsGroup and no fsGroupChangePolicy: this chart mounts no PVC. Both
|
|
# volumes are emptyDirs (state lives in Postgres, S3 and Valkey) and the
|
|
# kubelet creates those owned by the pod's user already.
|
|
container:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
runAsNonRoot: true
|
|
# DOCUMENTED DEVIATION from the repo's hardening baseline (README,
|
|
# guideline 5), forced by the stalwartlabs/stalwart image. Precedent for a
|
|
# justified capability: samba.
|
|
#
|
|
# NET_BIND_SERVICE IS REQUIRED TO *START* THE CONTAINER, NOT TO BIND A PORT.
|
|
# Both published image variants run
|
|
# setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
|
|
# (Dockerfile.build at v0.16.24, line 164 for the glibc target and line 186
|
|
# for the musl/-alpine one). The "e" in "+ep" sets the file's effective
|
|
# bit, which makes the binary a "capability-dumb binary" in the kernel's
|
|
# terms, and capabilities(7) then applies this check at execve time:
|
|
#
|
|
# "When executing a capability-dumb binary, the kernel checks if the
|
|
# process obtained all permitted capabilities that were specified in the
|
|
# file permitted set... If the process did not obtain the full set of
|
|
# file permitted capabilities, then execve(2) fails with the error
|
|
# EPERM."
|
|
#
|
|
# drop: [ALL] empties the bounding set, so the capability cannot be
|
|
# obtained, so the exec fails outright:
|
|
# exec /usr/local/bin/stalwart: operation not permitted
|
|
# The check runs BEFORE the program binds anything, and it does not ask
|
|
# whether the capability is needed. This is why the three escape routes
|
|
# that look obvious all fail for the same reason: high ports do not help,
|
|
# the -alpine image carries the identical setcap, and the
|
|
# net.ipv4.ip_unprivileged_port_start sysctl addresses binding rather than
|
|
# exec.
|
|
#
|
|
# AND THE SECOND HALF, WHICH IS WHY THIS LINE MUST NOT BE "CLEANED UP":
|
|
# THE CAPABILITY IS NEVER ACTUALLY USED. It is spent entirely on satisfying
|
|
# the execve check. Stalwart does bind 25/465/993 here, which are
|
|
# privileged — but inside a pod that needs no capability either way: the
|
|
# kubelet sets net.ipv4.ip_unprivileged_port_start=0 in the pod network
|
|
# namespace, so a non-root container binds low ports regardless (measured
|
|
# in k3d for template-chart, see its ports comment). So a hardening pass
|
|
# that greps for "a capability nobody uses" will find exactly this one and
|
|
# removing it stops the chart from starting at all. Do not remove it
|
|
# without first checking whether upstream still sets the file capability;
|
|
# the day that setcap disappears from Dockerfile.build is the day this
|
|
# deviation can go with it.
|
|
capabilities:
|
|
drop:
|
|
- ALL
|
|
add:
|
|
- NET_BIND_SERVICE
|
|
|
|
# Probes for every node group, on the two separate endpoints the image
|
|
# provides. Values-configurable; an erased block falls back to these defaults
|
|
# rather than removing the probe.
|
|
#
|
|
# THE INTERVALS ARE CALCULATED, NOT TASTE. DO NOT TIGHTEN THEM WITHOUT REDOING
|
|
# THE ARITHMETIC BELOW.
|
|
#
|
|
# Stalwart applies an anonymous HTTP rate limit to /healthz as to everything
|
|
# else, and its default is 100 requests per 60 seconds KEYED ON THE SOURCE IP
|
|
# (registry default rate_limit_anonymous: count 100, period 60000ms). Kubelet
|
|
# probes arrive from the IP of the NODE running the pod, not from the pod, so
|
|
# every Stalwart pod on one node draws from the SAME bucket. Exceeding it
|
|
# returns 429, a 429 fails the probe, and failing liveness restarts the pod —
|
|
# a self-inflicted restart loop that gets worse the more pods a node holds.
|
|
#
|
|
# The budget per pod, with the values below:
|
|
# steady state liveness 60/20 = 3/min + readiness 60/20 = 3/min = 6/min
|
|
# while starting startup 60/10 = 6/min = 6/min
|
|
# The startup probe GATES liveness and readiness, so the three never run at
|
|
# once and 6 requests/min/pod is the worst case in both phases. Against a
|
|
# 100/60s bucket that leaves room for about 16 Stalwart pods per node before
|
|
# the limit is in sight.
|
|
#
|
|
# For comparison, the obvious 10/10/5 defaults would cost 12/min/pod in steady
|
|
# state and allow only about 8 pods per node — which is not far from a
|
|
# plausible deployment. That is the whole reason these numbers are 20/20/10.
|
|
#
|
|
# What the wider intervals cost, stated so the trade is visible: up to 20s
|
|
# before unreadiness removes a pod from its Services, and up to 60s
|
|
# (3 x 20s) before a hung pod is restarted. For a mail server both are
|
|
# acceptable — SMTP senders retry for days.
|
|
#
|
|
# WHY HTTP AND NOT A tcpSocket READINESS PROBE. A tcpSocket probe on the mail
|
|
# port would bypass the bucket entirely, and it was considered for exactly that
|
|
# reason. It is rejected on three grounds, none of them about convenience:
|
|
# 1. It would be GREEN in precisely the failure this chart is built to
|
|
# contain. A node that fail-opens (its ClusterRole missing from the
|
|
# database) binds every listener, so a TCP connect to 25 succeeds on a
|
|
# broken "mx" node just as it does on a healthy "full" one.
|
|
# 2. It proves strictly less. A TCP accept says a socket is bound;
|
|
# /healthz/ready additionally says the server reached the point where a
|
|
# data store handle exists at all.
|
|
# 3. It would have to differ per node group (the "mx" group has no 465 or
|
|
# 993), so the fallback default in _helpers.tpl could no longer carry the
|
|
# probe's context — the failure mode guideline 12.5 exists for.
|
|
# If a node ever does need to carry more than ~16 pods, the right move is to
|
|
# raise the limit on the server (the allow-list bypasses the limiter outright
|
|
# for listed addresses) rather than to blind the probes.
|
|
#
|
|
# WHAT THESE PROVE, AND WHAT THEY DO NOT — three honest limits:
|
|
# - Liveness is deliberately on /healthz/live so a Postgres outage cannot
|
|
# restart every pod in the cluster at once; the restart would fix nothing
|
|
# and would drop every connection in flight (guideline 12.3).
|
|
# - /healthz/ready DOES NOT QUERY THE DATABASE. Read from the source at
|
|
# v0.16.24: it returns 200 iff the data store handle is not Store::None.
|
|
# So readiness here does not hang on the dependency the way guideline 12.3
|
|
# asks, and it cannot — the endpoint upstream offers is shallow. Named
|
|
# rather than papered over, because a green pod proves less than it looks.
|
|
# - It also does not catch the missing-config.json trap (a server that fell
|
|
# back to an ephemeral in-memory store still has a data store handle).
|
|
#
|
|
# NOT VERIFIED AGAINST A RUNNING SERVER: whether /healthz answers a plain
|
|
# kubelet probe at all. The source shows no auth, no loopback check and no
|
|
# trusted-network restriction, but the request still passes the configurable
|
|
# Http.allowedEndpoints expression (default "200", i.e. everything allowed) —
|
|
# and its loopback escape hatch does NOT cover a node IP, so an operator who
|
|
# later writes a restrictive expression breaks every probe. The image's own
|
|
# healthcheck sends "X-Forwarded-For: 127.0.0.1", which is how it puts itself
|
|
# in the loopback rate-limit bucket; that only has an effect when
|
|
# Http.useXForwarded is on, and it is off by default.
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /healthz/live
|
|
port: http
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 3
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /healthz/ready
|
|
port: http
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 3
|
|
# The startup probe gates liveness and readiness, so neither needs an
|
|
# initialDelaySeconds. Budget = periodSeconds x failureThreshold = 300s, which
|
|
# is generous on purpose: the first start of a node has to reach Postgres,
|
|
# acquire its cluster node-id lease and read the whole configuration registry
|
|
# out of the database.
|
|
startupProbe:
|
|
httpGet:
|
|
path: /healthz/ready
|
|
port: http
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 30
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The Valkey subchart — THIS REPO'S OWN valkey CHART, off by default
|
|
# ---------------------------------------------------------------------------
|
|
# Stalwart needs an in-memory store for a cluster, and the coordinator rides on
|
|
# the same connection. The subchart is the path for a deployment that has no
|
|
# Valkey yet; an installation that already runs one points
|
|
# externalServices.valkey.host at it and leaves this off.
|
|
#
|
|
# THE SWITCH IS HERE AND NOT UNDER "valkey:", and it has to be. Everything under
|
|
# "valkey:" is handed to the subchart as its values, and our valkey chart ships
|
|
# a strict schema (additionalProperties: false) with no "enabled" property — so
|
|
# "valkey.enabled" is rejected BY THE SUBCHART. Measured; and because Helm skips
|
|
# a disabled subchart's schema validation, the error would only ever appear for
|
|
# whoever first switched it on. Chart.yaml has the error text.
|
|
valkeySubchart:
|
|
enabled: false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Values passed THROUGH to the valkey subchart
|
|
# ---------------------------------------------------------------------------
|
|
# Read as the subchart's own values, so the key names are ITS schema, not
|
|
# upstream's and not this chart's. `valkey/values.yaml` in the repo is the
|
|
# reference; its header comment explains the pairs. Nothing here has any effect
|
|
# while valkeySubchart.enabled is false.
|
|
#
|
|
# WHY THIS IS NOT "upstream's chart as it comes": it is our chart, so there is
|
|
# no third party's defaults to leave unpatched and no shortfall against this
|
|
# repo's conventions to document — it follows them because it IS one of them.
|
|
# What still applies in full is the subchart being invisible downstream (a
|
|
# Valkey fix reaches a cluster only when THIS chart is republished — Chart.yaml
|
|
# has the detail and the PR-ordering warning), that scaleDown and
|
|
# checksum/config stop at the subchart boundary, and that every helm command
|
|
# needs ci/helm-dependencies.sh to have run first.
|
|
valkey:
|
|
secrets:
|
|
# REQUIRED once the subchart is on — the subchart's own required() aborts
|
|
# the render without it, which is the correct loud failure.
|
|
#
|
|
# MIND THE FIELD, it is not a password. The 1Password item carries ONE
|
|
# field, `auth-conf`, whose value is the COMPLETE configuration line
|
|
# requirepass <password>
|
|
# The valkey chart mounts that file and `include`s 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 perfectly healthy. A bare
|
|
# password in that field does not work.
|
|
authSecretRef: null
|
|
|
|
# CACHE, not queue — a decision, with its cost named.
|
|
#
|
|
# The valkey chart treats persistence and eviction as a PAIR and warns against
|
|
# mixing them: QUEUE is `persistence.enabled: true` + `noeviction`, CACHE is
|
|
# `persistence.enabled: false` + `allkeys-lru`.
|
|
#
|
|
# Stalwart's in-memory store holds rate-limiter counters, auth tokens and
|
|
# sessions, auto-ban entries, and the cluster coordination pub/sub. Every one
|
|
# of those is reconstructible: counters and bans restart from zero, tokens and
|
|
# sessions force a re-authentication, and pub/sub messages are not stored keys
|
|
# at all. Nothing in it is the system of record — messages and metadata are in
|
|
# Postgres, blobs in S3, and the cluster node-id lease is in the data store
|
|
# too, not here. Paying for a PVC and for AOF replay to protect data that is
|
|
# worthless after a restart is the wrong trade, so: cache.
|
|
#
|
|
# THE COST, because `allkeys-lru` means keys really are discarded under memory
|
|
# pressure: the rate limiters and the auto-ban FAIL OPEN. An attacker who can
|
|
# drive memory usage can age out their own ban entry. The answer is to size
|
|
# memory for the working set rather than to switch to `noeviction` — with
|
|
# `noeviction` Stalwart's writes to the store start erroring instead, which
|
|
# takes out authentication rather than just the limits. Size it, and treat the
|
|
# limiters as a nuisance filter, not as a security boundary.
|
|
persistence:
|
|
enabled: false
|
|
|
|
valkey:
|
|
maxmemoryPolicy: allkeys-lru
|
|
# The subchart derives maxmemory from the computed memory limit and aborts
|
|
# its render if an explicit maxmemory exceeds it. Defaults left alone.
|
|
resources:
|
|
requests:
|
|
cpu: 50m
|
|
memory: 128Mi
|
|
ephemeral-storage: 32Mi
|
|
|
|
# The subchart's own NetworkPolicy, with its own semantics — this chart's
|
|
# networkPolicy block does not reach it. On by default there, allowing the
|
|
# release's own namespace. It needs an entry here only when Stalwart runs in a
|
|
# DIFFERENT namespace from the Valkey, which with the subchart it does not.
|
|
networkPolicy:
|
|
allowedNamespaces: []
|