Compare commits
38 Commits
claude/luc
...
tp45-schem
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
.secrets
|
||||
|
||||
# Per-Projekt-MCP-Configs: können Tokens/URLs enthalten, projekt-spezifisch, nicht ins Repo
|
||||
.mcp.json
|
||||
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
67
.mcp.json
Normal file
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -6,6 +6,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
||||
|
||||
## Pflichtlektüre pro Sitzung (Reihenfolge)
|
||||
|
||||
0. 🔴 **TP45 = aktuell dringendster Posten (2026-09-21, Gitea #71):** interne `*.bf.emsenhuber.net`
|
||||
lösen im Browser **nicht** auf (AGH wertet Rewrites im **Filter-Pfad** aus → für unfiltrierte
|
||||
Clients übersprungen → Fallback auf den Core, der nur 10 von 26 Namen hält), und `ds-bf` ist
|
||||
**öffentlich kaputt** (einziger Ingress-Eintrag, der Caddy umgeht → TP5-Drop). Reihenfolge:
|
||||
VLAN20 ins TP32-Schema → VLAN30-Pool-Grenze → DNS/FQDN. Ausführlich: `### TP45`.
|
||||
**`### TP46`** = CF-/Tunnel-SSoT + Recovery (eigene Baustelle).
|
||||
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Für den **Detail/Kontext** eines TP dann den `### TPnn`-Prosa-Abschnitt in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`** lesen. *(Die frühere „Quick-Index-Block, 1 Read reicht"-Zeile ist überholt — Index/Status stehen nicht mehr in der Datei.)*
|
||||
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`**.
|
||||
@@ -20,6 +26,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
||||
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
|
||||
**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).
|
||||
⚠️ **Rueckgenommen am 2026-09-14 (Terrasse-In-situ-Session): `ft=yes ft-over-ds=yes` zurueck** — mit
|
||||
`ft=no` war jeder Roam eine Voll-Reassoziation, darin starben 2 Anrufe; Rueckkehrstand = Fund-E-Known-Good
|
||||
von 09-02 (ruhig auf 7.24.1). Weg: Template + `push-gc-template.py` + `gc-configplan.py
|
||||
--refresh-compliance` + `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed) → **140/140, 0 Drifts**;
|
||||
CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands:
|
||||
`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`, s. Handoff 2026-09-14.
|
||||
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf
|
||||
`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)
|
||||
@@ -53,7 +65,7 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
||||
|
||||
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
|
||||
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
|
||||
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell.
|
||||
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master, nicht manuell. 🔴 **Korrigiert 2026-09-12:** die frühere Angabe „2×/Tag" war nie real — es gab **keinen** Scheduler, Backup/Intended/Compliance liefen nur manuell aus Sessions (zuletzt 09-04, 8 Tage Stillstand). Jetzt **täglich 03:30 UTC** per ScheduledJob **„GC Backup+Compliance – taeglich"** (Nautobot-Job *Execute All Golden Configuration Jobs - Multiple Device*, Scope `platform=RouterOS`, Reihenfolge Backup→Intended→Compliance). Stand prüfen: `backup_last_success_date` am Gerät bzw. `scheduled-jobs` in Nautobot.
|
||||
- **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo.
|
||||
- **`inventory/` ist tot** (Pipeline seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer.
|
||||
- **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen.
|
||||
@@ -98,15 +110,15 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
||||
|---|---|---|
|
||||
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
|
||||
| ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt |
|
||||
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap2) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
|
||||
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap3) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
|
||||
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** (2026-08-16 verifiziert) · **TP15/TP21.1 ✅ 2026-08-08: PRODUKTIVER L3-Router** (ersetzt ap1) — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 Stufe 1 ✅ 2026-08-18; `servers=1.1.1.1` nur noch Bootstrap) · **NTP-Server für den Standort**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
|
||||
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · 🔴 **FUNKSTILL seit 2026-08-30**: keine Provisioning-Regel am Manager, weil ap3 daneben steht und denselben Kanal 5180 fährt. Gerät bleibt erreichbar. Beim Umzug ans Sideboard → `5260/Ce` (Band B, DFS) und Uplink auf `core ether3` — Uplink `ether1` (PoE-Port, `poe-out=off`) an crs326-core `ether15`; SSIDs/Kanäle kommen vom Manager, ch36/ch1 · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` |
|
||||
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — Uplink `ether1` (PoE) an crs326-branch `ether24`; ch44/ch11 (TP25: ch149 war unter ETSI leistungsbegrenzt) · MAC D0:EA:11:4D:40:3A · Config `routeros/.secrets/ap2-prov-base.rsc` |
|
||||
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · Uplink `ether1` → **core `ether21`** (Trunk) · Kanäle **`5180/ax/Ce/I`** (5 GHz) + **`2437/ax`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
|
||||
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · ✅ **aktiv am Sideboard** (Wohnzimmer; Uplink `ether1` → core `ether3`, PoE-Port `poe-out=off`) — Kanäle `5260/Ce` (Band B, DFS, Fallback 5280) + `2412`/ch1, vom Manager · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` |
|
||||
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — **Technik UG**, Uplink `ether1` (PoE) an core `ether21`; Kanäle `5300/Ce` (Band B, DFS, Fallback 5320) + `2462`/ch11 · MAC D0:EA:11:4D:40:39 (ether1; Bridge/Mgmt `:3A`) · Config `routeros/.secrets/ap2-prov-base.rsc` |
|
||||
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · **Technik OG**, Uplink `ether1` → **branch `ether24`** (Trunk) · Kanäle **`5180/ax`** (Band A, non-DFS, 5 GHz) + **`2437`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
|
||||
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
|
||||
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) |
|
||||
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
|
||||
| Nautobot (Portainer-Stack, NAS) | 10.40.20.30 | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
||||
| Nautobot (Portainer-Stack, NAS) | **10.40.20.154** (TP45 Schicht A 2026-09-21: `.30/.31/.32/.34` → `.154/.155/.156/.158`) | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
||||
| ~~Omada-Controller~~ | — | ❌ **entfernt 2026-07-16** (User hat den Container gelöscht — overkill für 1 Switch auf der Abschussliste, s. TP2.1/TP15) |
|
||||
| ~~omada-mcp~~ | — | ❌ **entfernt 2026-07-16** — mit dem Controller gelöscht. ✅ **Aufräumen erledigt/gegenstandslos (geprüft 2026-08-16):** nicht mehr in `mcpServers` registriert (nur noch als „disabled" in ein paar Projekt-Scopes = harmloser Merkposten), und **kein `omada`-Hostname mehr im CF-Ingress** |
|
||||
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1 2026-07-10) · **mcp-pve v0.6.1 (Upgrade 2026-07-10: Single-Session-Bug 0.4.8 „Server already initialized" gefixt, TP14.2a)** · `~/.claude.json` ✓ |
|
||||
@@ -144,6 +156,25 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
||||
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans
|
||||
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`.
|
||||
- 🔴 **`pct push` meldet Exit 0, auch wenn es die Quelldatei NICHT lesen kann** (gemessen 2026-09-21).
|
||||
`pct push 130 <fehlende-datei> /etc/caddy/Caddyfile` → `failed to open … for reading`, **`rc=0`**.
|
||||
Ein `&&`/`||` hinter dem Push **beweist nichts**, und ein anschließendes `validate`/`reload` prüft
|
||||
dann die **alte** Datei → **grüne Abnahme bei unverändertem Zustand**. Genau so sah der
|
||||
Nautobot-Umzug am 2026-09-21 „gelandet" aus, während live noch die alte Backend-IP stand
|
||||
(Symptom: 502 bei gesundem Backend). **Regel: Push-Werkzeug wird geprüft, nicht geglaubt** — nach
|
||||
jedem `pct push` den Inhalt vergleichen (`md5sum` diesseits/jenseits in *einem* Remote-Aufruf,
|
||||
Abbruch bei Abweichung). Muster: `push_verified()` in `caddy/deploy.sh`. Dieselbe Klasse wie
|
||||
`nc -z` (09-01) und das `grep|sed`-Maskierungsmuster (08-31): **erst das eigene Werkzeug
|
||||
verdächtigen, dann das Netz.**
|
||||
- 🔴 **Die Core-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert** (Fund 2026-09-21).
|
||||
Der Mgmt-Zugang des Cores für die Deploy-Pipeline läuft über `src-address-list=nautobot`
|
||||
(Regel `*28`) — die Liste selbst stammt aber aus dem **handgelaufenen**
|
||||
`routeros/tp5-etappe2-whitelists.rsc` und steht in **keinem** Template (`grep address-list` im
|
||||
Template findet nur Kommentare). 🔴 **Ein GC-Recovery von null erzeugt damit einen Core, in den
|
||||
Nautobot nicht mehr per SSH kommt** — die Pipeline wäre tot und könnte sich nicht selbst
|
||||
reparieren. Gleiche Lücke wie die DHCP-Reservierungen. **Merkregel: was auf dem Gerät existiert,
|
||||
aber nicht im Template, ist eine Recovery-Lücke, auch wenn Compliance 140/140 grün meldet** —
|
||||
die Zahl prüft nur, was gerendert wird.
|
||||
Ablösung durch Komodo (native MCP) geplant.
|
||||
|
||||
---
|
||||
@@ -276,7 +307,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
||||
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
|
||||
- **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).
|
||||
- **ap3 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5). ap2 hängt seit dem TP25-Tausch (2026-09-15) an `core ether21` (Technik UG), ap1 an `core ether3` (Sideboard).
|
||||
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko).
|
||||
- **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
|
||||
|
||||
@@ -71,7 +71,7 @@ dns:
|
||||
ipset_file: ""
|
||||
bootstrap_prefer_ipv6: false
|
||||
upstream_timeout: 10s
|
||||
private_networks: []
|
||||
private_<redacted>: []
|
||||
use_private_ptr_resolvers: true
|
||||
local_ptr_upstreams: []
|
||||
use_dns64: false
|
||||
|
||||
@@ -27,6 +27,19 @@ Ein VLAN = eine CIDR-Gruppe. Alle nutzen denselben CF-Upstream-Filter; Divergenz
|
||||
|
||||
`filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar.
|
||||
|
||||
## Personen-Clients (Stufe 5 Redesign 2026-09-12) — SSoT für Person→Gerät
|
||||
Zusätzlich zu den VLAN-CIDR-Gruppen bekommt **jede Person einen eigenen AGH-Client**
|
||||
(`/control/clients`): `name` = Personenname, `ids` = **alle** Geräte-IPs der Person. Das ist der
|
||||
**einzige** Ort, an dem Person→Gerät gepflegt wird (kein zweites Mapping-File). Damit:
|
||||
- wirken Self-Service-Ausnahmen **pro Person**: `@@||domain^$client='Personenname'` statt global;
|
||||
- filtert `recent-blocked --client <person>` das Query-Log auf **ihre** Geräte;
|
||||
- bleiben Ausnahmen stabil bei Gerätewechsel (Name statt IP).
|
||||
|
||||
🔴 **On-network verifizieren, bevor Stufe 6 scharf geht:** dass AGH `$client=` auf **Allow**-Regeln
|
||||
(`@@`) in der laufenden Version greift. Fällt der Test negativ aus → Fallback `$client=<ip>|<ip>`
|
||||
(die Geräte-IPs); `agh-exception.py scoped_rule()` kann beides. Die CIDR-Gruppen (Policy-Grenze)
|
||||
bleiben unberührt — Personen-Clients tragen **nur** die Ausnahmen.
|
||||
|
||||
## Sync-Invariante
|
||||
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen,
|
||||
sondern **`.36` == `.37`**. Genau das prüft `agh-sync.py check`.
|
||||
@@ -42,11 +55,16 @@ Referenz-RMW-Implementierung, die D aufruft (Deploy-Weg auf HAOS in Etappe D zu
|
||||
|
||||
| Aktion | CLI | AGH-API (RMW) |
|
||||
|---|---|---|
|
||||
| Domain global entsperren | `allow-domain <d>` | `GET /control/filtering/status` → +`@@\|\|<d>^` → `POST /control/filtering/set_rules` |
|
||||
| Entsperrung zurück | `unallow-domain <d>` | dito, Regel entfernen |
|
||||
| Client entfiltern | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
||||
| Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
|
||||
| Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
|
||||
| Entsperrung zurück | `unallow-domain <d> [--client <person>]` | dito, exakt dieselbe Regel entfernen |
|
||||
| „meine Ausnahmen" | `list-exceptions --client <person> --json` | `GET /control/filtering/status`, `@@`-Regeln parsen/filtern |
|
||||
| „zuletzt geblockt" | `recent-blocked --client <person> --json` | `GET /control/querylog?response_status=blocked`, client-seitig auf Geräte-IPs gefiltert |
|
||||
| Client entfiltern (grob) | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
||||
| Client wieder filtern | `refilter <ip>` | dito, zurück auf global |
|
||||
|
||||
Reine Regel-/Filter-Logik ist netzunabhängig getestet: `python3 agh-exception.py selftest`.
|
||||
|
||||
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
|
||||
RouterOS-Backstop.
|
||||
|
||||
|
||||
@@ -2,22 +2,37 @@
|
||||
"""
|
||||
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .37).
|
||||
|
||||
Von der HA-Self-Service-Schicht (rest_command) ODER von Hand aufrufbar. Wirkt IMMER auf
|
||||
Von der HA-Self-Service-Schicht (shell_command) ODER von Hand aufrufbar. Wirkt IMMER auf
|
||||
BEIDE Instanzen (kein Sync im Datenpfad). KEIN eigenes Timing — Ablauf/Revert macht der
|
||||
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove.
|
||||
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove/read.
|
||||
|
||||
allow-domain <domain> → user_rules += @@||domain^ (Domain für ALLE entsperren)
|
||||
unallow-domain <domain> → user_rules -= @@||domain^
|
||||
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false
|
||||
allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
|
||||
unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
|
||||
list-exceptions [--client <name>] [--json] → alle @@-Regeln, nach Client gruppiert
|
||||
recent-blocked --client <name|ip[,ip]> [--limit N] [--json]
|
||||
→ zuletzt geblockte Domains DIESER Person
|
||||
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false (grob!)
|
||||
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
|
||||
status → user_rules + Client-Policy beider Instanzen
|
||||
|
||||
--client:
|
||||
* ein AGH-Client-NAME (empfohlen, stabil bei Gerätewechsel) → Regel bekommt $client='Name',
|
||||
IPs werden für recent-blocked aus /control/clients (ids) aufgelöst.
|
||||
* ODER eine/mehrere IP/CIDR, komma-getrennt → $client=<ip>|<ip>, recent-blocked filtert darauf.
|
||||
ohne --client bei allow/unallow = GLOBAL (für alle) — nur für Admin-Zwecke.
|
||||
|
||||
🔴 ANNAHME, on-network zu verifizieren, BEVOR Stufe 6 scharf geht: dass AGH $client= auf
|
||||
ALLOW-Regeln (@@) in der laufenden Version wirkt. Die Regelkonstruktion steht isoliert in
|
||||
scoped_rule()/parse_allow_rule() — falls die Syntax abweicht, nur dort anpassen.
|
||||
|
||||
Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv).
|
||||
"""
|
||||
import sys, os, json, base64, urllib.request, urllib.error, pathlib, re
|
||||
import sys, os, json, base64, urllib.request, urllib.error, urllib.parse, pathlib, re, ipaddress, argparse
|
||||
|
||||
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9]+)*\.)+[a-z]{2,}$", re.I)
|
||||
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
||||
CIDR_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$")
|
||||
# Ein Client-„spec" ist IP-artig, wenn JEDES komma-Glied IP oder CIDR ist; sonst = Name.
|
||||
|
||||
INST = ["10.40.20.36", "10.40.20.37"]
|
||||
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt.
|
||||
@@ -50,14 +65,20 @@ def creds():
|
||||
sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden")
|
||||
|
||||
|
||||
AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
||||
_AUTH = None
|
||||
def auth_header():
|
||||
# lazy: erst beim ersten echten API-Call — so bleibt das Modul ohne Creds importier-/testbar.
|
||||
global _AUTH
|
||||
if _AUTH is None:
|
||||
_AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
||||
return _AUTH
|
||||
|
||||
|
||||
def api(ip, path, method="GET", body=None):
|
||||
url = f"http://{ip}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Authorization": AUTH,
|
||||
headers={"Authorization": auth_header(),
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=8) as r:
|
||||
@@ -69,26 +90,129 @@ def api(ip, path, method="GET", body=None):
|
||||
sys.exit(f"{ip} {path} → {e}")
|
||||
|
||||
|
||||
def rule(domain):
|
||||
return f"@@||{domain}^"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reine Regel-Logik (netzunabhängig, self-testbar). Bei AGH-Syntax-Drift NUR hier anfassen.
|
||||
# ---------------------------------------------------------------------------
|
||||
def is_ip_spec(spec):
|
||||
"""True, wenn spec eine (Komma-)Liste aus lauter IPs/CIDRs ist (→ IP-spec), sonst Name."""
|
||||
parts = [s.strip() for s in spec.split(",") if s.strip()]
|
||||
return bool(parts) and all(IP_RE.match(p) or CIDR_RE.match(p) for p in parts)
|
||||
|
||||
|
||||
def allow(domain, remove=False):
|
||||
def client_modifier(spec):
|
||||
"""$client-Modifier-Wert für eine Regel. IP-spec → ip|ip ; Name → 'Name' (quoted)."""
|
||||
if is_ip_spec(spec):
|
||||
return "|".join(s.strip() for s in spec.split(",") if s.strip())
|
||||
return "'" + spec.replace("'", "") + "'"
|
||||
|
||||
|
||||
def scoped_rule(domain, client=None):
|
||||
"""@@||domain^ bzw. @@||domain^$client=… (client = Name oder ip[,ip])."""
|
||||
base = f"@@||{domain}^"
|
||||
return base if not client else f"{base}$client={client_modifier(client)}"
|
||||
|
||||
|
||||
_ALLOW_RE = re.compile(r"^@@\|\|(?P<domain>[^\^]+)\^(?:\$client=(?P<client>.+))?$")
|
||||
def parse_allow_rule(rule):
|
||||
"""@@-Regel → {'domain':…, 'client': <str oder None>, 'raw': rule} oder None."""
|
||||
m = _ALLOW_RE.match(rule.strip())
|
||||
if not m:
|
||||
return None
|
||||
return {"domain": m.group("domain"), "client": m.group("client"), "raw": rule.strip()}
|
||||
|
||||
|
||||
def ip_in_specs(ip, specs):
|
||||
"""Ist ip (aus dem Query-Log) von einer der specs (IP oder CIDR) erfasst?"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
for s in specs:
|
||||
s = s.strip()
|
||||
try:
|
||||
if "/" in s:
|
||||
if addr in ipaddress.ip_network(s, strict=False):
|
||||
return True
|
||||
elif ip == s:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def resolve_client_ips(spec):
|
||||
"""spec → Liste von IP/CIDR-strings. IP-spec direkt; Name → ids des AGH-Clients (von .36)."""
|
||||
if is_ip_spec(spec):
|
||||
return [s.strip() for s in spec.split(",") if s.strip()]
|
||||
data = api(INST[0], "/control/clients")
|
||||
for c in data.get("clients", []):
|
||||
if c.get("name") == spec:
|
||||
return list(c.get("ids", []))
|
||||
sys.exit(f"kein AGH-Client mit name={spec!r} (für IP-spec Komma-Liste aus IP/CIDR angeben)")
|
||||
|
||||
|
||||
def allow(domain, client=None, remove=False):
|
||||
domain = domain.strip().lower()
|
||||
if not DOMAIN_RE.match(domain):
|
||||
sys.exit(f"ungültige Domain: {domain!r}")
|
||||
r = rule(domain)
|
||||
r = scoped_rule(domain, client)
|
||||
for ip in INST:
|
||||
st = api(ip, "/control/filtering/status")
|
||||
rules = st.get("user_rules", [])
|
||||
if remove:
|
||||
rules = [x for x in rules if x != r]
|
||||
rules = [x for x in rules if x.strip() != r]
|
||||
elif r not in rules:
|
||||
rules = rules + [r]
|
||||
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
|
||||
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(rules)})")
|
||||
|
||||
|
||||
def list_exceptions(client=None, as_json=False):
|
||||
st = api(INST[0], "/control/filtering/status") # .36 == .37 (Invariante, via agh-sync geprüft)
|
||||
items = []
|
||||
for raw in st.get("user_rules", []):
|
||||
p = parse_allow_rule(raw)
|
||||
if not p:
|
||||
continue
|
||||
if client is not None and (p["client"] or "") != client_modifier(client):
|
||||
continue
|
||||
items.append(p)
|
||||
if as_json:
|
||||
print(json.dumps(items, ensure_ascii=False))
|
||||
return
|
||||
if not items:
|
||||
print("— keine Ausnahmen —"); return
|
||||
for p in items:
|
||||
who = p["client"] or "GLOBAL"
|
||||
print(f" {p['domain']:40} client={who}")
|
||||
|
||||
|
||||
def recent_blocked(client, limit=20, as_json=False):
|
||||
specs = resolve_client_ips(client)
|
||||
q = urllib.parse.urlencode({"response_status": "blocked", "limit": max(limit * 8, 100)})
|
||||
data = api(INST[0], f"/control/querylog?{q}")
|
||||
seen = {} # domain -> {count, last}
|
||||
for e in data.get("data", []):
|
||||
c = e.get("client", "")
|
||||
if not ip_in_specs(c, specs):
|
||||
continue
|
||||
dom = (e.get("question", {}) or {}).get("name", "").rstrip(".").lower()
|
||||
if not dom:
|
||||
continue
|
||||
rec = seen.setdefault(dom, {"domain": dom, "count": 0, "last": e.get("time", "")})
|
||||
rec["count"] += 1
|
||||
if e.get("time", "") > rec["last"]:
|
||||
rec["last"] = e.get("time", "")
|
||||
out = sorted(seen.values(), key=lambda r: r["last"], reverse=True)[:limit]
|
||||
if as_json:
|
||||
print(json.dumps(out, ensure_ascii=False)); return
|
||||
if not out:
|
||||
print("— nichts geblockt —"); return
|
||||
for r in out:
|
||||
print(f" {r['domain']:40} x{r['count']:<4} {r['last']}")
|
||||
|
||||
|
||||
def set_filter(ip_addr, enabled):
|
||||
if not IP_RE.match(ip_addr):
|
||||
sys.exit(f"ungültige IP: {ip_addr!r}")
|
||||
@@ -117,13 +241,47 @@ def status():
|
||||
print(f" {c['name']:16} ids={c['ids']} global={c['use_global_settings']} filt={c['filtering_enabled']}")
|
||||
|
||||
|
||||
def _selftest():
|
||||
"""Netzunabhängiger Test der reinen Regel-Logik."""
|
||||
assert is_ip_spec("10.40.10.82") and is_ip_spec("10.40.10.82,10.40.10.183")
|
||||
assert is_ip_spec("10.40.10.0/24") and not is_ip_spec("christoph")
|
||||
assert scoped_rule("x.com") == "@@||x.com^"
|
||||
assert scoped_rule("x.com", "10.40.10.82,10.40.10.183") == "@@||x.com^$client=10.40.10.82|10.40.10.183"
|
||||
assert scoped_rule("x.com", "christoph") == "@@||x.com^$client='christoph'"
|
||||
p = parse_allow_rule("@@||x.com^$client='christoph'")
|
||||
assert p and p["domain"] == "x.com" and p["client"] == "'christoph'"
|
||||
p2 = parse_allow_rule("@@||y.com^")
|
||||
assert p2 and p2["client"] is None
|
||||
assert parse_allow_rule("||block.com^") is None
|
||||
assert ip_in_specs("10.40.10.82", ["10.40.10.82"]) is True
|
||||
assert ip_in_specs("10.40.10.5", ["10.40.10.0/24"]) is True
|
||||
assert ip_in_specs("10.40.20.5", ["10.40.10.0/24"]) is False
|
||||
# round-trip: eine scoped Regel wird von list-exceptions --client wieder getroffen
|
||||
assert (parse_allow_rule(scoped_rule("z.com", "christoph"))["client"]
|
||||
== client_modifier("christoph"))
|
||||
print("selftest OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
a = sys.argv[1:]
|
||||
if not a: sys.exit(__doc__)
|
||||
cmd = a[0]
|
||||
if cmd == "allow-domain": allow(a[1])
|
||||
elif cmd == "unallow-domain": allow(a[1], remove=True)
|
||||
elif cmd == "unfilter": set_filter(a[1], False)
|
||||
elif cmd == "refilter": set_filter(a[1], True)
|
||||
elif cmd == "status": status()
|
||||
else: sys.exit(f"unbekannt: {cmd}\n{__doc__}")
|
||||
ap = argparse.ArgumentParser(add_help=False)
|
||||
ap.add_argument("cmd", nargs="?")
|
||||
ap.add_argument("arg", nargs="?")
|
||||
ap.add_argument("--client")
|
||||
ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier
|
||||
ap.add_argument("--limit", type=int, default=20)
|
||||
ap.add_argument("--json", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not a.cmd:
|
||||
sys.exit(__doc__)
|
||||
if a.cmd == "allow-domain": allow(a.arg, a.client)
|
||||
elif a.cmd == "unallow-domain": allow(a.arg, a.client, remove=True)
|
||||
elif a.cmd == "list-exceptions": list_exceptions(a.client, a.json)
|
||||
elif a.cmd == "recent-blocked":
|
||||
if not a.client: sys.exit("recent-blocked braucht --client <name|ip[,ip]>")
|
||||
recent_blocked(a.client, a.limit, a.json)
|
||||
elif a.cmd == "unfilter": set_filter(a.arg, False)
|
||||
elif a.cmd == "refilter": set_filter(a.arg, True)
|
||||
elif a.cmd == "status": status()
|
||||
elif a.cmd == "selftest": _selftest()
|
||||
else: sys.exit(f"unbekannt: {a.cmd}\n{__doc__}")
|
||||
|
||||
@@ -1,63 +1,98 @@
|
||||
# TP23 Stufe 5 — HA-Self-Service für DNS-Ausnahmen
|
||||
# TP23 Stufe 5 (Redesign 2026-09-12) — DAU-tauglicher, personenbezogener DNS-Self-Service
|
||||
|
||||
Home Assistant (HAOS-VM 100, `10.40.20.105`) gibt eine Ein-Klick-Oberfläche, um eine Domain
|
||||
temporär (JIT) über das AGH-Paar `.36`/`.37` zu entsperren. Ablauf/Revert nativ per `timer`.
|
||||
> 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only,
|
||||
> HA-Neustart bei neuer Domain) war „funktional ein kastriertes Pi-hole" (User-Befund 2026-09-12)
|
||||
> und ist ersetzt. Bau + Abnahme laufen **on-network** (HAOS `10.40.20.105`, AGH `.36/.37`) — beim
|
||||
> Anlegen den **home-assistant-best-practices-Skill** konsultieren und via **ha-mcp** in `.storage`
|
||||
> anlegen (native Helper, `entity_id` statt `device_id`). Diese Datei ist die Repro-/SSoT-Quelle.
|
||||
|
||||
**Warum kein `rest_command`:** die AGH-Endpunkte sind read-modify-write (`set_rules` ersetzt die
|
||||
ganze Liste). HA ruft deshalb die deployte RMW-CLI `agh-exception.py` per `shell_command`.
|
||||
## Ziel (User-Requirements 2026-09-12)
|
||||
Ein **einfaches, klar verständliches** End-User-UI zum Entsperren:
|
||||
- **alles was den Nutzer betrifft, nichts** von anderen / Admin / Power-User;
|
||||
- Policy nur sichtbar, wo sie für den einfachen Nutzer Relevanz hat.
|
||||
|
||||
## Bausteine (alle in HA .storage, via ha-mcp angelegt — keine YAML außer shell_command)
|
||||
| Typ | entity_id | Zweck |
|
||||
| # | Requirement | Umsetzung |
|
||||
|---|---|---|
|
||||
| input_text | `input_text.dns_ausnahme_domain` | einzugebende Domain |
|
||||
| input_text | `input_text.dns_ausnahme_aktiv` | aktuell aktive Ausnahme (für leak-freien Revert; init `""`) |
|
||||
| input_select | `input_select.dns_ausnahme_dauer` | 15 Minuten / 1 Stunde / 4 Stunden |
|
||||
| timer | `timer.dns_ausnahme` | JIT-Countdown (restore=false) |
|
||||
| input_button | `input_button.dns_ausnahme_starten` | Auslöser |
|
||||
| automation | `automation.dns_ausnahme_starten` | Button → validieren, alte Ausnahme aufräumen, `agh_allow_domain`, Timer starten |
|
||||
| automation | `automation.dns_ausnahme_ablauf` | `timer.finished`/`timer.cancelled` → `agh_unallow_domain`, `_aktiv` leeren |
|
||||
| 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs |
|
||||
| 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client <person>` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert |
|
||||
| 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** |
|
||||
| 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar |
|
||||
|
||||
## `configuration.yaml` (der einzige YAML-Teil, Backup `.bak-tp23-*`)
|
||||
## Identitätsmodell — AGH-Client je Person ist die SSoT
|
||||
- Pro Person **ein AGH-Client** (`/control/clients`), `name` = Personenname, `ids` = **alle** Geräte-IPs
|
||||
der Person (die per DHCP faktisch statisch sind). Damit ist Person→Gerät **an einem Ort** gepflegt
|
||||
(kein zweites Mapping-File), und Ausnahmen sind stabil bei Gerätewechsel.
|
||||
- Ausnahme-Regel = `@@||domain^$client='Personenname'` (statt globalem `@@||domain^`).
|
||||
🔴 **Vor Scharfschalten on-network verifizieren, dass AGH `$client=` auf `@@`-Regeln wirkt**
|
||||
(s. `agh-exception.py` Kopf). Fällt der Test negativ aus: Fallback = `$client=<ip>|<ip>` mit den
|
||||
Geräte-IPs statt dem Namen (Code kann beides, `scoped_rule()`).
|
||||
- Die bestehenden **VLAN-CIDR-Gruppen** bleiben die Policy-Grenze (Filter an/aus je VLAN);
|
||||
Personen-Clients sind spezifischer und tragen nur die *Ausnahmen*.
|
||||
|
||||
## CLI (deployt nach `/config/agh-exception.py`, wirkt auf `.36`+`.37`)
|
||||
```
|
||||
allow-domain <domain> --client <person> # Ausnahme setzen (temp: HA revertiert; perm: kein Timer)
|
||||
unallow-domain <domain> --client <person> # dieselbe Ausnahme entfernen
|
||||
list-exceptions --client <person> --json # „meine Ausnahmen" (für Übersicht + Entfernen)
|
||||
recent-blocked --client <person> --json # „zuletzt geblockt" (nur Geräte der Person)
|
||||
```
|
||||
`--client` nimmt einen AGH-Client-Namen (→ IPs via `/control/clients`) ODER eine Komma-IP/CIDR-Liste.
|
||||
|
||||
## `configuration.yaml` — `shell_command` (einziger YAML-Teil, Backup `.bak-tp23-*`)
|
||||
Domain **und** Client sind Parameter → **kein HA-Neustart mehr** bei neuer Domain (das war der alte
|
||||
DAU-Killer: pro Domain ein eigener `shell_command`).
|
||||
```yaml
|
||||
shell_command:
|
||||
agh_allow_domain: "python3 /config/agh-exception.py allow-domain '{{ domain }}'"
|
||||
agh_unallow_domain: "python3 /config/agh-exception.py unallow-domain '{{ domain }}'"
|
||||
agh_unfilter_client: "python3 /config/agh-exception.py unfilter '{{ ip }}'"
|
||||
agh_refilter_client: "python3 /config/agh-exception.py refilter '{{ ip }}'"
|
||||
agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'"
|
||||
agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'"
|
||||
```
|
||||
Neue Domain → **HA-Neustart** nötig (reload_all lädt eine erstmals hinzugefügte Domain nicht).
|
||||
|
||||
## Deploy auf HAOS (Zugang `ssh admin@10.40.20.105`, sudo passwortlos)
|
||||
- `/config/agh-exception.py` — Kopie von `adguard/agh-exception.py` (liest Creds via `/config/agh-exception.secrets`).
|
||||
- `/config/agh-exception.secrets` (chmod 600, root) — `AGH_AUTOMATION_USER`/`_PASS`.
|
||||
- Redeploy nach Script-Änderung: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
|
||||
## Helfer / Entities (via ha-mcp in .storage)
|
||||
Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug:
|
||||
| Typ | entity_id | Zweck |
|
||||
|---|---|---|
|
||||
| command_line sensor | `sensor.dns_recent_blocked_<slug>` | `recent-blocked --client <person> --json` → `json_attributes: items` (Liste {domain,count,last}); Scan-Intervall ~60 s |
|
||||
| command_line sensor | `sensor.dns_exceptions_<slug>` | `list-exceptions --client <person> --json` → aktive Ausnahmen der Person |
|
||||
| input_select | `input_select.dns_dauer_<slug>` | 1 Stunde / 4 Stunden / **dauerhaft** |
|
||||
| timer | `timer.dns_ausnahme_<slug>` | JIT-Countdown je temporärer Freigabe (restore=false) |
|
||||
| input_boolean | `input_boolean.dns_permanent_ohne_anfrage` | **global**: an = „dauerhaft" wirkt sofort (Start); aus = erzeugt Admin-Genehmigung |
|
||||
| script | `script.dns_freigeben_<slug>` | nimmt `domain` als Feld → `agh_allow`; bei „dauerhaft"+Switch-aus → `todo`/Notification statt Freigabe; sonst Timer starten |
|
||||
| automation | `automation.dns_ablauf_<slug>` | `timer.finished`/`.cancelled` → `agh_unallow`, Aufräumen |
|
||||
|
||||
## Bedienung
|
||||
Domain eintragen → Dauer wählen → **DNS-Ausnahme starten**. Bei Ablauf/Abbruch wird die Regel auf
|
||||
beiden AGH automatisch zurückgenommen. Ungültige Domains werden HA- **und** CLI-seitig abgelehnt.
|
||||
## Dashboard — ein scoped View je Person
|
||||
- Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit
|
||||
der user-id; `require_admin:false`). Kleiner Haushalt → ein View je Person ist praktikabel und
|
||||
umgeht das „Template kennt den aktuellen Betrachter nicht"-Problem.
|
||||
- **Karte 1 „Zuletzt geblockt (antippen zum Freigeben)":** rendert `sensor.dns_recent_blocked_<slug>`
|
||||
Attribut `items`; jede Zeile hat eine `tap_action` → `script.dns_freigeben_<slug>` mit `domain`
|
||||
der Zeile + aktueller `input_select`-Dauer. (Dynamische Per-Zeilen-tap_action braucht i.d.R.
|
||||
`auto-entities` + `template`/`button-card` **oder** ein kleines Custom-List-Template — **beim Bau
|
||||
on-network entscheiden**, das ist der Teil, der Live-Verifikation braucht.)
|
||||
- **Karte 2 „Meine Ausnahmen":** `sensor.dns_exceptions_<slug>` als Liste, je Eintrag Entfernen
|
||||
(`agh_unallow`).
|
||||
- **Karte 3 „Dauer":** `input_select.dns_dauer_<slug>` (inkl. „dauerhaft").
|
||||
- **Kein** globaler Status, keine fremden Clients, keine Policy-Interna. Nur wenn relevant ein
|
||||
kurzer Hinweis („durch den Familien-Filter blockiert").
|
||||
|
||||
## Dashboard (1. Draft, 2026-09-06)
|
||||
Eigenes storage-mode Dashboard `url_path: dns-ausnahmen` (Sidebar, `require_admin: false`),
|
||||
angelegt via `ha_config_set_dashboard`. Da es in HA-`.storage` lebt, hier die Repro-Quelle:
|
||||
```json
|
||||
{"views":[{"title":"DNS-Ausnahmen","path":"dns","type":"sections","icon":"mdi:dns","max_columns":2,"sections":[
|
||||
{"type":"grid","cards":[
|
||||
{"type":"heading","heading":"Domain temporaer freigeben","icon":"mdi:lock-open-variant"},
|
||||
{"type":"entities","entities":[{"entity":"input_text.dns_ausnahme_domain","name":"Domain"},{"entity":"input_select.dns_ausnahme_dauer","name":"Dauer"}]},
|
||||
{"type":"button","name":"DNS-Ausnahme starten","icon":"mdi:lock-open-check","tap_action":{"action":"perform-action","perform_action":"input_button.press","target":{"entity_id":"input_button.dns_ausnahme_starten"}},"grid_options":{"columns":"full"}}]},
|
||||
{"type":"grid","cards":[
|
||||
{"type":"heading","heading":"Status","icon":"mdi:information-outline"},
|
||||
{"type":"tile","entity":"timer.dns_ausnahme","name":"Restlaufzeit"},
|
||||
{"type":"tile","entity":"input_text.dns_ausnahme_aktiv","name":"Aktive Ausnahme"},
|
||||
{"type":"markdown","content":"**AGH-Resolver-Paar** — .36: {{ states('binary_sensor.adguard_bf1_status') }} / .37: {{ states('binary_sensor.adguard_bf2_status') }}"}]},
|
||||
{"type":"grid","cards":[
|
||||
{"type":"heading","heading":"VLAN-Filter-Policy (AGH-Gruppen)","heading_style":"subtitle"},
|
||||
{"type":"markdown","content":"MAIN/IOT/UNTRUSTED=an · INFRA/MEDIA/MGMT=aus · GUEST=an+SafeSearch/Parental · dns-bypass-exempt=aus"}]}
|
||||
]}]}
|
||||
```
|
||||
⚠️ Draft-Notiz: `binary_sensor.adguard_bf1_status` ist stale (hing am alten `.16`-Container, TP42) —
|
||||
zeigt evtl. `unavailable`, obwohl `.36` läuft. Sensor-Quelle gehört bei Gelegenheit auf `.36` gefixt.
|
||||
## Genehmigungs-Variante (Modus-Switch aus)
|
||||
`input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen
|
||||
Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim
|
||||
Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der
|
||||
Switch entscheidet zur Laufzeit.
|
||||
|
||||
## Verifiziert (2026-09-04)
|
||||
Button → `@@||doubleclick.net^` auf `.36`+`.37`, Timer 15:00, `_aktiv` gesetzt; Timer-Abbruch →
|
||||
Regel auf beiden entfernt, `_aktiv` geleert, `.36==.37`.
|
||||
## Alt-Reste, die beim Bau wegfallen/fixen
|
||||
- Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`,
|
||||
`automation…_starten/_ablauf`) und der `agh_allow_domain`/`_unallow_domain`-**pro-Domain**-Zwang.
|
||||
- Statuskachel-Sensor `binary_sensor.adguard_bf1_status` hing am toten `.16` → auf `.36`/`.37` neu.
|
||||
|
||||
## Abnahme (on-network, = neue echte Stufe-6-Vorbedingung)
|
||||
Mit einem **echten Nicht-Admin-Login**: geblockte Seite → erscheint in *ihrer* Fläche → 1 Klick
|
||||
temporär (revertiert nach Ablauf) **und** 1 Klick dauerhaft (bleibt) → wirkt **nur** für diese
|
||||
Person (Gegentest: anderer Client sieht die Domain weiter geblockt). `.36 == .37`
|
||||
(`agh-sync.py check`). Modus-Switch beide Wege einmal durchgespielt.
|
||||
|
||||
## Verweise
|
||||
- CLI: `adguard/agh-exception.py` (Subcommands + `selftest`). Deploy-Weg unverändert (base64→scp,
|
||||
s. unten). Creds `/config/agh-exception.secrets` (chmod 600).
|
||||
- Redeploy: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
|
||||
- Personen-Client-Modell: `adguard/README.md`. Kontext/Reihenfolge: TP23 in `docs/teilprojekte.md`.
|
||||
|
||||
@@ -47,7 +47,7 @@ gitea.emsenhuber.net {
|
||||
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
|
||||
# =========================================================================
|
||||
nautobot.bf.emsenhuber.net {
|
||||
reverse_proxy http://10.40.20.30:8080
|
||||
reverse_proxy http://10.40.20.154:8080
|
||||
}
|
||||
|
||||
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
|
||||
|
||||
@@ -19,12 +19,35 @@ if [ -z "${TOKEN}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 🔴 `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann
|
||||
# ("failed to open <src> for reading", rc=0 — live gemessen 2026-09-21).
|
||||
# Ein `&&`/`||` hinter dem Push beweist deshalb NICHTS, und die anschliessende
|
||||
# `caddy validate` validiert dann die ALTE Datei -> gruene Abnahme bei
|
||||
# unveraendertem Zustand. Genau so ist der Nautobot-Umzug am 2026-09-21
|
||||
# scheinbar gelandet, obwohl live noch die alte Backend-IP stand.
|
||||
# Deshalb: Push + md5-Vergleich in EINEM Remote-Aufruf, der bei Abweichung
|
||||
# hart abbricht. Gepusht wird nur der lokal bekannte Pfad — die frueheren
|
||||
# `\$\$`/`$$`-Verrenkungen waren der Ausloeser.
|
||||
push_verified() { # push_verified <quelle-auf-pve> <ziel-im-ct> <perms> <label>
|
||||
local src=$1 dst=$2 perms=$3 label=$4
|
||||
ssh "${PVE}" "set -e
|
||||
pct push ${CTID} '${src}' '${dst}' --perms ${perms}
|
||||
want=\$(md5sum '${src}' | cut -d' ' -f1)
|
||||
got=\$(pct exec ${CTID} -- md5sum '${dst}' | cut -d' ' -f1)
|
||||
rm -f '${src}'
|
||||
if [ \"\$want\" != \"\$got\" ]; then
|
||||
echo \"FEHLER: ${label} nicht gelandet (\$want != \$got)\" >&2; exit 1
|
||||
fi
|
||||
echo \" ${label} verifiziert (\$got)\""
|
||||
}
|
||||
|
||||
echo "-> Caddyfile"
|
||||
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
|
||||
ssh "${PVE}" "pct push ${CTID} /tmp/.caddyfile.\$\$ /etc/caddy/Caddyfile --perms 644 2>/dev/null || pct push ${CTID} /tmp/.caddyfile.$$ /etc/caddy/Caddyfile --perms 644; rm -f /tmp/.caddyfile.$$"
|
||||
push_verified "/tmp/.caddyfile.$$" "/etc/caddy/Caddyfile" 644 "Caddyfile"
|
||||
|
||||
echo "-> Token (maskiert: Laenge ${#TOKEN})"
|
||||
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$ && pct push ${CTID} /tmp/.cenv.$$ /etc/caddy/caddy.env --perms 600 && rm -f /tmp/.cenv.$$"
|
||||
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$"
|
||||
push_verified "/tmp/.cenv.$$" "/etc/caddy/caddy.env" 600 "caddy.env"
|
||||
|
||||
echo "-> validate"
|
||||
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
|
||||
|
||||
47
docs/announcements.md
Normal file
47
docs/announcements.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Announcements — bf-infra
|
||||
|
||||
Breaking News / Querschnitts-Änderungen, die **jede** folgende Session kennen muss, bevor sie
|
||||
losarbeitet — Dinge, die man sonst erst nach dem ersten Fehlschlag merkt. Kein Ersatz für die
|
||||
TP-Prosa (`docs/teilprojekte.md`) oder das Gitea-Board; hier steht nur, was *quer* zu den TPs gilt.
|
||||
|
||||
**Format je Eintrag:** Überschrift `## YYYY-MM-DD — <Titel>`, dann die Zeilen
|
||||
**Betrifft:** (wer/was) und **Aktion:** (was eine Session konkret anders machen muss),
|
||||
optional ein, zwei Sätze Kontext. **Neueste oben.** Der Skill `/news` liest diese Datei und
|
||||
zeigt die Einträge, die seit dem letzten `/news`-Lauf dazugekommen sind (Rest auf Wunsch).
|
||||
|
||||
<!-- TP35 Schicht 4. Eintrag hinzufügen = neuen ##-Block ganz oben unter dieser Zeile einfügen. -->
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-12 — Golden-Config-Backups laufen jetzt täglich (vorher KEIN Scheduler)
|
||||
**Betrifft:** alle, die sich auf GC-Backups/Compliance oder `gc-snapshot.py`-Werte verlassen.
|
||||
**Aktion:** Es gab **nie** einen automatischen GC-Backup-Zeitplan — Backup/Intended/Compliance liefen
|
||||
nur manuell aus Sessions (zuletzt 09-04 → 8 Tage Stillstand). Jetzt ScheduledJob **„GC Backup+Compliance
|
||||
– taeglich"** (Nautobot, *Execute All GC Jobs - Multiple Device*, `platform=RouterOS`, täglich 03:30 UTC,
|
||||
Reihenfolge Backup→Intended→Compliance). Wenn `backup_last_success_date` eines Geräts alt ist: zuerst
|
||||
prüfen, ob der ScheduledJob noch `enabled` ist (`/api/extras/scheduled-jobs/`), nicht das Gerät verdächtigen.
|
||||
|
||||
## 2026-09-12 — claude.json-Secrets laufen über `{env:}` (TP35 Schicht 2 ✅)
|
||||
**Betrifft:** alle, die `~/.claude.json` oder `~/.secrets/exports.zsh` anfassen.
|
||||
**Aktion:** Keine Klartext-Secrets mehr in `~/.claude.json` — MCP-Credentials referenzieren jetzt
|
||||
`{env:MCP_*}` (dieselben Namen wie `opencode.jsonc`, bidirektional kompatibel). SSoT ist
|
||||
`~/.secrets/exports.zsh` (25 Vars). Nach jeder Änderung dort: `~/dev/.bin/gui-env-sync.sh`
|
||||
**und App-Neustart** (laufender Prozess erbt die launchd-Umgebung nicht). ⚠ `~/.claude.json` nie aus
|
||||
einer **laufenden** Session editieren — Claude Code schreibt dort State, der Edit würde überschrieben;
|
||||
der Flip läuft bei beendeter App über `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude`.
|
||||
|
||||
## 2026-09-12 — TP36-Live: Gerätedaten stehen als CustomFields in Nautobot
|
||||
**Betrifft:** alle, die Geräte-Ist-Werte (RouterOS-Version, Compliance %, DHCP-Server-Zahl,
|
||||
DNS-Modus, Mgmt-IP) brauchen.
|
||||
**Aktion:** Nicht mehr aus der Prosa ablesen und nicht raten — die fünf Felder (`grouping: TP36-Live`)
|
||||
hängen an den 5 RouterOS-`dcim.Device` in Nautobot. Aktualisieren: `python3 nautobot/gc-snapshot.py
|
||||
--commit` (liest nur aus Nautobot/GC, kontaktiert kein Gerät). Die Werte sind so aktuell wie das
|
||||
letzte Golden-Config-Backup (`backup_last_success_date` am Gerät).
|
||||
|
||||
## 2026-09-07 — SSoT-Cutover: Index/Status = Gitea, NICHT mehr teilprojekte.md
|
||||
**Betrifft:** jede Session, die einen TP-Status ändert, anlegt oder abschließt.
|
||||
**Aktion:** Status/Reihenfolge/„was lief zuletzt" gehen ins **Gitea-Issue**
|
||||
([`admin/bf-infra`](https://gitea.emsenhuber.net/admin/bf-infra/issues), Labels `status:*`/`thema:*`),
|
||||
**nicht** in eine Tabelle in `docs/teilprojekte.md`. Die Datei trägt nur noch die `### TPnn`-Prosa.
|
||||
`/projektstatus` liest Baum + Status aus der Gitea-API. Branch `tpNN-kurz` jederzeit möglich, Merge
|
||||
nach `main`, sobald Code/Config steht.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
|
||||
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
|
||||
> Nautobot (`10.40.20.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
||||
> Nautobot (`10.40.20.154`, TP45 Schicht A 2026-09-21; vorher `.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
||||
> `nautobot/tp44-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
|
||||
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
|
||||
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
||||
|
||||
@@ -82,7 +82,7 @@ terpene.teilzeitladen.xyz {
|
||||
"type": "http",
|
||||
"url": "https://n8n-mcp.emsenhuber.net/mcp",
|
||||
"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.
|
||||
@@ -68,7 +68,7 @@ den *damals geplanten* Werten und ⬜-Status. Sie sind erledigt bzw. gegenstands
|
||||
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
|
||||
|
||||
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
|
||||
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.30` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.31, `nautobot-scheduler`.20.32, `nautobot-redis`.20.34 |
|
||||
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.154` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.155, `nautobot-scheduler`.20.156, `nautobot-redis`.20.158. **TP45 Schicht A 2026-09-21:** Oktett-Klasse `.150–.199` (App-Services) — die alten `.30–.34` gibt Nautobot an AGH `.30`/`.31` frei |
|
||||
|
||||
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -344,12 +344,14 @@ dann Tunnel** (umgekehrt gäbe es 530er).
|
||||
- 📌 **Und die Erwartung wird damit ehrlicher:** Nautobot war nie ohne Access-Login öffentlich
|
||||
erreichbar. Der Schnitt schließt **keine offene Tür**, er entfernt eine **überflüssige zweite**.
|
||||
- ⚠️ **Vorprüfung, die vor jedem weiteren Schnitt zu wiederholen ist:** hängt Werkzeug am
|
||||
öffentlichen Namen? Hier nein — `gc-configplan.py` spricht `http://10.40.20.30:8080/api`, und
|
||||
öffentlichen Namen? Hier nein — `gc-configplan.py` spricht `http://10.40.20.154:8080/api`
|
||||
(TP45 Schicht A 2026-09-21; zum Messzeitpunkt war es `.20.30`), und
|
||||
kein Repo-Skript nutzt den FQDN.
|
||||
- ✅ **Betriebsfenster ausgewertet 2026-08-30 (Tag 6/7): `nautobot` bleibt mesh-only.** Kein Rückweg
|
||||
gebraucht, kein Ausfall. `nautobot.emsenhuber.net` hat inzwischen auch **keinen DNS-Record** mehr
|
||||
(mit den CF-Leichen am 08-29 gelöscht, Commit `39ba5da`) — der Dienst ist vollständig aus dem
|
||||
öffentlichen Strang heraus und ausschließlich über `10.40.20.30:8080` bzw. intern über Caddy erreichbar.
|
||||
öffentlichen Strang heraus und ausschließlich über `10.40.20.154:8080` (TP45 Schicht A 2026-09-21)
|
||||
bzw. intern über Caddy erreichbar.
|
||||
|
||||
##### ✅ §2/§3 durchgezogen — Vor-Ort-Session 2026-08-31
|
||||
|
||||
@@ -632,12 +634,13 @@ und Letzteres verlangt die **physische Bestätigung am Gerät** — ap1 Technik
|
||||
häufigsten per Mesh angefasste Dienst, und ein Fehlschlag kostet nichts Produktives), eine Woche fahren, dann
|
||||
entscheiden. **Rollback = eine Ingress-Zeile zurück.**
|
||||
- 📋 **Runbook: `docs/tp16.3-nautobot-mesh-only-runbook.md`** (2026-08-19) — der Schnitt oben, ausführbar
|
||||
ausformuliert: Vorbedingungen (Mesh-Route, Zugriff über `10.40.20.30:8080` **mit WARP von außerhalb**),
|
||||
ausformuliert: Vorbedingungen (Mesh-Route, Zugriff über `10.40.20.154:8080` **mit WARP von außerhalb**),
|
||||
Ingress-`PUT` über `CF_API_TOKEN` (**nicht** über den CF-MCP, der ist read-only), Rollback aus der
|
||||
gesicherten Config.
|
||||
- 🔴 **Der Abnahmepunkt, den man übersieht:** Nautobot ist nicht nur Web-UI, sondern die
|
||||
**Deploy-Pipeline**. Nach dem Schnitt gehört ein Job-Lauf zur Abnahme, nicht nur ein Browser-Aufruf
|
||||
— der Netmiko-Deploy kommt von `10.40.20.31` (`nautobot-worker`), nicht von `.20.30`.
|
||||
— der Netmiko-Deploy kommt von `10.40.20.155` (`nautobot-worker`), nicht von `.154` (Werte nach
|
||||
TP45 Schicht A 2026-09-21; zum Messzeitpunkt `.31` / `.30`).
|
||||
- ⚠️ Beim Kürzen des `ingress`-Arrays muss die **Catch-all-Regel die letzte bleiben**, sonst greift
|
||||
der Ingress für alle übrigen Hostnames nicht mehr.
|
||||
- **Abh.:** TP16.1 ✅ (Mesh live), TP16 (Public-Strang + dessen HA-Degradierung), **TP16.2** (der `vw`-Constraint
|
||||
@@ -974,7 +977,28 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
|
||||
- **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**.
|
||||
- Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21.
|
||||
|
||||
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)**
|
||||
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 5 WIEDER OFFEN (Redesign 2026-09-12): Self-Service deckt die DAU-Requirements nicht ab → Stufe 6 bleibt dahinter gated**
|
||||
> 🔴 **Redesign 2026-09-12 — Stufe 5 reopened.** Bei „Stufe 6 angehen" fiel auf: die 09-04-Stufe-5
|
||||
> (single-domain, temp-only 15m/1h/4h, **eine** Ausnahme gleichzeitig, **HA-Neustart bei neuer
|
||||
> Domain**, `allow-domain` global statt pro Person) deckt die Nutzer-Requirements **nicht** ab
|
||||
> („funktional ein kastriertes Pi-hole", User). Die harte Reihenfolge-Regel „erst der Ersatz, dann
|
||||
> die Sperre" ist damit real **nicht** erfüllt → **Stufe 6 darf nicht laufen**, bis der neue
|
||||
> Self-Service nachweislich trägt.
|
||||
> - **Neue Requirements (User 2026-09-12):** (1) **pro Person** (Login), Sicht über alle Geräte;
|
||||
> (2) **„zuletzt geblockt" → 1 Klick**; (3) temporär **und** dauerhaft, Modus umschaltbar
|
||||
> (self-permanent ⇄ Anfrage, Start self-permanent); (4) **scoped HA-Dashboard** je Person —
|
||||
> nichts von anderen/Admin/Power-User, AGH-Web-UI bleibt reines Admin-Werkzeug.
|
||||
> - **Design/Bau:** Personen-Client je AGH (SSoT Person→Gerät), Ausnahmen via `$client=`-Modifier,
|
||||
> `agh-exception.py` um scoped `allow/unallow` + `list-exceptions` + `recent-blocked` erweitert
|
||||
> (reine Logik netzunabhängig getestet, `selftest` ✅). HA-Dashboard/Sensoren/Modus-Switch als
|
||||
> Bau-Spec in [`adguard/ha-self-service.md`](../adguard/ha-self-service.md) (on-network zu bauen).
|
||||
> - 🔴 **Load-bearing, on-network zu prüfen:** wirkt AGH `$client=` auf **Allow**-Regeln (`@@`)?
|
||||
> Fallback = IP-Liste statt Client-Name (Code kann beides). Plan:
|
||||
> `~/.claude/plans/tp23-stufe-6-angehen-soft-sunrise.md`.
|
||||
> - **Neue echte Stufe-6-Vorbedingung:** End-to-End-Abnahme mit echtem Nicht-Admin-Flow.
|
||||
> - **Stufe-6-Runbook-Korrektur:** Core `10.40.X.1` in der Allowlist überdenken (Client-Gateway →
|
||||
> DoH direkt zu CF = interner Bypass der AGH-Personen-Policy).
|
||||
|
||||
> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt**
|
||||
> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block):
|
||||
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
|
||||
@@ -1078,7 +1102,7 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
|
||||
- **Abh.:** keine harten. **TP15 ✅** (die zu versorgende Hardware steht und hat DC-Eingänge). Verwandt: **TP18** (Garage-Nodes brauchen je Site eine Stromversorgung), **TP11** (USV-Zustand ins Monitoring). **Kein Blocker für TP5/TP6.**
|
||||
- **Historie:** war bis 2026-08-16 Unterpunkt von TP15 („🔋 DC-USV: MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt"). Vollständige Recherche inkl. Schaltbild, Effizienzvergleich und Gerätetabelle: `docs/hw-refresh-requirements.md §9` (+ §10 D/E/F für die offenen Fragen).
|
||||
|
||||
### TP25 — WLAN-Stabilisierung ▸ ⏳ **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) — **offen bleibt nur: ap2 unterperformt (2 m/−80 dBm, 23:02-Befund) als eigener In-situ-Unterpunkt** · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Offen nur noch die Umsetzung, wenn der dritte AP kommt. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
|
||||
### TP25 — WLAN-Stabilisierung ▸ ⏳ **ap2-UNTERPUNKT ✅ WIDERLEGT 2026-09-14 (In-situ, [Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)): ap2 sendet auf 2 m mit −49…−51 dBm, stabil, 0 Roams — Hardware sauber, kein Tausch begründet. Terrasse = 5-GHz-Zellkante (−76…−81 vs. 2,4 GHz −65…−73), Thrash nur in der Band-Überlappungszone −70…−77. Hebel „Access-List-Band-Boden" in der Machbarkeitsprüfung GESCHEITERT (kein `band`-Property, cap-wifiN-Rejoin-Falle) — s. Block unten** — **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
|
||||
> 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen
|
||||
> 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv:
|
||||
> [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
|
||||
@@ -1087,6 +1111,79 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
|
||||
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
|
||||
> *warum* etwas so entschieden wurde, liest die Langfassung.
|
||||
|
||||
**Stand 2026-09-15 — AP-Zuordnung DEFINITIV geklärt, Tausch-Befund ap2↔ap3** ([Handoff](handoff-20260915-tp25-zuordnung-und-redundanz.md)). Positionen per IP+MAC+Switch-Port (MNDP/Bridge-Host/Port-Link) live belegt: **ap1 = Sideboard/Wohnzimmer (`core ether3`), ap2 = Technik UG (`core ether21`), ap3 = Technik OG (`branch ether24`)**. Der geplante Tausch (ap1↔ap3, cAP ax → Sideboard) hat faktisch **ap2↔ap3** gedreht — ap1 blieb stehen, der cAP ax steht jetzt in OG. MAC-Korrektur: ap2 `ether1` ist `:39`, nicht `:3A` (`:3A` = Bridge/Mgmt-MAC). Rundgang (natürlicher Zustand, 1-s-Takt): **0 Disconnects**, −45…−77 dBm, Roaming über alle 3 APs; **Terrasse −58 stabil** (jetzt cAP ax/OG, 2,4G), ap1 nur kurz gestreift. ap1-Aus-Test: Main-Client voll abgedeckt (tiefster Wert −80), **alle 27 IOT kehren zurück**, aber ~4 Wohnzimmer-IOT marginal −77…−80 ⇒ ap1 technisch redundant, nicht ausfallkritisch. ap1 wieder an (Revert). Offen: (1) ap1 behalten/abbauen, (2) Tausch auf Plan korrigieren (cAP ax → Sideboard?), (3) Drei-Räume-Schatten (Schlafzimmer/Bad/Küche) gezielt nachmessen — noch nicht verifiziert, (4) Doku-Drift (diese Session).
|
||||
|
||||
**Stand 2026-09-14, 20:05 — ap2-In-situ: Hardware entlastet, Terrasse = 5-GHz-Zellkante,
|
||||
Band-Boden nicht sauber ausdrückbar** ([Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)).
|
||||
Live-Session auf der Terrasse (Christoph kooperierte physisch — der Test, der am 08-31 „nachts
|
||||
nicht möglich" war): das Fund-E-iPhone flappte 18:53–19:18 ~30×, fast ausschließlich
|
||||
**ap2-5G ↔ ap2-2,4G** (−68…−81), mit 3 Abwürfen (2 Call-Aussetzer). Vor ap2 (1–2 m): **−49…−51 dBm,
|
||||
>2 min stabil, 0 Roams** ⇒ der offene Verdacht „ap2 RF/Antenne/HW" (23:02-Befund, 2 m/−80) ist
|
||||
**in situ widerlegt** — der 23:02-Wert reproduziert sich seit dem 09-04-Kanalplan (5300/5320,
|
||||
tx-power 18 live) nicht mehr. Am Terrassenplatz ist **2,4 GHz klar besser** (−65…−73 vs. 5 GHz
|
||||
−76…−81) und das Telefon dort **ruhig** (36 s im 1-s-Takt, 0 Roams) — es thrasht nur in der
|
||||
Überlappungszone −70…−77 beider Bänder; zusätzlich client-getrieben (floh 19:44 selbst von −52
|
||||
auf 2,4/−77; Steering hat keine Schwellen, der AP drängt nichts). Mit `ft=no` ist jeder Roam ein
|
||||
spürbarer Voll-Reconnect ⇒ so sterben Anrufe. **Hebel „Access-List-Boden < −76 auf 5G":
|
||||
Machbarkeitsprüfung negativ** — `/interface/wifi/access-list` hat kein `band`-Property,
|
||||
Interface-Scoping bricht am cap-wifiN-Rejoin, SSID-/MAC-Scope träfen 2,4 GHz mit (Schlafzimmer
|
||||
−84 ⇒ Lockout). **Nicht umgesetzt.** Verbleibende Hebel: (1) ✅ **DURCH 2026-09-14, 20:30 Uhr** — `ft=yes ft-over-ds=yes`
|
||||
uniform auf `sec-main` (Ruecknahme der 09-04-Entscheidung; Go per Fragenrunde nach Dry-Run). Weg:
|
||||
Template-Edit + `push-gc-template.py` (GC-Commit `744a17e3`) → `--refresh-compliance` (Drift
|
||||
`wifi-security` wie erwartet) → `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed, Asserts gruen;
|
||||
ein Polling-Timeout am Skript war folgenlos — Geraet stand bereits auf ft=yes) → Refresh: **140/140, 0
|
||||
Drifts**. CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands-File:
|
||||
[`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`](transaktionen/TP25-ft-yes-secmain.commands.rsc).
|
||||
Abnahme ✅ **DURCH (2026-09-14, 20:33–20:34 Uhr, live)**: Anruf auf der Terrasse ueberstand
|
||||
**fuenf Band-Wechsel binnen 60 s** (nur `roamed to`, keine `disconnected` mehr im Fenster —
|
||||
Christoph: „call steht"). (2) Deckungsfix
|
||||
Variante 3 (ap1-5G erreichte die Terrasse mit −64) — beendet das Springen selbst.
|
||||
Nachtrag 20:37: ein **Totalverlust** anderer Klasse (Roam auf ap1-2,4 bei −89, danach iOS-Aufgeben
|
||||
ohne jeden Reconnect-Versuch; identisch schon 19:01 mit ft=no gesehen) — **keine ft=yes-Regression**,
|
||||
sondern der strukturelle Rest. **Wechselrichter/DTU (Hoymiles HMS-800W-2T, geht mit der Sonne an/ab,
|
||||
09-14: 07:09–19:35): nachts unbeteiligt; tagsüber ~5:05-Zyklus (connect/`connection lost`/reconnect +
|
||||
DHCP-Churn) auf ap2-2,4/ch11 bei −53.** Messplan „DTU vs. Terrasse" angelegt ([Issue #17, Kommentar
|
||||
#225](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-225)): Referenz-Client auf
|
||||
ch11 pinnen (tp25-Methodik), 30–60 min Ping, Korrelation gegen die 5:05-Taktmarken. ⚠️ **Korrektur
|
||||
09-14 abends ([Kommentar #227](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-227)):
|
||||
Der urspruenglich genannte Hebel „OpenDTU/AhoyDTU-Dongle" existiert fuer DASSES Geraet NICHT** — HMS-
|
||||
**XXXXW**-2T (W = eingebaute DTU „DTUBI", protobuf-Protokoll) wird von AhoyDTU/OpenDTU nicht
|
||||
unterstuetzt (lumapu/ahoy#1189 seit 09-2023 offen; Christophs eigene AhoyDTU nachweislich
|
||||
inkompatibel; nur die Nicht-W-Variante HMS-800-**2T** ginge per CMT2300-Funk). Die lokale Ebene
|
||||
(hms-mqtt-publisher = „hmsmqtt", dtuGateway, ha-hoymiles-wifi) redet **mit der internen DTU uebers
|
||||
Netzwerk** — keines davon nimmt die DTU von der Luft, hmsmqtt pollt sogar zusaetzlich. **Einziger
|
||||
Airtime-Hebel bleibt DNS-Hoheit ueber die Cloud**, von Christoph bewusst zurueckgestellt (Cloud-
|
||||
Aufzeichnungen noch benoetigt, bis hmsmqtt + WLAN stabil); ob eine DTU ohne Cloud ruhiger oder
|
||||
gespraechiger wird, ist selbst eine offene Messfrage. Pin auf anderen AP kontraindiziert
|
||||
(−53 direkt an ap2); 5-Min-Takt ist S-Miles-Telemetrie per Design, kein Ethernet an der DTU-WLite.
|
||||
✅ **Netzwerk-Mechanismus geklaert (09-14, Nacht; [Issue #17, Kommentar
|
||||
#228](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-228)):** Der **Fund-F-5:05-Zyklus
|
||||
ist das Cloud-Recovery-Verhalten der DTU.** Wireshark-Analyse (hms-mqtt-publisher PR #110, HMS-800W-2T):
|
||||
lokaler Poll <30 s resettet den Countdown und setzt Cloud-Updates aus; **nach 4–5 min erfolgloser
|
||||
Cloud-Updates** startet die DTU eine neue TCP-Session — und dabei (unser Log-Beweis 09-14) eine
|
||||
**vollstaendige WiFi-Disassoziation inkl. DHCP-Churn**. Die heute gemessenen Zyklus-Abstaende decken
|
||||
sich mit dem Befund aufs Minutenkomma. Ursache war hmsmqtt mit **30 500 ms** (Grenzzone);
|
||||
DTU-Firmware aelter, hmsmqtt funktioniert (⇒ #134-Key-Rotation-Falle nicht primaer).
|
||||
**Hebel = Poll-Rate, nicht DNS-Hoheit:** Christoph hat **30500 → 45000 ms** gesetzt (09-14 abends;
|
||||
Community-Wert, 10 Monate stabil). ✅ **Verifikation DURCH (09-15, 12:11; [Issue #17, Kommentar
|
||||
#230](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-230)): DTU online seit 06:41:49,
|
||||
uptime 5 h 29 min, 0 Disconnects** (Vergleich 09-14 mit 30,5 s: fuenf Zyklen in den ersten 18 min).
|
||||
Die DTU sitzt heute auf **ap3-2,4** (cap-wifi4-virtual1, −49; cap-Karte unveraendert, Attribution
|
||||
belastbar) — also aktuell auch vom Terrassen-Band ch11 weg. **Fund F ist damit abgeschlossen** —
|
||||
Ursache war die DTU-Cloud-Recovery, getriggert durch hmsmqtt-Polling-Kollision. Messplan #225 ist
|
||||
gegenstandslos (der zu messende Effekt existiert nicht mehr); eine ch11-Tagzeitmessung bleibt nur
|
||||
sinnvoll, falls die Terrasse tagsueber weiterhin Call-Probleme zeigt. 📅 **terminiert ([Issue #17,
|
||||
Kommentar #232](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-232)): Deckungsfix
|
||||
Variante 3 — Umsetzung 2026-09-15/16** (Christoph: „heute oder morgen"): Deckenstrahler an den guten
|
||||
Platz, verdrängter hAP in den Drei-Raeume-Schatten (Schlafzimmer −84,6/38 %, Bad −82,3/24 %,
|
||||
Kueche −82,0/23 % laut 08-22-Messung). Vorher-Messung Terrasse (Referenz 09-14: 5 GHz −76…−81,
|
||||
2,4 GHz −65…−73) → Umsetzung → Nachher-Messung + Anruf-Abnahme; Kanalplan (09-04) bleibt unberuehrt,
|
||||
nur Platzierung aendert sich; Config-Aenderungen nur ueber den GC-Weg. Offen vor Ort: Zielraum fuer
|
||||
den verdraengten hAP (PoE/Steckdose) und ap1-Uplink-Thema (Sideboard → `core ether3`) mitdenken.
|
||||
Randnotizen: ap1 „FUNKSTILL" im CLAUDE.md ist überholt (dient live, Radar-Event 16:51 auf 5280
|
||||
sauber abgehandelt); Template-Abschnitt [6c] („Fund E WIEDER OFFEN", Stand 08-30) ist inhaltlich
|
||||
überholt — beim nächsten Template-Edit mit aktualisieren, kein separater Edit nur für den Kommentar.
|
||||
|
||||
**Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken
|
||||
([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust /
|
||||
20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
|
||||
@@ -1783,6 +1880,7 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
|
||||
| 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste |
|
||||
| 7 | **`TERPENE_ANDROID_KEYSTORE_PW`** | Terpene-Android-TWA-Signing (`android/android.keystore`, alias `terpene`) | 🔴 am 2026-09-01 (TP41) durch **Regex-Suffix-Loch** (`_PW` nicht abgedeckt) ins Transkript geraten | Wert in `~/.secrets/credentials.env` + Keystore-Datei | **Store-/Key-Passwort** via `keytool -storepasswd`/`-keypasswd` ändern + `credentials.env` nachziehen; Konsument = Android-Build/Play-Signing (**Cross-Projekt blaueblüten**) — den **Key selbst NICHT** neu erzeugen |
|
||||
| 8 | **`N8N_SMTP_PASS`** (Gmail-App-PW für `johnny.claw.0x00@gmail.com`) | SMTP-Versand aus n8n (Portainer-Stack 174 `n8n`, macmini) | 🔴 am 2026-09-01 (n8n-mcp-SSRF-Session) beim Ausgeben des Stack-174-Files ins Transkript geraten — Redaktionsmuster hatte `PASSWORD`, aber **nicht bare `PASS`** → `N8N_SMTP_PASS=<wert>` blieb stehen | Wert in Portainer-Stack 174 (`environment`) | **Gmail-App-Passwort widerrufen** (Google-Konto → App-Passwörter) + neues erzeugen, Stack-174-Env nachziehen + redeploy; Konsument = n8n-SMTP-Node(s) |
|
||||
| 9 | **`GITEA_ACCESS_TOKEN`** (API-Zugang `gitea.emsenhuber.net`) | Session-Tooling (`bin/gitea-tp-sync.py`, Ad-hoc-API-Calls) | 🔴 am 2026-09-14 (TP25-In-situ-Session) **ins Transkript** geraten: Env-Parse-Fehler schob den Token in die URL-Konstruktion, `urllib` warf `ValueError: unknown url type` und druckte ihn im Traceback | Mechanismus zu: Token gehört **nur in den `Authorization`-Header, nie in eine URL** — und der Fehlerpfad, der ihn enthält, darf ihn nicht ausgeben (gleiche Familie wie #1/#5/#6/#7/#8, diesmal Unterklasse „Fehlerausgabe statt Maskierung") | neu anlegen (Gitea → Settings → Applications), `~/.hermes/.env` nachziehen, altes widerrufen; Konsumenten = `gitea-tp-sync.py` + Session-Ad-hoc-Calls |
|
||||
|
||||
> ✅ **Der Leak-MECHANISMUS hinter #1 ist seit 2026-08-31 zu — der Wert bleibt trotzdem verbrannt.**
|
||||
> Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und
|
||||
@@ -1816,6 +1914,15 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
|
||||
> Mal neu (und falsch) tippt: `sed -E 's/([A-Z0-9_]*(PASS|PASSWORD|PWD|PW|TOKEN|SECRET|KEY|BEARER))=[^[:space:]]*/\1=<redacted>/g'`
|
||||
> — ankert auf den **Schlüsselnamen inkl. Präfix** und deckt alle bisher gesehenen Suffixe ab.
|
||||
>
|
||||
> 🔴 **#9 ist der sechste Vorfall derselben Familie, aber eine neue Unterklasse** (SHELLY 08-28,
|
||||
> Nautobot-Tokens 08-31, MariaDB-root 09-01, Keystore 09-01, SMTP 09-01): diesmal kein
|
||||
> Maskierungsmuster, sondern ein **Fehlerpfad** — ein Env-Parse-Fehler schob den Token in die
|
||||
> URL-Konstruktion, und `urllib` druckte ihn im `ValueError: unknown url type`-Traceback.
|
||||
> **Merkregel: ein Secret gehört in keine Datenstruktur, die eine Exception ausgeben kann**
|
||||
> (URL-Konstruktion validieren, bevor das Secret hineinwandert; Fehlerpfade dürfen den
|
||||
> fehlerhaften Wert nicht mitschleppen). Das Redaktions-Snippet oben hilft hier nicht — es
|
||||
> kann nichts maskieren, was als Fehlermeldung und nicht als Dateiinhalt kommt.
|
||||
>
|
||||
> ℹ️ **#3 war bis 2026-08-24 TP17.2** und ist jetzt vollständig hier (s. Detailabschnitt unten) —
|
||||
> es benutzt denselben Geräte-Durchgang wie #2, **ein Reboot statt zwei**. #4 gehört sachlich zu
|
||||
> **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token).
|
||||
@@ -2287,18 +2394,36 @@ haben: `100–109` netznah · **`110–119` DNS** · **`130–139` Reverse Proxy
|
||||
|
||||
**Letztes Oktett je /24 — gilt in jedem VLAN, an jedem Standort:**
|
||||
|
||||
| Oktett | Rolle | Anker heute |
|
||||
|---|---|---|
|
||||
| `.1–.9` | Gateways / L3 | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
|
||||
| `.10–.29` | physische Hosts (Storage, Hypervisor, APs) | NAS `20.10` ✅ · APs `99.11/.12` ✅ |
|
||||
| `.30–.99` | virtuelle Core-Services, Zehnergruppen wie oben | DNS `.3x` · Ingress `.4x` · DBs `.5x` · Security `.6x` · Monitoring `.7x` |
|
||||
| `.100–.199` | App-Services | |
|
||||
| `.200–.249` | DHCP-Pool | |
|
||||
| `.250–.254` | VIPs / Relays | |
|
||||
| Oktett | Rolle | Ableitung aus TP32 | Anker |
|
||||
|---|---|---|---|
|
||||
| `.1–.9` | Gateways / L3 | Bestand | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
|
||||
| `.10–.19` | physische Netzhardware im VLAN | APs/Switches, sobald sie im VLAN liegen | heute nur VLAN99: `99.11/.12/.13` ✅ · VLAN30: Nuki-Bridge, NSPanelPro, P750W, DTUBI |
|
||||
| `.20–.29` | Core / netznah | Kreis 100–199, Stufe `10x` | mcast-relay (heute `10.21`/`40.21` — **kein VLAN20-Bein**) |
|
||||
| `.30–.39` | DNS / Resolver | Stufe `11x` | AGH-Paar — **Soll** `.30`/`.31`, Ist `.36`/`.37` (bis Nautobot dort weg ist) |
|
||||
| `.40–.49` | Ingress / Reverse-Proxy | Stufe `13x` | caddy `20.40` ✅ |
|
||||
| `.50–.59` | Datenbanken | Kreis 200–299 | Soll `platform-pg .50`, `mariadb .51`, `influxdb2 .52`, `redis .53` |
|
||||
| `.60–.69` | Security / Identity | Kreis 300–399 | Soll `vaultwarden .60` |
|
||||
| `.70–.79` | Monitoring | Stufe `15x` | frei |
|
||||
| `.80–.89` | Platform / Runtime | Kreis 400–499 | Soll `docker .80`, `coolify .81` |
|
||||
| `.90–.99` | Broker / Funk | **Ordnungsgruppe 40** (s. u.) — *nicht* aus dem VMID-Kreis abgeleitet | Soll `mqtt5 .90`, `z2m25 .91` |
|
||||
| `.100–.149` | **Server-Hosts** (physische Bleche) | `.10x` Reserve · `.11x` Storage · `.12x` Hypervisor · `.13x` Bleche/Workstation/Backup | Soll `nas .110`, `pve2 .120` (reserviert), `pve .121`, `macmini .130`; Ist `pbs 20.135` ✅ |
|
||||
| `.150–.199` | App-Services / Gäste | Kreis 500–599 | `gitea 20.157` ✅, `pve-mcp .173` ✅, `shelly-mcp .180` ✅ |
|
||||
| `.200–.249` | DHCP-Pool | Bestand | VLAN20 soll `.200–.249` (Ist `.100–.200`, `used=0`) |
|
||||
| `.250–.254` | VIPs / Relays | Bestand | |
|
||||
|
||||
🔑 **Der Merk-Trick: die Zehnergruppe im Oktett spiegelt die der VMID.** `110 agh` → `.3x`,
|
||||
`130 caddy` → `.4x`. Wer eine Zahl kennt, kennt die Klasse der anderen.
|
||||
|
||||
⚠️ **Kein Rechenweg — eine Nachschlagetabelle.** Die Zeile `.90–.99` (Broker/Funk) ist die
|
||||
**Ausnahme**: sie stammt nicht aus dem VMID-Kreis, sondern aus der Ordnungsgruppen-Tabelle unten
|
||||
(Stufe 40). **Wo die beiden Achsen auseinanderlaufen, gilt die Gruppe, nicht die Ziffer**
|
||||
(Christoph 2026-09-21). Dasselbe Muster bei `.100–.149`: die Server-Hosts werden über den
|
||||
**Gerätetyp** einsortiert (Blech), nicht über eine VMID.
|
||||
|
||||
⚠️ **Solange eine Alt-IP als Zweitadresse weiterläuft, ist sie reserviert** — `.10` (nas),
|
||||
`.100` (pve), `.167` (macmini) dürfen bis zum Rückbau **nicht** neu vergeben werden. Deshalb
|
||||
bekommt z. B. `z2m25` **nicht** `.167`, sondern `.91`.
|
||||
|
||||
#### Warum der Bestand anders aussieht — er war nicht falsch, nur auf einer anderen Achse
|
||||
**User 2026-08-25:** „zuerst war haos, welches mit 100 den nummernkreis für iot eröffnet hat. und die
|
||||
anderen … gehörten zu ha, daher 1xx. erst später habe ich erfahren, dass die nummer die
|
||||
@@ -2321,19 +2446,35 @@ alle Gäste mit `order=`, dann den Rest nach VMID** — real lief also `iob-mast
|
||||
| 10 | Core | `108 mcast-relay` |
|
||||
| 20 | Daten | `105 platform-pg` (`up=15`), `302 mariadb` (`up=15`), `106 influxdb2`, `107 redis` |
|
||||
| 30 | Platform | `400 docker` (`up=20`) |
|
||||
| 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25`, `330 RaspberryMatic` |
|
||||
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed` |
|
||||
| 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25` |
|
||||
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed`, `330 RaspberryMatic` |
|
||||
|
||||
🔴 **Korrektur 2026-09-21 (Christoph):** die CCU gehörte **nie** in die Funk-Gruppe. „Funk" ist
|
||||
`102`/`112` — die CCU läuft in der **Anwendungs-Gruppe**, in derselben wie `haos`. Live stand sie
|
||||
noch auf `order=40`; der Geräte-Write ist Teil desselben Zuges (nicht nur diese Doku-Zeile — eine
|
||||
Doku, die dem Gerät vorauseilt, ist genau der Drift, den TP32 vermeiden soll).
|
||||
|
||||
🎯 **Die wichtigste Erkenntnis des TP: VMID = Klassifikation, `startup: order=` = Abhängigkeitsreihenfolge.**
|
||||
Die Umnummerierung muss die Startreihenfolge gar nicht reparieren — das ist bereits erledigt, ohne eine
|
||||
einzige ID zu ändern. **Die Nummernkreise sind damit reine Ordnung und Lesbarkeit**, und der Bestand
|
||||
kann in Ruhe nachziehen.
|
||||
|
||||
- **Offen:** Migration des Bestands (Tabelle oben) — *am Ende der Reise, nicht jetzt*. Und die
|
||||
Kollision **AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker) ⇒ Vorschlag `.36` als
|
||||
Interim, `.30` erst nach dem Nautobot-Umzug in den App-Bereich. **Fällt vor dem AGH-Neubau an.**
|
||||
- **Offen:** Migration des Bestands (Tabelle oben) — **läuft jetzt an**, nicht mehr „am Ende der
|
||||
Reise": siehe **TP45** (VLAN20 + VLAN30 ins Schema, danach DNS/FQDN). Die alte Kollision
|
||||
**AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker, `.32` = scheduler, `.34` = redis)
|
||||
ist dort **Schritt 1**: Nautobot zieht in den App-Block (`20.154–.156`), **erst danach** greift
|
||||
AGH `.30`/`.31`. Reihenfolge bewusst so — der GC-Pfad läuft über Nautobot, ein Umzug von AGH zuerst
|
||||
würde die Werkzeugkette gegen sich selbst laufen lassen.
|
||||
- **Abh.:** keine. **Blockiert nichts**, aber TP31 vergibt bereits danach.
|
||||
|
||||
#### 🔎 Bestands-Fehler, die beim Umsetzen auffielen (2026-09-21, live gemessen)
|
||||
Die frühere Fassung dieser Tabelle war an vier Stellen **nicht gemessen, sondern angenommen**:
|
||||
`mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`), der Anker der Zeile
|
||||
`.20–.29` war damit falsch; `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), nicht drei; und der
|
||||
**DHCP-Pool in VLAN30 enthält ~25 Statics** (`.202–.249`) — die VLAN30-Nummerierung war ebenfalls
|
||||
gewachsen, nicht geplant. Merkregel: **„Bestand bleibt, wo er ist" gilt nur, solange niemand behauptet,
|
||||
wo er ist, ohne nachzusehen.**
|
||||
|
||||
|
||||
### TP33 — Sonos aus der Ferne bedienen (VPN/WARP) ▸ 💡 **IDEE, bewusst zurückgestellt** (angelegt 2026-08-25, User-Entscheid „später entscheiden")
|
||||
|
||||
@@ -2354,15 +2495,15 @@ kann in Ruhe nachziehen.
|
||||
|
||||
---
|
||||
|
||||
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 6 (Skill) ✅**
|
||||
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ✅ **ABGESCHLOSSEN 2026-09-12** — 5/6 Schichten durch (MCP-Schichtung FQDN→IP, Credentials-`{env:}`-Flip angewandt+verifiziert, globale CLAUDE.md-Schicht, `/news`, Backup-Guard, `/projektstatus`-Skill). Schicht-3-**pro-Projekt**-Rollout laut Abgrenzung eigene Session je Projekt (kein Blocker). Einziger Rest = On-Network-mikromcp-Smoketest (off-site nicht machbar, VLAN99:80 timeout, non-blocking) → als Merkposten nach TP30. Gitea #25 closed/`status:done`.
|
||||
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
|
||||
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
|
||||
- **Scope:**
|
||||
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
|
||||
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab.
|
||||
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. **✅ 2026-09-12 angewandt:** `exports.zsh` um die 3 fehlenden `MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS` ergänzt (jetzt 25 Vars, die opencode schon referenzierte — vorher liefen die dort leer), `gui-env-sync` gelaufen (25/25 in launchd-Env). `~/.claude.json` via `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude` (bei beendeter App, Backup `.bak-20260912-*`) geflippt — **alle 7 Secrets** (`mikromcp` 5× PASS, `hostinger-api` Token, `n8n-mcp` Bearer) jetzt `{env:}`, verifiziert; App neu gestartet, MCP-Server laden sauber. 🔴 **Merkregel:** `~/.claude.json` nie aus laufender Session editieren (Claude Code schreibt dort State → überschreibt). Restcheck offen: **authentifizierter** mikromcp-Read **on-network** (am 09-12 Connect-Timeout VLAN99:80 = Erreichbarkeit vom Standort, kein Auth-Fehler). Rollback = `.bak` zurück.
|
||||
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
|
||||
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort (voraussichtlich `bf-infra/docs/announcements.md`) + Skill `/news` oder `/announce` zum Einlesen beim Session-Start. Initial: Breaking News über die MCP-Schichtung selbst.
|
||||
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff.
|
||||
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort + Skill zum Einlesen beim Session-Start. **✅ 2026-09-12:** `docs/announcements.md` angelegt (Format: `## YYYY-MM-DD — Titel` + `Betrifft:`/`Aktion:`, neueste oben) + globaler Skill **`/news`** (in beiden Skill-Pfaden `~/.claude/skills/news/` **und** `~/.config/opencode/skills/news/`, `cp` kein Symlink — Schicht-6-Lehre). `/news` zeigt Einträge, die seit dem letzten Lauf dazukamen (Seen-State pro Repo unter `~/.news-seen/`). Erste Einträge: SSoT-Cutover (TP36) + TP36-Live.
|
||||
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. **✅ 2026-09-12 abgenommen:** Guard (112 Z., `check`/`mark`/`status`) + `.git/hooks/pre-commit` in `bf-infra` live verdrahtet; der Hook hat beim TP36-Live-Commit real gegriffen („Projekt aktiv 12 Commits/198 LOC, Backup markiert"). ⚠️ **Bekannte Grenze:** `check` prüft nur *Existenz* der Guard-Datei, nicht deren Alter — ein alter `mark` (hier 2026-08-29) lässt den Commit durch. Das ist als Push-Erinnerung gewollt, keine Frische-Garantie.
|
||||
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
|
||||
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
|
||||
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
|
||||
@@ -2546,11 +2687,180 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
|
||||
die Pipeline ist stabil und **104/104 compliant**. TP37 vergrößert die Angriffsfläche dieser Pipeline.
|
||||
Deshalb Punkt 1–2 als eigener, abnehmbarer Schritt **mit Compliance-Gegenprobe danach**, nicht als
|
||||
Nebeneffekt von Punkt 3.
|
||||
- **Status:** ⏳ **Punkt 3 (CF) ✅ via TP44; Punkt 1+2 (Substrat + Dienste) offen** — das ist die
|
||||
fehlende Voraussetzung für TP34. Datenmodell **entschieden 2026-09-06 (native Modelle:
|
||||
`virtualization.Cluster/VirtualMachine` + `ipam.Service`, keine App)**. Runbook liegt jetzt vor:
|
||||
[`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md). Live-Ausgangslage:
|
||||
VM 0 · Cluster 0 · `ipam.Service` 0 · CustomField 0 (2026-09-06 gegen die API gelesen).
|
||||
- **Status:** ✅ **Punkt 1+2 (Substrat + Dienste) ERLEDIGT** — die frühere Zeile „offen" war Doku-Drift.
|
||||
Live gegen die API gelesen **2026-09-21**: **28 VMs · 3 Cluster · 26 `ipam.Service` · 38 `ipam.IPAddress`
|
||||
· 9 Devices · 5 CustomFields**. Beide Seeder sind also gelaufen (`tp37-substrat-seed.py`,
|
||||
`tp37-dienste-seed.py` — die 26 Services sind genau die 26 Zeilen der Dienste-Liste).
|
||||
Datenmodell **entschieden 2026-09-06 (native Modelle: `virtualization.Cluster/VirtualMachine` +
|
||||
`ipam.Service`, keine App)**; Runbook [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
|
||||
**Offen bleibt Punkt 4** (Drift-Check gegen die CF-API) — und der Weg **Daten → gerenderte Config**
|
||||
ist nicht mehr TP37, sondern **TP34/TP45 Schicht C**.
|
||||
⚠️ **Merkregel:** die Zeile stand seit dem 09-06 auf „offen", obwohl die Seeder längst gelaufen waren.
|
||||
Ein Status, der nur in einer Datei lebt, altert still — der Abgleich gegen die API kostet eine Minute.
|
||||
|
||||
---
|
||||
|
||||
### TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN aus Nautobot ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #71)
|
||||
> **Auslöser (Christoph, 2026-09-21):** *„schau dir mal die fqdns von `*.emsenhuber.net` an. viele
|
||||
> verschwunden. sowohl caddy als auch cloudflare."* — und, als der Befund stand:
|
||||
> *„wenn wir schon reverse proxy & co korrigieren müssen, wäre es vl jetzt ein guter zeitpunkt,
|
||||
> unsere infrastruktur vlan20 an das ausgedachte ip-schema anzupassen. danach dns/fqdn."*
|
||||
|
||||
#### Befund (live, 2026-09-21) — **weder Caddy noch Cloudflare haben etwas verloren**
|
||||
| # | Befund | Beleg |
|
||||
|---|---|---|
|
||||
| 1 | **CF ist gewollt geschrumpft** — 24 Records, 8 proxied; `bf-public` trägt 5 (`vw`,`hass`,`n8n`,`gitea`,`ds-bf`), `fm` 2 | CF-DNS-API |
|
||||
| 2 | **Caddy hat nichts verloren** — 16 Sites, live **byte-gleich** zum Repo (`diff` identisch, 2977 B) | LXC 130 |
|
||||
| 3 | 🔴 **Internes Split-Horizon tot** | s. u. |
|
||||
| 4 | 🔴 **`ds-bf` öffentlich kaputt** | s. u. |
|
||||
|
||||
🔴 **Root Cause #3 (bewiesen).** AGH wertet DNS-Rewrites **im Filter-Pfad** aus; für Clients mit
|
||||
`filtering_enabled: false` werden sie **übersprungen**. Genau die Gruppen, von denen gebrowst wird,
|
||||
sind unfiltriert (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`, `grp-media`, `grp-mgmt`). Diese
|
||||
Clients fallen auf den conditional forward `[/bf.emsenhuber.net/] → 10.40.20.1` (Core) zurück — der
|
||||
Core hält aber nur **10** Host-Records; die **11 Dienstnamen** (`nautobot`, `portainer`, `pbs`, `ccu`,
|
||||
`iobroker`, `z2m25`, `tv`, `mfc`, `ha-mcp`, `gitea-mcp`, `n8n-mcp`) existieren **nur** als AGH-Rewrite
|
||||
→ **NXDOMAIN**. Aus dem AGH-Container selbst (`127.0.0.1`, kein Client-Match) liefern dieselben
|
||||
Rewrites korrekt — **deshalb war jede Handoff-Abnahme grün: sie wurde nie vom Browser aus gemessen.**
|
||||
Zweitdefekt: `pve.bf`/`pve-mcp.bf` **doppelt** definiert (Core → Host-IP, AGH → Caddy `.40`).
|
||||
|
||||
🔴 **Root Cause #4 (bewiesen, Firewall).** Der Origin antwortet aus VLAN20 in 0,08 s mit `200`,
|
||||
öffentlich `000` nach 20 s — die vier anderen Namen desselben Tunnels sind in ~0,2 s da. `vw`/`hass`/
|
||||
`n8n`/`gitea` gehen auf `https://10.40.20.40:443` (**Caddy**); `ds-bf` geht **direkt** auf
|
||||
`http://10.40.20.10:8080` (**NAS**) und ist damit der **einzige Ingress-Eintrag, der Caddy umgeht** →
|
||||
die AP-Connectoren (VLAN99) dürfen per Regel `*42` **ausschließlich** Caddy:443, alles andere fällt
|
||||
unter `*1A TP5-WOULD-DROP` / `*2D TP5: deny-by-default` (621 Pakete).
|
||||
**Fix ohne Firewall-Änderung: `ds-bf` über Caddy führen.**
|
||||
|
||||
#### Schichten (Reihenfolge = Abhängigkeit)
|
||||
| Schicht | Inhalt | Stand |
|
||||
|---|---|---|
|
||||
| **A** | VLAN20 virtuelle Gäste ins Schema — **Nautobot zuerst** (`.30–.34` → `.154–.156`/`.158`), dann AGH | 🔄 **Nautobot ✅ 2026-09-21** (s. u.) · AGH + übrige Gäste offen |
|
||||
| **B** | VLAN30 — **nur die Pool-Grenze**, Geräte bleiben (Christoph 2026-09-21) | offen |
|
||||
| **C** | DNS/FQDN: `ds-bf` über Caddy, `coolify.bf` neu, `.bf` vollständig auf den Core, AGH nur Filtering, TP34 | offen |
|
||||
| **D** | Tunnel-Inventar + GC + generische Namen, TP30 #10, Leak-Ursachen | offen |
|
||||
| **E** | nas/pve/macmini mit Doppeladresse — **eigene Session**, pve2 nur reservieren | offen |
|
||||
| **F** | CF-/Tunnel-SSoT + Recovery → **TP46** | offen |
|
||||
|
||||
🔴 **Warum Nautobot zuerst.** AGH will `.30`/`.31` — dort saß Nautobot (`.30`/`.31`/`.32`/`.34`).
|
||||
**28 Skript-/Config-Dateien** referenzierten `10.40.20.30` (alle `gc-*.py`, 14× `ingest-*.py`,
|
||||
`tp44-*`, `routeros/fetch-core-cutover-config.py`) — alle am 2026-09-21 mitgezogen. Und: **der
|
||||
GC-Pfad läuft über Nautobot** — AGH zuerst zu ziehen hieße, die Werkzeugkette gegen sich selbst
|
||||
laufen zu lassen.
|
||||
|
||||
##### ✅ Nautobot-Umzug durch (2026-09-21) — `.30/.31/.32/.34` → `.154/.155/.156/.158`
|
||||
|
||||
`.157` ist gitea, deshalb nimmt redis `.158`. Reihenfolge und Belege:
|
||||
|
||||
1. **Core-Adressliste `nautobot` additiv erweitert** (`.154/.155/.156` **dazu**, Alt-Einträge bleiben).
|
||||
Das ist die wirksame Ebene: der Mgmt-Zugang des Cores läuft über `src-address-list=nautobot`
|
||||
(Regel `*28`, 113 Pakete), **nicht** über den Literal `*24`.
|
||||
2. **Nautobot-Datenmigration** über das neue [`nautobot/tp45-ip-move.py`](../nautobot/tp45-ip-move.py).
|
||||
Nautobot **verbietet** das Ändern einer IP nach dem Anlegen (`Host address cannot be changed once
|
||||
created`) → Umzug ist Dreischritt *anlegen → `primary_ip4` umhängen → Altobjekt löschen*.
|
||||
3. **Stack-YML + Redeploy** (`PUT /api/stacks/203?endpointId=14`), Container auf den neuen IPs,
|
||||
`http://10.40.20.154:8080/health/` = **200**, alle vier Alt-IPs **000** (keine Doppeladresse —
|
||||
so entschieden).
|
||||
4. **Caddy** `nautobot.bf` → `.154`, deployt und **verifiziert**.
|
||||
5. **Core-Literal `*24` auf `.154`** über GC (`gc-manual-plan.py --deploy`, Plan `51a90e25`,
|
||||
Completed) mit Wirksamkeitsprüfung am Gerät.
|
||||
6. **`python3 nautobot/gc-configplan.py --refresh-compliance` → 140/140, 0 Drifts.**
|
||||
|
||||
##### 🔴 Zwei Werkzeug-Fehler, die genau dabei Zeit gekostet haben (beide neu)
|
||||
|
||||
**(a) `pct push` meldet Exit 0, auch wenn es die Quelle nicht lesen kann.**
|
||||
Gemessen: `pct push 130 <nicht-vorhandene-datei> /etc/caddy/Caddyfile` →
|
||||
`failed to open … for reading`, **`rc=0`**.
|
||||
Folge in `caddy/deploy.sh`: die Push-Zeile war als `push || push`-Fallback gebaut (wegen einer
|
||||
`$$`-Escaping-Verwechslung); der Fallback griff **nie**, weil der kaputte Push „Erfolg" meldete. Die
|
||||
anschließende `caddy validate` prüfte damit die **alte** Datei und war grün — **grüne Abnahme bei
|
||||
unverändertem Zustand**. Symptom war `nautobot.bf` **502** bei gleichzeitig gesundem Backend.
|
||||
**Fix:** `push_verified()` in `caddy/deploy.sh` — Push **und** md5-Vergleich in *einem* Remote-Aufruf,
|
||||
harter Abbruch bei Abweichung. Merkregel: **ein Push-Werkzeug, dessen Exit-Code nicht stimmt, wird
|
||||
geprüft, nicht geglaubt** — dieselbe Klasse wie `nc -z` (09-01) und das `sed`-Muster (08-31).
|
||||
|
||||
**(b) 🔴 Die Firewall-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert.**
|
||||
Der Core-Zugang für Nautobot hängt an `src-address-list=nautobot` — die Liste stammt aber aus dem
|
||||
**von Hand gelaufenen** `routeros/tp5-etappe2-whitelists.rsc` und existiert im Template nicht
|
||||
(`grep` auf `address-list` findet nur Kommentare). **Ein Golden-Config-Recovery von null erzeugt
|
||||
damit einen Core, in den Nautobot nicht mehr per SSH kommt** — die Deploy-Pipeline wäre tot und
|
||||
würde sich selbst nicht reparieren können. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
|
||||
Der Renderer gehört in dieselbe Baustelle wie Punkt „DHCP-Reservierungen" oben.
|
||||
|
||||
**(c) Nebenfund — Regel `*24`/`*28` sind Dubletten.** Dieselbe Erlaubnis existiert zweimal:
|
||||
`src-address=10.40.20.30` (Literal, **0 Pakete** = tot) und `src-address-list=nautobot`. Nur die
|
||||
Liste ist wartbar. Der Literal wurde auf `.154` gezogen (Soll-Konsistenz), die **Zusammenlegung**
|
||||
bleibt als Aufräumposten offen.
|
||||
|
||||
**(d) Nebenfund — `gc-manual-plan.py` Diagnosepfad ist tot.** Die Log-Abfrage
|
||||
`/api/extras/job-log-entries/?job_result=…` liefert **HTTP 404** (Nautobot 3.x hat den Endpunkt
|
||||
verschoben). Bei einem **erfolgreichen** Deploy ist das kosmetisch, bei einem **gescheiterten**
|
||||
nimmt es die Fehlermeldung weg — genau dann, wenn man sie braucht.
|
||||
|
||||
#### Soll-Oktette (Nachschlagetabelle, s. TP32 für die Herleitung)
|
||||
| Oktett | Rolle | Soll |
|
||||
|---|---|---|
|
||||
| `.20–.29` | Core / netznah | mcast-relay |
|
||||
| `.30–.39` | DNS | agh-bf1 `.30` · agh-bf2 `.31` |
|
||||
| `.50–.59` | DBs | platform-pg `.50` · mariadb `.51` · influxdb2 `.52` · redis `.53` |
|
||||
| `.60–.69` | Security | vaultwarden `.60` |
|
||||
| `.80–.89` | Platform | docker `.80` · coolify `.81` |
|
||||
| `.90–.99` | Broker/Funk | mqtt5 `.90` · z2m25 `.91` |
|
||||
| `.150–.199` | App | haos `.150` · iob-master `.151` · CCU `.152` · nautobot `.154–.156`+`.158` · gitea `.157` · iob_slave `.159` |
|
||||
|
||||
🔴 **DHCP-Reservierungen sind die Lücke:** Golden Config rendert sie **nirgends** (pools/DNS-statics/
|
||||
NAT/mangle/WG sind Template-Literale, der Firewall-Filter kommt als ConfigContext-**Daten** — aber
|
||||
`/ip dhcp-server lease` existiert im Template nicht). Die 26 VLAN30- + 1 VLAN20-Reservierung sind
|
||||
**Handarbeit auf dem Gerät** aus der ap1-Ära. Christoph-Entscheid 2026-09-21: **Renderer bauen**
|
||||
(Template + Nautobot-Datenmodell), statt Handarbeit zu dokumentieren.
|
||||
|
||||
#### Live-Befunde, die den ursprünglichen Plan widerlegten (2026-09-21)
|
||||
- `mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`) → der Anker der
|
||||
Zeile `.20–.29` war falsch gesetzt.
|
||||
- `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), der Plan sah **drei** Slots vor.
|
||||
- Die VLAN30-Soll-Spalte war **durchgehend erfunden**: Ender3V3KE liegt live auf `.150` (Plan `.180`),
|
||||
die 26 Shelly-Reservierungen auf `.202–.226`+`.233` (Plan `.150–.169`), Twinkly `.228`,
|
||||
Lichterketten `.236`/`.237`, Withings `.230`/`.231` … → Christoph: **„VLAN30 nur Pool-Grenze"**.
|
||||
- Pool-Fund: `pool-v20` `.100–.200` hat **`used=0`** (gratis verschiebbar); `pool-v30` `.100–.250`
|
||||
enthält **~25 Statics** → Pool und Statics tauschen praktisch die Plätze.
|
||||
- ⚠️ **`ap3.bf` existiert nirgends** — weder Core-Static noch AGH-Rewrite, obwohl ap3 seit 08-30 live ist.
|
||||
|
||||
- **Abh.:** TP32 (Systematik) · TP37 ✅ (Nautobot-Datenmodell) · TP21 ✅. **Blockiert:** TP34 (rendert
|
||||
die FQDNs, die hier Daten werden). **Abgrenzung:** VMID-Umnummerierung bleibt draußen; Token-**Rotation**
|
||||
läuft in TP30; Tunnel-GC-Namensgebung hier, die **SSoT/Recovery** in TP46.
|
||||
|
||||
### TP46 — Cloudflare/Tunnel: SSoT mit Push-Weg + Full-Recovery from scratch ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #72)
|
||||
> **Christoph (2026-09-21):** *„haben tunnel/warp/cf schon eine ssot für die config? nautobot? tunnel
|
||||
> sind essenzielle dinge, für die ich eine ssot möchte um drift zu vermeiden. zudem möchte ich auch
|
||||
> from scratch ein full setup/recovery aus der nautobot-config fahren können."* → Umfang **„Voll:
|
||||
> Tunnel+Ingress+DNS+Access"**, Platzierung **„Eigenes TP, GC bleibt hier"**.
|
||||
|
||||
#### Bestandsaufnahme (live 2026-09-21) — es gibt eine Doku-SSoT, aber keinen Master
|
||||
| Was | Wo | Stand |
|
||||
|---|---|---|
|
||||
| Tunnel, WARP-Connector, Workers, Access-Apps, Hyperdrive, VPC, teamnet-Routen | Nautobot `nautobot.cloud`, Job **`CF Estate Sync`** (`jobs/cf_estate_sync.py`) | ✅ 4 `CloudNetwork` · 16 `CloudService` live |
|
||||
| **Ingress-Origin-Ziele** | **nirgends** | 🔴 `_ingress()` speichert nur `ingress_hostnames`, **nicht** das Origin |
|
||||
| **DNS-Records** | **nirgends** | 🔴 nicht erfasst — live 24 Records / 8 proxied |
|
||||
| **Access-Policies** | **nirgends** | 🔴 nur die Apps selbst |
|
||||
| Richtung | CF → Nautobot | 🔴 **Pull-only** („CF bleibt operativer Master") |
|
||||
| `docs/cf-tunnel-mapping.md` | Repo | §1–16 eingefrorene As-Built-Historie · §0 = Live-Zeiger (altert: nennt `bf-public` „2 conns", live **12** = 3 AP-Connectoren × 4) |
|
||||
|
||||
**Antwort auf „full setup/recovery from scratch": heute nicht möglich.** Es fehlt (a) der Push-Weg,
|
||||
(b) Ingress-Origins, (c) DNS, (d) Access-Policies. Und ein frisch erzeugter Tunnel liefert **neue ID
|
||||
und neues Secret** → das **Token-Handoff an die Connectoren** (ap1/ap2/ap3) ist **Teil der Recovery**,
|
||||
kein Nebenschritt.
|
||||
|
||||
#### Umfang
|
||||
Tunnel + Ingress **inkl. Origin-Ziele** + DNS-Records + Access-Apps/Policies vollständig nach Nautobot;
|
||||
**Push-Renderer** (Dry-Run + Diff); **Recovery-Runbook** für den Aufbau von null.
|
||||
|
||||
🔴 **Vor dem ersten Schritt prüfen, nicht annehmen:** welches Nautobot-Objekt DNS-Records tragen kann
|
||||
(Kern-`ipam` vs. eigenes Modell), und ob das Ingress-Origin als `extra_config`-Feld reicht oder ein
|
||||
eigenes Modell braucht.
|
||||
|
||||
- **Abh.:** TP44 Etappe 1/2 ✅ (native `nautobot.cloud`-Modelle + Pull-Job stehen). **Abgrenzung:**
|
||||
Tunnel-**GC/Namensgebung** läuft in TP45 Schicht D; **CF-Writes** aus Nautobot sind hier *das Ziel*,
|
||||
in TP37 ausdrücklich ausgeschlossen — die Grenze verschiebt sich also bewusst mit diesem TP.
|
||||
|
||||
---
|
||||
|
||||
@@ -2657,6 +2967,12 @@ den Weg, über den man den Fehler zurücknehmen wollte.
|
||||
|
||||
### Follow-ups nebenher (blockieren nichts)
|
||||
|
||||
- **🔴 TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN (NEU 2026-09-21, Gitea #71):** der aktuell
|
||||
**dringendste** Posten — die internen `*.bf.emsenhuber.net`-Namen lösen im Browser nicht auf
|
||||
(Ursache: AGH-Rewrites im Filter-Pfad für unfiltrierte Clients übersprungen), und `ds-bf` ist
|
||||
öffentlich kaputt (einziger Ingress-Eintrag, der Caddy umgeht). Danach ist **TP34** erst baubar.
|
||||
- **TP46 — CF-/Tunnel-SSoT + Recovery (NEU 2026-09-21, Gitea #72):** Push-Weg + Ingress-Origins +
|
||||
DNS + Access-Policies nach Nautobot, Recovery-Runbook. Eigene Baustelle, unabhängig von TP45.
|
||||
- **TP25 — WLAN-Stabilisierung (NEU 2026-08-17):** Audit durch, **6 Funde** (2,4 GHz fährt 40 MHz statt der
|
||||
profilierten 20; ap2 sendet auf 5 GHz mit **9 statt 18 dBm**, weil ch149 unter ETSI leistungsbegrenzt ist;
|
||||
80 MHz kostet Randabdeckung; unzulässiger Channel-Override auf `w2-iot`; keine `access-list`; 5-Minuten-Takt
|
||||
|
||||
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
|
||||
@@ -84,7 +84,7 @@ dns:
|
||||
ipset_file: ""
|
||||
bootstrap_prefer_ipv6: false
|
||||
upstream_timeout: 10s
|
||||
private_networks: []
|
||||
private_<redacted>: []
|
||||
use_private_ptr_resolvers: false
|
||||
local_ptr_upstreams: []
|
||||
use_dns64: false
|
||||
|
||||
@@ -52,15 +52,19 @@ BENUTZUNG
|
||||
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
|
||||
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
||||
@@ -160,8 +164,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
|
||||
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
||||
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
||||
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
||||
|
||||
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
|
||||
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
|
||||
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
|
||||
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
|
||||
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
|
||||
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
|
||||
def created(p):
|
||||
raw = str(p.get("created") or "")
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
return datetime.min
|
||||
|
||||
by_name = {}
|
||||
for p in found:
|
||||
for p in sorted(found, key=created):
|
||||
name = (p.get("device") or {}).get("display")
|
||||
by_name[name] = p["id"]
|
||||
|
||||
@@ -289,7 +307,7 @@ def job_mgmt_acl():
|
||||
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
|
||||
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
|
||||
|
||||
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.30) aus, also aus
|
||||
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
|
||||
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
|
||||
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
|
||||
|
||||
@@ -353,10 +371,367 @@ def job_tp42_dhcp_domain():
|
||||
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
||||
|
||||
|
||||
def job_tp38_oob():
|
||||
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
|
||||
|
||||
Zwei Geraete, zwei config_sets:
|
||||
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
|
||||
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
|
||||
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
|
||||
die laufende Nautobot-SSH-Session (10.40.20.154) matcht auf Regel 1 (established),
|
||||
bevor Regel 8 (drop) greift.
|
||||
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
|
||||
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
|
||||
waere wirkungslos + non-compliant).
|
||||
|
||||
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
|
||||
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
|
||||
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
|
||||
"""
|
||||
bridge_set = (
|
||||
':do { /interface bridge port set [find interface=ether23] pvid=99 '
|
||||
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
|
||||
'on-error={ :log info "tp38: ether23 set geworfen" }'
|
||||
)
|
||||
vlan_set = (
|
||||
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
|
||||
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
|
||||
)
|
||||
|
||||
branch_fw = "\n".join([
|
||||
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||
])
|
||||
branch_set = "\n".join([
|
||||
bridge_set,
|
||||
vlan_set,
|
||||
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
|
||||
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
|
||||
])
|
||||
|
||||
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
|
||||
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
|
||||
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
|
||||
core_set = "\n".join([
|
||||
bridge_set,
|
||||
vlan_set,
|
||||
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
|
||||
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
|
||||
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
|
||||
'destination=[find where comment="TP5: input log-only"]',
|
||||
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
|
||||
])
|
||||
|
||||
return [
|
||||
(["crs326-branch"], branch_set, "TP38-oob-branch"),
|
||||
(["crs326-core"], core_set, "TP38-oob-core"),
|
||||
]
|
||||
|
||||
|
||||
def job_tp45_agh_fw():
|
||||
"""TP45 Schicht A (2026-09-21): DNS-Freigaben fuer die neuen Resolver-Adressen .30/.31.
|
||||
|
||||
Der Resolver zieht von .36/.37 auf .30/.31 (TP32-Oktett-Klasse DNS `.3x`). Damit die
|
||||
Umstellung ohne Ausfall geht, laufen beide Adressen kurz parallel — also braucht der
|
||||
Core **beide** Freigaben. Diese hier ist ADDITIV: die vier .36/.37-Regeln bleiben
|
||||
stehen und fallen erst beim Rueckbau (eigener Schritt, wenn die Zaehler stillstehen).
|
||||
|
||||
Die vier Regeln sind ein Literal im Soll (`local_config_context_data.firewall_filter`,
|
||||
gepflegt mit `nautobot/tp45-agh-firewall.py`) — der Job zieht nur das Geraet nach.
|
||||
|
||||
🔴 `place-before` wird ueber Netmiko IGNORIERT (Lehre aus TP38, 2026-09-15): die Regel
|
||||
landet am Ketten-Ende, also HINTER dem finalen `drop` und waere wirkungslos, waehrend
|
||||
der Plan trotzdem „Completed" meldet. Deshalb `add` (appends) und danach `move` vor
|
||||
die Log-Regel — in der Reihenfolge, in der die Regeln stehen sollen (jedes `move`
|
||||
schiebt seine Regel unmittelbar vor das Ziel, also hinter die zuvor verschobenen).
|
||||
|
||||
🔴 **Zweite Falle, am 2026-09-21 beim ersten Lauf aufgeschlagen:** nicht der `move`
|
||||
war falsch, sondern der **Selektor** — `find where dst-address=<bare-IP>` matcht nie
|
||||
(Details und Tragweite in der Docstring von `sel()` unten). Der Plan meldete
|
||||
trotzdem „Completed", die Regeln lagen hinter dem `drop`. Deshalb ist die Abnahme
|
||||
hier **nicht** der Plan-Status, sondern die Position am Geraet.
|
||||
|
||||
KEIN LOCKOUT-RISIKO: die Regeln erweitern nur `accept` in der forward-Kette; der
|
||||
Mgmt-Pfad (Nautobot-SSH -> vlan99) ist von der Aenderung nicht beruehrt. Der Deploy
|
||||
laeuft von 10.40.20.154, das auf Regel 22 (established) matcht.
|
||||
"""
|
||||
ips = ["10.40.20.30", "10.40.20.31"]
|
||||
log = 'TP5: log-only, Vorstufe zum deny-by-default'
|
||||
|
||||
def sel(cmt, ip):
|
||||
"""Selektor auf genau eine Regel.
|
||||
|
||||
🔴 **`dst-address=<bare-IP>` matcht in `find where` NIE** — live am Core
|
||||
gemessen (2026-09-21): `find where dst-address=10.40.20.30` → **0 Treffer**,
|
||||
auch mit `/32`; `dst-address~"10.40.20.30"` (Regex) → 2. Deshalb steht hier
|
||||
`~`, nicht `=`.
|
||||
|
||||
⚠️ Die Tragweite ist größer als dieser Job: **eine Prüfung, die 0 meldet,
|
||||
sieht wie „nicht vorhanden" aus und nicht wie „kaputt".** Genau daran ist
|
||||
der erste Lauf dieses Jobs gescheitert — der `add` lief (der Guard sah
|
||||
„nicht vorhanden"), der `move` lief ins Leere, und der Plan meldete
|
||||
trotzdem „Completed". Dieselbe Zeile stand als *Verifikation* in
|
||||
`docs/transaktionen/TP23-agh-firewall.commands.rsc:7` und hat dort seit
|
||||
dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu prüfen.
|
||||
Gleiche Klasse wie `nc -z` (2026-09-01) und das `sed`-Maskierungsmuster:
|
||||
**erst den Selektor verdächtigen, dann das Gerät.**
|
||||
"""
|
||||
return f'comment="{cmt}" dst-address~"{ip}"'
|
||||
|
||||
def rule(ip, proto, cmt):
|
||||
return (f'/ip firewall filter add action=accept chain=forward comment="{cmt}" '
|
||||
f'dst-address={ip} dst-port=53 protocol={proto}')
|
||||
|
||||
lines = []
|
||||
for ip in ips:
|
||||
# Guard + move getrennt: nur der Guard ist idempotent (ein zweiter Lauf
|
||||
# verdoppelt die Regeln nicht), der move läuft immer — er ist auch der
|
||||
# Reparaturweg, wenn eine Regel schon existiert, aber falsch einsortiert ist.
|
||||
for proto, cmt in (("udp", "TP5-E2: LAN->AGH dns"), ("tcp", "TP5-E2: LAN->AGH dns-tcp")):
|
||||
lines.append(f':if ([:len [/ip firewall filter find where {sel(cmt, ip)}]] = 0) '
|
||||
f'do={{ {rule(ip, proto, cmt)} }}')
|
||||
for ip in ips:
|
||||
for cmt in ("TP5-E2: LAN->AGH dns", "TP5-E2: LAN->AGH dns-tcp"):
|
||||
lines.append(f'/ip firewall filter move [find where {sel(cmt, ip)}] '
|
||||
f'destination=[find where comment="{log}"]')
|
||||
# Der Anker ist bewusst die Log-Regel (Baseline, stabil) und nicht `caddy`: haengt
|
||||
# sie unmittelbar vor der Log-Regel, landen die vier Regeln DAHINTER -- der AGH-Block
|
||||
# bleibt so zusammen, wie das Soll ihn fuehrt. Waehlte man `caddy` als Ziel und die
|
||||
# Regel fehlte, laegen die vier hinter dem `drop` = funktional kaputt; so bleibt im
|
||||
# Fehlerfall nur eine Reihenfolge-Abweichung (Compliance-Drift, kein Ausfall).
|
||||
lines.append(f'/ip firewall filter move [find where comment="TP31: AP-cloudflared -> caddy-bf1"] '
|
||||
f'destination=[find where comment="{log}"]')
|
||||
# ⚠️ Diese Zeile meldet NUR Anwesenheit (je IP 2 Regeln erwartet), NICHT die
|
||||
# Position -- sie kann also gruen aussehen, waehrend die Regeln hinter dem `drop`
|
||||
# liegen. Genau daran ist der erste Lauf gescheitert. **Die Abnahme ist der
|
||||
# Geraete-Read nach dem Deploy** (`print terse proplist=comment,packets`).
|
||||
lines.append(
|
||||
':log info ("tp45-agh-fw: .30=" . '
|
||||
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.30"]]] . " .31=" . '
|
||||
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.31"]]] . " log=" . '
|
||||
f'[:tostr [:len [/ip firewall filter find where comment="{log}"]]])')
|
||||
|
||||
return [(["crs326-core"], "\n".join(lines), "TP45-agh-firewall")]
|
||||
|
||||
|
||||
def job_tp45_agh_dhcp():
|
||||
"""TP45 Schicht A (2026-09-21): DHCP-DNS-Server der 7 Netze .36,.37 -> .30,.31.
|
||||
|
||||
Der Core verteilt in ALLEN sieben Netzen `dns-server=10.40.20.36,10.40.20.37`
|
||||
(live geprueft 2026-09-21). Das Template (Z.137) ist auf .30,.31 gehoben; dieser Job
|
||||
zieht das laufende Geraet nach.
|
||||
|
||||
MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der
|
||||
intended config (die wuerfe `already have such address`). `[find]` ohne Filter
|
||||
trifft alle 7 Netze. Gleiche Form wie `job_tp42_dhcp_domain`.
|
||||
|
||||
⚠️ **Reihenfolge zwingend:** erst `tp45-agh-fw` deployen. Sonst liefert DHCP Adressen
|
||||
aus, die die Firewall noch nicht durchlaesst — unter deny-by-default faellt DNS aus
|
||||
allen VLANs aus.
|
||||
|
||||
KEIN LOCKOUT-RISIKO: eine DHCP-Option beruehrt weder Routing noch Mgmt-Zugang;
|
||||
Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d, GUEST 12h).
|
||||
"""
|
||||
core_set = "\n".join([
|
||||
':do { /ip dhcp-server network set [find] dns-server=10.40.20.30,10.40.20.31 } '
|
||||
'on-error={ :log info "tp45-agh-dhcp: set geworfen" }',
|
||||
':log info ("tp45-agh-dhcp: auf-neu=" . [:tostr [:len [/ip dhcp-server network '
|
||||
'find where dns-server~"10.40.20.30"]]] . "/7")',
|
||||
])
|
||||
return [(["crs326-core"], core_set, "TP45-agh-dhcp")]
|
||||
|
||||
|
||||
def _pool_set(vlan, ranges):
|
||||
"""Zeilen, die `pool-v<vlan>` auf `ranges` ziehen -- mit gezaehltem Selektor.
|
||||
|
||||
MODIFY, kein add: der Pool existiert, die `add`-Zeile der intended config
|
||||
wuerfe `already have such name`. Gleiche Form wie `job_tp45_agh_dhcp`.
|
||||
|
||||
🔴 Der Selektor wird GEZAEHLT, nicht geglaubt (Lehre vom 2026-09-21): ein
|
||||
`find where`, das 0 trifft, sieht wie „nicht vorhanden" aus und nicht wie
|
||||
„kaputt" -- ein blankes `set [find where name=…]` liefe ins Leere und der
|
||||
Job meldete trotzdem Erfolg (dieselbe Klasse wie `dst-address=<bare IP>`
|
||||
in TP23-agh-firewall.commands.rsc:7, das seit dem 08-25 immer 0 meldete).
|
||||
|
||||
🔴🔴 **JEDES Statement muss auf EINER Zeile klammer-vollstaendig sein.**
|
||||
Erste Fassung hatte `:if (...) do={` am Zeilenende -- RouterOS geht dann in
|
||||
den Fortsetzungs-Modus und zeigt einen ANDEREN Prompt, der netmiko-Treiber
|
||||
verliert sein Muster und der Deploy stirbt mit
|
||||
`E1015: Pattern not detected: (redacted)@crs326\\-core\\].*$|#.*$)`.
|
||||
Der Plan stand danach auf `Failed`, **ohne** etwas geschrieben zu haben.
|
||||
Deshalb ist jede Zeile hier ein vollstaendiges `… do={ …; … } else={ … }`.
|
||||
"""
|
||||
pool = f"pool-v{vlan}"
|
||||
return "\n".join([
|
||||
f':if ([:len [/ip pool find where name="{pool}"]] = 1) do={{ '
|
||||
f'/ip pool set [find where name="{pool}"] ranges={ranges}; '
|
||||
f':log info ("tp45-pool: {pool} -> " . [/ip pool get [find where name="{pool}"] ranges]) }} '
|
||||
f'else={{ :log info ("tp45-pool: ABBRUCH, {pool} nicht eindeutig") }}',
|
||||
])
|
||||
|
||||
|
||||
def job_tp45_pool_v20():
|
||||
"""TP45 (2026-09-21): pool-v20 von .100-.200 auf .200-.249 verengen.
|
||||
|
||||
Damit werden die Schema-Baender .100-.149 (Server-Hosts) und .150-.199
|
||||
(App-Services) in VLAN20 echt frei. VLAN20 traegt KEINE DHCP-Reservierung
|
||||
im alten Fenster (live geprueft: nur `nas-bf .20.10` in .10x) und 0
|
||||
dynamische Leases -- die Verengung kostet hier nichts.
|
||||
|
||||
🔴 Warum ueberhaupt: `nautobot .154-.158`, `gitea .157`, `pve-mcp .173` und
|
||||
`shelly-mcp .180` tragen ihre IP GERAETELOKAL und stehen in KEINEM
|
||||
Lease-Eintrag -- der Core kann sie nicht schuetzen und einem DHCP-Client
|
||||
.154 anbieten. Die Verengung ist hier sofort wirksam.
|
||||
|
||||
⚠️ VLAN30 gehoert NICHT hierher: dort liegen 35 statische Reservierungen IM
|
||||
Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot .227, Twinkly .228,
|
||||
Withings .230/.231, lichterketten .236/.237, Nuki .241/.244, CCU .232).
|
||||
Erst Schicht B zieht die um, DANN pool-v30.
|
||||
"""
|
||||
return [(["crs326-core"], _pool_set(20, "10.40.20.200-10.40.20.249"), "TP45-pool-v20")]
|
||||
|
||||
|
||||
def _tp45_leases_module():
|
||||
"""MOVES/REMOVE/ADDS aus `tp45-leases.py` laden.
|
||||
|
||||
🔴 **NICHT kopieren.** Diese Tabellen sind die SSoT des Umzugs. Eine zweite
|
||||
Kopie hier liefe beim ersten Edit auseinander — und zwar still, weil beide
|
||||
Seiten fuer sich plausibel aussehen; am Geraet faellt nur auf, dass eine
|
||||
Adresse fehlt.
|
||||
|
||||
Der Dateiname traegt einen Bindestrich (Repo-Konvention fuer Skripte), ist
|
||||
also nicht regulaer importierbar -> importlib ueber den Pfad. `__name__` ist
|
||||
dann nicht `__main__`, das `main()`-Guard der Datei greift also nicht.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"tp45_leases", pathlib.Path(__file__).with_name("tp45-leases.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _lease_move(mac, soll):
|
||||
"""Eine Umzugs-Zeile — EINZEILIG und klammer-vollstaendig (E1015-Lehre, s. _pool_set).
|
||||
|
||||
Der Selektor wird GEZAEHLT: ein `find where`, das 0 trifft, sieht wie
|
||||
„nicht vorhanden" aus und nicht wie „kaputt", und ein blankes `set [find …]`
|
||||
liefe ins Leere, waehrend der Job Erfolg meldet.
|
||||
|
||||
Absichtlich OHNE `address=<ist>` im Selektor: ein zweiter Lauf muss ein
|
||||
No-Op sein, kein Fehler. Die Ist-Adresse wird stattdessen VOR dem Deploy
|
||||
geprueft — `tp45-leases.py move` verlangt, dass das Soll genau `ist` traegt
|
||||
und bricht sonst ab.
|
||||
|
||||
🔴🔴 **ZWEI VERSCHIEDENE Selektor-Formen, und das ist keine Kosmetik.**
|
||||
Die erste Fassung schrieb ueberall die Argument-Form `[… [find where …]]`.
|
||||
Die ist **Syntaxfehler**, sobald sie in `[:len …]` steht:
|
||||
`expected command name (line 1 column 55)` (live gemessen 2026-09-21)
|
||||
Der Deploy lief trotzdem als `Completed` durch, das Geraet blieb
|
||||
**unveraendert**, und im Log stand nur die Schlusszeile — von den 38
|
||||
`:if`-Statements hat **kein einziger** Zweig geloggt, also ist auch kein
|
||||
`set` gelaufen.
|
||||
|
||||
* in `[:len …]` und direkt vor `find`: **`/ip dhcp-server lease find where …`**
|
||||
(kein Klammerpaar um `find`) — Muster aus `_pool_set`/`job_tp45_agh_dhcp`
|
||||
* als Argument einer Vollpfad-Kommandozeile: `set [find where …]`
|
||||
— diese Form ist in `_pool_set` bewiesen und bleibt.
|
||||
|
||||
⚠️ **Merkregel:** `Completed` am Plan und `SUCCESS` am Job beweisen hier
|
||||
nichts. Der Beweis ist die **Zahl der Log-Zeilen** (jeder Zweig loggt) oder
|
||||
ein Geraete-Read. Beides gehoert zur Abnahme — genau wie beim
|
||||
`pool-v20`-Deploy, der ohne Gegenprobe als gelungen gegolten haette.
|
||||
"""
|
||||
find = f'find where mac-address={mac}'
|
||||
arg = f'[find where mac-address={mac}]'
|
||||
return (f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||
f'/ip dhcp-server lease set {arg} address={soll}; '
|
||||
f':log info ("tp45-v30: {mac} -> {soll}") }} '
|
||||
f'else={{ :log info ("tp45-v30: {mac} NICHT EINDEUTIG -- nicht angefasst") }}')
|
||||
|
||||
|
||||
def job_tp45_v30_leases():
|
||||
"""TP45 Schicht B2 (2026-09-21): die VLAN30-Reservierungen ins Oktett-Schema.
|
||||
|
||||
36 Reservierungen wandern aus dem DHCP-Pool-Fenster (.202-.244, .5, .8) in die
|
||||
Schema-Baender: `.11-.14` fest verbaute Infrastruktur, `.151-.176` Shelly-Block,
|
||||
`.177-.196` App-Services. Dazu zwei Sonderfaelle:
|
||||
|
||||
* `pve-homematic-raspi-ccu` (MAC BC:24:11:8E:0A:57) wird ENTFERNT — eine
|
||||
Leiche aus dem Core-Cutover (`status=waiting`, `last-seen=never`). Die CCU
|
||||
ist live in VLAN20 (.20.232, ARP + HTTP 200 belegt, Caddys `ccu.bf` proxyt
|
||||
dorthin); die VLAN30-Reservierung ist ein Zombie.
|
||||
* `SONOFF-NSPanelPro` hat GAR KEINE Reservierung, nur eine dynamische Lease
|
||||
auf `.249`. Wird per `make-static` gepinnt und auf `.12` gezogen.
|
||||
|
||||
⚠️ **Reihenfolge:** dieser Job gehoert VOR `tp45-pool-v30`. Die Verengung auf
|
||||
`.200-.249` setzt voraus, dass keine Reservierung mehr im Schema-Band liegt —
|
||||
sonst zieht der Pool Adressen, die schon vergeben sind.
|
||||
|
||||
⚠️ **Kein Ausfall, kein Lockout:** ueber DHCP-Reservierungen umzuziehen heisst,
|
||||
dass die Geraete beim naechsten Lease-Renew selbst nachziehen (lease-time 1d).
|
||||
Nur zwei der 36 sind im Moment ueberhaupt `bound` (SwitchBot, DTUBI); alle
|
||||
anderen stehen auf `last-seen=never`. Nichts wird hart umgeschaltet.
|
||||
|
||||
⚠️ **`.150` (Ender3V3KE) und `.180` (espressif/Airfryer) bleiben, wo sie sind** —
|
||||
sie liegen bereits im App-Band und werden bewusst nicht bewegt.
|
||||
|
||||
Rueckweg: `MOVES` traegt Ist UND Soll je MAC, das inverse `set` ist also
|
||||
mechanisch; zusaetzlich liegt vor dem Deploy ein binaeres Backup auf dem Core.
|
||||
"""
|
||||
mod = _tp45_leases_module()
|
||||
lines = [_lease_move(mac, soll) for mac, (_ist, soll) in sorted(mod.MOVES.items())]
|
||||
|
||||
for mac in sorted(mod.REMOVE):
|
||||
find = f'find where mac-address={mac}'
|
||||
arg = f'[find where mac-address={mac}]'
|
||||
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||
f'/ip dhcp-server lease remove {arg}; '
|
||||
f':log info ("tp45-v30: CCU-Leiche {mac} entfernt") }} '
|
||||
f'else={{ :log info ("tp45-v30: CCU-Leiche schon weg") }}')
|
||||
|
||||
for add in mod.ADDS:
|
||||
mac = re.search(r"mac-address=(\S+)", add).group(1)
|
||||
ip = re.search(r"address=(\S+)", add).group(1)
|
||||
cmt = re.search(r"comment=(\S+)", add).group(1)
|
||||
find = f'find where mac-address={mac}'
|
||||
arg = f'[find where mac-address={mac}]'
|
||||
# make-static VOR set: auf einer dynamischen Lease ist `set address=` wirkungslos
|
||||
# bzw. wird beim naechsten Renew ohnehin verworfen -- erst statisch, dann pinnen.
|
||||
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||
f'/ip dhcp-server lease make-static {arg}; '
|
||||
f'/ip dhcp-server lease set {arg} address={ip} comment="{cmt}"; '
|
||||
f':log info ("tp45-v30: NSPanelPro statisch auf {ip}") }} '
|
||||
f'else={{ :log info ("tp45-v30: NSPanelPro hat keine Lease -- spaeter nachziehen") }}')
|
||||
|
||||
# Abnahme-Zeile. `address=` ist ein echter Eigenschaftsvergleich (kein Regex) --
|
||||
# bewusst NICHT `~"10.40.30.11"`, das traefe auch .110-.119.
|
||||
lines.append(
|
||||
':log info ("tp45-v30: .11=" . [:tostr [:len [/ip dhcp-server lease '
|
||||
'find where address=10.40.30.11]]] . " .12=" . [:tostr [:len [/ip dhcp-server '
|
||||
'lease find where address=10.40.30.12]]] . " .151=" . [:tostr [:len '
|
||||
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .196=" . '
|
||||
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.196]]])')
|
||||
|
||||
return [(["crs326-core"], "\n".join(lines), "TP45-v30-leases")]
|
||||
|
||||
|
||||
JOBS = {
|
||||
"drift-cleanup": job_drift_cleanup,
|
||||
"mgmt-acl": job_mgmt_acl,
|
||||
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
||||
"tp38-oob": job_tp38_oob,
|
||||
"tp45-agh-fw": job_tp45_agh_fw,
|
||||
"tp45-agh-dhcp": job_tp45_agh_dhcp,
|
||||
"tp45-pool-v20": job_tp45_pool_v20,
|
||||
"tp45-v30-leases": job_tp45_v30_leases,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
||||
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
||||
|
||||
|
||||
199
nautobot/gc-snapshot.py
Normal file
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)
|
||||
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
||||
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
|
||||
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
||||
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
|
||||
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ["NAUTOBOT_TOKEN"]
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
|
||||
"""
|
||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet).
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
|
||||
"""
|
||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Quelle: ap1 live via MikroMCP + docs/teilprojekte.md.
|
||||
"""
|
||||
import json, sys, urllib.request, urllib.error
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
import os
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
@@ -27,7 +27,7 @@ import pathlib
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
DEVICE = "crs326-core"
|
||||
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null).
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -14,7 +14,7 @@ Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null).
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
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
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ UMGEKEHRT GILT: die `vlanXX`-Descriptions auf dem Core NICHT anfassen, die lande
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
105
nautobot/ingest-tp38-oob.py
Normal file
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.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
|
||||
{%- set vlans = [
|
||||
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
|
||||
{"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"},
|
||||
{"id": 20, "name": "INFRA", "pool": "10.40.20.200-10.40.20.249", "lease": "1d"},
|
||||
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
|
||||
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
|
||||
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
|
||||
@@ -73,9 +73,11 @@
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
||||
{% for n in range(17, 25) %}
|
||||
{% for n in range(17, 23) %}
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
||||
{% endfor %}
|
||||
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
||||
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
||||
@@ -94,7 +96,7 @@
|
||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
||||
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99
|
||||
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99
|
||||
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
||||
|
||||
# [1] INTERFACE-LISTS
|
||||
@@ -132,10 +134,29 @@
|
||||
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
||||
{% endfor %}
|
||||
{% for v in vlans %}
|
||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||
{% endfor %}
|
||||
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
||||
|
||||
# [5b] DHCP-RESERVIERUNGEN — kommen als DATEN aus local_config_context_data.dhcp_leases,
|
||||
# wie firewall_filter. Warum das noetig war: bis 2026-09-21 rendert dieses Template
|
||||
# KEIN einziges `/ip dhcp-server lease`. Die 54 Reservierungen stammen aus dem
|
||||
# Einmal-Skript routeros/tp21-core-dhcp-from-ap1.rsc (Core-Cutover 2026-08-06) und
|
||||
# standen damit in KEINEM Soll — ein GC-Recovery von null haette einen Core ohne
|
||||
# Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt. Und weil
|
||||
# Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben.
|
||||
# Dieselbe Klasse wie die handgelaufene `nautobot`-Adressliste (s. CLAUDE.md).
|
||||
#
|
||||
# 🔴 Zeilen sind RouterOS-/export-Form, alphabetisch, OHNE fuehrenden Pfad — genau
|
||||
# wie firewall_filter. Gefuellt und verschoben von nautobot/tp45-leases.py.
|
||||
# 🔴 Die REIHENFOLGE wird NICHT sortiert: das Backup traegt die Sektion in
|
||||
# Einfuege-Reihenfolge (.30.150 frueh, .30.5 spaet), ein numerisch sortiertes
|
||||
# Soll waere ein Dauer-Falsch-Drift. Die ComplianceRule steht deshalb auf
|
||||
# config_ordered=no.
|
||||
{% for line in obj.get_config_context().get("dhcp_leases", []) %}
|
||||
/ip dhcp-server lease {{ line }}
|
||||
{% endfor %}
|
||||
|
||||
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
|
||||
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
||||
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
||||
@@ -222,10 +243,12 @@
|
||||
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
||||
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
||||
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
||||
# sec-main: ft=no seit 2026-09-04 (User-Entscheid nach TP25-Messung). Fast-Transition
|
||||
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no.
|
||||
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus).
|
||||
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>"
|
||||
# sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
|
||||
# "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
|
||||
# Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
|
||||
# Fund-E-Abschlussmessung 09-02 ruhig (4 reauth/8,6 h, 0 Ausfaelle) auf 7.24.1.
|
||||
# 2026-09-04 stand hier: ft=no ft-over-ds=no (User-Entscheid nach TP25-Messung).
|
||||
/interface wifi security add authentication-types=wpa3-psk ft=yes ft-over-ds=yes name=sec-main passphrase="<<REDACTED>>"
|
||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
||||
@@ -239,11 +262,16 @@
|
||||
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
||||
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
||||
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
||||
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||
# st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
|
||||
# zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante). transition-threshold-time=5s
|
||||
# statt 10s. NUR 5G: die drei 2G-Configs bleiben auf st-main (unset=-80), weil 2.4G draussen
|
||||
# (Terrasse/ausserhalb der Wohnung) Reichweite behalten soll. 8 dB Abstand = kleine Hysterese.
|
||||
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes
|
||||
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
||||
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
||||
@@ -273,17 +301,16 @@
|
||||
# python3 nautobot/gc-transaktion.py begin --device crs326-core \
|
||||
# --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT
|
||||
#
|
||||
# 🔴 Hier landet TP25 **Fund E** (`signal-range`-Boden gegen klebende Clients),
|
||||
# sobald er entschieden ist. Die alte Auflage lautete "vor dem Eintragen den
|
||||
# Sticky-Fall erneut messen" -- das ist am 2026-08-30 passiert: dasselbe iPhone
|
||||
# machte 16 Bandwechsel in 20 Minuten bei -53..-81 dBm, erst an ap3, dann an ap2.
|
||||
# Fund E ist damit WIEDER OFFEN.
|
||||
# Eingetragen wird hier trotzdem NICHTS, und zwar bewusst: ein signal-range-Boden
|
||||
# ist der invasivste der drei Hebel und kann Clients AUSSPERREN -- genau das war
|
||||
# 08-22 das Gegenargument (in den schwachen Raeumen gibt es keinen besseren AP,
|
||||
# auf den ein Boden steuern koennte). Erst kommen (1) `2g-probe-delay=yes` und
|
||||
# (2) `ft-over-ds=no`, jeder einzeln und als Transaktion, mit dem seit 2026-08-30
|
||||
# laufenden wireless-Logging als Messgrundlage. Siehe teilprojekte.md TP25 Fund E.
|
||||
# 🔴 Stand 2026-09-14 (Terrasse-In-situ-Session): der `signal-range`-Boden gegen
|
||||
# klebende Clients ist GEPRUEFT und GESCHEITERT -- `/interface wifi access-list`
|
||||
# hat kein `band`-Property (Band-Scoping ginge nur ueber cap-wifiN-Interface-Namen,
|
||||
# die bei jedem CAP-Rejoin wechseln), SSID-/MAC-Scope ohne Band träfe 2,4 GHz mit
|
||||
# (Lockout-Risiko, Schlafzimmer -84). Der Boden ist damit NICHT umsetzbar; der
|
||||
# Ersatz gegen die Call-Aussetzer ist `ft=yes ft-over-ds=yes` auf sec-main
|
||||
# (2026-09-14 zurueckgesetzt, s. docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
|
||||
# und handoff-20260914-tp25-ap2-insitu-terrassse.md): Roams laufen als nahtlose
|
||||
# FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst
|
||||
# mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14).
|
||||
|
||||
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
|
||||
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
|
||||
@@ -416,9 +443,20 @@
|
||||
/system ntp client servers add address=10.40.99.1
|
||||
/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1
|
||||
|
||||
# INPUT-FIREWALL (TP38, 2026-09-15) — deny-by-default fuer die Mgmt-Ebene (10.40.99.2).
|
||||
# Der Branch ist L2-only: ALLE Mgmt-Anfragen kommen auf der einen vlan99-SVI an, egal ob
|
||||
# VLAN10 (ueber den Core geroutet), cf-mesh (100.96.0.0/12) oder der OOB-Port (untagged
|
||||
# ether23, 10.40.99.x). Deshalb matcht die Quelle per src-address, nicht per in-interface.
|
||||
# Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12 spiegelt bewusst das `available-from` der
|
||||
# /ip service-Zeilen — kein neues Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
|
||||
{% for line in obj.get_config_context().get("firewall_filter", []) %}
|
||||
/ip firewall filter {{ line }}
|
||||
{% endfor %}
|
||||
|
||||
# L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort).
|
||||
# ether9-23 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
|
||||
# ether9-22 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
|
||||
# bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird.
|
||||
# ether23 ist seit TP38 der OOB-MGMT-Port (untagged VLAN99), s. unten.
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether1
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether2
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10
|
||||
@@ -427,9 +465,10 @@
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether8
|
||||
{% for n in range(9, 24) %}
|
||||
{% for n in range(9, 23) %}
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20
|
||||
{% endfor %}
|
||||
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
|
||||
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
||||
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1
|
||||
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2
|
||||
@@ -439,7 +478,7 @@
|
||||
/interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40
|
||||
/interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50
|
||||
/interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90
|
||||
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge vlan-ids=99
|
||||
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge untagged=ether23 vlan-ids=99
|
||||
{% elif obj.role.name == "Wireless AP" %}
|
||||
{% set ap = obj.get_config_context()["ap"] %}
|
||||
{% set mgmt_host = obj.primary_ip4.host %}
|
||||
|
||||
@@ -36,7 +36,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
@@ -38,7 +38,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
@@ -134,10 +134,10 @@ VMS = {
|
||||
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
|
||||
],
|
||||
"nas-bf": [
|
||||
("nautobot", "10.40.20.30/24", "Nautobot Web (DS923 ep14)"),
|
||||
("nautobot-worker", "10.40.20.31/24", "Nautobot Golden-Config Worker"),
|
||||
("nautobot-scheduler", "10.40.20.32/24", "Nautobot Scheduler"),
|
||||
("nautobot-redis", "10.40.20.34/24", "Nautobot Redis"),
|
||||
("nautobot", "10.40.20.154/24", "Nautobot Web (DS923 ep14)"),
|
||||
("nautobot-worker", "10.40.20.155/24", "Nautobot Golden-Config Worker"),
|
||||
("nautobot-scheduler", "10.40.20.156/24", "Nautobot Scheduler"),
|
||||
("nautobot-redis", "10.40.20.158/24", "Nautobot Redis"),
|
||||
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
|
||||
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
|
||||
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),
|
||||
|
||||
@@ -29,7 +29,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
||||
|
||||
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
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()
|
||||
276
nautobot/tp45-leases.py
Normal file
276
nautobot/tp45-leases.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""TP45 Schicht B — die DHCP-Reservierungen des Cores ins Soll heben.
|
||||
|
||||
DIE LUECKE, DIE DAS SCHLIESST
|
||||
-----------------------------
|
||||
`nautobot/mikrotik_routeros.j2` rendert bis 2026-09-21 **kein einziges**
|
||||
`/ip dhcp-server lease`. Die 54 statischen Reservierungen des Cores stammen aus dem
|
||||
Einmal-Skript `routeros/tp21-core-dhcp-from-ap1.rsc` (Core-Cutover 2026-08-06) und
|
||||
stehen in **keinem** Soll.
|
||||
|
||||
Folge: ein GC-Recovery von null erzeugt einen Core ohne Reservierungen — jedes
|
||||
IoT-Geraet faellt in den Pool, und weil Compliance nur prueft, was gerendert wird,
|
||||
bliebe die Zahl dabei gruen. Dieselbe Klasse wie die `nautobot`-Adressliste
|
||||
(s. CLAUDE.md): "was auf dem Geraet existiert, aber nicht im Template, ist eine
|
||||
Recovery-Luecke". Zusaetzlich war keine einzige Reservierung drift-ueberwacht.
|
||||
|
||||
DAS SOLL
|
||||
--------
|
||||
Die Zeilen liegen wie `firewall_filter` als LISTE von Strings in
|
||||
`local_config_context_data.dhcp_leases` auf `crs326-core` und werden vom Template
|
||||
mit `/ip dhcp-server lease {{ line }}` gerendert.
|
||||
|
||||
🔴 **Zeilenform ist die RouterOS-`/export`-Form, alphabetisch, ohne fuehrenden Pfad**
|
||||
(genau wie `firewall_filter`):
|
||||
add address=10.40.30.244 comment=NukiBridge mac-address=24:62:AB:B1:63:F4 server=dhcp-v30
|
||||
|
||||
🔴 **Die REIHENFOLGE wird erhalten, nicht sortiert.** Am Geraet steht die Sektion in
|
||||
Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet) — `:export` gibt sie so aus, und
|
||||
das Backup wird textuell verglichen. Ein numerisch sortiertes Soll waere ein
|
||||
Dauer-Falsch-Drift.
|
||||
|
||||
🔴 **Zeilen werden NICHT neu gebaut, sondern uebernommen und nur im `address=`-Token
|
||||
geaendert.** Damit bleiben Quoting (`comment="P750W / P750W"` vs `comment=NukiBridge`)
|
||||
und die 14 `client-id`-Felder automatisch korrekt — beides nachzubauen waere eine
|
||||
Fehlerquelle ohne Nutzen.
|
||||
|
||||
WARUM DER LESEWEG UEBER NAUTOBOT GEHT
|
||||
-------------------------------------
|
||||
Quelle fuer `seed` ist das GC-`backup_config` des Cores (taeglich 03:30 UTC bzw.
|
||||
per `gc-configplan.py --refresh-compliance`). Kein Geraete-Credential noetig.
|
||||
⚠️ `seed` ist damit ein RE-BASELINE und darf **nur** laufen, solange der Ist-Stand
|
||||
der gewollte ist (Schicht B1). Fuer die geplante Aenderung gibt es `move`, das auf
|
||||
dem **bestehenden Soll** rechnet — ein Soll, das dem Ist nachlaeuft, erkennt keine
|
||||
Drift mehr (genau der Grund, warum `ingest-core-firewall.py` manuell bleibt).
|
||||
|
||||
python3 nautobot/tp45-leases.py check # Soll vs. Backup-Diff
|
||||
python3 nautobot/tp45-leases.py seed # Dry-Run
|
||||
python3 nautobot/tp45-leases.py seed --apply # B1
|
||||
python3 nautobot/tp45-leases.py seed --force --apply # Neuschnitt, ueberschreibt
|
||||
python3 nautobot/tp45-leases.py move # Dry-Run, s. MOVES
|
||||
python3 nautobot/tp45-leases.py move --apply # B2
|
||||
|
||||
Token: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||
Der Wert wird nie ausgegeben.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
NB = "http://10.40.20.154:8080"
|
||||
DEVICE = "crs326-core"
|
||||
KEY = "dhcp_leases"
|
||||
PREFIX = "/ip dhcp-server lease "
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs/nas-vlan20/stacks/210-nautobot.yml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Adressumzug (Schicht B2). Schluessel = MAC, Wert = (ist, soll).
|
||||
# Quelle der Soll-Spalte: docs/teilprojekte.md ### TP45 / der Plan.
|
||||
# Steht hier als Tabelle und nicht als Ableitung — der Umzug ist eine Absicht,
|
||||
# kein Rechenergebnis.
|
||||
# ---------------------------------------------------------------------------
|
||||
MOVES = {
|
||||
# fest verbaute Infrastruktur -> .10-.19
|
||||
"24:62:AB:B1:63:F4": ("10.40.30.244", "10.40.30.11"), # NukiBridge
|
||||
"4C:EB:BD:E3:25:F2": ("10.40.30.5", "10.40.30.13"), # P750W (Etikettendrucker)
|
||||
"54:F2:9F:12:51:F0": ("10.40.30.8", "10.40.30.14"), # DTUBI-414392337823
|
||||
# Shelly-Block .151-.176 (s1 -> .151 ... s26 -> .176)
|
||||
"E4:B0:63:D7:C7:A4": ("10.40.30.208", "10.40.30.151"), # shelly-s1
|
||||
"28:37:2F:36:F4:7C": ("10.40.30.209", "10.40.30.152"), # shelly-s2
|
||||
"E4:B0:63:E4:EF:7C": ("10.40.30.210", "10.40.30.153"), # shelly-s3
|
||||
"E4:B0:63:E4:0F:30": ("10.40.30.211", "10.40.30.154"), # shelly-s4
|
||||
"D8:85:AC:0C:5C:C8": ("10.40.30.212", "10.40.30.155"), # shelly-s5
|
||||
"D0:CF:13:D8:18:FC": ("10.40.30.213", "10.40.30.156"), # shelly-s6
|
||||
"D0:CF:13:D9:94:E8": ("10.40.30.214", "10.40.30.157"), # shelly-s7
|
||||
"E4:B0:63:FA:FD:E0": ("10.40.30.215", "10.40.30.158"), # shelly-s8
|
||||
"E8:F6:0A:61:88:08": ("10.40.30.216", "10.40.30.159"), # shelly-s9
|
||||
"E4:B0:63:FC:57:E4": ("10.40.30.217", "10.40.30.160"), # shelly-s10
|
||||
"E4:B0:63:FA:6C:00": ("10.40.30.218", "10.40.30.161"), # shelly-s11
|
||||
"48:F6:EE:C8:E6:AC": ("10.40.30.219", "10.40.30.162"), # shelly-s12
|
||||
"48:F6:EE:DD:6A:F4": ("10.40.30.220", "10.40.30.163"), # shelly-s13
|
||||
"E4:B0:63:FC:59:48": ("10.40.30.221", "10.40.30.164"), # shelly-s14
|
||||
"E4:B0:63:FA:D5:34": ("10.40.30.222", "10.40.30.165"), # shelly-s15
|
||||
"10:20:BA:FA:F5:40": ("10.40.30.223", "10.40.30.166"), # shelly-s16
|
||||
"10:20:BA:FB:6D:64": ("10.40.30.224", "10.40.30.167"), # shelly-s17
|
||||
"48:F6:EE:CC:16:E4": ("10.40.30.225", "10.40.30.168"), # shelly-s18
|
||||
"10:20:BA:FB:70:FC": ("10.40.30.226", "10.40.30.169"), # shelly-s19
|
||||
"98:A3:16:7C:5F:EC": ("10.40.30.206", "10.40.30.170"), # shelly-s20
|
||||
"20:6E:F1:01:F9:F8": ("10.40.30.207", "10.40.30.171"), # shelly-s21
|
||||
"A0:85:E3:C8:65:34": ("10.40.30.202", "10.40.30.172"), # shelly-s22
|
||||
"AC:15:18:7B:62:20": ("10.40.30.203", "10.40.30.173"), # shelly-s23
|
||||
"14:33:5C:D8:2D:7C": ("10.40.30.204", "10.40.30.174"), # shelly-s24
|
||||
"98:A3:16:79:38:50": ("10.40.30.205", "10.40.30.175"), # shelly-s25
|
||||
"98:A3:16:7A:A3:54": ("10.40.30.233", "10.40.30.176"), # shelly-s26 (bad luefter)
|
||||
# App-Block .177-.199
|
||||
"28:DB:A7:09:7B:54": ("10.40.30.241", "10.40.30.177"), # NukiSmartlock
|
||||
"B8:F8:62:09:F2:04": ("10.40.30.227", "10.40.30.178"), # SwitchBot-floor-lamp
|
||||
"98:F4:AB:2C:A7:88": ("10.40.30.228", "10.40.30.179"), # Twinkly-2CA789
|
||||
"A4:F0:0F:5F:D0:EC": ("10.40.30.236", "10.40.30.190"), # lichterkette
|
||||
"24:DC:C3:9B:89:64": ("10.40.30.237", "10.40.30.191"), # lichterkette-mini
|
||||
"00:24:E4:40:2A:72": ("10.40.30.230", "10.40.30.195"), # Withings_waage
|
||||
"A4:7E:FA:18:2C:64": ("10.40.30.231", "10.40.30.196"), # Withings_blutdruck
|
||||
# .150 (Ender3V3KE) und .180 (espressif/Airfryer) bleiben, wo sie sind.
|
||||
}
|
||||
|
||||
# CCU-Leiche: VLAN30-Reservierung `pve-homematic-raspi-ccu`, status=waiting,
|
||||
# last-seen=never. Die CCU ist live in VLAN20 (10.40.20.232, ARP+HTTP belegt,
|
||||
# Caddys ccu.bf proxyt dorthin) — dieser Eintrag ist ein Zombie aus dem
|
||||
# Core-Cutover. Er wird ENTFERNT, nicht umgezogen.
|
||||
REMOVE = {"BC:24:11:8E:0A:57"}
|
||||
|
||||
# SONOFF-NSPanelPro hat KEINE Reservierung, nur eine dynamische Lease auf .249.
|
||||
# Wird in B2 per make-static + set address gepinnt. Zeile hier nur als Soll.
|
||||
ADDS = [
|
||||
"add address=10.40.30.12 client-id=1:7c:88:99:6:7:ec comment=SONOFF-NSPanelPro "
|
||||
"mac-address=7C:88:99:06:07:EC server=dhcp-v30",
|
||||
]
|
||||
|
||||
|
||||
def token():
|
||||
for line in YML.read_text(errors="replace").splitlines():
|
||||
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
|
||||
|
||||
|
||||
def nb(path, method="GET", payload=None):
|
||||
req = urllib.request.Request(
|
||||
NB + path, method=method,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={"Authorization": "Token " + token(),
|
||||
"Content-Type": "application/json", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit("!! %s %s -> %s\n %s" % (method, path, e.code, e.read().decode()[:600]))
|
||||
|
||||
|
||||
def device():
|
||||
return nb("/api/dcim/devices/?name=" + DEVICE)["results"][0]
|
||||
|
||||
|
||||
def backup_lines():
|
||||
"""Die `/ip dhcp-server lease`-Zeilen des Cores aus dem GC-Backup, in Geraete-Reihenfolge."""
|
||||
rows = nb("/api/plugins/golden-config/golden-config/?depth=1&limit=0")["results"]
|
||||
for r in rows:
|
||||
dev = r.get("device")
|
||||
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||
if dev == DEVICE:
|
||||
cfg = r.get("backup_config") or ""
|
||||
when = (r.get("backup_last_success_date") or "?")[:19]
|
||||
# 🔴 len(PREFIX), NICHT l[1:]: das Argument-Teil ist ohne den ganzen Befehl
|
||||
# zu speichern — genau wie firewall_filter, wo `add action=…` steht und
|
||||
# das Template `/ip firewall filter ` davorsetzt. Ein blosses l[1:] liess
|
||||
# `ip dhcp-server lease add …` stehen und erzeugte im gerenderten Soll
|
||||
# den Befehl DOPPELT (`/ip dhcp-server lease ip dhcp-server lease add …`).
|
||||
# Gefunden am 2026-09-21 an der ersten Compliance-Zahl nach dem Einbau:
|
||||
# 144/145 statt 145/145.
|
||||
return ([l[len(PREFIX):] for l in cfg.splitlines() if l.startswith(PREFIX)], when)
|
||||
raise SystemExit("kein GC-Eintrag für " + DEVICE)
|
||||
|
||||
|
||||
def mac_of(line):
|
||||
m = re.search(r"\bmac-address=(\S+)", line)
|
||||
return m.group(1).upper() if m else ""
|
||||
|
||||
|
||||
def set_address(line, ip):
|
||||
new, n = re.subn(r"(?<=^add address=)\S+", ip, line)
|
||||
if n != 1:
|
||||
raise SystemExit("address-Token nicht eindeutig in: " + line)
|
||||
return new
|
||||
|
||||
|
||||
def show(old, new):
|
||||
print("== Diff (%d -> %d Zeilen) ==" % (len(old), len(new)))
|
||||
om, nm = {mac_of(l): l for l in old}, {mac_of(l): l for l in new}
|
||||
for mac in sorted(set(om) | set(nm)):
|
||||
a, b = om.get(mac), nm.get(mac)
|
||||
if a == b:
|
||||
continue
|
||||
if a is None:
|
||||
print(" + %s" % b[:150])
|
||||
elif b is None:
|
||||
print(" - %s" % a[:150])
|
||||
else:
|
||||
print(" ~ %s -> %s" % (re.search(r"address=(\S+)", a).group(1),
|
||||
re.search(r"address=(\S+)", b).group(1)))
|
||||
print(" %s" % b[:150])
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
if not argv or argv[0] not in ("seed", "move", "check"):
|
||||
raise SystemExit(__doc__.strip().splitlines()[-2].strip())
|
||||
cmd, apply_ = argv[0], "--apply" in argv
|
||||
dev = device()
|
||||
ctx = dict(dev.get("local_config_context_data") or {})
|
||||
soll = list(ctx.get(KEY) or [])
|
||||
|
||||
if cmd == "check":
|
||||
ist, when = backup_lines()
|
||||
print("== check: %s / %s (Backup %s, %d Zeilen) ==" % (DEVICE, KEY, when, len(ist)))
|
||||
if not soll:
|
||||
print(" Soll ist LEER — Reservierungen stehen in keinem Soll.")
|
||||
return
|
||||
show(soll, ist)
|
||||
print(" (Diff oben: '-' = nur im Soll, '~' = Adresse weicht ab)")
|
||||
return
|
||||
|
||||
if cmd == "seed":
|
||||
if soll and "--force" not in argv:
|
||||
raise SystemExit("Soll ist NICHT leer (%d Zeilen) — `seed` ist ein Re-Baseline. "
|
||||
"Fuer eine geplante Aenderung `move` benutzen; fuer einen "
|
||||
"bewussten Neuschnitt `--force`." % len(soll))
|
||||
new, when = backup_lines()
|
||||
print("== seed aus Backup %s (%d Zeilen)%s =="
|
||||
% (when, len(new), " [--force, ueberschreibt %d Zeilen]" % len(soll) if soll else ""))
|
||||
else:
|
||||
if not soll:
|
||||
raise SystemExit("Soll ist leer — erst `seed` laufen lassen (Schicht B1).")
|
||||
new, unknown = [], set()
|
||||
seen = set()
|
||||
for line in soll:
|
||||
mac = mac_of(line)
|
||||
if mac in REMOVE:
|
||||
seen.add(mac)
|
||||
continue
|
||||
if mac in MOVES:
|
||||
ist_ip, soll_ip = MOVES[mac]
|
||||
if re.search(r"address=(\S+)", line).group(1) != ist_ip:
|
||||
raise SystemExit("%s: Ist-Adresse im Soll ist nicht %s — Soll und MOVES "
|
||||
"auseinander. Zeile: %s" % (mac, ist_ip, line[:120]))
|
||||
line = set_address(line, soll_ip)
|
||||
seen.add(mac)
|
||||
new.append(line)
|
||||
for mac in list(MOVES) + list(REMOVE):
|
||||
if mac not in seen:
|
||||
unknown.add(mac)
|
||||
if unknown:
|
||||
raise SystemExit("MOVES/REMOVE nennen MACs, die im Soll fehlen: %s"
|
||||
% ", ".join(sorted(unknown)))
|
||||
for add in ADDS:
|
||||
if mac_of(add) not in {mac_of(l) for l in new}:
|
||||
new.append(add)
|
||||
print("== move: %d Adressen verschoben, %d entfernt, %d ergaenzt =="
|
||||
% (len(MOVES), len(REMOVE), len(ADDS)))
|
||||
|
||||
show(soll, new)
|
||||
if not apply_:
|
||||
print("\nDRY-RUN — mit --apply nach local_config_context_data.%s schreiben." % KEY)
|
||||
return
|
||||
ctx[KEY] = new
|
||||
nb("/api/dcim/devices/%s/" % dev["id"], "PATCH", {"local_config_context_data": ctx})
|
||||
print("\n ~ %d Zeilen nach local_config_context_data.%s geschrieben." % (len(new), KEY))
|
||||
print(" Geraet zieht nach per: python3 nautobot/gc-configplan.py --refresh-compliance")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
opencode.json
Normal file
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 urllib.request
|
||||
|
||||
NAUTOBOT_URL = "http://10.40.20.30:8080"
|
||||
NAUTOBOT_URL = "http://10.40.20.154:8080"
|
||||
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
|
||||
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")
|
||||
|
||||
Reference in New Issue
Block a user