5ffadfae17
Also fixes several LLDP parsing edge cases (blank-field regex bleed, endpoint-device filtering, HP ProCurve chassis ID format, Aruba port naming), corrects link dedup to prefer self-reported port names over neighbor guesses, and repoints NetBox sync at the new instance where devices are hand-curated rather than auto-created. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bp1HutYUh9fmTFgwB4qBLz
223 lines
8.9 KiB
Python
223 lines
8.9 KiB
Python
# parser.py - Parse LLDP output from FS, HP/Aruba ProCurve, and Dell switches
|
|
import re
|
|
|
|
|
|
def normalize_mac(mac_str):
|
|
"""Normalize any MAC format to XX-XX-XX-XX-XX-XX uppercase (e.g. 649d99-aa5100 → 64-9D-99-AA-51-00)."""
|
|
clean = re.sub(r'[:\-\.\s]', '', mac_str).upper()
|
|
if len(clean) != 12:
|
|
return mac_str.strip()
|
|
return '-'.join(clean[i:i+2] for i in range(0, 12, 2))
|
|
|
|
|
|
def port_number(iface):
|
|
"""Reduce any interface label to its trailing port number: 'GigabitEthernet1/9' -> '9', 'E14' -> '14', '9' -> '9'."""
|
|
m = re.search(r'(\d+)$', (iface or '').strip())
|
|
return m.group(1) if m else (iface or '').strip()
|
|
|
|
|
|
def parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgmt_ip):
|
|
"""
|
|
Parse raw 'show lldp neighbors' output from an FS switch.
|
|
|
|
Returns:
|
|
neighbors: list of dicts with parsed neighbor info
|
|
local_info: dict with this switch's details (enriched from LLDP data if needed)
|
|
"""
|
|
neighbors = []
|
|
|
|
# Split output into per-neighbor blocks (blank line separated)
|
|
# Each block starts with "Local Interface"
|
|
blocks = re.split(r'\n\s*\n', raw_output.strip())
|
|
|
|
for block in blocks:
|
|
if not block.strip():
|
|
continue
|
|
if 'Local Interface' not in block and 'Local Port' not in block:
|
|
continue
|
|
|
|
neighbor = {}
|
|
|
|
def extract(pattern, text, default=''):
|
|
m = re.search(pattern, text, re.IGNORECASE)
|
|
return m.group(1).strip() if m else default
|
|
|
|
# [ \t]* (not \s*) after the colon — a blank field followed immediately by
|
|
# the next field line must not let the match bleed across the newline into it.
|
|
neighbor['local_port'] = port_number(extract(r'Local Interface\s*:[ \t]*(.+)', block))
|
|
neighbor['chassis_id'] = extract(r'Chassis ID\s*:[ \t]*(.+)', block)
|
|
neighbor['port_id'] = extract(r'Port ID\s*:[ \t]*(.+)', block)
|
|
neighbor['port_desc'] = port_number(extract(r'Port Description\s*:[ \t]*(.+)', block))
|
|
neighbor['system_name'] = extract(r'System Name\s*:[ \t]*(.+)', block)
|
|
neighbor['system_desc'] = extract(r'System Description\s*:[ \t]*(.+)', block)
|
|
# FS switches report Management Address as MAC (e.g. '64-9D-99-AA-50-B0 (Other)')
|
|
# or as IP (e.g. '10.214.0.192'). Extract only valid IPv4.
|
|
raw_mgmt = extract(r'Management Address\s*:\s*([\d\.A-Fa-f\-:]+)', block)
|
|
ipv4_match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', raw_mgmt)
|
|
neighbor['mgmt_ip'] = ipv4_match.group(1) if ipv4_match else ''
|
|
neighbor['capabilities'] = extract(r'System Capabilities\s*:[ \t]*(.+)', block)
|
|
|
|
# Only include bridge/router neighbors — skip phones, APs, print servers,
|
|
# cameras, etc. that show up as "Station Only" or similar endpoint-only capabilities.
|
|
is_bridge_or_router = re.search(r'Bridge|Router', neighbor['capabilities'], re.IGNORECASE)
|
|
if neighbor['chassis_id'] and neighbor['system_name'] and is_bridge_or_router:
|
|
# Port ID is the mandatory, standardized LLDP field — prefer it. Port
|
|
# Description is optional free text; some vendors (e.g. Dell OS10) put
|
|
# human documentation there ("SUB-FAIR-SW01 9") that isn't a real port
|
|
# name and can collide across interfaces, so it's only a fallback.
|
|
neighbor['remote_port'] = port_number(neighbor['port_id']) if neighbor['port_id'] else neighbor['port_desc']
|
|
neighbors.append(neighbor)
|
|
|
|
return neighbors
|
|
|
|
|
|
def parse_aruba_procurve_local(raw_output):
|
|
"""Parse 'show lldp info local-device' from HP/Aruba switch into a dict."""
|
|
chassis_id = ''
|
|
system_name = ''
|
|
system_desc = ''
|
|
mgmt_ip = ''
|
|
|
|
for line in raw_output.splitlines():
|
|
m = re.search(r'Chassis Id\s*:\s*(.+)', line, re.IGNORECASE)
|
|
if m:
|
|
chassis_id = normalize_mac(m.group(1).strip())
|
|
m = re.search(r'System Name\s*:\s*(.+)', line, re.IGNORECASE)
|
|
if m:
|
|
system_name = m.group(1).strip()
|
|
m = re.search(r'System Description\s*:\s*(.+)', line, re.IGNORECASE)
|
|
if m:
|
|
system_desc = m.group(1).strip()
|
|
m = re.search(r'Address\s*:\s*([\d\.]+)', line, re.IGNORECASE)
|
|
if m and '.' in m.group(1):
|
|
mgmt_ip = m.group(1).strip()
|
|
|
|
return {'chassis_id': chassis_id, 'system_name': system_name,
|
|
'system_desc': system_desc, 'mgmt_ip': mgmt_ip}
|
|
|
|
|
|
|
|
# Older HP ProCurve firmware (e.g. 5412zl / J8698A) prints ChassisId as six
|
|
# space-separated hex octets ('64 9d 99 b8 f8 d0') instead of the single
|
|
# colon/dash-joined token newer Aruba switches use — match that form first,
|
|
# since a naive whitespace split misreads the octets as separate columns.
|
|
_SPACED_MAC_RE = re.compile(r'^((?:[0-9A-Fa-f]{2}\s+){5}[0-9A-Fa-f]{2})\s+(.*)$')
|
|
|
|
|
|
def parse_aruba_procurve_neighbors(raw_output):
|
|
"""
|
|
Parse 'show lldp info remote-device' tabular output from HP/Aruba switch.
|
|
Columns: LocalPort | ChassisId PortId PortDescr SysName
|
|
"""
|
|
neighbors = []
|
|
in_data = False
|
|
|
|
for line in raw_output.splitlines():
|
|
if re.match(r'\s*-+\s*\+', line):
|
|
in_data = True
|
|
continue
|
|
if not in_data or '|' not in line or not line.strip():
|
|
continue
|
|
|
|
left, right = line.split('|', 1)
|
|
# Not passed through port_number(): Aruba port names encode the module/slot
|
|
# (e.g. 'E14', 'F24') which is meaningful and must not be reduced to bare digits.
|
|
local_port = left.strip()
|
|
right = right.strip()
|
|
|
|
m = _SPACED_MAC_RE.match(right)
|
|
if m:
|
|
chassis_id = normalize_mac(m.group(1))
|
|
rest = m.group(2).split()
|
|
else:
|
|
parts = right.split()
|
|
if len(parts) < 4:
|
|
continue
|
|
chassis_id = normalize_mac(parts[0])
|
|
rest = parts[1:]
|
|
|
|
if len(rest) < 3:
|
|
continue
|
|
port_id = rest[0]
|
|
sys_name = rest[-1]
|
|
|
|
neighbors.append({
|
|
'local_port': local_port,
|
|
'chassis_id': chassis_id,
|
|
'port_id': port_id,
|
|
'port_desc': '',
|
|
'system_name': sys_name,
|
|
'system_desc': '',
|
|
'mgmt_ip': '',
|
|
'capabilities': '',
|
|
'remote_port': port_id,
|
|
})
|
|
|
|
return neighbors
|
|
|
|
|
|
def parse_neighbor_description(system_desc):
|
|
"""
|
|
Extract (model, firmware) from an LLDP system description string.
|
|
Handles Dell OS10, HP ProCurve, and Aruba ArubaOS-Switch descriptions.
|
|
Returns ('', '') if unrecognized.
|
|
"""
|
|
if not system_desc:
|
|
return '', ''
|
|
|
|
# Dell OS10: "...System Type: S4112F-ON...OS Version: 10.5.4.7..."
|
|
if 'Dell' in system_desc or 'OS10' in system_desc:
|
|
model_m = re.search(r'System Type\s*:\s*([\w\-]+)', system_desc)
|
|
fw_m = re.search(r'OS Version\s*:\s*([\d\s\.]+)', system_desc)
|
|
model = f"Dell {model_m.group(1)}" if model_m else 'Dell'
|
|
firmware = re.sub(r'\s+', '', fw_m.group(1)).strip('.') if fw_m else ''
|
|
return model, firmware
|
|
|
|
# HP ProCurve / Aruba ArubaOS-Switch:
|
|
# "Aruba JL258A 2930F-8G-PoE+-2SFP+ Switch, revision WC.16.10.0012, ROM ..."
|
|
# "HP J9576A 3800-24G-PoE+-2SFP+ Switch, revision K.16.02.0019, ROM ..."
|
|
if re.match(r'(?:Aruba|HP)\s', system_desc, re.IGNORECASE):
|
|
model_m = re.match(r'((?:Aruba|HP)\s+\S+\s+[\w\-\+]+)', system_desc, re.IGNORECASE)
|
|
fw_m = re.search(r'revision\s+(\S+?)(?:[,\s]|$)', system_desc)
|
|
model = model_m.group(1) if model_m else system_desc.split(',')[0]
|
|
firmware = fw_m.group(1) if fw_m else ''
|
|
return model, firmware
|
|
|
|
return '', ''
|
|
|
|
|
|
def parse_hostname_from_prompt(prompt_line):
|
|
"""Extract hostname from CLI prompt like 'ls-vhls-sw01#'"""
|
|
m = re.match(r'^([A-Za-z0-9_\-]+)[>#]', prompt_line.strip())
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def parse_mgmt_ip_from_interfaces(raw_output):
|
|
"""
|
|
Parse 'show ip interface brief' to find management IP.
|
|
Handles 'Vlan100' and 'Vlan 100' (space-separated) formats.
|
|
Prefers Vlan interfaces; falls back to Management interfaces.
|
|
"""
|
|
IPV4 = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')
|
|
fallback = None
|
|
for line in raw_output.splitlines():
|
|
line_stripped = line.strip()
|
|
if not line_stripped:
|
|
continue
|
|
# Match lines belonging to a Vlan or Management interface
|
|
iface_m = re.match(r'(Vlan|Management|Mgmt)\s*\S*', line_stripped, re.IGNORECASE)
|
|
if not iface_m:
|
|
continue
|
|
# Extract first valid IPv4 address anywhere on the line
|
|
ip_m = IPV4.search(line)
|
|
if not ip_m:
|
|
continue
|
|
ip = ip_m.group(1)
|
|
if ip.startswith('0.0.0.0') or ip.startswith('127.'):
|
|
continue
|
|
if re.match(r'Vlan', iface_m.group(1), re.IGNORECASE):
|
|
return ip
|
|
if not fallback:
|
|
fallback = ip
|
|
return fallback
|