feat: sync switch interface IP addresses

Read IPv4 and IPv6 addresses from Aruba CX, ArubaOS-Switch, and HPE Comware interfaces, display and assign them in NetBox, and add robust Aruba 6100 LLDP detail parsing with table fallback.
This commit is contained in:
2026-07-30 10:28:30 +02:00
parent 47e1d3499c
commit 10cc7b1196
4 changed files with 172 additions and 14 deletions
+2
View File
@@ -23,6 +23,8 @@ Windows-Desktopanwendung zum Synchronisieren virtueller Maschinen sowie HPE-/Aru
- Durchsuchbare, nach Mandant filterbare Auswahl vorhandener NetBox-Geräte - Durchsuchbare, nach Mandant filterbare Auswahl vorhandener NetBox-Geräte
- Automatische Aktivierung von global deaktiviertem LLDP auf unterstützten Switches - Automatische Aktivierung von global deaktiviertem LLDP auf unterstützten Switches
- Synchronisierung von Switch-Schnittstellen und optionalen LLDP-Verbindungen als NetBox-Kabel - Synchronisierung von Switch-Schnittstellen und optionalen LLDP-Verbindungen als NetBox-Kabel
- Auslesen und Synchronisieren vorhandener IPv4-/IPv6-Adressen auf Switch-Schnittstellen
- Spezieller LLDP-Detailparser mit Tabellen-Fallback für Aruba-6100-Switches
## Verwendung ## Verwendung
+11 -5
View File
@@ -1095,13 +1095,17 @@ class NetBoxVMImporter:
result_card = ttk.LabelFrame(main, text="Schnittstellen & LLDP-Nachbarn", style="Card.TLabelframe") result_card = ttk.LabelFrame(main, text="Schnittstellen & LLDP-Nachbarn", style="Card.TLabelframe")
result_card.grid(row=3, column=0, sticky="nsew", pady=(0, 10)) result_card.grid(row=3, column=0, sticky="nsew", pady=(0, 10))
result_columns = ("switch", "interface", "state", "speed", "neighbor", "remote_port") result_columns = ("switch", "interface", "state", "speed", "ips", "neighbor", "remote_port")
self.switch_result_tree = ttk.Treeview(result_card, columns=result_columns, show="headings") self.switch_result_tree = ttk.Treeview(result_card, columns=result_columns, show="headings")
result_headings = { result_headings = {
"switch": "Switch", "interface": "Schnittstelle", "state": "Status", "switch": "Switch", "interface": "Schnittstelle", "state": "Status",
"speed": "Geschwindigkeit", "neighbor": "LLDP-Nachbar", "remote_port": "Nachbar-Port", "speed": "Geschwindigkeit", "ips": "IP-Adressen",
"neighbor": "LLDP-Nachbar", "remote_port": "Nachbar-Port",
}
result_widths = {
"switch": 150, "interface": 140, "state": 70, "speed": 100,
"ips": 210, "neighbor": 170, "remote_port": 140,
} }
result_widths = {"switch": 180, "interface": 150, "state": 80, "speed": 110, "neighbor": 210, "remote_port": 170}
for column in result_columns: for column in result_columns:
self.switch_result_tree.heading(column, text=result_headings[column]) self.switch_result_tree.heading(column, text=result_headings[column])
self.switch_result_tree.column(column, width=result_widths[column]) self.switch_result_tree.column(column, width=result_widths[column])
@@ -1399,6 +1403,7 @@ class NetBoxVMImporter:
self.switch_log(f"{switch['name']}: LLDP war deaktiviert und wurde automatisch aktiviert") self.switch_log(f"{switch['name']}: LLDP war deaktiviert und wurde automatisch aktiviert")
self.switch_log( self.switch_log(
f"{switch['name']}: {len(snapshot['interfaces'])} Schnittstellen, " f"{switch['name']}: {len(snapshot['interfaces'])} Schnittstellen, "
f"{len(snapshot.get('ip_addresses', []))} IP-Adressen, "
f"{len(snapshot['neighbors'])} LLDP-Nachbarn" f"{len(snapshot['neighbors'])} LLDP-Nachbarn"
) )
except Exception as error: except Exception as error:
@@ -1424,6 +1429,7 @@ class NetBoxVMImporter:
interface["name"], interface["name"],
"Up" if interface.get("connected") else "Down", "Up" if interface.get("connected") else "Down",
speed_text, speed_text,
", ".join(interface.get("ip_addresses", [])),
neighbor.get("system_name", ""), neighbor.get("system_name", ""),
neighbor.get("remote_port", ""), neighbor.get("remote_port", ""),
)) ))
@@ -1435,7 +1441,7 @@ class NetBoxVMImporter:
if self.nb is None and not self.load_switch_devices(): if self.nb is None and not self.load_switch_devices():
return return
switch_map = {switch["id"]: switch for switch in self.current_switches} switch_map = {switch["id"]: switch for switch in self.current_switches}
totals = {"created": 0, "updated": 0, "cables": 0} totals = {"created": 0, "updated": 0, "ip_addresses": 0, "cables": 0}
errors = [] errors = []
for switch_id, snapshot in self.switch_snapshots.items(): for switch_id, snapshot in self.switch_snapshots.items():
switch = switch_map.get(switch_id) switch = switch_map.get(switch_id)
@@ -1453,7 +1459,7 @@ class NetBoxVMImporter:
errors.extend(f"{switch['name']}: {error}" for error in result["errors"]) errors.extend(f"{switch['name']}: {error}" for error in result["errors"])
text = ( text = (
f"{totals['created']} Schnittstellen erstellt, {totals['updated']} aktualisiert, " f"{totals['created']} Schnittstellen erstellt, {totals['updated']} aktualisiert, "
f"{totals['cables']} LLDP-Kabel angelegt." f"{totals['ip_addresses']} IP-Adressen synchronisiert, {totals['cables']} LLDP-Kabel angelegt."
) )
self.switch_log(text) self.switch_log(text)
if errors: if errors:
BIN
View File
Binary file not shown.
+159 -9
View File
@@ -1,5 +1,6 @@
"""SSH discovery and NetBox synchronization for HPE and Aruba switches.""" """SSH discovery and NetBox synchronization for HPE and Aruba switches."""
import ipaddress
import re import re
import time import time
@@ -13,11 +14,17 @@ PLATFORMS = {
} }
COMMANDS = { COMMANDS = {
"aruba_cx": ("no page", "show interface brief", "show lldp neighbor-info"), "aruba_cx": ("no page", "show interface brief", "show lldp neighbor-info detail"),
"aruba_aos": ("no page", "show interfaces brief", "show lldp info remote-device"), "aruba_aos": ("no page", "show interfaces brief", "show lldp info remote-device"),
"hpe_comware": ("screen-length disable", "display interface brief", "display lldp neighbor-information verbose"), "hpe_comware": ("screen-length disable", "display interface brief", "display lldp neighbor-information verbose"),
} }
IP_COMMANDS = {
"aruba_cx": ("show ip interface brief", "show ipv6 interface brief"),
"aruba_aos": ("show ip", "show ipv6"),
"hpe_comware": ("display ip interface brief", "display ipv6 interface brief"),
}
LLDP_CONTROL = { LLDP_CONTROL = {
"aruba_cx": { "aruba_cx": {
"status": "show lldp configuration", "status": "show lldp configuration",
@@ -110,10 +117,19 @@ def run_switch_commands(config):
channel.send(interface_command + "\n") channel.send(interface_command + "\n")
interfaces = _read_available(channel) interfaces = _read_available(channel)
ip_outputs = []
for command in IP_COMMANDS[platform]:
channel.send(command + "\n")
ip_outputs.append(_read_available(channel))
channel.send(neighbor_command + "\n") channel.send(neighbor_command + "\n")
neighbors = _read_available(channel) neighbors = _read_available(channel)
if platform == "aruba_cx" and re.search(r"(?i)invalid input|unknown command|unrecognized", neighbors):
channel.send("show lldp neighbor-info\n")
neighbors = _read_available(channel)
return { return {
"interfaces": interfaces, "interfaces": interfaces,
"ipv4_interfaces": ip_outputs[0],
"ipv6_interfaces": ip_outputs[1],
"neighbors": neighbors, "neighbors": neighbors,
"lldp_enabled_automatically": lldp_enabled_automatically, "lldp_enabled_automatically": lldp_enabled_automatically,
} }
@@ -147,7 +163,7 @@ def _interface_name(token):
token = token.strip(" |:") token = token.strip(" |:")
return bool(re.match( return bool(re.match(
r"(?i)^(?:\d+(?:/\d+){0,3}|(?:GE|XGE|HGE|FGE|FortyGigE|Ten-GigabitEthernet|GigabitEthernet)\S+|" r"(?i)^(?:\d+(?:/\d+){0,3}|(?:GE|XGE|HGE|FGE|FortyGigE|Ten-GigabitEthernet|GigabitEthernet)\S+|"
r"Bridge-Aggregation\d+|Trk\d+|lag\d+)$", r"Bridge-Aggregation\d+|Trk\d+|lag\d+|Vlan-interface\d+|vlan\s*\d+|loopback\s*\d*|lo\d+|mgmt)$",
token, token,
)) ))
@@ -185,11 +201,77 @@ def parse_interfaces(output, platform):
"mac_address": _normalize_mac(mac_match.group(0)) if mac_match else None, "mac_address": _normalize_mac(mac_match.group(0)) if mac_match else None,
"description": description, "description": description,
"neighbor": None, "neighbor": None,
"ip_addresses": [],
"virtual": False,
}) })
seen.add(name) seen.add(name)
return interfaces return interfaces
def _address_with_mask(address, mask=None):
try:
if "/" in address:
return str(ipaddress.ip_interface(address))
if mask:
return str(ipaddress.ip_interface(f"{address}/{mask}"))
except ValueError:
return None
return None
def parse_interface_ips(ipv4_output, ipv6_output, platform):
entries = []
if platform == "aruba_aos":
for line in _clean_output(ipv4_output).splitlines():
match = re.match(
r"^\s*([^|]+?)\s*\|\s*(?:Manual|DHCP/Bootp|Disabled)\s+(\d+(?:\.\d+){3})\s+(\d+(?:\.\d+){3})",
line,
flags=re.I,
)
if match:
address = _address_with_mask(match.group(2), match.group(3))
if address and not address.startswith("0.0.0.0/"):
entries.append({"interface": match.group(1).strip(), "address": address})
current_interface = ""
for line in _clean_output(ipv6_output).splitlines():
vlan_match = re.match(r"^\s*Vlan Name\s*:\s*(.+)$", line, flags=re.I)
if vlan_match:
current_interface = vlan_match.group(1).strip()
address_match = re.search(r"([0-9a-fA-F:]+/\d{1,3})", line)
if address_match and current_interface:
address = _address_with_mask(address_match.group(1))
if address and not address.lower().startswith("fe80:"):
entries.append({"interface": current_interface, "address": address})
else:
for output, version in ((ipv4_output, 4), (ipv6_output, 6)):
for line in _clean_output(output).splitlines():
tokens = re.split(r"\s+", line.strip())
if len(tokens) < 2 or not _interface_name(tokens[0]):
continue
patterns = (
r"\b(\d+(?:\.\d+){3}/\d{1,2})\b"
if version == 4 else
r"\b([0-9a-fA-F]*:[0-9a-fA-F:]+/\d{1,3})\b"
)
match = re.search(patterns, line)
if not match:
continue
address = _address_with_mask(match.group(1))
if not address or address.startswith("0.0.0.0/") or address.lower().startswith("fe80:"):
continue
entries.append({"interface": tokens[0], "address": address})
unique = {}
for entry in entries:
unique[(entry["interface"].casefold(), entry["address"])] = entry
return list(unique.values())
def _interface_key(value):
value = value.casefold().replace("_", "").replace("-", "").replace(" ", "")
value = value.replace("vlaninterface", "vlan")
return value
def _field(block, patterns): def _field(block, patterns):
for pattern in patterns: for pattern in patterns:
match = re.search(pattern, block, flags=re.I | re.M) match = re.search(pattern, block, flags=re.I | re.M)
@@ -201,7 +283,7 @@ def _field(block, patterns):
def parse_neighbors(output): def parse_neighbors(output):
clean = _clean_output(output) clean = _clean_output(output)
starts = list(re.finditer( starts = list(re.finditer(
r"(?im)^(?:\s*(?:Local (?:Port|Interface|Intf)|LLDP neighbor-information of port)\s*[: ]\s*.+)$", r"(?im)^(?:\s*(?:Port|Local (?:Port|Interface|Intf)|LLDP neighbor-information of port)\s*[: ]\s*.+)$",
clean, clean,
)) ))
if not starts: if not starts:
@@ -211,18 +293,23 @@ def parse_neighbors(output):
end = starts[index + 1].start() if index + 1 < len(starts) else len(clean) end = starts[index + 1].start() if index + 1 < len(starts) else len(clean)
block = clean[start.start():end] block = clean[start.start():end]
local_port = _field(block, ( local_port = _field(block, (
r"^\s*Port\s*:\s*(\S+)",
r"^\s*Local (?:Port|Interface|Intf)\s*[: ]\s*(\S+)", r"^\s*Local (?:Port|Interface|Intf)\s*[: ]\s*(\S+)",
r"^\s*LLDP neighbor-information of port\s+(?:\d+\[)?([^\]\s:]+)\]?", r"^\s*LLDP neighbor-information of port\s+(?:\d+\[)?([^\]\s:]+)\]?",
)) ))
system_name = _field(block, ( system_name = _field(block, (
r"^\s*Neighbor Chassis-Name\s*:\s*(.+)$",
r"^\s*(?:System Name|SysName)\s*[: ]\s*(.+)$", r"^\s*(?:System Name|SysName)\s*[: ]\s*(.+)$",
r"^\s*Neighbor name\s*[: ]\s*(.+)$", r"^\s*Neighbor name\s*[: ]\s*(.+)$",
)) ))
remote_port = _field(block, ( remote_port = _field(block, (
r"^\s*Neighbor Port-ID\s*:\s*(.+)$",
r"^\s*Neighbor Port-Desc\s*:\s*(.+)$",
r"^\s*(?:Port ID|PortId|Port ID subtype.*\n\s*Port ID)\s*[: ]\s*(.+)$", r"^\s*(?:Port ID|PortId|Port ID subtype.*\n\s*Port ID)\s*[: ]\s*(.+)$",
r"^\s*(?:Port Description|PortDesc)\s*[: ]\s*(.+)$", r"^\s*(?:Port Description|PortDesc)\s*[: ]\s*(.+)$",
)) ))
management_ip = _field(block, ( management_ip = _field(block, (
r"^\s*Neighbor Management-Address\s*:\s*([0-9a-fA-F:.]+)",
r"^\s*(?:Management Address|Management address|Management IP)\s*[: ]\s*([0-9a-fA-F:.]+)", r"^\s*(?:Management Address|Management address|Management IP)\s*[: ]\s*([0-9a-fA-F:.]+)",
)) ))
if local_port and (system_name or remote_port): if local_port and (system_name or remote_port):
@@ -268,21 +355,47 @@ def _parse_neighbor_table(output):
def discover_switch(config): def discover_switch(config):
outputs = run_switch_commands(config) outputs = run_switch_commands(config)
interfaces = parse_interfaces(outputs["interfaces"], config["platform"]) interfaces = parse_interfaces(outputs["interfaces"], config["platform"])
interface_ips = parse_interface_ips(
outputs.get("ipv4_interfaces", ""),
outputs.get("ipv6_interfaces", ""),
config["platform"],
)
neighbors = parse_neighbors(outputs["neighbors"]) neighbors = parse_neighbors(outputs["neighbors"])
interface_map = {interface["name"].casefold(): interface for interface in interfaces} interface_map = {_interface_key(interface["name"]): interface for interface in interfaces}
for item in interface_ips:
key = _interface_key(item["interface"])
interface = interface_map.get(key)
if not interface:
interface = {
"name": item["interface"],
"enabled": True,
"connected": True,
"speed_mbps": None,
"mac_address": None,
"description": "Logisches Layer-3-Interface",
"neighbor": None,
"ip_addresses": [],
"virtual": True,
}
interfaces.append(interface)
interface_map[key] = interface
interface["ip_addresses"].append(item["address"])
for neighbor in neighbors: for neighbor in neighbors:
local = interface_map.get(neighbor["local_port"].casefold()) local = interface_map.get(_interface_key(neighbor["local_port"]))
if local: if local:
local["neighbor"] = neighbor local["neighbor"] = neighbor
return { return {
"interfaces": interfaces, "interfaces": interfaces,
"ip_addresses": interface_ips,
"neighbors": neighbors, "neighbors": neighbors,
"raw": outputs, "raw": outputs,
"lldp_enabled_automatically": outputs.get("lldp_enabled_automatically", False), "lldp_enabled_automatically": outputs.get("lldp_enabled_automatically", False),
} }
def _netbox_interface_type(speed_mbps): def _netbox_interface_type(speed_mbps, virtual=False):
if virtual:
return "virtual"
if not speed_mbps: if not speed_mbps:
return "other" return "other"
if speed_mbps >= 100_000: if speed_mbps >= 100_000:
@@ -301,13 +414,15 @@ def _netbox_interface_type(speed_mbps):
def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True): def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True):
device_id = int(config["device_id"]) device_id = int(config["device_id"])
interface_records = {} interface_records = {}
created = updated = cables = 0 created = updated = cables = ip_addresses = 0
errors = [] errors = []
device = nb.dcim.devices.get(id=device_id)
tenant_id = getattr(getattr(device, "tenant", None), "id", None) if device else None
for interface in snapshot["interfaces"]: for interface in snapshot["interfaces"]:
payload = { payload = {
"device": device_id, "device": device_id,
"name": interface["name"], "name": interface["name"],
"type": _netbox_interface_type(interface.get("speed_mbps")), "type": _netbox_interface_type(interface.get("speed_mbps"), interface.get("virtual", False)),
"enabled": interface.get("enabled", True), "enabled": interface.get("enabled", True),
} }
if interface.get("description"): if interface.get("description"):
@@ -326,6 +441,35 @@ def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True):
except Exception as error: except Exception as error:
errors.append(f"Interface {interface['name']}: {error}") errors.append(f"Interface {interface['name']}: {error}")
for interface in snapshot["interfaces"]:
record = interface_records.get(interface["name"].casefold())
if not record:
continue
for address in interface.get("ip_addresses", []):
try:
host = str(ipaddress.ip_interface(address).ip)
candidates = [
item for item in nb.ipam.ip_addresses.filter(address=host)
if getattr(getattr(item, "vrf", None), "id", None) is None
]
if len(candidates) > 1:
raise ValueError("mehrere globale IP-Adressen mit diesem Host gefunden")
payload = {
"address": address,
"status": "active",
"assigned_object_type": "dcim.interface",
"assigned_object_id": record.id,
}
if tenant_id:
payload["tenant"] = tenant_id
if candidates:
candidates[0].update(payload)
else:
nb.ipam.ip_addresses.create(payload)
ip_addresses += 1
except Exception as error:
errors.append(f"IP-Adresse {address} auf {interface['name']}: {error}")
if create_cables: if create_cables:
for neighbor in snapshot["neighbors"]: for neighbor in snapshot["neighbors"]:
try: try:
@@ -349,4 +493,10 @@ def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True):
f"Nachbar {neighbor.get('local_port')}{neighbor.get('system_name')} " f"Nachbar {neighbor.get('local_port')}{neighbor.get('system_name')} "
f"{neighbor.get('remote_port')}: {error}" f"{neighbor.get('remote_port')}: {error}"
) )
return {"created": created, "updated": updated, "cables": cables, "errors": errors} return {
"created": created,
"updated": updated,
"ip_addresses": ip_addresses,
"cables": cables,
"errors": errors,
}