10 Commits

Author SHA1 Message Date
e474e5e38a Merge main + nervous-williams; Drift-Zuschreibung korrigiert
Der Branch enthaelt jetzt origin/main (0680cca, TP25 Fund E / TP27) und
die Korrektur c8b2a15 aus claude/nervous-williams-acf000.

Inhaltliche Korrektur an meinen eigenen Dateien
- Ich hatte die wifi-security-Drift (ft-over-ds=no auf sec-main) dem
  7.24.1-Upgrade zugeschrieben. Falsch: gc-transaktion.py status fuehrt
  TP25-fund-e-ft-over-ds als OFFEN -- der Hebel wurde 08:09 bewusst
  gesetzt, der Core erst 14:36 gehoben. Sie gehoert einer laufenden
  TP25-Session, braucht keine Entscheidung und darf nicht gruen gemacht
  werden. Korrigiert im §5-Prompt und im §2/§3-Handoff.
- Merkregel in CLAUDE.md: vor jeder Drift-Bewertung gc-transaktion.py
  status lesen. Dazu die Abgrenzung Transaktion (befristeter Versuch)
  vs. Soll/Template (Dauerzustand) -- die sechs AP-Drifts aus TP16.3
  §3.4 sind Dauerzustand und gehoeren deshalb ins Template, nicht in
  eine Transaktion.
- Notiert: gc-transaktion.py findet den Token aus einem Worktree heraus
  nicht (210-nautobot.yml liegt nur im Haupt-Checkout); die
  Transaktionsliste kommt trotzdem, nur die Compliance-Spalte fehlt.

Konflikt in teilprojekte.md (ROS-7.24.1-Zeile) zusammengefuehrt statt
eine Seite zu verwerfen: TP27-Erledigung samt Zeitstempeln aus main plus
die npk-Folge fuer TP16.3 aus diesem Branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:34:40 +02:00
17e8e9f72e Merge remote-tracking branch 'origin/claude/nervous-williams-acf000' into claude/stoic-khayyam-09b644 2026-08-31 19:33:14 +02:00
c8b2a15d5f Korrektur: die wifi-security-Drift ist TP25 Fund E, nicht das Upgrade
Ich hatte die 134/135 dem 7.24.1-Upgrade zugeschrieben. Falsch. Auf main steht
a0a650e "TP25 Fund E Hebel 1: ft-over-ds=no auf sec-main, Messung laeuft" --
der Hebel wurde am 2026-08-31 um 08:09 bewusst gesetzt, der Core erst um 14:36
gehoben. gc-transaktion.py status fuehrt TP25-fund-e-ft-over-ds als OFFEN
(Scope wifi-security, Ablauf 2026-09-03, Drift im Scope).

Die 134/135 sind also Absicht, kein Schaden, und brauchen keine Entscheidung
von Christoph -- sie gehoeren einer anderen, laufenden Session. Gruen machen
wuerde den Versuch unsichtbar machen.

Lehre, in beiden Dateien festgehalten: vor jeder Drift-Bewertung
gc-transaktion.py status lesen. Drift aus einem laufenden Versuch sieht
identisch aus wie ein Versehen -- genau dafuer existiert das Werkzeug, und ich
habe es nicht benutzt.

Handover-Punkt 5.1 von "braucht eine Entscheidung" auf "nicht anfassen"
umgestellt, Abnahme-Checkliste entsprechend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:31:19 +02:00
6f6fa71ffa TP38 bestaetigt + Uebergabeprompt fuer TP16.3 §5 (CNAME-Flip)
- TP38 (OOB-Mgmt, untagged-VLAN99-Port je Switch): Nummer von Christoph
  bestaetigt, Herkunft des Nummernwechsels im TP-Abschnitt vermerkt.
- Neu: docs/tp16.3-paragraph5-session-handoff-prompt.md -- fertiger
  Prompt fuer die Flip-Session, nach der Konvention der drei bereits
  vorhandenen *-session-handoff-prompt.md.
  Enthaelt als erste Auflage die offene Drift-Entscheidung (128/135),
  die vor dem ersten Flip fallen muss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:24:18 +02:00
60360fc3cc TP16.3 §2/§3 durch: cloudflared auf ap1/ap2/ap3, bf-public healthy mit 12 Connectoren
Merge von claude/nervous-williams-acf000 (d5b3ce3) plus Ergebnis der
Vor-Ort-Session vom 2026-08-31.

Ergebnis
- je ein cloudflared 2026.6.1 auf ap1/ap2/ap3, bf-public healthy/12
  (vorher 0 Connectoren), Flash je AP 54-55 MiB frei, WLAN zurueck.

Drei Runbook-Fehler aufgedeckt und korrigiert (§2.2, §3.3, §3.5, §8)
- Flash-Budget war Faktor 2,5 zu optimistisch: cloudflared entpackt
  69,5 MiB (gemessen mit docker export), nicht 28 MB -- das offizielle
  Image passt auf keinen der drei APs. Ersatz: eigenes FROM-scratch-Image
  mit 35,3 MiB aus dem statisch gelinkten Binary, Binary-SHA e93824f6
  gepinnt, Neubau byte-identisch. tools/build-cfd-image.py.
- docker save schreibt seit Docker 25 OCI statt docker-v1 -> RouterOS
  meldet "no config found in manifest". tools/oci2docker.py als Konverter.
- /container/envs nimmt list=, nicht name= (auch MikroMCPs
  manage_container_env ist dadurch fuer dieses Feld unbrauchbar).

Weitere Funde
- Der device-mode-Bestaetigungs-Boot installiert keine Pakete; erst
  Paket-Reboot, dann device-mode.
- "A - AVAILABLE" in /system package print ist ein Download-Katalog,
  kein Paket auf dem Geraet (Gegenprobe am Core: 2,9 MB frei, listet
  10,1 MB wifi-qcom als verfuegbar).
- MikroMCPs export_config exportiert unvollstaendig (301 B statt 6,2 KiB).

Offen und entscheidungsbeduerftig
- Compliance 128/135: §3.4 verlangt Direkt-Writes fuer ip address/nat,
  die unter GC-Kontrolle stehen -> sechs neue extra-Drifts, zwei je AP.
  Inhaltlich richtig, gehoeren ins Soll -- kein Re-Baseline, kein Rueckbau.
- Zwei Nautobot-Tokens sind in dieser Session ins Transkript geraten
  (unzureichende sed-Maskierung) -> Rotation noetig, TP30. Merkregel in
  CLAUDE.md ergaenzt.

Merge-Konflikte aufgeloest
- Nummernkonflikt: beide Branches hatten ein TP37. main behaelt TP37
  (Nautobot als Netzwerkdoku, User-Okay 08-30), OOB-Mgmt wird TP38.
  Braucht noch Christophs Okay.
- Drei CF-Rollback-Snapshots hatten gleichen Namen bei verschiedenem
  Inhalt -> aelterer Stand als *-vor-hostname-schnitt.json erhalten,
  keiner verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:15:13 +02:00
d5b3ce37ff TP37 angelegt: Out-of-Band-Management, ein untagged-VLAN99-Port je Switch
Auslöser (User 2026-08-31): beim Core-Neustart war crs326-branch nicht mehr
erreichbar, weil sein Management ausschliesslich ueber den Core laeuft.

Live nachgemessen: auf dem Branch ist vlan99 tagged=ether1,ether24,bridge mit
untagged="" - es gibt also keinen Weg zu 10.40.99.2, der nicht durch den Core
fuehrt. Wer den Core zerkonfiguriert, verliert den Branch gleich mit und damit
die Moeglichkeit, es vom Branch aus zu reparieren.

Zweiter, unangenehmerer Befund: crs326-branch hat GAR KEINE input-Regeln (Kette
leer). Sein gesamter Schutz besteht heute darin, dass VLAN99 nur ueber den Core
erreichbar ist und dessen forward-Kette deny-by-default faehrt - die
Erreichbarkeitsbeschraenkung IST die Zugangskontrolle. Damit ist ein untagged
MGMT-Port ein physischer Generalschluessel, und das TP traegt eine explizite
Auflage: Port disabled im Normalbetrieb ODER input-Regeln auf beiden Switches
ODER physische Sicherung. Empfehlung im Abschnitt: die input-Regeln, weil sie
eine Luecke schliessen, die unabhaengig von diesem TP besteht.

Kandidat ether23 - auf core und branch beide running=false, also frei, und auf
beiden Geraeten dieselbe Nummer.

Abnahme im Abschnitt so formuliert, dass sie den Zweck prueft: Laptop an
branch ether23, Core-Trunk gezogen, 10.40.99.2 muss antworten. Alles andere
misst nur den Normalfall.

Quick-Index (nach TP36 einsortiert), TP-Abschnitt und Ist-Stand mitgezogen;
Handover 20260831 auf den angelegten TP umgestellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:05:31 +02:00
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
badf715f5a TP16.3 §1: die drei Blocker remote behoben (Ingress, Firewall via GC, ap3-Cred)
A) bf-public-Ingress von http://10.40.20.40:80 auf https://10.40.20.40:443
   umgestellt, originServerName je Regel. Die :80-Variante haette ueber
   Cloudflare eine Redirect-Schleife gebaut (Caddy antwortet dort 308 auf
   https, das Ziel laeuft zurueck in denselben Tunnel).

B) Freigabe vlan99 -> vlan20:443 auf crs326-core angelegt - ueber Nautobot
   Golden Config, NICHT per Direkt-Write: ip-firewall-filter steht seit TP26
   unter Drift-Kontrolle, und die zwei genehmigten Ausnahmen (#2 Bring-up,
   #4 WireGuard-Keys) decken diesen Fall nicht.
   - Backup vorher: core-pre-tp16.3-blockerB-20260831.backup (TP22-Regel 3)
   - gc-manual-plan.py, Change-ID TP16.3-BlockerB, Plan Completed
   - Position verifiziert: direkt vor TP5: log-only / TP5: deny-by-default
   - Re-Baseline ingest-core-firewall.py --apply, dann --refresh-compliance
     => 135/135 compliant, 0 Drifts
   - funktional: /tool fetch von ap3 erreicht Caddy und bekommt einen
     TLS-Alert VON Caddy - ein Alert kann nur vom Server kommen, das Paket
     ist also da. Der Alert ist die SNI-Abweisung (ohne SNI 000, mit
     korrektem SNI 200) und damit genau der Grund fuer originServerName.

C) ROUTER_AP3_USER/_PASS in ~/.secrets/credentials.env nachgetragen (war nur
   in ~/.claude.json), chmod 600, Sicherung .bak-20260831. REST-Auth gegen
   ap3 = HTTP 200.

Vor Ort startet die Arbeit damit bei §2/§3 (container.npk, device-mode,
Container). Quick-Index, TP16.3-Abschnitt und Ist-Stand mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:06:38 +02:00
810126c01b Doku: bf-infra hat seit 2026-08-30 ein Gitea-Remote (Backup-Luecke geschlossen)
CLAUDE.md und README.md behaupteten beide noch den Inventur-Stand vom
2026-08-27: "lokal, kein Gitea-Remote (Working-Copy). Verloren bei
Festplattencrash." bzw. "kein Gitea-Remote (Sicherheitsluecke, offen)".

Live geprueft: origin = https://gitea.emsenhuber.net/admin/bf-infra.git,
main lokal und remote identisch. Die Luecke ist zu.

Dazu der Hinweis, der die Aussage traegt: ein Remote ist nur so viel wert wie
der letzte Push - der Backup-Guard (TP35 Schicht 5) ist genau diese
Erinnerung und wird nicht mit --no-verify umgangen.

docs/handoff-20260827-inventur-und-architektur.md bleibt unveraendert - der
Handoff ist ein historischer Snapshot und war an dem Tag korrekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 07:52:33 +02:00
b697ffc6fa TP16.3/TP31 Schritt 5: Vor-Ort-Runbook fuer bf-public-Connector (ap1/ap2/ap3)
Runbook docs/tp16.3-bf-public-ap-connector-runbook.md fuer die Vor-Ort-Session
geschrieben; docs/tp31-schritt5-runbook.md abgeloest (read-only-Header).

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:07:17 +02:00
18 changed files with 1781 additions and 14 deletions

View File

@@ -21,7 +21,7 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
## Was du sofort wissen musst (Inventur 2026-08-27) ## Was du sofort wissen musst (Inventur 2026-08-27)
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**. - **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header. - **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell. - **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell.
- **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo. - **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo.
@@ -289,6 +289,33 @@ Verhaltensregeln für nachfolgende AI-Sessions — kleiner, klarer, weniger Drif
- **Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität, wenn sie lang wird.** Befund 2026-08-28: der „Nächster Schritt (2026-08-26): TP31 Schritt 5"-Satz stand zwei Tage nach seiner Formulierung noch im Ist-Stand-Block, obwohl ich in derselben Zeit drei Folge-Sessions eröffnet hatte. **Merkregel pro Edit:** drei Stellen berücksichtigen — Quick-Index (Tabelle), TP-Abschnitt, Ist-Stand-Block. Keine schlägt die andere; wer eine ändert, **muss** die anderen mitlesen. Strukturelle Antwort = TP36 (SSoT via Nautobot, Datei wird read-only Snapshot) — eigene Session, nicht nebenbei. **Lessons-Block erweitert 2026-08-28 nach Skill-Verifikation in opencode.** - **Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität, wenn sie lang wird.** Befund 2026-08-28: der „Nächster Schritt (2026-08-26): TP31 Schritt 5"-Satz stand zwei Tage nach seiner Formulierung noch im Ist-Stand-Block, obwohl ich in derselben Zeit drei Folge-Sessions eröffnet hatte. **Merkregel pro Edit:** drei Stellen berücksichtigen — Quick-Index (Tabelle), TP-Abschnitt, Ist-Stand-Block. Keine schlägt die andere; wer eine ändert, **muss** die anderen mitlesen. Strukturelle Antwort = TP36 (SSoT via Nautobot, Datei wird read-only Snapshot) — eigene Session, nicht nebenbei. **Lessons-Block erweitert 2026-08-28 nach Skill-Verifikation in opencode.**
- **`sed 's/=.*/=<set>/'` maskiert NICHT, wenn das `.*` greedy ist und der Wert Slashes/Backslashes enthält** (Befund 2026-08-28, shelly-mcp-Deploy). Ich habe `sed 's/SHELLY_DEVICE_PW=.*/<set>/'` auf die Stack-YML angewendet — der **Wert selbst** wurde in den Output geschrieben, weil mein Replacement-String nicht greedy genug war und ich die Pipe nicht mit `cat -v` oder `head -15 | sed 's|=.*|=<set>|'` abgesichert hatte. Folge: **`SHELLY_DEVICE_PW` ist jetzt als verbrannt zu betrachten** — wandert in **TP30 Posten #3** mit Hochstufung. **Merkregel:** bei jeder Ausgabe von Secret-haltigen Dateien entweder `head -c <n>` (Abbruch nach Bytes), oder die Datei vor dem Read mit `tr -d '\n' | sed 's|=.*|=<set>|'` über einen Stream-Filter schicken — **niemals** einfach `cat` und auf einen Replacement-Pattern vertrauen. Vergleichbar zum PSK-Leak 2026-08-24. - **`sed 's/=.*/=<set>/'` maskiert NICHT, wenn das `.*` greedy ist und der Wert Slashes/Backslashes enthält** (Befund 2026-08-28, shelly-mcp-Deploy). Ich habe `sed 's/SHELLY_DEVICE_PW=.*/<set>/'` auf die Stack-YML angewendet — der **Wert selbst** wurde in den Output geschrieben, weil mein Replacement-String nicht greedy genug war und ich die Pipe nicht mit `cat -v` oder `head -15 | sed 's|=.*|=<set>|'` abgesichert hatte. Folge: **`SHELLY_DEVICE_PW` ist jetzt als verbrannt zu betrachten** — wandert in **TP30 Posten #3** mit Hochstufung. **Merkregel:** bei jeder Ausgabe von Secret-haltigen Dateien entweder `head -c <n>` (Abbruch nach Bytes), oder die Datei vor dem Read mit `tr -d '\n' | sed 's|=.*|=<set>|'` über einen Stream-Filter schicken — **niemals** einfach `cat` und auf einen Replacement-Pattern vertrauen. Vergleichbar zum PSK-Leak 2026-08-24.
- **Vor jeder Drift-Bewertung `python3 nautobot/gc-transaktion.py status` lesen** (Befund 2026-08-31,
zweimal am selben Tag gestolpert). **Drift aus einem laufenden Versuch sieht identisch aus wie ein
Versehen** — die Compliance-Zahl unterscheidet nicht. Konkret: `crs326-core`/`wifi-security`
(`ft-over-ds=no`) wurde dem 7.24.1-Upgrade zugeschrieben und als „braucht eine Entscheidung"
weitergereicht; tatsächlich ist es **TP25 Fund E Hebel 1**, um 08:09 bewusst gesetzt, während der
Core erst um 14:36 gehoben wurde, geführt als offene Transaktion `TP25-fund-e-ft-over-ds`.
🔴 **Eine solche Drift grün zu machen macht den Versuch unsichtbar.** Umgekehrt gilt: was **dauerhaft**
gelten soll (z.B. das `veth`/`masquerade` aus TP16.3 §3.4), gehört **nicht** in eine Transaktion,
sondern ins **Soll** — Transaktion = befristeter Versuch, Template = Dauerzustand.
⚠️ Das Skript löst den Token-Pfad gegen die Repo-Wurzel auf und findet ihn **aus einem Worktree
heraus nicht** (`210-nautobot.yml` liegt nur im Haupt-Checkout) — die Transaktionsliste kommt
trotzdem, nur die Compliance-Spalte fehlt dann.
- **Maskierungs-Muster auf den SCHLÜSSELNAMEN ankern, nie auf ein Trennzeichen** (Befund 2026-08-31,
zweiter Fall dieser Klasse nach `SHELLY_DEVICE_PW`). `grep -n "TOKEN" datei | sed 's/:.*TOKEN/…/'`
maskiert **nicht**: `grep -n` stellt `<zeile>:` voran, und das `:.*TOKEN` matcht diesen Präfix statt
den Wert — ein nachgeschaltetes `cut -c1-60` schneidet dann genau nichts weg. So sind zwei
Nautobot-Tokens ins Transkript geraten. **Sicher ist nur**
`sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'`. Und: **Werte nie suchen, um sie zu
sehen** — den Schlüssel per `sed -n 's/^…KEY=//p'` direkt in eine Variable holen, nie über `grep`
mit Ausgabe. Vergleichbar zum PSK-Leak 2026-08-24 und zum `sed`-Fall 2026-08-28.
- **Vor jedem Flash-Budget auf MikroTik `docker export | wc -c` messen, nicht `docker image inspect
.Size`** (Befund 2026-08-31, TP16.3). `.Size` meldete für `cloudflared` 27,5 MB, entpackt waren es
**69,5 MiB** — das offizielle Image passt damit auf keinen hAP/cAP. RouterOS braucht beim Import
**Tar UND entpacktes Abbild gleichzeitig**. 🔴 **Und `docker save` schreibt seit Docker 25
(containerd-Store) OCI statt docker-v1** → RouterOS meldet `no config found in manifest`, was nach
kaputtem Image klingt und ein Formatproblem ist. Beides in `docs/tp16.3-bf-public-ap-connector-runbook.md` §2.2.
**Checkliste vor jedem Edit-Vorschlag:** **Checkliste vor jedem Edit-Vorschlag:**
1. Habe ich die SSoT für die Datei/das Repo benannt? 1. Habe ich die SSoT für die Datei/das Repo benannt?

View File

@@ -8,7 +8,7 @@ unter OpenCode und anderem LLM weitergepflegt.
| Repo | Rolle | Stand | | Repo | Rolle | Stand |
|---|---|---| |---|---|---|
| `~/dev/bf-infra/` (lokal) | Aktiver Master, Working-Copy | kein Gitea-Remote (Sicherheitslücke, offen) | | `~/dev/bf-infra/` + `admin/bf-infra` (Gitea) | Aktiver Master | **Remote seit 2026-08-30**, `main` synchron — Backup-Lücke geschlossen |
| `admin/bf-network-r6` (Gitea) | Archiv, historischer Master | read-only-Header, letzte aktive Commits 2026-08-25 | | `admin/bf-network-r6` (Gitea) | Archiv, historischer Master | read-only-Header, letzte aktive Commits 2026-08-25 |
| `admin/bf-golden-config` (Gitea) | Bot-Master (Nautobot 2×/Tag) | intended/ + backups/, nicht manuell | | `admin/bf-golden-config` (Gitea) | Bot-Master (Nautobot 2×/Tag) | intended/ + backups/, nicht manuell |
| `admin/blaueblueten-n8n` (Gitea) | Master für terpene-Repo | aktiv, lokal synchron | | `admin/blaueblueten-n8n` (Gitea) | Master für terpene-Repo | aktiv, lokal synchron |

View File

@@ -0,0 +1,44 @@
{
"success": true,
"errors": [],
"messages": [],
"result": {
"tunnel_id": "7a322261-380a-447b-97d2-8ebce99f5ed1",
"version": 4,
"config": {
"ingress": [
{
"service": "http://10.40.20.167:5678",
"hostname": "n8n.emsenhuber.net"
},
{
"service": "http://10.40.20.167:3001",
"hostname": "n8n-mcp.emsenhuber.net"
},
{
"service": "http://10.40.20.167:3000",
"hostname": "gitea-mcp.emsenhuber.net"
},
{
"service": "ssh://10.40.20.167:22",
"hostname": "82unkpb5.emsenhuber.net"
},
{
"service": "https://10.40.20.135:8007",
"hostname": "pbs.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": false
}
},
"source": "cloudflare",
"created_at": "2026-07-27T13:53:23.512528Z"
}
}

View File

@@ -1 +1,20 @@
{"success":true,"errors":[],"messages":[],"result":{"tunnel_id":"7a322261-380a-447b-97d2-8ebce99f5ed1","version":4,"config":{"ingress":[{"service":"http://10.40.20.167:5678","hostname":"n8n.emsenhuber.net"},{"service":"http://10.40.20.167:3001","hostname":"n8n-mcp.emsenhuber.net"},{"service":"http://10.40.20.167:3000","hostname":"gitea-mcp.emsenhuber.net"},{"service":"ssh://10.40.20.167:22","hostname":"82unkpb5.emsenhuber.net"},{"service":"https://10.40.20.135:8007","hostname":"pbs.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"http_status:404"}],"warp-routing":{"enabled":false}},"source":"cloudflare","created_at":"2026-07-27T13:53:23.512528Z"}} {
"tunnel_id": "7a322261-380a-447b-97d2-8ebce99f5ed1",
"version": 6,
"config": {
"ingress": [
{
"service": "http://10.40.20.167:5678",
"hostname": "n8n.emsenhuber.net"
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": false
}
},
"source": "cloudflare",
"created_at": "2026-08-30T10:19:30.297725Z"
}

View File

@@ -0,0 +1,41 @@
{
"success": true,
"errors": [],
"messages": [],
"result": {
"tunnel_id": "220c9958-401b-4111-9451-f3db09bba2d5",
"version": 6,
"config": {
"ingress": [
{
"service": "http://10.40.20.157:3000",
"hostname": "gitea.emsenhuber.net"
},
{
"service": "http://10.40.20.173:3000",
"hostname": "pve-mcp.emsenhuber.net"
},
{
"service": "http://10.40.20.127",
"hostname": "vw.emsenhuber.net"
},
{
"service": "http://10.40.20.10:8080",
"hostname": "ds-bf.emsenhuber.net"
},
{
"service": "ssh://10.40.20.10:22",
"hostname": "3k23uow7.emsenhuber.net"
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": true
}
},
"source": "cloudflare",
"created_at": "2026-08-29T17:30:22.531419Z"
}
}

View File

@@ -1 +1,24 @@
{"success":true,"errors":[],"messages":[],"result":{"tunnel_id":"220c9958-401b-4111-9451-f3db09bba2d5","version":6,"config":{"ingress":[{"service":"http://10.40.20.157:3000","hostname":"gitea.emsenhuber.net"},{"service":"http://10.40.20.173:3000","hostname":"pve-mcp.emsenhuber.net"},{"service":"http://10.40.20.127","hostname":"vw.emsenhuber.net"},{"service":"http://10.40.20.10:8080","hostname":"ds-bf.emsenhuber.net"},{"service":"ssh://10.40.20.10:22","hostname":"3k23uow7.emsenhuber.net"},{"service":"http_status:404"}],"warp-routing":{"enabled":true}},"source":"cloudflare","created_at":"2026-08-29T17:30:22.531419Z"}} {
"tunnel_id": "220c9958-401b-4111-9451-f3db09bba2d5",
"version": 7,
"config": {
"ingress": [
{
"service": "http://10.40.20.157:3000",
"hostname": "gitea.emsenhuber.net"
},
{
"service": "http://10.40.20.127",
"hostname": "vw.emsenhuber.net"
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": true
}
},
"source": "cloudflare",
"created_at": "2026-08-30T08:50:21.241084Z"
}

View File

@@ -1 +1,44 @@
{"success":true,"errors":[],"messages":[],"result":{"tunnel_id":"48d3b0b6-7233-4781-a76a-1de4a5e957ce","version":2,"config":{"ingress":[{"service":"http://10.40.20.40:80","hostname":"vw.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"http://10.40.20.40:80","hostname":"hass.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"http://10.40.20.40:80","hostname":"n8n.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"http://10.40.20.40:80","hostname":"gitea.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"http_status:404"}],"warp-routing":{"enabled":false}},"source":"cloudflare","created_at":"2026-08-27T05:45:54.534390Z"}} {
"tunnel_id": "48d3b0b6-7233-4781-a76a-1de4a5e957ce",
"version": 2,
"config": {
"ingress": [
{
"service": "http://10.40.20.40:80",
"hostname": "vw.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http://10.40.20.40:80",
"hostname": "hass.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http://10.40.20.40:80",
"hostname": "n8n.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http://10.40.20.40:80",
"hostname": "gitea.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": false
}
},
"source": "cloudflare",
"created_at": "2026-08-27T05:45:54.534390Z"
}

View File

@@ -0,0 +1,59 @@
{
"success": true,
"errors": [],
"messages": [],
"result": {
"tunnel_id": "f7fdf3a6-b67d-4567-93b1-b0f273794d09",
"version": 3,
"config": {
"ingress": [
{
"service": "https://10.40.20.100:8006",
"hostname": "pve.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "ssh://10.40.20.100:22",
"hostname": "j44d6tua.emsenhuber.net"
},
{
"service": "http://10.40.20.232",
"hostname": "ccu.emsenhuber.net"
},
{
"service": "http://10.40.20.107:8081",
"hostname": "iobroker.emsenhuber.net"
},
{
"service": "http://10.40.20.106:8080",
"hostname": "z2m25.emsenhuber.net"
},
{
"service": "https://10.40.20.126:9443",
"hostname": "portainer.emsenhuber.net",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http://10.40.20.105:8123",
"hostname": "hass.emsenhuber.net"
},
{
"service": "http://10.40.20.105:9583",
"hostname": "ha-mcp.emsenhuber.net"
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": true
}
},
"source": "cloudflare",
"created_at": "2026-07-16T11:36:24.193655Z"
}
}

View File

@@ -1 +1,20 @@
{"success":true,"errors":[],"messages":[],"result":{"tunnel_id":"f7fdf3a6-b67d-4567-93b1-b0f273794d09","version":3,"config":{"ingress":[{"service":"https://10.40.20.100:8006","hostname":"pve.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"ssh://10.40.20.100:22","hostname":"j44d6tua.emsenhuber.net"},{"service":"http://10.40.20.232","hostname":"ccu.emsenhuber.net"},{"service":"http://10.40.20.107:8081","hostname":"iobroker.emsenhuber.net"},{"service":"http://10.40.20.106:8080","hostname":"z2m25.emsenhuber.net"},{"service":"https://10.40.20.126:9443","hostname":"portainer.emsenhuber.net","originRequest":{"noTLSVerify":true}},{"service":"http://10.40.20.105:8123","hostname":"hass.emsenhuber.net"},{"service":"http://10.40.20.105:9583","hostname":"ha-mcp.emsenhuber.net"},{"service":"http_status:404"}],"warp-routing":{"enabled":true}},"source":"cloudflare","created_at":"2026-07-16T11:36:24.193655Z"}} {
"tunnel_id": "f7fdf3a6-b67d-4567-93b1-b0f273794d09",
"version": 4,
"config": {
"ingress": [
{
"service": "http://10.40.20.105:8123",
"hostname": "hass.emsenhuber.net"
},
{
"service": "http_status:404"
}
],
"warp-routing": {
"enabled": true
}
},
"source": "cloudflare",
"created_at": "2026-08-30T08:50:25.151983Z"
}

View File

@@ -0,0 +1,44 @@
[
{
"name": "ds-fm.emsenhuber.net",
"type": "CNAME",
"content": "b697d124-53bf-4a33-8fcf-384cced4db1a.cfargotunnel.com",
"proxied": true,
"id": "0da61c6143bfd0d5f9c47ca4b4147b06"
},
{
"name": "gateway-fm.emsenhuber.net",
"type": "CNAME",
"content": "b697d124-53bf-4a33-8fcf-384cced4db1a.cfargotunnel.com",
"proxied": true,
"id": "54ac8b816765b45191469678891389bd"
},
{
"name": "gitea.emsenhuber.net",
"type": "CNAME",
"content": "220c9958-401b-4111-9451-f3db09bba2d5.cfargotunnel.com",
"proxied": true,
"id": "3d6053529f99fc5832d9956ee6e8834c"
},
{
"name": "hass.emsenhuber.net",
"type": "CNAME",
"content": "f7fdf3a6-b67d-4567-93b1-b0f273794d09.cfargotunnel.com",
"proxied": true,
"id": "0841148f401b5db40fa02f69d0f0e848"
},
{
"name": "n8n.emsenhuber.net",
"type": "CNAME",
"content": "7a322261-380a-447b-97d2-8ebce99f5ed1.cfargotunnel.com",
"proxied": true,
"id": "40b311a40e41fbeae85ca097a4036e6c"
},
{
"name": "vw.emsenhuber.net",
"type": "CNAME",
"content": "220c9958-401b-4111-9451-f3db09bba2d5.cfargotunnel.com",
"proxied": true,
"id": "2971d3ba9410e841b35ec58ffda59be4"
}
]

View File

@@ -0,0 +1,146 @@
# Handoff — TP16.3 §2/§3 durch: drei Connectoren live, eine Entscheidung offen
> **2026-08-31, Vor-Ort-Session.** Nachfolger von
> [`handoff-20260831-tp16.3-vorort-session.md`](handoff-20260831-tp16.3-vorort-session.md).
> **Nicht** Teil dieser Session: §5 (CNAME-Flip) und §6 (Abbau).
---
## 1. Was jetzt steht
`bf-public` ist **healthy mit 12 Connectoren** (3 APs × 4 CF-Edges) — der Tunnel hatte vorher
**null**. Je ein `cloudflared 2026.6.1` läuft auf ap1, ap2 und ap3.
| | ap1 | ap2 | ap3 |
|---|---|---|---|
| `container`-Paket | 7.24.1 ✅ | 7.24.1 ✅ | 7.24.1 ✅ |
| `device-mode container` | `yes` ✅ | `yes` ✅ | `yes` ✅ |
| Container | `R` running | `R` running | `R` running |
| `.tar` gelöscht | ✅ | ✅ | ✅ |
| Flash frei | 54,0 MiB | 54,6 MiB | 55,0 MiB |
WLAN auf allen drei zurück, CAPs `state=Ok` am Manager. Backups je AP liegen auf dem Gerät
(`vor-tp16.3-apN.backup`) **und** lokal unter `~/tp16.3/backups/` — inklusive vollständigem
`/export` (nicht der MCP-Export, s. Punkt 4).
---
## 2. 🔴 Die eine offene Entscheidung: Compliance steht auf 128/135
Nach vollem Refresh (Backup → Intended → Compliance, **nicht** die stale Zahl von vorher):
| Gerät | Regel | Inhalt |
|---|---|---|
| ap1 / ap2 / ap3 | `ip-address` | `/ip address add address=172.17.0.1/24 interface=veth-cf` |
| ap1 / ap2 / ap3 | `ip-firewall-nat` | `masquerade`, Kommentar `TP31: cloudflared egress` |
| crs326-core | `wifi-security` | `ft-over-ds` — **offene Transaktion `TP25-fund-e-ft-over-ds`**, kein Versehen |
**Ursache ist ein Widerspruch zwischen zwei Dokumenten**, kein Ausrutscher: Runbook §3.4 schreibt
Direkt-Writes für `veth`/`ip address`/`nat` vor, die Governance stellt `/ip address` und
`/ip firewall` unter GC-Pflicht. Ich bin dem Runbook gefolgt.
⚠️ **Korrektur 2026-08-31 (nachgetragen):** die siebte Drift wurde hier zuerst dem 7.24.1-Upgrade
zugeschrieben — falsch. `gc-transaktion.py status` führt **`TP25-fund-e-ft-over-ds` als OFFEN**
(gesetzt 08:09, Core-Upgrade erst 14:36). Sie ist Absicht einer laufenden Messung und braucht **keine**
Entscheidung. **Merkregel: vor jeder Drift-Bewertung `gc-transaktion.py status` lesen** — Drift aus
einem laufenden Versuch sieht identisch aus wie ein Versehen.
**Die sechs Drifts sind inhaltlich richtig** — die Config *soll* dort stehen, ohne sie läuft kein
Container. Der saubere Weg ist, sie ins **Soll** zu heben (AP-Template bzw. ConfigContext
`ap-common`), damit die Compliance sie als konform sieht.
🔴 **Kein Re-Baseline** (macht die Drift-Erkennung wertlos) und **kein Rückbau** (killt die Tunnel).
⚠️ `/interface veth` erzeugt **keine** Drift — dafür existiert keine ComplianceRule. Wenn das Soll
angepasst wird, gehört die Frage dazu, ob `veth` überhaupt erfasst sein soll.
---
## 3. 🔴 Drei Runbook-Fehler, die diese Session aufgedeckt hat
**(a) Die Flash-Rechnung war um Faktor 2,5 zu optimistisch.** §3.5 rechnete mit „28 MB `.tar`
+ ~28 MB entpackt ≈ 39 MB Rest". Die 28 MB sind die **komprimierte** Archivgrösse; entpackt misst
`cloudflare/cloudflared:2026.6.1` **72.926.208 B = 69,5 MiB**. Der Import braucht Tar **und**
entpacktes Abbild gleichzeitig ⇒ 95,7 MiB gegen ~90 MiB frei. **Das offizielle Image passt auf
keinen der drei APs.**
⚠️ **`docker image inspect .Size` führt in die Irre** (meldete 27.463.950). Für Flash-Budgets
`docker export | wc -c` messen.
**Lösung, ausgerollt:** eigenes `FROM scratch`-Image aus dem — nachweislich **statisch gelinkten** —
Binary (36,8 MB) plus CA-Bundle: **35,3 MiB**, Spitzenbedarf 70,6 MiB. Build-Skript liegt jetzt im
Repo (`tools/build-cfd-image.py`), ein Neubau ist **byte-identisch** (`f818bf40…`).
🔑 Der Pin ist die **Binary-SHA256 `e93824f6…`**, und cloudflared bestätigt sie beim Start selbst
(`Version 2026.6.1 (Checksum e93824f6…)`) — im Log aller drei APs nachlesbar.
**(b) `docker save` taugt seit Docker 25 nicht mehr als Quelle.** Mit containerd-Image-Store schreibt
es OCI-Layout (`blobs/sha256/`, gzip-Layer); RouterOS will docker-v1 und meldet
**`no config found in manifest`** — klingt nach kaputtem Image, ist das Format.
(Konverter für den Fall, dass man doch ein Docker-Image braucht: `tools/oci2docker.py`, prüft die
Layer-Digests gegen `rootfs.diff_ids`.)
**(c) `/container/envs` heisst auf ROS 7.24.1 `list=`, nicht `name=`.** Mit `name=` kommt
`bad parameter name` bzw. `unknown parameter name`. **Auch MikroMCPs `manage_container_env` benutzt
`name=` und ist damit für dieses Feld unbrauchbar.**
---
## 4. Vier weitere Funde
- 🔴 **Der `device-mode`-Bestätigungs-Boot installiert KEINE Pakete.** Auf ap1 lag die `.npk` schon
bereit, als `device-mode` gesetzt wurde — der Stromzyklus hat `container: yes` bestätigt und die
Datei **verbraucht, ohne zu installieren**. **Reihenfolge ist Pflicht: erst Paket-Reboot, dann
`device-mode`.** (Nachholen ging per SSH, ohne zweiten Gang zum Gerät.)
- 🟡 **`A - AVAILABLE` in `/system package print` ist ein Download-Katalog, kein Paket auf dem Gerät.**
Gegenprobe: `crs326-core` hat 2,9 MB frei von 16 MB, listet aber `wifi-qcom` (10,1 MB) und
`rose-storage` (4,3 MB) als „available"; die Grössen sind arch-spezifisch. `enable` löste einen
Internet-Fetch aus — auf ap1/ap2 per `device-mode=home` gesperrt. **Der `.npk`-Weg bleibt richtig.**
- 🟡 **MikroMCPs `export_config` exportiert unvollständig:** 301 Bytes gegen 6,2 KiB bei direktem
`/export`. Ein Rollback-Artefakt, das existiert und trotzdem fast leer ist — dieselbe Klasse wie
„Lücke statt Fehlermeldung". Für Backups `/export file=…` per SSH.
- ⚠️ **WLAN-Abnahme nur teilweise belegbar:** `main` (3 Clients) und `iot` (27) haben frische Leases;
`media`/`untrusted`/`guest` hatten **schon vor** dem Umbau null Clients. Kein Rückschritt, aber
auch kein Beleg — nicht als „grün" buchen.
---
## 5. 🔴 Sicherheitsvorfall in dieser Session — Rotation nötig
Beim Suchen des Nautobot-Tokens hat eine unzureichende `sed`-Maskierung
(`grep -n … | sed 's/:.*TOKEN/…/' | cut -c1-60`) **zwei Werte im Klartext ins Transkript geschrieben**:
- `NAUTOBOT_SUPERUSER_API_TOKEN`
- `NAUTOBOT_GITEA_TOKEN`
beide aus `docs/nas-vlan20/stacks/210-nautobot.yml`. **Als verbrannt behandeln → TP30.**
Das ist exakt die Falle, die seit 2026-08-28 in der CLAUDE.md steht (der `SHELLY_DEVICE_PW`-Fall).
**Merkregel, die offenbar noch nicht scharf genug formuliert war:** ein Maskierungs-Muster, das auf
`:` ankert, matcht bei `grep -n` den **Zeilennummer-Präfix** statt den Wert. Sicher ist nur
`sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'` — auf den **Schlüsselnamen** ankern,
nie auf ein Trennzeichen, das auch im Präfix vorkommt.
---
## 6. Repo-Zustand
- Branch `claude/nervous-williams-acf000` ist hier **hereingemergt**. Konflikte aufgelöst:
- **Nummernkonflikt TP37**: `main` hatte TP37 = *Nautobot als Netzwerkdoku* (User-Okay 08-30),
der Branch TP37 = *OOB-Mgmt*. Letzterer ist auf **TP38** umnummeriert.
✅ **Von Christoph bestätigt (2026-08-31).**
- **Drei CF-Rollback-Snapshots hatten denselben Dateinamen bei verschiedenem Inhalt**
(`bf-mm` v4/6 Regeln vs. v6/2 Regeln, analog `bf-nas`, `bf-pve`) — zwei Zeitpunkte, beide
„vor-tp16.3-schnitt" genannt. **Keiner verworfen:** der ältere liegt jetzt als
`*-vor-hostname-schnitt.json` daneben.
- Neu: `tools/build-cfd-image.py`, `tools/oci2docker.py`.
- `mcp/mikromcp/config/routers.yaml` meldet weiter `rosVersion: "7.24"` für alle fünf (live 7.24.1) —
kosmetisch, unangetastet gelassen.
---
## 7. Nächste Schritte
1. **Entscheidung zu Punkt 2** (sechs Drifts ins Soll heben) — vor dem Flip, sonst geht man mit
erklärter, aber ungeklärter Drift in den Produktivpfad.
2. **§5 CNAME-Flip**, einzeln mit Abnahme dazwischen, Testname zuerst (§5.1).
▸ Fertiger Übergabeprompt: [`tp16.3-paragraph5-session-handoff-prompt.md`](tp16.3-paragraph5-session-handoff-prompt.md).
🔴 `healthy` beweist nur, dass der Connector mit Cloudflare redet — **nicht**, dass das Origin
antwortet. Der Streckenbeweis bis Caddy ist §5.1.
3. **§6 Abbau** frühestens 7 Tage nach dem Flip.
4. **TP30**: die zwei Tokens aus Punkt 5 rotieren.

View File

@@ -0,0 +1,139 @@
# Handover — TP16.3 §2/§3: Container-Bootstrap auf ap1 / ap2 / ap3
> ✅ **ERLEDIGT am 2026-08-31.** Nachfolge-Dokument mit Ergebnis, drei korrigierten Runbook-Fehlern
> und der einen offenen Entscheidung (Compliance 128/135):
> [`handoff-20260831-tp16.3-paragraph2-3-durch.md`](handoff-20260831-tp16.3-paragraph2-3-durch.md).
> Dieses Dokument bleibt als Ausgangslage stehen — **Abschnitt 3 und 7 sind inhaltlich überholt**
> (Flash-Budget, `docker save`, `envs`-Parametername, Reihenfolge Paket/device-mode).
> **Erstellt 2026-08-31.** Zielsession: **§2 + §3** des Runbooks
> [`tp16.3-bf-public-ap-connector-runbook.md`](tp16.3-bf-public-ap-connector-runbook.md) — also
> `container.npk`, `device-mode`, Container. **Vor Ort**, weil jeder AP eine physische Bestätigung braucht.
> **Nicht** Teil dieser Session: der CNAME-Flip (§5) und der Abbau (§6).
---
## 1. Was vor dir schon erledigt ist — nicht nochmal machen
| Punkt | Stand | Nachweis |
|---|---|---|
| **§1 Blocker A** — Redirect-Schleife | ✅ 08-31 | `bf-public`-Ingress → `https://10.40.20.40:443`, `originServerName` je Regel, zurückgelesen |
| **§1 Blocker B** — Firewall `vlan99→vlan20:443` | ✅ 08-31 | via **Nautobot GC** (Plan `Completed`), Position vor `TP5: log-only` geprüft, funktional gegen Caddy nachgewiesen |
| **§1 Blocker C** — `ROUTER_AP3_PASS` | ✅ 08-31 | in `~/.secrets/credentials.env`, REST-Auth ap3 = **200** |
| Rollback-Snapshots | ✅ 08-30 | `docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json` |
| ROS-Upgrade auf **7.24.1** | ✅ 08-31 (User) | alle fünf Geräte, RouterBOOT mitgezogen |
**⇒ Die Session startet bei §2.** Wer §1 nochmal anfasst, arbeitet doppelt.
---
## 2. Die eine Änderung gegenüber dem Runbook-Stand vom 08-30
🎉 **Aus zwei `container.npk` ist eine geworden.** Christoph hat am 08-31 alle fünf MikroTik auf
**7.24.1** gehoben. Damit gilt:
```
container-7.24.1-arm64.npk → ap1, ap2 UND ap3
```
Die frühere Zwei-Dateien-Regel war **rein versionsbedingt**, nicht modellbedingt — `cAP ax` und
`hAP ax²` teilen sich die `arm64`-Pakete. Das Runbook ist entsprechend umgeschrieben (§0, §2.1, §3.1,
Falle 1).
⚠️ **Die Regel dahinter bleibt:** `container.npk` muss **exakt** zur ROS-Version passen. Vor dem Upload
`/system resource print` lesen. Falsche Datei ⇒ Paket ist nach dem Reboot **inaktiv, ohne Fehlermeldung**.
---
## 3. Ablauf in einem Satz
Je AP, **einzeln und vollständig, Reihenfolge ap3 → ap2 → ap1**:
`.npk` hoch → Reboot (SSH, **nicht** MikroMCP) → `/system package print` prüfen →
`device-mode update container=yes` → **physisch bestätigen** → `veth` + `masquerade` →
`.tar` hoch → `/container add file=` → starten → `.tar` löschen → Log prüfen.
**Details, Befehle und Abnahmekriterien: Runbook §2 und §3.** Hier nur die Punkte, die man
erfahrungsgemäß falsch macht:
- 🔴 **Reboot geht NICHT über MikroMCP** (ROS kennt den `delay`-Parameter nicht, `run_command` ist für
`/system reboot*` gesperrt) → `SSHPASS="$PW" sshpass -e ssh admin@<ip> "/system reboot"`.
**Niemals `sshpass -p`** — das schreibt das Passwort in `ps`.
- 🔴 **`~/.secrets/credentials.env` in zsh sourcen schlägt still fehl** (rohe WireGuard-Zeilen ab ~Z. 69).
Variablen bleiben **leer**, und der Fehler sieht aus wie ein ungültiger Token. **Immer `bash -c`.**
- 🔴 **Kein PoE an den CRS326** (beide haben gar kein PoE-Menü) ⇒ kein Remote-Power-Cycle. Die
Bestätigung ist wirklich physisch: **Stecker ziehen/stecken** (Weg A, eindeutig) oder Reset-Knopf am
*laufenden* Gerät kurz drücken (Weg B). Weg B **nie während des Bootens** — das wäre ein Werksreset.
- 🔴 **Ein Container pro AP.** Nach dem Import bleiben ~39 MB. Kein Caddy, kein AGH dazu.
- ⚠️ **Jeder AP rebootet zweimal** → WLAN dieses APs ist je ~1–2 min weg. Die CAPs holen ihre Config
per L2-Discovery vom Core. **Nicht ungeduldig nachprovisionieren.**
- ⚠️ **WLAN-Abnahme ist eine frische DHCP-Lease je SSID, nicht die Registration-Table.**
**Erwartetes Ergebnis am Ende:** `bf-public` meldet `healthy` mit Connectoren (Richtung 12 = 3 APs × 4
CF-Edge). 🔴 **`healthy` heißt nur „Connector redet mit Cloudflare", nicht „Origin antwortet"** — der
Beweis für die Strecke bis Caddy ist §5.1, und der gehört in die **Folge**session.
---
## 4. Was du unterwegs sehen wirst und **nicht** als Fehler behandeln darfst
**`/tool fetch` von einem AP auf `https://10.40.20.40` schlägt mit einem TLS-Alert fehl.**
Das ist **korrekt und erwartet**: Caddy weist Handshakes ohne passenden SNI ab (gemessen: ohne SNI
`000`, mit korrektem SNI `200`). Der Alert **beweist sogar**, dass das Paket ankommt — ein TLS-Alert
kann nur vom Server kommen, die Firewall-Freigabe trägt also. cloudflared schickt den richtigen SNI
über `originServerName`. **Nicht „reparieren".**
---
## 5. Offene Punkte, die NICHT in diese Session gehören
1. ✅ **Die 134/135 sind ABSICHT — nichts tun.** `crs326-core` / `wifi-security`: `sec-main` hat
`ft-over-ds=no` statt `yes`. Das ist **TP25 Fund E, Hebel 1**, am 2026-08-31 um 08:09 bewusst gesetzt
und als **offene Transaktion `TP25-fund-e-ft-over-ds`** geführt (Scope `wifi-security`, Ablauf
2026-09-03, gehört einer anderen Session). **Nicht anfassen, nicht grün machen, nicht re-baselinen** —
das würde einen laufenden Versuch unsichtbar machen.
*(Eine frühere Fassung dieses Handovers schrieb die Drift dem 7.24.1-Upgrade zu. Falsch: der Hebel
kam um 08:09, der Core wurde erst 14:36 gehoben.)*
🔑 **Merkregel für die Session:** wenn Compliance nicht 135/135 zeigt, **zuerst**
`nautobot/gc-transaktion.py status` lesen. Drift aus einem laufenden Versuch sieht genauso aus wie
ein Versehen.
2. 🆕 **[TP37](teilprojekte.md#tp37) — OOB-Management, angelegt 2026-08-31.** Je ein untagged-VLAN99-Port
auf beiden Switches (Kandidat `ether23`, auf core **und** branch frei). Auslöser: beim Core-Neustart
war `crs326-branch` nicht erreichbar, weil sein Mgmt **ausschließlich** über den Core läuft.
🔴 Nebenbefund mit eigenem Gewicht: **der Branch hat gar keine `input`-Firewallregeln** — sein Schutz
ist heute allein die Erreichbarkeit über den gehärteten Core.
💡 **Berührt diese Session, ist aber keine Voraussetzung:** hier rebooten dreimal Geräte, deren Mgmt
am Core hängt. Wer vor Ort ist, kann `ether23` schon mal beschriften — gebaut wird TP37 separat.
3. 🟡 **`mcp/mikromcp/config/routers.yaml` hält `rosVersion: "7.24"`** für alle fünf — jetzt `7.24.1`.
⚠️ **Die Datei liegt auf `main`, nicht auf diesem Branch** (der Worktree hat eine ältere Kopie
**ohne** ap3). Der Fix gehört auf `main`, nicht hierher — sonst gibt es einen Konflikt.
4. **CNAME-Flip (§5) und Abbau (§6)** — eigene Session, §6 frühestens 7 Tage nach dem Flip.
---
## 6. Zugänge und Werkzeuge
| Was | Wie |
|---|---|
| APs | MikroMCP `ap1`/`ap2`/`ap3` (Reads), SSH `admin@10.40.99.11/.12/.13`, Creds `ROUTER_AP{1,2,3}_PASS` in `~/.secrets` |
| Reboot | `bash -c 'set -a; . ~/.secrets/credentials.env; set +a; SSHPASS="$ROUTER_AP3_PASS" sshpass -e ssh admin@10.40.99.13 "/system reboot"'` |
| Config-Änderungen an MikroTik | **ausschließlich Nautobot GC** (`nautobot/gc-manual-plan.py`), Token aus `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored, **nur im Haupt-Checkout**) |
| Cloudflare | `CF_API_TOKEN` / `CF_ACCOUNT_ID` in `~/.secrets`, Zone `1b5bc02fdf61b6b989afde543f3f4134` |
| Caddy | `caddy-bf1`, LXC 130 auf pve, `10.40.20.40`; Logs `ssh root@10.40.20.100 'pct exec 130 -- journalctl -u caddy -n 50'` |
⚠️ **`device-mode`- und Container-Schritte sind KEINE Golden-Config-Sache** — sie sind Bootstrapping am
Gerät (wie die genehmigte Ausnahme #2 beim CRS326-Bring-up). Die GC-Pflicht gilt für
`/ip firewall`, `/interface` & Co. **Wenn die Session eine Firewall- oder Interface-Zeile braucht:
über `gc-manual-plan.py`, nicht per Direkt-Write.**
---
## 7. Abnahme dieser Session
- [ ] `/system package print` zeigt auf **allen drei** APs `container` in **7.24.1**, nicht disabled
- [ ] `/system device-mode print` zeigt `container: yes` auf allen dreien
- [ ] `/container print` zeigt je AP **einen** Container `running`
- [ ] `.tar` je AP gelöscht (Flash zurück)
- [ ] `bf-public` meldet `healthy` mit Connectoren > 0
- [ ] WLAN je AP zurück — **frische DHCP-Lease pro SSID**, nicht Registration-Table
- [ ] Compliance nicht schlechter als **134/135** — die `wifi-security`-Drift ist die offene
TP25-Transaktion aus Punkt 5.1 und **muss** bestehen bleiben

View File

@@ -27,12 +27,21 @@ Mgmt (VLAN99, Ist 2026-08-16): core `.99.1` · branch `.99.2` · ap1 `.99.11` ·
## Ist-Stand (Kurz) ## Ist-Stand (Kurz)
**Stand 2026-08-28 — live gegen die Geräte geprüft (MikroMCP/REST), nicht aus der Doku abgeschrieben.** **Stand 2026-08-31 — live gegen die Geräte geprüft (MikroMCP/REST/SSH), nicht aus der Doku abgeschrieben.**
**Die Compliance-Zeile unten ist am 2026-08-23 gegen die Nautobot-API nachgezogen.** **Compliance am 2026-08-31 nach vollem Refresh (Backup→Intended→Compliance) gelesen: 128/135.**
*(Die Fassung vom 2026-08-16 stand bis zum 18. hier und war an vier Stellen überholt. Wer diesen Block *(Die Fassung vom 2026-08-16 stand bis zum 18. hier und war an vier Stellen überholt. Wer diesen Block
liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)* liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)*
- 🆕 **MCP-Bestand 2026-08-28 korrigiert:** **9 MCPs identisch** in `~/.claude.json` und `~/.config/opencode/opencode.jsonc` (vorher Asymmetrie 7 vs. 11 — `portainer-mcp`/`shelly-mcp` raus, `gitea-mcp`/`n8n-mcp` rein). **Mac mini = n8n-Host, NICHT zentraler MCP-Host** (Performance-Restriktion); zentraler MCP-Host = **DS923** (`nas-bf`, Portainer Endpoint 14). Inventur-Stand `mcp/INVENTORY.md` + Policy `docs/mcp-host-policy.md`. - 🆕 **MCP-Bestand 2026-08-28 korrigiert:** **9 MCPs identisch** in `~/.claude.json` und `~/.config/opencode/opencode.jsonc` (vorher Asymmetrie 7 vs. 11 — `portainer-mcp`/`shelly-mcp` raus, `gitea-mcp`/`n8n-mcp` rein). **Mac mini = n8n-Host, NICHT zentraler MCP-Host** (Performance-Restriktion); zentraler MCP-Host = **DS923** (`nas-bf`, Portainer Endpoint 14). Inventur-Stand `mcp/INVENTORY.md` + Policy `docs/mcp-host-policy.md`.
- 🆕 **MCP-Migration FQDN→IP (2026-08-29, TP35 Schicht 1):** `gitea-mcp` und `n8n-mcp` in `~/.config/opencode/opencode.jsonc` von CF-FQDN auf LAN-IP umgestellt — `gitea-mcp` → `http://10.40.20.167:3000/mcp` (macmini, **ohne** Bearer, intern), `n8n-mcp` → `http://10.40.20.167:3001/mcp` (mit `Bearer {env:N8N_MCP_BEARER}`). **Befund dabei:** der Handoff 1.2 nannte für gitea-mcp die falsche IP `.157:3000` (das ist die Gitea-Web-UI, `400 Invalid CSRF`); der echte gitea-mcp läuft auf `.167:3000` (`serverInfo: gitea-mcp 0.0.10`). `cloudflare`-MCP bleibt auf dem offiziellen FQDN (User-Direktive). - 🆕 **MCP-Migration FQDN→IP (2026-08-29, TP35 Schicht 1):** `gitea-mcp` und `n8n-mcp` in `~/.config/opencode/opencode.jsonc` von CF-FQDN auf LAN-IP umgestellt — `gitea-mcp` → `http://10.40.20.167:3000/mcp` (macmini, **ohne** Bearer, intern), `n8n-mcp` → `http://10.40.20.167:3001/mcp` (mit `Bearer {env:N8N_MCP_BEARER}`). **Befund dabei:** der Handoff 1.2 nannte für gitea-mcp die falsche IP `.157:3000` (das ist die Gitea-Web-UI, `400 Invalid CSRF`); der echte gitea-mcp läuft auf `.167:3000` (`serverInfo: gitea-mcp 0.0.10`). `cloudflare`-MCP bleibt auf dem offiziellen FQDN (User-Direktive).
- 🆕 **TP16.3 §2/§3 durchgezogen (2026-08-31, Vor-Ort):** je ein `cloudflared`-Container auf
**ap1/ap2/ap3**, `bf-public` **healthy mit 12 Connectoren** — der Tunnel hat damit zum ersten Mal
überhaupt Connectoren. 🔴 **Zwei Runbook-Fehler dabei aufgedeckt:** die Flash-Rechnung war um Faktor
2,5 zu optimistisch (entpacktes cloudflared = **69,5 MiB**, nicht 28 MB ⇒ das offizielle Image passt
auf keinen der drei APs; Ersatz ist ein selbst gebautes `FROM scratch`-Image mit 35,3 MiB, Binary-SHA
`e93824f6…` gepinnt), und `/container/envs` heisst `list=`, nicht `name=`.
🔴 **Offen: Compliance 128/135.** §3.4 verlangt Direkt-Writes für `ip address`/`nat`, die unter
GC-Kontrolle stehen ⇒ **sechs neue `extra`-Drifts** (zwei je AP), inhaltlich richtig, aber nicht im
Soll. Braucht eine Entscheidung (ins Template heben), **kein Re-Baseline**. Details im TP16.3-Abschnitt.
- 🆕 **TP37 angelegt (2026-08-30, User-Okay):** Nautobot soll durchgehende Netzwerkdoku werden, erster - 🆕 **TP37 angelegt (2026-08-30, User-Okay):** Nautobot soll durchgehende Netzwerkdoku werden, erster
Anwendungsfall die CF-Connectoren. 🔴 **Befund dabei, live gegen die API:** Nautobot kennt heute Anwendungsfall die CF-Connectoren. 🔴 **Befund dabei, live gegen die API:** Nautobot kennt heute
**nur die 8 Netzgeräte** (4 aktiv, 4 decommissioned) — `ipam.Service`, `CustomField`, `Relationship`, **nur die 8 Netzgeräte** (4 aktiv, 4 decommissioned) — `ipam.Service`, `CustomField`, `Relationship`,
@@ -51,11 +60,43 @@ liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur d
laut Entscheid auf **ap1/ap2**, dort fehlen aber `container`-Paket **und** `device-mode container=yes` laut Entscheid auf **ap1/ap2**, dort fehlen aber `container`-Paket **und** `device-mode container=yes`
(physische Bestätigung nötig). Bis dahin stehen `bf-mm`/`bf-nas`/`bf-pve` mit **6 Containern**. (physische Bestätigung nötig). Bis dahin stehen `bf-mm`/`bf-nas`/`bf-pve` mit **6 Containern**.
Backups: `docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json`. Backups: `docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json`.
- 🆕 **TP16.3/TP31 Schritt 5 vorbereitet (2026-08-30):** Runbook für die Vor-Ort-Session geschrieben
([`tp16.3-bf-public-ap-connector-runbook.md`](tp16.3-bf-public-ap-connector-runbook.md)), CF-Rollback-Snapshots
unter `docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json` **neu angelegt** (es gab sie vorher nicht).
`docs/tp31-schritt5-runbook.md` ist damit **abgelöst** (read-only-Header): es kannte ap3 nicht,
unterstellte einen Registry-Pull (`fetch: no` auf ap1/ap2) und enthielt eine **falsche**
Firewall-Freigabe-Behauptung. **CF-Stand:** `bf-public` `inactive` / 0 Connectoren; die vier
öffentlichen Namen laufen noch über bf-mm/bf-nas/bf-pve **an Caddy vorbei** direkt aufs Backend.
- 🆕 **TP38 angelegt (2026-08-31):** OOB-Management — je ein untagged-VLAN99-Port auf beiden Switches.
Auslöser: beim Core-Neustart war `crs326-branch` nicht erreichbar, weil sein Mgmt **ausschließlich**
über den Core läuft (`vlan99` dort `tagged=ether1,ether24,bridge`, kein untagged-Member).
🔴 **Nebenbefund mit eigenem Gewicht: `crs326-branch` hat gar keine `input`-Firewallregeln** — sein
Schutz ist heute allein die Erreichbarkeit über den gehärteten Core. Das ist die Hauptauflage des TPs.
- 🆕 **ROS-Upgrade 7.24.1 auf allen fünf (2026-08-31, User).** In Nautobot nachgezogen: **ap1** stand
als einziges Gerät noch auf `7.24` (die anderen vier waren schon richtig) ⇒ auf `7.24.1` gesetzt.
⚠️ **Korrektur (2026-08-31, nachträglich):** die dabei gesehene `wifi-security`-Drift auf
`crs326-core` (`sec-main` `ft-over-ds=no` statt `yes`) hatte **nichts mit dem Upgrade zu tun** — sie
ist **TP25 Fund E, Hebel 1**, um 08:09 bewusst gesetzt (Core-Upgrade war erst 14:36) und als
**offene Transaktion `TP25-fund-e-ft-over-ds`** geführt (Scope `wifi-security`, Ablauf 2026-09-03).
**Die 134/135 sind Absicht**, nicht Schaden: grün machen würde den laufenden Versuch unsichtbar
machen, und `gc-transaktion.py` verweigert den Commit ohnehin, solange der Scope driftet.
🔑 **Lehre:** vor jeder Drift-Bewertung `nautobot/gc-transaktion.py status` lesen — Drift aus einem
laufenden Versuch sieht identisch aus wie ein Versehen, und genau dafür gibt es das Werkzeug.
- 🆕 **TP16.3 §1-Blocker remote erledigt (2026-08-31):** Ingress von `bf-public` auf
`https://10.40.20.40:443` + `originServerName`; Firewall-Freigabe `vlan99 → vlan20:443` **über
Nautobot GC** (Plan `Completed`, Re-Baseline `ingest-core-firewall.py --apply`, danach
**135/135 compliant / 0 Drifts**); `ROUTER_AP3_PASS` in `~/.secrets`. **Offen bleibt nur die
Vor-Ort-Session** (§2/§3: `container.npk`, `device-mode`, Container) und danach Flip + Abbau.
- 🆕 **Globale Verhaltensregeln konsolidiert (2026-08-29, TP35 Schicht 3):** `~/.config/opencode/AGENTS.md` ist jetzt die **SSoT** für globale Verhaltensregeln (Secrets, VLAN-Schema, 5 Lessons, SoC, Übergabe-Mechanik, Mid-Action-Checkpoint, Commit-Hinweis, Backup-Guard). `~/.claude/CLAUDE.md` hält denselben Block als Claude-Code-Spiegelung mit Verweis auf die SSoT. opencode liest AGENTS.md mit Vorrang vor `~/.claude/CLAUDE.md`. - 🆕 **Globale Verhaltensregeln konsolidiert (2026-08-29, TP35 Schicht 3):** `~/.config/opencode/AGENTS.md` ist jetzt die **SSoT** für globale Verhaltensregeln (Secrets, VLAN-Schema, 5 Lessons, SoC, Übergabe-Mechanik, Mid-Action-Checkpoint, Commit-Hinweis, Backup-Guard). `~/.claude/CLAUDE.md` hält denselben Block als Claude-Code-Spiegelung mit Verweis auf die SSoT. opencode liest AGENTS.md mit Vorrang vor `~/.claude/CLAUDE.md`.
- **Alle fünf MikroTik laufen auf RouterOS 7.24.1** (🆕 **Angleichung am 2026-08-31 von Christoph - **Alle fünf MikroTik laufen auf RouterOS `7.24.1`** (🆕 **Angleichung am 2026-08-31 von Christoph
selbst durchgezogen, TP27 damit erledigt** — ap2 08:56, core 14:36, ap1 14:50, branch 14:52; selbst durchgezogen, TP27 damit erledigt** — ap2 08:56, core 14:36, ap1 14:50, branch 14:52;
ap3 kam bereits so). Vorher: vier auf 7.24 (08-17), nur ap3 auf 7.24.1. ap3 kam bereits so. RouterBOOT jeweils = `upgrade-firmware` = `7.24.1`, live gegengelesen).
Vorher: vier auf 7.24 (08-17), nur ap3 auf 7.24.1. Neu in der Flotte **`ap3`**
(cAP ax `cAPGi-5HaxD2HaxD`, **`10.40.99.13`**, Uplink core `ether21`).
✅ **Folge für TP16.3:** die versionsgebundene `container.npk` ist damit **eine** Datei für alle drei
APs (alle `arm64`) — die Zwei-Dateien-Regel vom 08-30 war rein versionsbedingt und ist erledigt.
*(Hier stand bis 08-29 „Alle vier MikroTik … 7.24" — durch ap3 und das Upgrade zweifach überholt.)*
- **L3 + WAN-Edge = `crs326-core`** (CRS326-24G-2S+, Technik UG): Routing, DHCP (7 Server, - **L3 + WAN-Edge = `crs326-core`** (CRS326-24G-2S+, Technik UG): Routing, DHCP (7 Server,
54 Reservierungen), DNS, NAT, **NTP-Server für den Standort** und der WG-Hub (TP10, Peers `fm`/`dw`) 54 Reservierungen), DNS, NAT, **NTP-Server für den Standort** und der WG-Hub (TP10, Peers `fm`/`dw`)
laufen hier. Mgmt-SVI **`10.40.99.1`** (vlan99), Gateway `.1` je VLAN. **WAN = `vlan100`** — DHCP-Client laufen hier. Mgmt-SVI **`10.40.99.1`** (vlan99), Gateway `.1` je VLAN. **WAN = `vlan100`** — DHCP-Client
@@ -95,7 +136,15 @@ liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur d
**16 Sites** (4 public, 12 intern mit `.bf.`-Suffix), Zertifikate ausschliesslich **16 Sites** (4 public, 12 intern mit `.bf.`-Suffix), Zertifikate ausschliesslich
per **DNS-01** mit einem eng geschnittenen `Zone:DNS:Edit`-Token (`CF_DNS_API_TOKEN`, **nicht** das per **DNS-01** mit einem eng geschnittenen `Zone:DNS:Edit`-Token (`CF_DNS_API_TOKEN`, **nicht** das
account-weite). Abnahme über den Host-Header von einem zweiten Host: 11× 200, `nautobot` 302. account-weite). Abnahme über den Host-Header von einem zweiten Host: 11× 200, `nautobot` 302.
Offen: Schritt 5–6 (Tunnel `bf-public`, Abbau). Offen: **Schritt 5–6** (Connector für `bf-public`, CNAME-Flip, Abbau der sechs Container) —
**Runbook steht: [`tp16.3-bf-public-ap-connector-runbook.md`](tp16.3-bf-public-ap-connector-runbook.md)**,
Vor-Ort-Session an ap1/ap2/ap3. ✅ **Die drei §1-Blocker sind am 2026-08-31 remote erledigt** —
**(a)** Ingress auf `https://10.40.20.40:443` + `originServerName` je Regel umgestellt (die alte
`:80`-Zeile hätte über CF eine Redirect-Schleife gebaut, weil Caddy dort 308 auf https antwortet);
**(b)** Freigabe `vlan99 → vlan20:443` **über Nautobot GC** deployt (`ip-firewall-filter` steht unter
Drift-Kontrolle, TP26 ⇒ kein Direkt-Write), Position vor `TP5: log-only` verifiziert, **135/135
compliant, 0 Drifts**; **(c)** `ROUTER_AP3_PASS` in `~/.secrets` nachgetragen (REST-Auth 200).
⇒ **Vor Ort startet die Arbeit bei §2/§3** (Pakete, `device-mode`, Container).
- **DNS/Resolver = AdGuard-Paar `agh-bf1`/`agh-bf2`** (LXC 110/111 auf pve, **`10.40.20.36`/`.37`**, - **DNS/Resolver = AdGuard-Paar `agh-bf1`/`agh-bf2`** (LXC 110/111 auf pve, **`10.40.20.36`/`.37`**,
TP23 Stufe 2 + TP31 Schritt 4 ✅ seit 2026-08-26): Upstream DoH zum CF-Gateway, conditional TP23 Stufe 2 + TP31 Schritt 4 ✅ seit 2026-08-26): Upstream DoH zum CF-Gateway, conditional
`[/bf.emsenhuber.net/] → 10.40.20.1`, 47 Rewrites (16 Service → Caddy `.20.40` + 10 Host-Namen `[/bf.emsenhuber.net/] → 10.40.20.1`, 47 Rewrites (16 Service → Caddy `.20.40` + 10 Host-Namen
@@ -162,6 +211,11 @@ Public-Strang auf `bf-public` zu heben und die drei Alt-Tunnel samt sechs Contai
> - 🔜 **Session B** — shelly-mcp-Container auf DS923 (`10.40.20.10`) deployen (live, ssh). Repo-Materialien committed (`mcp/shelly/{Dockerfile,BUILD.md,requirements.txt,.gitignore}`); `.env` und Tunnel-Secret lokal — kein MCP-Client (User-Direktive). > - 🔜 **Session B** — shelly-mcp-Container auf DS923 (`10.40.20.10`) deployen (live, ssh). Repo-Materialien committed (`mcp/shelly/{Dockerfile,BUILD.md,requirements.txt,.gitignore}`); `.env` und Tunnel-Secret lokal — kein MCP-Client (User-Direktive).
> - 🔜 **Session C** — `caddy/cf-sync.py`-Drift-Check + Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5). > - 🔜 **Session C** — `caddy/cf-sync.py`-Drift-Check + Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5).
> >
> 🆕 **Dazu seit 2026-08-30: TP16.3/TP31 Schritt 5 = Vor-Ort-Session** an ap1/ap2/**ap3**
> ([Runbook](tp16.3-bf-public-ap-connector-runbook.md)). **Reihenfolge beachten:** die drei Blocker in
> §1 sind **remote** zu erledigen, *bevor* jemand in den Keller geht — sonst steht man vor einem
> Connector, der `healthy` meldet, während die Seite eine Redirect-Schleife dreht.
>
> 🆕 **Strukturelle Folge = TP36:** SSoT für `teilprojekte.md`-Live-Daten via Nautobot (eigene Session, nicht heute). Befund 2026-08-28: manuell gepflegte Datei verliert gegen die Realität, sobald sie lang wird. Quickref-Index, TP-Abschnitt und Ist-Stand-Block müssen bei jedem Edit mitgezogen werden — keine schlägt die andere. **Merkregel im Lessons-Block.** > 🆕 **Strukturelle Folge = TP36:** SSoT für `teilprojekte.md`-Live-Daten via Nautobot (eigene Session, nicht heute). Befund 2026-08-28: manuell gepflegte Datei verliert gegen die Realität, sobald sie lang wird. Quickref-Index, TP-Abschnitt und Ist-Stand-Block müssen bei jedem Edit mitgezogen werden — keine schlägt die andere. **Merkregel im Lessons-Block.**
> 🆕 **Neu am 2026-08-24/25:** [TP31](#tp31) löst **TP16** und **TP16.2** ab und dampft cloudflared > 🆕 **Neu am 2026-08-24/25:** [TP31](#tp31) löst **TP16** und **TP16.2** ab und dampft cloudflared
@@ -265,7 +319,7 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na
> | TP14.4 | 🔜 | CF-Access-Token | — | > | TP14.4 | 🔜 | CF-Access-Token | — |
> | TP16 | ✅ | CF-Tunnel HA wieder voll | 2026-08-24 | > | TP16 | ✅ | CF-Tunnel HA wieder voll | 2026-08-24 |
> | TP16.2 | 🔜 | Härtung bypass-Dienste | — | > | TP16.2 | 🔜 | Härtung bypass-Dienste | — |
> | TP16.3 | ⏳ | cloudflared-Reduktion (14 Hostnames geschnitten 08-30, F1 durch; Abbau braucht Vor-Ort-Session) | — | > | TP16.3 | ⏳ | cloudflared-Reduktion (14 Hostnames geschnitten 08-30, F1 durch; §1–§3 durch 08-31, `bf-public` healthy/12 — offen: Flip §5, Abbau §6) | — |
> | TP17.1 | ❌ | Shelly Gen4 → Zigbee | 2026-08-23 | > | TP17.1 | ❌ | Shelly Gen4 → Zigbee | 2026-08-23 |
> | TP17.3 | ✅ | Shelly-Regression behoben | 2026-08-23 | > | TP17.3 | ✅ | Shelly-Regression behoben | 2026-08-23 |
> | TP18 | 🔜 | Garage-Objektspeicher | — | > | TP18 | 🔜 | Garage-Objektspeicher | — |
@@ -276,13 +330,14 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na
> | TP28 | ✅ | WLAN/CAPsMAN — dritter AP `ap3` (cAP ax) **live** | 2026-08-30 | > | TP28 | ✅ | WLAN/CAPsMAN — dritter AP `ap3` (cAP ax) **live** | 2026-08-30 |
> | TP29 | 💡 | Anwesenheitserkennung (Idee) | — | > | TP29 | 💡 | Anwesenheitserkennung (Idee) | — |
> | TP30 | 🔜 | Credentials (Etappe 1 ✅, Rest gebündelt) | — | > | TP30 | 🔜 | Credentials (Etappe 1 ✅, Rest gebündelt) | — |
> | TP31 | ✅ | Reverse Proxy Caddy (Schritt 1–4 ✅) | — | > | TP31 | ⏳ | Reverse Proxy Caddy (1–4 ✅, 5–6 = Vor-Ort-Session) | — |
> | TP32 | 🟢 | Nummern-Systematik (Konzept) | — | > | TP32 | 🟢 | Nummern-Systematik (Konzept) | — |
> | TP33 | 💡 | Sonos fern (Idee, zurückgestellt) | — | > | TP33 | 💡 | Sonos fern (Idee, zurückgestellt) | — |
> | TP34 | 🔜 | Caddyfile via Nautobot/GC (Stub, Inventur-Handoff 2026-08-27) | — | > | TP34 | 🔜 | Caddyfile via Nautobot/GC (Stub, Inventur-Handoff 2026-08-27) | — |
> | TP35 | ⏳ | MCP-Schichtung + 3-Schicht-CLAUDE.md + Backup-Guard (Schicht 1+3+6 ✅) | — | > | TP35 | ⏳ | MCP-Schichtung + 3-Schicht-CLAUDE.md + Backup-Guard (Schicht 1+3+6 ✅) | — |
> | TP36 | 🔜 | SSoT via Nautobot für teilprojekte.md-Live-Daten + Datei-Aufteilung | — | > | TP36 | 🔜 | SSoT via Nautobot für teilprojekte.md-Live-Daten + Datei-Aufteilung | — |
> | TP37 | 🔜 | Nautobot als Netzwerkdoku: Hosts/Container/Dienste + CF-Connectoren (Okay 08-30) | — | > | TP37 | 🔜 | Nautobot als Netzwerkdoku: Hosts/Container/Dienste + CF-Connectoren (Okay 08-30) | — |
> | TP38 | 🔜 | OOB-Mgmt: untagged-VLAN99-Port je Switch | — |
> | — | — | **34 archivierte TPs** in `teilprojekte-archiv.md` | — | > | — | — | **34 archivierte TPs** in `teilprojekte-archiv.md` | — |
### TP5.1 — DNS-Visibility & Flow-Observability für Low-Trust-Geräte (Airfryer-Fall) ▸ vorgemerkt ### TP5.1 — DNS-Visibility & Flow-Observability für Low-Trust-Geräte (Airfryer-Fall) ▸ vorgemerkt
@@ -475,7 +530,49 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na
- **Reihenfolge:** (1) Client-IP-Weitergabe fixen (inkl. HA `trusted_proxies`) → (2) `/admin`-Pfade path-scoped schützen → (3) App-Limits + 2FA → (4) Edge-Rules → (5) CrowdSec/fail2ban mit CF-Bouncer. - **Reihenfolge:** (1) Client-IP-Weitergabe fixen (inkl. HA `trusted_proxies`) → (2) `/admin`-Pfade path-scoped schützen → (3) App-Limits + 2FA → (4) Edge-Rules → (5) CrowdSec/fail2ban mit CF-Bouncer.
- **Abh.:** TP16 ✅ (Tunnel-HA). Verwandt: **TP20.1** (vw wandert auf Platform-Postgres — DB-HA ≠ Access, getrennt halten), **TP23** (dessen WARP-Managed-Network-Idee ist durch diesen Constraint **entlastet**: `vw` verlangt keine Device-Identity, also kollidiert „WARP zuhause aus" nicht mehr mit dem Access-Strang). - **Abh.:** TP16 ✅ (Tunnel-HA). Verwandt: **TP20.1** (vw wandert auf Platform-Postgres — DB-HA ≠ Access, getrennt halten), **TP23** (dessen WARP-Managed-Network-Idee ist durch diesen Constraint **entlastet**: `vw` verlangt keine Device-Identity, also kollidiert „WARP zuhause aus" nicht mehr mit dem Access-Strang).
### TP16.3 — cloudflared-Reduktion: was der Mesh als Datenpfad kann (und was nicht) ▸ ⏳ **GROSSER SCHNITT DURCH (2026-08-30), Public-Strang blockiert** — `docs/tp16.3-nautobot-mesh-only-runbook.md §8` ### TP16.3 — cloudflared-Reduktion: was der Mesh als Datenpfad kann (und was nicht) ▸ ⏳ **§1–§3 DURCH (2026-08-31): drei Connectoren live, `bf-public` healthy/12** — offen: Flip §5, Abbau §6 — `docs/tp16.3-nautobot-mesh-only-runbook.md §8`
#### 🔜 Follow-up F2 — `bf-public`-Connector auf die APs (Runbook 2026-08-30)
**Runbook: [`tp16.3-bf-public-ap-connector-runbook.md`](tp16.3-bf-public-ap-connector-runbook.md)**
— Vor-Ort-Session an **ap1 + ap3 (Technik UG)** und **ap2 (Technik OG)**, weil der
`device-mode`-Wechsel eine **physische** Bestätigung am Gerät verlangt.
- 🆕 **ap3 ist neu und nicht baugleich** (live 08-30): **cAP ax** (`cAPGi-5HaxD2HaxD`, `10.40.99.13`),
arm64, 96 MB Flash frei, 689 MB RAM frei. ~~Zwei verschiedene `container.npk`~~ — **überholt seit dem
ROS-Upgrade 08-31: alle drei auf `7.24.1`/arm64 ⇒ EINE Datei** (`container-7.24.1-arm64.npk`,
1.187.985 B — exakt die Grösse, die die APs selbst für das Paket melden, damit belegt statt
angenommen). ap3 steht auf `device-mode=basic` (nicht `home`), dort ist `fetch` bereits erlaubt —
wir nutzen trotzdem auf allen dreien den `.tar`-Import, damit alle dasselbe Image fahren.
- ✅ **Blocker A — Redirect-Schleife, behoben 2026-08-31.** `bf-public` zeigt auf `http://10.40.20.40:80`; Caddy antwortet
dort mit **308 → `https://<name>/`**, also zurück durch Cloudflare in denselben Tunnel ⇒
`ERR_TOO_MANY_REDIRECTS` für alle vier Namen. **Fällt heute nicht auf**, weil die drei Alttunnel
**an Caddy vorbei** direkt aufs Backend gehen — die Schleife entsteht erst mit dem Umstieg.
Fix = `https://10.40.20.40:443` + `originServerName` je Regel (über 443 mit korrekter SNI: 4× 200).
- ✅ **Blocker B — die Freigabe fehlte, angelegt 2026-08-31 über Nautobot GC.** Die `forward`-Kette des Core wurde vollständig
gelesen (36 Regeln, Nr. 35 = `TP5: deny-by-default`): **keine** Regel `vlan99 → vlan20` für 80/443.
Die einzige MGMT→INFRA-Regel (Nr. 26) ist ein `src-port=8728`-**Rückweg** der API.
⚠️ **Der Vorgänger-Runbook behauptet das Gegenteil** („Regel 27 deckt `vlan99→vlan20:80` ab ✅") —
Regel 27 ist `IOT->INFRA http-rueckweg`, `in-interface=vlan30`. Ohne neue Regel läuft der Connector
in `TP5-WOULD-DROP` + Drop. Egress zu CF (Regel 13 `LAN->WAN`) und DNS (Regeln 30–33) sind dagegen ✅.
⇒ Das `masquerade` des `veth`-Netzes ist deshalb **Pflicht**, nicht Kosmetik: sonst kommen die Pakete
mit `172.17.0.2` am Core an, wo weder Route noch Regel sie kennt.
- ✅ **Blocker C — `ROUTER_AP3_PASS` fehlte in `~/.secrets/credentials.env`, nachgetragen 2026-08-31** (steht nur in
`~/.claude.json`), gegen die Credential-Store-Konvention. Der `sshpass -e`-Reboot-Weg braucht es dort.
⚠️ Nebenbefund: **`credentials.env` lässt sich in zsh nicht sourcen** — ab ~Zeile 69 stehen rohe
WireGuard-Zeilen, zsh bricht ab und lässt die Variablen **leer**; das sieht aus wie ein ungültiger
Token. Alle Snippets im Runbook laufen deshalb in `bash -c`.
- 🔩 **Kein PoE als Abkürzung:** beide CRS326 haben nachweislich gar kein PoE-Menü
(`bad command name poe`) ⇒ es gibt **keinen** Remote-Power-Cycle, die Bestätigung ist wirklich physisch.
- 🧹 **Abbau (frühestens 7 Tage nach dem Flip):** Tunnel `bf-mm`/`bf-nas`/`bf-pve` löschen und die
**sechs** Container entfernen — LXC 400 (`cloudflared-bfmm`, `-bfnas`), macmini (`-bfpve`, `-bfnas`),
NAS ep14 (`-bfpve`, `-bfmm`); alle drei Hosts 08-30 verifiziert. **Erst Container, dann Tunnel** —
umgekehrt erzeugt man 530er. 🚫 **`terpene-cloudflared` (LXC 400) und `fm.emsenhuber.net` bleiben.**
- 🔴 **Ehrliche Einschränkung (aus TP31, gilt unverändert):** solange Caddy nur auf `caddy-bf1` liegt,
ist das Origin aller drei Connectoren **derselbe Rechner** — drei Connectoren kaufen
**Hardware-Diversität am Eingang, aber keine Verfügbarkeit**. Der echte Gewinn kommt mit `caddy-bf2`
(LXC 131); für `vw` gilt dabei die TP20.1-Bremse (Schreibzugriffe auf einen read-only Standby).
- **Rollback:** `docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json` (vier Ingress-Configs + CNAMEs
inkl. Record-IDs), am 2026-08-30 **neu angelegt**.
- ▶️ **Schnitt ausgeführt 2026-08-24:** `nautobot.emsenhuber.net` ist aus dem Ingress von `bf-nas` - ▶️ **Schnitt ausgeführt 2026-08-24:** `nautobot.emsenhuber.net` ist aus dem Ingress von `bf-nas`
entfernt (**8 → 7 Regeln**, Catch-all weiterhin letzte). Rollback-Sicherung liegt unter entfernt (**8 → 7 Regeln**, Catch-all weiterhin letzte). Rollback-Sicherung liegt unter
`docs/cf-backup/bf-nas-ingress-20260824-vor-tp16.3.json` — Rückweg ist ein `PUT`, eine Minute. `docs/cf-backup/bf-nas-ingress-20260824-vor-tp16.3.json` — Rückweg ist ein `PUT`, eine Minute.
@@ -500,6 +597,58 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na
(mit den CF-Leichen am 08-29 gelöscht, Commit `39ba5da`) — der Dienst ist vollständig aus dem (mit den CF-Leichen am 08-29 gelöscht, Commit `39ba5da`) — der Dienst ist vollständig aus dem
öffentlichen Strang heraus und ausschließlich über `10.40.20.30:8080` bzw. intern über Caddy erreichbar. öffentlichen Strang heraus und ausschließlich über `10.40.20.30:8080` bzw. intern über Caddy erreichbar.
##### ✅ §2/§3 durchgezogen — Vor-Ort-Session 2026-08-31
**Ergebnis:** je ein `cloudflared` auf ap1/ap2/ap3, `bf-public` **healthy mit 12 Connectoren**
(3 APs × 4 CF-Edges, alle `2026.6.1`, Egress über `90.146.33.177`). WLAN auf allen drei zurück
(CAPs `state=Ok`), Flash je AP **54–55 MiB frei**.
- 🔴 **Die Platzrechnung des Runbooks war falsch — Faktor 2,5.** §3.5 rechnete mit „28 MB `.tar`
+ ~28 MB entpackte Layer ≈ 39 MB Rest". Die 28 MB sind die **komprimierte** Archivgrösse; das
entpackte Image misst **72.926.208 B = 69,5 MiB** (`docker export | wc -c`). Spitzenbedarf beim
Import ist **Tar + entpacktes Abbild gleichzeitig** ⇒ 26,2 + 69,5 = **95,7 MiB gegen 90,5 MiB frei**.
Das offizielle `cloudflare/cloudflared`-Image passt auf diese Geräte **nicht** — auf keinem der drei.
⚠️ **`docker image inspect .Size` führt hier in die Irre** (meldete 27.463.950); `docker history`
summiert auf ~70 MB. **Für Flash-Budgets `docker export` messen, nicht `inspect`.**
- ✅ **Lösung: eigenes Minimal-Image, `FROM scratch`.** Das Binary im offiziellen Image ist
**statisch gelinkt** (verifiziert) und 36,8 MB gross; die restlichen ~33 MB sind distroless-Debian,
das cloudflared nicht braucht. Das selbst gebaute Archiv (Binary + CA-Bundle) misst **35,3 MiB**,
Spitzenbedarf 70,6 MiB. Gepinnt über die **Binary-SHA256 `e93824f6…`** — cloudflared bestätigt sie
beim Start selbst (`Version 2026.6.1 (Checksum e93824f6…)`), das ist der Reproduzierbarkeits-Nachweis.
Build-Skript erzeugt das docker-v1-Archiv direkt, ohne Docker-Umweg.
- 🔴 **`docker save` taugt seit Docker 25 nicht mehr als Quelle.** Mit containerd-Image-Store schreibt
es ein **OCI-Layout** (`blobs/sha256/`, gzip-Layer); RouterOS will docker-v1 und quittiert das mit
**`no config found in manifest`** — eine Meldung, die nach kaputtem Image klingt, nicht nach Format.
- 🔴 **`/container/envs` heisst auf ROS 7.24.1 `list=`, nicht `name=`.** Das Runbook (§3.5) und das
MikroMCP-Tool `manage_container_env` benutzen beide `name=` und scheitern mit
`unknown parameter name`. Token-Übergabe deshalb per `.rsc` + `/import` (Datei danach löschen) —
so steht der Wert weder in `ps` noch im Transkript.
- 🔴 **Der `device-mode`-Bestätigungs-Boot installiert KEINE Pakete.** Auf ap1 wurde die `.npk` vor
dem Setzen von `device-mode` hochgeladen, um Christoph einen Reboot zu sparen: der Stromzyklus hat
`container: yes` bestätigt und die Datei **verbraucht, ohne sie zu installieren** — `/system package
print` zeigte danach nur zwei Pakete. **Reihenfolge ist also Pflicht: erst Paket-Reboot, dann
`device-mode`.** Nachholen ging ohne physischen Gang (SSH-Reboot).
- 🟡 **`A - AVAILABLE` bei `/system package print` ist ein Download-Katalog, kein Paket auf dem Gerät.**
Gegenprobe: `crs326-core` hat 2,9 MB frei von 16 MB, listet aber `wifi-qcom` (10,1 MB) und
`rose-storage` (4,3 MB) als „available"; die Grössen sind zudem arch-spezifisch. Ein
`/system package enable` löst also einen Internet-Fetch aus — auf ap1/ap2 per `device-mode=home`
gesperrt. **Der `.npk`-Weg bleibt richtig.**
- 🟡 **MikroMCPs `export_config` liefert einen unvollständigen Export** (301 B gegen 6,2 KiB bei
direktem `/export`) — ein Rollback-Artefakt, das existiert und trotzdem leer ist. Für die Backups
dieser Session wurden die MCP-Exporte verworfen und `/export file=…` verwendet.
- 🔴 **Offen und entscheidungsbedürftig: Compliance steht auf 128/135, nicht 134/135.** §3.4 schreibt
Direkt-Writes für `veth` / `ip address` / `nat` vor, während `/ip address` und `/ip firewall` unter
GC-Drift-Kontrolle stehen — **Runbook und Governance widersprechen sich.** Folge: je AP zwei neue
`extra`-Drifts (`ip-address` mit `172.17.0.1/24 interface=veth-cf`, `ip-firewall-nat` mit dem
`TP31: cloudflared egress`-Masquerade), also **sechs**. Sie sind **inhaltlich richtig** — die Config
soll dort stehen. Der saubere Weg ist, sie ins Soll zu heben (AP-Template/ConfigContext), **nicht**
ein Re-Baseline und **nicht** ein Rückbau. ⚠️ Bis dahin ist die Zahl erklärt, aber nicht in Ordnung.
(Die siebte Drift, `crs326-core` / `wifi-security` / `ft-over-ds`, ist die bekannte und unverändert.)
- ⚠️ **WLAN-Abnahme mit Einschränkung:** `main` (3) und `iot` (27) haben Clients und frische Leases;
`media`/`untrusted`/`guest` hatten **schon vor** dem Umbau null Clients — für die drei gibt es
keinen Lease-Nachweis, weil niemand eine zieht. Kein Rückschritt, aber auch kein Beleg.
#### ▶️ Der grosse Schnitt — ausgefuehrt 2026-08-30 (User-Entscheid „kompletter Schnitt in einem Zug") #### ▶️ Der grosse Schnitt — ausgefuehrt 2026-08-30 (User-Entscheid „kompletter Schnitt in einem Zug")
- **12 Hostnames entfernt**, aus Ingress **und** DNS, in einem Durchgang: - **12 Hostnames entfernt**, aus Ingress **und** DNS, in einem Durchgang:
`pve` · `portainer` · `pve-mcp` · `ha-mcp` · `ds-bf` · `ccu` · `iobroker` · `z2m25` · `pbs` `pve` · `portainer` · `pve-mcp` · `ha-mcp` · `ds-bf` · `ccu` · `iobroker` · `z2m25` · `pbs`
@@ -2359,6 +2508,81 @@ kann in Ruhe nachziehen.
- **Verzahnt mit:** TP22 (Nautobot-GC als Vorbild), TP35 (geht Hand in Hand — beide sind „SSoT + Konsolidierung"), `bf-infra/CLAUDE.md`-Lessons. - **Verzahnt mit:** TP22 (Nautobot-GC als Vorbild), TP35 (geht Hand in Hand — beide sind „SSoT + Konsolidierung"), `bf-infra/CLAUDE.md`-Lessons.
- **Referenzen:** Lessons-Block-Eintrag „Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität" · Handoff Session A 2026-08-28. - **Referenzen:** Lessons-Block-Eintrag „Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität" · Handoff Session A 2026-08-28.
<a id="tp37"></a>
### TP38 — Out-of-Band-Management: ein untagged-VLAN99-Port je Switch ▸ 🔜 **ANGELEGT 2026-08-31**
*(Nummer 2026-08-31 von 37 auf 38 korrigiert und von Christoph bestätigt: TP37 war zeitgleich in
einem zweiten Branch für „Nautobot als Netzwerkdoku" vergeben worden.)*
> **Auslöser (User 2026-08-31, aus eigener Anschauung):** *„ich durfte beim Neustart vom core gerade
> erleben, dass ich nicht mehr auf den branch zum verbinden kam. weil das eben alles über den core
> läuft."*
#### Der Befund, live nachgemessen (2026-08-31)
Das Management des Standorts hat **einen einzigen Weg**, und der führt über den Core:
| | `crs326-core` | `crs326-branch` |
|---|---|---|
| Mgmt-IP | `10.40.99.1` (VLAN99-SVI) | `10.40.99.2` (`vlan99` auf der Bridge) |
| VLAN99 auf der Bridge | SVI + Trunks | **`tagged=ether1,ether24,bridge`**, `untagged=""` |
| Weg dorthin | direkt | **nur über `ether1` → core `ether8`** |
⇒ **`crs326-branch` ist ausschließlich durch den Core hindurch erreichbar.** Rebootet der Core, ist der
Branch für die Dauer weg — nicht weil er ausfällt, sondern weil der einzige Pfad zu ihm fehlt. Dasselbe
gilt für jeden Fehler, der den Core-Trunk oder dessen VLAN99-Konfiguration betrifft. **Wer den Core
zerkonfiguriert, verliert den Branch gleich mit** — und damit die Möglichkeit, es vom Branch aus zu
reparieren.
🔴 **Der zweite Befund ist der unangenehmere: `crs326-branch` hat KEINE `input`-Firewallregeln** (Kette
leer, live gelesen). Sein gesamter Schutz besteht heute darin, dass VLAN99 nur über den Core erreichbar
ist und **dessen** `forward`-Kette seit TP5 deny-by-default fährt. **Die Erreichbarkeitsbeschränkung
IST die Zugangskontrolle.** Das ist für die Lösung unten kein Detail, sondern die Hauptauflage.
#### Zielbild
**Je Switch ein Port, untagged in VLAN99** — ein Laptop direkt daran erreicht das Gerät ohne jeden
Umweg über den Core, ohne Trunk, ohne funktionierendes Routing.
```
Laptop ──(statische IP 10.40.99.x/24)── ether23 (untagged VLAN99) ── crs326-branch 10.40.99.2
^ funktioniert auch, wenn der Core steht
```
**Kandidat `ether23` auf beiden Geräten** — auf core **und** branch heute `running=false`, also frei,
und symmetrisch (ein Port, eine Nummer, an beiden Standorten dieselbe). *(Core hat reichlich freie
Ports; auf dem Branch sind `ether23` und die Reihe darunter frei, `ether24` ist ap2.)*
#### Umsetzung (Skizze — eigene Session)
1. **Port aus dem Trunk-Verhalten nehmen und in VLAN99 untaggen:**
`bridge port` mit `pvid=99` + `frame-types=admit-only-untagged-and-priority-tagged`,
dazu `ether23` als `untagged`-Member in die `bridge vlan`-Zeile für VLAN99.
2. **Kein DHCP dort** — der VLAN99-DHCP läuft auf dem Core, und genau der soll ja nicht Voraussetzung
sein. ⇒ **statische IP am Laptop** (`10.40.99.50/24`), im Runbook mitschreiben.
3. **Beschriftung.** Ein OOB-Port, den niemand erkennt, wird im Zweifel für „ist ja frei" gehalten.
4. **Abnahme, die den Zweck wirklich prüft:** Laptop an `ether23` des Branch, **Core-Trunk `ether1`
gezogen** (oder Core im Reboot) → `10.40.99.2` muss antworten. Alles andere misst nur den Normalfall.
#### 🔴 Auflage, ohne die das TP nicht gebaut werden darf
Ein untagged-MGMT-Port ist **ein physischer Generalschlüssel**: wer ihn steckt, steht ungefiltert im
Management-VLAN eines Geräts, das (Branch) keinerlei `input`-Filter hat. Deshalb gehört **eine** der
folgenden Absicherungen dazu, und die Entscheidung darüber in die Umsetzungs-Session:
- **Port `disabled=yes` im Normalbetrieb**, im Bedarfsfall am *anderen* Gerät freigeschaltet — schützt,
kostet aber genau dann Zugriff, wenn das andere Gerät weg ist (halbierter Nutzen, ehrlich benennen);
- **oder** `input`-Regeln auf beiden Switches nachziehen (der Branch braucht sie ohnehin — das ist die
eigentliche Lücke, die dieses TP nebenbei aufgedeckt hat);
- **oder** rein physisch: abschließbarer Schrank, Port beschriftet und dokumentiert.
⚖️ **Meine Einschätzung:** die `input`-Regeln sind der richtige Weg, weil sie eine Lücke schließen, die
**unabhängig von diesem TP** besteht. Der OOB-Port macht sie nur sichtbar.
#### Abgrenzung
- **Nicht** Teil von TP16.3 — dort wird nur festgestellt, dass dreimal Geräte rebooten, deren Mgmt am
Core hängt. TP37 entschärft das für künftige Sessions, ist aber **keine Voraussetzung** dafür.
- **Nicht** VLAN-Schema-Änderung (TP32 bleibt unberührt), **nicht** Konsolen-/Serial-Zugang (das wäre
echtes OOB im engeren Sinn; hier geht es um ein zweites *Netz*-Bein).
- **Abh.:** TP5 (Firewall-Systematik, liefert das Muster für die `input`-Regeln), TP15 (Portbelegung
beider CRS326), TP22 (die Umsetzung läuft über Golden Config, `ip-firewall-filter` und
`bridge`-Sektionen stehen unter Drift-Kontrolle).
--- ---
### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt) ### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt)

View File

@@ -0,0 +1,671 @@
# 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).
> Offen bleiben **§5 (Flip)** und **§6 (Abbau)**.
> **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 — 🔴 **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.
```bash
# 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
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**
🔴 **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.
### 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.** 🔴 **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:
```bash
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
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.**
⚠️ 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. 🔴 **§3.4 kollidiert mit der GC-Governance:** `ip address` und `ip firewall nat` stehen unter
Drift-Kontrolle. Direkt gesetzt ⇒ **zwei `extra`-Drifts je AP**. Entweder vorher ins Soll heben
(AP-Template/ConfigContext) oder die Drift bewusst als offene Transaktion führen.
16. 🟡 **`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.
17. 🟡 **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 **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.

View File

@@ -0,0 +1,124 @@
# Übergabeprompt — TP16.3 §5: CNAME-Flip auf `bf-public`
> **Zum Einfügen in eine frische Session.** Erstellt 2026-08-31, direkt nach §2/§3.
> Alles unterhalb der Linie ist der Prompt.
---
TP16.3 / TP31 Schritt 5 — **§5 CNAME-Flip** (Repo `~/dev/bf-infra`).
Aufgabe: **§5 des Runbooks** `docs/tp16.3-bf-public-ap-connector-runbook.md` ausführen — die vier
öffentlichen Namen von den Alttunneln auf `bf-public` umhängen. **§6 (Abbau) gehört NICHT dazu**,
frühestens 7 Tage nach dem Flip.
## PFLICHTLEKTÜRE ZUERST, in dieser Reihenfolge
1. `docs/handoff-20260831-tp16.3-paragraph2-3-durch.md` — Ergebnis und Funde der Vorsession
2. `docs/tp16.3-bf-public-ap-connector-runbook.md` **§5** (und §7 Rollback)
3. `docs/teilprojekte.md` (Quick-Index + Ist-Stand + TP16.3-Abschnitt)
## AUSGANGSLAGE — nicht nochmal machen
- `bf-public` ist **healthy mit 12 Connectoren** (ap1/ap2/ap3, je ein `cloudflared 2026.6.1`,
Container `running`, `start-on-boot=yes`). Tunnel-ID `48d3b0b6-7233-4781-a76a-1de4a5e957ce`.
- Ingress steht auf `https://10.40.20.40:443` mit `originServerName` je Regel (Blocker A behoben).
- Firewall-Freigabe `vlan99 → vlan20:443` auf `crs326-core` liegt und ist verifiziert (Blocker B).
- Caddy antwortet **mit korrektem SNI 200, ohne SNI 000** — gemessen, das ist gesundes Verhalten.
- Rollback-Material: `docs/cf-backup/` — Ingress-Configs, CNAMEs **inklusive Record-IDs**, und für
drei Tunnel zusätzlich der ältere Stand als `*-vor-hostname-schnitt.json`.
## 🔴 ZUERST ENTSCHEIDEN, BEVOR EIN NAME GEFLIPPT WIRD
**Compliance steht auf 128/135.** Sechs `extra`-Drifts, zwei je AP: `ip-address`
(`172.17.0.1/24 interface=veth-cf`) und `ip-firewall-nat` (`masquerade`, Kommentar
`TP31: cloudflared egress`). Sie sind **inhaltlich richtig** — ohne sie läuft kein Container —, aber
sie stehen nicht im Soll, weil Runbook §3.4 Direkt-Writes vorschreibt für Features, die unter
Golden-Config-Kontrolle stehen.
Der saubere Weg ist, sie ins **Soll** zu heben (AP-Template bzw. ConfigContext `ap-common`).
🔑 **Bewusst KEINE Transaktion:** `gc-transaktion.py` ist für **befristete Versuche** da (so wie
`TP25-fund-e-ft-over-ds`). Das `veth`/`masquerade` soll **dauerhaft** gelten — Dauerzustand gehört
ins Template, nicht in eine Transaktion mit Ablaufdatum.
🔴 **Kein Re-Baseline** (macht die Drift-Erkennung wertlos), **kein Rückbau** (killt die Tunnel).
⚠️ Dabei mitentscheiden, ob `/interface veth` überhaupt erfasst sein soll — dafür existiert heute
keine ComplianceRule, deshalb erzeugt `veth` auch keine Drift.
Mit ungeklärter Drift in den Produktivpfad zu gehen ist die eine Sache, die diese Session
**nicht** tun sollte. Christoph entscheidet, nicht die Session.
🔴 **Die siebte Drift ist KEINE Drift im Sinne eines Versehens:** `crs326-core` / `wifi-security`
(`ft-over-ds=no`) ist **TP25 Fund E, Hebel 1** — am 2026-08-31 um 08:09 bewusst gesetzt und als
**offene Transaktion `TP25-fund-e-ft-over-ds`** geführt (Scope `wifi-security`, Ablauf 2026-09-03).
Sie gehört einer anderen, laufenden Session; **grün machen würde deren Versuch unsichtbar machen.**
⚠️ **Merkregel:** vor jeder Drift-Bewertung `python3 nautobot/gc-transaktion.py status` lesen.
Drift aus einem laufenden Versuch sieht identisch aus wie ein Versehen — genau dafür gibt es das
Werkzeug. (Diese Zeile behauptete zuerst, die Drift käme vom 7.24.1-Upgrade. Falsch: der Core wurde
erst um 14:36 gehoben, sechseinhalb Stunden nach dem Hebel.)*
## REIHENFOLGE
**Erst `test-tp163` (§5.1), dann `n8n` → `gitea` → `hass` → `vw`** — steigende Kritikalität, `vw`
zuletzt. Nach **jedem** Namen die Abnahme aus §5.3, erst dann der nächste.
## WICHTIGSTE FALLEN
- 🔴 **`healthy` beweist nichts über das Origin.** Der Tunnelstatus sagt nur, dass der Connector mit
Cloudflare redet. 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). Der einzige
Streckenbeweis ist §5.1 gegen einen Testnamen.
- 🔴 **WARP muss AUS sein.** Mit aktivem WARP läuft der Test über den Mesh und beweist den
öffentlichen Weg nicht — dieselbe Fehldiagnose-Klasse wie der ICMP-Fall 2026-08-13. Testen von
außerhalb des LAN, über Mobilfunk.
- 🔴 **`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.
- 🔴 **`gitea`: Access gilt nur der Web-UI**, Git-/API-Pfade laufen daran vorbei. Push von außen
**vor** und **nach** dem Flip testen, nicht nur die UI.
- 🔴 **`hass`: die Companion-App kann kein Access-Login.** Nach dem Flip die App auf dem Handy über
Mobilfunk prüfen, nicht nur den Browser.
- ⚠️ **`ERR_TOO_MANY_REDIRECTS` nach einem Flip = Blocker A ist zurück** (Ingress auf `:80` statt
`:443`, oder `originServerName` fehlt). Sofort zurückflippen, nicht debuggen während der Name tot ist.
- ⚠️ **`~/.secrets/credentials.env` lässt sich in zsh nicht sourcen** (rohe WireGuard-Zeilen ab
~Zeile 69) — Variablen bleiben still leer, das sieht aus wie ein ungültiger Token. **Immer `bash -c`.**
- 🔴 **Maskierung: Muster auf den Schlüsselnamen ankern, nie auf ein Trennzeichen.**
`grep -n "TOKEN" datei | sed 's/:.*TOKEN/…/'` maskiert **nicht** — `grep -n` stellt `<zeile>:`
voran, und das matcht das Muster. So sind in der Vorsession zwei Nautobot-Tokens ins Transkript
geraten. Sicher ist nur `sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'`.
## GOVERNANCE
- Config-Änderungen an MikroTik-Geräten **ausschließlich** über Nautobot Golden Config
(`nautobot/gc-manual-plan.py`), nicht per Direkt-Write. Nautobot: **`http://10.40.20.30:8080`**
(Port 8080, **nicht** 80 — der Runbook-Stand nannte lange nur den Host), Token in
`docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored, nur im Haupt-Checkout).
- Cloudflare-Änderungen sind **outward-facing**: DNS-Flip und Ingress-Edits vor der Ausführung mit
Christoph bestätigen, einer nach dem anderen.
- **MCP-Lifecycle aktiv melden**, falls sich daran etwas ändert.
## OFFENE PUNKTE, DIE NICHT IN DIESE SESSION GEHÖREN
1. **Rotation zweier Nautobot-Tokens** (`NAUTOBOT_SUPERUSER_API_TOKEN`, `NAUTOBOT_GITEA_TOKEN`) —
in der Vorsession ins Transkript geraten, als verbrannt zu behandeln. Gehört zu **TP30**.
2. **`crs326-core` / `wifi-security` / `ft-over-ds`** — offene Transaktion `TP25-fund-e-ft-over-ds`
einer laufenden TP25-Session. **Nicht anfassen, nicht grün machen**, Ablauf 2026-09-03.
3. **`mcp/mikromcp/config/routers.yaml`** meldet `rosVersion: "7.24"` für alle fünf, live ist
`7.24.1` — kosmetisch, gehört auf `main`.
4. **§6 Abbau** (sechs Container, drei Alttunnel) — frühestens 7 Tage nach dem Flip.
🚫 `terpene-cloudflared` (LXC 400) und `fm.emsenhuber.net` bleiben, sie stehen im `docker ps`
direkt neben den zu löschenden. **Namen lesen, nicht Zeilen zählen.**
## ABNAHME DIESER SESSION
- [ ] Entscheidung zu den sechs Drifts getroffen und umgesetzt (Compliance erklärt oder bereinigt)
- [ ] `test-tp163` von außerhalb des LAN ohne WARP: Access-Login, **keine** Redirect-Schleife
- [ ] Testname **und** Testregel danach wieder entfernt
- [ ] Alle vier Namen geflippt, je einzeln mit 15 min Beobachtung, kein 530/1033
- [ ] `gitea`-Push von außen funktioniert nach dem Flip
- [ ] `hass`-Companion-App über Mobilfunk funktioniert nach dem Flip
- [ ] `vw` Bitwarden-Clients funktionieren nach dem Flip
- [ ] Caddy-Log zeigt die Requests (`ssh root@10.40.20.100 'pct exec 130 -- journalctl -u caddy -n 20'`)
## ARBEITSWEISE
Bestandsaufnahme vor Plan. Eine große Fragenrunde statt vieler kleiner. Bei Abweichungen zwischen
Doku und Live-Stand: **Drift melden, nicht stillschweigend korrigieren.** Vor jedem Flip den
Rollback-Weg (§7, Record-IDs in `docs/cf-backup/cnames-tunnel-*.json`) parat haben.

View File

@@ -5,6 +5,22 @@
# Mode-Button-Druck an jedem AP, danach Reboot. Dieser Runbook-Teil muss # Mode-Button-Druck an jedem AP, danach Reboot. Dieser Runbook-Teil muss
# vor Ort ausgeführt werden. # vor Ort ausgeführt werden.
> 🔴 **ABGELÖST 2026-08-30 — NICHT MEHR AUSFÜHREN.**
> Nachfolger: [`tp16.3-bf-public-ap-connector-runbook.md`](tp16.3-bf-public-ap-connector-runbook.md).
> Dieses Dokument bleibt als Historie stehen. Drei Gründe, warum es nicht mehr trägt:
> 1. **Es kennt `ap3` nicht** (cAP ax, `10.40.99.13`, seit 2026-08-30 in Betrieb) — und ap3 läuft
> **ROS 7.24.1** gegen 7.24 auf ap1/ap2, braucht also ein **anderes `container.npk`**.
> 2. **§2b unterstellt einen Registry-Pull.** Auf ap1/ap2 ist `fetch: no` (`device-mode=home`) —
> das Image muss als `.tar` hoch und per `/container add file=` importiert werden.
> 3. 🔴 **§0 behauptet, „Regel 27 `MGMT→INFRA TCP` deckt `vlan99→vlan20:80` bereits ab ✅".**
> **Das ist falsch.** Die `forward`-Kette des Core wurde am 2026-08-30 vollständig gelesen:
> Regel 27 ist `IOT->INFRA http-rueckweg` (`in-interface=vlan30`), und die einzige MGMT→INFRA-Regel
> (Nr. 26) ist ein `src-port=8728`-Rückweg der API. **Eine Freigabe für den Connector existiert
> nicht** — ohne sie landet er in `TP5-WOULD-DROP` + Drop.
>
> Ebenfalls im Nachfolger, hier noch nicht enthalten: der **Redirect-Loop** über `:80`
> (Caddy antwortet dort mit 308) und der Abbau der sechs Container.
## 0. IST-STAND (live gelesen 2026-08-27) ## 0. IST-STAND (live gelesen 2026-08-27)
- Tunnel **bf-public** `48d3b0b6` ✅: Ingress mit 4 öffentlichen Hostnames → `10.40.20.40:80` - Tunnel **bf-public** `48d3b0b6` ✅: Ingress mit 4 öffentlichen Hostnames → `10.40.20.40:80`
- Token: `~/.secrets/credentials.env` → `BF_PUBLIC_TUNNEL_TOKEN` - Token: `~/.secrets/credentials.env` → `BF_PUBLIC_TUNNEL_TOKEN`

59
tools/build-cfd-image.py Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Baut ein minimales docker-v1-Image-Archiv (nur cloudflared + CA-Bundle),
wie RouterOS /container es einliest. Kein Docker noetig, damit das Format
nicht vom containerd-Store der Build-Maschine abhaengt."""
import hashlib, io, json, os, sys, tarfile, time
binp, cap, out = sys.argv[1], sys.argv[2], sys.argv[3]
MTIME = 0 # feste mtime -> reproduzierbares Archiv
def add(tf, name, data, mode, typ=tarfile.REGTYPE):
ti = tarfile.TarInfo(name)
ti.mode, ti.mtime, ti.uid, ti.gid, ti.type = mode, MTIME, 0, 0, typ
ti.size = len(data) if typ == tarfile.REGTYPE else 0
tf.addfile(ti, io.BytesIO(data) if typ == tarfile.REGTYPE else None)
# --- Layer bauen ---
layer = io.BytesIO()
with tarfile.open(fileobj=layer, mode="w", format=tarfile.GNU_FORMAT) as tf:
for d in ("usr/", "usr/local/", "usr/local/bin/", "etc/", "etc/ssl/",
"etc/ssl/certs/", "tmp/"):
add(tf, d, b"", 0o755 if d != "tmp/" else 0o1777, tarfile.DIRTYPE)
add(tf, "usr/local/bin/cloudflared", open(binp, "rb").read(), 0o755)
add(tf, "etc/ssl/certs/ca-certificates.crt", open(cap, "rb").read(), 0o644)
layer_bytes = layer.getvalue()
diff_id = "sha256:" + hashlib.sha256(layer_bytes).hexdigest()
# --- Image-Config ---
cfg = {
"architecture": "arm64",
"os": "linux",
"config": {
"Env": ["PATH=/usr/local/bin:/usr/bin:/bin",
"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"],
"Entrypoint": ["/usr/local/bin/cloudflared"],
"Cmd": ["version"],
"WorkingDir": "/",
},
"rootfs": {"type": "layers", "diff_ids": [diff_id]},
"history": [{"created": "1970-01-01T00:00:00Z",
"created_by": "bf-infra TP16.3: cloudflared + CA, FROM scratch"}],
}
cfg_raw = json.dumps(cfg, sort_keys=True, separators=(",", ":")).encode()
cfg_name = hashlib.sha256(cfg_raw).hexdigest() + ".json"
manifest = json.dumps([{"Config": cfg_name,
"RepoTags": ["cfd-min:2026.6.1"],
"Layers": ["layer/layer.tar"]}]).encode()
with tarfile.open(out, "w", format=tarfile.GNU_FORMAT) as tf:
add(tf, cfg_name, cfg_raw, 0o644)
add(tf, "layer/", b"", 0o755, tarfile.DIRTYPE)
add(tf, "layer/VERSION", b"1.0", 0o644)
add(tf, "layer/json", json.dumps({"id": "layer"}).encode(), 0o644)
add(tf, "layer/layer.tar", layer_bytes, 0o644)
add(tf, "manifest.json", manifest, 0o644)
print(f"Layer roh : {len(layer_bytes)} B ({len(layer_bytes)/1048576:.1f} MiB)")
print(f"Archiv : {os.path.getsize(out)} B ({os.path.getsize(out)/1048576:.1f} MiB)")
print(f"diff_id : {diff_id}")

69
tools/oci2docker.py Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""OCI-Archiv (docker save ab Docker 25 mit containerd-Store) -> klassisches
docker-archive v1, wie RouterOS /container es erwartet."""
import gzip, hashlib, json, os, shutil, sys, tarfile, tempfile
src, dst = sys.argv[1], sys.argv[2]
work = tempfile.mkdtemp(prefix="oci2docker.")
ext = os.path.join(work, "in")
out = os.path.join(work, "out")
os.makedirs(ext); os.makedirs(out)
with tarfile.open(src) as t:
t.extractall(ext)
man = json.load(open(os.path.join(ext, "manifest.json")))
if len(man) != 1:
sys.exit(f"erwarte genau 1 Image im Archiv, gefunden: {len(man)}")
entry = man[0]
# --- Config ---
cfg_src = os.path.join(ext, entry["Config"])
cfg_raw = open(cfg_src, "rb").read()
cfg_sha = hashlib.sha256(cfg_raw).hexdigest()
cfg_name = f"{cfg_sha}.json"
open(os.path.join(out, cfg_name), "wb").write(cfg_raw)
cfg = json.loads(cfg_raw)
# --- Layer: entpacken, falls gzip ---
layers_out = []
for i, lp in enumerate(entry["Layers"]):
blob = os.path.join(ext, lp)
with open(blob, "rb") as f:
magic = f.read(2)
d = os.path.join(out, f"layer{i:02d}")
os.makedirs(d)
target = os.path.join(d, "layer.tar")
if magic == b"\x1f\x8b":
with gzip.open(blob, "rb") as fi, open(target, "wb") as fo:
shutil.copyfileobj(fi, fo, 1024 * 1024)
else:
shutil.copyfile(blob, target)
open(os.path.join(d, "VERSION"), "w").write("1.0")
open(os.path.join(d, "json"), "w").write(json.dumps({"id": f"layer{i:02d}"}))
layers_out.append(f"layer{i:02d}/layer.tar")
# --- diff_ids gegenpruefen: sie muessen die UNKOMPRIMIERTEN Digests sein ---
diff_ids = cfg.get("rootfs", {}).get("diff_ids", [])
if len(diff_ids) != len(layers_out):
sys.exit(f"diff_ids ({len(diff_ids)}) != Layer ({len(layers_out)})")
for want, rel in zip(diff_ids, layers_out):
h = hashlib.sha256()
with open(os.path.join(out, rel), "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
got = "sha256:" + h.hexdigest()
if got != want:
sys.exit(f"Layer-Digest weicht ab:\n erwartet {want}\n erhalten {got}")
json.dump([{"Config": cfg_name,
"RepoTags": entry.get("RepoTags") or [],
"Layers": layers_out}],
open(os.path.join(out, "manifest.json"), "w"))
with tarfile.open(dst, "w") as t:
for n in sorted(os.listdir(out)):
t.add(os.path.join(out, n), arcname=n)
shutil.rmtree(work)
print(f"ok: {dst} ({os.path.getsize(dst)} Bytes, {len(layers_out)} Layer, diff_ids gepruegt)")