TP28: ap3 (cAP ax) live als CAP, ap1 funkstill, Compliance 130/130 ueber 5 Geraete
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>
This commit is contained in:
226
nautobot/ingest-ap3-device.py
Normal file
226
nautobot/ingest-ap3-device.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user