Neuer Chart: bulwark (JMAP-Webmail-Client fuer Stalwart) 1.0.0+up1.12.0 #89

Closed
opened 2026-10-05 21:55:23 +02:00 by jlab-bot · 5 comments
Member

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.

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.
Author
Member

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.

# 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`.
Author
Member

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.
# 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.
Author
Member

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)
  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:

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:

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.
# 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.
Author
Member

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.

# 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.
Author
Member

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:

// 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:

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:

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:

_ => 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:

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.

# 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jlab-cluster/helm-charts#89