# DNS Maintenance -- Graceful Drain & Restore for the 5-Server mTLS Cluster

**Zweck:** Beim Wartungs-Reboot eines mTLS-Cluster-Servers dessen DNS-Eintraege aus Cloudflare entfernen, warten bis kein Traffic mehr ankommt, dann sauber rebooten. Nach dem Boot werden die Eintraege automatisch wiederhergestellt.

**Zielgruppe:** Cluster-Admins (matthias, dietmar, andere Ops).

**Zugehoerige Docs:** [INFRASTRUCTURE.md](INFRASTRUCTURE.md) (Cluster-Topologie), [FIPS.md](FIPS.md) (FIPS-Vorbeugung), [DEPLOYMENT.md](DEPLOYMENT.md) (Deployment-Prozesse).

---

## 1. Kurzreferenz

```bash
dns-maintenance drain-and-reboot   # Empfohlener Weg: interaktiver Pre-Drain + Reboot
dns-maintenance drain              # Nur drainen (i.d.R. via systemd ExecStop)
dns-maintenance restore            # Records wiederherstellen (i.d.R. via systemd ExecStart)
dns-maintenance status             # Aktuelle DNS-Records des eigenen Servers anzeigen
dns-maintenance verify             # Soll/Ist-Abgleich ohne Aenderung
dns-maintenance probe <ip>         # Health-Check eines Servers auf Port 443 (exit 0=up, 1=down)
dns-maintenance peer-check         # Alle Peers pruefen, Quorum-Vote + Emergency-Drain bei Ausfall
dns-maintenance test-webhook       # Slack/Canari Webhook-Konnektivitaet testen
```

Als root direkt aufrufbar (`/usr/local/sbin/` ist im PATH), als Non-Root via `sudo dns-maintenance ...`.

---

## 2. Komponenten

| Komponente | Pfad | Zweck |
|------------|------|-------|
| Script | `/root/projects/cert-pki/scripts/dns-maintenance.sh` | Hauptlogik |
| Symlink | `/usr/local/sbin/dns-maintenance` | kurzer CLI-Aufruf |
| Systemd Unit | `/etc/systemd/system/dns-maintenance.service` | Auto-Drain on Shutdown, Auto-Restore on Boot |
| State-File | `/var/run/dns-maintenance-state.json` | Drain-Historie (nach Restore automatisch entfernt) |
| Predrain-Marker | `/run/dns-maintenance-predrained` | verhindert doppelten Drain bei `drain-and-reboot` (tmpfs, selbst-cleaning auf Reboot) |
| Access-Log (zur Quiet-Detection) | `/var/log/apache2/prd-access.log` | |

---

## 3. Funktionsweise

### 3.1 Record-Modell

Jeder Cluster-Server bedient per DNS Round-Robin mehrere Hostnamen (cs.*, cert.*, ccc.* je Stage und Domain). Der Script pflegt pro Server typisch ~86-102 DNS-Records (A+AAAA auf 2 Cloudflare-Zonen, `bahn.business` + `bahn.services`). Die erwartete Liste wird im Script statisch definiert (Funktion `get_expected_hostnames`, Ausnahmen in `is_record_expected`).

**Wichtig: Nicht alle 5 Server haben dieselbe Record-Menge.** Das ist bewusst so und im Script in `is_record_expected()` kodiert:

| Regel | Was | Welche Server sind betroffen |
|-------|-----|------------------------------|
| Server 1 (Cert-Server-1-NBG) hat Zusatz-Records | ROOT_RECORDS_SRV1: `ccc.bahn.business`, `cs.bahn.business`, `ccc.bahn.services`, `cert.bahn.services`, `cs.bahn.services` (Root-Level ohne Stage) | nur Server 1 |
| TST Basis-Services auf **services** | `cs.tst.bahn.services`, `cert.tst.bahn.services`, `ccc.tst.bahn.services` als Single-Server-Deployment | nur Server 1 |
| cert.* auf **business** (ausser dev) | Diese Hostnamen nutzen CNAME → pages.dev, keine A/AAAA-Records vom Cluster | KEINER |
| -b / -bw Varianten auf **business**, PRD/ABN | Fixed-IP-Server (49.12.179.109 = `-b`, 49.12.179.110 = `-bw`) statt Cluster | KEINER (Fixed-IP) |
| -b / -bw Varianten auf **business**, DEV | Single-Server-Deployment | nur Server 1 |
| -b / -bw Varianten auf **services**, PRD | Single-Server-Deployment | nur Server 1 |
| -b / -bw Varianten auf **services**, ABN | Round-Robin (keine Ausnahme) | alle 5 Server |

**Resultat:** Server 1 trägt ~102 Records, Server 2-5 je ~86. Diese Asymmetrie ist absichtlich und nicht als Fehler zu behandeln. Bei Drain wird nur weggenommen was wirklich zum eigenen Server gehoert; bei Restore wird exakt der erwartete Satz angelegt.

**Per-Zone TTL:**
- `bahn.business` (Enterprise CF-Plan): `ZONE_TTL=60` Sekunden
- `bahn.services` (Pro CF-Plan): `ZONE_TTL=60` Sekunden
- Fallback: 60 (falls das minimum-TTL der Zone hoeher liegt -- CF-API antwortet mit Error Code 9021, Retry wird automatisch angestossen)

### 3.2 Drain-Ablauf

1. **Server-Identifikation** via `hostname` (Mapping steht im Script)
2. **Batch-Delete** aller A+AAAA-Records fuer eigene IPs via Cloudflare Batch DNS API (ein Call pro Zone statt 172 einzelne -- ~20x schneller)
3. **State-File schreiben** mit Liste der geloeschten Record-IDs
4. **Quiet-Wait-Loop** (max. 900s):
   - Alle 15s pruefen: DNS propagated? (dig-Check auf Sample-Hostnames)
   - Alle 15s zaehlen: neue Requests auf mTLS-VHost? (Apache-Access-Log der letzten 15s)
   - Wenn 300s lang **keine** neuen Requests (Quiet-Counter): Drain complete
   - Wenn Request kommt: Quiet-Counter reset + `[WARN]` loggen
   - Nach 900s Timeout: CRITICAL-Log + Exit 1

### 3.3 Restore-Ablauf

1. Apache-Status pruefen -- muss `active` sein, sonst Abbruch
2. State-File loeschen, Marker loeschen (Cleanup nach Predrain-Szenario)
3. Expected Hostnames generieren via `get_expected_hostnames($SERVER_NUM)` (Regel-basiert)
4. Vorhandene Records in CF fetchen, Abgleich: welche fehlen?
5. **Batch-POST** der fehlenden Records (pro Zone ein Call, mit `comment` = `managed by dns-maintenance on $HOSTNAME (TIMESTAMP)`)
6. Verifikation: `dig +short` Sample-Check gegen eigenen IP

**KRITISCH: Restore ist regel-basiert, nicht state-basiert.** Der Drain schreibt zwar ein State-File (`/var/lib/dns-maintenance/state.json`), aber der Restore liest es nicht -- stattdessen wird die erwartete Hostnamen-Liste aus der hardcoded Funktion `is_record_expected()` + `ROOT_RECORDS_SRV1` generiert. Das hat zwei Konsequenzen:

- **Vorteil:** Idempotent. Selbst wenn das State-File verloren geht oder der Server frisch provisioniert wird, stellt Restore den Soll-Zustand wieder her.
- **Risiko:** Wenn jemand manuell einen DNS-Record anlegt, der im Regelwerk NICHT abgebildet ist, wird ein drain+restore diesen Record loeschen und **nicht** wiederherstellen -- Datenverlust.

**Mitigationsstrategie:** `scripts/dns-audit.sh` ueberprueft cluster-weit, ob Regel-Soll == DNS-Ist. Wird routinemaessig ausgefuehrt -- insbesondere **vor** jedem geplanten Drain/Reboot. Solange das Audit gruen ist, ist drain+restore bijektiv (lossless).

### 3.3.1 Fail-Safety-Audit

`scripts/dns-audit.sh` spiegelt die `is_record_expected()`+`ROOT_RECORDS_SRV1` Logik und laeuft dagegen gegen die Live-CF-API. Output pro Server:

- `[PASS]` -- Regel-Soll == DNS-Ist (drain+restore lossless)
- `[FAIL]` mit "DNS-Records ohne Regel-Coverage" -- **Drain wuerde Records loeschen die Restore nicht wiederherstellen**
- `[INFO]` mit "Regel-Soll fehlt im DNS" -- Restore wuerde Records neu anlegen (harmlos, aber evtl. ein Hinweis auf einen laufenden/unvollstaendigen Drain-Zustand)

```bash
# Voll-Audit (exit 0 = cluster-weit sauber)
/usr/local/sbin/dns-audit

# Kurzfassung, eine Zeile pro Server
/usr/local/sbin/dns-audit --quick

# Nur ein Server pruefen
/usr/local/sbin/dns-audit --server 1
```

Das Audit ist schreibend **passiv** -- liest nur CF-API, aendert nichts.

### 3.4 Restart-Detection (seit 2026-04-21, Commits b86851a + 18de20a)

`systemctl restart dns-maintenance.service` unterscheidet sich fundamental von `systemctl reboot`. Der Service triggert in beiden Faellen seine Hooks (`ExecStop=drain`, `ExecStart=restore`) — aber nur beim echten Shutdown soll ein produktiver Drain laufen. Bei einem Service-Restart (z.B. nach Script-Update oder EnvironmentFile-Aenderung) ist die Maschine gesund, Apache bedient weiter Requests, und ein Drain waere Self-Harm (172 Records aus CF entfernen und direkt wieder anlegen).

**Detection via `systemctl is-system-running`:**

- `running` / `degraded` → System laeuft normal weiter → **Service-Restart** → Drain+Restore werden zu No-Ops
- `stopping` → shutdown.target wird angefahren → **echter Shutdown** → Drain laeuft regulaer mit Quiet-Phase

**Verhaltens-Matrix:**

| Trigger | is-system-running | STATE_FILE | Drain | Restore | Slack-Alerts |
|---------|------------------|-----------|-------|---------|--------------|
| `systemctl restart dns-maintenance.service` | running | absent | **skip** | idempotent no-op | **silent** |
| `systemctl reboot` (Shutdown-Phase) | stopping | wird geschrieben | laeuft mit Quiet-Phase | (nach Boot) | Info-Alert |
| Nach Reboot (Boot-Phase) | running | present | — | laeuft real | Info-Alert |
| Operator `dns-maintenance drain` + `restore` | running | present | laeuft | laeuft real | Info-Alert |
| Fehler-Fall egal welchen Szenarios | * | * | * | * | **CRITICAL-Alert (immer)** |

**Implementierung:**

`do_drain()` prueft `is-system-running` als erstes und returned bei `running`/`degraded` sofort ohne Drain. Log: `=== Restart detected (system-state=running). Skipping drain ===`.

`do_restore()` setzt `quiet_mode=1` wenn `is-system-running` running/degraded UND `STATE_FILE` absent (= es wurde nichts gedrained, es gibt nichts zu restoren). Der idempotente Record-Check laeuft weiterhin (Safety-Net), aber `send_alert "info"` wird uebersprungen. Log: `=== Restart detected ... Quiet mode: info alerts suppressed. ===`. Fehler- und Verify-Pending-Alerts feuern unabhaengig vom quiet_mode.

**Incident-Hintergrund:** Am 2026-04-21 cluster-weiter `systemctl restart dns-maintenance.service` im P5-Rollout (Webhook-Migration zu `/etc/cert-pki/alerting.env`) → 5× paralleler Drain, 172-204 Records pro Server kurzzeitig aus CF entfernt, Emergency-Recovery via `systemctl kill` + `dns-maintenance restore`. Siehe Troubleshooting (Abschnitt 9).

**Test:**
```bash
systemctl restart dns-maintenance.service
journalctl -u dns-maintenance.service --since "1 minute ago" | grep -E "Restart detected|Skipping drain|Quiet mode"
# Erwartet: 2 Meldungen (drain-skip + quiet-mode)
dns-maintenance verify | grep Missing:
# Erwartet: Missing: 0
```

---

### 3.5 `drain-and-reboot`-Wrapper

```
Operator SSH-Session
  │
  ├─ dns-maintenance drain-and-reboot
  │    │
  │    ├─ do_drain()  [alle DRAIN-Logs live in Shell des Operators]
  │    ├─ Marker setzen: /run/dns-maintenance-predrained
  │    ├─ exec systemctl reboot
  │    │
  │    ▼
  ├─ [systemd Shutdown-Sequence startet]
  │    │
  │    ├─ dns-maintenance.service ExecStop
  │    │    → do_drain() sieht Marker → SKIPPED (kein zweiter Drain)
  │    │
  │    ├─ cloudflared, rsyslog, wazuh, apache2, ssh, systemd-user-sessions
  │    │   STOPPEN ERST JETZT (via After= Kette in der Unit)
  │    │
  │    ▼
  ├─ [Reboot]
  │
  ▼
  [Boot]
  ├─ dns-maintenance.service ExecStart
  │    → do_restore() erstellt 172 Records neu mit comment-Feld
  │
  └─ Cluster wieder vollstaendig
```

**Warum dieser Wrapper?**
Im normalen ExecStop-Drain-Modus (d.h. bei `sudo reboot` ohne Wrapper) stirbt die SSH-Session des Operators **sofort** beim Shutdown-Start (session-NNNN.scope hat `Before=shutdown.target`, wird parallel zur drain-and-maintenance gestoppt -- nicht per `After=` steuerbar, weil Scopes dynamisch sind). Der Operator sieht den Drain-Fortschritt nicht.

Der `drain-and-reboot` Wrapper fuehrt den Drain **vor** dem systemctl-reboot-Call aus -- also noch waehrend die Session lebt. Der Operator sieht alle Log-Zeilen live. Erst am Ende (nach DRAIN COMPLETE) wird der eigentliche Reboot getriggert -- da faellt die Session, aber der Drain ist bereits sauber durch.

---

## 4. systemd Unit

`/etc/systemd/system/dns-maintenance.service`:

```ini
[Unit]
Description=DNS Maintenance - Graceful drain/restore for mTLS cluster
After=network-online.target apache2.service ssh.service cloudflared.service rsyslog.service wazuh-agent.service systemd-user-sessions.service
Before=shutdown.target reboot.target halt.target poweroff.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
EnvironmentFile=/etc/environment
ExecStart=/usr/local/sbin/dns-maintenance restore
ExecStop=/usr/local/sbin/dns-maintenance drain
TimeoutStartSec=300
TimeoutStopSec=960

[Install]
WantedBy=multi-user.target
```

**Design-Rationales der Directives:**

| Direktive | Warum |
|-----------|-------|
| `After=apache2 ssh cloudflared rsyslog wazuh systemd-user-sessions` | systemd-Stop-Reihenfolge ist **umgekehrt** zur Start-Reihenfolge. Unser Service stoppt vor diesen -- sie bleiben waehrend des Drains aktiv und empfangen/protokollieren Traffic korrekt. |
| `Before=shutdown.target reboot.target halt.target poweroff.target` | Stellt sicher dass unser Drain abgeschlossen ist, bevor shutdown/reboot/halt/poweroff fortschreiten. Cover alle 3 Modi + Generic `shutdown.target`. |
| `systemd-user-sessions` in `After=` | Dieser Service erstellt `/run/nologin` beim Stop (triggert `pam_nologin` → blockiert neue Logins). Wenn er **nach** uns stoppt, koennen SSH-Re-Logins waehrend des Drain-Fensters weiterhin kommen (z.B. Operator via Cloudflare-Tunnel). Ohne diese Dependency wird der File sofort beim Shutdown-Start geschrieben. |
| `Type=oneshot` + `RemainAfterExit=yes` | Der Service hat kein Dauerprozess -- ExecStart/ExecStop sind kurze Aktionen. RemainAfterExit behaelt "active"-State, damit ExecStop beim naechsten Stop auch wirklich laeuft. |
| `TimeoutStartSec=300` | Restore via Batch-API dauert ~5s, Puffer fuer DNS-Propagation-Check. |
| `TimeoutStopSec=960` | Drain-Timeout ist 900s, +60s Puffer bevor systemd SIGKILL wuerde. |

---

## 5. Cloudflare Batch API

Script nutzt [`POST /zones/{zone_id}/dns_records/batch`](https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-batch-dns-records) statt Einzelaufrufe.

**Vorteile:**
- Pro Zone ein HTTP-Call (statt ~80 einzeln) → Drain-Delete ~5s statt ~90s
- Atomare Operation: entweder alle Records oder keiner (Rollback bei Fehler)
- Kein Rate-Limit-Problem

**Retry-Logik bei TTL-Reject (`batch_dns_operations_with_retry`):**
1. Primary: bekommen `zone_ttl` je Zone (60/60)
2. Wenn CF mit Error-Message `/ttl|minimum/i` antwortet → Body-Rewrite mit `DNS_TTL_FALLBACK=60`
3. Retry einmalig, dann endgueltige Entscheidung

---

## 6. Auslaesekurven

| Szenario | Empfohlener Weg |
|----------|-----------------|
| Geplanter Wartungs-Reboot eines Servers | `sudo dns-maintenance drain-and-reboot` |
| Automatischer Reboot (z.B. unattended-upgrades Kernel-Update) | Triggert ExecStop drain → Reboot → ExecStart restore (transparent) |
| Nur Drain ohne Reboot (z.B. vor laengerer Wartung, Kernel-Upgrade ohne Reboot) | `sudo dns-maintenance drain` + manuell irgendwann `sudo dns-maintenance restore` |
| `systemctl restart dns-maintenance.service` (Script-Update, EnvironmentFile-Aenderung) | Kein Drain, kein produktives Restore, keine Slack-Alerts — dank Restart-Detection (Abschnitt 3.4). Service kehrt in <5s active zurueck |
| Verify ob DNS-State stimmt (cron-artig) | `dns-maintenance verify` |
| Was habe ich gerade an Records? | `dns-maintenance status` |

---

## 7. Pre-Reboot Safety Checks

Vor JEDEM `drain-and-reboot` (oder `sudo reboot` ohne Wrapper) empfohlen:

```bash
# 1. FIPS-Check (sonst Server bootet in Hang, siehe docs/FIPS.md)
[ "$(cat /proc/sys/crypto/fips_enabled 2>/dev/null || echo 0)" = "0" ] && \
[ "$(uname -r | grep -c fips)" = "0" ] && \
echo "FIPS clean - safe" || echo "FIPS RISK - DO NOT REBOOT"

# 2. Full cluster audit vom Reference-Server aus
verify-deployment.sh --quiet | tail -10
# Alle Server 0 FAIL -> grunes Licht
```

---

## 8. Syslog-Integration

Alle Script-Ausgaben gehen parallel an:
- **stderr/stdout** der eigenen Shell (interaktiver Aufruf)
- **syslog** via `logger -t dns-maintenance -p local0.{info,warning,err,crit}`

Wazuh + NewRelic greifen Syslog ab → Alerts bei `[CRITICAL]` (Drain-Timeout, Restore-FAIL) oder `[ERROR]` (Single-Record-Fail).

Log-Levels:
- `[INFO]` normaler Ablauf
- `[WARN]` erholbare Anomalie (z.B. quiet-Reset wegen spaetem Request, TTL-Fallback griff, dig-Verifikation verzoegert)
- `[ERROR]` Einzelfehler (z.B. Restore konnte einen Record nicht anlegen; Script laeuft weiter)
- `[CRITICAL]` Abbruch-bedingter Fehler (Timeout, kompletter Restore-Fail)

---

## 9. Troubleshooting

### Drain TIMEOUT nach 900s

**Symptom:** `CRITICAL DRAIN TIMEOUT after 900s ... quiet=N/300s`

**Ursache:** Es kommen kontinuierlich Requests rein, die den Quiet-Counter zuruecksetzen.

**Analyse:**
```bash
# Welche IPs/Clients kommen durch den Filter?
tail -200 /var/log/apache2/prd-access.log | awk '{print $3}' | sort | uniq -c | sort -rn | head
# → welche Client-IP dominiert?

tail -200 /var/log/apache2/prd-access.log | awk '{print $3,$4}' | sort | uniq -c | sort -rn | head
# → welcher Client/Cert-ID dominiert?
```

**Haeufige Ursachen & Abhilfen:**

1. **Cluster-Peer-Traffic nicht gefiltert** (v1.9-Fix): Der Access-Log-Filter muss das `mtls_vhost` LogFormat beruecksichtigen (`%v:%p %{Host}i %h ...` — Client-IP ist Feld 3, nicht Feld 1). Wenn nur Cluster-IPs im Output der Analyse erscheinen → Filter-Pattern pruefen.

2. **Externe Clients mit gecachtem DNS**: Keepalive-Clients, die DNS-Aenderung nicht mitbekommen (z.B. `HttpClient/3.14159` Dauer-Polling).
   - `DRAIN_QUIET_REQUIRED` temporaer reduzieren (z.B. 60s statt 300s), wenn akzeptabler Risikograd
   - Keepalive-Timeout in Apache senken, damit Clients reconnecten (laengerfristig)
   - Den bestimmten Polling-Client identifizieren und mit Operator abklaeren

### Restore: "dig verifications pending (DNS propagation delay)"

**Symptom:** Restore meldet `[WARN] 4 dig verifications pending`.

**Ursache:** Records sind bei CF angelegt, aber lokale Resolver haben noch gecachte negative Antworten (NXDOMAIN). Bei TTL=30/60 aber schnell weg.

**Erwartetes Verhalten, kein Fix noetig.** Nach 60s-300s sollte `dig` clean auflosen.

### NBG0-spezifisch: FIPS-Risiko beim Reboot

**Hat in der Vergangenheit einen kompletten Hang verursacht.** Siehe [FIPS.md](FIPS.md) fuer Details. `verify-deployment.sh` Check 17 detektiert die Falle heute.

### Drain unerwartet bei `systemctl restart` (vor v2.0, gefixt)

**Symptom (historisch):** `systemctl restart dns-maintenance.service` loeste einen Drain aus — 172 DNS-Records pro Server aus Cloudflare entfernt und nach Restart neu angelegt. Slack zeigte `DRAIN gestartet` + `RESTORE abgeschlossen` fuer einen Restart ohne echten Shutdown der Maschine.

**Root Cause:** Die Service-Unit definiert `ExecStop=dns-maintenance drain`. systemd fuehrt beim Restart immer ExecStop → ExecStart aus — also lief der Drain auch wenn die Maschine gesund weiterlief und Apache Requests bediente. Kein Bug in systemd, sondern fehlende Restart-Detection im Script.

**Fix:**
- `b86851a` (do_drain): `systemctl is-system-running` Check. Bei state=running/degraded wird Drain uebersprungen (siehe 3.4).
- `18de20a` (do_restore): analoger Quiet-Mode, Slack-Info-Alerts werden bei No-Op-Restore unterdrueckt. Fehler- und Propagation-Warnungen bleiben aktiv.

**Post-Mortem 2026-04-21:** Cluster-weiter `systemctl restart` im P5-Rollout (Webhook-Migration) → 5 parallele Drains. Operator-Fehler (ich) verschlimmerte das durch paralleles Emergency-`dns-maintenance restore` mit bash `&`-Loop → auf Cert-Server-HEL entstand Race-Condition zwischen drei gleichzeitigen Restore-Calls, Cloudflare API returnte Code `81058` ("identical record already exists") 49× → Slack CRITICAL-Alert `RESTORE mit 49 FEHLERN abgeschlossen`. Post-Recovery-State-Check zeigte `Missing=0` cluster-weit: keine Datenverluste, keine Traffic-Verluste (off-hours, 0 active connections).

**Lehre:** `dns-maintenance restore` ist **nicht race-safe** bei parallelen Aufrufen. Emergency-Recovery auf mehreren Servern immer sequentiell loopen, niemals mit `&`.

**Erkennung (nach Fix):**
```bash
# Restart-Detection greift, wenn Journal nach systemctl restart zeigt:
journalctl -u dns-maintenance.service --since "1 minute ago" | grep "Restart detected"
# Zwei Meldungen erwartet:
#   === Restart detected (system-state=running). Skipping drain ... ===
#   === Restart detected (state=running, no drain state-file). Quiet mode ... ===
```

### Session-Abbruch waehrend Drain (nicht verhinderbar)

Wenn Operator `sudo reboot` (nicht `drain-and-reboot`) aus einer Shell tippt, wird die SSH-Session sofort beendet -- weil `session-NNNN.scope` `Before=shutdown.target` implizit hat. Kein Bug, systemd-Architektur.

**Workarounds:**
1. `drain-and-reboot` Wrapper benutzen (Drain **vor** dem Shutdown-Start)
2. Reboot ueber externe SSH ausloesen (von Laptop: `ssh server sudo reboot` -- eigene Session geht nicht)
3. `tmux`/`screen` nutzen, reboot darin triggern, nach Boot reattachen

### verify-deployment historische Failures (2026-04-16 bis 2026-04-20, resolved)

Deep-Log-Audit am 2026-04-21 foerderte **drei** historische Failures zu Tage, alle vor heutiger Failsafe-Installation. Zwei davon selbes root cause, einer fundamental anders:

**Event 1 — .6 2026-04-17 16:10:48 (Timeout, Default 120s gerissen)**
- Service timeout nach 120s, Result=`timeout`, Status 15/TERM
- HEL-Latenz × 21 Checks × 5 Server = 100-120s regulaer -> im P95-Bereich
- Kein OnFailure-Hook installiert -> **Silent Failure**

**Event 2 — .6 2026-04-20 16:19:49 (Timeout, Default 120s gerissen)**
- Identisch zu Event 1 (selbe Root Cause, erneut im rotierenden 16:05-Slot)
- Zweite Wiederholung bestaetigt: kein Einzelfall, Default-Timeout war strukturell zu knapp fuer HEL

**Event 3 — .4 2026-04-16 12:13:20 (exit-code=1, KEIN Timeout)**
- Script lief vollstaendig durch (13s CPU-Zeit), fand aber **8 FAIL cluster-weit**: `Cluster-Check von Cert-Server-0-FSN: 8 FAIL, all_ok=0`
- Exit-Code 1 weil `all_ok=0` -> systemd markierte Service als `Failed with result 'exit-code'`
- **Anderes Root Cause**: realer cluster-degradation-event waehrend P5-Entwicklungsphase (Webhook-Scripts in aktiver Aenderung, nicht alle 5 Server noch konsistent)
- `verify-and-alert.sh` postete in diesem Fall trotzdem einen Slack-CRITICAL-Alert (der Script-Pfad laeuft unabhaengig vom systemd-OnFailure-Hook) -> Operator wurde informiert, nur der systemd-Level-Failsafe fehlte noch

**Gemeinsame Root Cause (Events 1+2): 17h-Luecke zwischen "committed" und "deployed"**
- Git-Commit `28f15d7 feat: Slack failsafe via systemd OnFailure for verify-deployment` war bereits vor 2026-04-17 in `origin/pki-public`
- Die zugehoerigen Drop-Ins `/etc/systemd/system/verify-deployment.service.d/onfailure.conf` (`OnFailure=verify-deployment-alert@%n.service`) und `timeout.conf` (`TimeoutStartSec=300`) waren aber noch **nicht installiert** (mtime auf allen 5 Servern = 2026-04-21 09:36)
- Bei Events 1+2 existierte folglich kein `OnFailure=`-Hook -> systemd-Level-Failsafe konnte nicht feuern. Script-Level-Slack-Posting in `verify-and-alert.sh` feuert nur wenn der Script vollstaendig durchlaeuft -- bei Timeout wurde er mit SIGTERM gekillt, bevor der Slack-POST ausgefuehrt war.

**Fix (2026-04-21 09:36 deployed):**
- `TimeoutStartSec=300` Drop-In (timeout.conf) -- cluster-einheitlich auf allen 5 Servern. Ausreichend fuer HEL's 22ms RTT × 28 Checks × 5 Server (P95 ~150s, Default 120s war strukturell zu knapp)
- `OnFailure=verify-deployment-alert@%n.service` Drop-In (onfailure.conf) feuert bei jedem Timeout/Crash/SIGKILL/Nonzero-Exit -- unabhaengig davon ob der Script den POST schafft
- Template-Unit `verify-deployment-alert@.service` + Script `alert-service-failure.sh` installiert -> Slack-Alert mit Hostname, IPv4, Unit-Name, Reason, SSH-Link, letzte 20 Journal-Zeilen

**Verifikation (2026-04-21):**
- Alle 5 Server haben Drop-Ins installiert (mtime 2026-04-21 09:36), verify-deployment Check #22 prueft `verify-deployment.timer` kontinuierlich
- .6 Lauf heute 09:32:37 dauerte **122s** -- haette unter Default-120s-Timeout erneut gerissen, aber mit TimeoutStartSec=300 safely durchgelaufen
- .6 Lauf heute 16:08:23 dauerte nur 46s (cluster jetzt durchgaengig clean nach P5/P6-Consolidation)

**Lehren:**
1. **Committed != Deployed** (Events 1+2): Git-Push und cluster-weitem `/etc/systemd/system/`-Install koennen auseinanderlaufen. BACKLOG-Idee #19: Drift-Check (conf-Datei-mtime vs. Commit-Zeit).
2. **Skript-Level-Alert != systemd-Level-Alert** (Event 3 vs 1+2): `verify-and-alert.sh` postet bei `all_ok=0` direkt zu Slack (Event 3: ja, Alert gefeuert), aber bei Timeout wird der Script gekillt bevor er POST-en kann (Events 1+2: nein, silent). Deshalb braucht man BEIDE Layers: den Script-Alert fuer echte Cluster-Fails und den systemd-OnFailure fuer Timeouts/Crashes.

**Failsafe jetzt testen (jederzeit safe, feuert Test-Slack-Alert):**
```bash
systemd-run --unit=test-failsafe \
  --property='OnFailure=verify-deployment-alert@test-failsafe.service' \
  /bin/false
# Erwartet: Slack-Alert mit result=exit-code state=failed/failed exit=1
systemctl reset-failed test-failsafe.service 'verify-deployment-alert@test-failsafe.service'
```

---

## 10. Deployment-Prozess

Komplette Deployment-Reihenfolge fuer einen neuen / re-installed Server:

```bash
# 1. Script + dns-audit + Unit + Symlinks + apt-jq installieren
scp scripts/dns-maintenance.sh <server>:/root/projects/cert-pki/scripts/
scp scripts/dns-audit.sh       <server>:/root/projects/cert-pki/scripts/
scp scripts/dns-maintenance.service <server>:/etc/systemd/system/
ssh <server> bash -s <<'EOF'
  chmod +x /root/projects/cert-pki/scripts/dns-maintenance.sh /root/projects/cert-pki/scripts/dns-audit.sh
  ln -sf /root/projects/cert-pki/scripts/dns-maintenance.sh /usr/local/sbin/dns-maintenance
  ln -sf /root/projects/cert-pki/scripts/dns-audit.sh       /usr/local/sbin/dns-audit
  snap remove jq 2>/dev/null || true
  apt-get install -y jq
  systemctl daemon-reload
  systemctl enable --now dns-maintenance.service
EOF

# 2. CF-Tokens in /etc/environment hinterlegen
# CF_TOKEN_BUSINESS=...
# CF_TOKEN_SERVICES=...

# 3. Verify-Run ausloesen
verify-deployment.sh <nummer>

# 4. Fail-Safety Audit
/usr/local/sbin/dns-audit --quick

# Alle 28 Checks OK? Audit gruen? -> ready
```

`verify-deployment.sh` Check-Liste (seit 2026-04-21 P6-Konsolidierung: 28 Checks):
1. SSH reachable
2. dns-maintenance.sh present + checksum matches reference server (frueher doppelt als Check 2 und 18 gefuehrt, in P6 konsolidiert)
3. Symlink korrekt
4. dns-maintenance.service installiert
5. Service enabled
6. Service active
7. CF_TOKEN_BUSINESS+SERVICES gesetzt
8. Apache active
9. Apache-Config identisch mit Referenz
10. Letsencrypt-Cert mind. 14 Tage gueltig
11. Wazuh active
12. Cloudflared active
13. DNS verify 0 missing
14. Kernel: running vs cluster-newest + installed (erkennt reboot-needed)
15. Pending apt updates
16. jq apt-packaged (nicht Snap)
17. **FIPS inactive (runtime=0, kernel=non-fips, grub.d=0, fips-kernel-pkgs=0)**
18. **Claude Code Version** (verglichen gegen cluster-newest)
19. **known_hosts SSH-Banner-Freshness** — vergleicht gespeicherten Banner in `/root/.ssh/known_hosts` mit Live-`ssh-keyscan`; bei Abweichung Auto-Refresh (`ssh-keygen -R` + `ssh-keyscan -H`). Erkennt Paket-Wechsel des sshd (z. B. FIPS-Installation/Entfernung)
20. **Reboot/Restart needed** — `/var/run/reboot-required` Flag (kanonischer Ubuntu-Reboot-Indikator nach libc6/systemd/dbus/openssl-Upgrades) oder `needrestart -b` Service-Liste (Services die veraltete Libraries linken). Ergaenzt Test #14 (nur Kernel-Vergleich)
21. **ALERT_WEBHOOK_SLACK** Pruefung: bevorzugt `/etc/cert-pki/alerting.env` (mode 0600), Fallback `/etc/environment` (legacy). FAIL wenn Secret world-readable oder fehlt.
22. **verify-deployment.timer** enabled + active (monitoring-of-monitoring)
23. **Time sync** via `timedatectl NTPSynchronized=yes` (systemd-timesyncd oder chrony)
24. **Live TLS handshake** auf `localhost:443` mit SNI-aware openssl s_client — beweist Apache serviert das echte Cert, nicht nur haelt die Datei
25. **Disk /** < 85% used
26. **Memory** < 90% used
27. **CPU load** 1-min avg < 2 × nproc
28. **Cloudflared tunnel** readyConnections via `curl http://127.0.0.1:20241/ready` (QUIC-basiert, nicht via `ss -t` sichtbar)

---

## 11. Version & Historie

- **v1.0** (2026-04-14): Initialversion, Einzel-API, 60s-TTL einheitlich, `drain|restore|status|verify`
- **v1.1** (2026-04-15): Systemd-Unit mit `After=apache2 ssh`
- **v1.2** (2026-04-15): jq-Snap-Bug auf HEL gefixt (apt-jq), 5-Min-Stille-Anforderung (300s)
- **v1.3** (2026-04-15): Batch-API, Per-Zone-TTL mit Fallback
- **v1.4** (2026-04-15): `After=` erweitert um cloudflared, rsyslog, wazuh-agent (durch FSN1-Reboot-Test entdeckt)
- **v1.5** (2026-04-15): `After=` erweitert um systemd-user-sessions (PAM-Fix)
- **v1.6** (2026-04-15): `drain-and-reboot` Wrapper + Predrain-Marker + comment-Feld in Records
- **v1.7** (2026-04-15): Dokumentation konsolidiert ([docs/DNS-MAINTENANCE.md](DNS-MAINTENANCE.md) + [docs/FIPS.md](FIPS.md))
- **v1.8** (2026-04-16): Drain quiet-period filtert Cluster-Peer-IPs (Bug: Peer-Checks verhinderten 300s Stille); Webhook error-logging (HTTP-Statuscode statt `|| true`); `/etc/environment` auto-sourcing fuer interaktive Sessions; neues Subcommand `test-webhook`
- **v1.9** (2026-04-17): Fix: Access-Log-Filter matcht Client-IP jetzt im dritten Feld (`%h`) statt am Zeilenanfang — `mtls_vhost` LogFormat beginnt mit `%v:%p %{Host}i`, nicht mit `%h`. Ohne diesen Fix griff der Cluster-IP-Filter nie und Peer-Health-Checks verhinderten dauerhaft die 300s Quiet-Period
- **v2.0** (2026-04-21): **Restart-Detection** (Commits `b86851a` + `18de20a`). `do_drain()` prueft `systemctl is-system-running` und ueberspringt sich selbst bei Service-Restart (state=running/degraded). `do_restore()` analog mit Quiet-Mode wenn kein STATE_FILE vorliegt — idempotente Arbeit laeuft, aber keine Info-Slack-Alerts. Fehler-Alerts bleiben in allen Faellen aktiv. Incident-getrieben nach P5-Rollout-Drama mit 5 parallelen ungewollten Drains (siehe Abschnitt 9)

---

## 12. Siehe auch

- **[DNS-MAPPING.md](DNS-MAPPING.md) -- Canonical Source of Truth fuer alle DNS-Records, Routing-Regeln, Ghost-Vhosts, Fixed-IP-Regel.** Bei Konflikten mit anderen Doku-Dateien gilt DNS-MAPPING.md.
- [INFRASTRUCTURE.md](INFRASTRUCTURE.md) -- Cluster-Topologie, Netzwerk, Cloudflare-Zonen
- [FIPS.md](FIPS.md) -- FIPS-Problem und Vorbeugung (kritisch fuer Reboot-Safety)
- [CLAUDE.md](../CLAUDE.md) -- Kontext fuer Claude Code
- `scripts/dns-maintenance.sh` -- Hauptscript (drain/restore/status/verify/drain-and-reboot/test-webhook)
- `scripts/dns-audit.sh` -- Fail-Safety-Audit (Regel-Soll ↔ DNS-Ist)
- `scripts/verify-deployment.sh` -- 28-Check Cluster-Audit (SSH, Scripts, systemd-Units, Certs, Kernel, DNS, Apache, Cloudflared-Tunnel, Disk/Memory/CPU, Webhook-Env)
- `scripts/verify-and-alert.sh` -- Cluster-Check mit Slack/Canari Alerting
