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

30 KiB
Raw Blame History

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 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
terpene-db b83b68f0-… healthy 4

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 -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.

# 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

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):

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 -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

# 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).

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 -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

# .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 -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 -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 -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.

# 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 -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 -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 -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 Schreibzugriffe 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.