From d7cdd489ce9c8a400260fbb46ffc2bd41e814032 Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Tue, 24 Mar 2026 20:29:24 -0400 Subject: [PATCH] Add net_alerter: new device join alerts via Matrix to condo Pi --- net_alerter/deploy.sh | 64 ++++++++++++++ net_alerter/net_alerter.py | 171 +++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100755 net_alerter/deploy.sh create mode 100644 net_alerter/net_alerter.py diff --git a/net_alerter/deploy.sh b/net_alerter/deploy.sh new file mode 100755 index 0000000..45c6522 --- /dev/null +++ b/net_alerter/deploy.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Deploy net_alerter to condo Pi (10.0.0.0) +# Usage: ./deploy.sh [access_token] + +set -euo pipefail + +PI_USER="pi" +PI_HOST="10.0.0.0" +PI_KEY="$HOME/.ssh/condo" +REMOTE_DIR="/opt/net_alerter" +SCRIPT="$(dirname "$0")/net_alerter.py" + +# Matrix access token — get fresh one if not passed +if [[ -n "${1:-}" ]]; then + TOKEN="$1" +else + echo "[*] Fetching Matrix access token..." + TOKEN=$(curl -s -X POST https://m.example.org/_matrix/client/v3/login \ + -H "Content-Type: application/json" \ + -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"spectre"},"password":"spectre-cfe6b5f9dd10dd31a204e4a7"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +fi + +echo "[*] Deploying to $PI_HOST..." + +ssh -i "$PI_KEY" -o StrictHostKeyChecking=no "$PI_USER@$PI_HOST" " + sudo mkdir -p $REMOTE_DIR + sudo chown \$USER: $REMOTE_DIR +" + +scp -i "$PI_KEY" -o StrictHostKeyChecking=no "$SCRIPT" "$PI_USER@$PI_HOST:$REMOTE_DIR/net_alerter.py" + +ssh -i "$PI_KEY" -o StrictHostKeyChecking=no "$PI_USER@$PI_HOST" " + # Install nmap if missing + if ! command -v nmap &>/dev/null; then + sudo apt-get install -y nmap + fi + + # Write env config + cat > $REMOTE_DIR/.env < $REMOTE_DIR/run.sh <<'WRAPPER' +#!/usr/bin/env bash +set -a; source /opt/net_alerter/.env; set +a +exec python3 /opt/net_alerter/net_alerter.py +WRAPPER + chmod +x $REMOTE_DIR/run.sh + + # Install cron (every 5 minutes) + CRON='*/5 * * * * /opt/net_alerter/run.sh >> /opt/net_alerter/net_alerter.log 2>&1' + (crontab -l 2>/dev/null | grep -v net_alerter; echo \"\$CRON\") | crontab - + + echo '[ok] Deployed. Running first scan...' + bash $REMOTE_DIR/run.sh && echo '[ok] First scan complete' +" + +echo "[done] net_alerter deployed and cron set (every 5 min)" diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py new file mode 100644 index 0000000..790b7e0 --- /dev/null +++ b/net_alerter/net_alerter.py @@ -0,0 +1,171 @@ +#!/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. +""" + +import json +import os +import re +import socket +import sqlite3 +import subprocess +import sys +import urllib.request +from datetime import datetime +from pathlib import Path + +# --- Config (overridable via env) --- +MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://m.example.org") +MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "") +MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "!REDACTED:example.org") +DB_PATH = Path(os.getenv("NET_ALERTER_DB", "/opt/net_alerter/devices.db")) + + +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, + first_seen TEXT, + last_seen TEXT + ) + """) + conn.commit() + return conn + + +def get_local_subnets() -> list[str]: + """Return CIDR subnets for non-loopback, non-tailscale, non-wireguard interfaces.""" + try: + out = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], text=True) + except Exception: + return [] + + subnets = [] + for line in out.splitlines(): + # Fields: index iface inet ip/prefix ... + parts = line.split() + if len(parts) < 4: + continue + iface = parts[1] + cidr = parts[3] + # Skip loopback, tailscale, wireguard, docker + if any(iface.startswith(p) for p in ("lo", "tailscale", "wg", "tun", "docker", "br-")): + continue + subnets.append(cidr) + return subnets + + +def scan_subnet(cidr: str) -> list[dict]: + """Run nmap ping scan, return list of {ip, mac, name}.""" + try: + out = subprocess.check_output( + ["nmap", "-sn", "-PR", "--host-timeout", "5s", cidr], + text=True, stderr=subprocess.DEVNULL + ) + except Exception: + return [] + + devices = [] + current = {} + for line in out.splitlines(): + if line.startswith("Nmap scan report for"): + if current.get("mac"): + devices.append(current) + # "Nmap scan report for hostname (ip)" or "... for ip" + m = re.search(r"for (.+?)(?:\s+\((.+?)\))?$", line) + if m: + if m.group(2): + current = {"name": m.group(1), "ip": m.group(2), "mac": ""} + else: + current = {"name": "", "ip": m.group(1), "mac": ""} + elif "MAC Address:" in line: + m = re.search(r"MAC Address: ([0-9A-F:]{17})", line) + if m: + current["mac"] = m.group(1).lower() + + if current.get("mac"): + devices.append(current) + + return devices + + +def send_matrix_alert(msg: str) -> None: + if not MATRIX_ACCESS_TOKEN: + print(f"[warn] No MATRIX_ACCESS_TOKEN set, skipping alert: {msg}", file=sys.stderr) + return + + url = ( + f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/" + f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message" + ) + payload = json.dumps({"msgtype": "m.text", "body": msg}).encode() + req = urllib.request.Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + urllib.request.urlopen(req, timeout=10) + except Exception as e: + print(f"[error] Matrix send failed: {e}", file=sys.stderr) + + +# urllib.parse needed for quote +import urllib.parse # noqa: E402 + + +def run() -> None: + conn = get_db() + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + hostname = socket.gethostname() + + subnets = get_local_subnets() + if not subnets: + print("[warn] No local subnets found", file=sys.stderr) + return + + new_devices = [] + + 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: + conn.execute( + "INSERT INTO devices (mac, ip, name, first_seen, last_seen) VALUES (?,?,?,?,?)", + (mac, d["ip"], d["name"], now, now), + ) + new_devices.append(d) + else: + conn.execute( + "UPDATE devices SET ip=?, name=?, last_seen=? WHERE mac=?", + (d["ip"], d["name"], now, mac), + ) + conn.commit() + conn.close() + + for d in new_devices: + label = d["name"] if d["name"] else "unknown" + msg = ( + f"[{hostname}] NEW DEVICE\n" + f" IP: {d['ip']}\n" + f" MAC: {d['mac']}\n" + f" Name: {label}\n" + f" Time: {now}" + ) + print(msg) + send_matrix_alert(msg) + + +if __name__ == "__main__": + run()