Add LLDP neighbor device polling (Cisco C3850 support)
Follow the LLDP breadcrumb back to the connected router/switch: - New cisco-parse.py: standalone parser for Cisco SNMP walk data with interface matching, subinterface/SVI discovery, BDI/BVI correlation, and optics extraction - New /api/neighbor-walk and /api/neighbor-data endpoints - "Poll Neighbor" button in LLDP topology cards - Connected Neighbor Devices card showing interface status, counters, SVIs, and subinterface mappings - Platform-aware: handles IOS-XE (SVIs) and IOS-XR (subinterfaces) - Tested against lab C3850-04 (172.16.50.4) — 4,288 OIDs in 1.1s Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bcb179e7e4
commit
c64a80810f
@ -27,5 +27,11 @@ SNMP_WALK_MODE=targeted
|
|||||||
# The Traffic Policies card will be empty when disabled.
|
# The Traffic Policies card will be empty when disabled.
|
||||||
SNMP_WALK_POLICIES=true
|
SNMP_WALK_POLICIES=true
|
||||||
|
|
||||||
|
# ── Neighbor Device SNMP ──
|
||||||
|
# Credentials for polling LLDP-discovered neighbor devices (Cisco routers/switches)
|
||||||
|
# Falls back to NID credentials (SNMP_COMMUNITY / v3 settings) if not set
|
||||||
|
NEIGHBOR_SNMP_VERSION=2c
|
||||||
|
NEIGHBOR_SNMP_COMMUNITY=public
|
||||||
|
|
||||||
# ── Server ──
|
# ── Server ──
|
||||||
SERVER_PORT=5525
|
SERVER_PORT=5525
|
||||||
|
|||||||
@ -407,6 +407,11 @@ body {{
|
|||||||
0%, 100% {{ opacity: 1; }}
|
0%, 100% {{ opacity: 1; }}
|
||||||
50% {{ opacity: 0.4; }}
|
50% {{ opacity: 0.4; }}
|
||||||
}}
|
}}
|
||||||
|
@keyframes spin {{
|
||||||
|
from {{ transform: rotate(0deg); }}
|
||||||
|
to {{ transform: rotate(360deg); }}
|
||||||
|
}}
|
||||||
|
.spin {{ animation: spin 1s linear infinite; display: inline-block; }}
|
||||||
.walk-progress {{
|
.walk-progress {{
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
@ -620,6 +625,46 @@ body {{
|
|||||||
border-left: 1px dashed #3a3f4b;
|
border-left: 1px dashed #3a3f4b;
|
||||||
margin: 0 0.25rem;
|
margin: 0 0.25rem;
|
||||||
}}
|
}}
|
||||||
|
/* ── Poll Neighbor Button ── */
|
||||||
|
.btn-poll-neighbor {{
|
||||||
|
display: block; width: 100%; margin-top: 0.4rem;
|
||||||
|
padding: 0.25rem 0.4rem; font-size: 0.7rem;
|
||||||
|
background: var(--card-bg); color: var(--accent);
|
||||||
|
border: 1px solid var(--accent); border-radius: 4px;
|
||||||
|
cursor: pointer; text-align: center; transition: all 0.15s;
|
||||||
|
}}
|
||||||
|
.btn-poll-neighbor:hover {{ background: var(--accent); color: #fff; }}
|
||||||
|
.btn-poll-neighbor:disabled {{ opacity: 0.5; cursor: not-allowed; }}
|
||||||
|
.btn-poll-neighbor i {{ margin-right: 0.2rem; }}
|
||||||
|
/* ── Neighbor Device Section ── */
|
||||||
|
.neighbor-card {{ margin-bottom: 1rem; }}
|
||||||
|
.neighbor-header {{
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem; font-size: 0.85rem; font-weight: 600;
|
||||||
|
}}
|
||||||
|
.neighbor-header .nbr-platform {{ font-weight: 400; color: var(--text-muted); font-size: 0.75rem; }}
|
||||||
|
.neighbor-intf {{
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem; padding: 0.75rem;
|
||||||
|
}}
|
||||||
|
.nbr-intf-card {{
|
||||||
|
background: var(--card-bg); border: 1px solid #2a2e38; border-radius: 6px;
|
||||||
|
padding: 0.6rem; font-size: 0.75rem;
|
||||||
|
}}
|
||||||
|
.nbr-intf-card .intf-name {{ font-weight: 600; font-size: 0.8rem; margin-bottom: 0.3rem; font-family: 'JetBrains Mono', monospace; }}
|
||||||
|
.nbr-intf-card .intf-alias {{ color: var(--text-muted); font-style: italic; margin-bottom: 0.3rem; }}
|
||||||
|
.nbr-status-badge {{
|
||||||
|
display: inline-block; padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||||
|
font-size: 0.65rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
.nbr-status-badge.up {{ background: rgba(0,200,83,0.15); color: var(--green); }}
|
||||||
|
.nbr-status-badge.down {{ background: rgba(255,82,82,0.15); color: var(--red); }}
|
||||||
|
.nbr-status-badge.admin-down {{ background: rgba(255,160,0,0.15); color: var(--amber); }}
|
||||||
|
.nbr-detail {{ display: flex; justify-content: space-between; padding: 0.15rem 0; }}
|
||||||
|
.nbr-detail .nbr-lbl {{ color: var(--text-muted); }}
|
||||||
|
.nbr-sub-table {{ width: 100%; font-size: 0.72rem; margin-top: 0.5rem; }}
|
||||||
|
.nbr-sub-table th {{ color: var(--text-muted); font-weight: 500; text-align: left; padding: 0.25rem 0.5rem; border-bottom: 1px solid #2a2e38; }}
|
||||||
|
.nbr-sub-table td {{ padding: 0.25rem 0.5rem; border-bottom: 1px solid #1a1e28; }}
|
||||||
/* ── Location Map ── */
|
/* ── Location Map ── */
|
||||||
#sec-map .card-dark {{
|
#sec-map .card-dark {{
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -681,6 +726,9 @@ body {{
|
|||||||
<!-- ═══════════════ 3. LLDP TOPOLOGY ═══════════════ -->
|
<!-- ═══════════════ 3. LLDP TOPOLOGY ═══════════════ -->
|
||||||
<div id="sec-lldp"></div>
|
<div id="sec-lldp"></div>
|
||||||
|
|
||||||
|
<!-- ═══════════════ 3b. CONNECTED NEIGHBOR DATA ═══════════════ -->
|
||||||
|
<div id="sec-neighbor"></div>
|
||||||
|
|
||||||
<!-- ═══════════════ 4. INTERFACES TABLE ═══════════════ -->
|
<!-- ═══════════════ 4. INTERFACES TABLE ═══════════════ -->
|
||||||
<div id="sec-interfaces"></div>
|
<div id="sec-interfaces"></div>
|
||||||
|
|
||||||
@ -1835,6 +1883,10 @@ function renderLldp() {{
|
|||||||
nbr.capsEnabled === '4' ? '<span style="color:var(--cyan)">Router</span>' :
|
nbr.capsEnabled === '4' ? '<span style="color:var(--cyan)">Router</span>' :
|
||||||
'Cap=' + (nbr.capsEnabled||'?')}}
|
'Cap=' + (nbr.capsEnabled||'?')}}
|
||||||
</div>
|
</div>
|
||||||
|
${{nbr.mgmtIPv4 && !(nbr.remSysDesc || '').match(/AMN-|AMO-/) ?
|
||||||
|
`<button class="btn-poll-neighbor" onclick="pollNeighbor('${{nbr.mgmtIPv4}}','${{nbr.remPortId||''}}','${{(nbr.remSysName||'').split('.')[0]}}')">
|
||||||
|
<i class="bi bi-router"></i> Poll Neighbor
|
||||||
|
</button>` : ''}}
|
||||||
</div>`;
|
</div>`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@ -1913,6 +1965,216 @@ function renderLldp() {{
|
|||||||
</div>`;
|
</div>`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
// ── 3b. Connected Neighbor ─────────────────────────────
|
||||||
|
|
||||||
|
function pollNeighbor(target, remPortId, remSysName) {{
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Polling...';
|
||||||
|
|
||||||
|
// Ping first
|
||||||
|
fetch('/api/ping', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/json' }},
|
||||||
|
body: JSON.stringify({{ target }})
|
||||||
|
}})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(ping => {{
|
||||||
|
if (!ping.reachable) {{
|
||||||
|
btn.innerHTML = '<i class="bi bi-x-circle"></i> Unreachable';
|
||||||
|
btn.style.borderColor = 'var(--red)';
|
||||||
|
btn.style.color = 'var(--red)';
|
||||||
|
setTimeout(() => {{
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-router"></i> Poll Neighbor';
|
||||||
|
btn.style.borderColor = '';
|
||||||
|
btn.style.color = '';
|
||||||
|
}}, 3000);
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Start the neighbor walk
|
||||||
|
fetch('/api/neighbor-walk', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/json' }},
|
||||||
|
body: JSON.stringify({{ target, remPortId, remSysName }})
|
||||||
|
}})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {{
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat spin"></i> Walking...';
|
||||||
|
// Poll for completion
|
||||||
|
const pollInterval = setInterval(() => {{
|
||||||
|
fetch(`/api/neighbor-status?target=${{encodeURIComponent(target)}}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(status => {{
|
||||||
|
if (status.state === 'complete') {{
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
// Fetch the neighbor data and render it
|
||||||
|
fetch(`/api/neighbor-data?target=${{encodeURIComponent(target)}}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(ndata => {{
|
||||||
|
if (!DATA.neighbor_data) DATA.neighbor_data = {{}};
|
||||||
|
DATA.neighbor_data[target] = ndata;
|
||||||
|
renderNeighbor();
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check-circle"></i> Done';
|
||||||
|
btn.style.borderColor = 'var(--green)';
|
||||||
|
btn.style.color = 'var(--green)';
|
||||||
|
}});
|
||||||
|
}} else if (status.state === 'error') {{
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Error';
|
||||||
|
btn.style.borderColor = 'var(--red)';
|
||||||
|
btn.style.color = 'var(--red)';
|
||||||
|
btn.title = status.message || 'Walk failed';
|
||||||
|
}} else {{
|
||||||
|
btn.innerHTML = `<i class="bi bi-arrow-repeat spin"></i> ${{status.message || 'Walking...'}}`;
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}}, 2000);
|
||||||
|
}});
|
||||||
|
}})
|
||||||
|
.catch(err => {{
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i> Error';
|
||||||
|
console.error('Neighbor poll error:', err);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function renderNeighbor() {{
|
||||||
|
const nd = DATA.neighbor_data || {{}};
|
||||||
|
const container = document.getElementById('sec-neighbor');
|
||||||
|
if (!Object.keys(nd).length) {{ container.innerHTML = ''; return; }}
|
||||||
|
|
||||||
|
let cardsHtml = '';
|
||||||
|
for (const [ip, ndata] of Object.entries(nd)) {{
|
||||||
|
const sys = ndata.neighbor_system || {{}};
|
||||||
|
const qi = ndata.queried_interface || {{}};
|
||||||
|
const subs = ndata.subinterfaces || {{}};
|
||||||
|
const vlans = ndata.vlans || {{}};
|
||||||
|
const optics = ndata.optics || {{}};
|
||||||
|
|
||||||
|
const shortName = (sys.sysName || ip).split('.')[0];
|
||||||
|
const adminUp = qi.ifAdminStatus === '1';
|
||||||
|
const operUp = qi.ifOperStatus === '1';
|
||||||
|
const statusClass = !adminUp ? 'admin-down' : operUp ? 'up' : 'down';
|
||||||
|
const statusText = !adminUp ? 'Admin Down' : operUp ? 'Up' : 'Down';
|
||||||
|
|
||||||
|
function formatSpeed(hs) {{
|
||||||
|
const n = parseInt(hs);
|
||||||
|
if (!n) return '?';
|
||||||
|
if (n >= 1000) return (n/1000) + ' Gbps';
|
||||||
|
return n + ' Mbps';
|
||||||
|
}}
|
||||||
|
|
||||||
|
function formatOctets(val) {{
|
||||||
|
const n = parseInt(val);
|
||||||
|
if (isNaN(n)) return '?';
|
||||||
|
if (n > 1e12) return (n/1e12).toFixed(2) + ' TB';
|
||||||
|
if (n > 1e9) return (n/1e9).toFixed(2) + ' GB';
|
||||||
|
if (n > 1e6) return (n/1e6).toFixed(2) + ' MB';
|
||||||
|
if (n > 1e3) return (n/1e3).toFixed(1) + ' KB';
|
||||||
|
return n + ' B';
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Primary interface card
|
||||||
|
let intfHtml = `
|
||||||
|
<div class="nbr-intf-card" style="grid-column: span 2;">
|
||||||
|
<div class="intf-name">${{esc(qi.ifDescr || qi.remPortId || '?')}}</div>
|
||||||
|
${{qi.ifAlias ? `<div class="intf-alias">${{esc(qi.ifAlias)}}</div>` : ''}}
|
||||||
|
<div class="nbr-detail"><span class="nbr-lbl">Status</span> <span class="nbr-status-badge ${{statusClass}}">${{statusText}}</span></div>
|
||||||
|
<div class="nbr-detail"><span class="nbr-lbl">Speed</span> ${{formatSpeed(qi.ifHighSpeed || qi.ifSpeed)}}</div>
|
||||||
|
${{qi.ifMtu ? `<div class="nbr-detail"><span class="nbr-lbl">MTU</span> ${{qi.ifMtu}}</div>` : ''}}
|
||||||
|
<div class="nbr-detail"><span class="nbr-lbl">In</span> ${{formatOctets(qi.ifHCInOctets || qi.ifInOctets)}}</div>
|
||||||
|
<div class="nbr-detail"><span class="nbr-lbl">Out</span> ${{formatOctets(qi.ifHCOutOctets || qi.ifOutOctets)}}</div>
|
||||||
|
${{(qi.ifInErrors && qi.ifInErrors !== '0') || (qi.ifOutErrors && qi.ifOutErrors !== '0') ?
|
||||||
|
`<div class="nbr-detail"><span class="nbr-lbl">Errors</span> <span style="color:var(--red)">In: ${{qi.ifInErrors}} / Out: ${{qi.ifOutErrors}}</span></div>` : ''}}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Optics card (if available)
|
||||||
|
if (optics.txPower || optics.rxPower) {{
|
||||||
|
intfHtml += `
|
||||||
|
<div class="nbr-intf-card">
|
||||||
|
<div class="intf-name"><i class="bi bi-broadcast"></i> Optics</div>
|
||||||
|
${{optics.txPower ? `<div class="nbr-detail"><span class="nbr-lbl">Tx Power</span> ${{optics.txPower}} dBm</div>` : ''}}
|
||||||
|
${{optics.rxPower ? `<div class="nbr-detail"><span class="nbr-lbl">Rx Power</span> ${{optics.rxPower}} dBm</div>` : ''}}
|
||||||
|
${{optics.temperature ? `<div class="nbr-detail"><span class="nbr-lbl">Temp</span> ${{optics.temperature}} C</div>` : ''}}
|
||||||
|
</div>`;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Subinterfaces table (IOS-XR style)
|
||||||
|
let subsHtml = '';
|
||||||
|
const subKeys = Object.keys(subs);
|
||||||
|
if (subKeys.length) {{
|
||||||
|
let subRows = '';
|
||||||
|
for (const sk of subKeys) {{
|
||||||
|
const s = subs[sk];
|
||||||
|
const sUp = s.ifOperStatus === '1';
|
||||||
|
subRows += `<tr>
|
||||||
|
<td style="font-family:'JetBrains Mono',monospace">${{esc(s.ifDescr || s.ifName || '?')}}</td>
|
||||||
|
<td>${{s.vlanId || '?'}}</td>
|
||||||
|
<td><span class="nbr-status-badge ${{sUp ? 'up' : 'down'}}">${{sUp ? 'Up' : 'Down'}}</span></td>
|
||||||
|
<td>${{esc(s.ifAlias || '')}}</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>
|
||||||
|
</tr>`;
|
||||||
|
}}
|
||||||
|
subsHtml = `
|
||||||
|
<table class="nbr-sub-table">
|
||||||
|
<thead><tr><th>Subinterface</th><th>VLAN</th><th>Status</th><th>Description</th><th>BVI/BDI</th><th>L3 Status</th></tr></thead>
|
||||||
|
<tbody>${{subRows}}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// VLANs / SVIs table (IOS-XE style)
|
||||||
|
let vlansHtml = '';
|
||||||
|
const vlanKeys = Object.keys(vlans);
|
||||||
|
if (vlanKeys.length) {{
|
||||||
|
let vlanRows = '';
|
||||||
|
for (const vk of vlanKeys.sort((a,b) => parseInt(a) - parseInt(b))) {{
|
||||||
|
const v = vlans[vk];
|
||||||
|
const vUp = v.ifOperStatus === '1';
|
||||||
|
vlanRows += `<tr>
|
||||||
|
<td>Vlan${{vk}}</td>
|
||||||
|
<td><span class="nbr-status-badge ${{vUp ? 'up' : 'down'}}">${{vUp ? 'Up' : 'Down'}}</span></td>
|
||||||
|
<td>${{esc(v.ifAlias || '')}}</td>
|
||||||
|
<td style="font-family:'JetBrains Mono',monospace">${{esc(v.ifDescr || '')}}</td>
|
||||||
|
</tr>`;
|
||||||
|
}}
|
||||||
|
vlansHtml = `
|
||||||
|
<div style="margin-top:0.5rem;font-size:0.75rem;font-weight:600;color:var(--text-muted)">Switch Virtual Interfaces (SVIs)</div>
|
||||||
|
<table class="nbr-sub-table">
|
||||||
|
<thead><tr><th>SVI</th><th>Status</th><th>Description</th><th>ifDescr</th></tr></thead>
|
||||||
|
<tbody>${{vlanRows}}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}}
|
||||||
|
|
||||||
|
cardsHtml += `
|
||||||
|
<div class="neighbor-card">
|
||||||
|
<div class="neighbor-header">
|
||||||
|
<i class="bi bi-router" style="color:var(--accent)"></i>
|
||||||
|
${{esc(shortName)}} <span style="color:var(--text-muted);font-size:0.75rem">(${{ip}})</span>
|
||||||
|
<span class="nbr-platform">${{esc(sys.platform || sys.osType || '')}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="neighbor-intf">
|
||||||
|
${{intfHtml}}
|
||||||
|
</div>
|
||||||
|
${{subsHtml}}
|
||||||
|
${{vlansHtml}}
|
||||||
|
</div>`;
|
||||||
|
}}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="card-dark">
|
||||||
|
<div class="card-header collapsible"><i class="bi bi-router"></i> Connected Neighbor Devices<i class="bi bi-chevron-down collapse-chevron"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
${{cardsHtml}}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
initCollapsible();
|
||||||
|
}}
|
||||||
|
|
||||||
// ── 7. Coverage Matrix ───────────────────────────────
|
// ── 7. Coverage Matrix ───────────────────────────────
|
||||||
function renderCoverage() {{
|
function renderCoverage() {{
|
||||||
// Analyze each section for populated vs empty fields
|
// Analyze each section for populated vs empty fields
|
||||||
@ -2130,6 +2392,7 @@ renderHeader();
|
|||||||
renderMap();
|
renderMap();
|
||||||
renderPanel();
|
renderPanel();
|
||||||
renderLldp();
|
renderLldp();
|
||||||
|
renderNeighbor();
|
||||||
renderInterfaces();
|
renderInterfaces();
|
||||||
renderSfp();
|
renderSfp();
|
||||||
renderAlarms();
|
renderAlarms();
|
||||||
|
|||||||
734
cisco-parse.py
Normal file
734
cisco-parse.py
Normal file
@ -0,0 +1,734 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cisco SNMP Walk Parser
|
||||||
|
|
||||||
|
Parses raw snmpbulkwalk -On -OQ output from a Cisco device and produces
|
||||||
|
a structured JSON file with interface details focused on a specific
|
||||||
|
remPortId (LLDP neighbor interface).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 cisco-parse.py <walk_file.txt> <remPortId> [output.json]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 cisco-parse.py walks/172-16-50-4_walk.txt Te1/1/3
|
||||||
|
python3 cisco-parse.py walks/172-16-50-4_walk.txt Gi1/0/39 output.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Cisco short-name <-> long-name mapping
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CISCO_SHORT_TO_LONG = {
|
||||||
|
"Te": "TenGigabitEthernet",
|
||||||
|
"Gi": "GigabitEthernet",
|
||||||
|
"Fa": "FastEthernet",
|
||||||
|
"Hu": "HundredGigE",
|
||||||
|
"Tw": "TwentyFiveGigE",
|
||||||
|
"Fo": "FortyGigabitEthernet",
|
||||||
|
"Eth": "Ethernet",
|
||||||
|
"BDI": "BDI",
|
||||||
|
"BVI": "BVI",
|
||||||
|
"Lo": "Loopback",
|
||||||
|
"Vl": "Vlan",
|
||||||
|
"Po": "Port-channel",
|
||||||
|
"Twe": "TwentyFiveGigE",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse map: long -> short (built from the canonical mapping)
|
||||||
|
CISCO_LONG_TO_SHORT = {}
|
||||||
|
for _short, _long in CISCO_SHORT_TO_LONG.items():
|
||||||
|
# If multiple shorts map to the same long, prefer the shorter abbreviation
|
||||||
|
if _long not in CISCO_LONG_TO_SHORT or len(_short) < len(CISCO_LONG_TO_SHORT[_long]):
|
||||||
|
CISCO_LONG_TO_SHORT[_long] = _short
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# OID constants
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OID_SYS_DESCR = ".1.3.6.1.2.1.1.1.0"
|
||||||
|
OID_SYS_UPTIME = ".1.3.6.1.2.1.1.3.0"
|
||||||
|
OID_SYS_NAME = ".1.3.6.1.2.1.1.5.0"
|
||||||
|
|
||||||
|
# IF-MIB base prefixes (append .{ifIndex})
|
||||||
|
OID_IF_DESCR = ".1.3.6.1.2.1.2.2.1.2"
|
||||||
|
OID_IF_TYPE = ".1.3.6.1.2.1.2.2.1.3"
|
||||||
|
OID_IF_MTU = ".1.3.6.1.2.1.2.2.1.4"
|
||||||
|
OID_IF_SPEED = ".1.3.6.1.2.1.2.2.1.5"
|
||||||
|
OID_IF_ADMIN_STATUS = ".1.3.6.1.2.1.2.2.1.7"
|
||||||
|
OID_IF_OPER_STATUS = ".1.3.6.1.2.1.2.2.1.8"
|
||||||
|
OID_IF_IN_OCTETS = ".1.3.6.1.2.1.2.2.1.10"
|
||||||
|
OID_IF_IN_DISCARDS = ".1.3.6.1.2.1.2.2.1.13"
|
||||||
|
OID_IF_IN_ERRORS = ".1.3.6.1.2.1.2.2.1.14"
|
||||||
|
OID_IF_OUT_OCTETS = ".1.3.6.1.2.1.2.2.1.16"
|
||||||
|
OID_IF_OUT_DISCARDS = ".1.3.6.1.2.1.2.2.1.19"
|
||||||
|
OID_IF_OUT_ERRORS = ".1.3.6.1.2.1.2.2.1.20"
|
||||||
|
|
||||||
|
# IF-MIB ifXTable
|
||||||
|
OID_IF_NAME = ".1.3.6.1.2.1.31.1.1.1.1"
|
||||||
|
OID_IF_HC_IN_OCTETS = ".1.3.6.1.2.1.31.1.1.1.6"
|
||||||
|
OID_IF_HC_OUT_OCTETS = ".1.3.6.1.2.1.31.1.1.1.10"
|
||||||
|
OID_IF_HIGH_SPEED = ".1.3.6.1.2.1.31.1.1.1.15"
|
||||||
|
OID_IF_ALIAS = ".1.3.6.1.2.1.31.1.1.1.18"
|
||||||
|
|
||||||
|
# ifStackTable: .1.3.6.1.2.1.31.1.2.1.3.{higher}.{lower}
|
||||||
|
OID_IF_STACK_STATUS = ".1.3.6.1.2.1.31.1.2.1.3"
|
||||||
|
|
||||||
|
# ENTITY-MIB
|
||||||
|
OID_ENT_PHYS_PREFIX = ".1.3.6.1.2.1.47.1.1.1.1"
|
||||||
|
|
||||||
|
# CISCO-ENTITY-SENSOR-MIB
|
||||||
|
OID_CISCO_SENSOR_PREFIX = ".1.3.6.1.4.1.9.9.91.1.1.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Walk file parser
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_walk_file(walk_file):
|
||||||
|
"""Parse an snmpbulkwalk -On -OQ output file 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
|
||||||
|
|
||||||
|
String values have surrounding quotes stripped.
|
||||||
|
"""
|
||||||
|
walk_path = Path(walk_file)
|
||||||
|
oid_data = {}
|
||||||
|
|
||||||
|
with walk_path.open("r", errors="replace") as fh:
|
||||||
|
pending_oid = None
|
||||||
|
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:
|
||||||
|
val = pending_val.strip().strip('"')
|
||||||
|
oid_data[pending_oid] = val
|
||||||
|
|
||||||
|
return oid_data
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get(oid_data, oid, default=""):
|
||||||
|
"""Safely retrieve a value from oid_data."""
|
||||||
|
return oid_data.get(oid, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_subtree(oid_data, prefix):
|
||||||
|
"""Return all entries whose OID starts with prefix (with trailing dot)."""
|
||||||
|
dotted = prefix if prefix.endswith(".") else prefix + "."
|
||||||
|
return {k: v for k, v in oid_data.items() if k.startswith(dotted) or k == prefix}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_short_name(short_name):
|
||||||
|
"""Expand a Cisco short interface name to full form.
|
||||||
|
|
||||||
|
'Te1/1/3' -> 'TenGigabitEthernet1/1/3'
|
||||||
|
'Gi1/0/39' -> 'GigabitEthernet1/0/39'
|
||||||
|
"""
|
||||||
|
# Try each prefix, longest first to avoid partial matches (e.g., Twe before Tw)
|
||||||
|
for short in sorted(CISCO_SHORT_TO_LONG.keys(), key=len, reverse=True):
|
||||||
|
if short_name.startswith(short):
|
||||||
|
remainder = short_name[len(short):]
|
||||||
|
# Verify the remainder starts with a digit or slash (real interface numbering)
|
||||||
|
if remainder and (remainder[0].isdigit() or remainder[0] == "/"):
|
||||||
|
return CISCO_SHORT_TO_LONG[short] + remainder
|
||||||
|
return short_name
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_name(long_name):
|
||||||
|
"""Shorten a Cisco full interface name to abbreviated form.
|
||||||
|
|
||||||
|
'TenGigabitEthernet1/1/3' -> 'Te1/1/3'
|
||||||
|
"""
|
||||||
|
for long, short in sorted(CISCO_LONG_TO_SHORT.items(), key=lambda x: len(x[0]), reverse=True):
|
||||||
|
if long_name.startswith(long):
|
||||||
|
return short + long_name[len(long):]
|
||||||
|
return long_name
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# System info extraction
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_system_info(oid_data):
|
||||||
|
"""Extract system-level information."""
|
||||||
|
sys_descr = _get(oid_data, OID_SYS_DESCR)
|
||||||
|
sys_uptime = _get(oid_data, OID_SYS_UPTIME)
|
||||||
|
sys_name = _get(oid_data, OID_SYS_NAME)
|
||||||
|
|
||||||
|
os_type = "unknown"
|
||||||
|
platform = ""
|
||||||
|
|
||||||
|
if "IOS-XR" in sys_descr:
|
||||||
|
os_type = "IOS-XR"
|
||||||
|
# Try to extract version
|
||||||
|
ver_match = re.search(r"Version\s+([\d.]+)", sys_descr)
|
||||||
|
version = ver_match.group(1) if ver_match else ""
|
||||||
|
# Try to extract platform from sysDescr
|
||||||
|
plat_match = re.search(r"Cisco\s+(\S+)", sys_descr)
|
||||||
|
plat_name = plat_match.group(1) if plat_match else "Cisco"
|
||||||
|
platform = f"Cisco {plat_name} IOS-XR {version}".strip()
|
||||||
|
elif "IOS Software" in sys_descr or "Cisco IOS" in sys_descr:
|
||||||
|
os_type = "IOS-XE"
|
||||||
|
# Try to extract version
|
||||||
|
ver_match = re.search(r"Version\s+([\d.()A-Za-z]+)", sys_descr)
|
||||||
|
version = ver_match.group(1) if ver_match else ""
|
||||||
|
# Try to extract platform (e.g., CAT3K, C3850, Catalyst)
|
||||||
|
plat_match = re.search(r"(CAT\w+|C\d\w+|Catalyst\s+\S+)", sys_descr)
|
||||||
|
if not plat_match:
|
||||||
|
plat_match = re.search(r"Cisco\s+(\S+)", sys_descr)
|
||||||
|
plat_name = plat_match.group(1) if plat_match else "Cisco"
|
||||||
|
platform = f"Cisco {plat_name} IOS-XE {version}".strip()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sysName": sys_name,
|
||||||
|
"sysDescr": sys_descr,
|
||||||
|
"sysUpTime": sys_uptime,
|
||||||
|
"platform": platform,
|
||||||
|
"osType": os_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Interface index builder
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_interface_index(oid_data):
|
||||||
|
"""Build a dict of ifIndex -> {ifDescr, ifName, ...} from walk data."""
|
||||||
|
interfaces = {}
|
||||||
|
|
||||||
|
# Collect ifDescr entries
|
||||||
|
descr_prefix = OID_IF_DESCR + "."
|
||||||
|
for oid, value in oid_data.items():
|
||||||
|
if oid.startswith(descr_prefix):
|
||||||
|
ifindex = oid[len(descr_prefix):]
|
||||||
|
if ifindex not in interfaces:
|
||||||
|
interfaces[ifindex] = {}
|
||||||
|
interfaces[ifindex]["ifDescr"] = value
|
||||||
|
|
||||||
|
# Collect ifName entries
|
||||||
|
name_prefix = OID_IF_NAME + "."
|
||||||
|
for oid, value in oid_data.items():
|
||||||
|
if oid.startswith(name_prefix):
|
||||||
|
ifindex = oid[len(name_prefix):]
|
||||||
|
if ifindex not in interfaces:
|
||||||
|
interfaces[ifindex] = {}
|
||||||
|
interfaces[ifindex]["ifName"] = value
|
||||||
|
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# remPortId matching
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def match_rem_port_id(interfaces, rem_port_id):
|
||||||
|
"""Match a remPortId string to an ifIndex.
|
||||||
|
|
||||||
|
Tries:
|
||||||
|
1. Exact match against ifName values
|
||||||
|
2. Expand short name and match against ifDescr
|
||||||
|
3. Direct match against ifDescr (already long form)
|
||||||
|
|
||||||
|
Returns ifIndex as string, or None.
|
||||||
|
"""
|
||||||
|
# 1. Exact match against ifName
|
||||||
|
for ifindex, info in interfaces.items():
|
||||||
|
if info.get("ifName", "") == rem_port_id:
|
||||||
|
_dbg(f"Matched remPortId '{rem_port_id}' via ifName -> ifIndex {ifindex}")
|
||||||
|
return ifindex
|
||||||
|
|
||||||
|
# 2. Expand short name and match ifDescr
|
||||||
|
expanded = _expand_short_name(rem_port_id)
|
||||||
|
if expanded != rem_port_id:
|
||||||
|
for ifindex, info in interfaces.items():
|
||||||
|
if info.get("ifDescr", "") == expanded:
|
||||||
|
_dbg(f"Matched remPortId '{rem_port_id}' (expanded: '{expanded}') via ifDescr -> ifIndex {ifindex}")
|
||||||
|
return ifindex
|
||||||
|
|
||||||
|
# 3. Direct match against ifDescr
|
||||||
|
for ifindex, info in interfaces.items():
|
||||||
|
if info.get("ifDescr", "") == rem_port_id:
|
||||||
|
_dbg(f"Matched remPortId '{rem_port_id}' via ifDescr direct -> ifIndex {ifindex}")
|
||||||
|
return ifindex
|
||||||
|
|
||||||
|
_dbg(f"WARNING: Could not match remPortId '{rem_port_id}' to any interface")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Interface facts extraction
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_interface_facts(oid_data, ifindex):
|
||||||
|
"""Extract all relevant facts for a given ifIndex."""
|
||||||
|
idx = str(ifindex)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ifDescr": _get(oid_data, f"{OID_IF_DESCR}.{idx}"),
|
||||||
|
"ifType": _get(oid_data, f"{OID_IF_TYPE}.{idx}"),
|
||||||
|
"ifMtu": _get(oid_data, f"{OID_IF_MTU}.{idx}"),
|
||||||
|
"ifSpeed": _get(oid_data, f"{OID_IF_SPEED}.{idx}"),
|
||||||
|
"ifAdminStatus": _get(oid_data, f"{OID_IF_ADMIN_STATUS}.{idx}"),
|
||||||
|
"ifOperStatus": _get(oid_data, f"{OID_IF_OPER_STATUS}.{idx}"),
|
||||||
|
"ifInOctets": _get(oid_data, f"{OID_IF_IN_OCTETS}.{idx}"),
|
||||||
|
"ifInErrors": _get(oid_data, f"{OID_IF_IN_ERRORS}.{idx}"),
|
||||||
|
"ifOutOctets": _get(oid_data, f"{OID_IF_OUT_OCTETS}.{idx}"),
|
||||||
|
"ifOutErrors": _get(oid_data, f"{OID_IF_OUT_ERRORS}.{idx}"),
|
||||||
|
"ifInDiscards": _get(oid_data, f"{OID_IF_IN_DISCARDS}.{idx}"),
|
||||||
|
"ifOutDiscards": _get(oid_data, f"{OID_IF_OUT_DISCARDS}.{idx}"),
|
||||||
|
"ifName": _get(oid_data, f"{OID_IF_NAME}.{idx}"),
|
||||||
|
"ifHighSpeed": _get(oid_data, f"{OID_IF_HIGH_SPEED}.{idx}"),
|
||||||
|
"ifAlias": _get(oid_data, f"{OID_IF_ALIAS}.{idx}"),
|
||||||
|
"ifHCInOctets": _get(oid_data, f"{OID_IF_HC_IN_OCTETS}.{idx}"),
|
||||||
|
"ifHCOutOctets": _get(oid_data, f"{OID_IF_HC_OUT_OCTETS}.{idx}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Subinterface discovery
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def discover_subinterfaces_stack(oid_data, parent_ifindex):
|
||||||
|
"""Discover child interfaces via ifStackTable.
|
||||||
|
|
||||||
|
In ifStackTable, .1.3.6.1.2.1.31.1.2.1.3.{higher}.{lower} = status
|
||||||
|
When lower == parent_ifindex, higher is a child/sub-interface layered
|
||||||
|
on top of the parent. Skip ifIndex 0 (represents 'not stacked').
|
||||||
|
"""
|
||||||
|
children = set()
|
||||||
|
prefix = OID_IF_STACK_STATUS + "."
|
||||||
|
parent_suffix = f".{parent_ifindex}"
|
||||||
|
|
||||||
|
for oid in oid_data:
|
||||||
|
if not oid.startswith(prefix):
|
||||||
|
continue
|
||||||
|
remainder = oid[len(prefix):]
|
||||||
|
parts = remainder.split(".", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
higher, lower = parts[0], parts[1]
|
||||||
|
if lower == str(parent_ifindex) and higher != "0":
|
||||||
|
children.add(higher)
|
||||||
|
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
def discover_subinterfaces_pattern(oid_data, parent_descr):
|
||||||
|
"""Discover subinterfaces by name pattern matching.
|
||||||
|
|
||||||
|
Look for ifDescr values like '{parent_descr}.{number}'.
|
||||||
|
"""
|
||||||
|
children = {}
|
||||||
|
prefix = OID_IF_DESCR + "."
|
||||||
|
pattern = parent_descr + "."
|
||||||
|
|
||||||
|
for oid, value in oid_data.items():
|
||||||
|
if not oid.startswith(prefix):
|
||||||
|
continue
|
||||||
|
if value.startswith(pattern):
|
||||||
|
ifindex = oid[len(prefix):]
|
||||||
|
children[ifindex] = value
|
||||||
|
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
def discover_subinterfaces(oid_data, parent_ifindex, parent_descr):
|
||||||
|
"""Combine stack-based and pattern-based subinterface discovery."""
|
||||||
|
# Approach A: ifStackTable
|
||||||
|
stack_children = discover_subinterfaces_stack(oid_data, parent_ifindex)
|
||||||
|
|
||||||
|
# Approach B: pattern matching
|
||||||
|
pattern_children = discover_subinterfaces_pattern(oid_data, parent_descr)
|
||||||
|
|
||||||
|
# Merge: union of both sets of ifIndex values
|
||||||
|
all_child_indices = set(stack_children) | set(pattern_children.keys())
|
||||||
|
|
||||||
|
return all_child_indices
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# VLAN / SVI discovery (IOS-XE)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def discover_vlans(oid_data, os_type):
|
||||||
|
"""Discover VLAN SVIs for IOS-XE devices.
|
||||||
|
|
||||||
|
On IOS-XE Catalyst switches, scan ifDescr for 'Vlan{N}' entries
|
||||||
|
and collect their interface facts.
|
||||||
|
"""
|
||||||
|
vlans = {}
|
||||||
|
|
||||||
|
if os_type != "IOS-XE":
|
||||||
|
return vlans
|
||||||
|
|
||||||
|
prefix = OID_IF_DESCR + "."
|
||||||
|
vlan_re = re.compile(r"^Vlan(\d+)$")
|
||||||
|
|
||||||
|
for oid, value in oid_data.items():
|
||||||
|
if not oid.startswith(prefix):
|
||||||
|
continue
|
||||||
|
m = vlan_re.match(value)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
vlan_num = m.group(1)
|
||||||
|
ifindex = oid[len(prefix):]
|
||||||
|
|
||||||
|
vlans[vlan_num] = {
|
||||||
|
"ifIndex": ifindex,
|
||||||
|
"ifDescr": value,
|
||||||
|
"ifAlias": _get(oid_data, f"{OID_IF_ALIAS}.{ifindex}"),
|
||||||
|
"ifAdminStatus": _get(oid_data, f"{OID_IF_ADMIN_STATUS}.{ifindex}"),
|
||||||
|
"ifOperStatus": _get(oid_data, f"{OID_IF_OPER_STATUS}.{ifindex}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return vlans
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# BDI / BVI correlation
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def find_bdi_bvi(oid_data, vlan_id):
|
||||||
|
"""Find a BDI{vlan_id} or BVI{vlan_id} interface if it exists.
|
||||||
|
|
||||||
|
Returns (ifIndex, ifDescr, ifOperStatus, ifAlias) or (None, None, None, None).
|
||||||
|
"""
|
||||||
|
prefix = OID_IF_DESCR + "."
|
||||||
|
targets = [f"BDI{vlan_id}", f"BVI{vlan_id}"]
|
||||||
|
|
||||||
|
for oid, value in oid_data.items():
|
||||||
|
if not oid.startswith(prefix):
|
||||||
|
continue
|
||||||
|
if value in targets:
|
||||||
|
ifindex = oid[len(prefix):]
|
||||||
|
return (
|
||||||
|
ifindex,
|
||||||
|
value,
|
||||||
|
_get(oid_data, f"{OID_IF_OPER_STATUS}.{ifindex}"),
|
||||||
|
_get(oid_data, f"{OID_IF_ALIAS}.{ifindex}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (None, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Optics / Entity Sensor (best-effort)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_optics_info(oid_data, parent_ifindex, parent_descr):
|
||||||
|
"""Best-effort extraction of optics data from ENTITY-MIB and
|
||||||
|
CISCO-ENTITY-SENSOR-MIB.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Walk entPhysicalName (.1.3.6.1.2.1.47.1.1.1.1.7) to find entities
|
||||||
|
whose name contains the interface name/description.
|
||||||
|
2. For those entity indices, look up sensor values from
|
||||||
|
CISCO-ENTITY-SENSOR-MIB (.1.3.6.1.4.1.9.9.91.1.1.1.1.4.{entIdx}).
|
||||||
|
3. Use entPhysicalDescr or entSensorType to classify as Tx/Rx/temp.
|
||||||
|
|
||||||
|
Returns dict with txPower, rxPower, temperature (all may be None).
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"txPower": None,
|
||||||
|
"rxPower": None,
|
||||||
|
"temperature": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# entPhysicalName: .1.3.6.1.2.1.47.1.1.1.1.7.{entIdx}
|
||||||
|
ent_name_prefix = OID_ENT_PHYS_PREFIX.rsplit(".", 1)[0] + ".7."
|
||||||
|
# Actually: .1.3.6.1.2.1.47.1.1.1.1.7.{idx}
|
||||||
|
ent_name_prefix = ".1.3.6.1.2.1.47.1.1.1.1.7."
|
||||||
|
|
||||||
|
# entPhysicalDescr: .1.3.6.1.2.1.47.1.1.1.1.2.{idx}
|
||||||
|
ent_descr_prefix = ".1.3.6.1.2.1.47.1.1.1.1.2."
|
||||||
|
|
||||||
|
# entSensorValue: .1.3.6.1.4.1.9.9.91.1.1.1.1.4.{idx}
|
||||||
|
sensor_value_prefix = ".1.3.6.1.4.1.9.9.91.1.1.1.1.4."
|
||||||
|
|
||||||
|
# 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."
|
||||||
|
|
||||||
|
# Find entity indices that match the interface
|
||||||
|
short_name = _shorten_name(parent_descr)
|
||||||
|
matching_ent_indices = []
|
||||||
|
|
||||||
|
for oid, value in oid_data.items():
|
||||||
|
if not oid.startswith(ent_name_prefix):
|
||||||
|
continue
|
||||||
|
# Check if entity name references our interface
|
||||||
|
if parent_descr in value or short_name in value:
|
||||||
|
ent_idx = oid[len(ent_name_prefix):]
|
||||||
|
matching_ent_indices.append(ent_idx)
|
||||||
|
|
||||||
|
if not matching_ent_indices:
|
||||||
|
return result
|
||||||
|
|
||||||
|
_dbg(f"Found {len(matching_ent_indices)} entity entries for {parent_descr}")
|
||||||
|
|
||||||
|
# For matching entities, look up sensor readings
|
||||||
|
for ent_idx in matching_ent_indices:
|
||||||
|
sensor_val = _get(oid_data, f"{sensor_value_prefix}{ent_idx}")
|
||||||
|
if not sensor_val:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine sensor type from entSensorType or entPhysicalDescr
|
||||||
|
sensor_type = _get(oid_data, f"{sensor_type_prefix}{ent_idx}")
|
||||||
|
ent_descr = _get(oid_data, f"{ent_descr_prefix}{ent_idx}").lower()
|
||||||
|
|
||||||
|
# entSensorType: 8 = celsius, 14 = dBm
|
||||||
|
# Also check description text for classification
|
||||||
|
if sensor_type == "8" or "temperature" in ent_descr or "temp" in ent_descr:
|
||||||
|
result["temperature"] = sensor_val
|
||||||
|
elif "transmit" in ent_descr or "tx" in ent_descr:
|
||||||
|
result["txPower"] = sensor_val
|
||||||
|
elif "receive" in ent_descr or "rx" in ent_descr:
|
||||||
|
result["rxPower"] = sensor_val
|
||||||
|
elif sensor_type == "14":
|
||||||
|
# dBm but unclassified — assign to first empty power slot
|
||||||
|
if result["txPower"] is None:
|
||||||
|
result["txPower"] = sensor_val
|
||||||
|
elif result["rxPower"] is None:
|
||||||
|
result["rxPower"] = sensor_val
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Debug helper
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _dbg(msg):
|
||||||
|
"""Print debug/progress info to stderr."""
|
||||||
|
print(f"[cisco-parse] {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# Main builder
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_neighbor_output(oid_data, rem_port_id):
|
||||||
|
"""Build the structured neighbor output from parsed OID data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
oid_data: dict from parse_walk_file()
|
||||||
|
rem_port_id: interface identifier to focus on (e.g., 'Te1/1/3')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict matching the output JSON structure
|
||||||
|
"""
|
||||||
|
# System info
|
||||||
|
sys_info = get_system_info(oid_data)
|
||||||
|
_dbg(f"System: {sys_info['sysName']} ({sys_info['osType']})")
|
||||||
|
|
||||||
|
# Build interface index
|
||||||
|
interfaces = build_interface_index(oid_data)
|
||||||
|
_dbg(f"Found {len(interfaces)} interfaces in walk data")
|
||||||
|
|
||||||
|
# Match remPortId to ifIndex
|
||||||
|
matched_ifindex = match_rem_port_id(interfaces, rem_port_id)
|
||||||
|
|
||||||
|
# Build queried interface section
|
||||||
|
queried_iface = {"remPortId": rem_port_id}
|
||||||
|
parent_descr = ""
|
||||||
|
|
||||||
|
if matched_ifindex is not None:
|
||||||
|
facts = get_interface_facts(oid_data, matched_ifindex)
|
||||||
|
parent_descr = facts.get("ifDescr", "")
|
||||||
|
queried_iface.update({
|
||||||
|
"ifIndex": matched_ifindex,
|
||||||
|
"ifDescr": facts["ifDescr"],
|
||||||
|
"ifName": facts["ifName"],
|
||||||
|
"ifAlias": facts["ifAlias"],
|
||||||
|
"ifType": facts["ifType"],
|
||||||
|
"ifAdminStatus": facts["ifAdminStatus"],
|
||||||
|
"ifOperStatus": facts["ifOperStatus"],
|
||||||
|
"ifSpeed": facts["ifSpeed"],
|
||||||
|
"ifHighSpeed": facts["ifHighSpeed"],
|
||||||
|
"ifMtu": facts["ifMtu"],
|
||||||
|
"ifHCInOctets": facts["ifHCInOctets"],
|
||||||
|
"ifHCOutOctets": facts["ifHCOutOctets"],
|
||||||
|
"ifInErrors": facts["ifInErrors"],
|
||||||
|
"ifOutErrors": facts["ifOutErrors"],
|
||||||
|
"ifInDiscards": facts["ifInDiscards"],
|
||||||
|
"ifOutDiscards": facts["ifOutDiscards"],
|
||||||
|
})
|
||||||
|
_dbg(f"Queried interface: {parent_descr} (ifIndex {matched_ifindex})")
|
||||||
|
else:
|
||||||
|
# Fill with empty values so the JSON schema is consistent
|
||||||
|
for key in ("ifIndex", "ifDescr", "ifName", "ifAlias", "ifType",
|
||||||
|
"ifAdminStatus", "ifOperStatus", "ifSpeed", "ifHighSpeed",
|
||||||
|
"ifMtu", "ifHCInOctets", "ifHCOutOctets", "ifInErrors",
|
||||||
|
"ifOutErrors", "ifInDiscards", "ifOutDiscards"):
|
||||||
|
queried_iface[key] = ""
|
||||||
|
_dbg("WARNING: No interface matched, output will have empty queried_interface")
|
||||||
|
|
||||||
|
# Discover subinterfaces
|
||||||
|
subinterfaces = {}
|
||||||
|
if matched_ifindex and parent_descr:
|
||||||
|
child_indices = discover_subinterfaces(oid_data, matched_ifindex, parent_descr)
|
||||||
|
_dbg(f"Found {len(child_indices)} subinterfaces")
|
||||||
|
|
||||||
|
for child_idx in sorted(child_indices, key=lambda x: int(x) if x.isdigit() else 0):
|
||||||
|
child_facts = get_interface_facts(oid_data, child_idx)
|
||||||
|
child_descr = child_facts.get("ifDescr", "")
|
||||||
|
|
||||||
|
# Extract VLAN ID from .NNNN suffix
|
||||||
|
vlan_id = ""
|
||||||
|
vlan_match = re.search(r"\.(\d+)$", child_descr)
|
||||||
|
if vlan_match:
|
||||||
|
vlan_id = vlan_match.group(1)
|
||||||
|
|
||||||
|
# BDI/BVI correlation
|
||||||
|
bvi_ifindex, bvi_descr, bvi_oper, bvi_alias = (None, None, None, None)
|
||||||
|
if vlan_id:
|
||||||
|
bvi_ifindex, bvi_descr, bvi_oper, bvi_alias = find_bdi_bvi(oid_data, vlan_id)
|
||||||
|
|
||||||
|
subinterfaces[child_idx] = {
|
||||||
|
"ifDescr": child_descr,
|
||||||
|
"ifName": child_facts.get("ifName", ""),
|
||||||
|
"ifAlias": child_facts.get("ifAlias", ""),
|
||||||
|
"ifAdminStatus": child_facts.get("ifAdminStatus", ""),
|
||||||
|
"ifOperStatus": child_facts.get("ifOperStatus", ""),
|
||||||
|
"vlanId": vlan_id,
|
||||||
|
"bvi_ifIndex": bvi_ifindex,
|
||||||
|
"bvi_ifDescr": bvi_descr,
|
||||||
|
"bvi_ifOperStatus": bvi_oper,
|
||||||
|
"bvi_ifAlias": bvi_alias,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Discover VLANs (IOS-XE)
|
||||||
|
vlans = discover_vlans(oid_data, sys_info["osType"])
|
||||||
|
_dbg(f"Found {len(vlans)} VLAN SVIs")
|
||||||
|
|
||||||
|
# Optics (best-effort)
|
||||||
|
optics = {"txPower": None, "rxPower": None, "temperature": None}
|
||||||
|
if matched_ifindex and parent_descr:
|
||||||
|
optics = get_optics_info(oid_data, matched_ifindex, parent_descr)
|
||||||
|
|
||||||
|
# Assemble output
|
||||||
|
output = {
|
||||||
|
"neighbor_system": sys_info,
|
||||||
|
"queried_interface": queried_iface,
|
||||||
|
"subinterfaces": subinterfaces,
|
||||||
|
"vlans": vlans,
|
||||||
|
"optics": optics,
|
||||||
|
"_meta": {
|
||||||
|
"polled_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"target_ip": "",
|
||||||
|
"walk_lines": 0,
|
||||||
|
"elapsed_sec": 0.0,
|
||||||
|
"remPortId": rem_port_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
# CLI entry point
|
||||||
|
# ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: cisco-parse.py <walk_file> <remPortId> [output.json]")
|
||||||
|
print()
|
||||||
|
print("Arguments:")
|
||||||
|
print(" walk_file Raw snmpbulkwalk -On -OQ output file")
|
||||||
|
print(" remPortId Interface to focus on (e.g., Te1/1/3, Gi1/0/39)")
|
||||||
|
print(" output.json Optional output path (default: {stem}_neighbor_monitoring.json)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
walk_file = Path(sys.argv[1])
|
||||||
|
rem_port_id = sys.argv[2]
|
||||||
|
output_path = (
|
||||||
|
Path(sys.argv[3])
|
||||||
|
if len(sys.argv) > 3
|
||||||
|
else walk_file.with_name(walk_file.stem + "_neighbor_monitoring.json")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not walk_file.exists():
|
||||||
|
print(f"Error: walk file not found: {walk_file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_dbg(f"Parsing {walk_file}")
|
||||||
|
_dbg(f"Looking for remPortId: {rem_port_id}")
|
||||||
|
|
||||||
|
t_start = time.time()
|
||||||
|
oid_data = parse_walk_file(walk_file)
|
||||||
|
_dbg(f"Parsed {len(oid_data)} OID entries")
|
||||||
|
|
||||||
|
result = build_neighbor_output(oid_data, rem_port_id)
|
||||||
|
result["_meta"]["walk_lines"] = len(oid_data)
|
||||||
|
result["_meta"]["elapsed_sec"] = round(time.time() - t_start, 2)
|
||||||
|
|
||||||
|
# Try to extract target IP from filename pattern: {IP}_{timestamp}_..._walk.txt
|
||||||
|
# e.g., 172-16-50-4_2026-03-01_10-00-00_neighbor_walk.txt
|
||||||
|
name = walk_file.stem
|
||||||
|
ip_match = re.match(r"([\d-]+)_", name)
|
||||||
|
if ip_match:
|
||||||
|
result["_meta"]["target_ip"] = ip_match.group(1).replace("-", ".")
|
||||||
|
|
||||||
|
output_path.write_text(json.dumps(result, indent=2))
|
||||||
|
print(f"Wrote {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
227
nid-server.py
227
nid-server.py
@ -30,6 +30,18 @@ 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 ────────────────────
|
||||||
|
NEIGHBOR_TARGETED_OIDS = [
|
||||||
|
(".1.3.6.1.2.1.1", "System"),
|
||||||
|
(".1.3.6.1.2.1.2.2.1", "ifTable"),
|
||||||
|
(".1.3.6.1.2.1.31.1.1.1", "ifXTable"),
|
||||||
|
(".1.3.6.1.2.1.31.1.2", "ifStackTable"),
|
||||||
|
(".1.3.6.1.2.1.17.7.1.4.3.1", "dot1qVlanStatic"),
|
||||||
|
(".1.3.6.1.4.1.9.9.46.1.3.1", "vtpVlanTable"),
|
||||||
|
(".1.3.6.1.2.1.47.1.1.1", "entPhysicalTable"),
|
||||||
|
(".1.3.6.1.4.1.9.9.91.1.1.1", "ciscoEntitySensor"),
|
||||||
|
]
|
||||||
|
|
||||||
# ── OID subtrees for targeted walk (mirrors snmp-walk.sh) ────────────
|
# ── OID subtrees for targeted walk (mirrors snmp-walk.sh) ────────────
|
||||||
TARGETED_OIDS = [
|
TARGETED_OIDS = [
|
||||||
(".1.3.6.1.2.1.1", "System"),
|
(".1.3.6.1.2.1.1", "System"),
|
||||||
@ -82,6 +94,10 @@ SNMP_V3_SEC_LEVEL = ENV.get("SNMP_V3_SEC_LEVEL", "authPriv")
|
|||||||
if ENV.get("SNMP_WALK_POLICIES", "true").lower() == "true":
|
if ENV.get("SNMP_WALK_POLICIES", "true").lower() == "true":
|
||||||
TARGETED_OIDS.append((".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"))
|
TARGETED_OIDS.append((".1.3.6.1.4.1.22420.2.3", "ACD-POLICY-MIB"))
|
||||||
|
|
||||||
|
# Neighbor device SNMP credentials (falls back to NID creds)
|
||||||
|
NEIGHBOR_SNMP_VERSION = ENV.get("NEIGHBOR_SNMP_VERSION", SNMP_VERSION)
|
||||||
|
NEIGHBOR_SNMP_COMMUNITY = ENV.get("NEIGHBOR_SNMP_COMMUNITY", SNMP_COMMUNITY)
|
||||||
|
|
||||||
# ── Walk state (shared across threads) ───────────────────────────────
|
# ── Walk state (shared across threads) ───────────────────────────────
|
||||||
|
|
||||||
walk_lock = threading.Lock()
|
walk_lock = threading.Lock()
|
||||||
@ -98,6 +114,11 @@ walk_version = 0
|
|||||||
# Path to latest monitoring JSON (set after successful walk)
|
# Path to latest monitoring JSON (set after successful walk)
|
||||||
latest_json = None
|
latest_json = None
|
||||||
|
|
||||||
|
# Neighbor walk state (independent from NID walk)
|
||||||
|
neighbor_lock = threading.Lock()
|
||||||
|
neighbor_status = {} # keyed by target IP: {"state": ..., "message": ..., "json_path": ...}
|
||||||
|
latest_neighbor = {} # keyed by target IP: path to latest neighbor monitoring JSON
|
||||||
|
|
||||||
|
|
||||||
def set_status(state, message="", progress=0, **extra):
|
def set_status(state, message="", progress=0, **extra):
|
||||||
global walk_version
|
global walk_version
|
||||||
@ -232,6 +253,123 @@ def run_walk(target: str, mode: str, policies: bool = True):
|
|||||||
set_status("error", message=str(e)[:300])
|
set_status("error", message=str(e)[:300])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Neighbor device walk ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_neighbor_snmp_auth() -> list:
|
||||||
|
"""Build snmpwalk auth flags for neighbor device (falls back to NID creds)."""
|
||||||
|
if NEIGHBOR_SNMP_VERSION == "3":
|
||||||
|
# Future: support v3 for neighbor
|
||||||
|
return build_snmp_auth()
|
||||||
|
return ["-v", NEIGHBOR_SNMP_VERSION, "-c", NEIGHBOR_SNMP_COMMUNITY]
|
||||||
|
|
||||||
|
|
||||||
|
def run_neighbor_walk(target: str, rem_port_id: str, rem_sys_name: str = ""):
|
||||||
|
"""Execute a targeted SNMP walk against an LLDP neighbor device."""
|
||||||
|
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
||||||
|
if not ip_re.match(target):
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {"state": "error", "message": f"Invalid IP: {target}"}
|
||||||
|
return
|
||||||
|
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {"state": "walking", "message": "Starting neighbor walk..."}
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
safe_ip = target.replace(".", "-")
|
||||||
|
walk_file = WALKS_DIR / f"{safe_ip}_{timestamp}_neighbor_walk.txt"
|
||||||
|
WALKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
auth = build_neighbor_snmp_auth()
|
||||||
|
walk_cmd = "snmpbulkwalk" if shutil.which("snmpbulkwalk") else "snmpwalk"
|
||||||
|
t_start = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Walk neighbor subtrees in parallel
|
||||||
|
total = len(NEIGHBOR_TARGETED_OIDS)
|
||||||
|
completed = [0]
|
||||||
|
results_map = {}
|
||||||
|
|
||||||
|
def walk_subtree(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"Walking subtrees ({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_subtree, i, oid, label)
|
||||||
|
for i, (oid, label) in enumerate(NEIGHBOR_TARGETED_OIDS)
|
||||||
|
]
|
||||||
|
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)]
|
||||||
|
walk_file.write_text("\n".join(output_lines))
|
||||||
|
|
||||||
|
line_count = sum(1 for _ in walk_file.open())
|
||||||
|
elapsed = round(time.time() - t_start, 1)
|
||||||
|
|
||||||
|
if line_count == 0:
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "error",
|
||||||
|
"message": "Walk returned no data — check credentials",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse with cisco-parse.py
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {"state": "parsing", "message": "Parsing neighbor data..."}
|
||||||
|
|
||||||
|
parse_result = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT_DIR / "cisco-parse.py"),
|
||||||
|
str(walk_file), rem_port_id],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
if parse_result.returncode != 0:
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "error",
|
||||||
|
"message": f"Parse failed: {parse_result.stderr[:200]}",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
neighbor_json = walk_file.with_name(walk_file.stem + "_neighbor_monitoring.json")
|
||||||
|
if not neighbor_json.is_file():
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "error",
|
||||||
|
"message": "Parser did not produce neighbor JSON",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
with neighbor_lock:
|
||||||
|
latest_neighbor[target] = neighbor_json
|
||||||
|
neighbor_status[target] = {
|
||||||
|
"state": "complete",
|
||||||
|
"message": f"Done — {line_count:,} lines in {elapsed}s",
|
||||||
|
"json_path": str(neighbor_json),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
with neighbor_lock:
|
||||||
|
neighbor_status[target] = {"state": "error", "message": str(e)[:300]}
|
||||||
|
|
||||||
|
|
||||||
# ── Find latest monitoring JSON ──────────────────────────────────────
|
# ── Find latest monitoring JSON ──────────────────────────────────────
|
||||||
|
|
||||||
def find_latest_json() -> Path | None:
|
def find_latest_json() -> Path | None:
|
||||||
@ -269,6 +407,10 @@ class NIDHandler(BaseHTTPRequestHandler):
|
|||||||
self._serve_viewer()
|
self._serve_viewer()
|
||||||
elif self.path == "/api/status":
|
elif self.path == "/api/status":
|
||||||
self._serve_sse()
|
self._serve_sse()
|
||||||
|
elif self.path.startswith("/api/neighbor-data"):
|
||||||
|
self._handle_neighbor_data()
|
||||||
|
elif self.path.startswith("/api/neighbor-status"):
|
||||||
|
self._handle_neighbor_status()
|
||||||
else:
|
else:
|
||||||
self._send(404, "text/plain", b"Not found")
|
self._send(404, "text/plain", b"Not found")
|
||||||
|
|
||||||
@ -285,6 +427,20 @@ class NIDHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
|
# Merge any available neighbor data into the viewer data
|
||||||
|
with neighbor_lock:
|
||||||
|
if latest_neighbor:
|
||||||
|
nd = {}
|
||||||
|
for ip, npath in latest_neighbor.items():
|
||||||
|
if npath and npath.is_file():
|
||||||
|
try:
|
||||||
|
with npath.open() as f:
|
||||||
|
nd[ip] = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if nd:
|
||||||
|
data["neighbor_data"] = nd
|
||||||
|
|
||||||
html = build_html(data)
|
html = build_html(data)
|
||||||
self._send(200, "text/html; charset=utf-8", html.encode())
|
self._send(200, "text/html; charset=utf-8", html.encode())
|
||||||
|
|
||||||
@ -321,6 +477,8 @@ class NIDHandler(BaseHTTPRequestHandler):
|
|||||||
self._handle_ping()
|
self._handle_ping()
|
||||||
elif self.path == "/api/clear":
|
elif self.path == "/api/clear":
|
||||||
self._handle_clear()
|
self._handle_clear()
|
||||||
|
elif self.path == "/api/neighbor-walk":
|
||||||
|
self._handle_neighbor_walk()
|
||||||
else:
|
else:
|
||||||
self._send(404, "text/plain", b"Not found")
|
self._send(404, "text/plain", b"Not found")
|
||||||
|
|
||||||
@ -375,6 +533,75 @@ class NIDHandler(BaseHTTPRequestHandler):
|
|||||||
thread.start()
|
thread.start()
|
||||||
self._send_json(200, {"status": "started", "target": target, "mode": mode})
|
self._send_json(200, {"status": "started", "target": target, "mode": mode})
|
||||||
|
|
||||||
|
def _handle_neighbor_walk(self):
|
||||||
|
"""Start a neighbor device walk in a background thread."""
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(length)) if length else {}
|
||||||
|
target = body.get("target", "").strip()
|
||||||
|
rem_port_id = body.get("remPortId", "").strip()
|
||||||
|
rem_sys_name = body.get("remSysName", "").strip()
|
||||||
|
|
||||||
|
if not target or not rem_port_id:
|
||||||
|
self._send_json(400, {"error": "target and remPortId required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
ip_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
||||||
|
if not ip_re.match(target):
|
||||||
|
self._send_json(400, {"error": f"Invalid IP address: {target}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if already walking this neighbor
|
||||||
|
with neighbor_lock:
|
||||||
|
ns = neighbor_status.get(target, {})
|
||||||
|
if ns.get("state") == "walking":
|
||||||
|
self._send_json(409, {"error": f"Already walking {target}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=run_neighbor_walk,
|
||||||
|
args=(target, rem_port_id, rem_sys_name),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
self._send_json(200, {"status": "started", "target": target})
|
||||||
|
|
||||||
|
def _handle_neighbor_data(self):
|
||||||
|
"""Return the latest neighbor monitoring JSON for a given target IP."""
|
||||||
|
# Parse ?target=x.x.x.x from query string
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
qs = parse_qs(urlparse(self.path).query)
|
||||||
|
target = qs.get("target", [None])[0]
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
self._send_json(400, {"error": "target query param required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
with neighbor_lock:
|
||||||
|
json_path = latest_neighbor.get(target)
|
||||||
|
|
||||||
|
if not json_path or not json_path.is_file():
|
||||||
|
self._send_json(404, {"error": f"No neighbor data for {target}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
with json_path.open() as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self._send_json(200, data)
|
||||||
|
|
||||||
|
def _handle_neighbor_status(self):
|
||||||
|
"""Return the current walk status for a neighbor target."""
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
qs = parse_qs(urlparse(self.path).query)
|
||||||
|
target = qs.get("target", [None])[0]
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
self._send_json(400, {"error": "target query param required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
with neighbor_lock:
|
||||||
|
status = neighbor_status.get(target, {"state": "idle", "message": ""})
|
||||||
|
|
||||||
|
self._send_json(200, status)
|
||||||
|
|
||||||
def _handle_clear(self):
|
def _handle_clear(self):
|
||||||
"""Move all walk data to walks/archive/ and reset state."""
|
"""Move all walk data to walks/archive/ and reset state."""
|
||||||
global latest_json
|
global latest_json
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user