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
+104 -2
View File
@@ -2,12 +2,15 @@
import logging import logging
import threading import threading
import os import os
from flask import Flask, jsonify, send_file, render_template, request import csv
import io
from flask import Flask, jsonify, send_file, render_template, request, Response
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
import db import db
from db import save_node_positions, get_node_positions, clear_node_positions from db import save_node_positions, get_node_positions, clear_node_positions
import scanner import scanner
from nocodb_client import get_switches
from config import EXPORTS_DIR from config import EXPORTS_DIR
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
@@ -109,6 +112,35 @@ def api_scan_gw():
return jsonify({"status": "started", "dept": "GW"}) return jsonify({"status": "started", "dept": "GW"})
@app.route("/api/scan/sec", methods=["POST"])
def api_scan_sec():
"""Scan only SEC department switches."""
if scanner.scan_state["running"]:
return jsonify({"error": "Scan already running"}), 409
workers, login_delay = _scan_params()
_trigger_scan_background(dept="SEC", workers=workers, login_delay=login_delay)
return jsonify({"status": "started", "dept": "SEC"})
@app.route("/api/scan/ami", methods=["POST"])
def api_scan_ami():
"""Scan only AMI department switches."""
if scanner.scan_state["running"]:
return jsonify({"error": "Scan already running"}), 409
workers, login_delay = _scan_params()
_trigger_scan_background(dept="AMI", workers=workers, login_delay=login_delay)
return jsonify({"status": "started", "dept": "AMI"})
@app.route("/api/scan/abort", methods=["POST"])
def api_scan_abort():
"""Request the in-progress scan to stop after in-flight switches finish."""
if not scanner.scan_state["running"]:
return jsonify({"error": "No scan running"}), 409
scanner.request_abort()
return jsonify({"status": "aborting"})
@app.route("/api/status") @app.route("/api/status")
def api_status(): def api_status():
state = dict(scanner.scan_state) state = dict(scanner.scan_state)
@@ -124,6 +156,26 @@ def api_switches():
return jsonify(db.get_all_switches()) return jsonify(db.get_all_switches())
@app.route("/api/switches/inventory")
def api_switches_inventory():
"""Full active-switch list from NocoDB, for the test-connection device picker."""
try:
return jsonify(get_switches())
except RuntimeError as e:
return jsonify({"error": str(e)}), 502
@app.route("/api/test-connection", methods=["POST"])
def api_test_connection():
"""Try SSH + LLDP query against a single switch, independent of any running scan."""
data = request.get_json(silent=True) or {}
ip = (data.get("ip") or "").strip()
if not ip:
return jsonify({"error": "ip is required"}), 400
result = scanner.test_single_switch(ip, manufacturer=data.get("manufacturer", ""))
return jsonify(result)
@app.route("/api/links") @app.route("/api/links")
def api_links(): def api_links():
return jsonify(db.get_all_links()) return jsonify(db.get_all_links())
@@ -145,6 +197,7 @@ def api_topology():
"description": sw.get("description", ""), "description": sw.get("description", ""),
"firmware": sw.get("firmware", ""), "firmware": sw.get("firmware", ""),
"vendor": sw.get("vendor", ""), "vendor": sw.get("vendor", ""),
"serial": sw.get("serial", ""),
"chassis_id": chassis_id, "chassis_id": chassis_id,
"last_seen": sw.get("last_seen", ""), "last_seen": sw.get("last_seen", ""),
} }
@@ -170,6 +223,31 @@ def api_topology():
return jsonify({"nodes": nodes, "edges": edges}) return jsonify({"nodes": nodes, "edges": edges})
@app.route("/api/credentials", methods=["GET"])
def api_get_credentials():
"""Never returns the password itself — only whether one is set."""
username = db.get_setting("ssh_username") or ""
password_set = bool(db.get_setting("ssh_password"))
return jsonify({"username": username, "password_set": password_set})
@app.route("/api/credentials", methods=["POST"])
def api_set_credentials():
"""Update SSH credentials used by scans/tests. Blank password leaves the existing one unchanged."""
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username:
return jsonify({"error": "Username is required"}), 400
db.set_setting("ssh_username", username)
if password:
db.set_setting("ssh_password", password)
return jsonify({"username": username, "password_set": bool(db.get_setting("ssh_password"))})
@app.route("/api/settings", methods=["POST"]) @app.route("/api/settings", methods=["POST"])
def api_settings(): def api_settings():
data = request.json data = request.json
@@ -229,6 +307,30 @@ def api_clear_stale_switches():
deleted = db.clear_stale_switches(since) deleted = db.clear_stale_switches(since)
return jsonify({"deleted": deleted}) return jsonify({"deleted": deleted})
@app.route("/api/export/serials")
def api_export_serials():
"""Hostname/IP/Model/Serial for every scanned switch, generated live from the DB —
for pasting into NocoDB. Blank Serial means it wasn't found on that switch's
'show version' (or 'show system information' for Aruba/HP) output."""
switches = db.get_all_switches()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(['Hostname', 'Mgmt IP', 'Model', 'Vendor', 'Serial'])
for sw in sorted(switches, key=lambda s: s.get('hostname') or ''):
writer.writerow([
sw.get('hostname', ''),
sw.get('mgmt_ip', ''),
sw.get('description', ''),
sw.get('vendor', ''),
sw.get('serial', ''),
])
return Response(
buf.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=switch_serials.csv'},
)
@app.route("/api/export/csv") @app.route("/api/export/csv")
def api_export_csv(): def api_export_csv():
path = os.path.join(EXPORTS_DIR, "topology.csv") path = os.path.join(EXPORTS_DIR, "topology.csv")
@@ -255,4 +357,4 @@ def api_export_png():
if __name__ == "__main__": if __name__ == "__main__":
app.config['TEMPLATES_AUTO_RELOAD'] = True app.config['TEMPLATES_AUTO_RELOAD'] = True
app.run(host="0.0.0.0", port=5000, debug=False) app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)
+44 -11
View File
@@ -3,7 +3,7 @@ import sqlite3
import re import re
import os import os
import logging import logging
from config import DB_PATH from config import DB_PATH, SSH_USERNAME, SSH_PASSWORD
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,8 +30,8 @@ def init_db():
last_seen TEXT last_seen TEXT
) )
""") """)
# Migrate existing DBs that predate firmware/vendor columns # Migrate existing DBs that predate firmware/vendor/serial columns
for col, default in [('firmware', ''), ('vendor', '')]: for col, default in [('firmware', ''), ('vendor', ''), ('serial', '')]:
try: try:
c.execute(f"ALTER TABLE switches ADD COLUMN {col} TEXT DEFAULT '{default}'") c.execute(f"ALTER TABLE switches ADD COLUMN {col} TEXT DEFAULT '{default}'")
except Exception: except Exception:
@@ -68,6 +68,10 @@ def init_db():
c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_enabled', 'false')") c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_enabled', 'false')")
c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_interval', '60')") c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_interval', '60')")
# Seed SSH credentials from config.py on first run only — the settings
# table is the source of truth from then on (editable via /api/credentials).
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('ssh_username', ?)", (SSH_USERNAME,))
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('ssh_password', ?)", (SSH_PASSWORD,))
c.execute( c.execute(
"CREATE TABLE IF NOT EXISTS node_positions " "CREATE TABLE IF NOT EXISTS node_positions "
@@ -78,11 +82,11 @@ def init_db():
conn.close() conn.close()
def upsert_switch(chassis_id, hostname, mgmt_ip, description, firmware='', vendor=''): def upsert_switch(chassis_id, hostname, mgmt_ip, description, firmware='', vendor='', serial=''):
conn = get_conn() conn = get_conn()
conn.execute(""" conn.execute("""
INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, firmware, vendor, last_seen) INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, firmware, vendor, serial, last_seen)
VALUES (?, ?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(chassis_id) DO UPDATE SET ON CONFLICT(chassis_id) DO UPDATE SET
hostname = excluded.hostname, hostname = excluded.hostname,
mgmt_ip = CASE WHEN excluded.mgmt_ip != '' AND excluded.mgmt_ip LIKE '%.%.%.%' mgmt_ip = CASE WHEN excluded.mgmt_ip != '' AND excluded.mgmt_ip LIKE '%.%.%.%'
@@ -90,8 +94,9 @@ def upsert_switch(chassis_id, hostname, mgmt_ip, description, firmware='', vendo
description = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END, description = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END,
firmware = CASE WHEN excluded.firmware != '' THEN excluded.firmware ELSE firmware END, firmware = CASE WHEN excluded.firmware != '' THEN excluded.firmware ELSE firmware END,
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END, vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
serial = CASE WHEN excluded.serial != '' THEN excluded.serial ELSE serial END,
last_seen = excluded.last_seen last_seen = excluded.last_seen
""", (chassis_id, hostname, mgmt_ip, description, firmware, vendor)) """, (chassis_id, hostname, mgmt_ip, description, firmware, vendor, serial))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -103,10 +108,23 @@ def _port_num(port):
def upsert_link(chassis_a, port_a, chassis_b, port_b): def upsert_link(chassis_a, port_a, chassis_b, port_b):
"""
chassis_a/port_a is always the scanned switch's own self-reported port —
trustworthy. chassis_b/port_b is that switch's perception of its neighbor's
port, which for LLDP is often the neighbor's raw wire-transmitted Port ID
(an ifIndex-style number) rather than the neighbor's own friendly port name.
When the neighbor later gets scanned directly, its own upsert_link call
supplies the trustworthy label for what is now the "chassis_b" side here —
so on a match we overwrite that side with the newly-known self-report
instead of leaving the untrusted guess in place.
"""
self_chassis, self_port = chassis_a, port_a
# Normalize order so A→B and B→A are treated as the same link # Normalize order so A→B and B→A are treated as the same link
if chassis_a > chassis_b: if chassis_a > chassis_b:
chassis_a, chassis_b = chassis_b, chassis_a chassis_a, chassis_b = chassis_b, chassis_a
port_a, port_b = port_b, port_a port_a, port_b = port_b, port_a
conn = get_conn() conn = get_conn()
# Exact match first # Exact match first
existing = conn.execute(""" existing = conn.execute("""
@@ -116,13 +134,28 @@ def upsert_link(chassis_a, port_a, chassis_b, port_b):
# Fuzzy match: same chassis pair, same trailing port numbers # Fuzzy match: same chassis pair, same trailing port numbers
# handles 'Gi1/9' vs '9' reported by different vendors for the same cable # handles 'Gi1/9' vs '9' reported by different vendors for the same cable
rows = conn.execute(""" rows = conn.execute("""
SELECT port_a, port_b FROM links WHERE chassis_a=? AND chassis_b=? SELECT id, port_a, port_b FROM links WHERE chassis_a=? AND chassis_b=?
""", (chassis_a, chassis_b)).fetchall() """, (chassis_a, chassis_b)).fetchall()
for r in rows: for r in rows:
if _port_num(port_a) == _port_num(r[0]) and _port_num(port_b) == _port_num(r[1]): if _port_num(port_a) == _port_num(r[1]) and _port_num(port_b) == _port_num(r[2]):
existing = True existing = r
break break
if not existing: # A single physical port can only carry one live link, so an exact
# match on either side's port name (e.g. Aruba's front-panel label
# 'E5' vs the ifIndex-style '101' its neighbors report for it) means
# this is the same cable even though the trailing digits don't align.
if port_a == r[1] or port_b == r[2]:
existing = r
break
if existing:
# Overwrite whichever stored side corresponds to the self-reporting
# switch with its trusted value — it may currently hold the neighbor's
# untrusted guess from when this link was first inserted.
col = 'port_a' if self_chassis == chassis_a else 'port_b'
conn.execute(f"UPDATE links SET {col}=? WHERE id=?", (self_port, existing['id']))
conn.commit()
else:
conn.execute(""" conn.execute("""
INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b) INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
+264 -1
View File
@@ -464,6 +464,85 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
.btn-elec:hover { background: #e05a00; } .btn-elec:hover { background: #e05a00; }
.btn-gw { background: #1a5c2a; color: #fff; } .btn-gw { background: #1a5c2a; color: #fff; }
.btn-gw:hover { background: #247a39; } .btn-gw:hover { background: #247a39; }
.btn-sec { background: #7a2a00; color: #fff; }
.btn-sec:hover { background: #a83900; }
.btn-ami { background: #5500aa; color: #fff; }
.btn-ami:hover { background: #7700e0; }
/* ── Modals ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.55);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-overlay.visible { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
width: 380px;
max-width: 90vw;
box-shadow: 0 10px 40px rgba(0,0,0,.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 14px;
}
.modal-close {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 16px;
display: flex;
flex-direction: column;
}
.modal-label {
font-size: 12px;
color: var(--text-dim);
margin: 10px 0 6px;
}
.modal-label:first-child { margin-top: 0; }
.modal-hint { font-weight: 400; }
.modal-input {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
width: 100%;
font-family: inherit;
}
.modal-input:focus { outline: none; border-color: var(--accent); }
.modal-msg {
font-size: 12px;
margin-top: 10px;
white-space: pre-wrap;
line-height: 1.5;
}
.modal-msg.ok { color: var(--green); }
.modal-msg.fail { color: var(--red); }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
</style> </style>
</head> </head>
<body> <body>
@@ -507,6 +586,9 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<span class="setting-val" id="delayVal">3s</span> <span class="setting-val" id="delayVal">3s</span>
</div> </div>
<button class="btn-sm" onclick="openTestConnModal()" title="Test SSH connection to one device">⚡ Test Connection</button>
<button class="btn-sm" onclick="openCredentialsModal()" title="Update SSH credentials">🔑 Credentials</button>
<button class="btn btn-primary" id="scanBtn" onclick="triggerScan()"> <button class="btn btn-primary" id="scanBtn" onclick="triggerScan()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> <path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
@@ -521,12 +603,26 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M12 2C6 2 2 7 2 12s4 10 10 10 10-4.5 10-10S18 2 12 2zm0 4c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm4 12H8v-1c0-2.7 5.3-4 8-4v5z"/></svg> <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M12 2C6 2 2 7 2 12s4 10 10 10 10-4.5 10-10S18 2 12 2zm0 4c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm4 12H8v-1c0-2.7 5.3-4 8-4v5z"/></svg>
Scan GW Scan GW
</button> </button>
<button class="btn btn-sec" id="scanSecBtn" onclick="triggerScan('sec')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M12 2l8 4v6c0 5-3.4 8.7-8 10-4.6-1.3-8-5-8-10V6l8-4z"/></svg>
Scan SEC
</button>
<button class="btn btn-ami" id="scanAmiBtn" onclick="triggerScan('ami')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M4 14a8 8 0 0116 0M4 14h16M4 14l-1 6h18l-1-6M12 14V6m0 0l-3 3m3-3l3 3"/></svg>
Scan AMI
</button>
<button class="btn btn-danger" id="clearScanBtn" onclick="clearAndRescan()"> <button class="btn btn-danger" id="clearScanBtn" onclick="clearAndRescan()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6m4-6v6"/><path d="M9 6V4h6v2"/> <polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6m4-6v6"/><path d="M9 6V4h6v2"/>
</svg> </svg>
Clear &amp; Rescan Clear &amp; Rescan
</button> </button>
<button class="btn btn-danger" id="abortScanBtn" onclick="abortScan()" style="display:none">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="1"/>
</svg>
Abort
</button>
</div> </div>
</header> </header>
@@ -591,6 +687,7 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<button class="btn-sm" onclick="exportFile('csv')">⬇ CSV</button> <button class="btn-sm" onclick="exportFile('csv')">⬇ CSV</button>
<button class="btn-sm" onclick="exportFile('mermaid')">⬇ Mermaid</button> <button class="btn-sm" onclick="exportFile('mermaid')">⬇ Mermaid</button>
<button class="btn-sm" onclick="exportFile('png')">⬇ PNG</button> <button class="btn-sm" onclick="exportFile('png')">⬇ PNG</button>
<button class="btn-sm" onclick="exportFile('serials')" title="Hostname/IP/Model/Serial for pasting into NocoDB">⬇ Serials</button>
</div> </div>
</div> </div>
</div> </div>
@@ -605,6 +702,45 @@ var Xr=function(e){if(!(this instanceof Xr))return new Xr(e);this.id="Thenable/1
<div class="scan-log-body" id="scanLogBody"></div> <div class="scan-log-body" id="scanLogBody"></div>
</div> </div>
<!-- Modals -->
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this) closeModals()">
<div class="modal" id="credentialsModal" style="display:none">
<div class="modal-header">
<span>SSH Credentials</span>
<button class="modal-close" onclick="closeModals()"></button>
</div>
<div class="modal-body">
<div class="modal-label">Username</div>
<input type="text" id="credUsername" class="modal-input" autocomplete="off">
<div class="modal-label">Password <span class="modal-hint" id="credPasswordHint"></span></div>
<input type="password" id="credPassword" class="modal-input" autocomplete="new-password" placeholder="Leave blank to keep current password">
<div class="modal-msg" id="credMsg"></div>
</div>
<div class="modal-footer">
<button class="btn-sm" onclick="closeModals()">Cancel</button>
<button class="btn btn-primary" onclick="saveCredentials()">Save</button>
</div>
</div>
<div class="modal" id="testConnModal" style="display:none">
<div class="modal-header">
<span>Test Connection</span>
<button class="modal-close" onclick="closeModals()"></button>
</div>
<div class="modal-body">
<div class="modal-label">Switch</div>
<select id="testConnSelect" class="modal-input"><option>Loading…</option></select>
<div class="modal-msg" id="testConnResult"></div>
</div>
<div class="modal-footer">
<button class="btn-sm" onclick="closeModals()">Cancel</button>
<button class="btn btn-primary" id="testConnRunBtn" onclick="runTestConnection()">Test</button>
</div>
</div>
</div>
<script> <script>
/** /**
* Copyright (c) 2016-2025, The Cytoscape Consortium. * Copyright (c) 2016-2025, The Cytoscape Consortium.
@@ -827,6 +963,10 @@ function showNodeDetail(data) {
<span class="detail-key">Firmware</span> <span class="detail-key">Firmware</span>
<span class="detail-val">${data.firmware || '—'}</span> <span class="detail-val">${data.firmware || '—'}</span>
</div> </div>
<div class="detail-row">
<span class="detail-key">Serial</span>
<span class="detail-val">${data.serial || '—'}</span>
</div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-key">Last Seen</span> <span class="detail-key">Last Seen</span>
<span class="detail-val">${data.last_seen || '—'}</span> <span class="detail-val">${data.last_seen || '—'}</span>
@@ -916,6 +1056,8 @@ function scanSettings() {
async function triggerScan(dept) { async function triggerScan(dept) {
const url = dept === 'elec' ? '/api/scan/elec' const url = dept === 'elec' ? '/api/scan/elec'
: dept === 'gw' ? '/api/scan/gw' : dept === 'gw' ? '/api/scan/gw'
: dept === 'sec' ? '/api/scan/sec'
: dept === 'ami' ? '/api/scan/ami'
: '/api/scan'; : '/api/scan';
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@@ -926,6 +1068,116 @@ async function triggerScan(dept) {
startPolling(); startPolling();
} }
async function abortScan() {
if (!confirm('Abort the running scan? Switches already logged in will finish; the rest are skipped.')) return;
const res = await fetch('/api/scan/abort', { method: 'POST' });
if (res.status === 409) alert('No scan running');
}
// ── Modals ──
function closeModals() {
document.getElementById('modalOverlay').classList.remove('visible');
document.getElementById('credentialsModal').style.display = 'none';
document.getElementById('testConnModal').style.display = 'none';
}
async function openCredentialsModal() {
document.getElementById('modalOverlay').classList.add('visible');
document.getElementById('credentialsModal').style.display = 'block';
const msg = document.getElementById('credMsg');
msg.textContent = '';
msg.className = 'modal-msg';
document.getElementById('credPassword').value = '';
const res = await fetch('/api/credentials');
const d = await res.json();
document.getElementById('credUsername').value = d.username || '';
document.getElementById('credPasswordHint').textContent = d.password_set ? '(currently set — leave blank to keep it)' : '(not set)';
}
async function saveCredentials() {
const username = document.getElementById('credUsername').value.trim();
const password = document.getElementById('credPassword').value;
const msg = document.getElementById('credMsg');
if (!username) {
msg.textContent = 'Username is required';
msg.className = 'modal-msg fail';
return;
}
const res = await fetch('/api/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const d = await res.json();
if (d.error) {
msg.textContent = d.error;
msg.className = 'modal-msg fail';
return;
}
document.getElementById('credPassword').value = '';
document.getElementById('credPasswordHint').textContent = d.password_set ? '(currently set — leave blank to keep it)' : '(not set)';
msg.textContent = 'Saved.';
msg.className = 'modal-msg ok';
}
async function openTestConnModal() {
document.getElementById('modalOverlay').classList.add('visible');
document.getElementById('testConnModal').style.display = 'block';
const result = document.getElementById('testConnResult');
result.textContent = '';
result.className = 'modal-msg';
const sel = document.getElementById('testConnSelect');
sel.innerHTML = '<option>Loading…</option>';
try {
const res = await fetch('/api/switches/inventory');
const list = await res.json();
if (list.error) { sel.innerHTML = `<option value="">${list.error}</option>`; return; }
if (!list.length) { sel.innerHTML = '<option value="">No active switches found</option>'; return; }
sel.innerHTML = list.map(s =>
`<option value="${s.ip}" data-manufacturer="${s.manufacturer || ''}">${s.hostname || s.ip} (${s.ip})${s.dept ? ' — ' + s.dept : ''}</option>`
).join('');
} catch (e) {
sel.innerHTML = '<option value="">Failed to load switches</option>';
}
}
async function runTestConnection() {
const sel = document.getElementById('testConnSelect');
const opt = sel.options[sel.selectedIndex];
const result = document.getElementById('testConnResult');
if (!opt || !opt.value) {
result.className = 'modal-msg fail';
result.textContent = 'No switch selected';
return;
}
const ip = opt.value;
const manufacturer = opt.getAttribute('data-manufacturer') || '';
const btn = document.getElementById('testConnRunBtn');
btn.disabled = true;
result.className = 'modal-msg';
result.textContent = `Connecting to ${ip}…`;
try {
const res = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip, manufacturer }),
});
const d = await res.json();
if (d.success) {
result.className = 'modal-msg ok';
result.textContent = `✓ ${d.hostname} [${d.vendor}]\n${d.neighbors.length} LLDP neighbor(s)\nFirmware: ${d.firmware || 'n/a'}`;
} else {
result.className = 'modal-msg fail';
result.textContent = `✗ ${d.error || 'Unknown error'}`;
}
} catch (e) {
result.className = 'modal-msg fail';
result.textContent = `✗ Request failed: ${e}`;
} finally {
btn.disabled = false;
}
}
function startPolling() { function startPolling() {
if (pollInterval) return; if (pollInterval) return;
pollInterval = setInterval(pollStatus, 1500); pollInterval = setInterval(pollStatus, 1500);
@@ -947,7 +1199,10 @@ async function pollStatus() {
scanBtn.disabled = true; scanBtn.disabled = true;
document.getElementById('scanElecBtn').disabled = true; document.getElementById('scanElecBtn').disabled = true;
document.getElementById('scanGwBtn').disabled = true; document.getElementById('scanGwBtn').disabled = true;
document.getElementById('scanSecBtn').disabled = true;
document.getElementById('scanAmiBtn').disabled = true;
document.getElementById('clearScanBtn').disabled = true; document.getElementById('clearScanBtn').disabled = true;
document.getElementById('abortScanBtn').style.display = '';
progressWrap.classList.add('visible'); progressWrap.classList.add('visible');
statusBar.classList.add('visible'); statusBar.classList.add('visible');
document.getElementById('scanSpinner').style.display = ''; document.getElementById('scanSpinner').style.display = '';
@@ -991,7 +1246,12 @@ async function pollStatus() {
logBody.scrollTop = logBody.scrollHeight; logBody.scrollTop = logBody.scrollHeight;
spinner.style.display = 'none'; spinner.style.display = 'none';
if (s.fail > 0) { if (s.aborted) {
statusText.innerHTML =
`⚠ Scan aborted &nbsp;&nbsp; ` +
`<span class="ok-count">✓ ${s.ok}</span>&nbsp;&nbsp;` +
`<span class="fail-count">✗ ${s.fail}</span>`;
} else if (s.fail > 0) {
statusText.innerHTML = statusText.innerHTML =
`Scan complete &nbsp;&nbsp; ` + `Scan complete &nbsp;&nbsp; ` +
`<span class="ok-count">✓ ${s.ok}</span>&nbsp;&nbsp;` + `<span class="ok-count">✓ ${s.ok}</span>&nbsp;&nbsp;` +
@@ -1004,7 +1264,10 @@ async function pollStatus() {
scanBtn.disabled = false; scanBtn.disabled = false;
document.getElementById('scanElecBtn').disabled = false; document.getElementById('scanElecBtn').disabled = false;
document.getElementById('scanGwBtn').disabled = false; document.getElementById('scanGwBtn').disabled = false;
document.getElementById('scanSecBtn').disabled = false;
document.getElementById('scanAmiBtn').disabled = false;
document.getElementById('clearScanBtn').disabled = false; document.getElementById('clearScanBtn').disabled = false;
document.getElementById('abortScanBtn').style.display = 'none';
progressBar.style.width = '100%'; progressBar.style.width = '100%';
setTimeout(() => { setTimeout(() => {
progressWrap.classList.remove('visible'); progressWrap.classList.remove('visible');
+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)) return '-'.join(clean[i:i+2] for i in range(0, 12, 2))
def shorten_interface(iface): def port_number(iface):
"""GigabitEthernet 1/9 -> Gi1/9, TenGigabitEthernet 1/1 -> Te1/1 etc.""" """Reduce any interface label to its trailing port number: 'GigabitEthernet1/9' -> '9', 'E14' -> '14', '9' -> '9'."""
replacements = [ m = re.search(r'(\d+)$', (iface or '').strip())
(r'GigabitEthernet\s*', 'Gi'), return m.group(1) if m else (iface or '').strip()
(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 parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgmt_ip): 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) m = re.search(pattern, text, re.IGNORECASE)
return m.group(1).strip() if m else default return m.group(1).strip() if m else default
neighbor['local_port'] = shorten_interface(extract(r'Local Interface\s*:\s*(.+)', block)) # [ \t]* (not \s*) after the colon — a blank field followed immediately by
neighbor['chassis_id'] = extract(r'Chassis ID\s*:\s*(.+)', block) # the next field line must not let the match bleed across the newline into it.
neighbor['port_id'] = extract(r'Port ID\s*:\s*(.+)', block) neighbor['local_port'] = port_number(extract(r'Local Interface\s*:[ \t]*(.+)', block))
neighbor['port_desc'] = shorten_interface(extract(r'Port Description\s*:\s*(.+)', block)) neighbor['chassis_id'] = extract(r'Chassis ID\s*:[ \t]*(.+)', block)
neighbor['system_name'] = extract(r'System Name\s*:\s*(.+)', block) neighbor['port_id'] = extract(r'Port ID\s*:[ \t]*(.+)', block)
neighbor['system_desc'] = extract(r'System Description\s*:\s*(.+)', 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)') # 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. # 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) 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) 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['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) # Only include bridge/router neighbors skip phones, APs, print servers,
if neighbor['chassis_id'] and neighbor['system_name']: # cameras, etc. that show up as "Station Only" or similar endpoint-only capabilities.
# Use port_desc as remote port if available, fallback to port_id is_bridge_or_router = re.search(r'Bridge|Router', neighbor['capabilities'], re.IGNORECASE)
neighbor['remote_port'] = neighbor['port_desc'] if neighbor['port_desc'] else neighbor['port_id'] 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) neighbors.append(neighbor)
return neighbors return neighbors
@@ -100,6 +96,14 @@ def parse_aruba_procurve_local(raw_output):
'system_desc': system_desc, 'mgmt_ip': mgmt_ip} '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): def parse_aruba_procurve_neighbors(raw_output):
""" """
Parse 'show lldp info remote-device' tabular output from HP/Aruba switch. Parse 'show lldp info remote-device' tabular output from HP/Aruba switch.
@@ -116,14 +120,26 @@ def parse_aruba_procurve_neighbors(raw_output):
continue continue
left, right = line.split('|', 1) 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() local_port = left.strip()
parts = right.split() right = right.strip()
if len(parts) < 4:
continue
chassis_id = normalize_mac(parts[0]) m = _SPACED_MAC_RE.match(right)
port_id = parts[1] if m:
sys_name = parts[3] 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({ neighbors.append({
'local_port': local_port, 'local_port': local_port,
+34 -1
View File
@@ -1,5 +1,6 @@
# scanner.py - Orchestrates the full scan pipeline # scanner.py - Orchestrates the full scan pipeline
import logging import logging
import threading
from nocodb_client import get_switches from nocodb_client import get_switches
from ssh_client import scan_all_switches from ssh_client import scan_all_switches
from db import ( from db import (
@@ -24,8 +25,19 @@ scan_state = {
"log_lines": [], "log_lines": [],
"last_scan": None, "last_scan": None,
"dept_filter": None, # None = all, "ELEC" or "GW" = dept-only "dept_filter": None, # None = all, "ELEC" or "GW" = dept-only
"aborted": False,
} }
_abort_event = threading.Event()
def request_abort():
"""Signal the in-progress scan to stop after currently in-flight switches finish."""
if scan_state["running"]:
_abort_event.set()
return True
return False
def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3): def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
""" """
@@ -38,6 +50,8 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
logger.warning("Scan already running, skipping.") logger.warning("Scan already running, skipping.")
return return
_abort_event.clear()
# Fetch switch list from NocoDB (or fallback) # Fetch switch list from NocoDB (or fallback)
switches = get_switches(dept=dept) switches = get_switches(dept=dept)
@@ -59,6 +73,7 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
"errors": [], "errors": [],
"log_lines": [], "log_lines": [],
"dept_filter": dept, "dept_filter": dept,
"aborted": False,
}) })
scan_id = log_scan_start() scan_id = log_scan_start()
@@ -89,6 +104,7 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
description=result.get("description", ""), description=result.get("description", ""),
firmware=result.get("firmware", ""), firmware=result.get("firmware", ""),
vendor=result.get("vendor", ""), vendor=result.get("vendor", ""),
serial=result.get("serial", ""),
) )
for neighbor in result["neighbors"]: for neighbor in result["neighbors"]:
@@ -119,7 +135,16 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
"text": f"{ip}{error_short}", "text": f"{ip}{error_short}",
}) })
scan_all_switches(switches, progress_callback=on_progress, max_workers=workers, login_delay=login_delay) scan_all_switches(switches, progress_callback=on_progress, max_workers=workers,
login_delay=login_delay, abort_event=_abort_event)
if _abort_event.is_set():
scan_state["aborted"] = True
from datetime import datetime
scan_state["log_lines"].append({
"ts": datetime.now().strftime("%H:%M:%S"), "ok": False,
"text": "⚠ Scan aborted by user",
})
try: try:
merge_duplicate_switches() merge_duplicate_switches()
@@ -141,3 +166,11 @@ def run_scan(dept: str = None, workers: int = 5, login_delay: int = 3):
scan_state["dept_filter"] = None scan_state["dept_filter"] = None
logger.info(f"Scan complete. OK: {scan_state['ok']}, Failed: {scan_state['fail']}") logger.info(f"Scan complete. OK: {scan_state['ok']}, Failed: {scan_state['fail']}")
def test_single_switch(ip: str, manufacturer: str = "", login_delay: int = 1) -> dict:
"""Test SSH connectivity/LLDP query against a single switch, independent of the main scan.
Does not touch scan_state or the database — just returns the raw connect_and_query result."""
entry = {"ip": ip, "manufacturer": manufacturer}
results = scan_all_switches([entry], max_workers=1, login_delay=login_delay)
return results[0] if results else {"success": False, "ip": ip, "error": "No result"}
+44 -12
View File
@@ -7,7 +7,8 @@ import socket
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial from functools import partial
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
from config import SSH_USERNAME, SSH_PASSWORD, SSH_PORT, SSH_TIMEOUT, DEVICE_TYPE from config import SSH_PORT, SSH_TIMEOUT, DEVICE_TYPE
import db
from parser import (parse_lldp_neighbors, parse_mgmt_ip_from_interfaces, from parser import (parse_lldp_neighbors, parse_mgmt_ip_from_interfaces,
parse_aruba_procurve_local, parse_aruba_procurve_neighbors, parse_aruba_procurve_local, parse_aruba_procurve_neighbors,
normalize_mac) normalize_mac)
@@ -35,12 +36,18 @@ def _device_type_for(manufacturer: str) -> str:
return DEVICE_TYPE return DEVICE_TYPE
def _get_credentials():
"""SSH credentials, editable at runtime via /api/credentials (stored in the settings table)."""
return db.get_setting("ssh_username"), db.get_setting("ssh_password")
def connect_and_query(ip, login_delay=3, device_type=None): def connect_and_query(ip, login_delay=3, device_type=None):
username, password = _get_credentials()
device = { device = {
"device_type": device_type or DEVICE_TYPE, "device_type": device_type or DEVICE_TYPE,
"host": ip, "host": ip,
"username": SSH_USERNAME, "username": username,
"password": SSH_PASSWORD, "password": password,
"port": SSH_PORT, "port": SSH_PORT,
"timeout": SSH_TIMEOUT, "timeout": SSH_TIMEOUT,
"global_delay_factor": 2, "global_delay_factor": 2,
@@ -78,9 +85,9 @@ def connect_and_query(ip, login_delay=3, device_type=None):
vendor = _detect_vendor(version_output) vendor = _detect_vendor(version_output)
if vendor == 'aruba_procurve': if vendor == 'aruba_procurve':
chassis_id, mgmt_ip, model, firmware, neighbors = _query_aruba(conn, version_output, ip) chassis_id, mgmt_ip, model, firmware, serial, neighbors = _query_aruba(conn, version_output, ip)
else: else:
chassis_id, mgmt_ip, model, firmware, neighbors = _query_fs(conn, version_output, ip) chassis_id, mgmt_ip, model, firmware, serial, neighbors = _query_fs(conn, version_output, ip)
finally: finally:
conn.disconnect() conn.disconnect()
@@ -94,6 +101,7 @@ def connect_and_query(ip, login_delay=3, device_type=None):
"mgmt_ip": mgmt_ip, "mgmt_ip": mgmt_ip,
"description": model, "description": model,
"firmware": firmware, "firmware": firmware,
"serial": serial,
"vendor": vendor, "vendor": vendor,
"neighbors": neighbors, "neighbors": neighbors,
} }
@@ -110,7 +118,7 @@ def connect_and_query(ip, login_delay=3, device_type=None):
def _query_fs(conn, version_output, ip): def _query_fs(conn, version_output, ip):
"""Run FS/IES switch-specific commands and return (chassis_id, mgmt_ip, model, firmware, neighbors).""" """Run FS/IES switch-specific commands and return (chassis_id, mgmt_ip, model, firmware, serial, neighbors)."""
intf_output = conn.send_command("show ip interface brief", read_timeout=30) intf_output = conn.send_command("show ip interface brief", read_timeout=30)
local_info_output = conn.send_command("show lldp local-information", read_timeout=30) local_info_output = conn.send_command("show lldp local-information", read_timeout=30)
lldp_output = conn.send_command("show lldp neighbors", read_timeout=30) lldp_output = conn.send_command("show lldp neighbors", read_timeout=30)
@@ -123,24 +131,27 @@ def _query_fs(conn, version_output, ip):
model = _fs_model(version_output) or _extract_system_desc(local_info_output) model = _fs_model(version_output) or _extract_system_desc(local_info_output)
firmware = _fs_firmware(version_output) firmware = _fs_firmware(version_output)
serial = _extract_serial(version_output)
neighbors = parse_lldp_neighbors(lldp_output, chassis_id, '', mgmt_ip) neighbors = parse_lldp_neighbors(lldp_output, chassis_id, '', mgmt_ip)
return chassis_id, mgmt_ip, model, firmware, neighbors return chassis_id, mgmt_ip, model, firmware, serial, neighbors
def _query_aruba(conn, version_output, ip): def _query_aruba(conn, version_output, ip):
"""Run HP/Aruba ProCurve-specific commands and return (chassis_id, mgmt_ip, model, firmware, neighbors).""" """Run HP/Aruba ProCurve-specific commands and return (chassis_id, mgmt_ip, model, firmware, serial, neighbors)."""
local_output = conn.send_command("show lldp info local-device", read_timeout=30) local_output = conn.send_command("show lldp info local-device", read_timeout=30)
remote_output = conn.send_command("show lldp info remote-device", read_timeout=30) remote_output = conn.send_command("show lldp info remote-device", read_timeout=30)
sysinfo_output = conn.send_command("show system information", read_timeout=30)
local_info = parse_aruba_procurve_local(local_output) local_info = parse_aruba_procurve_local(local_output)
chassis_id = local_info['chassis_id'] or _extract_mac_from_version(version_output) or ip chassis_id = local_info['chassis_id'] or _extract_mac_from_version(version_output) or ip
mgmt_ip = local_info['mgmt_ip'] or ip mgmt_ip = local_info['mgmt_ip'] or ip
model = _aruba_model(local_info.get('system_desc', '')) model = _aruba_model(local_info.get('system_desc', ''))
firmware = _aruba_firmware(version_output) firmware = _aruba_firmware(version_output)
serial = _extract_serial(sysinfo_output) or _extract_serial(version_output)
neighbors = parse_aruba_procurve_neighbors(remote_output) neighbors = parse_aruba_procurve_neighbors(remote_output)
return chassis_id, mgmt_ip, model, firmware, neighbors return chassis_id, mgmt_ip, model, firmware, serial, neighbors
# ── Vendor detection ────────────────────────────────────────────────────────── # ── Vendor detection ──────────────────────────────────────────────────────────
@@ -165,6 +176,16 @@ def _extract_mac_from_version(output):
return m.group(1).strip() if m else None return m.group(1).strip() if m else None
def _extract_serial(output):
"""Serial number field — covers 'Serial Number:'/'Serial No:'/'Serial:' spellings
plus FS/IES's 'SN :' field (confirmed from a live rec-10016-sw01 'show version')."""
m = re.search(r'Serial\s*(?:No\.?|Number)?\s*:\s*(\S+)', output, re.IGNORECASE)
if m:
return m.group(1).strip()
m = re.search(r'^\s*SN\s*:\s*(\S+)', output, re.IGNORECASE | re.MULTILINE)
return m.group(1).strip() if m else ''
def _fs_model(output): def _fs_model(output):
m = re.search(r'(?:Model Name|Product)\s*:\s*([\w\-]+)', output, re.IGNORECASE) m = re.search(r'(?:Model Name|Product)\s*:\s*([\w\-]+)', output, re.IGNORECASE)
return f"FS {m.group(1)}" if m else '' return f"FS {m.group(1)}" if m else ''
@@ -203,8 +224,10 @@ def _aruba_firmware(version_output):
# ── Scan orchestration ──────────────────────────────────────────────────────── # ── Scan orchestration ────────────────────────────────────────────────────────
def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_delay=3): def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_delay=3, abort_event=None):
"""switch_list: list of IP strings or dicts with 'ip' and optional 'manufacturer'.""" """switch_list: list of IP strings or dicts with 'ip' and optional 'manufacturer'.
abort_event: optional threading.Event — when set, stops queueing new work and
returns without waiting on switches still in flight."""
results = [] results = []
total = len(switch_list) total = len(switch_list)
done = 0 done = 0
@@ -217,7 +240,8 @@ def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_
ip, dt = entry, None ip, dt = entry, None
return ip, executor.submit(connect_and_query, ip, login_delay=login_delay, device_type=dt) return ip, executor.submit(connect_and_query, ip, login_delay=login_delay, device_type=dt)
with ThreadPoolExecutor(max_workers=max_workers) as executor: executor = ThreadPoolExecutor(max_workers=max_workers)
try:
future_to_ip = {} future_to_ip = {}
for entry in switch_list: for entry in switch_list:
ip, fut = _submit(entry) ip, fut = _submit(entry)
@@ -237,4 +261,12 @@ def scan_all_switches(switch_list, progress_callback=None, max_workers=5, login_
if progress_callback: if progress_callback:
progress_callback(done, total, ip, result) progress_callback(done, total, ip, result)
if abort_event is not None and abort_event.is_set():
logger.warning("Scan aborted — stopping after in-flight switches finish.")
break
finally:
# wait=False: don't block on switches still logging in; they'll finish
# in the background and their results are discarded.
executor.shutdown(wait=False, cancel_futures=True)
return results return results
+45 -59
View File
@@ -16,13 +16,8 @@ logger = logging.getLogger(__name__)
# --- Configuration --- # --- Configuration ---
DB_PATH = "/opt/lldp-mapper/data/network.db" DB_PATH = "/opt/lldp-mapper/data/network.db"
NETBOX_URL = "http://192.168.16.130:8001" NETBOX_URL = "http://192.168.16.137:8000"
NETBOX_TOKEN = "nbt_T6aq9XpwNFQG.HtZziXuSATgabbeagWKk3vEhc2Ask1EMV210PWMM" NETBOX_TOKEN = "nbt_LYkI6iSsflIU.9N4ziQxpbrV1AGPmpk0fwhfhJOSrSr0nGXEF4Ze2"
SITE_NAME = "Field Sites"
DEVICE_ROLE = "Access Switch"
MANUFACTURER = "FS"
DEVICE_TYPE = "FS Switch"
# --- Helpers --- # --- Helpers ---
@@ -72,54 +67,21 @@ def load_db():
# --- NetBox Sync --- # --- NetBox Sync ---
#
# This NetBox instance is hand-curated (real sites, device types, roles) —
# this script never creates devices. It only looks up devices that already
# exist by hostname and syncs their interfaces/cables/IP. A switch lldp-mapper
# knows about but that has no matching device in NetBox is skipped with a
# warning so it can be added properly (correct site/manufacturer) by hand.
def ensure_site(): def ensure_device(switch):
return nb_get_or_create(
"dcim/sites/",
{"name": SITE_NAME},
{"name": SITE_NAME, "slug": SITE_NAME.lower().replace(" ", "-")}
)
def ensure_manufacturer():
return nb_get_or_create(
"dcim/manufacturers/",
{"name": MANUFACTURER},
{"name": MANUFACTURER, "slug": MANUFACTURER.lower()}
)
def ensure_device_type(manufacturer_id):
return nb_get_or_create(
"dcim/device-types/",
{"slug": "fs-switch"},
{"model": DEVICE_TYPE, "slug": "fs-switch", "manufacturer": manufacturer_id}
)
def ensure_device_role():
return nb_get_or_create(
"dcim/device-roles/",
{"name": DEVICE_ROLE},
{"name": DEVICE_ROLE, "slug": "access-switch", "color": "2196f3"}
)
def ensure_device(switch, site_id, device_type_id, role_id):
hostname = switch["hostname"] or switch["mgmt_ip"] hostname = switch["hostname"] or switch["mgmt_ip"]
results = nb_get("dcim/devices/", params={"name": hostname}).get("results", []) results = nb_get("dcim/devices/", params={"name": hostname}).get("results", [])
if results: if results:
logger.info(f" Device exists: {hostname}") logger.info(f" Device exists: {hostname}")
return results[0] return results[0]
logger.info(f" Creating device: {hostname}") logger.warning(f" Skipping {hostname} — no matching device in NetBox (add it manually first)")
return nb_post("dcim/devices/", { return None
"name": hostname,
"site": site_id,
"device_type": device_type_id,
"role": role_id,
"status": "active",
"comments": f"Chassis ID: {switch['chassis_id']}\nDiscovered by lldp-mapper",
})
def ensure_interface(device_id, port_name): def ensure_interface(device_id, port_name):
@@ -136,6 +98,35 @@ def ensure_interface(device_id, port_name):
}) })
def ensure_serial(switch, device):
serial = switch.get("serial") or ""
if not serial or device.get("serial") == serial:
return
logger.info(f" Updating serial: {serial}")
requests.patch(
f"{NETBOX_URL}/api/dcim/devices/{device['id']}/",
headers=NB_HEADERS,
json={"serial": serial}
)
def ensure_mac_comment(switch, device):
mac = switch.get("chassis_id") or ""
if not mac or "-" not in mac: # only real MACs, not IP-based chassis IDs
return
line = f"Chassis MAC: {mac}"
comments = device.get("comments") or ""
if line in comments:
return
logger.info(f" Recording chassis MAC in comments: {mac}")
new_comments = f"{comments}\n{line}".strip() if comments else line
requests.patch(
f"{NETBOX_URL}/api/dcim/devices/{device['id']}/",
headers=NB_HEADERS,
json={"comments": new_comments}
)
def ensure_ip(switch, device_id): def ensure_ip(switch, device_id):
if not switch.get("mgmt_ip"): if not switch.get("mgmt_ip"):
return return
@@ -187,29 +178,24 @@ def ensure_cable(device_map, link):
"a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}], "a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}], "b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}],
"status": "connected", "status": "connected",
"label": f"{hn_a}{hn_b}",
"type": "smf-os2",
}) })
def sync_netbox(switches, links): def sync_netbox(switches, links):
logger.info("=== Syncing to NetBox ===") logger.info("=== Syncing to NetBox ===")
site = ensure_site()
manufacturer = ensure_manufacturer()
device_type = ensure_device_type(manufacturer["id"])
role = ensure_device_role()
site_id = site["id"]
device_type_id = device_type["id"]
role_id = role["id"]
device_map = {} device_map = {}
for sw in switches: for sw in switches:
hostname = sw["hostname"] or sw["mgmt_ip"] hostname = sw["hostname"] or sw["mgmt_ip"]
logger.info(f"Processing device: {hostname}") logger.info(f"Processing device: {hostname}")
device = ensure_device(sw, site_id, device_type_id, role_id) device = ensure_device(sw)
if device: if device:
device_map[hostname] = device device_map[hostname] = device
ensure_serial(sw, device)
ensure_mac_comment(sw, device)
ensure_ip(sw, device["id"]) ensure_ip(sw, device["id"])
logger.info(f"Devices synced: {len(device_map)}") logger.info(f"Devices synced: {len(device_map)}")