Compare commits
72 Commits
f8982df200
...
tp45-schem
| Author | SHA1 | Date | |
|---|---|---|---|
| b91bac1265 | |||
| b509a2325e | |||
| 4d273117b6 | |||
| 92bd4aac85 | |||
| 974ecbc855 | |||
| 139d7c029d | |||
| b52d6aa49e | |||
| 8c6cb84dd1 | |||
| e87619e89b | |||
| 177ba100eb | |||
| bce40d182d | |||
| 1d86983f63 | |||
| 27effe937e | |||
| 2cf32bde14 | |||
| 1f19a3b4a9 | |||
| 3e727f1b3b | |||
| 037d2bd22c | |||
| 0e6742f1d7 | |||
| b7d82fe300 | |||
| 13a996c6d9 | |||
| 2a21afe74a | |||
| 9fdcab58ad | |||
| 441e97ed84 | |||
| 89eb9dc3ac | |||
| 4e80ffac28 | |||
| 548d73d84a | |||
| 3eb03ae7b4 | |||
| 3a97bbc41c | |||
| 7d237577e1 | |||
| 777c4e0984 | |||
| 425070ce11 | |||
| ae87e9f511 | |||
| f941731ac4 | |||
| 3877c23505 | |||
| 123a0fefdf | |||
| aebf3776ac | |||
| b6f2129b41 | |||
| 82cd8098ef | |||
| 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 | |||
| 56746cb24d | |||
| a0ba87d555 | |||
| 674e5fff0d | |||
| c024f85579 | |||
| 9caf145341 | |||
| 7edf01b021 | |||
| f507c50623 |
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
CLAUDE.md
77
CLAUDE.md
@@ -6,7 +6,13 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
|
|
||||||
## Pflichtlektüre pro Sitzung (Reihenfolge)
|
## Pflichtlektüre pro Sitzung (Reihenfolge)
|
||||||
|
|
||||||
1. **`docs/teilprojekte.md`** (Quick-Index-Block) — kanonischer Stand, Scope, Abhängigkeiten, **offene** TPs. **1 Read reicht.**
|
0. 🔴 **TP45 = aktuell dringendster Posten (2026-09-21, Gitea #71):** interne `*.bf.emsenhuber.net`
|
||||||
|
lösen im Browser **nicht** auf (AGH wertet Rewrites im **Filter-Pfad** aus → für unfiltrierte
|
||||||
|
Clients übersprungen → Fallback auf den Core, der nur 10 von 26 Namen hält), und `ds-bf` ist
|
||||||
|
**öffentlich kaputt** (einziger Ingress-Eintrag, der Caddy umgeht → TP5-Drop). Reihenfolge:
|
||||||
|
VLAN20 ins TP32-Schema → VLAN30-Pool-Grenze → DNS/FQDN. Ausführlich: `### TP45`.
|
||||||
|
**`### TP46`** = CF-/Tunnel-SSoT + Recovery (eigene Baustelle).
|
||||||
|
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Für den **Detail/Kontext** eines TP dann den `### TPnn`-Prosa-Abschnitt in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`** lesen. *(Die frühere „Quick-Index-Block, 1 Read reicht"-Zeile ist überholt — Index/Status stehen nicht mehr in der Datei.)*
|
||||||
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat.
|
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat.
|
||||||
3. Bei Live-Writes oder Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/teilprojekte-archiv.md`**.
|
3. Bei Live-Writes oder Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/teilprojekte-archiv.md`**.
|
||||||
4. ✅ **TP16.3 §5 durch (2026-09-01):** [`docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md`](docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md)
|
4. ✅ **TP16.3 §5 durch (2026-09-01):** [`docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md`](docs/handoff-20260901-tp16.3-paragraph5-flip-durch.md)
|
||||||
@@ -20,6 +26,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
am 09-01 zurueckgebaut; Hebel 2–4 entfallen, **kein Geraete-Write** (Soll stand: `ft=yes ft-over-ds=yes`,
|
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)
|
||||||
@@ -34,15 +46,30 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
6. ap3-Bring-up 2026-08-30: [`docs/handoff-20260830-ap3.md`](docs/handoff-20260830-ap3.md) — sechs Funde zu Werks-MikroTiks, CAPsMAN und Golden-Config-Lücken.
|
6. ap3-Bring-up 2026-08-30: [`docs/handoff-20260830-ap3.md`](docs/handoff-20260830-ap3.md) — sechs Funde zu Werks-MikroTiks, CAPsMAN und Golden-Config-Lücken.
|
||||||
7. Inventur-Befund 2026-08-27: [`docs/handoff-20260827-inventur-und-architektur.md`](docs/handoff-20260827-inventur-und-architektur.md) — Master-Struktur, Asymmetrie, Drift.
|
7. Inventur-Befund 2026-08-27: [`docs/handoff-20260827-inventur-und-architektur.md`](docs/handoff-20260827-inventur-und-architektur.md) — Master-Struktur, Asymmetrie, Drift.
|
||||||
|
|
||||||
|
## SSoT & Branch-Konvention (TP36-Cutover 2026-09-07)
|
||||||
|
|
||||||
|
- **SSoT-Dreiteilung:** **Index/Status = Gitea-Issues** (`admin/bf-infra`, Labels `status:*`/`thema:*`) ·
|
||||||
|
**Prosa/Detail = Repo** (`docs/teilprojekte.md` aktiv + `teilprojekte-archiv.md`, `### TPnn`-Abschnitte) ·
|
||||||
|
**Live-Gerätedaten = Nautobot** (Compliance-Zahl, RouterOS-Version …). Ein Status ändert sich **in Gitea**,
|
||||||
|
nicht mehr in einer Repo-Datei. Sync-/Abgleich-Werkzeug fürs Archiv: `bin/gitea-tp-sync.py` (Dry-Run default,
|
||||||
|
Token aus `~/.hermes/.env`, **nie ausgeben**).
|
||||||
|
- **Branch-Konvention:** Branch `tpNN-kurz` **jederzeit** mitten in der Session möglich (`git switch -c`,
|
||||||
|
uncommittete Arbeit wandert mit); für Multi-Session-TPs der Normalfall. **Merge nach `main`, sobald der
|
||||||
|
Code/Config des TP steht.** Weil Steuerungs-Updates nach **Gitea** gehen (nicht in `teilprojekte.md`),
|
||||||
|
kollidieren TP-Branches nicht mehr auf der Steuerungs-Doku — nur noch der eigene `### TPnn`-Prosa-Abschnitt
|
||||||
|
wird angefasst.
|
||||||
|
- **TP anlegen/schließen:** neues aktives TP = **direkt als Gitea-Issue** (Labels setzen), Prosa-Abschnitt
|
||||||
|
im Repo dazu; abschließen = Issue `closed`, Prosa wandert bei Bedarf ins Archiv. **Keine Handtabelle mehr.**
|
||||||
|
|
||||||
## Was du sofort wissen musst (Inventur 2026-08-27)
|
## Was du sofort wissen musst (Inventur 2026-08-27)
|
||||||
|
|
||||||
- **`bf-infra`** ist aktiver Master **mit Gitea-Remote**: `origin` = `https://gitea.emsenhuber.net/admin/bf-infra.git` (live verifiziert 2026-08-30). ⚠️ **Die frühere Zeile „lokal, kein Gitea-Remote → verloren bei Festplattencrash" ist überholt** — sie stammt aus der Inventur vom 2026-08-27 und war damals richtig; das Remote existiert seit spätestens 2026-08-28 (`origin/main` = `b917691`). **Die Backup-Lücke ist damit keine Struktur-, sondern eine Disziplinfrage: `git push`.** Am 2026-08-30 lagen vier Commits nur lokal. 🔴 **Das Remote ist nur so viel wert wie der letzte Push** — der Backup-Guard (`~/dev/.bin/backup-guard.sh`, TP35 Schicht 5) bricht Commits ab, solange kein Backup markiert ist; **das ist die Erinnerung ans Pushen, kein Hindernis.** Nicht mit `--no-verify` umgehen. 🔴 **Merkregel:** diese Zeile hat eine Session dazu gebracht, „kein Remote" zu behaupten, statt `git remote -v` zu tippen — vor jeder Aussage über Remotes/Backups **live prüfen**.
|
- **`bf-infra`** 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`.
|
||||||
|
|
||||||
@@ -50,12 +77,15 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
|||||||
|
|
||||||
Wenn während einer Session ein neues Teilprojekt, eine Abhängigkeit oder ein Querschnittsthema sichtbar wird, das bisher in keinem TP abgebildet ist:
|
Wenn während einer Session ein neues Teilprojekt, eine Abhängigkeit oder ein Querschnittsthema sichtbar wird, das bisher in keinem TP abgebildet ist:
|
||||||
- Vorschlagen und mit dir abstimmen (nicht eigenmächtig anlegen).
|
- Vorschlagen und mit dir abstimmen (nicht eigenmächtig anlegen).
|
||||||
- Auf dein Okay hin anlegen: Quick-Index-Zeile + `### TP<n>`-Abschnitt mit Status `🔜`, Scope,
|
- Auf dein Okay hin anlegen: **Gitea-Issue** `TPnn — <Kurztitel>` mit Labels `status:🔜`(todo) +
|
||||||
Abhängigkeiten, Abgrenzung. Nächste freie Nummer vergeben (höchste vergebene + 1; Lücken aus
|
passendem `thema:*` + Body (Anriss + Verweis auf den Prosa-Abschnitt) **und** einen
|
||||||
verworfenen TPs bleiben frei).
|
`### TP<n>`-Prosa-Abschnitt in `docs/teilprojekte.md` (Scope, Abhängigkeiten, Abgrenzung).
|
||||||
- Ist-Stand-Block mitziehen (Datum, betroffene Zeilen).
|
Nächste freie Nummer vergeben (höchste vergebene + 1; Lücken aus verworfenen TPs bleiben frei).
|
||||||
|
- **Status/Reihenfolge/„was lief zuletzt" gehen ins Gitea-Issue** (Label ändern, Kommentar), **nicht**
|
||||||
|
in eine Quick-Index-Tabelle oder einen Ist-Stand-Block — die gibt es seit dem TP36-Cutover (2026-09-07)
|
||||||
|
nicht mehr in der Datei.
|
||||||
|
|
||||||
**Abgeschlossene TPs** stehen seit 2026-08-23 in `docs/teilprojekte-archiv.md` (Volltext, unverändert, mit Inhaltsverzeichnis). **Ein TP wandert erst ins Archiv, wenn nichts mehr daran zu tun ist — und wieder zurück, sobald doch etwas dazukommt.** Ein archiviertes TP ist ein *geschlossenes Ticket, kein versiegeltes*: bei neuer Arbeit an derselben Baustelle wandert der **Volltext zurück** nach `teilprojekte.md` (Zeiger bleibt im Archiv), **kein** Zwillings-TP mit neuer Nummer (Christoph-Direktive 2026-08-30, erster Fall TP28/`ap3`) — und der **Ist-Stand-Block wird bei jeder Änderung mitgezogen**, nicht nur der betroffene TP-Abschnitt.
|
**Abgeschlossene TPs** stehen seit 2026-08-23 in `docs/teilprojekte-archiv.md` (Volltext, unverändert, mit Inhaltsverzeichnis) und ihr Gitea-Issue ist `closed` (Label `status:✅`/`❌`). **Ein TP wandert erst ins Archiv, wenn nichts mehr daran zu tun ist — und wieder zurück, sobald doch etwas dazukommt** (Issue dann `reopen`). Ein archiviertes TP ist ein *geschlossenes Ticket, kein versiegeltes*: bei neuer Arbeit an derselben Baustelle wandert der **Volltext zurück** nach `teilprojekte.md` (Zeiger bleibt im Archiv), **kein** Zwillings-TP mit neuer Nummer (Christoph-Direktive 2026-08-30, erster Fall TP28/`ap3`) — nur noch der betroffene `### TPnn`-Prosa-Abschnitt wird angefasst.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,15 +110,15 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~ALLNET Core-Switch (sw1)~~ | — | ❌ **außer Betrieb (2026-08-08)** — durch `crs326-core` (L3-Router + Core-Switch, Technik UG) ersetzt; physisch abgelöst. (Telnet-Runner `switch/` = nur noch historisch.) |
|
| ~~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`** |
|
||||||
| Nautobot (Portainer-Stack, NAS) | 10.40.20.30 | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
| Nautobot (Portainer-Stack, NAS) | **10.40.20.154** (TP45 Schicht A 2026-09-21: `.30/.31/.32/.34` → `.154/.155/.156/.158`) | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
||||||
| ~~Omada-Controller~~ | — | ❌ **entfernt 2026-07-16** (User hat den Container gelöscht — overkill für 1 Switch auf der Abschussliste, s. TP2.1/TP15) |
|
| ~~Omada-Controller~~ | — | ❌ **entfernt 2026-07-16** (User hat den Container gelöscht — overkill für 1 Switch auf der Abschussliste, s. TP2.1/TP15) |
|
||||||
| ~~omada-mcp~~ | — | ❌ **entfernt 2026-07-16** — mit dem Controller gelöscht. ✅ **Aufräumen erledigt/gegenstandslos (geprüft 2026-08-16):** nicht mehr in `mcpServers` registriert (nur noch als „disabled" in ein paar Projekt-Scopes = harmloser Merkposten), und **kein `omada`-Hostname mehr im CF-Ingress** |
|
| ~~omada-mcp~~ | — | ❌ **entfernt 2026-07-16** — mit dem Controller gelöscht. ✅ **Aufräumen erledigt/gegenstandslos (geprüft 2026-08-16):** nicht mehr in `mcpServers` registriert (nur noch als „disabled" in ein paar Projekt-Scopes = harmloser Merkposten), und **kein `omada`-Hostname mehr im CF-Ingress** |
|
||||||
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1 2026-07-10) · **mcp-pve v0.6.1 (Upgrade 2026-07-10: Single-Session-Bug 0.4.8 „Server already initialized" gefixt, TP14.2a)** · `~/.claude.json` ✓ |
|
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1 2026-07-10) · **mcp-pve v0.6.1 (Upgrade 2026-07-10: Single-Session-Bug 0.4.8 „Server already initialized" gefixt, TP14.2a)** · `~/.claude.json` ✓ |
|
||||||
@@ -126,6 +156,25 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
|||||||
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans
|
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans
|
||||||
entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
|
entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
|
||||||
- **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
|
- **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
|
||||||
|
- 🔴 **`pct push` meldet Exit 0, auch wenn es die Quelldatei NICHT lesen kann** (gemessen 2026-09-21).
|
||||||
|
`pct push 130 <fehlende-datei> /etc/caddy/Caddyfile` → `failed to open … for reading`, **`rc=0`**.
|
||||||
|
Ein `&&`/`||` hinter dem Push **beweist nichts**, und ein anschließendes `validate`/`reload` prüft
|
||||||
|
dann die **alte** Datei → **grüne Abnahme bei unverändertem Zustand**. Genau so sah der
|
||||||
|
Nautobot-Umzug am 2026-09-21 „gelandet" aus, während live noch die alte Backend-IP stand
|
||||||
|
(Symptom: 502 bei gesundem Backend). **Regel: Push-Werkzeug wird geprüft, nicht geglaubt** — nach
|
||||||
|
jedem `pct push` den Inhalt vergleichen (`md5sum` diesseits/jenseits in *einem* Remote-Aufruf,
|
||||||
|
Abbruch bei Abweichung). Muster: `push_verified()` in `caddy/deploy.sh`. Dieselbe Klasse wie
|
||||||
|
`nc -z` (09-01) und das `grep|sed`-Maskierungsmuster (08-31): **erst das eigene Werkzeug
|
||||||
|
verdächtigen, dann das Netz.**
|
||||||
|
- 🔴 **Die Core-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert** (Fund 2026-09-21).
|
||||||
|
Der Mgmt-Zugang des Cores für die Deploy-Pipeline läuft über `src-address-list=nautobot`
|
||||||
|
(Regel `*28`) — die Liste selbst stammt aber aus dem **handgelaufenen**
|
||||||
|
`routeros/tp5-etappe2-whitelists.rsc` und steht in **keinem** Template (`grep address-list` im
|
||||||
|
Template findet nur Kommentare). 🔴 **Ein GC-Recovery von null erzeugt damit einen Core, in den
|
||||||
|
Nautobot nicht mehr per SSH kommt** — die Pipeline wäre tot und könnte sich nicht selbst
|
||||||
|
reparieren. Gleiche Lücke wie die DHCP-Reservierungen. **Merkregel: was auf dem Gerät existiert,
|
||||||
|
aber nicht im Template, ist eine Recovery-Lücke, auch wenn Compliance 140/140 grün meldet** —
|
||||||
|
die Zahl prüft nur, was gerendert wird.
|
||||||
Ablösung durch Komodo (native MCP) geplant.
|
Ablösung durch Komodo (native MCP) geplant.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -258,7 +307,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
|||||||
danach die Ports gegen vorher vergleichen**, sonst verliert man still einen Dienst.
|
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.
|
||||||
|
"""
|
||||||
@@ -13,7 +13,9 @@ http:
|
|||||||
session_ttl: 30d
|
session_ttl: 30d
|
||||||
users:
|
users:
|
||||||
- name: admin
|
- name: admin
|
||||||
password: $2a$10$ue8PEedG4t2JRyT83XiGHeqIU4PFRzhn8rjDOZGYGt0sFC2qju2fi
|
password: '!on-device (siehe ~/.secrets + Gerät)'
|
||||||
|
- name: automation
|
||||||
|
password: '!on-device (siehe ~/.secrets + Gerät)'
|
||||||
auth_attempts: 5
|
auth_attempts: 5
|
||||||
block_auth_min: 15
|
block_auth_min: 15
|
||||||
http_proxy: ""
|
http_proxy: ""
|
||||||
@@ -69,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
|
||||||
@@ -202,69 +204,9 @@ filtering:
|
|||||||
- domain: gitea.emsenhuber.net
|
- domain: gitea.emsenhuber.net
|
||||||
answer: 10.40.20.40
|
answer: 10.40.20.40
|
||||||
enabled: true
|
enabled: true
|
||||||
- domain: nautobot.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: ds-bf.emsenhuber.net
|
- domain: ds-bf.emsenhuber.net
|
||||||
answer: 10.40.20.40
|
answer: 10.40.20.40
|
||||||
enabled: true
|
enabled: true
|
||||||
- domain: ccu.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: iobroker.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: z2m25.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: pve-mcp.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: ha-mcp.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: n8n-mcp.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: gitea-mcp.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: pve.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: portainer.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: pbs.emsenhuber.net
|
|
||||||
answer: 10.40.20.40
|
|
||||||
enabled: true
|
|
||||||
- domain: macmini.emsenhuber.net
|
|
||||||
answer: 10.40.20.167
|
|
||||||
enabled: true
|
|
||||||
- domain: mfc.emsenhuber.net
|
|
||||||
answer: 10.40.10.6
|
|
||||||
enabled: true
|
|
||||||
- domain: tv.emsenhuber.net
|
|
||||||
answer: 10.40.40.11
|
|
||||||
enabled: true
|
|
||||||
- domain: docker.emsenhuber.net
|
|
||||||
answer: 10.40.20.126
|
|
||||||
enabled: true
|
|
||||||
- domain: platform-pg.emsenhuber.net
|
|
||||||
answer: 10.40.20.20
|
|
||||||
enabled: true
|
|
||||||
- domain: core.emsenhuber.net
|
|
||||||
answer: 10.40.99.1
|
|
||||||
enabled: true
|
|
||||||
- domain: ap1.emsenhuber.net
|
|
||||||
answer: 10.40.99.11
|
|
||||||
enabled: true
|
|
||||||
- domain: ap2.emsenhuber.net
|
|
||||||
answer: 10.40.99.12
|
|
||||||
enabled: true
|
|
||||||
- domain: branch.emsenhuber.net
|
|
||||||
answer: 10.40.99.2
|
|
||||||
enabled: true
|
|
||||||
- domain: nas.bf.emsenhuber.net
|
- domain: nas.bf.emsenhuber.net
|
||||||
answer: 10.40.20.10
|
answer: 10.40.20.10
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -316,7 +258,232 @@ clients:
|
|||||||
rdns: true
|
rdns: true
|
||||||
dhcp: true
|
dhcp: true
|
||||||
hosts: true
|
hosts: true
|
||||||
persistent: []
|
persistent:
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: dns-bypass-exempt
|
||||||
|
ids:
|
||||||
|
- 10.40.10.82
|
||||||
|
- 10.40.10.183
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 381d431a-1a43-41ea-adaa-2d67ada9aab0
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: false
|
||||||
|
filtering_enabled: false
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: true
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-guest
|
||||||
|
ids:
|
||||||
|
- 10.40.90.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: af70dd86-43a9-4165-8228-26c7b90db5e6
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: false
|
||||||
|
filtering_enabled: true
|
||||||
|
parental_enabled: true
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-infra
|
||||||
|
ids:
|
||||||
|
- 10.40.20.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 78b2da82-1946-41ee-b386-b04eaee49bc3
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: false
|
||||||
|
filtering_enabled: false
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-iot
|
||||||
|
ids:
|
||||||
|
- 10.40.30.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 7a8ac674-4365-4b06-ad51-2516400903a1
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: true
|
||||||
|
filtering_enabled: true
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-main
|
||||||
|
ids:
|
||||||
|
- 10.40.10.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 39955cc1-6ec2-4cf6-a757-233672b9dc3d
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: true
|
||||||
|
filtering_enabled: true
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-media
|
||||||
|
ids:
|
||||||
|
- 10.40.40.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 44e4a1d7-d1b6-446c-a753-65e714ec93aa
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: false
|
||||||
|
filtering_enabled: false
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-mgmt
|
||||||
|
ids:
|
||||||
|
- 10.40.99.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 4c5eeafb-f7f2-4fb1-ab87-fd2a2366d57a
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: false
|
||||||
|
filtering_enabled: false
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
|
- safe_search:
|
||||||
|
enabled: false
|
||||||
|
bing: false
|
||||||
|
duckduckgo: false
|
||||||
|
ecosia: false
|
||||||
|
google: false
|
||||||
|
pixabay: false
|
||||||
|
yandex: false
|
||||||
|
youtube: false
|
||||||
|
blocked_services:
|
||||||
|
schedule:
|
||||||
|
time_zone: Local
|
||||||
|
ids: []
|
||||||
|
name: grp-untrusted
|
||||||
|
ids:
|
||||||
|
- 10.40.50.0/24
|
||||||
|
tags: []
|
||||||
|
upstreams: []
|
||||||
|
uid: 7f182498-7b82-4580-b1b0-0a2d46c205ef
|
||||||
|
upstreams_cache_size: 0
|
||||||
|
upstreams_cache_enabled: false
|
||||||
|
use_global_settings: true
|
||||||
|
filtering_enabled: true
|
||||||
|
parental_enabled: false
|
||||||
|
safebrowsing_enabled: false
|
||||||
|
use_global_blocked_services: true
|
||||||
|
ignore_querylog: false
|
||||||
|
ignore_statistics: false
|
||||||
log:
|
log:
|
||||||
enabled: true
|
enabled: true
|
||||||
file: ""
|
file: ""
|
||||||
|
|||||||
75
adguard/README.md
Normal file
75
adguard/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# AdGuard Home — `agh-bf1` (.36) / `agh-bf2` (.37) · TP23 Stufe 2/4/5
|
||||||
|
|
||||||
|
Das AGH-**Paar** (pve LXC 110/111, `10.40.20.36` / `.37`) ist der Client-Resolver des BF-Netzes
|
||||||
|
(DHCP liefert beide in allen 7 VLANs). Upstream: DoH zum CF-Gateway; conditional
|
||||||
|
`[/bf.emsenhuber.net/] → 10.40.20.1`. **Kein natives HA** → Sync ist Betriebsaufwand.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `AdGuardHome.yaml` | **Repo-SSoT** der Config. **Passwort-Hashes REDIGIERT** (`'<redacted>'`) — keine Secrets im Git. Echte Hashes leben auf den Geräten; Klartext des API-Users in `~/.secrets`. |
|
||||||
|
| `agh-sync.py` | `check` (Drift `.36`↔`.37`) · `push` (SSoT auf beide, gestaffelter Restart, reinjiziert Hashes live). |
|
||||||
|
| `agh-exception.py` | Exception-Primitive (Stufe 5) gegen **beide** Instanzen. |
|
||||||
|
|
||||||
|
## Client-Policy (Stufe 4, `clients.persistent`)
|
||||||
|
Ein VLAN = eine CIDR-Gruppe. Alle nutzen denselben CF-Upstream-Filter; Divergenz = per-Client-Toggle.
|
||||||
|
|
||||||
|
| Gruppe | CIDR | filtering | safe_search | parental |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| grp-main | 10.40.10.0/24 | an (global) | – | – |
|
||||||
|
| grp-infra | 10.40.20.0/24 | **aus** | – | – |
|
||||||
|
| grp-iot | 10.40.30.0/24 | an | – | – |
|
||||||
|
| grp-media | 10.40.40.0/24 | **aus** | – | – |
|
||||||
|
| grp-untrusted | 10.40.50.0/24 | an | – | – |
|
||||||
|
| grp-guest | 10.40.90.0/24 | an | **on** | **on** |
|
||||||
|
| grp-mgmt | 10.40.99.0/24 | **aus** | – | – |
|
||||||
|
| dns-bypass-exempt | 10.40.10.183 + .82 | **aus** | – | – |
|
||||||
|
|
||||||
|
`filtering aus` bei INFRA/MEDIA/MGMT = bewusste Reduktion (Streaming/Server/Mgmt nicht brechen), per Gruppe rückholbar.
|
||||||
|
|
||||||
|
## Personen-Clients (Stufe 5 Redesign 2026-09-12) — SSoT für Person→Gerät
|
||||||
|
Zusätzlich zu den VLAN-CIDR-Gruppen bekommt **jede Person einen eigenen AGH-Client**
|
||||||
|
(`/control/clients`): `name` = Personenname, `ids` = **alle** Geräte-IPs der Person. Das ist der
|
||||||
|
**einzige** Ort, an dem Person→Gerät gepflegt wird (kein zweites Mapping-File). Damit:
|
||||||
|
- wirken Self-Service-Ausnahmen **pro Person**: `@@||domain^$client='Personenname'` statt global;
|
||||||
|
- filtert `recent-blocked --client <person>` das Query-Log auf **ihre** Geräte;
|
||||||
|
- bleiben Ausnahmen stabil bei Gerätewechsel (Name statt IP).
|
||||||
|
|
||||||
|
🔴 **On-network verifizieren, bevor Stufe 6 scharf geht:** dass AGH `$client=` auf **Allow**-Regeln
|
||||||
|
(`@@`) in der laufenden Version greift. Fällt der Test negativ aus → Fallback `$client=<ip>|<ip>`
|
||||||
|
(die Geräte-IPs); `agh-exception.py scoped_rule()` kann beides. Die CIDR-Gruppen (Policy-Grenze)
|
||||||
|
bleiben unberührt — Personen-Clients tragen **nur** die Ausnahmen.
|
||||||
|
|
||||||
|
## Sync-Invariante
|
||||||
|
AGH **schreibt die YAML beim Restart um** (normalisiert) → **nicht** gegen die Repo-Datei diffen,
|
||||||
|
sondern **`.36` == `.37`**. Genau das prüft `agh-sync.py check`.
|
||||||
|
|
||||||
|
## Exception-Contract (Stufe 5 — was die HA-Schicht auslöst)
|
||||||
|
Auth: Basic, User `automation` (`~/.secrets` → `AGH_AUTOMATION_*`). **Immer auf `.36` UND `.37`.**
|
||||||
|
|
||||||
|
🔴 **Wichtig für D:** die beiden AGH-Endpunkte sind **read-modify-write**, kein simples Append —
|
||||||
|
`set_rules` **ersetzt die ganze Liste**, `clients/update` braucht das **volle Client-Objekt**.
|
||||||
|
Ein zustandsloses HA-`rest_command` reicht deshalb **nicht**; `agh-exception.py` ist die
|
||||||
|
Referenz-RMW-Implementierung, die D aufruft (Deploy-Weg auf HAOS in Etappe D zu entscheiden:
|
||||||
|
`shell_command`/`command_line` gegen die deployte CLI **oder** ein dünner Wrapper-Endpunkt).
|
||||||
|
|
||||||
|
| Aktion | CLI | AGH-API (RMW) |
|
||||||
|
|---|---|---|
|
||||||
|
| Domain **pro Person** entsperren | `allow-domain <d> --client <person>` | `GET /control/filtering/status` → +`@@\|\|<d>^$client='<person>'` → `POST /control/filtering/set_rules` |
|
||||||
|
| Domain global entsperren (Admin) | `allow-domain <d>` | dito, ohne `$client` |
|
||||||
|
| Entsperrung zurück | `unallow-domain <d> [--client <person>]` | dito, exakt dieselbe Regel entfernen |
|
||||||
|
| „meine Ausnahmen" | `list-exceptions --client <person> --json` | `GET /control/filtering/status`, `@@`-Regeln parsen/filtern |
|
||||||
|
| „zuletzt geblockt" | `recent-blocked --client <person> --json` | `GET /control/querylog?response_status=blocked`, client-seitig auf Geräte-IPs gefiltert |
|
||||||
|
| Client entfiltern (grob) | `unfilter <ip>` | `GET /control/clients` → Objekt, `filtering_enabled=false` → `POST /control/clients/update` |
|
||||||
|
| Client wieder filtern | `refilter <ip>` | dito, zurück auf global |
|
||||||
|
|
||||||
|
Reine Regel-/Filter-Logik ist netzunabhängig getestet: `python3 agh-exception.py selftest`.
|
||||||
|
|
||||||
|
**Kein Timing in der CLI** — Ablauf/Revert macht der Aufrufer (HA-Timer) und, ab Stufe 6, der
|
||||||
|
RouterOS-Backstop.
|
||||||
|
|
||||||
|
## RouterOS-Backstop (Stufe 6, noch NICHT gebaut)
|
||||||
|
Analog `jit-admin` (`routeros/tp5-etappe4-input-mgmt.rsc`): Address-List `jit-dns-bypass` mit
|
||||||
|
`timeout` + Forward-Regel, die dieser Liste 53/853 nach außen erlaubt — der Fail-safe, falls HA
|
||||||
|
den Revert verpasst. Gebaut via **Nautobot GC** (kein Direkt-Write), erst wenn die Bypass-Sperre
|
||||||
|
scharf geschaltet wird.
|
||||||
287
adguard/agh-exception.py
Executable file
287
adguard/agh-exception.py
Executable file
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agh-exception.py — TP23 Stufe 5 Exception-Primitive gegen das AGH-Paar (.36 + .37).
|
||||||
|
|
||||||
|
Von der HA-Self-Service-Schicht (shell_command) ODER von Hand aufrufbar. Wirkt IMMER auf
|
||||||
|
BEIDE Instanzen (kein Sync im Datenpfad). KEIN eigenes Timing — Ablauf/Revert macht der
|
||||||
|
Aufrufer (HA-Timer) bzw. der RouterOS-Backstop (Stufe 6). Diese CLI ist reines add/remove/read.
|
||||||
|
|
||||||
|
allow-domain <domain> [--client <name|ip[,ip]>] → user_rules += @@||domain^[$client=…]
|
||||||
|
unallow-domain <domain> [--client <name|ip[,ip]>] → dieselbe Regel wieder entfernen
|
||||||
|
list-exceptions [--client <name>] [--json] → alle @@-Regeln, nach Client gruppiert
|
||||||
|
recent-blocked --client <name|ip[,ip]> [--limit N] [--json]
|
||||||
|
→ zuletzt geblockte Domains DIESER Person
|
||||||
|
unfilter <ip> → Client (dessen ids <ip> enthält) filtering_enabled=false (grob!)
|
||||||
|
refilter <ip> → denselben Client wieder filtern (use_global_settings=true)
|
||||||
|
status → user_rules + Client-Policy beider Instanzen
|
||||||
|
|
||||||
|
--client:
|
||||||
|
* ein AGH-Client-NAME (empfohlen, stabil bei Gerätewechsel) → Regel bekommt $client='Name',
|
||||||
|
IPs werden für recent-blocked aus /control/clients (ids) aufgelöst.
|
||||||
|
* ODER eine/mehrere IP/CIDR, komma-getrennt → $client=<ip>|<ip>, recent-blocked filtert darauf.
|
||||||
|
ohne --client bei allow/unallow = GLOBAL (für alle) — nur für Admin-Zwecke.
|
||||||
|
|
||||||
|
🔴 ANNAHME, on-network zu verifizieren, BEVOR Stufe 6 scharf geht: dass AGH $client= auf
|
||||||
|
ALLOW-Regeln (@@) in der laufenden Version wirkt. Die Regelkonstruktion steht isoliert in
|
||||||
|
scoped_rule()/parse_allow_rule() — falls die Syntax abweicht, nur dort anpassen.
|
||||||
|
|
||||||
|
Auth: AGH_AUTOMATION_USER/_PASS aus ~/.secrets/credentials.env (nie in argv).
|
||||||
|
"""
|
||||||
|
import sys, os, json, base64, urllib.request, urllib.error, urllib.parse, pathlib, re, ipaddress, argparse
|
||||||
|
|
||||||
|
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9]+)*\.)+[a-z]{2,}$", re.I)
|
||||||
|
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
||||||
|
CIDR_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$")
|
||||||
|
# Ein Client-„spec" ist IP-artig, wenn JEDES komma-Glied IP oder CIDR ist; sonst = Name.
|
||||||
|
|
||||||
|
INST = ["10.40.20.36", "10.40.20.37"]
|
||||||
|
# Creds-Quellen in Reihenfolge: erste, die User+Pass liefert, gewinnt.
|
||||||
|
# So läuft derselbe Code auf dem Mac (~/.secrets) und auf HAOS (/config).
|
||||||
|
CRED_FILES = [
|
||||||
|
os.environ.get("AGH_CREDS_FILE"),
|
||||||
|
str(pathlib.Path.home() / ".secrets/credentials.env"),
|
||||||
|
"/config/agh-exception.secrets",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _from_file(path):
|
||||||
|
u = p = None
|
||||||
|
try:
|
||||||
|
for line in open(path).read().splitlines():
|
||||||
|
if line.startswith("AGH_AUTOMATION_USER="): u = line.split("=", 1)[1].strip()
|
||||||
|
if line.startswith("AGH_AUTOMATION_PASS="): p = line.split("=", 1)[1].strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (u, p) if (u and p) else None
|
||||||
|
|
||||||
|
|
||||||
|
def creds():
|
||||||
|
eu, ep = os.environ.get("AGH_AUTOMATION_USER"), os.environ.get("AGH_AUTOMATION_PASS")
|
||||||
|
if eu and ep:
|
||||||
|
return eu, ep
|
||||||
|
for f in CRED_FILES:
|
||||||
|
if f and (r := _from_file(f)):
|
||||||
|
return r
|
||||||
|
sys.exit("AGH_AUTOMATION_USER/_PASS: weder Env noch eine Creds-Datei gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
_AUTH = None
|
||||||
|
def auth_header():
|
||||||
|
# lazy: erst beim ersten echten API-Call — so bleibt das Modul ohne Creds importier-/testbar.
|
||||||
|
global _AUTH
|
||||||
|
if _AUTH is None:
|
||||||
|
_AUTH = "Basic " + base64.b64encode(":".join(creds()).encode()).decode()
|
||||||
|
return _AUTH
|
||||||
|
|
||||||
|
|
||||||
|
def api(ip, path, method="GET", body=None):
|
||||||
|
url = f"http://{ip}{path}"
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method,
|
||||||
|
headers={"Authorization": auth_header(),
|
||||||
|
"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit(f"{ip} {path} → HTTP {e.code}: {e.read().decode()[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"{ip} {path} → {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 client_modifier(spec):
|
||||||
|
"""$client-Modifier-Wert für eine Regel. IP-spec → ip|ip ; Name → 'Name' (quoted)."""
|
||||||
|
if is_ip_spec(spec):
|
||||||
|
return "|".join(s.strip() for s in spec.split(",") if s.strip())
|
||||||
|
return "'" + spec.replace("'", "") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def scoped_rule(domain, client=None):
|
||||||
|
"""@@||domain^ bzw. @@||domain^$client=… (client = Name oder ip[,ip])."""
|
||||||
|
base = f"@@||{domain}^"
|
||||||
|
return base if not client else f"{base}$client={client_modifier(client)}"
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOW_RE = re.compile(r"^@@\|\|(?P<domain>[^\^]+)\^(?:\$client=(?P<client>.+))?$")
|
||||||
|
def parse_allow_rule(rule):
|
||||||
|
"""@@-Regel → {'domain':…, 'client': <str oder None>, 'raw': rule} oder None."""
|
||||||
|
m = _ALLOW_RE.match(rule.strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return {"domain": m.group("domain"), "client": m.group("client"), "raw": rule.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def ip_in_specs(ip, specs):
|
||||||
|
"""Ist ip (aus dem Query-Log) von einer der specs (IP oder CIDR) erfasst?"""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
for s in specs:
|
||||||
|
s = s.strip()
|
||||||
|
try:
|
||||||
|
if "/" in s:
|
||||||
|
if addr in ipaddress.ip_network(s, strict=False):
|
||||||
|
return True
|
||||||
|
elif ip == s:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def resolve_client_ips(spec):
|
||||||
|
"""spec → Liste von IP/CIDR-strings. IP-spec direkt; Name → ids des AGH-Clients (von .36)."""
|
||||||
|
if is_ip_spec(spec):
|
||||||
|
return [s.strip() for s in spec.split(",") if s.strip()]
|
||||||
|
data = api(INST[0], "/control/clients")
|
||||||
|
for c in data.get("clients", []):
|
||||||
|
if c.get("name") == spec:
|
||||||
|
return list(c.get("ids", []))
|
||||||
|
sys.exit(f"kein AGH-Client mit name={spec!r} (für IP-spec Komma-Liste aus IP/CIDR angeben)")
|
||||||
|
|
||||||
|
|
||||||
|
def allow(domain, client=None, remove=False):
|
||||||
|
domain = domain.strip().lower()
|
||||||
|
if not DOMAIN_RE.match(domain):
|
||||||
|
sys.exit(f"ungültige Domain: {domain!r}")
|
||||||
|
r = scoped_rule(domain, client)
|
||||||
|
for ip in INST:
|
||||||
|
st = api(ip, "/control/filtering/status")
|
||||||
|
rules = st.get("user_rules", [])
|
||||||
|
if remove:
|
||||||
|
rules = [x for x in rules if x.strip() != r]
|
||||||
|
elif r not in rules:
|
||||||
|
rules = rules + [r]
|
||||||
|
api(ip, "/control/filtering/set_rules", "POST", {"rules": rules})
|
||||||
|
print(f"{ip}: {'entfernt' if remove else 'gesetzt'} {r} (user_rules={len(rules)})")
|
||||||
|
|
||||||
|
|
||||||
|
def list_exceptions(client=None, as_json=False):
|
||||||
|
st = api(INST[0], "/control/filtering/status") # .36 == .37 (Invariante, via agh-sync geprüft)
|
||||||
|
items = []
|
||||||
|
for raw in st.get("user_rules", []):
|
||||||
|
p = parse_allow_rule(raw)
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
if client is not None and (p["client"] or "") != client_modifier(client):
|
||||||
|
continue
|
||||||
|
items.append(p)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(items, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
if not items:
|
||||||
|
print("— keine Ausnahmen —"); return
|
||||||
|
for p in items:
|
||||||
|
who = p["client"] or "GLOBAL"
|
||||||
|
print(f" {p['domain']:40} client={who}")
|
||||||
|
|
||||||
|
|
||||||
|
def recent_blocked(client, limit=20, as_json=False):
|
||||||
|
specs = resolve_client_ips(client)
|
||||||
|
q = urllib.parse.urlencode({"response_status": "blocked", "limit": max(limit * 8, 100)})
|
||||||
|
data = api(INST[0], f"/control/querylog?{q}")
|
||||||
|
seen = {} # domain -> {count, last}
|
||||||
|
for e in data.get("data", []):
|
||||||
|
c = e.get("client", "")
|
||||||
|
if not ip_in_specs(c, specs):
|
||||||
|
continue
|
||||||
|
dom = (e.get("question", {}) or {}).get("name", "").rstrip(".").lower()
|
||||||
|
if not dom:
|
||||||
|
continue
|
||||||
|
rec = seen.setdefault(dom, {"domain": dom, "count": 0, "last": e.get("time", "")})
|
||||||
|
rec["count"] += 1
|
||||||
|
if e.get("time", "") > rec["last"]:
|
||||||
|
rec["last"] = e.get("time", "")
|
||||||
|
out = sorted(seen.values(), key=lambda r: r["last"], reverse=True)[:limit]
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(out, ensure_ascii=False)); return
|
||||||
|
if not out:
|
||||||
|
print("— nichts geblockt —"); return
|
||||||
|
for r in out:
|
||||||
|
print(f" {r['domain']:40} x{r['count']:<4} {r['last']}")
|
||||||
|
|
||||||
|
|
||||||
|
def set_filter(ip_addr, enabled):
|
||||||
|
if not IP_RE.match(ip_addr):
|
||||||
|
sys.exit(f"ungültige IP: {ip_addr!r}")
|
||||||
|
for ip in INST:
|
||||||
|
data = api(ip, "/control/clients")
|
||||||
|
c = None
|
||||||
|
for cand in data.get("clients", []):
|
||||||
|
if ip_addr in cand.get("ids", []):
|
||||||
|
c = cand; break
|
||||||
|
if not c:
|
||||||
|
sys.exit(f"{ip}: kein Client mit id exakt {ip_addr} — für Gruppen die CIDR angeben")
|
||||||
|
c = dict(c)
|
||||||
|
c["use_global_settings"] = enabled
|
||||||
|
c["filtering_enabled"] = enabled
|
||||||
|
api(ip, "/control/clients/update", "POST", {"name": c["name"], "data": c})
|
||||||
|
print(f"{ip}: {c['name']} filtering={'an' if enabled else 'AUS'}")
|
||||||
|
|
||||||
|
|
||||||
|
def status():
|
||||||
|
for ip in INST:
|
||||||
|
st = api(ip, "/control/filtering/status")
|
||||||
|
allows = [r for r in st.get("user_rules", []) if r.startswith("@@")]
|
||||||
|
cl = api(ip, "/control/clients").get("clients", [])
|
||||||
|
print(f"== {ip} == allow-rules={allows or '—'}")
|
||||||
|
for c in cl:
|
||||||
|
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__":
|
||||||
|
ap = argparse.ArgumentParser(add_help=False)
|
||||||
|
ap.add_argument("cmd", nargs="?")
|
||||||
|
ap.add_argument("arg", nargs="?")
|
||||||
|
ap.add_argument("--client")
|
||||||
|
ap.add_argument("--permanent", action="store_true") # semantisch = kein Timer; wirkt in HA, nicht hier
|
||||||
|
ap.add_argument("--limit", type=int, default=20)
|
||||||
|
ap.add_argument("--json", action="store_true")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
if not a.cmd:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
if a.cmd == "allow-domain": allow(a.arg, a.client)
|
||||||
|
elif a.cmd == "unallow-domain": allow(a.arg, a.client, remove=True)
|
||||||
|
elif a.cmd == "list-exceptions": list_exceptions(a.client, a.json)
|
||||||
|
elif a.cmd == "recent-blocked":
|
||||||
|
if not a.client: sys.exit("recent-blocked braucht --client <name|ip[,ip]>")
|
||||||
|
recent_blocked(a.client, a.limit, a.json)
|
||||||
|
elif a.cmd == "unfilter": set_filter(a.arg, False)
|
||||||
|
elif a.cmd == "refilter": set_filter(a.arg, True)
|
||||||
|
elif a.cmd == "status": status()
|
||||||
|
elif a.cmd == "selftest": _selftest()
|
||||||
|
else: sys.exit(f"unbekannt: {a.cmd}\n{__doc__}")
|
||||||
113
adguard/agh-sync.py
Executable file
113
adguard/agh-sync.py
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agh-sync.py — AdGuard-Home-Paar agh-bf1 (.36) / agh-bf2 (.37) synchron halten.
|
||||||
|
|
||||||
|
TP23 Stufe 2/4: die beiden AGH-Instanzen (pve LXC 110/111) haben KEIN natives HA →
|
||||||
|
Config-Sync ist Betriebsaufwand. Dieses Werkzeug ist der Sync.
|
||||||
|
|
||||||
|
SSoT = adguard/AdGuardHome.yaml (Passwort-Hashes REDIGIERT — keine Secrets im Git).
|
||||||
|
Wahrheit im Datenpfad = die beiden Live-Instanzen; die Invariante ist .36 == .37
|
||||||
|
(AGH normalisiert die YAML beim Restart, daher NICHT gegen die Repo-Datei vergleichen,
|
||||||
|
sondern die beiden Live-Configs gegeneinander).
|
||||||
|
|
||||||
|
check pull .36 + .37, Passwörter redigiert diffen → meldet Drift (exit 1 bei Drift)
|
||||||
|
push Repo-SSoT nehmen, echte users-Hashes aus .36 (live) reinjizieren, validieren,
|
||||||
|
auf beide pushen, GESTAFFELT neu starten (110 → prüfen → 111), .36==.37 + API prüfen
|
||||||
|
|
||||||
|
Kein Direkt-`sshpass -p` (Leak). SSH-Key zu root@pve vorausgesetzt.
|
||||||
|
"""
|
||||||
|
import subprocess, sys, tempfile, base64, os, re, pathlib, urllib.request
|
||||||
|
|
||||||
|
PVE = "root@10.40.20.100"
|
||||||
|
INST = {"110": "10.40.20.36", "111": "10.40.20.37"} # ct-id -> ip
|
||||||
|
PRIMARY = "110" # Reihenfolge: primary zuerst
|
||||||
|
SVC = "AdGuardHome.service"
|
||||||
|
CFG = "/opt/AdGuardHome/AdGuardHome.yaml"
|
||||||
|
REPO = pathlib.Path(__file__).resolve().parent
|
||||||
|
SSOT = REPO / "AdGuardHome.yaml"
|
||||||
|
PW_RE = re.compile(r"^( password: ).*$")
|
||||||
|
|
||||||
|
|
||||||
|
def sh(*args, inp=None):
|
||||||
|
return subprocess.run(args, input=inp, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def pull(ct):
|
||||||
|
r = sh("ssh", "-o", "ConnectTimeout=8", PVE, f"pct exec {ct} -- cat {CFG}")
|
||||||
|
if r.returncode:
|
||||||
|
sys.exit(f"pull {ct} fehlgeschlagen: {r.stderr.strip()}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def redact(text):
|
||||||
|
return "\n".join(PW_RE.sub(r"\1'<redacted>'", l) for l in text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def live_users_hashes(ct):
|
||||||
|
"""echte password-Zeilen (in Reihenfolge) aus der Live-Config von ct."""
|
||||||
|
out, inusers = [], False
|
||||||
|
for l in pull(ct).splitlines():
|
||||||
|
if l.startswith("users:"): inusers = True; continue
|
||||||
|
if inusers and l and not l.startswith((" ", "\t")): break
|
||||||
|
if inusers:
|
||||||
|
m = PW_RE.match(l)
|
||||||
|
if m: out.append(l)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_check():
|
||||||
|
a, b = redact(pull("110")), redact(pull("111"))
|
||||||
|
if a == b:
|
||||||
|
print("✅ .36 == .37 (synchron)")
|
||||||
|
return 0
|
||||||
|
print("🔴 DRIFT zwischen .36 und .37:")
|
||||||
|
import difflib
|
||||||
|
for line in difflib.unified_diff(a.splitlines(), b.splitlines(),
|
||||||
|
".36", ".37", lineterm=""):
|
||||||
|
print(line)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_push():
|
||||||
|
if not SSOT.exists():
|
||||||
|
sys.exit(f"SSoT fehlt: {SSOT}")
|
||||||
|
ssot = SSOT.read_text()
|
||||||
|
# echte Hashes aus dem primary reinjizieren (SSoT trägt nur Redaktions-Platzhalter)
|
||||||
|
hashes = live_users_hashes(PRIMARY)
|
||||||
|
red = [i for i, l in enumerate(ssot.splitlines()) if PW_RE.match(l)]
|
||||||
|
if len(red) != len(hashes):
|
||||||
|
sys.exit(f"users-Anzahl SSoT({len(red)}) != live({len(hashes)}) — manuell prüfen")
|
||||||
|
lines = ssot.splitlines()
|
||||||
|
for idx, real in zip(red, hashes):
|
||||||
|
lines[idx] = real
|
||||||
|
merged = "\n".join(lines) + "\n"
|
||||||
|
# validieren
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
doc = yaml.safe_load(merged)
|
||||||
|
assert doc["users"] and doc["clients"]["persistent"]
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"merge invalide: {e}")
|
||||||
|
b64 = base64.b64encode(merged.encode()).decode()
|
||||||
|
order = [PRIMARY] + [c for c in INST if c != PRIMARY]
|
||||||
|
for ct in order:
|
||||||
|
print(f"→ push + restart {ct} ({INST[ct]}) …")
|
||||||
|
r = sh("ssh", "-o", "ConnectTimeout=10", PVE,
|
||||||
|
f"pct exec {ct} -- sh -c 'base64 -d > {CFG}.new && "
|
||||||
|
f"cp -a {CFG} {CFG}.presync && mv {CFG}.new {CFG} && "
|
||||||
|
f"systemctl restart {SVC} && sleep 2 && systemctl is-active {SVC}'",
|
||||||
|
inp=b64)
|
||||||
|
if r.returncode or "active" not in r.stdout:
|
||||||
|
sys.exit(f"push {ct} fehlgeschlagen: {r.stdout} {r.stderr}")
|
||||||
|
print(f" {ct} active")
|
||||||
|
print(cmd_check_msg())
|
||||||
|
return cmd_check()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_check_msg():
|
||||||
|
return "— Verifikation:"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "check"
|
||||||
|
sys.exit({"check": cmd_check, "push": cmd_push}.get(cmd, cmd_check)())
|
||||||
98
adguard/ha-self-service.md
Normal file
98
adguard/ha-self-service.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# TP23 Stufe 5 (Redesign 2026-09-12) — DAU-tauglicher, personenbezogener DNS-Self-Service
|
||||||
|
|
||||||
|
> 🔴 **Status: Bau-Spec, noch nicht deployt.** Die alte Fassung (single-domain, temp-only,
|
||||||
|
> HA-Neustart bei neuer Domain) war „funktional ein kastriertes Pi-hole" (User-Befund 2026-09-12)
|
||||||
|
> und ist ersetzt. Bau + Abnahme laufen **on-network** (HAOS `10.40.20.105`, AGH `.36/.37`) — beim
|
||||||
|
> Anlegen den **home-assistant-best-practices-Skill** konsultieren und via **ha-mcp** in `.storage`
|
||||||
|
> anlegen (native Helper, `entity_id` statt `device_id`). Diese Datei ist die Repro-/SSoT-Quelle.
|
||||||
|
|
||||||
|
## Ziel (User-Requirements 2026-09-12)
|
||||||
|
Ein **einfaches, klar verständliches** End-User-UI zum Entsperren:
|
||||||
|
- **alles was den Nutzer betrifft, nichts** von anderen / Admin / Power-User;
|
||||||
|
- Policy nur sichtbar, wo sie für den einfachen Nutzer Relevanz hat.
|
||||||
|
|
||||||
|
| # | Requirement | Umsetzung |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Pro Person** (Login), Sicht über alle Geräte | HA-User = Person; Person → AGH-Client (Name), dessen `ids` = ihre Geräte-IPs |
|
||||||
|
| 2 | **„zuletzt geblockt" → 1 Klick** | `recent-blocked --client <person>` aus dem AGH-Query-Log, auf die Geräte der Person gefiltert |
|
||||||
|
| 3 | **temporär UND dauerhaft**, Modus umschaltbar | Dauer-Select inkl. „dauerhaft"; `input_boolean` self-permanent ⇄ Anfrage. **Start: self-permanent** |
|
||||||
|
| 4 | **HA-Dashboard, scoped** pro Person | ein View je Person, nur für ihre HA-User-ID sichtbar |
|
||||||
|
|
||||||
|
## Identitätsmodell — AGH-Client je Person ist die SSoT
|
||||||
|
- Pro Person **ein AGH-Client** (`/control/clients`), `name` = Personenname, `ids` = **alle** Geräte-IPs
|
||||||
|
der Person (die per DHCP faktisch statisch sind). Damit ist Person→Gerät **an einem Ort** gepflegt
|
||||||
|
(kein zweites Mapping-File), und Ausnahmen sind stabil bei Gerätewechsel.
|
||||||
|
- Ausnahme-Regel = `@@||domain^$client='Personenname'` (statt globalem `@@||domain^`).
|
||||||
|
🔴 **Vor Scharfschalten on-network verifizieren, dass AGH `$client=` auf `@@`-Regeln wirkt**
|
||||||
|
(s. `agh-exception.py` Kopf). Fällt der Test negativ aus: Fallback = `$client=<ip>|<ip>` mit den
|
||||||
|
Geräte-IPs statt dem Namen (Code kann beides, `scoped_rule()`).
|
||||||
|
- Die bestehenden **VLAN-CIDR-Gruppen** bleiben die Policy-Grenze (Filter an/aus je VLAN);
|
||||||
|
Personen-Clients sind spezifischer und tragen nur die *Ausnahmen*.
|
||||||
|
|
||||||
|
## CLI (deployt nach `/config/agh-exception.py`, wirkt auf `.36`+`.37`)
|
||||||
|
```
|
||||||
|
allow-domain <domain> --client <person> # Ausnahme setzen (temp: HA revertiert; perm: kein Timer)
|
||||||
|
unallow-domain <domain> --client <person> # dieselbe Ausnahme entfernen
|
||||||
|
list-exceptions --client <person> --json # „meine Ausnahmen" (für Übersicht + Entfernen)
|
||||||
|
recent-blocked --client <person> --json # „zuletzt geblockt" (nur Geräte der Person)
|
||||||
|
```
|
||||||
|
`--client` nimmt einen AGH-Client-Namen (→ IPs via `/control/clients`) ODER eine Komma-IP/CIDR-Liste.
|
||||||
|
|
||||||
|
## `configuration.yaml` — `shell_command` (einziger YAML-Teil, Backup `.bak-tp23-*`)
|
||||||
|
Domain **und** Client sind Parameter → **kein HA-Neustart mehr** bei neuer Domain (das war der alte
|
||||||
|
DAU-Killer: pro Domain ein eigener `shell_command`).
|
||||||
|
```yaml
|
||||||
|
shell_command:
|
||||||
|
agh_allow: "python3 /config/agh-exception.py allow-domain '{{ domain }}' --client '{{ client }}'"
|
||||||
|
agh_unallow: "python3 /config/agh-exception.py unallow-domain '{{ domain }}' --client '{{ client }}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helfer / Entities (via ha-mcp in .storage)
|
||||||
|
Pro Person (Beispiel `christoph`) — bei mehreren Personen je einmal, Suffix = Personen-Slug:
|
||||||
|
| Typ | entity_id | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| command_line sensor | `sensor.dns_recent_blocked_<slug>` | `recent-blocked --client <person> --json` → `json_attributes: items` (Liste {domain,count,last}); Scan-Intervall ~60 s |
|
||||||
|
| command_line sensor | `sensor.dns_exceptions_<slug>` | `list-exceptions --client <person> --json` → aktive Ausnahmen der Person |
|
||||||
|
| input_select | `input_select.dns_dauer_<slug>` | 1 Stunde / 4 Stunden / **dauerhaft** |
|
||||||
|
| timer | `timer.dns_ausnahme_<slug>` | JIT-Countdown je temporärer Freigabe (restore=false) |
|
||||||
|
| input_boolean | `input_boolean.dns_permanent_ohne_anfrage` | **global**: an = „dauerhaft" wirkt sofort (Start); aus = erzeugt Admin-Genehmigung |
|
||||||
|
| script | `script.dns_freigeben_<slug>` | nimmt `domain` als Feld → `agh_allow`; bei „dauerhaft"+Switch-aus → `todo`/Notification statt Freigabe; sonst Timer starten |
|
||||||
|
| automation | `automation.dns_ablauf_<slug>` | `timer.finished`/`.cancelled` → `agh_unallow`, Aufräumen |
|
||||||
|
|
||||||
|
## Dashboard — ein scoped View je Person
|
||||||
|
- Eigener storage-Dashboard-View, **nur für die HA-User-ID der Person sichtbar** (`visible:` mit
|
||||||
|
der user-id; `require_admin:false`). Kleiner Haushalt → ein View je Person ist praktikabel und
|
||||||
|
umgeht das „Template kennt den aktuellen Betrachter nicht"-Problem.
|
||||||
|
- **Karte 1 „Zuletzt geblockt (antippen zum Freigeben)":** rendert `sensor.dns_recent_blocked_<slug>`
|
||||||
|
Attribut `items`; jede Zeile hat eine `tap_action` → `script.dns_freigeben_<slug>` mit `domain`
|
||||||
|
der Zeile + aktueller `input_select`-Dauer. (Dynamische Per-Zeilen-tap_action braucht i.d.R.
|
||||||
|
`auto-entities` + `template`/`button-card` **oder** ein kleines Custom-List-Template — **beim Bau
|
||||||
|
on-network entscheiden**, das ist der Teil, der Live-Verifikation braucht.)
|
||||||
|
- **Karte 2 „Meine Ausnahmen":** `sensor.dns_exceptions_<slug>` als Liste, je Eintrag Entfernen
|
||||||
|
(`agh_unallow`).
|
||||||
|
- **Karte 3 „Dauer":** `input_select.dns_dauer_<slug>` (inkl. „dauerhaft").
|
||||||
|
- **Kein** globaler Status, keine fremden Clients, keine Policy-Interna. Nur wenn relevant ein
|
||||||
|
kurzer Hinweis („durch den Familien-Filter blockiert").
|
||||||
|
|
||||||
|
## Genehmigungs-Variante (Modus-Switch aus)
|
||||||
|
`input_boolean.dns_permanent_ohne_anfrage = off` → „dauerhaft"-Klick legt statt der Freigabe einen
|
||||||
|
Eintrag in einer **`todo`-Liste** „DNS-Freigaben zu prüfen" an (+ Notification an Christoph). Beim
|
||||||
|
Abhaken führt eine Admin-Automation `agh_allow` ohne Timer aus. Beide Varianten sind gebaut; der
|
||||||
|
Switch entscheidet zur Laufzeit.
|
||||||
|
|
||||||
|
## Alt-Reste, die beim Bau wegfallen/fixen
|
||||||
|
- Single-domain-Flow (`input_text.dns_ausnahme_domain/_aktiv`, `input_button…_starten`,
|
||||||
|
`automation…_starten/_ablauf`) und der `agh_allow_domain`/`_unallow_domain`-**pro-Domain**-Zwang.
|
||||||
|
- Statuskachel-Sensor `binary_sensor.adguard_bf1_status` hing am toten `.16` → auf `.36`/`.37` neu.
|
||||||
|
|
||||||
|
## Abnahme (on-network, = neue echte Stufe-6-Vorbedingung)
|
||||||
|
Mit einem **echten Nicht-Admin-Login**: geblockte Seite → erscheint in *ihrer* Fläche → 1 Klick
|
||||||
|
temporär (revertiert nach Ablauf) **und** 1 Klick dauerhaft (bleibt) → wirkt **nur** für diese
|
||||||
|
Person (Gegentest: anderer Client sieht die Domain weiter geblockt). `.36 == .37`
|
||||||
|
(`agh-sync.py check`). Modus-Switch beide Wege einmal durchgespielt.
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
- CLI: `adguard/agh-exception.py` (Subcommands + `selftest`). Deploy-Weg unverändert (base64→scp,
|
||||||
|
s. unten). Creds `/config/agh-exception.secrets` (chmod 600).
|
||||||
|
- Redeploy: `base64 < adguard/agh-exception.py | ssh admin@10.40.20.105 "base64 -d | sudo tee /config/agh-exception.py >/dev/null"`.
|
||||||
|
- Personen-Client-Modell: `adguard/README.md`. Kontext/Reihenfolge: TP23 in `docs/teilprojekte.md`.
|
||||||
218
bin/gitea-tp-sync.py
Normal file
218
bin/gitea-tp-sync.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP36 (reframed) — teilprojekte.md Index/Status → Gitea Issues (admin/bf-infra).
|
||||||
|
|
||||||
|
Idempotenter Sync (get-or-create, Match ueber "TPnn" im Titel):
|
||||||
|
- Archiv-Header in docs/teilprojekte-archiv.md -> closed Issues (nur TPs, die in Gitea
|
||||||
|
NICHT offen sind; ein reopentes TP mit Archiv-Zeiger bleibt offen).
|
||||||
|
- Labels status:* (Marker) + thema:* (Gruppierung) werden vorab get-or-create.
|
||||||
|
|
||||||
|
SSoT-Modell (seit TP36-Cutover 2026-09-07): Gitea fuehrt Index+Status — aktive TPs werden
|
||||||
|
DIREKT als Gitea-Issue angelegt/gepflegt, NICHT mehr aus einer Datei-Tabelle abgeleitet
|
||||||
|
(die Quick-Index-Tabelle wurde aus teilprojekte.md entfernt). Dieses Werkzeug haelt nur noch
|
||||||
|
die ARCHIVIERTEN (closed) TPs mit teilprojekte-archiv.md idempotent abgeglichen und fasst
|
||||||
|
offene Issues nie an. Die per-TP-Prosa bleibt in docs/teilprojekte.md / teilprojekte-archiv.md.
|
||||||
|
Token: ~/.hermes/.env (GITEA_ACCESS_TOKEN, GITEA_HOST) — wird NIE ausgegeben.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 bin/gitea-tp-sync.py # Dry-Run (default) — zeigt, was passieren wuerde
|
||||||
|
python3 bin/gitea-tp-sync.py --commit # legt Labels + Issues an / gleicht sie ab
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
ACTIVE = REPO / "docs" / "teilprojekte.md"
|
||||||
|
ARCHIV = REPO / "docs" / "teilprojekte-archiv.md"
|
||||||
|
HERMES = Path.home() / ".hermes" / ".env"
|
||||||
|
OWNER_REPO = "admin/bf-infra"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
# Marker -> (status-label, gitea-state)
|
||||||
|
MARKER = {
|
||||||
|
"🔜": ("status:todo", "open"),
|
||||||
|
"⏳": ("status:wip", "open"),
|
||||||
|
"⏸️": ("status:paused", "open"),
|
||||||
|
"💡": ("status:idea", "open"),
|
||||||
|
"🟢": ("status:concept", "open"),
|
||||||
|
"✅": ("status:done", "closed"),
|
||||||
|
"❌": ("status:dropped", "closed"),
|
||||||
|
}
|
||||||
|
LABEL_COLORS = {
|
||||||
|
"status:todo": "#ededed", "status:wip": "#fbca04", "status:paused": "#c5def5",
|
||||||
|
"status:idea": "#d4c5f9", "status:concept": "#bfdadc", "status:done": "#0e8a16",
|
||||||
|
"status:dropped": "#e11d21",
|
||||||
|
"thema:firewall-dns": "#b60205", "thema:wlan": "#1d76db", "thema:cloudflare-proxy": "#f9a825",
|
||||||
|
"thema:nautobot-doku": "#5319e7", "thema:infra": "#0e8a16", "thema:ipv6-matter": "#006b75",
|
||||||
|
"thema:identity": "#e99695", "thema:backlog": "#cccccc",
|
||||||
|
}
|
||||||
|
# TP-Nummer -> Themen-Label (aus dem Statusbaum-Gruppierung; nur aktive TPs brauchen ein Board-Thema)
|
||||||
|
THEME = {
|
||||||
|
"5.1": "firewall-dns", "23": "firewall-dns",
|
||||||
|
"25": "wlan", "28": "wlan", "13": "wlan", "17.1": "wlan", "17.3": "wlan",
|
||||||
|
"16": "cloudflare-proxy", "16.2": "cloudflare-proxy", "16.3": "cloudflare-proxy",
|
||||||
|
"31": "cloudflare-proxy", "41": "cloudflare-proxy", "14.4": "cloudflare-proxy",
|
||||||
|
"42": "cloudflare-proxy", "43": "cloudflare-proxy", "44": "cloudflare-proxy",
|
||||||
|
"32": "nautobot-doku", "34": "nautobot-doku", "35": "nautobot-doku",
|
||||||
|
"36": "nautobot-doku", "37": "nautobot-doku",
|
||||||
|
"30": "infra", "40": "infra", "20.1": "infra", "18": "infra",
|
||||||
|
"24": "infra", "38": "infra", "11": "infra",
|
||||||
|
"12": "ipv6-matter", "12.1": "ipv6-matter",
|
||||||
|
"8": "identity",
|
||||||
|
"29": "backlog", "33": "backlog",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hermes_env():
|
||||||
|
tok = host = None
|
||||||
|
for line in HERMES.read_text().splitlines():
|
||||||
|
m = re.match(r"\s*(GITEA_ACCESS_TOKEN|GITEA_HOST)\s*=\s*(.+?)\s*$", line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
if m.group(1) == "GITEA_ACCESS_TOKEN":
|
||||||
|
tok = m.group(2).strip().strip('"').strip("'")
|
||||||
|
else:
|
||||||
|
host = m.group(2).strip().strip('"').strip("'")
|
||||||
|
if not tok:
|
||||||
|
sys.exit("GITEA_ACCESS_TOKEN nicht in %s gefunden." % HERMES)
|
||||||
|
host = (host or "https://gitea.emsenhuber.net").rstrip("/")
|
||||||
|
if not host.startswith("http"):
|
||||||
|
host = "https://" + host
|
||||||
|
return tok, host
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN, HOST = hermes_env()
|
||||||
|
API = HOST + "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, body=None):
|
||||||
|
url = API + path
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||||
|
"Authorization": "token " + TOKEN,
|
||||||
|
"Accept": "application/json", "Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, json.load(r)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, (json.load(e) if e.headers.get("content-type", "").startswith("application/json") else {})
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- parse
|
||||||
|
def parse_archiv(exclude_nums):
|
||||||
|
"""Archiv-Header -> archivierte TPs. exclude_nums = TP-Nummern, die in Gitea OFFEN sind
|
||||||
|
(reopente TPs mit Archiv-Zeiger) -> die bleiben offen, nicht als closed abgleichen."""
|
||||||
|
out = []
|
||||||
|
hdr = re.compile(r"^#{2,4}\s*(TP[\d.\-A-Za-z]+)\s*—\s*(.+?)\s*▸", )
|
||||||
|
seen = set()
|
||||||
|
for line in ARCHIV.read_text().splitlines():
|
||||||
|
m = hdr.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
tp, title = m.groups()
|
||||||
|
num = tp[2:]
|
||||||
|
if num in exclude_nums or num in seen:
|
||||||
|
continue
|
||||||
|
seen.add(num)
|
||||||
|
short = re.sub(r"\*\*|`", "", title).strip()[:80]
|
||||||
|
out.append({"tp": tp, "num": num, "marker": "✅",
|
||||||
|
"title": short, "date": "", "src": "archiv"})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- gitea sync
|
||||||
|
def ensure_labels(existing):
|
||||||
|
by_name = {l["name"]: l for l in existing}
|
||||||
|
for name, color in LABEL_COLORS.items():
|
||||||
|
if name in by_name:
|
||||||
|
continue
|
||||||
|
if COMMIT:
|
||||||
|
api("POST", f"/repos/{OWNER_REPO}/labels",
|
||||||
|
{"name": name, "color": color.lstrip("#")})
|
||||||
|
print(f" [{'+' if COMMIT else 'DRY'}] label {name}")
|
||||||
|
st, cur = api("GET", f"/repos/{OWNER_REPO}/labels?limit=100")
|
||||||
|
return {l["name"]: l["id"] for l in (cur if isinstance(cur, list) else [])}
|
||||||
|
|
||||||
|
|
||||||
|
def all_issues():
|
||||||
|
out, page = [], 1
|
||||||
|
while True:
|
||||||
|
st, d = api("GET", f"/repos/{OWNER_REPO}/issues?state=all&type=issues&limit=50&page={page}")
|
||||||
|
if not isinstance(d, list) or not d:
|
||||||
|
break
|
||||||
|
out += d
|
||||||
|
page += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Gitea {HOST} repo {OWNER_REPO} mode={'COMMIT' if COMMIT else 'DRY-RUN'}")
|
||||||
|
|
||||||
|
st, existing_labels = api("GET", f"/repos/{OWNER_REPO}/labels?limit=100")
|
||||||
|
label_id = ensure_labels(existing_labels if isinstance(existing_labels, list) else [])
|
||||||
|
|
||||||
|
issues = all_issues()
|
||||||
|
by_tp = {}
|
||||||
|
open_nums = set()
|
||||||
|
for iss in issues:
|
||||||
|
# Titel ist "TPxx — …"; volle ID inkl. Buchstaben/Bindestrich (TP14.2a, TP-R) bis zum " — "
|
||||||
|
m = re.match(r"^(TP[\w.\-]*?)\s+—", iss["title"])
|
||||||
|
if m:
|
||||||
|
by_tp[m.group(1)] = iss
|
||||||
|
if iss.get("state") == "open":
|
||||||
|
open_nums.add(m.group(1)[2:])
|
||||||
|
|
||||||
|
# Aktive TPs sind seit dem TP36-Cutover SSoT direkt in Gitea -> nur das Archiv abgleichen,
|
||||||
|
# und offene (reopente) TPs davon ausschliessen, damit sie nicht faelschlich geschlossen werden.
|
||||||
|
items = parse_archiv(open_nums)
|
||||||
|
print(f"Geparst: {len(items)} archivierte TP (aktive TPs = SSoT in Gitea: "
|
||||||
|
f"{len(open_nums)} offen, nicht angetastet)\n")
|
||||||
|
|
||||||
|
created = updated = 0
|
||||||
|
for it in items:
|
||||||
|
status_label, state = MARKER.get(it["marker"], ("status:todo", "open"))
|
||||||
|
labels = [status_label]
|
||||||
|
th = THEME.get(it["num"])
|
||||||
|
if th:
|
||||||
|
labels.append(f"thema:{th}")
|
||||||
|
title = f"{it['tp']} — {it['title']}"
|
||||||
|
body = (f"Steuerungs-SSoT: Index/Status hier in Gitea; **Prosa/Detail** im Repo unter "
|
||||||
|
f"`docs/{'teilprojekte-archiv.md' if it['src']=='archiv' else 'teilprojekte.md'}` "
|
||||||
|
f"(Abschnitt `{it['tp']}`).\n\n"
|
||||||
|
f"- Status: {it['marker']} ({status_label})\n"
|
||||||
|
+ (f"- Abschluss: {it['date']}\n" if it["date"] else ""))
|
||||||
|
want_ids = [label_id[l] for l in labels if l in label_id]
|
||||||
|
|
||||||
|
cur = by_tp.get(it["tp"])
|
||||||
|
if cur is None:
|
||||||
|
print(f" [{'+' if COMMIT else 'DRY'}] NEW {title} {labels} state={state}")
|
||||||
|
if COMMIT:
|
||||||
|
sc, res = api("POST", f"/repos/{OWNER_REPO}/issues",
|
||||||
|
{"title": title, "body": body, "labels": want_ids})
|
||||||
|
if state == "closed" and isinstance(res, dict) and res.get("number"):
|
||||||
|
api("PATCH", f"/repos/{OWNER_REPO}/issues/{res['number']}", {"state": "closed"})
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
have = {l["name"] for l in cur.get("labels", [])}
|
||||||
|
drift = (have != set(labels)) or (cur.get("state") != state)
|
||||||
|
if drift:
|
||||||
|
print(f" [{'~' if COMMIT else 'DRY'}] UPD #{cur['number']} {title} "
|
||||||
|
f"{sorted(have)}->{labels} state {cur.get('state')}->{state}")
|
||||||
|
if COMMIT:
|
||||||
|
api("PATCH", f"/repos/{OWNER_REPO}/issues/{cur['number']}",
|
||||||
|
{"labels": want_ids, "state": state})
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
print(f"\n{'Angelegt' if COMMIT else 'Wuerde anlegen'}: {created} · "
|
||||||
|
f"{'Abgeglichen' if COMMIT else 'Wuerde abgleichen'}: {updated}")
|
||||||
|
if not COMMIT:
|
||||||
|
print("Dry-Run — nichts geschrieben. Mit --commit ausfuehren.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -47,7 +47,7 @@ gitea.emsenhuber.net {
|
|||||||
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
|
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
nautobot.bf.emsenhuber.net {
|
nautobot.bf.emsenhuber.net {
|
||||||
reverse_proxy http://10.40.20.30:8080
|
reverse_proxy http://10.40.20.154:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
|
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
|
||||||
|
|||||||
@@ -19,12 +19,35 @@ if [ -z "${TOKEN}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 🔴 `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann
|
||||||
|
# ("failed to open <src> for reading", rc=0 — live gemessen 2026-09-21).
|
||||||
|
# Ein `&&`/`||` hinter dem Push beweist deshalb NICHTS, und die anschliessende
|
||||||
|
# `caddy validate` validiert dann die ALTE Datei -> gruene Abnahme bei
|
||||||
|
# unveraendertem Zustand. Genau so ist der Nautobot-Umzug am 2026-09-21
|
||||||
|
# scheinbar gelandet, obwohl live noch die alte Backend-IP stand.
|
||||||
|
# Deshalb: Push + md5-Vergleich in EINEM Remote-Aufruf, der bei Abweichung
|
||||||
|
# hart abbricht. Gepusht wird nur der lokal bekannte Pfad — die frueheren
|
||||||
|
# `\$\$`/`$$`-Verrenkungen waren der Ausloeser.
|
||||||
|
push_verified() { # push_verified <quelle-auf-pve> <ziel-im-ct> <perms> <label>
|
||||||
|
local src=$1 dst=$2 perms=$3 label=$4
|
||||||
|
ssh "${PVE}" "set -e
|
||||||
|
pct push ${CTID} '${src}' '${dst}' --perms ${perms}
|
||||||
|
want=\$(md5sum '${src}' | cut -d' ' -f1)
|
||||||
|
got=\$(pct exec ${CTID} -- md5sum '${dst}' | cut -d' ' -f1)
|
||||||
|
rm -f '${src}'
|
||||||
|
if [ \"\$want\" != \"\$got\" ]; then
|
||||||
|
echo \"FEHLER: ${label} nicht gelandet (\$want != \$got)\" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo \" ${label} verifiziert (\$got)\""
|
||||||
|
}
|
||||||
|
|
||||||
echo "-> Caddyfile"
|
echo "-> Caddyfile"
|
||||||
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
|
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
|
||||||
ssh "${PVE}" "pct push ${CTID} /tmp/.caddyfile.\$\$ /etc/caddy/Caddyfile --perms 644 2>/dev/null || pct push ${CTID} /tmp/.caddyfile.$$ /etc/caddy/Caddyfile --perms 644; rm -f /tmp/.caddyfile.$$"
|
push_verified "/tmp/.caddyfile.$$" "/etc/caddy/Caddyfile" 644 "Caddyfile"
|
||||||
|
|
||||||
echo "-> Token (maskiert: Laenge ${#TOKEN})"
|
echo "-> Token (maskiert: Laenge ${#TOKEN})"
|
||||||
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$ && pct push ${CTID} /tmp/.cenv.$$ /etc/caddy/caddy.env --perms 600 && rm -f /tmp/.cenv.$$"
|
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$"
|
||||||
|
push_verified "/tmp/.cenv.$$" "/etc/caddy/caddy.env" 600 "caddy.env"
|
||||||
|
|
||||||
echo "-> validate"
|
echo "-> validate"
|
||||||
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
|
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
|
||||||
|
|||||||
47
docs/announcements.md
Normal file
47
docs/announcements.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Announcements — bf-infra
|
||||||
|
|
||||||
|
Breaking News / Querschnitts-Änderungen, die **jede** folgende Session kennen muss, bevor sie
|
||||||
|
losarbeitet — Dinge, die man sonst erst nach dem ersten Fehlschlag merkt. Kein Ersatz für die
|
||||||
|
TP-Prosa (`docs/teilprojekte.md`) oder das Gitea-Board; hier steht nur, was *quer* zu den TPs gilt.
|
||||||
|
|
||||||
|
**Format je Eintrag:** Überschrift `## YYYY-MM-DD — <Titel>`, dann die Zeilen
|
||||||
|
**Betrifft:** (wer/was) und **Aktion:** (was eine Session konkret anders machen muss),
|
||||||
|
optional ein, zwei Sätze Kontext. **Neueste oben.** Der Skill `/news` liest diese Datei und
|
||||||
|
zeigt die Einträge, die seit dem letzten `/news`-Lauf dazugekommen sind (Rest auf Wunsch).
|
||||||
|
|
||||||
|
<!-- TP35 Schicht 4. Eintrag hinzufügen = neuen ##-Block ganz oben unter dieser Zeile einfügen. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-12 — Golden-Config-Backups laufen jetzt täglich (vorher KEIN Scheduler)
|
||||||
|
**Betrifft:** alle, die sich auf GC-Backups/Compliance oder `gc-snapshot.py`-Werte verlassen.
|
||||||
|
**Aktion:** Es gab **nie** einen automatischen GC-Backup-Zeitplan — Backup/Intended/Compliance liefen
|
||||||
|
nur manuell aus Sessions (zuletzt 09-04 → 8 Tage Stillstand). Jetzt ScheduledJob **„GC Backup+Compliance
|
||||||
|
– taeglich"** (Nautobot, *Execute All GC Jobs - Multiple Device*, `platform=RouterOS`, täglich 03:30 UTC,
|
||||||
|
Reihenfolge Backup→Intended→Compliance). Wenn `backup_last_success_date` eines Geräts alt ist: zuerst
|
||||||
|
prüfen, ob der ScheduledJob noch `enabled` ist (`/api/extras/scheduled-jobs/`), nicht das Gerät verdächtigen.
|
||||||
|
|
||||||
|
## 2026-09-12 — claude.json-Secrets laufen über `{env:}` (TP35 Schicht 2 ✅)
|
||||||
|
**Betrifft:** alle, die `~/.claude.json` oder `~/.secrets/exports.zsh` anfassen.
|
||||||
|
**Aktion:** Keine Klartext-Secrets mehr in `~/.claude.json` — MCP-Credentials referenzieren jetzt
|
||||||
|
`{env:MCP_*}` (dieselben Namen wie `opencode.jsonc`, bidirektional kompatibel). SSoT ist
|
||||||
|
`~/.secrets/exports.zsh` (25 Vars). Nach jeder Änderung dort: `~/dev/.bin/gui-env-sync.sh`
|
||||||
|
**und App-Neustart** (laufender Prozess erbt die launchd-Umgebung nicht). ⚠ `~/.claude.json` nie aus
|
||||||
|
einer **laufenden** Session editieren — Claude Code schreibt dort State, der Edit würde überschrieben;
|
||||||
|
der Flip läuft bei beendeter App über `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude`.
|
||||||
|
|
||||||
|
## 2026-09-12 — TP36-Live: Gerätedaten stehen als CustomFields in Nautobot
|
||||||
|
**Betrifft:** alle, die Geräte-Ist-Werte (RouterOS-Version, Compliance %, DHCP-Server-Zahl,
|
||||||
|
DNS-Modus, Mgmt-IP) brauchen.
|
||||||
|
**Aktion:** Nicht mehr aus der Prosa ablesen und nicht raten — die fünf Felder (`grouping: TP36-Live`)
|
||||||
|
hängen an den 5 RouterOS-`dcim.Device` in Nautobot. Aktualisieren: `python3 nautobot/gc-snapshot.py
|
||||||
|
--commit` (liest nur aus Nautobot/GC, kontaktiert kein Gerät). Die Werte sind so aktuell wie das
|
||||||
|
letzte Golden-Config-Backup (`backup_last_success_date` am Gerät).
|
||||||
|
|
||||||
|
## 2026-09-07 — SSoT-Cutover: Index/Status = Gitea, NICHT mehr teilprojekte.md
|
||||||
|
**Betrifft:** jede Session, die einen TP-Status ändert, anlegt oder abschließt.
|
||||||
|
**Aktion:** Status/Reihenfolge/„was lief zuletzt" gehen ins **Gitea-Issue**
|
||||||
|
([`admin/bf-infra`](https://gitea.emsenhuber.net/admin/bf-infra/issues), Labels `status:*`/`thema:*`),
|
||||||
|
**nicht** in eine Tabelle in `docs/teilprojekte.md`. Die Datei trägt nur noch die `### TPnn`-Prosa.
|
||||||
|
`/projektstatus` liest Baum + Status aus der Gitea-API. Branch `tpNN-kurz` jederzeit möglich, Merge
|
||||||
|
nach `main`, sobald Code/Config steht.
|
||||||
@@ -2,15 +2,39 @@
|
|||||||
|
|
||||||
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
|
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
|
||||||
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
|
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
|
||||||
> Nautobot (`10.40.20.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
> Nautobot (`10.40.20.154`, TP45 Schicht A 2026-09-21; vorher `.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
||||||
> `nautobot/tp36-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
|
> `nautobot/tp44-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
|
||||||
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
|
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
|
||||||
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
||||||
> 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>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ Manufacturer "Cloudflare"
|
|||||||
|
|
||||||
## Werkzeug
|
## Werkzeug
|
||||||
|
|
||||||
`nautobot/tp36-cf-estate-seed.py` — **idempotenter** Seeder (get-or-create), Datenstand als Inline-Struktur.
|
`nautobot/tp44-cf-estate-seed.py` — **idempotenter** Seeder (get-or-create), Datenstand als Inline-Struktur.
|
||||||
Token aus `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored). Aufruf: Dry-Run default,
|
Token aus `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored). Aufruf: Dry-Run default,
|
||||||
`--commit` schreibt. Re-Run = 26/26 „existiert".
|
`--commit` schreibt. Re-Run = 26/26 „existiert".
|
||||||
*(Dateiname trägt noch `tp36`-Präfix aus dem ersten Arbeitsstand; inhaltlich TP44. Umbenennen bei Gelegenheit.)*
|
*(Datei am 2026-09-06 von `tp36-…` auf `tp44-cf-estate-seed.py` umbenannt — Präfix jetzt korrekt.)*
|
||||||
|
|
||||||
## Gotchas (für Etappe 2 / Folgesessions)
|
## Gotchas (für Etappe 2 / Folgesessions)
|
||||||
|
|
||||||
@@ -54,15 +54,17 @@ 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)
|
||||||
|
|
||||||
1. **CF-API→Nautobot-Sync** (Python/n8n): Inline-Daten im Seeder durch Live-Pull ersetzen, periodisch
|
1. **CF-API→Nautobot-Sync** (Python/n8n): Inline-Daten im Seeder durch Live-Pull ersetzen, periodisch
|
||||||
(analog golden-config-bot). Dann ist Nautobot driftfrei.
|
(analog golden-config-bot). Dann ist Nautobot driftfrei.
|
||||||
2. **TP44 in `teilprojekte.md`** eintragen (Quick-Index + Abschnitt + Ist-Stand), sobald die dortigen
|
2. ✅ **TP44 in `teilprojekte.md`** eingetragen (Quick-Index + Abschnitt + Ist-Stand) — 2026-09-06.
|
||||||
fremden Änderungen committet/geklärt sind.
|
|
||||||
3. IPAM-Prefixe `10.40.0.0/16` + `10.101.0.0/24` anlegen → dann greifen die Prefix-Assignments automatisch.
|
3. IPAM-Prefixe `10.40.0.0/16` + `10.101.0.0/24` anlegen → dann greifen die Prefix-Assignments automatisch.
|
||||||
4. Seeder-Datei nach `tp44-…` umbenennen.
|
4. ✅ Seeder-Datei nach `tp44-cf-estate-seed.py` umbenannt — 2026-09-06.
|
||||||
|
|
||||||
## Verifikation (2026-09-02)
|
## Verifikation (2026-09-02)
|
||||||
|
|
||||||
|
|||||||
127
docs/handoff-20260906-tp37-34-36-plan.md
Normal file
127
docs/handoff-20260906-tp37-34-36-plan.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Handoff 2026-09-06 — Plan TP37 (P1+2) → TP34, TP36 parallel
|
||||||
|
|
||||||
|
> **Sessiontyp:** „erst planen, nichts bauen" (User-Entscheid 2026-09-06). **Kein Nautobot-Write.**
|
||||||
|
> Dieses Dokument ist der verzahnte Runbook für die drei nachfolgenden Build-Sessions.
|
||||||
|
> Datenmodell-Entscheid: **native Nautobot-Modelle, keine eigene App** (konsistent zu TP44).
|
||||||
|
|
||||||
|
## 1. Ausgangslage — live gegen die API gelesen (2026-09-06)
|
||||||
|
|
||||||
|
| | Wert | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| Nautobot | `http://10.40.20.30:8080`, v3.1.7 | Apps: **Golden Config 3.0.7 + Nornir** (cloud-Modelle sind Core) |
|
||||||
|
| Devices | 9 | 4 aktive MikroTik + decommissioned/Rest — in Build-Schritt 0 verifizieren |
|
||||||
|
| **VirtualMachines** | **0** | TP37 P1 offen |
|
||||||
|
| **Clusters** | **0** | TP37 P1 offen |
|
||||||
|
| **ipam.Service** | **0** | TP37 P2 offen |
|
||||||
|
| **CustomFields** | **0** | TP36 offen |
|
||||||
|
| Relationships / Tags | 0 / 0 | — |
|
||||||
|
| `cloud.*` (Account/ResType/Network/Service) | 1 / 6 / 4 / 14 | ✅ **TP44 Etappe 1** — CF-Estate steht |
|
||||||
|
|
||||||
|
**Kernbefund:** TP37 **Punkt 3 (CF-Connectoren) ist durch TP44 erledigt** (native cloud-Modelle). Echt
|
||||||
|
offen bleibt das **Substrat** (P1) und die **Dienste** (P2) — und genau darauf sitzt TP34.
|
||||||
|
|
||||||
|
## 2. Entscheidungen (User, 2026-09-06)
|
||||||
|
|
||||||
|
1. **Erst planen, nichts bauen** — diese Session schreibt nichts nach Nautobot.
|
||||||
|
2. **Native Modelle** — `virtualization.Cluster/VirtualMachine` + `ipam.Service`, **keine eigene App**.
|
||||||
|
3. **TP44-Doku aufgeräumt** — Quick-Index-Zeile + Abschnitt in `teilprojekte.md`, Seeder
|
||||||
|
`nautobot/tp36-…` → `tp44-cf-estate-seed.py` umbenannt.
|
||||||
|
|
||||||
|
## 3. Reihenfolge / Abhängigkeit (korrigiert)
|
||||||
|
|
||||||
|
```
|
||||||
|
TP37 P3 (CF-Connectoren) ─── ✅ ERLEDIGT via TP44 ───┐
|
||||||
|
│ gleiche native-Modell-Linie
|
||||||
|
TP37 P1 (Hosts/Container → VirtualMachine) │
|
||||||
|
└─► TP37 P2 (Dienste → ipam.Service) ─► TP34 (Caddyfile via GC)
|
||||||
|
|
||||||
|
TP36 (CustomFields auf den 4 Devices + Datei-Split) ── ∥ unabhängig, jederzeit parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grund für die Unabhängigkeit von TP36:** seine Live-Daten-CustomFields hängen an den **bestehenden
|
||||||
|
`dcim.Device`**, nicht an neuen VM/Service-Objekten. TP34 dagegen kann ohne die Backend-`ipam.Service`
|
||||||
|
aus P2 nichts rendern → **TP37 vor TP34** bleibt zwingend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TP37 Punkt 1 — Substrat: Hosts + Container als VMs
|
||||||
|
|
||||||
|
**Modell-Mapping (nativ):**
|
||||||
|
- `virtualization.ClusterType`: `Proxmox VE`, `Synology Docker`, `Docker Desktop`.
|
||||||
|
- `virtualization.Cluster`: `pve`, `nas-bf`, `macmini` (je einer, passender ClusterType).
|
||||||
|
*(Die physischen Hosts als eigene `dcim.Device` sind **optional** und kommen frühestens in einer
|
||||||
|
Etappe 2 — für den Doku-SSoT reicht der Cluster als Gruppierung. Empfehlung: Etappe 1 ohne Host-Devices.)*
|
||||||
|
- `virtualization.VirtualMachine` je Workload, mit `cluster` + `primary_ip4`:
|
||||||
|
- **pve:** alle LXCs (u.a. `platform-pg` 105/`.20.20`, Docker-LXC 400/`.20.126`, `mcast-relay` 108,
|
||||||
|
influxdb, MariaDB 302/`.20.23`, …).
|
||||||
|
- **nas-bf (DS923, Portainer ep14):** Nautobot-Stack-Container, ggf. Caddy/weitere.
|
||||||
|
- **macmini (Docker Desktop):** `n8n`/`n8n-redis`/`n8n-mcp`/`gitea-mcp` + terpene-Worker `:8890`.
|
||||||
|
|
||||||
|
**Build-Schritt 0 (read-only Inventur, BEVOR geseedet wird):** je Host die Workloads mit Name, Rolle,
|
||||||
|
primärer IP enumerieren — `pct list` + `pct config <id>` über **pve-mcp**, Portainer-API (ep14) für die
|
||||||
|
NAS, `docker ps` auf dem macmini. Ergebnis in eine **Inline-Datenstruktur** (Muster: `tp44-cf-estate-seed.py`)
|
||||||
|
→ idempotenter Seeder `nautobot/tp37-substrat-seed.py` (get-or-create, Dry-Run default, `--commit`).
|
||||||
|
|
||||||
|
## 5. TP37 Punkt 2 — Dienste als `ipam.Service`
|
||||||
|
|
||||||
|
- Je exponiertem Port ein `ipam.Service`, an die VM (bzw. später das Device) gebunden, das ihn betreibt:
|
||||||
|
Caddy `:80/:443`, AdGuard `:53` (bf1/bf2), Nautobot `:8080`, n8n `:5678`, HA `:8123`, Portainer `:9443`,
|
||||||
|
`pve-mcp :3000`, gitea, Vaultwarden, …
|
||||||
|
- **Quelle ohne Raten:** die Ports stehen in `CLAUDE.md` (Hardware/Tooling) **und** in den
|
||||||
|
`extra_config.origin/port`-Feldern der TP44-`CloudService`-Objekte — gegeneinander abgleichen.
|
||||||
|
- Idempotenter Seeder `nautobot/tp37-dienste-seed.py`.
|
||||||
|
|
||||||
|
## 6. 🔴 Auflage: Compliance-Gegenprobe (aus TP37 selbst)
|
||||||
|
|
||||||
|
Nach P1+2 **muss** `python3 nautobot/gc-transaktion.py status` bzw. `gc-configplan.py --refresh-compliance`
|
||||||
|
zeigen, dass die Geräte-Compliance **unverändert 140/140** ist. VMs/Services hinzuzufügen darf die
|
||||||
|
Golden-Config-Pipeline der 4 MikroTik **nicht** berühren. P1 und P2 sind je ein **abnehmbarer** Schritt
|
||||||
|
mit dieser Gegenprobe danach — nicht als Nebeneffekt von etwas anderem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. TP34 — Caddyfile via Golden Config (nach P2)
|
||||||
|
|
||||||
|
- **Vorbedingung:** Backend-Dienste existieren als `ipam.Service` (P2).
|
||||||
|
- **Modell (nativ):** Proxy-Route = **Relationship** (oder CustomField) Frontend `caddy`-Service ↔ Backend-
|
||||||
|
Service, plus den öffentlichen Hostname am Backend. GC-Job + Jinja rendert den Caddyfile, Diff gegen
|
||||||
|
live `caddy-bf1`, neue ComplianceRule `caddyfile`.
|
||||||
|
- **Schritt 0 / Quick-Win (sobald gebaut wird):** `terpene.teilzeitladen.xyz → http://10.40.20.167:8890`
|
||||||
|
als **manueller** Caddy-Block, damit terpene läuft, bevor die GC-Pipeline steht.
|
||||||
|
- ⚠️ **Offener Design-Punkt:** Caddy ist **kein RouterOS-Gerät** — GC an ein Nicht-Netzgerät zu deployen
|
||||||
|
(Caddy-Reload) ist neu (die „nicht in TP37"-Grenze). Deploy-Pfad in der TP34-Build-Session entscheiden.
|
||||||
|
|
||||||
|
## 8. TP36 — Live-Daten-CustomFields + Datei-Split (parallel)
|
||||||
|
|
||||||
|
- **CustomFields auf `dcim.Device`:** `ros_version`, `compliance_pct`, `dhcp_server_count`, `mgmt_ip`,
|
||||||
|
`dns_upstream_mode`. Compliance-Zahl kommt aus **GC selbst**, der Rest aus MikroMCP/REST.
|
||||||
|
- **Skript `nautobot/gc-snapshot.py`** (neu — existiert noch nicht): liest live, setzt die CFs, rendert
|
||||||
|
`docs/teilprojekte-aktuell.md` als **read-only Snapshot**.
|
||||||
|
- **Datei-Split:** `teilprojekte-index.md` (nur aktive TPs, eine Zeile) · `teilprojekte-aktuell.md`
|
||||||
|
(Live-Snapshot) · `teilprojekte-archiv.md` (unverändert). `/projektstatus` liest **Index + Archiv**.
|
||||||
|
- **Unabhängig von TP37** → kann sofort/parallel starten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Empfohlene Reihenfolge der Build-Sessions
|
||||||
|
|
||||||
|
1. **TP37 P1** (Substrat) — Inventur → Seeder → Compliance-Gegenprobe.
|
||||||
|
2. **TP37 P2** (Dienste) — schließt an P1 an, gleiche Gegenprobe.
|
||||||
|
3. **TP34** — nach P2 (Quick-Win terpene als Schritt 0).
|
||||||
|
4. **TP36** — jederzeit parallel zu 1–3.
|
||||||
|
|
||||||
|
## 10. Offene Entscheidungen je Build-Session (nicht heute)
|
||||||
|
|
||||||
|
- **TP34:** Proxy-Route-Modell (Relationship vs. CustomField) + Caddy-Deploy-Pfad (GC an Nicht-Netzgerät).
|
||||||
|
- **TP37:** Hosts als eigene `dcim.Device` anlegen oder nur Cluster? (Empfehlung: Etappe 1 nur Cluster.)
|
||||||
|
- **TP36:** genaues Feld-Set + wie oft `gc-snapshot.py` läuft (Bot analog golden-config vs. on-demand).
|
||||||
|
|
||||||
|
## 11. Was diese Session geändert hat (nur Doku)
|
||||||
|
|
||||||
|
- `nautobot/tp36-cf-estate-seed.py` → **`nautobot/tp44-cf-estate-seed.py`** umbenannt (+ 4 Referenzen,
|
||||||
|
+ Docstring-Titel TP36→TP44).
|
||||||
|
- `teilprojekte.md`: Quick-Index-Zeile TP44; Ist-Stand-Bullet 2026-09-06; TP34/TP36/TP37-Abschnitte auf
|
||||||
|
den korrigierten Stand gezogen; **neuer TP44-Abschnitt** mit Anker.
|
||||||
|
- `handoff-20260902-cf-estate-nautobot-ssot.md` + `cf-tunnel-mapping.md`: Dateiname-Referenzen + erledigte
|
||||||
|
TODOs nachgezogen.
|
||||||
|
- **TP23-Quick-Index-Zeile** (fremde, korrekte Fremd-Änderung ✅→⏳) auf User-Okay mit übernommen.
|
||||||
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.
|
||||||
@@ -68,7 +68,7 @@ den *damals geplanten* Werten und ⬜-Status. Sie sind erledigt bzw. gegenstands
|
|||||||
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
|
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
|
||||||
|
|
||||||
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
|
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
|
||||||
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.30` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.31, `nautobot-scheduler`.20.32, `nautobot-redis`.20.34 |
|
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.154` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.155, `nautobot-scheduler`.20.156, `nautobot-redis`.20.158. **TP45 Schicht A 2026-09-21:** Oktett-Klasse `.150–.199` (App-Services) — die alten `.30–.34` gibt Nautobot an AGH `.30`/`.31` frei |
|
||||||
|
|
||||||
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.
|
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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).
|
||||||
|
|||||||
1162
docs/teilprojekte.md
1162
docs/teilprojekte.md
File diff suppressed because it is too large
Load Diff
76
docs/tp23-stufe6-bypass-sperre-runbook.md
Normal file
76
docs/tp23-stufe6-bypass-sperre-runbook.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# TP23 Stufe 6 — DNS-Bypass-Sperre (Runbook für eine SEPARATE, VOR-ORT-Session)
|
||||||
|
|
||||||
|
> 🔴🔴 **HARTES GATE: NUR VOR ORT ausführen. NICHT aus der Ferne, nicht während Christoph
|
||||||
|
> abwesend ist.** (Notiert 2026-09-06: Christoph ist ~1 Woche nicht vor Ort → in diesem Fenster
|
||||||
|
> **gar nicht anfangen**, auch nicht die log-only-Vorstufe scharf lassen.)
|
||||||
|
>
|
||||||
|
> Grund: Die Sperre macht das AGH-Paar `.36`/`.37` zum **harten DNS-SPOF**. Bis heute kann jeder
|
||||||
|
> Client bei einem AGH-Ausfall auf einen externen Resolver ausweichen; **nach** der Sperre nicht
|
||||||
|
> mehr — ein AGH-Ausfall = **hausweiter DNS-Ausfall** (betrifft die Familie), und ohne physischen
|
||||||
|
> Zugriff gibt es keinen schnellen Rollback. Eine falsch platzierte Drop-Regel kann außerdem den
|
||||||
|
> **Fernzugang selbst** (WG/cf-mesh) aussperren, mit dem man den Fehler zurücknehmen wollte.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Clients dürfen plain **DNS (udp/tcp 53)** und **DoT (853)** nur noch zu den erlaubten Resolvern
|
||||||
|
(`10.40.20.36`, `10.40.20.37`, Core `10.40.X.1`) schicken. Externe DNS-Server (Smart-TV mit
|
||||||
|
`8.8.8.8` hartkodiert, App-eigene Resolver) werden geblockt → **kein Umgehen des Filters**.
|
||||||
|
Das ist der letzte Baustein von TP23 und lebt in der **TP5-Firewall** (`forward`-Kette).
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
**Erfüllt (Stand 2026-09-06):**
|
||||||
|
- Self-Service-Oberfläche steht und ist getestet (HA-Dashboard `dns-ausnahmen`, Stufe 5) → ein
|
||||||
|
geblockter Dienst kann **ohne Admin** temporär freigegeben werden. Das war die harte
|
||||||
|
Reihenfolge-Regel („erst der Ersatz, dann die Sperre").
|
||||||
|
- `dns-bypass-exempt`-Gruppe existiert auf dem Resolver-Paar (AGH-Client-Seite).
|
||||||
|
|
||||||
|
**Noch zu bauen (Teil dieser Session, VOR dem Drop):**
|
||||||
|
- **Firewall-Exempt** ist noch NICHT da: eine Address-List `dns-bypass-exempt` (permanent) mit dem
|
||||||
|
dev-MacBook (`10.40.10.183` **und** `10.40.10.82`, dual-homed) + Agent-Toolchain, plus eine
|
||||||
|
`accept`-Regel **vor** dem Drop. Ohne die verliert der Entwicklungsrechner seinen Ausweg.
|
||||||
|
- Ad-hoc-JIT: Address-List `jit-dns-bypass` mit `timeout` nach dem Muster `jit-admin`
|
||||||
|
(`routeros/tp5-etappe4-input-mgmt.rsc`) — selbstverfallende Freigabe.
|
||||||
|
|
||||||
|
## Bekannte Grenze (offen benennen, nicht „lösen")
|
||||||
|
Die Sperre greift auf **Port** (53/853). **DoH läuft über 443** und ist damit portgleich zu HTTPS —
|
||||||
|
ein Gerät, das DoH gegen `8.8.8.8:443` fährt, umgeht die Sperre. Das per SNI/IP zu schließen ist
|
||||||
|
ein eigenes, größeres Thema (nicht Teil von TP23). Für den Alltag (TVs/IoT mit Plain-DNS) reicht die
|
||||||
|
Port-Sperre.
|
||||||
|
|
||||||
|
## Sichere Methodik — log-only zuerst, Drop zuletzt
|
||||||
|
1. **Exempt + JIT bauen** (via Nautobot GC, `ip-firewall-filter` steht unter Drift-Kontrolle → **kein
|
||||||
|
Direkt-Write**): `accept` für `dns-bypass-exempt`/`jit-dns-bypass` → 53/853, platziert **vor** der
|
||||||
|
künftigen Drop-Regel. Erst hier abnehmen, dass die Exempts wirklich durchkommen.
|
||||||
|
2. **Log-only-Vorstufe**: eine Regel `TP5-WOULD-DROP-DNS`, die LAN→extern auf `dst-port 53,853`
|
||||||
|
(udp+tcp), **nicht** zu `.36/.37/Core`, **nur loggt** (in den Kollektor `10.40.20.18:514`, wie die
|
||||||
|
bestehende `TP5-WOULD-DROP`). **Mehrere Tage laufen lassen** und den Drop-Log lesen: *welche*
|
||||||
|
Geräte reden mit *welchen* externen Resolvern? Jedes davon ist ein Kandidat, der nach dem Drop
|
||||||
|
bricht — vorab entscheiden (Reservierung auf AGH, Exempt, oder bewusst brechen lassen).
|
||||||
|
🔑 Merkregel aus dem E5-Drop-Log: Geräte fallen oft mit **udp/161/andere** nebenbei auf — nur die
|
||||||
|
53/853-Zeilen zählen hier.
|
||||||
|
3. **Erst nach ruhigem/verstandenem Log**: die Log-only-Regel auf `action=drop` (oder `reject` mit
|
||||||
|
`icmp-port-unreachable`, damit Clients schneller auf den nächsten Resolver fallen) heben —
|
||||||
|
**mit Auto-Revert-Absicherung** (Scheduler/Netwatch, der die Regel nach X Minuten deaktiviert,
|
||||||
|
falls nicht bestätigt), analog zum TP5-E3/E4-Vorgehen. **Vor Ort**, mit offener Konsole.
|
||||||
|
|
||||||
|
## Was NICHT gesperrt werden darf (sonst Selbst-Aussperrung)
|
||||||
|
- `.36`/`.37` → CF-DoH-Upstream (443) und Core-Resolver-Egress: die Resolver müssen raus.
|
||||||
|
- Core `10.40.X.1` DoH-Egress.
|
||||||
|
- **cf-mesh / WG-Pfad** (Fernzugang) — die Drop-Regel matcht nur `dst-port 53,853`; trotzdem vor dem
|
||||||
|
Scharfschalten gegenprüfen, dass Management-Input (`cf-mesh`, HA `8728`, Nautobot `22`) unberührt
|
||||||
|
bleibt.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
- GC-Deploy der vorherigen Version (Regel zurück auf `disabled`/entfernt) — bzw. der Auto-Revert.
|
||||||
|
- Fällt das AGH-Paar bei aktiver Sperre aus: Drop-Regel deaktivieren (Vor-Ort-Konsole) → Clients
|
||||||
|
können sofort wieder externe Resolver nutzen.
|
||||||
|
|
||||||
|
## Abnahme
|
||||||
|
- Exempt-Host (dev-MacBook) löst extern weiter auf; ein Test-Gerät mit hartkodiertem `8.8.8.8` löst
|
||||||
|
**nicht** mehr (aber über AGH schon).
|
||||||
|
- Self-Service: eine geblockte Domain per HA-Button freigeben → wirkt; nach Ablauf wieder gefiltert.
|
||||||
|
- Compliance nach dem GC-Deploy grün (`python3 nautobot/gc-transaktion.py status`, GC 140/140).
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
- Stufe 4/5 (fertig): `adguard/README.md`, `adguard/ha-self-service.md`, `adguard/agh-exception.py`.
|
||||||
|
- JIT-/Firewall-Muster: `routeros/tp5-etappe4-input-mgmt.rsc`; GC-Pipeline: `nautobot/gc-configplan.py`.
|
||||||
|
- Kontext/Reihenfolge-Regel: TP23-Abschnitt in `docs/teilprojekte.md`.
|
||||||
8
docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
Normal file
8
docs/transaktionen/TP25-ft-yes-secmain.commands.rsc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# TP25 — sec-main zurueck auf ft=yes / ft-over-ds=yes (Ruecknahme der 09-04-Entscheidung "ft=no als Dauerzustand")
|
||||||
|
# Go: Christoph 2026-09-14 ("go fuer ft=yes, dry-run vorlegen"), Terrasse-In-situ-Session
|
||||||
|
# Anlass: 2 Call-Aussetzer + ~30 Roams in 25 min — mit ft=no ist jeder Roam (Band- und AP-Wechsel)
|
||||||
|
# eine Voll-Reassoziation in Sekundenhoehe; genau darin sterben Anrufe.
|
||||||
|
# Nachweis, dass ft-over-ds=yes + 7.24.1 ruhig ist: Fund-E-Abschlussmessung 09-02 (4 reauth/8,6 h, 0 Ausfaelle).
|
||||||
|
# Nur am Manager noetig: sec-main wird per CAPsMAN an alle CAPs verteilt (Uniformitaet per Konstruktion);
|
||||||
|
# Inline-Overrides je CAP gibt es seit dem ap3-Fix 09-04 nicht mehr — Abnahme prueft sie trotzdem (Merkregel).
|
||||||
|
/interface wifi security set sec-main ft=yes ft-over-ds=yes
|
||||||
10
docs/transaktionen/TP25-steering-5g-threshold.commands.rsc
Normal file
10
docs/transaktionen/TP25-steering-5g-threshold.commands.rsc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# TP25 — 5G bricht frueher zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante)
|
||||||
|
# Go: Christoph 2026-09-15 ("committen und threshold deployen")
|
||||||
|
# Nur 5G: neues Steering-Profil st-main-5g mit transition-threshold=-72 (statt Default -80) und
|
||||||
|
# transition-threshold-time=5s (statt 10s). 2G bleibt auf st-main (unset -80) — Reichweite draussen.
|
||||||
|
# Nur am Manager noetig: steering/config werden per CAPsMAN an die CAPs verteilt.
|
||||||
|
# Rollback = steering der drei Configs zurueck auf st-main + st-main-5g loeschen.
|
||||||
|
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes wnm=yes transition-threshold=-72 transition-threshold-time=5s
|
||||||
|
/interface wifi configuration set [find name=cfg-main-5g-ap1] steering=st-main-5g
|
||||||
|
/interface wifi configuration set [find name=cfg-main-5g-ap2] steering=st-main-5g
|
||||||
|
/interface wifi configuration set [find name=cfg-main-5g-ap3] steering=st-main-5g
|
||||||
15
docs/transaktionen/TP45-firewall-nautobot-154.commands.rsc
Normal file
15
docs/transaktionen/TP45-firewall-nautobot-154.commands.rsc
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# TP45 Schicht A — Nautobot-Literal in der Core-Firewall von .30 auf .154
|
||||||
|
#
|
||||||
|
# KEINE Transaktion: das ist eine dauerhafte Soll-Aenderung (Nautobot ist umgezogen,
|
||||||
|
# das Soll in Nautobot wurde mitgezogen). Die Datei liegt nur hier, weil
|
||||||
|
# gc-manual-plan.py eine Commands-Datei braucht.
|
||||||
|
#
|
||||||
|
# Kontext: die Regel *24 "TP5-E2: Nautobot->MGMT ssh" ist der LITERAL-Pfad und stand
|
||||||
|
# auf 10.40.20.30. Sie steht bei 0 Paketen — die Arbeit macht die Parallelregel *28
|
||||||
|
# "TP5-E2: Nautobot->MGMT ssh (list)" ueber src-address-list=nautobot (113 Pakete,
|
||||||
|
# Liste am 2026-09-21 um .154/.155/.156 erweitert). Der Literal ist damit
|
||||||
|
# funktional redundant, wird aber auf .154 gezogen, damit Soll (Nautobot-Daten
|
||||||
|
# `firewall_filter`) und Ist nicht auseinanderlaufen.
|
||||||
|
#
|
||||||
|
# Rollback: siehe TP45-firewall-nautobot-154.rollback.rsc
|
||||||
|
/ip firewall filter set [find comment="TP5-E2: Nautobot->MGMT ssh"] src-address=10.40.20.154
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Rueckbau TP45 Schicht A: Nautobot-Literal zurueck auf .30
|
||||||
|
# (nur noetig, wenn Nautobot wieder auf .30 zurueckzieht — die Adressliste ist die
|
||||||
|
# wirksame Ebene, dieser Literal ist der tote Parallelpfad.)
|
||||||
|
/ip firewall filter set [find comment="TP5-E2: Nautobot->MGMT ssh"] src-address=10.40.20.30
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Ist-Stand der Regel VOR dem TP45-Schicht-A-Deploy (2026-09-21, gelesen per MikroMCP).
|
||||||
|
/ip firewall filter
|
||||||
|
add action=accept chain=forward comment="TP5-E2: Nautobot->MGMT ssh" dst-port=22 out-interface=vlan99 protocol=tcp src-address=10.40.20.30
|
||||||
@@ -84,7 +84,7 @@ dns:
|
|||||||
ipset_file: ""
|
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).
|
||||||
@@ -52,15 +52,19 @@ BENUTZUNG
|
|||||||
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
|
python3 nautobot/gc-configplan.py --job drift-cleanup --apply --no-deploy
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
|
|
||||||
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
||||||
@@ -160,8 +164,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
|
|||||||
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
||||||
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
||||||
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
||||||
|
|
||||||
|
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
|
||||||
|
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
|
||||||
|
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
|
||||||
|
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
|
||||||
|
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
|
||||||
|
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
|
||||||
|
def created(p):
|
||||||
|
raw = str(p.get("created") or "")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
except ValueError:
|
||||||
|
return datetime.min
|
||||||
|
|
||||||
by_name = {}
|
by_name = {}
|
||||||
for p in found:
|
for p in sorted(found, key=created):
|
||||||
name = (p.get("device") or {}).get("display")
|
name = (p.get("device") or {}).get("display")
|
||||||
by_name[name] = p["id"]
|
by_name[name] = p["id"]
|
||||||
|
|
||||||
@@ -289,7 +307,7 @@ def job_mgmt_acl():
|
|||||||
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
|
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
|
||||||
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
|
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
|
||||||
|
|
||||||
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.30) aus, also aus
|
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
|
||||||
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
|
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
|
||||||
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
|
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
|
||||||
|
|
||||||
@@ -353,10 +371,367 @@ def job_tp42_dhcp_domain():
|
|||||||
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
return [(["crs326-core"], core_set, "TP42-dhcp-domain-bf")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp38_oob():
|
||||||
|
"""TP38 (2026-09-15): OOB-MGMT-Port (ether23 untagged VLAN99) + Branch-Input-Firewall.
|
||||||
|
|
||||||
|
Zwei Geraete, zwei config_sets:
|
||||||
|
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
|
||||||
|
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
|
||||||
|
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
|
||||||
|
die laufende Nautobot-SSH-Session (10.40.20.154) matcht auf Regel 1 (established),
|
||||||
|
bevor Regel 8 (drop) greift.
|
||||||
|
* core: ether23 untaggen, VLAN99 untagged=ether23, EINE Regel "OOB->core mgmt" mit
|
||||||
|
place-before vor die Log-Regel (sonst landet sie hinter dem finalen drop und
|
||||||
|
waere wirkungslos + non-compliant).
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
|
||||||
|
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
|
||||||
|
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
|
||||||
|
"""
|
||||||
|
bridge_set = (
|
||||||
|
':do { /interface bridge port set [find interface=ether23] pvid=99 '
|
||||||
|
'frame-types=admit-only-untagged-and-priority-tagged comment="TP38-OOB-MGMT" } '
|
||||||
|
'on-error={ :log info "tp38: ether23 set geworfen" }'
|
||||||
|
)
|
||||||
|
vlan_set = (
|
||||||
|
':do { /interface bridge vlan set [find vlan-ids=99] untagged=ether23 } '
|
||||||
|
'on-error={ :log info "tp38: vlan99 untagged geworfen" }'
|
||||||
|
)
|
||||||
|
|
||||||
|
branch_fw = "\n".join([
|
||||||
|
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||||
|
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||||
|
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||||
|
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||||
|
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||||
|
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||||
|
])
|
||||||
|
branch_set = "\n".join([
|
||||||
|
bridge_set,
|
||||||
|
vlan_set,
|
||||||
|
*[f"/ip firewall filter {ln}" for ln in branch_fw.splitlines()],
|
||||||
|
':log info ("tp38: branch pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||||
|
' . " fw=" . [:tostr [:len [/ip firewall filter find where comment~"TP38"]]] . "/8")',
|
||||||
|
])
|
||||||
|
|
||||||
|
# ⚠️ `add ... place-before=[find ...]` wird ueber Netmiko IGNORIERT (Regel landet am
|
||||||
|
# Ketten-Ende, also HINTER dem finalen drop = wirkungslos; beim Deploy 2026-09-15 so
|
||||||
|
# aufgeschlagen). Deshalb: erst add (appends), dann `move` vor die Log-Regel.
|
||||||
|
core_set = "\n".join([
|
||||||
|
bridge_set,
|
||||||
|
vlan_set,
|
||||||
|
'/ip firewall filter add action=accept chain=input comment="TP38: OOB->core mgmt" '
|
||||||
|
'dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp',
|
||||||
|
'/ip firewall filter move [find where comment="TP38: OOB->core mgmt"] '
|
||||||
|
'destination=[find where comment="TP5: input log-only"]',
|
||||||
|
':log info ("tp38: core pvid=" . [:tostr [/interface bridge port get [find interface=ether23] pvid]]'
|
||||||
|
' . " oob=" . [:tostr [:len [/ip firewall filter find where comment="TP38: OOB->core mgmt"]]])',
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
(["crs326-branch"], branch_set, "TP38-oob-branch"),
|
||||||
|
(["crs326-core"], core_set, "TP38-oob-core"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_agh_fw():
|
||||||
|
"""TP45 Schicht A (2026-09-21): DNS-Freigaben fuer die neuen Resolver-Adressen .30/.31.
|
||||||
|
|
||||||
|
Der Resolver zieht von .36/.37 auf .30/.31 (TP32-Oktett-Klasse DNS `.3x`). Damit die
|
||||||
|
Umstellung ohne Ausfall geht, laufen beide Adressen kurz parallel — also braucht der
|
||||||
|
Core **beide** Freigaben. Diese hier ist ADDITIV: die vier .36/.37-Regeln bleiben
|
||||||
|
stehen und fallen erst beim Rueckbau (eigener Schritt, wenn die Zaehler stillstehen).
|
||||||
|
|
||||||
|
Die vier Regeln sind ein Literal im Soll (`local_config_context_data.firewall_filter`,
|
||||||
|
gepflegt mit `nautobot/tp45-agh-firewall.py`) — der Job zieht nur das Geraet nach.
|
||||||
|
|
||||||
|
🔴 `place-before` wird ueber Netmiko IGNORIERT (Lehre aus TP38, 2026-09-15): die Regel
|
||||||
|
landet am Ketten-Ende, also HINTER dem finalen `drop` und waere wirkungslos, waehrend
|
||||||
|
der Plan trotzdem „Completed" meldet. Deshalb `add` (appends) und danach `move` vor
|
||||||
|
die Log-Regel — in der Reihenfolge, in der die Regeln stehen sollen (jedes `move`
|
||||||
|
schiebt seine Regel unmittelbar vor das Ziel, also hinter die zuvor verschobenen).
|
||||||
|
|
||||||
|
🔴 **Zweite Falle, am 2026-09-21 beim ersten Lauf aufgeschlagen:** nicht der `move`
|
||||||
|
war falsch, sondern der **Selektor** — `find where dst-address=<bare-IP>` matcht nie
|
||||||
|
(Details und Tragweite in der Docstring von `sel()` unten). Der Plan meldete
|
||||||
|
trotzdem „Completed", die Regeln lagen hinter dem `drop`. Deshalb ist die Abnahme
|
||||||
|
hier **nicht** der Plan-Status, sondern die Position am Geraet.
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: die Regeln erweitern nur `accept` in der forward-Kette; der
|
||||||
|
Mgmt-Pfad (Nautobot-SSH -> vlan99) ist von der Aenderung nicht beruehrt. Der Deploy
|
||||||
|
laeuft von 10.40.20.154, das auf Regel 22 (established) matcht.
|
||||||
|
"""
|
||||||
|
ips = ["10.40.20.30", "10.40.20.31"]
|
||||||
|
log = 'TP5: log-only, Vorstufe zum deny-by-default'
|
||||||
|
|
||||||
|
def sel(cmt, ip):
|
||||||
|
"""Selektor auf genau eine Regel.
|
||||||
|
|
||||||
|
🔴 **`dst-address=<bare-IP>` matcht in `find where` NIE** — live am Core
|
||||||
|
gemessen (2026-09-21): `find where dst-address=10.40.20.30` → **0 Treffer**,
|
||||||
|
auch mit `/32`; `dst-address~"10.40.20.30"` (Regex) → 2. Deshalb steht hier
|
||||||
|
`~`, nicht `=`.
|
||||||
|
|
||||||
|
⚠️ Die Tragweite ist größer als dieser Job: **eine Prüfung, die 0 meldet,
|
||||||
|
sieht wie „nicht vorhanden" aus und nicht wie „kaputt".** Genau daran ist
|
||||||
|
der erste Lauf dieses Jobs gescheitert — der `add` lief (der Guard sah
|
||||||
|
„nicht vorhanden"), der `move` lief ins Leere, und der Plan meldete
|
||||||
|
trotzdem „Completed". Dieselbe Zeile stand als *Verifikation* in
|
||||||
|
`docs/transaktionen/TP23-agh-firewall.commands.rsc:7` und hat dort seit
|
||||||
|
dem 2026-08-25 immer `0` gemeldet, ohne je etwas zu prüfen.
|
||||||
|
Gleiche Klasse wie `nc -z` (2026-09-01) und das `sed`-Maskierungsmuster:
|
||||||
|
**erst den Selektor verdächtigen, dann das Gerät.**
|
||||||
|
"""
|
||||||
|
return f'comment="{cmt}" dst-address~"{ip}"'
|
||||||
|
|
||||||
|
def rule(ip, proto, cmt):
|
||||||
|
return (f'/ip firewall filter add action=accept chain=forward comment="{cmt}" '
|
||||||
|
f'dst-address={ip} dst-port=53 protocol={proto}')
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for ip in ips:
|
||||||
|
# Guard + move getrennt: nur der Guard ist idempotent (ein zweiter Lauf
|
||||||
|
# verdoppelt die Regeln nicht), der move läuft immer — er ist auch der
|
||||||
|
# Reparaturweg, wenn eine Regel schon existiert, aber falsch einsortiert ist.
|
||||||
|
for proto, cmt in (("udp", "TP5-E2: LAN->AGH dns"), ("tcp", "TP5-E2: LAN->AGH dns-tcp")):
|
||||||
|
lines.append(f':if ([:len [/ip firewall filter find where {sel(cmt, ip)}]] = 0) '
|
||||||
|
f'do={{ {rule(ip, proto, cmt)} }}')
|
||||||
|
for ip in ips:
|
||||||
|
for cmt in ("TP5-E2: LAN->AGH dns", "TP5-E2: LAN->AGH dns-tcp"):
|
||||||
|
lines.append(f'/ip firewall filter move [find where {sel(cmt, ip)}] '
|
||||||
|
f'destination=[find where comment="{log}"]')
|
||||||
|
# Der Anker ist bewusst die Log-Regel (Baseline, stabil) und nicht `caddy`: haengt
|
||||||
|
# sie unmittelbar vor der Log-Regel, landen die vier Regeln DAHINTER -- der AGH-Block
|
||||||
|
# bleibt so zusammen, wie das Soll ihn fuehrt. Waehlte man `caddy` als Ziel und die
|
||||||
|
# Regel fehlte, laegen die vier hinter dem `drop` = funktional kaputt; so bleibt im
|
||||||
|
# Fehlerfall nur eine Reihenfolge-Abweichung (Compliance-Drift, kein Ausfall).
|
||||||
|
lines.append(f'/ip firewall filter move [find where comment="TP31: AP-cloudflared -> caddy-bf1"] '
|
||||||
|
f'destination=[find where comment="{log}"]')
|
||||||
|
# ⚠️ Diese Zeile meldet NUR Anwesenheit (je IP 2 Regeln erwartet), NICHT die
|
||||||
|
# Position -- sie kann also gruen aussehen, waehrend die Regeln hinter dem `drop`
|
||||||
|
# liegen. Genau daran ist der erste Lauf gescheitert. **Die Abnahme ist der
|
||||||
|
# Geraete-Read nach dem Deploy** (`print terse proplist=comment,packets`).
|
||||||
|
lines.append(
|
||||||
|
':log info ("tp45-agh-fw: .30=" . '
|
||||||
|
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.30"]]] . " .31=" . '
|
||||||
|
'[:tostr [:len [/ip firewall filter find where dst-address~"10.40.20.31"]]] . " log=" . '
|
||||||
|
f'[:tostr [:len [/ip firewall filter find where comment="{log}"]]])')
|
||||||
|
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-agh-firewall")]
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_agh_dhcp():
|
||||||
|
"""TP45 Schicht A (2026-09-21): DHCP-DNS-Server der 7 Netze .36,.37 -> .30,.31.
|
||||||
|
|
||||||
|
Der Core verteilt in ALLEN sieben Netzen `dns-server=10.40.20.36,10.40.20.37`
|
||||||
|
(live geprueft 2026-09-21). Das Template (Z.137) ist auf .30,.31 gehoben; dieser Job
|
||||||
|
zieht das laufende Geraet nach.
|
||||||
|
|
||||||
|
MODIFY, kein add: das Netz existiert -> `set [find]`, nicht die `add`-Zeile der
|
||||||
|
intended config (die wuerfe `already have such address`). `[find]` ohne Filter
|
||||||
|
trifft alle 7 Netze. Gleiche Form wie `job_tp42_dhcp_domain`.
|
||||||
|
|
||||||
|
⚠️ **Reihenfolge zwingend:** erst `tp45-agh-fw` deployen. Sonst liefert DHCP Adressen
|
||||||
|
aus, die die Firewall noch nicht durchlaesst — unter deny-by-default faellt DNS aus
|
||||||
|
allen VLANs aus.
|
||||||
|
|
||||||
|
KEIN LOCKOUT-RISIKO: eine DHCP-Option beruehrt weder Routing noch Mgmt-Zugang;
|
||||||
|
Clients ziehen sie erst beim naechsten Lease-Renew (lease-time 1d, GUEST 12h).
|
||||||
|
"""
|
||||||
|
core_set = "\n".join([
|
||||||
|
':do { /ip dhcp-server network set [find] dns-server=10.40.20.30,10.40.20.31 } '
|
||||||
|
'on-error={ :log info "tp45-agh-dhcp: set geworfen" }',
|
||||||
|
':log info ("tp45-agh-dhcp: auf-neu=" . [:tostr [:len [/ip dhcp-server network '
|
||||||
|
'find where dns-server~"10.40.20.30"]]] . "/7")',
|
||||||
|
])
|
||||||
|
return [(["crs326-core"], core_set, "TP45-agh-dhcp")]
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_set(vlan, ranges):
|
||||||
|
"""Zeilen, die `pool-v<vlan>` auf `ranges` ziehen -- mit gezaehltem Selektor.
|
||||||
|
|
||||||
|
MODIFY, kein add: der Pool existiert, die `add`-Zeile der intended config
|
||||||
|
wuerfe `already have such name`. Gleiche Form wie `job_tp45_agh_dhcp`.
|
||||||
|
|
||||||
|
🔴 Der Selektor wird GEZAEHLT, nicht geglaubt (Lehre vom 2026-09-21): ein
|
||||||
|
`find where`, das 0 trifft, sieht wie „nicht vorhanden" aus und nicht wie
|
||||||
|
„kaputt" -- ein blankes `set [find where name=…]` liefe ins Leere und der
|
||||||
|
Job meldete trotzdem Erfolg (dieselbe Klasse wie `dst-address=<bare IP>`
|
||||||
|
in TP23-agh-firewall.commands.rsc:7, das seit dem 08-25 immer 0 meldete).
|
||||||
|
|
||||||
|
🔴🔴 **JEDES Statement muss auf EINER Zeile klammer-vollstaendig sein.**
|
||||||
|
Erste Fassung hatte `:if (...) do={` am Zeilenende -- RouterOS geht dann in
|
||||||
|
den Fortsetzungs-Modus und zeigt einen ANDEREN Prompt, der netmiko-Treiber
|
||||||
|
verliert sein Muster und der Deploy stirbt mit
|
||||||
|
`E1015: Pattern not detected: (redacted)@crs326\\-core\\].*$|#.*$)`.
|
||||||
|
Der Plan stand danach auf `Failed`, **ohne** etwas geschrieben zu haben.
|
||||||
|
Deshalb ist jede Zeile hier ein vollstaendiges `… do={ …; … } else={ … }`.
|
||||||
|
"""
|
||||||
|
pool = f"pool-v{vlan}"
|
||||||
|
return "\n".join([
|
||||||
|
f':if ([:len [/ip pool find where name="{pool}"]] = 1) do={{ '
|
||||||
|
f'/ip pool set [find where name="{pool}"] ranges={ranges}; '
|
||||||
|
f':log info ("tp45-pool: {pool} -> " . [/ip pool get [find where name="{pool}"] ranges]) }} '
|
||||||
|
f'else={{ :log info ("tp45-pool: ABBRUCH, {pool} nicht eindeutig") }}',
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_pool_v20():
|
||||||
|
"""TP45 (2026-09-21): pool-v20 von .100-.200 auf .200-.249 verengen.
|
||||||
|
|
||||||
|
Damit werden die Schema-Baender .100-.149 (Server-Hosts) und .150-.199
|
||||||
|
(App-Services) in VLAN20 echt frei. VLAN20 traegt KEINE DHCP-Reservierung
|
||||||
|
im alten Fenster (live geprueft: nur `nas-bf .20.10` in .10x) und 0
|
||||||
|
dynamische Leases -- die Verengung kostet hier nichts.
|
||||||
|
|
||||||
|
🔴 Warum ueberhaupt: `nautobot .154-.158`, `gitea .157`, `pve-mcp .173` und
|
||||||
|
`shelly-mcp .180` tragen ihre IP GERAETELOKAL und stehen in KEINEM
|
||||||
|
Lease-Eintrag -- der Core kann sie nicht schuetzen und einem DHCP-Client
|
||||||
|
.154 anbieten. Die Verengung ist hier sofort wirksam.
|
||||||
|
|
||||||
|
⚠️ VLAN30 gehoert NICHT hierher: dort liegen 35 statische Reservierungen IM
|
||||||
|
Fenster .200-.249 (26 Shellys .202-.226/.233, SwitchBot .227, Twinkly .228,
|
||||||
|
Withings .230/.231, lichterketten .236/.237, Nuki .241/.244, CCU .232).
|
||||||
|
Erst Schicht B zieht die um, DANN pool-v30.
|
||||||
|
"""
|
||||||
|
return [(["crs326-core"], _pool_set(20, "10.40.20.200-10.40.20.249"), "TP45-pool-v20")]
|
||||||
|
|
||||||
|
|
||||||
|
def _tp45_leases_module():
|
||||||
|
"""MOVES/REMOVE/ADDS aus `tp45-leases.py` laden.
|
||||||
|
|
||||||
|
🔴 **NICHT kopieren.** Diese Tabellen sind die SSoT des Umzugs. Eine zweite
|
||||||
|
Kopie hier liefe beim ersten Edit auseinander — und zwar still, weil beide
|
||||||
|
Seiten fuer sich plausibel aussehen; am Geraet faellt nur auf, dass eine
|
||||||
|
Adresse fehlt.
|
||||||
|
|
||||||
|
Der Dateiname traegt einen Bindestrich (Repo-Konvention fuer Skripte), ist
|
||||||
|
also nicht regulaer importierbar -> importlib ueber den Pfad. `__name__` ist
|
||||||
|
dann nicht `__main__`, das `main()`-Guard der Datei greift also nicht.
|
||||||
|
"""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"tp45_leases", pathlib.Path(__file__).with_name("tp45-leases.py"))
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_move(mac, soll):
|
||||||
|
"""Eine Umzugs-Zeile — EINZEILIG und klammer-vollstaendig (E1015-Lehre, s. _pool_set).
|
||||||
|
|
||||||
|
Der Selektor wird GEZAEHLT: ein `find where`, das 0 trifft, sieht wie
|
||||||
|
„nicht vorhanden" aus und nicht wie „kaputt", und ein blankes `set [find …]`
|
||||||
|
liefe ins Leere, waehrend der Job Erfolg meldet.
|
||||||
|
|
||||||
|
Absichtlich OHNE `address=<ist>` im Selektor: ein zweiter Lauf muss ein
|
||||||
|
No-Op sein, kein Fehler. Die Ist-Adresse wird stattdessen VOR dem Deploy
|
||||||
|
geprueft — `tp45-leases.py move` verlangt, dass das Soll genau `ist` traegt
|
||||||
|
und bricht sonst ab.
|
||||||
|
|
||||||
|
🔴🔴 **ZWEI VERSCHIEDENE Selektor-Formen, und das ist keine Kosmetik.**
|
||||||
|
Die erste Fassung schrieb ueberall die Argument-Form `[… [find where …]]`.
|
||||||
|
Die ist **Syntaxfehler**, sobald sie in `[:len …]` steht:
|
||||||
|
`expected command name (line 1 column 55)` (live gemessen 2026-09-21)
|
||||||
|
Der Deploy lief trotzdem als `Completed` durch, das Geraet blieb
|
||||||
|
**unveraendert**, und im Log stand nur die Schlusszeile — von den 38
|
||||||
|
`:if`-Statements hat **kein einziger** Zweig geloggt, also ist auch kein
|
||||||
|
`set` gelaufen.
|
||||||
|
|
||||||
|
* in `[:len …]` und direkt vor `find`: **`/ip dhcp-server lease find where …`**
|
||||||
|
(kein Klammerpaar um `find`) — Muster aus `_pool_set`/`job_tp45_agh_dhcp`
|
||||||
|
* als Argument einer Vollpfad-Kommandozeile: `set [find where …]`
|
||||||
|
— diese Form ist in `_pool_set` bewiesen und bleibt.
|
||||||
|
|
||||||
|
⚠️ **Merkregel:** `Completed` am Plan und `SUCCESS` am Job beweisen hier
|
||||||
|
nichts. Der Beweis ist die **Zahl der Log-Zeilen** (jeder Zweig loggt) oder
|
||||||
|
ein Geraete-Read. Beides gehoert zur Abnahme — genau wie beim
|
||||||
|
`pool-v20`-Deploy, der ohne Gegenprobe als gelungen gegolten haette.
|
||||||
|
"""
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
return (f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease set {arg} address={soll}; '
|
||||||
|
f':log info ("tp45-v30: {mac} -> {soll}") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30: {mac} NICHT EINDEUTIG -- nicht angefasst") }}')
|
||||||
|
|
||||||
|
|
||||||
|
def job_tp45_v30_leases():
|
||||||
|
"""TP45 Schicht B2 (2026-09-21): die VLAN30-Reservierungen ins Oktett-Schema.
|
||||||
|
|
||||||
|
36 Reservierungen wandern aus dem DHCP-Pool-Fenster (.202-.244, .5, .8) in die
|
||||||
|
Schema-Baender: `.11-.14` fest verbaute Infrastruktur, `.151-.176` Shelly-Block,
|
||||||
|
`.177-.196` App-Services. Dazu zwei Sonderfaelle:
|
||||||
|
|
||||||
|
* `pve-homematic-raspi-ccu` (MAC BC:24:11:8E:0A:57) wird ENTFERNT — eine
|
||||||
|
Leiche aus dem Core-Cutover (`status=waiting`, `last-seen=never`). Die CCU
|
||||||
|
ist live in VLAN20 (.20.232, ARP + HTTP 200 belegt, Caddys `ccu.bf` proxyt
|
||||||
|
dorthin); die VLAN30-Reservierung ist ein Zombie.
|
||||||
|
* `SONOFF-NSPanelPro` hat GAR KEINE Reservierung, nur eine dynamische Lease
|
||||||
|
auf `.249`. Wird per `make-static` gepinnt und auf `.12` gezogen.
|
||||||
|
|
||||||
|
⚠️ **Reihenfolge:** dieser Job gehoert VOR `tp45-pool-v30`. Die Verengung auf
|
||||||
|
`.200-.249` setzt voraus, dass keine Reservierung mehr im Schema-Band liegt —
|
||||||
|
sonst zieht der Pool Adressen, die schon vergeben sind.
|
||||||
|
|
||||||
|
⚠️ **Kein Ausfall, kein Lockout:** ueber DHCP-Reservierungen umzuziehen heisst,
|
||||||
|
dass die Geraete beim naechsten Lease-Renew selbst nachziehen (lease-time 1d).
|
||||||
|
Nur zwei der 36 sind im Moment ueberhaupt `bound` (SwitchBot, DTUBI); alle
|
||||||
|
anderen stehen auf `last-seen=never`. Nichts wird hart umgeschaltet.
|
||||||
|
|
||||||
|
⚠️ **`.150` (Ender3V3KE) und `.180` (espressif/Airfryer) bleiben, wo sie sind** —
|
||||||
|
sie liegen bereits im App-Band und werden bewusst nicht bewegt.
|
||||||
|
|
||||||
|
Rueckweg: `MOVES` traegt Ist UND Soll je MAC, das inverse `set` ist also
|
||||||
|
mechanisch; zusaetzlich liegt vor dem Deploy ein binaeres Backup auf dem Core.
|
||||||
|
"""
|
||||||
|
mod = _tp45_leases_module()
|
||||||
|
lines = [_lease_move(mac, soll) for mac, (_ist, soll) in sorted(mod.MOVES.items())]
|
||||||
|
|
||||||
|
for mac in sorted(mod.REMOVE):
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease remove {arg}; '
|
||||||
|
f':log info ("tp45-v30: CCU-Leiche {mac} entfernt") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30: CCU-Leiche schon weg") }}')
|
||||||
|
|
||||||
|
for add in mod.ADDS:
|
||||||
|
mac = re.search(r"mac-address=(\S+)", add).group(1)
|
||||||
|
ip = re.search(r"address=(\S+)", add).group(1)
|
||||||
|
cmt = re.search(r"comment=(\S+)", add).group(1)
|
||||||
|
find = f'find where mac-address={mac}'
|
||||||
|
arg = f'[find where mac-address={mac}]'
|
||||||
|
# make-static VOR set: auf einer dynamischen Lease ist `set address=` wirkungslos
|
||||||
|
# bzw. wird beim naechsten Renew ohnehin verworfen -- erst statisch, dann pinnen.
|
||||||
|
lines.append(f':if ([:len [/ip dhcp-server lease {find}]] = 1) do={{ '
|
||||||
|
f'/ip dhcp-server lease make-static {arg}; '
|
||||||
|
f'/ip dhcp-server lease set {arg} address={ip} comment="{cmt}"; '
|
||||||
|
f':log info ("tp45-v30: NSPanelPro statisch auf {ip}") }} '
|
||||||
|
f'else={{ :log info ("tp45-v30: NSPanelPro hat keine Lease -- spaeter nachziehen") }}')
|
||||||
|
|
||||||
|
# Abnahme-Zeile. `address=` ist ein echter Eigenschaftsvergleich (kein Regex) --
|
||||||
|
# bewusst NICHT `~"10.40.30.11"`, das traefe auch .110-.119.
|
||||||
|
lines.append(
|
||||||
|
':log info ("tp45-v30: .11=" . [:tostr [:len [/ip dhcp-server lease '
|
||||||
|
'find where address=10.40.30.11]]] . " .12=" . [:tostr [:len [/ip dhcp-server '
|
||||||
|
'lease find where address=10.40.30.12]]] . " .151=" . [:tostr [:len '
|
||||||
|
'[/ip dhcp-server lease find where address=10.40.30.151]]] . " .196=" . '
|
||||||
|
'[:tostr [:len [/ip dhcp-server lease find where address=10.40.30.196]]])')
|
||||||
|
|
||||||
|
return [(["crs326-core"], "\n".join(lines), "TP45-v30-leases")]
|
||||||
|
|
||||||
|
|
||||||
JOBS = {
|
JOBS = {
|
||||||
"drift-cleanup": job_drift_cleanup,
|
"drift-cleanup": job_drift_cleanup,
|
||||||
"mgmt-acl": job_mgmt_acl,
|
"mgmt-acl": job_mgmt_acl,
|
||||||
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
||||||
|
"tp38-oob": job_tp38_oob,
|
||||||
|
"tp45-agh-fw": job_tp45_agh_fw,
|
||||||
|
"tp45-agh-dhcp": job_tp45_agh_dhcp,
|
||||||
|
"tp45-pool-v20": job_tp45_pool_v20,
|
||||||
|
"tp45-v30-leases": job_tp45_v30_leases,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
||||||
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
||||||
|
|
||||||
|
|||||||
199
nautobot/gc-snapshot.py
Normal file
199
nautobot/gc-snapshot.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP36-Live — Live-Gerätedaten als CustomFields auf den RouterOS-dcim.Device.
|
||||||
|
|
||||||
|
Legt (idempotent, get-or-create) fünf CustomFields auf `dcim.device` an und
|
||||||
|
befüllt sie für jedes RouterOS-Gerät. Die Werte kommen AUSSCHLIESSLICH aus
|
||||||
|
Nautobot selbst — es wird KEIN Gerät kontaktiert:
|
||||||
|
|
||||||
|
ros_version aus der GoldenConfig-`backup_config` ("by RouterOS X.Y.Z")
|
||||||
|
dhcp_server_count gezählte Einträge der Sektion "/ip dhcp-server" im Backup
|
||||||
|
dns_upstream_mode aus der "/ip dns"-Sektion des Backups: doh | plain | none
|
||||||
|
compliance_pct GC config-compliance: compliant / total * 100 (gerundet)
|
||||||
|
mgmt_ip device.primary_ip4 (ohne /prefix)
|
||||||
|
|
||||||
|
Warum aus Nautobot statt live vom Gerät: der MGMT-/REST-Weg (VLAN99:80) ist
|
||||||
|
nicht aus jedem Vantage erreichbar; die GC-Backups werden täglich (03:30 UTC,
|
||||||
|
ScheduledJob "GC Backup+Compliance – taeglich") per SSH gezogen und sind die
|
||||||
|
Doku-SSoT. Damit braucht der Snapshot keine Geräte-Credentials. Aktualität steht
|
||||||
|
in `backup_last_success_date` — ist die alt, steht der Backup-Schedule (2026-09-12
|
||||||
|
war er 8 Tage aus, weil es vorher gar keinen gab).
|
||||||
|
|
||||||
|
Der Backup-Config-Text wird nur INTERN geparst (Zähler/Modus abgeleitet) und
|
||||||
|
NIE ausgegeben — er kann gescrubte Reste enthalten (PSK-Leak-Regel).
|
||||||
|
|
||||||
|
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||||
|
Schlüssel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/gc-snapshot.py # Dry-Run (default) — zeigt nur an
|
||||||
|
python3 nautobot/gc-snapshot.py --commit # legt CustomFields an + patcht Devices
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(f"{NB}{path}", data=data, headers=HDRS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- CustomFields
|
||||||
|
# (key, label, type, description). content_types wird ergänzt.
|
||||||
|
CUSTOM_FIELDS = [
|
||||||
|
("ros_version", "RouterOS-Version", "text",
|
||||||
|
"RouterOS-Version aus der GC-backup_config (by RouterOS …)."),
|
||||||
|
("compliance_pct", "GC-Compliance %", "integer",
|
||||||
|
"Golden-Config config-compliance: compliant/total in Prozent."),
|
||||||
|
("dhcp_server_count", "DHCP-Server", "integer",
|
||||||
|
"Anzahl Einträge der Sektion /ip dhcp-server im GC-Backup."),
|
||||||
|
("mgmt_ip", "Mgmt-IP", "text",
|
||||||
|
"device.primary_ip4 ohne Prefix (VLAN99-Management)."),
|
||||||
|
("dns_upstream_mode", "DNS-Upstream-Modus", "text",
|
||||||
|
"Aus /ip dns des GC-Backups: doh | plain | none."),
|
||||||
|
]
|
||||||
|
CF_KEYS = [c[0] for c in CUSTOM_FIELDS]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_custom_fields():
|
||||||
|
"""get-or-create die fünf CFs; bei Bestand: content_type dcim.device sicherstellen."""
|
||||||
|
st, d = req("GET", "/api/extras/custom-fields/?limit=0")
|
||||||
|
have = {r["key"]: r for r in d.get("results", [])}
|
||||||
|
for key, label, typ, desc in CUSTOM_FIELDS:
|
||||||
|
ex = have.get(key)
|
||||||
|
if ex:
|
||||||
|
cts = ex.get("content_types", [])
|
||||||
|
ok = "dcim.device" in cts
|
||||||
|
print(f" = CF '{key}' existiert ({ex['id']}){'' if ok else ' ⚠ ohne dcim.device!'}")
|
||||||
|
if not ok and COMMIT:
|
||||||
|
req("PATCH", f"/api/extras/custom-fields/{ex['id']}/",
|
||||||
|
{"content_types": sorted(set(cts) | {"dcim.device"})})
|
||||||
|
print(f" + dcim.device ergänzt")
|
||||||
|
continue
|
||||||
|
payload = {"key": key, "label": label, "type": typ, "description": desc,
|
||||||
|
"content_types": ["dcim.device"], "grouping": "TP36-Live",
|
||||||
|
"required": False}
|
||||||
|
if not COMMIT:
|
||||||
|
print(f" + CF '{key}' ({typ}) [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
st, r = req("POST", "/api/extras/custom-fields/", payload)
|
||||||
|
print(f" {'+' if st in (200,201) else '!'} CF '{key}'"
|
||||||
|
+ (f" angelegt ({r.get('id')})" if st in (200, 201)
|
||||||
|
else f" FEHLER {st}: {json.dumps(r)[:200]}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Backup-Parser
|
||||||
|
def parse_backup(cfg: str):
|
||||||
|
"""Werte aus dem flachen RouterOS-Export (eine '/…'-Zeile pro Kommando)."""
|
||||||
|
ver = ""
|
||||||
|
m = re.search(r"by RouterOS ([0-9][0-9A-Za-z.\-]*)", cfg)
|
||||||
|
if m:
|
||||||
|
ver = m.group(1)
|
||||||
|
# DHCP-Server = Zeilen '/ip dhcp-server add …' (NICHT lease/network/option)
|
||||||
|
dhcp = len(re.findall(r"(?m)^\s*/ip dhcp-server add\b", cfg))
|
||||||
|
# DNS-Modus aus der '/ip dns set …'-Zeile
|
||||||
|
mode = "none"
|
||||||
|
md = re.search(r"(?m)^\s*/ip dns set\b.*", cfg)
|
||||||
|
if md:
|
||||||
|
line = md.group(0)
|
||||||
|
doh = re.search(r"use-doh-server=([^\s]+)", line)
|
||||||
|
srv = re.search(r"\bservers=([^\s]+)", line)
|
||||||
|
if doh and doh.group(1) not in ("", '""'):
|
||||||
|
mode = "doh"
|
||||||
|
elif srv and srv.group(1) not in ("", '""'):
|
||||||
|
mode = "plain"
|
||||||
|
return ver, dhcp, mode
|
||||||
|
|
||||||
|
|
||||||
|
def compliance_pct(device_name: str):
|
||||||
|
q = urllib.parse.urlencode({"device": device_name, "limit": 0, "depth": 0})
|
||||||
|
st, d = req("GET", f"/api/plugins/golden-config/config-compliance/?{q}")
|
||||||
|
rows = d.get("results", [])
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
ok = sum(1 for r in rows if r.get("compliance"))
|
||||||
|
return round(ok / len(rows) * 100)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- main
|
||||||
|
def routeros_devices():
|
||||||
|
q = urllib.parse.urlencode({"limit": 0, "depth": 1, "platform": "RouterOS"})
|
||||||
|
st, d = req("GET", f"/api/dcim/devices/?{q}")
|
||||||
|
devs = []
|
||||||
|
for r in d.get("results", []):
|
||||||
|
ip = r.get("primary_ip4")
|
||||||
|
ip = ip.get("address") if isinstance(ip, dict) else ip
|
||||||
|
if not ip:
|
||||||
|
continue # tote Altgeräte (cudy/sw) ohne IP überspringen
|
||||||
|
devs.append((r["id"], r["name"], ip.split("/")[0]))
|
||||||
|
return sorted(devs, key=lambda x: x[1])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP36-Live gc-snapshot ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
|
||||||
|
print("\n[1] CustomFields sicherstellen")
|
||||||
|
ensure_custom_fields()
|
||||||
|
|
||||||
|
print("\n[2] Golden-Config-Backups laden")
|
||||||
|
st, gc = req("GET", "/api/plugins/golden-config/golden-config/?depth=1&limit=0")
|
||||||
|
backups = {}
|
||||||
|
for r in gc.get("results", []):
|
||||||
|
dev = r.get("device")
|
||||||
|
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||||
|
backups[dev] = (r.get("backup_config") or "", r.get("backup_last_success_date") or "?")
|
||||||
|
|
||||||
|
print("\n[3] Snapshot je Gerät")
|
||||||
|
print(f" {'Gerät':16} {'ros':9} {'cmpl%':>5} {'dhcp':>4} {'dns':6} {'mgmt-ip':15} backup")
|
||||||
|
for dev_id, name, mgmt in routeros_devices():
|
||||||
|
cfg, when = backups.get(name, ("", "—"))
|
||||||
|
ver, dhcp, dns = parse_backup(cfg) if cfg else ("?", 0, "?")
|
||||||
|
pct = compliance_pct(name)
|
||||||
|
vals = {
|
||||||
|
"ros_version": ver or None,
|
||||||
|
"compliance_pct": pct,
|
||||||
|
"dhcp_server_count": dhcp,
|
||||||
|
"mgmt_ip": mgmt,
|
||||||
|
"dns_upstream_mode": dns,
|
||||||
|
}
|
||||||
|
pct_s = "—" if pct is None else str(pct)
|
||||||
|
print(f" {name:16} {ver or '?':9} {pct_s:>5} {dhcp:>4} {dns:6} {mgmt:15} {when[:10]}")
|
||||||
|
if COMMIT:
|
||||||
|
st, r = req("PATCH", f"/api/dcim/devices/{dev_id}/", {"custom_fields": vals})
|
||||||
|
if st not in (200, 201):
|
||||||
|
print(f" ! PATCH FEHLER {st}: {json.dumps(r)[:200]}")
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT — geschrieben' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -55,7 +55,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
|
|||||||
REPO = os.path.dirname(HIER)
|
REPO = os.path.dirname(HIER)
|
||||||
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
||||||
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
|
||||||
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
||||||
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
|
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
|
||||||
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ["NAUTOBOT_TOKEN"]
|
TOKEN = os.environ["NAUTOBOT_TOKEN"]
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet).
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Quelle: ap1 live via MikroMCP + docs/teilprojekte.md.
|
|||||||
"""
|
"""
|
||||||
import json, sys, urllib.request, urllib.error
|
import json, sys, urllib.request, urllib.error
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
import os
|
import os
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import pathlib
|
|||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
DEVICE = "crs326-core"
|
DEVICE = "crs326-core"
|
||||||
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
|
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null).
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null).
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
132
nautobot/ingest-dhcp-lease-rule.py
Normal file
132
nautobot/ingest-dhcp-lease-rule.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ComplianceRule `dhcp-lease` anlegen (idempotent).
|
||||||
|
|
||||||
|
Die Luecke, die das schliesst
|
||||||
|
-----------------------------
|
||||||
|
`nautobot/mikrotik_routeros.j2` rendert seit dem 2026-09-21 den Abschnitt
|
||||||
|
`/ip dhcp-server lease` (TP45 Schicht B, aus `local_config_context_data.dhcp_leases`).
|
||||||
|
Gerendert heisst aber noch nicht bewacht: ohne ComplianceRule erzeugt der Block
|
||||||
|
**keine** Compliance-Eintraege, und die Gesamtzahl bliebe unauffaellig. Genau die
|
||||||
|
Falle aus CLAUDE.md — "eine gleichbleibende Compliance-Zahl nach dem Hinzufuegen
|
||||||
|
eines Geraets ist ein BEFUND, kein Erfolg".
|
||||||
|
|
||||||
|
Konkret: von 54 live existierenden Reservierungen war vorher **keine einzige**
|
||||||
|
drift-ueberwacht. Verschiebt jemand eine Adresse von Hand am Geraet, meldet
|
||||||
|
Golden Config "compliant", weil die Sektion unter keiner Rule steht.
|
||||||
|
|
||||||
|
Analogie: TP26 fuer `/ip firewall filter` (25 Regeln blind), `ingest-queue-rule.py`
|
||||||
|
fuer `/queue`, `ingest-wifi-access-list-rule.py` fuer `/interface wifi access-list`,
|
||||||
|
`ingest-logging-rule.py` fuer `/system logging`.
|
||||||
|
|
||||||
|
export NAUTOBOT_TOKEN=... # aus docs/nas-vlan20/stacks/210-nautobot.yml
|
||||||
|
python3 nautobot/ingest-dhcp-lease-rule.py # Dry-Run
|
||||||
|
python3 nautobot/ingest-dhcp-lease-rule.py --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
FEATURE = "dhcp-lease"
|
||||||
|
# ⚠️ Nautobot begrenzt die Feature-Beschreibung auf 200 Zeichen (400 sonst).
|
||||||
|
BESCHREIBUNG = ("TP45 Schicht B: DHCP-Reservierungen (/ip dhcp-server lease). Bis 2026-09-21 "
|
||||||
|
"unter keiner Rule -- die 54 Reservierungen waren drift-blind. Gleicher "
|
||||||
|
"blinder Fleck wie TP26 fuer /ip firewall filter.")
|
||||||
|
# Praefix, nicht die ganze Sektion: `/ip dhcp-server lease` faengt nur die Reservierungen.
|
||||||
|
# Ein blankes `/ip dhcp-server` wuerde die Server-, Network- und Option-Zeilen mitfangen,
|
||||||
|
# die eigene Rules haben (bzw. haben sollen).
|
||||||
|
MATCH = "/ip dhcp-server lease"
|
||||||
|
PLATTFORM = "RouterOS"
|
||||||
|
|
||||||
|
|
||||||
|
def req(method, path, data=None):
|
||||||
|
url = path if path.startswith("http") else BASE + path
|
||||||
|
body = json.dumps(data).encode() if data is not None else None
|
||||||
|
r = urllib.request.Request(url, data=body, method=method)
|
||||||
|
r.add_header("Authorization", "Token " + TOKEN)
|
||||||
|
r.add_header("Content-Type", "application/json")
|
||||||
|
r.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(r, timeout=60) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit("!! %s %s -> %s\n %s" % (method, url, e.code, e.read().decode()[:600]))
|
||||||
|
|
||||||
|
|
||||||
|
def eins(pfad, **filter):
|
||||||
|
q = urllib.parse.urlencode(filter)
|
||||||
|
res = req("GET", "%s?%s" % (pfad, q)).get("results", [])
|
||||||
|
if len(res) > 1:
|
||||||
|
sys.exit("FEHLER: %s?%s liefert %d Treffer, erwartet 0 oder 1" % (pfad, q, len(res)))
|
||||||
|
return res[0] if res else None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not TOKEN:
|
||||||
|
sys.exit("NAUTOBOT_TOKEN fehlt.")
|
||||||
|
print("Modus:", "APPLY" if APPLY else "DRY-RUN (nichts wird geschrieben)")
|
||||||
|
|
||||||
|
plat = eins("/dcim/platforms/", name=PLATTFORM)
|
||||||
|
if not plat:
|
||||||
|
sys.exit("Plattform %r nicht gefunden." % PLATTFORM)
|
||||||
|
print("Plattform : %s (%s)" % (plat["display"], plat["id"]))
|
||||||
|
|
||||||
|
feat = eins("/plugins/golden-config/compliance-feature/", name=FEATURE)
|
||||||
|
if feat:
|
||||||
|
print("Feature : existiert bereits (%s)" % feat["id"])
|
||||||
|
else:
|
||||||
|
print("Feature : anlegen -> %s" % FEATURE)
|
||||||
|
if APPLY:
|
||||||
|
feat = req("POST", "/plugins/golden-config/compliance-feature/",
|
||||||
|
{"name": FEATURE, "slug": FEATURE, "description": BESCHREIBUNG})
|
||||||
|
print(" angelegt (%s)" % feat["id"])
|
||||||
|
|
||||||
|
if not feat:
|
||||||
|
print("\nDry-Run: ohne Feature kann die Rule nicht geprueft werden. Mit --apply erneut.")
|
||||||
|
return
|
||||||
|
|
||||||
|
rule = eins("/plugins/golden-config/compliance-rule/",
|
||||||
|
feature=feat["id"], platform=plat["id"])
|
||||||
|
if rule:
|
||||||
|
print("Rule : existiert bereits (%s), match_config=%r, config_ordered=%s"
|
||||||
|
% (rule["id"], rule.get("match_config"), rule.get("config_ordered")))
|
||||||
|
if rule.get("match_config") != MATCH:
|
||||||
|
print(" ⚠️ match_config weicht ab -- erwartet %r" % MATCH)
|
||||||
|
if rule.get("config_ordered"):
|
||||||
|
print(" ⚠️ config_ordered=True -- erwartet False (s. u.)")
|
||||||
|
return
|
||||||
|
|
||||||
|
nutzlast = {
|
||||||
|
"feature": feat["id"],
|
||||||
|
"platform": plat["id"],
|
||||||
|
"description": BESCHREIBUNG,
|
||||||
|
# 🔴 False, NICHT True: die Reservierungs-Sektion steht am Geraet in
|
||||||
|
# Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet), nicht numerisch.
|
||||||
|
# Bei config_ordered=True waere jede Umsortierung durch RouterOS ein
|
||||||
|
# Falsch-Drift. Die Reihenfolge ist hier auch semantisch bedeutungslos —
|
||||||
|
# anders als bei /ip firewall filter, wo sie ueber Gewinnen/Verlieren entscheidet.
|
||||||
|
"config_ordered": False,
|
||||||
|
"config_type": "cli",
|
||||||
|
"match_config": MATCH,
|
||||||
|
"custom_compliance": False,
|
||||||
|
"config_remediation": False,
|
||||||
|
}
|
||||||
|
print("Rule : anlegen -> %s auf %s, match_config=%r, config_ordered=False"
|
||||||
|
% (FEATURE, PLATTFORM, MATCH))
|
||||||
|
if APPLY:
|
||||||
|
r = req("POST", "/plugins/golden-config/compliance-rule/", nutzlast)
|
||||||
|
print(" angelegt (%s)" % r["id"])
|
||||||
|
else:
|
||||||
|
print(" Nutzlast: %s" % json.dumps(nutzlast))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -32,7 +32,7 @@ Jinja-Template denselben String emittieren kann (sonst Dauer-Diff in der Complia
|
|||||||
"""
|
"""
|
||||||
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
|
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ UMGEKEHRT GILT: die `vlanXX`-Descriptions auf dem Core NICHT anfassen, die lande
|
|||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
log = []
|
log = []
|
||||||
|
|||||||
105
nautobot/ingest-tp38-oob.py
Normal file
105
nautobot/ingest-tp38-oob.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP38: OOB-Port + Input-Firewall als Daten nach Nautobot schreiben.
|
||||||
|
|
||||||
|
Zwei getrennte Aenderungen, ein Skript:
|
||||||
|
|
||||||
|
1. BRANCH (crs326-branch): `firewall_filter` im local_config_context_data ANLEGEN
|
||||||
|
(8 Input-Regeln, deny-by-default). Der src-Rahmen 10.40.0.0/16 + 100.96.0.0/12
|
||||||
|
spiegelt bewusst das `available-from` der /ip service-Zeilen — kein neues
|
||||||
|
Fenster, nur stateful + deny-by-default + WOULD-DROP-Log.
|
||||||
|
2. CORE (crs326-core): EINE Regel "TP38: OOB->core mgmt" in die bestehende
|
||||||
|
firewall_filter-Liste EINFUEGEN (vor log/drop), damit der OOB-Port (untagged
|
||||||
|
ether23, VLAN99) auch den Core selbst verwalten kann.
|
||||||
|
|
||||||
|
Attributreihenfolge = alphabetisch, exakt wie RouterOS `/export` sie schreibt
|
||||||
|
(gleiche Konvention wie ingest-core-firewall.py).
|
||||||
|
|
||||||
|
python3 nautobot/ingest-tp38-oob.py # Dry-Run
|
||||||
|
python3 nautobot/ingest-tp38-oob.py --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
|
||||||
|
BRANCH_RULES = [
|
||||||
|
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||||
|
'add action=drop chain=input comment="TP38: invalid" connection-state=invalid',
|
||||||
|
'add action=accept chain=input comment="TP38: icmp" protocol=icmp',
|
||||||
|
'add action=accept chain=input comment="TP38: mndp" dst-port=5678 protocol=udp',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt LAN" dst-port=22,80,8291,8728 protocol=tcp src-address=10.40.0.0/16',
|
||||||
|
'add action=accept chain=input comment="TP38: mgmt mesh" dst-port=22,80,8291,8728 protocol=tcp src-address=100.96.0.0/12',
|
||||||
|
'add action=log chain=input comment="TP38: input log-only" log-prefix=TP38-BRANCH-WOULD-DROP',
|
||||||
|
'add action=drop chain=input comment="TP38: input deny-by-default"',
|
||||||
|
]
|
||||||
|
|
||||||
|
CORE_NEW_RULE = 'add action=accept chain=input comment="TP38: OOB->core mgmt" dst-port=22,80,8291,8728 in-interface=vlan99 protocol=tcp'
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
tok = os.environ.get("NAUTOBOT_TOKEN") or sys.exit("NAUTOBOT_TOKEN nicht gesetzt")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{NB}{path}", method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {tok}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(name):
|
||||||
|
return nb(f"/api/dcim/devices/?name={name}&depth=1")["results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_device(dev, ctx, label):
|
||||||
|
dev_id = dev["id"]
|
||||||
|
nb(f"/api/dcim/devices/{dev_id}/", "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print(f" ~ {label} geschrieben.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
apply_ = "--apply" in sys.argv
|
||||||
|
|
||||||
|
# --- BRANCH ---
|
||||||
|
branch = get_device("crs326-branch")
|
||||||
|
bctx = dict(branch.get("local_config_context_data") or {})
|
||||||
|
if bctx.get("firewall_filter") == BRANCH_RULES:
|
||||||
|
print("== branch: firewall_filter unveraendert ==")
|
||||||
|
else:
|
||||||
|
bctx["firewall_filter"] = BRANCH_RULES
|
||||||
|
print(f"== branch: {len(BRANCH_RULES)} Input-Regeln ==")
|
||||||
|
for ln in BRANCH_RULES:
|
||||||
|
print(" " + ln)
|
||||||
|
if apply_:
|
||||||
|
patch_device(branch, bctx, "branch firewall_filter")
|
||||||
|
|
||||||
|
# --- CORE ---
|
||||||
|
core = get_device("crs326-core")
|
||||||
|
cctx = dict(core.get("local_config_context_data") or {})
|
||||||
|
ff = list(cctx.get("firewall_filter") or [])
|
||||||
|
if CORE_NEW_RULE in ff:
|
||||||
|
print("\n== core: Regel schon vorhanden ==")
|
||||||
|
else:
|
||||||
|
# vor der Log-Regel einsortieren (die sitzt direkt vor dem finalen drop).
|
||||||
|
insert_at = len(ff)
|
||||||
|
for i, ln in enumerate(ff):
|
||||||
|
if "log-prefix=TP5-INPUT-WOULD-DROP" in ln:
|
||||||
|
insert_at = i
|
||||||
|
break
|
||||||
|
ff.insert(insert_at, CORE_NEW_RULE)
|
||||||
|
cctx["firewall_filter"] = ff
|
||||||
|
print(f"\n== core: fuege OOB-Regel an Position {insert_at} ein ==")
|
||||||
|
print(" " + CORE_NEW_RULE)
|
||||||
|
if apply_:
|
||||||
|
patch_device(core, cctx, "core firewall_filter")
|
||||||
|
|
||||||
|
if not apply_:
|
||||||
|
print("\nDRY-RUN — mit --apply schreiben. Danach: "
|
||||||
|
"push-gc-template.py + gc-configplan.py --refresh-compliance")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -68,7 +68,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
DRY = "--apply" not in sys.argv
|
DRY = "--apply" not in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
BASE = "http://10.40.20.30:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
APPLY = "--apply" in sys.argv
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
|
{#- VLAN-Tabelle: Site-Konstante. name = RouterOS-comment, lease/pool je VLAN. -#}
|
||||||
{%- set vlans = [
|
{%- set vlans = [
|
||||||
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
|
{"id": 10, "name": "MAIN", "pool": "10.40.10.100-10.40.10.200", "lease": "1d"},
|
||||||
{"id": 20, "name": "INFRA", "pool": "10.40.20.100-10.40.20.200", "lease": "1d"},
|
{"id": 20, "name": "INFRA", "pool": "10.40.20.200-10.40.20.249", "lease": "1d"},
|
||||||
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
|
{"id": 30, "name": "IOT", "pool": "10.40.30.100-10.40.30.250", "lease": "1d"},
|
||||||
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
|
{"id": 40, "name": "MEDIA", "pool": "10.40.40.100-10.40.40.200", "lease": "1d"},
|
||||||
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
|
{"id": 50, "name": "UNTRUSTED", "pool": "10.40.50.100-10.40.50.200", "lease": "1d"},
|
||||||
@@ -73,9 +73,11 @@
|
|||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
/interface bridge port add bridge=bridge comment=defconf interface=ether14 pvid=30
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
/interface bridge port add bridge=bridge comment=defconf interface=ether15
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
/interface bridge port add bridge=bridge comment=defconf interface=ether16 pvid=100
|
||||||
{% for n in range(17, 25) %}
|
{% for n in range(17, 23) %}
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
/interface bridge port add bridge=bridge comment=defconf interface=ether{{ n }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
/interface bridge port add bridge=bridge comment=TP38-OOB-MGMT frame-types=admit-only-untagged-and-priority-tagged interface=ether23 pvid=99
|
||||||
|
/interface bridge port add bridge=bridge comment=defconf interface=ether24
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus1
|
||||||
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
/interface bridge port add bridge=bridge comment=defconf interface=sfp-sfpplus2
|
||||||
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
# ether4 = Wohnzimmer-Sideboard: 2026-08-13 von Trunk auf untagged VLAN40 gezogen (TP21.1/4).
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether13,ether15,ether21 untagged=ether2,ether4,ether5 vlan-ids=40
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=50
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
/interface bridge vlan add bridge=bridge tagged=ether1,ether3,ether8,ether15,ether21 vlan-ids=90
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge vlan-ids=99
|
/interface bridge vlan add bridge=bridge tagged=ether3,ether8,ether15,ether21,bridge untagged=ether23 vlan-ids=99
|
||||||
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
/interface bridge vlan add bridge=bridge tagged=ether1 untagged=ether16 vlan-ids=100
|
||||||
|
|
||||||
# [1] INTERFACE-LISTS
|
# [1] INTERFACE-LISTS
|
||||||
@@ -132,10 +134,29 @@
|
|||||||
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for v in vlans %}
|
{% for v in vlans %}
|
||||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
||||||
|
|
||||||
|
# [5b] DHCP-RESERVIERUNGEN — kommen als DATEN aus local_config_context_data.dhcp_leases,
|
||||||
|
# wie firewall_filter. Warum das noetig war: bis 2026-09-21 rendert dieses Template
|
||||||
|
# KEIN einziges `/ip dhcp-server lease`. Die 54 Reservierungen stammen aus dem
|
||||||
|
# Einmal-Skript routeros/tp21-core-dhcp-from-ap1.rsc (Core-Cutover 2026-08-06) und
|
||||||
|
# standen damit in KEINEM Soll — ein GC-Recovery von null haette einen Core ohne
|
||||||
|
# Reservierungen erzeugt, in dem jedes IoT-Geraet in den Pool faellt. Und weil
|
||||||
|
# Compliance nur prueft, was gerendert wird, waere die Zahl dabei gruen geblieben.
|
||||||
|
# Dieselbe Klasse wie die handgelaufene `nautobot`-Adressliste (s. CLAUDE.md).
|
||||||
|
#
|
||||||
|
# 🔴 Zeilen sind RouterOS-/export-Form, alphabetisch, OHNE fuehrenden Pfad — genau
|
||||||
|
# wie firewall_filter. Gefuellt und verschoben von nautobot/tp45-leases.py.
|
||||||
|
# 🔴 Die REIHENFOLGE wird NICHT sortiert: das Backup traegt die Sektion in
|
||||||
|
# Einfuege-Reihenfolge (.30.150 frueh, .30.5 spaet), ein numerisch sortiertes
|
||||||
|
# Soll waere ein Dauer-Falsch-Drift. Die ComplianceRule steht deshalb auf
|
||||||
|
# config_ordered=no.
|
||||||
|
{% for line in obj.get_config_context().get("dhcp_leases", []) %}
|
||||||
|
/ip dhcp-server lease {{ line }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
|
# [6] DNS — DoH zum Cloudflare-Gateway, MIT Zertifikatspruefung (TP23 Stufe 1,
|
||||||
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
# scharf seit 2026-08-18; schliesst TP21.1 TODO 1).
|
||||||
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
# `servers=1.1.1.1` BLEIBT als Bootstrap stehen: RouterOS loest den
|
||||||
@@ -222,10 +243,12 @@
|
|||||||
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
/interface wifi datapath add bridge=bridge name=dp-media vlan-id=40
|
||||||
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
/interface wifi datapath add bridge=bridge name=dp-untrusted vlan-id=50
|
||||||
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
/interface wifi datapath add bridge=bridge name=dp-guest vlan-id=90
|
||||||
# sec-main: ft=no seit 2026-09-04 (User-Entscheid nach TP25-Messung). Fast-Transition
|
# sec-main: ft=yes/ft-over-ds=yes zurueckgesetzt am 2026-09-14 (Ruecknahme des 09-04-Entscheids
|
||||||
# komplett aus; ft-over-ds ist damit gegenstandslos (gilt nur bei ft=yes) und steht auf no.
|
# "ft=no als Dauerzustand"; Go nach Terrasse-In-situ-Session: mit ft=no war jeder Roam eine
|
||||||
# Fund E hatte ft-over-ds=no BEI ft=yes widerlegt -- das hier ist ein anderer Hebel (FT aus).
|
# Voll-Reassoziation -- darin starben Anrufe). Known-Good: genau dieser Stand lief in der
|
||||||
/interface wifi security add authentication-types=wpa3-psk ft=no ft-over-ds=no name=sec-main passphrase="<<REDACTED>>"
|
# Fund-E-Abschlussmessung 09-02 ruhig (4 reauth/8,6 h, 0 Ausfaelle) auf 7.24.1.
|
||||||
|
# 2026-09-04 stand hier: ft=no ft-over-ds=no (User-Entscheid nach TP25-Messung).
|
||||||
|
/interface wifi security add authentication-types=wpa3-psk ft=yes ft-over-ds=yes name=sec-main passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-infra passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk disabled=no ft=no name=sec-iot passphrase="<<REDACTED>>"
|
||||||
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
/interface wifi security add authentication-types=wpa2-psk,wpa3-psk ft=no name=sec-media passphrase="<<REDACTED>>"
|
||||||
@@ -239,11 +262,16 @@
|
|||||||
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
# BSSIDs einer SSID melden, auf die sie gar nicht assoziieren koennen (TP28).
|
||||||
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
/interface wifi steering add name=st-main neighbor-group=bf-main rrm=yes wnm=yes
|
||||||
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
/interface wifi steering add name=st-guest neighbor-group=bf-guest rrm=yes wnm=yes
|
||||||
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
# st-main-5g (TP25, 2026-09-15): transition-threshold=-72 statt Default -80 -- 5G bricht frueher
|
||||||
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
# zur 2.4G ab (Terrasse sass am Rundgang 09-15 auf der -80/5G-Kante). transition-threshold-time=5s
|
||||||
|
# statt 10s. NUR 5G: die drei 2G-Configs bleiben auf st-main (unset=-80), weil 2.4G draussen
|
||||||
|
# (Terrasse/ausserhalb der Wohnung) Reichweite behalten soll. 8 dB Abstand = kleine Hysterese.
|
||||||
|
/interface wifi steering add name=st-main-5g neighbor-group=bf-main rrm=yes transition-threshold=-72 transition-threshold-time=5s wnm=yes
|
||||||
|
/interface wifi configuration add channel=ch-5g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
|
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main-5g
|
||||||
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
|
||||||
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
|
||||||
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
|
||||||
@@ -273,17 +301,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 +443,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 +465,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 +478,7 @@
|
|||||||
/interface bridge vlan add bridge=bridge comment=MEDIA tagged=ether1,ether24,bridge untagged=ether5,ether6 vlan-ids=40
|
/interface bridge vlan add bridge=bridge comment=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.154:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
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()
|
||||||
256
nautobot/tp37-substrat-seed.py
Normal file
256
nautobot/tp37-substrat-seed.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
TP37 Punkt 1 (Substrat) — Hosts/Container als native Nautobot-Objekte.
|
||||||
|
|
||||||
|
Idempotenter Seeder (get-or-create) fuer die native virtualization-Modellwelt:
|
||||||
|
ClusterType (3x: Proxmox VE / Synology Docker / Docker Desktop)
|
||||||
|
-> Cluster (pve / nas-bf / macmini)
|
||||||
|
-> VirtualMachine je Workload (Status Active)
|
||||||
|
+ VMInterface "eth0" + ipam.IPAddress (Namespace Global) + primary_ip4
|
||||||
|
|
||||||
|
Datenmodell-Entscheid (User 2026-09-06): native Modelle, KEINE eigene App
|
||||||
|
(konsistent zu TP44). Muster: nautobot/tp44-cf-estate-seed.py.
|
||||||
|
|
||||||
|
Umfang Etappe 1 (User-Default 2026-09-07):
|
||||||
|
- komplettes Substrat: alle 15 pve-Gaeste (inkl. Smart-Home) + alle running
|
||||||
|
Container auf nas-bf (Portainer ep14) und macmini (ep20).
|
||||||
|
- macmini-Container teilen sich die Host-IP 10.40.20.167 (Bridge/Port-Mapping)
|
||||||
|
-> KEIN primary_ip4; Port kommt in TP37 P2 (ipam.Service).
|
||||||
|
- portainer_agent (Bridge 172.17.0.2, kein Workload) weggelassen.
|
||||||
|
- gestoppte (exited) NAS-Container weggelassen (nur running).
|
||||||
|
|
||||||
|
Datenstand: Live-Inventur 2026-09-07 (pve-mcp pct config, Portainer ep14,
|
||||||
|
ssh macmini docker ps, ARP fuer die 2 QEMU-IPs).
|
||||||
|
|
||||||
|
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||||
|
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 nautobot/tp37-substrat-seed.py # Dry-Run (default)
|
||||||
|
python3 nautobot/tp37-substrat-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.154:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
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 is_dry(v) -> bool:
|
||||||
|
return isinstance(v, str) and v.startswith("<dry:")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
CLUSTER_TYPES = ["Proxmox VE", "Synology Docker", "Docker Desktop"]
|
||||||
|
|
||||||
|
# cluster -> clustertype
|
||||||
|
CLUSTERS = [("pve", "Proxmox VE"),
|
||||||
|
("nas-bf", "Synology Docker"),
|
||||||
|
("macmini", "Docker Desktop")]
|
||||||
|
|
||||||
|
# cluster -> [ (vm_name, primary_ip_with_mask_or_None, comment) ]
|
||||||
|
VMS = {
|
||||||
|
"pve": [
|
||||||
|
("haos", "10.40.20.105/24", "Home Assistant OS (QEMU 100)"),
|
||||||
|
("iob-master", "10.40.20.107/24", "ioBroker Master (LXC 101)"),
|
||||||
|
("mqtt5", "10.40.20.108/24", "MQTT-Broker Mosquitto (LXC 102)"),
|
||||||
|
("platform-pg", "10.40.20.20/24", "PostgreSQL 16 - Nautobot-DB (LXC 105, TP20)"),
|
||||||
|
("influxdb2", "10.40.20.8/24", "InfluxDB 2 (LXC 106)"),
|
||||||
|
("redis", "10.40.20.111/24", "Redis (LXC 107)"),
|
||||||
|
("mcast-relay", "10.40.10.21/24", "SSDP-Relay VLAN10<->40 (LXC 108); 2. Bein 10.40.40.21"),
|
||||||
|
("agh-bf1", "10.40.20.36/24", "AdGuard Home Resolver (LXC 110, TP23)"),
|
||||||
|
("agh-bf2", "10.40.20.37/24", "AdGuard Home Resolver (LXC 111, TP23)"),
|
||||||
|
("z2m25", "10.40.20.106/24", "Zigbee2MQTT (LXC 112)"),
|
||||||
|
("caddy-bf1", "10.40.20.40/24", "Caddy Reverse-Proxy (LXC 130, TP31)"),
|
||||||
|
("iob-exposed", "10.40.20.19/24", "ioBroker exposed (LXC 150)"),
|
||||||
|
("mariadb", "10.40.20.23/24", "MariaDB - vw/hass/gitea (LXC 302)"),
|
||||||
|
("RaspberryMatic","10.40.20.232/24", "Homematic CCU (QEMU 330)"),
|
||||||
|
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
|
||||||
|
],
|
||||||
|
"nas-bf": [
|
||||||
|
("nautobot", "10.40.20.154/24", "Nautobot Web (DS923 ep14)"),
|
||||||
|
("nautobot-worker", "10.40.20.155/24", "Nautobot Golden-Config Worker"),
|
||||||
|
("nautobot-scheduler", "10.40.20.156/24", "Nautobot Scheduler"),
|
||||||
|
("nautobot-redis", "10.40.20.158/24", "Nautobot Redis"),
|
||||||
|
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
|
||||||
|
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
|
||||||
|
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),
|
||||||
|
("pve-mcp", "10.40.20.173/24", "Proxmox-MCP (mcp-pve)"),
|
||||||
|
("iob_slave", "10.40.20.33/24", "ioBroker Slave"),
|
||||||
|
],
|
||||||
|
"macmini": [
|
||||||
|
# Bridge/Port-Mapping auf Host 10.40.20.167 -> kein eigenes primary_ip4
|
||||||
|
("n8n", None, "n8n (Docker Desktop, Host 10.40.20.167:5678)"),
|
||||||
|
("n8n-redis", None, "n8n Redis (intern, kein Host-Port)"),
|
||||||
|
("n8n-mcp", None, "n8n-MCP (Host 10.40.20.167:3001)"),
|
||||||
|
("gitea-mcp", None, "Gitea-MCP (Host 10.40.20.167:3000)"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP37 Substrat-Seed ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||||
|
|
||||||
|
st_active = status_id("Active")
|
||||||
|
ns_global = namespace_id("Global")
|
||||||
|
print(f" (Status Active={st_active} Namespace Global={ns_global})")
|
||||||
|
|
||||||
|
print("\n[1] ClusterTypes")
|
||||||
|
for ct in CLUSTER_TYPES:
|
||||||
|
get_or_create("virtualization/cluster-types", {"name": ct}, "name", ct,
|
||||||
|
{"name": ct}, "ClusterType")
|
||||||
|
|
||||||
|
print("\n[2] Clusters")
|
||||||
|
ct_lookup = {ct: find("virtualization/cluster-types", {"name": ct}, "name", ct)
|
||||||
|
for ct in CLUSTER_TYPES}
|
||||||
|
cluster_ids = {}
|
||||||
|
for cname, ctname in CLUSTERS:
|
||||||
|
ctrow = ct_lookup.get(ctname)
|
||||||
|
ct_id = ctrow["id"] if ctrow else None
|
||||||
|
cl = get_or_create("virtualization/clusters", {"name": cname}, "name", cname,
|
||||||
|
{"name": cname, "cluster_type": ct_id}, "Cluster")
|
||||||
|
cluster_ids[cname] = cl["id"] if cl else None
|
||||||
|
|
||||||
|
print("\n[3] VirtualMachines (+ eth0 + IPAddress + primary_ip4)")
|
||||||
|
n_vm = n_ip = 0
|
||||||
|
for cname, _ in CLUSTERS:
|
||||||
|
cl_id = cluster_ids.get(cname)
|
||||||
|
print(f" -- Cluster {cname} --")
|
||||||
|
for name, ip, comment in VMS[cname]:
|
||||||
|
vm = get_or_create(
|
||||||
|
"virtualization/virtual-machines", {"name": name}, "name", name,
|
||||||
|
{"name": name, "cluster": cl_id, "status": st_active,
|
||||||
|
"comments": comment}, "VM")
|
||||||
|
if not vm:
|
||||||
|
continue
|
||||||
|
n_vm += 1
|
||||||
|
if not ip:
|
||||||
|
print(f" (ohne primary_ip4 - Host-geteilt)")
|
||||||
|
continue
|
||||||
|
# VMInterface eth0
|
||||||
|
vmif = None
|
||||||
|
if is_dry(vm["id"]):
|
||||||
|
print(f" + eth0 @ {name} [DRY-RUN]")
|
||||||
|
vmif = {"id": f"<dry:{name}/eth0>"}
|
||||||
|
else:
|
||||||
|
vmif = find("virtualization/interfaces",
|
||||||
|
{"virtual_machine": name, "name": "eth0"}, "name", "eth0")
|
||||||
|
if vmif:
|
||||||
|
print(f" = eth0 @ {name} ({vmif['id']})")
|
||||||
|
else:
|
||||||
|
s, d = req("POST", "/api/virtualization/interfaces/",
|
||||||
|
{"virtual_machine": vm["id"], "name": "eth0",
|
||||||
|
"status": st_active})
|
||||||
|
if s in (200, 201):
|
||||||
|
vmif = d
|
||||||
|
print(f" + eth0 @ {name} ({d.get('id')})")
|
||||||
|
else:
|
||||||
|
print(f" ! eth0 @ {name} FEHLER {s}: {json.dumps(d)[:200]}")
|
||||||
|
continue
|
||||||
|
# IPAddress
|
||||||
|
ipobj = get_or_create("ipam/ip-addresses", {"address": ip}, "address", ip,
|
||||||
|
{"address": ip, "status": st_active,
|
||||||
|
"namespace": ns_global, "dns_name": ""},
|
||||||
|
"IP")
|
||||||
|
if not ipobj:
|
||||||
|
continue
|
||||||
|
n_ip += 1
|
||||||
|
if is_dry(vm["id"]) or is_dry(ipobj["id"]) or is_dry(vmif["id"]):
|
||||||
|
print(f" + assign {ip} -> {name}/eth0 + primary_ip4 [DRY-RUN]")
|
||||||
|
continue
|
||||||
|
# assign IP <-> interface (idempotent)
|
||||||
|
q = urllib.parse.urlencode({"vm_interface": vmif["id"], "depth": 0})
|
||||||
|
s, d = req("GET", f"/api/ipam/ip-address-to-interface/?{q}")
|
||||||
|
have = any((r.get("ip_address") or {}).get("id") == ipobj["id"]
|
||||||
|
for r in d.get("results", []))
|
||||||
|
if have:
|
||||||
|
print(f" = assign {ip} -> {name}/eth0")
|
||||||
|
else:
|
||||||
|
s, d = req("POST", "/api/ipam/ip-address-to-interface/",
|
||||||
|
{"ip_address": ipobj["id"], "vm_interface": vmif["id"]})
|
||||||
|
print(f" {'+' if s in (200,201) else '!'} assign {ip} -> {name}/eth0"
|
||||||
|
+ ("" if s in (200, 201) else f" FEHLER {s}: {json.dumps(d)[:150]}"))
|
||||||
|
# primary_ip4 (idempotent)
|
||||||
|
s, vmnow = req("GET", f"/api/virtualization/virtual-machines/{vm['id']}/?depth=0")
|
||||||
|
cur = vmnow.get("primary_ip4")
|
||||||
|
cur = cur.get("id") if isinstance(cur, dict) else cur
|
||||||
|
if cur == ipobj["id"]:
|
||||||
|
print(f" = primary_ip4 {ip} @ {name}")
|
||||||
|
else:
|
||||||
|
s, d = req("PATCH", f"/api/virtualization/virtual-machines/{vm['id']}/",
|
||||||
|
{"primary_ip4": ipobj["id"]})
|
||||||
|
print(f" {'+' if s == 200 else '!'} primary_ip4 {ip} @ {name}"
|
||||||
|
+ ("" if s == 200 else f" FEHLER {s}: {json.dumps(d)[:150]}"))
|
||||||
|
|
||||||
|
print(f"\n== fertig ({'COMMIT' if COMMIT else 'DRY-RUN - nichts geschrieben'}) "
|
||||||
|
f"| VMs={n_vm} IPs={n_ip} ==")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
TP36 — Cloudflare-Estate als SSoT in Nautobot (Etappe 1: manuelle Erstbefuellung).
|
TP44 — Cloudflare-Estate als SSoT in Nautobot (Etappe 1: manuelle Erstbefuellung).
|
||||||
|
|
||||||
Idempotenter Seeder (get-or-create) fuer die native nautobot.cloud-Modellwelt:
|
Idempotenter Seeder (get-or-create) fuer die native nautobot.cloud-Modellwelt:
|
||||||
Manufacturer "Cloudflare"
|
Manufacturer "Cloudflare"
|
||||||
@@ -18,8 +18,8 @@ Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
|||||||
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
|
Schluessel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
|
||||||
Aufruf:
|
Aufruf:
|
||||||
python3 nautobot/tp36-cf-estate-seed.py # Dry-Run (default)
|
python3 nautobot/tp44-cf-estate-seed.py # Dry-Run (default)
|
||||||
python3 nautobot/tp36-cf-estate-seed.py --commit # legt an
|
python3 nautobot/tp44-cf-estate-seed.py --commit # legt an
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -29,7 +29,7 @@ import urllib.parse
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
||||||
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.154: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.154:8080")
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
COMMIT = "--commit" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN = load_token()
|
||||||
|
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def req(method: str, path: str, body=None):
|
||||||
|
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()
|
||||||
135
nautobot/tp45-agh-firewall.py
Normal file
135
nautobot/tp45-agh-firewall.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP45 Schicht A — die AGH-DNS-Freigaben im Core-`firewall_filter`-Soll pflegen.
|
||||||
|
|
||||||
|
WAS HIER PASSIERT
|
||||||
|
-----------------
|
||||||
|
Die vier `TP5-E2: LAN->AGH dns*`-Regeln des Cores stehen als LITERALE im
|
||||||
|
`local_config_context_data.firewall_filter` (Nautobot) und werden vom GC-Template
|
||||||
|
nur gerendert. Zieht der Resolver um, muss das Soll mit — sonst meldet die
|
||||||
|
Compliance Drift, sobald das Geraet die neue Adresse traegt.
|
||||||
|
|
||||||
|
add <ip> [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den
|
||||||
|
bestehenden AGH-Block einsortiert
|
||||||
|
remove <ip> [...] genau diese zwei Regeln wieder entfernen (= Rueckbau)
|
||||||
|
|
||||||
|
WARUM KEIN `ingest-core-firewall.py`
|
||||||
|
------------------------------------
|
||||||
|
Das ist ein RE-BASELINE-Werkzeug: es liest den Ist-Stand vom Geraet und erklaert
|
||||||
|
ihn zum Soll. Fuer eine *geplante* Aenderung ist es das falsche Werkzeug — es
|
||||||
|
wuerde die Aenderung mit sich selbst absegnen. Hier wird das Soll explizit
|
||||||
|
gesetzt, das Geraet zieht per `gc-configplan.py --job tp45-agh-fw` nach.
|
||||||
|
|
||||||
|
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 # Dry-Run
|
||||||
|
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 --apply
|
||||||
|
|
||||||
|
REIHENFOLGE IST SEMANTISCH — die ComplianceRule steht auf `config_ordered`. Neue
|
||||||
|
Regeln landen hinter der bestehenden `.37`-tcp-Zeile, also weiterhin VOR der
|
||||||
|
`TP5: log-only`-Zeile und damit vor dem finalen `drop`.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||||
|
DEVICE = "crs326-core"
|
||||||
|
COMMENT_UDP = "TP5-E2: LAN->AGH dns"
|
||||||
|
COMMENT_TCP = "TP5-E2: LAN->AGH dns-tcp"
|
||||||
|
ANCHOR = 'comment="TP5-E2: LAN->AGH dns-tcp" dst-address='
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
tok = os.environ.get("NAUTOBOT_TOKEN")
|
||||||
|
if tok:
|
||||||
|
return tok
|
||||||
|
# Fallback: aus dem (gitignored) Stack-File, Wert wird nie ausgegeben.
|
||||||
|
yml = (pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
/ "docs/nas-vlan20/stacks/210-nautobot.yml")
|
||||||
|
for line in yml.read_text(errors="replace").splitlines():
|
||||||
|
key, sep, val = line.strip().lstrip("- ").partition("=")
|
||||||
|
if sep and key.strip() == "NAUTOBOT_SUPERUSER_API_TOKEN":
|
||||||
|
return val.strip().strip("'\"")
|
||||||
|
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{NB}{path}", method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {token()}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def rules_for(ip):
|
||||||
|
# Attributreihenfolge alphabetisch, exakt wie RouterOS /export sie schreibt —
|
||||||
|
# dieselbe Konvention wie ingest-core-firewall.py und das Template.
|
||||||
|
return [
|
||||||
|
f'add action=accept chain=forward comment="{COMMENT_UDP}" '
|
||||||
|
f'dst-address={ip} dst-port=53 protocol=udp',
|
||||||
|
f'add action=accept chain=forward comment="{COMMENT_TCP}" '
|
||||||
|
f'dst-address={ip} dst-port=53 protocol=tcp',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_agh(rule, ip):
|
||||||
|
return f"dst-address={ip}" in rule and "TP5-E2: LAN->AGH dns" in rule
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if len(argv) < 3 or argv[0] not in ("add", "remove"):
|
||||||
|
raise SystemExit(__doc__.strip().splitlines()[-1])
|
||||||
|
mode = argv[0]
|
||||||
|
ips = [a for a in argv[1:] if not a.startswith("--")]
|
||||||
|
apply_ = "--apply" in argv
|
||||||
|
|
||||||
|
dev = nb(f"/api/dcim/devices/?name={DEVICE}")["results"][0]
|
||||||
|
ctx = dict(dev.get("local_config_context_data") or {})
|
||||||
|
lines = list(ctx.get("firewall_filter") or [])
|
||||||
|
if not lines:
|
||||||
|
raise SystemExit("firewall_filter ist leer — falsches Geraet?")
|
||||||
|
|
||||||
|
new = list(lines)
|
||||||
|
for ip in ips:
|
||||||
|
present = [l for l in new if is_agh(l, ip)]
|
||||||
|
if mode == "add":
|
||||||
|
if present:
|
||||||
|
print(f" {ip}: schon vorhanden ({len(present)} Regeln) — uebersprungen")
|
||||||
|
continue
|
||||||
|
# hinter die letzte bestehende AGH-Zeile einsortieren
|
||||||
|
idx = max((i for i, l in enumerate(new) if ANCHOR in l), default=None)
|
||||||
|
if idx is None:
|
||||||
|
raise SystemExit(f" kein AGH-Anker ({ANCHOR}...) im Soll gefunden")
|
||||||
|
new[idx + 1:idx + 1] = rules_for(ip)
|
||||||
|
print(f" {ip}: +2 Regeln nach Index {idx}")
|
||||||
|
else:
|
||||||
|
if len(present) != 2:
|
||||||
|
raise SystemExit(f" {ip}: {len(present)} statt 2 Regeln gefunden — Abbruch")
|
||||||
|
new = [l for l in new if not is_agh(l, ip)]
|
||||||
|
print(f" {ip}: -2 Regeln entfernt")
|
||||||
|
|
||||||
|
if new == lines:
|
||||||
|
print("\n = unveraendert, nichts zu tun.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n== Diff ({len(lines)} -> {len(new)} Zeilen) ==")
|
||||||
|
for d in difflib.unified_diff(lines, new, "IST", "SOLL", lineterm="", n=1):
|
||||||
|
print(" " + d[:170])
|
||||||
|
|
||||||
|
if not apply_:
|
||||||
|
print(f"\nDRY-RUN — mit --apply nach "
|
||||||
|
f"local_config_context_data.firewall_filter schreiben.")
|
||||||
|
return
|
||||||
|
ctx["firewall_filter"] = new
|
||||||
|
nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print("\n ~ geschrieben. Geraet zieht nach per: "
|
||||||
|
"python3 nautobot/gc-configplan.py --job tp45-agh-fw --apply")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
131
nautobot/tp45-ip-move.py
Normal file
131
nautobot/tp45-ip-move.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP45 Schicht A — primaere IP eines Nautobot-Gastes umziehen.
|
||||||
|
|
||||||
|
WARUM ES DIESES SKRIPT GIBT
|
||||||
|
---------------------------
|
||||||
|
Nautobot verbietet das Aendern einer IP-Adresse nach dem Anlegen
|
||||||
|
(`{"__all__": ["Host address cannot be changed once created"]}`). Ein Umzug
|
||||||
|
ist deshalb kein PATCH, sondern ein Dreischritt je Gast:
|
||||||
|
|
||||||
|
1. neues ipam.IPAddress am Ziel anlegen (Status/Namespace vom Altobjekt)
|
||||||
|
2. altes Objekt an die VM-Schnittstelle haengen und primary_ip4 umhaengen
|
||||||
|
3. altes Objekt loeschen
|
||||||
|
|
||||||
|
Von Hand ist das pro Gast drei API-Aufrufe in genau dieser Reihenfolge — bei
|
||||||
|
~15 Gaesten in Schicht A eine sichere Fehlerquelle. Reihenfolge ist zwingend:
|
||||||
|
wer das Altobjekt vor dem Umhaengen loescht, reisst die primary_ip4-Referenz.
|
||||||
|
|
||||||
|
⚠️ Die IP allein ist NICHT der ganze Umzug. Ein Gast, den der Core ueber eine
|
||||||
|
Firewall-Adressliste erreicht (z.B. `nautobot`), braucht die neue IP ZUERST in
|
||||||
|
dieser Liste — sonst steht der Zugriff weg, bevor die Migration durch ist.
|
||||||
|
|
||||||
|
python3 nautobot/tp45-ip-move.py # Dry-Run (Default)
|
||||||
|
python3 nautobot/tp45-ip-move.py --apply
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
|
APPLY = "--apply" in sys.argv
|
||||||
|
|
||||||
|
# (VM-Name, alte IP, neue IP) — Oktett-Klassen nach TP32, s. docs/teilprojekte.md ### TP45
|
||||||
|
# Charge 1 (2026-09-21, angewendet). Die Altobjekte sind weg — ein erneuter Lauf
|
||||||
|
# bricht darum sauber ab ("0 Treffer"), er zieht nichts doppelt um.
|
||||||
|
MOVES = [
|
||||||
|
("nautobot", "10.40.20.30/24", "10.40.20.154/24"),
|
||||||
|
("nautobot-worker", "10.40.20.31/24", "10.40.20.155/24"),
|
||||||
|
("nautobot-scheduler", "10.40.20.32/24", "10.40.20.156/24"),
|
||||||
|
("nautobot-redis", "10.40.20.34/24", "10.40.20.158/24"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_token() -> str:
|
||||||
|
for line in YML.read_text().splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
raise SystemExit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML}")
|
||||||
|
|
||||||
|
|
||||||
|
TOK = load_token()
|
||||||
|
|
||||||
|
|
||||||
|
def api(path, method="GET", payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path, method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": f"Token {TOK}", "Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.load(r) if r.status != 204 else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise SystemExit(f"{method} {path} -> {e.code}: {e.read().decode()[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def only(path, expect=1):
|
||||||
|
res = api(path)["results"]
|
||||||
|
if len(res) != expect:
|
||||||
|
raise SystemExit(f"{path}: {len(res)} Treffer, erwartet {expect}")
|
||||||
|
return res[0] if expect == 1 else res
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"== TP45 IP-Move ({'APPLY' if APPLY else 'DRY-RUN'}) ==")
|
||||||
|
for vm_name, old_ip, new_ip in MOVES:
|
||||||
|
print(f"\n-- {vm_name}: {old_ip} -> {new_ip} --")
|
||||||
|
old = only(f"/ipam/ip-addresses/?address={old_ip}")
|
||||||
|
vm = only(f"/virtualization/virtual-machines/?name={vm_name}")
|
||||||
|
|
||||||
|
ref = vm.get("primary_ip4")
|
||||||
|
ref = ref.get("id") if isinstance(ref, dict) else ref
|
||||||
|
if ref != old["id"]:
|
||||||
|
raise SystemExit(f" primary_ip4 von {vm_name} zeigt nicht auf {old_ip} "
|
||||||
|
f"(ist {ref}) — Abbruch, nichts geschrieben")
|
||||||
|
|
||||||
|
if api(f"/ipam/ip-addresses/?address={new_ip}")["count"]:
|
||||||
|
raise SystemExit(f" {new_ip} ist in Nautobot schon belegt — Abbruch")
|
||||||
|
print(f" Altobjekt={old['id'][:8]} primary_ip4 bestaetigt Ziel frei")
|
||||||
|
|
||||||
|
if not APPLY:
|
||||||
|
print(" [DRY-RUN] wuerde: anlegen -> eth0 verknuepfen -> primary_ip4 "
|
||||||
|
"umhaengen -> Altobjekt loeschen")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# namespace ist am Altobjekt nicht serialisiert und beim Anlegen Pflicht
|
||||||
|
ns = old.get("namespace")
|
||||||
|
ns_id = (ns or {}).get("id") if isinstance(ns, dict) else ns
|
||||||
|
if not ns_id:
|
||||||
|
ns_id = only("/ipam/namespaces/?name=Global")["id"]
|
||||||
|
st = old.get("status")
|
||||||
|
new = api("/ipam/ip-addresses/", "POST",
|
||||||
|
{"address": new_ip,
|
||||||
|
"status": st["id"] if isinstance(st, dict) else st,
|
||||||
|
"namespace": ns_id, "dns_name": ""})
|
||||||
|
print(f" + angelegt {new['id'][:8]} @ {new['address']}")
|
||||||
|
|
||||||
|
vmif = api(f"/virtualization/interfaces/?virtual_machine={vm['id']}&name=eth0")["results"]
|
||||||
|
if vmif:
|
||||||
|
api("/ipam/ip-address-to-interface/", "POST",
|
||||||
|
{"ip_address": new["id"], "vm_interface": vmif[0]["id"]})
|
||||||
|
print(f" + verknuepft mit {vm_name}/eth0 ({vmif[0]['id'][:8]})")
|
||||||
|
else:
|
||||||
|
print(" ~ keine eth0-Schnittstelle — Verknuepfung uebersprungen")
|
||||||
|
|
||||||
|
api(f"/virtualization/virtual-machines/{vm['id']}/", "PATCH", {"primary_ip4": new["id"]})
|
||||||
|
print(f" + primary_ip4 umgehaengt")
|
||||||
|
|
||||||
|
api(f"/ipam/ip-addresses/{old['id']}/", "DELETE")
|
||||||
|
print(f" - Altobjekt {old_ip} geloescht")
|
||||||
|
|
||||||
|
print("\nFertig." if APPLY else "\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
276
nautobot/tp45-leases.py
Normal file
276
nautobot/tp45-leases.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""TP45 Schicht B — die DHCP-Reservierungen des Cores ins Soll heben.
|
||||||
|
|
||||||
|
DIE LUECKE, DIE DAS SCHLIESST
|
||||||
|
-----------------------------
|
||||||
|
`nautobot/mikrotik_routeros.j2` rendert bis 2026-09-21 **kein einziges**
|
||||||
|
`/ip dhcp-server lease`. Die 54 statischen Reservierungen des Cores stammen aus dem
|
||||||
|
Einmal-Skript `routeros/tp21-core-dhcp-from-ap1.rsc` (Core-Cutover 2026-08-06) und
|
||||||
|
stehen in **keinem** Soll.
|
||||||
|
|
||||||
|
Folge: ein GC-Recovery von null erzeugt einen Core ohne Reservierungen — jedes
|
||||||
|
IoT-Geraet faellt in den Pool, und weil Compliance nur prueft, was gerendert wird,
|
||||||
|
bliebe die Zahl dabei gruen. Dieselbe Klasse wie die `nautobot`-Adressliste
|
||||||
|
(s. CLAUDE.md): "was auf dem Geraet existiert, aber nicht im Template, ist eine
|
||||||
|
Recovery-Luecke". Zusaetzlich war keine einzige Reservierung drift-ueberwacht.
|
||||||
|
|
||||||
|
DAS SOLL
|
||||||
|
--------
|
||||||
|
Die Zeilen liegen wie `firewall_filter` als LISTE von Strings in
|
||||||
|
`local_config_context_data.dhcp_leases` auf `crs326-core` und werden vom Template
|
||||||
|
mit `/ip dhcp-server lease {{ line }}` gerendert.
|
||||||
|
|
||||||
|
🔴 **Zeilenform ist die RouterOS-`/export`-Form, alphabetisch, ohne fuehrenden Pfad**
|
||||||
|
(genau wie `firewall_filter`):
|
||||||
|
add address=10.40.30.244 comment=NukiBridge mac-address=24:62:AB:B1:63:F4 server=dhcp-v30
|
||||||
|
|
||||||
|
🔴 **Die REIHENFOLGE wird erhalten, nicht sortiert.** Am Geraet steht die Sektion in
|
||||||
|
Einfuege-Reihenfolge (`.30.150` frueh, `.30.5` spaet) — `:export` gibt sie so aus, und
|
||||||
|
das Backup wird textuell verglichen. Ein numerisch sortiertes Soll waere ein
|
||||||
|
Dauer-Falsch-Drift.
|
||||||
|
|
||||||
|
🔴 **Zeilen werden NICHT neu gebaut, sondern uebernommen und nur im `address=`-Token
|
||||||
|
geaendert.** Damit bleiben Quoting (`comment="P750W / P750W"` vs `comment=NukiBridge`)
|
||||||
|
und die 14 `client-id`-Felder automatisch korrekt — beides nachzubauen waere eine
|
||||||
|
Fehlerquelle ohne Nutzen.
|
||||||
|
|
||||||
|
WARUM DER LESEWEG UEBER NAUTOBOT GEHT
|
||||||
|
-------------------------------------
|
||||||
|
Quelle fuer `seed` ist das GC-`backup_config` des Cores (taeglich 03:30 UTC bzw.
|
||||||
|
per `gc-configplan.py --refresh-compliance`). Kein Geraete-Credential noetig.
|
||||||
|
⚠️ `seed` ist damit ein RE-BASELINE und darf **nur** laufen, solange der Ist-Stand
|
||||||
|
der gewollte ist (Schicht B1). Fuer die geplante Aenderung gibt es `move`, das auf
|
||||||
|
dem **bestehenden Soll** rechnet — ein Soll, das dem Ist nachlaeuft, erkennt keine
|
||||||
|
Drift mehr (genau der Grund, warum `ingest-core-firewall.py` manuell bleibt).
|
||||||
|
|
||||||
|
python3 nautobot/tp45-leases.py check # Soll vs. Backup-Diff
|
||||||
|
python3 nautobot/tp45-leases.py seed # Dry-Run
|
||||||
|
python3 nautobot/tp45-leases.py seed --apply # B1
|
||||||
|
python3 nautobot/tp45-leases.py seed --force --apply # Neuschnitt, ueberschreibt
|
||||||
|
python3 nautobot/tp45-leases.py move # Dry-Run, s. MOVES
|
||||||
|
python3 nautobot/tp45-leases.py move --apply # B2
|
||||||
|
|
||||||
|
Token: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored), NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||||
|
Der Wert wird nie ausgegeben.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
NB = "http://10.40.20.154:8080"
|
||||||
|
DEVICE = "crs326-core"
|
||||||
|
KEY = "dhcp_leases"
|
||||||
|
PREFIX = "/ip dhcp-server lease "
|
||||||
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
YML = REPO / "docs/nas-vlan20/stacks/210-nautobot.yml"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Der Adressumzug (Schicht B2). Schluessel = MAC, Wert = (ist, soll).
|
||||||
|
# Quelle der Soll-Spalte: docs/teilprojekte.md ### TP45 / der Plan.
|
||||||
|
# Steht hier als Tabelle und nicht als Ableitung — der Umzug ist eine Absicht,
|
||||||
|
# kein Rechenergebnis.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MOVES = {
|
||||||
|
# fest verbaute Infrastruktur -> .10-.19
|
||||||
|
"24:62:AB:B1:63:F4": ("10.40.30.244", "10.40.30.11"), # NukiBridge
|
||||||
|
"4C:EB:BD:E3:25:F2": ("10.40.30.5", "10.40.30.13"), # P750W (Etikettendrucker)
|
||||||
|
"54:F2:9F:12:51:F0": ("10.40.30.8", "10.40.30.14"), # DTUBI-414392337823
|
||||||
|
# Shelly-Block .151-.176 (s1 -> .151 ... s26 -> .176)
|
||||||
|
"E4:B0:63:D7:C7:A4": ("10.40.30.208", "10.40.30.151"), # shelly-s1
|
||||||
|
"28:37:2F:36:F4:7C": ("10.40.30.209", "10.40.30.152"), # shelly-s2
|
||||||
|
"E4:B0:63:E4:EF:7C": ("10.40.30.210", "10.40.30.153"), # shelly-s3
|
||||||
|
"E4:B0:63:E4:0F:30": ("10.40.30.211", "10.40.30.154"), # shelly-s4
|
||||||
|
"D8:85:AC:0C:5C:C8": ("10.40.30.212", "10.40.30.155"), # shelly-s5
|
||||||
|
"D0:CF:13:D8:18:FC": ("10.40.30.213", "10.40.30.156"), # shelly-s6
|
||||||
|
"D0:CF:13:D9:94:E8": ("10.40.30.214", "10.40.30.157"), # shelly-s7
|
||||||
|
"E4:B0:63:FA:FD:E0": ("10.40.30.215", "10.40.30.158"), # shelly-s8
|
||||||
|
"E8:F6:0A:61:88:08": ("10.40.30.216", "10.40.30.159"), # shelly-s9
|
||||||
|
"E4:B0:63:FC:57:E4": ("10.40.30.217", "10.40.30.160"), # shelly-s10
|
||||||
|
"E4:B0:63:FA:6C:00": ("10.40.30.218", "10.40.30.161"), # shelly-s11
|
||||||
|
"48:F6:EE:C8:E6:AC": ("10.40.30.219", "10.40.30.162"), # shelly-s12
|
||||||
|
"48:F6:EE:DD:6A:F4": ("10.40.30.220", "10.40.30.163"), # shelly-s13
|
||||||
|
"E4:B0:63:FC:59:48": ("10.40.30.221", "10.40.30.164"), # shelly-s14
|
||||||
|
"E4:B0:63:FA:D5:34": ("10.40.30.222", "10.40.30.165"), # shelly-s15
|
||||||
|
"10:20:BA:FA:F5:40": ("10.40.30.223", "10.40.30.166"), # shelly-s16
|
||||||
|
"10:20:BA:FB:6D:64": ("10.40.30.224", "10.40.30.167"), # shelly-s17
|
||||||
|
"48:F6:EE:CC:16:E4": ("10.40.30.225", "10.40.30.168"), # shelly-s18
|
||||||
|
"10:20:BA:FB:70:FC": ("10.40.30.226", "10.40.30.169"), # shelly-s19
|
||||||
|
"98:A3:16:7C:5F:EC": ("10.40.30.206", "10.40.30.170"), # shelly-s20
|
||||||
|
"20:6E:F1:01:F9:F8": ("10.40.30.207", "10.40.30.171"), # shelly-s21
|
||||||
|
"A0:85:E3:C8:65:34": ("10.40.30.202", "10.40.30.172"), # shelly-s22
|
||||||
|
"AC:15:18:7B:62:20": ("10.40.30.203", "10.40.30.173"), # shelly-s23
|
||||||
|
"14:33:5C:D8:2D:7C": ("10.40.30.204", "10.40.30.174"), # shelly-s24
|
||||||
|
"98:A3:16:79:38:50": ("10.40.30.205", "10.40.30.175"), # shelly-s25
|
||||||
|
"98:A3:16:7A:A3:54": ("10.40.30.233", "10.40.30.176"), # shelly-s26 (bad luefter)
|
||||||
|
# App-Block .177-.199
|
||||||
|
"28:DB:A7:09:7B:54": ("10.40.30.241", "10.40.30.177"), # NukiSmartlock
|
||||||
|
"B8:F8:62:09:F2:04": ("10.40.30.227", "10.40.30.178"), # SwitchBot-floor-lamp
|
||||||
|
"98:F4:AB:2C:A7:88": ("10.40.30.228", "10.40.30.179"), # Twinkly-2CA789
|
||||||
|
"A4:F0:0F:5F:D0:EC": ("10.40.30.236", "10.40.30.190"), # lichterkette
|
||||||
|
"24:DC:C3:9B:89:64": ("10.40.30.237", "10.40.30.191"), # lichterkette-mini
|
||||||
|
"00:24:E4:40:2A:72": ("10.40.30.230", "10.40.30.195"), # Withings_waage
|
||||||
|
"A4:7E:FA:18:2C:64": ("10.40.30.231", "10.40.30.196"), # Withings_blutdruck
|
||||||
|
# .150 (Ender3V3KE) und .180 (espressif/Airfryer) bleiben, wo sie sind.
|
||||||
|
}
|
||||||
|
|
||||||
|
# CCU-Leiche: VLAN30-Reservierung `pve-homematic-raspi-ccu`, status=waiting,
|
||||||
|
# last-seen=never. Die CCU ist live in VLAN20 (10.40.20.232, ARP+HTTP belegt,
|
||||||
|
# Caddys ccu.bf proxyt dorthin) — dieser Eintrag ist ein Zombie aus dem
|
||||||
|
# Core-Cutover. Er wird ENTFERNT, nicht umgezogen.
|
||||||
|
REMOVE = {"BC:24:11:8E:0A:57"}
|
||||||
|
|
||||||
|
# SONOFF-NSPanelPro hat KEINE Reservierung, nur eine dynamische Lease auf .249.
|
||||||
|
# Wird in B2 per make-static + set address gepinnt. Zeile hier nur als Soll.
|
||||||
|
ADDS = [
|
||||||
|
"add address=10.40.30.12 client-id=1:7c:88:99:6:7:ec comment=SONOFF-NSPanelPro "
|
||||||
|
"mac-address=7C:88:99:06:07:EC server=dhcp-v30",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
for line in YML.read_text(errors="replace").splitlines():
|
||||||
|
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
|
||||||
|
|
||||||
|
|
||||||
|
def nb(path, method="GET", payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
NB + path, method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={"Authorization": "Token " + token(),
|
||||||
|
"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit("!! %s %s -> %s\n %s" % (method, path, e.code, e.read().decode()[:600]))
|
||||||
|
|
||||||
|
|
||||||
|
def device():
|
||||||
|
return nb("/api/dcim/devices/?name=" + DEVICE)["results"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def backup_lines():
|
||||||
|
"""Die `/ip dhcp-server lease`-Zeilen des Cores aus dem GC-Backup, in Geraete-Reihenfolge."""
|
||||||
|
rows = nb("/api/plugins/golden-config/golden-config/?depth=1&limit=0")["results"]
|
||||||
|
for r in rows:
|
||||||
|
dev = r.get("device")
|
||||||
|
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||||
|
if dev == DEVICE:
|
||||||
|
cfg = r.get("backup_config") or ""
|
||||||
|
when = (r.get("backup_last_success_date") or "?")[:19]
|
||||||
|
# 🔴 len(PREFIX), NICHT l[1:]: das Argument-Teil ist ohne den ganzen Befehl
|
||||||
|
# zu speichern — genau wie firewall_filter, wo `add action=…` steht und
|
||||||
|
# das Template `/ip firewall filter ` davorsetzt. Ein blosses l[1:] liess
|
||||||
|
# `ip dhcp-server lease add …` stehen und erzeugte im gerenderten Soll
|
||||||
|
# den Befehl DOPPELT (`/ip dhcp-server lease ip dhcp-server lease add …`).
|
||||||
|
# Gefunden am 2026-09-21 an der ersten Compliance-Zahl nach dem Einbau:
|
||||||
|
# 144/145 statt 145/145.
|
||||||
|
return ([l[len(PREFIX):] for l in cfg.splitlines() if l.startswith(PREFIX)], when)
|
||||||
|
raise SystemExit("kein GC-Eintrag für " + DEVICE)
|
||||||
|
|
||||||
|
|
||||||
|
def mac_of(line):
|
||||||
|
m = re.search(r"\bmac-address=(\S+)", line)
|
||||||
|
return m.group(1).upper() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def set_address(line, ip):
|
||||||
|
new, n = re.subn(r"(?<=^add address=)\S+", ip, line)
|
||||||
|
if n != 1:
|
||||||
|
raise SystemExit("address-Token nicht eindeutig in: " + line)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def show(old, new):
|
||||||
|
print("== Diff (%d -> %d Zeilen) ==" % (len(old), len(new)))
|
||||||
|
om, nm = {mac_of(l): l for l in old}, {mac_of(l): l for l in new}
|
||||||
|
for mac in sorted(set(om) | set(nm)):
|
||||||
|
a, b = om.get(mac), nm.get(mac)
|
||||||
|
if a == b:
|
||||||
|
continue
|
||||||
|
if a is None:
|
||||||
|
print(" + %s" % b[:150])
|
||||||
|
elif b is None:
|
||||||
|
print(" - %s" % a[:150])
|
||||||
|
else:
|
||||||
|
print(" ~ %s -> %s" % (re.search(r"address=(\S+)", a).group(1),
|
||||||
|
re.search(r"address=(\S+)", b).group(1)))
|
||||||
|
print(" %s" % b[:150])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
if not argv or argv[0] not in ("seed", "move", "check"):
|
||||||
|
raise SystemExit(__doc__.strip().splitlines()[-2].strip())
|
||||||
|
cmd, apply_ = argv[0], "--apply" in argv
|
||||||
|
dev = device()
|
||||||
|
ctx = dict(dev.get("local_config_context_data") or {})
|
||||||
|
soll = list(ctx.get(KEY) or [])
|
||||||
|
|
||||||
|
if cmd == "check":
|
||||||
|
ist, when = backup_lines()
|
||||||
|
print("== check: %s / %s (Backup %s, %d Zeilen) ==" % (DEVICE, KEY, when, len(ist)))
|
||||||
|
if not soll:
|
||||||
|
print(" Soll ist LEER — Reservierungen stehen in keinem Soll.")
|
||||||
|
return
|
||||||
|
show(soll, ist)
|
||||||
|
print(" (Diff oben: '-' = nur im Soll, '~' = Adresse weicht ab)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if cmd == "seed":
|
||||||
|
if soll and "--force" not in argv:
|
||||||
|
raise SystemExit("Soll ist NICHT leer (%d Zeilen) — `seed` ist ein Re-Baseline. "
|
||||||
|
"Fuer eine geplante Aenderung `move` benutzen; fuer einen "
|
||||||
|
"bewussten Neuschnitt `--force`." % len(soll))
|
||||||
|
new, when = backup_lines()
|
||||||
|
print("== seed aus Backup %s (%d Zeilen)%s =="
|
||||||
|
% (when, len(new), " [--force, ueberschreibt %d Zeilen]" % len(soll) if soll else ""))
|
||||||
|
else:
|
||||||
|
if not soll:
|
||||||
|
raise SystemExit("Soll ist leer — erst `seed` laufen lassen (Schicht B1).")
|
||||||
|
new, unknown = [], set()
|
||||||
|
seen = set()
|
||||||
|
for line in soll:
|
||||||
|
mac = mac_of(line)
|
||||||
|
if mac in REMOVE:
|
||||||
|
seen.add(mac)
|
||||||
|
continue
|
||||||
|
if mac in MOVES:
|
||||||
|
ist_ip, soll_ip = MOVES[mac]
|
||||||
|
if re.search(r"address=(\S+)", line).group(1) != ist_ip:
|
||||||
|
raise SystemExit("%s: Ist-Adresse im Soll ist nicht %s — Soll und MOVES "
|
||||||
|
"auseinander. Zeile: %s" % (mac, ist_ip, line[:120]))
|
||||||
|
line = set_address(line, soll_ip)
|
||||||
|
seen.add(mac)
|
||||||
|
new.append(line)
|
||||||
|
for mac in list(MOVES) + list(REMOVE):
|
||||||
|
if mac not in seen:
|
||||||
|
unknown.add(mac)
|
||||||
|
if unknown:
|
||||||
|
raise SystemExit("MOVES/REMOVE nennen MACs, die im Soll fehlen: %s"
|
||||||
|
% ", ".join(sorted(unknown)))
|
||||||
|
for add in ADDS:
|
||||||
|
if mac_of(add) not in {mac_of(l) for l in new}:
|
||||||
|
new.append(add)
|
||||||
|
print("== move: %d Adressen verschoben, %d entfernt, %d ergaenzt =="
|
||||||
|
% (len(MOVES), len(REMOVE), len(ADDS)))
|
||||||
|
|
||||||
|
show(soll, new)
|
||||||
|
if not apply_:
|
||||||
|
print("\nDRY-RUN — mit --apply nach local_config_context_data.%s schreiben." % KEY)
|
||||||
|
return
|
||||||
|
ctx[KEY] = new
|
||||||
|
nb("/api/dcim/devices/%s/" % dev["id"], "PATCH", {"local_config_context_data": ctx})
|
||||||
|
print("\n ~ %d Zeilen nach local_config_context_data.%s geschrieben." % (len(new), KEY))
|
||||||
|
print(" Geraet zieht nach per: python3 nautobot/gc-configplan.py --refresh-compliance")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
75
opencode.json
Normal file
75
opencode.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"home-assistant": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "{env:MCP_HOME_ASSISTANT_URL}",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"hostinger-api": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"hostinger-api-mcp"
|
||||||
|
],
|
||||||
|
"environment": {
|
||||||
|
"HOSTINGER_API_TOKEN": "{env:MCP_HOSTINGER_API_TOKEN}"
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"mikromcp": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"mikromcp",
|
||||||
|
"serve"
|
||||||
|
],
|
||||||
|
"environment": {
|
||||||
|
"MIKROMCP_CONFIG_PATH": "/Users/christophemsenhuber/dev/bf-infra/mcp/mikromcp/config/routers.yaml",
|
||||||
|
"MIKROMCP_DEFAULT_ROUTER": "crs326-core",
|
||||||
|
"ROUTER_AP1_USER": "admin",
|
||||||
|
"ROUTER_AP2_USER": "admin",
|
||||||
|
"ROUTER_AP3_USER": "admin",
|
||||||
|
"CRS326_CORE_USER": "admin",
|
||||||
|
"CRS326_BRANCH_USER": "admin",
|
||||||
|
"ROUTER_AP1_PASS": "{env:MCP_MIKROMCP_ROUTER_AP1_PASS}",
|
||||||
|
"ROUTER_AP2_PASS": "{env:MCP_MIKROMCP_ROUTER_AP2_PASS}",
|
||||||
|
"ROUTER_AP3_PASS": "{env:MCP_MIKROMCP_ROUTER_AP3_PASS}",
|
||||||
|
"CRS326_CORE_PASS": "{env:MCP_MIKROMCP_CRS326_CORE_PASS}",
|
||||||
|
"CRS326_BRANCH_PASS": "{env:MCP_MIKROMCP_CRS326_BRANCH_PASS}"
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"cloudflare": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.cloudflare.com/mcp",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"-y",
|
||||||
|
"ollama-mcp"
|
||||||
|
],
|
||||||
|
"environment": {
|
||||||
|
"OLLAMA_HOST": "http://127.0.0.1:11434"
|
||||||
|
},
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"pve-mcp": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "http://10.40.20.173:3000/",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"n8n-mcp": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "http://10.40.20.167:3001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer {env:N8N_MCP_BEARER}"
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"instructions": ["CLAUDE.md"],
|
|
||||||
"permission": "allow",
|
|
||||||
"mcp": {
|
|
||||||
"gitea-mcp": {
|
|
||||||
"type": "remote",
|
|
||||||
"url": "http://10.40.20.167:3000/mcp",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"mikromcp": {
|
|
||||||
"type": "local",
|
|
||||||
"command": ["npx", "-y", "mikromcp", "serve"],
|
|
||||||
"environment": {
|
|
||||||
"MIKROMCP_CONFIG_PATH": "/Users/christophemsenhuber/dev/bf-infra/mcp/mikromcp/config/routers.yaml",
|
|
||||||
"MIKROMCP_DEFAULT_ROUTER": "ap1"
|
|
||||||
},
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": 30000
|
|
||||||
},
|
|
||||||
"pve-mcp": {
|
|
||||||
"type": "remote",
|
|
||||||
"url": "http://10.40.20.173:3000/",
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": 15000
|
|
||||||
},
|
|
||||||
"home-assistant": {
|
|
||||||
"type": "remote",
|
|
||||||
"url": "http://10.40.20.105:9583/private_29X8x73taoNEq6gzmbVdyg",
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": 15000
|
|
||||||
},
|
|
||||||
"cloudflare": {
|
|
||||||
"type": "remote",
|
|
||||||
"url": "https://mcp.cloudflare.com/mcp",
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": 15000,
|
|
||||||
"oauth": {}
|
|
||||||
},
|
|
||||||
"portainer": {
|
|
||||||
"type": "local",
|
|
||||||
"command": ["uvx", "--from", "mcp-portainer~=2.42.0", "mcp-portainer"],
|
|
||||||
"environment": {
|
|
||||||
"PORTAINER_URL": "https://10.40.20.126:9443",
|
|
||||||
"PORTAINER_API_KEY": "{env:PORTAINER_API_KEY}",
|
|
||||||
"PORTAINER_TLS_VERIFY": "0"
|
|
||||||
},
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": 15000
|
|
||||||
},
|
|
||||||
"shelly-mcp": {
|
|
||||||
"type": "remote",
|
|
||||||
"url": "http://10.40.20.180:3030/mcp",
|
|
||||||
"headers": {},
|
|
||||||
"enabled": true,
|
|
||||||
"timeout": 15000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"command": {
|
|
||||||
"handoff": {
|
|
||||||
"description": "Session-Übergabe generieren oder laden",
|
|
||||||
"template": "Du bist das Handoff-Tool für das BF-Infra-Projekt.\n\n1. Ermittle, was zu tun ist:\n - Lese `docs/handoff-*.md` und finde die neueste Datei (nach Datum).\n - Wenn `$ARGUMENTS` \"resume\" oder einen Dateinamen enthält: lade die genannte oder neueste Handoff-Datei.\n - Wenn `$ARGUMENTS` leer ist oder \"generate\" enthält: generiere eine neue Handoff-Datei.\n\n2. Generieren (`/handoff`):\n - Lese `docs/teilprojekte.md` (Quick-Index-Block, Zeilen ~174-200) für den aktuellen TP-Status.\n - Prüfe offene Config-Transaktionen: `bash` mit `python3 nautobot/gc-transaktion.py status`.\n - Schreibe `docs/handoff-YYYYMMDD-thema.md` mit: Titel, Ausgangslage (was erledigt, was läuft), offene Schritte, Fallen/Gotchas, Einstieg für die nächste Session.\n - Sag dem User, welche Datei erstellt wurde.\n\n3. Laden (`/handoff resume`):\n - Lese die neueste Handoff-Datei vollständig.\n - Gib Zusammenfassung: Thema, Stand, offene Schritte.\n - Frage: \"Soll ich die Arbeit aus dieser Handoff-Datei fortsetzen?\"\n\nWichtig: Keine Secrets in die Handoff-Datei schreiben. Verweise auf existierende Doku statt zu duplizieren. Ziel < 200 Zeilen."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import pathlib
|
|||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
NAUTOBOT_URL = "http://10.40.20.30:8080"
|
NAUTOBOT_URL = "http://10.40.20.154:8080"
|
||||||
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||||
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
|
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
|
||||||
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")
|
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")
|
||||||
|
|||||||
Reference in New Issue
Block a user