diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py index 3c81c07..2be0341 100644 --- a/net_alerter/net_alerter.py +++ b/net_alerter/net_alerter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -net_alerter.py — Alert on new devices joining the local network. -Runs via cron, sends Matrix messages when unknown MACs appear. +net_alerter.py — Presence-tracking network monitor. +Baseline on first run (silent). Alerts on join and rejoin. """ import json @@ -13,7 +13,7 @@ import subprocess import sys import urllib.parse import urllib.request -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path # --- Config (overridable via env) --- @@ -26,18 +26,30 @@ DB_PATH = Path(os.getenv("NET_ALERTER_DB", "/opt/net_alerter/devices.db")) ENV_PATH = Path(os.getenv("NET_ALERTER_ENV", "/opt/net_alerter/.env")) +def now_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def get_db() -> sqlite3.Connection: DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS devices ( - mac TEXT PRIMARY KEY, - ip TEXT, - name TEXT, + mac TEXT PRIMARY KEY, + ip TEXT, + name TEXT, first_seen TEXT, - last_seen TEXT + last_seen TEXT, + is_present INTEGER NOT NULL DEFAULT 1, + last_left TEXT ) """) + # Migrate existing DBs that lack the new columns + existing = {row[1] for row in conn.execute("PRAGMA table_info(devices)")} + if "is_present" not in existing: + conn.execute("ALTER TABLE devices ADD COLUMN is_present INTEGER NOT NULL DEFAULT 1") + if "last_left" not in existing: + conn.execute("ALTER TABLE devices ADD COLUMN last_left TEXT") conn.commit() return conn @@ -64,7 +76,10 @@ def refresh_token() -> str: token = data.get("access_token", "") if token and ENV_PATH.exists(): lines = ENV_PATH.read_text().splitlines() - lines = [l if not l.startswith("MATRIX_ACCESS_TOKEN=") else f"MATRIX_ACCESS_TOKEN={token}" for l in lines] + lines = [ + f"MATRIX_ACCESS_TOKEN={token}" if l.startswith("MATRIX_ACCESS_TOKEN=") else l + for l in lines + ] ENV_PATH.write_text("\n".join(lines) + "\n") return token except Exception as e: @@ -90,14 +105,14 @@ def _do_send(token: str, msg: str) -> int: except urllib.error.HTTPError as e: return e.code except Exception as e: - print(f"[error] Matrix send failed: {e}", file=sys.stderr) + print(f"[error] Matrix send exception: {e}", file=sys.stderr) return 0 def send_matrix_alert(msg: str) -> None: global MATRIX_ACCESS_TOKEN if not MATRIX_ACCESS_TOKEN: - print(f"[warn] No MATRIX_ACCESS_TOKEN set, skipping alert", file=sys.stderr) + print("[warn] No MATRIX_ACCESS_TOKEN set, skipping alert", file=sys.stderr) return code = _do_send(MATRIX_ACCESS_TOKEN, msg) if code == 401: @@ -143,7 +158,7 @@ def scan_subnet(cidr: str) -> list[dict]: return [] devices = [] - current = {} + current: dict = {} for line in out.splitlines(): if line.startswith("Nmap scan report for"): if current.get("mac"): @@ -167,43 +182,85 @@ def scan_subnet(cidr: str) -> list[dict]: def run() -> None: conn = get_db() - now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + ts = now_utc() hostname = socket.gethostname() subnets = get_local_subnets() if not subnets: print("[warn] No local subnets found", file=sys.stderr) + conn.close() return - new_devices = [] - + # Collect all MACs visible right now across all subnets + scan_results: dict[str, dict] = {} for cidr in subnets: - devices = scan_subnet(cidr) - for d in devices: - mac = d["mac"] - row = conn.execute("SELECT mac FROM devices WHERE mac=?", (mac,)).fetchone() - if row is None: + for d in scan_subnet(cidr): + scan_results[d["mac"]] = d + + # Is this the first run? (empty DB = baseline mode, no alerts) + row_count = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0] + baseline_mode = row_count == 0 + if baseline_mode: + print(f"[info] Baseline run — seeding {len(scan_results)} devices, no alerts", file=sys.stderr) + + alerts: list[tuple[str, dict]] = [] # (event_type, device_dict) + + # Process devices currently on the network + for mac, d in scan_results.items(): + row = conn.execute( + "SELECT is_present FROM devices WHERE mac=?", (mac,) + ).fetchone() + + if row is None: + # Brand new device + conn.execute( + "INSERT INTO devices (mac, ip, name, first_seen, last_seen, is_present, last_left) " + "VALUES (?,?,?,?,?,1,NULL)", + (mac, d["ip"], d["name"], ts, ts), + ) + if not baseline_mode: + alerts.append(("NEW", d)) + else: + is_present = row[0] + if is_present == 0: + # Known device rejoining conn.execute( - "INSERT INTO devices (mac, ip, name, first_seen, last_seen) VALUES (?,?,?,?,?)", - (mac, d["ip"], d["name"], now, now), + "UPDATE devices SET ip=?, name=?, last_seen=?, is_present=1, last_left=NULL WHERE mac=?", + (d["ip"], d["name"], ts, mac), ) - new_devices.append(d) + if not baseline_mode: + alerts.append(("REJOIN", d)) else: + # Already present — just update last_seen conn.execute( "UPDATE devices SET ip=?, name=?, last_seen=? WHERE mac=?", - (d["ip"], d["name"], now, mac), + (d["ip"], d["name"], ts, mac), ) + + # Mark devices that were present but didn't appear in this scan as gone + present_in_db = { + row[0] for row in conn.execute("SELECT mac FROM devices WHERE is_present=1") + } + for mac in present_in_db - set(scan_results.keys()): + conn.execute( + "UPDATE devices SET is_present=0, last_left=? WHERE mac=?", + (ts, mac), + ) + print(f"[info] Device left: {mac}", file=sys.stderr) + conn.commit() conn.close() - for d in new_devices: + # Send alerts + for event, d in alerts: label = d["name"] if d["name"] else "unknown" + tag = "NEW DEVICE" if event == "NEW" else "DEVICE REJOINED" msg = ( - f"[{hostname}] NEW DEVICE\n" + f"[{hostname}] {tag}\n" f" IP: {d['ip']}\n" f" MAC: {d['mac']}\n" f" Name: {label}\n" - f" Time: {now}" + f" Time: {ts}" ) print(msg) send_matrix_alert(msg)