From 40d4679a59f8cd8327369cfd24be3e061bd76731 Mon Sep 17 00:00:00 2001 From: dstephenson Date: Tue, 21 Apr 2026 20:56:13 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20LLDP=20network?= =?UTF-8?q?=20mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 9 + Dockerfile | 29 ++ README.md | 95 +++++ app.py | 228 +++++++++++ config.py.example | 20 + db.py | 312 ++++++++++++++ docker-compose.yml | 13 + exports.py | 190 +++++++++ files.zip | Bin 0 -> 16708 bytes index.html | 900 +++++++++++++++++++++++++++++++++++++++++ nocodb_client.py | 69 ++++ parser.py | 88 ++++ requirements.txt | 6 + scanner.py | 122 ++++++ ssh_client.py | 133 ++++++ sync_to_netbox.py | 230 +++++++++++ sync_to_netbox_full.py | 247 +++++++++++ 17 files changed, 2691 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 config.py.example create mode 100644 db.py create mode 100644 docker-compose.yml create mode 100644 exports.py create mode 100644 files.zip create mode 100644 index.html create mode 100644 nocodb_client.py create mode 100644 parser.py create mode 100644 requirements.txt create mode 100644 scanner.py create mode 100644 ssh_client.py create mode 100644 sync_to_netbox.py create mode 100644 sync_to_netbox_full.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c44e3e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +data/ +backup/ +__pycache__/ +*.pyc +*.pyo +*.bak +*.swp +*.kate-swp +config.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a6543c2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..26b43fc --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..47af3c9 --- /dev/null +++ b/app.py @@ -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) diff --git a/config.py.example b/config.py.example new file mode 100644 index 0000000..229f33d --- /dev/null +++ b/config.py.example @@ -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" diff --git a/db.py b/db.py new file mode 100644 index 0000000..5fa36e5 --- /dev/null +++ b/db.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a2a666c --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/exports.py b/exports.py new file mode 100644 index 0000000..e97b58c --- /dev/null +++ b/exports.py @@ -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} diff --git a/files.zip b/files.zip new file mode 100644 index 0000000000000000000000000000000000000000..51890a657c807493ebd9e3b0d6b2b8cda43e26d4 GIT binary patch literal 16708 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 + + + + +LLDP Network Mapper + + + + + + +
+ + +
+ + No scan yet + +
+
+ Auto-scan + + +
+ + + + +
+
+ + +
+
+
+ + +
+ + +
+
+ +
+ + + +

No topology data

+

Run a scan to discover your network

+
+ +
+ + + + + + + +
+
+ + + +
+ + +
+
+ Scanning... +
+ + + + + diff --git a/nocodb_client.py b/nocodb_client.py new file mode 100644 index 0000000..c80046e --- /dev/null +++ b/nocodb_client.py @@ -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)] diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..3660276 --- /dev/null +++ b/parser.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9e2e1c8 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..c9061fe --- /dev/null +++ b/scanner.py @@ -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']}") diff --git a/ssh_client.py b/ssh_client.py new file mode 100644 index 0000000..8c53d06 --- /dev/null +++ b/ssh_client.py @@ -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 diff --git a/sync_to_netbox.py b/sync_to_netbox.py new file mode 100644 index 0000000..864aa7a --- /dev/null +++ b/sync_to_netbox.py @@ -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 ===") diff --git a/sync_to_netbox_full.py b/sync_to_netbox_full.py new file mode 100644 index 0000000..298223d --- /dev/null +++ b/sync_to_netbox_full.py @@ -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 ===")