Compare commits
4 Commits
tp38-oob
...
974ecbc855
| Author | SHA1 | Date | |
|---|---|---|---|
| 974ecbc855 | |||
| 139d7c029d | |||
| b52d6aa49e | |||
| 8c6cb84dd1 |
27
CLAUDE.md
27
CLAUDE.md
@@ -6,6 +6,12 @@ DNS/DHCP-Ablösung, WLAN via **CAPsMAN auf dem Core** (TP28, 2026-08-17 — der
|
||||
|
||||
## Pflichtlektüre pro Sitzung (Reihenfolge)
|
||||
|
||||
0. 🔴 **TP45 = aktuell dringendster Posten (2026-09-21, Gitea #71):** interne `*.bf.emsenhuber.net`
|
||||
lösen im Browser **nicht** auf (AGH wertet Rewrites im **Filter-Pfad** aus → für unfiltrierte
|
||||
Clients übersprungen → Fallback auf den Core, der nur 10 von 26 Namen hält), und `ds-bf` ist
|
||||
**öffentlich kaputt** (einziger Ingress-Eintrag, der Caddy umgeht → TP5-Drop). Reihenfolge:
|
||||
VLAN20 ins TP32-Schema → VLAN30-Pool-Grenze → DNS/FQDN. Ausführlich: `### TP45`.
|
||||
**`### TP46`** = CF-/Tunnel-SSoT + Recovery (eigene Baustelle).
|
||||
1. **Status/Index = Gitea-Board** (seit TP36-Cutover 2026-09-07): [`admin/bf-infra` · Issues](https://gitea.emsenhuber.net/admin/bf-infra/issues) — TP = Issue, Status = Label `status:*`, Gruppierung = `thema:*`. Für den **Detail/Kontext** eines TP dann den `### TPnn`-Prosa-Abschnitt in **`docs/teilprojekte.md`** (aktiv) bzw. **`docs/teilprojekte-archiv.md`** lesen. *(Die frühere „Quick-Index-Block, 1 Read reicht"-Zeile ist überholt — Index/Status stehen nicht mehr in der Datei.)*
|
||||
2. **`docs/handoff-*.md`** (neueste) — wo die letzte Session aufgehört hat.
|
||||
3. Bei Live-Writes oder Inventur: **`docs/cf-tunnel-mapping.md`**, **`docs/teilprojekte-archiv.md`**.
|
||||
@@ -112,7 +118,7 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
||||
| **🔑 Einheitliches Admin-PW (2026-08-08)** | alle 4 MikroTik (core/branch/ap1/ap2) | user `admin`, gleiches PW (`~/.secrets/credentials.env` → `ROUTER_*_PASS`/`CRS326_*_PASS`; „Überlebensversicherung", User-Vorgabe). Werks-Defaults ap1/ap2 damit obsolet. |
|
||||
| NAS (nas-bf, DS923) | 10.40.20.10 | SSH `adm.christoph@10.40.20.10` (kein PW nötig) · Docker (Portainer endpointId=14) |
|
||||
| platform-pg (LXC 105, pve) | 10.40.20.20 | SSH via pve (`pct exec 105 --`) · **TP20 ✅ 2026-07-16: PG16, DB `nautobot`, Creds `postgres/.secrets`** |
|
||||
| Nautobot (Portainer-Stack, NAS) | 10.40.20.30 | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
||||
| Nautobot (Portainer-Stack, NAS) | **10.40.20.154** (TP45 Schicht A 2026-09-21: `.30/.31/.32/.34` → `.154/.155/.156/.158`) | Portainer ep14, Stack Id 203 · **TP21 ✅ 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, DB=platform-pg, Creds in `docs/nas-vlan20/stacks/210-nautobot.yml` (gitignored)** · **TP22 ✅ 2026-08-13: Golden-Config-App + Device-Adoption DURCH** — alle 4 MikroTik adoptiert, Deploy-Pipeline scharf (Image `3.1.7-gc9`), **68/68 Compliance-Checks grün / 0 Drifts** (2026-08-16) |
|
||||
| ~~Omada-Controller~~ | — | ❌ **entfernt 2026-07-16** (User hat den Container gelöscht — overkill für 1 Switch auf der Abschussliste, s. TP2.1/TP15) |
|
||||
| ~~omada-mcp~~ | — | ❌ **entfernt 2026-07-16** — mit dem Controller gelöscht. ✅ **Aufräumen erledigt/gegenstandslos (geprüft 2026-08-16):** nicht mehr in `mcpServers` registriert (nur noch als „disabled" in ein paar Projekt-Scopes = harmloser Merkposten), und **kein `omada`-Hostname mehr im CF-Ingress** |
|
||||
| pve-mcp | 10.40.20.173 | **StreamableHTTP `http://10.40.20.173:3000/` (Root-Pfad, NICHT /sse)** · NAS-Stack ep14 (=nas-bf `.20.10`, NICHT LXC .126!) id 202 (vlan20-only, TP14.1 2026-07-10) · **mcp-pve v0.6.1 (Upgrade 2026-07-10: Single-Session-Bug 0.4.8 „Server already initialized" gefixt, TP14.2a)** · `~/.claude.json` ✓ |
|
||||
@@ -150,6 +156,25 @@ GW je VLAN = `.1` (crs326-core L3) · Mgmt VLAN99: core `.99.1`, branch `.99.2`,
|
||||
⚠️ **`POST /api/plugins/golden-config/config-plan/` ist `405` — das ViewSet ist read-only.** ConfigPlans
|
||||
entstehen nur über den Job **`Generate Config Plans`** (`plan_type=manual` + `commands`-TextVar).
|
||||
- **Docker-Management:** Portainer API `https://10.40.20.126:9443`, Key in `mcp/portainer/.env`.
|
||||
- 🔴 **`pct push` meldet Exit 0, auch wenn es die Quelldatei NICHT lesen kann** (gemessen 2026-09-21).
|
||||
`pct push 130 <fehlende-datei> /etc/caddy/Caddyfile` → `failed to open … for reading`, **`rc=0`**.
|
||||
Ein `&&`/`||` hinter dem Push **beweist nichts**, und ein anschließendes `validate`/`reload` prüft
|
||||
dann die **alte** Datei → **grüne Abnahme bei unverändertem Zustand**. Genau so sah der
|
||||
Nautobot-Umzug am 2026-09-21 „gelandet" aus, während live noch die alte Backend-IP stand
|
||||
(Symptom: 502 bei gesundem Backend). **Regel: Push-Werkzeug wird geprüft, nicht geglaubt** — nach
|
||||
jedem `pct push` den Inhalt vergleichen (`md5sum` diesseits/jenseits in *einem* Remote-Aufruf,
|
||||
Abbruch bei Abweichung). Muster: `push_verified()` in `caddy/deploy.sh`. Dieselbe Klasse wie
|
||||
`nc -z` (09-01) und das `grep|sed`-Maskierungsmuster (08-31): **erst das eigene Werkzeug
|
||||
verdächtigen, dann das Netz.**
|
||||
- 🔴 **Die Core-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert** (Fund 2026-09-21).
|
||||
Der Mgmt-Zugang des Cores für die Deploy-Pipeline läuft über `src-address-list=nautobot`
|
||||
(Regel `*28`) — die Liste selbst stammt aber aus dem **handgelaufenen**
|
||||
`routeros/tp5-etappe2-whitelists.rsc` und steht in **keinem** Template (`grep address-list` im
|
||||
Template findet nur Kommentare). 🔴 **Ein GC-Recovery von null erzeugt damit einen Core, in den
|
||||
Nautobot nicht mehr per SSH kommt** — die Pipeline wäre tot und könnte sich nicht selbst
|
||||
reparieren. Gleiche Lücke wie die DHCP-Reservierungen. **Merkregel: was auf dem Gerät existiert,
|
||||
aber nicht im Template, ist eine Recovery-Lücke, auch wenn Compliance 140/140 grün meldet** —
|
||||
die Zahl prüft nur, was gerendert wird.
|
||||
Ablösung durch Komodo (native MCP) geplant.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,7 +47,7 @@ gitea.emsenhuber.net {
|
||||
# INTERN (Mesh/LAN; Split-Horizon zeigt diese Namen auf 10.40.20.40)
|
||||
# =========================================================================
|
||||
nautobot.bf.emsenhuber.net {
|
||||
reverse_proxy http://10.40.20.30:8080
|
||||
reverse_proxy http://10.40.20.154:8080
|
||||
}
|
||||
|
||||
# grafana.emsenhuber.net — 2026-08-25 entfernt: LXC 104 war seit Langem gestoppt und stand
|
||||
|
||||
@@ -19,12 +19,35 @@ if [ -z "${TOKEN}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 🔴 `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann
|
||||
# ("failed to open <src> for reading", rc=0 — live gemessen 2026-09-21).
|
||||
# Ein `&&`/`||` hinter dem Push beweist deshalb NICHTS, und die anschliessende
|
||||
# `caddy validate` validiert dann die ALTE Datei -> gruene Abnahme bei
|
||||
# unveraendertem Zustand. Genau so ist der Nautobot-Umzug am 2026-09-21
|
||||
# scheinbar gelandet, obwohl live noch die alte Backend-IP stand.
|
||||
# Deshalb: Push + md5-Vergleich in EINEM Remote-Aufruf, der bei Abweichung
|
||||
# hart abbricht. Gepusht wird nur der lokal bekannte Pfad — die frueheren
|
||||
# `\$\$`/`$$`-Verrenkungen waren der Ausloeser.
|
||||
push_verified() { # push_verified <quelle-auf-pve> <ziel-im-ct> <perms> <label>
|
||||
local src=$1 dst=$2 perms=$3 label=$4
|
||||
ssh "${PVE}" "set -e
|
||||
pct push ${CTID} '${src}' '${dst}' --perms ${perms}
|
||||
want=\$(md5sum '${src}' | cut -d' ' -f1)
|
||||
got=\$(pct exec ${CTID} -- md5sum '${dst}' | cut -d' ' -f1)
|
||||
rm -f '${src}'
|
||||
if [ \"\$want\" != \"\$got\" ]; then
|
||||
echo \"FEHLER: ${label} nicht gelandet (\$want != \$got)\" >&2; exit 1
|
||||
fi
|
||||
echo \" ${label} verifiziert (\$got)\""
|
||||
}
|
||||
|
||||
echo "-> Caddyfile"
|
||||
scp -q "${HERE}/Caddyfile" "${PVE}:/tmp/.caddyfile.$$"
|
||||
ssh "${PVE}" "pct push ${CTID} /tmp/.caddyfile.\$\$ /etc/caddy/Caddyfile --perms 644 2>/dev/null || pct push ${CTID} /tmp/.caddyfile.$$ /etc/caddy/Caddyfile --perms 644; rm -f /tmp/.caddyfile.$$"
|
||||
push_verified "/tmp/.caddyfile.$$" "/etc/caddy/Caddyfile" 644 "Caddyfile"
|
||||
|
||||
echo "-> Token (maskiert: Laenge ${#TOKEN})"
|
||||
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$ && pct push ${CTID} /tmp/.cenv.$$ /etc/caddy/caddy.env --perms 600 && rm -f /tmp/.cenv.$$"
|
||||
printf 'CF_DNS_API_TOKEN=%s\n' "${TOKEN}" | ssh "${PVE}" "cat > /tmp/.cenv.$$ && chmod 600 /tmp/.cenv.$$"
|
||||
push_verified "/tmp/.cenv.$$" "/etc/caddy/caddy.env" 600 "caddy.env"
|
||||
|
||||
echo "-> validate"
|
||||
ssh "${PVE}" "pct exec ${CTID} -- sh -c 'set -a; . /etc/caddy/caddy.env; set +a; /usr/local/bin/caddy validate --config /etc/caddy/Caddyfile' 2>&1 | tail -3"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 🆕 **SSoT-Wechsel im Aufbau (TP44, seit 2026-09-02):** Die CF-Estate (Tunnel, WARP-Connector,
|
||||
> VPC-Services, Hyperdrive, Workers, Access-Apps) wird als **native `nautobot.cloud`-Objekte** in
|
||||
> Nautobot (`10.40.20.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
||||
> Nautobot (`10.40.20.154`, TP45 Schicht A 2026-09-21; vorher `.30`) geführt — Etappe 1 (manuelle Erstbefüllung) ✅. Seeder:
|
||||
> `nautobot/tp44-cf-estate-seed.py`, Detail: `docs/handoff-20260902-cf-estate-nautobot-ssot.md`.
|
||||
> **CF bleibt operativer Master, Nautobot = Dokumentations-SSoT.** Dieses File bleibt gültige Historie
|
||||
> und As-Built-Log; die *strukturierte* Estate liest man ab jetzt in Nautobot. Etappe 2 (CF-API→Nautobot-Sync)
|
||||
|
||||
@@ -68,7 +68,7 @@ den *damals geplanten* Werten und ⬜-Status. Sie sind erledigt bzw. gegenstands
|
||||
| ~~FritzBox 6660~~ | — | WAN/Transfer | — | — | ❌ **physisch entfernt** | Liwest-Kabelmodemtausch 2026-07-01 (Edge-Cutover), kein Zurück — Arris-Kabelmodem im Bridge-Mode ersetzt sie komplett |
|
||||
|
||||
| platform-pg (LXC 105, pve) | — (neu) | Platform-Postgres (TP20), PG16, DB `nautobot` | 20 INFRA | `10.40.20.20` | ✅ **live** | TP20 2026-07-16: erstellt via `pct create`, Ubuntu 24.04 (native PG16, kein PGDG-Repo nötig), UTF8/en_US.UTF-8, `wal_level=replica`, `archive_mode` bewusst noch aus (s. Memory) bis pgBackRest/Garage-S3 da ist; Creds `postgres/.secrets` |
|
||||
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.30` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.31, `nautobot-scheduler`.20.32, `nautobot-redis`.20.34 |
|
||||
| nautobot (Portainer-Stack, NAS) | — (neu) | Nautobot Golden Config (TP21), App/API | 20 INFRA | `10.40.20.154` | ✅ **live** | TP21 2026-07-16: `ghcr.io/nautobot/nautobot:3.1.7`, ep14 Stack Id 203, DB=platform-pg; Begleitcontainer `nautobot-worker`.20.155, `nautobot-scheduler`.20.156, `nautobot-redis`.20.158. **TP45 Schicht A 2026-09-21:** Oktett-Klasse `.150–.199` (App-Services) — die alten `.30–.34` gibt Nautobot an AGH `.30`/`.31` frei |
|
||||
|
||||
> Weitere Geräte (Home Assistant, HA-IoT, Drucker-Reservierung, etc.) hier ergänzen, sobald relevant.
|
||||
|
||||
|
||||
@@ -344,12 +344,14 @@ dann Tunnel** (umgekehrt gäbe es 530er).
|
||||
- 📌 **Und die Erwartung wird damit ehrlicher:** Nautobot war nie ohne Access-Login öffentlich
|
||||
erreichbar. Der Schnitt schließt **keine offene Tür**, er entfernt eine **überflüssige zweite**.
|
||||
- ⚠️ **Vorprüfung, die vor jedem weiteren Schnitt zu wiederholen ist:** hängt Werkzeug am
|
||||
öffentlichen Namen? Hier nein — `gc-configplan.py` spricht `http://10.40.20.30:8080/api`, und
|
||||
öffentlichen Namen? Hier nein — `gc-configplan.py` spricht `http://10.40.20.154:8080/api`
|
||||
(TP45 Schicht A 2026-09-21; zum Messzeitpunkt war es `.20.30`), und
|
||||
kein Repo-Skript nutzt den FQDN.
|
||||
- ✅ **Betriebsfenster ausgewertet 2026-08-30 (Tag 6/7): `nautobot` bleibt mesh-only.** Kein Rückweg
|
||||
gebraucht, kein Ausfall. `nautobot.emsenhuber.net` hat inzwischen auch **keinen DNS-Record** mehr
|
||||
(mit den CF-Leichen am 08-29 gelöscht, Commit `39ba5da`) — der Dienst ist vollständig aus dem
|
||||
öffentlichen Strang heraus und ausschließlich über `10.40.20.30:8080` bzw. intern über Caddy erreichbar.
|
||||
öffentlichen Strang heraus und ausschließlich über `10.40.20.154:8080` (TP45 Schicht A 2026-09-21)
|
||||
bzw. intern über Caddy erreichbar.
|
||||
|
||||
##### ✅ §2/§3 durchgezogen — Vor-Ort-Session 2026-08-31
|
||||
|
||||
@@ -632,12 +634,13 @@ und Letzteres verlangt die **physische Bestätigung am Gerät** — ap1 Technik
|
||||
häufigsten per Mesh angefasste Dienst, und ein Fehlschlag kostet nichts Produktives), eine Woche fahren, dann
|
||||
entscheiden. **Rollback = eine Ingress-Zeile zurück.**
|
||||
- 📋 **Runbook: `docs/tp16.3-nautobot-mesh-only-runbook.md`** (2026-08-19) — der Schnitt oben, ausführbar
|
||||
ausformuliert: Vorbedingungen (Mesh-Route, Zugriff über `10.40.20.30:8080` **mit WARP von außerhalb**),
|
||||
ausformuliert: Vorbedingungen (Mesh-Route, Zugriff über `10.40.20.154:8080` **mit WARP von außerhalb**),
|
||||
Ingress-`PUT` über `CF_API_TOKEN` (**nicht** über den CF-MCP, der ist read-only), Rollback aus der
|
||||
gesicherten Config.
|
||||
- 🔴 **Der Abnahmepunkt, den man übersieht:** Nautobot ist nicht nur Web-UI, sondern die
|
||||
**Deploy-Pipeline**. Nach dem Schnitt gehört ein Job-Lauf zur Abnahme, nicht nur ein Browser-Aufruf
|
||||
— der Netmiko-Deploy kommt von `10.40.20.31` (`nautobot-worker`), nicht von `.20.30`.
|
||||
— der Netmiko-Deploy kommt von `10.40.20.155` (`nautobot-worker`), nicht von `.154` (Werte nach
|
||||
TP45 Schicht A 2026-09-21; zum Messzeitpunkt `.31` / `.30`).
|
||||
- ⚠️ Beim Kürzen des `ingress`-Arrays muss die **Catch-all-Regel die letzte bleiben**, sonst greift
|
||||
der Ingress für alle übrigen Hostnames nicht mehr.
|
||||
- **Abh.:** TP16.1 ✅ (Mesh live), TP16 (Public-Strang + dessen HA-Degradierung), **TP16.2** (der `vw`-Constraint
|
||||
@@ -2391,18 +2394,36 @@ haben: `100–109` netznah · **`110–119` DNS** · **`130–139` Reverse Proxy
|
||||
|
||||
**Letztes Oktett je /24 — gilt in jedem VLAN, an jedem Standort:**
|
||||
|
||||
| Oktett | Rolle | Anker heute |
|
||||
|---|---|---|
|
||||
| `.1–.9` | Gateways / L3 | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
|
||||
| `.10–.29` | physische Hosts (Storage, Hypervisor, APs) | NAS `20.10` ✅ · APs `99.11/.12` ✅ |
|
||||
| `.30–.99` | virtuelle Core-Services, Zehnergruppen wie oben | DNS `.3x` · Ingress `.4x` · DBs `.5x` · Security `.6x` · Monitoring `.7x` |
|
||||
| `.100–.199` | App-Services | |
|
||||
| `.200–.249` | DHCP-Pool | |
|
||||
| `.250–.254` | VIPs / Relays | |
|
||||
| Oktett | Rolle | Ableitung aus TP32 | Anker |
|
||||
|---|---|---|---|
|
||||
| `.1–.9` | Gateways / L3 | Bestand | `.1` je VLAN ✅ · `99.1` core, `99.2` branch ✅ |
|
||||
| `.10–.19` | physische Netzhardware im VLAN | APs/Switches, sobald sie im VLAN liegen | heute nur VLAN99: `99.11/.12/.13` ✅ · VLAN30: Nuki-Bridge, NSPanelPro, P750W, DTUBI |
|
||||
| `.20–.29` | Core / netznah | Kreis 100–199, Stufe `10x` | mcast-relay (heute `10.21`/`40.21` — **kein VLAN20-Bein**) |
|
||||
| `.30–.39` | DNS / Resolver | Stufe `11x` | AGH-Paar — **Soll** `.30`/`.31`, Ist `.36`/`.37` (bis Nautobot dort weg ist) |
|
||||
| `.40–.49` | Ingress / Reverse-Proxy | Stufe `13x` | caddy `20.40` ✅ |
|
||||
| `.50–.59` | Datenbanken | Kreis 200–299 | Soll `platform-pg .50`, `mariadb .51`, `influxdb2 .52`, `redis .53` |
|
||||
| `.60–.69` | Security / Identity | Kreis 300–399 | Soll `vaultwarden .60` |
|
||||
| `.70–.79` | Monitoring | Stufe `15x` | frei |
|
||||
| `.80–.89` | Platform / Runtime | Kreis 400–499 | Soll `docker .80`, `coolify .81` |
|
||||
| `.90–.99` | Broker / Funk | **Ordnungsgruppe 40** (s. u.) — *nicht* aus dem VMID-Kreis abgeleitet | Soll `mqtt5 .90`, `z2m25 .91` |
|
||||
| `.100–.149` | **Server-Hosts** (physische Bleche) | `.10x` Reserve · `.11x` Storage · `.12x` Hypervisor · `.13x` Bleche/Workstation/Backup | Soll `nas .110`, `pve2 .120` (reserviert), `pve .121`, `macmini .130`; Ist `pbs 20.135` ✅ |
|
||||
| `.150–.199` | App-Services / Gäste | Kreis 500–599 | `gitea 20.157` ✅, `pve-mcp .173` ✅, `shelly-mcp .180` ✅ |
|
||||
| `.200–.249` | DHCP-Pool | Bestand | VLAN20 soll `.200–.249` (Ist `.100–.200`, `used=0`) |
|
||||
| `.250–.254` | VIPs / Relays | Bestand | |
|
||||
|
||||
🔑 **Der Merk-Trick: die Zehnergruppe im Oktett spiegelt die der VMID.** `110 agh` → `.3x`,
|
||||
`130 caddy` → `.4x`. Wer eine Zahl kennt, kennt die Klasse der anderen.
|
||||
|
||||
⚠️ **Kein Rechenweg — eine Nachschlagetabelle.** Die Zeile `.90–.99` (Broker/Funk) ist die
|
||||
**Ausnahme**: sie stammt nicht aus dem VMID-Kreis, sondern aus der Ordnungsgruppen-Tabelle unten
|
||||
(Stufe 40). **Wo die beiden Achsen auseinanderlaufen, gilt die Gruppe, nicht die Ziffer**
|
||||
(Christoph 2026-09-21). Dasselbe Muster bei `.100–.149`: die Server-Hosts werden über den
|
||||
**Gerätetyp** einsortiert (Blech), nicht über eine VMID.
|
||||
|
||||
⚠️ **Solange eine Alt-IP als Zweitadresse weiterläuft, ist sie reserviert** — `.10` (nas),
|
||||
`.100` (pve), `.167` (macmini) dürfen bis zum Rückbau **nicht** neu vergeben werden. Deshalb
|
||||
bekommt z. B. `z2m25` **nicht** `.167`, sondern `.91`.
|
||||
|
||||
#### Warum der Bestand anders aussieht — er war nicht falsch, nur auf einer anderen Achse
|
||||
**User 2026-08-25:** „zuerst war haos, welches mit 100 den nummernkreis für iot eröffnet hat. und die
|
||||
anderen … gehörten zu ha, daher 1xx. erst später habe ich erfahren, dass die nummer die
|
||||
@@ -2425,19 +2446,35 @@ alle Gäste mit `order=`, dann den Rest nach VMID** — real lief also `iob-mast
|
||||
| 10 | Core | `108 mcast-relay` |
|
||||
| 20 | Daten | `105 platform-pg` (`up=15`), `302 mariadb` (`up=15`), `106 influxdb2`, `107 redis` |
|
||||
| 30 | Platform | `400 docker` (`up=20`) |
|
||||
| 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25`, `330 RaspberryMatic` |
|
||||
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed` |
|
||||
| 40 | Broker / Funk | `102 mqtt5` (`up=10`), `112 z2m25` |
|
||||
| 50 / 51 | Anwendungen | `101 iob-master`, **`100 haos`**, `150 iob-exposed`, `330 RaspberryMatic` |
|
||||
|
||||
🔴 **Korrektur 2026-09-21 (Christoph):** die CCU gehörte **nie** in die Funk-Gruppe. „Funk" ist
|
||||
`102`/`112` — die CCU läuft in der **Anwendungs-Gruppe**, in derselben wie `haos`. Live stand sie
|
||||
noch auf `order=40`; der Geräte-Write ist Teil desselben Zuges (nicht nur diese Doku-Zeile — eine
|
||||
Doku, die dem Gerät vorauseilt, ist genau der Drift, den TP32 vermeiden soll).
|
||||
|
||||
🎯 **Die wichtigste Erkenntnis des TP: VMID = Klassifikation, `startup: order=` = Abhängigkeitsreihenfolge.**
|
||||
Die Umnummerierung muss die Startreihenfolge gar nicht reparieren — das ist bereits erledigt, ohne eine
|
||||
einzige ID zu ändern. **Die Nummernkreise sind damit reine Ordnung und Lesbarkeit**, und der Bestand
|
||||
kann in Ruhe nachziehen.
|
||||
|
||||
- **Offen:** Migration des Bestands (Tabelle oben) — *am Ende der Reise, nicht jetzt*. Und die
|
||||
Kollision **AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker) ⇒ Vorschlag `.36` als
|
||||
Interim, `.30` erst nach dem Nautobot-Umzug in den App-Bereich. **Fällt vor dem AGH-Neubau an.**
|
||||
- **Offen:** Migration des Bestands (Tabelle oben) — **läuft jetzt an**, nicht mehr „am Ende der
|
||||
Reise": siehe **TP45** (VLAN20 + VLAN30 ins Schema, danach DNS/FQDN). Die alte Kollision
|
||||
**AGH will `.30`, dort sitzt Nautobot** (`.31` = nautobot-worker, `.32` = scheduler, `.34` = redis)
|
||||
ist dort **Schritt 1**: Nautobot zieht in den App-Block (`20.154–.156`), **erst danach** greift
|
||||
AGH `.30`/`.31`. Reihenfolge bewusst so — der GC-Pfad läuft über Nautobot, ein Umzug von AGH zuerst
|
||||
würde die Werkzeugkette gegen sich selbst laufen lassen.
|
||||
- **Abh.:** keine. **Blockiert nichts**, aber TP31 vergibt bereits danach.
|
||||
|
||||
#### 🔎 Bestands-Fehler, die beim Umsetzen auffielen (2026-09-21, live gemessen)
|
||||
Die frühere Fassung dieser Tabelle war an vier Stellen **nicht gemessen, sondern angenommen**:
|
||||
`mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`), der Anker der Zeile
|
||||
`.20–.29` war damit falsch; `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), nicht drei; und der
|
||||
**DHCP-Pool in VLAN30 enthält ~25 Statics** (`.202–.249`) — die VLAN30-Nummerierung war ebenfalls
|
||||
gewachsen, nicht geplant. Merkregel: **„Bestand bleibt, wo er ist" gilt nur, solange niemand behauptet,
|
||||
wo er ist, ohne nachzusehen.**
|
||||
|
||||
|
||||
### TP33 — Sonos aus der Ferne bedienen (VPN/WARP) ▸ 💡 **IDEE, bewusst zurückgestellt** (angelegt 2026-08-25, User-Entscheid „später entscheiden")
|
||||
|
||||
@@ -2650,11 +2687,180 @@ Der HA-Recorder schreibt **unbegrenzt** — es ist keine `purge_keep_days`-Grenz
|
||||
die Pipeline ist stabil und **104/104 compliant**. TP37 vergrößert die Angriffsfläche dieser Pipeline.
|
||||
Deshalb Punkt 1–2 als eigener, abnehmbarer Schritt **mit Compliance-Gegenprobe danach**, nicht als
|
||||
Nebeneffekt von Punkt 3.
|
||||
- **Status:** ⏳ **Punkt 3 (CF) ✅ via TP44; Punkt 1+2 (Substrat + Dienste) offen** — das ist die
|
||||
fehlende Voraussetzung für TP34. Datenmodell **entschieden 2026-09-06 (native Modelle:
|
||||
`virtualization.Cluster/VirtualMachine` + `ipam.Service`, keine App)**. Runbook liegt jetzt vor:
|
||||
[`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md). Live-Ausgangslage:
|
||||
VM 0 · Cluster 0 · `ipam.Service` 0 · CustomField 0 (2026-09-06 gegen die API gelesen).
|
||||
- **Status:** ✅ **Punkt 1+2 (Substrat + Dienste) ERLEDIGT** — die frühere Zeile „offen" war Doku-Drift.
|
||||
Live gegen die API gelesen **2026-09-21**: **28 VMs · 3 Cluster · 26 `ipam.Service` · 38 `ipam.IPAddress`
|
||||
· 9 Devices · 5 CustomFields**. Beide Seeder sind also gelaufen (`tp37-substrat-seed.py`,
|
||||
`tp37-dienste-seed.py` — die 26 Services sind genau die 26 Zeilen der Dienste-Liste).
|
||||
Datenmodell **entschieden 2026-09-06 (native Modelle: `virtualization.Cluster/VirtualMachine` +
|
||||
`ipam.Service`, keine App)**; Runbook [`handoff-20260906-tp37-34-36-plan.md`](handoff-20260906-tp37-34-36-plan.md).
|
||||
**Offen bleibt Punkt 4** (Drift-Check gegen die CF-API) — und der Weg **Daten → gerenderte Config**
|
||||
ist nicht mehr TP37, sondern **TP34/TP45 Schicht C**.
|
||||
⚠️ **Merkregel:** die Zeile stand seit dem 09-06 auf „offen", obwohl die Seeder längst gelaufen waren.
|
||||
Ein Status, der nur in einer Datei lebt, altert still — der Abgleich gegen die API kostet eine Minute.
|
||||
|
||||
---
|
||||
|
||||
### TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN aus Nautobot ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #71)
|
||||
> **Auslöser (Christoph, 2026-09-21):** *„schau dir mal die fqdns von `*.emsenhuber.net` an. viele
|
||||
> verschwunden. sowohl caddy als auch cloudflare."* — und, als der Befund stand:
|
||||
> *„wenn wir schon reverse proxy & co korrigieren müssen, wäre es vl jetzt ein guter zeitpunkt,
|
||||
> unsere infrastruktur vlan20 an das ausgedachte ip-schema anzupassen. danach dns/fqdn."*
|
||||
|
||||
#### Befund (live, 2026-09-21) — **weder Caddy noch Cloudflare haben etwas verloren**
|
||||
| # | Befund | Beleg |
|
||||
|---|---|---|
|
||||
| 1 | **CF ist gewollt geschrumpft** — 24 Records, 8 proxied; `bf-public` trägt 5 (`vw`,`hass`,`n8n`,`gitea`,`ds-bf`), `fm` 2 | CF-DNS-API |
|
||||
| 2 | **Caddy hat nichts verloren** — 16 Sites, live **byte-gleich** zum Repo (`diff` identisch, 2977 B) | LXC 130 |
|
||||
| 3 | 🔴 **Internes Split-Horizon tot** | s. u. |
|
||||
| 4 | 🔴 **`ds-bf` öffentlich kaputt** | s. u. |
|
||||
|
||||
🔴 **Root Cause #3 (bewiesen).** AGH wertet DNS-Rewrites **im Filter-Pfad** aus; für Clients mit
|
||||
`filtering_enabled: false` werden sie **übersprungen**. Genau die Gruppen, von denen gebrowst wird,
|
||||
sind unfiltriert (`dns-bypass-exempt` = beide Mac-NICs, `grp-infra`, `grp-media`, `grp-mgmt`). Diese
|
||||
Clients fallen auf den conditional forward `[/bf.emsenhuber.net/] → 10.40.20.1` (Core) zurück — der
|
||||
Core hält aber nur **10** Host-Records; die **11 Dienstnamen** (`nautobot`, `portainer`, `pbs`, `ccu`,
|
||||
`iobroker`, `z2m25`, `tv`, `mfc`, `ha-mcp`, `gitea-mcp`, `n8n-mcp`) existieren **nur** als AGH-Rewrite
|
||||
→ **NXDOMAIN**. Aus dem AGH-Container selbst (`127.0.0.1`, kein Client-Match) liefern dieselben
|
||||
Rewrites korrekt — **deshalb war jede Handoff-Abnahme grün: sie wurde nie vom Browser aus gemessen.**
|
||||
Zweitdefekt: `pve.bf`/`pve-mcp.bf` **doppelt** definiert (Core → Host-IP, AGH → Caddy `.40`).
|
||||
|
||||
🔴 **Root Cause #4 (bewiesen, Firewall).** Der Origin antwortet aus VLAN20 in 0,08 s mit `200`,
|
||||
öffentlich `000` nach 20 s — die vier anderen Namen desselben Tunnels sind in ~0,2 s da. `vw`/`hass`/
|
||||
`n8n`/`gitea` gehen auf `https://10.40.20.40:443` (**Caddy**); `ds-bf` geht **direkt** auf
|
||||
`http://10.40.20.10:8080` (**NAS**) und ist damit der **einzige Ingress-Eintrag, der Caddy umgeht** →
|
||||
die AP-Connectoren (VLAN99) dürfen per Regel `*42` **ausschließlich** Caddy:443, alles andere fällt
|
||||
unter `*1A TP5-WOULD-DROP` / `*2D TP5: deny-by-default` (621 Pakete).
|
||||
**Fix ohne Firewall-Änderung: `ds-bf` über Caddy führen.**
|
||||
|
||||
#### Schichten (Reihenfolge = Abhängigkeit)
|
||||
| Schicht | Inhalt | Stand |
|
||||
|---|---|---|
|
||||
| **A** | VLAN20 virtuelle Gäste ins Schema — **Nautobot zuerst** (`.30–.34` → `.154–.156`/`.158`), dann AGH | 🔄 **Nautobot ✅ 2026-09-21** (s. u.) · AGH + übrige Gäste offen |
|
||||
| **B** | VLAN30 — **nur die Pool-Grenze**, Geräte bleiben (Christoph 2026-09-21) | offen |
|
||||
| **C** | DNS/FQDN: `ds-bf` über Caddy, `coolify.bf` neu, `.bf` vollständig auf den Core, AGH nur Filtering, TP34 | offen |
|
||||
| **D** | Tunnel-Inventar + GC + generische Namen, TP30 #10, Leak-Ursachen | offen |
|
||||
| **E** | nas/pve/macmini mit Doppeladresse — **eigene Session**, pve2 nur reservieren | offen |
|
||||
| **F** | CF-/Tunnel-SSoT + Recovery → **TP46** | offen |
|
||||
|
||||
🔴 **Warum Nautobot zuerst.** AGH will `.30`/`.31` — dort saß Nautobot (`.30`/`.31`/`.32`/`.34`).
|
||||
**28 Skript-/Config-Dateien** referenzierten `10.40.20.30` (alle `gc-*.py`, 14× `ingest-*.py`,
|
||||
`tp44-*`, `routeros/fetch-core-cutover-config.py`) — alle am 2026-09-21 mitgezogen. Und: **der
|
||||
GC-Pfad läuft über Nautobot** — AGH zuerst zu ziehen hieße, die Werkzeugkette gegen sich selbst
|
||||
laufen zu lassen.
|
||||
|
||||
##### ✅ Nautobot-Umzug durch (2026-09-21) — `.30/.31/.32/.34` → `.154/.155/.156/.158`
|
||||
|
||||
`.157` ist gitea, deshalb nimmt redis `.158`. Reihenfolge und Belege:
|
||||
|
||||
1. **Core-Adressliste `nautobot` additiv erweitert** (`.154/.155/.156` **dazu**, Alt-Einträge bleiben).
|
||||
Das ist die wirksame Ebene: der Mgmt-Zugang des Cores läuft über `src-address-list=nautobot`
|
||||
(Regel `*28`, 113 Pakete), **nicht** über den Literal `*24`.
|
||||
2. **Nautobot-Datenmigration** über das neue [`nautobot/tp45-ip-move.py`](../nautobot/tp45-ip-move.py).
|
||||
Nautobot **verbietet** das Ändern einer IP nach dem Anlegen (`Host address cannot be changed once
|
||||
created`) → Umzug ist Dreischritt *anlegen → `primary_ip4` umhängen → Altobjekt löschen*.
|
||||
3. **Stack-YML + Redeploy** (`PUT /api/stacks/203?endpointId=14`), Container auf den neuen IPs,
|
||||
`http://10.40.20.154:8080/health/` = **200**, alle vier Alt-IPs **000** (keine Doppeladresse —
|
||||
so entschieden).
|
||||
4. **Caddy** `nautobot.bf` → `.154`, deployt und **verifiziert**.
|
||||
5. **Core-Literal `*24` auf `.154`** über GC (`gc-manual-plan.py --deploy`, Plan `51a90e25`,
|
||||
Completed) mit Wirksamkeitsprüfung am Gerät.
|
||||
6. **`python3 nautobot/gc-configplan.py --refresh-compliance` → 140/140, 0 Drifts.**
|
||||
|
||||
##### 🔴 Zwei Werkzeug-Fehler, die genau dabei Zeit gekostet haben (beide neu)
|
||||
|
||||
**(a) `pct push` meldet Exit 0, auch wenn es die Quelle nicht lesen kann.**
|
||||
Gemessen: `pct push 130 <nicht-vorhandene-datei> /etc/caddy/Caddyfile` →
|
||||
`failed to open … for reading`, **`rc=0`**.
|
||||
Folge in `caddy/deploy.sh`: die Push-Zeile war als `push || push`-Fallback gebaut (wegen einer
|
||||
`$$`-Escaping-Verwechslung); der Fallback griff **nie**, weil der kaputte Push „Erfolg" meldete. Die
|
||||
anschließende `caddy validate` prüfte damit die **alte** Datei und war grün — **grüne Abnahme bei
|
||||
unverändertem Zustand**. Symptom war `nautobot.bf` **502** bei gleichzeitig gesundem Backend.
|
||||
**Fix:** `push_verified()` in `caddy/deploy.sh` — Push **und** md5-Vergleich in *einem* Remote-Aufruf,
|
||||
harter Abbruch bei Abweichung. Merkregel: **ein Push-Werkzeug, dessen Exit-Code nicht stimmt, wird
|
||||
geprüft, nicht geglaubt** — dieselbe Klasse wie `nc -z` (09-01) und das `sed`-Muster (08-31).
|
||||
|
||||
**(b) 🔴 Die Firewall-Adressliste `nautobot` wird von Golden Config NIRGENDS gerendert.**
|
||||
Der Core-Zugang für Nautobot hängt an `src-address-list=nautobot` — die Liste stammt aber aus dem
|
||||
**von Hand gelaufenen** `routeros/tp5-etappe2-whitelists.rsc` und existiert im Template nicht
|
||||
(`grep` auf `address-list` findet nur Kommentare). **Ein Golden-Config-Recovery von null erzeugt
|
||||
damit einen Core, in den Nautobot nicht mehr per SSH kommt** — die Deploy-Pipeline wäre tot und
|
||||
würde sich selbst nicht reparieren können. Gleiche Klasse wie die fehlenden DHCP-Reservierungen.
|
||||
Der Renderer gehört in dieselbe Baustelle wie Punkt „DHCP-Reservierungen" oben.
|
||||
|
||||
**(c) Nebenfund — Regel `*24`/`*28` sind Dubletten.** Dieselbe Erlaubnis existiert zweimal:
|
||||
`src-address=10.40.20.30` (Literal, **0 Pakete** = tot) und `src-address-list=nautobot`. Nur die
|
||||
Liste ist wartbar. Der Literal wurde auf `.154` gezogen (Soll-Konsistenz), die **Zusammenlegung**
|
||||
bleibt als Aufräumposten offen.
|
||||
|
||||
**(d) Nebenfund — `gc-manual-plan.py` Diagnosepfad ist tot.** Die Log-Abfrage
|
||||
`/api/extras/job-log-entries/?job_result=…` liefert **HTTP 404** (Nautobot 3.x hat den Endpunkt
|
||||
verschoben). Bei einem **erfolgreichen** Deploy ist das kosmetisch, bei einem **gescheiterten**
|
||||
nimmt es die Fehlermeldung weg — genau dann, wenn man sie braucht.
|
||||
|
||||
#### Soll-Oktette (Nachschlagetabelle, s. TP32 für die Herleitung)
|
||||
| Oktett | Rolle | Soll |
|
||||
|---|---|---|
|
||||
| `.20–.29` | Core / netznah | mcast-relay |
|
||||
| `.30–.39` | DNS | agh-bf1 `.30` · agh-bf2 `.31` |
|
||||
| `.50–.59` | DBs | platform-pg `.50` · mariadb `.51` · influxdb2 `.52` · redis `.53` |
|
||||
| `.60–.69` | Security | vaultwarden `.60` |
|
||||
| `.80–.89` | Platform | docker `.80` · coolify `.81` |
|
||||
| `.90–.99` | Broker/Funk | mqtt5 `.90` · z2m25 `.91` |
|
||||
| `.150–.199` | App | haos `.150` · iob-master `.151` · CCU `.152` · nautobot `.154–.156`+`.158` · gitea `.157` · iob_slave `.159` |
|
||||
|
||||
🔴 **DHCP-Reservierungen sind die Lücke:** Golden Config rendert sie **nirgends** (pools/DNS-statics/
|
||||
NAT/mangle/WG sind Template-Literale, der Firewall-Filter kommt als ConfigContext-**Daten** — aber
|
||||
`/ip dhcp-server lease` existiert im Template nicht). Die 26 VLAN30- + 1 VLAN20-Reservierung sind
|
||||
**Handarbeit auf dem Gerät** aus der ap1-Ära. Christoph-Entscheid 2026-09-21: **Renderer bauen**
|
||||
(Template + Nautobot-Datenmodell), statt Handarbeit zu dokumentieren.
|
||||
|
||||
#### Live-Befunde, die den ursprünglichen Plan widerlegten (2026-09-21)
|
||||
- `mcast-relay` liegt **nicht in VLAN20** (Beine `10.40.10.21` + `10.40.40.21`) → der Anker der
|
||||
Zeile `.20–.29` war falsch gesetzt.
|
||||
- `nautobot` sind **vier** Gäste (`.30`–`.32`, `.34`), der Plan sah **drei** Slots vor.
|
||||
- Die VLAN30-Soll-Spalte war **durchgehend erfunden**: Ender3V3KE liegt live auf `.150` (Plan `.180`),
|
||||
die 26 Shelly-Reservierungen auf `.202–.226`+`.233` (Plan `.150–.169`), Twinkly `.228`,
|
||||
Lichterketten `.236`/`.237`, Withings `.230`/`.231` … → Christoph: **„VLAN30 nur Pool-Grenze"**.
|
||||
- Pool-Fund: `pool-v20` `.100–.200` hat **`used=0`** (gratis verschiebbar); `pool-v30` `.100–.250`
|
||||
enthält **~25 Statics** → Pool und Statics tauschen praktisch die Plätze.
|
||||
- ⚠️ **`ap3.bf` existiert nirgends** — weder Core-Static noch AGH-Rewrite, obwohl ap3 seit 08-30 live ist.
|
||||
|
||||
- **Abh.:** TP32 (Systematik) · TP37 ✅ (Nautobot-Datenmodell) · TP21 ✅. **Blockiert:** TP34 (rendert
|
||||
die FQDNs, die hier Daten werden). **Abgrenzung:** VMID-Umnummerierung bleibt draußen; Token-**Rotation**
|
||||
läuft in TP30; Tunnel-GC-Namensgebung hier, die **SSoT/Recovery** in TP46.
|
||||
|
||||
### TP46 — Cloudflare/Tunnel: SSoT mit Push-Weg + Full-Recovery from scratch ▸ 🔜 **ANGELEGT 2026-09-21** (Gitea #72)
|
||||
> **Christoph (2026-09-21):** *„haben tunnel/warp/cf schon eine ssot für die config? nautobot? tunnel
|
||||
> sind essenzielle dinge, für die ich eine ssot möchte um drift zu vermeiden. zudem möchte ich auch
|
||||
> from scratch ein full setup/recovery aus der nautobot-config fahren können."* → Umfang **„Voll:
|
||||
> Tunnel+Ingress+DNS+Access"**, Platzierung **„Eigenes TP, GC bleibt hier"**.
|
||||
|
||||
#### Bestandsaufnahme (live 2026-09-21) — es gibt eine Doku-SSoT, aber keinen Master
|
||||
| Was | Wo | Stand |
|
||||
|---|---|---|
|
||||
| Tunnel, WARP-Connector, Workers, Access-Apps, Hyperdrive, VPC, teamnet-Routen | Nautobot `nautobot.cloud`, Job **`CF Estate Sync`** (`jobs/cf_estate_sync.py`) | ✅ 4 `CloudNetwork` · 16 `CloudService` live |
|
||||
| **Ingress-Origin-Ziele** | **nirgends** | 🔴 `_ingress()` speichert nur `ingress_hostnames`, **nicht** das Origin |
|
||||
| **DNS-Records** | **nirgends** | 🔴 nicht erfasst — live 24 Records / 8 proxied |
|
||||
| **Access-Policies** | **nirgends** | 🔴 nur die Apps selbst |
|
||||
| Richtung | CF → Nautobot | 🔴 **Pull-only** („CF bleibt operativer Master") |
|
||||
| `docs/cf-tunnel-mapping.md` | Repo | §1–16 eingefrorene As-Built-Historie · §0 = Live-Zeiger (altert: nennt `bf-public` „2 conns", live **12** = 3 AP-Connectoren × 4) |
|
||||
|
||||
**Antwort auf „full setup/recovery from scratch": heute nicht möglich.** Es fehlt (a) der Push-Weg,
|
||||
(b) Ingress-Origins, (c) DNS, (d) Access-Policies. Und ein frisch erzeugter Tunnel liefert **neue ID
|
||||
und neues Secret** → das **Token-Handoff an die Connectoren** (ap1/ap2/ap3) ist **Teil der Recovery**,
|
||||
kein Nebenschritt.
|
||||
|
||||
#### Umfang
|
||||
Tunnel + Ingress **inkl. Origin-Ziele** + DNS-Records + Access-Apps/Policies vollständig nach Nautobot;
|
||||
**Push-Renderer** (Dry-Run + Diff); **Recovery-Runbook** für den Aufbau von null.
|
||||
|
||||
🔴 **Vor dem ersten Schritt prüfen, nicht annehmen:** welches Nautobot-Objekt DNS-Records tragen kann
|
||||
(Kern-`ipam` vs. eigenes Modell), und ob das Ingress-Origin als `extra_config`-Feld reicht oder ein
|
||||
eigenes Modell braucht.
|
||||
|
||||
- **Abh.:** TP44 Etappe 1/2 ✅ (native `nautobot.cloud`-Modelle + Pull-Job stehen). **Abgrenzung:**
|
||||
Tunnel-**GC/Namensgebung** läuft in TP45 Schicht D; **CF-Writes** aus Nautobot sind hier *das Ziel*,
|
||||
in TP37 ausdrücklich ausgeschlossen — die Grenze verschiebt sich also bewusst mit diesem TP.
|
||||
|
||||
---
|
||||
|
||||
@@ -2761,6 +2967,12 @@ den Weg, über den man den Fehler zurücknehmen wollte.
|
||||
|
||||
### Follow-ups nebenher (blockieren nichts)
|
||||
|
||||
- **🔴 TP45 — VLAN20/VLAN30 ins TP32-Schema + DNS/FQDN (NEU 2026-09-21, Gitea #71):** der aktuell
|
||||
**dringendste** Posten — die internen `*.bf.emsenhuber.net`-Namen lösen im Browser nicht auf
|
||||
(Ursache: AGH-Rewrites im Filter-Pfad für unfiltrierte Clients übersprungen), und `ds-bf` ist
|
||||
öffentlich kaputt (einziger Ingress-Eintrag, der Caddy umgeht). Danach ist **TP34** erst baubar.
|
||||
- **TP46 — CF-/Tunnel-SSoT + Recovery (NEU 2026-09-21, Gitea #72):** Push-Weg + Ingress-Origins +
|
||||
DNS + Access-Policies nach Nautobot, Recovery-Runbook. Eigene Baustelle, unabhängig von TP45.
|
||||
- **TP25 — WLAN-Stabilisierung (NEU 2026-08-17):** Audit durch, **6 Funde** (2,4 GHz fährt 40 MHz statt der
|
||||
profilierten 20; ap2 sendet auf 5 GHz mit **9 statt 18 dBm**, weil ch149 unter ETSI leistungsbegrenzt ist;
|
||||
80 MHz kostet Randabdeckung; unzulässiger Channel-Override auf `w2-iot`; keine `access-list`; 5-Minuten-Takt
|
||||
|
||||
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
|
||||
@@ -59,8 +59,9 @@ import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
|
||||
# Status-UUIDs fuer nautobot_golden_config.configplan (2026-08-16 aus der API gelesen)
|
||||
@@ -160,8 +161,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
|
||||
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
||||
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
||||
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
||||
|
||||
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
|
||||
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
|
||||
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
|
||||
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
|
||||
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
|
||||
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
|
||||
def created(p):
|
||||
raw = str(p.get("created") or "")
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
return datetime.min
|
||||
|
||||
by_name = {}
|
||||
for p in found:
|
||||
for p in sorted(found, key=created):
|
||||
name = (p.get("device") or {}).get("display")
|
||||
by_name[name] = p["id"]
|
||||
|
||||
@@ -289,7 +304,7 @@ def job_mgmt_acl():
|
||||
das ist auf dem Core passiert. Umgekehrt fuhren branch/ap1/ap2 ssh/www/winbox/api/ftp/
|
||||
telnet komplett OHNE Address-Beschraenkung; winbox war auf allen vier offen.
|
||||
|
||||
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.30) aus, also aus
|
||||
KEIN LOCKOUT-RISIKO: der Deploy laeuft von Nautobot (10.40.20.154) aus, also aus
|
||||
10.40.0.0/16 -- die Adresse bleibt in jeder gesetzten ACL enthalten. RouterOS wendet
|
||||
`address=` ohnehin erst auf NEUE Verbindungen an, die laufende Session ueberlebt.
|
||||
|
||||
@@ -360,7 +375,7 @@ def job_tp38_oob():
|
||||
* branch: ether23 untaggen, VLAN99 um untagged=ether23 erweitern, 8 Input-Regeln
|
||||
(deny-by-default). Die Regeln werden in der Reihenfolge angelegt, in der das
|
||||
Template sie rendert — established zuerst, drop zuletzt — damit kein LOCKOUT:
|
||||
die laufende Nautobot-SSH-Session (10.40.20.30) matcht auf Regel 1 (established),
|
||||
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
|
||||
@@ -368,7 +383,7 @@ def job_tp38_oob():
|
||||
|
||||
KEIN LOCKOUT-RISIKO: ether23 ist auf beiden Geraeten frei (running=false); der
|
||||
VLAN99-Umbau aendert nur `untagged`, nicht `tagged`; der Deploy laeuft von Nautobot
|
||||
(10.40.20.30), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
|
||||
(10.40.20.154), das in jeder gesetzten Firewall-Freigabe enthalten bleibt.
|
||||
"""
|
||||
bridge_set = (
|
||||
':do { /interface bridge port set [find interface=ether23] pvid=99 '
|
||||
@@ -418,11 +433,126 @@ def job_tp38_oob():
|
||||
]
|
||||
|
||||
|
||||
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")]
|
||||
|
||||
|
||||
JOBS = {
|
||||
"drift-cleanup": job_drift_cleanup,
|
||||
"mgmt-acl": job_mgmt_acl,
|
||||
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
||||
"tp38-oob": job_tp38_oob,
|
||||
"tp45-agh-fw": job_tp45_agh_fw,
|
||||
"tp45-agh-dhcp": job_tp45_agh_dhcp,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
||||
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
@@ -55,7 +55,7 @@ HIER = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HIER)
|
||||
AKTEN = os.path.join(REPO, "docs", "transaktionen")
|
||||
STACK = os.path.join(REPO, "docs", "nas-vlan20", "stacks", "210-nautobot.yml")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
|
||||
PW_ENV = {"crs326-core": "CRS326_CORE_PASS", "crs326-branch": "CRS326_BRANCH_PASS",
|
||||
"ap1": "ROUTER_AP1_PASS", "ap2": "ROUTER_AP2_PASS"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Restdaten-Ingest 2026-07-22: cudy1/cudy2, sw1-Ports, crs326-core Zielport."""
|
||||
import json, os, sys, urllib.parse, urllib.request, urllib.error
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ["NAUTOBOT_TOKEN"]
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -38,7 +38,7 @@ ConfigContext und DCIM-Modell nicht auseinanderlaufen.
|
||||
"""
|
||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ PSKs werden bewusst NICHT modelliert (liegen lokal am Geraet).
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -27,7 +27,7 @@ tatsaechlichen Umzug wandert er zurueck auf Sideboard.
|
||||
"""
|
||||
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Quelle: ap1 live via MikroMCP + docs/teilprojekte.md.
|
||||
"""
|
||||
import json, sys, urllib.request, urllib.error
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
import os
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "") # aus docs/nas-vlan20/stacks/210-nautobot.yml (gitignored)
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
@@ -27,7 +27,7 @@ import pathlib
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
DEVICE = "crs326-core"
|
||||
SECRETS = pathlib.Path.home() / ".secrets" / "credentials.env"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Idempotent; verwendet GraphQL fuer tagged_vlans (REST liefert null).
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -14,7 +14,7 @@ Verifikation NUR per GraphQL (tagged_vlans liest sich per REST als null).
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -32,7 +32,7 @@ Jinja-Template denselben String emittieren kann (sonst Dauer-Diff in der Complia
|
||||
"""
|
||||
import json, os, re, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ UMGEKEHRT GILT: die `vlanXX`-Descriptions auf dem Core NICHT anfassen, die lande
|
||||
"""
|
||||
import json, os, sys, urllib.request, urllib.error, urllib.parse
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
log = []
|
||||
|
||||
@@ -22,7 +22,7 @@ import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
|
||||
BRANCH_RULES = [
|
||||
'add action=accept chain=input comment="TP38: established" connection-state=established,related,untracked',
|
||||
|
||||
@@ -68,7 +68,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
DRY = "--apply" not in sys.argv
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://10.40.20.30:8080/api"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
||||
{% endfor %}
|
||||
{% for v in vlans %}
|
||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||
{% endfor %}
|
||||
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
@@ -38,7 +38,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
@@ -134,10 +134,10 @@ VMS = {
|
||||
("docker", "10.40.20.126/24", "Docker-Host nested (LXC 400)"),
|
||||
],
|
||||
"nas-bf": [
|
||||
("nautobot", "10.40.20.30/24", "Nautobot Web (DS923 ep14)"),
|
||||
("nautobot-worker", "10.40.20.31/24", "Nautobot Golden-Config Worker"),
|
||||
("nautobot-scheduler", "10.40.20.32/24", "Nautobot Scheduler"),
|
||||
("nautobot-redis", "10.40.20.34/24", "Nautobot Redis"),
|
||||
("nautobot", "10.40.20.154/24", "Nautobot Web (DS923 ep14)"),
|
||||
("nautobot-worker", "10.40.20.155/24", "Nautobot Golden-Config Worker"),
|
||||
("nautobot-scheduler", "10.40.20.156/24", "Nautobot Scheduler"),
|
||||
("nautobot-redis", "10.40.20.158/24", "Nautobot Redis"),
|
||||
("gitea", "10.40.20.157/24", "Gitea (git-SSoT)"),
|
||||
("vaultwarden", "10.40.20.127/24", "Vaultwarden (DB=mariadb LXC302)"),
|
||||
("shelly-mcp", "10.40.20.180/24", "Shelly-MCP"),
|
||||
|
||||
@@ -29,7 +29,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
CF_ACCOUNT_ID = "393c051f3accf51881d6c98fc5b66c36"
|
||||
|
||||
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
135
nautobot/tp45-agh-firewall.py
Normal file
135
nautobot/tp45-agh-firewall.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""TP45 Schicht A — die AGH-DNS-Freigaben im Core-`firewall_filter`-Soll pflegen.
|
||||
|
||||
WAS HIER PASSIERT
|
||||
-----------------
|
||||
Die vier `TP5-E2: LAN->AGH dns*`-Regeln des Cores stehen als LITERALE im
|
||||
`local_config_context_data.firewall_filter` (Nautobot) und werden vom GC-Template
|
||||
nur gerendert. Zieht der Resolver um, muss das Soll mit — sonst meldet die
|
||||
Compliance Drift, sobald das Geraet die neue Adresse traegt.
|
||||
|
||||
add <ip> [...] zwei Regeln (udp+tcp) fuer jede IP, direkt hinter den
|
||||
bestehenden AGH-Block einsortiert
|
||||
remove <ip> [...] genau diese zwei Regeln wieder entfernen (= Rueckbau)
|
||||
|
||||
WARUM KEIN `ingest-core-firewall.py`
|
||||
------------------------------------
|
||||
Das ist ein RE-BASELINE-Werkzeug: es liest den Ist-Stand vom Geraet und erklaert
|
||||
ihn zum Soll. Fuer eine *geplante* Aenderung ist es das falsche Werkzeug — es
|
||||
wuerde die Aenderung mit sich selbst absegnen. Hier wird das Soll explizit
|
||||
gesetzt, das Geraet zieht per `gc-configplan.py --job tp45-agh-fw` nach.
|
||||
|
||||
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 # Dry-Run
|
||||
python3 nautobot/tp45-agh-firewall.py add 10.40.20.30 10.40.20.31 --apply
|
||||
|
||||
REIHENFOLGE IST SEMANTISCH — die ComplianceRule steht auf `config_ordered`. Neue
|
||||
Regeln landen hinter der bestehenden `.37`-tcp-Zeile, also weiterhin VOR der
|
||||
`TP5: log-only`-Zeile und damit vor dem finalen `drop`.
|
||||
"""
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.154:8080")
|
||||
DEVICE = "crs326-core"
|
||||
COMMENT_UDP = "TP5-E2: LAN->AGH dns"
|
||||
COMMENT_TCP = "TP5-E2: LAN->AGH dns-tcp"
|
||||
ANCHOR = 'comment="TP5-E2: LAN->AGH dns-tcp" dst-address='
|
||||
|
||||
|
||||
def token():
|
||||
tok = os.environ.get("NAUTOBOT_TOKEN")
|
||||
if tok:
|
||||
return tok
|
||||
# Fallback: aus dem (gitignored) Stack-File, Wert wird nie ausgegeben.
|
||||
yml = (pathlib.Path(__file__).resolve().parent.parent
|
||||
/ "docs/nas-vlan20/stacks/210-nautobot.yml")
|
||||
for line in yml.read_text(errors="replace").splitlines():
|
||||
key, sep, val = line.strip().lstrip("- ").partition("=")
|
||||
if sep and key.strip() == "NAUTOBOT_SUPERUSER_API_TOKEN":
|
||||
return val.strip().strip("'\"")
|
||||
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt und nicht in 210-nautobot.yml")
|
||||
|
||||
|
||||
def nb(path, method="GET", payload=None):
|
||||
req = urllib.request.Request(
|
||||
f"{NB}{path}", method=method,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={"Authorization": f"Token {token()}", "Content-Type": "application/json",
|
||||
"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def rules_for(ip):
|
||||
# Attributreihenfolge alphabetisch, exakt wie RouterOS /export sie schreibt —
|
||||
# dieselbe Konvention wie ingest-core-firewall.py und das Template.
|
||||
return [
|
||||
f'add action=accept chain=forward comment="{COMMENT_UDP}" '
|
||||
f'dst-address={ip} dst-port=53 protocol=udp',
|
||||
f'add action=accept chain=forward comment="{COMMENT_TCP}" '
|
||||
f'dst-address={ip} dst-port=53 protocol=tcp',
|
||||
]
|
||||
|
||||
|
||||
def is_agh(rule, ip):
|
||||
return f"dst-address={ip}" in rule and "TP5-E2: LAN->AGH dns" in rule
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
if len(argv) < 3 or argv[0] not in ("add", "remove"):
|
||||
raise SystemExit(__doc__.strip().splitlines()[-1])
|
||||
mode = argv[0]
|
||||
ips = [a for a in argv[1:] if not a.startswith("--")]
|
||||
apply_ = "--apply" in argv
|
||||
|
||||
dev = nb(f"/api/dcim/devices/?name={DEVICE}")["results"][0]
|
||||
ctx = dict(dev.get("local_config_context_data") or {})
|
||||
lines = list(ctx.get("firewall_filter") or [])
|
||||
if not lines:
|
||||
raise SystemExit("firewall_filter ist leer — falsches Geraet?")
|
||||
|
||||
new = list(lines)
|
||||
for ip in ips:
|
||||
present = [l for l in new if is_agh(l, ip)]
|
||||
if mode == "add":
|
||||
if present:
|
||||
print(f" {ip}: schon vorhanden ({len(present)} Regeln) — uebersprungen")
|
||||
continue
|
||||
# hinter die letzte bestehende AGH-Zeile einsortieren
|
||||
idx = max((i for i, l in enumerate(new) if ANCHOR in l), default=None)
|
||||
if idx is None:
|
||||
raise SystemExit(f" kein AGH-Anker ({ANCHOR}...) im Soll gefunden")
|
||||
new[idx + 1:idx + 1] = rules_for(ip)
|
||||
print(f" {ip}: +2 Regeln nach Index {idx}")
|
||||
else:
|
||||
if len(present) != 2:
|
||||
raise SystemExit(f" {ip}: {len(present)} statt 2 Regeln gefunden — Abbruch")
|
||||
new = [l for l in new if not is_agh(l, ip)]
|
||||
print(f" {ip}: -2 Regeln entfernt")
|
||||
|
||||
if new == lines:
|
||||
print("\n = unveraendert, nichts zu tun.")
|
||||
return
|
||||
|
||||
print(f"\n== Diff ({len(lines)} -> {len(new)} Zeilen) ==")
|
||||
for d in difflib.unified_diff(lines, new, "IST", "SOLL", lineterm="", n=1):
|
||||
print(" " + d[:170])
|
||||
|
||||
if not apply_:
|
||||
print(f"\nDRY-RUN — mit --apply nach "
|
||||
f"local_config_context_data.firewall_filter schreiben.")
|
||||
return
|
||||
ctx["firewall_filter"] = new
|
||||
nb(f"/api/dcim/devices/{dev['id']}/", "PATCH", {"local_config_context_data": ctx})
|
||||
print("\n ~ geschrieben. Geraet zieht nach per: "
|
||||
"python3 nautobot/gc-configplan.py --job tp45-agh-fw --apply")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
131
nautobot/tp45-ip-move.py
Normal file
131
nautobot/tp45-ip-move.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""TP45 Schicht A — primaere IP eines Nautobot-Gastes umziehen.
|
||||
|
||||
WARUM ES DIESES SKRIPT GIBT
|
||||
---------------------------
|
||||
Nautobot verbietet das Aendern einer IP-Adresse nach dem Anlegen
|
||||
(`{"__all__": ["Host address cannot be changed once created"]}`). Ein Umzug
|
||||
ist deshalb kein PATCH, sondern ein Dreischritt je Gast:
|
||||
|
||||
1. neues ipam.IPAddress am Ziel anlegen (Status/Namespace vom Altobjekt)
|
||||
2. altes Objekt an die VM-Schnittstelle haengen und primary_ip4 umhaengen
|
||||
3. altes Objekt loeschen
|
||||
|
||||
Von Hand ist das pro Gast drei API-Aufrufe in genau dieser Reihenfolge — bei
|
||||
~15 Gaesten in Schicht A eine sichere Fehlerquelle. Reihenfolge ist zwingend:
|
||||
wer das Altobjekt vor dem Umhaengen loescht, reisst die primary_ip4-Referenz.
|
||||
|
||||
⚠️ Die IP allein ist NICHT der ganze Umzug. Ein Gast, den der Core ueber eine
|
||||
Firewall-Adressliste erreicht (z.B. `nautobot`), braucht die neue IP ZUERST in
|
||||
dieser Liste — sonst steht der Zugriff weg, bevor die Migration durch ist.
|
||||
|
||||
python3 nautobot/tp45-ip-move.py # Dry-Run (Default)
|
||||
python3 nautobot/tp45-ip-move.py --apply
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
BASE = "http://10.40.20.154:8080/api"
|
||||
APPLY = "--apply" in sys.argv
|
||||
|
||||
# (VM-Name, alte IP, neue IP) — Oktett-Klassen nach TP32, s. docs/teilprojekte.md ### TP45
|
||||
# Charge 1 (2026-09-21, angewendet). Die Altobjekte sind weg — ein erneuter Lauf
|
||||
# bricht darum sauber ab ("0 Treffer"), er zieht nichts doppelt um.
|
||||
MOVES = [
|
||||
("nautobot", "10.40.20.30/24", "10.40.20.154/24"),
|
||||
("nautobot-worker", "10.40.20.31/24", "10.40.20.155/24"),
|
||||
("nautobot-scheduler", "10.40.20.32/24", "10.40.20.156/24"),
|
||||
("nautobot-redis", "10.40.20.34/24", "10.40.20.158/24"),
|
||||
]
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
for line in YML.read_text().splitlines():
|
||||
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
raise SystemExit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML}")
|
||||
|
||||
|
||||
TOK = load_token()
|
||||
|
||||
|
||||
def api(path, method="GET", payload=None):
|
||||
req = urllib.request.Request(
|
||||
BASE + path, method=method,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={"Authorization": f"Token {TOK}", "Content-Type": "application/json",
|
||||
"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.load(r) if r.status != 204 else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"{method} {path} -> {e.code}: {e.read().decode()[:300]}")
|
||||
|
||||
|
||||
def only(path, expect=1):
|
||||
res = api(path)["results"]
|
||||
if len(res) != expect:
|
||||
raise SystemExit(f"{path}: {len(res)} Treffer, erwartet {expect}")
|
||||
return res[0] if expect == 1 else res
|
||||
|
||||
|
||||
def main():
|
||||
print(f"== TP45 IP-Move ({'APPLY' if APPLY else 'DRY-RUN'}) ==")
|
||||
for vm_name, old_ip, new_ip in MOVES:
|
||||
print(f"\n-- {vm_name}: {old_ip} -> {new_ip} --")
|
||||
old = only(f"/ipam/ip-addresses/?address={old_ip}")
|
||||
vm = only(f"/virtualization/virtual-machines/?name={vm_name}")
|
||||
|
||||
ref = vm.get("primary_ip4")
|
||||
ref = ref.get("id") if isinstance(ref, dict) else ref
|
||||
if ref != old["id"]:
|
||||
raise SystemExit(f" primary_ip4 von {vm_name} zeigt nicht auf {old_ip} "
|
||||
f"(ist {ref}) — Abbruch, nichts geschrieben")
|
||||
|
||||
if api(f"/ipam/ip-addresses/?address={new_ip}")["count"]:
|
||||
raise SystemExit(f" {new_ip} ist in Nautobot schon belegt — Abbruch")
|
||||
print(f" Altobjekt={old['id'][:8]} primary_ip4 bestaetigt Ziel frei")
|
||||
|
||||
if not APPLY:
|
||||
print(" [DRY-RUN] wuerde: anlegen -> eth0 verknuepfen -> primary_ip4 "
|
||||
"umhaengen -> Altobjekt loeschen")
|
||||
continue
|
||||
|
||||
# namespace ist am Altobjekt nicht serialisiert und beim Anlegen Pflicht
|
||||
ns = old.get("namespace")
|
||||
ns_id = (ns or {}).get("id") if isinstance(ns, dict) else ns
|
||||
if not ns_id:
|
||||
ns_id = only("/ipam/namespaces/?name=Global")["id"]
|
||||
st = old.get("status")
|
||||
new = api("/ipam/ip-addresses/", "POST",
|
||||
{"address": new_ip,
|
||||
"status": st["id"] if isinstance(st, dict) else st,
|
||||
"namespace": ns_id, "dns_name": ""})
|
||||
print(f" + angelegt {new['id'][:8]} @ {new['address']}")
|
||||
|
||||
vmif = api(f"/virtualization/interfaces/?virtual_machine={vm['id']}&name=eth0")["results"]
|
||||
if vmif:
|
||||
api("/ipam/ip-address-to-interface/", "POST",
|
||||
{"ip_address": new["id"], "vm_interface": vmif[0]["id"]})
|
||||
print(f" + verknuepft mit {vm_name}/eth0 ({vmif[0]['id'][:8]})")
|
||||
else:
|
||||
print(" ~ keine eth0-Schnittstelle — Verknuepfung uebersprungen")
|
||||
|
||||
api(f"/virtualization/virtual-machines/{vm['id']}/", "PATCH", {"primary_ip4": new["id"]})
|
||||
print(f" + primary_ip4 umgehaengt")
|
||||
|
||||
api(f"/ipam/ip-addresses/{old['id']}/", "DELETE")
|
||||
print(f" - Altobjekt {old_ip} geloescht")
|
||||
|
||||
print("\nFertig." if APPLY else "\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,7 +7,7 @@ import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
|
||||
NAUTOBOT_URL = "http://10.40.20.30:8080"
|
||||
NAUTOBOT_URL = "http://10.40.20.154:8080"
|
||||
TOKEN_PATH = pathlib.Path(__file__).parent.parent / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
OUT = pathlib.Path(__file__).with_name("tp15-core-cutover-intended.rsc")
|
||||
LEASES_RSC = pathlib.Path(__file__).with_name("ap1-leases-to-core.rsc")
|
||||
|
||||
Reference in New Issue
Block a user