1f90f9ca25
- 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
349 lines
12 KiB
Python
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()
|