16 Commits

Author SHA1 Message Date
7b365e1586 TP45 Doku: Schicht C (FQDN) als durch dokumentiert, Pflichtlektuere nachgezogen
- docs/teilprojekte.md: Schicht-C-Zeile auf C1-C3 durch; neuer Abschnitt mit
  C1 (27 Core-Statics, Job tp45-c-dns), C2 (AGH entdoppelt), C3 (ds-bf ueber
  Caddy), WARP-Abnahme, sowie den zwei offen gelassenen Befunden (AAAA-Leak,
  AGH-Doppeladresse) und der dig-Messfalle. ap3.bf-Bullet korrigiert.
- CLAUDE.md: Pflichtlektuere Block 0 beschrieb noch das ungeloeste FQDN-Problem.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-21 12:58:40 +02:00
43 changed files with 1801 additions and 158 deletions

View File

@@ -6,6 +6,16 @@ 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 (Gitea #71) — Schicht C (DNS/FQDN) ist durch (2026-09-22, Commit `6d1d56e`), vorgezogen.**
Die internen `*.bf.emsenhuber.net` lösen wieder auf (der Core hält jetzt **27** Statics statt 10),
`ds-bf` ist öffentlich heil (läuft über Caddy), AGH macht **nur noch Filtering** (Upstream = Core).
Der *sichtbare* Schaden ist damit weg — der Ursachen-Kontext steht unter `### TP45` (AGH wertete
Rewrites im Filter-Pfad aus → unfiltrierte Clients fielen auf den Core zurück).
**Offen sind die IP-Schichten, in dieser Reihenfolge:** **A** VLAN20 ins TP32-Schema (Nautobot
`.30–.34` → `.154–.158` zuerst, dann AGH `.36/.37` → `.30/.31` — ⚠️ AGH ist **doppelt adressiert**,
beide Adressen live) → **B** VLAN30-Pool-Grenze (braucht den DHCP-Reservierungs-Renderer) →
**E** nas/pve/macmini umziehen (eigene Session) → **C4** (TP34: FQDN als Daten).
**`### 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`**.
@@ -112,7 +122,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
| **🔑 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` ✓ |
@@ -150,6 +160,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.
--- ---

View File

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

View File

@@ -47,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"

View File

@@ -2,7 +2,7 @@
> 🆕 **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)

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.

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
@@ -1864,15 +1867,16 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
> Gerät. Deshalb steht hier die Liste, nicht der Termin. > Gerät. Deshalb steht hier die Liste, nicht der Termin.
- **Auslösekriterium (statt Datum):** wenn die offenen TPs abgearbeitet sind bzw. Christoph das - **Auslösekriterium (statt Datum):** wenn die offenen TPs abgearbeitet sind bzw. Christoph das
Projekt schließt. 🔴 **Harte Ausnahme, die den Termin vorzieht:** ein *neuer* Leak, ein Projekt schließt. *(2026-09-21: eine frühere „harte Ausnahme für neue Leaks" wurde **gestrichen** —
fremdzugängliches Gerät oder ein Personenwechsel — dann sofort und einzeln, nicht gebündelt. sie widersprach dem Bündelungszweck dieses TPs und der Praxis: auch die leak-getriebenen Posten
#1 und #5–#9 stehen unverändert im Bündel. Ein Transkript-Leak zieht **keinen** Posten vor.)*
| # | Credential | Umfang | Anlass | Vorarbeit | Preis der Rotation | | # | Credential | Umfang | Anlass | Vorarbeit | Preis der Rotation |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1 | **MikroTik `admin`** | core · branch · ap1 · ap2 (**ein** geteilter Wert) | 🔴 **zweimal in ein Transkript geraten** (2026-08-22 via `sshpass -p`; 2026-08-23 durch einen Enumerationsfehler) | ✅ **Etappe 1 durch** (s.u.) | nach Etappe 2 nur noch `~/.secrets/credentials.env` + `~/.claude.json` + MikroMCP-Neustart | | 1 | **MikroTik `admin`** | core · branch · ap1 · ap2 (**ein** geteilter Wert) | 🔴 **zweimal in ein Transkript geraten** (2026-08-22 via `sshpass -p`; 2026-08-23 durch einen Enumerationsfehler) | ✅ **Etappe 1 durch** (s.u.) | nach Etappe 2 nur noch `~/.secrets/credentials.env` + `~/.claude.json` + MikroMCP-Neustart |
| 2 | **IoT-WLAN-PSK** (`sec-iot`) | `dirtyvader-iot`, 26 Clients | steht **im Klartext** im TP17-Text ⇒ auch in der **Git-Historie**; Redigieren holt ihn dort nicht raus | Hebel `multi-passphrase` am Core verifiziert vorhanden, Tabelle leer | 23 Shellys per Skript + **3 von Hand** (`.249` NSPanel Pro, `.244` Nuki Bridge, `.227` SwitchBot) | | 2 | **IoT-WLAN-PSK** (`sec-iot`) | `dirtyvader-iot`, 26 Clients | steht **im Klartext** im TP17-Text ⇒ auch in der **Git-Historie**; Redigieren holt ihn dort nicht raus | Hebel `multi-passphrase` am Core verifiziert vorhanden, Tabelle leer | 23 Shellys per Skript + **3 von Hand** (`.249` NSPanel Pro, `.244` Nuki Bridge, `.227` SwitchBot) |
| 3 | **Shelly-Gerätepasswort** *(vormals TP17.2)* | 23 Shellys | Härtung (bisher **keine** Auth auf den Geräten) | ✅ Werkzeug fertig (`migrate.py cmd_auth` + Digest-Fallback) | **je Gerät ein HA-Re-Auth-Flow** — das ist der Aufwand, nicht `SetAuth` | | 3 | **Shelly-Gerätepasswort** *(vormals TP17.2)* | 23 Shellys | Härtung (bisher **keine** Auth auf den Geräten) | ✅ Werkzeug fertig (`migrate.py cmd_auth` + Digest-Fallback) | **je Gerät ein HA-Re-Auth-Flow** — das ist der Aufwand, nicht `SetAuth` |
| 4 | **CF-Access-Service-Token** (`CF_API_TOKEN`) | Cloudflare-Zugang, s. TP14.4 | ⏳ **läuft am 2027-01-09 ohnehin ab** — harter Termin, unabhängig vom Projektende | Runbook liegt (`tp14.4-cf-access-token-runbook.md`) | Token neu ausstellen + `~/.secrets` nachziehen; **account-owned**, anderer verify-Endpoint | | 4 | **CF-Access-Service-Token** (`CF_API_TOKEN`) | Cloudflare-Zugang, s. TP14.4 | 🔴 **2026-09-21 erneut ins Transkript geraten** (Session-`grep` unmaskiert). Der bisherige harte Termin ist **entfallen**: ⏳ „2027-01-09" galt für das längst revokte Alt-Token `9984078812bc…`; das **live** `CF_API_TOKEN` (id `493ed8bd…`) hat **kein** Ablaufdatum — verifiziert 2026-09-21 via `/accounts/{id}/tokens/verify`, `expires_on: null` | Runbook liegt (`tp14.4-cf-access-token-runbook.md`) | Token neu ausstellen + `~/.secrets` nachziehen; **account-owned**, anderer verify-Endpoint |
| 5 | **Nautobot-Tokens** (`NAUTOBOT_SUPERUSER_API_TOKEN`, `NAUTOBOT_GITEA_TOKEN`) | Nautobot-Superuser + Gitea-Anbindung | 🔴 in der TP16.3-§2/§3-Session (2026-08-31) durch schwache `sed`-Maskierung **ins Transkript** geraten | Werte in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored) | neu ausstellen + Stack-YML nachziehen; Superuser-Token wird von den `gc-*`-Skripten gelesen | | 5 | **Nautobot-Tokens** (`NAUTOBOT_SUPERUSER_API_TOKEN`, `NAUTOBOT_GITEA_TOKEN`) | Nautobot-Superuser + Gitea-Anbindung | 🔴 in der TP16.3-§2/§3-Session (2026-08-31) durch schwache `sed`-Maskierung **ins Transkript** geraten | Werte in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored) | neu ausstellen + Stack-YML nachziehen; Superuser-Token wird von den `gc-*`-Skripten gelesen |
| 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste | | 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 |
@@ -2034,8 +2038,12 @@ Es ist **ein** Passwort für alle vier Geräte — die „Überlebensversicherun
- **CF-Access-Service-Token** — **Rotationsposten #4**, am 2026-08-24 aus den Follow-ups hierher - **CF-Access-Service-Token** — **Rotationsposten #4**, am 2026-08-24 aus den Follow-ups hierher
gezogen (stand dort als „TP14.4 — CF-Access-Service-Token rotieren"). Sachlich gehört er zu gezogen (stand dort als „TP14.4 — CF-Access-Service-Token rotieren"). Sachlich gehört er zu
**TP14.4** (CF-Access-Profil für Claude, Runbook `docs/tp14.4-cf-access-token-runbook.md`); hier **TP14.4** (CF-Access-Profil für Claude, Runbook `docs/tp14.4-cf-access-token-runbook.md`); hier
steht er, damit die Rotationsliste vollständig ist. ⚠️ Der `CF_API_TOKEN` läuft ohnehin steht er, damit die Rotationsliste vollständig ist. ⚠️ ~~Der `CF_API_TOKEN` läuft ohnehin
**2027-01-09** ab — spätestens dann ist dieser Posten fällig, unabhängig vom Projektende. **2027-01-09** ab~~ — **korrigiert 2026-09-21:** dieses Datum gehörte zum revokten Alt-Token
`9984078812bc…`; das **live** `CF_API_TOKEN` (id `493ed8bd…`) hat **kein** Ablaufdatum
(`expires_on: null`, via `/accounts/{id}/tokens/verify`). Der harte Termin ist damit weg —
**Anlass** ist jetzt der **Leak vom 2026-09-21** (🔴 siehe Posten #4 oben). Der Posten bleibt
**gebündelt** und wird nicht vorgezogen (Auslösekriterium oben).
#### Posten #3 im Detail — Shelly-Gerätepasswort (`SetAuth`), vormals TP17.2 #### Posten #3 im Detail — Shelly-Gerätepasswort (`SetAuth`), vormals TP17.2
*Am 2026-08-24 als eigenes TP aufgelöst und hierher gezogen — Text unverändert übernommen.* *Am 2026-08-24 als eigenes TP aufgelöst und hierher gezogen — Text unverändert übernommen.*
@@ -2391,18 +2399,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
@@ -2425,19 +2451,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")
@@ -2650,11 +2692,271 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
die Pipeline ist stabil und **104/104 compliant**. TP37 vergrößert die Angriffsfläche dieser Pipeline. 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.
---
### TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN aus Nautobot ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #71)
> **Auslöser (Christoph, 2026-09-21):** *„schau dir mal die fqdns von `*.emsenhuber.net` an. viele
> verschwunden. sowohl caddy als auch cloudflare."* — und, als der Befund stand:
> *„wenn wir schon reverse proxy & co korrigieren müssen, wäre es vl jetzt ein guter zeitpunkt,
> unsere infrastruktur vlan20 an das ausgedachte ip-schema anzupassen. danach dns/fqdn."*
#### Befund (live, 2026-09-21) — **weder Caddy noch Cloudflare haben etwas verloren**
| # | Befund | Beleg |
|---|---|---|
| 1 | **CF ist gewollt geschrumpft** — 24 Records, 8 proxied; `bf-public` trägt 5 (`vw`,`hass`,`n8n`,`gitea`,`ds-bf`), `fm` 2 | CF-DNS-API |
| 2 | **Caddy hat nichts verloren** — 16 Sites, live **byte-gleich** zum Repo (`diff` identisch, 2977 B) | LXC 130 |
| 3 | 🔴 **Internes Split-Horizon tot** | s. u. |
| 4 | 🔴 **`ds-bf` öffentlich kaputt** | s. u. |
🔴 **Root Cause #3 (bewiesen).** AGH wertet DNS-Rewrites **im Filter-Pfad** aus; für Clients mit
`filtering_enabled: false` werden sie **übersprungen**. Genau die Gruppen, von denen gebrowst wird,
sind unfiltriert (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`, `grp-media`, `grp-mgmt`). Diese
Clients fallen auf den conditional forward `[/bf.emsenhuber.net/] → 10.40.20.1` (Core) zurück — der
Core hält aber nur **10** Host-Records; die **11 Dienstnamen** (`nautobot`, `portainer`, `pbs`, `ccu`,
`iobroker`, `z2m25`, `tv`, `mfc`, `ha-mcp`, `gitea-mcp`, `n8n-mcp`) existieren **nur** als AGH-Rewrite
→ **NXDOMAIN**. Aus dem AGH-Container selbst (`127.0.0.1`, kein Client-Match) liefern dieselben
Rewrites korrekt — **deshalb war jede Handoff-Abnahme grün: sie wurde nie vom Browser aus gemessen.**
Zweitdefekt: `pve.bf`/`pve-mcp.bf` **doppelt** definiert (Core → Host-IP, AGH → Caddy `.40`).
🔴 **Root Cause #4 (bewiesen, Firewall).** Der Origin antwortet aus VLAN20 in 0,08 s mit `200`,
öffentlich `000` nach 20 s — die vier anderen Namen desselben Tunnels sind in ~0,2 s da. `vw`/`hass`/
`n8n`/`gitea` gehen auf `https://10.40.20.40:443` (**Caddy**); `ds-bf` geht **direkt** auf
`http://10.40.20.10:8080` (**NAS**) und ist damit der **einzige Ingress-Eintrag, der Caddy umgeht** →
die AP-Connectoren (VLAN99) dürfen per Regel `*42` **ausschließlich** Caddy:443, alles andere fällt
unter `*1A TP5-WOULD-DROP` / `*2D TP5: deny-by-default` (621 Pakete).
**Fix ohne Firewall-Änderung: `ds-bf` über Caddy führen.**
#### Schichten (Reihenfolge = Abhängigkeit)
| Schicht | Inhalt | Stand |
|---|---|---|
| **A** | VLAN20 virtuelle Gäste ins Schema — **Nautobot zuerst** (`.30–.34` → `.154–.156`/`.158`), dann AGH | 🔄 **Nautobot ✅ 2026-09-21** (s. u.) · AGH + übrige Gäste offen |
| **B** | VLAN30 — **nur die Pool-Grenze**, Geräte bleiben (Christoph 2026-09-21) | offen |
| **C** | DNS/FQDN: `ds-bf` über Caddy, `coolify.bf` neu, `.bf` vollständig auf den Core, AGH nur Filtering, TP34 | ✅ **C1–C3 durch 2026-09-22** (s. u.) · C4 (TP34) + `coolify.bf` offen |
| **D** | Tunnel-Inventar + GC + generische Namen, TP30 #10, Leak-Ursachen | offen |
| **E** | nas/pve/macmini — **neue IPs zuerst am Core** (Sekundär-IP + 1:1-NAT), dann messen und umstellen; nas zuletzt | offen |
| **F** | CF-/Tunnel-SSoT + Recovery → **TP46** | offen |
##### Schicht E — Mechanik (Christoph-Entscheid 2026-09-21, ersetzt „Doppeladresse am Host zuerst")
**Neue IPs zuerst am Core, nicht am Gerät:** Sekundär-IPs `.110` (nas) · `.121` (pve) · `.130`
(macmini) auf der Core-`vlan20` **plus 1:1-NAT** auf das Gerät (`dst-nat` + `src-nat`, platziert
**vor** der `no-NAT inter-VLAN`-Regel). 🔴 **Routing allein reicht nicht** — im selben Subnetz
antwortet das Gerät dem Client direkt per L2, der Client verwirft eine Antwort von `.10` auf eine
Verbindung zu `.110`; der Rückweg braucht `src-nat`, und die Adresse muss **am Core liegen**, sonst
beantwortet er kein ARP dafür (**nicht** `proxy-arp` — das antwortet für *jede* freie Adresse im
/24). Heute ist die Core-NAT-Kette leer (nur `no-NAT inter-VLAN` + WAN-Masquerade) ⇒ Neubau.
**Ab dann gemessen:** zwei Zähler je Host — auf der **neuen** IP (NAT-Regel: „wer ist schon
umgestellt") und auf der **Alt-IP** (`action=passthrough`, `comment="dualaddr-watch: …"`: „ist noch
was los"), beide ohne `log`. **Erst wenn der Alt-IP-Zähler über N Tage stillsteht**, wird das Gerät
**zum Siedeln vorgemerkt**: neue IP aufs Gerät → NAT + Sekundär-IP + Zähler raus → Alt-IP entfernen
→ Doku/Nautobot nachziehen. Alles **ins Soll**, nicht als Transaktion (Christoph: *„wir haben einen
Ist- und einen Sollzustand; die Regel kann uns nicht vorschreiben, wie lange wir für die Überführung
brauchen"*).
**Reihenfolge:** `nas` siedelt **zuletzt und gemeinsam** (die meisten Abhängigkeiten: SMB, Time
Machine, Caddy-Backend `ds-bf`), `pve`/`macmini` vorher; **`pve2` nur reservieren** (`.120`).
**Vorabcheck ✅ 2026-09-21:** NAS-NFS (`/etc/exports`, 9 Shares) und iSCSI (`.10`/`.11`:3260) sind
IP-ACL'd, haben aber **keine lebenden Nutzer** — pve hat **null** NAS-Mounts (einzige Remote-Storage
ist `pbs 10.40.20.135`), kein LXC hängt am NAS, die Synology-Firewall hat keine Regeln ⇒ der
src-nat-Umweg (gleich-subnetzige Clients sehen den Core als Quelle) **bricht nichts**.
🔴 **Eigener Aufräumposten:** dieselben Exports stehen komplett auf totem **`10.40.41.x`** plus drei
`10.0.0.0/8`-Freigaben (offen für jeden 10/8-Client).
⚠️ Der NAS trägt **zwei** VLAN20-Adressen (`.10` `ovs_eth0`, `.11` `ovs_eth1`) — laut Christoph
haben die beiden nichts miteinander zu tun; `.11` gehört **nicht** in die Umzugslogik.
🔴 **Warum Nautobot zuerst.** AGH will `.30`/`.31` — dort saß Nautobot (`.30`/`.31`/`.32`/`.34`).
**28 Skript-/Config-Dateien** referenzierten `10.40.20.30` (alle `gc-*.py`, 14× `ingest-*.py`,
`tp44-*`, `routeros/fetch-core-cutover-config.py`) — alle am 2026-09-21 mitgezogen. Und: **der
GC-Pfad läuft über Nautobot** — AGH zuerst zu ziehen hieße, die Werkzeugkette gegen sich selbst
laufen zu lassen.
##### ✅ Nautobot-Umzug durch (2026-09-21) — `.30/.31/.32/.34` → `.154/.155/.156/.158`
`.157` ist gitea, deshalb nimmt redis `.158`. Reihenfolge und Belege:
1. **Core-Adressliste `nautobot` additiv erweitert** (`.154/.155/.156` **dazu**, Alt-Einträge bleiben).
Das ist die wirksame Ebene: der Mgmt-Zugang des Cores läuft über `src-address-list=nautobot`
(Regel `*28`, 113 Pakete), **nicht** über den Literal `*24`.
2. **Nautobot-Datenmigration** über das neue [`nautobot/tp45-ip-move.py`](../nautobot/tp45-ip-move.py).
Nautobot **verbietet** das Ändern einer IP nach dem Anlegen (`Host address cannot be changed once
created`) → Umzug ist Dreischritt *anlegen → `primary_ip4` umhängen → Altobjekt löschen*.
3. **Stack-YML + Redeploy** (`PUT /api/stacks/203?endpointId=14`), Container auf den neuen IPs,
`http://10.40.20.154:8080/health/` = **200**, alle vier Alt-IPs **000** (keine Doppeladresse —
so entschieden).
4. **Caddy** `nautobot.bf` → `.154`, deployt und **verifiziert**.
5. **Core-Literal `*24` auf `.154`** über GC (`gc-manual-plan.py --deploy`, Plan `51a90e25`,
Completed) mit Wirksamkeitsprüfung am Gerät.
6. **`python3 nautobot/gc-configplan.py --refresh-compliance` → 140/140, 0 Drifts.**
##### 🔴 Zwei Werkzeug-Fehler, die genau dabei Zeit gekostet haben (beide neu)
**(a) `pct push` meldet Exit 0, auch wenn es die Quelle nicht lesen kann.**
Gemessen: `pct push 130 <nicht-vorhandene-datei> /etc/caddy/Caddyfile` →
`failed to open … for reading`, **`rc=0`**.
Folge in `caddy/deploy.sh`: die Push-Zeile war als `push || push`-Fallback gebaut (wegen einer
`$$`-Escaping-Verwechslung); der Fallback griff **nie**, weil der kaputte Push „Erfolg" meldete. Die
anschließende `caddy validate` prüfte damit die **alte** Datei und war grün — **grüne Abnahme bei
unverändertem Zustand**. Symptom war `nautobot.bf` **502** bei gleichzeitig gesundem Backend.
**Fix:** `push_verified()` in `caddy/deploy.sh` — Push **und** md5-Vergleich in *einem* Remote-Aufruf,
harter Abbruch bei Abweichung. Merkregel: **ein Push-Werkzeug, dessen Exit-Code nicht stimmt, wird
geprüft, nicht geglaubt** — dieselbe Klasse wie `nc -z` (09-01) und das `sed`-Muster (08-31).
**(b) 🔴 Die Firewall-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert.**
Der Core-Zugang für Nautobot hängt an `src-address-list=nautobot` — die Liste stammt aber aus dem
**von Hand gelaufenen** `routeros/tp5-etappe2-whitelists.rsc` und existiert im Template nicht
(`grep` auf `address-list` findet nur Kommentare). **Ein Golden-Config-Recovery von null erzeugt
damit einen Core, in den Nautobot nicht mehr per SSH kommt** — die Deploy-Pipeline wäre tot und
würde sich selbst nicht reparieren können. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
Der Renderer gehört in dieselbe Baustelle wie Punkt „DHCP-Reservierungen" oben.
**(c) Nebenfund — Regel `*24`/`*28` sind Dubletten.** Dieselbe Erlaubnis existiert zweimal:
`src-address=10.40.20.30` (Literal, **0 Pakete** = tot) und `src-address-list=nautobot`. Nur die
Liste ist wartbar. Der Literal wurde auf `.154` gezogen (Soll-Konsistenz), die **Zusammenlegung**
bleibt als Aufräumposten offen.
**(d) Nebenfund — `gc-manual-plan.py` Diagnosepfad ist tot.** Die Log-Abfrage
`/api/extras/job-log-entries/?job_result=…` liefert **HTTP 404** (Nautobot 3.x hat den Endpunkt
verschoben). Bei einem **erfolgreichen** Deploy ist das kosmetisch, bei einem **gescheiterten**
nimmt es die Fehlermeldung weg — genau dann, wenn man sie braucht.
#### ✅ Schicht C durch (2026-09-22) — FQDN-Wiederherstellung, vorgezogen (C1–C3, Commit `6d1d56e`)
Auslöser: die IP-Umstellung (A/B/E) dauert länger; die internen `*.bf.emsenhuber.net`-Namen mussten
**sofort** zurück. Drei Züge, jeder am **Gerät** *und* am **Client** belegt — nicht am Plan-Status.
**C1 — 17 Namen in den Core** (`nautobot/mikrotik_routeros.j2`, Deploy über den neuen GC-Job
`tp45-c-dns`). Der Core hält jetzt **27** `/ip dns static` (vorher 10): die 9 Caddy-Frontends
(`nautobot` `pbs` `portainer` `ccu` `iobroker` `z2m25` `ha-mcp` `n8n-mcp` `gitea-mcp`) → `.40` ·
`tv.bf`→`10.40.40.11` · `mfc.bf`→`10.40.10.6` · **`ap3.bf`→`.99.13`** (existierte in *keiner* Quelle) ·
`pve.bf`/`pve-mcp.bf` von Host-IP auf `.40` · dazu die **5 bare Namen** (`vw` `hass` `n8n` `gitea`
`ds-bf`) → `.40` (Entscheid 09-22: lokaler Split-Horizon ist **gewollt**, weil `/admin` nur header-frei
geht). 🔑 Der Job liest die Zeilen **aus dem Template selbst** und macht daraus einen **idempotenten
Upsert** (`:if ([:len [/ip dns static find where name=X]] = 0) do={ … }`) — `add` allein ist nicht
wiederholbar. Abnahme: Geräte-Read **27 Statics** · `--refresh-compliance` → **145/145, 0 Drifts**.
**C2 — AGH entdoppelt** (`adguard/AdGuardHome.yaml`, Weg `python3 adguard/agh-sync.py push`).
`upstream_dns` = **nur** `10.40.20.1`; CF-DoH-Zeile und conditional `[/bf.emsenhuber.net/]`-Forward
entfallen; **alle 26 Rewrites raus**. Der Core ist damit die **einzige** Quelle — auch für die bare
Namen, die vorher über DoH **öffentlich** aufgelöst wurden und den Client an den CF-Edge schickten
(dort setzt der Tunnel `CF-Connecting-IP` → `@cf_admin`-Guard → 404). Vorher/Nachher aller 26 Rewrites
geprüft: **verhaltensneutral**, identisches Ziel, eine Quelle statt zwei.
Abnahme: `agh-sync.py check` → `.36 == .37` · `/admin` **header-frei 200**, mit `CF-Connecting-IP`
weiter **404** · Filter intakt (`doubleclick` → `0.0.0.0`) · öffentliche Auflösung intakt.
**C3 — `ds-bf` über Caddy** (CF-Write am Tunnel `bf-public` `48d3b0b6`): Ingress-Origin von
`http://10.40.20.10:8080` auf `https://10.40.20.40:443` + `originRequest.originServerName`. Öffentlich
**200 in 0,24 s** (vorher `000` nach 20 s); `vw`/`hass`/`n8n`/`gitea` unverändert. ⚠️ Der **CF-MCP darf
lesen, aber nicht schreiben** (`1001: Not authorized` auf `PUT …/configurations`) — der Write lief über
`CF_API_TOKEN` aus `mcp/cloudflare/.env` (Wert nie ausgegeben); Original-Ingress vorher gesichert,
nachher gegengeprüft.
**WARP-Abnahme (Christophs Bedingung) ✅.** Unter verbundenem WARP löst `127.0.2.2/.3` den Namen auf
**`.40`** auf, die Route geht über `utun8` (Mesh), `/admin` = **200**. Der zuvor gemeldete 404 des Users
kam aus dem **Browser-/mDNSResponder-Cache** (dort lag noch die CF-Anycast-A); nach TTL-Ablauf sauber.
**Hebel (a) (Zero-Trust Local Domain Fallback) war nicht nötig.**
🔴 **Befund, bewusst offen — AAAA bleibt öffentlich.** Der Core hält nur **A**-Statics und ist kein
Zonen-Eigentümer: eine AAAA-Abfrage **reicht er weiter** und liefert für die bare Namen die **CF-IPv6**
(`2a06:98c1::`). **Heute wirkungslos** — im Netz läuft kein globales IPv6 (der Mac hat keine globale
Adresse; `curl -6` landet über `::ffff:10.40.20.40`). **Latent:** sobald IPv6 kommt, geht der
`/admin`-Weg wieder über die Edge und die Tür ist zu. Christoph-Entscheid 2026-09-22: **dokumentieren,
nicht ändern** — `aaaa_disabled: true` in AGH wäre die Ein-Zeilen-Abhilfe, ist aber netzweit „IPv4-only"
und passt nicht zu „AGH filtert nur".
🔴 **Zweiter Befund — AGH ist doppelt adressiert.** LXC 110/111 tragen im Container **zwei**
VLAN20-Adressen: `eth0` `.36`/`.37` (aus `net0`) **plus** `eth1` `.30`/`.31`. Schicht A ist dort
**halb** umgestellt — die neue IP ist live und wird per DHCP verteilt (`dns-server=10.40.20.30,10.40.20.31`),
die Alt-IP lebt noch. Gehört in die „siedeln"-Messung, nicht in C. ℹ️ **Korrektur einer eigenen
Fehlannahme:** `adguard/agh-sync.py` nennt `.36`/`.37` — das ist **kein** Drift, sondern die
`net0`-Adresse; nur die Konstante kennt die zweite Adresse nicht.
🧪 **Messfalle, die fast ein Fehlalarm wurde:** `dig nautobot.bf` **ohne** `.emsenhuber.net` fragt den
Namen `nautobot.bf.` im **Root** ab und liefert leer — sieht wie ein nicht gefixter Name aus, war aber
die eigene Abfrage. Dieselbe Klasse wie `nc -z` (09-01) und `pct push` (09-21): **erst das eigene
Werkzeug verdächtigen.**
**Offen in C:** `coolify.bf` (nicht ausgerollt, Port unbekannt — **nicht raten**) · **C4** (TP34: FQDN
als **Daten** in Nautobot). Kosmetisch zurückgestellt: die `ds-bf`-Site steht im `caddy/Caddyfile`
weiter im INTERN-Block (nur Beschriftung; ein Caddy-Redeploy allein dafür wäre unverhältnismäßig).
#### Soll-Oktette (Nachschlagetabelle, s. TP32 für die Herleitung)
| Oktett | Rolle | Soll |
|---|---|---|
| `.20–.29` | Core / netznah | mcast-relay |
| `.30–.39` | DNS | agh-bf1 `.30` · agh-bf2 `.31` |
| `.50–.59` | DBs | platform-pg `.50` · mariadb `.51` · influxdb2 `.52` · redis `.53` |
| `.60–.69` | Security | vaultwarden `.60` |
| `.80–.89` | Platform | docker `.80` · coolify `.81` |
| `.90–.99` | Broker/Funk | mqtt5 `.90` · z2m25 `.91` |
| `.150–.199` | App | haos `.150` · iob-master `.151` · CCU `.152` · nautobot `.154–.156`+`.158` · gitea `.157` · iob_slave `.159` |
🔴 **DHCP-Reservierungen sind die Lücke:** Golden Config rendert sie **nirgends** (pools/DNS-statics/
NAT/mangle/WG sind Template-Literale, der Firewall-Filter kommt als ConfigContext-**Daten** — aber
`/ip dhcp-server lease` existiert im Template nicht). Die 26 VLAN30- + 1 VLAN20-Reservierung sind
**Handarbeit auf dem Gerät** aus der ap1-Ära. Christoph-Entscheid 2026-09-21: **Renderer bauen**
(Template + Nautobot-Datenmodell), statt Handarbeit zu dokumentieren.
#### Live-Befunde, die den ursprünglichen Plan widerlegten (2026-09-21)
- `mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`) → der Anker der
Zeile `.20–.29` war falsch gesetzt.
- `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), der Plan sah **drei** Slots vor.
- Die VLAN30-Soll-Spalte war **durchgehend erfunden**: Ender3V3KE liegt live auf `.150` (Plan `.180`),
die 26 Shelly-Reservierungen auf `.202–.226`+`.233` (Plan `.150–.169`), Twinkly `.228`,
Lichterketten `.236`/`.237`, Withings `.230`/`.231` … → Christoph: **„VLAN30 nur Pool-Grenze"**.
- Pool-Fund: `pool-v20` `.100–.200` hat **`used=0`** (gratis verschiebbar); `pool-v30` `.100–.250`
enthält **~25 Statics** → Pool und Statics tauschen praktisch die Plätze.
- ⚠️ **`ap3.bf` existierte nirgends** — weder Core-Static noch AGH-Rewrite, obwohl ap3 seit 08-30 live
ist. ✅ **Behoben in C1 (09-22):** `ap3.bf` → `.99.13` als Core-Static.
- **Abh.:** TP32 (Systematik) · TP37 ✅ (Nautobot-Datenmodell) · TP21 ✅. **Blockiert:** TP34 (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,6 +3063,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

View File

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

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_<redacted>: [] private_networks: []
use_private_ptr_resolvers: false use_private_ptr_resolvers: false
local_ptr_upstreams: [] local_ptr_upstreams: []
use_dns64: false use_dns64: false

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

View File

@@ -61,7 +61,7 @@ import urllib.error
import urllib.parse import urllib.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

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

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 = []

View File

@@ -22,7 +22,7 @@ import os
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")
BRANCH_RULES = [ BRANCH_RULES = [
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked', 'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',

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"},
@@ -134,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
@@ -293,18 +312,49 @@
# FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst # FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst
# mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14). # mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14).
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich): # Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich; ap3 ergaenzt 2026-09-22):
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 # core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 | ap3 .99.13
#
# 🔴 TP45 Schicht C (2026-09-22): DIESE Liste ist die EINZIGE Quelle fuer interne Namen.
# AGH wertet seine DNS-Rewrites nur im Filter-Pfad aus; fuer unfiltrierte Clients
# (dns-bypass-exempt, grp-infra/-media/-mgmt) greifen sie nicht — die landen hier.
# Hier fehlten 12 Namen, genau das war der FQDN-Ausfall.
# Merkregel: was in AGH als Rewrite steht, muss auch hier stehen.
/ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.emsenhuber.net type=A /ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.emsenhuber.net type=A /ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.emsenhuber.net type=A /ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.emsenhuber.net type=A /ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.emsenhuber.net type=A
/ip dns static add address=10.40.99.13 comment="ap3 = reiner CAP (cAP ax)" name=ap3.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.167 name=macmini.bf.emsenhuber.net type=A /ip dns static add address=10.40.20.167 name=macmini.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.100 name=pve.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.173 name=pve-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.10 name=nas.bf.emsenhuber.net type=A /ip dns static add address=10.40.20.10 name=nas.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.126 name=docker.bf.emsenhuber.net type=A /ip dns static add address=10.40.20.126 name=docker.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.20 name=platform-pg.bf.emsenhuber.net type=A /ip dns static add address=10.40.20.20 name=platform-pg.bf.emsenhuber.net type=A
/ip dns static add address=10.40.10.6 comment="Brother MFC (VLAN10)" name=mfc.bf.emsenhuber.net type=A
/ip dns static add address=10.40.40.11 comment="TV (VLAN40)" name=tv.bf.emsenhuber.net type=A
# Caddy-Frontends (TP31): Dienste mit eigener Site laufen ueber den Reverse Proxy, damit das
# TLS-Zertifikat gueltig ist. Quelle: caddy/Caddyfile (live geprueft 2026-09-22).
/ip dns static add address=10.40.20.40 comment="caddy -> nautobot :8080" name=nautobot.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> pbs :8007" name=pbs.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> portainer :9443" name=portainer.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> ccu :80" name=ccu.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> iobroker :8081" name=iobroker.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> z2m25 :8080" name=z2m25.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> home-assistant mcp :9583" name=ha-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> n8n-mcp :3001" name=n8n-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> gitea-mcp :3000" name=gitea-mcp.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> pve :8006" name=pve.bf.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="caddy -> pve-mcp :3000" name=pve-mcp.bf.emsenhuber.net type=A
# Oeffentliche Namen (bare emsenhuber.net) — real via CF Access + Tunnel. Sie stehen hier NUR,
# damit LAN-Clients direkt auf Caddy kommen: der CF-Weg setzt CF-Connecting-IP, und der
# @cf_admin-Guard in caddy/Caddyfile antwortet darauf mit 404 (TP41 T2). Der lokale,
# header-freie Weg ist die einzige Tuer zum Vault-Admin (Christoph-Entscheid 2026-09-22).
/ip dns static add address=10.40.20.40 comment="vw via caddy (Vault-Admin nur lokal)" name=vw.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="hass via caddy" name=hass.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="n8n via caddy" name=n8n.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="gitea via caddy" name=gitea.emsenhuber.net type=A
/ip dns static add address=10.40.20.40 comment="ds-bf via caddy" name=ds-bf.emsenhuber.net type=A
# [7] NAT # [7] NAT
/ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16 /ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16

View File

@@ -36,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

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"

View File

@@ -32,7 +32,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

View File

@@ -32,7 +32,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

View File

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

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

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

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

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

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