Rewrite net_alerter with presence tracking: baseline on first run, alert on join and rejoin

This commit is contained in:
n0mad1k
2026-04-06 08:47:48 -04:00
parent 532f5f4a33
commit 0156101f11
+83 -26
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
net_alerter.py — Alert on new devices joining the local network. net_alerter.py — Presence-tracking network monitor.
Runs via cron, sends Matrix messages when unknown MACs appear. Baseline on first run (silent). Alerts on join and rejoin.
""" """
import json import json
@@ -13,7 +13,7 @@ import subprocess
import sys import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# --- Config (overridable via env) --- # --- 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")) 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: def get_db() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True) DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
mac TEXT PRIMARY KEY, mac TEXT PRIMARY KEY,
ip TEXT, ip TEXT,
name TEXT, name TEXT,
first_seen 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() conn.commit()
return conn return conn
@@ -64,7 +76,10 @@ def refresh_token() -> str:
token = data.get("access_token", "") token = data.get("access_token", "")
if token and ENV_PATH.exists(): if token and ENV_PATH.exists():
lines = ENV_PATH.read_text().splitlines() 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") ENV_PATH.write_text("\n".join(lines) + "\n")
return token return token
except Exception as e: except Exception as e:
@@ -90,14 +105,14 @@ def _do_send(token: str, msg: str) -> int:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return e.code return e.code
except Exception as e: 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 return 0
def send_matrix_alert(msg: str) -> None: def send_matrix_alert(msg: str) -> None:
global MATRIX_ACCESS_TOKEN global MATRIX_ACCESS_TOKEN
if not 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 return
code = _do_send(MATRIX_ACCESS_TOKEN, msg) code = _do_send(MATRIX_ACCESS_TOKEN, msg)
if code == 401: if code == 401:
@@ -143,7 +158,7 @@ def scan_subnet(cidr: str) -> list[dict]:
return [] return []
devices = [] devices = []
current = {} current: dict = {}
for line in out.splitlines(): for line in out.splitlines():
if line.startswith("Nmap scan report for"): if line.startswith("Nmap scan report for"):
if current.get("mac"): if current.get("mac"):
@@ -167,43 +182,85 @@ def scan_subnet(cidr: str) -> list[dict]:
def run() -> None: def run() -> None:
conn = get_db() conn = get_db()
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") ts = now_utc()
hostname = socket.gethostname() hostname = socket.gethostname()
subnets = get_local_subnets() subnets = get_local_subnets()
if not subnets: if not subnets:
print("[warn] No local subnets found", file=sys.stderr) print("[warn] No local subnets found", file=sys.stderr)
conn.close()
return return
new_devices = [] # Collect all MACs visible right now across all subnets
scan_results: dict[str, dict] = {}
for cidr in subnets: for cidr in subnets:
devices = scan_subnet(cidr) for d in scan_subnet(cidr):
for d in devices: scan_results[d["mac"]] = d
mac = d["mac"]
row = conn.execute("SELECT mac FROM devices WHERE mac=?", (mac,)).fetchone() # Is this the first run? (empty DB = baseline mode, no alerts)
if row is None: 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( conn.execute(
"INSERT INTO devices (mac, ip, name, first_seen, last_seen) VALUES (?,?,?,?,?)", "UPDATE devices SET ip=?, name=?, last_seen=?, is_present=1, last_left=NULL WHERE mac=?",
(mac, d["ip"], d["name"], now, now), (d["ip"], d["name"], ts, mac),
) )
new_devices.append(d) if not baseline_mode:
alerts.append(("REJOIN", d))
else: else:
# Already present — just update last_seen
conn.execute( conn.execute(
"UPDATE devices SET ip=?, name=?, last_seen=? WHERE mac=?", "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.commit()
conn.close() conn.close()
for d in new_devices: # Send alerts
for event, d in alerts:
label = d["name"] if d["name"] else "unknown" label = d["name"] if d["name"] else "unknown"
tag = "NEW DEVICE" if event == "NEW" else "DEVICE REJOINED"
msg = ( msg = (
f"[{hostname}] NEW DEVICE\n" f"[{hostname}] {tag}\n"
f" IP: {d['ip']}\n" f" IP: {d['ip']}\n"
f" MAC: {d['mac']}\n" f" MAC: {d['mac']}\n"
f" Name: {label}\n" f" Name: {label}\n"
f" Time: {now}" f" Time: {ts}"
) )
print(msg) print(msg)
send_matrix_alert(msg) send_matrix_alert(msg)