Oktett-Klasse .150-.199 (App-Services). Nautobot gibt damit .30/.31 an AGH frei. .157 ist gitea, deshalb nimmt redis .158. - Datenmigration ueber neues nautobot/tp45-ip-move.py: Nautobot verbietet das Aendern einer IP nach dem Anlegen -> anlegen/primary_ip4 umhaengen/ Altobjekt loeschen, in dieser Reihenfolge - Stack-Redeploy (PUT /api/stacks/203), Caddy-Backend, Core-Literal *24 ueber GC deployt (Plan 51a90e25) mit Wirksamkeitspruefung am Geraet - caddy/deploy.sh: push_verified() ersetzt den push||push-Fallback. `pct push` meldet Exit 0 AUCH wenn es die Quelle nicht lesen kann, der Fallback griff deshalb nie und `caddy validate` pruefte die ALTE Datei -> gruene Abnahme bei unveraendertem Zustand (Symptom: 502 bei gesundem Backend) - 39 Referenzen in 28 Dateien mitgezogen; lebende Doku nachgezogen (CLAUDE.md, ip-plan.md, cf-tunnel-mapping §0-Kopf, TP16.3/TP31-Prosa) - GC-Compliance 140/140, 0 Drifts Neue Funde (TP45-Prosa + CLAUDE.md): - Die Core-Adressliste `nautobot` wird von GC nirgends gerendert -> ein Recovery von null erzeugt einen Core, in den Nautobot nicht mehr per SSH kommt. Gleiche Klasse wie die fehlenden DHCP-Reservierungen. - Regel *24 (Literal) und *28 (Liste) sind Dubletten; der Literal steht bei 0 Paketen. Zusammenlegung bleibt offen. - gc-manual-plan.py: /api/extras/job-log-entries/ liefert 404 (Nautobot 3.x) -> bei gescheitertem Deploy fehlt die Fehlermeldung. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
227 lines
11 KiB
Python
227 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
r"""BF-Netzwerk -> Nautobot: ap3 (cAP ax) als Device anlegen. TP28-Nachtrag, 2026-08-30.
|
|
|
|
WOZU
|
|
----
|
|
ap3 ist der dritte Access Point und laeuft seit dem 2026-08-30 als reiner CAP unter
|
|
`crs326-core`. Damit Golden Config ihn rendern und pruefen kann, braucht er ein
|
|
DCIM-Modell: DeviceType, Device, Interfaces, Mgmt-IP.
|
|
|
|
MUSTER = ap1 (per GraphQL gelesen, nicht geraten -- `tagged_vlans` liest sich per REST
|
|
als null, das war schon in `ingest-ap-rebuild.py` eine Falle).
|
|
|
|
DELTAS ZU ap1/ap2 (hAP ax2), und beide sind der Grund fuer die ganze Uebung:
|
|
- cAP ax hat nur ZWEI Ethernet-Ports (ether1 PoE-in, ether2 PoE-out). Kein ether3/4/5.
|
|
Deshalb `ap.extra_ports = []` in local_config_context_data; das GC-Template rendert
|
|
die Bridge-Ports seit 2026-08-30 aus diesem Schluessel statt hartkodiert.
|
|
- ether2 ist bewusst KEIN Bridge-Port (Zielzustand wie ap2).
|
|
|
|
NEBENKORREKTUR: ap1 stand in Nautobot auf "BF -> Wohnzimmer Sideboard", steht physisch
|
|
aber in Technik UG (User-Bestaetigung 2026-08-30). Der Sideboard-Eintrag war der ZIEL-
|
|
Standort nach dem geplanten Umzug, nicht der Ist-Zustand. Wird hier mitkorrigiert; beim
|
|
tatsaechlichen Umzug wandert er zurueck auf Sideboard.
|
|
|
|
export NAUTOBOT_TOKEN=...
|
|
python3 nautobot/ingest-ap3-device.py # Dry-Run
|
|
python3 nautobot/ingest-ap3-device.py --apply # schreiben
|
|
"""
|
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
|
|
|
BASE = "http://10.40.20.154:8080/api"
|
|
TOKEN = os.environ.get("NAUTOBOT_TOKEN", "")
|
|
DRY = "--apply" not in sys.argv
|
|
|
|
DEV = "ap3"
|
|
MODEL = "cAP ax" # board-name, wie "hAP ax2" bei ap1/ap2
|
|
MANUFACTURER = "MikroTik"
|
|
LOCATION = "BF → Technik UG"
|
|
ROLE = "Wireless AP"
|
|
PLATFORM = "RouterOS"
|
|
MGMT_IP = "10.40.99.13/24"
|
|
TRUNK_VLANS = [10, 20, 30, 40, 50, 90, 99]
|
|
|
|
# Interfaces in Anlegereihenfolge: bridge zuerst (die anderen referenzieren sie).
|
|
# radio-mac steht in der description, weil die CAPsMAN-Provisionierung am Core genau
|
|
# daran haengt -- wer das Geraet tauscht, findet hier, was er am Manager nachziehen muss.
|
|
IFACES = [
|
|
("bridge", "bridge", None, None, [], None, None, "vlan-filtering=yes; VLAN1 untagged, 10-99 tagged"),
|
|
("ether1", "1000base-t", "tagged", 1, TRUNK_VLANS, None, None, "Uplink-Trunk (PoE-in) -> crs326-core ether21"),
|
|
("ether2", "1000base-t", None, None, [], None, None, "PoE-out, frei. KEIN Bridge-Port - cAP ax hat nur 2 Ports"),
|
|
("lo", "virtual", None, None, [], None, None, "loopback"),
|
|
("vlan99", "virtual", "tagged", None, [99], None, "bridge", "Mgmt SVI (VLAN99)"),
|
|
("wifi1", "ieee802.11ax", "access", 10, [], "bridge", None, "5GHz master - dirtyvader-main (radio-mac D0:EA:11:FF:16:9F)"),
|
|
("wifi2", "ieee802.11ax", "access", 10, [], "bridge", None, "2.4GHz master - dirtyvader-main (radio-mac D0:EA:11:FF:16:A0)"),
|
|
("w5-infra", "ieee802.11ax", "access", 20, [], "bridge", None, "5G slave - dirtyvader-infra"),
|
|
("w5-media", "ieee802.11ax", "access", 40, [], "bridge", None, "5G slave - dirtyvader-media"),
|
|
("w5-untrusted", "ieee802.11ax", "access", 50, [], "bridge", None, "5G slave - dirtyvader-untrusted"),
|
|
("w5-guest", "ieee802.11ax", "access", 90, [], "bridge", None, "5G slave - dirtyvader-guest"),
|
|
("w2-infra", "ieee802.11ax", "access", 20, [], "bridge", None, "2.4G slave - dirtyvader-infra"),
|
|
("w2-iot", "ieee802.11ax", "access", 30, [], "bridge", None, "2.4G slave - dirtyvader-iot (nur 2.4GHz)"),
|
|
("w2-media", "ieee802.11ax", "access", 40, [], "bridge", None, "2.4G slave - dirtyvader-media"),
|
|
("w2-untrusted", "ieee802.11ax", "access", 50, [], "bridge", None, "2.4G slave - dirtyvader-untrusted"),
|
|
("w2-guest", "ieee802.11ax", "access", 90, [], "bridge", None, "2.4G slave - dirtyvader-guest"),
|
|
]
|
|
|
|
|
|
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()[:500]}")
|
|
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 main():
|
|
if not TOKEN:
|
|
sys.exit("!! NAUTOBOT_TOKEN nicht gesetzt")
|
|
mode = "DRY-RUN" if DRY else "APPLY"
|
|
print(f"== ap3 -> Nautobot ({mode}) ==\n")
|
|
|
|
status_active = find("/extras/statuses", name="Active")
|
|
role = find("/extras/roles", name=ROLE)
|
|
plat = find("/dcim/platforms", name=PLATFORM)
|
|
loc = find("/dcim/locations", name="Technik UG")
|
|
manu = find("/dcim/manufacturers", name=MANUFACTURER)
|
|
# 🔴 OHNE SecretsGroup kein Backup. Das Geraet landet trotzdem in der GC-DynamicGroup,
|
|
# die intended config wird sauber gerendert -- nur der Backup-Job kann sich nicht
|
|
# anmelden, und ohne Backup entstehen KEINE Compliance-Eintraege. Die Gesamtzahl
|
|
# bleibt dann unauffaellig (104/104), weil ein Geraet ohne Ergebnisse auch keine
|
|
# Drift meldet. Aufgeschlagen am 2026-08-30 bei ap3.
|
|
secrets = find("/extras/secrets-groups", name="RouterOS Nornir")
|
|
for label, obj in [("Status Active", status_active), (f"Role {ROLE}", role),
|
|
(f"Platform {PLATFORM}", plat), ("Location Technik UG", loc),
|
|
(f"Manufacturer {MANUFACTURER}", manu),
|
|
("SecretsGroup 'RouterOS Nornir'", secrets)]:
|
|
if not obj:
|
|
sys.exit(f"!! {label} nicht gefunden")
|
|
vlans = {v["vid"]: v["id"] for v in req("GET", "/ipam/vlans/?limit=50")["results"]}
|
|
|
|
# --- DeviceType ---
|
|
dt = find("/dcim/device-types", model=MODEL)
|
|
if dt:
|
|
print(f" = DeviceType {MODEL!r}")
|
|
elif DRY:
|
|
print(f" + DeviceType {MODEL!r} (dry-run)")
|
|
else:
|
|
dt = req("POST", "/dcim/device-types/", {"manufacturer": manu["id"], "model": MODEL, "u_height": 0})
|
|
print(f" + DeviceType {MODEL!r}")
|
|
|
|
# --- Device ---
|
|
dev = find("/dcim/devices", name=DEV)
|
|
if dev:
|
|
print(f" = Device {DEV}")
|
|
elif DRY:
|
|
print(f" + Device {DEV} (dry-run)")
|
|
else:
|
|
dev = req("POST", "/dcim/devices/", {
|
|
"name": DEV, "device_type": dt["id"], "role": role["id"], "platform": plat["id"],
|
|
"location": loc["id"], "status": status_active["id"], "secrets_group": secrets["id"],
|
|
"comments": "cAP ax (cAPGi-5HaxD2HaxD), reiner CAP unter crs326-core. Bring-up 2026-08-30 (TP28-Nachtrag).",
|
|
})
|
|
print(f" + Device {DEV}")
|
|
|
|
if DRY and not dev:
|
|
print("\n (Interfaces/IP koennen im Dry-Run nicht geplant werden, solange das Device fehlt)")
|
|
print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.")
|
|
return
|
|
|
|
# SecretsGroup auch nachziehen, wenn das Device schon existierte
|
|
if dev and not DRY:
|
|
cur = req("GET", f"/dcim/devices/{dev['id']}/")
|
|
if (cur.get("secrets_group") or {}).get("id") != secrets["id"]:
|
|
req("PATCH", f"/dcim/devices/{dev['id']}/", {"secrets_group": secrets["id"]})
|
|
print(" ~ SecretsGroup 'RouterOS Nornir' gesetzt")
|
|
|
|
# --- Interfaces ---
|
|
print(f"\n Interfaces ({len(IFACES)}):")
|
|
have = {i["name"]: i for i in req("GET", f"/dcim/interfaces/?device={DEV}&limit=100")["results"]}
|
|
ids = {n: o["id"] for n, o in have.items()}
|
|
for name, typ, mode_, untag, tagged, bridge, parent, descr in IFACES:
|
|
payload = {
|
|
"device": dev["id"], "name": name, "type": typ, "status": status_active["id"],
|
|
"enabled": True, "description": descr,
|
|
}
|
|
if mode_:
|
|
payload["mode"] = mode_
|
|
if untag is not None:
|
|
payload["untagged_vlan"] = vlans[untag]
|
|
if tagged:
|
|
payload["tagged_vlans"] = [vlans[v] for v in tagged]
|
|
if bridge:
|
|
payload["bridge"] = ids.get(bridge)
|
|
if parent:
|
|
payload["parent_interface"] = ids.get(parent)
|
|
if name in have:
|
|
print(f" = {name}")
|
|
continue
|
|
if DRY:
|
|
print(f" + {name:<13} type={typ} mode={mode_} untag={untag} tagged={tagged} (dry-run)")
|
|
continue
|
|
new = req("POST", "/dcim/interfaces/", payload)
|
|
ids[name] = new["id"]
|
|
print(f" + {name:<13} type={typ} mode={mode_} untag={untag} tagged={tagged}")
|
|
|
|
# --- Mgmt-IP + primary_ip4 ---
|
|
print("\n Mgmt-IP:")
|
|
ip = find("/ipam/ip-addresses", address=MGMT_IP.split("/")[0])
|
|
if not ip:
|
|
if DRY:
|
|
print(f" + {MGMT_IP} (dry-run)")
|
|
else:
|
|
ns = find("/ipam/namespaces", name="Global")
|
|
ip = req("POST", "/ipam/ip-addresses/", {
|
|
"address": MGMT_IP, "status": status_active["id"], "type": "host",
|
|
"namespace": ns["id"], "description": "ap3 Mgmt (VLAN99)"})
|
|
print(f" + {MGMT_IP}")
|
|
else:
|
|
print(f" = {MGMT_IP}")
|
|
|
|
if not DRY and ip and ids.get("vlan99"):
|
|
assigned = req("GET", f"/ipam/ip-address-to-interface/?ip_address={ip['id']}&interface={ids['vlan99']}")
|
|
if assigned.get("count"):
|
|
print(" = Zuordnung vlan99")
|
|
else:
|
|
req("POST", "/ipam/ip-address-to-interface/", {"ip_address": ip["id"], "interface": ids["vlan99"]})
|
|
print(" + Zuordnung vlan99")
|
|
cur = req("GET", f"/dcim/devices/{dev['id']}/")
|
|
if (cur.get("primary_ip4") or {}).get("id") != ip["id"]:
|
|
req("PATCH", f"/dcim/devices/{dev['id']}/", {"primary_ip4": ip["id"]})
|
|
print(" ~ primary_ip4 gesetzt")
|
|
else:
|
|
print(" = primary_ip4")
|
|
|
|
# --- Nebenkorrektur ap1-Standort ---
|
|
print("\n Nebenkorrektur ap1-Standort:")
|
|
ap1 = find("/dcim/devices", name="ap1")
|
|
if ap1:
|
|
cur = req("GET", f"/dcim/devices/{ap1['id']}/?depth=1")
|
|
curloc = (cur.get("location") or {}).get("name")
|
|
if curloc == "Technik UG":
|
|
print(" = ap1 steht bereits auf Technik UG")
|
|
elif DRY:
|
|
print(f" ~ ap1: {curloc!r} -> 'Technik UG' (dry-run)")
|
|
else:
|
|
req("PATCH", f"/dcim/devices/{ap1['id']}/", {"location": loc["id"]})
|
|
print(f" ~ ap1: {curloc!r} -> 'Technik UG'")
|
|
|
|
if DRY:
|
|
print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|