53 Commits

Author SHA1 Message Date
e87619e89b TP38: bridge-port comment ungequotet + core place-before->move
RouterOS exportiert comment=TP38-OOB-MGMT ohne Quotes (kein Leerzeichen),
das Template hatte sie gesetzt -> bridge-port Drift. Fix: unquoten.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 12:11:29 +02:00
614025eafb TP43 ✅ umgesetzt: dw-FritzBox-DNS auf FB-Upstream .36/.37 + Rebind-Ausnahme
Christoph on-site: Upstream 10.40.20.36 + .37, DNS-Rebind-Schutz-Ausnahme
bf.emsenhuber.net, Fallback-Haken "öffentliche NS bei Unreachbarkeit der
primären" gesetzt (existiert auf diesem FRITZ!OS-Stand) → Ideal-Variante,
.37-Redundanz bleibt, BF-down fällt auf public. Gitea #32 closed (status:done).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4
.gitignore vendored
View File

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

67
.mcp.json Normal file
View File

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

View File

@@ -20,6 +20,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`, am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
**Compliance 140/140**). **Am 2026-09-04 hat Christoph `ft=no` auf `sec-main` als Dauerzustand **Compliance 140/140**). **Am 2026-09-04 hat Christoph `ft=no` auf `sec-main` als Dauerzustand
entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen). entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen).
⚠️ **Rueckgenommen am 2026-09-14 (Terrasse-In-situ-Session): `ft=yes ft-over-ds=yes` zurueck** — mit
`ft=no` war jeder Roam eine Voll-Reassoziation, darin starben 2 Anrufe; Rueckkehrstand = Fund-E-Known-Good
von 09-02 (ruhig auf 7.24.1). Weg: Template + `push-gc-template.py` + `gc-configplan.py
--refresh-compliance` + `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed) → **140/140, 0 Drifts**;
CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands:
`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`, s. Handoff 2026-09-14.
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf 🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf
`wifi1/wifi2` (Bring-up unset `!ft-over-ds`, aber nicht `!ft`; Abnahme zählt nur benannte `wifi1/wifi2` (Bring-up unset `!ft-over-ds`, aber nicht `!ft`; Abnahme zählt nur benannte
security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm) security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm)
@@ -53,11 +59,11 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**. - **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header. - **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell. - **`admin/bf-golden-config`** (Gitea) ist Bot-Master, nicht manuell. 🔴 **Korrigiert 2026-09-12:** die frühere Angabe „2×/Tag" war nie real — es gab **keinen** Scheduler, Backup/Intended/Compliance liefen nur manuell aus Sessions (zuletzt 09-04, 8 Tage Stillstand). Jetzt **täglich 03:30 UTC** per ScheduledJob **„GC Backup+Compliance – taeglich"** (Nautobot-Job *Execute All Golden Configuration Jobs - Multiple Device*, Scope `platform=RouterOS`, Reihenfolge Backup→Intended→Compliance). Stand prüfen: `backup_last_success_date` am Gerät bzw. `scheduled-jobs` in Nautobot.
- **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo. - **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo.
- **`inventory/` ist tot** (Pipeline seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer. - **`inventory/` ist tot** (Pipeline seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer.
- **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen. - **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen.
- **terpene.teilzeitladen.xyz**: Worker läuft auf Mac mini (`10.40.20.167:8890`), **kein Caddy-Block, kein CF-Tunnel** — Backfill via DB-Migration blockiert (Handoff 2026-08-27). - **Terpenfinder = `420.teilzeitladen.xyz`** (CF-Worker, Repo `admin/blaueblueten-n8n`, hinter CF Access), **kein Caddy, kein Mac mini im Pfad**. `terpene.*` **existiert nicht und soll es nicht** (live geprüft 2026-09-10). Einziger terpene-benannter CF-Gegenstand: der **interne** Tunnel `terpene-db` (`f2d2fc8a`, ohne öffentlichen Hostname, Hyperdrive→VPC→Postgres, s. `docs/cf-tunnel-mapping.md` §17). ⚠️ **Grenze:** blaueblüten ist ein eigenes Projekt — nicht anfassen. *(Die frühere „terpene.teilzeitladen.xyz:8890 via Tunnel"-Zeile war veraltet — bf-mm ist gelöscht.)*
- **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. Inventar-Eintrag spiegelt das. `mcp-host-policy.md` ist SSoT für die Host-/IP-Konvention. - **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. Inventar-Eintrag spiegelt das. `mcp-host-policy.md` ist SSoT für die Host-/IP-Konvention.
- **Mac mini `10.40.20.167`** ist der **n8n-Host** (n8n `:5678`, n8n-redis `:6379`, n8n-mcp `:3001`) — **NICHT** der zentrale MCP-Host. Performance-Überlegung: MCP-Clients würden auf dem macmini (User-Workstation) laufen und die Box zusätzlich belasten; deshalb liegt der MCP-Host auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft aktuell noch auf dem macmini (historisch, eigene Container-Definition dort) — gehört **bei Gelegenheit** auf die DS923 umgezogen (separater Punkt, **nicht** Teil dieser Session). Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`. - **Mac mini `10.40.20.167`** ist der **n8n-Host** (n8n `:5678`, n8n-redis `:6379`, n8n-mcp `:3001`) — **NICHT** der zentrale MCP-Host. Performance-Überlegung: MCP-Clients würden auf dem macmini (User-Workstation) laufen und die Box zusätzlich belasten; deshalb liegt der MCP-Host auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft aktuell noch auf dem macmini (historisch, eigene Container-Definition dort) — gehört **bei Gelegenheit** auf die DS923 umgezogen (separater Punkt, **nicht** Teil dieser Session). Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`.
@@ -98,11 +104,11 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|---|---|---| |---|---|---|
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) | | ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
| ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt | | ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt |
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap2) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 | | **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap3) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** (2026-08-16 verifiziert) · **TP15/TP21.1 ✅ 2026-08-08: PRODUKTIVER L3-Router** (ersetzt ap1) — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 Stufe 1 ✅ 2026-08-18; `servers=1.1.1.1` nur noch Bootstrap) · **NTP-Server für den Standort**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 | | **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** (2026-08-16 verifiziert) · **TP15/TP21.1 ✅ 2026-08-08: PRODUKTIVER L3-Router** (ersetzt ap1) — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 Stufe 1 ✅ 2026-08-18; `servers=1.1.1.1` nur noch Bootstrap) · **NTP-Server für den Standort**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · 🔴 **FUNKSTILL seit 2026-08-30**: keine Provisioning-Regel am Manager, weil ap3 daneben steht und denselben Kanal 5180 fährt. Gerät bleibt erreichbar. Beim Umzug ans Sideboard → `5260/Ce` (Band B, DFS) und Uplink auf `core ether3` — Uplink `ether1` (PoE-Port, `poe-out=off`) an crs326-core `ether15`; SSIDs/Kanäle kommen vom Manager, ch36/ch1 · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` | | hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · ✅ **aktiv am Sideboard** (Wohnzimmer; Uplink `ether1` → core `ether3`, PoE-Port `poe-out=off`) — Kanäle `5260/Ce` (Band B, DFS, Fallback 5280) + `2412`/ch1, vom Manager · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` |
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — Uplink `ether1` (PoE) an crs326-branch `ether24`; ch44/ch11 (TP25: ch149 war unter ETSI leistungsbegrenzt) · MAC D0:EA:11:4D:40:3A · Config `routeros/.secrets/ap2-prov-base.rsc` | | hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — **Technik UG**, Uplink `ether1` (PoE) an core `ether21`; Kanäle `5300/Ce` (Band B, DFS, Fallback 5320) + `2462`/ch11 · MAC D0:EA:11:4D:40:39 (ether1; Bridge/Mgmt `:3A`) · Config `routeros/.secrets/ap2-prov-base.rsc` |
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · Uplink `ether1` → **core `ether21`** (Trunk) · Kanäle **`5180/ax/Ce/I`** (5 GHz) + **`2437/ax`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` | | **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · **Technik OG**, Uplink `ether1` → **branch `ether24`** (Trunk) · Kanäle **`5180/ax`** (Band A, non-DFS, 5 GHz) + **`2437`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. | | **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) | | NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) |
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** | | platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
@@ -276,7 +282,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst. danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
- **VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a):** NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert (`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle: DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der *Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber `TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress. - **VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a):** NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert (`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle: DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der *Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber `TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
- 📜 **~~ALLNET VLAN-DB (TP1)~~ — historisch.** Die VLAN-Semantik gilt weiter (**40=MEDIA, 50=UNTRUSTED**), die Portangaben (GE1/GE2/GE8) beziehen sich auf ein ausgebautes Gerät. - 📜 **~~ALLNET VLAN-DB (TP1)~~ — historisch.** Die VLAN-Semantik gilt weiter (**40=MEDIA, 50=UNTRUSTED**), die Portangaben (GE1/GE2/GE8) beziehen sich auf ein ausgebautes Gerät.
- **ap2 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5). - **ap3 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5). ap2 hängt seit dem TP25-Tausch (2026-09-15) an `core ether21` (Technik UG), ap1 an `core ether3` (Sideboard).
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko). - **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko).
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅ 2026-08-17).** Der Entscheid „kein CAPsMAN" von 2026-07-22 ist **revidiert**: er galt der Konstellation ap1=Manager+AP, in der ein Manager seine eigenen Radios nicht provisionieren kann — mit dem Core als Manager sind beide APs symmetrisch. Der Core braucht dafür **kein** `wifi-qcom`, `capsman` steckt im Basispaket. - **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅ 2026-08-17).** Der Entscheid „kein CAPsMAN" von 2026-07-22 ist **revidiert**: er galt der Konstellation ap1=Manager+AP, in der ein Manager seine eigenen Radios nicht provisionieren kann — mit dem Core als Manager sind beide APs symmetrisch. Der Core braucht dafür **kein** `wifi-qcom`, `capsman` steckt im Basispaket.
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät - 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät

18
__init__.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,10 +39,6 @@ n8n.emsenhuber.net {
reverse_proxy http://10.40.20.167:5678 reverse_proxy http://10.40.20.167:5678
} }
terpene.teilzeitladen.xyz {
reverse_proxy http://10.40.20.167:8890
}
gitea.emsenhuber.net { gitea.emsenhuber.net {
reverse_proxy http://10.40.20.157:3000 reverse_proxy http://10.40.20.157:3000
} }

47
docs/announcements.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -974,7 +974,28 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
- **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**. - **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**.
- Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21. - Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21.
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)** ### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 5 WIEDER OFFEN (Redesign 2026-09-12): Self-Service deckt die DAU-Requirements nicht ab → Stufe 6 bleibt dahinter gated**
> 🔴 **Redesign 2026-09-12 — Stufe 5 reopened.** Bei „Stufe 6 angehen" fiel auf: die 09-04-Stufe-5
> (single-domain, temp-only 15m/1h/4h, **eine** Ausnahme gleichzeitig, **HA-Neustart bei neuer
> Domain**, `allow-domain` global statt pro Person) deckt die Nutzer-Requirements **nicht** ab
> („funktional ein kastriertes Pi-hole", User). Die harte Reihenfolge-Regel „erst der Ersatz, dann
> die Sperre" ist damit real **nicht** erfüllt → **Stufe 6 darf nicht laufen**, bis der neue
> Self-Service nachweislich trägt.
> - **Neue Requirements (User 2026-09-12):** (1) **pro Person** (Login), Sicht über alle Geräte;
> (2) **„zuletzt geblockt" → 1 Klick**; (3) temporär **und** dauerhaft, Modus umschaltbar
> (self-permanent ⇄ Anfrage, Start self-permanent); (4) **scoped HA-Dashboard** je Person —
> nichts von anderen/Admin/Power-User, AGH-Web-UI bleibt reines Admin-Werkzeug.
> - **Design/Bau:** Personen-Client je AGH (SSoT Person→Gerät), Ausnahmen via `$client=`-Modifier,
> `agh-exception.py` um scoped `allow/unallow` + `list-exceptions` + `recent-blocked` erweitert
> (reine Logik netzunabhängig getestet, `selftest` ✅). HA-Dashboard/Sensoren/Modus-Switch als
> Bau-Spec in [`adguard/ha-self-service.md`](../adguard/ha-self-service.md) (on-network zu bauen).
> - 🔴 **Load-bearing, on-network zu prüfen:** wirkt AGH `$client=` auf **Allow**-Regeln (`@@`)?
> Fallback = IP-Liste statt Client-Name (Code kann beides). Plan:
> `~/.claude/plans/tp23-stufe-6-angehen-soft-sunrise.md`.
> - **Neue echte Stufe-6-Vorbedingung:** End-to-End-Abnahme mit echtem Nicht-Admin-Flow.
> - **Stufe-6-Runbook-Korrektur:** Core `10.40.X.1` in der Allowlist überdenken (Client-Gateway →
> DoH direkt zu CF = interner Bypass der AGH-Personen-Policy).
> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt** > 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt**
> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block): > (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block):
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden > - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
@@ -1078,7 +1099,7 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
- **Abh.:** keine harten. **TP15 ✅** (die zu versorgende Hardware steht und hat DC-Eingänge). Verwandt: **TP18** (Garage-Nodes brauchen je Site eine Stromversorgung), **TP11** (USV-Zustand ins Monitoring). **Kein Blocker für TP5/TP6.** - **Abh.:** keine harten. **TP15 ✅** (die zu versorgende Hardware steht und hat DC-Eingänge). Verwandt: **TP18** (Garage-Nodes brauchen je Site eine Stromversorgung), **TP11** (USV-Zustand ins Monitoring). **Kein Blocker für TP5/TP6.**
- **Historie:** war bis 2026-08-16 Unterpunkt von TP15 („🔋 DC-USV: MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt"). Vollständige Recherche inkl. Schaltbild, Effizienzvergleich und Gerätetabelle: `docs/hw-refresh-requirements.md §9` (+ §10 D/E/F für die offenen Fragen). - **Historie:** war bis 2026-08-16 Unterpunkt von TP15 („🔋 DC-USV: MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt"). Vollständige Recherche inkl. Schaltbild, Effizienzvergleich und Gerätetabelle: `docs/hw-refresh-requirements.md §9` (+ §10 D/E/F für die offenen Fragen).
### TP25 — WLAN-Stabilisierung ▸ ⏳ **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) — **offen bleibt nur: ap2 unterperformt (2 m/−80 dBm, 23:02-Befund) als eigener In-situ-Unterpunkt** · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Offen nur noch die Umsetzung, wenn der dritte AP kommt. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6 ### TP25 — WLAN-Stabilisierung ▸ ⏳ **ap2-UNTERPUNKT ✅ WIDERLEGT 2026-09-14 (In-situ, [Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)): ap2 sendet auf 2 m mit −49…−51 dBm, stabil, 0 Roams — Hardware sauber, kein Tausch begründet. Terrasse = 5-GHz-Zellkante (−76…−81 vs. 2,4 GHz −65…−73), Thrash nur in der Band-Überlappungszone −70…−77. Hebel „Access-List-Band-Boden" in der Machbarkeitsprüfung GESCHEITERT (kein `band`-Property, cap-wifiN-Rejoin-Falle) — s. Block unten** — **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
> 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen > 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen
> 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv: > 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv:
> [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G), > [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
@@ -1087,6 +1108,79 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will > Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
> *warum* etwas so entschieden wurde, liest die Langfassung. > *warum* etwas so entschieden wurde, liest die Langfassung.
**Stand 2026-09-15 — AP-Zuordnung DEFINITIV geklärt, Tausch-Befund ap2↔ap3** ([Handoff](handoff-20260915-tp25-zuordnung-und-redundanz.md)). Positionen per IP+MAC+Switch-Port (MNDP/Bridge-Host/Port-Link) live belegt: **ap1 = Sideboard/Wohnzimmer (`core ether3`), ap2 = Technik UG (`core ether21`), ap3 = Technik OG (`branch ether24`)**. Der geplante Tausch (ap1↔ap3, cAP ax → Sideboard) hat faktisch **ap2↔ap3** gedreht — ap1 blieb stehen, der cAP ax steht jetzt in OG. MAC-Korrektur: ap2 `ether1` ist `:39`, nicht `:3A` (`:3A` = Bridge/Mgmt-MAC). Rundgang (natürlicher Zustand, 1-s-Takt): **0 Disconnects**, −45…−77 dBm, Roaming über alle 3 APs; **Terrasse −58 stabil** (jetzt cAP ax/OG, 2,4G), ap1 nur kurz gestreift. ap1-Aus-Test: Main-Client voll abgedeckt (tiefster Wert −80), **alle 27 IOT kehren zurück**, aber ~4 Wohnzimmer-IOT marginal −77…−80 ⇒ ap1 technisch redundant, nicht ausfallkritisch. ap1 wieder an (Revert). Offen: (1) ap1 behalten/abbauen, (2) Tausch auf Plan korrigieren (cAP ax → Sideboard?), (3) Drei-Räume-Schatten (Schlafzimmer/Bad/Küche) gezielt nachmessen — noch nicht verifiziert, (4) Doku-Drift (diese Session).
**Stand 2026-09-14, 20:05 — ap2-In-situ: Hardware entlastet, Terrasse = 5-GHz-Zellkante,
Band-Boden nicht sauber ausdrückbar** ([Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)).
Live-Session auf der Terrasse (Christoph kooperierte physisch — der Test, der am 08-31 „nachts
nicht möglich" war): das Fund-E-iPhone flappte 18:53–19:18 ~30×, fast ausschließlich
**ap2-5G ↔ ap2-2,4G** (−68…−81), mit 3 Abwürfen (2 Call-Aussetzer). Vor ap2 (1–2 m): **−49…−51 dBm,
>2 min stabil, 0 Roams** ⇒ der offene Verdacht „ap2 RF/Antenne/HW" (23:02-Befund, 2 m/−80) ist
**in situ widerlegt** — der 23:02-Wert reproduziert sich seit dem 09-04-Kanalplan (5300/5320,
tx-power 18 live) nicht mehr. Am Terrassenplatz ist **2,4 GHz klar besser** (−65…−73 vs. 5 GHz
−76…−81) und das Telefon dort **ruhig** (36 s im 1-s-Takt, 0 Roams) — es thrasht nur in der
Überlappungszone −70…−77 beider Bänder; zusätzlich client-getrieben (floh 19:44 selbst von −52
auf 2,4/−77; Steering hat keine Schwellen, der AP drängt nichts). Mit `ft=no` ist jeder Roam ein
spürbarer Voll-Reconnect ⇒ so sterben Anrufe. **Hebel „Access-List-Boden < −76 auf 5G":
Machbarkeitsprüfung negativ** — `/interface/wifi/access-list` hat kein `band`-Property,
Interface-Scoping bricht am cap-wifiN-Rejoin, SSID-/MAC-Scope träfen 2,4 GHz mit (Schlafzimmer
−84 ⇒ Lockout). **Nicht umgesetzt.** Verbleibende Hebel: (1) ✅ **DURCH 2026-09-14, 20:30 Uhr** — `ft=yes ft-over-ds=yes`
uniform auf `sec-main` (Ruecknahme der 09-04-Entscheidung; Go per Fragenrunde nach Dry-Run). Weg:
Template-Edit + `push-gc-template.py` (GC-Commit `744a17e3`) → `--refresh-compliance` (Drift
`wifi-security` wie erwartet) → `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed, Asserts gruen;
ein Polling-Timeout am Skript war folgenlos — Geraet stand bereits auf ft=yes) → Refresh: **140/140, 0
Drifts**. CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands-File:
[`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`](transaktionen/TP25-ft-yes-secmain.commands.rsc).
Abnahme ✅ **DURCH (2026-09-14, 20:33–20:34 Uhr, live)**: Anruf auf der Terrasse ueberstand
**fuenf Band-Wechsel binnen 60 s** (nur `roamed to`, keine `disconnected` mehr im Fenster —
Christoph: „call steht"). (2) Deckungsfix
Variante 3 (ap1-5G erreichte die Terrasse mit −64) — beendet das Springen selbst.
Nachtrag 20:37: ein **Totalverlust** anderer Klasse (Roam auf ap1-2,4 bei −89, danach iOS-Aufgeben
ohne jeden Reconnect-Versuch; identisch schon 19:01 mit ft=no gesehen) — **keine ft=yes-Regression**,
sondern der strukturelle Rest. **Wechselrichter/DTU (Hoymiles HMS-800W-2T, geht mit der Sonne an/ab,
09-14: 07:09–19:35): nachts unbeteiligt; tagsüber ~5:05-Zyklus (connect/`connection lost`/reconnect +
DHCP-Churn) auf ap2-2,4/ch11 bei −53.** Messplan „DTU vs. Terrasse" angelegt ([Issue #17, Kommentar
#225](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-225)): Referenz-Client auf
ch11 pinnen (tp25-Methodik), 30–60 min Ping, Korrelation gegen die 5:05-Taktmarken. ⚠️ **Korrektur
09-14 abends ([Kommentar #227](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-227)):
Der urspruenglich genannte Hebel „OpenDTU/AhoyDTU-Dongle" existiert fuer DASSES Geraet NICHT** — HMS-
**XXXXW**-2T (W = eingebaute DTU „DTUBI", protobuf-Protokoll) wird von AhoyDTU/OpenDTU nicht
unterstuetzt (lumapu/ahoy#1189 seit 09-2023 offen; Christophs eigene AhoyDTU nachweislich
inkompatibel; nur die Nicht-W-Variante HMS-800-**2T** ginge per CMT2300-Funk). Die lokale Ebene
(hms-mqtt-publisher = „hmsmqtt", dtuGateway, ha-hoymiles-wifi) redet **mit der internen DTU uebers
Netzwerk** — keines davon nimmt die DTU von der Luft, hmsmqtt pollt sogar zusaetzlich. **Einziger
Airtime-Hebel bleibt DNS-Hoheit ueber die Cloud**, von Christoph bewusst zurueckgestellt (Cloud-
Aufzeichnungen noch benoetigt, bis hmsmqtt + WLAN stabil); ob eine DTU ohne Cloud ruhiger oder
gespraechiger wird, ist selbst eine offene Messfrage. Pin auf anderen AP kontraindiziert
(−53 direkt an ap2); 5-Min-Takt ist S-Miles-Telemetrie per Design, kein Ethernet an der DTU-WLite.
✅ **Netzwerk-Mechanismus geklaert (09-14, Nacht; [Issue #17, Kommentar
#228](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-228)):** Der **Fund-F-5:05-Zyklus
ist das Cloud-Recovery-Verhalten der DTU.** Wireshark-Analyse (hms-mqtt-publisher PR #110, HMS-800W-2T):
lokaler Poll <30 s resettet den Countdown und setzt Cloud-Updates aus; **nach 4–5 min erfolgloser
Cloud-Updates** startet die DTU eine neue TCP-Session — und dabei (unser Log-Beweis 09-14) eine
**vollstaendige WiFi-Disassoziation inkl. DHCP-Churn**. Die heute gemessenen Zyklus-Abstaende decken
sich mit dem Befund aufs Minutenkomma. Ursache war hmsmqtt mit **30 500 ms** (Grenzzone);
DTU-Firmware aelter, hmsmqtt funktioniert (⇒ #134-Key-Rotation-Falle nicht primaer).
**Hebel = Poll-Rate, nicht DNS-Hoheit:** Christoph hat **30500 → 45000 ms** gesetzt (09-14 abends;
Community-Wert, 10 Monate stabil). ✅ **Verifikation DURCH (09-15, 12:11; [Issue #17, Kommentar
#230](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-230)): DTU online seit 06:41:49,
uptime 5 h 29 min, 0 Disconnects** (Vergleich 09-14 mit 30,5 s: fuenf Zyklen in den ersten 18 min).
Die DTU sitzt heute auf **ap3-2,4** (cap-wifi4-virtual1, −49; cap-Karte unveraendert, Attribution
belastbar) — also aktuell auch vom Terrassen-Band ch11 weg. **Fund F ist damit abgeschlossen** —
Ursache war die DTU-Cloud-Recovery, getriggert durch hmsmqtt-Polling-Kollision. Messplan #225 ist
gegenstandslos (der zu messende Effekt existiert nicht mehr); eine ch11-Tagzeitmessung bleibt nur
sinnvoll, falls die Terrasse tagsueber weiterhin Call-Probleme zeigt. 📅 **terminiert ([Issue #17,
Kommentar #232](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-232)): Deckungsfix
Variante 3 — Umsetzung 2026-09-15/16** (Christoph: „heute oder morgen"): Deckenstrahler an den guten
Platz, verdrängter hAP in den Drei-Raeume-Schatten (Schlafzimmer −84,6/38 %, Bad −82,3/24 %,
Kueche −82,0/23 % laut 08-22-Messung). Vorher-Messung Terrasse (Referenz 09-14: 5 GHz −76…−81,
2,4 GHz −65…−73) → Umsetzung → Nachher-Messung + Anruf-Abnahme; Kanalplan (09-04) bleibt unberuehrt,
nur Platzierung aendert sich; Config-Aenderungen nur ueber den GC-Weg. Offen vor Ort: Zielraum fuer
den verdraengten hAP (PoE/Steckdose) und ap1-Uplink-Thema (Sideboard → `core ether3`) mitdenken.
Randnotizen: ap1 „FUNKSTILL" im CLAUDE.md ist überholt (dient live, Radar-Event 16:51 auf 5280
sauber abgehandelt); Template-Abschnitt [6c] („Fund E WIEDER OFFEN", Stand 08-30) ist inhaltlich
überholt — beim nächsten Template-Edit mit aktualisieren, kein separater Edit nur für den Kommentar.
**Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken **Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken
([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust / ([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust /
20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das 20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
@@ -1783,6 +1877,7 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
| 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste | | 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste |
| 7 | **`TERPENE_ANDROID_KEYSTORE_PW`** | Terpene-Android-TWA-Signing (`android/android.keystore`, alias `terpene`) | 🔴 am 2026-09-01 (TP41) durch **Regex-Suffix-Loch** (`_PW` nicht abgedeckt) ins Transkript geraten | Wert in `~/.secrets/credentials.env` + Keystore-Datei | **Store-/Key-Passwort** via `keytool -storepasswd`/`-keypasswd` ändern + `credentials.env` nachziehen; Konsument = Android-Build/Play-Signing (**Cross-Projekt blaueblüten**) — den **Key selbst NICHT** neu erzeugen | | 7 | **`TERPENE_ANDROID_KEYSTORE_PW`** | Terpene-Android-TWA-Signing (`android/android.keystore`, alias `terpene`) | 🔴 am 2026-09-01 (TP41) durch **Regex-Suffix-Loch** (`_PW` nicht abgedeckt) ins Transkript geraten | Wert in `~/.secrets/credentials.env` + Keystore-Datei | **Store-/Key-Passwort** via `keytool -storepasswd`/`-keypasswd` ändern + `credentials.env` nachziehen; Konsument = Android-Build/Play-Signing (**Cross-Projekt blaueblüten**) — den **Key selbst NICHT** neu erzeugen |
| 8 | **`N8N_SMTP_PASS`** (Gmail-App-PW für `johnny.claw.0x00@gmail.com`) | SMTP-Versand aus n8n (Portainer-Stack 174 `n8n`, macmini) | 🔴 am 2026-09-01 (n8n-mcp-SSRF-Session) beim Ausgeben des Stack-174-Files ins Transkript geraten — Redaktionsmuster hatte `PASSWORD`, aber **nicht bare `PASS`** → `N8N_SMTP_PASS=<wert>` blieb stehen | Wert in Portainer-Stack 174 (`environment`) | **Gmail-App-Passwort widerrufen** (Google-Konto → App-Passwörter) + neues erzeugen, Stack-174-Env nachziehen + redeploy; Konsument = n8n-SMTP-Node(s) | | 8 | **`N8N_SMTP_PASS`** (Gmail-App-PW für `johnny.claw.0x00@gmail.com`) | SMTP-Versand aus n8n (Portainer-Stack 174 `n8n`, macmini) | 🔴 am 2026-09-01 (n8n-mcp-SSRF-Session) beim Ausgeben des Stack-174-Files ins Transkript geraten — Redaktionsmuster hatte `PASSWORD`, aber **nicht bare `PASS`** → `N8N_SMTP_PASS=<wert>` blieb stehen | Wert in Portainer-Stack 174 (`environment`) | **Gmail-App-Passwort widerrufen** (Google-Konto → App-Passwörter) + neues erzeugen, Stack-174-Env nachziehen + redeploy; Konsument = n8n-SMTP-Node(s) |
| 9 | **`GITEA_ACCESS_TOKEN`** (API-Zugang `gitea.emsenhuber.net`) | Session-Tooling (`bin/gitea-tp-sync.py`, Ad-hoc-API-Calls) | 🔴 am 2026-09-14 (TP25-In-situ-Session) **ins Transkript** geraten: Env-Parse-Fehler schob den Token in die URL-Konstruktion, `urllib` warf `ValueError: unknown url type` und druckte ihn im Traceback | Mechanismus zu: Token gehört **nur in den `Authorization`-Header, nie in eine URL** — und der Fehlerpfad, der ihn enthält, darf ihn nicht ausgeben (gleiche Familie wie #1/#5/#6/#7/#8, diesmal Unterklasse „Fehlerausgabe statt Maskierung") | neu anlegen (Gitea → Settings → Applications), `~/.hermes/.env` nachziehen, altes widerrufen; Konsumenten = `gitea-tp-sync.py` + Session-Ad-hoc-Calls |
> ✅ **Der Leak-MECHANISMUS hinter #1 ist seit 2026-08-31 zu — der Wert bleibt trotzdem verbrannt.** > ✅ **Der Leak-MECHANISMUS hinter #1 ist seit 2026-08-31 zu — der Wert bleibt trotzdem verbrannt.**
> Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und > Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und
@@ -1816,6 +1911,15 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
> Mal neu (und falsch) tippt: `sed -E 's/([A-Z0-9_]*(PASS|PASSWORD|PWD|PW|TOKEN|SECRET|KEY|BEARER))=[^[:space:]]*/\1=<redacted>/g'` > Mal neu (und falsch) tippt: `sed -E 's/([A-Z0-9_]*(PASS|PASSWORD|PWD|PW|TOKEN|SECRET|KEY|BEARER))=[^[:space:]]*/\1=<redacted>/g'`
> — ankert auf den **Schlüsselnamen inkl. Präfix** und deckt alle bisher gesehenen Suffixe ab. > — ankert auf den **Schlüsselnamen inkl. Präfix** und deckt alle bisher gesehenen Suffixe ab.
> >
> 🔴 **#9 ist der sechste Vorfall derselben Familie, aber eine neue Unterklasse** (SHELLY 08-28,
> Nautobot-Tokens 08-31, MariaDB-root 09-01, Keystore 09-01, SMTP 09-01): diesmal kein
> Maskierungsmuster, sondern ein **Fehlerpfad** — ein Env-Parse-Fehler schob den Token in die
> URL-Konstruktion, und `urllib` druckte ihn im `ValueError: unknown url type`-Traceback.
> **Merkregel: ein Secret gehört in keine Datenstruktur, die eine Exception ausgeben kann**
> (URL-Konstruktion validieren, bevor das Secret hineinwandert; Fehlerpfade dürfen den
> fehlerhaften Wert nicht mitschleppen). Das Redaktions-Snippet oben hilft hier nicht — es
> kann nichts maskieren, was als Fehlermeldung und nicht als Dateiinhalt kommt.
>
> ℹ️ **#3 war bis 2026-08-24 TP17.2** und ist jetzt vollständig hier (s. Detailabschnitt unten) — > ℹ️ **#3 war bis 2026-08-24 TP17.2** und ist jetzt vollständig hier (s. Detailabschnitt unten) —
> es benutzt denselben Geräte-Durchgang wie #2, **ein Reboot statt zwei**. #4 gehört sachlich zu > es benutzt denselben Geräte-Durchgang wie #2, **ein Reboot statt zwei**. #4 gehört sachlich zu
> **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token). > **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token).
@@ -2354,26 +2458,15 @@ kann in Ruhe nachziehen.
--- ---
### TP34 — Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) ▸ 🔜 STUB ### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ✅ **ABGESCHLOSSEN 2026-09-12** — 5/6 Schichten durch (MCP-Schichtung FQDN→IP, Credentials-`{env:}`-Flip angewandt+verifiziert, globale CLAUDE.md-Schicht, `/news`, Backup-Guard, `/projektstatus`-Skill). Schicht-3-**pro-Projekt**-Rollout laut Abgrenzung eigene Session je Projekt (kein Blocker). Einziger Rest = On-Network-mikromcp-Smoketest (off-site nicht machbar, VLAN99:80 timeout, non-blocking) → als Merkposten nach TP30. Gitea #25 closed/`status:done`.
> **Konzept + Architektur-Skizze:** [`docs/handoff-20260827-inventur-und-architektur.md`](handoff-20260827-inventur-und-architektur.md) — bitte dort weiterlesen (Architektur-Abschnitt).
- **Scope:** Reverse-Proxy-Hosts werden im **Nautobot-Datenmodell** gepflegt (Site, Host, Backend-Spec). **Golden Config** rendert den Caddyfile aus den Daten, Diff-Check gegen die Live-Version auf `caddy-bf1`, Compliance-Checks + Transaktionen identisch zum bestehenden Workflow für Router-Configs.
- **Auslöser (Inventur 2026-08-27):** der Caddyfile (`bf-infra/caddy/Caddyfile`) ist heute **manuell gepflegt** — kein Drift-Check, keine Transaktionen, andere Projekte können keine Sites anlegen ohne Christoph. Lösung: **Nautobot/GC**, kein Caddy-API-Sidecar (kein zweites SSoT).
- **Erster Anwendungsfall:** `terpene.teilzeitladen.xyz → http://10.40.20.167:8890` (Mac-mini-Backend, dort PostgREST + sync + web via lokalem Caddy `:8890`). Vor TP34: manueller Quick-Win-Caddy-Block.
- **Abh.:** TP22 (GC-Pipeline) ✅ · TP23 (DNS/Caddy-Voraussetzungen) ✅ Stufe 1 · 🔴 **NEU 2026-09-06: TP37 Punkt 1+2** — Caddy rendert *Config aus Daten*, die Backend-Dienste müssen als `ipam.Service` in Nautobot existieren, sonst gibt es nichts zu rendern. **TP37 vor TP34.**
- **Datenmodell (entschieden 2026-09-06):** native Modelle — Backend = `ipam.Service`, Proxy-Route = Relationship/CustomField Frontend↔Backend, GC-Job rendert den Caddyfile. Keine eigene App (konsistent zu TP44).
- **Status:** 🔜 Stub; wartet auf TP37 P1+2. Quick-Win `terpene.teilzeitladen.xyz → 10.40.20.167:8890` als manueller Caddy-Block ist **Schritt 0** des Runbooks. Plan: [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
---
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 6 (Skill) ✅**
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema. - **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell). - **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
- **Scope:** - **Scope:**
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive). 1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. 2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. **✅ 2026-09-12 angewandt:** `exports.zsh` um die 3 fehlenden `MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS` ergänzt (jetzt 25 Vars, die opencode schon referenzierte — vorher liefen die dort leer), `gui-env-sync` gelaufen (25/25 in launchd-Env). `~/.claude.json` via `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude` (bei beendeter App, Backup `.bak-20260912-*`) geflippt — **alle 7 Secrets** (`mikromcp` 5× PASS, `hostinger-api` Token, `n8n-mcp` Bearer) jetzt `{env:}`, verifiziert; App neu gestartet, MCP-Server laden sauber. 🔴 **Merkregel:** `~/.claude.json` nie aus laufender Session editieren (Claude Code schreibt dort State → überschreibt). Restcheck offen: **authentifizierter** mikromcp-Read **on-network** (am 09-12 Connect-Timeout VLAN99:80 = Erreichbarkeit vom Standort, kein Auth-Fehler). Rollback = `.bak` zurück.
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions. 3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort (voraussichtlich `bf-infra/docs/announcements.md`) + Skill `/news` oder `/announce` zum Einlesen beim Session-Start. Initial: Breaking News über die MCP-Schichtung selbst. 4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort + Skill zum Einlesen beim Session-Start. **✅ 2026-09-12:** `docs/announcements.md` angelegt (Format: `## YYYY-MM-DD — Titel` + `Betrifft:`/`Aktion:`, neueste oben) + globaler Skill **`/news`** (in beiden Skill-Pfaden `~/.claude/skills/news/` **und** `~/.config/opencode/skills/news/`, `cp` kein Symlink — Schicht-6-Lehre). `/news` zeigt Einträge, die seit dem letzten Lauf dazukamen (Seen-State pro Repo unter `~/.news-seen/`). Erste Einträge: SSoT-Cutover (TP36) + TP36-Live.
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. 5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. **✅ 2026-09-12 abgenommen:** Guard (112 Z., `check`/`mark`/`status`) + `.git/hooks/pre-commit` in `bf-infra` live verdrahtet; der Hook hat beim TP36-Live-Commit real gegriffen („Projekt aktiv 12 Commits/198 LOC, Backup markiert"). ⚠️ **Bekannte Grenze:** `check` prüft nur *Existenz* der Guard-Datei, nicht deren Alter — ein alter `mark` (hier 2026-08-29) lässt den Commit durch. Das ist als Push-Erinnerung gewollt, keine Frische-Garantie.
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block. 6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung. - **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring). - **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
@@ -2512,157 +2605,6 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
- 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die - 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die
Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen. Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen.
### TP41 — CF-Access-Reparatur + Zero-Trust-Konsolidierung ▸ ⏸️ **ZURÜCKGESTELLT 2026-09-01 (User-Entscheid)** — Auth-Kern durch; offen nur Gelis WARP-Client (nicht aus der Ferne machbar), User gibt ggf. später Info
**Auslöser:** CF-Access zwang Christoph **und Geli** täglich zum OTP-Login (soll: 30 Tage); GitHub/Google
„funktionieren nicht". Root-Causes: Team-Domain-Rename `bf4`→`emsenhuber` (Provider-Callback veraltet),
App-Session 24h, diverse Ordnungs-/Sicherheitslecks. Vollständiger Verlauf + offene Schritte:
**[`docs/handoff-20260901-tp41.md`](handoff-20260901-tp41.md)**. Branch `tp41-cf-access-repair`.
**✅ Live erledigt:** Session 720h (Wildcard/vw/Warp-Login) · login_method-Loch zu (reusable Policy
`bf-users` = nur `email:christoph`+`email:geli`) · `8e44f5d1` von 4 Apps auf **nur Terpenfinder**
entkoppelt · Access-**Gruppen** `zt-humans`/`zt-home`/`zt-automation`, Policies zeigen darauf · **vw voll
Bypass** (Lebensader offen) + **`vw/admin` per WAF-Regel öffentlich geblockt** (CF-Edge + Mobilfunk
verifiziert) · API-Token auf das vollständige `…4148` konsolidiert (User hat Service-Tokens- + Zero-Trust-
Write ergänzt), altes `…2174` revoken.
**✅ T4 DURCH (2026-09-01, Variante B):** ein Access-Service-Token **`zt-svc`** (`65f1821a`, rotiert) über
Gruppe `zt-automation`; Terpene-Worker per `wrangler secret put TERPENE_SVC_CLIENT_ID` auf zt-svc
umgestellt (Worker-Pin `index.js:44`); `terpene-verify`+`-v2` **gelöscht**; `test_whoami` grün.
**Token-Altlast geklärt:** `b00722` = LIVE Caddy-DNS-Token (LXC130 caddy.env, SSoT), `d47435` = stale
Kopie (kein Consumer) → **User revoket `d47435`**; `credentials.env`-Kopie auf b00722 realignt.
**✅ T3 APPLIED (2026-09-01):** Default-Profil (`0197d1b3`; das custom „Cloudflare Mesh" matcht nur den
`warp_connector`) `fallback_domains += emsenhuber.net → [10.40.20.36, 10.40.20.37]`; Tunnel-Route
`10.40.0.0/16` (`d1da7c8a`) da, kein `10/8`-Exclude → routbar. ⏳ Client-Verify: WARP neu verbinden.
**✅ T2 DURCH (2026-09-01):** WAF-Regel `f26744eb` gelöscht; Caddy `vw`-Block `respond @cf_admin 404` bei
Header `CF-Connecting-IP` (LXC130). Verify: Edge `/admin`=404, lokal/WARP `/admin`=200. ✅
**✅ T5 DURCH (2026-09-01, User-bestätigt):** GitHub-Login läuft; Google-Login läuft nach dem
Consent-Screen-Fix (§A des Follow-up-Handoffs: User Type *Internal*→*External*/veröffentlicht).
Callback für beide `https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback`.
**❌ Facebook/Meta für TP41 abgesagt (2026-09-01, User-Entscheid):** optional, kein Bedarf — Geli ist
über OTP `angelikahy@outlook.de` + Google `pillepalle670@gmail.com` voll gedeckt. **Keine Facebook-App,
kein CF-Facebook-IdP angelegt → nichts aufzuräumen.** Instagram hat ohnehin keinen CF-IdP.
**✅ App-Launcher (D1):** je eine Bookmark-Kachel vw/hass/terpenfinder, Wildcard-Kachel raus, Launcher
gestylt (Follow-up-Handoff §D, Policies unverändert).
**✅ Geli in WARP — CF-seitig nichts zu tun (2026-09-01 live geprüft):** Device-Enrollment läuft über
die App **„Warp Login App"** (`6c32fe23…`, type `warp`) → Policy `bf-users` → Gruppe **`zt-humans`**, die
Gelis beide Mails **schon enthält** → sie **darf bereits enrollen** (deshalb war „Device enrollment
permissions" nicht als fehlendes Setting zu finden). Terpenfinder-`geli`-Policy lässt sie per E-Mail durch.
⇒ verbleibt **nur ein Handgriff auf Gelis Gerät** (WARP installieren, Team `emsenhuber`, Login OTP
`angelikahy@outlook.de` / Google `pillepalle670@gmail.com`) — nicht aus der Ferne machbar.
🔑 **Permission-Frage beantwortet:** CF-seitig **keine** nötig. Für generelle WARP/Enrollment/User-Verwaltung
per Account-Token: *Access: Apps and Policies → Edit* + *Access: Organizations, IdPs, and Groups → Edit*
(User erteilt 2026-09-01). **Seat-Verwaltung (`/access/seats`) bleibt per MCP gesperrt** — `10405 Method
not allowed for this authentication scheme` (Endpoint will User-Token, nicht Account-Token; kein Scope-Fix,
für Enroll irrelevant).
**🔜 Offen — nur noch außerhalb CF:** **Gelis WARP-Client** auf ihrem Gerät (s.o.) · **T6** optional
Rate-Limit · 🔴 `TERPENE_ANDROID_KEYSTORE_PW`-Rotation erwägen (TP30 Posten #7, Transkript-Leak).
**d47435-Revoke ✅ · T3-WARP-Client-Verify ✅** (User, 2026-09-01).
**🔻 Optionaler Security-Cleanup (eigener Punkt, nicht in dieser Runde):** die `geli`-Terpenfinder-Policy
hat **OR-Semantik** und enthält den breiten CGNAT-Bereich `94.154.138.0/24` als IP-Include → jeder aus
dieser geteilten Range käme **ohne Login** rein. Über WARP ist der Egress ohnehin Cloudflare (nicht diese
IP) → der IP-Include wäre dann überflüssig und entfernbar.
**Abgrenzung:** hass/gitea bleiben (diese Runde) öffentlich; Terpenfinder-Human-Policies nicht angefasst;
**Gitea-Least-Privilege** = eigener künftiger TP (jedes Projekt repo-scoped Login, Admin nur Claude/Christoph).
- Bezug **TP11** (Monitoring): eine Plattenfüllstand-Warnung hätte das Wochen früher gezeigt.
### TP42 — Namens-Vereinheitlichung bf.lan → emsenhuber.net ▸ 🔜 ANGELEGT 2026-09-01 (User-Okay erteilt)
**Auslöser (2026-09-01, TP41-Randbefund):** live gemessen liefern die realen Client-Resolver
`10.40.20.36`/`.37` `*.emsenhuber.net → 10.40.20.40` (Caddy) — korrekt. Daneben existiert aber ein
zweiter, gedrifteter interner Namensraum: der **Core** trägt in `/ip dns static` 11 Einträge auf
`*.bf.lan`, der **Caddyfile** doppelt jeden internen Namen als `*.bf.emsenhuber.net`, und die alten
AdGuard `.16`/`.17` antworten noch mit `nginx*.bf.emsenhuber.net` bzw. totem VLAN1 `10.40.41.123`.
Christoph: „aus emsenhuber.net wurde schon wieder bf.lan — das müssen wir ein für alle mal aus allen
Geräten rausprügeln und in Nautobot festhalten."
**Ziel:** genau **ein** interner Namensraum `emsenhuber.net` (Split-Horizon `.36/.37 → .40`). `.bf.lan`
und `.bf.emsenhuber.net` werden entfernt; Sollzustand in Nautobot dokumentiert.
**Scope (Reihenfolge = eigene Session):**
1. **Bestandsaufnahme** aller Fundstellen: Core `/ip dns static` (`*.bf.lan`), DHCP-Option
`domain`/Search-Domain je Netz, Caddyfile-Aliase (`*.bf.emsenhuber.net`), AdGuard-Rewrites `.36/.37`,
stale `.16/.17`, Geräte-`identity`.
2. **Entscheidung Zielschema** (nur `emsenhuber.net`, ggf. `bf.emsenhuber.net` als *einzige* interne
Zone — Christoph-Entscheid) und Migrationsreihenfolge ohne DNS-Loch.
3. **Umsetzen:** Core-DNS + DHCP + Caddyfile angleichen; stale `.16/.17` klären/abbauen.
4. **Nautobot:** interne Namen/Records als Sollzustand erfassen (Bezug TP37 Datenmodell).
**Abgrenzung:** kein CF-Public-DNS-Umbau (Zone `emsenhuber.net` öffentlich bleibt); kein Resolver-Swap
(`.36/.37` bleiben); `.1`-als-DNS-Reste auf Clients sind separat/tolerierbar (User: „bis jetzt kein
Problem"). **Abh.:** TP37 (Nautobot-Datenmodell) für Punkt 4; sonst nichts Offenes.
#### ✅ Bestandsaufnahme LIVE (2026-09-02) — Drift hat 80 % schon erledigt
Gegen die Geräte gemessen (MikroMCP + dig), nicht aus dem Handoff:
| Fundstelle | Handoff 09-01 | Live 09-02 |
|---|---|---|
| Core `/ip dns static` | 11× `*.bf.lan` | ✅ **0× `.bf.lan`** — 10× `*.bf.emsenhuber.net` (deckt sich mit GC-Template Z.284–293) |
| DHCP `domain` (7 Netze) | „prüfen" | `emsenhuber.net` (GC-Template Z.133 = gleich ⇒ **keine Drift**), DNS `.36/.37` |
| `.36/.37` (Soll) | aktiv | `vw.emsenhuber.net→.40` ✅ · `macmini.bf.emsenhuber.net→.20.167` ✅ · **`macmini.emsenhuber.net→leer`** ❌ |
| Caddyfile | `*.bf.emsenhuber.net`-Aliase | unverändert, **konsistent** mit Core-Static |
| `.16`/`.17` | tot | laufen (:53), liefern totes VLAN1; **kein Core-Objekt** (Firewall/NAT/DNS-Upstream/Addr-List) hängt an `.16` |
**Kernkonflikt:** DHCP verteilt `emsenhuber.net` (bare), Records leben unter `bf.emsenhuber.net` ⇒ bare `macmini` löst nicht.
#### ✅ Entscheidungen (User, 2026-09-02)
1. **Zielschema = `bf.emsenhuber.net`** (der wenig-Arbeit-Weg): DHCP-domain → `bf.emsenhuber.net`, dann
löst `macmini` bare korrekt; Core-Static + Caddyfile bleiben (tragen schon `.bf.`). Öffentliche Zone
`emsenhuber.net` bleibt getrennt.
2. **`.16` (adguard_bf1) abbauen**, **`.17` (adguard_fm2 = Standort FM) NICHT anfassen.**
#### ✅ Umsetzung DURCH (2026-09-02)
- **(A) DHCP-domain `emsenhuber.net → bf.emsenhuber.net` über GC ✅** — kein Direkt-Write:
`mikrotik_routeros.j2` Z.133 gehoben → `push-gc-template.py` (Commit `7a4068c4`, **Nebenbefund:** das
Remote-Template hing dem lokalen hinterher — es trug noch `*.bf.lan`+`router.lan`, obwohl Gerät+lokal
längst `.bf.emsenhuber.net` fuhren; der Push hat das GC-Repo an den Live-Stand angeglichen) →
GitRepo-Sync (SUCCESS) → manueller ConfigPlan **`tp42-dhcp-domain`** (`set [find] domain=…`, weil
`add` das Netz doppelt anlegen würde) → Deploy an **crs326-core** → Live **7/7 = bf.emsenhuber.net**,
Compliance **140/140, keine Drifts**. Der Job steht dauerhaft in `nautobot/gc-configplan.py`.
- **(B) `.16`-Teardown ✅** — `adguard_bf1` lag im geteilten Portainer-Stack **195 `adguard`** (Docker-LXC
`.126`, **ep2** — *nicht* ep14) **zusammen mit `adguard_fm2` (`.17`, Standort FM!)**. Deshalb **nicht**
Stack löschen, sondern compose auf nur `.17` getrimmt + Stack redeployt (Prune): `.16`-Container weg,
`.16:53` tot; **`.17` läuft** (⚠️ ~40 s mit-neugestartet durch den Redeploy — kurzer FM-DNS-Blip,
danach sauber). Rollback-compose gesichert. 🧹 Daten-Dirs `/opt/docker/adguard_bf1/*` bleiben als
harmlose Leichen auf dem LXC.
- **(C) Nautobot-Soll** (Scope-Punkt 4) bleibt an **TP37** gekoppelt (Datenmodell), separat — kein Blocker.
**Status:** ✅ **Namensraum vereinheitlicht (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`,
`.bf.lan` raus, `.16` abgebaut, Compliance 140/140. Offen nur der TP37-gekoppelte Nautobot-Doku-Teil.
Handoff: [`docs/handoff-20260901-tp42-naming.md`](handoff-20260901-tp42-naming.md).
### TP43 — dw-Standort-DNS-Uplink (site-spezifische Overrides) ▸ 🔜 ANGELEGT 2026-09-01 (User-Okay erteilt)
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
uns verfranzen. Geli kommt rein, das zählt."*
**Ziel:** dem dw-Standort einen **eigenen Resolver/DNS-Uplink** geben, der **site-spezifische Overrides**
hält (erster Fall `420.teilzeitladen.xyz` → BF-Caddy über WG), als DNS der FritzBox/Clients gesetzt.
**Kernfrage (explizit):** Verhalten bei **WG-Disconnect.** Ein Override auf eine BF-interne IP
(`10.40.20.40`) fällt aus, wenn der Tunnel unten ist. Für einen Login-gegateten Dienst gibt es kein
„kein Login **und** funktioniert bei WG-down" — Optionen: (a) Override nur bei WG-up (Failover-Resolver),
(b) öffentlicher Pfad (CF, mit Login) als Fallback. Zu entwerfen.
**Sofortlösung (nicht Teil von TP43):** Geli per **WARP** (Session statt täglichem Login, CGNAT egal,
degradiert sauber: WG-up = nahtlos, WG-down = öffentliche URL + Login).
**BF-seitige Prereqs:** Caddy-vhost `420.teilzeitladen.xyz` → macmini-Worker (`10.40.20.167:8890`) mit
Cert (DNS-01, `…4148` hat teilzeitladen.xyz-DNS) · WG-Route dw→`10.40.20.40` · Split-Horizon für
`420.teilzeitladen.xyz` (heute liefern auch `.36/.37` public). **Vorher verifizieren:** FritzOS-Version +
Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
[`docs/handoff-20260901-tp41-followup.md`](handoff-20260901-tp41-followup.md).
**Status:** 🔜 angelegt 2026-09-01, eigene Session.
### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt) ### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt)
> **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in > **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in
> nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf > nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf
@@ -2687,7 +2629,7 @@ Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
„wo läuft was" steht. „wo läuft was" steht.
2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`, 2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`,
Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**. Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**.
3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit 3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](teilprojekte-archiv.md#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit
entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/ entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/
Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch
TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.** TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.**
@@ -2716,35 +2658,6 @@ Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
--- ---
<a id="tp44"></a>
### TP44 — Cloudflare-Estate als Dokumentations-SSoT in Nautobot ▸ ⏳ **Etappe 1 ✅ live (2026-09-02), Quick-Index nachgezogen 2026-09-06**
> **Entscheid (User, 2026-09-02):** die volle CF-Estate (Tunnel + WARP-Connector + VPC-Service +
> Hyperdrive + Workers + alle Access-Apps) als strukturierte, abfragbare SSoT in Nautobot abbilden —
> **etappenweise: manuell jetzt, Sync später.** Nautobot kann CF nicht steuern → „SSoT" heißt
> **Dokumentations-SSoT; CF bleibt operativer Master.** Detail-Handoff:
> [`handoff-20260902-cf-estate-nautobot-ssot.md`](handoff-20260902-cf-estate-nautobot-ssot.md).
- **Warum eigenes TP, nicht TP36:** TP36 ist eng auf `teilprojekte.md`-Live-Daten geschnitten; gleiche
Idee (Nautobot als Doku-SSoT), andere Dimension. TP44 = nächste freie Nummer nach TP43.
- **Modell (native `nautobot.cloud`, Nautobot 3.1.7 — keine eigene App):** `Manufacturer "Cloudflare"` →
`CloudAccount` (`393c051f…`) → `CloudResourceType ×6` → `CloudNetwork ×4` (`bf-public`/`fm`/`terpene-db`/
`bf-core-mesh`) + `CloudService ×14`; Service↔Tunnel über das Through-Modell
`cloud-service-network-assignments` (3 Rows). IDs/Ports/Hostnames/Origins je Objekt in `extra_config`.
- **Werkzeug:** `nautobot/tp44-cf-estate-seed.py` — idempotenter Seeder (Re-Run = 26/26 „existiert"),
Datenstand inline (Live-CF-Inventur 2026-09-02). Token aus `docs/nas-vlan20/stacks/210-nautobot.yml`.
- ✅ **Verifikation 2026-09-06 (live gegen die API):** CloudAccount 1 · ResourceType 6 · CloudNetwork 4 ·
CloudService 14 — unverändert seit 09-02.
- 🔴 **Gotchas für Etappe 2:** `CloudService.cloud_networks` ist `read_only` (nur Through-Rows schreiben/
lesen) · VPC-Service-API unter `connectivity/directory/services` (PUT, voller Body) · Repo-`CF_API_TOKEN`
darf kein Workers-VPC → dort über den `cloudflare`-MCP-Credential.
- **Offen (Etappe 2, eigene Session):** (1) CF-API→Nautobot-Sync (Python/n8n, analog golden-config-bot) →
driftfrei; (2) IPAM-Prefixe `10.40.0.0/16` + `10.101.0.0/24` anlegen, dann greifen die Prefix-Assignments
automatisch.
- **Beziehung zu TP37:** TP44 hat **TP37 Punkt 3** (CF-Connectoren) vorweggenommen — dieselbe native-Modell-
Linie, die TP37 P1+2 (Substrat/Dienste) fortsetzt.
---
## Abgeschlossene Teilprojekte ▸ [`teilprojekte-archiv.md`](teilprojekte-archiv.md) ## Abgeschlossene Teilprojekte ▸ [`teilprojekte-archiv.md`](teilprojekte-archiv.md)
**34 abgeschlossene TPs** liegen dort im Volltext, unverändert und mit eigenem Inhaltsverzeichnis. **34 abgeschlossene TPs** liegen dort im Volltext, unverändert und mit eigenem Inhaltsverzeichnis.
@@ -2761,7 +2674,7 @@ Kurzorientierung, wo was zu finden ist:
| **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung | | **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung |
| **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos | | **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos |
| **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement | | **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement |
| **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep | | **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep · [TP44](teilprojekte-archiv.md#tp44) CF-Estate Doku-SSoT |
| **Zugang** | [TP10](teilprojekte-archiv.md#tp10) Multi-Site-VPN *(offen nur der DDNS-Updater, s. Follow-ups)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh | | **Zugang** | [TP10](teilprojekte-archiv.md#tp10) Multi-Site-VPN *(offen nur der DDNS-Updater, s. Follow-ups)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh |
| **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) | | **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) |
@@ -2916,3 +2829,78 @@ TP22 · TP-R · **TP3.2** (08-17) · **TP5** (08-18) · **TP7.1** (08-18) · **T
7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest). 7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest).
</details> </details>
### TP43 — dw-Standort: interne BF-DNS-Namen über WG auflösbar machen ▸ ✅ **UMGESETZT 2026-09-11** — dw-FritzBox: Upstream `.36`+`.37`, DNS-Rebind-Ausnahme `bf.emsenhuber.net`, Fallback-Haken „öffentliche NS bei Unreachbarkeit" **gesetzt** (Ideal-Variante: `.37`-Redundanz bleibt, BF-down → public). (verworfene 420-Historie am Ende dieses Abschnitts)
**Live-Auslöser (2026-09-11):** Christoph am dw-Standort, WG-Tunnel zu BF up, kann `portainer.bf.emsenhuber.net`
nicht erreichen. Direkte IP `https://10.40.20.126:9443` geht ⇒ **reines DNS-, kein Routing-Problem.** Der
dw-Client nutzt die FritzBox (`10.95.10.1`) als Resolver; die kennt die interne Zone `bf.emsenhuber.net`
(Split-Horizon `.36/.37 → 10.40.20.40` Caddy) nicht.
**Korrektur gegenüber dem verworfenen Scope:** Der ursprüngliche Fall (`420.teilzeitladen.xyz`) ist ein
**öffentlicher** CF-Worker und war nie das Problem — der ist via TP41/WARP + CF Access gelöst. Das echte,
offene Problem sind **interne** BF-Namen (`*.bf.emsenhuber.net`) über den WG-Tunnel.
**Live verifiziert (2026-09-11, `dig @10.40.20.36` über den Tunnel):** `portainer.bf.emsenhuber.net
→ 10.40.20.40` (Caddy), `macmini.bf.emsenhuber.net → 10.40.20.167` — alle internen bf-Namen landen
in `10.40.0.0/16`, über WG erreichbar. `ds-fm`/`gateway-fm.emsenhuber.net → 188.114.96.10/.97.10`
= **Cloudflare-Anycast** (public), **nie `10.101.0.x`**. ⇒ **Der fm-Einwand (10.101.x intern ohne
Pfad von dw) trifft für `.36/.37` nicht zu** — fm läuft über den public `fm.emsenhuber.net`-Tunnel,
nicht über interne Records. (WG-Topologie TP10: dw-Peer AllowedIPs BF-seitig = `10.40.0.0/16`, dw
routet **nicht** nach `10.101.0.0/24`.)
**Constraint am dw:** die 24/7-Geräte `10.95.10.246/.247` sind **Cudy-APs (kein DNS-Host)**; die
FritzBox kann **kein Per-Zone-Forwarding** (nur einen globalen Upstream) **und per DHCP nur EINE
DNS-IP ausliefern** (⇒ „mehrere NS an die Clients" scheidet aus). Ein echter conditional forwarder
(dnsmasq `server=/bf.emsenhuber.net/.36`) bräuchte einen Zusatz-Dauerläufer — abgelehnt.
**Entscheidung (Christoph, 2026-09-11): FB-Upstream + Rebind-Ausnahme.** Clients bekommen per DHCP
`10.95.10.1` (die FB selbst, Default); die **FB forwardet an die BF-Resolver**: *DNS-Server → „Andere
DNSv4-Server"* Bevorzugt **`10.40.20.36`**, Alternativ **`10.40.20.37`** (beide BF, beide
Voll-Resolver → Redundanz bleibt). Sauberer als der zwischenzeitliche DHCP-Multi-Gedanke: die FB ist
der **einzige** Client-Resolver und `.36` löst **intern und public** auf → alles geht zuerst an `.36`,
public wird nur als Unreachable-Fallback konsultiert, **nie „zuerst"** → kein NXDOMAIN-Leak im
Normalbetrieb.
🔴 **DNS-Rebind-Schutz-Ausnahme `bf.emsenhuber.net`** (*Heimnetz → Netzwerk → Netzwerkeinstellungen →
DNS-Rebind-Schutz*) ist **zwingend**: sonst filtert die FB die Privat-IP-Antworten (`10.40.x`) als
Rebind-Angriff weg → bf-Namen liefern nichts, obwohl `.36` korrekt antwortet. Die Ausnahme ist ein
reines Namens-Whitelist (keine IP).
**BF-down-Fallback (Verzweigung am FRITZ!OS-Stand):** Christoph erinnert ein Setting „öffentliche NS
verwenden, wenn die primären nicht erreichbar sind". Vorhanden → aktivieren, Upstream bleibt
`.36`+`.37`, BF-down geht auf public. Fehlt → Alternativ-Slot statt `.37` auf **public `9.9.9.9`**
(kostet `.37`-Redundanz); dann zusätzlich prüfen, dass die FB **strikt primär** fragt (nicht
round-robbt), sonst NXDOMAIN-Leak für `bf.*` bei BF-up.
- **BF-up:** FB → `.36` → interne `bf.*` (→ `.40`, dank Rebind-Ausnahme durchgereicht) **und** public.
- **BF-down:** `.36`/`.37` unreachable → public-Fallback → public lebt, `bf.*` = NXDOMAIN (erwartet).
**Bewusst akzeptierter Tradeoff:** bei BF-up läuft Geli's **gesamter** Haushalt-DNS über Linz
(`.36`/AdGuard-Filter) — vermeidbar nur mit conditional forwarder (Zusatz-HW, abgelehnt).
**On-site-Verifikationen (Christoph, am dw):** (1) Rebind-Ausnahme greift — `nslookup
portainer.bf.emsenhuber.net` über die FB → stabil `10.40.20.40`; (2) BF-down-Fallback vorhanden
(Setting) oder via public-Alternativ-Slot gesetzt; (3) nur falls BF+public gemischt: FB fragt strikt
primär. Rückfall pro Gerät: `/etc/hosts` `10.40.20.40 <name>` oder WARP `fallback_domains
bf.emsenhuber.net → [.36,.37]` (TP41-Präzedenz).
**Sofort-Workaround (läuft unabhängig):** `https://10.40.20.126:9443` direkt bzw.
`/etc/hosts`-Zeile `10.40.20.40 portainer.bf.emsenhuber.net`.
**Abhängigkeiten:** WG-Route dw→`10.40.0.0/16` (steht). Grenzt an TP42 (interner Namensraum
`bf.emsenhuber.net`) und TP10 (Multi-Site-VPN). **Umsetzung = eigene On-site-Session** (FritzBox-DHCP
ist Christophs manueller Schritt, kein Tool-Zugriff).
---
**Historie — verworfener 420-Scope (angelegt 2026-09-01, verworfen 2026-09-11):**
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
uns verfranzen. Geli kommt rein, das zählt."* — Diese Prämisse fiel weg: `420.teilzeitladen.xyz` ist ein
public CF-Worker hinter CF Access (löst auf CF-Anycast), kein BF-Origin ⇒ dafür braucht es kein
Split-Horizon; Zugang via TP41-**WARP** gelöst (Geli 2026-09-10 via Android). Der Override-Bedarf für
**interne** Namen blieb dabei übersehen — deshalb reopened, s.o.

View File

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

View File

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

View File

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

14
jobs/__init__.py Normal file
View File

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

267
jobs/cf_estate_sync.py Normal file
View File

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

View File

@@ -353,10 +353,76 @@ def job_tp42_dhcp_domain():
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")] return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
def job_tp38_oob():
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
Zwei Geraete, zwei config_sets:
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
die laufende Nautobot-SSH-Session (10.40.20.30) matcht auf Regel 1 (established),
bevor Regel 8 (drop) greift.
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
waere wirkungslos + non-compliant).
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
(10.40.20.30), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
"""
bridge_set = (
':do { /interface bridge port set [find interface=ether23] pvid=99 '
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
'on-error={ :log info "tp38: ether23 set geworfen" }'
)
vlan_set = (
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
)
branch_fw = "\n".join([
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
'add action=drop chain=input comment="TP38: input deny-by-default"',
])
branch_set = "\n".join([
bridge_set,
vlan_set,
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
])
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
core_set = "\n".join([
bridge_set,
vlan_set,
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
'destination=[find where comment="TP5: input log-only"]',
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
])
return [
(["crs326-branch"], branch_set, "TP38-oob-branch"),
(["crs326-core"], core_set, "TP38-oob-core"),
]
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,
} }

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

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

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

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

View File

@@ -73,9 +73,11 @@
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30 /interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
/interface bridge port add bridge=bridge comment=defconf interface=ether15 /interface bridge port add bridge=bridge comment=defconf interface=ether15
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100 /interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
{% for n in range(17, 25) %} {% for n in range(17, 23) %}
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} /interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
{% endfor %} {% endfor %}
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
/interface bridge port add bridge=bridge comment=defconf interface=ether24
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1 /interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2 /interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4). # ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
@@ -94,7 +96,7 @@
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40 /interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50 /interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90 /interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99 /interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100 /interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
# [1] INTERFACE-LISTS # [1] INTERFACE-LISTS
@@ -222,10 +224,12 @@
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40 /interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50 /interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90 /interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
# sec-main: ft=no seit 2026-09-04 (User-Entscheid nach TP25-Messung). Fast-Transition # sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no. # "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus). # Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>" # Fund-E-Abschlussmessung 09-02 ruhig (4 reauth/8,6 h, 0 Ausfaelle) auf 7.24.1.
# 2026-09-04 stand hier: ft=no ft-over-ds=no (User-Entscheid nach TP25-Messung).
/interface wifi security add authentication-types=wpa3-psk ft=yes ft-over-ds=yes name=sec-main passphrase="<<REDACTED>>"
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>" /interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>" /interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>" /interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
@@ -239,11 +243,16 @@
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28). # BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes /interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes /interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main # st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main # zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante). transition-threshold-time=5s
# statt 10s. NUR 5G: die drei 2G-Configs bleiben auf st-main (unset=-80), weil 2.4G draussen
# (Terrasse/ausserhalb der Wohnung) Reichweite behalten soll. 8 dB Abstand = kleine Hysterese.
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main-5g
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main-5g
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main /interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main /interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main /interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main-5g
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main /interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest /interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none /interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
@@ -273,17 +282,16 @@
# python3 nautobot/gc-transaktion.py begin --device crs326-core \ # python3 nautobot/gc-transaktion.py begin --device crs326-core \
# --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT # --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT
# #
# 🔴 Hier landet TP25 **Fund E** (`signal-range`-Boden gegen klebende Clients), # 🔴 Stand 2026-09-14 (Terrasse-In-situ-Session): der `signal-range`-Boden gegen
# sobald er entschieden ist. Die alte Auflage lautete "vor dem Eintragen den # klebende Clients ist GEPRUEFT und GESCHEITERT -- `/interface wifi access-list`
# Sticky-Fall erneut messen" -- das ist am 2026-08-30 passiert: dasselbe iPhone # hat kein `band`-Property (Band-Scoping ginge nur ueber cap-wifiN-Interface-Namen,
# machte 16 Bandwechsel in 20 Minuten bei -53..-81 dBm, erst an ap3, dann an ap2. # die bei jedem CAP-Rejoin wechseln), SSID-/MAC-Scope ohne Band träfe 2,4 GHz mit
# Fund E ist damit WIEDER OFFEN. # (Lockout-Risiko, Schlafzimmer -84). Der Boden ist damit NICHT umsetzbar; der
# Eingetragen wird hier trotzdem NICHTS, und zwar bewusst: ein signal-range-Boden # Ersatz gegen die Call-Aussetzer ist `ft=yes ft-over-ds=yes` auf sec-main
# ist der invasivste der drei Hebel und kann Clients AUSSPERREN -- genau das war # (2026-09-14 zurueckgesetzt, s. docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
# 08-22 das Gegenargument (in den schwachen Raeumen gibt es keinen besseren AP, # und handoff-20260914-tp25-ap2-insitu-terrassse.md): Roams laufen als nahtlose
# auf den ein Boden steuern koennte). Erst kommen (1) `2g-probe-delay=yes` und # FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst
# (2) `ft-over-ds=no`, jeder einzeln und als Transaktion, mit dem seit 2026-08-30 # mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14).
# laufenden wireless-Logging als Messgrundlage. Siehe teilprojekte.md TP25 Fund E.
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich): # Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 # core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
@@ -416,9 +424,20 @@
/system ntp client servers add address=10.40.99.1 /system ntp client servers add address=10.40.99.1
/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1 /ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1
# INPUT-FIREWALL (TP38, 2026-09-15) — deny-by-default fuer die Mgmt-Ebene (10.40.99.2).
# Der Branch ist L2-only: ALLE Mgmt-Anfragen kommen auf der einen vlan99-SVI an, egal ob
# VLAN10 (ueber den Core geroutet), cf-mesh (100.96.0.0/12) oder der OOB-Port (untagged
# ether23, 10.40.99.x). Deshalb matcht die Quelle per src-address, nicht per in-interface.
# Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12 spiegelt bewusst das `available-from` der
# /ip service-Zeilen — kein neues Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
{% for line in obj.get_config_context().get("firewall_filter", []) %}
/ip firewall filter {{ line }}
{% endfor %}
# L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort). # L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort).
# ether9-23 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) — # ether9-22 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
# bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird. # bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird.
# ether23 ist seit TP38 der OOB-MGMT-Port (untagged VLAN99), s. unten.
/interface bridge port add bridge=bridge comment=defconf interface=ether1 /interface bridge port add bridge=bridge comment=defconf interface=ether1
/interface bridge port add bridge=bridge comment=defconf interface=ether2 /interface bridge port add bridge=bridge comment=defconf interface=ether2
/interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10 /interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10
@@ -427,9 +446,10 @@
/interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40 /interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40
/interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20 /interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20
/interface bridge port add bridge=bridge comment=defconf interface=ether8 /interface bridge port add bridge=bridge comment=defconf interface=ether8
{% for n in range(9, 24) %} {% for n in range(9, 23) %}
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20 /interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20
{% endfor %} {% endfor %}
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
/interface bridge port add bridge=bridge comment=defconf interface=ether24 /interface bridge port add bridge=bridge comment=defconf interface=ether24
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1 /interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2 /interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2
@@ -439,7 +459,7 @@
/interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40 /interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40
/interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50 /interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50
/interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90 /interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge vlan-ids=99 /interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge untagged=ether23 vlan-ids=99
{% elif obj.role.name == "Wireless AP" %} {% elif obj.role.name == "Wireless AP" %}
{% set ap = obj.get_config_context()["ap"] %} {% set ap = obj.get_config_context()["ap"] %}
{% set mgmt_host = obj.primary_ip4.host %} {% set mgmt_host = obj.primary_ip4.host %}

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
TP44 Etappe 2 Teil A — die zwei fehlenden IPAM-Prefixe der CF-CloudNetworks anlegen.
Zwei CloudNetworks (tp44-cf-estate-seed.py) tragen eine Route, die der Seeder als
cloud-network-prefix-assignment verknuepfen wuerde -- aber nur, wenn der Prefix in
IPAM existiert (dort Schritt [7], sonst "~ Prefix ... nicht in IPAM"):
10.40.0.0/16 -> bf-core-mesh (WARP-Connector auf crs326-core, TP16.1) [container]
10.101.0.0/24 -> fm.emsenhuber.net (Standort FM, cloudflared-Tunnel) [network]
Idempotenter get-or-create (Dry-Run default). Nach dem --commit den Seeder erneut
laufen lassen -- er haengt die zwei Assignments dann automatisch ein.
10.40.0.0/16 als 'container', weil die VLAN-/24 (10.40.10/20/30/...) aus
ingest-baseline.py bereits als Prefixe existieren; Nautobot nistet sie
automatisch darunter (rein hierarchisch, nicht-destruktiv).
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
Aufruf:
python3 nautobot/tp44-ipam-prefixes.py # Dry-Run (default)
python3 nautobot/tp44-ipam-prefixes.py --commit # legt an
"""
import json
import os
import re
import sys
import urllib.request
import urllib.parse
import urllib.error
from pathlib import Path
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
REPO = Path(__file__).resolve().parent.parent
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
COMMIT = "--commit" in sys.argv
def load_token() -> str:
for line in YML.read_text().splitlines():
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
if m:
return m.group(1)
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
TOKEN = load_token()
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
"Content-Type": "application/json"}
def req(method: str, path: str, body=None):
url = f"{NB}{path}"
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, data=data, headers=HDRS, method=method)
try:
with urllib.request.urlopen(r, timeout=20) as resp:
return resp.status, json.loads(resp.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode() or "{}")
def find(endpoint: str, params: dict, match_key: str, match_val: str):
q = urllib.parse.urlencode(dict(params, depth=0))
st, d = req("GET", f"/api/{endpoint}/?{q}")
for row in d.get("results", []):
if row.get(match_key) == match_val:
return row
return None
def status_id(name: str):
return find("extras/statuses", {"name": name}, "name", name)["id"]
def namespace_id(name: str):
return find("ipam/namespaces", {"name": name}, "name", name)["id"]
# ---------------------------------------------------------------- Inventar
# (prefix, type, description)
PREFIXES = [
("10.40.0.0/16", "container",
"BF-Supernet; Route des CF WARP-Connectors bf-core-mesh (crs326-core, TP16.1)"),
("10.101.0.0/24", "network",
"Standort FM; Route des CF cloudflared-Tunnels fm.emsenhuber.net"),
]
def main():
print(f"== TP44 IPAM-Prefixe ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
st_active = status_id("Active")
ns_global = namespace_id("Global")
for prefix, ptype, desc in PREFIXES:
existing = find("ipam/prefixes", {"prefix": prefix}, "prefix", prefix)
if existing:
print(f" = Prefix: '{prefix}' existiert ({existing['id']})")
continue
if not COMMIT:
print(f" + Prefix: '{prefix}' [{ptype}] [DRY-RUN]")
continue
payload = {"prefix": prefix, "status": st_active, "namespace": ns_global,
"type": ptype, "description": desc}
st, d = req("POST", "/api/ipam/prefixes/", payload)
if st in (200, 201):
print(f" + Prefix: '{prefix}' [{ptype}] angelegt ({d.get('id')})")
else:
print(f" ! Prefix: '{prefix}' FEHLER {st}: {json.dumps(d)[:300]}")
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
if not COMMIT:
print("Danach: python3 nautobot/tp44-cf-estate-seed.py --commit (haengt die 2 Assignments ein)")
if __name__ == "__main__":
main()

75
opencode.json Normal file
View File

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

View File

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