TP5 Etappe 1: Log-Lauf live auf crs326-core
Log-only-Regel am Ende der forward-Kette (blockiert nichts) plus Syslog- Kollektor, ausgerollt ueber Nautobot GC als manual Config Plans. - routeros/tp5-etappe1-log-lauf.rsc: der ausgerollte Endzustand + die am Geraet belegten Lehren aus dem Rollout - nautobot/gc-manual-plan.py: Push-Werkzeug (assertet plan.device, liest Plan-Status statt Job-Status, strippt Kommentarzeilen) Zwei Korrekturen an bisherigen Annahmen: - Ein gescheiterter Netmiko-Deploy hinterlaesst einen TEIL-Zustand. Die TP22-Notiz "sauberer Fehlschlag" galt dem NAPALM-Weg. - Bindestriche in name= werden von RouterOS abgelehnt, Quoten hilft nicht. Die zuerst notierte target=/remote=-Erklaerung war falsch und ist raus. Die Log-Regel steht auf out-interface-list="!WAN": bare fing sie auch allen Internet-Verkehr. LAN->WAN ist eine garantierte Whitelist-Zeile, also Rauschen - und ein Merkposten fuer Etappe 3, wo der Drop aus demselben Grund nicht bare stehen darf. D2 entschieden: iPhone .30.201 wird ins MAIN-SSID umgehaengt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,7 +205,11 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung".
|
||||
- Abh.: TP1 ✅. TP3-Abhängigkeit entfällt (WLAN läuft weiterhin über FB bis TP3).
|
||||
- **Ermöglicht jetzt:** PVID-Fix in Omada-Profilen (MAIN→PVID10, INFRA→PVID20), untagNetworkIds-Bereinigung, DHCP-Reservierungen für NAS/macmini/pve auf neue VLAN-Subnetze migrieren.
|
||||
|
||||
### TP5 — Firewall & Inter-VLAN-Policy ▸ ⏳ TEIL-LIVE auf `crs326-core` (Holes + WAN-Seal re-verifiziert 2026-08-14; Deny-by-default steht aus) — **jetzt das nächste Arbeitspaket, Session vorbereitet** — 📋 `docs/tp5-deny-by-default-runbook.md`
|
||||
### TP5 — Firewall & Inter-VLAN-Policy ▸ ⏳ **ETAPPE 1 LÄUFT** (Log-Lauf seit 2026-08-14 live auf `crs326-core`) — 📋 `docs/tp5-deny-by-default-runbook.md`
|
||||
- ✅ **Etappe 1 ausgerollt 2026-08-14** (über Nautobot GC, manual Config Plans; Backups `core-pre-/post-tp5-etappe1-20260814.backup`). Auf dem Gerät: `forward`-Regel `action=log log-prefix="TP5-WOULD-DROP"` am Kettenende, Syslog-Action `tp5syslog` → **Kollektor `10.40.20.18:514`** (syslog-ng-Container `tp5-syslog` auf dem Docker-LXC, Logs `/opt/tp5-syslog/log/`, 10-Minuten-Cron kappt bei 500 MB — der LXC hat nur ~2,5 GB frei). `firewall` ist aus dem Memory-Ring genommen (`topics=info,!firewall`), sonst wäre der On-Box-Log tagelang unbrauchbar. **Blockiert nichts.** Artefakte: `routeros/tp5-etappe1-log-lauf.rsc`, Push-Werkzeug `nautobot/gc-manual-plan.py`. Compliance danach **16/17** (der eine Rotpunkt `ip-address` = bekannte VLAN1-Bring-up-IP `10.40.41.211` aus dem TP22-Backlog, **keine neue Drift**).
|
||||
- 🔴 **Fund aus dem Log-Lauf, der Etappe 3 bestimmt: die Regel fing anfangs auch allen Internet-Verkehr** (`out:vlan100`, ~¼ der Zeilen in einer ruhigen Minute, unter Last mehr). Die Log-Regel steht deshalb jetzt auf `out-interface-list="!WAN"`. **Konsequenz für den späteren Drop: er darf NICHT bare ans Kettenende** — davor gehört `accept out-interface-list=WAN`, sonst ist das ganze Haus offline. Diese Whitelist-Zeile ist damit bekannt und muss nicht aus dem Log wiederentdeckt werden.
|
||||
- 🔍 **Erste Befunde nach 60 s** (Rate nach Einengung ~166 Zeilen/min): **76 % des Inter-VLAN-Verkehrs ist `vlan20 → bridge`, also INFRA nach VLAN1** — ein Monitoring-Host `10.40.20.151` scrapet ~10 tote/alte `10.40.41.x`-Ziele, HA `.20.105` zieht auf `10.40.41.100:8006` (pve unter seiner **alten** VLAN1-Adresse, real `.20.100`), `.20.107` auf `10.40.41.10:8080`. **Das ist TP19-Material, das TP5 gerade sichtbar macht.** Weiter: `vlan20 → vlan99:8728` (MikroTik-API auf branch/ap1/ap2 = **der Management-Pfad selbst**, muss in die Whitelist, betrifft D4), `vlan20 → vlan30` (HA → Drucker `.30.5:631`, Shelly, Nuki `.30.244:8080`), `vlan10 ↔ vlan20` (Synology Drive `6690`, Apple-Continuity `udp/3722`).
|
||||
- ✅ **D2 entschieden (User 2026-08-14): iPhone wird ins MAIN-SSID umgehängt.** Faktenlage war: `10.40.30.201`, dynamische Lease, Hostname `iPhone`, MAC `12:99:A6:AC:B0:CB` = iOS Private Wi-Fi Address, aktiv auf `dirtyvader-iot` mit 748 MB rx. Die statische Reservierung `10.40.10.196` „iPhone" (andere private MAC — iOS vergibt **pro SSID** eine eigene) belegt, dass es dasselbe Telefon ist. **Umhängen ist die Hands-on-Aufgabe von Christoph** (SSID am Gerät wechseln + `dirtyvader-iot` vergessen lassen); technisches Argument: eine Whitelist auf eine rotierende Private MAC wäre ohnehin instabil.
|
||||
- 📋 **Session-Runbook liegt (2026-08-14): `docs/tp5-deny-by-default-runbook.md`** — Ist-Stand live erhoben, **beobachtete Inter-VLAN-Flow-Matrix** (conntrack + Counter), 6 offene Entscheidungen (D1–D6), 4 Etappen mit Absicherung, Verifikation + Rollback. **Kernansatz: Etappe 1 ist ein reiner Log-Lauf ohne Blockierung** (`action=log` ans Ende von `forward`) — die Whitelist wird aus dem Log abgeleitet, nicht am Schreibtisch geraten. Drei Funde, die vorab eine Entscheidung brauchen: ein **iPhone sitzt in VLAN30**, ein **ICMP-Dauersweep von `.10.82` auf alle Shellys**, und **vlan99 ist Mitglied der `LAN`-Liste** (→ `seal-lan` erlaubt Mgmt-Input aus jedem VLAN). **VLAN50/90 tragen null Inter-VLAN-Verkehr** → Internet-only-Pinning ist dort risikofrei.
|
||||
- **Deny-by-default + least-privilege** (Ziel). MAIN→INFRA nur Whitelist-Dienste; MAIN↔MEDIA allow (+mDNS); UNTRUSTED/GUEST nur Internet; MGMT isoliert; Router-input gehärtet.
|
||||
- ⚠️ **Gerätewechsel beachten (2026-08-14 nachgetragen):** der ✅-Block unten war ursprünglich **auf ap1** verifiziert (2026-07-02) — ap1 ist seit 2026-08-08 Werksreset und reiner AP. Die Regeln sind beim Core-Cutover (TP21.1) mitgewandert, waren am neuen Gerät aber **nie gegengeprüft**. Am **2026-08-14 auf `crs326-core` live nachverifiziert** (`list_firewall_rules`, chains `forward` 13 Regeln / `input` 12 Regeln): **alle unten gelisteten Regeln existieren, sind enabled, tragen ihre `TP5:`/`TP10-seal-`-Kommentare und zählen Traffic.** Merkregel: nach jedem Rollenwechsel eines Geräts gilt eine Verifikation vom Vorgänger nicht weiter.
|
||||
|
||||
@@ -89,7 +89,7 @@ Beispiel: `INFRA→MEDIA` hat 178.142 Pakete auf dem Zähler, stand aber im Snap
|
||||
| # | Frage | Vorschlag |
|
||||
|---|---|---|
|
||||
| **D1** | MAIN→INFRA: welche Dienste? | Beobachtet: NAS `6690`, pve-mcp `3000`, macmini `49152`, `3722`. Vorschlag: **erst mit dem Log-Lauf (Etappe 1) vervollständigen**, dann Whitelist — nicht jetzt raten. |
|
||||
| **D2** | iPhone `.30.201` in IOT | Ins MAIN-SSID holen (sauber) **oder** bewusst als Ausnahme dulden. Empfehlung: umhängen. |
|
||||
| **D2** | ~~iPhone `.30.201` in IOT~~ | ✅ **ENTSCHIEDEN (2026-08-14): ins MAIN-SSID umhängen.** Hands-on bei Christoph (SSID wechseln + `dirtyvader-iot` vergessen lassen). Beleg, dass es dasselbe Telefon ist: die statische Reservierung `10.40.10.196` „iPhone" trägt eine *andere* private MAC — iOS vergibt **pro SSID** eine eigene Private Address. Genau darum wäre eine Whitelist auf `.30.201` instabil. |
|
||||
| **D3** | ~~ICMP-Sweep `.10.82`→IOT~~ | ✅ **ENTSCHIEDEN (2026-08-14): `lanscan.app`, Ad-hoc vom Arbeitsplatz.** Keine Dauerregel — läuft über die **JIT-Admin-Gruppe** (selbstverfallend). Deckt sich mit D4. |
|
||||
| **D4** | Wer darf auf VLAN99-Mgmt? | Vorschlag: nur MAIN (`vlan10`), Rest drop. `seal-lan` entsprechend verengen. |
|
||||
| **D5** | IOT→INFRA-Whitelist | Doku nennt MQTT 1883/8883, HA-API, Influx 8086. Beobachtet real: **`8123` (HA), `5540/udp` (Matter)**. Vorschlag: die beobachteten + MQTT. |
|
||||
@@ -104,19 +104,69 @@ Beispiel: `INFRA→MEDIA` hat 178.142 Pakete auf dem Zähler, stand aber im Snap
|
||||
> Vor **jedem** Push `/system backup` auf dem Core (Regel 3). Und: **nur die Gegenprüfung am Gerät zählt**,
|
||||
> Job- und Plan-Status sind kein Wirksamkeitsnachweis (zweimal belegt, TP21.1/TP22).
|
||||
|
||||
### Etappe 1 — Log-Lauf (blockiert nichts, Dauer: einige Tage)
|
||||
#### Push-Werkzeug + Fallen (aus dem Etappe-1-Rollout, 2026-08-14 am Gerät belegt)
|
||||
|
||||
Eine einzige Regel **ans Ende von `forward`**, `action=log` statt `drop`:
|
||||
Werkzeug: `nautobot/gc-manual-plan.py --device <name> --commands-file <datei> --change-id <id> [--dry-run|--deploy]`.
|
||||
Es legt den manual Plan an, prüft `plan.device` gegen den Namen, gibt frei, deployt und liest
|
||||
**den Plan-Status** zurück (nicht den Job-Status). Kommentar-/Leerzeilen werden abgestreift.
|
||||
|
||||
1. 🔴 **Bindestriche in `name=` werden abgelehnt.** `add name="tp5-remote"` und `add name="tp5-abc"`
|
||||
werfen, `add name="tp5syslog"` geht — **Quoten hilft nicht.** Das hat vier Deploys gekostet, weil die
|
||||
naheliegende Erklärung („`remote=` darf nicht ins selbe `add` wie `target=remote`") *falsch* war:
|
||||
`set remote=… remote-port=…` in einem Statement läuft anstandslos. Bei neuen benannten Objekten
|
||||
also **Namen ohne Bindestrich** wählen, oder das Gegenteil erst beweisen.
|
||||
2. 🔴 **Ein gescheiterter Netmiko-Deploy hinterlässt einen TEIL-Zustand.** Die TP22-Notiz
|
||||
„gescheiterter Deploy = sauberer Fehlschlag" galt dem **NAPALM**-Weg, der scheiterte, *bevor* er das
|
||||
Gerät anfasste. Netmiko schickt Zeile für Zeile und lässt Abgesetztes stehen: der erste Push hier
|
||||
hinterließ Zeile 3+4 ohne 1+2. **Nach jedem Fehlschlag den Ist-Stand am Gerät prüfen.**
|
||||
3. **Keine `print`-Zeilen in einen Config Plan.** Ein `/system identity print` als Kanarienzeile hat die
|
||||
unmittelbar folgende Zeile verschluckt und eine Fehldiagnose erzeugt.
|
||||
4. **Selbstprotokollierende Pläne sind die Diagnosemethode.** Der Deploy-Job liefert nur
|
||||
`E1028: found bad/failed/failure in output` und zeigt den Geräte-Output **nicht**. Jedes Kommando in
|
||||
`:do {…} on-error={ :log info "…" }` wickeln und danach eine Zählzeile
|
||||
`:log info ("… count=" . [:tostr [:len [… find …]]])` setzen — anschließend per `get_log` zeilengenau
|
||||
auslesen. Damit ist Root-Cause-Analyse **ohne Direkt-Write** möglich.
|
||||
|
||||
### Etappe 1 — Log-Lauf (blockiert nichts, Dauer: einige Tage) ▸ ✅ **AUSGEROLLT 2026-08-14, läuft**
|
||||
|
||||
Ausgerollt über Nautobot GC (manual Config Plans, `nautobot/gc-manual-plan.py`), Quelle
|
||||
`routeros/tp5-etappe1-log-lauf.rsc`. Auf dem Gerät:
|
||||
|
||||
```
|
||||
/ip firewall filter add chain=forward action=log log-prefix="TP5-WOULD-DROP" comment="TP5: log-only, Vorstufe zum deny-by-default"
|
||||
/ip firewall filter add chain=forward action=log log-prefix="TP5-WOULD-DROP" \
|
||||
comment="TP5: log-only, Vorstufe zum deny-by-default" out-interface-list="!WAN"
|
||||
```
|
||||
|
||||
Das protokolliert exakt die Pakete, die der spätere Drop treffen würde — inklusive der Dinge, an die
|
||||
niemand denkt (Drucker-Discovery, Backup-Jobs, ein Gerät, das nur einmal pro Woche funkt). **Diese Liste
|
||||
ist die Whitelist**, nicht eine am Schreibtisch ausgedachte. Danach D1/D3/D5 final beantworten.
|
||||
ist die Whitelist**, nicht eine am Schreibtisch ausgedachte. Danach D1/D5 final beantworten.
|
||||
|
||||
⚠️ Log-Ziel vorher setzen (`/system logging`), sonst läuft nur der Speicher-Ringpuffer mit.
|
||||
**Log-Ziel steht:** syslog-ng-Container `tp5-syslog` auf dem Docker-LXC, **`10.40.20.18:514`**,
|
||||
Dateien unter `/opt/tp5-syslog/log/` (`tp5-would-drop.log` gefiltert + Host-Dateien).
|
||||
Auf dem Core: Action `tp5syslog` + `logging add topics=firewall action=tp5syslog`.
|
||||
Disk-Logging auf dem Core scheidet aus (**nur ~3 MB freier Flash**), der Memory-Ring (1000 Zeilen)
|
||||
wickelt sich in Minuten um. `firewall` ist aus dem Memory-Ring genommen (`topics=info,!firewall`),
|
||||
damit der On-Box-Log während des mehrtägigen Laufs für andere Fehlersuche brauchbar bleibt —
|
||||
**am Ende von Etappe 1 auf `topics=info` zurückbauen.**
|
||||
⚠️ Der LXC hat nur ~2,5 GB frei; `/usr/local/bin/tp5-log-guard.sh` (Cron, alle 10 min) kappt bei 500 MB
|
||||
je Datei per Truncate-in-place. Ein vollgelaufenes Dateisystem risse dort auch adguard/cloudflared mit.
|
||||
|
||||
#### 🔴 `out-interface-list="!WAN"` — warum die Regel nicht bare steht
|
||||
|
||||
Bare gefasst fing sie **auch den gesamten Internet-Verkehr** (`out:vlan100`): in einer ruhigen Minute
|
||||
242 Zeilen/min, davon ¼ WAN-bound, unter Last deutlich mehr. LAN→WAN ist aber eine **garantierte**
|
||||
Whitelist-Zeile — ohne sie hat kein VLAN Internet, auch die „Internet-only"-VLANs 50/90 nicht.
|
||||
Es wäre also Rauschen genau in der Matrix, die Etappe 2 ableiten soll.
|
||||
|
||||
**Merkposten für Etappe 3:** der Drop darf aus demselben Grund **nicht** bare ans Kettenende —
|
||||
`accept out-interface-list=WAN` gehört davor, sonst ist das ganze Haus offline. Diese Zeile ist
|
||||
bekannt und braucht keinen Beleg aus dem Log.
|
||||
|
||||
#### Auswertung
|
||||
|
||||
```bash
|
||||
ssh root@10.40.20.100 'pct exec 400 -- grep -o "in:vlan[0-9]* out:[a-z0-9]*" /opt/tp5-syslog/log/tp5-would-drop.log | sort | uniq -c | sort -rn'
|
||||
```
|
||||
|
||||
### Etappe 2 — Whitelists bauen (noch immer ohne Drop)
|
||||
|
||||
|
||||
197
nautobot/gc-manual-plan.py
Normal file
197
nautobot/gc-manual-plan.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Manuellen Golden-Config-Plan anlegen, freigeben und deployen.
|
||||
|
||||
Der von der Governance (TP22) vorgeschriebene Weg fuer Geraeteaenderungen:
|
||||
feature-/manual-scoped, **nie** "full intended" — ein Voll-Push schriebe
|
||||
`<<REDACTED>>`-Sektionen woertlich aufs Geraet (WLAN-PSKs, WG-Privkey).
|
||||
|
||||
python3 nautobot/gc-manual-plan.py --device crs326-core \
|
||||
--commands-file /pfad/zu/commands.rsc --change-id "TP5-Etappe1" --dry-run
|
||||
python3 nautobot/gc-manual-plan.py --device crs326-core \
|
||||
--commands-file /pfad/zu/commands.rsc --change-id "TP5-Etappe1" --deploy
|
||||
|
||||
Ablauf: Job "Generate Config Plans" (plan_type=manual) -> Plan auf Approved
|
||||
-> Job "Deploy Config Plans". Dazwischen wird jeweils auf den JobResult gewartet.
|
||||
|
||||
ZWEI TP22-LEHREN SIND HIER EINGEBAUT
|
||||
------------------------------------
|
||||
1. **Job-Status ist kein Wirksamkeitsnachweis.** Der Deploy-Job meldete schon
|
||||
SUCCESS, waehrend der Plan auf Failed stand. Das Skript liest darum nach dem
|
||||
Deploy den *Plan*-Status zurueck und richtet seinen Exit-Code danach.
|
||||
Die eigentliche Abnahme bleibt trotzdem die Gegenpruefung am Geraet.
|
||||
2. **Geraete werden einzeln per Name aufgeloest**, nie positional aus einer
|
||||
Sammelabfrage. Nautobot sortiert Sammelabfragen alphabetisch; eine positionale
|
||||
Zuordnung hat einen Deploy schon mal ans falsche Geraet geschickt.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
GEN_JOB = "f93bfa53-2332-4fa2-a6ab-5921c802fcee" # Generate Config Plans
|
||||
DEPLOY_JOB = "31c3f093-eeec-43fa-ba4a-0aed2f3c67aa" # Deploy Config Plans
|
||||
|
||||
|
||||
def token():
|
||||
t = os.environ.get("NAUTOBOT_TOKEN")
|
||||
if not t:
|
||||
raise SystemExit("NAUTOBOT_TOKEN nicht gesetzt "
|
||||
"(steht in docs/nas-vlan20/stacks/210-nautobot.yml, gitignored)")
|
||||
return t
|
||||
|
||||
|
||||
def api(path, method="GET", payload=None):
|
||||
req = urllib.request.Request(
|
||||
f"{NB}{path}",
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
method=method,
|
||||
headers={"Authorization": f"Token {token()}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.stderr.write(f"HTTP {e.code} auf {method} {path}\n{e.read().decode()[:2000]}\n")
|
||||
raise
|
||||
|
||||
|
||||
def resolve_device(name):
|
||||
"""Einzeln per Name — siehe Kopfkommentar, Lehre 2."""
|
||||
res = api(f"/api/dcim/devices/?name={name}")["results"]
|
||||
if len(res) != 1:
|
||||
raise SystemExit(f"Device {name!r}: {len(res)} Treffer statt genau 1")
|
||||
return res[0]["id"]
|
||||
|
||||
|
||||
def status_id(name):
|
||||
res = api(f"/api/extras/statuses/?content_types=nautobot_golden_config.configplan&name={name}")["results"]
|
||||
if len(res) != 1:
|
||||
raise SystemExit(f"ConfigPlan-Status {name!r}: {len(res)} Treffer statt genau 1")
|
||||
return res[0]["id"]
|
||||
|
||||
|
||||
def wait_job(job_result_id, label, timeout=900):
|
||||
"""Pollt bis der JobResult terminal ist. Gibt den finalen Status zurueck."""
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
jr = api(f"/api/extras/job-results/{job_result_id}/")
|
||||
st = (jr.get("status") or {}).get("value") or jr.get("status")
|
||||
if st != last:
|
||||
print(f" {label}: {st}")
|
||||
last = st
|
||||
if st in ("SUCCESS", "FAILURE", "ERRORED", "REVOKED"):
|
||||
return st
|
||||
time.sleep(5)
|
||||
raise SystemExit(f"{label}: Timeout nach {timeout}s")
|
||||
|
||||
|
||||
def job_log(job_result_id, levels=("error", "warning", "failure")):
|
||||
try:
|
||||
logs = api(f"/api/extras/job-log-entries/?job_result={job_result_id}&limit=200")["results"]
|
||||
except Exception:
|
||||
return
|
||||
for e in logs:
|
||||
if (e.get("log_level") or "").lower() in levels:
|
||||
print(f" [{e.get('log_level')}] {e.get('message')}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--device", required=True)
|
||||
p.add_argument("--commands-file", required=True)
|
||||
p.add_argument("--change-id", default="")
|
||||
p.add_argument("--change-url", default="")
|
||||
p.add_argument("--deploy", action="store_true",
|
||||
help="nach dem Anlegen freigeben und ausrollen")
|
||||
p.add_argument("--dry-run", action="store_true",
|
||||
help="nur zeigen, was gepusht wuerde — legt keinen Plan an")
|
||||
args = p.parse_args()
|
||||
|
||||
# Kommentar- und Leerzeilen fliegen raus: der Plan-config_set geht 1:1 per
|
||||
# netmiko an die RouterOS-Konsole, dort haben Doku-Zeilen nichts verloren.
|
||||
# Die kommentierte .rsc bleibt das lesbare Artefakt im Repo.
|
||||
raw = pathlib.Path(args.commands_file).read_text()
|
||||
commands = "\n".join(l for l in (x.rstrip() for x in raw.splitlines())
|
||||
if l.strip() and not l.lstrip().startswith("#"))
|
||||
if not commands:
|
||||
raise SystemExit("commands-file enthaelt keine Kommandozeilen")
|
||||
|
||||
print(f" Geraet: {args.device}")
|
||||
print(f" Change: {args.change_id or '(keine)'}")
|
||||
print(f" Kommandos ({len(commands.splitlines())} Zeilen):")
|
||||
for line in commands.splitlines():
|
||||
print(f" | {line}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n DRY-RUN — kein Plan angelegt, nichts gepusht.")
|
||||
return
|
||||
|
||||
device_id = resolve_device(args.device)
|
||||
print(f"\n Device-UUID: {device_id}")
|
||||
|
||||
known = {r["id"] for r in api(f"/api/plugins/golden-config/config-plan/?device_id={device_id}&limit=200")["results"]}
|
||||
|
||||
print(" -> Generate Config Plans (plan_type=manual)")
|
||||
run = api(f"/api/extras/jobs/{GEN_JOB}/run/", "POST", {"data": {
|
||||
"device": [device_id],
|
||||
"plan_type": "manual",
|
||||
"commands": commands,
|
||||
"change_control_id": args.change_id,
|
||||
"change_control_url": args.change_url,
|
||||
}})
|
||||
jr = run.get("job_result", run).get("id")
|
||||
st = wait_job(jr, "generate")
|
||||
job_log(jr)
|
||||
if st != "SUCCESS":
|
||||
raise SystemExit(f"Plan-Erzeugung fehlgeschlagen: {st}")
|
||||
|
||||
plans = api(f"/api/plugins/golden-config/config-plan/?device_id={device_id}&limit=200")["results"]
|
||||
new = [r for r in plans if r["id"] not in known]
|
||||
if len(new) != 1:
|
||||
raise SystemExit(f"Erwartet genau 1 neuen Plan, gefunden: {len(new)}")
|
||||
plan = new[0]
|
||||
print(f" Plan angelegt: {plan['id']}")
|
||||
|
||||
# Gegenprobe, dass der Plan wirklich an diesem Geraet haengt (Lehre 2).
|
||||
plan_dev = api(f"/api/plugins/golden-config/config-plan/{plan['id']}/?depth=1")["device"]
|
||||
assert plan_dev["name"] == args.device, f"Plan haengt an {plan_dev['name']}, nicht an {args.device}"
|
||||
print(f" Plan.device verifiziert: {plan_dev['name']}")
|
||||
|
||||
if not args.deploy:
|
||||
print("\n Plan liegt bereit (nicht freigegeben). Mit --deploy ausrollen.")
|
||||
return
|
||||
|
||||
print(" -> Plan auf Approved")
|
||||
api(f"/api/plugins/golden-config/config-plan/{plan['id']}/", "PATCH",
|
||||
{"status": status_id("Approved")})
|
||||
|
||||
print(" -> Deploy Config Plans")
|
||||
run = api(f"/api/extras/jobs/{DEPLOY_JOB}/run/", "POST", {"data": {
|
||||
"config_plan": [plan["id"]],
|
||||
"fail_job_on_task_failure": True,
|
||||
}})
|
||||
jr = run.get("job_result", run).get("id")
|
||||
st = wait_job(jr, "deploy")
|
||||
job_log(jr)
|
||||
|
||||
# Lehre 1: nicht dem Job glauben, den Plan-Status lesen.
|
||||
final = api(f"/api/plugins/golden-config/config-plan/{plan['id']}/?depth=1")
|
||||
plan_status = (final.get("status") or {}).get("name")
|
||||
print(f"\n Job-Status: {st}")
|
||||
print(f" Plan-Status: {plan_status} <- der zaehlt")
|
||||
if plan_status != "Completed":
|
||||
raise SystemExit(f"Deploy nicht sauber durch (Plan: {plan_status})")
|
||||
print("\n Plan Completed. Abnahme trotzdem erst nach Gegenpruefung am Geraet.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
routeros/tp5-etappe1-log-lauf.rsc
Normal file
74
routeros/tp5-etappe1-log-lauf.rsc
Normal file
@@ -0,0 +1,74 @@
|
||||
# TP5 Etappe 1 — Log-Lauf auf crs326-core. Blockiert NICHTS.
|
||||
# Ausgerollt 2026-08-14 ueber Nautobot GC als manual Config Plans (Governance TP22),
|
||||
# nicht per Direkt-Write. Backup davor: core-pre-tp5-etappe1-20260814.backup
|
||||
#
|
||||
# Diese Datei bildet den AUSGEROLLTEN Endzustand ab. Der Weg dorthin brauchte
|
||||
# mehrere Anlaeufe; die Lehren stehen unten, damit Etappe 2-4 sie nicht wiederholen.
|
||||
# Push: python3 nautobot/gc-manual-plan.py --device crs326-core \
|
||||
# --commands-file routeros/tp5-etappe1-log-lauf.rsc --change-id TP5-Etappe1 --deploy
|
||||
|
||||
# --- 1) Syslog-Ziel ---------------------------------------------------------
|
||||
# Der Core hat nur ~3 MB freien Flash -> Disk-Logging faellt aus (Platz + Flash-Verschleiss),
|
||||
# und der Memory-Ring fasst 1000 Zeilen, den wickelt der Log-Lauf in Minuten um.
|
||||
# Kollektor: syslog-ng auf dem Docker-LXC, 10.40.20.18:514 (Container `tp5-syslog`,
|
||||
# Config /opt/tp5-syslog/config/syslog-ng.conf, Logs /opt/tp5-syslog/log/).
|
||||
# ACHTUNG Name ohne Bindestrich — s. Lehre 1.
|
||||
:do { /system logging action add name="tp5syslog" target=remote } on-error={ :log info "tp5: action add hat geworfen" }
|
||||
:do { /system logging action set [find name="tp5syslog"] remote=10.40.20.18 remote-port=514 } on-error={ :log info "tp5: action set hat geworfen" }
|
||||
|
||||
# --- 2) firewall-Topic an den Kollektor -------------------------------------
|
||||
:do { /system logging add action="tp5syslog" topics=firewall } on-error={ :log info "tp5: logging rule hat geworfen" }
|
||||
|
||||
# --- 3) firewall aus dem Memory-Ring nehmen ---------------------------------
|
||||
# Sonst besteht der 1000-Zeilen-Puffer tagelang zu 100 % aus TP5-Zeilen und der
|
||||
# On-Box-Log ist fuer jede andere Fehlersuche wertlos — genau dann, wenn man ihn braucht.
|
||||
# Matcher live geprueft: topics=info trifft NICHT (Listen-Property), topics~"info" trifft genau 1.
|
||||
# Rueckbau am Ende von Etappe 1: topics=info
|
||||
/system logging set [find topics~"info" and action=memory] topics=info,!firewall
|
||||
|
||||
# --- 4) Die Log-Regel ans ENDE der forward-Kette ----------------------------
|
||||
# action=log ist nicht-terminierend: das Paket laeuft weiter und faellt danach aus
|
||||
# der Kette in die Default-Policy (accept). Es wird nichts blockiert.
|
||||
/ip firewall filter add action=log chain=forward comment="TP5: log-only, Vorstufe zum deny-by-default" log-prefix="TP5-WOULD-DROP"
|
||||
|
||||
# --- 5) Einengung auf Nicht-WAN --------------------------------------------
|
||||
# Der erste Lauf zeigte, dass die Regel auch saemtlichen Internet-Verkehr faengt
|
||||
# (out:vlan100) — bei 242 Zeilen/min war ein Viertel davon WAN-bound, unter Last mehr.
|
||||
# LAN->WAN ist eine GARANTIERTE Whitelist-Zeile (ohne sie hat kein VLAN Internet,
|
||||
# auch die "Internet-only"-VLANs 50/90 nicht), also reines Rauschen fuer die Matrix,
|
||||
# die Etappe 2 ableiten soll.
|
||||
# 🔴 MERKPOSTEN FUER ETAPPE 3: der spaetere drop DARF NICHT bare ans Kettenende —
|
||||
# davor gehoert `accept out-interface-list=WAN`, sonst ist das ganze Haus offline.
|
||||
# Diese Zeile ist bekannt und muss nicht aus dem Log wiederentdeckt werden.
|
||||
/ip firewall filter set [find comment="TP5: log-only, Vorstufe zum deny-by-default"] out-interface-list="!WAN"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LEHREN AUS DEM ROLLOUT (jede am Geraet belegt, nicht vermutet)
|
||||
# ===========================================================================
|
||||
#
|
||||
# 1) 🔴 BINDESTRICHE IN `name=` WERDEN ABGELEHNT — der teuerste Fund.
|
||||
# /system logging action add name="tp5-remote" target=remote -> wirft
|
||||
# /system logging action add name="tp5-abc" target=remote -> wirft
|
||||
# /system logging action add name="tp5syslog" target=remote -> geht
|
||||
# Quoten hilft NICHT. Isoliert per :do/on-error-Plan mit Zaehler-Checkpoints.
|
||||
# Zwischenzeitlich hatte ich das faelschlich der Reihenfolge `target=`/`remote=`
|
||||
# im selben add-Statement zugeschrieben — diese Erklaerung ist WIDERLEGT
|
||||
# (`set remote=... remote-port=...` in einem Statement laeuft anstandslos).
|
||||
#
|
||||
# 2) 🔴 EIN GESCHEITERTER NETMIKO-DEPLOY HINTERLAESST EINEN TEIL-ZUSTAND.
|
||||
# Die TP22-Notiz "gescheiterter Deploy = sauberer Fehlschlag" galt dem NAPALM-Weg,
|
||||
# der scheiterte, BEVOR er das Geraet anfasste. Netmiko schickt Zeile fuer Zeile
|
||||
# und laesst Abgesetztes stehen: der erste Push hier hinterliess Zeile 3+4 auf dem
|
||||
# Geraet, ohne 1+2. **Nach jedem Fehlschlag den Geraete-Ist-Stand pruefen.**
|
||||
#
|
||||
# 3) KEINE `print`-ZEILEN IN EINEN CONFIG PLAN. Ein `/system identity print` als
|
||||
# Kanarienzeile hat die unmittelbar folgende Zeile verschluckt und eine
|
||||
# Fehldiagnose erzeugt. Config Plans enthalten nur schreibende Kommandos.
|
||||
#
|
||||
# 4) SELBSTPROTOKOLLIERENDE PLAENE SIND DIE DIAGNOSE-METHODE DER WAHL.
|
||||
# Der Deploy-Job liefert nur "E1028: found bad/failed/failure in output" und
|
||||
# zeigt den Geraete-Output NICHT. Wickelt man jedes Kommando in
|
||||
# `:do {...} on-error={ :log info "..." }` und setzt danach eine Zaehlzeile
|
||||
# `:log info ("... count=" . [:tostr [:len [... find ...]]])`, liest man das
|
||||
# Ergebnis anschliessend per get_log zeilengenau aus — ohne Direkt-Write.
|
||||
Reference in New Issue
Block a user