Files
bigbrother/net_alerter/net_alerter.py
T
Cobra 402c4e849b Replace nmap ARP scan with passive /proc/net/arp read — no active probing (#449)
- Removed get_local_subnets() and scan_subnet() — no longer needed
- Added read_arp_cache() to read kernel ARP table from /proc/net/arp
- Added fallback to 'ip neigh show' if /proc/net/arp unavailable
- Added lookup_oui() to query system /usr/share/hwdata/oui.txt for vendor names
- Removed subprocess nmap call and re import (no longer needed)
- run() now directly reads ARP cache instead of scanning subnets
- Zero active network traffic — purely passive monitoring
- Removed sudo requirement
2026-04-08 21:01:12 -04:00

349 lines
12 KiB
Python

#!/usr/bin/env python3
"""
net_alerter.py — Presence-tracking network monitor.
Baseline on first run (silent). Alerts on join and rejoin.
"""
import json
import os
import socket
import sqlite3
import subprocess
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
# --- Config (overridable via env) ---
# Minimum consecutive missed scans before a device is considered gone and the
# tripwire resets. Uncomment to enable — set to 3 for 15-min grace at 5-min cron.
# ABSENT_THRESHOLD = int(os.getenv("NET_ALERTER_ABSENT_THRESHOLD", "3"))
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")
MATRIX_USER = os.getenv("MATRIX_USER", "")
MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD", "")
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,
first_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")
# absent_count tracks consecutive missed scans (used by ABSENT_THRESHOLD)
if "absent_count" not in existing:
conn.execute("ALTER TABLE devices ADD COLUMN absent_count INTEGER NOT NULL DEFAULT 0")
conn.commit()
return conn
def refresh_token() -> str:
if not MATRIX_USER or not MATRIX_PASSWORD:
print("[error] Cannot refresh token: MATRIX_USER/MATRIX_PASSWORD not set", file=sys.stderr)
return ""
payload = json.dumps({
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": MATRIX_USER},
"password": MATRIX_PASSWORD,
"initial_device_display_name": "net-alerter",
}).encode()
req = urllib.request.Request(
f"{MATRIX_HOMESERVER}/_matrix/client/v3/login",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
token = data.get("access_token", "")
if token and ENV_PATH.exists():
lines = ENV_PATH.read_text().splitlines()
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:
print(f"[error] Token refresh failed: {e}", file=sys.stderr)
return ""
def _do_send(token: str, msg: str) -> int:
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 {token}",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=10)
return 200
except urllib.error.HTTPError as e:
return e.code
except Exception as e:
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("[warn] No MATRIX_ACCESS_TOKEN set, skipping alert", file=sys.stderr)
return
code = _do_send(MATRIX_ACCESS_TOKEN, msg)
if code == 401:
print("[info] Token expired, refreshing...", file=sys.stderr)
new_token = refresh_token()
if new_token:
MATRIX_ACCESS_TOKEN = new_token
_do_send(MATRIX_ACCESS_TOKEN, msg)
else:
print("[error] Could not refresh token, alert dropped", file=sys.stderr)
elif code == 403:
print("[error] Room permission denied (403) — check bot membership in room", file=sys.stderr)
elif code not in (200, 0):
print(f"[error] Matrix send failed with HTTP {code}", file=sys.stderr)
def read_arp_cache() -> list[dict]:
"""
Read kernel ARP cache from /proc/net/arp (passive, no active probing).
Falls back to 'ip neigh show' if /proc/net/arp unavailable.
OUI vendor lookup from system hwdata if available.
Returns list of dicts: {"ip": str, "mac": str, "name": str}.
"""
devices = []
# Try /proc/net/arp first
proc_path = Path("/proc/net/arp")
if proc_path.exists():
try:
lines = proc_path.read_text().splitlines()
for line in lines:
if line.startswith("IP"): # Skip header
continue
fields = line.split()
if len(fields) < 4:
continue
ip = fields[0]
hw_addr = fields[3]
flags = fields[2]
# Skip incomplete entries
if hw_addr == "00:00:00:00:00:00":
continue
if flags == "0x0":
continue
device = {"ip": ip, "mac": hw_addr.lower(), "name": ""}
# Try OUI vendor lookup from system hwdata
try:
vendor = lookup_oui(hw_addr)
if vendor:
device["vendor"] = vendor
except Exception:
pass
devices.append(device)
return devices
except Exception:
pass
# Fallback to 'ip neigh show'
try:
out = subprocess.check_output(["ip", "neigh", "show"], text=True)
for line in out.splitlines():
fields = line.split()
if len(fields) < 5:
continue
# Format: <IP> dev <IFACE> lladdr <MAC> ...
ip = fields[0]
try:
lladdr_idx = fields.index("lladdr")
if lladdr_idx + 1 < len(fields):
mac = fields[lladdr_idx + 1].lower()
device = {"ip": ip, "mac": mac, "name": ""}
try:
vendor = lookup_oui(mac)
if vendor:
device["vendor"] = vendor
except Exception:
pass
devices.append(device)
except ValueError:
continue
except Exception:
pass
return devices
def lookup_oui(mac: str) -> str:
"""
Look up OUI vendor from MAC address (first 3 octets).
Searches /usr/share/hwdata/oui.txt or /usr/share/misc/oui.txt if available.
Returns vendor name or empty string if not found.
"""
if not mac or mac == "00:00:00:00:00:00":
return ""
oui_prefix = mac[:8].replace(":", "").upper()
# Check both possible OUI database locations
for oui_path in ["/usr/share/hwdata/oui.txt", "/usr/share/misc/oui.txt"]:
try:
lines = Path(oui_path).read_text().splitlines()
for line in lines:
if line.startswith(oui_prefix):
# Format: XXXXXX (hex) (vendor name)
parts = line.split(maxsplit=1)
if len(parts) > 1:
return parts[1].strip()
except Exception:
continue
return ""
def run() -> None:
conn = get_db()
ts = now_utc()
hostname = socket.gethostname()
# Read kernel ARP cache (passive, no active probing)
arp_devices = read_arp_cache()
scan_results: dict[str, dict] = {}
for d in arp_devices:
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(
"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, reset absent_count
conn.execute(
"UPDATE devices SET ip=?, name=?, last_seen=?, absent_count=0 WHERE mac=?",
(d["ip"], d["name"], ts, mac),
)
# Mark devices that were present but didn't appear in this scan as gone.
# absent_count increments each missed scan; is_present flips to 0 immediately
# (or after ABSENT_THRESHOLD consecutive misses if that option is enabled).
present_in_db = {
row[0]: row[1]
for row in conn.execute("SELECT mac, absent_count FROM devices WHERE is_present=1")
}
for mac, absent_count in present_in_db.items():
if mac in scan_results:
continue
new_count = absent_count + 1
# Uncomment the block below (and ABSENT_THRESHOLD above) to add a grace period:
# if new_count < ABSENT_THRESHOLD:
# conn.execute("UPDATE devices SET absent_count=? WHERE mac=?", (new_count, mac))
# continue
conn.execute(
"UPDATE devices SET is_present=0, last_left=?, absent_count=0 WHERE mac=?",
(ts, mac),
)
print(f"[info] Device left: {mac}", file=sys.stderr)
conn.commit()
conn.close()
# Send alerts
for event, d in alerts:
name = d.get("name", "")
vendor = d.get("vendor", "")
if not name:
try:
name = socket.gethostbyaddr(d["ip"])[0]
except Exception:
pass
if name and vendor:
label = f"{name} ({vendor})"
elif name:
label = name
elif vendor:
label = vendor
else:
label = "unknown"
tag = "NEW DEVICE" if event == "NEW" else "DEVICE REJOINED"
msg = (
f"[{hostname}] {tag}\n"
f" IP: {d['ip']}\n"
f" MAC: {d['mac']}\n"
f" Name: {label}\n"
f" Time: {ts}"
)
print(msg)
send_matrix_alert(msg)
if __name__ == "__main__":
run()