Compare commits
57 Commits
56746cb24d
...
tp38-oob
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 614025eafb | |||
| 677dade184 | |||
| b20edd6b62 | |||
| a38ed0fe1d | |||
| 5a29c482bd | |||
| 4eba3673b7 | |||
| 2b856d3d42 | |||
| f1d565bc88 | |||
| 43c592e5d4 | |||
| 33b18a08db | |||
| 799b693ffa | |||
| 4aeced30b5 | |||
| 89d94dbd45 | |||
| 6f60e03679 | |||
| 2f4866ca9a | |||
| 8e41268b4b | |||
| b915d02a82 | |||
| efb03979a0 | |||
| 268fd859ab | |||
| 7a20a1db4f | |||
| bca8f6e27e | |||
| d63dc7bb78 | |||
| b200401c5a | |||
| 384f356290 | |||
| e96fca5a52 | |||
| 1625c30fd7 | |||
| 83fd69ef8d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,10 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.secrets
|
.secrets
|
||||||
|
|
||||||
# Per-Projekt-MCP-Configs: können Tokens/URLs enthalten, projekt-spezifisch, nicht ins Repo
|
|
||||||
.mcp.json
|
|
||||||
|
|
||||||
|
|
||||||
# Python bytecode
|
# Python bytecode
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
67
.mcp.json
Normal file
67
.mcp.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"home-assistant": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "${MCP_HOME_ASSISTANT_URL}"
|
||||||
|
},
|
||||||
|
"mikromcp": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"mikromcp",
|
||||||
|
"serve"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MIKROMCP_CONFIG_PATH": "/Users/christophemsenhuber/dev/bf-infra/mcp/mikromcp/config/routers.yaml",
|
||||||
|
"MIKROMCP_DEFAULT_ROUTER": "ap1",
|
||||||
|
"ROUTER_AP1_USER": "admin",
|
||||||
|
"ROUTER_AP1_PASS": "${MCP_MIKROMCP_ROUTER_AP1_PASS}",
|
||||||
|
"ROUTER_AP2_USER": "admin",
|
||||||
|
"ROUTER_AP2_PASS": "${MCP_MIKROMCP_ROUTER_AP2_PASS}",
|
||||||
|
"CRS326_CORE_USER": "admin",
|
||||||
|
"CRS326_CORE_PASS": "${MCP_MIKROMCP_CRS326_CORE_PASS}",
|
||||||
|
"CRS326_BRANCH_USER": "admin",
|
||||||
|
"CRS326_BRANCH_PASS": "${MCP_MIKROMCP_CRS326_BRANCH_PASS}",
|
||||||
|
"ROUTER_AP3_USER": "admin",
|
||||||
|
"ROUTER_AP3_PASS": "${MCP_MIKROMCP_ROUTER_AP3_PASS}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hostinger-api": {
|
||||||
|
"alwaysLoad": true,
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "hostinger-api-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"HOSTINGER_API_TOKEN": "${MCP_HOSTINGER_API_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloudflare": {
|
||||||
|
"alwaysLoad": true,
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.cloudflare.com/mcp"
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"ollama-mcp"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"OLLAMA_HOST": "http://127.0.0.1:11434"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pve-mcp": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://10.40.20.173:3000/"
|
||||||
|
},
|
||||||
|
"n8n-mcp": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://10.40.20.167:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${N8N_MCP_BEARER}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -20,6 +20,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
|
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
|
||||||
**Compliance 140/140**). **Am 2026-09-04 hat Christoph `ft=no` auf `sec-main` als Dauerzustand
|
**Compliance 140/140**). **Am 2026-09-04 hat Christoph `ft=no` auf `sec-main` als Dauerzustand
|
||||||
entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen).
|
entschieden** (Hebel 2 doch gezogen: Template + bf-golden-config + Nautobot-Sync, `wifi-security` gruen).
|
||||||
|
⚠️ **Rueckgenommen am 2026-09-14 (Terrasse-In-situ-Session): `ft=yes ft-over-ds=yes` zurueck** — mit
|
||||||
|
`ft=no` war jeder Roam eine Voll-Reassoziation, darin starben 2 Anrufe; Rueckkehrstand = Fund-E-Known-Good
|
||||||
|
von 09-02 (ruhig auf 7.24.1). Weg: Template + `push-gc-template.py` + `gc-configplan.py
|
||||||
|
--refresh-compliance` + `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed) → **140/140, 0 Drifts**;
|
||||||
|
CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands:
|
||||||
|
`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`, s. Handoff 2026-09-14.
|
||||||
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf
|
🔴 **Nachtrag 09-04: `ft=no` war NICHT uniform.** ap3 trug einen Werks-Inline `security.ft=yes` auf
|
||||||
`wifi1/wifi2` (Bring-up unset `!ft-over-ds`, aber nicht `!ft`; Abnahme zählt nur benannte
|
`wifi1/wifi2` (Bring-up unset `!ft-over-ds`, aber nicht `!ft`; Abnahme zählt nur benannte
|
||||||
security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm)
|
security-Objekte → Inline-Wert unsichtbar). Gemischtes FT-ESS → Strong-Signal-Abbrüche (−43/−45 dBm)
|
||||||
@@ -53,11 +59,11 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
|
|
||||||
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
|
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
|
||||||
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
|
- **`admin/bf-network-r6`** (Gitea) ist Archiv, letzte aktive Commits 2026-08-25 → read-only-Header.
|
||||||
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master (Nautobot 2×/Tag), nicht manuell.
|
- **`admin/bf-golden-config`** (Gitea) ist Bot-Master, nicht manuell. 🔴 **Korrigiert 2026-09-12:** die frühere Angabe „2×/Tag" war nie real — es gab **keinen** Scheduler, Backup/Intended/Compliance liefen nur manuell aus Sessions (zuletzt 09-04, 8 Tage Stillstand). Jetzt **täglich 03:30 UTC** per ScheduledJob **„GC Backup+Compliance – taeglich"** (Nautobot-Job *Execute All Golden Configuration Jobs - Multiple Device*, Scope `platform=RouterOS`, Reihenfolge Backup→Intended→Compliance). Stand prüfen: `backup_last_success_date` am Gerät bzw. `scheduled-jobs` in Nautobot.
|
||||||
- **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo.
|
- **`admin/blaueblueten-n8n`** (Gitea) ist Master für terpene-Repo.
|
||||||
- **`inventory/` ist tot** (Pipeline seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer.
|
- **`inventory/` ist tot** (Pipeline seit 2025-06 stale). Live-Daten aus MikroMCP/CF/Portainer.
|
||||||
- **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen.
|
- **`mcp/INVENTORY.md`** ist BF-Netz-SSoT; `~/.claude.json`/`opencode.jsonc` sind globalere Werkzeugkästen.
|
||||||
- **terpene.teilzeitladen.xyz**: Worker läuft auf Mac mini (`10.40.20.167:8890`), **kein Caddy-Block, kein CF-Tunnel** — Backfill via DB-Migration blockiert (Handoff 2026-08-27).
|
- **Terpenfinder = `420.teilzeitladen.xyz`** (CF-Worker, Repo `admin/blaueblueten-n8n`, hinter CF Access), **kein Caddy, kein Mac mini im Pfad**. `terpene.*` **existiert nicht und soll es nicht** (live geprüft 2026-09-10). Einziger terpene-benannter CF-Gegenstand: der **interne** Tunnel `terpene-db` (`f2d2fc8a`, ohne öffentlichen Hostname, Hyperdrive→VPC→Postgres, s. `docs/cf-tunnel-mapping.md` §17). ⚠️ **Grenze:** blaueblüten ist ein eigenes Projekt — nicht anfassen. *(Die frühere „terpene.teilzeitladen.xyz:8890 via Tunnel"-Zeile war veraltet — bf-mm ist gelöscht.)*
|
||||||
- **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. Inventar-Eintrag spiegelt das. `mcp-host-policy.md` ist SSoT für die Host-/IP-Konvention.
|
- **shelly-mcp / portainer-mcp bewusst NICHT als MCP-Client** (User-Direktive 2026-08-28): shelly-mcp-Container auf DS923 wird via **Portainer-API** (Skript-Aufrufe, `mcp/portainer/.env` als Token-Quelle) deployt, nicht via MCP-Protokoll. Inventar-Eintrag spiegelt das. `mcp-host-policy.md` ist SSoT für die Host-/IP-Konvention.
|
||||||
- **Mac mini `10.40.20.167`** ist der **n8n-Host** (n8n `:5678`, n8n-redis `:6379`, n8n-mcp `:3001`) — **NICHT** der zentrale MCP-Host. Performance-Überlegung: MCP-Clients würden auf dem macmini (User-Workstation) laufen und die Box zusätzlich belasten; deshalb liegt der MCP-Host auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft aktuell noch auf dem macmini (historisch, eigene Container-Definition dort) — gehört **bei Gelegenheit** auf die DS923 umgezogen (separater Punkt, **nicht** Teil dieser Session). Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`.
|
- **Mac mini `10.40.20.167`** ist der **n8n-Host** (n8n `:5678`, n8n-redis `:6379`, n8n-mcp `:3001`) — **NICHT** der zentrale MCP-Host. Performance-Überlegung: MCP-Clients würden auf dem macmini (User-Workstation) laufen und die Box zusätzlich belasten; deshalb liegt der MCP-Host auf der **DS923** (`nas-bf`, Portainer Endpoint 14). gitea-mcp läuft aktuell noch auf dem macmini (historisch, eigene Container-Definition dort) — gehört **bei Gelegenheit** auf die DS923 umgezogen (separater Punkt, **nicht** Teil dieser Session). Container-Definitionen für n8n leben auf dem macmini (Docker Desktop); FQDN→Backend-Mapping im `caddy/Caddyfile`.
|
||||||
|
|
||||||
@@ -98,11 +104,11 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
|
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
|
||||||
| ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt |
|
| ~~Omada Access-Switch (sw2)~~ | — | ❌ **abgebaut 2026-07-23** (TP15 Branch-Tausch) — durch `crs326-branch` in Technik OG ersetzt, physisch entfernt |
|
||||||
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap2) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
|
| **crs326-branch** (CRS326, Technik OG) | **10.40.99.2** (VLAN99) · Bootstrap Rev4 2026-08-08 | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_BRANCH_*`) · REST `http://10.40.99.2/rest` · MikroMCP `crs326-branch` ✅ **live** (2026-08-16 verifiziert) · **TP15 ✅: ersetzt sw2** (L2-Switch), volle Portbelegung (ether1 Uplink-Trunk, 2/8 NAS, 3 Drucker, 4 Dock, 5/6 Sonos, 7 macmini, 24 ap3) · sw-id RGZJ-UK0T, MAC 04:F4:1C:B4:15:58 |
|
||||||
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** (2026-08-16 verifiziert) · **TP15/TP21.1 ✅ 2026-08-08: PRODUKTIVER L3-Router** (ersetzt ap1) — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 Stufe 1 ✅ 2026-08-18; `servers=1.1.1.1` nur noch Bootstrap) · **NTP-Server für den Standort**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
|
| **crs326-core** (CRS326, Technik UG) | **10.40.99.1** (VLAN99-SVI = L3-GW; same-subnet-Fallback `10.40.10.1`) · Identity `crs326-core` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`CRS326_CORE_*`) · MikroMCP routerId `crs326-core` ✅ **live** (2026-08-16 verifiziert) · **TP15/TP21.1 ✅ 2026-08-08: PRODUKTIVER L3-Router** (ersetzt ap1) — Routing/DHCP (7 Server + 54 Leases)/DNS (**DoH zum CF-Gateway mit `verify-doh-cert=yes`**, TP23 Stufe 1 ✅ 2026-08-18; `servers=1.1.1.1` nur noch Bootstrap) · **NTP-Server für den Standort**/NAT/WG-Hub (fm+dw live); WAN `vlan100`=Liwest `90.146.33.177` · sw-id 4W04-URHT, MAC 04:F4:1C:B4:17:76 |
|
||||||
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · 🔴 **FUNKSTILL seit 2026-08-30**: keine Provisioning-Regel am Manager, weil ap3 daneben steht und denselben Kanal 5180 fährt. Gerät bleibt erreichbar. Beim Umzug ans Sideboard → `5260/Ce` (Band B, DFS) und Uplink auf `core ether3` — Uplink `ether1` (PoE-Port, `poe-out=off`) an crs326-core `ether15`; SSIDs/Kanäle kommen vom Manager, ch36/ch1 · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` |
|
| hAP ax² #1 (ap1) | **10.40.99.11** (VLAN99) · Identity `ap1` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP1_*`) · REST `http://10.40.99.11/rest` · MikroMCP `ap1` ✅ live · **TP28 ✅ 2026-08-17: reiner CAP** (Manager = `crs326-core`) · ✅ **aktiv am Sideboard** (Wohnzimmer; Uplink `ether1` → core `ether3`, PoE-Port `poe-out=off`) — Kanäle `5260/Ce` (Band B, DFS, Fallback 5280) + `2412`/ch1, vom Manager · MAC D0:EA:11:4D:40:E0 · Config-Referenz `routeros/.secrets/ap1-prov-base.rsc` |
|
||||||
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — Uplink `ether1` (PoE) an crs326-branch `ether24`; ch44/ch11 (TP25: ch149 war unter ETSI leistungsbegrenzt) · MAC D0:EA:11:4D:40:3A · Config `routeros/.secrets/ap2-prov-base.rsc` |
|
| hAP ax² #2 (ap2) | **10.40.99.12** (VLAN99) · Identity `ap2` | RouterOS **7.24.1** · `admin`/`~/.secrets` (`ROUTER_AP2_*`) · REST `http://10.40.99.12/rest` · MikroMCP `ap2` · **TP28 ✅ 2026-08-17: reiner CAP** — **Technik UG**, Uplink `ether1` (PoE) an core `ether21`; Kanäle `5300/Ce` (Band B, DFS, Fallback 5320) + `2462`/ch11 · MAC D0:EA:11:4D:40:39 (ether1; Bridge/Mgmt `:3A`) · Config `routeros/.secrets/ap2-prov-base.rsc` |
|
||||||
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · Uplink `ether1` → **core `ether21`** (Trunk) · Kanäle **`5180/ax/Ce/I`** (5 GHz) + **`2437/ax`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
|
| **cAP ax (ap3)** | **10.40.99.13** (VLAN99) · Identity `ap3` | ✅ **LIVE seit 2026-08-30** (TP28-Nachtrag) · `cAPGi-5HaxD2HaxD`, RouterOS **7.24.1** + RouterBOOT 7.24.1 (**seit 2026-08-31 ist die ganze Flotte auf 7.24.1**, TP27-Angleichung erledigt) · reiner CAP, Manager `crs326-core` · **Technik OG**, Uplink `ether1` → **branch `ether24`** (Trunk) · Kanäle **`5180/ax`** (Band A, non-DFS, 5 GHz) + **`2437`** (ch6) nach TP25-Plan · 🔴 **nur 2 Ethernet-Ports** (`ether1` PoE-in, `ether2` PoE-out, **kein** Bridge-Port) → `ap.extra_ports=[]` · `device-mode=basic` (scheduler/fetch/romon **erlaubt**, anders als ap1/ap2) · Dateien `routeros/tp28-ap3-phase{1,2}-*.rsc` + `-service-accounts.rsc` + `-werksreste.rsc` |
|
||||||
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
|
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
|
||||||
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) |
|
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) |
|
||||||
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
|
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
|
||||||
@@ -276,7 +282,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
|||||||
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
|
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
|
||||||
- **VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a):** NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert (`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle: DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der *Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber `TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
|
- **VLAN20-Docker-Hosts: Host-DNS nach dem Umzug prüfen (2026-07-10, TP14.2a):** NAS + pve-LXC zeigten `/etc/resolv.conf` noch auf totes `.41.254` → `docker pull` ghcr scheitert (`EAI_AGAIN`, aber Egress ok!). Fix = `.20.1`: LXC `pct set 400 --nameserver 10.40.20.1`; **Synology-Falle: DSM-„Allgemein"-DNS ≠ was `dockerd` liest** — Interface zieht DNS per DHCP → Fix an der *Netzwerkschnittstelle* „DNS manuell". Laufende Container frieren den `127.0.0.11`-Upstream zum Erstellzeitpunkt ein (Recreate nötig). Merkregel: `docker-pull-hängt` + `dns.lookup EAI_AGAIN` aber `TCP zu ghcr-IP:443 ok` = toter Resolver, nicht Egress.
|
||||||
- 📜 **~~ALLNET VLAN-DB (TP1)~~ — historisch.** Die VLAN-Semantik gilt weiter (**40=MEDIA, 50=UNTRUSTED**), die Portangaben (GE1/GE2/GE8) beziehen sich auf ein ausgebautes Gerät.
|
- 📜 **~~ALLNET VLAN-DB (TP1)~~ — historisch.** Die VLAN-Semantik gilt weiter (**40=MEDIA, 50=UNTRUSTED**), die Portangaben (GE1/GE2/GE8) beziehen sich auf ein ausgebautes Gerät.
|
||||||
- **ap2 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5).
|
- **ap3 hängt an `crs326-branch ether24`** (Trunk, native VLAN1 + tagged 10-99); **branch-Uplink `ether1` → `crs326-core ether8`** (korrigiert 2026-08-16 — die frühere Angabe „→ sw1 GE8" stammt aus der Zeit vor dem Core-Tausch; MAC-hart belegt in TP22 Schritt 5). ap2 hängt seit dem TP25-Tausch (2026-09-15) an `core ether21` (Technik UG), ap1 an `core ether3` (Sideboard).
|
||||||
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko).
|
- **Mesh = NEIN** (beide APs haben wired Trunk-Uplink → Loop-Risiko).
|
||||||
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅ 2026-08-17).** Der Entscheid „kein CAPsMAN" von 2026-07-22 ist **revidiert**: er galt der Konstellation ap1=Manager+AP, in der ein Manager seine eigenen Radios nicht provisionieren kann — mit dem Core als Manager sind beide APs symmetrisch. Der Core braucht dafür **kein** `wifi-qcom`, `capsman` steckt im Basispaket.
|
- **Beide APs = CAPs, Manager ist `crs326-core` (TP28 ✅ 2026-08-17).** Der Entscheid „kein CAPsMAN" von 2026-07-22 ist **revidiert**: er galt der Konstellation ap1=Manager+AP, in der ein Manager seine eigenen Radios nicht provisionieren kann — mit dem Core als Manager sind beide APs symmetrisch. Der Core braucht dafür **kein** `wifi-qcom`, `capsman` steckt im Basispaket.
|
||||||
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät
|
- 🔴 **`/interface wifi scan` geht am CAP NICHT — nur am Manager** (Fund 2026-08-23). Das Gerät
|
||||||
|
|||||||
18
__init__.py
Normal file
18
__init__.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Macht die bf-infra-Repo-Wurzel zu einem regulären Python-Paket.
|
||||||
|
|
||||||
|
🔴 LOAD-BEARING — NICHT löschen (sonst laden die Nautobot-Jobs nicht mehr).
|
||||||
|
|
||||||
|
Nautobot klont dieses Repo nach GIT_ROOT/bf_infra/ und lädt Jobs über
|
||||||
|
`import_modules_privately()` → `pkgutil.walk_packages([GIT_ROOT])`. walk_packages
|
||||||
|
entdeckt ausschließlich *reguläre* Pakete (mit __init__.py), niemals
|
||||||
|
Namespace-Packages (Verzeichnisse ohne __init__.py). Ohne diese Datei ist
|
||||||
|
`bf_infra` für den Loader unsichtbar und `jobs/` wird nie importiert — Symptom:
|
||||||
|
Git-Sync SUCCESS, aber Log "No jobs were registered on loading the
|
||||||
|
`bf_infra.jobs` submodule" und kein Job-DB-Eintrag.
|
||||||
|
|
||||||
|
Der module_path-Filter des Loaders (["bf_infra","jobs"]) importiert nur
|
||||||
|
`bf_infra` + `bf_infra.jobs.*`; die übrigen Verzeichnisse (nautobot/, routeros/,
|
||||||
|
bin/, …) bleiben unangetastet und laufen weiter als Standalone-Skripte.
|
||||||
|
|
||||||
|
Verifiziert 2026-09-10 (Nautobot 3.1.7, Python 3.14) — TP44 Teil B.
|
||||||
|
"""
|
||||||
@@ -71,7 +71,7 @@ dns:
|
|||||||
ipset_file: ""
|
ipset_file: ""
|
||||||
bootstrap_prefer_ipv6: false
|
bootstrap_prefer_ipv6: false
|
||||||
upstream_timeout: 10s
|
upstream_timeout: 10s
|
||||||
private_networks: []
|
private_<redacted>: []
|
||||||
use_private_ptr_resolvers: true
|
use_private_ptr_resolvers: true
|
||||||
local_ptr_upstreams: []
|
local_ptr_upstreams: []
|
||||||
use_dns64: false
|
use_dns64: false
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ Ein VLAN = eine CIDR-Gruppe. Alle nutzen denselben CF-Upstream-Filter; Divergenz
|
|||||||
|
|
||||||
`filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar.
|
`filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar.
|
||||||
|
|
||||||
|
## Personen-Clients (Stufe 5 Redesign 2026-09-12) — SSoT für Person→Gerät
|
||||||
|
Zusätzlich zu den VLAN-CIDR-Gruppen bekommt **jede Person einen eigenen AGH-Client**
|
||||||
|
(`/control/clients`): `name` = Personenname, `ids` = **alle** Geräte-IPs der Person. Das ist der
|
||||||
|
**einzige** Ort, an dem Person→Gerät gepflegt wird (kein zweites Mapping-File). Damit:
|
||||||
|
- wirken Self-Service-Ausnahmen **pro Person**: `@@||domain^$client='Personenname'` statt global;
|
||||||
|
- filtert `recent-blocked --client <person>` das Query-Log auf **ihre** Geräte;
|
||||||
|
- bleiben Ausnahmen stabil bei Gerätewechsel (Name statt IP).
|
||||||
|
|
||||||
|
🔴 **On-network verifizieren, bevor Stufe 6 scharf geht:** dass AGH `$client=` auf **Allow**-Regeln
|
||||||
|
(`@@`) in der laufenden Version greift. Fällt der Test negativ aus → Fallback `$client=<ip>|<ip>`
|
||||||
|
(die Geräte-IPs); `agh-exception.py scoped_rule()` kann beides. Die CIDR-Gruppen (Policy-Grenze)
|
||||||
|
bleiben unberührt — Personen-Clients tragen **nur** die Ausnahmen.
|
||||||
|
|
||||||
## Sync-Invariante
|
## Sync-Invariante
|
||||||
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen,
|
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen,
|
||||||
sondern **`.36` == `.37`**. Genau das prüft `agh-sync.py check`.
|
sondern **`.36` == `.37`**. Genau das prüft `agh-sync.py check`.
|
||||||
@@ -42,11 +55,16 @@ Referenz-RMW-Implementierung, die D aufruft (Deploy-Weg auf HAOS in Etappe D zu
|
|||||||
|
|
||||||
| Aktion | CLI | AGH-API (RMW) |
|
| Aktion | CLI | AGH-API (RMW) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Domain global entsperren | `allow-domain <d>` | `GET /control/filtering/status` → +`@@\|\|<d>^` → `POST /control/filtering/set_rules` |
|
| Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
|
||||||
| Entsperrung zurück | `unallow-domain <d>` | dito, Regel entfernen |
|
| Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
|
||||||
| Client entfiltern | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
| Entsperrung zurück | `unallow-domain <d> [--client <person>]` | dito, exakt dieselbe Regel entfernen |
|
||||||
|
| „meine Ausnahmen" | `list-exceptions --client <person> --json` | `GET /control/filtering/status`, `@@`-Regeln parsen/filtern |
|
||||||
|
| „zuletzt geblockt" | `recent-blocked --client <person> --json` | `GET /control/querylog?response_status=blocked`, client-seitig auf Geräte-IPs gefiltert |
|
||||||
|
| Client entfiltern (grob) | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
||||||
| Client wieder filtern | `refilter <ip>` | dito, zurück auf global |
|
| Client wieder filtern | `refilter <ip>` | dito, zurück auf global |
|
||||||
|
|
||||||
|
Reine Regel-/Filter-Logik ist netzunabhängig getestet: `python3 agh-exception.py selftest`.
|
||||||
|
|
||||||
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
|
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
|
||||||
RouterOS-Backstop.
|
RouterOS-Backstop.
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,37 @@
|
|||||||
"""
|
"""
|
||||||
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .37).
|
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .37).
|
||||||
|
|
||||||
Von der HA-Self-Service-Schicht (rest_command) ODER von Hand aufrufbar. Wirkt IMMER auf
|
Von der HA-Self-Service-Schicht (shell_command) ODER von Hand aufrufbar. Wirkt IMMER auf
|
||||||
BEIDE Instanzen (kein Sync im Datenpfad). KEIN eigenes Timing — Ablauf/Revert macht der
|
BEIDE Instanzen (kein Sync im Datenpfad). KEIN eigenes Timing — Ablauf/Revert macht der
|
||||||
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove.
|
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove/read.
|
||||||
|
|
||||||
allow-domain <domain> → user_rules += @@||domain^ (Domain für ALLE entsperren)
|
allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
|
||||||
unallow-domain <domain> → user_rules -= @@||domain^
|
unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
|
||||||
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false
|
list-exceptions [--client <name>] [--json] → alle @@-Regeln, nach Client gruppiert
|
||||||
|
recent-blocked --client <name|ip[,ip]> [--limit N] [--json]
|
||||||
|
→ zuletzt geblockte Domains DIESER Person
|
||||||
|
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false (grob!)
|
||||||
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
|
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
|
||||||
status → user_rules + Client-Policy beider Instanzen
|
status → user_rules + Client-Policy beider Instanzen
|
||||||
|
|
||||||
|
--client:
|
||||||
|
* ein AGH-Client-NAME (empfohlen, stabil bei Gerätewechsel) → Regel bekommt $client='Name',
|
||||||
|
IPs werden für recent-blocked aus /control/clients (ids) aufgelöst.
|
||||||
|
* ODER eine/mehrere IP/CIDR, komma-getrennt → $client=<ip>|<ip>, recent-blocked filtert darauf.
|
||||||
|
ohne --client bei allow/unallow = GLOBAL (für alle) — nur für Admin-Zwecke.
|
||||||
|
|
||||||
|
🔴 ANNAHME, on-network zu verifizieren, BEVOR Stufe 6 scharf geht: dass AGH $client= auf
|
||||||
|
ALLOW-Regeln (@@) in der laufenden Version wirkt. Die Regelkonstruktion steht isoliert in
|
||||||
|
scoped_rule()/parse_allow_rule() — falls die Syntax abweicht, nur dort anpassen.
|
||||||
|
|
||||||
Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv).
|
Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv).
|
||||||
"""
|
"""
|
||||||
import sys, os, json, base64, urllib.request, urllib.error, pathlib, re
|
import sys, os, json, base64, urllib.request, urllib.error, urllib.parse, pathlib, re, ipaddress, argparse
|
||||||
|
|
||||||
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9]+)*\.)+[a-z]{2,}$", re.I)
|
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9]+)*\.)+[a-z]{2,}$", re.I)
|
||||||
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
||||||
|
CIDR_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$")
|
||||||
|
# Ein Client-„spec" ist IP-artig, wenn JEDES komma-Glied IP oder CIDR ist; sonst = Name.
|
||||||
|
|
||||||
INST = ["10.40.20.36", "10.40.20.37"]
|
INST = ["10.40.20.36", "10.40.20.37"]
|
||||||
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt.
|
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt.
|
||||||
@@ -50,14 +65,20 @@ def creds():
|
|||||||
sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden")
|
sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden")
|
||||||
|
|
||||||
|
|
||||||
AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
_AUTH = None
|
||||||
|
def auth_header():
|
||||||
|
# lazy: erst beim ersten echten API-Call — so bleibt das Modul ohne Creds importier-/testbar.
|
||||||
|
global _AUTH
|
||||||
|
if _AUTH is None:
|
||||||
|
_AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
||||||
|
return _AUTH
|
||||||
|
|
||||||
|
|
||||||
def api(ip, path, method="GET", body=None):
|
def api(ip, path, method="GET", body=None):
|
||||||
url = f"http://{ip}{path}"
|
url = f"http://{ip}{path}"
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
req = urllib.request.Request(url, data=data, method=method,
|
req = urllib.request.Request(url, data=data, method=method,
|
||||||
headers={"Authorization": AUTH,
|
headers={"Authorization": auth_header(),
|
||||||
"Content-Type": "application/json"})
|
"Content-Type": "application/json"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=8) as r:
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
@@ -69,26 +90,129 @@ def api(ip, path, method="GET", body=None):
|
|||||||
sys.exit(f"{ip} {path} → {e}")
|
sys.exit(f"{ip} {path} → {e}")
|
||||||
|
|
||||||
|
|
||||||
def rule(domain):
|
# ---------------------------------------------------------------------------
|
||||||
return f"@@||{domain}^"
|
# Reine Regel-Logik (netzunabhängig, self-testbar). Bei AGH-Syntax-Drift NUR hier anfassen.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def is_ip_spec(spec):
|
||||||
|
"""True, wenn spec eine (Komma-)Liste aus lauter IPs/CIDRs ist (→ IP-spec), sonst Name."""
|
||||||
|
parts = [s.strip() for s in spec.split(",") if s.strip()]
|
||||||
|
return bool(parts) and all(IP_RE.match(p) or CIDR_RE.match(p) for p in parts)
|
||||||
|
|
||||||
|
|
||||||
def allow(domain, remove=False):
|
def client_modifier(spec):
|
||||||
|
"""$client-Modifier-Wert für eine Regel. IP-spec → ip|ip ; Name → 'Name' (quoted)."""
|
||||||
|
if is_ip_spec(spec):
|
||||||
|
return "|".join(s.strip() for s in spec.split(",") if s.strip())
|
||||||
|
return "'" + spec.replace("'", "") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def scoped_rule(domain, client=None):
|
||||||
|
"""@@||domain^ bzw. @@||domain^$client=… (client = Name oder ip[,ip])."""
|
||||||
|
base = f"@@||{domain}^"
|
||||||
|
return base if not client else f"{base}$client={client_modifier(client)}"
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOW_RE = re.compile(r"^@@\|\|(?P<domain>[^\^]+)\^(?:\$client=(?P<client>.+))?$")
|
||||||
|
def parse_allow_rule(rule):
|
||||||
|
"""@@-Regel → {'domain':…, 'client': <str oder None>, 'raw': rule} oder None."""
|
||||||
|
m = _ALLOW_RE.match(rule.strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return {"domain": m.group("domain"), "client": m.group("client"), "raw": rule.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def ip_in_specs(ip, specs):
|
||||||
|
"""Ist ip (aus dem Query-Log) von einer der specs (IP oder CIDR) erfasst?"""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for s in specs:
|
||||||
|
s = s.strip()
|
||||||
|
try:
|
||||||
|
if "/" in s:
|
||||||
|
if addr in ipaddress.ip_network(s, strict=False):
|
||||||
|
return True
|
||||||
|
elif ip == s:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def resolve_client_ips(spec):
|
||||||
|
"""spec → Liste von IP/CIDR-strings. IP-spec direkt; Name → ids des AGH-Clients (von .36)."""
|
||||||
|
if is_ip_spec(spec):
|
||||||
|
return [s.strip() for s in spec.split(",") if s.strip()]
|
||||||
|
data = api(INST[0], "/control/clients")
|
||||||
|
for c in data.get("clients", []):
|
||||||
|
if c.get("name") == spec:
|
||||||
|
return list(c.get("ids", []))
|
||||||
|
sys.exit(f"kein AGH-Client mit name={spec!r} (für IP-spec Komma-Liste aus IP/CIDR angeben)")
|
||||||
|
|
||||||
|
|
||||||
|
def allow(domain, client=None, remove=False):
|
||||||
domain = domain.strip().lower()
|
domain = domain.strip().lower()
|
||||||
if not DOMAIN_RE.match(domain):
|
if not DOMAIN_RE.match(domain):
|
||||||
sys.exit(f"ungültige Domain: {domain!r}")
|
sys.exit(f"ungültige Domain: {domain!r}")
|
||||||
r = rule(domain)
|
r = scoped_rule(domain, client)
|
||||||
for ip in INST:
|
for ip in INST:
|
||||||
st = api(ip, "/control/filtering/status")
|
st = api(ip, "/control/filtering/status")
|
||||||
rules = st.get("user_rules", [])
|
rules = st.get("user_rules", [])
|
||||||
if remove:
|
if remove:
|
||||||
rules = [x for x in rules if x != r]
|
rules = [x for x in rules if x.strip() != r]
|
||||||
elif r not in rules:
|
elif r not in rules:
|
||||||
rules = rules + [r]
|
rules = rules + [r]
|
||||||
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
|
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
|
||||||
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(rules)})")
|
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(rules)})")
|
||||||
|
|
||||||
|
|
||||||
|
def list_exceptions(client=None, as_json=False):
|
||||||
|
st = api(INST[0], "/control/filtering/status") # .36 == .37 (Invariante, via agh-sync geprüft)
|
||||||
|
items = []
|
||||||
|
for raw in st.get("user_rules", []):
|
||||||
|
p = parse_allow_rule(raw)
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
if client is not None and (p["client"] or "") != client_modifier(client):
|
||||||
|
continue
|
||||||
|
items.append(p)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(items, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
if not items:
|
||||||
|
print("— keine Ausnahmen —"); return
|
||||||
|
for p in items:
|
||||||
|
who = p["client"] or "GLOBAL"
|
||||||
|
print(f" {p['domain']:40} client={who}")
|
||||||
|
|
||||||
|
|
||||||
|
def recent_blocked(client, limit=20, as_json=False):
|
||||||
|
specs = resolve_client_ips(client)
|
||||||
|
q = urllib.parse.urlencode({"response_status": "blocked", "limit": max(limit * 8, 100)})
|
||||||
|
data = api(INST[0], f"/control/querylog?{q}")
|
||||||
|
seen = {} # domain -> {count, last}
|
||||||
|
for e in data.get("data", []):
|
||||||
|
c = e.get("client", "")
|
||||||
|
if not ip_in_specs(c, specs):
|
||||||
|
continue
|
||||||
|
dom = (e.get("question", {}) or {}).get("name", "").rstrip(".").lower()
|
||||||
|
if not dom:
|
||||||
|
continue
|
||||||
|
rec = seen.setdefault(dom, {"domain": dom, "count": 0, "last": e.get("time", "")})
|
||||||
|
rec["count"] += 1
|
||||||
|
if e.get("time", "") > rec["last"]:
|
||||||
|
rec["last"] = e.get("time", "")
|
||||||
|
out = sorted(seen.values(), key=lambda r: r["last"], reverse=True)[:limit]
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(out, ensure_ascii=False)); return
|
||||||
|
if not out:
|
||||||
|
print("— nichts geblockt —"); return
|
||||||
|
for r in out:
|
||||||
|
print(f" {r['domain']:40} x{r['count']:<4} {r['last']}")
|
||||||
|
|
||||||
|
|
||||||
def set_filter(ip_addr, enabled):
|
def set_filter(ip_addr, enabled):
|
||||||
if not IP_RE.match(ip_addr):
|
if not IP_RE.match(ip_addr):
|
||||||
sys.exit(f"ungültige IP: {ip_addr!r}")
|
sys.exit(f"ungültige IP: {ip_addr!r}")
|
||||||
@@ -117,13 +241,47 @@ def status():
|
|||||||
print(f" {c['name']:16} ids={c['ids']} global={c['use_global_settings']} filt={c['filtering_enabled']}")
|
print(f" {c['name']:16} ids={c['ids']} global={c['use_global_settings']} filt={c['filtering_enabled']}")
|
||||||
|
|
||||||
|
|
||||||
|
def _selftest():
|
||||||
|
"""Netzunabhängiger Test der reinen Regel-Logik."""
|
||||||
|
assert is_ip_spec("10.40.10.82") and is_ip_spec("10.40.10.82,10.40.10.183")
|
||||||
|
assert is_ip_spec("10.40.10.0/24") and not is_ip_spec("christoph")
|
||||||
|
assert scoped_rule("x.com") == "@@||x.com^"
|
||||||
|
assert scoped_rule("x.com", "10.40.10.82,10.40.10.183") == "@@||x.com^$client=10.40.10.82|10.40.10.183"
|
||||||
|
assert scoped_rule("x.com", "christoph") == "@@||x.com^$client='christoph'"
|
||||||
|
p = parse_allow_rule("@@||x.com^$client='christoph'")
|
||||||
|
assert p and p["domain"] == "x.com" and p["client"] == "'christoph'"
|
||||||
|
p2 = parse_allow_rule("@@||y.com^")
|
||||||
|
assert p2 and p2["client"] is None
|
||||||
|
assert parse_allow_rule("||block.com^") is None
|
||||||
|
assert ip_in_specs("10.40.10.82", ["10.40.10.82"]) is True
|
||||||
|
assert ip_in_specs("10.40.10.5", ["10.40.10.0/24"]) is True
|
||||||
|
assert ip_in_specs("10.40.20.5", ["10.40.10.0/24"]) is False
|
||||||
|
# round-trip: eine scoped Regel wird von list-exceptions --client wieder getroffen
|
||||||
|
assert (parse_allow_rule(scoped_rule("z.com", "christoph"))["client"]
|
||||||
|
== client_modifier("christoph"))
|
||||||
|
print("selftest OK")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
a = sys.argv[1:]
|
ap = argparse.ArgumentParser(add_help=False)
|
||||||
if not a: sys.exit(__doc__)
|
ap.add_argument("cmd", nargs="?")
|
||||||
cmd = a[0]
|
ap.add_argument("arg", nargs="?")
|
||||||
if cmd == "allow-domain": allow(a[1])
|
ap.add_argument("--client")
|
||||||
elif cmd == "unallow-domain": allow(a[1], remove=True)
|
ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier
|
||||||
elif cmd == "unfilter": set_filter(a[1], False)
|
ap.add_argument("--limit", type=int, default=20)
|
||||||
elif cmd == "refilter": set_filter(a[1], True)
|
ap.add_argument("--json", action="store_true")
|
||||||
elif cmd == "status": status()
|
a = ap.parse_args()
|
||||||
else: sys.exit(f"unbekannt: {cmd}\n{__doc__}")
|
|
||||||
|
if not a.cmd:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
if a.cmd == "allow-domain": allow(a.arg, a.client)
|
||||||
|
elif a.cmd == "unallow-domain": allow(a.arg, a.client, remove=True)
|
||||||
|
elif a.cmd == "list-exceptions": list_exceptions(a.client, a.json)
|
||||||
|
elif a.cmd == "recent-blocked":
|
||||||
|
if not a.client: sys.exit("recent-blocked braucht --client <name|ip[,ip]>")
|
||||||
|
recent_blocked(a.client, a.limit, a.json)
|
||||||
|
elif a.cmd == "unfilter": set_filter(a.arg, False)
|
||||||
|
elif a.cmd == "refilter": set_filter(a.arg, True)
|
||||||
|
elif a.cmd == "status": status()
|
||||||
|
elif a.cmd == "selftest": _selftest()
|
||||||
|
else: sys.exit(f"unbekannt: {a.cmd}\n{__doc__}")
|
||||||
|
|||||||
@@ -1,63 +1,98 @@
|
|||||||
# TP23 Stufe 5 — HA-Self-Service für DNS-Ausnahmen
|
# TP23 Stufe 5 (Redesign 2026-09-12) — DAU-tauglicher, personenbezogener DNS-Self-Service
|
||||||
|
|
||||||
Home Assistant (HAOS-VM 100, `10.40.20.105`) gibt eine Ein-Klick-Oberfläche, um eine Domain
|
> 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only,
|
||||||
temporär (JIT) über das AGH-Paar `.36`/`.37` zu entsperren. Ablauf/Revert nativ per `timer`.
|
> HA-Neustart bei neuer Domain) war „funktional ein kastriertes Pi-hole" (User-Befund 2026-09-12)
|
||||||
|
> und ist ersetzt. Bau + Abnahme laufen **on-network** (HAOS `10.40.20.105`, AGH `.36/.37`) — beim
|
||||||
|
> Anlegen den **home-assistant-best-practices-Skill** konsultieren und via **ha-mcp** in `.storage`
|
||||||
|
> anlegen (native Helper, `entity_id` statt `device_id`). Diese Datei ist die Repro-/SSoT-Quelle.
|
||||||
|
|
||||||
**Warum kein `rest_command`:** die AGH-Endpunkte sind read-modify-write (`set_rules` ersetzt die
|
## Ziel (User-Requirements 2026-09-12)
|
||||||
ganze Liste). HA ruft deshalb die deployte RMW-CLI `agh-exception.py` per `shell_command`.
|
Ein **einfaches, klar verständliches** End-User-UI zum Entsperren:
|
||||||
|
- **alles was den Nutzer betrifft, nichts** von anderen / Admin / Power-User;
|
||||||
|
- Policy nur sichtbar, wo sie für den einfachen Nutzer Relevanz hat.
|
||||||
|
|
||||||
## Bausteine (alle in HA .storage, via ha-mcp angelegt — keine YAML außer shell_command)
|
| # | Requirement | Umsetzung |
|
||||||
| Typ | entity_id | Zweck |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| input_text | `input_text.dns_ausnahme_domain` | einzugebende Domain |
|
| 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs |
|
||||||
| input_text | `input_text.dns_ausnahme_aktiv` | aktuell aktive Ausnahme (für leak-freien Revert; init `""`) |
|
| 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client <person>` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert |
|
||||||
| input_select | `input_select.dns_ausnahme_dauer` | 15 Minuten / 1 Stunde / 4 Stunden |
|
| 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** |
|
||||||
| timer | `timer.dns_ausnahme` | JIT-Countdown (restore=false) |
|
| 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar |
|
||||||
| input_button | `input_button.dns_ausnahme_starten` | Auslöser |
|
|
||||||
| automation | `automation.dns_ausnahme_starten` | Button → validieren, alte Ausnahme aufräumen, `agh_allow_domain`, Timer starten |
|
|
||||||
| automation | `automation.dns_ausnahme_ablauf` | `timer.finished`/`timer.cancelled` → `agh_unallow_domain`, `_aktiv` leeren |
|
|
||||||
|
|
||||||
## `configuration.yaml` (der einzige YAML-Teil, Backup `.bak-tp23-*`)
|
## Identitätsmodell — AGH-Client je Person ist die SSoT
|
||||||
|
- Pro Person **ein AGH-Client** (`/control/clients`), `name` = Personenname, `ids` = **alle** Geräte-IPs
|
||||||
|
der Person (die per DHCP faktisch statisch sind). Damit ist Person→Gerät **an einem Ort** gepflegt
|
||||||
|
(kein zweites Mapping-File), und Ausnahmen sind stabil bei Gerätewechsel.
|
||||||
|
- Ausnahme-Regel = `@@||domain^$client='Personenname'` (statt globalem `@@||domain^`).
|
||||||
|
🔴 **Vor Scharfschalten on-network verifizieren, dass AGH `$client=` auf `@@`-Regeln wirkt**
|
||||||
|
(s. `agh-exception.py` Kopf). Fällt der Test negativ aus: Fallback = `$client=<ip>|<ip>` mit den
|
||||||
|
Geräte-IPs statt dem Namen (Code kann beides, `scoped_rule()`).
|
||||||
|
- Die bestehenden **VLAN-CIDR-Gruppen** bleiben die Policy-Grenze (Filter an/aus je VLAN);
|
||||||
|
Personen-Clients sind spezifischer und tragen nur die *Ausnahmen*.
|
||||||
|
|
||||||
|
## CLI (deployt nach `/config/agh-exception.py`, wirkt auf `.36`+`.37`)
|
||||||
|
```
|
||||||
|
allow-domain <domain> --client <person> # Ausnahme setzen (temp: HA revertiert; perm: kein Timer)
|
||||||
|
unallow-domain <domain> --client <person> # dieselbe Ausnahme entfernen
|
||||||
|
list-exceptions --client <person> --json # „meine Ausnahmen" (für Übersicht + Entfernen)
|
||||||
|
recent-blocked --client <person> --json # „zuletzt geblockt" (nur Geräte der Person)
|
||||||
|
```
|
||||||
|
`--client` nimmt einen AGH-Client-Namen (→ IPs via `/control/clients`) ODER eine Komma-IP/CIDR-Liste.
|
||||||
|
|
||||||
|
## `configuration.yaml` — `shell_command` (einziger YAML-Teil, Backup `.bak-tp23-*`)
|
||||||
|
Domain **und** Client sind Parameter → **kein HA-Neustart mehr** bei neuer Domain (das war der alte
|
||||||
|
DAU-Killer: pro Domain ein eigener `shell_command`).
|
||||||
```yaml
|
```yaml
|
||||||
shell_command:
|
shell_command:
|
||||||
agh_allow_domain: "python3 /config/agh-exception.py allow-domain '{{ domain }}'"
|
agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'"
|
||||||
agh_unallow_domain: "python3 /config/agh-exception.py unallow-domain '{{ domain }}'"
|
agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'"
|
||||||
agh_unfilter_client: "python3 /config/agh-exception.py unfilter '{{ ip }}'"
|
|
||||||
agh_refilter_client: "python3 /config/agh-exception.py refilter '{{ ip }}'"
|
|
||||||
```
|
```
|
||||||
Neue Domain → **HA-Neustart** nötig (reload_all lädt eine erstmals hinzugefügte Domain nicht).
|
|
||||||
|
|
||||||
## Deploy auf HAOS (Zugang `ssh admin@10.40.20.105`, sudo passwortlos)
|
## Helfer / Entities (via ha-mcp in .storage)
|
||||||
- `/config/agh-exception.py` — Kopie von `adguard/agh-exception.py` (liest Creds via `/config/agh-exception.secrets`).
|
Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug:
|
||||||
- `/config/agh-exception.secrets` (chmod 600, root) — `AGH_AUTOMATION_USER`/`_PASS`.
|
| Typ | entity_id | Zweck |
|
||||||
- Redeploy nach Script-Änderung: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
|
|---|---|---|
|
||||||
|
| command_line sensor | `sensor.dns_recent_blocked_<slug>` | `recent-blocked --client <person> --json` → `json_attributes: items` (Liste {domain,count,last}); Scan-Intervall ~60 s |
|
||||||
|
| command_line sensor | `sensor.dns_exceptions_<slug>` | `list-exceptions --client <person> --json` → aktive Ausnahmen der Person |
|
||||||
|
| input_select | `input_select.dns_dauer_<slug>` | 1 Stunde / 4 Stunden / **dauerhaft** |
|
||||||
|
| timer | `timer.dns_ausnahme_<slug>` | JIT-Countdown je temporärer Freigabe (restore=false) |
|
||||||
|
| input_boolean | `input_boolean.dns_permanent_ohne_anfrage` | **global**: an = „dauerhaft" wirkt sofort (Start); aus = erzeugt Admin-Genehmigung |
|
||||||
|
| script | `script.dns_freigeben_<slug>` | nimmt `domain` als Feld → `agh_allow`; bei „dauerhaft"+Switch-aus → `todo`/Notification statt Freigabe; sonst Timer starten |
|
||||||
|
| automation | `automation.dns_ablauf_<slug>` | `timer.finished`/`.cancelled` → `agh_unallow`, Aufräumen |
|
||||||
|
|
||||||
## Bedienung
|
## Dashboard — ein scoped View je Person
|
||||||
Domain eintragen → Dauer wählen → **DNS-Ausnahme starten**. Bei Ablauf/Abbruch wird die Regel auf
|
- Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit
|
||||||
beiden AGH automatisch zurückgenommen. Ungültige Domains werden HA- **und** CLI-seitig abgelehnt.
|
der user-id; `require_admin:false`). Kleiner Haushalt → ein View je Person ist praktikabel und
|
||||||
|
umgeht das „Template kennt den aktuellen Betrachter nicht"-Problem.
|
||||||
|
- **Karte 1 „Zuletzt geblockt (antippen zum Freigeben)":** rendert `sensor.dns_recent_blocked_<slug>`
|
||||||
|
Attribut `items`; jede Zeile hat eine `tap_action` → `script.dns_freigeben_<slug>` mit `domain`
|
||||||
|
der Zeile + aktueller `input_select`-Dauer. (Dynamische Per-Zeilen-tap_action braucht i.d.R.
|
||||||
|
`auto-entities` + `template`/`button-card` **oder** ein kleines Custom-List-Template — **beim Bau
|
||||||
|
on-network entscheiden**, das ist der Teil, der Live-Verifikation braucht.)
|
||||||
|
- **Karte 2 „Meine Ausnahmen":** `sensor.dns_exceptions_<slug>` als Liste, je Eintrag Entfernen
|
||||||
|
(`agh_unallow`).
|
||||||
|
- **Karte 3 „Dauer":** `input_select.dns_dauer_<slug>` (inkl. „dauerhaft").
|
||||||
|
- **Kein** globaler Status, keine fremden Clients, keine Policy-Interna. Nur wenn relevant ein
|
||||||
|
kurzer Hinweis („durch den Familien-Filter blockiert").
|
||||||
|
|
||||||
## Dashboard (1. Draft, 2026-09-06)
|
## Genehmigungs-Variante (Modus-Switch aus)
|
||||||
Eigenes storage-mode Dashboard `url_path: dns-ausnahmen` (Sidebar, `require_admin: false`),
|
`input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen
|
||||||
angelegt via `ha_config_set_dashboard`. Da es in HA-`.storage` lebt, hier die Repro-Quelle:
|
Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim
|
||||||
```json
|
Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der
|
||||||
{"views":[{"title":"DNS-Ausnahmen","path":"dns","type":"sections","icon":"mdi:dns","max_columns":2,"sections":[
|
Switch entscheidet zur Laufzeit.
|
||||||
{"type":"grid","cards":[
|
|
||||||
{"type":"heading","heading":"Domain temporaer freigeben","icon":"mdi:lock-open-variant"},
|
|
||||||
{"type":"entities","entities":[{"entity":"input_text.dns_ausnahme_domain","name":"Domain"},{"entity":"input_select.dns_ausnahme_dauer","name":"Dauer"}]},
|
|
||||||
{"type":"button","name":"DNS-Ausnahme starten","icon":"mdi:lock-open-check","tap_action":{"action":"perform-action","perform_action":"input_button.press","target":{"entity_id":"input_button.dns_ausnahme_starten"}},"grid_options":{"columns":"full"}}]},
|
|
||||||
{"type":"grid","cards":[
|
|
||||||
{"type":"heading","heading":"Status","icon":"mdi:information-outline"},
|
|
||||||
{"type":"tile","entity":"timer.dns_ausnahme","name":"Restlaufzeit"},
|
|
||||||
{"type":"tile","entity":"input_text.dns_ausnahme_aktiv","name":"Aktive Ausnahme"},
|
|
||||||
{"type":"markdown","content":"**AGH-Resolver-Paar** — .36: {{ states('binary_sensor.adguard_bf1_status') }} / .37: {{ states('binary_sensor.adguard_bf2_status') }}"}]},
|
|
||||||
{"type":"grid","cards":[
|
|
||||||
{"type":"heading","heading":"VLAN-Filter-Policy (AGH-Gruppen)","heading_style":"subtitle"},
|
|
||||||
{"type":"markdown","content":"MAIN/IOT/UNTRUSTED=an · INFRA/MEDIA/MGMT=aus · GUEST=an+SafeSearch/Parental · dns-bypass-exempt=aus"}]}
|
|
||||||
]}]}
|
|
||||||
```
|
|
||||||
⚠️ Draft-Notiz: `binary_sensor.adguard_bf1_status` ist stale (hing am alten `.16`-Container, TP42) —
|
|
||||||
zeigt evtl. `unavailable`, obwohl `.36` läuft. Sensor-Quelle gehört bei Gelegenheit auf `.36` gefixt.
|
|
||||||
|
|
||||||
## Verifiziert (2026-09-04)
|
## Alt-Reste, die beim Bau wegfallen/fixen
|
||||||
Button → `@@||doubleclick.net^` auf `.36`+`.37`, Timer 15:00, `_aktiv` gesetzt; Timer-Abbruch →
|
- Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`,
|
||||||
Regel auf beiden entfernt, `_aktiv` geleert, `.36==.37`.
|
`automation…_starten/_ablauf`) und der `agh_allow_domain`/`_unallow_domain`-**pro-Domain**-Zwang.
|
||||||
|
- Statuskachel-Sensor `binary_sensor.adguard_bf1_status` hing am toten `.16` → auf `.36`/`.37` neu.
|
||||||
|
|
||||||
|
## Abnahme (on-network, = neue echte Stufe-6-Vorbedingung)
|
||||||
|
Mit einem **echten Nicht-Admin-Login**: geblockte Seite → erscheint in *ihrer* Fläche → 1 Klick
|
||||||
|
temporär (revertiert nach Ablauf) **und** 1 Klick dauerhaft (bleibt) → wirkt **nur** für diese
|
||||||
|
Person (Gegentest: anderer Client sieht die Domain weiter geblockt). `.36 == .37`
|
||||||
|
(`agh-sync.py check`). Modus-Switch beide Wege einmal durchgespielt.
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
- CLI: `adguard/agh-exception.py` (Subcommands + `selftest`). Deploy-Weg unverändert (base64→scp,
|
||||||
|
s. unten). Creds `/config/agh-exception.secrets` (chmod 600).
|
||||||
|
- Redeploy: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
|
||||||
|
- Personen-Client-Modell: `adguard/README.md`. Kontext/Reihenfolge: TP23 in `docs/teilprojekte.md`.
|
||||||
|
|||||||
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.
|
||||||
@@ -8,9 +8,33 @@
|
|||||||
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
||||||
> ist offen.
|
> ist offen.
|
||||||
|
|
||||||
> **Aktueller Stand: → §10 (2026-07-10, TP16 UMGESETZT ✅).** §9 = Analyse/Zielbild, §10 = As-Built.
|
> **Aktueller Stand: → §0 (unten).** §1–16 sind Historie/As-Built-Log früherer Sessions und beschreiben
|
||||||
> Abschnitte 1–8 sind Historie (Track A, 2026-06-30); die „3 Connectors gut/Zielbild"-Aussagen dort sind
|
> Topologien, die **nicht mehr live** sind (v.a. das 3-Tunnel-HA `bf-mm`/`bf-nas`/`bf-pve`). §9 = Analyse,
|
||||||
> durch §9/§10 **überholt** (Round-Robin = macvlan-Host-Isolation → 1/N-Ausfälle). Track A aus `docs/handoff-cf-tunnel-1033.md`.
|
> §10–15 = As-Built des inzwischen abgelösten 3-Tunnel-Designs. Track A aus `docs/handoff-cf-tunnel-1033.md`.
|
||||||
|
|
||||||
|
## §0 — Live-Ist-Stand (2026-09-08, maßgeblich)
|
||||||
|
|
||||||
|
> Der einzige aktuelle Topologie-Stand. **§1–16 nicht als aktuelle Konfiguration lesen** — insbesondere das
|
||||||
|
> 3-Tunnel-HA `bf-mm`/`bf-nas`/`bf-pve` (§10–15) existiert seit dem TP16.3-Cutover (bf-public/Caddy,
|
||||||
|
> 2026-08-27) **nicht mehr**. Strukturierte Estate = Nautobot (TP44, deckt sich mit diesem Stand);
|
||||||
|
> operativer Master = Cloudflare. Read-only gemessen gegen die CF-API (`cfd_tunnel`, `warp_connector`,
|
||||||
|
> `teamnet/routes`) am 2026-09-08.
|
||||||
|
|
||||||
|
**cloudflared-Tunnel (live) — es gibt genau drei:**
|
||||||
|
|
||||||
|
| Tunnel | ID | Conns | Zweck |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **bf-public** | `48d3b0b6-7233-4781-a76a-1de4a5e957ce` | 2, healthy | **einziger öffentlicher BF-Tunnel** (TP31/TP16.3), erstellt 2026-08-27 |
|
||||||
|
| **fm.emsenhuber.net** | `b697d124-53bf-4a33-8fcf-384cced4db1a` | 1, healthy | Standort FM (`ds-fm`, `gateway-fm`), separat |
|
||||||
|
| **terpene-db** | `f2d2fc8a-54ae-41df-b09a-97312cdb9fe2` | 1, healthy | DB-Pfad (Hyperdrive→VPC-Service), Rebuild 2026-09-01 — s. §17 |
|
||||||
|
|
||||||
|
**bf-public-Ingress** → alle vier auf **Caddy `10.40.20.40:443`** (Caddy macht das Fan-out), catch-all 404:
|
||||||
|
`vw` · `hass` · `n8n` · `gitea` (`.emsenhuber.net`).
|
||||||
|
|
||||||
|
**WARP / Private Access — nativer Mesh, KEIN cloudflared:**
|
||||||
|
- warp_connector **bf-core-mesh** `d1da7c8a-2736-4cbb-8af1-32db5393fb9c`, healthy — nativer
|
||||||
|
WireGuard-Mesh-Node auf dem Core (TP16.1). **Live und produktiv** (nicht mehr „gated/Konzept" wie in §14).
|
||||||
|
- Routen: `10.40.0.0/16` → **bf-core-mesh** · `10.101.0.0/24` → **fm**. Ein vnet `vnetemsenhuber` (default).
|
||||||
|
|
||||||
## 1. Alle Tunnel im Account (`393c…66c36`)
|
## 1. Alle Tunnel im Account (`393c…66c36`)
|
||||||
| Tunnel | ID | Status | Conns | Verwendung |
|
| Tunnel | ID | Status | Conns | Verwendung |
|
||||||
@@ -234,6 +258,9 @@ Der macmini ist vollwertiger dritter Knoten (nicht „vergessen").
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>🗄️ <b>ARCHIV (§10–16)</b> — überholtes 3-Tunnel-HA <code>bf-mm</code>/<code>bf-nas</code>/<code>bf-pve</code>. Seit dem TP16.3-Cutover (2026-08-27) nicht mehr live — siehe §0. Aufklappen nur für die Historie.</summary>
|
||||||
|
|
||||||
## 10. AS-BUILT 2026-07-10 (TP16 umgesetzt) — reziprokes 3-Tunnel-HA ✅
|
## 10. AS-BUILT 2026-07-10 (TP16 umgesetzt) — reziprokes 3-Tunnel-HA ✅
|
||||||
> Gebaut & verifiziert in eigener Session. Round-Robin-#1033 **strukturell weg** (Loop-Gegentest 0×530 über alle 19 Live-FQDNs, HA per Host-Ausfall-Sim bewiesen). Schritte 1–4+HA durch; **Schritt 5 (Access/WARP + `vw`) offen** (user-gated).
|
> Gebaut & verifiziert in eigener Session. Round-Robin-#1033 **strukturell weg** (Loop-Gegentest 0×530 über alle 19 Live-FQDNs, HA per Host-Ausfall-Sim bewiesen). Schritte 1–4+HA durch; **Schritt 5 (Access/WARP + `vw`) offen** (user-gated).
|
||||||
|
|
||||||
@@ -337,6 +364,10 @@ Letzte in §9.2/§8 als „bleibt `.41`" geführte Origin-Klasse (`pve .41.100`,
|
|||||||
|
|
||||||
## 14. Richtungsentscheid 2026-07-16 — Private Access via native Cloudflare Mesh (WARP Connector) auf dem CRS326 Core-Switch (TP16.1)
|
## 14. Richtungsentscheid 2026-07-16 — Private Access via native Cloudflare Mesh (WARP Connector) auf dem CRS326 Core-Switch (TP16.1)
|
||||||
|
|
||||||
|
> ✅ **Nachtrag 2026-09-08 (live):** umgesetzt. warp_connector **bf-core-mesh** (`d1da7c8a-…`) ist healthy
|
||||||
|
> und trägt die Route `10.40.0.0/16` — der „Scharfschalten = eigene Session, gated"-Status unten ist
|
||||||
|
> überholt. Siehe §0.
|
||||||
|
|
||||||
> ✅ **Update 2026-07-22 (User-Entscheid):** Ziel-Device retargeted von **ap1** auf den **CRS326 Core-Switch** — konsistent mit TP15-Endstand (kein RB5009, Router bleibt ap1/ap2) und TP21s Kabel-Fabric-Modell. Reine Ziel-Doku-Änderung, Scharfschalten bleibt gated bis TP21 den CRS326 abdeckt. Der Rest dieser Sektion ist entsprechend auf den neuen Zielhost umgeschrieben; CRS326 ist aktuell noch Werkszustand, Management-IP/Interface TBD.
|
> ✅ **Update 2026-07-22 (User-Entscheid):** Ziel-Device retargeted von **ap1** auf den **CRS326 Core-Switch** — konsistent mit TP15-Endstand (kein RB5009, Router bleibt ap1/ap2) und TP21s Kabel-Fabric-Modell. Reine Ziel-Doku-Änderung, Scharfschalten bleibt gated bis TP21 den CRS326 abdeckt. Der Rest dieser Sektion ist entsprechend auf den neuen Zielhost umgeschrieben; CRS326 ist aktuell noch Werkszustand, Management-IP/Interface TBD.
|
||||||
|
|
||||||
**Trennung der Concerns (wichtig):**
|
**Trennung der Concerns (wichtig):**
|
||||||
@@ -452,6 +483,8 @@ Netzwerk-Namespace, sieht ohnehin alles), und die cloudflared-Connectoren laufen
|
|||||||
eigene Maschinen, auf denen die Blindheit prinzipiell nicht auftreten kann. Der Shim bleibt als Option
|
eigene Maschinen, auf denen die Blindheit prinzipiell nicht auftreten kann. Der Shim bleibt als Option
|
||||||
für den Fall, dass später doch ein Dienst neben macvlan-Nachbarn einziehen soll.
|
für den Fall, dass später doch ein Dienst neben macvlan-Nachbarn einziehen soll.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 17. Terpenfinder-DB-Pfad neu aufgebaut 2026-09-02 (Fremdprojekt-Handout blaueblüten)
|
## 17. Terpenfinder-DB-Pfad neu aufgebaut 2026-09-02 (Fremdprojekt-Handout blaueblüten)
|
||||||
|
|
||||||
**Auslöser:** Handout `blaueblueten-n8n/docs/handout-bf-infra-db-pfad-20260902.md` — der Cloudflare-Worker
|
**Auslöser:** Handout `blaueblueten-n8n/docs/handout-bf-infra-db-pfad-20260902.md` — der Cloudflare-Worker
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ terpene.teilzeitladen.xyz {
|
|||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://n8n-mcp.emsenhuber.net/mcp",
|
"url": "https://n8n-mcp.emsenhuber.net/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer 47a34f5589388d37d8b7503abac07b930df264c469ef0674aaeeea9b71dde847"
|
"Authorization": "Bearer <redacted>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ Token aus `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored). Aufruf: Dry-Ru
|
|||||||
- VPC-Service-API liegt unter `connectivity/directory/services` (Update = **PUT**, voller Body), nicht `/vpc/`.
|
- VPC-Service-API liegt unter `connectivity/directory/services` (Update = **PUT**, voller Body), nicht `/vpc/`.
|
||||||
- Der Repo-`CF_API_TOKEN` darf `cfd_tunnel`, aber **nicht** Workers-VPC (`connectivity/directory`) — dort
|
- Der Repo-`CF_API_TOKEN` darf `cfd_tunnel`, aber **nicht** Workers-VPC (`connectivity/directory`) — dort
|
||||||
über den `cloudflare`-MCP-Credential gehen.
|
über den `cloudflare`-MCP-Credential gehen.
|
||||||
|
> ⚠️ **ÜBERHOLT (2026-09-11):** falsch. Live getestet liest der `CF_API_TOKEN` aus
|
||||||
|
> `~/.secrets/credentials.env` Hyperdrive/VPC/Workers (alle 200) — der schmale ist `CF_DNS_API_TOKEN`.
|
||||||
|
> Der Teil-B-Sync deckt damit 14/14 ab. Details: `handoff-20260910-tp44-teilb-sync-live.md`.
|
||||||
|
|
||||||
## Offen (Etappe 2, eigene Session)
|
## Offen (Etappe 2, eigene Session)
|
||||||
|
|
||||||
|
|||||||
57
docs/handoff-20260910-tp44-teilb-sync-live.md
Normal file
57
docs/handoff-20260910-tp44-teilb-sync-live.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Handoff 2026-09-10 — TP44 Teil B: CF-Estate-Sync als Nautobot-Job LIVE
|
||||||
|
|
||||||
|
> **Ergebnis:** Der Cloudflare-Estate→Nautobot-Sync läuft als **täglicher Nautobot-Job**
|
||||||
|
> (`CFEstateSync`, grouping `TP44 / Cloudflare`). Dry-Run + Commit-Run sauber: **4 CloudNetworks /
|
||||||
|
> 14 CloudServices, alle CF-Endpoints lesbar, 0 Drift.** CF bleibt operativer Master, Nautobot ist
|
||||||
|
> read-only Dokumentations-SSoT. **TP44 ist damit vollständig durch.**
|
||||||
|
|
||||||
|
## Governance (User, 2026-09-09)
|
||||||
|
Periodische externe→Nautobot-Syncs leben als **Nautobot-Jobs** in `bf-infra/jobs/` und werden **in
|
||||||
|
Nautobot geplant** — kein per-Task-Container, kein Cron. Künftige Syncs = weitere Jobs in `jobs/`.
|
||||||
|
|
||||||
|
## Was live ist
|
||||||
|
- **Runtime:** `jobs/cf_estate_sync.py` (`CFEstateSync`, ORM). Pull der CF-API (urllib+Bearer),
|
||||||
|
Upsert per `extra_config`-Match (`tunnel_id`/`script`/`id`/`service_id`), Verwaisung = `stale`-Flag
|
||||||
|
statt Löschen. 403-tolerant (fehlender Scope kippt den Sync nicht; Verwaisung wird dann übersprungen).
|
||||||
|
- **Registrierung:** `jobs/__init__.py` ruft `register_jobs(CFEstateSync)`; **Repo-Wurzel-`__init__.py`**
|
||||||
|
macht `bf_infra` als reguläres Paket sichtbar.
|
||||||
|
- **Token:** Nautobot-Secret `cf-api-token` (Provider `environment-variable`) → `CF_API_TOKEN` am
|
||||||
|
`nautobot-worker`, verdrahtet im Anchor-`environment` von `docs/nas-vlan20/stacks/210-nautobot.yml`
|
||||||
|
(gitignored). Wert = der breite `CF_API_TOKEN` aus `~/.secrets/credentials.env` (liest 14/14).
|
||||||
|
- **Schedule:** `ScheduledJob "CF Estate Sync – taeglich"`, `interval=daily`, `start_time=2026-09-11T03:00:00Z`
|
||||||
|
(= 05:00 Europe/Vienna, vor den Golden-Config-Läufen), `enabled=True`, `dryrun=False`.
|
||||||
|
- **GitRepository:** `bf-infra` (`provided_contents=['extras.job']`) — war schon verdrahtet.
|
||||||
|
|
||||||
|
## Drei load-bearing Funde (sonst lädt/plant der Job nicht)
|
||||||
|
1. **Repo-Wurzel `__init__.py` ist Pflicht.** Nautobot 3.1.7 lädt Git-Repo-Jobs über
|
||||||
|
`import_modules_privately()` → `pkgutil.walk_packages([GIT_ROOT])`. walk_packages entdeckt **nur**
|
||||||
|
reguläre Pakete (mit `__init__.py`), **keine** Namespace-Packages. Ohne die Datei ist der geklonte
|
||||||
|
`GIT_ROOT/bf_infra/` unsichtbar → `jobs/` wird nie importiert. Symptom: Git-Sync **SUCCESS**, aber
|
||||||
|
Log „No jobs were registered on loading the `bf_infra.jobs` submodule" und **kein** Job-DB-Eintrag.
|
||||||
|
bf-infra ist das **erste** Git-Repo mit `extras.job` — daher nie zuvor aufgetreten. Der `module_path`-
|
||||||
|
Filter des Loaders importiert nur `bf_infra` + `bf_infra.jobs.*`; `nautobot/`, `routeros/` etc. bleiben
|
||||||
|
unberührt.
|
||||||
|
2. **`register_jobs()` gehört ins Paket-`__init__`**, nicht nur ins Submodul (Doku bestätigt).
|
||||||
|
3. **`Meta.has_sensitive_variables = False`** nötig — Default `True` ⇒ Nautobot verweigert das Scheduling
|
||||||
|
(„Job may have sensitive input variables"). Der Job nimmt nur `dryrun` (Boolean); das Token kommt aus
|
||||||
|
dem Secret, nicht aus einer Job-Variable.
|
||||||
|
|
||||||
|
## Zwei Daten-/Code-Funde beim Dry-Run
|
||||||
|
- **VPC-Bug:** VPC-Directory-Services tragen ihren Identifier in **`service_id`**, nicht `id`. Der Job las
|
||||||
|
`id` (=None) → per `.get(mk)==None` matchte er ein Fremdobjekt (App Launcher) und ließ den echten
|
||||||
|
`terpene-db` auf stale laufen. Fix: VPC nutzt `service_id`; `_upsert` überspringt `mv=None` generell.
|
||||||
|
- **Seed-Artefakt:** der geseedete CloudService `terpene-db` hatte `extra_config.service_id: null`
|
||||||
|
(der echte `019fa3d5-…` stand beim Seed nur in den Hyperdrive-Einträgen als `vpc_service_id`).
|
||||||
|
**Einmalig reconciled** (PATCH `service_id=019fa3d5-…`) → seither matcht der Sync sauber, 0 Drift.
|
||||||
|
|
||||||
|
## Drift-Korrektur (nicht stillschweigend)
|
||||||
|
Die frühere Notiz „Repo-`CF_API_TOKEN` darf kein Workers-VPC → über den `cloudflare`-MCP-Credential"
|
||||||
|
(TP44-Prosa + `handoff-20260902-…`) war **falsch**. Live getestet liest der `CF_API_TOKEN` aus
|
||||||
|
`~/.secrets/credentials.env` Hyperdrive/VPC/Workers (alle 200). Der schmale Token ist `CF_DNS_API_TOKEN`
|
||||||
|
(403). Der `cloudflare`-MCP ist der **remote OAuth-MCP** (kein API-Token) und für einen serverseitigen
|
||||||
|
Job ohnehin nicht nutzbar.
|
||||||
|
|
||||||
|
## Nächster CF-Sync = neuer Job im selben Muster
|
||||||
|
Modul in `jobs/` anlegen, Klasse importieren + `register_jobs(...)` im `jobs/__init__.py` ergänzen,
|
||||||
|
`Meta.has_sensitive_variables=False` setzen, Git-Sync, enablen, Dry-Run, dann Schedule. Die Wurzel-
|
||||||
|
`__init__.py` gilt schon.
|
||||||
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.
|
||||||
File diff suppressed because one or more lines are too long
@@ -63,6 +63,11 @@ teuerste Eigenschaft der Datei zerstört, um ihr billigstes Problem zu lösen. B
|
|||||||
| [TP28](#tp28) | WLAN über CAPsMAN auf `crs326-core` | ↩️ **wieder geöffnet 2026-08-30** (ap3) — Volltext in [teilprojekte.md](teilprojekte.md#tp28) |
|
| [TP28](#tp28) | WLAN über CAPsMAN auf `crs326-core` | ↩️ **wieder geöffnet 2026-08-30** (ap3) — Volltext in [teilprojekte.md](teilprojekte.md#tp28) |
|
||||||
| [TP-R](#tp-r) | Router-Capabilities-Sweep | FERTIG (docs/research/routeros-capabilities.md) |
|
| [TP-R](#tp-r) | Router-Capabilities-Sweep | FERTIG (docs/research/routeros-capabilities.md) |
|
||||||
| [TP39](#tp39) | LXC-Hygiene pve: Resolver-Altlasten, `networking.service`, DB-Reverse-Lookup | ✅ ABGESCHLOSSEN (2026-09-01) — pve+NAS-Sweep grün |
|
| [TP39](#tp39) | LXC-Hygiene pve: Resolver-Altlasten, `networking.service`, DB-Reverse-Lookup | ✅ ABGESCHLOSSEN (2026-09-01) — pve+NAS-Sweep grün |
|
||||||
|
| [TP34](#tp34) | Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) | ❌ VERWORFEN (2026-09-08) — Caddyfile bleibt manuell |
|
||||||
|
| [TP44](#tp44) | Cloudflare-Estate als Dokumentations-SSoT in Nautobot | ✅ DURCH (2026-09-10) |
|
||||||
|
| [TP41](#tp41) | CF-Access-Reparatur + Zero-Trust-Konsolidierung | ✅ ABGESCHLOSSEN (2026-09-10) — Gelis WARP via Android eingerichtet, letzter Blocker weg |
|
||||||
|
| [TP43](#tp43) | dw-Standort-DNS-Uplink (site-spezifische Overrides) | ♻️ REOPENED (2026-09-11) — Volltext zurück in teilprojekte.md; interne BF-Namen über WG am dw offen |
|
||||||
|
| [TP42](#tp42) | Namens-Vereinheitlichung: ein interner Suffix bf.emsenhuber.net | ✅ DURCH (2026-09-02) — `.bf.lan` raus, `.16` abgebaut, Compliance 140/140 |
|
||||||
|
|
||||||
Dazu am Ende: **[TP25 (Langfassung)](#tp25-lang)** — die Messchronik und die Funde A–E im Volltext.
|
Dazu am Ende: **[TP25 (Langfassung)](#tp25-lang)** — die Messchronik und die Funde A–E im Volltext.
|
||||||
TP25 selbst ist **nicht** abgeschlossen und steht weiter in `teilprojekte.md`; nur seine 394 Zeilen
|
TP25 selbst ist **nicht** abgeschlossen und steht weiter in `teilprojekte.md`; nur seine 394 Zeilen
|
||||||
@@ -1777,3 +1782,198 @@ gemessen hat. Beim Beheben kam eine zweite, unabhängige Altlast zum Vorschein.
|
|||||||
- **Nicht** TP31/TP34 (Caddy) und nicht TP11 (Monitoring) — obwohl gilt: ein Monitoring, das
|
- **Nicht** TP31/TP34 (Caddy) und nicht TP11 (Monitoring) — obwohl gilt: ein Monitoring, das
|
||||||
DB-Verbindungszeiten kennt, hätte diesen Fall Monate früher gezeigt.
|
DB-Verbindungszeiten kennt, hätte diesen Fall Monate früher gezeigt.
|
||||||
- **Nicht** TP40: der HA-Recorder-Wildwuchs (Punkt 3) ist HA-seitig und ein eigenes TP.
|
- **Nicht** TP40: der HA-Recorder-Wildwuchs (Punkt 3) ist HA-seitig und ein eigenes TP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TP34 — Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) ▸ ❌ **VERWORFEN (2026-09-08)**
|
||||||
|
> 🔴 **Nachtrag 2026-09-08 (verworfen):** Der Ansatz „Caddyfile via Golden Config rendern" ist
|
||||||
|
> zurückgezogen (Commits `d63dc7b`, `bca8f6e`; Gitea-Issue #24 → `status:dropped`, closed). Der
|
||||||
|
> `420.teilzeitladen.xyz`-Block wurde wieder aus dem GC-gerenderten Caddyfile entfernt; **der
|
||||||
|
> Caddyfile bleibt manuell** in `caddy/Caddyfile`. Gründe: kein zweites SSoT-Modell für Proxy-Routen
|
||||||
|
> nötig, TP37 deckt die Dienst-Doku als `ipam.Service` bereits ab, und der einzige akute Anwendungsfall
|
||||||
|
> ließ sich mit einem manuellen Block lösen. Text unten unverändert als Konzept-Historie.
|
||||||
|
|
||||||
|
> **Konzept + Architektur-Skizze:** [`docs/handoff-20260827-inventur-und-architektur.md`](handoff-20260827-inventur-und-architektur.md) — bitte dort weiterlesen (Architektur-Abschnitt).
|
||||||
|
- **Scope:** Reverse-Proxy-Hosts werden im **Nautobot-Datenmodell** gepflegt (Site, Host, Backend-Spec). **Golden Config** rendert den Caddyfile aus den Daten, Diff-Check gegen die Live-Version auf `caddy-bf1`, Compliance-Checks + Transaktionen identisch zum bestehenden Workflow für Router-Configs.
|
||||||
|
- **Auslöser (Inventur 2026-08-27):** der Caddyfile (`bf-infra/caddy/Caddyfile`) ist heute **manuell gepflegt** — kein Drift-Check, keine Transaktionen, andere Projekte können keine Sites anlegen ohne Christoph. Lösung: **Nautobot/GC**, kein Caddy-API-Sidecar (kein zweites SSoT).
|
||||||
|
- **Erster Anwendungsfall:** `terpene.teilzeitladen.xyz → http://10.40.20.167:8890` (Mac-mini-Backend, dort PostgREST + sync + web via lokalem Caddy `:8890`). Vor TP34: manueller Quick-Win-Caddy-Block.
|
||||||
|
- **Abh.:** TP22 (GC-Pipeline) ✅ · TP23 (DNS/Caddy-Voraussetzungen) ✅ Stufe 1 · 🔴 **NEU 2026-09-06: TP37 Punkt 1+2** — Caddy rendert *Config aus Daten*, die Backend-Dienste müssen als `ipam.Service` in Nautobot existieren, sonst gibt es nichts zu rendern. **TP37 vor TP34.**
|
||||||
|
- **Datenmodell (entschieden 2026-09-06):** native Modelle — Backend = `ipam.Service`, Proxy-Route = Relationship/CustomField Frontend↔Backend, GC-Job rendert den Caddyfile. Keine eigene App (konsistent zu TP44).
|
||||||
|
- **Status:** 🔜 Stub; wartet auf TP37 P1+2. Quick-Win `terpene.teilzeitladen.xyz → 10.40.20.167:8890` als manueller Caddy-Block ist **Schritt 0** des Runbooks. Plan: [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a id="tp44"></a>
|
||||||
|
### TP44 — Cloudflare-Estate als Dokumentations-SSoT in Nautobot ▸ ✅ **DURCH (2026-09-10): Etappe 1 + Etappe 2 (Teil A IPAM-Prefixe, Teil B Live-Sync als täglicher Nautobot-Job)**
|
||||||
|
> **Entscheid (User, 2026-09-02):** die volle CF-Estate (Tunnel + WARP-Connector + VPC-Service +
|
||||||
|
> Hyperdrive + Workers + alle Access-Apps) als strukturierte, abfragbare SSoT in Nautobot abbilden —
|
||||||
|
> **etappenweise: manuell jetzt, Sync später.** Nautobot kann CF nicht steuern → „SSoT" heißt
|
||||||
|
> **Dokumentations-SSoT; CF bleibt operativer Master.** Detail-Handoff:
|
||||||
|
> [`handoff-20260902-cf-estate-nautobot-ssot.md`](handoff-20260902-cf-estate-nautobot-ssot.md).
|
||||||
|
|
||||||
|
- **Warum eigenes TP, nicht TP36:** TP36 ist eng auf `teilprojekte.md`-Live-Daten geschnitten; gleiche
|
||||||
|
Idee (Nautobot als Doku-SSoT), andere Dimension. TP44 = nächste freie Nummer nach TP43.
|
||||||
|
- **Modell (native `nautobot.cloud`, Nautobot 3.1.7 — keine eigene App):** `Manufacturer "Cloudflare"` →
|
||||||
|
`CloudAccount` (`393c051f…`) → `CloudResourceType ×6` → `CloudNetwork ×4` (`bf-public`/`fm`/`terpene-db`/
|
||||||
|
`bf-core-mesh`) + `CloudService ×14`; Service↔Tunnel über das Through-Modell
|
||||||
|
`cloud-service-network-assignments` (3 Rows). IDs/Ports/Hostnames/Origins je Objekt in `extra_config`.
|
||||||
|
- **Werkzeug:** `nautobot/tp44-cf-estate-seed.py` — idempotenter Seeder (Re-Run = 26/26 „existiert"),
|
||||||
|
Datenstand inline (Live-CF-Inventur 2026-09-02). Token aus `docs/nas-vlan20/stacks/210-nautobot.yml`.
|
||||||
|
- ✅ **Verifikation 2026-09-06 (live gegen die API):** CloudAccount 1 · ResourceType 6 · CloudNetwork 4 ·
|
||||||
|
CloudService 14 — unverändert seit 09-02.
|
||||||
|
- 🔴 **Gotchas:** `CloudService.cloud_networks` ist `read_only` (nur Through-Rows schreiben/lesen) ·
|
||||||
|
VPC-Service-API unter `connectivity/directory/services` (PUT, voller Body) · **VPC-Objekte tragen ihren
|
||||||
|
Identifier in `service_id`, NICHT `id`** (kostete im Sync einen Fehl-Match) · ⚠️ **DRIFT-KORREKTUR
|
||||||
|
2026-09-10:** die frühere Zeile „Repo-`CF_API_TOKEN` darf kein Workers-VPC → über den `cloudflare`-MCP"
|
||||||
|
war **falsch**. Live getestet liest der `CF_API_TOKEN` aus `~/.secrets/credentials.env` Hyperdrive **und**
|
||||||
|
VPC **und** Workers (alle 200). Der schmale Token ist `CF_DNS_API_TOKEN` (403). Der `cloudflare`-MCP ist
|
||||||
|
der **remote OAuth-MCP** (kein API-Token) und für einen serverseitigen Job ohnehin nicht nutzbar.
|
||||||
|
- ✅ **Etappe 2 Teil A (2026-09-08): IPAM-Prefixe live.** `10.40.0.0/16` (container, Route `bf-core-mesh`) +
|
||||||
|
`10.101.0.0/24` (network, Route `fm`) angelegt via `nautobot/tp44-ipam-prefixes.py`; der Seeder-Re-Run
|
||||||
|
hat die zwei `cloud-network-prefix-assignments` eingehängt (count=2, Idempotenz-Gegenprobe grün).
|
||||||
|
- ✅ **Etappe 2 Teil B (2026-09-10): Live-Sync als Nautobot-Job — DURCH.** Governance (User 2026-09-09):
|
||||||
|
periodische externe→Nautobot-Syncs leben als **Nautobot-Jobs** in `bf-infra/jobs/`, in Nautobot geplant —
|
||||||
|
**kein per-Task-Container/-Cron.** Runtime `jobs/cf_estate_sync.py` (`CFEstateSync`, ORM, read-only Pull,
|
||||||
|
403-tolerant, Verwaisung = `extra_config.stale` statt Löschen). Token = Nautobot-Secret `cf-api-token`
|
||||||
|
(Provider env → `CF_API_TOKEN` am Worker, in `210-nautobot.yml` verdrahtet). **Schedule: täglich 03:00Z**
|
||||||
|
(`ScheduledJob "CF Estate Sync – taeglich"`). Verifiziert: Dry-Run + Commit-Run sauber, **4 Networks /
|
||||||
|
14 Services, alle Endpoints lesbar, 0 Drift**. Der REST-Einmalproof `nautobot/tp44-cf-sync.py` bleibt als
|
||||||
|
workstation-seitiges Diagnose-Werkzeug liegen (nicht die kanonische Runtime).
|
||||||
|
- 🔴 **Drei load-bearing Funde (Detail-Handoff `handoff-20260910-tp44-teilb-sync-live.md`):**
|
||||||
|
(1) **Wurzel-`__init__.py` im Repo ist Pflicht** — Nautobot lädt Git-Repo-Jobs via
|
||||||
|
`pkgutil.walk_packages([GIT_ROOT])`, das Namespace-Packages (ohne `__init__.py`) NICHT entdeckt; ohne die
|
||||||
|
Datei bleibt `bf_infra.jobs` unsichtbar (Symptom: Sync SUCCESS + „No jobs were registered"). bf-infra ist
|
||||||
|
das erste Git-Repo mit `extras.job`. (2) `register_jobs()` gehört ins **Paket-`__init__`**, nicht nur ins
|
||||||
|
Submodul. (3) `Meta.has_sensitive_variables=False` nötig, sonst verweigert Nautobot das Scheduling.
|
||||||
|
- **Beziehung zu TP37:** TP44 hat **TP37 Punkt 3** (CF-Connectoren) vorweggenommen — dieselbe native-Modell-
|
||||||
|
Linie, die TP37 P1+2 (Substrat/Dienste) fortsetzt.
|
||||||
|
|
||||||
|
### TP41 — CF-Access-Reparatur + Zero-Trust-Konsolidierung ▸ ✅ **ABGESCHLOSSEN (2026-09-10)** — Auth-Kern seit 2026-09-01 komplett; **Gelis WARP-Client am 2026-09-10 via Android erfolgreich eingerichtet** (letzter Blocker weg, User-Bestätigung). Optionale Restpunkte (T6 Rate-Limit, geli-CGNAT-Policy-Cleanup) bewusst nicht gezogen; Keystore-Rotation läuft unter TP30 #7.
|
||||||
|
|
||||||
|
**Auslöser:** CF-Access zwang Christoph **und Geli** täglich zum OTP-Login (soll: 30 Tage); GitHub/Google
|
||||||
|
„funktionieren nicht". Root-Causes: Team-Domain-Rename `bf4`→`emsenhuber` (Provider-Callback veraltet),
|
||||||
|
App-Session 24h, diverse Ordnungs-/Sicherheitslecks. Vollständiger Verlauf + offene Schritte:
|
||||||
|
**[`docs/handoff-20260901-tp41.md`](handoff-20260901-tp41.md)**. Branch `tp41-cf-access-repair`.
|
||||||
|
|
||||||
|
**✅ Live erledigt:** Session 720h (Wildcard/vw/Warp-Login) · login_method-Loch zu (reusable Policy
|
||||||
|
`bf-users` = nur `email:christoph`+`email:geli`) · `8e44f5d1` von 4 Apps auf **nur Terpenfinder**
|
||||||
|
entkoppelt · Access-**Gruppen** `zt-humans`/`zt-home`/`zt-automation`, Policies zeigen darauf · **vw voll
|
||||||
|
Bypass** (Lebensader offen) + **`vw/admin` per WAF-Regel öffentlich geblockt** (CF-Edge + Mobilfunk
|
||||||
|
verifiziert) · API-Token auf das vollständige `…4148` konsolidiert (User hat Service-Tokens- + Zero-Trust-
|
||||||
|
Write ergänzt), altes `…2174` revoken.
|
||||||
|
|
||||||
|
**✅ T4 DURCH (2026-09-01, Variante B):** ein Access-Service-Token **`zt-svc`** (`65f1821a`, rotiert) über
|
||||||
|
Gruppe `zt-automation`; Terpene-Worker per `wrangler secret put TERPENE_SVC_CLIENT_ID` auf zt-svc
|
||||||
|
umgestellt (Worker-Pin `index.js:44`); `terpene-verify`+`-v2` **gelöscht**; `test_whoami` grün.
|
||||||
|
**Token-Altlast geklärt:** `b00722` = LIVE Caddy-DNS-Token (LXC130 caddy.env, SSoT), `d47435` = stale
|
||||||
|
Kopie (kein Consumer) → **User revoket `d47435`**; `credentials.env`-Kopie auf b00722 realignt.
|
||||||
|
|
||||||
|
**✅ T3 APPLIED (2026-09-01):** Default-Profil (`0197d1b3`; das custom „Cloudflare Mesh" matcht nur den
|
||||||
|
`warp_connector`) `fallback_domains += emsenhuber.net → [10.40.20.36, 10.40.20.37]`; Tunnel-Route
|
||||||
|
`10.40.0.0/16` (`d1da7c8a`) da, kein `10/8`-Exclude → routbar. ⏳ Client-Verify: WARP neu verbinden.
|
||||||
|
**✅ T2 DURCH (2026-09-01):** WAF-Regel `f26744eb` gelöscht; Caddy `vw`-Block `respond @cf_admin 404` bei
|
||||||
|
Header `CF-Connecting-IP` (LXC130). Verify: Edge `/admin`=404, lokal/WARP `/admin`=200. ✅
|
||||||
|
|
||||||
|
**✅ T5 DURCH (2026-09-01, User-bestätigt):** GitHub-Login läuft; Google-Login läuft nach dem
|
||||||
|
Consent-Screen-Fix (§A des Follow-up-Handoffs: User Type *Internal*→*External*/veröffentlicht).
|
||||||
|
Callback für beide `https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback`.
|
||||||
|
**❌ Facebook/Meta für TP41 abgesagt (2026-09-01, User-Entscheid):** optional, kein Bedarf — Geli ist
|
||||||
|
über OTP `angelikahy@outlook.de` + Google `pillepalle670@gmail.com` voll gedeckt. **Keine Facebook-App,
|
||||||
|
kein CF-Facebook-IdP angelegt → nichts aufzuräumen.** Instagram hat ohnehin keinen CF-IdP.
|
||||||
|
**✅ App-Launcher (D1):** je eine Bookmark-Kachel vw/hass/terpenfinder, Wildcard-Kachel raus, Launcher
|
||||||
|
gestylt (Follow-up-Handoff §D, Policies unverändert).
|
||||||
|
|
||||||
|
**✅ Geli in WARP — CF-seitig nichts zu tun (2026-09-01 live geprüft):** Device-Enrollment läuft über
|
||||||
|
die App **„Warp Login App"** (`6c32fe23…`, type `warp`) → Policy `bf-users` → Gruppe **`zt-humans`**, die
|
||||||
|
Gelis beide Mails **schon enthält** → sie **darf bereits enrollen** (deshalb war „Device enrollment
|
||||||
|
permissions" nicht als fehlendes Setting zu finden). Terpenfinder-`geli`-Policy lässt sie per E-Mail durch.
|
||||||
|
✅ **Client am 2026-09-10 via Android erfolgreich eingerichtet** (WARP installiert, Team `emsenhuber`,
|
||||||
|
Login OTP `angelikahy@outlook.de` — User-Bestätigung); damit ist der einzige verbliebene Handgriff erledigt.
|
||||||
|
🔑 **Permission-Frage beantwortet:** CF-seitig **keine** nötig. Für generelle WARP/Enrollment/User-Verwaltung
|
||||||
|
per Account-Token: *Access: Apps and Policies → Edit* + *Access: Organizations, IdPs, and Groups → Edit*
|
||||||
|
(User erteilt 2026-09-01). **Seat-Verwaltung (`/access/seats`) bleibt per MCP gesperrt** — `10405 Method
|
||||||
|
not allowed for this authentication scheme` (Endpoint will User-Token, nicht Account-Token; kein Scope-Fix,
|
||||||
|
für Enroll irrelevant).
|
||||||
|
|
||||||
|
**🔻 Optionale Restpunkte (bewusst nicht gezogen, kein Blocker):** **T6** optional Rate-Limit ·
|
||||||
|
🔴 `TERPENE_ANDROID_KEYSTORE_PW`-Rotation (→ **TP30 Posten #7**, Transkript-Leak) · **Security-Cleanup:**
|
||||||
|
die `geli`-Terpenfinder-Policy hat **OR-Semantik** und enthält den breiten CGNAT-Bereich
|
||||||
|
`94.154.138.0/24` als IP-Include → jeder aus dieser geteilten Range käme **ohne Login** rein. Über WARP ist
|
||||||
|
der Egress ohnehin Cloudflare (nicht diese IP) → der IP-Include wäre dann überflüssig und entfernbar.
|
||||||
|
**d47435-Revoke ✅ · T3-WARP-Client-Verify ✅** (User, 2026-09-01).
|
||||||
|
|
||||||
|
**Abgrenzung:** hass/gitea bleiben (diese Runde) öffentlich; Terpenfinder-Human-Policies nicht angefasst;
|
||||||
|
**Gitea-Least-Privilege** = eigener künftiger TP (jedes Projekt repo-scoped Login, Admin nur Claude/Christoph).
|
||||||
|
- Bezug **TP11** (Monitoring): eine Plattenfüllstand-Warnung hätte das Wochen früher gezeigt.
|
||||||
|
|
||||||
|
### TP43 — dw-Standort-DNS-Uplink (site-spezifische Overrides) ▸ ♻️ **REOPENED 2026-09-11 → Volltext zurück in [`teilprojekte.md`](teilprojekte.md) (Abschnitt TP43)**
|
||||||
|
|
||||||
|
Kurz zwischen 2026-09-11 verworfen und am selben Tag reopened: die 420-Prämisse war gegenstandslos
|
||||||
|
(public CF-Worker, via WARP/TP41 gelöst), aber die **interne** BF-Namensauflösung über WG am dw blieb
|
||||||
|
dabei offen (`portainer.bf.emsenhuber.net` am dw nicht erreichbar). Korrigierter Scope + verworfene
|
||||||
|
420-Historie stehen jetzt aktiv in `teilprojekte.md`. Gitea-Issue #32 = `status:todo` · `thema:firewall-dns`.
|
||||||
|
|
||||||
|
### TP42 — Namens-Vereinheitlichung: ein interner Suffix bf.emsenhuber.net ▸ ✅ **DURCH (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`, `.bf.lan` raus, `.16` (adguard_bf1) abgebaut, Compliance 140/140. Offen nur der an **TP37** gekoppelte Nautobot-Doku-Teil.
|
||||||
|
|
||||||
|
**Auslöser (2026-09-01, TP41-Randbefund):** live gemessen liefern die realen Client-Resolver
|
||||||
|
`10.40.20.36`/`.37` `*.emsenhuber.net → 10.40.20.40` (Caddy) — korrekt. Daneben existiert aber ein
|
||||||
|
zweiter, gedrifteter interner Namensraum: der **Core** trägt in `/ip dns static` 11 Einträge auf
|
||||||
|
`*.bf.lan`, der **Caddyfile** doppelt jeden internen Namen als `*.bf.emsenhuber.net`, und die alten
|
||||||
|
AdGuard `.16`/`.17` antworten noch mit `nginx*.bf.emsenhuber.net` bzw. totem VLAN1 `10.40.41.123`.
|
||||||
|
Christoph: „aus emsenhuber.net wurde schon wieder bf.lan — das müssen wir ein für alle mal aus allen
|
||||||
|
Geräten rausprügeln und in Nautobot festhalten."
|
||||||
|
|
||||||
|
**Ziel:** genau **ein** interner Namensraum `emsenhuber.net` (Split-Horizon `.36/.37 → .40`). `.bf.lan`
|
||||||
|
und `.bf.emsenhuber.net` werden entfernt; Sollzustand in Nautobot dokumentiert.
|
||||||
|
|
||||||
|
**Scope (Reihenfolge = eigene Session):**
|
||||||
|
1. **Bestandsaufnahme** aller Fundstellen: Core `/ip dns static` (`*.bf.lan`), DHCP-Option
|
||||||
|
`domain`/Search-Domain je Netz, Caddyfile-Aliase (`*.bf.emsenhuber.net`), AdGuard-Rewrites `.36/.37`,
|
||||||
|
stale `.16/.17`, Geräte-`identity`.
|
||||||
|
2. **Entscheidung Zielschema** (nur `emsenhuber.net`, ggf. `bf.emsenhuber.net` als *einzige* interne
|
||||||
|
Zone — Christoph-Entscheid) und Migrationsreihenfolge ohne DNS-Loch.
|
||||||
|
3. **Umsetzen:** Core-DNS + DHCP + Caddyfile angleichen; stale `.16/.17` klären/abbauen.
|
||||||
|
4. **Nautobot:** interne Namen/Records als Sollzustand erfassen (Bezug TP37 Datenmodell).
|
||||||
|
|
||||||
|
**Abgrenzung:** kein CF-Public-DNS-Umbau (Zone `emsenhuber.net` öffentlich bleibt); kein Resolver-Swap
|
||||||
|
(`.36/.37` bleiben); `.1`-als-DNS-Reste auf Clients sind separat/tolerierbar (User: „bis jetzt kein
|
||||||
|
Problem"). **Abh.:** TP37 (Nautobot-Datenmodell) für Punkt 4; sonst nichts Offenes.
|
||||||
|
|
||||||
|
#### ✅ Bestandsaufnahme LIVE (2026-09-02) — Drift hat 80 % schon erledigt
|
||||||
|
Gegen die Geräte gemessen (MikroMCP + dig), nicht aus dem Handoff:
|
||||||
|
|
||||||
|
| Fundstelle | Handoff 09-01 | Live 09-02 |
|
||||||
|
|---|---|---|
|
||||||
|
| Core `/ip dns static` | 11× `*.bf.lan` | ✅ **0× `.bf.lan`** — 10× `*.bf.emsenhuber.net` (deckt sich mit GC-Template Z.284–293) |
|
||||||
|
| DHCP `domain` (7 Netze) | „prüfen" | `emsenhuber.net` (GC-Template Z.133 = gleich ⇒ **keine Drift**), DNS `.36/.37` |
|
||||||
|
| `.36/.37` (Soll) | aktiv | `vw.emsenhuber.net→.40` ✅ · `macmini.bf.emsenhuber.net→.20.167` ✅ · **`macmini.emsenhuber.net→leer`** ❌ |
|
||||||
|
| Caddyfile | `*.bf.emsenhuber.net`-Aliase | unverändert, **konsistent** mit Core-Static |
|
||||||
|
| `.16`/`.17` | tot | laufen (:53), liefern totes VLAN1; **kein Core-Objekt** (Firewall/NAT/DNS-Upstream/Addr-List) hängt an `.16` |
|
||||||
|
|
||||||
|
**Kernkonflikt:** DHCP verteilt `emsenhuber.net` (bare), Records leben unter `bf.emsenhuber.net` ⇒ bare `macmini` löst nicht.
|
||||||
|
|
||||||
|
#### ✅ Entscheidungen (User, 2026-09-02)
|
||||||
|
1. **Zielschema = `bf.emsenhuber.net`** (der wenig-Arbeit-Weg): DHCP-domain → `bf.emsenhuber.net`, dann
|
||||||
|
löst `macmini` bare korrekt; Core-Static + Caddyfile bleiben (tragen schon `.bf.`). Öffentliche Zone
|
||||||
|
`emsenhuber.net` bleibt getrennt.
|
||||||
|
2. **`.16` (adguard_bf1) abbauen**, **`.17` (adguard_fm2 = Standort FM) NICHT anfassen.**
|
||||||
|
|
||||||
|
#### ✅ Umsetzung DURCH (2026-09-02)
|
||||||
|
- **(A) DHCP-domain `emsenhuber.net → bf.emsenhuber.net` über GC ✅** — kein Direkt-Write:
|
||||||
|
`mikrotik_routeros.j2` Z.133 gehoben → `push-gc-template.py` (Commit `7a4068c4`, **Nebenbefund:** das
|
||||||
|
Remote-Template hing dem lokalen hinterher — es trug noch `*.bf.lan`+`router.lan`, obwohl Gerät+lokal
|
||||||
|
längst `.bf.emsenhuber.net` fuhren; der Push hat das GC-Repo an den Live-Stand angeglichen) →
|
||||||
|
GitRepo-Sync (SUCCESS) → manueller ConfigPlan **`tp42-dhcp-domain`** (`set [find] domain=…`, weil
|
||||||
|
`add` das Netz doppelt anlegen würde) → Deploy an **crs326-core** → Live **7/7 = bf.emsenhuber.net**,
|
||||||
|
Compliance **140/140, keine Drifts**. Der Job steht dauerhaft in `nautobot/gc-configplan.py`.
|
||||||
|
- **(B) `.16`-Teardown ✅** — `adguard_bf1` lag im geteilten Portainer-Stack **195 `adguard`** (Docker-LXC
|
||||||
|
`.126`, **ep2** — *nicht* ep14) **zusammen mit `adguard_fm2` (`.17`, Standort FM!)**. Deshalb **nicht**
|
||||||
|
Stack löschen, sondern compose auf nur `.17` getrimmt + Stack redeployt (Prune): `.16`-Container weg,
|
||||||
|
`.16:53` tot; **`.17` läuft** (⚠️ ~40 s mit-neugestartet durch den Redeploy — kurzer FM-DNS-Blip,
|
||||||
|
danach sauber). Rollback-compose gesichert. 🧹 Daten-Dirs `/opt/docker/adguard_bf1/*` bleiben als
|
||||||
|
harmlose Leichen auf dem LXC.
|
||||||
|
- **(C) Nautobot-Soll** (Scope-Punkt 4) bleibt an **TP37** gekoppelt (Datenmodell), separat — kein Blocker.
|
||||||
|
|
||||||
|
**Status:** ✅ **Namensraum vereinheitlicht (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`,
|
||||||
|
`.bf.lan` raus, `.16` abgebaut, Compliance 140/140. Offen nur der TP37-gekoppelte Nautobot-Doku-Teil.
|
||||||
|
Handoff: [`docs/handoff-20260901-tp42-naming.md`](handoff-20260901-tp42-naming.md).
|
||||||
|
|||||||
@@ -974,7 +974,28 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
|
|||||||
- **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**.
|
- **Verwandt/Abgrenzung:** vw ist auch in **TP16** offen (CF-Access: `bypass`→WARP-geschützt) — **DB-HA ≠ Access**, getrennt behandeln. Reziprokes 2-Site-Muster analog **TP16**/**TP18**.
|
||||||
- Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21.
|
- Abh.: **TP20** (Tier-1 Postgres muss stehen), **TP10** (WG-Transport für Phase-2), **TP18** (Backup-Schirm). Kein Blocker für TP21.
|
||||||
|
|
||||||
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 1–5 DURCH (2026-09-04); offen nur Stufe 6 (Bypass-Sperre, in TP5)**
|
### TP23 — DNS-Policy & Self-Service-Ausnahmen ▸ ⏳ **STUFE 5 WIEDER OFFEN (Redesign 2026-09-12): Self-Service deckt die DAU-Requirements nicht ab → Stufe 6 bleibt dahinter gated**
|
||||||
|
> 🔴 **Redesign 2026-09-12 — Stufe 5 reopened.** Bei „Stufe 6 angehen" fiel auf: die 09-04-Stufe-5
|
||||||
|
> (single-domain, temp-only 15m/1h/4h, **eine** Ausnahme gleichzeitig, **HA-Neustart bei neuer
|
||||||
|
> Domain**, `allow-domain` global statt pro Person) deckt die Nutzer-Requirements **nicht** ab
|
||||||
|
> („funktional ein kastriertes Pi-hole", User). Die harte Reihenfolge-Regel „erst der Ersatz, dann
|
||||||
|
> die Sperre" ist damit real **nicht** erfüllt → **Stufe 6 darf nicht laufen**, bis der neue
|
||||||
|
> Self-Service nachweislich trägt.
|
||||||
|
> - **Neue Requirements (User 2026-09-12):** (1) **pro Person** (Login), Sicht über alle Geräte;
|
||||||
|
> (2) **„zuletzt geblockt" → 1 Klick**; (3) temporär **und** dauerhaft, Modus umschaltbar
|
||||||
|
> (self-permanent ⇄ Anfrage, Start self-permanent); (4) **scoped HA-Dashboard** je Person —
|
||||||
|
> nichts von anderen/Admin/Power-User, AGH-Web-UI bleibt reines Admin-Werkzeug.
|
||||||
|
> - **Design/Bau:** Personen-Client je AGH (SSoT Person→Gerät), Ausnahmen via `$client=`-Modifier,
|
||||||
|
> `agh-exception.py` um scoped `allow/unallow` + `list-exceptions` + `recent-blocked` erweitert
|
||||||
|
> (reine Logik netzunabhängig getestet, `selftest` ✅). HA-Dashboard/Sensoren/Modus-Switch als
|
||||||
|
> Bau-Spec in [`adguard/ha-self-service.md`](../adguard/ha-self-service.md) (on-network zu bauen).
|
||||||
|
> - 🔴 **Load-bearing, on-network zu prüfen:** wirkt AGH `$client=` auf **Allow**-Regeln (`@@`)?
|
||||||
|
> Fallback = IP-Liste statt Client-Name (Code kann beides). Plan:
|
||||||
|
> `~/.claude/plans/tp23-stufe-6-angehen-soft-sunrise.md`.
|
||||||
|
> - **Neue echte Stufe-6-Vorbedingung:** End-to-End-Abnahme mit echtem Nicht-Admin-Flow.
|
||||||
|
> - **Stufe-6-Runbook-Korrektur:** Core `10.40.X.1` in der Allowlist überdenken (Client-Gateway →
|
||||||
|
> DoH direkt zu CF = interner Bypass der AGH-Personen-Policy).
|
||||||
|
|
||||||
> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt**
|
> 🟢 **Update 2026-09-04 — Stufe 4 + 5 gebaut, live verifiziert. Der `.16`-Text unten ist überholt**
|
||||||
> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block):
|
> (Stufe 2 lebt seit 08-26 auf dem Paar `agh-bf1`/`agh-bf2` = `.36`/`.37`, LXC 110/111 — s. Ist-Stand-Block):
|
||||||
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
|
> - **Stufe 4 (CIDR-Substrat):** 7 VLAN-Gruppen + `dns-bypass-exempt` in `clients.persistent` auf beiden
|
||||||
@@ -1078,7 +1099,7 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
|
|||||||
- **Abh.:** keine harten. **TP15 ✅** (die zu versorgende Hardware steht und hat DC-Eingänge). Verwandt: **TP18** (Garage-Nodes brauchen je Site eine Stromversorgung), **TP11** (USV-Zustand ins Monitoring). **Kein Blocker für TP5/TP6.**
|
- **Abh.:** keine harten. **TP15 ✅** (die zu versorgende Hardware steht und hat DC-Eingänge). Verwandt: **TP18** (Garage-Nodes brauchen je Site eine Stromversorgung), **TP11** (USV-Zustand ins Monitoring). **Kein Blocker für TP5/TP6.**
|
||||||
- **Historie:** war bis 2026-08-16 Unterpunkt von TP15 („🔋 DC-USV: MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt"). Vollständige Recherche inkl. Schaltbild, Effizienzvergleich und Gerätetabelle: `docs/hw-refresh-requirements.md §9` (+ §10 D/E/F für die offenen Fragen).
|
- **Historie:** war bis 2026-08-16 Unterpunkt von TP15 („🔋 DC-USV: MeanWell LRS-150-48 + DDR-120-48 + LiFePO4 20Ah (~€190), 48V-Bus; CRS326 DC-input passt"). Vollständige Recherche inkl. Schaltbild, Effizienzvergleich und Gerätetabelle: `docs/hw-refresh-requirements.md §9` (+ §10 D/E/F für die offenen Fragen).
|
||||||
|
|
||||||
### TP25 — WLAN-Stabilisierung ▸ ⏳ **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) — **offen bleibt nur: ap2 unterperformt (2 m/−80 dBm, 23:02-Befund) als eigener In-situ-Unterpunkt** · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Offen nur noch die Umsetzung, wenn der dritte AP kommt. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
|
### TP25 — WLAN-Stabilisierung ▸ ⏳ **ap2-UNTERPUNKT ✅ WIDERLEGT 2026-09-14 (In-situ, [Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)): ap2 sendet auf 2 m mit −49…−51 dBm, stabil, 0 Roams — Hardware sauber, kein Tausch begründet. Terrasse = 5-GHz-Zellkante (−76…−81 vs. 2,4 GHz −65…−73), Thrash nur in der Band-Überlappungszone −70…−77. Hebel „Access-List-Band-Boden" in der Machbarkeitsprüfung GESCHEITERT (kein `band`-Property, cap-wifiN-Rejoin-Falle) — s. Block unten** — **FUND E ✅ GESCHLOSSEN 2026-09-02: die Abschlussmessung ist ruhig (4 reauth in 8,6 h, 0 Ausfälle, Verlauf 218→40→4) ⇒ `7.24.1` (TP27) war die Ursache, nicht `ft-over-ds`. Hebel 1 widerlegt+zurückgebaut, Hebel 2–4 entfallen, kein Geräte-Write** ([Handoff](handoff-20260902-tp25-fund-e-messung.md)) · Kern erledigt: 🟢 **(2026-08-22) Dead Spot Terrasse repariert** (43 % → 9 % Verlust, 20,4 s → 1,0 s). **Fund F ✅ aufgelöst (08-24, geräteseitig — kein Netzfehler)**, **5-GHz-Umfeldscan ✅ (08-24)**, Kanalplan für AP 3 ✅ gerechnet. Nicht mehr kritischer Pfad — Sammel-TP für Christophs Beobachtungen 1–6
|
||||||
> 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen
|
> 📚 **Kondensiert am 2026-08-23 (Doku-Session, Punkt F).** Dieser Abschnitt war mit 394 Zeilen
|
||||||
> 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv:
|
> 18 % der Datei. Die **Langfassung steht vollständig und unverändert** im Archiv:
|
||||||
> [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
|
> [`teilprojekte-archiv.md` → TP25 (Langfassung)](teilprojekte-archiv.md#tp25-lang) — Messchronik (A)–(G),
|
||||||
@@ -1087,6 +1108,79 @@ des Dimmers (= Lichtausfall in Diele UG/Wohnzimmer) wurde daraufhin **nicht** au
|
|||||||
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
|
> Merkregeln.** Wer an TP25 weiterarbeitet, kommt mit diesem Abschnitt aus; wer wissen will
|
||||||
> *warum* etwas so entschieden wurde, liest die Langfassung.
|
> *warum* etwas so entschieden wurde, liest die Langfassung.
|
||||||
|
|
||||||
|
**Stand 2026-09-15 — AP-Zuordnung DEFINITIV geklärt, Tausch-Befund ap2↔ap3** ([Handoff](handoff-20260915-tp25-zuordnung-und-redundanz.md)). Positionen per IP+MAC+Switch-Port (MNDP/Bridge-Host/Port-Link) live belegt: **ap1 = Sideboard/Wohnzimmer (`core ether3`), ap2 = Technik UG (`core ether21`), ap3 = Technik OG (`branch ether24`)**. Der geplante Tausch (ap1↔ap3, cAP ax → Sideboard) hat faktisch **ap2↔ap3** gedreht — ap1 blieb stehen, der cAP ax steht jetzt in OG. MAC-Korrektur: ap2 `ether1` ist `:39`, nicht `:3A` (`:3A` = Bridge/Mgmt-MAC). Rundgang (natürlicher Zustand, 1-s-Takt): **0 Disconnects**, −45…−77 dBm, Roaming über alle 3 APs; **Terrasse −58 stabil** (jetzt cAP ax/OG, 2,4G), ap1 nur kurz gestreift. ap1-Aus-Test: Main-Client voll abgedeckt (tiefster Wert −80), **alle 27 IOT kehren zurück**, aber ~4 Wohnzimmer-IOT marginal −77…−80 ⇒ ap1 technisch redundant, nicht ausfallkritisch. ap1 wieder an (Revert). Offen: (1) ap1 behalten/abbauen, (2) Tausch auf Plan korrigieren (cAP ax → Sideboard?), (3) Drei-Räume-Schatten (Schlafzimmer/Bad/Küche) gezielt nachmessen — noch nicht verifiziert, (4) Doku-Drift (diese Session).
|
||||||
|
|
||||||
|
**Stand 2026-09-14, 20:05 — ap2-In-situ: Hardware entlastet, Terrasse = 5-GHz-Zellkante,
|
||||||
|
Band-Boden nicht sauber ausdrückbar** ([Handoff](handoff-20260914-tp25-ap2-insitu-terrassse.md)).
|
||||||
|
Live-Session auf der Terrasse (Christoph kooperierte physisch — der Test, der am 08-31 „nachts
|
||||||
|
nicht möglich" war): das Fund-E-iPhone flappte 18:53–19:18 ~30×, fast ausschließlich
|
||||||
|
**ap2-5G ↔ ap2-2,4G** (−68…−81), mit 3 Abwürfen (2 Call-Aussetzer). Vor ap2 (1–2 m): **−49…−51 dBm,
|
||||||
|
>2 min stabil, 0 Roams** ⇒ der offene Verdacht „ap2 RF/Antenne/HW" (23:02-Befund, 2 m/−80) ist
|
||||||
|
**in situ widerlegt** — der 23:02-Wert reproduziert sich seit dem 09-04-Kanalplan (5300/5320,
|
||||||
|
tx-power 18 live) nicht mehr. Am Terrassenplatz ist **2,4 GHz klar besser** (−65…−73 vs. 5 GHz
|
||||||
|
−76…−81) und das Telefon dort **ruhig** (36 s im 1-s-Takt, 0 Roams) — es thrasht nur in der
|
||||||
|
Überlappungszone −70…−77 beider Bänder; zusätzlich client-getrieben (floh 19:44 selbst von −52
|
||||||
|
auf 2,4/−77; Steering hat keine Schwellen, der AP drängt nichts). Mit `ft=no` ist jeder Roam ein
|
||||||
|
spürbarer Voll-Reconnect ⇒ so sterben Anrufe. **Hebel „Access-List-Boden < −76 auf 5G":
|
||||||
|
Machbarkeitsprüfung negativ** — `/interface/wifi/access-list` hat kein `band`-Property,
|
||||||
|
Interface-Scoping bricht am cap-wifiN-Rejoin, SSID-/MAC-Scope träfen 2,4 GHz mit (Schlafzimmer
|
||||||
|
−84 ⇒ Lockout). **Nicht umgesetzt.** Verbleibende Hebel: (1) ✅ **DURCH 2026-09-14, 20:30 Uhr** — `ft=yes ft-over-ds=yes`
|
||||||
|
uniform auf `sec-main` (Ruecknahme der 09-04-Entscheidung; Go per Fragenrunde nach Dry-Run). Weg:
|
||||||
|
Template-Edit + `push-gc-template.py` (GC-Commit `744a17e3`) → `--refresh-compliance` (Drift
|
||||||
|
`wifi-security` wie erwartet) → `gc-manual-plan.py --deploy` (Plan `b1135f3e`, Completed, Asserts gruen;
|
||||||
|
ein Polling-Timeout am Skript war folgenlos — Geraet stand bereits auf ft=yes) → Refresh: **140/140, 0
|
||||||
|
Drifts**. CAP-Abnahme: **keine** Inline-Overrides auf ap1/ap2/ap3. Commands-File:
|
||||||
|
[`docs/transaktionen/TP25-ft-yes-secmain.commands.rsc`](transaktionen/TP25-ft-yes-secmain.commands.rsc).
|
||||||
|
Abnahme ✅ **DURCH (2026-09-14, 20:33–20:34 Uhr, live)**: Anruf auf der Terrasse ueberstand
|
||||||
|
**fuenf Band-Wechsel binnen 60 s** (nur `roamed to`, keine `disconnected` mehr im Fenster —
|
||||||
|
Christoph: „call steht"). (2) Deckungsfix
|
||||||
|
Variante 3 (ap1-5G erreichte die Terrasse mit −64) — beendet das Springen selbst.
|
||||||
|
Nachtrag 20:37: ein **Totalverlust** anderer Klasse (Roam auf ap1-2,4 bei −89, danach iOS-Aufgeben
|
||||||
|
ohne jeden Reconnect-Versuch; identisch schon 19:01 mit ft=no gesehen) — **keine ft=yes-Regression**,
|
||||||
|
sondern der strukturelle Rest. **Wechselrichter/DTU (Hoymiles HMS-800W-2T, geht mit der Sonne an/ab,
|
||||||
|
09-14: 07:09–19:35): nachts unbeteiligt; tagsüber ~5:05-Zyklus (connect/`connection lost`/reconnect +
|
||||||
|
DHCP-Churn) auf ap2-2,4/ch11 bei −53.** Messplan „DTU vs. Terrasse" angelegt ([Issue #17, Kommentar
|
||||||
|
#225](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-225)): Referenz-Client auf
|
||||||
|
ch11 pinnen (tp25-Methodik), 30–60 min Ping, Korrelation gegen die 5:05-Taktmarken. ⚠️ **Korrektur
|
||||||
|
09-14 abends ([Kommentar #227](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-227)):
|
||||||
|
Der urspruenglich genannte Hebel „OpenDTU/AhoyDTU-Dongle" existiert fuer DASSES Geraet NICHT** — HMS-
|
||||||
|
**XXXXW**-2T (W = eingebaute DTU „DTUBI", protobuf-Protokoll) wird von AhoyDTU/OpenDTU nicht
|
||||||
|
unterstuetzt (lumapu/ahoy#1189 seit 09-2023 offen; Christophs eigene AhoyDTU nachweislich
|
||||||
|
inkompatibel; nur die Nicht-W-Variante HMS-800-**2T** ginge per CMT2300-Funk). Die lokale Ebene
|
||||||
|
(hms-mqtt-publisher = „hmsmqtt", dtuGateway, ha-hoymiles-wifi) redet **mit der internen DTU uebers
|
||||||
|
Netzwerk** — keines davon nimmt die DTU von der Luft, hmsmqtt pollt sogar zusaetzlich. **Einziger
|
||||||
|
Airtime-Hebel bleibt DNS-Hoheit ueber die Cloud**, von Christoph bewusst zurueckgestellt (Cloud-
|
||||||
|
Aufzeichnungen noch benoetigt, bis hmsmqtt + WLAN stabil); ob eine DTU ohne Cloud ruhiger oder
|
||||||
|
gespraechiger wird, ist selbst eine offene Messfrage. Pin auf anderen AP kontraindiziert
|
||||||
|
(−53 direkt an ap2); 5-Min-Takt ist S-Miles-Telemetrie per Design, kein Ethernet an der DTU-WLite.
|
||||||
|
✅ **Netzwerk-Mechanismus geklaert (09-14, Nacht; [Issue #17, Kommentar
|
||||||
|
#228](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-228)):** Der **Fund-F-5:05-Zyklus
|
||||||
|
ist das Cloud-Recovery-Verhalten der DTU.** Wireshark-Analyse (hms-mqtt-publisher PR #110, HMS-800W-2T):
|
||||||
|
lokaler Poll <30 s resettet den Countdown und setzt Cloud-Updates aus; **nach 4–5 min erfolgloser
|
||||||
|
Cloud-Updates** startet die DTU eine neue TCP-Session — und dabei (unser Log-Beweis 09-14) eine
|
||||||
|
**vollstaendige WiFi-Disassoziation inkl. DHCP-Churn**. Die heute gemessenen Zyklus-Abstaende decken
|
||||||
|
sich mit dem Befund aufs Minutenkomma. Ursache war hmsmqtt mit **30 500 ms** (Grenzzone);
|
||||||
|
DTU-Firmware aelter, hmsmqtt funktioniert (⇒ #134-Key-Rotation-Falle nicht primaer).
|
||||||
|
**Hebel = Poll-Rate, nicht DNS-Hoheit:** Christoph hat **30500 → 45000 ms** gesetzt (09-14 abends;
|
||||||
|
Community-Wert, 10 Monate stabil). ✅ **Verifikation DURCH (09-15, 12:11; [Issue #17, Kommentar
|
||||||
|
#230](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-230)): DTU online seit 06:41:49,
|
||||||
|
uptime 5 h 29 min, 0 Disconnects** (Vergleich 09-14 mit 30,5 s: fuenf Zyklen in den ersten 18 min).
|
||||||
|
Die DTU sitzt heute auf **ap3-2,4** (cap-wifi4-virtual1, −49; cap-Karte unveraendert, Attribution
|
||||||
|
belastbar) — also aktuell auch vom Terrassen-Band ch11 weg. **Fund F ist damit abgeschlossen** —
|
||||||
|
Ursache war die DTU-Cloud-Recovery, getriggert durch hmsmqtt-Polling-Kollision. Messplan #225 ist
|
||||||
|
gegenstandslos (der zu messende Effekt existiert nicht mehr); eine ch11-Tagzeitmessung bleibt nur
|
||||||
|
sinnvoll, falls die Terrasse tagsueber weiterhin Call-Probleme zeigt. 📅 **terminiert ([Issue #17,
|
||||||
|
Kommentar #232](https://gitea.emsenhuber.net/admin/bf-infra/issues/17#issuecomment-232)): Deckungsfix
|
||||||
|
Variante 3 — Umsetzung 2026-09-15/16** (Christoph: „heute oder morgen"): Deckenstrahler an den guten
|
||||||
|
Platz, verdrängter hAP in den Drei-Raeume-Schatten (Schlafzimmer −84,6/38 %, Bad −82,3/24 %,
|
||||||
|
Kueche −82,0/23 % laut 08-22-Messung). Vorher-Messung Terrasse (Referenz 09-14: 5 GHz −76…−81,
|
||||||
|
2,4 GHz −65…−73) → Umsetzung → Nachher-Messung + Anruf-Abnahme; Kanalplan (09-04) bleibt unberuehrt,
|
||||||
|
nur Platzierung aendert sich; Config-Aenderungen nur ueber den GC-Weg. Offen vor Ort: Zielraum fuer
|
||||||
|
den verdraengten hAP (PoE/Steckdose) und ap1-Uplink-Thema (Sideboard → `core ether3`) mitdenken.
|
||||||
|
Randnotizen: ap1 „FUNKSTILL" im CLAUDE.md ist überholt (dient live, Radar-Event 16:51 auf 5280
|
||||||
|
sauber abgehandelt); Template-Abschnitt [6c] („Fund E WIEDER OFFEN", Stand 08-30) ist inhaltlich
|
||||||
|
überholt — beim nächsten Template-Edit mit aktualisieren, kein separater Edit nur für den Kommentar.
|
||||||
|
|
||||||
**Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken
|
**Stand 2026-08-22, 23:30 — die Terrasse ist repariert.** Erster Lauf mit beiden Ping-Strecken
|
||||||
([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust /
|
([`tp25-runde-20260822-2330-auswertung.md`](tp25-runde-20260822-2330-auswertung.md)): **43 % Verlust /
|
||||||
20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
|
20,4 s längste Lücke (08-21) → 9 % / 1,0 s** bei gleichem Gerät und gleicher Rolle. Wirksam war das
|
||||||
@@ -1783,6 +1877,7 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
|
|||||||
| 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste |
|
| 6 | **MariaDB `root`** (LXC 302, `10.40.20.23`) | root-DB-Login | 🔴 am 2026-09-01 (TP39) ins Transkript geraten — die `/root/.my.cnf` trug `password <wert>` **ohne `=`**, die Feld-Maskierung ankerte auf `=` und griff nicht | Wert in `/root/.my.cnf` (chmod 600) | `SET PASSWORD` + `.my.cnf` nachziehen; **kein** externer Konsument (Dienste nutzen eigene DB-User) ⇒ billigste Rotation der Liste |
|
||||||
| 7 | **`TERPENE_ANDROID_KEYSTORE_PW`** | Terpene-Android-TWA-Signing (`android/android.keystore`, alias `terpene`) | 🔴 am 2026-09-01 (TP41) durch **Regex-Suffix-Loch** (`_PW` nicht abgedeckt) ins Transkript geraten | Wert in `~/.secrets/credentials.env` + Keystore-Datei | **Store-/Key-Passwort** via `keytool -storepasswd`/`-keypasswd` ändern + `credentials.env` nachziehen; Konsument = Android-Build/Play-Signing (**Cross-Projekt blaueblüten**) — den **Key selbst NICHT** neu erzeugen |
|
| 7 | **`TERPENE_ANDROID_KEYSTORE_PW`** | Terpene-Android-TWA-Signing (`android/android.keystore`, alias `terpene`) | 🔴 am 2026-09-01 (TP41) durch **Regex-Suffix-Loch** (`_PW` nicht abgedeckt) ins Transkript geraten | Wert in `~/.secrets/credentials.env` + Keystore-Datei | **Store-/Key-Passwort** via `keytool -storepasswd`/`-keypasswd` ändern + `credentials.env` nachziehen; Konsument = Android-Build/Play-Signing (**Cross-Projekt blaueblüten**) — den **Key selbst NICHT** neu erzeugen |
|
||||||
| 8 | **`N8N_SMTP_PASS`** (Gmail-App-PW für `johnny.claw.0x00@gmail.com`) | SMTP-Versand aus n8n (Portainer-Stack 174 `n8n`, macmini) | 🔴 am 2026-09-01 (n8n-mcp-SSRF-Session) beim Ausgeben des Stack-174-Files ins Transkript geraten — Redaktionsmuster hatte `PASSWORD`, aber **nicht bare `PASS`** → `N8N_SMTP_PASS=<wert>` blieb stehen | Wert in Portainer-Stack 174 (`environment`) | **Gmail-App-Passwort widerrufen** (Google-Konto → App-Passwörter) + neues erzeugen, Stack-174-Env nachziehen + redeploy; Konsument = n8n-SMTP-Node(s) |
|
| 8 | **`N8N_SMTP_PASS`** (Gmail-App-PW für `johnny.claw.0x00@gmail.com`) | SMTP-Versand aus n8n (Portainer-Stack 174 `n8n`, macmini) | 🔴 am 2026-09-01 (n8n-mcp-SSRF-Session) beim Ausgeben des Stack-174-Files ins Transkript geraten — Redaktionsmuster hatte `PASSWORD`, aber **nicht bare `PASS`** → `N8N_SMTP_PASS=<wert>` blieb stehen | Wert in Portainer-Stack 174 (`environment`) | **Gmail-App-Passwort widerrufen** (Google-Konto → App-Passwörter) + neues erzeugen, Stack-174-Env nachziehen + redeploy; Konsument = n8n-SMTP-Node(s) |
|
||||||
|
| 9 | **`GITEA_ACCESS_TOKEN`** (API-Zugang `gitea.emsenhuber.net`) | Session-Tooling (`bin/gitea-tp-sync.py`, Ad-hoc-API-Calls) | 🔴 am 2026-09-14 (TP25-In-situ-Session) **ins Transkript** geraten: Env-Parse-Fehler schob den Token in die URL-Konstruktion, `urllib` warf `ValueError: unknown url type` und druckte ihn im Traceback | Mechanismus zu: Token gehört **nur in den `Authorization`-Header, nie in eine URL** — und der Fehlerpfad, der ihn enthält, darf ihn nicht ausgeben (gleiche Familie wie #1/#5/#6/#7/#8, diesmal Unterklasse „Fehlerausgabe statt Maskierung") | neu anlegen (Gitea → Settings → Applications), `~/.hermes/.env` nachziehen, altes widerrufen; Konsumenten = `gitea-tp-sync.py` + Session-Ad-hoc-Calls |
|
||||||
|
|
||||||
> ✅ **Der Leak-MECHANISMUS hinter #1 ist seit 2026-08-31 zu — der Wert bleibt trotzdem verbrannt.**
|
> ✅ **Der Leak-MECHANISMUS hinter #1 ist seit 2026-08-31 zu — der Wert bleibt trotzdem verbrannt.**
|
||||||
> Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und
|
> Alle sechs Skripte im Repo, die `sshpass -p <PW>` benutzten (`nautobot/gc-transaktion.py` und
|
||||||
@@ -1816,6 +1911,15 @@ Umzug **nebeneinander**, ein zweiter AP am selben Punkt bringt nichts und stört
|
|||||||
> Mal neu (und falsch) tippt: `sed -E 's/([A-Z0-9_]*(PASS|PASSWORD|PWD|PW|TOKEN|SECRET|KEY|BEARER))=[^[:space:]]*/\1=<redacted>/g'`
|
> Mal neu (und falsch) tippt: `sed -E 's/([A-Z0-9_]*(PASS|PASSWORD|PWD|PW|TOKEN|SECRET|KEY|BEARER))=[^[:space:]]*/\1=<redacted>/g'`
|
||||||
> — ankert auf den **Schlüsselnamen inkl. Präfix** und deckt alle bisher gesehenen Suffixe ab.
|
> — ankert auf den **Schlüsselnamen inkl. Präfix** und deckt alle bisher gesehenen Suffixe ab.
|
||||||
>
|
>
|
||||||
|
> 🔴 **#9 ist der sechste Vorfall derselben Familie, aber eine neue Unterklasse** (SHELLY 08-28,
|
||||||
|
> Nautobot-Tokens 08-31, MariaDB-root 09-01, Keystore 09-01, SMTP 09-01): diesmal kein
|
||||||
|
> Maskierungsmuster, sondern ein **Fehlerpfad** — ein Env-Parse-Fehler schob den Token in die
|
||||||
|
> URL-Konstruktion, und `urllib` druckte ihn im `ValueError: unknown url type`-Traceback.
|
||||||
|
> **Merkregel: ein Secret gehört in keine Datenstruktur, die eine Exception ausgeben kann**
|
||||||
|
> (URL-Konstruktion validieren, bevor das Secret hineinwandert; Fehlerpfade dürfen den
|
||||||
|
> fehlerhaften Wert nicht mitschleppen). Das Redaktions-Snippet oben hilft hier nicht — es
|
||||||
|
> kann nichts maskieren, was als Fehlermeldung und nicht als Dateiinhalt kommt.
|
||||||
|
>
|
||||||
> ℹ️ **#3 war bis 2026-08-24 TP17.2** und ist jetzt vollständig hier (s. Detailabschnitt unten) —
|
> ℹ️ **#3 war bis 2026-08-24 TP17.2** und ist jetzt vollständig hier (s. Detailabschnitt unten) —
|
||||||
> es benutzt denselben Geräte-Durchgang wie #2, **ein Reboot statt zwei**. #4 gehört sachlich zu
|
> es benutzt denselben Geräte-Durchgang wie #2, **ein Reboot statt zwei**. #4 gehört sachlich zu
|
||||||
> **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token).
|
> **TP14.4**, das als TP bestehen bleibt (dort steckt das CF-Access-Profil, nicht nur der Token).
|
||||||
@@ -2354,26 +2458,15 @@ kann in Ruhe nachziehen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TP34 — Caddyfile via Nautobot/GC (generische Reverse-Proxy-Schnittstelle) ▸ 🔜 STUB
|
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ✅ **ABGESCHLOSSEN 2026-09-12** — 5/6 Schichten durch (MCP-Schichtung FQDN→IP, Credentials-`{env:}`-Flip angewandt+verifiziert, globale CLAUDE.md-Schicht, `/news`, Backup-Guard, `/projektstatus`-Skill). Schicht-3-**pro-Projekt**-Rollout laut Abgrenzung eigene Session je Projekt (kein Blocker). Einziger Rest = On-Network-mikromcp-Smoketest (off-site nicht machbar, VLAN99:80 timeout, non-blocking) → als Merkposten nach TP30. Gitea #25 closed/`status:done`.
|
||||||
> **Konzept + Architektur-Skizze:** [`docs/handoff-20260827-inventur-und-architektur.md`](handoff-20260827-inventur-und-architektur.md) — bitte dort weiterlesen (Architektur-Abschnitt).
|
|
||||||
- **Scope:** Reverse-Proxy-Hosts werden im **Nautobot-Datenmodell** gepflegt (Site, Host, Backend-Spec). **Golden Config** rendert den Caddyfile aus den Daten, Diff-Check gegen die Live-Version auf `caddy-bf1`, Compliance-Checks + Transaktionen identisch zum bestehenden Workflow für Router-Configs.
|
|
||||||
- **Auslöser (Inventur 2026-08-27):** der Caddyfile (`bf-infra/caddy/Caddyfile`) ist heute **manuell gepflegt** — kein Drift-Check, keine Transaktionen, andere Projekte können keine Sites anlegen ohne Christoph. Lösung: **Nautobot/GC**, kein Caddy-API-Sidecar (kein zweites SSoT).
|
|
||||||
- **Erster Anwendungsfall:** `terpene.teilzeitladen.xyz → http://10.40.20.167:8890` (Mac-mini-Backend, dort PostgREST + sync + web via lokalem Caddy `:8890`). Vor TP34: manueller Quick-Win-Caddy-Block.
|
|
||||||
- **Abh.:** TP22 (GC-Pipeline) ✅ · TP23 (DNS/Caddy-Voraussetzungen) ✅ Stufe 1 · 🔴 **NEU 2026-09-06: TP37 Punkt 1+2** — Caddy rendert *Config aus Daten*, die Backend-Dienste müssen als `ipam.Service` in Nautobot existieren, sonst gibt es nichts zu rendern. **TP37 vor TP34.**
|
|
||||||
- **Datenmodell (entschieden 2026-09-06):** native Modelle — Backend = `ipam.Service`, Proxy-Route = Relationship/CustomField Frontend↔Backend, GC-Job rendert den Caddyfile. Keine eigene App (konsistent zu TP44).
|
|
||||||
- **Status:** 🔜 Stub; wartet auf TP37 P1+2. Quick-Win `terpene.teilzeitladen.xyz → 10.40.20.167:8890` als manueller Caddy-Block ist **Schritt 0** des Runbooks. Plan: [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 6 (Skill) ✅**
|
|
||||||
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
|
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
|
||||||
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
|
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
|
||||||
- **Scope:**
|
- **Scope:**
|
||||||
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
|
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
|
||||||
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab.
|
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. **✅ 2026-09-12 angewandt:** `exports.zsh` um die 3 fehlenden `MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS` ergänzt (jetzt 25 Vars, die opencode schon referenzierte — vorher liefen die dort leer), `gui-env-sync` gelaufen (25/25 in launchd-Env). `~/.claude.json` via `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude` (bei beendeter App, Backup `.bak-20260912-*`) geflippt — **alle 7 Secrets** (`mikromcp` 5× PASS, `hostinger-api` Token, `n8n-mcp` Bearer) jetzt `{env:}`, verifiziert; App neu gestartet, MCP-Server laden sauber. 🔴 **Merkregel:** `~/.claude.json` nie aus laufender Session editieren (Claude Code schreibt dort State → überschreibt). Restcheck offen: **authentifizierter** mikromcp-Read **on-network** (am 09-12 Connect-Timeout VLAN99:80 = Erreichbarkeit vom Standort, kein Auth-Fehler). Rollback = `.bak` zurück.
|
||||||
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
|
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
|
||||||
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort (voraussichtlich `bf-infra/docs/announcements.md`) + Skill `/news` oder `/announce` zum Einlesen beim Session-Start. Initial: Breaking News über die MCP-Schichtung selbst.
|
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort + Skill zum Einlesen beim Session-Start. **✅ 2026-09-12:** `docs/announcements.md` angelegt (Format: `## YYYY-MM-DD — Titel` + `Betrifft:`/`Aktion:`, neueste oben) + globaler Skill **`/news`** (in beiden Skill-Pfaden `~/.claude/skills/news/` **und** `~/.config/opencode/skills/news/`, `cp` kein Symlink — Schicht-6-Lehre). `/news` zeigt Einträge, die seit dem letzten Lauf dazukamen (Seen-State pro Repo unter `~/.news-seen/`). Erste Einträge: SSoT-Cutover (TP36) + TP36-Live.
|
||||||
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff.
|
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. **✅ 2026-09-12 abgenommen:** Guard (112 Z., `check`/`mark`/`status`) + `.git/hooks/pre-commit` in `bf-infra` live verdrahtet; der Hook hat beim TP36-Live-Commit real gegriffen („Projekt aktiv 12 Commits/198 LOC, Backup markiert"). ⚠️ **Bekannte Grenze:** `check` prüft nur *Existenz* der Guard-Datei, nicht deren Alter — ein alter `mark` (hier 2026-08-29) lässt den Commit durch. Das ist als Push-Erinnerung gewollt, keine Frische-Garantie.
|
||||||
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
|
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
|
||||||
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
|
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
|
||||||
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
|
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
|
||||||
@@ -2512,157 +2605,6 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
|
|||||||
- 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die
|
- 🔴 **Kein eigenmächtiger Datenlöschvorgang** — `purge` wirft echte Historie weg; die
|
||||||
Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen.
|
Aufbewahrungsdauer ist eine Nutzerentscheidung. Diese Session hat **nur** vergrössert und gemessen.
|
||||||
|
|
||||||
### TP41 — CF-Access-Reparatur + Zero-Trust-Konsolidierung ▸ ⏸️ **ZURÜCKGESTELLT 2026-09-01 (User-Entscheid)** — Auth-Kern durch; offen nur Gelis WARP-Client (nicht aus der Ferne machbar), User gibt ggf. später Info
|
|
||||||
|
|
||||||
**Auslöser:** CF-Access zwang Christoph **und Geli** täglich zum OTP-Login (soll: 30 Tage); GitHub/Google
|
|
||||||
„funktionieren nicht". Root-Causes: Team-Domain-Rename `bf4`→`emsenhuber` (Provider-Callback veraltet),
|
|
||||||
App-Session 24h, diverse Ordnungs-/Sicherheitslecks. Vollständiger Verlauf + offene Schritte:
|
|
||||||
**[`docs/handoff-20260901-tp41.md`](handoff-20260901-tp41.md)**. Branch `tp41-cf-access-repair`.
|
|
||||||
|
|
||||||
**✅ Live erledigt:** Session 720h (Wildcard/vw/Warp-Login) · login_method-Loch zu (reusable Policy
|
|
||||||
`bf-users` = nur `email:christoph`+`email:geli`) · `8e44f5d1` von 4 Apps auf **nur Terpenfinder**
|
|
||||||
entkoppelt · Access-**Gruppen** `zt-humans`/`zt-home`/`zt-automation`, Policies zeigen darauf · **vw voll
|
|
||||||
Bypass** (Lebensader offen) + **`vw/admin` per WAF-Regel öffentlich geblockt** (CF-Edge + Mobilfunk
|
|
||||||
verifiziert) · API-Token auf das vollständige `…4148` konsolidiert (User hat Service-Tokens- + Zero-Trust-
|
|
||||||
Write ergänzt), altes `…2174` revoken.
|
|
||||||
|
|
||||||
**✅ T4 DURCH (2026-09-01, Variante B):** ein Access-Service-Token **`zt-svc`** (`65f1821a`, rotiert) über
|
|
||||||
Gruppe `zt-automation`; Terpene-Worker per `wrangler secret put TERPENE_SVC_CLIENT_ID` auf zt-svc
|
|
||||||
umgestellt (Worker-Pin `index.js:44`); `terpene-verify`+`-v2` **gelöscht**; `test_whoami` grün.
|
|
||||||
**Token-Altlast geklärt:** `b00722` = LIVE Caddy-DNS-Token (LXC130 caddy.env, SSoT), `d47435` = stale
|
|
||||||
Kopie (kein Consumer) → **User revoket `d47435`**; `credentials.env`-Kopie auf b00722 realignt.
|
|
||||||
|
|
||||||
**✅ T3 APPLIED (2026-09-01):** Default-Profil (`0197d1b3`; das custom „Cloudflare Mesh" matcht nur den
|
|
||||||
`warp_connector`) `fallback_domains += emsenhuber.net → [10.40.20.36, 10.40.20.37]`; Tunnel-Route
|
|
||||||
`10.40.0.0/16` (`d1da7c8a`) da, kein `10/8`-Exclude → routbar. ⏳ Client-Verify: WARP neu verbinden.
|
|
||||||
**✅ T2 DURCH (2026-09-01):** WAF-Regel `f26744eb` gelöscht; Caddy `vw`-Block `respond @cf_admin 404` bei
|
|
||||||
Header `CF-Connecting-IP` (LXC130). Verify: Edge `/admin`=404, lokal/WARP `/admin`=200. ✅
|
|
||||||
|
|
||||||
**✅ T5 DURCH (2026-09-01, User-bestätigt):** GitHub-Login läuft; Google-Login läuft nach dem
|
|
||||||
Consent-Screen-Fix (§A des Follow-up-Handoffs: User Type *Internal*→*External*/veröffentlicht).
|
|
||||||
Callback für beide `https://emsenhuber.cloudflareaccess.com/cdn-cgi/access/callback`.
|
|
||||||
**❌ Facebook/Meta für TP41 abgesagt (2026-09-01, User-Entscheid):** optional, kein Bedarf — Geli ist
|
|
||||||
über OTP `angelikahy@outlook.de` + Google `pillepalle670@gmail.com` voll gedeckt. **Keine Facebook-App,
|
|
||||||
kein CF-Facebook-IdP angelegt → nichts aufzuräumen.** Instagram hat ohnehin keinen CF-IdP.
|
|
||||||
**✅ App-Launcher (D1):** je eine Bookmark-Kachel vw/hass/terpenfinder, Wildcard-Kachel raus, Launcher
|
|
||||||
gestylt (Follow-up-Handoff §D, Policies unverändert).
|
|
||||||
|
|
||||||
**✅ Geli in WARP — CF-seitig nichts zu tun (2026-09-01 live geprüft):** Device-Enrollment läuft über
|
|
||||||
die App **„Warp Login App"** (`6c32fe23…`, type `warp`) → Policy `bf-users` → Gruppe **`zt-humans`**, die
|
|
||||||
Gelis beide Mails **schon enthält** → sie **darf bereits enrollen** (deshalb war „Device enrollment
|
|
||||||
permissions" nicht als fehlendes Setting zu finden). Terpenfinder-`geli`-Policy lässt sie per E-Mail durch.
|
|
||||||
⇒ verbleibt **nur ein Handgriff auf Gelis Gerät** (WARP installieren, Team `emsenhuber`, Login OTP
|
|
||||||
`angelikahy@outlook.de` / Google `pillepalle670@gmail.com`) — nicht aus der Ferne machbar.
|
|
||||||
🔑 **Permission-Frage beantwortet:** CF-seitig **keine** nötig. Für generelle WARP/Enrollment/User-Verwaltung
|
|
||||||
per Account-Token: *Access: Apps and Policies → Edit* + *Access: Organizations, IdPs, and Groups → Edit*
|
|
||||||
(User erteilt 2026-09-01). **Seat-Verwaltung (`/access/seats`) bleibt per MCP gesperrt** — `10405 Method
|
|
||||||
not allowed for this authentication scheme` (Endpoint will User-Token, nicht Account-Token; kein Scope-Fix,
|
|
||||||
für Enroll irrelevant).
|
|
||||||
|
|
||||||
**🔜 Offen — nur noch außerhalb CF:** **Gelis WARP-Client** auf ihrem Gerät (s.o.) · **T6** optional
|
|
||||||
Rate-Limit · 🔴 `TERPENE_ANDROID_KEYSTORE_PW`-Rotation erwägen (TP30 Posten #7, Transkript-Leak).
|
|
||||||
**d47435-Revoke ✅ · T3-WARP-Client-Verify ✅** (User, 2026-09-01).
|
|
||||||
|
|
||||||
**🔻 Optionaler Security-Cleanup (eigener Punkt, nicht in dieser Runde):** die `geli`-Terpenfinder-Policy
|
|
||||||
hat **OR-Semantik** und enthält den breiten CGNAT-Bereich `94.154.138.0/24` als IP-Include → jeder aus
|
|
||||||
dieser geteilten Range käme **ohne Login** rein. Über WARP ist der Egress ohnehin Cloudflare (nicht diese
|
|
||||||
IP) → der IP-Include wäre dann überflüssig und entfernbar.
|
|
||||||
|
|
||||||
**Abgrenzung:** hass/gitea bleiben (diese Runde) öffentlich; Terpenfinder-Human-Policies nicht angefasst;
|
|
||||||
**Gitea-Least-Privilege** = eigener künftiger TP (jedes Projekt repo-scoped Login, Admin nur Claude/Christoph).
|
|
||||||
- Bezug **TP11** (Monitoring): eine Plattenfüllstand-Warnung hätte das Wochen früher gezeigt.
|
|
||||||
|
|
||||||
### TP42 — Namens-Vereinheitlichung bf.lan → emsenhuber.net ▸ 🔜 ANGELEGT 2026-09-01 (User-Okay erteilt)
|
|
||||||
|
|
||||||
**Auslöser (2026-09-01, TP41-Randbefund):** live gemessen liefern die realen Client-Resolver
|
|
||||||
`10.40.20.36`/`.37` `*.emsenhuber.net → 10.40.20.40` (Caddy) — korrekt. Daneben existiert aber ein
|
|
||||||
zweiter, gedrifteter interner Namensraum: der **Core** trägt in `/ip dns static` 11 Einträge auf
|
|
||||||
`*.bf.lan`, der **Caddyfile** doppelt jeden internen Namen als `*.bf.emsenhuber.net`, und die alten
|
|
||||||
AdGuard `.16`/`.17` antworten noch mit `nginx*.bf.emsenhuber.net` bzw. totem VLAN1 `10.40.41.123`.
|
|
||||||
Christoph: „aus emsenhuber.net wurde schon wieder bf.lan — das müssen wir ein für alle mal aus allen
|
|
||||||
Geräten rausprügeln und in Nautobot festhalten."
|
|
||||||
|
|
||||||
**Ziel:** genau **ein** interner Namensraum `emsenhuber.net` (Split-Horizon `.36/.37 → .40`). `.bf.lan`
|
|
||||||
und `.bf.emsenhuber.net` werden entfernt; Sollzustand in Nautobot dokumentiert.
|
|
||||||
|
|
||||||
**Scope (Reihenfolge = eigene Session):**
|
|
||||||
1. **Bestandsaufnahme** aller Fundstellen: Core `/ip dns static` (`*.bf.lan`), DHCP-Option
|
|
||||||
`domain`/Search-Domain je Netz, Caddyfile-Aliase (`*.bf.emsenhuber.net`), AdGuard-Rewrites `.36/.37`,
|
|
||||||
stale `.16/.17`, Geräte-`identity`.
|
|
||||||
2. **Entscheidung Zielschema** (nur `emsenhuber.net`, ggf. `bf.emsenhuber.net` als *einzige* interne
|
|
||||||
Zone — Christoph-Entscheid) und Migrationsreihenfolge ohne DNS-Loch.
|
|
||||||
3. **Umsetzen:** Core-DNS + DHCP + Caddyfile angleichen; stale `.16/.17` klären/abbauen.
|
|
||||||
4. **Nautobot:** interne Namen/Records als Sollzustand erfassen (Bezug TP37 Datenmodell).
|
|
||||||
|
|
||||||
**Abgrenzung:** kein CF-Public-DNS-Umbau (Zone `emsenhuber.net` öffentlich bleibt); kein Resolver-Swap
|
|
||||||
(`.36/.37` bleiben); `.1`-als-DNS-Reste auf Clients sind separat/tolerierbar (User: „bis jetzt kein
|
|
||||||
Problem"). **Abh.:** TP37 (Nautobot-Datenmodell) für Punkt 4; sonst nichts Offenes.
|
|
||||||
|
|
||||||
#### ✅ Bestandsaufnahme LIVE (2026-09-02) — Drift hat 80 % schon erledigt
|
|
||||||
Gegen die Geräte gemessen (MikroMCP + dig), nicht aus dem Handoff:
|
|
||||||
|
|
||||||
| Fundstelle | Handoff 09-01 | Live 09-02 |
|
|
||||||
|---|---|---|
|
|
||||||
| Core `/ip dns static` | 11× `*.bf.lan` | ✅ **0× `.bf.lan`** — 10× `*.bf.emsenhuber.net` (deckt sich mit GC-Template Z.284–293) |
|
|
||||||
| DHCP `domain` (7 Netze) | „prüfen" | `emsenhuber.net` (GC-Template Z.133 = gleich ⇒ **keine Drift**), DNS `.36/.37` |
|
|
||||||
| `.36/.37` (Soll) | aktiv | `vw.emsenhuber.net→.40` ✅ · `macmini.bf.emsenhuber.net→.20.167` ✅ · **`macmini.emsenhuber.net→leer`** ❌ |
|
|
||||||
| Caddyfile | `*.bf.emsenhuber.net`-Aliase | unverändert, **konsistent** mit Core-Static |
|
|
||||||
| `.16`/`.17` | tot | laufen (:53), liefern totes VLAN1; **kein Core-Objekt** (Firewall/NAT/DNS-Upstream/Addr-List) hängt an `.16` |
|
|
||||||
|
|
||||||
**Kernkonflikt:** DHCP verteilt `emsenhuber.net` (bare), Records leben unter `bf.emsenhuber.net` ⇒ bare `macmini` löst nicht.
|
|
||||||
|
|
||||||
#### ✅ Entscheidungen (User, 2026-09-02)
|
|
||||||
1. **Zielschema = `bf.emsenhuber.net`** (der wenig-Arbeit-Weg): DHCP-domain → `bf.emsenhuber.net`, dann
|
|
||||||
löst `macmini` bare korrekt; Core-Static + Caddyfile bleiben (tragen schon `.bf.`). Öffentliche Zone
|
|
||||||
`emsenhuber.net` bleibt getrennt.
|
|
||||||
2. **`.16` (adguard_bf1) abbauen**, **`.17` (adguard_fm2 = Standort FM) NICHT anfassen.**
|
|
||||||
|
|
||||||
#### ✅ Umsetzung DURCH (2026-09-02)
|
|
||||||
- **(A) DHCP-domain `emsenhuber.net → bf.emsenhuber.net` über GC ✅** — kein Direkt-Write:
|
|
||||||
`mikrotik_routeros.j2` Z.133 gehoben → `push-gc-template.py` (Commit `7a4068c4`, **Nebenbefund:** das
|
|
||||||
Remote-Template hing dem lokalen hinterher — es trug noch `*.bf.lan`+`router.lan`, obwohl Gerät+lokal
|
|
||||||
längst `.bf.emsenhuber.net` fuhren; der Push hat das GC-Repo an den Live-Stand angeglichen) →
|
|
||||||
GitRepo-Sync (SUCCESS) → manueller ConfigPlan **`tp42-dhcp-domain`** (`set [find] domain=…`, weil
|
|
||||||
`add` das Netz doppelt anlegen würde) → Deploy an **crs326-core** → Live **7/7 = bf.emsenhuber.net**,
|
|
||||||
Compliance **140/140, keine Drifts**. Der Job steht dauerhaft in `nautobot/gc-configplan.py`.
|
|
||||||
- **(B) `.16`-Teardown ✅** — `adguard_bf1` lag im geteilten Portainer-Stack **195 `adguard`** (Docker-LXC
|
|
||||||
`.126`, **ep2** — *nicht* ep14) **zusammen mit `adguard_fm2` (`.17`, Standort FM!)**. Deshalb **nicht**
|
|
||||||
Stack löschen, sondern compose auf nur `.17` getrimmt + Stack redeployt (Prune): `.16`-Container weg,
|
|
||||||
`.16:53` tot; **`.17` läuft** (⚠️ ~40 s mit-neugestartet durch den Redeploy — kurzer FM-DNS-Blip,
|
|
||||||
danach sauber). Rollback-compose gesichert. 🧹 Daten-Dirs `/opt/docker/adguard_bf1/*` bleiben als
|
|
||||||
harmlose Leichen auf dem LXC.
|
|
||||||
- **(C) Nautobot-Soll** (Scope-Punkt 4) bleibt an **TP37** gekoppelt (Datenmodell), separat — kein Blocker.
|
|
||||||
|
|
||||||
**Status:** ✅ **Namensraum vereinheitlicht (2026-09-02)** — ein interner Suffix `bf.emsenhuber.net`,
|
|
||||||
`.bf.lan` raus, `.16` abgebaut, Compliance 140/140. Offen nur der TP37-gekoppelte Nautobot-Doku-Teil.
|
|
||||||
Handoff: [`docs/handoff-20260901-tp42-naming.md`](handoff-20260901-tp42-naming.md).
|
|
||||||
|
|
||||||
### TP43 — dw-Standort-DNS-Uplink (site-spezifische Overrides) ▸ 🔜 ANGELEGT 2026-09-01 (User-Okay erteilt)
|
|
||||||
|
|
||||||
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
|
|
||||||
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
|
|
||||||
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
|
|
||||||
uns verfranzen. Geli kommt rein, das zählt."*
|
|
||||||
|
|
||||||
**Ziel:** dem dw-Standort einen **eigenen Resolver/DNS-Uplink** geben, der **site-spezifische Overrides**
|
|
||||||
hält (erster Fall `420.teilzeitladen.xyz` → BF-Caddy über WG), als DNS der FritzBox/Clients gesetzt.
|
|
||||||
|
|
||||||
**Kernfrage (explizit):** Verhalten bei **WG-Disconnect.** Ein Override auf eine BF-interne IP
|
|
||||||
(`10.40.20.40`) fällt aus, wenn der Tunnel unten ist. Für einen Login-gegateten Dienst gibt es kein
|
|
||||||
„kein Login **und** funktioniert bei WG-down" — Optionen: (a) Override nur bei WG-up (Failover-Resolver),
|
|
||||||
(b) öffentlicher Pfad (CF, mit Login) als Fallback. Zu entwerfen.
|
|
||||||
|
|
||||||
**Sofortlösung (nicht Teil von TP43):** Geli per **WARP** (Session statt täglichem Login, CGNAT egal,
|
|
||||||
degradiert sauber: WG-up = nahtlos, WG-down = öffentliche URL + Login).
|
|
||||||
|
|
||||||
**BF-seitige Prereqs:** Caddy-vhost `420.teilzeitladen.xyz` → macmini-Worker (`10.40.20.167:8890`) mit
|
|
||||||
Cert (DNS-01, `…4148` hat teilzeitladen.xyz-DNS) · WG-Route dw→`10.40.20.40` · Split-Horizon für
|
|
||||||
`420.teilzeitladen.xyz` (heute liefern auch `.36/.37` public). **Vorher verifizieren:** FritzOS-Version +
|
|
||||||
Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
|
|
||||||
[`docs/handoff-20260901-tp41-followup.md`](handoff-20260901-tp41-followup.md).
|
|
||||||
|
|
||||||
**Status:** 🔜 angelegt 2026-09-01, eigene Session.
|
|
||||||
|
|
||||||
### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt)
|
### TP37 — Nautobot als durchgehende Netzwerkdoku: Hosts/Container/Dienste, erster Anwendungsfall CF-Connectoren ▸ 🔜 ANGELEGT 2026-08-30 (User-Okay erteilt)
|
||||||
> **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in
|
> **Christophs Formulierung (2026-08-30):** *„können wir das setup mit den cf-connectoren dann auch in
|
||||||
> nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf
|
> nautobot abbilden. quasi eine durchgehende netzwerkdoku stück für stück. muss eh fast sein, da du auf
|
||||||
@@ -2687,7 +2629,7 @@ Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
|
|||||||
„wo läuft was" steht.
|
„wo läuft was" steht.
|
||||||
2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`,
|
2. **Dienste abbilden** — `ipam.Service` je exponiertem Port (Caddy `:80/:443`, AdGuard `:53`,
|
||||||
Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**.
|
Nautobot `:8080`, n8n `:5678`, …). Das ist Nautobot-Kernmodell, **keine eigene App nötig**.
|
||||||
3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit
|
3. ✅ **CF-Connectoren — ERLEDIGT via [TP44](teilprojekte-archiv.md#tp44) (Etappe 1, 2026-09-02).** Die Modellwahl ist damit
|
||||||
entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/
|
entschieden: **native `nautobot.cloud`-Modelle** (Tunnel/WARP = `CloudNetwork`, VPC/Hyperdrive/Worker/
|
||||||
Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch
|
Access-App = `CloudService`, IDs/Ports/Origins in `extra_config`), **keine eigene App**. Offen nur noch
|
||||||
TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.**
|
TP44 Etappe 2 (CF-API→Nautobot-Sync). ⇒ **Punkt 1+2 unten sind der echte Rest von TP37.**
|
||||||
@@ -2716,35 +2658,6 @@ Dauerläufer am dw. Cross-Projekt (teilzeitladen.xyz). Handoff:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<a id="tp44"></a>
|
|
||||||
### TP44 — Cloudflare-Estate als Dokumentations-SSoT in Nautobot ▸ ⏳ **Etappe 1 ✅ live (2026-09-02), Quick-Index nachgezogen 2026-09-06**
|
|
||||||
> **Entscheid (User, 2026-09-02):** die volle CF-Estate (Tunnel + WARP-Connector + VPC-Service +
|
|
||||||
> Hyperdrive + Workers + alle Access-Apps) als strukturierte, abfragbare SSoT in Nautobot abbilden —
|
|
||||||
> **etappenweise: manuell jetzt, Sync später.** Nautobot kann CF nicht steuern → „SSoT" heißt
|
|
||||||
> **Dokumentations-SSoT; CF bleibt operativer Master.** Detail-Handoff:
|
|
||||||
> [`handoff-20260902-cf-estate-nautobot-ssot.md`](handoff-20260902-cf-estate-nautobot-ssot.md).
|
|
||||||
|
|
||||||
- **Warum eigenes TP, nicht TP36:** TP36 ist eng auf `teilprojekte.md`-Live-Daten geschnitten; gleiche
|
|
||||||
Idee (Nautobot als Doku-SSoT), andere Dimension. TP44 = nächste freie Nummer nach TP43.
|
|
||||||
- **Modell (native `nautobot.cloud`, Nautobot 3.1.7 — keine eigene App):** `Manufacturer "Cloudflare"` →
|
|
||||||
`CloudAccount` (`393c051f…`) → `CloudResourceType ×6` → `CloudNetwork ×4` (`bf-public`/`fm`/`terpene-db`/
|
|
||||||
`bf-core-mesh`) + `CloudService ×14`; Service↔Tunnel über das Through-Modell
|
|
||||||
`cloud-service-network-assignments` (3 Rows). IDs/Ports/Hostnames/Origins je Objekt in `extra_config`.
|
|
||||||
- **Werkzeug:** `nautobot/tp44-cf-estate-seed.py` — idempotenter Seeder (Re-Run = 26/26 „existiert"),
|
|
||||||
Datenstand inline (Live-CF-Inventur 2026-09-02). Token aus `docs/nas-vlan20/stacks/210-nautobot.yml`.
|
|
||||||
- ✅ **Verifikation 2026-09-06 (live gegen die API):** CloudAccount 1 · ResourceType 6 · CloudNetwork 4 ·
|
|
||||||
CloudService 14 — unverändert seit 09-02.
|
|
||||||
- 🔴 **Gotchas für Etappe 2:** `CloudService.cloud_networks` ist `read_only` (nur Through-Rows schreiben/
|
|
||||||
lesen) · VPC-Service-API unter `connectivity/directory/services` (PUT, voller Body) · Repo-`CF_API_TOKEN`
|
|
||||||
darf kein Workers-VPC → dort über den `cloudflare`-MCP-Credential.
|
|
||||||
- **Offen (Etappe 2, eigene Session):** (1) CF-API→Nautobot-Sync (Python/n8n, analog golden-config-bot) →
|
|
||||||
driftfrei; (2) IPAM-Prefixe `10.40.0.0/16` + `10.101.0.0/24` anlegen, dann greifen die Prefix-Assignments
|
|
||||||
automatisch.
|
|
||||||
- **Beziehung zu TP37:** TP44 hat **TP37 Punkt 3** (CF-Connectoren) vorweggenommen — dieselbe native-Modell-
|
|
||||||
Linie, die TP37 P1+2 (Substrat/Dienste) fortsetzt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Abgeschlossene Teilprojekte ▸ [`teilprojekte-archiv.md`](teilprojekte-archiv.md)
|
## Abgeschlossene Teilprojekte ▸ [`teilprojekte-archiv.md`](teilprojekte-archiv.md)
|
||||||
|
|
||||||
**34 abgeschlossene TPs** liegen dort im Volltext, unverändert und mit eigenem Inhaltsverzeichnis.
|
**34 abgeschlossene TPs** liegen dort im Volltext, unverändert und mit eigenem Inhaltsverzeichnis.
|
||||||
@@ -2761,7 +2674,7 @@ Kurzorientierung, wo was zu finden ist:
|
|||||||
| **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung |
|
| **Firewall / QoS** | [TP5](teilprojekte-archiv.md#tp5) deny-by-default · [TP26](teilprojekte-archiv.md#tp26) `ip-firewall-filter` unter Drift-Kontrolle · [TP6](teilprojekte-archiv.md#tp6) CAKE *(geschlossen statt gebaut)* · [TP6.1](teilprojekte-archiv.md#tp6-1) Upload-Priorisierung |
|
||||||
| **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos |
|
| **Discovery / Media** | [TP7](teilprojekte-archiv.md#tp7) mDNS · [TP7.1](teilprojekte-archiv.md#tp7-1) SSDP-Relay · [TP7.2](teilprojekte-archiv.md#tp7-2) HA↔Sonos |
|
||||||
| **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement |
|
| **Hosts / Migration** | [TP14](teilprojekte-archiv.md#tp14) (+[.1](teilprojekte-archiv.md#tp14-1)/[.2](teilprojekte-archiv.md#tp14-2)/[.2a](teilprojekte-archiv.md#tp14-2a)/[.3](teilprojekte-archiv.md#tp14-3)/[.5](teilprojekte-archiv.md#tp14-5)) VLAN20-Cutover · [TP17](teilprojekte-archiv.md#tp17) Shelly→VLAN30 · [TP19](teilprojekte-archiv.md#tp19) VLAN1-Retirement |
|
||||||
| **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep |
|
| **Control-Plane** | [TP20](teilprojekte-archiv.md#tp20) Platform-Postgres · [TP21](teilprojekte-archiv.md#tp21) Nautobot GC · [TP22](teilprojekte-archiv.md#tp22) Adoption + Compliance-Push · [TP27](teilprojekte-archiv.md#tp27) RouterBOOT 7.24 · [TP-R](teilprojekte-archiv.md#tp-r) Capabilities-Sweep · [TP44](teilprojekte-archiv.md#tp44) CF-Estate Doku-SSoT |
|
||||||
| **Zugang** | [TP10](teilprojekte-archiv.md#tp10) Multi-Site-VPN *(offen nur der DDNS-Updater, s. Follow-ups)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh |
|
| **Zugang** | [TP10](teilprojekte-archiv.md#tp10) Multi-Site-VPN *(offen nur der DDNS-Updater, s. Follow-ups)* · [TP16.1](teilprojekte-archiv.md#tp16-1) natives CF-Mesh |
|
||||||
| **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) |
|
| **Verworfen** | [TP9](teilprojekte-archiv.md#tp9) Gateway-HA (User-Entscheid 2026-08-08) |
|
||||||
|
|
||||||
@@ -2916,3 +2829,78 @@ TP22 · TP-R · **TP3.2** (08-17) · **TP5** (08-18) · **TP7.1** (08-18) · **T
|
|||||||
7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest).
|
7. **Foundation (2026-07-14):** **TP15** (MikroTik-Switching / Omada-WLAN, 2× CRS326 bestellt) ∥ **TP20 (Platform-Postgres, single-site replication-ready)** → **TP21 (Nautobot Golden Config)** ∥ **TP20.1 (Vaultwarden→PG + 2-Site-HA)**. TP20 hängt an **TP18 ①** (Garage-Bucket für pgBackRest).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
### TP43 — dw-Standort: interne BF-DNS-Namen über WG auflösbar machen ▸ ✅ **UMGESETZT 2026-09-11** — dw-FritzBox: Upstream `.36`+`.37`, DNS-Rebind-Ausnahme `bf.emsenhuber.net`, Fallback-Haken „öffentliche NS bei Unreachbarkeit" **gesetzt** (Ideal-Variante: `.37`-Redundanz bleibt, BF-down → public). (verworfene 420-Historie am Ende dieses Abschnitts)
|
||||||
|
|
||||||
|
**Live-Auslöser (2026-09-11):** Christoph am dw-Standort, WG-Tunnel zu BF up, kann `portainer.bf.emsenhuber.net`
|
||||||
|
nicht erreichen. Direkte IP `https://10.40.20.126:9443` geht ⇒ **reines DNS-, kein Routing-Problem.** Der
|
||||||
|
dw-Client nutzt die FritzBox (`10.95.10.1`) als Resolver; die kennt die interne Zone `bf.emsenhuber.net`
|
||||||
|
(Split-Horizon `.36/.37 → 10.40.20.40` Caddy) nicht.
|
||||||
|
|
||||||
|
**Korrektur gegenüber dem verworfenen Scope:** Der ursprüngliche Fall (`420.teilzeitladen.xyz`) ist ein
|
||||||
|
**öffentlicher** CF-Worker und war nie das Problem — der ist via TP41/WARP + CF Access gelöst. Das echte,
|
||||||
|
offene Problem sind **interne** BF-Namen (`*.bf.emsenhuber.net`) über den WG-Tunnel.
|
||||||
|
|
||||||
|
**Live verifiziert (2026-09-11, `dig @10.40.20.36` über den Tunnel):** `portainer.bf.emsenhuber.net
|
||||||
|
→ 10.40.20.40` (Caddy), `macmini.bf.emsenhuber.net → 10.40.20.167` — alle internen bf-Namen landen
|
||||||
|
in `10.40.0.0/16`, über WG erreichbar. `ds-fm`/`gateway-fm.emsenhuber.net → 188.114.96.10/.97.10`
|
||||||
|
= **Cloudflare-Anycast** (public), **nie `10.101.0.x`**. ⇒ **Der fm-Einwand (10.101.x intern ohne
|
||||||
|
Pfad von dw) trifft für `.36/.37` nicht zu** — fm läuft über den public `fm.emsenhuber.net`-Tunnel,
|
||||||
|
nicht über interne Records. (WG-Topologie TP10: dw-Peer AllowedIPs BF-seitig = `10.40.0.0/16`, dw
|
||||||
|
routet **nicht** nach `10.101.0.0/24`.)
|
||||||
|
|
||||||
|
**Constraint am dw:** die 24/7-Geräte `10.95.10.246/.247` sind **Cudy-APs (kein DNS-Host)**; die
|
||||||
|
FritzBox kann **kein Per-Zone-Forwarding** (nur einen globalen Upstream) **und per DHCP nur EINE
|
||||||
|
DNS-IP ausliefern** (⇒ „mehrere NS an die Clients" scheidet aus). Ein echter conditional forwarder
|
||||||
|
(dnsmasq `server=/bf.emsenhuber.net/.36`) bräuchte einen Zusatz-Dauerläufer — abgelehnt.
|
||||||
|
|
||||||
|
**Entscheidung (Christoph, 2026-09-11): FB-Upstream + Rebind-Ausnahme.** Clients bekommen per DHCP
|
||||||
|
`10.95.10.1` (die FB selbst, Default); die **FB forwardet an die BF-Resolver**: *DNS-Server → „Andere
|
||||||
|
DNSv4-Server"* Bevorzugt **`10.40.20.36`**, Alternativ **`10.40.20.37`** (beide BF, beide
|
||||||
|
Voll-Resolver → Redundanz bleibt). Sauberer als der zwischenzeitliche DHCP-Multi-Gedanke: die FB ist
|
||||||
|
der **einzige** Client-Resolver und `.36` löst **intern und public** auf → alles geht zuerst an `.36`,
|
||||||
|
public wird nur als Unreachable-Fallback konsultiert, **nie „zuerst"** → kein NXDOMAIN-Leak im
|
||||||
|
Normalbetrieb.
|
||||||
|
|
||||||
|
🔴 **DNS-Rebind-Schutz-Ausnahme `bf.emsenhuber.net`** (*Heimnetz → Netzwerk → Netzwerkeinstellungen →
|
||||||
|
DNS-Rebind-Schutz*) ist **zwingend**: sonst filtert die FB die Privat-IP-Antworten (`10.40.x`) als
|
||||||
|
Rebind-Angriff weg → bf-Namen liefern nichts, obwohl `.36` korrekt antwortet. Die Ausnahme ist ein
|
||||||
|
reines Namens-Whitelist (keine IP).
|
||||||
|
|
||||||
|
**BF-down-Fallback (Verzweigung am FRITZ!OS-Stand):** Christoph erinnert ein Setting „öffentliche NS
|
||||||
|
verwenden, wenn die primären nicht erreichbar sind". Vorhanden → aktivieren, Upstream bleibt
|
||||||
|
`.36`+`.37`, BF-down geht auf public. Fehlt → Alternativ-Slot statt `.37` auf **public `9.9.9.9`**
|
||||||
|
(kostet `.37`-Redundanz); dann zusätzlich prüfen, dass die FB **strikt primär** fragt (nicht
|
||||||
|
round-robbt), sonst NXDOMAIN-Leak für `bf.*` bei BF-up.
|
||||||
|
|
||||||
|
- **BF-up:** FB → `.36` → interne `bf.*` (→ `.40`, dank Rebind-Ausnahme durchgereicht) **und** public.
|
||||||
|
- **BF-down:** `.36`/`.37` unreachable → public-Fallback → public lebt, `bf.*` = NXDOMAIN (erwartet).
|
||||||
|
|
||||||
|
**Bewusst akzeptierter Tradeoff:** bei BF-up läuft Geli's **gesamter** Haushalt-DNS über Linz
|
||||||
|
(`.36`/AdGuard-Filter) — vermeidbar nur mit conditional forwarder (Zusatz-HW, abgelehnt).
|
||||||
|
|
||||||
|
**On-site-Verifikationen (Christoph, am dw):** (1) Rebind-Ausnahme greift — `nslookup
|
||||||
|
portainer.bf.emsenhuber.net` über die FB → stabil `10.40.20.40`; (2) BF-down-Fallback vorhanden
|
||||||
|
(Setting) oder via public-Alternativ-Slot gesetzt; (3) nur falls BF+public gemischt: FB fragt strikt
|
||||||
|
primär. Rückfall pro Gerät: `/etc/hosts` `10.40.20.40 <name>` oder WARP `fallback_domains
|
||||||
|
bf.emsenhuber.net → [.36,.37]` (TP41-Präzedenz).
|
||||||
|
|
||||||
|
**Sofort-Workaround (läuft unabhängig):** `https://10.40.20.126:9443` direkt bzw.
|
||||||
|
`/etc/hosts`-Zeile `10.40.20.40 portainer.bf.emsenhuber.net`.
|
||||||
|
|
||||||
|
**Abhängigkeiten:** WG-Route dw→`10.40.0.0/16` (steht). Grenzt an TP42 (interner Namensraum
|
||||||
|
`bf.emsenhuber.net`) und TP10 (Multi-Site-VPN). **Umsetzung = eigene On-site-Session** (FritzBox-DHCP
|
||||||
|
ist Christophs manueller Schritt, kein Tool-Zugriff).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Historie — verworfener 420-Scope (angelegt 2026-09-01, verworfen 2026-09-11):**
|
||||||
|
|
||||||
|
**Auslöser (2026-09-01, TP41-Follow-up):** Geli soll `420.teilzeitladen.xyz` am dw-Standort erreichen.
|
||||||
|
Split-Horizon am dw scheitert an der **FritzBox** (`10.95.10.1`): kein Per-Host-A-Override im Standard
|
||||||
|
(nur DNS-Rebind-Ausnahmen ohne IP). Christoph: *„keine Chance im FritzBox-Standard? … lieber ein TP als
|
||||||
|
uns verfranzen. Geli kommt rein, das zählt."* — Diese Prämisse fiel weg: `420.teilzeitladen.xyz` ist ein
|
||||||
|
public CF-Worker hinter CF Access (löst auf CF-Anycast), kein BF-Origin ⇒ dafür braucht es kein
|
||||||
|
Split-Horizon; Zugang via TP41-**WARP** gelöst (Geli 2026-09-10 via Android). Der Override-Bedarf für
|
||||||
|
**interne** Namen blieb dabei übersehen — deshalb reopened, s.o.
|
||||||
|
|||||||
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
|
||||||
@@ -84,7 +84,7 @@ dns:
|
|||||||
ipset_file: ""
|
ipset_file: ""
|
||||||
bootstrap_prefer_ipv6: false
|
bootstrap_prefer_ipv6: false
|
||||||
upstream_timeout: 10s
|
upstream_timeout: 10s
|
||||||
private_networks: []
|
private_<redacted>: []
|
||||||
use_private_ptr_resolvers: false
|
use_private_ptr_resolvers: false
|
||||||
local_ptr_upstreams: []
|
local_ptr_upstreams: []
|
||||||
use_dns64: false
|
use_dns64: false
|
||||||
|
|||||||
14
jobs/__init__.py
Normal file
14
jobs/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""bf-infra Nautobot-Jobs (Git-Datenquelle).
|
||||||
|
|
||||||
|
Nautobot importiert nur dieses Paket; damit die enthaltenen Jobs sich
|
||||||
|
registrieren, muss jedes Job-Modul hier importiert werden. Neue periodische
|
||||||
|
externe->Nautobot-Syncs kommen als weiteres Modul dazu (kein per-Task-Container).
|
||||||
|
"""
|
||||||
|
from nautobot.apps.jobs import register_jobs
|
||||||
|
|
||||||
|
from .cf_estate_sync import CFEstateSync
|
||||||
|
|
||||||
|
# Nautobot importiert nur dieses Paket (bf_infra.jobs) und erwartet register_jobs()
|
||||||
|
# HIER im __init__ (nicht im Submodul) — sonst: "No jobs were registered on loading
|
||||||
|
# the bf_infra.jobs submodule". Neue Jobs: Klasse importieren + hier mitregistrieren.
|
||||||
|
register_jobs(CFEstateSync)
|
||||||
267
jobs/cf_estate_sync.py
Normal file
267
jobs/cf_estate_sync.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
TP44 Teil B — CF-Estate → Nautobot, als geplanter Nautobot-Job (ORM).
|
||||||
|
|
||||||
|
Kanonische Runtime des CF→Nautobot-Sync. Laeuft im nautobot-worker (kein extra
|
||||||
|
Container), schreibt per ORM (keine Nautobot-Exposure), zieht die CF-API outbound.
|
||||||
|
Portierung der Reconcile-Logik aus nautobot/tp44-cf-sync.py (REST) auf das ORM.
|
||||||
|
|
||||||
|
Governance (User 2026-09-09): periodische externe->Nautobot-Syncs leben als
|
||||||
|
Nautobot-Jobs in bf-infra/jobs/ und werden in Nautobot geplant — KEIN per-Task-
|
||||||
|
Container/-Cron. Kuenftige Syncs = weitere Jobs hier.
|
||||||
|
|
||||||
|
Deckung: CloudNetwork (cfd_tunnel + warp_connector), CloudService (workers,
|
||||||
|
access-apps, hyperdrive, VPC), Prefix-Assignments (teamnet/routes), Verwaisung
|
||||||
|
(stale-Markierung statt Loeschen). ID-Matching + extra_config-Merge wie im Skript.
|
||||||
|
|
||||||
|
Token: Nautobot-Secret "cf-api-token" (Provider environment-variable -> CF_API_TOKEN
|
||||||
|
am Worker). CF-Account-ID: Secret "cf-account-id" ODER Konstante unten.
|
||||||
|
|
||||||
|
CF bleibt operativer Master; Nautobot ist Dokumentations-SSoT (read-only Pull).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
from nautobot.apps.jobs import BooleanVar, Job
|
||||||
|
from nautobot.cloud.models import (
|
||||||
|
CloudAccount,
|
||||||
|
CloudNetwork,
|
||||||
|
CloudNetworkPrefixAssignment,
|
||||||
|
CloudResourceType,
|
||||||
|
CloudService,
|
||||||
|
)
|
||||||
|
from nautobot.dcim.models import Manufacturer
|
||||||
|
from nautobot.extras.models import Secret
|
||||||
|
from nautobot.ipam.models import Prefix
|
||||||
|
|
||||||
|
CF_API = "https://api.cloudflare.com/client/v4"
|
||||||
|
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
||||||
|
|
||||||
|
RESOURCE_TYPES = [
|
||||||
|
("Cloudflare cloudflared Tunnel", "cloud", "cloudnetwork"),
|
||||||
|
("Cloudflare WARP Connector", "cloud", "cloudnetwork"),
|
||||||
|
("Cloudflare VPC Service", "cloud", "cloudservice"),
|
||||||
|
("Cloudflare Hyperdrive", "cloud", "cloudservice"),
|
||||||
|
("Cloudflare Worker", "cloud", "cloudservice"),
|
||||||
|
("Cloudflare Access Application", "cloud", "cloudservice"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CFEstateSync(Job):
|
||||||
|
dryrun = BooleanVar(
|
||||||
|
default=True,
|
||||||
|
description="Nur berichten, nichts schreiben (empfohlen fuer den ersten Lauf).",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
name = "CF Estate Sync"
|
||||||
|
description = "Cloudflare-Estate (Tunnel/Worker/Access/Hyperdrive/VPC) nach Nautobot syncen (Doku-SSoT)."
|
||||||
|
grouping = "TP44 / Cloudflare"
|
||||||
|
# Nur ein Boolean (dryrun) als Eingabe; das CF-Token kommt aus dem Nautobot-Secret,
|
||||||
|
# nicht aus einer Job-Variable. Ohne diese Zeile (Default True) verweigert Nautobot
|
||||||
|
# das Scheduling: "Job may have sensitive input variables".
|
||||||
|
has_sensitive_variables = False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- CF-Pull
|
||||||
|
def _cf_get(self, path):
|
||||||
|
"""(ok, result) — 403/404 werden abgefangen, damit fehlende Scopes den Sync nicht kippen."""
|
||||||
|
req = urllib.request.Request(CF_API + path, headers={"Authorization": "Bearer " + self._tok})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return True, json.load(r).get("result")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code in (403, 404):
|
||||||
|
return False, None
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _ingress(self, tunnel_id):
|
||||||
|
ok, res = self._cf_get(f"/accounts/{CF_ACCOUNT_ID}/cfd_tunnel/{tunnel_id}/configurations")
|
||||||
|
rules = ((res or {}).get("config") or {}).get("ingress") or [] if ok else []
|
||||||
|
return [r["hostname"] for r in rules if r.get("hostname")]
|
||||||
|
|
||||||
|
def _load_cf(self):
|
||||||
|
"""networks/services: (name, rt_name, desc, extra, match_key, match_val).
|
||||||
|
routes: {tunnel_id: cidr}. skipped: Liste nicht lesbarer Endpoints."""
|
||||||
|
A = CF_ACCOUNT_ID
|
||||||
|
networks, services, routes, skipped = [], [], {}, []
|
||||||
|
|
||||||
|
_, tuns = self._cf_get(f"/accounts/{A}/cfd_tunnel?is_deleted=false&per_page=50")
|
||||||
|
for t in (tuns or []):
|
||||||
|
networks.append((t["name"], "Cloudflare cloudflared Tunnel",
|
||||||
|
f"cloudflared-Tunnel ({len(t.get('connections') or [])} Connectoren)",
|
||||||
|
{"tunnel_id": t["id"], "kind": "cloudflared",
|
||||||
|
"connector_count": len(t.get("connections") or []),
|
||||||
|
"ingress_hostnames": self._ingress(t["id"])},
|
||||||
|
"tunnel_id", t["id"]))
|
||||||
|
_, warps = self._cf_get(f"/accounts/{A}/warp_connector?per_page=50")
|
||||||
|
for w in (warps or []):
|
||||||
|
networks.append((w["name"], "Cloudflare WARP Connector",
|
||||||
|
f"WARP-Connector ({len(w.get('connections') or [])} Connectoren)",
|
||||||
|
{"tunnel_id": w["id"], "kind": "warp_connector",
|
||||||
|
"connector_count": len(w.get("connections") or [])},
|
||||||
|
"tunnel_id", w["id"]))
|
||||||
|
|
||||||
|
_, scripts = self._cf_get(f"/accounts/{A}/workers/scripts")
|
||||||
|
for s in (scripts or []):
|
||||||
|
services.append((s["id"], "Cloudflare Worker", "Cloudflare Worker",
|
||||||
|
{"script": s["id"]}, "script", s["id"]))
|
||||||
|
|
||||||
|
ok, apps = self._cf_get(f"/accounts/{A}/access/apps?per_page=100")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("access/apps")
|
||||||
|
for a in (apps or []):
|
||||||
|
services.append((a.get("name") or a.get("id"), "Cloudflare Access Application",
|
||||||
|
f"Access-App ({a.get('type')})",
|
||||||
|
{"id": a.get("id"), "type": a.get("type"), "domain": a.get("domain")},
|
||||||
|
"id", a.get("id")))
|
||||||
|
|
||||||
|
ok, hyper = self._cf_get(f"/accounts/{A}/hyperdrive/configs")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("hyperdrive/configs")
|
||||||
|
for h in (hyper or []):
|
||||||
|
origin = h.get("origin") or {}
|
||||||
|
services.append((h.get("name") or h.get("id"), "Cloudflare Hyperdrive", "Hyperdrive-Config",
|
||||||
|
{"id": h.get("id"), "database": origin.get("database"),
|
||||||
|
"user": origin.get("user")}, "id", h.get("id")))
|
||||||
|
|
||||||
|
ok, vpc = self._cf_get(f"/accounts/{A}/connectivity/directory/services")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("connectivity/directory/services")
|
||||||
|
for v in (vpc or []):
|
||||||
|
# VPC-Directory-Services tragen ihren Identifier in "service_id" (NICHT "id").
|
||||||
|
sid = v.get("service_id") or v.get("id")
|
||||||
|
services.append((v.get("name") or sid, "Cloudflare VPC Service", "VPC-Service",
|
||||||
|
{"service_id": sid, "type": v.get("type")}, "service_id", sid))
|
||||||
|
|
||||||
|
ok, rts = self._cf_get(f"/accounts/{A}/teamnet/routes?per_page=50")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("teamnet/routes")
|
||||||
|
for r in (rts or []):
|
||||||
|
if r.get("network") and r.get("tunnel_id"):
|
||||||
|
routes[r["tunnel_id"]] = r["network"]
|
||||||
|
|
||||||
|
return networks, services, routes, skipped
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ORM-Upsert
|
||||||
|
def _upsert(self, model, cache, name, rt, acct, desc, extra, mk, mv, label, seen, dryrun):
|
||||||
|
# mv=None nie matchen: sonst greift .get(mk)==None auf das erste Objekt OHNE mk
|
||||||
|
# und mischt fremde extra_config zusammen (z.B. VPC ohne id -> App-Launcher).
|
||||||
|
if mv is None:
|
||||||
|
self.logger.warning("~ %s '%s' ohne Match-Wert (%s) — uebersprungen", label, name, mk)
|
||||||
|
return None
|
||||||
|
ex = next((o for o in cache if (o.extra_config or {}).get(mk) == mv), None)
|
||||||
|
if ex:
|
||||||
|
seen.add(ex.id)
|
||||||
|
merged = dict(ex.extra_config or {})
|
||||||
|
merged.update(extra)
|
||||||
|
if merged == (ex.extra_config or {}):
|
||||||
|
return ex
|
||||||
|
if dryrun:
|
||||||
|
self.logger.info("~ %s '%s': extra_config-Update [DRY-RUN]", label, ex.name)
|
||||||
|
return ex
|
||||||
|
ex.extra_config = merged
|
||||||
|
ex.validated_save()
|
||||||
|
self.logger.info("~ %s '%s' aktualisiert", label, ex.name)
|
||||||
|
return ex
|
||||||
|
if dryrun:
|
||||||
|
self.logger.info("+ %s '%s' [DRY-RUN]", label, name)
|
||||||
|
return None
|
||||||
|
obj = model(name=name, cloud_resource_type=rt, cloud_account=acct,
|
||||||
|
description=desc, extra_config=extra)
|
||||||
|
obj.validated_save()
|
||||||
|
cache.append(obj)
|
||||||
|
seen.add(obj.id)
|
||||||
|
self.logger.info("+ %s '%s' angelegt", label, name)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _mark_stale(self, cache, seen, label, dryrun):
|
||||||
|
for o in cache:
|
||||||
|
extra = o.extra_config or {}
|
||||||
|
if o.id in seen:
|
||||||
|
if extra.get("stale") and not dryrun:
|
||||||
|
o.extra_config = {k: v for k, v in extra.items() if k != "stale"}
|
||||||
|
o.validated_save()
|
||||||
|
self.logger.info("↩ %s '%s': stale entfernt (wieder da)", label, o.name)
|
||||||
|
continue
|
||||||
|
if extra.get("stale"):
|
||||||
|
continue
|
||||||
|
if dryrun:
|
||||||
|
self.logger.warning("⚠ %s '%s' fehlt im Live-Pull → wuerde stale markiert [DRY-RUN]", label, o.name)
|
||||||
|
continue
|
||||||
|
o.extra_config = dict(extra, stale=True)
|
||||||
|
o.validated_save()
|
||||||
|
self.logger.warning("⚠ %s '%s' als stale markiert (nicht mehr in CF)", label, o.name)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- run
|
||||||
|
def run(self, dryrun=True):
|
||||||
|
try:
|
||||||
|
self._tok = Secret.objects.get(name="cf-api-token").get_value()
|
||||||
|
except Secret.DoesNotExist:
|
||||||
|
raise RuntimeError("Nautobot-Secret 'cf-api-token' fehlt — bitte anlegen (Provider env, CF_API_TOKEN).")
|
||||||
|
|
||||||
|
networks, services, routes, skipped = self._load_cf()
|
||||||
|
self.logger.info("Live-Pull: %d CloudNetworks · %d CloudServices%s",
|
||||||
|
len(networks), len(services),
|
||||||
|
f" · SKIP: {', '.join(skipped)}" if skipped else " · alle Endpoints lesbar")
|
||||||
|
|
||||||
|
mfr, _ = Manufacturer.objects.get_or_create(name="Cloudflare")
|
||||||
|
acct, created = CloudAccount.objects.get_or_create(
|
||||||
|
name="Cloudflare", defaults={"account_number": CF_ACCOUNT_ID, "provider": mfr})
|
||||||
|
if created and dryrun:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rt_by_name = {}
|
||||||
|
for name, app, model_name in RESOURCE_TYPES:
|
||||||
|
ct = ContentType.objects.get(app_label=app, model=model_name)
|
||||||
|
rt, _ = CloudResourceType.objects.get_or_create(name=name, defaults={"provider": mfr})
|
||||||
|
rt.content_types.add(ct)
|
||||||
|
rt_by_name[name] = rt
|
||||||
|
|
||||||
|
net_cache = list(CloudNetwork.objects.filter(cloud_account=acct))
|
||||||
|
svc_cache = list(CloudService.objects.filter(cloud_account=acct))
|
||||||
|
seen_net, seen_svc = set(), set()
|
||||||
|
|
||||||
|
self.logger.info("[CloudNetworks]")
|
||||||
|
for name, rt_name, desc, extra, mk, mv in networks:
|
||||||
|
self._upsert(CloudNetwork, net_cache, name, rt_by_name.get(rt_name), acct,
|
||||||
|
desc, extra, mk, mv, "CloudNetwork", seen_net, dryrun)
|
||||||
|
|
||||||
|
self.logger.info("[CloudServices]")
|
||||||
|
for name, rt_name, desc, extra, mk, mv in services:
|
||||||
|
self._upsert(CloudService, svc_cache, name, rt_by_name.get(rt_name), acct,
|
||||||
|
desc, extra, mk, mv, "CloudService", seen_svc, dryrun)
|
||||||
|
|
||||||
|
self.logger.info("[Prefix-Assignments]")
|
||||||
|
for tunnel_id, cidr in routes.items():
|
||||||
|
net = next((n for n in net_cache if (n.extra_config or {}).get("tunnel_id") == tunnel_id), None)
|
||||||
|
if not net or getattr(net, "id", None) is None:
|
||||||
|
continue
|
||||||
|
pfx = Prefix.objects.filter(prefix=cidr).first()
|
||||||
|
if not pfx:
|
||||||
|
self.logger.info(" ~ Route %s: Prefix nicht in IPAM → uebersprungen", cidr)
|
||||||
|
continue
|
||||||
|
if CloudNetworkPrefixAssignment.objects.filter(cloud_network=net, prefix=pfx).exists():
|
||||||
|
continue
|
||||||
|
if dryrun:
|
||||||
|
self.logger.info(" + %s ↔ %s [DRY-RUN]", net.name, cidr)
|
||||||
|
continue
|
||||||
|
CloudNetworkPrefixAssignment(cloud_network=net, prefix=pfx).validated_save()
|
||||||
|
self.logger.info(" + %s ↔ %s angelegt", net.name, cidr)
|
||||||
|
|
||||||
|
self.logger.info("[Verwaisung]")
|
||||||
|
# CloudServices nur markieren, wenn ALLE Service-Endpoints lesbar waren — sonst
|
||||||
|
# wuerde ein fehlender Scope echte Objekte faelschlich als verwaist markieren.
|
||||||
|
svc_safe = not ({"access/apps", "hyperdrive/configs", "connectivity/directory/services"} & set(skipped))
|
||||||
|
if svc_safe:
|
||||||
|
self._mark_stale(svc_cache, seen_svc, "CloudService", dryrun)
|
||||||
|
else:
|
||||||
|
self.logger.info(" · CloudService-Verwaisung uebersprungen (Token-Scope unvollstaendig)")
|
||||||
|
self._mark_stale(net_cache, seen_net, "CloudNetwork", dryrun)
|
||||||
|
|
||||||
|
self.logger.info("Fertig (%s).", "DRY-RUN — nichts geschrieben" if dryrun else "COMMIT")
|
||||||
|
|
||||||
|
|
||||||
|
# Registrierung zentral in jobs/__init__.py (Nautobot importiert nur das Paket).
|
||||||
@@ -353,10 +353,76 @@ def job_tp42_dhcp_domain():
|
|||||||
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp38_oob():
|
||||||
|
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
|
||||||
|
|
||||||
|
Zwei Geraete, zwei config_sets:
|
||||||
|
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
|
||||||
|
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
|
||||||
|
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
|
||||||
|
die laufende Nautobot-SSH-Session (10.40.20.30) matcht auf Regel 1 (established),
|
||||||
|
bevor Regel 8 (drop) greift.
|
||||||
|
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
|
||||||
|
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
|
||||||
|
waere wirkungslos + non-compliant).
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
|
||||||
|
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
|
||||||
|
(10.40.20.30), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
|
||||||
|
"""
|
||||||
|
bridge_set = (
|
||||||
|
':do { /interface bridge port set [find interface=ether23] pvid=99 '
|
||||||
|
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
|
||||||
|
'on-error={ :log info "tp38: ether23 set geworfen" }'
|
||||||
|
)
|
||||||
|
vlan_set = (
|
||||||
|
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
|
||||||
|
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_fw = "\n".join([
|
||||||
|
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||||
|
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||||
|
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||||
|
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||||
|
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||||
|
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||||
|
])
|
||||||
|
branch_set = "\n".join([
|
||||||
|
bridge_set,
|
||||||
|
vlan_set,
|
||||||
|
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
|
||||||
|
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||||
|
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
|
||||||
|
])
|
||||||
|
|
||||||
|
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
|
||||||
|
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
|
||||||
|
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
|
||||||
|
core_set = "\n".join([
|
||||||
|
bridge_set,
|
||||||
|
vlan_set,
|
||||||
|
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
|
||||||
|
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
|
||||||
|
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
|
||||||
|
'destination=[find where comment="TP5: input log-only"]',
|
||||||
|
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||||
|
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
(["crs326-branch"], branch_set, "TP38-oob-branch"),
|
||||||
|
(["crs326-core"], core_set, "TP38-oob-core"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
JOBS = {
|
JOBS = {
|
||||||
"drift-cleanup": job_drift_cleanup,
|
"drift-cleanup": job_drift_cleanup,
|
||||||
"mgmt-acl": job_mgmt_acl,
|
"mgmt-acl": job_mgmt_acl,
|
||||||
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
||||||
|
"tp38-oob": job_tp38_oob,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
199
nautobot/gc-snapshot.py
Normal file
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.30:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(f"{NB}{path}", data=data, headers=HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- CustomFields
|
||||||
|
# (key, label, type, description). content_types wird ergänzt.
|
||||||
|
CUSTOM_FIELDS = [
|
||||||
|
("ros_version", "RouterOS-Version", "text",
|
||||||
|
"RouterOS-Version aus der GC-backup_config (by RouterOS …)."),
|
||||||
|
("compliance_pct", "GC-Compliance %", "integer",
|
||||||
|
"Golden-Config config-compliance: compliant/total in Prozent."),
|
||||||
|
("dhcp_server_count", "DHCP-Server", "integer",
|
||||||
|
"Anzahl Einträge der Sektion /ip dhcp-server im GC-Backup."),
|
||||||
|
("mgmt_ip", "Mgmt-IP", "text",
|
||||||
|
"device.primary_ip4 ohne Prefix (VLAN99-Management)."),
|
||||||
|
("dns_upstream_mode", "DNS-Upstream-Modus", "text",
|
||||||
|
"Aus /ip dns des GC-Backups: doh | plain | none."),
|
||||||
|
]
|
||||||
|
CF_KEYS = [c[0] for c in CUSTOM_FIELDS]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_custom_fields():
|
||||||
|
"""get-or-create die fünf CFs; bei Bestand: content_type dcim.device sicherstellen."""
|
||||||
|
st, d = req("GET", "/api/extras/custom-fields/?limit=0")
|
||||||
|
have = {r["key"]: r for r in d.get("results", [])}
|
||||||
|
for key, label, typ, desc in CUSTOM_FIELDS:
|
||||||
|
ex = have.get(key)
|
||||||
|
if ex:
|
||||||
|
cts = ex.get("content_types", [])
|
||||||
|
ok = "dcim.device" in cts
|
||||||
|
print(f" = CF '{key}' existiert ({ex['id']}){'' if ok else ' ⚠ ohne dcim.device!'}")
|
||||||
|
if not ok and COMMIT:
|
||||||
|
req("PATCH", f"/api/extras/custom-fields/{ex['id']}/",
|
||||||
|
{"content_types": sorted(set(cts) | {"dcim.device"})})
|
||||||
|
print(f" + dcim.device ergänzt")
|
||||||
|
continue
|
||||||
|
payload = {"key": key, "label": label, "type": typ, "description": desc,
|
||||||
|
"content_types": ["dcim.device"], "grouping": "TP36-Live",
|
||||||
|
"required": False}
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + CF '{key}' ({typ}) [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
st, r = req("POST", "/api/extras/custom-fields/", payload)
|
||||||
|
print(f" {'+' if st in (200,201) else '!'} CF '{key}'"
|
||||||
|
+ (f" angelegt ({r.get('id')})" if st in (200, 201)
|
||||||
|
else f" FEHLER {st}: {json.dumps(r)[:200]}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Backup-Parser
|
||||||
|
def parse_backup(cfg: str):
|
||||||
|
"""Werte aus dem flachen RouterOS-Export (eine '/…'-Zeile pro Kommando)."""
|
||||||
|
ver = ""
|
||||||
|
m = re.search(r"by RouterOS ([0-9][0-9A-Za-z.\-]*)", cfg)
|
||||||
|
if m:
|
||||||
|
ver = m.group(1)
|
||||||
|
# DHCP-Server = Zeilen '/ip dhcp-server add …' (NICHT lease/network/option)
|
||||||
|
dhcp = len(re.findall(r"(?m)^\s*/ip dhcp-server add\b", cfg))
|
||||||
|
# DNS-Modus aus der '/ip dns set …'-Zeile
|
||||||
|
mode = "none"
|
||||||
|
md = re.search(r"(?m)^\s*/ip dns set\b.*", cfg)
|
||||||
|
if md:
|
||||||
|
line = md.group(0)
|
||||||
|
doh = re.search(r"use-doh-server=([^\s]+)", line)
|
||||||
|
srv = re.search(r"\bservers=([^\s]+)", line)
|
||||||
|
if doh and doh.group(1) not in ("", '""'):
|
||||||
|
mode = "doh"
|
||||||
|
elif srv and srv.group(1) not in ("", '""'):
|
||||||
|
mode = "plain"
|
||||||
|
return ver, dhcp, mode
|
||||||
|
|
||||||
|
|
||||||
|
def compliance_pct(device_name: str):
|
||||||
|
q = urllib.parse.urlencode({"device": device_name, "limit": 0, "depth": 0})
|
||||||
|
st, d = req("GET", f"/api/plugins/golden-config/config-compliance/?{q}")
|
||||||
|
rows = d.get("results", [])
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
ok = sum(1 for r in rows if r.get("compliance"))
|
||||||
|
return round(ok / len(rows) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- main
|
||||||
|
def routeros_devices():
|
||||||
|
q = urllib.parse.urlencode({"limit": 0, "depth": 1, "platform": "RouterOS"})
|
||||||
|
st, d = req("GET", f"/api/dcim/devices/?{q}")
|
||||||
|
devs = []
|
||||||
|
for r in d.get("results", []):
|
||||||
|
ip = r.get("primary_ip4")
|
||||||
|
ip = ip.get("address") if isinstance(ip, dict) else ip
|
||||||
|
if not ip:
|
||||||
|
continue # tote Altgeräte (cudy/sw) ohne IP überspringen
|
||||||
|
devs.append((r["id"], r["name"], ip.split("/")[0]))
|
||||||
|
return sorted(devs, key=lambda x: x[1])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP36-Live gc-snapshot ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
|
||||||
|
print("\n[1] CustomFields sicherstellen")
|
||||||
|
ensure_custom_fields()
|
||||||
|
|
||||||
|
print("\n[2] Golden-Config-Backups laden")
|
||||||
|
st, gc = req("GET", "/api/plugins/golden-config/golden-config/?depth=1&limit=0")
|
||||||
|
backups = {}
|
||||||
|
for r in gc.get("results", []):
|
||||||
|
dev = r.get("device")
|
||||||
|
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||||
|
backups[dev] = (r.get("backup_config") or "", r.get("backup_last_success_date") or "?")
|
||||||
|
|
||||||
|
print("\n[3] Snapshot je Gerät")
|
||||||
|
print(f" {'Gerät':16} {'ros':9} {'cmpl%':>5} {'dhcp':>4} {'dns':6} {'mgmt-ip':15} backup")
|
||||||
|
for dev_id, name, mgmt in routeros_devices():
|
||||||
|
cfg, when = backups.get(name, ("", "—"))
|
||||||
|
ver, dhcp, dns = parse_backup(cfg) if cfg else ("?", 0, "?")
|
||||||
|
pct = compliance_pct(name)
|
||||||
|
vals = {
|
||||||
|
"ros_version": ver or None,
|
||||||
|
"compliance_pct": pct,
|
||||||
|
"dhcp_server_count": dhcp,
|
||||||
|
"mgmt_ip": mgmt,
|
||||||
|
"dns_upstream_mode": dns,
|
||||||
|
}
|
||||||
|
pct_s = "—" if pct is None else str(pct)
|
||||||
|
print(f" {name:16} {ver or '?':9} {pct_s:>5} {dhcp:>4} {dns:6} {mgmt:15} {when[:10]}")
|
||||||
|
if COMMIT:
|
||||||
|
st, r = req("PATCH", f"/api/dcim/devices/{dev_id}/", {"custom_fields": vals})
|
||||||
|
if st not in (200, 201):
|
||||||
|
print(f" ! PATCH FEHLER {st}: {json.dumps(r)[:200]}")
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT — geschrieben' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
105
nautobot/ingest-tp38-oob.py
Normal file
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.30:8080")
|
||||||
|
|
||||||
|
BRANCH_RULES = [
|
||||||
|
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||||
|
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||||
|
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||||
|
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||||
|
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||||
|
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||||
|
]
|
||||||
|
|
||||||
|
CORE_NEW_RULE = 'add action=accept chain=input comment="TP38: OOB->core mgmt" dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp'
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
tok = os.environ.get("NAUTOBOT_TOKEN") or sys.exit("NAUTOBOT_TOKEN nicht gesetzt")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{NB}{path}", method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {tok}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(name):
|
||||||
|
return nb(f"/api/dcim/devices/?name={name}&depth=1")["results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_device(dev, ctx, label):
|
||||||
|
dev_id = dev["id"]
|
||||||
|
nb(f"/api/dcim/devices/{dev_id}/", "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print(f" ~ {label} geschrieben.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
apply_ = "--apply" in sys.argv
|
||||||
|
|
||||||
|
# --- BRANCH ---
|
||||||
|
branch = get_device("crs326-branch")
|
||||||
|
bctx = dict(branch.get("local_config_context_data") or {})
|
||||||
|
if bctx.get("firewall_filter") == BRANCH_RULES:
|
||||||
|
print("== branch: firewall_filter unveraendert ==")
|
||||||
|
else:
|
||||||
|
bctx["firewall_filter"] = BRANCH_RULES
|
||||||
|
print(f"== branch: {len(BRANCH_RULES)} Input-Regeln ==")
|
||||||
|
for ln in BRANCH_RULES:
|
||||||
|
print(" " + ln)
|
||||||
|
if apply_:
|
||||||
|
patch_device(branch, bctx, "branch firewall_filter")
|
||||||
|
|
||||||
|
# --- CORE ---
|
||||||
|
core = get_device("crs326-core")
|
||||||
|
cctx = dict(core.get("local_config_context_data") or {})
|
||||||
|
ff = list(cctx.get("firewall_filter") or [])
|
||||||
|
if CORE_NEW_RULE in ff:
|
||||||
|
print("\n== core: Regel schon vorhanden ==")
|
||||||
|
else:
|
||||||
|
# vor der Log-Regel einsortieren (die sitzt direkt vor dem finalen drop).
|
||||||
|
insert_at = len(ff)
|
||||||
|
for i, ln in enumerate(ff):
|
||||||
|
if "log-prefix=TP5-INPUT-WOULD-DROP" in ln:
|
||||||
|
insert_at = i
|
||||||
|
break
|
||||||
|
ff.insert(insert_at, CORE_NEW_RULE)
|
||||||
|
cctx["firewall_filter"] = ff
|
||||||
|
print(f"\n== core: fuege OOB-Regel an Position {insert_at} ein ==")
|
||||||
|
print(" " + CORE_NEW_RULE)
|
||||||
|
if apply_:
|
||||||
|
patch_device(core, cctx, "core firewall_filter")
|
||||||
|
|
||||||
|
if not apply_:
|
||||||
|
print("\nDRY-RUN — mit --apply schreiben. Danach: "
|
||||||
|
"push-gc-template.py + gc-configplan.py --refresh-compliance")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -73,9 +73,11 @@
|
|||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
||||||
{% for n in range(17, 25) %}
|
{% for n in range(17, 23) %}
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
|
||||||
|
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
||||||
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99
|
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
||||||
|
|
||||||
# [1] INTERFACE-LISTS
|
# [1] INTERFACE-LISTS
|
||||||
@@ -222,10 +224,12 @@
|
|||||||
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
||||||
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
||||||
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
||||||
# sec-main: ft=no seit 2026-09-04 (User-Entscheid nach TP25-Messung). Fast-Transition
|
# sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
|
||||||
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no.
|
# "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
|
||||||
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus).
|
# Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
|
||||||
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>"
|
# Fund-E-Abschlussmessung 09-02 ruhig (4 reauth/8,6 h, 0 Ausfaelle) auf 7.24.1.
|
||||||
|
# 2026-09-04 stand hier: ft=no ft-over-ds=no (User-Entscheid nach TP25-Messung).
|
||||||
|
/interface wifi security add authentication-types=wpa3-psk ft=yes ft-over-ds=yes name=sec-main passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
||||||
@@ -239,11 +243,16 @@
|
|||||||
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
||||||
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
||||||
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
||||||
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
# st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
|
||||||
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
# zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante). transition-threshold-time=5s
|
||||||
|
# statt 10s. NUR 5G: die drei 2G-Configs bleiben auf st-main (unset=-80), weil 2.4G draussen
|
||||||
|
# (Terrasse/ausserhalb der Wohnung) Reichweite behalten soll. 8 dB Abstand = kleine Hysterese.
|
||||||
|
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes
|
||||||
|
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
|
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
||||||
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
||||||
@@ -273,17 +282,16 @@
|
|||||||
# python3 nautobot/gc-transaktion.py begin --device crs326-core \
|
# python3 nautobot/gc-transaktion.py begin --device crs326-core \
|
||||||
# --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT
|
# --scope wifi-access-list --change-id TEST-... --expires JJJJ-MM-TT
|
||||||
#
|
#
|
||||||
# 🔴 Hier landet TP25 **Fund E** (`signal-range`-Boden gegen klebende Clients),
|
# 🔴 Stand 2026-09-14 (Terrasse-In-situ-Session): der `signal-range`-Boden gegen
|
||||||
# sobald er entschieden ist. Die alte Auflage lautete "vor dem Eintragen den
|
# klebende Clients ist GEPRUEFT und GESCHEITERT -- `/interface wifi access-list`
|
||||||
# Sticky-Fall erneut messen" -- das ist am 2026-08-30 passiert: dasselbe iPhone
|
# hat kein `band`-Property (Band-Scoping ginge nur ueber cap-wifiN-Interface-Namen,
|
||||||
# machte 16 Bandwechsel in 20 Minuten bei -53..-81 dBm, erst an ap3, dann an ap2.
|
# die bei jedem CAP-Rejoin wechseln), SSID-/MAC-Scope ohne Band träfe 2,4 GHz mit
|
||||||
# Fund E ist damit WIEDER OFFEN.
|
# (Lockout-Risiko, Schlafzimmer -84). Der Boden ist damit NICHT umsetzbar; der
|
||||||
# Eingetragen wird hier trotzdem NICHTS, und zwar bewusst: ein signal-range-Boden
|
# Ersatz gegen die Call-Aussetzer ist `ft=yes ft-over-ds=yes` auf sec-main
|
||||||
# ist der invasivste der drei Hebel und kann Clients AUSSPERREN -- genau das war
|
# (2026-09-14 zurueckgesetzt, s. docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
|
||||||
# 08-22 das Gegenargument (in den schwachen Raeumen gibt es keinen besseren AP,
|
# und handoff-20260914-tp25-ap2-insitu-terrassse.md): Roams laufen als nahtlose
|
||||||
# auf den ein Boden steuern koennte). Erst kommen (1) `2g-probe-delay=yes` und
|
# FT-Transition statt Voll-Reassoziation. Das Band-Springen selbst endet erst
|
||||||
# (2) `ft-over-ds=no`, jeder einzeln und als Transaktion, mit dem seit 2026-08-30
|
# mit dem Deckungsfix (TP25 Variante 3). Siehe teilprojekte.md TP25 (Stand 09-14).
|
||||||
# laufenden wireless-Logging als Messgrundlage. Siehe teilprojekte.md TP25 Fund E.
|
|
||||||
|
|
||||||
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
|
# Kanonischer Mgmt-Nummernplan (User-Bestaetigung 2026-08-13, verbindlich):
|
||||||
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
|
# core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12
|
||||||
@@ -416,9 +424,20 @@
|
|||||||
/system ntp client servers add address=10.40.99.1
|
/system ntp client servers add address=10.40.99.1
|
||||||
/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1
|
/ip route add comment=TP21-default dst-address=0.0.0.0/0 gateway=10.40.99.1
|
||||||
|
|
||||||
|
# INPUT-FIREWALL (TP38, 2026-09-15) — deny-by-default fuer die Mgmt-Ebene (10.40.99.2).
|
||||||
|
# Der Branch ist L2-only: ALLE Mgmt-Anfragen kommen auf der einen vlan99-SVI an, egal ob
|
||||||
|
# VLAN10 (ueber den Core geroutet), cf-mesh (100.96.0.0/12) oder der OOB-Port (untagged
|
||||||
|
# ether23, 10.40.99.x). Deshalb matcht die Quelle per src-address, nicht per in-interface.
|
||||||
|
# Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12 spiegelt bewusst das `available-from` der
|
||||||
|
# /ip service-Zeilen — kein neues Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
|
||||||
|
{% for line in obj.get_config_context().get("firewall_filter", []) %}
|
||||||
|
/ip firewall filter {{ line }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
# L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort).
|
# L2-FABRIC — wie beim Core ein Literal-Block (Begruendung dort).
|
||||||
# ether9-23 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
|
# ether9-22 tragen pvid=20 OHNE VLAN-Membership (Bootstrap-Rev4-Rest, funktionslos) —
|
||||||
# bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird.
|
# bewusst so abgebildet, damit eine spaetere Bereinigung als Drift sichtbar wird.
|
||||||
|
# ether23 ist seit TP38 der OOB-MGMT-Port (untagged VLAN99), s. unten.
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether1
|
/interface bridge port add bridge=bridge comment=defconf interface=ether1
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether2
|
/interface bridge port add bridge=bridge comment=defconf interface=ether2
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10
|
/interface bridge port add bridge=bridge comment=defconf interface=ether3 pvid=10
|
||||||
@@ -427,9 +446,10 @@
|
|||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40
|
/interface bridge port add bridge=bridge comment=defconf interface=ether6 pvid=40
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20
|
/interface bridge port add bridge=bridge comment=defconf interface=ether7 pvid=20
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether8
|
/interface bridge port add bridge=bridge comment=defconf interface=ether8
|
||||||
{% for n in range(9, 24) %}
|
{% for n in range(9, 23) %}
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20
|
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }} pvid=20
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
||||||
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1
|
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus1
|
||||||
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2
|
/interface bridge port add bridge=bridge comment=defconf disabled=yes interface=sfp-sfpplus2
|
||||||
@@ -439,7 +459,7 @@
|
|||||||
/interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40
|
/interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40
|
||||||
/interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50
|
/interface bridge vlan add bridge=bridge comment=UNTRUSTED tagged=ether1,ether24,bridge vlan-ids=50
|
||||||
/interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90
|
/interface bridge vlan add bridge=bridge comment=GUEST tagged=ether1,ether24,bridge vlan-ids=90
|
||||||
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge vlan-ids=99
|
/interface bridge vlan add bridge=bridge comment=MGMT tagged=ether1,ether24,bridge untagged=ether23 vlan-ids=99
|
||||||
{% elif obj.role.name == "Wireless AP" %}
|
{% elif obj.role.name == "Wireless AP" %}
|
||||||
{% set ap = obj.get_config_context()["ap"] %}
|
{% set ap = obj.get_config_context()["ap"] %}
|
||||||
{% set mgmt_host = obj.primary_ip4.host %}
|
{% set mgmt_host = obj.primary_ip4.host %}
|
||||||
|
|||||||
178
nautobot/tp37-dienste-seed.py
Normal file
178
nautobot/tp37-dienste-seed.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP37 Punkt 2 (Dienste) — exponierte Ports als native ipam.Service-Objekte.
|
||||||
|
|
||||||
|
Idempotenter Seeder (get-or-create) fuer ipam.Service:
|
||||||
|
je VM-Name + Port + Protokoll ein Service-Eintrag.
|
||||||
|
|
||||||
|
Vorbedingung: TP37 P1 (tp37-substrat-seed.py --commit) muss vorher gelaufen sein,
|
||||||
|
da die VirtualMachine-Objekte existieren muessen.
|
||||||
|
|
||||||
|
Datenmodell-Entscheid (User 2026-09-06): native Modelle, KEINE eigene App.
|
||||||
|
Muster: nautobot/tp37-substrat-seed.py.
|
||||||
|
|
||||||
|
Umfang (User-Default 2026-09-07):
|
||||||
|
Caddy (:80/:443), Caddy-Backends (haos:8123, vaultwarden:80, gitea:3000, n8n:5678),
|
||||||
|
Nautobot:8080, AdGuard:53 (bf1+bf2, tcp+udp), platform-pg:5432, mariadb:3306,
|
||||||
|
influxdb2:8086, mqtt5:1883, portainer:9443,
|
||||||
|
MCP-Dienste (pve-mcp:3000, shelly-mcp:3030, n8n-mcp:3001, gitea-mcp:3000).
|
||||||
|
+ Smart-Home (vervollstaendigt 2026-09-08, Ports per Socket-Probe verifiziert):
|
||||||
|
RaspberryMatic/CCU (80/443/2001/2010), iob-master:8081, z2m25:8080.
|
||||||
|
iob-exposed/iob_slave BEWUSST ohne Service (kein exponierter Port gefunden).
|
||||||
|
|
||||||
|
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||||
|
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/tp37-dienste-seed.py # Dry-Run (default)
|
||||||
|
python3 nautobot/tp37-dienste-seed.py --commit # legt an
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
url = f"{NB}{path}"
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(url, data=data, headers=HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def find(endpoint: str, params: dict, match_key: str, match_val: str):
|
||||||
|
q = urllib.parse.urlencode(dict(params, depth=0))
|
||||||
|
st, d = req("GET", f"/api/{endpoint}/?{q}")
|
||||||
|
for row in d.get("results", []):
|
||||||
|
if row.get(match_key) == match_val:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create(endpoint: str, find_params: dict, match_key: str,
|
||||||
|
match_val: str, payload: dict, label: str):
|
||||||
|
existing = find(endpoint, find_params, match_key, match_val)
|
||||||
|
if existing:
|
||||||
|
print(f" = {label}: '{match_val}' existiert ({existing['id']})")
|
||||||
|
return existing
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + {label}: '{match_val}' [DRY-RUN]")
|
||||||
|
return {"id": f"<dry:{match_val}>"}
|
||||||
|
st, d = req("POST", f"/api/{endpoint}/", payload)
|
||||||
|
if st in (200, 201):
|
||||||
|
print(f" + {label}: '{match_val}' angelegt ({d.get('id')})")
|
||||||
|
return d
|
||||||
|
print(f" ! {label}: '{match_val}' FEHLER {st}: {json.dumps(d)[:300]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Inventar
|
||||||
|
# (vm_name, svc_name, ports, protocol, description)
|
||||||
|
SERVICES = [
|
||||||
|
# Caddy Reverse-Proxy (TP34 critical)
|
||||||
|
("caddy-bf1", "caddy-http", [80], "tcp", "Caddy HTTP"),
|
||||||
|
("caddy-bf1", "caddy-https", [443], "tcp", "Caddy HTTPS Reverse Proxy"),
|
||||||
|
# Caddy-Backends via bf-public Tunnel
|
||||||
|
("haos", "home-assistant", [8123], "tcp", "Home Assistant Web"),
|
||||||
|
("vaultwarden", "vaultwarden-http", [80], "tcp", "Vaultwarden (HTTP, TLS via Caddy)"),
|
||||||
|
("gitea", "gitea-http", [3000], "tcp", "Gitea Web"),
|
||||||
|
("n8n", "n8n-web", [5678], "tcp", "n8n Workflow Engine"),
|
||||||
|
# Nautobot
|
||||||
|
("nautobot", "nautobot-web", [8080], "tcp", "Nautobot API/Web"),
|
||||||
|
# DNS (TCP + UDP je Resolver)
|
||||||
|
("agh-bf1", "adguard-dns-tcp", [53], "tcp", "AdGuard Home DNS/TCP"),
|
||||||
|
("agh-bf1", "adguard-dns-udp", [53], "udp", "AdGuard Home DNS/UDP"),
|
||||||
|
("agh-bf2", "adguard-dns-tcp", [53], "tcp", "AdGuard Home DNS/TCP"),
|
||||||
|
("agh-bf2", "adguard-dns-udp", [53], "udp", "AdGuard Home DNS/UDP"),
|
||||||
|
# Infrastruktur
|
||||||
|
("platform-pg", "postgresql", [5432], "tcp", "PostgreSQL 16 (Nautobot-DB)"),
|
||||||
|
("mariadb", "mariadb", [3306], "tcp", "MariaDB (vw/hass/gitea)"),
|
||||||
|
("influxdb2", "influxdb", [8086], "tcp", "InfluxDB 2"),
|
||||||
|
("mqtt5", "mqtt", [1883], "tcp", "MQTT Broker (Mosquitto)"),
|
||||||
|
("docker", "portainer", [9443], "tcp", "Portainer CE"),
|
||||||
|
# MCP-Dienste
|
||||||
|
("pve-mcp", "pve-mcp", [3000], "tcp", "Proxmox MCP (StreamableHTTP)"),
|
||||||
|
("shelly-mcp", "shelly-mcp", [3030], "tcp", "Shelly MCP"),
|
||||||
|
("n8n-mcp", "n8n-mcp", [3001], "tcp", "n8n MCP"),
|
||||||
|
("gitea-mcp", "gitea-mcp", [3000], "tcp", "Gitea MCP"),
|
||||||
|
# Smart-Home / IoT — Ports live per Socket-Probe verifiziert 2026-09-08 (NICHT raten!)
|
||||||
|
("RaspberryMatic","ccu-http", [80], "tcp", "Homematic CCU Web (HTTP)"),
|
||||||
|
("RaspberryMatic","ccu-https", [443], "tcp", "Homematic CCU Web (HTTPS)"),
|
||||||
|
("RaspberryMatic","ccu-rpc-bidcos", [2001], "tcp", "Homematic BidCos-RF XML-RPC"),
|
||||||
|
("RaspberryMatic","ccu-rpc-hmip", [2010], "tcp", "Homematic-IP XML-RPC"),
|
||||||
|
("iob-master", "iobroker-admin", [8081], "tcp", "ioBroker Admin"),
|
||||||
|
("z2m25", "zigbee2mqtt", [8080], "tcp", "Zigbee2MQTT Frontend"),
|
||||||
|
# iob-exposed (.19) + iob_slave (.33): kein exponierter Port gefunden (13 Ports, alle Timeout)
|
||||||
|
# -> BEWUSST kein Service, bis ein Port live belegt ist. Nicht "hilfreich" einen erfinden.
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP37 Dienste-Seed ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
|
||||||
|
# VM-IDs vorab holen (unique Namen, Batch)
|
||||||
|
vm_names = sorted({vm for vm, *_ in SERVICES})
|
||||||
|
print(f"\n[0] VM-IDs holen ({len(vm_names)} unique VMs)")
|
||||||
|
vm_ids = {}
|
||||||
|
for name in vm_names:
|
||||||
|
row = find("virtualization/virtual-machines", {"name": name}, "name", name)
|
||||||
|
if row:
|
||||||
|
vm_ids[name] = row["id"]
|
||||||
|
print(f" = {name}: {row['id']}")
|
||||||
|
else:
|
||||||
|
vm_ids[name] = None
|
||||||
|
print(f" ! {name}: NICHT gefunden — Service-Eintraege werden uebersprungen")
|
||||||
|
|
||||||
|
print(f"\n[1] ipam.Services ({len(SERVICES)} Eintraege)")
|
||||||
|
n_ok = n_skip = 0
|
||||||
|
for vm_name, svc_name, ports, protocol, desc in SERVICES:
|
||||||
|
vm_id = vm_ids.get(vm_name)
|
||||||
|
if not vm_id and COMMIT:
|
||||||
|
print(f" ~ {vm_name}/{svc_name}: VM fehlt, uebersprungen")
|
||||||
|
n_skip += 1
|
||||||
|
continue
|
||||||
|
svc = get_or_create(
|
||||||
|
"ipam/services",
|
||||||
|
{"name": svc_name, "virtual_machine": vm_name},
|
||||||
|
"name", svc_name,
|
||||||
|
{"virtual_machine": vm_id, "name": svc_name,
|
||||||
|
"ports": ports, "protocol": protocol, "description": desc},
|
||||||
|
f"{vm_name}/{protocol}/{ports[0]}",
|
||||||
|
)
|
||||||
|
if svc:
|
||||||
|
n_ok += 1
|
||||||
|
else:
|
||||||
|
n_skip += 1
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) "
|
||||||
|
f"| Services={n_ok} uebersprungen={n_skip} ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
315
nautobot/tp44-cf-sync.py
Normal file
315
nautobot/tp44-cf-sync.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP44 Etappe 2 Teil B — CF-Estate LIVE aus der Cloudflare-API nach Nautobot syncen.
|
||||||
|
|
||||||
|
Ersetzt den Inline-Snapshot von tp44-cf-estate-seed.py durch einen periodischen
|
||||||
|
Live-Pull (get-or-create + Merge). Idempotent, Dry-Run default. Kein Push nach CF —
|
||||||
|
read-only Pull, Nautobot bleibt Dokumentations-SSoT, CF operativer Master.
|
||||||
|
|
||||||
|
Deckung (Live-Pull, je Endpoint 403-tolerant):
|
||||||
|
CloudNetwork cfd_tunnel + warp_connector (4)
|
||||||
|
CloudService workers/scripts + access/apps (11) [Repo-Token OK]
|
||||||
|
CloudService hyperdrive/configs + VPC directory (3) [braucht Token-Scope]
|
||||||
|
|
||||||
|
Verwaisung: ein CF-Objekt, das in Nautobot steht, aber im Live-Pull fehlt, wird
|
||||||
|
NICHT geloescht, sondern extra_config.stale=true gesetzt (Doku-SSoT bewahrt Historie).
|
||||||
|
Ein Objekt, das wieder auftaucht, verliert die Markierung.
|
||||||
|
|
||||||
|
Tokens:
|
||||||
|
CF : CF_API_TOKEN + CF_ACCOUNT_ID aus ~/.secrets/credentials.env (nie im Output)
|
||||||
|
Nautobot: NAUTOBOT_SUPERUSER_API_TOKEN aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/tp44-cf-sync.py # Dry-Run (default) — zeigt Diff
|
||||||
|
python3 nautobot/tp44-cf-sync.py --commit # schreibt
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Cloudflare-Seite
|
||||||
|
def cf_secrets():
|
||||||
|
env = {}
|
||||||
|
with open(os.path.expanduser("~/.secrets/credentials.env")) as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
env[k.strip()] = v.strip().strip("'\"")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
_CF = cf_secrets()
|
||||||
|
CF_TOK, CF_ACCT = _CF.get("CF_API_TOKEN"), _CF.get("CF_ACCOUNT_ID")
|
||||||
|
if not CF_TOK or not CF_ACCT:
|
||||||
|
sys.exit("CF_API_TOKEN/CF_ACCOUNT_ID fehlen in ~/.secrets/credentials.env")
|
||||||
|
|
||||||
|
|
||||||
|
def cf_get(path):
|
||||||
|
"""(ok, result) — faengt 403/404 ab, damit fehlende Token-Scopes den Sync nicht kippen."""
|
||||||
|
req = urllib.request.Request("https://api.cloudflare.com/client/v4" + path,
|
||||||
|
headers={"Authorization": "Bearer " + CF_TOK})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return True, json.load(r).get("result")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code in (403, 404):
|
||||||
|
return False, None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def cf_ingress(tunnel_id):
|
||||||
|
ok, res = cf_get(f"/accounts/{CF_ACCT}/cfd_tunnel/{tunnel_id}/configurations")
|
||||||
|
rules = ((res or {}).get("config") or {}).get("ingress") or [] if ok else []
|
||||||
|
return [r["hostname"] for r in rules if r.get("hostname")]
|
||||||
|
|
||||||
|
|
||||||
|
def load_cf_estate():
|
||||||
|
"""Baut (networks, services) aus dem Live-Pull. Jedes Objekt:
|
||||||
|
(name, resource_type, description, extra_config, match_key, match_val).
|
||||||
|
match_key/-val = stabile CF-ID → idempotentes Matching trotz kuratierter Namen.
|
||||||
|
Live-Zaehl-/Statusfelder liegen unter eigenen Keys (connector_count, ingress_hostnames),
|
||||||
|
damit der Merge kuratierte Strings (z.B. connectors='3x @ ap1/ap2/ap3') nicht ueberschreibt."""
|
||||||
|
networks, services = [], []
|
||||||
|
skipped = []
|
||||||
|
|
||||||
|
# --- CloudNetworks: cloudflared-Tunnel (+ Connector-Zahl, Ingress-Hostnames)
|
||||||
|
ok, tuns = cf_get(f"/accounts/{CF_ACCT}/cfd_tunnel?is_deleted=false&per_page=50")
|
||||||
|
for t in (tuns or []):
|
||||||
|
networks.append((t["name"], "Cloudflare cloudflared Tunnel",
|
||||||
|
f"cloudflared-Tunnel ({len(t.get('connections') or [])} Connectoren)",
|
||||||
|
{"tunnel_id": t["id"], "kind": "cloudflared",
|
||||||
|
"connector_count": len(t.get("connections") or []),
|
||||||
|
"ingress_hostnames": cf_ingress(t["id"])},
|
||||||
|
"tunnel_id", t["id"]))
|
||||||
|
# --- CloudNetworks: WARP-Connector
|
||||||
|
ok, warps = cf_get(f"/accounts/{CF_ACCT}/warp_connector?per_page=50")
|
||||||
|
for w in (warps or []):
|
||||||
|
networks.append((w["name"], "Cloudflare WARP Connector",
|
||||||
|
f"WARP-Connector ({len(w.get('connections') or [])} Connectoren)",
|
||||||
|
{"tunnel_id": w["id"], "kind": "warp_connector",
|
||||||
|
"connector_count": len(w.get("connections") or [])},
|
||||||
|
"tunnel_id", w["id"]))
|
||||||
|
|
||||||
|
# --- CloudServices: Workers
|
||||||
|
ok, scripts = cf_get(f"/accounts/{CF_ACCT}/workers/scripts")
|
||||||
|
for s in (scripts or []):
|
||||||
|
services.append((s["id"], "Cloudflare Worker", "Cloudflare Worker",
|
||||||
|
{"script": s["id"]}, "script", s["id"]))
|
||||||
|
|
||||||
|
# --- CloudServices: Access-Apps
|
||||||
|
ok, apps = cf_get(f"/accounts/{CF_ACCT}/access/apps?per_page=100")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("access/apps")
|
||||||
|
for a in (apps or []):
|
||||||
|
services.append((a.get("name") or a.get("id"), "Cloudflare Access Application",
|
||||||
|
f"Access-App ({a.get('type')})",
|
||||||
|
{"id": a.get("id"), "type": a.get("type"), "domain": a.get("domain")},
|
||||||
|
"id", a.get("id")))
|
||||||
|
|
||||||
|
# --- CloudServices: Hyperdrive (braucht Scope)
|
||||||
|
ok, hyper = cf_get(f"/accounts/{CF_ACCT}/hyperdrive/configs")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("hyperdrive/configs")
|
||||||
|
for h in (hyper or []):
|
||||||
|
origin = h.get("origin") or {}
|
||||||
|
services.append((h.get("name") or h.get("id"), "Cloudflare Hyperdrive",
|
||||||
|
"Hyperdrive-Config",
|
||||||
|
{"id": h.get("id"), "database": origin.get("database"),
|
||||||
|
"user": origin.get("user")}, "id", h.get("id")))
|
||||||
|
|
||||||
|
# --- CloudServices: VPC-Service (braucht Scope)
|
||||||
|
ok, vpc = cf_get(f"/accounts/{CF_ACCT}/connectivity/directory/services")
|
||||||
|
if not ok:
|
||||||
|
skipped.append("connectivity/directory/services")
|
||||||
|
for v in (vpc or []):
|
||||||
|
services.append((v.get("name") or v.get("id"), "Cloudflare VPC Service",
|
||||||
|
"VPC-Service",
|
||||||
|
{"service_id": v.get("id"), "type": v.get("type")},
|
||||||
|
"service_id", v.get("id")))
|
||||||
|
|
||||||
|
return networks, services, skipped
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Nautobot-Seite
|
||||||
|
def nb_token():
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
NB_HDRS = {"Authorization": f"Token {nb_token()}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def nb(method, path, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(f"{NB}{path}", data=data, headers=NB_HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def nb_all(endpoint, params):
|
||||||
|
"""Alle Rows eines Endpoints (paginiert)."""
|
||||||
|
out, url = [], f"/api/{endpoint}/?{urllib.parse.urlencode(dict(params, depth=0, limit=200))}"
|
||||||
|
while url:
|
||||||
|
st, d = nb("GET", url)
|
||||||
|
out += d.get("results", [])
|
||||||
|
nxt = d.get("next")
|
||||||
|
url = nxt.replace(NB, "") if nxt else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_INDEX = {} # endpoint -> [rows] (einmal je Account geladen)
|
||||||
|
|
||||||
|
|
||||||
|
def index(endpoint, acct_id):
|
||||||
|
if endpoint not in _INDEX:
|
||||||
|
_INDEX[endpoint] = nb_all(endpoint, {"cloud_account": acct_id})
|
||||||
|
return _INDEX[endpoint]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert(endpoint, name, payload, label, seen, acct_id=None,
|
||||||
|
match_key=None, match_val=None):
|
||||||
|
"""get-or-create + Merge von extra_config.
|
||||||
|
Matching: bei (match_key,match_val) nach der stabilen CF-ID in extra_config
|
||||||
|
(Rename-fest), sonst nach Name. Trackt die getroffene id in seen[endpoint]."""
|
||||||
|
ex = None
|
||||||
|
if match_val is not None:
|
||||||
|
for row in index(endpoint, acct_id):
|
||||||
|
if (row.get("extra_config") or {}).get(match_key) == match_val:
|
||||||
|
ex = row
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
for row in (index(endpoint, acct_id) if acct_id else
|
||||||
|
nb_all(endpoint, {"name": name})):
|
||||||
|
if row.get("name") == name:
|
||||||
|
ex = row
|
||||||
|
break
|
||||||
|
if ex:
|
||||||
|
seen.setdefault(endpoint, set()).add(ex["id"])
|
||||||
|
merged = dict(ex.get("extra_config") or {})
|
||||||
|
merged.update(payload.get("extra_config") or {})
|
||||||
|
if merged == (ex.get("extra_config") or {}):
|
||||||
|
print(f" = {label}: '{name}' unveraendert")
|
||||||
|
return ex
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" ~ {label}: '{name}' extra_config-Update [DRY-RUN]")
|
||||||
|
return ex
|
||||||
|
st, d = nb("PATCH", f"/api/{endpoint}/{ex['id']}/", {"extra_config": merged})
|
||||||
|
print(f" ~ {label}: '{name}' aktualisiert" if st == 200
|
||||||
|
else f" ! {label}: '{name}' PATCH {st}: {json.dumps(d)[:200]}")
|
||||||
|
return d if st == 200 else ex
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + {label}: '{name}' [DRY-RUN]")
|
||||||
|
return {"id": f"<dry:{name}>"}
|
||||||
|
st, d = nb("POST", f"/api/{endpoint}/", payload)
|
||||||
|
if st in (200, 201):
|
||||||
|
seen.setdefault(endpoint, set()).add(d["id"])
|
||||||
|
print(f" + {label}: '{name}' angelegt")
|
||||||
|
return d
|
||||||
|
print(f" ! {label}: '{name}' FEHLER {st}: {json.dumps(d)[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_stale(endpoint, acct_id, seen_ids, label):
|
||||||
|
"""CF-Objekte in Nautobot (per id), die im Live-Pull fehlen → extra_config.stale=true.
|
||||||
|
Ein wieder aufgetauchtes Objekt verliert die Markierung."""
|
||||||
|
for row in index(endpoint, acct_id):
|
||||||
|
extra = row.get("extra_config") or {}
|
||||||
|
nm = row.get("name")
|
||||||
|
if row["id"] in seen_ids:
|
||||||
|
if extra.get("stale") and COMMIT:
|
||||||
|
nb("PATCH", f"/api/{endpoint}/{row['id']}/",
|
||||||
|
{"extra_config": {k: v for k, v in extra.items() if k != "stale"}})
|
||||||
|
print(f" ↩ {label}: '{nm}' stale-Markierung entfernt (wieder da)")
|
||||||
|
continue
|
||||||
|
if extra.get("stale"):
|
||||||
|
continue
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" ⚠ {label}: '{nm}' fehlt im Live-Pull → wuerde stale markiert [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
nb("PATCH", f"/api/{endpoint}/{row['id']}/", {"extra_config": dict(extra, stale=True)})
|
||||||
|
print(f" ⚠ {label}: '{nm}' als stale markiert (nicht mehr in CF)")
|
||||||
|
|
||||||
|
|
||||||
|
RESOURCE_TYPES = [
|
||||||
|
("Cloudflare cloudflared Tunnel", "cloud.cloudnetwork"),
|
||||||
|
("Cloudflare WARP Connector", "cloud.cloudnetwork"),
|
||||||
|
("Cloudflare VPC Service", "cloud.cloudservice"),
|
||||||
|
("Cloudflare Hyperdrive", "cloud.cloudservice"),
|
||||||
|
("Cloudflare Worker", "cloud.cloudservice"),
|
||||||
|
("Cloudflare Access Application", "cloud.cloudservice"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP44 CF-Sync ({'COMMIT' if COMMIT else 'DRY-RUN'}) CF→{NB} ==")
|
||||||
|
networks, services, skipped = load_cf_estate()
|
||||||
|
print(f"Live-Pull: {len(networks)} CloudNetworks · {len(services)} CloudServices"
|
||||||
|
+ (f" · SKIP (Token-Scope): {', '.join(skipped)}" if skipped else " · alle Endpoints lesbar"))
|
||||||
|
|
||||||
|
print("\n[1] Manufacturer + CloudAccount")
|
||||||
|
mfr = upsert("dcim/manufacturers", "Cloudflare", {"name": "Cloudflare"}, "Manufacturer", {})
|
||||||
|
mfr_id = mfr["id"]
|
||||||
|
acct = upsert("cloud/cloud-accounts", "Cloudflare",
|
||||||
|
{"name": "Cloudflare", "account_number": CF_ACCT, "provider": mfr_id},
|
||||||
|
"CloudAccount", {})
|
||||||
|
acct_id = acct["id"]
|
||||||
|
|
||||||
|
print("\n[2] CloudResourceTypes")
|
||||||
|
rt_ids = {}
|
||||||
|
for name, ct in RESOURCE_TYPES:
|
||||||
|
rt = upsert("cloud/cloud-resource-types", name,
|
||||||
|
{"name": name, "provider": mfr_id, "content_types": [ct]}, "ResourceType", {})
|
||||||
|
rt_ids[name] = rt["id"]
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
print("\n[3] CloudNetworks")
|
||||||
|
for name, rt_name, desc, extra, mk, mv in networks:
|
||||||
|
upsert("cloud/cloud-networks", name,
|
||||||
|
{"name": name, "cloud_resource_type": rt_ids.get(rt_name), "cloud_account": acct_id,
|
||||||
|
"description": desc, "extra_config": extra}, "CloudNetwork", seen,
|
||||||
|
acct_id=acct_id, match_key=mk, match_val=mv)
|
||||||
|
|
||||||
|
print("\n[4] CloudServices")
|
||||||
|
for name, rt_name, desc, extra, mk, mv in services:
|
||||||
|
upsert("cloud/cloud-services", name,
|
||||||
|
{"name": name, "cloud_resource_type": rt_ids.get(rt_name), "cloud_account": acct_id,
|
||||||
|
"description": desc, "extra_config": extra}, "CloudService", seen,
|
||||||
|
acct_id=acct_id, match_key=mk, match_val=mv)
|
||||||
|
|
||||||
|
print("\n[5] Verwaisung (stale-Markierung; nur Endpoints, die gelesen wurden)")
|
||||||
|
# Nur markieren, wenn der zugehoerige Endpoint erreichbar war — sonst wuerde ein
|
||||||
|
# fehlender Token-Scope echte Objekte faelschlich als verwaist markieren.
|
||||||
|
if "access/apps" not in skipped and "hyperdrive/configs" not in skipped \
|
||||||
|
and "connectivity/directory/services" not in skipped:
|
||||||
|
mark_stale("cloud/cloud-services", acct_id, seen.get("cloud/cloud-services", set()), "CloudService")
|
||||||
|
else:
|
||||||
|
print(" · CloudService-Verwaisung uebersprungen (Token-Scope unvollstaendig — kein sicherer Vergleich)")
|
||||||
|
mark_stale("cloud/cloud-networks", acct_id, seen.get("cloud/cloud-networks", set()), "CloudNetwork")
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||||
|
if skipped:
|
||||||
|
print(f"Hinweis: {', '.join(skipped)} nicht lesbar → Token-Scope erweitern, dann syncen sie mit.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
119
nautobot/tp44-ipam-prefixes.py
Normal file
119
nautobot/tp44-ipam-prefixes.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP44 Etappe 2 Teil A — die zwei fehlenden IPAM-Prefixe der CF-CloudNetworks anlegen.
|
||||||
|
|
||||||
|
Zwei CloudNetworks (tp44-cf-estate-seed.py) tragen eine Route, die der Seeder als
|
||||||
|
cloud-network-prefix-assignment verknuepfen wuerde -- aber nur, wenn der Prefix in
|
||||||
|
IPAM existiert (dort Schritt [7], sonst "~ Prefix ... nicht in IPAM"):
|
||||||
|
|
||||||
|
10.40.0.0/16 -> bf-core-mesh (WARP-Connector auf crs326-core, TP16.1) [container]
|
||||||
|
10.101.0.0/24 -> fm.emsenhuber.net (Standort FM, cloudflared-Tunnel) [network]
|
||||||
|
|
||||||
|
Idempotenter get-or-create (Dry-Run default). Nach dem --commit den Seeder erneut
|
||||||
|
laufen lassen -- er haengt die zwei Assignments dann automatisch ein.
|
||||||
|
|
||||||
|
10.40.0.0/16 als 'container', weil die VLAN-/24 (10.40.10/20/30/...) aus
|
||||||
|
ingest-baseline.py bereits als Prefixe existieren; Nautobot nistet sie
|
||||||
|
automatisch darunter (rein hierarchisch, nicht-destruktiv).
|
||||||
|
|
||||||
|
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||||
|
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/tp44-ipam-prefixes.py # Dry-Run (default)
|
||||||
|
python3 nautobot/tp44-ipam-prefixes.py --commit # legt an
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
url = f"{NB}{path}"
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(url, data=data, headers=HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def find(endpoint: str, params: dict, match_key: str, match_val: str):
|
||||||
|
q = urllib.parse.urlencode(dict(params, depth=0))
|
||||||
|
st, d = req("GET", f"/api/{endpoint}/?{q}")
|
||||||
|
for row in d.get("results", []):
|
||||||
|
if row.get(match_key) == match_val:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def status_id(name: str):
|
||||||
|
return find("extras/statuses", {"name": name}, "name", name)["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_id(name: str):
|
||||||
|
return find("ipam/namespaces", {"name": name}, "name", name)["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Inventar
|
||||||
|
# (prefix, type, description)
|
||||||
|
PREFIXES = [
|
||||||
|
("10.40.0.0/16", "container",
|
||||||
|
"BF-Supernet; Route des CF WARP-Connectors bf-core-mesh (crs326-core, TP16.1)"),
|
||||||
|
("10.101.0.0/24", "network",
|
||||||
|
"Standort FM; Route des CF cloudflared-Tunnels fm.emsenhuber.net"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP44 IPAM-Prefixe ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
st_active = status_id("Active")
|
||||||
|
ns_global = namespace_id("Global")
|
||||||
|
|
||||||
|
for prefix, ptype, desc in PREFIXES:
|
||||||
|
existing = find("ipam/prefixes", {"prefix": prefix}, "prefix", prefix)
|
||||||
|
if existing:
|
||||||
|
print(f" = Prefix: '{prefix}' existiert ({existing['id']})")
|
||||||
|
continue
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + Prefix: '{prefix}' [{ptype}] [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
payload = {"prefix": prefix, "status": st_active, "namespace": ns_global,
|
||||||
|
"type": ptype, "description": desc}
|
||||||
|
st, d = req("POST", "/api/ipam/prefixes/", payload)
|
||||||
|
if st in (200, 201):
|
||||||
|
print(f" + Prefix: '{prefix}' [{ptype}] angelegt ({d.get('id')})")
|
||||||
|
else:
|
||||||
|
print(f" ! Prefix: '{prefix}' FEHLER {st}: {json.dumps(d)[:300]}")
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||||
|
if not COMMIT:
|
||||||
|
print("Danach: python3 nautobot/tp44-cf-estate-seed.py --commit (haengt die 2 Assignments ein)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
75
opencode.json
Normal file
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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user