diff --git a/README.md b/README.md index da5955f..79b032b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Windows-Desktopanwendung zum Synchronisieren virtueller Maschinen sowie HPE-/Aru - Durchsuchbare, nach Mandant filterbare Auswahl vorhandener NetBox-Geräte - Automatische Aktivierung von global deaktiviertem LLDP auf unterstützten Switches - 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 diff --git a/app.py b/app.py index 903f4c3..10a55ef 100644 --- a/app.py +++ b/app.py @@ -1095,13 +1095,17 @@ class NetBoxVMImporter: 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_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") result_headings = { "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: self.switch_result_tree.heading(column, text=result_headings[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']}: {len(snapshot['interfaces'])} Schnittstellen, " + f"{len(snapshot.get('ip_addresses', []))} IP-Adressen, " f"{len(snapshot['neighbors'])} LLDP-Nachbarn" ) except Exception as error: @@ -1424,6 +1429,7 @@ class NetBoxVMImporter: interface["name"], "Up" if interface.get("connected") else "Down", speed_text, + ", ".join(interface.get("ip_addresses", [])), neighbor.get("system_name", ""), neighbor.get("remote_port", ""), )) @@ -1435,7 +1441,7 @@ class NetBoxVMImporter: if self.nb is None and not self.load_switch_devices(): return 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 = [] for switch_id, snapshot in self.switch_snapshots.items(): switch = switch_map.get(switch_id) @@ -1453,7 +1459,7 @@ class NetBoxVMImporter: errors.extend(f"{switch['name']}: {error}" for error in result["errors"]) text = ( 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) if errors: diff --git a/dist/NetBox VM Import.exe b/dist/NetBox VM Import.exe index 871828b..7bd74dd 100644 Binary files a/dist/NetBox VM Import.exe and b/dist/NetBox VM Import.exe differ diff --git a/switch_sync.py b/switch_sync.py index b34865e..4e4d331 100644 --- a/switch_sync.py +++ b/switch_sync.py @@ -1,5 +1,6 @@ """SSH discovery and NetBox synchronization for HPE and Aruba switches.""" +import ipaddress import re import time @@ -13,11 +14,17 @@ PLATFORMS = { } 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"), "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 = { "aruba_cx": { "status": "show lldp configuration", @@ -110,10 +117,19 @@ def run_switch_commands(config): channel.send(interface_command + "\n") 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") 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 { "interfaces": interfaces, + "ipv4_interfaces": ip_outputs[0], + "ipv6_interfaces": ip_outputs[1], "neighbors": neighbors, "lldp_enabled_automatically": lldp_enabled_automatically, } @@ -147,7 +163,7 @@ def _interface_name(token): token = token.strip(" |:") return bool(re.match( 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, )) @@ -185,11 +201,77 @@ def parse_interfaces(output, platform): "mac_address": _normalize_mac(mac_match.group(0)) if mac_match else None, "description": description, "neighbor": None, + "ip_addresses": [], + "virtual": False, }) seen.add(name) 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): for pattern in patterns: match = re.search(pattern, block, flags=re.I | re.M) @@ -201,7 +283,7 @@ def _field(block, patterns): def parse_neighbors(output): clean = _clean_output(output) 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, )) if not starts: @@ -211,18 +293,23 @@ def parse_neighbors(output): end = starts[index + 1].start() if index + 1 < len(starts) else len(clean) block = clean[start.start():end] local_port = _field(block, ( + r"^\s*Port\s*:\s*(\S+)", r"^\s*Local (?:Port|Interface|Intf)\s*[: ]\s*(\S+)", r"^\s*LLDP neighbor-information of port\s+(?:\d+\[)?([^\]\s:]+)\]?", )) system_name = _field(block, ( + r"^\s*Neighbor Chassis-Name\s*:\s*(.+)$", r"^\s*(?:System Name|SysName)\s*[: ]\s*(.+)$", r"^\s*Neighbor name\s*[: ]\s*(.+)$", )) 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 Description|PortDesc)\s*[: ]\s*(.+)$", )) 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:.]+)", )) if local_port and (system_name or remote_port): @@ -268,21 +355,47 @@ def _parse_neighbor_table(output): def discover_switch(config): outputs = run_switch_commands(config) 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"]) - 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: - local = interface_map.get(neighbor["local_port"].casefold()) + local = interface_map.get(_interface_key(neighbor["local_port"])) if local: local["neighbor"] = neighbor return { "interfaces": interfaces, + "ip_addresses": interface_ips, "neighbors": neighbors, "raw": outputs, "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: return "other" 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): device_id = int(config["device_id"]) interface_records = {} - created = updated = cables = 0 + created = updated = cables = ip_addresses = 0 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"]: payload = { "device": device_id, "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), } if interface.get("description"): @@ -326,6 +441,35 @@ def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True): except Exception as 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: for neighbor in snapshot["neighbors"]: 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"{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, + }