Initial commit — LLDP network mapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dstephenson
2026-04-21 20:56:13 +00:00
commit 40d4679a59
17 changed files with 2691 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
data/
backup/
__pycache__/
*.pyc
*.pyo
*.bak
*.swp
*.kate-swp
config.py
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim
# Install graphviz for PNG rendering
RUN apt-get update && apt-get install -y --no-install-recommends \
graphviz \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy and install requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy all Python modules
COPY config.py db.py parser.py ssh_client.py scanner.py exports.py app.py nocodb_client.py ./
# Copy frontend template
COPY index.html ./templates/index.html
# Create static and data dirs
RUN mkdir -p ./static /data
# Data volume for SQLite + exports
VOLUME ["/data"]
EXPOSE 5000
CMD ["python", "app.py"]
+95
View File
@@ -0,0 +1,95 @@
# LLDP Network Mapper
SSH-based network topology mapper for FS switches (and IOS-like CLIs).
Discovers switch topology via LLDP, stores in SQLite, and visualises with Cytoscape.js.
## Quick Start
### 1. Configure your switches
Edit `mapper/config.py`:
```python
SWITCHES = [
"192.168.1.1",
"192.168.1.2",
# Add all switch management IPs
]
SSH_USERNAME = "admin"
SSH_PASSWORD = "yourpassword"
```
### 2. Build and run
```bash
docker-compose up --build
```
### 3. Open the UI
Navigate to: http://localhost:5000
Click **Scan Now** to start discovery.
---
## What it does
- SSHs into each switch in parallel (up to 10 at once)
- Runs `show lldp neighbors` and `show ip interface brief`
- Parses neighbors, hostnames, management IPs, chassis IDs
- Stores everything in SQLite (`data/network.db`)
- De-duplicates bidirectional links automatically
- Renders an interactive Cytoscape.js topology diagram
- Exports to CSV, Mermaid (.md), and Graphviz PNG
## Outputs
All files written to `data/exports/`:
| File | Purpose |
|------|---------|
| `topology.csv` | Switch links with hostnames and IPs |
| `topology.md` | Mermaid diagram (paste into any markdown viewer) |
| `topology.dot` | Graphviz source |
| `topology.png` | Rendered network diagram |
## Auto-scan
Toggle auto-scan on/off from the UI. Set interval (15 min to 6 hours).
State persists across container restarts.
## Troubleshooting
**Auth errors**: Check SSH_USERNAME / SSH_PASSWORD in config.py
**Timeout errors**: Increase SSH_TIMEOUT in config.py (default: 30s)
**Wrong device type**: If your FS switch uses a non-IOS CLI, try changing
DEVICE_TYPE to `"linux"` or `"generic"` in config.py
**No management IP found**: The script looks for Vlan interfaces in
`show ip interface brief`. If your switch uses a different command,
edit `parser.py → parse_mgmt_ip_from_interfaces()`
## Project Structure
```
lldp-mapper/
├── mapper/
│ ├── config.py # Switch IPs + credentials
│ ├── db.py # SQLite operations
│ ├── parser.py # LLDP output parser
│ ├── ssh_client.py # Netmiko SSH + parallel scan
│ ├── scanner.py # Orchestrator
│ └── exports.py # CSV / Mermaid / Graphviz
├── web/
│ ├── app.py # Flask API + scheduler
│ └── templates/
│ └── index.html # Cytoscape.js frontend
├── data/ # SQLite DB + exports (created at runtime)
├── Dockerfile
├── docker-compose.yml
└── README.md
```
+228
View File
@@ -0,0 +1,228 @@
# app.py - Flask backend
import logging
import threading
import os
from flask import Flask, jsonify, send_file, render_template, request
from apscheduler.schedulers.background import BackgroundScheduler
import db
from db import save_node_positions, get_node_positions, clear_node_positions
import scanner
from config import EXPORTS_DIR
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
app = Flask(__name__, template_folder='templates', static_folder='static')
# --- Scheduler setup ---
apscheduler = BackgroundScheduler()
apscheduler.start()
_scheduler_job_id = "autoscan"
def _reschedule(interval_minutes):
if apscheduler.get_job(_scheduler_job_id):
apscheduler.remove_job(_scheduler_job_id)
apscheduler.add_job(
lambda: _trigger_scan_background(dept=None),
'interval',
minutes=int(interval_minutes),
id=_scheduler_job_id,
replace_existing=True
)
logger.info(f"Auto-scan scheduled every {interval_minutes} minutes")
def _trigger_scan_background(dept: str = None):
"""Run scan in a background thread. dept=None → all switches."""
if not scanner.scan_state["running"]:
t = threading.Thread(target=scanner.run_scan, kwargs={"dept": dept}, daemon=True)
t.start()
# Restore scheduler state on startup
db.init_db()
if db.get_setting("autoscan_enabled") == "true":
interval = db.get_setting("autoscan_interval") or "60"
_reschedule(interval)
# --- API Routes ---
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/scan", methods=["POST"])
def api_scan():
"""Scan all active switches (no dept filter)."""
if scanner.scan_state["running"]:
return jsonify({"error": "Scan already running"}), 409
_trigger_scan_background(dept=None)
return jsonify({"status": "started", "dept": None})
@app.route("/api/scan/elec", methods=["POST"])
def api_scan_elec():
"""Scan only ELEC department switches."""
if scanner.scan_state["running"]:
return jsonify({"error": "Scan already running"}), 409
_trigger_scan_background(dept="ELEC")
return jsonify({"status": "started", "dept": "ELEC"})
@app.route("/api/scan/gw", methods=["POST"])
def api_scan_gw():
"""Scan only GW department switches."""
if scanner.scan_state["running"]:
return jsonify({"error": "Scan already running"}), 409
_trigger_scan_background(dept="GW")
return jsonify({"status": "started", "dept": "GW"})
@app.route("/api/status")
def api_status():
state = dict(scanner.scan_state)
last = db.get_last_scan()
state["last_scan_log"] = dict(last) if last else None
state["autoscan_enabled"] = db.get_setting("autoscan_enabled") == "true"
state["autoscan_interval"] = int(db.get_setting("autoscan_interval") or 60)
return jsonify(state)
@app.route("/api/switches")
def api_switches():
return jsonify(db.get_all_switches())
@app.route("/api/links")
def api_links():
return jsonify(db.get_all_links())
@app.route("/api/topology")
def api_topology():
switches = {s["chassis_id"]: s for s in db.get_all_switches()}
links = db.get_all_links()
nodes = []
for chassis_id, sw in switches.items():
nodes.append({
"data": {
"id": chassis_id,
"label": sw.get("hostname") or chassis_id,
"hostname": sw.get("hostname", ""),
"mgmt_ip": sw.get("mgmt_ip", ""),
"description": sw.get("description", ""),
"chassis_id": chassis_id,
"last_seen": sw.get("last_seen", ""),
}
})
edges = []
for link in links:
edge_id = f"{link['chassis_a']}:{link['port_a']}__{link['chassis_b']}:{link['port_b']}"
hostname_a = switches.get(link['chassis_a'], {}).get('hostname', link['chassis_a'])
hostname_b = switches.get(link['chassis_b'], {}).get('hostname', link['chassis_b'])
edges.append({
"data": {
"id": edge_id,
"source": link["chassis_a"],
"target": link["chassis_b"],
"port_a": link["port_a"],
"port_b": link["port_b"],
"hostname_a": hostname_a,
"hostname_b": hostname_b,
}
})
return jsonify({"nodes": nodes, "edges": edges})
@app.route("/api/settings", methods=["POST"])
def api_settings():
data = request.json
enabled = data.get("autoscan_enabled", False)
interval = int(data.get("autoscan_interval", 60))
db.set_setting("autoscan_enabled", "true" if enabled else "false")
db.set_setting("autoscan_interval", str(interval))
if enabled:
_reschedule(interval)
else:
if apscheduler.get_job(_scheduler_job_id):
apscheduler.remove_job(_scheduler_job_id)
logger.info("Auto-scan disabled")
return jsonify({"autoscan_enabled": enabled, "autoscan_interval": interval})
@app.route("/api/layout", methods=["POST"])
def api_save_layout():
data = request.json
positions = data.get("positions", [])
if not positions:
return jsonify({"error": "No positions provided"}), 400
# Normalize 'id' -> 'chassis_id' so JS can send either key
normalized = [{"chassis_id": p.get("chassis_id") or p.get("id"), "x": p["x"], "y": p["y"]} for p in positions]
save_node_positions(normalized)
return jsonify({"saved": len(positions)})
@app.route("/api/layout", methods=["DELETE"])
def api_clear_layout():
clear_node_positions()
return jsonify({"status": "cleared"})
@app.route("/api/layout", methods=["GET"])
def api_get_layout():
positions = get_node_positions()
return jsonify({"has_custom_layout": len(positions) > 0, "count": len(positions)})
@app.route("/api/layout/positions", methods=["GET"])
def api_get_layout_positions():
"""Return saved positions as list of {id, x, y} for Cytoscape."""
positions = get_node_positions()
return jsonify([{"id": cid, "x": xy[0], "y": xy[1]} for cid, xy in positions.items()])
@app.route("/api/switches/clear-stale", methods=["POST"])
def api_clear_stale_switches():
"""Remove switches not seen in the last scan."""
last = db.get_last_scan()
if not last:
return jsonify({"error": "No scan has been run yet"}), 400
since = last["started_at"]
deleted = db.clear_stale_switches(since)
return jsonify({"deleted": deleted})
@app.route("/api/export/csv")
def api_export_csv():
path = os.path.join(EXPORTS_DIR, "topology.csv")
if not os.path.exists(path):
return jsonify({"error": "No export found, run a scan first"}), 404
return send_file(path, as_attachment=True, download_name="topology.csv")
@app.route("/api/export/mermaid")
def api_export_mermaid():
path = os.path.join(EXPORTS_DIR, "topology.md")
if not os.path.exists(path):
return jsonify({"error": "No export found, run a scan first"}), 404
return send_file(path, as_attachment=True, download_name="topology.md")
@app.route("/api/export/png")
def api_export_png():
path = os.path.join(EXPORTS_DIR, "topology.png")
if not os.path.exists(path):
return jsonify({"error": "No PNG found, run a scan first"}), 404
return send_file(path, as_attachment=True, download_name="topology.png")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)
+20
View File
@@ -0,0 +1,20 @@
# config.py - Central configuration
# Copy this file to config.py and fill in your values.
# ─── NocoDB ───────────────────────────────────────────────────────────────────
NOCODB_URL = "http://192.168.x.x:8080"
NOCODB_TOKEN = "your-nocodb-api-token"
# ─── SSH ──────────────────────────────────────────────────────────────────────
SSH_USERNAME = "admin"
SSH_PASSWORD = "your-ssh-password"
SSH_PORT = 22
SSH_TIMEOUT = 30
# Netmiko device type for FS switches (IOS-like CLI)
DEVICE_TYPE = "cisco_ios"
# ─── Output paths ─────────────────────────────────────────────────────────────
DATA_DIR = "/data"
DB_PATH = "/data/network.db"
EXPORTS_DIR = "/data/exports"
+312
View File
@@ -0,0 +1,312 @@
# db.py - SQLite database operations
import sqlite3
import re
import os
import logging
from config import DB_PATH
logger = logging.getLogger(__name__)
def get_conn():
conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10)
conn.row_factory = sqlite3.Row
return conn
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = get_conn()
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS switches (
chassis_id TEXT PRIMARY KEY,
hostname TEXT,
mgmt_ip TEXT,
description TEXT,
last_seen TEXT
)
""")
c.execute("""
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chassis_a TEXT,
port_a TEXT,
chassis_b TEXT,
port_b TEXT,
UNIQUE(chassis_a, port_a, chassis_b, port_b)
)
""")
c.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)
""")
c.execute("""
CREATE TABLE IF NOT EXISTS scan_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT,
finished_at TEXT,
status TEXT,
switches_ok INTEGER DEFAULT 0,
switches_fail INTEGER DEFAULT 0
)
""")
c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_enabled', 'false')")
c.execute("INSERT OR IGNORE INTO settings VALUES ('autoscan_interval', '60')")
c.execute(
"CREATE TABLE IF NOT EXISTS node_positions "
"(chassis_id TEXT PRIMARY KEY, x REAL, y REAL)"
)
conn.commit()
conn.close()
def upsert_switch(chassis_id, hostname, mgmt_ip, description):
conn = get_conn()
conn.execute("""
INSERT INTO switches (chassis_id, hostname, mgmt_ip, description, last_seen)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(chassis_id) DO UPDATE SET
hostname = excluded.hostname,
mgmt_ip = CASE WHEN excluded.mgmt_ip != '' AND excluded.mgmt_ip NOT LIKE '%.%.%.%' = 0
THEN excluded.mgmt_ip ELSE mgmt_ip END,
description = CASE WHEN excluded.description != '' THEN excluded.description ELSE description END,
last_seen = excluded.last_seen
""", (chassis_id, hostname, mgmt_ip, description))
conn.commit()
conn.close()
def upsert_link(chassis_a, port_a, chassis_b, port_b):
# Normalize order so A→B and B→A are treated as the same link
if chassis_a > chassis_b:
chassis_a, chassis_b = chassis_b, chassis_a
port_a, port_b = port_b, port_a
conn = get_conn()
# Check both orderings before inserting
existing = conn.execute("""
SELECT id FROM links WHERE
(chassis_a=? AND port_a=? AND chassis_b=? AND port_b=?) OR
(chassis_a=? AND port_a=? AND chassis_b=? AND port_b=?)
""", (chassis_a, port_a, chassis_b, port_b,
chassis_b, port_b, chassis_a, port_a)).fetchone()
if not existing:
conn.execute("""
INSERT OR IGNORE INTO links (chassis_a, port_a, chassis_b, port_b)
VALUES (?, ?, ?, ?)
""", (chassis_a, port_a, chassis_b, port_b))
conn.commit()
conn.close()
def clear_stale_switches(since_dt):
"""Remove switches not seen since the given datetime string."""
conn = get_conn()
conn.execute("DELETE FROM switches WHERE last_seen < ? OR last_seen IS NULL", (since_dt,))
deleted = conn.execute("SELECT changes()").fetchone()[0]
conn.commit()
conn.close()
return deleted
def clear_links():
conn = get_conn()
conn.execute("DELETE FROM links")
conn.commit()
conn.close()
def _is_mac(chassis):
return bool(re.match(r'^[0-9A-Fa-f]{2}[-:][0-9A-Fa-f]{2}', chassis))
def _is_real_ip(val):
return bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', val or ''))
def merge_duplicate_switches():
"""
After a scan, some switches may have two records:
- One with MAC chassis_id (seen as neighbor by another switch)
- One with IP chassis_id (directly scanned but chassis extraction failed)
This function merges them: keeps MAC as canonical, fixes links, removes IP record.
"""
conn = get_conn()
dupes = conn.execute("""
SELECT hostname, COUNT(*) as cnt, GROUP_CONCAT(chassis_id) as ids,
GROUP_CONCAT(mgmt_ip) as ips
FROM switches
GROUP BY hostname
HAVING cnt > 1
""").fetchall()
merged = 0
for row in dupes:
ids = row['ids'].split(',')
ips_raw = row['ips'].split(',')
mac_ids = [i for i in ids if _is_mac(i)]
ip_ids = [i for i in ids if not _is_mac(i)]
real_ips = [v for v in ips_raw if _is_real_ip(v)]
if not mac_ids or not ip_ids:
continue
canonical = mac_ids[0]
best_ip = real_ips[0] if real_ips else ''
# Update canonical with best mgmt_ip
if best_ip:
conn.execute("UPDATE switches SET mgmt_ip=? WHERE chassis_id=?",
(best_ip, canonical))
# Repoint links
for old_id in ip_ids:
conn.execute("UPDATE links SET chassis_a=? WHERE chassis_a=?", (canonical, old_id))
conn.execute("UPDATE links SET chassis_b=? WHERE chassis_b=?", (canonical, old_id))
conn.execute("DELETE FROM switches WHERE chassis_id=?", (old_id,))
merged += 1
logger.info(f"Merged {row['hostname']}: {ip_ids} -> {canonical} (mgmt_ip={best_ip})")
# Remove any self-links or exact duplicate links created by repointing
# Remove duplicate links - normalize chassis+port order before grouping
conn.execute("""
DELETE FROM links WHERE id NOT IN (
SELECT MIN(id) FROM links
GROUP BY
CASE WHEN chassis_a < chassis_b THEN chassis_a ELSE chassis_b END,
CASE WHEN chassis_a < chassis_b THEN chassis_b ELSE chassis_a END,
CASE WHEN chassis_a < chassis_b THEN port_a ELSE port_b END,
CASE WHEN chassis_a < chassis_b THEN port_b ELSE port_a END
)
""")
conn.execute("DELETE FROM links WHERE chassis_a = chassis_b")
conn.commit()
conn.close()
logger.info(f"merge_duplicate_switches: merged {merged} hostname groups")
def get_all_switches():
conn = get_conn()
rows = conn.execute("SELECT * FROM switches ORDER BY hostname").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_all_links():
conn = get_conn()
rows = conn.execute("""
SELECT l.*,
sa.hostname as hostname_a, sa.mgmt_ip as ip_a,
sb.hostname as hostname_b, sb.mgmt_ip as ip_b
FROM links l
LEFT JOIN switches sa ON l.chassis_a = sa.chassis_id
LEFT JOIN switches sb ON l.chassis_b = sb.chassis_id
""").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_setting(key):
conn = get_conn()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
return row["value"] if row else None
def set_setting(key, value):
conn = get_conn()
conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, str(value)))
conn.commit()
conn.close()
def log_scan_start():
conn = get_conn()
c = conn.cursor()
c.execute("INSERT INTO scan_log (started_at, status) VALUES (datetime('now'), 'running')")
scan_id = c.lastrowid
conn.commit()
conn.close()
return scan_id
def log_scan_finish(scan_id, ok_count, fail_count):
conn = get_conn()
conn.execute("""
UPDATE scan_log SET finished_at=datetime('now'), status='done',
switches_ok=?, switches_fail=? WHERE id=?
""", (ok_count, fail_count, scan_id))
conn.commit()
conn.close()
def get_last_scan():
conn = get_conn()
row = conn.execute(
"SELECT * FROM scan_log ORDER BY id DESC LIMIT 1"
).fetchone()
conn.close()
return dict(row) if row else None
def deduplicate_links():
"""Remove duplicate bidirectional links. Always run after a scan."""
conn = get_conn()
before = conn.execute("SELECT COUNT(*) FROM links").fetchone()[0]
conn.execute("""
DELETE FROM links WHERE id NOT IN (
SELECT MIN(id) FROM links
GROUP BY
CASE WHEN chassis_a < chassis_b THEN chassis_a ELSE chassis_b END,
CASE WHEN chassis_a < chassis_b THEN chassis_b ELSE chassis_a END,
CASE WHEN chassis_a < chassis_b THEN port_a ELSE port_b END,
CASE WHEN chassis_a < chassis_b THEN port_b ELSE port_a END
)
""")
conn.execute("DELETE FROM links WHERE chassis_a = chassis_b")
conn.commit()
after = conn.execute("SELECT COUNT(*) FROM links").fetchone()[0]
conn.close()
logger.info(f"deduplicate_links: {before} -> {after} links")
def save_node_positions(positions):
"""Save node positions from Cytoscape. positions = list of {chassis_id, x, y}"""
conn = get_conn()
conn.execute("DELETE FROM node_positions")
for p in positions:
conn.execute(
"INSERT INTO node_positions (chassis_id, x, y) VALUES (?, ?, ?)",
(p['chassis_id'], float(p['x']), float(p['y']))
)
conn.commit()
conn.close()
logging.getLogger(__name__).info(f"Saved {len(positions)} node positions")
def get_node_positions():
"""Returns dict of chassis_id -> (x, y), empty if none saved."""
conn = get_conn()
rows = conn.execute("SELECT chassis_id, x, y FROM node_positions").fetchall()
conn.close()
return {r["chassis_id"]: (r["x"], r["y"]) for r in rows}
def clear_node_positions():
conn = get_conn()
conn.execute("DELETE FROM node_positions")
conn.commit()
conn.close()
logging.getLogger(__name__).info("Node positions cleared")
+13
View File
@@ -0,0 +1,13 @@
services:
lldp-mapper:
build: .
container_name: lldp-mapper
ports:
- "5000:5000"
volumes:
- ./data:/data
- ./config.py:/app/config.py
- ./index.html:/app/templates/index.html:ro
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
+190
View File
@@ -0,0 +1,190 @@
# exports.py - Generate CSV, Mermaid, and Graphviz outputs from DB
import csv
import os
import logging
from config import EXPORTS_DIR
from db import get_all_switches, get_all_links
logger = logging.getLogger(__name__)
def _ensure_exports_dir():
os.makedirs(EXPORTS_DIR, exist_ok=True)
def export_csv():
"""Export links + switch info to CSV."""
_ensure_exports_dir()
path = os.path.join(EXPORTS_DIR, "topology.csv")
switches = {s['chassis_id']: s for s in get_all_switches()}
links = get_all_links()
with open(path, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
'Switch A Hostname', 'Switch A IP', 'Switch A Chassis',
'Port A',
'Switch B Hostname', 'Switch B IP', 'Switch B Chassis',
'Port B'
])
for link in links:
sw_a = switches.get(link['chassis_a'], {})
sw_b = switches.get(link['chassis_b'], {})
writer.writerow([
sw_a.get('hostname', link['chassis_a']),
sw_a.get('mgmt_ip', ''),
link['chassis_a'],
link['port_a'],
sw_b.get('hostname', link['chassis_b']),
sw_b.get('mgmt_ip', ''),
link['chassis_b'],
link['port_b'],
])
logger.info(f"CSV exported to {path}")
return path
def export_mermaid():
"""Export topology as clean Mermaid diagram with reference tables."""
_ensure_exports_dir()
path = os.path.join(EXPORTS_DIR, "topology.md")
switches = {s['chassis_id']: s for s in get_all_switches()}
links = get_all_links()
def node_id(sw, chassis_id):
return sw.get('hostname', chassis_id).replace('-', '_').replace('.', '_')
lines = [
"# Network Topology",
"",
"```mermaid",
"graph LR",
]
# Node definitions - hostname only as label
for chassis_id, sw in sorted(switches.items(), key=lambda x: x[1].get('hostname','')):
nid = node_id(sw, chassis_id)
hostname = sw.get('hostname', chassis_id)
lines.append(f' {nid}["{hostname}"]')
lines.append("")
# Edge definitions - portA --> portB as label
for link in links:
sw_a = switches.get(link['chassis_a'], {})
sw_b = switches.get(link['chassis_b'], {})
id_a = node_id(sw_a, link['chassis_a'])
id_b = node_id(sw_b, link['chassis_b'])
lines.append(f' {id_a} -- "{link["port_a"]} to {link["port_b"]}" --> {id_b}')
lines.append("```")
lines.append("")
# Switch reference table
lines.append("## Switch Reference")
lines.append("")
lines.append("| Hostname | Management IP | Chassis ID | Last Seen |")
lines.append("|----------|---------------|------------|-----------|")
for sw in sorted(switches.values(), key=lambda x: x.get('hostname', '')):
lines.append(
f"| {sw.get('hostname','')} "
f"| {sw.get('mgmt_ip','')} "
f"| {sw.get('chassis_id','')} "
f"| {sw.get('last_seen','')} |"
)
lines.append("")
# Link reference table
lines.append("## Link Reference")
lines.append("")
lines.append("| Switch A | Port A | Switch B | Port B |")
lines.append("|----------|--------|----------|--------|")
for link in links:
sw_a = switches.get(link['chassis_a'], {})
sw_b = switches.get(link['chassis_b'], {})
lines.append(
f"| {sw_a.get('hostname', link['chassis_a'])} "
f"| {link['port_a']} "
f"| {sw_b.get('hostname', link['chassis_b'])} "
f"| {link['port_b']} |"
)
with open(path, 'w') as f:
f.write('\n'.join(lines))
logger.info(f"Mermaid exported to {path}")
return path
def export_graphviz():
"""Export topology as Graphviz DOT and render to PNG."""
_ensure_exports_dir()
dot_path = os.path.join(EXPORTS_DIR, "topology.dot")
png_path = os.path.join(EXPORTS_DIR, "topology.png")
switches = {s['chassis_id']: s for s in get_all_switches()}
links = get_all_links()
def nid(chassis):
sw = switches.get(chassis, {})
return sw.get('hostname', chassis).replace('-', '_').replace('.', '_')
dot = []
dot.append('digraph network {')
dot.append(' rankdir=LR;')
dot.append(' bgcolor="white";')
dot.append(' pad=0.8;')
dot.append(' nodesep=0.6;')
dot.append(' ranksep=1.5;')
dot.append(' node [shape=box, style="filled,rounded", fillcolor="#1a3a6e", fontcolor=white, fontname="Helvetica Bold", fontsize=12, margin="0.3,0.2", width=2.2];')
dot.append(' edge [fontname="Helvetica", fontsize=9, fontcolor="#444444", color="#1a3a6e", penwidth=1.5, dir=none];')
dot.append('')
for chassis_id, sw in switches.items():
n = nid(chassis_id)
hostname = sw.get('hostname', chassis_id)
mgmt_ip = sw.get('mgmt_ip', '')
label = f"{hostname}\n{mgmt_ip}" if mgmt_ip else hostname
dot.append(f' {n} [label="{label}"];')
dot.append('')
for link in links:
a = nid(link["chassis_a"])
b = nid(link["chassis_b"])
pa = link["port_a"]
pb = link["port_b"]
dot.append(f' {a} -> {b} [taillabel="{pa}", headlabel="{pb}"];')
dot.append('}')
with open(dot_path, 'w') as f:
f.write('\n'.join(dot))
try:
import subprocess
result = subprocess.run(
['dot', '-Tpng', '-Gdpi=150', dot_path, '-o', png_path],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
logger.info(f"Graphviz PNG exported to {png_path}")
return png_path
else:
logger.error(f"Graphviz render failed: {result.stderr}")
return dot_path
except FileNotFoundError:
logger.warning("graphviz not found")
return dot_path
except Exception as e:
logger.error(f"Graphviz error: {e}")
return dot_path
def run_all_exports():
csv_path = export_csv()
md_path = export_mermaid()
png_path = export_graphviz()
return {"csv": csv_path, "mermaid": md_path, "graphviz": png_path}
BIN
View File
Binary file not shown.
+900
View File
File diff suppressed because one or more lines are too long
+69
View File
@@ -0,0 +1,69 @@
# nocodb_client.py - Fetch switch list from NocoDB "Fiber Switches" base
import logging
import requests
from config import NOCODB_URL, NOCODB_TOKEN
logger = logging.getLogger(__name__)
BASE_ID = "p1h7iyzjjbsnfv5"
TABLE_ID = "md03qoibd5fz839"
HEADERS = {
"xc-token": NOCODB_TOKEN,
"Content-Type": "application/json",
}
def get_switches(dept: str = None) -> list[dict]:
"""
Return list of switch dicts from NocoDB (Active=YES only).
dept: None = all, "ELEC" or "GW" to filter by department.
Raises RuntimeError if NocoDB is unreachable or returns no rows.
"""
where = "(Active,eq,YES)"
if dept:
where = f"(Active,eq,YES)~and(Dept,eq,{dept})"
switches = []
offset = 0
limit = 100
while True:
r = requests.get(
f"{NOCODB_URL}/api/v1/db/data/noco/{BASE_ID}/{TABLE_ID}",
headers=HEADERS,
params={"limit": limit, "offset": offset, "where": where},
timeout=15,
)
if r.status_code != 200:
raise RuntimeError(f"NocoDB error {r.status_code}: {r.text[:200]}")
data = r.json()
for row in data.get("list", []):
ip = (row.get("IP") or "").strip()
if not ip:
continue
switches.append({
"ip": ip,
"hostname": (row.get("Hostname") or "").strip(),
"location": (row.get("Location") or "").strip(),
"model": (row.get("Model") or "").strip(),
"manufacturer": (row.get("Manufacturer") or "FS").strip(),
"dept": (row.get("Dept") or "").strip(),
"asset_tag": (row.get("Asset Tag") or "").strip(),
})
if data.get("pageInfo", {}).get("isLastPage", True):
break
offset += limit
if not switches:
raise RuntimeError(f"NocoDB returned 0 active switches (dept={dept})")
logger.info(f"NocoDB: loaded {len(switches)} switches" + (f" (dept={dept})" if dept else ""))
return switches
def get_switch_ips(dept: str = None) -> list[str]:
"""Convenience wrapper — returns just the IP list."""
return [s["ip"] for s in get_switches(dept=dept)]
+88
View File
@@ -0,0 +1,88 @@
# parser.py - Parse 'show lldp neighbors' output from FS switches
import re
def shorten_interface(iface):
"""GigabitEthernet 1/9 -> Gi1/9, TenGigabitEthernet 1/1 -> Te1/1 etc."""
replacements = [
(r'GigabitEthernet\s*', 'Gi'),
(r'TenGigabitEthernet\s*', 'Te'),
(r'TwentyFiveGigE\s*', 'Twe'),
(r'FortyGigabitEthernet\s*', 'Fo'),
(r'HundredGigE\s*', 'Hu'),
(r'FastEthernet\s*', 'Fa'),
(r'Ethernet\s*', 'Eth'),
(r'mgmt\s*', 'mgmt'),
]
for pattern, short in replacements:
iface = re.sub(pattern, short, iface, flags=re.IGNORECASE)
return iface.strip()
def parse_lldp_neighbors(raw_output, local_chassis_id, local_hostname, local_mgmt_ip):
"""
Parse raw 'show lldp neighbors' output from an FS switch.
Returns:
neighbors: list of dicts with parsed neighbor info
local_info: dict with this switch's details (enriched from LLDP data if needed)
"""
neighbors = []
# Split output into per-neighbor blocks (blank line separated)
# Each block starts with "Local Interface"
blocks = re.split(r'\n\s*\n', raw_output.strip())
for block in blocks:
if not block.strip():
continue
if 'Local Interface' not in block and 'Local Port' not in block:
continue
neighbor = {}
def extract(pattern, text, default=''):
m = re.search(pattern, text, re.IGNORECASE)
return m.group(1).strip() if m else default
neighbor['local_port'] = shorten_interface(extract(r'Local Interface\s*:\s*(.+)', block))
neighbor['chassis_id'] = extract(r'Chassis ID\s*:\s*(.+)', block)
neighbor['port_id'] = extract(r'Port ID\s*:\s*(.+)', block)
neighbor['port_desc'] = shorten_interface(extract(r'Port Description\s*:\s*(.+)', block))
neighbor['system_name'] = extract(r'System Name\s*:\s*(.+)', block)
neighbor['system_desc'] = extract(r'System Description\s*:\s*(.+)', block)
# FS switches report Management Address as MAC (e.g. '64-9D-99-AA-50-B0 (Other)')
# or as IP (e.g. '10.214.0.192'). Extract only valid IPv4.
raw_mgmt = extract(r'Management Address\s*:\s*([\d\.A-Fa-f\-:]+)', block)
ipv4_match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', raw_mgmt)
neighbor['mgmt_ip'] = ipv4_match.group(1) if ipv4_match else ''
neighbor['capabilities'] = extract(r'System Capabilities\s*:\s*(.+)', block)
# Only include bridge/switch neighbors (skip phones, APs listed as endpoints)
if neighbor['chassis_id'] and neighbor['system_name']:
# Use port_desc as remote port if available, fallback to port_id
neighbor['remote_port'] = neighbor['port_desc'] if neighbor['port_desc'] else neighbor['port_id']
neighbors.append(neighbor)
return neighbors
def parse_hostname_from_prompt(prompt_line):
"""Extract hostname from CLI prompt like 'ls-vhls-sw01#'"""
m = re.match(r'^([A-Za-z0-9_\-]+)[>#]', prompt_line.strip())
return m.group(1) if m else None
def parse_mgmt_ip_from_interfaces(raw_output):
"""
Parse 'show ip interface brief' to find management VLAN IP.
Looks for Vlan interfaces with an IP assigned.
Returns first Vlan IP found (typically the management VLAN).
"""
lines = raw_output.splitlines()
for line in lines:
# Match lines like: Vlan100 192.168.1.10 YES ...
m = re.match(r'\s*(Vlan\S+)\s+([\d\.]+)\s+', line, re.IGNORECASE)
if m and not m.group(2).startswith('0.0.0.0'):
return m.group(2)
return None
+6
View File
@@ -0,0 +1,6 @@
flask==3.0.3
netmiko==4.3.0
apscheduler==3.10.4
graphviz==0.20.3
paramiko==3.4.0
requests
+122
View File
@@ -0,0 +1,122 @@
# scanner.py - Orchestrates the full scan pipeline
import logging
from nocodb_client import get_switch_ips
from ssh_client import scan_all_switches
from db import (
upsert_switch, upsert_link, clear_links,
log_scan_start, log_scan_finish, merge_duplicate_switches
)
from exports import run_all_exports
logger = logging.getLogger(__name__)
# Global scan state (shared with Flask via import)
scan_state = {
"running": False,
"done": 0,
"total": 0,
"current_ip": None,
"current_hostname": None,
"ok": 0,
"fail": 0,
"errors": [],
"last_scan": None,
"dept_filter": None, # None = all, "ELEC" or "GW" = dept-only
}
def run_scan(dept: str = None):
"""
Full scan: fetch switch IPs from NocoDB → SSH all → parse → store → export.
dept: None = all active switches, "ELEC" or "GW" = dept-filtered.
"""
global scan_state
if scan_state["running"]:
logger.warning("Scan already running, skipping.")
return
# Fetch switch list from NocoDB (or fallback)
switches = get_switch_ips(dept=dept)
if not switches:
logger.error("NocoDB returned no switches — aborting scan.")
scan_state["running"] = False
return
logger.error("No switches returned from NocoDB, aborting scan.")
return
scan_state.update({
"running": True,
"done": 0,
"total": len(switches),
"current_ip": None,
"current_hostname": None,
"ok": 0,
"fail": 0,
"errors": [],
"dept_filter": dept,
})
scan_id = log_scan_start()
clear_links() # Fresh start for links each scan
def on_progress(done, total, ip, result):
scan_state["done"] = done
scan_state["total"] = total
scan_state["current_ip"] = ip
if result["success"]:
scan_state["ok"] += 1
scan_state["current_hostname"] = result.get("hostname", ip)
upsert_switch(
chassis_id=result["chassis_id"],
hostname=result["hostname"],
mgmt_ip=result["mgmt_ip"],
description=result.get("description", ""),
)
for neighbor in result["neighbors"]:
if neighbor.get("chassis_id") and neighbor.get("system_name"):
upsert_switch(
chassis_id=neighbor["chassis_id"],
hostname=neighbor["system_name"],
mgmt_ip=neighbor.get("mgmt_ip", ""),
description=neighbor.get("system_desc", ""),
)
upsert_link(
chassis_a=result["chassis_id"],
port_a=neighbor["local_port"],
chassis_b=neighbor["chassis_id"],
port_b=neighbor["remote_port"],
)
else:
scan_state["fail"] += 1
scan_state["errors"].append({
"ip": ip,
"error": result.get("error", "Unknown error")
})
scan_all_switches(switches, progress_callback=on_progress)
try:
merge_duplicate_switches()
except Exception as e:
logger.error(f"Merge error: {e}")
try:
run_all_exports()
except Exception as e:
logger.error(f"Export error: {e}")
log_scan_finish(scan_id, scan_state["ok"], scan_state["fail"])
from datetime import datetime
scan_state["last_scan"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
scan_state["running"] = False
scan_state["current_ip"] = None
scan_state["current_hostname"] = None
scan_state["dept_filter"] = None
logger.info(f"Scan complete. OK: {scan_state['ok']}, Failed: {scan_state['fail']}")
+133
View File
@@ -0,0 +1,133 @@
# ssh_client.py - Netmiko SSH connections to FS switches
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
from config import SSH_USERNAME, SSH_PASSWORD, SSH_PORT, SSH_TIMEOUT, DEVICE_TYPE
from parser import parse_lldp_neighbors, parse_mgmt_ip_from_interfaces
logger = logging.getLogger(__name__)
def connect_and_query(ip):
"""
SSH into a switch, run LLDP + interface commands.
Returns dict with switch info and neighbors, or error info.
"""
device = {
"device_type": DEVICE_TYPE,
"host": ip,
"username": SSH_USERNAME,
"password": SSH_PASSWORD,
"port": SSH_PORT,
"timeout": SSH_TIMEOUT,
"global_delay_factor": 2,
"fast_cli": False,
"conn_timeout": SSH_TIMEOUT,
# "ssh_config_file": None,
# "disabled_algorithms": {},
# "transport": "paramiko",
}
# FS switches use legacy ssh-rsa keys — must be explicitly allowed
import paramiko
_orig_connect = ConnectHandler.__init__
try:
logger.info(f"Connecting to {ip}...")
# Patch paramiko transport to allow legacy key algorithms
import paramiko.transport as _pt
_orig_preferred_keys = _pt.Transport._preferred_keys
_pt.Transport._preferred_keys = (
"ssh-rsa", "rsa-sha2-256", "rsa-sha2-512",
"ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521", "ssh-ed25519",
)
with ConnectHandler(**device) as conn:
# Restore after connect
_pt.Transport._preferred_keys = _orig_preferred_keys
# Get hostname from prompt
hostname = conn.find_prompt().replace('#', '').replace('>', '').strip()
# Get LLDP neighbors
lldp_output = conn.send_command("show lldp neighbors", read_timeout=30)
# Get management IP from interface brief
intf_output = conn.send_command("show ip interface brief", read_timeout=30)
mgmt_ip = parse_mgmt_ip_from_interfaces(intf_output) or ip
# Get chassis ID from LLDP local info
local_info_output = conn.send_command("show lldp local-information", read_timeout=30)
chassis_id = _extract_local_chassis(local_info_output) or ip
# Parse neighbors
neighbors = parse_lldp_neighbors(lldp_output, chassis_id, hostname, mgmt_ip)
# Get system description
sys_desc = _extract_system_desc(local_info_output)
logger.info(f" {hostname} ({ip}): {len(neighbors)} neighbors found")
return {
"success": True,
"ip": ip,
"hostname": hostname,
"chassis_id": chassis_id,
"mgmt_ip": mgmt_ip,
"description": sys_desc,
"neighbors": neighbors,
}
except NetmikoAuthenticationException:
logger.error(f"Auth failed for {ip}")
return {"success": False, "ip": ip, "error": "Authentication failed"}
except NetmikoTimeoutException:
logger.error(f"Timeout connecting to {ip}")
return {"success": False, "ip": ip, "error": "Connection timed out"}
except Exception as e:
logger.error(f"Error on {ip}: {e}")
return {"success": False, "ip": ip, "error": str(e)}
def _extract_local_chassis(output):
import re
m = re.search(r'Chassis ID\s*:\s*([0-9A-Fa-f\-:]+)', output)
return m.group(1).strip() if m else None
def _extract_system_desc(output):
import re
m = re.search(r'System Description\s*:\s*(.+)', output)
return m.group(1).strip() if m else ''
def scan_all_switches(ip_list, progress_callback=None, max_workers=10):
"""
Scan all switches in parallel.
progress_callback(done, total, current_ip, result) called after each switch.
Returns list of results.
"""
results = []
total = len(ip_list)
done = 0
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_ip = {executor.submit(connect_and_query, ip): ip for ip in ip_list}
for future in as_completed(future_to_ip):
ip = future_to_ip[future]
try:
result = future.result()
except Exception as e:
result = {"success": False, "ip": ip, "error": str(e)}
import time; time.sleep(2) # 2s delay between switches to avoid RADIUS lockout
results.append(result)
done += 1
if progress_callback:
progress_callback(done, total, ip, result)
return results
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
sync_to_netbox.py
Reads the lldp-mapper SQLite DB and syncs devices/links to NetBox.
Additive only - adds new devices/cables, never deletes anything.
Run manually: python3 sync_to_netbox.py
"""
import sqlite3
import requests
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
# --- Configuration ---
DB_PATH = "/opt/lldp-mapper/data/network.db"
NETBOX_URL = "http://192.168.16.130:8001"
NETBOX_TOKEN = "nbt_T6aq9XpwNFQG.HtZziXuSATgabbeagWKk3vEhc2Ask1EMV210PWMM"
SITE_NAME = "Field Sites"
DEVICE_ROLE = "Access Switch"
MANUFACTURER = "FS"
DEVICE_TYPE = "FS Switch"
# --- Helpers ---
NB_HEADERS = {
"Authorization": f"Bearer {NETBOX_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def nb_get(path, params=None):
r = requests.get(f"{NETBOX_URL}/api/{path}", headers=NB_HEADERS, params=params)
r.raise_for_status()
return r.json()
def nb_post(path, data):
r = requests.post(f"{NETBOX_URL}/api/{path}", headers=NB_HEADERS, json=data)
if r.status_code not in (200, 201):
logger.error(f"NetBox POST {path} failed: {r.status_code} {r.text}")
return None
return r.json()
def nb_get_or_create(path, lookup_params, create_data):
results = nb_get(path, params=lookup_params).get("results", [])
if results:
return results[0]
logger.info(f"Creating {path}: {create_data.get('name', create_data)}")
return nb_post(path, create_data)
# --- Read from SQLite ---
def load_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
switches = [dict(r) for r in conn.execute("SELECT * FROM switches ORDER BY hostname").fetchall()]
links = [dict(r) for r in conn.execute("""
SELECT l.*, sa.hostname as hn_a, sb.hostname as hn_b
FROM links l
LEFT JOIN switches sa ON l.chassis_a = sa.chassis_id
LEFT JOIN switches sb ON l.chassis_b = sb.chassis_id
""").fetchall()]
conn.close()
return switches, links
# --- NetBox Sync ---
def ensure_site():
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"]
results = nb_get("dcim/devices/", params={"name": hostname}).get("results", [])
if results:
logger.info(f" Device exists: {hostname}")
return results[0]
logger.info(f" Creating device: {hostname}")
return nb_post("dcim/devices/", {
"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):
results = nb_get("dcim/interfaces/", params={
"device_id": device_id,
"name": port_name
}).get("results", [])
if results:
return results[0]
return nb_post("dcim/interfaces/", {
"device": device_id,
"name": port_name,
"type": "1000base-t",
})
def ensure_ip(switch, device_id):
if not switch.get("mgmt_ip"):
return
ip_addr = f"{switch['mgmt_ip']}/24"
results = nb_get("ipam/ip-addresses/", params={"address": ip_addr}).get("results", [])
if results:
ip_obj = results[0]
else:
logger.info(f" Creating IP: {ip_addr}")
ip_obj = nb_post("ipam/ip-addresses/", {
"address": ip_addr,
"status": "active",
})
if not ip_obj:
return
requests.patch(
f"{NETBOX_URL}/api/dcim/devices/{device_id}/",
headers=NB_HEADERS,
json={"primary_ip4": ip_obj["id"]}
)
def ensure_cable(device_map, link):
hn_a = link["hn_a"]
hn_b = link["hn_b"]
port_a = link["port_a"]
port_b = link["port_b"]
if hn_a not in device_map or hn_b not in device_map:
logger.warning(f" Skipping cable {hn_a}:{port_a} <-> {hn_b}:{port_b} — device not in NetBox")
return
dev_a = device_map[hn_a]
dev_b = device_map[hn_b]
iface_a = ensure_interface(dev_a["id"], port_a)
iface_b = ensure_interface(dev_b["id"], port_b)
if not iface_a or not iface_b:
return
result_a = nb_get("dcim/interfaces/", params={"device_id": dev_a["id"], "name": port_a}).get("results", [])
if result_a and result_a[0].get("cable"):
logger.info(f" Cable already exists: {hn_a}:{port_a} <-> {hn_b}:{port_b}")
return
logger.info(f" Creating cable: {hn_a}:{port_a} <-> {hn_b}:{port_b}")
nb_post("dcim/cables/", {
"a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}],
"status": "connected",
})
def sync_netbox(switches, links):
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 = {}
for sw in switches:
hostname = sw["hostname"] or sw["mgmt_ip"]
logger.info(f"Processing device: {hostname}")
device = ensure_device(sw, site_id, device_type_id, role_id)
if device:
device_map[hostname] = device
ensure_ip(sw, device["id"])
logger.info(f"Devices synced: {len(device_map)}")
logger.info("Processing cables...")
for link in links:
ensure_cable(device_map, link)
logger.info("NetBox sync complete.")
# --- Main ---
if __name__ == "__main__":
switches, links = load_db()
logger.info(f"Loaded {len(switches)} switches and {len(links)} links from DB")
sync_netbox(switches, links)
logger.info("=== All done ===")
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
sync_to_netbox_full.py
Full sync - clears all cables in NetBox and re-creates from lldp-mapper DB.
Use this after topology changes (moved switches, new connections, removed links).
For a safe additive-only sync, use sync_to_netbox.py instead.
"""
import sqlite3
import requests
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
# --- Configuration ---
DB_PATH = "/opt/lldp-mapper/data/network.db"
NETBOX_URL = "http://192.168.16.130:8001"
NETBOX_TOKEN = "nbt_T6aq9XpwNFQG.HtZziXuSATgabbeagWKk3vEhc2Ask1EMV210PWMM"
SITE_NAME = "Field Sites"
DEVICE_ROLE = "Access Switch"
MANUFACTURER = "FS"
DEVICE_TYPE = "FS Switch"
# --- Helpers ---
NB_HEADERS = {
"Authorization": f"Bearer {NETBOX_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def nb_get(path, params=None):
r = requests.get(f"{NETBOX_URL}/api/{path}", headers=NB_HEADERS, params=params)
r.raise_for_status()
return r.json()
def nb_post(path, data):
r = requests.post(f"{NETBOX_URL}/api/{path}", headers=NB_HEADERS, json=data)
if r.status_code not in (200, 201):
logger.error(f"NetBox POST {path} failed: {r.status_code} {r.text}")
return None
return r.json()
def nb_delete(path):
r = requests.delete(f"{NETBOX_URL}/api/{path}", headers=NB_HEADERS)
return r.status_code == 204
def nb_get_or_create(path, lookup_params, create_data):
results = nb_get(path, params=lookup_params).get("results", [])
if results:
return results[0]
logger.info(f"Creating {path}: {create_data.get('name', create_data)}")
return nb_post(path, create_data)
# --- Read from SQLite ---
def load_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
switches = [dict(r) for r in conn.execute("SELECT * FROM switches ORDER BY hostname").fetchall()]
links = [dict(r) for r in conn.execute("""
SELECT l.*, sa.hostname as hn_a, sb.hostname as hn_b
FROM links l
LEFT JOIN switches sa ON l.chassis_a = sa.chassis_id
LEFT JOIN switches sb ON l.chassis_b = sb.chassis_id
""").fetchall()]
conn.close()
return switches, links
# --- NetBox Sync ---
def ensure_site():
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"]
results = nb_get("dcim/devices/", params={"name": hostname}).get("results", [])
if results:
logger.info(f" Device exists: {hostname}")
return results[0]
logger.info(f" Creating device: {hostname}")
return nb_post("dcim/devices/", {
"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):
results = nb_get("dcim/interfaces/", params={
"device_id": device_id,
"name": port_name
}).get("results", [])
if results:
return results[0]
return nb_post("dcim/interfaces/", {
"device": device_id,
"name": port_name,
"type": "1000base-t",
})
def ensure_ip(switch, device_id):
if not switch.get("mgmt_ip"):
return
ip_addr = f"{switch['mgmt_ip']}/24"
results = nb_get("ipam/ip-addresses/", params={"address": ip_addr}).get("results", [])
if results:
ip_obj = results[0]
else:
logger.info(f" Creating IP: {ip_addr}")
ip_obj = nb_post("ipam/ip-addresses/", {
"address": ip_addr,
"status": "active",
})
if not ip_obj:
return
requests.patch(
f"{NETBOX_URL}/api/dcim/devices/{device_id}/",
headers=NB_HEADERS,
json={"primary_ip4": ip_obj["id"]}
)
def clear_all_cables():
logger.info("Clearing all existing cables from NetBox...")
count = 0
while True:
results = nb_get("dcim/cables/", params={"limit": 50}).get("results", [])
if not results:
break
for cable in results:
nb_delete(f"dcim/cables/{cable['id']}/")
count += 1
logger.info(f"Deleted {count} cables.")
def create_cable(device_map, link):
hn_a = link["hn_a"]
hn_b = link["hn_b"]
port_a = link["port_a"]
port_b = link["port_b"]
if hn_a not in device_map or hn_b not in device_map:
logger.warning(f" Skipping cable {hn_a}:{port_a} <-> {hn_b}:{port_b} — device not in NetBox")
return
dev_a = device_map[hn_a]
dev_b = device_map[hn_b]
iface_a = ensure_interface(dev_a["id"], port_a)
iface_b = ensure_interface(dev_b["id"], port_b)
if not iface_a or not iface_b:
return
logger.info(f" Creating cable: {hn_a}:{port_a} <-> {hn_b}:{port_b}")
nb_post("dcim/cables/", {
"a_terminations": [{"object_type": "dcim.interface", "object_id": iface_a["id"]}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": iface_b["id"]}],
"status": "connected",
})
def sync_netbox(switches, links):
logger.info("=== Syncing to NetBox (FULL - cables will be cleared and re-created) ===")
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 = {}
for sw in switches:
hostname = sw["hostname"] or sw["mgmt_ip"]
logger.info(f"Processing device: {hostname}")
device = ensure_device(sw, site_id, device_type_id, role_id)
if device:
device_map[hostname] = device
ensure_ip(sw, device["id"])
logger.info(f"Devices synced: {len(device_map)}")
clear_all_cables()
logger.info("Re-creating cables from current scan data...")
for link in links:
create_cable(device_map, link)
logger.info("NetBox sync complete.")
# --- Main ---
if __name__ == "__main__":
logger.info("*** FULL SYNC — existing cables will be deleted and re-created ***")
switches, links = load_db()
logger.info(f"Loaded {len(switches)} switches and {len(links)} links from DB")
sync_netbox(switches, links)
logger.info("=== All done ===")