Compare commits
2 Commits
b52d6aa49e
...
974ecbc855
| Author | SHA1 | Date | |
|---|---|---|---|
| 974ecbc855 | |||
| 139d7c029d |
@@ -59,6 +59,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
BASE = "http://10.40.20.154:8080/api"
|
BASE = "http://10.40.20.154:8080/api"
|
||||||
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
||||||
@@ -160,8 +161,22 @@ def create_plans(device_names, config_set, change_control_id, dry):
|
|||||||
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
# Sammelabfrage zuordnen (Lehre aus TP21.1: REST sortiert alphabetisch).
|
||||||
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
q = urllib.parse.urlencode({"change_control_id": change_control_id, "depth": 1})
|
||||||
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
found = req("GET", f"/plugins/golden-config/config-plan/?{q}").get("results", [])
|
||||||
|
|
||||||
|
# Mehrere Plaene unter derselben ccid sind normal, sobald ein Job mit geaendertem
|
||||||
|
# Inhalt erneut laeuft -- die ccid benennt die Aenderung, nicht den Versuch.
|
||||||
|
# 🔴 Wer dann die REST-Reihenfolge entscheiden laesst, nimmt nondeterministisch
|
||||||
|
# den ALTEN Plan und laeuft in den config_set-Guard unten (am 2026-09-21 genau so
|
||||||
|
# passiert: Abbruch trotz korrekt erzeugtem neuen Plan). Der Job hat eben einen
|
||||||
|
# neuen Plan angelegt, also gilt der juengste -- die alten bleiben Historie.
|
||||||
|
def created(p):
|
||||||
|
raw = str(p.get("created") or "")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
except ValueError:
|
||||||
|
return datetime.min
|
||||||
|
|
||||||
by_name = {}
|
by_name = {}
|
||||||
for p in found:
|
for p in sorted(found, key=created):
|
||||||
name = (p.get("device") or {}).get("display")
|
name = (p.get("device") or {}).get("display")
|
||||||
by_name[name] = p["id"]
|
by_name[name] = p["id"]
|
||||||
|
|
||||||
@@ -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 = {
|
JOBS = {
|
||||||
"drift-cleanup": job_drift_cleanup,
|
"drift-cleanup": job_drift_cleanup,
|
||||||
"mgmt-acl": job_mgmt_acl,
|
"mgmt-acl": job_mgmt_acl,
|
||||||
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
"tp42-dhcp-domain": job_tp42_dhcp_domain,
|
||||||
"tp38-oob": job_tp38_oob,
|
"tp38-oob": job_tp38_oob,
|
||||||
|
"tp45-agh-fw": job_tp45_agh_fw,
|
||||||
|
"tp45-agh-dhcp": job_tp45_agh_dhcp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
/ip dhcp-server add address-pool=pool-v{{ v.id }} comment={{ v.name }} interface=vlan{{ v.id }} lease-time={{ v.lease }} name=dhcp-v{{ v.id }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for v in vlans %}
|
{% for v in vlans %}
|
||||||
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.36,10.40.20.37 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
/ip dhcp-server network add address=10.40.{{ v.id }}.0/24 dns-server=10.40.20.30,10.40.20.31 domain=bf.emsenhuber.net gateway=10.40.{{ v.id }}.1 ntp-server=10.40.{{ v.id }}.1
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
/ip dhcp-server option add code=6 name="dns 1.1.1.1" value=0x01010101
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
Reference in New Issue
Block a user