diff --git a/CLAUDE.md b/CLAUDE.md index 3ccd870..bd1b323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,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) -- **`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-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell. - **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo. @@ -272,6 +272,21 @@ 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.** - **`sed 's/=.*/=/'` 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=.*//'` 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|=.*|=|'` 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 ` (Abbruch nach Bytes), oder die Datei vor dem Read mit `tr -d '\n' | sed 's|=.*|=|'` über einen Stream-Filter schicken — **niemals** einfach `cat` und auf einen Replacement-Pattern vertrauen. Vergleichbar zum PSK-Leak 2026-08-24. +- **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 `:` 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=/'`. 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:** 1. Habe ich die SSoT für die Datei/das Repo benannt? diff --git a/README.md b/README.md index d2a5c7e..2f641ef 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ unter OpenCode und anderem LLM weitergepflegt. | 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-golden-config` (Gitea) | Bot-Master (Nautobot 2×/Tag) | intended/ + backups/, nicht manuell | | `admin/blaueblueten-n8n` (Gitea) | Master für terpene-Repo | aktiv, lokal synchron | diff --git a/docs/cf-backup/bf-mm-ingress-20260830-vor-hostname-schnitt.json b/docs/cf-backup/bf-mm-ingress-20260830-vor-hostname-schnitt.json new file mode 100644 index 0000000..e6396d7 --- /dev/null +++ b/docs/cf-backup/bf-mm-ingress-20260830-vor-hostname-schnitt.json @@ -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" + } +} diff --git a/docs/cf-backup/bf-mm-ingress-20260830-vor-tp16.3-schnitt.json b/docs/cf-backup/bf-mm-ingress-20260830-vor-tp16.3-schnitt.json index 890b5ee..54b9b9e 100644 --- a/docs/cf-backup/bf-mm-ingress-20260830-vor-tp16.3-schnitt.json +++ b/docs/cf-backup/bf-mm-ingress-20260830-vor-tp16.3-schnitt.json @@ -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"}} \ No newline at end of file +{ + "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" +} diff --git a/docs/cf-backup/bf-nas-ingress-20260830-vor-hostname-schnitt.json b/docs/cf-backup/bf-nas-ingress-20260830-vor-hostname-schnitt.json new file mode 100644 index 0000000..416c3eb --- /dev/null +++ b/docs/cf-backup/bf-nas-ingress-20260830-vor-hostname-schnitt.json @@ -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" + } +} diff --git a/docs/cf-backup/bf-nas-ingress-20260830-vor-tp16.3-schnitt.json b/docs/cf-backup/bf-nas-ingress-20260830-vor-tp16.3-schnitt.json index a105566..f339d01 100644 --- a/docs/cf-backup/bf-nas-ingress-20260830-vor-tp16.3-schnitt.json +++ b/docs/cf-backup/bf-nas-ingress-20260830-vor-tp16.3-schnitt.json @@ -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"}} \ No newline at end of file +{ + "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" +} diff --git a/docs/cf-backup/bf-public-ingress-20260830-vor-tp16.3-schnitt.json b/docs/cf-backup/bf-public-ingress-20260830-vor-tp16.3-schnitt.json index f733504..872c457 100644 --- a/docs/cf-backup/bf-public-ingress-20260830-vor-tp16.3-schnitt.json +++ b/docs/cf-backup/bf-public-ingress-20260830-vor-tp16.3-schnitt.json @@ -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"}} \ No newline at end of file +{ + "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" +} diff --git a/docs/cf-backup/bf-pve-ingress-20260830-vor-hostname-schnitt.json b/docs/cf-backup/bf-pve-ingress-20260830-vor-hostname-schnitt.json new file mode 100644 index 0000000..e588546 --- /dev/null +++ b/docs/cf-backup/bf-pve-ingress-20260830-vor-hostname-schnitt.json @@ -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" + } +} diff --git a/docs/cf-backup/bf-pve-ingress-20260830-vor-tp16.3-schnitt.json b/docs/cf-backup/bf-pve-ingress-20260830-vor-tp16.3-schnitt.json index 8001eb8..f4a0232 100644 --- a/docs/cf-backup/bf-pve-ingress-20260830-vor-tp16.3-schnitt.json +++ b/docs/cf-backup/bf-pve-ingress-20260830-vor-tp16.3-schnitt.json @@ -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"}} \ No newline at end of file +{ + "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" +} diff --git a/docs/cf-backup/cnames-tunnel-20260830-vor-tp16.3-schnitt.json b/docs/cf-backup/cnames-tunnel-20260830-vor-tp16.3-schnitt.json new file mode 100644 index 0000000..d512b66 --- /dev/null +++ b/docs/cf-backup/cnames-tunnel-20260830-vor-tp16.3-schnitt.json @@ -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" + } +] diff --git a/docs/handoff-20260831-tp16.3-paragraph2-3-durch.md b/docs/handoff-20260831-tp16.3-paragraph2-3-durch.md new file mode 100644 index 0000000..b29e995 --- /dev/null +++ b/docs/handoff-20260831-tp16.3-paragraph2-3-durch.md @@ -0,0 +1,139 @@ +# 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` — **die bekannte, unverändert** | + +**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. + +**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=/'` — 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. ⚠️ **Braucht dein Okay** — + Nummernvergabe ist laut Konvention deine Entscheidung. + - **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). + 🔴 `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. diff --git a/docs/handoff-20260831-tp16.3-vorort-session.md b/docs/handoff-20260831-tp16.3-vorort-session.md new file mode 100644 index 0000000..f8f502b --- /dev/null +++ b/docs/handoff-20260831-tp16.3-vorort-session.md @@ -0,0 +1,132 @@ +# 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@ "/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. 🔴 **Compliance-Drift `crs326-core` / `wifi-security` (134/135).** `sec-main` hat am Gerät + `ft-over-ds=no`, Soll `yes` (`ft=yes` ist korrekt). **Kam mit dem 7.24.1-Upgrade.** Braucht eine + Entscheidung — Gerät korrigieren *oder* neuen Wert zum Soll erklären — plus Re-Provisionierung der + CAPs. ⚠️ **Kein Re-Baseline ohne Entscheid**, das macht die Drift-Erkennung wertlos. +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 aus Punkt 5.1 bleibt bestehen) diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index 86ea66d..e59e827 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -27,12 +27,21 @@ Mgmt (VLAN99, Ist 2026-08-16): core `.99.1` · branch `.99.2` · ap1 `.99.11` · ## Ist-Stand (Kurz) -**Stand 2026-08-28 — live gegen die Geräte geprüft (MikroMCP/REST), nicht aus der Doku abgeschrieben.** -**Die Compliance-Zeile unten ist am 2026-08-23 gegen die Nautobot-API nachgezogen.** +**Stand 2026-08-31 — live gegen die Geräte geprüft (MikroMCP/REST/SSH), nicht aus der Doku abgeschrieben.** +**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 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-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 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`, @@ -51,10 +60,38 @@ 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` (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`. +- 🆕 **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. + 🔴 **Eine Drift kam mit dem Upgrade: `crs326-core` / `wifi-security`** — `sec-main` hat am Gerät + `ft-over-ds=no`, Soll ist `yes` (`ft=yes` steht korrekt). **134/135 compliant.** Bewusst **nicht** + angefasst: das ist eine Roaming-Einstellung und braucht eine Entscheidung (Gerät korrigieren **oder** + den neuen Wert zum Soll erklären) plus Re-Provisionierung der CAPs — **nicht nebenbei**. + ⚠️ Ein Re-Baseline ohne Entscheid würde die Drift-Erkennung wertlos machen. +- 🆕 **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`. -- **Alle vier MikroTik laufen auf RouterOS 7.24** (stable, 2026-08-17 von Christoph selbst gehoben, TP27), - **RouterBOOT ebenfalls 7.24** auf allen vieren (`current-firmware` = `upgrade-firmware`, geprüft 08-18). +- **Fünf MikroTik, alle auf RouterOS `7.24.1`** (User-Upgrade 2026-08-31, live gegengelesen; + RouterBOOT jeweils = `upgrade-firmware` = `7.24.1`). Neu dabei **`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, 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 @@ -94,7 +131,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 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. - 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`**, 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 @@ -150,6 +195,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 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.** > 🆕 **Neu am 2026-08-24/25:** [TP31](#tp31) löst **TP16** und **TP16.2** ab und dampft cloudflared @@ -253,7 +303,7 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na > | TP14.4 | 🔜 | CF-Access-Token | — | > | TP16 | ✅ | CF-Tunnel HA wieder voll | 2026-08-24 | > | 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.3 | ✅ | Shelly-Regression behoben | 2026-08-23 | > | TP18 | 🔜 | Garage-Objektspeicher | — | @@ -264,13 +314,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 | > | TP29 | 💡 | Anwesenheitserkennung (Idee) | — | > | 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) | — | > | TP33 | 💡 | Sonos fern (Idee, zurückgestellt) | — | > | TP34 | 🔜 | Caddyfile via Nautobot/GC (Stub, Inventur-Handoff 2026-08-27) | — | > | 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 | — | > | 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` | — | ### TP5.1 — DNS-Visibility & Flow-Observability für Low-Trust-Geräte (Airfryer-Fall) ▸ vorgemerkt @@ -463,7 +514,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. - **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:///`**, 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` 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. @@ -488,6 +581,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 ö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") - **12 Hostnames entfernt**, aus Ingress **und** DNS, in einem Durchgang: `pve` · `portainer` · `pve-mcp` · `ha-mcp` · `ds-bf` · `ccu` · `iobroker` · `z2m25` · `pbs` @@ -2347,6 +2492,78 @@ 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. - **Referenzen:** Lessons-Block-Eintrag „Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität" · Handoff Session A 2026-08-28. + +### TP38 — Out-of-Band-Management: ein untagged-VLAN99-Port je Switch ▸ 🔜 **ANGELEGT 2026-08-31** + +> **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) diff --git a/docs/tp16.3-bf-public-ap-connector-runbook.md b/docs/tp16.3-bf-public-ap-connector-runbook.md new file mode 100644 index 0000000..d5689eb --- /dev/null +++ b/docs/tp16.3-bf-public-ap-connector-runbook.md @@ -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 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-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@: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. diff --git a/docs/tp31-schritt5-runbook.md b/docs/tp31-schritt5-runbook.md index 440830e..9f88b62 100644 --- a/docs/tp31-schritt5-runbook.md +++ b/docs/tp31-schritt5-runbook.md @@ -5,6 +5,22 @@ # Mode-Button-Druck an jedem AP, danach Reboot. Dieser Runbook-Teil muss # 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) - Tunnel **bf-public** `48d3b0b6` ✅: Ingress mit 4 öffentlichen Hostnames → `10.40.20.40:80` - Token: `~/.secrets/credentials.env` → `BF_PUBLIC_TUNNEL_TOKEN` diff --git a/tools/build-cfd-image.py b/tools/build-cfd-image.py new file mode 100755 index 0000000..755aeca --- /dev/null +++ b/tools/build-cfd-image.py @@ -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}") diff --git a/tools/oci2docker.py b/tools/oci2docker.py new file mode 100755 index 0000000..2e39337 --- /dev/null +++ b/tools/oci2docker.py @@ -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)")