Full deployment exercised in a compose stack whose networks are all internal: true — setup, login, live collaboration, search, upload, all export formats, backup and restore. tcpdump full capture on both bridges: zero packets leave the isolated subnets; the only outbound attempt the application makes is SMTP, which fails contained in the outbox (5 retries, then FAILED) while the instance stays fully functional. The restore finding became #288, fixed earlier in this chain and re-verified in the same stack. Plan checkbox P1-3 and the I-28 open question ticked; operations handbook airgap section updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
108 lines
6.6 KiB
Markdown
108 lines
6.6 KiB
Markdown
# Isolationslauf-Protokoll — Deployment in netzisolierter Umgebung (Issue #220)
|
||
|
||
Nachweis für Maßnahmenplan P1-3 (ADR 0024): ein vollständiges Deployment
|
||
läuft mit blockiertem Egress. Beantwortet zugleich die offene Frage der
|
||
Ist-Aufnahme I-28 („Was bricht ohne Internetzugang?"). Belegstufe:
|
||
**live verifiziert** — alle Ergebnisse stammen aus dem beschriebenen
|
||
Lauf, Abweichungen sind ausgewiesen.
|
||
|
||
- **Datum:** 2026-07-31
|
||
- **Host:** lokaler Docker-Host (aarch64); Compose-Projekt
|
||
`dorfteich-isolated` aus dem Produktions-Compose
|
||
(`deploy/compose/docker-compose.yml`) plus Override.
|
||
- **Netzisolation:** beide Compose-Netze (`frontend`, `internal`) mit
|
||
`internal: true` — Docker legt die Bridges ohne NAT/Default-Route an;
|
||
es existiert kein Egress-Pfad. Port-Publishing ist dadurch wirkungslos,
|
||
alle Prüfzugriffe erfolgen aus einem Treiber-Container
|
||
(`node:22.15.1-alpine`, fetch-basierte Prüfskripte) im `frontend`-Netz
|
||
gegen `http://api:3000`.
|
||
- **Eigene Images:** `dorfteich-iso-{web,api,collab,backup}:isolated`,
|
||
lokal gebaut aus `main@a758c9d`; das backup-Image nach dem Befund
|
||
unten neu aus `4f6596e` (#288). Image-IDs: web `9d65c54f05fc`, api
|
||
`1826dedd662a`, collab `8a1b2841b91c`, backup `117ff18c7e71`.
|
||
- **Dritt-Images (digest-gepinnt, Compose-Pins):**
|
||
`postgres:17.5-alpine@sha256:6567bca8…`,
|
||
`pandoc/core:3.6@sha256:5b8a29d9…`,
|
||
`gotenberg/gotenberg:8@sha256:67097317…`.
|
||
- **Egress-Beweisführung:** `tcpdump -n -l not arp` auf beiden
|
||
Bridge-Interfaces des Hosts über die gesamte Laufzeit (Setup bis nach
|
||
dem Restore-Re-Run).
|
||
|
||
## Prüfschritte und Ergebnisse
|
||
|
||
| Schritt | Ergebnis |
|
||
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||
| Installation / First-Run-Setup | ✅ migrate-on-start (41 Migrationen) + Pre-Seed-Setup über `SETUP_*`-Env (Registrierung closed) |
|
||
| `/readyz` | ✅ alle Checks ok (backup-Check erst nach dem ersten Backup-Lauf, s. Abweichung 1) |
|
||
| Login (Session-Cookie) | ✅ Status 200 |
|
||
| Seite anlegen | ✅ Seite `3331f670…` im Personal-Teich |
|
||
| Live-Kollaboration | ✅ echter Collab-Client (HocuspocusProvider) über den collab-Dienst, Inhalt synced + persistiert |
|
||
| Markdown-Export | ✅ 78 Zeichen, Inhalt vorhanden |
|
||
| Suche (Postgres-Volltext) | ✅ 1 Treffer auf den Seiteninhalt |
|
||
| Datei-Upload + Download | ✅ 201 / 200 |
|
||
| DOCX-Export (pandoc-Sidecar) | ✅ 9 926 Bytes |
|
||
| PDF-Export (Gotenberg-Sidecar) | ✅ 20 903 Bytes |
|
||
| Mail-Enqueue | ✅ 204 — Zustellverhalten s. eigener Abschnitt |
|
||
| Backup | ✅ Set `20260731-132200` (Dump + Volume-Archiv), readyz-backup-Check danach ok |
|
||
| Restore | ✅ nach Fix #288 — Exit 0, Inhalt exakt der Backup-Stand (s. Abweichung 2) |
|
||
|
||
## Egress-Bilanz
|
||
|
||
Vollständige Capture-Auswertung (alle Pakete, beide Bridges):
|
||
|
||
| Bridge | Pakete | Ziel-Verteilung |
|
||
| ---------- | ------ | --------------------------------------------------------------------------- |
|
||
| `internal` | 37 897 | ausnahmslos `192.168.156.0/24` → `192.168.156.0/24` (api/collab/backup ↔ db) |
|
||
| `frontend` | 448 | ausnahmslos `192.168.158.0/24` → `192.168.158.0/24` (Treiber ↔ api/Sidecars) |
|
||
|
||
Einzige weitere Frames: 2 × IPv6 Link-Local → `ff02::16` (MLDv2
|
||
Multicast Listener Report, Kernel-Housekeeping der Bridge — kein
|
||
Egress). **Kein einziges Paket an ein Ziel außerhalb der isolierten
|
||
Subnetze, keine DNS-Anfragen auf den Bridges.** Die Anwendung
|
||
unternimmt genau **einen** nach außen gerichteten Verbindungsversuch:
|
||
SMTP (s. u.). Damit ist I-28 beantwortet: ohne Internetzugang bricht
|
||
nichts außer der Mail-**Zustellung**; Telemetrie-, Update-, CDN- oder
|
||
Font-Abrufe existieren nicht (bestätigt die Ist-Aufnahme §0.2).
|
||
|
||
## SMTP-Verhalten ohne Egress
|
||
|
||
Die einzige nach außen gerichtete Verbindung; genau der Punkt, den eine
|
||
Behörde zulassen oder untersagen wird:
|
||
|
||
- `POST`-Auslöser antwortet **204** — die Outbox entkoppelt Annahme und
|
||
Zustellung; kein Nutzer-Request hängt oder scheitert an fehlendem
|
||
Egress.
|
||
- Der Zustell-Worker scheitert bei jedem Versuch an der
|
||
DNS-Auflösung: `getaddrinfo EAI_AGAIN smtp.egress-nachweis.example`
|
||
(Dockers embedded DNS löst in `internal`-Netzen nicht extern auf; auf
|
||
den Bridges ist dabei kein Paket sichtbar).
|
||
- Retry-Leiter: 5 Versuche, danach Log
|
||
`mail delivery failed permanently` (Level warn), Outbox-Status
|
||
`FAILED` mit `last_error`. Die Instanz bleibt voll funktionsfähig.
|
||
- **Betriebsempfehlung:** entweder ein von der Behörde zugelassenes
|
||
internes SMTP-Relay konfigurieren oder bewusst ohne Mail betreiben
|
||
(Einschränkung: keine Verifikations-/Benachrichtigungs-Mails;
|
||
Site-Admin legt Konten dann manuell verifiziert an).
|
||
|
||
## Abweichungen und Befunde
|
||
|
||
1. **readyz-backup-Check `warn` vor dem ersten Backup-Lauf** („no backup
|
||
status recorded yet") — erwartetes Verhalten des Status-File-Checks,
|
||
nach dem ersten Lauf `ok`. Keine Maßnahme.
|
||
2. **Restore meldete FAILED trotz inhaltlich korrekter
|
||
Wiederherstellung** → Befund **#288**: `pg_restore --clean` scheiterte
|
||
an den geerbten Partitions-PKs von `read_events` (partitioniert seit
|
||
#224), Exit ≠ 0 bei 4 ignorierten Fehlern. Fix (Schema-Reset vor
|
||
`pg_restore`, macht den Restore zugleich exakt) in derselben
|
||
PR-Kette; Re-Run im selben isolierten Stack: Exit 0, bewusst
|
||
angelegte Post-Backup-Tabelle entfernt, Seiteninhalt und alle
|
||
`read_events`-Partitionen intakt, `/readyz` danach vollständig grün.
|
||
|
||
## Rückfluss (Akzeptanzkriterium)
|
||
|
||
- **#288** — aus dem Lauf entstandener Fix, in der M28-PR-Kette.
|
||
- **#221** — der Offline-Update-Pfad wird in derselben isolierten
|
||
Umgebung mit denselben Images geprobt.
|
||
- **#229** — Betriebshandbuch §1 (Airgap-Variante) auf diesen Nachweis
|
||
aktualisiert.
|