Two-phase focused neighbor walk and fix status/optics bugs
- Restructure neighbor walk into Phase 1 (discovery: ifDescr + ifName + ifStackTable) and Phase 2 (targeted snmpget for matched interfaces only). Reduces NCS 5500 walk from ~150k OIDs to ~20k discovery + ~600 targeted. - Rename cisco-parse.py to cisco_parse.py for Python import compatibility. - Add parse_walk_text() for in-process parsing without file I/O. - Fix interface status showing DOWN/ADMIN DOWN: use isUp() instead of hardcoded === '1' checks, add -Oe flag to snmpget for numeric enums. - Fix optics showing raw sensor values: apply entSensorPrecision scaling (e.g., -95122 with precision 4 → -9.5122 dBm). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9b98e260d1
commit
c285810c68
@ -2056,8 +2056,8 @@ function renderNeighbor() {{
|
|||||||
const optics = ndata.optics || {{}};
|
const optics = ndata.optics || {{}};
|
||||||
|
|
||||||
const shortName = (sys.sysName || ip).split('.')[0];
|
const shortName = (sys.sysName || ip).split('.')[0];
|
||||||
const adminUp = qi.ifAdminStatus === '1';
|
const adminUp = isUp(qi.ifAdminStatus);
|
||||||
const operUp = qi.ifOperStatus === '1';
|
const operUp = isUp(qi.ifOperStatus);
|
||||||
const statusClass = !adminUp ? 'admin-down' : operUp ? 'up' : 'down';
|
const statusClass = !adminUp ? 'admin-down' : operUp ? 'up' : 'down';
|
||||||
const statusText = !adminUp ? 'Admin Down' : operUp ? 'Up' : 'Down';
|
const statusText = !adminUp ? 'Admin Down' : operUp ? 'Up' : 'Down';
|
||||||
|
|
||||||
@ -2110,14 +2110,14 @@ function renderNeighbor() {{
|
|||||||
let subRows = '';
|
let subRows = '';
|
||||||
for (const sk of subKeys) {{
|
for (const sk of subKeys) {{
|
||||||
const s = subs[sk];
|
const s = subs[sk];
|
||||||
const sUp = s.ifOperStatus === '1';
|
const sUp = isUp(s.ifOperStatus);
|
||||||
subRows += `<tr>
|
subRows += `<tr>
|
||||||
<td style="font-family:'JetBrains Mono',monospace">${{esc(s.ifDescr || s.ifName || '?')}}</td>
|
<td style="font-family:'JetBrains Mono',monospace">${{esc(s.ifDescr || s.ifName || '?')}}</td>
|
||||||
<td>${{s.vlanId || '?'}}</td>
|
<td>${{s.vlanId || '?'}}</td>
|
||||||
<td><span class="nbr-status-badge ${{sUp ? 'up' : 'down'}}">${{sUp ? 'Up' : 'Down'}}</span></td>
|
<td><span class="nbr-status-badge ${{sUp ? 'up' : 'down'}}">${{sUp ? 'Up' : 'Down'}}</span></td>
|
||||||
<td>${{esc(s.ifAlias || '')}}</td>
|
<td>${{esc(s.ifAlias || '')}}</td>
|
||||||
<td style="font-family:'JetBrains Mono',monospace">${{s.bvi_ifDescr ? esc(s.bvi_ifDescr) : '—'}}</td>
|
<td style="font-family:'JetBrains Mono',monospace">${{s.bvi_ifDescr ? esc(s.bvi_ifDescr) : '—'}}</td>
|
||||||
<td>${{s.bvi_ifDescr ? `<span class="nbr-status-badge ${{s.bvi_ifOperStatus === '1' ? 'up' : 'down'}}">${{s.bvi_ifOperStatus === '1' ? 'Up' : 'Down'}}</span>` : ''}}</td>
|
<td>${{s.bvi_ifDescr ? `<span class="nbr-status-badge ${{isUp(s.bvi_ifOperStatus) ? 'up' : 'down'}}">${{isUp(s.bvi_ifOperStatus) ? 'Up' : 'Down'}}</span>` : ''}}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}}
|
}}
|
||||||
subsHtml = `
|
subsHtml = `
|
||||||
@ -2134,7 +2134,7 @@ function renderNeighbor() {{
|
|||||||
let vlanRows = '';
|
let vlanRows = '';
|
||||||
for (const vk of vlanKeys.sort((a,b) => parseInt(a) - parseInt(b))) {{
|
for (const vk of vlanKeys.sort((a,b) => parseInt(a) - parseInt(b))) {{
|
||||||
const v = vlans[vk];
|
const v = vlans[vk];
|
||||||
const vUp = v.ifOperStatus === '1';
|
const vUp = isUp(v.ifOperStatus);
|
||||||
vlanRows += `<tr>
|
vlanRows += `<tr>
|
||||||
<td>Vlan${{vk}}</td>
|
<td>Vlan${{vk}}</td>
|
||||||
<td><span class="nbr-status-badge ${{vUp ? 'up' : 'down'}}">${{vUp ? 'Up' : 'Down'}}</span></td>
|
<td><span class="nbr-status-badge ${{vUp ? 'up' : 'down'}}">${{vUp ? 'Up' : 'Down'}}</span></td>
|
||||||
|
|||||||
@ -103,73 +103,75 @@ OID_CISCO_SENSOR_PREFIX = ".1.3.6.1.4.1.9.9.91.1.1.1.1"
|
|||||||
# Walk file parser
|
# Walk file parser
|
||||||
# ────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def parse_walk_file(walk_file):
|
def _parse_lines(lines):
|
||||||
"""Parse an snmpbulkwalk -On -OQ output file into {oid: value} dict.
|
"""Parse snmpbulkwalk/snmpget -On -OQ output lines into {oid: value} dict.
|
||||||
|
|
||||||
Lines look like:
|
|
||||||
.1.3.6.1.2.1.2.2.1.2.62 = "TenGigabitEthernet1/1/3"
|
|
||||||
.1.3.6.1.2.1.2.2.1.7.62 = 1
|
|
||||||
|
|
||||||
|
Handles multi-line quoted values (e.g., Cisco sysDescr).
|
||||||
String values have surrounding quotes stripped.
|
String values have surrounding quotes stripped.
|
||||||
"""
|
"""
|
||||||
walk_path = Path(walk_file)
|
|
||||||
oid_data = {}
|
oid_data = {}
|
||||||
|
pending_oid = None
|
||||||
|
pending_val = None
|
||||||
|
|
||||||
with walk_path.open("r", errors="replace") as fh:
|
for raw_line in lines:
|
||||||
pending_oid = None
|
line = raw_line.rstrip("\n\r")
|
||||||
pending_val = None
|
|
||||||
|
|
||||||
for raw_line in fh:
|
|
||||||
line = raw_line.rstrip("\n\r")
|
|
||||||
|
|
||||||
# Handle multi-line values (e.g., Cisco sysDescr spans multiple lines)
|
|
||||||
if pending_oid is not None:
|
|
||||||
# Continuation of a multi-line quoted value
|
|
||||||
pending_val += " " + line.strip()
|
|
||||||
if '"' in line:
|
|
||||||
# Closing quote found — finalize
|
|
||||||
val = pending_val.strip()
|
|
||||||
if val.startswith('"'):
|
|
||||||
val = val[1:]
|
|
||||||
if val.endswith('"'):
|
|
||||||
val = val[:-1]
|
|
||||||
oid_data[pending_oid] = val
|
|
||||||
pending_oid = None
|
|
||||||
pending_val = None
|
|
||||||
continue
|
|
||||||
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Split on first ' = '
|
|
||||||
parts = line.split(" = ", 1)
|
|
||||||
if len(parts) != 2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
oid = parts[0].strip()
|
|
||||||
value = parts[1].strip()
|
|
||||||
|
|
||||||
# Check for opening quote without closing (multi-line value)
|
|
||||||
if value.startswith('"') and not value.endswith('"'):
|
|
||||||
pending_oid = oid
|
|
||||||
pending_val = value
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Strip surrounding quotes
|
|
||||||
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
|
|
||||||
value = value[1:-1]
|
|
||||||
|
|
||||||
oid_data[oid] = value
|
|
||||||
|
|
||||||
# Handle any trailing pending value
|
|
||||||
if pending_oid is not None:
|
if pending_oid is not None:
|
||||||
val = pending_val.strip().strip('"')
|
pending_val += " " + line.strip()
|
||||||
oid_data[pending_oid] = val
|
if '"' in line:
|
||||||
|
val = pending_val.strip()
|
||||||
|
if val.startswith('"'):
|
||||||
|
val = val[1:]
|
||||||
|
if val.endswith('"'):
|
||||||
|
val = val[:-1]
|
||||||
|
oid_data[pending_oid] = val
|
||||||
|
pending_oid = None
|
||||||
|
pending_val = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split(" = ", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
oid = parts[0].strip()
|
||||||
|
value = parts[1].strip()
|
||||||
|
|
||||||
|
if value.startswith('"') and not value.endswith('"'):
|
||||||
|
pending_oid = oid
|
||||||
|
pending_val = value
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
|
||||||
|
value = value[1:-1]
|
||||||
|
|
||||||
|
oid_data[oid] = value
|
||||||
|
|
||||||
|
if pending_oid is not None:
|
||||||
|
val = pending_val.strip().strip('"')
|
||||||
|
oid_data[pending_oid] = val
|
||||||
|
|
||||||
return oid_data
|
return oid_data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_walk_file(walk_file):
|
||||||
|
"""Parse an snmpbulkwalk -On -OQ output file into {oid: value} dict."""
|
||||||
|
walk_path = Path(walk_file)
|
||||||
|
with walk_path.open("r", errors="replace") as fh:
|
||||||
|
return _parse_lines(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_walk_text(text):
|
||||||
|
"""Parse snmpbulkwalk/snmpget -On -OQ output from a string.
|
||||||
|
|
||||||
|
Useful for in-process parsing without writing to a file first.
|
||||||
|
"""
|
||||||
|
return _parse_lines(text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
# Helpers
|
# Helpers
|
||||||
# ────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
@ -539,6 +541,14 @@ def get_optics_info(oid_data, parent_ifindex, parent_descr):
|
|||||||
# entSensorType: .1.3.6.1.4.1.9.9.91.1.1.1.1.1.{idx}
|
# entSensorType: .1.3.6.1.4.1.9.9.91.1.1.1.1.1.{idx}
|
||||||
sensor_type_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.1."
|
sensor_type_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.1."
|
||||||
|
|
||||||
|
# entSensorPrecision: .1.3.6.1.4.1.9.9.91.1.1.1.1.3.{idx}
|
||||||
|
# Number of decimal places to apply to entSensorValue
|
||||||
|
sensor_precision_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.3."
|
||||||
|
|
||||||
|
# entSensorScale: .1.3.6.1.4.1.9.9.91.1.1.1.1.2.{idx}
|
||||||
|
# Scale factor (1=yocto..9=units..17=exa) — 9 means no scaling
|
||||||
|
sensor_scale_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.2."
|
||||||
|
|
||||||
# Find entity indices that match the interface
|
# Find entity indices that match the interface
|
||||||
short_name = _shorten_name(parent_descr)
|
short_name = _shorten_name(parent_descr)
|
||||||
matching_ent_indices = []
|
matching_ent_indices = []
|
||||||
@ -556,6 +566,25 @@ def get_optics_info(oid_data, parent_ifindex, parent_descr):
|
|||||||
|
|
||||||
_dbg(f"Found {len(matching_ent_indices)} entity entries for {parent_descr}")
|
_dbg(f"Found {len(matching_ent_indices)} entity entries for {parent_descr}")
|
||||||
|
|
||||||
|
def _scale_sensor_value(raw_val, ent_idx):
|
||||||
|
"""Apply entSensorPrecision to scale a raw sensor value."""
|
||||||
|
try:
|
||||||
|
val = float(raw_val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return raw_val
|
||||||
|
|
||||||
|
precision = _get(oid_data, f"{sensor_precision_prefix}{ent_idx}")
|
||||||
|
try:
|
||||||
|
prec = int(precision)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
prec = 0
|
||||||
|
|
||||||
|
if prec > 0:
|
||||||
|
val = val / (10 ** prec)
|
||||||
|
|
||||||
|
# Round to avoid floating point noise
|
||||||
|
return str(round(val, prec if prec > 0 else 1))
|
||||||
|
|
||||||
# For matching entities, look up sensor readings
|
# For matching entities, look up sensor readings
|
||||||
for ent_idx in matching_ent_indices:
|
for ent_idx in matching_ent_indices:
|
||||||
sensor_val = _get(oid_data, f"{sensor_value_prefix}{ent_idx}")
|
sensor_val = _get(oid_data, f"{sensor_value_prefix}{ent_idx}")
|
||||||
@ -566,20 +595,23 @@ def get_optics_info(oid_data, parent_ifindex, parent_descr):
|
|||||||
sensor_type = _get(oid_data, f"{sensor_type_prefix}{ent_idx}")
|
sensor_type = _get(oid_data, f"{sensor_type_prefix}{ent_idx}")
|
||||||
ent_descr = _get(oid_data, f"{ent_descr_prefix}{ent_idx}").lower()
|
ent_descr = _get(oid_data, f"{ent_descr_prefix}{ent_idx}").lower()
|
||||||
|
|
||||||
|
# Scale the raw value using entSensorPrecision
|
||||||
|
scaled = _scale_sensor_value(sensor_val, ent_idx)
|
||||||
|
|
||||||
# entSensorType: 8 = celsius, 14 = dBm
|
# entSensorType: 8 = celsius, 14 = dBm
|
||||||
# Also check description text for classification
|
# Also check description text for classification
|
||||||
if sensor_type == "8" or "temperature" in ent_descr or "temp" in ent_descr:
|
if sensor_type == "8" or "temperature" in ent_descr or "temp" in ent_descr:
|
||||||
result["temperature"] = sensor_val
|
result["temperature"] = scaled
|
||||||
elif "transmit" in ent_descr or "tx" in ent_descr:
|
elif "transmit" in ent_descr or "tx" in ent_descr:
|
||||||
result["txPower"] = sensor_val
|
result["txPower"] = scaled
|
||||||
elif "receive" in ent_descr or "rx" in ent_descr:
|
elif "receive" in ent_descr or "rx" in ent_descr:
|
||||||
result["rxPower"] = sensor_val
|
result["rxPower"] = scaled
|
||||||
elif sensor_type == "14":
|
elif sensor_type == "14":
|
||||||
# dBm but unclassified — assign to first empty power slot
|
# dBm but unclassified — assign to first empty power slot
|
||||||
if result["txPower"] is None:
|
if result["txPower"] is None:
|
||||||
result["txPower"] = sensor_val
|
result["txPower"] = scaled
|
||||||
elif result["rxPower"] is None:
|
elif result["rxPower"] is None:
|
||||||
result["rxPower"] = sensor_val
|
result["rxPower"] = scaled
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
249
nid-server.py
249
nid-server.py
@ -30,14 +30,40 @@ from pathlib import Path
|
|||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
WALKS_DIR = SCRIPT_DIR / "walks"
|
WALKS_DIR = SCRIPT_DIR / "walks"
|
||||||
|
|
||||||
# ── OID subtrees for neighbor (Cisco) device walk ────────────────────
|
# ── Phase 1: Discovery OIDs (lightweight, column-specific walks) ──────
|
||||||
NEIGHBOR_TARGETED_OIDS = [
|
# On an NCS 5500 with 10k+ interfaces, walking full ifTable/ifXTable would
|
||||||
(".1.3.6.1.2.1.1", "System"),
|
# return ~150k OIDs. Instead, walk only the columns needed to identify the
|
||||||
(".1.3.6.1.2.1.2.2.1", "ifTable"),
|
# target interface and its children. ~2 OIDs per interface for discovery.
|
||||||
(".1.3.6.1.2.1.31.1.1.1", "ifXTable"),
|
NEIGHBOR_DISCOVERY_OIDS = [
|
||||||
(".1.3.6.1.2.1.31.1.2", "ifStackTable"),
|
(".1.3.6.1.2.1.1", "System"), # ~8 OIDs
|
||||||
(".1.3.6.1.2.1.17.7.1.4.3.1", "dot1qVlanStatic"),
|
(".1.3.6.1.2.1.2.2.1.2", "ifDescr"), # 1 column: ifDescr
|
||||||
(".1.3.6.1.4.1.9.9.46.1.3.1", "vtpVlanTable"),
|
(".1.3.6.1.2.1.31.1.1.1.1", "ifName"), # 1 column: ifName
|
||||||
|
(".1.3.6.1.2.1.31.1.2", "ifStackTable"), # parent-child relationships
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Phase 2: Per-interface OID suffixes for targeted snmpget ─────────
|
||||||
|
# After matching the target ifIndex + children, we GET only these OIDs
|
||||||
|
# for each relevant interface. ~15 OIDs per interface instead of ~150k total.
|
||||||
|
NEIGHBOR_INTERFACE_OID_BASES = [
|
||||||
|
".1.3.6.1.2.1.2.2.1.3", # ifType
|
||||||
|
".1.3.6.1.2.1.2.2.1.4", # ifMtu
|
||||||
|
".1.3.6.1.2.1.2.2.1.5", # ifSpeed
|
||||||
|
".1.3.6.1.2.1.2.2.1.7", # ifAdminStatus
|
||||||
|
".1.3.6.1.2.1.2.2.1.8", # ifOperStatus
|
||||||
|
".1.3.6.1.2.1.2.2.1.10", # ifInOctets
|
||||||
|
".1.3.6.1.2.1.2.2.1.13", # ifInDiscards
|
||||||
|
".1.3.6.1.2.1.2.2.1.14", # ifInErrors
|
||||||
|
".1.3.6.1.2.1.2.2.1.16", # ifOutOctets
|
||||||
|
".1.3.6.1.2.1.2.2.1.19", # ifOutDiscards
|
||||||
|
".1.3.6.1.2.1.2.2.1.20", # ifOutErrors
|
||||||
|
".1.3.6.1.2.1.31.1.1.1.6", # ifHCInOctets
|
||||||
|
".1.3.6.1.2.1.31.1.1.1.10", # ifHCOutOctets
|
||||||
|
".1.3.6.1.2.1.31.1.1.1.15", # ifHighSpeed
|
||||||
|
".1.3.6.1.2.1.31.1.1.1.18", # ifAlias
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Extra subtrees to walk for IOS-XE VLAN/optics (Phase 2 optional) ─
|
||||||
|
NEIGHBOR_EXTRA_OIDS = [
|
||||||
(".1.3.6.1.2.1.47.1.1.1", "entPhysicalTable"),
|
(".1.3.6.1.2.1.47.1.1.1", "entPhysicalTable"),
|
||||||
(".1.3.6.1.4.1.9.9.91.1.1.1", "ciscoEntitySensor"),
|
(".1.3.6.1.4.1.9.9.91.1.1.1", "ciscoEntitySensor"),
|
||||||
]
|
]
|
||||||
@ -263,8 +289,80 @@ def build_neighbor_snmp_auth() -> list:
|
|||||||
return ["-v", NEIGHBOR_SNMP_VERSION, "-c", NEIGHBOR_SNMP_COMMUNITY]
|
return ["-v", NEIGHBOR_SNMP_VERSION, "-c", NEIGHBOR_SNMP_COMMUNITY]
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_subtrees_parallel(walk_cmd, auth, target, oid_list, status_prefix=""):
|
||||||
|
"""Walk a list of (oid, label) subtrees in parallel. Returns combined text."""
|
||||||
|
total = len(oid_list)
|
||||||
|
completed = [0]
|
||||||
|
results_map = {}
|
||||||
|
|
||||||
|
def walk_one(idx, oid, label):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
[walk_cmd, "-On", "-OQ"] + auth + [target, oid],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
completed[0] += 1
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "walking",
|
||||||
|
"message": f"{status_prefix}({completed[0]}/{total})",
|
||||||
|
}
|
||||||
|
return idx, res.stdout
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
completed[0] += 1
|
||||||
|
return idx, ""
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
futures = [
|
||||||
|
pool.submit(walk_one, i, oid, label)
|
||||||
|
for i, (oid, label) in enumerate(oid_list)
|
||||||
|
]
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
idx, output = fut.result()
|
||||||
|
if output.strip():
|
||||||
|
results_map[idx] = output
|
||||||
|
|
||||||
|
return "\n".join(results_map[i] for i in sorted(results_map))
|
||||||
|
|
||||||
|
|
||||||
|
def _snmpget_batch(walk_cmd, auth, target, oid_list):
|
||||||
|
"""Run snmpget for a batch of specific OIDs. Returns raw output text.
|
||||||
|
|
||||||
|
Uses snmpget (not bulkwalk) since we're requesting exact OIDs.
|
||||||
|
Falls back to individual gets if batch fails.
|
||||||
|
"""
|
||||||
|
if not oid_list:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# snmpget can handle multiple OIDs in one call (much faster than individual)
|
||||||
|
# Split into chunks of 30 to avoid command-line length limits
|
||||||
|
all_output = []
|
||||||
|
for i in range(0, len(oid_list), 30):
|
||||||
|
chunk = oid_list[i:i + 30]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["snmpget", "-On", "-OQ", "-Oe"] + auth + [target] + chunk,
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
if res.stdout.strip():
|
||||||
|
all_output.append(res.stdout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "\n".join(all_output)
|
||||||
|
|
||||||
|
|
||||||
def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
||||||
"""Execute a targeted SNMP walk against an LLDP neighbor device."""
|
"""Execute a two-phase focused SNMP walk against an LLDP neighbor device.
|
||||||
|
|
||||||
|
Phase 1 (Discovery): Walk System + ifDescr + ifName + ifStackTable
|
||||||
|
→ Identify target ifIndex and child subinterfaces
|
||||||
|
Phase 2 (Targeted): snmpget ~15 OIDs per matched interface only
|
||||||
|
→ Full interface facts for only the relevant interfaces
|
||||||
|
|
||||||
|
On an NCS 5500 with 10k interfaces, this reduces from ~150k OIDs
|
||||||
|
to ~20k discovery + ~600 targeted GETs.
|
||||||
|
"""
|
||||||
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
||||||
if not ip_re.match(target):
|
if not ip_re.match(target):
|
||||||
with neighbor_lock:
|
with neighbor_lock:
|
||||||
@ -272,7 +370,7 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
|||||||
return
|
return
|
||||||
|
|
||||||
with neighbor_lock:
|
with neighbor_lock:
|
||||||
neighbor_status[target] = {"state": "walking", "message": "Starting neighbor walk..."}
|
neighbor_status[target] = {"state": "walking", "message": "Phase 1: Discovering interfaces..."}
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
safe_ip = target.replace(".", "-")
|
safe_ip = target.replace(".", "-")
|
||||||
@ -284,40 +382,103 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
|||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Walk neighbor subtrees in parallel
|
# ── Phase 1: Discovery walk ──────────────────────────────────
|
||||||
total = len(NEIGHBOR_TARGETED_OIDS)
|
discovery_output = _walk_subtrees_parallel(
|
||||||
completed = [0]
|
walk_cmd, auth, target, NEIGHBOR_DISCOVERY_OIDS,
|
||||||
results_map = {}
|
status_prefix="Phase 1: Discovery "
|
||||||
|
)
|
||||||
|
|
||||||
def walk_subtree(idx, oid, label):
|
if not discovery_output.strip():
|
||||||
try:
|
with neighbor_lock:
|
||||||
res = subprocess.run(
|
neighbor_status[target] = {
|
||||||
[walk_cmd, "-On", "-OQ"] + auth + [target, oid],
|
"state": "error",
|
||||||
capture_output=True, text=True, timeout=60,
|
"message": "Discovery walk returned no data — check credentials",
|
||||||
)
|
}
|
||||||
completed[0] += 1
|
return
|
||||||
with neighbor_lock:
|
|
||||||
neighbor_status[target] = {
|
|
||||||
"state": "walking",
|
|
||||||
"message": f"Walking subtrees ({completed[0]}/{total})",
|
|
||||||
}
|
|
||||||
return idx, res.stdout
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
completed[0] += 1
|
|
||||||
return idx, ""
|
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
# Parse discovery data in-process to find target interface
|
||||||
futures = [
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
pool.submit(walk_subtree, i, oid, label)
|
from cisco_parse import parse_walk_text, build_interface_index, match_rem_port_id, \
|
||||||
for i, (oid, label) in enumerate(NEIGHBOR_TARGETED_OIDS)
|
discover_subinterfaces_stack, discover_subinterfaces_pattern
|
||||||
]
|
|
||||||
for fut in as_completed(futures):
|
|
||||||
idx, output = fut.result()
|
|
||||||
if output.strip():
|
|
||||||
results_map[idx] = output
|
|
||||||
|
|
||||||
output_lines = [results_map[i] for i in sorted(results_map)]
|
discovery_oids = parse_walk_text(discovery_output)
|
||||||
walk_file.write_text("\n".join(output_lines))
|
interfaces = build_interface_index(discovery_oids)
|
||||||
|
discovery_count = len(discovery_oids)
|
||||||
|
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "walking",
|
||||||
|
"message": f"Phase 1 done: {discovery_count:,} OIDs, {len(interfaces)} interfaces. Matching...",
|
||||||
|
}
|
||||||
|
|
||||||
|
matched_ifindex = match_rem_port_id(interfaces, rem_port_id)
|
||||||
|
|
||||||
|
if matched_ifindex is None:
|
||||||
|
# Write what we have and let cisco-parse produce a best-effort result
|
||||||
|
walk_file.write_text(discovery_output)
|
||||||
|
else:
|
||||||
|
# Find child interfaces (subinterfaces)
|
||||||
|
parent_descr = interfaces.get(matched_ifindex, {}).get("ifDescr", "")
|
||||||
|
child_indices = set()
|
||||||
|
|
||||||
|
# ifStackTable children
|
||||||
|
child_indices |= discover_subinterfaces_stack(discovery_oids, matched_ifindex)
|
||||||
|
|
||||||
|
# Pattern-based children (ifDescr matching)
|
||||||
|
if parent_descr:
|
||||||
|
pattern_children = discover_subinterfaces_pattern(discovery_oids, parent_descr)
|
||||||
|
child_indices |= set(pattern_children.keys())
|
||||||
|
|
||||||
|
# Also look for BDI/BVI interfaces that correlate with subinterfaces
|
||||||
|
bvi_indices = set()
|
||||||
|
for child_idx in child_indices:
|
||||||
|
child_descr = interfaces.get(child_idx, {}).get("ifDescr", "")
|
||||||
|
vlan_match = re.search(r"\.(\d+)$", child_descr)
|
||||||
|
if vlan_match:
|
||||||
|
vlan_id = vlan_match.group(1)
|
||||||
|
# Search ifDescr for BDI{N} or BVI{N}
|
||||||
|
for ifidx, info in interfaces.items():
|
||||||
|
d = info.get("ifDescr", "")
|
||||||
|
if d == f"BDI{vlan_id}" or d == f"BVI{vlan_id}":
|
||||||
|
bvi_indices.add(ifidx)
|
||||||
|
|
||||||
|
# Also find Vlan{N} SVIs (IOS-XE)
|
||||||
|
vlan_indices = set()
|
||||||
|
for ifidx, info in interfaces.items():
|
||||||
|
d = info.get("ifDescr", "")
|
||||||
|
if re.match(r"^Vlan\d+$", d):
|
||||||
|
vlan_indices.add(ifidx)
|
||||||
|
|
||||||
|
all_target_indices = {matched_ifindex} | child_indices | bvi_indices | vlan_indices
|
||||||
|
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "walking",
|
||||||
|
"message": f"Phase 2: Getting details for {len(all_target_indices)} interfaces...",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 2: Targeted snmpget ────────────────────────────
|
||||||
|
target_oids = []
|
||||||
|
for ifidx in all_target_indices:
|
||||||
|
for base_oid in NEIGHBOR_INTERFACE_OID_BASES:
|
||||||
|
target_oids.append(f"{base_oid}.{ifidx}")
|
||||||
|
|
||||||
|
phase2_output = _snmpget_batch(walk_cmd, auth, target, target_oids)
|
||||||
|
|
||||||
|
# Also walk optics/entity subtrees (small on most devices)
|
||||||
|
extra_output = _walk_subtrees_parallel(
|
||||||
|
walk_cmd, auth, target, NEIGHBOR_EXTRA_OIDS,
|
||||||
|
status_prefix="Phase 2: Optics/Entity "
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combine all phases into one walk file
|
||||||
|
combined = discovery_output
|
||||||
|
if phase2_output.strip():
|
||||||
|
combined += "\n" + phase2_output
|
||||||
|
if extra_output.strip():
|
||||||
|
combined += "\n" + extra_output
|
||||||
|
|
||||||
|
walk_file.write_text(combined)
|
||||||
|
|
||||||
line_count = sum(1 for _ in walk_file.open())
|
line_count = sum(1 for _ in walk_file.open())
|
||||||
elapsed = round(time.time() - t_start, 1)
|
elapsed = round(time.time() - t_start, 1)
|
||||||
@ -330,12 +491,12 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse with cisco-parse.py
|
# ── Parse combined data with cisco-parse.py ──────────────────
|
||||||
with neighbor_lock:
|
with neighbor_lock:
|
||||||
neighbor_status[target] = {"state": "parsing", "message": "Parsing neighbor data..."}
|
neighbor_status[target] = {"state": "parsing", "message": "Parsing neighbor data..."}
|
||||||
|
|
||||||
parse_result = subprocess.run(
|
parse_result = subprocess.run(
|
||||||
[sys.executable, str(SCRIPT_DIR / "cisco-parse.py"),
|
[sys.executable, str(SCRIPT_DIR / "cisco_parse.py"),
|
||||||
str(walk_file), rem_port_id],
|
str(walk_file), rem_port_id],
|
||||||
capture_output=True, text=True, timeout=60,
|
capture_output=True, text=True, timeout=60,
|
||||||
)
|
)
|
||||||
@ -361,7 +522,7 @@ def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
|||||||
latest_neighbor[target] = neighbor_json
|
latest_neighbor[target] = neighbor_json
|
||||||
neighbor_status[target] = {
|
neighbor_status[target] = {
|
||||||
"state": "complete",
|
"state": "complete",
|
||||||
"message": f"Done — {line_count:,} lines in {elapsed}s",
|
"message": f"Done — {line_count:,} OIDs ({discovery_count:,} discovery + {line_count - discovery_count:,} targeted) in {elapsed}s",
|
||||||
"json_path": str(neighbor_json),
|
"json_path": str(neighbor_json),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user