Nimmt die 4 Live-Geräte (crs326-core/-branch, ap1, ap2) real unter Golden
Config: Backup -> Compliance -> intended -> kontrollierter Push. Damit greift
der Governance-Entscheid "Config exklusiv über Nautobot" operativ.
Secret-Leak geschlossen (Anlass, ungeplant gefunden)
Es gab keine ConfigReplace-Regeln, deshalb hatte der Backup-Job den
ap1-/export ungefiltert nach Gitea geschrieben: der live genutzte
WG-Hub-private-key und 6 WLAN-PSKs lagen dort im Klartext.
- nautobot/ingest-gc-scrubbing.py: 5 ConfigReplace-Regeln, Maske
"<<REDACTED>>". RouterOS quotet nur bei Bedarf (4 von 6 PSKs standen
unquotiert), daher Alternation mit der quotierten Variante zuerst --
\S+ ist greedy und würde bei einem PSK mit Leerzeichen den Rest
exponieren. Regex-Selbsttest läuft bei jedem Aufruf.
- Gitea-Repo neu angelegt (History-Rewrite verworfen: Gitea hält
unerreichbare Blobs bis zum server-seitigen gc). Verifiziert: 0
unmaskierte Secrets über alle 4 Geräte. User-Entscheid: nicht rotieren.
- push-gc-template.py: Pre-Push-Secret-Guard, --dry-run, -m, sha optional.
Template
- AP-Zweig (role == "Wireless AP"), datengetrieben aus ConfigContext
ap-common + local_config_context_data; ap1/ap2 differieren nur in 4
Werten. RadioProfile verworfen (hängt an einem Controller, den es seit
der CAPsMAN-Abschaffung nicht gibt).
- crs326-core-Zweig von Slash- auf /export-Form (Space-Form, Attribute
alphabetisch), stale disabled=yes aus der Cutover-Ära entfernt.
- Switch-Zweig für crs326-branch.
- Mgmt-Nummernplan korrigiert: ap2 und branch waren in den DNS-Statics
vertauscht, ap1 zeigte auf ein nicht existierendes .99.3.
Verbindlich: core .99.1 | branch .99.2 | ap1 .99.11 | ap2 .99.12.
Ergebnis: ap1 intended == Geräte-Ist (0 Drift), core 68/69 Zeilen
byte-identisch. 14 ComplianceRules, 51/56 compliant -- die 5 Restdrifts
sind echte Altlasten (Rettungspfade, Aufräumen gehört zu TP19).
Deploy (nautobot_config.py, Image gc9)
enable_plan/enable_deploy an. napalm-ros ist READ-ONLY: der Default-
Dispatcher wählt NAPALM (Platform trägt napalm: ros), Backup geht,
merge_config scheitert grundsätzlich mit leerer Fehlermeldung. Fix =
custom_dispatcher auf NetmikoMikrotikRouteros, in den Golden-Config-
Settings (nicht den Nornir-Settings). Pilot-Deploy: DNS-Statics auf dem
Core korrigiert, per Purge-vor-Add, weil plan_type=missing nur add
erzeugt und sonst Duplikate entstanden wären.
Governance-Regeln in docs/teilprojekte.md festgeschrieben: Pläne nur
feature-scoped (ein Voll-Push schriebe die Maske als PSK auf die APs),
AP-Template nie zeilenweise auf ein laufendes Gerät (vlan-filtering=no
reisst den Mgmt-Pfad ab).
CLAUDE.md: Power-Save-Fehldiagnose korrigiert
Der P750W war nicht im Power-Save, sondern schlicht ausgeschaltet; das
Ping-Symptom kam vom lokal mitlaufenden WARP-Client (cloudflared =
QUIC-Proxy, kein ICMP). Die Merkregel "DHCP-geht-aber-Ping-nicht =
ARP/Power-Save" ist damit widerlegt und entfernt; strukturell schliesst
TP16.1 diese ICMP-Lücke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
9.0 KiB
Python
204 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
r"""BF-Netzwerk -> Nautobot: ConfigContext fuer die Access Points (TP22 Schritt 1, 2026-08-13).
|
|
|
|
WOZU
|
|
----
|
|
Das Golden-Config-Template soll ap1 UND ap2 aus EINEM Zweig rendern. Beide unterscheiden
|
|
sich in exakt 4 Werten (identity, 5G-Kanal, 2.4G-Kanal, Mgmt-IP). identity und Mgmt-IP
|
|
stehen schon im DCIM-Modell (obj.name / obj.primary_ip4) - fehlen also nur die Kanaele,
|
|
plus die geteilte SSID-Tabelle.
|
|
|
|
DATENMODELL-ENTSCHEID (Alternativen bewusst verworfen):
|
|
- Wireless-Modelle (RadioProfile): haengen ueber ControllerManagedDeviceGroup an einem
|
|
Controller. Den gibt es hier bewusst NICHT mehr (CAPsMAN abgeschafft, TP3-Rebuild) ->
|
|
ein Fake-Controller nur als Aufhaenger waere Modellschuld.
|
|
- CustomFields: 2 Integer taeten es heute, skalieren aber nicht (jeder weitere AP-Knopf
|
|
= ein weiteres CF auf ALLEN Devices) und bieten keinen Platz fuer die SSID-Tabelle.
|
|
- Ableitung aus VAP-Descriptions: die SSID steht dort nur als Freitext -> stille Brueche.
|
|
=> ConfigContext: Nautobot-Idiom fuer Template-Variablen, keine Schema-Migration,
|
|
und der natuerliche Ort fuer spaetere SSIDs (z.B. TP13 "Botschafts-WLANs").
|
|
|
|
AUFTEILUNG
|
|
- ConfigContext "ap-common": Scope Role "Wireless AP" + Platform RouterOS. Traegt die
|
|
geteilte Wahrheit (zones/radios/site-Konstanten).
|
|
- Device.local_config_context_data je AP: nur die 2 Kanalnummern. Hoechste Praezedenz,
|
|
kein zusaetzliches ConfigContext-Objekt noetig.
|
|
|
|
MERGE-FALLE: Nautobot merged verschachtelte Dicts TIEF, Listen aber ERSETZEND. Der
|
|
per-Device-Teil haengt deshalb ausschliesslich neue Keys unter "ap" ein und fasst
|
|
zones/radios nie an.
|
|
|
|
CROSS-CHECK: die zones-Tabelle wird gegen die real modellierten VAP-Interfaces
|
|
(Name-Praefix + untagged_vlan) validiert. Divergenz => Abbruch. Damit koennen
|
|
ConfigContext und DCIM-Modell nicht auseinanderlaufen.
|
|
|
|
export NAUTOBOT_TOKEN=...
|
|
python3 nautobot/ingest-ap-configcontext.py # Dry-Run
|
|
python3 nautobot/ingest-ap-configcontext.py --apply # schreiben
|
|
"""
|
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
|
|
|
BASE = "http://10.40.20.30:8080/api"
|
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
|
DRY = "--apply" not in sys.argv
|
|
|
|
# --- Geteilte Wahrheit (Site-Konstanten + SSID-Tabelle) --------------------------------
|
|
# Quelle: echter gescrubbter /export von ap1 (routeros/state-backups/
|
|
# ap1-gc-backup-scrubbed-20260813.cfg) - NICHT die handgesetzte .rsc, deren
|
|
# Attributreihenfolge/Padding vom Export abweicht.
|
|
AP_COMMON = {
|
|
"ap": {
|
|
"country": "Austria",
|
|
"bridge": "bridge",
|
|
"uplink": "ether1", # PoE-Port, poe-out=off
|
|
"mgmt": {"vlan": 99, "gateway": "10.40.99.1"},
|
|
"native_vlan": 1,
|
|
# Liste (nicht Dict): Reihenfolge ist garantiert und diffbar.
|
|
"radios": [
|
|
{"key": "5g", "iface": "wifi1", "band": "5ghz-ax", "width": "20/40/80mhz", "prefix": "w5"},
|
|
{"key": "2g", "iface": "wifi2", "band": "2ghz-ax", "width": "20mhz", "prefix": "w2"},
|
|
],
|
|
# main laeuft auf den MASTER-Radios (2 configurations mit channel=),
|
|
# alle anderen als Slave-VAPs (1 configuration ohne channel=).
|
|
"zones": [
|
|
{"name": "main", "ssid": "dirtyvader-main", "vlan": 10, "bands": ["5g", "2g"],
|
|
"auth": "wpa3-psk", "ft": "yes", "ft_over_ds": "no", "master": True},
|
|
{"name": "infra", "ssid": "dirtyvader-infra", "vlan": 20, "bands": ["5g", "2g"],
|
|
"auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False},
|
|
{"name": "iot", "ssid": "dirtyvader-iot", "vlan": 30, "bands": ["2g"],
|
|
"auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False},
|
|
{"name": "media", "ssid": "dirtyvader-media", "vlan": 40, "bands": ["5g", "2g"],
|
|
"auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False},
|
|
{"name": "untrusted", "ssid": "dirtyvader-untrusted", "vlan": 50, "bands": ["5g", "2g"],
|
|
"auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False},
|
|
{"name": "guest", "ssid": "dirtyvader-guest", "vlan": 90, "bands": ["5g", "2g"],
|
|
"auth": "wpa2-psk,wpa3-psk", "ft": "no", "master": False},
|
|
],
|
|
}
|
|
}
|
|
|
|
# --- Per-Device: nur die Kanalnummern -------------------------------------------------
|
|
# MHz wird im Template gerechnet (5000+5*ch bzw. 2407+5*ch), nicht gespeichert:
|
|
# eine Quelle der Wahrheit, und kompatibel zu einer spaeteren RadioProfile-Migration.
|
|
# ap1 ch36 -> 5180, ch1 -> 2412
|
|
# ap2 ch149-> 5745, ch11 -> 2462
|
|
DEVICE_CHANNELS = {
|
|
"ap1": {"5g": 36, "2g": 1},
|
|
"ap2": {"5g": 149, "2g": 11},
|
|
}
|
|
|
|
CTX_NAME = "ap-common"
|
|
ROLE = "Wireless AP"
|
|
PLATFORM = "RouterOS"
|
|
|
|
|
|
def req(method, path, data=None):
|
|
url = path if path.startswith("http") else f"{BASE}{path}"
|
|
body = json.dumps(data).encode() if data is not None else None
|
|
r = urllib.request.Request(url, data=body, method=method)
|
|
r.add_header("Authorization", f"Token {TOKEN}")
|
|
r.add_header("Content-Type", "application/json")
|
|
r.add_header("Accept", "application/json")
|
|
try:
|
|
with urllib.request.urlopen(r, timeout=30) as resp:
|
|
raw = resp.read()
|
|
return json.loads(raw) if raw else {}
|
|
except urllib.error.HTTPError as e:
|
|
print(f"!! {method} {url}\n -> {e.code} {e.read().decode()[:400]}")
|
|
raise
|
|
|
|
|
|
def find(path, **params):
|
|
q = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items())
|
|
res = req("GET", f"{path}?{q}&limit=2")
|
|
return res["results"][0] if res.get("count") else None
|
|
|
|
|
|
def crosscheck():
|
|
"""zones gegen die real modellierten VAP-Interfaces validieren."""
|
|
problems = []
|
|
zones = {z["name"]: z for z in AP_COMMON["ap"]["zones"]}
|
|
radios = {r["key"]: r for r in AP_COMMON["ap"]["radios"]}
|
|
for dev in DEVICE_CHANNELS:
|
|
res = req("GET", f"/dcim/interfaces/?device={dev}&limit=60&depth=1")
|
|
ifaces = {i["name"]: i for i in res.get("results", [])}
|
|
for zname, z in zones.items():
|
|
for band in z["bands"]:
|
|
# main sitzt auf dem Master-Radio, nicht auf einem w5-/w2-Slave
|
|
iname = radios[band]["iface"] if z.get("master") else f"{radios[band]['prefix']}-{zname}"
|
|
i = ifaces.get(iname)
|
|
if not i:
|
|
problems.append(f"{dev}: Interface {iname} fehlt (zone {zname}/{band})")
|
|
continue
|
|
got = (i.get("untagged_vlan") or {}).get("vid")
|
|
if got != z["vlan"]:
|
|
problems.append(f"{dev}: {iname} untagged_vlan={got}, zones sagt {z['vlan']}")
|
|
return problems
|
|
|
|
|
|
def main():
|
|
if not TOKEN:
|
|
sys.exit("!! NAUTOBOT_TOKEN nicht gesetzt")
|
|
|
|
print("== Cross-Check zones <-> DCIM-VAP-Interfaces ==")
|
|
problems = crosscheck()
|
|
if problems:
|
|
print("!! Divergenz zwischen ConfigContext-Tabelle und DCIM-Modell - NICHTS geschrieben:")
|
|
for p in problems:
|
|
print(" -", p)
|
|
sys.exit(1)
|
|
nz = len(AP_COMMON["ap"]["zones"])
|
|
print(f" {nz} Zonen x {len(DEVICE_CHANNELS)} Geraete gegen DCIM verifiziert - konsistent\n")
|
|
|
|
role = find("/extras/roles", name=ROLE)
|
|
plat = find("/dcim/platforms", name=PLATFORM)
|
|
if not role or not plat:
|
|
sys.exit(f"!! Role {ROLE!r} oder Platform {PLATFORM!r} nicht gefunden")
|
|
|
|
print(f"== ConfigContext {CTX_NAME!r} (Scope: Role {ROLE} + Platform {PLATFORM}) ==")
|
|
payload = {
|
|
"name": CTX_NAME,
|
|
"weight": 1000,
|
|
"description": "Geteilte AP-Wahrheit fuer das Golden-Config-Template (TP22): SSID-/VLAN-Tabelle, Radios, Site-Konstanten. PSKs bewusst NICHT hier.",
|
|
"is_active": True,
|
|
"roles": [role["id"]],
|
|
"platforms": [plat["id"]],
|
|
"data": AP_COMMON,
|
|
}
|
|
ctx = find("/extras/config-contexts", name=CTX_NAME)
|
|
if ctx:
|
|
if ctx.get("data") == AP_COMMON:
|
|
print(" = ap-common (unveraendert)")
|
|
elif DRY:
|
|
print(" ~ ap-common data wuerde aktualisiert (dry-run)")
|
|
else:
|
|
req("PATCH", f"/extras/config-contexts/{ctx['id']}/", {"data": AP_COMMON, "weight": 1000})
|
|
print(" ~ ap-common data aktualisiert")
|
|
elif DRY:
|
|
print(" + ap-common (dry-run)")
|
|
else:
|
|
req("POST", "/extras/config-contexts/", payload)
|
|
print(" + ap-common")
|
|
|
|
print("\n== local_config_context_data je AP (Kanaele) ==")
|
|
for dev, ch in DEVICE_CHANNELS.items():
|
|
d = find("/dcim/devices", name=dev)
|
|
if not d:
|
|
print(f" !! Device {dev} nicht gefunden")
|
|
continue
|
|
want = {"ap": {"channels": ch}}
|
|
if d.get("local_config_context_data") == want:
|
|
print(f" = {dev} 5g=ch{ch['5g']} 2g=ch{ch['2g']} (unveraendert)")
|
|
elif DRY:
|
|
print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']} (dry-run)")
|
|
else:
|
|
req("PATCH", f"/dcim/devices/{d['id']}/", {"local_config_context_data": want})
|
|
print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']}")
|
|
|
|
if DRY:
|
|
print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|