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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+159
-9
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user