Add net_alerter: new device join alerts via Matrix to condo Pi
This commit is contained in:
Executable
+64
@@ -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 <<EOF
|
||||||
|
MATRIX_HOMESERVER=https://m.example.org
|
||||||
|
MATRIX_ACCESS_TOKEN=$TOKEN
|
||||||
|
MATRIX_ROOM_ID=!REDACTED:example.org
|
||||||
|
NET_ALERTER_DB=$REMOTE_DIR/devices.db
|
||||||
|
EOF
|
||||||
|
chmod 600 $REMOTE_DIR/.env
|
||||||
|
|
||||||
|
# Write wrapper that sources env
|
||||||
|
cat > $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)"
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user