Add SEC/AMI scans, scan abort, connection test, runtime credentials, serial tracking

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
This commit is contained in:
dcstephenson
2026-07-24 10:40:55 -05:00
parent 3a93c6d6ed
commit 5ffadfae17
7 changed files with 583 additions and 118 deletions
+48 -32
View File
@@ -10,21 +10,10 @@ def normalize_mac(mac_str):
return '-'.join(clean[i:i+2] for i in range(0, 12, 2))
def shorten_interface(iface):
"""GigabitEthernet 1/9 -> Gi1/9, TenGigabitEthernet 1/1 -> Te1/1 etc."""
replacements = [
(r'GigabitEthernet\s*', 'Gi'),
(r'TenGigabitEthernet\s*', 'Te'),
(r'TwentyFiveGigE\s*', 'Twe'),
(r'FortyGigabitEthernet\s*', 'Fo'),
(r'HundredGigE\s*', 'Hu'),
(r'FastEthernet\s*', 'Fa'),
(r'Ethernet\s*', 'Eth'),
(r'mgmt\s*', 'mgmt'),
]
for pattern, short in replacements:
iface = re.sub(pattern, short, iface, flags=re.IGNORECASE)
return iface.strip()
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):
@@ -53,23 +42,30 @@ def parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgm
m = re.search(pattern, text, re.IGNORECASE)
return m.group(1).strip() if m else default
neighbor['local_port'] = shorten_interface(extract(r'Local Interface\s*:\s*(.+)', block))
neighbor['chassis_id'] = extract(r'Chassis ID\s*:\s*(.+)', block)
neighbor['port_id'] = extract(r'Port ID\s*:\s*(.+)', block)
neighbor['port_desc'] = shorten_interface(extract(r'Port Description\s*:\s*(.+)', block))
neighbor['system_name'] = extract(r'System Name\s*:\s*(.+)', block)
neighbor['system_desc'] = extract(r'System Description\s*:\s*(.+)', block)
# [ \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*:\s*(.+)', block)
neighbor['capabilities'] = extract(r'System Capabilities\s*:[ \t]*(.+)', block)
# Only include bridge/switch neighbors (skip phones, APs listed as endpoints)
if neighbor['chassis_id'] and neighbor['system_name']:
# Use port_desc as remote port if available, fallback to port_id
neighbor['remote_port'] = neighbor['port_desc'] if neighbor['port_desc'] else neighbor['port_id']
# 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
@@ -100,6 +96,14 @@ def parse_aruba_procurve_local(raw_output):
'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.
@@ -116,14 +120,26 @@ def parse_aruba_procurve_neighbors(raw_output):
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()
parts = right.split()
if len(parts) < 4:
continue
right = right.strip()
chassis_id = normalize_mac(parts[0])
port_id = parts[1]
sys_name = parts[3]
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,