Compare commits
80 Commits
claude/luc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2d9c0ea7 | |||
| 5c8824850f | |||
| 359ad666c4 | |||
| 4d4779e400 | |||
| 4b307315f0 | |||
| 81cd7bfb7e | |||
| c5086db223 | |||
| a3f2ade4e0 | |||
| 52a8ac462c | |||
| 9638a03e4b | |||
| fb415ae52d | |||
| f63149a769 | |||
| 3601a2b6cb | |||
| 75bd43d1f7 | |||
| 315f1e79e7 | |||
| 6e2a7cfb47 | |||
| 5ad71b37c0 | |||
| 5bcd0b6989 | |||
| 1fddf5d798 | |||
| 08a1aaa80d | |||
| 808d3a168b | |||
| 3ae6399b93 | |||
| 528f133264 | |||
| 27a9977115 | |||
| 6601df5a1b | |||
| c0c63882e4 | |||
| 230f052a01 | |||
| 39e19e7f0f | |||
| be51f12ab9 | |||
| 769d0ac6b3 | |||
| 4a63c1e527 | |||
| 52a469185b | |||
| 2f3d44d4be | |||
| fe11ae2a5c | |||
| 7b365e1586 | |||
| 6d1d56e463 | |||
| 4720b5d223 | |||
| 3f774b7b17 | |||
| 180c5b6c57 | |||
| 7b03e01fab | |||
| 4e61c63ae7 | |||
| 08bbe1830f | |||
| b91bac1265 | |||
| b509a2325e | |||
| 4d273117b6 | |||
| 92bd4aac85 | |||
| 974ecbc855 | |||
| 139d7c029d | |||
| b52d6aa49e | |||
| 8c6cb84dd1 | |||
| e87619e89b | |||
| 177ba100eb | |||
| bce40d182d | |||
| 1d86983f63 | |||
| 27effe937e | |||
| 2cf32bde14 | |||
| 1f19a3b4a9 | |||
| 3e727f1b3b | |||
| 037d2bd22c | |||
| 0e6742f1d7 | |||
| b7d82fe300 | |||
| 13a996c6d9 | |||
| 2a21afe74a | |||
| 9fdcab58ad | |||
| 441e97ed84 | |||
| 89eb9dc3ac | |||
| 4e80ffac28 | |||
| 548d73d84a | |||
| 3eb03ae7b4 | |||
| 3a97bbc41c | |||
| 7d237577e1 | |||
| 777c4e0984 | |||
| 425070ce11 | |||
| ae87e9f511 | |||
| f941731ac4 | |||
| 3877c23505 | |||
| 123a0fefdf | |||
| aebf3776ac | |||
| b6f2129b41 | |||
| 82cd8098ef |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
67
.mcp.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
408
CLAUDE.md
408
CLAUDE.md
@@ -1,46 +1,34 @@
|
|||||||
# BF-Netzwerk — Projekt-Kontext (auto-read)
|
# BF-Netzwerk — Projekt-Kontext (auto-read)
|
||||||
|
|
||||||
**Standort BF:** VLAN-segmentiertes Heimnetz auf MikroTik/RouterOS. Ziel: CAKE/QoS gegen Bufferbloat,
|
**Standort BF:** VLAN-segmentiertes Heimnetz auf MikroTik/RouterOS. Ziel: CAKE/QoS gegen Bufferbloat,
|
||||||
DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der Standalone-Entscheid von
|
DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17), Firewall Least-Privilege
|
||||||
2026-07-22 ist revidiert), Firewall Least-Privilege (**TP5 deny-by-default scharf seit 2026-08-18**), Multi-Site VPN.
|
(**TP5 deny-by-default scharf seit 2026-08-18**), Multi-Site VPN.
|
||||||
|
|
||||||
## Pflichtlektüre pro Sitzung (Reihenfolge)
|
## Pflichtlektüre pro Sitzung (Reihenfolge)
|
||||||
|
|
||||||
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Für den **Detail/Kontext** eines TP dann den `### TPnn`-Prosa-Abschnitt in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`** lesen. *(Die frühere „Quick-Index-Block, 1 Read reicht"-Zeile ist überholt — Index/Status stehen nicht mehr in der Datei.)*
|
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Detail/Kontext eines TP → `### TPnn` in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`**.
|
||||||
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat.
|
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat.
|
||||||
3. Bei Live-Writes oder Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/teilprojekte-archiv.md`**.
|
3. Bei Live-Writes/Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/gotchas.md`** (ausführliche Diagnose-Fallen), **`docs/teilprojekte-archiv.md`**.
|
||||||
4. ✅ **TP16.3 §5 durch (2026-09-01):** [`docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md`](docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md)
|
|
||||||
— alle vier oeffentlichen Namen laufen ueber `bf-public`/Caddy; Alttunnel bleiben als Rueckfallebene
|
**Aktueller Fokus:** TP45 (IP-Schichten A/B/E, C4→**TP34.2**), TP34.2 (interne DNS-Namen als Daten, Gitea #73) und TP46 (CF-/Tunnel-SSoT) offen — Status im Gitea-Issue, Detail in den `### TPnn`-Abschnitten von `teilprojekte.md`.
|
||||||
(§6 offen). Enthaelt den 10-Sekunden-DNS-Fund und drei Messfallen.
|
|
||||||
5. ✅ **WLAN/Fund E — GESCHLOSSEN 2026-09-02: 7.24.1 war die Ursache, nicht `ft-over-ds`:**
|
|
||||||
[`docs/handoff-20260902-tp25-fund-e-messung.md`](docs/handoff-20260902-tp25-fund-e-messung.md)
|
|
||||||
— die Abschlussmessung ergab am 09-02 nur **4 `reauthenticating` in 8,6 h** (alle iPhone `F2:62`,
|
|
||||||
isolierte Einzel, 0 Ausfaelle), Verlauf **218 → 40 → 4**; Vorbedingung geprueft (Core-Uptime 1d18h,
|
|
||||||
`caps joined=0`). Hebel 1 (`ft-over-ds=no`) war widerlegt (6,4 h allein → 124× + 2 Ausfaelle) und ist
|
|
||||||
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
|
|
||||||
**Compliance 140/140**). **Am 2026-09-04 hat Christoph `ft=no` auf `sec-main` als Dauerzustand
|
|
||||||
entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen).
|
|
||||||
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf
|
|
||||||
`wifi1/wifi2` (Bring-up unset `!ft-over-ds`, aber nicht `!ft`; Abnahme zählt nur benannte
|
|
||||||
security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm)
|
|
||||||
**nur auf ap3** bei 3/3 Strichen. Fix: `!security.ft` auf ap3 (uniform `ft=no`), Quelle
|
|
||||||
`tp28-ap3-phase2-cap.rsc` korrigiert. **Merkregel: nach jeder `sec-main`-FT-Änderung jeden CAP einzeln
|
|
||||||
`get wifiN security.ft` prüfen — CAP-lokale Inline-Overrides sind compliance-unsichtbar.**
|
|
||||||
Der TP25-Kanalplan ist am 09-04 committed (DFS-Frequenzlisten + ap1-Provisioning, **140/140, 0 Drifts**).
|
|
||||||
Offen in TP25 bleibt nur **ap2 unterperformt** (2 m/−80 dBm). Vorstufen:
|
|
||||||
[Befund](docs/handoff-20260831-tp27-upgrade-und-hebel1-befund.md) ·
|
|
||||||
[Eingriff](docs/handoff-20260831-fund-e-hebel1-gezogen.md) ·
|
|
||||||
[Rueckbau](docs/handoff-20260901-fund-e-hebel1-zurueckgebaut.md).
|
|
||||||
6. ap3-Bring-up 2026-08-30: [`docs/handoff-20260830-ap3.md`](docs/handoff-20260830-ap3.md) — sechs Funde zu Werks-MikroTiks, CAPsMAN und Golden-Config-Lücken.
|
|
||||||
7. Inventur-Befund 2026-08-27: [`docs/handoff-20260827-inventur-und-architektur.md`](docs/handoff-20260827-inventur-und-architektur.md) — Master-Struktur, Asymmetrie, Drift.
|
|
||||||
|
|
||||||
## SSoT & Branch-Konvention (TP36-Cutover 2026-09-07)
|
## SSoT & Branch-Konvention (TP36-Cutover 2026-09-07)
|
||||||
|
|
||||||
- **SSoT-Dreiteilung:** **Index/Status = Gitea-Issues** (`admin/bf-infra`, Labels `status:*`/`thema:*`) ·
|
- **SSoT-Dreiteilung:** **Index/Status = Gitea-Issues** (`admin/bf-infra`, Labels `status:*`/`thema:*`) ·
|
||||||
**Prosa/Detail = Repo** (`docs/teilprojekte.md` aktiv + `teilprojekte-archiv.md`, `### TPnn`-Abschnitte) ·
|
**Prosa/Detail = Repo** (`docs/teilprojekte.md` aktiv + `teilprojekte-archiv.md`, `### TPnn`-Abschnitte) ·
|
||||||
**Live-Gerätedaten = Nautobot** (Compliance-Zahl, RouterOS-Version …). Ein Status ändert sich **in Gitea**,
|
**Live-Gerätedaten = Nautobot** (Compliance-Zahl, RouterOS-Version …). Ein Status ändert sich **in Gitea**,
|
||||||
nicht mehr in einer Repo-Datei. Sync-/Abgleich-Werkzeug fürs Archiv: `bin/gitea-tp-sync.py` (Dry-Run default,
|
nicht mehr in einer Repo-Datei. Archiv-Backfill-Werkzeug `bin/gitea-tp-sync.py` (Dry-Run default,
|
||||||
Token aus `~/.hermes/.env`, **nie ausgeben**).
|
Token aus `~/.hermes/.env`, **nie ausgeben**).
|
||||||
|
- 🔴 **Die TP-Header in beiden Dateien tragen KEINEN Status mehr** (seit 2026-10-03). Form:
|
||||||
|
`### TP25 · Gitea #17 — <Titel> — <Caveat>`. Der `· Gitea #N`-Zeiger ist die Brücke zum Issue.
|
||||||
|
Wer einen Status sucht, liest das **Label im Board** — nicht den Datei-Kopf. Der alte `▸ ✅ FERTIG …`-Anspruch
|
||||||
|
war eine dritte Statusquelle und ist entfernt; die Header-Marker waren teils älter als die Issues.
|
||||||
|
⚠️ **Status-Marker im Fließtext** (in `└─`-Zeilen, Unterpunkten, Messchronik) bleiben erlaubt — sie
|
||||||
|
beschreiben historische Ereignisse, keinen aktuellen Stand.
|
||||||
|
- 🔴 **`bin/gitea-tp-sync.py` schreibt nur noch NEUE Issues** für Archiv-TPs, die im Board fehlen.
|
||||||
|
Existierende Issues werden **nie** angefasst — kein Label-, kein State-Patch. Die Vorgängerversion
|
||||||
|
glich bestehende Issues gegen den Datei-Marker ab und zog so #24 (`status:dropped` → `done`) und
|
||||||
|
#32 auf falsche Labels. **Richtung Datei → Gitea ist nur fürs *Anlegen* zulässig, nie für *Status*.**
|
||||||
- **Branch-Konvention:** Branch `tpNN-kurz` **jederzeit** mitten in der Session möglich (`git switch -c`,
|
- **Branch-Konvention:** Branch `tpNN-kurz` **jederzeit** mitten in der Session möglich (`git switch -c`,
|
||||||
uncommittete Arbeit wandert mit); für Multi-Session-TPs der Normalfall. **Merge nach `main`, sobald der
|
uncommittete Arbeit wandert mit); für Multi-Session-TPs der Normalfall. **Merge nach `main`, sobald der
|
||||||
Code/Config des TP steht.** Weil Steuerungs-Updates nach **Gitea** gehen (nicht in `teilprojekte.md`),
|
Code/Config des TP steht.** Weil Steuerungs-Updates nach **Gitea** gehen (nicht in `teilprojekte.md`),
|
||||||
@@ -51,15 +39,15 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
|
|
||||||
## Was du sofort wissen musst (Inventur 2026-08-27)
|
## Was du sofort wissen musst (Inventur 2026-08-27)
|
||||||
|
|
||||||
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
|
- **`bf-infra`** = aktiver Master mit Gitea-Remote: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git`. **Das Remote ist nur so viel wert wie der letzte Push** — Backup ist eine Disziplinfrage: `git push`. Der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist — **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Vor jeder Aussage über Remotes/Backups `git remote -v` live prüfen** (eine Session hat „kein Remote" behauptet, statt zu tippen).
|
||||||
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
|
- **`admin/bf-network-r6`** (Gitea) ist Archiv → 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. 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 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.
|
||||||
- **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.)*
|
- **Terpenfinder = `420.teilzeitladen.xyz`** (CF-Worker, Repo `admin/blaueblueten-n8n`, hinter CF Access), **kein Caddy, kein Mac mini im Pfad**. `terpene.*` **existiert nicht und soll es nicht** (live geprüft 2026-09-10). Einziger terpene-benannter CF-Gegenstand: der interne Tunnel `terpene-db` (s. `docs/cf-tunnel-mapping.md` §17). ⚠️ **Grenze:** blaueblüten ist ein eigenes Projekt — nicht anfassen.
|
||||||
- **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. 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. `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. Der MCP-Host liegt auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft noch auf dem macmini (historisch) — gehört **bei Gelegenheit** auf die DS923 umgezogen. Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`.
|
||||||
|
|
||||||
## TP-Management (2026-08-26)
|
## TP-Management (2026-08-26)
|
||||||
|
|
||||||
@@ -73,7 +61,7 @@ Wenn während einer Session ein neues Teilprojekt, eine Abhängigkeit oder ein Q
|
|||||||
in eine Quick-Index-Tabelle oder einen Ist-Stand-Block — die gibt es seit dem TP36-Cutover (2026-09-07)
|
in eine Quick-Index-Tabelle oder einen Ist-Stand-Block — die gibt es seit dem TP36-Cutover (2026-09-07)
|
||||||
nicht mehr in der Datei.
|
nicht mehr in der Datei.
|
||||||
|
|
||||||
**Abgeschlossene TPs** stehen seit 2026-08-23 in `docs/teilprojekte-archiv.md` (Volltext, unverändert, mit Inhaltsverzeichnis) und ihr Gitea-Issue ist `closed` (Label `status:✅`/`❌`). **Ein TP wandert erst ins Archiv, wenn nichts mehr daran zu tun ist — und wieder zurück, sobald doch etwas dazukommt** (Issue dann `reopen`). Ein archiviertes TP ist ein *geschlossenes Ticket, kein versiegeltes*: bei neuer Arbeit an derselben Baustelle wandert der **Volltext zurück** nach `teilprojekte.md` (Zeiger bleibt im Archiv), **kein** Zwillings-TP mit neuer Nummer (Christoph-Direktive 2026-08-30, erster Fall TP28/`ap3`) — nur noch der betroffene `### TPnn`-Prosa-Abschnitt wird angefasst.
|
**Abgeschlossene TPs** stehen seit 2026-08-23 in `docs/teilprojekte-archiv.md` (Volltext, unverändert, mit Inhaltsverzeichnis) und ihr Gitea-Issue ist `closed` (Label `status:✅`/`❌`). **Ein TP wandert erst ins Archiv, wenn nichts mehr daran zu tun ist — und wieder zurück, sobald doch etwas dazukommt** (Issue dann `reopen`). Ein archiviertes TP ist ein *geschlossenes Ticket, kein versiegeltes*: bei neuer Arbeit an derselben Baustelle wandert der **Volltext zurück** nach `teilprojekte.md` (Zeiger bleibt im Archiv), **kein** Zwillings-TP mit neuer Nummer (Christoph-Direktive 2026-08-30, erster Fall TP28/`ap3`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,245 +84,88 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
|||||||
## Hardware & Zugang
|
## Hardware & Zugang
|
||||||
| Gerät | IP | Zugang |
|
| Gerät | IP | Zugang |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
|
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** · **TP15 ✅: ersetzt sw2** (L2-Switch), Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap3) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
|
||||||
| ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt |
|
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** · **PRODUKTIVER L3-Router** — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 ✅)/**NTP-Server**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
|
||||||
| **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 |
|
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **reiner CAP** (Manager = `crs326-core`) · **aktiv am Sideboard** (Wohnzimmer; Uplink `ether1` → core `ether3`, PoE-Port `poe-out=off`) — Kanäle `5260/Ce` (Band B, DFS, Fallback 5280) + `2412`/ch1 · MAC D0:EA:11:4D:40:E0 · Config `routeros/.secrets/ap1-prov-base.rsc` |
|
||||||
| **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² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **reiner CAP** — **Technik UG**, Uplink `ether1` (PoE) an core `ether21`; Kanäle `5300/Ce` (Band B, DFS, Fallback 5320) + `2462`/ch11 · MAC D0:EA:11:4D:40:39 (ether1; Bridge/Mgmt `:3A`) · Config `routeros/.secrets/ap2-prov-base.rsc` |
|
||||||
| 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` |
|
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 · reiner CAP, Manager `crs326-core` · **Technik OG**, Uplink `ether1` → **branch `ether24`** (Trunk) · Kanäle **`5180/ax`** (Band A, non-DFS) + **`2437`** (ch6) · 🔴 **nur 2 Ethernet-Ports** → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon erlaubt) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` |
|
||||||
| 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` |
|
| **🔑 Einheitliches Admin-PW** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). |
|
||||||
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · Uplink `ether1` → **core `ether21`** (Trunk) · Kanäle **`5180/ax/Ce/I`** (5 GHz) + **`2437/ax`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
|
|
||||||
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
|
|
||||||
| 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 ✅: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
|
||||||
| Nautobot (Portainer-Stack, NAS) | 10.40.20.30 | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
| Nautobot (Portainer-Stack, NAS) | **10.40.20.154** | Portainer ep14, Stack Id 203 · **TP21 ✅: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅: Golden-Config-App + Device-Adoption DURCH** (alle 4 MikroTik, Image `3.1.7-gc9`) |
|
||||||
| ~~Omada-Controller~~ | — | ❌ **entfernt 2026-07-16** (User hat den Container gelöscht — overkill für 1 Switch auf der Abschussliste, s. TP2.1/TP15) |
|
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1) · **mcp-pve v0.6.1** (TP14.2a) · `~/.claude.json` ✓ |
|
||||||
| ~~omada-mcp~~ | — | ❌ **entfernt 2026-07-16** — mit dem Controller gelöscht. ✅ **Aufräumen erledigt/gegenstandslos (geprüft 2026-08-16):** nicht mehr in `mcpServers` registriert (nur noch als „disabled" in ein paar Projekt-Scopes = harmloser Merkposten), und **kein `omada`-Hostname mehr im CF-Ingress** |
|
| Proxmox (pve) | 10.40.20.100 | `ssh root@10.40.20.100` (SSH-Key ✓) · **TP14: Host-Mgmt VLAN1→VLAN20** (vmbr0.20 + Default-GW-Flip) |
|
||||||
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1 2026-07-10) · **mcp-pve v0.6.1 (Upgrade 2026-07-10: Single-Session-Bug 0.4.8 „Server already initialized" gefixt, TP14.2a)** · `~/.claude.json` ✓ |
|
| Docker-LXC (auf pve) | 10.40.20.126 | Portainer CE 2.40.0 (`https://10.40.20.126:9443`) · **TP14.2: LXC 400 net0 tag=20/.20.126 (iface heißt `veth20`, NICHT eth0); Docker-Netz `vlan20`=ipvlan parent `veth20`; adguard_bf1 .20.16, adguard_fm2 .20.17** |
|
||||||
| Proxmox (pve) | 10.40.20.100 | `ssh root@10.40.20.100` (SSH-Key ✓) · **TP14 2026-07-13: Host-Mgmt VLAN1→VLAN20** (vmbr0.20 + Default-GW-Flip); `.41.100` bleibt interim ohne Route |
|
| Mac mini | **10.40.20.167** (VLAN20) | `ssh macmini` · n8n-Host (siehe oben) |
|
||||||
| Docker-LXC (auf pve) | 10.40.20.126 | Portainer CE 2.40.0 (`https://10.40.20.126:9443`) · **TP14.2 2026-07-10: LXC 400 net0 tag=20/.20.126 (iface heißt jetzt `veth20`, NICHT eth0); Docker-Netz `vlan20`=ipvlan parent `veth20`; adguard_bf1 .20.16, adguard_fm2 .20.17** |
|
| Arris-Kabelmodem | (Bridge-Mode, keine Mgmt-IP im BF-LAN) | Liwest-Edge · speist **`crs326-core vlan100`** (VLAN100-Transfer, WAN-IP `90.146.33.177`) · **FritzBox 6660 seit 2026-07-01 ausgebaut** (Liwest-Tausch, kein Zurück); Telefonie nicht weitergeführt |
|
||||||
| Mac mini | **10.40.20.167** (VLAN20) | `ssh macmini` · ⚠️ die früher hier stehende `10.40.41.167` war **stale** (VLAN1) — live korrigiert 2026-08-16 (ARP `reachable` auf `vlan20`) |
|
|
||||||
| Arris-Kabelmodem | (Bridge-Mode, keine Mgmt-IP im BF-LAN) | Liwest-Edge · speist **`crs326-core vlan100`** (VLAN100-Transfer, WAN-IP `90.146.33.177`) — seit Core-Cutover 2026-08-08, davor via sw1 gi16 → ap1 · **FritzBox 6660 seit 2026-07-01 komplett ausgebaut** (Liwest-Tausch, kein Zurück); Telefonie nicht weitergeführt |
|
*Ausgemusterte Geräte (ALLNET sw1, Omada sw2/Controller/ES208G, omada-mcp): Historie in TP15/TP2.1-Archiv + `switch/README.md`.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
- **MikroTik (core/branch/ap1/ap2):** MikroMCP-Server live → `mcp__mikromcp__*`-Tools (routerId `crs326-core`/`crs326-branch`/`ap1`/`ap2`).
|
- **MikroTik (core/branch/ap1/ap2):** MikroMCP-Server live → `mcp__mikromcp__*`-Tools (routerId `crs326-core`/`crs326-branch`/`ap1`/`ap2`).
|
||||||
Reads immer safe; Writes mit `dryRun:true` previewen, dann `apply_plan`. **Passwort setzen via `manage_user set-password`** (`/user set*` ist per Deny-Policy blockiert). ⚠️ **Reboot geht auf ROS 7.24 gar nicht ueber MikroMCP:** das `reboot`-Tool sendet `delay`, die REST-API kennt den Parameter nicht (`unknown parameter delay`), und `run_command` ist fuer `/system reboot*` gesperrt — Weg ist `SSHPASS="$PW" sshpass -e ssh admin@<ip> "/system reboot"` (PW aus `~/.secrets`, im Output
|
Reads immer safe; Writes mit `dryRun:true` previewen, dann `apply_plan`. **Passwort setzen via `manage_user set-password`** (`/user set*` ist per Deny-Policy blockiert). ⚠️ **Reboot geht auf ROS 7.24 gar nicht über MikroMCP** — Weg ist `SSHPASS="$PW" sshpass -e ssh admin@<ip> "/system reboot"` (PW aus `~/.secrets`, im Output maskieren).
|
||||||
maskieren). 🔴 **Niemals `sshpass -p`** — das schreibt das Passwort in die Kommandozeile, wo es in
|
🔴 **Niemals `sshpass -p`** — das schreibt das Passwort in die Kommandozeile (`ps`/argv-Trace; so ist am 2026-08-22 das Core-PW in ein Transkript geraten). `-e` hält es in der Umgebung des Kindprozesses. **Neue Skripte erben die Regel nicht von selbst**: `env=dict(os.environ, SSHPASS=pw)` einbauen. `bridge/vlan/set` nur auf `[find … and !dynamic]`.
|
||||||
`ps` und jedem argv-Trace steht; **genau so ist am 2026-08-22 das Core-Passwort in ein
|
🔴 **`list_wifi_interfaces` gibt `security.passphrase` ALLER Profile im Klartext zurück** (2026-08-24: fünf WLAN-PSKs ins Transkript). **Für Kanäle `/interface wifi channel print detail`, für die CAP-Zuordnung `… print where master=yes` mit Feldauswahl.** Der Scan (`/interface wifi scan`, nur am Manager) ist unbedenklich. Merkregel: **vor jedem Read fragen, was das Tool AUSSER dem Gesuchten noch ausgibt.**
|
||||||
Sitzungstranskript geraten** und musste rotiert werden. `-e` hält es in der Umgebung des Kindprozesses.
|
|
||||||
✅ **Alle sechs Skripte im Repo sind am 2026-08-31 umgestellt** (`nautobot/gc-transaktion.py`,
|
|
||||||
`routeros/import-core-rsc.py` und die vier inzwischen nach
|
|
||||||
`routeros/archiv-tp15-cutover/` archivierten Cutover-Skripte) — gemessen
|
|
||||||
mit `ps`-Stichproben plus Negativkontrolle, nicht nur gelesen. **Neue Skripte erben die Regel nicht
|
|
||||||
von selbst**: wer einen sshpass-Aufruf schreibt, baut `env=dict(os.environ, SSHPASS=pw)` mit ein. `bridge/vlan/set` nur auf `[find … and !dynamic]`.
|
|
||||||
🔴 **Zweiter Leak-Weg, gleiche Klasse: `list_wifi_interfaces` gibt `security.passphrase` ALLER
|
|
||||||
Profile im Klartext zurück** — am 2026-08-24 hat ein Read, der nur die `cap-wifiN`-Zuordnung finden
|
|
||||||
sollte, damit **fünf WLAN-PSKs** (`main`/`iot`/`media`/`untrusted`/`guest`) in ein Transkript
|
|
||||||
geschrieben. Kein Werkzeugfehler — das falsche Werkzeug für die Frage. **Für Kanäle
|
|
||||||
`/interface wifi channel print detail`, für die CAP-Zuordnung `… print where master=yes` mit
|
|
||||||
Feldauswahl.** Der Scan selbst (`/interface wifi scan`, **nur am Manager**) ist unbedenklich.
|
|
||||||
Merkregel dahinter: **vor jedem Read fragen, was das Tool AUSSER dem Gesuchten noch ausgibt.**
|
|
||||||
- **~~ALLNET-Switch (sw1)~~:** ❌ **Gerät ausgebaut 2026-08-08** → der Telnet-Runner unter `switch/` ist **Archiv, nicht mehr ausführbar** (kein Endpunkt). Siehe `switch/README.md`. Nicht mehr als Werkzeug anbieten.
|
|
||||||
- **~~Omada-Switch (sw2)~~:** ❌ abgebaut 2026-07-23 (TP15, durch `crs326-branch` ersetzt). Controller + omada-mcp komplett gelöscht — **Omada spielt keine Rolle mehr, nie wieder aufgreifen** (User-Entscheid 2026-07-22). Aufräum-Reste ✅ geprüft 2026-08-16 (s. Tabelle oben).
|
|
||||||
- **Nautobot Golden Config (= der einzige legitime Config-Weg für Netzgeräte):** `nautobot/gc-configplan.py`
|
- **Nautobot Golden Config (= der einzige legitime Config-Weg für Netzgeräte):** `nautobot/gc-configplan.py`
|
||||||
(Token = `NAUTOBOT_SUPERUSER_API_TOKEN` aus `docs/nas-vlan20/stacks/210-nautobot.yml`, gitignored).
|
(Token = `NAUTOBOT_SUPERUSER_API_TOKEN` aus `docs/nas-vlan20/stacks/210-nautobot.yml`, gitignored).
|
||||||
`--job <name>` legt manuelle Pläne an (Dry-Run default), `--deploy-ids …` deployt gestaffelt,
|
`--job <name>` legt manuelle Pläne an (Dry-Run default), `--deploy-ids …` deployt gestaffelt,
|
||||||
`--refresh-compliance` fährt **Backup → Intended → Compliance** in der einzig richtigen Reihenfolge.
|
`--refresh-compliance` fährt **Backup → Intended → Compliance** in der einzig richtigen Reihenfolge.
|
||||||
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans
|
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — ViewSet read-only.** ConfigPlans entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
|
||||||
entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
|
|
||||||
- **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
|
- **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
|
||||||
Ablösung durch Komodo (native MCP) geplant.
|
- 🔴 **`pct push` meldet Exit 0, auch wenn es die Quelldatei NICHT lesen kann** (gemessen 2026-09-21).
|
||||||
|
`rc=0` trotz `failed to open … for reading`; ein `&&`/`||` dahinter beweist nichts, und `validate`/`reload` prüft dann die **alte** Datei → **grüne Abnahme bei unverändertem Zustand** (so sah der Nautobot-Umzug „gelandet" aus, live stand noch die alte Backend-IP). **Regel: Push-Werkzeug wird geprüft, nicht geglaubt** — nach jedem `pct push` den Inhalt vergleichen (`md5sum` diesseits/jenseits in *einem* Remote-Aufruf). Muster: `push_verified()` in `caddy/deploy.sh`. **Erst das eigene Werkzeug verdächtigen, dann das Netz.**
|
||||||
|
- 🔴 **Die Core-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert** (Fund 2026-09-21).
|
||||||
|
Der Mgmt-Zugang für die Deploy-Pipeline läuft über `src-address-list=nautobot` (Regel `*28`), die Liste
|
||||||
|
stammt aber aus dem handgelaufenen `routeros/tp5-etappe2-whitelists.rsc` — **steht in keinem Template**.
|
||||||
|
🔴 **Ein GC-Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per SSH kommt** — die
|
||||||
|
Pipeline wäre tot. Gleiche Lücke wie die DHCP-Reservierungen. **Merkregel: was auf dem Gerät existiert,
|
||||||
|
aber nicht im Template, ist eine Recovery-Lücke, auch wenn Compliance 140/140 grün meldet** — die Zahl
|
||||||
|
prüft nur, was gerendert wird. Systematisch erfasst in **[TP48](docs/teilprojekte.md#tp48)** (Gitea #75),
|
||||||
|
der auch prüft, ob Ansible Nautobot als SSoT ablösen kann. ⚠️ **Nicht Komodo** — es verwaltet Container,
|
||||||
|
nicht RouterOS-Config (frühere Notiz hier war eine Kategorie-Verwechslung).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kritische Gotchas
|
## Kritische Gotchas
|
||||||
- 📜 **~~ALLNET-Gotchas (1 Telnet-VTY, `save` nicht vergessen)~~ — gegenstandslos seit 2026-08-08** (Gerät ausgebaut). Nur noch historisch relevant, s. `switch/README.md`.
|
|
||||||
- 📝 **WLAN-Logs sind seit 2026-08-30 persistent — und der Topic heißt `wireless`, nicht `wifi`.**
|
- 🔥 **Die Firewall ist seit 2026-08-18 deny-by-default — in BEIDEN Ketten.** Ein neuer Dienst über eine VLAN-Grenze braucht eine explizite Regel, sonst funktioniert er nicht. Diagnosewerkzeug = **Log-Regel direkt vor dem Drop** (`TP5-WOULD-DROP` / `TP5-INPUT-WOULD-DROP`, Kollektor `10.40.20.18:514`, Dateien Docker-LXC `/opt/tp5-syslog/log/`). **Erst dort nachsehen, bevor irgendwo anders gesucht wird.** **MAIN→INFRA/MEDIA/MGMT/IOT offen** (IOT seit TP5 E5, 2026-08-19); **IOT→MAIN bewusst zu.** Ad-hoc-Zugriff aus MAIN in geschlossenes VLAN (UNTRUSTED, GUEST): `/ip firewall address-list add list=jit-admin address=<deine-IP> timeout=1h`.
|
||||||
Auch auf dem neuen `wifi-qcom`-Stack tragen Roaming-/Connect-Events `wireless;info` bzw.
|
- 🖨️ **Drucker fällt mit udp/161 (SNMP) auf, nicht 631** — macOS fragt zuerst SNMP, bricht dann ab. Bei „Gerät antwortet nicht" das Log lesen, statt den erwarteten Port zu raten.
|
||||||
`wireless,debug`. Ein geratenes `topics=wifi` wäre wirkungslos geblieben. Zwei Regeln auf dem
|
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅).** Mesh = **NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko). Der Core braucht dafür **kein** `wifi-qcom` (capsman steckt im Basispaket).
|
||||||
**Core** (nicht auf den CAPs — unter CAPsMAN führt der Manager die Events) schicken `wireless`
|
- 🔴🔴 **`/interface wifi cap slaves-datapath` MUSS auf beiden APs gesetzt sein** (`dp-caps-slaves`, `bridge=bridge`, **ohne** `vlan-id`). Fehlt es, assoziieren Clients auf `-iot`/`-media`/`-untrusted`/`-guest` sauber und bekommen **nie DHCP**, während `dirtyvader-main` weiterläuft (so lagen IOT+MEDIA am 2026-08-18 ~14 h). **Abnahme für jeden WLAN-Umbau = frische DHCP-Lease pro SSID — NICHT die Registration-Table.**
|
||||||
und `caps` in denselben Kollektor wie TP5 (`10.40.20.18:514`, Tagesdateien unter
|
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager.** `cap-wifiN`-Zuordnung vorher über `radio-mac` bestimmen, sie ist nicht stabil.
|
||||||
`/opt/tp5-syslog/log/10.40.20.1/`). ⚠️ **RouterOS UND-verknüpft Topics innerhalb EINER Regel**
|
- 🔴 **CAPs finden den Manager per L2-Discovery** (`discovery-interfaces=vlan99`, `current-caps-man-address` = MAC). Deshalb **MNDP (udp/5678)** + **CAPWAP (udp/5246-5247)** in der input-Kette des Core offen lassen.
|
||||||
(`topics=info,!firewall` = info UND NICHT firewall) — `topics=wireless,caps` hätte **nichts**
|
- 🔁 **Slave-VAPs ändern = zweimal arbeiten:** nach `slave-configurations`-Änderung `/interface wifi capsman remote-cap provision [find where identity="apX"]` nachziehen (beide Richtungen). Abnahme nicht am Kommentar (`comment~"ssid"`), sondern `datapath.vlan-id` bzw. Interface-Zahl **am CAP**.
|
||||||
gematcht, es braucht zwei Regeln. Vorher lief alles nur in einen ~75-Zeilen-Ringpuffer, der von
|
- 🔎 **„connect ok, kein DHCP":** zuerst `torch` auf dem VLAN am Core UND den TP5-Drop-Log lesen. Beide leer ⇒ nichts wird verworfen, es kommt nichts an → L2 (Bridge-Mitgliedschaft/Tagging), nicht Regelwerk.
|
||||||
einer Stunde Arbeit überschrieben war; ein seit Wochen gemeldetes Client-Problem war deshalb
|
- 📝 **WLAN-Log-Topic heißt `wireless`, nicht `wifi`.** Zwei Regeln auf dem **Core** (nicht den CAPs) schicken `wireless` + `caps` zum Kollektor `10.40.20.18:514`. ⚠️ **RouterOS UND-verknüpft Topics in EINER Regel** — `topics=wireless,caps` matcht **nichts**, es braucht zwei Regeln. `/system logging` steht unter der ComplianceRule `system-logging`. **Merkregel: bevor man ein sporadisches Problem sucht, erst prüfen, ob es überhaupt mitgeschrieben wird.**
|
||||||
**rückwirkend nicht diagnostizierbar**. `/system logging` steht jetzt unter der 27. ComplianceRule
|
- 🧮 **Log-Burst über VIELE MACs = Infrastruktur, über EINE MAC = Client.** Vor jeder Client-Diagnose erst `grep " caps,"` + `/system resource get uptime` lesen (ein Massenburst war oft ein geplanter Reboot). Umkehrschluss: 93/93 `reauthenticating` von einer MAC auf einer SSID = echter Client-/SSID-Befund.
|
||||||
`system-logging` — vorher unter keiner. **Merkregel: bevor man ein sporadisches Problem sucht,
|
- 🗺️ **Die `cap-wifiN`-Karte verschiebt sich bei JEDEM CAP-Rejoin.** Vor jeder Auswertung `/interface wifi radio print terse proplist=cap,radio-mac,interface` am Manager lesen; liegt zwischen Ereignis und Auswertung ein Rejoin, Zuordnung als **unbekannt** behandeln statt raten.
|
||||||
erst prüfen, ob es überhaupt irgendwo mitgeschrieben wird.**
|
- 🔑 **CAPsMAN verteilt KEINE Login-Passwörter — nur WLAN-PSKs.** WPA-Passphrase (`/interface wifi security`) liegt am Manager ✅; Geräte-Login (`/user`) ist eine **lokale DB je Gerät**. Das einheitliche Admin-PW ist eine **Hand-Konvention**, keine Automatik.
|
||||||
- 🧮 **Ein Log-Burst über VIELE MACs ist Infrastruktur, über EINE MAC ein Client** (Fund 2026-08-31).
|
- 🔴 **Ein neuer CAP braucht `configuration.manager=capsman` auf JEDEM Master-Radio.** `/interface wifi cap set enabled=yes` allein reicht nicht — im Werkszustand ist das Feld leer (= `local`).
|
||||||
Am 31.08. sahen 13 × `caps,info … joined`, 4 × `activity timeout` und zwei Massenbursts
|
- 🔌 **Werks-MikroTik am Trunk: `ether1` ist WAN.** Interface-Liste `WAN` → Discovery aus (kein MNDP), Input-TCP (22/80/8291/8728) **zu**, ICMP **erlaubt** → es pingt, wirkt erreichbar. **Weg rein ist die LAN-Seite** (`ether2`), dort `/interface list member remove [find where interface=ether1]`.
|
||||||
`association rejected, no cached PMK` (74 Zeilen, **38 verschiedene MACs**) wie eine CAPWAP-
|
- 🔀 **`pvid` ist INGRESS, `tagged`/`untagged` ist EGRESS.** Werksgerät-Port gehört **untagged** in die VLAN-Zeile.
|
||||||
Instabilität aus, die das ganze Netz trifft. Es waren die **Reboots des TP27-Upgrades**. Jedes
|
- 🕳️ **Ein neues Gerät ohne `secrets_group` erzeugt eine LÜCKE, keine Fehlermeldung.** Backup-Job scheitert still, keine Compliance-Einträge. 🔴 **Eine gleichbleibende Compliance-Zahl nach dem Hinzufügen eines Geräts ist ein BEFUND, kein Erfolg.** Zweite Voraussetzung: dedizierte Service-Accounts `nautobot`/`ha` existieren auf einem Werksgerät nicht.
|
||||||
Ereignis fiel auf einen Neustart. 🔴 **Vor jeder Client-Diagnose erst `grep " caps,"` und
|
- 🔢 **Firewall-Regelnummern aus `print where` sind Handles, keine Positionen.** Beweis ist der **Zähler** (`print stats`), nicht die Zahl.
|
||||||
`/system resource get uptime` lesen** — sonst sucht man einen Gerätefehler, der ein geplanter
|
- 🔌 **`crs326-core` hat KEIN PoE-out** (CRS326-24G-2S+). AP am Core-Port braucht eigenes Netzteil; der **cAP ax bringt Netzteil + PoE-Injector im Karton** mit.
|
||||||
Reboot war. Der Umkehrschluss gilt auch: 93 von 93 `reauthenticating` von **einer** MAC auf
|
- 🧩 **Die AP-Flotte ist heterogen (cAP ax = 2 Ports vs hAP = 5).** Bridge-Ports kommen aus **`ap.extra_ports`** (ConfigContext `ap-common`; ap3 überschreibt per `local_config_context_data` mit `[]`). ⚠️ **Nautobot merged Dicts tief, Listen ERSETZEND.** **Bevor ein zweites Modell derselben Rolle dazukommt, Literal-Blöcke lesen** („Gerätetyp" vs „Rolle").
|
||||||
**einer** SSID sind ein echter Client-/SSID-Befund.
|
- 🔵 **Container auf MikroTik:** ap1/ap2 = `arm64`, 95 MB Flash frei → **ein** kleiner Container ok (~70 MB nutzbar). **`crs326-core` dagegen NIE** (`arm` 32-bit, 2,9 MB frei) — SPOF, kein Anwendungs-Host (Hardware-Tatsache). **`cloudflared` braucht kein TUN** (Userspace-Proxy) → RouterOS-Container möglich; der WARP-Connector nicht (braucht TUN). **Schwere Container laufen weiterhin auf NAS/pve**, nicht am Router.
|
||||||
- 🗺️ **Die `cap-wifiN`-Karte verschiebt sich bei JEDEM CAP-Rejoin, nicht nur beim Provisionieren.**
|
- **hAP `device-mode=home` (ap1/ap2): scheduler + fetch blockiert** → AP-Provisionierung idempotent **ohne** Scheduler. **`crs326-core` ist KEIN `device-mode=home`** → dort gehen scheduler/fetch (DDNS-Updater).
|
||||||
Am 2026-08-31 wanderte sie zweimal an einem Tag (08:09 Re-Provisionierung, 14:36 Core-Reboot);
|
- **Erstzugriff Werks-hAP am CRS-Fabric:** Uplink `ether1` = Werks-WAN → `192.168.88.1` (LAN) darüber NICHT erreichbar. Weg: **LAN-Port (ether2)** patchen ODER **Core-Temp-Pfad** `192.168.88.2/24` + srcnat masq `192.168.88.0/24`, dann scp+`/import` (danach Temp-Pfad + stale ARP `.88.1` entfernen).
|
||||||
eine im Handoff festgehaltene Tabelle war nach sechs Stunden falsch. ⇒ **Interface-Namen in
|
- 🖨️ **ZWEI Drucker, nicht verwechseln:** **Brother MFC** = eigentlicher Drucker, **VLAN10** (`10.40.10.6`, MAC `30:05:5C:78:86:20`, statisch). **P750W** = Etikettendrucker, **VLAN30** (`10.40.30.5`), läuft auf `dirtyvader-iot`. P750W-Coverage SSoT `docs/wifi-p750w-coverage-and-map.md`. ⚠️ **Power-Save ist KEIN Thema** (frühere „DHCP-geht-aber-Ping-nicht=ARP/Power-Save"-Merkregel war Fehldiagnose).
|
||||||
Logzeilen sind ohne einen zeitnahen Karten-Snapshot NICHT zuschreibbar.** Vor jeder Auswertung
|
- **Ping auf JEDE lokale IP tot, aber TCP geht überall = WARP-Client lokal noch aktiv.** `cloudflared`/WARP ist QUIC-Proxy, kann **kein ICMP**. **Fix: WARP trennen**, kein Router-Eingriff. Memory `ref_warp_local_icmp_trap`.
|
||||||
`/interface wifi radio print terse proplist=cap,radio-mac,interface` am Manager lesen — und wenn
|
- **L3-Router = `crs326-core` (TP21.1 ✅, ersetzt ap1).** Bridge vlan-filtering=yes; VLAN-SVIs `.1` je VLAN mit DHCP (7 Server/54 Leases); DNS über **DoH** zum CF-Gateway `verify-doh-cert=yes` (TP23 ✅). Beide Firewall-Ketten enden mit `drop`; Mgmt-Input nur aus VLAN10, von HA (`8728`), von Nautobot (`22`) und über `cf-mesh`. **ap1/ap2 sind NUR noch APs.**
|
||||||
zwischen Ereignis und Auswertung ein Rejoin liegt, die Zuordnung als **unbekannt** behandeln
|
- **✅✅ Edge-Cutover:** FritzBox aus, Arris Bridge-Mode. WAN terminiert auf **`crs326-core vlan100`**, öffentliche Liwest-IP `90.146.33.177`, kein Double-NAT. **core = Public-Edge + WG-Hub.** Runbook `routeros/wan-cutover-runbook.md`.
|
||||||
statt zu raten.
|
- **mgmt-VLAN-Wechsel am Switch = Lockout-Risiko:** VLAN muss existieren + Port muss es tragen, in einem Schritt.
|
||||||
- 🔑 **CAPsMAN verteilt KEINE Login-Passwörter — nur WLAN-PSKs.** Zwei verschiedene Dinge heißen
|
|
||||||
„Passwort": die **WPA-Passphrase** (`/interface wifi security`) liegt am Manager und wird an die
|
**Diagnose-Fallen (Detail in [`docs/gotchas.md`](docs/gotchas.md), hier nur die Merkregel):**
|
||||||
CAPs verteilt ✅; der **Geräte-Login** (`/user`) ist eine **lokale Benutzer-DB auf jedem Gerät** ❌.
|
- ⏰ Zeitstempel zwischen Geräten widersprüchlich → `/system ntp client get status` (Zeitquelle = Core, DHCP-Option 42, ComplianceRule `system-ntp`).
|
||||||
Beleg: ap1 trägt `admin`, `ha`, `nautobot` lokal, mit gerätelokalen `last-logged-in`-Zeitstempeln.
|
- 🔌 Vor jedem geplanten LXC-Neustart `systemctl --failed` lesen + danach Ports gegen vorher vergleichen.
|
||||||
Das einheitliche Admin-PW ist eine **von Hand angewendete Konvention**, keine Automatik — und
|
- 🧪 „alles gleichzeitig kaputt" → **zuerst das eigene Werkzeug verdächtigen** (`nc -z` lügt), dann das Netz.
|
||||||
logisch kann es nicht anders sein: ein Gerät *wird* erst zum CAP, nachdem sich jemand eingeloggt hat.
|
- 📡 Sonos sucht hunderte Male ohne Verbindung → **Relay, nicht Netz** (Richtungsbilanz `grep -c "via eth1"` vs `"via eth0"`).
|
||||||
- 🔴 **Ein neuer CAP braucht `configuration.manager=capsman` auf JEDEM Master-Radio.**
|
- ⏱️ Konstanter 10-s-Hänger = **DNS-Timeout**, nicht Last (DB-Reverse-Lookup auf toten Resolver).
|
||||||
`/interface wifi cap set enabled=yes` allein reicht nicht — ohne das Feld bietet der CAP seine
|
- 🏠 HA-`http:`-Block in `configuration.yaml` **wirkungslos** → `.storage/http` + WS `http/config/configure`→`promote`.
|
||||||
Radios dem Manager gar nicht erst an. **Symptom (2026-08-30, ap3):** `remote-cap print` zeigt
|
- 🐳 Nautobot auf DS923: **Image-Healthcheck aus**; `cpus:` geht auf Synology nicht.
|
||||||
`state=Ok`, aber `/interface wifi radio print` am Manager listet für dieses Gerät **nichts**, und
|
|
||||||
weil die Provisioning-Regel dann nicht matcht, sucht man den Fehler in der `radio-mac`. Im
|
|
||||||
Werkszustand ist das Feld leer (= `local`).
|
|
||||||
- 🔌 **Werks-MikroTik am Trunk: `ether1` ist WAN, und das sieht aus wie ein Netzproblem.** Beim
|
|
||||||
cAP ax (und den hAPs) steht `ether1` per defconf in der Interface-Liste `WAN`. Folge: Discovery
|
|
||||||
aus (**kein MNDP → in `/ip neighbor` unsichtbar**), Input-TCP verworfen (22/80/8291/8728 **zu**),
|
|
||||||
**ICMP aber erlaubt** — es *pingt*, was sich wie „erreichbar" liest. Kein Registry-Eintrag und
|
|
||||||
kein MikroMCP hilft, denn Port 80 ist zu. **Der Weg rein ist die LAN-Seite** (`ether2` oder das
|
|
||||||
Werks-WLAN); dort `/interface list member remove [find where interface=ether1]` — das ist der Fix.
|
|
||||||
- 🔀 **`pvid` ist INGRESS, `tagged`/`untagged` ist EGRESS.** Ein Port mit `pvid=99`, der in der
|
|
||||||
*tagged*-Liste von VLAN99 steht, nimmt untagged an und antwortet **getaggt**. Symptom
|
|
||||||
(2026-08-30): ein DHCP-Lease hing ewig auf `offered` und ARP auf `failed` — der DISCOVER kam an,
|
|
||||||
der ACK nie. Für ein Werksgerät gehört der Port **untagged** in die VLAN-Zeile.
|
|
||||||
- 🕳️ **Ein neues Gerät ohne `secrets_group` erzeugt eine LÜCKE, keine Fehlermeldung.** Ohne
|
|
||||||
Secrets Group scheitert der Golden-Config-**Backup**-Job still; die *intended* config wird
|
|
||||||
trotzdem sauber gerendert, aber ohne Backup entstehen **keine Compliance-Einträge** — und die
|
|
||||||
Gesamtzahl bleibt unauffällig (bei uns 104/104), weil ein Gerät ohne Ergebnisse auch keine Drift
|
|
||||||
meldet. 🔴 **Merkregel: eine gleichbleibende Compliance-Zahl nach dem Hinzufügen eines Geräts ist
|
|
||||||
ein BEFUND, kein Erfolg.** Erst `130/130` bewies, dass ap3 wirklich geprüft wird. Zweite
|
|
||||||
Voraussetzung: die dedizierten Service-Accounts `nautobot`/`ha` (TP-PWD) existieren auf einem
|
|
||||||
Werksgerät nicht.
|
|
||||||
- 🔢 **Firewall-Regelnummern aus `print where` sind Handles, keine Positionen.** Nach einem
|
|
||||||
`place-before` zeigte `/ip firewall filter print terse where comment~"..."` die Nummer **60**,
|
|
||||||
während `drop` auf **59** stand — die Regel lag trotzdem davor. Beweis ist der **Zähler**: eine
|
|
||||||
Regel hinter dem `drop` kann nicht zählen. Wer nach der Zahl geht statt nach `print stats`,
|
|
||||||
„repariert" eine funktionierende Regel kaputt.
|
|
||||||
- 🔌 **`crs326-core` hat KEIN PoE-out** (`/interface ethernet poe` existiert dort gar nicht,
|
|
||||||
CRS326-24G-2S+). Ein AP an einem Core-Port braucht eigene Stromversorgung. Der **cAP ax** bringt
|
|
||||||
dafür **Netzteil UND Gigabit-PoE-Injector im Karton** mit (Produktseite, 2026-08-30) — die
|
|
||||||
TP25-Notiz „kein Netzteil im Lieferumfang üblich" gilt für dieses Modell **nicht**. Symptom, wenn
|
|
||||||
man das übersieht: der Core-Port steht auf `no-link` und man sucht den Fehler im VLAN-Tagging.
|
|
||||||
- 🧩 **Die AP-Flotte ist seit ap3 heterogen — Literal-Listen im GC-Template sind dadurch gefährlich
|
|
||||||
geworden.** Der AP-Zweig hatte `ether3/ether4/ether5` als Bridge-Ports **hartkodiert**; das war die
|
|
||||||
stille Annahme *„AP == hAP ax² mit 5 Ports"*. Der **cAP ax hat zwei**. Seit 2026-08-30 kommt die
|
|
||||||
Liste aus **`ap.extra_ports`** (ConfigContext `ap-common`, Default = hAP-ax²-Bestand; ap3
|
|
||||||
überschreibt sie per `local_config_context_data` mit `[]`). ⚠️ **Nautobot merged Dicts tief, Listen
|
|
||||||
ERSETZEND** — für diesen Fall genau richtig, bei `zones`/`radios` wäre es eine Falle.
|
|
||||||
Merkregel: **bevor ein zweites Modell derselben Rolle dazukommt, die Literal-Blöcke im Template
|
|
||||||
daraufhin lesen, welche Zeile eigentlich „Gerätetyp" meint und nicht „Rolle".**
|
|
||||||
- 🔵 **Container auf MikroTik — korrigiert 2026-08-25, die alte Pauschalregel war nie nachgemessen.**
|
|
||||||
Es hiess hier „hAP ax² hat 128 MB Flash, kein USB → Container NICHT auf dem Router". Live gelesen:
|
|
||||||
**ap1/ap2 sind `arm64` mit 95 MB FREIEM Flash und 690 MB freiem RAM** — das reicht für **einen**
|
|
||||||
kleinen Container (USB ist laut MikroTik-Doku nur für schnellere Extraktion, nicht Pflicht).
|
|
||||||
Faustregel: **~70 MB nutzbar je AP**, weil RouterOS Reserve für sein eigenes `.npk` braucht.
|
|
||||||
🔴 **`crs326-core` dagegen NIE:** `arm` **32-bit**, **2,9 MB frei von 16 MB**. Der Core ist der
|
|
||||||
Netz-SPOF, aber **kein Anwendungs-Host** — das ist eine Hardware-Tatsache, keine Politik.
|
|
||||||
🔑 **Und die TP16.1-Aussage „Connector als RouterOS-Container ❌ nicht möglich" gilt nur für den
|
|
||||||
WARP-Connector**, weil der ein **TUN-Device** braucht (RouterOS reicht keines durch).
|
|
||||||
**`cloudflared` braucht kein TUN** — Userspace-Proxy. Die Schlussfolgerung überträgt sich nicht.
|
|
||||||
Merkregel: **bei einer „strukturellen" Einschränkung erst prüfen, ob sie ein Default ist oder ein
|
|
||||||
Gesetz** — und ob sie dem konkreten Dienst überhaupt gilt.
|
|
||||||
- **Schwere Container laufen weiterhin auf NAS/pve**, nicht am Router (Caddy, AdGuard: die eine Config
|
|
||||||
ändert sich staendig, die andere schreibt dauernd Logs auf NAND).
|
|
||||||
- ⏱️ **Ein konstanter 10-Sekunden-Hänger ist ein DNS-Timeout, kein Lastproblem** (Fund 2026-09-01,
|
|
||||||
TP16.3-Randbefund). `gitea` hing bei ~10 % der Anfragen exakt 10,1 s — quer über Web-UI,
|
|
||||||
`/api/healthz` **und** den Git-Pfad, also kein Rendering. Ursache: **MariaDB (LXC 302) hatte
|
|
||||||
`skip-name-resolve` auskommentiert** und machte bei **jeder neuen Verbindung** einen
|
|
||||||
Reverse-Lookup, während ihr `/etc/resolv.conf` noch auf den toten `10.40.41.254` zeigte
|
|
||||||
(glibc: 5 s × 2 Versuche = 10 s). Pool-Verbindungen waren schnell ⇒ es wirkte sporadisch.
|
|
||||||
🔑 **Drei Merkregeln daraus:** (a) **immer gegen eine zweite Sache messen, die denselben Weg
|
|
||||||
nimmt** — `vw`/`hass` über dieselbe Strecke hatten 0 von 30 Hängern, das hat CF, Tunnel, Firewall
|
|
||||||
und Caddy in einem Schritt entlastet; (b) **wer an einer DB hängt, sagt die Verbindungstabelle**
|
|
||||||
(`ss -tn | grep 3306`), nicht die Vermutung — hier hingen **Vaultwarden, Home Assistant und gitea**
|
|
||||||
an derselben MariaDB, und die Annahme „Vaultwarden nutzt SQLite" war schlicht falsch;
|
|
||||||
(c) **ein toter Resolver ist selten allein** — der Sweep über alle LXCs fand **fünf** Container mit
|
|
||||||
`10.40.41.254`. Sweep: `for c in $(pct list|awk 'NR>1{print $1}'); do pct exec $c -- grep ^nameserver /etc/resolv.conf; done`
|
|
||||||
- 🔌 **`networking.service` failed bei funktionierendem Netz = tote `auto`-Stanzas in
|
|
||||||
`/etc/network/interfaces`** (Ursache gefunden + behoben 2026-09-01). In vier LXCs standen dort noch
|
|
||||||
Einträge aus dem **alten VLAN1-Schema** (`eth0` → `10.40.41.x`) und aus früheren Versuchen
|
|
||||||
(`veth16`, `veth24`, Tippfehler `eth01`) — auf Interfaces, die es im Container gar nicht gibt.
|
|
||||||
`ifup -a` scheitert daran, das echte `veth20` kommt trotzdem hoch. **Fix:** nur die Stanzas
|
|
||||||
entfernen, deren Interface in `ip -br link` fehlt (`lo` und die echten behalten), Backup daneben.
|
|
||||||
🔴 **Abnahme ist ein echter Reboot, nicht `systemctl is-active`** — der Servicestatus sagt nichts
|
|
||||||
darüber, ob beim nächsten Start etwas hochkommt.
|
|
||||||
- 🧪 **Ein Messwerkzeug, das etwas anderes beantwortet als die Frage, kostet mehr Zeit als der Fehler**
|
|
||||||
(zweimal in einer Nacht, 2026-09-01). (a) **`nc -z` lieferte falsch-negative** „Port zu" für neun
|
|
||||||
Dienste gleichzeitig — inklusive des SSH-Ports, über den die Session gerade arbeitete; `curl` bekam
|
|
||||||
von demselben Host **HTTP 200 in 24 ms**. **Bei „alles gleichzeitig kaputt" zuerst das eigene
|
|
||||||
Werkzeug verdächtigen**, dann das Netz: ein Python-Socket oder `curl` entscheidet in Sekunden.
|
|
||||||
(b) **Ein Firewall-Zähler taugt nicht als Wegbeweis**, wenn der Client die Verbindung offenhält
|
|
||||||
(`cloudflared`): er zählt nur NEUE Verbindungen und steht still, obwohl alles läuft — richtig ist
|
|
||||||
die **Verbindungstabelle** (`/ip firewall connection`).
|
|
||||||
- 🔌 **`networking.service` kann in einem LXC dauerhaft `failed` sein, ohne dass das Netz fehlt** —
|
|
||||||
und dann startet alles mit `After=network-online.target` nach einem Reboot **nicht**
|
|
||||||
(Fund 2026-09-01: `101`, `102`, `106`, `107`; **influxdb2 kam nach dem Neustart nicht hoch**,
|
|
||||||
Unit `enabled`, **kein Startversuch im Journal**). Fällt nur auf, wenn man neu startet — die
|
|
||||||
Container liefen 13+ Tage. **Vor jedem geplanten LXC-Neustart `systemctl --failed` lesen und
|
|
||||||
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
|
|
||||||
- **VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a):** NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert (`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle: DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der *Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber `TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
|
|
||||||
- 📜 **~~ALLNET VLAN-DB (TP1)~~ — historisch.** Die VLAN-Semantik gilt weiter (**40=MEDIA, 50=UNTRUSTED**), die Portangaben (GE1/GE2/GE8) beziehen sich auf ein ausgebautes Gerät.
|
|
||||||
- **ap2 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5).
|
|
||||||
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko).
|
|
||||||
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅ 2026-08-17).** Der Entscheid „kein CAPsMAN" von 2026-07-22 ist **revidiert**: er galt der Konstellation ap1=Manager+AP, in der ein Manager seine eigenen Radios nicht provisionieren kann — mit dem Core als Manager sind beide APs symmetrisch. Der Core braucht dafür **kein** `wifi-qcom`, `capsman` steckt im Basispaket.
|
|
||||||
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät
|
|
||||||
antwortet `failure: not allowed (/interface/wifi/scan)`; das ist **weder device-mode noch eine
|
|
||||||
Policy-Lücke** (Gegentest: `/tool fetch` sagt auf demselben Gerät explizit „not allowed **by
|
|
||||||
device-mode**", und die Gruppe `full` hat `test`+`sniff`). Ein CAP besitzt sein Radio nicht.
|
|
||||||
Richtig ist `/interface wifi scan cap-wifiN duration=…` **am Core** — dort läuft es einwandfrei.
|
|
||||||
⚠️ Die `cap-wifiN`-Zuordnung vorher über `radio-mac` bestimmen, sie ist nicht stabil.
|
|
||||||
- 🔴 **Messfalle: unter CAPsMAN führt der MANAGER die Tabellen.** `/interface/wifi/registration-table` und die `wireless`-Logs sind auf ap1/ap2 **leer**, obwohl das WLAN läuft. **Immer am Manager zählen.**
|
|
||||||
- 🔴 **Die CAPs finden den Manager per Layer-2-Discovery** (`discovery-interfaces=vlan99`, `current-caps-man-address` ist eine **MAC**, keine IP). Deshalb müssen **MNDP (udp/5678)** und **CAPWAP (udp/5246-5247)** in der `input`-Kette des Core erlaubt bleiben — sonst kommt das WLAN nach dem nächsten AP-Reboot nicht zurück, während laufende Sessions unauffällig weiterlaufen (Fund 2026-08-18, TP5 Etappe 4).
|
|
||||||
- 🔁 **Slave-VAPs aendern heisst zweimal arbeiten (2026-08-22).** `slave-configurations` in einer
|
|
||||||
`provisioning`-Zeile zu setzen bewirkt am Funk **nichts**, solange nicht
|
|
||||||
`/interface wifi capsman remote-cap provision [find where identity="apX"]` nachgezogen wird —
|
|
||||||
und das gilt in **beide** Richtungen: auch beim Entfernen senden die VAPs sonst munter weiter.
|
|
||||||
🔴 **Und die Abnahme nicht am Kommentar festmachen:** beim Rueckbau wird der Interface-Kommentar
|
|
||||||
geleert, waehrend der VAP steht. Wer auf `comment~"ssid"` prueft, sieht "weg" und liegt falsch.
|
|
||||||
Richtig ist `datapath.vlan-id` bzw. die Interface-Zahl **am CAP** (`/interface wifi print` dort).
|
|
||||||
Beim Interface-*Zustand* ist ueberhaupt der CAP die Wahrheit: der Manager zeigt nach einer
|
|
||||||
Re-Provisionierung `running=false` fuer alle `cap-*`, waehrend am CAP alles laeuft — das
|
|
||||||
Gegenstueck zur Regel, dass die *Registration-Table* am Manager zu lesen ist.
|
|
||||||
- **Local Forwarding ist keine Einstellung mehr:** im neuen `/interface/wifi` gibt es kein zentrales Forwarding, `datapath` existiert dort nicht.
|
|
||||||
- 🔴🔴 **`/interface wifi cap slaves-datapath` MUSS auf beiden APs gesetzt sein** (`dp-caps-slaves`, ein lokales datapath-Objekt mit `bridge=bridge` und **ohne** `vlan-id`). Es ist das Einzige, was die vom Manager erzeugten **Slave-VAPs** lokal in die Bridge haengt: der Manager schickt `datapath.bridge=bridge` mit, **der CAP uebernimmt davon nur die `vlan-id`**. Fehlt es, assoziieren Clients auf `-iot`/`-media`/`-untrusted`/`-guest` sauber und bekommen **nie DHCP**, waehrend `dirtyvader-main` weiterlaeuft — dessen Master-Radios `wifi1`/`wifi2` sind statische Bridge-Ports. Genau so lagen IOT+MEDIA am 2026-08-18 rund 14 Stunden (Ursache: CAPsMAN-Cutover 08-17, nicht die TP5-Haertung). **Abnahme fuer jeden WLAN-Umbau ist eine frische DHCP-Lease pro SSID — NICHT die Registration-Table**, die zeigte waehrend des Ausfalls 25 zufriedene IOT-Clients.
|
|
||||||
- 🔎 **Merkregel „connect ok, kein DHCP":** zuerst `torch` auf dem betroffenen VLAN am Core UND den TP5-Drop-Log lesen. Sind **beide leer**, wird nichts verworfen — es kommt nichts an, und die Firewall ist entlastet. Dann liegt es an L2 (Bridge-Mitgliedschaft/Tagging), nicht am Regelwerk.
|
|
||||||
- mgmt-VLAN-Wechsel am Switch = Lockout-Risiko: VLAN muss existieren + Port muss es tragen, in einem Schritt.
|
|
||||||
- 🔥 **Die Firewall ist seit 2026-08-18 deny-by-default — in BEIDEN Ketten.** Ein neuer Dienst über eine VLAN-Grenze braucht ab jetzt eine explizite Regel, sonst funktioniert er einfach nicht. Diagnosewerkzeug ist die **Log-Regel direkt vor dem Drop** (`TP5-WOULD-DROP` / `TP5-INPUT-WOULD-DROP`), die weiterläuft und in den Kollektor `10.40.20.18:514` schreibt (Dateien auf dem Docker-LXC unter `/opt/tp5-syslog/log/`). **Erst dort nachsehen, bevor irgendwo anders gesucht wird.** **MAIN→INFRA/MEDIA/MGMT/IOT stehen dauerhaft offen** (IOT seit TP5 E5, 2026-08-19 — Drucker + Shellys); **IOT→MAIN bleibt bewusst zu**, dort sitzt der Wert der Segmentierung. Ad-hoc-Zugriff aus MAIN in ein **noch geschlossenes** VLAN (UNTRUSTED, GUEST) läuft über die selbstverfallende Liste: `/ip firewall address-list add list=jit-admin address=<deine-IP> timeout=1h`. *(Die früher hier genannten Beispiele „Scannen, IoT anlernen" brauchen jit-admin seit E5 nicht mehr.)*
|
|
||||||
- 🖨️ **Merkregel aus dem E5-Drop-Log:** der Drucker fällt mit **udp/161 (SNMP)** auf, nicht mit 631 — macOS fragt zuerst den SNMP-Status ab und bricht ab, bevor es zu IPP kommt. **Eine Whitelist auf 631 hätte nichts repariert.** Bei „Gerät antwortet nicht" also das Log lesen, statt den erwarteten Port zu raten.
|
|
||||||
- ⏰ **Zeitquelle ist der Core** (`/system ntp server`, seit 2026-08-18), ausgeliefert per **DHCP-Option 42** (`ntp-server=` in jedem der 7 Netze) und drift-überwacht (ComplianceRule `system-ntp`). Vorher lief **nirgends** eine Synchronisation: die anderen drei Geräte zeigten auf `10.40.99.1`, wo kein Server lief, und der Core selbst hatte gar keinen NTP-Client — ap2 war dadurch **17 Stunden** versetzt. 🔴 **Zwei Fallen dabei:** (a) ein NTP-Server mit `use-local-clock=no` und unsynchronisiertem Client liefert **Stratum 16**, das jeder Client verwirft — Server einschalten allein reicht nicht; (b) wird der Server eingeschaltet, *bevor* sein Client synchron ist, hält er diesen Zustand fest → **`enabled=no`/`=yes` nach der Synchronisation**. Merkregel: bei widersprüchlichen Zeitstempeln zwischen Geräten zuerst `/system ntp client get status` lesen, und zum Eingrenzen einen Client testweise auf eine öffentliche Quelle zeigen lassen.
|
|
||||||
- 📡 **SSDP-Relay für die macOS-Sonos-App:** LXC **108 `mcast-relay`** auf pve, zweibeinig `10.40.10.21` (VLAN10) + `10.40.40.21` (VLAN40), `multicast-relay` mit **`--noMDNS`** (mDNS macht der Core). Unit im Repo: **`mcast-relay/multicast-relay.service`**. **Zwei Beine sind zwingend** — SSDP ist link-lokal mit TTL 1, ein Relay muss Mitglied beider Broadcast-Domänen sein; Routing hilft nicht.
|
|
||||||
- 🔴 **`--ssdpUnicastAddr` gehört in ein zweibeiniges Relay NICHT — die Flagge hat den Hinweg 7 Wochen lang blockiert** (drin 2026-08-18, ausgebaut **2026-08-25**). Sie schreibt in `multicast-relay.py` **Z. 630** die Quelle *jedes* SSDP-Pakets an `239.255.255.250:1900` auf `<addr>:1901` um (**auch NOTIFY**); beim Senden greift dann **Z. 691** `if srcAddr == ssdpUnicastAddr and not onNetwork(srcAddr, tx['addr'], tx['netmask']): continue` — `10.40.10.21` liegt nicht im VLAN40-Netz, also **verwarf der Relay die M-SEARCH selbst, bevor sie die Player erreichte**. Der Rückweg gehört stattdessen **geroutet**: `forward TP5: MEDIA->MAIN` ist offen, die Player antworten unicast an den echten Sucher. (Upstream nennt den Rückweg selbst `# FIXME: record more than one?` — es gibt nur **einen globalen** Merkslot.)
|
|
||||||
- 🔎 **Symptom-Merkregel:** *App sucht hunderte Male, sieht NOTIFYs, baut aber nie eine Verbindung auf* = **Relay, nicht Netz**. Diagnose in einer Zeile — **Richtungsbilanz** im Journal: `grep -c "via eth1"` (VLAN10→VLAN40) gegen `grep -c "via eth0"`. **0 zu n heißt: der Hinweg fehlt.**
|
|
||||||
- ⚠️ **Aus dem Relay heraus lässt sich der Relay nicht testen** (Loop-Schutz ignoriert eigene Quelladressen) — am echten Client testen oder `journalctl -u multicast-relay` mitlesen. **Und am Client nicht auf Port 1900 messen:** Sonos-App *und* Spotify halten dort je ein `SO_REUSEPORT`-Socket, die Unicast-Antwort bekommt nur **eines** davon. Richtig ist eine Probe mit **ephemerem Quellport** (`bind((ifaddr, 0))`), genau wie ein echter Client sucht.
|
|
||||||
- 🔴 **Zweite, unabhängige Ursache desselben Falls (08-25): die Sonos-App suchte gar nicht.** Nach der Relay-Reparatur lag weiter nichts an — der Mitschnitt auf der VLAN40-Leitung (`pct exec 108 -- tcpdump -i eth1 -n -s0 -A "udp and host <client>"`) zeigte: **jedes** SSDP-Paket des MacBooks kam von **Chrome** (`ST: urn:dial-multiscreen-org:…dial:1`, `USER-AGENT: Chromium`), **kein einziges** von Sonos. Die App hatte Sockets offen (`*:1900`, `*:6969`, `TCP *:3400`) und redete mit der Cloud, legte aber nichts auf die Leitung. Ursache war das **Local-Network-Gate von macOS**: es stand auf **„an" und griff trotzdem nicht**. Fix = Schalter unter *Datenschutz & Sicherheit → Lokales Netzwerk* **aus, App beenden, wieder an, App starten** (hart: `sudo tccutil reset LocalNetwork com.sonos.macController2`).
|
|
||||||
⇒ **Merkregel: „die App sucht hunderte Male" ist erst dann ein Befund, wenn der Mitschnitt sagt, WER sucht.** Im Handoff vom Vormittag waren die gezählten 291 Suchen Chrome + eigene Proben; die Zahl hat drei Stunden in die falsche Richtung gezeigt.
|
|
||||||
- 🖥️ **Das MacBook ist dual-homed in VLAN10:** `en5` = **10.40.10.183** (USB-Dock, Default-Route) und `en0` = **10.40.10.82** (WLAN). Die früher als „zweiter Mac" gelesene `.183` ist **dasselbe Gerät** — beim Messen immer beide Beine prüfen.
|
|
||||||
- 🏠 **Home Assistant: der `http:`-Block in `configuration.yaml` ist WIRKUNGSLOS** (Fund 2026-08-26,
|
|
||||||
TP31). HA hat die http-Config nach **`.storage/http`** migriert (`"yaml_migration_done": true`);
|
|
||||||
seither wird die YAML kommentarlos ignoriert — selbst ein ungueltiger Schluessel darin erzeugt
|
|
||||||
**keinen** Config-Fehler. 🔴 **Die Logzeile fuehrt aktiv in die Irre:** `Received X-Forwarded-For
|
|
||||||
header from an untrusted proxy <ip>` faellt laut Quelltext nur bei **aktivem**
|
|
||||||
`use_x_forwarded_for` — man haelt die Option fuer wirksam und die Liste fuer falsch und editiert
|
|
||||||
die falsche Datei. Richtig ist der WS-Zweischritt **`http/config/configure`** (setzt `pending`,
|
|
||||||
startet HA neu) → binnen **5 Minuten** **`http/config/promote`**, sonst Auto-Revert auf `stable`.
|
|
||||||
Merkregel zur Eingrenzung: ein Testskript in `<config>/python_scripts/` + `python_script.reload` —
|
|
||||||
taucht der Service auf, liest HA das Verzeichnis, und die Frage ist nicht mehr *welche Datei*,
|
|
||||||
sondern *warum ein gelesener Block nicht wirkt*.
|
|
||||||
- 🐳 **Nautobot auf der DS923 braucht einen abgeschalteten Image-Healthcheck.** Der eingebackene Check ruft alle 10 s `nautobot-server health_check` = einen vollen Django-Start; unter Last dauert der >2 min, die Aufrufe **stapeln sich unbegrenzt** → Speicher alle → `fork()` mit ENOMEM → DSM räumt den ganzen Stack ab (am 2026-08-17 zweimal passiert, Load bis 73 bei 4 Kernen). Im Stack stehen deshalb `healthcheck: disable: true` auf worker+scheduler, `--concurrency=2` und `mem_limit`. **`cpus:` geht auf Synology nicht** (Kernel ohne CFS-Quota, der Stack-Deploy schlägt sonst fehl).
|
|
||||||
- 📜 **~~Omada ES208G = Easy Managed~~ — gegenstandslos** (Gerät abgebaut 2026-07-23, Controller gelöscht).
|
|
||||||
- **L3-Router = `crs326-core` (TP21.1 ✅ 2026-08-08, ersetzt ap1).** Bridge vlan-filtering=yes; VLAN-SVIs `.1` je VLAN mit DHCP (7 Server / 54 Leases); DNS über **DoH** zum CF-Gateway, `verify-doh-cert=yes` (TP23 Stufe 1 ✅ 2026-08-18). Die alte Notiz „core hat kein CA" war zu pessimistisch: es braucht kein Bundle, sondern **genau einen Root** (GTS Root R4, ~1 KB) — die Kette ist Leaf → GTS WE1 → R4. **Beide Firewall-Ketten enden seit 2026-08-18 mit `drop`** (TP5 E3/E4); `TP10-seal-lan` ist `disabled`, Mgmt-Input kommt nur noch aus VLAN10, von HA (`8728`), von Nautobot (`22`) und über `cf-mesh`. **ap1/ap2 sind NUR noch APs** — kein DHCP/DNS/NAT/Routing/WG dort. (Die frühere „TP4: ap1 ist L3-Router"-Zeile ist überholt.)
|
|
||||||
- **✅✅ Edge-Cutover (2026-07-01; Router-Umzug 2026-08-08): FritzBox ausgebaut, Arris im Bridge-Mode.** Liwest hat die FB6660 dauerhaft gegen ein Arris getauscht (physisch weg). WAN terminiert auf **`crs326-core vlan100`** (VLAN100-Transfer), core zieht die öffentliche Liwest-IP `90.146.33.177` → kein Double-NAT. **core = Public-Edge + WG-Hub** (fm/dw live), NICHT mehr ap1. Telefonie nicht weitergeführt. Runbook `routeros/wan-cutover-runbook.md` (ap1-Teile historisch).
|
|
||||||
- **hAP `device-mode=home` (ap1/ap2): scheduler + fetch blockiert** (`not allowed by device-mode`) → AP-Provisionierung idempotent **ohne** Scheduler. **`[/ping]` gibt nil in 7.23**. REST je AP `http://10.40.99.11|12/rest` (Creds = `ROUTER_AP{1,2}` in `~/.claude.json`). **Der L3-Router crs326-core ist KEIN device-mode=home** → dort gehen scheduler/fetch (z.B. DDNS-Updater).
|
|
||||||
- **Erstzugriff Werks-hAP am CRS-Fabric:** Uplink `ether1` = Werks-WAN → `192.168.88.1` (LAN) darüber NICHT erreichbar. Weg: Gerät auf **LAN-Port (ether2)** patchen ODER **Core-Temp-Pfad** `192.168.88.2/24` auf core-`bridge` + srcnat masq `192.168.88.0/24`, dann scp+`/import` (danach Temp-Pfad + stale ARP `.88.1` entfernen). Winbox-Neighbors zeigt die APs NICHT (MNDP pro-VLAN, Mgmt auf vlan99) — kein L2-Fehler.
|
|
||||||
- 🖨️ **ZWEI Drucker, nicht verwechseln (geklärt 2026-08-23):** der **Brother MFC** ist der
|
|
||||||
*eigentliche* Drucker und hängt auf **VLAN10** (`10.40.10.6`, MAC `30:05:5C:78:86:20`, **statisch**,
|
|
||||||
keine DHCP-Reservierung) — er steht damit im *selben* VLAN wie die Macs, die `forward`-Kette greift
|
|
||||||
dort gar nicht. Der **P750W ist der Etikettendrucker** auf **VLAN30** (`10.40.30.5`) — nur für ihn
|
|
||||||
gilt der VLAN-übergreifende SNMP-Fall unten.
|
|
||||||
- **P750W (Brother, VLAN30) läuft auf `dirtyvader-iot`** (User-bestätigt 2026-08-08) — **kein** Access-List-Pin nötig; die alte „nur am co-located L3-GW-AP erreichbar"-Logik ist mit L3-GW auf dem Core hinfällig. Coverage-Kanten/Terrasse grenzwertig (−76..−89 dBm); TX am AT-Max (18/15 dBm); echter Fix = Platzierung. SSoT `docs/wifi-p750w-coverage-and-map.md`. ⚠️ **Power-Save ist KEIN Thema dieses Netzes** — die früher hier stehende Merkregel „DHCP-geht-aber-Ping-nicht = ARP/Power-Save" war eine Fehldiagnose (2026-08-13 korrigiert: der Drucker war schlicht ausgeschaltet, das Ping-Symptom war WARP, s.u.). Nicht wieder einführen.
|
|
||||||
- **Ping auf JEDE lokale IP tot, aber Web/App/Print (TCP) geht überall = WARP-Client lokal versehentlich noch aktiv** (2026-08-13 live beobachtet, BF vor Ort): `cloudflared`/WARP ist QUIC-Proxy, kann **kein ICMP**, reicht TCP aber durch — auch wenn man physisch im BF-LAN steht, nimmt der Client bei aktivem WARP trotzdem den Tunnel-Umweg. **Fix: WARP trennen**, kein Router-Eingriff nötig. **Vor jeder Firewall-/Mangle-/VLAN-Suche zuerst prüfen**, wenn das Symptom „Ping auf ALLES tot, TCP geht" lautet — das ist ein Client-, kein Netzproblem. Strukturell schließt **TP16.1** (natives WireGuard-CF-Mesh auf `crs326-core` statt cloudflared-QUIC) genau diese ICMP-Lücke. Memory `ref_warp_local_icmp_trap`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -346,43 +177,22 @@ Verhaltensregeln für nachfolgende AI-Sessions — kleiner, klarer, weniger Drif
|
|||||||
- **Eine große Fragenrunde statt vieler kleiner.** Wenn der Plan von Annahmen abhängt, sammle die Annahmen in **einer** Runde, nicht in 3–4 Mikrorunden.
|
- **Eine große Fragenrunde statt vieler kleiner.** Wenn der Plan von Annahmen abhängt, sammle die Annahmen in **einer** Runde, nicht in 3–4 Mikrorunden.
|
||||||
- **Master-Frage früh stellen.** Bei jedem Repo, jeder Datei, jedem Tooling: „Was ist die SSoT, wo lebt der Master, was ist Mirror?" — **bevor** du eine Lösung vorschlägst.
|
- **Master-Frage früh stellen.** Bei jedem Repo, jeder Datei, jedem Tooling: „Was ist die SSoT, wo lebt der Master, was ist Mirror?" — **bevor** du eine Lösung vorschlägst.
|
||||||
- **Drift zuerst, Lösungen später.** Drift-Tabelle posten + fragen, was richtig ist. Keine Lösungen vor dem Befund.
|
- **Drift zuerst, Lösungen später.** Drift-Tabelle posten + fragen, was richtig ist. Keine Lösungen vor dem Befund.
|
||||||
- **MCP-Erstellung/-Stilllegung = aktiv melden** (2026-08-27, Christoph-Direktive). Wer einen neuen MCP anlegt, ändert oder abschaltet — **meldet das proaktiv** an den User, nicht stillschweigend im Commit. Begründung: jede AI-Session ist gleichzeitig **Oberhand über Container** (MCP-Hosts) und **Garbage-Collector** auf Infra-Ebene — wenn Drift unbemerkt entsteht, sammelt sich Müll.
|
- **MCP-Erstellung/-Stilllegung = aktiv melden** (2026-08-27, Christoph-Direktive). Wer einen neuen MCP anlegt, ändert oder abschaltet — **meldet das proaktiv** an den User, nicht stillschweigend im Commit. Jede AI-Session ist zugleich **Oberhand über Container** und **Garbage-Collector** auf Infra-Ebene.
|
||||||
- **MCPs brauchen Host-Verifikation** vor Edit. Wenn der MCP verändert/editiert wird: vorher per Live-Read (nc/ps/curl) prüfen, ob der Host noch antwortet. Beispiel: 2026-08-27 — `shelly-mcp` zeigte auf `10.40.20.30:3030`, aber kein Host antwortet. Konfig auf `enabled=false`, dokumentieren statt hart fehlschlagen zu lassen. **(Stand 2026-08-28: shelly-mcp wird auf DS923 `10.40.20.180` deployt — `BUILD.md` liegt bereit.)**
|
- **MCPs brauchen Host-Verifikation** vor Edit: per Live-Read (nc/ps/curl) prüfen, ob der Host noch antwortet. Beispiel: `shelly-mcp` zeigte auf `10.40.20.30:3030`, kein Host antwortet → `enabled=false`, dokumentieren. (Stand 2026-08-28: shelly-mcp auf DS923 `10.40.20.180` deployt.)
|
||||||
- **Mac mini ist n8n-Host, nicht zentraler MCP-Host** (User-Direktive 2026-08-28): MCP-Dienste gehören auf die **DS923** (`nas-bf`, Portainer Endpoint 14) — nicht auf die User-Workstation. Aktuell laufen gitea-mcp + n8n-mcp **noch** auf dem macmini (historisch), gehören **bei Gelegenheit** auf die DS923 umgezogen.
|
- **Tote MCP-Skripte → ins Projekt-Repo, nicht global.** Projekt-spezifische MCPs (`cannaleo`, `terpene-pg`) → `~/dev/<projekt>/mcp.json` + Skript im Projekt-Repo. Globale `~/.claude.json`/`opencode.jsonc` = nur projekt-übergreifende Tools.
|
||||||
- **Tote MCP-Skripte → ins Projekt-Repo, nicht global.** Wenn ein MCP nur für ein Projekt relevant ist (`cannaleo` für terpene, `terpene-pg` für terpene): gehört in `~/dev/<projekt>/mcp.json` + Skript im Projekt-Repo. Globale `~/.claude.json`/`~/.config/opencode/opencode.jsonc` = nur projekt-übergreifende Tools.
|
- **opencode-Skills: Default-Pfad kopieren, NICHT symlinken** (TP35 Schicht 6). Claude-Skill `~/.claude/skills/<name>/SKILL.md` ist die Quelle; für opencode `cp` nach `~/.config/opencode/skills/<name>/SKILL.md` (kein `skills.paths`-Eintrag nötig). Symlink verletzt Christophs „opencode = eigene Pfade"-Direktive. Lehre: opencode-Verhalten nicht raten, Doku lesen.
|
||||||
- **opencode-Skills: Default-Pfad kopieren, NICHT symlinken** (TP35 Schicht 6, 2026-08-28). Claude-Skill in `~/.claude/skills/<name>/SKILL.md` ist die Quelle; für opencode gehört er nach `~/.config/opencode/skills/<name>/SKILL.md` (`cp`, nicht `ln -s`). Begründung: opencode scannt diesen Pfad als Default — **kein** `skills.paths`-Eintrag in `~/.config/opencode/opencode.jsonc` nötig, das wäre redundant. Symlink wurde in der Inventur-Session testweise angelegt, hat Christophs "opencode = eigene Pfade"-Direktive verletzt und wurde wieder entfernt. Schema-Referenz: <https://opencode.ai/config.json> → `Config.skills.paths`. **Lehre aus dem Fall:** opencode-CLI-Verhalten nicht raten, Doku lesen — und ein "load-bearing"-Detail (Default-Scan-Pfad) zuerst verifizieren, bevor man ihn umgeht.
|
- **Skills sind IMMER global, niemals pro Projekt** (Christoph-Direktive). Nur `~/.claude/skills/` und `~/.config/opencode/skills/`. Kein Repo-Override. Wer überschreiben will, editiert die globale Datei oder schreibt einen zweiten Skill.
|
||||||
- **Skills sind IMMER global, niemals pro Projekt** (Christoph-Direktive 2026-08-28). `projektstatus` und alle übrigen Skills liegen **ausschließlich** unter `~/.claude/skills/` und `~/.config/opencode/skills/`. **Kein** Repo-lokaler Skill unter `.opencode/skill/` oder `.claude/skill/`. Begründung: keine Redundanz, keine Drift zwischen Skill-Versionen, eine Quelle pro Skill. **Merkregel:** wer einen Skill „überschreiben" will, editiert **die globale Datei** oder schreibt einen zweiten Skill mit anderem Namen — niemals ein Repo-Override.
|
- **Skills sind seit 2026-09-25 versioniert** (Befund: sie waren reiner Home-Ordner-State, kein Git, kein Backup — bei 22 Skills über mehrere Monate gewachsen). Master = Gitea **`admin/claude-skills`** (privat), `~/.claude/skills/` ist dessen **Arbeitsbaum**. **Ablauf ab jetzt: editieren → committen → pushen → `cp -R ~/.claude/skills/<name> ~/.config/opencode/skills/<name>`.** ⚠️ Der `cp`-Schritt ist der, den man vergisst — genau so entsteht stille Drift zwischen den beiden Kopien. Secret-Gebot gilt auch hier: keine Tokens/Keys in Skills, Platzhalter (`REPLACE_WITH_…`) sind okay.
|
||||||
- **Kommentar in Code ≠ Verhalten.** `mcp/shelly/.env` trug den Kommentar "gitignored, NICHT committen" — es gab aber **kein** `mcp/shelly/.gitignore`. Der Kommentar war Wunsch, nicht Wirklichkeit. `routeros/cloudflared/credentials.json` (Cloudflare-Tunnel-PrivateKey base64) lag ungegittoret im Working Tree. **Merkregel:** Behauptungen in Kommentaren/Konfig-Kommentaren durch `git check-ignore -v <datei>` (oder `git status`) verifizieren, bevor man darauf vertraut. **Lessons-Block erweitert 2026-08-28 nach `commit`-Session.**
|
- **Projekt-Memories sind seit 2026-10-06 versioniert** — dasselbe Muster wie die Skills, aus demselben Befund (reiner Home-Ordner-State). Master = Gitea **`admin/claude-memories`** (privat), Arbeitsbaum **`~/dev/claude-memories/`**. Ein Ordner je Projekt, Name = Projektpfad ohne das Präfix `-Users-christophemsenhuber-`; **führender Unterstrich = projektübergreifend** (`_dev` = `~/dev`, `_home` = `~`). Anbindung per **Symlink**, nicht Kopie: `~/.claude/projects/<kodiertes-projekt>/memory/` → `~/dev/claude-memories/<projekt>/` (eine zweite Kopie wäre genau die Drift, die das Repo verhindern soll). **Ablauf: editieren → `MEMORY.md`-Zeile ergänzen → committen → pushen.**
|
||||||
- **opencode-TUI: führendes Leerzeichen vor `/command` manchmal nötig** (Beobachtung 2026-08-28, Skill „projektstatus" getestet; **identifiziert als bekannter Bug**). Symptom: Tippen von `/projektstatus` führt nicht zum Skill, ` /projektstatus` (führendes Leerzeichen) schon. Doku <https://opencode.ai/docs/commands> zeigt den Aufruf ohne Space. **Verifiziert:** Issue **anomalyco/opencode#27528** „ACP: prompt starting with '/' is silently dropped if command name is not recognized" — Status **Not planned (skipped)**, closed 2026-07-14. Maintainer haben es also gesehen und nicht gefixt. **Konsequenz:** Workaround (führendes Leerzeichen) ist der offizielle Pfad; Skill selbst ist nicht kaputt. Vor jedem neuen opencode-Build prüfen, ob das Verhalten weg ist.
|
- 🔴 **`name:` muss dem Dateinamen entsprechen** — die `[[wikilinks]]` adressieren den Slug, nicht den Pfad. Laufen beide auseinander, brechen Verweise **still** (kein Fehler, kein Hinweis). Belegt am 2026-10-06: 12 Links zeigten ins Leere, weil 8 Dateien einen abweichenden `name:` trugen.
|
||||||
- **Manuell gepflegte Doku mit Live-Daten verliert gegen die Realität, wenn sie lang wird.** Befund 2026-08-28: der „Nächster Schritt (2026-08-26): TP31 Schritt 5"-Satz stand zwei Tage nach seiner Formulierung noch im Ist-Stand-Block, obwohl ich in derselben Zeit drei Folge-Sessions eröffnet hatte. **Merkregel pro Edit:** drei Stellen berücksichtigen — Quick-Index (Tabelle), TP-Abschnitt, Ist-Stand-Block. Keine schlägt die andere; wer eine ändert, **muss** die anderen mitlesen. Strukturelle Antwort = TP36 (SSoT via Nautobot, Datei wird read-only Snapshot) — eigene Session, nicht nebenbei. **Lessons-Block erweitert 2026-08-28 nach Skill-Verifikation in opencode.**
|
- 🔴 **`MEMORY.md` ist ein Index, kein Log** — nur das Index-File wird in jede Session des Projekts geladen, die Einzeldateien nicht. Statusberichte/Chronologie gehören in eine eigene, nicht geladene Datei (Muster: `bf-network-r6/CHRONIK.md`). 67 Log-Zeilen hatten den r6-Index auf 16,4 KB getrieben — mehr als alle anderen elf Indizes zusammen.
|
||||||
- **`sed 's/=.*/=<set>/'` maskiert NICHT, wenn das `.*` greedy ist und der Wert Slashes/Backslashes enthält** (Befund 2026-08-28, shelly-mcp-Deploy). Ich habe `sed 's/SHELLY_DEVICE_PW=.*/<set>/'` auf die Stack-YML angewendet — der **Wert selbst** wurde in den Output geschrieben, weil mein Replacement-String nicht greedy genug war und ich die Pipe nicht mit `cat -v` oder `head -15 | sed 's|=.*|=<set>|'` abgesichert hatte. Folge: **`SHELLY_DEVICE_PW` ist jetzt als verbrannt zu betrachten** — wandert in **TP30 Posten #3** mit Hochstufung. **Merkregel:** bei jeder Ausgabe von Secret-haltigen Dateien entweder `head -c <n>` (Abbruch nach Bytes), oder die Datei vor dem Read mit `tr -d '\n' | sed 's|=.*|=<set>|'` über einen Stream-Filter schicken — **niemals** einfach `cat` und auf einen Replacement-Pattern vertrauen. Vergleichbar zum PSK-Leak 2026-08-24.
|
- Keine Secrets in Memories — sie werden in jede Session geladen und liegen versioniert. Platzhalter sind okay.
|
||||||
|
- **Kommentar in Code ≠ Verhalten.** `mcp/shelly/.env` trug „gitignored", es gab aber kein `.gitignore`. Behauptungen in Kommentaren per `git check-ignore -v <datei>` verifizieren, bevor man darauf vertraut.
|
||||||
- **Vor jeder Drift-Bewertung `python3 nautobot/gc-transaktion.py status` lesen** (Befund 2026-08-31,
|
- **opencode-TUI: führendes Leerzeichen vor `/command` manchmal nötig** (bekannter Bug, anomalyco/opencode#27528, „Not planned"). Workaround (führendes Leerzeichen) ist der offizielle Pfad.
|
||||||
zweimal am selben Tag gestolpert). **Drift aus einem laufenden Versuch sieht identisch aus wie ein
|
- **Manuell gepflegte Doku verliert gegen die Realität, wenn sie lang wird.** Wer eine Stelle ändert, muss die anderen mitlesen (Quick-Index, TP-Abschnitt, Ist-Stand). Strukturelle Antwort = TP36 (SSoT via Nautobot).
|
||||||
Versehen** — die Compliance-Zahl unterscheidet nicht. Konkret: `crs326-core`/`wifi-security`
|
- **Maskierungs-Muster: auf den SCHLÜSSELNAMEN ankern, nie auf ein Trennzeichen.** Sicher ist nur `sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'` — greedy `sed 's/=.*/…/'` schneidet Slashes/Backslashes nicht. **Werte nie suchen, um sie zu sehen** — den Schlüssel per `sed -n 's/^…KEY=//p'` direkt in eine Variable holen. Drei Leaks dieser Klasse: PSK 2026-08-24, `SHELLY_DEVICE_PW` 2026-08-28, Nautobot-Tokens 2026-08-31.
|
||||||
(`ft-over-ds=no`) wurde dem 7.24.1-Upgrade zugeschrieben und als „braucht eine Entscheidung"
|
- **Vor jeder Drift-Bewertung `python3 nautobot/gc-transaktion.py status` lesen** (Befund 2026-08-31, zweimal gestolpert). **Drift aus einem laufenden Versuch sieht identisch aus wie ein Versehen** — die Compliance-Zahl unterscheidet nicht. 🔴 **Eine solche Drift grün zu machen macht den Versuch unsichtbar.** Was dauerhaft gelten soll, gehört ins **Soll/Template**, nicht in eine Transaktion. ⚠️ Das Skript findet den Token aus einem Worktree heraus nicht (`210-nautobot.yml` nur im Haupt-Checkout).
|
||||||
weitergereicht; tatsächlich ist es **TP25 Fund E Hebel 1**, um 08:09 bewusst gesetzt, während der
|
- **Vor jedem Flash-Budget auf MikroTik `docker export | wc -c` messen, nicht `docker image inspect .Size`** (Befund 2026-08-31, TP16.3). `.Size` meldete für `cloudflared` 27,5 MB, entpackt **69,5 MiB**. 🔴 **`docker save` schreibt seit Docker 25 OCI statt docker-v1** → RouterOS meldet `no config found in manifest`. Beides in `docs/tp16.3-bf-public-ap-connector-runbook.md` §2.2.
|
||||||
Core erst um 14:36 gehoben wurde, geführt als offene Transaktion `TP25-fund-e-ft-over-ds`.
|
|
||||||
🔴 **Eine solche Drift grün zu machen macht den Versuch unsichtbar.** Umgekehrt gilt: was **dauerhaft**
|
|
||||||
gelten soll (z.B. das `veth`/`masquerade` aus TP16.3 §3.4), gehört **nicht** in eine Transaktion,
|
|
||||||
sondern ins **Soll** — Transaktion = befristeter Versuch, Template = Dauerzustand.
|
|
||||||
⚠️ Das Skript löst den Token-Pfad gegen die Repo-Wurzel auf und findet ihn **aus einem Worktree
|
|
||||||
heraus nicht** (`210-nautobot.yml` liegt nur im Haupt-Checkout) — die Transaktionsliste kommt
|
|
||||||
trotzdem, nur die Compliance-Spalte fehlt dann.
|
|
||||||
- **Maskierungs-Muster auf den SCHLÜSSELNAMEN ankern, nie auf ein Trennzeichen** (Befund 2026-08-31,
|
|
||||||
zweiter Fall dieser Klasse nach `SHELLY_DEVICE_PW`). `grep -n "TOKEN" datei | sed 's/:.*TOKEN/…/'`
|
|
||||||
maskiert **nicht**: `grep -n` stellt `<zeile>:` voran, und das `:.*TOKEN` matcht diesen Präfix statt
|
|
||||||
den Wert — ein nachgeschaltetes `cut -c1-60` schneidet dann genau nichts weg. So sind zwei
|
|
||||||
Nautobot-Tokens ins Transkript geraten. **Sicher ist nur**
|
|
||||||
`sed -E 's/(TOKEN|PASSWORD|SECRET|KEY|PASS)=.*/\1=<redacted>/'`. Und: **Werte nie suchen, um sie zu
|
|
||||||
sehen** — den Schlüssel per `sed -n 's/^…KEY=//p'` direkt in eine Variable holen, nie über `grep`
|
|
||||||
mit Ausgabe. Vergleichbar zum PSK-Leak 2026-08-24 und zum `sed`-Fall 2026-08-28.
|
|
||||||
- **Vor jedem Flash-Budget auf MikroTik `docker export | wc -c` messen, nicht `docker image inspect
|
|
||||||
.Size`** (Befund 2026-08-31, TP16.3). `.Size` meldete für `cloudflared` 27,5 MB, entpackt waren es
|
|
||||||
**69,5 MiB** — das offizielle Image passt damit auf keinen hAP/cAP. RouterOS braucht beim Import
|
|
||||||
**Tar UND entpacktes Abbild gleichzeitig**. 🔴 **Und `docker save` schreibt seit Docker 25
|
|
||||||
(containerd-Store) OCI statt docker-v1** → RouterOS meldet `no config found in manifest`, was nach
|
|
||||||
kaputtem Image klingt und ein Formatproblem ist. Beides in `docs/tp16.3-bf-public-ap-connector-runbook.md` §2.2.
|
|
||||||
|
|
||||||
**Checkliste vor jedem Edit-Vorschlag:**
|
**Checkliste vor jedem Edit-Vorschlag:**
|
||||||
|
|
||||||
@@ -391,4 +201,4 @@ Verhaltensregeln für nachfolgende AI-Sessions — kleiner, klarer, weniger Drif
|
|||||||
3. Habe ich **eine** Fragenrunde mit allen offenen Annahmen gestellt?
|
3. Habe ich **eine** Fragenrunde mit allen offenen Annahmen gestellt?
|
||||||
4. Habe ich für jeden Edit **deine** Bestätigung eingeholt (nicht nur für den Plan-Output)?
|
4. Habe ich für jeden Edit **deine** Bestätigung eingeholt (nicht nur für den Plan-Output)?
|
||||||
5. **MCP-Änderung?** Vorher Live-Check (Port/Prozess), aktiv melden, Garbage-Collector-Pflicht.
|
5. **MCP-Änderung?** Vorher Live-Check (Port/Prozess), aktiv melden, Garbage-Collector-Pflicht.
|
||||||
6. **Output-Länge.** Christoph hat sich heute (2026-08-28) wiederholt in langen Outputs verirrt. **Merkregel:** eine Antwort pro Edit-Vorschlag, eine Zeile pro Bestätigung, keine Wiederholung dessen, was im Diff steht. Keine Frage-Runden, wenn keine echte Annahme kollidiert. Erklärungstexte unter 6 Zeilen, sofern nicht ausdrücklich verlangt.
|
6. **Output-Länge.** Christoph hat sich wiederholt in langen Outputs verirrt. **Merkregel:** eine Antwort pro Edit-Vorschlag, eine Zeile pro Bestätigung, keine Wiederholung dessen, was im Diff steht. Keine Frage-Runden, wenn keine echte Annahme kollidiert. Erklärungstexte unter 6 Zeilen, sofern nicht ausdrücklich verlangt.
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ dns:
|
|||||||
ratelimit_subnet_len_ipv6: 56
|
ratelimit_subnet_len_ipv6: 56
|
||||||
ratelimit_whitelist: []
|
ratelimit_whitelist: []
|
||||||
refuse_any: true
|
refuse_any: true
|
||||||
|
# TP45 C2 (2026-09-22): der Core ist der EINZIGE Upstream — AGH macht nur noch
|
||||||
|
# Filtering. Die interne Zone und die bare Namen kommen damit aus derselben Quelle
|
||||||
|
# wie fuer jeden anderen Client. DoH nach draussen macht allein der Core.
|
||||||
upstream_dns:
|
upstream_dns:
|
||||||
- https://px35ynlol3.cloudflare-gateway.com/dns-query
|
- 10.40.20.1
|
||||||
- '[/bf.emsenhuber.net/]10.40.20.1'
|
|
||||||
- '[/40.10.in-addr.arpa/]10.40.20.1'
|
|
||||||
upstream_dns_file: ""
|
upstream_dns_file: ""
|
||||||
bootstrap_dns:
|
bootstrap_dns:
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
@@ -158,85 +159,9 @@ filtering:
|
|||||||
blocking_mode: default
|
blocking_mode: default
|
||||||
parental_block_host: family-block.dns.adguard.com
|
parental_block_host: family-block.dns.adguard.com
|
||||||
safebrowsing_block_host: standard-block.dns.adguard.com
|
safebrowsing_block_host: standard-block.dns.adguard.com
|
||||||
rewrites:
|
# TP45 C2 (2026-09-22): alle 26 Rewrites entfernt — sie waren die zweite, stille
|
||||||
- domain: pbs.bf.emsenhuber.net
|
# Definitionsquelle. Der Core haelt jeden dieser Namen als /ip dns static.
|
||||||
answer: 10.40.20.40
|
rewrites: []
|
||||||
enabled: true
|
|
||||||
- domain: portainer.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: pve.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: gitea-mcp.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: n8n-mcp.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: ha-mcp.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: pve-mcp.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: z2m25.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: iobroker.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: ccu.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: nautobot.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: vw.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: hass.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: n8n.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: gitea.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: ds-bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: nas.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.10
|
|
||||||
enabled: true
|
|
||||||
- domain: macmini.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.167
|
|
||||||
enabled: true
|
|
||||||
- domain: mfc.bf.emsenhuber.net
|
|
||||||
answer: 10.40.10.6
|
|
||||||
enabled: true
|
|
||||||
- domain: tv.bf.emsenhuber.net
|
|
||||||
answer: 10.40.40.11
|
|
||||||
enabled: true
|
|
||||||
- domain: docker.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.126
|
|
||||||
enabled: true
|
|
||||||
- domain: platform-pg.bf.emsenhuber.net
|
|
||||||
answer: 10.40.20.20
|
|
||||||
enabled: true
|
|
||||||
- domain: core.bf.emsenhuber.net
|
|
||||||
answer: 10.40.99.1
|
|
||||||
enabled: true
|
|
||||||
- domain: ap1.bf.emsenhuber.net
|
|
||||||
answer: 10.40.99.11
|
|
||||||
enabled: true
|
|
||||||
- domain: ap2.bf.emsenhuber.net
|
|
||||||
answer: 10.40.99.12
|
|
||||||
enabled: true
|
|
||||||
- domain: branch.bf.emsenhuber.net
|
|
||||||
answer: 10.40.99.2
|
|
||||||
enabled: true
|
|
||||||
safe_fs_patterns:
|
safe_fs_patterns:
|
||||||
- /opt/AdGuardHome/userfilters/*
|
- /opt/AdGuardHome/userfilters/*
|
||||||
max_http_size: 256MB
|
max_http_size: 256MB
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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__}")
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
TP36 (reframed) — teilprojekte.md Index/Status → Gitea Issues (admin/bf-infra).
|
TP36 (reframed) — Archiv-Backfill: fehlende Archiv-TPs als Gitea-Issues anlegen.
|
||||||
|
|
||||||
Idempotenter Sync (get-or-create, Match ueber "TPnn" im Titel):
|
🔴 RICHTUNG (seit 2026-10-03): **Gitea ist die SSoT fuer Index/Status.** Dieses Werkzeug
|
||||||
- Archiv-Header in docs/teilprojekte-archiv.md -> closed Issues (nur TPs, die in Gitea
|
liest die Datei NUR, um zu pruefen, ob jedes archivierte TP ein Issue hat. Es schreibt
|
||||||
NICHT offen sind; ein reopentes TP mit Archiv-Zeiger bleibt offen).
|
**ausschliesslich** NEUE Issues fuer noch nicht existierende Archiv-TPs — existierende
|
||||||
- Labels status:* (Marker) + thema:* (Gruppierung) werden vorab get-or-create.
|
Issues werden NIE angefasst (kein Label-, kein State-Patch).
|
||||||
|
|
||||||
|
Warum das wichtig ist: die Vorgaenger-Version glich auch bestehende Issues ab und leitete
|
||||||
|
den Zielzustand aus dem Datei-Marker ab (fest `✅`). Damit konnte sie ein bewusst gesetztes
|
||||||
|
Label ueberschreiben — so wurde #24 (TP34, `status:dropped`) auf `status:done` und #32
|
||||||
|
(TP43) auf ein falsches `thema:` gezogen. Die Richtung Datei → Gitea fuer *Status* ist
|
||||||
|
seit dem TP36-Cutover (2026-09-07) falsch; sie bleibt hier nur fuer das *Anlegen* erhalten.
|
||||||
|
|
||||||
|
Abgrenzung:
|
||||||
|
- Aktive TPs: werden DIREKT als Gitea-Issue gepflegt (nicht hier).
|
||||||
|
- Status/Thema/State bestehender Issues: aendert nur der Mensch im Board.
|
||||||
|
- Per-TP-Prosa: bleibt in docs/teilprojekte.md / teilprojekte-archiv.md.
|
||||||
|
|
||||||
SSoT-Modell (seit TP36-Cutover 2026-09-07): Gitea fuehrt Index+Status — aktive TPs werden
|
|
||||||
DIREKT als Gitea-Issue angelegt/gepflegt, NICHT mehr aus einer Datei-Tabelle abgeleitet
|
|
||||||
(die Quick-Index-Tabelle wurde aus teilprojekte.md entfernt). Dieses Werkzeug haelt nur noch
|
|
||||||
die ARCHIVIERTEN (closed) TPs mit teilprojekte-archiv.md idempotent abgeglichen und fasst
|
|
||||||
offene Issues nie an. Die per-TP-Prosa bleibt in docs/teilprojekte.md / teilprojekte-archiv.md.
|
|
||||||
Token: ~/.hermes/.env (GITEA_ACCESS_TOKEN, GITEA_HOST) — wird NIE ausgegeben.
|
Token: ~/.hermes/.env (GITEA_ACCESS_TOKEN, GITEA_HOST) — wird NIE ausgegeben.
|
||||||
|
|
||||||
Aufruf:
|
Aufruf:
|
||||||
python3 bin/gitea-tp-sync.py # Dry-Run (default) — zeigt, was passieren wuerde
|
python3 bin/gitea-tp-sync.py # Dry-Run (default) — zeigt, was fehlt
|
||||||
python3 bin/gitea-tp-sync.py --commit # legt Labels + Issues an / gleicht sie ab
|
python3 bin/gitea-tp-sync.py --commit # legt fehlende Archiv-Issues an
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -33,16 +39,6 @@ HERMES = Path.home() / ".hermes" / ".env"
|
|||||||
OWNER_REPO = "admin/bf-infra"
|
OWNER_REPO = "admin/bf-infra"
|
||||||
COMMIT = "--commit" in sys.argv
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
# Marker -> (status-label, gitea-state)
|
|
||||||
MARKER = {
|
|
||||||
"🔜": ("status:todo", "open"),
|
|
||||||
"⏳": ("status:wip", "open"),
|
|
||||||
"⏸️": ("status:paused", "open"),
|
|
||||||
"💡": ("status:idea", "open"),
|
|
||||||
"🟢": ("status:concept", "open"),
|
|
||||||
"✅": ("status:done", "closed"),
|
|
||||||
"❌": ("status:dropped", "closed"),
|
|
||||||
}
|
|
||||||
LABEL_COLORS = {
|
LABEL_COLORS = {
|
||||||
"status:todo": "#ededed", "status:wip": "#fbca04", "status:paused": "#c5def5",
|
"status:todo": "#ededed", "status:wip": "#fbca04", "status:paused": "#c5def5",
|
||||||
"status:idea": "#d4c5f9", "status:concept": "#bfdadc", "status:done": "#0e8a16",
|
"status:idea": "#d4c5f9", "status:concept": "#bfdadc", "status:done": "#0e8a16",
|
||||||
@@ -105,23 +101,31 @@ def api(method, path, body=None):
|
|||||||
|
|
||||||
# --------------------------------------------------------------------------- parse
|
# --------------------------------------------------------------------------- parse
|
||||||
def parse_archiv(exclude_nums):
|
def parse_archiv(exclude_nums):
|
||||||
"""Archiv-Header -> archivierte TPs. exclude_nums = TP-Nummern, die in Gitea OFFEN sind
|
"""Archiv-Header -> archivierte TPs, die ein Issue bekommen SOLLEN.
|
||||||
(reopente TPs mit Archiv-Zeiger) -> die bleiben offen, nicht als closed abgleichen."""
|
|
||||||
|
Header-Form seit 2026-10-03: `### TP25 · Gitea #17 — WLAN-Stabilisierung — …`
|
||||||
|
Der Status-Anspruch (frueher `▸ ✅ FERTIG …`) ist entfernt; die Datei traegt keinen
|
||||||
|
Status mehr. Der Gitea-Zeiger `· Gitea #N` ist die Bruecke, falls er schon da ist.
|
||||||
|
|
||||||
|
exclude_nums = TP-Nummern, die in Gitea OFFEN sind -> die brauchen kein neues Issue.
|
||||||
|
"""
|
||||||
out = []
|
out = []
|
||||||
hdr = re.compile(r"^#{2,4}\s*(TP[\d.\-A-Za-z]+)\s*—\s*(.+?)\s*▸", )
|
hdr = re.compile(r"^#{2,4}\s*(TP[\d.\-A-Za-z]+)\b\s*(?:·\s*Gitea\s*#(\d+)\s*)?—\s*(.+?)\s*$")
|
||||||
seen = set()
|
seen = set()
|
||||||
for line in ARCHIV.read_text().splitlines():
|
for line in ARCHIV.read_text().splitlines():
|
||||||
m = hdr.match(line)
|
m = hdr.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
tp, title = m.groups()
|
tp, issue_no, title = m.groups()
|
||||||
num = tp[2:]
|
num = tp[2:]
|
||||||
if num in exclude_nums or num in seen:
|
if num in exclude_nums or num in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(num)
|
seen.add(num)
|
||||||
short = re.sub(r"\*\*|`", "", title).strip()[:80]
|
# Caveat hinter dem zweiten ` — ` abschneiden; nur der Kurztitel gehoert ins Issue.
|
||||||
out.append({"tp": tp, "num": num, "marker": "✅",
|
short = re.split(r"\s+—\s+", title)[0]
|
||||||
"title": short, "date": "", "src": "archiv"})
|
short = re.sub(r"\*\*|`", "", short).strip()[:80]
|
||||||
|
out.append({"tp": tp, "num": num, "issue": issue_no,
|
||||||
|
"title": short, "src": "archiv"})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -173,43 +177,44 @@ def main():
|
|||||||
print(f"Geparst: {len(items)} archivierte TP (aktive TPs = SSoT in Gitea: "
|
print(f"Geparst: {len(items)} archivierte TP (aktive TPs = SSoT in Gitea: "
|
||||||
f"{len(open_nums)} offen, nicht angetastet)\n")
|
f"{len(open_nums)} offen, nicht angetastet)\n")
|
||||||
|
|
||||||
created = updated = 0
|
created = skipped = 0
|
||||||
for it in items:
|
for it in items:
|
||||||
status_label, state = MARKER.get(it["marker"], ("status:todo", "open"))
|
# Archiv-TPs sind abgeschlossen -> neu angelegte Issues gehen direkt auf closed.
|
||||||
|
status_label, state = "status:done", "closed"
|
||||||
labels = [status_label]
|
labels = [status_label]
|
||||||
th = THEME.get(it["num"])
|
th = THEME.get(it["num"])
|
||||||
if th:
|
if th:
|
||||||
labels.append(f"thema:{th}")
|
labels.append(f"thema:{th}")
|
||||||
title = f"{it['tp']} — {it['title']}"
|
title = f"{it['tp']} — {it['title']}"
|
||||||
body = (f"Steuerungs-SSoT: Index/Status hier in Gitea; **Prosa/Detail** im Repo unter "
|
body = (f"Steuerungs-SSoT: Index/Status hier in Gitea; **Prosa/Detail** im Repo unter "
|
||||||
f"`docs/{'teilprojekte-archiv.md' if it['src']=='archiv' else 'teilprojekte.md'}` "
|
f"`docs/teilprojekte-archiv.md` (Abschnitt `{it['tp']}`).\n\n"
|
||||||
f"(Abschnitt `{it['tp']}`).\n\n"
|
f"- Status: ✅ ({status_label})\n"
|
||||||
f"- Status: {it['marker']} ({status_label})\n"
|
f"- Angelegt aus dem Archiv-Backfill (`bin/gitea-tp-sync.py`).\n")
|
||||||
+ (f"- Abschluss: {it['date']}\n" if it["date"] else ""))
|
|
||||||
want_ids = [label_id[l] for l in labels if l in label_id]
|
want_ids = [label_id[l] for l in labels if l in label_id]
|
||||||
|
|
||||||
cur = by_tp.get(it["tp"])
|
cur = by_tp.get(it["tp"])
|
||||||
if cur is None:
|
if cur is not None:
|
||||||
print(f" [{'+' if COMMIT else 'DRY'}] NEW {title} {labels} state={state}")
|
# 🔴 Existierendes Issue: NICHT anfassen. Weder Label noch State noch Body.
|
||||||
if COMMIT:
|
# Status/Thema aendert nur der Mensch im Board (Gitea = SSoT seit TP36-Cutover).
|
||||||
sc, res = api("POST", f"/repos/{OWNER_REPO}/issues",
|
skipped += 1
|
||||||
{"title": title, "body": body, "labels": want_ids})
|
continue
|
||||||
if state == "closed" and isinstance(res, dict) and res.get("number"):
|
|
||||||
api("PATCH", f"/repos/{OWNER_REPO}/issues/{res['number']}", {"state": "closed"})
|
# Fehlt das Issue, obwohl der Header schon einen Zeiger traegt? -> Zeiger ist stale.
|
||||||
created += 1
|
hint = f" ⚠ Datei-Header nennt bereits Gitea #{it['issue']} — Issue fehlt!" if it.get("issue") else ""
|
||||||
else:
|
print(f" [{'+' if COMMIT else 'DRY'}] NEW {title} {labels} state={state}{hint}")
|
||||||
have = {l["name"] for l in cur.get("labels", [])}
|
if COMMIT:
|
||||||
drift = (have != set(labels)) or (cur.get("state") != state)
|
sc, res = api("POST", f"/repos/{OWNER_REPO}/issues",
|
||||||
if drift:
|
{"title": title, "body": body, "labels": want_ids})
|
||||||
print(f" [{'~' if COMMIT else 'DRY'}] UPD #{cur['number']} {title} "
|
if isinstance(res, dict) and res.get("number"):
|
||||||
f"{sorted(have)}->{labels} state {cur.get('state')}->{state}")
|
api("PATCH", f"/repos/{OWNER_REPO}/issues/{res['number']}", {"state": "closed"})
|
||||||
if COMMIT:
|
print(f" -> #{res['number']} angelegt (closed). Zeiger in "
|
||||||
api("PATCH", f"/repos/{OWNER_REPO}/issues/{cur['number']}",
|
f"docs/teilprojekte-archiv.md nachtragen: `· Gitea #{res['number']}`")
|
||||||
{"labels": want_ids, "state": state})
|
else:
|
||||||
updated += 1
|
print(f" !! Anlegen fehlgeschlagen: {res}")
|
||||||
|
created += 1
|
||||||
|
|
||||||
print(f"\n{'Angelegt' if COMMIT else 'Wuerde anlegen'}: {created} · "
|
print(f"\n{'Angelegt' if COMMIT else 'Wuerde anlegen'}: {created} · "
|
||||||
f"{'Abgeglichen' if COMMIT else 'Wuerde abgleichen'}: {updated}")
|
f"unveraendert gelassen (SSoT = Gitea): {skipped}")
|
||||||
if not COMMIT:
|
if not COMMIT:
|
||||||
print("Dry-Run — nichts geschrieben. Mit --commit ausfuehren.")
|
print("Dry-Run — nichts geschrieben. Mit --commit ausfuehren.")
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ gitea.emsenhuber.net {
|
|||||||
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
|
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
nautobot.bf.emsenhuber.net {
|
nautobot.bf.emsenhuber.net {
|
||||||
reverse_proxy http://10.40.20.30:8080
|
reverse_proxy http://10.40.20.154:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
|
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
|
||||||
|
|||||||
@@ -19,12 +19,35 @@ if [ -z "${TOKEN}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 🔴 `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann
|
||||||
|
# ("failed to open <src> for reading", rc=0 — live gemessen 2026-09-21).
|
||||||
|
# Ein `&&`/`||` hinter dem Push beweist deshalb NICHTS, und die anschliessende
|
||||||
|
# `caddy validate` validiert dann die ALTE Datei -> gruene Abnahme bei
|
||||||
|
# unveraendertem Zustand. Genau so ist der Nautobot-Umzug am 2026-09-21
|
||||||
|
# scheinbar gelandet, obwohl live noch die alte Backend-IP stand.
|
||||||
|
# Deshalb: Push + md5-Vergleich in EINEM Remote-Aufruf, der bei Abweichung
|
||||||
|
# hart abbricht. Gepusht wird nur der lokal bekannte Pfad — die frueheren
|
||||||
|
# `\$\$`/`$$`-Verrenkungen waren der Ausloeser.
|
||||||
|
push_verified() { # push_verified <quelle-auf-pve> <ziel-im-ct> <perms> <label>
|
||||||
|
local src=$1 dst=$2 perms=$3 label=$4
|
||||||
|
ssh "${PVE}" "set -e
|
||||||
|
pct push ${CTID} '${src}' '${dst}' --perms ${perms}
|
||||||
|
want=\$(md5sum '${src}' | cut -d' ' -f1)
|
||||||
|
got=\$(pct exec ${CTID} -- md5sum '${dst}' | cut -d' ' -f1)
|
||||||
|
rm -f '${src}'
|
||||||
|
if [ \"\$want\" != \"\$got\" ]; then
|
||||||
|
echo \"FEHLER: ${label} nicht gelandet (\$want != \$got)\" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo \" ${label} verifiziert (\$got)\""
|
||||||
|
}
|
||||||
|
|
||||||
echo "-> Caddyfile"
|
echo "-> Caddyfile"
|
||||||
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
|
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
|
||||||
ssh "${PVE}" "pct push ${CTID} /tmp/.caddyfile.\$\$ /etc/caddy/Caddyfile --perms 644 2>/dev/null || pct push ${CTID} /tmp/.caddyfile.$$ /etc/caddy/Caddyfile --perms 644; rm -f /tmp/.caddyfile.$$"
|
push_verified "/tmp/.caddyfile.$$" "/etc/caddy/Caddyfile" 644 "Caddyfile"
|
||||||
|
|
||||||
echo "-> Token (maskiert: Laenge ${#TOKEN})"
|
echo "-> Token (maskiert: Laenge ${#TOKEN})"
|
||||||
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$ && pct push ${CTID} /tmp/.cenv.$$ /etc/caddy/caddy.env --perms 600 && rm -f /tmp/.cenv.$$"
|
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$"
|
||||||
|
push_verified "/tmp/.cenv.$$" "/etc/caddy/caddy.env" 600 "caddy.env"
|
||||||
|
|
||||||
echo "-> validate"
|
echo "-> validate"
|
||||||
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
|
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
|
||||||
|
|||||||
104
docs/announcements.md
Normal file
104
docs/announcements.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 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-10-12 — Airfryer „airschreier" ist FUSION/Cloud-only — lokale Anbindung endgültig tot
|
||||||
|
**Betrifft:** TP5.1 (Airfryer-Fall, DNS-Redirect + Live-Torch), die UNTRUSTED-Einordnung des Geräts
|
||||||
|
(`10.40.30.180`, VLAN30) und jede Session, die den Airfryer als „Low-Trust-Gerät mit lokalem OTA-Problem"
|
||||||
|
behandelt.
|
||||||
|
**Aktion:** Die Begründung für den DHCP-Override `dns 1.1.1.1` auf **dieser** Lease („OTA geht nicht über
|
||||||
|
den DoH-Gateway-Pfad") ist **erledigt** — das Gerät hat die FUSION-Migration bereits vollzogen und hängt
|
||||||
|
sowieso an der Cloud. **Der Override-Mechanismus selbst bleibt bestehen** und ist für künftige Low-Trust-
|
||||||
|
Kandidaten weiter definiert (Entscheidung 2026-10-12) — er wird **nicht** abgebaut, sondern behält seinen
|
||||||
|
Wert als Werkzeug, falls wieder ein Gerät mit OTA-DoH-Latenzproblemen auftaucht. Das gilt ausdrücklich
|
||||||
|
auch für die in TP5.1 skizzierte DNS-Redirect-Variante (a).
|
||||||
|
|
||||||
|
**Aber:** Bei FUSION ist die **Cloud-Verbindung die einzige** Steuerungsroute — das Gerät darf **nicht**
|
||||||
|
ins internetlose UNTRUSTED-VLAN (40) wandern, sonst ist es komplett unsteuerbar (auch aus HA). Der
|
||||||
|
frühere UNTRUSTED-Move-Kandidat aus TP5.1 ist damit **zurückzustellen**, nicht umzusetzen.
|
||||||
|
|
||||||
|
**Steuerungsentscheidung (2026-10-12):** Die Cloud-Integration **bleibt** — der Airfryer wird bewusst
|
||||||
|
über `philips_homeid` / Cloud-Relay betrieben. Kein Rückbau, keine weitere Diskussion nötig.
|
||||||
|
|
||||||
|
**Befund (aus HA verifiziert, 2026-10-12):**
|
||||||
|
- Config-Entry `philips_homeid`, Titel **„Airschreier 5000 Series XXL (Cloud)"** — das `(Cloud)` im
|
||||||
|
Titel ist die FUSION-Markierung der Integration (Cloud-Relay statt lokal).
|
||||||
|
- Firmware-Sensoren: **NCP `1.1.14` / Host `0.5.6`** → entspricht der dokumentierten FUSION-Liste
|
||||||
|
(`1.1.14/0.5.6`; vgl. HD9285-Fälle `1.6.2/0.6.8`, `1.1.8/0.5.6`). Achtung: Die HomeID-App zeigt die
|
||||||
|
Reihenfolge **umgedreht** an (`0.5.6/1.1.14`) — nicht als SPECTRE fehldeuten.
|
||||||
|
- Modellnummer-Sensor: `HD9285` (SPECTRE wäre die lokale Variante, ist es aber nicht mehr).
|
||||||
|
- Lokaler Port 80/443 antwortet **nicht** (aus MAIN getestet); mDNS announce't aber weiter
|
||||||
|
`PHILIPS_HD9285_2_1FB281._http._tcp.local.` → Port 80. Das Gerät läuft also, hat aber keine
|
||||||
|
lokalen Credentials mehr.
|
||||||
|
- **Kein Weg zurück:** Laut Integrations-Doku ist die FUSION-Migration einwegig („There is no way back"),
|
||||||
|
weil die Zuordnung cloud-seitig über `externalDeviceId` verankert ist. Firmware-Downgrade hilft nicht,
|
||||||
|
Custom-Firmware existiert nicht (geschlossener ESP-Stack, signierter Bootloader).
|
||||||
|
- Steuerung **funktioniert** über die Cloud (Live-Werte plausibel: Kochstatus `setting`, 180 °C,
|
||||||
|
`fresh_fries`; Buttons für Start/Pause/Stopp/Warmhalten vorhanden).
|
||||||
|
- Nebenbefund: `dns 1.1.1.1` = anonymer Consumer-Resolver → **DNS-Sicht weiter blind**, Gerät umgeht
|
||||||
|
die Filterung. Redet nachweislich mit Versuni → AWS `eu-west-1` (IoT). Bleibt ein Beobachtungspunkt,
|
||||||
|
aber ohne OTA-Rechtfertigung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-03 — TP-Header tragen keinen Status mehr (SSoT-Drift geschlossen)
|
||||||
|
**Betrifft:** alle, die `docs/teilprojekte.md` oder `teilprojekte-archiv.md` lesen oder editieren, und
|
||||||
|
alle, die ein TP anlegen/abschließen.
|
||||||
|
**Aktion:** Die `### TPnn`-Header haben die Form `### TP25 · Gitea #17 — <Titel> — <Caveat>`.
|
||||||
|
Der Status-Anspruch (`▸ ✅ FERTIG (2026-09-15)`, `▸ ⏳ CUTOVER …`) ist **entfernt** — er war eine
|
||||||
|
dritte Statusquelle neben Issue und Prosa und lief den Labels nachweislich hinterher. **Wer einen
|
||||||
|
Status braucht, liest das `status:*`-Label im Gitea-Board**, nicht den Datei-Kopf — `· Gitea #N`
|
||||||
|
ist die Brücke. Status-Marker **im Fließtext** (Messchronik, `└─`-Zeilen) bleiben: sie beschreiben
|
||||||
|
Historie, keinen Stand.
|
||||||
|
|
||||||
|
Dazu zwei Befunde, die dabei behoben wurden:
|
||||||
|
- **TP47 existierte nur in der Datei**, ohne Gitea-Issue (seit Commit `808d3a1`, 2026-09-27) — jetzt
|
||||||
|
**#74**. Das war ein echter SSoT-Bruch, kein fehlender Zeiger.
|
||||||
|
- **`bin/gitea-tp-sync.py` schrieb in die falsche Richtung**: der Abgleich leitete den Zielzustand
|
||||||
|
aus dem Datei-Marker ab (fest `✅`) und hätte #24 (TP34, bewusst `status:dropped`) auf `status:done`
|
||||||
|
gezogen. Das Werkzeug schreibt jetzt **nur noch fehlende** Archiv-Issues neu und fasst bestehende
|
||||||
|
**nie** an.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
|
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
|
||||||
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
|
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
|
||||||
> Nautobot (`10.40.20.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
> Nautobot (`10.40.20.154`, TP45 Schicht A 2026-09-21; vorher `.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
||||||
> `nautobot/tp44-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
|
> `nautobot/tp44-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
|
||||||
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
|
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
|
||||||
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
||||||
@@ -303,6 +303,30 @@ Tokens: nur im Container-Env + jederzeit via `GET /accounts/…/cfd_tunnel/<id>/
|
|||||||
|
|
||||||
**🔴 NEUER FUND (wichtiger als vw!) — Wildcard `*.emsenhuber.net` (`16aaec78`) zu offen:** Policy `christoph` hat `include: email:christoph OR login_method:github OR login_method:google`. **`login_method:<idp>` als include matcht JEDEN, der sich via GitHub/Google einloggt** — nicht nur christoph. D.h. **jeder GitHub-/Google-User erreicht nach Login alle internen Dienste** (hass, grafana, portainer, pve, gitea, …). Fix = die zwei `login_method`-Includes raus (nur `email:christoph` + ggf. weitere erlaubte Mails + `non_identity bf4` + `claude-mcp`). **NICHT einseitig geändert** (ändert Login-Verhalten überall) → User-Entscheidung. War im Memory schon als „Policy christoph verschlanken" geflaggt.
|
**🔴 NEUER FUND (wichtiger als vw!) — Wildcard `*.emsenhuber.net` (`16aaec78`) zu offen:** Policy `christoph` hat `include: email:christoph OR login_method:github OR login_method:google`. **`login_method:<idp>` als include matcht JEDEN, der sich via GitHub/Google einloggt** — nicht nur christoph. D.h. **jeder GitHub-/Google-User erreicht nach Login alle internen Dienste** (hass, grafana, portainer, pve, gitea, …). Fix = die zwei `login_method`-Includes raus (nur `email:christoph` + ggf. weitere erlaubte Mails + `non_identity bf4` + `claude-mcp`). **NICHT einseitig geändert** (ändert Login-Verhalten überall) → User-Entscheidung. War im Memory schon als „Policy christoph verschlanken" geflaggt.
|
||||||
|
|
||||||
|
> ✅ **BESTÄTIGT und BEHOBEN am 2026-10-06 — der Fund oben war RICHTIG.**
|
||||||
|
> Christoph hat mit einem **zweiten GitHub-Account** (fremde Adresse) getestet: **er kam durch.**
|
||||||
|
> `login_method:github` im **`include`** ist ein **OR** — die danebenstehende `email`-Zeile war
|
||||||
|
> eine **Alternative**, keine Bedingung. Jeder GitHub-Account kam hinein.
|
||||||
|
>
|
||||||
|
> **Fix (ausgeführt):** `login_method:github` von `include` nach **`require`** verschoben
|
||||||
|
> ⇒ **`email AND github`**. Zurückgelesen über beide Endpunkte, Fix bestätigt.
|
||||||
|
> ⚠️ Nebenwirkung: der E-Mail-Einmalcode allein reicht auf GitHub-Seite **nicht mehr**.
|
||||||
|
>
|
||||||
|
> 🔴 **Zwischenzeitliche Fehlspur (dokumentiert, weil lehrreich):** eine frühere Fassung dieser
|
||||||
|
> Notiz erklärte den Fund am selben Tag für **widerlegt** — auf Basis eines **Google**-Tests
|
||||||
|
> (`testus@emsenhuber.com` → abgelehnt) plus eines **Schlusses** auf GitHub. Der Schluss war
|
||||||
|
> falsch: der Google-Test zeigt nur Googles Verhalten. Die IdP-Configs sind konfigurationsgleich
|
||||||
|
> (beide nur `client_id` + `redirect_url`) — **das unterscheidende Feld steht nicht in der Config.**
|
||||||
|
> **Lehre: pro IdP einzeln messen, nicht den ersten Test auf den zweiten übertragen.**
|
||||||
|
>
|
||||||
|
> **Bleibt gültig:** TP41 hat den internen Wildcard auf Access-Groups (`zt-humans`/`zt-home`/
|
||||||
|
> `zt-automation`) umgestellt — dort steht heute kein `login_method` mehr. Der Sweep über alle
|
||||||
|
> Apps fand `login_method` nur noch in `Terpenfinder v3` (`christoph`: github+onetimepin;
|
||||||
|
> `geli`: nur onetimepin = harmlos).
|
||||||
|
>
|
||||||
|
> Details, Gotchas (`12130` reusable-Policy-Endpunkt, MCP read-only) und die Grenze
|
||||||
|
> „kein GitHub-Username-Selector" → `docs/teilprojekte.md` → `### TP49`.
|
||||||
|
|
||||||
**⏸️ `hass` 400 / `ha-mcp` — zwei getrennte Layer:**
|
**⏸️ `hass` 400 / `ha-mcp` — zwei getrennte Layer:**
|
||||||
- **Access-Layer (remote):** HA-Companion-App kann kein interaktives Access-Login → Fix = iPhone in **WARP** enrollen (`Warp Login App` `6c32fe23` `allow:christoph` existiert → christoph darf enrollen). Client-seitig, braucht User am Gerät. Vom Heim-Netz (bf4-IP) ohnehin ohne Login erreichbar.
|
- **Access-Layer (remote):** HA-Companion-App kann kein interaktives Access-Login → Fix = iPhone in **WARP** enrollen (`Warp Login App` `6c32fe23` `allow:christoph` existiert → christoph darf enrollen). Client-seitig, braucht User am Gerät. Vom Heim-Netz (bf4-IP) ohnehin ohne Login erreichbar.
|
||||||
- **Origin-Layer:** `hass.emsenhuber.net` gibt **vom Heim-Netz** (Access passt via bf4-IP) trotzdem **400** (CF `server:cloudflare`, aber Origin-verursacht) = **HA lehnt den Request ab** — HA-`http:`-Config (`use_x_forwarded_for` + `trusted_proxies` inkl. cloudflared-Range, ggf. Host-Allowlist). **Separates HA-Thema, NICHT Tunnel/Access** — offen.
|
- **Origin-Layer:** `hass.emsenhuber.net` gibt **vom Heim-Netz** (Access passt via bf4-IP) trotzdem **400** (CF `server:cloudflare`, aber Origin-verursacht) = **HA lehnt den Request ab** — HA-`http:`-Config (`use_x_forwarded_for` + `trusted_proxies` inkl. cloudflared-Range, ggf. Host-Allowlist). **Separates HA-Thema, NICHT Tunnel/Access** — offen.
|
||||||
|
|||||||
162
docs/gotchas.md
Normal file
162
docs/gotchas.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Gotchas — ausführliche Diagnose-Anleitungen (Detail-Doku)
|
||||||
|
|
||||||
|
Diese Datei trägt die ausführlichen Diagnose-Blöcke, die in `CLAUDE.md` nur noch als 1-Zeilen-Merkregel
|
||||||
|
mit Zeiger stehen. Volltext hier, damit der Kern-Index schlank bleibt. Alle Blöcke sind **aktiv**
|
||||||
|
(querschnittliche Fallen), keine Historie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSDP-Relay für die macOS-Sonos-App
|
||||||
|
|
||||||
|
LXC **108 `mcast-relay`** auf pve, zweibeinig `10.40.10.21` (VLAN10) + `10.40.40.21` (VLAN40),
|
||||||
|
`multicast-relay` mit **`--noMDNS`** (mDNS macht der Core). Unit im Repo:
|
||||||
|
**`mcast-relay/multicast-relay.service`**. **Zwei Beine sind zwingend** — SSDP ist link-lokal mit TTL 1,
|
||||||
|
ein Relay muss Mitglied beider Broadcast-Domänen sein; Routing hilft nicht.
|
||||||
|
|
||||||
|
- 🔴 **`--ssdpUnicastAddr` gehört in ein zweibeiniges Relay NICHT — die Flagge hat den Hinweg 7 Wochen
|
||||||
|
lang blockiert** (drin 2026-08-18, ausgebaut **2026-08-25**). Sie schreibt in `multicast-relay.py`
|
||||||
|
**Z. 630** die Quelle *jedes* SSDP-Pakets an `239.255.255.250:1900` auf `<addr>:1901` um (**auch
|
||||||
|
NOTIFY**); beim Senden greift dann **Z. 691**
|
||||||
|
`if srcAddr == ssdpUnicastAddr and not onNetwork(srcAddr, tx['addr'], tx['netmask']): continue` —
|
||||||
|
`10.40.10.21` liegt nicht im VLAN40-Netz, also **verwarf der Relay die M-SEARCH selbst, bevor sie die
|
||||||
|
Player erreichte**. Der Rückweg gehört stattdessen **geroutet**: `forward TP5: MEDIA->MAIN` ist offen,
|
||||||
|
die Player antworten unicast an den echten Sucher. (Upstream nennt den Rückweg selbst
|
||||||
|
`# FIXME: record more than one?` — es gibt nur **einen globalen** Merkslot.)
|
||||||
|
- 🔎 **Symptom-Merkregel:** *App sucht hunderte Male, sieht NOTIFYs, baut aber nie eine Verbindung auf*
|
||||||
|
= **Relay, nicht Netz**. Diagnose in einer Zeile — **Richtungsbilanz** im Journal:
|
||||||
|
`grep -c "via eth1"` (VLAN10→VLAN40) gegen `grep -c "via eth0"`. **0 zu n heißt: der Hinweg fehlt.**
|
||||||
|
- ⚠️ **Aus dem Relay heraus lässt sich der Relay nicht testen** (Loop-Schutz ignoriert eigene
|
||||||
|
Quelladressen) — am echten Client testen oder `journalctl -u multicast-relay` mitlesen. **Und am Client
|
||||||
|
nicht auf Port 1900 messen:** Sonos-App *und* Spotify halten dort je ein `SO_REUSEPORT`-Socket, die
|
||||||
|
Unicast-Antwort bekommt nur **eines** davon. Richtig ist eine Probe mit **ephemerem Quellport**
|
||||||
|
(`bind((ifaddr, 0))`), genau wie ein echter Client sucht.
|
||||||
|
- 🔴 **Zweite, unabhängige Ursache desselben Falls (08-25): die Sonos-App suchte gar nicht.** Nach der
|
||||||
|
Relay-Reparatur lag weiter nichts an — der Mitschnitt auf der VLAN40-Leitung
|
||||||
|
(`pct exec 108 -- tcpdump -i eth1 -n -s0 -A "udp and host <client>"`) zeigte: **jedes** SSDP-Paket des
|
||||||
|
MacBooks kam von **Chrome** (`ST: urn:dial-multiscreen-org:…dial:1`, `USER-AGENT: Chromium`), **kein
|
||||||
|
einziges** von Sonos. Die App hatte Sockets offen (`*:1900`, `*:6969`, `TCP *:3400`) und redete mit der
|
||||||
|
Cloud, legte aber nichts auf die Leitung. Ursache war das **Local-Network-Gate von macOS**: es stand auf
|
||||||
|
**„an" und griff trotzdem nicht**. Fix = Schalter unter *Datenschutz & Sicherheit → Lokales Netzwerk*
|
||||||
|
**aus, App beenden, wieder an, App starten** (hart: `sudo tccutil reset LocalNetwork com.sonos.macController2`).
|
||||||
|
⇒ **Merkregel: „die App sucht hunderte Male" ist erst dann ein Befund, wenn der Mitschnitt sagt, WER
|
||||||
|
sucht.** Im Handoff vom Vormittag waren die gezählten 291 Suchen Chrome + eigene Proben; die Zahl hat
|
||||||
|
drei Stunden in die falsche Richtung gezeigt.
|
||||||
|
- 🖥️ **Das MacBook ist dual-homed in VLAN10:** `en5` = **10.40.10.183** (USB-Dock, Default-Route) und
|
||||||
|
`en0` = **10.40.10.82** (WLAN). Die früher als „zweiter Mac" gelesene `.183` ist **dasselbe Gerät** —
|
||||||
|
beim Messen immer beide Beine prüfen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konstanter 10-Sekunden-Hänger = DNS-Timeout, kein Lastproblem
|
||||||
|
|
||||||
|
(Fund 2026-09-01, TP16.3-Randbefund.) `gitea` hing bei ~10 % der Anfragen exakt 10,1 s — quer über
|
||||||
|
Web-UI, `/api/healthz` **und** den Git-Pfad, also kein Rendering. Ursache: **MariaDB (LXC 302) hatte
|
||||||
|
`skip-name-resolve` auskommentiert** und machte bei **jeder neuen Verbindung** einen Reverse-Lookup,
|
||||||
|
während ihr `/etc/resolv.conf` noch auf den toten `10.40.41.254` zeigte (glibc: 5 s × 2 Versuche = 10 s).
|
||||||
|
Pool-Verbindungen waren schnell ⇒ es wirkte sporadisch.
|
||||||
|
|
||||||
|
🔑 **Drei Merkregeln daraus:**
|
||||||
|
- (a) **immer gegen eine zweite Sache messen, die denselben Weg nimmt** — `vw`/`hass` über dieselbe
|
||||||
|
Strecke hatten 0 von 30 Hängern, das hat CF, Tunnel, Firewall und Caddy in einem Schritt entlastet.
|
||||||
|
- (b) **wer an einer DB hängt, sagt die Verbindungstabelle** (`ss -tn | grep 3306`), nicht die
|
||||||
|
Vermutung — hier hingen **Vaultwarden, Home Assistant und gitea** an derselben MariaDB, und die Annahme
|
||||||
|
„Vaultwarden nutzt SQLite" war schlicht falsch.
|
||||||
|
- (c) **ein toter Resolver ist selten allein** — der Sweep über alle LXCs fand **fünf** Container mit
|
||||||
|
`10.40.41.254`. Sweep:
|
||||||
|
`for c in $(pct list|awk 'NR>1{print $1}'); do pct exec $c -- grep ^nameserver /etc/resolv.conf; done`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## networking.service failed bei funktionierendem Netz = tote `auto`-Stanzas
|
||||||
|
|
||||||
|
(Ursache gefunden + behoben 2026-09-01.) In vier LXCs standen in `/etc/network/interfaces` noch
|
||||||
|
Einträge aus dem **alten VLAN1-Schema** (`eth0` → `10.40.41.x`) und aus früheren Versuchen (`veth16`,
|
||||||
|
`veth24`, Tippfehler `eth01`) — auf Interfaces, die es im Container gar nicht gibt. `ifup -a` scheitert
|
||||||
|
daran, das echte `veth20` kommt trotzdem hoch. **Fix:** nur die Stanzas entfernen, deren Interface in
|
||||||
|
`ip -br link` fehlt (`lo` und die echten behalten), Backup daneben.
|
||||||
|
|
||||||
|
🔴 **Abnahme ist ein echter Reboot, nicht `systemctl is-active`** — der Servicestatus sagt nichts
|
||||||
|
darüber, ob beim nächsten Start etwas hochkommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## networking.service failed im LXC, ohne dass das Netz fehlt
|
||||||
|
|
||||||
|
`networking.service` kann in einem LXC dauerhaft `failed` sein, ohne dass das Netz fehlt — und dann
|
||||||
|
startet alles mit `After=network-online.target` nach einem Reboot **nicht** (Fund 2026-09-01: `101`,
|
||||||
|
`102`, `106`, `107`; **influxdb2 kam nach dem Neustart nicht hoch**, Unit `enabled`, **kein Startversuch
|
||||||
|
im Journal**). Fällt nur auf, wenn man neu startet — die Container liefen 13+ Tage.
|
||||||
|
|
||||||
|
**Vor jedem geplanten LXC-Neustart `systemctl --failed` lesen und danach die Ports gegen vorher
|
||||||
|
vergleichen**, sonst verliert man still einen Dienst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Messwerkzeug, das die falsche Frage beantwortet
|
||||||
|
|
||||||
|
(zweimal in einer Nacht, 2026-09-01.)
|
||||||
|
|
||||||
|
- (a) **`nc -z` lieferte falsch-negative** „Port zu" für neun Dienste gleichzeitig — inklusive des
|
||||||
|
SSH-Ports, über den die Session gerade arbeitete; `curl` bekam von demselben Host **HTTP 200 in 24 ms**.
|
||||||
|
**Bei „alles gleichzeitig kaputt" zuerst das eigene Werkzeug verdächtigen**, dann das Netz: ein
|
||||||
|
Python-Socket oder `curl` entscheidet in Sekunden.
|
||||||
|
- (b) **Ein Firewall-Zähler taugt nicht als Wegbeweis**, wenn der Client die Verbindung offenhält
|
||||||
|
(`cloudflared`): er zählt nur NEUE Verbindungen und steht still, obwohl alles läuft — richtig ist die
|
||||||
|
**Verbindungstabelle** (`/ip firewall connection`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a)
|
||||||
|
|
||||||
|
NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert
|
||||||
|
(`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle:
|
||||||
|
DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der
|
||||||
|
*Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum
|
||||||
|
Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber
|
||||||
|
`TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zeitquelle ist der Core (NTP)
|
||||||
|
|
||||||
|
`/system ntp server`, seit 2026-08-18, ausgeliefert per **DHCP-Option 42** (`ntp-server=` in jedem der
|
||||||
|
7 Netze) und drift-überwacht (ComplianceRule `system-ntp`). Vorher lief **nirgends** eine
|
||||||
|
Synchronisation: die anderen drei Geräte zeigten auf `10.40.99.1`, wo kein Server lief, und der Core
|
||||||
|
selbst hatte gar keinen NTP-Client — ap2 war dadurch **17 Stunden** versetzt.
|
||||||
|
|
||||||
|
🔴 **Zwei Fallen dabei:**
|
||||||
|
- (a) ein NTP-Server mit `use-local-clock=no` und unsynchronisiertem Client liefert **Stratum 16**, das
|
||||||
|
jeder Client verwirft — Server einschalten allein reicht nicht.
|
||||||
|
- (b) wird der Server eingeschaltet, *bevor* sein Client synchron ist, hält er diesen Zustand fest →
|
||||||
|
**`enabled=no`/`=yes` nach der Synchronisation**.
|
||||||
|
|
||||||
|
Merkregel: bei widersprüchlichen Zeitstempeln zwischen Geräten zuerst `/system ntp client get status`
|
||||||
|
lesen, und zum Eingrenzen einen Client testweise auf eine öffentliche Quelle zeigen lassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Home Assistant: der `http:`-Block in `configuration.yaml` ist WIRKUNGSLOS
|
||||||
|
|
||||||
|
(Fund 2026-08-26, TP31.) HA hat die http-Config nach **`.storage/http`** migriert
|
||||||
|
(`"yaml_migration_done": true`); seither wird die YAML kommentarlos ignoriert — selbst ein ungueltiger
|
||||||
|
Schluessel darin erzeugt **keinen** Config-Fehler.
|
||||||
|
|
||||||
|
🔴 **Die Logzeile fuehrt aktiv in die Irre:** `Received X-Forwarded-For header from an untrusted proxy
|
||||||
|
<ip>` faellt laut Quelltext nur bei **aktivem** `use_x_forwarded_for` — man haelt die Option fuer wirksam
|
||||||
|
und die Liste fuer falsch und editiert die falsche Datei. Richtig ist der WS-Zweischritt
|
||||||
|
**`http/config/configure`** (setzt `pending`, startet HA neu) → binnen **5 Minuten**
|
||||||
|
**`http/config/promote`**, sonst Auto-Revert auf `stable`.
|
||||||
|
|
||||||
|
Merkregel zur Eingrenzung: ein Testskript in `<config>/python_scripts/` + `python_script.reload` —
|
||||||
|
taucht der Service auf, liest HA das Verzeichnis, und die Frage ist nicht mehr *welche Datei*, sondern
|
||||||
|
*warum ein gelesener Block nicht wirkt*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nautobot auf der DS923 braucht einen abgeschalteten Image-Healthcheck
|
||||||
|
|
||||||
|
Der eingebackene Check ruft alle 10 s `nautobot-server health_check` = einen vollen Django-Start; unter
|
||||||
|
Last dauert der >2 min, die Aufrufe **stapeln sich unbegrenzt** → Speicher alle → `fork()` mit ENOMEM →
|
||||||
|
DSM räumt den ganzen Stack ab (am 2026-08-17 zweimal passiert, Load bis 73 bei 4 Kernen). Im Stack stehen
|
||||||
|
deshalb `healthcheck: disable: true` auf worker+scheduler, `--concurrency=2` und `mem_limit`.
|
||||||
|
**`cpus:` geht auf Synology nicht** (Kernel ohne CFS-Quota, der Stack-Deploy schlägt sonst fehl).
|
||||||
@@ -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>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
28
docs/handoff-20260913-cf-api-token-rolled.md
Normal file
28
docs/handoff-20260913-cf-api-token-rolled.md
Normal 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.
|
||||||
111
docs/handoff-20260914-tp25-ap2-insitu-terrassse.md
Normal file
111
docs/handoff-20260914-tp25-ap2-insitu-terrassse.md
Normal 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".
|
||||||
@@ -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]].
|
||||||
105
docs/handoff-20260915-tp25-tausch-und-zuordnung.md
Normal file
105
docs/handoff-20260915-tp25-tausch-und-zuordnung.md
Normal 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).
|
||||||
96
docs/handoff-20260915-tp25-zuordnung-und-redundanz.md
Normal file
96
docs/handoff-20260915-tp25-zuordnung-und-redundanz.md
Normal 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.
|
||||||
75
docs/handoff-20260922-claude-md-ausduennen.md
Normal file
75
docs/handoff-20260922-claude-md-ausduennen.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Handoff 2026-09-22 — CLAUDE.md ausdünnen (strukturelle Hygiene, kein Bug)
|
||||||
|
|
||||||
|
**Anlass:** Christoph stellt fest, dass CLAUDE.md „eh schon so groß" ist. Das ist kein kosmetischer
|
||||||
|
Punkt — es ist eine strukturelle Drift, die jede künftige Session belastet. Dieser Handoff beschreibt
|
||||||
|
das Problem aus der Innenperspektive einer Session (die CLAUDE.md bei jedem Start komplett lädt) und
|
||||||
|
gibt einen Scope für eine eigene Cleanup-Session.
|
||||||
|
|
||||||
|
## Das Problem, aus Sicht der Session
|
||||||
|
|
||||||
|
Jede Session lädt CLAUDE.md **vollständig** in den Kontext. Was dabei konkret wehtut:
|
||||||
|
|
||||||
|
1. **Reine Kontext-Kosten.** Die Datei ist >58 KB. Ein großer Teil davon ist nicht „Kontext für die
|
||||||
|
anstehende Arbeit", sondern Historie und abgeschlossene TPs. Der Token-Preis wird bei jeder Session
|
||||||
|
fällig, egal worum es geht.
|
||||||
|
|
||||||
|
2. **Signal-to-noise im Gotcha-Block.** Der „Kritische Gotchas"-Abschnitt mischt **aktive Fallen** mit
|
||||||
|
**erledigten/gegenstandslosen** Einträgen. Beispiele direkt aus dem Bestand: `~~ALLNET~~` und
|
||||||
|
`~~Omada~~` sind mehrfach als „gegenstandslos/historisch" markiert, stehen aber weiterhin voll im
|
||||||
|
Text. Mehrere Einträge tragen „korrigiert am X" — die **korrigierte** Fassung steht neben der
|
||||||
|
**falschen**, die damit weiter mitgelesen wird. Beim Suchen nach *einer* relevanten Falle scannt
|
||||||
|
die Session dutzende Zeilen, von denen ein guter Teil totes Gewicht ist.
|
||||||
|
|
||||||
|
3. **Redundanz über drei Orte.** Dieselbe Information (z. B. die AP-Positionen, der FT-Verlauf) steht
|
||||||
|
in CLAUDE.md **und** in `teilprojekte.md` **und** teils in Handoffs. Eine Korrektur an einer Stelle
|
||||||
|
lässt die anderen stumm veralten — genau die Drift, vor der die eigene CLAUDE.md warnt
|
||||||
|
(„Manuell gepflegte Doku verliert gegen die Realität, wenn sie lang wird").
|
||||||
|
|
||||||
|
4. **Der gefährlichste Effekt: die Bloat untergräbt „live prüfen".** Die Projektregel lautet „nicht
|
||||||
|
raten, live prüfen oder fragen". Eine Riesen-CLAUDE.md mit eingestreuten Stale-Angaben verleitet
|
||||||
|
genau dazu, sich auf die Datei zu verlassen statt nachzusehen — die Datei ist Teil des Problems,
|
||||||
|
das sie zu lösen vorgibt.
|
||||||
|
|
||||||
|
## Was wohin gehört (SSoT-Dreiteilung, TP36)
|
||||||
|
|
||||||
|
| Inhalt | Soll-Ort |
|
||||||
|
|---|---|
|
||||||
|
| VLAN-Modell, Hardware-Tabelle (aktuell), Tooling-Pointer | CLAUDE.md (bleibt) |
|
||||||
|
| **Aktive, querschnittliche** Fallen (Firewall deny-by-default, sshpass-Regel, cap-wifiN-Karte …) | CLAUDE.md (bleibt, komprimiert) |
|
||||||
|
| TP-spezifische Gotchas & Detail-Verläufe | `teilprojekte.md` `### TPnn` |
|
||||||
|
| Erledigte/historische Geräte & Entscheidungen | `teilprojekte-archiv.md` |
|
||||||
|
| „korrigiert am X"-Notizen | Original-Eintrag bereinigen (Stale-Wert raus), Korrektur **ersetzt** den Irrtum, statt neben ihm zu stehen |
|
||||||
|
|
||||||
|
## Konkrete Kandidaten zum Ausdünnen (Startpunkte, nicht abschließend)
|
||||||
|
|
||||||
|
- **„Kritische Gotchas":** alle `~~durchgestrichenen~~`/„gegenstandslos"-Einträge (ALLNET, Omada,
|
||||||
|
sw1, ES208G, „ALLNET VLAN-DB") → Archiv oder löschen.
|
||||||
|
- **„korrigiert"-Notizen:** die falsche Angabe entfernen, nur die korrigierte behalten (z. B.
|
||||||
|
Nautobot-IP, „2×/Tag war nie real", „`ft=no` war NICHT uniform").
|
||||||
|
- **„Pflichtlektüre":** ist von „1–7" auf effektiv „0–7" angewachsen; die ✅-GESCHLOSSEN-Punkte
|
||||||
|
(TP16.3 §5, Fund E) sind abgeschlossene Historie → aus dem Pflichtteil in einen Verweis.
|
||||||
|
- **„Lessons for Sessions":** mehrere Meta-Lektionen überlappen sich (Maskierungs-Muster kommt 3×,
|
||||||
|
„erst das Werkzeug verdächtigen" 2×) → auf je eine Fassung verdichten.
|
||||||
|
- **Hardware-Tabelle:** ausgemusterte Geräte (Omada, ALLNET) sind noch als Zeilen da, teils nur mit
|
||||||
|
❌ — prüfen, ob sie ganz raus können (Archiv) statt als Toten in der aktuellen Tabelle zu stehen.
|
||||||
|
|
||||||
|
## Vorgehen für die Cleanup-Session
|
||||||
|
|
||||||
|
1. **Bestandsaufnahme zuerst:** CLAUDE.md abschnittsweise sichten und je Eintrag klassifizieren
|
||||||
|
(aktiv-querschnittlich / TP-spezifisch / historisch / redundant). Drift-Tabelle vorlegen, **fragen**
|
||||||
|
was wohin soll — nicht stillschweigend umsortieren.
|
||||||
|
2. **Nichts löschen ohne Zielort:** TP-spezifisches wandert nach `teilprojekte.md`, Historisches nach
|
||||||
|
`teilprojekte-archiv.md`. Erst wenn der Zielort steht, aus CLAUDE.md entfernen.
|
||||||
|
3. **Zielbild:** CLAUDE.md zurück auf einen **schlanken Index** (VLAN-Modell, Hardware, Tooling, die
|
||||||
|
~10 wirklich querschnittlichen aktiven Fallen). Detail gehört nach `teilprojekte.md`.
|
||||||
|
4. **Abnahme:** CLAUDE.md-Wortzahl/Zeilen vorher/nachher notieren; sicherstellen, dass kein
|
||||||
|
noch-aktiver Gotcha (Firewall, sshpass, cap-wifiN, slaves-datapath, WLAN-Logs-Topic) verloren geht.
|
||||||
|
|
||||||
|
## Übergabeprompt (nächste Session)
|
||||||
|
|
||||||
|
> CLAUDE.md ausdünnen: die Datei ist >58 KB und mischt aktive Fallen mit erledigter Historie
|
||||||
|
> (durchgestrichene „gegenstandslos"-Einträge, „korrigiert"-Notizen neben der falschen Angabe,
|
||||||
|
> Redundanz mit teilprojekte.md). Aufgabe: Bestandsaufnahme + Drift-Tabelle, dann TP-spezifisches
|
||||||
|
> nach teilprojekte.md, Historisches ins Archiv, CLAUDE.md zurück auf einen schlanken Index
|
||||||
|
> (~10 querschnittliche aktive Gotchas behalten). Nicht stillschweigend umsortieren — erst klassifizieren
|
||||||
|
> und fragen. Siehe [[handoff-20260922-claude-md-ausduennen]].
|
||||||
45
docs/handoff-20260922-doku-umbau-vorschlag.md
Normal file
45
docs/handoff-20260922-doku-umbau-vorschlag.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Handoff 2026-09-22 — Doku-/TP-Verwaltung: Umbau-Vorschlag (Basis für eigene Session)
|
||||||
|
|
||||||
|
**Anlass:** Christoph will eine separate Session, die die Doku-/TP-Verwaltung strafft und die
|
||||||
|
Vorschläge selbstständig umsetzt. Diese Notiz ist die Analyse-Basis aus der Ausdünnen-Session
|
||||||
|
(2026-09-22), in der die Drift-Kosten konkret sichtbar wurden. **Vorschläge, kein Mandat.**
|
||||||
|
|
||||||
|
## Diagnose (woran es hakt)
|
||||||
|
|
||||||
|
Drei parallele Wahrheitsquellen driften gegeneinander:
|
||||||
|
|
||||||
|
1. **Gitea-Issue** (Index + Label `status:*`/`thema:*`)
|
||||||
|
2. **`docs/teilprojekte.md`** (Prosa, kuratiert, `### TPnn`-Abschnitte)
|
||||||
|
3. **CLAUDE.md** (Quick-Index/Fokus — bis zur Ausdünnen-Session ein Ist-Stand-Block)
|
||||||
|
|
||||||
|
Jede Status-/Fakten-Änderung muss an 2–3 Stellen nachgezogen werden. Die komplette
|
||||||
|
Ausdünnen-Session plus der TP25-Roll-up (507 Zeilen verschieben, Index-Zeile, zwei
|
||||||
|
„nicht abgeschlossen"-Notizen, Pointer in `teilprojekte.md`) war reine Drift-Bekämpfung —
|
||||||
|
Arbeit, die ein Gitea-Issue-Kommentar gratis ersetzt.
|
||||||
|
|
||||||
|
## Standard vs. Eigenbau (kurz)
|
||||||
|
|
||||||
|
Der Claude-Code-Standard ist schmal: **CLAUDE.md** (kurz, statisch, nur das Nicht-Offensichtliche)
|
||||||
|
+ **git** + **Auto-Memory** + ein Issue-Tracker (Gitea/GitHub/Linear). Ein „TP-System" ist **kein**
|
||||||
|
Standard — die TP-Verwaltung hier ist Eigenbau auf Gitea. Der Teil „Gitea-Issue als Index + Label
|
||||||
|
als Status" ist der richtige, standardnahe Kern.
|
||||||
|
|
||||||
|
## Drei Vorschläge (Reihenfolge = Wert)
|
||||||
|
|
||||||
|
1. **CLAUDE.md = nur statische Konventionen.** Nach der Ausdünnen-Session 28 KB und gut so.
|
||||||
|
Nie wieder „aktueller Stand"/Ist-Stand reinschreiben — der gehört nach Gitea.
|
||||||
|
2. **Status → nur Gitea, Prosa → nur eine Stelle.** Der Kuratier-Aufwand (Index-Tabellen,
|
||||||
|
„Dazu am Ende"-Notizen, Archiv-Roll-ups) kostet pro geschlossenem TP mehr als er wert ist.
|
||||||
|
Kandidat: Prosa/Detail in den Gitea-Issue-Body/Kommentar verlagern, `teilprojekte.md`
|
||||||
|
auf ein append-only Log reduzieren oder ganz durch Issues ersetzen.
|
||||||
|
3. **Mehr git, weniger kuratieren.** `handoff-*.md` pro Session ist gut. Archiv/Roll-up liefert
|
||||||
|
wenig über `git log` + geschlossenes Issue hinaus.
|
||||||
|
|
||||||
|
## Abgrenzung / Tradeoffs
|
||||||
|
|
||||||
|
- Das System hat echten Wert: auf Christoph zugeschnitten, die Drift-Warnungen haben real etwas
|
||||||
|
gefangen. **Nicht ersatzlos streichen.**
|
||||||
|
- Teuerster Teil ist die **zweite, kuratierte Prosa-Ebene** (`teilprojekte.md` + Archiv) — nicht die
|
||||||
|
Gitea-Ebene und nicht CLAUDE.md.
|
||||||
|
- Die separate Session sucht selbstständig Vorschläge UND setzt um; diese Notiz ist die Ausgangslage,
|
||||||
|
kein Plan.
|
||||||
@@ -68,7 +68,7 @@ den *damals geplanten* Werten und ⬜-Status. Sie sind erledigt bzw. gegenstands
|
|||||||
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
|
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
|
||||||
|
|
||||||
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
|
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
|
||||||
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.30` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.31, `nautobot-scheduler`.20.32, `nautobot-redis`.20.34 |
|
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.154` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.155, `nautobot-scheduler`.20.156, `nautobot-redis`.20.158. **TP45 Schicht A 2026-09-21:** Oktett-Klasse `.150–.199` (App-Services) — die alten `.30–.34` gibt Nautobot an AGH `.30`/`.31` frei |
|
||||||
|
|
||||||
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.
|
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
1293
docs/teilprojekte.md
1293
docs/teilprojekte.md
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,12 @@ lokal" — das ist nach dieser Rotation nicht mehr wahr.
|
|||||||
|
|
||||||
## 4. Fußnote mit Verfallsdatum
|
## 4. Fußnote mit Verfallsdatum
|
||||||
|
|
||||||
⏰ **Der `CF_API_TOKEN` selbst läuft am 2027-01-09 ab** (Memory `ref_cf_mcp_readonly_token_writes`).
|
⏰ ~~Der `CF_API_TOKEN` selbst läuft am 2027-01-09 ab~~ — **korrigiert 2026-09-21:** das Datum
|
||||||
|
gehörte zum längst **revokten** Alt-Token `9984078812bc…`; das **live** `CF_API_TOKEN`
|
||||||
|
(id `493ed8bd…`) hat **kein** Ablaufdatum (verifiziert via `/accounts/{id}/tokens/verify`,
|
||||||
|
`expires_on: null`). Die Rotation ist damit nicht mehr termin-, sondern **anlassgetrieben**:
|
||||||
|
**Anlass** ist der **Leak vom 2026-09-21**. Sie bleibt **gebündelt** in TP30 (Posten #4) und wird
|
||||||
|
nicht vorgezogen. Memory `ref_cf_mcp_readonly_token_writes` ist nachgezogen.
|
||||||
Er ist **account-owned**, verifiziert also über einen anderen Endpoint als ein User-Token — wer das
|
Er ist **account-owned**, verifiziert also über einen anderen Endpoint als ein User-Token — wer das
|
||||||
verwechselt, hält ein funktionierendes Token für kaputt. Ohne ihn ist auch diese Rotation nicht mehr
|
verwechselt, hält ein funktionierendes Token für kaputt. Ohne ihn ist auch diese Rotation nicht mehr
|
||||||
möglich, ebensowenig der TP16.3-Schnitt.
|
möglich, ebensowenig der TP16.3-Schnitt.
|
||||||
|
|||||||
78
docs/tp49-cf-access-startauftrag.md
Normal file
78
docs/tp49-cf-access-startauftrag.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Startauftrag — TP49: CF-Access `login_method`-Include ✅ ERLEDIGT (2026-10-06)
|
||||||
|
|
||||||
|
> **Abgeschlossen.** Gitea #76, Prosa in `docs/teilprojekte.md` → `### TP49`.
|
||||||
|
>
|
||||||
|
> 🔴 **Diese Datei nicht mehr als Auftrag lesen — der ist ausgeführt.** Sie bleibt als Nachweis
|
||||||
|
> stehen: Lage, Vorgehen und die Fehlschläge auf dem Weg dorthin.
|
||||||
|
|
||||||
|
## Der Auftrag (erledigt)
|
||||||
|
|
||||||
|
`login_method:github` aus dem `include` der Policy `christoph` der CF-Access-App
|
||||||
|
`420.teilzeitladen.xyz` entfernen — nach Rücksprache, wen der Zugang treffen soll.
|
||||||
|
|
||||||
|
## Ausgeführt (2026-10-06)
|
||||||
|
|
||||||
|
`github` von `include` nach **`require`** verschoben ⇒ **`email AND github`**.
|
||||||
|
|
||||||
|
| | vorher | jetzt |
|
||||||
|
|---|---|---|
|
||||||
|
| `include` | email · **github** · onetimepin | email · onetimepin |
|
||||||
|
| `require` | — | **github** |
|
||||||
|
|
||||||
|
Gesetzt über **`PUT /accounts/{id}/access/policies/{policy_id}`**, zurückgelesen über **beide**
|
||||||
|
Endpunkte (App-Pfad + reusable-Pfad) — identisch. Der `success:true` der API wurde **nicht**
|
||||||
|
geglaubt, sondern die Policy neu gelesen.
|
||||||
|
|
||||||
|
**App-ID** `e6a7991f-466c-4d1d-bac6-4b30fafccce4` · **Policy (reusable)** `8e44f5d1-8257-443e-a797-b3de939952ee`
|
||||||
|
**github-IdP** `72c1291b…` · **onetimepin** `9024c7a6…`
|
||||||
|
|
||||||
|
**Sweep über alle Apps** (`login_method` in include/require/exclude): genau zwei Treffer, beide in
|
||||||
|
`Terpenfinder v3` — `christoph` (github+onetimepin) und `geli` (nur onetimepin, harmlos).
|
||||||
|
Interne Apps/Wildcard: sauber (TP41).
|
||||||
|
|
||||||
|
## 🔴 Gotchas — live bestätigt, alle drei neu am 2026-10-06
|
||||||
|
|
||||||
|
1. **Reusable Policies ändern geht NICHT über den App-Pfad.**
|
||||||
|
`PUT …/access/apps/{app}/policies/{pol}` → **`12130 can not update reusable policies through
|
||||||
|
this endpoint`**. Korrekt: **`PUT …/access/policies/{policy_id}`**.
|
||||||
|
⚠️ Der ältere Merksatz „Reusable Policy nur per App-PUT detachbar" meint das **Detach** vom
|
||||||
|
App-`policies`-Array — **nicht** das Ändern des Inhalts. Zwei verschiedene Operationen.
|
||||||
|
2. **Der `cloudflare`-MCP ist strikt read-only.** Write-Probe: no-op-PUT (byte-identischer Body)
|
||||||
|
→ **`1010: undefined`**. Writes nur via `CF_API_TOKEN`/curl
|
||||||
|
(`cd mcp/cloudflare && set -a; . ./.env; set +a`). Token **nie** ausgeben.
|
||||||
|
3. **Access-App-IDs sind 32 Hex** — Kurzform `16aaec78` → `12045 invalid_application_id`.
|
||||||
|
|
||||||
|
## ⚠️ Was `login_method` nicht kann
|
||||||
|
|
||||||
|
Die ursprüngliche Absicht war eine Kopplung **an den GitHub-Username**. Das gibt Access nicht her:
|
||||||
|
`login_method` prüft die **Methode**, nicht den Nutzer; einen Username-Selector gibt es nicht.
|
||||||
|
Der einzige GitHub-bezogene Identitäts-Selector ist **„Identity provider group"** — der braucht
|
||||||
|
**SCIM**, und am hiesigen GitHub-IdP ist `scim_config.enabled = false`
|
||||||
|
(`GET …/scim/groups` → `500 (12158)`). **Weg zu.**
|
||||||
|
⇒ `email AND github` ist die vollständige Umsetzung des hier Möglichen, keine Notlösung.
|
||||||
|
Eine echte Org-Kopplung bräuchte SCIM aktiviert — **eigener TP**.
|
||||||
|
|
||||||
|
## Aktenhistorie — der Umweg (nicht löschen)
|
||||||
|
|
||||||
|
Zwischenzeitlich wurde TP49 als **Phantom geschlossen** — auf Basis eines **Google**-Tests plus
|
||||||
|
eines **Schlusses** auf GitHub. Beides falsch:
|
||||||
|
|
||||||
|
1. **Lese-Schluss als Befund verkauft.** „`include` ist ein OR" + „Google bindet die Adresse"
|
||||||
|
⇒ „GitHub bindet auch". Nie gemessen, aber als Tatsache in Akte und Issue geschrieben.
|
||||||
|
2. **Der Gegenbeweis war keiner.** Der Google-Test zeigt nur Googles Verhalten. Die IdP-Configs
|
||||||
|
sind konfigurationsgleich — das unterscheidende Feld steht **nicht** in der Config.
|
||||||
|
|
||||||
|
Christoph hat mit einem **zweiten GitHub-Account** getestet: er kam durch. Damit war der
|
||||||
|
Ursprungsfund bestätigt und der Fix nötig.
|
||||||
|
|
||||||
|
**Lehre: pro IdP einzeln messen, nicht den ersten Test auf den zweiten übertragen.**
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- [x] Policy über beide Endpunkte zurückgelesen — `require` trägt github, `include` nicht mehr
|
||||||
|
- [x] Sweep: keine weiteren `login_method` in `include` außer den bekannten
|
||||||
|
- [x] Scope: reusable Policy nur an diese eine App gebunden (kein Kollateral)
|
||||||
|
- [x] **Verhaltensabnahme (2026-10-06, echter Login):** fremder GitHub-Account → **abgewiesen**;
|
||||||
|
eigener Zugang + Geli → funktionieren; alle übrigen Auth-Pfade wie erwartet
|
||||||
|
|
||||||
|
**Status: abgeschlossen.** Gitea #76 `closed` / `status:done`. Kein offener Punkt.
|
||||||
8
docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
Normal file
8
docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
Normal 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
|
||||||
10
docs/transaktionen/TP25-steering-5g-threshold.commands.rsc
Normal file
10
docs/transaktionen/TP25-steering-5g-threshold.commands.rsc
Normal 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
|
||||||
15
docs/transaktionen/TP45-firewall-nautobot-154.commands.rsc
Normal file
15
docs/transaktionen/TP45-firewall-nautobot-154.commands.rsc
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# TP45 Schicht A — Nautobot-Literal in der Core-Firewall von .30 auf .154
|
||||||
|
#
|
||||||
|
# KEINE Transaktion: das ist eine dauerhafte Soll-Aenderung (Nautobot ist umgezogen,
|
||||||
|
# das Soll in Nautobot wurde mitgezogen). Die Datei liegt nur hier, weil
|
||||||
|
# gc-manual-plan.py eine Commands-Datei braucht.
|
||||||
|
#
|
||||||
|
# Kontext: die Regel *24 "TP5-E2: Nautobot->MGMT ssh" ist der LITERAL-Pfad und stand
|
||||||
|
# auf 10.40.20.30. Sie steht bei 0 Paketen — die Arbeit macht die Parallelregel *28
|
||||||
|
# "TP5-E2: Nautobot->MGMT ssh (list)" ueber src-address-list=nautobot (113 Pakete,
|
||||||
|
# Liste am 2026-09-21 um .154/.155/.156 erweitert). Der Literal ist damit
|
||||||
|
# funktional redundant, wird aber auf .154 gezogen, damit Soll (Nautobot-Daten
|
||||||
|
# `firewall_filter`) und Ist nicht auseinanderlaufen.
|
||||||
|
#
|
||||||
|
# Rollback: siehe TP45-firewall-nautobot-154.rollback.rsc
|
||||||
|
/ip firewall filter set [find comment="TP5-E2: Nautobot->MGMT ssh"] src-address=10.40.20.154
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Rueckbau TP45 Schicht A: Nautobot-Literal zurueck auf .30
|
||||||
|
# (nur noetig, wenn Nautobot wieder auf .30 zurueckzieht — die Adressliste ist die
|
||||||
|
# wirksame Ebene, dieser Literal ist der tote Parallelpfad.)
|
||||||
|
/ip firewall filter set [find comment="TP5-E2: Nautobot->MGMT ssh"] src-address=10.40.20.30
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Ist-Stand der Regel VOR dem TP45-Schicht-A-Deploy (2026-09-21, gelesen per MikroMCP).
|
||||||
|
/ip firewall filter
|
||||||
|
add action=accept chain=forward comment="TP5-E2: Nautobot->MGMT ssh" dst-port=22 out-interface=vlan99 protocol=tcp src-address=10.40.20.30
|
||||||
123
docs/warp-egress-native-crs326.md
Normal file
123
docs/warp-egress-native-crs326.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# WARP-Egress — nativer Consumer-WARP (WireGuard) auf dem crs326-core
|
||||||
|
|
||||||
|
> **Status:** ❌ **VERWORFEN 2026-09-26** — Cloudflare blockt inoffizielle Consumer-WARP-WireGuard-Clients.
|
||||||
|
> Live gebaut, verifiziert, dann vollständig zurückgebaut. Key + Backup bleiben zur Nachvollziehbarkeit liegen.
|
||||||
|
>
|
||||||
|
> ➡️ **Nachtrag §6 (selber Tag, zweiter Durchgang):** Der Mesh-Tunnel `cf-mesh` **trägt** als
|
||||||
|
> ziel-basierter Ausgang **und erreicht den Origin mit `200`** (Core kommt als `104.28.207.39`, `warp=on`,
|
||||||
|
> `colo=VIE` heraus — dieselbe IP wie der WARP-Client). **Lösung ist dauerhaft** über Nautobot/Golden Config
|
||||||
|
> aktiv (Template `nautobot/mikrotik_routeros.j2`, Abschnitt `[10b]`). Der frühere „Origin blockt CF-IPs"-
|
||||||
|
> Befund war ein Messfehler — korrigiert in §6.3.
|
||||||
|
|
||||||
|
## 1. Kontext / Warum
|
||||||
|
|
||||||
|
- Cannaleo-Origin `164.90.191.7` (blaueblueten.de, DigitalOcean multi-tenant, ~400 Vhosts) blockt die Heim-IP
|
||||||
|
`90.146.33.177` hart (leerer `403`, live 2026-09-25).
|
||||||
|
- **Scope (User-Vorgabe 2026-09-25):** nicht der ganze Outbound, sondern **genau eine Ziel-IP**
|
||||||
|
(`164.90.191.7/32` — blaueblueten.de apex + www, beide identisch). Nur **Privatbestellungen**, Traffic
|
||||||
|
überschaubar gering.
|
||||||
|
- **Terpenfinder wird NICHT über diesen Weg** in Betrieb genommen (eigene, saubere Lösung — siehe §5).
|
||||||
|
|
||||||
|
## 2. Was gebaut wurde (2026-09-26, live)
|
||||||
|
|
||||||
|
1. **Consumer-WARP-Key** per `wgcf` registriert (ViRb3 v2.3.0, Wegwerf-Konto via `api.cloudflareclient.com`).
|
||||||
|
→ `mcp/cloudflare/.env` (`CF_WARP_CLIENT_PRIVKEY`, gitignored) + `~/.secrets/wgcf-client/` (account + profil).
|
||||||
|
2. Auf `crs326-core` (WG-Zeilen via `sshpass -e`, Rest per MikroMCP/ssh):
|
||||||
|
- `/interface wireguard add name=warpout mtu=1280 private-key=… comment="WARP Egress (privat)"`
|
||||||
|
- Peer `warp-cf-pop`: public-key `bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=`,
|
||||||
|
endpoint `engage.cloudflareclient.com:2408`, `allowed-address=0.0.0.0/0`, `persistent-keepalive=25s`
|
||||||
|
- `/ip address add 172.16.0.2/32 interface=warpout`
|
||||||
|
- `/routing table add name=warpout fib` + `/ip route add 0.0.0.0/0 gateway=warpout routing-table=warpout`
|
||||||
|
- `/routing rule add dst-address=164.90.191.7/32 action=lookup-only-in-table table=warpout`
|
||||||
|
- NAT `masquerade out-interface=warpout` + forward `accept out-interface=warpout`
|
||||||
|
3. **Verifiziert:** Key korrekt (abgeleiteter Public-Key == Router-Public-Key
|
||||||
|
`HTFM3gj3FyT0EjWkG+2LLmvtxaFz1kH/P1iDwM0HswI=`), Konto „Active" (`wgcf status`).
|
||||||
|
|
||||||
|
## 3. Befund: Handshake schlägt fehl
|
||||||
|
|
||||||
|
- Peer `warp-cf-pop`: `rx=0`, kein `last-handshake` — Cloudflare antwortet **nie** auf den WG-Handshake,
|
||||||
|
egal welcher Endpoint (`162.159.192.1` / `.193.2` / `.193.5`).
|
||||||
|
- **Ursache (recherchiert):** Cloudflare blockt **inoffizielle WireGuard-Clients** für Consumer-WARP. Der
|
||||||
|
offizielle Client setzt ein spezielles Handshake-Feld (`reserved`/Client-ID, z. B. `b91981`), das RouterOS
|
||||||
|
(und jedes plain-wgcf-Profil) nicht setzt; dazu Abuse-Prevention. Der Mesh-Connector `cf-mesh` läuft nur,
|
||||||
|
weil das ein **anderes Produkt** ist (Zero Trust, nicht Consumer).
|
||||||
|
- Quellen: [ViRb3/wgcf Issue #542](https://github.com/ViRb3/wgcf/issues/542) ·
|
||||||
|
[#515](https://github.com/ViRb3/wgcf/issues/515) · [#158](https://github.com/ViRb3/wgcf/issues/158).
|
||||||
|
|
||||||
|
## 4. Abbau (2026-09-26)
|
||||||
|
|
||||||
|
- Alle 8 Objekte entfernt (Routing-Regel, Route, Tabelle, NAT, Firewall, Adresse, Peer, Interface). Core
|
||||||
|
wieder im Ursprungszustand — **keine Drift**, Golden-Config-Compliance unverändert.
|
||||||
|
- Backup `pre-warp-egress-20260925.backup` liegt auf dem Core (vor dem Versuch, TP22-Regel).
|
||||||
|
- `CF_WARP_CLIENT_PRIVKEY` bleibt in `mcp/cloudflare/.env` (nutzlos, da Client geblockt; kann bei Bedarf entfernt werden).
|
||||||
|
|
||||||
|
## 5. Weg vorwärts
|
||||||
|
|
||||||
|
> ⚠️ **Durch §6 überholt (2026-09-26).** Die Grundannahme hier — „geteilte CF-IPs werden geblockt, WARP
|
||||||
|
> liefert 403" — war **falsch** und ist in §6.3 korrigiert. WARP-Client und `cf-mesh`-Ausgang erreichen den
|
||||||
|
> Origin beide mit `200`. Eine Dedicated Egress IP ist damit nicht nötig.
|
||||||
|
|
||||||
|
- **Bleibt gültig:** Consumer-WARP über natives RouterOS-WG ist auf absehbare Zeit blockiert (separates
|
||||||
|
Produkt, betrifft nicht `cf-mesh`).
|
||||||
|
|
||||||
|
## 6. Nachtrag 2026-09-26: Mesh-Exit trägt und erreicht den Origin (200) — Lösung dauerhaft
|
||||||
|
|
||||||
|
**Frage:** Kann der bereits laufende `cf-mesh`-Tunnel (TP16.1) als *Ziel-basierter Ausgang* dienen, statt eines
|
||||||
|
separaten Consumer-WARP? **Antwort: ja — und er erreicht den Origin mit `200`.** Die Lösung ist dauerhaft über
|
||||||
|
Nautobot/Golden Config aktiv (Template-Abschnitt `[10b]`).
|
||||||
|
|
||||||
|
### 6.1 Der Origin-Weg ist jetzt vermessen (vorher nur angenommen)
|
||||||
|
|
||||||
|
`blaueblueten.de` geht **direkt** zum DO-Droplet, **kein Cloudflare im Pfad** (Zertifikat = Let's Encrypt
|
||||||
|
`CN=blaueblueten.de` vom 11.08., **kein `cf-ray`**, kein `server`-Header, kein `/cdn-cgi/trace` — CF setzt
|
||||||
|
`cf-ray` immer). Der 403 kommt also **vom Origin**, nicht vom Edge.
|
||||||
|
|
||||||
|
**Der Block ist rein quell-IP-basiert:** voller Safari-/Chrome-User-Agent ändert nichts, HTTP→HTTPS-Redirect
|
||||||
|
(`302`) funktioniert, erst der HTTPS-Request antwortet `403` mit `content-length: 0`.
|
||||||
|
|
||||||
|
### 6.2 Mesh als Exit — funktioniert
|
||||||
|
|
||||||
|
Temporär gebaut (Tabelle `meshtest` + `0.0.0.0/0` via `cf-mesh` + Regel nur auf `1.1.1.1/32`), dann gemessen:
|
||||||
|
|
||||||
|
```
|
||||||
|
/tool fetch url="http://1.1.1.1/cdn-cgi/trace" mode=http
|
||||||
|
→ ip=104.28.207.39 warp=on gateway=on colo=VIE
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Core kommt also über den Mesh-Tunnel als **Cloudflare-IP** heraus (vorher `90.146.33.177`).
|
||||||
|
⚠️ **Merkregel: für `cdn-cgi/trace` `mode=http` nehmen, nicht `mode=https`** — über HTTPS liefert RouterOS nur
|
||||||
|
den 69-Byte-Datei-Header (`# … by RouterOS`, `# software id =`), die Nutzdaten fehlen. Das sieht wie ein
|
||||||
|
leerer Download aus, ist aber nur der falsche Fetch-Modus.
|
||||||
|
|
||||||
|
### 6.3 KORREKTUR: der frühere „403 über Mesh" war ein Messfehler — der Weg trägt
|
||||||
|
|
||||||
|
Der erste Test hatte **nur** die Regel `1.1.1.1/32 → meshtest` gesetzt und dann das Ziel gemessen. Für das
|
||||||
|
Ziel griff die Regel nicht → der Request lief über die **Heim-IP** (`90.146.33.177`) raus. Der gemeldete
|
||||||
|
`403` war also **403 über die Heim-IP**, nicht über den Mesh-Ausgang.
|
||||||
|
|
||||||
|
Beweis (A/B in einem Durchgang, Regel `164.90.191.7/32 → egress-cannaleo`):
|
||||||
|
|
||||||
|
```
|
||||||
|
Regel aktiv → https://blaueblueten.de/ → 200 (168 KiB)
|
||||||
|
Regel deaktiv → https://blaueblueten.de/ → 403 (fällt auf Heim-IP zurück)
|
||||||
|
```
|
||||||
|
|
||||||
|
Merke: `action=lookup-only-in-table` für Ziel A routet Ziel B **nicht** — nur messen, was tatsächlich
|
||||||
|
umgeleitet ist. Dieselbe CF-IP (`104.28.207.39`) erreicht den Origin via WARP-Client und via `cf-mesh`.
|
||||||
|
|
||||||
|
### 6.4 Rückbau (vollständig)
|
||||||
|
|
||||||
|
Alle Objekte entfernt (Regel, Route, Tabelle, Testdateien). `/routing table print` = nur `main`,
|
||||||
|
`/routing rule print` = leer, Default-Route wieder `90.146.33.1%vlan100`. `cf-mesh` unberührt und gesund
|
||||||
|
(Handshake 4 s). Keine Drift.
|
||||||
|
|
||||||
|
### 6.5 Was daraus folgt
|
||||||
|
|
||||||
|
- **Der Core-Teil ist gelöst und wiederverwendbar:** `routing-table` + `0.0.0.0/0`-Route + `routing rule
|
||||||
|
dst-address=<ziel>/32 action=lookup-only-in-table` + `masquerade out-interface=<wg-x>`. Nur **ein** Ziel
|
||||||
|
betroffen, Rest unberührt. Genau so gebaut, verifiziert, zurückgebaut.
|
||||||
|
- **Offen bleibt allein das Transportmittel.** CF scheidet aus (Ziel blockt CF). Der Rest des Vorschlags aus
|
||||||
|
§5 steht: eigener Ausgang über einen **externen WireGuard-Host**. Ein DO-VPS ist dabei der naheliegendste
|
||||||
|
Kandidat — der Miner fährt heute schon erfolgreich über eine DO-IP gegen genau diesen Origin.
|
||||||
|
- 🔴 **Der Core hat heute keinen zweiten Ausgang.** Kein zweites WAN, kein zweites Gateway — jeder künftige
|
||||||
|
Egress braucht erst ein Interface, das ihn trägt.
|
||||||
@@ -52,15 +52,19 @@ BENUTZUNG
|
|||||||
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
|
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
|
|
||||||
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
||||||
@@ -160,8 +164,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
|
|||||||
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
||||||
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
||||||
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
||||||
|
|
||||||
|
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
|
||||||
|
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
|
||||||
|
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
|
||||||
|
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
|
||||||
|
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
|
||||||
|
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
|
||||||
|
def created(p):
|
||||||
|
raw = str(p.get("created") or "")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
except ValueError:
|
||||||
|
return datetime.min
|
||||||
|
|
||||||
by_name = {}
|
by_name = {}
|
||||||
for p in found:
|
for p in sorted(found, key=created):
|
||||||
name = (p.get("device") or {}).get("display")
|
name = (p.get("device") or {}).get("display")
|
||||||
by_name[name] = p["id"]
|
by_name[name] = p["id"]
|
||||||
|
|
||||||
@@ -289,7 +307,7 @@ def job_mgmt_acl():
|
|||||||
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
|
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
|
||||||
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
|
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
|
||||||
|
|
||||||
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.30) aus, also aus
|
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
|
||||||
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
|
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
|
||||||
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
|
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
|
||||||
|
|
||||||
@@ -353,10 +371,602 @@ def job_tp42_dhcp_domain():
|
|||||||
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp38_oob():
|
||||||
|
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
|
||||||
|
|
||||||
|
Zwei Geraete, zwei config_sets:
|
||||||
|
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
|
||||||
|
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
|
||||||
|
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
|
||||||
|
die laufende Nautobot-SSH-Session (10.40.20.154) matcht auf Regel 1 (established),
|
||||||
|
bevor Regel 8 (drop) greift.
|
||||||
|
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
|
||||||
|
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
|
||||||
|
waere wirkungslos + non-compliant).
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
|
||||||
|
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
|
||||||
|
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
|
||||||
|
"""
|
||||||
|
bridge_set = (
|
||||||
|
':do { /interface bridge port set [find interface=ether23] pvid=99 '
|
||||||
|
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
|
||||||
|
'on-error={ :log info "tp38: ether23 set geworfen" }'
|
||||||
|
)
|
||||||
|
vlan_set = (
|
||||||
|
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
|
||||||
|
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_fw = "\n".join([
|
||||||
|
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||||
|
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||||
|
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||||
|
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||||
|
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||||
|
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||||
|
])
|
||||||
|
branch_set = "\n".join([
|
||||||
|
bridge_set,
|
||||||
|
vlan_set,
|
||||||
|
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
|
||||||
|
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||||
|
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
|
||||||
|
])
|
||||||
|
|
||||||
|
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
|
||||||
|
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
|
||||||
|
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
|
||||||
|
core_set = "\n".join([
|
||||||
|
bridge_set,
|
||||||
|
vlan_set,
|
||||||
|
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
|
||||||
|
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
|
||||||
|
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
|
||||||
|
'destination=[find where comment="TP5: input log-only"]',
|
||||||
|
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||||
|
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
(["crs326-branch"], branch_set, "TP38-oob-branch"),
|
||||||
|
(["crs326-core"], core_set, "TP38-oob-core"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_agh_fw():
|
||||||
|
"""TP45 Schicht A (2026-09-21): DNS-Freigaben fuer die neuen Resolver-Adressen .30/.31.
|
||||||
|
|
||||||
|
Der Resolver zieht von .36/.37 auf .30/.31 (TP32-Oktett-Klasse DNS `.3x`). Damit die
|
||||||
|
Umstellung ohne Ausfall geht, laufen beide Adressen kurz parallel — also braucht der
|
||||||
|
Core **beide** Freigaben. Diese hier ist ADDITIV: die vier .36/.37-Regeln bleiben
|
||||||
|
stehen und fallen erst beim Rueckbau (eigener Schritt, wenn die Zaehler stillstehen).
|
||||||
|
|
||||||
|
Die vier Regeln sind ein Literal im Soll (`local_config_context_data.firewall_filter`,
|
||||||
|
gepflegt mit `nautobot/tp45-agh-firewall.py`) — der Job zieht nur das Geraet nach.
|
||||||
|
|
||||||
|
🔴 `place-before` wird ueber Netmiko IGNORIERT (Lehre aus TP38, 2026-09-15): die Regel
|
||||||
|
landet am Ketten-Ende, also HINTER dem finalen `drop` und waere wirkungslos, waehrend
|
||||||
|
der Plan trotzdem „Completed" meldet. Deshalb `add` (appends) und danach `move` vor
|
||||||
|
die Log-Regel — in der Reihenfolge, in der die Regeln stehen sollen (jedes `move`
|
||||||
|
schiebt seine Regel unmittelbar vor das Ziel, also hinter die zuvor verschobenen).
|
||||||
|
|
||||||
|
🔴 **Zweite Falle, am 2026-09-21 beim ersten Lauf aufgeschlagen:** nicht der `move`
|
||||||
|
war falsch, sondern der **Selektor** — `find where dst-address=<bare-IP>` matcht nie
|
||||||
|
(Details und Tragweite in der Docstring von `sel()` unten). Der Plan meldete
|
||||||
|
trotzdem „Completed", die Regeln lagen hinter dem `drop`. Deshalb ist die Abnahme
|
||||||
|
hier **nicht** der Plan-Status, sondern die Position am Geraet.
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: die Regeln erweitern nur `accept` in der forward-Kette; der
|
||||||
|
Mgmt-Pfad (Nautobot-SSH -> vlan99) ist von der Aenderung nicht beruehrt. Der Deploy
|
||||||
|
laeuft von 10.40.20.154, das auf Regel 22 (established) matcht.
|
||||||
|
"""
|
||||||
|
ips = ["10.40.20.30", "10.40.20.31"]
|
||||||
|
log = 'TP5: log-only, Vorstufe zum deny-by-default'
|
||||||
|
|
||||||
|
def sel(cmt, ip):
|
||||||
|
"""Selektor auf genau eine Regel.
|
||||||
|
|
||||||
|
🔴 **`dst-address=<bare-IP>` matcht in `find where` NIE** — live am Core
|
||||||
|
gemessen (2026-09-21): `find where dst-address=10.40.20.30` → **0 Treffer**,
|
||||||
|
auch mit `/32`; `dst-address~"10.40.20.30"` (Regex) → 2. Deshalb steht hier
|
||||||
|
`~`, nicht `=`.
|
||||||
|
|
||||||
|
⚠️ Die Tragweite ist größer als dieser Job: **eine Prüfung, die 0 meldet,
|
||||||
|
sieht wie „nicht vorhanden" aus und nicht wie „kaputt".** Genau daran ist
|
||||||
|
der erste Lauf dieses Jobs gescheitert — der `add` lief (der Guard sah
|
||||||
|
„nicht vorhanden"), der `move` lief ins Leere, und der Plan meldete
|
||||||
|
trotzdem „Completed". Dieselbe Zeile stand als *Verifikation* in
|
||||||
|
`docs/transaktionen/TP23-agh-firewall.commands.rsc:7` und hat dort seit
|
||||||
|
dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu prüfen.
|
||||||
|
Gleiche Klasse wie `nc -z` (2026-09-01) und das `sed`-Maskierungsmuster:
|
||||||
|
**erst den Selektor verdächtigen, dann das Gerät.**
|
||||||
|
"""
|
||||||
|
return f'comment="{cmt}" dst-address~"{ip}"'
|
||||||
|
|
||||||
|
def rule(ip, proto, cmt):
|
||||||
|
return (f'/ip firewall filter add action=accept chain=forward comment="{cmt}" '
|
||||||
|
f'dst-address={ip} dst-port=53 protocol={proto}')
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for ip in ips:
|
||||||
|
# Guard + move getrennt: nur der Guard ist idempotent (ein zweiter Lauf
|
||||||
|
# verdoppelt die Regeln nicht), der move läuft immer — er ist auch der
|
||||||
|
# Reparaturweg, wenn eine Regel schon existiert, aber falsch einsortiert ist.
|
||||||
|
for proto, cmt in (("udp", "TP5-E2: LAN->AGH dns"), ("tcp", "TP5-E2: LAN->AGH dns-tcp")):
|
||||||
|
lines.append(f':if ([:len [/ip firewall filter find where {sel(cmt, ip)}]] = 0) '
|
||||||
|
f'do={{ {rule(ip, proto, cmt)} }}')
|
||||||
|
for ip in ips:
|
||||||
|
for cmt in ("TP5-E2: LAN->AGH dns", "TP5-E2: LAN->AGH dns-tcp"):
|
||||||
|
lines.append(f'/ip firewall filter move [find where {sel(cmt, ip)}] '
|
||||||
|
f'destination=[find where comment="{log}"]')
|
||||||
|
# Der Anker ist bewusst die Log-Regel (Baseline, stabil) und nicht `caddy`: haengt
|
||||||
|
# sie unmittelbar vor der Log-Regel, landen die vier Regeln DAHINTER -- der AGH-Block
|
||||||
|
# bleibt so zusammen, wie das Soll ihn fuehrt. Waehlte man `caddy` als Ziel und die
|
||||||
|
# Regel fehlte, laegen die vier hinter dem `drop` = funktional kaputt; so bleibt im
|
||||||
|
# Fehlerfall nur eine Reihenfolge-Abweichung (Compliance-Drift, kein Ausfall).
|
||||||
|
lines.append(f'/ip firewall filter move [find where comment="TP31: AP-cloudflared -> caddy-bf1"] '
|
||||||
|
f'destination=[find where comment="{log}"]')
|
||||||
|
# ⚠️ Diese Zeile meldet NUR Anwesenheit (je IP 2 Regeln erwartet), NICHT die
|
||||||
|
# Position -- sie kann also gruen aussehen, waehrend die Regeln hinter dem `drop`
|
||||||
|
# liegen. Genau daran ist der erste Lauf gescheitert. **Die Abnahme ist der
|
||||||
|
# Geraete-Read nach dem Deploy** (`print terse proplist=comment,packets`).
|
||||||
|
lines.append(
|
||||||
|
':log info ("tp45-agh-fw: .30=" . '
|
||||||
|
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.30"]]] . " .31=" . '
|
||||||
|
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.31"]]] . " log=" . '
|
||||||
|
f'[:tostr [:len [/ip firewall filter find where comment="{log}"]]])')
|
||||||
|
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-agh-firewall")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_agh_dhcp():
|
||||||
|
"""TP45 Schicht A (2026-09-21): DHCP-DNS-Server der 7 Netze .36,.37 -> .30,.31.
|
||||||
|
|
||||||
|
Der Core verteilt in ALLEN sieben Netzen `dns-server=10.40.20.36,10.40.20.37`
|
||||||
|
(live geprueft 2026-09-21). Das Template (Z.137) ist auf .30,.31 gehoben; dieser Job
|
||||||
|
zieht das laufende Geraet nach.
|
||||||
|
|
||||||
|
MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der
|
||||||
|
intended config (die wuerfe `already have such address`). `[find]` ohne Filter
|
||||||
|
trifft alle 7 Netze. Gleiche Form wie `job_tp42_dhcp_domain`.
|
||||||
|
|
||||||
|
⚠️ **Reihenfolge zwingend:** erst `tp45-agh-fw` deployen. Sonst liefert DHCP Adressen
|
||||||
|
aus, die die Firewall noch nicht durchlaesst — unter deny-by-default faellt DNS aus
|
||||||
|
allen VLANs aus.
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: eine DHCP-Option beruehrt weder Routing noch Mgmt-Zugang;
|
||||||
|
Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d, GUEST 12h).
|
||||||
|
"""
|
||||||
|
core_set = "\n".join([
|
||||||
|
':do { /ip dhcp-server network set [find] dns-server=10.40.20.30,10.40.20.31 } '
|
||||||
|
'on-error={ :log info "tp45-agh-dhcp: set geworfen" }',
|
||||||
|
':log info ("tp45-agh-dhcp: auf-neu=" . [:tostr [:len [/ip dhcp-server network '
|
||||||
|
'find where dns-server~"10.40.20.30"]]] . "/7")',
|
||||||
|
])
|
||||||
|
return [(["crs326-core"], core_set, "TP45-agh-dhcp")]
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_set(vlan, ranges):
|
||||||
|
"""Zeilen, die `pool-v<vlan>` auf `ranges` ziehen -- mit gezaehltem Selektor.
|
||||||
|
|
||||||
|
MODIFY, kein add: der Pool existiert, die `add`-Zeile der intended config
|
||||||
|
wuerfe `already have such name`. Gleiche Form wie `job_tp45_agh_dhcp`.
|
||||||
|
|
||||||
|
🔴 Der Selektor wird GEZAEHLT, nicht geglaubt (Lehre vom 2026-09-21): ein
|
||||||
|
`find where`, das 0 trifft, sieht wie „nicht vorhanden" aus und nicht wie
|
||||||
|
„kaputt" -- ein blankes `set [find where name=…]` liefe ins Leere und der
|
||||||
|
Job meldete trotzdem Erfolg (dieselbe Klasse wie `dst-address=<bare IP>`
|
||||||
|
in TP23-agh-firewall.commands.rsc:7, das seit dem 08-25 immer 0 meldete).
|
||||||
|
|
||||||
|
🔴🔴 **JEDES Statement muss auf EINER Zeile klammer-vollstaendig sein.**
|
||||||
|
Erste Fassung hatte `:if (...) do={` am Zeilenende -- RouterOS geht dann in
|
||||||
|
den Fortsetzungs-Modus und zeigt einen ANDEREN Prompt, der netmiko-Treiber
|
||||||
|
verliert sein Muster und der Deploy stirbt mit
|
||||||
|
`E1015: Pattern not detected: (redacted)@crs326\\-core\\].*$|#.*$)`.
|
||||||
|
Der Plan stand danach auf `Failed`, **ohne** etwas geschrieben zu haben.
|
||||||
|
Deshalb ist jede Zeile hier ein vollstaendiges `… do={ …; … } else={ … }`.
|
||||||
|
"""
|
||||||
|
pool = f"pool-v{vlan}"
|
||||||
|
return "\n".join([
|
||||||
|
f':if ([:len [/ip pool find where name="{pool}"]] = 1) do={{ '
|
||||||
|
f'/ip pool set [find where name="{pool}"] ranges={ranges}; '
|
||||||
|
f':log info ("tp45-pool: {pool} -> " . [/ip pool get [find where name="{pool}"] ranges]) }} '
|
||||||
|
f'else={{ :log info ("tp45-pool: ABBRUCH, {pool} nicht eindeutig") }}',
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_pool_v20():
|
||||||
|
"""TP45 (2026-09-21): pool-v20 von .100-.200 auf .200-.249 verengen.
|
||||||
|
|
||||||
|
Damit werden die Schema-Baender .100-.149 (Server-Hosts) und .150-.199
|
||||||
|
(App-Services) in VLAN20 echt frei. VLAN20 traegt KEINE DHCP-Reservierung
|
||||||
|
im alten Fenster (live geprueft: nur `nas-bf .20.10` in .10x) und 0
|
||||||
|
dynamische Leases -- die Verengung kostet hier nichts.
|
||||||
|
|
||||||
|
🔴 Warum ueberhaupt: `nautobot .154-.158`, `gitea .157`, `pve-mcp .173` und
|
||||||
|
`shelly-mcp .180` tragen ihre IP GERAETELOKAL und stehen in KEINEM
|
||||||
|
Lease-Eintrag -- der Core kann sie nicht schuetzen und einem DHCP-Client
|
||||||
|
.154 anbieten. Die Verengung ist hier sofort wirksam.
|
||||||
|
|
||||||
|
⚠️ VLAN30 gehoert NICHT hierher: dort liegen 35 statische Reservierungen IM
|
||||||
|
Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot .227, Twinkly .228,
|
||||||
|
Withings .230/.231, lichterketten .236/.237, Nuki .241/.244, CCU .232).
|
||||||
|
Erst Schicht B zieht die um, DANN pool-v30.
|
||||||
|
"""
|
||||||
|
return [(["crs326-core"], _pool_set(20, "10.40.20.200-10.40.20.249"), "TP45-pool-v20")]
|
||||||
|
|
||||||
|
|
||||||
|
def _tp45_leases_module():
|
||||||
|
"""MOVES/REMOVE/ADDS aus `tp45-leases.py` laden.
|
||||||
|
|
||||||
|
🔴 **NICHT kopieren.** Diese Tabellen sind die SSoT des Umzugs. Eine zweite
|
||||||
|
Kopie hier liefe beim ersten Edit auseinander — und zwar still, weil beide
|
||||||
|
Seiten fuer sich plausibel aussehen; am Geraet faellt nur auf, dass eine
|
||||||
|
Adresse fehlt.
|
||||||
|
|
||||||
|
Der Dateiname traegt einen Bindestrich (Repo-Konvention fuer Skripte), ist
|
||||||
|
also nicht regulaer importierbar -> importlib ueber den Pfad. `__name__` ist
|
||||||
|
dann nicht `__main__`, das `main()`-Guard der Datei greift also nicht.
|
||||||
|
"""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"tp45_leases", pathlib.Path(__file__).with_name("tp45-leases.py"))
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_move(mac, soll, tag="tp45-v30"):
|
||||||
|
"""Eine Umzugs-Zeile — EINZEILIG und klammer-vollstaendig (E1015-Lehre, s. _pool_set).
|
||||||
|
|
||||||
|
Der Selektor wird GEZAEHLT: ein `find where`, das 0 trifft, sieht wie
|
||||||
|
„nicht vorhanden" aus und nicht wie „kaputt", und ein blankes `set [find …]`
|
||||||
|
liefe ins Leere, waehrend der Job Erfolg meldet.
|
||||||
|
|
||||||
|
Absichtlich OHNE `address=<ist>` im Selektor: ein zweiter Lauf muss ein
|
||||||
|
No-Op sein, kein Fehler. Die Ist-Adresse wird stattdessen VOR dem Deploy
|
||||||
|
geprueft — `tp45-leases.py move` verlangt, dass das Soll genau `ist` traegt
|
||||||
|
und bricht sonst ab.
|
||||||
|
|
||||||
|
🔴🔴 **ZWEI VERSCHIEDENE Selektor-Formen, und das ist keine Kosmetik.**
|
||||||
|
Die erste Fassung schrieb ueberall die Argument-Form `[… [find where …]]`.
|
||||||
|
Die ist **Syntaxfehler**, sobald sie in `[:len …]` steht:
|
||||||
|
`expected command name (line 1 column 55)` (live gemessen 2026-09-21)
|
||||||
|
Der Deploy lief trotzdem als `Completed` durch, das Geraet blieb
|
||||||
|
**unveraendert**, und im Log stand nur die Schlusszeile — von den 38
|
||||||
|
`:if`-Statements hat **kein einziger** Zweig geloggt, also ist auch kein
|
||||||
|
`set` gelaufen.
|
||||||
|
|
||||||
|
* in `[:len …]` und direkt vor `find`: **`/ip dhcp-server lease find where …`**
|
||||||
|
(kein Klammerpaar um `find`) — Muster aus `_pool_set`/`job_tp45_agh_dhcp`
|
||||||
|
* als Argument einer Vollpfad-Kommandozeile: `set [find where …]`
|
||||||
|
— diese Form ist in `_pool_set` bewiesen und bleibt.
|
||||||
|
|
||||||
|
⚠️ **Merkregel:** `Completed` am Plan und `SUCCESS` am Job beweisen hier
|
||||||
|
nichts. Der Beweis ist die **Zahl der Log-Zeilen** (jeder Zweig loggt) oder
|
||||||
|
ein Geraete-Read. Beides gehoert zur Abnahme — genau wie beim
|
||||||
|
`pool-v20`-Deploy, der ohne Gegenprobe als gelungen gegolten haette.
|
||||||
|
"""
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
return (f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease set {arg} address={soll}; '
|
||||||
|
f':log info ("{tag}: {mac} -> {soll}") }} '
|
||||||
|
f'else={{ :log info ("{tag}: {mac} NICHT EINDEUTIG -- nicht angefasst") }}')
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_v30_leases():
|
||||||
|
"""TP45 Schicht B2 (2026-09-21): die VLAN30-Reservierungen ins Oktett-Schema.
|
||||||
|
|
||||||
|
36 Reservierungen wandern aus dem DHCP-Pool-Fenster (.202-.244, .5, .8) in die
|
||||||
|
Schema-Baender: `.11-.14` fest verbaute Infrastruktur, `.151-.176` Shelly-Block,
|
||||||
|
`.177-.196` App-Services. Dazu zwei Sonderfaelle:
|
||||||
|
|
||||||
|
* `pve-homematic-raspi-ccu` (MAC BC:24:11:8E:0A:57) wird ENTFERNT — eine
|
||||||
|
Leiche aus dem Core-Cutover (`status=waiting`, `last-seen=never`). Die CCU
|
||||||
|
ist live in VLAN20 (.20.232, ARP + HTTP 200 belegt, Caddys `ccu.bf` proxyt
|
||||||
|
dorthin); die VLAN30-Reservierung ist ein Zombie.
|
||||||
|
* `SONOFF-NSPanelPro` hat GAR KEINE Reservierung, nur eine dynamische Lease
|
||||||
|
auf `.249`. Wird per `make-static` gepinnt und auf `.12` gezogen.
|
||||||
|
|
||||||
|
⚠️ **Reihenfolge:** dieser Job gehoert VOR `tp45-pool-v30`. Die Verengung auf
|
||||||
|
`.200-.249` setzt voraus, dass keine Reservierung mehr im Schema-Band liegt —
|
||||||
|
sonst zieht der Pool Adressen, die schon vergeben sind.
|
||||||
|
|
||||||
|
⚠️ **Kein Ausfall, kein Lockout:** ueber DHCP-Reservierungen umzuziehen heisst,
|
||||||
|
dass die Geraete beim naechsten Lease-Renew selbst nachziehen (lease-time 1d).
|
||||||
|
Nur zwei der 36 sind im Moment ueberhaupt `bound` (SwitchBot, DTUBI); alle
|
||||||
|
anderen stehen auf `last-seen=never`. Nichts wird hart umgeschaltet.
|
||||||
|
|
||||||
|
⚠️ **`.150` (Ender3V3KE) und `.180` (espressif/Airfryer) bleiben, wo sie sind** —
|
||||||
|
sie liegen bereits im App-Band und werden bewusst nicht bewegt.
|
||||||
|
|
||||||
|
Rueckweg: `MOVES` traegt Ist UND Soll je MAC, das inverse `set` ist also
|
||||||
|
mechanisch; zusaetzlich liegt vor dem Deploy ein binaeres Backup auf dem Core.
|
||||||
|
"""
|
||||||
|
mod = _tp45_leases_module()
|
||||||
|
lines = [_lease_move(mac, soll) for mac, (_ist, soll) in sorted(mod.MOVES.items())]
|
||||||
|
|
||||||
|
for mac in sorted(mod.REMOVE):
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease remove {arg}; '
|
||||||
|
f':log info ("tp45-v30: CCU-Leiche {mac} entfernt") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30: CCU-Leiche schon weg") }}')
|
||||||
|
|
||||||
|
for add in mod.ADDS:
|
||||||
|
mac = re.search(r"mac-address=(\S+)", add).group(1)
|
||||||
|
ip = re.search(r"address=(\S+)", add).group(1)
|
||||||
|
cmt = re.search(r"comment=(\S+)", add).group(1)
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
# make-static VOR set: auf einer dynamischen Lease ist `set address=` wirkungslos
|
||||||
|
# bzw. wird beim naechsten Renew ohnehin verworfen -- erst statisch, dann pinnen.
|
||||||
|
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease make-static {arg}; '
|
||||||
|
f'/ip dhcp-server lease set {arg} address={ip} comment="{cmt}"; '
|
||||||
|
f':log info ("tp45-v30: NSPanelPro statisch auf {ip}") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30: NSPanelPro hat keine Lease -- spaeter nachziehen") }}')
|
||||||
|
|
||||||
|
# Abnahme-Zeile. `address=` ist ein echter Eigenschaftsvergleich (kein Regex) --
|
||||||
|
# bewusst NICHT `~"10.40.30.11"`, das traefe auch .110-.119.
|
||||||
|
lines.append(
|
||||||
|
':log info ("tp45-v30: .11=" . [:tostr [:len [/ip dhcp-server lease '
|
||||||
|
'find where address=10.40.30.11]]] . " .12=" . [:tostr [:len [/ip dhcp-server '
|
||||||
|
'lease find where address=10.40.30.12]]] . " .151=" . [:tostr [:len '
|
||||||
|
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .196=" . '
|
||||||
|
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.196]]])')
|
||||||
|
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-v30-leases")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_v30_revert():
|
||||||
|
"""RUECKNAHME 2026-09-21: die 26 Shelly-Reservierungen zurueck auf ihre ALTEN Adressen.
|
||||||
|
|
||||||
|
Christoph-Direktive, sofort: *„mach die shellys sofort rueckgaengig! das gibt
|
||||||
|
sonst ein riesen durcheinander mit home assistant"*. Grund: HA hat die Shellys
|
||||||
|
mit ihren alten Adressen eingebunden -- ein Umzug zerreisst die Integrationen.
|
||||||
|
|
||||||
|
🔴 **NUR die Shellys** — das war die erste, eng gefasste Ruecknahme. Die
|
||||||
|
uebrigen 10 Umzuege aus `job_tp45_v30_leases` (NukiBridge .11, P750W .13,
|
||||||
|
DTUBI .14, NukiSmartlock .177, SwitchBot .178, Twinkly .179, lichterketten
|
||||||
|
.190/.191, Withings .195/.196) und die beiden Sonderfaelle (CCU-Leiche
|
||||||
|
entfernt, NSPanelPro statisch auf .12) liess er unangetastet.
|
||||||
|
|
||||||
|
⚠️ **Noch am selben Tag ueberholt:** auf die weitergehende Direktive
|
||||||
|
(„wir sollten ALLE vlan30-aenderungen rueckgaengig machen" — Sperr-Risiko
|
||||||
|
Nuki) folgte `job_tp45_v30_restore`, der auch diese 10 plus beide
|
||||||
|
Sonderfaelle zurueckdreht. Dieser Job bleibt als der engere, zuerst
|
||||||
|
gelaufene Schritt bestehen.
|
||||||
|
|
||||||
|
Die Tabelle steht in `tp45-leases.py` als `REVERT` (abgeleitet aus `MOVES`,
|
||||||
|
gefiltert auf die 26 Shelly-MACs) -- NICHT hier, aus demselben Grund wie
|
||||||
|
`MOVES`: eine zweite Kopie laeuft still auseinander.
|
||||||
|
|
||||||
|
⚠️ **Reihenfolge:** erst `python3 nautobot/tp45-leases.py revert --apply`
|
||||||
|
(Soll zurueck), DANN diesen Job. Umgekehrt meldet GC sofort wieder Drift --
|
||||||
|
das Geraet waere richtig und das Soll falsch.
|
||||||
|
|
||||||
|
Abnahme: Geraete-Read `proplist=address,comment where server=dhcp-v30` -- die
|
||||||
|
26 Shellys muessen auf .202-.226/.233 stehen, die uebrigen 13 Eintraege
|
||||||
|
unveraendert. Danach `--refresh-compliance`, erwartet 145/145 und 0 Drifts.
|
||||||
|
"""
|
||||||
|
mod = _tp45_leases_module()
|
||||||
|
lines = [_lease_move(mac, alt, tag="tp45-v30-revert")
|
||||||
|
for mac, (_neu, alt) in sorted(mod.REVERT.items())]
|
||||||
|
|
||||||
|
# Abnahme-Zeile: die alten Baender muessen wieder voll sein, die neuen leer.
|
||||||
|
# Bewusst ein Bereichs-Paar statt aller 26 Adressen -- .202/.233 rahmen den
|
||||||
|
# alten Shelly-Block ein, .151/.176 den neuen.
|
||||||
|
lines.append(
|
||||||
|
':log info ("tp45-v30-revert: .151=" . [:tostr [:len [/ip dhcp-server lease '
|
||||||
|
'find where address=10.40.30.151]]] . " .176=" . [:tostr [:len '
|
||||||
|
'[/ip dhcp-server lease find where address=10.40.30.176]]] . " .202=" . '
|
||||||
|
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.202]]] . '
|
||||||
|
' " .233=" . [:tostr [:len [/ip dhcp-server lease '
|
||||||
|
'find where address=10.40.30.233]]])')
|
||||||
|
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-v30-shelly-revert")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_v30_restore():
|
||||||
|
"""VOLLRUECKNAHME 2026-09-21: alle VLAN30-Aenderungen aus B2 zuruecknehmen.
|
||||||
|
|
||||||
|
Christoph-Direktive: *„wir sollten alle vlan30-aenderungen rueckgaengig machen.
|
||||||
|
du koenntest mich auf diesem wege aus meiner wohnung aussperren."*
|
||||||
|
|
||||||
|
Was B2 am 2026-09-21 getan hat, und was hier einzeln zurueckgedreht wird:
|
||||||
|
|
||||||
|
1. **36 Reservierungs-Adressen** verschoben -> hier ALLE 36 zurueck auf ihre
|
||||||
|
`MOVES`-Ist-Adresse (idempotent: die 26 Shellys stehen schon dort, ein
|
||||||
|
zweites `set` auf dieselbe Adresse ist ein No-Op).
|
||||||
|
2. **CCU-Leiche entfernt** (`BC:24:11:8E:0A:57`, .30.232) -> wieder angelegt,
|
||||||
|
wortgleich der Stand vor B2. Ob die Leiche spaeter geloescht werden soll,
|
||||||
|
ist eine **eigene** Entscheidung, nicht Teil dieser Ruecknahme.
|
||||||
|
3. **SONOFF-NSPanelPro auf statisch .12 gepinnt** -> Reservierung entfernt;
|
||||||
|
vor B2 hatte es keine (reiner DHCP-Client auf .249). `remove` ist der
|
||||||
|
Weg, weil RouterOS 7.24 kein `make-dynamic` kennt (s. u.).
|
||||||
|
|
||||||
|
⚠️ **Reihenfolge:** erst `python3 nautobot/tp45-leases.py restore --apply`
|
||||||
|
(Soll zurueck), DANN diesen Job.
|
||||||
|
|
||||||
|
🔴 **Warum die Eile berechtigt war:** die Reservierung eines Geraets mit
|
||||||
|
**lokaler** IP zu aendern, bewegt das Geraet nicht — aber bei einem Geraet,
|
||||||
|
das **DHCP** nutzt, haette die naechste Erneuerung es auf die neue Adresse
|
||||||
|
gezogen, und IP-gebundene Einbindungen (Home Assistant: Nuki, Shelly, …)
|
||||||
|
waeren gerissen. Dass hier nichts passiert ist, war Glueck, kein Entwurf.
|
||||||
|
|
||||||
|
Abnahme: Geraete-Read der v30-Leases + ARP-Band-Zaehlung; danach
|
||||||
|
`--refresh-compliance`, erwartet 145/145 und 0 Drifts.
|
||||||
|
"""
|
||||||
|
mod = _tp45_leases_module()
|
||||||
|
lines = [_lease_move(mac, ist, tag="tp45-v30-restore")
|
||||||
|
for mac, (ist, _soll) in sorted(mod.MOVES.items())]
|
||||||
|
|
||||||
|
# NSPanelPro: die statische Reservierung ENTFERNEN -- vor B2 hatte es keine,
|
||||||
|
# es war ein reiner DHCP-Client (dynamische Lease auf .249).
|
||||||
|
#
|
||||||
|
# 🔴 `make-dynamic` gibt es in RouterOS 7.24 NICHT: `/ip dhcp-server lease
|
||||||
|
# make-dynamic` antwortet `bad command name make-dynamic` (live gemessen
|
||||||
|
# 2026-09-21). Der erste Versuch dieses Jobs ist daran gestorben und hat den
|
||||||
|
# Plan auf `Failed` gesetzt, OBWOHL alle uebrigen Statements gelandet sind —
|
||||||
|
# genau die Klasse „Completed/Failed beweist nichts, das Geraet ist die
|
||||||
|
# Wahrheit". `make-static` existiert (B2 hat es benutzt), das Gegenstueck
|
||||||
|
# fehlt; der Rueckweg ist `remove`.
|
||||||
|
#
|
||||||
|
# ⚠️ Das Panel haelt .12 noch bis zum Lease-Ablauf (~20 h, `status=bound`,
|
||||||
|
# `active-address=10.40.30.12`) und faellt danach in den Pool zurueck.
|
||||||
|
# Das ist eine von B2 erzeugte Uebergangserscheinung, keine neue Aenderung.
|
||||||
|
for add in mod.ADDS:
|
||||||
|
mac = re.search(r"mac-address=(\S+)", add).group(1)
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease remove {arg}; '
|
||||||
|
f':log info ("tp45-v30-restore: NSPanelPro-Reservierung entfernt") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30-restore: NSPanelPro hat keine Reservierung") }}')
|
||||||
|
|
||||||
|
# CCU-Leiche wieder anlegen -- nur wenn sie fehlt.
|
||||||
|
for mac in sorted(mod.REMOVE):
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 0) do={{ '
|
||||||
|
f'/ip dhcp-server lease {mod.RESTORE_ADD}; '
|
||||||
|
f':log info ("tp45-v30-restore: CCU-Leiche wieder angelegt") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30-restore: CCU-Leiche ist schon da") }}')
|
||||||
|
|
||||||
|
# Abnahme: die alten Baender muessen voll sein, die neuen leer.
|
||||||
|
lines.append(
|
||||||
|
':log info ("tp45-v30-restore: alt .202=" . [:tostr [:len [/ip dhcp-server lease '
|
||||||
|
'find where address=10.40.30.202]]] . " .233=" . [:tostr [:len '
|
||||||
|
'[/ip dhcp-server lease find where address=10.40.30.233]]] . " .244=" . '
|
||||||
|
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.244]]] . '
|
||||||
|
' " neu .11=" . [:tostr [:len [/ip dhcp-server lease '
|
||||||
|
'find where address=10.40.30.11]]] . " .151=" . [:tostr [:len '
|
||||||
|
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .177=" . '
|
||||||
|
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.177]]])')
|
||||||
|
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-v30-restore")]
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_static_lines():
|
||||||
|
"""Die `/ip dns static add`-Zeilen aus dem GC-Template, wortgetreu.
|
||||||
|
|
||||||
|
🔴 **Quelle ist das Template selbst, NICHT eine zweite Kopie.** Eine handgepflegte
|
||||||
|
Liste hier liefe beim ersten Template-Edit auseinander — still, weil beide Seiten
|
||||||
|
fuer sich plausibel aussehen; am Geraet faellt nur auf, dass ein Name fehlt.
|
||||||
|
Und die Kommentare muessen **zeichengleich** sein: Golden Config vergleicht
|
||||||
|
`comment=` mit, ein abweichender Text ist eine Drift.
|
||||||
|
|
||||||
|
Der `/ip dns static`-Block ist ein Literal im Template (im Gegensatz zu
|
||||||
|
`firewall_filter` und `dhcp_leases`, die als Daten aus
|
||||||
|
`local_config_context_data` kommen) — deshalb ist das Parsen hier zulaessig.
|
||||||
|
"""
|
||||||
|
text = (pathlib.Path(__file__).with_name("mikrotik_routeros.j2")).read_text()
|
||||||
|
return [ln.strip() for ln in text.splitlines()
|
||||||
|
if ln.strip().startswith("/ip dns static add ")]
|
||||||
|
|
||||||
|
|
||||||
|
def _dns_upsert(line):
|
||||||
|
"""Eine Template-Zeile -> idempotenter Upsert, EINZEILIG und klammer-vollstaendig.
|
||||||
|
|
||||||
|
`/ip dns static add` ist NICHT idempotent — ein zweiter Lauf legte den Namen ein
|
||||||
|
zweites Mal an. Deshalb: nur anlegen, wenn der Name fehlt, sonst in place setzen.
|
||||||
|
Der Selektor wird wie in `_lease_move` gezaehlt (`find where …` in `[:len …]`,
|
||||||
|
`[find where …]` als Argument) — die vertauschte Form ist ein Syntaxfehler, der
|
||||||
|
als `Completed` durchlaeuft und das Geraet unveraendert laesst.
|
||||||
|
"""
|
||||||
|
addr = re.search(r"address=(\S+)", line).group(1)
|
||||||
|
name = re.search(r"name=(\S+)", line).group(1)
|
||||||
|
cm = re.search(r'comment=("(?:[^"\\]|\\.)*"|\S+)', line)
|
||||||
|
comment = f" comment={cm.group(1)}" if cm else ""
|
||||||
|
return (f':if ([:len [/ip dns static find where name={name}]] = 0) do={{ '
|
||||||
|
f'{line} }} '
|
||||||
|
f'else={{ /ip dns static set [find where name={name}] address={addr}{comment} }}')
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_c_dns():
|
||||||
|
"""TP45 Schicht C (2026-09-22): die internen Namen in den CORE — der FQDN-Fix.
|
||||||
|
|
||||||
|
Hintergrund: AGH wertet seine DNS-Rewrites nur im **Filter-Pfad** aus. Fuer
|
||||||
|
unfiltrierte Clients (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`,
|
||||||
|
`grp-media`, `grp-mgmt`) greifen sie nicht, und die fallen auf den conditional
|
||||||
|
forward `[/bf.emsenhuber.net/] -> 10.40.20.1` zurueck — den Core. Der hielt
|
||||||
|
aber nur 10 Statics; 12 Namen existierten nur als AGH-Rewrite und waren damit
|
||||||
|
**NXDOMAIN**. Vom Client gemessen 2026-09-22: alle 12 leer, `vw.emsenhuber.net`
|
||||||
|
lieferte die Cloudflare-Anycast-IP statt `.40` — und genau daher kam der
|
||||||
|
404 auf `/admin` (der CF-Weg setzt `CF-Connecting-IP`, der `@cf_admin`-Guard
|
||||||
|
antwortet darauf mit 404).
|
||||||
|
|
||||||
|
Dieser Job bringt die Statics auf das Geraet. Danach beantwortet der Core alle
|
||||||
|
internen Namen unabhaengig vom Filterstatus des Clients — und, weil die Namen
|
||||||
|
ueber Caddy laufen, mit gueltigem TLS.
|
||||||
|
|
||||||
|
⚠️ **Die 5 bare Namen (`vw`, `hass`, `n8n`, `gitea`, `ds-bf`) kommen bewusst mit
|
||||||
|
auf `.40`** (Christoph-Entscheid 2026-09-22): der lokale, header-freie Weg ist
|
||||||
|
die einzige Tuer zum Vault-Admin. ⚠️ Nebenwirkung: diese Namen werden damit fuer
|
||||||
|
**alle** VLANs aufloesbar, nicht nur fuer MAIN — MEDIA (`*A`) und GUEST
|
||||||
|
(`*B`+`*A`) erreichen `.40:443` ebenfalls. Einhegung ist ein eigener Schritt.
|
||||||
|
|
||||||
|
Rückweg: die Vorher-Werte stehen als `A1`/`A2`-Tabellen im Plan
|
||||||
|
(`~/.claude/plans/…`); vor dem Deploy liegt ein Backup auf dem Core.
|
||||||
|
"""
|
||||||
|
lines = [_dns_upsert(ln) for ln in _dns_static_lines()]
|
||||||
|
# Abnahme-Zeile: die Zahl der Statics plus drei Stichproben, die den Ausfall
|
||||||
|
# repraesentieren (ap3 gab es vorher nirgends, pve zeigt auf Caddy, vw muss
|
||||||
|
# `.40` sein — nicht die CF-Anycast-IP).
|
||||||
|
lines.append(
|
||||||
|
':do { :log info ("tp45-c-dns: statics=" . [:tostr [:len [/ip dns static find]]] '
|
||||||
|
'. " ap3=" . [:tostr [:len [/ip dns static find where name=ap3.bf.emsenhuber.net]]] '
|
||||||
|
'. " vw=" . [/ip dns static get [find where name=vw.emsenhuber.net] address] '
|
||||||
|
'. " pve=" . [/ip dns static get [find where name=pve.bf.emsenhuber.net] address]) } '
|
||||||
|
f'on-error={{ :log info "tp45-c-dns: Abnahme-Zeile geworfen" }}')
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-c-dns")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp10_mesh_nat():
|
||||||
|
"""TP10 (2026-10-03): Mesh-WARP-Verkehr Richtung FM/DW-Spokes auf eine BF-IP NATen.
|
||||||
|
|
||||||
|
WARUM: die Spoke-FritzBoxen akzeptieren am WG-Peer nur Quell-IPs aus 10.40.0.0/16
|
||||||
|
(AllowedIPs). WARP-Clients kommen ueber cf-mesh mit 100.96.0.0/12 -> die FritzBox
|
||||||
|
verwirft den Verkehr (bewiesen: ping src=100.96.0.6 -> 100% Loss, src=10.40.20.1
|
||||||
|
-> 0%). Eine src-nat auf dem wg-hub-Egress legt die Quelle in den AllowedIPs-Raum.
|
||||||
|
|
||||||
|
`action=src-nat` (NICHT masquerade): wg-hub hat keine Interface-IP, masquerade
|
||||||
|
haette keine Quelle. src-nat auf eine feste BF-IP (10.40.10.1) haelt zudem das
|
||||||
|
Connection-Tracking -- die Antwort an 10.40.10.1 wird im prerouting auf die
|
||||||
|
Mesh-IP zurueckuebersetzt und via cf-mesh geroutet.
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: reine srcnat-Ergaenzung; der no-NAT-accept (dst 10.40.0.0/16)
|
||||||
|
und der WAN-masquerade bleiben unberuehrt, die Regel matcht nur out-interface=wg-hub.
|
||||||
|
"""
|
||||||
|
cmt = "TP10: NAT mesh source to WG spokes (AllowedIPs)"
|
||||||
|
find = f'find where comment="{cmt}"'
|
||||||
|
add = (f'/ip firewall nat add action=src-nat chain=srcnat comment="{cmt}" '
|
||||||
|
f'out-interface=wg-hub src-address=100.96.0.0/12 to-addresses=10.40.10.1')
|
||||||
|
lines = [
|
||||||
|
f':if ([:len [/ip firewall nat {find}]] = 0) do={{ {add} }} '
|
||||||
|
f'else={{ :log info "tp10-mesh-nat: Regel existiert schon" }}',
|
||||||
|
f':log info ("tp10-mesh-nat: regeln=" . [:tostr [:len [/ip firewall nat {find}]]])',
|
||||||
|
]
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP10-mesh-nat")]
|
||||||
|
|
||||||
|
|
||||||
JOBS = {
|
JOBS = {
|
||||||
"drift-cleanup": job_drift_cleanup,
|
"drift-cleanup": job_drift_cleanup,
|
||||||
"mgmt-acl": job_mgmt_acl,
|
"mgmt-acl": job_mgmt_acl,
|
||||||
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
||||||
|
"tp38-oob": job_tp38_oob,
|
||||||
|
"tp45-agh-fw": job_tp45_agh_fw,
|
||||||
|
"tp45-agh-dhcp": job_tp45_agh_dhcp,
|
||||||
|
"tp45-c-dns": job_tp45_c_dns,
|
||||||
|
"tp45-pool-v20": job_tp45_pool_v20,
|
||||||
|
"tp45-v30-leases": job_tp45_v30_leases,
|
||||||
|
"tp45-v30-revert": job_tp45_v30_revert,
|
||||||
|
"tp45-v30-restore": job_tp45_v30_restore,
|
||||||
|
"tp10-mesh-nat": job_tp10_mesh_nat,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
||||||
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
||||||
|
|
||||||
|
|||||||
199
nautobot/gc-snapshot.py
Normal file
199
nautobot/gc-snapshot.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP36-Live — Live-Gerätedaten als CustomFields auf den RouterOS-dcim.Device.
|
||||||
|
|
||||||
|
Legt (idempotent, get-or-create) fünf CustomFields auf `dcim.device` an und
|
||||||
|
befüllt sie für jedes RouterOS-Gerät. Die Werte kommen AUSSCHLIESSLICH aus
|
||||||
|
Nautobot selbst — es wird KEIN Gerät kontaktiert:
|
||||||
|
|
||||||
|
ros_version aus der GoldenConfig-`backup_config` ("by RouterOS X.Y.Z")
|
||||||
|
dhcp_server_count gezählte Einträge der Sektion "/ip dhcp-server" im Backup
|
||||||
|
dns_upstream_mode aus der "/ip dns"-Sektion des Backups: doh | plain | none
|
||||||
|
compliance_pct GC config-compliance: compliant / total * 100 (gerundet)
|
||||||
|
mgmt_ip device.primary_ip4 (ohne /prefix)
|
||||||
|
|
||||||
|
Warum aus Nautobot statt live vom Gerät: der MGMT-/REST-Weg (VLAN99:80) ist
|
||||||
|
nicht aus jedem Vantage erreichbar; die GC-Backups werden täglich (03:30 UTC,
|
||||||
|
ScheduledJob "GC Backup+Compliance – taeglich") per SSH gezogen und sind die
|
||||||
|
Doku-SSoT. Damit braucht der Snapshot keine Geräte-Credentials. Aktualität steht
|
||||||
|
in `backup_last_success_date` — ist die alt, steht der Backup-Schedule (2026-09-12
|
||||||
|
war er 8 Tage aus, weil es vorher gar keinen gab).
|
||||||
|
|
||||||
|
Der Backup-Config-Text wird nur INTERN geparst (Zähler/Modus abgeleitet) und
|
||||||
|
NIE ausgegeben — er kann gescrubte Reste enthalten (PSK-Leak-Regel).
|
||||||
|
|
||||||
|
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||||
|
Schlüssel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/gc-snapshot.py # Dry-Run (default) — zeigt nur an
|
||||||
|
python3 nautobot/gc-snapshot.py --commit # legt CustomFields an + patcht Devices
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(f"{NB}{path}", data=data, headers=HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- CustomFields
|
||||||
|
# (key, label, type, description). content_types wird ergänzt.
|
||||||
|
CUSTOM_FIELDS = [
|
||||||
|
("ros_version", "RouterOS-Version", "text",
|
||||||
|
"RouterOS-Version aus der GC-backup_config (by RouterOS …)."),
|
||||||
|
("compliance_pct", "GC-Compliance %", "integer",
|
||||||
|
"Golden-Config config-compliance: compliant/total in Prozent."),
|
||||||
|
("dhcp_server_count", "DHCP-Server", "integer",
|
||||||
|
"Anzahl Einträge der Sektion /ip dhcp-server im GC-Backup."),
|
||||||
|
("mgmt_ip", "Mgmt-IP", "text",
|
||||||
|
"device.primary_ip4 ohne Prefix (VLAN99-Management)."),
|
||||||
|
("dns_upstream_mode", "DNS-Upstream-Modus", "text",
|
||||||
|
"Aus /ip dns des GC-Backups: doh | plain | none."),
|
||||||
|
]
|
||||||
|
CF_KEYS = [c[0] for c in CUSTOM_FIELDS]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_custom_fields():
|
||||||
|
"""get-or-create die fünf CFs; bei Bestand: content_type dcim.device sicherstellen."""
|
||||||
|
st, d = req("GET", "/api/extras/custom-fields/?limit=0")
|
||||||
|
have = {r["key"]: r for r in d.get("results", [])}
|
||||||
|
for key, label, typ, desc in CUSTOM_FIELDS:
|
||||||
|
ex = have.get(key)
|
||||||
|
if ex:
|
||||||
|
cts = ex.get("content_types", [])
|
||||||
|
ok = "dcim.device" in cts
|
||||||
|
print(f" = CF '{key}' existiert ({ex['id']}){'' if ok else ' ⚠ ohne dcim.device!'}")
|
||||||
|
if not ok and COMMIT:
|
||||||
|
req("PATCH", f"/api/extras/custom-fields/{ex['id']}/",
|
||||||
|
{"content_types": sorted(set(cts) | {"dcim.device"})})
|
||||||
|
print(f" + dcim.device ergänzt")
|
||||||
|
continue
|
||||||
|
payload = {"key": key, "label": label, "type": typ, "description": desc,
|
||||||
|
"content_types": ["dcim.device"], "grouping": "TP36-Live",
|
||||||
|
"required": False}
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + CF '{key}' ({typ}) [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
st, r = req("POST", "/api/extras/custom-fields/", payload)
|
||||||
|
print(f" {'+' if st in (200,201) else '!'} CF '{key}'"
|
||||||
|
+ (f" angelegt ({r.get('id')})" if st in (200, 201)
|
||||||
|
else f" FEHLER {st}: {json.dumps(r)[:200]}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Backup-Parser
|
||||||
|
def parse_backup(cfg: str):
|
||||||
|
"""Werte aus dem flachen RouterOS-Export (eine '/…'-Zeile pro Kommando)."""
|
||||||
|
ver = ""
|
||||||
|
m = re.search(r"by RouterOS ([0-9][0-9A-Za-z.\-]*)", cfg)
|
||||||
|
if m:
|
||||||
|
ver = m.group(1)
|
||||||
|
# DHCP-Server = Zeilen '/ip dhcp-server add …' (NICHT lease/network/option)
|
||||||
|
dhcp = len(re.findall(r"(?m)^\s*/ip dhcp-server add\b", cfg))
|
||||||
|
# DNS-Modus aus der '/ip dns set …'-Zeile
|
||||||
|
mode = "none"
|
||||||
|
md = re.search(r"(?m)^\s*/ip dns set\b.*", cfg)
|
||||||
|
if md:
|
||||||
|
line = md.group(0)
|
||||||
|
doh = re.search(r"use-doh-server=([^\s]+)", line)
|
||||||
|
srv = re.search(r"\bservers=([^\s]+)", line)
|
||||||
|
if doh and doh.group(1) not in ("", '""'):
|
||||||
|
mode = "doh"
|
||||||
|
elif srv and srv.group(1) not in ("", '""'):
|
||||||
|
mode = "plain"
|
||||||
|
return ver, dhcp, mode
|
||||||
|
|
||||||
|
|
||||||
|
def compliance_pct(device_name: str):
|
||||||
|
q = urllib.parse.urlencode({"device": device_name, "limit": 0, "depth": 0})
|
||||||
|
st, d = req("GET", f"/api/plugins/golden-config/config-compliance/?{q}")
|
||||||
|
rows = d.get("results", [])
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
ok = sum(1 for r in rows if r.get("compliance"))
|
||||||
|
return round(ok / len(rows) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- main
|
||||||
|
def routeros_devices():
|
||||||
|
q = urllib.parse.urlencode({"limit": 0, "depth": 1, "platform": "RouterOS"})
|
||||||
|
st, d = req("GET", f"/api/dcim/devices/?{q}")
|
||||||
|
devs = []
|
||||||
|
for r in d.get("results", []):
|
||||||
|
ip = r.get("primary_ip4")
|
||||||
|
ip = ip.get("address") if isinstance(ip, dict) else ip
|
||||||
|
if not ip:
|
||||||
|
continue # tote Altgeräte (cudy/sw) ohne IP überspringen
|
||||||
|
devs.append((r["id"], r["name"], ip.split("/")[0]))
|
||||||
|
return sorted(devs, key=lambda x: x[1])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP36-Live gc-snapshot ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
|
||||||
|
print("\n[1] CustomFields sicherstellen")
|
||||||
|
ensure_custom_fields()
|
||||||
|
|
||||||
|
print("\n[2] Golden-Config-Backups laden")
|
||||||
|
st, gc = req("GET", "/api/plugins/golden-config/golden-config/?depth=1&limit=0")
|
||||||
|
backups = {}
|
||||||
|
for r in gc.get("results", []):
|
||||||
|
dev = r.get("device")
|
||||||
|
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||||
|
backups[dev] = (r.get("backup_config") or "", r.get("backup_last_success_date") or "?")
|
||||||
|
|
||||||
|
print("\n[3] Snapshot je Gerät")
|
||||||
|
print(f" {'Gerät':16} {'ros':9} {'cmpl%':>5} {'dhcp':>4} {'dns':6} {'mgmt-ip':15} backup")
|
||||||
|
for dev_id, name, mgmt in routeros_devices():
|
||||||
|
cfg, when = backups.get(name, ("", "—"))
|
||||||
|
ver, dhcp, dns = parse_backup(cfg) if cfg else ("?", 0, "?")
|
||||||
|
pct = compliance_pct(name)
|
||||||
|
vals = {
|
||||||
|
"ros_version": ver or None,
|
||||||
|
"compliance_pct": pct,
|
||||||
|
"dhcp_server_count": dhcp,
|
||||||
|
"mgmt_ip": mgmt,
|
||||||
|
"dns_upstream_mode": dns,
|
||||||
|
}
|
||||||
|
pct_s = "—" if pct is None else str(pct)
|
||||||
|
print(f" {name:16} {ver or '?':9} {pct_s:>5} {dhcp:>4} {dns:6} {mgmt:15} {when[:10]}")
|
||||||
|
if COMMIT:
|
||||||
|
st, r = req("PATCH", f"/api/dcim/devices/{dev_id}/", {"custom_fields": vals})
|
||||||
|
if st not in (200, 201):
|
||||||
|
print(f" ! PATCH FEHLER {st}: {json.dumps(r)[:200]}")
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT — geschrieben' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -55,7 +55,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
|
|||||||
REPO = os.path.dirname(HIER)
|
REPO = os.path.dirname(HIER)
|
||||||
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
||||||
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
|
||||||
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
||||||
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
|
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
|
||||||
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ["NAUTOBOT_TOKEN"]
|
TOKEN = os.environ["NAUTOBOT_TOKEN"]
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet).
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Quelle: ap1 live via MikroMCP + docs/teilprojekte.md.
|
|||||||
"""
|
"""
|
||||||
import json, sys, urllib.request, urllib.error
|
import json, sys, urllib.request, urllib.error
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
import os
|
import os
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import pathlib
|
|||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
DEVICE = "crs326-core"
|
DEVICE = "crs326-core"
|
||||||
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
|
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null).
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null).
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
132
nautobot/ingest-dhcp-lease-rule.py
Normal file
132
nautobot/ingest-dhcp-lease-rule.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ComplianceRule `dhcp-lease` anlegen (idempotent).
|
||||||
|
|
||||||
|
Die Luecke, die das schliesst
|
||||||
|
-----------------------------
|
||||||
|
`nautobot/mikrotik_routeros.j2` rendert seit dem 2026-09-21 den Abschnitt
|
||||||
|
`/ip dhcp-server lease` (TP45 Schicht B, aus `local_config_context_data.dhcp_leases`).
|
||||||
|
Gerendert heisst aber noch nicht bewacht: ohne ComplianceRule erzeugt der Block
|
||||||
|
**keine** Compliance-Eintraege, und die Gesamtzahl bliebe unauffaellig. Genau die
|
||||||
|
Falle aus CLAUDE.md — "eine gleichbleibende Compliance-Zahl nach dem Hinzufuegen
|
||||||
|
eines Geraets ist ein BEFUND, kein Erfolg".
|
||||||
|
|
||||||
|
Konkret: von 54 live existierenden Reservierungen war vorher **keine einzige**
|
||||||
|
drift-ueberwacht. Verschiebt jemand eine Adresse von Hand am Geraet, meldet
|
||||||
|
Golden Config "compliant", weil die Sektion unter keiner Rule steht.
|
||||||
|
|
||||||
|
Analogie: TP26 fuer `/ip firewall filter` (25 Regeln blind), `ingest-queue-rule.py`
|
||||||
|
fuer `/queue`, `ingest-wifi-access-list-rule.py` fuer `/interface wifi access-list`,
|
||||||
|
`ingest-logging-rule.py` fuer `/system logging`.
|
||||||
|
|
||||||
|
export NAUTOBOT_TOKEN=... # aus docs/nas-vlan20/stacks/210-nautobot.yml
|
||||||
|
python3 nautobot/ingest-dhcp-lease-rule.py # Dry-Run
|
||||||
|
python3 nautobot/ingest-dhcp-lease-rule.py --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
FEATURE = "dhcp-lease"
|
||||||
|
# ⚠️ Nautobot begrenzt die Feature-Beschreibung auf 200 Zeichen (400 sonst).
|
||||||
|
BESCHREIBUNG = ("TP45 Schicht B: DHCP-Reservierungen (/ip dhcp-server lease). Bis 2026-09-21 "
|
||||||
|
"unter keiner Rule -- die 54 Reservierungen waren drift-blind. Gleicher "
|
||||||
|
"blinder Fleck wie TP26 fuer /ip firewall filter.")
|
||||||
|
# Praefix, nicht die ganze Sektion: `/ip dhcp-server lease` faengt nur die Reservierungen.
|
||||||
|
# Ein blankes `/ip dhcp-server` wuerde die Server-, Network- und Option-Zeilen mitfangen,
|
||||||
|
# die eigene Rules haben (bzw. haben sollen).
|
||||||
|
MATCH = "/ip dhcp-server lease"
|
||||||
|
PLATTFORM = "RouterOS"
|
||||||
|
|
||||||
|
|
||||||
|
def req(method, path, data=None):
|
||||||
|
url = path if path.startswith("http") else BASE + path
|
||||||
|
body = json.dumps(data).encode() if data is not None else None
|
||||||
|
r = urllib.request.Request(url, data=body, method=method)
|
||||||
|
r.add_header("Authorization", "Token " + TOKEN)
|
||||||
|
r.add_header("Content-Type", "application/json")
|
||||||
|
r.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=60) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit("!! %s %s -> %s\n %s" % (method, url, e.code, e.read().decode()[:600]))
|
||||||
|
|
||||||
|
|
||||||
|
def eins(pfad, **filter):
|
||||||
|
q = urllib.parse.urlencode(filter)
|
||||||
|
res = req("GET", "%s?%s" % (pfad, q)).get("results", [])
|
||||||
|
if len(res) > 1:
|
||||||
|
sys.exit("FEHLER: %s?%s liefert %d Treffer, erwartet 0 oder 1" % (pfad, q, len(res)))
|
||||||
|
return res[0] if res else None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not TOKEN:
|
||||||
|
sys.exit("NAUTOBOT_TOKEN fehlt.")
|
||||||
|
print("Modus:", "APPLY" if APPLY else "DRY-RUN (nichts wird geschrieben)")
|
||||||
|
|
||||||
|
plat = eins("/dcim/platforms/", name=PLATTFORM)
|
||||||
|
if not plat:
|
||||||
|
sys.exit("Plattform %r nicht gefunden." % PLATTFORM)
|
||||||
|
print("Plattform : %s (%s)" % (plat["display"], plat["id"]))
|
||||||
|
|
||||||
|
feat = eins("/plugins/golden-config/compliance-feature/", name=FEATURE)
|
||||||
|
if feat:
|
||||||
|
print("Feature : existiert bereits (%s)" % feat["id"])
|
||||||
|
else:
|
||||||
|
print("Feature : anlegen -> %s" % FEATURE)
|
||||||
|
if APPLY:
|
||||||
|
feat = req("POST", "/plugins/golden-config/compliance-feature/",
|
||||||
|
{"name": FEATURE, "slug": FEATURE, "description": BESCHREIBUNG})
|
||||||
|
print(" angelegt (%s)" % feat["id"])
|
||||||
|
|
||||||
|
if not feat:
|
||||||
|
print("\nDry-Run: ohne Feature kann die Rule nicht geprueft werden. Mit --apply erneut.")
|
||||||
|
return
|
||||||
|
|
||||||
|
rule = eins("/plugins/golden-config/compliance-rule/",
|
||||||
|
feature=feat["id"], platform=plat["id"])
|
||||||
|
if rule:
|
||||||
|
print("Rule : existiert bereits (%s), match_config=%r, config_ordered=%s"
|
||||||
|
% (rule["id"], rule.get("match_config"), rule.get("config_ordered")))
|
||||||
|
if rule.get("match_config") != MATCH:
|
||||||
|
print(" ⚠️ match_config weicht ab -- erwartet %r" % MATCH)
|
||||||
|
if rule.get("config_ordered"):
|
||||||
|
print(" ⚠️ config_ordered=True -- erwartet False (s. u.)")
|
||||||
|
return
|
||||||
|
|
||||||
|
nutzlast = {
|
||||||
|
"feature": feat["id"],
|
||||||
|
"platform": plat["id"],
|
||||||
|
"description": BESCHREIBUNG,
|
||||||
|
# 🔴 False, NICHT True: die Reservierungs-Sektion steht am Geraet in
|
||||||
|
# Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet), nicht numerisch.
|
||||||
|
# Bei config_ordered=True waere jede Umsortierung durch RouterOS ein
|
||||||
|
# Falsch-Drift. Die Reihenfolge ist hier auch semantisch bedeutungslos —
|
||||||
|
# anders als bei /ip firewall filter, wo sie ueber Gewinnen/Verlieren entscheidet.
|
||||||
|
"config_ordered": False,
|
||||||
|
"config_type": "cli",
|
||||||
|
"match_config": MATCH,
|
||||||
|
"custom_compliance": False,
|
||||||
|
"config_remediation": False,
|
||||||
|
}
|
||||||
|
print("Rule : anlegen -> %s auf %s, match_config=%r, config_ordered=False"
|
||||||
|
% (FEATURE, PLATTFORM, MATCH))
|
||||||
|
if APPLY:
|
||||||
|
r = req("POST", "/plugins/golden-config/compliance-rule/", nutzlast)
|
||||||
|
print(" angelegt (%s)" % r["id"])
|
||||||
|
else:
|
||||||
|
print(" Nutzlast: %s" % json.dumps(nutzlast))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -32,7 +32,7 @@ Jinja-Template denselben String emittieren kann (sonst Dauer-Diff in der Complia
|
|||||||
"""
|
"""
|
||||||
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
|
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ UMGEKEHRT GILT: die `vlanXX`-Descriptions auf dem Core NICHT anfassen, die lande
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
105
nautobot/ingest-tp38-oob.py
Normal file
105
nautobot/ingest-tp38-oob.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP38: OOB-Port + Input-Firewall als Daten nach Nautobot schreiben.
|
||||||
|
|
||||||
|
Zwei getrennte Aenderungen, ein Skript:
|
||||||
|
|
||||||
|
1. BRANCH (crs326-branch): `firewall_filter` im local_config_context_data ANLEGEN
|
||||||
|
(8 Input-Regeln, deny-by-default). Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12
|
||||||
|
spiegelt bewusst das `available-from` der /ip service-Zeilen — kein neues
|
||||||
|
Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
|
||||||
|
2. CORE (crs326-core): EINE Regel "TP38: OOB->core mgmt" in die bestehende
|
||||||
|
firewall_filter-Liste EINFUEGEN (vor log/drop), damit der OOB-Port (untagged
|
||||||
|
ether23, VLAN99) auch den Core selbst verwalten kann.
|
||||||
|
|
||||||
|
Attributreihenfolge = alphabetisch, exakt wie RouterOS `/export` sie schreibt
|
||||||
|
(gleiche Konvention wie ingest-core-firewall.py).
|
||||||
|
|
||||||
|
python3 nautobot/ingest-tp38-oob.py # Dry-Run
|
||||||
|
python3 nautobot/ingest-tp38-oob.py --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
|
||||||
|
BRANCH_RULES = [
|
||||||
|
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||||
|
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||||
|
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||||
|
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||||
|
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||||
|
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||||
|
]
|
||||||
|
|
||||||
|
CORE_NEW_RULE = 'add action=accept chain=input comment="TP38: OOB->core mgmt" dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp'
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
tok = os.environ.get("NAUTOBOT_TOKEN") or sys.exit("NAUTOBOT_TOKEN nicht gesetzt")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{NB}{path}", method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {tok}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(name):
|
||||||
|
return nb(f"/api/dcim/devices/?name={name}&depth=1")["results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_device(dev, ctx, label):
|
||||||
|
dev_id = dev["id"]
|
||||||
|
nb(f"/api/dcim/devices/{dev_id}/", "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print(f" ~ {label} geschrieben.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
apply_ = "--apply" in sys.argv
|
||||||
|
|
||||||
|
# --- BRANCH ---
|
||||||
|
branch = get_device("crs326-branch")
|
||||||
|
bctx = dict(branch.get("local_config_context_data") or {})
|
||||||
|
if bctx.get("firewall_filter") == BRANCH_RULES:
|
||||||
|
print("== branch: firewall_filter unveraendert ==")
|
||||||
|
else:
|
||||||
|
bctx["firewall_filter"] = BRANCH_RULES
|
||||||
|
print(f"== branch: {len(BRANCH_RULES)} Input-Regeln ==")
|
||||||
|
for ln in BRANCH_RULES:
|
||||||
|
print(" " + ln)
|
||||||
|
if apply_:
|
||||||
|
patch_device(branch, bctx, "branch firewall_filter")
|
||||||
|
|
||||||
|
# --- CORE ---
|
||||||
|
core = get_device("crs326-core")
|
||||||
|
cctx = dict(core.get("local_config_context_data") or {})
|
||||||
|
ff = list(cctx.get("firewall_filter") or [])
|
||||||
|
if CORE_NEW_RULE in ff:
|
||||||
|
print("\n== core: Regel schon vorhanden ==")
|
||||||
|
else:
|
||||||
|
# vor der Log-Regel einsortieren (die sitzt direkt vor dem finalen drop).
|
||||||
|
insert_at = len(ff)
|
||||||
|
for i, ln in enumerate(ff):
|
||||||
|
if "log-prefix=TP5-INPUT-WOULD-DROP" in ln:
|
||||||
|
insert_at = i
|
||||||
|
break
|
||||||
|
ff.insert(insert_at, CORE_NEW_RULE)
|
||||||
|
cctx["firewall_filter"] = ff
|
||||||
|
print(f"\n== core: fuege OOB-Regel an Position {insert_at} ein ==")
|
||||||
|
print(" " + CORE_NEW_RULE)
|
||||||
|
if apply_:
|
||||||
|
patch_device(core, cctx, "core firewall_filter")
|
||||||
|
|
||||||
|
if not apply_:
|
||||||
|
print("\nDRY-RUN — mit --apply schreiben. Danach: "
|
||||||
|
"push-gc-template.py + gc-configplan.py --refresh-compliance")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -68,7 +68,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
|
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
|
||||||
{%- set vlans = [
|
{%- set vlans = [
|
||||||
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
|
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
|
||||||
{"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"},
|
{"id": 20, "name": "INFRA", "pool": "10.40.20.200-10.40.20.249", "lease": "1d"},
|
||||||
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
|
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
|
||||||
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
|
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
|
||||||
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
|
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
|
||||||
@@ -73,9 +73,11 @@
|
|||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
||||||
{% for n in range(17, 25) %}
|
{% for n in range(17, 23) %}
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
|
||||||
|
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
||||||
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99
|
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
||||||
|
|
||||||
# [1] INTERFACE-LISTS
|
# [1] INTERFACE-LISTS
|
||||||
@@ -132,10 +134,29 @@
|
|||||||
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for v in vlans %}
|
{% for v in vlans %}
|
||||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
||||||
|
|
||||||
|
# [5b] DHCP-RESERVIERUNGEN — kommen als DATEN aus local_config_context_data.dhcp_leases,
|
||||||
|
# wie firewall_filter. Warum das noetig war: bis 2026-09-21 rendert dieses Template
|
||||||
|
# KEIN einziges `/ip dhcp-server lease`. Die 54 Reservierungen stammen aus dem
|
||||||
|
# Einmal-Skript routeros/tp21-core-dhcp-from-ap1.rsc (Core-Cutover 2026-08-06) und
|
||||||
|
# standen damit in KEINEM Soll — ein GC-Recovery von null haette einen Core ohne
|
||||||
|
# Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt. Und weil
|
||||||
|
# Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben.
|
||||||
|
# Dieselbe Klasse wie die handgelaufene `nautobot`-Adressliste (s. CLAUDE.md).
|
||||||
|
#
|
||||||
|
# 🔴 Zeilen sind RouterOS-/export-Form, alphabetisch, OHNE fuehrenden Pfad — genau
|
||||||
|
# wie firewall_filter. Gefuellt und verschoben von nautobot/tp45-leases.py.
|
||||||
|
# 🔴 Die REIHENFOLGE wird NICHT sortiert: das Backup traegt die Sektion in
|
||||||
|
# Einfuege-Reihenfolge (.30.150 frueh, .30.5 spaet), ein numerisch sortiertes
|
||||||
|
# Soll waere ein Dauer-Falsch-Drift. Die ComplianceRule steht deshalb auf
|
||||||
|
# config_ordered=no.
|
||||||
|
{% for line in obj.get_config_context().get("dhcp_leases", []) %}
|
||||||
|
/ip dhcp-server lease {{ line }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
|
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
|
||||||
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
||||||
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
||||||
@@ -222,10 +243,12 @@
|
|||||||
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
||||||
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
||||||
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
||||||
# sec-main: ft=no seit 2026-09-04 (User-Entscheid nach TP25-Messung). Fast-Transition
|
# sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
|
||||||
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no.
|
# "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
|
||||||
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus).
|
# Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
|
||||||
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>"
|
# Fund-E-Abschlussmessung 09-02 ruhig (4 reauth/8,6 h, 0 Ausfaelle) auf 7.24.1.
|
||||||
|
# 2026-09-04 stand hier: ft=no ft-over-ds=no (User-Entscheid nach TP25-Messung).
|
||||||
|
/interface wifi security add authentication-types=wpa3-psk ft=yes ft-over-ds=yes name=sec-main passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
||||||
@@ -239,11 +262,16 @@
|
|||||||
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
||||||
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
||||||
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
||||||
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
# st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
|
||||||
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
# zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante). transition-threshold-time=5s
|
||||||
|
# statt 10s. NUR 5G: die drei 2G-Configs bleiben auf st-main (unset=-80), weil 2.4G draussen
|
||||||
|
# (Terrasse/ausserhalb der Wohnung) Reichweite behalten soll. 8 dB Abstand = kleine Hysterese.
|
||||||
|
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes
|
||||||
|
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
|
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
||||||
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
||||||
@@ -273,34 +301,65 @@
|
|||||||
# 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; ap3 ergaenzt 2026-09-22):
|
||||||
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
|
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12 | ap3 .99.13
|
||||||
|
#
|
||||||
|
# 🔴 TP45 Schicht C (2026-09-22): DIESE Liste ist die EINZIGE Quelle fuer interne Namen.
|
||||||
|
# AGH wertet seine DNS-Rewrites nur im Filter-Pfad aus; fuer unfiltrierte Clients
|
||||||
|
# (dns-bypass-exempt, grp-infra/-media/-mgmt) greifen sie nicht — die landen hier.
|
||||||
|
# Hier fehlten 12 Namen, genau das war der FQDN-Ausfall.
|
||||||
|
# Merkregel: was in AGH als Rewrite steht, muss auch hier stehen.
|
||||||
/ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.99.1 comment="crs326-core (L3)" name=core.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.99.2 comment=crs326-branch name=branch.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.99.11 comment="ap1 = reiner AP" name=ap1.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.99.12 comment="ap2 = reiner AP" name=ap2.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.99.13 comment="ap3 = reiner CAP (cAP ax)" name=ap3.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.20.167 name=macmini.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.20.167 name=macmini.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.20.100 name=pve.bf.emsenhuber.net type=A
|
|
||||||
/ip dns static add address=10.40.20.173 name=pve-mcp.bf.emsenhuber.net type=A
|
|
||||||
/ip dns static add address=10.40.20.10 name=nas.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.20.10 name=nas.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.20.126 name=docker.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.20.126 name=docker.bf.emsenhuber.net type=A
|
||||||
/ip dns static add address=10.40.20.20 name=platform-pg.bf.emsenhuber.net type=A
|
/ip dns static add address=10.40.20.20 name=platform-pg.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.10.6 comment="Brother MFC (VLAN10)" name=mfc.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.40.11 comment="TV (VLAN40)" name=tv.bf.emsenhuber.net type=A
|
||||||
|
|
||||||
|
# Caddy-Frontends (TP31): Dienste mit eigener Site laufen ueber den Reverse Proxy, damit das
|
||||||
|
# TLS-Zertifikat gueltig ist. Quelle: caddy/Caddyfile (live geprueft 2026-09-22).
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> nautobot :8080" name=nautobot.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> pbs :8007" name=pbs.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> portainer :9443" name=portainer.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> ccu :80" name=ccu.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> iobroker :8081" name=iobroker.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> z2m25 :8080" name=z2m25.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> home-assistant mcp :9583" name=ha-mcp.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> n8n-mcp :3001" name=n8n-mcp.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> gitea-mcp :3000" name=gitea-mcp.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> pve :8006" name=pve.bf.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="caddy -> pve-mcp :3000" name=pve-mcp.bf.emsenhuber.net type=A
|
||||||
|
|
||||||
|
# Oeffentliche Namen (bare emsenhuber.net) — real via CF Access + Tunnel. Sie stehen hier NUR,
|
||||||
|
# damit LAN-Clients direkt auf Caddy kommen: der CF-Weg setzt CF-Connecting-IP, und der
|
||||||
|
# @cf_admin-Guard in caddy/Caddyfile antwortet darauf mit 404 (TP41 T2). Der lokale,
|
||||||
|
# header-freie Weg ist die einzige Tuer zum Vault-Admin (Christoph-Entscheid 2026-09-22).
|
||||||
|
/ip dns static add address=10.40.20.40 comment="vw via caddy (Vault-Admin nur lokal)" name=vw.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="hass via caddy" name=hass.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="n8n via caddy" name=n8n.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="gitea via caddy" name=gitea.emsenhuber.net type=A
|
||||||
|
/ip dns static add address=10.40.20.40 comment="ds-bf via caddy" name=ds-bf.emsenhuber.net type=A
|
||||||
|
|
||||||
# [7] NAT
|
# [7] NAT
|
||||||
/ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16
|
/ip firewall nat add action=accept chain=srcnat comment="TP4: no-NAT inter-VLAN (LAN-internal, before masquerade)" dst-address=10.40.0.0/16
|
||||||
/ip firewall nat add action=masquerade chain=srcnat comment="defconf: masquerade" ipsec-policy=out,none out-interface-list=WAN
|
/ip firewall nat add action=masquerade chain=srcnat comment="defconf: masquerade" ipsec-policy=out,none out-interface-list=WAN
|
||||||
|
/ip firewall nat add action=src-nat chain=srcnat comment="TP10: NAT mesh source to WG spokes (AllowedIPs)" out-interface=wg-hub src-address=100.96.0.0/12 to-addresses=10.40.10.1
|
||||||
|
|
||||||
# [7b] FIREWALL FILTER (TP26, 2026-08-17). Kommt als DATEN aus
|
# [7b] FIREWALL FILTER (TP26, 2026-08-17). Kommt als DATEN aus
|
||||||
# local_config_context_data.firewall_filter, nicht als Templatezeilen —
|
# local_config_context_data.firewall_filter, nicht als Templatezeilen —
|
||||||
@@ -338,6 +397,17 @@
|
|||||||
/ip address add address=100.96.0.6 comment="TP16.1 Mesh-Overlay" interface=cf-mesh network=100.96.0.6
|
/ip address add address=100.96.0.6 comment="TP16.1 Mesh-Overlay" interface=cf-mesh network=100.96.0.6
|
||||||
/ip route add comment="TP16.1 Mesh" disabled=no distance=1 dst-address=100.96.0.0/12 gateway=cf-mesh
|
/ip route add comment="TP16.1 Mesh" disabled=no distance=1 dst-address=100.96.0.0/12 gateway=cf-mesh
|
||||||
|
|
||||||
|
# [10b] EGRESS (2026-09-26) — ziel-basierter Ausgang fuer 164.90.191.7/32 (cannaleo/DO-Origin).
|
||||||
|
# Der Origin blockt die Heim-IP 90.146.33.177 hart (403), Cloudflare aber nicht: WARP-Client
|
||||||
|
# und cf-mesh kommen beide durch (live gemessen, gleiche Ausgangs-IP 104.28.x). Genau EIN Ziel,
|
||||||
|
# ein Ausgang — kein Default-Route-Eingriff, restlicher Traffic unberuehrt. Kein extra NAT noetig:
|
||||||
|
# masquerade out-interface-list=WAN greift fuer cf-mesh NICHT, CF-NATet die LAN-Source im Mesh
|
||||||
|
# selbst (verifiziert von macmini 10.40.20.167). Cave User: kein Zugriffsmuster, das Heim-IP und
|
||||||
|
# diesen Ausgang gemeinsam gegen den Origin zeigt — der Ausgang ist ausschliesslich fuer cannaleo.
|
||||||
|
/routing table add fib name=egress-cannaleo
|
||||||
|
/ip route add comment="TP-EGRESS: cannaleo via cf-mesh" disabled=no distance=1 dst-address=0.0.0.0/0 gateway=cf-mesh routing-table=egress-cannaleo
|
||||||
|
/routing rule add action=lookup-only-in-table comment="TP-EGRESS: cannaleo 164.90.191.7/32 via cf-mesh" disabled=no dst-address=164.90.191.7/32 table=egress-cannaleo
|
||||||
|
|
||||||
# [11] QoS — BEWUSST LEER (TP6 geschlossen 2026-08-23).
|
# [11] QoS — BEWUSST LEER (TP6 geschlossen 2026-08-23).
|
||||||
#
|
#
|
||||||
# Hier stand einen Tag lang eine CAKE-Queue. Sie ist entfernt, weil sie GEMESSEN
|
# Hier stand einen Tag lang eine CAKE-Queue. Sie ist entfernt, weil sie GEMESSEN
|
||||||
@@ -416,9 +486,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 +508,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 +521,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 %}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import urllib.parse
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
COMMIT = "--commit" in sys.argv
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import urllib.parse
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
COMMIT = "--commit" in sys.argv
|
COMMIT = "--commit" in sys.argv
|
||||||
@@ -134,10 +134,10 @@ VMS = {
|
|||||||
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
|
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
|
||||||
],
|
],
|
||||||
"nas-bf": [
|
"nas-bf": [
|
||||||
("nautobot", "10.40.20.30/24", "Nautobot Web (DS923 ep14)"),
|
("nautobot", "10.40.20.154/24", "Nautobot Web (DS923 ep14)"),
|
||||||
("nautobot-worker", "10.40.20.31/24", "Nautobot Golden-Config Worker"),
|
("nautobot-worker", "10.40.20.155/24", "Nautobot Golden-Config Worker"),
|
||||||
("nautobot-scheduler", "10.40.20.32/24", "Nautobot Scheduler"),
|
("nautobot-scheduler", "10.40.20.156/24", "Nautobot Scheduler"),
|
||||||
("nautobot-redis", "10.40.20.34/24", "Nautobot Redis"),
|
("nautobot-redis", "10.40.20.158/24", "Nautobot Redis"),
|
||||||
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
|
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
|
||||||
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
|
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
|
||||||
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),
|
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import urllib.parse
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import urllib.parse
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
COMMIT = "--commit" in sys.argv
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import urllib.parse
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
COMMIT = "--commit" in sys.argv
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|||||||
135
nautobot/tp45-agh-firewall.py
Normal file
135
nautobot/tp45-agh-firewall.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP45 Schicht A — die AGH-DNS-Freigaben im Core-`firewall_filter`-Soll pflegen.
|
||||||
|
|
||||||
|
WAS HIER PASSIERT
|
||||||
|
-----------------
|
||||||
|
Die vier `TP5-E2: LAN->AGH dns*`-Regeln des Cores stehen als LITERALE im
|
||||||
|
`local_config_context_data.firewall_filter` (Nautobot) und werden vom GC-Template
|
||||||
|
nur gerendert. Zieht der Resolver um, muss das Soll mit — sonst meldet die
|
||||||
|
Compliance Drift, sobald das Geraet die neue Adresse traegt.
|
||||||
|
|
||||||
|
add <ip> [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den
|
||||||
|
bestehenden AGH-Block einsortiert
|
||||||
|
remove <ip> [...] genau diese zwei Regeln wieder entfernen (= Rueckbau)
|
||||||
|
|
||||||
|
WARUM KEIN `ingest-core-firewall.py`
|
||||||
|
------------------------------------
|
||||||
|
Das ist ein RE-BASELINE-Werkzeug: es liest den Ist-Stand vom Geraet und erklaert
|
||||||
|
ihn zum Soll. Fuer eine *geplante* Aenderung ist es das falsche Werkzeug — es
|
||||||
|
wuerde die Aenderung mit sich selbst absegnen. Hier wird das Soll explizit
|
||||||
|
gesetzt, das Geraet zieht per `gc-configplan.py --job tp45-agh-fw` nach.
|
||||||
|
|
||||||
|
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 # Dry-Run
|
||||||
|
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 --apply
|
||||||
|
|
||||||
|
REIHENFOLGE IST SEMANTISCH — die ComplianceRule steht auf `config_ordered`. Neue
|
||||||
|
Regeln landen hinter der bestehenden `.37`-tcp-Zeile, also weiterhin VOR der
|
||||||
|
`TP5: log-only`-Zeile und damit vor dem finalen `drop`.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
DEVICE = "crs326-core"
|
||||||
|
COMMENT_UDP = "TP5-E2: LAN->AGH dns"
|
||||||
|
COMMENT_TCP = "TP5-E2: LAN->AGH dns-tcp"
|
||||||
|
ANCHOR = 'comment="TP5-E2: LAN->AGH dns-tcp" dst-address='
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
tok = os.environ.get("NAUTOBOT_TOKEN")
|
||||||
|
if tok:
|
||||||
|
return tok
|
||||||
|
# Fallback: aus dem (gitignored) Stack-File, Wert wird nie ausgegeben.
|
||||||
|
yml = (pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
/ "docs/nas-vlan20/stacks/210-nautobot.yml")
|
||||||
|
for line in yml.read_text(errors="replace").splitlines():
|
||||||
|
key, sep, val = line.strip().lstrip("- ").partition("=")
|
||||||
|
if sep and key.strip() == "NAUTOBOT_SUPERUSER_API_TOKEN":
|
||||||
|
return val.strip().strip("'\"")
|
||||||
|
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{NB}{path}", method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {token()}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def rules_for(ip):
|
||||||
|
# Attributreihenfolge alphabetisch, exakt wie RouterOS /export sie schreibt —
|
||||||
|
# dieselbe Konvention wie ingest-core-firewall.py und das Template.
|
||||||
|
return [
|
||||||
|
f'add action=accept chain=forward comment="{COMMENT_UDP}" '
|
||||||
|
f'dst-address={ip} dst-port=53 protocol=udp',
|
||||||
|
f'add action=accept chain=forward comment="{COMMENT_TCP}" '
|
||||||
|
f'dst-address={ip} dst-port=53 protocol=tcp',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_agh(rule, ip):
|
||||||
|
return f"dst-address={ip}" in rule and "TP5-E2: LAN->AGH dns" in rule
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if len(argv) < 3 or argv[0] not in ("add", "remove"):
|
||||||
|
raise SystemExit(__doc__.strip().splitlines()[-1])
|
||||||
|
mode = argv[0]
|
||||||
|
ips = [a for a in argv[1:] if not a.startswith("--")]
|
||||||
|
apply_ = "--apply" in argv
|
||||||
|
|
||||||
|
dev = nb(f"/api/dcim/devices/?name={DEVICE}")["results"][0]
|
||||||
|
ctx = dict(dev.get("local_config_context_data") or {})
|
||||||
|
lines = list(ctx.get("firewall_filter") or [])
|
||||||
|
if not lines:
|
||||||
|
raise SystemExit("firewall_filter ist leer — falsches Geraet?")
|
||||||
|
|
||||||
|
new = list(lines)
|
||||||
|
for ip in ips:
|
||||||
|
present = [l for l in new if is_agh(l, ip)]
|
||||||
|
if mode == "add":
|
||||||
|
if present:
|
||||||
|
print(f" {ip}: schon vorhanden ({len(present)} Regeln) — uebersprungen")
|
||||||
|
continue
|
||||||
|
# hinter die letzte bestehende AGH-Zeile einsortieren
|
||||||
|
idx = max((i for i, l in enumerate(new) if ANCHOR in l), default=None)
|
||||||
|
if idx is None:
|
||||||
|
raise SystemExit(f" kein AGH-Anker ({ANCHOR}...) im Soll gefunden")
|
||||||
|
new[idx + 1:idx + 1] = rules_for(ip)
|
||||||
|
print(f" {ip}: +2 Regeln nach Index {idx}")
|
||||||
|
else:
|
||||||
|
if len(present) != 2:
|
||||||
|
raise SystemExit(f" {ip}: {len(present)} statt 2 Regeln gefunden — Abbruch")
|
||||||
|
new = [l for l in new if not is_agh(l, ip)]
|
||||||
|
print(f" {ip}: -2 Regeln entfernt")
|
||||||
|
|
||||||
|
if new == lines:
|
||||||
|
print("\n = unveraendert, nichts zu tun.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n== Diff ({len(lines)} -> {len(new)} Zeilen) ==")
|
||||||
|
for d in difflib.unified_diff(lines, new, "IST", "SOLL", lineterm="", n=1):
|
||||||
|
print(" " + d[:170])
|
||||||
|
|
||||||
|
if not apply_:
|
||||||
|
print(f"\nDRY-RUN — mit --apply nach "
|
||||||
|
f"local_config_context_data.firewall_filter schreiben.")
|
||||||
|
return
|
||||||
|
ctx["firewall_filter"] = new
|
||||||
|
nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print("\n ~ geschrieben. Geraet zieht nach per: "
|
||||||
|
"python3 nautobot/gc-configplan.py --job tp45-agh-fw --apply")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
131
nautobot/tp45-ip-move.py
Normal file
131
nautobot/tp45-ip-move.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP45 Schicht A — primaere IP eines Nautobot-Gastes umziehen.
|
||||||
|
|
||||||
|
WARUM ES DIESES SKRIPT GIBT
|
||||||
|
---------------------------
|
||||||
|
Nautobot verbietet das Aendern einer IP-Adresse nach dem Anlegen
|
||||||
|
(`{"__all__": ["Host address cannot be changed once created"]}`). Ein Umzug
|
||||||
|
ist deshalb kein PATCH, sondern ein Dreischritt je Gast:
|
||||||
|
|
||||||
|
1. neues ipam.IPAddress am Ziel anlegen (Status/Namespace vom Altobjekt)
|
||||||
|
2. altes Objekt an die VM-Schnittstelle haengen und primary_ip4 umhaengen
|
||||||
|
3. altes Objekt loeschen
|
||||||
|
|
||||||
|
Von Hand ist das pro Gast drei API-Aufrufe in genau dieser Reihenfolge — bei
|
||||||
|
~15 Gaesten in Schicht A eine sichere Fehlerquelle. Reihenfolge ist zwingend:
|
||||||
|
wer das Altobjekt vor dem Umhaengen loescht, reisst die primary_ip4-Referenz.
|
||||||
|
|
||||||
|
⚠️ Die IP allein ist NICHT der ganze Umzug. Ein Gast, den der Core ueber eine
|
||||||
|
Firewall-Adressliste erreicht (z.B. `nautobot`), braucht die neue IP ZUERST in
|
||||||
|
dieser Liste — sonst steht der Zugriff weg, bevor die Migration durch ist.
|
||||||
|
|
||||||
|
python3 nautobot/tp45-ip-move.py # Dry-Run (Default)
|
||||||
|
python3 nautobot/tp45-ip-move.py --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
# (VM-Name, alte IP, neue IP) — Oktett-Klassen nach TP32, s. docs/teilprojekte.md ### TP45
|
||||||
|
# Charge 1 (2026-09-21, angewendet). Die Altobjekte sind weg — ein erneuter Lauf
|
||||||
|
# bricht darum sauber ab ("0 Treffer"), er zieht nichts doppelt um.
|
||||||
|
MOVES = [
|
||||||
|
("nautobot", "10.40.20.30/24", "10.40.20.154/24"),
|
||||||
|
("nautobot-worker", "10.40.20.31/24", "10.40.20.155/24"),
|
||||||
|
("nautobot-scheduler", "10.40.20.32/24", "10.40.20.156/24"),
|
||||||
|
("nautobot-redis", "10.40.20.34/24", "10.40.20.158/24"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
raise SystemExit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML}")
|
||||||
|
|
||||||
|
|
||||||
|
TOK = load_token()
|
||||||
|
|
||||||
|
|
||||||
|
def api(path, method="GET", payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path, method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {TOK}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.load(r) if r.status != 204 else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise SystemExit(f"{method} {path} -> {e.code}: {e.read().decode()[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def only(path, expect=1):
|
||||||
|
res = api(path)["results"]
|
||||||
|
if len(res) != expect:
|
||||||
|
raise SystemExit(f"{path}: {len(res)} Treffer, erwartet {expect}")
|
||||||
|
return res[0] if expect == 1 else res
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP45 IP-Move ({'APPLY' if APPLY else 'DRY-RUN'}) ==")
|
||||||
|
for vm_name, old_ip, new_ip in MOVES:
|
||||||
|
print(f"\n-- {vm_name}: {old_ip} -> {new_ip} --")
|
||||||
|
old = only(f"/ipam/ip-addresses/?address={old_ip}")
|
||||||
|
vm = only(f"/virtualization/virtual-machines/?name={vm_name}")
|
||||||
|
|
||||||
|
ref = vm.get("primary_ip4")
|
||||||
|
ref = ref.get("id") if isinstance(ref, dict) else ref
|
||||||
|
if ref != old["id"]:
|
||||||
|
raise SystemExit(f" primary_ip4 von {vm_name} zeigt nicht auf {old_ip} "
|
||||||
|
f"(ist {ref}) — Abbruch, nichts geschrieben")
|
||||||
|
|
||||||
|
if api(f"/ipam/ip-addresses/?address={new_ip}")["count"]:
|
||||||
|
raise SystemExit(f" {new_ip} ist in Nautobot schon belegt — Abbruch")
|
||||||
|
print(f" Altobjekt={old['id'][:8]} primary_ip4 bestaetigt Ziel frei")
|
||||||
|
|
||||||
|
if not APPLY:
|
||||||
|
print(" [DRY-RUN] wuerde: anlegen -> eth0 verknuepfen -> primary_ip4 "
|
||||||
|
"umhaengen -> Altobjekt loeschen")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# namespace ist am Altobjekt nicht serialisiert und beim Anlegen Pflicht
|
||||||
|
ns = old.get("namespace")
|
||||||
|
ns_id = (ns or {}).get("id") if isinstance(ns, dict) else ns
|
||||||
|
if not ns_id:
|
||||||
|
ns_id = only("/ipam/namespaces/?name=Global")["id"]
|
||||||
|
st = old.get("status")
|
||||||
|
new = api("/ipam/ip-addresses/", "POST",
|
||||||
|
{"address": new_ip,
|
||||||
|
"status": st["id"] if isinstance(st, dict) else st,
|
||||||
|
"namespace": ns_id, "dns_name": ""})
|
||||||
|
print(f" + angelegt {new['id'][:8]} @ {new['address']}")
|
||||||
|
|
||||||
|
vmif = api(f"/virtualization/interfaces/?virtual_machine={vm['id']}&name=eth0")["results"]
|
||||||
|
if vmif:
|
||||||
|
api("/ipam/ip-address-to-interface/", "POST",
|
||||||
|
{"ip_address": new["id"], "vm_interface": vmif[0]["id"]})
|
||||||
|
print(f" + verknuepft mit {vm_name}/eth0 ({vmif[0]['id'][:8]})")
|
||||||
|
else:
|
||||||
|
print(" ~ keine eth0-Schnittstelle — Verknuepfung uebersprungen")
|
||||||
|
|
||||||
|
api(f"/virtualization/virtual-machines/{vm['id']}/", "PATCH", {"primary_ip4": new["id"]})
|
||||||
|
print(f" + primary_ip4 umgehaengt")
|
||||||
|
|
||||||
|
api(f"/ipam/ip-addresses/{old['id']}/", "DELETE")
|
||||||
|
print(f" - Altobjekt {old_ip} geloescht")
|
||||||
|
|
||||||
|
print("\nFertig." if APPLY else "\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
368
nautobot/tp45-leases.py
Normal file
368
nautobot/tp45-leases.py
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP45 Schicht B — die DHCP-Reservierungen des Cores ins Soll heben.
|
||||||
|
|
||||||
|
DIE LUECKE, DIE DAS SCHLIESST
|
||||||
|
-----------------------------
|
||||||
|
`nautobot/mikrotik_routeros.j2` rendert bis 2026-09-21 **kein einziges**
|
||||||
|
`/ip dhcp-server lease`. Die 54 statischen Reservierungen des Cores stammen aus dem
|
||||||
|
Einmal-Skript `routeros/tp21-core-dhcp-from-ap1.rsc` (Core-Cutover 2026-08-06) und
|
||||||
|
stehen in **keinem** Soll.
|
||||||
|
|
||||||
|
Folge: ein GC-Recovery von null erzeugt einen Core ohne Reservierungen — jedes
|
||||||
|
IoT-Geraet faellt in den Pool, und weil Compliance nur prueft, was gerendert wird,
|
||||||
|
bliebe die Zahl dabei gruen. Dieselbe Klasse wie die `nautobot`-Adressliste
|
||||||
|
(s. CLAUDE.md): "was auf dem Geraet existiert, aber nicht im Template, ist eine
|
||||||
|
Recovery-Luecke". Zusaetzlich war keine einzige Reservierung drift-ueberwacht.
|
||||||
|
|
||||||
|
DAS SOLL
|
||||||
|
--------
|
||||||
|
Die Zeilen liegen wie `firewall_filter` als LISTE von Strings in
|
||||||
|
`local_config_context_data.dhcp_leases` auf `crs326-core` und werden vom Template
|
||||||
|
mit `/ip dhcp-server lease {{ line }}` gerendert.
|
||||||
|
|
||||||
|
🔴 **Zeilenform ist die RouterOS-`/export`-Form, alphabetisch, ohne fuehrenden Pfad**
|
||||||
|
(genau wie `firewall_filter`):
|
||||||
|
add address=10.40.30.244 comment=NukiBridge mac-address=24:62:AB:B1:63:F4 server=dhcp-v30
|
||||||
|
|
||||||
|
🔴 **Die REIHENFOLGE wird erhalten, nicht sortiert.** Am Geraet steht die Sektion in
|
||||||
|
Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet) — `:export` gibt sie so aus, und
|
||||||
|
das Backup wird textuell verglichen. Ein numerisch sortiertes Soll waere ein
|
||||||
|
Dauer-Falsch-Drift.
|
||||||
|
|
||||||
|
🔴 **Zeilen werden NICHT neu gebaut, sondern uebernommen und nur im `address=`-Token
|
||||||
|
geaendert.** Damit bleiben Quoting (`comment="P750W / P750W"` vs `comment=NukiBridge`)
|
||||||
|
und die 14 `client-id`-Felder automatisch korrekt — beides nachzubauen waere eine
|
||||||
|
Fehlerquelle ohne Nutzen.
|
||||||
|
|
||||||
|
WARUM DER LESEWEG UEBER NAUTOBOT GEHT
|
||||||
|
-------------------------------------
|
||||||
|
Quelle fuer `seed` ist das GC-`backup_config` des Cores (taeglich 03:30 UTC bzw.
|
||||||
|
per `gc-configplan.py --refresh-compliance`). Kein Geraete-Credential noetig.
|
||||||
|
⚠️ `seed` ist damit ein RE-BASELINE und darf **nur** laufen, solange der Ist-Stand
|
||||||
|
der gewollte ist (Schicht B1). Fuer die geplante Aenderung gibt es `move`, das auf
|
||||||
|
dem **bestehenden Soll** rechnet — ein Soll, das dem Ist nachlaeuft, erkennt keine
|
||||||
|
Drift mehr (genau der Grund, warum `ingest-core-firewall.py` manuell bleibt).
|
||||||
|
|
||||||
|
python3 nautobot/tp45-leases.py check # Soll vs. Backup-Diff
|
||||||
|
python3 nautobot/tp45-leases.py seed # Dry-Run
|
||||||
|
python3 nautobot/tp45-leases.py seed --apply # B1
|
||||||
|
python3 nautobot/tp45-leases.py seed --force --apply # Neuschnitt, ueberschreibt
|
||||||
|
python3 nautobot/tp45-leases.py move # Dry-Run, s. MOVES
|
||||||
|
python3 nautobot/tp45-leases.py move --apply # B2
|
||||||
|
python3 nautobot/tp45-leases.py revert # Dry-Run, s. REVERT (nur Shellys)
|
||||||
|
python3 nautobot/tp45-leases.py revert --apply # Ruecknahme 2026-09-21
|
||||||
|
python3 nautobot/tp45-leases.py restore # Dry-Run: ALLES zurueck auf vor B2
|
||||||
|
python3 nautobot/tp45-leases.py restore --apply # Vollruecknahme 2026-09-21
|
||||||
|
|
||||||
|
Token: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
Der Wert wird nie ausgegeben.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NB = "http://10.40.20.154:8080"
|
||||||
|
DEVICE = "crs326-core"
|
||||||
|
KEY = "dhcp_leases"
|
||||||
|
PREFIX = "/ip dhcp-server lease "
|
||||||
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs/nas-vlan20/stacks/210-nautobot.yml"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Der Adressumzug (Schicht B2). Schluessel = MAC, Wert = (ist, soll).
|
||||||
|
# Quelle der Soll-Spalte: docs/teilprojekte.md ### TP45 / der Plan.
|
||||||
|
# Steht hier als Tabelle und nicht als Ableitung — der Umzug ist eine Absicht,
|
||||||
|
# kein Rechenergebnis.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MOVES = {
|
||||||
|
# fest verbaute Infrastruktur -> .10-.19
|
||||||
|
"24:62:AB:B1:63:F4": ("10.40.30.244", "10.40.30.11"), # NukiBridge
|
||||||
|
"4C:EB:BD:E3:25:F2": ("10.40.30.5", "10.40.30.13"), # P750W (Etikettendrucker)
|
||||||
|
"54:F2:9F:12:51:F0": ("10.40.30.8", "10.40.30.14"), # DTUBI-414392337823
|
||||||
|
# Shelly-Block .151-.176 (s1 -> .151 ... s26 -> .176)
|
||||||
|
"E4:B0:63:D7:C7:A4": ("10.40.30.208", "10.40.30.151"), # shelly-s1
|
||||||
|
"28:37:2F:36:F4:7C": ("10.40.30.209", "10.40.30.152"), # shelly-s2
|
||||||
|
"E4:B0:63:E4:EF:7C": ("10.40.30.210", "10.40.30.153"), # shelly-s3
|
||||||
|
"E4:B0:63:E4:0F:30": ("10.40.30.211", "10.40.30.154"), # shelly-s4
|
||||||
|
"D8:85:AC:0C:5C:C8": ("10.40.30.212", "10.40.30.155"), # shelly-s5
|
||||||
|
"D0:CF:13:D8:18:FC": ("10.40.30.213", "10.40.30.156"), # shelly-s6
|
||||||
|
"D0:CF:13:D9:94:E8": ("10.40.30.214", "10.40.30.157"), # shelly-s7
|
||||||
|
"E4:B0:63:FA:FD:E0": ("10.40.30.215", "10.40.30.158"), # shelly-s8
|
||||||
|
"E8:F6:0A:61:88:08": ("10.40.30.216", "10.40.30.159"), # shelly-s9
|
||||||
|
"E4:B0:63:FC:57:E4": ("10.40.30.217", "10.40.30.160"), # shelly-s10
|
||||||
|
"E4:B0:63:FA:6C:00": ("10.40.30.218", "10.40.30.161"), # shelly-s11
|
||||||
|
"48:F6:EE:C8:E6:AC": ("10.40.30.219", "10.40.30.162"), # shelly-s12
|
||||||
|
"48:F6:EE:DD:6A:F4": ("10.40.30.220", "10.40.30.163"), # shelly-s13
|
||||||
|
"E4:B0:63:FC:59:48": ("10.40.30.221", "10.40.30.164"), # shelly-s14
|
||||||
|
"E4:B0:63:FA:D5:34": ("10.40.30.222", "10.40.30.165"), # shelly-s15
|
||||||
|
"10:20:BA:FA:F5:40": ("10.40.30.223", "10.40.30.166"), # shelly-s16
|
||||||
|
"10:20:BA:FB:6D:64": ("10.40.30.224", "10.40.30.167"), # shelly-s17
|
||||||
|
"48:F6:EE:CC:16:E4": ("10.40.30.225", "10.40.30.168"), # shelly-s18
|
||||||
|
"10:20:BA:FB:70:FC": ("10.40.30.226", "10.40.30.169"), # shelly-s19
|
||||||
|
"98:A3:16:7C:5F:EC": ("10.40.30.206", "10.40.30.170"), # shelly-s20
|
||||||
|
"20:6E:F1:01:F9:F8": ("10.40.30.207", "10.40.30.171"), # shelly-s21
|
||||||
|
"A0:85:E3:C8:65:34": ("10.40.30.202", "10.40.30.172"), # shelly-s22
|
||||||
|
"AC:15:18:7B:62:20": ("10.40.30.203", "10.40.30.173"), # shelly-s23
|
||||||
|
"14:33:5C:D8:2D:7C": ("10.40.30.204", "10.40.30.174"), # shelly-s24
|
||||||
|
"98:A3:16:79:38:50": ("10.40.30.205", "10.40.30.175"), # shelly-s25
|
||||||
|
"98:A3:16:7A:A3:54": ("10.40.30.233", "10.40.30.176"), # shelly-s26 (bad luefter)
|
||||||
|
# App-Block .177-.199
|
||||||
|
"28:DB:A7:09:7B:54": ("10.40.30.241", "10.40.30.177"), # NukiSmartlock
|
||||||
|
"B8:F8:62:09:F2:04": ("10.40.30.227", "10.40.30.178"), # SwitchBot-floor-lamp
|
||||||
|
"98:F4:AB:2C:A7:88": ("10.40.30.228", "10.40.30.179"), # Twinkly-2CA789
|
||||||
|
"A4:F0:0F:5F:D0:EC": ("10.40.30.236", "10.40.30.190"), # lichterkette
|
||||||
|
"24:DC:C3:9B:89:64": ("10.40.30.237", "10.40.30.191"), # lichterkette-mini
|
||||||
|
"00:24:E4:40:2A:72": ("10.40.30.230", "10.40.30.195"), # Withings_waage
|
||||||
|
"A4:7E:FA:18:2C:64": ("10.40.30.231", "10.40.30.196"), # Withings_blutdruck
|
||||||
|
# .150 (Ender3V3KE) und .180 (espressif/Airfryer) bleiben, wo sie sind.
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RUECKNAHME 2026-09-21 (Christoph-Direktive, sofort): NUR die Shellys.
|
||||||
|
#
|
||||||
|
# Die 26 Shelly-Reservierungen gehen auf ihre ALTEN Adressen zurueck. Grund:
|
||||||
|
# Home Assistant hat sie mit den alten Adressen eingebunden -- der Umzug haette
|
||||||
|
# die HA-Integrationen zerrissen. Die uebrigen 10 Umzuege (NukiBridge, P750W,
|
||||||
|
# DTUBI, NukiSmartlock, SwitchBot, Twinkly, Lichterketten, Withings) BLEIBEN.
|
||||||
|
#
|
||||||
|
# Bewusst als eigene Menge und nicht als Bereichs-Ableitung: welche MAC eine
|
||||||
|
# Shelly ist, steht nur im Kommentar der Soll-Zeile, nicht im MOVES-Key.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SHELLY = {
|
||||||
|
"E4:B0:63:D7:C7:A4", "28:37:2F:36:F4:7C", "E4:B0:63:E4:EF:7C",
|
||||||
|
"E4:B0:63:E4:0F:30", "D8:85:AC:0C:5C:C8", "D0:CF:13:D8:18:FC",
|
||||||
|
"D0:CF:13:D9:94:E8", "E4:B0:63:FA:FD:E0", "E8:F6:0A:61:88:08",
|
||||||
|
"E4:B0:63:FC:57:E4", "E4:B0:63:FA:6C:00", "48:F6:EE:C8:E6:AC",
|
||||||
|
"48:F6:EE:DD:6A:F4", "E4:B0:63:FC:59:48", "E4:B0:63:FA:D5:34",
|
||||||
|
"10:20:BA:FA:F5:40", "10:20:BA:FB:6D:64", "48:F6:EE:CC:16:E4",
|
||||||
|
"10:20:BA:FB:70:FC", "98:A3:16:7C:5F:EC", "20:6E:F1:01:F9:F8",
|
||||||
|
"A0:85:E3:C8:65:34", "AC:15:18:7B:62:20", "14:33:5C:D8:2D:7C",
|
||||||
|
"98:A3:16:79:38:50", "98:A3:16:7A:A3:54",
|
||||||
|
}
|
||||||
|
if len(SHELLY) != 26:
|
||||||
|
raise SystemExit("SHELLY hat %d MACs statt 26 -- Tabelle beschaedigt." % len(SHELLY))
|
||||||
|
# Schluessel = MAC, Wert = (aktuelle Soll-Adresse, Adresse davor) -- also die
|
||||||
|
# Umkehrung von MOVES, aber nur fuer den Shelly-Block.
|
||||||
|
REVERT = {mac: (soll, ist) for mac, (ist, soll) in MOVES.items() if mac in SHELLY}
|
||||||
|
if len(REVERT) != 26:
|
||||||
|
raise SystemExit("REVERT hat %d Eintraege statt 26 -- MOVES/SHELLY passen nicht zusammen."
|
||||||
|
% len(REVERT))
|
||||||
|
|
||||||
|
# CCU-Leiche: VLAN30-Reservierung `pve-homematic-raspi-ccu`, status=waiting,
|
||||||
|
# last-seen=never. Die CCU ist live in VLAN20 (10.40.20.232, ARP+HTTP belegt,
|
||||||
|
# Caddys ccu.bf proxyt dorthin) — dieser Eintrag ist ein Zombie aus dem
|
||||||
|
# Core-Cutover. Er wird ENTFERNT, nicht umgezogen.
|
||||||
|
REMOVE = {"BC:24:11:8E:0A:57"}
|
||||||
|
|
||||||
|
# Die Zeile, mit der B2 die CCU-Leiche entfernt hat -- wortgleich der Zustand
|
||||||
|
# VOR B2, Quelle `routeros/tp21-core-dhcp-from-ap1.rsc` Z. 70 (dort ohne
|
||||||
|
# `disabled`). Fuer den vollstaendigen Rueckweg wird sie wieder angelegt.
|
||||||
|
RESTORE_ADD = ("add address=10.40.30.232 comment=pve-homematic-raspi-ccu "
|
||||||
|
"mac-address=BC:24:11:8E:0A:57 server=dhcp-v30")
|
||||||
|
|
||||||
|
# SONOFF-NSPanelPro hat KEINE Reservierung, nur eine dynamische Lease auf .249.
|
||||||
|
# Wird in B2 per make-static + set address gepinnt. Zeile hier nur als Soll.
|
||||||
|
ADDS = [
|
||||||
|
"add address=10.40.30.12 client-id=1:7c:88:99:6:7:ec comment=SONOFF-NSPanelPro "
|
||||||
|
"mac-address=7C:88:99:06:07:EC server=dhcp-v30",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
for line in YML.read_text(errors="replace").splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
NB + path, method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": "Token " + token(),
|
||||||
|
"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit("!! %s %s -> %s\n %s" % (method, path, e.code, e.read().decode()[:600]))
|
||||||
|
|
||||||
|
|
||||||
|
def device():
|
||||||
|
return nb("/api/dcim/devices/?name=" + DEVICE)["results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def backup_lines():
|
||||||
|
"""Die `/ip dhcp-server lease`-Zeilen des Cores aus dem GC-Backup, in Geraete-Reihenfolge."""
|
||||||
|
rows = nb("/api/plugins/golden-config/golden-config/?depth=1&limit=0")["results"]
|
||||||
|
for r in rows:
|
||||||
|
dev = r.get("device")
|
||||||
|
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||||
|
if dev == DEVICE:
|
||||||
|
cfg = r.get("backup_config") or ""
|
||||||
|
when = (r.get("backup_last_success_date") or "?")[:19]
|
||||||
|
# 🔴 len(PREFIX), NICHT l[1:]: das Argument-Teil ist ohne den ganzen Befehl
|
||||||
|
# zu speichern — genau wie firewall_filter, wo `add action=…` steht und
|
||||||
|
# das Template `/ip firewall filter ` davorsetzt. Ein blosses l[1:] liess
|
||||||
|
# `ip dhcp-server lease add …` stehen und erzeugte im gerenderten Soll
|
||||||
|
# den Befehl DOPPELT (`/ip dhcp-server lease ip dhcp-server lease add …`).
|
||||||
|
# Gefunden am 2026-09-21 an der ersten Compliance-Zahl nach dem Einbau:
|
||||||
|
# 144/145 statt 145/145.
|
||||||
|
return ([l[len(PREFIX):] for l in cfg.splitlines() if l.startswith(PREFIX)], when)
|
||||||
|
raise SystemExit("kein GC-Eintrag für " + DEVICE)
|
||||||
|
|
||||||
|
|
||||||
|
def mac_of(line):
|
||||||
|
m = re.search(r"\bmac-address=(\S+)", line)
|
||||||
|
return m.group(1).upper() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def set_address(line, ip):
|
||||||
|
new, n = re.subn(r"(?<=^add address=)\S+", ip, line)
|
||||||
|
if n != 1:
|
||||||
|
raise SystemExit("address-Token nicht eindeutig in: " + line)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def show(old, new):
|
||||||
|
print("== Diff (%d -> %d Zeilen) ==" % (len(old), len(new)))
|
||||||
|
om, nm = {mac_of(l): l for l in old}, {mac_of(l): l for l in new}
|
||||||
|
for mac in sorted(set(om) | set(nm)):
|
||||||
|
a, b = om.get(mac), nm.get(mac)
|
||||||
|
if a == b:
|
||||||
|
continue
|
||||||
|
if a is None:
|
||||||
|
print(" + %s" % b[:150])
|
||||||
|
elif b is None:
|
||||||
|
print(" - %s" % a[:150])
|
||||||
|
else:
|
||||||
|
print(" ~ %s -> %s" % (re.search(r"address=(\S+)", a).group(1),
|
||||||
|
re.search(r"address=(\S+)", b).group(1)))
|
||||||
|
print(" %s" % b[:150])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if not argv or argv[0] not in ("seed", "move", "revert", "restore", "check"):
|
||||||
|
raise SystemExit(__doc__.strip().splitlines()[-2].strip())
|
||||||
|
cmd, apply_ = argv[0], "--apply" in argv
|
||||||
|
dev = device()
|
||||||
|
ctx = dict(dev.get("local_config_context_data") or {})
|
||||||
|
soll = list(ctx.get(KEY) or [])
|
||||||
|
|
||||||
|
if cmd == "check":
|
||||||
|
ist, when = backup_lines()
|
||||||
|
print("== check: %s / %s (Backup %s, %d Zeilen) ==" % (DEVICE, KEY, when, len(ist)))
|
||||||
|
if not soll:
|
||||||
|
print(" Soll ist LEER — Reservierungen stehen in keinem Soll.")
|
||||||
|
return
|
||||||
|
show(soll, ist)
|
||||||
|
print(" (Diff oben: '-' = nur im Soll, '~' = Adresse weicht ab)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if cmd == "seed":
|
||||||
|
if soll and "--force" not in argv:
|
||||||
|
raise SystemExit("Soll ist NICHT leer (%d Zeilen) — `seed` ist ein Re-Baseline. "
|
||||||
|
"Fuer eine geplante Aenderung `move` benutzen; fuer einen "
|
||||||
|
"bewussten Neuschnitt `--force`." % len(soll))
|
||||||
|
new, when = backup_lines()
|
||||||
|
print("== seed aus Backup %s (%d Zeilen)%s =="
|
||||||
|
% (when, len(new), " [--force, ueberschreibt %d Zeilen]" % len(soll) if soll else ""))
|
||||||
|
elif cmd == "restore":
|
||||||
|
if not soll:
|
||||||
|
raise SystemExit("Soll ist leer — nichts wiederherzustellen.")
|
||||||
|
drop = {mac_of(a) for a in ADDS}
|
||||||
|
new, seen, moved, dropped = [], set(), [], []
|
||||||
|
for line in soll:
|
||||||
|
mac = mac_of(line)
|
||||||
|
if mac in drop:
|
||||||
|
dropped.append(mac)
|
||||||
|
continue
|
||||||
|
if mac in MOVES:
|
||||||
|
ist_ip, soll_ip = MOVES[mac]
|
||||||
|
cur = re.search(r"address=(\S+)", line).group(1)
|
||||||
|
if cur != ist_ip:
|
||||||
|
if cur != soll_ip:
|
||||||
|
raise SystemExit("%s: Soll traegt weder %s noch %s — Soll, MOVES und "
|
||||||
|
"Wirklichkeit auseinander. Zeile: %s"
|
||||||
|
% (mac, ist_ip, soll_ip, line[:120]))
|
||||||
|
line = set_address(line, ist_ip)
|
||||||
|
moved.append(mac)
|
||||||
|
seen.add(mac)
|
||||||
|
new.append(line)
|
||||||
|
fehlend = set(MOVES) - seen
|
||||||
|
if fehlend:
|
||||||
|
raise SystemExit("MOVES nennt MACs, die im Soll fehlen: %s"
|
||||||
|
% ", ".join(sorted(fehlend)))
|
||||||
|
for mac in sorted(REMOVE):
|
||||||
|
if mac not in seen:
|
||||||
|
new.append(RESTORE_ADD)
|
||||||
|
moved.append(mac)
|
||||||
|
print("== restore: %d Adressen zurueck, %d Zeilen entfernt, %d wieder angelegt "
|
||||||
|
"(Soll jetzt %d Zeilen) ==" % (len(moved), len(dropped), len(REMOVE), len(new)))
|
||||||
|
elif cmd == "revert":
|
||||||
|
if not soll:
|
||||||
|
raise SystemExit("Soll ist leer — es gibt nichts zurueckzunehmen.")
|
||||||
|
new, seen = [], set()
|
||||||
|
for line in soll:
|
||||||
|
mac = mac_of(line)
|
||||||
|
if mac in REVERT:
|
||||||
|
neu_ip, alt_ip = REVERT[mac]
|
||||||
|
if re.search(r"address=(\S+)", line).group(1) != neu_ip:
|
||||||
|
raise SystemExit("%s: Soll traegt nicht %s — Soll und REVERT auseinander. "
|
||||||
|
"Zeile: %s" % (mac, neu_ip, line[:120]))
|
||||||
|
line = set_address(line, alt_ip)
|
||||||
|
seen.add(mac)
|
||||||
|
new.append(line)
|
||||||
|
if seen != set(REVERT):
|
||||||
|
raise SystemExit("REVERT nennt MACs, die im Soll fehlen: %s"
|
||||||
|
% ", ".join(sorted(set(REVERT) - seen)))
|
||||||
|
print("== revert: %d Shelly-Reservierungen zurueck auf die alten Adressen =="
|
||||||
|
% len(REVERT))
|
||||||
|
else:
|
||||||
|
if not soll:
|
||||||
|
raise SystemExit("Soll ist leer — erst `seed` laufen lassen (Schicht B1).")
|
||||||
|
new, unknown = [], set()
|
||||||
|
seen = set()
|
||||||
|
for line in soll:
|
||||||
|
mac = mac_of(line)
|
||||||
|
if mac in REMOVE:
|
||||||
|
seen.add(mac)
|
||||||
|
continue
|
||||||
|
if mac in MOVES:
|
||||||
|
ist_ip, soll_ip = MOVES[mac]
|
||||||
|
if re.search(r"address=(\S+)", line).group(1) != ist_ip:
|
||||||
|
raise SystemExit("%s: Ist-Adresse im Soll ist nicht %s — Soll und MOVES "
|
||||||
|
"auseinander. Zeile: %s" % (mac, ist_ip, line[:120]))
|
||||||
|
line = set_address(line, soll_ip)
|
||||||
|
seen.add(mac)
|
||||||
|
new.append(line)
|
||||||
|
for mac in list(MOVES) + list(REMOVE):
|
||||||
|
if mac not in seen:
|
||||||
|
unknown.add(mac)
|
||||||
|
if unknown:
|
||||||
|
raise SystemExit("MOVES/REMOVE nennen MACs, die im Soll fehlen: %s"
|
||||||
|
% ", ".join(sorted(unknown)))
|
||||||
|
for add in ADDS:
|
||||||
|
if mac_of(add) not in {mac_of(l) for l in new}:
|
||||||
|
new.append(add)
|
||||||
|
print("== move: %d Adressen verschoben, %d entfernt, %d ergaenzt =="
|
||||||
|
% (len(MOVES), len(REMOVE), len(ADDS)))
|
||||||
|
|
||||||
|
show(soll, new)
|
||||||
|
if not apply_:
|
||||||
|
print("\nDRY-RUN — mit --apply nach local_config_context_data.%s schreiben." % KEY)
|
||||||
|
return
|
||||||
|
ctx[KEY] = new
|
||||||
|
nb("/api/dcim/devices/%s/" % dev["id"], "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print("\n ~ %d Zeilen nach local_config_context_data.%s geschrieben." % (len(new), KEY))
|
||||||
|
print(" Geraet zieht nach per: python3 nautobot/gc-configplan.py --refresh-compliance")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
75
opencode.json
Normal file
75
opencode.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import pathlib
|
|||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
NAUTOBOT_URL = "http://10.40.20.30:8080"
|
NAUTOBOT_URL = "http://10.40.20.154:8080"
|
||||||
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
|
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
|
||||||
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")
|
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")
|
||||||
|
|||||||
112
routeros/tp10-ddns-cf-updater.rsc
Normal file
112
routeros/tp10-ddns-cf-updater.rsc
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# TP10 — Cloudflare-DDNS-Updater fuer bf4.emsenhuber.net
|
||||||
|
# =============================================================================
|
||||||
|
# Zweck: der WG-Hub-Endpoint der Spokes (fm/dw) ist bf4.emsenhuber.net. Der
|
||||||
|
# A-Record muss der Liwest-Public-IP des Core folgen. Heute manuell gepflegt.
|
||||||
|
#
|
||||||
|
# BASIS: bajodel/mikrotik-cloudflare-dns (MIT, Copyright (c) 2025 bajodel)
|
||||||
|
# https://github.com/bajodel/mikrotik-cloudflare-dns
|
||||||
|
# Aenderungen ggue. dem Original (Christoph-Entscheid 2026-10-03):
|
||||||
|
# 1. IP kommt aus der DHCP-Lease (client1/vlan100), NICHT von Echo-Diensten.
|
||||||
|
# Liwest ist die Quelle, kein Dritter; kein Cloudflare im Pfad.
|
||||||
|
# 2. check-certificate=yes (Original-Default war false -> Token ungeprueft).
|
||||||
|
# 3. Nur EIN Record (bf4), Konfiguration auf das Noetige reduziert.
|
||||||
|
#
|
||||||
|
# Token: liegt NICHT hier, sondern als globale Script-Variable `cfDdnsToken`.
|
||||||
|
# Einmalig setzen per SSH (siehe docs/teilprojekte.md ### TP10).
|
||||||
|
#
|
||||||
|
# Installation: /import file=tp10-ddns-cf-updater.rsc
|
||||||
|
# danach /system scheduler add ... (siehe unten)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---- Konfiguration ----------------------------------------------------------
|
||||||
|
:local DnsRcName "bf4.emsenhuber.net"
|
||||||
|
:local DnsZoneID "1b5bc02fdf61b6b989afde543f3f4134" # CF_ZONE_ID aus mcp/cloudflare/.env
|
||||||
|
:local DnsRcrdID "dba9d9ec3b261fdb314c0f83e4e62f0d"
|
||||||
|
:local RecordTTL "120" # wie der bestehende Record
|
||||||
|
:local RecordProxied "false" # WG-Endpoint: NICHT proxied
|
||||||
|
:local VerboseLog false
|
||||||
|
:local WanDhcpName "client1"
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Token aus /system script environment holen (NIE im Skriptkopf, NIE per :global).
|
||||||
|
# Grund (2026-10-03): :global cfDdnsToken liest im Skript-Kontext "nothing", obwohl
|
||||||
|
# /system script environment cfDdnsToken gesetzt ist. CLI-Scope und Skript-Scope sind
|
||||||
|
# auf RouterOS 7.24 unterschiedlich. `/system script environment get` ist scope-unabhaengig.
|
||||||
|
:do {
|
||||||
|
:local tk ""
|
||||||
|
:local found [/system script environment find name=cfDdnsToken]
|
||||||
|
:if ([:len $found] > 0) do={
|
||||||
|
:set tk [/system script environment get [find name=cfDdnsToken] value]
|
||||||
|
}
|
||||||
|
:if ([:typeof $tk] = "") do={
|
||||||
|
:log error "[tp10-ddns] cfDdnsToken nicht gesetzt - Abbruch"
|
||||||
|
:error "cfDdnsToken fehlt"
|
||||||
|
}
|
||||||
|
:global cfDdnsToken
|
||||||
|
:set cfDdnsToken $tk
|
||||||
|
} on-error={
|
||||||
|
:log error "[tp10-ddns] cfDdnsToken nicht lesbar - Abbruch"
|
||||||
|
:error "cfDdnsToken fehlt"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- WAN-IPv4 aus der DHCP-Lease (Quelle: Liwest, kein Dritter) ------------
|
||||||
|
:local CFWanIP4New ""
|
||||||
|
:do {
|
||||||
|
:local leaseAddr [/ip dhcp-client get [find name=$WanDhcpName] address]
|
||||||
|
:if ([:typeof $leaseAddr] != "nothing") do={
|
||||||
|
# "90.146.33.177/24" -> "90.146.33.177"
|
||||||
|
:local slashPos [:find $leaseAddr "/"]
|
||||||
|
:if ($slashPos >= 0) do={
|
||||||
|
:set CFWanIP4New [:pick $leaseAddr 0 $slashPos]
|
||||||
|
} else={
|
||||||
|
:set CFWanIP4New $leaseAddr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on-error={
|
||||||
|
:log error "[tp10-ddns] DHCP-Lease nicht lesbar ($WanDhcpName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
:if ($CFWanIP4New = "") do={
|
||||||
|
:log warning "[tp10-ddns] Keine WAN-IP aus der Lease, naechster Lauf"
|
||||||
|
} else={
|
||||||
|
|
||||||
|
# ---- Guard: nur oeffentliche IPv4 zulassen --------------------------------
|
||||||
|
# Faengt den Fall ab, dass DHCP Muell liefert (privat/CGNAT) -> kein Write.
|
||||||
|
:local isPrivate false
|
||||||
|
:if ([:pick $CFWanIP4New 0 3] = "10.") do={ :set isPrivate true }
|
||||||
|
:if ([:pick $CFWanIP4New 0 8] = "192.168.") do={ :set isPrivate true }
|
||||||
|
:if ([:pick $CFWanIP4New 0 7] = "172.16.") do={ :set isPrivate true }
|
||||||
|
:if ([:pick $CFWanIP4New 0 8] = "100.64.0") do={ :set isPrivate true } # CGNAT 100.64/10
|
||||||
|
:if ($isPrivate) do={
|
||||||
|
:log error "[tp10-ddns] Lease liefert nicht-oeffentliche IP ($CFWanIP4New) - ABBRUCH, kein Write"
|
||||||
|
} else={
|
||||||
|
|
||||||
|
# ---- Nur bei Abweichung schreiben (Idempotenz) --------------------------
|
||||||
|
:global CFWanIP4Cur
|
||||||
|
:if ($CFWanIP4New != $CFWanIP4Cur) do={
|
||||||
|
:if ($VerboseLog) do={ :log info "[tp10-ddns] WAN-IP: $CFWanIP4Cur -> $CFWanIP4New" }
|
||||||
|
|
||||||
|
# Kommentar am Record: Zeit + Geraet, wie im Original
|
||||||
|
:local ds [/system clock get date]
|
||||||
|
:local ts [/system clock get time]
|
||||||
|
:local datetime ([:pick $ds 0 4] . "." . [:pick $ds 5 7] . "." . [:pick $ds 8 10] . "-" . [:pick $ts 0 2] . ":" . [:pick $ts 3 5])
|
||||||
|
:local CFComment ("$datetime by crs326-core")
|
||||||
|
|
||||||
|
:local url "https://api.cloudflare.com/client/v4/zones/$DnsZoneID/dns_records/$DnsRcrdID/"
|
||||||
|
:do {
|
||||||
|
:local res [/tool fetch http-method=put mode=https url=$url check-certificate=yes output=user as-value http-header-field="Authorization: Bearer $cfDdnsToken,Content-Type: application/json" http-data="{\"type\":\"A\",\"name\":\"$DnsRcName\",\"content\":\"$CFWanIP4New\",\"ttl\":$RecordTTL,\"proxied\":$RecordProxied,\"comment\":\"$CFComment\"}"]
|
||||||
|
:if (($res->"status") = "finished") do={
|
||||||
|
:log warning "[tp10-ddns] $DnsRcName -> $CFWanIP4New aktualisiert"
|
||||||
|
:set CFWanIP4Cur $CFWanIP4New
|
||||||
|
} else={
|
||||||
|
:log error "[tp10-ddns] Update fehlgeschlagen (status != finished)"
|
||||||
|
}
|
||||||
|
} on-error={
|
||||||
|
:log error "[tp10-ddns] Exception beim Cloudflare-Update - naechster Lauf"
|
||||||
|
}
|
||||||
|
} else={
|
||||||
|
:if ($VerboseLog) do={ :log info "[tp10-ddns] WAN-IP unveraendert ($CFWanIP4New)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user