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:
2026-08-30 17:54:07 +02:00
parent f7d26db7a6
commit 1006f91e6a
9 changed files with 509 additions and 31 deletions

View File

@@ -97,9 +97,22 @@ DEVICE_CHANNELS = {
# 5 GHz mit 40 MHz: ap1 ch36 belegt 5170-5210 (36+40), ap2 ch44 belegt 5210-5250
# (44+48) -> benachbart, ueberlappungsfrei, beide mit /I-Indoor-Freigabe und 18 dBm.
# ap2 war bis 2026-08-17 auf ch149 (5745, ETSI-Band C) und dort auf 9 dBm gedrosselt.
# 2,4 GHz: ch1/ch11 mit 20 MHz -> ueberlappungsfrei, ch6 bleibt fuer die Nachbarschaft frei.
# ap3 (cAP ax, seit 2026-08-30) uebernimmt in Technik UG ch36; ap1 steht bis zu seinem
# Umzug daneben und ist deshalb FUNKSTILL (keine Provisioning-Regel am Manager) --
# seine Kanaele bleiben hier stehen, weil sie beim Umzug wieder gebraucht werden.
# 2,4 GHz: mit ap3 auf ch6 ist 1/6/11 erstmals vollstaendig ueberlappungsfrei.
"ap1": {"5g": 36, "2g": 1},
"ap2": {"5g": 44, "2g": 11},
"ap3": {"5g": 36, "2g": 6},
}
# --- Per-Device: Abweichungen vom ap-common-Default -----------------------------------
# Nur eintragen, was wirklich abweicht. Nautobot merged Dicts TIEF, Listen ERSETZEND --
# eine leere Liste hier loescht den Default, genau das ist beim cAP ax gewollt.
DEVICE_OVERRIDES = {
# cAP ax hat nur ether1 (PoE-in) + ether2 (PoE-out); ether2 ist bewusst kein
# Bridge-Port. Es bleibt also KEIN zusaetzlicher Bridge-Port uebrig.
"ap3": {"extra_ports": []},
}
CTX_NAME = "ap-common"
@@ -201,14 +214,15 @@ def main():
if not d:
print(f" !! Device {dev} nicht gefunden")
continue
want = {"ap": {"channels": ch}}
ov = "".join(f" {k}={v}" for k, v in DEVICE_OVERRIDES.get(dev, {}).items())
want = {"ap": {"channels": ch, **DEVICE_OVERRIDES.get(dev, {})}}
if d.get("local_config_context_data") == want:
print(f" = {dev} 5g=ch{ch['5g']} 2g=ch{ch['2g']} (unveraendert)")
print(f" = {dev} 5g=ch{ch['5g']} 2g=ch{ch['2g']}{ov} (unveraendert)")
elif DRY:
print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']} (dry-run)")
print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']}{ov} (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']}")
print(f" ~ {dev} -> 5g=ch{ch['5g']} 2g=ch{ch['2g']}{ov}")
if DRY:
print("\nDRY-RUN - nichts geschrieben. Mit --apply ausfuehren.")

View 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()

View File

@@ -184,6 +184,12 @@
/interface wifi channel add band=5ghz-ax frequency=5220 name=ch-5g-ap2 width=20/40mhz
/interface wifi channel add band=2ghz-ax frequency=2412 name=ch-2g-ap1 width=20mhz
/interface wifi channel add band=2ghz-ax frequency=2462 name=ch-2g-ap2 width=20mhz
# ap3 (cAP ax, Technik UG) seit 2026-08-30. Kanaele aus dem TP25-Plan vom 2026-08-24:
# Band A (5150-5250) ist das einzige non-DFS-Band mit vollen 23 dBm; ap3 bekommt es, weil
# seine Flaeche von keinem anderen AP mitgetragen wird. 2437 = ch6 -> mit ap1 (ch1) und
# ap2 (ch11) ist 2,4 GHz erstmals vollstaendig ueberlappungsfrei.
/interface wifi channel add band=5ghz-ax frequency=5180 name=ch-5g-ap3 width=20/40mhz
/interface wifi channel add band=2ghz-ax frequency=2437 name=ch-2g-ap3 width=20mhz
/interface wifi datapath add bridge=bridge name=dp-main vlan-id=10
/interface wifi datapath add bridge=bridge name=dp-infra vlan-id=20
/interface wifi datapath add bridge=bridge name=dp-iot vlan-id=30
@@ -208,12 +214,22 @@
/interface wifi configuration add channel=ch-5g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-2g-ap1 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap1 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-2g-ap2 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap2 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-5g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-5g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add channel=ch-2g-ap3 country=Austria datapath=dp-main mode=ap name=cfg-main-2g-ap3 security=sec-main ssid=dirtyvader-main steering=st-main
/interface wifi configuration add country=Austria datapath=dp-guest mode=ap name=cfg-guest security=sec-guest ssid=dirtyvader-guest steering=st-guest
/interface wifi capsman set ca-certificate=auto certificate=auto enabled=yes interfaces=vlan99 upgrade-policy=none
/interface wifi provisioning add action=create-dynamic-enabled comment="ap1 5GHz" master-configuration=cfg-main-5g-ap1 radio-mac=D0:EA:11:4D:40:E5 slave-configurations=cfg-media,cfg-untrusted,cfg-guest
/interface wifi provisioning add action=create-dynamic-enabled comment="ap1 2.4GHz" master-configuration=cfg-main-2g-ap1 radio-mac=D0:EA:11:4D:40:E6 slave-configurations=cfg-iot,cfg-media,cfg-untrusted,cfg-guest
# 🔴 ap1 hat BEWUSST keine Provisioning-Zeile (2026-08-30). ap3 hat in Technik UG seinen
# Platz uebernommen und funkt dort auf 5180; ap1 steht bis zu seinem Umzug ans Sideboard
# direkt daneben und wuerde auf demselben Kanal kollidieren. Ohne passende Regel legt
# CAPsMAN fuer ein Radio gar nichts an -> es bleibt still, das Geraet aber erreichbar.
# Die Objekte `ch-5g-ap1`/`ch-2g-ap1` und `cfg-main-*-ap1` bleiben absichtlich stehen:
# beim Umzug bekommt ap1 laut TP25 `5260/Ce` (Band B, DFS) -- dann werden nur diese
# zwei Zeilen wieder eingesetzt und das Channel-Objekt auf eine Frequenz-LISTE gestellt
# (mit nur einem Wert waere eine Radar-Erkennung ein SPOF).
/interface wifi provisioning add action=create-dynamic-enabled comment="ap2 5GHz" master-configuration=cfg-main-5g-ap2 radio-mac=D0:EA:11:4D:40:3E slave-configurations=cfg-media,cfg-untrusted,cfg-guest
/interface wifi provisioning add action=create-dynamic-enabled comment="ap2 2.4GHz" master-configuration=cfg-main-2g-ap2 radio-mac=D0:EA:11:4D:40:3F slave-configurations=cfg-iot,cfg-media,cfg-untrusted,cfg-guest
/interface wifi provisioning add action=create-dynamic-enabled comment="ap3 5GHz" master-configuration=cfg-main-5g-ap3 radio-mac=D0:EA:11:FF:16:9F slave-configurations=cfg-media,cfg-untrusted,cfg-guest
/interface wifi provisioning add action=create-dynamic-enabled comment="ap3 2.4GHz" master-configuration=cfg-main-2g-ap3 radio-mac=D0:EA:11:FF:16:A0 slave-configurations=cfg-iot,cfg-media,cfg-untrusted,cfg-guest
# [6c] WLAN ACCESS-LIST — der Soll-Zustand ist LEER, und das ist eine Aussage.
#