Rewrite net_alerter with presence tracking: baseline on first run, alert on join and rejoin
This commit is contained in:
+79
-22
@@ -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,6 +26,10 @@ 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)
|
||||||
@@ -35,9 +39,17 @@ def get_db() -> sqlite3.Connection:
|
|||||||
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)
|
||||||
|
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:
|
if row is None:
|
||||||
|
# Brand new device
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO devices (mac, ip, name, first_seen, last_seen) VALUES (?,?,?,?,?)",
|
"INSERT INTO devices (mac, ip, name, first_seen, last_seen, is_present, last_left) "
|
||||||
(mac, d["ip"], d["name"], now, now),
|
"VALUES (?,?,?,?,?,1,NULL)",
|
||||||
|
(mac, d["ip"], d["name"], ts, ts),
|
||||||
)
|
)
|
||||||
new_devices.append(d)
|
if not baseline_mode:
|
||||||
|
alerts.append(("NEW", d))
|
||||||
else:
|
else:
|
||||||
|
is_present = row[0]
|
||||||
|
if is_present == 0:
|
||||||
|
# Known device rejoining
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE devices SET ip=?, name=?, last_seen=?, is_present=1, last_left=NULL WHERE mac=?",
|
||||||
|
(d["ip"], d["name"], ts, mac),
|
||||||
|
)
|
||||||
|
if not baseline_mode:
|
||||||
|
alerts.append(("REJOIN", d))
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user