Files
bf-infra/docs/tp16.3-bf-public-ap-connector-runbook.md
Christoph Emsenhuber 452ea53576 ROS 7.24.1 auf allen fuenf nachgezogen + Handover fuer TP16.3 §2/§3
Nautobot: ap1 stand als einziges Geraet noch auf SoftwareVersion 7.24 (ap2,
ap3, core, branch waren bereits richtig) -> auf 7.24.1 gesetzt. Danach
Backup/Intended/Compliance.

Folge fuers Runbook: aus zwei container.npk wird EINE. Die Zwei-Dateien-Regel
vom 08-30 war rein versionsbedingt, nicht modellbedingt - cAP ax und hAP ax^2
teilen sich die arm64-Pakete. §0, §2.1, §3.1 und Falle 1 umgeschrieben.
Die Regel dahinter bleibt: npk muss exakt zur ROS-Version passen, sonst ist
das Paket nach dem Reboot inaktiv - ohne Fehlermeldung.

Neue Compliance-Drift, NICHT von dieser Session und bewusst offen gelassen:
crs326-core / wifi-security - sec-main hat am Geraet ft-over-ds=no, Soll ist
yes (ft=yes ist korrekt). Kam mit dem Upgrade. 134/135 compliant. Braucht eine
Entscheidung (Geraet korrigieren oder neuen Wert zum Soll erklaeren) plus
Re-Provisionierung der CAPs; ein Re-Baseline ohne Entscheid wuerde die
Drift-Erkennung wertlos machen.

Handover docs/handoff-20260831-tp16.3-vorort-session.md fuer die Folgesession
(§2/§3: npk, device-mode, Container). Enthaelt u.a. den Hinweis, dass ein
fehlschlagendes /tool fetch vom AP auf Caddy KEIN Fehler ist, sondern die
SNI-Abweisung - und dass der TLS-Alert sogar beweist, dass die Freigabe traegt.

Offen notiert, nicht angelegt: TP-Vorschlag "dedizierter untagged-VLAN99-Port
je Switch" (Out-of-Band-Mgmt) - Auslöser war, dass crs326-branch beim
Core-Neustart nicht erreichbar war. Wartet auf Christophs Okay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:57:35 +02:00

605 lines
30 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.1** | 95 MB / 128 | 712 MB | `home` | ❌ `no` | ❌ `no` |
| **ap2** | 10.40.99.12 | hAP ax² (`C52iG`) | arm64 | **7.24.1** | 95 MB / 128 | 699 MB | `home` | ❌ `no` | ❌ `no` |
| **ap3** | 10.40.99.13 | **cAP ax** (`cAPGi`) | arm64 | **7.24.1** | 96 MB / 128 | 689 MB | **`basic`** | ❌ `no` | ✅ `yes` |
*(Versionsspalte am **2026-08-31** nachgelesen: Christoph hat alle fünf MikroTik auf **7.24.1** gehoben,
RouterBOOT mitgezogen. Bis 08-30 liefen ap1/ap2 auf 7.24 — daher stand hier vorher eine Zwei-Dateien-Regel.)*
✅ **Seit dem Upgrade auf 7.24.1 (2026-08-31) reicht EINE `.npk` für alle drei APs** — alle sind `arm64`
und alle auf derselben Version. Die frühere Zwei-Dateien-Regel war **rein versionsbedingt**, nicht
modellbedingt: `cAP ax` und `hAP ax²` teilen sich die `arm64`-Pakete.
🔴 **Die Regel dahinter gilt weiter:** ein `container.npk` **muss exakt zur RouterOS-Version passen** —
sonst bleibt es nach dem Reboot deaktiviert. Wer künftig einen AP einzeln hebt, hat wieder zwei Dateien.
🟡 **Ein Unterschied bleibt:**
- **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 — ✅ **ALLE ERLEDIGT 2026-08-31 (remote)**
> ✅ **Nichts davon ist vor Ort noch zu tun.** Abschnitt bleibt als Nachweis stehen; die Fixes sind
> gemessen, nicht nur abgesetzt. **Vor Ort startet die Arbeit bei §2/§3.**
>
> | Blocker | Stand | Nachweis |
> |---|---|---|
> | **A** Redirect-Schleife (`:80`) | ✅ behoben | Ingress zeigt auf `https://10.40.20.40:443`, `originServerName` je Regel gesetzt |
> | **B** Freigabe `vlan99 → vlan20:443` | ✅ behoben | Regel via Nautobot GC deployt, Plan `Completed`, am Gerät verifiziert, **135/135 compliant, 0 Drifts** |
> | **C** `ROUTER_AP3_PASS` | ✅ behoben | in `~/.secrets/credentials.env` (`chmod 600`), REST-Auth gegen ap3 = **HTTP 200** |
### 1.1 ✅ Blocker A — `bf-public` zeigte auf `:80`, und Caddy antwortet dort mit 308
> **Erledigt 2026-08-31.** Ingress zurückgelesen: alle vier Namen → `https://10.40.20.40:443`,
> `SNI` = der jeweilige Hostname. Der `PUT` unten ist der ausgeführte Stand, nicht mehr eine Aufgabe.
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 liess MGMT → Caddy **nicht** durch
> **Erledigt 2026-08-31 über Nautobot Golden Config** (`gc-manual-plan.py --device crs326-core --deploy`,
> Change-ID `TP16.3-BlockerB`, Plan `23e5b667-…` **Completed**), **nicht** per Direkt-Write — `ip-firewall-filter`
> steht unter Drift-Kontrolle (TP26). Backup vorher: `core-pre-tp16.3-blockerB-20260831.backup` (TP22-Regel 3).
> Danach `ingest-core-firewall.py --apply` (Re-Baseline) + `--refresh-compliance` ⇒ **135/135, 0 Drifts.**
> **Position verifiziert:** die Regel sitzt direkt **vor** `TP5: log-only` und `TP5: deny-by-default`.
> **Funktional verifiziert:** `/tool fetch` von ap3 auf `https://10.40.20.40` erreicht Caddy und bekommt
> einen **TLS-Alert von Caddy selbst** — ein Alert kann nur vom Server kommen, das Paket ist also da.
> Der Alert ist die **SNI-Abweisung** (Gegenprobe: ohne SNI `000`, mit korrektem SNI `200`) und genau
> der Grund, warum `originServerName` in §1.1 gesetzt ist. **Kein Fehler, sondern der Beweis.**
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` fehlte in `~/.secrets/credentials.env`
> **Erledigt 2026-08-31.** `ROUTER_AP3_USER`/`_PASS` nachgetragen, Datei `chmod 600`,
> Sicherung `~/.secrets/credentials.env.bak-20260831`. Funktionsbeweis: REST-Auth gegen ap3 = **HTTP 200**.
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 `container.npk` holen — **eine Datei für alle drei APs**
```bash
mkdir -p ~/tp16.3 && cd ~/tp16.3
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-7.24.1-arm64.npk
```
*(URL am 2026-08-30 mit `HTTP 200` verifiziert.)*
⚠️ **Vor dem Upload gegenprüfen, dass das Gerät wirklich auf 7.24.1 steht** (`/system resource print`).
Stimmt die Version nicht, ist die Datei die falsche — und das merkt man erst nach dem Reboot.
### 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
| AP | Datei |
|---|---|
| ap1 (`.99.11`) | `container-7.24.1-arm64.npk` |
| ap2 (`.99.12`) | `container-7.24.1-arm64.npk` |
| ap3 (`.99.13`) | `container-7.24.1-arm64.npk` |
✅ **Seit 2026-08-31 dieselbe Datei überall** (alle drei `arm64` + 7.24.1).
```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. **`container.npk` ist versionsgebunden.** Seit 08-31 sind alle drei APs auf 7.24.1 ⇒ **eine** Datei.
Weicht ein Gerät später ab, braucht es wieder seine eigene. Falsche Datei ⇒ Paket bleibt nach dem
Reboot **inaktiv, ohne Fehler**. **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.