Files
bf-infra/docs/teilprojekte.md
Christoph Emsenhuber a3f2ade4e0 TP49: Befund live korrigiert — nur 420.teilzeitladen.xyz betroffen, nicht die interne Infra
Der Fund vom 2026-07-10 beschrieb login_method-Includes am internen Wildcard
*.emsenhuber.net. Live-Nachpruefung am 2026-10-06: dieser Teil ist durch TP41 behoben —
die Policies binden jetzt an Groups (E-Mail/IP/service_token).

Offen ist genau ein Eintrag: login_method:github in Policy 'christoph' auf
420.teilzeitladen.xyz (oeffentlich, Projekt blaueblueten). Kein Zugriff auf
hass/pve/portainer — die kritische Tuer war schon zu.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 06:48:42 +02:00

2619 lines
250 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BF-Netzwerk — Teilprojekte (kanonische Steuerung, Stand 2026-06-10)
> **Zweck:** Das große Netz-Projekt in **einzeln durchziehbare Teilprojekte (TP)** zerlegt — jedes mit klarem Scope,
> aktuellen Entscheidungen, Abhängigkeiten und Status. Jedes TP ist so geschnitten, dass es eine frische Session
> **mit sauberem Kontext** straight abarbeiten kann. Architektur/Historie: `~/.claude/plans/ich-will-das-delegieren-atomic-sunrise.md`.
>
> 📚 **Diese Datei führt den Ist-Stand, den kritischen Pfad und die OFFENEN Teilprojekte.**
> Abgeschlossene TPs stehen im **[Archiv](teilprojekte-archiv.md)** — im Volltext, unverändert, mit
> Inhaltsverzeichnis. **Für den Einstieg reicht diese Datei**; ins Archiv sieht man, wenn man wissen
> will, *warum* etwas so entschieden wurde. *(Aufgeteilt am 2026-08-23, Doku-Session Punkt F: 2217
> Zeilen waren zu 49 % Historie erledigter TPs.)*
## Kanonisches VLAN-Modell (gültig — überschreibt ältere Tabellen)
| VLAN | Name | Subnetz (Ziel) | Zweck |
|---|---|---|---|
| 10 | MAIN | 10.40.10.0/24 | Trusted (Handy/Laptop/Apple Watch) |
| 20 | INFRA | 10.40.20.0/24 | NAS/VMs/Docker/HA |
| 30 | IOT | 10.40.30.0/24 | Smart-Home (HA-managed), Waage, Fitness-Tracker |
| **40** | **MEDIA** | 10.40.40.0/24 | **Sonos, AppleTV, TV, VuPlus, Cast-Ziele** (getauscht ggü. früher) |
| **50** | **UNTRUSTED** | 10.40.50.0/24 | **Spielekonsole, eBook-Reader, Low-Trust-Internet** |
| 90 | GUEST | 10.40.90.0/24 | nur Internet |
| 99 | MGMT | 10.40.99.0/24 | Switch/AP/Hypervisor-Mgmt |
| 100 | WAN | (Arris-Transfer) | zum Arris-Kabelmodem (Liwest, Bridge-Mode, Public-IP an `crs326-core`) |
IP-Blöcke: `.1–10` Router/Gateways · `.11–20` Switches/APs. **GW je VLAN = `.1` (crs326-core).**
Mgmt (VLAN99, Ist 2026-08-16): core `.99.1` · branch `.99.2` · ap1 `.99.11` · ap2 `.99.12`.
*(Historisch, überholt: „ap1=.1, ap2=.2 · ALLNET=.11, Omada=.12" — aus der Zeit vor dem Core-Cutover.)*
## Status & Index → Gitea-Board (SSoT seit 2026-09-07, TP36-Cutover)
> 🧭 **Ist-Stand, kritischer Pfad und Quick-Index leben ab jetzt als Gitea-Issues**, nicht mehr in
> dieser Datei: **[admin/bf-infra · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues)**.
> **TP = Issue · Status = Label `status:*`** (todo/wip/paused/idea/concept/done/dropped) ·
> **Gruppierung = Label `thema:*`**. Filtern über die Label-Chips der Issue-Liste, z.B.
> [nur offene](https://gitea.emsenhuber.net/admin/bf-infra/issues?type=issue&state=open).
>
> **Diese Datei führt nur noch die per-TP-Prosa** — die `### TPnn`-Abschnitte unten; Abgeschlossenes im
> **[Archiv](teilprojekte-archiv.md)**. **Live-Gerätedaten** (Compliance-Zahl, RouterOS-Version …) kommen
> aus **Nautobot**, nicht von hier. Sync-Werkzeug Datei/Archiv → Gitea (idempotent, Dry-Run default):
> `bin/gitea-tp-sync.py`.
>
> *(TP36-Cutover 2026-09-07: die frühere Quick-Index-Tabelle und der chronologische „Ist-Stand (Kurz)"-Block
> sind aus dieser Datei entfernt, damit TP-Branches nicht mehr auf der Steuerungs-Doku kollidieren — Status-
> Updates gehen nach Gitea statt in diese Datei. Die alte Ist-Stand-Historie steht in der Git-Historie.)*
## Governance & genehmigte Ausnahmen (stehende Regeln — kein Status)
> 🎯 **Ziel: sauberes System „from scratch", keine Leichen/Altlasten.** Migrations-Session-Endstand = **VLAN1 + altes cudy-WLAN komplett weg** (VLAN1 nur noch Mgmt, `defconf`-Client-DHCP zurückgezogen, cudy-Uplinks aufs Ziel-Client-VLAN getaggt oder cudy abgebaut). **Unterpunkte/Aufräum-Liste bei Migration:** (a) die Interim-Härtung ap2 `wifi1/wifi2 frame-types=admit-only-vlan-tagged` (TP3.1, 2026-07-06) bleibt gültig — verschwindet automatisch mit VLAN1; (b) ~~`st-main rrm/wnm` = reines Roaming-Provisorium~~ → **kein Provisorium mehr (2026-08-13, TP3.3): Steering ist fester Bestandteil des Golden-Config-Templates + ComplianceFeature `wifi-steering`** — es war die Neuprovisionierung, die es stillschweigend verloren hatte; (c) ~~**cudy1 = verbleibender aktiver VLAN1-Leak**~~ → **ERLEDIGT (User-bestätigt + verifiziert 2026-08-14): cudy1 ist offline.** `core ether11` trägt **kein Link** (kein `R`-Flag), Nautobot-Status *Offline* (TP22 Schritt 5). Damit existiert kein aktiver VLAN1-Leak mehr; der Dual-DHCP-Race aus `project_vlan1_dhcp_incident` ist Historie. Der formale Teardown (Gerät ausbauen, Doku/Inventar) bleibt TP19.
> ⚠️ **HW-Constraint hAP ax²:** 1 GB RAM, aber nur **128 MB Flash + KEIN USB** → **Container auf dem Router NICHT praktikabel**.
> Folge: **native RouterOS-Features bevorzugen** (z.B. Built-in-mDNS statt Container); Container (mDNS-Reflector, BIND9-DNS, Omada-Controller) laufen auf **NAS/pve**, nicht am hAP.
> 🔒 **Governance-Entscheid (User, 2026-07-16, ausgeweitet 2026-07-16): Config ausschließlich über Nautobot — keine Direkt-Changes mehr an einzelnen im Netz verstreuten Geräten.** Ursprünglich als ap1/ap2-Config-Freeze bis TP21 formuliert, noch am selben Tag verallgemeinert: **jede** Netz-Gerätekonfiguration (Router, APs, künftig auch die CRS326-Switches) läuft ab jetzt exklusiv über Nautobot Golden Config (TP21), sobald das steht — keine ad-hoc MikroMCP-Writes/UI-Klicks mehr an Einzelgeräten. **TP19.1 war die letzte Ausnahme** (ap1-DNS-Fix + ap2/sw1/sw2-Mgmt-VLAN-Umzug, s.u.) — davon ist nur der risikolose ap1-Teil noch direkt gemacht worden, ap2 wurde bewusst auf „nach Nautobot" vertagt. **Macht Nautobot + alle Prerequisites (TP18→TP20→TP20.1→TP21, jetzt auch Komodo) zum aktuell kritischen Pfad**, vor TP5/TP6. Betrifft nicht Software-Migrationen auf NAS/pve (anderes Substrat) und die Frage, ob sw1/sw2 vor der CRS326-Ablösung noch angefasst werden (offen, s. TP15/TP19.1).
> ✅ **Genehmigte Ausnahme (User, 2026-07-17): der zentrale CF-Tunnel-Zugang auf ap1 (TP16/TP16.1).** Alle für den zentralen Cloudflare-Zugang **auf ap1** notwendigen (Vor-)Arbeiten dürfen direkt via MikroMCP/CF-API gemacht werden, ohne auf Nautobot GC zu warten — konkret die nativen WireGuard-Mesh-Zeilen aus TP16.1 (und analoge cloudflared/WG-Anpassungen am ap1-Edge). Begründung: Remote-Erreichbarkeit ist die Voraussetzung, um überhaupt sicher am Netz zu arbeiten (Henne-Ei zu Nautobot). **Scope-Grenze:** Die Ausnahme gilt **nur für ap1**. Sobald der zentrale Zugang später auf die **CRS326** wandert (TP15), gilt die Ausnahme dafür **vorerst NICHT** — CRS326-Config läuft dann regulär über Nautobot GC (oder braucht eine eigene, dann neu zu erteilende Ausnahme). Nachtrag der ap1-Ausnahmezeilen in die GC als Follow-up, sobald Nautobot steht.
> ✅ **Genehmigte Ausnahme #4 (User, 2026-08-16): die WireGuard-Zeilen des Cloudflare-Mesh-Bring-ups auf `crs326-core` (TP16.1).** Dürfen direkt via MikroMCP/REST gesetzt werden. **Begründung ist strukturell, nicht bequem:** TP22-Regel 1 schließt jede Sektion mit `<<REDACTED>>` generell vom GC-Push aus, und `/interface wireguard` ist genau so eine — ein privater Schlüssel ist über Golden Config **nicht deploybar**. Damit ist die frühere TP16.1-Zusage „CRS326-Zeilen laufen regulär über GC" gegenstandslos. **Auflage:** vorher `/system backup`, danach Ist-Stand als intended config zurück nach Nautobot. **Scope-Grenze:** nur der Mesh-Bring-up auf `crs326-core` — nicht spätere WG-Änderungen, nicht andere Geräte, und sie verlängert sich nicht automatisch. Langfristige Auflösung = das Secret-Handling-Design in TP16.1 (Privkey ist Geräte-Identität, nicht Config).
> ✅ **Genehmigte Ausnahme #2 (User, 2026-07-22): der CRS326-Bring-up (TP15).** Die **Grundkonfiguration** beider CRS326 — Management-IP, Bridge, VLAN-Filtering, Trunks/Access-Ports — darf **direkt** via MikroMCP/Winbox gemacht werden. **Begründung = Henne-Ei:** Golden Config kann nicht auf ein Gerät pushen, das im Werkszustand keine erreichbare IP hat, und die GC-Templates existieren noch nicht (TP21 „offen: Templates/intended config"). **Auflage:** der so entstandene Ist-Stand wird anschließend als **intended config nach Nautobot zurückgeschrieben**, danach übernimmt GC — die Ausnahme deckt das Bootstrapping, nicht den Dauerbetrieb. **Scope-Grenze:** gilt für die beiden CRS326 im Bring-up. Sie hebt die Regel für ap1/ap2 **nicht** auf (deren Neuprovisionierung läuft weiterhin über GC) und verlängert sich nicht automatisch auf spätere CRS326-Änderungen. Damit ist die frühere Formulierung in TP16.1 („CRS326-Config läuft regulär über Nautobot GC oder braucht eine eigene, dann neu zu erteilende Ausnahme") **erfüllt** — dies ist diese Ausnahme.
---
## Offene Teilprojekte
> **Alles hier hat noch offene Arbeit.** Abgeschlossenes steht im
> **[Archiv](teilprojekte-archiv.md)** (Volltext, mit Inhaltsverzeichnis) — die Zuordnung ist
> mechanisch: *ein TP wandert erst dann ins Archiv, wenn nichts mehr daran zu tun ist* — **und der
> Weg zurück ist offen: ein archiviertes TP ist ein geschlossenes Ticket, kein versiegeltes.**
> Kommt an derselben Baustelle neue Arbeit dazu, **wandert der Volltext zurück hierher** (Zeiger
> bleibt im Archiv stehen, damit alte Links halten) — statt ein Zwillings-TP mit neuer Nummer
> anzulegen. *(Christoph-Direktive 2026-08-30, erster Fall: **TP28** wegen `ap3`.)* Wo nur ein
> Sub-TP offen war, steht dieses hier und das erledigte Eltern-TP im Archiv (so bei
> ~~**TP17.2** ↔ TP17~~ *(TP17.2 ist am 2026-08-24 in **TP30** aufgegangen und kein eigenes TP mehr)*
> und **TP20.1** ↔ TP20).
>
> 📊 **Index/Statustabelle → [Gitea-Board](https://gitea.emsenhuber.net/admin/bf-infra/issues)** (siehe Kopf dieser Datei). Hier unten folgt nur noch die per-TP-Prosa.
### TP5.1 · Gitea #1 — DNS-Visibility & Flow-Observability für Low-Trust-Geräte (Airfryer-Fall)
- **Kontext (2026-07-08/09):** Airfryer „airschreier" (Philips/Versuni, ESP, `10.40.30.180`) bekam per DHCP-Option 6 `dns 1.1.1.1` als Override, weil das OTA-Update über den ap1-DoH-Gateway-Pfad nicht durchging (DoH-Latenz beim primitiven ESP-Stack — **kein** Content-Block, via Gateway-Logs bewiesen, s. `memory/ref_esp_ota_doh_not_block`). Nebenwirkung: public **1.1.1.1 = anonymer Consumer-Resolver ≠ accountgebundener Gateway-DoH** (`px35ynlol3.cloudflare-gateway.com`) → **DNS-Sicht auf das Gerät weg**, Gerät umgeht zudem die Filterung.
- **(a) DNS-Redirect statt Geräte-Override** (der dauerhafte Fix): dst-nat „UDP/53 (+TCP/53) von Low-Trust-Src → auf einen *plain* Resolver umbiegen" gibt **Logging zurück UND** funktionierendes OTA (kein langsamer DoH-Pfad fürs Gerät). Redirect-Ziel-Optionen: (i) ap1-Resolver mit *nicht*-DoH-Upstream für dieses Src-Set, (ii) direkt public 1.1.1.1 (dann nur Flow-Logging), (iii) eigener plain-Resolver. Sauberer als „Gerät verschwindet auf 1.1.1.1". Verortung: Teil des UNTRUSTED-Internet-only-Pinnings (TP5-„Offen"); Entscheidung v50-DNS-Policy (gefiltert vs. plain) hängt dran.
- **(b) Live-Torch als Ad-hoc-Observability:** DNS ist bei direktem 1.1.1.1 blind, **Flows nicht** (NATen über ap1). `torch` auf `vlan30`/`vlan50` gefiltert auf die Geräte-IP zeigt Ziel-IPs live (Versuni → AWS `eu-west-1` IoT erkennbar). Voraussetzung: Gerät aktiv. Merkregel: Airfryer nur online wenn er kocht → Capture ans Einschalten koppeln.
- **Abh.:** UNTRUSTED-Move des Airfryers (Kandidat, s. VLAN50) + TP5-Deny-by-default. Kein Blocker.
### TP8 · Gitea #2 — Identity & Access-Control
- **RADIUS = FreeRADIUS auf der NAS** (✏️ User-Manager v5 kann **KEIN LDAP** → openldap nur via FreeRADIUS anbindbar).
- **EAP-TLS (Zert) je Gerät + dynamische VLAN-Zuweisung** (RADIUS-Attribut `Tunnel-Private-Group-ID`). **Einfachste Heim-Alternative:** pro VLAN eine SSID mit **WPA3-PSK** (kein RADIUS) — Abwägung Komfort vs. Admin-Aufwand.
- Abh.: TP3. (Research erledigt, s. TP-R.)
### TP11 · Gitea #3 — Monitoring & Mgmt-Control-Plane
- MikroMCP + omada-mcp + **SNMP/icinga-mikrotik**, RoMON, NTP, **E-Mail-Alerts**, Netwatch. → Teil von `docs/research/routeros-capabilities.md`.
- **CF-Tunnel #1033 Track A ✅ live (2026-06-30):** Round-Robin-1033er über 3 asymmetrische Connectors behoben
durch Reduktion auf pve-only Connector (NAS + macmini scheiterten an pve-lokalen/stalen Origins). SSoT:
`docs/cf-tunnel-mapping.md`. **Track B/C läuft über TP14** (Ingress bereits v116 .41→.20 für NAS;
pve-Connector→VLAN20 via TP14 Session B = #1033-Zielbild).
### TP12 · Gitea #4 — IPv6 (intern + extern, 3 Standorte)
- Konzept für FM/BF/DW. → TP-R.
#### TP12.1 · Gitea #5 — IPv6 im LAN (ULA) + v6-Firewall
> **Anlass:** der runde IKEA-Fühler **TIMMERFLOTTE** (Matter over Thread) ließ sich nicht anlernen —
> „pairing failed" in der Companion-App. Ursache ist **nicht** die Firewall, sondern dass zwischen
> VLAN10 und VLAN20 **überhaupt kein IPv6 geroutet wird**. Damit ist TP12 vom Konzept-TP zu einem
> Punkt mit einem konkreten, wiederkehrenden Schaden geworden.
- **Befund am `crs326-core` (live gelesen 2026-08-22):**
- IPv6-Adressen: **ausschließlich Link-Local** (`fe80::…`) auf jedem Interface. Keine ULA, keine GUA.
- IPv6-Routing-Tabelle: nur `::1/128` und `fe80::/64` je Interface. **Keine Route** auf den
Thread-OMR-Prefix `fd92:505a:c64e:1::/64` und keine auf `fd03:cace:953f:e13d::/64` (den On-Link-Prefix,
den der OTBR auf VLAN20 wirbt).
- `/ipv6 nd`: Default-Eintrag, `advertise-dns=no` — es gibt aber ohnehin keine Prefixe zu werben.
- 🔴🔴 **Der eigentliche Kostenpunkt ist nicht IPv6, sondern die Firewall:** `/ipv6 firewall filter` ist
**komplett leer**. Heute harmlos, weil ohne Adressen nichts geroutet wird. In der Sekunde, in der
Adressen vergeben werden, steht **IOT→MAIN offen, GUEST→INFRA offen — an TP5 vorbei**. Das gesamte
TP5-Regelwerk muss **vorher** in v6 nachgebaut sein, sonst reißt dieser TP genau die Segmentierung
auf, die TP5 am 2026-08-18 geschlossen hat. **Das ist die Arbeit, nicht die Adressvergabe.**
- 🔎 **Warum Matter das braucht:** Commissioning läuft in zwei Etappen. Etappe 1 (BLE zum Gerät) ist
netzunabhängig. In Etappe 2 muss der Commissioner — das iPhone — das Gerät auf dem **operativen
Thread-Netz** wiederfinden (DNS-SD `_matter._tcp`, dann CASE). Dafür braucht das Telefon eine
IPv6-Route in den OMR-Prefix hinein. iPhone hängt auf VLAN10, OTBR auf VLAN20 → Etappe 2 bricht ab.
Die ~30 bereits laufenden Matter-Knoten widersprechen dem nicht: HA und OTBR sitzen auf **demselben
Host** (`wpan0` ist lokal), im Betrieb quert nichts die VLAN-Grenze. Nur das *Anlernen* tut es.
- ⚠️ **Zwei Fallen, die den naiven Weg verbauen:**
- **(a) Der OMR-Prefix ist generiert und wandert.** Am 2026-08-22 um 17:33 (OTBR-Neustart) sind alle
Knoten von `fd92:505a:c64e:1::/64` auf **Mesh-Local** `fd66:986:170:bb1a::/64` gefallen — und
Mesh-Local ist per Definition *nicht* off-mesh routbar. Eine statische Route auf den OMR-Prefix ist
also nur so stabil wie der nächste Neustart. Ihn im Thread-Dataset festzunageln formiert das Netz
neu — mit 30+ produktiven Knoten daran kein Nebenbei-Change.
- **(b) RouterOS lernt fremde RAs nicht.** Der OTBR wirbt den OMR-Prefix per RA mit Route-Information-Option
auf VLAN20. Der Core müsste die annehmen — als forwardender Router für dieses Interface tut er das
nicht sauber.
- 📡 **Dritter Baustein: DNS-SD über die VLAN-Grenze.** `mdns-repeat-ifaces` am Core (vlan10/20/30/40/90)
ist **IPv4**. Für Matter-Discovery bräuchte es zusätzlich einen v6-fähigen Reflector — der
`mcast-relay`-LXC (108) kann das prinzipiell, aber als **zweite Instanz** mit Beinen in VLAN10+VLAN20
(SSDP-Instanz ist VLAN10+VLAN40, s. Gotcha in CLAUDE.md).
- ✅ **Der Workaround, der das umgeht statt löst:** iPhone zum Anlernen auf **VLAN20** — dann sind Telefon
und Border Router auf demselben L2-Link, der OTBR wirbt dort On-Link-Prefix *und* Route, und iOS wertet
beides aus. `cfg-infra` / SSID `dirtyvader-infra` (`dp-infra`, VLAN20, `sec-infra` mit PSK) existiert am
Manager **bereits**, ist nur in **keiner** `provisioning`-Zeile als Slave hinterlegt. Runbook:
[`docs/matter-pairing-infra-ssid-runbook.md`](matter-pairing-infra-ssid-runbook.md).
- ✅ **2026-08-22 durchgefuehrt und wieder zurueckgebaut.** `dirtyvader-infra` temporaer auf ap2
(Transaktion `TEST-infra-ssid-matter-pairing`, Scope `wifi-provisioning`), iPhone dort hinein,
**TIMMERFLOTTE per Companion-App angelernt** — liefert 25,44 °C / 53,1 % rF / Batterie 100 %
(Bereich Technik UG). Danach SSID deaktiviert; `cfg-infra`/`dp-infra`/`sec-infra` bleiben auf
User-Wunsch **inaktiv am Manager stehen**, damit der Weg beim naechsten Thread-Geraet ein
Zweizeiler ist. Beide Akten geschlossen, `crs326-core` transaktionsfrei.
⇒ **Der Workaround ist damit bewiesen** — und TP12.1 bleibt trotzdem offen: er umgeht das
fehlende IPv6-Routing, er behebt es nicht.
- ✅ **Der Weg, der das Problem dauerhaft erledigt:** Matter-Server-Option **`ble_proxy: true`** (ab HA
2026.06, hier 2026.8.3). Dann kommissioniert **HA selbst** über seinen Bluetooth-Stack; das Telefon ist
raus und die VLAN-Grenze wird nie berührt. Braucht eine **connectable** BLE-Quelle — Shelly-Proxies sind
reine Advertisement-Scanner (kein GATT), ein **ESP32 als ESPHome-BT-Proxy** (`active: true`) reicht.
Der **ESPHome Device Builder ist bereits installiert**. Damit wäre TP12.1 für Matter **optional**.
🔴 **User-Entscheid 2026-08-22: ESP32 wird NICHT angeschafft.** Damit bleibt `ble_proxy` zwar an
(schadet nicht, kostet nichts), ist aber **wirkungslos** — HA hat keine connectable BLE-Quelle und
wird auch keine bekommen. **Konsequenz, die man beim nächsten Thread-Gerät kennen muss:** der Weg ist
dann wieder die temporäre `-infra`-SSID nach dem Runbook. Die ist inzwischen ein Zweizeiler, weil
`cfg-infra`/`dp-infra`/`sec-infra` am Manager stehen bleiben — aber sie ist jedes Mal ein
Config-Change am Core plus eine Transaktion. Wer das dauerhaft loswerden will, muss **TP12.1 bauen**
(v6-Firewall zuerst); ein dritter Weg ist damit nicht mehr vorgesehen.
- 🔎 **Nebenbefund 2026-08-22, gehört zur Thread-Gesundheit, NICHT zu IPv6:** drei Matter-Knoten
flappen dauerhaft — **`@1:a` („Diele OG/Treppenauge")**, **`@1:f`** und **`@1:10`**. Muster im
Matter-Server-Log: `IpServiceStatus … Resolving (address is unreachable)`, dann
`offline grace period expired, marking unavailable`, im Abstand von 15–30 Minuten, über Stunden.
Die übrigen ~20 Knoten sind stabil, es ist also **kein** Border-Router- oder Fabric-Problem.
⚠️ **Vor einer Diagnose die Sonderlage von heute abziehen:** der OTBR ist am 2026-08-22 um 17:33
neu gestartet (Knoten fielen kurzzeitig von OMR `fd92:…` auf Mesh-Local `fd66:…` zurück), und ap1
war zwischenzeitlich vom Netz. Erst nachsehen, ob das Flappen **danach** noch besteht — sonst
diagnostiziert man das Nachbeben eines Umbaus. Erste Verdachtsrichtungen, wenn es bleibt:
Thread-Routing/Parent-Wechsel dieser drei Knoten, schwache Funkstrecke zum nächsten Router-Knoten,
oder eine ICD-/Sleepy-End-Device-Eigenheit dieser Geräteklasse.
- **Abh.:** hängt an nichts. **Blockiert nichts** — außer künftige Thread-Geräte, solange weder
`ble_proxy` noch die `-infra`-SSID steht. Reihenfolge innerhalb des TP ist zwingend:
**v6-Firewall nachbauen → dann erst Adressen vergeben.** Gehört unter TP12 (Gesamtkonzept 3 Standorte),
ist aber allein für BF ausführbar.
### TP13 · Gitea #6 — „Botschafts-WLANs" — `docs/tp13-botschafts-wlan-runbook.md` · fun/low-prio
> 📌 Am 2026-08-19 überarbeitet: der Technikteil beschrieb noch Standalone-APs (SSIDs entstehen seit
> TP28 am **Manager**, also ein Deploy statt zwei), und der Entwurf „nur `ssid=`, kein Security" war ein
> **Denkfehler** — eine SSID ohne Security *ist* ein offenes WLAN, also genau der Fall, den der
> Rechtsvorbehalt ausschließen soll. Richtig: PSK, den niemand kennt. Dazu die Datapath-Falle
> (Slave-VAP ohne `vlan-id` landet ungetaggt in der Bridge — dieselbe Mechanik wie der 14-h-Ausfall).
- **Idee:** eine/mehrere **Broadcast-only-SSIDs als „schwarzes Brett"** an die Funk-Nachbarschaft — der SSID-Name *ist* die Botschaft (z.B. `Kanal-11-bitte-freihalten`, `WLAN-Tuning-siehe-Portal`). Optional ein **offenes WLAN mit Captive-Portal** auf eine **statische Info-HTML** (Kanalabsprache, Auto-Kanalwechsel-Bitte). Leitgedanke: wer die SSID sieht, kann auch stören → die Infoseite erreicht genau die Zielgruppe.
- ~~**Technik (jetzt einfach, da WLAN standalone seit TP3-Rebuild):** reine Namens-SSIDs = zusätzliche `/interface/wifi` Slave-VAPs auf wifi1/wifi2 mit eigener `configuration` (**nur `ssid=`, kein Security/Datapath**)~~ ⚠️ **Überholt 2026-08-19, in zwei Punkten:**
- **(a) WLAN ist seit TP28 nicht mehr standalone.** SSIDs entstehen am **Manager `crs326-core`** (9 `configuration`-, 6 `datapath`-Objekte, 4 `provisioning`-Zeilen — live gelesen 08-19). Das ist für TP13 sogar einfacher: **ein** Deploy statt zwei.
- **(b) 🔴 „kein Security" *ist* ein offenes WLAN** — genau das, was der Rechtsvorbehalt unten ausschließen will. Richtig ist eine SSID **mit** WPA2/WPA3-PSK, den niemand kennt: sie beacont den Namen, ist aber nicht beitretbar.
- Captive-Portal-Variante (RouterOS-**Hotspot**, isoliertes VLAN, `walled-garden`) bleibt als Option bestehen — s. Rechts-Gate unten.
- ~~**Umsetzungs-Skizze:** … Beacon-SSIDs in `routeros/.secrets/ap{1,2}-prov-base.rsc` ergänzen …~~ → diese Dateien sind für den WLAN-Teil **Historie** (die CAPs sind auf die `cap`-Zeile eingedampft). **Aktuelle Umsetzung: `docs/tp13-botschafts-wlan-runbook.md`** (2026-08-19).
- 🔴🔴 **Die Falle, die dort ausführlich steht:** ein Slave-VAP **ohne explizite `vlan-id` am Manager** landet ungetaggt in `bridge` — weil der CAP vom Manager-Datapath **nur die `vlan-id`** übernimmt. Das ist dasselbe Leck wie TP3.1 (`cfg-main-2g` ohne datapath) und dieselbe Mechanik wie der 14-h-Ausfall vom 08-18. Die Botschafts-SSID braucht deshalb einen Datapath mit **totem** `vlan-id` — PSK **und** toter Datapath, nicht eines von beidem.
- ✅ **VAP-Budget geprüft (live 08-19), unkritisch:** `max-interfaces=16` je Radio, belegt sind 4 (5 GHz) bzw. 5 (2,4 GHz). **Die Grenze ist Airtime, nicht die Zahl** — jeder VAP beacont eigenständig. Empfehlung: **genau eine SSID, auf 2,4 GHz** (mehr Reichweite = mehr Nachbarn, und 5 GHz bleibt für Nutzverkehr frei).
- ⚠️ **Recht vor Scharfschalten prüfen (User privat, AT):** offenes WLAN + Portal berührt Impressumspflicht/Störerhaftung — **reine Namens-SSIDs ohne offenen Zugang sind unkritisch** (kein Fremd-Datenverkehr); im Zweifel nur die Beacon-Variante.
- **Abh.:** ~~TP3-Rebuild ✅ (Standalone-APs — SSID-Änderung = 1 Deploy/GC-Job)~~ → **TP28 ✅** (CAPsMAN auf dem Core) + **TP22 ✅** (GC-Deploy-Pipeline). SSID-Änderung = **ein** Deploy auf den Core; Direkt-Edit am Gerät ist durch den Governance-Entscheid ausgeschlossen.
### TP14.4 · Gitea #7 — Session D: CF-Access-Profil für Claude — `docs/tp14.4-cf-access-token-runbook.md` (historisch)
> ✅ **Erledigt über TP41 T4 (2026-09-01), nicht als eigenständiger `claude-mcp`-Token:** der
> `claude-mcp`-Service-Token war die Basis (`t4a.py`), wurde **→ `zt-svc` umbenannt + rotiert** und über
> Gruppe `zt-automation` konsolidiert. `mcp/cloudflare/.env` trägt jetzt **beide** Keys
> `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` (Block „TP41 T4 zt-svc"), verifiziert
> (`test_whoami.py` → `admin:true`, `common_name=zt-svc`). Handoff: [`handoff-20260901-tp41.md`](handoff-20260901-tp41.md) §T4.
> 📌 *Überholt (Ist-Stand vor 09-01, bewusst erhalten):* „in `mcp/cloudflare/.env` stehen weder
> `CF_ACCESS_CLIENT_ID` noch `_SECRET` — nichts halb erledigt." Das war bis zum 2026-09-01 richtig;
> TP41 T4 hat die Keys dann geschrieben. Abnahme-Regel bleibt gültig (mit Headern antwortet / ohne 403).
- CF-Token „claude code mcp" hat jetzt `Access: Service Tokens: Write`+`Read` → `claude-mcp` rotieren (`POST …/access/service_tokens/{id}/rotate`) → `CF_ACCESS_CLIENT_ID`/`_SECRET` in `mcp/cloudflare/.env` (chmod600). Memory `ref-cf-access-claude-profile`.
- 📋 **Runbook: `docs/tp14.4-cf-access-token-runbook.md`** (2026-08-19). **Ist-Stand verifiziert:**
`mcp/cloudflare/.env` enthält bisher weder `CF_ACCESS_CLIENT_ID` noch `_SECRET` — es ist nichts halb
erledigt. 🔴 **Das Secret wird nur bei der Rotation ausgegeben**, wer es nicht sofort schreibt, muss
erneut rotieren. Abnahme = ein Aufruf **mit** den Headern (antwortet) **und** einer **ohne** (403) —
der Erfolgsfall allein beweist nichts, der Hostname könnte auf `bypass` stehen.
### TP16 · Gitea #8 — CF-Tunnel: Round-Robin killen + reziprokes HA — Kern 2026-07-10 — `docs/cf-tunnel-mapping.md §10 + §15`, #1033
- ✅ **GEFIXT 2026-08-24 — Ursache war ein `docker stop`, kein QUIC-Flap.** Die beiden macmini-Connectoren
(`cloudflared-bfnas`, `cloudflared-bfpve`) standen auf `Exited (0)`, **beide `finishedAt` auf derselben
Sekunde `2026-07-21T13:00:53`**, bei korrekt gesetztem `restart=unless-stopped` und `http2`. Fix =
`docker start` der zwei, sonst nichts. **Abnahme: alle drei bf-Tunnel je 2 Connectoren / 8 conns,
und 36 Anfragen über 6 FQDNs beider betroffener Tunnel mit `530er = 0`.** Details `§15`.
- 🔴 **Die Datierung im Befund unten war falsch:** degradiert **seit 2026-07-21**, nicht seit dem 08-16 —
der 08-16 war der Tag der *Entdeckung*. **Vier Wochen unbemerkt.**
- 🔴 **Die eigentliche Lehre ist die Nachweislücke:** ein Tunnel mit 1 von 2 Connectoren meldet
`healthy`. Über den Tunnel-Status ist die Degradation **unsichtbar**, nur die **Connector-Anzahl**
zeigt sie. ⇒ Check nach **TP11**: `connectoren < 2` je bf-Tunnel = Alarm. **Ein HA-Design ohne
Zähler auf die Redundanz ist kein HA-Design.**
- 📜 **Ursprünglicher Befund 2026-08-16 (Historie, Ursache s.o.):**
- `bf-mm` = **2 Connectoren** (je 4 conns) ✅ · `bf-nas` = **1 Connector** (3 conns) 🟠 · `bf-pve` = **1 Connector** (4 conns) 🟠.
- **Bedeutung:** die Dienste hinter `bf-nas` (gitea, grafana, pve-mcp, **vw**, ds-bf, nautobot) und `bf-pve` (pve, ccu, iobroker, z2m25, portainer, **hass**, ha-mcp) hängen jeweils an **einem einzigen** cloudflared-Prozess. Fällt dessen Host aus, sind sie weg — genau der Zustand, den TP16 beseitigt hat. **Kein `530`/Round-Robin-Problem** (das bleibt strukturell gelöst), sondern **Redundanzverlust**.
- ~~**Bekanntes Muster:** identisch zum Incident vom 2026-07-16~~ 🔴 **war es NICHT** — s. Fix oben. Ursprungstext:
**Bekanntes Muster:** identisch zum Incident vom 2026-07-16 (macmini-Connectoren flappten unter QUIC → Fix `TUNNEL_TRANSPORT_PROTOCOL=http2`). Diesmal sind die *macmini*-Connectoren oben (bf-mm hat 2), es fehlt je **ein** Gegenstück auf NAS/pve. **Nächster Schritt (eigene Session):** auf NAS und pve nachsehen, welcher der beiden cloudflared-Container nicht läuft, und ob es der `http2`-Konventionsfall ist.
- ✅ **GELÖSCHT 2026-08-24 (User-Entscheid „alle vier"):** `openclaw`, `coolify`, `johnny2`,
`TeilzeitCharlie` sind über die CF-API entfernt, alle vier mit `success: true`. **Gegenprobe: 5 Tunnel
verbleiben** (`bf-mm`/`bf-nas`/`bf-pve` je 2 Connectoren, `fm` und `terpene-db` je 1 — beide
standortfremd bzw. eigenes Projekt, kein Defekt). Damit sind auch die letzten **VLAN1-Origins**
(`10.40.41.147/.148/.139/.133`) aus der Cloudflare-Config verschwunden — der Rest von TP19.
- 📜 **Ursprünglicher Nebenbefund (Historie):** Alt-Tunnel mit VLAN1-Origins (alle `down`, 0 Connectoren, daher aktuell harmlos):** `openclaw` (→ `10.40.41.147/.148/.139`), `coolify` (→ `10.40.41.133`), `johnny2`, `TeilzeitCharlie`. ⚠️ **`openclaw` beansprucht `n8n.emsenhuber.net`, das produktiv auf `bf-mm` läuft** — käme der Tunnel je wieder hoch, entstünde exakt das alte Round-Robin-Muster auf diesem Hostname. **Empfehlung: die vier toten Tunnel löschen** (user-gated, Löschen ist nicht umkehrbar)
🔵 **Begründung korrigiert 2026-08-24:** die Sorge „käme der Tunnel wieder hoch, entstünde das alte
Round-Robin auf diesem Hostname" ist **so nicht richtig** — **welcher Tunnel einen Hostname bedient,
entscheidet das DNS**, und live zeigt **kein einziger CNAME** auf die vier (`n8n` zeigt korrekt auf
`bf-mm`). Ein hochkommender `openclaw` wäre inert. Der reale Pfad ist ein *aktives*
`cloudflared tunnel route dns`, das den CNAME umschreibt. ⇒ **Löschen bleibt richtig — als Hygiene
und um genau diese Falle zu entfernen, nicht als Störungsbehebung.** — gehört fachlich zu **TP19** (VLAN1-Retirement: `.41.133` war eines der „unidentifizierten VLAN1-Geräte" aus TP19 B1.2 — hiermit als coolify-Origin identifiziert).
> **✅ Umgesetzt (2026-07-10, eigene Session):** reziprokes **3-Tunnel-HA** live (bf-nas `70d6a4cf` / bf-pve `f7fdf3a6` / bf-mm `7a322261`), 6 Connectoren (2/Host, je Fremdhost), 19 FQDNs Loop-Smoke **0×530**, HA per Host-Ausfall-Sim bewiesen. Round-Robin-#1033 **strukturell weg**. As-Built = `docs/cf-tunnel-mapping.md §10`. **Schritt 5 Access-Strang: ~~`vw` bypass→geschützt via WARP~~ — 🔴 GESTRICHEN (User-Constraint 2026-08-14, s. TP16.2). Verbleibend offen: `hass`/`ha-mcp` 400/403; `gateway-dw` (WG→404) klären.**
- **Symptom (User):** FQDNs über CF werden **sporadisch / „sehr unsicher"** aufgelöst = #1033-Muster zurück („jeden 3. Schuss eine Niete"). User hat alle 3 cloudflared-Images + Container aktualisiert/neu gestartet.
- **✅ Root-Cause endgültig (User-Diagnose bestätigt): macvlan/ipvlan-Host-Isolation.** Ein cloudflared-Connector ist **blind für macvlan/ipvlan-Container auf seinem EIGENEN Host** (Linux-Kernel-Regel), erreicht aber Fremdhost-L2 + co-lokierte Bridge-Container. Mehrere Connectoren auf EINEM Tunnel = geteilter Ingress + CF-**Round-Robin** → jede Origin, die auf dem Host *eines* Connectors liegt, fällt bei ~**1/N** aus. **Strukturell**, nicht durch „Origins fixen" heilbar. Bsp: `omada .20.175`/`pve-mcp .20.173`/`gitea .20.157`/`grafana .20.109` = NAS-macvlan → vom NAS-Connector NICHT erreichbar.
- **Invariante:** innerhalb EINES Tunnels ist **kein Route→Connector-Pinning** möglich (alle Connectoren teilen Ingress, CF round-robined). „Ausgewählte Routen für ausgewählte Connectoren" = **getrennte Tunnel**.
- **Ziel-Design (HA): reziproke Per-Host-Tunnel** — jeder Host-Dienst wird von den Connectoren der ANDEREN Hosts bedient (nie vom eigenen):
| Tunnel (= Dienste eines Hosts) | Connectoren auf | HA |
|---|---|---|
| pve-Dienste | NAS + macmini | 1 abdrehbar |
| NAS-Dienste (omada/pve-mcp/gitea/grafana/…) | pve + macmini | 1 abdrehbar |
| macmini-Dienste (`.20.167`) | pve + NAS | 1 abdrehbar |
3 Tunnel, jeder Host fährt 2 cloudflared → Host-Ausfall ⇒ jeder Tunnel ≥1 Connector = **voll HA**. Voraussetzung: jede Origin L2-erreichbar.
- **Ist-Inventory (live 2026-07-10, s. `cf-tunnel-mapping.md §9`):** Ingress v119, 24 Hostnames, 2 live Connectoren. **DOWN/stale:** `pbs .20.135`, `nginx-bf .41.123` (noch VLAN1!), `hermes`+`hermes-geli .20.167`. **Zu klären (Host/Netz):** `vw .20.127`/`ccu .20.232`/`iobroker .20.107`/`z2m25 .20.106` — ggf. `vw`/`ccu` auf VLAN20-macvlan heben. `gateway-dw` (192.168.178.1) = WG-Sonderfall.
- **CF-Access-Strang (verzahnt, s. TP11 Track-B):** `hass`-**400 in der HA-iPhone-App = CF-Access davor** (Companion-App kann Access-Login nicht) → Fix **ohne Passthrough**: iPhone in **WARP** enrollen (Device-Identity erfüllt Access; „Warp Login App" existiert bereits `allow:christoph`). ~~⚠️ **`vw` = aktuell `bypass` (öffentlich!)** → von bypass auf geschützt heben.~~ 🔴 **Überholt (2026-08-14): `vw` BLEIBT bypass — harter User-Constraint, Härtung stattdessen über TP16.2.** „Passthrough-Hosts sind die kritischen" (User).
- **Umsetzungs-Reihenfolge (für die eigene Session):** (1) **Reachability-Matrix je Host** (pve/NAS/macmini → jede Origin, read-only) BEVOR gruppiert wird; (2) **Interim-Sofortfix:** bf-Tunnel auf **pve-only** (pve erreicht alles außer gateway-dw) → 0×1033, reversibel; (3) reziprokes 3-Tunnel-HA bauen (CNAMEs re-pointen, 6 Connectoren, Token je Tunnel; NAS/macmini-Alt-Token entfernen); (4) tote Origins bereinigen; (5) Access-Strang (WARP + vw); (6) Loop-Gegentest.
- Abh.: TP14.1/14.2 ✅. Verwandt: **TP11** (CF-Tunnel-Track-B/C + Access), #1033. SSoT: `docs/cf-tunnel-mapping.md`. Memory `project_cf_tunnel_1033`.
- ✅ **Incident 2026-07-16 (User unterwegs, WARP „spinnt", keine BF-IPs mehr) ▸ Public-FQDN-Teil gefixt:** macmini-Connectoren (`cloudflared-bfnas`/`-bfpve`) flappten live unter QUIC/UDP (Docker-Desktop-Bridge-NAT) → `bf-nas`/`bf-pve` von 2 auf 1 Connector degradiert (HA faktisch weg, nicht down). Fix: beide auf `TUNNEL_TRANSPORT_PROTOCOL=http2` umgestellt (rollend, 0 Downtime), wieder voll HA. Konvention: macmini-Connectoren künftig immer `http2`. **ICMP-Befund (getrennt, strukturell):** TCP über die WARP-Route ging überall, ICMP nicht (fm 36 %/bf 100 %/dw 100 % Loss) — cloudflared ist QUIC-Proxy, kein WireGuard. **Kurz als „bf-pve-Route wegen USV" notiert (Commit `1c1a676`) → REVIDIERT** (Begründung invertiert: bf-pve-Connectoren laufen auf NAS+macmini, nicht pve). Details: `docs/cf-tunnel-mapping.md §13`. **→ Richtungswechsel für Private Access = TP16.1.**
### TP16.2 · Gitea #9 — Härtung der öffentlich erreichbaren Dienste (bypass-Strang) — Umsetzung offen
- 🔴 **HARTER CONSTRAINT (User 2026-08-14): `vw` wird NICHT WARP-/Access-geschützt.** Gilt dauerhaft, nicht „vorerst". **Begründung (technisch belastbar, nicht Bequemlichkeit):** die Bitwarden-Clients (Browser-Extension, Mobile, Desktop) können den **interaktiven Access-Login nicht durchführen** — dasselbe Muster, das die HA-Companion-App auf `400` laufen lässt. Ein Passwortmanager hinter einem Client, den man im Notfall (fremdes Gerät, Neuinstallation, Handyverlust) nicht installieren kann, ist ein **Aussperr-Risiko gegen den eigenen Schlüsselbund** — und damit gefährlicher als das, wovor die Maßnahme schützen soll. **Die frühere TP16-Schritt-5-Absicht „vw von bypass auf geschützt heben" ist damit gestrichen.**
- **Ziel stattdessen: Schutz erhöhen, ohne Clients zu brechen** — gilt für **alle** bypass-Dienste (`vw`, `hass`, `gitea`).
- ✅ **Schicht 0 — bereits vorhanden, nicht unterschätzen:** keine offenen Ports, **alles ausschließlich über CF-Tunnel** (3-Tunnel-HA, TP16), TLS terminiert an der Edge, DDoS-Schutz. Der Origin ist nicht direkt erreichbar. Das ist bereits mehr als bei den meisten Selfhostern.
- ✅ **Schicht 1 — chirurgisch: Access nur auf die Pfade, die kein Client anfasst.** CF Access wertet **die spezifischere Application zuerst** → man kann eine **zweite Access-App auf `vw.emsenhuber.net/admin*`** legen (echte Policy) und den Host im Übrigen auf bypass lassen. **Das Admin-Panel wird ausschließlich per Browser bedient — dort funktioniert der Access-Login einwandfrei**, während `/api`, `/identity` und `/notifications` (die Client-Pfade) unberührt bleiben. Kein Client bricht, das wertvollste Ziel ist weg.
- Alternativ noch härter: **Admin-Panel ganz aus** (`ADMIN_TOKEN` leer lassen) und nur bei Bedarf temporär aktivieren.
- Analog für **`gitea`**: `/admin*` + Registrierung path-scoped schützen (Git-über-HTTPS mit Token bleibt offen).
- ✅ **Schicht 2 — App-eigene Bremsen (kostenlos, sofort):** Vaultwarden hat Rate-Limits **per Default an** (`LOGIN_RATELIMIT_SECONDS=60`, `LOGIN_RATELIMIT_MAX_BURST=10`, dazu `ADMIN_RATELIMIT_*`) → Werte prüfen/verschärfen; `SIGNUPS_ALLOWED=false`, `INVITATIONS_ALLOWED=false`, `SHOW_PASSWORD_HINT=false`, **2FA erzwingen**. HA hat `ip_ban_enabled` + `login_attempts_threshold`. Gitea: Registrierung aus.
- ✅ **Schicht 3 — Edge-Regeln (CF), wirkt für alle Dienste gemeinsam:** WAF-Custom-Rules + **Rate Limiting gezielt auf die Login-Endpunkte** (`/identity/connect/token` bei vw, `/auth/login` bei HA, `/user/login` bei Gitea); optional Geo-/ASN-Filter. ⚠️ **Free-Plan-Kontingente vorher im Dashboard prüfen** (nicht belegt).
- ✅ **Schicht 4 — ein Mechanismus für alle: CrowdSec (oder fail2ban) mit Cloudflare-Bouncer.** Bans landen dann **an der Edge** statt erst am Origin. Vaultwarden hat ein offizielles Fail2Ban-Setup-Wiki (Log-Muster für Login **und** Admin-Panel).
- 🔴 **Voraussetzung für Schicht 2–4, und der eigentliche Knackpunkt: die echte Client-IP muss am Origin ankommen.** Hinter dem CF-Tunnel sieht die Anwendung sonst **nur die IP des cloudflared-Containers** → jede IP-Sperre bannt den Tunnel statt den Angreifer = **Selbstaussperrung aller Nutzer**, und Rate-Limits greifen ins Leere. **Genau dieser Defekt ist bereits offen dokumentiert:** HAs `trusted_proxies` fehlen `.20.10`/`.20.167` (TP16, `hass`-400-Strang). ➡️ **`X-Forwarded-For`/`CF-Connecting-IP` sauber durchreichen ist Schritt 1 dieses TPs**, alles andere baut darauf auf.
- **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 · Gitea #10 — cloudflared-Reduktion: was der Mesh als Datenpfad kann (und was nicht) — Flip (§5) + Abbau (§6) durch; öffentlich nur noch `bf-public` (+`fm`) — `docs/tp16.3-bf-public-ap-connector-runbook.md`
#### ✅ §6 Abbau — AUSGEFÜHRT 2026-09-01 (vom User vorgezogen, „es wird nichts mehr sein")
Die 7-Tage-Wartefrist war auf **User-Entscheid** hinfällig. Reihenfolge eingehalten: **erst Container,
dann Tunnel** (umgekehrt gäbe es 530er).
- **Vorbedingung geprüft:** alle vier Namen auf `bf-public`, **kein** Name mehr an den Alttunneln ⇒
Abbau gefahrlos.
- **7 Container entfernt** (per exakter Namensliste, nicht per Filter — „Namen lesen, nicht Zeilen
zählen"): LXC 400 `cloudflared-bfmm`/`-bfnas`, macmini `-bfpve`/`-bfnas`, NAS ep14 `-bfpve`/`-bfmm`,
**plus `terpene-cloudflared`** — Letzterer auf zusätzlichen User-Entscheid, weil `420.teilzeitladen.xyz`
jetzt über Caddy/CF läuft. **Kein Stack dahinter** ⇒ kein Wiederauferstehen.
- **Beweis vor der Tunnel-Löschung:** `420.teilzeitladen.xyz` lieferte **mit bereits entferntem
`terpene-cloudflared`** echten Origin-Inhalt (assetlinks-JSON, 200) — der Dienst hängt also nicht
mehr am Tunnel.
- **4 Tunnel gelöscht:** `bf-mm`, `bf-nas`, `bf-pve`, `terpene-db` — jede ID gegen die Behalten-Liste
(`bf-public`, `fm`) geprüft.
- **Schlusskontrolle:** nur noch `bf-public` (12) + `fm.emsenhuber.net` (4) healthy; alle vier
BF-Namen und `420` weiterhin **200**.
- ℹ️ Der `terpene-db`-Tunnel-Token stand kurz im `docker inspect`-Output — mit der Tunnel-Löschung
**ungültig**, kein Rotationsbedarf.
#### 🔜 Follow-up F2 — `bf-public`-Connector auf die APs (Runbook 2026-08-30)
**Runbook: [`tp16.3-bf-public-ap-connector-runbook.md`](tp16.3-bf-public-ap-connector-runbook.md)**
— Vor-Ort-Session an **ap1 + ap3 (Technik UG)** und **ap2 (Technik OG)**, weil der
`device-mode`-Wechsel eine **physische** Bestätigung am Gerät verlangt.
- 🆕 **ap3 ist neu und nicht baugleich** (live 08-30): **cAP ax** (`cAPGi-5HaxD2HaxD`, `10.40.99.13`),
arm64, 96 MB Flash frei, 689 MB RAM frei. ~~Zwei verschiedene `container.npk`~~ — **überholt seit dem
ROS-Upgrade 08-31: alle drei auf `7.24.1`/arm64 ⇒ EINE Datei** (`container-7.24.1-arm64.npk`,
1.187.985 B — exakt die Grösse, die die APs selbst für das Paket melden, damit belegt statt
angenommen). ap3 steht auf `device-mode=basic` (nicht `home`), dort ist `fetch` bereits erlaubt —
wir nutzen trotzdem auf allen dreien den `.tar`-Import, damit alle dasselbe Image fahren.
- ✅ **Blocker A — Redirect-Schleife, behoben 2026-08-31.** `bf-public` zeigt auf `http://10.40.20.40:80`; Caddy antwortet
dort mit **308 → `https://<name>/`**, also zurück durch Cloudflare in denselben Tunnel ⇒
`ERR_TOO_MANY_REDIRECTS` für alle vier Namen. **Fällt heute nicht auf**, weil die drei Alttunnel
**an Caddy vorbei** direkt aufs Backend gehen — die Schleife entsteht erst mit dem Umstieg.
Fix = `https://10.40.20.40:443` + `originServerName` je Regel (über 443 mit korrekter SNI: 4× 200).
- ✅ **Blocker B — die Freigabe fehlte, angelegt 2026-08-31 über Nautobot GC.** Die `forward`-Kette des Core wurde vollständig
gelesen (36 Regeln, Nr. 35 = `TP5: deny-by-default`): **keine** Regel `vlan99 → vlan20` für 80/443.
Die einzige MGMT→INFRA-Regel (Nr. 26) ist ein `src-port=8728`-**Rückweg** der API.
⚠️ **Der Vorgänger-Runbook behauptet das Gegenteil** („Regel 27 deckt `vlan99→vlan20:80` ab ✅") —
Regel 27 ist `IOT->INFRA http-rueckweg`, `in-interface=vlan30`. Ohne neue Regel läuft der Connector
in `TP5-WOULD-DROP` + Drop. Egress zu CF (Regel 13 `LAN->WAN`) und DNS (Regeln 30–33) sind dagegen ✅.
⇒ Das `masquerade` des `veth`-Netzes ist deshalb **Pflicht**, nicht Kosmetik: sonst kommen die Pakete
mit `172.17.0.2` am Core an, wo weder Route noch Regel sie kennt.
- ✅ **Blocker C — `ROUTER_AP3_PASS` fehlte in `~/.secrets/credentials.env`, nachgetragen 2026-08-31** (steht nur in
`~/.claude.json`), gegen die Credential-Store-Konvention. Der `sshpass -e`-Reboot-Weg braucht es dort.
⚠️ Nebenbefund: **`credentials.env` lässt sich in zsh nicht sourcen** — ab ~Zeile 69 stehen rohe
WireGuard-Zeilen, zsh bricht ab und lässt die Variablen **leer**; das sieht aus wie ein ungültiger
Token. Alle Snippets im Runbook laufen deshalb in `bash -c`.
- 🔩 **Kein PoE als Abkürzung:** beide CRS326 haben nachweislich gar kein PoE-Menü
(`bad command name poe`) ⇒ es gibt **keinen** Remote-Power-Cycle, die Bestätigung ist wirklich physisch.
- 🧹 **Abbau (frühestens 7 Tage nach dem Flip):** Tunnel `bf-mm`/`bf-nas`/`bf-pve` löschen und die
**sechs** Container entfernen — LXC 400 (`cloudflared-bfmm`, `-bfnas`), macmini (`-bfpve`, `-bfnas`),
NAS ep14 (`-bfpve`, `-bfmm`); alle drei Hosts 08-30 verifiziert. **Erst Container, dann Tunnel** —
umgekehrt erzeugt man 530er. 🚫 **`terpene-cloudflared` (LXC 400) und `fm.emsenhuber.net` bleiben.**
- 🔴 **Ehrliche Einschränkung (aus TP31, gilt unverändert):** solange Caddy nur auf `caddy-bf1` liegt,
ist das Origin aller drei Connectoren **derselbe Rechner** — drei Connectoren kaufen
**Hardware-Diversität am Eingang, aber keine Verfügbarkeit**. Der echte Gewinn kommt mit `caddy-bf2`
(LXC 131); für `vw` gilt dabei die TP20.1-Bremse (Schreibzugriffe auf einen read-only Standby).
- **Rollback:** `docs/cf-backup/*-20260830-vor-tp16.3-schnitt.json` (vier Ingress-Configs + CNAMEs
inkl. Record-IDs), am 2026-08-30 **neu angelegt**.
- ▶️ **Schnitt ausgeführt 2026-08-24:** `nautobot.emsenhuber.net` ist aus dem Ingress von `bf-nas`
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.
**Abnahme vollständig:** private IP mit WARP von außerhalb des LAN 302 in 0,16 s · die übrigen fünf
`bf-nas`-Hostnames unverändert · und der Punkt, den man übersieht — **Golden Config lief durch:
Backup → Intended → Compliance, 104/104, 0 Drifts.**
- 🎯 **Der Testaufbau war der einzige, der etwas beweist:** die Session lief auf einem Host im
Hotspot-Netz `172.20.10.13` mit aktivem WARP, also **nicht im BF-LAN**. Aus dem LAN heraus wäre
der Zugriff ohnehin gegangen und hätte nichts gezeigt (Merkregel `feedback_adresse_lesen_nicht_ansagen`).
- 🔴 **Befund, der die Abnahmetabelle des Runbooks korrigiert: der öffentliche FQDN taugt nicht als
Test.** `nautobot.emsenhuber.net` liefert **vor und nach** dem Schnitt HTTP 302 — der kommt von
**Cloudflare Access**, nicht vom Origin (`location: emsenhuber.cloudflareaccess.com/cdn-cgi/access/…`,
JWT `auth_status: NONE` bei `is_warp: true`). Access greift **vor** dem Tunnel. ⇒ Gültiger Nachweis
ist **das Zurücklesen der Ingress-Config**, nicht die Antwort des Frontends — dieselbe TP22/TP23-Regel.
- 📌 **Und die Erwartung wird damit ehrlicher:** Nautobot war nie ohne Access-Login öffentlich
erreichbar. Der Schnitt schließt **keine offene Tür**, er entfernt eine **überflüssige zweite**.
- ⚠️ **Vorprüfung, die vor jedem weiteren Schnitt zu wiederholen ist:** hängt Werkzeug am
öffentlichen Namen? Hier nein — `gc-configplan.py` spricht `http://10.40.20.154:8080/api`
(TP45 Schicht A 2026-09-21; zum Messzeitpunkt war es `.20.30`), und
kein Repo-Skript nutzt den FQDN.
- ✅ **Betriebsfenster ausgewertet 2026-08-30 (Tag 6/7): `nautobot` bleibt mesh-only.** Kein Rückweg
gebraucht, kein Ausfall. `nautobot.emsenhuber.net` hat inzwischen auch **keinen DNS-Record** mehr
(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.154:8080` (TP45 Schicht A 2026-09-21)
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.
- ✅ **ERLEDIGT 2026-08-31: die sechs Drifts sind ins Soll gehoben, Compliance 139/140.**
Entscheid Christoph: ins Template heben (nicht als Transaktion führen), und `/interface veth`
bekommt eine **eigene ComplianceRule** — vorher war es das tragende Stück ohne jede Überwachung.
Umgesetzt in `nautobot/mikrotik_routeros.j2` (Block `[7]`), `nautobot/ingest-ap-configcontext.py`
(`ap.container`) und neu `nautobot/ingest-veth-rule.py`. **Kein Geräte-Write.**
🔴 **Der veth-Export trägt zwei gerätevergebene MACs** (`mac-address`, `container-mac-address`,
je AP verschieden). Ohne Vorbehandlung hätte die neue Regel auf **allen drei APs Dauer-Drift**
erzeugt — also genau die immer-rote Zahl, die niemand mehr liest. Zwei `ConfigReplace`-Regeln
normalisieren sie im Backup, eng auf die veth-Zeile verankert (`[^\n]*?` plus Lookbehind, damit
VAP-/Bridge-/Ethernet-MACs nicht mitgefangen werden; Selbsttest im Skript).
⚠️ **Ehrlich dazugesagt:** das kostet Backup-Treue — die veth-MACs stehen in `bf-golden-config`
nicht mehr im Klartext. Vertretbar, weil RouterOS sie beim Neuanlegen ohnehin neu vergibt; was die
Regel beweist, ist die **Existenz** des veth mit der richtigen Adressierung.
*(Der ursprüngliche Befund, zur Nachvollziehbarkeit:)*
- 🟢 **War: Compliance stand 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`
und die drei CF-Access-SSH-Ziele `82unkpb5` (macmini) · `3k23uow7` (NAS) · `j44d6tua` (pve).
Ingress-Bilanz: `bf-mm` 6→4 · `bf-nas` 6→3 · `bf-pve` 9→2. Catch-all in allen dreien geprüft letzte.
- **`pbs` und die SSH-Ziele standen nicht in der Kandidatenliste** — User-Entscheid 2026-08-30: beide
mesh-only. PBS erreicht man über `10.40.20.135:8007`, SSH direkt per IP, jeweils mit WARP.
- ✅ **Zwei Namen zunächst NICHT geschnitten: `gitea-mcp` und `n8n-mcp` — am selben Tag per F1 nachgeholt.**
Die TP16.3-Vorprüfung („hängt Werkzeug am öffentlichen Namen?") schlug hier an:
`gitea-mcp.emsenhuber.net` stand in **11 Dateien** (nicht 9 — beim Edit kamen zwei
Worktree-Kopien dazu, s. F1) — `~/dev/{bf-network-r6,bf-network-r6.opencode,blaueblueten-n8n,
dachterrasse,ha,ha-xstate,poulios-recherche,valamar}/.mcp.json` plus `bf-infra/opencode.jsonc` —
und `~/.claude.json` nutzte zusätzlich `n8n-mcp.emsenhuber.net`. **TP35 Schicht 1 hatte nur die
globale `~/.config/opencode/opencode.jsonc` auf LAN-IP migriert**; Projekt-Ebene und
`~/.claude.json` waren zurückgeblieben. Regel „erst migrieren, dann schneiden" eingehalten:
**F1 hat migriert, danach fiel der Schnitt** (Ingress `bf-mm` 4→2, beide DNS-CNAMEs gelöscht).
- ✅ **Abnahme (2026-08-30), beide Richtungen gemessen:**
- **intern trägt weiter:** AdGuard löst alle neun Namen unverändert auf `10.40.20.40` (Split-Horizon,
TP31 Schritt 4), Caddy antwortet über HTTPS mit **HTTP 200** (`pve` 0,08 s · `portainer` 0,03 s ·
`ds-bf` 0,91 s · `z2m25` 0,03 s · `pbs` 0,03 s).
- **öffentlich weg:** `pve`/`portainer`/`ha-mcp`/`pbs` gegen `1.1.1.1` → **NXDOMAIN**.
- **Public-Strang unberührt:** `vw`/`hass`/`n8n`/`gitea` weiterhin **HTTP 200** über Cloudflare.
- Nach TP16.3-Regel ist der gültige Nachweis das **Zurücklesen der Ingress-Config**, nicht die
Antwort des Frontends — die Bilanz oben stammt aus dem `GET` nach dem `PUT`.
- 📦 **Rollback:** `docs/cf-backup/{bf-mm,bf-nas,bf-pve,bf-public}-ingress-20260830-vor-tp16.3-schnitt.json`
+ `docs/cf-backup/dns-emsenhuber.net-20260830-vor-tp16.3-schnitt.json` (alle `success:true`).
Ingress zurück = ein `PUT` je Tunnel; DNS zurück = CNAME auf `<tunnel-id>.cfargotunnel.com`, proxied.
#### 🔴 Blocker: der Abbau der drei Alt-Tunnel haengt an einer Vor-Ort-Session
Der Schnitt endet hier, weil `bf-public` **noch keinen Connector hat** (0 Verbindungen, angelegt
2026-08-27) und die vier Public-Dienste weiterhin auf `bf-mm`/`bf-nas`/`bf-pve` liegen. User-Entscheid
2026-08-30: der Connector gehört auf **ap1/ap2** (TP31-Begründung: der AP ist eine eigene Maschine,
kein Docker-Host — gegenüber macvlan/ipvlan sind wir dort nicht blind).
**Live gelesen am 2026-08-30 — der Preis ist nicht bezahlt:**
```
ap1: /system package print → routeros 7.24, wifi-qcom 7.24 (2 Pakete, KEIN container)
ap1: /system device-mode print → mode: home · container: no · fetch: no · scheduler: no
```
⇒ Es fehlt **beides**: das `container`-Paket (extra `.npk`, arm64) **und** `device-mode container=yes`,
und Letzteres verlangt die **physische Bestätigung am Gerät** — ap1 Technik UG, ap2 Technik OG.
`fetch: no` heißt zusätzlich: das Image kann der AP nicht selbst ziehen, es muss als `.tar` hoch und
über `/container add file=` importiert werden. **Das ist eine Vor-Ort-Session, kein Remote-Schritt.**
→ Runbook-Bedarf als Follow-up **F2**; bis dahin bleiben die drei Alt-Tunnel mit je 2 Connectoren stehen.
#### 📌 Follow-ups aus dieser Session (2026-08-30)
- ✅ **F1 — `gitea-mcp`/`n8n-mcp` FQDN→LAN-IP, DURCH 2026-08-30.** Statt der geschätzten 10 waren es
**12 Dateien**: `~/.claude.json` (beide Namen), `bf-infra/opencode.jsonc`, 8× `dev/*/.mcp.json` und
**zwei Worktree-Kopien** in `bf-network-r6{,.opencode}/.claude/worktrees/frosty-hermann-76f9cd/`,
die erst ein Sweep fand. Danach fiel der Schnitt (s. oben). Backups je Datei.
- 🔑 **Die zwei Clients haben verschiedene Konfigpfade — das ist die Stelle, an der man Dateien übersieht.**
**Claude Code** liest `<projekt>/.mcp.json`, also **eine Datei pro Projekt**. **opencode** liest
**eine globale** `~/.config/opencode/opencode.jsonc`; projekt-lokal existiert nur
`bf-infra/opencode.jsonc`. Deshalb genügte für opencode die eine globale Migration (TP35 Schicht 1),
während Claude Code acht Einzeldateien brauchte. `.mcp.json` ist **kein** opencode-Pfad.
- 🔴 **`n8n-mcp` lässt sich NICHT ohne Bearer betreiben.** `dist/http-server.js` ruft
`process.exit(1)`, wenn `AUTH_TOKEN` fehlt („required for HTTP mode"). Damit ist er der **einzige
echte Zugriffs-Bearer** im Setup — `gitea-mcp` hat gar keine Auth, alles andere (`mikromcp`,
`hostinger`, `portainer`, `github`) sind Arbeits-Credentials der MCPs, kein Zugriffsschutz.
Der Wunsch „Bearer für alle weg" ist also faktisch schon erfüllt, bis auf den einen, der es nicht kann.
- 🔧 **`gitea-mcp` auf `10.40.20.167:3000` gebunden** (war `0.0.0.0:3000`). Zustandslos, kein Volume,
per `docker run` rekonstruiert; Token über die Prozessumgebung (`-e VAR` ohne Wert), **nicht** über
argv — dieselbe Klasse wie die `sshpass -p`-Falle. ⚠️ Der Nutzen ist klein: der macmini hat **nur
`en0`**, `0.0.0.0` war dort faktisch schon `.167`. `n8n-mcp` blieb deshalb auf `*:3001`.
- 🔴🔴 **`gitea-mcp` war funktionsunfähig — mit einem veralteten Gitea-Token, seit unbekannter Zeit.**
Der Container trug `GITEA_ACCESS_TOKEN` = `a9317c68…`, den Gitea mit **401** ablehnt; der SSoT-Store
`~/.secrets/credentials.env` hielt längst den gültigen (`bc12ad19…`, **200**). Gitea selbst war die
ganze Zeit erreichbar (`/api/v1/version` → 200). Fix: Container mit dem Store-Token neu gebaut
(Token per **stdin**, nicht argv). Abnahme = echter API-Call `user__get_my_userinfo` → `login=admin`.
⚠️ **Nicht durch den Recreate verursacht:** der Token-Hash war vor und nach dem Bind-Umbau identisch.
🔑 **Die eigentliche Lehre ist die Abnahme-Methode:** ein `initialize`-Handshake beweist nur, dass
der MCP-**Prozess** lebt — er antwortet mit `serverInfo` auch dann, wenn jeder echte Aufruf am
Token scheitert. Die erste Verifikation dieser Session sah deshalb grün aus und war es nicht.
**Für MCPs mit Backend-Credential ist die gültige Abnahme ein `tools/call`, der das Backend
wirklich anfasst** — dasselbe Muster wie „frische DHCP-Lease statt Registration-Table" bei WLAN.
Direkt daraus folgt **F5**: die Container-Envs driften vom Store, weil sie ihn nie lesen.
- ⚪ **Tote Diagnose-Permissions entfernt:** `blaueblueten-n8n/.claude/settings.local.json` hatte 3×
`Bash(dig/host/curl … gitea-mcp.emsenhuber.net)` in der `allow`-Liste (10→7 Einträge). Das waren
**keine** MCP-Verbindungen — `enabledMcpjsonServers: ["gitea-mcp"]` gibt per **Name** frei und
blieb unberührt. Projektweit geprüft: nur dieses eine Projekt betroffen.
- 📌 **Nachweis-Korrektur zur Abnahme oben:** geschnittene Namen antworten öffentlich mit
**`NOERROR` + leerer Antwort**, nicht mit `NXDOMAIN` — gegengeprüft an `pve`/`portainer`/`ha-mcp`/`pbs`
aus dem großen Schnitt, die sich identisch verhalten. Gültiger Nachweis bleibt der zurückgelesene
Ingress plus die leere Antwort.
- ⚠️ Projektübergreifend → gehört ins **Announcement** (TP35 Schicht 4), eigene Session.
Dort mitnehmen: **„achte auf opencode"** sowie den neuen Pfad `~/dev/bf-infra` / Repo `admin/bf-infra`.
- **F2 — Vor-Ort-Runbook ap1/ap2:** `container`-Paket + `device-mode`, cloudflared-Image als `.tar`,
`bf-public`-Token, danach CNAME-Umhang der vier Public-Dienste und Abbau von `bf-mm`/`bf-nas`/`bf-pve`
samt der **sechs** Container (LXC400 `.126`: `cloudflared-bfmm`+`cloudflared-bfnas` · macmini `.167`:
`cloudflared-bfpve`+`cloudflared-bfnas` · NAS `.10`: `cloudflared-bfpve`+`cloudflared-bfmm`).
⚠️ `terpene-cloudflared` (LXC400) gehört zum Tunnel `terpene-db` und **bleibt** — anderes Projekt.
Ebenso unberührt: Tunnel `fm.emsenhuber.net` (Standort FM).
- **F3 — CF-Connectoren in Nautobot abbilden** (User-Wunsch 2026-08-30, „durchgehende Netzwerkdoku
Stück für Stück"). Als **TP37** vorgeschlagen, noch nicht angelegt — wartet auf dein Okay.
- 🔜 **F4 — `n8n-mcp`-Compose versöhnen** (Befund aus F1, 2026-08-30; User-Entscheid: eigener Punkt,
nicht nebenbei). Der laufende Container stimmt mit **keiner** seiner Definitionsquellen überein —
ein `docker compose up -d` wäre deshalb **destruktiv**, es würde die interne n8n-Anbindung umstellen:
| Quelle | `N8N_API_URL` | Netz | Port |
|---|---|---|---|
| `.env` (macmini) | `http://10.40.41.167:5678` ← **totes 41.x-Schema** | — | `3000` |
| `docker-compose.yml` | `https://n8n.emsenhuber.net` (hartkodiert, ignoriert die `.env`) | `web` | `${HOST_PORT:-3000}` |
| **laufender Container** | `http://n8n:5678` ← korrekt, Container-DNS | `n8n_default` | `3001` |
🔴 **Vorsicht — die naheliegende Lösung ist die falsche.** Ein `tools/call n8n_health_check` (statt
nur `initialize`) zeigt: **n8n-mcp hat einen SSRF-Schutz und lehnt private Adressen ab** —
`"SSRF protection: Private IP addresses not allowed", apiUrl: http://n8n:5678`. Die
Container-DNS-Adresse des laufenden Containers **funktioniert also gar nicht**; sie im Compose
festzuschreiben würde den Zustand zementieren, nicht heilen. Umgekehrt war
`https://n8n.emsenhuber.net` im Compose vermutlich **Absicht**, kein Drift — nur eben über den
CF-Umweg, was der TP16.3-Stossrichtung zuwiderläuft. Ein Abschalter für den Schutz ist im Image
nicht ersichtlich (nur `TRUST_PROXY`). **F4 muss diesen Zielkonflikt lösen**, nicht nur Dateien angleichen.
⚠️ **Aktueller Ausfallumfang:** die **Doku-Tools laufen** (`search_nodes` ✅ — das ist die
Hauptfunktion von `n8n-documentation-mcp`), nur die **n8n-Instanz-Management-Tools** (`n8n_*`) sind tot.
Zu tun: Netz als `external: true` auf `n8n_default`, `N8N_API_URL` **erst nach Klärung des SSRF-Punkts**,
Image auf den laufenden Digest pinnen (`sha256:2c8ff077…` = v2.57.1; heute `:latest` +
`pull_policy: always`, was bei jedem Recreate unkontrolliert hochzieht), Bind auf `10.40.20.167`.
⚠️ **`N8N_API_KEY` ist in allen drei Quellen verschieden**; der laufende und der aus
`~/.secrets/credentials.env` wurden beide gegen die n8n-API getestet (**HTTP 200**), der `.env`-Wert
ist ungetestet. Der Bearer dagegen ist überall identisch. Volume `n8n-mcp-deploy_n8n-mcp-data`
überlebt einen Recreate, `gitea-mcp` hat gar kein Compose (reiner `docker run`).
- ✅ **F7 — `pve-mcp`-URL korrigiert (2026-08-30).** Beide globalen Configs zeigten auf
`http://10.40.20.173:3000/**mcp**`, der Server antwortet aber **nur auf dem Root-Pfad**
(live: `/` → **200**, `/mcp` → **404**) — die Session meldete entsprechend `ENDPOINT_NOT_FOUND`.
Damit widersprachen `~/.claude.json` und `~/.config/opencode/opencode.jsonc` der **eigenen
CLAUDE.md**, die den Root-Pfad seit jeher korrekt nennt („Root-Pfad, NICHT /sse"). Beide auf
`http://10.40.20.173:3000/` gezogen; `bf-infra/opencode.jsonc` war bereits richtig.
Merkregel: **wenn ein MCP „endpoint not found" meldet, zuerst die Doku gegen die Config lesen** —
hier stand die Antwort seit Monaten im Repo.
- ✅ **F6 — GUI-Apps sehen `exports.zsh` nicht: gelöst 2026-08-30 per LaunchAgent.** Aufgefallen, weil
`n8n-mcp` beim Session-Start mit **401** abgewiesen wurde, obwohl URL und Bearer stimmten.
🔑 **Ursache — und sie reicht weit über n8n-mcp hinaus:** `~/.zprofile` sourct `exports.zsh` korrekt,
aber **`.zprofile` liest nur eine Login-Shell**. Claude Desktop und opencode starten **gar keine
Shell**, sie erben die launchd-Umgebung. Damit lösen **alle `{env:VAR}`-Referenzen zu LEER auf** —
live nachgewiesen: in **keinem** der `Claude Helper`-, `claude`- und opencode-MCP-Prozesse war
`N8N_MCP_BEARER` gesetzt, während dieselbe Variable in einer Login-Shell mit `len=64` vorhanden ist.
🔴 **Das erklärt rückwirkend die Klartext-Secrets in `~/.claude.json`** (4 Router-PW + Hostinger-Token):
dort steht Klartext, *weil* `{env:}` in einer GUI-App nicht funktioniert — vermutlich kein
Schlamperei-, sondern ein Notbehelf. **Die naheliegende „Hygiene"-Umstellung auf `{env:}` hätte
`mikromcp` und `hostinger` stillgelegt.** Erst jetzt ist sie gefahrlos → Voraussetzung für **TP30**.
- **Lösung:** `~/Library/LaunchAgents/net.emsenhuber.gui-env.plist` (RunAtLoad) ruft
`~/dev/.bin/gui-env-sync.sh`; das Skript liest `exports.zsh` **zur Laufzeit** und setzt die
Variablen per `launchctl setenv`. **Kein Secret-Duplikat** — die plist enthält keine Werte, die
SSoT bleibt `exports.zsh`, neue Variablen dort werden beim nächsten Login automatisch mitgenommen.
Abnahme: **22 von 22** gesetzt, `N8N_MCP_BEARER` mit dem Hash des Store-Werts.
- ⚠️ **Damit ist die Konvention „niemals `launchctl setenv`" zu präzisieren, nicht zu brechen:**
verboten war der *Handaufruf*, weil er in keiner Datei steht und keinen Reboot überlebt. plist +
Skript sind genau die Datei-Form davon. In Kauf genommen: `launchctl setenv` übergibt den Wert
als Argument (kurz in `ps` sichtbar) — es gibt keine stdin-Variante.
- 📌 **Nach jeder Änderung an `exports.zsh` gilt:** Skript erneut laufen lassen **und die App neu
starten** — ein laufender Prozess erbt die Umgebung nicht nachträglich.
- 🔜 **F5 — Secret-Verteilung auf Container-Hosts klären** (offen aus F1). `~/.secrets/credentials.env`
ist SSoT, liegt aber **nur auf dem MacBook**; auf dem macmini existiert **kein `~/.secrets`**, und
dort laufen die Container. Ein Symlink ist deshalb physisch unmöglich. Zu entscheiden: schlanke
Ableitung mit nur den nötigen Keys je Host, oder Vollspiegelung. Zusammen mit **TP30** lösen — dort
liegt der verwandte Posten: `~/.claude.json` hält **5 Secrets im Klartext** (4 Router-PW,
Hostinger-Token), wo die opencode-Configs an denselben Stellen `{env:…}` nutzen.
🔴 **Falle beim Auslesen des Stores:** die Zeilen tragen **Inline-Kommentare**
(`CF_MM_TUNNEL_ID=7a32… # bf-mm …`). Naives `cut -d=` liefert einen 80-Zeichen-Wert, die CF-API
antwortet dann `7003 Could not route`. Ein globales `set -a; source` setzt die Variablen gar nicht
(`len=0`, Grund ungeklärt). Richtig ist gezieltes Lesen **mit** `sed -E 's/[[:space:]]+#.*$//'`.
- 📌 **Ein Argument des TP ist entfallen:** die HA-Degradierung, die §7 als Nebeneffekt anführte, ist am
**selben Tag repariert** (TP16 ✅). Der Fall steht damit allein auf dem eigentlichen Argument —
**weniger bewegliche Teile für Dienste, die öffentlich nicht gebraucht werden.**
> 📌 Der Schnitt (`nautobot` aus dem `bf-nas`-Ingress) ist ausformuliert inkl. Vorbedingungen, PUT-Ablauf
> und Rollback. Zwei Punkte stehen dort explizit, weil sie den TP sonst kippen: **Mesh-Hostname-Routes
> sind Egress und veröffentlichen nichts** (wer das verwechselt, baut den Public-Strang ab), und
> **Nautobot ist nicht nur Web-UI, sondern die Deploy-Pipeline** → zur Abnahme gehört ein Job-Lauf,
> nicht nur ein Browser-Aufruf.
> **Christophs Frage (2026-08-17):** „recherchiere, ob unser neuer wg-tunnel/cf mesh als datenpfad für die
> server/services genutzt werden kann. die containerpflicht als offizieller weg ist für mich unlogisch."
- ✅ **Die Container-„Pflicht" gibt es für den privaten Zugang tatsächlich nicht — und wir nutzen sie schon nicht mehr.**
CF-Docs (Mesh↔Tunnel-Vergleichstabelle) sind eindeutig: **Mesh** = bidirektional, L3/L4, **TCP+UDP+ICMP**, jeder
Teilnehmer bekommt eine Mesh-IP, Connector = `warp-cli` **oder ein nativer WireGuard-Peer** (unser Fall).
**Tunnel** = *inbound zum Origin*, Veröffentlichen von Hostnames, Connector = `cloudflared`, Protokolle
HTTP/S·TCP·SSH·RDP·SMB **über WebSocket geproxied**. Für „Server hinter dem Node erreichbar machen, die keinen
Client laufen lassen können" nennen die Docs ausdrücklich **CIDR-Routes auf dem Mesh-Node** als den Weg —
wörtlich für „servers, databases, printers, IoT devices".
- 🎯 **Das ist bei uns seit TP16.1 bereits scharf:** die vnet-Route `10.40.0.0/16` zeigt auf `bf-core-mesh`
(§4.4, Weg A). **Der gesamte private Datenpfad läuft schon über natives WireGuard auf dem Core, ohne jeden
Container.** Und er ist im TP5-Log-Lauf **belegt**, nicht vermutet: 137 Zeilen `in:cf-mesh`, Quelle
`100.96.0.1` → Nautobot `.20.30:8080` (62×), NAS `.20.10:6690` (46×), macmini `:22`, NAS `:5000/:5001/:443/:22`,
pve `.20.100:8006`, pve-mcp `.20.173:3000`, branch/ap1/ap2 `:22`. **Christophs Instinkt war richtig.**
- 🔴 **Wo die Container-Pflicht echt ist — und warum sie nicht willkürlich ist:** für einen **öffentlichen
Hostname**, den jemand **ohne** WARP im Browser aufruft, braucht es einen Reverse-Proxy an der Edge. Das ist
`cloudflared`. **Achtung Verwechslungsfalle:** Mesh kennt seit 2026-07-02 ebenfalls **Hostname-Routes**, und
die können auch *public* hostnames — aber das ist **Egress**: „route that hostname's traffic through the node
and egress via the node's public IP". Es **veröffentlicht nichts**. Wer das verwechselt, baut den Public-Strang
ab und ist überrascht.
- ✅ **Der reale Gewinn ist trotzdem groß, weil die meisten Dienste keinen öffentlichen Hostname brauchen.**
Kriterium: **hat der Dienst einen Client, der keinen interaktiven Access-Login kann?** (dieselbe Logik wie der
TP16.2-Constraint bei `vw`)
| Muss öffentlich bleiben (cloudflared) | Grund | Kann auf Mesh-only |
|---|---|---|
| `vw` | Bitwarden-Clients, harter TP16.2-Constraint | `pve`, `nautobot`, `portainer`, `grafana` |
| `hass` | Companion-App (400-Muster) | `pve-mcp`, `ha-mcp`, `gitea-mcp`, `ds-bf` |
| `n8n` | eingehende Webhooks von Dritten | `ccu`, `iobroker`, `z2m25` |
| `gitea` | Git-Clients / CI von außen — **zu prüfen** | |
→ Für die rechte Spalte ist der Mesh **funktional gleichwertig und operativ einfacher** (keine Ingress-Zeile,
kein Connector-Paar, keine HA-Frage). Sie erreicht man ohnehin schon per privater IP.
- 📌 **Zwei Nebeneffekte, die den Fall stärken:** (1) die **TP16-HA-Degradierung** (`bf-nas`/`bf-pve` je nur ein
Connector) verliert an Gewicht, sobald nur noch die vier Public-Dienste an cloudflared hängen — weniger Dienste
pro SPOF; (2) **ICMP + beliebiges UDP** gehen über den Mesh und über cloudflared grundsätzlich nicht (der
QUIC-Proxy war genau die Lücke aus TP16.1).
- ⚠️ **Grenzen, ehrlich benannt:** (a) der Mesh-Weg setzt **WARP auf dem Client** voraus — für Christophs eigene
Geräte gegeben, für Gäste/Fremdgeräte nicht; (b) **IPv6-CIDR-Routes brauchen MASQUE** und funktionieren mit
einem WireGuard-Device-Profil **nicht** — deckt sich mit dem v4-only-Entscheid aus TP16.1, wäre aber ein
Blocker, falls TP12 später v6 über den Mesh wollte; (c) es bleibt eine **Cloudflare-Abhängigkeit** — der Mesh
ersetzt cloudflared, nicht Cloudflare.
- **Vorgeschlagener Umsetzungsschnitt (klein anfangen, reversibel):** einen Dienst der rechten Spalte als Probe
aus dem CF-Ingress nehmen (Kandidat: `nautobot` — nur Christoph nutzt es, im Mesh-Log mit 62 Treffern der am
häufigsten per Mesh angefasste Dienst, und ein Fehlschlag kostet nichts Produktives), eine Woche fahren, dann
entscheiden. **Rollback = eine Ingress-Zeile zurück.**
- 📋 **Runbook: `docs/tp16.3-nautobot-mesh-only-runbook.md`** (2026-08-19) — der Schnitt oben, ausführbar
ausformuliert: Vorbedingungen (Mesh-Route, Zugriff über `10.40.20.154:8080` **mit WARP von außerhalb**),
Ingress-`PUT` über `CF_API_TOKEN` (**nicht** über den CF-MCP, der ist read-only), Rollback aus der
gesicherten Config.
- 🔴 **Der Abnahmepunkt, den man übersieht:** Nautobot ist nicht nur Web-UI, sondern die
**Deploy-Pipeline**. Nach dem Schnitt gehört ein Job-Lauf zur Abnahme, nicht nur ein Browser-Aufruf
— der Netmiko-Deploy kommt von `10.40.20.155` (`nautobot-worker`), nicht von `.154` (Werte nach
TP45 Schicht A 2026-09-21; zum Messzeitpunkt `.31` / `.30`).
- ⚠️ Beim Kürzen des `ingress`-Arrays muss die **Catch-all-Regel die letzte bleiben**, sonst greift
der Ingress für alle übrigen Hostnames nicht mehr.
- **Abh.:** TP16.1 ✅ (Mesh live), TP16 (Public-Strang + dessen HA-Degradierung), **TP16.2** (der `vw`-Constraint
liefert das Entscheidungskriterium). ⚠️ **Berührt TP5:** der Mesh-Verkehr braucht bei deny-by-default eine
explizite `forward`-Zeile — s. TP5 Etappe 2.
#### 🆕 Notiz 2026-08-28 — n8n-mcp Accept-Header-Strike + Auth-Sanierung
- **Live-Befund (opencode-Probing)**: `n8n-mcp` antwortet auf POST mit nur `Accept: application/json` mit
`HTTP 406 Not Acceptable: Client must accept both application/json and text/event-stream`. Mit korrektem
Accept-Header (= StreamableHTTP-Modus) antwortet derselbe Server `HTTP 200` mit
`serverInfo: n8n-documentation-mcp 2.57.1`. ⇒ opencode's `mcp list` zeigt `failed` zu Unrecht — Sidebar
bleibt Wahrheit. Volle Diagnose im Session-Anker
[`docs/session-20260828-decisions.md`](session-20260828-decisions.md), Abschnitt
„MCP-Status-Check (Cold-Start-Race + Accept-Header-Strike)".
- **Konsequenz für TP16.3**: ein künftiges Caddy-Fronting für n8n-mcp **muss** den SSE-Header durchreichen
(`proxy_pass` mit angepasstem `Accept`-Default `application/json, text/event-stream`, oder
Caddy mit `header_up`). Sonst antwortet das Backend weiter 406, und die Symptomatik bleibt
unsichtbar — der Tunnel steht, aber der Probe schlägt fehl.
- **Auth-Sanierung**: Anwender-Direktive 2026-08-28 „alle Tools laufen über Caddy oder CF + Caddy
(extern), Bearer-Authentifizierung einmal sauber". Gehört thematisch in TP16.3, **nicht** als neuer
TP — siehe Quick-Index Zeile TP16.3. Bezug zu **TP35** Schicht 2: Reihenfolge ist TP16.3 → TP35.
TP35 wartet mit Schicht 2, bis das Fronting steht.
### TP17.1 · Gitea #11 — Shelly Gen4 raus aus dem WLAN — 📋 `docs/tp17.1-shelly-gen4-funkmigration-runbook.md`
> ❌ **User-Entscheid 2026-08-23 nach dem Pilot: stoppen und zurückbauen.** Auslöser war Christophs
> strukturelle Frage: *„zigbee sollte die antwort auf die vielen radios auf -iot geben. nutzt aber
> wenig wenn man das wifi nicht abdrehen kann bzw. es nicht mehr aktivieren kann ohne reset."*
>
> **Der Pilot hat den Weg technisch vollständig bewiesen** — `s9` lief auf `PowerStripZB`, trat ZHA
> bei, ein Zigbee-Befehl schaltete das physische Relais, kein Broadcast-Sturm. **Und er hat gezeigt,
> dass das Ziel so nicht erreichbar ist:**
> - Das Gerät **bleibt WLAN-Client** — Zigbee kommt *zusätzlich*, nicht *statt*.
> - Der Funkgewinn ist **gemessen klein**: die 12 Gen4 sind 12 von 26 IoT-Clients, bewegen aber je nur
> ~150–200 KB rx / 0,6–3 MB tx in sechs Stunden. Leerlaufende Stationen.
> - Der Preis wäre **dauerhaft**: die Zigbee-Firmware hat **keinen AP** (`WiFi.GetConfig` kennt nur
> `sta`/`sta1`/`roam`), Shelly macht **kein Zigbee-OTA**, und `Shelly.InstallAlt` braucht IP.
> WLAN aus hieße: nie wieder Firmware-Update, kein Rückweg — bei 10 Unterputz-Modulen.
>
> ✅ **Rückbau durchgeführt 2026-08-23:** ZHA-Knoten sauber entfernt (Leave **vor** dem Flash, damit
> kein Geisterrouter im 52-Geräte-Mesh bleibt), `s9` zurück auf `PowerStrip`, Config aus dem Backup
> wiederhergestellt. Compliance des Zigbee-Netzes unauffällig: alle vier roten EZSP-Zähler blieben
> **0**, Retry-Quote fiel im Delta auf 8,9 %.
> ⚠️ **Beim Rückflash zog die Firmware einen OFFENEN AP hoch** (`ap.enable=true, is_open=true`) —
> abgeschaltet und mit dem IoT-PSK abgesichert. **Nach jedem Shelly-Firmwarewechsel den AP prüfen.**
>
> 📌 **Das Runbook bleibt** — es enthält den kartierten Mechanismus (drei undokumentierte RPCs,
> Zwei-Slot-Modell), das gemessene 2,4-GHz-Bild und die ZHA-Sturmzähler mit Baseline.
> *Der Weg ist nicht gescheitert, das Ziel war mit ihm nicht erreichbar.*
> 🔭 **Einziger Restposten:** der **Frühindikator** — `Shelly.CheckForUpdate` auf einem Gen4 auf einen
> `…MT`/Thread-`alt`-Eintrag beobachten. Erscheint er, ändert sich die Rechnung.
<details><summary>Ursprünglicher Planungstext (historisch)</summary>
> **User-Formulierung (2026-08-23):** „wir lassen das pwd vorerst wie es ist. wir werden alle shelly
> gen4 auf thread umstellen — neue firmware. das machen wir im ha-projekt. das sehe ich als subpunkt
> zu pwd ändern. der vorteil: wir müssen dann weit weniger geräte umstellen, steigern
> thread-stabilität und bringen zudem mehr ruhe ins wifi."
>
> 📋 **Runbook: [`tp17.1-shelly-gen4-funkmigration-runbook.md`](tp17.1-shelly-gen4-funkmigration-runbook.md)**
> — Bestand, die drei Wege im Vergleich, Ablauf, Abnahme, Verzahnung mit der PSK-Rotation.
> 📌 **Wohnort ist das Netz-Projekt (User-Entscheid 2026-08-23).** Eine erste Fassung lag kurz im
> HA-Projekt (`ha-xstate`); sie ist dort **entfernt**, damit es keine zweite Wahrheit gibt. **HA ist
> Betroffener, nicht Eigentümer:** die Entities brechen, aber die Entscheidung ist eine Funk- und
> Netzentscheidung. *(Der Titel sagt jetzt „Thread **oder Zigbee**" — der ursprüngliche
> Thread-Zuschnitt hat den heute tatsächlich verfügbaren Weg ausgeschlossen, s. Nachtrag unten.)*
- 🔀 **Die Reihenfolge ist der eigentliche Entscheid:** die **PSK-Rotation** (Follow-up unten) wird
**nachgelagert**, nicht vorgezogen. Begründung des Users: jedes Gerät, das vorher auf Thread
wandert, muss beim PSK-Wechsel nicht mehr angefasst werden. Damit ist die Rotation **Nachfolger**
von TP17.1, nicht mehr eigenständig. *(Sie war nie dringend — Anlass war ein Passwort in einem
Sitzungstranskript, kein Vorfall.)*
- 📊 **Bestand live gesweept 2026-08-23** (`mcp/shelly/discover.py`, read-only, VLAN30): **22 von 26
erreichbar**, davon **12 Gen4** (9× 2PM Gen4 `S4SW-002P16EU`, 1× 1PM Gen4, 2× Plug S Gen4), 8 Gen3,
2 Gen2 — alle auf Firmware 2.0.0. Nicht erreichbar: s1/s2/s3 (weihnachten_*, saisonal) und **s4
`kueche_mikrowelle`** (Metallgehäuse, RSSI −93 — der Härtefall des WLAN-Betriebs).
- 🔴 **Die Prämisse „neue Firmware" trägt so nicht, und das ändert die Rechnung:** **Shelly
unterstützt Matter over Thread offiziell nicht** und plant es nicht. Gen4 kann offiziell nur
**Matter over WiFi** — was hier nutzlos wäre, weil das Gerät im WLAN bliebe und den PSK weiter
bräuchte. Thread geht nur über **Dritt-Firmware** (die ESP32-C6-Hardware kann es). Das brauchbare
Projekt (`hencou/shelly_gen4_matter_module`) deckt 1/1 Mini/1PM/2PM Gen4 ab = **10 der 12** Geräte;
die zwei **Plug S Gen4 fallen raus**. Preis: Garantie und Shelly-Cloud weg, Rückweg nur mit
**UART-Vollbackup je Gerät**, PM-Kalibrierung zunächst Platzhalter.
⇒ **Selbst bei vollem Erfolg blieben 12 Shellys im WLAN** (8 Gen3 + 2 Gen2 + 2 Plug S + die 4
ausgesteckten). Die Rotation wird **kleiner, nicht überflüssig** — und Gen3 kann nie folgen, dort
fehlt 802.15.4. **Das ist Christophs Abwägung vor dem ersten Flash, nicht die des Umsetzenden.**
- 📊 **Und die Rechnung ist noch etwas ungünstiger, als „Shellys" nahelegt** (live 2026-08-23):
auf `dirtyvader-iot` hängen **26 der 27 WLAN-Clients des ganzen Netzes** — darunter Drucker
(P750W), Airfryer, SwitchBot, Twinkly, DTUBI und ein SONOFF-NSPanelPro, die von Thread **nichts**
haben. Nach zehn migrierten Gen4 blieben also grob **16 Geräte** auf dem IoT-SSID, nicht 12.
Der WLAN-Ruhe-Effekt ist real (−38 % Clients), der PSK-Effekt ist es **halb**.
- ✅ **Was netzseitig schon steht und nicht nochmal gebaut werden muss:** Thread-Border-Router läuft
mit ~30 produktiven Matter-Knoten; der **Commissioning-Weg ist erprobt** — temporäre SSID
`dirtyvader-infra` (VLAN20) nach [`matter-pairing-infra-ssid-runbook.md`](matter-pairing-infra-ssid-runbook.md),
am 2026-08-22 mit dem TIMMERFLOTTE bewiesen. `cfg-infra`/`dp-infra`/`sec-infra` stehen dauerhaft am
Manager, das Anlernen ist ein Zweizeiler plus eine Config-Transaktion.
- 🔴 **Der Umweg bleibt nötig, weil TP12.1 offen ist** (kein IPv6-Routing VLAN10↔VLAN20 → Matter-
Commissioning-Etappe 2 bricht ab), und `ble_proxy` ist wirkungslos, seit der ESP32-BT-Proxy
ausgeschlossen wurde. **Je mehr Thread-Geräte kommen, desto öfter zahlt man diesen Umweg** — das
ist das stärkste neue Argument für TP12.1, das bisher nur an einem einzigen Fühler hing.
- **Abnahme (aus TP28 gelernt):** pro Gerät ein **frischer Thread-Knoten und eine funktionierende
HA-Entity** — nicht die Anzeige in einer Tabelle. Ein Gerät, das in Thread hängt und in HA halbtot
ist, ist ein Rückschritt.
- 🔬 **Nachtrag 2026-08-23 (abends) — die Geräte selbst befragt (`Shelly.CheckForUpdate`, read-only).
Ergebnis: es gibt heute KEIN Thread-Update, aber die offizielle Alt-Firmware-Schiene existiert — sie
liefert bisher ZIGBEE.**
| Was | Stand am Gerät |
|---|---|
| Stable | **2.0.0 auf allen 22** — für **keines** ein stabiles Update verfügbar |
| Beta | `2.0.1-beta1` (Build **2026-08-19**) für alle, auch Gen3 |
| **Alt-Varianten (nur Gen4!)** | `S2PMG4ZB` „Shelly 2 PM Gen4 **with Zigbee**" · `S1PMG4ZB` (1 PM Gen4) · `PowerStripZB` (Power Strip Gen4) — je stable 2.0.0 + beta |
| Gen3 (`S3PL-*`) | **kein `alt`-Eintrag** → Alternativfunk ist eine reine Gen4-Sache, wie erwartet |
- 🎯 **Das ist die wichtigste Erkenntnis für die Planung:** Shelly liefert alternative
**Funk**-Firmware für Gen4 **über den normalen Update-Kanal**, nicht über einen Bastelpfad. Käme
eine Thread/Matter-Variante, erschiene sie mit hoher Wahrscheinlichkeit **genau dort** als weiterer
`alt`-Eintrag. ⇒ **Billiger Frühindikator statt Warten auf Pressemeldungen:**
`Shelly.CheckForUpdate` auf einem Gen4 pollen und auf einen `…MT`/Thread-Eintrag warten.
- 📰 **Presselage (recherchiert 08-23):** Shellys offizielle Linie ist **„wird evaluiert"**, nicht
„ist angekündigt" — Stand Juli 2026, mit **Flash-Platz** als genanntem Engpass. Ein Release-Datum
gibt es nicht. *(Die Thread-Firmware, die im Mai 2026 als „für Gen4 verfügbar" durch die Presse
ging, ist die **Dritt**-Firmware von automatous-io, nicht Shelly.)*
- 💡 **Und daraus folgt eine Option, die vorher nicht auf dem Tisch lag: ZIGBEE statt Thread.** Sie
erfüllt **alle drei** Ziele von TP17.1 — Geräte raus aus dem WLAN (PSK-Rotation wird billiger),
Mesh-Stabilität, Funkruhe im 2,4-GHz-WLAN — und zwar **heute, mit Hersteller-Firmware über den
offiziellen Kanal**, also ohne Garantieverlust, ohne UART-Backup, ohne Test-VID. Die Infrastruktur
steht bereits: **Zigbee2MQTT läuft als LXC 112 (`10.40.20.106`, `z2m25`)**.
⚠️ **Zu prüfen, bevor das zur Empfehlung wird:** **(a)** ob die Alt-Firmware genauso zurückrollbar
ist wie ein normales Update (Wechsel des Funkstacks, nicht nur der Version) — **offen**;
**(b)** ~~der Entity-Bruch~~ ✅ **entschärft (User 2026-08-23): der Umbau behält die
Entity-Namen** — damit fällt der teuerste Posten der Abwägung weg, für Zigbee **und** Thread
gleichermaßen *(zwei Fallen dazu im Runbook: die alte `entity_id` ist erst nach dem Entfernen
frei, und `unique_id`/Statistiken sind trotzdem neu)*;
**(c)** Kanalplanung ✅ **erhoben 2026-08-23** — es gibt **drei** 802.15.4-Netze, nicht eines:
**Thread 15** (~30 Matter-Knoten), **ZHA 20** (**52 Geräte**, SONOFF Dongle-M über
`socket://10.40.30.137:6638`), **Z2M 25** (Sonoff Dongle Plus). Gegen WLAN **ap1 ch1 / ap2 ch11**
ist das sauber — **außer ZHA ch20 (2450), das direkt auf der Unterkante von WLAN ch11 (2451)
sitzt**, ausgerechnet 52 Geräte gegen den AP mit 26 Clients. Die Lücke 2423–2451 (ZB 16/17/18) ist
unbenutzt; ⚠️ ein ZHA-Kanalwechsel müsste von 52 Geräten mitgemacht werden, schlafende und ältere
Router folgen nicht zuverlässig → **erst Energy-Scan, dann entscheiden.**
🔴 **Und der Vorbehalt, der schwerer wiegt als alle drei: Christophs Z2M-Vorfall von 2024**
(„Lichterdisko", Installation unsteuerbar, Neuaufbau). Fehlerklasse ist benennbar
(Broadcast-/Routing-Sturm — trifft ZHA genauso), Root Cause nicht mehr rekonstruierbar.
⇒ **Mitigation ohne Ursachenkenntnis = Blast Radius trennen:** die zehn neuen Router komplett in
**ein** Zigbee-Netz, und zwar in das, das **nicht** Licht und Fernbedienungen trägt. Details und
Abbruchkriterium im Runbook.
- 🩺 **HA-Gegencheck 2026-08-23: genau EIN Shelly ist offline** — `s4 kueche_mikrowelle` (23 Entities
`unavailable`). Deckungsgleich mit dem Netz-Sweep, und es ist dasselbe Gerät, das bei **RSSI −93**
hinter Metall hängt. **s1/s2/s3 (`weihnachten_*`) sind in HA nur DEAKTIVIERT**, nicht gelöscht
(Christoph 2026-08-23) — deaktivierte Entities haben keinen Zustand und tauchen deshalb in keiner
Zustandssuche auf. Die Entities gibt es also zu Recht; sie kommen mit den Geräten zurück.
*(Meine erste Formulierung „existieren gar nicht" war falsch.)* ⇒ Für die
PSK-Rotation heißt das: **drei Geräte, die niemand vermisst, aber die beim nächsten Einstecken mit
dem alten PSK kommen.** Genau dafür ist die Übergangs-`access-list` da.
- 🖨️ **Es gibt ZWEI Drucker — geklärt 2026-08-23, beide Angaben waren richtig:**
**(1) P750W = Etikettendrucker, VLAN30** (`10.40.30.5`, Reservierung auf `dhcp-v30`, ARP `vlan30`;
am 08-23 aus, `last-seen 3d15h` — deshalb nicht unter den 26 assoziierten Clients).
**(2) Brother MFC = der eigentliche Drucker, VLAN10** — live bestätigt `10.40.10.6`, MAC
`30:05:5C:78:86:20` (OUI Brother Industries), ARP `delay` = aktiv, **statisch konfiguriert, keine
DHCP-Reservierung**.
📌 **Folge für TP5:** die Merkregel „Drucker fällt mit **udp/161 (SNMP)** aus, nicht mit 631"
(`ref_printer_snmp_before_ipp`) gilt dem **VLAN-übergreifenden** Fall. Der MFC steht im **selben
VLAN** wie die Macs — dort greift die `forward`-Kette gar nicht.
- 🔀 **Reihenfolge revidiert (User 2026-08-23): PSK-Rotation und Thread-Umstellung laufen PARALLEL.**
Sie blockieren einander nicht — der Passphrase-Override je MAC (`/interface wifi access-list`,
s. Follow-up) macht den Flag-Day überflüssig: neuer PSK aufs Profil, die noch nicht migrierten
Geräte übergangsweise per MAC mit dem alten weiter. **Einzige Regel, damit die Parallelität nichts
kostet:** ein Gerät, das ohnehin auf Thread/Zigbee wandert, bekommt den neuen PSK **gar nicht** —
es kommt auf die Override-Liste und verschwindet später ganz. *(Die frühere Formulierung „blockt
die Rotation" war zu streng; die Reihenfolge war nie technisch nötig, nur ökonomisch.)*
- **Abh.:** keine harten fürs Netz. **Läuft parallel zur PSK-Rotation** (User-Entscheid 08-23,
vorher als Blocker geführt). Verwandt: **TP12.1**
(IPv6/Matter — profitiert direkt), **TP25** (jedes Gerät weniger im 2,4-GHz-Band ist Funkruhe),
**TP17** im [Archiv](teilprojekte-archiv.md#tp17) (Migrationshistorie + `mcp/shelly/`-Werkzeug).
</details>
### TP17.3 · Gitea #12 — Shelly-Regression nach der VLAN30-Migration — offen nur noch der Flotten-Entscheid (gehört TP17.1) — aus TP17 herausgelöst, User-Meldung
> **User-Meldung 2026-08-23:** „vor einiger zeit hast du an den shellys änderungen vorgenommen. das
> hatte bei einigen einen bösen seiteneffekt: **lüfter-shelly musste ich doppelt drücken**. der übelste
> ist jedoch der von **diele ug: seit deinen änderungen spinnt er herum und blinkt wie ein wilder.
> sobald AP on ist, ist die symptomatik weg.** ich muss vermutlich eh alle shellys neu machen."
- 🔴 **Erste Konsequenz, sofort: der aktivierte AP auf `s23` ist ein WORKAROUND, kein Rest.** Ich hatte
ihn beim WLAN-Scan am selben Tag als „übrig gebliebenen Anker aus TP17, gehört abgeschaltet"
eingeordnet — **das war falsch**, und es hätte Christophs Notbehelf zerstört. Er ist bewusst an.
**Nicht abschalten, bevor die Ursache behoben ist.** *(Merkregel: ein Zustand, der nach einem
Verfahren „übrig" aussieht, kann eine Reparatur sein. Vor dem Aufräumen fragen, wozu er da ist.)*
✅ **Aufgelöst 2026-08-23:** die Ursache ist behoben, der AP auf `s23` ist **abgeschaltet** und wird
nicht mehr gebraucht. Der Workaround hat exakt das getan, was die Hypothese vorhersagte.
- **Symptome und Geräte:**
| Gerät | Modell | Symptom |
|---|---|---|
| **s23** `dieleug-wohnzimmer_licht` `.203` | **SPDM-002PE01EU**, app `ProDimmerx` = **Phasenanschnitt-Dimmer** (Diele UG/Licht + Wohnzimmer/Seitenlicht) | „blinkt wie ein wilder", **AP an ⇒ weg** |
| **s26** `bad_luefter` `.233` | S4SW-002P16EU (Gen4), `switch:0 in_mode=momentary` | muss **doppelt gedrückt** werden |
#### Was die Migration am Gerät wirklich verändert hat (TP17, 2026-07-13)
Vier Dinge, nicht nur die WLAN-Config: **(1)** STA-SSID/PSK · **(2)** **statische IP** statt DHCP
(`ipv4mode=static`, gw/dns `10.40.30.1` — an allen 22 bestätigt) · **(3)** **`eco_mode`** nach einer
RSSI-Politik · **(4)** AP-Anker an/aus.
#### 🎯 ✅ BESTÄTIGTE URSACHE: **`eco_mode`**, nicht die WLAN-Config
- `eco_mode` **senkt den CPU-Takt und die WLAN-Sendeleistung** — so steht es in TP17 selbst.
- Ein **Phasenanschnitt-Dimmer** braucht präzises **Nulldurchgangs-Timing**. Ein gedrosselter Takt
erzeugt Jitter in genau diesem Timing ⇒ **Flackern**. Das ist s23.
- **Warum „AP an" heilt:** ein aktiver Access Point muss beaconen, das Funkteil darf nicht in die
Sparzustände. Der AP hebt also faktisch den Energiesparbetrieb auf — **er behandelt das Symptom
genau dort, wo die Hypothese die Ursache vermutet.** Diese Korrelation ist das stärkste Indiz.
- **s26:** `momentary`-Eingang + gedrosselter Takt ⇒ die erste Flanke wird verschluckt ⇒ „doppelt drücken".
- ✅ **Gegenprobe im Bestand:** **`s12 dieleug_steckdosen`** hängt am selben Ort, ist dasselbe
Gen4-Modell wie s26 — und hat **`eco_mode=false`**. Es ist unauffällig. *(Kein Beweis, aber die
Hypothese überlebt ihn.)*
- ✅ **Die Alternativerklärung ist damit erledigt:** der Verdacht auf gerätespezifische Dimmer-Themen
(Leading/Trailing Edge, Kalibrierung, Mindestlast/LED-Treiber) hat sich nicht bestätigt — `eco_mode`
allein genügte. *Nebenbei: die Config dieses Geräts kennt für Phasenan-/-abschnitt ohnehin **keinen**
Parameter; Stellschrauben wären nur `warmup`, `range_map`, `gamma`, `min_brightness_on_toggle`
gewesen.* Der Link war mit −54 dBm die ganze Zeit sehr gut, ein reines Funkproblem war nie plausibel.
#### Der diskriminierende Test (klein, reversibel, ein Gerät) — ▶️ AUSGEFÜHRT 2026-08-23
1. ✅ `s23`: `Sys.SetConfig {"config":{"device":{"eco_mode":false}}}` — **AP zunächst AN gelassen**.
`cfg_rev` 91 → 92, `eco_mode=false` gelesen.
2. ✅ Christoph beobachtet: **„licht in diele ug ist jetzt flackerfrei"**.
⚠️ **Das ist noch kein Nachweis** — mit AP an war es vorher schon flackerfrei. Beide Hypothesen
sagen an dieser Stelle dasselbe voraus. *(Merkregel [[feedback_abnahme_wirkung_nicht_anzeige]].)*
3. ✅ **AP aus** (`WiFi.SetConfig ap.enable=false`) — damit war der Test scharf: `eco_mode=false`,
`ap.enable=false`, STA `got ip 10.40.30.203` bei −54 dBm.
4. 🎯 **ERGEBNIS — Christoph, 2026-08-23: „beide lampenausgänge getestet. funktioniert perfekt, kein
flackern."** Genau die Konstellation, die vorher geflackert hat, ist ohne AP stabil.
⇒ **`eco_mode` war die Ursache. Hypothese bestätigt, `s23` repariert.**
5. ✅ `s26 bad_luefter` analog: `eco_mode=false` gesetzt (`cfg_rev` 18 → 19) **+ Reboot** (s.u.).
🎯 **ERGEBNIS — Christoph: „lüfter perfekt."** Der Doppeldruck ist weg.
⇒ **Beide gemeldeten Geräte repariert, beide Symptome durch dieselbe Ursache erklärt.**
🔴 **Korrektur einer Annahme aus dieser Session:** ich hatte gewarnt, `eco_mode` brauche einen
**Neustart**, um zu wirken, und der Test sei deshalb unvollständig. **Falsch** — das Gerät antwortet auf
`Sys.SetConfig` selbst mit `{"restart_required": false}`. Der Test war richtig formuliert, ein Reboot
des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** ausgeführt.
*(Und daraus folgt der wichtigere Schluss: `eco_mode` kann das `restart_required` der 14 Geräte unten
**nicht** verursacht haben.)*
#### Weitere Funde aus dem Sweep vom 2026-08-23 (read-only, alle 22 Geräte)
- 🔴 **Die eco-Politik ist damit nicht nur stale, sondern WIDERLEGT.** Regel von 2026-07-13 war
„eco **an** bei RSSI ≥ −80, **aus** darunter" — sie hat an `s23` **realen Alltagsschaden** erzeugt und
war nie konsistent umgesetzt. Ist-Stand des Sweeps 2026-08-23 (22 Geräte, live):
**eco AUS:** s7 (−90), s12 (−64), s17 (−76), s18 (−81), s19 (−88) · **eco AN:** alle übrigen 16,
darunter s20 (−85) und s21 (−85) — die RSSI-Regel ist an keiner Kante mehr erkennbar. Die Werte haben
sich seit Juli durch TP25/TP28 ohnehin verschoben. Die in TP17 vorgemerkte „2. Evaluation nach TP3.3"
hat nie stattgefunden.
⇒ ✅ **ENTSCHIEDEN UND AUSGEFÜHRT 2026-08-23. User: „eco-mode für alle deaktivieren. der rotz macht
nur probleme."** 13 Geräte umgestellt (s5, s6, s8, s10, s11, s13, s14, s15, s16, s20, s21, s22, s25),
**null Fehler**; 9 standen bereits auf `false`. **Stand jetzt: `eco_mode=false` auf allen 22
erreichbaren Shellys**, einzige Ausnahme `s24` (SNDC-0D4P10WW meldet `eco_mode: None` — nicht
unterstützt). Offen nur s1/s2/s3 (`weihnachten_*`, saisonal stromlos).
🔧 **Werkzeug:** `mcp/shelly/migrate.py eco <range> [--go|--on]` — neu, über denselben `_rpc`-Pfad,
mit Retry je Gerät (ein Timeout an einem schwachen Link darf den Sweep nicht abreißen) und einer
Fehlerliste am Ende.
🔬 **Und die Gegenmessung widerlegt meine eigene Warnung.** Ich hatte hier notiert, eco aus hebe die
**Sendeleistung** an und sei deshalb eine Airtime-Änderung, die TP25 berühre. **Gemessen stimmt das
nicht:** Signal der 13 geänderten Geräte in der Registration-Table des Managers, vorher/nachher —
**Mittel −0,9 dB, Median −1 dB, Spanne −3..+3**, bei einer **Kontrollgruppe von 13 unveränderten
Geräten mit +0,0 dB und derselben Streuung**. Das ist Rauschen, kein Effekt.
⇒ **Die Aussage aus TP17, `eco_mode` senke „den CPU-Takt UND die WLAN-Sendeleistung", stimmt nur zur
Hälfte.** Der Takt-Teil ist durch das Dimmer-Flackern bewiesen; der Sendeleistungs-Teil ist an diesen
Geräten nicht messbar. **Kein Funkbild-Argument mehr für oder gegen eco.**
- ✅ **AP-Anker-Sweep erledigt (der offene read-only-Punkt aus der Übergabe):** `WiFi.GetConfig →
ap.enable` über alle 22 Geräte. **Nur `s23` hatte einen AP an** (= Christophs Workaround, inzwischen
abgeschaltet), die übrigen 21 stehen auf `false`. **Kein Aufräumbedarf, kein weiterer Anker im Bestand.**
- 🔵 **Nebenbefund, gehört nicht hierher, aber nicht verlieren:** alle Shellys stehen auf
`sys.sntp.server = time.cloudflare.com`. Kanonisch ist seit 2026-08-18 **der Core als Zeitquelle**,
ausgeliefert per **DHCP-Option 42** — die Shellys haben aber aus TP17 **statische IPs** und sehen die
Option nie. Funktioniert (Uhrzeit stimmt, udp/123 aus VLAN30 geht raus), ist aber ein blinder Fleck in
der NTP-Vereinheitlichung. **Gleiche Klasse für jedes statisch adressierte Gerät im Netz.**
- 🔵 **`inventory/bf.csv` ist bei `s12` stale:** dort steht noch `10.40.41.219`, live ist das Gerät auf
`10.40.30.219` (migriert). Die drei `weihnachten_*` (s1–s3) sind saisonal stromlos, `s4
kueche_mikrowelle` offline — das erklärt 26 Einträge vs. 22 erreichbare.
- 🟠 **`restart_required = true` — nicht auf 2 Geräten, sondern auf 14 von 22** (Sweep 2026-08-23,
Folge-Session): s5, s6, s7, s10, s12, s13, s16, s17, s18, s19, s20, s21, s24, s26. Uptimes 22–41 Tage.
✅ **Entwarnung, empirisch:** `s26` wurde kontrolliert neu gestartet und die Config **davor/danach
verglichen** — sie ist **identisch**. Der Flag stand für **keine** abweichende Konfiguration; er ist
ein *sticky* Bit, das nur nie zurückgesetzt wurde. **Die Geräte sitzen nicht auf versteckten
Änderungen.** Reboots können damit gefahrlos gebündelt mit TP17.1 laufen.
- 🔍 **Was `restart_required` gesetzt hat, ist am Gerät NICHT rekonstruierbar** — drei Wege zu Ende
gegangen: (a) **169 RPC-Methoden** auf `s26`, keine gibt Auskunft über Pending-Zustand; (b) der
**Config-Diff über die Gruppengrenze trägt nicht** — die Gen3-Familie `S3PL-00112EU` hat *identische*
Configs, davon s6/s7/s10 flagged und s8/s11/s14/s15 sauber; (c) **kein persistentes Gerätelog**
(`sys.debug.file_log.enable=false`, `/debug/log` ist ein Live-Stream ohne Ring-Puffer).
Ausgeschlossen sind immerhin `matter.enable`, `ble.rpc.enable`, `cloud`, `mqtt`, `ws` (trennen die
Gruppen alle nicht) — und **`eco_mode`** (s.o.). *Nicht erneut untersuchen; der Aufwand ist gedeckelt
und das Ergebnis negativ.*
#### Scope / Abgrenzung
- **Gehört hierher:** alles, was die Migration am **Gerät** verstellt hat (eco_mode, statische IP,
AP-Anker) und was daraus an Fehlverhalten folgt.
- **Gehört nicht hierher:** die Funkumstellung selbst (**TP17.1**, verworfen) und das Geräte-Passwort
(seit 2026-08-24 **TP30 Posten #3**, vormals TP17.2).
- 🔗 **Aber die Verzahnung mit TP17.1 ist der Hebel:** wenn ohnehin **alle** Shellys angefasst werden,
ist das der Moment, `eco_mode` **grundsätzlich** zu entscheiden statt gerätweise zu pflegen.
Christophs „ich muss vermutlich eh alle shellys neu machen" ist damit nicht Resignation, sondern der
effiziente Weg — nur eben **nach** dem Test, nicht davor.
- **Governance:** Shellys laufen **nicht** über Nautobot GC (kein Netzgerät im GC-Sinn). Änderungen
über `mcp/shelly/` bzw. direkte RPC. ⚠️ `migrate.py` hat **kein** Kommando für `eco_mode` — der
Sweep-Weg wäre eine kleine Ergänzung über denselben Pfad.
- **Abh.:** keine. **Blockt nichts.** Der Alltagsschaden ist seit 2026-08-23 behoben.
#### 📌 Was noch offen ist (Stand 2026-08-23, Folge-Session)
1. ✅ Tastentest `s26` — **erledigt, Doppeldruck weg.**
2. **Flotten-Entscheid `eco_mode` aus** für die übrigen ~20 Geräte — **gebündelt mit TP17.1** wegen der
Funkbild-/Airtime-Wirkung (s.o.). Gehört Christoph.
3. **`migrate.py` um ein `eco`-Kommando ergänzen**, wenn 2. beschlossen wird — heute gibt es dafür
keinen Pfad im Werkzeug, der Sweep lief ad hoc.
4. Reboots der verbleibenden 13 `restart_required`-Geräte — **gefahrlos** (empirisch an `s26` belegt),
deshalb kein eigener Termin, sondern Mitnahme bei TP17.1.
### TP18 · Gitea #13 — Souveräner Objektspeicher (Garage, Multi-Site, S3-Cold-Tier)
- **Auslöser:** 9-TB-Syno-Backup-Volume **zu 2/3 voll**; reine Daten ~3 TB, Tendenz steigend, „schlagartiger" Bedarf nicht ausgeschlossen. „Wenn skalieren, dann jetzt Syno-Nachfolger mitdenken."
- **Kriterien (User-priorisiert):** (1) **keine Fremd-Abhängigkeit / kein Cloud-Lock-in** (Cloud-Opex abschreckend); (2) **Multi-Site BF/FM/DW**; (3) **HA** (Standort-Verlust überlebbar); (4) **billig/teuer trennen** — Variante **„kalt = Objektspeicher, heiß = lokal"** genügt (kein Hot/Cold *innerhalb* des Stores nötig); (5) **horizontal + vertikal skalierbar**; (6) **S3-API als Branchenstandard**, den jedes Backup-Tool spricht.
- **Entscheid Engine = Garage** (Deuxfleurs, Rust): einziger Kandidat, der **geo-verteilt über offenes Internet/WAN latenztolerant** ist (CRDT-Metadaten, eventually consistent) → passt aufs 3-Keller-Modell BF/FM/DW. **Ceph verworfen** (will Sub-ms-LAN, Single-Cluster-über-WAN = Anti-Pattern; zu schwer, ~1 GB RAM/TB). **MinIO** (AGPL + Console/Feature-Abbau 2025) und **SeaweedFS** (kann Hot/Cold + Erasure-Coding, aber komplexer, Multi-Site async) = **Fallback**, falls später feingranulares Tiering / EC-Effizienz bei zweistelligen TB dominant wird.
- **Entscheid Disk-Layout = JBOD, KEIN RAID.** Redundanz gehört in **eine** Schicht: Garage RF=3 über 3 Sites *ist* die Redundanz. RAID 1 darunter = Doppelt-Bezahlen (3 Sites × 2 Mirror = **6× roh für 1× nutzbar**, absurd). RAID 0 verworfen (1 Platte tot = ganzer Node-Rebuild über WAN). **JBOD = jede Platte eigenes Garage-Data-Dir** (v1 Multi-HDD): Failure-Domain = 1 Platte, Rebuild nur deren Anteil, kein Kapazitätsverlust. **Merksatz: distributed store = „just a bunch of disks", Redundanz ausschließlich über die Sites.**
- **Was man dafür bezahlt:** Plattentod triggert **Re-Replikation über WAN** (durch Upload-BW gedeckelt). Bei ~3 TB/Site tolerabel; wenn Bedarf „schlagartig" auf zweistellige TB springt → mehr/kleinere Platten (Blast-Radius) oder 4. Node, ggf. Wechsel auf SeaweedFS-EC.
- **Dimensionierung (Planungsanker 10 TB nutzbar; heute 3 TB unique / 6 TB Backup-used):** RF=3 → **30 TB roh gesamt = 1 volle Kopie ~10 TB/Site** (provisionieren ~16 TB roh/Site: 2 Platten = halber Blast-Radius + Headroom). Linear skalierend.
- **Kostenmodell** *(Annahmen: HDD neu CMR ~€18/TB · recert ~€10/TB · Strom €0,25/kWh · Lean-Node Mini-PC/pve 12 W + 2 HDD ~20–28 W)*:
- **Capex kumulativ (3 Sites):** Platten €540 (recert) – €900 (neu) + Nodes €400 (BF=pve €0; FM/DW je Mini-PC ~€200) = **~€600–1.300**. Vergleich: *ein* resilienter Syno-Nachfolger für 10 TB = €1.000–1.600 capex **ohne** Geo-HA → Garage-3-Node gleich teuer/billiger **mit** HA.
- **Opex kumulativ:** ~€135–185/J Strom — aber **getrennt gerechnet:** FM/DW laufen über **fremde Zähler**, auf Christophs Zähler landet nur der **BF-Node ≈ €50–60/J** (Gegenleistung: er hostet deren Kopie, reziprok wie TP16).
- **JBOD-vs-RAID1-Ersparnis:** Strom bloß **€9–22/J** (Christophs Zähler) → *kein* Entscheidungsgrund; der Hebel ist **Capex ~€900** (nicht gekaufte Spiegelplatten) + architektonische Korrektheit. (Reicht nicht für ein BW-Upgrade €120–240/J, taugt aber als Seed für HW-Rückstellungen.)
- **Konzeptioneller Bruch (Syno-Nachfolger):** Kein *großes resilientes NAS* mehr nötig — die kollektive 3-Site-Redundanz *ist* die Garantie. **Syno-Rolle splittet:** Hot/SMB/VM-Disks bleiben lokal (Syno **oder** ZFS auf pve, klein/schnell); **Backup/Cold wandert in Garage über BF/FM/DW** → das 9-TB-Volume wird **aufgelöst** statt ersetzt (aus 1 SPOF werden 3 geo-redundante Kopien). Backup-Tool (restic/Kopia) sprechen S3 nativ.
- **Impacts auf Bestand (Kurz):** (a) **Storage-Redundanz-Philosophie kippt** — weg von SHR/RAID pro NAS, hin zu 3× JBOD; (b) **Backup-Kette** Hyper-Backup/SMB → S3-Bucket umstellen; (c) **Container-Host-System** (TP14) kriegt Garage als LXC/Container je Site, aber auf **rohen Platten**, nicht auf bestehenden SHR-Volumes; (d) **Upload-BW** wird Durchsatz-/Rebuild-Limit; (e) **HW-Planung** verzahnt mit TP15 (Node-HW, pve-Standort) und **TP24** (Stromversorgung je Site), FM/DW brauchen je 1 Node.
- **Startpfad (inkrementell/reversibel):** ① BF zuerst — Garage-Container auf pve, 2 HDDs JBOD, Backups lokal auf Bucket (sofort Nutzen, noch keine Redundanz). ② FM/DW nachziehen (Mini-PC + 2 Platten), RF→3, ab jetzt geo-redundant+HA. ③ Syno entlasten (Backup-Last abgeben).
- **Offen (Modell scharfstellen):** realer Strompreis; ob **FM/DW eigene Nodes stellen** oder Christoph HW hinstellt; Backup-Tool-Wahl (restic vs Kopia); ob heißes Zeug künftig auf ZFS/pve statt Syno.
- Abh.: **TP10** (WG-Multi-Site = Transport, Upload-BW-Constraint), **TP14** (Container-Hosts), **TP15** (Node-HW) / **TP24** (Stromversorgung), Muster von **TP16** (reziprokes Per-Host-HA). Kein Blocker für laufende TPs. SSoT dieser Session.
### TP20.1 · Gitea #14 — Vaultwarden → Platform-Postgres + Cross-Site-HA — ausgelagert aus TP20)
- **Ziel:** `vw` (`.20.127`, heute MariaDB) auf den Precious-Tier holen **und** das eigentliche User-Ziel erfüllen: **DB-Replikation über 2 Sites** (Standort-Verlust überlebbar).
- **Phase 1 — Migration (single-site):** vw **MariaDB→Postgres** (Export → Import in Tier-1-DB `vaultwarden` → `DATABASE_URL` umstellen → verifizieren). Danach ist vw precious + im pgBackRest/PITR-Schirm von TP20. *(Alternative verworfen: vw auf MariaDB lassen + MariaDB-Repl — zugunsten EINER Replikations-/Backup-Mechanik im Precious-Tier.)*
- **Phase 2 — Cross-Site-Standby:** `db-platform-standby`-LXC am 2. Standort → `pg_basebackup`/Streaming-Replication über **WG (TP10)** → **manuelles Promote-Runbook**. Repliziert den ganzen Tier-1-Cluster (vw = HA-Ziel, Nautobot bekommt Gratis-DR-Kopie). **Patroni + 3. Witness** nur, falls später Auto-Failover statt manuellem Promote gewünscht.
- **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**.
- Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21.
### TP23 · Gitea #15 — DNS-Policy & Self-Service-Ausnahmen
> 🔴 **Redesign 2026-09-12 — Stufe 5 reopened.** Bei „Stufe 6 angehen" fiel auf: die 09-04-Stufe-5
> (single-domain, temp-only 15m/1h/4h, **eine** Ausnahme gleichzeitig, **HA-Neustart bei neuer
> Domain**, `allow-domain` global statt pro Person) deckt die Nutzer-Requirements **nicht** ab
> („funktional ein kastriertes Pi-hole", User). Die harte Reihenfolge-Regel „erst der Ersatz, dann
> die Sperre" ist damit real **nicht** erfüllt → **Stufe 6 darf nicht laufen**, bis der neue
> Self-Service nachweislich trägt.
> - **Neue Requirements (User 2026-09-12):** (1) **pro Person** (Login), Sicht über alle Geräte;
> (2) **„zuletzt geblockt" → 1 Klick**; (3) temporär **und** dauerhaft, Modus umschaltbar
> (self-permanent ⇄ Anfrage, Start self-permanent); (4) **scoped HA-Dashboard** je Person —
> nichts von anderen/Admin/Power-User, AGH-Web-UI bleibt reines Admin-Werkzeug.
> - **Design/Bau:** Personen-Client je AGH (SSoT Person→Gerät), Ausnahmen via `$client=`-Modifier,
> `agh-exception.py` um scoped `allow/unallow` + `list-exceptions` + `recent-blocked` erweitert
> (reine Logik netzunabhängig getestet, `selftest` ✅). HA-Dashboard/Sensoren/Modus-Switch als
> Bau-Spec in [`adguard/ha-self-service.md`](../adguard/ha-self-service.md) (on-network zu bauen).
> - 🔴 **Load-bearing, on-network zu prüfen:** wirkt AGH `$client=` auf **Allow**-Regeln (`@@`)?
> Fallback = IP-Liste statt Client-Name (Code kann beides). Plan:
> `~/.claude/plans/tp23-stufe-6-angehen-soft-sunrise.md`.
> - **Neue echte Stufe-6-Vorbedingung:** End-to-End-Abnahme mit echtem Nicht-Admin-Flow.
> - **Stufe-6-Runbook-Korrektur:** Core `10.40.X.1` in der Allowlist überdenken (Client-Gateway →
> DoH direkt zu CF = interner Bypass der AGH-Personen-Policy).
> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt**
> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block):
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
> Instanzen; Policy-Matrix (INFRA/MEDIA/MGMT `filtering=aus`, GUEST safe-search+parental an). Neuer
> AGH-API-User `automation` (PW in `~/.secrets` → TP30). Sync-Werkzeug `adguard/agh-sync.py`
> (`check`/`push`), Repo-SSoT `adguard/AdGuardHome.yaml` mit **redigierten** Hashes. Invariante `.36==.37`.
> - **Stufe 5 (HA-Self-Service):** `agh-exception.py` (RMW-CLI, `allow-domain`/`unallow`/`unfilter`/`refilter`)
> auf HAOS deployt; `shell_command` + 5 Helper + Timer + 2 Automations (`ha-mcp`). Button → Domain für
> 15 min/1 h/4 h entsperren, nativer `timer` revertiert; RouterOS-`jit-dns-bypass`-Backstop erst mit Stufe 6.
> Doku: `adguard/README.md` + `adguard/ha-self-service.md`. End-to-End getestet (Start + Ablauf).
> - **Offen:** nur **Stufe 6** (DNS-Bypass-Sperre) — bewusst NICHT geschaltet. Runbook für eine
> **separate Session**: [`docs/tp23-stufe6-bypass-sperre-runbook.md`](tp23-stufe6-bypass-sperre-runbook.md).
> 🔴🔴 **HARTES VOR-ORT-GATE:** macht AGH `.36/.37` zum harten DNS-SPOF, aus der Ferne nicht
> reparierbar → **NUR vor Ort, NICHT während Christoph abwesend ist** (2026-09-06: ~1 Woche weg →
> in dem Fenster gar nicht anfangen). Methodik: log-only-Vorstufe zuerst, Drop mit Auto-Revert zuletzt.
> - **WARP-Managed-Network (Stufe 2 im alten Plan)** bleibt offen (TP41-nah). Der `.16`-basierte
> Abschnitt darunter ist die historische Fassung (2026-08-18), bewusst erhalten.
**[Historische Fassung 2026-08-18 — `.16`-basiert, durch den Block oben überholt:]**
- ✅ **Stufe 1 — DoH auf dem Core scharf** (`routeros/tp23-stufe1-doh-core.rsc`), schliesst zugleich
**TP21.1 TODO 1**. `use-doh-server=https://px35ynlol3.cloudflare-gateway.com/dns-query`,
**`verify-doh-cert=yes`**. `servers=1.1.1.1` bleibt als Bootstrap fuer den DoH-Hostnamen stehen.
- 🔑 **Die alte Begruendung „core hat kein CA-Bundle, verify also unmoeglich" war zu pessimistisch.**
Es braucht kein Bundle, sondern **genau einen Root**: die Kette ist
`Leaf → GTS WE1 → GTS Root R4`, importiert wurde nur **R4** (~1 KB, gueltig bis 2036) — bei
**2,8 MB freiem Flash** ist das der Unterschied zwischen geht und geht nicht.
Fingerprint lokal und am Geraet gegengeprueft (`349dfa40…3c7d`).
- 📌 Der Zertifikatsimport steht bewusst **nicht** im GC-Template: er braucht die Datei am Geraet
und ist einmaliger Bootstrap, kein Sollzustand. Im Template steht nur die `/ip dns set`-Zeile.
- ⚠️ **Der Deploy meldete FAILURE, obwohl alles sass.** `/certificate import` gibt `failed: 0` aus,
und der Job scannt den Geraete-Output stumpf nach dem Wort. Wieder ein Beleg fuer die
TP22-Regel: **Job-Status ist kein Wirksamkeitsnachweis**, nur die Gegenpruefung am Geraet zaehlt.
- ⏳ **Stufe 2 — AdGuard eingerichtet, aber bewusst NICHT scharf geschaltet** (User-Vorgabe 2026-08-18).
`adguard_bf1` (`10.40.20.16`) macht bereits DoH zum selben CF-Gateway. Ergaenzt wurden:
conditional upstreams **`[/bf.lan/]10.40.20.1`** und **`[/40.10.in-addr.arpa/]10.40.20.1`**
(ohne die zweite Zeile wuerden interne Reverse-Lookups zu Cloudflare abfliessen), sechs
**CIDR-Client-Gruppen** (ein VLAN = eine Gruppe) und die permanente Ausnahmegruppe
**`dns-bypass-exempt`** (aktuell `10.40.10.183`). Abnahme per `dig`: `core.bf.lan` → `10.40.99.1`,
`nas.bf.lan` → `10.40.20.10`, extern ueber DoH. Backup `AdGuardHome.yaml.bak-tp23-20260817`.
- **Kein DHCP-Scope wurde angefasst** — alle sieben liefern weiter `10.40.X.1` aus. Das
Scharfschalten ist damit spaeter ein reiner DHCP-Change; die passende Firewall-Zeile
(`TP5-E2: LAN->AGH dns (Vorrat)`) steht bereits, damit dabei die scharfe Firewall nicht
angefasst werden muss.
- ⚠️ **AGH schreibt die YAML beim Start neu** (eigene Schluesselreihenfolge, Kommentare weg,
`time_zone: Local` → `UTC`). Wer die Datei patcht, muss danach am **laufenden** Stand pruefen,
nicht am eigenen Entwurf.
- 🔜 **Offen bleiben Stufe 3–6** (zweite AGH-Instanz, per-Lease Option 6, HA-Buttons, und zuletzt die
Bypass-Sperre). **Ausdruecklich keine HA/zweite Instanz** (User 2026-08-18).
- **Auslöser:** sobald gefiltertes DNS scharf ist, gehen einzelne Seiten/Dienste kaputt. Es braucht **Ausnahmen ohne Admin-Eingriff** — per User/Client/Gruppe/global, temporär oder permanent, mit Oberfläche. **Bedarf ist belegt, nicht vermutet:** der bisherige Workaround war „Client von Hand auf `1.1.1.1` stellen", regelmäßig (User 2026-08-14).
- 🔴 **Zentrale Reihenfolge-Regel: genau dieser Workaround IST der DNS-Bypass, den TP5 schließen will → erst der Ersatz, dann die Sperre.** Ohne funktionierende Ausnahme-Oberfläche verliert u.a. der Entwicklungsrechner samt Agenten-Toolchain seinen Ausweg — und DNS-Blocks äußern sich dort nicht als klare Meldung, sondern als diffus kaputte Build-Umgebung.
- **Zwei Befunde, die die Architektur bestimmen:**
1. **DoH ist nicht das Problem.** Clients sprechen plain mit dem lokalen Resolver, der macht DoH nach oben — die empfohlene Auflösung des DoH/Split-Horizon-Konflikts, `bf.lan` bleibt heil. Was Seiten blockiert, ist die **Policy**. ➡️ **Nie einen „DoH aus"-Schalter bauen**, sondern Policy-Ausnahmen.
2. **Identität existiert nur dort, wo die Client-IP sichtbar ist.** Router-DoH aggregiert alle Clients hinter *einer* Quelle — CF sieht nur den Core. Per-User-Identität *bei CF* verlangt einen **Agent** (WARP; bei NextDNS/ControlD deren CLI) → auf Shelly/Sonos/TV unmöglich. **RouterOS kann keine per-Client-Ausnahme** (keine DNS-Views; `/ip dns static` wirkt domain-weit — verifiziert). ➡️ Ein lokaler Resolver mit Client-Policy ist unvermeidbar.
- ✅ **Architektur-Entscheid: Filter zentral bei CF, Identität + Ausnahmen lokal.** Die **Filterliste bleibt vollständig bei Cloudflare** (Core via DoH, unterwegs via WARP); lokal wird **nie eine Filterliste vorgehalten, nur die Diffs**. Vorteile: kein RAM-Thema auf dem Core (eine Adlist kostet ~1,9 KB/Domain, 50k ≈ 95 MB — Konkurrenz mit Conntrack/DHCP/WG), **eine** Policy-Quelle statt zweier, gleiche Regelbasis zuhause wie unterwegs.
- ✅ **Resolver-Wahl: AdGuard Home** (läuft bereits, `10.40.20.16`). Vergleich gegen die realen Anforderungen:
- **Pi-hole disqualifiziert:** **spricht kein DoH** → bräuchte cloudflared/unbound als Sidecar (vierte QUIC-Komponente neben den drei TP16-Connectoren). Dazu belegtes Bruchrisiko **v5→v6** (FTL/API/Web neu, Docker-Env-Variablen tot).
- **AGHs „keine Gruppen"-Schwäche verschwindet hier**, weil Clients als **CIDR** eintragbar sind → `10.40.30.0/24` = die IoT-Gruppe. **Die VLAN-Segmentierung ist bereits die Policy-Grenze.** Dazu natives DoH, per-Device-ID auch bei wechselnder IP, „Unblock for this client", API + HA-Integration.
- **Technitium** = ernsthafte Alternative (autoritativer DNS + DHCP + Apps, `networkGroupMap`); seine IP/Subnet-Bindung trifft uns kaum, weil die IPs per Reservierung faktisch statisch sind. **Neubewertung legitim** (die TP4-Ablehnung galt einer anderen Frage) — sinnvoller Anlass wäre, DNS **und** DHCP zusammenzuführen. Eigener TP, nicht dieser.
- **Verworfen:** eigene Self-Service-UI bauen · per-Device-DoH via NextDNS/ControlD (Agent-Zwang, bricht `bf.lan`) · Umbrella ($30–45/User/Jahr) · Cloudflare verlassen (wirft TP16/TP16.1 weg) · Adlist auf dem Core.
- **Zuweisung ist nativ möglich:** `/ip/dhcp-server/lease` hat die Property **`dhcp-option`** (verifiziert) → **Option 6 pro Lease**, bei 54 Reservierungen ein Konfigurationsschritt. *(Nebenfund: Leases haben `address-lists` → automatische Aufnahme in eine Firewall-Liste, direkt nützlich für die TP5-JIT-Gruppe.)*
- ✅ **WARP im LAN abschalten — Mechanismus: Cloudflare „Managed Networks".** Der Client sucht bei jedem Netzwechsel einen **selbst gehosteten TLS-Endpunkt**, prüft dessen SHA-256-Fingerprint und zieht ein Device-Profil, das WARP **deaktiviert**. Beacon auf **NAS/pve**, nicht auf den Core (`certs=0`). Beseitigt die ICMP-Falle vom 2026-08-13 (`ref_warp_local_icmp_trap`) an der Wurzel.
- ✅ **Kollision aufgelöst (2026-08-14):** die frühere Sorge war, dass TP16-Schritt 5 `vw` auf WARP-geschützt hebt und damit „WARP zuhause aus" bricht. **Der User-Constraint aus TP16.2 streicht genau das** — `vw` bleibt bypass und verlangt nie Device-Identity. **Damit steht der Managed-Networks-Weg frei.** Rest-Risiko nur für Dienste, die künftig doch Device-Identity verlangen sollten → dann Access-Policy zusätzlich auf die Heim-IP (`bf4-home-ip` existiert bereits).
- ✅ **Kein Verlust beim Fremdnetz-Zugriff:** FM/DW laufen über den WG-Hub (TP10), nicht über WARP. Managed Networks schaltet nur *im* Heimnetz ab.
- ⚠️ Free-Plan-Verfügbarkeit des Features **nicht belegt** → im Dashboard gegenprüfen. Holzhammer-Alternative (WARP-Endpunkte per Firewall sperren) **nicht empfohlen**: bricht still, Client probiert Transport-Fallbacks, trifft auch Gäste.
- **Umsetzung in Stufen — Sperre zuletzt:**
1. **DoH auf dem Core scharf** → schließt TP21.1-TODO 1 (heute plain `1.1.1.1`); CA-Bundle nötig.
2. **WARP-Managed-Network** (Beacon auf NAS/pve) → WARP zuhause aus.
3. AGH in den Pfad, **zweite Instanz** + Config-Sync, beide per DHCP als DNS 1 + 2.
4. **Client-Gruppen per CIDR** (ein VLAN = eine Gruppe); per-Lease Option 6 nur für Abweichler.
5. HA-Buttons für temporäre Freigaben + JIT (AGH-API, RouterOS-REST, CF-API), RouterOS-`scheduler` als Backstop, damit Ablauf auch ohne HA greift.
6. 🔴 **Erst jetzt (in TP5): DNS-Bypass sperren** — Vorbedingung: Freigabe über HA funktioniert **nachweislich**, und die permanente Ausnahmegruppe **`dns-bypass-exempt`** (Entwicklungsrechner + Build-/Agent-Tooling) steht. Dann: nur Resolver dürfen 53/853 nach außen (schließt Geräte, die eigene Resolver hardcoden — Smart-TV mit `8.8.8.8` ist der Klassiker).
- **Grenzen, offen benannt:** **ein Resolver im Datenpfad** ist unvermeidbar (zwei Instanzen federn ab, ⚠️ **AGH hat kein natives HA → Config-Sync ist Betriebsaufwand**, sonst gilt nach Failover eine andere Policy) · **keine Blockseite bei HTTPS** (gilt für jede DNS-Filterlösung, auch Umbrella) · **CF-Log-Retention 24 h** im Free-Plan — laut User ausreichend, weil Ausnahmen JIT entstehen und ältere Fälle reproduziert werden.
- ⚠️ **802.1X löst die IoT-Identität NICHT:** Consumer-IoT (Shelly, Sonos) hat keinen 802.1X-Supplicant; der Standardweg ist **MAB** = wieder MAC-basiert → **kein Identitätsgewinn gegenüber der DHCP-Reservierung**. **TP8 bleibt richtig, aber als Sicherheitsthema** (dynamische VLAN-Zuweisung, kein MAC-Spoofing bei Managed-Geräten), nicht als DNS-Identitätsquelle.
- **Abh.:** **TP21.1 TODO 1** (DoH auf core), **TP5** (Firewall/Bypass-Sperre — Reihenfolge s.o.), **TP16 Schritt 5** (Access-Kollision), **TP16.1** (falls WARP zuhause bleiben soll), **TP8** (nur Sicherheit). **Kein Blocker für TP5 → TP6**, aber TP5 darf die Bypass-Sperre nicht vorziehen.
### TP24 · Gitea #16 — Unterbrechungsfreie Stromversorgung (DC-USV, 48V-Bus) — aus TP15 herausgelöst (User-Entscheid 2026-08-16)** — `docs/hw-refresh-requirements.md §9`
- **Warum ein eigener TP:** Die USV hing bisher als Unterpunkt in TP15 (HW-Refresh Switching/Router) mit. TP15s Kernauftrag — CRS326-Beschaffung, Bring-up, Core-Tausch — ist seit 2026-08-08 **durch**; die Stromversorgung ist als einziger Rest übrig geblieben und hat mit Switching sachlich nichts zu tun. Sie ist ein **eigenständiges Infrastruktur-Thema mit eigener Einkaufs-, Verkabelungs- und Entscheidungslogik** (Elektrik, Batterie, Standort) und blockiert nichts auf dem kritischen Pfad. Darum eigener TP.
- **Was es ist (Begriffsklärung):** eine **Pufferbatterie für die Netz-/Infrastruktur-Hardware** — **keine** klassische AC-USV mit 230-V-Steckdosen. Ein 48-V-Gleichspannungsbus versorgt die Geräte **direkt an ihrem DC-Eingang**; die Batterie hängt am selben Bus und übernimmt bei Netzausfall ohne Umschaltlücke. Der Umweg AC→DC→AC→DC (und damit dessen Dauerverlust) entfällt.
- **Zielbild (Aufbau, unverändert gültig aus der TP15-Recherche):**
| Teil | Typ | Preis | Wirkungsgrad |
|---|---|---|---|
| Netzteil | MeanWell **LRS-150-48** (150 W, 48 V) | ~€30 | ~88 % |
| DC-USV-Modul | MeanWell **DDR-120-48** (120 W) | ~€80 | ~95 % (pass-through) |
| Batterie | **LiFePO4 12,8 V / 20 Ah** (256 Wh) | ~€50 | — |
| DC-DC 12 V | MeanWell SD-100A-12 (100 W, für NAS/pve) | ~€30 | ~85 % |
| **Summe** | | **~€190** | |
`230 V AC → LRS-150-48 → 48-V-Bus →` Switches direkt · `→ DC-DC 12 V →` DS923/pve · `→ DDR-120-48 →` LiFePO4.
- **Warum 48 V:** PoE-Technik arbeitet nativ mit 44–57 V (kein Wandler nötig), höhere Spannung = kleinere Ströme = weniger Leitungsverlust, und 48 V ist der Telecom-/USV-Standard.
- **Laufzeit (256 Wh):** ~2,5 h bei 100 W reiner DC-Last · **~5,5 h wenn nur Switch + PoE (45 W)** · ~2 h wenn ein Inverter für den Mac mini mithängt.
- **Effizienz-Argument (der eigentliche Grund für DC statt AC):** eine AC-USV verheizt bei 100 W Dauerlast ~25–40 W (≈ €66–105/Jahr), die DC-Variante ~5–12 W (≈ €13–32/Jahr) — bei praktisch gleicher Anschaffung (~€190 vs. €150–250). Der Mehrpreis amortisiert sich über den Dauerbetrieb.
- ✅ **DC-Eingang vorhanden (geprüft):** `crs326-core` + `crs326-branch` (12–57 V, DC-Jack) · ap1/ap2 hAP ax² (12–57 V, alternativ PoE-in). ⚠️ **Adapter/Sonderfall:** DS923+ (externes 12 V/7 A, 4-Pin Mini-Fit JR) · pve/N100 (12 V Hohlstecker) · **Mac mini hat gar keinen DC-Eingang** (eingebautes Netzteil → nur über Inverter).
- **Offene Entscheidungen (aus TP15 mitgenommen, unverändert offen):**
1. **Mac mini auf USV?** Braucht einen Inverter (~15–20 % Verlust + Komplexität). Vorfrage: läuft er überhaupt 24/7? Wenn nein, reicht ein Graceful-Shutdown-Script auf USV-Warnung.
2. **Standort/Verkabelung:** Der Bus soll laut Skizze im **Arbeitszimmer/Technik OG** sitzen — der L3-Core steht aber in **Technik UG**. Entweder zweiter Zweig nach unten oder Bus dorthin, wo der Core steht. **Diese Frage ist durch den Core-Umzug neu zu stellen** (die Skizze stammt aus der Zeit, als oben der Verteiler geplant war).
3. **Arris-Kabelmodem:** vorentschieden auf **Grid** (muss ohnehin am Netz bleiben, DC-Zweig nach unten wäre Aufwand für ~1–2 W).
4. **Batteriegröße:** 20 Ah als Startwert; 50 Ah wenn die gepufferte Last über Switch+AP hinaus wächst.
5. **Monitoring/Shutdown:** wie meldet die USV einen Netzausfall an NAS/pve (Signalkontakt vs. nichts)? Ohne das gibt es kein sauberes Herunterfahren, nur Durchhalten bis die Batterie leer ist.
- **Nutzen konkret:** bei Stromausfall bleiben Core-Switch, Branch-Switch und beide APs online → **Netz, WLAN und der WG-Hub überleben** (der WAN-Uplink nur, solange auch der Arris Strom hat, s. Entscheidung 3). Für NAS/pve zählt vor allem das **kontrollierte Herunterfahren** statt des harten Abschaltens.
- **Abh.:** keine harten. **TP15 ✅** (die zu versorgende Hardware steht und hat DC-Eingänge). Verwandt: **TP18** (Garage-Nodes brauchen je Site eine Stromversorgung), **TP11** (USV-Zustand ins Monitoring). **Kein Blocker für TP5/TP6.**
- **Historie:** war bis 2026-08-16 Unterpunkt von TP15 („🔋 DC-USV: MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt"). Vollständige Recherche inkl. Schaltbild, Effizienzvergleich und Gerätetabelle: `docs/hw-refresh-requirements.md §9` (+ §10 D/E/F für die offenen Fragen).
### TP25 · Gitea #17 — WLAN-Stabilisierung — → [Archiv](teilprojekte-archiv.md#tp25)
> **Abgeschlossen:** Platzierung final (ap1 Sideboard · ap2 Technik UG · ap3 Technik OG), Fund E
> geschlossen (`7.24.1` war die Ursache, nicht `ft-over-ds`), alle vier Handoff-Punkte aufgelöst,
> Gitea #17 closed. Volltext (Kondensat + Langfassung) im Archiv.
### TP28 · Gitea #18 — WLAN über CAPsMAN auf `crs326-core` — → [Archiv](teilprojekte-archiv.md#tp28)
> **Abgeschlossen:** ap1 Sideboard · ap2 Technik UG · ap3 Technik OG (Platzierung final),
> Kanalplan committed (ap1 `5260/5280` · ap2 `5300/5320` · ap3 `5180`), `wifi-cap`-Rest auf dem
> Core bereinigt (`enabled=no`), Compliance 29/29 über 5 Geräte. Gitea #18 closed. Volltext im Archiv.
### TP29 · Gitea #19 — Anwesenheitserkennung fürs Energiesparen
> **User-Formulierung (2026-08-21):** „Personen im Haus orten mit BT (Shellys), WiFi und Radar. So
> kommen wir dem Ziel der Energieersparnis ohne Komfortverlust groß näher."
- **Ziel:** raumgenaue Anwesenheit als Eingangssignal für Heizung und Licht — sparen, **ohne** den
typischen Komfortverlust („erst kalt, dann warm, wenn jemand da ist").
- 🔎 **Der Entwurfsgedanke sind die komplementären Schwächen der drei Quellen** — keine trägt allein:
| Quelle | erkennt | erkennt **nicht** |
|---|---|---|
| **BLE über die Shellys** | grobe Zonen, billig, überall vorhanden | Menschen ohne getragenes Gerät; Raumgrenzen unscharf |
| **WLAN** (Registration-Table am Manager: Gerät, AP, Signal) | wer mit Telefon da ist, inkl. AP-Zuordnung | Menschen ohne Telefon; Raum nur so genau wie die AP-Abdeckung |
| **mmWave-Radar** | auch die **stillsitzende Person ohne Gerät** | braucht einen Sensor **je Raum** |
- ✅ **Was schon steht:** Shellys vollständig auf VLAN30 (TP17 ✅), HA produktiv — und mit
[`docs/tp6-monitor-core.sh`](tp6-monitor-core.sh) existiert seit TP25 bereits ein Sammler, der die
WLAN-Präsenzdaten am Manager mitschreibt (Gerät → AP → Signal, im Sekundentakt).
- ❓ **Offene Fragen für die erste Session:** Datenpfad ins HA (MQTT oder REST?) · Firewall-Regeln für
neue Sensorik (**TP5 ist deny-by-default**, jeder neue Dienst über eine VLAN-Grenze braucht eine
Regel) · Aufbewahrung und Privatsphäre der Ortsdaten · ob Radar-Sensoren auf `dirtyvader-iot` laufen
oder eigene Segmentierung brauchen.
- **Abgrenzung:** TP25 **misst** den Funk, TP29 **nutzt** ihn als Sensor — dieselbe Datenquelle,
anderes Ziel. Die Raumkarten aus TP25 sind für TP29 direkt verwertbar: sie sagen, wie genau die
WLAN-Quelle einen Raum überhaupt auflösen kann.
- **Abh.:** keine harten. Profitiert von TP25 (Abdeckung/AP-Zuordnung) und TP11 (Monitoring), wenn es
dahin kommt. **Kein Blocker für irgendetwas.**
<a id="tp30"></a>
### TP30 · Gitea #20 — Credentials: neu setzen & rotieren (Sammelbecken)
> **User-Entscheid 2026-08-24:** „ich würde gerne daraus einen eigenen punkt machen. ich bin mir
> sicher, dass es noch öfters passwörter zu rotieren geben wird. das machen wir dann alles gegen
> projektende." — nachgeschärft am selben Tag: **„tp30 wird unser sammelbecken für credentials,
> die neu gesetzt werden müssen"**, und **TP17.2 wandert hierher.**
>
> 📥 **Scope ist damit breiter als „Rotation": jedes Credential, das neu gesetzt werden muss** —
> **Erstvergabe** (bisher gar keins vorhanden, z.B. Shelly `SetAuth`) **oder Rotation** (Wert
> verbrannt/abgelaufen). Die Trennung ist für die Priorität wichtig, für die Arbeit nicht: der
> Kostentreiber ist in beiden Fällen derselbe — die Konsumenten, nicht das Setzen.
> **Neue Credential-Aufgaben kommen ab jetzt hier hinein, nicht als eigenes TP.**
>
> **Warum ein eigener TP und nicht vier verstreute Follow-up-Zeilen:** die Rotationen teilen sich Werkzeug,
> Fallen und vor allem den **teuren Teil — den Konsumenten** (HA, Nautobot, MikroMCP, `~/.secrets`).
> Wer sie einzeln macht, zahlt den Re-Auth-Aufwand mehrfach. Gebündelt ist es **ein** Durchgang je
> Gerät. Deshalb steht hier die Liste, nicht der Termin.
- **Auslösekriterium (statt Datum):** wenn die offenen TPs abgearbeitet sind bzw. Christoph das
Projekt schließt. *(2026-09-21: eine frühere „harte Ausnahme für neue Leaks" wurde **gestrichen** —
sie widersprach dem Bündelungszweck dieses TPs und der Praxis: auch die leak-getriebenen Posten
#1 und #5–#9 stehen unverändert im Bündel. Ein Transkript-Leak zieht **keinen** Posten vor.)*
| # | Credential | Umfang | Anlass | Vorarbeit | Preis der Rotation |
|---|---|---|---|---|---|
| 1 | **MikroTik `admin`** | core · branch · ap1 · ap2 (**ein** geteilter Wert) | 🔴 **zweimal in ein Transkript geraten** (2026-08-22 via `sshpass -p`; 2026-08-23 durch einen Enumerationsfehler) | ✅ **Etappe 1 durch** (s.u.) | nach Etappe 2 nur noch `~/.secrets/credentials.env` + `~/.claude.json` + MikroMCP-Neustart |
| 2 | **IoT-WLAN-PSK** (`sec-iot`) | `dirtyvader-iot`, 26 Clients | steht **im Klartext** im TP17-Text ⇒ auch in der **Git-Historie**; Redigieren holt ihn dort nicht raus | Hebel `multi-passphrase` am Core verifiziert vorhanden, Tabelle leer | 23 Shellys per Skript + **3 von Hand** (`.249` NSPanel Pro, `.244` Nuki Bridge, `.227` SwitchBot) |
| 3 | **Shelly-Gerätepasswort** *(vormals TP17.2)* | 23 Shellys | Härtung (bisher **keine** Auth auf den Geräten) | ✅ Werkzeug fertig (`migrate.py cmd_auth` + Digest-Fallback) | **je Gerät ein HA-Re-Auth-Flow** — das ist der Aufwand, nicht `SetAuth` |
| 4 | **CF-Access-Service-Token** (`CF_API_TOKEN`) | Cloudflare-Zugang, s. TP14.4 | 🔴 **2026-09-21 erneut ins Transkript geraten** (Session-`grep` unmaskiert). Der bisherige harte Termin ist **entfallen**: ⏳ „2027-01-09" galt für das längst revokte Alt-Token `9984078812bc…`; das **live** `CF_API_TOKEN` (id `493ed8bd…`) hat **kein** Ablaufdatum — verifiziert 2026-09-21 via `/accounts/{id}/tokens/verify`, `expires_on: null` | Runbook liegt (`tp14.4-cf-access-token-runbook.md`) | Token neu ausstellen + `~/.secrets` nachziehen; **account-owned**, anderer verify-Endpoint |
| 5 | **Nautobot-Tokens** (`NAUTOBOT_SUPERUSER_API_TOKEN`, `NAUTOBOT_GITEA_TOKEN`) | Nautobot-Superuser + Gitea-Anbindung | 🔴 in der TP16.3-§2/§3-Session (2026-08-31) durch schwache `sed`-Maskierung **ins Transkript** geraten | Werte in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored) | neu ausstellen + Stack-YML nachziehen; Superuser-Token wird von den `gc-*`-Skripten gelesen |
| 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste |
| 7 | **`TERPENE_ANDROID_KEYSTORE_PW`** | Terpene-Android-TWA-Signing (`android/android.keystore`, alias `terpene`) | 🔴 am 2026-09-01 (TP41) durch **Regex-Suffix-Loch** (`_PW` nicht abgedeckt) ins Transkript geraten | Wert in `~/.secrets/credentials.env` + Keystore-Datei | **Store-/Key-Passwort** via `keytool -storepasswd`/`-keypasswd` ändern + `credentials.env` nachziehen; Konsument = Android-Build/Play-Signing (**Cross-Projekt blaueblüten**) — den **Key selbst NICHT** neu erzeugen |
| 8 | **`N8N_SMTP_PASS`** (Gmail-App-PW für `johnny.claw.0x00@gmail.com`) | SMTP-Versand aus n8n (Portainer-Stack 174 `n8n`, macmini) | 🔴 am 2026-09-01 (n8n-mcp-SSRF-Session) beim Ausgeben des Stack-174-Files ins Transkript geraten — Redaktionsmuster hatte `PASSWORD`, aber **nicht bare `PASS`** → `N8N_SMTP_PASS=<wert>` blieb stehen | Wert in Portainer-Stack 174 (`environment`) | **Gmail-App-Passwort widerrufen** (Google-Konto → App-Passwörter) + neues erzeugen, Stack-174-Env nachziehen + redeploy; Konsument = n8n-SMTP-Node(s) |
| 9 | **`GITEA_ACCESS_TOKEN`** (API-Zugang `gitea.emsenhuber.net`) | Session-Tooling (`bin/gitea-tp-sync.py`, Ad-hoc-API-Calls) | 🔴 am 2026-09-14 (TP25-In-situ-Session) **ins Transkript** geraten: Env-Parse-Fehler schob den Token in die URL-Konstruktion, `urllib` warf `ValueError: unknown url type` und druckte ihn im Traceback | Mechanismus zu: Token gehört **nur in den `Authorization`-Header, nie in eine URL** — und der Fehlerpfad, der ihn enthält, darf ihn nicht ausgeben (gleiche Familie wie #1/#5/#6/#7/#8, diesmal Unterklasse „Fehlerausgabe statt Maskierung") | neu anlegen (Gitea → Settings → Applications), `~/.hermes/.env` nachziehen, altes widerrufen; Konsumenten = `gitea-tp-sync.py` + Session-Ad-hoc-Calls |
| 10 | **`N8N_MCP_BEARER`** (n8n-Public-API) | n8n-MCP `macmini:3001` — in **7 Projekten** referenziert (`bf-infra`, `blaueblueten-n8n`, `travelmate`, `poulios-recherche`, `ha-xstate`, `valamar`, `uwe-job-bewerbung`) | 🔴 am 2026-09-22 (Env-/OpenRouter-Session) **ins Transkript** geraten: die Redaktions-Alternation führte `BEARER` nicht, und der Schlüssel endet **ohne `=`** auf den Bezeichner — die Feld-Ersetzung hatte damit keine Ankerstelle | Ablage nur `~/.secrets/exports.zsh`; alle Konsumenten sind `${…}`-Referenzen (kein Klartext in den Repos, geprüft) | Token in n8n neu erzeugen, `~/.secrets` nachziehen, **App-Neustart** (MCP-Env wird beim Start gelesen) — danach 7 Projekte ohne Edit, weil sie nur referenzieren |
| 11 | **`TERPENE_RO_URL`** (PG-User `terpene_ro`, DB `terpene`) | Terpenfinder-DB read-only `10.40.20.20:5432` — **Cross-Projekt (blaueblüten)**; einziger Konsument ist `~/.secrets/exports.zsh` (+ `.bak`) | 🔴 am 2026-09-22 (Env-/OpenRouter-Session) **ins Transkript** geraten — **neue Unterklasse derselben Familie:** das Passwort steckt in der **URL** (`…://user:PASS@host`), die Feld-Maskierung ankert auf `=` und findet dort keine Zeile zum Ersetzen (dieselbe Lücke wie #9: was nicht als Feld auftritt, wird nicht maskiert — das geteilte Redaktions-Snippet deckt diesen Fall **nicht** ab) | Wert nur in `~/.secrets/exports.zsh`; **kein** Konsument in Code oder Configs | `ALTER ROLE terpene_ro … PASSWORD '<neu>'` am NAS (`10.40.20.20`), dann `exports.zsh` nachziehen; **kein** Geräte-Roundtrip ⇒ billigste Rotation nach #6. ⚠️ Cross-Projekt: nur mit Christophs Okay anfassen |
| 12 | **OpenRouter-API-Key** (`sk-or-v1-cb6…258`) | Claude-Desktop-3P-Gateway: CCR-Provider in `~/.claude-code-router/config.sqlite` (`Providers[].api_key`) **+** 3P-Config `e1219ea4` (`inferenceGatewayApiKey`) | 🔴 am 2026-09-23 (Claude-Desktop/OpenRouter-Session) ins Transkript geraten — das `sed` ankerte auf den **geratenen** Namen `apiKey`, der echte Schlüssel heißt `inferenceGatewayApiKey` ⇒ kein Match (gleiche Familie wie #5/#10/#11, neue Unterklasse s. Merkregel unten). Zweiter Fund derselben Session: `head -c 400` auf `~/.claude-code-router/bin/ccr-claude-code-api-key-claude-code` gab den **CCR-Profil-Token** aus (local-only `127.0.0.1`, geringe Reichweite — nicht in der Zählung) | ⚠️ **kein `~/.secrets`-Eintrag** — der Wert lebt nur in `config.sqlite` + 3P-Config, und er ist **noch gültig** (200, verifiziert 2026-09-23 via `/api/v1/key`) | neu ausstellen (openrouter.ai/keys); danach **zwei** Konsumenten: `config.sqlite` (`Providers[].api_key`, mit CCR-Neustart) + 3P-Config (`inferenceGatewayApiKey`) — beide müssen mit, sonst steht die App still (3P ist alles-oder-nichts). **Nachtrag 2026-09-24 (Konsumenten-Inventur korrigiert, live geprüft):** die **angewandte** 3P-Config `8f69f2f1` (= „Claude Code Router", `appliedId` seit 2026-09-24) trägt **nur den CCR-Gateway-Token** (len 50, `x-api-key`, gegen CCR mit 200 verifiziert) — **nicht** den OR-Key. Der OR-Key lebt real in **`config.sqlite`** (live, von CCR benutzt) **und** in der nur *gespeicherten*, nicht angewandten `e1219ea4` (len 73, sha `f23aaebdaa4c`). Rotation also: `config.sqlite` → `Providers[0].api_key`, **CCR-App neu starten**, `e1219ea4` löschen/ersetzen; `8f69f2f1` bleibt **unangetastet** (sonst steht die App still) |
> ✅ **Der Leak-MECHANISMUS hinter #1 ist seit 2026-08-31 zu — der Wert bleibt trotzdem verbrannt.**
> Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und
> `routeros/import-core-rsc.py` sowie die vier TP15-Cutover-Skripte, die am selben Tag nach
> `routeros/archiv-tp15-cutover/` gewandert sind), laufen jetzt über `SSHPASS`-Env + `sshpass -e`
> — **auch die archivierten**, denn Archiv-Code wird abgeschrieben. **Kontrolliert gemessen**, nicht behauptet: mit `-e` taucht
> das Passwort in 40 `ps`-Stichproben während einer laufenden Verbindung **nicht** auf, und die
> Negativkontrolle (ein Dummy-Wert über die alte `-p`-Form) wurde **36-mal** gesehen — die Probe ist
> also nicht blind. 🔴 **Das ändert an #1 nichts:** ein Wert, der einmal in einem Transkript stand,
> wird nicht dadurch sauber, dass der Weg dorthin geschlossen ist. Rotation bleibt fällig.
>
> 🔴 **Posten #6 ist die dritte Maskierungs-Panne derselben Klasse** (nach `SHELLY_DEVICE_PW` 08-28
> und den Nautobot-Tokens 08-31): das Muster ankerte auf ein **Trennzeichen** (`=`), das im
> konkreten File gar nicht stand (`password <wert>` mit Leerzeichen). **Merkregel, jetzt zum dritten
> Mal:** vor jedem Ausgeben einer Secret-Datei den **Schlüsselnamen** ankern und das ganze Feld
> ersetzen, nie auf ein Trennzeichen vertrauen, das evtl. fehlt — und Werte gar nicht erst suchen,
> um sie zu sehen. #6 ist zugleich der **billigste** Posten (kein externer Konsument), aber die
> Panne dahinter ist der teure Teil.
>
> 🔴 **#7 ist die vierte Maskierungs-Panne derselben Klasse** (nach `SHELLY_DEVICE_PW` 08-28,
> Nautobot-Tokens 08-31, MariaDB-root 09-01): diesmal ein **Regex-Suffix-Loch** — das Muster
> `(TOKEN|PASS|PASSWORD|SECRET|KEY|CLIENT_ID)` deckte den Suffix `_PW` **nicht** ab, also blieb der
> Wert von `TERPENE_ANDROID_KEYSTORE_PW` beim maskierten Lesen stehen. **Dieselbe Merkregel, erweitert:**
> die Alternation muss `PW`/`PWD` mit enthalten (`…|PW|PWD|…`), sonst greift die Feld-Ersetzung nicht.
>
> 🔴 **#8 ist die fünfte Maskierungs-Panne derselben Klasse** (SHELLY 08-28, Nautobot-Tokens 08-31,
> MariaDB-root #6, Keystore-`_PW` #7): diesmal enthielt das Muster zwar `PASSWORD`, aber **nicht das
> bare `PASS`** — `N8N_SMTP_PASS` endet auf `PASS`, nicht `PASSWORD`, also blieb der Wert stehen.
> **Die Merkregel steht jetzt zum fünften Mal da und wurde zum fünften Mal gebrochen** — Konsequenz
> ist nicht noch eine Notiz, sondern ein **fixes, geteiltes Redaktions-Snippet**, das man nicht jedes
> Mal neu (und falsch) tippt: `sed -E 's/([A-Z0-9_]*(PASS|PASSWORD|PWD|PW|TOKEN|SECRET|KEY|BEARER))=[^[:space:]]*/\1=<redacted>/g'`
> — ankert auf den **Schlüsselnamen inkl. Präfix** und deckt alle bisher gesehenen Suffixe ab.
>
> 🔴 **#9 ist der sechste Vorfall derselben Familie, aber eine neue Unterklasse** (SHELLY 08-28,
> Nautobot-Tokens 08-31, MariaDB-root 09-01, Keystore 09-01, SMTP 09-01): diesmal kein
> Maskierungsmuster, sondern ein **Fehlerpfad** — ein Env-Parse-Fehler schob den Token in die
> URL-Konstruktion, und `urllib` druckte ihn im `ValueError: unknown url type`-Traceback.
> **Merkregel: ein Secret gehört in keine Datenstruktur, die eine Exception ausgeben kann**
> (URL-Konstruktion validieren, bevor das Secret hineinwandert; Fehlerpfade dürfen den
> fehlerhaften Wert nicht mitschleppen). Das Redaktions-Snippet oben hilft hier nicht — es
> kann nichts maskieren, was als Fehlermeldung und nicht als Dateiinhalt kommt.
>
> 🔴 **#12 ist die siebte Panne derselben Familie — und wieder eine neue Unterklasse** (SHELLY 08-28,
> Nautobot-Tokens 08-31, MariaDB-root #6, Keystore-`_PW` #7, SMTP-`PASS` #8, Gitea-Traceback #9):
> diesmal war die Quelle **JSON**, nicht Env. Das geteilte Snippet oben ist auf **`KEY=VALUE`-Zeilen**
> gebaut (Anker `([A-Z0-9_]*(…|KEY|…))=[^[:space:]]*`) — es kann `"inferenceGatewayApiKey": "…"`
> gar nicht treffen: **camelCase**-Schlüssel, kein `=`, kein Zeilenanfang. Wer JSON mit dem Env-Snippet
> redigiert, maskiert **nichts** — und genau das ist passiert; das danach handgetippte `sed` ankerte
> auf den **geratenen** Namen `apiKey` statt auf den echten. **Merkregel: für JSON einen JSON-bewussten
> Masker benutzen** — Objekt laden, über **Schlüsselnamen** filtern (case-insensitiv, Teilstring ok),
> jeden Treffer durch `<redacted>` ersetzen; nie das Env-Snippet, nie ein geratenes Muster.
> Und: **Schlüsselnamen erst auflisten, dann maskieren** — „ich weiß schon, wie der Key heißt" ist
> genau die Annahme, die hier gerissen ist.
>
> ℹ️ **#3 war bis 2026-08-24 TP17.2** und ist jetzt vollständig hier (s. Detailabschnitt unten) —
> es benutzt denselben Geräte-Durchgang wie #2, **ein Reboot statt zwei**. #4 gehört sachlich zu
> **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token).
#### Posten #1 im Detail — MikroTik `admin`: Radius, Etappe 1, Etappe 2
*Am 2026-08-24 aus den Follow-ups hierher gezogen — Text unverändert übernommen.*
**Vorgeschichte des Entscheids** (Stand bis 2026-08-23, User: „machen wir, aber erst später"):
⏭️ ~~**Nachgelagert seit 2026-08-23: erst TP17.1, dann rotieren.**~~
**Revidiert noch am selben Tag (User): beides läuft PARALLEL.** Der Passphrase-Override je MAC
(s.u.) macht den Flag-Day überflüssig, damit war die Reihenfolge nie technisch nötig — nur
ökonomisch. Regel für die Parallelität: **ein Gerät, das ohnehin auf Thread/Zigbee wandert,
bekommt den neuen PSK gar nicht**, sondern nur einen Übergangs-Eintrag in der `access-list`.
⚠️ Der Entscheid betrifft **beide** Passwörter: der **IoT-WLAN-PSK** steht im Klartext im
TP17-Text ([Archiv](teilprojekte-archiv.md#tp17)) und damit auch in der Git-Historie — **bleibt
auf User-Entscheid 2026-08-23 vorerst unverändert**, weil Redigieren ihn nicht aus der Historie
holt und Rotieren heute ~22 Shellys kostet. ~~Nach TP17.1 sind es deutlich weniger.~~
🔴 **Hinfällig seit 2026-08-23: TP17.1 ist verworfen** — die Geräte bleiben WLAN-Clients, es
werden also *nicht* weniger. Die Rechnung „~23 Shellys + 3 Fremdgeräte" gilt unverändert. Anlass: das Core-Passwort ist bei einer Debug-Ausgabe im Klartext in
ein Sitzungstranskript geraten. Ursache war `sshpass -p`, das es in die **Kommandozeile**
schreibt — sichtbar in `ps` und in jedem argv-Trace.
#### 🔑 Radius-Befund 2026-08-23: das Admin-Konto teilen sich FÜNF Konsumenten
Vor dem Rotieren erhoben — das ist deutlich mehr als „vier Geräte + eine Datei":
| Halter | Nachziehen |
|---|---|
| `~/.secrets/credentials.env` | Datei |
| `~/.claude.json` (MikroMCP-Env, `ROUTER_*`/`CRS326_*`) | JSON + MCP-Neustart |
| **Nautobot-Stack** `NAUTOBOT_ROUTEROS_USERNAME/PASSWORD` | Stack-Datei + **Portainer-Redeploy auf der DS923** |
| **HA: VIER `mikrotik`-Integrationen** (`.99.1/.2/.11/.12`, 40 `device_tracker`) | 🔴 **`supports_reconfigure: false` — NICHT skriptbar**, 4× Re-Auth von Hand |
| Repo-Skripte (`routeros/*.py`, `nautobot/*.py`, `docs/tp*.sh`) | ziehen aus `~/.secrets` → automatisch |
⇒ **User-Entscheid 2026-08-23: Konten trennen statt ein Passwort teilen.** Nicht das Rotieren ist
das Problem, sondern dass ein einziges `admin` von fünf Stellen benutzt wird — jede Rotation kostet
sonst wieder vier Re-Auths und einen Stack-Redeploy.
#### ✅ Etappe 1 DURCH (2026-08-23): dedizierte Service-Accounts auf allen vier Geräten
Rein **additiv**, kein laufender Dienst berührt. Vorher existierte **nur** `admin` (Gruppe `full`).
| Gruppe | Policy | User | Zweck |
|---|---|---|---|
| `grp-ha` | `read,api,rest-api,test` | `ha` | HA-Mikrotik-Integration, **lesend** über API 8728 |
| `grp-nautobot` | `ssh,read,write,test,sensitive` | `nautobot` | Golden Config über SSH/Netmiko (Backup **und** Deploy) |
Passwörter: `MIKROTIK_HA_PASS` / `MIKROTIK_NAUTOBOT_PASS` in `~/.secrets/credentials.env`
(32 Zeichen, je Dienst eigen, geräteübergreifend gleich).
🔒 **Angelegt über ein Skript, das die Werte aus der Datei liest** — bewusst *nicht* über
`manage_user`, dessen `password`-Parameter im Sitzungstranskript landen würde.
**Abnahme, auf allen vier Geräten geprüft:**
- `ha` liest (`identity`, `dhcp-lease` — am Core 59 Einträge, genau was das Device-Tracking braucht),
**Schreibversuch wird abgewiesen**.
- `nautobot` meldet sich per SSH an und exportiert; **kein REST** (bewusst kein `rest-api` in der Policy).
- 🎯 **Der Export von `nautobot` ist byte-identisch zu dem von `admin`** (391/56/87/87 Zeilen je Gerät)
⇒ **die GC-Compliance driftet beim Umstellen nicht.** Das war das Hauptrisiko dieser Etappe.
- `/user` wird von **keiner** ComplianceRule abgedeckt ⇒ die neuen Konten erzeugen selbst keinen Drift.
#### ⏭️ Etappe 2 offen — die Konsumenten umhängen, DANN `admin` rotieren
1. **Nautobot** auf `nautobot` umstellen (Stack-Env + Portainer-Redeploy), GC-Backup-Lauf als Abnahme.
2. **HA**: die vier `mikrotik`-Einträge auf den `ha`-User bringen. ⚠️ Reconfigure ist nicht skriptbar;
Entfernen-und-neu-Anlegen **wäre** skriptbar, riskiert aber die 40 `device_tracker`-Entity-IDs
(sie leiten sich aus der MAC ab, kämen also gleich zurück — **aber erst nachdem** die alten
Registry-Einträge weg sind, sonst hängt HA ein `_2` an; dieselbe Falle wie beim Shelly-Rename).
3. **Erst danach** `admin` rotieren — dann berührt es nur noch `~/.secrets` + `~/.claude.json`.
🔴 **Eigener Fehler, protokolliert:** beim Erheben des Radius hat ein `json.dumps`-Aufruf über
`~/.claude.json` das **ap1-Passwort im Klartext ins Transkript geschrieben**. Da alle vier Geräte
dasselbe `admin`-Passwort teilen, ist es **derselbe Wert, der am 2026-08-22 bereits verbrannt ist** —
keine neue Expositionsklasse, aber ein zweiter Beleg dafür, dass die Rotation fällig ist.
**Merkregel: Secrets-Enumeration gibt nur Schlüsselnamen aus, nie Werte** — auch nicht abgeschnitten.
✅ **Vorbedingung erfüllt, geprüft 2026-08-23:** alle vier
Skripte laufen auf `sshpass -e` (`tp6-marker/server.py`, `tp6-monitor-core.sh`,
`tp25-walk-messung.sh`, `tp25-pin-s22.sh`) — die frühere Notiz, zwei davon hätten die Schwäche
noch, war veraltet. Übrig blieb nur **`CLAUDE.md`, das `sshpass -p` weiterhin aktiv als Reboot-Weg
empfahl** — am 2026-08-23 auf `sshpass -e` korrigiert. **Damit ist die Rotation ohne Rückfallgefahr
fahrbar.**
Weg: `manage_user set-password` (⚠️ `/user set*` ist per Deny-Policy blockiert), danach
`~/.secrets/credentials.env` (`ROUTER_*_PASS`/`CRS326_*_PASS`) und `~/.claude.json` nachziehen.
Es ist **ein** Passwort für alle vier Geräte — die „Überlebensversicherung" aus CLAUDE.md.
- 🔎 **Nachtrag 2026-08-23 (Recherche-Session, read-only — kein Eingriff): der PSK in der Doku ist
noch der LIVE konfigurierte.** Gegengeprüft am Manager `crs326-core` (`/interface wifi security`,
Profil `sec-iot` → `cfg-iot` → SSID `dirtyvader-iot`, `datapath.vlan-id=30`,
`wpa2-psk,wpa3-psk`) — und zwar **per Fingerprint statt Klartext** (Länge + drei Zeichenpaare),
damit der Wert nicht ein zweites Mal in ein Transkript wandert. **Diese Methode ist die Merkregel
daraus:** einen Verdacht auf Übereinstimmung prüft man mit Stichproben, nicht durch Ausgeben.
- ✅ **User-Entscheid 2026-08-23 zur Git-Historie: belassen, im Nachtrag dokumentieren** — **kein**
`git filter-repo`. Begründung trägt: genau **eine** getrackte Fundstelle
([Archiv](teilprojekte-archiv.md#tp17)), drei Commits (`e76289c` Ersteintrag 2026-07-11,
`f0d1ff9` 08-08, `7ee64b5` Doku-Split 08-23), Remote **ausschließlich** `gitea.emsenhuber.net`,
**ein** lokaler Klon. Eine History-Rewrite hätte hier also nichts gerettet, was nicht ohnehin nur
auf der eigenen Infrastruktur liegt — und den Preis (alle Hashes neu) für nichts bezahlt.
- 📊 **Warum die Rotation heute teuer ist — live gemessen 2026-08-23:** **26 der 27 WLAN-Clients des
gesamten Netzes hängen auf `dirtyvader-iot`** (`dirtyvader-main` 1, MEDIA/UNTRUSTED/GUEST je 0).
VLAN30 führt **41 Leases/Reservierungen** (5 aktuell `bound`, der Rest sind statische
Reservierungen ausgeschalteter Geräte). Neben den ~26 Shellys hängen dort **P750W** (Drucker),
**Airfryer** (`espressif`, `.180`), **SwitchBot floor-lamp**, **Twinkly** `.228`, **DTUBI** `.8`,
**SONOFF-NSPanelPro** und ein **iPhone** `.177`. ⇒ Ein Passwortwechsel ist heute eine
Batch-Aktion über ~26 Geräte, und **das ist genau die Rechnung, die TP17.1 verkleinert.**
- 🔧 **Zwei Umsetzungshinweise fürs spätere Rotations-Paket:**
**(a)** `mcp/shelly/migrate.py` hat **kein** Rotate-Kommando (nur `migrate`/`deanchor`/`auth`/
`table`) — es bräuchte eine kleine Variante über denselben `set_sta`-Pfad.
**(b) Ein Flag-Day ist vermeidbar:** RouterOS 7 kann in `/interface wifi access-list`
**Passphrase-Overrides je MAC**. Also neuer PSK aufs Profil, Altgeräte übergangsweise per MAC mit
dem alten weiter, danach die Liste leeren. 💡 Nebeneffekt: der TP25-Befund „keine `access-list`"
bekäme damit endlich einen Zweck — und seit dem 08-23 gibt es die ComplianceRule
`wifi-access-list`, die Einträge dort auch sichtbar macht.
- **Mitzuziehen bei der Rotation** (legitime, gitignorete Ablagen desselben Werts):
`~/.secrets/credentials.env` (+ `.bak-20260808`), `routeros/.secrets/wlan-psks.env`,
`mcp/shelly/.env`.
- **CF-Access-Service-Token** — **Rotationsposten #4**, am 2026-08-24 aus den Follow-ups hierher
gezogen (stand dort als „TP14.4 — CF-Access-Service-Token rotieren"). Sachlich gehört er zu
**TP14.4** (CF-Access-Profil für Claude, Runbook `docs/tp14.4-cf-access-token-runbook.md`); hier
steht er, damit die Rotationsliste vollständig ist. ⚠️ ~~Der `CF_API_TOKEN` läuft ohnehin
**2027-01-09** ab~~ — **korrigiert 2026-09-21:** dieses Datum gehörte zum revokten Alt-Token
`9984078812bc…`; das **live** `CF_API_TOKEN` (id `493ed8bd…`) hat **kein** Ablaufdatum
(`expires_on: null`, via `/accounts/{id}/tokens/verify`). Der harte Termin ist damit weg —
**Anlass** ist jetzt der **Leak vom 2026-09-21** (🔴 siehe Posten #4 oben). Der Posten bleibt
**gebündelt** und wird nicht vorgezogen (Auslösekriterium oben).
#### Posten #3 im Detail — Shelly-Gerätepasswort (`SetAuth`), vormals TP17.2
*Am 2026-08-24 als eigenes TP aufgelöst und hierher gezogen — Text unverändert übernommen.*
> 🔴 **Hochstufung 2026-08-28:** `SHELLY_DEVICE_PW` ist im Zuge des shelly-mcp-Deploys (Session B)
> erneut in ein Sitzungstranskript geraten — `sed 's/=.*/=<set>/'` auf der Stack-YML hat den Wert
> **nicht** maskiert (Replacement-Pattern zu wenig greedy). Datei selbst ist gitignored/chmod 600,
> aber das Transkript ist die Expositionsklasse. Folge: Vorziehgrund, **Posten #3 ist ab jetzt der
> dringendste offene Rotationspunkt** — vor #2 (PSK, der schon länger im Klartext in der Git-Historie
> steht) und #1 (MikroTik `admin`, der schon zwei Transkript-Leaks hat, aber beide male dasselbe PW,
> nicht **vier** verschiedene Quellen wie hier). Reihenfolge: erst **Werkzeug-Härtung**
> (`sed`-Replacement absichern ODER `tr`-Stream-Filter vor jedem Read einer Secret-Datei),
> dann Posten #3 rotieren (alle 23 Shellys + HA-Re-Auth-Flow).
> **User 2026-08-23:** „außerdem haben wir einen punkt, um die shellys mit einem pwd zu schützen."
> Damit ist der Zurückgestellt-Status von 2026-07-13 aufgehoben. **Begründung der Kopplung:** die
> PSK-Rotation fasst ohnehin jeden Shelly an und rebootet ihn — `SetAuth` gehört in denselben
> Durchgang, ein Reboot statt zwei.
>
> ✅ **Werkzeugseitig ist nichts zu bauen:** `mcp/shelly/migrate.py` hat `cmd_auth` (setzt
> `Shelly.SetAuth` mit SHA-256-`ha1` gegen die Geräte-`realm`, Dry-Run als Default), und `_rpc` hat
> einen **Digest-Auth-Fallback** auf 401 mit `SHELLY_DEVICE_PW`.
>
> 🔴 **Der teure Teil ist nicht `SetAuth`, sondern Home Assistant.** Mit aktiver Auth verlangt die
> Shelly-Integration je Gerät einen **Re-Auth-Flow** — bei 23 Geräten ist *das* der Aufwand, und genau
> der Grund für die frühere Einstufung als „Kosmetik". ⇒ **Erst an EINEM Gerät machen und den
> HA-Aufwand messen** (Vorschlag `s15 technik_pve`, RSSI −36, unkritisch), bevor der Rest folgt.
> Sonst bündelt man ein ungemessenes Risiko in eine 23-Geräte-Aktion.
<details><summary>Ursprünglicher Text (historisch)</summary>
- **Was:** je Gerät `Shelly.SetAuth` (User `admin`, realm-individueller `ha1` = sha256("admin:<device-id>:<pw>"), einheitliches Klartext-PW) via `mcp/shelly/migrate.py auth --go`. `SHELLY_DEVICE_PW` liegt in `mcp/shelly/.env` (gesetzt, len 13). Runner + Digest-Auth-Fallback in `_rpc` fertig.
- **Warum zurückgestellt:** rein kosmetisch/Härtung; **bricht die HA-Shelly-Integration**, bis pro Config-Entry (22×) das Credential neu eingetragen ist → disruptiv, kein Mehrwert im abgeschotteten IOT-VLAN. **Erst nach** dem Netz-Teardown (TP19) neu bewerten.
</details>
#### 🔴 Neues Leak-Ereignis 2026-08-24 — der IoT-PSK ist erneut exponiert
Beim 5-GHz-Umfeldscan für TP25 wurde `mcp__mikromcp__list_wifi_interfaces` gegen den Manager gefahren,
um die `cap-wifiN`-Zuordnung zu bestimmen. **Das Tool gibt `security.passphrase` aller Profile im
Klartext zurück** — damit stehen **alle fünf WLAN-Passphrasen** (`main`, `iot`, `media`, `untrusted`,
`guest`) in einem Sitzungstranskript. Kein Werkzeug hat versagt; es war das falsche Werkzeug für eine
Kanalabfrage. **Betrifft Posten #2 (IoT-PSK) und zusätzlich vier bisher unverbrannte Passphrasen.**
⚖️ **Nach der „harten Ausnahme" oben ist das ein Vorziehgrund** — Entscheidung gehört Christoph.
Gegenargument, das dieselbe Rechnung wie am 08-23 ergibt: der IoT-PSK lag ohnehin schon im Klartext
in der Git-Historie, neu exponiert sind vor allem `main`/`media`/`untrusted`/`guest` — und die kosten
beim Rotieren **wenige** Clients, nicht 26. ⇒ **`main` und `guest` wären billig und einzeln machbar,
unabhängig vom gebündelten Durchgang.**
#### Fallen, die für jedes Setzen/Rotieren gelten (nicht neu erheben)
- 🔴 **Kein Read auf `/interface/wifi`, wenn nur Kanäle/Interfaces gebraucht werden** —
`list_wifi_interfaces` liefert alle Passphrasen im Klartext mit (Leak vom 2026-08-24, s.o.).
Feldauswahl benutzen oder `/interface wifi channel print detail`.
- 🔴 **Niemals `sshpass -p`** — schreibt das Passwort in die Kommandozeile, sichtbar in `ps` und in
jedem argv-Trace. **Genau so entstand Leak #1.** `-e` hält es in der Umgebung des Kindprozesses.
Der Repo-Stand ist bereits durchgängig `-e`.
- 🔴 **Passwörter nie per MCP-`password`-Parameter setzen**, sondern per Skript aus `~/.secrets`
(`manage_user set-password`; `/user set*` ist per Deny-Policy blockiert).
- 🔴 **Keine Secrets-Enumeration im Transkript** — das war Leak #2, ohne dass irgendein Werkzeug
fehlerhaft war.
- 🔒 **`wifi-security` ist als ComplianceRule grün, aber NICHT deploybar** (Passphrasen stehen als
`<<REDACTED>>` im Template). Ist-Stand für #2 immer per REST vom Gerät lesen
(`GET http://10.40.99.1/rest/interface/wifi/security`), **nie** aus `wlan-psks.env`.
- ⚠️ **CAPsMAN, für #2:** eine Profiländerung wirkt erst nach `remote-cap provision` für **beide**
APs — und die Abnahme ist eine **frische DHCP-Lease je SSID**, nicht die Registration-Table.
- ⚠️ **Für #2 zuerst `multi-passphrase` gegen WPA3/SAE testen — auf einer Wegwerf-SSID, nie an
`dirtyvader-iot`.** `sec-iot` fährt `wpa2-psk,wpa3-psk`; trägt multi-passphrase SAE nicht, sperrt
man **26 IoT-Geräte auf einmal** aus, inklusive Nuki und NSPanel.
- ⚠️ **`s1`/`s2`/`s3` sind ausgesteckt** und kommen im Dezember mit dem **alten** PSK zurück — der
Übergangs-Eintrag muss sie überleben oder sie werden bei Rückkehr von Hand nachgezogen.
- 🔴 **HA- und Repo-Nummerierung der Shellys weichen ab** (`s10`, `s17`) — **immer über MAC/IP
zuordnen, nie über die s-Nummer.**
- **Abh.:** keine harten; #2 und #3 laufen sinnvollerweise in **einem** Geräte-Durchgang.
**Blockiert nichts.** Die frühere Kopplung „erst TP17.1, dann rotieren" ist **gegenstandslos** —
TP17.1 ist am 2026-08-23 verworfen, die Geräte bleiben WLAN-Clients.
<a id="tp31"></a>
### TP31 · Gitea #21 — Reverse Proxy für innen und außen; cloudflared von sechs Knoten auf einen
> ✅ **Durch — Schritt 5+6 sind über den Piloten [TP16.3](#tp163) miterledigt (2026-09-01):** `bf-public`
> **healthy, 12 Connectoren** auf ap1/ap2/ap3; `vw`/`hass`/`n8n`/`gitea` laufen über CF → **Caddy**
> (`https://10.40.20.40:443`), die drei Alttunnel `bf-nas`/`bf-pve`/`bf-mm` **gelöscht**. Damit steht das
> Zielbild: **eine** Host→Origin-SoT im `Caddyfile`, öffentlich nur vier Namen. Abnahme-Tool
> [`caddy/cf-sync.py`](../caddy/cf-sync.py) **grün** (2026-09-02: die vier geflippten Namen wurden vorher
> als Fehlalarm gemeldet — das Tool prüfte nur `http://`, gebaut ist `https://…:443`; Einzeiler-Fix in `cf-sync.py`).
> 📎 **Überholt:** Schritt-6-Teil „MCP-Clients auf FQDN umstellen" ist durch **TP35 Schicht 1**
> **andersherum** entschieden (FQDN→LAN-IP). 🧹 Rest-Kleinkram (verwaistes `grafana`-Zertifikat) läuft
> mit dem Grafana-Wiederaufbau nach TP32.
> **Auslöser (User 2026-08-24):** „ich weigere mich, für public applications 2 cloudflare container zu
> installieren. … ich habe [den Core] unter der prämisse gekauft, dass über diesen dann auch die public
> apps laufen können. wir wollten doch den core als single point of failure platzieren."
#### Der Vorwurf, fair geprüft
Eine schriftliche Zusage, dass die Public-Apps auf den Core wandern, existiert **nicht** —
[TP16.1](teilprojekte-archiv.md#tp16-1) ist durchgehend auf den *privaten* Zugang zugeschnitten und hält
mehrfach fest, dass „der cloudflared-Public-Teil unberührt blieb". **Aber der Vorwurf trifft trotzdem
etwas Richtiges:** dasselbe Dokument begründet die gesamte Auswahl mit **Christophs** Prinzip —
*„Router = einziger SPOF, keine Abhängigkeit zu anderen Rechnern"* — und **niemand hat je geprüft, ob der
öffentliche Strang ohne sechs Container auskommt.** Keine Täuschung, aber eine **ungeprüfte Annahme**.
#### Die Lösung ist nicht „raus aus Cloudflare", sondern „ein Knoten statt sechs"
**Caddy** als Reverse Proxy für **innen und außen**, `emsenhuber.net` in beiden Richtungen, plus **ein**
cloudflared als Tunnel-Endpunkt.
- ⚖️ **Erst geprüft, dann verworfen: der Weg über `dst-nat`.** Der Core hält `90.146.33.177` direkt ohne
CGNAT, Liwest lässt eingehend alles durch (User: „hatte früher schon nginx hier laufen"), und
Origin Rules (Ziel-Port, **Free = 10 Regeln**), die CF-HTTPS-Ports (`443, 2053, 2083, 2087, 2096, 8443`)
sowie Authenticated Origin Pulls sind alle im Free-Tarif enthalten. Es **ginge** also ohne jeden Container.
- 🎯 **Christophs Einwand ist der bessere Weg:** *„caddy muss so oder so. nachdem dieser vorhanden ist,
fahren wir natürlich über zero trust apps. noch etwas sicherer, aber kein zusätzlicher knotenpunkt,
der ausfallen kann."* Der Tunnel spart drei Risiken auf einmal: **keine angreifbare Origin-IP, keine
CF-IP-Allowlist, kein DDNS als Voraussetzung** (der TP10-Updater bliebe sonst zwingend).
#### Zielbild
```
Internet ─► Cloudflare (Access, WAF) ─► Tunnel »bf-public« ─► cloudflared auf ap1/ap2
WARP ─► cf-mesh (nativ am Core, kein Container) ─┐
LAN ─► Split-Horizon-DNS ───────────────────────┴─► Caddy (LXC 130, 10.40.20.40)
└─► alle Dienste per Host-Header
```
**Öffentlich bleiben vier:** `vw` (Bitwarden-Clients), `hass` (Companion-App), `n8n` (Webhooks Dritter),
`gitea` (Access **nur für die Web-UI**, Git-/API-Pfade daran vorbei ⇒ **Push ohne WARP bleibt**).
Alles andere ist intern; **19 Ingress-Zeilen über drei Tunnel werden EINE Catch-all-Regel auf Caddy.**
#### Wo cloudflared läuft — und warum die APs (live gemessen 2026-08-24/25)
| Gerät | Arch | Flash frei | RAM frei | Container? |
|---|---|---|---|---|
| **ap1 / ap2** (hAP ax²) | **arm64** | **95 MB / 128 MB** | **690 MB / 1 GB** | ✅ ja |
| **crs326-core** | arm **32-bit** | **2,9 MB / 16 MB** | 512 MB | ❌ **nie** |
- ✅ **Christophs Argument, das den Ausschlag gibt:** *„cf auf den aps hat den vorteil, dass wir gegenüber
unseren hosts/container nicht blind sind."* — Der AP ist eine **eigene Maschine**, kein Docker-Host;
die macvlan/ipvlan-Blindheit kann dort **prinzipiell** nicht auftreten. Kein anderer Ort hat das.
💡 Daraus folgt eine Reserve: fällt Caddy aus, kann derselbe Connector über eine zweite Ingress-Regel
**direkt** auf ein, zwei kritische Backends zeigen.
- 🔴 **Ehrliche Einschränkung:** cloudflareds einziges Origin ist Caddy. Solange Caddy auf pve liegt,
kauft ein Connector auf dem AP **Hardware-Diversität, aber keine Verfügbarkeit** — fällt pve aus, ist
alles weg. Der echte Gewinn kommt erst mit `caddy-bf2` (LXC 131).
- **Caddy oder AGH zusätzlich auf die APs? Nein.** 95 MB minus RouterOS-Reserve fürs eigene `.npk`
(~20 MB) = **ein Container pro AP**. Kriterium, das auch künftig trägt: **auf die Infrastruktur darf,
was sich selten ändert und wenig schreibt** — cloudflared ja (Config liegt in Cloudflare, schreibt
nichts); **Caddy nein** (Caddyfile ändert sich bei jedem neuen Dienst); **AGH nein** (Query-Log
schreibt dauernd → NAND-Verschleiß).
- ⚠️ **Preis:** `container` verlangt eine **`device-mode`-Änderung mit physischer Bestätigung** am Gerät
(ap1 Technik UG, ap2 Technik OG). Und der AP bekommt eine zweite Rolle neben dem WLAN.
#### Umsetzung
1. ✅ **ERLEDIGT 2026-08-25 — Leichen gelöscht:** `120 adguard-bf1`, `121 adguard-bf2`,
`128 nginx-proxy-manager`, `129 caddy`, `130 pihole1`, `131 pihole2` — alle waren `stopped`,
`onboot: 0` und hingen konfigurativ noch am **toten VLAN1** (`10.40.41.116/.117`, GW `.41.254`).
`pct destroy --purge` hat `120` und `128` zugleich aus dem Backup-Job entfernt.
**Alle 72 PBS-Snapshots radiert** — sieben davon waren in PBS als **`protected`** markiert und
mussten erst über `pvesh set …/content/<volid> --protected 0` entschützt werden
(User-Freigabe 2026-08-25). Gegenprobe: **0 Snapshots** zu allen sechs IDs.
⚠️ **Merkregel:** `pvesm free` scheitert an geschützten Snapshots mit *„cannot remove protected
snapshot"* — und zwar **stillschweigend**, wenn man den Fehler nicht ausliest. Wer nur die
Erfolgszähler betrachtet, hält ein halb erledigtes Aufräumen für fertig.
2. ✅ **ERLEDIGT 2026-08-25 — LXC 130 `caddy-bf1` steht:** Debian 13, VLAN20 `tag=20`,
**`10.40.20.40/24`**, GW/DNS `10.40.20.1`, **`onboot: 1`**, **`startup order=10`** (Core-Stufe
nach TP32), 4 GB / 512 MB, 2 Cores. In den Backup-Job `backup-6dcf5c10-610f` aufgenommen.
**Caddy v2.11.4** installiert, Modul **`dns.providers.cloudflare`** verifiziert vorhanden.
Egress geprüft (DNS, HTTPS, CF-API) — TP5 lässt VLAN20 nach draußen.
3. ✅ **ERLEDIGT 2026-08-25 — Token da, `deploy.sh` durchgelaufen, Proxy verifiziert.**
**`caddy/Caddyfile` im Repo** (17 Sites: 4 öffentlich `vw`/`hass`/`n8n`/`gitea`, 13 intern inkl.
der vier MCP-Endpunkte und drei HTTPS-Backends mit `tls_insecure_skip_verify`), Syntaxprüfung
läuft durch. Zertifikate ausschließlich per **DNS-01**, damit auch rein interne Namen gültige
Zertifikate bekommen.
🔑 **Das eng geschnittene Token liegt** — `Zone:DNS:Edit`, Zone-Resource auf `emsenhuber.net`
begrenzt, **nicht** das account-weite `CF_API_TOKEN` (Fingerprint gegengeprüft). Ablage als
`CF_DNS_API_TOKEN` in `~/.secrets/credentials.env`, Ausrollen per **`caddy/deploy.sh`** — das
liest den Wert von dort und schreibt ihn in die Container-Env, **ohne ihn je auf stdout oder in
eine Kommandozeile zu bringen** (dieselbe Regel wie `sshpass -e`).
4. 📋 **Split-Horizon → ENTSCHIEDEN 2026-08-25: in AdGuard, zusammen mit TP23 Stufe 2.**
➡️ **Übergabe steht (2. Fassung, 08-25 spät):**
[`handoff-20260825-agh-dns-cutover.md`](handoff-20260825-agh-dns-cutover.md) — mit drei live
erhobenen Funden, die den ersten Plan korrigieren: **(a)** der Bestands-AGH `10.40.20.16` ist ein
**Archiv, keine Vorlage** (24 Rewrites: 8 ins tote VLAN1, 9 auf den heute zerstörten NPM, 2
FritzBox-Reste) ⇒ Upstreams ja, Rewrites neu; **(b)** `10.40.20.17` ist **`adguard_fm2`**, gehört
FM und antwortet ebenfalls ins tote VLAN1 — BF hat heute **einen** AGH, keine Redundanz;
**(c)** die TP5-Vorratsregeln zeigen hart auf `10.40.20.16` — nach dem DHCP-Flip wird DNS zu einem
**`forward`-Fluss über VLAN-Grenzen**, ohne erweiterte Regeln verlieren IOT/MEDIA/UNTRUSTED/GUEST/MGMT
die Auflösung, während VLAN20 unauffällig weiterläuft. Dazu: `lease-time=1d` macht Flip **und**
Rollback träge, Rückfallebene bleibt der Core.
Begruendung des Users: nichts zweimal bauen. **Konsequenz, die man kennen muss:** AdGuard wird
damit vom Nebendarsteller zum **Resolver des Standorts** — heute liefert der DHCP in **allen
sieben VLANs** `dns-server = <VLAN>.1`, also den Core; AGH auf `10.40.20.16` bekommt niemand.
⇒ Der Schritt ist groesser als „ein paar Eintraege": er verschiebt die DNS-Aufloesung des ganzen
Hauses vom grundsoliden Core in einen Container.
🔑 **Deshalb als Paar bauen** — TP32 reserviert `110`/`111` fuer DNS, und die geloeschten
Altlasten hiessen nicht zufaellig `adguard-bf1`/`adguard-bf2`: die Redundanz war urspruenglich
schon einmal geplant. **Ein einzelner AGH waere ein DNS-SPOF auf pve.**
5. ✅ **ERLEDIGT via [TP16.3](#tp163) §5 (2026-09-01) — Tunnel `bf-public`** + Connectoren auf ap1/ap2/ap3
(12 conns healthy), CNAMEs geflippt (`n8n`/`gitea`/`hass`/`vw` → CF → Caddy `https://10.40.20.40:443`).
Je Name einzeln geflippt, dazwischen gemessen; Antwortzeiten vorher/nachher unverändert.
6. ✅ **ERLEDIGT via [TP16.3](#tp163) §6 (2026-09-01) — Abbau:** `bf-nas`/`bf-pve`/`bf-mm` gelöscht,
7 cloudflared-Container weg. 🔴 **Ausnahme, bewusst umgekehrt:** der Teil „**MCP-Clients auf FQDN
umstellen**" ist durch **TP35 Schicht 1** **andersherum** entschieden — `gitea-mcp`/`n8n-mcp` sprechen
heute die **LAN-IP** (`10.40.20.167:3000`/`:3001`), nicht den FQDN. Der ursprüngliche Plan ist damit
gegenstandslos, nicht offen.
#### ✅ Stand 2026-08-25 nach dem Bau — Caddy laeuft, zwei Backends fielen dabei auf
**17 von 17 Zertifikaten** per DNS-01 von Let's Encrypt geholt, Dienst `active`+`enabled`,
Proxy-Funktion gegen den Host-Header verifiziert: `nautobot` 302 · `gitea` 200 · `pve` 200 ·
`portainer` 200. `caddy/deploy.sh` laeuft End-to-End (Token wird aus `~/.secrets` gelesen, per
Fingerprint gegengeprueft: **eigenes Token, nicht das account-weite**).
🔴 **Fund: `grafana` war seit Langem tot — und Cloudflare Access hat es maskiert.**
LXC 104 `alpine-grafana` stand auf `stopped` und konfigurativ noch auf **VLAN1**
(`10.40.41.109`); die CF-Ingress-Zeile zeigte auf `10.40.20.109:3000` — eine Adresse, die es fuer
Grafana **nie gab**. Im FQDN-Smoke-Test lieferte `grafana.emsenhuber.net` trotzdem ein sauberes
**302** — das war der **Access-Login**, nicht der Dienst.
⚠️ **Das ist exakt die Falle aus TP16.3 §8, diesmal in freier Wildbahn:** ein Statuscode vom
oeffentlichen FQDN sagt nichts ueber das Origin, solange Access davor steht. **Ein toter Dienst
sieht monatelang gesund aus.** ⇒ Caddy-Zeile entfernt (16 Sites), **LXC 104 zerstoert** und aus dem
Backup-Job genommen. ~~Die 14 PBS-Snapshots bleiben bewusst stehen.~~ **Am 2026-08-25 auf
User-Entscheid ebenfalls weg** — zusammen mit den vier gestoppten Gaesten `103 minarca`,
`109 node-red`, `115 hms-mqtt-publisher` und `201 openproject` (28 Snapshots). **Damit laeuft auf
pve jeder existierende Gast**, 11 LXC + 2 VMs, kein `stopped` mehr. ⚠️ **Snapshot-Liste vor dem
`destroy` einsammeln** — `pvesm list --vmid` meldet danach 0, obwohl die Backups noch liegen** — direkt gegen den Datastore gegengeprueft (`ct/104` = 0), nicht nur
gegen `pvesm list --vmid`, dessen Filter nach dem `destroy` nicht mehr aussagekraeftig ist. Wiederaufbau spaeter als eigener Punkt, dann nach TP32-Systematik.
#### ✅ Abnahme 2026-08-25 (spaet) — 16 von 16 Sites gegen den Host-Header gemessen
Dienst `active`+`enabled`, Probe von einem **zweiten Host** (pve, VLAN20) mit
`curl --resolve <fqdn>:443:10.40.20.40`, also ueber den echten Namen statt ueber die IP:
**11x 200** (`vw` `n8n` `gitea` `ds-bf` `ccu` `iobroker` `z2m25` `n8n-mcp` `pve` `portainer` `pbs`),
`nautobot` **302** (Login-Redirect, erwartet), Antwortzeiten 0,02–0,11 s.
- ✅ **Die drei „Fehlercodes" der MCP-Endpunkte sind KEINE:** `pve-mcp` 400, `ha-mcp` 403,
`gitea-mcp` 404 kommen **identisch auch direkt vom Origin** (gegengemessen ohne Caddy). Es sind die
Antworten der MCP-Server auf ein nacktes `GET /` — der Proxy reicht sie unveraendert durch.
⇒ **Merkregel: einen Statuscode nie ohne Gegenprobe am Origin als Proxy-Fehler lesen** — sonst
jagt man einen Fehler, der keiner ist (dieselbe Klasse wie der `grafana`-Fall eine Zeile hoeher,
nur andersherum).
- 🧹 **Rest: ein verwaistes Zertifikat.** Der Store haelt **17** Zertifikate, die Config nur 16 Sites —
`grafana.emsenhuber.net` liegt noch da. Harmlos (Caddy erneuert es nicht mehr), beim
Grafana-Wiederaufbau nach TP32 mit aufraeumen.
✅ **`hass` liefert 200 — gelöst 2026-08-26, und die Ursache ist eine Falle für jeden Reverse Proxy.**
Der `http:`-Block in `configuration.yaml` war **wirkungslos**: Home Assistant hat die http-Konfiguration
nach **`.storage/http`** migriert (`"yaml_migration_done": true`, `created_at` 2026-08-05). Seither wird
der YAML-Block **ignoriert** — ohne Fehlermeldung.
- 🔎 **Warum das so schwer zu sehen war:** die Logzeile lautet `Received X-Forwarded-For header from an
untrusted proxy 10.40.20.40` und **nicht** „not set-up for reverse proxies". Laut Quelltext fällt die
erste nur bei **aktivem** `use_x_forwarded_for` — was zur Annahme führt, die Option greife und nur die
Liste sei falsch. Tatsächlich kam beides aus `.storage`: `use_x_forwarded_for: true` stand dort längst,
die Liste enthielt `.20.10`, `.20.126`, `.20.167` (die drei cloudflared-Origins) — nur Caddy fehlte.
- 🧪 **Was den Verdacht in die Irre führte, der Reihe nach ausgeschlossen:** Datei-Identität (ein
Testskript in `python_scripts/` tauchte sofort als Service auf ⇒ HA liest dieses Verzeichnis),
Blockposition (nach ganz oben verschoben — keine Änderung), Kommentare/Formatierung (minimaler Block —
keine Änderung), Prozesszeitpunkt (`.ha_run.lock` `start_ts` gegen die Datei-`mtime` gestellt) und
Schema-Validierung (ein absichtlich ungültiger Schlüssel im `http:`-Block erzeugte **keinen**
Config-Fehler — im Nachhinein der eigentliche Beweis).
- ✅ **Der vorgesehene Weg ist ein WS-Zweischritt mit Auto-Revert:** `http/config` lesen →
`http/config/configure` mit der **vollständigen** Config (setzt `pending` und **startet HA neu**) →
innerhalb von **5 Minuten** `http/config/promote`, sonst fällt HA automatisch auf `stable` zurück.
⚠️ Der Promote ist knapp: HA braucht hier ~2 min bis zur Erreichbarkeit, und der WS-Weg über das
MCP-Add-on antwortet währenddessen mit 502. **Erst promoten, wenn die Wirkung gemessen ist** — genau
dafür ist das Fenster da.
- 🧹 In `configuration.yaml` steht jetzt ein Hinweis-Kommentar statt eines wirkungslosen Blocks.
- ✅ **Trusted-Liste aufgeräumt (User, 2026-08-26):** `10.40.41.10/32` — die alte VLAN1-NAS — ist
entfernt und der Stand als `stable` bestätigt. Vertraut wird jetzt genau dem, was auch wirklich
proxyt: `10.40.20.40` (Caddy) sowie `.20.10`/`.20.126`/`.20.167` (die cloudflared-Origins, die mit
Schritt 6 wegfallen), dazu Loopback und `172.16.0.123`. Abnahme: `hass`/`vw`/`gitea`/`n8n` je 200.
#### 🧭 Wo liegt die Source of Truth? — beantwortet 2026-08-26
> **User-Frage:** „schaffen wir es mit überschaubarem aufwand cf und caddy synchron zu halten? wo
> liegt die sot?"
**Antwort: gar nicht synchron halten — die Doppelpflege fällt mit Schritt 5/6 von selbst weg.**
Heute steht dieselbe Zuordnung *Name → Origin* an **zwei** Orten: im CF-Ingress (19 Regeln über drei
bf-Tunnel) und im `caddy/Caddyfile` (16 Sites). Der Vergleich am 08-26 zeigt: **sie stimmen fachlich
überein** — jeder gemeinsame Name zeigt auf **dasselbe** Origin. Es gibt also nichts zu versöhnen,
nur etwas abzuschaffen.
Im Zielbild hat der Tunnel `bf-public` **eine einzige Catch-all-Regel auf `10.40.20.40`**. Damit hält
Cloudflare **keine** Host→Origin-Zuordnung mehr, und die SoT ist eindeutig:
| Was | SoT | wo gepflegt |
|---|---|---|
| Name → Origin (alle Dienste) | **`caddy/Caddyfile`** (Git) | dieses Repo |
| Welcher Name ist öffentlich | **CNAME + Access-App** (4 Namen) | Cloudflare |
| Name → IP **intern** | **AdGuard-Rewrites** (TP23 Stufe 2) | AGH-Paar |
🔴 **Standortsuffix als Regel (User-Entscheid 2026-08-26).** Hosts, die es an mehreren Standorten gibt,
heißen **nie ohne Suffix** (`nas.emsenhuber.net` ist die Falle) — Schema ist
`<host>.<site>.emsenhuber.net` (`nas.bf.…`, `nas.fm.…`, `nas.dw.…`). Caddy bleibt **pro Standort
eigenständig** (BF-Caddy kennt `ds-bf`, FM-Caddy kennt `ds-fm`); die AdGuard-Instanzen je Site halten
die lokale `<host>.<site>`-Auflösung, und ein **gemeinsamer** Name ohne Suffix existiert nicht.
**Konsequenz für `nas.emsenhuber.net`: gelöscht** — weder AGH-Rewrite, noch CF-CNAME (gab es nie),
noch Caddyfile-Eintrag (das hatte von Anfang an `ds-bf`). Der Rewrite war eine AGH-Insel ohne
Auflöser dahinter, der Cutover ändert für **niemanden** etwas an der heutigen Erreichbarkeit.
**Andere namensgleiche Hosts** (`macmini`/`mfc`/`tv`) stehen heute noch ohne Suffix in AGH —
Bewertung einzeln in der TP23-Stufe-2-Session; hier nur festgehalten, damit das Muster ab jetzt
nicht mehr ohne Suffix entsteht.
⇒ Zu pflegen bleiben **vier** öffentliche Namen statt neunzehn Ingress-Regeln. Und die zwölf internen
Namen brauchen dann **gar keinen öffentlichen DNS-Eintrag mehr** — heute ist z. B. `pve.emsenhuber.net`
öffentlich auflösbar (hinter Access), danach ist es von außen schlicht unbekannt. **Das ist ein
Sicherheitsgewinn, kein Nebeneffekt.**
🛠️ **Werkzeug dafür: [`caddy/cf-sync.py`](../caddy/cf-sync.py)** — read-only, liest CF-Ingress, Zonen-DNS
und Access-Apps und stellt sie gegen den Caddyfile; Exit 1 bei Drift (cron-/CI-tauglich). Erster Lauf
2026-08-26 fand **7 echte Abweichungen**, alle in Cloudflare, keine in Caddy:
- **`grafana` steht noch im Ingress** (`bf-nas → 10.40.20.109:3000`) — ein Origin, das es nie gab.
- **`nautobot` hat noch seinen CNAME**, aber seit TP16.3 keine Ingress-Regel ⇒ läuft in die Catch-all;
**das** ist die Quelle des 302, das man leicht für „lebt noch" hält. Dasselbe bei `gateway-dw`.
- **Drei `*.bf`-CNAMEs zeigen auf `nginx.bf`** — den heute zerstörten NPM (`phpmyadmin`, `speedtest`,
`z2m25`).
- Eine **Wildcard-Access-App `*.emsenhuber.net`** deckt die ganze Zone ab — sie ist der Grund, warum
jeder FQDN ein sauberes 302 liefert, auch wenn dahinter nichts läuft.
🔑 **Merkregel daraus:** *ein Eintrag ohne Gegenstück ist unsichtbar, solange etwas davor antwortet.*
Der Drift-Check muss deshalb **beide** Richtungen prüfen — Ingress ohne Site **und** CNAME ohne Ingress.
#### HA-Ausblick (nicht Teil der Umsetzung)
**Es bleibt bei Caddy** — zustandslos bis auf den Zertifikatsspeicher, identisches Caddyfile je Instanz,
versionierbar; NPM hätte eine Datenbank, die man mitreplizieren müsste.
🔴 **Prinzip für `vw`: der Eingang darf nicht klüger sein als die Datenbank.** [TP20.1](#tp20-1) sieht
**manuelles Promote** vor. Zwei Connectoren auf **verschiedene** Caddys an verschiedenen Standorten
würden die Hälfte der **Schreib**zugriffe auf einen read-only Standby schicken — das #1033-Muster,
diesmal mit Datenverlust statt 530ern. **Automatisches Ingress-Failover erst, wenn der DB-Teil es trägt**
(User: vertagt bis TP20.1 Phase 1+2 laufen).
- **Abh.:** [TP32](#tp32) (Nummern/IPs — vergibt `130`/`.40`), TP16.1 ✅ (Mesh), **TP23 Stufe 2**
(Split-Horizon-Ort). **Löst ab:** TP16 (reziprokes HA) und **TP16.2** (der `vw`-bypass-Strang bekommt
mit Caddy + CF davor einen anderen Schutz). **TP16.3 ist der Pilot dieses TP.**
- 📋 **Vollständiger Plan mit allen Abwägungen:** `~/.claude/plans/expressive-pondering-clarke.md`
<a id="tp32"></a>
### TP32 · Gitea #22 — Nummern- und Adress-Systematik
> **User-Auftrag 2026-08-24:** „du/ich sollst in der lage sein, selbstständig nach einem vorgegebenen
> system freie ips vergeben zu können … und diese systematik findet sich in anderen netzen künftig auch
> wieder" · „ich meine 100 sollte künftig für die core services stehen."
>
> **Eigener TP, weil es eine Konvention ist, kein Bauteil** — TP31 ist ihr erster Kunde.
> **Regel: Bestand bleibt, wo er ist; jeder NEUE Gast folgt der Systematik.**
🔑 **Ein gemeinsamer VMID-Raum für VMs *und* LXCs.** `100 haos` und `330 RaspberryMatic` sind **VMs** —
die Klasse bestimmt die Nummer, nicht die Virtualisierungsart.
| Kreis | Klasse | heute dort (VM *kursiv*) |
|---|---|---|
| **100–199** | **Core / Infrastruktur** | `108 mcast-relay` |
| **200–299** | **Daten / DBs** (Precious-Tier) | *(`105`, `106`, `107`, `302` → später hierher)* |
| **300–399** | **Security / Identity** | — |
| **400–499** | **Platform / Runtime** | ✅ `400 docker` passt bereits |
| **500–599** | **App-Services / Smart Home** | `101`, `102`, `112`, `150`, *`100`*, *`330`* → später hierher |
| **600–699** | **Business / ERP** | `201 openproject` |
| **700–799** | **KI / ML** | — |
| **800–899** | **Dev / R&D / Test** | — |
| **900–999** | **Ephemeral / Scratch** | wird ungefragt gelöscht |
**Zehnerschritte innerhalb eines Kreises** („Respektabstand", User), damit Instanzen und Nachbarn Platz
haben: `100–109` netznah · **`110–119` DNS** · **`130–139` Reverse Proxy** · `150–159` Monitoring ·
`170–179` PKI/Zeit. ⇒ **AGH `110`, Caddy `130`.**
**Letztes Oktett je /24 — gilt in jedem VLAN, an jedem Standort:**
| Oktett | Rolle | Ableitung aus TP32 | Anker |
|---|---|---|---|
| `.1–.9` | Gateways / L3 | Bestand | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
| `.10–.19` | physische Netzhardware im VLAN | APs/Switches, sobald sie im VLAN liegen | heute nur VLAN99: `99.11/.12/.13` ✅ · VLAN30: Nuki-Bridge, NSPanelPro, P750W, DTUBI |
| `.20–.29` | Core / netznah | Kreis 100–199, Stufe `10x` | mcast-relay (heute `10.21`/`40.21` — **kein VLAN20-Bein**) |
| `.30–.39` | DNS / Resolver | Stufe `11x` | AGH-Paar — **Soll** `.30`/`.31`, Ist `.36`/`.37` (bis Nautobot dort weg ist) |
| `.40–.49` | Ingress / Reverse-Proxy | Stufe `13x` | caddy `20.40` ✅ |
| `.50–.59` | Datenbanken | Kreis 200–299 | Soll `platform-pg .50`, `mariadb .51`, `influxdb2 .52`, `redis .53` |
| `.60–.69` | Security / Identity | Kreis 300–399 | Soll `vaultwarden .60` |
| `.70–.79` | Monitoring | Stufe `15x` | frei |
| `.80–.89` | Platform / Runtime | Kreis 400–499 | Soll `docker .80`, `coolify .81` |
| `.90–.99` | Broker / Funk | **Ordnungsgruppe 40** (s. u.) — *nicht* aus dem VMID-Kreis abgeleitet | Soll `mqtt5 .90`, `z2m25 .91` |
| `.100–.149` | **Server-Hosts** (physische Bleche) | `.10x` Reserve · `.11x` Storage · `.12x` Hypervisor · `.13x` Bleche/Workstation/Backup | Soll `nas .110`, `pve2 .120` (reserviert), `pve .121`, `macmini .130`; Ist `pbs 20.135` ✅ |
| `.150–.199` | App-Services / Gäste | Kreis 500–599 | `gitea 20.157` ✅, `pve-mcp .173` ✅, `shelly-mcp .180` ✅ |
| `.200–.249` | DHCP-Pool | Bestand | VLAN20 soll `.200–.249` (Ist `.100–.200`, `used=0`) |
| `.250–.254` | VIPs / Relays | Bestand | |
🔑 **Der Merk-Trick: die Zehnergruppe im Oktett spiegelt die der VMID.** `110 agh` → `.3x`,
`130 caddy` → `.4x`. Wer eine Zahl kennt, kennt die Klasse der anderen.
⚠️ **Kein Rechenweg — eine Nachschlagetabelle.** Die Zeile `.90–.99` (Broker/Funk) ist die
**Ausnahme**: sie stammt nicht aus dem VMID-Kreis, sondern aus der Ordnungsgruppen-Tabelle unten
(Stufe 40). **Wo die beiden Achsen auseinanderlaufen, gilt die Gruppe, nicht die Ziffer**
(Christoph 2026-09-21). Dasselbe Muster bei `.100–.149`: die Server-Hosts werden über den
**Gerätetyp** einsortiert (Blech), nicht über eine VMID.
⚠️ **Solange eine Alt-IP als Zweitadresse weiterläuft, ist sie reserviert** — `.10` (nas),
`.100` (pve), `.167` (macmini) dürfen bis zum Rückbau **nicht** neu vergeben werden. Deshalb
bekommt z. B. `z2m25` **nicht** `.167`, sondern `.91`.
#### Warum der Bestand anders aussieht — er war nicht falsch, nur auf einer anderen Achse
**User 2026-08-25:** „zuerst war haos, welches mit 100 den nummernkreis für iot eröffnet hat. und die
anderen … gehörten zu ha, daher 1xx. erst später habe ich erfahren, dass die nummer die
startreihenfolge definiert."
⇒ Der Bestand gruppiert nach **Zugehörigkeit**, die neue Systematik nach **Klasse**. Beide Achsen sind
legitim; die zweite ist die, die das System auswertet. Der alte `1xx`-Block ist der **Smart-Home-Block**
und wandert später als Gruppe nach `5xx`.
#### ✅ ERLEDIGT 2026-08-25: die Startreihenfolge war nachweislich falsch
🔴 **Und der Hebel ist NICHT die VMID.** Drei Gäste hatten ein explizites `startup: order=`
(`101 iob-master` 110, `102 mqtt5` 120, `100 haos` 125), alle übrigen keines. **Proxmox startet erst
alle Gäste mit `order=`, dann den Rest nach VMID** — real lief also `iob-master → mqtt5 → haos` und
**erst danach** `platform-pg`, `mariadb`, `z2m25`, `docker`.
⇒ **Home Assistant startete vor seiner eigenen Datenbank.**
**Repariert (wirkt beim nächsten pve-Neustart):**
| `order=` | Stufe | Gäste |
|---|---|---|
| 10 | Core | `108 mcast-relay` |
| 20 | Daten | `105 platform-pg` (`up=15`), `302 mariadb` (`up=15`), `106 influxdb2`, `107 redis` |
| 30 | Platform | `400 docker` (`up=20`) |
| 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25` |
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed`, `330 RaspberryMatic` |
🔴 **Korrektur 2026-09-21 (Christoph):** die CCU gehörte **nie** in die Funk-Gruppe. „Funk" ist
`102`/`112` — die CCU läuft in der **Anwendungs-Gruppe**, in derselben wie `haos`. Live stand sie
noch auf `order=40`; der Geräte-Write ist Teil desselben Zuges (nicht nur diese Doku-Zeile — eine
Doku, die dem Gerät vorauseilt, ist genau der Drift, den TP32 vermeiden soll).
🎯 **Die wichtigste Erkenntnis des TP: VMID = Klassifikation, `startup: order=` = Abhängigkeitsreihenfolge.**
Die Umnummerierung muss die Startreihenfolge gar nicht reparieren — das ist bereits erledigt, ohne eine
einzige ID zu ändern. **Die Nummernkreise sind damit reine Ordnung und Lesbarkeit**, und der Bestand
kann in Ruhe nachziehen.
- **Offen:** Migration des Bestands (Tabelle oben) — **läuft jetzt an**, nicht mehr „am Ende der
Reise": siehe **TP45** (VLAN20 + VLAN30 ins Schema, danach DNS/FQDN). Die alte Kollision
**AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker, `.32` = scheduler, `.34` = redis)
ist dort **Schritt 1**: Nautobot zieht in den App-Block (`20.154–.156`), **erst danach** greift
AGH `.30`/`.31`. Reihenfolge bewusst so — der GC-Pfad läuft über Nautobot, ein Umzug von AGH zuerst
würde die Werkzeugkette gegen sich selbst laufen lassen.
- **Abh.:** keine. **Blockiert nichts**, aber TP31 vergibt bereits danach.
#### 🔎 Bestands-Fehler, die beim Umsetzen auffielen (2026-09-21, live gemessen)
Die frühere Fassung dieser Tabelle war an vier Stellen **nicht gemessen, sondern angenommen**:
`mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`), der Anker der Zeile
`.20–.29` war damit falsch; `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), nicht drei; und der
**DHCP-Pool in VLAN30 enthält ~25 Statics** (`.202–.249`) — die VLAN30-Nummerierung war ebenfalls
gewachsen, nicht geplant. Merkregel: **„Bestand bleibt, wo er ist" gilt nur, solange niemand behauptet,
wo er ist, ohne nachzusehen.**
### TP33 · Gitea #23 — Sonos aus der Ferne bedienen (VPN/WARP) — angelegt 2026-08-25, User-Entscheid „später entscheiden")
- **Auslöser:** beim Sonos-Fall vom 08-25 kam die Frage auf, ob die Player auch **von unterwegs**
auffindbar sein sollen. Antwort für jetzt: **nein, später entscheiden** — hier nur festgehalten,
damit die Begründung nicht verloren geht.
- 🔑 **Der Kernsatz:** **SSDP und mDNS queren einen Tunnel nicht von selbst.** Beides ist link-lokal
(TTL 1 bzw. Multicast auf `224.0.0.251`), ein WireGuard-/WARP-Tunnel ist ein Punkt-zu-Punkt-Interface
ohne Broadcast-Domäne. Es bräuchte also **dieselbe Relay-Bauart wie TP7.1, nur ein zweites Mal** —
ein Bein im Tunnel, eines in VLAN40 — plus die passende `forward`-Regel (TP5 ist deny-by-default).
- ⚖️ **Warum zurückgestellt:** echter Zusatzaufwand für einen **Randfall**. Steuern aus der Ferne geht
heute schon über die **Sonos-Cloud-App** und über **HA** (`cf-mesh`/CF-Tunnel), beides ohne Discovery.
Fehlt nur die Desktop-App von außen — dafür lohnt der zweite Relay-Strang derzeit nicht.
- ⚠️ **Nicht mit dem Gegenteil verwechseln:** **mit aktivem WARP findet die App die Player auch lokal
nicht** — erwartbares Verhalten, kein Fehler (`ref_warp_local_icmp_trap`, Gotcha in `CLAUDE.md`).
Wer den Fernzugriff baut, muss genau diesen Fall mit auflösen.
- **Abh.:** TP7.1 (Relay-Muster) ✅, TP16.1 (`cf-mesh`) ✅. **Blockiert nichts.**
---
### TP35 · Gitea #25 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard — 5/6 Schichten durch (MCP-Schichtung FQDN→IP, Credentials-`{env:}`-Flip angewandt+verifiziert, globale CLAUDE.md-Schicht, `/news`, Backup-Guard, `/projektstatus`-Skill). Schicht-3-**pro-Projekt**-Rollout laut Abgrenzung eigene Session je Projekt (kein Blocker). Einziger Rest = On-Network-mikromcp-Smoketest (off-site nicht machbar, VLAN99:80 timeout, non-blocking) → als Merkposten nach TP30. Gitea #25 closed/`status:done`.
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
- **Scope:**
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. **✅ 2026-09-12 angewandt:** `exports.zsh` um die 3 fehlenden `MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS` ergänzt (jetzt 25 Vars, die opencode schon referenzierte — vorher liefen die dort leer), `gui-env-sync` gelaufen (25/25 in launchd-Env). `~/.claude.json` via `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude` (bei beendeter App, Backup `.bak-20260912-*`) geflippt — **alle 7 Secrets** (`mikromcp` 5× PASS, `hostinger-api` Token, `n8n-mcp` Bearer) jetzt `{env:}`, verifiziert; App neu gestartet, MCP-Server laden sauber. 🔴 **Merkregel:** `~/.claude.json` nie aus laufender Session editieren (Claude Code schreibt dort State → überschreibt). Restcheck offen: **authentifizierter** mikromcp-Read **on-network** (am 09-12 Connect-Timeout VLAN99:80 = Erreichbarkeit vom Standort, kein Auth-Fehler). Rollback = `.bak` zurück.
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort + Skill zum Einlesen beim Session-Start. **✅ 2026-09-12:** `docs/announcements.md` angelegt (Format: `## YYYY-MM-DD — Titel` + `Betrifft:`/`Aktion:`, neueste oben) + globaler Skill **`/news`** (in beiden Skill-Pfaden `~/.claude/skills/news/` **und** `~/.config/opencode/skills/news/`, `cp` kein Symlink — Schicht-6-Lehre). `/news` zeigt Einträge, die seit dem letzten Lauf dazukamen (Seen-State pro Repo unter `~/.news-seen/`). Erste Einträge: SSoT-Cutover (TP36) + TP36-Live.
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. **✅ 2026-09-12 abgenommen:** Guard (112 Z., `check`/`mark`/`status`) + `.git/hooks/pre-commit` in `bf-infra` live verdrahtet; der Hook hat beim TP36-Live-Commit real gegriffen („Projekt aktiv 12 Commits/198 LOC, Backup markiert"). ⚠️ **Bekannte Grenze:** `check` prüft nur *Existenz* der Guard-Datei, nicht deren Alter — ein alter `mark` (hier 2026-08-29) lässt den Commit durch. Das ist als Push-Erinnerung gewollt, keine Frische-Garantie.
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
- **Verzahnt mit:** TP30 (Credentials), TP34.2 (Daten→Config-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
- **Erste Session für Scope-Ausarbeitung:** Schichten 1–5 + Skill-Setup; Handover-Posts pro Projekt (Schicht 3 + 4 ergeben die Posts).
- **Referenzen:** Handoff 2026-08-28 (`docs/handoff-20260828-pre-opencode-migration.md`) · Backups `~/dev/.pre-opencode-migration-backups/20260828-150335/` · MCP-Inventur `bf-infra/mcp/INVENTORY.md` · Lessons `bf-infra/CLAUDE.md` Abschnitt „Lessons for Sessions" · `docs/handoff-20260828-mcp-bestand-und-performance.md` (MCP-Bestand-Korrektur + Mac-mini-Restriktion).
---
### TP36 · Gitea #26 — Index/Status als SSoT nach Gitea (Steuerung aus der branchbaren Datei herauslösen) — Index/Status = Gitea-Issues, Prosa bleibt Datei
- **Auslöser:** Skill-Run 2026-08-28 (`docs/handoff-20260828-session-a-projektstatus.md`) lieferte einen überholten „Nächster Schritt"-Output; **manuell gepflegte Doku mit Live-Daten verliert gegen die Realität, sobald sie lang wird.** Verschärft 2026-09-06: `docs/teilprojekte.md` wurde in **40/40** letzten Commits angefasst — jeder TP editierte Quick-Index **und** Ist-Stand-Block → **Merge-Konflikt-Magnet**, der Branch-pro-TP verhinderte (ein Parallel-Branch hatte so die TP37-Doppelvergabe verursacht).
- 🔄 **Reframe 2026-09-06 (Christoph):** von „SSoT via **Nautobot**" auf „SSoT via **Gitea**". Grund: Index+Status sind **kuratierte Steuerung** (Reihenfolge, Marker, Abhängigkeiten), kein Geräte-Live-Zustand — die gehören auf ein **Issue-Board**, nicht in Custom Fields. **Nautobot bleibt nur für Live-Gerätedaten** relevant (Compliance-Zahl, RouterOS-Version …) → als optionaler Rest unten, entkoppelt von diesem TP.
- **Modell (umgesetzt):** TP = **Gitea-Issue** in [`admin/bf-infra`](https://gitea.emsenhuber.net/admin/bf-infra/issues) · Status = Label `status:*` (todo/wip/paused/idea/concept/done/dropped) · Gruppierung = Label `thema:*`. Seeder `bin/gitea-tp-sync.py` (idempotent, Dry-Run default, `--commit` schreibt; Token aus `~/.hermes/.env`, **nie ausgeben**). Erstlauf: **68 Issues (25 open/43 closed) + 15 Labels**.
- ✅ **Erledigt (A1–A3):** Labels/Modell · 68 TPs → Issues · Board = Index (Label-Filter statt Handtabelle).
- ⏳ **Cutover 2026-09-07 (diese Session):**
- **A4** — Quick-Index-Tabelle + „Ist-Stand (Kurz)"-Block aus dieser Datei entfernt; Header zeigt aufs Gitea-Board; die vier **Genehmigten Ausnahmen** unter eigenem Governance-Header behalten. Alle `### TPnn`-Prosa bleibt.
- **A5** — `/projektstatus` liest Baum+Status aus der **Gitea-API** (Issues + `status:*`/`thema:*`), Transaktionen weiter aus `nautobot/gc-transaktion.py status`, Prosa-Detail bei Bedarf aus dem Repo. Beide Skill-Kopien (`~/.claude/skills/…` **und** `~/.config/opencode/skills/…`, `cp`, kein Symlink).
- **A6** — Projekt-`CLAUDE.md`: Pflichtlektüre = Gitea-Board (Status) + Prosa-Datei (Detail); Branch-Konvention (`tpNN-kurz` jederzeit, Merge nach `main` wenn Code/Config steht, Steuerungs-Updates nach Gitea); SSoT-Notiz.
- 🔜 **Optionaler Rest (entkoppelt, „TP36-Live"):** Live-Daten-CustomFields auf den **4 `dcim.Device`** (`ros_version`, `compliance_pct`, `dhcp_server_count`, `mgmt_ip`, `dns_upstream_mode`) + Snapshot-Skript `nautobot/gc-snapshot.py` (existiert noch nicht). Unabhängig von diesem Cutover; nur relevant, wenn die Doku Live-Zahlen tragen soll. ⚠️ **Namensfalle bereinigt:** `nautobot/tp36-cf-estate-seed.py` gehörte **nie** zu TP36 → seit 2026-09-06 `tp44-cf-estate-seed.py`.
- **Verzahnt mit:** TP35 (SSoT + Konsolidierung), TP44 (native Nautobot-Modelle), `bf-infra/CLAUDE.md`-Lessons. Plan: `~/.claude/plans/geht-das-los-auch-reflective-lark.md`.
<a id="tp37"></a>
### TP38 · Gitea #28 — Out-of-Band-Management: ein untagged-VLAN99-Port je Switch
*(Nummer 2026-08-31 von 37 auf 38 korrigiert und von Christoph bestätigt: TP37 war zeitgleich in
einem zweiten Branch für „Nautobot als Netzwerkdoku" vergeben worden.)*
> **Auslöser (User 2026-08-31, aus eigener Anschauung):** *„ich durfte beim Neustart vom core gerade
> erleben, dass ich nicht mehr auf den branch zum verbinden kam. weil das eben alles über den core
> läuft."*
#### Der Befund, live nachgemessen (2026-08-31)
Das Management des Standorts hat **einen einzigen Weg**, und der führt über den Core:
| | `crs326-core` | `crs326-branch` |
|---|---|---|
| Mgmt-IP | `10.40.99.1` (VLAN99-SVI) | `10.40.99.2` (`vlan99` auf der Bridge) |
| VLAN99 auf der Bridge | SVI + Trunks | **`tagged=ether1,ether24,bridge`**, `untagged=""` |
| Weg dorthin | direkt | **nur über `ether1` → core `ether8`** |
⇒ **`crs326-branch` ist ausschließlich durch den Core hindurch erreichbar.** Rebootet der Core, ist der
Branch für die Dauer weg — nicht weil er ausfällt, sondern weil der einzige Pfad zu ihm fehlt. Dasselbe
gilt für jeden Fehler, der den Core-Trunk oder dessen VLAN99-Konfiguration betrifft. **Wer den Core
zerkonfiguriert, verliert den Branch gleich mit** — und damit die Möglichkeit, es vom Branch aus zu
reparieren.
🔴 **Der zweite Befund ist der unangenehmere: `crs326-branch` hat KEINE `input`-Firewallregeln** (Kette
leer, live gelesen). Sein gesamter Schutz besteht heute darin, dass VLAN99 nur über den Core erreichbar
ist und **dessen** `forward`-Kette seit TP5 deny-by-default fährt. **Die Erreichbarkeitsbeschränkung
IST die Zugangskontrolle.** Das ist für die Lösung unten kein Detail, sondern die Hauptauflage.
#### Zielbild
**Je Switch ein Port, untagged in VLAN99** — ein Laptop direkt daran erreicht das Gerät ohne jeden
Umweg über den Core, ohne Trunk, ohne funktionierendes Routing.
```
Laptop ──(statische IP 10.40.99.x/24)── ether23 (untagged VLAN99) ── crs326-branch 10.40.99.2
^ funktioniert auch, wenn der Core steht
```
**Kandidat `ether23` auf beiden Geräten** — auf core **und** branch heute `running=false`, also frei,
und symmetrisch (ein Port, eine Nummer, an beiden Standorten dieselbe). *(Core hat reichlich freie
Ports; auf dem Branch sind `ether23` und die Reihe darunter frei, `ether24` ist ap2.)*
#### Umsetzung (Skizze — eigene Session)
1. **Port aus dem Trunk-Verhalten nehmen und in VLAN99 untaggen:**
`bridge port` mit `pvid=99` + `frame-types=admit-only-untagged-and-priority-tagged`,
dazu `ether23` als `untagged`-Member in die `bridge vlan`-Zeile für VLAN99.
2. **Kein DHCP dort** — der VLAN99-DHCP läuft auf dem Core, und genau der soll ja nicht Voraussetzung
sein. ⇒ **statische IP am Laptop** (`10.40.99.50/24`), im Runbook mitschreiben.
3. **Beschriftung.** Ein OOB-Port, den niemand erkennt, wird im Zweifel für „ist ja frei" gehalten.
4. **Abnahme, die den Zweck wirklich prüft:** Laptop an `ether23` des Branch, **Core-Trunk `ether1`
gezogen** (oder Core im Reboot) → `10.40.99.2` muss antworten. Alles andere misst nur den Normalfall.
#### 🔴 Auflage, ohne die das TP nicht gebaut werden darf
Ein untagged-MGMT-Port ist **ein physischer Generalschlüssel**: wer ihn steckt, steht ungefiltert im
Management-VLAN eines Geräts, das (Branch) keinerlei `input`-Filter hat. Deshalb gehört **eine** der
folgenden Absicherungen dazu, und die Entscheidung darüber in die Umsetzungs-Session:
- **Port `disabled=yes` im Normalbetrieb**, im Bedarfsfall am *anderen* Gerät freigeschaltet — schützt,
kostet aber genau dann Zugriff, wenn das andere Gerät weg ist (halbierter Nutzen, ehrlich benennen);
- **oder** `input`-Regeln auf beiden Switches nachziehen (der Branch braucht sie ohnehin — das ist die
eigentliche Lücke, die dieses TP nebenbei aufgedeckt hat);
- **oder** rein physisch: abschließbarer Schrank, Port beschriftet und dokumentiert.
⚖️ **Meine Einschätzung:** die `input`-Regeln sind der richtige Weg, weil sie eine Lücke schließen, die
**unabhängig von diesem TP** besteht. Der OOB-Port macht sie nur sichtbar.
#### Abgrenzung
- **Nicht** Teil von TP16.3 — dort wird nur festgestellt, dass dreimal Geräte rebooten, deren Mgmt am
Core hängt. TP37 entschärft das für künftige Sessions, ist aber **keine Voraussetzung** dafür.
- **Nicht** VLAN-Schema-Änderung (TP32 bleibt unberührt), **nicht** Konsolen-/Serial-Zugang (das wäre
echtes OOB im engeren Sinn; hier geht es um ein zweites *Netz*-Bein).
- **Abh.:** TP5 (Firewall-Systematik, liefert das Muster für die `input`-Regeln), TP15 (Portbelegung
beider CRS326), TP22 (die Umsetzung läuft über Golden Config, `ip-firewall-filter` und
`bridge`-Sektionen stehen unter Drift-Kontrolle).
---
### TP40 · Gitea #29 — Home-Assistant-Recorder-Retention: die 41-GB-`states`-Tabelle beschneiden
**Auslöser:** LXC 302 (MariaDB) lief am 2026-09-01 auf **100 %** voll. Sofortmassnahme war `+30G`
(TP39 Punkt 3), aber 59 der 65 GB sind die **`homeassistant`-Datenbank**, und die wächst weiter —
`+30G` verschiebt das Problem um ~4 Wochen, es löst es nicht.
#### Befund (live gemessen)
| Tabelle | Größe |
|---|---|
| `homeassistant.states` | **41 GB** |
| `homeassistant.state_attributes` | 8,6 GB |
| `homeassistant.statistics_short_term` | 8,1 GB |
| `homeassistant.statistics` / `events` | je ~0,7 GB |
Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenze wirksam, sonst wäre
`states` nicht auf 41 GB gewachsen. Das ist ein Konfigurations-, kein Plattenproblem.
#### Scope
1. **HA-`recorder`-Retention setzen** (`configuration.yaml` bzw. `.storage`): `purge_keep_days`
auf einen bewussten Wert (Vorschlag 30) — **Christoph entscheidet die Zahl**, das ist eine
Aufbewahrungs-Policy, keine rein technische Größe.
2. **Rauschende Entities ausschließen** (`recorder: exclude`) — die üblichen Verdächtigen sind
hochfrequente Sensoren (Leistung, Netz-Durchsatz, `sensor.time`/`date`). Vorher per
`SELECT COUNT(*) … GROUP BY entity_id` die Top-Schreiber ermitteln, nicht raten.
3. **`recorder.purge` mit `repack: true`** — erst nach 1+2, sonst wächst es sofort nach. `repack`
gibt den Plattenplatz auf InnoDB-Ebene **real** frei (ohne repack bleibt die `.ibd`-Datei gross).
🔴 **`states` hängt per FK an `state_attributes` und `events`** — purge räumt beide mit; die
Reihenfolge macht HA selbst, **nicht** von Hand einzelne Tabellen truncaten.
4. **Danach die TP39-Vergrösserung bewerten:** schrumpft `states` deutlich, waren die `+30G`
Vorhalt und keine Dauerlösung — die Platte lässt sich zwar nicht online verkleinern, aber der
Druck ist weg.
#### Abgrenzung
- **Nicht** TP39: das ist LXC-/DB-Hygiene (Resolver, `networking`, `skip-name-resolve`). Der
Resize tauchte dort nur auf; die **Ursache** (HA-Retention) ist HA-seitig und darum ein eigenes TP.
- 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die
Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen.
### TP37 · Gitea #27 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren
> **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in
> nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf
> den mikrotiks ja nur via gc/nautobot deployst."*
- **Auslöser:** der TP16.3-Schnitt am 2026-08-30. Um ihn zu planen, mussten **sechs `cloudflared`-Container
über drei Hosts hinweg per SSH und Portainer-API zusammengesucht** werden (LXC400 `.126`, macmini `.167`,
NAS `.10`), und die Zuordnung Tunnel→Connector→Host existierte **nirgends als Datensatz** — nur verstreut
in Prosa. Dieselbe Suche fällt bei jedem weiteren Eingriff erneut an.
- 🔴 **Befund (live gegen die API gelesen 2026-08-30) — das Fundament fehlt, nicht nur die Connector-Zeilen:**
```
Devices: 8 (ap1, ap2, crs326-core, crs326-branch + cudy1/cudy2/sw1/sw2 decommissioned)
Apps: golden-config Platform: 1 (RouterOS)
ipam.Service: 0 · CustomField: 0 · Relationship: 0 · VirtualMachine: 0 · Tag: 0
```
Nautobot kennt heute **ausschließlich die vier MikroTik**. Kein Host, kein Container, kein Dienst ist
erfasst — weder Caddy (`caddy-bf1`), noch AdGuard (`agh-bf1`/`bf2`), noch pve/NAS/macmini, noch Nautobot
selbst. ⇒ **TP37 ist zuerst eine Datenmodell-Frage, erst danach eine Connector-Frage.**
- **Scope (Vorschlag, Reihenfolge ist der Punkt):**
1. **Substrat abbilden** — pve/NAS/macmini als Hosts, die LXCs und Docker-Container als
`virtualization.VirtualMachine` (Cluster je Host). Damit existiert erstmals ein Ort, an dem
„wo läuft was" steht.
2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`,
Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**.
3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](teilprojekte-archiv.md#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit
entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/
Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch
TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.**
4. **Drift-Check** — Skript liest CF-API (`cfd_tunnel` + `configurations`) und vergleicht gegen Nautobot,
analog `nautobot/gc-configplan.py --refresh-compliance`. **Read-only zuerst**; ob Nautobot je nach
Cloudflare *schreibt*, ist eine spätere, eigene Entscheidung.
- **Abgrenzung (wichtig, sonst überlappt es mit zwei offenen TPs):**
- **TP34** (Caddyfile via GC) rendert *Config aus Daten*. TP37 liefert **die Daten**, auf denen TP34
aufsetzt — Punkt 1+2 sind TP34s fehlende Voraussetzung. **TP37 vor TP34.**
⚠️ **Nachtrag 2026-09-22:** TP34 ist **❌ verworfen** (09-08, #24 — der Caddyfile bleibt manuell);
der *Daten → gerenderte Config*-Gedanke lebt als **[TP34.2](#tp342)** (interne DNS-Namen) weiter.
- **TP36** (SSoT für `teilprojekte.md`) rendert *Doku aus Daten* und braucht dieselben Custom Fields.
Läuft parallel, gleiches Fundament.
- **Nicht** in TP37: Golden Config für Nicht-Netzgeräte (Caddy/AdGuard deployen) — das ist **TP34.2**
(das Caddy-Teilstück davon bleibt ❌ verworfen).
**Nicht** in TP37: Cloudflare-Writes aus Nautobot heraus.
- **Abh.:** TP21/TP22 ✅ (Nautobot + GC-Pipeline stehen) · TP32 ✅ (Nummern-Systematik liefert die
Klassifikation für Punkt 1) · TP16.3 (liefert den Anwendungsfall und den Beleg, warum es fehlt).
Hängt an **nichts Offenem**.
- ⚠️ **Ehrliche Einschränkung:** Nautobot ist bis heute bewusst schlank (8 Devices, 0 Custom Fields) —
die Pipeline ist stabil und **104/104 compliant**. TP37 vergrößert die Angriffsfläche dieser Pipeline.
Deshalb Punkt 1–2 als eigener, abnehmbarer Schritt **mit Compliance-Gegenprobe danach**, nicht als
Nebeneffekt von Punkt 3.
- **Status:** ✅ **Punkt 1+2 (Substrat + Dienste) ERLEDIGT** — die frühere Zeile „offen" war Doku-Drift.
Live gegen die API gelesen **2026-09-21**: **28 VMs · 3 Cluster · 26 `ipam.Service` · 38 `ipam.IPAddress`
· 9 Devices · 5 CustomFields**. Beide Seeder sind also gelaufen (`tp37-substrat-seed.py`,
`tp37-dienste-seed.py` — die 26 Services sind genau die 26 Zeilen der Dienste-Liste).
Datenmodell **entschieden 2026-09-06 (native Modelle: `virtualization.Cluster/VirtualMachine` +
`ipam.Service`, keine App)**; Runbook [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
**Offen bleibt Punkt 4** (Drift-Check gegen die CF-API) — und der Weg **Daten → gerenderte Config**
ist nicht mehr TP37, sondern **TP34.2 / TP45 Schicht C**.
⚠️ **Merkregel:** die Zeile stand seit dem 09-06 auf „offen", obwohl die Seeder längst gelaufen waren.
Ein Status, der nur in einer Datei lebt, altert still — der Abgleich gegen die API kostet eine Minute.
---
<a id="tp342"></a>
### TP34.2 · Gitea #73 — Interne DNS-Namen als Daten (FQDN aus Nautobot) — **gemeinsame Arbeit**
> **Auslöser:** beim Schärfen von **TP45 Schicht C (C4)** am 2026-09-22 stand die Frage, was aus dem
> verworfenen **TP34** wird. Christoph: *„C4 unter TP45 schärfen, TP34 wird gemeinsame Arbeit. wir beide!"*
> → **eigener Träger im TP34-Nummernkreis**. Parent **#24 (TP34) bleibt `status:dropped`** — kein Reopen:
> dort wurde *Caddyfile-Rendering* verworfen, hier geht es um *interne DNS-Namen als Daten*. **Kein
> Zwillings-TP mit neuer Nummer**, aber auch kein Wiederbeleben eines anderen Gegenstands.
- **Scope:** Die **27 internen `A`-Statics** auf `crs326-core` (`*.bf.emsenhuber.net`, live geprüft
2026-09-22) werden **Daten in Nautobot**; ein Renderer schreibt sie als GC-Soll. **Abnahme:** ein
Golden-Config-Recovery von null erzeugt einen Core, in dem die internen Namen **wieder auflösen**.
- **Ausdrücklich NICHT Scope:** Caddyfile-/Reverse-Proxy-Rendering (der verworfene TP34-/`.1`-Ansatz) —
der `caddy/Caddyfile` bleibt **manuell**.
- 🔴 **Warum es das braucht:** die Namen existieren heute **nur am Gerät** — Handarbeit mit
Freitext-Kommentaren (`"caddy -> nautobot :8080"`), und GC rendert sie **nirgends**. Ein Recovery
von null erzeugt damit einen Core **ohne interne Namensauflösung**: dieselbe **Recovery-Lücke** wie
die DHCP-Reservierungen und die `nautobot`-Adressliste (beide inzwischen als Renderer nachgezogen,
TP45 Schicht B). ⚠️ **Compliance meldet trotzdem grün — die Zahl prüft nur, was gerendert wird.**
- **Datenlage / offener Modell-Entscheid:** `ipam.Service` (**26**) und `ipam.IPAddress` (**38**) stehen
(TP37 P1+2 ✅). Für DNS braucht es voraussichtlich `ipam.IPAddress` + `dns_name` je Gerät/Dienst, mit
**zwei Zielarten**: Geräte-Statics zeigen auf die echte IP, Proxy-Namen auf **`10.40.20.40`** (Caddy)
— dazu je Name ein Kommentar-Feld. Der eigentliche Modell-Entscheid steht noch aus (**nicht raten**).
- **Abh.:** TP37 ✅ (Datenmodell) · TP45 **Schicht A** ✅ / **E** offen (die Namen müssen auf die **neuen**
Gast-IPs zeigen) · TP21/22 ✅ (GC-Pipeline). **Blockiert:** nichts.
- **Abgrenzung:** die `coolify.bf`-Frage bleibt in **TP45 Schicht C** (Port unbekannt — nicht raten);
Token-**Rotation** ist TP30.
---
### TP45 · Gitea #71 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN aus Nautobot
> **Auslöser (Christoph, 2026-09-21):** *„schau dir mal die fqdns von `*.emsenhuber.net` an. viele
> verschwunden. sowohl caddy als auch cloudflare."* — und, als der Befund stand:
> *„wenn wir schon reverse proxy & co korrigieren müssen, wäre es vl jetzt ein guter zeitpunkt,
> unsere infrastruktur vlan20 an das ausgedachte ip-schema anzupassen. danach dns/fqdn."*
#### Befund (live, 2026-09-21) — **weder Caddy noch Cloudflare haben etwas verloren**
| # | Befund | Beleg |
|---|---|---|
| 1 | **CF ist gewollt geschrumpft** — 24 Records, 8 proxied; `bf-public` trägt 5 (`vw`,`hass`,`n8n`,`gitea`,`ds-bf`), `fm` 2 | CF-DNS-API |
| 2 | **Caddy hat nichts verloren** — 16 Sites, live **byte-gleich** zum Repo (`diff` identisch, 2977 B) | LXC 130 |
| 3 | 🔴 **Internes Split-Horizon tot** | s. u. |
| 4 | 🔴 **`ds-bf` öffentlich kaputt** | s. u. |
🔴 **Root Cause #3 (bewiesen).** AGH wertet DNS-Rewrites **im Filter-Pfad** aus; für Clients mit
`filtering_enabled: false` werden sie **übersprungen**. Genau die Gruppen, von denen gebrowst wird,
sind unfiltriert (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`, `grp-media`, `grp-mgmt`). Diese
Clients fallen auf den conditional forward `[/bf.emsenhuber.net/] → 10.40.20.1` (Core) zurück — der
Core hält aber nur **10** Host-Records; die **11 Dienstnamen** (`nautobot`, `portainer`, `pbs`, `ccu`,
`iobroker`, `z2m25`, `tv`, `mfc`, `ha-mcp`, `gitea-mcp`, `n8n-mcp`) existieren **nur** als AGH-Rewrite
→ **NXDOMAIN**. Aus dem AGH-Container selbst (`127.0.0.1`, kein Client-Match) liefern dieselben
Rewrites korrekt — **deshalb war jede Handoff-Abnahme grün: sie wurde nie vom Browser aus gemessen.**
Zweitdefekt: `pve.bf`/`pve-mcp.bf` **doppelt** definiert (Core → Host-IP, AGH → Caddy `.40`).
🔴 **Root Cause #4 (bewiesen, Firewall).** Der Origin antwortet aus VLAN20 in 0,08 s mit `200`,
öffentlich `000` nach 20 s — die vier anderen Namen desselben Tunnels sind in ~0,2 s da. `vw`/`hass`/
`n8n`/`gitea` gehen auf `https://10.40.20.40:443` (**Caddy**); `ds-bf` geht **direkt** auf
`http://10.40.20.10:8080` (**NAS**) und ist damit der **einzige Ingress-Eintrag, der Caddy umgeht** →
die AP-Connectoren (VLAN99) dürfen per Regel `*42` **ausschließlich** Caddy:443, alles andere fällt
unter `*1A TP5-WOULD-DROP` / `*2D TP5: deny-by-default` (621 Pakete).
**Fix ohne Firewall-Änderung: `ds-bf` über Caddy führen.**
#### Schichten (Reihenfolge = Abhängigkeit)
| Schicht | Inhalt | Stand |
|---|---|---|
| **A** | VLAN20 virtuelle Gäste ins Schema — **Nautobot zuerst** (`.30–.34` → `.154–.156`/`.158`), dann AGH | 🔄 **Nautobot ✅ 2026-09-21** (s. u.) · AGH + übrige Gäste offen |
| **B** | VLAN30 — **nur die Pool-Grenze**, Geräte bleiben (Christoph 2026-09-21) | offen |
| **C** | DNS/FQDN: `ds-bf` über Caddy, `coolify.bf` neu, `.bf` vollständig auf den Core, AGH nur Filtering, TP34.2 | ✅ **C1–C3 durch 2026-09-22** (s. u.) · offen: **C4 → TP34.2** (Gitea #73) + `coolify.bf` |
| **D** | Tunnel-Inventar + GC + generische Namen, TP30 #10, Leak-Ursachen | offen |
| **E** | nas/pve/macmini — **neue IPs zuerst am Core** (Sekundär-IP + 1:1-NAT), dann messen und umstellen; nas zuletzt | offen |
| **F** | CF-/Tunnel-SSoT + Recovery → **TP46** | offen |
##### Schicht E — Mechanik (Christoph-Entscheid 2026-09-21, ersetzt „Doppeladresse am Host zuerst")
**Neue IPs zuerst am Core, nicht am Gerät:** Sekundär-IPs `.110` (nas) · `.121` (pve) · `.130`
(macmini) auf der Core-`vlan20` **plus 1:1-NAT** auf das Gerät (`dst-nat` + `src-nat`, platziert
**vor** der `no-NAT inter-VLAN`-Regel). 🔴 **Routing allein reicht nicht** — im selben Subnetz
antwortet das Gerät dem Client direkt per L2, der Client verwirft eine Antwort von `.10` auf eine
Verbindung zu `.110`; der Rückweg braucht `src-nat`, und die Adresse muss **am Core liegen**, sonst
beantwortet er kein ARP dafür (**nicht** `proxy-arp` — das antwortet für *jede* freie Adresse im
/24). Heute ist die Core-NAT-Kette leer (nur `no-NAT inter-VLAN` + WAN-Masquerade) ⇒ Neubau.
**Ab dann gemessen:** zwei Zähler je Host — auf der **neuen** IP (NAT-Regel: „wer ist schon
umgestellt") und auf der **Alt-IP** (`action=passthrough`, `comment="dualaddr-watch: …"`: „ist noch
was los"), beide ohne `log`. **Erst wenn der Alt-IP-Zähler über N Tage stillsteht**, wird das Gerät
**zum Siedeln vorgemerkt**: neue IP aufs Gerät → NAT + Sekundär-IP + Zähler raus → Alt-IP entfernen
→ Doku/Nautobot nachziehen. Alles **ins Soll**, nicht als Transaktion (Christoph: *„wir haben einen
Ist- und einen Sollzustand; die Regel kann uns nicht vorschreiben, wie lange wir für die Überführung
brauchen"*).
**Reihenfolge:** `nas` siedelt **zuletzt und gemeinsam** (die meisten Abhängigkeiten: SMB, Time
Machine, Caddy-Backend `ds-bf`), `pve`/`macmini` vorher; **`pve2` nur reservieren** (`.120`).
**Vorabcheck ✅ 2026-09-21:** NAS-NFS (`/etc/exports`, 9 Shares) und iSCSI (`.10`/`.11`:3260) sind
IP-ACL'd, haben aber **keine lebenden Nutzer** — pve hat **null** NAS-Mounts (einzige Remote-Storage
ist `pbs 10.40.20.135`), kein LXC hängt am NAS, die Synology-Firewall hat keine Regeln ⇒ der
src-nat-Umweg (gleich-subnetzige Clients sehen den Core als Quelle) **bricht nichts**.
🔴 **Eigener Aufräumposten:** dieselben Exports stehen komplett auf totem **`10.40.41.x`** plus drei
`10.0.0.0/8`-Freigaben (offen für jeden 10/8-Client).
⚠️ Der NAS trägt **zwei** VLAN20-Adressen (`.10` `ovs_eth0`, `.11` `ovs_eth1`) — laut Christoph
haben die beiden nichts miteinander zu tun; `.11` gehört **nicht** in die Umzugslogik.
🔴 **Warum Nautobot zuerst.** AGH will `.30`/`.31` — dort saß Nautobot (`.30`/`.31`/`.32`/`.34`).
**28 Skript-/Config-Dateien** referenzierten `10.40.20.30` (alle `gc-*.py`, 14× `ingest-*.py`,
`tp44-*`, `routeros/fetch-core-cutover-config.py`) — alle am 2026-09-21 mitgezogen. Und: **der
GC-Pfad läuft über Nautobot** — AGH zuerst zu ziehen hieße, die Werkzeugkette gegen sich selbst
laufen zu lassen.
##### ✅ Nautobot-Umzug durch (2026-09-21) — `.30/.31/.32/.34` → `.154/.155/.156/.158`
`.157` ist gitea, deshalb nimmt redis `.158`. Reihenfolge und Belege:
1. **Core-Adressliste `nautobot` additiv erweitert** (`.154/.155/.156` **dazu**, Alt-Einträge bleiben).
Das ist die wirksame Ebene: der Mgmt-Zugang des Cores läuft über `src-address-list=nautobot`
(Regel `*28`, 113 Pakete), **nicht** über den Literal `*24`.
2. **Nautobot-Datenmigration** über das neue [`nautobot/tp45-ip-move.py`](../nautobot/tp45-ip-move.py).
Nautobot **verbietet** das Ändern einer IP nach dem Anlegen (`Host address cannot be changed once
created`) → Umzug ist Dreischritt *anlegen → `primary_ip4` umhängen → Altobjekt löschen*.
3. **Stack-YML + Redeploy** (`PUT /api/stacks/203?endpointId=14`), Container auf den neuen IPs,
`http://10.40.20.154:8080/health/` = **200**, alle vier Alt-IPs **000** (keine Doppeladresse —
so entschieden).
4. **Caddy** `nautobot.bf` → `.154`, deployt und **verifiziert**.
5. **Core-Literal `*24` auf `.154`** über GC (`gc-manual-plan.py --deploy`, Plan `51a90e25`,
Completed) mit Wirksamkeitsprüfung am Gerät.
6. **`python3 nautobot/gc-configplan.py --refresh-compliance` → 140/140, 0 Drifts.**
##### 🔴 Zwei Werkzeug-Fehler, die genau dabei Zeit gekostet haben (beide neu)
**(a) `pct push` meldet Exit 0, auch wenn es die Quelle nicht lesen kann.**
Gemessen: `pct push 130 <nicht-vorhandene-datei> /etc/caddy/Caddyfile` →
`failed to open … for reading`, **`rc=0`**.
Folge in `caddy/deploy.sh`: die Push-Zeile war als `push || push`-Fallback gebaut (wegen einer
`$$`-Escaping-Verwechslung); der Fallback griff **nie**, weil der kaputte Push „Erfolg" meldete. Die
anschließende `caddy validate` prüfte damit die **alte** Datei und war grün — **grüne Abnahme bei
unverändertem Zustand**. Symptom war `nautobot.bf` **502** bei gleichzeitig gesundem Backend.
**Fix:** `push_verified()` in `caddy/deploy.sh` — Push **und** md5-Vergleich in *einem* Remote-Aufruf,
harter Abbruch bei Abweichung. Merkregel: **ein Push-Werkzeug, dessen Exit-Code nicht stimmt, wird
geprüft, nicht geglaubt** — dieselbe Klasse wie `nc -z` (09-01) und das `sed`-Muster (08-31).
**(b) 🔴 Die Firewall-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert.**
Der Core-Zugang für Nautobot hängt an `src-address-list=nautobot` — die Liste stammt aber aus dem
**von Hand gelaufenen** `routeros/tp5-etappe2-whitelists.rsc` und existiert im Template nicht
(`grep` auf `address-list` findet nur Kommentare). **Ein Golden-Config-Recovery von null erzeugt
damit einen Core, in den Nautobot nicht mehr per SSH kommt** — die Deploy-Pipeline wäre tot und
würde sich selbst nicht reparieren können. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
Der Renderer gehört in dieselbe Baustelle wie Punkt „DHCP-Reservierungen" oben.
**(c) Nebenfund — Regel `*24`/`*28` sind Dubletten.** Dieselbe Erlaubnis existiert zweimal:
`src-address=10.40.20.30` (Literal, **0 Pakete** = tot) und `src-address-list=nautobot`. Nur die
Liste ist wartbar. Der Literal wurde auf `.154` gezogen (Soll-Konsistenz), die **Zusammenlegung**
bleibt als Aufräumposten offen.
**(d) Nebenfund — `gc-manual-plan.py` Diagnosepfad ist tot.** Die Log-Abfrage
`/api/extras/job-log-entries/?job_result=…` liefert **HTTP 404** (Nautobot 3.x hat den Endpunkt
verschoben). Bei einem **erfolgreichen** Deploy ist das kosmetisch, bei einem **gescheiterten**
nimmt es die Fehlermeldung weg — genau dann, wenn man sie braucht.
#### ✅ Schicht C durch (2026-09-22) — FQDN-Wiederherstellung, vorgezogen (C1–C3, Commit `6d1d56e`)
Auslöser: die IP-Umstellung (A/B/E) dauert länger; die internen `*.bf.emsenhuber.net`-Namen mussten
**sofort** zurück. Drei Züge, jeder am **Gerät** *und* am **Client** belegt — nicht am Plan-Status.
**C1 — 17 Namen in den Core** (`nautobot/mikrotik_routeros.j2`, Deploy über den neuen GC-Job
`tp45-c-dns`). Der Core hält jetzt **27** `/ip dns static` (vorher 10): die 9 Caddy-Frontends
(`nautobot` `pbs` `portainer` `ccu` `iobroker` `z2m25` `ha-mcp` `n8n-mcp` `gitea-mcp`) → `.40` ·
`tv.bf`→`10.40.40.11` · `mfc.bf`→`10.40.10.6` · **`ap3.bf`→`.99.13`** (existierte in *keiner* Quelle) ·
`pve.bf`/`pve-mcp.bf` von Host-IP auf `.40` · dazu die **5 bare Namen** (`vw` `hass` `n8n` `gitea`
`ds-bf`) → `.40` (Entscheid 09-22: lokaler Split-Horizon ist **gewollt**, weil `/admin` nur header-frei
geht). 🔑 Der Job liest die Zeilen **aus dem Template selbst** und macht daraus einen **idempotenten
Upsert** (`:if ([:len [/ip dns static find where name=X]] = 0) do={ … }`) — `add` allein ist nicht
wiederholbar. Abnahme: Geräte-Read **27 Statics** · `--refresh-compliance` → **145/145, 0 Drifts**.
**C2 — AGH entdoppelt** (`adguard/AdGuardHome.yaml`, Weg `python3 adguard/agh-sync.py push`).
`upstream_dns` = **nur** `10.40.20.1`; CF-DoH-Zeile und conditional `[/bf.emsenhuber.net/]`-Forward
entfallen; **alle 26 Rewrites raus**. Der Core ist damit die **einzige** Quelle — auch für die bare
Namen, die vorher über DoH **öffentlich** aufgelöst wurden und den Client an den CF-Edge schickten
(dort setzt der Tunnel `CF-Connecting-IP` → `@cf_admin`-Guard → 404). Vorher/Nachher aller 26 Rewrites
geprüft: **verhaltensneutral**, identisches Ziel, eine Quelle statt zwei.
Abnahme: `agh-sync.py check` → `.36 == .37` · `/admin` **header-frei 200**, mit `CF-Connecting-IP`
weiter **404** · Filter intakt (`doubleclick` → `0.0.0.0`) · öffentliche Auflösung intakt.
**C3 — `ds-bf` über Caddy** (CF-Write am Tunnel `bf-public` `48d3b0b6`): Ingress-Origin von
`http://10.40.20.10:8080` auf `https://10.40.20.40:443` + `originRequest.originServerName`. Öffentlich
**200 in 0,24 s** (vorher `000` nach 20 s); `vw`/`hass`/`n8n`/`gitea` unverändert. ⚠️ Der **CF-MCP darf
lesen, aber nicht schreiben** (`1001: Not authorized` auf `PUT …/configurations`) — der Write lief über
`CF_API_TOKEN` aus `mcp/cloudflare/.env` (Wert nie ausgegeben); Original-Ingress vorher gesichert,
nachher gegengeprüft.
**WARP-Abnahme (Christophs Bedingung) ✅.** Unter verbundenem WARP löst `127.0.2.2/.3` den Namen auf
**`.40`** auf, die Route geht über `utun8` (Mesh), `/admin` = **200**. Der zuvor gemeldete 404 des Users
kam aus dem **Browser-/mDNSResponder-Cache** (dort lag noch die CF-Anycast-A); nach TTL-Ablauf sauber.
**Hebel (a) (Zero-Trust Local Domain Fallback) war nicht nötig.**
🔴 **Befund, bewusst offen — AAAA bleibt öffentlich.** Der Core hält nur **A**-Statics und ist kein
Zonen-Eigentümer: eine AAAA-Abfrage **reicht er weiter** und liefert für die bare Namen die **CF-IPv6**
(`2a06:98c1::`). **Heute wirkungslos** — im Netz läuft kein globales IPv6 (der Mac hat keine globale
Adresse; `curl -6` landet über `::ffff:10.40.20.40`). **Latent:** sobald IPv6 kommt, geht der
`/admin`-Weg wieder über die Edge und die Tür ist zu. Christoph-Entscheid 2026-09-22: **dokumentieren,
nicht ändern** — `aaaa_disabled: true` in AGH wäre die Ein-Zeilen-Abhilfe, ist aber netzweit „IPv4-only"
und passt nicht zu „AGH filtert nur".
🔴 **Zweiter Befund — AGH ist doppelt adressiert.** LXC 110/111 tragen im Container **zwei**
VLAN20-Adressen: `eth0` `.36`/`.37` (aus `net0`) **plus** `eth1` `.30`/`.31`. Schicht A ist dort
**halb** umgestellt — die neue IP ist live und wird per DHCP verteilt (`dns-server=10.40.20.30,10.40.20.31`),
die Alt-IP lebt noch. Gehört in die „siedeln"-Messung, nicht in C. ℹ️ **Korrektur einer eigenen
Fehlannahme:** `adguard/agh-sync.py` nennt `.36`/`.37` — das ist **kein** Drift, sondern die
`net0`-Adresse; nur die Konstante kennt die zweite Adresse nicht.
🧪 **Messfalle, die fast ein Fehlalarm wurde:** `dig nautobot.bf` **ohne** `.emsenhuber.net` fragt den
Namen `nautobot.bf.` im **Root** ab und liefert leer — sieht wie ein nicht gefixter Name aus, war aber
die eigene Abfrage. Dieselbe Klasse wie `nc -z` (09-01) und `pct push` (09-21): **erst das eigene
Werkzeug verdächtigen.**
**Offen in C:** `coolify.bf` (nicht ausgerollt, Port unbekannt — **nicht raten**) · **C4 = die internen
Namen als Daten** → eigener Träger **[TP34.2](#tp342)** (Gitea #73, angelegt 2026-09-22, gemeinsame Arbeit).
🔴 **Was C4 genau ist:** die **27** `A`-Statics auf `crs326-core` sind heute Handarbeit (live geprüft
2026-09-22: 27 Einträge, Freitext-Kommentare, `coolify.bf` fehlt) und werden **von GC nirgends gerendert**
⇒ ein Recovery von null erzeugt einen Core **ohne** interne Namensauflösung — dieselbe **Recovery-Lücke**
wie die DHCP-Reservierungen und die `nautobot`-Adressliste. **Nicht** C4 ist Caddyfile-Rendering: das war
TP34 und bleibt ❌ verworfen (09-08). Kosmetisch zurückgestellt: die `ds-bf`-Site steht im `caddy/Caddyfile`
weiter im INTERN-Block (nur Beschriftung; ein Caddy-Redeploy allein dafür wäre unverhältnismäßig).
#### Soll-Oktette (Nachschlagetabelle, s. TP32 für die Herleitung)
| Oktett | Rolle | Soll |
|---|---|---|
| `.20–.29` | Core / netznah | mcast-relay |
| `.30–.39` | DNS | agh-bf1 `.30` · agh-bf2 `.31` |
| `.50–.59` | DBs | platform-pg `.50` · mariadb `.51` · influxdb2 `.52` · redis `.53` |
| `.60–.69` | Security | vaultwarden `.60` |
| `.80–.89` | Platform | docker `.80` · coolify `.81` |
| `.90–.99` | Broker/Funk | mqtt5 `.90` · z2m25 `.91` |
| `.150–.199` | App | haos `.150` · iob-master `.151` · CCU `.152` · nautobot `.154–.156`+`.158` · gitea `.157` · iob_slave `.159` |
🔴 **DHCP-Reservierungen sind die Lücke:** Golden Config rendert sie **nirgends** (pools/DNS-statics/
NAT/mangle/WG sind Template-Literale, der Firewall-Filter kommt als ConfigContext-**Daten** — aber
`/ip dhcp-server lease` existiert im Template nicht). Die 26 VLAN30- + 1 VLAN20-Reservierung sind
**Handarbeit auf dem Gerät** aus der ap1-Ära. Christoph-Entscheid 2026-09-21: **Renderer bauen**
(Template + Nautobot-Datenmodell), statt Handarbeit zu dokumentieren.
#### Live-Befunde, die den ursprünglichen Plan widerlegten (2026-09-21)
- `mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`) → der Anker der
Zeile `.20–.29` war falsch gesetzt.
- `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), der Plan sah **drei** Slots vor.
- Die VLAN30-Soll-Spalte war **durchgehend erfunden**: Ender3V3KE liegt live auf `.150` (Plan `.180`),
die 26 Shelly-Reservierungen auf `.202–.226`+`.233` (Plan `.150–.169`), Twinkly `.228`,
Lichterketten `.236`/`.237`, Withings `.230`/`.231` … → Christoph: **„VLAN30 nur Pool-Grenze"**.
- Pool-Fund: `pool-v20` `.100–.200` hat **`used=0`** (gratis verschiebbar); `pool-v30` `.100–.250`
enthält **~25 Statics** → Pool und Statics tauschen praktisch die Plätze.
- ⚠️ **`ap3.bf` existierte nirgends** — weder Core-Static noch AGH-Rewrite, obwohl ap3 seit 08-30 live
ist. ✅ **Behoben in C1 (09-22):** `ap3.bf` → `.99.13` als Core-Static.
- **Abh.:** TP32 (Systematik) · TP37 ✅ (Nautobot-Datenmodell) · TP21 ✅. **Blockiert:** TP34.2 (rendert
die FQDNs, die hier Daten werden). **Abgrenzung:** VMID-Umnummerierung bleibt draußen; Token-**Rotation**
läuft in TP30; Tunnel-GC-Namensgebung hier, die **SSoT/Recovery** in TP46.
<a id="tp48"></a>
### TP48 · Gitea #75 — Recovery-Lücke: Nicht-GC-Objekte + Evaluation Ansible als Nautobot-Ablösung
> **Anlass:** beim Abschluss von **TP10** (2026-10-05) fiel auf, dass der DDNS-Updater als
> `/system script` + DHCP-Trigger auf `crs326-core` lebt und in **keinem** Template steht — ein
> Recovery von null hätte ihn nicht. Das ist eine **Klasse**, kein Einzelfund. Christophs Frage
> daraus: *„wäre ansible in der lage, nautobot vollständig als ssot abzulösen. 1 tool für
> doku/deployment und nicht mehrere … keine indiv-anpassungen falls einfach vermeidbar."*
#### Teil 1 — Die Lücke (live geprüft 2026-10-05, `crs326-core`)
| Objekt | In einem Template? |
|---|---|
| `tp10-ddns-bf4`, DHCP-Client `client1` `script=` | ❌ |
| `/system script environment cfDdnsToken` (**Secret**) | ❌ und darf nie (Secret-Gebot) |
| 8 Alt-Skripte aus TP21.1 (`tp21-dhcp-restore`, `tp21-wan-wg-restore`, …) | ❌ |
| `/system scheduler` | — (keiner vorhanden) |
🔴 **Nebenbefund:** `tp21-wan-wg-restore` trägt den **WG-Hub-Privkey im Klartext** im Skript-Körper.
Das Skript liegt **nicht im Git** (nur auf dem Gerät), es ist also ein undokumentierter Recovery-Pfad
mit einem Secret darin. **Aufnehmen, nicht sofort anfassen** (Christoph 2026-10-05).
**Warum unsichtbar:** GC prüft **29 ComplianceFeatures** — **keines** deckt `system script`,
`system scheduler` oder `script environment`. Die Compliance-Zahl prüft nur, **was gerendert wird**;
was nicht gerendert wird, ist per Definition nicht „drift".
#### Teil 2 — Die Evaluation
🔴 **Der Befund, der die Fragestellung verschiebt:** Nautobot ist **heute schon nur teilweise SSoT.**
Live gezählt im Template: **datengetrieben** sind nur Device-Felder, `interfaces.all()`,
`ip_addresses.all()` und zwei ConfigContexts (`dhcp_leases`, `firewall_filter`). **Literal im Jinja**
stehen: 7 VLAN-Definitionen, 37 `bridge-port`- und 19 `bridge-vlan`-Zeilen, die Core-Firewall,
NAT/Mangle/Queue/Routen. Der Ablöse-Vergleich lautet also nicht „Ansible vs. Nautobot-SSoT", sondern
„Ansible vs. **Jinja-im-Repo + Gitea + ein paar Nautobot-Felder**".
| Rolle | Träger heute | Ansible-Äquivalent |
|---|---|---|
| TP-Index/Status | **Gitea** (seit TP36 — nicht Nautobot) | ❌ bleibt Gitea |
| Live-Gerätedaten (Compliance) | Nautobot DCIM + GC | ⚠️ Facts + `--check` |
| Soll-Config | GC-Templates (Jinja) | ✅ Templates/Inventar |
| Ist-Config | GC-Backup | ⚠️ `netcommon` backup |
| Doku/Inventar (133 Interfaces, 38 IPs, 54 Leases) | Nautobot DB | ❓ offen — Ansible hat keine DB |
**Offene Punkte:** (1) was leistet Nautobot *unersetzlich* (Compliance-Historie, ConfigPlan-Audit,
GitRepository-Sync, Secret-Maskierung)? (2) Kann Ansible Inventar+Soll+Drift aus *einer* Git-Quelle
bedienen? (3) Migrationskosten: 29 Features + 7 `config_replace`-Regeln nachbauen? (4) **Wo bleibt
Doku**, wenn Ansible nur deployt? (5) Werden die heutigen Sonderfälle (Literal-Blöcke wegen bewusster
Schiefstände wie `ether2` in VLAN1) mit Ansible sauberer oder chaotischer?
**Abgrenzung:** nicht TP21/TP22 (bauen GC auf), nicht TP46 (CF-SSoT). Berührt die Governance-Regel
„Config nur über Nautobot" — bei Trägerwechsel neu zu fassen. Verwandt: TP30 (der WG-Privkey ist ein
Rotations-Kandidat). **Ergebnis = Empfehlung mit Zahlen, keine Migration.**
---
## Abgeschlossene Teilprojekte ▸ [`teilprojekte-archiv.md`](teilprojekte-archiv.md)
**34 abgeschlossene TPs** liegen dort im Volltext, unverändert und mit eigenem Inhaltsverzeichnis.
📊 **Für einen Statusbaum reicht diese Datei:** die Tabelle unten nennt **jedes** archivierte TP, und
die Zeile „✅ Durch (Fundament)" weiter unten führt sie noch einmal mit Abschlussdatum. Das Archiv muss
dafür **nicht** geöffnet werden — es trägt die Begründungen, nicht den Status.
Kurzorientierung, wo was zu finden ist:
| Thema | TPs im Archiv |
|---|---|
| **Fundament / Fabric** | [TP1](teilprojekte-archiv.md#tp1) ALLNET-Core *(Gerät ausgebaut)* · [TP2](teilprojekte-archiv.md#tp2)/[TP2.1](teilprojekte-archiv.md#tp2-1) Omada *(ausgebaut)* · [TP15](teilprojekte-archiv.md#tp15) HW-Refresh CRS326 · [TP21.1](teilprojekte-archiv.md#tp21-1) Core-Cutover-Recovery · [TP4](teilprojekte-archiv.md#tp4) Router-Cutover *(Rolle seit 08-08 auf dem Core)* |
| **WLAN** | [TP3](teilprojekte-archiv.md#tp3) *(historisch)* · [TP3.1](teilprojekte-archiv.md#tp3-1) · [TP3.2](teilprojekte-archiv.md#tp3-2) · [TP3.3](teilprojekte-archiv.md#tp3-3) *(in TP25 aufgegangen)* · [TP28](teilprojekte-archiv.md#tp28) CAPsMAN auf dem Core · [TP25-Langfassung](teilprojekte-archiv.md#tp25-lang) |
| **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung |
| **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos |
| **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement |
| **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep · [TP44](teilprojekte-archiv.md#tp44) CF-Estate Doku-SSoT |
| **Zugang** | [TP10](teilprojekte-archiv.md#tp10-ddns) Multi-Site-VPN *(✅ abgeschlossen 2026-10-05 — DDNS-Updater live + Mesh-Quell-NAT)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh |
| **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) |
## Reihenfolge / Abhängigkeiten
### 🎯 Kritischer Pfad (Stand 2026-08-23) — **LEER**
```
TP5 E1 ✅ ─► E2 ✅ ─► E2-Nachtrag ✅ ─► E3 Drop ✅ ─► E4 input+MGMT ✅ ─► E5 MAIN→IOT ✅ ─► TP6 ✅ GESCHLOSSEN
🎯 DER KRITISCHE PFAD IST LEER (2026-08-23).
Das Projekt ist in seinem Kern fertig: deny-by-default steht, Golden Config
steht, WLAN steht, VLAN1 ist weg, und TP6 ist beantwortet statt offengelassen.
Was jetzt kommt, entscheidet Christoph — nichts blockiert mehr etwas.
```
> ➡️ **Update 2026-08-22 (Sitzungsende):** TP25 hat den Pfad seit dem 08-20 blockiert und ist im
> Kern **erledigt** — die Terrasse ist repariert, der Dead Spot weg, Fund E abgestuft. Damit ist
> **TP6 wieder der einzige Punkt auf dem kritischen Pfad**, und seine Prämisse ist inzwischen
> gemessen (`tp6-praemisse-messrunbook.md` §5): **formal Fall B** (+28…47 ms in der Senderichtung),
> **aber durch sechs Lastphasen mit laufendem Videocall kein einziger Problem-Klick.** Die
> nächste Sitzung entscheidet daher **bauen oder begründet schließen** — sie misst nicht neu.
> Übergabe mit den Sitzungszuschnitten: [`handoff-20260822-naechste-schritte.md`](handoff-20260822-naechste-schritte.md).
> 🟢 **Update 2026-08-23 (Endstand): TP6 ist beantwortet — und der kritische Pfad damit leer.**
> Gebaut wurde, wie entschieden. Der erste Anlauf (`/queue tree parent=vlan100`) war wirkungslos,
> **aber nicht aus dem zuerst notierten Grund**: nicht der Switch-Chip, sondern die
> MikroTik-dokumentierte Regel, dass Interface-HTB bei hw-offloadetem out-interface nicht greift.
> Ueber `parent=global` + Mangle-Mark **funktioniert CAKE einwandfrei** (zaehlt 6 MB, drosselt
> 9,3 → 7,78 Mbit). Und genau deshalb ist das Ergebnis belastbar: **die Latenz unter Last bessert
> sich trotzdem nicht — bei keiner Bandbreite, auch nicht bei 52 % der Linerate** (42,4 / 43,3 /
> 44,0 / 44,6 ms). Der Puffer sitzt im **DOCSIS-Uplink**, nicht im Router. ⇒ **TP6 schliessen:
> nicht mangels Nutzen, sondern weil das Werkzeug nicht an die Ursache reicht.** Details im
> TP6-Abschnitt oben, Merkregeln in `ref_queue_htb_platzierung_docsis`.
> 🔴 **Korrektur wenige Stunden spaeter (2026-08-23, TP6.1): die DOCSIS-Erklaerung oben ist FALSCH.**
> Der Puffer sitzt nicht unabaenderlich im Uplink — er loest sich auf, sobald der Verkehr in
> **getrennte Klassen** faellt: NAS-Bulk `priority=8` gegen interaktiv `priority=1` bringt dieselbe
> Strecke unter derselben Saettigung von **40,8 auf 11,0 ms (−73 %)**. Der Fehler steckte in der
> **Messmethode** — Last und Messung liefen vom selben Host, ein Shaper kann eine Queue nicht
> priorisieren, in der nur eine Klasse liegt. ⇒ **TP6 bleibt geschlossen** (CAKE war das falsche
> Werkzeug), **aber nicht aus dem dort notierten Grund.** Der Rest steht in **TP6.1**
> ([Archiv](teilprojekte-archiv.md#tp6-1)) und in `ref_queue_htb_platzierung_docsis`.
📜 *Die folgende Fassung stammt vom 2026-08-18 und ist **erledigt** — die Praemisse ist am 08-20/21
gemessen, TP6 am 08-23 geschlossen. Bewusst erhalten, weil sie zeigt, warum nicht gebaut, sondern
zuerst gemessen wurde:*
**TP5 ist am 2026-08-18 abgeschlossen.** Damit steht rechnerisch nur noch **TP6** auf dem kritischen Pfad —
🟠 **aber dessen Prämisse ist offen**: eine Messung von 2026-06-16 (`docs/call-quality-diagnosis.md`)
bescheinigt dem Netz bereits **A/A+** und hält CAKE für „marginal". Der nächste Schritt ist deshalb ein
**Bufferbloat-Test gegen den heutigen Edge**, nicht der Bau einer Queue. Fällt er gut aus, ist der
kritische Pfad **leer** und das Projekt in seinem Kern fertig — dann entscheidet Christoph, was das neue
Ziel ist. Alles andere läuft nebenher und blockiert nichts.
1. ✅ **TP5 Etappe 1–4 — komplett durch** (2026-08-16 bis 2026-08-18). Details im TP5-Abschnitt oben.
Die Wartezeit-Position, die hier bis 2026-08-17 als eigener Schritt stand, ist eingeloest: statt
eines mehrtaegigen Fensters wurde der Nachtrag aus einem **dreieinhalbstuendigen** Fenster
abgeleitet und der Drop mit Auto-Revert plus weiterlaufender Log-Regel abgesichert.
Die Vorbedingung D2 (iPhone `.30.201`) ist **gegenstandslos**: das Geraet hing dort bewusst zum
Anlernen eines IoT-Geraets (User 2026-08-17). Wer im IOT-SSID sitzt, ist im selben VLAN wie das
Geraet und beruehrt die `forward`-Kette gar nicht; fuer Ad-hoc-Zugriffe aus MAIN gibt es jetzt die
selbstverfallende Gruppe `jit-admin`.
2. ✅ **TP6 — QoS/CAKE ▸ GESCHLOSSEN 2026-08-23.** Gebaut, gemessen, wieder ausgebaut. CAKE ist
auf diesem Geraet baubar (Global-HTB + Mangle) und wirkt technisch — verbessert die Latenz
unter Last aber bei **keiner** Bandbreite, auch nicht bei 52 % der Linerate. Der Puffer sitzt
im DOCSIS-Uplink, nicht im Router *(⚠️ **widerlegt von TP6.1 am selben Tag** — s. Korrektur oben)*.
Queue und Template-Block sind entfernt, die ComplianceRule
`queue` bleibt und bewacht den Abschnitt als leer. **Damit ist der kritische Pfad leer.**
💡 Aus dem Abschluss ist **TP6.1** entstanden (Saettigung vermeiden statt Bufferbloat
bekaempfen) — Christophs Gedanke, den NAS-Sync zurueckzuhalten. ~~Steht als Vorschlag, nicht
auf dem Pfad.~~ **✅ Am selben Tag gebaut und gemessen: 40,8 → 11,0 ms.** ~~Das eigentliche Projektziel; **keine offenen Blocker mehr**.~~
**Korrigiert 2026-08-19:** kein *Blocker*, aber eine offene **Prämisse** — der Satz oben und dieser
Punkt widersprachen sich seit dem 18. Richtig ist: der nächste Schritt ist eine **Messung**, kein Bau.
Runbook liegt bereit: `docs/tp6-praemisse-messrunbook.md`. Fällt sie A/A+ aus, wird TP6 **geschlossen
statt gebaut** — dann ist der kritische Pfad leer und das Projekt in seinem Kern fertig.
**Eine harte Reihenfolge-Regel dazwischen:** TP5 darf die **DNS-Bypass-Sperre erst schließen, wenn TP23s
Ausnahme-Oberfläche steht** — der bisherige Workaround „Client von Hand auf `1.1.1.1`" *ist* genau dieser
Bypass. Alles andere aus TP5 ist davon unabhängig und kann vorher laufen.
⚠️ **Und eine zweite, aus TP16.1/TP16.3:** der Drop braucht `cf-mesh` explizit — steht seit Etappe 2 als
`TP5-E2: mesh->LAN` / `LAN->mesh` drin. Ohne diese zwei Zeilen sperrt Etappe 3 den Fernzugang aus, also genau
den Weg, über den man den Fehler zurücknehmen wollte.
### Follow-ups nebenher (blockieren nichts)
- **🔴 TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN (NEU 2026-09-21, Gitea #71):** der aktuell
**dringendste** Posten — die internen `*.bf.emsenhuber.net`-Namen lösen im Browser nicht auf
(Ursache: AGH-Rewrites im Filter-Pfad für unfiltrierte Clients übersprungen), und `ds-bf` ist
öffentlich kaputt (einziger Ingress-Eintrag, der Caddy umgeht). Danach ist **TP34.2** erst baubar.
- **TP46 — CF-/Tunnel-SSoT + Recovery (NEU 2026-09-21, Gitea #72):** Push-Weg + Ingress-Origins +
DNS + Access-Policies nach Nautobot, Recovery-Runbook. Eigene Baustelle, unabhängig von TP45.
- **TP25 — WLAN-Stabilisierung (NEU 2026-08-17):** Audit durch, **6 Funde** (2,4 GHz fährt 40 MHz statt der
profilierten 20; ap2 sendet auf 5 GHz mit **9 statt 18 dBm**, weil ch149 unter ETSI leistungsbegrenzt ist;
80 MHz kostet Randabdeckung; unzulässiger Channel-Override auf `w2-iot`; keine `access-list`; 5-Minuten-Takt
bei IoT-Trennungen offen). Umsetzung in 6 Schritten, alles via GC. **Übernimmt den offenen Rest von TP3.3.**
- **TP16.3 — cloudflared-Reduktion (NEU 2026-08-17):** Recherche durch. Der Mesh **ist** schon der private
Datenpfad (im TP5-Log mit 137 Zeilen belegt); Container braucht nur noch der **öffentliche** Ingress.
Vorschlag: `nautobot` als Probe aus dem CF-Ingress nehmen.
- ~~**DoH-Cloudflare auf `crs326-core`**~~ ✅ **erledigt 2026-08-18** (TP23 Stufe 1 = TP21.1 TODO 1).
- ~~**TP6.1 — Saettigung vermeiden**~~ ✅ **LIVE 2026-08-23: 40,8 → 11,0 ms unter Last** (−73 %).
NAS-Sync weicht bei Last zurueck, ComplianceRule `ip-firewall-mangle` angelegt, 104/104.
- **TP24 — DC-USV** (seit 2026-08-16 eigener TP, aus TP15 herausgelöst).
- ~~**TP19 — VLAN1-Client-Retirement / cudy-Teardown**~~ ✅ **geschlossen 2026-08-18** (VLAN1-ARP = 0). Die zwei Reste sind dorthin verschoben, wo sie hingehören: tote CF-Tunnel → TP16, HA-Portainer `setup_retry` → TP11.
- ~~**TP3.3 — WLAN-Coverage-Kur**~~ → **in TP25 aufgegangen** (2026-08-17). 802.11k/v ist repariert (08-13);
Re-Walk + Dead-Spot-Kartierung laufen als Schritt 5 von TP25 — und zwar erst **nach** den Funkparameter-Fixes,
weil vorher gemessene Werte wertlos sind.
- **TP10 —** DDNS-Auto-Updater für `bf4.emsenhuber.net` (auf dem Core möglich, `device-mode` erlaubt scheduler).
- 🔑 **Alle Passwort-/Token-Rotationen laufen seit 2026-08-24 unter [TP30](#tp30) — Sammel-TP,
gebündelt gegen Projektende (User-Entscheid).** Dort steht die Rotationsliste (MikroTik `admin`,
IoT-PSK, Shelly-Gerätepasswort, CF-Access-Token), der Radius der fünf Konsumenten, der
Etappe-1-Abschluss und die Fallenliste. **Etappe 1 ist durch**, Etappe 2 wartet bewusst.
- ~~**Doku-Aufräumen** (`docs/doku-cleanup-session.md`)~~ ✅ **abgeschlossen 2026-08-23.** Punkte A–D
(08-16), E (08-18/19) und **F — der Split** (08-23) sind durch: abgeschlossene TPs liegen jetzt in
[`teilprojekte-archiv.md`](teilprojekte-archiv.md), TP25 ist von 394 auf ~100 Zeilen kondensiert,
diese Datei ist von **2217 auf ~760 Zeilen**. Die Regel dahinter bleibt gültig:
⚠️ **während einer Doku-Session `teilprojekte.md` nicht parallel bearbeiten** (in der Nacht auf den
18. viermal committet, das kollidiert sonst) — und **ein TP wandert erst dann ins Archiv, wenn
nichts mehr daran zu tun ist.** *(Ergänzt 2026-08-30: und **zurück**, wenn doch wieder etwas
zu tun ist — das Archiv ist eine Ablage, kein Endlager.)*
### Nachgelagert / research-getrieben
**TP8** (Identity/802.1X) · **TP11** (Monitoring) · **TP12** (IPv6) · **TP13** (Botschafts-WLANs) ·
**TP16.2** (Härtung bypass-Dienste) · **TP18** (Garage-Objektspeicher) · **TP20.1** (Vaultwarden → Platform-PG) ·
**TP23** (DNS-Policy, s. Regel oben) · **TP30** (Credential-Rotation — nicht research-, sondern
**termin**-gated: gebündelt gegen Projektende, User-Entscheid 2026-08-24).
*(**TP17.1** — Shelly Gen4 → Thread, aktiviert 2026-08-23 — läuft **nicht hier**, sondern im
HA-Projekt `~/dev/ha-xstate`; netzseitig ist nichts zu bauen. Es blockt bewusst die PSK-Rotation.)*
*(**TP16.1** stand hier bis 2026-08-17 fälschlich als offen — es ist seit demselben Tag ✅ abgeschlossen und
steht unten unter „Durch" (Volltext im [Archiv](teilprojekte-archiv.md#tp16-1)). Nachfolger ist
**TP16.3**, das oben unter Follow-ups läuft.)*
### ✅ Durch (Fundament — nicht mehr planen, nur nachschlagen)
> 📚 **Alle folgenden TPs stehen seit 2026-08-23 im [Archiv](teilprojekte-archiv.md)** — im Volltext,
> mit Inhaltsverzeichnis. Diese Zeile bleibt als *Landkarte*: sie sagt, was fertig ist; das Archiv
> sagt, wie es dazu kam.
TP1 · TP2 · TP2.1 · TP3 (Rebuild 2026-08-08) · TP3.1 · TP4 (Rolle seit 08-08 auf dem Core) · TP7 · TP7.2 ·
TP10 (Kern) · TP14 (+14.1/14.2/14.2a/14.3/14.5) · TP15 · TP16 (Kern) · TP17 · TP19.1 · TP20 · TP21 · TP21.1 ·
TP22 · TP-R · **TP3.2** (08-17) · **TP5** (08-18) · **TP7.1** (08-18) · **TP19** (08-18) · **TP23 Stufe 1** (08-18) · **TP6** (08-23, geschlossen statt gebaut) · **TP6.1** (08-23, Upload-Prio live) · **TP26** (08-17) · **TP27** (08-17/18) · **TP28** (08-17, GC-Modell 08-18). **Verworfen:** TP9 (Gateway-HA, User-Entscheid 2026-08-08). **Obsolet:** Omada-PVID-Fix
(User-Entscheid 2026-07-22 — „nie wieder aufgreifen").
<details><summary>📜 Historische Fassung dieses Abschnitts (bis 2026-08-14 fortgeschrieben, hier bewusst erhalten)</summary>
> 🎯 **AKTUELLER KRITISCHER PFAD (Stand 2026-07-16, überschreibt die Reihenfolge unten): TP20 (Platform-Postgres) → TP21 (Nautobot) — Ziel: fertig vor CRS326-Produktivsetzung, spätestens Montag 2026-07-20.** TP18 (Garage-S3) und die Komodo-Abhängigkeit wurden am selben Tag als Blocker entfernt/entkoppelt (s. TP20), um diese Deadline zu halten — beide werden später nachgezogen, nicht mehr vorher abgewartet. **➡️ Stand-Update 2026-08-08:** TP20/TP21-Plattform steht, Core-Cutover (TP21.1) + WLAN-Rebuild (TP3) + VLAN20-Migration (TP14) sind durch. Neuer Rest-Pfad: **TP22 (Device-Adoption + Compliance-Push) → TP5 (Deny-by-default) → TP6 (CAKE, das Ziel).** Kleine Follow-ups davor: DoH-Cloudflare auf core, USV (**TP24**). **➡️ Stand-Update 2026-08-13 (Abschluss): TP22 ist DURCH** (alle 5 Schritte; Fleet adoptiert, Deploy-Pipeline scharf, Datenmodell as-built, Secret-Handling entschieden) → **kritischer Pfad jetzt: TP5 (Deny-by-default) → TP6 (CAKE)**. Kleine Follow-ups davor: DoH-Cloudflare auf core, DC-USV (**TP24**), **Doku-Aufräumen (`docs/doku-cleanup-session.md`, vorbereitet 2026-08-14 — dieses Dokument beschreibt an mehreren Stellen noch die Topologie vor dem Core-Cutover)**. ➡️ **Neu 2026-08-14: TP23 (DNS-Policy & Self-Service) hängt an TP5** — und zwar in *beide* Richtungen: TP23 braucht TP5s Firewall, **TP5 darf aber die DNS-Bypass-Sperre erst schließen, wenn TP23s Ausnahme-Oberfläche steht** (der bisherige Workaround „Client auf 1.1.1.1" *ist* dieser Bypass). Ebenfalls in TP5 gezogen: **TP7.1** (SSDP-Reflector für die macOS-Sonos-App). ~~Kabel `core ether4`→`ether2` (Sideboard/TV)~~ **entfällt (2026-08-14):** gelöst wurde am **Port**, nicht am Kabel (User-Vorgabe „Patchfeld-Nummerierung wird nicht wegen einer Config geändert") — `ether4` steht seit 08-13 auf untagged VLAN40, End-to-End-Beweis am 08-14 erbracht, s. **TP21.1 Punkt 4**. Historisch: **TP22 war das nächste Arbeitspaket** — TP17 (Shelly-Migration) ist parallel dazu vollständig abgeschlossen (alle Shellys auf VLAN30, User-bestätigt). Grund für den Nautobot-First-Ansatz: Governance-Entscheid „Config exklusiv über Nautobot" (s. oben) macht Nautobot zur Voraussetzung für praktisch jede weitere Router-/Switch-Änderung. Die Punkte 3-4 unten (Omada-PVID-Fix etc.) sind **obsolet** (User-Entscheid 2026-07-22 — Omada spielt seit TP15-Korrektur keine Rolle mehr, nie wieder aufgreifen) — ganze Liste braucht mal eine Aufräum-Session, hier nur der aktuelle Fokus vorangestellt.
> ✅ **TP16 (CF-Tunnel Round-Robin/macvlan-Host-Isolation → reziprokes 3-Tunnel-HA)** KERN UMGESETZT 2026-07-10 (#1033 strukturell weg). Offen nur Access-Strang (WARP/`vw`) = user-gated. As-Built `docs/cf-tunnel-mapping.md §10`.
1. ~~**TP1 (GE8) → TP2 (Omada Controller).**~~ ✅ erledigt 2026-06-15.
2. ~~**TP4 (Router-Cutover L3-Infra + WAN-Edge).**~~ ✅ erledigt 2026-06-15 (L3) + 2026-07-01 (Edge-Cutover, FB physisch weg).
3. **Jetzt: Omada-PVID-Fix** — Omada Access-Profile korrigieren (PVID 10/20, untagNetworkIds bereinigen, sicher P3/P5/P6 zuerst), dann IP-Migration NAS/macmini/pve auf VLAN-Subnetze + DNS-Einträge updaten. **IP-Migration NAS/pve = TP14** (NAS ✅ 2026-07-10, pve 🔜 Session B).
4. **Dann:** TP5 (Deny-by-default fertigziehen — Sonos-/HA-/mDNS-Holes + WAN-Input-Seal stehen bereits; **TP5.1 = DNS-Redirect + Live-Torch für Low-Trust-Geräte/Airfryer hängt dran**) → TP6 (QoS/CAKE).
~~TP7 (mDNS)~~ ✅ 2026-07-02 (inkl. HA/vlan20), ~~TP3 (WLAN/CAPsMAN)~~ ✅ 2026-06-18, ~~TP3.1 (ap2-VLAN-Trunk)~~ ✅ 2026-07-01,
~~TP3.2 (P750W/Discovery)~~ ✅ 2026-07-02, ~~Sonos-Migration~~ ✅ 2026-07-02 (alle 10 auf VLAN 40).
⏳ **TP3.3 (WLAN-Coverage-Kur) offen** — Fortsetzung neue Session (`docs/wifi-p750w-coverage-and-map.md`).
5. ~~**TP10 (VPN)**~~ ✅ Kern durch (2026-06-27) + Edge-Cutover (2026-07-01) → DURCH. Offen nur: DDNS-Auto-Updater.
6. **Nachgelagert/Research-getrieben:** TP8 (Access), TP9 (HA), TP11 (Monitoring + CF-Tunnel-Track-B/C), TP12 (IPv6), TP13 (Spaß).
7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest).
</details>
### TP43 · Gitea #32 — dw-Standort: interne BF-DNS-Namen über WG auflösbar machen — dw-FritzBox: Upstream `.36`+`.37`, DNS-Rebind-Ausnahme `bf.emsenhuber.net`, Fallback-Haken „öffentliche NS bei Unreachbarkeit" **gesetzt** (Ideal-Variante: `.37`-Redundanz bleibt, BF-down → public). (verworfene 420-Historie am Ende dieses Abschnitts)
**Live-Auslöser (2026-09-11):** Christoph am dw-Standort, WG-Tunnel zu BF up, kann `portainer.bf.emsenhuber.net`
nicht erreichen. Direkte IP `https://10.40.20.126:9443` geht ⇒ **reines DNS-, kein Routing-Problem.** Der
dw-Client nutzt die FritzBox (`10.95.10.1`) als Resolver; die kennt die interne Zone `bf.emsenhuber.net`
(Split-Horizon `.36/.37 → 10.40.20.40` Caddy) nicht.
**Korrektur gegenüber dem verworfenen Scope:** Der ursprüngliche Fall (`420.teilzeitladen.xyz`) ist ein
**öffentlicher** CF-Worker und war nie das Problem — der ist via TP41/WARP + CF Access gelöst. Das echte,
offene Problem sind **interne** BF-Namen (`*.bf.emsenhuber.net`) über den WG-Tunnel.
**Live verifiziert (2026-09-11, `dig @10.40.20.36` über den Tunnel):** `portainer.bf.emsenhuber.net
→ 10.40.20.40` (Caddy), `macmini.bf.emsenhuber.net → 10.40.20.167` — alle internen bf-Namen landen
in `10.40.0.0/16`, über WG erreichbar. `ds-fm`/`gateway-fm.emsenhuber.net → 188.114.96.10/.97.10`
= **Cloudflare-Anycast** (public), **nie `10.101.0.x`**. ⇒ **Der fm-Einwand (10.101.x intern ohne
Pfad von dw) trifft für `.36/.37` nicht zu** — fm läuft über den public `fm.emsenhuber.net`-Tunnel,
nicht über interne Records. (WG-Topologie TP10: dw-Peer AllowedIPs BF-seitig = `10.40.0.0/16`, dw
routet **nicht** nach `10.101.0.0/24`.)
**Constraint am dw:** die 24/7-Geräte `10.95.10.246/.247` sind **Cudy-APs (kein DNS-Host)**; die
FritzBox kann **kein Per-Zone-Forwarding** (nur einen globalen Upstream) **und per DHCP nur EINE
DNS-IP ausliefern** (⇒ „mehrere NS an die Clients" scheidet aus). Ein echter conditional forwarder
(dnsmasq `server=/bf.emsenhuber.net/.36`) bräuchte einen Zusatz-Dauerläufer — abgelehnt.
**Entscheidung (Christoph, 2026-09-11): FB-Upstream + Rebind-Ausnahme.** Clients bekommen per DHCP
`10.95.10.1` (die FB selbst, Default); die **FB forwardet an die BF-Resolver**: *DNS-Server → „Andere
DNSv4-Server"* Bevorzugt **`10.40.20.36`**, Alternativ **`10.40.20.37`** (beide BF, beide
Voll-Resolver → Redundanz bleibt). Sauberer als der zwischenzeitliche DHCP-Multi-Gedanke: die FB ist
der **einzige** Client-Resolver und `.36` löst **intern und public** auf → alles geht zuerst an `.36`,
public wird nur als Unreachable-Fallback konsultiert, **nie „zuerst"** → kein NXDOMAIN-Leak im
Normalbetrieb.
🔴 **DNS-Rebind-Schutz-Ausnahme `bf.emsenhuber.net`** (*Heimnetz → Netzwerk → Netzwerkeinstellungen →
DNS-Rebind-Schutz*) ist **zwingend**: sonst filtert die FB die Privat-IP-Antworten (`10.40.x`) als
Rebind-Angriff weg → bf-Namen liefern nichts, obwohl `.36` korrekt antwortet. Die Ausnahme ist ein
reines Namens-Whitelist (keine IP).
**BF-down-Fallback (Verzweigung am FRITZ!OS-Stand):** Christoph erinnert ein Setting „öffentliche NS
verwenden, wenn die primären nicht erreichbar sind". Vorhanden → aktivieren, Upstream bleibt
`.36`+`.37`, BF-down geht auf public. Fehlt → Alternativ-Slot statt `.37` auf **public `9.9.9.9`**
(kostet `.37`-Redundanz); dann zusätzlich prüfen, dass die FB **strikt primär** fragt (nicht
round-robbt), sonst NXDOMAIN-Leak für `bf.*` bei BF-up.
- **BF-up:** FB → `.36` → interne `bf.*` (→ `.40`, dank Rebind-Ausnahme durchgereicht) **und** public.
- **BF-down:** `.36`/`.37` unreachable → public-Fallback → public lebt, `bf.*` = NXDOMAIN (erwartet).
**Bewusst akzeptierter Tradeoff:** bei BF-up läuft Geli's **gesamter** Haushalt-DNS über Linz
(`.36`/AdGuard-Filter) — vermeidbar nur mit conditional forwarder (Zusatz-HW, abgelehnt).
**On-site-Verifikationen (Christoph, am dw):** (1) Rebind-Ausnahme greift — `nslookup
portainer.bf.emsenhuber.net` über die FB → stabil `10.40.20.40`; (2) BF-down-Fallback vorhanden
(Setting) oder via public-Alternativ-Slot gesetzt; (3) nur falls BF+public gemischt: FB fragt strikt
primär. Rückfall pro Gerät: `/etc/hosts` `10.40.20.40 <name>` oder WARP `fallback_domains
bf.emsenhuber.net → [.36,.37]` (TP41-Präzedenz).
**Sofort-Workaround (läuft unabhängig):** `https://10.40.20.126:9443` direkt bzw.
`/etc/hosts`-Zeile `10.40.20.40 portainer.bf.emsenhuber.net`.
**Abhängigkeiten:** WG-Route dw→`10.40.0.0/16` (steht). Grenzt an TP42 (interner Namensraum
`bf.emsenhuber.net`) und TP10 (Multi-Site-VPN). **Umsetzung = eigene On-site-Session** (FritzBox-DHCP
ist Christophs manueller Schritt, kein Tool-Zugriff).
---
**Historie — verworfener 420-Scope (angelegt 2026-09-01, verworfen 2026-09-11):**
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
uns verfranzen. Geli kommt rein, das zählt."* — Diese Prämisse fiel weg: `420.teilzeitladen.xyz` ist ein
public CF-Worker hinter CF Access (löst auf CF-Anycast), kein BF-Origin ⇒ dafür braucht es kein
Split-Horizon; Zugang via TP41-**WARP** gelöst (Geli 2026-09-10 via Android). Der Override-Bedarf für
**interne** Namen blieb dabei übersehen — deshalb reopened, s.o.
<a id="tp49"></a>
### TP49 · Gitea #76 — CF-Access: `login_method`-Includes auf Policy-Ebene ▸ 🔴 ABSOLUTE PRIORITÄT
*(Angelegt 2026-10-06 auf Christoph-Direktive. Fund vom 2026-07-10, stand seither unbehoben in
der Memory `project_cf_tunnel_1033` — beim Sichern der Memories wiederentdeckt.
**Am 2026-10-06 live nachgeprüft: der Ursprungsbefund ist kleiner als notiert.**)*
#### ⚠️ Korrigierte Lage (2026-10-06, live gegen die CF-API)
**Der interne Wildcard ist sauber.** TP41 hat `*.emsenhuber.net` (App `16aaec78…`) auf
Access-Groups umgestellt — nachgeprüft, Policies `bf-users` / `bf4` / `claude-mcp` binden an
`zt-humans` (drei einzelne E-Mail-Adressen), `zt-home` (`ip:90.146.33.177/32`) und
`zt-automation` (service_token). ✅ **Kein `login_method` mehr an `hass`/`grafana`/`portainer`/`pve`.**
**Offen ist genau ein Eintrag**, gefunden per Sweep über alle 9 Apps:
`420.teilzeitladen.xyz` (Terpenfinder, **öffentlich**), Policy `christoph` trägt
`login_method:github`. Der zweite Include (`onetimepin`) ist **unkritisch** — das ist der
gewünschte E-Mail-Einmalcode.
**Auswirkung:** ein beliebiger GitHub-Account sieht den Terpenfinder. Kein Zugriff auf die
interne Infrastruktur — die kritische Tür war schon zu. Aber es ist eine offene Tür auf einer
öffentlichen Domain, die „nur für uns" sein sollte.
#### Fix
`login_method:github` aus dem Include entfernen; `onetimepin` bleibt. Danach prüfen, dass
`christoph` und `geli` über ihre E-Mail-Adressen weiter hineinkommen.
⚠️ Vorher abstimmen (Christoph-Entscheid 2026-07-10) — Aussperrung wäre der teurere Fehler.
#### Gotchas (live bestätigt)
- 🔴 **Access-App-IDs sind 32 Hex** — die Kurzform `16aaec78` liefert `12045 invalid_application_id`.
Volle ID zuerst per `GET /accounts/{id}/access/apps` holen.
- Writes nur via `CF_API_TOKEN`/curl (MCP ist read-only). Token am 2026-10-06 als `active` verifiziert.
- Reusable Policy nur per **App-PUT** detachbar, nicht per `/policies`-DELETE.
#### Abgrenzung
**Nicht** TP41 (hat genau diesen Wildcard bereits saniert), **nicht** TP44 (Dokumentation).
⚠️ `420.teilzeitladen.xyz` gehört zum Projekt **blaueblüten** — Projektgrenze beachten.
**→ Abschluss, wenn der GitHub-Include entfernt und den beiden Personen der Zugang bestätigt ist.**