Files
bf-infra/docs/tp16.3-bf-public-ap-connector-runbook.md
Christoph Emsenhuber 3f95a7adf3 TP16.3 §6 Abbau durch, TP GESCHLOSSEN (User zog §6 vor)
Die 7-Tage-Frist wurde auf User-Entscheid aufgehoben ("es wird nichts mehr
sein"). Reihenfolge Container -> Tunnel eingehalten (umgekehrt gaebe es 530er).

Vorbedingung geprueft: alle vier Namen auf bf-public, kein Name mehr an den
Alttunneln -> Abbau gefahrlos.

7 Container entfernt, per exakter Namensliste (nicht per Filter):
  LXC 400 (pve): cloudflared-bfmm, cloudflared-bfnas
  macmini:       cloudflared-bfpve, cloudflared-bfnas
  NAS ep14:      cloudflared-bfpve, cloudflared-bfmm
  plus terpene-cloudflared -- auf zusaetzlichen User-Entscheid, weil
  420.teilzeitladen.xyz jetzt ueber Caddy/CF laeuft. Kein Stack dahinter, also
  kein Wiederauferstehen.

Beweis VOR der Tunnel-Loeschung: 420.teilzeitladen.xyz lieferte mit bereits
entferntem terpene-cloudflared echten Origin-Inhalt (assetlinks-JSON, 200) --
der Dienst haengt also nicht mehr am Tunnel.

4 Tunnel geloescht: bf-mm, bf-nas, bf-pve, terpene-db -- jede ID gegen die
Behalten-Liste (bf-public, fm) geprueft.

Schlusskontrolle: nur noch bf-public (12) + fm (4) healthy; alle vier BF-Namen
und 420 weiterhin 200.

Der terpene-db-Tunnel-Token stand kurz im docker-inspect-Output; mit der
Tunnel-Loeschung ungueltig, kein Rotationsbedarf.

TP16.3 damit vollstaendig abgeschlossen (Status ✅ 2026-09-01). Quick-Index,
Abschnittsheader, §6-Record, Ist-Stand, Runbook §6 und Handoff nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:08:34 +02:00

39 KiB
Raw Permalink Blame History

TP16.3 / TP31 Schritt 5 — Runbook: bf-public-Connector auf ap1 / ap2 / ap3, CNAME-Flip, Abbau

§1–§3 AUSGEFÜHRT 2026-08-31. Drei Connectoren laufen, bf-public healthy/12. §2.2, §3.4 und §3.5 sind unten korrigiert — die ursprüngliche Fassung war an drei Stellen falsch (Flash-Budget, docker save-Format, envs-Parametername). ✅ §5 (Flip) AUSGEFÜHRT 2026-09-01 — alle vier Namen auf bf-public. Offen bleibt nur §6 (Abbau). 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 — 🔴 KORRIGIERT 2026-08-31, die alte Fassung funktioniert nicht

Zwei Gründe, warum docker save des offiziellen Images hier scheitert:

  1. Es passt nicht ins Flash. Entpackt misst cloudflare/cloudflared:2026.6.1 72.926.208 B = 69,5 MiB (docker export | wc -c), nicht die früher hier genannten 28 MB — das ist die komprimierte Archivgrösse. RouterOS braucht beim Import Tar und entpacktes Abbild gleichzeitig: 26,2 + 69,5 = 95,7 MiB gegen ~90 MiB frei. ⚠️ docker image inspect .Size lügt hier (meldet 27.463.950); docker history summiert korrekt auf ~70 MB.
  2. Das Format stimmt nicht. Seit Docker 25 (containerd-Image-Store) schreibt docker save ein OCI-Layout (blobs/sha256/, gzip-Layer). RouterOS will docker-v1 und meldet no config found in manifest — klingt nach kaputtem Image, ist aber das Format.

Stattdessen: Minimal-Image aus dem Binary bauen. Das Binary im offiziellen Image ist statisch gelinkt und 36,8 MB gross; die restlichen ~33 MB sind distroless-Debian, das cloudflared nicht braucht.

# Binary + CA-Bundle aus dem gepinnten Upstream-Image ziehen
ssh macmini 'docker pull --platform linux/arm64 \
  cloudflare/cloudflared@sha256:6d91c121b803126f7a5344005d17a9324788fc09d305b6e2560ec6040a7ae283 \
  && docker tag cloudflare/cloudflared@sha256:6d91c121b803126f7a5344005d17a9324788fc09d305b6e2560ec6040a7ae283 cfd:2026.6.1'
ssh macmini 'C=$(docker create cfd:2026.6.1); \
  docker cp "$C":/usr/local/bin/cloudflared /tmp/cfd-bin; \
  docker cp "$C":/etc/ssl/certs/ca-certificates.crt /tmp/cfd-ca.crt; docker rm -f "$C"'
scp macmini:/tmp/cfd-bin      ~/tp16.3/cloudflared-linux-arm64
scp macmini:/tmp/cfd-ca.crt   ~/tp16.3/ca-certificates.crt

# docker-v1-Archiv direkt bauen (kein Docker-Umweg -> Format haengt nicht am Image-Store)
python3 tools/build-cfd-image.py ~/tp16.3/cloudflared-linux-arm64 \
        ~/tp16.3/ca-certificates.crt ~/tp16.3/cfd-min-arm64.tar

Ergebnis: 35,3 MiB Archiv, 35,3 MiB entpackt, Spitzenbedarf 70,6 MiB — passt mit ~20 MiB Reserve, nach dem Löschen des Tars bleiben 54–55 MiB frei.

🔑 Der Pin ist die Binary-SHA256 e93824f677eb2f072b0a42b7ad8b57e1ed76c6aed51b13ed805a6064a82d424e. cloudflared gibt sie beim Start selbst aus (Version 2026.6.1 (Checksum e93824f6…)) — damit ist im Log nachweisbar, dass alle drei APs dasselbe Binary fahren.

⚠️ Gegenprobe vor dem Ausrollen: docker load -i + docker run --rm <tag> version auf dem macmini. Ein Formatfehler fällt dort in Sekunden auf, auf dem AP erst nach einem 35-MiB-Upload.

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

🔴 Reihenfolge ist Pflicht: erst der Paket-Reboot (§3.2), dann device-mode. Der Bestätigungs-Boot installiert keine Pakete — er verbraucht eine wartende .npk, ohne sie zu installieren. Am 2026-08-31 wurde auf ap1 versucht, beides in einem Stromzyklus zu erledigen (um einen Gang zu sparen): danach stand container: yes, die Datei war weg und /system package print zeigte zwei Pakete. Nachholen ging per SSH-Reboot, aber die Abkürzung spart nichts.

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

✅ Seit 2026-08-31 stehen diese drei Zeilen im SOLL (AP-Template Block [7], Werte aus dem ConfigContext ap-common → ap.container). Wer sie hier direkt setzt, erzeugt daher keine Drift mehr — aber auch keinen Bedarf: bei einem neuen AP rendert Golden Config sie mit.

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. 🔴 Die Eigenschaft heisst auf ROS 7.24.1 list=, NICHT name= — mit name= antwortet das Gerät bad parameter name bzw. die REST-API unknown parameter name. (Das MikroMCP-Tool manage_container_env benutzt name= und ist damit hier unbrauchbar.)

Der Token darf weder in eine Kommandozeile (→ ps) noch ins Sitzungstranskript. Deshalb per Datei:

T=$(cat ~/tp16.3/bf-public.token)
TMP=$(mktemp /tmp/cfdenv.XXXXXX.rsc); chmod 600 "$TMP"
{ printf '/container/envs/add list=cfd key=TUNNEL_TOKEN value="%s"\n' "$T"
  printf '/container/envs/add list=cfd key=TUNNEL_TRANSPORT_PROTOCOL value="quic"\n'; } > "$TMP"
SSHPASS="$PW" sshpass -e scp -O "$TMP" admin@<ip>:cfdenv.rsc && rm -f "$TMP"

Auf dem AP: /import file=cfdenv.rsc, danach /file/remove [find name="cfdenv.rsc"] — die Datei enthält den Token im Klartext. Gegenprobe ohne Wert-Ausgabe:

:foreach e in=[/container/envs/find] do={:put ([/container/envs/get $e list] . " / " . \
  [/container/envs/get $e key] . " / len=" . [:len [/container/envs/get $e value]])}

⚠️ Niemals /container/envs/print — das gibt den Token aus.

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 (korrigiert 2026-08-31, gemessen): ~90 MiB frei − 35,3 MiB .tar − 35,3 MiB entpacktes Abbild ≈ 20 MiB in der Spitze, nach dem Löschen des Tars 54–55 MiB frei. 🔴 Mit dem offiziellen Image wären es 95,7 MiB Bedarf gegen 90 MiB frei — es passt schlicht nicht. Auch mit dem Minimal-Image gilt weiter: ein Container pro AP, kein zweiter.

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 — ✅ AUSGEFÜHRT 2026-09-01 (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.

🔑 Das Erfolgskriterium ist praeziser, als es hier bis 2026-08-31 stand — und die frühere Fassung war zweideutig. Gemessen am 2026-08-31 gegen 10.40.20.40:

Antwort Bedeutung
Access-Login, danach 200 mit LEEREM Body und Header server: Caddy ✅ Strecke bewiesen. Caddy hat geantwortet; es gibt für test-tp163 nur kein Site-Block, und genau so antwortet Caddy auf einen unbekannten Host.
404 ❌ Die Ingress-Regel greift nicht — das ist der Catch-all von bf-public (http_status:404).
ERR_TOO_MANY_REDIRECTS ❌ Blocker A ist zurück (Ingress auf :80 oder originServerName fehlt). Sofort abbrechen.

⚠️ Ohne diese Tabelle liest man das leere 200 als „kaputt" und das 404 als „Caddy sagt nichts" — beide Male falsch herum. Der zweite, unabhängige Beweis ist immer das Caddy-Log (§5.3).

⇒ 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)
  • 15 min beobachten, kein 530/1033

🔴 KORRIGIERT 2026-09-01 — der frühere Punkt „Caddy sieht den Request: journalctl -u caddy -n 20" funktioniert NICHT. Im Caddyfile steht keine log-Direktive; im Journal landen nur info/warn. Ein erfolgreicher Request hinterlässt dort nichts, und die leere Ausgabe liest sich wie „kam nie an". (Dass der öffentliche Reverse Proxy überhaupt keine Requests protokolliert, ist eine eigene Lücke — gehört zu TP31/TP34.)

Was stattdessen funktioniert — und 2026-09-01 auch benutzt wurde:

Werkzeug Beweist
curl --resolve <name>:443:188.114.96.10 Erzwingt den öffentlichen Weg. 🔴 Pflicht, denn der Haus-Resolver liefert für alle vier Namen direkt 10.40.20.40 (Split-Horizon) — ein Test von zuhause sieht Cloudflare nie.
Verbindungstabelle am Core (/ip firewall connection) Zeigt 10.40.99.x → 10.40.20.40 = ein AP-Connector redet mit Caddy. Der Zähler der Blocker-B-Regel taugt dafür NICHT: cloudflared hält die Origin-Verbindung offen, es entstehen kaum neue — der Zähler steht still, obwohl alles läuft.
Antwort-Fingerabdruck z.B. i_like_gitea-Cookie = echte Gitea-Antwort, nicht CF/Caddy.
WebSocket-Handshake mit --http1.1 101 Switching Protocols = der Companion-App-Pfad steht. ⚠️ Über HTTP/2 gibt es keinen klassischen Upgrade — ein 400 ist dann der eigene Testfehler, nicht der Dienst.

⚠️ Access antwortet je nach Standort verschieden, beides gesund: n8n liefert aus dem Haus 200 (die bf4-Policy trägt die WAN-IP), von außen 302 zum Login. hass/vw/gitea stehen auf bypass (App hass, vw, gitea, alle drei in self_hosted_domains) — dort kommt nie ein Login. Ein Service-Token für die Wildcard-App gibt es nicht (TP14.4), deshalb ist n8n von außen nicht bis zum Origin prüfbar — das musste der User im Browser bestätigen.

🔴 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 — ✅ AUSGEFÜHRT 2026-09-01 (7-Tage-Frist vom User aufgehoben)

✅ Durch. 7 Container entfernt (inkl. terpene-cloudflared, weil 420.teilzeitladen.xyz nun über Caddy/CF läuft — vor der Tunnel-Löschung mit echtem Origin-Inhalt belegt), 4 Tunnel gelöscht (bf-mm/bf-nas/bf-pve/terpene-db). Übrig: nur bf-public + fm. Reihenfolge Container→Tunnel eingehalten. Der Rest dieses Abschnitts bleibt als Referenz stehen.

(Original-Anleitung, Referenz)

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. ⚠️ Ergänzung 08-31: media/untrusted/guest haben im Normalbetrieb null Clients — dort ist gar kein Lease-Nachweis zu holen. Das ist kein Fehler, aber auch kein Beleg; nicht als „grün" buchen.
  11. 🔴 Das offizielle cloudflared-Image passt nicht ins Flash (69,5 MiB entpackt gegen ~90 MiB frei, Tar kommt obendrauf). Minimal-Image bauen, s. §2.2. Flash-Budgets mit docker export messen, nie mit docker image inspect .Size.
  12. 🔴 docker save liefert seit Docker 25 OCI statt docker-v1 → no config found in manifest.
  13. 🔴 /container/envs nimmt list=, nicht name=.
  14. 🔴 Der device-mode-Bestätigungs-Boot installiert keine Pakete — Reihenfolge s. §3.3.
  15. ✅ ERLEDIGT 2026-08-31 — §3.4 kollidierte mit der GC-Governance: ip address und ip firewall nat stehen unter Drift-Kontrolle, direkt gesetzt ergab das zwei extra-Drifts je AP. Sie sind jetzt ins Soll gehoben (AP-Template Block [7] + ConfigContext ap.container), nicht als Transaktion geführt: ein Dauerzustand gehört ins Template. Dazu kam die 28. ComplianceRule interface-veth — vorher war das veth das tragende Stück ohne jede Überwachung. 🔴 Falle darin: der veth-Export trägt zwei gerätevergebene MACs; ohne Normalisierung (ConfigReplace 60-/61-veth-*) erzeugt die Regel Dauer-Drift auf allen drei APs. Stand danach: 139/140, übrig nur die offene TP25-Transaktion.
  16. 🔴 Ein Golden-Config-Job meldet SUCCESS und kann trotzdem halb durchgelaufen sein. Am 2026-08-31 fehlte nach dem ersten Compliance-Lauf genau ein Eintrag (crs326-core/interface-veth), Job-Status SUCCESS, im Job-Log dagegen RENDER COMPLIANCE TASK GROUP failed: Broken pipe. Abnahme ist deshalb nicht die Konformitätsquote, sondern die ANZAHL der Einträge = Regeln × Geräte. 139 statt 140 sieht aus wie „die neue Regel greift nicht" und ist ein halb gelaufener Job. Zweiter Lauf reichte.
  17. 🟡 A - AVAILABLE in /system package print ist ein Download-Katalog, kein Paket auf dem Gerät — enable würde einen Internet-Fetch auslösen (auf ap1/ap2 gesperrt). .npk bleibt der Weg.
  18. 🟡 MikroMCPs export_config exportiert unvollständig (301 B statt 6,2 KiB). Für Backups /export file=… per SSH verwenden. 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.