Erster Plan für ein Chart für Stalwart (stalwartlabs/stalwart), einen Mailserver mit SMTP, IMAP, POP3, JMAP, ManageSieve und Weboberfläche.
Ausgangspunkt war die verlinkte Seite https://stalw.art/docs/0.15/cluster/orchestration/kubernetes/. Sie ist veraltet — siehe unten. Dieser Text ist eine Bestandsaufnahme plus die Entscheidungen, die vor der Umsetzung zu treffen sind; er ist noch kein freigegebener Umsetzungsplan.
Verifizierte Fakten
Aus der Registry und dem Dockerfile gelesen, nicht aus der Doku übernommen:
Abbild
docker.io/stalwartlabs/stalwart — neueste Fassung v0.16.24, identischer Digest wie latest. Daneben -alpine-Varianten und wandernde Tags latest, v0.16
Größe
100 MiB komprimiert — für den Install-Test unproblematisch
Benutzer
USER stalwart, uid/gid 2000 — das Abbild läuft bereits als Nicht-Root
zwei: /etc/stalwart (Konfiguration) und /var/lib/stalwart (Daten)
Ports
25, 110, 143, 443, 465, 587, 993, 995, 4190, 8080
Health
/healthz/live und /healthz/ready; der Healthcheck des Abbilds ruft https://127.0.0.1:443/healthz/livemit dem Kopf X-Forwarded-For: 127.0.0.1 und fällt auf http://127.0.0.1:8080/healthz/live zurück
Die verlinkte Seite beschreibt 0.15 und stimmt nicht mehr: Dort wird ein einziges Volume unter /opt/stalwart gemountet. In 0.16 sind es /etc/stalwart und /var/lib/stalwart, und die Konfiguration ist JSON, nicht TOML. Die Manifeste dort sind Beispiele, kein offizielles Helm-Chart — im Repository gibt es kein helm/- oder k8s/-Verzeichnis. Die Frage nach einer Chart-Abhängigkeit stellt sich also nicht.
Drei Kollisionen mit unseren Konventionen
1. Die Datei-Capability gegen drop: [ALL]. Das Dockerfile setzt setcap cap_net_bind_service=+ep auf die Binärdatei — so bindet die Anwendung als uid 2000 die Ports 25, 143 und 443. Unsere Härtungsvorgabe nimmt genau diese Fähigkeit weg. Drei Auswege, alle im Repo schon einmal begangen:
hohe Ports in der Anwendung, Abbildung im Service (port: 25 → targetPort: 2525) — Capabilities bleiben vollständig entfernt;
capabilities.add: [NET_BIND_SERVICE] als dokumentierte Abweichung;
der sysctl-Weg wie bei rss-bridge (net.ipv4.ip_unprivileged_port_start).
2. Die Konfiguration ist Zustand, nicht Vorgabe. Ohne config.json startet Stalwart im Bootstrap-Modus: kein Maildienst, ein Assistent auf Port 8080, und am Ende schreibt der Server die Datei selbst und startet neu. /etc/stalwart muss also beschreibbar sein. Eine gerenderte ConfigMap mit checksum/config — unsere Regel §9 — passt darauf nicht; das Muster ist eher homeassistant, das seine Konfiguration ebenfalls selbst verwaltet.
3. Ein Ingress kann Mail nicht tragen. SMTP, IMAP, POP3 und ManageSieve sind kein HTTP. Für die Weboberfläche und JMAP ist ein Ingress richtig, für die übrigen acht Ports braucht es einen Service vom Typ LoadBalancer oder NodePort — im Repo gibt es NodePort-Vorbilder (minecraft-router, teamspeak-server, samba).
Was ohnehin gut passt
Nicht-Root ist Bildvorgabe (uid 2000), anders als bei ConvertX oder Valkey. Ein schreibgeschütztes Root-Dateisystem sollte mit den zwei Volumes erreichbar sein.
Der Wiederherstellungs-Zugang lässt sich festlegen: Ohne Vorgabe erzeugt Stalwart bei jedem Bootstrap ein temporäres Administratorkonto und schreibt das Kennwort nach stdout — in Kubernetes also ins Pod-Protokoll und bei jedem Neustart neu. Die Umgebungsvariable STALWART_RECOVERY_ADMIN fixiert es; das ist der natürliche Platz für ein OnePasswordItem.
Health-Endpunkte existieren und sind getrennt in live und ready — die Voraussetzung dafür, §12.3 richtig umzusetzen.
Zu entscheiden, bevor umgesetzt wird
Einzelknoten oder Cluster? Die verlinkte Seite gehört zum Cluster-Kapitel, ihr Manifest zeigt aber replicaCount: 1. Ein Cluster verlangt einen externen Datenspeicher (Postgres, MySQL, S3) und einen Koordinierungsweg (peer-to-peer, oder Kafka, NATS, Redis). Ein Einzelknoten mit eingebettetem RocksDB auf einer PVC ist erheblich einfacher und deckt einen Familien- oder Vereins-Mailserver vollständig ab. Empfehlung: Einzelknoten zuerst, Cluster später als eigenes Vorhaben — ein Mailserver ist schon ohne Cluster die komplizierteste Gattung in diesem Repo.
Wie kommen die Mail-Ports nach außen? LoadBalancer setzt eine entsprechende Infrastruktur voraus, NodePort verschiebt die Portwahl in den Cluster-Bereich. Und die eigentliche Frage dahinter: Hat der Cluster überhaupt eingehende Erreichbarkeit auf Port 25 samt passendem Reverse-DNS? Ohne das ist ein Mailserver nicht betreibbar, und das entscheidet nichts im Chart.
Privilegierte Ports: hohe Ports plus Service-Abbildung, NET_BIND_SERVICE, oder sysctl?
TLS: Stalwart bringt eigenes ACME mit. Alternativ ein von cert-manager erzeugtes Secret einhängen. Beides schließt sich aus, und die Wahl entscheidet, ob Port 80 von außen erreichbar sein muss.
Konfiguration: den Bootstrap-Assistenten einmalig laufen lassen und die Datei auf der PVC belassen (wie homeassistant), oder eine vollständige config.json vorgeben und den Assistenten nie sehen? Letzteres wäre reproduzierbarer, bräuchte aber eine gepflegte Vorlage für eine große JSON-Datei — und die Weboberfläche schreibt möglicherweise dagegen.
Was gemessen werden muss
Antwortet /healthz/live für die Probe überhaupt? Der Healthcheck des Abbilds setzt X-Forwarded-For: 127.0.0.1 — ein Hinweis darauf, dass der Endpunkt auf vertrauenswürdige Netze beschränkt sein könnte. Das ist genau die Falle aus §12.5 (patchmon braucht einen Host-Kopf). Am laufenden Dienst zu prüfen, nicht am Pfadnamen.
Was der Bootstrap-Modus für den Install-Test bedeutet. Ohne config.json startet kein Maildienst. Der Test muss also entweder eine Konfiguration mitbringen oder gegen den Bootstrap-Zustand prüfen — und im zweiten Fall belegt er weniger, als er zu belegen scheint.
Ob readOnlyRootFilesystem: true mit den zwei Volumes hält, und welche weiteren Schreibpfade nötig sind.
Welche Ports die Anwendung tatsächlich öffnet, abhängig von der Konfiguration — die Portliste des Abbilds ist eine Obergrenze, keine Zusage.
Umfang, wenn entschieden ist
Chart stalwart mit StatefulSet, zwei Volumes, Service für Mail (Typ offen) plus Ingress für Weboberfläche und JMAP, OnePasswordItem für den Wiederherstellungs-Zugang, Probes auf den getrennten Endpunkten, strenges Schema, NOTES.txt mit den Feldern des Tresor-Eintrags und den DNS-Einträgen, die ein Mailserver braucht (MX, SPF, DKIM, DMARC, Reverse-DNS). Dazu Publish-Workflow, Eintrag in change-app-version.yml, CI-Fixtures, Renovate-Block mit Verankerung am v-Präfix, README-Zeile, Wiki-Seite.
Erster Plan für ein Chart für **Stalwart** (`stalwartlabs/stalwart`), einen Mailserver mit SMTP, IMAP, POP3, JMAP, ManageSieve und Weboberfläche.
Ausgangspunkt war die verlinkte Seite <https://stalw.art/docs/0.15/cluster/orchestration/kubernetes/>. **Sie ist veraltet** — siehe unten. Dieser Text ist eine Bestandsaufnahme plus die Entscheidungen, die vor der Umsetzung zu treffen sind; er ist noch **kein** freigegebener Umsetzungsplan.
## Verifizierte Fakten
Aus der Registry und dem Dockerfile gelesen, nicht aus der Doku übernommen:
| | |
|---|---|
| Abbild | `docker.io/stalwartlabs/stalwart` — neueste Fassung **`v0.16.24`**, identischer Digest wie `latest`. Daneben `-alpine`-Varianten und wandernde Tags `latest`, `v0.16` |
| Größe | **100 MiB** komprimiert — für den Install-Test unproblematisch |
| Benutzer | **`USER stalwart`, uid/gid 2000** — das Abbild läuft bereits als Nicht-Root |
| Form | `WORKDIR /var/lib/stalwart`, `ENTRYPOINT /usr/local/bin/stalwart`, `CMD --config /etc/stalwart/config.json` |
| Volumes | **zwei**: `/etc/stalwart` (Konfiguration) und `/var/lib/stalwart` (Daten) |
| Ports | 25, 110, 143, 443, 465, 587, 993, 995, 4190, 8080 |
| Health | `/healthz/live` und `/healthz/ready`; der Healthcheck des Abbilds ruft `https://127.0.0.1:443/healthz/live` **mit dem Kopf `X-Forwarded-For: 127.0.0.1`** und fällt auf `http://127.0.0.1:8080/healthz/live` zurück |
| Gebaute Funktionen | sqlite, postgres, mysql, rocks, s3, redis, azure, nats |
**Die verlinkte Seite beschreibt 0.15 und stimmt nicht mehr:** Dort wird ein einziges Volume unter `/opt/stalwart` gemountet. In 0.16 sind es `/etc/stalwart` und `/var/lib/stalwart`, und die Konfiguration ist JSON, nicht TOML. Die Manifeste dort sind Beispiele, **kein offizielles Helm-Chart** — im Repository gibt es kein `helm/`- oder `k8s/`-Verzeichnis. Die Frage nach einer Chart-Abhängigkeit stellt sich also nicht.
## Drei Kollisionen mit unseren Konventionen
**1. Die Datei-Capability gegen `drop: [ALL]`.** Das Dockerfile setzt `setcap cap_net_bind_service=+ep` auf die Binärdatei — so bindet die Anwendung als uid 2000 die Ports 25, 143 und 443. Unsere Härtungsvorgabe nimmt genau diese Fähigkeit weg. Drei Auswege, alle im Repo schon einmal begangen:
- hohe Ports in der Anwendung, Abbildung im Service (`port: 25` → `targetPort: 2525`) — Capabilities bleiben vollständig entfernt;
- `capabilities.add: [NET_BIND_SERVICE]` als dokumentierte Abweichung;
- der sysctl-Weg wie bei `rss-bridge` (`net.ipv4.ip_unprivileged_port_start`).
**2. Die Konfiguration ist Zustand, nicht Vorgabe.** Ohne `config.json` startet Stalwart im **Bootstrap-Modus**: kein Maildienst, ein Assistent auf Port 8080, und am Ende **schreibt der Server die Datei selbst** und startet neu. `/etc/stalwart` muss also beschreibbar sein. Eine gerenderte ConfigMap mit `checksum/config` — unsere Regel §9 — passt darauf nicht; das Muster ist eher `homeassistant`, das seine Konfiguration ebenfalls selbst verwaltet.
**3. Ein Ingress kann Mail nicht tragen.** SMTP, IMAP, POP3 und ManageSieve sind kein HTTP. Für die Weboberfläche und JMAP ist ein Ingress richtig, für die übrigen acht Ports braucht es einen Service vom Typ LoadBalancer oder NodePort — im Repo gibt es NodePort-Vorbilder (`minecraft-router`, `teamspeak-server`, `samba`).
## Was ohnehin gut passt
- **Nicht-Root ist Bildvorgabe** (uid 2000), anders als bei ConvertX oder Valkey. Ein schreibgeschütztes Root-Dateisystem sollte mit den zwei Volumes erreichbar sein.
- **Der Wiederherstellungs-Zugang lässt sich festlegen:** Ohne Vorgabe erzeugt Stalwart bei jedem Bootstrap ein temporäres Administratorkonto und **schreibt das Kennwort nach stdout** — in Kubernetes also ins Pod-Protokoll und bei jedem Neustart neu. Die Umgebungsvariable `STALWART_RECOVERY_ADMIN` fixiert es; das ist der natürliche Platz für ein `OnePasswordItem`.
- **Health-Endpunkte existieren** und sind getrennt in live und ready — die Voraussetzung dafür, §12.3 richtig umzusetzen.
## Zu entscheiden, bevor umgesetzt wird
1. **Einzelknoten oder Cluster?** Die verlinkte Seite gehört zum Cluster-Kapitel, ihr Manifest zeigt aber `replicaCount: 1`. Ein Cluster verlangt einen externen Datenspeicher (Postgres, MySQL, S3) **und** einen Koordinierungsweg (peer-to-peer, oder Kafka, NATS, Redis). Ein Einzelknoten mit eingebettetem RocksDB auf einer PVC ist erheblich einfacher und deckt einen Familien- oder Vereins-Mailserver vollständig ab. Empfehlung: **Einzelknoten zuerst**, Cluster später als eigenes Vorhaben — ein Mailserver ist schon ohne Cluster die komplizierteste Gattung in diesem Repo.
2. **Wie kommen die Mail-Ports nach außen?** LoadBalancer setzt eine entsprechende Infrastruktur voraus, NodePort verschiebt die Portwahl in den Cluster-Bereich. Und die eigentliche Frage dahinter: **Hat der Cluster überhaupt eingehende Erreichbarkeit auf Port 25 samt passendem Reverse-DNS?** Ohne das ist ein Mailserver nicht betreibbar, und das entscheidet nichts im Chart.
3. **Privilegierte Ports:** hohe Ports plus Service-Abbildung, `NET_BIND_SERVICE`, oder sysctl?
4. **TLS:** Stalwart bringt eigenes ACME mit. Alternativ ein von cert-manager erzeugtes Secret einhängen. Beides schließt sich aus, und die Wahl entscheidet, ob Port 80 von außen erreichbar sein muss.
5. **Konfiguration:** den Bootstrap-Assistenten einmalig laufen lassen und die Datei auf der PVC belassen (wie homeassistant), oder eine vollständige `config.json` vorgeben und den Assistenten nie sehen? Letzteres wäre reproduzierbarer, bräuchte aber eine gepflegte Vorlage für eine große JSON-Datei — und die Weboberfläche schreibt möglicherweise dagegen.
## Was gemessen werden muss
- **Antwortet `/healthz/live` für die Probe überhaupt?** Der Healthcheck des Abbilds setzt `X-Forwarded-For: 127.0.0.1` — ein Hinweis darauf, dass der Endpunkt auf vertrauenswürdige Netze beschränkt sein könnte. Das ist genau die Falle aus §12.5 (`patchmon` braucht einen `Host`-Kopf). Am laufenden Dienst zu prüfen, nicht am Pfadnamen.
- **Was der Bootstrap-Modus für den Install-Test bedeutet.** Ohne `config.json` startet kein Maildienst. Der Test muss also entweder eine Konfiguration mitbringen oder gegen den Bootstrap-Zustand prüfen — und im zweiten Fall belegt er weniger, als er zu belegen scheint.
- **Ob `readOnlyRootFilesystem: true` mit den zwei Volumes hält**, und welche weiteren Schreibpfade nötig sind.
- **Welche Ports die Anwendung tatsächlich öffnet**, abhängig von der Konfiguration — die Portliste des Abbilds ist eine Obergrenze, keine Zusage.
## Umfang, wenn entschieden ist
Chart `stalwart` mit StatefulSet, zwei Volumes, Service für Mail (Typ offen) plus Ingress für Weboberfläche und JMAP, `OnePasswordItem` für den Wiederherstellungs-Zugang, Probes auf den getrennten Endpunkten, strenges Schema, `NOTES.txt` mit den Feldern des Tresor-Eintrags und den DNS-Einträgen, die ein Mailserver braucht (MX, SPF, DKIM, DMARC, Reverse-DNS). Dazu Publish-Workflow, Eintrag in `change-app-version.yml`, CI-Fixtures, Renovate-Block mit Verankerung am `v`-Präfix, README-Zeile, Wiki-Seite.
Freigegebener Umsetzungsplan — Cluster-fähiges Chart, Konfiguration bleibt beim Betreiber
Die fünf Entscheidungen aus der Bestandsaufnahme sind gefallen. Dazu kam ein Befund, der den Zuschnitt des Charts bestimmt und in der Bestandsaufnahme noch fehlte, weil er aus v0.16 stammt und die verlinkte 0.15-Doku ihn nicht kennt.
Der Befund: v0.16 hat die Konfiguration in die Datenbank verlegt
config.json auf Platte enthält nur noch ein Objekt — wo die Datenbank liegt:
{"@type":"RocksDb","path":"/var/lib/stalwart/"}
Alles andere — über 2 400 Einstellungen in 117 Objekten, darunter Listener, Blob-Store, In-Memory-Store, TLS-Objekte, Domains und die ClusterRole-Objekte — liegt im Data-Store und wird über die JMAP-API verwaltet (WebUI oder stalwart-cli). Die REST-API aus v0.15 ist entfallen. Begründung von Upstream ist genau unser Anwendungsfall: im alten Modell musste jeder Cluster-Knoten seine eigene Konfigurationsdatei synchron halten.
Folge für dieses Chart: es kann S3, Valkey, Listener und Rollen nicht deklarativ setzen. Ein stalwart-cli apply-Job wäre der Weg dorthin, ist aber verworfen — siehe Entscheidung 5. Das Chart rendert also config.json (Data-Store), die Workloads samt STALWART_ROLE, die Services, die NetworkPolicies und den Ingress. Der Rest ist Betreiberarbeit und gehört deshalb vollständig in die Wiki-Seite.
Die Entscheidungen
1. Einzelknoten oder Cluster → Cluster, direkt mehrknotenfähig.
Nicht symmetrisch, sondern mit Rollen: eine Gruppe mit allen Aufgaben und Listenern, eine zweite, die nur SMTP annimmt. Das ist in Stalwart erstklassig vorgesehen — eine ClusterRole schaltet Tasks und Listener getrennt, der Knoten wählt sie über STALWART_ROLE. Das dokumentierte Beispiel ist genau dieser Fall:
Beide Felder kennen EnableAll, DisableAll, EnableSome (mit taskTypes/listenerIds) und DisableSome. Die Tasks: storeMaintenance, accountMaintenance, metricsCalculate, metricsPush, pushNotifications, searchIndexing, spamClassifierTraining, outboundMta, taskQueueProcessing, taskScheduler. Nur spamClassifierTraining darf laut Doku höchstens auf einem Knoten laufen.
Clustering ist in der Community-Edition unter AGPL-3.0 enthalten, keine Lizenz nötig; Enterprise gatet Multi-Tenancy, Dashboards, KI-Modelle, Read-Replicas und geshardete Stores.
Die Rollenliste in meiner ersten Fassung (Purge, ACME-Erneuerung, iMIP, Kalender-Alarme) stammte aus den 0.15-Seiten und ist ungültig. ACME ist keine Task mehr.
2. Speicher → Postgres als Data-Store, S3 als Blob-Store, Valkey als In-Memory-Store.
Backends werden pro Zweck gewählt, unabhängig von der Knotenzahl (storage.data, storage.blob, storage.fts, storage.lookup). Zwei Knoten erzwingen allerdings externe Stores: das Dateisystem als Blob-Store ist dokumentiert „available only for single node installations", und RocksDB ist nur für Einzelknoten empfohlen.
Die Volltextsuche braucht keinen Zusatzdienst: der Suchspeicher hat eine Default-Variante, die den Data-Store als Index nutzt — Bloom-Filter in der Datenbank, automatische Mehrsprachigkeit, „no additional services are required". Also kein Elasticsearch, kein Meilisearch; die FTS-Last landet auf demselben Postgres und gehört in die Sizing-Überlegung.
Valkey trägt zwei Aufgaben mit einer Verbindung. Valkey ist ausdrücklich unterstützt („the open-source Redis-compatible fork, is also supported through the same variant"), und die Default-Variante des Koordinators „reuses the Redis connection already configured on the in-memory store". Redis/Valkey ist dabei nur als In-Memory-Store zulässig, nie als Data- oder Blob-Store.
Der Valkey-Subchart kommt als Abhängigkeit mit, enabled: false hinter condition: wie von den Konventionen gefordert, dazu ein Wert für eine vorhandene externe Instanz. Ist der Cluster an und ist weder der Subchart noch eine URL gesetzt, bricht das Rendern mit fail ab — der Fehlerfall wird laut statt still. Der Preis der Abhängigkeit ist bekannt und bleibt gültig: ein Valkey-Fix erreicht einen Cluster erst, wenn das äußere Chart neu veröffentlicht wird; ci/helm-dependencies.sh läuft vor Lint, Package, Render und Install; Renovates helmv3-Manager plus ci/renovate-bump-chart.sh sind das Netz und gehören als Kommentar an die gepinnte Version.
3. Mail-Ports nach außen → NodePort 30025 / 30465 / 30993, und die Anwendung bindet dieselben Ports.
Damit ist Entscheidung 3 der Bestandsaufnahme (privilegierte Ports) mit der härtesten der drei Varianten erledigt: capabilities: drop: [ALL], kein NET_BIND_SERVICE, kein sysctl, keine dokumentierte Abweichung. Die setcap-Fähigkeit des Abbilds wird nicht gebraucht, weil nichts unter 1024 gebunden wird. Ausgehendes SMTP auf 25 braucht keine Capability.
Veröffentlicht werden zunächst nur diese drei Ports plus HTTP; 587, 143, 110, 995 und 4190 bleiben außen vor und können später additiv dazukommen.
4. TLS → cert-manager, nicht Stalwarts eigenes ACME.
Abgeleitet aus der Topologie: der HA-Proxy leitet 25, 465 und 993 weiter, nicht 80. Stalwarts HTTP-01-ACME wäre damit von außen nicht erreichbar. Gleichzeitig terminiert der HA-Proxy kein TLS, Stalwart muss das Zertifikat für STARTTLS auf 25 und implizites TLS auf 465/993 also selbst vorweisen — der Ingress deckt nur WebUI und JMAP ab. Also: eine cert-manager-Certificate, das Secret schreibgeschützt in den Pod gehängt, und das TLS-Objekt in der Datenbank auf diese Pfade gezeigt. DNS-01 mit Stalwarts eigenem ACME wäre technisch möglich, würde aber doppeln, was cert-manager im Cluster schon tut.
5. Konfiguration → kein Apply-Job. Das Chart rendert config.json, der Rest wird in der WebUI eingerichtet.
Abgewogen gegen einen Hook-Job, der einen stalwart-cli apply-Plan anwendet. Dagegen entschieden, mit offenem Restpunkt: EnableSome vergleicht group.listener_ids gegen listener.id.id(), also Objekt-IDs, keine Namen. Ein Plan müsste serverseitige IDs kennen; ob apply Verweise über natürliche Schlüssel auflöst, ist unbelegt. Löst es sie nicht, ist der Job kein Rendering mehr, sondern ein kleiner Reconciler im Chart — mehr, als ein Helm-Chart hier tragen soll. Die Erstinstallation bliebe ohnehin interaktiv, weil erst der Assistent ein API-Token erzeugt.
Konsequenz, die benannt gehört: nodes.*.tasks gibt es in der values.yaml nicht. Welche Tasks eine Rolle fährt, kann das Chart nicht setzen, und ein Wert, der nichts tut, ist nach dem Maßstab dieses Repositories schlechter als keiner.
Die Cluster-Topologie, und was sie entscheidet
Drei CP-Knoten, zwei Worker. Davor ein Server mit HA-Proxy, der 25, 465 und 993 auf einen der drei CP-Knoten verteilt (je nachdem, welcher erreichbar ist) und dort auf 30025, 30465, 30993. Auf CP-Knoten wird nie ein Stalwart-Pod geplant.
externalTrafficPolicy: Local ist damit unmöglich. Es berücksichtigt nur Pods auf dem empfangenden Knoten; auf einem CP-Knoten gibt es nie einen, also würde jedes Paket verworfen. Es muss Cluster sein — und damit maskiert kube-proxy die Quell-IP auf die des CP-Knotens, weil die Antwort sonst über einen anderen Knoten zurückläuft als den, mit dem der Absender seine Verbindung hat.
Der HA-Proxy löst das vollständig, und zwar für Port 25 mit. Mein früherer Einwand — fremde Mailserver senden keinen PROXY-Header — gilt nur bei direktem DNAT. Hier nimmt der HA-Proxy die Verbindung an und baut eine eigene zum CP-Knoten auf; auf dieser setzt er mit send-proxy-v2 den Header. Die echte Absender-IP reist damit im PROXY-Header statt im IP-Kopf, und die Maskierung wird gleichgültig.
Ohne das kostet Cluster bei einem Mailserver, in dieser Reihenfolge: die automatische Sperre schaltet den Dienst ab (sie sperrt die maskierte Knoten-IP und damit alle Absender — hinter Traefik dokumentiert, von außen auslösbar); SPF prüft die falsche Adresse und reißt DMARC mit; Blocklisten melden alles als sauber; Ratenbegrenzungen pro IP kollabieren auf eine Adresse; Greylisting verliert seinen Schlüssel; und die Received:-Kette wird wertlos — der einzige dieser Punkte, der nicht reparabel ist, sondern rückwirkend Beweismittel vernichtet.
Welche Netze vertraut werden, hängt an der Topologie — und geht nur wegen ihr auf. Ein vertrautes Netz macht den PROXY-Header zur Pflicht: eine Verbindung von dort ohne Header wird fehlgedeutet und hängt bis zum Zeitablauf. In Kubernetes ist das gefährlich, weil Kubelet-Probes von einer Knoten-IP kommen.
Verkehr
Quell-IP, die Stalwart sieht
vertraut?
Mail über HA-Proxy
IP des CP-Knotens (nach Maskierung)
ja
Kubelet-Probes
IP des Worker-Knotens mit dem Pod
nein
Ingress zu WebUI/JMAP
Pod-IP des Ingress-Controllers
nein, anderer Mechanismus
proxyTrustedNetworks enthält also nur die drei CP-Knoten-IPs. Weil dort nie ein Stalwart-Pod läuft, sind Probe-Quellen und vertraute Adressen disjunkt. Das ist keine Betriebsregel mehr, sondern eine tragende Annahme des Entwurfs: wer später einen Stalwart-Pod auf einen CP-Knoten lässt, bricht sie.
Welche Adresse die Maskierung tatsächlich einsetzt, ist zu messen, nicht anzunehmen — sie steht nach der ersten Verbindung in der Received:-Kopfzeile.
Was der Chart rendert
Objekt
Form
config.json
ConfigMap mit nur dem Postgres-DataStore-Objekt, checksum/config am Pod-Template
Workload
ein StatefulSet pro Knotengruppe, ohne volumeClaimTemplates (Zustand liegt extern), STALWART_HOSTNAME aus dem Pod-Namen für das Node-ID-Lease
Service je Port
smtp → NodePort 30025, Selektor full + mx; submissions → 30465 und imaps → 30993, Selektor full; http → ClusterIP für den Ingress
Headless-Service
je Gruppe, für die stabilen DNS-Namen des StatefulSets
NetworkPolicy
je Gruppe, Ingress nur auf deren Ports, Egress auf DNS, Postgres, S3, Valkey und TCP/25 nach außen — einheitlich für alle Gruppen
Ingress
nur für die Gruppe aus ingress.nodeGroup, TLS-Secret tls-<release>-stalwart-ingress
cert-manager, für die Mail-Ports, Secret schreibgeschützt gehängt
Services pro Port, nicht pro Gruppe — NodePorts sind cluster-global eindeutig, also können full und mx SMTP nur hinter demselben Service annehmen. Alle Ports in einen Service zu legen geht nicht, weil ein Service einen Selektor hat und 30993 dann auf mx-Pods geroutet würde, wo nichts lauscht. Umgesetzt über ein Label pro Port am Pod, aus exposedPorts gerendert.
Die Egress-Regel ist bewusst einheitlich. Sie je Gruppe aus outboundMta abzuleiten wäre falsch: das Chart kennt die Tasks nicht, der Wert liefe mit der Datenbank auseinander, und der Fehlerfall wäre stilles Ausbleiben ausgehender Mail.
values.yaml
scaleDown:false# überschreibt alle nodes.*.replicas mit 0limitFactor:3ingress:domain:""# required, ohne DefaultclusterIssuer:""# required, ohne DefaultclassName:nginxannotations:{}nodeGroup:fullnetworkPolicy:enabled:true# CNI setzt durch, bestätigtservice:externalTrafficPolicy:Cluster# Pods nur auf Workern, Eintritt über CP-Knoten:# Local würde alles verwerfen. Die Quell-IP# liefert der PROXY-Header des HA-Proxy.dataStore:# der einzige Teil, der nach config.json gehtpostgres:host:""port:5432database:stalwartsecretRef:""# op://… — username und passwordtls:{enable:true, allowInvalidCerts:false}poolMaxConnections:10timeout:15s# NUR für Egress-Regeln und den Subchart. Blob- und In-Memory-Store werden# in der WebUI eingerichtet; diese Werte konfigurieren Stalwart nicht.externalServices:s3:{host:"", port:443}valkey:{host:"", port:6379}# leer → Service des Subcharts# Portnummern. Müssen mit den Listener-Objekten in der Datenbank# übereinstimmen — weicht eine ab, zeigt der Service auf einen toten Port# und nichts meldet es. Die Dopplung ist nicht vermeidbar.ports:smtp:{number:30025, nodePort:30025, public:true}submissions:{number:30465, nodePort:30465, public:true}imaps:{number:30993, nodePort:30993, public:true}http:{number:8080, public:false}# Je Eintrag ein StatefulSet, Label pro Port, eine NetworkPolicy. Der# Schlüssel ist der Rollenname und landet in STALWART_ROLE.nodes:full:replicas:1exposedPorts:[smtp, submissions, imaps, http]resources:{requests:{cpu:200m, memory:512Mi } }mx:replicas:0# erst hoch, wenn die Rolle "mx" existiertexposedPorts:[smtp, http] # http nur intern, für die Probesresources:{requests:{cpu:100m, memory:256Mi } }securityContext:pod:runAsNonRoot:truerunAsUser:2000runAsGroup:2000seccompProfile:{type:RuntimeDefault }container:readOnlyRootFilesystem:trueallowPrivilegeEscalation:falsecapabilities:{drop:[ALL] } # kein NET_BIND_SERVICE: hohe Portsvalkey:enabled:false
Als Karte, nicht als Liste — bei einer Liste wären alle Überschreibungen index-basiert (--set nodes[1].replicas=2), und eine später eingefügte Gruppe würde jede bestehende Überschreibung still auf die falsche zeigen.
nodes.*.exposedPorts ist keine Kopie der Listener-Konfiguration aus der Datenbank, sondern eine eigene Aussage: „diese Gruppe darf nur hier erreicht werden". Deshalb sind beide Abweichungsrichtungen harmlos — öffnet die Rolle mehr, ist es unerreichbar; erlaubt die Policy mehr, lauscht nichts. Das ist der Grund, warum sie das Fail-Open entschärft.
Jede Gruppe braucht den HTTP-Listener für die Probes, auch mx. Er wird nicht veröffentlicht; die NetworkPolicy muss dafür Ingress auf 8080 von den Worker-Knoten-IPs zulassen.
Fail-Open: die Falle, gegen die die NetworkPolicy steht
Aus dem Quellcode, nicht aus der Doku. crates/store/src/registry/bootstrap.rs:34-50 sucht die Rolle nach Namen; findet sie keine, ruft es build_error — und das setzt has_fatal_errorsnicht (Zeile 153). Der Start läuft weiter, bp.role bleibt None. In crates/common/src/config/server/listener.rs:50 steht dann:
is_none_or liefert bei Nonetrue — für jeden Listener. Dasselbe gilt für die Tasks.
Ein Knoten, dessen Rolle nicht in der Datenbank steht, wird zum Vollknoten. Der mx-Pod würde 993 und die Admin-Oberfläche binden. Der Pod ist Running, im Log steht eine Zeile Cluster role "mx" not found in registry, und eine Readiness-Probe wäre grün. Kubernetes kann es nicht erkennen.
Das trifft nicht nur die Erstinstallation, sondern jeden Tippfehler im Rollennamen und jede Umbenennung in der values.yaml ohne Umbenennung in der Datenbank. Gegenmittel, die wirken: mx startet mit replicas: 0; der Service je Port veröffentlicht nur, was gewollt ist; und die NetworkPolicy ist die Obergrenze der Erreichbarkeit, unabhängig davon, was der Prozess gebunden hat. Dokumentiert als Falle, nicht wegerklärt.
Erstinstallation: die Pflichtschritte
Bei leerer Datenbank gehen alle Pods in den Bootstrap-Modus — „No configuration file was found. Port 8080 is open for initial setup." Kein Maildienst. Mit zwei Gruppen hinter einem Service wäre der Assistent nicht benutzbar, weil jede Anfrage auf einem zufälligen Pod landet. Daher:
Installation mit nodes.mx.replicas: 0.
Assistenten auf der full-Gruppe durchlaufen; STALWART_RECOVERY_ADMIN setzt das Kennwort fest, statt es bei jedem Bootstrap neu ins Pod-Protokoll zu schreiben.
In der WebUI einrichten: Blob-Store (S3), In-Memory-Store (Valkey), Koordinator (Default), Suchspeicher (Default), die Listener auf 30025/30465/30993 und 8080, das TLS-Objekt auf das cert-manager-Secret, proxyTrustedNetworks auf die drei CP-Knoten-IPs, und die beiden ClusterRole-Objekte full und mx.
Am HA-Proxy send-proxy-v2 setzen. Das ist ein Alles-oder-nichts-Schritt zusammen mit 3: fehlt der Header bei vertrautem Netz, hängt jede Verbindung; kommt er bei nicht vertrautem Netz, liest Stalwart PROXY TCP4 … als SMTP-Befehl. Erprobt wird auf einem freien Port mit eigenem Frontend, und erst wenn die Received:-Zeile die echte Absender-IP zeigt, wird 25 umgestellt.
Pods neu starten — „A newly added listener binds only on the next start."
nodes.mx.replicas hochsetzen.
DNS: MX, SPF, DKIM, DMARC und Reverse-DNS auf den HA-Proxy.
Und die Falle, die der Ingress mitbringt und die der HA-Proxy nicht löst: Stalwart sieht für jede JMAP- und WebUI-Anfrage die Pod-IP des Ingress-Controllers, und die automatische Sperre greift irgendwann diese — dann sind WebUI und JMAP für alle tot. Für HTTP ist nicht das PROXY-Protokoll das Mittel, sondern forwarded mit Auswertung von X-Forwarded-For, bei gleichzeitigem Verwerfen vom Client mitgebrachter Weiterleitungs-Kopfzeilen. Pflichtschritt, weil der Ausfall erst Wochen später eintritt und dann nach einem Serverproblem aussieht.
Was am laufenden Dienst zu messen ist
Die Punkte aus der Bestandsaufnahme bleiben gültig, zwei kommen hinzu:
Antwortet /healthz/live für die Probe? Der Healthcheck des Abbilds setzt X-Forwarded-For: 127.0.0.1 — ein Hinweis, dass der Endpunkt auf vertraute Netze beschränkt sein könnte. Jetzt doppelt relevant, weil wir mit proxyTrustedNetworks arbeiten. Am Dienst prüfen, nicht am Pfadnamen.
Hält readOnlyRootFilesystem: true mit /etc/stalwart und /var/lib/stalwart als emptyDir, und muss unter /var/lib/stalwart etwas einen Neustart überleben, wenn Daten und Blobs extern liegen? Fällt die Antwort auf ja, braucht es doch eine PVC je Gruppe.
Schreibt Stalwart config.json selbst, wenn die Datei existiert und auf ein leeres Postgres zeigt? Die Bestandsaufnahme nennt das Selbst-Schreiben für den Fall einer fehlenden Datei. Wir liefern eine, der Bootstrap-Grund wäre also ein leeres Register, nicht eine fehlende Datei. Davon hängt ab, ob /etc/stalwart beschreibbar sein muss — Vorschlag: emptyDir plus Init-Container, der die Datei aus der ConfigMap hineinkopiert, damit checksum/config greift und der Pfad trotzdem beschreibbar bleibt.
Welche Adresse die SNAT-Maskierung einsetzt — bestimmt den Inhalt von proxyTrustedNetworks.
Ob --service-node-port-range 30025 bis 30993 einschließt (Vorgabe 30000–32767).
Lieferumfang
Chart stalwart nach den Konventionen, dazu: Publish-Workflow über workflow-util/pipelines, Eintrag in change-app-version.yml, CI-Fixtures unter ci/stalwart/, Renovate-customManagers-Block mit Verankerung am v-Präfix, README-Zeile, Valkey-Abhängigkeit mit Kommentar an der gepinnten Version, NOTES.txt mit den Tresor-Feldern und den DNS-Einträgen.
Wiki, im selben Durchgang: neue Chart-Seite nach dem Muster von outline mit vollständiger Werte-Tabelle, der siebenschrittigen Erstinstallation und einem Abschnitt „Traps" für Fail-Open, die Portnummern-Dopplung, externalTrafficPolicy und die Ingress-Sperrfalle; eine Zeile in Charts; ein Eintrag in _Sidebar.md; und ein Eintrag in Lessons-Learned zum Quell-IP-Mechanismus, weil er sich bei jedem Chart wiederholt, dessen Anwendung die Quell-IP auswertet — das ist nicht auf Mail beschränkt.
# Freigegebener Umsetzungsplan — Cluster-fähiges Chart, Konfiguration bleibt beim Betreiber
Die fünf Entscheidungen aus der Bestandsaufnahme sind gefallen. Dazu kam ein Befund, der den Zuschnitt des Charts bestimmt und in der Bestandsaufnahme noch fehlte, weil er aus v0.16 stammt und die verlinkte 0.15-Doku ihn nicht kennt.
## Der Befund: v0.16 hat die Konfiguration in die Datenbank verlegt
`config.json` auf Platte enthält **nur noch ein Objekt** — wo die Datenbank liegt:
```json
{"@type":"RocksDb","path":"/var/lib/stalwart/"}
```
Alles andere — über 2 400 Einstellungen in 117 Objekten, darunter Listener, Blob-Store, In-Memory-Store, TLS-Objekte, Domains und die `ClusterRole`-Objekte — liegt im Data-Store und wird über die JMAP-API verwaltet (WebUI oder `stalwart-cli`). Die REST-API aus v0.15 ist entfallen. Begründung von Upstream ist genau unser Anwendungsfall: im alten Modell musste jeder Cluster-Knoten seine eigene Konfigurationsdatei synchron halten.
Belege: [Blog v0.16](https://stalw.art/blog/stalwart-0-16/), [Configuration Overview](https://stalw.art/docs/configuration/), [Discussion #2892](https://github.com/stalwartlabs/stalwart/discussions/2892), [UPGRADING/v0_16.md](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md).
**Folge für dieses Chart:** es kann S3, Valkey, Listener und Rollen nicht deklarativ setzen. Ein `stalwart-cli apply`-Job wäre der Weg dorthin, ist aber **verworfen** — siehe Entscheidung 5. Das Chart rendert also `config.json` (Data-Store), die Workloads samt `STALWART_ROLE`, die Services, die NetworkPolicies und den Ingress. Der Rest ist Betreiberarbeit und gehört deshalb vollständig in die Wiki-Seite.
## Die Entscheidungen
**1. Einzelknoten oder Cluster → Cluster, direkt mehrknotenfähig.**
Nicht symmetrisch, sondern mit Rollen: eine Gruppe mit allen Aufgaben und Listenern, eine zweite, die nur SMTP annimmt. Das ist in Stalwart erstklassig vorgesehen — eine `ClusterRole` schaltet **Tasks und Listener getrennt**, der Knoten wählt sie über `STALWART_ROLE`. Das dokumentierte Beispiel ist genau dieser Fall:
```json
{"name":"frontend","tasks":{"@type":"DisableAll"},"listeners":{"@type":"EnableAll"}}
```
Beide Felder kennen `EnableAll`, `DisableAll`, `EnableSome` (mit `taskTypes`/`listenerIds`) und `DisableSome`. Die Tasks: `storeMaintenance`, `accountMaintenance`, `metricsCalculate`, `metricsPush`, `pushNotifications`, `searchIndexing`, `spamClassifierTraining`, `outboundMta`, `taskQueueProcessing`, `taskScheduler`. Nur `spamClassifierTraining` darf laut Doku höchstens auf einem Knoten laufen.
Clustering ist in der **Community-Edition unter AGPL-3.0** enthalten, keine Lizenz nötig; Enterprise gatet Multi-Tenancy, Dashboards, KI-Modelle, Read-Replicas und geshardete Stores.
Die Rollenliste in meiner ersten Fassung (Purge, ACME-Erneuerung, iMIP, Kalender-Alarme) stammte aus den 0.15-Seiten und ist ungültig. **ACME ist keine Task mehr.**
**2. Speicher → Postgres als Data-Store, S3 als Blob-Store, Valkey als In-Memory-Store.**
Backends werden **pro Zweck** gewählt, unabhängig von der Knotenzahl (`storage.data`, `storage.blob`, `storage.fts`, `storage.lookup`). Zwei Knoten erzwingen allerdings externe Stores: das Dateisystem als Blob-Store ist dokumentiert „available only for single node installations", und RocksDB ist nur für Einzelknoten empfohlen.
Die **Volltextsuche braucht keinen Zusatzdienst**: der Suchspeicher hat eine `Default`-Variante, die den Data-Store als Index nutzt — Bloom-Filter in der Datenbank, automatische Mehrsprachigkeit, „no additional services are required". Also kein Elasticsearch, kein Meilisearch; die FTS-Last landet auf demselben Postgres und gehört in die Sizing-Überlegung.
**Valkey trägt zwei Aufgaben mit einer Verbindung.** Valkey ist ausdrücklich unterstützt („the open-source Redis-compatible fork, is also supported through the same variant"), und die `Default`-Variante des Koordinators „reuses the Redis connection already configured on the in-memory store". Redis/Valkey ist dabei **nur** als In-Memory-Store zulässig, nie als Data- oder Blob-Store.
Der Valkey-Subchart kommt als Abhängigkeit mit, **`enabled: false` hinter `condition:`** wie von den Konventionen gefordert, dazu ein Wert für eine vorhandene externe Instanz. Ist der Cluster an und ist weder der Subchart noch eine URL gesetzt, **bricht das Rendern mit `fail` ab** — der Fehlerfall wird laut statt still. Der Preis der Abhängigkeit ist bekannt und bleibt gültig: ein Valkey-Fix erreicht einen Cluster erst, wenn das äußere Chart neu veröffentlicht wird; `ci/helm-dependencies.sh` läuft vor Lint, Package, Render und Install; Renovates `helmv3`-Manager plus `ci/renovate-bump-chart.sh` sind das Netz und gehören als Kommentar an die gepinnte Version.
**3. Mail-Ports nach außen → NodePort 30025 / 30465 / 30993, und die Anwendung bindet dieselben Ports.**
Damit ist Entscheidung 3 der Bestandsaufnahme (privilegierte Ports) mit der härtesten der drei Varianten erledigt: **`capabilities: drop: [ALL]`, kein `NET_BIND_SERVICE`, kein sysctl, keine dokumentierte Abweichung.** Die `setcap`-Fähigkeit des Abbilds wird nicht gebraucht, weil nichts unter 1024 gebunden wird. Ausgehendes SMTP auf 25 braucht keine Capability.
Veröffentlicht werden zunächst nur diese drei Ports plus HTTP; 587, 143, 110, 995 und 4190 bleiben außen vor und können später additiv dazukommen.
**4. TLS → cert-manager, nicht Stalwarts eigenes ACME.**
Abgeleitet aus der Topologie: der HA-Proxy leitet 25, 465 und 993 weiter, **nicht 80**. Stalwarts HTTP-01-ACME wäre damit von außen nicht erreichbar. Gleichzeitig terminiert der HA-Proxy kein TLS, Stalwart muss das Zertifikat für STARTTLS auf 25 und implizites TLS auf 465/993 also selbst vorweisen — der Ingress deckt nur WebUI und JMAP ab. Also: eine cert-manager-`Certificate`, das Secret schreibgeschützt in den Pod gehängt, und das TLS-Objekt in der Datenbank auf diese Pfade gezeigt. DNS-01 mit Stalwarts eigenem ACME wäre technisch möglich, würde aber doppeln, was cert-manager im Cluster schon tut.
**5. Konfiguration → kein Apply-Job. Das Chart rendert `config.json`, der Rest wird in der WebUI eingerichtet.**
Abgewogen gegen einen Hook-Job, der einen `stalwart-cli apply`-Plan anwendet. Dagegen entschieden, mit offenem Restpunkt: `EnableSome` vergleicht `group.listener_ids` gegen `listener.id.id()`, also **Objekt-IDs, keine Namen**. Ein Plan müsste serverseitige IDs kennen; ob `apply` Verweise über natürliche Schlüssel auflöst, ist unbelegt. Löst es sie nicht, ist der Job kein Rendering mehr, sondern ein kleiner Reconciler im Chart — mehr, als ein Helm-Chart hier tragen soll. Die Erstinstallation bliebe ohnehin interaktiv, weil erst der Assistent ein API-Token erzeugt.
Konsequenz, die benannt gehört: **`nodes.*.tasks` gibt es in der values.yaml nicht.** Welche Tasks eine Rolle fährt, kann das Chart nicht setzen, und ein Wert, der nichts tut, ist nach dem Maßstab dieses Repositories schlechter als keiner.
## Die Cluster-Topologie, und was sie entscheidet
Drei CP-Knoten, zwei Worker. Davor ein Server mit HA-Proxy, der 25, 465 und 993 auf **einen der drei CP-Knoten** verteilt (je nachdem, welcher erreichbar ist) und dort auf 30025, 30465, 30993. **Auf CP-Knoten wird nie ein Stalwart-Pod geplant.**
**`externalTrafficPolicy: Local` ist damit unmöglich.** Es berücksichtigt nur Pods auf dem empfangenden Knoten; auf einem CP-Knoten gibt es nie einen, also würde **jedes** Paket verworfen. Es muss `Cluster` sein — und damit maskiert kube-proxy die Quell-IP auf die des CP-Knotens, weil die Antwort sonst über einen anderen Knoten zurückläuft als den, mit dem der Absender seine Verbindung hat.
**Der HA-Proxy löst das vollständig, und zwar für Port 25 mit.** Mein früherer Einwand — fremde Mailserver senden keinen PROXY-Header — gilt nur bei direktem DNAT. Hier nimmt der HA-Proxy die Verbindung an und baut eine **eigene** zum CP-Knoten auf; auf dieser setzt er mit `send-proxy-v2` den Header. Die echte Absender-IP reist damit im PROXY-Header statt im IP-Kopf, und die Maskierung wird gleichgültig.
Ohne das kostet `Cluster` bei einem Mailserver, in dieser Reihenfolge: die **automatische Sperre schaltet den Dienst ab** (sie sperrt die maskierte Knoten-IP und damit alle Absender — hinter Traefik dokumentiert, von außen auslösbar); **SPF prüft die falsche Adresse und reißt DMARC mit**; **Blocklisten melden alles als sauber**; **Ratenbegrenzungen pro IP kollabieren auf eine Adresse**; **Greylisting verliert seinen Schlüssel**; und die **`Received:`-Kette wird wertlos** — der einzige dieser Punkte, der nicht reparabel ist, sondern rückwirkend Beweismittel vernichtet.
**Welche Netze vertraut werden, hängt an der Topologie — und geht nur wegen ihr auf.** Ein vertrautes Netz macht den PROXY-Header zur **Pflicht**: eine Verbindung von dort ohne Header wird fehlgedeutet und hängt bis zum Zeitablauf. In Kubernetes ist das gefährlich, weil Kubelet-Probes von einer Knoten-IP kommen.
| Verkehr | Quell-IP, die Stalwart sieht | vertraut? |
| --- | --- | --- |
| Mail über HA-Proxy | IP des **CP-Knotens** (nach Maskierung) | ja |
| Kubelet-Probes | IP des **Worker-Knotens** mit dem Pod | nein |
| Ingress zu WebUI/JMAP | Pod-IP des Ingress-Controllers | nein, anderer Mechanismus |
`proxyTrustedNetworks` enthält also **nur die drei CP-Knoten-IPs**. Weil dort nie ein Stalwart-Pod läuft, sind Probe-Quellen und vertraute Adressen **disjunkt**. Das ist keine Betriebsregel mehr, sondern eine tragende Annahme des Entwurfs: wer später einen Stalwart-Pod auf einen CP-Knoten lässt, bricht sie.
**Welche Adresse die Maskierung tatsächlich einsetzt, ist zu messen**, nicht anzunehmen — sie steht nach der ersten Verbindung in der `Received:`-Kopfzeile.
## Was der Chart rendert
| Objekt | Form |
| --- | --- |
| `config.json` | ConfigMap mit **nur** dem Postgres-`DataStore`-Objekt, `checksum/config` am Pod-Template |
| Workload | ein **StatefulSet pro Knotengruppe**, ohne `volumeClaimTemplates` (Zustand liegt extern), `STALWART_HOSTNAME` aus dem Pod-Namen für das Node-ID-Lease |
| Service je Port | `smtp` → NodePort 30025, Selektor `full` + `mx`; `submissions` → 30465 und `imaps` → 30993, Selektor `full`; `http` → ClusterIP für den Ingress |
| Headless-Service | je Gruppe, für die stabilen DNS-Namen des StatefulSets |
| NetworkPolicy | **je Gruppe**, Ingress nur auf deren Ports, Egress auf DNS, Postgres, S3, Valkey und TCP/25 nach außen — **einheitlich für alle Gruppen** |
| Ingress | nur für die Gruppe aus `ingress.nodeGroup`, TLS-Secret `tls-<release>-stalwart-ingress` |
| OnePasswordItem | Postgres-Zugang, S3-Zugang, `STALWART_RECOVERY_ADMIN` |
| Certificate | cert-manager, für die Mail-Ports, Secret schreibgeschützt gehängt |
**Services pro Port, nicht pro Gruppe** — NodePorts sind cluster-global eindeutig, also können `full` und `mx` SMTP nur hinter **demselben** Service annehmen. Alle Ports in einen Service zu legen geht nicht, weil ein Service einen Selektor hat und 30993 dann auf `mx`-Pods geroutet würde, wo nichts lauscht. Umgesetzt über ein Label pro Port am Pod, aus `exposedPorts` gerendert.
Die **Egress-Regel ist bewusst einheitlich**. Sie je Gruppe aus `outboundMta` abzuleiten wäre falsch: das Chart kennt die Tasks nicht, der Wert liefe mit der Datenbank auseinander, und der Fehlerfall wäre stilles Ausbleiben ausgehender Mail.
### values.yaml
```yaml
scaleDown: false # überschreibt alle nodes.*.replicas mit 0
limitFactor: 3
ingress:
domain: "" # required, ohne Default
clusterIssuer: "" # required, ohne Default
className: nginx
annotations: {}
nodeGroup: full
networkPolicy:
enabled: true # CNI setzt durch, bestätigt
service:
externalTrafficPolicy: Cluster # Pods nur auf Workern, Eintritt über CP-Knoten:
# Local würde alles verwerfen. Die Quell-IP
# liefert der PROXY-Header des HA-Proxy.
dataStore: # der einzige Teil, der nach config.json geht
postgres:
host: ""
port: 5432
database: stalwart
secretRef: "" # op://… — username und password
tls: { enable: true, allowInvalidCerts: false }
poolMaxConnections: 10
timeout: 15s
# NUR für Egress-Regeln und den Subchart. Blob- und In-Memory-Store werden
# in der WebUI eingerichtet; diese Werte konfigurieren Stalwart nicht.
externalServices:
s3: { host: "", port: 443 }
valkey: { host: "", port: 6379 } # leer → Service des Subcharts
# Portnummern. Müssen mit den Listener-Objekten in der Datenbank
# übereinstimmen — weicht eine ab, zeigt der Service auf einen toten Port
# und nichts meldet es. Die Dopplung ist nicht vermeidbar.
ports:
smtp: { number: 30025, nodePort: 30025, public: true }
submissions: { number: 30465, nodePort: 30465, public: true }
imaps: { number: 30993, nodePort: 30993, public: true }
http: { number: 8080, public: false }
# Je Eintrag ein StatefulSet, Label pro Port, eine NetworkPolicy. Der
# Schlüssel ist der Rollenname und landet in STALWART_ROLE.
nodes:
full:
replicas: 1
exposedPorts: [smtp, submissions, imaps, http]
resources: { requests: { cpu: 200m, memory: 512Mi } }
mx:
replicas: 0 # erst hoch, wenn die Rolle "mx" existiert
exposedPorts: [smtp, http] # http nur intern, für die Probes
resources: { requests: { cpu: 100m, memory: 256Mi } }
securityContext:
pod:
runAsNonRoot: true
runAsUser: 2000
runAsGroup: 2000
seccompProfile: { type: RuntimeDefault }
container:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] } # kein NET_BIND_SERVICE: hohe Ports
valkey:
enabled: false
```
Als **Karte, nicht als Liste** — bei einer Liste wären alle Überschreibungen index-basiert (`--set nodes[1].replicas=2`), und eine später eingefügte Gruppe würde jede bestehende Überschreibung still auf die falsche zeigen.
`nodes.*.exposedPorts` ist **keine Kopie** der Listener-Konfiguration aus der Datenbank, sondern eine eigene Aussage: „diese Gruppe darf nur hier erreicht werden". Deshalb sind beide Abweichungsrichtungen harmlos — öffnet die Rolle mehr, ist es unerreichbar; erlaubt die Policy mehr, lauscht nichts. Das ist der Grund, warum sie das Fail-Open entschärft.
Jede Gruppe braucht den HTTP-Listener für die Probes, auch `mx`. Er wird nicht veröffentlicht; die NetworkPolicy muss dafür Ingress auf 8080 **von den Worker-Knoten-IPs** zulassen.
## Fail-Open: die Falle, gegen die die NetworkPolicy steht
Aus dem Quellcode, nicht aus der Doku. `crates/store/src/registry/bootstrap.rs:34-50` sucht die Rolle nach **Namen**; findet sie keine, ruft es `build_error` — und das setzt `has_fatal_errors` **nicht** (Zeile 153). Der Start läuft weiter, `bp.role` bleibt `None`. In `crates/common/src/config/server/listener.rs:50` steht dann:
```rust
if bp.role.as_ref().is_none_or(|r| match &r.listeners { ... })
```
`is_none_or` liefert bei `None` **`true`** — für jeden Listener. Dasselbe gilt für die Tasks.
**Ein Knoten, dessen Rolle nicht in der Datenbank steht, wird zum Vollknoten.** Der `mx`-Pod würde 993 und die Admin-Oberfläche binden. Der Pod ist `Running`, im Log steht eine Zeile `Cluster role "mx" not found in registry`, und eine Readiness-Probe wäre **grün**. Kubernetes kann es nicht erkennen.
Das trifft nicht nur die Erstinstallation, sondern jeden Tippfehler im Rollennamen und jede Umbenennung in der values.yaml ohne Umbenennung in der Datenbank. Gegenmittel, die wirken: `mx` startet mit `replicas: 0`; der Service je Port veröffentlicht nur, was gewollt ist; und die NetworkPolicy ist die Obergrenze der Erreichbarkeit, unabhängig davon, was der Prozess gebunden hat. Dokumentiert als Falle, nicht wegerklärt.
## Erstinstallation: die Pflichtschritte
Bei leerer Datenbank gehen alle Pods in den **Bootstrap-Modus** — „No configuration file was found. Port 8080 is open for initial setup." Kein Maildienst. Mit zwei Gruppen hinter einem Service wäre der Assistent nicht benutzbar, weil jede Anfrage auf einem zufälligen Pod landet. Daher:
1. Installation mit `nodes.mx.replicas: 0`.
2. Assistenten auf der `full`-Gruppe durchlaufen; `STALWART_RECOVERY_ADMIN` setzt das Kennwort fest, statt es bei jedem Bootstrap neu ins Pod-Protokoll zu schreiben.
3. In der WebUI einrichten: Blob-Store (S3), In-Memory-Store (Valkey), Koordinator (`Default`), Suchspeicher (`Default`), die **Listener auf 30025/30465/30993 und 8080**, das TLS-Objekt auf das cert-manager-Secret, `proxyTrustedNetworks` auf die drei CP-Knoten-IPs, und die beiden `ClusterRole`-Objekte `full` und `mx`.
4. Am HA-Proxy `send-proxy-v2` setzen. **Das ist ein Alles-oder-nichts-Schritt zusammen mit 3:** fehlt der Header bei vertrautem Netz, hängt jede Verbindung; kommt er bei nicht vertrautem Netz, liest Stalwart `PROXY TCP4 …` als SMTP-Befehl. Erprobt wird auf einem freien Port mit eigenem Frontend, und erst wenn die `Received:`-Zeile die echte Absender-IP zeigt, wird 25 umgestellt.
5. Pods neu starten — „A newly added listener binds only on the next start."
6. `nodes.mx.replicas` hochsetzen.
7. DNS: MX, SPF, DKIM, DMARC und Reverse-DNS auf den HA-Proxy.
Und die Falle, die der Ingress mitbringt und die der HA-Proxy **nicht** löst: Stalwart sieht für jede JMAP- und WebUI-Anfrage die Pod-IP des Ingress-Controllers, und die automatische Sperre greift irgendwann diese — dann sind WebUI und JMAP für alle tot. Für HTTP ist nicht das PROXY-Protokoll das Mittel, sondern `forwarded` mit Auswertung von `X-Forwarded-For`, bei gleichzeitigem Verwerfen vom Client mitgebrachter Weiterleitungs-Kopfzeilen. Pflichtschritt, weil der Ausfall erst Wochen später eintritt und dann nach einem Serverproblem aussieht.
## Was am laufenden Dienst zu messen ist
Die Punkte aus der Bestandsaufnahme bleiben gültig, zwei kommen hinzu:
- **Antwortet `/healthz/live` für die Probe?** Der Healthcheck des Abbilds setzt `X-Forwarded-For: 127.0.0.1` — ein Hinweis, dass der Endpunkt auf vertraute Netze beschränkt sein könnte. Jetzt doppelt relevant, weil wir mit `proxyTrustedNetworks` arbeiten. Am Dienst prüfen, nicht am Pfadnamen.
- **Hält `readOnlyRootFilesystem: true`** mit `/etc/stalwart` und `/var/lib/stalwart` als emptyDir, und muss unter `/var/lib/stalwart` etwas einen Neustart überleben, wenn Daten und Blobs extern liegen? Fällt die Antwort auf ja, braucht es doch eine PVC je Gruppe.
- **Schreibt Stalwart `config.json` selbst**, wenn die Datei existiert und auf ein leeres Postgres zeigt? Die Bestandsaufnahme nennt das Selbst-Schreiben für den Fall einer fehlenden Datei. Wir liefern eine, der Bootstrap-Grund wäre also ein leeres Register, nicht eine fehlende Datei. Davon hängt ab, ob `/etc/stalwart` beschreibbar sein muss — Vorschlag: emptyDir plus Init-Container, der die Datei aus der ConfigMap hineinkopiert, damit `checksum/config` greift und der Pfad trotzdem beschreibbar bleibt.
- **Welche Adresse die SNAT-Maskierung einsetzt** — bestimmt den Inhalt von `proxyTrustedNetworks`.
- **Ob `--service-node-port-range` 30025 bis 30993 einschließt** (Vorgabe 30000–32767).
## Lieferumfang
Chart `stalwart` nach den Konventionen, dazu: Publish-Workflow über `workflow-util/pipelines`, Eintrag in `change-app-version.yml`, CI-Fixtures unter `ci/stalwart/`, Renovate-`customManagers`-Block mit Verankerung am `v`-Präfix, README-Zeile, Valkey-Abhängigkeit mit Kommentar an der gepinnten Version, `NOTES.txt` mit den Tresor-Feldern und den DNS-Einträgen.
Wiki, im selben Durchgang: neue Chart-Seite nach dem Muster von `outline` mit vollständiger Werte-Tabelle, der siebenschrittigen Erstinstallation und einem Abschnitt „Traps" für Fail-Open, die Portnummern-Dopplung, `externalTrafficPolicy` und die Ingress-Sperrfalle; eine Zeile in `Charts`; ein Eintrag in `_Sidebar.md`; und ein Eintrag in `Lessons-Learned` zum Quell-IP-Mechanismus, weil er sich bei jedem Chart wiederholt, dessen Anwendung die Quell-IP auswertet — das ist nicht auf Mail beschränkt.
Halt: Entscheidung 3 ist so nicht baubar — drop: [ALL] verhindert bei diesem Abbild schon das exec
Umsetzung angehalten, bevor die Workloads geschrieben waren. Der Grund ist kein Detail, sondern eine der neun festgelegten Entscheidungen: Entscheidung 3 verlangt capabilities: drop: [ALL], kein NET_BIND_SERVICE, keine dokumentierte Abweichung — und mit diesem Abbild startet der Container dann überhaupt nicht.
Der Befund
Beide veröffentlichten Varianten setzen eine Datei-Capability mit gesetztem Effective-Bit auf die Binärdatei. Aus Dockerfile.build am Tag v0.16.24 — das ist die Datei, aus der die veröffentlichten Abbilder gebaut werden (docker-bake.hcl, Target image):
154: FROM ... debian:trixie-slim AS gnu
164: RUN setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
...
178: FROM ... alpine AS musl
186: RUN setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
Entscheidend ist das e in +ep. capabilities(7), Abschnitt „Safety checking for capability-dumb binaries":
When executing a capability-dumb binary, the kernel checks if the process obtained all permitted capabilities that were specified in the file permitted set, after the capability transformations described above. (The typical reason why this might not occur is that the capability bounding set masked out some of the capabilities 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] leert genau diese Bounding-Set-Menge. F(permitted) & P(bounding) ist damit leer, der Prozess erhält CAP_NET_BIND_SERVICE nicht, das Effective-Bit der Datei ist aber gesetzt — also schlägt execve fehl:
exec /usr/local/bin/stalwart: operation not permitted
Die Begründung in Entscheidung 3 ist für das Binden richtig und für das exec unerheblich. „Nichts unter 1024 wird gebunden" stimmt — die Prüfung des Kernels findet aber bei execve statt, bevor das Programm irgendetwas bindet, und sie fragt nicht, ob die Capability gebraucht wird. Die hohen Ports 30025/30465/30993 sparen die Capability im Betrieb, nicht beim Start.
Was nicht hilft
-alpine statt glibc. Geprüft: beide Targets setzen dasselbe setcap (Zeilen 164 und 186 oben). Es gibt kein veröffentlichtes Abbild ohne die Datei-Capability.
Die Portwahl ändern. Das Problem hängt nicht an den Portnummern.
Ein sysctl (net.ipv4.ip_unprivileged_port_start). Das betrifft das Binden, nicht die execve-Prüfung.
Theoretisch bliebe, die Binärdatei über einen Init-Container in ein emptyDir zu kopieren (cp verliert Datei-Capabilities) und die Kopie zu starten. Das halte ich für nicht vertretbar: es bricht den Abbild-Vertrag, überlebt kein Abbild-Update ohne stille Überraschung, und es wäre eine Eigenbau-Lösung an der empfindlichsten Stelle des Charts.
Also genau das, was Entscheidung 3 ausdrücklich ausgeschlossen hat („keine dokumentierte Abweichung"). Die Abweichung wäre eng begründbar — die Capability wird zum Start gebraucht und nie zum Binden eines privilegierten Ports, weil die Listener auf 30025/30465/30993 liegen; samba hat im Repo bereits den Präzedenzfall einer begründeten Capability.
Was ich brauche: die Freigabe für add: [NET_BIND_SERVICE] als dokumentierte Abweichung in values.yaml — oder eine andere Festlegung. Der Rest des Entwurfs ist davon unberührt; die hohen Ports, der NodePort-Zuschnitt und alles Übrige bleiben wie entschieden.
Vier weitere Korrekturen, alle am Quelltext bzw. an der Objekt-Referenz belegt
Diese sind nicht blockierend — ich habe sie bereits richtig umgesetzt bzw. würde es tun — gehören aber auf das Protokoll, weil der Plan sie anders beschreibt.
1. timeout: 15s ist in config.json ungültig. Dauern sind ganzzahlige Millisekunden.
Die Objekt-Referenz gibt das Feld als Duration? · default: 15000; die serde-Implementierung deserialisiert über u64 → Duration::from_millis. Ein "15s" lässt den Server beim Start abbrechen. Der Wert heißt bei mir deshalb dataStore.postgres.timeoutMs: 15000 — nach seiner Einheit benannt, statt ein Suffix anzunehmen, das die Datei nicht tragen kann.
2. Der Postgres-Benutzername kann nicht ins 1Password-Item. Auf dem DataStore-Objekt ist authSecret vom Typ SecretKeyOptional, authUsername dagegen ein einfacher String ohne Secret-Form. Der Benutzername ist also notwendig öffentlich (dataStore.postgres.username, landet in der ConfigMap); das Item trägt nur password. Upstream-Beschränkung, keine Wahl.
3. Dafür ist das Passwort nicht in der ConfigMap — besser als der Plan annahm.authSecret ist ein getaggtes Unterobjekt mit vier Varianten (None, Value, EnvironmentVariable, File):
config.json bleibt damit vollständig geheimnisfrei und kann eine echte ConfigMap mit checksum/config sein, genau wie geplant — ohne Init-Container-Substitution. Dasselbe gilt für den S3-Blob-Store (secretKey: SecretKeyOptional, accessKey: PublicStringOptional, beide mit EnvironmentVariable-Variante), wodurch das S3-OnePasswordItem tatsächlich konsumiert wird statt nur zu existieren. Ein leerer Umgebungswert gilt dabei als „nicht gefunden" und ist ein harter Fehler.
4. Der Bootstrap-Assistent erscheint nicht, wenn das Chart config.json rendert. Schritt 1–2 der Erstinstallation stimmen so nicht.
Wir liefern immer eine. Gegen eine leere Datenbank ergibt das einen Server ohne Listener, ohne Domain und ohne Administrator — und ohne Weg, das zu ändern. Der dokumentierte Pfad für genau diesen Fall ist der Recovery-Modus:
„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)
Der Recovery-Modus bringt die Verwaltungs-Oberfläche auf 8080 und hält alle Mail-Ports zu. Die Erstinstallation braucht daher einen zusätzlichen ersten Schritt und einen Wert dafür (bootstrap.enabled, Vorgabe false), und aus sieben Schritten werden acht. Die WebUI-Arbeit aus Schritt 3 bleibt unverändert — sie findet nur im Recovery-Modus statt, nicht im Bootstrap-Assistenten.
Nebenbefund, der die Sache schärft: fehlt die Datei ganz (gescheiterter ConfigMap-Mount), bricht der Server nicht ab, sondern öffnet einen flüchtigen In-Memory-Store und läuft scheinbar normal. Ein stiller Totalverlust-Zustand, der in die Wiki-Fallen gehört.
Was bestätigt ist und trägt
Damit nicht nur die Korrekturen im Protokoll stehen — am Quelltext von v0.16.24 geprüft und wie geplant:
config.json ist genau das DataStore-Objekt, kein Umschlag mit data/blob/fts/lookup. Die gegenteilige Stelle in UPGRADING/v0_16.md ist lose Prosa; das Migrationsskript schreibt nachweislich nur DataStore in die Datei. Der @type ist PostgreSql.
Das Fail-Open ist unverändert vorhanden — und schlimmer als beschrieben: es betrifft nicht nur die Listener (listener.rs:50, is_none_or), sondern über network.rs:341-363 auch die Tasks, deren Vorgabe alle zehn Flags auf true setzt. Ein Tippfehler in STALWART_ROLE ergibt einen Knoten mit allen Listenern und allen Tasks. Zudem läuft log_errors()nachListeners::parse — die Listener sind gebunden, bevor die Fehlermeldung erscheint.
ClusterRole hat genau die geplante Form und liegt in der Datenbank; insert_safe_defaults legt keine an, eine frische Installation hat also null Rollen.
/healthz/live und /healthz/ready sind nicht auf vertraute Netze beschränkt — kein Auth, keine Loopback-Prüfung. /healthz/ready prüft allerdings nur, dass das Data-Store-Handle gesetzt ist, und fragt die Datenbank nicht ab.
Neue Falle für die Probes, die der Plan nicht kennt: es gilt ein anonymes Ratenlimit, Vorgabe 100 Anfragen/60 s je Quell-IP. Probes kommen von der Knoten-IP, also teilen sich alle Pods eines Knotens einen Eimer. Bei periodSeconds: 10 sind das 12/min je Pod — ab etwa acht Pods auf einem Knoten kippt es in 429er und damit in Neustarts. Gehört in die Wiki-Fallen und ist ein Argument, periodSeconds nicht zu verkleinern.
Stand der Arbeit
Branch feat/stalwart-chart (lokal, signiert, nicht gepusht, kein PR): Chart.yaml, values.yaml, _helpers.tpl, publish-stalwart.yml, der Eintrag in change-app-version.yml und der Renovate-Block stehen. Die Workloads, Services, NetworkPolicies, der Ingress, die ConfigMap, das Certificate, die OnePasswordItems, NOTES.txt, values.schema.json, die CI-Fixtures, die README-Zeile und die Wiki-Seiten fehlen — der Sicherheitskontext des StatefulSets ist genau das, was oben blockiert.
Sobald die Entscheidung zu NET_BIND_SERVICE da ist, baue ich den Rest in einem Durchgang fertig.
# Halt: Entscheidung 3 ist so nicht baubar — `drop: [ALL]` verhindert bei diesem Abbild schon das `exec`
Umsetzung angehalten, bevor die Workloads geschrieben waren. Der Grund ist kein Detail, sondern eine der neun festgelegten Entscheidungen: **Entscheidung 3 verlangt `capabilities: drop: [ALL]`, kein `NET_BIND_SERVICE`, keine dokumentierte Abweichung** — und mit diesem Abbild startet der Container dann überhaupt nicht.
## Der Befund
Beide veröffentlichten Varianten setzen eine **Datei-Capability mit gesetztem Effective-Bit** auf die Binärdatei. Aus `Dockerfile.build` am Tag `v0.16.24` — das ist die Datei, aus der die veröffentlichten Abbilder gebaut werden (`docker-bake.hcl`, Target `image`):
```
154: FROM ... debian:trixie-slim AS gnu
164: RUN setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
...
178: FROM ... alpine AS musl
186: RUN setcap cap_net_bind_service=+ep /usr/local/bin/stalwart
```
Entscheidend ist das `e` in `+ep`. `capabilities(7)`, Abschnitt „Safety checking for capability-dumb binaries":
> When executing a capability-dumb binary, the kernel checks if the process obtained all permitted capabilities that were specified in the file permitted set, after the capability transformations described above. (The typical reason why this might not occur is that the capability bounding set masked out some of the capabilities 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]` leert genau diese Bounding-Set-Menge. `F(permitted) & P(bounding)` ist damit leer, der Prozess erhält `CAP_NET_BIND_SERVICE` nicht, das Effective-Bit der Datei ist aber gesetzt — also schlägt `execve` fehl:
```
exec /usr/local/bin/stalwart: operation not permitted
```
**Die Begründung in Entscheidung 3 ist für das Binden richtig und für das `exec` unerheblich.** „Nichts unter 1024 wird gebunden" stimmt — die Prüfung des Kernels findet aber bei `execve` statt, bevor das Programm irgendetwas bindet, und sie fragt nicht, ob die Capability gebraucht wird. Die hohen Ports 30025/30465/30993 sparen die Capability im Betrieb, nicht beim Start.
## Was nicht hilft
- **`-alpine` statt glibc.** Geprüft: beide Targets setzen dasselbe `setcap` (Zeilen 164 und 186 oben). Es gibt kein veröffentlichtes Abbild ohne die Datei-Capability.
- **Die Portwahl ändern.** Das Problem hängt nicht an den Portnummern.
- **Ein sysctl** (`net.ipv4.ip_unprivileged_port_start`). Das betrifft das Binden, nicht die `execve`-Prüfung.
Theoretisch bliebe, die Binärdatei über einen Init-Container in ein `emptyDir` zu kopieren (`cp` verliert Datei-Capabilities) und die Kopie zu starten. Das halte ich für nicht vertretbar: es bricht den Abbild-Vertrag, überlebt kein Abbild-Update ohne stille Überraschung, und es wäre eine Eigenbau-Lösung an der empfindlichsten Stelle des Charts.
## Die Entscheidung, die ich nicht selbst treffe
Es bleibt praktisch nur:
```yaml
securityContext:
container:
capabilities:
drop: [ALL]
add: [NET_BIND_SERVICE] # dokumentierte Abweichung
```
Also genau das, was Entscheidung 3 ausdrücklich ausgeschlossen hat („keine dokumentierte Abweichung"). Die Abweichung wäre eng begründbar — die Capability wird zum Start gebraucht und nie zum Binden eines privilegierten Ports, weil die Listener auf 30025/30465/30993 liegen; `samba` hat im Repo bereits den Präzedenzfall einer begründeten Capability.
**Was ich brauche:** die Freigabe für `add: [NET_BIND_SERVICE]` als dokumentierte Abweichung in `values.yaml` — oder eine andere Festlegung. Der Rest des Entwurfs ist davon unberührt; die hohen Ports, der NodePort-Zuschnitt und alles Übrige bleiben wie entschieden.
---
# Vier weitere Korrekturen, alle am Quelltext bzw. an der Objekt-Referenz belegt
Diese sind nicht blockierend — ich habe sie bereits richtig umgesetzt bzw. würde es tun — gehören aber auf das Protokoll, weil der Plan sie anders beschreibt.
**1. `timeout: 15s` ist in `config.json` ungültig. Dauern sind ganzzahlige Millisekunden.**
> „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>)
Die Objekt-Referenz gibt das Feld als `Duration? · default: 15000`; die serde-Implementierung deserialisiert über `u64` → `Duration::from_millis`. Ein `"15s"` lässt den Server beim Start abbrechen. Der Wert heißt bei mir deshalb `dataStore.postgres.timeoutMs: 15000` — nach seiner Einheit benannt, statt ein Suffix anzunehmen, das die Datei nicht tragen kann.
**2. Der Postgres-Benutzername kann nicht ins 1Password-Item.** Auf dem `DataStore`-Objekt ist `authSecret` vom Typ `SecretKeyOptional`, `authUsername` dagegen ein einfacher `String` ohne Secret-Form. Der Benutzername ist also notwendig öffentlich (`dataStore.postgres.username`, landet in der ConfigMap); das Item trägt nur `password`. Upstream-Beschränkung, keine Wahl.
**3. Dafür ist das Passwort *nicht* in der ConfigMap — besser als der Plan annahm.** `authSecret` ist ein getaggtes Unterobjekt mit vier Varianten (`None`, `Value`, `EnvironmentVariable`, `File`):
```json
"authSecret": {"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}
```
`config.json` bleibt damit vollständig geheimnisfrei und kann eine echte ConfigMap mit `checksum/config` sein, genau wie geplant — ohne Init-Container-Substitution. Dasselbe gilt für den S3-Blob-Store (`secretKey`: `SecretKeyOptional`, `accessKey`: `PublicStringOptional`, beide mit `EnvironmentVariable`-Variante), wodurch das S3-`OnePasswordItem` tatsächlich konsumiert wird statt nur zu existieren. Ein **leerer** Umgebungswert gilt dabei als „nicht gefunden" und ist ein harter Fehler.
**4. Der Bootstrap-Assistent erscheint nicht, wenn das Chart `config.json` rendert. Schritt 1–2 der Erstinstallation stimmen so nicht.**
> „The first time Stalwart is started **without** a `config.json`, it enters bootstrap mode." (<https://stalw.art/docs/configuration/bootstrap-mode>)
Wir liefern immer eine. Gegen eine leere Datenbank ergibt das einen Server ohne Listener, ohne Domain und ohne Administrator — und ohne Weg, das zu ändern. Der dokumentierte Pfad für genau diesen Fall ist der **Recovery-Modus**:
> „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>)
Der Recovery-Modus bringt die Verwaltungs-Oberfläche auf 8080 und hält alle Mail-Ports zu. Die Erstinstallation braucht daher einen zusätzlichen ersten Schritt und einen Wert dafür (`bootstrap.enabled`, Vorgabe `false`), und aus sieben Schritten werden acht. Die WebUI-Arbeit aus Schritt 3 bleibt unverändert — sie findet nur im Recovery-Modus statt, nicht im Bootstrap-Assistenten.
**Nebenbefund, der die Sache schärft:** fehlt die Datei ganz (gescheiterter ConfigMap-Mount), bricht der Server *nicht* ab, sondern öffnet einen **flüchtigen In-Memory-Store** und läuft scheinbar normal. Ein stiller Totalverlust-Zustand, der in die Wiki-Fallen gehört.
## Was bestätigt ist und trägt
Damit nicht nur die Korrekturen im Protokoll stehen — am Quelltext von `v0.16.24` geprüft und wie geplant:
- `config.json` ist **genau** das `DataStore`-Objekt, kein Umschlag mit `data`/`blob`/`fts`/`lookup`. Die gegenteilige Stelle in `UPGRADING/v0_16.md` ist lose Prosa; das Migrationsskript schreibt nachweislich nur `DataStore` in die Datei. Der `@type` ist `PostgreSql`.
- Das **Fail-Open** ist unverändert vorhanden — und schlimmer als beschrieben: es betrifft nicht nur die Listener (`listener.rs:50`, `is_none_or`), sondern über `network.rs:341-363` auch die **Tasks**, deren Vorgabe alle zehn Flags auf `true` setzt. Ein Tippfehler in `STALWART_ROLE` ergibt einen Knoten mit allen Listenern *und* allen Tasks. Zudem läuft `log_errors()` **nach** `Listeners::parse` — die Listener sind gebunden, bevor die Fehlermeldung erscheint.
- `ClusterRole` hat genau die geplante Form und liegt in der Datenbank; `insert_safe_defaults` legt **keine** an, eine frische Installation hat also null Rollen.
- `/healthz/live` und `/healthz/ready` sind **nicht** auf vertraute Netze beschränkt — kein Auth, keine Loopback-Prüfung. `/healthz/ready` prüft allerdings nur, dass das Data-Store-Handle gesetzt ist, und fragt die Datenbank **nicht** ab.
- **Neue Falle für die Probes, die der Plan nicht kennt:** es gilt ein anonymes Ratenlimit, Vorgabe **100 Anfragen/60 s je Quell-IP**. Probes kommen von der Knoten-IP, also teilen sich alle Pods eines Knotens einen Eimer. Bei `periodSeconds: 10` sind das 12/min je Pod — ab etwa acht Pods auf einem Knoten kippt es in 429er und damit in Neustarts. Gehört in die Wiki-Fallen und ist ein Argument, `periodSeconds` nicht zu verkleinern.
## Stand der Arbeit
Branch `feat/stalwart-chart` (lokal, signiert, nicht gepusht, kein PR): `Chart.yaml`, `values.yaml`, `_helpers.tpl`, `publish-stalwart.yml`, der Eintrag in `change-app-version.yml` und der Renovate-Block stehen. Die Workloads, Services, NetworkPolicies, der Ingress, die ConfigMap, das Certificate, die `OnePasswordItem`s, `NOTES.txt`, `values.schema.json`, die CI-Fixtures, die README-Zeile und die Wiki-Seiten fehlen — der Sicherheitskontext des StatefulSets ist genau das, was oben blockiert.
Sobald die Entscheidung zu `NET_BIND_SERVICE` da ist, baue ich den Rest in einem Durchgang fertig.
Entscheidung zum Blocker: Entscheidung 3 wird umgekehrt
Der Befund im Kommentar davor trägt. Nachgeprüft gegen capabilities(7), wörtlich:
„If the process did not obtain the full set of file permitted capabilities, then execve(2) fails with the error EPERM."
Und zum Auslöser:
„the capability bounding set masked out some of the capabilities in the file permitted set"
drop: [ALL] leert genau dieses Bounding-Set. Weil setcap cap_net_bind_service=+ep das Effective-Bit setzt, ist die Binärdatei ein „capability-dumb binary", und die Prüfung läuft beim execve — vor jedem Bind. Damit fallen alle drei Auswege aus der Bestandsaufnahme aus demselben Grund weg, und der Grund ist das exec, nicht das Binden: hohe Ports helfen nicht, -alpine hat dasselbe setcap, und der sysctl-Weg adressiert ebenfalls nur das Binden.
Die Planbegründung („nichts unter 1024 wird gebunden, also wird die Capability nicht gebraucht") war für das Binden richtig und für die Kernel-Prüfung unerheblich. Der Fehler liegt beim Planautor, nicht beim Umsetzer; das Anhalten vor dem PR war richtig.
Ein vierter Weg wurde geprüft und verworfen: ein Init-Container mit CAP_SETFCAP, der die Binärdatei in ein emptyDir kopiert und die Capability entfernt. Technisch gangbar, tauscht aber eine harmlose Capability gegen eine gefährliche und lässt den Hauptcontainer eine Binärdatei aus einem beschreibbaren Volume ausführen.
Festgelegt
1. drop: [ALL] plus add: [NET_BIND_SERVICE] als dokumentierte Abweichung, Präzedenz samba. Der Kommentar in values.yaml muss zwei Dinge sagen, nicht eines:
warum sie dasteht — die execve-Regel, nicht ein Bedarf beim Binden;
dass sie nie benutzt wird, solange die Mail-Ports über den Service abgebildet werden.
Ohne den zweiten Satz entfernt die nächste Härtungsrunde sie als unbenutzt, und der Chart startet nicht mehr. Fällt die Datei-Capability bei Upstream je weg, ist das die Stelle, an der die Abweichung wieder verschwindet.
2. Der Container bindet die üblichen Ports 25, 465 und 993. Der NodePort bleibt 30025/30465/30993, die Anforderung des Betreibers ist damit erfüllt — der NodePort-Bereich beginnt ohnehin bei 30000. Der Service bildet ab: nodePort: 30025 → targetPort: 25.
Begründung: da die Capability ohnehin gewährt werden muss, ist die Tiefenverteidigung hoher Ports hinfällig — der Prozess könnte jederzeit einen privilegierten Port binden. Was bleibt, ist der Gewinn an Erwartbarkeit: die Listener-Objekte, die in der WebUI angelegt werden, tragen die Zahlen aus jeder Stalwart-Doku und aus der EXPOSE-Liste des Abbilds. Die unvermeidliche Portnummern-Dopplung aus Fall 1 wird dadurch erheblich weniger überraschend.
Folgen für den Entwurf:
ports trägt je Eintrag zwei Zahlen (containerPort und nodePort) statt einer.
Die NetworkPolicy bezieht sich auf die Pod-Ports, also auf 25/465/993 — nicht auf die NodePorts. Das ist eine häufige Verwechslung und gehört in den Kommentar an der Policy.
Der HTTP-Listener bleibt bei 8080 und wird nicht veröffentlicht.
Die vier Korrekturen am Plan werden übernommen
timeoutMs: 15000 statt timeout: 15s — Dauern sind ganzzahlige Millisekunden. Der genaue Feldname ist gegen das serde(rename)-Attribut der Postgres-Store-Struktur zu bestätigen, nicht abzuleiten.
Der Postgres-Benutzername gehört nicht ins 1Password-Item: authUsername ist ein einfacher String ohne Secret-Form, also notwendig öffentlich. Das Item trägt nur das Passwort.
authSecret kennt die Variante EnvironmentVariable — im Registry-Schema bestätigt (SecretKeyEnvironmentVariable). Damit bleibt config.jsongeheimnisfrei: echte ConfigMap mit checksum/config, kein Substitutions-Init-Container, und das S3-OnePasswordItem wird konsumiert statt nur zu existieren. Besser als der Plan annahm.
Der Bootstrap-Assistent erscheint nie, wenn der Chart config.json rendert — er läuft nur beim ersten Start ohne diese Datei. Die Erstinstallation braucht stattdessen einen Recovery-Modus-Schritt (STALWART_RECOVERY_MODE=1) und damit einen Wert bootstrap.enabled; aus sieben Schritten werden acht. Deckt sich mit boot.rs, wo Bootstrap- und Recovery-Modus zwei getrennte Zustände sind.
Zwei neue Fallen, beide ins Wiki
Fehlendes config.json — etwa durch einen gescheiterten Mount — bricht den Start nicht ab, sondern öffnet einen flüchtigen In-Memory-Store, bei grünen Probes. Ein Mailserver, der annimmt und beim Neustart alles verliert.
/healthz/* hat ein anonymes Ratenlimit von 100 Anfragen pro 60 Sekunden je Quell-IP. Probes kommen von der Knoten-IP, alle Pods eines Knotens teilen denselben Eimer. Bei Liveness, Readiness und Startup mit periodSeconds: 10 sind das rund 18 Anfragen je Pod und Minute — ab etwa fünf Pods pro Knoten kippt es in 429er und damit in Neustarts.
Daraus folgt eine Festlegung für die Probes: die Summe eines Knotens muss bei realistischer Pod-Zahl deutlich unter dem Eimer bleiben, und die Rechnung gehört als Kommentar in values.yaml, sonst zieht die nächste Runde die Intervalle wieder an. Zu prüfen ist dabei, ob die Readiness-Probe besser als TCP-Socket auf den Mail-Port des jeweiligen Workloads läuft — das umgeht den Eimer vollständig, belegt aber nur einen offenen Listener statt der Dienstbereitschaft. Die Wahl ist zu begründen, nicht zu raten.
Ergänzend zum Fail-Open: es betrifft auch die Tasks, nicht nur die Listener, mit allen zehn als Vorgabe.
Der Rest des Plans im Kommentar davor bleibt unverändert gültig.
# Entscheidung zum Blocker: Entscheidung 3 wird umgekehrt
Der Befund im Kommentar davor trägt. Nachgeprüft gegen `capabilities(7)`, wörtlich:
> „If the process did not obtain the full set of file permitted capabilities, then execve(2) fails with the error EPERM."
Und zum Auslöser:
> „the capability bounding set masked out some of the capabilities in the file permitted set"
`drop: [ALL]` leert genau dieses Bounding-Set. Weil `setcap cap_net_bind_service=+ep` das Effective-Bit setzt, ist die Binärdatei ein „capability-dumb binary", und die Prüfung läuft beim `execve` — **vor** jedem Bind. Damit fallen alle drei Auswege aus der Bestandsaufnahme aus demselben Grund weg, und der Grund ist das `exec`, nicht das Binden: hohe Ports helfen nicht, `-alpine` hat dasselbe `setcap`, und der sysctl-Weg adressiert ebenfalls nur das Binden.
Die Planbegründung („nichts unter 1024 wird gebunden, also wird die Capability nicht gebraucht") war für das Binden richtig und für die Kernel-Prüfung unerheblich. Der Fehler liegt beim Planautor, nicht beim Umsetzer; das Anhalten vor dem PR war richtig.
Ein vierter Weg wurde geprüft und verworfen: ein Init-Container mit `CAP_SETFCAP`, der die Binärdatei in ein emptyDir kopiert und die Capability entfernt. Technisch gangbar, tauscht aber eine harmlose Capability gegen eine gefährliche und lässt den Hauptcontainer eine Binärdatei aus einem beschreibbaren Volume ausführen.
## Festgelegt
**1. `drop: [ALL]` plus `add: [NET_BIND_SERVICE]`** als dokumentierte Abweichung, Präzedenz `samba`. Der Kommentar in `values.yaml` muss zwei Dinge sagen, nicht eines:
- **warum** sie dasteht — die `execve`-Regel, nicht ein Bedarf beim Binden;
- **dass sie nie benutzt wird**, solange die Mail-Ports über den Service abgebildet werden.
Ohne den zweiten Satz entfernt die nächste Härtungsrunde sie als unbenutzt, und der Chart startet nicht mehr. Fällt die Datei-Capability bei Upstream je weg, ist das die Stelle, an der die Abweichung wieder verschwindet.
**2. Der Container bindet die üblichen Ports 25, 465 und 993.** Der NodePort bleibt 30025/30465/30993, die Anforderung des Betreibers ist damit erfüllt — der NodePort-Bereich beginnt ohnehin bei 30000. Der Service bildet ab: `nodePort: 30025` → `targetPort: 25`.
Begründung: da die Capability ohnehin gewährt werden muss, ist die Tiefenverteidigung hoher Ports hinfällig — der Prozess könnte jederzeit einen privilegierten Port binden. Was bleibt, ist der Gewinn an Erwartbarkeit: die Listener-Objekte, die in der WebUI angelegt werden, tragen die Zahlen aus jeder Stalwart-Doku und aus der EXPOSE-Liste des Abbilds. Die unvermeidliche Portnummern-Dopplung aus Fall 1 wird dadurch erheblich weniger überraschend.
Folgen für den Entwurf:
- `ports` trägt je Eintrag **zwei** Zahlen (`containerPort` und `nodePort`) statt einer.
- Die **NetworkPolicy** bezieht sich auf die Pod-Ports, also auf 25/465/993 — **nicht** auf die NodePorts. Das ist eine häufige Verwechslung und gehört in den Kommentar an der Policy.
- Der HTTP-Listener bleibt bei 8080 und wird nicht veröffentlicht.
## Die vier Korrekturen am Plan werden übernommen
1. `timeoutMs: 15000` statt `timeout: 15s` — Dauern sind ganzzahlige Millisekunden. Der genaue Feldname ist gegen das `serde(rename)`-Attribut der Postgres-Store-Struktur zu bestätigen, nicht abzuleiten.
2. Der Postgres-**Benutzername** gehört nicht ins 1Password-Item: `authUsername` ist ein einfacher String ohne Secret-Form, also notwendig öffentlich. Das Item trägt nur das Passwort.
3. `authSecret` kennt die Variante `EnvironmentVariable` — im Registry-Schema bestätigt (`SecretKeyEnvironmentVariable`). Damit bleibt `config.json` **geheimnisfrei**: echte ConfigMap mit `checksum/config`, kein Substitutions-Init-Container, und das S3-`OnePasswordItem` wird konsumiert statt nur zu existieren. Besser als der Plan annahm.
4. Der Bootstrap-Assistent erscheint **nie**, wenn der Chart `config.json` rendert — er läuft nur beim ersten Start ohne diese Datei. Die Erstinstallation braucht stattdessen einen Recovery-Modus-Schritt (`STALWART_RECOVERY_MODE=1`) und damit einen Wert `bootstrap.enabled`; aus sieben Schritten werden acht. Deckt sich mit `boot.rs`, wo Bootstrap- und Recovery-Modus zwei getrennte Zustände sind.
## Zwei neue Fallen, beide ins Wiki
**Fehlendes `config.json`** — etwa durch einen gescheiterten Mount — bricht den Start nicht ab, sondern öffnet einen **flüchtigen In-Memory-Store**, bei grünen Probes. Ein Mailserver, der annimmt und beim Neustart alles verliert.
**`/healthz/*` hat ein anonymes Ratenlimit von 100 Anfragen pro 60 Sekunden je Quell-IP.** Probes kommen von der Knoten-IP, alle Pods eines Knotens teilen denselben Eimer. Bei Liveness, Readiness und Startup mit `periodSeconds: 10` sind das rund 18 Anfragen je Pod und Minute — ab etwa fünf Pods pro Knoten kippt es in 429er und damit in Neustarts.
Daraus folgt eine Festlegung für die Probes: die Summe eines Knotens muss bei realistischer Pod-Zahl deutlich unter dem Eimer bleiben, und **die Rechnung gehört als Kommentar in `values.yaml`**, sonst zieht die nächste Runde die Intervalle wieder an. Zu prüfen ist dabei, ob die Readiness-Probe besser als TCP-Socket auf den Mail-Port des jeweiligen Workloads läuft — das umgeht den Eimer vollständig, belegt aber nur einen offenen Listener statt der Dienstbereitschaft. Die Wahl ist zu begründen, nicht zu raten.
Ergänzend zum Fail-Open: es betrifft **auch die Tasks**, nicht nur die Listener, mit allen zehn als Vorgabe.
Der Rest des Plans im Kommentar davor bleibt unverändert gültig.
stalwart/ liegt auf main, Version 1.0.1+upv0.16.24, veröffentlicht. PR #77 gemergt am 2026-10-02 um 22:59 mit Part of #65 — dieses Issue ist deshalb bewusst offen.
Nachträglich ist die Valkey-Abhängigkeit auf unseren eigenen Chart aus dem veröffentlichten Index umgestellt worden (nicht Upstreams), und die Bedingung heißt valkeySubchart.enabled, nicht valkey.enabled — alles unter valkey: geht an den Subchart und wird gegen dessen Schema geprüft, das kein enabled kennt.
Offen bleiben vier Messungen, alle am echten Cluster:
Muss unter /var/lib/stalwart etwas einen Neustart überleben, wenn Daten und Blobs extern liegen? Fällt die Antwort auf ja, braucht es doch eine PVC je Knotengruppe.
Schreibt Stalwart config.json selbst, wenn die Datei existiert und auf ein leeres Postgres zeigt?
Welche Adresse setzt die SNAT-Maskierung ein? Sie bestimmt den Inhalt von proxyTrustedNetworks und steht nach der ersten Verbindung in der Received:-Kopfzeile. Davon hängt die Spam-Abwehr ab: ohne richtig gesetztes vertrautes Netz sieht Stalwart die Knoten-IP statt des Absenders, und SPF, Blocklisten und Ratenbegrenzung bewerten dann die falsche Adresse.
Schließt --service-node-port-range dieses Clusters 30025–30993 ein? (Vorgabe 30000–32767.)
Teilweise offen: das /healthz-Ratenlimit von 100 Anfragen/60 s je Quell-IP stammt aus den Registry-Vorgaben im Quelltext, nicht von einem laufenden Dienst.
Anderswo geschlossen: der Valkey-Subchart-Pfad war hier nur gerendert, nie installiert. Mit #78/PR #80 (paperless-ngx) ist er erstmals wirklich installiert worden — ci-valkey-sfs-0 wurde 1/1 Running, der Init-Container hat die requirepass-Zeile gefunden und die Probe sich authentifiziert.
## Stand nach dem Merge (2026-10-04)
`stalwart/` liegt auf `main`, Version **1.0.1+upv0.16.24**, veröffentlicht. PR #77 gemergt am 2026-10-02 um 22:59 mit `Part of #65` — dieses Issue ist deshalb bewusst **offen**.
Nachträglich ist die Valkey-Abhängigkeit auf **unseren eigenen** Chart aus dem veröffentlichten Index umgestellt worden (nicht Upstreams), und die Bedingung heißt `valkeySubchart.enabled`, nicht `valkey.enabled` — alles unter `valkey:` geht an den Subchart und wird gegen **dessen** Schema geprüft, das kein `enabled` kennt.
**Offen bleiben vier Messungen, alle am echten Cluster:**
1. **Muss unter `/var/lib/stalwart` etwas einen Neustart überleben**, wenn Daten und Blobs extern liegen? Fällt die Antwort auf ja, braucht es doch eine PVC je Knotengruppe.
2. **Schreibt Stalwart `config.json` selbst**, wenn die Datei existiert und auf ein leeres Postgres zeigt?
3. **Welche Adresse setzt die SNAT-Maskierung ein?** Sie bestimmt den Inhalt von `proxyTrustedNetworks` und steht nach der ersten Verbindung in der `Received:`-Kopfzeile. Davon hängt die Spam-Abwehr ab: ohne richtig gesetztes vertrautes Netz sieht Stalwart die Knoten-IP statt des Absenders, und SPF, Blocklisten und Ratenbegrenzung bewerten dann die falsche Adresse.
4. **Schließt `--service-node-port-range` dieses Clusters 30025–30993 ein?** (Vorgabe 30000–32767.)
**Teilweise offen:** das `/healthz`-Ratenlimit von 100 Anfragen/60 s je Quell-IP stammt aus den Registry-Vorgaben im Quelltext, nicht von einem laufenden Dienst.
**Anderswo geschlossen:** der Valkey-Subchart-Pfad war hier nur gerendert, nie installiert. Mit #78/PR #80 (`paperless-ngx`) ist er erstmals wirklich installiert worden — `ci-valkey-sfs-0` wurde `1/1 Running`, der Init-Container hat die `requirepass`-Zeile gefunden und die Probe sich authentifiziert.
Stalwarts OIDC-Seite, gemessen — Querverweis auf #89
Beim Chart für den Webmail-Client (#89) ist die Anmeldekette durchgelesen worden, und dabei sind zwei Eigenschaften dieses Servers aufgefallen, die hier festgehalten gehören. Die vollständige Kette samt Fehlerfällen steht als Kommentar auf #89; hier nur, was Stalwart selbst betrifft.
Für SSO gegen einen externen Anbieter ist OidcDirectory das Richtige: ein Client legt ein Token vor, Stalwart prüft es und bildet einen Claim auf ein Postfach ab.
Es braucht kein Client-Secret, und das ist kein Versehen
Stalwart holt die öffentlichen Schlüssel aus dem Discovery-Dokument (fünf Minuten Cache) und prüft damit die Signatur. Dass OidcDirectory kein Secret-Feld hat, ist eine Folge davon: authenticate_jwt weist HS256, HS384 und HS512 ab — die symmetrischen Verfahren, die ein gemeinsames Geheimnis bräuchten und deren Annahme die klassische Algorithmus-Verwechslungslücke wäre. Opake Token laufen über fetch_userinfo(token), ebenfalls ohne Secret.
Ein OIDC-Directory nimmt nur Bearer — die Aussperrungsfalle
Das Ende von authenticate:
_=>Err(AuthEvent::Error.into_err().reason("Unsupported credentials type for OIDC backend")),
Benutzername und Passwort gegen ein OIDC-Directory funktionieren nicht. Daraus folgt eine Konfiguration, die aussperrt: OIDC als DirectoryBootstrapanstelle von Internal. Dann gibt es nirgends mehr ein Passwort — auch nicht für die Weboberfläche dieses Servers, und auch nicht für IMAP oder SMTP.
Das Schema trennt die beiden Fälle:
pubenumDirectory{Ldap,Sql,Oidc}// hinzufügbar, kein Internal
pubenumDirectoryBootstrap{Internal,Ldap,Sql,Oidc}// das eine aus dem Bootstrap
Internal ist die Grundlage und keines der hinzufügbaren Verzeichnisse. Ein OIDC-Directory tritt also neben die internen Konten: Admin-Zugang und Passwort-Anmeldung bleiben, Bearer-Token kommen hinzu. Internal bleibt das Bootstrap-Verzeichnis — das ist die Regel, die den Zugang zur eigenen Oberfläche erhält.
Audience: der Fehler, dessen Symptom irreführt
authenticate_jwt weist ein Token ausdrücklich ab, wenn require_aud gesetzt ist und das Token gar keinaud trägt:
Keycloak legt in ein Access-Token standardmäßig keine brauchbare Audience; der Client braucht einen Audience-Mapper. Und weil ein JMAP-Client bei 401 üblicherweise erneuert und wiederholt, zeigt sich der Fehler als Schleife statt als Meldung — die Anmeldung beim Anbieter klappt, der Mailzugriff nicht.
Nicht gemessen
Wie Stalwart ein Zugangsdatum einem Verzeichnis zuordnet, wenn mehrere existieren — der Reihe nach, oder über die Zugehörigkeit des Kontos. memberTenantId an OidcDirectory deutet auf Letzteres. Die Aussage „tritt daneben" folgt aus den Enum-Formen, nicht aus gelesenem Zuordnungscode, und sie gehört vor dem Ausrollen belegt.
# Stalwarts OIDC-Seite, gemessen — Querverweis auf #89
Beim Chart für den Webmail-Client (#89) ist die Anmeldekette durchgelesen worden, und dabei sind zwei Eigenschaften dieses Servers aufgefallen, die hier festgehalten gehören. **Die vollständige Kette samt Fehlerfällen steht als Kommentar auf #89**; hier nur, was Stalwart selbst betrifft.
## Zwei getrennte Rollen, zwei Objekte
| Objekt | Rolle |
| --- | --- |
| **`OidcDirectory`** | Stalwart als **Token-Prüfer** (Resource Server): `issuerUrl`, `requireAudience`, `requireScopes`, `claimUsername`, `usernameDomain`, `claimName`, `claimGroups`, `memberTenantId` |
| **`OidcProvider`** | Stalwart als **eigener Autorisierungsserver**: `authCodeExpiry`, `refreshTokenExpiry`, `idTokenExpiry`, `userCodeExpiry` (Device-Flow), eigener `signatureKey`, Client-Registrierung |
Für SSO gegen einen externen Anbieter ist `OidcDirectory` das Richtige: ein Client legt ein Token vor, Stalwart prüft es und bildet einen Claim auf ein Postfach ab.
## Es braucht kein Client-Secret, und das ist kein Versehen
`crates/directory/src/backend/oidc/lookup.rs`, `get_key`:
```rust
let new_keys = fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await?;
```
Stalwart holt die **öffentlichen** Schlüssel aus dem Discovery-Dokument (fünf Minuten Cache) und prüft damit die Signatur. Dass `OidcDirectory` kein Secret-Feld hat, ist eine **Folge** davon: `authenticate_jwt` weist `HS256`, `HS384` und `HS512` ab — die symmetrischen Verfahren, die ein gemeinsames Geheimnis bräuchten und deren Annahme die klassische Algorithmus-Verwechslungslücke wäre. Opake Token laufen über `fetch_userinfo(token)`, ebenfalls ohne Secret.
## Ein OIDC-Directory nimmt **nur** Bearer — die Aussperrungsfalle
Das Ende von `authenticate`:
```rust
_ => Err(AuthEvent::Error
.into_err()
.reason("Unsupported credentials type for OIDC backend")),
```
Benutzername und Passwort gegen ein OIDC-Directory funktionieren nicht. **Daraus folgt eine Konfiguration, die aussperrt:** OIDC als `DirectoryBootstrap` **anstelle** von `Internal`. Dann gibt es nirgends mehr ein Passwort — auch nicht für die Weboberfläche dieses Servers, und auch nicht für IMAP oder SMTP.
Das Schema trennt die beiden Fälle:
```rust
pub enum Directory { Ldap, Sql, Oidc } // hinzufügbar, kein Internal
pub enum DirectoryBootstrap { Internal, Ldap, Sql, Oidc } // das eine aus dem Bootstrap
```
`Internal` ist die Grundlage und keines der hinzufügbaren Verzeichnisse. Ein OIDC-Directory tritt also **neben** die internen Konten: Admin-Zugang und Passwort-Anmeldung bleiben, Bearer-Token kommen hinzu. **`Internal` bleibt das Bootstrap-Verzeichnis** — das ist die Regel, die den Zugang zur eigenen Oberfläche erhält.
## Audience: der Fehler, dessen Symptom irreführt
`authenticate_jwt` weist ein Token ausdrücklich ab, wenn `require_aud` gesetzt ist und das Token **gar kein** `aud` trägt:
```rust
if self.config.require_aud.is_some() && token_data.claims.get("aud").is_none() {
… ErrorKind::InvalidAudience
```
Keycloak legt in ein Access-Token standardmäßig keine brauchbare Audience; der Client braucht einen **Audience-Mapper**. Und weil ein JMAP-Client bei 401 üblicherweise erneuert und wiederholt, zeigt sich der Fehler als **Schleife** statt als Meldung — die Anmeldung beim Anbieter klappt, der Mailzugriff nicht.
## Nicht gemessen
**Wie Stalwart ein Zugangsdatum einem Verzeichnis zuordnet, wenn mehrere existieren** — der Reihe nach, oder über die Zugehörigkeit des Kontos. `memberTenantId` an `OidcDirectory` deutet auf Letzteres. Die Aussage „tritt daneben" folgt aus den Enum-Formen, nicht aus gelesenem Zuordnungscode, und sie gehört vor dem Ausrollen belegt.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Erster Plan für ein Chart für Stalwart (
stalwartlabs/stalwart), einen Mailserver mit SMTP, IMAP, POP3, JMAP, ManageSieve und Weboberfläche.Ausgangspunkt war die verlinkte Seite https://stalw.art/docs/0.15/cluster/orchestration/kubernetes/. Sie ist veraltet — siehe unten. Dieser Text ist eine Bestandsaufnahme plus die Entscheidungen, die vor der Umsetzung zu treffen sind; er ist noch kein freigegebener Umsetzungsplan.
Verifizierte Fakten
Aus der Registry und dem Dockerfile gelesen, nicht aus der Doku übernommen:
docker.io/stalwartlabs/stalwart— neueste Fassungv0.16.24, identischer Digest wielatest. Daneben-alpine-Varianten und wandernde Tagslatest,v0.16USER stalwart, uid/gid 2000 — das Abbild läuft bereits als Nicht-RootWORKDIR /var/lib/stalwart,ENTRYPOINT /usr/local/bin/stalwart,CMD --config /etc/stalwart/config.json/etc/stalwart(Konfiguration) und/var/lib/stalwart(Daten)/healthz/liveund/healthz/ready; der Healthcheck des Abbilds rufthttps://127.0.0.1:443/healthz/livemit dem KopfX-Forwarded-For: 127.0.0.1und fällt aufhttp://127.0.0.1:8080/healthz/livezurückDie verlinkte Seite beschreibt 0.15 und stimmt nicht mehr: Dort wird ein einziges Volume unter
/opt/stalwartgemountet. In 0.16 sind es/etc/stalwartund/var/lib/stalwart, und die Konfiguration ist JSON, nicht TOML. Die Manifeste dort sind Beispiele, kein offizielles Helm-Chart — im Repository gibt es keinhelm/- oderk8s/-Verzeichnis. Die Frage nach einer Chart-Abhängigkeit stellt sich also nicht.Drei Kollisionen mit unseren Konventionen
1. Die Datei-Capability gegen
drop: [ALL]. Das Dockerfile setztsetcap cap_net_bind_service=+epauf die Binärdatei — so bindet die Anwendung als uid 2000 die Ports 25, 143 und 443. Unsere Härtungsvorgabe nimmt genau diese Fähigkeit weg. Drei Auswege, alle im Repo schon einmal begangen:port: 25→targetPort: 2525) — Capabilities bleiben vollständig entfernt;capabilities.add: [NET_BIND_SERVICE]als dokumentierte Abweichung;rss-bridge(net.ipv4.ip_unprivileged_port_start).2. Die Konfiguration ist Zustand, nicht Vorgabe. Ohne
config.jsonstartet Stalwart im Bootstrap-Modus: kein Maildienst, ein Assistent auf Port 8080, und am Ende schreibt der Server die Datei selbst und startet neu./etc/stalwartmuss also beschreibbar sein. Eine gerenderte ConfigMap mitchecksum/config— unsere Regel §9 — passt darauf nicht; das Muster ist eherhomeassistant, das seine Konfiguration ebenfalls selbst verwaltet.3. Ein Ingress kann Mail nicht tragen. SMTP, IMAP, POP3 und ManageSieve sind kein HTTP. Für die Weboberfläche und JMAP ist ein Ingress richtig, für die übrigen acht Ports braucht es einen Service vom Typ LoadBalancer oder NodePort — im Repo gibt es NodePort-Vorbilder (
minecraft-router,teamspeak-server,samba).Was ohnehin gut passt
STALWART_RECOVERY_ADMINfixiert es; das ist der natürliche Platz für einOnePasswordItem.Zu entscheiden, bevor umgesetzt wird
replicaCount: 1. Ein Cluster verlangt einen externen Datenspeicher (Postgres, MySQL, S3) und einen Koordinierungsweg (peer-to-peer, oder Kafka, NATS, Redis). Ein Einzelknoten mit eingebettetem RocksDB auf einer PVC ist erheblich einfacher und deckt einen Familien- oder Vereins-Mailserver vollständig ab. Empfehlung: Einzelknoten zuerst, Cluster später als eigenes Vorhaben — ein Mailserver ist schon ohne Cluster die komplizierteste Gattung in diesem Repo.NET_BIND_SERVICE, oder sysctl?config.jsonvorgeben und den Assistenten nie sehen? Letzteres wäre reproduzierbarer, bräuchte aber eine gepflegte Vorlage für eine große JSON-Datei — und die Weboberfläche schreibt möglicherweise dagegen.Was gemessen werden muss
/healthz/livefür die Probe überhaupt? Der Healthcheck des Abbilds setztX-Forwarded-For: 127.0.0.1— ein Hinweis darauf, dass der Endpunkt auf vertrauenswürdige Netze beschränkt sein könnte. Das ist genau die Falle aus §12.5 (patchmonbraucht einenHost-Kopf). Am laufenden Dienst zu prüfen, nicht am Pfadnamen.config.jsonstartet kein Maildienst. Der Test muss also entweder eine Konfiguration mitbringen oder gegen den Bootstrap-Zustand prüfen — und im zweiten Fall belegt er weniger, als er zu belegen scheint.readOnlyRootFilesystem: truemit den zwei Volumes hält, und welche weiteren Schreibpfade nötig sind.Umfang, wenn entschieden ist
Chart
stalwartmit StatefulSet, zwei Volumes, Service für Mail (Typ offen) plus Ingress für Weboberfläche und JMAP,OnePasswordItemfür den Wiederherstellungs-Zugang, Probes auf den getrennten Endpunkten, strenges Schema,NOTES.txtmit den Feldern des Tresor-Eintrags und den DNS-Einträgen, die ein Mailserver braucht (MX, SPF, DKIM, DMARC, Reverse-DNS). Dazu Publish-Workflow, Eintrag inchange-app-version.yml, CI-Fixtures, Renovate-Block mit Verankerung amv-Präfix, README-Zeile, Wiki-Seite.Freigegebener Umsetzungsplan — Cluster-fähiges Chart, Konfiguration bleibt beim Betreiber
Die fünf Entscheidungen aus der Bestandsaufnahme sind gefallen. Dazu kam ein Befund, der den Zuschnitt des Charts bestimmt und in der Bestandsaufnahme noch fehlte, weil er aus v0.16 stammt und die verlinkte 0.15-Doku ihn nicht kennt.
Der Befund: v0.16 hat die Konfiguration in die Datenbank verlegt
config.jsonauf Platte enthält nur noch ein Objekt — wo die Datenbank liegt:Alles andere — über 2 400 Einstellungen in 117 Objekten, darunter Listener, Blob-Store, In-Memory-Store, TLS-Objekte, Domains und die
ClusterRole-Objekte — liegt im Data-Store und wird über die JMAP-API verwaltet (WebUI oderstalwart-cli). Die REST-API aus v0.15 ist entfallen. Begründung von Upstream ist genau unser Anwendungsfall: im alten Modell musste jeder Cluster-Knoten seine eigene Konfigurationsdatei synchron halten.Belege: Blog v0.16, Configuration Overview, Discussion #2892, UPGRADING/v0_16.md.
Folge für dieses Chart: es kann S3, Valkey, Listener und Rollen nicht deklarativ setzen. Ein
stalwart-cli apply-Job wäre der Weg dorthin, ist aber verworfen — siehe Entscheidung 5. Das Chart rendert alsoconfig.json(Data-Store), die Workloads samtSTALWART_ROLE, die Services, die NetworkPolicies und den Ingress. Der Rest ist Betreiberarbeit und gehört deshalb vollständig in die Wiki-Seite.Die Entscheidungen
1. Einzelknoten oder Cluster → Cluster, direkt mehrknotenfähig.
Nicht symmetrisch, sondern mit Rollen: eine Gruppe mit allen Aufgaben und Listenern, eine zweite, die nur SMTP annimmt. Das ist in Stalwart erstklassig vorgesehen — eine
ClusterRoleschaltet Tasks und Listener getrennt, der Knoten wählt sie überSTALWART_ROLE. Das dokumentierte Beispiel ist genau dieser Fall:Beide Felder kennen
EnableAll,DisableAll,EnableSome(mittaskTypes/listenerIds) undDisableSome. Die Tasks:storeMaintenance,accountMaintenance,metricsCalculate,metricsPush,pushNotifications,searchIndexing,spamClassifierTraining,outboundMta,taskQueueProcessing,taskScheduler. NurspamClassifierTrainingdarf laut Doku höchstens auf einem Knoten laufen.Clustering ist in der Community-Edition unter AGPL-3.0 enthalten, keine Lizenz nötig; Enterprise gatet Multi-Tenancy, Dashboards, KI-Modelle, Read-Replicas und geshardete Stores.
Die Rollenliste in meiner ersten Fassung (Purge, ACME-Erneuerung, iMIP, Kalender-Alarme) stammte aus den 0.15-Seiten und ist ungültig. ACME ist keine Task mehr.
2. Speicher → Postgres als Data-Store, S3 als Blob-Store, Valkey als In-Memory-Store.
Backends werden pro Zweck gewählt, unabhängig von der Knotenzahl (
storage.data,storage.blob,storage.fts,storage.lookup). Zwei Knoten erzwingen allerdings externe Stores: das Dateisystem als Blob-Store ist dokumentiert „available only for single node installations", und RocksDB ist nur für Einzelknoten empfohlen.Die Volltextsuche braucht keinen Zusatzdienst: der Suchspeicher hat eine
Default-Variante, die den Data-Store als Index nutzt — Bloom-Filter in der Datenbank, automatische Mehrsprachigkeit, „no additional services are required". Also kein Elasticsearch, kein Meilisearch; die FTS-Last landet auf demselben Postgres und gehört in die Sizing-Überlegung.Valkey trägt zwei Aufgaben mit einer Verbindung. Valkey ist ausdrücklich unterstützt („the open-source Redis-compatible fork, is also supported through the same variant"), und die
Default-Variante des Koordinators „reuses the Redis connection already configured on the in-memory store". Redis/Valkey ist dabei nur als In-Memory-Store zulässig, nie als Data- oder Blob-Store.Der Valkey-Subchart kommt als Abhängigkeit mit,
enabled: falsehintercondition:wie von den Konventionen gefordert, dazu ein Wert für eine vorhandene externe Instanz. Ist der Cluster an und ist weder der Subchart noch eine URL gesetzt, bricht das Rendern mitfailab — der Fehlerfall wird laut statt still. Der Preis der Abhängigkeit ist bekannt und bleibt gültig: ein Valkey-Fix erreicht einen Cluster erst, wenn das äußere Chart neu veröffentlicht wird;ci/helm-dependencies.shläuft vor Lint, Package, Render und Install; Renovateshelmv3-Manager plusci/renovate-bump-chart.shsind das Netz und gehören als Kommentar an die gepinnte Version.3. Mail-Ports nach außen → NodePort 30025 / 30465 / 30993, und die Anwendung bindet dieselben Ports.
Damit ist Entscheidung 3 der Bestandsaufnahme (privilegierte Ports) mit der härtesten der drei Varianten erledigt:
capabilities: drop: [ALL], keinNET_BIND_SERVICE, kein sysctl, keine dokumentierte Abweichung. Diesetcap-Fähigkeit des Abbilds wird nicht gebraucht, weil nichts unter 1024 gebunden wird. Ausgehendes SMTP auf 25 braucht keine Capability.Veröffentlicht werden zunächst nur diese drei Ports plus HTTP; 587, 143, 110, 995 und 4190 bleiben außen vor und können später additiv dazukommen.
4. TLS → cert-manager, nicht Stalwarts eigenes ACME.
Abgeleitet aus der Topologie: der HA-Proxy leitet 25, 465 und 993 weiter, nicht 80. Stalwarts HTTP-01-ACME wäre damit von außen nicht erreichbar. Gleichzeitig terminiert der HA-Proxy kein TLS, Stalwart muss das Zertifikat für STARTTLS auf 25 und implizites TLS auf 465/993 also selbst vorweisen — der Ingress deckt nur WebUI und JMAP ab. Also: eine cert-manager-
Certificate, das Secret schreibgeschützt in den Pod gehängt, und das TLS-Objekt in der Datenbank auf diese Pfade gezeigt. DNS-01 mit Stalwarts eigenem ACME wäre technisch möglich, würde aber doppeln, was cert-manager im Cluster schon tut.5. Konfiguration → kein Apply-Job. Das Chart rendert
config.json, der Rest wird in der WebUI eingerichtet.Abgewogen gegen einen Hook-Job, der einen
stalwart-cli apply-Plan anwendet. Dagegen entschieden, mit offenem Restpunkt:EnableSomevergleichtgroup.listener_idsgegenlistener.id.id(), also Objekt-IDs, keine Namen. Ein Plan müsste serverseitige IDs kennen; obapplyVerweise über natürliche Schlüssel auflöst, ist unbelegt. Löst es sie nicht, ist der Job kein Rendering mehr, sondern ein kleiner Reconciler im Chart — mehr, als ein Helm-Chart hier tragen soll. Die Erstinstallation bliebe ohnehin interaktiv, weil erst der Assistent ein API-Token erzeugt.Konsequenz, die benannt gehört:
nodes.*.tasksgibt es in der values.yaml nicht. Welche Tasks eine Rolle fährt, kann das Chart nicht setzen, und ein Wert, der nichts tut, ist nach dem Maßstab dieses Repositories schlechter als keiner.Die Cluster-Topologie, und was sie entscheidet
Drei CP-Knoten, zwei Worker. Davor ein Server mit HA-Proxy, der 25, 465 und 993 auf einen der drei CP-Knoten verteilt (je nachdem, welcher erreichbar ist) und dort auf 30025, 30465, 30993. Auf CP-Knoten wird nie ein Stalwart-Pod geplant.
externalTrafficPolicy: Localist damit unmöglich. Es berücksichtigt nur Pods auf dem empfangenden Knoten; auf einem CP-Knoten gibt es nie einen, also würde jedes Paket verworfen. Es mussClustersein — und damit maskiert kube-proxy die Quell-IP auf die des CP-Knotens, weil die Antwort sonst über einen anderen Knoten zurückläuft als den, mit dem der Absender seine Verbindung hat.Der HA-Proxy löst das vollständig, und zwar für Port 25 mit. Mein früherer Einwand — fremde Mailserver senden keinen PROXY-Header — gilt nur bei direktem DNAT. Hier nimmt der HA-Proxy die Verbindung an und baut eine eigene zum CP-Knoten auf; auf dieser setzt er mit
send-proxy-v2den Header. Die echte Absender-IP reist damit im PROXY-Header statt im IP-Kopf, und die Maskierung wird gleichgültig.Ohne das kostet
Clusterbei einem Mailserver, in dieser Reihenfolge: die automatische Sperre schaltet den Dienst ab (sie sperrt die maskierte Knoten-IP und damit alle Absender — hinter Traefik dokumentiert, von außen auslösbar); SPF prüft die falsche Adresse und reißt DMARC mit; Blocklisten melden alles als sauber; Ratenbegrenzungen pro IP kollabieren auf eine Adresse; Greylisting verliert seinen Schlüssel; und dieReceived:-Kette wird wertlos — der einzige dieser Punkte, der nicht reparabel ist, sondern rückwirkend Beweismittel vernichtet.Welche Netze vertraut werden, hängt an der Topologie — und geht nur wegen ihr auf. Ein vertrautes Netz macht den PROXY-Header zur Pflicht: eine Verbindung von dort ohne Header wird fehlgedeutet und hängt bis zum Zeitablauf. In Kubernetes ist das gefährlich, weil Kubelet-Probes von einer Knoten-IP kommen.
proxyTrustedNetworksenthält also nur die drei CP-Knoten-IPs. Weil dort nie ein Stalwart-Pod läuft, sind Probe-Quellen und vertraute Adressen disjunkt. Das ist keine Betriebsregel mehr, sondern eine tragende Annahme des Entwurfs: wer später einen Stalwart-Pod auf einen CP-Knoten lässt, bricht sie.Welche Adresse die Maskierung tatsächlich einsetzt, ist zu messen, nicht anzunehmen — sie steht nach der ersten Verbindung in der
Received:-Kopfzeile.Was der Chart rendert
config.jsonDataStore-Objekt,checksum/configam Pod-TemplatevolumeClaimTemplates(Zustand liegt extern),STALWART_HOSTNAMEaus dem Pod-Namen für das Node-ID-Leasesmtp→ NodePort 30025, Selektorfull+mx;submissions→ 30465 undimaps→ 30993, Selektorfull;http→ ClusterIP für den Ingressingress.nodeGroup, TLS-Secrettls-<release>-stalwart-ingressSTALWART_RECOVERY_ADMINServices pro Port, nicht pro Gruppe — NodePorts sind cluster-global eindeutig, also können
fullundmxSMTP nur hinter demselben Service annehmen. Alle Ports in einen Service zu legen geht nicht, weil ein Service einen Selektor hat und 30993 dann aufmx-Pods geroutet würde, wo nichts lauscht. Umgesetzt über ein Label pro Port am Pod, ausexposedPortsgerendert.Die Egress-Regel ist bewusst einheitlich. Sie je Gruppe aus
outboundMtaabzuleiten wäre falsch: das Chart kennt die Tasks nicht, der Wert liefe mit der Datenbank auseinander, und der Fehlerfall wäre stilles Ausbleiben ausgehender Mail.values.yaml
Als Karte, nicht als Liste — bei einer Liste wären alle Überschreibungen index-basiert (
--set nodes[1].replicas=2), und eine später eingefügte Gruppe würde jede bestehende Überschreibung still auf die falsche zeigen.nodes.*.exposedPortsist keine Kopie der Listener-Konfiguration aus der Datenbank, sondern eine eigene Aussage: „diese Gruppe darf nur hier erreicht werden". Deshalb sind beide Abweichungsrichtungen harmlos — öffnet die Rolle mehr, ist es unerreichbar; erlaubt die Policy mehr, lauscht nichts. Das ist der Grund, warum sie das Fail-Open entschärft.Jede Gruppe braucht den HTTP-Listener für die Probes, auch
mx. Er wird nicht veröffentlicht; die NetworkPolicy muss dafür Ingress auf 8080 von den Worker-Knoten-IPs zulassen.Fail-Open: die Falle, gegen die die NetworkPolicy steht
Aus dem Quellcode, nicht aus der Doku.
crates/store/src/registry/bootstrap.rs:34-50sucht die Rolle nach Namen; findet sie keine, ruft esbuild_error— und das setzthas_fatal_errorsnicht (Zeile 153). Der Start läuft weiter,bp.rolebleibtNone. Incrates/common/src/config/server/listener.rs:50steht dann:is_none_orliefert beiNonetrue— für jeden Listener. Dasselbe gilt für die Tasks.Ein Knoten, dessen Rolle nicht in der Datenbank steht, wird zum Vollknoten. Der
mx-Pod würde 993 und die Admin-Oberfläche binden. Der Pod istRunning, im Log steht eine ZeileCluster role "mx" not found in registry, und eine Readiness-Probe wäre grün. Kubernetes kann es nicht erkennen.Das trifft nicht nur die Erstinstallation, sondern jeden Tippfehler im Rollennamen und jede Umbenennung in der values.yaml ohne Umbenennung in der Datenbank. Gegenmittel, die wirken:
mxstartet mitreplicas: 0; der Service je Port veröffentlicht nur, was gewollt ist; und die NetworkPolicy ist die Obergrenze der Erreichbarkeit, unabhängig davon, was der Prozess gebunden hat. Dokumentiert als Falle, nicht wegerklärt.Erstinstallation: die Pflichtschritte
Bei leerer Datenbank gehen alle Pods in den Bootstrap-Modus — „No configuration file was found. Port 8080 is open for initial setup." Kein Maildienst. Mit zwei Gruppen hinter einem Service wäre der Assistent nicht benutzbar, weil jede Anfrage auf einem zufälligen Pod landet. Daher:
nodes.mx.replicas: 0.full-Gruppe durchlaufen;STALWART_RECOVERY_ADMINsetzt das Kennwort fest, statt es bei jedem Bootstrap neu ins Pod-Protokoll zu schreiben.Default), Suchspeicher (Default), die Listener auf 30025/30465/30993 und 8080, das TLS-Objekt auf das cert-manager-Secret,proxyTrustedNetworksauf die drei CP-Knoten-IPs, und die beidenClusterRole-Objektefullundmx.send-proxy-v2setzen. Das ist ein Alles-oder-nichts-Schritt zusammen mit 3: fehlt der Header bei vertrautem Netz, hängt jede Verbindung; kommt er bei nicht vertrautem Netz, liest StalwartPROXY TCP4 …als SMTP-Befehl. Erprobt wird auf einem freien Port mit eigenem Frontend, und erst wenn dieReceived:-Zeile die echte Absender-IP zeigt, wird 25 umgestellt.nodes.mx.replicashochsetzen.Und die Falle, die der Ingress mitbringt und die der HA-Proxy nicht löst: Stalwart sieht für jede JMAP- und WebUI-Anfrage die Pod-IP des Ingress-Controllers, und die automatische Sperre greift irgendwann diese — dann sind WebUI und JMAP für alle tot. Für HTTP ist nicht das PROXY-Protokoll das Mittel, sondern
forwardedmit Auswertung vonX-Forwarded-For, bei gleichzeitigem Verwerfen vom Client mitgebrachter Weiterleitungs-Kopfzeilen. Pflichtschritt, weil der Ausfall erst Wochen später eintritt und dann nach einem Serverproblem aussieht.Was am laufenden Dienst zu messen ist
Die Punkte aus der Bestandsaufnahme bleiben gültig, zwei kommen hinzu:
/healthz/livefür die Probe? Der Healthcheck des Abbilds setztX-Forwarded-For: 127.0.0.1— ein Hinweis, dass der Endpunkt auf vertraute Netze beschränkt sein könnte. Jetzt doppelt relevant, weil wir mitproxyTrustedNetworksarbeiten. Am Dienst prüfen, nicht am Pfadnamen.readOnlyRootFilesystem: truemit/etc/stalwartund/var/lib/stalwartals emptyDir, und muss unter/var/lib/stalwartetwas einen Neustart überleben, wenn Daten und Blobs extern liegen? Fällt die Antwort auf ja, braucht es doch eine PVC je Gruppe.config.jsonselbst, wenn die Datei existiert und auf ein leeres Postgres zeigt? Die Bestandsaufnahme nennt das Selbst-Schreiben für den Fall einer fehlenden Datei. Wir liefern eine, der Bootstrap-Grund wäre also ein leeres Register, nicht eine fehlende Datei. Davon hängt ab, ob/etc/stalwartbeschreibbar sein muss — Vorschlag: emptyDir plus Init-Container, der die Datei aus der ConfigMap hineinkopiert, damitchecksum/configgreift und der Pfad trotzdem beschreibbar bleibt.proxyTrustedNetworks.--service-node-port-range30025 bis 30993 einschließt (Vorgabe 30000–32767).Lieferumfang
Chart
stalwartnach den Konventionen, dazu: Publish-Workflow überworkflow-util/pipelines, Eintrag inchange-app-version.yml, CI-Fixtures unterci/stalwart/, Renovate-customManagers-Block mit Verankerung amv-Präfix, README-Zeile, Valkey-Abhängigkeit mit Kommentar an der gepinnten Version,NOTES.txtmit den Tresor-Feldern und den DNS-Einträgen.Wiki, im selben Durchgang: neue Chart-Seite nach dem Muster von
outlinemit vollständiger Werte-Tabelle, der siebenschrittigen Erstinstallation und einem Abschnitt „Traps" für Fail-Open, die Portnummern-Dopplung,externalTrafficPolicyund die Ingress-Sperrfalle; eine Zeile inCharts; ein Eintrag in_Sidebar.md; und ein Eintrag inLessons-Learnedzum Quell-IP-Mechanismus, weil er sich bei jedem Chart wiederholt, dessen Anwendung die Quell-IP auswertet — das ist nicht auf Mail beschränkt.Halt: Entscheidung 3 ist so nicht baubar —
drop: [ALL]verhindert bei diesem Abbild schon dasexecUmsetzung angehalten, bevor die Workloads geschrieben waren. Der Grund ist kein Detail, sondern eine der neun festgelegten Entscheidungen: Entscheidung 3 verlangt
capabilities: drop: [ALL], keinNET_BIND_SERVICE, keine dokumentierte Abweichung — und mit diesem Abbild startet der Container dann überhaupt nicht.Der Befund
Beide veröffentlichten Varianten setzen eine Datei-Capability mit gesetztem Effective-Bit auf die Binärdatei. Aus
Dockerfile.buildam Tagv0.16.24— das ist die Datei, aus der die veröffentlichten Abbilder gebaut werden (docker-bake.hcl, Targetimage):Entscheidend ist das
ein+ep.capabilities(7), Abschnitt „Safety checking for capability-dumb binaries":drop: [ALL]leert genau diese Bounding-Set-Menge.F(permitted) & P(bounding)ist damit leer, der Prozess erhältCAP_NET_BIND_SERVICEnicht, das Effective-Bit der Datei ist aber gesetzt — also schlägtexecvefehl:Die Begründung in Entscheidung 3 ist für das Binden richtig und für das
execunerheblich. „Nichts unter 1024 wird gebunden" stimmt — die Prüfung des Kernels findet aber beiexecvestatt, bevor das Programm irgendetwas bindet, und sie fragt nicht, ob die Capability gebraucht wird. Die hohen Ports 30025/30465/30993 sparen die Capability im Betrieb, nicht beim Start.Was nicht hilft
-alpinestatt glibc. Geprüft: beide Targets setzen dasselbesetcap(Zeilen 164 und 186 oben). Es gibt kein veröffentlichtes Abbild ohne die Datei-Capability.net.ipv4.ip_unprivileged_port_start). Das betrifft das Binden, nicht dieexecve-Prüfung.Theoretisch bliebe, die Binärdatei über einen Init-Container in ein
emptyDirzu kopieren (cpverliert Datei-Capabilities) und die Kopie zu starten. Das halte ich für nicht vertretbar: es bricht den Abbild-Vertrag, überlebt kein Abbild-Update ohne stille Überraschung, und es wäre eine Eigenbau-Lösung an der empfindlichsten Stelle des Charts.Die Entscheidung, die ich nicht selbst treffe
Es bleibt praktisch nur:
Also genau das, was Entscheidung 3 ausdrücklich ausgeschlossen hat („keine dokumentierte Abweichung"). Die Abweichung wäre eng begründbar — die Capability wird zum Start gebraucht und nie zum Binden eines privilegierten Ports, weil die Listener auf 30025/30465/30993 liegen;
sambahat im Repo bereits den Präzedenzfall einer begründeten Capability.Was ich brauche: die Freigabe für
add: [NET_BIND_SERVICE]als dokumentierte Abweichung invalues.yaml— oder eine andere Festlegung. Der Rest des Entwurfs ist davon unberührt; die hohen Ports, der NodePort-Zuschnitt und alles Übrige bleiben wie entschieden.Vier weitere Korrekturen, alle am Quelltext bzw. an der Objekt-Referenz belegt
Diese sind nicht blockierend — ich habe sie bereits richtig umgesetzt bzw. würde es tun — gehören aber auf das Protokoll, weil der Plan sie anders beschreibt.
1.
timeout: 15sist inconfig.jsonungültig. Dauern sind ganzzahlige Millisekunden.Die Objekt-Referenz gibt das Feld als
Duration? · default: 15000; die serde-Implementierung deserialisiert überu64→Duration::from_millis. Ein"15s"lässt den Server beim Start abbrechen. Der Wert heißt bei mir deshalbdataStore.postgres.timeoutMs: 15000— nach seiner Einheit benannt, statt ein Suffix anzunehmen, das die Datei nicht tragen kann.2. Der Postgres-Benutzername kann nicht ins 1Password-Item. Auf dem
DataStore-Objekt istauthSecretvom TypSecretKeyOptional,authUsernamedagegen ein einfacherStringohne Secret-Form. Der Benutzername ist also notwendig öffentlich (dataStore.postgres.username, landet in der ConfigMap); das Item trägt nurpassword. Upstream-Beschränkung, keine Wahl.3. Dafür ist das Passwort nicht in der ConfigMap — besser als der Plan annahm.
authSecretist ein getaggtes Unterobjekt mit vier Varianten (None,Value,EnvironmentVariable,File):config.jsonbleibt damit vollständig geheimnisfrei und kann eine echte ConfigMap mitchecksum/configsein, genau wie geplant — ohne Init-Container-Substitution. Dasselbe gilt für den S3-Blob-Store (secretKey:SecretKeyOptional,accessKey:PublicStringOptional, beide mitEnvironmentVariable-Variante), wodurch das S3-OnePasswordItemtatsächlich konsumiert wird statt nur zu existieren. Ein leerer Umgebungswert gilt dabei als „nicht gefunden" und ist ein harter Fehler.4. Der Bootstrap-Assistent erscheint nicht, wenn das Chart
config.jsonrendert. Schritt 1–2 der Erstinstallation stimmen so nicht.Wir liefern immer eine. Gegen eine leere Datenbank ergibt das einen Server ohne Listener, ohne Domain und ohne Administrator — und ohne Weg, das zu ändern. Der dokumentierte Pfad für genau diesen Fall ist der Recovery-Modus:
Der Recovery-Modus bringt die Verwaltungs-Oberfläche auf 8080 und hält alle Mail-Ports zu. Die Erstinstallation braucht daher einen zusätzlichen ersten Schritt und einen Wert dafür (
bootstrap.enabled, Vorgabefalse), und aus sieben Schritten werden acht. Die WebUI-Arbeit aus Schritt 3 bleibt unverändert — sie findet nur im Recovery-Modus statt, nicht im Bootstrap-Assistenten.Nebenbefund, der die Sache schärft: fehlt die Datei ganz (gescheiterter ConfigMap-Mount), bricht der Server nicht ab, sondern öffnet einen flüchtigen In-Memory-Store und läuft scheinbar normal. Ein stiller Totalverlust-Zustand, der in die Wiki-Fallen gehört.
Was bestätigt ist und trägt
Damit nicht nur die Korrekturen im Protokoll stehen — am Quelltext von
v0.16.24geprüft und wie geplant:config.jsonist genau dasDataStore-Objekt, kein Umschlag mitdata/blob/fts/lookup. Die gegenteilige Stelle inUPGRADING/v0_16.mdist lose Prosa; das Migrationsskript schreibt nachweislich nurDataStorein die Datei. Der@typeistPostgreSql.listener.rs:50,is_none_or), sondern übernetwork.rs:341-363auch die Tasks, deren Vorgabe alle zehn Flags auftruesetzt. Ein Tippfehler inSTALWART_ROLEergibt einen Knoten mit allen Listenern und allen Tasks. Zudem läuftlog_errors()nachListeners::parse— die Listener sind gebunden, bevor die Fehlermeldung erscheint.ClusterRolehat genau die geplante Form und liegt in der Datenbank;insert_safe_defaultslegt keine an, eine frische Installation hat also null Rollen./healthz/liveund/healthz/readysind nicht auf vertraute Netze beschränkt — kein Auth, keine Loopback-Prüfung./healthz/readyprüft allerdings nur, dass das Data-Store-Handle gesetzt ist, und fragt die Datenbank nicht ab.periodSeconds: 10sind das 12/min je Pod — ab etwa acht Pods auf einem Knoten kippt es in 429er und damit in Neustarts. Gehört in die Wiki-Fallen und ist ein Argument,periodSecondsnicht zu verkleinern.Stand der Arbeit
Branch
feat/stalwart-chart(lokal, signiert, nicht gepusht, kein PR):Chart.yaml,values.yaml,_helpers.tpl,publish-stalwart.yml, der Eintrag inchange-app-version.ymlund der Renovate-Block stehen. Die Workloads, Services, NetworkPolicies, der Ingress, die ConfigMap, das Certificate, dieOnePasswordItems,NOTES.txt,values.schema.json, die CI-Fixtures, die README-Zeile und die Wiki-Seiten fehlen — der Sicherheitskontext des StatefulSets ist genau das, was oben blockiert.Sobald die Entscheidung zu
NET_BIND_SERVICEda ist, baue ich den Rest in einem Durchgang fertig.Entscheidung zum Blocker: Entscheidung 3 wird umgekehrt
Der Befund im Kommentar davor trägt. Nachgeprüft gegen
capabilities(7), wörtlich:Und zum Auslöser:
drop: [ALL]leert genau dieses Bounding-Set. Weilsetcap cap_net_bind_service=+epdas Effective-Bit setzt, ist die Binärdatei ein „capability-dumb binary", und die Prüfung läuft beimexecve— vor jedem Bind. Damit fallen alle drei Auswege aus der Bestandsaufnahme aus demselben Grund weg, und der Grund ist dasexec, nicht das Binden: hohe Ports helfen nicht,-alpinehat dasselbesetcap, und der sysctl-Weg adressiert ebenfalls nur das Binden.Die Planbegründung („nichts unter 1024 wird gebunden, also wird die Capability nicht gebraucht") war für das Binden richtig und für die Kernel-Prüfung unerheblich. Der Fehler liegt beim Planautor, nicht beim Umsetzer; das Anhalten vor dem PR war richtig.
Ein vierter Weg wurde geprüft und verworfen: ein Init-Container mit
CAP_SETFCAP, der die Binärdatei in ein emptyDir kopiert und die Capability entfernt. Technisch gangbar, tauscht aber eine harmlose Capability gegen eine gefährliche und lässt den Hauptcontainer eine Binärdatei aus einem beschreibbaren Volume ausführen.Festgelegt
1.
drop: [ALL]plusadd: [NET_BIND_SERVICE]als dokumentierte Abweichung, Präzedenzsamba. Der Kommentar invalues.yamlmuss zwei Dinge sagen, nicht eines:execve-Regel, nicht ein Bedarf beim Binden;Ohne den zweiten Satz entfernt die nächste Härtungsrunde sie als unbenutzt, und der Chart startet nicht mehr. Fällt die Datei-Capability bei Upstream je weg, ist das die Stelle, an der die Abweichung wieder verschwindet.
2. Der Container bindet die üblichen Ports 25, 465 und 993. Der NodePort bleibt 30025/30465/30993, die Anforderung des Betreibers ist damit erfüllt — der NodePort-Bereich beginnt ohnehin bei 30000. Der Service bildet ab:
nodePort: 30025→targetPort: 25.Begründung: da die Capability ohnehin gewährt werden muss, ist die Tiefenverteidigung hoher Ports hinfällig — der Prozess könnte jederzeit einen privilegierten Port binden. Was bleibt, ist der Gewinn an Erwartbarkeit: die Listener-Objekte, die in der WebUI angelegt werden, tragen die Zahlen aus jeder Stalwart-Doku und aus der EXPOSE-Liste des Abbilds. Die unvermeidliche Portnummern-Dopplung aus Fall 1 wird dadurch erheblich weniger überraschend.
Folgen für den Entwurf:
portsträgt je Eintrag zwei Zahlen (containerPortundnodePort) statt einer.Die vier Korrekturen am Plan werden übernommen
timeoutMs: 15000statttimeout: 15s— Dauern sind ganzzahlige Millisekunden. Der genaue Feldname ist gegen dasserde(rename)-Attribut der Postgres-Store-Struktur zu bestätigen, nicht abzuleiten.authUsernameist ein einfacher String ohne Secret-Form, also notwendig öffentlich. Das Item trägt nur das Passwort.authSecretkennt die VarianteEnvironmentVariable— im Registry-Schema bestätigt (SecretKeyEnvironmentVariable). Damit bleibtconfig.jsongeheimnisfrei: echte ConfigMap mitchecksum/config, kein Substitutions-Init-Container, und das S3-OnePasswordItemwird konsumiert statt nur zu existieren. Besser als der Plan annahm.config.jsonrendert — er läuft nur beim ersten Start ohne diese Datei. Die Erstinstallation braucht stattdessen einen Recovery-Modus-Schritt (STALWART_RECOVERY_MODE=1) und damit einen Wertbootstrap.enabled; aus sieben Schritten werden acht. Deckt sich mitboot.rs, wo Bootstrap- und Recovery-Modus zwei getrennte Zustände sind.Zwei neue Fallen, beide ins Wiki
Fehlendes
config.json— etwa durch einen gescheiterten Mount — bricht den Start nicht ab, sondern öffnet einen flüchtigen In-Memory-Store, bei grünen Probes. Ein Mailserver, der annimmt und beim Neustart alles verliert./healthz/*hat ein anonymes Ratenlimit von 100 Anfragen pro 60 Sekunden je Quell-IP. Probes kommen von der Knoten-IP, alle Pods eines Knotens teilen denselben Eimer. Bei Liveness, Readiness und Startup mitperiodSeconds: 10sind das rund 18 Anfragen je Pod und Minute — ab etwa fünf Pods pro Knoten kippt es in 429er und damit in Neustarts.Daraus folgt eine Festlegung für die Probes: die Summe eines Knotens muss bei realistischer Pod-Zahl deutlich unter dem Eimer bleiben, und die Rechnung gehört als Kommentar in
values.yaml, sonst zieht die nächste Runde die Intervalle wieder an. Zu prüfen ist dabei, ob die Readiness-Probe besser als TCP-Socket auf den Mail-Port des jeweiligen Workloads läuft — das umgeht den Eimer vollständig, belegt aber nur einen offenen Listener statt der Dienstbereitschaft. Die Wahl ist zu begründen, nicht zu raten.Ergänzend zum Fail-Open: es betrifft auch die Tasks, nicht nur die Listener, mit allen zehn als Vorgabe.
Der Rest des Plans im Kommentar davor bleibt unverändert gültig.
Stand nach dem Merge (2026-10-04)
stalwart/liegt aufmain, Version 1.0.1+upv0.16.24, veröffentlicht. PR #77 gemergt am 2026-10-02 um 22:59 mitPart of #65— dieses Issue ist deshalb bewusst offen.Nachträglich ist die Valkey-Abhängigkeit auf unseren eigenen Chart aus dem veröffentlichten Index umgestellt worden (nicht Upstreams), und die Bedingung heißt
valkeySubchart.enabled, nichtvalkey.enabled— alles untervalkey:geht an den Subchart und wird gegen dessen Schema geprüft, das keinenabledkennt.Offen bleiben vier Messungen, alle am echten Cluster:
/var/lib/stalwartetwas einen Neustart überleben, wenn Daten und Blobs extern liegen? Fällt die Antwort auf ja, braucht es doch eine PVC je Knotengruppe.config.jsonselbst, wenn die Datei existiert und auf ein leeres Postgres zeigt?proxyTrustedNetworksund steht nach der ersten Verbindung in derReceived:-Kopfzeile. Davon hängt die Spam-Abwehr ab: ohne richtig gesetztes vertrautes Netz sieht Stalwart die Knoten-IP statt des Absenders, und SPF, Blocklisten und Ratenbegrenzung bewerten dann die falsche Adresse.--service-node-port-rangedieses Clusters 30025–30993 ein? (Vorgabe 30000–32767.)Teilweise offen: das
/healthz-Ratenlimit von 100 Anfragen/60 s je Quell-IP stammt aus den Registry-Vorgaben im Quelltext, nicht von einem laufenden Dienst.Anderswo geschlossen: der Valkey-Subchart-Pfad war hier nur gerendert, nie installiert. Mit #78/PR #80 (
paperless-ngx) ist er erstmals wirklich installiert worden —ci-valkey-sfs-0wurde1/1 Running, der Init-Container hat dierequirepass-Zeile gefunden und die Probe sich authentifiziert.Stalwarts OIDC-Seite, gemessen — Querverweis auf #89
Beim Chart für den Webmail-Client (#89) ist die Anmeldekette durchgelesen worden, und dabei sind zwei Eigenschaften dieses Servers aufgefallen, die hier festgehalten gehören. Die vollständige Kette samt Fehlerfällen steht als Kommentar auf #89; hier nur, was Stalwart selbst betrifft.
Zwei getrennte Rollen, zwei Objekte
OidcDirectoryissuerUrl,requireAudience,requireScopes,claimUsername,usernameDomain,claimName,claimGroups,memberTenantIdOidcProviderauthCodeExpiry,refreshTokenExpiry,idTokenExpiry,userCodeExpiry(Device-Flow), eigenersignatureKey, Client-RegistrierungFür SSO gegen einen externen Anbieter ist
OidcDirectorydas Richtige: ein Client legt ein Token vor, Stalwart prüft es und bildet einen Claim auf ein Postfach ab.Es braucht kein Client-Secret, und das ist kein Versehen
crates/directory/src/backend/oidc/lookup.rs,get_key:Stalwart holt die öffentlichen Schlüssel aus dem Discovery-Dokument (fünf Minuten Cache) und prüft damit die Signatur. Dass
OidcDirectorykein Secret-Feld hat, ist eine Folge davon:authenticate_jwtweistHS256,HS384undHS512ab — die symmetrischen Verfahren, die ein gemeinsames Geheimnis bräuchten und deren Annahme die klassische Algorithmus-Verwechslungslücke wäre. Opake Token laufen überfetch_userinfo(token), ebenfalls ohne Secret.Ein OIDC-Directory nimmt nur Bearer — die Aussperrungsfalle
Das Ende von
authenticate:Benutzername und Passwort gegen ein OIDC-Directory funktionieren nicht. Daraus folgt eine Konfiguration, die aussperrt: OIDC als
DirectoryBootstrapanstelle vonInternal. Dann gibt es nirgends mehr ein Passwort — auch nicht für die Weboberfläche dieses Servers, und auch nicht für IMAP oder SMTP.Das Schema trennt die beiden Fälle:
Internalist die Grundlage und keines der hinzufügbaren Verzeichnisse. Ein OIDC-Directory tritt also neben die internen Konten: Admin-Zugang und Passwort-Anmeldung bleiben, Bearer-Token kommen hinzu.Internalbleibt das Bootstrap-Verzeichnis — das ist die Regel, die den Zugang zur eigenen Oberfläche erhält.Audience: der Fehler, dessen Symptom irreführt
authenticate_jwtweist ein Token ausdrücklich ab, wennrequire_audgesetzt ist und das Token gar keinaudträgt:Keycloak legt in ein Access-Token standardmäßig keine brauchbare Audience; der Client braucht einen Audience-Mapper. Und weil ein JMAP-Client bei 401 üblicherweise erneuert und wiederholt, zeigt sich der Fehler als Schleife statt als Meldung — die Anmeldung beim Anbieter klappt, der Mailzugriff nicht.
Nicht gemessen
Wie Stalwart ein Zugangsdatum einem Verzeichnis zuordnet, wenn mehrere existieren — der Reihe nach, oder über die Zugehörigkeit des Kontos.
memberTenantIdanOidcDirectorydeutet auf Letzteres. Die Aussage „tritt daneben" folgt aus den Enum-Formen, nicht aus gelesenem Zuordnungscode, und sie gehört vor dem Ausrollen belegt.