Der dritte Access-Point laeuft: 10.40.99.13 auf vlan99, Uplink ether1 -> core ether21, reiner CAP unter crs326-core, Kanaele 5180/ax/Ce/I und 2437/ax (ch6) -- am Geraet nachgemessen, nicht aus dem Plan abgeschrieben. Der Kanalplan stammt aus TP25 vom 2026-08-24; mein erster Entwurf hatte ihn neu erfunden und waere bei ch1 statt ch6 gelandet. Lehre: den TP-Body lesen, nicht nur den Quick-Index. ap1 hat bewusst KEINE Provisioning-Regel mehr: es steht bis zu seinem Umzug ans Sideboard direkt neben ap3 und wuerde auf 5180 kollidieren. Geraet bleibt erreichbar, die Objekte ch-*-ap1 / cfg-main-*-ap1 bleiben fuer den Umzug stehen. Bring-up in zwei Phasen ueber ein Patchkabel ap3 ether2 -> core ether22, weil ueber ether1 nichts ging (s.u.). Loop-Schutz: ether21 war waehrend Phase 1 abgeschaltet und wurde im selben Befehl gegen ether22 getauscht. RouterOS 7.20.8 -> 7.24.1 plus RouterBOOT (zwei Reboots, TP27-Kriterium erfuellt). Die anderen vier stehen auf 7.24 -- die Angleichung ist ein eigener Durchgang, der Core-Reboot ist ein Standort-Ausfall. Nautobot: DeviceType "cAP ax", Device ap3 mit 16 Interfaces, primary_ip4, secrets_group, ConfigContext 5g=ch36 2g=ch6 extra_ports=[]. Nebenkorrektur: ap1 stand auf "Wohnzimmer Sideboard", steht physisch aber in Technik UG (User-Bestaetigung). Sechs Funde, alle als Gotcha in CLAUDE.md und im Handoff: - ether1 ist am Werksgeraet WAN: Discovery aus, Input-TCP verworfen, ICMP aber erlaubt. Es pingt, und das liest sich wie "erreichbar". MikroMCP hilft nicht -- Port 80 ist zu. - pvid ist Ingress, tagged/untagged ist Egress. Ein Lease hing ewig auf "offered", weil der ACK getaggt rausging und ein Werksgeraet das nicht versteht. - `cap enabled=yes` reicht nicht: jedes Master-Radio braucht configuration.manager=capsman, sonst bietet der CAP seine Radios gar nicht an -- state=Ok, radio-Tabelle leer. - Ein Geraet ohne secrets_group erzeugt eine LUECKE, keine Fehlermeldung: Backup scheitert still, keine Compliance-Eintraege, Zahl bleibt unauffaellig bei 104/104. Eine gleichbleibende Compliance-Zahl nach dem Hinzufuegen eines Geraets ist ein BEFUND. - CAPsMAN verteilt keine Login-Passwoerter, nur WLAN-PSKs. Der Geraete-Login ist eine lokale DB pro Geraet; das einheitliche Admin-PW ist eine Konvention von Hand. - Firewall-Regelnummern aus `print where` sind Handles, keine Positionen. Beweis ist der Zaehler, nicht die Zahl. Abnahme: 3 CAPs state=Ok, VAP-Satz vollstaendig und running, 20 IOT-Clients auf ap3 mit 0 % Ping-Verlust ueber VLAN30 (bewusst nicht ueber die Registration-Table abgenommen), Compliance 130/130 / 0 Drifts, Core-Temp-Pfad restlos abgeraeumt. Offen: TP25-Messrunde (braucht Rundgang), PW-Rotation TP30, MikroMCP-Neustart. Co-Authored-By: Claude Opus 5 <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.30: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()
|