61 Commits

Author SHA1 Message Date
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
614025eafb TP43 ✅ umgesetzt: dw-FritzBox-DNS auf FB-Upstream .36/.37 + Rebind-Ausnahme
Christoph on-site: Upstream 10.40.20.36 + .37, DNS-Rebind-Schutz-Ausnahme
bf.emsenhuber.net, Fallback-Haken "öffentliche NS bei Unreachbarkeit der
primären" gesetzt (existiert auf diesem FRITZ!OS-Stand) → Ideal-Variante,
.37-Redundanz bleibt, BF-down fällt auf public. Gitea #32 closed (status:done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 14:59:30 +02:00
677dade184 TP43: DHCP-Multi verworfen (FB-DHCP nur 1 DNS-IP) → FB-Upstream .36/.37 + Rebind-Ausnahme
dw-FritzBox kann per DHCP nur eine DNS-IP ausliefern → "mehrere NS an die
Clients" scheidet aus. Neue Richtung: Clients → 10.95.10.1 (FB), die FB
forwardet an Upstream 10.40.20.36 + .37 (beide BF, Voll-Resolver).

- Zwingend: DNS-Rebind-Schutz-Ausnahme bf.emsenhuber.net (sonst filtert die FB
  die Privat-IP-Antworten 10.40.x als Rebind weg).
- BF-down-Fallback: FRITZ!OS-"öffentliche NS bei Unreachbarkeit"-Setting, sonst
  public im Alternativ-Slot (kostet .37, dann strikt-primär verifizieren).
- Sauberer als DHCP-Multi: FB ist einziger Client-Resolver, .36 löst
  intern+public → public nie "zuerst" gefragt, kein NXDOMAIN-Leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 14:25:53 +02:00
b20edd6b62 TP43 reopened: interne BF-Namen über WG am dw — Entscheidung DHCP-Multi [.36/.37/public]
Auslöser: portainer.bf.emsenhuber.net vom dw-Standort (WG-Tunnel up) nicht
auflösbar — dw-FritzBox kennt die interne Zone bf.emsenhuber.net nicht.

- Gitea #32 reopened (status:todo, thema:firewall-dns), Prosa aus dem Archiv
  zurück nach teilprojekte.md (Archiv = ♻️-Zeiger).
- fm-Einwand live entkräftet (dig @.36): bf-Namen → 10.40.0.0/16 (über WG
  erreichbar), fm-Namen → Cloudflare-Anycast, nie 10.101.x.
- Entscheidung: FritzBox verteilt per DHCP die DNS-Liste .36/.37/9.9.9.9;
  .37-Redundanz bleibt, FB entlastet. NXDOMAIN-Failover-Haken + zwei
  On-site-Verifikationen dokumentiert. Umsetzung = eigene On-site-Session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 13:53:41 +02:00
a38ed0fe1d TP41 (✅) + TP43 (❌) + TP42 (✅) ins Archiv; TP41-Titel entschärft
- TP41 abgeschlossen: Gelis WARP via Android eingerichtet (letzter Blocker weg)
- TP43 verworfen: 420 = public CF-Worker, kein BF-Split-Horizon nötig; keine Altlasten
- TP42-Prosa nachgezogen (war closed in Gitea, Prosa stand noch in teilprojekte.md)
- Status-SSoT-Änderungen in Gitea gesetzt (#30 done, #32 dropped, #31 title-Fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 12:19:51 +02:00
5a29c482bd TP44 abgeschlossen: Prosa ins Archiv verschoben
Volltext TP44 (✅ DURCH) nach teilprojekte-archiv.md (Anker #tp44 + TOC-Zeile),
aus teilprojekte.md entfernt, Control-Plane-Themenzeile + gebrochenen intra-file
Link auf teilprojekte-archiv.md#tp44 gefixt. Gitea #33 war bereits closed/status:done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 11:03:45 +02:00
4eba3673b7 TP44: Überholt-Zeiger auf falsche CF_API_TOKEN-Gotcha (Handoff 09-02)
Die Zeile "Repo-CF_API_TOKEN darf kein Workers-VPC" war falsch (live: liest
14/14). Handoff als Snapshot belassen, aber Korrektur-Hinweis ergänzt. Damit
ist die letzte Wiederholung der stale Gotcha markiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 10:58:18 +02:00
2b856d3d42 TP44 Teil B durch: Prosa auf ✅ + Handoff (Sync live, täglicher Job)
- teilprojekte.md: TP44-Status ✅ DURCH; Teil-B-Ergebnis (Live-Sync als
  täglicher Nautobot-Job, 4/14, 0 Drift); Drift-Korrektur der falschen
  "CF_API_TOKEN kann kein VPC"-Gotcha (live: liest 14/14).
- handoff-20260910: Root-Cause (Wurzel-__init__.py für walk_packages),
  register_jobs im __init__, has_sensitive_variables, VPC service_id-Fix,
  terpene-db service_id-Reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 11:15:13 +02:00
f1d565bc88 TP44 Teil B: Meta.has_sensitive_variables=False — Job schedulebar machen
Nautobot verweigert das Scheduling von Jobs mit has_sensitive_variables
(Default True): "Job may have sensitive input variables". Der Job nimmt nur
ein Boolean (dryrun); das CF-Token stammt aus dem Nautobot-Secret, nicht aus
einer Job-Variable — daher unbedenklich abschaltbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 11:12:56 +02:00
43c592e5d4 TP44 Teil B: CF-Sync-Job — VPC service_id-Feld + None-Match-Guard
Dry-Run-Befund: CloudService 'terpene-db' (VPC) wurde faelschlich als stale
markiert und fremde extra_config in 'App Launcher' gemischt. Ursache: VPC-
Directory-Services tragen ihren Identifier in "service_id", der Job las "id"
(=None). Ueber .get(mk)==None matchte der None-Wert das erste Objekt ohne den
Key. Fix: VPC nutzt v["service_id"]; _upsert ueberspringt mv=None generell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 11:09:30 +02:00
33b18a08db TP44 Teil B: __init__.py in Repo-Wurzel — macht bf_infra für Nautobots Job-Loader sichtbar
Root-Cause des "No jobs were registered on loading the bf_infra.jobs submodule":
Nautobot 3.1.7 lädt Git-Repo-Jobs via import_modules_privately() ->
pkgutil.walk_packages([GIT_ROOT]). walk_packages entdeckt nur reguläre Pakete
(mit __init__.py), keine Namespace-Packages. Ohne Wurzel-__init__.py ist der
geklonte Ordner GIT_ROOT/bf_infra/ unsichtbar -> jobs/ wird nie importiert.
Live verifiziert: __init__.py da -> registry["jobs"] enthält CFEstateSync.
bf-infra ist das erste Git-Repo mit extras.job, daher nie zuvor aufgetreten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 11:06:28 +02:00
799b693ffa TP44 Teil B: register_jobs() ins Paket-__init__ (Git-Repo-Job registriert jetzt)
Nautobot importiert von einer Git-Datenquelle nur das Paket bf_infra.jobs und
erwartet register_jobs() im __init__ — der Call im Submodul blieb unsichtbar
(Sync-Log: "No jobs were registered on loading the bf_infra.jobs submodule").
register_jobs(CFEstateSync) daher ins __init__ verschoben, redundanten Call am
Ende von cf_estate_sync.py entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 10:51:02 +02:00
4aeced30b5 CLAUDE.md: terpene-Zeile korrigiert — Terpenfinder = 420.teilzeitladen.xyz
terpene.* existiert nicht (0 DNS in allen Zonen, live 2026-09-10) und soll es
nicht; die öffentliche App ist der CF-Worker 420.teilzeitladen.xyz (blaueblüten).
Alte Zeile nannte terpene.teilzeitladen.xyz:8890 via Tunnel bf-mm — bf-mm ist
gelöscht. Einziger terpene-CF-Gegenstand: interner Tunnel terpene-db (f2d2fc8a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 10:13:00 +02:00
89d94dbd45 TP44 Teil B: jobs/__init__.py — Job-Modul importieren, damit register_jobs greift
Nautobot laedt aus einer Git-Datenquelle nur das jobs-Paket; ohne __init__.py,
das cf_estate_sync importiert, wird kein Job registriert (Sync-Warnung
'No jobs were registered on loading the bf_infra.jobs submodule').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:50:33 +02:00
6f60e03679 TP44 Teil B: CF-Estate-Sync als Nautobot-Job (ORM, aus Git-Datenquelle)
jobs/cf_estate_sync.py — kanonische Runtime des CF→Nautobot-Sync als
geplanter Nautobot-Job im vorhandenen nautobot-worker (kein neuer
Container, keine Nautobot-Exposure). Portierung der Reconcile-Logik aus
nautobot/tp44-cf-sync.py von REST auf ORM:
- ID-Matching (tunnel_id/app-id/script/service_id), extra_config-Merge
- Prefix-Assignments live aus teamnet/routes (subsumiert Seeder [7])
- Verwaisung = stale-Markierung statt Loeschen, Scope-sicher uebersprungen
- CF-Token aus Nautobot-Secret 'cf-api-token', dryrun-BooleanVar default True

Import im Worker verifiziert (IMPORT_OK), CF-Egress vom Worker bestaetigt.
Governance: periodische externe→Nautobot-Syncs = Jobs hier, kein per-Task-Container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:47:55 +02:00
2f4866ca9a TP44 Teil B: CF-API→Nautobot-Sync (WIP, Dry-Run verifiziert)
nautobot/tp44-cf-sync.py — periodischer Live-Pull der CF-Estate nach
Nautobot (ersetzt den Inline-Snapshot des Seeders). Reuse: api() aus
caddy/cf-sync.py, Nautobot-Helper des Seeders.

- ID-Matching (tunnel_id/app-id/script/service_id) statt Name → rename-fest,
  keine Duplikate
- Merge statt Replace → kuratierte extra_config-Keys bleiben
- Verwaisung: extra_config.stale=true statt Hard-Delete; sicher
  uebersprungen, solange Token-Scope unvollstaendig
- 403-tolerant: deckt die 15 lesbaren Objekte driftfrei ab, zieht
  Hyperdrive/VPC automatisch mit, sobald Token-Scope da ist

Dry-Run: 11 Services unveraendert, 4 Networks Enrichment, 0 Duplikate.
Offen: Token-Scope (Hyperdrive+VPC) erweitern, dann Full-Sync + Cron-Host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 20:43:35 +02:00
8e41268b4b TP44 Etappe 2 Teil A durch: IPAM-Prefixe live, Prosa nachgezogen
10.40.0.0/16 (container, Route bf-core-mesh) + 10.101.0.0/24 (network,
Route fm) in Nautobot IPAM angelegt; Seeder-Re-Run hat die zwei
cloud-network-prefix-assignments eingehängt (count=2, idempotent).
Gitea #33 kommentiert, bleibt WIP (Teil B CF-Sync offen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 19:50:58 +02:00
b915d02a82 TP44 Etappe 2 Teil A: Seeder für die zwei fehlenden IPAM-Prefixe
nautobot/tp44-ipam-prefixes.py — idempotenter get-or-create für
10.40.0.0/16 (container, Route bf-core-mesh) und 10.101.0.0/24
(network, Route fm.emsenhuber.net). Dry-Run default, --commit schreibt.
Nach dem Anlegen zieht tp44-cf-estate-seed.py Schritt [7] die zwei
cloud-network-prefix-assignments automatisch ein. Muster:
ingest-baseline.py / tp37-substrat-seed.py. Noch nicht gegen Nautobot
ausgeführt (nur Dry-Run verifiziert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 19:48:26 +02:00
efb03979a0 TP34 verworfen: Prosa-Abschnitt ins Archiv verschoben
Caddyfile-via-GC-Ansatz zurückgezogen (Gitea #24 status:dropped, closed).
Caddyfile bleibt manuell in caddy/Caddyfile. Volltext-Abschnitt aus
teilprojekte.md ins Archiv gezogen (fortschreiben-nicht-überschreiben:
datierter Verworfen-Nachtrag oben, Konzepttext unverändert darunter),
plus TOC-Zeile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 16:31:22 +02:00
268fd859ab TP44: cf-tunnel-mapping §0 Live-Kopf + §10-16 als ARCHIV eingeklappt
Doku-Drift behoben: §10-15 beschrieben das 3-Tunnel-HA bf-mm/bf-nas/bf-pve,
das seit dem TP16.3-Cutover (2026-08-27) nicht mehr existiert und eine
Folgesession zu halluzinierten Tunneln/FQDNs verleitet hatte. Neuer §0 mit
dem live gegen die CF-API gemessenen Ist-Stand (bf-public/fm/terpene-db +
warp_connector bf-core-mesh, WARP-Routen); §10-16 in <details> ARCHIV
gekapselt (Inhalt unverändert), §17 (terpene-db, aktuell) bleibt sichtbar;
§14 Mesh-live-Notiz. Deckt sich mit dem Nautobot-Seed (TP44).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 15:50:31 +02:00
7a20a1db4f TP37 P2 vervollständigt: +6 live-verifizierte Smart-Home-ipam.Services
Fehlende exponierte Dienste ergänzt (RaspberryMatic 80/443/2001/2010,
iob-master 8081, z2m25 8080). Jeder Port per Socket-Probe belegt (nicht
aus Doku geraten); nc -z lieferte falsch-negativ und wurde verworfen.
iob-exposed/iob_slave bewusst ohne Service (kein Port gefunden).
Nautobot: 20 -> 26 ipam.Services, idempotent. Compliance 140/140.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 15:12:33 +02:00
bca8f6e27e TP34 zurückgezogen: Caddyfile via GC verworfen
Session lief auf veralteten Annahmen (Hostname terpene.* statt 420.*, zweiter
CF-Token erfunden) — entgegen dem User-Constraint "raten verboten". 420.teilzeitladen.xyz
ist ein Cloudflare-Worker (Projekt blaueblueten), nicht ein Caddy-Backend; :8890 gehoert
zu terpene.* via Tunnel bf-mm. Caddyfile war netto unveraendert, nur die beiden neuen
Skripte werden entfernt. Live LXC 130 ist bereits Baseline (e96fca5), Nautobot fuer TP34
unberuehrt (nur Dry-Run). Uebergabe: ~/.claude/plans/tp37-p2-seeden-dienste-silly-lerdorf.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 14:00:02 +02:00
d63dc7bb78 TP34: 420.teilzeitladen.xyz-Caddy-Block wieder entfernt
teilzeitladen.xyz liegt NICHT im emsenhuber.net-CF-Account (live geprueft: der
CF_DNS_API_TOKEN sieht nur die Zone emsenhuber.net, teilzeitladen.xyz count=0).
Damit kann Caddy auf LXC 130 kein DNS-01-Zert fuer den Namen holen -> Dauer-ACME-
Fehlerschleife. 420.teilzeitladen.xyz bleibt beim Cloudflare-Worker (cf-tunnel-mapping §17).

Ein Caddy-Backend fuer 420 braucht einen DNS-Token aus dem teilzeitladen.xyz-CF-Account
(blaueblueten-Projekt) — separat, nicht Teil von bf-infra. Seeder/Renderer bleiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 13:36:33 +02:00
b200401c5a TP34: 420.teilzeitladen.xyz korrigiert (war terpene.*) + eigener DNS-Token
- caddy/Caddyfile: Hostname 420.teilzeitladen.xyz (nicht terpene.*); eigener
  per-site tls-Block mit CF_DNS_API_TOKEN_TEILZEITLADEN, damit der enge
  emsenhuber.net-ACME-Token unangetastet bleibt (Least-Privilege)
- nautobot/tp34-caddy-cf-seed.py: veraltete terpene.*-Referenz im Kommentar korrigiert

Deploy erst nach Hinterlegen des teilzeitladen.xyz-Tokens in /etc/caddy/caddy.env
(Zone:DNS:Edit nur teilzeitladen.xyz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 13:30:30 +02:00
67 changed files with 3901 additions and 879 deletions

4
.gitignore vendored
View File

@@ -1,10 +1,6 @@
.DS_Store .DS_Store
.secrets .secrets
# Per-Projekt-MCP-Configs: können Tokens/URLs enthalten, projekt-spezifisch, nicht ins Repo
.mcp.json
# Python bytecode # Python bytecode
__pycache__/ __pycache__/
*.pyc *.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}"
}
}
}
}

View File

@@ -6,6 +6,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
## Pflichtlektüre pro Sitzung (Reihenfolge) ## Pflichtlektüre pro Sitzung (Reihenfolge)
0. 🔴 **TP45 = aktuell dringendster Posten (2026-09-21, Gitea #71):** interne `*.bf.emsenhuber.net`
lösen im Browser **nicht** auf (AGH wertet Rewrites im **Filter-Pfad** aus → für unfiltrierte
Clients übersprungen → Fallback auf den Core, der nur 10 von 26 Namen hält), und `ds-bf` ist
**öffentlich kaputt** (einziger Ingress-Eintrag, der Caddy umgeht → TP5-Drop). Reihenfolge:
VLAN20 ins TP32-Schema → VLAN30-Pool-Grenze → DNS/FQDN. Ausführlich: `### TP45`.
**`### TP46`** = CF-/Tunnel-SSoT + Recovery (eigene Baustelle).
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:*`. 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.)*
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat. 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`**. 3. Bei Live-Writes oder Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/teilprojekte-archiv.md`**.
@@ -20,6 +26,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`, 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 **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). entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen).
⚠️ **Rueckgenommen am 2026-09-14 (Terrasse-In-situ-Session): `ft=yes ft-over-ds=yes` zurueck** — mit
`ft=no` war jeder Roam eine Voll-Reassoziation, darin starben 2 Anrufe; Rueckkehrstand = Fund-E-Known-Good
von 09-02 (ruhig auf 7.24.1). Weg: Template + `push-gc-template.py` + `gc-configplan.py
--refresh-compliance` + `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed) → **140/140, 0 Drifts**;
CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands:
`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`, s. Handoff 2026-09-14.
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf 🔴 **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 `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) security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm)
@@ -53,11 +65,11 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
- **`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**. - **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header. - **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell. - **`admin/bf-golden-config`** (Gitea) ist Bot-Master, nicht manuell. 🔴 **Korrigiert 2026-09-12:** die frühere Angabe „2×/Tag" war nie real — es gab **keinen** Scheduler, Backup/Intended/Compliance liefen nur manuell aus Sessions (zuletzt 09-04, 8 Tage Stillstand). Jetzt **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. - **`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 seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer.
- **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen. - **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen.
- **terpene.teilzeitladen.xyz**: Worker läuft auf Mac mini (`10.40.20.167:8890`), **kein Caddy-Block, kein CF-Tunnel** — Backfill via DB-Migration blockiert (Handoff 2026-08-27). - **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. - **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`. - **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`.
@@ -98,15 +110,15 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|---|---|---| |---|---|---|
| ~~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.) | | ~~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 | | ~~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-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 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** (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 | | **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² #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`) · ✅ **aktiv am Sideboard** (Wohnzimmer; Uplink `ether1` → core `ether3`, PoE-Port `poe-out=off`) — Kanäle `5260/Ce` (Band B, DFS, Fallback 5280) + `2412`/ch1, vom Manager · 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` | | 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** — **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** (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` | | **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` · **Technik OG**, Uplink `ether1` → **branch `ether24`** (Trunk) · Kanäle **`5180/ax`** (Band A, non-DFS, 5 GHz) + **`2437`** (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. | | **🔑 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. |
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) | | 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`** | | 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) | | Nautobot (Portainer-Stack, NAS) | **10.40.20.154** (TP45 Schicht A 2026-09-21: `.30/.31/.32/.34` → `.154/.155/.156/.158`) | 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-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** | | ~~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` ✓ | | 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` ✓ |
@@ -144,6 +156,25 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans ⚠️ **`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). 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`. - **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
- 🔴 **`pct push` meldet Exit 0, auch wenn es die Quelldatei NICHT lesen kann** (gemessen 2026-09-21).
`pct push 130 <fehlende-datei> /etc/caddy/Caddyfile` → `failed to open … for reading`, **`rc=0`**.
Ein `&&`/`||` hinter dem Push **beweist nichts**, und ein anschließendes `validate`/`reload` prüft
dann die **alte** Datei → **grüne Abnahme bei unverändertem Zustand**. Genau so sah der
Nautobot-Umzug am 2026-09-21 „gelandet" aus, während live noch die alte Backend-IP stand
(Symptom: 502 bei gesundem Backend). **Regel: Push-Werkzeug wird geprüft, nicht geglaubt** — nach
jedem `pct push` den Inhalt vergleichen (`md5sum` diesseits/jenseits in *einem* Remote-Aufruf,
Abbruch bei Abweichung). Muster: `push_verified()` in `caddy/deploy.sh`. Dieselbe Klasse wie
`nc -z` (09-01) und das `grep|sed`-Maskierungsmuster (08-31): **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 des Cores für die Deploy-Pipeline läuft über `src-address-list=nautobot`
(Regel `*28`) — die Liste selbst stammt aber aus dem **handgelaufenen**
`routeros/tp5-etappe2-whitelists.rsc` und steht in **keinem** Template (`grep address-list` im
Template findet nur Kommentare). 🔴 **Ein GC-Recovery von null erzeugt damit einen Core, in den
Nautobot nicht mehr per SSH kommt** — die Pipeline wäre tot und könnte sich nicht selbst
reparieren. 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. Ablösung durch Komodo (native MCP) geplant.
--- ---
@@ -276,7 +307,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst. 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. - **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. - 📜 **~~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). - **ap3 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). ap2 hängt seit dem TP25-Tausch (2026-09-15) an `core ether21` (Technik UG), ap1 an `core ether3` (Sideboard).
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko). - **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. - **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 - 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät

18
__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""Macht die bf-infra-Repo-Wurzel zu einem regulären Python-Paket.
🔴 LOAD-BEARING — NICHT löschen (sonst laden die Nautobot-Jobs nicht mehr).
Nautobot klont dieses Repo nach GIT_ROOT/bf_infra/ und lädt Jobs über
`import_modules_privately()` → `pkgutil.walk_packages([GIT_ROOT])`. walk_packages
entdeckt ausschließlich *reguläre* Pakete (mit __init__.py), niemals
Namespace-Packages (Verzeichnisse ohne __init__.py). Ohne diese Datei ist
`bf_infra` für den Loader unsichtbar und `jobs/` wird nie importiert — Symptom:
Git-Sync SUCCESS, aber Log "No jobs were registered on loading the
`bf_infra.jobs` submodule" und kein Job-DB-Eintrag.
Der module_path-Filter des Loaders (["bf_infra","jobs"]) importiert nur
`bf_infra` + `bf_infra.jobs.*`; die übrigen Verzeichnisse (nautobot/, routeros/,
bin/, …) bleiben unangetastet und laufen weiter als Standalone-Skripte.
Verifiziert 2026-09-10 (Nautobot 3.1.7, Python 3.14) — TP44 Teil B.
"""

View File

@@ -71,7 +71,7 @@ dns:
ipset_file: "" ipset_file: ""
bootstrap_prefer_ipv6: false bootstrap_prefer_ipv6: false
upstream_timeout: 10s upstream_timeout: 10s
private_networks: [] private_<redacted>: []
use_private_ptr_resolvers: true use_private_ptr_resolvers: true
local_ptr_upstreams: [] local_ptr_upstreams: []
use_dns64: false use_dns64: false

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. `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 ## Sync-Invariante
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen, 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`. 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) | | Aktion | CLI | AGH-API (RMW) |
|---|---|---| |---|---|---|
| Domain global entsperren | `allow-domain <d>` | `GET /control/filtering/status` → +`@@\|\|<d>^` → `POST /control/filtering/set_rules` | | Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
| Entsperrung zurück | `unallow-domain <d>` | dito, Regel entfernen | | Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
| Client entfiltern | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` | | 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 | | 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 **Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
RouterOS-Backstop. RouterOS-Backstop.

View File

@@ -2,22 +2,37 @@
""" """
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .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 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) allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
unallow-domain <domain> → user_rules -= @@||domain^ unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false 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) refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
status → user_rules + Client-Policy beider Instanzen 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). 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) 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}$") 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"] INST = ["10.40.20.36", "10.40.20.37"]
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt. # 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") 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): def api(ip, path, method="GET", body=None):
url = f"http://{ip}{path}" url = f"http://{ip}{path}"
data = json.dumps(body).encode() if body is not None else None data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, req = urllib.request.Request(url, data=data, method=method,
headers={"Authorization": AUTH, headers={"Authorization": auth_header(),
"Content-Type": "application/json"}) "Content-Type": "application/json"})
try: try:
with urllib.request.urlopen(req, timeout=8) as r: 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}") 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() domain = domain.strip().lower()
if not DOMAIN_RE.match(domain): if not DOMAIN_RE.match(domain):
sys.exit(f"ungültige Domain: {domain!r}") sys.exit(f"ungültige Domain: {domain!r}")
r = rule(domain) r = scoped_rule(domain, client)
for ip in INST: for ip in INST:
st = api(ip, "/control/filtering/status") st = api(ip, "/control/filtering/status")
rules = st.get("user_rules", []) rules = st.get("user_rules", [])
if remove: 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: elif r not in rules:
rules = rules + [r] rules = rules + [r]
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules}) api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(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): def set_filter(ip_addr, enabled):
if not IP_RE.match(ip_addr): if not IP_RE.match(ip_addr):
sys.exit(f"ungültige IP: {ip_addr!r}") 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']}") 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__": if __name__ == "__main__":
a = sys.argv[1:] ap = argparse.ArgumentParser(add_help=False)
if not a: sys.exit(__doc__) ap.add_argument("cmd", nargs="?")
cmd = a[0] ap.add_argument("arg", nargs="?")
if cmd == "allow-domain": allow(a[1]) ap.add_argument("--client")
elif cmd == "unallow-domain": allow(a[1], remove=True) ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier
elif cmd == "unfilter": set_filter(a[1], False) ap.add_argument("--limit", type=int, default=20)
elif cmd == "refilter": set_filter(a[1], True) ap.add_argument("--json", action="store_true")
elif cmd == "status": status() a = ap.parse_args()
else: sys.exit(f"unbekannt: {cmd}\n{__doc__}")
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 > 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only,
temporär (JIT) über das AGH-Paar `.36`/`.37` zu entsperren. Ablauf/Revert nativ per `timer`. > 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 ## Ziel (User-Requirements 2026-09-12)
ganze Liste). HA ruft deshalb die deployte RMW-CLI `agh-exception.py` per `shell_command`. 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) | # | Requirement | Umsetzung |
| Typ | entity_id | Zweck |
|---|---|---| |---|---|---|
| input_text | `input_text.dns_ausnahme_domain` | einzugebende Domain | | 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs |
| input_text | `input_text.dns_ausnahme_aktiv` | aktuell aktive Ausnahme (für leak-freien Revert; init `""`) | | 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client <person>` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert |
| input_select | `input_select.dns_ausnahme_dauer` | 15 Minuten / 1 Stunde / 4 Stunden | | 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** |
| timer | `timer.dns_ausnahme` | JIT-Countdown (restore=false) | | 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar |
| 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 |
## `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 ```yaml
shell_command: shell_command:
agh_allow_domain: "python3 /config/agh-exception.py allow-domain '{{ domain }}'" agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'"
agh_unallow_domain: "python3 /config/agh-exception.py unallow-domain '{{ domain }}'" agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'"
agh_unfilter_client: "python3 /config/agh-exception.py unfilter '{{ ip }}'"
agh_refilter_client: "python3 /config/agh-exception.py refilter '{{ ip }}'"
``` ```
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) ## Helfer / Entities (via ha-mcp in .storage)
- `/config/agh-exception.py` — Kopie von `adguard/agh-exception.py` (liest Creds via `/config/agh-exception.secrets`). Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug:
- `/config/agh-exception.secrets` (chmod 600, root) — `AGH_AUTOMATION_USER`/`_PASS`. | Typ | entity_id | Zweck |
- 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"`. |---|---|---|
| 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 ## Dashboard — ein scoped View je Person
Domain eintragen → Dauer wählen → **DNS-Ausnahme starten**. Bei Ablauf/Abbruch wird die Regel auf - Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit
beiden AGH automatisch zurückgenommen. Ungültige Domains werden HA- **und** CLI-seitig abgelehnt. 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) ## Genehmigungs-Variante (Modus-Switch aus)
Eigenes storage-mode Dashboard `url_path: dns-ausnahmen` (Sidebar, `require_admin: false`), `input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen
angelegt via `ha_config_set_dashboard`. Da es in HA-`.storage` lebt, hier die Repro-Quelle: Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim
```json Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der
{"views":[{"title":"DNS-Ausnahmen","path":"dns","type":"sections","icon":"mdi:dns","max_columns":2,"sections":[ Switch entscheidet zur Laufzeit.
{"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.
## Verifiziert (2026-09-04) ## Alt-Reste, die beim Bau wegfallen/fixen
Button → `@@||doubleclick.net^` auf `.36`+`.37`, Timer 15:00, `_aktiv` gesetzt; Timer-Abbruch → - Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`,
Regel auf beiden entfernt, `_aktiv` geleert, `.36==.37`. `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

@@ -39,10 +39,6 @@ n8n.emsenhuber.net {
reverse_proxy http://10.40.20.167:5678 reverse_proxy http://10.40.20.167:5678
} }
terpene.teilzeitladen.xyz {
reverse_proxy http://10.40.20.167:8890
}
gitea.emsenhuber.net { gitea.emsenhuber.net {
reverse_proxy http://10.40.20.157:3000 reverse_proxy http://10.40.20.157:3000
} }
@@ -51,7 +47,7 @@ gitea.emsenhuber.net {
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40) # INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
# ========================================================================= # =========================================================================
nautobot.bf.emsenhuber.net { 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 # 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 exit 1
fi 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" echo "-> Caddyfile"
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.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})" 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" 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" 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,15 +2,39 @@
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector, > 🆕 **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 > 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`. > `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 > **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) > und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
> ist offen. > ist offen.
> **Aktueller Stand: → §10 (2026-07-10, TP16 UMGESETZT ✅).** §9 = Analyse/Zielbild, §10 = As-Built. > **Aktueller Stand: → §0 (unten).** §1–16 sind Historie/As-Built-Log früherer Sessions und beschreiben
> Abschnitte 1–8 sind Historie (Track A, 2026-06-30); die „3 Connectors gut/Zielbild"-Aussagen dort sind > Topologien, die **nicht mehr live** sind (v.a. das 3-Tunnel-HA `bf-mm`/`bf-nas`/`bf-pve`). §9 = Analyse,
> durch §9/§10 **überholt** (Round-Robin = macvlan-Host-Isolation → 1/N-Ausfälle). Track A aus `docs/handoff-cf-tunnel-1033.md`. > §10–15 = As-Built des inzwischen abgelösten 3-Tunnel-Designs. Track A aus `docs/handoff-cf-tunnel-1033.md`.
## §0 — Live-Ist-Stand (2026-09-08, maßgeblich)
> Der einzige aktuelle Topologie-Stand. **§1–16 nicht als aktuelle Konfiguration lesen** — insbesondere das
> 3-Tunnel-HA `bf-mm`/`bf-nas`/`bf-pve` (§10–15) existiert seit dem TP16.3-Cutover (bf-public/Caddy,
> 2026-08-27) **nicht mehr**. Strukturierte Estate = Nautobot (TP44, deckt sich mit diesem Stand);
> operativer Master = Cloudflare. Read-only gemessen gegen die CF-API (`cfd_tunnel`, `warp_connector`,
> `teamnet/routes`) am 2026-09-08.
**cloudflared-Tunnel (live) — es gibt genau drei:**
| Tunnel | ID | Conns | Zweck |
|---|---|---|---|
| **bf-public** | `48d3b0b6-7233-4781-a76a-1de4a5e957ce` | 2, healthy | **einziger öffentlicher BF-Tunnel** (TP31/TP16.3), erstellt 2026-08-27 |
| **fm.emsenhuber.net** | `b697d124-53bf-4a33-8fcf-384cced4db1a` | 1, healthy | Standort FM (`ds-fm`, `gateway-fm`), separat |
| **terpene-db** | `f2d2fc8a-54ae-41df-b09a-97312cdb9fe2` | 1, healthy | DB-Pfad (Hyperdrive→VPC-Service), Rebuild 2026-09-01 — s. §17 |
**bf-public-Ingress** → alle vier auf **Caddy `10.40.20.40:443`** (Caddy macht das Fan-out), catch-all 404:
`vw` · `hass` · `n8n` · `gitea` (`.emsenhuber.net`).
**WARP / Private Access — nativer Mesh, KEIN cloudflared:**
- warp_connector **bf-core-mesh** `d1da7c8a-2736-4cbb-8af1-32db5393fb9c`, healthy — nativer
WireGuard-Mesh-Node auf dem Core (TP16.1). **Live und produktiv** (nicht mehr „gated/Konzept" wie in §14).
- Routen: `10.40.0.0/16` → **bf-core-mesh** · `10.101.0.0/24` → **fm**. Ein vnet `vnetemsenhuber` (default).
## 1. Alle Tunnel im Account (`393c…66c36`) ## 1. Alle Tunnel im Account (`393c…66c36`)
| Tunnel | ID | Status | Conns | Verwendung | | Tunnel | ID | Status | Conns | Verwendung |
@@ -234,6 +258,9 @@ Der macmini ist vollwertiger dritter Knoten (nicht „vergessen").
--- ---
<details>
<summary>🗄️ <b>ARCHIV (§10–16)</b> — überholtes 3-Tunnel-HA <code>bf-mm</code>/<code>bf-nas</code>/<code>bf-pve</code>. Seit dem TP16.3-Cutover (2026-08-27) nicht mehr live — siehe §0. Aufklappen nur für die Historie.</summary>
## 10. AS-BUILT 2026-07-10 (TP16 umgesetzt) — reziprokes 3-Tunnel-HA ✅ ## 10. AS-BUILT 2026-07-10 (TP16 umgesetzt) — reziprokes 3-Tunnel-HA ✅
> Gebaut & verifiziert in eigener Session. Round-Robin-#1033 **strukturell weg** (Loop-Gegentest 0×530 über alle 19 Live-FQDNs, HA per Host-Ausfall-Sim bewiesen). Schritte 1–4+HA durch; **Schritt 5 (Access/WARP + `vw`) offen** (user-gated). > Gebaut & verifiziert in eigener Session. Round-Robin-#1033 **strukturell weg** (Loop-Gegentest 0×530 über alle 19 Live-FQDNs, HA per Host-Ausfall-Sim bewiesen). Schritte 1–4+HA durch; **Schritt 5 (Access/WARP + `vw`) offen** (user-gated).
@@ -337,6 +364,10 @@ Letzte in §9.2/§8 als „bleibt `.41`" geführte Origin-Klasse (`pve .41.100`,
## 14. Richtungsentscheid 2026-07-16 — Private Access via native Cloudflare Mesh (WARP Connector) auf dem CRS326 Core-Switch (TP16.1) ## 14. Richtungsentscheid 2026-07-16 — Private Access via native Cloudflare Mesh (WARP Connector) auf dem CRS326 Core-Switch (TP16.1)
> ✅ **Nachtrag 2026-09-08 (live):** umgesetzt. warp_connector **bf-core-mesh** (`d1da7c8a-…`) ist healthy
> und trägt die Route `10.40.0.0/16` — der „Scharfschalten = eigene Session, gated"-Status unten ist
> überholt. Siehe §0.
> ✅ **Update 2026-07-22 (User-Entscheid):** Ziel-Device retargeted von **ap1** auf den **CRS326 Core-Switch** — konsistent mit TP15-Endstand (kein RB5009, Router bleibt ap1/ap2) und TP21s Kabel-Fabric-Modell. Reine Ziel-Doku-Änderung, Scharfschalten bleibt gated bis TP21 den CRS326 abdeckt. Der Rest dieser Sektion ist entsprechend auf den neuen Zielhost umgeschrieben; CRS326 ist aktuell noch Werkszustand, Management-IP/Interface TBD. > ✅ **Update 2026-07-22 (User-Entscheid):** Ziel-Device retargeted von **ap1** auf den **CRS326 Core-Switch** — konsistent mit TP15-Endstand (kein RB5009, Router bleibt ap1/ap2) und TP21s Kabel-Fabric-Modell. Reine Ziel-Doku-Änderung, Scharfschalten bleibt gated bis TP21 den CRS326 abdeckt. Der Rest dieser Sektion ist entsprechend auf den neuen Zielhost umgeschrieben; CRS326 ist aktuell noch Werkszustand, Management-IP/Interface TBD.
**Trennung der Concerns (wichtig):** **Trennung der Concerns (wichtig):**
@@ -452,6 +483,8 @@ Netzwerk-Namespace, sieht ohnehin alles), und die cloudflared-Connectoren laufen
eigene Maschinen, auf denen die Blindheit prinzipiell nicht auftreten kann. Der Shim bleibt als Option eigene Maschinen, auf denen die Blindheit prinzipiell nicht auftreten kann. Der Shim bleibt als Option
für den Fall, dass später doch ein Dienst neben macvlan-Nachbarn einziehen soll. für den Fall, dass später doch ein Dienst neben macvlan-Nachbarn einziehen soll.
</details>
## 17. Terpenfinder-DB-Pfad neu aufgebaut 2026-09-02 (Fremdprojekt-Handout blaueblüten) ## 17. Terpenfinder-DB-Pfad neu aufgebaut 2026-09-02 (Fremdprojekt-Handout blaueblüten)
**Auslöser:** Handout `blaueblueten-n8n/docs/handout-bf-infra-db-pfad-20260902.md` — der Cloudflare-Worker **Auslöser:** Handout `blaueblueten-n8n/docs/handout-bf-infra-db-pfad-20260902.md` — der Cloudflare-Worker

View File

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

View File

@@ -54,6 +54,9 @@ Token aus `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored). Aufruf: Dry-Ru
- VPC-Service-API liegt unter `connectivity/directory/services` (Update = **PUT**, voller Body), nicht `/vpc/`. - VPC-Service-API liegt unter `connectivity/directory/services` (Update = **PUT**, voller Body), nicht `/vpc/`.
- Der Repo-`CF_API_TOKEN` darf `cfd_tunnel`, aber **nicht** Workers-VPC (`connectivity/directory`) — dort - Der Repo-`CF_API_TOKEN` darf `cfd_tunnel`, aber **nicht** Workers-VPC (`connectivity/directory`) — dort
über den `cloudflare`-MCP-Credential gehen. über den `cloudflare`-MCP-Credential gehen.
> ⚠️ **ÜBERHOLT (2026-09-11):** falsch. Live getestet liest der `CF_API_TOKEN` aus
> `~/.secrets/credentials.env` Hyperdrive/VPC/Workers (alle 200) — der schmale ist `CF_DNS_API_TOKEN`.
> Der Teil-B-Sync deckt damit 14/14 ab. Details: `handoff-20260910-tp44-teilb-sync-live.md`.
## Offen (Etappe 2, eigene Session) ## Offen (Etappe 2, eigene Session)

View File

@@ -0,0 +1,57 @@
# Handoff 2026-09-10 — TP44 Teil B: CF-Estate-Sync als Nautobot-Job LIVE
> **Ergebnis:** Der Cloudflare-Estate→Nautobot-Sync läuft als **täglicher Nautobot-Job**
> (`CFEstateSync`, grouping `TP44 / Cloudflare`). Dry-Run + Commit-Run sauber: **4 CloudNetworks /
> 14 CloudServices, alle CF-Endpoints lesbar, 0 Drift.** CF bleibt operativer Master, Nautobot ist
> read-only Dokumentations-SSoT. **TP44 ist damit vollständig durch.**
## Governance (User, 2026-09-09)
Periodische externe→Nautobot-Syncs leben als **Nautobot-Jobs** in `bf-infra/jobs/` und werden **in
Nautobot geplant** — kein per-Task-Container, kein Cron. Künftige Syncs = weitere Jobs in `jobs/`.
## Was live ist
- **Runtime:** `jobs/cf_estate_sync.py` (`CFEstateSync`, ORM). Pull der CF-API (urllib+Bearer),
Upsert per `extra_config`-Match (`tunnel_id`/`script`/`id`/`service_id`), Verwaisung = `stale`-Flag
statt Löschen. 403-tolerant (fehlender Scope kippt den Sync nicht; Verwaisung wird dann übersprungen).
- **Registrierung:** `jobs/__init__.py` ruft `register_jobs(CFEstateSync)`; **Repo-Wurzel-`__init__.py`**
macht `bf_infra` als reguläres Paket sichtbar.
- **Token:** Nautobot-Secret `cf-api-token` (Provider `environment-variable`) → `CF_API_TOKEN` am
`nautobot-worker`, verdrahtet im Anchor-`environment` von `docs/nas-vlan20/stacks/210-nautobot.yml`
(gitignored). Wert = der breite `CF_API_TOKEN` aus `~/.secrets/credentials.env` (liest 14/14).
- **Schedule:** `ScheduledJob "CF Estate Sync – taeglich"`, `interval=daily`, `start_time=2026-09-11T03:00:00Z`
(= 05:00 Europe/Vienna, vor den Golden-Config-Läufen), `enabled=True`, `dryrun=False`.
- **GitRepository:** `bf-infra` (`provided_contents=['extras.job']`) — war schon verdrahtet.
## Drei load-bearing Funde (sonst lädt/plant der Job nicht)
1. **Repo-Wurzel `__init__.py` ist Pflicht.** Nautobot 3.1.7 lädt Git-Repo-Jobs über
`import_modules_privately()` → `pkgutil.walk_packages([GIT_ROOT])`. walk_packages entdeckt **nur**
reguläre Pakete (mit `__init__.py`), **keine** Namespace-Packages. Ohne die Datei ist der geklonte
`GIT_ROOT/bf_infra/` unsichtbar → `jobs/` wird nie importiert. Symptom: Git-Sync **SUCCESS**, aber
Log „No jobs were registered on loading the `bf_infra.jobs` submodule" und **kein** Job-DB-Eintrag.
bf-infra ist das **erste** Git-Repo mit `extras.job` — daher nie zuvor aufgetreten. Der `module_path`-
Filter des Loaders importiert nur `bf_infra` + `bf_infra.jobs.*`; `nautobot/`, `routeros/` etc. bleiben
unberührt.
2. **`register_jobs()` gehört ins Paket-`__init__`**, nicht nur ins Submodul (Doku bestätigt).
3. **`Meta.has_sensitive_variables = False`** nötig — Default `True` ⇒ Nautobot verweigert das Scheduling
(„Job may have sensitive input variables"). Der Job nimmt nur `dryrun` (Boolean); das Token kommt aus
dem Secret, nicht aus einer Job-Variable.
## Zwei Daten-/Code-Funde beim Dry-Run
- **VPC-Bug:** VPC-Directory-Services tragen ihren Identifier in **`service_id`**, nicht `id`. Der Job las
`id` (=None) → per `.get(mk)==None` matchte er ein Fremdobjekt (App Launcher) und ließ den echten
`terpene-db` auf stale laufen. Fix: VPC nutzt `service_id`; `_upsert` überspringt `mv=None` generell.
- **Seed-Artefakt:** der geseedete CloudService `terpene-db` hatte `extra_config.service_id: null`
(der echte `019fa3d5-…` stand beim Seed nur in den Hyperdrive-Einträgen als `vpc_service_id`).
**Einmalig reconciled** (PATCH `service_id=019fa3d5-…`) → seither matcht der Sync sauber, 0 Drift.
## Drift-Korrektur (nicht stillschweigend)
Die frühere Notiz „Repo-`CF_API_TOKEN` darf kein Workers-VPC → über den `cloudflare`-MCP-Credential"
(TP44-Prosa + `handoff-20260902-…`) war **falsch**. Live getestet liest der `CF_API_TOKEN` aus
`~/.secrets/credentials.env` Hyperdrive/VPC/Workers (alle 200). Der schmale Token ist `CF_DNS_API_TOKEN`
(403). Der `cloudflare`-MCP ist der **remote OAuth-MCP** (kein API-Token) und für einen serverseitigen
Job ohnehin nicht nutzbar.
## Nächster CF-Sync = neuer Job im selben Muster
Modul in `jobs/` anlegen, Klasse importieren + `register_jobs(...)` im `jobs/__init__.py` ergänzen,
`Meta.has_sensitive_variables=False` setzen, Git-Sync, enablen, Dry-Run, dann Schedule. Die Wurzel-
`__init__.py` gilt schon.

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

@@ -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 | | ~~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` | | 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. > 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

@@ -63,6 +63,11 @@ teuerste Eigenschaft der Datei zerstört, um ihr billigstes Problem zu lösen. B
| [TP28](#tp28) | WLAN über CAPsMAN auf `crs326-core` | ↩️ **wieder geöffnet 2026-08-30** (ap3) — Volltext in [teilprojekte.md](teilprojekte.md#tp28) | | [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) | | [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 | | [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 |
| [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. 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 TP25 selbst ist **nicht** abgeschlossen und steht weiter in `teilprojekte.md`; nur seine 394 Zeilen
@@ -1777,3 +1782,198 @@ gemessen hat. Beim Beheben kam eine zweite, unabhängige Altlast zum Vorschein.
- **Nicht** TP31/TP34 (Caddy) und nicht TP11 (Monitoring) — obwohl gilt: ein Monitoring, das - **Nicht** TP31/TP34 (Caddy) und nicht TP11 (Monitoring) — obwohl gilt: ein Monitoring, das
DB-Verbindungszeiten kennt, hätte diesen Fall Monate früher gezeigt. DB-Verbindungszeiten kennt, hätte diesen Fall Monate früher gezeigt.
- **Nicht** TP40: der HA-Recorder-Wildwuchs (Punkt 3) ist HA-seitig und ein eigenes TP. - **Nicht** TP40: der HA-Recorder-Wildwuchs (Punkt 3) ist HA-seitig und ein eigenes TP.
---
### TP34 — Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) ▸ ❌ **VERWORFEN (2026-09-08)**
> 🔴 **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
> Caddyfile bleibt manuell** in `caddy/Caddyfile`. Gründe: kein zweites SSoT-Modell für Proxy-Routen
> nötig, TP37 deckt die Dienst-Doku als `ipam.Service` bereits ab, und der einzige akute Anwendungsfall
> ließ sich mit einem manuellen Block lösen. Text unten unverändert als Konzept-Historie.
> **Konzept + Architektur-Skizze:** [`docs/handoff-20260827-inventur-und-architektur.md`](handoff-20260827-inventur-und-architektur.md) — bitte dort weiterlesen (Architektur-Abschnitt).
- **Scope:** Reverse-Proxy-Hosts werden im **Nautobot-Datenmodell** gepflegt (Site, Host, Backend-Spec). **Golden Config** rendert den Caddyfile aus den Daten, Diff-Check gegen die Live-Version auf `caddy-bf1`, Compliance-Checks + Transaktionen identisch zum bestehenden Workflow für Router-Configs.
- **Auslöser (Inventur 2026-08-27):** der Caddyfile (`bf-infra/caddy/Caddyfile`) ist heute **manuell gepflegt** — kein Drift-Check, keine Transaktionen, andere Projekte können keine Sites anlegen ohne Christoph. Lösung: **Nautobot/GC**, kein Caddy-API-Sidecar (kein zweites SSoT).
- **Erster Anwendungsfall:** `terpene.teilzeitladen.xyz → http://10.40.20.167:8890` (Mac-mini-Backend, dort PostgREST + sync + web via lokalem Caddy `:8890`). Vor TP34: manueller Quick-Win-Caddy-Block.
- **Abh.:** TP22 (GC-Pipeline) ✅ · TP23 (DNS/Caddy-Voraussetzungen) ✅ Stufe 1 · 🔴 **NEU 2026-09-06: TP37 Punkt 1+2** — Caddy rendert *Config aus Daten*, die Backend-Dienste müssen als `ipam.Service` in Nautobot existieren, sonst gibt es nichts zu rendern. **TP37 vor TP34.**
- **Datenmodell (entschieden 2026-09-06):** native Modelle — Backend = `ipam.Service`, Proxy-Route = Relationship/CustomField Frontend↔Backend, GC-Job rendert den Caddyfile. Keine eigene App (konsistent zu TP44).
- **Status:** 🔜 Stub; wartet auf TP37 P1+2. Quick-Win `terpene.teilzeitladen.xyz → 10.40.20.167:8890` als manueller Caddy-Block ist **Schritt 0** des Runbooks. Plan: [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
---
<a id="tp44"></a>
### TP44 — Cloudflare-Estate als Dokumentations-SSoT in Nautobot ▸ ✅ **DURCH (2026-09-10): Etappe 1 + Etappe 2 (Teil A IPAM-Prefixe, Teil B Live-Sync als täglicher Nautobot-Job)**
> **Entscheid (User, 2026-09-02):** die volle CF-Estate (Tunnel + WARP-Connector + VPC-Service +
> Hyperdrive + Workers + alle Access-Apps) als strukturierte, abfragbare SSoT in Nautobot abbilden —
> **etappenweise: manuell jetzt, Sync später.** Nautobot kann CF nicht steuern → „SSoT" heißt
> **Dokumentations-SSoT; CF bleibt operativer Master.** Detail-Handoff:
> [`handoff-20260902-cf-estate-nautobot-ssot.md`](handoff-20260902-cf-estate-nautobot-ssot.md).
- **Warum eigenes TP, nicht TP36:** TP36 ist eng auf `teilprojekte.md`-Live-Daten geschnitten; gleiche
Idee (Nautobot als Doku-SSoT), andere Dimension. TP44 = nächste freie Nummer nach TP43.
- **Modell (native `nautobot.cloud`, Nautobot 3.1.7 — keine eigene App):** `Manufacturer "Cloudflare"` →
`CloudAccount` (`393c051f…`) → `CloudResourceType ×6` → `CloudNetwork ×4` (`bf-public`/`fm`/`terpene-db`/
`bf-core-mesh`) + `CloudService ×14`; Service↔Tunnel über das Through-Modell
`cloud-service-network-assignments` (3 Rows). IDs/Ports/Hostnames/Origins je Objekt in `extra_config`.
- **Werkzeug:** `nautobot/tp44-cf-estate-seed.py` — idempotenter Seeder (Re-Run = 26/26 „existiert"),
Datenstand inline (Live-CF-Inventur 2026-09-02). Token aus `docs/nas-vlan20/stacks/210-nautobot.yml`.
- ✅ **Verifikation 2026-09-06 (live gegen die API):** CloudAccount 1 · ResourceType 6 · CloudNetwork 4 ·
CloudService 14 — unverändert seit 09-02.
- 🔴 **Gotchas:** `CloudService.cloud_networks` ist `read_only` (nur Through-Rows schreiben/lesen) ·
VPC-Service-API unter `connectivity/directory/services` (PUT, voller Body) · **VPC-Objekte tragen ihren
Identifier in `service_id`, NICHT `id`** (kostete im Sync einen Fehl-Match) · ⚠️ **DRIFT-KORREKTUR
2026-09-10:** die frühere Zeile „Repo-`CF_API_TOKEN` darf kein Workers-VPC → über den `cloudflare`-MCP"
war **falsch**. Live getestet liest der `CF_API_TOKEN` aus `~/.secrets/credentials.env` Hyperdrive **und**
VPC **und** Workers (alle 200). Der schmale Token ist `CF_DNS_API_TOKEN` (403). Der `cloudflare`-MCP ist
der **remote OAuth-MCP** (kein API-Token) und für einen serverseitigen Job ohnehin nicht nutzbar.
- ✅ **Etappe 2 Teil A (2026-09-08): IPAM-Prefixe live.** `10.40.0.0/16` (container, Route `bf-core-mesh`) +
`10.101.0.0/24` (network, Route `fm`) angelegt via `nautobot/tp44-ipam-prefixes.py`; der Seeder-Re-Run
hat die zwei `cloud-network-prefix-assignments` eingehängt (count=2, Idempotenz-Gegenprobe grün).
- ✅ **Etappe 2 Teil B (2026-09-10): Live-Sync als Nautobot-Job — DURCH.** Governance (User 2026-09-09):
periodische externe→Nautobot-Syncs leben als **Nautobot-Jobs** in `bf-infra/jobs/`, in Nautobot geplant —
**kein per-Task-Container/-Cron.** Runtime `jobs/cf_estate_sync.py` (`CFEstateSync`, ORM, read-only Pull,
403-tolerant, Verwaisung = `extra_config.stale` statt Löschen). Token = Nautobot-Secret `cf-api-token`
(Provider env → `CF_API_TOKEN` am Worker, in `210-nautobot.yml` verdrahtet). **Schedule: täglich 03:00Z**
(`ScheduledJob "CF Estate Sync – taeglich"`). Verifiziert: Dry-Run + Commit-Run sauber, **4 Networks /
14 Services, alle Endpoints lesbar, 0 Drift**. Der REST-Einmalproof `nautobot/tp44-cf-sync.py` bleibt als
workstation-seitiges Diagnose-Werkzeug liegen (nicht die kanonische Runtime).
- 🔴 **Drei load-bearing Funde (Detail-Handoff `handoff-20260910-tp44-teilb-sync-live.md`):**
(1) **Wurzel-`__init__.py` im Repo ist Pflicht** — Nautobot lädt Git-Repo-Jobs via
`pkgutil.walk_packages([GIT_ROOT])`, das Namespace-Packages (ohne `__init__.py`) NICHT entdeckt; ohne die
Datei bleibt `bf_infra.jobs` unsichtbar (Symptom: Sync SUCCESS + „No jobs were registered"). bf-infra ist
das erste Git-Repo mit `extras.job`. (2) `register_jobs()` gehört ins **Paket-`__init__`**, nicht nur ins
Submodul. (3) `Meta.has_sensitive_variables=False` nötig, sonst verweigert Nautobot das Scheduling.
- **Beziehung zu TP37:** TP44 hat **TP37 Punkt 3** (CF-Connectoren) vorweggenommen — dieselbe native-Modell-
Linie, die TP37 P1+2 (Substrat/Dienste) fortsetzt.
### TP41 — CF-Access-Reparatur + Zero-Trust-Konsolidierung ▸ ✅ **ABGESCHLOSSEN (2026-09-10)** — Auth-Kern seit 2026-09-01 komplett; **Gelis WARP-Client am 2026-09-10 via Android erfolgreich eingerichtet** (letzter Blocker weg, User-Bestätigung). Optionale Restpunkte (T6 Rate-Limit, geli-CGNAT-Policy-Cleanup) bewusst nicht gezogen; Keystore-Rotation läuft unter TP30 #7.
**Auslöser:** CF-Access zwang Christoph **und Geli** täglich zum OTP-Login (soll: 30 Tage); GitHub/Google
„funktionieren nicht". Root-Causes: Team-Domain-Rename `bf4`→`emsenhuber` (Provider-Callback veraltet),
App-Session 24h, diverse Ordnungs-/Sicherheitslecks. Vollständiger Verlauf + offene Schritte:
**[`docs/handoff-20260901-tp41.md`](handoff-20260901-tp41.md)**. Branch `tp41-cf-access-repair`.
**✅ Live erledigt:** Session 720h (Wildcard/vw/Warp-Login) · login_method-Loch zu (reusable Policy
`bf-users` = nur `email:christoph`+`email:geli`) · `8e44f5d1` von 4 Apps auf **nur Terpenfinder**
entkoppelt · Access-**Gruppen** `zt-humans`/`zt-home`/`zt-automation`, Policies zeigen darauf · **vw voll
Bypass** (Lebensader offen) + **`vw/admin` per WAF-Regel öffentlich geblockt** (CF-Edge + Mobilfunk
verifiziert) · API-Token auf das vollständige `…4148` konsolidiert (User hat Service-Tokens- + Zero-Trust-
Write ergänzt), altes `…2174` revoken.
**✅ T4 DURCH (2026-09-01, Variante B):** ein Access-Service-Token **`zt-svc`** (`65f1821a`, rotiert) über
Gruppe `zt-automation`; Terpene-Worker per `wrangler secret put TERPENE_SVC_CLIENT_ID` auf zt-svc
umgestellt (Worker-Pin `index.js:44`); `terpene-verify`+`-v2` **gelöscht**; `test_whoami` grün.
**Token-Altlast geklärt:** `b00722` = LIVE Caddy-DNS-Token (LXC130 caddy.env, SSoT), `d47435` = stale
Kopie (kein Consumer) → **User revoket `d47435`**; `credentials.env`-Kopie auf b00722 realignt.
**✅ T3 APPLIED (2026-09-01):** Default-Profil (`0197d1b3`; das custom „Cloudflare Mesh" matcht nur den
`warp_connector`) `fallback_domains += emsenhuber.net → [10.40.20.36, 10.40.20.37]`; Tunnel-Route
`10.40.0.0/16` (`d1da7c8a`) da, kein `10/8`-Exclude → routbar. ⏳ Client-Verify: WARP neu verbinden.
**✅ T2 DURCH (2026-09-01):** WAF-Regel `f26744eb` gelöscht; Caddy `vw`-Block `respond @cf_admin 404` bei
Header `CF-Connecting-IP` (LXC130). Verify: Edge `/admin`=404, lokal/WARP `/admin`=200. ✅
**✅ T5 DURCH (2026-09-01, User-bestätigt):** GitHub-Login läuft; Google-Login läuft nach dem
Consent-Screen-Fix (§A des Follow-up-Handoffs: User Type *Internal*→*External*/veröffentlicht).
Callback für beide `https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback`.
**❌ Facebook/Meta für TP41 abgesagt (2026-09-01, User-Entscheid):** optional, kein Bedarf — Geli ist
über OTP `angelikahy@outlook.de` + Google `pillepalle670@gmail.com` voll gedeckt. **Keine Facebook-App,
kein CF-Facebook-IdP angelegt → nichts aufzuräumen.** Instagram hat ohnehin keinen CF-IdP.
**✅ App-Launcher (D1):** je eine Bookmark-Kachel vw/hass/terpenfinder, Wildcard-Kachel raus, Launcher
gestylt (Follow-up-Handoff §D, Policies unverändert).
**✅ Geli in WARP — CF-seitig nichts zu tun (2026-09-01 live geprüft):** Device-Enrollment läuft über
die App **„Warp Login App"** (`6c32fe23…`, type `warp`) → Policy `bf-users` → Gruppe **`zt-humans`**, die
Gelis beide Mails **schon enthält** → sie **darf bereits enrollen** (deshalb war „Device enrollment
permissions" nicht als fehlendes Setting zu finden). Terpenfinder-`geli`-Policy lässt sie per E-Mail durch.
✅ **Client am 2026-09-10 via Android erfolgreich eingerichtet** (WARP installiert, Team `emsenhuber`,
Login OTP `angelikahy@outlook.de` — User-Bestätigung); damit ist der einzige verbliebene Handgriff erledigt.
🔑 **Permission-Frage beantwortet:** CF-seitig **keine** nötig. Für generelle WARP/Enrollment/User-Verwaltung
per Account-Token: *Access: Apps and Policies → Edit* + *Access: Organizations, IdPs, and Groups → Edit*
(User erteilt 2026-09-01). **Seat-Verwaltung (`/access/seats`) bleibt per MCP gesperrt** — `10405 Method
not allowed for this authentication scheme` (Endpoint will User-Token, nicht Account-Token; kein Scope-Fix,
für Enroll irrelevant).
**🔻 Optionale Restpunkte (bewusst nicht gezogen, kein Blocker):** **T6** optional Rate-Limit ·
🔴 `TERPENE_ANDROID_KEYSTORE_PW`-Rotation (→ **TP30 Posten #7**, Transkript-Leak) · **Security-Cleanup:**
die `geli`-Terpenfinder-Policy hat **OR-Semantik** und enthält den breiten CGNAT-Bereich
`94.154.138.0/24` als IP-Include → jeder aus dieser geteilten Range käme **ohne Login** rein. Über WARP ist
der Egress ohnehin Cloudflare (nicht diese IP) → der IP-Include wäre dann überflüssig und entfernbar.
**d47435-Revoke ✅ · T3-WARP-Client-Verify ✅** (User, 2026-09-01).
**Abgrenzung:** hass/gitea bleiben (diese Runde) öffentlich; Terpenfinder-Human-Policies nicht angefasst;
**Gitea-Least-Privilege** = eigener künftiger TP (jedes Projekt repo-scoped Login, Admin nur Claude/Christoph).
- Bezug **TP11** (Monitoring): eine Plattenfüllstand-Warnung hätte das Wochen früher gezeigt.
### TP43 — dw-Standort-DNS-Uplink (site-spezifische Overrides) ▸ ♻️ **REOPENED 2026-09-11 → Volltext zurück in [`teilprojekte.md`](teilprojekte.md) (Abschnitt TP43)**
Kurz zwischen 2026-09-11 verworfen und am selben Tag reopened: die 420-Prämisse war gegenstandslos
(public CF-Worker, via WARP/TP41 gelöst), aber die **interne** BF-Namensauflösung über WG am dw blieb
dabei offen (`portainer.bf.emsenhuber.net` am dw nicht erreichbar). Korrigierter Scope + verworfene
420-Historie stehen jetzt aktiv in `teilprojekte.md`. Gitea-Issue #32 = `status:todo` · `thema:firewall-dns`.
### TP42 — Namens-Vereinheitlichung: ein interner Suffix bf.emsenhuber.net ▸ ✅ **DURCH (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`, `.bf.lan` raus, `.16` (adguard_bf1) abgebaut, Compliance 140/140. Offen nur der an **TP37** gekoppelte Nautobot-Doku-Teil.
**Auslöser (2026-09-01, TP41-Randbefund):** live gemessen liefern die realen Client-Resolver
`10.40.20.36`/`.37` `*.emsenhuber.net → 10.40.20.40` (Caddy) — korrekt. Daneben existiert aber ein
zweiter, gedrifteter interner Namensraum: der **Core** trägt in `/ip dns static` 11 Einträge auf
`*.bf.lan`, der **Caddyfile** doppelt jeden internen Namen als `*.bf.emsenhuber.net`, und die alten
AdGuard `.16`/`.17` antworten noch mit `nginx*.bf.emsenhuber.net` bzw. totem VLAN1 `10.40.41.123`.
Christoph: „aus emsenhuber.net wurde schon wieder bf.lan — das müssen wir ein für alle mal aus allen
Geräten rausprügeln und in Nautobot festhalten."
**Ziel:** genau **ein** interner Namensraum `emsenhuber.net` (Split-Horizon `.36/.37 → .40`). `.bf.lan`
und `.bf.emsenhuber.net` werden entfernt; Sollzustand in Nautobot dokumentiert.
**Scope (Reihenfolge = eigene Session):**
1. **Bestandsaufnahme** aller Fundstellen: Core `/ip dns static` (`*.bf.lan`), DHCP-Option
`domain`/Search-Domain je Netz, Caddyfile-Aliase (`*.bf.emsenhuber.net`), AdGuard-Rewrites `.36/.37`,
stale `.16/.17`, Geräte-`identity`.
2. **Entscheidung Zielschema** (nur `emsenhuber.net`, ggf. `bf.emsenhuber.net` als *einzige* interne
Zone — Christoph-Entscheid) und Migrationsreihenfolge ohne DNS-Loch.
3. **Umsetzen:** Core-DNS + DHCP + Caddyfile angleichen; stale `.16/.17` klären/abbauen.
4. **Nautobot:** interne Namen/Records als Sollzustand erfassen (Bezug TP37 Datenmodell).
**Abgrenzung:** kein CF-Public-DNS-Umbau (Zone `emsenhuber.net` öffentlich bleibt); kein Resolver-Swap
(`.36/.37` bleiben); `.1`-als-DNS-Reste auf Clients sind separat/tolerierbar (User: „bis jetzt kein
Problem"). **Abh.:** TP37 (Nautobot-Datenmodell) für Punkt 4; sonst nichts Offenes.
#### ✅ Bestandsaufnahme LIVE (2026-09-02) — Drift hat 80 % schon erledigt
Gegen die Geräte gemessen (MikroMCP + dig), nicht aus dem Handoff:
| Fundstelle | Handoff 09-01 | Live 09-02 |
|---|---|---|
| Core `/ip dns static` | 11× `*.bf.lan` | ✅ **0× `.bf.lan`** — 10× `*.bf.emsenhuber.net` (deckt sich mit GC-Template Z.284–293) |
| DHCP `domain` (7 Netze) | „prüfen" | `emsenhuber.net` (GC-Template Z.133 = gleich ⇒ **keine Drift**), DNS `.36/.37` |
| `.36/.37` (Soll) | aktiv | `vw.emsenhuber.net→.40` ✅ · `macmini.bf.emsenhuber.net→.20.167` ✅ · **`macmini.emsenhuber.net→leer`** ❌ |
| Caddyfile | `*.bf.emsenhuber.net`-Aliase | unverändert, **konsistent** mit Core-Static |
| `.16`/`.17` | tot | laufen (:53), liefern totes VLAN1; **kein Core-Objekt** (Firewall/NAT/DNS-Upstream/Addr-List) hängt an `.16` |
**Kernkonflikt:** DHCP verteilt `emsenhuber.net` (bare), Records leben unter `bf.emsenhuber.net` ⇒ bare `macmini` löst nicht.
#### ✅ Entscheidungen (User, 2026-09-02)
1. **Zielschema = `bf.emsenhuber.net`** (der wenig-Arbeit-Weg): DHCP-domain → `bf.emsenhuber.net`, dann
löst `macmini` bare korrekt; Core-Static + Caddyfile bleiben (tragen schon `.bf.`). Öffentliche Zone
`emsenhuber.net` bleibt getrennt.
2. **`.16` (adguard_bf1) abbauen**, **`.17` (adguard_fm2 = Standort FM) NICHT anfassen.**
#### ✅ Umsetzung DURCH (2026-09-02)
- **(A) DHCP-domain `emsenhuber.net → bf.emsenhuber.net` über GC ✅** — kein Direkt-Write:
`mikrotik_routeros.j2` Z.133 gehoben → `push-gc-template.py` (Commit `7a4068c4`, **Nebenbefund:** das
Remote-Template hing dem lokalen hinterher — es trug noch `*.bf.lan`+`router.lan`, obwohl Gerät+lokal
längst `.bf.emsenhuber.net` fuhren; der Push hat das GC-Repo an den Live-Stand angeglichen) →
GitRepo-Sync (SUCCESS) → manueller ConfigPlan **`tp42-dhcp-domain`** (`set [find] domain=…`, weil
`add` das Netz doppelt anlegen würde) → Deploy an **crs326-core** → Live **7/7 = bf.emsenhuber.net**,
Compliance **140/140, keine Drifts**. Der Job steht dauerhaft in `nautobot/gc-configplan.py`.
- **(B) `.16`-Teardown ✅** — `adguard_bf1` lag im geteilten Portainer-Stack **195 `adguard`** (Docker-LXC
`.126`, **ep2** — *nicht* ep14) **zusammen mit `adguard_fm2` (`.17`, Standort FM!)**. Deshalb **nicht**
Stack löschen, sondern compose auf nur `.17` getrimmt + Stack redeployt (Prune): `.16`-Container weg,
`.16:53` tot; **`.17` läuft** (⚠️ ~40 s mit-neugestartet durch den Redeploy — kurzer FM-DNS-Blip,
danach sauber). Rollback-compose gesichert. 🧹 Daten-Dirs `/opt/docker/adguard_bf1/*` bleiben als
harmlose Leichen auf dem LXC.
- **(C) Nautobot-Soll** (Scope-Punkt 4) bleibt an **TP37** gekoppelt (Datenmodell), separat — kein Blocker.
**Status:** ✅ **Namensraum vereinheitlicht (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`,
`.bf.lan` raus, `.16` abgebaut, Compliance 140/140. Offen nur der TP37-gekoppelte Nautobot-Doku-Teil.
Handoff: [`docs/handoff-20260901-tp42-naming.md`](handoff-20260901-tp42-naming.md).

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 - 📌 **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**. 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 - ⚠️ **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. kein Repo-Skript nutzt den FQDN.
- ✅ **Betriebsfenster ausgewertet 2026-08-30 (Tag 6/7): `nautobot` bleibt mesh-only.** Kein Rückweg - ✅ **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 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 (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 ##### ✅ §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 häufigsten per Mesh angefasste Dienst, und ein Fehlschlag kostet nichts Produktives), eine Woche fahren, dann
entscheiden. **Rollback = eine Ingress-Zeile zurück.** entscheiden. **Rollback = eine Ingress-Zeile zurück.**
- 📋 **Runbook: `docs/tp16.3-nautobot-mesh-only-runbook.md`** (2026-08-19) — der Schnitt oben, ausführbar - 📋 **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 Ingress-`PUT` über `CF_API_TOKEN` (**nicht** über den CF-MCP, der ist read-only), Rollback aus der
gesicherten Config. gesicherten Config.
- 🔴 **Der Abnahmepunkt, den man übersieht:** Nautobot ist nicht nur Web-UI, sondern die - 🔴 **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 **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 - ⚠️ 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. 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 - **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**. - **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. - 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** > 🟢 **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 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 > - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
@@ -1078,7 +1102,7 @@ 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.** - **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). - **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 ### TP25 — WLAN-Stabilisierung ▸ ⏳ **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 > 📚 **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: > 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), > [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
@@ -1087,6 +1111,79 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will > Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
> *warum* etwas so entschieden wurde, liest die Langfassung. > *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). Offen: (1) ap1 behalten/abbauen, (2) Tausch auf Plan korrigieren (cAP ax → Sideboard?), (3) Drei-Räume-Schatten (Schlafzimmer/Bad/Küche) gezielt nachmessen — noch nicht verifiziert, (4) Doku-Drift (diese Session).
**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 **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 / ([`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 20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
@@ -1783,6 +1880,7 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
| 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 | | 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 | | 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) | | 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.** > ✅ **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 > Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und
@@ -1816,6 +1914,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'` > 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. > — 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) — > ℹ️ **#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 > 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). > **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token).
@@ -2287,18 +2394,36 @@ haben: `100–109` netznah · **`110–119` DNS** · **`130–139` Reverse Proxy
**Letztes Oktett je /24 — gilt in jedem VLAN, an jedem Standort:** **Letztes Oktett je /24 — gilt in jedem VLAN, an jedem Standort:**
| Oktett | Rolle | Anker heute | | Oktett | Rolle | Ableitung aus TP32 | Anker |
|---|---|---| |---|---|---|---|
| `.1–.9` | Gateways / L3 | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ | | `.1–.9` | Gateways / L3 | Bestand | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
| `.10–.29` | physische Hosts (Storage, Hypervisor, APs) | NAS `20.10` ✅ · APs `99.11/.12` ✅ | | `.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 |
| `.30–.99` | virtuelle Core-Services, Zehnergruppen wie oben | DNS `.3x` · Ingress `.4x` · DBs `.5x` · Security `.6x` · Monitoring `.7x` | | `.20–.29` | Core / netznah | Kreis 100–199, Stufe `10x` | mcast-relay (heute `10.21`/`40.21` — **kein VLAN20-Bein**) |
| `.100–.199` | App-Services | | | `.30–.39` | DNS / Resolver | Stufe `11x` | AGH-Paar — **Soll** `.30`/`.31`, Ist `.36`/`.37` (bis Nautobot dort weg ist) |
| `.200–.249` | DHCP-Pool | | | `.40–.49` | Ingress / Reverse-Proxy | Stufe `13x` | caddy `20.40` ✅ |
| `.250–.254` | VIPs / Relays | | | `.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`, 🔑 **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. `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 #### 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 **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 anderen … gehörten zu ha, daher 1xx. erst später habe ich erfahren, dass die nummer die
@@ -2321,19 +2446,35 @@ alle Gäste mit `order=`, dann den Rest nach VMID** — real lief also `iob-mast
| 10 | Core | `108 mcast-relay` | | 10 | Core | `108 mcast-relay` |
| 20 | Daten | `105 platform-pg` (`up=15`), `302 mariadb` (`up=15`), `106 influxdb2`, `107 redis` | | 20 | Daten | `105 platform-pg` (`up=15`), `302 mariadb` (`up=15`), `106 influxdb2`, `107 redis` |
| 30 | Platform | `400 docker` (`up=20`) | | 30 | Platform | `400 docker` (`up=20`) |
| 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25`, `330 RaspberryMatic` | | 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25` |
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed` | | 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 wichtigste Erkenntnis des TP: VMID = Klassifikation, `startup: order=` = Abhängigkeitsreihenfolge.**
Die Umnummerierung muss die Startreihenfolge gar nicht reparieren — das ist bereits erledigt, ohne eine 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 einzige ID zu ändern. **Die Nummernkreise sind damit reine Ordnung und Lesbarkeit**, und der Bestand
kann in Ruhe nachziehen. kann in Ruhe nachziehen.
- **Offen:** Migration des Bestands (Tabelle oben) — *am Ende der Reise, nicht jetzt*. Und die - **Offen:** Migration des Bestands (Tabelle oben) — **läuft jetzt an**, nicht mehr „am Ende der
Kollision **AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker) ⇒ Vorschlag `.36` als Reise": siehe **TP45** (VLAN20 + VLAN30 ins Schema, danach DNS/FQDN). Die alte Kollision
Interim, `.30` erst nach dem Nautobot-Umzug in den App-Bereich. **Fällt vor dem AGH-Neubau an.** **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. - **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") ### TP33 — Sonos aus der Ferne bedienen (VPN/WARP) ▸ 💡 **IDEE, bewusst zurückgestellt** (angelegt 2026-08-25, User-Entscheid „später entscheiden")
@@ -2354,26 +2495,15 @@ kann in Ruhe nachziehen.
--- ---
### TP34 — Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) ▸ 🔜 STUB ### 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`.
> **Konzept + Architektur-Skizze:** [`docs/handoff-20260827-inventur-und-architektur.md`](handoff-20260827-inventur-und-architektur.md) — bitte dort weiterlesen (Architektur-Abschnitt).
- **Scope:** Reverse-Proxy-Hosts werden im **Nautobot-Datenmodell** gepflegt (Site, Host, Backend-Spec). **Golden Config** rendert den Caddyfile aus den Daten, Diff-Check gegen die Live-Version auf `caddy-bf1`, Compliance-Checks + Transaktionen identisch zum bestehenden Workflow für Router-Configs.
- **Auslöser (Inventur 2026-08-27):** der Caddyfile (`bf-infra/caddy/Caddyfile`) ist heute **manuell gepflegt** — kein Drift-Check, keine Transaktionen, andere Projekte können keine Sites anlegen ohne Christoph. Lösung: **Nautobot/GC**, kein Caddy-API-Sidecar (kein zweites SSoT).
- **Erster Anwendungsfall:** `terpene.teilzeitladen.xyz → http://10.40.20.167:8890` (Mac-mini-Backend, dort PostgREST + sync + web via lokalem Caddy `:8890`). Vor TP34: manueller Quick-Win-Caddy-Block.
- **Abh.:** TP22 (GC-Pipeline) ✅ · TP23 (DNS/Caddy-Voraussetzungen) ✅ Stufe 1 · 🔴 **NEU 2026-09-06: TP37 Punkt 1+2** — Caddy rendert *Config aus Daten*, die Backend-Dienste müssen als `ipam.Service` in Nautobot existieren, sonst gibt es nichts zu rendern. **TP37 vor TP34.**
- **Datenmodell (entschieden 2026-09-06):** native Modelle — Backend = `ipam.Service`, Proxy-Route = Relationship/CustomField Frontend↔Backend, GC-Job rendert den Caddyfile. Keine eigene App (konsistent zu TP44).
- **Status:** 🔜 Stub; wartet auf TP37 P1+2. Quick-Win `terpene.teilzeitladen.xyz → 10.40.20.167:8890` als manueller Caddy-Block ist **Schritt 0** des Runbooks. Plan: [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
---
### 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) ✅**
- **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. - **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). - **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:** - **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). 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. 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. 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. 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. 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. - **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 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
@@ -2512,157 +2642,6 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
- 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die - 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die
Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen. Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen.
### TP41 — CF-Access-Reparatur + Zero-Trust-Konsolidierung ▸ ⏸️ **ZURÜCKGESTELLT 2026-09-01 (User-Entscheid)** — Auth-Kern durch; offen nur Gelis WARP-Client (nicht aus der Ferne machbar), User gibt ggf. später Info
**Auslöser:** CF-Access zwang Christoph **und Geli** täglich zum OTP-Login (soll: 30 Tage); GitHub/Google
„funktionieren nicht". Root-Causes: Team-Domain-Rename `bf4`→`emsenhuber` (Provider-Callback veraltet),
App-Session 24h, diverse Ordnungs-/Sicherheitslecks. Vollständiger Verlauf + offene Schritte:
**[`docs/handoff-20260901-tp41.md`](handoff-20260901-tp41.md)**. Branch `tp41-cf-access-repair`.
**✅ Live erledigt:** Session 720h (Wildcard/vw/Warp-Login) · login_method-Loch zu (reusable Policy
`bf-users` = nur `email:christoph`+`email:geli`) · `8e44f5d1` von 4 Apps auf **nur Terpenfinder**
entkoppelt · Access-**Gruppen** `zt-humans`/`zt-home`/`zt-automation`, Policies zeigen darauf · **vw voll
Bypass** (Lebensader offen) + **`vw/admin` per WAF-Regel öffentlich geblockt** (CF-Edge + Mobilfunk
verifiziert) · API-Token auf das vollständige `…4148` konsolidiert (User hat Service-Tokens- + Zero-Trust-
Write ergänzt), altes `…2174` revoken.
**✅ T4 DURCH (2026-09-01, Variante B):** ein Access-Service-Token **`zt-svc`** (`65f1821a`, rotiert) über
Gruppe `zt-automation`; Terpene-Worker per `wrangler secret put TERPENE_SVC_CLIENT_ID` auf zt-svc
umgestellt (Worker-Pin `index.js:44`); `terpene-verify`+`-v2` **gelöscht**; `test_whoami` grün.
**Token-Altlast geklärt:** `b00722` = LIVE Caddy-DNS-Token (LXC130 caddy.env, SSoT), `d47435` = stale
Kopie (kein Consumer) → **User revoket `d47435`**; `credentials.env`-Kopie auf b00722 realignt.
**✅ T3 APPLIED (2026-09-01):** Default-Profil (`0197d1b3`; das custom „Cloudflare Mesh" matcht nur den
`warp_connector`) `fallback_domains += emsenhuber.net → [10.40.20.36, 10.40.20.37]`; Tunnel-Route
`10.40.0.0/16` (`d1da7c8a`) da, kein `10/8`-Exclude → routbar. ⏳ Client-Verify: WARP neu verbinden.
**✅ T2 DURCH (2026-09-01):** WAF-Regel `f26744eb` gelöscht; Caddy `vw`-Block `respond @cf_admin 404` bei
Header `CF-Connecting-IP` (LXC130). Verify: Edge `/admin`=404, lokal/WARP `/admin`=200. ✅
**✅ T5 DURCH (2026-09-01, User-bestätigt):** GitHub-Login läuft; Google-Login läuft nach dem
Consent-Screen-Fix (§A des Follow-up-Handoffs: User Type *Internal*→*External*/veröffentlicht).
Callback für beide `https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback`.
**❌ Facebook/Meta für TP41 abgesagt (2026-09-01, User-Entscheid):** optional, kein Bedarf — Geli ist
über OTP `angelikahy@outlook.de` + Google `pillepalle670@gmail.com` voll gedeckt. **Keine Facebook-App,
kein CF-Facebook-IdP angelegt → nichts aufzuräumen.** Instagram hat ohnehin keinen CF-IdP.
**✅ App-Launcher (D1):** je eine Bookmark-Kachel vw/hass/terpenfinder, Wildcard-Kachel raus, Launcher
gestylt (Follow-up-Handoff §D, Policies unverändert).
**✅ Geli in WARP — CF-seitig nichts zu tun (2026-09-01 live geprüft):** Device-Enrollment läuft über
die App **„Warp Login App"** (`6c32fe23…`, type `warp`) → Policy `bf-users` → Gruppe **`zt-humans`**, die
Gelis beide Mails **schon enthält** → sie **darf bereits enrollen** (deshalb war „Device enrollment
permissions" nicht als fehlendes Setting zu finden). Terpenfinder-`geli`-Policy lässt sie per E-Mail durch.
⇒ verbleibt **nur ein Handgriff auf Gelis Gerät** (WARP installieren, Team `emsenhuber`, Login OTP
`angelikahy@outlook.de` / Google `pillepalle670@gmail.com`) — nicht aus der Ferne machbar.
🔑 **Permission-Frage beantwortet:** CF-seitig **keine** nötig. Für generelle WARP/Enrollment/User-Verwaltung
per Account-Token: *Access: Apps and Policies → Edit* + *Access: Organizations, IdPs, and Groups → Edit*
(User erteilt 2026-09-01). **Seat-Verwaltung (`/access/seats`) bleibt per MCP gesperrt** — `10405 Method
not allowed for this authentication scheme` (Endpoint will User-Token, nicht Account-Token; kein Scope-Fix,
für Enroll irrelevant).
**🔜 Offen — nur noch außerhalb CF:** **Gelis WARP-Client** auf ihrem Gerät (s.o.) · **T6** optional
Rate-Limit · 🔴 `TERPENE_ANDROID_KEYSTORE_PW`-Rotation erwägen (TP30 Posten #7, Transkript-Leak).
**d47435-Revoke ✅ · T3-WARP-Client-Verify ✅** (User, 2026-09-01).
**🔻 Optionaler Security-Cleanup (eigener Punkt, nicht in dieser Runde):** die `geli`-Terpenfinder-Policy
hat **OR-Semantik** und enthält den breiten CGNAT-Bereich `94.154.138.0/24` als IP-Include → jeder aus
dieser geteilten Range käme **ohne Login** rein. Über WARP ist der Egress ohnehin Cloudflare (nicht diese
IP) → der IP-Include wäre dann überflüssig und entfernbar.
**Abgrenzung:** hass/gitea bleiben (diese Runde) öffentlich; Terpenfinder-Human-Policies nicht angefasst;
**Gitea-Least-Privilege** = eigener künftiger TP (jedes Projekt repo-scoped Login, Admin nur Claude/Christoph).
- Bezug **TP11** (Monitoring): eine Plattenfüllstand-Warnung hätte das Wochen früher gezeigt.
### TP42 — Namens-Vereinheitlichung bf.lan → emsenhuber.net ▸ 🔜 ANGELEGT 2026-09-01 (User-Okay erteilt)
**Auslöser (2026-09-01, TP41-Randbefund):** live gemessen liefern die realen Client-Resolver
`10.40.20.36`/`.37` `*.emsenhuber.net → 10.40.20.40` (Caddy) — korrekt. Daneben existiert aber ein
zweiter, gedrifteter interner Namensraum: der **Core** trägt in `/ip dns static` 11 Einträge auf
`*.bf.lan`, der **Caddyfile** doppelt jeden internen Namen als `*.bf.emsenhuber.net`, und die alten
AdGuard `.16`/`.17` antworten noch mit `nginx*.bf.emsenhuber.net` bzw. totem VLAN1 `10.40.41.123`.
Christoph: „aus emsenhuber.net wurde schon wieder bf.lan — das müssen wir ein für alle mal aus allen
Geräten rausprügeln und in Nautobot festhalten."
**Ziel:** genau **ein** interner Namensraum `emsenhuber.net` (Split-Horizon `.36/.37 → .40`). `.bf.lan`
und `.bf.emsenhuber.net` werden entfernt; Sollzustand in Nautobot dokumentiert.
**Scope (Reihenfolge = eigene Session):**
1. **Bestandsaufnahme** aller Fundstellen: Core `/ip dns static` (`*.bf.lan`), DHCP-Option
`domain`/Search-Domain je Netz, Caddyfile-Aliase (`*.bf.emsenhuber.net`), AdGuard-Rewrites `.36/.37`,
stale `.16/.17`, Geräte-`identity`.
2. **Entscheidung Zielschema** (nur `emsenhuber.net`, ggf. `bf.emsenhuber.net` als *einzige* interne
Zone — Christoph-Entscheid) und Migrationsreihenfolge ohne DNS-Loch.
3. **Umsetzen:** Core-DNS + DHCP + Caddyfile angleichen; stale `.16/.17` klären/abbauen.
4. **Nautobot:** interne Namen/Records als Sollzustand erfassen (Bezug TP37 Datenmodell).
**Abgrenzung:** kein CF-Public-DNS-Umbau (Zone `emsenhuber.net` öffentlich bleibt); kein Resolver-Swap
(`.36/.37` bleiben); `.1`-als-DNS-Reste auf Clients sind separat/tolerierbar (User: „bis jetzt kein
Problem"). **Abh.:** TP37 (Nautobot-Datenmodell) für Punkt 4; sonst nichts Offenes.
#### ✅ Bestandsaufnahme LIVE (2026-09-02) — Drift hat 80 % schon erledigt
Gegen die Geräte gemessen (MikroMCP + dig), nicht aus dem Handoff:
| Fundstelle | Handoff 09-01 | Live 09-02 |
|---|---|---|
| Core `/ip dns static` | 11× `*.bf.lan` | ✅ **0× `.bf.lan`** — 10× `*.bf.emsenhuber.net` (deckt sich mit GC-Template Z.284–293) |
| DHCP `domain` (7 Netze) | „prüfen" | `emsenhuber.net` (GC-Template Z.133 = gleich ⇒ **keine Drift**), DNS `.36/.37` |
| `.36/.37` (Soll) | aktiv | `vw.emsenhuber.net→.40` ✅ · `macmini.bf.emsenhuber.net→.20.167` ✅ · **`macmini.emsenhuber.net→leer`** ❌ |
| Caddyfile | `*.bf.emsenhuber.net`-Aliase | unverändert, **konsistent** mit Core-Static |
| `.16`/`.17` | tot | laufen (:53), liefern totes VLAN1; **kein Core-Objekt** (Firewall/NAT/DNS-Upstream/Addr-List) hängt an `.16` |
**Kernkonflikt:** DHCP verteilt `emsenhuber.net` (bare), Records leben unter `bf.emsenhuber.net` ⇒ bare `macmini` löst nicht.
#### ✅ Entscheidungen (User, 2026-09-02)
1. **Zielschema = `bf.emsenhuber.net`** (der wenig-Arbeit-Weg): DHCP-domain → `bf.emsenhuber.net`, dann
löst `macmini` bare korrekt; Core-Static + Caddyfile bleiben (tragen schon `.bf.`). Öffentliche Zone
`emsenhuber.net` bleibt getrennt.
2. **`.16` (adguard_bf1) abbauen**, **`.17` (adguard_fm2 = Standort FM) NICHT anfassen.**
#### ✅ Umsetzung DURCH (2026-09-02)
- **(A) DHCP-domain `emsenhuber.net → bf.emsenhuber.net` über GC ✅** — kein Direkt-Write:
`mikrotik_routeros.j2` Z.133 gehoben → `push-gc-template.py` (Commit `7a4068c4`, **Nebenbefund:** das
Remote-Template hing dem lokalen hinterher — es trug noch `*.bf.lan`+`router.lan`, obwohl Gerät+lokal
längst `.bf.emsenhuber.net` fuhren; der Push hat das GC-Repo an den Live-Stand angeglichen) →
GitRepo-Sync (SUCCESS) → manueller ConfigPlan **`tp42-dhcp-domain`** (`set [find] domain=…`, weil
`add` das Netz doppelt anlegen würde) → Deploy an **crs326-core** → Live **7/7 = bf.emsenhuber.net**,
Compliance **140/140, keine Drifts**. Der Job steht dauerhaft in `nautobot/gc-configplan.py`.
- **(B) `.16`-Teardown ✅** — `adguard_bf1` lag im geteilten Portainer-Stack **195 `adguard`** (Docker-LXC
`.126`, **ep2** — *nicht* ep14) **zusammen mit `adguard_fm2` (`.17`, Standort FM!)**. Deshalb **nicht**
Stack löschen, sondern compose auf nur `.17` getrimmt + Stack redeployt (Prune): `.16`-Container weg,
`.16:53` tot; **`.17` läuft** (⚠️ ~40 s mit-neugestartet durch den Redeploy — kurzer FM-DNS-Blip,
danach sauber). Rollback-compose gesichert. 🧹 Daten-Dirs `/opt/docker/adguard_bf1/*` bleiben als
harmlose Leichen auf dem LXC.
- **(C) Nautobot-Soll** (Scope-Punkt 4) bleibt an **TP37** gekoppelt (Datenmodell), separat — kein Blocker.
**Status:** ✅ **Namensraum vereinheitlicht (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`,
`.bf.lan` raus, `.16` abgebaut, Compliance 140/140. Offen nur der TP37-gekoppelte Nautobot-Doku-Teil.
Handoff: [`docs/handoff-20260901-tp42-naming.md`](handoff-20260901-tp42-naming.md).
### TP43 — dw-Standort-DNS-Uplink (site-spezifische Overrides) ▸ 🔜 ANGELEGT 2026-09-01 (User-Okay erteilt)
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
uns verfranzen. Geli kommt rein, das zählt."*
**Ziel:** dem dw-Standort einen **eigenen Resolver/DNS-Uplink** geben, der **site-spezifische Overrides**
hält (erster Fall `420.teilzeitladen.xyz` → BF-Caddy über WG), als DNS der FritzBox/Clients gesetzt.
**Kernfrage (explizit):** Verhalten bei **WG-Disconnect.** Ein Override auf eine BF-interne IP
(`10.40.20.40`) fällt aus, wenn der Tunnel unten ist. Für einen Login-gegateten Dienst gibt es kein
„kein Login **und** funktioniert bei WG-down" — Optionen: (a) Override nur bei WG-up (Failover-Resolver),
(b) öffentlicher Pfad (CF, mit Login) als Fallback. Zu entwerfen.
**Sofortlösung (nicht Teil von TP43):** Geli per **WARP** (Session statt täglichem Login, CGNAT egal,
degradiert sauber: WG-up = nahtlos, WG-down = öffentliche URL + Login).
**BF-seitige Prereqs:** Caddy-vhost `420.teilzeitladen.xyz` → macmini-Worker (`10.40.20.167:8890`) mit
Cert (DNS-01, `…4148` hat teilzeitladen.xyz-DNS) · WG-Route dw→`10.40.20.40` · Split-Horizon für
`420.teilzeitladen.xyz` (heute liefern auch `.36/.37` public). **Vorher verifizieren:** FritzOS-Version +
Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
[`docs/handoff-20260901-tp41-followup.md`](handoff-20260901-tp41-followup.md).
**Status:** 🔜 angelegt 2026-09-01, eigene Session.
### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt) ### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt)
> **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in > **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in
> nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf > nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf
@@ -2687,7 +2666,7 @@ Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
„wo läuft was" steht. „wo läuft was" steht.
2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`, 2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`,
Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**. Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**.
3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit 3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](teilprojekte-archiv.md#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit
entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/ entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/
Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch
TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.** TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.**
@@ -2708,40 +2687,180 @@ Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
die Pipeline ist stabil und **104/104 compliant**. TP37 vergrößert die Angriffsfläche dieser Pipeline. 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 Deshalb Punkt 1–2 als eigener, abnehmbarer Schritt **mit Compliance-Gegenprobe danach**, nicht als
Nebeneffekt von Punkt 3. Nebeneffekt von Punkt 3.
- **Status:** ⏳ **Punkt 3 (CF) ✅ via TP44; Punkt 1+2 (Substrat + Dienste) offen** — das ist die - **Status:** ✅ **Punkt 1+2 (Substrat + Dienste) ERLEDIGT** — die frühere Zeile „offen" war Doku-Drift.
fehlende Voraussetzung für TP34. Datenmodell **entschieden 2026-09-06 (native Modelle: Live gegen die API gelesen **2026-09-21**: **28 VMs · 3 Cluster · 26 `ipam.Service` · 38 `ipam.IPAddress`
`virtualization.Cluster/VirtualMachine` + `ipam.Service`, keine App)**. Runbook liegt jetzt vor: · 9 Devices · 5 CustomFields**. Beide Seeder sind also gelaufen (`tp37-substrat-seed.py`,
[`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md). Live-Ausgangslage: `tp37-dienste-seed.py` — die 26 Services sind genau die 26 Zeilen der Dienste-Liste).
VM 0 · Cluster 0 · `ipam.Service` 0 · CustomField 0 (2026-09-06 gegen die API gelesen). 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/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="tp44"></a> ### TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN aus Nautobot ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #71)
### TP44 — Cloudflare-Estate als Dokumentations-SSoT in Nautobot ▸ ⏳ **Etappe 1 ✅ live (2026-09-02), Quick-Index nachgezogen 2026-09-06** > **Auslöser (Christoph, 2026-09-21):** *„schau dir mal die fqdns von `*.emsenhuber.net` an. viele
> **Entscheid (User, 2026-09-02):** die volle CF-Estate (Tunnel + WARP-Connector + VPC-Service + > verschwunden. sowohl caddy als auch cloudflare."* — und, als der Befund stand:
> Hyperdrive + Workers + alle Access-Apps) als strukturierte, abfragbare SSoT in Nautobot abbilden — > *„wenn wir schon reverse proxy & co korrigieren müssen, wäre es vl jetzt ein guter zeitpunkt,
> **etappenweise: manuell jetzt, Sync später.** Nautobot kann CF nicht steuern → „SSoT" heißt > unsere infrastruktur vlan20 an das ausgedachte ip-schema anzupassen. danach dns/fqdn."*
> **Dokumentations-SSoT; CF bleibt operativer Master.** Detail-Handoff:
> [`handoff-20260902-cf-estate-nautobot-ssot.md`](handoff-20260902-cf-estate-nautobot-ssot.md).
- **Warum eigenes TP, nicht TP36:** TP36 ist eng auf `teilprojekte.md`-Live-Daten geschnitten; gleiche #### Befund (live, 2026-09-21) — **weder Caddy noch Cloudflare haben etwas verloren**
Idee (Nautobot als Doku-SSoT), andere Dimension. TP44 = nächste freie Nummer nach TP43. | # | Befund | Beleg |
- **Modell (native `nautobot.cloud`, Nautobot 3.1.7 — keine eigene App):** `Manufacturer "Cloudflare"` → |---|---|---|
`CloudAccount` (`393c051f…`) → `CloudResourceType ×6` → `CloudNetwork ×4` (`bf-public`/`fm`/`terpene-db`/ | 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 |
`bf-core-mesh`) + `CloudService ×14`; Service↔Tunnel über das Through-Modell | 2 | **Caddy hat nichts verloren** — 16 Sites, live **byte-gleich** zum Repo (`diff` identisch, 2977 B) | LXC 130 |
`cloud-service-network-assignments` (3 Rows). IDs/Ports/Hostnames/Origins je Objekt in `extra_config`. | 3 | 🔴 **Internes Split-Horizon tot** | s. u. |
- **Werkzeug:** `nautobot/tp44-cf-estate-seed.py` — idempotenter Seeder (Re-Run = 26/26 „existiert"), | 4 | 🔴 **`ds-bf` öffentlich kaputt** | s. u. |
Datenstand inline (Live-CF-Inventur 2026-09-02). Token aus `docs/nas-vlan20/stacks/210-nautobot.yml`.
- ✅ **Verifikation 2026-09-06 (live gegen die API):** CloudAccount 1 · ResourceType 6 · CloudNetwork 4 · 🔴 **Root Cause #3 (bewiesen).** AGH wertet DNS-Rewrites **im Filter-Pfad** aus; für Clients mit
CloudService 14 — unverändert seit 09-02. `filtering_enabled: false` werden sie **übersprungen**. Genau die Gruppen, von denen gebrowst wird,
- 🔴 **Gotchas für Etappe 2:** `CloudService.cloud_networks` ist `read_only` (nur Through-Rows schreiben/ sind unfiltriert (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`, `grp-media`, `grp-mgmt`). Diese
lesen) · VPC-Service-API unter `connectivity/directory/services` (PUT, voller Body) · Repo-`CF_API_TOKEN` Clients fallen auf den conditional forward `[/bf.emsenhuber.net/] → 10.40.20.1` (Core) zurück — der
darf kein Workers-VPC → dort über den `cloudflare`-MCP-Credential. Core hält aber nur **10** Host-Records; die **11 Dienstnamen** (`nautobot`, `portainer`, `pbs`, `ccu`,
- **Offen (Etappe 2, eigene Session):** (1) CF-API→Nautobot-Sync (Python/n8n, analog golden-config-bot) → `iobroker`, `z2m25`, `tv`, `mfc`, `ha-mcp`, `gitea-mcp`, `n8n-mcp`) existieren **nur** als AGH-Rewrite
driftfrei; (2) IPAM-Prefixe `10.40.0.0/16` + `10.101.0.0/24` anlegen, dann greifen die Prefix-Assignments → **NXDOMAIN**. Aus dem AGH-Container selbst (`127.0.0.1`, kein Client-Match) liefern dieselben
automatisch. Rewrites korrekt — **deshalb war jede Handoff-Abnahme grün: sie wurde nie vom Browser aus gemessen.**
- **Beziehung zu TP37:** TP44 hat **TP37 Punkt 3** (CF-Connectoren) vorweggenommen — dieselbe native-Modell- Zweitdefekt: `pve.bf`/`pve-mcp.bf` **doppelt** definiert (Core → Host-IP, AGH → Caddy `.40`).
Linie, die TP37 P1+2 (Substrat/Dienste) fortsetzt.
🔴 **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 | offen |
| **D** | Tunnel-Inventar + GC + generische Namen, TP30 #10, Leak-Ursachen | offen |
| **E** | nas/pve/macmini mit Doppeladresse — **eigene Session**, pve2 nur reservieren | offen |
| **F** | CF-/Tunnel-SSoT + Recovery → **TP46** | offen |
🔴 **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.
#### 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` existiert nirgends** — weder Core-Static noch AGH-Rewrite, obwohl ap3 seit 08-30 live ist.
- **Abh.:** TP32 (Systematik) · TP37 ✅ (Nautobot-Datenmodell) · TP21 ✅. **Blockiert:** TP34 (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.
--- ---
@@ -2761,7 +2880,7 @@ Kurzorientierung, wo was zu finden ist:
| **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung | | **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung |
| **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos | | **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos |
| **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement | | **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement |
| **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep | | **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep · [TP44](teilprojekte-archiv.md#tp44) CF-Estate Doku-SSoT |
| **Zugang** | [TP10](teilprojekte-archiv.md#tp10) Multi-Site-VPN *(offen nur der DDNS-Updater, s. Follow-ups)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh | | **Zugang** | [TP10](teilprojekte-archiv.md#tp10) Multi-Site-VPN *(offen nur der DDNS-Updater, s. Follow-ups)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh |
| **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) | | **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) |
@@ -2848,6 +2967,12 @@ den Weg, über den man den Fehler zurücknehmen wollte.
### Follow-ups nebenher (blockieren nichts) ### 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** 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 - **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; 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 80 MHz kostet Randabdeckung; unzulässiger Channel-Override auf `w2-iot`; keine `access-list`; 5-Minuten-Takt
@@ -2916,3 +3041,78 @@ TP22 · TP-R · **TP3.2** (08-17) · **TP5** (08-18) · **TP7.1** (08-18) · **T
7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest). 7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest).
</details> </details>
### TP43 — dw-Standort: interne BF-DNS-Namen über WG auflösbar machen ▸ ✅ **UMGESETZT 2026-09-11** — dw-FritzBox: Upstream `.36`+`.37`, DNS-Rebind-Ausnahme `bf.emsenhuber.net`, Fallback-Haken „öffentliche NS bei Unreachbarkeit" **gesetzt** (Ideal-Variante: `.37`-Redundanz bleibt, BF-down → public). (verworfene 420-Historie am Ende dieses Abschnitts)
**Live-Auslöser (2026-09-11):** Christoph am dw-Standort, WG-Tunnel zu BF up, kann `portainer.bf.emsenhuber.net`
nicht erreichen. Direkte IP `https://10.40.20.126:9443` geht ⇒ **reines DNS-, kein Routing-Problem.** Der
dw-Client nutzt die FritzBox (`10.95.10.1`) als Resolver; die kennt die interne Zone `bf.emsenhuber.net`
(Split-Horizon `.36/.37 → 10.40.20.40` Caddy) nicht.
**Korrektur gegenüber dem verworfenen Scope:** Der ursprüngliche Fall (`420.teilzeitladen.xyz`) ist ein
**öffentlicher** CF-Worker und war nie das Problem — der ist via TP41/WARP + CF Access gelöst. Das echte,
offene Problem sind **interne** BF-Namen (`*.bf.emsenhuber.net`) über den WG-Tunnel.
**Live verifiziert (2026-09-11, `dig @10.40.20.36` über den Tunnel):** `portainer.bf.emsenhuber.net
→ 10.40.20.40` (Caddy), `macmini.bf.emsenhuber.net → 10.40.20.167` — alle internen bf-Namen landen
in `10.40.0.0/16`, über WG erreichbar. `ds-fm`/`gateway-fm.emsenhuber.net → 188.114.96.10/.97.10`
= **Cloudflare-Anycast** (public), **nie `10.101.0.x`**. ⇒ **Der fm-Einwand (10.101.x intern ohne
Pfad von dw) trifft für `.36/.37` nicht zu** — fm läuft über den public `fm.emsenhuber.net`-Tunnel,
nicht über interne Records. (WG-Topologie TP10: dw-Peer AllowedIPs BF-seitig = `10.40.0.0/16`, dw
routet **nicht** nach `10.101.0.0/24`.)
**Constraint am dw:** die 24/7-Geräte `10.95.10.246/.247` sind **Cudy-APs (kein DNS-Host)**; die
FritzBox kann **kein Per-Zone-Forwarding** (nur einen globalen Upstream) **und per DHCP nur EINE
DNS-IP ausliefern** (⇒ „mehrere NS an die Clients" scheidet aus). Ein echter conditional forwarder
(dnsmasq `server=/bf.emsenhuber.net/.36`) bräuchte einen Zusatz-Dauerläufer — abgelehnt.
**Entscheidung (Christoph, 2026-09-11): FB-Upstream + Rebind-Ausnahme.** Clients bekommen per DHCP
`10.95.10.1` (die FB selbst, Default); die **FB forwardet an die BF-Resolver**: *DNS-Server → „Andere
DNSv4-Server"* Bevorzugt **`10.40.20.36`**, Alternativ **`10.40.20.37`** (beide BF, beide
Voll-Resolver → Redundanz bleibt). Sauberer als der zwischenzeitliche DHCP-Multi-Gedanke: die FB ist
der **einzige** Client-Resolver und `.36` löst **intern und public** auf → alles geht zuerst an `.36`,
public wird nur als Unreachable-Fallback konsultiert, **nie „zuerst"** → kein NXDOMAIN-Leak im
Normalbetrieb.
🔴 **DNS-Rebind-Schutz-Ausnahme `bf.emsenhuber.net`** (*Heimnetz → Netzwerk → Netzwerkeinstellungen →
DNS-Rebind-Schutz*) ist **zwingend**: sonst filtert die FB die Privat-IP-Antworten (`10.40.x`) als
Rebind-Angriff weg → bf-Namen liefern nichts, obwohl `.36` korrekt antwortet. Die Ausnahme ist ein
reines Namens-Whitelist (keine IP).
**BF-down-Fallback (Verzweigung am FRITZ!OS-Stand):** Christoph erinnert ein Setting „öffentliche NS
verwenden, wenn die primären nicht erreichbar sind". Vorhanden → aktivieren, Upstream bleibt
`.36`+`.37`, BF-down geht auf public. Fehlt → Alternativ-Slot statt `.37` auf **public `9.9.9.9`**
(kostet `.37`-Redundanz); dann zusätzlich prüfen, dass die FB **strikt primär** fragt (nicht
round-robbt), sonst NXDOMAIN-Leak für `bf.*` bei BF-up.
- **BF-up:** FB → `.36` → interne `bf.*` (→ `.40`, dank Rebind-Ausnahme durchgereicht) **und** public.
- **BF-down:** `.36`/`.37` unreachable → public-Fallback → public lebt, `bf.*` = NXDOMAIN (erwartet).
**Bewusst akzeptierter Tradeoff:** bei BF-up läuft Geli's **gesamter** Haushalt-DNS über Linz
(`.36`/AdGuard-Filter) — vermeidbar nur mit conditional forwarder (Zusatz-HW, abgelehnt).
**On-site-Verifikationen (Christoph, am dw):** (1) Rebind-Ausnahme greift — `nslookup
portainer.bf.emsenhuber.net` über die FB → stabil `10.40.20.40`; (2) BF-down-Fallback vorhanden
(Setting) oder via public-Alternativ-Slot gesetzt; (3) nur falls BF+public gemischt: FB fragt strikt
primär. Rückfall pro Gerät: `/etc/hosts` `10.40.20.40 <name>` oder WARP `fallback_domains
bf.emsenhuber.net → [.36,.37]` (TP41-Präzedenz).
**Sofort-Workaround (läuft unabhängig):** `https://10.40.20.126:9443` direkt bzw.
`/etc/hosts`-Zeile `10.40.20.40 portainer.bf.emsenhuber.net`.
**Abhängigkeiten:** WG-Route dw→`10.40.0.0/16` (steht). Grenzt an TP42 (interner Namensraum
`bf.emsenhuber.net`) und TP10 (Multi-Site-VPN). **Umsetzung = eigene On-site-Session** (FritzBox-DHCP
ist Christophs manueller Schritt, kein Tool-Zugriff).
---
**Historie — verworfener 420-Scope (angelegt 2026-09-01, verworfen 2026-09-11):**
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
uns verfranzen. Geli kommt rein, das zählt."* — Diese Prämisse fiel weg: `420.teilzeitladen.xyz` ist ein
public CF-Worker hinter CF Access (löst auf CF-Anycast), kein BF-Origin ⇒ dafür braucht es kein
Split-Horizon; Zugang via TP41-**WARP** gelöst (Geli 2026-09-10 via Android). Der Override-Bedarf für
**interne** Namen blieb dabei übersehen — deshalb reopened, s.o.

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

@@ -84,7 +84,7 @@ dns:
ipset_file: "" ipset_file: ""
bootstrap_prefer_ipv6: false bootstrap_prefer_ipv6: false
upstream_timeout: 10s upstream_timeout: 10s
private_networks: [] private_<redacted>: []
use_private_ptr_resolvers: false use_private_ptr_resolvers: false
local_ptr_upstreams: [] local_ptr_upstreams: []
use_dns64: false use_dns64: false

14
jobs/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""bf-infra Nautobot-Jobs (Git-Datenquelle).
Nautobot importiert nur dieses Paket; damit die enthaltenen Jobs sich
registrieren, muss jedes Job-Modul hier importiert werden. Neue periodische
externe->Nautobot-Syncs kommen als weiteres Modul dazu (kein per-Task-Container).
"""
from nautobot.apps.jobs import register_jobs
from .cf_estate_sync import CFEstateSync
# Nautobot importiert nur dieses Paket (bf_infra.jobs) und erwartet register_jobs()
# HIER im __init__ (nicht im Submodul) — sonst: "No jobs were registered on loading
# the bf_infra.jobs submodule". Neue Jobs: Klasse importieren + hier mitregistrieren.
register_jobs(CFEstateSync)

267
jobs/cf_estate_sync.py Normal file
View File

@@ -0,0 +1,267 @@
"""
TP44 Teil B — CF-Estate → Nautobot, als geplanter Nautobot-Job (ORM).
Kanonische Runtime des CF→Nautobot-Sync. Laeuft im nautobot-worker (kein extra
Container), schreibt per ORM (keine Nautobot-Exposure), zieht die CF-API outbound.
Portierung der Reconcile-Logik aus nautobot/tp44-cf-sync.py (REST) auf das ORM.
Governance (User 2026-09-09): periodische externe->Nautobot-Syncs leben als
Nautobot-Jobs in bf-infra/jobs/ und werden in Nautobot geplant — KEIN per-Task-
Container/-Cron. Kuenftige Syncs = weitere Jobs hier.
Deckung: CloudNetwork (cfd_tunnel + warp_connector), CloudService (workers,
access-apps, hyperdrive, VPC), Prefix-Assignments (teamnet/routes), Verwaisung
(stale-Markierung statt Loeschen). ID-Matching + extra_config-Merge wie im Skript.
Token: Nautobot-Secret "cf-api-token" (Provider environment-variable -> CF_API_TOKEN
am Worker). CF-Account-ID: Secret "cf-account-id" ODER Konstante unten.
CF bleibt operativer Master; Nautobot ist Dokumentations-SSoT (read-only Pull).
"""
import json
import urllib.request
import urllib.parse
import urllib.error
from django.contrib.contenttypes.models import ContentType
from nautobot.apps.jobs import BooleanVar, Job
from nautobot.cloud.models import (
CloudAccount,
CloudNetwork,
CloudNetworkPrefixAssignment,
CloudResourceType,
CloudService,
)
from nautobot.dcim.models import Manufacturer
from nautobot.extras.models import Secret
from nautobot.ipam.models import Prefix
CF_API = "https://api.cloudflare.com/client/v4"
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
RESOURCE_TYPES = [
("Cloudflare cloudflared Tunnel", "cloud", "cloudnetwork"),
("Cloudflare WARP Connector", "cloud", "cloudnetwork"),
("Cloudflare VPC Service", "cloud", "cloudservice"),
("Cloudflare Hyperdrive", "cloud", "cloudservice"),
("Cloudflare Worker", "cloud", "cloudservice"),
("Cloudflare Access Application", "cloud", "cloudservice"),
]
class CFEstateSync(Job):
dryrun = BooleanVar(
default=True,
description="Nur berichten, nichts schreiben (empfohlen fuer den ersten Lauf).",
)
class Meta:
name = "CF Estate Sync"
description = "Cloudflare-Estate (Tunnel/Worker/Access/Hyperdrive/VPC) nach Nautobot syncen (Doku-SSoT)."
grouping = "TP44 / Cloudflare"
# Nur ein Boolean (dryrun) als Eingabe; das CF-Token kommt aus dem Nautobot-Secret,
# nicht aus einer Job-Variable. Ohne diese Zeile (Default True) verweigert Nautobot
# das Scheduling: "Job may have sensitive input variables".
has_sensitive_variables = False
# ---------------------------------------------------------------- CF-Pull
def _cf_get(self, path):
"""(ok, result) — 403/404 werden abgefangen, damit fehlende Scopes den Sync nicht kippen."""
req = urllib.request.Request(CF_API + path, headers={"Authorization": "Bearer " + self._tok})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return True, json.load(r).get("result")
except urllib.error.HTTPError as e:
if e.code in (403, 404):
return False, None
raise
def _ingress(self, tunnel_id):
ok, res = self._cf_get(f"/accounts/{CF_ACCOUNT_ID}/cfd_tunnel/{tunnel_id}/configurations")
rules = ((res or {}).get("config") or {}).get("ingress") or [] if ok else []
return [r["hostname"] for r in rules if r.get("hostname")]
def _load_cf(self):
"""networks/services: (name, rt_name, desc, extra, match_key, match_val).
routes: {tunnel_id: cidr}. skipped: Liste nicht lesbarer Endpoints."""
A = CF_ACCOUNT_ID
networks, services, routes, skipped = [], [], {}, []
_, tuns = self._cf_get(f"/accounts/{A}/cfd_tunnel?is_deleted=false&per_page=50")
for t in (tuns or []):
networks.append((t["name"], "Cloudflare cloudflared Tunnel",
f"cloudflared-Tunnel ({len(t.get('connections') or [])} Connectoren)",
{"tunnel_id": t["id"], "kind": "cloudflared",
"connector_count": len(t.get("connections") or []),
"ingress_hostnames": self._ingress(t["id"])},
"tunnel_id", t["id"]))
_, warps = self._cf_get(f"/accounts/{A}/warp_connector?per_page=50")
for w in (warps or []):
networks.append((w["name"], "Cloudflare WARP Connector",
f"WARP-Connector ({len(w.get('connections') or [])} Connectoren)",
{"tunnel_id": w["id"], "kind": "warp_connector",
"connector_count": len(w.get("connections") or [])},
"tunnel_id", w["id"]))
_, scripts = self._cf_get(f"/accounts/{A}/workers/scripts")
for s in (scripts or []):
services.append((s["id"], "Cloudflare Worker", "Cloudflare Worker",
{"script": s["id"]}, "script", s["id"]))
ok, apps = self._cf_get(f"/accounts/{A}/access/apps?per_page=100")
if not ok:
skipped.append("access/apps")
for a in (apps or []):
services.append((a.get("name") or a.get("id"), "Cloudflare Access Application",
f"Access-App ({a.get('type')})",
{"id": a.get("id"), "type": a.get("type"), "domain": a.get("domain")},
"id", a.get("id")))
ok, hyper = self._cf_get(f"/accounts/{A}/hyperdrive/configs")
if not ok:
skipped.append("hyperdrive/configs")
for h in (hyper or []):
origin = h.get("origin") or {}
services.append((h.get("name") or h.get("id"), "Cloudflare Hyperdrive", "Hyperdrive-Config",
{"id": h.get("id"), "database": origin.get("database"),
"user": origin.get("user")}, "id", h.get("id")))
ok, vpc = self._cf_get(f"/accounts/{A}/connectivity/directory/services")
if not ok:
skipped.append("connectivity/directory/services")
for v in (vpc or []):
# VPC-Directory-Services tragen ihren Identifier in "service_id" (NICHT "id").
sid = v.get("service_id") or v.get("id")
services.append((v.get("name") or sid, "Cloudflare VPC Service", "VPC-Service",
{"service_id": sid, "type": v.get("type")}, "service_id", sid))
ok, rts = self._cf_get(f"/accounts/{A}/teamnet/routes?per_page=50")
if not ok:
skipped.append("teamnet/routes")
for r in (rts or []):
if r.get("network") and r.get("tunnel_id"):
routes[r["tunnel_id"]] = r["network"]
return networks, services, routes, skipped
# ---------------------------------------------------------------- ORM-Upsert
def _upsert(self, model, cache, name, rt, acct, desc, extra, mk, mv, label, seen, dryrun):
# mv=None nie matchen: sonst greift .get(mk)==None auf das erste Objekt OHNE mk
# und mischt fremde extra_config zusammen (z.B. VPC ohne id -> App-Launcher).
if mv is None:
self.logger.warning("~ %s '%s' ohne Match-Wert (%s) — uebersprungen", label, name, mk)
return None
ex = next((o for o in cache if (o.extra_config or {}).get(mk) == mv), None)
if ex:
seen.add(ex.id)
merged = dict(ex.extra_config or {})
merged.update(extra)
if merged == (ex.extra_config or {}):
return ex
if dryrun:
self.logger.info("~ %s '%s': extra_config-Update [DRY-RUN]", label, ex.name)
return ex
ex.extra_config = merged
ex.validated_save()
self.logger.info("~ %s '%s' aktualisiert", label, ex.name)
return ex
if dryrun:
self.logger.info("+ %s '%s' [DRY-RUN]", label, name)
return None
obj = model(name=name, cloud_resource_type=rt, cloud_account=acct,
description=desc, extra_config=extra)
obj.validated_save()
cache.append(obj)
seen.add(obj.id)
self.logger.info("+ %s '%s' angelegt", label, name)
return obj
def _mark_stale(self, cache, seen, label, dryrun):
for o in cache:
extra = o.extra_config or {}
if o.id in seen:
if extra.get("stale") and not dryrun:
o.extra_config = {k: v for k, v in extra.items() if k != "stale"}
o.validated_save()
self.logger.info("↩ %s '%s': stale entfernt (wieder da)", label, o.name)
continue
if extra.get("stale"):
continue
if dryrun:
self.logger.warning("⚠ %s '%s' fehlt im Live-Pull → wuerde stale markiert [DRY-RUN]", label, o.name)
continue
o.extra_config = dict(extra, stale=True)
o.validated_save()
self.logger.warning("⚠ %s '%s' als stale markiert (nicht mehr in CF)", label, o.name)
# ---------------------------------------------------------------- run
def run(self, dryrun=True):
try:
self._tok = Secret.objects.get(name="cf-api-token").get_value()
except Secret.DoesNotExist:
raise RuntimeError("Nautobot-Secret 'cf-api-token' fehlt — bitte anlegen (Provider env, CF_API_TOKEN).")
networks, services, routes, skipped = self._load_cf()
self.logger.info("Live-Pull: %d CloudNetworks · %d CloudServices%s",
len(networks), len(services),
f" · SKIP: {', '.join(skipped)}" if skipped else " · alle Endpoints lesbar")
mfr, _ = Manufacturer.objects.get_or_create(name="Cloudflare")
acct, created = CloudAccount.objects.get_or_create(
name="Cloudflare", defaults={"account_number": CF_ACCOUNT_ID, "provider": mfr})
if created and dryrun:
pass
rt_by_name = {}
for name, app, model_name in RESOURCE_TYPES:
ct = ContentType.objects.get(app_label=app, model=model_name)
rt, _ = CloudResourceType.objects.get_or_create(name=name, defaults={"provider": mfr})
rt.content_types.add(ct)
rt_by_name[name] = rt
net_cache = list(CloudNetwork.objects.filter(cloud_account=acct))
svc_cache = list(CloudService.objects.filter(cloud_account=acct))
seen_net, seen_svc = set(), set()
self.logger.info("[CloudNetworks]")
for name, rt_name, desc, extra, mk, mv in networks:
self._upsert(CloudNetwork, net_cache, name, rt_by_name.get(rt_name), acct,
desc, extra, mk, mv, "CloudNetwork", seen_net, dryrun)
self.logger.info("[CloudServices]")
for name, rt_name, desc, extra, mk, mv in services:
self._upsert(CloudService, svc_cache, name, rt_by_name.get(rt_name), acct,
desc, extra, mk, mv, "CloudService", seen_svc, dryrun)
self.logger.info("[Prefix-Assignments]")
for tunnel_id, cidr in routes.items():
net = next((n for n in net_cache if (n.extra_config or {}).get("tunnel_id") == tunnel_id), None)
if not net or getattr(net, "id", None) is None:
continue
pfx = Prefix.objects.filter(prefix=cidr).first()
if not pfx:
self.logger.info(" ~ Route %s: Prefix nicht in IPAM → uebersprungen", cidr)
continue
if CloudNetworkPrefixAssignment.objects.filter(cloud_network=net, prefix=pfx).exists():
continue
if dryrun:
self.logger.info(" + %s ↔ %s [DRY-RUN]", net.name, cidr)
continue
CloudNetworkPrefixAssignment(cloud_network=net, prefix=pfx).validated_save()
self.logger.info(" + %s ↔ %s angelegt", net.name, cidr)
self.logger.info("[Verwaisung]")
# CloudServices nur markieren, wenn ALLE Service-Endpoints lesbar waren — sonst
# wuerde ein fehlender Scope echte Objekte faelschlich als verwaist markieren.
svc_safe = not ({"access/apps", "hyperdrive/configs", "connectivity/directory/services"} & set(skipped))
if svc_safe:
self._mark_stale(svc_cache, seen_svc, "CloudService", dryrun)
else:
self.logger.info(" · CloudService-Verwaisung uebersprungen (Token-Scope unvollstaendig)")
self._mark_stale(net_cache, seen_net, "CloudNetwork", dryrun)
self.logger.info("Fertig (%s).", "DRY-RUN — nichts geschrieben" if dryrun else "COMMIT")
# Registrierung zentral in jobs/__init__.py (Nautobot importiert nur das Paket).

View File

@@ -52,15 +52,19 @@ BENUTZUNG
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
""" """
import argparse import argparse
import importlib.util
import json import json
import os import os
import pathlib
import re
import sys import sys
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen) # 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). # Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1}) q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", []) 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 = {} by_name = {}
for p in found: for p in sorted(found, key=created):
name = (p.get("device") or {}).get("display") name = (p.get("device") or {}).get("display")
by_name[name] = p["id"] 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/ 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. 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 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. `address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
@@ -353,10 +371,367 @@ def job_tp42_dhcp_domain():
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")] 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):
"""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 ("tp45-v30: {mac} -> {soll}") }} '
f'else={{ :log info ("tp45-v30: {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")]
JOBS = { JOBS = {
"drift-cleanup": job_drift_cleanup, "drift-cleanup": job_drift_cleanup,
"mgmt-acl": job_mgmt_acl, "mgmt-acl": job_mgmt_acl,
"tp42-dhcp-domain": job_tp42_dhcp_domain, "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-pool-v20": job_tp45_pool_v20,
"tp45-v30-leases": job_tp45_v30_leases,
} }

View File

@@ -61,7 +61,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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 GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy 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) REPO = os.path.dirname(HIER)
AKTEN = os.path.join(REPO, "docs", "transaktionen") AKTEN = os.path.join(REPO, "docs", "transaktionen")
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml") 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", PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_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.""" """Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
import json, os, sys, urllib.parse, urllib.request, urllib.error 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"] TOKEN = os.environ["NAUTOBOT_TOKEN"]
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv
log = [] log = []

View File

@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
""" """
import json, os, sys, urllib.error, urllib.parse, urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv 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 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv
log = [] log = []

View File

@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
""" """
import json, os, sys, urllib.error, urllib.parse, urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv 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 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 import os
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored) TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv

View File

@@ -27,7 +27,7 @@ import pathlib
import sys import sys
import urllib.request 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" DEVICE = "crs326-core"
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env" 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 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv
log = [] 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 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv
log = [] 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 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv

View File

@@ -38,7 +38,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv APPLY = "--apply" in sys.argv

View File

@@ -29,7 +29,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv APPLY = "--apply" in sys.argv

View File

@@ -35,7 +35,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv 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 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv
log = [] 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.parse
import urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
DRY = "--apply" not in sys.argv DRY = "--apply" not in sys.argv

View File

@@ -30,7 +30,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request 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", "") TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
APPLY = "--apply" in sys.argv APPLY = "--apply" in sys.argv

View File

@@ -36,7 +36,7 @@
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#} {#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
{%- set vlans = [ {%- set vlans = [
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"}, {"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": 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": 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"}, {"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=ether14 pvid=30
/interface bridge port add bridge=bridge comment=defconf interface=ether15 /interface bridge port add bridge=bridge comment=defconf interface=ether15
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100 /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 }} /interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
{% endfor %} {% 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-sfpplus1
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2 /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). # 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,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=50
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90 /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 /interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
# [1] INTERFACE-LISTS # [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 }} /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 %} {% endfor %}
{% for v in vlans %} {% 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 %} {% endfor %}
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101 /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, # [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1). # scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den # `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-media vlan-id=40
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50 /interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90 /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 # sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no. # "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus). # Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>" # 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-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 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>>" /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). # 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-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 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 # st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
/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 # 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-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-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 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 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 /interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
@@ -273,17 +301,16 @@
# python3 nautobot/gc-transaktion.py begin --device crs326-core \ # python3 nautobot/gc-transaktion.py begin --device crs326-core \
# --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT # --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT
# #
# 🔴 Hier landet TP25 **Fund E** (`signal-range`-Boden gegen klebende Clients), # 🔴 Stand 2026-09-14 (Terrasse-In-situ-Session): der `signal-range`-Boden gegen
# sobald er entschieden ist. Die alte Auflage lautete "vor dem Eintragen den # klebende Clients ist GEPRUEFT und GESCHEITERT -- `/interface wifi access-list`
# Sticky-Fall erneut messen" -- das ist am 2026-08-30 passiert: dasselbe iPhone # hat kein `band`-Property (Band-Scoping ginge nur ueber cap-wifiN-Interface-Namen,
# machte 16 Bandwechsel in 20 Minuten bei -53..-81 dBm, erst an ap3, dann an ap2. # die bei jedem CAP-Rejoin wechseln), SSID-/MAC-Scope ohne Band träfe 2,4 GHz mit
# Fund E ist damit WIEDER OFFEN. # (Lockout-Risiko, Schlafzimmer -84). Der Boden ist damit NICHT umsetzbar; der
# Eingetragen wird hier trotzdem NICHTS, und zwar bewusst: ein signal-range-Boden # Ersatz gegen die Call-Aussetzer ist `ft=yes ft-over-ds=yes` auf sec-main
# ist der invasivste der drei Hebel und kann Clients AUSSPERREN -- genau das war # (2026-09-14 zurueckgesetzt, s. docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
# 08-22 das Gegenargument (in den schwachen Raeumen gibt es keinen besseren AP, # und handoff-20260914-tp25-ap2-insitu-terrassse.md): Roams laufen als nahtlose
# auf den ein Boden steuern koennte). Erst kommen (1) `2g-probe-delay=yes` und # FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst
# (2) `ft-over-ds=no`, jeder einzeln und als Transaktion, mit dem seit 2026-08-30 # mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14).
# laufenden wireless-Logging als Messgrundlage. Siehe teilprojekte.md TP25 Fund E.
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich): # Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 # core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
@@ -416,9 +443,20 @@
/system ntp client servers add address=10.40.99.1 /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 /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). # 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. # 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=ether1
/interface bridge port add bridge=bridge comment=defconf interface=ether2 /interface bridge port add bridge=bridge comment=defconf interface=ether2
/interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10 /interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10
@@ -427,9 +465,10 @@
/interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40 /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=ether7 pvid=20
/interface bridge port add bridge=bridge comment=defconf interface=ether8 /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 /interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20
{% endfor %} {% 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=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-sfpplus1
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2 /interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2
@@ -439,7 +478,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=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=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=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" %} {% elif obj.role.name == "Wireless AP" %}
{% set ap = obj.get_config_context()["ap"] %} {% set ap = obj.get_config_context()["ap"] %}
{% set mgmt_host = obj.primary_ip4.host %} {% set mgmt_host = obj.primary_ip4.host %}

View File

@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""
TP34 — Caddy-CustomFields + Werte auf ipam.Service-Objekte setzen.
Idempotenter Seeder (get-or-create) fuer zwei CustomFields auf ipam.Service:
caddy_fqdn (text) — Caddy-Hostname (z.B. vw.emsenhuber.net)
caddy_group (choice) — Kategorie: public / internal / mcp / https-backend
Danach werden die Werte aus dem aktuellen Caddyfile (Ist-Stand 2026-09-07)
auf die zugehoerigen ipam.Service-Objekte gesetzt.
Vorbedingung: TP37 P2 muss gelaufen sein (ipam.Service-Objekte existieren).
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
Aufruf:
python3 nautobot/tp34-caddy-cf-seed.py # Dry-Run (default)
python3 nautobot/tp34-caddy-cf-seed.py --commit # schreibt nach Nautobot
"""
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.30: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):
url = f"{NB}{path}"
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, 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 "{}")
def find(endpoint: str, params: dict, match_key: str, match_val: str):
q = urllib.parse.urlencode(dict(params, depth=0))
st, d = req("GET", f"/api/{endpoint}/?{q}")
for row in d.get("results", []):
if row.get(match_key) == match_val:
return row
return None
# ---------------------------------------------------------------- CustomField-Definitionen
# Beide CFs werden auf ContentType ipam.service gesetzt.
CF_FQDN = {
"key": "caddy_fqdn",
"label": "Caddy FQDN",
"type": "text",
"description": "Caddy-Hostname fuer diesen Dienst (z.B. vw.emsenhuber.net)",
"content_types": ["ipam.service"],
"required": False,
"default": None,
}
CF_GROUP = {
"key": "caddy_group",
"label": "Caddy Group",
"type": "select",
"description": "Caddy-Abschnitt (public/internal/mcp/https-backend)",
"content_types": ["ipam.service"],
"required": False,
"default": None,
"choices": [
{"value": "public", "display": "Oeffentlich (CF Tunnel)"},
{"value": "internal", "display": "Intern (Mesh/LAN)"},
{"value": "mcp", "display": "MCP-Endpunkt"},
{"value": "https-backend", "display": "HTTPS-Backend (Selbstsignat)"},
],
}
def ensure_custom_field(cf_def: dict) -> bool:
"""Legt einen CustomField an wenn er nicht existiert. Gibt True zurueck wenn ok."""
key = cf_def["key"]
existing = find("extras/custom-fields", {"key": key}, "key", key)
if existing:
print(f" = CF '{key}' existiert ({existing['id']})")
return True
if not COMMIT:
print(f" + CF '{key}' [DRY-RUN]")
return True
payload = {
"key": cf_def["key"],
"label": cf_def["label"],
"type": cf_def["type"],
"description": cf_def.get("description", ""),
"content_types": cf_def["content_types"],
"required": cf_def.get("required", False),
}
if cf_def.get("choices"):
payload["choices"] = cf_def["choices"]
st, d = req("POST", "/api/extras/custom-fields/", payload)
if st in (200, 201):
print(f" + CF '{key}' angelegt ({d.get('id')})")
return True
print(f" ! CF '{key}' FEHLER {st}: {json.dumps(d)[:300]}")
return False
# ---------------------------------------------------------------- Caddyfile-Routen-Inventar
# (vm_name, svc_name, fqdn, group)
# Basiert auf caddy/Caddyfile Stand 2026-09-07 (terpene als Quick-Win schon drin).
ROUTES = [
# Oeffentlich (via CF Tunnel)
("vaultwarden", "vaultwarden-http", "vw.emsenhuber.net", "public"),
("haos", "home-assistant", "hass.emsenhuber.net", "public"),
("n8n", "n8n-web", "n8n.emsenhuber.net", "public"),
("gitea", "gitea-http", "gitea.emsenhuber.net", "public"),
# terpene: kein ipam.Service in TP37 P2 → hier als Hinweis, wird separat geseedet
# ("macmini", "terpene-worker", "terpene.teilzeitladen.xyz", "public"),
# Intern
("nautobot", "nautobot-web", "nautobot.bf.emsenhuber.net", "internal"),
("docker", "portainer", "portainer.bf.emsenhuber.net", "https-backend"),
# ds-bf: kein eigener ipam.Service — NAS-DSM haengt am Port 8080 direkt
# ("nautobot", "ds-bf", "ds-bf.emsenhuber.net", "internal"),
# MCP-Endpunkte
("pve-mcp", "pve-mcp", "pve-mcp.bf.emsenhuber.net", "mcp"),
# ha-mcp: kein Service in TP37 P2 (haos lauscht auf 9583, nicht als Service angelegt)
("n8n-mcp", "n8n-mcp", "n8n-mcp.bf.emsenhuber.net", "mcp"),
("gitea-mcp", "gitea-mcp", "gitea-mcp.bf.emsenhuber.net", "mcp"),
# HTTPS-Backends (Selbstsignat)
("docker", "portainer", "portainer.bf.emsenhuber.net", "https-backend"),
]
# Nur eindeutige Eintraege (portainer erscheint nur einmal)
_seen = set()
_unique_routes = []
for r in ROUTES:
k = (r[0], r[1])
if k not in _seen:
_seen.add(k)
_unique_routes.append(r)
ROUTES = _unique_routes
def main():
print(f"== TP34 Caddy-CF-Seed ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
print("\n[1] CustomFields anlegen")
ensure_custom_field(CF_FQDN)
ensure_custom_field(CF_GROUP)
print(f"\n[2] ipam.Service-Objekte mit Caddy-CFs versehen ({len(ROUTES)} Routen)")
n_ok = n_skip = 0
for vm_name, svc_name, fqdn, group in ROUTES:
# Service-Objekt suchen
svc = find("ipam/services", {"name": svc_name, "virtual_machine": vm_name},
"name", svc_name)
if not svc:
print(f" ! {vm_name}/{svc_name}: Service nicht gefunden — uebersprungen")
n_skip += 1
continue
svc_id = svc["id"]
# Aktuelle CF-Werte pruefen
cfs = svc.get("custom_fields", {})
current_fqdn = cfs.get("caddy_fqdn")
current_group = cfs.get("caddy_group")
if current_fqdn == fqdn and current_group == group:
print(f" = {vm_name}/{svc_name}: '{fqdn}' ({group}) — unveraendert")
n_ok += 1
continue
if not COMMIT:
print(f" + {vm_name}/{svc_name}: '{fqdn}' ({group}) [DRY-RUN]"
+ (f" (war: fqdn='{current_fqdn}', group='{current_group}')"
if current_fqdn or current_group else ""))
n_ok += 1
continue
st, d = req("PATCH", f"/api/ipam/services/{svc_id}/",
{"custom_fields": {"caddy_fqdn": fqdn, "caddy_group": group}})
if st == 200:
print(f" + {vm_name}/{svc_name}: '{fqdn}' ({group}) gesetzt")
n_ok += 1
else:
print(f" ! {vm_name}/{svc_name}: FEHLER {st}: {json.dumps(d)[:300]}")
n_skip += 1
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) "
f"| gesetzt={n_ok} uebersprungen={n_skip} ==")
if __name__ == "__main__":
main()

View File

@@ -1,216 +0,0 @@
#!/usr/bin/env python3
"""
TP34 — Caddyfile aus Nautobot-Daten rendern.
Liest alle ipam.Service-Objekte mit gesetztem CustomField `caddy_fqdn` aus Nautobot
und rendert daraus ein Caddyfile. Optional: Diff gegen das eingecheckte Caddyfile
oder deploy via pct push + caddy reload.
Aufruf:
python3 nautobot/tp34-caddy-render.py # rendert + diff
python3 nautobot/tp34-caddy-render.py --deploy # rendert + diff + push + reload
python3 nautobot/tp34-caddy-render.py --out /tmp/Caddyfile # nur rendern nach Datei
"""
import difflib
import json
import os
import re
import subprocess
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.30:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
CADDYFILE = REPO / "caddy" / "Caddyfile"
DEPLOY = "--deploy" in sys.argv
OUT = None
for i, a in enumerate(sys.argv):
if a == "--out" and i + 1 < len(sys.argv):
OUT = Path(sys.argv[i + 1])
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):
url = f"{NB}{path}"
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, 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 "{}")
def fetch_caddy_services() -> list[dict]:
"""Alle ipam.Service-Objekte mit caddy_fqdn != None."""
page, out = 1, []
while True:
q = urllib.parse.urlencode({"limit": 100, "offset": (page - 1) * 100, "depth": 1})
st, d = req("GET", f"/api/ipam/services/?{q}")
results = d.get("results", [])
if not results:
break
for svc in results:
cf = svc.get("custom_fields", {})
if cf.get("caddy_fqdn"):
out.append({
"fqdn": cf["caddy_fqdn"],
"group": cf.get("caddy_group") or "internal",
"vm": (svc.get("virtual_machine") or {}).get("name", "?"),
"svc": svc.get("name", "?"),
"ports": svc.get("ports", []),
"protocol": svc.get("protocol", "tcp"),
})
if not d.get("next"):
break
page += 1
return out
def get_vm_ip(vm_name: str) -> str | None:
"""Primaere IP einer VM aus Nautobot."""
q = urllib.parse.urlencode({"name": vm_name, "depth": 1})
st, d = req("GET", f"/api/virtualization/virtual-machines/?{q}")
for vm in d.get("results", []):
pip = vm.get("primary_ip4")
if isinstance(pip, dict):
addr = pip.get("address", "")
return addr.split("/")[0] if addr else None
return None
HEADER = """\
# TP34 — Caddyfile (aus Nautobot generiert)
# SSoT: Nautobot ipam.Service + CustomFields caddy_fqdn / caddy_group
# Manueller Deploy: pct push 130 caddy/Caddyfile /etc/caddy/Caddyfile
# ssh root@10.40.20.40 "caddy reload --config /etc/caddy/Caddyfile"
{
\temail christoph@emsenhuber.com
\tacme_dns cloudflare {env.CF_DNS_API_TOKEN}
}
# --- Backends mit Selbstsignat: Caddy prueft deren Zertifikat nicht ---------
(selfsigned) {
\ttransport http {
\t\ttls_insecure_skip_verify
\t}
}
"""
GROUP_HEADERS = {
"public": "# =========================================================================\n"
"# OEFFENTLICH (via Cloudflare Tunnel + Access; Clients ohne WARP)\n"
"# =========================================================================\n",
"internal": "# =========================================================================\n"
"# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)\n"
"# =========================================================================\n",
"mcp": "# --- MCP-Endpunkte: heute spricht ~/.claude.json diese IPs roh --------------\n",
"https-backend": "# --- HTTPS-Backends (Selbstsignat) -----------------------------------------\n",
}
def render_caddyfile(services: list[dict], vm_ips: dict) -> str:
groups: dict[str, list] = {"public": [], "internal": [], "mcp": [], "https-backend": []}
for svc in sorted(services, key=lambda s: (s["group"], s["fqdn"])):
groups.setdefault(svc["group"], []).append(svc)
out = [HEADER]
for group, label in [("public", None), ("internal", None), ("mcp", None),
("https-backend", None)]:
svcs = groups.get(group, [])
if not svcs:
continue
hdr = GROUP_HEADERS.get(group, f"# --- {group} ---\n")
out.append(hdr)
for svc in svcs:
ip = vm_ips.get(svc["vm"], "MISSING")
port = svc["ports"][0] if svc["ports"] else 80
scheme = "https" if group == "https-backend" else "http"
backend = f"{scheme}://{ip}:{port}"
out.append(f'{svc["fqdn"]} {{\n')
if group == "https-backend":
out.append(f"\treverse_proxy {backend} {{\n\t\timport selfsigned\n\t}}\n")
else:
out.append(f"\treverse_proxy {backend}\n")
out.append("}\n\n")
return "".join(out)
def main():
print(f"== TP34 Caddy-Render @ {NB} ==")
print("\n[0] Caddy-Services aus Nautobot laden...")
services = fetch_caddy_services()
print(f" {len(services)} Dienste mit caddy_fqdn gefunden")
print("\n[1] VM-IPs holen...")
vm_names = sorted({s["vm"] for s in services})
vm_ips = {}
for name in vm_names:
ip = get_vm_ip(name)
vm_ips[name] = ip or "MISSING"
status = ip or "! NICHT GEFUNDEN"
print(f" {name}: {status}")
print("\n[2] Caddyfile rendern...")
rendered = render_caddyfile(services, vm_ips)
if OUT:
OUT.write_text(rendered)
print(f" → geschrieben nach {OUT}")
return
print("\n[3] Diff gegen eingechecktes Caddyfile (caddy/Caddyfile)...")
live = CADDYFILE.read_text() if CADDYFILE.exists() else ""
diff = list(difflib.unified_diff(
live.splitlines(keepends=True),
rendered.splitlines(keepends=True),
fromfile="caddy/Caddyfile (eingecheckt)",
tofile="gerendert (Nautobot)",
))
if not diff:
print(" ✅ Kein Diff — Caddyfile ist in Sync mit Nautobot")
else:
print(f" ⚠️ {len(diff)} Diff-Zeilen:")
for line in diff[:80]:
print(" " + line, end="")
if len(diff) > 80:
print(f" … ({len(diff) - 80} weitere Zeilen)")
if DEPLOY:
if diff:
print("\n[4] Deploy...")
CADDYFILE.write_text(rendered)
ret = subprocess.call(
["pct", "push", "130", str(CADDYFILE), "/etc/caddy/Caddyfile"])
if ret == 0:
subprocess.call(
["ssh", "root@10.40.20.40",
"caddy reload --config /etc/caddy/Caddyfile"])
else:
print(" ! pct push fehlgeschlagen")
else:
print("\n[4] Deploy: nichts zu tun (kein Diff)")
print("\n== fertig ==")
if __name__ == "__main__":
main()

View File

@@ -16,6 +16,9 @@ Umfang (User-Default 2026-09-07):
Nautobot:8080, AdGuard:53 (bf1+bf2, tcp+udp), platform-pg:5432, mariadb:3306, Nautobot:8080, AdGuard:53 (bf1+bf2, tcp+udp), platform-pg:5432, mariadb:3306,
influxdb2:8086, mqtt5:1883, portainer:9443, influxdb2:8086, mqtt5:1883, portainer:9443,
MCP-Dienste (pve-mcp:3000, shelly-mcp:3030, n8n-mcp:3001, gitea-mcp:3000). MCP-Dienste (pve-mcp:3000, shelly-mcp:3030, n8n-mcp:3001, gitea-mcp:3000).
+ Smart-Home (vervollstaendigt 2026-09-08, Ports per Socket-Probe verifiziert):
RaspberryMatic/CCU (80/443/2001/2010), iob-master:8081, z2m25:8080.
iob-exposed/iob_slave BEWUSST ohne Service (kein exponierter Port gefunden).
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN. Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
@@ -33,7 +36,7 @@ import urllib.parse
import urllib.error import urllib.error
from pathlib import Path 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 REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml" YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv COMMIT = "--commit" in sys.argv
@@ -118,6 +121,15 @@ SERVICES = [
("shelly-mcp", "shelly-mcp", [3030], "tcp", "Shelly MCP"), ("shelly-mcp", "shelly-mcp", [3030], "tcp", "Shelly MCP"),
("n8n-mcp", "n8n-mcp", [3001], "tcp", "n8n MCP"), ("n8n-mcp", "n8n-mcp", [3001], "tcp", "n8n MCP"),
("gitea-mcp", "gitea-mcp", [3000], "tcp", "Gitea MCP"), ("gitea-mcp", "gitea-mcp", [3000], "tcp", "Gitea MCP"),
# Smart-Home / IoT — Ports live per Socket-Probe verifiziert 2026-09-08 (NICHT raten!)
("RaspberryMatic","ccu-http", [80], "tcp", "Homematic CCU Web (HTTP)"),
("RaspberryMatic","ccu-https", [443], "tcp", "Homematic CCU Web (HTTPS)"),
("RaspberryMatic","ccu-rpc-bidcos", [2001], "tcp", "Homematic BidCos-RF XML-RPC"),
("RaspberryMatic","ccu-rpc-hmip", [2010], "tcp", "Homematic-IP XML-RPC"),
("iob-master", "iobroker-admin", [8081], "tcp", "ioBroker Admin"),
("z2m25", "zigbee2mqtt", [8080], "tcp", "Zigbee2MQTT Frontend"),
# iob-exposed (.19) + iob_slave (.33): kein exponierter Port gefunden (13 Ports, alle Timeout)
# -> BEWUSST kein Service, bis ein Port live belegt ist. Nicht "hilfreich" einen erfinden.
] ]

View File

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

View File

@@ -29,7 +29,7 @@ import urllib.parse
import urllib.error import urllib.error
from pathlib import Path 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 REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml" YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36" CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"

315
nautobot/tp44-cf-sync.py Normal file
View File

@@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""
TP44 Etappe 2 Teil B — CF-Estate LIVE aus der Cloudflare-API nach Nautobot syncen.
Ersetzt den Inline-Snapshot von tp44-cf-estate-seed.py durch einen periodischen
Live-Pull (get-or-create + Merge). Idempotent, Dry-Run default. Kein Push nach CF —
read-only Pull, Nautobot bleibt Dokumentations-SSoT, CF operativer Master.
Deckung (Live-Pull, je Endpoint 403-tolerant):
CloudNetwork cfd_tunnel + warp_connector (4)
CloudService workers/scripts + access/apps (11) [Repo-Token OK]
CloudService hyperdrive/configs + VPC directory (3) [braucht Token-Scope]
Verwaisung: ein CF-Objekt, das in Nautobot steht, aber im Live-Pull fehlt, wird
NICHT geloescht, sondern extra_config.stale=true gesetzt (Doku-SSoT bewahrt Historie).
Ein Objekt, das wieder auftaucht, verliert die Markierung.
Tokens:
CF : CF_API_TOKEN + CF_ACCOUNT_ID aus ~/.secrets/credentials.env (nie im Output)
Nautobot: NAUTOBOT_SUPERUSER_API_TOKEN aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
Aufruf:
python3 nautobot/tp44-cf-sync.py # Dry-Run (default) — zeigt Diff
python3 nautobot/tp44-cf-sync.py --commit # schreibt
"""
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
# ------------------------------------------------------------ Cloudflare-Seite
def cf_secrets():
env = {}
with open(os.path.expanduser("~/.secrets/credentials.env")) as fh:
for line in fh:
line = line.strip()
if line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip("'\"")
return env
_CF = cf_secrets()
CF_TOK, CF_ACCT = _CF.get("CF_API_TOKEN"), _CF.get("CF_ACCOUNT_ID")
if not CF_TOK or not CF_ACCT:
sys.exit("CF_API_TOKEN/CF_ACCOUNT_ID fehlen in ~/.secrets/credentials.env")
def cf_get(path):
"""(ok, result) — faengt 403/404 ab, damit fehlende Token-Scopes den Sync nicht kippen."""
req = urllib.request.Request("https://api.cloudflare.com/client/v4" + path,
headers={"Authorization": "Bearer " + CF_TOK})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return True, json.load(r).get("result")
except urllib.error.HTTPError as e:
if e.code in (403, 404):
return False, None
raise
def cf_ingress(tunnel_id):
ok, res = cf_get(f"/accounts/{CF_ACCT}/cfd_tunnel/{tunnel_id}/configurations")
rules = ((res or {}).get("config") or {}).get("ingress") or [] if ok else []
return [r["hostname"] for r in rules if r.get("hostname")]
def load_cf_estate():
"""Baut (networks, services) aus dem Live-Pull. Jedes Objekt:
(name, resource_type, description, extra_config, match_key, match_val).
match_key/-val = stabile CF-ID → idempotentes Matching trotz kuratierter Namen.
Live-Zaehl-/Statusfelder liegen unter eigenen Keys (connector_count, ingress_hostnames),
damit der Merge kuratierte Strings (z.B. connectors='3x @ ap1/ap2/ap3') nicht ueberschreibt."""
networks, services = [], []
skipped = []
# --- CloudNetworks: cloudflared-Tunnel (+ Connector-Zahl, Ingress-Hostnames)
ok, tuns = cf_get(f"/accounts/{CF_ACCT}/cfd_tunnel?is_deleted=false&per_page=50")
for t in (tuns or []):
networks.append((t["name"], "Cloudflare cloudflared Tunnel",
f"cloudflared-Tunnel ({len(t.get('connections') or [])} Connectoren)",
{"tunnel_id": t["id"], "kind": "cloudflared",
"connector_count": len(t.get("connections") or []),
"ingress_hostnames": cf_ingress(t["id"])},
"tunnel_id", t["id"]))
# --- CloudNetworks: WARP-Connector
ok, warps = cf_get(f"/accounts/{CF_ACCT}/warp_connector?per_page=50")
for w in (warps or []):
networks.append((w["name"], "Cloudflare WARP Connector",
f"WARP-Connector ({len(w.get('connections') or [])} Connectoren)",
{"tunnel_id": w["id"], "kind": "warp_connector",
"connector_count": len(w.get("connections") or [])},
"tunnel_id", w["id"]))
# --- CloudServices: Workers
ok, scripts = cf_get(f"/accounts/{CF_ACCT}/workers/scripts")
for s in (scripts or []):
services.append((s["id"], "Cloudflare Worker", "Cloudflare Worker",
{"script": s["id"]}, "script", s["id"]))
# --- CloudServices: Access-Apps
ok, apps = cf_get(f"/accounts/{CF_ACCT}/access/apps?per_page=100")
if not ok:
skipped.append("access/apps")
for a in (apps or []):
services.append((a.get("name") or a.get("id"), "Cloudflare Access Application",
f"Access-App ({a.get('type')})",
{"id": a.get("id"), "type": a.get("type"), "domain": a.get("domain")},
"id", a.get("id")))
# --- CloudServices: Hyperdrive (braucht Scope)
ok, hyper = cf_get(f"/accounts/{CF_ACCT}/hyperdrive/configs")
if not ok:
skipped.append("hyperdrive/configs")
for h in (hyper or []):
origin = h.get("origin") or {}
services.append((h.get("name") or h.get("id"), "Cloudflare Hyperdrive",
"Hyperdrive-Config",
{"id": h.get("id"), "database": origin.get("database"),
"user": origin.get("user")}, "id", h.get("id")))
# --- CloudServices: VPC-Service (braucht Scope)
ok, vpc = cf_get(f"/accounts/{CF_ACCT}/connectivity/directory/services")
if not ok:
skipped.append("connectivity/directory/services")
for v in (vpc or []):
services.append((v.get("name") or v.get("id"), "Cloudflare VPC Service",
"VPC-Service",
{"service_id": v.get("id"), "type": v.get("type")},
"service_id", v.get("id")))
return networks, services, skipped
# ------------------------------------------------------------ Nautobot-Seite
def nb_token():
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")
NB_HDRS = {"Authorization": f"Token {nb_token()}", "Accept": "application/json",
"Content-Type": "application/json"}
def nb(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(f"{NB}{path}", data=data, headers=NB_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 "{}")
def nb_all(endpoint, params):
"""Alle Rows eines Endpoints (paginiert)."""
out, url = [], f"/api/{endpoint}/?{urllib.parse.urlencode(dict(params, depth=0, limit=200))}"
while url:
st, d = nb("GET", url)
out += d.get("results", [])
nxt = d.get("next")
url = nxt.replace(NB, "") if nxt else None
return out
_INDEX = {} # endpoint -> [rows] (einmal je Account geladen)
def index(endpoint, acct_id):
if endpoint not in _INDEX:
_INDEX[endpoint] = nb_all(endpoint, {"cloud_account": acct_id})
return _INDEX[endpoint]
def upsert(endpoint, name, payload, label, seen, acct_id=None,
match_key=None, match_val=None):
"""get-or-create + Merge von extra_config.
Matching: bei (match_key,match_val) nach der stabilen CF-ID in extra_config
(Rename-fest), sonst nach Name. Trackt die getroffene id in seen[endpoint]."""
ex = None
if match_val is not None:
for row in index(endpoint, acct_id):
if (row.get("extra_config") or {}).get(match_key) == match_val:
ex = row
break
else:
for row in (index(endpoint, acct_id) if acct_id else
nb_all(endpoint, {"name": name})):
if row.get("name") == name:
ex = row
break
if ex:
seen.setdefault(endpoint, set()).add(ex["id"])
merged = dict(ex.get("extra_config") or {})
merged.update(payload.get("extra_config") or {})
if merged == (ex.get("extra_config") or {}):
print(f" = {label}: '{name}' unveraendert")
return ex
if not COMMIT:
print(f" ~ {label}: '{name}' extra_config-Update [DRY-RUN]")
return ex
st, d = nb("PATCH", f"/api/{endpoint}/{ex['id']}/", {"extra_config": merged})
print(f" ~ {label}: '{name}' aktualisiert" if st == 200
else f" ! {label}: '{name}' PATCH {st}: {json.dumps(d)[:200]}")
return d if st == 200 else ex
if not COMMIT:
print(f" + {label}: '{name}' [DRY-RUN]")
return {"id": f"<dry:{name}>"}
st, d = nb("POST", f"/api/{endpoint}/", payload)
if st in (200, 201):
seen.setdefault(endpoint, set()).add(d["id"])
print(f" + {label}: '{name}' angelegt")
return d
print(f" ! {label}: '{name}' FEHLER {st}: {json.dumps(d)[:200]}")
return None
def mark_stale(endpoint, acct_id, seen_ids, label):
"""CF-Objekte in Nautobot (per id), die im Live-Pull fehlen → extra_config.stale=true.
Ein wieder aufgetauchtes Objekt verliert die Markierung."""
for row in index(endpoint, acct_id):
extra = row.get("extra_config") or {}
nm = row.get("name")
if row["id"] in seen_ids:
if extra.get("stale") and COMMIT:
nb("PATCH", f"/api/{endpoint}/{row['id']}/",
{"extra_config": {k: v for k, v in extra.items() if k != "stale"}})
print(f" ↩ {label}: '{nm}' stale-Markierung entfernt (wieder da)")
continue
if extra.get("stale"):
continue
if not COMMIT:
print(f" ⚠ {label}: '{nm}' fehlt im Live-Pull → wuerde stale markiert [DRY-RUN]")
continue
nb("PATCH", f"/api/{endpoint}/{row['id']}/", {"extra_config": dict(extra, stale=True)})
print(f" ⚠ {label}: '{nm}' als stale markiert (nicht mehr in CF)")
RESOURCE_TYPES = [
("Cloudflare cloudflared Tunnel", "cloud.cloudnetwork"),
("Cloudflare WARP Connector", "cloud.cloudnetwork"),
("Cloudflare VPC Service", "cloud.cloudservice"),
("Cloudflare Hyperdrive", "cloud.cloudservice"),
("Cloudflare Worker", "cloud.cloudservice"),
("Cloudflare Access Application", "cloud.cloudservice"),
]
def main():
print(f"== TP44 CF-Sync ({'COMMIT' if COMMIT else 'DRY-RUN'}) CF→{NB} ==")
networks, services, skipped = load_cf_estate()
print(f"Live-Pull: {len(networks)} CloudNetworks · {len(services)} CloudServices"
+ (f" · SKIP (Token-Scope): {', '.join(skipped)}" if skipped else " · alle Endpoints lesbar"))
print("\n[1] Manufacturer + CloudAccount")
mfr = upsert("dcim/manufacturers", "Cloudflare", {"name": "Cloudflare"}, "Manufacturer", {})
mfr_id = mfr["id"]
acct = upsert("cloud/cloud-accounts", "Cloudflare",
{"name": "Cloudflare", "account_number": CF_ACCT, "provider": mfr_id},
"CloudAccount", {})
acct_id = acct["id"]
print("\n[2] CloudResourceTypes")
rt_ids = {}
for name, ct in RESOURCE_TYPES:
rt = upsert("cloud/cloud-resource-types", name,
{"name": name, "provider": mfr_id, "content_types": [ct]}, "ResourceType", {})
rt_ids[name] = rt["id"]
seen = {}
print("\n[3] CloudNetworks")
for name, rt_name, desc, extra, mk, mv in networks:
upsert("cloud/cloud-networks", name,
{"name": name, "cloud_resource_type": rt_ids.get(rt_name), "cloud_account": acct_id,
"description": desc, "extra_config": extra}, "CloudNetwork", seen,
acct_id=acct_id, match_key=mk, match_val=mv)
print("\n[4] CloudServices")
for name, rt_name, desc, extra, mk, mv in services:
upsert("cloud/cloud-services", name,
{"name": name, "cloud_resource_type": rt_ids.get(rt_name), "cloud_account": acct_id,
"description": desc, "extra_config": extra}, "CloudService", seen,
acct_id=acct_id, match_key=mk, match_val=mv)
print("\n[5] Verwaisung (stale-Markierung; nur Endpoints, die gelesen wurden)")
# Nur markieren, wenn der zugehoerige Endpoint erreichbar war — sonst wuerde ein
# fehlender Token-Scope echte Objekte faelschlich als verwaist markieren.
if "access/apps" not in skipped and "hyperdrive/configs" not in skipped \
and "connectivity/directory/services" not in skipped:
mark_stale("cloud/cloud-services", acct_id, seen.get("cloud/cloud-services", set()), "CloudService")
else:
print(" · CloudService-Verwaisung uebersprungen (Token-Scope unvollstaendig — kein sicherer Vergleich)")
mark_stale("cloud/cloud-networks", acct_id, seen.get("cloud/cloud-networks", set()), "CloudNetwork")
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
if skipped:
print(f"Hinweis: {', '.join(skipped)} nicht lesbar → Token-Scope erweitern, dann syncen sie mit.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
TP44 Etappe 2 Teil A — die zwei fehlenden IPAM-Prefixe der CF-CloudNetworks anlegen.
Zwei CloudNetworks (tp44-cf-estate-seed.py) tragen eine Route, die der Seeder als
cloud-network-prefix-assignment verknuepfen wuerde -- aber nur, wenn der Prefix in
IPAM existiert (dort Schritt [7], sonst "~ Prefix ... nicht in IPAM"):
10.40.0.0/16 -> bf-core-mesh (WARP-Connector auf crs326-core, TP16.1) [container]
10.101.0.0/24 -> fm.emsenhuber.net (Standort FM, cloudflared-Tunnel) [network]
Idempotenter get-or-create (Dry-Run default). Nach dem --commit den Seeder erneut
laufen lassen -- er haengt die zwei Assignments dann automatisch ein.
10.40.0.0/16 als 'container', weil die VLAN-/24 (10.40.10/20/30/...) aus
ingest-baseline.py bereits als Prefixe existieren; Nautobot nistet sie
automatisch darunter (rein hierarchisch, nicht-destruktiv).
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
Aufruf:
python3 nautobot/tp44-ipam-prefixes.py # Dry-Run (default)
python3 nautobot/tp44-ipam-prefixes.py --commit # legt an
"""
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):
url = f"{NB}{path}"
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, 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 "{}")
def find(endpoint: str, params: dict, match_key: str, match_val: str):
q = urllib.parse.urlencode(dict(params, depth=0))
st, d = req("GET", f"/api/{endpoint}/?{q}")
for row in d.get("results", []):
if row.get(match_key) == match_val:
return row
return None
def status_id(name: str):
return find("extras/statuses", {"name": name}, "name", name)["id"]
def namespace_id(name: str):
return find("ipam/namespaces", {"name": name}, "name", name)["id"]
# ---------------------------------------------------------------- Inventar
# (prefix, type, description)
PREFIXES = [
("10.40.0.0/16", "container",
"BF-Supernet; Route des CF WARP-Connectors bf-core-mesh (crs326-core, TP16.1)"),
("10.101.0.0/24", "network",
"Standort FM; Route des CF cloudflared-Tunnels fm.emsenhuber.net"),
]
def main():
print(f"== TP44 IPAM-Prefixe ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
st_active = status_id("Active")
ns_global = namespace_id("Global")
for prefix, ptype, desc in PREFIXES:
existing = find("ipam/prefixes", {"prefix": prefix}, "prefix", prefix)
if existing:
print(f" = Prefix: '{prefix}' existiert ({existing['id']})")
continue
if not COMMIT:
print(f" + Prefix: '{prefix}' [{ptype}] [DRY-RUN]")
continue
payload = {"prefix": prefix, "status": st_active, "namespace": ns_global,
"type": ptype, "description": desc}
st, d = req("POST", "/api/ipam/prefixes/", payload)
if st in (200, 201):
print(f" + Prefix: '{prefix}' [{ptype}] angelegt ({d.get('id')})")
else:
print(f" ! Prefix: '{prefix}' FEHLER {st}: {json.dumps(d)[:300]}")
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
if not COMMIT:
print("Danach: python3 nautobot/tp44-cf-estate-seed.py --commit (haengt die 2 Assignments ein)")
if __name__ == "__main__":
main()

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()

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

@@ -0,0 +1,276 @@
#!/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
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.
}
# 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"}
# 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", "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 ""))
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 re
import urllib.request 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" 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") OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc") LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")