Ein Chart für Bulwark Webmail (bulwarkmail/webmail), einen JMAP-Webmail-Client auf Next.js — Mail, Kalender, Kontakte und Dateien im Browser.
Er ist der Begleiter zu stalwart (#65). Upstream formuliert die Arbeitsteilung selbst: „Stalwart is the mail server: it holds the messages, speaks SMTP, and owns the accounts." Bulwark ist ausschließlich Client und hält keine Mail.
| Dies ist noch kein freigegebener Plan. Fünf Entscheidungen unten sind offen und gehören vor der Umsetzung beantwortet.
Verifizierte Fakten
Gegen die GitHub-Releases, das Dockerfile am Zweig main und die Upstream-Dokumentation geprüft, nicht aus der Erinnerung:
Abbild
ghcr.io/bulwarkmail/webmail
Neueste Fassung
1.12.0 (30.09.2026); davor 1.11.2, 1.11.1, 1.11.0
Chart-Version daraus
1.0.0+up1.12.0
Lizenz
AGPL-3.0-only
Grundlage
node:24-alpine, Next.js-Standalone-Build
Benutzer
USER nextjs, uid/gid 1001 — läuft bereits als Nicht-Root
Port
EXPOSE 3000, PORT=3000, HOSTNAME=0.0.0.0
Start
CMD ["node", "server.js"]
Health
/api/health — „answers liveness probes for Docker and orchestrators. It returns healthy, degraded, or unhealthy along with uptime, version, and heap usage"
Datenbank
keine, kein Cache, kein Broker
Lobenswert am Abbild: Upstream entfernt npm und npx aus dem Laufzeit-Abbild und ruft apk upgrade im Build.
Es ist nicht zustandslos — vier Verzeichnisse
Die Webseite sagt „Docker is the only requirement". Das Abbild legt aber vier beschreibbare Verzeichnisse unter /app/data an, und die Doku nennt für drei von ihnen, was darin liegt:
Verzeichnis
Variable
Inhalt
muss überleben
/app/data/admin
ADMIN_CONFIG_DIR
„Operator-authored state written by the setup wizard and the admin dashboard: config.json, policy.json, admin.json (passwordHash only), plugin-config/, plugins/, themes/, and uploaded branding assets"
ja
/app/data/admin-state
ADMIN_STATE_DIR
„Runtime mutations that must always stay writable: admin-state.json (login timestamps), audit.log, and the bootstrap setup token"
ja — das Protokoll
/app/data/settings
SETTINGS_DATA_DIR
„Encrypted per-account user preferences. Required only when SETTINGS_SYNC_ENABLED=true"
nur bei eingeschalteter Synchronisierung
/app/data/telemetry
—
„Mount this so the consent and instance id survive image upgrades"
optional
Ein Chart ohne Volume verliert also die Admin-Konfiguration und das Audit-Protokoll bei jedem Neustart.
Drei Optionen sind beim Bauen festgebacken
„These are compiled into the bundle and cannot be changed at runtime … To change build-time variables with the published Docker image, rebuild with matching --build-arg."
NEXT_PUBLIC_BASE_PATH — ein Unterpfad wie /webmail. Der Chart kann Bulwark also nur an der Wurzel eines Hostnamens ausliefern, nicht unter einem Pfad.
NEXT_PUBLIC_DEFAULT_LOCALE — Vorgabe en. Die Rückfall-Sprache der Oberfläche ist damit nicht durch den Chart änderbar; ein Besucher mit passendem Accept-Language bekommt seine Sprache, alle anderen Englisch.
NEXT_PUBLIC_PUSH_RELAY_URL — Vorgabe https://notifications.relay.bulwarkmail.org. Web-Push läuft damit über den Relay des Anbieters, und das ist mit dem veröffentlichten Abbild nicht umstellbar. Bei einem Mailclient ist das eine Angabe, die in values.yaml und auf die Wiki-Seite gehört, auch wenn der Chart sie nicht beeinflussen kann.
Ein eigenes Abbild zu bauen ist der einzige Ausweg und liegt außerhalb dessen, was dieses Repository tut — dieselbe Grenze wie bei garage (#81) und keycloak (#83).
Deklarativ geht, mit einer Vorrangfalle
Es gibt einen Einrichtungsassistenten beim ersten Start, der „chooses OAuth or password sign-in, generates a session secret and sets the admin password" und das Ergebnis nach ADMIN_CONFIG_DIR schreibt. Er ist aber vermeidbar: JMAP_SERVER_URL schaltet ihn ab.
Die Falle, und sie ist die unangenehmste Stelle dieses Charts: „A server URL saved by the wizard takes priority over the environment variable." Hat der Assistent also je gelaufen, gewinnt die Datei auf dem Volume über den Wert aus dem Chart — lautlos, und ein helm upgrade mit geänderter URL ändert dann nichts.
Die Laufzeitvariablen
Variable
Bedeutung
JMAP_SERVER_URL
Adresse des Stalwart-Servers; schaltet den Assistenten ab
SESSION_SECRET
„Secret used to encrypt persistent 'Remember me' sessions, settings sync data, multi-account state" — openssl rand -base64 32
ADMIN_PASSWORD
Anfangspasswort der Admin-Oberfläche. Ohne Angabe wird ein Zufallspasswort erzeugt und nach stdout geschrieben — in Kubernetes also ins Pod-Protokoll, bei jedem Neustart neu. Dieselbe Gestalt wie STALWART_RECOVERY_ADMIN bei #65
OIDC-Anmeldung; OAUTH_ONLY verbirgt das Passwortformular
OAUTH_CLIENT_SECRET_FILE
eine Dateiform — das OAuth-Geheimnis muss nicht in die Prozessumgebung
OAUTH_CLIENT_SECRET_FILE ist erwähnenswert, weil garage (#81) und keycloak (#83) beide keine Dateiform kennen und wir dort auf Umgebungsvariablen ausgewichen sind. Hier geht der bessere Weg.
Offene Entscheidungen
Deklarativ über JMAP_SERVER_URL oder der Assistent? Deklarativ passt zu diesem Repository, aber die Vorrangfalle oben bleibt: wer den Assistenten einmal laufen lässt, entkoppelt den Chart lautlos.
Ein Volume oder mehrere? Alle vier Verzeichnisse liegen unter /app/data. Eine PVC dort hält Konfiguration, Zustand und Audit-Protokoll zueinander konsistent — nach der Lehre aus #81 (Longhorn-Schnappschüsse sind je Volume atomar) spricht das für eine.
Einstellungs-Synchronisierung an oder aus?SETTINGS_SYNC_ENABLED verschlüsselt Nutzereinstellungen auf dem Volume und verlangt SESSION_SECRET. An heißt: Einstellungen folgen dem Nutzer, und das Volume trägt Nutzerdaten.
Anmeldung: Passwort oder OIDC gegen Keycloak? Mit #83 läuft ein Anbieter. OAUTH_ONLY würde das Passwortformular ganz verbergen. Das verbindet die drei Charts und ist eine Betriebsentscheidung, keine Chart-Frage.
StatefulSet oder Deployment? Mit einer PVC und ReadWriteOnce blockiert ein rollendes Update eines Deployments sich selbst — zwei Pods können das Volume nicht teilen. Die Konvention dieses Repositories verlangt für zustandsbehaftete Anwendungen ohnehin eine StatefulSet (bulwark.sfs.yaml).
Am laufenden Dienst zu messen
Antwortet /api/health mit einem Code ungleich 200, wenn der Zustand degraded oder unhealthy ist? Das entscheidet, ob eine Probe darauf überhaupt scheitern kann. Tut sie es nicht, ist es die Gestalt von #218 und zuletzt von #83 — eine Probe, die immer grün ist. Vor dem Festlegen der Probes zu messen, nicht danach.
Hält readOnlyRootFilesystem: true mit /app/data als Volume? Next.js im Standalone-Modus schreibt unter Umständen nach /tmp oder in einen Cache unter .next — welche emptyDirs nötig sind, ist zu messen.
Gewinnt die vom Assistenten geschriebene config.json wirklich über JMAP_SERVER_URL? Die Doku behauptet es; davon hängt ab, wie scharf die Warnung formuliert werden muss.
Wird OAUTH_CLIENT_SECRET_FILE beim Start oder je Anfrage gelesen? Entscheidet, ob eine Geheimnis-Drehung einen Neustart braucht.
CI-Verdrahtung
.github/workflows/publish-bulwark.yml — eine bestehende Hülle kopieren.
bulwark in die chart-name-Auswahlliste von change-app-version.yml.
Install-Test-Fixtures unter ci/bulwark/. Hier billig und aussagekräftig: es braucht keine Datenbank, ein JMAP_SERVER_URL auf eine Attrappe genügt, und der Pod soll Ready werden.
customManagers-Block in renovate.json5. Zu messen statt anzunehmen: es gibt Vorab-Fassungen (1.11.0-beta.1 bis .3), also ist per Dry-Run zu belegen, ob Renovate sie anbietet — bei keycloak (#83) war die vermutete Beschränkung am Ende unnötig, bei garage (#81) nötig.
Nicht in diesem Chart
Der Stalwart-Server selbst (#65) und die OIDC-Client-Registrierung in Keycloak (#83) gehören in das abnehmende Bundle in cluster-config, nicht hierher.
Ein Chart für **Bulwark Webmail** (`bulwarkmail/webmail`), einen JMAP-Webmail-Client auf Next.js — Mail, Kalender, Kontakte und Dateien im Browser.
**Er ist der Begleiter zu `stalwart` (#65).** Upstream formuliert die Arbeitsteilung selbst: „Stalwart is the mail server: it holds the messages, speaks SMTP, and owns the accounts." Bulwark ist ausschließlich Client und hält keine Mail.
| **Dies ist noch kein freigegebener Plan.** Fünf Entscheidungen unten sind offen und gehören vor der Umsetzung beantwortet.
## Verifizierte Fakten
Gegen die GitHub-Releases, das `Dockerfile` am Zweig `main` und die Upstream-Dokumentation geprüft, nicht aus der Erinnerung:
| | |
| --- | --- |
| Abbild | `ghcr.io/bulwarkmail/webmail` |
| Neueste Fassung | **1.12.0** (30.09.2026); davor 1.11.2, 1.11.1, 1.11.0 |
| Chart-Version daraus | `1.0.0+up1.12.0` |
| Lizenz | AGPL-3.0-only |
| Grundlage | `node:24-alpine`, Next.js-Standalone-Build |
| Benutzer | `USER nextjs`, **uid/gid 1001** — läuft bereits als Nicht-Root |
| Port | `EXPOSE 3000`, `PORT=3000`, `HOSTNAME=0.0.0.0` |
| Start | `CMD ["node", "server.js"]` |
| Health | **`/api/health`** — „answers liveness probes for Docker and orchestrators. It returns healthy, degraded, or unhealthy along with uptime, version, and heap usage" |
| Datenbank | **keine**, kein Cache, kein Broker |
Lobenswert am Abbild: Upstream entfernt `npm` und `npx` aus dem Laufzeit-Abbild und ruft `apk upgrade` im Build.
## Es ist nicht zustandslos — vier Verzeichnisse
Die Webseite sagt „Docker is the only requirement". Das Abbild legt aber vier beschreibbare Verzeichnisse unter `/app/data` an, und die Doku nennt für drei von ihnen, was darin liegt:
| Verzeichnis | Variable | Inhalt | muss überleben |
| --- | --- | --- | --- |
| `/app/data/admin` | `ADMIN_CONFIG_DIR` | „Operator-authored state written by the setup wizard and the admin dashboard: `config.json`, `policy.json`, `admin.json` (passwordHash only), `plugin-config/`, `plugins/`, `themes/`, and uploaded branding assets" | **ja** |
| `/app/data/admin-state` | `ADMIN_STATE_DIR` | „Runtime mutations that must always stay writable: `admin-state.json` (login timestamps), `audit.log`, and the bootstrap setup token" | **ja** — das Protokoll |
| `/app/data/settings` | `SETTINGS_DATA_DIR` | „Encrypted per-account user preferences. Required only when `SETTINGS_SYNC_ENABLED=true`" | nur bei eingeschalteter Synchronisierung |
| `/app/data/telemetry` | — | „Mount this so the consent and instance id survive image upgrades" | optional |
Ein Chart ohne Volume verliert also die Admin-Konfiguration und das Audit-Protokoll bei jedem Neustart.
## Drei Optionen sind beim Bauen festgebacken
„These are compiled into the bundle and cannot be changed at runtime … To change build-time variables with the published Docker image, rebuild with matching `--build-arg`."
- **`NEXT_PUBLIC_BASE_PATH`** — ein Unterpfad wie `/webmail`. Der Chart kann Bulwark also **nur an der Wurzel eines Hostnamens** ausliefern, nicht unter einem Pfad.
- **`NEXT_PUBLIC_DEFAULT_LOCALE`** — Vorgabe `en`. Die Rückfall-Sprache der Oberfläche ist damit nicht durch den Chart änderbar; ein Besucher mit passendem `Accept-Language` bekommt seine Sprache, alle anderen Englisch.
- **`NEXT_PUBLIC_PUSH_RELAY_URL`** — Vorgabe **`https://notifications.relay.bulwarkmail.org`**. Web-Push läuft damit über den Relay des Anbieters, und das ist mit dem veröffentlichten Abbild nicht umstellbar. Bei einem Mailclient ist das eine Angabe, die in `values.yaml` und auf die Wiki-Seite gehört, auch wenn der Chart sie nicht beeinflussen kann.
Ein eigenes Abbild zu bauen ist der einzige Ausweg und liegt außerhalb dessen, was dieses Repository tut — dieselbe Grenze wie bei `garage` (#81) und `keycloak` (#83).
## Deklarativ geht, mit einer Vorrangfalle
Es gibt einen **Einrichtungsassistenten** beim ersten Start, der „chooses OAuth or password sign-in, generates a session secret and sets the admin password" und das Ergebnis nach `ADMIN_CONFIG_DIR` schreibt. Er ist aber vermeidbar: **`JMAP_SERVER_URL` schaltet ihn ab.**
**Die Falle, und sie ist die unangenehmste Stelle dieses Charts:** „A server URL saved by the wizard takes priority over the environment variable." Hat der Assistent also je gelaufen, gewinnt die Datei auf dem Volume über den Wert aus dem Chart — lautlos, und ein `helm upgrade` mit geänderter URL ändert dann nichts.
### Die Laufzeitvariablen
| Variable | Bedeutung |
| --- | --- |
| `JMAP_SERVER_URL` | Adresse des Stalwart-Servers; schaltet den Assistenten ab |
| `SESSION_SECRET` | „Secret used to encrypt persistent 'Remember me' sessions, settings sync data, multi-account state" — `openssl rand -base64 32` |
| `ADMIN_PASSWORD` | Anfangspasswort der Admin-Oberfläche. **Ohne Angabe wird ein Zufallspasswort erzeugt und nach stdout geschrieben** — in Kubernetes also ins Pod-Protokoll, bei jedem Neustart neu. Dieselbe Gestalt wie `STALWART_RECOVERY_ADMIN` bei #65 |
| `STALWART_FEATURES` | Stalwart-spezifisches (Passwortwechsel, Sieve-Verwaltung), Vorgabe `true` |
| `OAUTH_ENABLED`, `OAUTH_CLIENT_ID`, `OAUTH_ISSUER_URL`, `OAUTH_ONLY` | OIDC-Anmeldung; `OAUTH_ONLY` verbirgt das Passwortformular |
| **`OAUTH_CLIENT_SECRET_FILE`** | **eine Dateiform** — das OAuth-Geheimnis muss nicht in die Prozessumgebung |
`OAUTH_CLIENT_SECRET_FILE` ist erwähnenswert, weil `garage` (#81) und `keycloak` (#83) beide **keine** Dateiform kennen und wir dort auf Umgebungsvariablen ausgewichen sind. Hier geht der bessere Weg.
## Offene Entscheidungen
1. **Deklarativ über `JMAP_SERVER_URL` oder der Assistent?** Deklarativ passt zu diesem Repository, aber die Vorrangfalle oben bleibt: wer den Assistenten einmal laufen lässt, entkoppelt den Chart lautlos.
2. **Ein Volume oder mehrere?** Alle vier Verzeichnisse liegen unter `/app/data`. Eine PVC dort hält Konfiguration, Zustand und Audit-Protokoll zueinander konsistent — nach der Lehre aus #81 (Longhorn-Schnappschüsse sind je Volume atomar) spricht das für **eine**.
3. **Einstellungs-Synchronisierung an oder aus?** `SETTINGS_SYNC_ENABLED` verschlüsselt Nutzereinstellungen auf dem Volume und verlangt `SESSION_SECRET`. An heißt: Einstellungen folgen dem Nutzer, und das Volume trägt Nutzerdaten.
4. **Anmeldung: Passwort oder OIDC gegen Keycloak?** Mit #83 läuft ein Anbieter. `OAUTH_ONLY` würde das Passwortformular ganz verbergen. Das verbindet die drei Charts und ist eine Betriebsentscheidung, keine Chart-Frage.
5. **StatefulSet oder Deployment?** Mit einer PVC und `ReadWriteOnce` blockiert ein rollendes Update eines Deployments sich selbst — zwei Pods können das Volume nicht teilen. Die Konvention dieses Repositories verlangt für zustandsbehaftete Anwendungen ohnehin eine StatefulSet (`bulwark.sfs.yaml`).
## Am laufenden Dienst zu messen
- **Antwortet `/api/health` mit einem Code ungleich 200, wenn der Zustand `degraded` oder `unhealthy` ist?** Das entscheidet, ob eine Probe darauf überhaupt scheitern kann. Tut sie es nicht, ist es die Gestalt von #218 und zuletzt von #83 — eine Probe, die immer grün ist. **Vor dem Festlegen der Probes zu messen, nicht danach.**
- **Hält `readOnlyRootFilesystem: true`** mit `/app/data` als Volume? Next.js im Standalone-Modus schreibt unter Umständen nach `/tmp` oder in einen Cache unter `.next` — welche emptyDirs nötig sind, ist zu messen.
- **Gewinnt die vom Assistenten geschriebene `config.json` wirklich über `JMAP_SERVER_URL`?** Die Doku behauptet es; davon hängt ab, wie scharf die Warnung formuliert werden muss.
- **Wird `OAUTH_CLIENT_SECRET_FILE` beim Start oder je Anfrage gelesen?** Entscheidet, ob eine Geheimnis-Drehung einen Neustart braucht.
## CI-Verdrahtung
1. `.github/workflows/publish-bulwark.yml` — eine bestehende Hülle **kopieren**.
2. `bulwark` in die `chart-name`-Auswahlliste von `change-app-version.yml`.
3. Install-Test-Fixtures unter `ci/bulwark/`. Hier billig und aussagekräftig: es braucht keine Datenbank, ein `JMAP_SERVER_URL` auf eine Attrappe genügt, und der Pod soll `Ready` werden.
4. `customManagers`-Block in `renovate.json5`. **Zu messen statt anzunehmen:** es gibt Vorab-Fassungen (`1.11.0-beta.1` bis `.3`), also ist per Dry-Run zu belegen, ob Renovate sie anbietet — bei `keycloak` (#83) war die vermutete Beschränkung am Ende unnötig, bei `garage` (#81) nötig.
## Nicht in diesem Chart
Der Stalwart-Server selbst (#65) und die OIDC-Client-Registrierung in Keycloak (#83) gehören in das abnehmende Bundle in `cluster-config`, nicht hierher.
Die fünf offenen Entscheidungen sind beantwortet: drei vom Betreuer, zwei folgen aus den Konventionen.
1. Deklarativ, der Assistent läuft nie
JMAP_SERVER_URL wird gesetzt und schaltet den Einrichtungsassistenten ab. SESSION_SECRET und ADMIN_PASSWORD kommen aus einem OnePasswordItem.
ADMIN_PASSWORD ist dabei nicht optional, sondern der Grund für diese Richtung: ohne Angabe erzeugt Bulwark ein Zufallspasswort und schreibt es nach stdout — in Kubernetes also ins Pod-Protokoll, bei jedem Neustart ein neues. Dieselbe Gestalt wie STALWART_RECOVERY_ADMIN bei #65, und dieselbe Antwort.
Die Vorrangfalle gehört prominent dokumentiert, nicht als Fußnote: „A server URL saved by the wizard takes priority over the environment variable." Wer die Admin-Oberfläche öffnet und dort eine Server-URL speichert, entkoppelt den Chart lautlos — ein helm upgrade mit geändertem JMAP_SERVER_URL ändert danach nichts, und nichts in der Oberfläche sagt das.
Verworfen: ADMIN_CONFIG_DIR schreibgeschützt einhängen, um den Assistenten hart zu sperren. Die Admin-Oberfläche schreibt in dasselbe Verzeichnis (Policy, Themes, Branding, Plugins), ein schreibgeschütztes Einhängen würde sie also mitnehmen. Die Falle wird benannt, nicht verbaut.
2. Einstellungs-Synchronisierung an
SETTINGS_SYNC_ENABLED=true, /app/data/settings trägt verschlüsselte Einstellungen je Konto.
Folge, die in values.yaml, NOTES.txt und ins Wiki gehört: das Volume trägt damit Nutzerdaten, verschlüsselt mit SESSION_SECRET. Geht das Geheimnis verloren, sind sie unlesbar — auch aus einer intakten Sicherung. Das Geheimnis gehört deshalb genauso in die Sicherung wie das Volume, und das ist der Satz, der dort stehen muss: eine Sicherung des Volumes allein ist keine Sicherung.
3. Anmeldung ausschließlich über OIDC
OAUTH_ENABLED=true, OAUTH_ONLY=true, Keycloak (#83) als Anbieter, das Geheimnis über OAUTH_CLIENT_SECRET_FILE aus einem OnePasswordItem — also als vertraulicher Client, was Keycloak problemlos kann, und über die Dateiform, die bei garage (#81) und keycloak (#83) nicht existierte.
Das Risiko ist benannt und wird in Kauf genommen: fällt Keycloak aus oder ist die OIDC-Konfiguration falsch, kommt niemand mehr an seine Mail, und das Passwortformular ist verborgen. Daraus folgen zwei Dinge, die der Chart tragen muss:
Die Client-Registrierung in Keycloak ist ein Pflichtschritt vor der Installation, nicht danach. Mit OAUTH_ONLY und ohne registrierten Client ist die erste Anmeldung unmöglich. NOTES.txt nennt die Reihenfolge.
Der Ausweg aus einer Aussperrung gehört dokumentiert, weil er nicht offensichtlich ist:helm upgrade mit OAUTH_ONLY=false bringt das Passwortformular zurück. Das ist die einzige Tür, die nicht an Keycloak hängt — die Admin-Oberfläche hat ihr eigenes Passwort, gibt aber keinen Mailzugang. Ohne diesen Satz in NOTES.txt ist eine Fehlkonfiguration ein Ausfall ohne sichtbaren Weg zurück.
SESSION_SECRET ist damit verpflichtend, nicht optional: sowohl die Einstellungs-Synchronisierung als auch die persistenten Sitzungen hängen daran.
4. Eine PVC an /app/data
Alle vier Verzeichnisse liegen darunter, Bulwark legt sie selbst an. Nach der Lehre aus #81 — Longhorn-Schnappschüsse sind je Volume atomar, nicht volumenübergreifend — hält eine PVC Konfiguration, Laufzeitzustand, Audit-Protokoll und Nutzereinstellungen zueinander konsistent. Getrennte Volumes könnten eine Sicherung erzeugen, in der das Protokoll Anmeldungen nennt, die die Konfiguration noch nicht kennt.
5. StatefulSet
bulwark.sfs.yaml. Mit ReadWriteOnce blockiert ein rollendes Deployment sich selbst, weil zwei Pods das Volume nicht teilen können; die Konvention dieses Repositories verlangt für zustandsbehaftete Anwendungen ohnehin eine StatefulSet.
Was der Chart nicht ändern kann, und trotzdem benennen muss
Drei Optionen sind beim Bauen festgebacken. Zwei sind Unbequemlichkeiten, eine ist eine Offenlegung:
NEXT_PUBLIC_PUSH_RELAY_URL, Vorgabe https://notifications.relay.bulwarkmail.org. Web-Push läuft über den Relay des Anbieters, und mit dem veröffentlichten Abbild ist das nicht umstellbar. Bei einem Mailclient gehört das in values.yaml und auf die Wiki-Seite, auch wenn der Chart nichts dagegen tun kann.
NEXT_PUBLIC_BASE_PATH — kein Unterpfad, der Ingress bedient die Wurzel eines Hostnamens.
Gebaut aus template-chart/ nach CLAUDE.md und dem Stilleitfaden: StatefulSet, eine PVC, Service, Ingress nach Konvention (ingress.domain und ingress.clusterIssuer required ohne Vorgabe, className Vorgabe nginx, TLS-Secret tls-<release>-bulwark-ingress), OnePasswordItem für SESSION_SECRET, ADMIN_PASSWORD und das OAuth-Geheimnis, drei Probes, berechnete Limits, replicas plus scaleDown, strenges values.schema.json gegen die echten Bundle-Werte.
Entsteht keine ConfigMap — die Konfiguration läuft über Umgebungsvariablen —, dann greift die checksum/config-Regel (#82) nicht, und das muss ausdrücklich in values.yaml stehen, sonst sieht es wie eine Auslassung aus.
CI: publish-bulwark.yml aus einer bestehenden Hülle kopiert; bulwark in die chart-name-Liste; Fixtures unter ci/bulwark/ mit einer JMAP_SERVER_URL-Attrappe und Dummy-Geheimnissen; customManagers-Block in renovate.json5.
Wiki im selben Durchgang: neue Seite bulwark, Zeile in Charts (Zähler prüfen, nicht übernehmen), Eintrag in _Sidebar.md, und auf der stalwart-Seite ein Querverweis, weil die beiden nur zusammen nützlich sind.
Am laufenden Dienst zu messen
Antwortet /api/health mit einem Code ungleich 200, wenn der Zustand degraded oder unhealthy ist? Es gibt nur diesen einen Endpunkt, nicht die Dreiteilung live/ready/started. Antwortet er immer mit 200 und nennt den Zustand nur im Rumpf, ist eine Probe darauf eine Probe, die nicht scheitern kann — die Gestalt von #218 und zuletzt von #83. Vor dem Festlegen der Probes zu messen, nicht danach, und wenn er nicht scheitern kann, gehört das als Grenze an den Wert geschrieben statt verschwiegen.
Hält readOnlyRootFilesystem: true mit /app/data als Volume? Next.js im Standalone-Modus schreibt unter Umständen nach /tmp oder in einen Cache unter .next — welche emptyDirs nötig sind, ist zu messen.
Gewinnt die vom Assistenten geschriebene config.json wirklich über JMAP_SERVER_URL? Davon hängt ab, wie scharf die Warnung formuliert werden muss.
Wird OAUTH_CLIENT_SECRET_FILE beim Start oder je Anfrage gelesen? Entscheidet, ob eine Geheimnis-Drehung einen Neustart braucht — und das gehört ins Wiki, weil es sonst beim ersten Wechsel überrascht.
Bietet Renovate die Vorab-Fassungen an? Es gibt 1.11.0-beta.1 bis .3. Per Dry-Run zu belegen: bei keycloak (#83) war die vermutete Beschränkung am Ende unnötig, bei garage (#81) nötig. Keine Zeile schreiben, die nichts tut.
Nicht in diesem Chart
Der Stalwart-Server selbst (#65) und die OIDC-Client-Registrierung in Keycloak (#83) gehören in das abnehmende Bundle in cluster-config.
# Freigegebener Umsetzungsplan
Die fünf offenen Entscheidungen sind beantwortet: drei vom Betreuer, zwei folgen aus den Konventionen.
## 1. Deklarativ, der Assistent läuft nie
`JMAP_SERVER_URL` wird gesetzt und schaltet den Einrichtungsassistenten ab. `SESSION_SECRET` und `ADMIN_PASSWORD` kommen aus einem `OnePasswordItem`.
`ADMIN_PASSWORD` ist dabei nicht optional, sondern der Grund für diese Richtung: ohne Angabe erzeugt Bulwark ein Zufallspasswort und **schreibt es nach stdout** — in Kubernetes also ins Pod-Protokoll, bei jedem Neustart ein neues. Dieselbe Gestalt wie `STALWART_RECOVERY_ADMIN` bei #65, und dieselbe Antwort.
**Die Vorrangfalle gehört prominent dokumentiert**, nicht als Fußnote: „A server URL saved by the wizard takes priority over the environment variable." Wer die Admin-Oberfläche öffnet und dort eine Server-URL speichert, entkoppelt den Chart lautlos — ein `helm upgrade` mit geändertem `JMAP_SERVER_URL` ändert danach nichts, und nichts in der Oberfläche sagt das.
**Verworfen: `ADMIN_CONFIG_DIR` schreibgeschützt einhängen**, um den Assistenten hart zu sperren. Die Admin-Oberfläche schreibt in dasselbe Verzeichnis (Policy, Themes, Branding, Plugins), ein schreibgeschütztes Einhängen würde sie also mitnehmen. Die Falle wird benannt, nicht verbaut.
## 2. Einstellungs-Synchronisierung an
`SETTINGS_SYNC_ENABLED=true`, `/app/data/settings` trägt verschlüsselte Einstellungen je Konto.
**Folge, die in `values.yaml`, `NOTES.txt` und ins Wiki gehört:** das Volume trägt damit **Nutzerdaten**, verschlüsselt mit `SESSION_SECRET`. Geht das Geheimnis verloren, sind sie unlesbar — auch aus einer intakten Sicherung. Das Geheimnis gehört deshalb genauso in die Sicherung wie das Volume, und das ist der Satz, der dort stehen muss: *eine Sicherung des Volumes allein ist keine Sicherung.*
## 3. Anmeldung ausschließlich über OIDC
`OAUTH_ENABLED=true`, `OAUTH_ONLY=true`, Keycloak (#83) als Anbieter, das Geheimnis über **`OAUTH_CLIENT_SECRET_FILE`** aus einem `OnePasswordItem` — also als vertraulicher Client, was Keycloak problemlos kann, und über die Dateiform, die bei `garage` (#81) und `keycloak` (#83) nicht existierte.
**Das Risiko ist benannt und wird in Kauf genommen:** fällt Keycloak aus oder ist die OIDC-Konfiguration falsch, kommt niemand mehr an seine Mail, und das Passwortformular ist verborgen. Daraus folgen zwei Dinge, die der Chart tragen muss:
- **Die Client-Registrierung in Keycloak ist ein Pflichtschritt *vor* der Installation**, nicht danach. Mit `OAUTH_ONLY` und ohne registrierten Client ist die erste Anmeldung unmöglich. `NOTES.txt` nennt die Reihenfolge.
- **Der Ausweg aus einer Aussperrung gehört dokumentiert, weil er nicht offensichtlich ist:** `helm upgrade` mit `OAUTH_ONLY=false` bringt das Passwortformular zurück. Das ist die einzige Tür, die nicht an Keycloak hängt — die Admin-Oberfläche hat ihr eigenes Passwort, gibt aber keinen Mailzugang. Ohne diesen Satz in `NOTES.txt` ist eine Fehlkonfiguration ein Ausfall ohne sichtbaren Weg zurück.
`SESSION_SECRET` ist damit **verpflichtend**, nicht optional: sowohl die Einstellungs-Synchronisierung als auch die persistenten Sitzungen hängen daran.
## 4. Eine PVC an `/app/data`
Alle vier Verzeichnisse liegen darunter, Bulwark legt sie selbst an. Nach der Lehre aus #81 — Longhorn-Schnappschüsse sind je Volume atomar, nicht volumenübergreifend — hält eine PVC Konfiguration, Laufzeitzustand, Audit-Protokoll und Nutzereinstellungen zueinander konsistent. Getrennte Volumes könnten eine Sicherung erzeugen, in der das Protokoll Anmeldungen nennt, die die Konfiguration noch nicht kennt.
## 5. StatefulSet
`bulwark.sfs.yaml`. Mit `ReadWriteOnce` blockiert ein rollendes Deployment sich selbst, weil zwei Pods das Volume nicht teilen können; die Konvention dieses Repositories verlangt für zustandsbehaftete Anwendungen ohnehin eine StatefulSet.
## Was der Chart nicht ändern kann, und trotzdem benennen muss
Drei Optionen sind beim Bauen festgebacken. Zwei sind Unbequemlichkeiten, eine ist eine Offenlegung:
- **`NEXT_PUBLIC_PUSH_RELAY_URL`**, Vorgabe `https://notifications.relay.bulwarkmail.org`. Web-Push läuft über den Relay des Anbieters, und mit dem veröffentlichten Abbild ist das nicht umstellbar. Bei einem Mailclient gehört das in `values.yaml` und auf die Wiki-Seite, auch wenn der Chart nichts dagegen tun kann.
- **`NEXT_PUBLIC_BASE_PATH`** — kein Unterpfad, der Ingress bedient die Wurzel eines Hostnamens.
- **`NEXT_PUBLIC_DEFAULT_LOCALE`** — Rückfall-Sprache bleibt `en`.
## Umfang
Gebaut aus `template-chart/` nach CLAUDE.md und dem Stilleitfaden: StatefulSet, eine PVC, Service, Ingress nach Konvention (`ingress.domain` und `ingress.clusterIssuer` required ohne Vorgabe, `className` Vorgabe `nginx`, TLS-Secret `tls-<release>-bulwark-ingress`), `OnePasswordItem` für `SESSION_SECRET`, `ADMIN_PASSWORD` und das OAuth-Geheimnis, drei Probes, berechnete Limits, `replicas` plus `scaleDown`, strenges `values.schema.json` gegen die echten Bundle-Werte.
**Entsteht keine ConfigMap** — die Konfiguration läuft über Umgebungsvariablen —, dann greift die `checksum/config`-Regel (#82) nicht, und **das muss ausdrücklich in `values.yaml` stehen**, sonst sieht es wie eine Auslassung aus.
CI: `publish-bulwark.yml` aus einer bestehenden Hülle **kopiert**; `bulwark` in die `chart-name`-Liste; Fixtures unter `ci/bulwark/` mit einer `JMAP_SERVER_URL`-Attrappe und Dummy-Geheimnissen; `customManagers`-Block in `renovate.json5`.
Wiki im selben Durchgang: neue Seite `bulwark`, Zeile in `Charts` (Zähler prüfen, nicht übernehmen), Eintrag in `_Sidebar.md`, und auf der `stalwart`-Seite ein Querverweis, weil die beiden nur zusammen nützlich sind.
## Am laufenden Dienst zu messen
1. **Antwortet `/api/health` mit einem Code ungleich 200, wenn der Zustand `degraded` oder `unhealthy` ist?** Es gibt nur **diesen einen** Endpunkt, nicht die Dreiteilung live/ready/started. Antwortet er immer mit 200 und nennt den Zustand nur im Rumpf, ist eine Probe darauf eine Probe, die nicht scheitern kann — die Gestalt von #218 und zuletzt von #83. **Vor dem Festlegen der Probes zu messen, nicht danach**, und wenn er nicht scheitern kann, gehört das als Grenze an den Wert geschrieben statt verschwiegen.
2. **Hält `readOnlyRootFilesystem: true`** mit `/app/data` als Volume? Next.js im Standalone-Modus schreibt unter Umständen nach `/tmp` oder in einen Cache unter `.next` — welche emptyDirs nötig sind, ist zu messen.
3. **Gewinnt die vom Assistenten geschriebene `config.json` wirklich über `JMAP_SERVER_URL`?** Davon hängt ab, wie scharf die Warnung formuliert werden muss.
4. **Wird `OAUTH_CLIENT_SECRET_FILE` beim Start oder je Anfrage gelesen?** Entscheidet, ob eine Geheimnis-Drehung einen Neustart braucht — und das gehört ins Wiki, weil es sonst beim ersten Wechsel überrascht.
5. **Bietet Renovate die Vorab-Fassungen an?** Es gibt `1.11.0-beta.1` bis `.3`. Per Dry-Run zu belegen: bei `keycloak` (#83) war die vermutete Beschränkung am Ende unnötig, bei `garage` (#81) nötig. Keine Zeile schreiben, die nichts tut.
## Nicht in diesem Chart
Der Stalwart-Server selbst (#65) und die OIDC-Client-Registrierung in Keycloak (#83) gehören in das abnehmende Bundle in `cluster-config`.
Angehalten: drei sachliche Fehler, einer davon sicherheitsrelevant
Der Entwicklungsagent hat vor dem Festlegen der Probes gemessen, wie gefordert — und dabei im Quelltext am Tag 1.12.0 drei Behauptungen widerlegt, auf denen der freigegebene Plan aufbaut. Eine davon betrifft den einzigen dokumentierten Ausweg aus einer Totalaussperrung. Es ist noch keine Zeile Chart geschrieben, weil alle drei values.yaml, NOTES.txt und den Traps-Abschnitt des Wikis verändern.
Gemessen am entpackten Quell-Tarball bulwarkmail/webmail Tag 1.12.0 (nicht am Zweig main, nicht aus der Dokumentation). Kein Laufzeitlauf — kein Docker in diesem Workspace.
1. Der Ausweg aus der Aussperrung funktioniert so nicht
Der Plan nennt unter Punkt 3 genau eine Tür: „helm upgrade mit OAUTH_ONLY=false bringt das Passwortformular zurück. Das ist die einzige Tür, die nicht an Keycloak hängt."
Diese Tür kann verschlossen sein, und zwar genau dann, wenn man sie braucht.
Die Vorrangregel, die der Plan für die Server-URL benennt, ist nicht auf die Server-URL beschränkt. Sie ist das Verhalten jedes Wertes, den dieser Chart setzt. lib/admin/config-manager.ts:56-88:
/**
* Get a config value. Priority: admin override > env var > default.
*/
get<T>(key: string, defaultValue?: T): T {
const mapping = CONFIG_ENV_MAP[key];
if (key in this.adminConfig) {
return this.adminConfig[key] as T; // config.json gewinnt
}
...
if (mapping) {
const envVal = process.env[mapping.envVar]; // erst danach die Umgebung
this.adminConfig ist config.json aus ADMIN_CONFIG_DIR, also von der PVC (config-manager.ts:35).
oauthOnly ist ein gewöhnlicher Schlüssel dieser Tabelle — lib/admin/types.ts:236:
und jeder Laufzeitkonsument liest ihn über configManager.get, nicht über process.env:
app/api/auth/session/route.ts:83 — serverseitige Sperre des Passwort-Logins
app/api/auth/verify/route.ts:52 — dieselbe Sperre
app/api/config/route.ts:51 — das, was die Login-Seite auswertet
Und geschrieben wird dieser Override von der Admin-Oberfläche, die nicht an Keycloak hängt und die man in einer Aussperrung als erstes aufsucht. app/api/admin/oauth/setup/route.ts:216-221:
Dazu app/api/admin/config/route.ts:70 — die Oberfläche darf jeden Schlüssel aus CONFIG_ENV_MAP als Override schreiben:
const validKeys = Object.keys(CONFIG_ENV_MAP);
Folge: Hat jemand die OAuth-Einrichtung der Admin-Oberfläche benutzt — der naheliegende Weg, und der Plan verbietet ihn nicht —, dann steht oauthOnly: true in config.json auf der PVC. Ab da ist OAUTH_ONLY=false aus dem Chart wirkungslos, ein helm upgrade ändert nichts, und NOTES.txt würde eine Anweisung tragen, die nachweislich nicht greift. Das ist dieselbe Falle, die der Plan korrekt für JMAP_SERVER_URL benennt — nur steht sie hier in der Rettungsanweisung selbst.
Was stattdessen wirklich zurückführt
Beides im Quelltext belegt, beides unabhängig von Keycloak:
Override zurücknehmen statt ihn zu überschreiben: die Admin-Oberfläche hat ihr eigenes Passwort und kennt ein „revert" — configManager.removeAdminOverride (config-manager.ts:148-152), freigelegt vom DELETE-Zweig in app/api/admin/config/route.ts:165. Danach greift die Umgebung wieder.
Die Überschreibung von vorn verhindern:ADMIN_CONFIG_READONLY=true sperrt alle Schreibzugriffe auf das Konfigurationsverzeichnis auf Anwendungsebene (lib/admin/paths.ts:81-92, assertWritable in paths.ts:124-126). Damit entsteht nie ein Override und die Chart-Werte sind dauerhaft maßgeblich.
Das ist nicht das unter Punkt 1 verworfene schreibgeschützte Einhängen. Der Plan hat das :ro-Mount verworfen, weil Policy, Themes, Branding und Plugins im gleichen Verzeichnis liegen — das Argument stimmt für das Mount. ADMIN_CONFIG_READONLY ist ein anderer Mechanismus, kostet aber denselben Preis: assertWritable schützt auch setPolicy (config-manager.ts:182) und markSetupComplete. Themes und Branding wären also ebenfalls eingefroren.
Zu entscheiden (Betriebsentscheidung, nicht Chart-Frage):
(a) Falle nur dokumentieren, Rettungsweg in NOTES.txt auf „Override in der Admin-Oberfläche zurücknehmen, sonst config.json auf der PVC bearbeiten" korrigieren — Chart bleibt wie geplant.
(b) Zusätzlich einen Wert adminConfigReadOnly (Vorgabe false) einführen, der ADMIN_CONFIG_READONLY=true setzt. Macht die Chart-Werte maßgeblich und die Falle unmöglich, friert aber Policy/Themes/Branding ein.
Ich habe (a) nicht einfach umgesetzt, weil (b) die Werte-Oberfläche des Charts verändert und damit nicht meine Entscheidung ist.
2. /api/health kann den Zustand nicht melden — die Messung, die zuerst kommen sollte
Antwort: nein, der Endpunkt scheitert nicht an degraded oder unhealthy.app/api/health/route.ts:
Der Erfolgspfad gibt hart status: 200 zurück (Zeile 93-100), unabhängig vom ermittelten Zustand.
Für einen unangemeldeten Aufrufer ist der Rumpf zusätzlich hart auf healthy gesetzt — Zeile 70: status: detailed ? status : 'healthy'. degraded erscheint nur bei ?detailed=trueund bestandenem canSeeInstanceDetails (Zeile 51). Eine Kubernetes-Probe ist unangemeldet und sieht also nie etwas anderes als healthy.
503 kommt ausschließlich aus dem catch, wenn das Sammeln der Diagnose selbst wirft (Zeile 102-112) — also wenn process.memoryUsage() oder v8.getHeapStatistics() fehlschlägt. Praktisch nie.
Das ist nicht meine Auslegung, upstream testet es so — lib/__tests__/health-route.test.ts:61-62:
Die Formulierung aus der Dokumentation im Issue-Rumpf („returns healthy, degraded, or unhealthy") ist für Probe-Zwecke also irreführend: der Zustand steht im Rumpf, nie im Code, und für die Probe nicht einmal im Rumpf.
Was eine Probe darauf trägt — und das ist mehr als bei #218: Sie prüft echt, dass der Node-Prozess lebt, der HTTP-Server annimmt, die Ereignisschleife nicht blockiert ist und Next.js routen kann. Ein OOM-Kill, ein Absturz, eine festsitzende Ereignisschleife fallen auf. Was sie nicht trägt: den gemeldeten Anwendungszustand und jede Aussage über Stalwart. Ist der Mailserver weg, ist /api/health grün — Bulwark hält keine Mail und fragt den JMAP-Server für diesen Endpunkt nicht.
Das gehört als Grenze an den Wert geschrieben, nicht verschwiegen. Mein Vorschlag, wenn du nichts anderes sagst: alle drei Probes auf /api/health — Startup und Liveness sind damit sachlich korrekt bedient (Prozess lebt / ist hochgekommen), die Readiness trägt nur „nimmt HTTP an" und bekommt den Kommentar, dass sie die JMAP-Erreichbarkeit nicht abdeckt und auch nicht abdecken kann. HEAD /api/health (Zeile 118-124) ist dafür die billigste Form.
Eine echte Readiness gegen Stalwart wäre /api/setup/test-jmap, aber das ist eine Wizard-Route hinter dem Setup-Token und für eine Probe ungeeignet. Ich sehe keinen geeigneten Endpunkt.
3. Der Push-Relay ist umstellbar — die geplante Offenlegung wäre falsch
Der Rumpf sagt, Web-Push laufe über den Relay des Anbieters und das sei „mit dem veröffentlichten Abbild nicht umstellbar", und der Plan macht daraus eine Pflicht-Offenlegung in values.yaml und Wiki.
Das trifft nicht zu.lib/push-relays.ts:9-10 zeigt, dass die Bau-Variable nur den eingebauten Vorgabe-Eintrag verschiebt:
Der Anbieter-Relay ist also nur der Rückfall. Setzt ein Betreiber pushRelayUrl, ist das die Vorgabe für alle; mit pushRelayUrlLocked: true (push-relays.ts:74, lib/admin/types.ts:155-156) sind die Nutzer darauf festgenagelt und können nichts anderes wählen.
Die korrekte Aussage ist damit zweiteilig, und sie ist deutlich günstiger als die geplante: der Anbieter-Relay ist die Vorgabe und bleibt ohne eigenes Abbild in der Auswahlliste stehen, aber ein eigener Relay kann zur wirksamen Vorgabe gemacht und verbindlich gesetzt werden — über policy.json, also die Admin-Oberfläche auf der PVC, nicht über einen Chart-Wert. Eine Offenlegung „nicht umstellbar" würde Betreibern eine Wahl absprechen, die sie haben.
Dass es kein Chart-Wert ist, liegt daran, dass policy.json keine Umgebungsvariablen kennt — nachgesehen: pushRelayUrl steht in SettingsPolicy (types.ts:154), nicht in CONFIG_ENV_MAP.
4. ADMIN_PASSWORD: die Begründung im Plan stimmt nicht, und der Wert ist einmalig
Zwei getrennte Punkte.
Kein Zufallspasswort nach stdout. Rumpf und Plan begründen ADMIN_PASSWORD damit, dass ohne Angabe „ein Zufallspasswort erzeugt und nach stdout geschrieben" werde — „dieselbe Gestalt wie STALWART_RECOVERY_ADMIN bei #65". Gemessen: ohne ADMIN_PASSWORD wird die Admin-Oberfläche einfach abgeschaltet. lib/admin/password.ts:142-147:
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
return false;
}
Ich habe lib/admin und lib/setup nach randomBytes, Passwortgenerierung und console.log durchsucht: der einzige erzeugte Zufallswert ist das Bootstrap-Setup-Token (lib/setup/token.ts:54), und das wird in das Zustandsverzeichnis geschrieben und nie protokolliert. Es gibt also kein Leck ins Pod-Protokoll — die Gestalt von #65 liegt hier nicht vor.
Die Entscheidung, ADMIN_PASSWORD zu setzen, bleibt davon unberührt richtig (sonst keine Admin-Oberfläche — und nach Punkt 1 ist sie der Rettungsweg). Nur die Begründung gehört korrigiert, damit sie nicht als Präzedenz weiterwandert. Erwähnenswert ist die Gegenrichtung: ohneADMIN_PASSWORD gibt es auch keine Oberfläche, die Overrides schreiben und damit die Falle aus Punkt 1 auslösen kann.
Der Wert wird genau einmal verbraucht.initAdminPassword kehrt zurück, sobald admin.json existiert, und sieht die Umgebungsvariable dann nie wieder an (password.ts:125-140); beim ersten Start wird sie gehasht und persistiert (password.ts:149-153). Eine Drehung des Feldes im Tresor hat danach keine Wirkung — zurückgesetzt wird nur über die Oberfläche selbst oder durch Löschen von admin.json auf der PVC. Das gehört in values.yaml, NOTES.txt und ins Wiki, zusammen mit der Falle aus Punkt 1, weil es dieselbe Ursache hat: der Zustand auf dem Volume schlägt den Chart.
Was unabhängig davon schon gemessen ist
Die Falle aus #81 greift hier nicht. Bulwark prüft die Dateirechte nicht. lib/read-file-env.ts ist vollständig:
Kein statSync, kein mode & 0o077. Ein gemountetes Kubernetes-Secret mit Gruppen-Lesebit ist lesbar, fsGroup ist unschädlich. Die Dateiform ist tragfähig — der Ausweg auf eine Umgebungsvariable wie bei garage ist hier nicht nötig.
Drehung des OAuth-Geheimnisses braucht keinen Neustart — mit einer Einschränkung, die der Chart bestimmt. getGlobalClientSecret (lib/oauth/token-exchange.ts:55-65) ruft readFileEnv bei jedem Token-Tausch auf, ohne Zwischenspeicher. Die Datei wird also je Anfrage gelesen. Damit das trägt, muss das Secret ohne subPath eingehängt werden — ein subPath-Mount wird vom Kubelet nie aktualisiert, und dann bräuchte es doch einen Neustart. Das nehme ich so auf.
SESSION_SECRET ist zu Recht verpflichtend.app/api/config/route.ts:68 schaltet die Synchronisierung still ab, wenn das Geheimnis fehlt: settingsSyncEnabled: configManager.get(...) && hasSessionSecret(). Ohne das Geheimnis wäre SETTINGS_SYNC_ENABLED=true wirkungslos — lautlos. Der Plan liegt hier richtig.
Nebenbefund für die Sicherung, der sie verschärft:app/api/admin/oauth/setup/route.ts:218 schreibt oauthClientSecretim Klartext in config.json auf die PVC. Der Satz „eine Sicherung des Volumes allein ist keine Sicherung" bekommt damit eine Kehrseite, die genauso wichtig ist: eine Sicherung des Volumes kann das OAuth-Client-Geheimnis im Klartext enthalten. Das gehört in den Traps-Abschnitt.
Es gibt auch SESSION_SECRET_FILE (lib/admin/types.ts:277, lib/auth/session-secret.ts:22). Der Plan nennt nur die Dateiform für das OAuth-Geheimnis; SESSION_SECRET könnte genauso aus einer Datei kommen und bliebe dann aus der Prozessumgebung heraus. Da das Secret für OAuth ohnehin eingehängt wird, ist es fast kostenlos. ADMIN_PASSWORD hat keine Dateiform. Ich setze das nicht eigenmächtig um — sag, ob du es willst.
Bestätigt, unverändert gültig: uid/gid 1001 (nextjs/nodejs, Dockerfile:42-43 via addgroup --gid 1001 / adduser --uid 1001), Port 3000, CMD ["node", "server.js"], die vier Verzeichnisse werden im Abbild angelegt und gechownt (Dockerfile:55), apk upgrade und npm uninstall -g npm im Laufzeit-Abbild. JMAP_SERVER_URL schaltet den Assistenten tatsächlich ab: detectSetupState gibt env-managed zurück (lib/setup/state.ts:24-28), und setupComplete wird nur von der Wizard-Abschlussroute gesetzt — der Assistent läuft also nie. Plan-Punkt 1 trägt insoweit.
Zu readOnlyRootFilesystem (noch nicht abschließend):next.config.ts:51 baut output: "standalone", und images.unoptimized gilt nur im isLite-Zweig (next.config.ts:54) — die Bildoptimierung ist im normalen Abbild also aktiv und schreibt ihren Zwischenspeicher nach /app/.next/cache. Ich erwarte daher emptyDirs für /tmp und /app/.next/cache. Das ist eine Ableitung aus dem Quelltext, keine Messung — den genauen Satz belegt erst der Install-Test in der CI, und ich werde ihn dort belegen statt behaupten.
Renovate-Dry-Run: noch offen. Im Workspace ist keine Renovate-CLI vorhanden (renovate, renovate-config-validator beide nicht gefunden). Die Vorab-Fassungen gibt es: Tags 1.11.0-beta.1 bis .3 neben 1.11.0…1.12.0, und die Tags tragen keinv-Präfix. Ich hole den Dry-Run über npx renovate nach, sobald die Punkte oben entschieden sind, und schreibe das Ergebnis als Begründung an den Block — keine Zeile, die nichts tut.
Stand
Kein Branch, kein Commit, keine PR. Nichts im Repository angefasst — nur gelesen. Es wartet auf deine Entscheidung zu:
Punkt 1 — Variante (a) nur dokumentieren, oder (b) zusätzlich ein Wert adminConfigReadOnly. Das ist die eine Entscheidung, die den Chart selbst ändert.
Punkt 2 — meinen Probe-Vorschlag (alle drei auf /api/health, Readiness mit benannter Grenze) bestätigen oder ersetzen.
Punkt 3 — die korrigierte, zweiteilige Push-Relay-Aussage statt „nicht umstellbar".
Punkt 4 — Korrektur der ADMIN_PASSWORD-Begründung und Aufnahme der Einmaligkeit; ob die #65-Analogie aus dem Rumpf gestrichen wird.
SESSION_SECRET_FILE statt Umgebungsvariable — ja oder nein.
# Angehalten: drei sachliche Fehler, einer davon sicherheitsrelevant
Der Entwicklungsagent hat **vor** dem Festlegen der Probes gemessen, wie gefordert — und dabei im Quelltext am Tag `1.12.0` drei Behauptungen widerlegt, auf denen der freigegebene Plan aufbaut. Eine davon betrifft den einzigen dokumentierten Ausweg aus einer Totalaussperrung. Es ist noch keine Zeile Chart geschrieben, weil alle drei `values.yaml`, `NOTES.txt` und den Traps-Abschnitt des Wikis verändern.
Gemessen am entpackten Quell-Tarball `bulwarkmail/webmail` Tag `1.12.0` (nicht am Zweig `main`, nicht aus der Dokumentation). Kein Laufzeitlauf — kein Docker in diesem Workspace.
---
## 1. Der Ausweg aus der Aussperrung funktioniert so nicht
Der Plan nennt unter Punkt 3 genau eine Tür: „`helm upgrade` mit `OAUTH_ONLY=false` bringt das Passwortformular zurück. Das ist die einzige Tür, die nicht an Keycloak hängt."
**Diese Tür kann verschlossen sein, und zwar genau dann, wenn man sie braucht.**
Die Vorrangregel, die der Plan für die Server-URL benennt, ist nicht auf die Server-URL beschränkt. Sie ist das Verhalten **jedes** Wertes, den dieser Chart setzt. `lib/admin/config-manager.ts:56-88`:
```
/**
* Get a config value. Priority: admin override > env var > default.
*/
get<T>(key: string, defaultValue?: T): T {
const mapping = CONFIG_ENV_MAP[key];
if (key in this.adminConfig) {
return this.adminConfig[key] as T; // config.json gewinnt
}
...
if (mapping) {
const envVal = process.env[mapping.envVar]; // erst danach die Umgebung
```
`this.adminConfig` ist `config.json` aus `ADMIN_CONFIG_DIR`, also von der PVC (`config-manager.ts:35`).
`oauthOnly` ist ein gewöhnlicher Schlüssel dieser Tabelle — `lib/admin/types.ts:236`:
```
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
```
und **jeder** Laufzeitkonsument liest ihn über `configManager.get`, nicht über `process.env`:
- `app/api/auth/session/route.ts:83` — serverseitige Sperre des Passwort-Logins
- `app/api/auth/verify/route.ts:52` — dieselbe Sperre
- `app/api/config/route.ts:51` — das, was die Login-Seite auswertet
Und geschrieben wird dieser Override von der Admin-Oberfläche, die **nicht** an Keycloak hängt und die man in einer Aussperrung als erstes aufsucht. `app/api/admin/oauth/setup/route.ts:216-221`:
```
oauthEnabled: true,
oauthClientId: CLIENT_ID,
oauthClientSecret: secret,
oauthIssuerUrl: issuerUrl,
...
if (oauthOnly) updates.oauthOnly = true;
```
Dazu `app/api/admin/config/route.ts:70` — die Oberfläche darf **jeden** Schlüssel aus `CONFIG_ENV_MAP` als Override schreiben:
```
const validKeys = Object.keys(CONFIG_ENV_MAP);
```
**Folge:** Hat jemand die OAuth-Einrichtung der Admin-Oberfläche benutzt — der naheliegende Weg, und der Plan verbietet ihn nicht —, dann steht `oauthOnly: true` in `config.json` auf der PVC. Ab da ist `OAUTH_ONLY=false` aus dem Chart wirkungslos, ein `helm upgrade` ändert nichts, und `NOTES.txt` würde eine Anweisung tragen, die nachweislich nicht greift. Das ist dieselbe Falle, die der Plan korrekt für `JMAP_SERVER_URL` benennt — nur steht sie hier in der Rettungsanweisung selbst.
### Was stattdessen wirklich zurückführt
Beides im Quelltext belegt, beides unabhängig von Keycloak:
1. **Override zurücknehmen** statt ihn zu überschreiben: die Admin-Oberfläche hat ihr eigenes Passwort und kennt ein „revert" — `configManager.removeAdminOverride` (`config-manager.ts:148-152`), freigelegt vom `DELETE`-Zweig in `app/api/admin/config/route.ts:165`. Danach greift die Umgebung wieder.
2. **Die Überschreibung von vorn verhindern:** `ADMIN_CONFIG_READONLY=true` sperrt alle Schreibzugriffe auf das Konfigurationsverzeichnis auf **Anwendungsebene** (`lib/admin/paths.ts:81-92`, `assertWritable` in `paths.ts:124-126`). Damit entsteht nie ein Override und die Chart-Werte sind dauerhaft maßgeblich.
Das ist **nicht** das unter Punkt 1 verworfene schreibgeschützte Einhängen. Der Plan hat das `:ro`-Mount verworfen, weil Policy, Themes, Branding und Plugins im gleichen Verzeichnis liegen — das Argument stimmt für das Mount. `ADMIN_CONFIG_READONLY` ist ein anderer Mechanismus, kostet aber denselben Preis: `assertWritable` schützt auch `setPolicy` (`config-manager.ts:182`) und `markSetupComplete`. Themes und Branding wären also ebenfalls eingefroren.
**Zu entscheiden (Betriebsentscheidung, nicht Chart-Frage):**
- (a) Falle nur dokumentieren, Rettungsweg in `NOTES.txt` auf „Override in der Admin-Oberfläche zurücknehmen, sonst `config.json` auf der PVC bearbeiten" korrigieren — Chart bleibt wie geplant.
- (b) Zusätzlich einen Wert `adminConfigReadOnly` (Vorgabe `false`) einführen, der `ADMIN_CONFIG_READONLY=true` setzt. Macht die Chart-Werte maßgeblich und die Falle unmöglich, friert aber Policy/Themes/Branding ein.
Ich habe (a) nicht einfach umgesetzt, weil (b) die Werte-Oberfläche des Charts verändert und damit nicht meine Entscheidung ist.
---
## 2. `/api/health` kann den Zustand nicht melden — die Messung, die zuerst kommen sollte
**Antwort: nein, der Endpunkt scheitert nicht an `degraded` oder `unhealthy`.** `app/api/health/route.ts`:
- Der Erfolgspfad gibt **hart `status: 200`** zurück (Zeile 93-100), unabhängig vom ermittelten Zustand.
- Für einen **unangemeldeten** Aufrufer ist der Rumpf zusätzlich hart auf `healthy` gesetzt — Zeile 70: `status: detailed ? status : 'healthy'`. `degraded` erscheint nur bei `?detailed=true` **und** bestandenem `canSeeInstanceDetails` (Zeile 51). Eine Kubernetes-Probe ist unangemeldet und sieht also nie etwas anderes als `healthy`.
- `503` kommt ausschließlich aus dem `catch`, wenn das Sammeln der Diagnose selbst wirft (Zeile 102-112) — also wenn `process.memoryUsage()` oder `v8.getHeapStatistics()` fehlschlägt. Praktisch nie.
Das ist nicht meine Auslegung, upstream testet es so — `lib/__tests__/health-route.test.ts:61-62`:
```
expect(response.status).toBe(200);
expect(payload.status).toBe('degraded');
```
Die Formulierung aus der Dokumentation im Issue-Rumpf („returns healthy, degraded, or unhealthy") ist für Probe-Zwecke also irreführend: der Zustand steht im Rumpf, nie im Code, und für die Probe nicht einmal im Rumpf.
**Was eine Probe darauf trägt** — und das ist mehr als bei #218: Sie prüft echt, dass der Node-Prozess lebt, der HTTP-Server annimmt, die Ereignisschleife nicht blockiert ist und Next.js routen kann. Ein OOM-Kill, ein Absturz, eine festsitzende Ereignisschleife fallen auf. Was sie **nicht** trägt: den gemeldeten Anwendungszustand und **jede** Aussage über Stalwart. Ist der Mailserver weg, ist `/api/health` grün — Bulwark hält keine Mail und fragt den JMAP-Server für diesen Endpunkt nicht.
Das gehört als Grenze an den Wert geschrieben, nicht verschwiegen. Mein Vorschlag, wenn du nichts anderes sagst: alle drei Probes auf `/api/health` — Startup und Liveness sind damit sachlich korrekt bedient (Prozess lebt / ist hochgekommen), die Readiness trägt nur „nimmt HTTP an" und bekommt den Kommentar, dass sie die JMAP-Erreichbarkeit **nicht** abdeckt und auch nicht abdecken kann. `HEAD /api/health` (Zeile 118-124) ist dafür die billigste Form.
Eine echte Readiness gegen Stalwart wäre `/api/setup/test-jmap`, aber das ist eine Wizard-Route hinter dem Setup-Token und für eine Probe ungeeignet. Ich sehe keinen geeigneten Endpunkt.
---
## 3. Der Push-Relay ist umstellbar — die geplante Offenlegung wäre falsch
Der Rumpf sagt, Web-Push laufe über den Relay des Anbieters und das sei „mit dem veröffentlichten Abbild nicht umstellbar", und der Plan macht daraus eine Pflicht-Offenlegung in `values.yaml` und Wiki.
**Das trifft nicht zu.** `lib/push-relays.ts:9-10` zeigt, dass die Bau-Variable nur den **eingebauten Vorgabe-Eintrag** verschiebt:
```
export const DEFAULT_RELAY_BASE_URL =
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org';
```
Der **wirksame** Relay kommt aber aus `policy.json` — `push-relays.ts:60-63`:
```
export function resolveDefaultRelayUrl(policy) {
const configured = normalizeRelayUrl(policy?.pushRelayUrl);
return isValidRelayUrl(configured) ? configured : normalizeRelayUrl(DEFAULT_RELAY_BASE_URL);
}
```
Der Anbieter-Relay ist also nur der **Rückfall**. Setzt ein Betreiber `pushRelayUrl`, ist das die Vorgabe für alle; mit `pushRelayUrlLocked: true` (`push-relays.ts:74`, `lib/admin/types.ts:155-156`) sind die Nutzer darauf festgenagelt und können nichts anderes wählen.
Die korrekte Aussage ist damit zweiteilig, und sie ist deutlich günstiger als die geplante: der Anbieter-Relay ist die **Vorgabe** und bleibt ohne eigenes Abbild in der Auswahlliste stehen, aber ein eigener Relay kann zur wirksamen Vorgabe gemacht und verbindlich gesetzt werden — über `policy.json`, also die Admin-Oberfläche auf der PVC, **nicht** über einen Chart-Wert. Eine Offenlegung „nicht umstellbar" würde Betreibern eine Wahl absprechen, die sie haben.
Dass es kein Chart-Wert ist, liegt daran, dass `policy.json` keine Umgebungsvariablen kennt — nachgesehen: `pushRelayUrl` steht in `SettingsPolicy` (`types.ts:154`), nicht in `CONFIG_ENV_MAP`.
---
## 4. `ADMIN_PASSWORD`: die Begründung im Plan stimmt nicht, und der Wert ist einmalig
Zwei getrennte Punkte.
**Kein Zufallspasswort nach stdout.** Rumpf und Plan begründen `ADMIN_PASSWORD` damit, dass ohne Angabe „ein Zufallspasswort erzeugt und nach stdout geschrieben" werde — „dieselbe Gestalt wie `STALWART_RECOVERY_ADMIN` bei #65". Gemessen: ohne `ADMIN_PASSWORD` wird die Admin-Oberfläche einfach **abgeschaltet**. `lib/admin/password.ts:142-147`:
```
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
return false;
}
```
Ich habe `lib/admin` und `lib/setup` nach `randomBytes`, Passwortgenerierung und `console.log` durchsucht: der einzige erzeugte Zufallswert ist das Bootstrap-Setup-Token (`lib/setup/token.ts:54`), und das wird in das Zustandsverzeichnis geschrieben und **nie** protokolliert. Es gibt also kein Leck ins Pod-Protokoll — die Gestalt von #65 liegt hier nicht vor.
Die Entscheidung, `ADMIN_PASSWORD` zu setzen, bleibt davon unberührt richtig (sonst keine Admin-Oberfläche — und nach Punkt 1 ist sie der Rettungsweg). Nur die Begründung gehört korrigiert, damit sie nicht als Präzedenz weiterwandert. Erwähnenswert ist die Gegenrichtung: **ohne** `ADMIN_PASSWORD` gibt es auch keine Oberfläche, die Overrides schreiben und damit die Falle aus Punkt 1 auslösen kann.
**Der Wert wird genau einmal verbraucht.** `initAdminPassword` kehrt zurück, sobald `admin.json` existiert, und sieht die Umgebungsvariable dann nie wieder an (`password.ts:125-140`); beim ersten Start wird sie gehasht und persistiert (`password.ts:149-153`). **Eine Drehung des Feldes im Tresor hat danach keine Wirkung** — zurückgesetzt wird nur über die Oberfläche selbst oder durch Löschen von `admin.json` auf der PVC. Das gehört in `values.yaml`, `NOTES.txt` und ins Wiki, zusammen mit der Falle aus Punkt 1, weil es dieselbe Ursache hat: der Zustand auf dem Volume schlägt den Chart.
---
## Was unabhängig davon schon gemessen ist
**Die Falle aus #81 greift hier nicht.** Bulwark prüft die **Dateirechte nicht**. `lib/read-file-env.ts` ist vollständig:
```
export function readFileEnv(path: string | undefined): string | null {
if (!path) return null;
try {
return readFileSync(path, "utf-8").trim();
} catch {
return null;
}
}
```
Kein `statSync`, kein `mode & 0o077`. Ein gemountetes Kubernetes-Secret mit Gruppen-Lesebit ist lesbar, `fsGroup` ist unschädlich. **Die Dateiform ist tragfähig** — der Ausweg auf eine Umgebungsvariable wie bei `garage` ist hier nicht nötig.
**Drehung des OAuth-Geheimnisses braucht keinen Neustart** — mit einer Einschränkung, die der Chart bestimmt. `getGlobalClientSecret` (`lib/oauth/token-exchange.ts:55-65`) ruft `readFileEnv` bei **jedem** Token-Tausch auf, ohne Zwischenspeicher. Die Datei wird also je Anfrage gelesen. Damit das trägt, muss das Secret **ohne `subPath`** eingehängt werden — ein `subPath`-Mount wird vom Kubelet nie aktualisiert, und dann bräuchte es doch einen Neustart. Das nehme ich so auf.
**`SESSION_SECRET` ist zu Recht verpflichtend.** `app/api/config/route.ts:68` schaltet die Synchronisierung still ab, wenn das Geheimnis fehlt: `settingsSyncEnabled: configManager.get(...) && hasSessionSecret()`. Ohne das Geheimnis wäre `SETTINGS_SYNC_ENABLED=true` wirkungslos — lautlos. Der Plan liegt hier richtig.
**Nebenbefund für die Sicherung, der sie verschärft:** `app/api/admin/oauth/setup/route.ts:218` schreibt `oauthClientSecret` **im Klartext** in `config.json` auf die PVC. Der Satz „eine Sicherung des Volumes allein ist keine Sicherung" bekommt damit eine Kehrseite, die genauso wichtig ist: eine Sicherung des Volumes kann das OAuth-Client-Geheimnis im Klartext enthalten. Das gehört in den Traps-Abschnitt.
**Es gibt auch `SESSION_SECRET_FILE`** (`lib/admin/types.ts:277`, `lib/auth/session-secret.ts:22`). Der Plan nennt nur die Dateiform für das OAuth-Geheimnis; `SESSION_SECRET` könnte genauso aus einer Datei kommen und bliebe dann aus der Prozessumgebung heraus. Da das Secret für OAuth ohnehin eingehängt wird, ist es fast kostenlos. `ADMIN_PASSWORD` hat **keine** Dateiform. Ich setze das nicht eigenmächtig um — sag, ob du es willst.
**Bestätigt, unverändert gültig:** uid/gid **1001** (`nextjs`/`nodejs`, `Dockerfile:42-43` via `addgroup --gid 1001` / `adduser --uid 1001`), Port **3000**, `CMD ["node", "server.js"]`, die vier Verzeichnisse werden im Abbild angelegt und gechownt (`Dockerfile:55`), `apk upgrade` und `npm uninstall -g npm` im Laufzeit-Abbild. `JMAP_SERVER_URL` schaltet den Assistenten tatsächlich ab: `detectSetupState` gibt `env-managed` zurück (`lib/setup/state.ts:24-28`), und `setupComplete` wird nur von der Wizard-Abschlussroute gesetzt — der Assistent läuft also nie. Plan-Punkt 1 trägt insoweit.
**Zu `readOnlyRootFilesystem` (noch nicht abschließend):** `next.config.ts:51` baut `output: "standalone"`, und `images.unoptimized` gilt nur im `isLite`-Zweig (`next.config.ts:54`) — die Bildoptimierung ist im normalen Abbild also **aktiv** und schreibt ihren Zwischenspeicher nach `/app/.next/cache`. Ich erwarte daher emptyDirs für `/tmp` und `/app/.next/cache`. Das ist eine Ableitung aus dem Quelltext, **keine Messung** — den genauen Satz belegt erst der Install-Test in der CI, und ich werde ihn dort belegen statt behaupten.
**Renovate-Dry-Run: noch offen.** Im Workspace ist keine Renovate-CLI vorhanden (`renovate`, `renovate-config-validator` beide nicht gefunden). Die Vorab-Fassungen gibt es: Tags `1.11.0-beta.1` bis `.3` neben `1.11.0`…`1.12.0`, und die Tags tragen **kein** `v`-Präfix. Ich hole den Dry-Run über `npx renovate` nach, sobald die Punkte oben entschieden sind, und schreibe das Ergebnis als Begründung an den Block — keine Zeile, die nichts tut.
---
## Stand
Kein Branch, kein Commit, keine PR. Nichts im Repository angefasst — nur gelesen. Es wartet auf deine Entscheidung zu:
1. **Punkt 1** — Variante (a) nur dokumentieren, oder (b) zusätzlich ein Wert `adminConfigReadOnly`. Das ist die eine Entscheidung, die den Chart selbst ändert.
2. **Punkt 2** — meinen Probe-Vorschlag (alle drei auf `/api/health`, Readiness mit benannter Grenze) bestätigen oder ersetzen.
3. **Punkt 3** — die korrigierte, zweiteilige Push-Relay-Aussage statt „nicht umstellbar".
4. **Punkt 4** — Korrektur der `ADMIN_PASSWORD`-Begründung und Aufnahme der Einmaligkeit; ob die #65-Analogie aus dem Rumpf gestrichen wird.
5. **`SESSION_SECRET_FILE`** statt Umgebungsvariable — ja oder nein.
Korrekturen am freigegebenen Plan — drei Fehler, einer davon in der Rettungsanweisung
Alle drei Befunde sind gegen den Quelltext am Tag 1.12.0 nachgeprüft und bestätigt. Der Plan wird an diesen Stellen korrigiert; die Entscheidungen des Betreuers (deklarativ, Synchronisierung an, nur OIDC) bleiben gültig.
Fehler 1 — der dokumentierte Ausweg aus der Aussperrung funktioniert nicht
Der Plan nannte genau eine Tür: helm upgrade mit OAUTH_ONLY=false. Die ist verschlossen, genau wenn man sie braucht.
lib/admin/config-manager.ts, wörtlich:
/** Get a config value. Priority: admin override > env var > default. */get<T>(key: string,defaultValue?: T):T{// Admin override (highest priority)
constmapping=CONFIG_ENV_MAP[key];if(keyinthis.adminConfig){returnthis.adminConfig[key]asT;}
Und oauthOnly ist so ein Schlüssel — lib/admin/types.ts:236:
Der Override liegt in config.json auf der PVC und wird von der Admin-Oberfläche geschrieben, die nicht an Keycloak hängt und die man in einer Aussperrung zuerst aufsucht. Wer dort die OAuth-Einrichtung benutzt hat, hat oauthOnly: true auf dem Volume — ab da ist der Chart-Wert wirkungslos. Die Falle, die der Plan korrekt für JMAP_SERVER_URL benannte, steckte in der Rettung selbst.
Der Ausweg, der wirklich zurückführt, und er hängt nicht an Keycloak: in der Admin-Oberfläche (eigenes Passwort) den Override entfernen — removeAdminOverride, DELETE auf app/api/admin/config/route.ts. Danach greift der Wert aus dem Chart. Das ist der Satz, der in NOTES.txt und ins Wiki gehört, und er ist der Grund, warum die Admin-Oberfläche erreichbar sein muss.
Zusätzlich kommt ein Wert adminConfigReadOnly (Vorgabe false), der ADMIN_CONFIG_READONLY=true setzt. Damit kann die Oberfläche überhaupt keinen Override schreiben, der Chart bleibt autoritativ, und OAUTH_ONLY=false wirkt immer. Der Preis gehört an den Wert, nicht in eine Fußnote: es friert auch setPolicy ein, also Policy, Themes, Branding — und nach Fehler 3 damit auch die einzige Möglichkeit, Web-Push auf einen eigenen Relay umzustellen. Darum Vorgabe false: wer den strengeren Modus will, wählt ihn bewusst.
Das Risiko der Nur-OIDC-Entscheidung ist damit wieder begrenzt, nur anders als ich geschrieben hatte: die Tür ist die Admin-Oberfläche, nicht helm upgrade.
Fehler 2 — /api/health kann nicht scheitern, und das war die verlangte Messung
Eine Probe ist unangemeldet, bekommt also detailed = false, und damit sowohl HTTP 200 als auch den Rumpf healthy — unabhängig vom tatsächlichen Zustand. Upstream prüft das selbst so: lib/__tests__/health-route.test.ts erwartet status 200 bei payload.status === 'degraded'. 503 kommt nur aus dem catch, wenn process.memoryUsage() selbst wirft.
Es ist aber nicht die Gestalt von #218 und #83, und diese Unterscheidung gehört an den Wert statt in eine pauschale Warnung: die Probe trägt echt, dass der Prozess lebt, HTTP annimmt, die Ereignisschleife frei ist und Next.js routet — ein OOM-Kill, ein Absturz, eine festsitzende Schleife fallen damit auf. Sie trägt nicht den gemeldeten Zustand und keine Aussage über Stalwart: ist der Mailserver weg, ist /api/health grün.
Festgelegt: alle drei Probes auf /api/health, als HEAD (die billigste Form, die der Endpunkt anbietet), und die Grenze wörtlich an den Readiness-Wert geschrieben. Einen besseren Endpunkt gibt es nicht — /api/setup/test-jmap liegt hinter dem Setup-Token.
Fehler 3 — der Push-Relay ist umstellbar, die geplante Offenlegung war falsch
lib/push-relays.ts: die Bau-Variable verschiebt nur den eingebauten Vorgabe-Eintrag. Der wirksame Relay kommt aus policy.json — resolveDefaultRelayUrl nimmt policy.pushRelayUrl, wenn gültig, und der Anbieter-Relay ist der Rückfall; mit pushRelayUrlLocked: true sind die Nutzer darauf festgenagelt.
Korrekt ist zweiteilig: der Anbieter-Relay ist die Vorgabe und bleibt ohne eigenes Abbild in der Auswahlliste — aber ein eigener Relay kann wirksame Vorgabe und verbindlich werden, über die Admin-Oberfläche, nicht über einen Chart-Wert (pushRelayUrl steht in SettingsPolicy, nicht in CONFIG_ENV_MAP). „Nicht umstellbar" hätte Betreibern eine Wahl abgesprochen, die sie haben.
Fehler 4 — ADMIN_PASSWORD: Begründung falsch, Wert ist einmalig
Ohne ADMIN_PASSWORD wird kein Zufallspasswort erzeugt und nichts nach stdout geschrieben — die Admin-Oberfläche wird schlicht abgeschaltet (lib/admin/password.ts). Der einzige Zufallswert im Projekt ist das Bootstrap-Setup-Token, das ins Zustandsverzeichnis geschrieben und nie protokolliert wird. Die Analogie zu STALWART_RECOVERY_ADMIN (#65) trägt hier nicht und wird aus dem Plan gestrichen.
Die Entscheidung, es zu setzen, bleibt — mit einer besseren Begründung: nach Fehler 1 ist die Admin-Oberfläche der Rettungsweg. Sie abzuschalten heißt, sich die Tür zu nehmen.
Und der Wert ist einmalig:initAdminPassword kehrt zurück, sobald admin.json existiert, und sieht die Variable nie wieder an. Eine Drehung des Tresorfeldes wirkt danach nicht; ein Reset läuft über die Oberfläche oder über das Löschen von admin.json. Gleiche Ursache wie Fehler 1: Volumenzustand schlägt Chart. Gehört ins Wiki.
Was übernommen wird
SESSION_SECRET_FILE existiert und wird genommen — ein Secret wird ohnehin eingehängt, die Dateiform kostet also nichts und hält das Geheimnis aus der Prozessumgebung. ADMIN_PASSWORD hat keine Dateiform und bleibt eine Umgebungsvariable.
#81 greift hier nicht:lib/read-file-env.ts ist readFileSync plus try/catch, ohne statSync und ohne mode & 0o077. Das Gruppen-Lesebit aus fsGroup ist unschädlich, die Dateiform ist tragfähig.
Das OAuth-Geheimnis wird bei jedem Token-Tausch neu gelesen (lib/oauth/token-exchange.ts), eine Drehung wirkt also ohne Neustart — aber nur, wenn das Secret ohne subPath eingehängt wird, weil das Kubelet subPath-Mounts nie aktualisiert.
Die Sicherungsaussage wird schärfer, nicht nur zweiteilig:app/api/admin/oauth/setup/route.ts schreibt oauthClientSecretim Klartext in config.json auf die PVC. Eine Volumensicherung kann also das OAuth-Client-Geheimnis im Klartext enthalten. Das gehört neben den Satz, dass eine Sicherung ohne SESSION_SECRET keine Sicherung ist.
SESSION_SECRET ist zu Recht verpflichtend:app/api/config/route.ts schaltet die Einstellungs-Synchronisierung lautlos ab, wenn es fehlt.
Offen für den nächsten Durchgang
readOnlyRootFilesystem ist abgeleitet, nicht gemessen: next.config.ts baut output: "standalone" und images.unoptimized gilt nur im Lite-Zweig, die Bildoptimierung ist also aktiv und schreibt nach /app/.next/cache. Erwartet werden emptyDirs für /tmp und diesen Pfad; den genauen Satz belegt der Install-Test.
Renovate-Dry-Run steht aus — im Workspace ist keine Renovate-CLI installiert, nachzuholen über npx renovate. Belegt ist bisher nur: Vorab-Fassungen existieren (1.11.0-beta.1 bis .3) und die Tags tragen keinv-Präfix.
# Korrekturen am freigegebenen Plan — drei Fehler, einer davon in der Rettungsanweisung
Alle drei Befunde sind gegen den Quelltext am Tag **1.12.0** nachgeprüft und bestätigt. Der Plan wird an diesen Stellen korrigiert; die Entscheidungen des Betreuers (deklarativ, Synchronisierung an, nur OIDC) bleiben gültig.
## Fehler 1 — der dokumentierte Ausweg aus der Aussperrung funktioniert nicht
Der Plan nannte genau eine Tür: `helm upgrade` mit `OAUTH_ONLY=false`. Die ist verschlossen, genau wenn man sie braucht.
`lib/admin/config-manager.ts`, wörtlich:
```ts
/** Get a config value. Priority: admin override > env var > default. */
get<T>(key: string, defaultValue?: T): T {
// Admin override (highest priority)
const mapping = CONFIG_ENV_MAP[key];
if (key in this.adminConfig) {
return this.adminConfig[key] as T;
}
```
Und `oauthOnly` ist so ein Schlüssel — `lib/admin/types.ts:236`:
```ts
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
```
Der Override liegt in `config.json` auf der PVC und wird von der **Admin-Oberfläche** geschrieben, die nicht an Keycloak hängt und die man in einer Aussperrung zuerst aufsucht. Wer dort die OAuth-Einrichtung benutzt hat, hat `oauthOnly: true` auf dem Volume — ab da ist der Chart-Wert wirkungslos. Die Falle, die der Plan korrekt für `JMAP_SERVER_URL` benannte, steckte in der Rettung selbst.
**Der Ausweg, der wirklich zurückführt**, und er hängt nicht an Keycloak: in der **Admin-Oberfläche** (eigenes Passwort) den Override **entfernen** — `removeAdminOverride`, `DELETE` auf `app/api/admin/config/route.ts`. Danach greift der Wert aus dem Chart. **Das** ist der Satz, der in `NOTES.txt` und ins Wiki gehört, und er ist der Grund, warum die Admin-Oberfläche erreichbar sein muss.
**Zusätzlich kommt ein Wert `adminConfigReadOnly`** (Vorgabe **`false`**), der `ADMIN_CONFIG_READONLY=true` setzt. Damit kann die Oberfläche überhaupt keinen Override schreiben, der Chart bleibt autoritativ, und `OAUTH_ONLY=false` wirkt immer. **Der Preis gehört an den Wert**, nicht in eine Fußnote: es friert auch `setPolicy` ein, also Policy, Themes, Branding — und nach Fehler 3 damit auch die einzige Möglichkeit, Web-Push auf einen eigenen Relay umzustellen. Darum Vorgabe `false`: wer den strengeren Modus will, wählt ihn bewusst.
**Das Risiko der Nur-OIDC-Entscheidung ist damit wieder begrenzt**, nur anders als ich geschrieben hatte: die Tür ist die Admin-Oberfläche, nicht `helm upgrade`.
## Fehler 2 — `/api/health` kann nicht scheitern, und das war die verlangte Messung
`app/api/health/route.ts`, der Erfolgspfad:
```ts
const response: HealthStatus = {
status: detailed ? status : 'healthy',
…
return NextResponse.json(response, { status: 200,
```
Eine Probe ist unangemeldet, bekommt also `detailed = false`, und damit sowohl HTTP **200** als auch den Rumpf **`healthy`** — unabhängig vom tatsächlichen Zustand. Upstream prüft das selbst so: `lib/__tests__/health-route.test.ts` erwartet `status 200` bei `payload.status === 'degraded'`. `503` kommt nur aus dem `catch`, wenn `process.memoryUsage()` selbst wirft.
**Es ist aber nicht die Gestalt von #218 und #83**, und diese Unterscheidung gehört an den Wert statt in eine pauschale Warnung: die Probe trägt echt, dass der Prozess lebt, HTTP annimmt, die Ereignisschleife frei ist und Next.js routet — ein OOM-Kill, ein Absturz, eine festsitzende Schleife fallen damit auf. Sie trägt **nicht** den gemeldeten Zustand und **keine** Aussage über Stalwart: ist der Mailserver weg, ist `/api/health` grün.
**Festgelegt:** alle drei Probes auf `/api/health`, als `HEAD` (die billigste Form, die der Endpunkt anbietet), und die Grenze wörtlich an den Readiness-Wert geschrieben. Einen besseren Endpunkt gibt es nicht — `/api/setup/test-jmap` liegt hinter dem Setup-Token.
## Fehler 3 — der Push-Relay ist umstellbar, die geplante Offenlegung war falsch
`lib/push-relays.ts`: die Bau-Variable verschiebt nur den **eingebauten Vorgabe-Eintrag**. Der wirksame Relay kommt aus `policy.json` — `resolveDefaultRelayUrl` nimmt `policy.pushRelayUrl`, wenn gültig, und der Anbieter-Relay ist der **Rückfall**; mit `pushRelayUrlLocked: true` sind die Nutzer darauf festgenagelt.
**Korrekt ist zweiteilig:** der Anbieter-Relay ist die Vorgabe und bleibt ohne eigenes Abbild in der Auswahlliste — aber ein eigener Relay kann wirksame Vorgabe und verbindlich werden, über die **Admin-Oberfläche**, nicht über einen Chart-Wert (`pushRelayUrl` steht in `SettingsPolicy`, nicht in `CONFIG_ENV_MAP`). „Nicht umstellbar" hätte Betreibern eine Wahl abgesprochen, die sie haben.
## Fehler 4 — `ADMIN_PASSWORD`: Begründung falsch, Wert ist einmalig
Ohne `ADMIN_PASSWORD` wird **kein Zufallspasswort erzeugt und nichts nach stdout geschrieben** — die Admin-Oberfläche wird schlicht abgeschaltet (`lib/admin/password.ts`). Der einzige Zufallswert im Projekt ist das Bootstrap-Setup-Token, das ins Zustandsverzeichnis geschrieben und **nie** protokolliert wird. Die Analogie zu `STALWART_RECOVERY_ADMIN` (#65) trägt hier nicht und wird aus dem Plan gestrichen.
**Die Entscheidung, es zu setzen, bleibt — mit einer besseren Begründung:** nach Fehler 1 ist die Admin-Oberfläche der Rettungsweg. Sie abzuschalten heißt, sich die Tür zu nehmen.
**Und der Wert ist einmalig:** `initAdminPassword` kehrt zurück, sobald `admin.json` existiert, und sieht die Variable nie wieder an. **Eine Drehung des Tresorfeldes wirkt danach nicht**; ein Reset läuft über die Oberfläche oder über das Löschen von `admin.json`. Gleiche Ursache wie Fehler 1: Volumenzustand schlägt Chart. Gehört ins Wiki.
## Was übernommen wird
- **`SESSION_SECRET_FILE` existiert** und wird genommen — ein Secret wird ohnehin eingehängt, die Dateiform kostet also nichts und hält das Geheimnis aus der Prozessumgebung. `ADMIN_PASSWORD` hat **keine** Dateiform und bleibt eine Umgebungsvariable.
- **#81 greift hier nicht:** `lib/read-file-env.ts` ist `readFileSync` plus `try/catch`, ohne `statSync` und ohne `mode & 0o077`. Das Gruppen-Lesebit aus `fsGroup` ist unschädlich, die Dateiform ist tragfähig.
- **Das OAuth-Geheimnis wird bei jedem Token-Tausch neu gelesen** (`lib/oauth/token-exchange.ts`), eine Drehung wirkt also **ohne Neustart** — aber nur, wenn das Secret **ohne `subPath`** eingehängt wird, weil das Kubelet `subPath`-Mounts nie aktualisiert.
- **Die Sicherungsaussage wird schärfer, nicht nur zweiteilig:** `app/api/admin/oauth/setup/route.ts` schreibt `oauthClientSecret` **im Klartext** in `config.json` auf die PVC. Eine Volumensicherung kann also das OAuth-Client-Geheimnis im Klartext enthalten. Das gehört neben den Satz, dass eine Sicherung ohne `SESSION_SECRET` keine Sicherung ist.
- **`SESSION_SECRET` ist zu Recht verpflichtend:** `app/api/config/route.ts` schaltet die Einstellungs-Synchronisierung **lautlos** ab, wenn es fehlt.
## Offen für den nächsten Durchgang
- **`readOnlyRootFilesystem`** ist abgeleitet, nicht gemessen: `next.config.ts` baut `output: "standalone"` und `images.unoptimized` gilt nur im Lite-Zweig, die Bildoptimierung ist also aktiv und schreibt nach `/app/.next/cache`. Erwartet werden emptyDirs für `/tmp` und diesen Pfad; den genauen Satz belegt der Install-Test.
- **Renovate-Dry-Run** steht aus — im Workspace ist keine Renovate-CLI installiert, nachzuholen über `npx renovate`. Belegt ist bisher nur: Vorab-Fassungen existieren (`1.11.0-beta.1` bis `.3`) und die Tags tragen **kein** `v`-Präfix.
Umgesetzt in PR #90 — mit fünf Korrekturen, eine davon braucht deine Bestätigung
Der Chart ist gebaut, alle Checks sind grün, das Wiki ist im selben Durchgang gelandet (996f8c0). PR trägt Part of #89, nicht Fixes — der Grund steht unter Punkt 5.
Alles unten ist am Quelltext am Tag 1.12.0 gemessen. Die Befunde aus den Kommentaren 5342/5349 habe ich dabei alle unabhängig nachgeprüft und bestätigt (Vorrangordnung in config-manager.ts, oauthOnly in types.ts:236 und seine drei Konsumenten, /api/health mit hartem 200, push-relays.ts, password.ts, read-file-env.ts, token-exchange.ts, session-secret.ts). Nichts davon war falsch.
1. Die Begründung für telemetry: false stimmt so nicht
Der Plan behandelt das Abschalten als Abweichung von der Abbild-Vorgabe. Gemessen: DEFAULTS in lib/telemetry/state.ts trägt consent: 'off' — Telemetrie ist opt-in, und der Kommentar in instrumentation.node.ts („on by default") ist falsch. Was das Rendern der Variable wirklich bringt, ist die Sperre: effectiveConsent() nimmt die Umgebung über die Zustandsdatei, und Upstream blockiert die Oberfläche, solange der Override gesetzt ist.
Folge für 5349: es gibt nicht zwei, sondern drei Ausnahmen von „der Volumenzustand schlägt den Chart" — SESSION_SECRET(_FILE), ADMIN_PASSWORD und BULWARK_TELEMETRY. BULWARK_UPDATE_CHECK ist übrigens genau umgekehrt (an, solange nicht abgeschaltet), die beiden Werte sind also nicht symmetrisch.
2. adminConfigReadOnly kostet mehr als 5349 beziffert
5349 nennt einen Preis (setPolicy friert ein → Policy, Themes, Branding, Push-Relay). Es sind drei, alle aus demselben assertWritable:
removeAdminOverride ist ebenfalls bewacht. Einschalten entfernt einen bestehenden Override also nicht, sondern friert ihn fest — und nimmt im selben Zug die dokumentierte Reparatur weg. Eine behebbare Aussperrung wird damit zu einer dauerhaften.
Auf einer frischen Installation nimmt es die Admin-Oberfläche ganz weg — also genau die Tür, für die der Chart ein Passwort hält. initAdminPassword persistiert über writeConfigData, das mit assertWritable('save admin password') beginnt. Ohne vorhandene admin.json wirft das: der Start-Hook protokolliert Admin dashboard init skipped, der erneute Versuch in /api/admin/auth wirft wieder, und der Login-Endpunkt antwortet HTTP 500 — während der Pod Ready ist und Mail ausliefert.
Die Entscheidung (Wert einführen, Vorgabe false) bleibt richtig und ist jetzt besser begründet. Der vollständige Preis und die einzige gefahrlose Reihenfolge stehen am Wert und in NOTES.txt.
3. Die OIDC-Redirect-URI trägt die Oberflächensprache
Sie lautet https://<domain>/<locale>/auth/callback, wobei <locale> die Sprache ist, die der Browser des Besuchers aufgelöst hat — 27 Sprachen, und der Locale-Präfix ist in diesem Abbild never, die Sprache steht also nicht in der sichtbaren URL. Upstreams eigener Admin-Helfer registriert deshalb eine URI pro Sprache (buildRedirectUris).
Eine einzeln registrierte URI ist damit die wahrscheinlichste Ursache für eine scheiternde erste Anmeldung, und das stand nirgends. NOTES.txt nennt jetzt den Wildcard und die vollständige Sprachliste.
4. Die Registry veröffentlicht jede Fassung doppelt
Die frühere Notiz sagt, die Tags trügen kein v-Präfix. Gemessen gegen die ghcr.io-Tag-API: 1.12.0undv1.12.0 existieren für dasselbe Abbild, beide Familien bis 1.3.9 zurück. Insgesamt 315 Tags in acht Formen (dazu -always, gleitende Minors, latest/main/dev, Commit-SHAs, die Betas, Attestierungen).
Der Chart pinnt die suffixfreie Form. Renovate-Dry-Run, 44.138.0, --platform=local --dry-run=lookup, gegen die echte renovate.json5 dieses PRs:
Kein Lauf bot eine Beta, ein -always, eine v-Form, einen gleitenden oder einen SHA-Tag an. Also kein versioningTemplate — wie bei keycloak, nicht wie bei garage. Die Zählung und die Begründung je Form stehen im Block.
Ein Vorbehalt, der im Log sichtbar war und deshalb dort steht: ghcr.io lieferte anonym keinenreleaseTimestamp, weshalb Renovate beide Updates unter minimumReleaseAge als pendingChecks: true markierte. Ob die geteilte Instanz die Zeitstempel sieht, habe ich nicht geprüft — sie authentifiziert sich mit GITHUB_COM_TOKEN, dieser Lauf nicht. Falls nicht, betrifft es jeden ghcr.io-Chart hier gleichermaßen und ist keine Entscheidung über diesen.
5. HEAD ist mit einer Kubernetes-Probe nicht herstellbar — hier weiche ich ab
5349 legt fest: „alle drei Probes auf /api/health, als HEAD (die billigste Form, die der Endpunkt anbietet)".
Das geht nicht. Der API-Typ ist HTTPGetAction; er hat kein Methodenfeld, und das Kubelet sendet immer GET. Es gibt keinen Wert in einer Pod-Spezifikation, der das ändert. Die einzige Möglichkeit für ein HEAD wäre eine exec-Probe mit einem HTTP-Client — und dieses Abbild hat weder curl noch wget (npm und npx sind aus der Laufzeitschicht entfernt), es wäre also alle paar Sekunden ein neuer Node-Prozess gegen einen Endpunkt, dessen GET bereits billig ist.
Der Chart verwendet GET. Sachlich ändert das nichts an deiner Begründung: der Handler gibt weiterhin hart 200 zurück, setzt den Rumpf für unangemeldete Aufrufer hart auf healthy, und sagt nichts über Stalwart. GET ist sogar geringfügig tragfähiger als HEAD, weil sein catch-Zweig 503 liefern kann, wenn die Diagnose selbst wirft — HEAD gibt ein nacktes 200 zurück und rechnet gar nichts.
Das ist die einzige Entscheidung, die ich geändert statt erfragt habe, und deshalb trägt der PR Part of #89: ein Fixes würde das Issue automatisch schließen und damit eine geänderte verbindliche Festlegung als abgenommen verbuchen. Sag Bescheid, wenn du es anders willst — dann ist die exec-Probe der Weg, mit dem Preis oben.
Dazu ein Befund, der nicht im Plan stand
bulwark.stalwartAdminAccess steht auf Upstreams auto, also unverändert — aber die Folge gehört benannt: mit auto erreicht jeder Stalwart-Admin /adminohne das Bulwark-Admin-Passwort. Und /admin ist genau die Stelle, die die Overrides schreibt und den Anmeldeweg sperren kann. Meist dieselbe Person, deshalb nicht geändert; password verengt die Tür auf den Tresor-Inhaber. Steht am Wert und auf der Wiki-Seite.
Offen geblieben
Der genaue Satz der nötigen emptyDirs ist jetzt belegt — der Install-Test lief grün (1/1 Running, 0 Restarts), also hält readOnlyRootFilesystem: true mit genau /tmp und /app/.next/cache. Was dauerhaft offen bleibt und auf der Wiki-Seite steht: ein überflüssiger emptyDir würde so nicht auffallen.
Kein k3d, kein Docker, kein Trivy lokal — Laufzeitaussagen kommen ausschließlich aus der CI.
Die Schlüsselnamen der beiden Datei-Geheimnisse belegt der Install-Test nicht: das projizierte Volume ist absichtlich optional: true, ein Tippfehler ergibt also eine fehlende Datei und einen gesunden Pod. Nur admin-password scheitert laut.
Zu JMAP, OIDC und der Vorrangfalle sagt die CI nichts — config.json existiert in einem CI-Lauf nie.
Nebenbefund außerhalb dieses Issues
Das Wiki listet in Charts eine Seite convertx, für die es im Repository kein Chart-Verzeichnis gibt. Ich habe sie nicht gelöscht — ob der Chart weg, geplant oder umgezogen ist, ist eine Entscheidung und keine Dokumentationskorrektur — aber den Zähler auf der Seite habe ich neu gemessen statt fortgeschrieben und die Abweichung dort vermerkt.
# Umgesetzt in PR #90 — mit fünf Korrekturen, eine davon braucht deine Bestätigung
Der Chart ist gebaut, alle Checks sind grün, das Wiki ist im selben Durchgang gelandet (`996f8c0`). PR trägt **`Part of #89`**, nicht `Fixes` — der Grund steht unter Punkt 5.
Alles unten ist am Quelltext am Tag `1.12.0` gemessen. Die Befunde aus den Kommentaren 5342/5349 habe ich dabei alle unabhängig nachgeprüft und **bestätigt** (Vorrangordnung in `config-manager.ts`, `oauthOnly` in `types.ts:236` und seine drei Konsumenten, `/api/health` mit hartem 200, `push-relays.ts`, `password.ts`, `read-file-env.ts`, `token-exchange.ts`, `session-secret.ts`). Nichts davon war falsch.
## 1. Die Begründung für `telemetry: false` stimmt so nicht
Der Plan behandelt das Abschalten als Abweichung von der Abbild-Vorgabe. Gemessen: `DEFAULTS` in `lib/telemetry/state.ts` trägt `consent: 'off'` — Telemetrie ist **opt-in**, und der Kommentar in `instrumentation.node.ts` („on by default") ist falsch. Was das Rendern der Variable wirklich bringt, ist die **Sperre**: `effectiveConsent()` nimmt die Umgebung über die Zustandsdatei, und Upstream blockiert die Oberfläche, solange der Override gesetzt ist.
**Folge für 5349:** es gibt nicht zwei, sondern **drei** Ausnahmen von „der Volumenzustand schlägt den Chart" — `SESSION_SECRET(_FILE)`, `ADMIN_PASSWORD` und `BULWARK_TELEMETRY`. `BULWARK_UPDATE_CHECK` ist übrigens genau umgekehrt (an, solange nicht abgeschaltet), die beiden Werte sind also nicht symmetrisch.
## 2. `adminConfigReadOnly` kostet mehr als 5349 beziffert
5349 nennt einen Preis (`setPolicy` friert ein → Policy, Themes, Branding, Push-Relay). Es sind drei, alle aus demselben `assertWritable`:
- **`removeAdminOverride` ist ebenfalls bewacht.** Einschalten *entfernt* einen bestehenden Override also nicht, sondern **friert ihn fest** — und nimmt im selben Zug die dokumentierte Reparatur weg. Eine behebbare Aussperrung wird damit zu einer dauerhaften.
- **Auf einer frischen Installation nimmt es die Admin-Oberfläche ganz weg** — also genau die Tür, für die der Chart ein Passwort hält. `initAdminPassword` persistiert über `writeConfigData`, das mit `assertWritable('save admin password')` beginnt. Ohne vorhandene `admin.json` wirft das: der Start-Hook protokolliert `Admin dashboard init skipped`, der erneute Versuch in `/api/admin/auth` wirft wieder, und **der Login-Endpunkt antwortet HTTP 500** — während der Pod `Ready` ist und Mail ausliefert.
Die Entscheidung (Wert einführen, Vorgabe `false`) bleibt richtig und ist jetzt besser begründet. Der vollständige Preis und die einzige gefahrlose Reihenfolge stehen am Wert und in `NOTES.txt`.
## 3. Die OIDC-Redirect-URI trägt die Oberflächensprache
Sie lautet `https://<domain>/<locale>/auth/callback`, wobei `<locale>` die Sprache ist, die der **Browser des Besuchers** aufgelöst hat — 27 Sprachen, und der Locale-Präfix ist in diesem Abbild `never`, die Sprache steht also **nicht** in der sichtbaren URL. Upstreams eigener Admin-Helfer registriert deshalb eine URI pro Sprache (`buildRedirectUris`).
Eine einzeln registrierte URI ist damit die wahrscheinlichste Ursache für eine scheiternde erste Anmeldung, und das stand nirgends. `NOTES.txt` nennt jetzt den Wildcard und die vollständige Sprachliste.
## 4. Die Registry veröffentlicht jede Fassung doppelt
Die frühere Notiz sagt, die Tags trügen kein `v`-Präfix. Gemessen gegen die ghcr.io-Tag-API: `1.12.0` **und** `v1.12.0` existieren für dasselbe Abbild, beide Familien bis `1.3.9` zurück. Insgesamt 315 Tags in **acht** Formen (dazu `-always`, gleitende Minors, `latest`/`main`/`dev`, Commit-SHAs, die Betas, Attestierungen).
Der Chart pinnt die suffixfreie Form. **Renovate-Dry-Run**, 44.138.0, `--platform=local --dry-run=lookup`, gegen die echte `renovate.json5` dieses PRs:
- `1.12.0` → `updates: []`, `warnings: []`
- `1.11.0` → genau zwei: `1.11.2` (patch), `1.12.0` (minor)
- `1.10.0` → genau eine: `1.12.0` (minor)
Kein Lauf bot eine Beta, ein `-always`, eine `v`-Form, einen gleitenden oder einen SHA-Tag an. Also **kein `versioningTemplate`** — wie bei keycloak, nicht wie bei garage. Die Zählung und die Begründung je Form stehen im Block.
Ein Vorbehalt, der im Log sichtbar war und deshalb dort steht: ghcr.io lieferte anonym **keinen** `releaseTimestamp`, weshalb Renovate beide Updates unter `minimumReleaseAge` als `pendingChecks: true` markierte. Ob die geteilte Instanz die Zeitstempel sieht, habe ich **nicht** geprüft — sie authentifiziert sich mit `GITHUB_COM_TOKEN`, dieser Lauf nicht. Falls nicht, betrifft es jeden ghcr.io-Chart hier gleichermaßen und ist keine Entscheidung über diesen.
## 5. `HEAD` ist mit einer Kubernetes-Probe nicht herstellbar — **hier weiche ich ab**
5349 legt fest: „alle drei Probes auf `/api/health`, als **HEAD** (die billigste Form, die der Endpunkt anbietet)".
**Das geht nicht.** Der API-Typ ist `HTTPGetAction`; er hat kein Methodenfeld, und das Kubelet sendet immer GET. Es gibt keinen Wert in einer Pod-Spezifikation, der das ändert. Die einzige Möglichkeit für ein HEAD wäre eine `exec`-Probe mit einem HTTP-Client — und dieses Abbild hat weder `curl` noch `wget` (`npm` und `npx` sind aus der Laufzeitschicht entfernt), es wäre also alle paar Sekunden ein neuer Node-Prozess gegen einen Endpunkt, dessen GET bereits billig ist.
**Der Chart verwendet GET.** Sachlich ändert das nichts an deiner Begründung: der Handler gibt weiterhin hart 200 zurück, setzt den Rumpf für unangemeldete Aufrufer hart auf `healthy`, und sagt nichts über Stalwart. GET ist sogar *geringfügig* tragfähiger als HEAD, weil sein `catch`-Zweig 503 liefern kann, wenn die Diagnose selbst wirft — HEAD gibt ein nacktes 200 zurück und rechnet gar nichts.
**Das ist die einzige Entscheidung, die ich geändert statt erfragt habe**, und deshalb trägt der PR `Part of #89`: ein `Fixes` würde das Issue automatisch schließen und damit eine geänderte verbindliche Festlegung als abgenommen verbuchen. Sag Bescheid, wenn du es anders willst — dann ist die `exec`-Probe der Weg, mit dem Preis oben.
## Dazu ein Befund, der nicht im Plan stand
`bulwark.stalwartAdminAccess` steht auf Upstreams `auto`, also **unverändert** — aber die Folge gehört benannt: mit `auto` erreicht jeder Stalwart-Admin `/admin` **ohne** das Bulwark-Admin-Passwort. Und `/admin` ist genau die Stelle, die die Overrides schreibt und den Anmeldeweg sperren kann. Meist dieselbe Person, deshalb nicht geändert; `password` verengt die Tür auf den Tresor-Inhaber. Steht am Wert und auf der Wiki-Seite.
## Offen geblieben
- **Der genaue Satz der nötigen emptyDirs ist jetzt belegt** — der Install-Test lief grün (`1/1 Running`, **0 Restarts**), also hält `readOnlyRootFilesystem: true` mit genau `/tmp` und `/app/.next/cache`. Was dauerhaft offen bleibt und auf der Wiki-Seite steht: ein **überflüssiger** emptyDir würde so nicht auffallen.
- Kein k3d, kein Docker, kein Trivy lokal — Laufzeitaussagen kommen ausschließlich aus der CI.
- Die **Schlüsselnamen** der beiden Datei-Geheimnisse belegt der Install-Test nicht: das projizierte Volume ist absichtlich `optional: true`, ein Tippfehler ergibt also eine fehlende Datei und einen gesunden Pod. Nur `admin-password` scheitert laut.
- Zu JMAP, OIDC und der Vorrangfalle sagt die CI nichts — `config.json` existiert in einem CI-Lauf nie.
## Nebenbefund außerhalb dieses Issues
Das Wiki listet in `Charts` eine Seite **`convertx`**, für die es im Repository **kein Chart-Verzeichnis** gibt. Ich habe sie nicht gelöscht — ob der Chart weg, geplant oder umgezogen ist, ist eine Entscheidung und keine Dokumentationskorrektur — aber den Zähler auf der Seite habe ich neu gemessen statt fortgeschrieben und die Abweichung dort vermerkt.
Wie OIDC zwischen Bulwark, Stalwart und Keycloak wirklich läuft
Nachgetragen, weil der Plan „Keycloak als Anbieter" sagt, ohne die Kette zu benennen — und weil ein falsch gesetztes Stück beim ersten Ausrollen mit einem 401 endet, dessen Ursache man nicht sieht. Alles unten ist am Quelltext gemessen: Bulwark am Tag 1.12.0, Stalwart am Zweig main.
Es gibt zwei Topologien, und nur eine ist SSO
Stalwart kann beide Rollen spielen, und das Registry-Schema trennt sie:
OidcProvider — Stalwart als eigener Autorisierungsserver, mit authCodeExpiry, refreshTokenExpiry, idTokenExpiry, userCodeExpiry (Device-Flow), eigenem signatureKey und Client-Registrierung.
Und Bulwark wählt stillschweigend zwischen beiden.lib/oauth/token-exchange.ts:108-112:
// When the user picked a server, discovery must stay on that server: its
// own issuer if configured, otherwise the server itself.
constissuerUrl=entry?(entry.oauth?.issuerUrl||entry.url):globalIssuerUrl;
Ist OAUTH_ISSUER_URLleer, holt Bulwark /.well-known/oauth-authorization-servervon Stalwart und benutzt Stalwart als Aussteller — der Nutzer meldet sich dann gegen Stalwarts eigenes Verzeichnis an, nicht gegen Keycloak. Das sieht aus wie OIDC, funktioniert, und ist kein SSO. OAUTH_ISSUER_URL muss also ausdrücklich gesetzt sein.
app/api/auth/sso/complete/route.ts legt nach dem Callback access_token, refresh_token und id_token ab, in einem „Per-account refresh-token cookie slot".
lib/jmap/client.ts:1194 schickt es bei jeder JMAP-Anfrage mit: const headers = { …, 'Authorization': this.authHeader }.
Bei einem 401 holt der Client ein frisches Token (updateAccessToken) und wiederholt die Anfrage.
Es gibt also keinen zweiten Flow und keine Konto-Verknüpfung. Stalwart ist keine zweite Identität, sondern ein Resource Server; die Verbindung zwischen Person und Postfach entsteht über claimUsername — der Claim ist die Verknüpfung, es gibt keinen Zustand, der sie speichern müsste.
Ein zweiter Flow entsteht nur bei einem zweiten Konto (der Slot ist je Konto), bei der Handy-Kopplung (app/api/auth/pair/*, das Bündel trägt Token, Token-Endpunkt und die client_id für die späteren Erneuerungen) und bei abgelaufenem Refresh-Token (app/api/auth/reauth/sso/complete).
Zum Prüfen braucht Stalwart kein Client-Secret
Häufiger Irrtum, und die Antwort erklärt gleich, warum das Feld fehlt. 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. Ein Token zu holen braucht client_id + client_secret — das ist Bulwarks Seite. Ein Token zu prüfen braucht nur den öffentlichen Schlüssel.
Und das fehlende Secret-Feld ist eine Folge, keine Lücke: authenticate_jwt weist HS256, HS384 und HS512 ab — genau die symmetrischen Verfahren, die ein gemeinsames Geheimnis bräuchten, und deren Annahme die klassische Algorithmus-Verwechslungslücke wäre. Für opake Token gilt dasselbe ohne Secret: authenticate_opaque ruft fetch_userinfo(token), der Token authentifiziert den Aufruf selbst.
Keycloak legt in ein Access-Token standardmäßig keine brauchbare Audience — dafür braucht der Client einen Audience-Mapper. Das ist der häufigste Fehler dieser Kette.
Und das Symptom ist irreführend: weil der JMAP-Client auf 401 automatisch erneuert und wiederholt, zeigt sich eine falsche Audience nicht als Fehlermeldung, sondern als Schleife — Anmeldung klappt, Rücksprung klappt, dann dreht Erneuern-und-Wiederholen. Das verleitet dazu, es als „ich muss mein Konto noch verbinden" zu deuten.
3. claimUsername muss das Postfach treffen. Ist Keycloaks preferred_username nur justin, das Postfach aber justin@domain, braucht es usernameDomain.
4. Die Redirect-URI trägt die Sprache — https://<domain>/<locale>/auth/callback, 27 Locales, und weil der Präfix never ist, steht sie nicht in der sichtbaren URL. Upstreams eigener Helfer registriert eine URI pro Sprache. Wahrscheinlichste Ursache einer scheiternden ersten Anmeldung.
Ein OIDC-Directory nimmt nur Bearer — und was das bedeutet
lib/.../oidc/lookup.rs:23-44, das Ende der authenticate-Funktion:
_=>Err(AuthEvent::Error.into_err().reason("Unsupported credentials type for OIDC backend")),
Benutzername und Passwort gegen ein OIDC-Directory funktionieren nicht. Daraus folgt zweierlei:
Die Konfiguration, die aussperrt, ist OIDC als DirectoryBootstrapanstelle von Internal. 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. Ersetzt man Internal dagegen durch OIDC, gibt es nirgends mehr ein Passwort, auch nicht für Stalwarts eigene Oberfläche.
Und es bestätigt OAUTH_ONLY: Bulwarks Client kennt authMode === 'basic', also Passwort gegen Stalwart. Für ein Konto im OIDC-Directory kann dieser Weg nicht tragen — er bekäme „Unsupported credentials type". Ein sichtbares Passwortformular wäre dort eine Tür, die garantiert nicht aufgeht.
Pflichtschritte vor der Installation, in cluster-config
Keycloak-Client für Bulwark, vertraulich, mit Audience-Mapper und den Scopes, die Stalwart verlangt.
Eine Redirect-URI je Locale, die benutzt werden soll.
OidcDirectory in Stalwart: issuerUrl auf Keycloak, requireAudience passend zum Mapper, claimUsername (plus usernameDomain, falls nötig) auf das Postfach abgebildet.
Internalbleibt das Bootstrap-Verzeichnis.
Mit OAUTH_ONLY und ohne diese vier kommt niemand hinein.
Nicht gemessen
Wie Stalwart ein Zugangsdatum einem Verzeichnis zuordnet, wenn mehrere existieren — der Reihe nach, oder über die Zugehörigkeit des Kontos. Dass OidcDirectory ein memberTenantId trägt, deutet auf Letzteres. Die Aussage „tritt daneben" folgt aus den Enum-Formen, nicht aus gelesenem Zuordnungscode. Das ist der eine Punkt, den ich vor dem Ausrollen belegen würde.
Ebenfalls offen: der ganze Pfad ist in keinem CI-Lauf durchlaufen — config.json entsteht dort nie, und ein Keycloak gibt es nicht. Die wichtigste Betriebseigenschaft dieses Charts ist damit unbelegt und kann es im Install-Test nicht werden.
# Wie OIDC zwischen Bulwark, Stalwart und Keycloak wirklich läuft
Nachgetragen, weil der Plan „Keycloak als Anbieter" sagt, ohne die Kette zu benennen — und weil ein falsch gesetztes Stück beim ersten Ausrollen mit einem 401 endet, dessen Ursache man nicht sieht. Alles unten ist am Quelltext gemessen: Bulwark am Tag `1.12.0`, Stalwart am Zweig `main`.
## Es gibt zwei Topologien, und nur eine ist SSO
Stalwart kann **beide Rollen** spielen, und das Registry-Schema trennt sie:
- **`OidcDirectory`** — Stalwart als **Token-Prüfer** (Resource Server). Felder: `issuerUrl`, `requireAudience`, `requireScopes`, `claimUsername`, `usernameDomain`, `claimName`, `claimGroups`, `memberTenantId`.
- **`OidcProvider`** — Stalwart als **eigener Autorisierungsserver**, mit `authCodeExpiry`, `refreshTokenExpiry`, `idTokenExpiry`, `userCodeExpiry` (Device-Flow), eigenem `signatureKey` und Client-Registrierung.
**Und Bulwark wählt stillschweigend zwischen beiden.** `lib/oauth/token-exchange.ts:108-112`:
```ts
// When the user picked a server, discovery must stay on that server: its
// own issuer if configured, otherwise the server itself.
const issuerUrl = entry ? (entry.oauth?.issuerUrl || entry.url) : globalIssuerUrl;
```
Ist `OAUTH_ISSUER_URL` **leer**, holt Bulwark `/.well-known/oauth-authorization-server` **von Stalwart** und benutzt Stalwart als Aussteller — der Nutzer meldet sich dann gegen Stalwarts eigenes Verzeichnis an, nicht gegen Keycloak. Das sieht aus wie OIDC, funktioniert, und ist **kein SSO**. `OAUTH_ISSUER_URL` muss also ausdrücklich gesetzt sein.
## Die Kette, die gewollt ist — ein Flow, ein Token
```
Nutzer → Bulwark ──(Auth-Code-Flow)──→ Keycloak
←──── access_token ───┘
Bulwark ──(Authorization: Bearer <Keycloak-Token>)──→ Stalwart JMAP
└→ OidcDirectory prüft
Signatur, aud, Scopes
→ claimUsername → Postfach
```
Belegt an drei Stellen:
1. `app/api/auth/sso/complete/route.ts` legt nach dem Callback `access_token`, `refresh_token` und `id_token` ab, in einem **„Per-account refresh-token cookie slot"**.
2. `lib/jmap/client.ts:1194` schickt es bei **jeder** JMAP-Anfrage mit: `const headers = { …, 'Authorization': this.authHeader }`.
3. Bei einem 401 holt der Client ein frisches Token (`updateAccessToken`) und wiederholt die Anfrage.
**Es gibt also keinen zweiten Flow und keine Konto-Verknüpfung.** Stalwart ist keine zweite Identität, sondern ein Resource Server; die Verbindung zwischen Person und Postfach entsteht über `claimUsername` — der Claim *ist* die Verknüpfung, es gibt keinen Zustand, der sie speichern müsste.
Ein zweiter Flow entsteht nur bei einem **zweiten Konto** (der Slot ist je Konto), bei der **Handy-Kopplung** (`app/api/auth/pair/*`, das Bündel trägt Token, Token-Endpunkt und die `client_id` für die späteren Erneuerungen) und bei **abgelaufenem Refresh-Token** (`app/api/auth/reauth/sso/complete`).
## Zum Prüfen braucht Stalwart kein Client-Secret
Häufiger Irrtum, und die Antwort erklärt gleich, warum das Feld fehlt. `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. Ein Token zu *holen* braucht `client_id` + `client_secret` — das ist Bulwarks Seite. Ein Token zu *prüfen* braucht nur den öffentlichen Schlüssel.
Und das fehlende Secret-Feld ist eine **Folge**, keine Lücke: `authenticate_jwt` weist `HS256`, `HS384` und `HS512` ab — genau die symmetrischen Verfahren, die ein gemeinsames Geheimnis bräuchten, und deren Annahme die klassische Algorithmus-Verwechslungslücke wäre. Für opake Token gilt dasselbe ohne Secret: `authenticate_opaque` ruft `fetch_userinfo(token)`, der Token authentifiziert den Aufruf selbst.
## Die vier Stellen, an denen es scheitert
**1. `OAUTH_ISSUER_URL` leer** → stillschweigend Stalwart-lokale Anmeldung statt SSO.
**2. Die Audience.** `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 — dafür braucht der Client einen **Audience-Mapper**. Das ist der häufigste Fehler dieser Kette.
**Und das Symptom ist irreführend:** weil der JMAP-Client auf 401 automatisch erneuert und wiederholt, zeigt sich eine falsche Audience nicht als Fehlermeldung, sondern als **Schleife** — Anmeldung klappt, Rücksprung klappt, dann dreht Erneuern-und-Wiederholen. Das verleitet dazu, es als „ich muss mein Konto noch verbinden" zu deuten.
**3. `claimUsername` muss das Postfach treffen.** Ist Keycloaks `preferred_username` nur `justin`, das Postfach aber `justin@domain`, braucht es `usernameDomain`.
**4. Die Redirect-URI trägt die Sprache** — `https://<domain>/<locale>/auth/callback`, 27 Locales, und weil der Präfix `never` ist, steht sie **nicht** in der sichtbaren URL. Upstreams eigener Helfer registriert eine URI pro Sprache. Wahrscheinlichste Ursache einer scheiternden **ersten** Anmeldung.
## Ein OIDC-Directory nimmt nur Bearer — und was das bedeutet
`lib/.../oidc/lookup.rs:23-44`, das Ende der `authenticate`-Funktion:
```rust
_ => Err(AuthEvent::Error
.into_err()
.reason("Unsupported credentials type for OIDC backend")),
```
Benutzername und Passwort gegen ein OIDC-Directory funktionieren **nicht**. Daraus folgt zweierlei:
**Die Konfiguration, die aussperrt**, ist OIDC als `DirectoryBootstrap` **anstelle** von `Internal`. 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. Ersetzt man `Internal` dagegen durch OIDC, gibt es nirgends mehr ein Passwort, auch nicht für Stalwarts eigene Oberfläche.
**Und es bestätigt `OAUTH_ONLY`:** Bulwarks Client kennt `authMode === 'basic'`, also Passwort gegen Stalwart. Für ein Konto im OIDC-Directory **kann** dieser Weg nicht tragen — er bekäme „Unsupported credentials type". Ein sichtbares Passwortformular wäre dort eine Tür, die garantiert nicht aufgeht.
## Pflichtschritte vor der Installation, in `cluster-config`
1. Keycloak-Client für Bulwark, **vertraulich**, mit **Audience-Mapper** und den Scopes, die Stalwart verlangt.
2. **Eine Redirect-URI je Locale**, die benutzt werden soll.
3. `OidcDirectory` in Stalwart: `issuerUrl` auf Keycloak, `requireAudience` passend zum Mapper, `claimUsername` (plus `usernameDomain`, falls nötig) auf das Postfach abgebildet.
4. `Internal` **bleibt** das Bootstrap-Verzeichnis.
Mit `OAUTH_ONLY` und ohne diese vier kommt niemand hinein.
## Nicht gemessen
**Wie Stalwart ein Zugangsdatum einem Verzeichnis zuordnet, wenn mehrere existieren** — der Reihe nach, oder über die Zugehörigkeit des Kontos. Dass `OidcDirectory` ein `memberTenantId` trägt, deutet auf Letzteres. Die Aussage „tritt daneben" folgt aus den Enum-Formen, nicht aus gelesenem Zuordnungscode. Das ist der eine Punkt, den ich vor dem Ausrollen belegen würde.
Ebenfalls offen: der ganze Pfad ist in keinem CI-Lauf durchlaufen — `config.json` entsteht dort nie, und ein Keycloak gibt es nicht. Die wichtigste Betriebseigenschaft dieses Charts ist damit unbelegt und kann es im Install-Test nicht werden.
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.
Ein Chart für Bulwark Webmail (
bulwarkmail/webmail), einen JMAP-Webmail-Client auf Next.js — Mail, Kalender, Kontakte und Dateien im Browser.Er ist der Begleiter zu
stalwart(#65). Upstream formuliert die Arbeitsteilung selbst: „Stalwart is the mail server: it holds the messages, speaks SMTP, and owns the accounts." Bulwark ist ausschließlich Client und hält keine Mail.| Dies ist noch kein freigegebener Plan. Fünf Entscheidungen unten sind offen und gehören vor der Umsetzung beantwortet.
Verifizierte Fakten
Gegen die GitHub-Releases, das
Dockerfileam Zweigmainund die Upstream-Dokumentation geprüft, nicht aus der Erinnerung:ghcr.io/bulwarkmail/webmail1.0.0+up1.12.0node:24-alpine, Next.js-Standalone-BuildUSER nextjs, uid/gid 1001 — läuft bereits als Nicht-RootEXPOSE 3000,PORT=3000,HOSTNAME=0.0.0.0CMD ["node", "server.js"]/api/health— „answers liveness probes for Docker and orchestrators. It returns healthy, degraded, or unhealthy along with uptime, version, and heap usage"Lobenswert am Abbild: Upstream entfernt
npmundnpxaus dem Laufzeit-Abbild und ruftapk upgradeim Build.Es ist nicht zustandslos — vier Verzeichnisse
Die Webseite sagt „Docker is the only requirement". Das Abbild legt aber vier beschreibbare Verzeichnisse unter
/app/dataan, und die Doku nennt für drei von ihnen, was darin liegt:/app/data/adminADMIN_CONFIG_DIRconfig.json,policy.json,admin.json(passwordHash only),plugin-config/,plugins/,themes/, and uploaded branding assets"/app/data/admin-stateADMIN_STATE_DIRadmin-state.json(login timestamps),audit.log, and the bootstrap setup token"/app/data/settingsSETTINGS_DATA_DIRSETTINGS_SYNC_ENABLED=true"/app/data/telemetryEin Chart ohne Volume verliert also die Admin-Konfiguration und das Audit-Protokoll bei jedem Neustart.
Drei Optionen sind beim Bauen festgebacken
„These are compiled into the bundle and cannot be changed at runtime … To change build-time variables with the published Docker image, rebuild with matching
--build-arg."NEXT_PUBLIC_BASE_PATH— ein Unterpfad wie/webmail. Der Chart kann Bulwark also nur an der Wurzel eines Hostnamens ausliefern, nicht unter einem Pfad.NEXT_PUBLIC_DEFAULT_LOCALE— Vorgabeen. Die Rückfall-Sprache der Oberfläche ist damit nicht durch den Chart änderbar; ein Besucher mit passendemAccept-Languagebekommt seine Sprache, alle anderen Englisch.NEXT_PUBLIC_PUSH_RELAY_URL— Vorgabehttps://notifications.relay.bulwarkmail.org. Web-Push läuft damit über den Relay des Anbieters, und das ist mit dem veröffentlichten Abbild nicht umstellbar. Bei einem Mailclient ist das eine Angabe, die invalues.yamlund auf die Wiki-Seite gehört, auch wenn der Chart sie nicht beeinflussen kann.Ein eigenes Abbild zu bauen ist der einzige Ausweg und liegt außerhalb dessen, was dieses Repository tut — dieselbe Grenze wie bei
garage(#81) undkeycloak(#83).Deklarativ geht, mit einer Vorrangfalle
Es gibt einen Einrichtungsassistenten beim ersten Start, der „chooses OAuth or password sign-in, generates a session secret and sets the admin password" und das Ergebnis nach
ADMIN_CONFIG_DIRschreibt. Er ist aber vermeidbar:JMAP_SERVER_URLschaltet ihn ab.Die Falle, und sie ist die unangenehmste Stelle dieses Charts: „A server URL saved by the wizard takes priority over the environment variable." Hat der Assistent also je gelaufen, gewinnt die Datei auf dem Volume über den Wert aus dem Chart — lautlos, und ein
helm upgrademit geänderter URL ändert dann nichts.Die Laufzeitvariablen
JMAP_SERVER_URLSESSION_SECRETopenssl rand -base64 32ADMIN_PASSWORDSTALWART_RECOVERY_ADMINbei #65STALWART_FEATUREStrueOAUTH_ENABLED,OAUTH_CLIENT_ID,OAUTH_ISSUER_URL,OAUTH_ONLYOAUTH_ONLYverbirgt das PasswortformularOAUTH_CLIENT_SECRET_FILEOAUTH_CLIENT_SECRET_FILEist erwähnenswert, weilgarage(#81) undkeycloak(#83) beide keine Dateiform kennen und wir dort auf Umgebungsvariablen ausgewichen sind. Hier geht der bessere Weg.Offene Entscheidungen
JMAP_SERVER_URLoder der Assistent? Deklarativ passt zu diesem Repository, aber die Vorrangfalle oben bleibt: wer den Assistenten einmal laufen lässt, entkoppelt den Chart lautlos./app/data. Eine PVC dort hält Konfiguration, Zustand und Audit-Protokoll zueinander konsistent — nach der Lehre aus #81 (Longhorn-Schnappschüsse sind je Volume atomar) spricht das für eine.SETTINGS_SYNC_ENABLEDverschlüsselt Nutzereinstellungen auf dem Volume und verlangtSESSION_SECRET. An heißt: Einstellungen folgen dem Nutzer, und das Volume trägt Nutzerdaten.OAUTH_ONLYwürde das Passwortformular ganz verbergen. Das verbindet die drei Charts und ist eine Betriebsentscheidung, keine Chart-Frage.ReadWriteOnceblockiert ein rollendes Update eines Deployments sich selbst — zwei Pods können das Volume nicht teilen. Die Konvention dieses Repositories verlangt für zustandsbehaftete Anwendungen ohnehin eine StatefulSet (bulwark.sfs.yaml).Am laufenden Dienst zu messen
/api/healthmit einem Code ungleich 200, wenn der Zustanddegradedoderunhealthyist? Das entscheidet, ob eine Probe darauf überhaupt scheitern kann. Tut sie es nicht, ist es die Gestalt von #218 und zuletzt von #83 — eine Probe, die immer grün ist. Vor dem Festlegen der Probes zu messen, nicht danach.readOnlyRootFilesystem: truemit/app/dataals Volume? Next.js im Standalone-Modus schreibt unter Umständen nach/tmpoder in einen Cache unter.next— welche emptyDirs nötig sind, ist zu messen.config.jsonwirklich überJMAP_SERVER_URL? Die Doku behauptet es; davon hängt ab, wie scharf die Warnung formuliert werden muss.OAUTH_CLIENT_SECRET_FILEbeim Start oder je Anfrage gelesen? Entscheidet, ob eine Geheimnis-Drehung einen Neustart braucht.CI-Verdrahtung
.github/workflows/publish-bulwark.yml— eine bestehende Hülle kopieren.bulwarkin diechart-name-Auswahlliste vonchange-app-version.yml.ci/bulwark/. Hier billig und aussagekräftig: es braucht keine Datenbank, einJMAP_SERVER_URLauf eine Attrappe genügt, und der Pod sollReadywerden.customManagers-Block inrenovate.json5. Zu messen statt anzunehmen: es gibt Vorab-Fassungen (1.11.0-beta.1bis.3), also ist per Dry-Run zu belegen, ob Renovate sie anbietet — beikeycloak(#83) war die vermutete Beschränkung am Ende unnötig, beigarage(#81) nötig.Nicht in diesem Chart
Der Stalwart-Server selbst (#65) und die OIDC-Client-Registrierung in Keycloak (#83) gehören in das abnehmende Bundle in
cluster-config, nicht hierher.Freigegebener Umsetzungsplan
Die fünf offenen Entscheidungen sind beantwortet: drei vom Betreuer, zwei folgen aus den Konventionen.
1. Deklarativ, der Assistent läuft nie
JMAP_SERVER_URLwird gesetzt und schaltet den Einrichtungsassistenten ab.SESSION_SECRETundADMIN_PASSWORDkommen aus einemOnePasswordItem.ADMIN_PASSWORDist dabei nicht optional, sondern der Grund für diese Richtung: ohne Angabe erzeugt Bulwark ein Zufallspasswort und schreibt es nach stdout — in Kubernetes also ins Pod-Protokoll, bei jedem Neustart ein neues. Dieselbe Gestalt wieSTALWART_RECOVERY_ADMINbei #65, und dieselbe Antwort.Die Vorrangfalle gehört prominent dokumentiert, nicht als Fußnote: „A server URL saved by the wizard takes priority over the environment variable." Wer die Admin-Oberfläche öffnet und dort eine Server-URL speichert, entkoppelt den Chart lautlos — ein
helm upgrademit geändertemJMAP_SERVER_URLändert danach nichts, und nichts in der Oberfläche sagt das.Verworfen:
ADMIN_CONFIG_DIRschreibgeschützt einhängen, um den Assistenten hart zu sperren. Die Admin-Oberfläche schreibt in dasselbe Verzeichnis (Policy, Themes, Branding, Plugins), ein schreibgeschütztes Einhängen würde sie also mitnehmen. Die Falle wird benannt, nicht verbaut.2. Einstellungs-Synchronisierung an
SETTINGS_SYNC_ENABLED=true,/app/data/settingsträgt verschlüsselte Einstellungen je Konto.Folge, die in
values.yaml,NOTES.txtund ins Wiki gehört: das Volume trägt damit Nutzerdaten, verschlüsselt mitSESSION_SECRET. Geht das Geheimnis verloren, sind sie unlesbar — auch aus einer intakten Sicherung. Das Geheimnis gehört deshalb genauso in die Sicherung wie das Volume, und das ist der Satz, der dort stehen muss: eine Sicherung des Volumes allein ist keine Sicherung.3. Anmeldung ausschließlich über OIDC
OAUTH_ENABLED=true,OAUTH_ONLY=true, Keycloak (#83) als Anbieter, das Geheimnis überOAUTH_CLIENT_SECRET_FILEaus einemOnePasswordItem— also als vertraulicher Client, was Keycloak problemlos kann, und über die Dateiform, die beigarage(#81) undkeycloak(#83) nicht existierte.Das Risiko ist benannt und wird in Kauf genommen: fällt Keycloak aus oder ist die OIDC-Konfiguration falsch, kommt niemand mehr an seine Mail, und das Passwortformular ist verborgen. Daraus folgen zwei Dinge, die der Chart tragen muss:
OAUTH_ONLYund ohne registrierten Client ist die erste Anmeldung unmöglich.NOTES.txtnennt die Reihenfolge.helm upgrademitOAUTH_ONLY=falsebringt das Passwortformular zurück. Das ist die einzige Tür, die nicht an Keycloak hängt — die Admin-Oberfläche hat ihr eigenes Passwort, gibt aber keinen Mailzugang. Ohne diesen Satz inNOTES.txtist eine Fehlkonfiguration ein Ausfall ohne sichtbaren Weg zurück.SESSION_SECRETist damit verpflichtend, nicht optional: sowohl die Einstellungs-Synchronisierung als auch die persistenten Sitzungen hängen daran.4. Eine PVC an
/app/dataAlle vier Verzeichnisse liegen darunter, Bulwark legt sie selbst an. Nach der Lehre aus #81 — Longhorn-Schnappschüsse sind je Volume atomar, nicht volumenübergreifend — hält eine PVC Konfiguration, Laufzeitzustand, Audit-Protokoll und Nutzereinstellungen zueinander konsistent. Getrennte Volumes könnten eine Sicherung erzeugen, in der das Protokoll Anmeldungen nennt, die die Konfiguration noch nicht kennt.
5. StatefulSet
bulwark.sfs.yaml. MitReadWriteOnceblockiert ein rollendes Deployment sich selbst, weil zwei Pods das Volume nicht teilen können; die Konvention dieses Repositories verlangt für zustandsbehaftete Anwendungen ohnehin eine StatefulSet.Was der Chart nicht ändern kann, und trotzdem benennen muss
Drei Optionen sind beim Bauen festgebacken. Zwei sind Unbequemlichkeiten, eine ist eine Offenlegung:
NEXT_PUBLIC_PUSH_RELAY_URL, Vorgabehttps://notifications.relay.bulwarkmail.org. Web-Push läuft über den Relay des Anbieters, und mit dem veröffentlichten Abbild ist das nicht umstellbar. Bei einem Mailclient gehört das invalues.yamlund auf die Wiki-Seite, auch wenn der Chart nichts dagegen tun kann.NEXT_PUBLIC_BASE_PATH— kein Unterpfad, der Ingress bedient die Wurzel eines Hostnamens.NEXT_PUBLIC_DEFAULT_LOCALE— Rückfall-Sprache bleibten.Umfang
Gebaut aus
template-chart/nach CLAUDE.md und dem Stilleitfaden: StatefulSet, eine PVC, Service, Ingress nach Konvention (ingress.domainundingress.clusterIssuerrequired ohne Vorgabe,classNameVorgabenginx, TLS-Secrettls-<release>-bulwark-ingress),OnePasswordItemfürSESSION_SECRET,ADMIN_PASSWORDund das OAuth-Geheimnis, drei Probes, berechnete Limits,replicasplusscaleDown, strengesvalues.schema.jsongegen die echten Bundle-Werte.Entsteht keine ConfigMap — die Konfiguration läuft über Umgebungsvariablen —, dann greift die
checksum/config-Regel (#82) nicht, und das muss ausdrücklich invalues.yamlstehen, sonst sieht es wie eine Auslassung aus.CI:
publish-bulwark.ymlaus einer bestehenden Hülle kopiert;bulwarkin diechart-name-Liste; Fixtures unterci/bulwark/mit einerJMAP_SERVER_URL-Attrappe und Dummy-Geheimnissen;customManagers-Block inrenovate.json5.Wiki im selben Durchgang: neue Seite
bulwark, Zeile inCharts(Zähler prüfen, nicht übernehmen), Eintrag in_Sidebar.md, und auf derstalwart-Seite ein Querverweis, weil die beiden nur zusammen nützlich sind.Am laufenden Dienst zu messen
/api/healthmit einem Code ungleich 200, wenn der Zustanddegradedoderunhealthyist? Es gibt nur diesen einen Endpunkt, nicht die Dreiteilung live/ready/started. Antwortet er immer mit 200 und nennt den Zustand nur im Rumpf, ist eine Probe darauf eine Probe, die nicht scheitern kann — die Gestalt von #218 und zuletzt von #83. Vor dem Festlegen der Probes zu messen, nicht danach, und wenn er nicht scheitern kann, gehört das als Grenze an den Wert geschrieben statt verschwiegen.readOnlyRootFilesystem: truemit/app/dataals Volume? Next.js im Standalone-Modus schreibt unter Umständen nach/tmpoder in einen Cache unter.next— welche emptyDirs nötig sind, ist zu messen.config.jsonwirklich überJMAP_SERVER_URL? Davon hängt ab, wie scharf die Warnung formuliert werden muss.OAUTH_CLIENT_SECRET_FILEbeim Start oder je Anfrage gelesen? Entscheidet, ob eine Geheimnis-Drehung einen Neustart braucht — und das gehört ins Wiki, weil es sonst beim ersten Wechsel überrascht.1.11.0-beta.1bis.3. Per Dry-Run zu belegen: beikeycloak(#83) war die vermutete Beschränkung am Ende unnötig, beigarage(#81) nötig. Keine Zeile schreiben, die nichts tut.Nicht in diesem Chart
Der Stalwart-Server selbst (#65) und die OIDC-Client-Registrierung in Keycloak (#83) gehören in das abnehmende Bundle in
cluster-config.Angehalten: drei sachliche Fehler, einer davon sicherheitsrelevant
Der Entwicklungsagent hat vor dem Festlegen der Probes gemessen, wie gefordert — und dabei im Quelltext am Tag
1.12.0drei Behauptungen widerlegt, auf denen der freigegebene Plan aufbaut. Eine davon betrifft den einzigen dokumentierten Ausweg aus einer Totalaussperrung. Es ist noch keine Zeile Chart geschrieben, weil alle dreivalues.yaml,NOTES.txtund den Traps-Abschnitt des Wikis verändern.Gemessen am entpackten Quell-Tarball
bulwarkmail/webmailTag1.12.0(nicht am Zweigmain, nicht aus der Dokumentation). Kein Laufzeitlauf — kein Docker in diesem Workspace.1. Der Ausweg aus der Aussperrung funktioniert so nicht
Der Plan nennt unter Punkt 3 genau eine Tür: „
helm upgrademitOAUTH_ONLY=falsebringt das Passwortformular zurück. Das ist die einzige Tür, die nicht an Keycloak hängt."Diese Tür kann verschlossen sein, und zwar genau dann, wenn man sie braucht.
Die Vorrangregel, die der Plan für die Server-URL benennt, ist nicht auf die Server-URL beschränkt. Sie ist das Verhalten jedes Wertes, den dieser Chart setzt.
lib/admin/config-manager.ts:56-88:this.adminConfigistconfig.jsonausADMIN_CONFIG_DIR, also von der PVC (config-manager.ts:35).oauthOnlyist ein gewöhnlicher Schlüssel dieser Tabelle —lib/admin/types.ts:236:und jeder Laufzeitkonsument liest ihn über
configManager.get, nicht überprocess.env:app/api/auth/session/route.ts:83— serverseitige Sperre des Passwort-Loginsapp/api/auth/verify/route.ts:52— dieselbe Sperreapp/api/config/route.ts:51— das, was die Login-Seite auswertetUnd geschrieben wird dieser Override von der Admin-Oberfläche, die nicht an Keycloak hängt und die man in einer Aussperrung als erstes aufsucht.
app/api/admin/oauth/setup/route.ts:216-221:Dazu
app/api/admin/config/route.ts:70— die Oberfläche darf jeden Schlüssel ausCONFIG_ENV_MAPals Override schreiben:Folge: Hat jemand die OAuth-Einrichtung der Admin-Oberfläche benutzt — der naheliegende Weg, und der Plan verbietet ihn nicht —, dann steht
oauthOnly: trueinconfig.jsonauf der PVC. Ab da istOAUTH_ONLY=falseaus dem Chart wirkungslos, einhelm upgradeändert nichts, undNOTES.txtwürde eine Anweisung tragen, die nachweislich nicht greift. Das ist dieselbe Falle, die der Plan korrekt fürJMAP_SERVER_URLbenennt — nur steht sie hier in der Rettungsanweisung selbst.Was stattdessen wirklich zurückführt
Beides im Quelltext belegt, beides unabhängig von Keycloak:
Override zurücknehmen statt ihn zu überschreiben: die Admin-Oberfläche hat ihr eigenes Passwort und kennt ein „revert" —
configManager.removeAdminOverride(config-manager.ts:148-152), freigelegt vomDELETE-Zweig inapp/api/admin/config/route.ts:165. Danach greift die Umgebung wieder.Die Überschreibung von vorn verhindern:
ADMIN_CONFIG_READONLY=truesperrt alle Schreibzugriffe auf das Konfigurationsverzeichnis auf Anwendungsebene (lib/admin/paths.ts:81-92,assertWritableinpaths.ts:124-126). Damit entsteht nie ein Override und die Chart-Werte sind dauerhaft maßgeblich.Das ist nicht das unter Punkt 1 verworfene schreibgeschützte Einhängen. Der Plan hat das
:ro-Mount verworfen, weil Policy, Themes, Branding und Plugins im gleichen Verzeichnis liegen — das Argument stimmt für das Mount.ADMIN_CONFIG_READONLYist ein anderer Mechanismus, kostet aber denselben Preis:assertWritableschützt auchsetPolicy(config-manager.ts:182) undmarkSetupComplete. Themes und Branding wären also ebenfalls eingefroren.Zu entscheiden (Betriebsentscheidung, nicht Chart-Frage):
NOTES.txtauf „Override in der Admin-Oberfläche zurücknehmen, sonstconfig.jsonauf der PVC bearbeiten" korrigieren — Chart bleibt wie geplant.adminConfigReadOnly(Vorgabefalse) einführen, derADMIN_CONFIG_READONLY=truesetzt. Macht die Chart-Werte maßgeblich und die Falle unmöglich, friert aber Policy/Themes/Branding ein.Ich habe (a) nicht einfach umgesetzt, weil (b) die Werte-Oberfläche des Charts verändert und damit nicht meine Entscheidung ist.
2.
/api/healthkann den Zustand nicht melden — die Messung, die zuerst kommen sollteAntwort: nein, der Endpunkt scheitert nicht an
degradedoderunhealthy.app/api/health/route.ts:status: 200zurück (Zeile 93-100), unabhängig vom ermittelten Zustand.healthygesetzt — Zeile 70:status: detailed ? status : 'healthy'.degradederscheint nur bei?detailed=trueund bestandenemcanSeeInstanceDetails(Zeile 51). Eine Kubernetes-Probe ist unangemeldet und sieht also nie etwas anderes alshealthy.503kommt ausschließlich aus demcatch, wenn das Sammeln der Diagnose selbst wirft (Zeile 102-112) — also wennprocess.memoryUsage()oderv8.getHeapStatistics()fehlschlägt. Praktisch nie.Das ist nicht meine Auslegung, upstream testet es so —
lib/__tests__/health-route.test.ts:61-62:Die Formulierung aus der Dokumentation im Issue-Rumpf („returns healthy, degraded, or unhealthy") ist für Probe-Zwecke also irreführend: der Zustand steht im Rumpf, nie im Code, und für die Probe nicht einmal im Rumpf.
Was eine Probe darauf trägt — und das ist mehr als bei #218: Sie prüft echt, dass der Node-Prozess lebt, der HTTP-Server annimmt, die Ereignisschleife nicht blockiert ist und Next.js routen kann. Ein OOM-Kill, ein Absturz, eine festsitzende Ereignisschleife fallen auf. Was sie nicht trägt: den gemeldeten Anwendungszustand und jede Aussage über Stalwart. Ist der Mailserver weg, ist
/api/healthgrün — Bulwark hält keine Mail und fragt den JMAP-Server für diesen Endpunkt nicht.Das gehört als Grenze an den Wert geschrieben, nicht verschwiegen. Mein Vorschlag, wenn du nichts anderes sagst: alle drei Probes auf
/api/health— Startup und Liveness sind damit sachlich korrekt bedient (Prozess lebt / ist hochgekommen), die Readiness trägt nur „nimmt HTTP an" und bekommt den Kommentar, dass sie die JMAP-Erreichbarkeit nicht abdeckt und auch nicht abdecken kann.HEAD /api/health(Zeile 118-124) ist dafür die billigste Form.Eine echte Readiness gegen Stalwart wäre
/api/setup/test-jmap, aber das ist eine Wizard-Route hinter dem Setup-Token und für eine Probe ungeeignet. Ich sehe keinen geeigneten Endpunkt.3. Der Push-Relay ist umstellbar — die geplante Offenlegung wäre falsch
Der Rumpf sagt, Web-Push laufe über den Relay des Anbieters und das sei „mit dem veröffentlichten Abbild nicht umstellbar", und der Plan macht daraus eine Pflicht-Offenlegung in
values.yamlund Wiki.Das trifft nicht zu.
lib/push-relays.ts:9-10zeigt, dass die Bau-Variable nur den eingebauten Vorgabe-Eintrag verschiebt:Der wirksame Relay kommt aber aus
policy.json—push-relays.ts:60-63:Der Anbieter-Relay ist also nur der Rückfall. Setzt ein Betreiber
pushRelayUrl, ist das die Vorgabe für alle; mitpushRelayUrlLocked: true(push-relays.ts:74,lib/admin/types.ts:155-156) sind die Nutzer darauf festgenagelt und können nichts anderes wählen.Die korrekte Aussage ist damit zweiteilig, und sie ist deutlich günstiger als die geplante: der Anbieter-Relay ist die Vorgabe und bleibt ohne eigenes Abbild in der Auswahlliste stehen, aber ein eigener Relay kann zur wirksamen Vorgabe gemacht und verbindlich gesetzt werden — über
policy.json, also die Admin-Oberfläche auf der PVC, nicht über einen Chart-Wert. Eine Offenlegung „nicht umstellbar" würde Betreibern eine Wahl absprechen, die sie haben.Dass es kein Chart-Wert ist, liegt daran, dass
policy.jsonkeine Umgebungsvariablen kennt — nachgesehen:pushRelayUrlsteht inSettingsPolicy(types.ts:154), nicht inCONFIG_ENV_MAP.4.
ADMIN_PASSWORD: die Begründung im Plan stimmt nicht, und der Wert ist einmaligZwei getrennte Punkte.
Kein Zufallspasswort nach stdout. Rumpf und Plan begründen
ADMIN_PASSWORDdamit, dass ohne Angabe „ein Zufallspasswort erzeugt und nach stdout geschrieben" werde — „dieselbe Gestalt wieSTALWART_RECOVERY_ADMINbei #65". Gemessen: ohneADMIN_PASSWORDwird die Admin-Oberfläche einfach abgeschaltet.lib/admin/password.ts:142-147:Ich habe
lib/adminundlib/setupnachrandomBytes, Passwortgenerierung undconsole.logdurchsucht: der einzige erzeugte Zufallswert ist das Bootstrap-Setup-Token (lib/setup/token.ts:54), und das wird in das Zustandsverzeichnis geschrieben und nie protokolliert. Es gibt also kein Leck ins Pod-Protokoll — die Gestalt von #65 liegt hier nicht vor.Die Entscheidung,
ADMIN_PASSWORDzu setzen, bleibt davon unberührt richtig (sonst keine Admin-Oberfläche — und nach Punkt 1 ist sie der Rettungsweg). Nur die Begründung gehört korrigiert, damit sie nicht als Präzedenz weiterwandert. Erwähnenswert ist die Gegenrichtung: ohneADMIN_PASSWORDgibt es auch keine Oberfläche, die Overrides schreiben und damit die Falle aus Punkt 1 auslösen kann.Der Wert wird genau einmal verbraucht.
initAdminPasswordkehrt zurück, sobaldadmin.jsonexistiert, und sieht die Umgebungsvariable dann nie wieder an (password.ts:125-140); beim ersten Start wird sie gehasht und persistiert (password.ts:149-153). Eine Drehung des Feldes im Tresor hat danach keine Wirkung — zurückgesetzt wird nur über die Oberfläche selbst oder durch Löschen vonadmin.jsonauf der PVC. Das gehört invalues.yaml,NOTES.txtund ins Wiki, zusammen mit der Falle aus Punkt 1, weil es dieselbe Ursache hat: der Zustand auf dem Volume schlägt den Chart.Was unabhängig davon schon gemessen ist
Die Falle aus #81 greift hier nicht. Bulwark prüft die Dateirechte nicht.
lib/read-file-env.tsist vollständig:Kein
statSync, keinmode & 0o077. Ein gemountetes Kubernetes-Secret mit Gruppen-Lesebit ist lesbar,fsGroupist unschädlich. Die Dateiform ist tragfähig — der Ausweg auf eine Umgebungsvariable wie beigarageist hier nicht nötig.Drehung des OAuth-Geheimnisses braucht keinen Neustart — mit einer Einschränkung, die der Chart bestimmt.
getGlobalClientSecret(lib/oauth/token-exchange.ts:55-65) ruftreadFileEnvbei jedem Token-Tausch auf, ohne Zwischenspeicher. Die Datei wird also je Anfrage gelesen. Damit das trägt, muss das Secret ohnesubPatheingehängt werden — einsubPath-Mount wird vom Kubelet nie aktualisiert, und dann bräuchte es doch einen Neustart. Das nehme ich so auf.SESSION_SECRETist zu Recht verpflichtend.app/api/config/route.ts:68schaltet die Synchronisierung still ab, wenn das Geheimnis fehlt:settingsSyncEnabled: configManager.get(...) && hasSessionSecret(). Ohne das Geheimnis wäreSETTINGS_SYNC_ENABLED=truewirkungslos — lautlos. Der Plan liegt hier richtig.Nebenbefund für die Sicherung, der sie verschärft:
app/api/admin/oauth/setup/route.ts:218schreibtoauthClientSecretim Klartext inconfig.jsonauf die PVC. Der Satz „eine Sicherung des Volumes allein ist keine Sicherung" bekommt damit eine Kehrseite, die genauso wichtig ist: eine Sicherung des Volumes kann das OAuth-Client-Geheimnis im Klartext enthalten. Das gehört in den Traps-Abschnitt.Es gibt auch
SESSION_SECRET_FILE(lib/admin/types.ts:277,lib/auth/session-secret.ts:22). Der Plan nennt nur die Dateiform für das OAuth-Geheimnis;SESSION_SECRETkönnte genauso aus einer Datei kommen und bliebe dann aus der Prozessumgebung heraus. Da das Secret für OAuth ohnehin eingehängt wird, ist es fast kostenlos.ADMIN_PASSWORDhat keine Dateiform. Ich setze das nicht eigenmächtig um — sag, ob du es willst.Bestätigt, unverändert gültig: uid/gid 1001 (
nextjs/nodejs,Dockerfile:42-43viaaddgroup --gid 1001/adduser --uid 1001), Port 3000,CMD ["node", "server.js"], die vier Verzeichnisse werden im Abbild angelegt und gechownt (Dockerfile:55),apk upgradeundnpm uninstall -g npmim Laufzeit-Abbild.JMAP_SERVER_URLschaltet den Assistenten tatsächlich ab:detectSetupStategibtenv-managedzurück (lib/setup/state.ts:24-28), undsetupCompletewird nur von der Wizard-Abschlussroute gesetzt — der Assistent läuft also nie. Plan-Punkt 1 trägt insoweit.Zu
readOnlyRootFilesystem(noch nicht abschließend):next.config.ts:51bautoutput: "standalone", undimages.unoptimizedgilt nur imisLite-Zweig (next.config.ts:54) — die Bildoptimierung ist im normalen Abbild also aktiv und schreibt ihren Zwischenspeicher nach/app/.next/cache. Ich erwarte daher emptyDirs für/tmpund/app/.next/cache. Das ist eine Ableitung aus dem Quelltext, keine Messung — den genauen Satz belegt erst der Install-Test in der CI, und ich werde ihn dort belegen statt behaupten.Renovate-Dry-Run: noch offen. Im Workspace ist keine Renovate-CLI vorhanden (
renovate,renovate-config-validatorbeide nicht gefunden). Die Vorab-Fassungen gibt es: Tags1.11.0-beta.1bis.3neben1.11.0…1.12.0, und die Tags tragen keinv-Präfix. Ich hole den Dry-Run übernpx renovatenach, sobald die Punkte oben entschieden sind, und schreibe das Ergebnis als Begründung an den Block — keine Zeile, die nichts tut.Stand
Kein Branch, kein Commit, keine PR. Nichts im Repository angefasst — nur gelesen. Es wartet auf deine Entscheidung zu:
adminConfigReadOnly. Das ist die eine Entscheidung, die den Chart selbst ändert./api/health, Readiness mit benannter Grenze) bestätigen oder ersetzen.ADMIN_PASSWORD-Begründung und Aufnahme der Einmaligkeit; ob die #65-Analogie aus dem Rumpf gestrichen wird.SESSION_SECRET_FILEstatt Umgebungsvariable — ja oder nein.Korrekturen am freigegebenen Plan — drei Fehler, einer davon in der Rettungsanweisung
Alle drei Befunde sind gegen den Quelltext am Tag 1.12.0 nachgeprüft und bestätigt. Der Plan wird an diesen Stellen korrigiert; die Entscheidungen des Betreuers (deklarativ, Synchronisierung an, nur OIDC) bleiben gültig.
Fehler 1 — der dokumentierte Ausweg aus der Aussperrung funktioniert nicht
Der Plan nannte genau eine Tür:
helm upgrademitOAUTH_ONLY=false. Die ist verschlossen, genau wenn man sie braucht.lib/admin/config-manager.ts, wörtlich:Und
oauthOnlyist so ein Schlüssel —lib/admin/types.ts:236:Der Override liegt in
config.jsonauf der PVC und wird von der Admin-Oberfläche geschrieben, die nicht an Keycloak hängt und die man in einer Aussperrung zuerst aufsucht. Wer dort die OAuth-Einrichtung benutzt hat, hatoauthOnly: trueauf dem Volume — ab da ist der Chart-Wert wirkungslos. Die Falle, die der Plan korrekt fürJMAP_SERVER_URLbenannte, steckte in der Rettung selbst.Der Ausweg, der wirklich zurückführt, und er hängt nicht an Keycloak: in der Admin-Oberfläche (eigenes Passwort) den Override entfernen —
removeAdminOverride,DELETEaufapp/api/admin/config/route.ts. Danach greift der Wert aus dem Chart. Das ist der Satz, der inNOTES.txtund ins Wiki gehört, und er ist der Grund, warum die Admin-Oberfläche erreichbar sein muss.Zusätzlich kommt ein Wert
adminConfigReadOnly(Vorgabefalse), derADMIN_CONFIG_READONLY=truesetzt. Damit kann die Oberfläche überhaupt keinen Override schreiben, der Chart bleibt autoritativ, undOAUTH_ONLY=falsewirkt immer. Der Preis gehört an den Wert, nicht in eine Fußnote: es friert auchsetPolicyein, also Policy, Themes, Branding — und nach Fehler 3 damit auch die einzige Möglichkeit, Web-Push auf einen eigenen Relay umzustellen. Darum Vorgabefalse: wer den strengeren Modus will, wählt ihn bewusst.Das Risiko der Nur-OIDC-Entscheidung ist damit wieder begrenzt, nur anders als ich geschrieben hatte: die Tür ist die Admin-Oberfläche, nicht
helm upgrade.Fehler 2 —
/api/healthkann nicht scheitern, und das war die verlangte Messungapp/api/health/route.ts, der Erfolgspfad:Eine Probe ist unangemeldet, bekommt also
detailed = false, und damit sowohl HTTP 200 als auch den Rumpfhealthy— unabhängig vom tatsächlichen Zustand. Upstream prüft das selbst so:lib/__tests__/health-route.test.tserwartetstatus 200beipayload.status === 'degraded'.503kommt nur aus demcatch, wennprocess.memoryUsage()selbst wirft.Es ist aber nicht die Gestalt von #218 und #83, und diese Unterscheidung gehört an den Wert statt in eine pauschale Warnung: die Probe trägt echt, dass der Prozess lebt, HTTP annimmt, die Ereignisschleife frei ist und Next.js routet — ein OOM-Kill, ein Absturz, eine festsitzende Schleife fallen damit auf. Sie trägt nicht den gemeldeten Zustand und keine Aussage über Stalwart: ist der Mailserver weg, ist
/api/healthgrün.Festgelegt: alle drei Probes auf
/api/health, alsHEAD(die billigste Form, die der Endpunkt anbietet), und die Grenze wörtlich an den Readiness-Wert geschrieben. Einen besseren Endpunkt gibt es nicht —/api/setup/test-jmapliegt hinter dem Setup-Token.Fehler 3 — der Push-Relay ist umstellbar, die geplante Offenlegung war falsch
lib/push-relays.ts: die Bau-Variable verschiebt nur den eingebauten Vorgabe-Eintrag. Der wirksame Relay kommt auspolicy.json—resolveDefaultRelayUrlnimmtpolicy.pushRelayUrl, wenn gültig, und der Anbieter-Relay ist der Rückfall; mitpushRelayUrlLocked: truesind die Nutzer darauf festgenagelt.Korrekt ist zweiteilig: der Anbieter-Relay ist die Vorgabe und bleibt ohne eigenes Abbild in der Auswahlliste — aber ein eigener Relay kann wirksame Vorgabe und verbindlich werden, über die Admin-Oberfläche, nicht über einen Chart-Wert (
pushRelayUrlsteht inSettingsPolicy, nicht inCONFIG_ENV_MAP). „Nicht umstellbar" hätte Betreibern eine Wahl abgesprochen, die sie haben.Fehler 4 —
ADMIN_PASSWORD: Begründung falsch, Wert ist einmaligOhne
ADMIN_PASSWORDwird kein Zufallspasswort erzeugt und nichts nach stdout geschrieben — die Admin-Oberfläche wird schlicht abgeschaltet (lib/admin/password.ts). Der einzige Zufallswert im Projekt ist das Bootstrap-Setup-Token, das ins Zustandsverzeichnis geschrieben und nie protokolliert wird. Die Analogie zuSTALWART_RECOVERY_ADMIN(#65) trägt hier nicht und wird aus dem Plan gestrichen.Die Entscheidung, es zu setzen, bleibt — mit einer besseren Begründung: nach Fehler 1 ist die Admin-Oberfläche der Rettungsweg. Sie abzuschalten heißt, sich die Tür zu nehmen.
Und der Wert ist einmalig:
initAdminPasswordkehrt zurück, sobaldadmin.jsonexistiert, und sieht die Variable nie wieder an. Eine Drehung des Tresorfeldes wirkt danach nicht; ein Reset läuft über die Oberfläche oder über das Löschen vonadmin.json. Gleiche Ursache wie Fehler 1: Volumenzustand schlägt Chart. Gehört ins Wiki.Was übernommen wird
SESSION_SECRET_FILEexistiert und wird genommen — ein Secret wird ohnehin eingehängt, die Dateiform kostet also nichts und hält das Geheimnis aus der Prozessumgebung.ADMIN_PASSWORDhat keine Dateiform und bleibt eine Umgebungsvariable.lib/read-file-env.tsistreadFileSyncplustry/catch, ohnestatSyncund ohnemode & 0o077. Das Gruppen-Lesebit ausfsGroupist unschädlich, die Dateiform ist tragfähig.lib/oauth/token-exchange.ts), eine Drehung wirkt also ohne Neustart — aber nur, wenn das Secret ohnesubPatheingehängt wird, weil das KubeletsubPath-Mounts nie aktualisiert.app/api/admin/oauth/setup/route.tsschreibtoauthClientSecretim Klartext inconfig.jsonauf die PVC. Eine Volumensicherung kann also das OAuth-Client-Geheimnis im Klartext enthalten. Das gehört neben den Satz, dass eine Sicherung ohneSESSION_SECRETkeine Sicherung ist.SESSION_SECRETist zu Recht verpflichtend:app/api/config/route.tsschaltet die Einstellungs-Synchronisierung lautlos ab, wenn es fehlt.Offen für den nächsten Durchgang
readOnlyRootFilesystemist abgeleitet, nicht gemessen:next.config.tsbautoutput: "standalone"undimages.unoptimizedgilt nur im Lite-Zweig, die Bildoptimierung ist also aktiv und schreibt nach/app/.next/cache. Erwartet werden emptyDirs für/tmpund diesen Pfad; den genauen Satz belegt der Install-Test.npx renovate. Belegt ist bisher nur: Vorab-Fassungen existieren (1.11.0-beta.1bis.3) und die Tags tragen keinv-Präfix.Umgesetzt in PR #90 — mit fünf Korrekturen, eine davon braucht deine Bestätigung
Der Chart ist gebaut, alle Checks sind grün, das Wiki ist im selben Durchgang gelandet (
996f8c0). PR trägtPart of #89, nichtFixes— der Grund steht unter Punkt 5.Alles unten ist am Quelltext am Tag
1.12.0gemessen. Die Befunde aus den Kommentaren 5342/5349 habe ich dabei alle unabhängig nachgeprüft und bestätigt (Vorrangordnung inconfig-manager.ts,oauthOnlyintypes.ts:236und seine drei Konsumenten,/api/healthmit hartem 200,push-relays.ts,password.ts,read-file-env.ts,token-exchange.ts,session-secret.ts). Nichts davon war falsch.1. Die Begründung für
telemetry: falsestimmt so nichtDer Plan behandelt das Abschalten als Abweichung von der Abbild-Vorgabe. Gemessen:
DEFAULTSinlib/telemetry/state.tsträgtconsent: 'off'— Telemetrie ist opt-in, und der Kommentar ininstrumentation.node.ts(„on by default") ist falsch. Was das Rendern der Variable wirklich bringt, ist die Sperre:effectiveConsent()nimmt die Umgebung über die Zustandsdatei, und Upstream blockiert die Oberfläche, solange der Override gesetzt ist.Folge für 5349: es gibt nicht zwei, sondern drei Ausnahmen von „der Volumenzustand schlägt den Chart" —
SESSION_SECRET(_FILE),ADMIN_PASSWORDundBULWARK_TELEMETRY.BULWARK_UPDATE_CHECKist übrigens genau umgekehrt (an, solange nicht abgeschaltet), die beiden Werte sind also nicht symmetrisch.2.
adminConfigReadOnlykostet mehr als 5349 beziffert5349 nennt einen Preis (
setPolicyfriert ein → Policy, Themes, Branding, Push-Relay). Es sind drei, alle aus demselbenassertWritable:removeAdminOverrideist ebenfalls bewacht. Einschalten entfernt einen bestehenden Override also nicht, sondern friert ihn fest — und nimmt im selben Zug die dokumentierte Reparatur weg. Eine behebbare Aussperrung wird damit zu einer dauerhaften.initAdminPasswordpersistiert überwriteConfigData, das mitassertWritable('save admin password')beginnt. Ohne vorhandeneadmin.jsonwirft das: der Start-Hook protokolliertAdmin dashboard init skipped, der erneute Versuch in/api/admin/authwirft wieder, und der Login-Endpunkt antwortet HTTP 500 — während der PodReadyist und Mail ausliefert.Die Entscheidung (Wert einführen, Vorgabe
false) bleibt richtig und ist jetzt besser begründet. Der vollständige Preis und die einzige gefahrlose Reihenfolge stehen am Wert und inNOTES.txt.3. Die OIDC-Redirect-URI trägt die Oberflächensprache
Sie lautet
https://<domain>/<locale>/auth/callback, wobei<locale>die Sprache ist, die der Browser des Besuchers aufgelöst hat — 27 Sprachen, und der Locale-Präfix ist in diesem Abbildnever, die Sprache steht also nicht in der sichtbaren URL. Upstreams eigener Admin-Helfer registriert deshalb eine URI pro Sprache (buildRedirectUris).Eine einzeln registrierte URI ist damit die wahrscheinlichste Ursache für eine scheiternde erste Anmeldung, und das stand nirgends.
NOTES.txtnennt jetzt den Wildcard und die vollständige Sprachliste.4. Die Registry veröffentlicht jede Fassung doppelt
Die frühere Notiz sagt, die Tags trügen kein
v-Präfix. Gemessen gegen die ghcr.io-Tag-API:1.12.0undv1.12.0existieren für dasselbe Abbild, beide Familien bis1.3.9zurück. Insgesamt 315 Tags in acht Formen (dazu-always, gleitende Minors,latest/main/dev, Commit-SHAs, die Betas, Attestierungen).Der Chart pinnt die suffixfreie Form. Renovate-Dry-Run, 44.138.0,
--platform=local --dry-run=lookup, gegen die echterenovate.json5dieses PRs:1.12.0→updates: [],warnings: []1.11.0→ genau zwei:1.11.2(patch),1.12.0(minor)1.10.0→ genau eine:1.12.0(minor)Kein Lauf bot eine Beta, ein
-always, einev-Form, einen gleitenden oder einen SHA-Tag an. Also keinversioningTemplate— wie bei keycloak, nicht wie bei garage. Die Zählung und die Begründung je Form stehen im Block.Ein Vorbehalt, der im Log sichtbar war und deshalb dort steht: ghcr.io lieferte anonym keinen
releaseTimestamp, weshalb Renovate beide Updates unterminimumReleaseAgealspendingChecks: truemarkierte. Ob die geteilte Instanz die Zeitstempel sieht, habe ich nicht geprüft — sie authentifiziert sich mitGITHUB_COM_TOKEN, dieser Lauf nicht. Falls nicht, betrifft es jeden ghcr.io-Chart hier gleichermaßen und ist keine Entscheidung über diesen.5.
HEADist mit einer Kubernetes-Probe nicht herstellbar — hier weiche ich ab5349 legt fest: „alle drei Probes auf
/api/health, als HEAD (die billigste Form, die der Endpunkt anbietet)".Das geht nicht. Der API-Typ ist
HTTPGetAction; er hat kein Methodenfeld, und das Kubelet sendet immer GET. Es gibt keinen Wert in einer Pod-Spezifikation, der das ändert. Die einzige Möglichkeit für ein HEAD wäre eineexec-Probe mit einem HTTP-Client — und dieses Abbild hat wedercurlnochwget(npmundnpxsind aus der Laufzeitschicht entfernt), es wäre also alle paar Sekunden ein neuer Node-Prozess gegen einen Endpunkt, dessen GET bereits billig ist.Der Chart verwendet GET. Sachlich ändert das nichts an deiner Begründung: der Handler gibt weiterhin hart 200 zurück, setzt den Rumpf für unangemeldete Aufrufer hart auf
healthy, und sagt nichts über Stalwart. GET ist sogar geringfügig tragfähiger als HEAD, weil seincatch-Zweig 503 liefern kann, wenn die Diagnose selbst wirft — HEAD gibt ein nacktes 200 zurück und rechnet gar nichts.Das ist die einzige Entscheidung, die ich geändert statt erfragt habe, und deshalb trägt der PR
Part of #89: einFixeswürde das Issue automatisch schließen und damit eine geänderte verbindliche Festlegung als abgenommen verbuchen. Sag Bescheid, wenn du es anders willst — dann ist dieexec-Probe der Weg, mit dem Preis oben.Dazu ein Befund, der nicht im Plan stand
bulwark.stalwartAdminAccesssteht auf Upstreamsauto, also unverändert — aber die Folge gehört benannt: mitautoerreicht jeder Stalwart-Admin/adminohne das Bulwark-Admin-Passwort. Und/administ genau die Stelle, die die Overrides schreibt und den Anmeldeweg sperren kann. Meist dieselbe Person, deshalb nicht geändert;passwordverengt die Tür auf den Tresor-Inhaber. Steht am Wert und auf der Wiki-Seite.Offen geblieben
1/1 Running, 0 Restarts), also hältreadOnlyRootFilesystem: truemit genau/tmpund/app/.next/cache. Was dauerhaft offen bleibt und auf der Wiki-Seite steht: ein überflüssiger emptyDir würde so nicht auffallen.optional: true, ein Tippfehler ergibt also eine fehlende Datei und einen gesunden Pod. Nuradmin-passwordscheitert laut.config.jsonexistiert in einem CI-Lauf nie.Nebenbefund außerhalb dieses Issues
Das Wiki listet in
Chartseine Seiteconvertx, für die es im Repository kein Chart-Verzeichnis gibt. Ich habe sie nicht gelöscht — ob der Chart weg, geplant oder umgezogen ist, ist eine Entscheidung und keine Dokumentationskorrektur — aber den Zähler auf der Seite habe ich neu gemessen statt fortgeschrieben und die Abweichung dort vermerkt.Wie OIDC zwischen Bulwark, Stalwart und Keycloak wirklich läuft
Nachgetragen, weil der Plan „Keycloak als Anbieter" sagt, ohne die Kette zu benennen — und weil ein falsch gesetztes Stück beim ersten Ausrollen mit einem 401 endet, dessen Ursache man nicht sieht. Alles unten ist am Quelltext gemessen: Bulwark am Tag
1.12.0, Stalwart am Zweigmain.Es gibt zwei Topologien, und nur eine ist SSO
Stalwart kann beide Rollen spielen, und das Registry-Schema trennt sie:
OidcDirectory— Stalwart als Token-Prüfer (Resource Server). Felder:issuerUrl,requireAudience,requireScopes,claimUsername,usernameDomain,claimName,claimGroups,memberTenantId.OidcProvider— Stalwart als eigener Autorisierungsserver, mitauthCodeExpiry,refreshTokenExpiry,idTokenExpiry,userCodeExpiry(Device-Flow), eigenemsignatureKeyund Client-Registrierung.Und Bulwark wählt stillschweigend zwischen beiden.
lib/oauth/token-exchange.ts:108-112:Ist
OAUTH_ISSUER_URLleer, holt Bulwark/.well-known/oauth-authorization-servervon Stalwart und benutzt Stalwart als Aussteller — der Nutzer meldet sich dann gegen Stalwarts eigenes Verzeichnis an, nicht gegen Keycloak. Das sieht aus wie OIDC, funktioniert, und ist kein SSO.OAUTH_ISSUER_URLmuss also ausdrücklich gesetzt sein.Die Kette, die gewollt ist — ein Flow, ein Token
Belegt an drei Stellen:
app/api/auth/sso/complete/route.tslegt nach dem Callbackaccess_token,refresh_tokenundid_tokenab, in einem „Per-account refresh-token cookie slot".lib/jmap/client.ts:1194schickt es bei jeder JMAP-Anfrage mit:const headers = { …, 'Authorization': this.authHeader }.updateAccessToken) und wiederholt die Anfrage.Es gibt also keinen zweiten Flow und keine Konto-Verknüpfung. Stalwart ist keine zweite Identität, sondern ein Resource Server; die Verbindung zwischen Person und Postfach entsteht über
claimUsername— der Claim ist die Verknüpfung, es gibt keinen Zustand, der sie speichern müsste.Ein zweiter Flow entsteht nur bei einem zweiten Konto (der Slot ist je Konto), bei der Handy-Kopplung (
app/api/auth/pair/*, das Bündel trägt Token, Token-Endpunkt und dieclient_idfür die späteren Erneuerungen) und bei abgelaufenem Refresh-Token (app/api/auth/reauth/sso/complete).Zum Prüfen braucht Stalwart kein Client-Secret
Häufiger Irrtum, und die Antwort erklärt gleich, warum das Feld fehlt.
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. Ein Token zu holen braucht
client_id+client_secret— das ist Bulwarks Seite. Ein Token zu prüfen braucht nur den öffentlichen Schlüssel.Und das fehlende Secret-Feld ist eine Folge, keine Lücke:
authenticate_jwtweistHS256,HS384undHS512ab — genau die symmetrischen Verfahren, die ein gemeinsames Geheimnis bräuchten, und deren Annahme die klassische Algorithmus-Verwechslungslücke wäre. Für opake Token gilt dasselbe ohne Secret:authenticate_opaqueruftfetch_userinfo(token), der Token authentifiziert den Aufruf selbst.Die vier Stellen, an denen es scheitert
1.
OAUTH_ISSUER_URLleer → stillschweigend Stalwart-lokale Anmeldung statt SSO.2. Die Audience.
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 — dafür braucht der Client einen Audience-Mapper. Das ist der häufigste Fehler dieser Kette.
Und das Symptom ist irreführend: weil der JMAP-Client auf 401 automatisch erneuert und wiederholt, zeigt sich eine falsche Audience nicht als Fehlermeldung, sondern als Schleife — Anmeldung klappt, Rücksprung klappt, dann dreht Erneuern-und-Wiederholen. Das verleitet dazu, es als „ich muss mein Konto noch verbinden" zu deuten.
3.
claimUsernamemuss das Postfach treffen. Ist Keycloakspreferred_usernamenurjustin, das Postfach aberjustin@domain, braucht esusernameDomain.4. Die Redirect-URI trägt die Sprache —
https://<domain>/<locale>/auth/callback, 27 Locales, und weil der Präfixneverist, steht sie nicht in der sichtbaren URL. Upstreams eigener Helfer registriert eine URI pro Sprache. Wahrscheinlichste Ursache einer scheiternden ersten Anmeldung.Ein OIDC-Directory nimmt nur Bearer — und was das bedeutet
lib/.../oidc/lookup.rs:23-44, das Ende derauthenticate-Funktion:Benutzername und Passwort gegen ein OIDC-Directory funktionieren nicht. Daraus folgt zweierlei:
Die Konfiguration, die aussperrt, ist OIDC als
DirectoryBootstrapanstelle vonInternal. 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. Ersetzt manInternaldagegen durch OIDC, gibt es nirgends mehr ein Passwort, auch nicht für Stalwarts eigene Oberfläche.Und es bestätigt
OAUTH_ONLY: Bulwarks Client kenntauthMode === 'basic', also Passwort gegen Stalwart. Für ein Konto im OIDC-Directory kann dieser Weg nicht tragen — er bekäme „Unsupported credentials type". Ein sichtbares Passwortformular wäre dort eine Tür, die garantiert nicht aufgeht.Pflichtschritte vor der Installation, in
cluster-configOidcDirectoryin Stalwart:issuerUrlauf Keycloak,requireAudiencepassend zum Mapper,claimUsername(plususernameDomain, falls nötig) auf das Postfach abgebildet.Internalbleibt das Bootstrap-Verzeichnis.Mit
OAUTH_ONLYund ohne diese vier kommt niemand hinein.Nicht gemessen
Wie Stalwart ein Zugangsdatum einem Verzeichnis zuordnet, wenn mehrere existieren — der Reihe nach, oder über die Zugehörigkeit des Kontos. Dass
OidcDirectoryeinmemberTenantIdträgt, deutet auf Letzteres. Die Aussage „tritt daneben" folgt aus den Enum-Formen, nicht aus gelesenem Zuordnungscode. Das ist der eine Punkt, den ich vor dem Ausrollen belegen würde.Ebenfalls offen: der ganze Pfad ist in keinem CI-Lauf durchlaufen —
config.jsonentsteht dort nie, und ein Keycloak gibt es nicht. Die wichtigste Betriebseigenschaft dieses Charts ist damit unbelegt und kann es im Install-Test nicht werden.