diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 03c6d9f..48f5e39 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -73,7 +73,8 @@ gehen" ist dort eine schlechtere Antwort als „getestet, hier ist die Anleitung - [x] Mirror-Verfahren in interne Registry dokumentieren · 1 AT · #218 - [x] Build ohne Netz reproduzierbar (pnpm Offline-Store / reine Prebuilt-Images) · 2–3 AT · #219 -- [ ] Testlauf in netzisolierter Umgebung, Protokoll als Beleg · 2 AT · #220 +- [x] Testlauf in netzisolierter Umgebung, Protokoll als Beleg · 2 AT · #220 + → `97-isolationslauf-protokoll.md` - [ ] Offline-Update-Pfad inkl. Migrationen · 2–3 AT · #221 --- @@ -234,8 +235,9 @@ fertiges Produkt ohne Gesprächspartner. kein Ausschlusskriterium (Ist-Aufnahme I-40) - [x] Fließen Labels heute in Exporte? **Nein** — `export.service.ts` lädt `labelIds` nur für die Permission-Filterung (Ist-Aufnahme I-03a) -- [ ] Was bricht ohne Internetzugang? → wird durch den Testlauf #220 - beantwortet (Ist-Aufnahme I-28) +- [x] Was bricht ohne Internetzugang? **Nur die Mail-Zustellung** — der + Testlauf #220 zeigt null Egress-Pakete außer dem SMTP-Versuch; + Beleg: `97-isolationslauf-protokoll.md` (Ist-Aufnahme I-28) - [x] MCP-Gate-Duplikat: **divergiert nicht** — nutzt den zentralen `PermissionService`, eigenständig sind nur die Schalter (Ist-Aufnahme I-33) diff --git a/docs/vs-nfd/70-betriebshandbuch.md b/docs/vs-nfd/70-betriebshandbuch.md index 5506352..be58289 100644 --- a/docs/vs-nfd/70-betriebshandbuch.md +++ b/docs/vs-nfd/70-betriebshandbuch.md @@ -63,10 +63,17 @@ dokumentiert die realen Instanzen). einzige Grenze: das installierbare drawio-Plugin-ZIP braucht sein vorab abgelegtes Vendor-Tarball). Entscheidung und Prozedur: ADR 0024 §Decisions, Nachweis: `96-offline-build-protokoll.md`. -- ⏳ offen: Testlauf in isolierter Umgebung (#220), Offline-Update-Pfad - (#221); Meilenstein M28. Bereits vorhanden als Grundlage: alle - Dritt-Images digest-gepinnt (#203), ein authoritativer Node-Pin - (#236), SBOMs je Release (#202). +- **Isolationslauf (#220): ✅ live verifiziert.** Vollständiges + Deployment (Setup, Login, Live-Kollaboration, Suche, Upload, alle + Exporte, Backup + Restore) in einer Umgebung ohne jeden Egress-Pfad + (`internal: true`-Netze, tcpdump-Vollcapture: null Pakete nach + außen). Einzige nach außen gerichtete Verbindung ist SMTP; ohne + Egress bleibt die Instanz voll funktionsfähig, nur die + Mail-Zustellung scheitert kontrolliert (Outbox, 5 Versuche, dann + FAILED). Nachweis: `97-isolationslauf-protokoll.md`. +- ⏳ offen: Offline-Update-Pfad (#221); Meilenstein M28. Bereits + vorhanden als Grundlage: alle Dritt-Images digest-gepinnt (#203), ein + authoritativer Node-Pin (#236), SBOMs je Release (#202). ## 2 Update und Rollback diff --git a/docs/vs-nfd/97-isolationslauf-protokoll.md b/docs/vs-nfd/97-isolationslauf-protokoll.md new file mode 100644 index 0000000..f6dab0b --- /dev/null +++ b/docs/vs-nfd/97-isolationslauf-protokoll.md @@ -0,0 +1,107 @@ +# 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.