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.
503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""SSH discovery and NetBox synchronization for HPE and Aruba switches."""
|
|
|
|
import ipaddress
|
|
import re
|
|
import time
|
|
|
|
import paramiko
|
|
|
|
|
|
PLATFORMS = {
|
|
"aruba_cx": "Aruba CX",
|
|
"aruba_aos": "ArubaOS-Switch / ProCurve",
|
|
"hpe_comware": "HPE Comware",
|
|
}
|
|
|
|
COMMANDS = {
|
|
"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",
|
|
"disabled": r"LLDP Enabled\s*:\s*No",
|
|
"enable": ("configure terminal", "lldp", "end"),
|
|
},
|
|
"aruba_aos": {
|
|
"status": "show lldp config",
|
|
"disabled": r"LLDP (?:Enabled|Run)\s*:\s*(?:No|False)|LLDP operation.*disabled",
|
|
"enable": ("configure terminal", "lldp run", "exit"),
|
|
},
|
|
"hpe_comware": {
|
|
"status": "display lldp status",
|
|
"disabled": r"Global status of LLDP\s*:\s*Disable",
|
|
"enable": ("system-view", "lldp global enable", "return"),
|
|
},
|
|
}
|
|
|
|
ANSI_ESCAPE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
MAC_PATTERN = re.compile(r"(?i)\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b|\b[0-9a-f]{4}(?:-[0-9a-f]{4}){2}\b")
|
|
|
|
|
|
def _clean_output(value):
|
|
value = ANSI_ESCAPE.sub("", value).replace("\r", "")
|
|
value = re.sub(r"--\s*MORE\s*--|Press any key to continue.*", "", value, flags=re.I)
|
|
return value
|
|
|
|
|
|
def _read_available(channel, quiet_seconds=0.35, timeout=12):
|
|
chunks = []
|
|
started = time.monotonic()
|
|
last_data = started
|
|
while time.monotonic() - started < timeout:
|
|
if channel.recv_ready():
|
|
chunks.append(channel.recv(65535).decode("utf-8", errors="replace"))
|
|
last_data = time.monotonic()
|
|
elif time.monotonic() - last_data >= quiet_seconds:
|
|
break
|
|
else:
|
|
time.sleep(0.05)
|
|
return _clean_output("".join(chunks))
|
|
|
|
|
|
def run_switch_commands(config):
|
|
platform = config["platform"]
|
|
if platform not in COMMANDS:
|
|
raise ValueError(f"Nicht unterstützte Switch-Plattform: {platform}")
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
try:
|
|
client.connect(
|
|
hostname=config["host"],
|
|
port=int(config.get("port", 22)),
|
|
username=config["username"],
|
|
password=config["password"],
|
|
look_for_keys=False,
|
|
allow_agent=False,
|
|
timeout=12,
|
|
auth_timeout=12,
|
|
banner_timeout=12,
|
|
)
|
|
channel = client.invoke_shell(width=240, height=1000)
|
|
_read_available(channel, timeout=3)
|
|
pager_command, interface_command, neighbor_command = COMMANDS[platform]
|
|
channel.send(pager_command + "\n")
|
|
_read_available(channel)
|
|
|
|
control = LLDP_CONTROL[platform]
|
|
channel.send(control["status"] + "\n")
|
|
lldp_status = _read_available(channel)
|
|
lldp_enabled_automatically = bool(re.search(control["disabled"], lldp_status, flags=re.I))
|
|
if lldp_enabled_automatically:
|
|
enable_output = []
|
|
for command in control["enable"]:
|
|
channel.send(command + "\n")
|
|
enable_output.append(_read_available(channel))
|
|
combined = "\n".join(enable_output)
|
|
if platform == "hpe_comware" and re.search(r"(?i)unrecognized|invalid|wrong parameter", combined):
|
|
channel.send("system-view\n")
|
|
_read_available(channel)
|
|
channel.send("lldp enable\n")
|
|
combined = _read_available(channel)
|
|
channel.send("return\n")
|
|
combined += "\n" + _read_available(channel)
|
|
if re.search(r"(?i)permission denied|authorization failed|access denied", combined):
|
|
raise PermissionError("LLDP konnte mangels Konfigurationsrechten nicht aktiviert werden.")
|
|
if re.search(r"(?i)unrecognized|invalid input|unknown command|wrong parameter", combined):
|
|
raise RuntimeError("Der LLDP-Aktivierungsbefehl wird von diesem Switch nicht unterstützt.")
|
|
time.sleep(2)
|
|
|
|
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,
|
|
}
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def _normalize_mac(value):
|
|
if not value:
|
|
return None
|
|
compact = re.sub(r"[^0-9a-fA-F]", "", value).upper()
|
|
if len(compact) != 12:
|
|
return None
|
|
return ":".join(compact[index:index + 2] for index in range(0, 12, 2))
|
|
|
|
|
|
def _speed_mbps(line):
|
|
matches = re.findall(r"(?i)\b(\d+(?:\.\d+)?)\s*(T|G|M)(?:b(?:it)?|bps)?\b", line)
|
|
if not matches:
|
|
duplex = re.search(r"(?i)\b(\d+)(?:FDx|HDx)\b", line)
|
|
if duplex:
|
|
return int(duplex.group(1))
|
|
trailing = re.search(r"\b(\d{2,6})\s*$", line)
|
|
return int(trailing.group(1)) if trailing else None
|
|
value, unit = matches[-1]
|
|
factor = {"M": 1, "G": 1000, "T": 1_000_000}[unit.upper()]
|
|
return int(float(value) * factor)
|
|
|
|
|
|
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+|Vlan-interface\d+|vlan\s*\d+|loopback\s*\d*|lo\d+|mgmt)$",
|
|
token,
|
|
))
|
|
|
|
|
|
def parse_interfaces(output, platform):
|
|
interfaces = []
|
|
seen = set()
|
|
for raw_line in _clean_output(output).splitlines():
|
|
line = raw_line.strip()
|
|
if not line or set(line) <= {"-", "=", " "}:
|
|
continue
|
|
tokens = re.split(r"\s+", line.replace("|", " | ").strip())
|
|
name_index = next((index for index, token in enumerate(tokens[:3]) if _interface_name(token)), None)
|
|
if name_index is None:
|
|
continue
|
|
name = tokens[name_index].strip(" |:")
|
|
if name in seen:
|
|
continue
|
|
lower = line.lower()
|
|
if not re.search(r"\b(up|down|administratively down|disabled|enabled)\b", lower):
|
|
continue
|
|
enabled = not bool(re.search(r"administratively down|admin.*down|disabled|\bno\s+(?:up|down)\b", lower))
|
|
connected = bool(re.search(r"\bup\b", lower)) and not bool(re.search(r"\bdown\b", lower))
|
|
mac_match = MAC_PATTERN.search(line)
|
|
description = ""
|
|
if platform == "aruba_aos" and "|" in raw_line:
|
|
columns = [column.strip() for column in raw_line.split("|")]
|
|
if len(columns) > 1:
|
|
description = columns[1]
|
|
interfaces.append({
|
|
"name": name,
|
|
"enabled": enabled,
|
|
"connected": connected,
|
|
"speed_mbps": _speed_mbps(line),
|
|
"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)
|
|
if match:
|
|
return match.group(1).strip()
|
|
return ""
|
|
|
|
|
|
def parse_neighbors(output):
|
|
clean = _clean_output(output)
|
|
starts = list(re.finditer(
|
|
r"(?im)^(?:\s*(?:Port|Local (?:Port|Interface|Intf)|LLDP neighbor-information of port)\s*[: ]\s*.+)$",
|
|
clean,
|
|
))
|
|
if not starts:
|
|
return _parse_neighbor_table(clean)
|
|
neighbors = []
|
|
for index, start in enumerate(starts):
|
|
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):
|
|
neighbors.append({
|
|
"local_port": local_port,
|
|
"system_name": system_name,
|
|
"remote_port": remote_port,
|
|
"management_ip": management_ip,
|
|
})
|
|
return neighbors
|
|
|
|
|
|
def _parse_neighbor_table(output):
|
|
neighbors = []
|
|
for raw_line in output.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or set(line) <= {"-", "=", " "}:
|
|
continue
|
|
tokens = re.split(r"\s+", line.replace("|", " | "))
|
|
if not tokens or not _interface_name(tokens[0]):
|
|
continue
|
|
if "|" in tokens:
|
|
separator = tokens.index("|")
|
|
remaining = tokens[separator + 1:]
|
|
if len(remaining) < 3:
|
|
continue
|
|
remote_port = remaining[1]
|
|
system_name = remaining[-1]
|
|
else:
|
|
if len(tokens) < 5:
|
|
continue
|
|
remote_port = tokens[2]
|
|
system_name = tokens[-1]
|
|
neighbors.append({
|
|
"local_port": tokens[0],
|
|
"system_name": system_name,
|
|
"remote_port": remote_port,
|
|
"management_ip": "",
|
|
})
|
|
return neighbors
|
|
|
|
|
|
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_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(_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, virtual=False):
|
|
if virtual:
|
|
return "virtual"
|
|
if not speed_mbps:
|
|
return "other"
|
|
if speed_mbps >= 100_000:
|
|
return "100gbase-x-qsfp28"
|
|
if speed_mbps >= 40_000:
|
|
return "40gbase-x-qsfpp"
|
|
if speed_mbps >= 25_000:
|
|
return "25gbase-x-sfp28"
|
|
if speed_mbps >= 10_000:
|
|
return "10gbase-x-sfpp"
|
|
if speed_mbps >= 1000:
|
|
return "1000base-t"
|
|
return "100base-tx"
|
|
|
|
|
|
def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True):
|
|
device_id = int(config["device_id"])
|
|
interface_records = {}
|
|
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"), interface.get("virtual", False)),
|
|
"enabled": interface.get("enabled", True),
|
|
}
|
|
if interface.get("description"):
|
|
payload["description"] = interface["description"][:200]
|
|
if interface.get("mac_address"):
|
|
payload["mac_address"] = interface["mac_address"]
|
|
try:
|
|
record = nb.dcim.interfaces.get(device_id=device_id, name=interface["name"])
|
|
if record:
|
|
record.update(payload)
|
|
updated += 1
|
|
else:
|
|
record = nb.dcim.interfaces.create(payload)
|
|
created += 1
|
|
interface_records[interface["name"].casefold()] = record
|
|
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:
|
|
local = interface_records.get(neighbor["local_port"].casefold())
|
|
if not local or getattr(local, "cable", None):
|
|
continue
|
|
remote_device = nb.dcim.devices.get(name=neighbor["system_name"])
|
|
if not remote_device:
|
|
continue
|
|
remote = nb.dcim.interfaces.get(device_id=remote_device.id, name=neighbor["remote_port"])
|
|
if not remote or getattr(remote, "cable", None):
|
|
continue
|
|
nb.dcim.cables.create({
|
|
"a_terminations": [{"object_type": "dcim.interface", "object_id": local.id}],
|
|
"b_terminations": [{"object_type": "dcim.interface", "object_id": remote.id}],
|
|
"status": "connected",
|
|
})
|
|
cables += 1
|
|
except Exception as error:
|
|
errors.append(
|
|
f"Nachbar {neighbor.get('local_port')} → {neighbor.get('system_name')} "
|
|
f"{neighbor.get('remote_port')}: {error}"
|
|
)
|
|
return {
|
|
"created": created,
|
|
"updated": updated,
|
|
"ip_addresses": ip_addresses,
|
|
"cables": cables,
|
|
"errors": errors,
|
|
}
|