52 Commits

Author SHA1 Message Date
be51f12ab9 TP25: Roll-up — Kondensat nach teilprojekte-archiv.md (FERTIG)
TP25 ist abgeschlossen (Platzierung final, Fund E geschlossen, Gitea #17 closed).
Der aktive Stand (Kondensat, ~507 Zeilen) wandert ins Archiv als neue Sektion
## TP25 — Kondensat (Anchor #tp25); in teilprojekte.md bleibt ein Pointer.
Archiv-Index + "Dazu am Ende"-Notiz + Langfassung-Header ("nicht abgeschlossen"
→ abgeschlossen) nachgezogen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-22 13:14:49 +02:00
769d0ac6b3 TP25: Stand-Block + Header auf FERTIG (Platzierung final, vier Offen-Punkte aufgelöst)
Die vier "Offen"-Punkte aus dem 09-15-Zuordnungs-Block waren am selben Tag
im Steering-Handoff §6 aufgelöst (ap1 bleibt, Tausch-Korrektur verworfen,
Drei-Räume verworfen, Doku-Drift geschlossen), aber nie zurückgeschrieben.
Header ⏳→✅ FERTIG, Gitea #17 geschlossen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-22 13:11:10 +02:00
4a63c1e527 TP34.2: C4 → interne DNS-Namen als Daten (Gitea #73)
Träger für TP45 Schicht C4. TP34 (Caddyfile-Rendering) bleibt verworfen (#24);
der Daten→Config-Gedanke lebt als TP34.2 weiter. Prosa-Abschnitt + Anker angelegt,
Schicht-C-Tabelle / „Offen in C" / Abh. / Follow-up / TP37-Abgrenzung auf TP34.2
umgestellt, Archiv-Zeiger ergänzt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-22 13:06:39 +02:00
52a469185b docs: CLAUDE.md ausgedünnt (61→28 KB) + Diagnose-Fallen nach gotchas.md
Strukturelle Hygiene (Handoff 2026-09-22): Pflichtlektüre auf 3 Pointer,
Kritische Gotchas auf Kern-Cluster komprimiert, ausführliche Diagnose-Blöcke
(SSDP-Relay, DNS-Hang, networking.service, NTP, HA-http, Nautobot-healthcheck …)
nach docs/gotchas.md verschoben. Tote ALLNET/Omada-Einträge entfernt,
"korrigiert"-Notizen bereinigt (nur die korrigierte Angabe behalten).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-22 13:05:56 +02:00
2f3d44d4be docs: Handoff für CLAUDE.md-Ausdünnen (strukturelle Hygiene)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-22 12:48:11 +02:00
fe11ae2a5c TP25: Merkregel — no cached PMK ≠ FT-Problem
Tritt auch auf ft=no-SSIDs (IOT) auf, harmloser PMK-Cache-Miss.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-22 12:48:11 +02:00
7b365e1586 TP45 Doku: Schicht C (FQDN) als durch dokumentiert, Pflichtlektuere nachgezogen
- docs/teilprojekte.md: Schicht-C-Zeile auf C1-C3 durch; neuer Abschnitt mit
  C1 (27 Core-Statics, Job tp45-c-dns), C2 (AGH entdoppelt), C3 (ds-bf ueber
  Caddy), WARP-Abnahme, sowie den zwei offen gelassenen Befunden (AAAA-Leak,
  AGH-Doppeladresse) und der dig-Messfalle. ap3.bf-Bullet korrigiert.
- CLAUDE.md: Pflichtlektuere Block 0 beschrieb noch das ungeloeste FQDN-Problem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 12:31:59 +02:00
6d1d56e463 TP45 Schicht C: FQDN-Wiederherstellung (C1-C3)
C1 - Der Core haelt jetzt 27 /ip dns static (vorher 10). Die 12 toten Namen
loesen wieder auf: 9 Caddy-Frontends, tv.bf/mfc.bf, das bisher nirgends
existierende ap3.bf, dazu pve.bf und pve-mcp.bf von der Host-IP auf .40 und
die 5 bare Namen. Neuer GC-Job tp45-c-dns; er liest die Zeilen aus dem
Template selbst und macht daraus einen idempotenten Upsert.

C2 - AGH entdoppelt: upstream_dns nur noch 10.40.20.1, alle 26 Rewrites raus.
Der Core ist damit die einzige Quelle. /admin ist lokal wieder erreichbar
(header-frei 200, mit CF-Connecting-IP weiter 404 - der Guard bleibt zu).

C3 - ds-bf-Ingress-Origin auf https://10.40.20.40:443 + SNI (CF-Write).
Oeffentlich 200 in 0,24 s statt 000 nach 20 s.

Abnahme: Geraete-Read (27 Statics), Compliance 145/145 / 0 Drifts, dig vom
Mac fuer alle Namen, /admin lokal 200, ds-bf oeffentlich 200, Filter
(doubleclick -> 0.0.0.0) intakt, vw/hass/n8n/gitea unveraendert.

Offener Befund: der Core reicht AAAA weiter und liefert damit fuer die bare
Namen die CF-IPv6. Heute wirkungslos (kein globales IPv6 im Netz), aber
latent - bewusst dokumentiert statt geaendert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-22 12:16:56 +02:00
4720b5d223 Redaktionsschaden beheben: private_networks in beiden AGH-YAMLs
Commit 3eb03ae (2026-09-14, "Plaintext-Secrets entfernen") hat in beiden
Dateien `private_networks: []` durch `private_<redacted>: []` ersetzt -- ein
Redaktionswerkzeug hat den Schluesselnamen fuer einen Secret-Wert gehalten.

Folge: `adguard/AdGuardHome.yaml` ist die SSoT, die `agh-sync.py push` in beide
Live-Instanzen schreibt -- der naechste Push haette einen unbekannten Schluessel
in die AGH-Config gebracht (private_networks faellt dann auf Default zurueck,
oder der Parser lehnt die Config ab).

Wiederhergestellt in
  adguard/AdGuardHome.yaml:74
  docs/transaktionen/agh-bf1-AdGuardHome.snapshot.yaml:87

Abnahme: Repo-SSoT vs. live .36 -> 498/498 Zeilen identisch (ohne password-Zeilen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 23:06:30 +02:00
3f774b7b17 TP45 Schicht B2 Ruecknahme: VLAN30 vollstaendig auf den Stand vor dem Umzug
Zwei Christoph-Direktiven am 2026-09-21, in dieser Reihenfolge:
1. "mach die shellys sofort rueckgaengig" -- HA bindet sie per IP ein.
2. "wir sollten alle vlan30-aenderungen rueckgaengig machen" -- Sperr-Risiko
   Nuki: ein Fehlgriff haette aussperren koennen.

Neu in tp45-leases.py: die Tabellen SHELLY/REVERT und das Kommando `revert`
(nur die 26 Shellys) sowie `restore` (alles zurueck auf vor B2, inkl.
NSPanelPro-Reservierung und wiederangelegter CCU-Zeile). In gc-configplan.py
die Jobs tp45-v30-revert und tp45-v30-restore; _lease_move nimmt jetzt ein
tag, damit die :log-Zeilen den Job unterscheidbar machen.

Abnahme: Geraete-Read (alle 36 Adressen zurueck, NSPanelPro ohne Reservierung,
CCU .232 wieder da) und --refresh-compliance 145/145, 0 Drifts.

Zwei Funde, die als Merkregel bleiben: `make-dynamic` gibt es in RouterOS 7.24
nicht (der Rueckweg ist `remove`), und der GC-Plan-Status beweist nichts --
beide Laeufe meldeten Failed, waehrend der Job-:log alle 36 Statements
protokollierte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 22:22:22 +02:00
180c5b6c57 TP45 Schicht E: Umzug laeuft ueber den Core, nicht ueber Doppeladresse am Host
Neue IPs .110/.121/.130 werden zuerst am Core generiert (Sekundaer-IP auf
vlan20 + 1:1-NAT aufs Geraet), gemessen wird ab Tag 1 mit zwei Zaehlern
(neue IP = wer ist umgestellt, Alt-IP = ist noch was los). Das Geraet wird
erst angefasst, wenn seine Alt-IP still ist ("siedeln"). Alles ins Soll,
nicht als Transaktion. NAS siedelt zuletzt und gemeinsam.

Warum NAT statt Routing: im selben Subnetz antwortet das Geraet dem Client
direkt per L2 -- er verwirft die Antwort von .10 auf eine Verbindung zu .110.

Vorabcheck: NAS-NFS/iSCSI sind IP-ACL'd, aber ohne lebende Nutzer (pve hat
null NAS-Mounts) -- der src-nat-Umweg bricht nichts. Nebenbefund: die
Exports stehen auf totem 10.40.41.x plus drei 10.0.0.0/8-Freigaben.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 21:29:38 +02:00
7b03e01fab TP30 #4: "faellig" -> "Anlass" — kein Vorgezogen-Signal mehr
Nach dem Streichen der Ausnahme durfte die Wortwahl nicht mehr nach
sofortiger Einzelrotation klingen. Posten #4 ist anlass- aber weiterhin
gebunden; beide Stellen sagen das jetzt explizit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 17:43:37 +02:00
4e61c63ae7 TP30: "harte Ausnahme fuer neue Leaks" gestrichen
Die Klausel zog bei einem neuen Leak eine sofortige Einzelrotation vor und
widersprach damit dem Buendelungszweck des TPs (Kostentreiber sind die
Konsumenten, nicht das Setzen) und der gelebten Praxis: auch die
leak-getriebenen Posten #1 und #5-#9 blieben gebuendelt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 17:43:21 +02:00
08bbe1830f TP30 #4: CF_API_TOKEN-Leak 2026-09-21 nachgezogen + falsches Ablaufdatum korrigiert
Der Posten hing an einem harten Termin ("laeuft am 2027-01-09 ab"), der nicht
mehr existiert: dieses Datum gehoerte zum laengst revokten Alt-Token
9984078812bc..., waehrend das live CF_API_TOKEN (id 493ed8bd...) gar kein
Ablaufdatum hat (expires_on: null, verifiziert ueber /accounts/{id}/tokens/verify).

Damit ist die Rotation anlass- statt termingetrieben — Ausloeser ist jetzt der
Leak vom 2026-09-21 (Wert in einer Session-Ausgabe sichtbar geworden).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 16:58:53 +02:00
b91bac1265 TP45 Schicht B2: VLAN30-Reservierungen ins Oktett-Schema
36 DHCP-Reservierungen wandern aus dem Pool-Fenster (.5, .8, .202-.244)
in die Schema-Baender .11-.14 / .151-.176 / .177-.196. Dazu die CCU-Leiche
.232 (status=waiting, last-seen=never; die CCU lebt in VLAN20) entfernt und
SONOFF-NSPanelPro von einer dynamischen Lease auf .249 zu einer statischen
Reservierung auf .12 gepinnt.

Neuer Job `tp45-v30-leases` in gc-configplan.py. Er laedt MOVES/REMOVE/ADDS
per importlib aus tp45-leases.py, statt sie zu kopieren -- die Tabelle dort
ist die SSoT des Umzugs, eine zweite Kopie liefe still auseinander.

Warum der Job zwei Selektor-Formen traegt: die erste Fassung schrieb
ueberall `[/ip dhcp-server lease [find where ...]]`. In `[:len ...]` ist das
Syntaxfehler (`expected command name (line 1 column 55)`, live gemessen) --
Golden Config meldete trotzdem Job SUCCESS und Plan Completed, das Geraet
blieb unveraendert. Aufgefallen ist es an der Zahl der Log-Zeilen: von 38
`:if`-Statements hat kein einziger Zweig geloggt. Richtig ist
`[/ip dhcp-server lease find where ...]` ohne Klammerpaar um `find`;
die Argument-Form `set [find where ...]` bleibt.

Abnahme am Geraet, nicht am Plan-Status: 39 Leases im Soll-Schema, .232 und
.249 geraeumt, NSPanelPro dynamic=false; Compliance 145/145, keine Drifts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 16:18:05 +02:00
b509a2325e TP45 Schicht B1 Fix: Befehlspraefix nicht doppelt rendern
Erster Lauf nach dem Einbau ergab 144/145 -- und die eine Drift war mein Fehler,
nicht der des Geraets: backup_lines() strippte mit l[1:] nur das fuehrende '/',
nicht den ganzen Befehl. Das gerenderte Soll enthielt dadurch
`/ip dhcp-server lease ip dhcp-server lease add ...`.

Gefunden hat das die Compliance-Zahl selbst. Genau deshalb ist der NENNER die
Abnahme und nicht der Zaehler: 140 -> 145 beweist, dass die neue Rule auf allen
fuenf Geraeten auswertet; 145/145 beweist, dass das Soll stimmt.

Ausserdem: seed --force fuer einen bewussten Neuschnitt (der Guard gegen
versehentliches Re-Baseline bleibt).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 14:59:14 +02:00
4d273117b6 TP45 Schicht B1: DHCP-Reservierungen ins GC-Soll (Renderer + ComplianceRule)
Die 54 statischen Reservierungen des Cores standen in KEINEM Soll: das Template
rendert kein einziges /ip dhcp-server lease, und die Sektion stand unter keiner
ComplianceRule. Ein GC-Recovery von null haette einen Core ohne Reservierungen
erzeugt, in dem jedes IoT-Geraet in den Pool faellt -- und weil Compliance nur
prueft, was gerendert wird, waere die Zahl dabei gruen geblieben. Dieselbe
Luecke wie die handgelaufene `nautobot`-Adressliste.

- mikrotik_routeros.j2 [5b]: rendert local_config_context_data.dhcp_leases,
  gleiches Idiom wie firewall_filter.
- tp45-leases.py: seed/move/check. Zeilen werden verbatim uebernommen und nur
  im address=-Token getauscht -- das erhaelt Quoting und die 14 client-id-Felder.
  Reihenfolge bleibt die Geraete-Reihenfolge (Einfuege-, nicht numerisch).
- ingest-dhcp-lease-rule.py: Feature+Rule `dhcp-lease` auf RouterOS,
  config_ordered=False.

Noch KEIN Geraetewrite -- der Ist-Stand wurde als Soll gesetzt (Re-Baseline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 14:55:00 +02:00
92bd4aac85 TP45: pool-v20 auf .200-.249 verengt -- Schema-Baender .100-.199 freigegeben
Die 7 DHCP-Pools starteten alle bei .100 und ueberlappten damit die beiden
Oktett-Baender, die dieses TP gerade belegt: .100-.149 (Server-Hosts) und
.150-.199 (App-Services). In VLAN20 ist das eine echte Kollisionsflaeche --
nautobot .154-.158, gitea .157, pve-mcp .173 und shelly-mcp .180 tragen ihre
IP GERAETELOKAL und stehen in keinem Lease-Eintrag, der Core kann sie also
nicht schuetzen und einem DHCP-Client .154 anbieten.

Entscheid 2026-09-21: nur pool-v20 und pool-v30 werden verengt, v20 zuerst.
VLAN10/40/50/90/99 bleiben bei .100-.200 -- dort gibt es kein Schema-Band,
das etwas gewoenne, und VLAN40/VLAN10 haetten sonst 11 Sonos (.191-.201),
den iPhone (.196) und den Mac (.82) mitgezogen.

VLAN20 kostet die Verengung nichts: eine statische Reservierung (nas-bf
.20.10, in .10x) und 0 dynamische Leases.

pool-v30 bleibt bewusst unangetastet -- dort liegen 35 statische
Reservierungen IM Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot
.227, Twinkly .228, Withings .230/.231, lichterketten .236/.237, Nuki
.241/.244, CCU .232). Erst Schicht B zieht die um, dann pool-v30.
Nebenbei faellt damit die .30.250-Kollision: der Sonoff Dongle-M sitzt dort
statisch, der Pool reicht heute bis .250.

Neuer Job `tp45-pool-v20` (gc-configplan.py) mit gezaehltem Selektor: ein
`find where`, das 0 trifft, sieht wie "nicht vorhanden" aus statt wie
"kaputt" -- ein blankes `set [find where name=...]` liefe ins Leere und der
Job meldete Erfolg.

🔴 Lehre aus dem ersten Fehlversuch: jedes config_set-Statement muss auf EINER
Zeile klammer-vollstaendig sein. Mit `:if (...) do={` am Zeilenende geht
RouterOS in den Fortsetzungs-Modus und zeigt einen anderen Prompt, der
netmiko-Treiber verliert sein Muster und der Deploy stirbt mit
`E1015: Pattern not detected: ...@crs326\-core\].*$|#.*$)` -- Plan auf
Failed, ohne etwas geschrieben zu haben.

Abnahme: Gerate-Read pool-v20 = 10.40.20.200-10.40.20.249 (total=50,
used=0); DHCPOFFER-Probe auf vmbr0.20 -> 10.40.20.200 mit
Domain Name Server 10.40.20.30, 10.40.20.31; Probe-Lease wieder entfernt;
Compliance 140/140 ohne Drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 14:39:40 +02:00
974ecbc855 TP45 Schicht A: DHCP-Soll liefert AGH .30/.31 statt .36/.37
Die vier Core-Forward-Regeln fuer den Resolver waren nachgezogen, die
DHCP-Antwort an die Clients noch nicht: /ip dhcp-server network trug
weiterhin dns-server=10.40.20.36,10.40.20.37. Ein Client haette nach dem
AGH-Umzug den Resolver unter der alten Adresse gesucht.

Template geaendert, nach bf-golden-config gepusht (Git-DataSource ist die
Wahrheit, nicht die lokale Datei), Git-Sync, am Core gesetzt (alle 7 Netze),
per DHCPOFFER-Probe auf vmbr0.20 bestaetigt (Domain Name Server:
10.40.20.30, 10.40.20.31), Compliance 140/140 ohne Drift.

Randbefund (nicht in diesem Commit): die 7 DHCP-Pools starten live bei .100
und ueberlappen die Schema-Baender .100-.199 — pool-v30 reicht sogar bis
.250, wo statisch der Sonoff Dongle-M sitzt. Entscheid 2026-09-21: nur
pool-v20 und pool-v30 werden auf .200-.249 verengt, v20 zuerst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 14:34:49 +02:00
139d7c029d TP45 Schicht A: AGH-DNS-Freigaben .30/.31 im GC-Soll + am Core nachgezogen
- nautobot/tp45-agh-firewall.py (neu): pflegt die vier `TP5-E2: LAN->AGH dns*`-
  Regeln im `local_config_context_data.firewall_filter` (add/remove, Dry-Run
  default, Unified Diff). Bewusst NICHT `ingest-core-firewall.py` -- das ist ein
  RE-BASELINE-Werkzeug und wuerde die Aenderung mit sich selbst absegnen.
- gc-configplan.py: Jobs `tp45-agh-fw` (add + move vor die Log-Regel; `caddy`
  dahinter, damit der AGH-Block zusammenbleibt) und `tp45-agh-dhcp`.

Fix in `create_plans`: unter derselben `change_control_id` liegen nach einem
zweiten Lauf zwei Plaene. Die Auswahl folgte der REST-Reihenfolge und traf den
ALTEN -> der config_set-Guard brach ab, obwohl der neue Plan korrekt erzeugt
war. Jetzt gewinnt der juengste; die alten bleiben Historie.

Fund: `find where dst-address=<bare-IP>` matcht in RouterOS nie (nur `~` als
Regex). Die Zeile stand als "Verifikation" in
docs/transaktionen/TP23-agh-firewall.commands.rsc:7 und hat dort seit dem
2026-08-25 immer `0` gemeldet, ohne je etwas zu pruefen. Gleiche Klasse wie
`nc -z` (09-01): eine Pruefung, die 0 meldet, sieht wie "fehlt" aus, nicht wie
"kaputt".

Abnahme: Reihenfolge am Geraet (vier neue Regeln vor dem Drop), Compliance
140/140 / 0 Drifts mit `RouterOS - ip-firewall-filter` (ordered, frisch
bewertet, `actual` traegt die neuen Zeilen), `dig` vom VLAN10-Client gegen
.30/.31/.36/.37 alle beantwortet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 13:27:23 +02:00
b52d6aa49e TP45 Schicht A: Nautobot .30/.31/.32/.34 -> .154/.155/.156/.158
Oktett-Klasse .150-.199 (App-Services). Nautobot gibt damit .30/.31 an
AGH frei. .157 ist gitea, deshalb nimmt redis .158.

- Datenmigration ueber neues nautobot/tp45-ip-move.py: Nautobot verbietet
  das Aendern einer IP nach dem Anlegen -> anlegen/primary_ip4 umhaengen/
  Altobjekt loeschen, in dieser Reihenfolge
- Stack-Redeploy (PUT /api/stacks/203), Caddy-Backend, Core-Literal *24
  ueber GC deployt (Plan 51a90e25) mit Wirksamkeitspruefung am Geraet
- caddy/deploy.sh: push_verified() ersetzt den push||push-Fallback.
  `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann, der
  Fallback griff deshalb nie und `caddy validate` pruefte die ALTE Datei
  -> gruene Abnahme bei unveraendertem Zustand (Symptom: 502 bei
  gesundem Backend)
- 39 Referenzen in 28 Dateien mitgezogen; lebende Doku nachgezogen
  (CLAUDE.md, ip-plan.md, cf-tunnel-mapping §0-Kopf, TP16.3/TP31-Prosa)
- GC-Compliance 140/140, 0 Drifts

Neue Funde (TP45-Prosa + CLAUDE.md):
- Die Core-Adressliste `nautobot` wird von GC nirgends gerendert -> ein
  Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per
  SSH kommt. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
- Regel *24 (Literal) und *28 (Liste) sind Dubletten; der Literal steht
  bei 0 Paketen. Zusammenlegung bleibt offen.
- gc-manual-plan.py: /api/extras/job-log-entries/ liefert 404 (Nautobot
  3.x) -> bei gescheitertem Deploy fehlt die Fehlermeldung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 13:14:35 +02:00
8c6cb84dd1 TP45: TP32-Oktett-Tabelle korrigiert + TP45/TP46 angelegt
Bestandsaufnahme 2026-09-21 ergab: die FQDNs sind nicht in Caddy/CF
verschwunden, sondern (a) AGH wertet DNS-Rewrites im Filter-Pfad aus und
ueberspringt sie fuer unfiltrierte Clients -> Fallback auf den Core, der
nur 10 von 26 Namen haelt; (b) ds-bf ist der einzige Ingress-Eintrag, der
Caddy umgeht, und faellt damit unter TP5 deny-by-default.

- TP32-Oktett-Tabelle: .10-.19/.20-.29-Split, Server-Block .100-.149,
  Broker/Funk .9x, Platform .8x, Alt-IP-Reservierung dokumentiert
- TP32-Ordnungsgruppe 40 auf 102/112 reduziert (CCU gehoert zu haos)
- TP37-Status: P1+2 waren als "offen" notiert, live sind 28 VMs/26 Services
- TP45 (VLAN20/30 ins Schema + DNS/FQDN) + TP46 (CF-SSoT/Recovery) als
  Prosa-Abschnitt, Zeiger in der Pflichtlektuere

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 12:58:40 +02:00
e87619e89b TP38: bridge-port comment ungequotet + core place-before->move
RouterOS exportiert comment=TP38-OOB-MGMT ohne Quotes (kein Leerzeichen),
das Template hatte sie gesetzt -> bridge-port Drift. Fix: unquoten.

Der Deploy-`place-before=[find ...]` wurde von Netmiko ignoriert (Regel
landete hinter dem drop); im Job jetzt add + move.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 19:33:22 +02:00
177ba100eb TP38: OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall
Template mikrotik_routeros.j2: ether23 auf core+branch als OOB-Port
(pvid=99, admit-only-untagged-and-priority-tagged) + VLAN99 untagged=ether23.
Branch bekommt einen deny-by-default-Input-Filter (8 Regeln, src-Rahmen
spiegelt /ip service available-from), Core eine Regel "OOB->core mgmt".

Neue Skripte: ingest-tp38-oob.py (seedet firewall_filter in beiden
local_config_context_data) und der gc-configplan.py-Job tp38-oob (Deploy).
ConfigPlans angelegt+approved, Deploy steht noch aus.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 19:23:59 +02:00
bce40d182d TP25: Handoff Steering-Threshold + Terrasse-Verifikation
st-main-5g deployed (140/140), Terrasse am iPhone verifiziert (2.4G statt 5G/-80).
MacBook-Klebrigkeit dokumentiert, transition-time=immediate wird nicht umgesetzt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 18:40:22 +02:00
1d86983f63 TP25: st-main-5g Parameter-Reihenfolge gefixt + Commands-File
RouterOS exportiert transition-threshold vor wnm; Template-Reihenfolge ans
Export-Format angepasst. Deploy über manuellen Plan (Plan cc8340a8) — 140/140.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 18:30:24 +02:00
27effe937e TP25: AP-Zuordnung definitiv + Doku-Drift geschlossen
ap1=Sideboard/core ether3, ap2=Technik UG/core ether21, ap3=Technik OG/branch
ether24 (live per IP+MAC+Switch-Port). ap2 ether1 = :39. Nautobot Location
gesetzt (vorher None). Kanäle ap1/ap2 mitkorrigiert.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 18:27:19 +02:00
2cf32bde14 TP25: Steering 5G bricht früher ab (st-main-5g, -72/5s)
Terrasse saß am Rundgang 09-15 auf der -80/5G-Kante. transition-threshold
-80→-72, transition-threshold-time 10s→5s — nur für 5G; 2,4G bleibt auf
st-main (unset -80) für Reichweite draußen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 18:27:16 +02:00
1f19a3b4a9 TP25: AP-Zuordnung definitiv geklärt, ap1-Redundanz gemessen
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 18:06:15 +02:00
3e727f1b3b TP25: AP-Zuordnung per Kabel-Test geklärt, Messrunde Vorher, Tausch ap1↔ap3 begonnen
Handoff der Vor-Ort-Session: ap1=Sideboard, ap2=Technik OG, ap3=Technik UG (per
Kabel-Test eindeutig). Messwerte Vorher (Innenräume + Terrasse) festgehalten;
Terrasse wird ausschließlich marginal von ap2 bedient. Tausch ap1↔ap3 angefangen,
aber Netzzustand unklar (ap2 down, ap3 an ether8). Nächste Schritte dokumentiert.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-15 17:11:17 +02:00
037d2bd22c TP25: Deckungsfix Variante 3 terminiert -- Umsetzung 2026-09-15/16
Letzter offener TP25-Posten: Terrasse ein eindeutig dominantes Signal
geben (Ende von Band-Thrash und -89-Kollaps-Roams). Plan gemess Variante 3
(Deckenstrahler an den guten Platz, hAP in den Drei-Raeume-Schatten),
Vorher/Nachher-Terrassenmessung + Anruf-Abnahme, Kanalplan unberuehrt,
Config nur ueber GC-Weg. Issue #17, Kommentar #232.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 12:17:38 +02:00
0e6742f1d7 TP25: DTU-Verifikation DURCH -- 45000 ms eliminiert den 5:05-Zyklus, Fund F abgeschlossen
- 09-15 12:11: DTU online seit 06:41:49, uptime 5h29m, 0 Disconnects
  (Vergleich 09-14 mit 30,5 s: fuenf Zyklen in 18 min). DTU sitzt heute
  auf ap3-2,4 statt ap2/ch11.
- Fund F damit abgeschlossen: Ursache = DTU-Cloud-Recovery, getriggert
  durch hmsmqtt-Polling-Kollision. Messplan #225 gegenstandslos.
- Issue #17, Kommentar #230.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 12:12:30 +02:00
b7d82fe300 TP25: Fund-F-5:05-Zyklus geklaert -- Cloud-Recovery der DTU bei Polling-Kollision
- Wireshark-Analyse (hms-mqtt-publisher PR #110, HMS-800W-2T): Poll <30 s
  resettet den Countdown, Cloud-Updates fallen aus; nach 4-5 min startet
  die DTU eine neue TCP-Session -- und dabei (Log-Beweis 09-14) eine
  vollstaendige WiFi-Disassoziation inkl. DHCP-Churn. Zyklus-Abstaende
  decken sich aufs Minutenkomma. Fund F damit neu interpretiert.
- Ursache: hmsmqtt 30500 ms (Grenzzone). Hebel = Poll-Rate, nicht
  DNS-Hoheit: Christoph hat 45000 ms gesetzt (09-14 abends).
- Verifikation ab 09-15 frueh (Log-Lauf ohne 4-5-min-Zyklen = Fund-F-
  Abschlussbeweis); Fallback: DTU-Firmware klaeren (ha-hoymiles-wifi#134).
- Issue #17, Kommentar #228.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 22:18:35 +02:00
13a996c6d9 TP25: DTU-Hebel korrigiert -- OpenDTU/AhoyDTU entfaellt fuer HMS-800W-2T
Recherche 09-14 abends: HMS-XXXXW-2T (W = eingebaute DTU, protobuf-
Protokoll) wird von AhoyDTU/OpenDTU nicht unterstuetzt (lumapu/ahoy#1189
seit 09-2023 offen; Christophs eigene AhoyDTU inkompatibel). Lokale Tools
(hmsmqtt, dtuGateway, ha-hoymiles-wifi) nehmen die DTU nicht von der Luft.
Einziger Airtime-Heibel bleibt DNS-Hoheit ueber die Cloud -- von Christoph
bewusst zurueckgestellt (Cloud-Aufzeichnungen bis hmsmqtt + WLAN stabil).
Korrektur auch als Issue-17-Kommentar #227; Messplan #225 bleibt gueltig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 21:13:27 +02:00
2a21afe74a TP25: Messplan Wechselrichter/DTU (Hoymiles HMS-800W-2T) angelegt; 20:37-Nachtrag
- DTU geht mit der Sonne an/ab (09-14: 07:09-19:35) -- nachts unbetelligt,
  20:37-Verlust passierte ohne sie. Tagsueber ~5:05-Zyklus auf ch11 bei -53.
- Messplan (Issue #17, Kommentar #225): Referenz-Client auf ch11 pinnen,
  30-60 min Ping, Korrelation gegen die 5:05-Taktmarken.
- Hebel falls messbar: OpenDTU/AhoyDTU-Dongle statt Cloud-DTU (5-Min-Takt
  ist S-Miles-Telemetrie per Design, kein Ethernet an der DTU-WLite,
  Pin auf anderen AP kontraindiziert).
- 20:37-Nachtrag zur Abnahme: Totalverlust eigener Klasse (-89-Roam +
  iOS-Aufgeben, identisch 19:01 mit ft=no) -- keine ft=yes-Regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 21:04:42 +02:00
9fdcab58ad TP25: ft=yes-Abnahme DURCH — Anruf uebersteht 5 Band-Wechsel in 60 s
Live-Beleg 2026-09-14 20:33-20:34, Terrasse: nur roamed-to-Events, keine
disconnected-Zeile mehr im Fenster (davor 20:29/20:31 noch zwei volle
Trennungen). Hebel (1) damit komplett abgenommen; offen bleibt nur noch
der Deckungsfix Variante 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 20:37:37 +02:00
441e97ed84 TP25: ft=yes-Durchfuehrung dokumentiert (140/140, 0 Drifts, CAP-Abnahme sauber)
- CLAUDE.md: 09-04-Entscheid (ft=no) mit Ruecknahme 09-14 konsistent gemacht —
  Weg, Plan-ID, Compliance-Stand und CAP-Abnahme vermerkt, damit keine
  Folgesession einen ft=no-Zustand liest, der nicht mehr existiert.
- teilprojekte.md TP25: Hebel (1) auf DURCH gesetzt (Sequenz, Plan b1135f3e,
  Asserts, Polling-Timeout-Folgenlosigkeit), offene Abnahme = Terrassen-Anruf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 20:34:47 +02:00
89eb9dc3ac TP25: sec-main-Begruendungskommentar im Template auf 09-14-Stand bringen
Kontextkommentar ueber der sec-main-Zeile dokumentierte noch den 09-04-Entscheid
(ft=no); nach der Ruecknahme am 09-14 konsistent gemacht (Entscheid-Historie bleibt
als Zeile erhalten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 20:21:47 +02:00
4e80ffac28 TP25: sec-main zurueck auf ft=yes/ft-over-ds=yes (Ruecknahme 09-04, Hebel-A gescheitert)
- Go Christoph 2026-09-14 nach Terrasse-In-situ-Session: mit ft=no ist jeder
  Roam eine Voll-Reassoziation -- darin starben 2 Anrufe.
- Known-Good-Stand vor Hebel-2: Fund-E-Abschlussmessung 09-02 lief auf
  ft=yes/ft-over-ds=yes + 7.24.1 ruhig (4 reauth/8,6 h, 0 Ausfaelle).
- Template [6c]-Kommentar auf Stand 09-14 gebracht: signal-range-Boden
  geprueft und gescheitert (kein band-Property, cap-wifiN-Rejoin-Falle).
- Commands-File docs/transaktionen/TP25-ft-yes-secmain.commands.rsc fuer
  den GC-Deploy via gc-manual-plan.py (Dry-Run gelesen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 20:21:13 +02:00
548d73d84a TP25: ap2-In-situ widerlegt (Terrasse), Hebel-A-Machbarkeitspruefung negativ; TP30 #9 GITEA_ACCESS_TOKEN
- Handoff 2026-09-14: iPhone-Flapping auf der Terrasse = 5-GHz-Zellkante
  (-76..-81) plus client-getriebene Band-Wechsel in der Uberlappungszone
  -70..-77; vor ap2 (1-2 m) -49..-51 dBm, >2 min stabil => HW-Verdacht
  (23:02-Befund 2 m/-80) in situ widerlegt, kein Tausch begruendet.
- Hebel "Access-List-Band-Boden < -76 auf 5G" nicht sauber ausdrueckbar:
  kein band-Property, Interface-Scoping bricht am cap-wifiN-Rejoin,
  SSID-/MAC-Scope trae fen 2,4 GHz mit (Lockout-Risiko). Nicht umgesetzt.
- TP30: Posten #9 vorgemerkt -- GITEA_ACCESS_TOKEN am 09-14 ueber einen
  Fehlerpfad (URL-Konstruktion -> ValueError-Traceback) ins Transkript
  geraten; sechster Vorfall der Leak-Familie, neue Unterklasse.
- Gitea: TP25-Issue #17 Kommentar 217, TP30-Issue #20 Kommentar 218.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 20:16:20 +02:00
3eb03ae7b4 Plaintext-Secrets entfernt: Portainer-State-Dump gelöscht, n8n-Bearer + AdGuard-Token redaktieren
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-14 15:14:57 +02:00
3a97bbc41c opencode.jsonc entfernt (durch opencode.json ersetzt, Migration abgeschlossen)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-14 15:09:50 +02:00
7d237577e1 MCP: 7 globale Server auf Projektebene (env-Referenzen, keine Tokens im Repo)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-14 11:58:09 +02:00
777c4e0984 opencode.json: Projekt-MCP-Konfig mit {env:}-Platzhaltern
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-14 11:57:28 +02:00
425070ce11 Handoff: CF_API_TOKEN gerollt + Verify-Endpoint-Gotcha (Gitea #69 closed)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-13 15:13:23 +02:00
ae87e9f511 TP23 Stufe 5 Redesign: pro-Person Self-Service (agh-exception.py + Bau-Spec)
Stufe 5 reopened — DAU-Requirements (pro Person, "zuletzt geblockt -> 1 Klick",
temp+dauerhaft umschaltbar, scoped HA-Dashboard) nicht erfuellt → Stufe 6 gated.
agh-exception.py: $client-scoped allow/unallow, list-exceptions, recent-blocked
(selftest OK, reine Logik netzunabhaengig getestet). HA-Bau-Spec + Personen-
Client-Modell dokumentiert. Bau/Abnahme on-network offen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 14:32:13 +02:00
f941731ac4 TP35 ✅ abgeschlossen: Header ⏳→✅ (Gitea #25 closed/status:done)
5/6 Schichten durch; Schicht-3-pro-Projekt-Rollout laut Abgrenzung eigene
Sessions; On-Network-mikromcp-Smoketest als Merkposten → TP30 #20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:52:52 +02:00
3877c23505 GC-Backup: täglicher Scheduler angelegt (vorher gab es keinen)
Diagnose: die GC-Backups standen seit 2026-09-04, weil NIE ein Scheduler
existierte — Backup/Intended/Compliance liefen nur manuell aus Sessions. Die
"2×/Tag per SSH"-Annahme (CLAUDE.md Bot-Master-Zeile + gc-snapshot.py-Docstring)
war nie real; beide korrigiert.

Fix: Nautobot-Job "Execute All Golden Configuration Jobs - Multiple Device"
enabled + ScheduledJob "GC Backup+Compliance – taeglich" (platform=RouterOS,
täglich 03:30 UTC, Backup→Intended→Compliance). Sofort-Lauf SUCCESS, alle 5
Geräte backup/compliance auf 2026-09-12 10:35, frischer bf-golden-config-Commit.
Announcement-Eintrag ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:36:48 +02:00
123a0fefdf TP35 Schicht 2 ✅ angewandt: claude.json-Secrets auf {env:} geflippt
Flip via ~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude bei beendeter
App (Backup .bak-20260912-*), App neu gestartet, MCP-Server laden sauber. Alle 7
Secrets (mikromcp 5× PASS, hostinger Token, n8n Bearer) jetzt {env:}, verifiziert.
Restcheck: authentifizierter mikromcp-Read on-network (09-12 nur Connect-Timeout
VLAN99:80 = Erreichbarkeit, kein Auth-Fehler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:28:23 +02:00
aebf3776ac TP35 Schicht 2 vorbereitet: exports.zsh komplett, claude.json-Flip ausstehend
exports.zsh um die 3 von opencode bereits referenzierten, aber fehlenden
MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS ergänzt (25 Vars,
gui-env-sync 25/25 in launchd-Env). claude.json hält noch 7 Klartext-Secrets;
DRY-RUN bestätigt alle Ziel-Vars MATCH. Flip läuft bei BEENDETER App über
~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude (nicht aus laufender
Session — Claude Code schreibt State in die Datei). Announcement-Eintrag ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:19:00 +02:00
b6f2129b41 TP35 Schicht 4+5 ✅: /news-Announcements + Backup-Guard abgenommen
Schicht 4 (Announcement): docs/announcements.md angelegt (Format
## YYYY-MM-DD — Titel + Betrifft/Aktion, neueste oben) + globaler Skill /news
(liegt in ~/.claude/skills/news/ UND ~/.config/opencode/skills/news/, cp kein
Symlink). /news zeigt Einträge seit dem letzten Lauf (Seen-State ~/.news-seen/).
Erste Einträge: SSoT-Cutover + TP36-Live.

Schicht 5 (Backup-Guard): abgenommen — Guard + pre-commit-Hook live verdrahtet,
hat beim TP36-Live-Commit real gegriffen. Grenze dokumentiert (check prüft nur
Existenz der Guard-Datei, nicht deren Alter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:14:10 +02:00
82cd8098ef TP36-Live ✅: gc-snapshot.py live — 5 CustomFields auf dcim.Device
Legt die fünf TP36-Live-CustomFields (grouping TP36-Live) auf dcim.device an
und befüllt sie für alle 5 RouterOS-Geräte (ap1/2/3, crs326-branch/core)
ausschließlich aus Nautobot/Golden-Config — kein Geräte-Kontakt:
  ros_version 7.24.1 (flotte), compliance_pct 100, dhcp_server_count
  (core 7, Rest 0), dns_upstream_mode (core doh, Rest plain), mgmt_ip.

Idempotent (get-or-create CF, PATCH Device), Dry-Run default, --commit schreibt.
Token aus gitignored 210-nautobot.yml, Backup-Text nur intern geparst (nie ausgegeben).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:11:29 +02:00
62 changed files with 4019 additions and 1077 deletions

4
.gitignore vendored
View File

@@ -1,10 +1,6 @@
.DS_Store
.secrets
# Per-Projekt-MCP-Configs: können Tokens/URLs enthalten, projekt-spezifisch, nicht ins Repo
.mcp.json
# Python bytecode
__pycache__/
*.pyc

67
.mcp.json Normal file
View File

@@ -0,0 +1,67 @@
{
"mcpServers": {
"home-assistant": {
"type": "http",
"url": "${MCP_HOME_ASSISTANT_URL}"
},
"mikromcp": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"mikromcp",
"serve"
],
"env": {
"MIKROMCP_CONFIG_PATH": "/Users/christophemsenhuber/dev/bf-infra/mcp/mikromcp/config/routers.yaml",
"MIKROMCP_DEFAULT_ROUTER": "ap1",
"ROUTER_AP1_USER": "admin",
"ROUTER_AP1_PASS": "${MCP_MIKROMCP_ROUTER_AP1_PASS}",
"ROUTER_AP2_USER": "admin",
"ROUTER_AP2_PASS": "${MCP_MIKROMCP_ROUTER_AP2_PASS}",
"CRS326_CORE_USER": "admin",
"CRS326_CORE_PASS": "${MCP_MIKROMCP_CRS326_CORE_PASS}",
"CRS326_BRANCH_USER": "admin",
"CRS326_BRANCH_PASS": "${MCP_MIKROMCP_CRS326_BRANCH_PASS}",
"ROUTER_AP3_USER": "admin",
"ROUTER_AP3_PASS": "${MCP_MIKROMCP_ROUTER_AP3_PASS}"
}
},
"hostinger-api": {
"alwaysLoad": true,
"type": "stdio",
"command": "hostinger-api-mcp",
"args": [],
"env": {
"HOSTINGER_API_TOKEN": "${MCP_HOSTINGER_API_TOKEN}"
}
},
"cloudflare": {
"alwaysLoad": true,
"type": "http",
"url": "https://mcp.cloudflare.com/mcp"
},
"ollama": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"ollama-mcp"
],
"env": {
"OLLAMA_HOST": "http://127.0.0.1:11434"
}
},
"pve-mcp": {
"type": "http",
"url": "http://10.40.20.173:3000/"
},
"n8n-mcp": {
"type": "http",
"url": "http://10.40.20.167:3001/mcp",
"headers": {
"Authorization": "Bearer ${N8N_MCP_BEARER}"
}
}
}
}

389
CLAUDE.md
View File

@@ -1,38 +1,16 @@
# BF-Netzwerk — Projekt-Kontext (auto-read)
**Standort BF:** VLAN-segmentiertes Heimnetz auf MikroTik/RouterOS. Ziel: CAKE/QoS gegen Bufferbloat,
DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der Standalone-Entscheid von
2026-07-22 ist revidiert), Firewall Least-Privilege (**TP5 deny-by-default scharf seit 2026-08-18**), Multi-Site VPN.
DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17), Firewall Least-Privilege
(**TP5 deny-by-default scharf seit 2026-08-18**), Multi-Site VPN.
## Pflichtlektüre pro Sitzung (Reihenfolge)
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Für den **Detail/Kontext** eines TP dann den `### TPnn`-Prosa-Abschnitt in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`** lesen. *(Die frühere „Quick-Index-Block, 1 Read reicht"-Zeile ist überholt — Index/Status stehen nicht mehr in der Datei.)*
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Detail/Kontext eines TP → `### TPnn` in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`**.
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat.
3. Bei Live-Writes oder Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/teilprojekte-archiv.md`**.
4. ✅ **TP16.3 §5 durch (2026-09-01):** [`docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md`](docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md)
— alle vier oeffentlichen Namen laufen ueber `bf-public`/Caddy; Alttunnel bleiben als Rueckfallebene
(§6 offen). Enthaelt den 10-Sekunden-DNS-Fund und drei Messfallen.
5. ✅ **WLAN/Fund E — GESCHLOSSEN 2026-09-02: 7.24.1 war die Ursache, nicht `ft-over-ds`:**
[`docs/handoff-20260902-tp25-fund-e-messung.md`](docs/handoff-20260902-tp25-fund-e-messung.md)
— die Abschlussmessung ergab am 09-02 nur **4 `reauthenticating` in 8,6 h** (alle iPhone `F2:62`,
isolierte Einzel, 0 Ausfaelle), Verlauf **218 → 40 → 4**; Vorbedingung geprueft (Core-Uptime 1d18h,
`caps joined=0`). Hebel 1 (`ft-over-ds=no`) war widerlegt (6,4 h allein → 124× + 2 Ausfaelle) und ist
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
**Compliance 140/140**). **Am 2026-09-04 hat Christoph `ft=no` auf `sec-main` als Dauerzustand
entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen).
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf
`wifi1/wifi2` (Bring-up unset `!ft-over-ds`, aber nicht `!ft`; Abnahme zählt nur benannte
security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm)
**nur auf ap3** bei 3/3 Strichen. Fix: `!security.ft` auf ap3 (uniform `ft=no`), Quelle
`tp28-ap3-phase2-cap.rsc` korrigiert. **Merkregel: nach jeder `sec-main`-FT-Änderung jeden CAP einzeln
`get wifiN security.ft` prüfen — CAP-lokale Inline-Overrides sind compliance-unsichtbar.**
Der TP25-Kanalplan ist am 09-04 committed (DFS-Frequenzlisten + ap1-Provisioning, **140/140, 0 Drifts**).
Offen in TP25 bleibt nur **ap2 unterperformt** (2 m/−80 dBm). Vorstufen:
[Befund](docs/handoff-20260831-tp27-upgrade-und-hebel1-befund.md) ·
[Eingriff](docs/handoff-20260831-fund-e-hebel1-gezogen.md) ·
[Rueckbau](docs/handoff-20260901-fund-e-hebel1-zurueckgebaut.md).
6. ap3-Bring-up 2026-08-30: [`docs/handoff-20260830-ap3.md`](docs/handoff-20260830-ap3.md) — sechs Funde zu Werks-MikroTiks, CAPsMAN und Golden-Config-Lücken.
7. Inventur-Befund 2026-08-27: [`docs/handoff-20260827-inventur-und-architektur.md`](docs/handoff-20260827-inventur-und-architektur.md) — Master-Struktur, Asymmetrie, Drift.
3. Bei Live-Writes/Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/gotchas.md`** (ausführliche Diagnose-Fallen), **`docs/teilprojekte-archiv.md`**.
**Aktueller Fokus:** TP45 (IP-Schichten A/B/E, C4→**TP34.2**), TP34.2 (interne DNS-Namen als Daten, Gitea #73) und TP46 (CF-/Tunnel-SSoT) offen — Status im Gitea-Issue, Detail in den `### TPnn`-Abschnitten von `teilprojekte.md`.
## SSoT & Branch-Konvention (TP36-Cutover 2026-09-07)
@@ -51,15 +29,15 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
## Was du sofort wissen musst (Inventur 2026-08-27)
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell.
- **`bf-infra`** = aktiver Master mit Gitea-Remote: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git`. **Das Remote ist nur so viel wert wie der letzte Push** — Backup ist eine Disziplinfrage: `git push`. Der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist — **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Vor jeder Aussage über Remotes/Backups `git remote -v` live prüfen** (eine Session hat „kein Remote" behauptet, statt zu tippen).
- **`admin/bf-network-r6`** (Gitea) ist Archiv → read-only-Header.
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master, nicht manuell. Täglich **03:30 UTC** per ScheduledJob **„GC Backup+Compliance – taeglich"** (Nautobot-Job *Execute All Golden Configuration Jobs - Multiple Device*, Scope `platform=RouterOS`, Reihenfolge Backup→Intended→Compliance). Stand prüfen: `backup_last_success_date` am Gerät bzw. `scheduled-jobs` in Nautobot.
- **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo.
- **`inventory/` ist tot** (Pipeline seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer.
- **`inventory/` ist tot** (Pipeline stale). Live-Daten aus MikroMCP/CF/Portainer.
- **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen.
- **Terpenfinder = `420.teilzeitladen.xyz`** (CF-Worker, Repo `admin/blaueblueten-n8n`, hinter CF Access), **kein Caddy, kein Mac mini im Pfad**. `terpene.*` **existiert nicht und soll es nicht** (live geprüft 2026-09-10). Einziger terpene-benannter CF-Gegenstand: der **interne** Tunnel `terpene-db` (`f2d2fc8a`, ohne öffentlichen Hostname, Hyperdrive→VPC→Postgres, s. `docs/cf-tunnel-mapping.md` §17). ⚠️ **Grenze:** blaueblüten ist ein eigenes Projekt — nicht anfassen. *(Die frühere „terpene.teilzeitladen.xyz:8890 via Tunnel"-Zeile war veraltet — bf-mm ist gelöscht.)*
- **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. Inventar-Eintrag spiegelt das. `mcp-host-policy.md` ist SSoT für die Host-/IP-Konvention.
- **Mac mini `10.40.20.167`** ist der **n8n-Host** (n8n `:5678`, n8n-redis `:6379`, n8n-mcp `:3001`) — **NICHT** der zentrale MCP-Host. Performance-Überlegung: MCP-Clients würden auf dem macmini (User-Workstation) laufen und die Box zusätzlich belasten; deshalb liegt der MCP-Host auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft aktuell noch auf dem macmini (historisch, eigene Container-Definition dort) — gehört **bei Gelegenheit** auf die DS923 umgezogen (separater Punkt, **nicht** Teil dieser Session). Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`.
- **Terpenfinder = `420.teilzeitladen.xyz`** (CF-Worker, Repo `admin/blaueblueten-n8n`, hinter CF Access), **kein Caddy, kein Mac mini im Pfad**. `terpene.*` **existiert nicht und soll es nicht** (live geprüft 2026-09-10). Einziger terpene-benannter CF-Gegenstand: der interne Tunnel `terpene-db` (s. `docs/cf-tunnel-mapping.md` §17). ⚠️ **Grenze:** blaueblüten ist ein eigenes Projekt — nicht anfassen.
- **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. `mcp-host-policy.md` ist SSoT für die Host-/IP-Konvention.
- **Mac mini `10.40.20.167`** ist der **n8n-Host** (n8n `:5678`, n8n-redis `:6379`, n8n-mcp `:3001`) — **NICHT** der zentrale MCP-Host. Der MCP-Host liegt auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft noch auf dem macmini (historisch) — gehört **bei Gelegenheit** auf die DS923 umgezogen. Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`.
## TP-Management (2026-08-26)
@@ -73,7 +51,7 @@ Wenn während einer Session ein neues Teilprojekt, eine Abhängigkeit oder ein Q
in eine Quick-Index-Tabelle oder einen Ist-Stand-Block — die gibt es seit dem TP36-Cutover (2026-09-07)
nicht mehr in der Datei.
**Abgeschlossene TPs** stehen seit 2026-08-23 in `docs/teilprojekte-archiv.md` (Volltext, unverändert, mit Inhaltsverzeichnis) und ihr Gitea-Issue ist `closed` (Label `status:✅`/`❌`). **Ein TP wandert erst ins Archiv, wenn nichts mehr daran zu tun ist — und wieder zurück, sobald doch etwas dazukommt** (Issue dann `reopen`). Ein archiviertes TP ist ein *geschlossenes Ticket, kein versiegeltes*: bei neuer Arbeit an derselben Baustelle wandert der **Volltext zurück** nach `teilprojekte.md` (Zeiger bleibt im Archiv), **kein** Zwillings-TP mit neuer Nummer (Christoph-Direktive 2026-08-30, erster Fall TP28/`ap3`) — nur noch der betroffene `### TPnn`-Prosa-Abschnitt wird angefasst.
**Abgeschlossene TPs** stehen seit 2026-08-23 in `docs/teilprojekte-archiv.md` (Volltext, unverändert, mit Inhaltsverzeichnis) und ihr Gitea-Issue ist `closed` (Label `status:✅`/`❌`). **Ein TP wandert erst ins Archiv, wenn nichts mehr daran zu tun ist — und wieder zurück, sobald doch etwas dazukommt** (Issue dann `reopen`). Ein archiviertes TP ist ein *geschlossenes Ticket, kein versiegeltes*: bei neuer Arbeit an derselben Baustelle wandert der **Volltext zurück** nach `teilprojekte.md` (Zeiger bleibt im Archiv), **kein** Zwillings-TP mit neuer Nummer (Christoph-Direktive 2026-08-30, erster Fall TP28/`ap3`).
---
@@ -96,245 +74,86 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
## Hardware & Zugang
| Gerät | IP | Zugang |
|---|---|---|
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
| ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt |
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap2) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** (2026-08-16 verifiziert) · **TP15/TP21.1 ✅ 2026-08-08: PRODUKTIVER L3-Router** (ersetzt ap1) — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 Stufe 1 ✅ 2026-08-18; `servers=1.1.1.1` nur noch Bootstrap) · **NTP-Server für den Standort**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · 🔴 **FUNKSTILL seit 2026-08-30**: keine Provisioning-Regel am Manager, weil ap3 daneben steht und denselben Kanal 5180 fährt. Gerät bleibt erreichbar. Beim Umzug ans Sideboard → `5260/Ce` (Band B, DFS) und Uplink auf `core ether3` — Uplink `ether1` (PoE-Port, `poe-out=off`) an crs326-core `ether15`; SSIDs/Kanäle kommen vom Manager, ch36/ch1 · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` |
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — Uplink `ether1` (PoE) an crs326-branch `ether24`; ch44/ch11 (TP25: ch149 war unter ETSI leistungsbegrenzt) · MAC D0:EA:11:4D:40:3A · Config `routeros/.secrets/ap2-prov-base.rsc` |
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · Uplink `ether1` → **core `ether21`** (Trunk) · Kanäle **`5180/ax/Ce/I`** (5 GHz) + **`2437/ax`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** · **TP15 ✅: ersetzt sw2** (L2-Switch), Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap3) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** · **PRODUKTIVER L3-Router** — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 ✅)/**NTP-Server**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **reiner CAP** (Manager = `crs326-core`) · **aktiv am Sideboard** (Wohnzimmer; Uplink `ether1` → core `ether3`, PoE-Port `poe-out=off`) — Kanäle `5260/Ce` (Band B, DFS, Fallback 5280) + `2412`/ch1 · MAC D0:EA:11:4D:40:E0 · Config `routeros/.secrets/ap1-prov-base.rsc` |
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **reiner CAP** — **Technik UG**, Uplink `ether1` (PoE) an core `ether21`; Kanäle `5300/Ce` (Band B, DFS, Fallback 5320) + `2462`/ch11 · MAC D0:EA:11:4D:40:39 (ether1; Bridge/Mgmt `:3A`) · Config `routeros/.secrets/ap2-prov-base.rsc` |
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 · reiner CAP, Manager `crs326-core` · **Technik OG**, Uplink `ether1` → **branch `ether24`** (Trunk) · Kanäle **`5180/ax`** (Band A, non-DFS) + **`2437`** (ch6) · 🔴 **nur 2 Ethernet-Ports** → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon erlaubt) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` |
| **🔑 Einheitliches Admin-PW** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). |
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) |
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
| Nautobot (Portainer-Stack, NAS) | 10.40.20.30 | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
| ~~Omada-Controller~~ | — | ❌ **entfernt 2026-07-16** (User hat den Container gelöscht — overkill für 1 Switch auf der Abschussliste, s. TP2.1/TP15) |
| ~~omada-mcp~~ | — | ❌ **entfernt 2026-07-16** — mit dem Controller gelöscht. ✅ **Aufräumen erledigt/gegenstandslos (geprüft 2026-08-16):** nicht mehr in `mcpServers` registriert (nur noch als „disabled" in ein paar Projekt-Scopes = harmloser Merkposten), und **kein `omada`-Hostname mehr im CF-Ingress** |
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1 2026-07-10) · **mcp-pve v0.6.1 (Upgrade 2026-07-10: Single-Session-Bug 0.4.8 „Server already initialized" gefixt, TP14.2a)** · `~/.claude.json` ✓ |
| Proxmox (pve) | 10.40.20.100 | `ssh root@10.40.20.100` (SSH-Key ✓) · **TP14 2026-07-13: Host-Mgmt VLAN1→VLAN20** (vmbr0.20 + Default-GW-Flip); `.41.100` bleibt interim ohne Route |
| Docker-LXC (auf pve) | 10.40.20.126 | Portainer CE 2.40.0 (`https://10.40.20.126:9443`) · **TP14.2 2026-07-10: LXC 400 net0 tag=20/.20.126 (iface heißt jetzt `veth20`, NICHT eth0); Docker-Netz `vlan20`=ipvlan parent `veth20`; adguard_bf1 .20.16, adguard_fm2 .20.17** |
| Mac mini | **10.40.20.167** (VLAN20) | `ssh macmini` · ⚠️ die früher hier stehende `10.40.41.167` war **stale** (VLAN1) — live korrigiert 2026-08-16 (ARP `reachable` auf `vlan20`) |
| Arris-Kabelmodem | (Bridge-Mode, keine Mgmt-IP im BF-LAN) | Liwest-Edge · speist **`crs326-core vlan100`** (VLAN100-Transfer, WAN-IP `90.146.33.177`) — seit Core-Cutover 2026-08-08, davor via sw1 gi16 → ap1 · **FritzBox 6660 seit 2026-07-01 komplett ausgebaut** (Liwest-Tausch, kein Zurück); Telefonie nicht weitergeführt |
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
| Nautobot (Portainer-Stack, NAS) | **10.40.20.154** | Portainer ep14, Stack Id 203 · **TP21 ✅: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅: Golden-Config-App + Device-Adoption DURCH** (alle 4 MikroTik, Image `3.1.7-gc9`) |
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1) · **mcp-pve v0.6.1** (TP14.2a) · `~/.claude.json` ✓ |
| Proxmox (pve) | 10.40.20.100 | `ssh root@10.40.20.100` (SSH-Key ✓) · **TP14: Host-Mgmt VLAN1→VLAN20** (vmbr0.20 + Default-GW-Flip) |
| Docker-LXC (auf pve) | 10.40.20.126 | Portainer CE 2.40.0 (`https://10.40.20.126:9443`) · **TP14.2: LXC 400 net0 tag=20/.20.126 (iface heißt `veth20`, NICHT eth0); Docker-Netz `vlan20`=ipvlan parent `veth20`; adguard_bf1 .20.16, adguard_fm2 .20.17** |
| Mac mini | **10.40.20.167** (VLAN20) | `ssh macmini` · n8n-Host (siehe oben) |
| Arris-Kabelmodem | (Bridge-Mode, keine Mgmt-IP im BF-LAN) | Liwest-Edge · speist **`crs326-core vlan100`** (VLAN100-Transfer, WAN-IP `90.146.33.177`) · **FritzBox 6660 seit 2026-07-01 ausgebaut** (Liwest-Tausch, kein Zurück); Telefonie nicht weitergeführt |
*Ausgemusterte Geräte (ALLNET sw1, Omada sw2/Controller/ES208G, omada-mcp): Historie in TP15/TP2.1-Archiv + `switch/README.md`.*
---
## Tooling
- **MikroTik (core/branch/ap1/ap2):** MikroMCP-Server live → `mcp__mikromcp__*`-Tools (routerId `crs326-core`/`crs326-branch`/`ap1`/`ap2`).
Reads immer safe; Writes mit `dryRun:true` previewen, dann `apply_plan`. **Passwort setzen via `manage_user set-password`** (`/user set*` ist per Deny-Policy blockiert). ⚠️ **Reboot geht auf ROS 7.24 gar nicht ueber MikroMCP:** das `reboot`-Tool sendet `delay`, die REST-API kennt den Parameter nicht (`unknown parameter delay`), und `run_command` ist fuer `/system reboot*` gesperrt — Weg ist `SSHPASS="$PW" sshpass -e ssh admin@<ip> "/system reboot"` (PW aus `~/.secrets`, im Output
maskieren). 🔴 **Niemals `sshpass -p`** — das schreibt das Passwort in die Kommandozeile, wo es in
`ps` und jedem argv-Trace steht; **genau so ist am 2026-08-22 das Core-Passwort in ein
Sitzungstranskript geraten** und musste rotiert werden. `-e` hält es in der Umgebung des Kindprozesses.
✅ **Alle sechs Skripte im Repo sind am 2026-08-31 umgestellt** (`nautobot/gc-transaktion.py`,
`routeros/import-core-rsc.py` und die vier inzwischen nach
`routeros/archiv-tp15-cutover/` archivierten Cutover-Skripte) — gemessen
mit `ps`-Stichproben plus Negativkontrolle, nicht nur gelesen. **Neue Skripte erben die Regel nicht
von selbst**: wer einen sshpass-Aufruf schreibt, baut `env=dict(os.environ, SSHPASS=pw)` mit ein. `bridge/vlan/set` nur auf `[find … and !dynamic]`.
🔴 **Zweiter Leak-Weg, gleiche Klasse: `list_wifi_interfaces` gibt `security.passphrase` ALLER
Profile im Klartext zurück** — am 2026-08-24 hat ein Read, der nur die `cap-wifiN`-Zuordnung finden
sollte, damit **fünf WLAN-PSKs** (`main`/`iot`/`media`/`untrusted`/`guest`) in ein Transkript
geschrieben. Kein Werkzeugfehler — das falsche Werkzeug für die Frage. **Für Kanäle
`/interface wifi channel print detail`, für die CAP-Zuordnung `… print where master=yes` mit
Feldauswahl.** Der Scan selbst (`/interface wifi scan`, **nur am Manager**) ist unbedenklich.
Merkregel dahinter: **vor jedem Read fragen, was das Tool AUSSER dem Gesuchten noch ausgibt.**
- **~~ALLNET-Switch (sw1)~~:** ❌ **Gerät ausgebaut 2026-08-08** → der Telnet-Runner unter `switch/` ist **Archiv, nicht mehr ausführbar** (kein Endpunkt). Siehe `switch/README.md`. Nicht mehr als Werkzeug anbieten.
- **~~Omada-Switch (sw2)~~:** ❌ abgebaut 2026-07-23 (TP15, durch `crs326-branch` ersetzt). Controller + omada-mcp komplett gelöscht — **Omada spielt keine Rolle mehr, nie wieder aufgreifen** (User-Entscheid 2026-07-22). Aufräum-Reste ✅ geprüft 2026-08-16 (s. Tabelle oben).
Reads immer safe; Writes mit `dryRun:true` previewen, dann `apply_plan`. **Passwort setzen via `manage_user set-password`** (`/user set*` ist per Deny-Policy blockiert). ⚠️ **Reboot geht auf ROS 7.24 gar nicht über MikroMCP** — Weg ist `SSHPASS="$PW" sshpass -e ssh admin@<ip> "/system reboot"` (PW aus `~/.secrets`, im Output maskieren).
🔴 **Niemals `sshpass -p`** — das schreibt das Passwort in die Kommandozeile (`ps`/argv-Trace; so ist am 2026-08-22 das Core-PW in ein Transkript geraten). `-e` hält es in der Umgebung des Kindprozesses. **Neue Skripte erben die Regel nicht von selbst**: `env=dict(os.environ, SSHPASS=pw)` einbauen. `bridge/vlan/set` nur auf `[find … and !dynamic]`.
🔴 **`list_wifi_interfaces` gibt `security.passphrase` ALLER Profile im Klartext zurück** (2026-08-24: fünf WLAN-PSKs ins Transkript). **Für Kanäle `/interface wifi channel print detail`, für die CAP-Zuordnung `… print where master=yes` mit Feldauswahl.** Der Scan (`/interface wifi scan`, nur am Manager) ist unbedenklich. Merkregel: **vor jedem Read fragen, was das Tool AUSSER dem Gesuchten noch ausgibt.**
- **Nautobot Golden Config (= der einzige legitime Config-Weg für Netzgeräte):** `nautobot/gc-configplan.py`
(Token = `NAUTOBOT_SUPERUSER_API_TOKEN` aus `docs/nas-vlan20/stacks/210-nautobot.yml`, gitignored).
`--job <name>` legt manuelle Pläne an (Dry-Run default), `--deploy-ids …` deployt gestaffelt,
`--refresh-compliance` fährt **Backup → Intended → Compliance** in der einzig richtigen Reihenfolge.
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans
entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — ViewSet read-only.** ConfigPlans entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
- **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
Ablösung durch Komodo (native MCP) geplant.
- 🔴 **`pct push` meldet Exit 0, auch wenn es die Quelldatei NICHT lesen kann** (gemessen 2026-09-21).
`rc=0` trotz `failed to open … for reading`; ein `&&`/`||` dahinter beweist nichts, und `validate`/`reload` prüft dann die **alte** Datei → **grüne Abnahme bei unverändertem Zustand** (so sah der Nautobot-Umzug „gelandet" aus, live stand noch die alte Backend-IP). **Regel: Push-Werkzeug wird geprüft, nicht geglaubt** — nach jedem `pct push` den Inhalt vergleichen (`md5sum` diesseits/jenseits in *einem* Remote-Aufruf). Muster: `push_verified()` in `caddy/deploy.sh`. **Erst das eigene Werkzeug verdächtigen, dann das Netz.**
- 🔴 **Die Core-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert** (Fund 2026-09-21).
Der Mgmt-Zugang für die Deploy-Pipeline läuft über `src-address-list=nautobot` (Regel `*28`), die Liste
stammt aber aus dem handgelaufenen `routeros/tp5-etappe2-whitelists.rsc` — **steht in keinem Template**.
🔴 **Ein GC-Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per SSH kommt** — die
Pipeline wäre tot. Gleiche Lücke wie die DHCP-Reservierungen. **Merkregel: was auf dem Gerät existiert,
aber nicht im Template, ist eine Recovery-Lücke, auch wenn Compliance 140/140 grün meldet** — die Zahl
prüft nur, was gerendert wird. Ablösung durch Komodo (native MCP) geplant.
---
## Kritische Gotchas
- 📜 **~~ALLNET-Gotchas (1 Telnet-VTY, `save` nicht vergessen)~~ — gegenstandslos seit 2026-08-08** (Gerät ausgebaut). Nur noch historisch relevant, s. `switch/README.md`.
- 📝 **WLAN-Logs sind seit 2026-08-30 persistent — und der Topic heißt `wireless`, nicht `wifi`.**
Auch auf dem neuen `wifi-qcom`-Stack tragen Roaming-/Connect-Events `wireless;info` bzw.
`wireless,debug`. Ein geratenes `topics=wifi` wäre wirkungslos geblieben. Zwei Regeln auf dem
**Core** (nicht auf den CAPs — unter CAPsMAN führt der Manager die Events) schicken `wireless`
und `caps` in denselben Kollektor wie TP5 (`10.40.20.18:514`, Tagesdateien unter
`/opt/tp5-syslog/log/10.40.20.1/`). ⚠️ **RouterOS UND-verknüpft Topics innerhalb EINER Regel**
(`topics=info,!firewall` = info UND NICHT firewall) — `topics=wireless,caps` hätte **nichts**
gematcht, es braucht zwei Regeln. Vorher lief alles nur in einen ~75-Zeilen-Ringpuffer, der von
einer Stunde Arbeit überschrieben war; ein seit Wochen gemeldetes Client-Problem war deshalb
**rückwirkend nicht diagnostizierbar**. `/system logging` steht jetzt unter der 27. ComplianceRule
`system-logging` — vorher unter keiner. **Merkregel: bevor man ein sporadisches Problem sucht,
erst prüfen, ob es überhaupt irgendwo mitgeschrieben wird.**
- 🧮 **Ein Log-Burst über VIELE MACs ist Infrastruktur, über EINE MAC ein Client** (Fund 2026-08-31).
Am 31.08. sahen 13 × `caps,info … joined`, 4 × `activity timeout` und zwei Massenbursts
`association rejected, no cached PMK` (74 Zeilen, **38 verschiedene MACs**) wie eine CAPWAP-
Instabilität aus, die das ganze Netz trifft. Es waren die **Reboots des TP27-Upgrades**. Jedes
Ereignis fiel auf einen Neustart. 🔴 **Vor jeder Client-Diagnose erst `grep " caps,"` und
`/system resource get uptime` lesen** — sonst sucht man einen Gerätefehler, der ein geplanter
Reboot war. Der Umkehrschluss gilt auch: 93 von 93 `reauthenticating` von **einer** MAC auf
**einer** SSID sind ein echter Client-/SSID-Befund.
- 🗺️ **Die `cap-wifiN`-Karte verschiebt sich bei JEDEM CAP-Rejoin, nicht nur beim Provisionieren.**
Am 2026-08-31 wanderte sie zweimal an einem Tag (08:09 Re-Provisionierung, 14:36 Core-Reboot);
eine im Handoff festgehaltene Tabelle war nach sechs Stunden falsch. ⇒ **Interface-Namen in
Logzeilen sind ohne einen zeitnahen Karten-Snapshot NICHT zuschreibbar.** Vor jeder Auswertung
`/interface wifi radio print terse proplist=cap,radio-mac,interface` am Manager lesen — und wenn
zwischen Ereignis und Auswertung ein Rejoin liegt, die Zuordnung als **unbekannt** behandeln
statt zu raten.
- 🔑 **CAPsMAN verteilt KEINE Login-Passwörter — nur WLAN-PSKs.** Zwei verschiedene Dinge heißen
„Passwort": die **WPA-Passphrase** (`/interface wifi security`) liegt am Manager und wird an die
CAPs verteilt ✅; der **Geräte-Login** (`/user`) ist eine **lokale Benutzer-DB auf jedem Gerät** ❌.
Beleg: ap1 trägt `admin`, `ha`, `nautobot` lokal, mit gerätelokalen `last-logged-in`-Zeitstempeln.
Das einheitliche Admin-PW ist eine **von Hand angewendete Konvention**, keine Automatik — und
logisch kann es nicht anders sein: ein Gerät *wird* erst zum CAP, nachdem sich jemand eingeloggt hat.
- 🔴 **Ein neuer CAP braucht `configuration.manager=capsman` auf JEDEM Master-Radio.**
`/interface wifi cap set enabled=yes` allein reicht nicht — ohne das Feld bietet der CAP seine
Radios dem Manager gar nicht erst an. **Symptom (2026-08-30, ap3):** `remote-cap print` zeigt
`state=Ok`, aber `/interface wifi radio print` am Manager listet für dieses Gerät **nichts**, und
weil die Provisioning-Regel dann nicht matcht, sucht man den Fehler in der `radio-mac`. Im
Werkszustand ist das Feld leer (= `local`).
- 🔌 **Werks-MikroTik am Trunk: `ether1` ist WAN, und das sieht aus wie ein Netzproblem.** Beim
cAP ax (und den hAPs) steht `ether1` per defconf in der Interface-Liste `WAN`. Folge: Discovery
aus (**kein MNDP → in `/ip neighbor` unsichtbar**), Input-TCP verworfen (22/80/8291/8728 **zu**),
**ICMP aber erlaubt** — es *pingt*, was sich wie „erreichbar" liest. Kein Registry-Eintrag und
kein MikroMCP hilft, denn Port 80 ist zu. **Der Weg rein ist die LAN-Seite** (`ether2` oder das
Werks-WLAN); dort `/interface list member remove [find where interface=ether1]` — das ist der Fix.
- 🔀 **`pvid` ist INGRESS, `tagged`/`untagged` ist EGRESS.** Ein Port mit `pvid=99`, der in der
*tagged*-Liste von VLAN99 steht, nimmt untagged an und antwortet **getaggt**. Symptom
(2026-08-30): ein DHCP-Lease hing ewig auf `offered` und ARP auf `failed` — der DISCOVER kam an,
der ACK nie. Für ein Werksgerät gehört der Port **untagged** in die VLAN-Zeile.
- 🕳️ **Ein neues Gerät ohne `secrets_group` erzeugt eine LÜCKE, keine Fehlermeldung.** Ohne
Secrets Group scheitert der Golden-Config-**Backup**-Job still; die *intended* config wird
trotzdem sauber gerendert, aber ohne Backup entstehen **keine Compliance-Einträge** — und die
Gesamtzahl bleibt unauffällig (bei uns 104/104), weil ein Gerät ohne Ergebnisse auch keine Drift
meldet. 🔴 **Merkregel: eine gleichbleibende Compliance-Zahl nach dem Hinzufügen eines Geräts ist
ein BEFUND, kein Erfolg.** Erst `130/130` bewies, dass ap3 wirklich geprüft wird. Zweite
Voraussetzung: die dedizierten Service-Accounts `nautobot`/`ha` (TP-PWD) existieren auf einem
Werksgerät nicht.
- 🔢 **Firewall-Regelnummern aus `print where` sind Handles, keine Positionen.** Nach einem
`place-before` zeigte `/ip firewall filter print terse where comment~"..."` die Nummer **60**,
während `drop` auf **59** stand — die Regel lag trotzdem davor. Beweis ist der **Zähler**: eine
Regel hinter dem `drop` kann nicht zählen. Wer nach der Zahl geht statt nach `print stats`,
„repariert" eine funktionierende Regel kaputt.
- 🔌 **`crs326-core` hat KEIN PoE-out** (`/interface ethernet poe` existiert dort gar nicht,
CRS326-24G-2S+). Ein AP an einem Core-Port braucht eigene Stromversorgung. Der **cAP ax** bringt
dafür **Netzteil UND Gigabit-PoE-Injector im Karton** mit (Produktseite, 2026-08-30) — die
TP25-Notiz „kein Netzteil im Lieferumfang üblich" gilt für dieses Modell **nicht**. Symptom, wenn
man das übersieht: der Core-Port steht auf `no-link` und man sucht den Fehler im VLAN-Tagging.
- 🧩 **Die AP-Flotte ist seit ap3 heterogen — Literal-Listen im GC-Template sind dadurch gefährlich
geworden.** Der AP-Zweig hatte `ether3/ether4/ether5` als Bridge-Ports **hartkodiert**; das war die
stille Annahme *„AP == hAP ax² mit 5 Ports"*. Der **cAP ax hat zwei**. Seit 2026-08-30 kommt die
Liste aus **`ap.extra_ports`** (ConfigContext `ap-common`, Default = hAP-ax²-Bestand; ap3
überschreibt sie per `local_config_context_data` mit `[]`). ⚠️ **Nautobot merged Dicts tief, Listen
ERSETZEND** — für diesen Fall genau richtig, bei `zones`/`radios` wäre es eine Falle.
Merkregel: **bevor ein zweites Modell derselben Rolle dazukommt, die Literal-Blöcke im Template
daraufhin lesen, welche Zeile eigentlich „Gerätetyp" meint und nicht „Rolle".**
- 🔵 **Container auf MikroTik — korrigiert 2026-08-25, die alte Pauschalregel war nie nachgemessen.**
Es hiess hier „hAP ax² hat 128 MB Flash, kein USB → Container NICHT auf dem Router". Live gelesen:
**ap1/ap2 sind `arm64` mit 95 MB FREIEM Flash und 690 MB freiem RAM** — das reicht für **einen**
kleinen Container (USB ist laut MikroTik-Doku nur für schnellere Extraktion, nicht Pflicht).
Faustregel: **~70 MB nutzbar je AP**, weil RouterOS Reserve für sein eigenes `.npk` braucht.
🔴 **`crs326-core` dagegen NIE:** `arm` **32-bit**, **2,9 MB frei von 16 MB**. Der Core ist der
Netz-SPOF, aber **kein Anwendungs-Host** — das ist eine Hardware-Tatsache, keine Politik.
🔑 **Und die TP16.1-Aussage „Connector als RouterOS-Container ❌ nicht möglich" gilt nur für den
WARP-Connector**, weil der ein **TUN-Device** braucht (RouterOS reicht keines durch).
**`cloudflared` braucht kein TUN** — Userspace-Proxy. Die Schlussfolgerung überträgt sich nicht.
Merkregel: **bei einer „strukturellen" Einschränkung erst prüfen, ob sie ein Default ist oder ein
Gesetz** — und ob sie dem konkreten Dienst überhaupt gilt.
- **Schwere Container laufen weiterhin auf NAS/pve**, nicht am Router (Caddy, AdGuard: die eine Config
ändert sich staendig, die andere schreibt dauernd Logs auf NAND).
- ⏱️ **Ein konstanter 10-Sekunden-Hänger ist ein DNS-Timeout, kein Lastproblem** (Fund 2026-09-01,
TP16.3-Randbefund). `gitea` hing bei ~10 % der Anfragen exakt 10,1 s — quer über Web-UI,
`/api/healthz` **und** den Git-Pfad, also kein Rendering. Ursache: **MariaDB (LXC 302) hatte
`skip-name-resolve` auskommentiert** und machte bei **jeder neuen Verbindung** einen
Reverse-Lookup, während ihr `/etc/resolv.conf` noch auf den toten `10.40.41.254` zeigte
(glibc: 5 s × 2 Versuche = 10 s). Pool-Verbindungen waren schnell ⇒ es wirkte sporadisch.
🔑 **Drei Merkregeln daraus:** (a) **immer gegen eine zweite Sache messen, die denselben Weg
nimmt** — `vw`/`hass` über dieselbe Strecke hatten 0 von 30 Hängern, das hat CF, Tunnel, Firewall
und Caddy in einem Schritt entlastet; (b) **wer an einer DB hängt, sagt die Verbindungstabelle**
(`ss -tn | grep 3306`), nicht die Vermutung — hier hingen **Vaultwarden, Home Assistant und gitea**
an derselben MariaDB, und die Annahme „Vaultwarden nutzt SQLite" war schlicht falsch;
(c) **ein toter Resolver ist selten allein** — der Sweep über alle LXCs fand **fünf** Container mit
`10.40.41.254`. Sweep: `for c in $(pct list|awk 'NR>1{print $1}'); do pct exec $c -- grep ^nameserver /etc/resolv.conf; done`
- 🔌 **`networking.service` failed bei funktionierendem Netz = tote `auto`-Stanzas in
`/etc/network/interfaces`** (Ursache gefunden + behoben 2026-09-01). In vier LXCs standen dort noch
Einträge aus dem **alten VLAN1-Schema** (`eth0` → `10.40.41.x`) und aus früheren Versuchen
(`veth16`, `veth24`, Tippfehler `eth01`) — auf Interfaces, die es im Container gar nicht gibt.
`ifup -a` scheitert daran, das echte `veth20` kommt trotzdem hoch. **Fix:** nur die Stanzas
entfernen, deren Interface in `ip -br link` fehlt (`lo` und die echten behalten), Backup daneben.
🔴 **Abnahme ist ein echter Reboot, nicht `systemctl is-active`** — der Servicestatus sagt nichts
darüber, ob beim nächsten Start etwas hochkommt.
- 🧪 **Ein Messwerkzeug, das etwas anderes beantwortet als die Frage, kostet mehr Zeit als der Fehler**
(zweimal in einer Nacht, 2026-09-01). (a) **`nc -z` lieferte falsch-negative** „Port zu" für neun
Dienste gleichzeitig — inklusive des SSH-Ports, über den die Session gerade arbeitete; `curl` bekam
von demselben Host **HTTP 200 in 24 ms**. **Bei „alles gleichzeitig kaputt" zuerst das eigene
Werkzeug verdächtigen**, dann das Netz: ein Python-Socket oder `curl` entscheidet in Sekunden.
(b) **Ein Firewall-Zähler taugt nicht als Wegbeweis**, wenn der Client die Verbindung offenhält
(`cloudflared`): er zählt nur NEUE Verbindungen und steht still, obwohl alles läuft — richtig ist
die **Verbindungstabelle** (`/ip firewall connection`).
- 🔌 **`networking.service` kann in einem LXC dauerhaft `failed` sein, ohne dass das Netz fehlt** —
und dann startet alles mit `After=network-online.target` nach einem Reboot **nicht**
(Fund 2026-09-01: `101`, `102`, `106`, `107`; **influxdb2 kam nach dem Neustart nicht hoch**,
Unit `enabled`, **kein Startversuch im Journal**). Fällt nur auf, wenn man neu startet — die
Container liefen 13+ Tage. **Vor jedem geplanten LXC-Neustart `systemctl --failed` lesen und
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
- **VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a):** NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert (`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle: DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der *Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber `TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
- 📜 **~~ALLNET VLAN-DB (TP1)~~ — historisch.** Die VLAN-Semantik gilt weiter (**40=MEDIA, 50=UNTRUSTED**), die Portangaben (GE1/GE2/GE8) beziehen sich auf ein ausgebautes Gerät.
- **ap2 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5).
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko).
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅ 2026-08-17).** Der Entscheid „kein CAPsMAN" von 2026-07-22 ist **revidiert**: er galt der Konstellation ap1=Manager+AP, in der ein Manager seine eigenen Radios nicht provisionieren kann — mit dem Core als Manager sind beide APs symmetrisch. Der Core braucht dafür **kein** `wifi-qcom`, `capsman` steckt im Basispaket.
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät
antwortet `failure: not allowed (/interface/wifi/scan)`; das ist **weder device-mode noch eine
Policy-Lücke** (Gegentest: `/tool fetch` sagt auf demselben Gerät explizit „not allowed **by
device-mode**", und die Gruppe `full` hat `test`+`sniff`). Ein CAP besitzt sein Radio nicht.
Richtig ist `/interface wifi scan cap-wifiN duration=…` **am Core** — dort läuft es einwandfrei.
⚠️ Die `cap-wifiN`-Zuordnung vorher über `radio-mac` bestimmen, sie ist nicht stabil.
- 🔴 **Messfalle: unter CAPsMAN führt der MANAGER die Tabellen.** `/interface/wifi/registration-table` und die `wireless`-Logs sind auf ap1/ap2 **leer**, obwohl das WLAN läuft. **Immer am Manager zählen.**
- 🔴 **Die CAPs finden den Manager per Layer-2-Discovery** (`discovery-interfaces=vlan99`, `current-caps-man-address` ist eine **MAC**, keine IP). Deshalb müssen **MNDP (udp/5678)** und **CAPWAP (udp/5246-5247)** in der `input`-Kette des Core erlaubt bleiben — sonst kommt das WLAN nach dem nächsten AP-Reboot nicht zurück, während laufende Sessions unauffällig weiterlaufen (Fund 2026-08-18, TP5 Etappe 4).
- 🔁 **Slave-VAPs aendern heisst zweimal arbeiten (2026-08-22).** `slave-configurations` in einer
`provisioning`-Zeile zu setzen bewirkt am Funk **nichts**, solange nicht
`/interface wifi capsman remote-cap provision [find where identity="apX"]` nachgezogen wird —
und das gilt in **beide** Richtungen: auch beim Entfernen senden die VAPs sonst munter weiter.
🔴 **Und die Abnahme nicht am Kommentar festmachen:** beim Rueckbau wird der Interface-Kommentar
geleert, waehrend der VAP steht. Wer auf `comment~"ssid"` prueft, sieht "weg" und liegt falsch.
Richtig ist `datapath.vlan-id` bzw. die Interface-Zahl **am CAP** (`/interface wifi print` dort).
Beim Interface-*Zustand* ist ueberhaupt der CAP die Wahrheit: der Manager zeigt nach einer
Re-Provisionierung `running=false` fuer alle `cap-*`, waehrend am CAP alles laeuft — das
Gegenstueck zur Regel, dass die *Registration-Table* am Manager zu lesen ist.
- **Local Forwarding ist keine Einstellung mehr:** im neuen `/interface/wifi` gibt es kein zentrales Forwarding, `datapath` existiert dort nicht.
- 🔴🔴 **`/interface wifi cap slaves-datapath` MUSS auf beiden APs gesetzt sein** (`dp-caps-slaves`, ein lokales datapath-Objekt mit `bridge=bridge` und **ohne** `vlan-id`). Es ist das Einzige, was die vom Manager erzeugten **Slave-VAPs** lokal in die Bridge haengt: der Manager schickt `datapath.bridge=bridge` mit, **der CAP uebernimmt davon nur die `vlan-id`**. Fehlt es, assoziieren Clients auf `-iot`/`-media`/`-untrusted`/`-guest` sauber und bekommen **nie DHCP**, waehrend `dirtyvader-main` weiterlaeuft — dessen Master-Radios `wifi1`/`wifi2` sind statische Bridge-Ports. Genau so lagen IOT+MEDIA am 2026-08-18 rund 14 Stunden (Ursache: CAPsMAN-Cutover 08-17, nicht die TP5-Haertung). **Abnahme fuer jeden WLAN-Umbau ist eine frische DHCP-Lease pro SSID — NICHT die Registration-Table**, die zeigte waehrend des Ausfalls 25 zufriedene IOT-Clients.
- 🔎 **Merkregel „connect ok, kein DHCP":** zuerst `torch` auf dem betroffenen VLAN am Core UND den TP5-Drop-Log lesen. Sind **beide leer**, wird nichts verworfen — es kommt nichts an, und die Firewall ist entlastet. Dann liegt es an L2 (Bridge-Mitgliedschaft/Tagging), nicht am Regelwerk.
- mgmt-VLAN-Wechsel am Switch = Lockout-Risiko: VLAN muss existieren + Port muss es tragen, in einem Schritt.
- 🔥 **Die Firewall ist seit 2026-08-18 deny-by-default — in BEIDEN Ketten.** Ein neuer Dienst über eine VLAN-Grenze braucht ab jetzt eine explizite Regel, sonst funktioniert er einfach nicht. Diagnosewerkzeug ist die **Log-Regel direkt vor dem Drop** (`TP5-WOULD-DROP` / `TP5-INPUT-WOULD-DROP`), die weiterläuft und in den Kollektor `10.40.20.18:514` schreibt (Dateien auf dem Docker-LXC unter `/opt/tp5-syslog/log/`). **Erst dort nachsehen, bevor irgendwo anders gesucht wird.** **MAIN→INFRA/MEDIA/MGMT/IOT stehen dauerhaft offen** (IOT seit TP5 E5, 2026-08-19 — Drucker + Shellys); **IOT→MAIN bleibt bewusst zu**, dort sitzt der Wert der Segmentierung. Ad-hoc-Zugriff aus MAIN in ein **noch geschlossenes** VLAN (UNTRUSTED, GUEST) läuft über die selbstverfallende Liste: `/ip firewall address-list add list=jit-admin address=<deine-IP> timeout=1h`. *(Die früher hier genannten Beispiele „Scannen, IoT anlernen" brauchen jit-admin seit E5 nicht mehr.)*
- 🖨️ **Merkregel aus dem E5-Drop-Log:** der Drucker fällt mit **udp/161 (SNMP)** auf, nicht mit 631 — macOS fragt zuerst den SNMP-Status ab und bricht ab, bevor es zu IPP kommt. **Eine Whitelist auf 631 hätte nichts repariert.** Bei „Gerät antwortet nicht" also das Log lesen, statt den erwarteten Port zu raten.
- ⏰ **Zeitquelle ist der Core** (`/system ntp server`, seit 2026-08-18), ausgeliefert per **DHCP-Option 42** (`ntp-server=` in jedem der 7 Netze) und drift-überwacht (ComplianceRule `system-ntp`). Vorher lief **nirgends** eine Synchronisation: die anderen drei Geräte zeigten auf `10.40.99.1`, wo kein Server lief, und der Core selbst hatte gar keinen NTP-Client — ap2 war dadurch **17 Stunden** versetzt. 🔴 **Zwei Fallen dabei:** (a) ein NTP-Server mit `use-local-clock=no` und unsynchronisiertem Client liefert **Stratum 16**, das jeder Client verwirft — Server einschalten allein reicht nicht; (b) wird der Server eingeschaltet, *bevor* sein Client synchron ist, hält er diesen Zustand fest → **`enabled=no`/`=yes` nach der Synchronisation**. Merkregel: bei widersprüchlichen Zeitstempeln zwischen Geräten zuerst `/system ntp client get status` lesen, und zum Eingrenzen einen Client testweise auf eine öffentliche Quelle zeigen lassen.
- 📡 **SSDP-Relay für die macOS-Sonos-App:** LXC **108 `mcast-relay`** auf pve, zweibeinig `10.40.10.21` (VLAN10) + `10.40.40.21` (VLAN40), `multicast-relay` mit **`--noMDNS`** (mDNS macht der Core). Unit im Repo: **`mcast-relay/multicast-relay.service`**. **Zwei Beine sind zwingend** — SSDP ist link-lokal mit TTL 1, ein Relay muss Mitglied beider Broadcast-Domänen sein; Routing hilft nicht.
- 🔴 **`--ssdpUnicastAddr` gehört in ein zweibeiniges Relay NICHT — die Flagge hat den Hinweg 7 Wochen lang blockiert** (drin 2026-08-18, ausgebaut **2026-08-25**). Sie schreibt in `multicast-relay.py` **Z. 630** die Quelle *jedes* SSDP-Pakets an `239.255.255.250:1900` auf `<addr>:1901` um (**auch NOTIFY**); beim Senden greift dann **Z. 691** `if srcAddr == ssdpUnicastAddr and not onNetwork(srcAddr, tx['addr'], tx['netmask']): continue` — `10.40.10.21` liegt nicht im VLAN40-Netz, also **verwarf der Relay die M-SEARCH selbst, bevor sie die Player erreichte**. Der Rückweg gehört stattdessen **geroutet**: `forward TP5: MEDIA->MAIN` ist offen, die Player antworten unicast an den echten Sucher. (Upstream nennt den Rückweg selbst `# FIXME: record more than one?` — es gibt nur **einen globalen** Merkslot.)
- 🔎 **Symptom-Merkregel:** *App sucht hunderte Male, sieht NOTIFYs, baut aber nie eine Verbindung auf* = **Relay, nicht Netz**. Diagnose in einer Zeile — **Richtungsbilanz** im Journal: `grep -c "via eth1"` (VLAN10→VLAN40) gegen `grep -c "via eth0"`. **0 zu n heißt: der Hinweg fehlt.**
- ⚠️ **Aus dem Relay heraus lässt sich der Relay nicht testen** (Loop-Schutz ignoriert eigene Quelladressen) — am echten Client testen oder `journalctl -u multicast-relay` mitlesen. **Und am Client nicht auf Port 1900 messen:** Sonos-App *und* Spotify halten dort je ein `SO_REUSEPORT`-Socket, die Unicast-Antwort bekommt nur **eines** davon. Richtig ist eine Probe mit **ephemerem Quellport** (`bind((ifaddr, 0))`), genau wie ein echter Client sucht.
- 🔴 **Zweite, unabhängige Ursache desselben Falls (08-25): die Sonos-App suchte gar nicht.** Nach der Relay-Reparatur lag weiter nichts an — der Mitschnitt auf der VLAN40-Leitung (`pct exec 108 -- tcpdump -i eth1 -n -s0 -A "udp and host <client>"`) zeigte: **jedes** SSDP-Paket des MacBooks kam von **Chrome** (`ST: urn:dial-multiscreen-org:…dial:1`, `USER-AGENT: Chromium`), **kein einziges** von Sonos. Die App hatte Sockets offen (`*:1900`, `*:6969`, `TCP *:3400`) und redete mit der Cloud, legte aber nichts auf die Leitung. Ursache war das **Local-Network-Gate von macOS**: es stand auf **„an" und griff trotzdem nicht**. Fix = Schalter unter *Datenschutz & Sicherheit → Lokales Netzwerk* **aus, App beenden, wieder an, App starten** (hart: `sudo tccutil reset LocalNetwork com.sonos.macController2`).
⇒ **Merkregel: „die App sucht hunderte Male" ist erst dann ein Befund, wenn der Mitschnitt sagt, WER sucht.** Im Handoff vom Vormittag waren die gezählten 291 Suchen Chrome + eigene Proben; die Zahl hat drei Stunden in die falsche Richtung gezeigt.
- 🖥️ **Das MacBook ist dual-homed in VLAN10:** `en5` = **10.40.10.183** (USB-Dock, Default-Route) und `en0` = **10.40.10.82** (WLAN). Die früher als „zweiter Mac" gelesene `.183` ist **dasselbe Gerät** — beim Messen immer beide Beine prüfen.
- 🏠 **Home Assistant: der `http:`-Block in `configuration.yaml` ist WIRKUNGSLOS** (Fund 2026-08-26,
TP31). HA hat die http-Config nach **`.storage/http`** migriert (`"yaml_migration_done": true`);
seither wird die YAML kommentarlos ignoriert — selbst ein ungueltiger Schluessel darin erzeugt
**keinen** Config-Fehler. 🔴 **Die Logzeile fuehrt aktiv in die Irre:** `Received X-Forwarded-For
header from an untrusted proxy <ip>` faellt laut Quelltext nur bei **aktivem**
`use_x_forwarded_for` — man haelt die Option fuer wirksam und die Liste fuer falsch und editiert
die falsche Datei. Richtig ist der WS-Zweischritt **`http/config/configure`** (setzt `pending`,
startet HA neu) → binnen **5 Minuten** **`http/config/promote`**, sonst Auto-Revert auf `stable`.
Merkregel zur Eingrenzung: ein Testskript in `<config>/python_scripts/` + `python_script.reload` —
taucht der Service auf, liest HA das Verzeichnis, und die Frage ist nicht mehr *welche Datei*,
sondern *warum ein gelesener Block nicht wirkt*.
- 🐳 **Nautobot auf der DS923 braucht einen abgeschalteten Image-Healthcheck.** Der eingebackene Check ruft alle 10 s `nautobot-server health_check` = einen vollen Django-Start; unter Last dauert der >2 min, die Aufrufe **stapeln sich unbegrenzt** → Speicher alle → `fork()` mit ENOMEM → DSM räumt den ganzen Stack ab (am 2026-08-17 zweimal passiert, Load bis 73 bei 4 Kernen). Im Stack stehen deshalb `healthcheck: disable: true` auf worker+scheduler, `--concurrency=2` und `mem_limit`. **`cpus:` geht auf Synology nicht** (Kernel ohne CFS-Quota, der Stack-Deploy schlägt sonst fehl).
- 📜 **~~Omada ES208G = Easy Managed~~ — gegenstandslos** (Gerät abgebaut 2026-07-23, Controller gelöscht).
- **L3-Router = `crs326-core` (TP21.1 ✅ 2026-08-08, ersetzt ap1).** Bridge vlan-filtering=yes; VLAN-SVIs `.1` je VLAN mit DHCP (7 Server / 54 Leases); DNS über **DoH** zum CF-Gateway, `verify-doh-cert=yes` (TP23 Stufe 1 ✅ 2026-08-18). Die alte Notiz „core hat kein CA" war zu pessimistisch: es braucht kein Bundle, sondern **genau einen Root** (GTS Root R4, ~1 KB) — die Kette ist Leaf → GTS WE1 → R4. **Beide Firewall-Ketten enden seit 2026-08-18 mit `drop`** (TP5 E3/E4); `TP10-seal-lan` ist `disabled`, Mgmt-Input kommt nur noch aus VLAN10, von HA (`8728`), von Nautobot (`22`) und über `cf-mesh`. **ap1/ap2 sind NUR noch APs** — kein DHCP/DNS/NAT/Routing/WG dort. (Die frühere „TP4: ap1 ist L3-Router"-Zeile ist überholt.)
- **✅✅ Edge-Cutover (2026-07-01; Router-Umzug 2026-08-08): FritzBox ausgebaut, Arris im Bridge-Mode.** Liwest hat die FB6660 dauerhaft gegen ein Arris getauscht (physisch weg). WAN terminiert auf **`crs326-core vlan100`** (VLAN100-Transfer), core zieht die öffentliche Liwest-IP `90.146.33.177` → kein Double-NAT. **core = Public-Edge + WG-Hub** (fm/dw live), NICHT mehr ap1. Telefonie nicht weitergeführt. Runbook `routeros/wan-cutover-runbook.md` (ap1-Teile historisch).
- **hAP `device-mode=home` (ap1/ap2): scheduler + fetch blockiert** (`not allowed by device-mode`) → AP-Provisionierung idempotent **ohne** Scheduler. **`[/ping]` gibt nil in 7.23**. REST je AP `http://10.40.99.11|12/rest` (Creds = `ROUTER_AP{1,2}` in `~/.claude.json`). **Der L3-Router crs326-core ist KEIN device-mode=home** → dort gehen scheduler/fetch (z.B. DDNS-Updater).
- **Erstzugriff Werks-hAP am CRS-Fabric:** Uplink `ether1` = Werks-WAN → `192.168.88.1` (LAN) darüber NICHT erreichbar. Weg: Gerät auf **LAN-Port (ether2)** patchen ODER **Core-Temp-Pfad** `192.168.88.2/24` auf core-`bridge` + srcnat masq `192.168.88.0/24`, dann scp+`/import` (danach Temp-Pfad + stale ARP `.88.1` entfernen). Winbox-Neighbors zeigt die APs NICHT (MNDP pro-VLAN, Mgmt auf vlan99) — kein L2-Fehler.
- 🖨️ **ZWEI Drucker, nicht verwechseln (geklärt 2026-08-23):** der **Brother MFC** ist der
*eigentliche* Drucker und hängt auf **VLAN10** (`10.40.10.6`, MAC `30:05:5C:78:86:20`, **statisch**,
keine DHCP-Reservierung) — er steht damit im *selben* VLAN wie die Macs, die `forward`-Kette greift
dort gar nicht. Der **P750W ist der Etikettendrucker** auf **VLAN30** (`10.40.30.5`) — nur für ihn
gilt der VLAN-übergreifende SNMP-Fall unten.
- **P750W (Brother, VLAN30) läuft auf `dirtyvader-iot`** (User-bestätigt 2026-08-08) — **kein** Access-List-Pin nötig; die alte „nur am co-located L3-GW-AP erreichbar"-Logik ist mit L3-GW auf dem Core hinfällig. Coverage-Kanten/Terrasse grenzwertig (−76..−89 dBm); TX am AT-Max (18/15 dBm); echter Fix = Platzierung. SSoT `docs/wifi-p750w-coverage-and-map.md`. ⚠️ **Power-Save ist KEIN Thema dieses Netzes** — die früher hier stehende Merkregel „DHCP-geht-aber-Ping-nicht = ARP/Power-Save" war eine Fehldiagnose (2026-08-13 korrigiert: der Drucker war schlicht ausgeschaltet, das Ping-Symptom war WARP, s.u.). Nicht wieder einführen.
- **Ping auf JEDE lokale IP tot, aber Web/App/Print (TCP) geht überall = WARP-Client lokal versehentlich noch aktiv** (2026-08-13 live beobachtet, BF vor Ort): `cloudflared`/WARP ist QUIC-Proxy, kann **kein ICMP**, reicht TCP aber durch — auch wenn man physisch im BF-LAN steht, nimmt der Client bei aktivem WARP trotzdem den Tunnel-Umweg. **Fix: WARP trennen**, kein Router-Eingriff nötig. **Vor jeder Firewall-/Mangle-/VLAN-Suche zuerst prüfen**, wenn das Symptom „Ping auf ALLES tot, TCP geht" lautet — das ist ein Client-, kein Netzproblem. Strukturell schließt **TP16.1** (natives WireGuard-CF-Mesh auf `crs326-core` statt cloudflared-QUIC) genau diese ICMP-Lücke. Memory `ref_warp_local_icmp_trap`.
- 🔥 **Die Firewall ist seit 2026-08-18 deny-by-default — in BEIDEN Ketten.** Ein neuer Dienst über eine VLAN-Grenze braucht eine explizite Regel, sonst funktioniert er nicht. Diagnosewerkzeug = **Log-Regel direkt vor dem Drop** (`TP5-WOULD-DROP` / `TP5-INPUT-WOULD-DROP`, Kollektor `10.40.20.18:514`, Dateien Docker-LXC `/opt/tp5-syslog/log/`). **Erst dort nachsehen, bevor irgendwo anders gesucht wird.** **MAIN→INFRA/MEDIA/MGMT/IOT offen** (IOT seit TP5 E5, 2026-08-19); **IOT→MAIN bewusst zu.** Ad-hoc-Zugriff aus MAIN in geschlossenes VLAN (UNTRUSTED, GUEST): `/ip firewall address-list add list=jit-admin address=<deine-IP> timeout=1h`.
- 🖨️ **Drucker fällt mit udp/161 (SNMP) auf, nicht 631** — macOS fragt zuerst SNMP, bricht dann ab. Bei „Gerät antwortet nicht" das Log lesen, statt den erwarteten Port zu raten.
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅).** Mesh = **NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko). Der Core braucht dafür **kein** `wifi-qcom` (capsman steckt im Basispaket).
- 🔴🔴 **`/interface wifi cap slaves-datapath` MUSS auf beiden APs gesetzt sein** (`dp-caps-slaves`, `bridge=bridge`, **ohne** `vlan-id`). Fehlt es, assoziieren Clients auf `-iot`/`-media`/`-untrusted`/`-guest` sauber und bekommen **nie DHCP**, während `dirtyvader-main` weiterläuft (so lagen IOT+MEDIA am 2026-08-18 ~14 h). **Abnahme für jeden WLAN-Umbau = frische DHCP-Lease pro SSID — NICHT die Registration-Table.**
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager.** `cap-wifiN`-Zuordnung vorher über `radio-mac` bestimmen, sie ist nicht stabil.
- 🔴 **CAPs finden den Manager per L2-Discovery** (`discovery-interfaces=vlan99`, `current-caps-man-address` = MAC). Deshalb **MNDP (udp/5678)** + **CAPWAP (udp/5246-5247)** in der input-Kette des Core offen lassen.
- 🔁 **Slave-VAPs ändern = zweimal arbeiten:** nach `slave-configurations`-Änderung `/interface wifi capsman remote-cap provision [find where identity="apX"]` nachziehen (beide Richtungen). Abnahme nicht am Kommentar (`comment~"ssid"`), sondern `datapath.vlan-id` bzw. Interface-Zahl **am CAP**.
- 🔎 **„connect ok, kein DHCP":** zuerst `torch` auf dem VLAN am Core UND den TP5-Drop-Log lesen. Beide leer ⇒ nichts wird verworfen, es kommt nichts an → L2 (Bridge-Mitgliedschaft/Tagging), nicht Regelwerk.
- 📝 **WLAN-Log-Topic heißt `wireless`, nicht `wifi`.** Zwei Regeln auf dem **Core** (nicht den CAPs) schicken `wireless` + `caps` zum Kollektor `10.40.20.18:514`. ⚠️ **RouterOS UND-verknüpft Topics in EINER Regel** — `topics=wireless,caps` matcht **nichts**, es braucht zwei Regeln. `/system logging` steht unter der ComplianceRule `system-logging`. **Merkregel: bevor man ein sporadisches Problem sucht, erst prüfen, ob es überhaupt mitgeschrieben wird.**
- 🧮 **Log-Burst über VIELE MACs = Infrastruktur, über EINE MAC = Client.** Vor jeder Client-Diagnose erst `grep " caps,"` + `/system resource get uptime` lesen (ein Massenburst war oft ein geplanter Reboot). Umkehrschluss: 93/93 `reauthenticating` von einer MAC auf einer SSID = echter Client-/SSID-Befund.
- 🗺️ **Die `cap-wifiN`-Karte verschiebt sich bei JEDEM CAP-Rejoin.** Vor jeder Auswertung `/interface wifi radio print terse proplist=cap,radio-mac,interface` am Manager lesen; liegt zwischen Ereignis und Auswertung ein Rejoin, Zuordnung als **unbekannt** behandeln statt raten.
- 🔑 **CAPsMAN verteilt KEINE Login-Passwörter — nur WLAN-PSKs.** WPA-Passphrase (`/interface wifi security`) liegt am Manager ✅; Geräte-Login (`/user`) ist eine **lokale DB je Gerät**. Das einheitliche Admin-PW ist eine **Hand-Konvention**, keine Automatik.
- 🔴 **Ein neuer CAP braucht `configuration.manager=capsman` auf JEDEM Master-Radio.** `/interface wifi cap set enabled=yes` allein reicht nicht — im Werkszustand ist das Feld leer (= `local`).
- 🔌 **Werks-MikroTik am Trunk: `ether1` ist WAN.** Interface-Liste `WAN` → Discovery aus (kein MNDP), Input-TCP (22/80/8291/8728) **zu**, ICMP **erlaubt** → es pingt, wirkt erreichbar. **Weg rein ist die LAN-Seite** (`ether2`), dort `/interface list member remove [find where interface=ether1]`.
- 🔀 **`pvid` ist INGRESS, `tagged`/`untagged` ist EGRESS.** Werksgerät-Port gehört **untagged** in die VLAN-Zeile.
- 🕳️ **Ein neues Gerät ohne `secrets_group` erzeugt eine LÜCKE, keine Fehlermeldung.** Backup-Job scheitert still, keine Compliance-Einträge. 🔴 **Eine gleichbleibende Compliance-Zahl nach dem Hinzufügen eines Geräts ist ein BEFUND, kein Erfolg.** Zweite Voraussetzung: dedizierte Service-Accounts `nautobot`/`ha` existieren auf einem Werksgerät nicht.
- 🔢 **Firewall-Regelnummern aus `print where` sind Handles, keine Positionen.** Beweis ist der **Zähler** (`print stats`), nicht die Zahl.
- 🔌 **`crs326-core` hat KEIN PoE-out** (CRS326-24G-2S+). AP am Core-Port braucht eigenes Netzteil; der **cAP ax bringt Netzteil + PoE-Injector im Karton** mit.
- 🧩 **Die AP-Flotte ist heterogen (cAP ax = 2 Ports vs hAP = 5).** Bridge-Ports kommen aus **`ap.extra_ports`** (ConfigContext `ap-common`; ap3 überschreibt per `local_config_context_data` mit `[]`). ⚠️ **Nautobot merged Dicts tief, Listen ERSETZEND.** **Bevor ein zweites Modell derselben Rolle dazukommt, Literal-Blöcke lesen** („Gerätetyp" vs „Rolle").
- 🔵 **Container auf MikroTik:** ap1/ap2 = `arm64`, 95 MB Flash frei → **ein** kleiner Container ok (~70 MB nutzbar). **`crs326-core` dagegen NIE** (`arm` 32-bit, 2,9 MB frei) — SPOF, kein Anwendungs-Host (Hardware-Tatsache). **`cloudflared` braucht kein TUN** (Userspace-Proxy) → RouterOS-Container möglich; der WARP-Connector nicht (braucht TUN). **Schwere Container laufen weiterhin auf NAS/pve**, nicht am Router.
- **hAP `device-mode=home` (ap1/ap2): scheduler + fetch blockiert** → AP-Provisionierung idempotent **ohne** Scheduler. **`crs326-core` ist KEIN `device-mode=home`** → dort gehen scheduler/fetch (DDNS-Updater).
- **Erstzugriff Werks-hAP am CRS-Fabric:** Uplink `ether1` = Werks-WAN → `192.168.88.1` (LAN) darüber NICHT erreichbar. Weg: **LAN-Port (ether2)** patchen ODER **Core-Temp-Pfad** `192.168.88.2/24` + srcnat masq `192.168.88.0/24`, dann scp+`/import` (danach Temp-Pfad + stale ARP `.88.1` entfernen).
- 🖨️ **ZWEI Drucker, nicht verwechseln:** **Brother MFC** = eigentlicher Drucker, **VLAN10** (`10.40.10.6`, MAC `30:05:5C:78:86:20`, statisch). **P750W** = Etikettendrucker, **VLAN30** (`10.40.30.5`), läuft auf `dirtyvader-iot`. P750W-Coverage SSoT `docs/wifi-p750w-coverage-and-map.md`. ⚠️ **Power-Save ist KEIN Thema** (frühere „DHCP-geht-aber-Ping-nicht=ARP/Power-Save"-Merkregel war Fehldiagnose).
- **Ping auf JEDE lokale IP tot, aber TCP geht überall = WARP-Client lokal noch aktiv.** `cloudflared`/WARP ist QUIC-Proxy, kann **kein ICMP**. **Fix: WARP trennen**, kein Router-Eingriff. Memory `ref_warp_local_icmp_trap`.
- **L3-Router = `crs326-core` (TP21.1 ✅, ersetzt ap1).** Bridge vlan-filtering=yes; VLAN-SVIs `.1` je VLAN mit DHCP (7 Server/54 Leases); DNS über **DoH** zum CF-Gateway `verify-doh-cert=yes` (TP23 ✅). Beide Firewall-Ketten enden mit `drop`; Mgmt-Input nur aus VLAN10, von HA (`8728`), von Nautobot (`22`) und über `cf-mesh`. **ap1/ap2 sind NUR noch APs.**
- **✅✅ Edge-Cutover:** FritzBox aus, Arris Bridge-Mode. WAN terminiert auf **`crs326-core vlan100`**, öffentliche Liwest-IP `90.146.33.177`, kein Double-NAT. **core = Public-Edge + WG-Hub.** Runbook `routeros/wan-cutover-runbook.md`.
- **mgmt-VLAN-Wechsel am Switch = Lockout-Risiko:** VLAN muss existieren + Port muss es tragen, in einem Schritt.
**Diagnose-Fallen (Detail in [`docs/gotchas.md`](docs/gotchas.md), hier nur die Merkregel):**
- ⏰ Zeitstempel zwischen Geräten widersprüchlich → `/system ntp client get status` (Zeitquelle = Core, DHCP-Option 42, ComplianceRule `system-ntp`).
- 🔌 Vor jedem geplanten LXC-Neustart `systemctl --failed` lesen + danach Ports gegen vorher vergleichen.
- 🧪 „alles gleichzeitig kaputt" → **zuerst das eigene Werkzeug verdächtigen** (`nc -z` lügt), dann das Netz.
- 📡 Sonos sucht hunderte Male ohne Verbindung → **Relay, nicht Netz** (Richtungsbilanz `grep -c "via eth1"` vs `"via eth0"`).
- ⏱️ Konstanter 10-s-Hänger = **DNS-Timeout**, nicht Last (DB-Reverse-Lookup auf toten Resolver).
- 🏠 HA-`http:`-Block in `configuration.yaml` **wirkungslos** → `.storage/http` + WS `http/config/configure`→`promote`.
- 🐳 Nautobot auf DS923: **Image-Healthcheck aus**; `cpus:` geht auf Synology nicht.
---
@@ -346,43 +165,17 @@ Verhaltensregeln für nachfolgende AI-Sessions — kleiner, klarer, weniger Drif
- **Eine große Fragenrunde statt vieler kleiner.** Wenn der Plan von Annahmen abhängt, sammle die Annahmen in **einer** Runde, nicht in 3–4 Mikrorunden.
- **Master-Frage früh stellen.** Bei jedem Repo, jeder Datei, jedem Tooling: „Was ist die SSoT, wo lebt der Master, was ist Mirror?" — **bevor** du eine Lösung vorschlägst.
- **Drift zuerst, Lösungen später.** Drift-Tabelle posten + fragen, was richtig ist. Keine Lösungen vor dem Befund.
- **MCP-Erstellung/-Stilllegung = aktiv melden** (2026-08-27, Christoph-Direktive). Wer einen neuen MCP anlegt, ändert oder abschaltet — **meldet das proaktiv** an den User, nicht stillschweigend im Commit. Begründung: jede AI-Session ist gleichzeitig **Oberhand über Container** (MCP-Hosts) und **Garbage-Collector** auf Infra-Ebene — wenn Drift unbemerkt entsteht, sammelt sich Müll.
- **MCPs brauchen Host-Verifikation** vor Edit. Wenn der MCP verändert/editiert wird: vorher per Live-Read (nc/ps/curl) prüfen, ob der Host noch antwortet. Beispiel: 2026-08-27 — `shelly-mcp` zeigte auf `10.40.20.30:3030`, aber kein Host antwortet. Konfig auf `enabled=false`, dokumentieren statt hart fehlschlagen zu lassen. **(Stand 2026-08-28: shelly-mcp wird auf DS923 `10.40.20.180` deployt — `BUILD.md` liegt bereit.)**
- **Mac mini ist n8n-Host, nicht zentraler MCP-Host** (User-Direktive 2026-08-28): MCP-Dienste gehören auf die **DS923** (`nas-bf`, Portainer Endpoint 14) — nicht auf die User-Workstation. Aktuell laufen gitea-mcp + n8n-mcp **noch** auf dem macmini (historisch), gehören **bei Gelegenheit** auf die DS923 umgezogen.
- **Tote MCP-Skripte → ins Projekt-Repo, nicht global.** Wenn ein MCP nur für ein Projekt relevant ist (`cannaleo` für terpene, `terpene-pg` für terpene): gehört in `~/dev/<projekt>/mcp.json` + Skript im Projekt-Repo. Globale `~/.claude.json`/`~/.config/opencode/opencode.jsonc` = nur projekt-übergreifende Tools.
- **opencode-Skills: Default-Pfad kopieren, NICHT symlinken** (TP35 Schicht 6, 2026-08-28). Claude-Skill in `~/.claude/skills/<name>/SKILL.md` ist die Quelle; für opencode gehört er nach `~/.config/opencode/skills/<name>/SKILL.md` (`cp`, nicht `ln -s`). Begründung: opencode scannt diesen Pfad als Default — **kein** `skills.paths`-Eintrag in `~/.config/opencode/opencode.jsonc` nötig, das wäre redundant. Symlink wurde in der Inventur-Session testweise angelegt, hat Christophs "opencode = eigene Pfade"-Direktive verletzt und wurde wieder entfernt. Schema-Referenz: <https://opencode.ai/config.json> → `Config.skills.paths`. **Lehre aus dem Fall:** opencode-CLI-Verhalten nicht raten, Doku lesen — und ein "load-bearing"-Detail (Default-Scan-Pfad) zuerst verifizieren, bevor man ihn umgeht.
- **Skills sind IMMER global, niemals pro Projekt** (Christoph-Direktive 2026-08-28). `projektstatus` und alle übrigen Skills liegen **ausschließlich** unter `~/.claude/skills/` und `~/.config/opencode/skills/`. **Kein** Repo-lokaler Skill unter `.opencode/skill/` oder `.claude/skill/`. Begründung: keine Redundanz, keine Drift zwischen Skill-Versionen, eine Quelle pro Skill. **Merkregel:** wer einen Skill „überschreiben" will, editiert **die globale Datei** oder schreibt einen zweiten Skill mit anderem Namen — niemals ein Repo-Override.
- **Kommentar in Code ≠ Verhalten.** `mcp/shelly/.env` trug den Kommentar "gitignored, NICHT committen" — es gab aber **kein** `mcp/shelly/.gitignore`. Der Kommentar war Wunsch, nicht Wirklichkeit. `routeros/cloudflared/credentials.json` (Cloudflare-Tunnel-PrivateKey base64) lag ungegittoret im Working Tree. **Merkregel:** Behauptungen in Kommentaren/Konfig-Kommentaren durch `git check-ignore -v <datei>` (oder `git status`) verifizieren, bevor man darauf vertraut. **Lessons-Block erweitert 2026-08-28 nach `commit`-Session.**
- **opencode-TUI: führendes Leerzeichen vor `/command` manchmal nötig** (Beobachtung 2026-08-28, Skill „projektstatus" getestet; **identifiziert als bekannter Bug**). Symptom: Tippen von `/projektstatus` führt nicht zum Skill, ` /projektstatus` (führendes Leerzeichen) schon. Doku <https://opencode.ai/docs/commands> zeigt den Aufruf ohne Space. **Verifiziert:** Issue **anomalyco/opencode#27528** „ACP: prompt starting with '/' is silently dropped if command name is not recognized" — Status **Not planned (skipped)**, closed 2026-07-14. Maintainer haben es also gesehen und nicht gefixt. **Konsequenz:** Workaround (führendes Leerzeichen) ist der offizielle Pfad; Skill selbst ist nicht kaputt. Vor jedem neuen opencode-Build prüfen, ob das Verhalten weg ist.
- **Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität, wenn sie lang wird.** Befund 2026-08-28: der „Nächster Schritt (2026-08-26): TP31 Schritt 5"-Satz stand zwei Tage nach seiner Formulierung noch im Ist-Stand-Block, obwohl ich in derselben Zeit drei Folge-Sessions eröffnet hatte. **Merkregel pro Edit:** drei Stellen berücksichtigen — Quick-Index (Tabelle), TP-Abschnitt, Ist-Stand-Block. Keine schlägt die andere; wer eine ändert, **muss** die anderen mitlesen. Strukturelle Antwort = TP36 (SSoT via Nautobot, Datei wird read-only Snapshot) — eigene Session, nicht nebenbei. **Lessons-Block erweitert 2026-08-28 nach Skill-Verifikation in opencode.**
- **`sed 's/=.*/=<set>/'` maskiert NICHT, wenn das `.*` greedy ist und der Wert Slashes/Backslashes enthält** (Befund 2026-08-28, shelly-mcp-Deploy). Ich habe `sed 's/SHELLY_DEVICE_PW=.*/<set>/'` auf die Stack-YML angewendet — der **Wert selbst** wurde in den Output geschrieben, weil mein Replacement-String nicht greedy genug war und ich die Pipe nicht mit `cat -v` oder `head -15 | sed 's|=.*|=<set>|'` abgesichert hatte. Folge: **`SHELLY_DEVICE_PW` ist jetzt als verbrannt zu betrachten** — wandert in **TP30 Posten #3** mit Hochstufung. **Merkregel:** bei jeder Ausgabe von Secret-haltigen Dateien entweder `head -c <n>` (Abbruch nach Bytes), oder die Datei vor dem Read mit `tr -d '\n' | sed 's|=.*|=<set>|'` über einen Stream-Filter schicken — **niemals** einfach `cat` und auf einen Replacement-Pattern vertrauen. Vergleichbar zum PSK-Leak 2026-08-24.
- **Vor jeder Drift-Bewertung `python3 nautobot/gc-transaktion.py status` lesen** (Befund 2026-08-31,
zweimal am selben Tag gestolpert). **Drift aus einem laufenden Versuch sieht identisch aus wie ein
Versehen** — die Compliance-Zahl unterscheidet nicht. Konkret: `crs326-core`/`wifi-security`
(`ft-over-ds=no`) wurde dem 7.24.1-Upgrade zugeschrieben und als „braucht eine Entscheidung"
weitergereicht; tatsächlich ist es **TP25 Fund E Hebel 1**, um 08:09 bewusst gesetzt, während der
Core erst um 14:36 gehoben wurde, geführt als offene Transaktion `TP25-fund-e-ft-over-ds`.
🔴 **Eine solche Drift grün zu machen macht den Versuch unsichtbar.** Umgekehrt gilt: was **dauerhaft**
gelten soll (z.B. das `veth`/`masquerade` aus TP16.3 §3.4), gehört **nicht** in eine Transaktion,
sondern ins **Soll** — Transaktion = befristeter Versuch, Template = Dauerzustand.
⚠️ Das Skript löst den Token-Pfad gegen die Repo-Wurzel auf und findet ihn **aus einem Worktree
heraus nicht** (`210-nautobot.yml` liegt nur im Haupt-Checkout) — die Transaktionsliste kommt
trotzdem, nur die Compliance-Spalte fehlt dann.
- **Maskierungs-Muster auf den SCHLÜSSELNAMEN ankern, nie auf ein Trennzeichen** (Befund 2026-08-31,
zweiter Fall dieser Klasse nach `SHELLY_DEVICE_PW`). `grep -n "TOKEN" datei | sed 's/:.*TOKEN/…/'`
maskiert **nicht**: `grep -n` stellt `<zeile>:` voran, und das `:.*TOKEN` matcht diesen Präfix statt
den Wert — ein nachgeschaltetes `cut -c1-60` schneidet dann genau nichts weg. So sind zwei
Nautobot-Tokens ins Transkript geraten. **Sicher ist nur**
`sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'`. Und: **Werte nie suchen, um sie zu
sehen** — den Schlüssel per `sed -n 's/^…KEY=//p'` direkt in eine Variable holen, nie über `grep`
mit Ausgabe. Vergleichbar zum PSK-Leak 2026-08-24 und zum `sed`-Fall 2026-08-28.
- **Vor jedem Flash-Budget auf MikroTik `docker export | wc -c` messen, nicht `docker image inspect
.Size`** (Befund 2026-08-31, TP16.3). `.Size` meldete für `cloudflared` 27,5 MB, entpackt waren es
**69,5 MiB** — das offizielle Image passt damit auf keinen hAP/cAP. RouterOS braucht beim Import
**Tar UND entpacktes Abbild gleichzeitig**. 🔴 **Und `docker save` schreibt seit Docker 25
(containerd-Store) OCI statt docker-v1** → RouterOS meldet `no config found in manifest`, was nach
kaputtem Image klingt und ein Formatproblem ist. Beides in `docs/tp16.3-bf-public-ap-connector-runbook.md` §2.2.
- **MCP-Erstellung/-Stilllegung = aktiv melden** (2026-08-27, Christoph-Direktive). Wer einen neuen MCP anlegt, ändert oder abschaltet — **meldet das proaktiv** an den User, nicht stillschweigend im Commit. Jede AI-Session ist zugleich **Oberhand über Container** und **Garbage-Collector** auf Infra-Ebene.
- **MCPs brauchen Host-Verifikation** vor Edit: per Live-Read (nc/ps/curl) prüfen, ob der Host noch antwortet. Beispiel: `shelly-mcp` zeigte auf `10.40.20.30:3030`, kein Host antwortet → `enabled=false`, dokumentieren. (Stand 2026-08-28: shelly-mcp auf DS923 `10.40.20.180` deployt.)
- **Tote MCP-Skripte → ins Projekt-Repo, nicht global.** Projekt-spezifische MCPs (`cannaleo`, `terpene-pg`) → `~/dev/<projekt>/mcp.json` + Skript im Projekt-Repo. Globale `~/.claude.json`/`opencode.jsonc` = nur projekt-übergreifende Tools.
- **opencode-Skills: Default-Pfad kopieren, NICHT symlinken** (TP35 Schicht 6). Claude-Skill `~/.claude/skills/<name>/SKILL.md` ist die Quelle; für opencode `cp` nach `~/.config/opencode/skills/<name>/SKILL.md` (kein `skills.paths`-Eintrag nötig). Symlink verletzt Christophs „opencode = eigene Pfade"-Direktive. Lehre: opencode-Verhalten nicht raten, Doku lesen.
- **Skills sind IMMER global, niemals pro Projekt** (Christoph-Direktive). Nur `~/.claude/skills/` und `~/.config/opencode/skills/`. Kein Repo-Override. Wer überschreiben will, editiert die globale Datei oder schreibt einen zweiten Skill.
- **Kommentar in Code ≠ Verhalten.** `mcp/shelly/.env` trug „gitignored", es gab aber kein `.gitignore`. Behauptungen in Kommentaren per `git check-ignore -v <datei>` verifizieren, bevor man darauf vertraut.
- **opencode-TUI: führendes Leerzeichen vor `/command` manchmal nötig** (bekannter Bug, anomalyco/opencode#27528, „Not planned"). Workaround (führendes Leerzeichen) ist der offizielle Pfad.
- **Manuell gepflegte Doku verliert gegen die Realität, wenn sie lang wird.** Wer eine Stelle ändert, muss die anderen mitlesen (Quick-Index, TP-Abschnitt, Ist-Stand). Strukturelle Antwort = TP36 (SSoT via Nautobot).
- **Maskierungs-Muster: auf den SCHLÜSSELNAMEN ankern, nie auf ein Trennzeichen.** Sicher ist nur `sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'` — greedy `sed 's/=.*/…/'` schneidet Slashes/Backslashes nicht. **Werte nie suchen, um sie zu sehen** — den Schlüssel per `sed -n 's/^…KEY=//p'` direkt in eine Variable holen. Drei Leaks dieser Klasse: PSK 2026-08-24, `SHELLY_DEVICE_PW` 2026-08-28, Nautobot-Tokens 2026-08-31.
- **Vor jeder Drift-Bewertung `python3 nautobot/gc-transaktion.py status` lesen** (Befund 2026-08-31, zweimal gestolpert). **Drift aus einem laufenden Versuch sieht identisch aus wie ein Versehen** — die Compliance-Zahl unterscheidet nicht. 🔴 **Eine solche Drift grün zu machen macht den Versuch unsichtbar.** Was dauerhaft gelten soll, gehört ins **Soll/Template**, nicht in eine Transaktion. ⚠️ Das Skript findet den Token aus einem Worktree heraus nicht (`210-nautobot.yml` nur im Haupt-Checkout).
- **Vor jedem Flash-Budget auf MikroTik `docker export | wc -c` messen, nicht `docker image inspect .Size`** (Befund 2026-08-31, TP16.3). `.Size` meldete für `cloudflared` 27,5 MB, entpackt **69,5 MiB**. 🔴 **`docker save` schreibt seit Docker 25 OCI statt docker-v1** → RouterOS meldet `no config found in manifest`. Beides in `docs/tp16.3-bf-public-ap-connector-runbook.md` §2.2.
**Checkliste vor jedem Edit-Vorschlag:**
@@ -391,4 +184,4 @@ Verhaltensregeln für nachfolgende AI-Sessions — kleiner, klarer, weniger Drif
3. Habe ich **eine** Fragenrunde mit allen offenen Annahmen gestellt?
4. Habe ich für jeden Edit **deine** Bestätigung eingeholt (nicht nur für den Plan-Output)?
5. **MCP-Änderung?** Vorher Live-Check (Port/Prozess), aktiv melden, Garbage-Collector-Pflicht.
6. **Output-Länge.** Christoph hat sich heute (2026-08-28) wiederholt in langen Outputs verirrt. **Merkregel:** eine Antwort pro Edit-Vorschlag, eine Zeile pro Bestätigung, keine Wiederholung dessen, was im Diff steht. Keine Frage-Runden, wenn keine echte Annahme kollidiert. Erklärungstexte unter 6 Zeilen, sofern nicht ausdrücklich verlangt.
6. **Output-Länge.** Christoph hat sich wiederholt in langen Outputs verirrt. **Merkregel:** eine Antwort pro Edit-Vorschlag, eine Zeile pro Bestätigung, keine Wiederholung dessen, was im Diff steht. Keine Frage-Runden, wenn keine echte Annahme kollidiert. Erklärungstexte unter 6 Zeilen, sofern nicht ausdrücklich verlangt.

View File

@@ -31,10 +31,11 @@ dns:
ratelimit_subnet_len_ipv6: 56
ratelimit_whitelist: []
refuse_any: true
# TP45 C2 (2026-09-22): der Core ist der EINZIGE Upstream — AGH macht nur noch
# Filtering. Die interne Zone und die bare Namen kommen damit aus derselben Quelle
# wie fuer jeden anderen Client. DoH nach draussen macht allein der Core.
upstream_dns:
- https://px35ynlol3.cloudflare-gateway.com/dns-query
- '[/bf.emsenhuber.net/]10.40.20.1'
- '[/40.10.in-addr.arpa/]10.40.20.1'
- 10.40.20.1
upstream_dns_file: ""
bootstrap_dns:
- 1.1.1.1
@@ -158,85 +159,9 @@ filtering:
blocking_mode: default
parental_block_host: family-block.dns.adguard.com
safebrowsing_block_host: standard-block.dns.adguard.com
rewrites:
- domain: pbs.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: portainer.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: pve.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: gitea-mcp.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: n8n-mcp.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: ha-mcp.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: pve-mcp.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: z2m25.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: iobroker.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: ccu.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: nautobot.bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: vw.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: hass.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: n8n.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: gitea.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: ds-bf.emsenhuber.net
answer: 10.40.20.40
enabled: true
- domain: nas.bf.emsenhuber.net
answer: 10.40.20.10
enabled: true
- domain: macmini.bf.emsenhuber.net
answer: 10.40.20.167
enabled: true
- domain: mfc.bf.emsenhuber.net
answer: 10.40.10.6
enabled: true
- domain: tv.bf.emsenhuber.net
answer: 10.40.40.11
enabled: true
- domain: docker.bf.emsenhuber.net
answer: 10.40.20.126
enabled: true
- domain: platform-pg.bf.emsenhuber.net
answer: 10.40.20.20
enabled: true
- domain: core.bf.emsenhuber.net
answer: 10.40.99.1
enabled: true
- domain: ap1.bf.emsenhuber.net
answer: 10.40.99.11
enabled: true
- domain: ap2.bf.emsenhuber.net
answer: 10.40.99.12
enabled: true
- domain: branch.bf.emsenhuber.net
answer: 10.40.99.2
enabled: true
# TP45 C2 (2026-09-22): alle 26 Rewrites entfernt — sie waren die zweite, stille
# Definitionsquelle. Der Core haelt jeden dieser Namen als /ip dns static.
rewrites: []
safe_fs_patterns:
- /opt/AdGuardHome/userfilters/*
max_http_size: 256MB

View File

@@ -27,6 +27,19 @@ Ein VLAN = eine CIDR-Gruppe. Alle nutzen denselben CF-Upstream-Filter; Divergenz
`filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar.
## Personen-Clients (Stufe 5 Redesign 2026-09-12) — SSoT für Person→Gerät
Zusätzlich zu den VLAN-CIDR-Gruppen bekommt **jede Person einen eigenen AGH-Client**
(`/control/clients`): `name` = Personenname, `ids` = **alle** Geräte-IPs der Person. Das ist der
**einzige** Ort, an dem Person→Gerät gepflegt wird (kein zweites Mapping-File). Damit:
- wirken Self-Service-Ausnahmen **pro Person**: `@@||domain^$client='Personenname'` statt global;
- filtert `recent-blocked --client <person>` das Query-Log auf **ihre** Geräte;
- bleiben Ausnahmen stabil bei Gerätewechsel (Name statt IP).
🔴 **On-network verifizieren, bevor Stufe 6 scharf geht:** dass AGH `$client=` auf **Allow**-Regeln
(`@@`) in der laufenden Version greift. Fällt der Test negativ aus → Fallback `$client=<ip>|<ip>`
(die Geräte-IPs); `agh-exception.py scoped_rule()` kann beides. Die CIDR-Gruppen (Policy-Grenze)
bleiben unberührt — Personen-Clients tragen **nur** die Ausnahmen.
## Sync-Invariante
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen,
sondern **`.36` == `.37`**. Genau das prüft `agh-sync.py check`.
@@ -42,11 +55,16 @@ Referenz-RMW-Implementierung, die D aufruft (Deploy-Weg auf HAOS in Etappe D zu
| Aktion | CLI | AGH-API (RMW) |
|---|---|---|
| Domain global entsperren | `allow-domain <d>` | `GET /control/filtering/status` → +`@@\|\|<d>^` → `POST /control/filtering/set_rules` |
| Entsperrung zurück | `unallow-domain <d>` | dito, Regel entfernen |
| Client entfiltern | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
| Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
| Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
| Entsperrung zurück | `unallow-domain <d> [--client <person>]` | dito, exakt dieselbe Regel entfernen |
| „meine Ausnahmen" | `list-exceptions --client <person> --json` | `GET /control/filtering/status`, `@@`-Regeln parsen/filtern |
| „zuletzt geblockt" | `recent-blocked --client <person> --json` | `GET /control/querylog?response_status=blocked`, client-seitig auf Geräte-IPs gefiltert |
| Client entfiltern (grob) | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
| Client wieder filtern | `refilter <ip>` | dito, zurück auf global |
Reine Regel-/Filter-Logik ist netzunabhängig getestet: `python3 agh-exception.py selftest`.
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
RouterOS-Backstop.

View File

@@ -2,22 +2,37 @@
"""
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .37).
Von der HA-Self-Service-Schicht (rest_command) ODER von Hand aufrufbar. Wirkt IMMER auf
Von der HA-Self-Service-Schicht (shell_command) ODER von Hand aufrufbar. Wirkt IMMER auf
BEIDE Instanzen (kein Sync im Datenpfad). KEIN eigenes Timing — Ablauf/Revert macht der
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove.
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove/read.
allow-domain <domain> → user_rules += @@||domain^ (Domain für ALLE entsperren)
unallow-domain <domain> → user_rules -= @@||domain^
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false
allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
list-exceptions [--client <name>] [--json] → alle @@-Regeln, nach Client gruppiert
recent-blocked --client <name|ip[,ip]> [--limit N] [--json]
→ zuletzt geblockte Domains DIESER Person
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false (grob!)
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
status → user_rules + Client-Policy beider Instanzen
--client:
* ein AGH-Client-NAME (empfohlen, stabil bei Gerätewechsel) → Regel bekommt $client='Name',
IPs werden für recent-blocked aus /control/clients (ids) aufgelöst.
* ODER eine/mehrere IP/CIDR, komma-getrennt → $client=<ip>|<ip>, recent-blocked filtert darauf.
ohne --client bei allow/unallow = GLOBAL (für alle) — nur für Admin-Zwecke.
🔴 ANNAHME, on-network zu verifizieren, BEVOR Stufe 6 scharf geht: dass AGH $client= auf
ALLOW-Regeln (@@) in der laufenden Version wirkt. Die Regelkonstruktion steht isoliert in
scoped_rule()/parse_allow_rule() — falls die Syntax abweicht, nur dort anpassen.
Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv).
"""
import sys, os, json, base64, urllib.request, urllib.error, pathlib, re
import sys, os, json, base64, urllib.request, urllib.error, urllib.parse, pathlib, re, ipaddress, argparse
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9]+)*\.)+[a-z]{2,}$", re.I)
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
CIDR_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$")
# Ein Client-„spec" ist IP-artig, wenn JEDES komma-Glied IP oder CIDR ist; sonst = Name.
INST = ["10.40.20.36", "10.40.20.37"]
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt.
@@ -50,14 +65,20 @@ def creds():
sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden")
AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
_AUTH = None
def auth_header():
# lazy: erst beim ersten echten API-Call — so bleibt das Modul ohne Creds importier-/testbar.
global _AUTH
if _AUTH is None:
_AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
return _AUTH
def api(ip, path, method="GET", body=None):
url = f"http://{ip}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Authorization": AUTH,
headers={"Authorization": auth_header(),
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=8) as r:
@@ -69,26 +90,129 @@ def api(ip, path, method="GET", body=None):
sys.exit(f"{ip} {path} → {e}")
def rule(domain):
return f"@@||{domain}^"
# ---------------------------------------------------------------------------
# Reine Regel-Logik (netzunabhängig, self-testbar). Bei AGH-Syntax-Drift NUR hier anfassen.
# ---------------------------------------------------------------------------
def is_ip_spec(spec):
"""True, wenn spec eine (Komma-)Liste aus lauter IPs/CIDRs ist (→ IP-spec), sonst Name."""
parts = [s.strip() for s in spec.split(",") if s.strip()]
return bool(parts) and all(IP_RE.match(p) or CIDR_RE.match(p) for p in parts)
def allow(domain, remove=False):
def client_modifier(spec):
"""$client-Modifier-Wert für eine Regel. IP-spec → ip|ip ; Name → 'Name' (quoted)."""
if is_ip_spec(spec):
return "|".join(s.strip() for s in spec.split(",") if s.strip())
return "'" + spec.replace("'", "") + "'"
def scoped_rule(domain, client=None):
"""@@||domain^ bzw. @@||domain^$client=… (client = Name oder ip[,ip])."""
base = f"@@||{domain}^"
return base if not client else f"{base}$client={client_modifier(client)}"
_ALLOW_RE = re.compile(r"^@@\|\|(?P<domain>[^\^]+)\^(?:\$client=(?P<client>.+))?$")
def parse_allow_rule(rule):
"""@@-Regel → {'domain':…, 'client': <str oder None>, 'raw': rule} oder None."""
m = _ALLOW_RE.match(rule.strip())
if not m:
return None
return {"domain": m.group("domain"), "client": m.group("client"), "raw": rule.strip()}
def ip_in_specs(ip, specs):
"""Ist ip (aus dem Query-Log) von einer der specs (IP oder CIDR) erfasst?"""
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
for s in specs:
s = s.strip()
try:
if "/" in s:
if addr in ipaddress.ip_network(s, strict=False):
return True
elif ip == s:
return True
except ValueError:
continue
return False
# ---------------------------------------------------------------------------
def resolve_client_ips(spec):
"""spec → Liste von IP/CIDR-strings. IP-spec direkt; Name → ids des AGH-Clients (von .36)."""
if is_ip_spec(spec):
return [s.strip() for s in spec.split(",") if s.strip()]
data = api(INST[0], "/control/clients")
for c in data.get("clients", []):
if c.get("name") == spec:
return list(c.get("ids", []))
sys.exit(f"kein AGH-Client mit name={spec!r} (für IP-spec Komma-Liste aus IP/CIDR angeben)")
def allow(domain, client=None, remove=False):
domain = domain.strip().lower()
if not DOMAIN_RE.match(domain):
sys.exit(f"ungültige Domain: {domain!r}")
r = rule(domain)
r = scoped_rule(domain, client)
for ip in INST:
st = api(ip, "/control/filtering/status")
rules = st.get("user_rules", [])
if remove:
rules = [x for x in rules if x != r]
rules = [x for x in rules if x.strip() != r]
elif r not in rules:
rules = rules + [r]
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(rules)})")
def list_exceptions(client=None, as_json=False):
st = api(INST[0], "/control/filtering/status") # .36 == .37 (Invariante, via agh-sync geprüft)
items = []
for raw in st.get("user_rules", []):
p = parse_allow_rule(raw)
if not p:
continue
if client is not None and (p["client"] or "") != client_modifier(client):
continue
items.append(p)
if as_json:
print(json.dumps(items, ensure_ascii=False))
return
if not items:
print("— keine Ausnahmen —"); return
for p in items:
who = p["client"] or "GLOBAL"
print(f" {p['domain']:40} client={who}")
def recent_blocked(client, limit=20, as_json=False):
specs = resolve_client_ips(client)
q = urllib.parse.urlencode({"response_status": "blocked", "limit": max(limit * 8, 100)})
data = api(INST[0], f"/control/querylog?{q}")
seen = {} # domain -> {count, last}
for e in data.get("data", []):
c = e.get("client", "")
if not ip_in_specs(c, specs):
continue
dom = (e.get("question", {}) or {}).get("name", "").rstrip(".").lower()
if not dom:
continue
rec = seen.setdefault(dom, {"domain": dom, "count": 0, "last": e.get("time", "")})
rec["count"] += 1
if e.get("time", "") > rec["last"]:
rec["last"] = e.get("time", "")
out = sorted(seen.values(), key=lambda r: r["last"], reverse=True)[:limit]
if as_json:
print(json.dumps(out, ensure_ascii=False)); return
if not out:
print("— nichts geblockt —"); return
for r in out:
print(f" {r['domain']:40} x{r['count']:<4} {r['last']}")
def set_filter(ip_addr, enabled):
if not IP_RE.match(ip_addr):
sys.exit(f"ungültige IP: {ip_addr!r}")
@@ -117,13 +241,47 @@ def status():
print(f" {c['name']:16} ids={c['ids']} global={c['use_global_settings']} filt={c['filtering_enabled']}")
def _selftest():
"""Netzunabhängiger Test der reinen Regel-Logik."""
assert is_ip_spec("10.40.10.82") and is_ip_spec("10.40.10.82,10.40.10.183")
assert is_ip_spec("10.40.10.0/24") and not is_ip_spec("christoph")
assert scoped_rule("x.com") == "@@||x.com^"
assert scoped_rule("x.com", "10.40.10.82,10.40.10.183") == "@@||x.com^$client=10.40.10.82|10.40.10.183"
assert scoped_rule("x.com", "christoph") == "@@||x.com^$client='christoph'"
p = parse_allow_rule("@@||x.com^$client='christoph'")
assert p and p["domain"] == "x.com" and p["client"] == "'christoph'"
p2 = parse_allow_rule("@@||y.com^")
assert p2 and p2["client"] is None
assert parse_allow_rule("||block.com^") is None
assert ip_in_specs("10.40.10.82", ["10.40.10.82"]) is True
assert ip_in_specs("10.40.10.5", ["10.40.10.0/24"]) is True
assert ip_in_specs("10.40.20.5", ["10.40.10.0/24"]) is False
# round-trip: eine scoped Regel wird von list-exceptions --client wieder getroffen
assert (parse_allow_rule(scoped_rule("z.com", "christoph"))["client"]
== client_modifier("christoph"))
print("selftest OK")
if __name__ == "__main__":
a = sys.argv[1:]
if not a: sys.exit(__doc__)
cmd = a[0]
if cmd == "allow-domain": allow(a[1])
elif cmd == "unallow-domain": allow(a[1], remove=True)
elif cmd == "unfilter": set_filter(a[1], False)
elif cmd == "refilter": set_filter(a[1], True)
elif cmd == "status": status()
else: sys.exit(f"unbekannt: {cmd}\n{__doc__}")
ap = argparse.ArgumentParser(add_help=False)
ap.add_argument("cmd", nargs="?")
ap.add_argument("arg", nargs="?")
ap.add_argument("--client")
ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier
ap.add_argument("--limit", type=int, default=20)
ap.add_argument("--json", action="store_true")
a = ap.parse_args()
if not a.cmd:
sys.exit(__doc__)
if a.cmd == "allow-domain": allow(a.arg, a.client)
elif a.cmd == "unallow-domain": allow(a.arg, a.client, remove=True)
elif a.cmd == "list-exceptions": list_exceptions(a.client, a.json)
elif a.cmd == "recent-blocked":
if not a.client: sys.exit("recent-blocked braucht --client <name|ip[,ip]>")
recent_blocked(a.client, a.limit, a.json)
elif a.cmd == "unfilter": set_filter(a.arg, False)
elif a.cmd == "refilter": set_filter(a.arg, True)
elif a.cmd == "status": status()
elif a.cmd == "selftest": _selftest()
else: sys.exit(f"unbekannt: {a.cmd}\n{__doc__}")

View File

@@ -1,63 +1,98 @@
# TP23 Stufe 5 — HA-Self-Service für DNS-Ausnahmen
# TP23 Stufe 5 (Redesign 2026-09-12) — DAU-tauglicher, personenbezogener DNS-Self-Service
Home Assistant (HAOS-VM 100, `10.40.20.105`) gibt eine Ein-Klick-Oberfläche, um eine Domain
temporär (JIT) über das AGH-Paar `.36`/`.37` zu entsperren. Ablauf/Revert nativ per `timer`.
> 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only,
> HA-Neustart bei neuer Domain) war „funktional ein kastriertes Pi-hole" (User-Befund 2026-09-12)
> und ist ersetzt. Bau + Abnahme laufen **on-network** (HAOS `10.40.20.105`, AGH `.36/.37`) — beim
> Anlegen den **home-assistant-best-practices-Skill** konsultieren und via **ha-mcp** in `.storage`
> anlegen (native Helper, `entity_id` statt `device_id`). Diese Datei ist die Repro-/SSoT-Quelle.
**Warum kein `rest_command`:** die AGH-Endpunkte sind read-modify-write (`set_rules` ersetzt die
ganze Liste). HA ruft deshalb die deployte RMW-CLI `agh-exception.py` per `shell_command`.
## Ziel (User-Requirements 2026-09-12)
Ein **einfaches, klar verständliches** End-User-UI zum Entsperren:
- **alles was den Nutzer betrifft, nichts** von anderen / Admin / Power-User;
- Policy nur sichtbar, wo sie für den einfachen Nutzer Relevanz hat.
## Bausteine (alle in HA .storage, via ha-mcp angelegt — keine YAML außer shell_command)
| Typ | entity_id | Zweck |
| # | Requirement | Umsetzung |
|---|---|---|
| input_text | `input_text.dns_ausnahme_domain` | einzugebende Domain |
| input_text | `input_text.dns_ausnahme_aktiv` | aktuell aktive Ausnahme (für leak-freien Revert; init `""`) |
| input_select | `input_select.dns_ausnahme_dauer` | 15 Minuten / 1 Stunde / 4 Stunden |
| timer | `timer.dns_ausnahme` | JIT-Countdown (restore=false) |
| input_button | `input_button.dns_ausnahme_starten` | Auslöser |
| automation | `automation.dns_ausnahme_starten` | Button → validieren, alte Ausnahme aufräumen, `agh_allow_domain`, Timer starten |
| automation | `automation.dns_ausnahme_ablauf` | `timer.finished`/`timer.cancelled` → `agh_unallow_domain`, `_aktiv` leeren |
| 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs |
| 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client <person>` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert |
| 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** |
| 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar |
## `configuration.yaml` (der einzige YAML-Teil, Backup `.bak-tp23-*`)
## Identitätsmodell — AGH-Client je Person ist die SSoT
- Pro Person **ein AGH-Client** (`/control/clients`), `name` = Personenname, `ids` = **alle** Geräte-IPs
der Person (die per DHCP faktisch statisch sind). Damit ist Person→Gerät **an einem Ort** gepflegt
(kein zweites Mapping-File), und Ausnahmen sind stabil bei Gerätewechsel.
- Ausnahme-Regel = `@@||domain^$client='Personenname'` (statt globalem `@@||domain^`).
🔴 **Vor Scharfschalten on-network verifizieren, dass AGH `$client=` auf `@@`-Regeln wirkt**
(s. `agh-exception.py` Kopf). Fällt der Test negativ aus: Fallback = `$client=<ip>|<ip>` mit den
Geräte-IPs statt dem Namen (Code kann beides, `scoped_rule()`).
- Die bestehenden **VLAN-CIDR-Gruppen** bleiben die Policy-Grenze (Filter an/aus je VLAN);
Personen-Clients sind spezifischer und tragen nur die *Ausnahmen*.
## CLI (deployt nach `/config/agh-exception.py`, wirkt auf `.36`+`.37`)
```
allow-domain <domain> --client <person> # Ausnahme setzen (temp: HA revertiert; perm: kein Timer)
unallow-domain <domain> --client <person> # dieselbe Ausnahme entfernen
list-exceptions --client <person> --json # „meine Ausnahmen" (für Übersicht + Entfernen)
recent-blocked --client <person> --json # „zuletzt geblockt" (nur Geräte der Person)
```
`--client` nimmt einen AGH-Client-Namen (→ IPs via `/control/clients`) ODER eine Komma-IP/CIDR-Liste.
## `configuration.yaml` — `shell_command` (einziger YAML-Teil, Backup `.bak-tp23-*`)
Domain **und** Client sind Parameter → **kein HA-Neustart mehr** bei neuer Domain (das war der alte
DAU-Killer: pro Domain ein eigener `shell_command`).
```yaml
shell_command:
agh_allow_domain: "python3 /config/agh-exception.py allow-domain '{{ domain }}'"
agh_unallow_domain: "python3 /config/agh-exception.py unallow-domain '{{ domain }}'"
agh_unfilter_client: "python3 /config/agh-exception.py unfilter '{{ ip }}'"
agh_refilter_client: "python3 /config/agh-exception.py refilter '{{ ip }}'"
agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'"
agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'"
```
Neue Domain → **HA-Neustart** nötig (reload_all lädt eine erstmals hinzugefügte Domain nicht).
## Deploy auf HAOS (Zugang `ssh admin@10.40.20.105`, sudo passwortlos)
- `/config/agh-exception.py` — Kopie von `adguard/agh-exception.py` (liest Creds via `/config/agh-exception.secrets`).
- `/config/agh-exception.secrets` (chmod 600, root) — `AGH_AUTOMATION_USER`/`_PASS`.
- Redeploy nach Script-Änderung: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
## Helfer / Entities (via ha-mcp in .storage)
Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug:
| Typ | entity_id | Zweck |
|---|---|---|
| command_line sensor | `sensor.dns_recent_blocked_<slug>` | `recent-blocked --client <person> --json` → `json_attributes: items` (Liste {domain,count,last}); Scan-Intervall ~60 s |
| command_line sensor | `sensor.dns_exceptions_<slug>` | `list-exceptions --client <person> --json` → aktive Ausnahmen der Person |
| input_select | `input_select.dns_dauer_<slug>` | 1 Stunde / 4 Stunden / **dauerhaft** |
| timer | `timer.dns_ausnahme_<slug>` | JIT-Countdown je temporärer Freigabe (restore=false) |
| input_boolean | `input_boolean.dns_permanent_ohne_anfrage` | **global**: an = „dauerhaft" wirkt sofort (Start); aus = erzeugt Admin-Genehmigung |
| script | `script.dns_freigeben_<slug>` | nimmt `domain` als Feld → `agh_allow`; bei „dauerhaft"+Switch-aus → `todo`/Notification statt Freigabe; sonst Timer starten |
| automation | `automation.dns_ablauf_<slug>` | `timer.finished`/`.cancelled` → `agh_unallow`, Aufräumen |
## Bedienung
Domain eintragen → Dauer wählen → **DNS-Ausnahme starten**. Bei Ablauf/Abbruch wird die Regel auf
beiden AGH automatisch zurückgenommen. Ungültige Domains werden HA- **und** CLI-seitig abgelehnt.
## Dashboard — ein scoped View je Person
- Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit
der user-id; `require_admin:false`). Kleiner Haushalt → ein View je Person ist praktikabel und
umgeht das „Template kennt den aktuellen Betrachter nicht"-Problem.
- **Karte 1 „Zuletzt geblockt (antippen zum Freigeben)":** rendert `sensor.dns_recent_blocked_<slug>`
Attribut `items`; jede Zeile hat eine `tap_action` → `script.dns_freigeben_<slug>` mit `domain`
der Zeile + aktueller `input_select`-Dauer. (Dynamische Per-Zeilen-tap_action braucht i.d.R.
`auto-entities` + `template`/`button-card` **oder** ein kleines Custom-List-Template — **beim Bau
on-network entscheiden**, das ist der Teil, der Live-Verifikation braucht.)
- **Karte 2 „Meine Ausnahmen":** `sensor.dns_exceptions_<slug>` als Liste, je Eintrag Entfernen
(`agh_unallow`).
- **Karte 3 „Dauer":** `input_select.dns_dauer_<slug>` (inkl. „dauerhaft").
- **Kein** globaler Status, keine fremden Clients, keine Policy-Interna. Nur wenn relevant ein
kurzer Hinweis („durch den Familien-Filter blockiert").
## Dashboard (1. Draft, 2026-09-06)
Eigenes storage-mode Dashboard `url_path: dns-ausnahmen` (Sidebar, `require_admin: false`),
angelegt via `ha_config_set_dashboard`. Da es in HA-`.storage` lebt, hier die Repro-Quelle:
```json
{"views":[{"title":"DNS-Ausnahmen","path":"dns","type":"sections","icon":"mdi:dns","max_columns":2,"sections":[
{"type":"grid","cards":[
{"type":"heading","heading":"Domain temporaer freigeben","icon":"mdi:lock-open-variant"},
{"type":"entities","entities":[{"entity":"input_text.dns_ausnahme_domain","name":"Domain"},{"entity":"input_select.dns_ausnahme_dauer","name":"Dauer"}]},
{"type":"button","name":"DNS-Ausnahme starten","icon":"mdi:lock-open-check","tap_action":{"action":"perform-action","perform_action":"input_button.press","target":{"entity_id":"input_button.dns_ausnahme_starten"}},"grid_options":{"columns":"full"}}]},
{"type":"grid","cards":[
{"type":"heading","heading":"Status","icon":"mdi:information-outline"},
{"type":"tile","entity":"timer.dns_ausnahme","name":"Restlaufzeit"},
{"type":"tile","entity":"input_text.dns_ausnahme_aktiv","name":"Aktive Ausnahme"},
{"type":"markdown","content":"**AGH-Resolver-Paar** — .36: {{ states('binary_sensor.adguard_bf1_status') }} / .37: {{ states('binary_sensor.adguard_bf2_status') }}"}]},
{"type":"grid","cards":[
{"type":"heading","heading":"VLAN-Filter-Policy (AGH-Gruppen)","heading_style":"subtitle"},
{"type":"markdown","content":"MAIN/IOT/UNTRUSTED=an · INFRA/MEDIA/MGMT=aus · GUEST=an+SafeSearch/Parental · dns-bypass-exempt=aus"}]}
]}]}
```
⚠️ Draft-Notiz: `binary_sensor.adguard_bf1_status` ist stale (hing am alten `.16`-Container, TP42) —
zeigt evtl. `unavailable`, obwohl `.36` läuft. Sensor-Quelle gehört bei Gelegenheit auf `.36` gefixt.
## Genehmigungs-Variante (Modus-Switch aus)
`input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen
Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim
Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der
Switch entscheidet zur Laufzeit.
## Verifiziert (2026-09-04)
Button → `@@||doubleclick.net^` auf `.36`+`.37`, Timer 15:00, `_aktiv` gesetzt; Timer-Abbruch →
Regel auf beiden entfernt, `_aktiv` geleert, `.36==.37`.
## Alt-Reste, die beim Bau wegfallen/fixen
- Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`,
`automation…_starten/_ablauf`) und der `agh_allow_domain`/`_unallow_domain`-**pro-Domain**-Zwang.
- Statuskachel-Sensor `binary_sensor.adguard_bf1_status` hing am toten `.16` → auf `.36`/`.37` neu.
## Abnahme (on-network, = neue echte Stufe-6-Vorbedingung)
Mit einem **echten Nicht-Admin-Login**: geblockte Seite → erscheint in *ihrer* Fläche → 1 Klick
temporär (revertiert nach Ablauf) **und** 1 Klick dauerhaft (bleibt) → wirkt **nur** für diese
Person (Gegentest: anderer Client sieht die Domain weiter geblockt). `.36 == .37`
(`agh-sync.py check`). Modus-Switch beide Wege einmal durchgespielt.
## Verweise
- CLI: `adguard/agh-exception.py` (Subcommands + `selftest`). Deploy-Weg unverändert (base64→scp,
s. unten). Creds `/config/agh-exception.secrets` (chmod 600).
- Redeploy: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
- Personen-Client-Modell: `adguard/README.md`. Kontext/Reihenfolge: TP23 in `docs/teilprojekte.md`.

View File

@@ -47,7 +47,7 @@ gitea.emsenhuber.net {
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
# =========================================================================
nautobot.bf.emsenhuber.net {
reverse_proxy http://10.40.20.30:8080
reverse_proxy http://10.40.20.154:8080
}
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand

View File

@@ -19,12 +19,35 @@ if [ -z "${TOKEN}" ]; then
exit 1
fi
# 🔴 `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann
# ("failed to open <src> for reading", rc=0 — live gemessen 2026-09-21).
# Ein `&&`/`||` hinter dem Push beweist deshalb NICHTS, und die anschliessende
# `caddy validate` validiert dann die ALTE Datei -> gruene Abnahme bei
# unveraendertem Zustand. Genau so ist der Nautobot-Umzug am 2026-09-21
# scheinbar gelandet, obwohl live noch die alte Backend-IP stand.
# Deshalb: Push + md5-Vergleich in EINEM Remote-Aufruf, der bei Abweichung
# hart abbricht. Gepusht wird nur der lokal bekannte Pfad — die frueheren
# `\$\$`/`$$`-Verrenkungen waren der Ausloeser.
push_verified() { # push_verified <quelle-auf-pve> <ziel-im-ct> <perms> <label>
local src=$1 dst=$2 perms=$3 label=$4
ssh "${PVE}" "set -e
pct push ${CTID} '${src}' '${dst}' --perms ${perms}
want=\$(md5sum '${src}' | cut -d' ' -f1)
got=\$(pct exec ${CTID} -- md5sum '${dst}' | cut -d' ' -f1)
rm -f '${src}'
if [ \"\$want\" != \"\$got\" ]; then
echo \"FEHLER: ${label} nicht gelandet (\$want != \$got)\" >&2; exit 1
fi
echo \" ${label} verifiziert (\$got)\""
}
echo "-> Caddyfile"
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
ssh "${PVE}" "pct push ${CTID} /tmp/.caddyfile.\$\$ /etc/caddy/Caddyfile --perms 644 2>/dev/null || pct push ${CTID} /tmp/.caddyfile.$$ /etc/caddy/Caddyfile --perms 644; rm -f /tmp/.caddyfile.$$"
push_verified "/tmp/.caddyfile.$$" "/etc/caddy/Caddyfile" 644 "Caddyfile"
echo "-> Token (maskiert: Laenge ${#TOKEN})"
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$ && pct push ${CTID} /tmp/.cenv.$$ /etc/caddy/caddy.env --perms 600 && rm -f /tmp/.cenv.$$"
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$"
push_verified "/tmp/.cenv.$$" "/etc/caddy/caddy.env" 600 "caddy.env"
echo "-> validate"
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"

47
docs/announcements.md Normal file
View File

@@ -0,0 +1,47 @@
# Announcements — bf-infra
Breaking News / Querschnitts-Änderungen, die **jede** folgende Session kennen muss, bevor sie
losarbeitet — Dinge, die man sonst erst nach dem ersten Fehlschlag merkt. Kein Ersatz für die
TP-Prosa (`docs/teilprojekte.md`) oder das Gitea-Board; hier steht nur, was *quer* zu den TPs gilt.
**Format je Eintrag:** Überschrift `## YYYY-MM-DD — <Titel>`, dann die Zeilen
**Betrifft:** (wer/was) und **Aktion:** (was eine Session konkret anders machen muss),
optional ein, zwei Sätze Kontext. **Neueste oben.** Der Skill `/news` liest diese Datei und
zeigt die Einträge, die seit dem letzten `/news`-Lauf dazugekommen sind (Rest auf Wunsch).
<!-- TP35 Schicht 4. Eintrag hinzufügen = neuen ##-Block ganz oben unter dieser Zeile einfügen. -->
---
## 2026-09-12 — Golden-Config-Backups laufen jetzt täglich (vorher KEIN Scheduler)
**Betrifft:** alle, die sich auf GC-Backups/Compliance oder `gc-snapshot.py`-Werte verlassen.
**Aktion:** Es gab **nie** einen automatischen GC-Backup-Zeitplan — Backup/Intended/Compliance liefen
nur manuell aus Sessions (zuletzt 09-04 → 8 Tage Stillstand). Jetzt ScheduledJob **„GC Backup+Compliance
– taeglich"** (Nautobot, *Execute All GC Jobs - Multiple Device*, `platform=RouterOS`, täglich 03:30 UTC,
Reihenfolge Backup→Intended→Compliance). Wenn `backup_last_success_date` eines Geräts alt ist: zuerst
prüfen, ob der ScheduledJob noch `enabled` ist (`/api/extras/scheduled-jobs/`), nicht das Gerät verdächtigen.
## 2026-09-12 — claude.json-Secrets laufen über `{env:}` (TP35 Schicht 2 ✅)
**Betrifft:** alle, die `~/.claude.json` oder `~/.secrets/exports.zsh` anfassen.
**Aktion:** Keine Klartext-Secrets mehr in `~/.claude.json` — MCP-Credentials referenzieren jetzt
`{env:MCP_*}` (dieselben Namen wie `opencode.jsonc`, bidirektional kompatibel). SSoT ist
`~/.secrets/exports.zsh` (25 Vars). Nach jeder Änderung dort: `~/dev/.bin/gui-env-sync.sh`
**und App-Neustart** (laufender Prozess erbt die launchd-Umgebung nicht). ⚠ `~/.claude.json` nie aus
einer **laufenden** Session editieren — Claude Code schreibt dort State, der Edit würde überschrieben;
der Flip läuft bei beendeter App über `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude`.
## 2026-09-12 — TP36-Live: Gerätedaten stehen als CustomFields in Nautobot
**Betrifft:** alle, die Geräte-Ist-Werte (RouterOS-Version, Compliance %, DHCP-Server-Zahl,
DNS-Modus, Mgmt-IP) brauchen.
**Aktion:** Nicht mehr aus der Prosa ablesen und nicht raten — die fünf Felder (`grouping: TP36-Live`)
hängen an den 5 RouterOS-`dcim.Device` in Nautobot. Aktualisieren: `python3 nautobot/gc-snapshot.py
--commit` (liest nur aus Nautobot/GC, kontaktiert kein Gerät). Die Werte sind so aktuell wie das
letzte Golden-Config-Backup (`backup_last_success_date` am Gerät).
## 2026-09-07 — SSoT-Cutover: Index/Status = Gitea, NICHT mehr teilprojekte.md
**Betrifft:** jede Session, die einen TP-Status ändert, anlegt oder abschließt.
**Aktion:** Status/Reihenfolge/„was lief zuletzt" gehen ins **Gitea-Issue**
([`admin/bf-infra`](https://gitea.emsenhuber.net/admin/bf-infra/issues), Labels `status:*`/`thema:*`),
**nicht** in eine Tabelle in `docs/teilprojekte.md`. Die Datei trägt nur noch die `### TPnn`-Prosa.
`/projektstatus` liest Baum + Status aus der Gitea-API. Branch `tpNN-kurz` jederzeit möglich, Merge
nach `main`, sobald Code/Config steht.

View File

@@ -2,7 +2,7 @@
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
> Nautobot (`10.40.20.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
> Nautobot (`10.40.20.154`, TP45 Schicht A 2026-09-21; vorher `.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
> `nautobot/tp44-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)

162
docs/gotchas.md Normal file
View File

@@ -0,0 +1,162 @@
# Gotchas — ausführliche Diagnose-Anleitungen (Detail-Doku)
Diese Datei trägt die ausführlichen Diagnose-Blöcke, die in `CLAUDE.md` nur noch als 1-Zeilen-Merkregel
mit Zeiger stehen. Volltext hier, damit der Kern-Index schlank bleibt. Alle Blöcke sind **aktiv**
(querschnittliche Fallen), keine Historie.
---
## SSDP-Relay für die macOS-Sonos-App
LXC **108 `mcast-relay`** auf pve, zweibeinig `10.40.10.21` (VLAN10) + `10.40.40.21` (VLAN40),
`multicast-relay` mit **`--noMDNS`** (mDNS macht der Core). Unit im Repo:
**`mcast-relay/multicast-relay.service`**. **Zwei Beine sind zwingend** — SSDP ist link-lokal mit TTL 1,
ein Relay muss Mitglied beider Broadcast-Domänen sein; Routing hilft nicht.
- 🔴 **`--ssdpUnicastAddr` gehört in ein zweibeiniges Relay NICHT — die Flagge hat den Hinweg 7 Wochen
lang blockiert** (drin 2026-08-18, ausgebaut **2026-08-25**). Sie schreibt in `multicast-relay.py`
**Z. 630** die Quelle *jedes* SSDP-Pakets an `239.255.255.250:1900` auf `<addr>:1901` um (**auch
NOTIFY**); beim Senden greift dann **Z. 691**
`if srcAddr == ssdpUnicastAddr and not onNetwork(srcAddr, tx['addr'], tx['netmask']): continue` —
`10.40.10.21` liegt nicht im VLAN40-Netz, also **verwarf der Relay die M-SEARCH selbst, bevor sie die
Player erreichte**. Der Rückweg gehört stattdessen **geroutet**: `forward TP5: MEDIA->MAIN` ist offen,
die Player antworten unicast an den echten Sucher. (Upstream nennt den Rückweg selbst
`# FIXME: record more than one?` — es gibt nur **einen globalen** Merkslot.)
- 🔎 **Symptom-Merkregel:** *App sucht hunderte Male, sieht NOTIFYs, baut aber nie eine Verbindung auf*
= **Relay, nicht Netz**. Diagnose in einer Zeile — **Richtungsbilanz** im Journal:
`grep -c "via eth1"` (VLAN10→VLAN40) gegen `grep -c "via eth0"`. **0 zu n heißt: der Hinweg fehlt.**
- ⚠️ **Aus dem Relay heraus lässt sich der Relay nicht testen** (Loop-Schutz ignoriert eigene
Quelladressen) — am echten Client testen oder `journalctl -u multicast-relay` mitlesen. **Und am Client
nicht auf Port 1900 messen:** Sonos-App *und* Spotify halten dort je ein `SO_REUSEPORT`-Socket, die
Unicast-Antwort bekommt nur **eines** davon. Richtig ist eine Probe mit **ephemerem Quellport**
(`bind((ifaddr, 0))`), genau wie ein echter Client sucht.
- 🔴 **Zweite, unabhängige Ursache desselben Falls (08-25): die Sonos-App suchte gar nicht.** Nach der
Relay-Reparatur lag weiter nichts an — der Mitschnitt auf der VLAN40-Leitung
(`pct exec 108 -- tcpdump -i eth1 -n -s0 -A "udp and host <client>"`) zeigte: **jedes** SSDP-Paket des
MacBooks kam von **Chrome** (`ST: urn:dial-multiscreen-org:…dial:1`, `USER-AGENT: Chromium`), **kein
einziges** von Sonos. Die App hatte Sockets offen (`*:1900`, `*:6969`, `TCP *:3400`) und redete mit der
Cloud, legte aber nichts auf die Leitung. Ursache war das **Local-Network-Gate von macOS**: es stand auf
**„an" und griff trotzdem nicht**. Fix = Schalter unter *Datenschutz & Sicherheit → Lokales Netzwerk*
**aus, App beenden, wieder an, App starten** (hart: `sudo tccutil reset LocalNetwork com.sonos.macController2`).
⇒ **Merkregel: „die App sucht hunderte Male" ist erst dann ein Befund, wenn der Mitschnitt sagt, WER
sucht.** Im Handoff vom Vormittag waren die gezählten 291 Suchen Chrome + eigene Proben; die Zahl hat
drei Stunden in die falsche Richtung gezeigt.
- 🖥️ **Das MacBook ist dual-homed in VLAN10:** `en5` = **10.40.10.183** (USB-Dock, Default-Route) und
`en0` = **10.40.10.82** (WLAN). Die früher als „zweiter Mac" gelesene `.183` ist **dasselbe Gerät** —
beim Messen immer beide Beine prüfen.
---
## Konstanter 10-Sekunden-Hänger = DNS-Timeout, kein Lastproblem
(Fund 2026-09-01, TP16.3-Randbefund.) `gitea` hing bei ~10 % der Anfragen exakt 10,1 s — quer über
Web-UI, `/api/healthz` **und** den Git-Pfad, also kein Rendering. Ursache: **MariaDB (LXC 302) hatte
`skip-name-resolve` auskommentiert** und machte bei **jeder neuen Verbindung** einen Reverse-Lookup,
während ihr `/etc/resolv.conf` noch auf den toten `10.40.41.254` zeigte (glibc: 5 s × 2 Versuche = 10 s).
Pool-Verbindungen waren schnell ⇒ es wirkte sporadisch.
🔑 **Drei Merkregeln daraus:**
- (a) **immer gegen eine zweite Sache messen, die denselben Weg nimmt** — `vw`/`hass` über dieselbe
Strecke hatten 0 von 30 Hängern, das hat CF, Tunnel, Firewall und Caddy in einem Schritt entlastet.
- (b) **wer an einer DB hängt, sagt die Verbindungstabelle** (`ss -tn | grep 3306`), nicht die
Vermutung — hier hingen **Vaultwarden, Home Assistant und gitea** an derselben MariaDB, und die Annahme
„Vaultwarden nutzt SQLite" war schlicht falsch.
- (c) **ein toter Resolver ist selten allein** — der Sweep über alle LXCs fand **fünf** Container mit
`10.40.41.254`. Sweep:
`for c in $(pct list|awk 'NR>1{print $1}'); do pct exec $c -- grep ^nameserver /etc/resolv.conf; done`
---
## networking.service failed bei funktionierendem Netz = tote `auto`-Stanzas
(Ursache gefunden + behoben 2026-09-01.) In vier LXCs standen in `/etc/network/interfaces` noch
Einträge aus dem **alten VLAN1-Schema** (`eth0` → `10.40.41.x`) und aus früheren Versuchen (`veth16`,
`veth24`, Tippfehler `eth01`) — auf Interfaces, die es im Container gar nicht gibt. `ifup -a` scheitert
daran, das echte `veth20` kommt trotzdem hoch. **Fix:** nur die Stanzas entfernen, deren Interface in
`ip -br link` fehlt (`lo` und die echten behalten), Backup daneben.
🔴 **Abnahme ist ein echter Reboot, nicht `systemctl is-active`** — der Servicestatus sagt nichts
darüber, ob beim nächsten Start etwas hochkommt.
---
## networking.service failed im LXC, ohne dass das Netz fehlt
`networking.service` kann in einem LXC dauerhaft `failed` sein, ohne dass das Netz fehlt — und dann
startet alles mit `After=network-online.target` nach einem Reboot **nicht** (Fund 2026-09-01: `101`,
`102`, `106`, `107`; **influxdb2 kam nach dem Neustart nicht hoch**, Unit `enabled`, **kein Startversuch
im Journal**). Fällt nur auf, wenn man neu startet — die Container liefen 13+ Tage.
**Vor jedem geplanten LXC-Neustart `systemctl --failed` lesen und danach die Ports gegen vorher
vergleichen**, sonst verliert man still einen Dienst.
---
## Messwerkzeug, das die falsche Frage beantwortet
(zweimal in einer Nacht, 2026-09-01.)
- (a) **`nc -z` lieferte falsch-negative** „Port zu" für neun Dienste gleichzeitig — inklusive des
SSH-Ports, über den die Session gerade arbeitete; `curl` bekam von demselben Host **HTTP 200 in 24 ms**.
**Bei „alles gleichzeitig kaputt" zuerst das eigene Werkzeug verdächtigen**, dann das Netz: ein
Python-Socket oder `curl` entscheidet in Sekunden.
- (b) **Ein Firewall-Zähler taugt nicht als Wegbeweis**, wenn der Client die Verbindung offenhält
(`cloudflared`): er zählt nur NEUE Verbindungen und steht still, obwohl alles läuft — richtig ist die
**Verbindungstabelle** (`/ip firewall connection`).
---
## VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a)
NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert
(`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle:
DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der
*Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum
Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber
`TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
---
## Zeitquelle ist der Core (NTP)
`/system ntp server`, seit 2026-08-18, ausgeliefert per **DHCP-Option 42** (`ntp-server=` in jedem der
7 Netze) und drift-überwacht (ComplianceRule `system-ntp`). Vorher lief **nirgends** eine
Synchronisation: die anderen drei Geräte zeigten auf `10.40.99.1`, wo kein Server lief, und der Core
selbst hatte gar keinen NTP-Client — ap2 war dadurch **17 Stunden** versetzt.
🔴 **Zwei Fallen dabei:**
- (a) ein NTP-Server mit `use-local-clock=no` und unsynchronisiertem Client liefert **Stratum 16**, das
jeder Client verwirft — Server einschalten allein reicht nicht.
- (b) wird der Server eingeschaltet, *bevor* sein Client synchron ist, hält er diesen Zustand fest →
**`enabled=no`/`=yes` nach der Synchronisation**.
Merkregel: bei widersprüchlichen Zeitstempeln zwischen Geräten zuerst `/system ntp client get status`
lesen, und zum Eingrenzen einen Client testweise auf eine öffentliche Quelle zeigen lassen.
---
## Home Assistant: der `http:`-Block in `configuration.yaml` ist WIRKUNGSLOS
(Fund 2026-08-26, TP31.) HA hat die http-Config nach **`.storage/http`** migriert
(`"yaml_migration_done": true`); seither wird die YAML kommentarlos ignoriert — selbst ein ungueltiger
Schluessel darin erzeugt **keinen** Config-Fehler.
🔴 **Die Logzeile fuehrt aktiv in die Irre:** `Received X-Forwarded-For header from an untrusted proxy
<ip>` faellt laut Quelltext nur bei **aktivem** `use_x_forwarded_for` — man haelt die Option fuer wirksam
und die Liste fuer falsch und editiert die falsche Datei. Richtig ist der WS-Zweischritt
**`http/config/configure`** (setzt `pending`, startet HA neu) → binnen **5 Minuten**
**`http/config/promote`**, sonst Auto-Revert auf `stable`.
Merkregel zur Eingrenzung: ein Testskript in `<config>/python_scripts/` + `python_script.reload` —
taucht der Service auf, liest HA das Verzeichnis, und die Frage ist nicht mehr *welche Datei*, sondern
*warum ein gelesener Block nicht wirkt*.
---
## Nautobot auf der DS923 braucht einen abgeschalteten Image-Healthcheck
Der eingebackene Check ruft alle 10 s `nautobot-server health_check` = einen vollen Django-Start; unter
Last dauert der >2 min, die Aufrufe **stapeln sich unbegrenzt** → Speicher alle → `fork()` mit ENOMEM →
DSM räumt den ganzen Stack ab (am 2026-08-17 zweimal passiert, Load bis 73 bei 4 Kernen). Im Stack stehen
deshalb `healthcheck: disable: true` auf worker+scheduler, `--concurrency=2` und `mem_limit`.
**`cpus:` geht auf Synology nicht** (Kernel ohne CFS-Quota, der Stack-Deploy schlägt sonst fehl).

View File

@@ -82,7 +82,7 @@ terpene.teilzeitladen.xyz {
"type": "http",
"url": "https://n8n-mcp.emsenhuber.net/mcp",
"headers": {
"Authorization": "Bearer 47a34f5589388d37d8b7503abac07b930df264c469ef0674aaeeea9b71dde847"
"Authorization": "Bearer <redacted>"
}
}
```

View File

@@ -0,0 +1,28 @@
# Handoff 2026-09-13 — CF_API_TOKEN gerollt (Gitea bf-infra #69)
## Was passiert ist
`CF_API_TOKEN` (id `493ed8bd…`, konsolidiert) war nach TP41 tot (Fehler `1000` an allen drei
Fundorten). Statt eines separaten railplanner-Tokens wurde das konsolidierte Token am 2026-09-13
im CF-Dashboard **gerollt** (neuer Wert, gleicher Scope). Workers-Scope verifiziert:
`GET /accounts/{id}/workers/scripts` listet `railplanner` / `blaueblueten-n8n` / `terpene`.
## Drift behoben
Der neue Wert lag zunächst nur in `credentials.env`. Realignt auf identischen Wert in:
- `~/.secrets/exports.zsh` (SSoT) — vorher gar kein `CF_API_TOKEN`
- `~/.secrets/credentials.env`
- `bf-infra/mcp/cloudflare/.env` (vorher `…4148`)
- `bf-network-r6/mcp/cloudflare/.env` (vorher `…2174`)
## Gotcha (hat die Session zweimal „Token kaputt" fehldeuten lassen)
Account-owned Tokens verifizieren an `GET /accounts/{account_id}/tokens/verify` — am alten
`/user/tokens/verify` liefern sie `1000 "Invalid API Token"`, **obwohl gültig**. Siehe
`docs/tp16.1-warp-mesh-native-crs326.md:164` und `docs/teilprojekte-archiv.md:830`.
Ins Memory notiert (`reference_cf_account_token_verify.md`).
## Status
Gitea #69 `closed` (`status:done`). Kein Repo-Code geändert — alles Secret-Store + Gitea-Status.

View File

@@ -0,0 +1,111 @@
# Handoff 2026-09-14 — TP25: ap2-In-situ-Messung (Terrasse), Hebel-A-Machbarkeitsprüfung
**Anlass:** Christoph beobachtete 19:18 auf der Terrasse springende WLAN-Balken (1→2→3 binnen
Sekunden) und zwei Anruf-Aussetzer; danach Umstellung des Telefons auf 5G-Zellular. Verdacht:
Das Telefon probe die APs ab und falle dabei hinaus.
**Beteiligte:** iPhone `F2:62:4C:E1:AF:09` (das bekannte Fund-E-Gerät), Manager `crs326-core`.
Alle Reads am Manager; **kein einziger Geräte-Write** in dieser Session.
## cap-wifi-Karte (verifiziert live 19:22, vor jeder Auswertung gelesen)
`cap-wifi1` = ap2 5G (5300/ax/Ce/DI, tx-power 18) · `cap-wifi6` = ap2 2,4G (2462, tx-power 15) ·
`cap-wifi2` = ap3 5G (5180) · `cap-wifi4` = ap3 2,4G · `cap-wifi3`/`cap-wifi5` = ap1 (5G 5280/2,4G).
⚠️ Das CLAUDE.md sagt „ap1 FUNKSTILL seit 08-30" — **überholt**: seit dem 09-04-Kanalplan-Commit
hat ap1 wieder eine Provisioning-Zeile und dient live (heute 16:51:53 sogar ein Radar-Event auf
5280 mit sauberem Kanalwechsel auf 5260/5280-Liste). Doku-Drift, hier vermerkt.
## Befund 1 — das Flapping ist echt und überwiegend band-intern an ap2
Kollektor-Log 18:53–19:18: **~30 Roams**, fast alle `cap-wifi1 ↔ cap-wifi6` = **ap2 5G ↔ ap2 2,4G**
(derselbe AP), Feldstärke −68…−81 dBm. Drei echte Abwürfe `disconnected, not responding`
(19:01:29, 19:12:10, 19:47:51 — der letzte tötete den Anruf). Ein Ausflug zu ap1-5G (19:35:45,
**−64**) und ap3 (−78…−82).
## Befund 2 — In-situ vor ap2: Hardware-Verdacht WIDERLEGT
Christoph stellte sich 1–2 m vor ap2. Registration-Table live (3 Stichproben + Log):
| Position | Interface | Signal | Verhalten |
|---|---|---|---|
| 1–2 m vor ap2 | cap-wifi1 (5G) | **−49…−51** | **>2 min stabil, 0 Roams** |
| Rückweg (Weg) | cap-wifi1 → cap-wifi6 | −76…−78 | 2 Roams binnen 26 s |
| Terrassenplatz | cap-wifi6 (2,4G) | **−65…−73** | 36 s im 1-s-Takt, 0 Roams, ±4 dB |
⇒ Der offene TP25-Unterpunkt „**ap2 unterperformt (2 m/−80, 23:02-Befund)**" ist **in situ
widerlegt**: ap2 sendet auf 2 m mit −49…−51 dBm und bleibt dort stabil. Der 23:02-Befund
reproduziert sich nicht — plausibel, weil seitdem der 09-04-Kanalplan (5220→5300/5320,
Band-B-Liste) committed ist; alternativ war die damalige Position/Attribution anders.
**ap2 RF/Antenne/HW: sauber. Kein Hardware-Tausch begründet.**
## Befund 3 — Ursache-Modell Terrasse: 5-GHz-Zellkante + client-getriebene Band-Aggressivität
- Terrassenplatz: 5 GHz **−76…−81** (Zellkante), 2,4 GHz **−65…−73** — die ~7 dB sind normale
Bandspreizung plus Wand. Link-Budget, kein Defekt.
- Das iPhone **bleibt**, sobald ein Band klar gewinnt (am Platz: 2,4 GHz, minutenlang ruhig).
Das Thrash passiert ausschließlich in der **Überlappungszone −70…−77**, wo beide Bänder
gleichauf liegen und 5 GHz ständig um die iOS-Aufgabeschwelle (~−75) pendelt.
- Client-Aggressivität als Second Layer: 19:44:21 stand das Telefon bei **−52 auf ap2-5G** und
floh 13 s später auf 2,4 (−77) — dasselbe Muster wie im Archiv-Fund E („Abbruch bei −47").
Steering-Objekte `st-main`/`st-guest` haben **keine Schwellen** (nur rrm/wnm) — der AP
drängt nichts aktiv per BTM, die Entscheidungen trifft das iPhone.
- Mit `ft=no` (Dauerzustand seit 09-04) ist jeder Roam eine Voll-Reassoziation in Sekundenhöhe
⇒ genau darin sterben Anrufe.
## Hebel-A-Machbarkeitsprüfung (Dry-Run, kein Write)
**Ziel:** 5-GHz-Boden für `dirtyvader-main` (reject unter ~−76), damit das Telefon an der
Zellkante sofort endgültig auf 2,4 GHz committet statt zu thrashen.
Live + MikroTik-Doku verifiziert:
- `signal-range` wird **kontinuierlich** geprüft (Grace über `allow-signal-out-of-range`,
Default 0s) — der Mechanismus trägt grundsätzlich.
- ABER: `/interface/wifi/access-list` hat **kein `band`-Property**. Band-Scoping nur über
`interface=` möglich — und die `cap-wifiN`-Namen wechseln bei jedem CAP-Rejoin (Gotcha
2026-08-31): die Regel würde stillschweigend auf ein falsches Radio zeigen (im Worst Case
ein 2,4-Master ⇒ Lockout-Schatten). Alternativen fallen alle durch:
- **Nur SSID scope** (ohne Band): trifft auch 2,4 GHz — Schlafzimmer-Gegenmessung 08-22
(−84 dBm am Counter-AP) ⇒ Lockout, exakt das 08-22-Gegenargument.
- **Nur MAC scope** (iPhone): trifft beide Bänder — das iPhone saß heute selbst bei −89 auf
2,4 GHz (19:01) ⇒ WiFi-Verlust in schwachen Räumen.
- **Fazit: Der Band-Boden ist in der aktuellen wifi-qcom-Access-List nicht sauber ausdrückbar.
Nicht umgesetzt; Dry-Run-Preview des Kandidaten gefahren, kein Apply.**
Kandidat (NICHT angewendet, nur als Referenz wie er *heute* aussehen würde):
```
/interface wifi access-list add interface=cap-wifi1 ssid-regexp=dirtyvader-main \
signal-range=-120..-76 action=reject comment="TP25-hebel-a-5g-boden"
```
(× cap-wifi1/2/3 — die drei 5G-Master — bricht bei jedem CAP-Rejoin)
**Verbleibende Hebel:**
1. **`ft=yes` uniform auf `sec-main`** — macht Roams nahtlos ⇒ Anrufe überleben das (verbleibende)
Springen. Bewusste Aufweichung der 09-04-Entscheidung (`ft=no`), Fund-E-Ursache (7.24.1) ist
längst behoben, FT-Flotte uniform. Ein Config-Weg: Template + Compliance + Transaktion.
2. **Deckungsfix (TP25 Variante 3)** — der eigentliche Hebel: ein AP muss an der Terrasse
eindeutig stärker sein als alle anderen. Bemerkenswert: ap1-5G erreichte die Terrasse heute
mit −64 (besser als ap2-5G mit −78) — ap1 sitzt bereits auf der Band-B-Liste 5260/5280.
3. Monitoring: wireless-Logging läuft persistent; das Flapping ist damit künftig rückwirkend
diagnostizierbar.
## Nachweise
- Live-Log am Manager: 43 + 18 + 8 Zeilen `wireless,info` zu `F2:62:4C:E1:AF:09`, 18:53–19:56.
- 1-s-Sampling: zwei 18-Sekunden-Loops (`:for` am Manager, Registration-Table), 20:01:44–20:02:26.
- Monitor-Reads: `cap-wifi1` → `5300/ax/Ce/DI tx-power 18`; `cap-wifi6` → `2462/ax tx-power 15`.
- Doku-Verifikation Access-List: help.mikrotik.com/docs/display/ROS/Wifi (signal-range kontinuierlich,
kein band-Property, first-match-wins, kein impliziter Reject).
## Offen / nächste Schritte
1. Entscheidung Christoph: `ft=yes` ja/nein (Hebel 1 oben) — ich lege dazu auf Go den
GC-ConfigPlan als Dry-Run vor.
2. Deckungsfix-Terminierung (Variante 3 / ap1-Endausbau) — TP25 bleibt offen.
3. CLAUDE.md-Drift „ap1 FUNKSTILL" beim nächsten CLAUDE.md-Edit korrigieren (nur Vermerk hier,
kein stiller Fix).
4. Gitea-TP25-Issue **#17** hat einen Kommentar mit diesem Befund erhalten.
5. 🔴 **Sicherheitsvorfall dieser Session:** durch einen Env-Parse-Fehler stand der
`GITEA_ACCESS_TOKEN` in einer URL im Traceback — **Token ist als kompromittiert zu
betrachten und zu rotieren** (neues Token erzeugen, `~/.hermes/.env` aktualisieren, altes
widerrufen). Siehe Merkregel-Klasse „Werte nie suchen, um sie zu sehen".

View File

@@ -0,0 +1,72 @@
# Handoff 2026-09-15 — TP25: Steering-Threshold 5G deployed, Terrasse verifiziert, MacBook-Klebrigkeit „wird nicht umgesetzt"
**Anlass:** Folgesession zum TP25-Handoff „zuordnung-und-redundanz" ([[handoff-20260915-tp25-zuordnung-und-redundanz]]). Ziele: (1) Doku-Drift schließen, (2) Roaming-Threshold für die Terrasse (5G bricht früher zur 2,4G ab), (3) Terrasse-Nachmessung nach dem Deploy.
## 1. Steering-Threshold 5G — DEPLOYED, 140/140
**Problem:** Am Rundgang 09-15 saß der Terrasse-Client auf der **−80/5G-Kante** (genau der Default
`transition-threshold`); auf 2,4 GHz wäre das nicht passiert.
**Umsetzung (nur 5G):** neues Steering-Profil `st-main-5g` mit `transition-threshold=-72`
(statt Default −80) und `transition-threshold-time=5s` (statt 10s). Die drei 5G-Configs
(`cfg-main-5g-ap1/ap2/ap3`) zeigen darauf; die drei 2G-Configs bleiben auf `st-main` (unset −80),
weil 2,4 GHz draußen (Terrasse/außerhalb) Reichweite behalten soll. **8 dB Abstand = kleine Hysterese.**
Weg: Template-Edit (`nautobot/mikrotik_routeros.j2`) → `push-gc-template.py` (GC-Commits `6a4b504f`,
`242ed108`) → manueller Plan (`gc-manual-plan.py`, Plan `cc8340a8`, Completed) → Refresh:
**140/140, 0 Drifts.** Commands-File:
[`docs/transaktionen/TP25-steering-5g-threshold.commands.rsc`](transaktionen/TP25-steering-5g-threshold.commands.rsc).
## 2. Gotcha: Parameter-Reihenfolge im Template
Nach dem ersten Push meldete Compliance **139/140** mit einem `wifi-steering`-Drift: `extra`/`missing`
derselben Zeile, aber mit unterschiedlicher Feld-Reihenfolge. **RouterOS exportiert `transition-threshold`
vor `wnm` (alphabetisch)** — mein Template hatte `wnm` davor. Fix = Template-Zeile auf die Export-Reihenfolge
gebracht (`… rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes`). Kein Re-Deploy nötig
(das Gerät war schon korrekt, nur das Template war es nicht). **Merkregel: bei neuen Parametern im
`/interface wifi steering add` die RouterOS-Export-Reihenfolge (alphabetisch) verwenden, sonst Drift ohne
Fehlfunktion.**
## 3. Terrasse-Nachmessung — Threshold wirkt am iPhone, nicht am MacBook
Zwei Apple-Geräte 20 cm auseinander auf der Terrasse, beide auf `dirtyvader-main`, beide physisch
rein/raus bewegt (A/B):
| Gerät | Band | Signal | Roam-Verhalten |
|---|---|---|---|
| iPhone `F2:62:4C:E1:AF:09` | **2,4 GHz** | −55…−66 | ✅ roamt sauber (rein→5G, raus→2,4G) |
| MacBook `56:71:6C:22:E6:A9` | **5 GHz** | −73…−75 | ❌ kein Roam (uptime 1h15m unverändert) |
**Befund:** gleiche Bewegung, gegensätzliches Verhalten. iOS folgt dem Band-Wechsel; **macOS hält 5 GHz
klebrig fest** und ignoriert die 802.11v-Transition-Request — selbst unter der −72-Schwelle und beim
physischen Umhertragen. Christoph: das MacBook hatte an diesem Ort nie Probleme (−73/5G reicht ihm).
## 4. Entscheidung: MacBook-Klebrigkeit = ❌ WIRD NICHT UMGESETZT
Der einzige Hebel dagegen wäre `transition-time=immediate` (deauth-Erzwingung nach der
Transition-Request statt bloßem Vorschlag) — ein schärferer Eingriff mit Abbruch-Risiko für aktive
Sitzungen. **Christoph-Entscheid 2026-09-15: nicht umsetzen.** Kein Handlungsbedarf, da das MacBook
dort funktioniert. Nur dokumentiert, falls es später doch wieder aufkommt.
## 5. Doku-Drift geschlossen (diese Session)
- **CLAUDE.md:** ap1 „FUNKSTILL"→aktiv am Sideboard (`core ether3`), ap2→Technik UG (`core ether21`,
ether1-MAC `:39`), ap3→Technik OG (`branch ether24`), Branch-Portliste `24 ap3`, Gotcha-Zeile
ap2/ap3-Uplink. Kanäle mitkorrigiert (ap1 `5260/Ce`, ap2 `5300/Ce`).
- **teilprojekte.md:** neuer Stand-Block 2026-09-15 (definitive Zuordnung + Tausch-Befund ap2↔ap3).
- **Nautobot Location** (war `None`): ap1→`Wohnzimmer Sideboard`, ap2→`Technik UG`, ap3→`Technik OG`.
## 6. Handoff-Punkte (aus dem Vorgänger) — aufgelöst
1. **ap1 behalten/abbauen** → **behalten** (Christoph). APs bleiben an ihren jetzigen Orten.
2. **Tausch korrigieren** (cAP ax → Sideboard) → **verworfen**; aktueller Zustand bleibt.
3. **Drei-Räume-Schatten** nachmessen → **verworfen**; Signal war beim letzten Rundgang gut.
4. **Doku-Drift** → ✅ geschlossen (s. §5).
## Übergabeprompt (nächste Session)
> TP25: Steering-Threshold 5G deployed (st-main-5g, −72/5s, 140/140), Terrasse am iPhone verifiziert
> (2,4G statt 5G/−80). MacBook-Klebrigkeit auf 5G dokumentiert und per Christoph-Entscheid
> „wird nicht umgesetzt" (transition-time=immediate). Doku-Drift geschlossen, Handoff-Punkte aufgelöst
> (ap1 bleibt, APs bleiben, Drei-Räume verworfen). Siehe
> [[handoff-20260915-tp25-steering-threshold-und-terrasse]].

View File

@@ -0,0 +1,105 @@
# Handoff 2026-09-15 — TP25: AP-Zuordnung geklärt, Messrunde (Vorher), Tausch ap1↔ap3 begonnen
**Anlass:** Christoph wieder on site in Linz. Zurückgestellter Job = TP25 Deckungsfix Variante 3
(„Deckenstrahler an den guten Platz, hAP in den Schatten"). Session lief als große Messrunde
(Vorher) + physischer Tausch ap1↔ap3. **Tausch ist physisch angefangen, aber der Netzzustand ist
zum Sitzungsende unklar — ap2 ist down.**
## 🔑 AP-Zuordnung — EINDEUTIG per physischem Test geklärt
Die Session lief lange mit vertauschter Zuordnung (auch Christoph hat sie zwischenzeitlich falsch
„korrigiert"). Der **Kabel-Test** hat sie eindeutig festgelegt:
| Gerät | IP | Position | Uplink |
|---|---|---|---|
| **ap1** | 10.40.99.11 | **Sideboard / Wohnzimmer** | core `ether3` |
| **ap2** | 10.40.99.12 | **Technik OG** | core `ether8` → branch `ether24` |
| **ap3** | 10.40.99.13 | Technik UG | core `ether21` |
**Beweis:** Christoph zog das Kabel in Technik OG → `cap-wifi1`/`cap-wifi6` (= ap2,
Radio-MAC `D0:EA:11:4D:40:3E/3F`) verschwanden aus der cap-wifi-Karte, ap1/ap3 blieben.
⇒ ap2 = Technik OG, ap1 = Sideboard. Die ARP zeigt ap2 noch ~30 s „reachable" (Cache), die
CAPWAP-Karte ist die Wahrheit.
⚠️ **CLAUDE.md-Drift:** die Hardware-Tabelle sagt für ap1 „core ether15" — überholt, ap1 hängt an
`ether3`. Und die Doku-Aussage „ap1 FUNKSTILL seit 08-30" ist überholt (ap1 dient live).
## Messrunde (Vorher) — Methode
iPhone `F2:62:4C:E1:AF:09` (Fund-E-Gerät) wurde per **Access-List-Reject** gezielt auf je einen AP
gelenkt (die anderen zwei APs ablehnen), dann Registration-Table am Manager gelesen. Reject-Regeln
nur auf `ssid-regexp=dirtyvader-main`, am Ende immer `remove [find where comment~"TP25-messung"]`.
⚠️ **Zwei Methodik-Gotchas (neu):**
1. `interface=` in der Access-List nimmt **nur EINEN Wert** (keine Komma-Liste) — pro AP zwei
Regeln (5G- + 2,4G-Radio). Eine Komma-Liste wirft `input does not match any value of interface`.
2. **MAC-Tippfehler** `F2:62:4C:E1:41:AF:09` (ein „41" zu viel) ist mir **zweimal** passiert und
hat stille Fehl-Reads (leere Registration-Table) erzeugt. Korrekt ist `F2:62:4C:E1:AF:09`.
**Methodischer Vorbehalt:** die wiederholten Rejects + Disconnects drosseln die iOS-Wiederverbindung.
Ein „✗ tot"-Wert direkt nach mehreren Disconnects kann teils Artefakt sein — Christoph hat den
ap1-tot-Befund auf der Terrasse aber selbst per manuellen Verbindungsversuchen bestätigt.
## Messwerte (Vorher)
**Innenräume** (Durchschaltung, je Station alle 3 APs):
| Ort | ap1 (Sideboard) | ap2 (OG) | ap3 (UG) |
|---|---|---|---|
| Küche | 5G −52 | — | — |
| Schlafzimmer | 5G −52 | ✗ tot | 2,4G −57 / 5G −73 |
| Bad | 2,4G −71…−76 | ✗ tot | 5G −73 / 2,4G −72 |
| Klo | ✗ tot | 2,4G −75…−81 | 5G −69 |
| Technik OG | (−52 erst, dann ✗)† | 5G −47…−57 | 5G −68…−71 |
**Terrasse** (3 Runden je AP: sitzend → Rundgang entlang der Mauern → wieder sitzen):
| | ap1 (Sideboard) | ap2 (OG) | ap3 (UG) |
|---|---|---|---|
| Sitzend | ✗ tot | 5G −78…−85 / 2,4G −67 (Thrash) | ✗ tot |
| Rundgang | ✗ überall | 2,4G −63…−74 / 5G −73…−75 | ✗ (nur 1 Mauerdurchlass −70…−82) |
† Vorbehalt s.o.
**Kernbefunde:**
1. **Die Terrasse wird ausschließlich von ap2 (Technik OG) bedient — marginal** (Band-Thrash
5G −78…−85 / 2,4G −67). ap1 (Sideboard) erreicht die Terrasse **nicht**, ap3 nur an einem
Mauerdurchlass kurz (−70…−82). Die Prämisse „Sideboard-AP deckt die Terrasse" ist **widerlegt**.
2. **ap1 (Sideboard) ist der Innenraum-König:** Küche −52, Schlafzimmer −52, Bad −71…−76.
3. **ap3 (cAP ax, UG) hält als einziger beide Geschoße** (UG: Schlafzimmer/Bad/Klo −69…−73,
OG: Technik OG −68…−71 über die Decke).
## Tausch ap1↔ap3 — Status UNKLAR (Sitzungsende)
Christoph hat ap1 und ap3 physisch getauscht: **ap3 (cAP ax) liegt jetzt „am Kasten auf
ap1-Position"**, bewusst „oben/unten ausgerichtet wie Deckenmontage" (Abstrahlwinkel). Ports/VLAN
sollten laut Vorgabe gleich bleiben.
**Live-Befund direkt nach dem Tausch (Vorsicht, Zustand kann sich noch setzen):**
- ap2 = **down** (10.40.99.12 ARP `incomplete`, fehlt in cap-wifi-Karte). Vermutlich Kabel beim
Tausch abgezogen.
- ap1 = reachable, noch an `ether3` (Sideboard).
- ap3 = reachable, aber Bridge-Host zeigt es an **`ether8` (branch)**, nicht an `ether21`.
- `ether21` ist **leer**.
⇒ Der Tausch ist im Netz noch nicht so angekommen, wie physisch beabsichtigt. **Bevor irgendetwas
weitergemessen wird: ap2 wieder einstecken und die tatsächliche Verkabelung (welches Kabel an
welchem Gerät) klären.**
## Offen / nächste Schritte
1. **ap2 (Technik OG) wieder versorgen** — Kabel prüfen, ARP `10.40.99.12` wieder `reachable`.
2. **Verkabelung nach dem Tausch verifizieren:** Bridge-Host-Tabelle (`mac-address~"D0:EA:11"`)
gegen Soll (ap1=`ether3`, ap2=`ether8`/branch, ap3=`ether21`) stellen. ap3 hängt aktuell an
`ether8` — klären, ob das Absicht ist oder Verwechslung.
3. **Nachher-Messung** derselben Stationen (Terrasse + Innenräume), erst wenn 1+2 sauber sind.
4. **Nautobot hat keine Location/Position** für ap1/ap2/ap3 hinterlegt (`location: None`). Nach dem
abgeschlossenen Tausch die korrekten Positionen dort nachtragen (SSoT-Drift schließen).
5. Gitea-TP25-Issue #17 bekommt den Befund als Kommentar (Zitat auf Messwerte + Zuordnung).
## Messwerte-Kurzform (für Folgesession)
`cap-wifi`-Karte war am Sitzungsende nach ap2-Rejoin: `cap-wifi1`=ap3-5G, `cap-wifi2`=ap3-2,4G,
`cap-wifi3`=ap1-5G, `cap-wifi5`=ap1-2,4G, ap2 fehlte (down). **Die cap-wifiN-Nummern verschieben
sich bei jedem CAP-Rejoin — vor jeder Messung neu lesen** (`/interface wifi radio print terse
proplist=cap,radio-mac,interface` am Manager).

View File

@@ -0,0 +1,96 @@
# Handoff 2026-09-15 — TP25: AP-Zuordnung fixiert, Tausch-Befund, ap1-Redundanz gemessen
**Anlass:** Folgesession zur unklaren Tausch-Lage vom Vormittag ([[handoff-20260915-tp25-tausch-und-zuordnung]]).
Ziele: (1) AP-Positionen definitiv per IP/MAC/Switch-Port klären, (2) ob die Messwerte den richtigen
Geräten zugeordnet sind, (3) Rundgang-Messung im natürlichen Zustand, (4) ap1-Redundanz-Test (ap1 aus).
## 🔑 AP-Zuordnung — DEFINITIV (IP + MAC + Switch-Port)
| Gerät | IP | ether1-MAC | Bridge/Mgmt-MAC | Radios (5G/2,4G) | Port | Position |
|---|---|---|---|---|---|---|
| **ap1** | .11 | `D0:EA:11:4D:40:E0` | `:E1` | `:E5` / `:E6` | core **ether3** | Sideboard (Wohnzimmer) |
| **ap2** | .12 | `D0:EA:11:4D:40:39` | `:3A` | `:3E` / `:3F` | core **ether21** | Technik **UG** |
| **ap3** | .13 | `D0:EA:11:FF:16:9D` | `:9E` | `:9F` / `:A0` | branch **ether24** | Technik **OG** |
Beweis: Neighbor-Tabelle (MNDP, L2) + Bridge-Host-Tabelle + Port-Link-Status am Core, live gelesen.
⚠️ **MAC-Korrektur:** ap2s `ether1` ist **`:39`**, nicht `:3A`. `:3A` ist die Bridge-/Mgmt-MAC
(die CLAUDE.md als „ap2" führt). ap2s Radios sind **`:3E`/`:3F`** — das Handoff-09-15 hatte damit
recht; meine frühere „um eins versetzt"-Korrektur war falsch (ich hatte `:3A` fälschlich als
ether1-Basis angenommen).
## Tausch-Befund — ap2↔ap3 statt ap1↔ap3
Geplant (Deckungsfix Variante 3): **ap1↔ap3** (cAP ax → Sideboard, hAP → UG-Schatten).
Tatsächlich getauscht: **ap2↔ap3**:
- **ap1** blieb Sideboard (ether3)
- **ap2** ging von OG → UG (ether21)
- **ap3** (cAP ax) ging von UG → OG (branch ether24)
⇒ Drei-Wege-Drehung mit dem **falschen Paar**. Der „Deckenstrahler" (cAP ax) steht jetzt in **OG**,
nicht am guten Platz (Sideboard). Ursache war die ap1/ap2-Verwechslung während der Session.
## Rundgang 1 — alle APs an, natürlicher Zustand (ohne Fixierung)
Methode: Registration-Table am Manager im 1-s-Takt, iPhone `F2:62:4C:E1:AF:09` auf `dirtyvader-main`.
- **0 Disconnects**, Signal **−45…−77 dBm** (alles brauchbar, meist deutlich besser).
- Roaming natürlich über **alle 3 APs** (ap3→ap2→ap1→ap2→ap3) — funktioniert.
- **Terrasse** (das Sorgenkind): **−58 dBm stabil** an OG-Position (jetzt cAP ax), 2,4G.
- **ap1 (Sideboard)** wurde nur **kurz gestreift** (−52…−60); den Rest dominieren ap3 (OG) + ap2 (UG).
- Kein Band-Thrash: nur ein sinnvoller 2,4G-Ausflug bei 5G-Zellkante (−77), sonst sauber 5G.
## Rundgang 2 — ap1 komplett aus (Redundanz-Test)
**Methode (neues CAPsMAN-Wissen):** ap1-Radios lassen sich **nicht** über `cap-wifiN` abschalten
(`failure: can not change dynamic interface`). Richtig: Provisioning-Regeln auf `action=none`, dann
re-provisionieren:
```
/interface wifi provisioning set [find where radio-mac=D0:EA:11:4D:40:E5 or radio-mac=D0:EA:11:4D:40:E6] action=none
/interface wifi capsman remote-cap provision [find where identity="ap1"]
```
Rückbau = `action=create-dynamic-enabled` + dieselbe re-provision. (Merkregel: `cap-wifiN` sind
dynamische Interfaces; Abschalten geht nur über die Provisioning-Regel + re-provision.)
**Ergebnis:**
- **Main-Client (iPhone):** ohne ap1 voll abgedeckt (ap2+ap3), tiefster Wert **−80 dBm** (ap3/OG 5G,
an der Terrasse — dieselbe bekannte Zellkante, nur diesmal auf 5G statt 2,4G erwischt), kein Disconnect.
- **IOT:** vorher 27 Geräte (19 ap1 + 8 ap2). Nach Abschalten **alle 27 zurück** — 23 auf ap2, 4 auf ap3.
MAC-gegengeprüft, keiner verloren. **Aber ~4 Geräte sitzen marginal bei −77…−80 dBm** (Wohnzimmer-Geräte,
vorher nah am Sideboard-AP, jetzt an der ap2/UG-2,4G-Zellkante).
- **ap1 wieder eingeschaltet** (Revert) — Zustand vor dem Test wiederhergestellt.
## Befund: ap1 technisch redundant, aber 4 IOT-Geräte an der Kante
| Aspekt | Ergebnis |
|---|---|
| Main-Client | ✅ ohne ap1 voll abgedeckt, tiefster Wert −80, kein Disconnect |
| IOT (27) | ✅ alle verbunden, **aber ~4 marginal −77…−80 dBm** |
ap1 ist **technisch redundant** — nichts fällt aus, wenn es weg ist. Aber die ~4 Wohnzimmer-IOT-Geräte
verlieren ihre Signal-Reserve (verbunden ja, Aussetzer-Risiko erhöht). „ap1 abbauen" ist eine echte
Option, aber kein Selbstläufer.
## Offen / nächste Schritte
1. **Entscheidung ap1:** behalten (Status quo) oder Richtung Abbau? Nur sinnvoll, wenn die ~4 marginalen
IOT-Geräte akzeptiert werden (oder deren Platzierung/ap2-Abdeckung verbessert wird).
2. **Tausch korrigieren?** Aktuell cAP ax @ OG, hAP @ UG, hAP @ Sideboard — misst gut, ist aber nicht der
geplante Zustand (cAP ax → Sideboard). Der **Drei-Räume-Schatten** (Schlafzimmer/Bad/Küche, 08-22:
−82…−84) ist mit dem jetzigen Arrangement **noch nicht verifiziert** — der Rundgang lief ohne
Raumnamen/Fixierung. Nächste Messung sollte die drei Räume gezielt prüfen.
3. **Doku-Drift schließen** (nicht in dieser Session angefasst): CLAUDE.md-Hardware-Tabelle (ap2-MAC
`:3A`→`:39` ether1, ap2 Position OG→UG, ap3 UG→OG, ap1 „FUNKSTILL" überholt), teilprojekte.md TP25-Abschnitt,
Nautobot Location (nach wie vor `None`).
4. Gitea-Issue #17 trägt den Befund als Kommentar.
## Übergabeprompt (nächste Session)
> TP25 weiter: Die AP-Zuordnung ist jetzt definitiv geklärt (Handoff 2026-09-15 „zuordnung-und-redundanz").
> ap1=Sideboard (core ether3), ap2=Technik UG (core ether21), ap3=Technik OG (branch ether24) — der Tausch
> hat ap2↔ap3 vertauscht statt ap1↔ap3, ap1 blieb stehen. Rundgang ergab: Coverage überall gut (0 Disconnects,
> −45…−77 dBm), Terrasse −58 stabil an OG, ap1 nur kurz gestreift. ap1-Aus-Test: Main-Client voll abgedeckt,
> alle 27 IOT-Geräte kehren zurück, aber ~4 Wohnzimmer-IOT sitzen marginal −77…−80 dBm. ap1 ist wieder an.
> Offen: (1) ap1 behalten oder abbauen? (2) Tausch auf Plan korrigieren (cAP ax → Sideboard)? (3) Drei-Räume-
> Schatten (Schlafzimmer/Bad/Küche) gezielt nachmessen. (4) Doku-Drift (CLAUDE.md MAC/Positionen, Nautobot
> Location) schließen.

View File

@@ -0,0 +1,75 @@
# Handoff 2026-09-22 — CLAUDE.md ausdünnen (strukturelle Hygiene, kein Bug)
**Anlass:** Christoph stellt fest, dass CLAUDE.md „eh schon so groß" ist. Das ist kein kosmetischer
Punkt — es ist eine strukturelle Drift, die jede künftige Session belastet. Dieser Handoff beschreibt
das Problem aus der Innenperspektive einer Session (die CLAUDE.md bei jedem Start komplett lädt) und
gibt einen Scope für eine eigene Cleanup-Session.
## Das Problem, aus Sicht der Session
Jede Session lädt CLAUDE.md **vollständig** in den Kontext. Was dabei konkret wehtut:
1. **Reine Kontext-Kosten.** Die Datei ist >58 KB. Ein großer Teil davon ist nicht „Kontext für die
anstehende Arbeit", sondern Historie und abgeschlossene TPs. Der Token-Preis wird bei jeder Session
fällig, egal worum es geht.
2. **Signal-to-noise im Gotcha-Block.** Der „Kritische Gotchas"-Abschnitt mischt **aktive Fallen** mit
**erledigten/gegenstandslosen** Einträgen. Beispiele direkt aus dem Bestand: `~~ALLNET~~` und
`~~Omada~~` sind mehrfach als „gegenstandslos/historisch" markiert, stehen aber weiterhin voll im
Text. Mehrere Einträge tragen „korrigiert am X" — die **korrigierte** Fassung steht neben der
**falschen**, die damit weiter mitgelesen wird. Beim Suchen nach *einer* relevanten Falle scannt
die Session dutzende Zeilen, von denen ein guter Teil totes Gewicht ist.
3. **Redundanz über drei Orte.** Dieselbe Information (z. B. die AP-Positionen, der FT-Verlauf) steht
in CLAUDE.md **und** in `teilprojekte.md` **und** teils in Handoffs. Eine Korrektur an einer Stelle
lässt die anderen stumm veralten — genau die Drift, vor der die eigene CLAUDE.md warnt
(„Manuell gepflegte Doku verliert gegen die Realität, wenn sie lang wird").
4. **Der gefährlichste Effekt: die Bloat untergräbt „live prüfen".** Die Projektregel lautet „nicht
raten, live prüfen oder fragen". Eine Riesen-CLAUDE.md mit eingestreuten Stale-Angaben verleitet
genau dazu, sich auf die Datei zu verlassen statt nachzusehen — die Datei ist Teil des Problems,
das sie zu lösen vorgibt.
## Was wohin gehört (SSoT-Dreiteilung, TP36)
| Inhalt | Soll-Ort |
|---|---|
| VLAN-Modell, Hardware-Tabelle (aktuell), Tooling-Pointer | CLAUDE.md (bleibt) |
| **Aktive, querschnittliche** Fallen (Firewall deny-by-default, sshpass-Regel, cap-wifiN-Karte …) | CLAUDE.md (bleibt, komprimiert) |
| TP-spezifische Gotchas & Detail-Verläufe | `teilprojekte.md` `### TPnn` |
| Erledigte/historische Geräte & Entscheidungen | `teilprojekte-archiv.md` |
| „korrigiert am X"-Notizen | Original-Eintrag bereinigen (Stale-Wert raus), Korrektur **ersetzt** den Irrtum, statt neben ihm zu stehen |
## Konkrete Kandidaten zum Ausdünnen (Startpunkte, nicht abschließend)
- **„Kritische Gotchas":** alle `~~durchgestrichenen~~`/„gegenstandslos"-Einträge (ALLNET, Omada,
sw1, ES208G, „ALLNET VLAN-DB") → Archiv oder löschen.
- **„korrigiert"-Notizen:** die falsche Angabe entfernen, nur die korrigierte behalten (z. B.
Nautobot-IP, „2×/Tag war nie real", „`ft=no` war NICHT uniform").
- **„Pflichtlektüre":** ist von „1–7" auf effektiv „0–7" angewachsen; die ✅-GESCHLOSSEN-Punkte
(TP16.3 §5, Fund E) sind abgeschlossene Historie → aus dem Pflichtteil in einen Verweis.
- **„Lessons for Sessions":** mehrere Meta-Lektionen überlappen sich (Maskierungs-Muster kommt 3×,
„erst das Werkzeug verdächtigen" 2×) → auf je eine Fassung verdichten.
- **Hardware-Tabelle:** ausgemusterte Geräte (Omada, ALLNET) sind noch als Zeilen da, teils nur mit
❌ — prüfen, ob sie ganz raus können (Archiv) statt als Toten in der aktuellen Tabelle zu stehen.
## Vorgehen für die Cleanup-Session
1. **Bestandsaufnahme zuerst:** CLAUDE.md abschnittsweise sichten und je Eintrag klassifizieren
(aktiv-querschnittlich / TP-spezifisch / historisch / redundant). Drift-Tabelle vorlegen, **fragen**
was wohin soll — nicht stillschweigend umsortieren.
2. **Nichts löschen ohne Zielort:** TP-spezifisches wandert nach `teilprojekte.md`, Historisches nach
`teilprojekte-archiv.md`. Erst wenn der Zielort steht, aus CLAUDE.md entfernen.
3. **Zielbild:** CLAUDE.md zurück auf einen **schlanken Index** (VLAN-Modell, Hardware, Tooling, die
~10 wirklich querschnittlichen aktiven Fallen). Detail gehört nach `teilprojekte.md`.
4. **Abnahme:** CLAUDE.md-Wortzahl/Zeilen vorher/nachher notieren; sicherstellen, dass kein
noch-aktiver Gotcha (Firewall, sshpass, cap-wifiN, slaves-datapath, WLAN-Logs-Topic) verloren geht.
## Übergabeprompt (nächste Session)
> CLAUDE.md ausdünnen: die Datei ist >58 KB und mischt aktive Fallen mit erledigter Historie
> (durchgestrichene „gegenstandslos"-Einträge, „korrigiert"-Notizen neben der falschen Angabe,
> Redundanz mit teilprojekte.md). Aufgabe: Bestandsaufnahme + Drift-Tabelle, dann TP-spezifisches
> nach teilprojekte.md, Historisches ins Archiv, CLAUDE.md zurück auf einen schlanken Index
> (~10 querschnittliche aktive Gotchas behalten). Nicht stillschweigend umsortieren — erst klassifizieren
> und fragen. Siehe [[handoff-20260922-claude-md-ausduennen]].

View File

@@ -68,7 +68,7 @@ den *damals geplanten* Werten und ⬜-Status. Sie sind erledigt bzw. gegenstands
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.30` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.31, `nautobot-scheduler`.20.32, `nautobot-redis`.20.34 |
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.154` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.155, `nautobot-scheduler`.20.156, `nautobot-redis`.20.158. **TP45 Schicht A 2026-09-21:** Oktett-Klasse `.150–.199` (App-Services) — die alten `.30–.34` gibt Nautobot an AGH `.30`/`.31` frei |
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.

File diff suppressed because one or more lines are too long

View File

@@ -58,20 +58,21 @@ teuerste Eigenschaft der Datei zerstört, um ihr billigstes Problem zu lösen. B
| [TP21](#tp21) | Nautobot Golden Config (config-as-code Control-Plane) | 🟢 Plattform + Datenmodell + Templates live |
| [TP21.1](#tp21-1) | Core-Cutover-Recovery (ap1 → crs326-core als L3-Router) | ✅ NETZ WIEDER LIVE (2026-08-08) — Aufräum-Todos **alle erledigt** (Stand 2026-08-23) |
| [TP22](#tp22) | Device-Adoption + Golden-Config Compliance-Push (RouterOS-Fleet) | ✅ DURCH (2026-08-13) — Compliance heute **104/104, 0 Drifts** (live 2026-08-23) |
| [TP25](#tp25) | WLAN-Stabilisierung | ✅ FERTIG (2026-09-15) — Platzierung final (ap1 Sideboard · ap2 UG · ap3 OG), Fund E geschlossen |
| [TP26](#tp26) | `/ip firewall filter` unter Drift-Kontrolle | ✅ DURCH (2026-08-17) |
| [TP27](#tp27) | RouterBOOT-Firmware auf 7.24 nachziehen | ✅ DURCH (2026-08-17/18) |
| [TP28](#tp28) | WLAN über CAPsMAN auf `crs326-core` | ↩️ **wieder geöffnet 2026-08-30** (ap3) — Volltext in [teilprojekte.md](teilprojekte.md#tp28) |
| [TP-R](#tp-r) | Router-Capabilities-Sweep | FERTIG (docs/research/routeros-capabilities.md) |
| [TP39](#tp39) | LXC-Hygiene pve: Resolver-Altlasten, `networking.service`, DB-Reverse-Lookup | ✅ ABGESCHLOSSEN (2026-09-01) — pve+NAS-Sweep grün |
| [TP34](#tp34) | Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) | ❌ VERWORFEN (2026-09-08) — Caddyfile bleibt manuell |
| [TP34](#tp34) | Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) | ❌ VERWORFEN (2026-09-08) — Caddyfile bleibt manuell · **Fortsetzung: TP34.2 (2026-09-22)** |
| [TP44](#tp44) | Cloudflare-Estate als Dokumentations-SSoT in Nautobot | ✅ DURCH (2026-09-10) |
| [TP41](#tp41) | CF-Access-Reparatur + Zero-Trust-Konsolidierung | ✅ ABGESCHLOSSEN (2026-09-10) — Gelis WARP via Android eingerichtet, letzter Blocker weg |
| [TP43](#tp43) | dw-Standort-DNS-Uplink (site-spezifische Overrides) | ♻️ REOPENED (2026-09-11) — Volltext zurück in teilprojekte.md; interne BF-Namen über WG am dw offen |
| [TP42](#tp42) | Namens-Vereinheitlichung: ein interner Suffix bf.emsenhuber.net | ✅ DURCH (2026-09-02) — `.bf.lan` raus, `.16` abgebaut, Compliance 140/140 |
Dazu am Ende: **[TP25 (Langfassung)](#tp25-lang)** — die Messchronik und die Funde A–E im Volltext.
TP25 selbst ist **nicht** abgeschlossen und steht weiter in `teilprojekte.md`; nur seine 394 Zeilen
Herleitung liegen hier.
Dazu am Ende: **[TP25 (Kondensat)](#tp25)** + **[TP25 (Langfassung)](#tp25-lang)** — TP25 ist
✅ abgeschlossen (2026-09-15); der aktive Stand (Kondensat) und die 394-Zeilen-Herleitung
(Messchronik A–G) liegen beide hier.
---
<a id="tp1"></a>
@@ -1295,13 +1296,529 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung".
---
<a id="tp25"></a>
## TP25 — Kondensat (aktiver Stand bis 2026-09-15, abgeschlossen)
> ✅ **TP25 ist ABGESCHLOSSEN (2026-09-15).** 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. Die 394-Zeilen-Herleitung (Messchronik A–G) steht
> weiter unten unter [TP25 — Langfassung](#tp25-lang).
### TP25 — WLAN-Stabilisierung ▸ ✅ **FERTIG: Platzierung final (ap1 Sideboard · ap2 UG · ap3 OG), alle Handoff-Punkte aufgelöst (2026-09-15, [Steering-Handoff](handoff-20260915-tp25-steering-threshold-und-terrasse.md))** — ap2-UNTERPUNKT ✅ WIDERLEGT 2026-09-14 (In-situ, [Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)): ap2 sendet auf 2 m mit −49…−51 dBm, stabil, 0 Roams — Hardware sauber, kein Tausch begründet. Terrasse = 5-GHz-Zellkante (−76…−81 vs. 2,4 GHz −65…−73), Thrash nur in der Band-Überlappungszone −70…−77. Hebel „Access-List-Band-Boden" in der Machbarkeitsprüfung GESCHEITERT (kein `band`-Property, cap-wifiN-Rejoin-Falle) — s. Block unten** — **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
> 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen
> 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv:
> [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
> Funde A–E im Volltext, die beantworteten Fragen (Beobachtung 5, ch149-Herkunft, `2ghz-g`,
> Deckenmontage), Umsetzungsreihenfolge mit allen Deploy-Gotchas. Hier steht: **Stand, Offenes,
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
> *warum* etwas so entschieden wurde, liest die Langfassung.
**Stand 2026-09-15 — AP-Zuordnung DEFINITIV geklärt, Tausch-Befund ap2↔ap3** ([Handoff](handoff-20260915-tp25-zuordnung-und-redundanz.md)). Positionen per IP+MAC+Switch-Port (MNDP/Bridge-Host/Port-Link) live belegt: **ap1 = Sideboard/Wohnzimmer (`core ether3`), ap2 = Technik UG (`core ether21`), ap3 = Technik OG (`branch ether24`)**. Der geplante Tausch (ap1↔ap3, cAP ax → Sideboard) hat faktisch **ap2↔ap3** gedreht — ap1 blieb stehen, der cAP ax steht jetzt in OG. MAC-Korrektur: ap2 `ether1` ist `:39`, nicht `:3A` (`:3A` = Bridge/Mgmt-MAC). Rundgang (natürlicher Zustand, 1-s-Takt): **0 Disconnects**, −45…−77 dBm, Roaming über alle 3 APs; **Terrasse −58 stabil** (jetzt cAP ax/OG, 2,4G), ap1 nur kurz gestreift. ap1-Aus-Test: Main-Client voll abgedeckt (tiefster Wert −80), **alle 27 IOT kehren zurück**, aber ~4 Wohnzimmer-IOT marginal −77…−80 ⇒ ap1 technisch redundant, nicht ausfallkritisch. ap1 wieder an (Revert). **Aufgelöst 2026-09-15 ([Steering-Handoff](handoff-20260915-tp25-steering-threshold-und-terrasse.md) §6):** (1) ap1 **bleibt** am Sideboard, (2) Tausch-Korrektur **verworfen** — die Prämisse „Sideboard-AP deckt die Terrasse" war widerlegt (ap1 = Innenraum-König, cAP ax gehört nach OG), (3) Drei-Räume-Schatten **verworfen** (Signal beim Rundgang gut), (4) Doku-Drift ✅ geschlossen. **Platzierung final: ap1 Sideboard · ap2 UG · ap3 OG.**
**Merke:** `no cached PMK` im Log ≠ FT-Problem — es tritt auch auf `ft=no`-SSIDs (IOT) auf und ist ein harmloser PMK-Cache-Miss (voller Handshake statt Fast-Reassoc). Der 09-15-Abriss war die Terrassen-Bandkante, nicht PMK/FT.
**Stand 2026-09-14, 20:05 — ap2-In-situ: Hardware entlastet, Terrasse = 5-GHz-Zellkante,
Band-Boden nicht sauber ausdrückbar** ([Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)).
Live-Session auf der Terrasse (Christoph kooperierte physisch — der Test, der am 08-31 „nachts
nicht möglich" war): das Fund-E-iPhone flappte 18:53–19:18 ~30×, fast ausschließlich
**ap2-5G ↔ ap2-2,4G** (−68…−81), mit 3 Abwürfen (2 Call-Aussetzer). Vor ap2 (1–2 m): **−49…−51 dBm,
>2 min stabil, 0 Roams** ⇒ der offene Verdacht „ap2 RF/Antenne/HW" (23:02-Befund, 2 m/−80) ist
**in situ widerlegt** — der 23:02-Wert reproduziert sich seit dem 09-04-Kanalplan (5300/5320,
tx-power 18 live) nicht mehr. Am Terrassenplatz ist **2,4 GHz klar besser** (−65…−73 vs. 5 GHz
−76…−81) und das Telefon dort **ruhig** (36 s im 1-s-Takt, 0 Roams) — es thrasht nur in der
Überlappungszone −70…−77 beider Bänder; zusätzlich client-getrieben (floh 19:44 selbst von −52
auf 2,4/−77; Steering hat keine Schwellen, der AP drängt nichts). Mit `ft=no` ist jeder Roam ein
spürbarer Voll-Reconnect ⇒ so sterben Anrufe. **Hebel „Access-List-Boden < −76 auf 5G":
Machbarkeitsprüfung negativ** — `/interface/wifi/access-list` hat kein `band`-Property,
Interface-Scoping bricht am cap-wifiN-Rejoin, SSID-/MAC-Scope träfen 2,4 GHz mit (Schlafzimmer
−84 ⇒ Lockout). **Nicht umgesetzt.** Verbleibende Hebel: (1) ✅ **DURCH 2026-09-14, 20:30 Uhr** — `ft=yes ft-over-ds=yes`
uniform auf `sec-main` (Ruecknahme der 09-04-Entscheidung; Go per Fragenrunde nach Dry-Run). Weg:
Template-Edit + `push-gc-template.py` (GC-Commit `744a17e3`) → `--refresh-compliance` (Drift
`wifi-security` wie erwartet) → `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed, Asserts gruen;
ein Polling-Timeout am Skript war folgenlos — Geraet stand bereits auf ft=yes) → Refresh: **140/140, 0
Drifts**. CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands-File:
[`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`](transaktionen/TP25-ft-yes-secmain.commands.rsc).
Abnahme ✅ **DURCH (2026-09-14, 20:33–20:34 Uhr, live)**: Anruf auf der Terrasse ueberstand
**fuenf Band-Wechsel binnen 60 s** (nur `roamed to`, keine `disconnected` mehr im Fenster —
Christoph: „call steht"). (2) Deckungsfix
Variante 3 (ap1-5G erreichte die Terrasse mit −64) — beendet das Springen selbst.
Nachtrag 20:37: ein **Totalverlust** anderer Klasse (Roam auf ap1-2,4 bei −89, danach iOS-Aufgeben
ohne jeden Reconnect-Versuch; identisch schon 19:01 mit ft=no gesehen) — **keine ft=yes-Regression**,
sondern der strukturelle Rest. **Wechselrichter/DTU (Hoymiles HMS-800W-2T, geht mit der Sonne an/ab,
09-14: 07:09–19:35): nachts unbeteiligt; tagsüber ~5:05-Zyklus (connect/`connection lost`/reconnect +
DHCP-Churn) auf ap2-2,4/ch11 bei −53.** Messplan „DTU vs. Terrasse" angelegt ([Issue #17, Kommentar
#225](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-225)): Referenz-Client auf
ch11 pinnen (tp25-Methodik), 30–60 min Ping, Korrelation gegen die 5:05-Taktmarken. ⚠️ **Korrektur
09-14 abends ([Kommentar #227](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-227)):
Der urspruenglich genannte Hebel „OpenDTU/AhoyDTU-Dongle" existiert fuer DASSES Geraet NICHT** — HMS-
**XXXXW**-2T (W = eingebaute DTU „DTUBI", protobuf-Protokoll) wird von AhoyDTU/OpenDTU nicht
unterstuetzt (lumapu/ahoy#1189 seit 09-2023 offen; Christophs eigene AhoyDTU nachweislich
inkompatibel; nur die Nicht-W-Variante HMS-800-**2T** ginge per CMT2300-Funk). Die lokale Ebene
(hms-mqtt-publisher = „hmsmqtt", dtuGateway, ha-hoymiles-wifi) redet **mit der internen DTU uebers
Netzwerk** — keines davon nimmt die DTU von der Luft, hmsmqtt pollt sogar zusaetzlich. **Einziger
Airtime-Hebel bleibt DNS-Hoheit ueber die Cloud**, von Christoph bewusst zurueckgestellt (Cloud-
Aufzeichnungen noch benoetigt, bis hmsmqtt + WLAN stabil); ob eine DTU ohne Cloud ruhiger oder
gespraechiger wird, ist selbst eine offene Messfrage. Pin auf anderen AP kontraindiziert
(−53 direkt an ap2); 5-Min-Takt ist S-Miles-Telemetrie per Design, kein Ethernet an der DTU-WLite.
✅ **Netzwerk-Mechanismus geklaert (09-14, Nacht; [Issue #17, Kommentar
#228](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-228)):** Der **Fund-F-5:05-Zyklus
ist das Cloud-Recovery-Verhalten der DTU.** Wireshark-Analyse (hms-mqtt-publisher PR #110, HMS-800W-2T):
lokaler Poll <30 s resettet den Countdown und setzt Cloud-Updates aus; **nach 4–5 min erfolgloser
Cloud-Updates** startet die DTU eine neue TCP-Session — und dabei (unser Log-Beweis 09-14) eine
**vollstaendige WiFi-Disassoziation inkl. DHCP-Churn**. Die heute gemessenen Zyklus-Abstaende decken
sich mit dem Befund aufs Minutenkomma. Ursache war hmsmqtt mit **30 500 ms** (Grenzzone);
DTU-Firmware aelter, hmsmqtt funktioniert (⇒ #134-Key-Rotation-Falle nicht primaer).
**Hebel = Poll-Rate, nicht DNS-Hoheit:** Christoph hat **30500 → 45000 ms** gesetzt (09-14 abends;
Community-Wert, 10 Monate stabil). ✅ **Verifikation DURCH (09-15, 12:11; [Issue #17, Kommentar
#230](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-230)): DTU online seit 06:41:49,
uptime 5 h 29 min, 0 Disconnects** (Vergleich 09-14 mit 30,5 s: fuenf Zyklen in den ersten 18 min).
Die DTU sitzt heute auf **ap3-2,4** (cap-wifi4-virtual1, −49; cap-Karte unveraendert, Attribution
belastbar) — also aktuell auch vom Terrassen-Band ch11 weg. **Fund F ist damit abgeschlossen** —
Ursache war die DTU-Cloud-Recovery, getriggert durch hmsmqtt-Polling-Kollision. Messplan #225 ist
gegenstandslos (der zu messende Effekt existiert nicht mehr); eine ch11-Tagzeitmessung bleibt nur
sinnvoll, falls die Terrasse tagsueber weiterhin Call-Probleme zeigt. 📅 **terminiert ([Issue #17,
Kommentar #232](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-232)): Deckungsfix
Variante 3 — Umsetzung 2026-09-15/16** (Christoph: „heute oder morgen"): Deckenstrahler an den guten
Platz, verdrängter hAP in den Drei-Raeume-Schatten (Schlafzimmer −84,6/38 %, Bad −82,3/24 %,
Kueche −82,0/23 % laut 08-22-Messung). Vorher-Messung Terrasse (Referenz 09-14: 5 GHz −76…−81,
2,4 GHz −65…−73) → Umsetzung → Nachher-Messung + Anruf-Abnahme; Kanalplan (09-04) bleibt unberuehrt,
nur Platzierung aendert sich; Config-Aenderungen nur ueber den GC-Weg. Offen vor Ort: Zielraum fuer
den verdraengten hAP (PoE/Steckdose) und ap1-Uplink-Thema (Sideboard → `core ether3`) mitdenken.
Randnotizen: ap1 „FUNKSTILL" im CLAUDE.md ist überholt (dient live, Radar-Event 16:51 auf 5280
sauber abgehandelt); Template-Abschnitt [6c] („Fund E WIEDER OFFEN", Stand 08-30) ist inhaltlich
überholt — beim nächsten Template-Edit mit aktualisieren, kein separater Edit nur für den Kommentar.
**Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken
([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust /
20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
**Anheben von ap2 auf 2,5 m**; 1,0 s liegt unter der Hörbarkeitsschwelle im Call. Der Dead Spot, der
TP25 seit dem 08-20 getragen hat, ist damit weg.
🔎 **Kernbefund, der bleibt: das Netz ist gut, die Redundanz ist es nicht.** Die frei roamende Referenz
hat in **17 von 21** Raumabschnitten 0 % Verlust; der Gegen-AP liefert Schlafzimmer −84,6/38 %,
Bad −82,3/24 %, Küche −82,0/23 %, Terrasse −78,7/19 %. **Fällt ein AP aus, ist die halbe Wohnung
„verbunden, aber tot".** ⇒ Ein **dritter AP ist nach Christophs eigener Randbedingung derzeit NICHT
begründet** — er kaufte Redundanz, keine Abdeckung. Das ist eine andere Frage und Christophs.
**Beide vormals offenen Punkte sind am 2026-08-24 abgearbeitet** — Fund F ist aufgelöst, der
5-GHz-Umfeldscan gefahren und der Kanalplan für einen dritten AP gerechnet. **Was von TP25 bleibt,
ist Umsetzung bei Bedarf, keine offene Frage:**
1. ✅ **Fund F — GEMESSEN UND AUFGELÖST (2026-08-24): kein netzseitiges Phänomen.**
Kollektor am **Manager** (`/interface wifi registration-table`, 15-s-Takt, 29 Samples über 7,7 min,
28 Clients), Rohdaten `docs/messdaten/tp25-fundf-20260824.log`. Von den drei Kandidaten sind **zwei
erledigt, einer bleibt** — und die zwei fielen ohne Wartezeit:
| Kandidat | Ergebnis | woran es hängt |
|---|---|---|
| **(b)** DHCP-`lease-time` gegen die Periode | ❌ **tot** | `dhcp-v30` fährt **`lease-time=1d`** → Renew bei 12 h. Kann 5:05 nicht erzeugen. **Eine Abfrage, keine Messung.** |
| **(a)** periodischer VAP-Re-Apply | ❌ **tot** | Ein Re-Apply wirft **alle** Clients des VAP gleichzeitig. Auf `cap-wifi4-virtual1` standen zeitgleich **drei Clients bei 16 h 51 min** Uptime, 25 von 28 über 6 h. Mit einem 5-Minuten-Takt ist das unvereinbar. |
| **(c)** das Gerät selbst | 🟡 **bleibt** | Der einzige Client, der im Fenster überhaupt neu verband. |
🔑 **Der entscheidende Punkt an der Beweisführung: dafür braucht es die vier Zyklen nicht.** Ein
einziges Sample mit **16 h 51 min Uptime auf genau dem VAP**, der angeblich alle 5:05 abwirft,
widerlegt (a) vollständig. Die Fensterlänge begrenzt nur, was man über *abwesende* Geräte sagen kann.
🔴 **Und genau die sind abwesend: die beiden Fund-F-MACs gibt es gerade nicht.**
`38:A5:C9:C0:73:1E` (`10.40.30.178`) und `38:A5:C9:BC:33:31` (`10.40.30.179`) stehen in `/ip arp` auf
**`status=failed`**, haben **keine DHCP-Lease**, stehen **nicht** in der Registration-Table und sind
**auch HA unbekannt** (kein `device_tracker` zu diesen MACs). ⇒ **Das Phänomen ist an diesen Geräten
derzeit nicht reproduzierbar, weil die Geräte weg sind.**
📌 **Merkregel — dieselbe wie beim P750W:** bevor ein periodisches Trennen als Funk- oder
Konfigurationsfehler untersucht wird, **erst prüfen, ob die betroffenen Geräte überhaupt da sind.**
Beim Drucker war die Fehldiagnose „Power-Save", hier wäre sie „VAP-Re-Apply" geworden.
⇒ **Fund F wird geschlossen:** was auch immer der 5:05-Takt war, er lag **geräteseitig**, und die zwei
betroffenen Geräte sind vom Netz. Netzseitig ist nichts zu tun. **Nicht erneut aufgreifen**, solange
`.30.178`/`.179` nicht wieder da sind *und* das Muster erneut gemessen wird.
🟡 **Nebenbefund mit Substanz — der DTU ist der einzige Wackelkandidat, den es wirklich gibt.**
`54:F2:9F:12:51:F0` (DTU/Wechselrichter, auf `cap-wifi4-virtual1` = **ap1**) war der **einzige** Client
mit einem Uptime-Rücksetzer: 43 min Sitzung, dann weg für ein Sample, dann wieder da — **bei −52 dBm**,
also bei tadellosem Signal. Das ist derselbe Mechanismus, der schon die Transaktion
`TP17.1-dtu-pin-ap2` begründet hat: **ein ESP hat EIN Radio, sein eigener AP und die STA-Verbindung
teilen sich den Kanal.** Periode hier 43 min, nicht 5:05 — es ist **nicht** Fund F, aber es ist echt.
⇒ Gehört zur ohnehin vorgesehenen Neubewertung des DTU-Pins, sobald der Kanalplan steht (s.o.).
<details><summary>📜 Ursprüngliche Fund-F-Formulierung (2026-08-22)</summary>
1. 🟡 **Fund F — IoT-Clients trennen exakt alle ~5:05 bei gutem Signal** (−59 dBm, vier Messpaare,
auf 3 s reproduzierbar → **ein Timer, kein Funk**). Muss **am Manager** nachgemessen werden, die
AP-Tabellen sind unter CAPsMAN leer. Kandidaten und der Test, der sie trennt:
(a) periodischer VAP-Re-Apply (Fund D war die Ursache → prüfen, ob es nach dem Fix weg ist);
(b) DHCP-`lease-time` der beiden MACs (`38:A5:C9:C0:73:1E`, `38:A5:C9:BC:33:31`) gegen die Periode legen;
(c) das Gerät selbst mit 5-min-Duty-Cycle → dann bleibt es unverändert.
🚫 **Nicht die alte Power-Save-Merkregel wiederbeleben** — die ist am 2026-08-13 als Fehldiagnose
zurückgezogen (`ref_wifi_powersave_multicast`).
</details>
2. ⚖️ **Entscheidung 5 GHz auf 20 MHz** (statt heute 20/40) — **✅ 5-GHz-Umfeldscan durchgeführt
2026-08-24, Empfehlung: NICHT verschmälern, Entscheidung an einen Auslöser hängen.**
⚠️ **2,4 GHz NICHT vorschnell drosseln** — dort hängen ~20 IoT-Geräte.
**Messung** (`/interface wifi scan` **am Manager**, cap-wifi3 = ap1 / cap-wifi1 = ap2, 10–12 s):
| vom Radio auf | fremdes 5-GHz-Netz | Kanal | Signal | Abstand zum eigenen AP |
|---|---|---|---|---|
| **ap1** (5180/Ce) | `Drei_H288A_5G_acHG` | 5745/ac/**80 MHz** | **−86 dBm** | 19 dB darunter |
| **ap2** (5220/Ce) | `LiwestBB93-5G` | 5200/ac/**80 MHz** (Block 5180–5240) | **−89 dBm** | 24 dB darunter |
⇒ **Das 5-GHz-Band ist an beiden Standorten praktisch leer.** Genau **ein** Fremdnetz je AP, beide
rund 20 dB unter dem eigenen Pegel. Der Nachbar `LiwestBB93-5G` liegt mit 80 MHz zwar formal über
**beiden** unserer Blöcke — bei −89 dBm ist das ohne Wirkung.
🎯 **Warum das gegen 20 MHz spricht — die zwei üblichen Gründe treffen hier nicht zu:**
- **Fremdinterferenz:** existiert nicht (−86/−89). Ein schmalerer Kanal hätte nichts abzuwehren.
- **Selbstüberlappung:** existiert nicht. ap1 `5180/Ce` = ch36+40, ap2 `5220/Ce` = ch44+48 —
überlappungsfrei (Fund C, 08-17).
- **Bleibt allein das +3 dB Link-Budget** — und das ist zum guten Teil **schon vorhanden**: in einem
40-MHz-BSS sendet die Ratenanpassung an schwache Clients ohnehin auf dem Primärkanal in 20 MHz.
Den Ceiling zahlen aber **alle** Clients dauerhaft. **Der Nutzen geht an die, die ihn schon haben.**
- **Und es löst das gemessene Problem nicht:** die offenen Schwachstellen sind die
Redundanz-Lücken (Schlafzimmer −84,6 / 38 %, Bad −82,3 / 24 %) **am Gegen-AP**. +3 dB macht aus
38 % Verlust keine 0 % — das ist eine Platzierungs-/AP-Zahl-Frage, keine Kanalbreiten-Frage.
🔑 **Der echte Auslöser ist die AP-Zahl, nicht die Interferenz** — und das ist der Grund, die
Entscheidung *jetzt* zu treffen statt sie offenzulassen. Beide Kanäle stehen hart in **Band A**
(`ch-5g-ap1`=5180, `ch-5g-ap2`=5220, kein `skip-dfs-channels`), und Band A hat unter ETSI genau
**4 × 20 MHz = 2 × 40 MHz**. Mit zwei APs ist es damit **exakt aufgebraucht**:
| Szenario | bei 40 MHz | bei 20 MHz |
|---|---|---|
| 2 APs (heute) | ✅ passt genau, überlappungsfrei | ✅ passt, +3 dB, halber Ceiling |
| **3.–4. AP** | ❌ Band A voll ⇒ **nur mit DFS** (Band B ch52–64: Radar-Events, 60 s CAC) | ✅ 4 saubere Kanäle, **kein DFS** |
⇒ **Empfehlung: 20/40 MHz beibehalten.** Die Verschmälerung ist heute ein Preis ohne Gegenwert.
Sie wird **fällig, sobald ein dritter AP dazukommt** — dann lautet die Wahl nicht mehr „20 gegen 40",
sondern **„20 MHz flottenweit" gegen „DFS akzeptieren"**, und dafür ist 20 MHz die ruhigere Antwort.
Ein Zwischenweg existiert (ap1 behält 40 MHz, AP 2+3 fahren 20 MHz auf 5220/5240) und braucht
ebenfalls kein DFS.
📌 **Damit ist dieser Punkt entscheidungsreif und an einen Auslöser gebunden statt offen.**
#### 📐 Kanalplan für den dritten AP ▸ **gerechnet 2026-08-24, DFS vom User genehmigt**
> **User-Vorgaben (2026-08-24):** 20/40 MHz bleibt · **DFS ist genehmigt**, soll aber auf **ap1 oder ap2**
> liegen, nicht auf dem Neuen · der Neue ist ein **MikroTik-Deckenstrahler mit ax** · **möglicherweise
> kommt er an die Stelle eines der bisherigen APs** · der vorhandene Outdoor-AP bleibt konzeptionell außen vor.
**Das Kanalbudget unter ETSI (Indoor), als Rechnung statt als Gefühl.** Ein 40-MHz-Block darf keine
Bandgrenze überschreiten — `[48+52]` ist deshalb unmöglich, und daran hängt die ganze Knappheit:
| Band | Frequenzen | Kanäle | 40-MHz-Blöcke | DFS | EIRP |
|---|---|---|---|---|---|
| **A** | 5150–5250 | 36/40/44/48 | **2** (`5180/Ce`, `5220/Ce`) | nein | 23 dBm |
| **B** | 5250–5350 | 52/56/60/64 | **2** (`5260/Ce`, `5300/Ce`) | **ja** + TPC | 23 dBm |
| **C** | 5470–5725 | 100–140 | **5** | **ja** + TPC | 30 dBm, outdoor erlaubt |
Heute belegen zwei APs mit 40 MHz **ganz Band A**. Genau das macht den dritten AP zur Bandfrage — und
mit genehmigtem DFS ist sie lösbar, ohne irgendwo auf 20 MHz zu gehen.
**Empfohlene Zuordnung (3 APs, alle 40 MHz):**
| AP | Rolle | Kanal 5 GHz | Band | DFS | 2,4 GHz |
|---|---|---|---|---|---|
| **cAP ax (neu)** | Primär, beste Position | **`5180/Ce`** (36+40) | A | **nein** | 2437 (ch6) |
| **ap1** hAP ax² | Sekundär | **`5260/Ce`** (52+56) | B | ja | 2412 (ch1) |
| **ap2** hAP ax² | Sekundär | **`5300/Ce`** (60+64) | B | ja | 2462 (ch11) |
| — | **Ausweichspur, frei halten** | `5220/Ce` (44+48) | A | — | — |
- **Warum Band B und nicht Band C für die zwei DFS-Radios:** gleicher 23-dBm-Deckel wie Band A (kein
Leistungssprung zwischen den APs) und **CAC 60 s statt 600 s** — die 10-Minuten-Prüfung gilt im
Wetterradar-Bereich von Band C. Band C bleibt damit frei für den Outdoor-AP, wo seine 30 dBm auch
hingehören.
- **Warum der Neue das nicht-DFS-Band bekommt:** DFS kostet **60 s CAC bei jedem Boot und jeder
Provisionierung** plus einen erzwungenen Kanalwechsel bei Radar-Erkennung. Diese Kosten gehören auf
die Radios, deren Fläche von einem anderen AP mitgetragen wird — nicht auf den bestplatzierten.
Christophs Instinkt ist hier richtig.
- **2,4 GHz fällt nebenbei sauber:** ch1/6/11 ist mit drei APs erstmals vollständig überlappungsfrei
möglich (heute 1/11 mit einer Lücke in der Mitte).
🔴 **Zwei Fallen, ohne die der Plan teurer wird als er aussieht:**
1. **Eine hart gepinnte Frequenz macht DFS zum Single Point of Failure.** `ch-5g-ap1`/`-ap2` tragen heute
**je genau einen** Wert (`frequency=5180` bzw. `5220`). Bei einer Radar-Erkennung **muss** das Radio
den Kanal verlassen — hat es keinen zweiten konfiguriert, bleibt das 5-GHz-Funk dieses APs **unten**.
⇒ Für DFS gehört in das Channel-Objekt eine **Liste** (z.B. `frequency=5260,5280,5220`), damit der AP
neu wählen kann. Das ist der Unterschied zwischen „DFS ist unkritisch" und „DFS kostet einen AP".
2. **Vor dem Umzug die *erreichte* Sendeleistung messen — das ist Fund B, nochmal.** Band B/C verlangen
TPC; ohne TPC sinkt der erlaubte EIRP um 3 dB. TP25 Fund B war exakt dieses Muster: **18 dBm gesetzt,
9 dBm gesendet**, weil ch149 unter ETSI leistungsbegrenzt ist. ⇒ **Diskriminierender Test, bevor
irgendwas gekauft oder umgezogen wird:** ap2 testweise auf `5260`, dann `/interface wifi monitor`
lesen und die tatsächliche `tx-power` gegen die heutigen 18 dBm auf `5220` stellen. Kostet eine
Config-Transaktion und zehn Minuten, und entscheidet, ob der Plan trägt.
⚖️ **Zur Platzierung — die Messung widerspricht dem Aufstellen an einer bisherigen Stelle.**
Die Referenzmessung vom 08-22 sagt: **17 von 21 Raumabschnitten haben 0 % Verlust.** Zusätzliche
*Abdeckung* ist damit wertlos. Der ganze Wert liegt im **Gegen-AP**: Schlafzimmer −84,6 / 38 %,
Bad −82,3 / 24 %, Küche −82,0 / 23 %.
| Variante | was sie bringt |
|---|---|
| Neuer AP an **bisheriger** Stelle | besseres Deckenbild an einem Punkt, **der schon funktioniert**. Die Drei-Zimmer-Lücke bleibt **unverändert** — gleicher Standort, gleicher Schatten. |
| Neuer AP an **neuer** Stelle (Richtung Schlafzimmer/Bad/Küche) | schließt die Lücke, also genau die Redundanz, die gekauft werden soll. |
| 🎯 **Beides: cAP ax an die bisherige Stelle, der verdrängte hAP ax² wandert in den Schatten** | bestes Deckenbild dort, wo die Clients sind, **und** eine zweite Option in den drei Räumen. Braucht keine vierte Antenne, nur eine Steckdose/PoE im Zielraum. |
⇒ **Empfehlung: Variante 3.** Sie ist der einzige Weg, bei dem der Deckenstrahler an den guten Platz
darf, *ohne* dass die gemessene Schwäche unangetastet bleibt.
- **Voraussetzung Neuer AP:** PoE-in am Zielort (die cAP-ax-Reihe hat einen Gigabit-PoE-Port, kein
Netzteil im Lieferumfang üblich) und ein Trunk mit VLAN 10/30/40/50/90/99 wie bei ap1/ap2. Der AP
läuft auf `wifi-qcom` und tritt damit als **regulärer CAP** dem Manager bei — kein neues Modell im GC,
nur ein weiteres Gerät plus zwei `channel`-Objekte. ⚠️ **Exaktes Modell vor dem Kauf prüfen**
(`cAP ax` vs. `cAP XL ax`) — beide sind ax/wifi-qcom, unterscheiden sich in Abdeckung und Preis.
- **Wenn später doch der Outdoor-AP dazukommt:** er gehört nach **Band C** (30 dBm, outdoor zulässig);
Band A/B sind dann von den drei Indoor-APs belegt und die Ausweichspur `5220/Ce` ist aufgebraucht.
#### 🔴 Fund E — **WIEDER GEÖFFNET 2026-08-30: die Wiedervorlage-Bedingung ist eingetreten**
Fund E war „abgestuft, NICHT umsetzen" mit einer ausdrücklichen Bedingung: *„Erst wieder
aufgreifen, wenn ein Sticky-Fall erneut gemessen ist."* Am **2026-08-30** ist genau das passiert.
**Auslöser war eine unabhängige User-Meldung**, nicht die Suche danach: *„die letzten Tage/Wochen
regelmäßig mit dem iPhone den WiFi-Link verloren obwohl bester Kontakt. Ich lande auf 5G und nicht
am alten AP oder dem anderen."* 🔑 **Auf Mobilfunk zu fallen ist kein Roaming-Problem** — bei
schlechtem Roaming bliebe das Telefon im WLAN und wäre nur langsam. Es heißt: die Assoziation ist
gestorben und die Neuanmeldung gescheitert.
**Neue Messung (Manager-Log, 2026-08-30):** dasselbe iPhone `F2:62:4C:E1:AF:09` machte **16
Bandwechsel in 20 Minuten** zwischen `cap-wifi5` (ap3, 5 GHz) und `cap-wifi6` (ap3, 2,4 GHz),
zweimal im Abstand von **einer Sekunde**, bei **−53 … −81 dBm**. Kurz darauf dasselbe zwischen
`cap-wifi1`↔`cap-wifi2` — also **an ap2**. ⇒ **Nicht ap3-spezifisch, sondern an jedem AP.**
Das deckt sich mit der Ursprungsmessung vom 08-20 (sechs Flaps in 22 Minuten) und mit
`…@wifi2 disconnected, not responding, signal strength −47` — **Abbruch bei ausgezeichnetem Signal**.
⚠️ **Zwei Symptome, die NICHT gleichgesetzt werden dürfen:** die Band-Oszillation (beide Male
gemessen) und der Abbruch *„not responding"* bei gutem Signal. Christophs Beschreibung passt auf das
**zweite**. Ob dieselbe Ursache dahintersteht, ist **offen**.
**Zeitachse:** am **2026-08-17** liefen TP28 (CAPsMAN-Cutover) und TP27 (7.24) gleichzeitig. Vorher
waren ap1/ap2 Standalone **ohne Steering und ohne Neighbor-Group**, und `sec-main` fuhr
`ft=yes ft-over-ds=`**`no`**. Beides änderte sich an diesem Tag. Das Beschwerdefenster deckt sich —
**Korrelation, kein Beweis.**
**Was die MikroTik-Doku dazu hergibt (recherchiert 2026-08-30):**
- `transition-threshold` Default **−80 dBm**, `-time` **10 s**, `-request-period` **30 s**,
`-count` **3**. Unsere Steering-Objekte setzen **nichts** davon. Bei −53…−74 dBm wird das iPhone
also kaum Transition-Kandidat ⇒ **unsolicited BTM scheidet als Treiber weitgehend aus.**
- **WPA3 + 802.11r:** die Doku nennt **nur** „802.11r geht nicht über **OWE**". Eine
WPA3-PSK/FT-Inkompatibilität steht dort **nicht** — sie kursiert in Foren. **Hypothese.**
- **`2g-probe-delay`**: dokumentiertes Band-Steering (2,4-GHz-AP ignoriert die ersten 3
Probe-Requests je Client pro 60 s). Bei uns **nicht gesetzt**.
- Bei **dynamischen** Neighbor-Groups markiert RouterOS 5 GHz als *preferable*. Wir nutzen die
**statische** Gruppe `bf-main` über beide Bänder; ob die Präferenz dort greift, sagt die Doku
nicht ⇒ **offene Frage**.
**✅ Erledigt 2026-08-30: die Beweislage ist jetzt dauerhaft.** `wireless`- und `caps`-Logs gehen in
den Kollektor (`10.40.20.18:514`, dieselbe Senke wie TP5), und `/system logging` steht seit heute
unter der **27. ComplianceRule `system-logging`** — vorher war der Block unter keiner Regel und im
Template gar nicht vorhanden. Vorher lief alles nur in einen 75-Zeilen-Ringpuffer.
**🔴 NACHTRAG 2026-08-31 — die Daten sind da, und sie kippen die Reihenfolge.** Nach einer Nacht
Logging: **559 `wireless`-Zeilen, 439 davon (79 %) vom iPhone.** Entscheidend ist die Trennschärfe —
`reauthenticating` tritt **93 von 93 Mal bei genau einem Client** auf (dem iPhone) und **93 von 93
Mal auf genau einer SSID** (`dirtyvader-main`). Was `main` von den anderen fünf unterscheidet, ist
**ein einziges Attribut: `ft=yes ft-over-ds=yes`**. Dieselben APs, dieselben Radios, derselbe
Zeitraum — die SSIDs mit `ft=no` zeigen **null** Ereignisse. Das ist eine saubere Kontrollgruppe;
die WPA3+802.11r-Hypothese ist damit **durch eigene Messung gestützt**, nicht mehr Forenwissen.
Verlauf am 31.08.: 07:25 (10×) → 07:34 (21×), eskalierend, teils mehrere pro Sekunde, endend mit
der Flucht auf einen **25 dB schlechteren** AP (`-89 dBm`).
⇒ **`ft-over-ds=no` ist jetzt Hebel 1**, nicht `2g-probe-delay`: Letzteres adressiert
Band-Steering, hier liegt ein **Authentifizierungs**problem vor. Details und Messrezept im
[Handoff vom 2026-08-31](handoff-20260831-wlan-fund-e.md).
**🚫 Kein Funk-Parameter wird geändert, solange keine Daten da sind.** Vier Hebel, **einzeln** und je
als Transaktion (`gc-transaktion.py`). **Reihenfolge korrigiert am 2026-08-31** durch den Befund oben —
die alte Liste führte `2g-probe-delay` an, das aber **Band-Steering** adressiert, während hier ein
**Authentifizierungs**problem gemessen ist:
| # | Hebel | Begründung der Reihenfolge | Risiko | Stand |
|---|---|---|---|---|
| 1 | **`ft-over-ds=no`** auf `sec-main` | nimmt die Änderung vom 08-17 zurück; FT bleibt über `ft=yes` (over-the-air) erhalten. Kleinster Eingriff, der die verdächtige Achse trifft | gering | ❌ **widerlegt** — lief 6,4 h allein → 124× (≈19/h) + 2 Ausfälle, **zurückgebaut 2026-09-01** |
| 2 | `ft=no` auf `sec-main` | falls (1) nicht reicht: FT komplett aus. Kostet Roaming-*Geschwindigkeit*, nicht Roaming | mittel | ✅ **gezogen 2026-09-04** (User-Entscheid, Dauerzustand — Template + bf-golden-config + Nautobot-Sync) |
| 3 | `2g-probe-delay=yes` (2,4-GHz-Configs) | MikroTiks *dafür gebautes* Werkzeug; verzögert nur Probe-Antworten und **kann keinen Client stranden lassen**. Adressiert die **Band-Oszillation** — ein anderes Symptom | sehr gering | ⏹️ entfällt (Fund E geschlossen) |
| 4 | `signal-range`-Boden per `access-list` (Fund E im Original) | **kann Clients aussperren** — genau das war 08-22 das Gegenargument; nur mit gemessenem Wert | hoch | ⏹️ entfällt (Fund E geschlossen) |
> ✅ **Ergebnis 2026-09-02 (Abschlussmessung, [Handoff](handoff-20260902-tp25-fund-e-messung.md)):** Der Sturm ist mit
> `ft-over-ds=yes` **und** 7.24.1 verschwunden — 09-02 nur **4 reauth in 8,6 h** (alle iPhone `F2:62`, isolierte
> Einzel, 0 Ausfälle), Verlauf **218 → 40 → 4**. Vorbedingung geprüft (Core-Uptime 1d18h, `caps joined=0`).
> ⇒ **7.24.1 war die Ursache/der Fix, nicht `ft-over-ds`.** Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen,
> **kein Geräte-Write** (Soll stand: `ft=yes ft-over-ds=yes`, Compliance 140/140). **Am 2026-09-04 hat
> Christoph `ft=no` auf `sec-main` als Dauerzustand entschieden** (Hebel 2 doch gezogen). Der folgende Detailblock zu
> Hebel 1 ist historisch.
#### 🔬 Hebel 1 — gezogen am 2026-08-31 08:09, Messung läuft bis 2026-09-01
**Was steht:** `sec-main` am Core auf **`ft=yes ft-over-ds=no`**. Abnahme **am Manager** über alle
Master-VAPs — `cap-wifi3/4/5/6` (= ap2 + ap3) melden `ft=true ft-over-ds=false`; `cap-wifi1/2` (= ap1)
sind leer, weil ap1 funkstill ist. Danach `remote-cap provision [find]`; alle drei CAPs kamen mit
`state=Ok` zurück, alle VAPs `running=yes` **am CAP gelesen**, und das iPhone hing 71 s später wieder
auf `dirtyvader-main` mit **`auth-type=ft-wpa3-psk`** und **frischer DHCP-Lease** `10.40.10.196` —
also die vorgeschriebene Abnahme (Lease, nicht Registration-Table). 30 Clients insgesamt zurück.
**Governance:** Transaktion `TP25-fund-e-ft-over-ds` (Scope `wifi-security`, Ablauf 2026-09-03),
Geräte-Backup `pre-tp25-funde-ftoverds.backup` auf dem Core, Snapshot
[`docs/transaktionen/TP25-fund-e-ft-over-ds.snapshot.rsc`](transaktionen/TP25-fund-e-ft-over-ds.snapshot.rsc).
Compliance danach **134/135**, und der Diff ist **exakt** `ft-over-ds=yes → no` auf `sec-main` —
kein Kollateral-Drift durch die Re-Provisionierung. Das GC-Template wird **beim Commit** nachgezogen,
nicht vorher: sonst wäre der laufende Versuch unsichtbar und `status` könnte ihn nicht mehr von einem
Versehen unterscheiden.
🔴 **Die `cap-wifiN`-Karte hat sich durch die Re-Provisionierung VERSCHOBEN** — wer die Logs vom
31.08. auswertet, muss den Schnitt kennen, sonst ordnet er Ereignisse dem falschen AP zu:
| | vor 08:09 (Log 07:xx) | ab 08:09 |
|---|---|---|
| ap1 (funkstill) | — | `cap-wifi1` / `cap-wifi2` |
| ap2 | `cap-wifi1` / `cap-wifi2` | `cap-wifi3` / `cap-wifi5` |
| ap3 | `cap-wifi5` / `cap-wifi6` | `cap-wifi4` / `cap-wifi6` |
⚠️ **Korrektur am Befund: `main` unterscheidet sich in ZWEI Attributen, nicht einem.** Neben
`ft=yes ft-over-ds=yes` fährt `sec-main` als einzige SSID **`authentication-types=wpa3-psk` allein**;
die fünf anderen sind `wpa2-psk,wpa3-psk`. Die Kontrollgruppe trennt also FT **und** WPA3-only nicht
voneinander. Das ändert nichts an der Hebelreihenfolge (die Foren-Hypothese nennt genau die
Kombination WPA3+FT), aber die Formulierung „genau ein Attribut" ist zu stark — **wenn Hebel 1 und 2
beide nichts bewegen, ist `wpa2-psk,wpa3-psk` auf `sec-main` der nächste Kandidat, nicht `signal-range`.**
**Messung (Erfolgskriterium): `reauthenticating` für `F2:62:4C:E1:AF:09` geht auf ~0, ohne dass die
anderen SSIDs neue Ereignisse zeigen. Die Zahl MUSS sich bewegen.**
```bash
ssh root@10.40.20.100 "pct exec 400 -- sh -c 'cat /opt/tp5-syslog/log/10.40.20.1/2026-0*-*.log |
grep reauthenticating | grep -oE \"([0-9A-F]{2}:){5}[0-9A-F]{2}\" | sort | uniq -c'"
```
Baseline **93** (alle iPhone, alle `main`); davon **79 am 31.08., alle in Stunde 07**, der letzte um
**07:34:43** — also **vor** dem Eingriff. Zwischen 08:00 und 08:15 kein einziges. Das ist noch **kein
Ergebnis**, nur der saubere Schnitt.
*(Der Defekt war nie gegenstandslos, nur unbelegt: Fund B/C wurden am 08-20 mit ap1 auf `ether15`
gemessen — also in genau der Stellung, in der ap1 heute wieder hängt. Er ist platzierungsunabhängig.)*
**✅ Erledigte Funde — Ergebnis und Merkregel (Volltext im Archiv):**
| Fund | Ergebnis | Merkregel, die bleibt |
|---|---|---|
| **A** 2,4 GHz fuhr 40 MHz statt der profilierten 20 | ✅ 08-17: Inline-`channel.*` auf `wifi1/wifi2` geleert → `2412`/`2462` @ 20 MHz | **Inline-Overrides gewinnen gegen das verlinkte Profil** — die SoT war die ganze Zeit richtig, das Gerät hat sie nicht befolgt |
| **B** ap2 sendete 5 GHz mit **9 statt 18 dBm** | ✅ 08-17: `5220/ax/Ce/I`, **18 dBm** | Der Fix ist gemessen, **der Mechanismus nicht** — beide ersten Erklärungen (ETSI-Band C, „80 MHz kostet generell") sind widerlegt. Resthypothese + diskriminierender Test (`ap2 auf 5180/Ceee`) stehen im Archiv und gehören **vor** einen Hardwarekauf |
| **C** 80 MHz kostete Randabdeckung | ✅ 08-17: `ch-5g` auf `20/40mhz`; ap1 `5180/Ce`, ap2 `5220/Ce`, überlappungsfrei | Unter ETSI gibt es im Indoor-Band **nur einen** 80-MHz-Block — zwei APs können dort nicht beide 80 MHz fahren. Genau deshalb war ap2 je auf 5745 gelandet |
| **D** `w2-iot` trug einen Channel-Override auf einem **Slave**-VAP | ✅ 08-17: Overrides auf beiden APs entfernt, `w2-iot` seither `BR` | `band` ist eine Eigenschaft des **Radios**, nicht eines Slave-VAPs. War der Mechanismus hinter „alle SSIDs da, `-iot` beharrlich nicht" |
| **Drift-Lücke** — genau diese Overrides lagen außerhalb jeder ComplianceRule | ✅ 08-18 mit dem TP28-GC-Umbau: `wifi-*`-Features hängen am Core, drei neue Rules; ein CAP hat keine Inline-Overrides mehr | **„Compliant" heißt nur: die abgedeckten Sektionen sind sauber.** Fund A und D blieben seit 08-08 unentdeckt, während die Flotte durchgehend compliant meldete |
🔴 **Deploy-Gotchas aus derselben Arbeit (kosten sonst zwei Deploys, Volltext im Archiv):**
`/interface/wifi/set <if> !channel` **löscht die Inline-Sub-Properties NICHT** — es wirft nicht, es tut
nichts; richtig ist einzeln `!channel.width !channel.band !channel.skip-dfs-channels`. Und bei
Kanalarbeit **Breite VOR Frequenz** ändern, sonst erzeugt man auf dem Weg eine Co-Channel-Kollision.
⚠️ `steering=`/Channel-Änderungen **re-applien alle VAPs und werfen jeden Client kurz raus** — nicht
während eines Calls deployen, `/system backup` je AP vorher.
🔬 **Messwerkzeug — vier Regeln, jede aus einem Fehlbefund geboren:**
- 🔴 **Für einen Kanal-/Umfeldscan NICHT `list_wifi_interfaces` benutzen** (Fund 2026-08-24): das Tool
gibt `security.passphrase` **aller** Profile im Klartext aus — ein Read für die Kanalbreite hat so
fünf WLAN-Passphrasen in ein Transkript geschrieben, den IoT-PSK inklusive. Für Kanäle reicht
`/interface wifi channel print detail`; für die `cap-wifiN`-Zuordnung `… print where master=yes`
mit Feldauswahl. **Der Scan selbst (`/interface wifi scan`) ist unbedenklich.**
- 🔴 **Die `cap-wifiN`-Nummern sind NICHT stabil.** Der Manager vergibt sie in der Reihenfolge, in der
sich die CAPs anmelden — nach dem ap1-Rückbau sind `cap-wifi1/2` = **ap2** und `cap-wifi3/4` = **ap1**,
umgekehrt zur früher fest verdrahteten Tabelle. ⇒ **Die Zuordnung gehört zum Messlauf**, nicht zur
Auswertungszeit: [`tp25-walk-messung.sh`](tp25-walk-messung.sh) friert sie als `ap-map.txt` ein, die
Auswertung lässt die Namen sonst roh — **unbeschriftet ist ehrlicher als falsch beschriftet.**
**Läufe vor dem 2026-08-22 sind nicht rekonstruierbar**; wer ihre AP-Namen zitiert, prüft sie an der
Physik (der AP im Raum liefert dort das stärkste Signal).
- 🔴 **Ein vom Pin abgewiesener Verbindungsversuch ist in Ping-Daten nicht von einem Funkloch zu
unterscheiden** — und der Manager protokolliert ihn auf `info`-Ebene **gar nicht**. Android quittiert
die Abweisung mit „Verbindung fehlgeschlagen" und **wählt das Netz ab**; danach misst der Rest des
Rundgangs Stille. Die Klick-Konsole schlägt deshalb Alarm, sobald ein **gepinnter** Client nirgends
hängt (`sta_iface=absent` in `sampler-<name>.csv`) — denn „der Gegen-AP trägt hier nicht" **ist das
Messergebnis**.
- 📐 **Eine Runde, N Messpunkte** (seit 2026-09-04): die frei roamenden Clients zeigen, was ein normaler
Client wählt — interessant sind die **gepinnten**. Jeder Client wird **band-genau** auf einen AP
gezwungen ([`tp25-pin-multi.sh`](tp25-pin-multi.sh), `TP25_PINSPEC="name|mac|ap|band;…"`), direkt aus
der Klick-Konsole. Gute Referenz + schlechte Sonde = die Abdeckung steht, die Rückfallebene nicht.
Memory `ref_gegen_ap_messung`. *(Die alte 2-Client-`access-list`-Automatik über `tp25-pin-s22.sh` ist
mit dem Multi-Pin-Umbau entfallen; `tp25-pin-s22.sh` liefert nur noch die `map`.)*
- ▶️ **Start ohne Werkzeugkette: `bash docs/tp25-runde.sh [name]`** — legt den Lauf unter
`docs/messdaten/tp25-runde-<name>/` an, friert die AP-Karte ein, startet Konsole (Port 3031) und
Messung, sagt die **vom Interface gelesene** Adresse fürs Telefon an. In der Konsole: **pinnen**
(N Clients je AP/Band) → **gehen** (🚶/📍) → **Räume antippen** → **stoppen**; beim Beenden löst der
Wrapper den Multi-Pin. Auswertung: `tp25-gehweg-auswertung.py --dir …`.
- ℹ️ **Und das alles passiert TROTZ aktivem 802.11k/v und FT** (`st-main` mit `rrm=yes`/`wnm=yes`,
`ft-over-ds=yes`, live geprüft). Die Merkregel `ref_ft_is_not_roaming` („11k/v fehlen") ist
**historisch**. Bei −48…−55 dBm kostet eine Übergabe 0–2,6 s, meist unter einer halben Sekunde —
**nicht die Übergabe ist teuer, sondern die schwache Zone.**
**User-Randbedingung (2026-08-17, wörtlich): „ich weigere mich einen anderen access point zu kaufen
solange ich nicht das gefühl habe, wir haben alles aus dem ap rausgeholt."** Das ist die
**Abnahmebedingung dieses TPs** — keine Hardware-Empfehlung, bevor die Parameter stimmen. Der Boden ist
gelegt (beide APs 18 dBm, überlappungsfrei, 20 MHz auf 2,4 GHz, ap2 auf 2,5 m); was übrig bleibt, ist
die Redundanzfrage oben.
- **Warum eigener TP:** Christoph hat am 2026-08-16/17 sechs konkrete Beobachtungen gemeldet und
ausdrücklich **einen** TP dafür verlangt; sie hängen alle an derselben Ursache-Klasse (Funkparameter,
die nicht nach Manual gesetzt sind). **TP25 übernimmt den offenen Rest von TP3.3** (Re-Walk,
Dead-Spot-Kartierung — mit der Raumkarte vom 08-22 faktisch geleistet). TP3.2/TP3.3 stehen als Historie.
- **Stand bei Sitzungsende 2026-08-22:** Pin gelöst, Automatik aus, `crs326-core` unverändert und
**transaktionsfrei** — die `access-list` steht unter keiner ComplianceRule, es gab nichts zu committen.
*(Seit 2026-08-23 gibt es die Rule `wifi-access-list`, s. TP22-Nachtrag — Fund E wäre ab jetzt
transaktionsfähig.)*
- 📋 **Übergabe:** [`handoff-tp25-session-20260821.md`](handoff-tp25-session-20260821.md) ·
Messungen: [`tp25-wlan-roaming-messung-20260820.md`](tp25-wlan-roaming-messung-20260820.md),
[`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)
- **Abh.:** TP22 ✅ (GC-Deploy-Pipeline). Verwandt **TP3.2** (`docs/wifi-p750w-coverage-and-map.md`),
**TP29** (nutzt dieselbe Datenquelle als Sensor). **Kein Blocker für irgendetwas.**
#### 🔬 Fund E — 2026-09-01: der `ap2`-Teilbefund (gültig) und eine widerlegte Heilungs-Wertung
**Anlass:** Christoph meldete **2026-08-31 23:02** eine Störung am Schreibtisch (5G, iPhone, kein
Telefonat). Analyse (alles read-only am Manager `crs326-core`):
- **AP-Karte** (stabil seit letztem CAP-Rejoin 15:57, kein Rejoin danach — Attribution belastbar):
`cap-wifi5/6 = ap2`, `cap-wifi3/4 = ap3`, `cap-wifi1/2 = ap1` (funkstill, Radios da, 0 Clients).
- **23:02-Ereignis:** iPhone `F2:62:4C:E1:AF:09` ping-pongt im Sekundentakt `cap-wifi5 ↔ cap-wifi6`
= **ap2s 5G (5220) ↔ ap2s 2,4G (ch11/2462)** — Band-Steering-Thrash auf **demselben** AP, plus
`disconnected, not responding` um 22:37:40 (−76) und **23:01:00** (−82). Feldstärke der Roams
**−75…−88 dBm**.
- 🔴 **Christophs Fakt kippt die Coverage-Deutung:** iPhone und **ap2 nur 2 m** entfernt, gleicher
Raum, ap2 auf 2,3 m. Bei 2 m Sicht wären −35…−45 dBm normal. −80 auf 2 m ist **kein** Coverage-
und **kein** FT-Problem. Dazu: **aktuell 0 Clients auf ap2**, alle 29 auf ap3 — die Clients *meiden*
ap2. ⇒ **ap2s Funk selbst unterperformt** (RF/Antenne/HW) und/oder die **DTU-Störung auf ap2/ch11**
(Transaktion `TP17.1-dtu-pin-ap2`, Wechselrichter `54:F2:9F:12:51:F0` mit −59 dBm auf ap2s 2,4-Radio).
- **In-situ zu messen** (nachts nicht möglich — Phone idle auf ap3): Registration-Signal live prüfen,
während ein Client auf ap2 hängt; Kandidaten-Eingriffe — DTU-Pin lösen/verlegen, `dirtyvader-main`
auf ap2 temporär deaktivieren (ap3 übernimmt den Schreibtisch), ap2-Antenne/HW prüfen. **Keine
Änderung ohne diese Messung.**
🔴 **WIDERRUFEN am 2026-09-01 vormittags — der zweite Teil dieses Abschnitts war ein Messfehler.**
Ursprünglich stand hier: „Fund E ist im sauberen Fenster geheilt — 0× `reauthenticating` seit dem
14:36-Reboot (`sinceMinutes=1440`, total 0), also hat Hebel 1 sein Ziel erreicht."
**Warum das nicht trägt:** `get_log` liest den **flüchtigen Ringpuffer des Routers**, nicht den
Kollektor. Live nachgemessen fasst er **1000 Zeilen und reicht bis 01.09. 00:38:58 zurück** — keine
9 Stunden. Der Core-Reboot um 14:36 hatte ihn geleert; der 31.08. Vormittag war darin gar nicht
enthalten. `total 0` hieß **„im Puffer nichts", nicht „in 24 h nichts"**. Das ist exakt die Falle,
wegen der am 30.08. das persistente Logging gebaut wurde.
**Was der Kollektor zeigt:** im upgrade-freien Fenster 08:09–14:36 (Hebel 1 **allein**) **124×
`reauthenticating`** — 09h=1 · 11h=2 · 12h=20 · 13h=31 · 14h=70, **alle vom iPhone allein**, dazu
Christophs zwei erlebte Ausfälle 13:16 und 14:23. Zwischen 09:00 und 14:36 lag **kein Reboot**.
⇒ Hebel 1 wurde am 2026-09-01 **zurückgebaut**; der Rückbau ist das entscheidende Experiment
(s. Ist-Stand-Block oben).
✅ **Der `ap2`-Teilbefund oben bleibt davon unberührt und gültig** — er ist unabhängig gemessen und
erklärt die 23:02-Störung, die weder FT noch Coverage erklärt.
**Commit-Weg (wartet auf Christophs Go — git-push-Kette):** Template `nautobot/mikrotik_routeros.j2`
**Z. 220** `sec-main` von `ft-over-ds=yes` → `ft-over-ds=no`; dann Template-Repo nach Nautobot syncen,
`gc-configplan.py --refresh-compliance` (Backup→Intended→Compliance), dann
`gc-transaktion.py commit --change-id TP25-fund-e-ft-over-ds`. Danach `wifi-security` wieder driftfrei.
🔴 Kreuzt git-push (Backup-Guard + Commit-Hinweis-Regel) und Live-Nautobot-Jobs — **nicht autonom.**
Handoff: [`handoff-20260901-tp25-ap2-verdacht.md`](handoff-20260901-tp25-ap2-verdacht.md).
---
<a id="tp25-lang"></a>
## TP25 — Langfassung (Messchronik + Funde im Volltext, Stand 2026-08-22)
> ⚠️ **TP25 ist NICHT abgeschlossen.** Der geltende Stand, das Offene (Fund F, die
> 20-MHz-Entscheidung) und die Merkregeln stehen in [`teilprojekte.md` → TP25](teilprojekte.md).
> Hier liegt **wortgleich** die Fassung, die bis zum 2026-08-23 dort stand — 394 Zeilen Herleitung:
> ✅ **TP25 ist ABGESCHLOSSEN (2026-09-15).** Der aktive Stand (Kondensat) steht oben unter
> [TP25](#tp25). Hier liegt **wortgleich** die Fassung, die bis zum 2026-08-23 dort stand — 394 Zeilen Herleitung:
> die Messchronik (A)–(G) mit allen zwischendurch korrigierten Irrtümern, die Funde A–F im Volltext,
> die beantworteten Fragen (Beobachtung 5 „CAPsMAN oder Nautobot GC?", Herkunft der ch149-Notiz,
> `2ghz-g` für IoT, Deckenmontage-APs) und die Umsetzungsreihenfolge mit den Deploy-Gotchas.
@@ -1786,6 +2303,9 @@ gemessen hat. Beim Beheben kam eine zweite, unabhängige Altlast zum Vorschein.
---
### TP34 — Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) ▸ ❌ **VERWORFEN (2026-09-08)**
> ➡️ **Fortsetzung 2026-09-22: `TP34.2 — Interne DNS-Namen als Daten`** (Gitea #73, Prosa in
> `docs/teilprojekte.md` → `### TP34.2`). Dort lebt der *Daten → gerenderte Config*-Gedanke weiter;
> **verworfen bleibt nur das Caddyfile-Rendering** (dieser Abschnitt hier).
> 🔴 **Nachtrag 2026-09-08 (verworfen):** Der Ansatz „Caddyfile via Golden Config rendern" ist
> zurückgezogen (Commits `d63dc7b`, `bca8f6e`; Gitea-Issue #24 → `status:dropped`, closed). Der
> `420.teilzeitladen.xyz`-Block wurde wieder aus dem GC-gerenderten Caddyfile entfernt; **der

View File

@@ -344,12 +344,14 @@ dann Tunnel** (umgekehrt gäbe es 530er).
- 📌 **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.30:8080/api`, und
ö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.30:8080` bzw. intern über Caddy erreichbar.
ö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
@@ -632,12 +634,13 @@ und Letzteres verlangt die **physische Bestätigung am Gerät** — ap1 Technik
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.30:8080` **mit WARP von außerhalb**),
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.31` (`nautobot-worker`), nicht von `.20.30`.
— 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
@@ -974,7 +977,28 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
- **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 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)**
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 5 WIEDER OFFEN (Redesign 2026-09-12): Self-Service deckt die DAU-Requirements nicht ab → Stufe 6 bleibt dahinter gated**
> 🔴 **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
@@ -1078,436 +1102,11 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
- **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 — WLAN-Stabilisierung ▸ ⏳ **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) — **offen bleibt nur: ap2 unterperformt (2 m/−80 dBm, 23:02-Befund) als eigener In-situ-Unterpunkt** · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Offen nur noch die Umsetzung, wenn der dritte AP kommt. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
> 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen
> 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv:
> [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
> Funde A–E im Volltext, die beantworteten Fragen (Beobachtung 5, ch149-Herkunft, `2ghz-g`,
> Deckenmontage), Umsetzungsreihenfolge mit allen Deploy-Gotchas. Hier steht: **Stand, Offenes,
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
> *warum* etwas so entschieden wurde, liest die Langfassung.
### TP25 — WLAN-Stabilisierung ▸ ✅ **FERTIG (2026-09-15)** → [Archiv](teilprojekte-archiv.md#tp25)
**Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken
([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust /
20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
**Anheben von ap2 auf 2,5 m**; 1,0 s liegt unter der Hörbarkeitsschwelle im Call. Der Dead Spot, der
TP25 seit dem 08-20 getragen hat, ist damit weg.
🔎 **Kernbefund, der bleibt: das Netz ist gut, die Redundanz ist es nicht.** Die frei roamende Referenz
hat in **17 von 21** Raumabschnitten 0 % Verlust; der Gegen-AP liefert Schlafzimmer −84,6/38 %,
Bad −82,3/24 %, Küche −82,0/23 %, Terrasse −78,7/19 %. **Fällt ein AP aus, ist die halbe Wohnung
„verbunden, aber tot".** ⇒ Ein **dritter AP ist nach Christophs eigener Randbedingung derzeit NICHT
begründet** — er kaufte Redundanz, keine Abdeckung. Das ist eine andere Frage und Christophs.
**Beide vormals offenen Punkte sind am 2026-08-24 abgearbeitet** — Fund F ist aufgelöst, der
5-GHz-Umfeldscan gefahren und der Kanalplan für einen dritten AP gerechnet. **Was von TP25 bleibt,
ist Umsetzung bei Bedarf, keine offene Frage:**
1. ✅ **Fund F — GEMESSEN UND AUFGELÖST (2026-08-24): kein netzseitiges Phänomen.**
Kollektor am **Manager** (`/interface wifi registration-table`, 15-s-Takt, 29 Samples über 7,7 min,
28 Clients), Rohdaten `docs/messdaten/tp25-fundf-20260824.log`. Von den drei Kandidaten sind **zwei
erledigt, einer bleibt** — und die zwei fielen ohne Wartezeit:
| Kandidat | Ergebnis | woran es hängt |
|---|---|---|
| **(b)** DHCP-`lease-time` gegen die Periode | ❌ **tot** | `dhcp-v30` fährt **`lease-time=1d`** → Renew bei 12 h. Kann 5:05 nicht erzeugen. **Eine Abfrage, keine Messung.** |
| **(a)** periodischer VAP-Re-Apply | ❌ **tot** | Ein Re-Apply wirft **alle** Clients des VAP gleichzeitig. Auf `cap-wifi4-virtual1` standen zeitgleich **drei Clients bei 16 h 51 min** Uptime, 25 von 28 über 6 h. Mit einem 5-Minuten-Takt ist das unvereinbar. |
| **(c)** das Gerät selbst | 🟡 **bleibt** | Der einzige Client, der im Fenster überhaupt neu verband. |
🔑 **Der entscheidende Punkt an der Beweisführung: dafür braucht es die vier Zyklen nicht.** Ein
einziges Sample mit **16 h 51 min Uptime auf genau dem VAP**, der angeblich alle 5:05 abwirft,
widerlegt (a) vollständig. Die Fensterlänge begrenzt nur, was man über *abwesende* Geräte sagen kann.
🔴 **Und genau die sind abwesend: die beiden Fund-F-MACs gibt es gerade nicht.**
`38:A5:C9:C0:73:1E` (`10.40.30.178`) und `38:A5:C9:BC:33:31` (`10.40.30.179`) stehen in `/ip arp` auf
**`status=failed`**, haben **keine DHCP-Lease**, stehen **nicht** in der Registration-Table und sind
**auch HA unbekannt** (kein `device_tracker` zu diesen MACs). ⇒ **Das Phänomen ist an diesen Geräten
derzeit nicht reproduzierbar, weil die Geräte weg sind.**
📌 **Merkregel — dieselbe wie beim P750W:** bevor ein periodisches Trennen als Funk- oder
Konfigurationsfehler untersucht wird, **erst prüfen, ob die betroffenen Geräte überhaupt da sind.**
Beim Drucker war die Fehldiagnose „Power-Save", hier wäre sie „VAP-Re-Apply" geworden.
⇒ **Fund F wird geschlossen:** was auch immer der 5:05-Takt war, er lag **geräteseitig**, und die zwei
betroffenen Geräte sind vom Netz. Netzseitig ist nichts zu tun. **Nicht erneut aufgreifen**, solange
`.30.178`/`.179` nicht wieder da sind *und* das Muster erneut gemessen wird.
🟡 **Nebenbefund mit Substanz — der DTU ist der einzige Wackelkandidat, den es wirklich gibt.**
`54:F2:9F:12:51:F0` (DTU/Wechselrichter, auf `cap-wifi4-virtual1` = **ap1**) war der **einzige** Client
mit einem Uptime-Rücksetzer: 43 min Sitzung, dann weg für ein Sample, dann wieder da — **bei −52 dBm**,
also bei tadellosem Signal. Das ist derselbe Mechanismus, der schon die Transaktion
`TP17.1-dtu-pin-ap2` begründet hat: **ein ESP hat EIN Radio, sein eigener AP und die STA-Verbindung
teilen sich den Kanal.** Periode hier 43 min, nicht 5:05 — es ist **nicht** Fund F, aber es ist echt.
⇒ Gehört zur ohnehin vorgesehenen Neubewertung des DTU-Pins, sobald der Kanalplan steht (s.o.).
<details><summary>📜 Ursprüngliche Fund-F-Formulierung (2026-08-22)</summary>
1. 🟡 **Fund F — IoT-Clients trennen exakt alle ~5:05 bei gutem Signal** (−59 dBm, vier Messpaare,
auf 3 s reproduzierbar → **ein Timer, kein Funk**). Muss **am Manager** nachgemessen werden, die
AP-Tabellen sind unter CAPsMAN leer. Kandidaten und der Test, der sie trennt:
(a) periodischer VAP-Re-Apply (Fund D war die Ursache → prüfen, ob es nach dem Fix weg ist);
(b) DHCP-`lease-time` der beiden MACs (`38:A5:C9:C0:73:1E`, `38:A5:C9:BC:33:31`) gegen die Periode legen;
(c) das Gerät selbst mit 5-min-Duty-Cycle → dann bleibt es unverändert.
🚫 **Nicht die alte Power-Save-Merkregel wiederbeleben** — die ist am 2026-08-13 als Fehldiagnose
zurückgezogen (`ref_wifi_powersave_multicast`).
</details>
2. ⚖️ **Entscheidung 5 GHz auf 20 MHz** (statt heute 20/40) — **✅ 5-GHz-Umfeldscan durchgeführt
2026-08-24, Empfehlung: NICHT verschmälern, Entscheidung an einen Auslöser hängen.**
⚠️ **2,4 GHz NICHT vorschnell drosseln** — dort hängen ~20 IoT-Geräte.
**Messung** (`/interface wifi scan` **am Manager**, cap-wifi3 = ap1 / cap-wifi1 = ap2, 10–12 s):
| vom Radio auf | fremdes 5-GHz-Netz | Kanal | Signal | Abstand zum eigenen AP |
|---|---|---|---|---|
| **ap1** (5180/Ce) | `Drei_H288A_5G_acHG` | 5745/ac/**80 MHz** | **−86 dBm** | 19 dB darunter |
| **ap2** (5220/Ce) | `LiwestBB93-5G` | 5200/ac/**80 MHz** (Block 5180–5240) | **−89 dBm** | 24 dB darunter |
⇒ **Das 5-GHz-Band ist an beiden Standorten praktisch leer.** Genau **ein** Fremdnetz je AP, beide
rund 20 dB unter dem eigenen Pegel. Der Nachbar `LiwestBB93-5G` liegt mit 80 MHz zwar formal über
**beiden** unserer Blöcke — bei −89 dBm ist das ohne Wirkung.
🎯 **Warum das gegen 20 MHz spricht — die zwei üblichen Gründe treffen hier nicht zu:**
- **Fremdinterferenz:** existiert nicht (−86/−89). Ein schmalerer Kanal hätte nichts abzuwehren.
- **Selbstüberlappung:** existiert nicht. ap1 `5180/Ce` = ch36+40, ap2 `5220/Ce` = ch44+48 —
überlappungsfrei (Fund C, 08-17).
- **Bleibt allein das +3 dB Link-Budget** — und das ist zum guten Teil **schon vorhanden**: in einem
40-MHz-BSS sendet die Ratenanpassung an schwache Clients ohnehin auf dem Primärkanal in 20 MHz.
Den Ceiling zahlen aber **alle** Clients dauerhaft. **Der Nutzen geht an die, die ihn schon haben.**
- **Und es löst das gemessene Problem nicht:** die offenen Schwachstellen sind die
Redundanz-Lücken (Schlafzimmer −84,6 / 38 %, Bad −82,3 / 24 %) **am Gegen-AP**. +3 dB macht aus
38 % Verlust keine 0 % — das ist eine Platzierungs-/AP-Zahl-Frage, keine Kanalbreiten-Frage.
🔑 **Der echte Auslöser ist die AP-Zahl, nicht die Interferenz** — und das ist der Grund, die
Entscheidung *jetzt* zu treffen statt sie offenzulassen. Beide Kanäle stehen hart in **Band A**
(`ch-5g-ap1`=5180, `ch-5g-ap2`=5220, kein `skip-dfs-channels`), und Band A hat unter ETSI genau
**4 × 20 MHz = 2 × 40 MHz**. Mit zwei APs ist es damit **exakt aufgebraucht**:
| Szenario | bei 40 MHz | bei 20 MHz |
|---|---|---|
| 2 APs (heute) | ✅ passt genau, überlappungsfrei | ✅ passt, +3 dB, halber Ceiling |
| **3.–4. AP** | ❌ Band A voll ⇒ **nur mit DFS** (Band B ch52–64: Radar-Events, 60 s CAC) | ✅ 4 saubere Kanäle, **kein DFS** |
⇒ **Empfehlung: 20/40 MHz beibehalten.** Die Verschmälerung ist heute ein Preis ohne Gegenwert.
Sie wird **fällig, sobald ein dritter AP dazukommt** — dann lautet die Wahl nicht mehr „20 gegen 40",
sondern **„20 MHz flottenweit" gegen „DFS akzeptieren"**, und dafür ist 20 MHz die ruhigere Antwort.
Ein Zwischenweg existiert (ap1 behält 40 MHz, AP 2+3 fahren 20 MHz auf 5220/5240) und braucht
ebenfalls kein DFS.
📌 **Damit ist dieser Punkt entscheidungsreif und an einen Auslöser gebunden statt offen.**
#### 📐 Kanalplan für den dritten AP ▸ **gerechnet 2026-08-24, DFS vom User genehmigt**
> **User-Vorgaben (2026-08-24):** 20/40 MHz bleibt · **DFS ist genehmigt**, soll aber auf **ap1 oder ap2**
> liegen, nicht auf dem Neuen · der Neue ist ein **MikroTik-Deckenstrahler mit ax** · **möglicherweise
> kommt er an die Stelle eines der bisherigen APs** · der vorhandene Outdoor-AP bleibt konzeptionell außen vor.
**Das Kanalbudget unter ETSI (Indoor), als Rechnung statt als Gefühl.** Ein 40-MHz-Block darf keine
Bandgrenze überschreiten — `[48+52]` ist deshalb unmöglich, und daran hängt die ganze Knappheit:
| Band | Frequenzen | Kanäle | 40-MHz-Blöcke | DFS | EIRP |
|---|---|---|---|---|---|
| **A** | 5150–5250 | 36/40/44/48 | **2** (`5180/Ce`, `5220/Ce`) | nein | 23 dBm |
| **B** | 5250–5350 | 52/56/60/64 | **2** (`5260/Ce`, `5300/Ce`) | **ja** + TPC | 23 dBm |
| **C** | 5470–5725 | 100–140 | **5** | **ja** + TPC | 30 dBm, outdoor erlaubt |
Heute belegen zwei APs mit 40 MHz **ganz Band A**. Genau das macht den dritten AP zur Bandfrage — und
mit genehmigtem DFS ist sie lösbar, ohne irgendwo auf 20 MHz zu gehen.
**Empfohlene Zuordnung (3 APs, alle 40 MHz):**
| AP | Rolle | Kanal 5 GHz | Band | DFS | 2,4 GHz |
|---|---|---|---|---|---|
| **cAP ax (neu)** | Primär, beste Position | **`5180/Ce`** (36+40) | A | **nein** | 2437 (ch6) |
| **ap1** hAP ax² | Sekundär | **`5260/Ce`** (52+56) | B | ja | 2412 (ch1) |
| **ap2** hAP ax² | Sekundär | **`5300/Ce`** (60+64) | B | ja | 2462 (ch11) |
| — | **Ausweichspur, frei halten** | `5220/Ce` (44+48) | A | — | — |
- **Warum Band B und nicht Band C für die zwei DFS-Radios:** gleicher 23-dBm-Deckel wie Band A (kein
Leistungssprung zwischen den APs) und **CAC 60 s statt 600 s** — die 10-Minuten-Prüfung gilt im
Wetterradar-Bereich von Band C. Band C bleibt damit frei für den Outdoor-AP, wo seine 30 dBm auch
hingehören.
- **Warum der Neue das nicht-DFS-Band bekommt:** DFS kostet **60 s CAC bei jedem Boot und jeder
Provisionierung** plus einen erzwungenen Kanalwechsel bei Radar-Erkennung. Diese Kosten gehören auf
die Radios, deren Fläche von einem anderen AP mitgetragen wird — nicht auf den bestplatzierten.
Christophs Instinkt ist hier richtig.
- **2,4 GHz fällt nebenbei sauber:** ch1/6/11 ist mit drei APs erstmals vollständig überlappungsfrei
möglich (heute 1/11 mit einer Lücke in der Mitte).
🔴 **Zwei Fallen, ohne die der Plan teurer wird als er aussieht:**
1. **Eine hart gepinnte Frequenz macht DFS zum Single Point of Failure.** `ch-5g-ap1`/`-ap2` tragen heute
**je genau einen** Wert (`frequency=5180` bzw. `5220`). Bei einer Radar-Erkennung **muss** das Radio
den Kanal verlassen — hat es keinen zweiten konfiguriert, bleibt das 5-GHz-Funk dieses APs **unten**.
⇒ Für DFS gehört in das Channel-Objekt eine **Liste** (z.B. `frequency=5260,5280,5220`), damit der AP
neu wählen kann. Das ist der Unterschied zwischen „DFS ist unkritisch" und „DFS kostet einen AP".
2. **Vor dem Umzug die *erreichte* Sendeleistung messen — das ist Fund B, nochmal.** Band B/C verlangen
TPC; ohne TPC sinkt der erlaubte EIRP um 3 dB. TP25 Fund B war exakt dieses Muster: **18 dBm gesetzt,
9 dBm gesendet**, weil ch149 unter ETSI leistungsbegrenzt ist. ⇒ **Diskriminierender Test, bevor
irgendwas gekauft oder umgezogen wird:** ap2 testweise auf `5260`, dann `/interface wifi monitor`
lesen und die tatsächliche `tx-power` gegen die heutigen 18 dBm auf `5220` stellen. Kostet eine
Config-Transaktion und zehn Minuten, und entscheidet, ob der Plan trägt.
⚖️ **Zur Platzierung — die Messung widerspricht dem Aufstellen an einer bisherigen Stelle.**
Die Referenzmessung vom 08-22 sagt: **17 von 21 Raumabschnitten haben 0 % Verlust.** Zusätzliche
*Abdeckung* ist damit wertlos. Der ganze Wert liegt im **Gegen-AP**: Schlafzimmer −84,6 / 38 %,
Bad −82,3 / 24 %, Küche −82,0 / 23 %.
| Variante | was sie bringt |
|---|---|
| Neuer AP an **bisheriger** Stelle | besseres Deckenbild an einem Punkt, **der schon funktioniert**. Die Drei-Zimmer-Lücke bleibt **unverändert** — gleicher Standort, gleicher Schatten. |
| Neuer AP an **neuer** Stelle (Richtung Schlafzimmer/Bad/Küche) | schließt die Lücke, also genau die Redundanz, die gekauft werden soll. |
| 🎯 **Beides: cAP ax an die bisherige Stelle, der verdrängte hAP ax² wandert in den Schatten** | bestes Deckenbild dort, wo die Clients sind, **und** eine zweite Option in den drei Räumen. Braucht keine vierte Antenne, nur eine Steckdose/PoE im Zielraum. |
⇒ **Empfehlung: Variante 3.** Sie ist der einzige Weg, bei dem der Deckenstrahler an den guten Platz
darf, *ohne* dass die gemessene Schwäche unangetastet bleibt.
- **Voraussetzung Neuer AP:** PoE-in am Zielort (die cAP-ax-Reihe hat einen Gigabit-PoE-Port, kein
Netzteil im Lieferumfang üblich) und ein Trunk mit VLAN 10/30/40/50/90/99 wie bei ap1/ap2. Der AP
läuft auf `wifi-qcom` und tritt damit als **regulärer CAP** dem Manager bei — kein neues Modell im GC,
nur ein weiteres Gerät plus zwei `channel`-Objekte. ⚠️ **Exaktes Modell vor dem Kauf prüfen**
(`cAP ax` vs. `cAP XL ax`) — beide sind ax/wifi-qcom, unterscheiden sich in Abdeckung und Preis.
- **Wenn später doch der Outdoor-AP dazukommt:** er gehört nach **Band C** (30 dBm, outdoor zulässig);
Band A/B sind dann von den drei Indoor-APs belegt und die Ausweichspur `5220/Ce` ist aufgebraucht.
#### 🔴 Fund E — **WIEDER GEÖFFNET 2026-08-30: die Wiedervorlage-Bedingung ist eingetreten**
Fund E war „abgestuft, NICHT umsetzen" mit einer ausdrücklichen Bedingung: *„Erst wieder
aufgreifen, wenn ein Sticky-Fall erneut gemessen ist."* Am **2026-08-30** ist genau das passiert.
**Auslöser war eine unabhängige User-Meldung**, nicht die Suche danach: *„die letzten Tage/Wochen
regelmäßig mit dem iPhone den WiFi-Link verloren obwohl bester Kontakt. Ich lande auf 5G und nicht
am alten AP oder dem anderen."* 🔑 **Auf Mobilfunk zu fallen ist kein Roaming-Problem** — bei
schlechtem Roaming bliebe das Telefon im WLAN und wäre nur langsam. Es heißt: die Assoziation ist
gestorben und die Neuanmeldung gescheitert.
**Neue Messung (Manager-Log, 2026-08-30):** dasselbe iPhone `F2:62:4C:E1:AF:09` machte **16
Bandwechsel in 20 Minuten** zwischen `cap-wifi5` (ap3, 5 GHz) und `cap-wifi6` (ap3, 2,4 GHz),
zweimal im Abstand von **einer Sekunde**, bei **−53 … −81 dBm**. Kurz darauf dasselbe zwischen
`cap-wifi1`↔`cap-wifi2` — also **an ap2**. ⇒ **Nicht ap3-spezifisch, sondern an jedem AP.**
Das deckt sich mit der Ursprungsmessung vom 08-20 (sechs Flaps in 22 Minuten) und mit
`…@wifi2 disconnected, not responding, signal strength −47` — **Abbruch bei ausgezeichnetem Signal**.
⚠️ **Zwei Symptome, die NICHT gleichgesetzt werden dürfen:** die Band-Oszillation (beide Male
gemessen) und der Abbruch *„not responding"* bei gutem Signal. Christophs Beschreibung passt auf das
**zweite**. Ob dieselbe Ursache dahintersteht, ist **offen**.
**Zeitachse:** am **2026-08-17** liefen TP28 (CAPsMAN-Cutover) und TP27 (7.24) gleichzeitig. Vorher
waren ap1/ap2 Standalone **ohne Steering und ohne Neighbor-Group**, und `sec-main` fuhr
`ft=yes ft-over-ds=`**`no`**. Beides änderte sich an diesem Tag. Das Beschwerdefenster deckt sich —
**Korrelation, kein Beweis.**
**Was die MikroTik-Doku dazu hergibt (recherchiert 2026-08-30):**
- `transition-threshold` Default **−80 dBm**, `-time` **10 s**, `-request-period` **30 s**,
`-count` **3**. Unsere Steering-Objekte setzen **nichts** davon. Bei −53…−74 dBm wird das iPhone
also kaum Transition-Kandidat ⇒ **unsolicited BTM scheidet als Treiber weitgehend aus.**
- **WPA3 + 802.11r:** die Doku nennt **nur** „802.11r geht nicht über **OWE**". Eine
WPA3-PSK/FT-Inkompatibilität steht dort **nicht** — sie kursiert in Foren. **Hypothese.**
- **`2g-probe-delay`**: dokumentiertes Band-Steering (2,4-GHz-AP ignoriert die ersten 3
Probe-Requests je Client pro 60 s). Bei uns **nicht gesetzt**.
- Bei **dynamischen** Neighbor-Groups markiert RouterOS 5 GHz als *preferable*. Wir nutzen die
**statische** Gruppe `bf-main` über beide Bänder; ob die Präferenz dort greift, sagt die Doku
nicht ⇒ **offene Frage**.
**✅ Erledigt 2026-08-30: die Beweislage ist jetzt dauerhaft.** `wireless`- und `caps`-Logs gehen in
den Kollektor (`10.40.20.18:514`, dieselbe Senke wie TP5), und `/system logging` steht seit heute
unter der **27. ComplianceRule `system-logging`** — vorher war der Block unter keiner Regel und im
Template gar nicht vorhanden. Vorher lief alles nur in einen 75-Zeilen-Ringpuffer.
**🔴 NACHTRAG 2026-08-31 — die Daten sind da, und sie kippen die Reihenfolge.** Nach einer Nacht
Logging: **559 `wireless`-Zeilen, 439 davon (79 %) vom iPhone.** Entscheidend ist die Trennschärfe —
`reauthenticating` tritt **93 von 93 Mal bei genau einem Client** auf (dem iPhone) und **93 von 93
Mal auf genau einer SSID** (`dirtyvader-main`). Was `main` von den anderen fünf unterscheidet, ist
**ein einziges Attribut: `ft=yes ft-over-ds=yes`**. Dieselben APs, dieselben Radios, derselbe
Zeitraum — die SSIDs mit `ft=no` zeigen **null** Ereignisse. Das ist eine saubere Kontrollgruppe;
die WPA3+802.11r-Hypothese ist damit **durch eigene Messung gestützt**, nicht mehr Forenwissen.
Verlauf am 31.08.: 07:25 (10×) → 07:34 (21×), eskalierend, teils mehrere pro Sekunde, endend mit
der Flucht auf einen **25 dB schlechteren** AP (`-89 dBm`).
⇒ **`ft-over-ds=no` ist jetzt Hebel 1**, nicht `2g-probe-delay`: Letzteres adressiert
Band-Steering, hier liegt ein **Authentifizierungs**problem vor. Details und Messrezept im
[Handoff vom 2026-08-31](handoff-20260831-wlan-fund-e.md).
**🚫 Kein Funk-Parameter wird geändert, solange keine Daten da sind.** Vier Hebel, **einzeln** und je
als Transaktion (`gc-transaktion.py`). **Reihenfolge korrigiert am 2026-08-31** durch den Befund oben —
die alte Liste führte `2g-probe-delay` an, das aber **Band-Steering** adressiert, während hier ein
**Authentifizierungs**problem gemessen ist:
| # | Hebel | Begründung der Reihenfolge | Risiko | Stand |
|---|---|---|---|---|
| 1 | **`ft-over-ds=no`** auf `sec-main` | nimmt die Änderung vom 08-17 zurück; FT bleibt über `ft=yes` (over-the-air) erhalten. Kleinster Eingriff, der die verdächtige Achse trifft | gering | ❌ **widerlegt** — lief 6,4 h allein → 124× (≈19/h) + 2 Ausfälle, **zurückgebaut 2026-09-01** |
| 2 | `ft=no` auf `sec-main` | falls (1) nicht reicht: FT komplett aus. Kostet Roaming-*Geschwindigkeit*, nicht Roaming | mittel | ✅ **gezogen 2026-09-04** (User-Entscheid, Dauerzustand — Template + bf-golden-config + Nautobot-Sync) |
| 3 | `2g-probe-delay=yes` (2,4-GHz-Configs) | MikroTiks *dafür gebautes* Werkzeug; verzögert nur Probe-Antworten und **kann keinen Client stranden lassen**. Adressiert die **Band-Oszillation** — ein anderes Symptom | sehr gering | ⏹️ entfällt (Fund E geschlossen) |
| 4 | `signal-range`-Boden per `access-list` (Fund E im Original) | **kann Clients aussperren** — genau das war 08-22 das Gegenargument; nur mit gemessenem Wert | hoch | ⏹️ entfällt (Fund E geschlossen) |
> ✅ **Ergebnis 2026-09-02 (Abschlussmessung, [Handoff](handoff-20260902-tp25-fund-e-messung.md)):** Der Sturm ist mit
> `ft-over-ds=yes` **und** 7.24.1 verschwunden — 09-02 nur **4 reauth in 8,6 h** (alle iPhone `F2:62`, isolierte
> Einzel, 0 Ausfälle), Verlauf **218 → 40 → 4**. Vorbedingung geprüft (Core-Uptime 1d18h, `caps joined=0`).
> ⇒ **7.24.1 war die Ursache/der Fix, nicht `ft-over-ds`.** Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen,
> **kein Geräte-Write** (Soll stand: `ft=yes ft-over-ds=yes`, Compliance 140/140). **Am 2026-09-04 hat
> Christoph `ft=no` auf `sec-main` als Dauerzustand entschieden** (Hebel 2 doch gezogen). Der folgende Detailblock zu
> Hebel 1 ist historisch.
#### 🔬 Hebel 1 — gezogen am 2026-08-31 08:09, Messung läuft bis 2026-09-01
**Was steht:** `sec-main` am Core auf **`ft=yes ft-over-ds=no`**. Abnahme **am Manager** über alle
Master-VAPs — `cap-wifi3/4/5/6` (= ap2 + ap3) melden `ft=true ft-over-ds=false`; `cap-wifi1/2` (= ap1)
sind leer, weil ap1 funkstill ist. Danach `remote-cap provision [find]`; alle drei CAPs kamen mit
`state=Ok` zurück, alle VAPs `running=yes` **am CAP gelesen**, und das iPhone hing 71 s später wieder
auf `dirtyvader-main` mit **`auth-type=ft-wpa3-psk`** und **frischer DHCP-Lease** `10.40.10.196` —
also die vorgeschriebene Abnahme (Lease, nicht Registration-Table). 30 Clients insgesamt zurück.
**Governance:** Transaktion `TP25-fund-e-ft-over-ds` (Scope `wifi-security`, Ablauf 2026-09-03),
Geräte-Backup `pre-tp25-funde-ftoverds.backup` auf dem Core, Snapshot
[`docs/transaktionen/TP25-fund-e-ft-over-ds.snapshot.rsc`](transaktionen/TP25-fund-e-ft-over-ds.snapshot.rsc).
Compliance danach **134/135**, und der Diff ist **exakt** `ft-over-ds=yes → no` auf `sec-main` —
kein Kollateral-Drift durch die Re-Provisionierung. Das GC-Template wird **beim Commit** nachgezogen,
nicht vorher: sonst wäre der laufende Versuch unsichtbar und `status` könnte ihn nicht mehr von einem
Versehen unterscheiden.
🔴 **Die `cap-wifiN`-Karte hat sich durch die Re-Provisionierung VERSCHOBEN** — wer die Logs vom
31.08. auswertet, muss den Schnitt kennen, sonst ordnet er Ereignisse dem falschen AP zu:
| | vor 08:09 (Log 07:xx) | ab 08:09 |
|---|---|---|
| ap1 (funkstill) | — | `cap-wifi1` / `cap-wifi2` |
| ap2 | `cap-wifi1` / `cap-wifi2` | `cap-wifi3` / `cap-wifi5` |
| ap3 | `cap-wifi5` / `cap-wifi6` | `cap-wifi4` / `cap-wifi6` |
⚠️ **Korrektur am Befund: `main` unterscheidet sich in ZWEI Attributen, nicht einem.** Neben
`ft=yes ft-over-ds=yes` fährt `sec-main` als einzige SSID **`authentication-types=wpa3-psk` allein**;
die fünf anderen sind `wpa2-psk,wpa3-psk`. Die Kontrollgruppe trennt also FT **und** WPA3-only nicht
voneinander. Das ändert nichts an der Hebelreihenfolge (die Foren-Hypothese nennt genau die
Kombination WPA3+FT), aber die Formulierung „genau ein Attribut" ist zu stark — **wenn Hebel 1 und 2
beide nichts bewegen, ist `wpa2-psk,wpa3-psk` auf `sec-main` der nächste Kandidat, nicht `signal-range`.**
**Messung (Erfolgskriterium): `reauthenticating` für `F2:62:4C:E1:AF:09` geht auf ~0, ohne dass die
anderen SSIDs neue Ereignisse zeigen. Die Zahl MUSS sich bewegen.**
```bash
ssh root@10.40.20.100 "pct exec 400 -- sh -c 'cat /opt/tp5-syslog/log/10.40.20.1/2026-0*-*.log |
grep reauthenticating | grep -oE \"([0-9A-F]{2}:){5}[0-9A-F]{2}\" | sort | uniq -c'"
```
Baseline **93** (alle iPhone, alle `main`); davon **79 am 31.08., alle in Stunde 07**, der letzte um
**07:34:43** — also **vor** dem Eingriff. Zwischen 08:00 und 08:15 kein einziges. Das ist noch **kein
Ergebnis**, nur der saubere Schnitt.
*(Der Defekt war nie gegenstandslos, nur unbelegt: Fund B/C wurden am 08-20 mit ap1 auf `ether15`
gemessen — also in genau der Stellung, in der ap1 heute wieder hängt. Er ist platzierungsunabhängig.)*
**✅ Erledigte Funde — Ergebnis und Merkregel (Volltext im Archiv):**
| Fund | Ergebnis | Merkregel, die bleibt |
|---|---|---|
| **A** 2,4 GHz fuhr 40 MHz statt der profilierten 20 | ✅ 08-17: Inline-`channel.*` auf `wifi1/wifi2` geleert → `2412`/`2462` @ 20 MHz | **Inline-Overrides gewinnen gegen das verlinkte Profil** — die SoT war die ganze Zeit richtig, das Gerät hat sie nicht befolgt |
| **B** ap2 sendete 5 GHz mit **9 statt 18 dBm** | ✅ 08-17: `5220/ax/Ce/I`, **18 dBm** | Der Fix ist gemessen, **der Mechanismus nicht** — beide ersten Erklärungen (ETSI-Band C, „80 MHz kostet generell") sind widerlegt. Resthypothese + diskriminierender Test (`ap2 auf 5180/Ceee`) stehen im Archiv und gehören **vor** einen Hardwarekauf |
| **C** 80 MHz kostete Randabdeckung | ✅ 08-17: `ch-5g` auf `20/40mhz`; ap1 `5180/Ce`, ap2 `5220/Ce`, überlappungsfrei | Unter ETSI gibt es im Indoor-Band **nur einen** 80-MHz-Block — zwei APs können dort nicht beide 80 MHz fahren. Genau deshalb war ap2 je auf 5745 gelandet |
| **D** `w2-iot` trug einen Channel-Override auf einem **Slave**-VAP | ✅ 08-17: Overrides auf beiden APs entfernt, `w2-iot` seither `BR` | `band` ist eine Eigenschaft des **Radios**, nicht eines Slave-VAPs. War der Mechanismus hinter „alle SSIDs da, `-iot` beharrlich nicht" |
| **Drift-Lücke** — genau diese Overrides lagen außerhalb jeder ComplianceRule | ✅ 08-18 mit dem TP28-GC-Umbau: `wifi-*`-Features hängen am Core, drei neue Rules; ein CAP hat keine Inline-Overrides mehr | **„Compliant" heißt nur: die abgedeckten Sektionen sind sauber.** Fund A und D blieben seit 08-08 unentdeckt, während die Flotte durchgehend compliant meldete |
🔴 **Deploy-Gotchas aus derselben Arbeit (kosten sonst zwei Deploys, Volltext im Archiv):**
`/interface/wifi/set <if> !channel` **löscht die Inline-Sub-Properties NICHT** — es wirft nicht, es tut
nichts; richtig ist einzeln `!channel.width !channel.band !channel.skip-dfs-channels`. Und bei
Kanalarbeit **Breite VOR Frequenz** ändern, sonst erzeugt man auf dem Weg eine Co-Channel-Kollision.
⚠️ `steering=`/Channel-Änderungen **re-applien alle VAPs und werfen jeden Client kurz raus** — nicht
während eines Calls deployen, `/system backup` je AP vorher.
🔬 **Messwerkzeug — vier Regeln, jede aus einem Fehlbefund geboren:**
- 🔴 **Für einen Kanal-/Umfeldscan NICHT `list_wifi_interfaces` benutzen** (Fund 2026-08-24): das Tool
gibt `security.passphrase` **aller** Profile im Klartext aus — ein Read für die Kanalbreite hat so
fünf WLAN-Passphrasen in ein Transkript geschrieben, den IoT-PSK inklusive. Für Kanäle reicht
`/interface wifi channel print detail`; für die `cap-wifiN`-Zuordnung `… print where master=yes`
mit Feldauswahl. **Der Scan selbst (`/interface wifi scan`) ist unbedenklich.**
- 🔴 **Die `cap-wifiN`-Nummern sind NICHT stabil.** Der Manager vergibt sie in der Reihenfolge, in der
sich die CAPs anmelden — nach dem ap1-Rückbau sind `cap-wifi1/2` = **ap2** und `cap-wifi3/4` = **ap1**,
umgekehrt zur früher fest verdrahteten Tabelle. ⇒ **Die Zuordnung gehört zum Messlauf**, nicht zur
Auswertungszeit: [`tp25-walk-messung.sh`](tp25-walk-messung.sh) friert sie als `ap-map.txt` ein, die
Auswertung lässt die Namen sonst roh — **unbeschriftet ist ehrlicher als falsch beschriftet.**
**Läufe vor dem 2026-08-22 sind nicht rekonstruierbar**; wer ihre AP-Namen zitiert, prüft sie an der
Physik (der AP im Raum liefert dort das stärkste Signal).
- 🔴 **Ein vom Pin abgewiesener Verbindungsversuch ist in Ping-Daten nicht von einem Funkloch zu
unterscheiden** — und der Manager protokolliert ihn auf `info`-Ebene **gar nicht**. Android quittiert
die Abweisung mit „Verbindung fehlgeschlagen" und **wählt das Netz ab**; danach misst der Rest des
Rundgangs Stille. Die Klick-Konsole schlägt deshalb Alarm, sobald ein **gepinnter** Client nirgends
hängt (`sta_iface=absent` in `sampler-<name>.csv`) — denn „der Gegen-AP trägt hier nicht" **ist das
Messergebnis**.
- 📐 **Eine Runde, N Messpunkte** (seit 2026-09-04): die frei roamenden Clients zeigen, was ein normaler
Client wählt — interessant sind die **gepinnten**. Jeder Client wird **band-genau** auf einen AP
gezwungen ([`tp25-pin-multi.sh`](tp25-pin-multi.sh), `TP25_PINSPEC="name|mac|ap|band;…"`), direkt aus
der Klick-Konsole. Gute Referenz + schlechte Sonde = die Abdeckung steht, die Rückfallebene nicht.
Memory `ref_gegen_ap_messung`. *(Die alte 2-Client-`access-list`-Automatik über `tp25-pin-s22.sh` ist
mit dem Multi-Pin-Umbau entfallen; `tp25-pin-s22.sh` liefert nur noch die `map`.)*
- ▶️ **Start ohne Werkzeugkette: `bash docs/tp25-runde.sh [name]`** — legt den Lauf unter
`docs/messdaten/tp25-runde-<name>/` an, friert die AP-Karte ein, startet Konsole (Port 3031) und
Messung, sagt die **vom Interface gelesene** Adresse fürs Telefon an. In der Konsole: **pinnen**
(N Clients je AP/Band) → **gehen** (🚶/📍) → **Räume antippen** → **stoppen**; beim Beenden löst der
Wrapper den Multi-Pin. Auswertung: `tp25-gehweg-auswertung.py --dir …`.
- ℹ️ **Und das alles passiert TROTZ aktivem 802.11k/v und FT** (`st-main` mit `rrm=yes`/`wnm=yes`,
`ft-over-ds=yes`, live geprüft). Die Merkregel `ref_ft_is_not_roaming` („11k/v fehlen") ist
**historisch**. Bei −48…−55 dBm kostet eine Übergabe 0–2,6 s, meist unter einer halben Sekunde —
**nicht die Übergabe ist teuer, sondern die schwache Zone.**
**User-Randbedingung (2026-08-17, wörtlich): „ich weigere mich einen anderen access point zu kaufen
solange ich nicht das gefühl habe, wir haben alles aus dem ap rausgeholt."** Das ist die
**Abnahmebedingung dieses TPs** — keine Hardware-Empfehlung, bevor die Parameter stimmen. Der Boden ist
gelegt (beide APs 18 dBm, überlappungsfrei, 20 MHz auf 2,4 GHz, ap2 auf 2,5 m); was übrig bleibt, ist
die Redundanzfrage oben.
- **Warum eigener TP:** Christoph hat am 2026-08-16/17 sechs konkrete Beobachtungen gemeldet und
ausdrücklich **einen** TP dafür verlangt; sie hängen alle an derselben Ursache-Klasse (Funkparameter,
die nicht nach Manual gesetzt sind). **TP25 übernimmt den offenen Rest von TP3.3** (Re-Walk,
Dead-Spot-Kartierung — mit der Raumkarte vom 08-22 faktisch geleistet). TP3.2/TP3.3 stehen als Historie.
- **Stand bei Sitzungsende 2026-08-22:** Pin gelöst, Automatik aus, `crs326-core` unverändert und
**transaktionsfrei** — die `access-list` steht unter keiner ComplianceRule, es gab nichts zu committen.
*(Seit 2026-08-23 gibt es die Rule `wifi-access-list`, s. TP22-Nachtrag — Fund E wäre ab jetzt
transaktionsfähig.)*
- 📋 **Übergabe:** [`handoff-tp25-session-20260821.md`](handoff-tp25-session-20260821.md) ·
Messungen: [`tp25-wlan-roaming-messung-20260820.md`](tp25-wlan-roaming-messung-20260820.md),
[`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)
- **Abh.:** TP22 ✅ (GC-Deploy-Pipeline). Verwandt **TP3.2** (`docs/wifi-p750w-coverage-and-map.md`),
**TP29** (nutzt dieselbe Datenquelle als Sensor). **Kein Blocker für irgendetwas.**
#### 🔬 Fund E — 2026-09-01: der `ap2`-Teilbefund (gültig) und eine widerlegte Heilungs-Wertung
**Anlass:** Christoph meldete **2026-08-31 23:02** eine Störung am Schreibtisch (5G, iPhone, kein
Telefonat). Analyse (alles read-only am Manager `crs326-core`):
- **AP-Karte** (stabil seit letztem CAP-Rejoin 15:57, kein Rejoin danach — Attribution belastbar):
`cap-wifi5/6 = ap2`, `cap-wifi3/4 = ap3`, `cap-wifi1/2 = ap1` (funkstill, Radios da, 0 Clients).
- **23:02-Ereignis:** iPhone `F2:62:4C:E1:AF:09` ping-pongt im Sekundentakt `cap-wifi5 ↔ cap-wifi6`
= **ap2s 5G (5220) ↔ ap2s 2,4G (ch11/2462)** — Band-Steering-Thrash auf **demselben** AP, plus
`disconnected, not responding` um 22:37:40 (−76) und **23:01:00** (−82). Feldstärke der Roams
**−75…−88 dBm**.
- 🔴 **Christophs Fakt kippt die Coverage-Deutung:** iPhone und **ap2 nur 2 m** entfernt, gleicher
Raum, ap2 auf 2,3 m. Bei 2 m Sicht wären −35…−45 dBm normal. −80 auf 2 m ist **kein** Coverage-
und **kein** FT-Problem. Dazu: **aktuell 0 Clients auf ap2**, alle 29 auf ap3 — die Clients *meiden*
ap2. ⇒ **ap2s Funk selbst unterperformt** (RF/Antenne/HW) und/oder die **DTU-Störung auf ap2/ch11**
(Transaktion `TP17.1-dtu-pin-ap2`, Wechselrichter `54:F2:9F:12:51:F0` mit −59 dBm auf ap2s 2,4-Radio).
- **In-situ zu messen** (nachts nicht möglich — Phone idle auf ap3): Registration-Signal live prüfen,
während ein Client auf ap2 hängt; Kandidaten-Eingriffe — DTU-Pin lösen/verlegen, `dirtyvader-main`
auf ap2 temporär deaktivieren (ap3 übernimmt den Schreibtisch), ap2-Antenne/HW prüfen. **Keine
Änderung ohne diese Messung.**
🔴 **WIDERRUFEN am 2026-09-01 vormittags — der zweite Teil dieses Abschnitts war ein Messfehler.**
Ursprünglich stand hier: „Fund E ist im sauberen Fenster geheilt — 0× `reauthenticating` seit dem
14:36-Reboot (`sinceMinutes=1440`, total 0), also hat Hebel 1 sein Ziel erreicht."
**Warum das nicht trägt:** `get_log` liest den **flüchtigen Ringpuffer des Routers**, nicht den
Kollektor. Live nachgemessen fasst er **1000 Zeilen und reicht bis 01.09. 00:38:58 zurück** — keine
9 Stunden. Der Core-Reboot um 14:36 hatte ihn geleert; der 31.08. Vormittag war darin gar nicht
enthalten. `total 0` hieß **„im Puffer nichts", nicht „in 24 h nichts"**. Das ist exakt die Falle,
wegen der am 30.08. das persistente Logging gebaut wurde.
**Was der Kollektor zeigt:** im upgrade-freien Fenster 08:09–14:36 (Hebel 1 **allein**) **124×
`reauthenticating`** — 09h=1 · 11h=2 · 12h=20 · 13h=31 · 14h=70, **alle vom iPhone allein**, dazu
Christophs zwei erlebte Ausfälle 13:16 und 14:23. Zwischen 09:00 und 14:36 lag **kein Reboot**.
⇒ Hebel 1 wurde am 2026-09-01 **zurückgebaut**; der Rückbau ist das entscheidende Experiment
(s. Ist-Stand-Block oben).
✅ **Der `ap2`-Teilbefund oben bleibt davon unberührt und gültig** — er ist unabhängig gemessen und
erklärt die 23:02-Störung, die weder FT noch Coverage erklärt.
**Commit-Weg (wartet auf Christophs Go — git-push-Kette):** Template `nautobot/mikrotik_routeros.j2`
**Z. 220** `sec-main` von `ft-over-ds=yes` → `ft-over-ds=no`; dann Template-Repo nach Nautobot syncen,
`gc-configplan.py --refresh-compliance` (Backup→Intended→Compliance), dann
`gc-transaktion.py commit --change-id TP25-fund-e-ft-over-ds`. Danach `wifi-security` wieder driftfrei.
🔴 Kreuzt git-push (Backup-Guard + Commit-Hinweis-Regel) und Live-Nautobot-Jobs — **nicht autonom.**
Handoff: [`handoff-20260901-tp25-ap2-verdacht.md`](handoff-20260901-tp25-ap2-verdacht.md).
> **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.
<a id="tp28"></a>
@@ -1770,19 +1369,21 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
> 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. 🔴 **Harte Ausnahme, die den Termin vorzieht:** ein *neuer* Leak, ein
fremdzugängliches Gerät oder ein Personenwechsel — dann sofort und einzeln, nicht gebündelt.
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 | ⏳ **läuft am 2027-01-09 ohnehin ab** — harter Termin, unabhängig vom Projektende | Runbook liegt (`tp14.4-cf-access-token-runbook.md`) | Token neu ausstellen + `~/.secrets` nachziehen; **account-owned**, anderer verify-Endpoint |
| 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 |
> ✅ **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
@@ -1816,6 +1417,15 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
> 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.
>
> ℹ️ **#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).
@@ -1930,8 +1540,12 @@ Es ist **ein** Passwort für alle vier Geräte — die „Überlebensversicherun
- **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 — spätestens dann ist dieser Posten fällig, unabhängig vom Projektende.
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.*
@@ -2287,18 +1901,36 @@ haben: `100–109` netznah · **`110–119` DNS** · **`130–139` Reverse Proxy
**Letztes Oktett je /24 — gilt in jedem VLAN, an jedem Standort:**
| Oktett | Rolle | Anker heute |
|---|---|---|
| `.1–.9` | Gateways / L3 | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
| `.10–.29` | physische Hosts (Storage, Hypervisor, APs) | NAS `20.10` ✅ · APs `99.11/.12` ✅ |
| `.30–.99` | virtuelle Core-Services, Zehnergruppen wie oben | DNS `.3x` · Ingress `.4x` · DBs `.5x` · Security `.6x` · Monitoring `.7x` |
| `.100–.199` | App-Services | |
| `.200–.249` | DHCP-Pool | |
| `.250–.254` | VIPs / Relays | |
| 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
@@ -2321,19 +1953,35 @@ alle Gäste mit `order=`, dann den Rest nach VMID** — real lief also `iob-mast
| 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`, `330 RaspberryMatic` |
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed` |
| 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) — *am Ende der Reise, nicht jetzt*. Und die
Kollision **AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker) ⇒ Vorschlag `.36` als
Interim, `.30` erst nach dem Nautobot-Umzug in den App-Bereich. **Fällt vor dem AGH-Neubau an.**
- **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 — Sonos aus der Ferne bedienen (VPN/WARP) ▸ 💡 **IDEE, bewusst zurückgestellt** (angelegt 2026-08-25, User-Entscheid „später entscheiden")
@@ -2354,18 +2002,18 @@ kann in Ruhe nachziehen.
---
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 6 (Skill) ✅**
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ✅ **ABGESCHLOSSEN 2026-09-12** — 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.
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 (voraussichtlich `bf-infra/docs/announcements.md`) + Skill `/news` oder `/announce` zum Einlesen beim Session-Start. Initial: Breaking News über die MCP-Schichtung selbst.
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff.
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 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
- **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).
@@ -2535,9 +2183,12 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
- **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.
- **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).
@@ -2546,11 +2197,305 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
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 3 (CF) ✅ via TP44; Punkt 1+2 (Substrat + Dienste) offen** — das ist die
fehlende Voraussetzung für TP34. Datenmodell **entschieden 2026-09-06 (native Modelle:
`virtualization.Cluster/VirtualMachine` + `ipam.Service`, keine App)**. Runbook liegt jetzt vor:
[`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md). Live-Ausgangslage:
VM 0 · Cluster 0 · `ipam.Service` 0 · CustomField 0 (2026-09-06 gegen die API gelesen).
- **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 — Interne DNS-Namen als Daten (FQDN aus Nautobot) ▸ 🔜 **ANGELEGT 2026-09-22** (Gitea #73) — **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 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN aus Nautobot ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #71)
> **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.
### TP46 — Cloudflare/Tunnel: SSoT mit Push-Weg + Full-Recovery from scratch ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #72)
> **Christoph (2026-09-21):** *„haben tunnel/warp/cf schon eine ssot für die config? nautobot? tunnel
> sind essenzielle dinge, für die ich eine ssot möchte um drift zu vermeiden. zudem möchte ich auch
> from scratch ein full setup/recovery aus der nautobot-config fahren können."* → Umfang **„Voll:
> Tunnel+Ingress+DNS+Access"**, Platzierung **„Eigenes TP, GC bleibt hier"**.
#### Bestandsaufnahme (live 2026-09-21) — es gibt eine Doku-SSoT, aber keinen Master
| Was | Wo | Stand |
|---|---|---|
| Tunnel, WARP-Connector, Workers, Access-Apps, Hyperdrive, VPC, teamnet-Routen | Nautobot `nautobot.cloud`, Job **`CF Estate Sync`** (`jobs/cf_estate_sync.py`) | ✅ 4 `CloudNetwork` · 16 `CloudService` live |
| **Ingress-Origin-Ziele** | **nirgends** | 🔴 `_ingress()` speichert nur `ingress_hostnames`, **nicht** das Origin |
| **DNS-Records** | **nirgends** | 🔴 nicht erfasst — live 24 Records / 8 proxied |
| **Access-Policies** | **nirgends** | 🔴 nur die Apps selbst |
| Richtung | CF → Nautobot | 🔴 **Pull-only** („CF bleibt operativer Master") |
| `docs/cf-tunnel-mapping.md` | Repo | §1–16 eingefrorene As-Built-Historie · §0 = Live-Zeiger (altert: nennt `bf-public` „2 conns", live **12** = 3 AP-Connectoren × 4) |
**Antwort auf „full setup/recovery from scratch": heute nicht möglich.** Es fehlt (a) der Push-Weg,
(b) Ingress-Origins, (c) DNS, (d) Access-Policies. Und ein frisch erzeugter Tunnel liefert **neue ID
und neues Secret** → das **Token-Handoff an die Connectoren** (ap1/ap2/ap3) ist **Teil der Recovery**,
kein Nebenschritt.
#### Umfang
Tunnel + Ingress **inkl. Origin-Ziele** + DNS-Records + Access-Apps/Policies vollständig nach Nautobot;
**Push-Renderer** (Dry-Run + Diff); **Recovery-Runbook** für den Aufbau von null.
🔴 **Vor dem ersten Schritt prüfen, nicht annehmen:** welches Nautobot-Objekt DNS-Records tragen kann
(Kern-`ipam` vs. eigenes Modell), und ob das Ingress-Origin als `extra_config`-Feld reicht oder ein
eigenes Modell braucht.
- **Abh.:** TP44 Etappe 1/2 ✅ (native `nautobot.cloud`-Modelle + Pull-Job stehen). **Abgrenzung:**
Tunnel-**GC/Namensgebung** läuft in TP45 Schicht D; **CF-Writes** aus Nautobot sind hier *das Ziel*,
in TP37 ausdrücklich ausgeschlossen — die Grenze verschiebt sich also bewusst mit diesem TP.
---
@@ -2657,6 +2602,12 @@ 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

View File

@@ -64,7 +64,12 @@ lokal" — das ist nach dieser Rotation nicht mehr wahr.
## 4. Fußnote mit Verfallsdatum
⏰ **Der `CF_API_TOKEN` selbst läuft am 2027-01-09 ab** (Memory `ref_cf_mcp_readonly_token_writes`).
⏰ ~~Der `CF_API_TOKEN` selbst läuft am 2027-01-09 ab~~ — **korrigiert 2026-09-21:** das Datum
gehörte zum längst **revokten** Alt-Token `9984078812bc…`; das **live** `CF_API_TOKEN`
(id `493ed8bd…`) hat **kein** Ablaufdatum (verifiziert via `/accounts/{id}/tokens/verify`,
`expires_on: null`). Die Rotation ist damit nicht mehr termin-, sondern **anlassgetrieben**:
**Anlass** ist der **Leak vom 2026-09-21**. Sie bleibt **gebündelt** in TP30 (Posten #4) und wird
nicht vorgezogen. Memory `ref_cf_mcp_readonly_token_writes` ist nachgezogen.
Er ist **account-owned**, verifiziert also über einen anderen Endpoint als ein User-Token — wer das
verwechselt, hält ein funktionierendes Token für kaputt. Ohne ihn ist auch diese Rotation nicht mehr
möglich, ebensowenig der TP16.3-Schnitt.

View File

@@ -0,0 +1,8 @@
# TP25 — sec-main zurueck auf ft=yes / ft-over-ds=yes (Ruecknahme der 09-04-Entscheidung "ft=no als Dauerzustand")
# Go: Christoph 2026-09-14 ("go fuer ft=yes, dry-run vorlegen"), Terrasse-In-situ-Session
# Anlass: 2 Call-Aussetzer + ~30 Roams in 25 min — mit ft=no ist jeder Roam (Band- und AP-Wechsel)
# eine Voll-Reassoziation in Sekundenhoehe; genau darin sterben Anrufe.
# Nachweis, dass ft-over-ds=yes + 7.24.1 ruhig ist: Fund-E-Abschlussmessung 09-02 (4 reauth/8,6 h, 0 Ausfaelle).
# Nur am Manager noetig: sec-main wird per CAPsMAN an alle CAPs verteilt (Uniformitaet per Konstruktion);
# Inline-Overrides je CAP gibt es seit dem ap3-Fix 09-04 nicht mehr — Abnahme prueft sie trotzdem (Merkregel).
/interface wifi security set sec-main ft=yes ft-over-ds=yes

View File

@@ -0,0 +1,10 @@
# TP25 — 5G bricht frueher zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante)
# Go: Christoph 2026-09-15 ("committen und threshold deployen")
# Nur 5G: neues Steering-Profil st-main-5g mit transition-threshold=-72 (statt Default -80) und
# transition-threshold-time=5s (statt 10s). 2G bleibt auf st-main (unset -80) — Reichweite draussen.
# Nur am Manager noetig: steering/config werden per CAPsMAN an die CAPs verteilt.
# Rollback = steering der drei Configs zurueck auf st-main + st-main-5g loeschen.
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes wnm=yes transition-threshold=-72 transition-threshold-time=5s
/interface wifi configuration set [find name=cfg-main-5g-ap1] steering=st-main-5g
/interface wifi configuration set [find name=cfg-main-5g-ap2] steering=st-main-5g
/interface wifi configuration set [find name=cfg-main-5g-ap3] steering=st-main-5g

View File

@@ -0,0 +1,15 @@
# TP45 Schicht A — Nautobot-Literal in der Core-Firewall von .30 auf .154
#
# KEINE Transaktion: das ist eine dauerhafte Soll-Aenderung (Nautobot ist umgezogen,
# das Soll in Nautobot wurde mitgezogen). Die Datei liegt nur hier, weil
# gc-manual-plan.py eine Commands-Datei braucht.
#
# Kontext: die Regel *24 "TP5-E2: Nautobot->MGMT ssh" ist der LITERAL-Pfad und stand
# auf 10.40.20.30. Sie steht bei 0 Paketen — die Arbeit macht die Parallelregel *28
# "TP5-E2: Nautobot->MGMT ssh (list)" ueber src-address-list=nautobot (113 Pakete,
# Liste am 2026-09-21 um .154/.155/.156 erweitert). Der Literal ist damit
# funktional redundant, wird aber auf .154 gezogen, damit Soll (Nautobot-Daten
# `firewall_filter`) und Ist nicht auseinanderlaufen.
#
# Rollback: siehe TP45-firewall-nautobot-154.rollback.rsc
/ip firewall filter set [find comment="TP5-E2: Nautobot->MGMT ssh"] src-address=10.40.20.154

View File

@@ -0,0 +1,4 @@
# Rueckbau TP45 Schicht A: Nautobot-Literal zurueck auf .30
# (nur noetig, wenn Nautobot wieder auf .30 zurueckzieht — die Adressliste ist die
# wirksame Ebene, dieser Literal ist der tote Parallelpfad.)
/ip firewall filter set [find comment="TP5-E2: Nautobot->MGMT ssh"] src-address=10.40.20.30

View File

@@ -0,0 +1,3 @@
# Ist-Stand der Regel VOR dem TP45-Schicht-A-Deploy (2026-09-21, gelesen per MikroMCP).
/ip firewall filter
add action=accept chain=forward comment="TP5-E2: Nautobot->MGMT ssh" dst-port=22 out-interface=vlan99 protocol=tcp src-address=10.40.20.30

View File

@@ -52,15 +52,19 @@ BENUTZUNG
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
"""
import argparse
import importlib.util
import json
import os
import pathlib
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
@@ -160,8 +164,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
def created(p):
raw = str(p.get("created") or "")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
except ValueError:
return datetime.min
by_name = {}
for p in found:
for p in sorted(found, key=created):
name = (p.get("device") or {}).get("display")
by_name[name] = p["id"]
@@ -289,7 +307,7 @@ def job_mgmt_acl():
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.30) aus, also aus
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
@@ -353,10 +371,573 @@ def job_tp42_dhcp_domain():
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
def job_tp38_oob():
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
Zwei Geraete, zwei config_sets:
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
die laufende Nautobot-SSH-Session (10.40.20.154) matcht auf Regel 1 (established),
bevor Regel 8 (drop) greift.
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
waere wirkungslos + non-compliant).
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
"""
bridge_set = (
':do { /interface bridge port set [find interface=ether23] pvid=99 '
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
'on-error={ :log info "tp38: ether23 set geworfen" }'
)
vlan_set = (
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
)
branch_fw = "\n".join([
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
'add action=drop chain=input comment="TP38: input deny-by-default"',
])
branch_set = "\n".join([
bridge_set,
vlan_set,
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
])
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
core_set = "\n".join([
bridge_set,
vlan_set,
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
'destination=[find where comment="TP5: input log-only"]',
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
])
return [
(["crs326-branch"], branch_set, "TP38-oob-branch"),
(["crs326-core"], core_set, "TP38-oob-core"),
]
def job_tp45_agh_fw():
"""TP45 Schicht A (2026-09-21): DNS-Freigaben fuer die neuen Resolver-Adressen .30/.31.
Der Resolver zieht von .36/.37 auf .30/.31 (TP32-Oktett-Klasse DNS `.3x`). Damit die
Umstellung ohne Ausfall geht, laufen beide Adressen kurz parallel — also braucht der
Core **beide** Freigaben. Diese hier ist ADDITIV: die vier .36/.37-Regeln bleiben
stehen und fallen erst beim Rueckbau (eigener Schritt, wenn die Zaehler stillstehen).
Die vier Regeln sind ein Literal im Soll (`local_config_context_data.firewall_filter`,
gepflegt mit `nautobot/tp45-agh-firewall.py`) — der Job zieht nur das Geraet nach.
🔴 `place-before` wird ueber Netmiko IGNORIERT (Lehre aus TP38, 2026-09-15): die Regel
landet am Ketten-Ende, also HINTER dem finalen `drop` und waere wirkungslos, waehrend
der Plan trotzdem „Completed" meldet. Deshalb `add` (appends) und danach `move` vor
die Log-Regel — in der Reihenfolge, in der die Regeln stehen sollen (jedes `move`
schiebt seine Regel unmittelbar vor das Ziel, also hinter die zuvor verschobenen).
🔴 **Zweite Falle, am 2026-09-21 beim ersten Lauf aufgeschlagen:** nicht der `move`
war falsch, sondern der **Selektor** — `find where dst-address=<bare-IP>` matcht nie
(Details und Tragweite in der Docstring von `sel()` unten). Der Plan meldete
trotzdem „Completed", die Regeln lagen hinter dem `drop`. Deshalb ist die Abnahme
hier **nicht** der Plan-Status, sondern die Position am Geraet.
KEIN LOCKOUT-RISIKO: die Regeln erweitern nur `accept` in der forward-Kette; der
Mgmt-Pfad (Nautobot-SSH -> vlan99) ist von der Aenderung nicht beruehrt. Der Deploy
laeuft von 10.40.20.154, das auf Regel 22 (established) matcht.
"""
ips = ["10.40.20.30", "10.40.20.31"]
log = 'TP5: log-only, Vorstufe zum deny-by-default'
def sel(cmt, ip):
"""Selektor auf genau eine Regel.
🔴 **`dst-address=<bare-IP>` matcht in `find where` NIE** — live am Core
gemessen (2026-09-21): `find where dst-address=10.40.20.30` → **0 Treffer**,
auch mit `/32`; `dst-address~"10.40.20.30"` (Regex) → 2. Deshalb steht hier
`~`, nicht `=`.
⚠️ Die Tragweite ist größer als dieser Job: **eine Prüfung, die 0 meldet,
sieht wie „nicht vorhanden" aus und nicht wie „kaputt".** Genau daran ist
der erste Lauf dieses Jobs gescheitert — der `add` lief (der Guard sah
„nicht vorhanden"), der `move` lief ins Leere, und der Plan meldete
trotzdem „Completed". Dieselbe Zeile stand als *Verifikation* in
`docs/transaktionen/TP23-agh-firewall.commands.rsc:7` und hat dort seit
dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu prüfen.
Gleiche Klasse wie `nc -z` (2026-09-01) und das `sed`-Maskierungsmuster:
**erst den Selektor verdächtigen, dann das Gerät.**
"""
return f'comment="{cmt}" dst-address~"{ip}"'
def rule(ip, proto, cmt):
return (f'/ip firewall filter add action=accept chain=forward comment="{cmt}" '
f'dst-address={ip} dst-port=53 protocol={proto}')
lines = []
for ip in ips:
# Guard + move getrennt: nur der Guard ist idempotent (ein zweiter Lauf
# verdoppelt die Regeln nicht), der move läuft immer — er ist auch der
# Reparaturweg, wenn eine Regel schon existiert, aber falsch einsortiert ist.
for proto, cmt in (("udp", "TP5-E2: LAN->AGH dns"), ("tcp", "TP5-E2: LAN->AGH dns-tcp")):
lines.append(f':if ([:len [/ip firewall filter find where {sel(cmt, ip)}]] = 0) '
f'do={{ {rule(ip, proto, cmt)} }}')
for ip in ips:
for cmt in ("TP5-E2: LAN->AGH dns", "TP5-E2: LAN->AGH dns-tcp"):
lines.append(f'/ip firewall filter move [find where {sel(cmt, ip)}] '
f'destination=[find where comment="{log}"]')
# Der Anker ist bewusst die Log-Regel (Baseline, stabil) und nicht `caddy`: haengt
# sie unmittelbar vor der Log-Regel, landen die vier Regeln DAHINTER -- der AGH-Block
# bleibt so zusammen, wie das Soll ihn fuehrt. Waehlte man `caddy` als Ziel und die
# Regel fehlte, laegen die vier hinter dem `drop` = funktional kaputt; so bleibt im
# Fehlerfall nur eine Reihenfolge-Abweichung (Compliance-Drift, kein Ausfall).
lines.append(f'/ip firewall filter move [find where comment="TP31: AP-cloudflared -> caddy-bf1"] '
f'destination=[find where comment="{log}"]')
# ⚠️ Diese Zeile meldet NUR Anwesenheit (je IP 2 Regeln erwartet), NICHT die
# Position -- sie kann also gruen aussehen, waehrend die Regeln hinter dem `drop`
# liegen. Genau daran ist der erste Lauf gescheitert. **Die Abnahme ist der
# Geraete-Read nach dem Deploy** (`print terse proplist=comment,packets`).
lines.append(
':log info ("tp45-agh-fw: .30=" . '
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.30"]]] . " .31=" . '
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.31"]]] . " log=" . '
f'[:tostr [:len [/ip firewall filter find where comment="{log}"]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-agh-firewall")]
def job_tp45_agh_dhcp():
"""TP45 Schicht A (2026-09-21): DHCP-DNS-Server der 7 Netze .36,.37 -> .30,.31.
Der Core verteilt in ALLEN sieben Netzen `dns-server=10.40.20.36,10.40.20.37`
(live geprueft 2026-09-21). Das Template (Z.137) ist auf .30,.31 gehoben; dieser Job
zieht das laufende Geraet nach.
MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der
intended config (die wuerfe `already have such address`). `[find]` ohne Filter
trifft alle 7 Netze. Gleiche Form wie `job_tp42_dhcp_domain`.
⚠️ **Reihenfolge zwingend:** erst `tp45-agh-fw` deployen. Sonst liefert DHCP Adressen
aus, die die Firewall noch nicht durchlaesst — unter deny-by-default faellt DNS aus
allen VLANs aus.
KEIN LOCKOUT-RISIKO: eine DHCP-Option beruehrt weder Routing noch Mgmt-Zugang;
Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d, GUEST 12h).
"""
core_set = "\n".join([
':do { /ip dhcp-server network set [find] dns-server=10.40.20.30,10.40.20.31 } '
'on-error={ :log info "tp45-agh-dhcp: set geworfen" }',
':log info ("tp45-agh-dhcp: auf-neu=" . [:tostr [:len [/ip dhcp-server network '
'find where dns-server~"10.40.20.30"]]] . "/7")',
])
return [(["crs326-core"], core_set, "TP45-agh-dhcp")]
def _pool_set(vlan, ranges):
"""Zeilen, die `pool-v<vlan>` auf `ranges` ziehen -- mit gezaehltem Selektor.
MODIFY, kein add: der Pool existiert, die `add`-Zeile der intended config
wuerfe `already have such name`. Gleiche Form wie `job_tp45_agh_dhcp`.
🔴 Der Selektor wird GEZAEHLT, nicht geglaubt (Lehre vom 2026-09-21): ein
`find where`, das 0 trifft, sieht wie „nicht vorhanden" aus und nicht wie
„kaputt" -- ein blankes `set [find where name=…]` liefe ins Leere und der
Job meldete trotzdem Erfolg (dieselbe Klasse wie `dst-address=<bare IP>`
in TP23-agh-firewall.commands.rsc:7, das seit dem 08-25 immer 0 meldete).
🔴🔴 **JEDES Statement muss auf EINER Zeile klammer-vollstaendig sein.**
Erste Fassung hatte `:if (...) do={` am Zeilenende -- RouterOS geht dann in
den Fortsetzungs-Modus und zeigt einen ANDEREN Prompt, der netmiko-Treiber
verliert sein Muster und der Deploy stirbt mit
`E1015: Pattern not detected: (redacted)@crs326\\-core\\].*$|#.*$)`.
Der Plan stand danach auf `Failed`, **ohne** etwas geschrieben zu haben.
Deshalb ist jede Zeile hier ein vollstaendiges `… do={ …; … } else={ … }`.
"""
pool = f"pool-v{vlan}"
return "\n".join([
f':if ([:len [/ip pool find where name="{pool}"]] = 1) do={{ '
f'/ip pool set [find where name="{pool}"] ranges={ranges}; '
f':log info ("tp45-pool: {pool} -> " . [/ip pool get [find where name="{pool}"] ranges]) }} '
f'else={{ :log info ("tp45-pool: ABBRUCH, {pool} nicht eindeutig") }}',
])
def job_tp45_pool_v20():
"""TP45 (2026-09-21): pool-v20 von .100-.200 auf .200-.249 verengen.
Damit werden die Schema-Baender .100-.149 (Server-Hosts) und .150-.199
(App-Services) in VLAN20 echt frei. VLAN20 traegt KEINE DHCP-Reservierung
im alten Fenster (live geprueft: nur `nas-bf .20.10` in .10x) und 0
dynamische Leases -- die Verengung kostet hier nichts.
🔴 Warum ueberhaupt: `nautobot .154-.158`, `gitea .157`, `pve-mcp .173` und
`shelly-mcp .180` tragen ihre IP GERAETELOKAL und stehen in KEINEM
Lease-Eintrag -- der Core kann sie nicht schuetzen und einem DHCP-Client
.154 anbieten. Die Verengung ist hier sofort wirksam.
⚠️ VLAN30 gehoert NICHT hierher: dort liegen 35 statische Reservierungen IM
Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot .227, Twinkly .228,
Withings .230/.231, lichterketten .236/.237, Nuki .241/.244, CCU .232).
Erst Schicht B zieht die um, DANN pool-v30.
"""
return [(["crs326-core"], _pool_set(20, "10.40.20.200-10.40.20.249"), "TP45-pool-v20")]
def _tp45_leases_module():
"""MOVES/REMOVE/ADDS aus `tp45-leases.py` laden.
🔴 **NICHT kopieren.** Diese Tabellen sind die SSoT des Umzugs. Eine zweite
Kopie hier liefe beim ersten Edit auseinander — und zwar still, weil beide
Seiten fuer sich plausibel aussehen; am Geraet faellt nur auf, dass eine
Adresse fehlt.
Der Dateiname traegt einen Bindestrich (Repo-Konvention fuer Skripte), ist
also nicht regulaer importierbar -> importlib ueber den Pfad. `__name__` ist
dann nicht `__main__`, das `main()`-Guard der Datei greift also nicht.
"""
spec = importlib.util.spec_from_file_location(
"tp45_leases", pathlib.Path(__file__).with_name("tp45-leases.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _lease_move(mac, soll, tag="tp45-v30"):
"""Eine Umzugs-Zeile — EINZEILIG und klammer-vollstaendig (E1015-Lehre, s. _pool_set).
Der Selektor wird GEZAEHLT: ein `find where`, das 0 trifft, sieht wie
„nicht vorhanden" aus und nicht wie „kaputt", und ein blankes `set [find …]`
liefe ins Leere, waehrend der Job Erfolg meldet.
Absichtlich OHNE `address=<ist>` im Selektor: ein zweiter Lauf muss ein
No-Op sein, kein Fehler. Die Ist-Adresse wird stattdessen VOR dem Deploy
geprueft — `tp45-leases.py move` verlangt, dass das Soll genau `ist` traegt
und bricht sonst ab.
🔴🔴 **ZWEI VERSCHIEDENE Selektor-Formen, und das ist keine Kosmetik.**
Die erste Fassung schrieb ueberall die Argument-Form `[… [find where …]]`.
Die ist **Syntaxfehler**, sobald sie in `[:len …]` steht:
`expected command name (line 1 column 55)` (live gemessen 2026-09-21)
Der Deploy lief trotzdem als `Completed` durch, das Geraet blieb
**unveraendert**, und im Log stand nur die Schlusszeile — von den 38
`:if`-Statements hat **kein einziger** Zweig geloggt, also ist auch kein
`set` gelaufen.
* in `[:len …]` und direkt vor `find`: **`/ip dhcp-server lease find where …`**
(kein Klammerpaar um `find`) — Muster aus `_pool_set`/`job_tp45_agh_dhcp`
* als Argument einer Vollpfad-Kommandozeile: `set [find where …]`
— diese Form ist in `_pool_set` bewiesen und bleibt.
⚠️ **Merkregel:** `Completed` am Plan und `SUCCESS` am Job beweisen hier
nichts. Der Beweis ist die **Zahl der Log-Zeilen** (jeder Zweig loggt) oder
ein Geraete-Read. Beides gehoert zur Abnahme — genau wie beim
`pool-v20`-Deploy, der ohne Gegenprobe als gelungen gegolten haette.
"""
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
return (f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease set {arg} address={soll}; '
f':log info ("{tag}: {mac} -> {soll}") }} '
f'else={{ :log info ("{tag}: {mac} NICHT EINDEUTIG -- nicht angefasst") }}')
def job_tp45_v30_leases():
"""TP45 Schicht B2 (2026-09-21): die VLAN30-Reservierungen ins Oktett-Schema.
36 Reservierungen wandern aus dem DHCP-Pool-Fenster (.202-.244, .5, .8) in die
Schema-Baender: `.11-.14` fest verbaute Infrastruktur, `.151-.176` Shelly-Block,
`.177-.196` App-Services. Dazu zwei Sonderfaelle:
* `pve-homematic-raspi-ccu` (MAC BC:24:11:8E:0A:57) wird ENTFERNT — eine
Leiche aus dem Core-Cutover (`status=waiting`, `last-seen=never`). Die CCU
ist live in VLAN20 (.20.232, ARP + HTTP 200 belegt, Caddys `ccu.bf` proxyt
dorthin); die VLAN30-Reservierung ist ein Zombie.
* `SONOFF-NSPanelPro` hat GAR KEINE Reservierung, nur eine dynamische Lease
auf `.249`. Wird per `make-static` gepinnt und auf `.12` gezogen.
⚠️ **Reihenfolge:** dieser Job gehoert VOR `tp45-pool-v30`. Die Verengung auf
`.200-.249` setzt voraus, dass keine Reservierung mehr im Schema-Band liegt —
sonst zieht der Pool Adressen, die schon vergeben sind.
⚠️ **Kein Ausfall, kein Lockout:** ueber DHCP-Reservierungen umzuziehen heisst,
dass die Geraete beim naechsten Lease-Renew selbst nachziehen (lease-time 1d).
Nur zwei der 36 sind im Moment ueberhaupt `bound` (SwitchBot, DTUBI); alle
anderen stehen auf `last-seen=never`. Nichts wird hart umgeschaltet.
⚠️ **`.150` (Ender3V3KE) und `.180` (espressif/Airfryer) bleiben, wo sie sind** —
sie liegen bereits im App-Band und werden bewusst nicht bewegt.
Rueckweg: `MOVES` traegt Ist UND Soll je MAC, das inverse `set` ist also
mechanisch; zusaetzlich liegt vor dem Deploy ein binaeres Backup auf dem Core.
"""
mod = _tp45_leases_module()
lines = [_lease_move(mac, soll) for mac, (_ist, soll) in sorted(mod.MOVES.items())]
for mac in sorted(mod.REMOVE):
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease remove {arg}; '
f':log info ("tp45-v30: CCU-Leiche {mac} entfernt") }} '
f'else={{ :log info ("tp45-v30: CCU-Leiche schon weg") }}')
for add in mod.ADDS:
mac = re.search(r"mac-address=(\S+)", add).group(1)
ip = re.search(r"address=(\S+)", add).group(1)
cmt = re.search(r"comment=(\S+)", add).group(1)
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
# make-static VOR set: auf einer dynamischen Lease ist `set address=` wirkungslos
# bzw. wird beim naechsten Renew ohnehin verworfen -- erst statisch, dann pinnen.
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease make-static {arg}; '
f'/ip dhcp-server lease set {arg} address={ip} comment="{cmt}"; '
f':log info ("tp45-v30: NSPanelPro statisch auf {ip}") }} '
f'else={{ :log info ("tp45-v30: NSPanelPro hat keine Lease -- spaeter nachziehen") }}')
# Abnahme-Zeile. `address=` ist ein echter Eigenschaftsvergleich (kein Regex) --
# bewusst NICHT `~"10.40.30.11"`, das traefe auch .110-.119.
lines.append(
':log info ("tp45-v30: .11=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.11]]] . " .12=" . [:tostr [:len [/ip dhcp-server '
'lease find where address=10.40.30.12]]] . " .151=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .196=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.196]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-v30-leases")]
def job_tp45_v30_revert():
"""RUECKNAHME 2026-09-21: die 26 Shelly-Reservierungen zurueck auf ihre ALTEN Adressen.
Christoph-Direktive, sofort: *„mach die shellys sofort rueckgaengig! das gibt
sonst ein riesen durcheinander mit home assistant"*. Grund: HA hat die Shellys
mit ihren alten Adressen eingebunden -- ein Umzug zerreisst die Integrationen.
🔴 **NUR die Shellys** — das war die erste, eng gefasste Ruecknahme. Die
uebrigen 10 Umzuege aus `job_tp45_v30_leases` (NukiBridge .11, P750W .13,
DTUBI .14, NukiSmartlock .177, SwitchBot .178, Twinkly .179, lichterketten
.190/.191, Withings .195/.196) und die beiden Sonderfaelle (CCU-Leiche
entfernt, NSPanelPro statisch auf .12) liess er unangetastet.
⚠️ **Noch am selben Tag ueberholt:** auf die weitergehende Direktive
(„wir sollten ALLE vlan30-aenderungen rueckgaengig machen" — Sperr-Risiko
Nuki) folgte `job_tp45_v30_restore`, der auch diese 10 plus beide
Sonderfaelle zurueckdreht. Dieser Job bleibt als der engere, zuerst
gelaufene Schritt bestehen.
Die Tabelle steht in `tp45-leases.py` als `REVERT` (abgeleitet aus `MOVES`,
gefiltert auf die 26 Shelly-MACs) -- NICHT hier, aus demselben Grund wie
`MOVES`: eine zweite Kopie laeuft still auseinander.
⚠️ **Reihenfolge:** erst `python3 nautobot/tp45-leases.py revert --apply`
(Soll zurueck), DANN diesen Job. Umgekehrt meldet GC sofort wieder Drift --
das Geraet waere richtig und das Soll falsch.
Abnahme: Geraete-Read `proplist=address,comment where server=dhcp-v30` -- die
26 Shellys muessen auf .202-.226/.233 stehen, die uebrigen 13 Eintraege
unveraendert. Danach `--refresh-compliance`, erwartet 145/145 und 0 Drifts.
"""
mod = _tp45_leases_module()
lines = [_lease_move(mac, alt, tag="tp45-v30-revert")
for mac, (_neu, alt) in sorted(mod.REVERT.items())]
# Abnahme-Zeile: die alten Baender muessen wieder voll sein, die neuen leer.
# Bewusst ein Bereichs-Paar statt aller 26 Adressen -- .202/.233 rahmen den
# alten Shelly-Block ein, .151/.176 den neuen.
lines.append(
':log info ("tp45-v30-revert: .151=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.151]]] . " .176=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.176]]] . " .202=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.202]]] . '
' " .233=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.233]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-v30-shelly-revert")]
def job_tp45_v30_restore():
"""VOLLRUECKNAHME 2026-09-21: alle VLAN30-Aenderungen aus B2 zuruecknehmen.
Christoph-Direktive: *„wir sollten alle vlan30-aenderungen rueckgaengig machen.
du koenntest mich auf diesem wege aus meiner wohnung aussperren."*
Was B2 am 2026-09-21 getan hat, und was hier einzeln zurueckgedreht wird:
1. **36 Reservierungs-Adressen** verschoben -> hier ALLE 36 zurueck auf ihre
`MOVES`-Ist-Adresse (idempotent: die 26 Shellys stehen schon dort, ein
zweites `set` auf dieselbe Adresse ist ein No-Op).
2. **CCU-Leiche entfernt** (`BC:24:11:8E:0A:57`, .30.232) -> wieder angelegt,
wortgleich der Stand vor B2. Ob die Leiche spaeter geloescht werden soll,
ist eine **eigene** Entscheidung, nicht Teil dieser Ruecknahme.
3. **SONOFF-NSPanelPro auf statisch .12 gepinnt** -> Reservierung entfernt;
vor B2 hatte es keine (reiner DHCP-Client auf .249). `remove` ist der
Weg, weil RouterOS 7.24 kein `make-dynamic` kennt (s. u.).
⚠️ **Reihenfolge:** erst `python3 nautobot/tp45-leases.py restore --apply`
(Soll zurueck), DANN diesen Job.
🔴 **Warum die Eile berechtigt war:** die Reservierung eines Geraets mit
**lokaler** IP zu aendern, bewegt das Geraet nicht — aber bei einem Geraet,
das **DHCP** nutzt, haette die naechste Erneuerung es auf die neue Adresse
gezogen, und IP-gebundene Einbindungen (Home Assistant: Nuki, Shelly, …)
waeren gerissen. Dass hier nichts passiert ist, war Glueck, kein Entwurf.
Abnahme: Geraete-Read der v30-Leases + ARP-Band-Zaehlung; danach
`--refresh-compliance`, erwartet 145/145 und 0 Drifts.
"""
mod = _tp45_leases_module()
lines = [_lease_move(mac, ist, tag="tp45-v30-restore")
for mac, (ist, _soll) in sorted(mod.MOVES.items())]
# NSPanelPro: die statische Reservierung ENTFERNEN -- vor B2 hatte es keine,
# es war ein reiner DHCP-Client (dynamische Lease auf .249).
#
# 🔴 `make-dynamic` gibt es in RouterOS 7.24 NICHT: `/ip dhcp-server lease
# make-dynamic` antwortet `bad command name make-dynamic` (live gemessen
# 2026-09-21). Der erste Versuch dieses Jobs ist daran gestorben und hat den
# Plan auf `Failed` gesetzt, OBWOHL alle uebrigen Statements gelandet sind —
# genau die Klasse „Completed/Failed beweist nichts, das Geraet ist die
# Wahrheit". `make-static` existiert (B2 hat es benutzt), das Gegenstueck
# fehlt; der Rueckweg ist `remove`.
#
# ⚠️ Das Panel haelt .12 noch bis zum Lease-Ablauf (~20 h, `status=bound`,
# `active-address=10.40.30.12`) und faellt danach in den Pool zurueck.
# Das ist eine von B2 erzeugte Uebergangserscheinung, keine neue Aenderung.
for add in mod.ADDS:
mac = re.search(r"mac-address=(\S+)", add).group(1)
find = f'find where mac-address={mac}'
arg = f'[find where mac-address={mac}]'
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
f'/ip dhcp-server lease remove {arg}; '
f':log info ("tp45-v30-restore: NSPanelPro-Reservierung entfernt") }} '
f'else={{ :log info ("tp45-v30-restore: NSPanelPro hat keine Reservierung") }}')
# CCU-Leiche wieder anlegen -- nur wenn sie fehlt.
for mac in sorted(mod.REMOVE):
find = f'find where mac-address={mac}'
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 0) do={{ '
f'/ip dhcp-server lease {mod.RESTORE_ADD}; '
f':log info ("tp45-v30-restore: CCU-Leiche wieder angelegt") }} '
f'else={{ :log info ("tp45-v30-restore: CCU-Leiche ist schon da") }}')
# Abnahme: die alten Baender muessen voll sein, die neuen leer.
lines.append(
':log info ("tp45-v30-restore: alt .202=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.202]]] . " .233=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.233]]] . " .244=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.244]]] . '
' " neu .11=" . [:tostr [:len [/ip dhcp-server lease '
'find where address=10.40.30.11]]] . " .151=" . [:tostr [:len '
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .177=" . '
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.177]]])')
return [(["crs326-core"], "\n".join(lines), "TP45-v30-restore")]
def _dns_static_lines():
"""Die `/ip dns static add`-Zeilen aus dem GC-Template, wortgetreu.
🔴 **Quelle ist das Template selbst, NICHT eine zweite Kopie.** Eine handgepflegte
Liste hier liefe beim ersten Template-Edit auseinander — still, weil beide Seiten
fuer sich plausibel aussehen; am Geraet faellt nur auf, dass ein Name fehlt.
Und die Kommentare muessen **zeichengleich** sein: Golden Config vergleicht
`comment=` mit, ein abweichender Text ist eine Drift.
Der `/ip dns static`-Block ist ein Literal im Template (im Gegensatz zu
`firewall_filter` und `dhcp_leases`, die als Daten aus
`local_config_context_data` kommen) — deshalb ist das Parsen hier zulaessig.
"""
text = (pathlib.Path(__file__).with_name("mikrotik_routeros.j2")).read_text()
return [ln.strip() for ln in text.splitlines()
if ln.strip().startswith("/ip dns static add ")]
def _dns_upsert(line):
"""Eine Template-Zeile -> idempotenter Upsert, EINZEILIG und klammer-vollstaendig.
`/ip dns static add` ist NICHT idempotent — ein zweiter Lauf legte den Namen ein
zweites Mal an. Deshalb: nur anlegen, wenn der Name fehlt, sonst in place setzen.
Der Selektor wird wie in `_lease_move` gezaehlt (`find where …` in `[:len …]`,
`[find where …]` als Argument) — die vertauschte Form ist ein Syntaxfehler, der
als `Completed` durchlaeuft und das Geraet unveraendert laesst.
"""
addr = re.search(r"address=(\S+)", line).group(1)
name = re.search(r"name=(\S+)", line).group(1)
cm = re.search(r'comment=("(?:[^"\\]|\\.)*"|\S+)', line)
comment = f" comment={cm.group(1)}" if cm else ""
return (f':if ([:len [/ip dns static find where name={name}]] = 0) do={{ '
f'{line} }} '
f'else={{ /ip dns static set [find where name={name}] address={addr}{comment} }}')
def job_tp45_c_dns():
"""TP45 Schicht C (2026-09-22): die internen Namen in den CORE — der FQDN-Fix.
Hintergrund: AGH wertet seine DNS-Rewrites nur im **Filter-Pfad** aus. Fuer
unfiltrierte Clients (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`,
`grp-media`, `grp-mgmt`) greifen sie nicht, und die fallen auf den conditional
forward `[/bf.emsenhuber.net/] -> 10.40.20.1` zurueck — den Core. Der hielt
aber nur 10 Statics; 12 Namen existierten nur als AGH-Rewrite und waren damit
**NXDOMAIN**. Vom Client gemessen 2026-09-22: alle 12 leer, `vw.emsenhuber.net`
lieferte die Cloudflare-Anycast-IP statt `.40` — und genau daher kam der
404 auf `/admin` (der CF-Weg setzt `CF-Connecting-IP`, der `@cf_admin`-Guard
antwortet darauf mit 404).
Dieser Job bringt die Statics auf das Geraet. Danach beantwortet der Core alle
internen Namen unabhaengig vom Filterstatus des Clients — und, weil die Namen
ueber Caddy laufen, mit gueltigem TLS.
⚠️ **Die 5 bare Namen (`vw`, `hass`, `n8n`, `gitea`, `ds-bf`) kommen bewusst mit
auf `.40`** (Christoph-Entscheid 2026-09-22): der lokale, header-freie Weg ist
die einzige Tuer zum Vault-Admin. ⚠️ Nebenwirkung: diese Namen werden damit fuer
**alle** VLANs aufloesbar, nicht nur fuer MAIN — MEDIA (`*A`) und GUEST
(`*B`+`*A`) erreichen `.40:443` ebenfalls. Einhegung ist ein eigener Schritt.
Rückweg: die Vorher-Werte stehen als `A1`/`A2`-Tabellen im Plan
(`~/.claude/plans/…`); vor dem Deploy liegt ein Backup auf dem Core.
"""
lines = [_dns_upsert(ln) for ln in _dns_static_lines()]
# Abnahme-Zeile: die Zahl der Statics plus drei Stichproben, die den Ausfall
# repraesentieren (ap3 gab es vorher nirgends, pve zeigt auf Caddy, vw muss
# `.40` sein — nicht die CF-Anycast-IP).
lines.append(
':do { :log info ("tp45-c-dns: statics=" . [:tostr [:len [/ip dns static find]]] '
'. " ap3=" . [:tostr [:len [/ip dns static find where name=ap3.bf.emsenhuber.net]]] '
'. " vw=" . [/ip dns static get [find where name=vw.emsenhuber.net] address] '
'. " pve=" . [/ip dns static get [find where name=pve.bf.emsenhuber.net] address]) } '
f'on-error={{ :log info "tp45-c-dns: Abnahme-Zeile geworfen" }}')
return [(["crs326-core"], "\n".join(lines), "TP45-c-dns")]
JOBS = {
"drift-cleanup": job_drift_cleanup,
"mgmt-acl": job_mgmt_acl,
"tp42-dhcp-domain": job_tp42_dhcp_domain,
"tp38-oob": job_tp38_oob,
"tp45-agh-fw": job_tp45_agh_fw,
"tp45-agh-dhcp": job_tp45_agh_dhcp,
"tp45-c-dns": job_tp45_c_dns,
"tp45-pool-v20": job_tp45_pool_v20,
"tp45-v30-leases": job_tp45_v30_leases,
"tp45-v30-revert": job_tp45_v30_revert,
"tp45-v30-restore": job_tp45_v30_restore,
}

View File

@@ -61,7 +61,7 @@ import urllib.error
import urllib.parse
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans

199
nautobot/gc-snapshot.py Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
TP36-Live — Live-Gerätedaten als CustomFields auf den RouterOS-dcim.Device.
Legt (idempotent, get-or-create) fünf CustomFields auf `dcim.device` an und
befüllt sie für jedes RouterOS-Gerät. Die Werte kommen AUSSCHLIESSLICH aus
Nautobot selbst — es wird KEIN Gerät kontaktiert:
ros_version aus der GoldenConfig-`backup_config` ("by RouterOS X.Y.Z")
dhcp_server_count gezählte Einträge der Sektion "/ip dhcp-server" im Backup
dns_upstream_mode aus der "/ip dns"-Sektion des Backups: doh | plain | none
compliance_pct GC config-compliance: compliant / total * 100 (gerundet)
mgmt_ip device.primary_ip4 (ohne /prefix)
Warum aus Nautobot statt live vom Gerät: der MGMT-/REST-Weg (VLAN99:80) ist
nicht aus jedem Vantage erreichbar; die GC-Backups werden täglich (03:30 UTC,
ScheduledJob "GC Backup+Compliance – taeglich") per SSH gezogen und sind die
Doku-SSoT. Damit braucht der Snapshot keine Geräte-Credentials. Aktualität steht
in `backup_last_success_date` — ist die alt, steht der Backup-Schedule (2026-09-12
war er 8 Tage aus, weil es vorher gar keinen gab).
Der Backup-Config-Text wird nur INTERN geparst (Zähler/Modus abgeleitet) und
NIE ausgegeben — er kann gescrubte Reste enthalten (PSK-Leak-Regel).
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
Schlüssel NAUTOBOT_SUPERUSER_API_TOKEN.
Aufruf:
python3 nautobot/gc-snapshot.py # Dry-Run (default) — zeigt nur an
python3 nautobot/gc-snapshot.py --commit # legt CustomFields an + patcht Devices
"""
import json
import os
import re
import sys
import urllib.request
import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv
def load_token() -> str:
for line in YML.read_text().splitlines():
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
if m:
return m.group(1)
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
TOKEN = load_token()
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
"Content-Type": "application/json"}
def req(method: str, path: str, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(f"{NB}{path}", data=data, headers=HDRS, method=method)
try:
with urllib.request.urlopen(r, timeout=20) as resp:
return resp.status, json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "{}")
# --------------------------------------------------------------- CustomFields
# (key, label, type, description). content_types wird ergänzt.
CUSTOM_FIELDS = [
("ros_version", "RouterOS-Version", "text",
"RouterOS-Version aus der GC-backup_config (by RouterOS …)."),
("compliance_pct", "GC-Compliance %", "integer",
"Golden-Config config-compliance: compliant/total in Prozent."),
("dhcp_server_count", "DHCP-Server", "integer",
"Anzahl Einträge der Sektion /ip dhcp-server im GC-Backup."),
("mgmt_ip", "Mgmt-IP", "text",
"device.primary_ip4 ohne Prefix (VLAN99-Management)."),
("dns_upstream_mode", "DNS-Upstream-Modus", "text",
"Aus /ip dns des GC-Backups: doh | plain | none."),
]
CF_KEYS = [c[0] for c in CUSTOM_FIELDS]
def ensure_custom_fields():
"""get-or-create die fünf CFs; bei Bestand: content_type dcim.device sicherstellen."""
st, d = req("GET", "/api/extras/custom-fields/?limit=0")
have = {r["key"]: r for r in d.get("results", [])}
for key, label, typ, desc in CUSTOM_FIELDS:
ex = have.get(key)
if ex:
cts = ex.get("content_types", [])
ok = "dcim.device" in cts
print(f" = CF '{key}' existiert ({ex['id']}){'' if ok else ' ⚠ ohne dcim.device!'}")
if not ok and COMMIT:
req("PATCH", f"/api/extras/custom-fields/{ex['id']}/",
{"content_types": sorted(set(cts) | {"dcim.device"})})
print(f" + dcim.device ergänzt")
continue
payload = {"key": key, "label": label, "type": typ, "description": desc,
"content_types": ["dcim.device"], "grouping": "TP36-Live",
"required": False}
if not COMMIT:
print(f" + CF '{key}' ({typ}) [DRY-RUN]")
continue
st, r = req("POST", "/api/extras/custom-fields/", payload)
print(f" {'+' if st in (200,201) else '!'} CF '{key}'"
+ (f" angelegt ({r.get('id')})" if st in (200, 201)
else f" FEHLER {st}: {json.dumps(r)[:200]}"))
# --------------------------------------------------------------- Backup-Parser
def parse_backup(cfg: str):
"""Werte aus dem flachen RouterOS-Export (eine '/…'-Zeile pro Kommando)."""
ver = ""
m = re.search(r"by RouterOS ([0-9][0-9A-Za-z.\-]*)", cfg)
if m:
ver = m.group(1)
# DHCP-Server = Zeilen '/ip dhcp-server add …' (NICHT lease/network/option)
dhcp = len(re.findall(r"(?m)^\s*/ip dhcp-server add\b", cfg))
# DNS-Modus aus der '/ip dns set …'-Zeile
mode = "none"
md = re.search(r"(?m)^\s*/ip dns set\b.*", cfg)
if md:
line = md.group(0)
doh = re.search(r"use-doh-server=([^\s]+)", line)
srv = re.search(r"\bservers=([^\s]+)", line)
if doh and doh.group(1) not in ("", '""'):
mode = "doh"
elif srv and srv.group(1) not in ("", '""'):
mode = "plain"
return ver, dhcp, mode
def compliance_pct(device_name: str):
q = urllib.parse.urlencode({"device": device_name, "limit": 0, "depth": 0})
st, d = req("GET", f"/api/plugins/golden-config/config-compliance/?{q}")
rows = d.get("results", [])
if not rows:
return None
ok = sum(1 for r in rows if r.get("compliance"))
return round(ok / len(rows) * 100)
# ---------------------------------------------------------------------- main
def routeros_devices():
q = urllib.parse.urlencode({"limit": 0, "depth": 1, "platform": "RouterOS"})
st, d = req("GET", f"/api/dcim/devices/?{q}")
devs = []
for r in d.get("results", []):
ip = r.get("primary_ip4")
ip = ip.get("address") if isinstance(ip, dict) else ip
if not ip:
continue # tote Altgeräte (cudy/sw) ohne IP überspringen
devs.append((r["id"], r["name"], ip.split("/")[0]))
return sorted(devs, key=lambda x: x[1])
def main():
print(f"== TP36-Live gc-snapshot ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
print("\n[1] CustomFields sicherstellen")
ensure_custom_fields()
print("\n[2] Golden-Config-Backups laden")
st, gc = req("GET", "/api/plugins/golden-config/golden-config/?depth=1&limit=0")
backups = {}
for r in gc.get("results", []):
dev = r.get("device")
dev = dev.get("name") if isinstance(dev, dict) else dev
backups[dev] = (r.get("backup_config") or "", r.get("backup_last_success_date") or "?")
print("\n[3] Snapshot je Gerät")
print(f" {'Gerät':16} {'ros':9} {'cmpl%':>5} {'dhcp':>4} {'dns':6} {'mgmt-ip':15} backup")
for dev_id, name, mgmt in routeros_devices():
cfg, when = backups.get(name, ("", "—"))
ver, dhcp, dns = parse_backup(cfg) if cfg else ("?", 0, "?")
pct = compliance_pct(name)
vals = {
"ros_version": ver or None,
"compliance_pct": pct,
"dhcp_server_count": dhcp,
"mgmt_ip": mgmt,
"dns_upstream_mode": dns,
}
pct_s = "—" if pct is None else str(pct)
print(f" {name:16} {ver or '?':9} {pct_s:>5} {dhcp:>4} {dns:6} {mgmt:15} {when[:10]}")
if COMMIT:
st, r = req("PATCH", f"/api/dcim/devices/{dev_id}/", {"custom_fields": vals})
if st not in (200, 201):
print(f" ! PATCH FEHLER {st}: {json.dumps(r)[:200]}")
print(f"\n== fertig ({'COMMIT — geschrieben' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
if __name__ == "__main__":
main()

View File

@@ -55,7 +55,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HIER)
AKTEN = os.path.join(REPO, "docs", "transaktionen")
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}

View File

@@ -2,7 +2,7 @@
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
import json, os, sys, urllib.parse, urllib.request, urllib.error
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ["NAUTOBOT_TOKEN"]
DRY = "--apply" not in sys.argv
log = []

View File

@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
"""
import json, os, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -20,7 +20,7 @@ PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet).
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
"""
import json, os, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -6,7 +6,7 @@ Quelle: ap1 live via MikroMCP + docs/teilprojekte.md.
"""
import json, sys, urllib.request, urllib.error
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
import os
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
DRY = "--apply" not in sys.argv

View File

@@ -27,7 +27,7 @@ import pathlib
import sys
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
DEVICE = "crs326-core"
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"

View File

@@ -11,7 +11,7 @@ Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null).
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -14,7 +14,7 @@ Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null).
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
ComplianceRule `dhcp-lease` anlegen (idempotent).
Die Luecke, die das schliesst
-----------------------------
`nautobot/mikrotik_routeros.j2` rendert seit dem 2026-09-21 den Abschnitt
`/ip dhcp-server lease` (TP45 Schicht B, aus `local_config_context_data.dhcp_leases`).
Gerendert heisst aber noch nicht bewacht: ohne ComplianceRule erzeugt der Block
**keine** Compliance-Eintraege, und die Gesamtzahl bliebe unauffaellig. Genau die
Falle aus CLAUDE.md — "eine gleichbleibende Compliance-Zahl nach dem Hinzufuegen
eines Geraets ist ein BEFUND, kein Erfolg".
Konkret: von 54 live existierenden Reservierungen war vorher **keine einzige**
drift-ueberwacht. Verschiebt jemand eine Adresse von Hand am Geraet, meldet
Golden Config "compliant", weil die Sektion unter keiner Rule steht.
Analogie: TP26 fuer `/ip firewall filter` (25 Regeln blind), `ingest-queue-rule.py`
fuer `/queue`, `ingest-wifi-access-list-rule.py` fuer `/interface wifi access-list`,
`ingest-logging-rule.py` fuer `/system logging`.
export NAUTOBOT_TOKEN=... # aus docs/nas-vlan20/stacks/210-nautobot.yml
python3 nautobot/ingest-dhcp-lease-rule.py # Dry-Run
python3 nautobot/ingest-dhcp-lease-rule.py --apply
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv
FEATURE = "dhcp-lease"
# ⚠️ Nautobot begrenzt die Feature-Beschreibung auf 200 Zeichen (400 sonst).
BESCHREIBUNG = ("TP45 Schicht B: DHCP-Reservierungen (/ip dhcp-server lease). Bis 2026-09-21 "
"unter keiner Rule -- die 54 Reservierungen waren drift-blind. Gleicher "
"blinder Fleck wie TP26 fuer /ip firewall filter.")
# Praefix, nicht die ganze Sektion: `/ip dhcp-server lease` faengt nur die Reservierungen.
# Ein blankes `/ip dhcp-server` wuerde die Server-, Network- und Option-Zeilen mitfangen,
# die eigene Rules haben (bzw. haben sollen).
MATCH = "/ip dhcp-server lease"
PLATTFORM = "RouterOS"
def req(method, path, data=None):
url = path if path.startswith("http") else BASE + path
body = json.dumps(data).encode() if data is not None else None
r = urllib.request.Request(url, data=body, method=method)
r.add_header("Authorization", "Token " + TOKEN)
r.add_header("Content-Type", "application/json")
r.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(r, timeout=60) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
sys.exit("!! %s %s -> %s\n %s" % (method, url, e.code, e.read().decode()[:600]))
def eins(pfad, **filter):
q = urllib.parse.urlencode(filter)
res = req("GET", "%s?%s" % (pfad, q)).get("results", [])
if len(res) > 1:
sys.exit("FEHLER: %s?%s liefert %d Treffer, erwartet 0 oder 1" % (pfad, q, len(res)))
return res[0] if res else None
def main():
if not TOKEN:
sys.exit("NAUTOBOT_TOKEN fehlt.")
print("Modus:", "APPLY" if APPLY else "DRY-RUN (nichts wird geschrieben)")
plat = eins("/dcim/platforms/", name=PLATTFORM)
if not plat:
sys.exit("Plattform %r nicht gefunden." % PLATTFORM)
print("Plattform : %s (%s)" % (plat["display"], plat["id"]))
feat = eins("/plugins/golden-config/compliance-feature/", name=FEATURE)
if feat:
print("Feature : existiert bereits (%s)" % feat["id"])
else:
print("Feature : anlegen -> %s" % FEATURE)
if APPLY:
feat = req("POST", "/plugins/golden-config/compliance-feature/",
{"name": FEATURE, "slug": FEATURE, "description": BESCHREIBUNG})
print(" angelegt (%s)" % feat["id"])
if not feat:
print("\nDry-Run: ohne Feature kann die Rule nicht geprueft werden. Mit --apply erneut.")
return
rule = eins("/plugins/golden-config/compliance-rule/",
feature=feat["id"], platform=plat["id"])
if rule:
print("Rule : existiert bereits (%s), match_config=%r, config_ordered=%s"
% (rule["id"], rule.get("match_config"), rule.get("config_ordered")))
if rule.get("match_config") != MATCH:
print(" ⚠️ match_config weicht ab -- erwartet %r" % MATCH)
if rule.get("config_ordered"):
print(" ⚠️ config_ordered=True -- erwartet False (s. u.)")
return
nutzlast = {
"feature": feat["id"],
"platform": plat["id"],
"description": BESCHREIBUNG,
# 🔴 False, NICHT True: die Reservierungs-Sektion steht am Geraet in
# Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet), nicht numerisch.
# Bei config_ordered=True waere jede Umsortierung durch RouterOS ein
# Falsch-Drift. Die Reihenfolge ist hier auch semantisch bedeutungslos —
# anders als bei /ip firewall filter, wo sie ueber Gewinnen/Verlieren entscheidet.
"config_ordered": False,
"config_type": "cli",
"match_config": MATCH,
"custom_compliance": False,
"config_remediation": False,
}
print("Rule : anlegen -> %s auf %s, match_config=%r, config_ordered=False"
% (FEATURE, PLATTFORM, MATCH))
if APPLY:
r = req("POST", "/plugins/golden-config/compliance-rule/", nutzlast)
print(" angelegt (%s)" % r["id"])
else:
print(" Nutzlast: %s" % json.dumps(nutzlast))
if __name__ == "__main__":
main()

View File

@@ -32,7 +32,7 @@ Jinja-Template denselben String emittieren kann (sonst Dauer-Diff in der Complia
"""
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -38,7 +38,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -29,7 +29,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -35,7 +35,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -36,7 +36,7 @@ UMGEKEHRT GILT: die `vlanXX`-Descriptions auf dem Core NICHT anfassen, die lande
"""
import json, os, sys, urllib.request, urllib.error, urllib.parse
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv
log = []

105
nautobot/ingest-tp38-oob.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
r"""TP38: OOB-Port + Input-Firewall als Daten nach Nautobot schreiben.
Zwei getrennte Aenderungen, ein Skript:
1. BRANCH (crs326-branch): `firewall_filter` im local_config_context_data ANLEGEN
(8 Input-Regeln, deny-by-default). Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12
spiegelt bewusst das `available-from` der /ip service-Zeilen — kein neues
Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
2. CORE (crs326-core): EINE Regel "TP38: OOB->core mgmt" in die bestehende
firewall_filter-Liste EINFUEGEN (vor log/drop), damit der OOB-Port (untagged
ether23, VLAN99) auch den Core selbst verwalten kann.
Attributreihenfolge = alphabetisch, exakt wie RouterOS `/export` sie schreibt
(gleiche Konvention wie ingest-core-firewall.py).
python3 nautobot/ingest-tp38-oob.py # Dry-Run
python3 nautobot/ingest-tp38-oob.py --apply
"""
import json
import os
import sys
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
BRANCH_RULES = [
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
'add action=drop chain=input comment="TP38: input deny-by-default"',
]
CORE_NEW_RULE = 'add action=accept chain=input comment="TP38: OOB->core mgmt" dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp'
def nb(path, method="GET", payload=None):
tok = os.environ.get("NAUTOBOT_TOKEN") or sys.exit("NAUTOBOT_TOKEN nicht gesetzt")
req = urllib.request.Request(
f"{NB}{path}", method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Token {tok}", "Content-Type": "application/json",
"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def get_device(name):
return nb(f"/api/dcim/devices/?name={name}&depth=1")["results"][0]
def patch_device(dev, ctx, label):
dev_id = dev["id"]
nb(f"/api/dcim/devices/{dev_id}/", "PATCH", {"local_config_context_data": ctx})
print(f" ~ {label} geschrieben.")
def main():
apply_ = "--apply" in sys.argv
# --- BRANCH ---
branch = get_device("crs326-branch")
bctx = dict(branch.get("local_config_context_data") or {})
if bctx.get("firewall_filter") == BRANCH_RULES:
print("== branch: firewall_filter unveraendert ==")
else:
bctx["firewall_filter"] = BRANCH_RULES
print(f"== branch: {len(BRANCH_RULES)} Input-Regeln ==")
for ln in BRANCH_RULES:
print(" " + ln)
if apply_:
patch_device(branch, bctx, "branch firewall_filter")
# --- CORE ---
core = get_device("crs326-core")
cctx = dict(core.get("local_config_context_data") or {})
ff = list(cctx.get("firewall_filter") or [])
if CORE_NEW_RULE in ff:
print("\n== core: Regel schon vorhanden ==")
else:
# vor der Log-Regel einsortieren (die sitzt direkt vor dem finalen drop).
insert_at = len(ff)
for i, ln in enumerate(ff):
if "log-prefix=TP5-INPUT-WOULD-DROP" in ln:
insert_at = i
break
ff.insert(insert_at, CORE_NEW_RULE)
cctx["firewall_filter"] = ff
print(f"\n== core: fuege OOB-Regel an Position {insert_at} ein ==")
print(" " + CORE_NEW_RULE)
if apply_:
patch_device(core, cctx, "core firewall_filter")
if not apply_:
print("\nDRY-RUN — mit --apply schreiben. Danach: "
"push-gc-template.py + gc-configplan.py --refresh-compliance")
if __name__ == "__main__":
main()

View File

@@ -68,7 +68,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv

View File

@@ -30,7 +30,7 @@ import urllib.error
import urllib.parse
import urllib.request
BASE = "http://10.40.20.30:8080/api"
BASE = "http://10.40.20.154:8080/api"
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv

View File

@@ -36,7 +36,7 @@
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
{%- set vlans = [
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
{"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"},
{"id": 20, "name": "INFRA", "pool": "10.40.20.200-10.40.20.249", "lease": "1d"},
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
@@ -73,9 +73,11 @@
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
/interface bridge port add bridge=bridge comment=defconf interface=ether15
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
{% for n in range(17, 25) %}
{% for n in range(17, 23) %}
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
{% endfor %}
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
/interface bridge port add bridge=bridge comment=defconf interface=ether24
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
@@ -94,7 +96,7 @@
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
# [1] INTERFACE-LISTS
@@ -132,10 +134,29 @@
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
{% endfor %}
{% for v in vlans %}
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
{% endfor %}
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
# [5b] DHCP-RESERVIERUNGEN — kommen als DATEN aus local_config_context_data.dhcp_leases,
# wie firewall_filter. Warum das noetig war: bis 2026-09-21 rendert dieses Template
# KEIN einziges `/ip dhcp-server lease`. Die 54 Reservierungen stammen aus dem
# Einmal-Skript routeros/tp21-core-dhcp-from-ap1.rsc (Core-Cutover 2026-08-06) und
# standen damit in KEINEM Soll — ein GC-Recovery von null haette einen Core ohne
# Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt. Und weil
# Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben.
# Dieselbe Klasse wie die handgelaufene `nautobot`-Adressliste (s. CLAUDE.md).
#
# 🔴 Zeilen sind RouterOS-/export-Form, alphabetisch, OHNE fuehrenden Pfad — genau
# wie firewall_filter. Gefuellt und verschoben von nautobot/tp45-leases.py.
# 🔴 Die REIHENFOLGE wird NICHT sortiert: das Backup traegt die Sektion in
# Einfuege-Reihenfolge (.30.150 frueh, .30.5 spaet), ein numerisch sortiertes
# Soll waere ein Dauer-Falsch-Drift. Die ComplianceRule steht deshalb auf
# config_ordered=no.
{% for line in obj.get_config_context().get("dhcp_leases", []) %}
/ip dhcp-server lease {{ line }}
{% endfor %}
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
@@ -222,10 +243,12 @@
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
# sec-main: ft=no seit 2026-09-04 (User-Entscheid nach TP25-Messung). Fast-Transition
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no.
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus).
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>"
# sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
# "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
# Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
# Fund-E-Abschlussmessung 09-02 ruhig (4 reauth/8,6 h, 0 Ausfaelle) auf 7.24.1.
# 2026-09-04 stand hier: ft=no ft-over-ds=no (User-Entscheid nach TP25-Messung).
/interface wifi security add authentication-types=wpa3-psk ft=yes ft-over-ds=yes name=sec-main passphrase="<<REDACTED>>"
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
@@ -239,11 +262,16 @@
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
# st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
# zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante). transition-threshold-time=5s
# statt 10s. NUR 5G: die drei 2G-Configs bleiben auf st-main (unset=-80), weil 2.4G draussen
# (Terrasse/ausserhalb der Wohnung) Reichweite behalten soll. 8 dB Abstand = kleine Hysterese.
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main-5g
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main-5g
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main-5g
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
@@ -273,30 +301,60 @@
# python3 nautobot/gc-transaktion.py begin --device crs326-core \
# --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT
#
# 🔴 Hier landet TP25 **Fund E** (`signal-range`-Boden gegen klebende Clients),
# sobald er entschieden ist. Die alte Auflage lautete "vor dem Eintragen den
# Sticky-Fall erneut messen" -- das ist am 2026-08-30 passiert: dasselbe iPhone
# machte 16 Bandwechsel in 20 Minuten bei -53..-81 dBm, erst an ap3, dann an ap2.
# Fund E ist damit WIEDER OFFEN.
# Eingetragen wird hier trotzdem NICHTS, und zwar bewusst: ein signal-range-Boden
# ist der invasivste der drei Hebel und kann Clients AUSSPERREN -- genau das war
# 08-22 das Gegenargument (in den schwachen Raeumen gibt es keinen besseren AP,
# auf den ein Boden steuern koennte). Erst kommen (1) `2g-probe-delay=yes` und
# (2) `ft-over-ds=no`, jeder einzeln und als Transaktion, mit dem seit 2026-08-30
# laufenden wireless-Logging als Messgrundlage. Siehe teilprojekte.md TP25 Fund E.
# 🔴 Stand 2026-09-14 (Terrasse-In-situ-Session): der `signal-range`-Boden gegen
# klebende Clients ist GEPRUEFT und GESCHEITERT -- `/interface wifi access-list`
# hat kein `band`-Property (Band-Scoping ginge nur ueber cap-wifiN-Interface-Namen,
# die bei jedem CAP-Rejoin wechseln), SSID-/MAC-Scope ohne Band träfe 2,4 GHz mit
# (Lockout-Risiko, Schlafzimmer -84). Der Boden ist damit NICHT umsetzbar; der
# Ersatz gegen die Call-Aussetzer ist `ft=yes ft-over-ds=yes` auf sec-main
# (2026-09-14 zurueckgesetzt, s. docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
# und handoff-20260914-tp25-ap2-insitu-terrassse.md): Roams laufen als nahtlose
# FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst
# mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14).
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich; ap3 ergaenzt 2026-09-22):
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 | ap3 .99.13
#
# 🔴 TP45 Schicht C (2026-09-22): DIESE Liste ist die EINZIGE Quelle fuer interne Namen.
# AGH wertet seine DNS-Rewrites nur im Filter-Pfad aus; fuer unfiltrierte Clients
# (dns-bypass-exempt, grp-infra/-media/-mgmt) greifen sie nicht — die landen hier.
# Hier fehlten 12 Namen, genau das war der FQDN-Ausfall.
# Merkregel: was in AGH als Rewrite steht, muss auch hier stehen.
/ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.13 comment="ap3 = reiner CAP (cAP ax)" name=ap3.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.167 name=macmini.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.100 name=pve.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.173 name=pve-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.10 name=nas.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.126 name=docker.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.20 name=platform-pg.bf.emsenhuber.net type=A
/ip dns static add address=10.40.10.6 comment="Brother MFC (VLAN10)" name=mfc.bf.emsenhuber.net type=A
/ip dns static add address=10.40.40.11 comment="TV (VLAN40)" name=tv.bf.emsenhuber.net type=A
# Caddy-Frontends (TP31): Dienste mit eigener Site laufen ueber den Reverse Proxy, damit das
# TLS-Zertifikat gueltig ist. Quelle: caddy/Caddyfile (live geprueft 2026-09-22).
/ip dns static add address=10.40.20.40 comment="caddy -> nautobot :8080" name=nautobot.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> pbs :8007" name=pbs.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> portainer :9443" name=portainer.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> ccu :80" name=ccu.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> iobroker :8081" name=iobroker.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> z2m25 :8080" name=z2m25.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> home-assistant mcp :9583" name=ha-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> n8n-mcp :3001" name=n8n-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> gitea-mcp :3000" name=gitea-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> pve :8006" name=pve.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> pve-mcp :3000" name=pve-mcp.bf.emsenhuber.net type=A
# Oeffentliche Namen (bare emsenhuber.net) — real via CF Access + Tunnel. Sie stehen hier NUR,
# damit LAN-Clients direkt auf Caddy kommen: der CF-Weg setzt CF-Connecting-IP, und der
# @cf_admin-Guard in caddy/Caddyfile antwortet darauf mit 404 (TP41 T2). Der lokale,
# header-freie Weg ist die einzige Tuer zum Vault-Admin (Christoph-Entscheid 2026-09-22).
/ip dns static add address=10.40.20.40 comment="vw via caddy (Vault-Admin nur lokal)" name=vw.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="hass via caddy" name=hass.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="n8n via caddy" name=n8n.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="gitea via caddy" name=gitea.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="ds-bf via caddy" name=ds-bf.emsenhuber.net type=A
# [7] NAT
/ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16
@@ -416,9 +474,20 @@
/system ntp client servers add address=10.40.99.1
/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1
# INPUT-FIREWALL (TP38, 2026-09-15) — deny-by-default fuer die Mgmt-Ebene (10.40.99.2).
# Der Branch ist L2-only: ALLE Mgmt-Anfragen kommen auf der einen vlan99-SVI an, egal ob
# VLAN10 (ueber den Core geroutet), cf-mesh (100.96.0.0/12) oder der OOB-Port (untagged
# ether23, 10.40.99.x). Deshalb matcht die Quelle per src-address, nicht per in-interface.
# Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12 spiegelt bewusst das `available-from` der
# /ip service-Zeilen — kein neues Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
{% for line in obj.get_config_context().get("firewall_filter", []) %}
/ip firewall filter {{ line }}
{% endfor %}
# L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort).
# ether9-23 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
# ether9-22 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
# bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird.
# ether23 ist seit TP38 der OOB-MGMT-Port (untagged VLAN99), s. unten.
/interface bridge port add bridge=bridge comment=defconf interface=ether1
/interface bridge port add bridge=bridge comment=defconf interface=ether2
/interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10
@@ -427,9 +496,10 @@
/interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40
/interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20
/interface bridge port add bridge=bridge comment=defconf interface=ether8
{% for n in range(9, 24) %}
{% for n in range(9, 23) %}
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20
{% endfor %}
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
/interface bridge port add bridge=bridge comment=defconf interface=ether24
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2
@@ -439,7 +509,7 @@
/interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40
/interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50
/interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge vlan-ids=99
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge untagged=ether23 vlan-ids=99
{% elif obj.role.name == "Wireless AP" %}
{% set ap = obj.get_config_context()["ap"] %}
{% set mgmt_host = obj.primary_ip4.host %}

View File

@@ -36,7 +36,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

View File

@@ -38,7 +38,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv
@@ -134,10 +134,10 @@ VMS = {
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
],
"nas-bf": [
("nautobot", "10.40.20.30/24", "Nautobot Web (DS923 ep14)"),
("nautobot-worker", "10.40.20.31/24", "Nautobot Golden-Config Worker"),
("nautobot-scheduler", "10.40.20.32/24", "Nautobot Scheduler"),
("nautobot-redis", "10.40.20.34/24", "Nautobot Redis"),
("nautobot", "10.40.20.154/24", "Nautobot Web (DS923 ep14)"),
("nautobot-worker", "10.40.20.155/24", "Nautobot Golden-Config Worker"),
("nautobot-scheduler", "10.40.20.156/24", "Nautobot Scheduler"),
("nautobot-redis", "10.40.20.158/24", "Nautobot Redis"),
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),

View File

@@ -29,7 +29,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"

View File

@@ -32,7 +32,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

View File

@@ -32,7 +32,7 @@ import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
r"""TP45 Schicht A — die AGH-DNS-Freigaben im Core-`firewall_filter`-Soll pflegen.
WAS HIER PASSIERT
-----------------
Die vier `TP5-E2: LAN->AGH dns*`-Regeln des Cores stehen als LITERALE im
`local_config_context_data.firewall_filter` (Nautobot) und werden vom GC-Template
nur gerendert. Zieht der Resolver um, muss das Soll mit — sonst meldet die
Compliance Drift, sobald das Geraet die neue Adresse traegt.
add <ip> [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den
bestehenden AGH-Block einsortiert
remove <ip> [...] genau diese zwei Regeln wieder entfernen (= Rueckbau)
WARUM KEIN `ingest-core-firewall.py`
------------------------------------
Das ist ein RE-BASELINE-Werkzeug: es liest den Ist-Stand vom Geraet und erklaert
ihn zum Soll. Fuer eine *geplante* Aenderung ist es das falsche Werkzeug — es
wuerde die Aenderung mit sich selbst absegnen. Hier wird das Soll explizit
gesetzt, das Geraet zieht per `gc-configplan.py --job tp45-agh-fw` nach.
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 # Dry-Run
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 --apply
REIHENFOLGE IST SEMANTISCH — die ComplianceRule steht auf `config_ordered`. Neue
Regeln landen hinter der bestehenden `.37`-tcp-Zeile, also weiterhin VOR der
`TP5: log-only`-Zeile und damit vor dem finalen `drop`.
"""
import difflib
import json
import os
import pathlib
import sys
import urllib.request
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
DEVICE = "crs326-core"
COMMENT_UDP = "TP5-E2: LAN->AGH dns"
COMMENT_TCP = "TP5-E2: LAN->AGH dns-tcp"
ANCHOR = 'comment="TP5-E2: LAN->AGH dns-tcp" dst-address='
def token():
tok = os.environ.get("NAUTOBOT_TOKEN")
if tok:
return tok
# Fallback: aus dem (gitignored) Stack-File, Wert wird nie ausgegeben.
yml = (pathlib.Path(__file__).resolve().parent.parent
/ "docs/nas-vlan20/stacks/210-nautobot.yml")
for line in yml.read_text(errors="replace").splitlines():
key, sep, val = line.strip().lstrip("- ").partition("=")
if sep and key.strip() == "NAUTOBOT_SUPERUSER_API_TOKEN":
return val.strip().strip("'\"")
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
def nb(path, method="GET", payload=None):
req = urllib.request.Request(
f"{NB}{path}", method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Token {token()}", "Content-Type": "application/json",
"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def rules_for(ip):
# Attributreihenfolge alphabetisch, exakt wie RouterOS /export sie schreibt —
# dieselbe Konvention wie ingest-core-firewall.py und das Template.
return [
f'add action=accept chain=forward comment="{COMMENT_UDP}" '
f'dst-address={ip} dst-port=53 protocol=udp',
f'add action=accept chain=forward comment="{COMMENT_TCP}" '
f'dst-address={ip} dst-port=53 protocol=tcp',
]
def is_agh(rule, ip):
return f"dst-address={ip}" in rule and "TP5-E2: LAN->AGH dns" in rule
def main():
argv = sys.argv[1:]
if len(argv) < 3 or argv[0] not in ("add", "remove"):
raise SystemExit(__doc__.strip().splitlines()[-1])
mode = argv[0]
ips = [a for a in argv[1:] if not a.startswith("--")]
apply_ = "--apply" in argv
dev = nb(f"/api/dcim/devices/?name={DEVICE}")["results"][0]
ctx = dict(dev.get("local_config_context_data") or {})
lines = list(ctx.get("firewall_filter") or [])
if not lines:
raise SystemExit("firewall_filter ist leer — falsches Geraet?")
new = list(lines)
for ip in ips:
present = [l for l in new if is_agh(l, ip)]
if mode == "add":
if present:
print(f" {ip}: schon vorhanden ({len(present)} Regeln) — uebersprungen")
continue
# hinter die letzte bestehende AGH-Zeile einsortieren
idx = max((i for i, l in enumerate(new) if ANCHOR in l), default=None)
if idx is None:
raise SystemExit(f" kein AGH-Anker ({ANCHOR}...) im Soll gefunden")
new[idx + 1:idx + 1] = rules_for(ip)
print(f" {ip}: +2 Regeln nach Index {idx}")
else:
if len(present) != 2:
raise SystemExit(f" {ip}: {len(present)} statt 2 Regeln gefunden — Abbruch")
new = [l for l in new if not is_agh(l, ip)]
print(f" {ip}: -2 Regeln entfernt")
if new == lines:
print("\n = unveraendert, nichts zu tun.")
return
print(f"\n== Diff ({len(lines)} -> {len(new)} Zeilen) ==")
for d in difflib.unified_diff(lines, new, "IST", "SOLL", lineterm="", n=1):
print(" " + d[:170])
if not apply_:
print(f"\nDRY-RUN — mit --apply nach "
f"local_config_context_data.firewall_filter schreiben.")
return
ctx["firewall_filter"] = new
nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx})
print("\n ~ geschrieben. Geraet zieht nach per: "
"python3 nautobot/gc-configplan.py --job tp45-agh-fw --apply")
if __name__ == "__main__":
main()

131
nautobot/tp45-ip-move.py Normal file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
r"""TP45 Schicht A — primaere IP eines Nautobot-Gastes umziehen.
WARUM ES DIESES SKRIPT GIBT
---------------------------
Nautobot verbietet das Aendern einer IP-Adresse nach dem Anlegen
(`{"__all__": ["Host address cannot be changed once created"]}`). Ein Umzug
ist deshalb kein PATCH, sondern ein Dreischritt je Gast:
1. neues ipam.IPAddress am Ziel anlegen (Status/Namespace vom Altobjekt)
2. altes Objekt an die VM-Schnittstelle haengen und primary_ip4 umhaengen
3. altes Objekt loeschen
Von Hand ist das pro Gast drei API-Aufrufe in genau dieser Reihenfolge — bei
~15 Gaesten in Schicht A eine sichere Fehlerquelle. Reihenfolge ist zwingend:
wer das Altobjekt vor dem Umhaengen loescht, reisst die primary_ip4-Referenz.
⚠️ Die IP allein ist NICHT der ganze Umzug. Ein Gast, den der Core ueber eine
Firewall-Adressliste erreicht (z.B. `nautobot`), braucht die neue IP ZUERST in
dieser Liste — sonst steht der Zugriff weg, bevor die Migration durch ist.
python3 nautobot/tp45-ip-move.py # Dry-Run (Default)
python3 nautobot/tp45-ip-move.py --apply
"""
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
BASE = "http://10.40.20.154:8080/api"
APPLY = "--apply" in sys.argv
# (VM-Name, alte IP, neue IP) — Oktett-Klassen nach TP32, s. docs/teilprojekte.md ### TP45
# Charge 1 (2026-09-21, angewendet). Die Altobjekte sind weg — ein erneuter Lauf
# bricht darum sauber ab ("0 Treffer"), er zieht nichts doppelt um.
MOVES = [
("nautobot", "10.40.20.30/24", "10.40.20.154/24"),
("nautobot-worker", "10.40.20.31/24", "10.40.20.155/24"),
("nautobot-scheduler", "10.40.20.32/24", "10.40.20.156/24"),
("nautobot-redis", "10.40.20.34/24", "10.40.20.158/24"),
]
def load_token() -> str:
for line in YML.read_text().splitlines():
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
if m:
return m.group(1)
raise SystemExit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML}")
TOK = load_token()
def api(path, method="GET", payload=None):
req = urllib.request.Request(
BASE + path, method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": f"Token {TOK}", "Content-Type": "application/json",
"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r) if r.status != 204 else {}
except urllib.error.HTTPError as e:
raise SystemExit(f"{method} {path} -> {e.code}: {e.read().decode()[:300]}")
def only(path, expect=1):
res = api(path)["results"]
if len(res) != expect:
raise SystemExit(f"{path}: {len(res)} Treffer, erwartet {expect}")
return res[0] if expect == 1 else res
def main():
print(f"== TP45 IP-Move ({'APPLY' if APPLY else 'DRY-RUN'}) ==")
for vm_name, old_ip, new_ip in MOVES:
print(f"\n-- {vm_name}: {old_ip} -> {new_ip} --")
old = only(f"/ipam/ip-addresses/?address={old_ip}")
vm = only(f"/virtualization/virtual-machines/?name={vm_name}")
ref = vm.get("primary_ip4")
ref = ref.get("id") if isinstance(ref, dict) else ref
if ref != old["id"]:
raise SystemExit(f" primary_ip4 von {vm_name} zeigt nicht auf {old_ip} "
f"(ist {ref}) — Abbruch, nichts geschrieben")
if api(f"/ipam/ip-addresses/?address={new_ip}")["count"]:
raise SystemExit(f" {new_ip} ist in Nautobot schon belegt — Abbruch")
print(f" Altobjekt={old['id'][:8]} primary_ip4 bestaetigt Ziel frei")
if not APPLY:
print(" [DRY-RUN] wuerde: anlegen -> eth0 verknuepfen -> primary_ip4 "
"umhaengen -> Altobjekt loeschen")
continue
# namespace ist am Altobjekt nicht serialisiert und beim Anlegen Pflicht
ns = old.get("namespace")
ns_id = (ns or {}).get("id") if isinstance(ns, dict) else ns
if not ns_id:
ns_id = only("/ipam/namespaces/?name=Global")["id"]
st = old.get("status")
new = api("/ipam/ip-addresses/", "POST",
{"address": new_ip,
"status": st["id"] if isinstance(st, dict) else st,
"namespace": ns_id, "dns_name": ""})
print(f" + angelegt {new['id'][:8]} @ {new['address']}")
vmif = api(f"/virtualization/interfaces/?virtual_machine={vm['id']}&name=eth0")["results"]
if vmif:
api("/ipam/ip-address-to-interface/", "POST",
{"ip_address": new["id"], "vm_interface": vmif[0]["id"]})
print(f" + verknuepft mit {vm_name}/eth0 ({vmif[0]['id'][:8]})")
else:
print(" ~ keine eth0-Schnittstelle — Verknuepfung uebersprungen")
api(f"/virtualization/virtual-machines/{vm['id']}/", "PATCH", {"primary_ip4": new["id"]})
print(f" + primary_ip4 umgehaengt")
api(f"/ipam/ip-addresses/{old['id']}/", "DELETE")
print(f" - Altobjekt {old_ip} geloescht")
print("\nFertig." if APPLY else "\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
if __name__ == "__main__":
main()

368
nautobot/tp45-leases.py Normal file
View File

@@ -0,0 +1,368 @@
#!/usr/bin/env python3
r"""TP45 Schicht B — die DHCP-Reservierungen des Cores ins Soll heben.
DIE LUECKE, DIE DAS SCHLIESST
-----------------------------
`nautobot/mikrotik_routeros.j2` rendert bis 2026-09-21 **kein einziges**
`/ip dhcp-server lease`. Die 54 statischen Reservierungen des Cores stammen aus dem
Einmal-Skript `routeros/tp21-core-dhcp-from-ap1.rsc` (Core-Cutover 2026-08-06) und
stehen in **keinem** Soll.
Folge: ein GC-Recovery von null erzeugt einen Core ohne Reservierungen — jedes
IoT-Geraet faellt in den Pool, und weil Compliance nur prueft, was gerendert wird,
bliebe die Zahl dabei gruen. Dieselbe Klasse wie die `nautobot`-Adressliste
(s. CLAUDE.md): "was auf dem Geraet existiert, aber nicht im Template, ist eine
Recovery-Luecke". Zusaetzlich war keine einzige Reservierung drift-ueberwacht.
DAS SOLL
--------
Die Zeilen liegen wie `firewall_filter` als LISTE von Strings in
`local_config_context_data.dhcp_leases` auf `crs326-core` und werden vom Template
mit `/ip dhcp-server lease {{ line }}` gerendert.
🔴 **Zeilenform ist die RouterOS-`/export`-Form, alphabetisch, ohne fuehrenden Pfad**
(genau wie `firewall_filter`):
add address=10.40.30.244 comment=NukiBridge mac-address=24:62:AB:B1:63:F4 server=dhcp-v30
🔴 **Die REIHENFOLGE wird erhalten, nicht sortiert.** Am Geraet steht die Sektion in
Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet) — `:export` gibt sie so aus, und
das Backup wird textuell verglichen. Ein numerisch sortiertes Soll waere ein
Dauer-Falsch-Drift.
🔴 **Zeilen werden NICHT neu gebaut, sondern uebernommen und nur im `address=`-Token
geaendert.** Damit bleiben Quoting (`comment="P750W / P750W"` vs `comment=NukiBridge`)
und die 14 `client-id`-Felder automatisch korrekt — beides nachzubauen waere eine
Fehlerquelle ohne Nutzen.
WARUM DER LESEWEG UEBER NAUTOBOT GEHT
-------------------------------------
Quelle fuer `seed` ist das GC-`backup_config` des Cores (taeglich 03:30 UTC bzw.
per `gc-configplan.py --refresh-compliance`). Kein Geraete-Credential noetig.
⚠️ `seed` ist damit ein RE-BASELINE und darf **nur** laufen, solange der Ist-Stand
der gewollte ist (Schicht B1). Fuer die geplante Aenderung gibt es `move`, das auf
dem **bestehenden Soll** rechnet — ein Soll, das dem Ist nachlaeuft, erkennt keine
Drift mehr (genau der Grund, warum `ingest-core-firewall.py` manuell bleibt).
python3 nautobot/tp45-leases.py check # Soll vs. Backup-Diff
python3 nautobot/tp45-leases.py seed # Dry-Run
python3 nautobot/tp45-leases.py seed --apply # B1
python3 nautobot/tp45-leases.py seed --force --apply # Neuschnitt, ueberschreibt
python3 nautobot/tp45-leases.py move # Dry-Run, s. MOVES
python3 nautobot/tp45-leases.py move --apply # B2
python3 nautobot/tp45-leases.py revert # Dry-Run, s. REVERT (nur Shellys)
python3 nautobot/tp45-leases.py revert --apply # Ruecknahme 2026-09-21
python3 nautobot/tp45-leases.py restore # Dry-Run: ALLES zurueck auf vor B2
python3 nautobot/tp45-leases.py restore --apply # Vollruecknahme 2026-09-21
Token: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), NAUTOBOT_SUPERUSER_API_TOKEN.
Der Wert wird nie ausgegeben.
"""
import json
import pathlib
import re
import sys
import urllib.error
import urllib.request
NB = "http://10.40.20.154:8080"
DEVICE = "crs326-core"
KEY = "dhcp_leases"
PREFIX = "/ip dhcp-server lease "
REPO = pathlib.Path(__file__).resolve().parent.parent
YML = REPO / "docs/nas-vlan20/stacks/210-nautobot.yml"
# ---------------------------------------------------------------------------
# Der Adressumzug (Schicht B2). Schluessel = MAC, Wert = (ist, soll).
# Quelle der Soll-Spalte: docs/teilprojekte.md ### TP45 / der Plan.
# Steht hier als Tabelle und nicht als Ableitung — der Umzug ist eine Absicht,
# kein Rechenergebnis.
# ---------------------------------------------------------------------------
MOVES = {
# fest verbaute Infrastruktur -> .10-.19
"24:62:AB:B1:63:F4": ("10.40.30.244", "10.40.30.11"), # NukiBridge
"4C:EB:BD:E3:25:F2": ("10.40.30.5", "10.40.30.13"), # P750W (Etikettendrucker)
"54:F2:9F:12:51:F0": ("10.40.30.8", "10.40.30.14"), # DTUBI-414392337823
# Shelly-Block .151-.176 (s1 -> .151 ... s26 -> .176)
"E4:B0:63:D7:C7:A4": ("10.40.30.208", "10.40.30.151"), # shelly-s1
"28:37:2F:36:F4:7C": ("10.40.30.209", "10.40.30.152"), # shelly-s2
"E4:B0:63:E4:EF:7C": ("10.40.30.210", "10.40.30.153"), # shelly-s3
"E4:B0:63:E4:0F:30": ("10.40.30.211", "10.40.30.154"), # shelly-s4
"D8:85:AC:0C:5C:C8": ("10.40.30.212", "10.40.30.155"), # shelly-s5
"D0:CF:13:D8:18:FC": ("10.40.30.213", "10.40.30.156"), # shelly-s6
"D0:CF:13:D9:94:E8": ("10.40.30.214", "10.40.30.157"), # shelly-s7
"E4:B0:63:FA:FD:E0": ("10.40.30.215", "10.40.30.158"), # shelly-s8
"E8:F6:0A:61:88:08": ("10.40.30.216", "10.40.30.159"), # shelly-s9
"E4:B0:63:FC:57:E4": ("10.40.30.217", "10.40.30.160"), # shelly-s10
"E4:B0:63:FA:6C:00": ("10.40.30.218", "10.40.30.161"), # shelly-s11
"48:F6:EE:C8:E6:AC": ("10.40.30.219", "10.40.30.162"), # shelly-s12
"48:F6:EE:DD:6A:F4": ("10.40.30.220", "10.40.30.163"), # shelly-s13
"E4:B0:63:FC:59:48": ("10.40.30.221", "10.40.30.164"), # shelly-s14
"E4:B0:63:FA:D5:34": ("10.40.30.222", "10.40.30.165"), # shelly-s15
"10:20:BA:FA:F5:40": ("10.40.30.223", "10.40.30.166"), # shelly-s16
"10:20:BA:FB:6D:64": ("10.40.30.224", "10.40.30.167"), # shelly-s17
"48:F6:EE:CC:16:E4": ("10.40.30.225", "10.40.30.168"), # shelly-s18
"10:20:BA:FB:70:FC": ("10.40.30.226", "10.40.30.169"), # shelly-s19
"98:A3:16:7C:5F:EC": ("10.40.30.206", "10.40.30.170"), # shelly-s20
"20:6E:F1:01:F9:F8": ("10.40.30.207", "10.40.30.171"), # shelly-s21
"A0:85:E3:C8:65:34": ("10.40.30.202", "10.40.30.172"), # shelly-s22
"AC:15:18:7B:62:20": ("10.40.30.203", "10.40.30.173"), # shelly-s23
"14:33:5C:D8:2D:7C": ("10.40.30.204", "10.40.30.174"), # shelly-s24
"98:A3:16:79:38:50": ("10.40.30.205", "10.40.30.175"), # shelly-s25
"98:A3:16:7A:A3:54": ("10.40.30.233", "10.40.30.176"), # shelly-s26 (bad luefter)
# App-Block .177-.199
"28:DB:A7:09:7B:54": ("10.40.30.241", "10.40.30.177"), # NukiSmartlock
"B8:F8:62:09:F2:04": ("10.40.30.227", "10.40.30.178"), # SwitchBot-floor-lamp
"98:F4:AB:2C:A7:88": ("10.40.30.228", "10.40.30.179"), # Twinkly-2CA789
"A4:F0:0F:5F:D0:EC": ("10.40.30.236", "10.40.30.190"), # lichterkette
"24:DC:C3:9B:89:64": ("10.40.30.237", "10.40.30.191"), # lichterkette-mini
"00:24:E4:40:2A:72": ("10.40.30.230", "10.40.30.195"), # Withings_waage
"A4:7E:FA:18:2C:64": ("10.40.30.231", "10.40.30.196"), # Withings_blutdruck
# .150 (Ender3V3KE) und .180 (espressif/Airfryer) bleiben, wo sie sind.
}
# ---------------------------------------------------------------------------
# RUECKNAHME 2026-09-21 (Christoph-Direktive, sofort): NUR die Shellys.
#
# Die 26 Shelly-Reservierungen gehen auf ihre ALTEN Adressen zurueck. Grund:
# Home Assistant hat sie mit den alten Adressen eingebunden -- der Umzug haette
# die HA-Integrationen zerrissen. Die uebrigen 10 Umzuege (NukiBridge, P750W,
# DTUBI, NukiSmartlock, SwitchBot, Twinkly, Lichterketten, Withings) BLEIBEN.
#
# Bewusst als eigene Menge und nicht als Bereichs-Ableitung: welche MAC eine
# Shelly ist, steht nur im Kommentar der Soll-Zeile, nicht im MOVES-Key.
# ---------------------------------------------------------------------------
SHELLY = {
"E4:B0:63:D7:C7:A4", "28:37:2F:36:F4:7C", "E4:B0:63:E4:EF:7C",
"E4:B0:63:E4:0F:30", "D8:85:AC:0C:5C:C8", "D0:CF:13:D8:18:FC",
"D0:CF:13:D9:94:E8", "E4:B0:63:FA:FD:E0", "E8:F6:0A:61:88:08",
"E4:B0:63:FC:57:E4", "E4:B0:63:FA:6C:00", "48:F6:EE:C8:E6:AC",
"48:F6:EE:DD:6A:F4", "E4:B0:63:FC:59:48", "E4:B0:63:FA:D5:34",
"10:20:BA:FA:F5:40", "10:20:BA:FB:6D:64", "48:F6:EE:CC:16:E4",
"10:20:BA:FB:70:FC", "98:A3:16:7C:5F:EC", "20:6E:F1:01:F9:F8",
"A0:85:E3:C8:65:34", "AC:15:18:7B:62:20", "14:33:5C:D8:2D:7C",
"98:A3:16:79:38:50", "98:A3:16:7A:A3:54",
}
if len(SHELLY) != 26:
raise SystemExit("SHELLY hat %d MACs statt 26 -- Tabelle beschaedigt." % len(SHELLY))
# Schluessel = MAC, Wert = (aktuelle Soll-Adresse, Adresse davor) -- also die
# Umkehrung von MOVES, aber nur fuer den Shelly-Block.
REVERT = {mac: (soll, ist) for mac, (ist, soll) in MOVES.items() if mac in SHELLY}
if len(REVERT) != 26:
raise SystemExit("REVERT hat %d Eintraege statt 26 -- MOVES/SHELLY passen nicht zusammen."
% len(REVERT))
# CCU-Leiche: VLAN30-Reservierung `pve-homematic-raspi-ccu`, status=waiting,
# last-seen=never. Die CCU ist live in VLAN20 (10.40.20.232, ARP+HTTP belegt,
# Caddys ccu.bf proxyt dorthin) — dieser Eintrag ist ein Zombie aus dem
# Core-Cutover. Er wird ENTFERNT, nicht umgezogen.
REMOVE = {"BC:24:11:8E:0A:57"}
# Die Zeile, mit der B2 die CCU-Leiche entfernt hat -- wortgleich der Zustand
# VOR B2, Quelle `routeros/tp21-core-dhcp-from-ap1.rsc` Z. 70 (dort ohne
# `disabled`). Fuer den vollstaendigen Rueckweg wird sie wieder angelegt.
RESTORE_ADD = ("add address=10.40.30.232 comment=pve-homematic-raspi-ccu "
"mac-address=BC:24:11:8E:0A:57 server=dhcp-v30")
# SONOFF-NSPanelPro hat KEINE Reservierung, nur eine dynamische Lease auf .249.
# Wird in B2 per make-static + set address gepinnt. Zeile hier nur als Soll.
ADDS = [
"add address=10.40.30.12 client-id=1:7c:88:99:6:7:ec comment=SONOFF-NSPanelPro "
"mac-address=7C:88:99:06:07:EC server=dhcp-v30",
]
def token():
for line in YML.read_text(errors="replace").splitlines():
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
if m:
return m.group(1)
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
def nb(path, method="GET", payload=None):
req = urllib.request.Request(
NB + path, method=method,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"Authorization": "Token " + token(),
"Content-Type": "application/json", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
sys.exit("!! %s %s -> %s\n %s" % (method, path, e.code, e.read().decode()[:600]))
def device():
return nb("/api/dcim/devices/?name=" + DEVICE)["results"][0]
def backup_lines():
"""Die `/ip dhcp-server lease`-Zeilen des Cores aus dem GC-Backup, in Geraete-Reihenfolge."""
rows = nb("/api/plugins/golden-config/golden-config/?depth=1&limit=0")["results"]
for r in rows:
dev = r.get("device")
dev = dev.get("name") if isinstance(dev, dict) else dev
if dev == DEVICE:
cfg = r.get("backup_config") or ""
when = (r.get("backup_last_success_date") or "?")[:19]
# 🔴 len(PREFIX), NICHT l[1:]: das Argument-Teil ist ohne den ganzen Befehl
# zu speichern — genau wie firewall_filter, wo `add action=…` steht und
# das Template `/ip firewall filter ` davorsetzt. Ein blosses l[1:] liess
# `ip dhcp-server lease add …` stehen und erzeugte im gerenderten Soll
# den Befehl DOPPELT (`/ip dhcp-server lease ip dhcp-server lease add …`).
# Gefunden am 2026-09-21 an der ersten Compliance-Zahl nach dem Einbau:
# 144/145 statt 145/145.
return ([l[len(PREFIX):] for l in cfg.splitlines() if l.startswith(PREFIX)], when)
raise SystemExit("kein GC-Eintrag für " + DEVICE)
def mac_of(line):
m = re.search(r"\bmac-address=(\S+)", line)
return m.group(1).upper() if m else ""
def set_address(line, ip):
new, n = re.subn(r"(?<=^add address=)\S+", ip, line)
if n != 1:
raise SystemExit("address-Token nicht eindeutig in: " + line)
return new
def show(old, new):
print("== Diff (%d -> %d Zeilen) ==" % (len(old), len(new)))
om, nm = {mac_of(l): l for l in old}, {mac_of(l): l for l in new}
for mac in sorted(set(om) | set(nm)):
a, b = om.get(mac), nm.get(mac)
if a == b:
continue
if a is None:
print(" + %s" % b[:150])
elif b is None:
print(" - %s" % a[:150])
else:
print(" ~ %s -> %s" % (re.search(r"address=(\S+)", a).group(1),
re.search(r"address=(\S+)", b).group(1)))
print(" %s" % b[:150])
def main():
argv = sys.argv[1:]
if not argv or argv[0] not in ("seed", "move", "revert", "restore", "check"):
raise SystemExit(__doc__.strip().splitlines()[-2].strip())
cmd, apply_ = argv[0], "--apply" in argv
dev = device()
ctx = dict(dev.get("local_config_context_data") or {})
soll = list(ctx.get(KEY) or [])
if cmd == "check":
ist, when = backup_lines()
print("== check: %s / %s (Backup %s, %d Zeilen) ==" % (DEVICE, KEY, when, len(ist)))
if not soll:
print(" Soll ist LEER — Reservierungen stehen in keinem Soll.")
return
show(soll, ist)
print(" (Diff oben: '-' = nur im Soll, '~' = Adresse weicht ab)")
return
if cmd == "seed":
if soll and "--force" not in argv:
raise SystemExit("Soll ist NICHT leer (%d Zeilen) — `seed` ist ein Re-Baseline. "
"Fuer eine geplante Aenderung `move` benutzen; fuer einen "
"bewussten Neuschnitt `--force`." % len(soll))
new, when = backup_lines()
print("== seed aus Backup %s (%d Zeilen)%s =="
% (when, len(new), " [--force, ueberschreibt %d Zeilen]" % len(soll) if soll else ""))
elif cmd == "restore":
if not soll:
raise SystemExit("Soll ist leer — nichts wiederherzustellen.")
drop = {mac_of(a) for a in ADDS}
new, seen, moved, dropped = [], set(), [], []
for line in soll:
mac = mac_of(line)
if mac in drop:
dropped.append(mac)
continue
if mac in MOVES:
ist_ip, soll_ip = MOVES[mac]
cur = re.search(r"address=(\S+)", line).group(1)
if cur != ist_ip:
if cur != soll_ip:
raise SystemExit("%s: Soll traegt weder %s noch %s — Soll, MOVES und "
"Wirklichkeit auseinander. Zeile: %s"
% (mac, ist_ip, soll_ip, line[:120]))
line = set_address(line, ist_ip)
moved.append(mac)
seen.add(mac)
new.append(line)
fehlend = set(MOVES) - seen
if fehlend:
raise SystemExit("MOVES nennt MACs, die im Soll fehlen: %s"
% ", ".join(sorted(fehlend)))
for mac in sorted(REMOVE):
if mac not in seen:
new.append(RESTORE_ADD)
moved.append(mac)
print("== restore: %d Adressen zurueck, %d Zeilen entfernt, %d wieder angelegt "
"(Soll jetzt %d Zeilen) ==" % (len(moved), len(dropped), len(REMOVE), len(new)))
elif cmd == "revert":
if not soll:
raise SystemExit("Soll ist leer — es gibt nichts zurueckzunehmen.")
new, seen = [], set()
for line in soll:
mac = mac_of(line)
if mac in REVERT:
neu_ip, alt_ip = REVERT[mac]
if re.search(r"address=(\S+)", line).group(1) != neu_ip:
raise SystemExit("%s: Soll traegt nicht %s — Soll und REVERT auseinander. "
"Zeile: %s" % (mac, neu_ip, line[:120]))
line = set_address(line, alt_ip)
seen.add(mac)
new.append(line)
if seen != set(REVERT):
raise SystemExit("REVERT nennt MACs, die im Soll fehlen: %s"
% ", ".join(sorted(set(REVERT) - seen)))
print("== revert: %d Shelly-Reservierungen zurueck auf die alten Adressen =="
% len(REVERT))
else:
if not soll:
raise SystemExit("Soll ist leer — erst `seed` laufen lassen (Schicht B1).")
new, unknown = [], set()
seen = set()
for line in soll:
mac = mac_of(line)
if mac in REMOVE:
seen.add(mac)
continue
if mac in MOVES:
ist_ip, soll_ip = MOVES[mac]
if re.search(r"address=(\S+)", line).group(1) != ist_ip:
raise SystemExit("%s: Ist-Adresse im Soll ist nicht %s — Soll und MOVES "
"auseinander. Zeile: %s" % (mac, ist_ip, line[:120]))
line = set_address(line, soll_ip)
seen.add(mac)
new.append(line)
for mac in list(MOVES) + list(REMOVE):
if mac not in seen:
unknown.add(mac)
if unknown:
raise SystemExit("MOVES/REMOVE nennen MACs, die im Soll fehlen: %s"
% ", ".join(sorted(unknown)))
for add in ADDS:
if mac_of(add) not in {mac_of(l) for l in new}:
new.append(add)
print("== move: %d Adressen verschoben, %d entfernt, %d ergaenzt =="
% (len(MOVES), len(REMOVE), len(ADDS)))
show(soll, new)
if not apply_:
print("\nDRY-RUN — mit --apply nach local_config_context_data.%s schreiben." % KEY)
return
ctx[KEY] = new
nb("/api/dcim/devices/%s/" % dev["id"], "PATCH", {"local_config_context_data": ctx})
print("\n ~ %d Zeilen nach local_config_context_data.%s geschrieben." % (len(new), KEY))
print(" Geraet zieht nach per: python3 nautobot/gc-configplan.py --refresh-compliance")
if __name__ == "__main__":
main()

75
opencode.json Normal file
View File

@@ -0,0 +1,75 @@
{
"mcp": {
"home-assistant": {
"type": "remote",
"url": "{env:MCP_HOME_ASSISTANT_URL}",
"enabled": false
},
"hostinger-api": {
"type": "local",
"command": [
"npx",
"-y",
"hostinger-api-mcp"
],
"environment": {
"HOSTINGER_API_TOKEN": "{env:MCP_HOSTINGER_API_TOKEN}"
},
"enabled": false
},
"mikromcp": {
"type": "local",
"command": [
"npx",
"-y",
"mikromcp",
"serve"
],
"environment": {
"MIKROMCP_CONFIG_PATH": "/Users/christophemsenhuber/dev/bf-infra/mcp/mikromcp/config/routers.yaml",
"MIKROMCP_DEFAULT_ROUTER": "crs326-core",
"ROUTER_AP1_USER": "admin",
"ROUTER_AP2_USER": "admin",
"ROUTER_AP3_USER": "admin",
"CRS326_CORE_USER": "admin",
"CRS326_BRANCH_USER": "admin",
"ROUTER_AP1_PASS": "{env:MCP_MIKROMCP_ROUTER_AP1_PASS}",
"ROUTER_AP2_PASS": "{env:MCP_MIKROMCP_ROUTER_AP2_PASS}",
"ROUTER_AP3_PASS": "{env:MCP_MIKROMCP_ROUTER_AP3_PASS}",
"CRS326_CORE_PASS": "{env:MCP_MIKROMCP_CRS326_CORE_PASS}",
"CRS326_BRANCH_PASS": "{env:MCP_MIKROMCP_CRS326_BRANCH_PASS}"
},
"enabled": false
},
"cloudflare": {
"type": "remote",
"url": "https://mcp.cloudflare.com/mcp",
"enabled": true
},
"ollama": {
"type": "local",
"command": [
"npx",
"-y",
"ollama-mcp"
],
"environment": {
"OLLAMA_HOST": "http://127.0.0.1:11434"
},
"enabled": false
},
"pve-mcp": {
"type": "remote",
"url": "http://10.40.20.173:3000/",
"enabled": true
},
"n8n-mcp": {
"type": "remote",
"url": "http://10.40.20.167:3001/mcp",
"headers": {
"Authorization": "Bearer {env:N8N_MCP_BEARER}"
},
"enabled": true
}
}
}

View File

@@ -1,65 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CLAUDE.md"],
"permission": "allow",
"mcp": {
"gitea-mcp": {
"type": "remote",
"url": "http://10.40.20.167:3000/mcp",
"enabled": true
},
"mikromcp": {
"type": "local",
"command": ["npx", "-y", "mikromcp", "serve"],
"environment": {
"MIKROMCP_CONFIG_PATH": "/Users/christophemsenhuber/dev/bf-infra/mcp/mikromcp/config/routers.yaml",
"MIKROMCP_DEFAULT_ROUTER": "ap1"
},
"enabled": true,
"timeout": 30000
},
"pve-mcp": {
"type": "remote",
"url": "http://10.40.20.173:3000/",
"enabled": true,
"timeout": 15000
},
"home-assistant": {
"type": "remote",
"url": "http://10.40.20.105:9583/private_29X8x73taoNEq6gzmbVdyg",
"enabled": true,
"timeout": 15000
},
"cloudflare": {
"type": "remote",
"url": "https://mcp.cloudflare.com/mcp",
"enabled": true,
"timeout": 15000,
"oauth": {}
},
"portainer": {
"type": "local",
"command": ["uvx", "--from", "mcp-portainer~=2.42.0", "mcp-portainer"],
"environment": {
"PORTAINER_URL": "https://10.40.20.126:9443",
"PORTAINER_API_KEY": "{env:PORTAINER_API_KEY}",
"PORTAINER_TLS_VERIFY": "0"
},
"enabled": true,
"timeout": 15000
},
"shelly-mcp": {
"type": "remote",
"url": "http://10.40.20.180:3030/mcp",
"headers": {},
"enabled": true,
"timeout": 15000
}
},
"command": {
"handoff": {
"description": "Session-Übergabe generieren oder laden",
"template": "Du bist das Handoff-Tool für das BF-Infra-Projekt.\n\n1. Ermittle, was zu tun ist:\n - Lese `docs/handoff-*.md` und finde die neueste Datei (nach Datum).\n - Wenn `$ARGUMENTS` \"resume\" oder einen Dateinamen enthält: lade die genannte oder neueste Handoff-Datei.\n - Wenn `$ARGUMENTS` leer ist oder \"generate\" enthält: generiere eine neue Handoff-Datei.\n\n2. Generieren (`/handoff`):\n - Lese `docs/teilprojekte.md` (Quick-Index-Block, Zeilen ~174-200) für den aktuellen TP-Status.\n - Prüfe offene Config-Transaktionen: `bash` mit `python3 nautobot/gc-transaktion.py status`.\n - Schreibe `docs/handoff-YYYYMMDD-thema.md` mit: Titel, Ausgangslage (was erledigt, was läuft), offene Schritte, Fallen/Gotchas, Einstieg für die nächste Session.\n - Sag dem User, welche Datei erstellt wurde.\n\n3. Laden (`/handoff resume`):\n - Lese die neueste Handoff-Datei vollständig.\n - Gib Zusammenfassung: Thema, Stand, offene Schritte.\n - Frage: \"Soll ich die Arbeit aus dieser Handoff-Datei fortsetzen?\"\n\nWichtig: Keine Secrets in die Handoff-Datei schreiben. Verweise auf existierende Doku statt zu duplizieren. Ziel < 200 Zeilen."
}
}
}

View File

@@ -7,7 +7,7 @@ import pathlib
import re
import urllib.request
NAUTOBOT_URL = "http://10.40.20.30:8080"
NAUTOBOT_URL = "http://10.40.20.154:8080"
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")