Files
bf-infra/docs/tp16.3-bf-public-ap-connector-runbook.md
Christoph Emsenhuber b697ffc6fa TP16.3/TP31 Schritt 5: Vor-Ort-Runbook fuer bf-public-Connector (ap1/ap2/ap3)
Runbook docs/tp16.3-bf-public-ap-connector-runbook.md fuer die Vor-Ort-Session
geschrieben; docs/tp31-schritt5-runbook.md abgeloest (read-only-Header).

Live-Befunde 2026-08-30, die den Plan aendern:

- ap3 ist neu und NICHT baugleich: cAP ax (cAPGi-5HaxD2HaxD, 10.40.99.13),
  arm64/96 MB frei, aber ROS 7.24.1 gegen 7.24 auf ap1/ap2 => zwei
  verschiedene container.npk. device-mode=basic statt home (fetch bereits an).
- Blocker A: bf-public-Ingress zeigt auf :80, Caddy antwortet dort mit 308 auf
  https => Redirect-Schleife ueber CF fuer alle vier Namen. Faellt heute nicht
  auf, weil die Alttunnel an Caddy vorbei direkt aufs Backend gehen.
  Fix: https://10.40.20.40:443 + originServerName je Regel (ueber 443 4x 200).
- Blocker B: es gibt KEINE Freigabe vlan99 -> vlan20:443. Die forward-Kette
  wurde vollstaendig gelesen (36 Regeln); die einzige MGMT->INFRA-Regel ist ein
  src-port=8728-Rueckweg. Der Vorgaenger-Runbook behauptet das Gegenteil
  ("Regel 27 deckt vlan99->vlan20:80 ab") - Regel 27 ist IOT->INFRA.
- Blocker C: ROUTER_AP3_PASS fehlt in ~/.secrets/credentials.env (nur in
  ~/.claude.json). Nebenbefund: credentials.env laesst sich in zsh nicht
  sourcen (rohe WireGuard-Zeilen ab ~Z.69) - Variablen bleiben still leer.
- Kein PoE an beiden CRS326 => kein Remote-Power-Cycle, Bestaetigung physisch.
- Die sechs abzubauenden Container auf allen drei Hosts verifiziert;
  terpene-cloudflared und fm.emsenhuber.net bleiben.

CF-Rollback-Snapshots unter docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json
neu angelegt (vier Ingress-Configs + CNAMEs inkl. Record-IDs) - es gab sie
vorher nicht.

Quick-Index, TP16.3-Abschnitt und Ist-Stand-Block mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:07:17 +02:00

575 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TP16.3 / TP31 Schritt 5 — Runbook: `bf-public`-Connector auf ap1 / ap2 / **ap3**, CNAME-Flip, Abbau
> **Stand 2026-08-30, live gelesen. Vorbereitet, NICHT ausgeführt.**
> **Löst [`tp31-schritt5-runbook.md`](tp31-schritt5-runbook.md) ab** (Stand 08-27). Jenes Dokument
> kennt ap3 nicht, unterstellt einen Registry-Pull (geht bei `fetch: no` nicht) und enthält **eine
> nachweislich falsche Freigabe-Behauptung** (§8 Falle 2). Es bleibt als Historie liegen, ist aber
> **nicht mehr auszuführen**.
**Charakter:** Vor-Ort-Session. Der `device-mode`-Wechsel verlangt an **jedem** der drei APs eine
**physische** Bestätigung — ap1 + ap3 in **Technik UG**, ap2 in **Technik OG**.
---
## §0 Ist-Stand (live gelesen 2026-08-30, ~20:45)
### Die drei APs — **sie sind NICHT gleich**
| AP | IP | Modell | Arch | ROS **live** | Flash frei | RAM frei | `device-mode` | `container` | `fetch` |
|---|---|---|---|---|---|---|---|---|---|
| **ap1** | 10.40.99.11 | hAP ax² (`C52iG`) | arm64 | **7.24** | 95 MB / 128 | 686 MB | `home` | ❌ `no` | ❌ `no` |
| **ap2** | 10.40.99.12 | hAP ax² (`C52iG`) | arm64 | **7.24** | 95 MB / 128 | 694 MB | `home` | ❌ `no` | ❌ `no` |
| **ap3** | 10.40.99.13 | **cAP ax** (`cAPGi`) | arm64 | **7.24.1** | 96 MB / 128 | 689 MB | **`basic`** | ❌ `no` | ✅ `yes` |
🔴 **Zwei Unterschiede, die je einen eigenen Arbeitsschritt kosten:**
1. **ap3 läuft 7.24.1, ap1/ap2 laufen 7.24.** Ein `container.npk` **muss exakt zur RouterOS-Version
passen** — sonst bleibt es nach dem Reboot schlicht deaktiviert. ⇒ **zwei verschiedene Downloads**,
nicht einer. Nicht aus Bequemlichkeit dieselbe Datei auf alle drei schieben.
2. **ap3 steht auf `device-mode=basic`**, nicht `home` — dort sind `fetch`/`scheduler` bereits erlaubt.
Das ändert am `container`-Flag nichts (auch dort `no`, auch dort physische Bestätigung), macht ap3
aber zum einzigen AP, der theoretisch aus der Registry ziehen könnte. **Wir nutzen das bewusst
nicht** — gleicher `.tar`-Weg auf allen dreien, damit alle drei dasselbe Image fahren.
*(Auf ap3 lief zum Lesezeitpunkt 4 h Uptime — frisch in Betrieb. Uplink laut Registry `core ether21`.)*
### Cloudflare
| Tunnel | ID | Status | Connectoren |
|---|---|---|---|
| **bf-public** | `48d3b0b6-7233-4781-a76a-1de4a5e957ce` | **inactive** | **0** ← Ziel dieser Session |
| bf-mm | `7a322261-380a-447b-97d2-8ebce99f5ed1` | healthy | 8 |
| bf-nas | `220c9958-401b-4111-9451-f3db09bba2d5` | healthy | 8 |
| bf-pve | `f7fdf3a6-b67d-4567-93b1-b0f273794d09` | healthy | 8 |
| `fm.emsenhuber.net` | `b697d124-…` | healthy | 4 | 🚫 **Standort FM — nicht anfassen** |
| `terpene-db` | `b83b68f0-…` | healthy | 4 | 🚫 **anderes Projekt — nicht anfassen** |
**Heutige Wege der vier öffentlichen Namen** (alle noch **an Caddy vorbei**, direkt aufs Backend):
| Name | CNAME → Tunnel | Ingress-Ziel |
|---|---|---|
| `n8n` | bf-mm | `http://10.40.20.167:5678` |
| `gitea` | bf-nas | `http://10.40.20.157:3000` |
| `vw` | bf-nas | `http://10.40.20.127` |
| `hass` | bf-pve | `http://10.40.20.105:8123` |
**`bf-public` hat bereits alle vier Ingress-Regeln** → `http://10.40.20.40:80` (Caddy) + Catch-all 404.
⚠️ Genau dieses `:80` ist Blocker A, siehe §1.1.
### Caddy (`caddy-bf1`, LXC 130, `10.40.20.40`) — gemessen
| Name | `:80` | `:443` (SNI korrekt) |
|---|---|---|
| `vw` / `hass` / `n8n` / `gitea` | **308 → `https://…`** | **200** |
### Die sechs Container, die am Ende weg müssen — alle drei Hosts verifiziert
| Host | Zugang | Container |
|---|---|---|
| LXC 400 (pve) | `ssh root@10.40.20.100` → `pct exec 400 -- docker` | `cloudflared-bfmm`, `cloudflared-bfnas` |
| macmini | `ssh macmini` | `cloudflared-bfpve`, `cloudflared-bfnas` |
| NAS DS923 | Portainer `endpointId=14` | `cloudflared-bfpve`, `cloudflared-bfmm` |
🚫 **`terpene-cloudflared` auf LXC 400 bleibt** (Tunnel `terpene-db`, anderes Projekt). Steht im
`docker ps` **direkt neben** den beiden zu löschenden — beim Löschen den Namen lesen, nicht die Zeile zählen.
### Rollback-Material (2026-08-30 **neu angelegt**, es gab es vorher nicht)
```
docs/cf-backup/bf-public-ingress-20260830-vor-tp16.3-schnitt.json
docs/cf-backup/bf-mm-ingress-20260830-vor-tp16.3-schnitt.json
docs/cf-backup/bf-nas-ingress-20260830-vor-tp16.3-schnitt.json
docs/cf-backup/bf-pve-ingress-20260830-vor-tp16.3-schnitt.json
docs/cf-backup/cnames-tunnel-20260830-vor-tp16.3-schnitt.json # inkl. Record-IDs für den Flip-Rollback
```
---
## §1 Drei Blocker — **remote zu erledigen, BEVOR jemand in den Keller geht**
> Alle drei sind vom Schreibtisch aus lösbar. Wer sie offen lässt, steht vor Ort vor einem
> Connector, der `healthy` meldet, während die Seite eine Redirect-Schleife dreht.
### 1.1 🔴 Blocker A — `bf-public` zeigt auf `:80`, und Caddy antwortet dort mit 308
Gemessen: `curl -H "Host: vw.emsenhuber.net" http://10.40.20.40/` → **`308` → `Location: https://vw.emsenhuber.net/`**.
Der Weg wäre also: Browser → CF → Tunnel → Caddy `:80` → **308 zurück auf denselben öffentlichen
Namen** → CF → Tunnel → Caddy `:80` → … ⇒ **`ERR_TOO_MANY_REDIRECTS`**, für alle vier Namen.
Das fällt heute niemandem auf, weil die drei Alttunnel **an Caddy vorbei** direkt aufs Backend gehen
(§0) — die Schleife entsteht **erst** mit dem Umstieg auf Caddy als Origin.
**Fix:** Origin auf `https://10.40.20.40:443` mit **`originServerName` je Regel** (Caddy hält für alle
vier Namen gültige LE-Zertifikate ⇒ **kein** `noTLSVerify` nötig, die Kette bleibt geprüft):
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
TID=48d3b0b6-7233-4781-a76a-1de4a5e957ce
curl -s -X PUT -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$TID/configurations" \
-d "{\"config\":{\"ingress\":[
{\"hostname\":\"vw.emsenhuber.net\",\"service\":\"https://10.40.20.40:443\",\"originRequest\":{\"originServerName\":\"vw.emsenhuber.net\"}},
{\"hostname\":\"hass.emsenhuber.net\",\"service\":\"https://10.40.20.40:443\",\"originRequest\":{\"originServerName\":\"hass.emsenhuber.net\"}},
{\"hostname\":\"n8n.emsenhuber.net\",\"service\":\"https://10.40.20.40:443\",\"originRequest\":{\"originServerName\":\"n8n.emsenhuber.net\"}},
{\"hostname\":\"gitea.emsenhuber.net\",\"service\":\"https://10.40.20.40:443\",\"originRequest\":{\"originServerName\":\"gitea.emsenhuber.net\"}},
{\"service\":\"http_status:404\"}]}}" | jq -r ".success"'
```
**Abnahme:** `.success == true`, danach Ingress zurücklesen und die vier `https://…:443` sehen.
### 1.2 🔴 Blocker B — die Firewall lässt MGMT → Caddy **nicht** durch
Die `forward`-Kette des Core wurde vollständig gelesen (36 Regeln, Nr. 35 = `TP5: deny-by-default`).
**Es gibt keine Regel `vlan99 → vlan20` für 80/443.** Die einzige Regel in dieser Richtung ist
```
26 TP5-E2: MGMT->INFRA api-rueckweg
chain=forward accept protocol=tcp in-interface=vlan99 out-interface=vlan20 src-port=8728
```
— ein **Rückweg** der RouterOS-API, `src-port`, nicht `dst-port`. Sie trägt den Connector nicht.
🔴 **Der Vorgänger-Runbook behauptet in §0 wörtlich, „Regel 27 `MGMT→INFRA TCP` deckt `vlan99→vlan20:80`
bereits ab ✅". Das ist falsch** — Regel 27 ist `IOT->INFRA http-rueckweg` (`in-interface=vlan30`).
Ohne die Regel unten läuft der Connector in Regel 34 (`TP5-WOULD-DROP`, Log) und Regel 35 (Drop).
**Fix** (vor die Log-Regel setzen, nicht ans Ende):
```
/ip firewall filter add chain=forward action=accept protocol=tcp \
in-interface=vlan99 out-interface=vlan20 dst-address=10.40.20.40 dst-port=443 \
comment="TP31: AP-cloudflared -> caddy-bf1" \
place-before=[find comment~"TP5: log-only"]
```
Über MikroMCP: `plan_changes` mit `dryRun:true`, dann `apply_plan`.
**Was NICHT gebraucht wird:**
- **Egress zu Cloudflare** (QUIC udp/7844): Regel 13 `TP5-E2: LAN->WAN` (`out-interface-list=WAN`,
ohne `in-interface`-Einschränkung) deckt das ab. ✅
- **DNS für den Container**: Regeln 30–33 erlauben `LAN → 10.40.20.36/.37:53` aus **jedem** VLAN. ✅
⇒ im Container `dns=10.40.20.36` setzen.
⚠️ **Damit die Regel greift, muss der Container-Verkehr auch wirklich als `vlan99` ankommen** — das
`veth`-Netz `172.17.0.0/24` ist dem Core unbekannt und hätte keinen Rückweg. Deshalb ist das
`masquerade` in §3.4 **nicht optional**, sondern die Voraussetzung dieser Regel.
### 1.3 🟡 Blocker C — `ROUTER_AP3_PASS` fehlt in `~/.secrets/credentials.env`
Vorhanden sind `ROUTER_AP1_PASS`, `ROUTER_AP2_PASS`, `CRS326_CORE_PASS`, `CRS326_BRANCH_PASS`.
Das **ap3-Passwort steht nur in `~/.claude.json`** (MCP-Env) — gegen die globale Konvention
(`~/.secrets` ist der Credential-Store) **und** ein Problem für §3.2: der Reboot-Weg liest
`SSHPASS` aus `~/.secrets`.
```bash
# Wert aus ~/.claude.json übernehmen und ergänzen — Wert dabei NICHT auf stdout bringen:
printf 'ROUTER_AP3_USER=admin\nROUTER_AP3_PASS=%s\n' "$(python3 -c "
import json;print(json.load(open('$HOME/.claude.json'))['mcpServers']['mikromcp']['env']['ROUTER_AP3_PASS'])")" \
>> ~/.secrets/credentials.env
chmod 600 ~/.secrets/credentials.env
grep -c '^ROUTER_AP3_PASS=' ~/.secrets/credentials.env # muss 1 sein
```
⚠️ **`~/.secrets/credentials.env` ist kein sauberes `KEY=VALUE`-File** — ab Zeile ~69 stehen rohe
WireGuard-Zeilen (`Endpoint:`, `AllowedIPs:`, `PersistentKeepalive:`). In **zsh** bricht
`source` daran ab und lässt die Variablen **leer**, ohne Fehler. Alle Snippets hier laufen deshalb
in `bash -c '…'`. **Wer das übersieht, jagt einen „ungültigen Token", der in Wahrheit ein leerer ist.**
---
## §2 Vorbereitung am Schreibtisch (vor der Fahrt, mit Internet)
### 2.1 Die **zwei** `container.npk` holen — Versionen nicht mischen
```bash
mkdir -p ~/tp16.3 && cd ~/tp16.3
# ap1 + ap2 (7.24)
curl -fLO https://download.mikrotik.com/routeros/7.24/all_packages-arm64-7.24.zip
unzip -o -j all_packages-arm64-7.24.zip 'container-7.24-arm64.npk'
# ap3 (7.24.1)
curl -fLO https://download.mikrotik.com/routeros/7.24.1/all_packages-arm64-7.24.1.zip
unzip -o -j all_packages-arm64-7.24.1.zip 'container-7.24.1-arm64.npk'
ls -la container-*.npk
```
*(Beide URLs am 2026-08-30 mit `HTTP 200` verifiziert.)*
### 2.2 cloudflared als `.tar` bauen — **Registry-Pull ist auf ap1/ap2 gesperrt** (`fetch: no`)
Auf dem macmini (hat Docker, Image liegt schon lokal):
```bash
ssh macmini 'docker pull --platform linux/arm64 \
cloudflare/cloudflared@sha256:6d91c121b803126f7a5344005d17a9324788fc09d305b6e2560ec6040a7ae283 \
&& docker tag cloudflare/cloudflared@sha256:6d91c121b803126f7a5344005d17a9324788fc09d305b6e2560ec6040a7ae283 cfd:2026.6.1 \
&& docker save cfd:2026.6.1 -o /tmp/cloudflared-arm64.tar && ls -la /tmp/cloudflared-arm64.tar'
scp macmini:/tmp/cloudflared-arm64.tar ~/tp16.3/
```
**Gepinnt auf `cloudflared 2026.6.1`** (Digest `sha256:6d91c121…`, arm64, Image 27,5 MB) — damit alle
drei APs nachweislich dasselbe fahren und ein späterer `:latest`-Sprung nichts still verschiebt.
### 2.3 Tunnel-Token bereitlegen
Der Token liegt bereits als `BF_PUBLIC_TUNNEL_TOKEN` in `~/.secrets/credentials.env`. Frisch ziehen
(z. B. nach Token-Rotation) — **Ausgabe nie in eine Kommandozeile oder ins Transkript**:
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
TID=48d3b0b6-7233-4781-a76a-1de4a5e957ce
T=$(curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$TID/token" | jq -r .result)
echo "Token-Länge: ${#T}" # nur die Länge, nie der Wert
printf "%s" "$T" > ~/tp16.3/bf-public.token; chmod 600 ~/tp16.3/bf-public.token'
```
### 2.4 Backups je AP
```bash
# je AP, über MikroMCP oder SSH:
/system backup save name=vor-tp16.3-ap1 # bzw. -ap2 / -ap3
/export file=vor-tp16.3-ap1
```
---
## §3 Vor Ort — **je AP einzeln, komplett durchziehen, dann der nächste**
> **Reihenfolge: ap3 → ap2 → ap1.**
> ap3 zuerst, weil dort am wenigsten hängt (frisch in Betrieb, 4 h Uptime) — wenn der Ablauf
> irgendwo klemmt, klemmt er am unkritischsten Gerät. **ap1 zuletzt**, weil an ihm die
> `crs326-core`-Seite des WLAN hängt.
>
> ⚠️ **Jeder Schritt hier trennt kurzzeitig das WLAN dieses APs** (zwei Reboots). Die CAPs holen
> sich ihre Config nach dem Boot vom Manager (`crs326-core`) per **L2-Discovery auf `vlan99`** —
> das kommt von selbst zurück, braucht aber ~1–2 min. **Nicht ungeduldig nachprovisionieren.**
### 3.1 `.npk` hochladen — **die richtige Datei zum richtigen AP**
| AP | Datei |
|---|---|
| ap1 (`.99.11`) | `container-7.24-arm64.npk` |
| ap2 (`.99.12`) | `container-7.24-arm64.npk` |
| **ap3 (`.99.13`)** | **`container-7.24.1-arm64.npk`** |
```bash
cd ~/tp16.3
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
SSHPASS="$ROUTER_AP3_PASS" sshpass -e scp -O container-7.24.1-arm64.npk admin@10.40.99.13:'
```
🔴 **Niemals `sshpass -p`** — das schreibt das Passwort in die Kommandozeile und damit in `ps` und
jeden argv-Trace. **Genau so ist am 2026-08-22 das Core-Passwort in ein Transkript geraten.**
`-e` hält es in der Umgebung des Kindprozesses.
Gegenprobe vor dem Reboot — **die Datei muss wirklich liegen**, sonst rebootet man umsonst:
```
/file print where name~"container"
```
### 3.2 Reboot (installiert das Paket)
🔴 **Der Reboot geht NICHT über MikroMCP.** Das `reboot`-Tool sendet einen `delay`-Parameter, den die
REST-API auf ROS 7.24 nicht kennt (`unknown parameter delay`); `run_command` ist für `/system reboot*`
per Deny-Policy gesperrt. Der Weg ist SSH:
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
SSHPASS="$ROUTER_AP3_PASS" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
admin@10.40.99.13 "/system reboot"'
```
**Abnahme nach ~90 s:**
```
/system package print
# muss jetzt DREI Pakete zeigen: routeros, wifi-qcom, container — alle gleiche Version
```
⚠️ **Steht `container` da mit abweichender Version oder `disabled=yes`, war es die falsche `.npk`.**
Dann: Datei löschen, richtige hochladen, erneut rebooten. Nicht weitermachen.
### 3.3 `device-mode` — **der physische Schritt**
```
/system device-mode update container=yes
```
Die Session **bricht danach ab** und RouterOS wartet auf eine Bestätigung **am Gerät** mit einer
Frist (typisch **5 Minuten**, die Konsole nennt die genaue Deadline). Bestätigt wird durch:
**Weg A — Kaltstart (empfohlen, eindeutig):**
> **Stecker ziehen, ~5 Sekunden warten, wieder einstecken.**
🔴 **Wichtig: die APs hängen NICHT an PoE aus den Switches** — beide CRS326 haben nachweislich gar
kein PoE-Menü (`bad command name poe`). Es gibt also **keinen** Weg, das aus der Ferne zu tun; der
Strom kommt vom jeweiligen Netzteil bzw. Injektor am AP.
**Weg B — Taster:**
> **Reset-Knopf am laufenden Gerät kurz drücken und loslassen.** Am hAP ax² sitzt er an der
> Unterseite, am cAP ax unter der Abdeckung an der Rückseite.
⚠️ **Weg B nur am LAUFENDEN Gerät und nur kurz.** Den Reset-Knopf **während des Bootens** gedrückt zu
halten ist etwas völlig anderes — das setzt die Konfiguration zurück bzw. geht nach Netinstall.
Wer unsicher ist, nimmt **Weg A**; ein Kaltstart kann nicht versehentlich zum Werksreset werden.
Verstreicht die Frist ohne Bestätigung, passiert **nichts** — die Änderung verfällt, das Gerät bleibt
wie es war. Dann einfach neu absetzen.
**Abnahme:**
```
/system device-mode print
# container: yes
```
### 3.4 Container-Netz anlegen
```
/interface veth add name=veth-cf address=172.17.0.2/24 gateway=172.17.0.1
/ip address add address=172.17.0.1/24 interface=veth-cf
/ip firewall nat add chain=srcnat action=masquerade src-address=172.17.0.0/24 \
comment="TP31: cloudflared egress"
```
Das `masquerade` ist **Pflicht**, nicht Kosmetik — siehe §1.2: ohne es kommen die Pakete mit
`172.17.0.2` am Core an, wo weder Route noch Firewall-Regel sie kennt.
### 3.5 Image importieren und Container bauen
```bash
# .tar hochladen (~28 MB)
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
SSHPASS="$ROUTER_AP3_PASS" sshpass -e scp -O ~/tp16.3/cloudflared-arm64.tar admin@10.40.99.13:'
```
Auf dem AP:
```
/container config set tmpdir=tmp
/container add file=cloudflared-arm64.tar interface=veth-cf root-dir=cf \
dns=10.40.20.36 logging=yes start-on-boot=yes \
cmd="tunnel --no-autoupdate run --token=<TOKEN>"
```
🔑 **Token-Handling:** den Wert aus `~/tp16.3/bf-public.token` einsetzen. Sauberer als ihn in die
`cmd` zu schreiben ist eine Env-Variable — dann steht er nicht im `/container print`:
```
/container envs add name=cfd key=TUNNEL_TOKEN value="<TOKEN>"
/container envs add name=cfd key=TUNNEL_TRANSPORT_PROTOCOL value=quic
# und im add: envlist=cfd cmd="tunnel --no-autoupdate run"
```
Starten und aufräumen:
```
/container start [find root-dir=cf]
/container print # status: running
/file remove [find name~"cloudflared-arm64.tar"] # ~28 MB Flash zurückholen
```
⚠️ **Platzrechnung:** 95 MB frei − 28 MB `.tar` − ~28 MB entpackte Layer ≈ **39 MB Rest**, und das
nur bis der `.tar` gelöscht ist. Es geht aus, aber **ohne Reserve für einen zweiten Container** —
das ist die harte Grenze aus TP31, kein Richtwert.
### 3.6 Abnahme **auf diesem AP**
```
/log print where message~"cloudflared" or message~"tunnel"
# erwartet: "Registered tunnel connection" (mehrfach, je CF-Edge)
```
Und **die Strecke zu Caddy separat prüfen** — sie ist der Teil, den Blocker B betraf:
```
/tool fetch url="https://10.40.20.40/" check-certificate=no output=none
```
*(Auf ap1/ap2 ist `fetch` per `device-mode=home` gesperrt — dort stattdessen aus dem Container heraus
oder über den Drop-Log gegenprüfen, siehe §8.)*
**Der Drop-Log ist das erste Diagnosewerkzeug, nicht das letzte:**
`10.40.20.18:514`, Dateien auf dem Docker-LXC unter `/opt/tp5-syslog/log/`, Präfix `TP5-WOULD-DROP`.
Taucht dort `10.40.99.13 → 10.40.20.40:443` auf, fehlt die Regel aus §1.2.
### 3.7 Erst wenn dieser AP sauber ist: nächster AP
---
## §4 Abnahme des Tunnels (nach allen drei APs)
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/48d3b0b6-7233-4781-a76a-1de4a5e957ce" \
| jq "{status:.result.status, conns:(.result.connections|length)}"'
```
**Kriterium: `status: "healthy"`, `conns` > 0** (erfahrungsgemäß 4 CF-Edge-Verbindungen je Connector,
bei drei APs also Richtung 12).
🔴 **`healthy` heißt „der Connector redet mit Cloudflare" — nicht „das Origin antwortet".** Der Beweis
für die Strecke bis Caddy ist §5.1, nicht diese Zahl. Genau diese Verwechslung hat beim
`grafana`-Fall monatelang einen toten Dienst gesund aussehen lassen.
---
## §5 CNAME-Flip — einzeln, mit Abnahme dazwischen
**Reihenfolge: `n8n` → `gitea` → `hass` → `vw`** (steigende Kritikalität; `vw` zuletzt).
Zone-ID `1b5bc02fdf61b6b989afde543f3f4134`.
### 5.1 Vorab: Testname statt Produktivname
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
Z=1b5bc02fdf61b6b989afde543f3f4134
curl -s -X POST -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$Z/dns_records" \
-d "{\"type\":\"CNAME\",\"name\":\"test-tp163\",\"content\":\"48d3b0b6-7233-4781-a76a-1de4a5e957ce.cfargotunnel.com\",\"proxied\":true}" | jq -r .success'
```
Dazu **eine Ingress-Regel** für `test-tp163.emsenhuber.net` → `https://10.40.20.40:443` mit
`originServerName: vw.emsenhuber.net` in `bf-public` ergänzen. Dann **von außerhalb des LAN, ohne
WARP** aufrufen. Erwartet: Access-Login, **keine Redirect-Schleife**.
⇒ Erst wenn das steht, wird ein Produktivname angefasst. Danach Testname + Testregel wieder entfernen.
### 5.2 Flip je Name
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
Z=1b5bc02fdf61b6b989afde543f3f4134
N=n8n # danach: gitea, hass, vw
ID=$(curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$Z/dns_records?name=$N.emsenhuber.net" | jq -r ".result[0].id")
curl -s -X PATCH -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
"https://api.cloudflare.com/client/v4/zones/$Z/dns_records/$ID" \
-d "{\"content\":\"48d3b0b6-7233-4781-a76a-1de4a5e957ce.cfargotunnel.com\",\"proxied\":true}" | jq -r .success'
```
### 5.3 Abnahme je Name — **von außerhalb des LAN, WARP AUS**
- [ ] Aufruf über Mobilfunk (oder anderer Standort) → Access-Login → **200**
- [ ] **Kein** `ERR_TOO_MANY_REDIRECTS` (das wäre Blocker A)
- [ ] Caddy sieht den Request: `ssh root@10.40.20.100 'pct exec 130 -- journalctl -u caddy -n 20'`
- [ ] 15 min beobachten, kein 530/1033
🔴 **WARP muss aus sein.** Mit aktivem WARP läuft der Test über den Mesh und beweist den öffentlichen
Weg **nicht** — dieselbe Klasse Fehldiagnose wie beim ICMP-Fall 2026-08-13.
**Namensspezifisch:**
- **`gitea`** — Access gilt **nur der Web-UI**, Git-/API-Pfade laufen daran vorbei. **Vor** dem Flip
einen Push von außen testen, **nach** dem Flip denselben Push wiederholen.
- **`hass`** — die Companion-App kann kein Access-Login. Nach dem Flip App auf dem Handy **über
Mobilfunk** prüfen, nicht nur den Browser.
- **`vw`** — Bitwarden läuft auf **Access-Bypass**. **Vor** dem Flip prüfen, dass die Bypass-Policy
für `vw.emsenhuber.net` steht; sonst sperrt der Flip alle Bitwarden-Clients aus.
---
## §6 Abbau — **frühestens nach 7 Tagen störungsfreiem Betrieb**
> Die drei Alttunnel sind die Rückfallebene. Solange sie stehen, ist jeder Flip eine
> Ein-Feld-Änderung zurück. **Nicht am selben Abend abbauen.**
### 6.1 Erst die Container stoppen, dann die Tunnel löschen
Umgekehrt genommen läuft man in 530er von Connectoren, die auf einen gelöschten Tunnel zeigen.
```bash
# LXC 400 auf pve — terpene-cloudflared NICHT anfassen
ssh root@10.40.20.100 'pct exec 400 -- docker rm -f cloudflared-bfmm cloudflared-bfnas'
ssh root@10.40.20.100 'pct exec 400 -- docker ps --format "{{.Names}}"' # terpene-cloudflared muss bleiben
# macmini
ssh macmini 'docker rm -f cloudflared-bfpve cloudflared-bfnas && docker ps --format "{{.Names}}"'
```
NAS (DS923, Portainer `endpointId=14`, Key aus `mcp/portainer/.env`):
```bash
bash -c 'set -a; . ~/dev/bf-infra/mcp/portainer/.env; set +a
K="${PORTAINER_API_KEY:-$PORTAINER_TOKEN}"; U="${PORTAINER_URL:-https://10.40.20.126:9443}"
for c in cloudflared-bfpve cloudflared-bfmm; do
ID=$(curl -sk -H "X-API-Key: $K" "$U/api/endpoints/14/docker/containers/json?all=true" \
| jq -r ".[]|select(.Names[0]==\"/$c\")|.Id")
curl -sk -X DELETE -H "X-API-Key: $K" "$U/api/endpoints/14/docker/containers/$ID?force=true"
echo "removed $c"
done'
```
⚠️ Sind es **Stacks** statt einzelner Container, kommen sie sonst beim nächsten Stack-Deploy zurück —
dann den Stack-Eintrag entfernen, nicht nur den Container.
### 6.2 Dann die drei Tunnel löschen
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
for T in 7a322261-380a-447b-97d2-8ebce99f5ed1 220c9958-401b-4111-9451-f3db09bba2d5 f7fdf3a6-b67d-4567-93b1-b0f273794d09; do
curl -s -X DELETE -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$T" | jq -r "{id:\"$T\",ok:.success}"
done'
```
🚫 **Nicht in dieser Schleife und nirgends sonst:** `b697d124-…` (`fm.emsenhuber.net`, Standort FM)
und `b83b68f0-…` (`terpene-db`). **IDs vor dem Absetzen einzeln gegen §0 prüfen.**
### 6.3 Schlusskontrolle
```bash
bash -c 'set -a; . ~/.secrets/credentials.env; set +a
curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel?is_deleted=false" \
| jq -r ".result[]|\"\(.name)\t\(.status)\tconns=\(.connections|length)\""'
```
**Soll:** `bf-public healthy`, `fm.emsenhuber.net healthy`, `terpene-db healthy` — sonst nichts.
---
## §7 Rollback
| Lage | Rückweg |
|---|---|
| Ein Name kaputt nach Flip | CNAME zurück auf die alte Tunnel-ID — Werte in `cnames-tunnel-20260830-…json` (inkl. Record-ID) |
| `bf-public`-Ingress kaputt | `PUT …/configurations` mit `bf-public-ingress-20260830-…json` |
| Container auf einem AP stört | `/container stop` + `/container remove`, WLAN läuft davon unabhängig |
| AP-Config verkorkst | `/system backup load name=vor-tp16.3-apX` |
| Firewall-Regel unerwünscht | `/ip firewall filter remove [find comment~"TP31: AP-cloudflared"]` |
**`device-mode=container:yes` ist nicht Teil des Rollbacks.** Zurückdrehen ginge nur mit erneutem
physischem Gang. Es schadet nicht — ohne laufenden Container ist es ein gesetztes Flag, sonst nichts.
---
## §8 Fallen
1. **Zwei ROS-Versionen, zwei `.npk`.** ap1/ap2 = 7.24, ap3 = **7.24.1**. Falsche Datei ⇒ Paket bleibt
nach dem Reboot inaktiv. **Nach jedem Reboot `/system package print` lesen**, nicht annehmen.
2. **Die Freigabe `vlan99 → vlan20:443` existiert nicht** — entgegen der Behauptung im
Vorgänger-Runbook. Ohne §1.2 landet der Connector im `TP5-WOULD-DROP`-Log. **Erst dort nachsehen.**
3. **Caddy antwortet auf `:80` mit 308.** Ein Ingress auf `:80` erzeugt eine Redirect-Schleife —
und zwar **erst** beim Umstieg auf Caddy, weil die Altwege am Proxy vorbeigehen.
4. **`healthy` ≠ „Dienst erreichbar".** Der Tunnelstatus sagt nichts über das Origin; die
Wildcard-Access-App `*.emsenhuber.net` liefert für **jeden** Namen ein sauberes 302. Ein toter
Dienst sieht so monatelang gesund aus (der `grafana`-Fall).
5. **Kein `sshpass -p`.** Nur `-e` mit `SSHPASS` aus der Umgebung.
6. **`~/.secrets/credentials.env` in zsh sourcen schlägt still fehl** (rohe WireGuard-Zeilen ab ~Z. 69)
— Variablen bleiben **leer**, die API meldet einen „ungültigen Token". Immer `bash -c`.
7. **`terpene-cloudflared` steht direkt neben den zu löschenden Containern.** Namen lesen.
8. **Ein Container pro AP, keiner mehr.** Nach dem Import bleiben ~39 MB. Caddy oder AGH gehören
nicht dazu (die eine Config ändert sich ständig, die andere schreibt dauernd auf NAND).
9. **Reboot nie über MikroMCP** (`unknown parameter delay`), sondern per SSH.
10. **WLAN-Abnahme ist eine frische DHCP-Lease je SSID, nicht die Registration-Table.** Nach zwei
Reboots je AP lohnt der Blick — die Tabelle zeigte beim CAPsMAN-Ausfall 14 h lang zufriedene
Clients ohne Adresse.
---
## §9 Ehrliche Einschränkung — was diese Session **nicht** kauft
Solange Caddy **nur** auf `caddy-bf1` (LXC 130, pve) läuft, ist das Origin aller drei Connectoren
**derselbe Rechner**. Drei Connectoren auf drei APs kaufen damit **Hardware-Diversität am Eingang,
aber keine Verfügbarkeit**: fällt pve aus, sind alle vier öffentlichen Dienste weg — mit drei
Connectoren genauso vollständig wie mit einem.
**Der Gewinn ist real, aber begrenzt:** kein Docker-Host mehr im öffentlichen Pfad (die APs sind
eigene Maschinen ⇒ die macvlan/ipvlan-Blindheit kann dort prinzipiell nicht auftreten), sechs
Container werden drei, und die Host→Origin-Zuordnung lebt nur noch im `caddy/Caddyfile`.
**Der echte Verfügbarkeitsgewinn kommt erst mit `caddy-bf2` (LXC 131)** und einer zweiten
Ingress-Regel. ⚠️ Für `vw` gilt dabei die TP20.1-Bremse: zwei Caddys an verschiedenen Standorten
würden die Hälfte der **Schreib**zugriffe auf einen read-only Standby schicken — das #1033-Muster,
diesmal mit Datenverlust. **Automatisches Ingress-Failover erst, wenn der DB-Teil es trägt.**
Bis dahin ist die Reserve die manuelle: derselbe Connector kann über eine zusätzliche Ingress-Regel
**direkt** auf ein kritisches Backend zeigen, an Caddy vorbei.