# 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