Rebuild net_alerter as persistent daemon — DHCP sniffer + Netlink RTM_NEWNEIGH/DELNEIGH; removes 5-min cron, zero active probing
This commit is contained in:
+426
-286
@@ -1,222 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
net_alerter.py — Presence-tracking network monitor.
|
||||
Baseline on first run (silent). Alerts on join and rejoin.
|
||||
net_alerter.py — Net alerter persistent daemon.
|
||||
Monitors device presence via DHCP sniffing + Netlink neighbor events.
|
||||
Zero active probing. No traffic generated.
|
||||
|
||||
Three concurrent threads:
|
||||
1. DHCP sniffer — AF_PACKET raw socket, captures DHCP traffic passively
|
||||
2. RTM_NEWNEIGH watcher — Netlink socket, kernel pushes new ARP neighbor events
|
||||
3. RTM_DELNEIGH watcher — Netlink socket, kernel pushes deleted ARP neighbor events
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
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"))
|
||||
# --- Config (from .env or env vars) ---
|
||||
MATRIX_HOMESERVER = ""
|
||||
MATRIX_ACCESS_TOKEN = ""
|
||||
MATRIX_ROOM_ID = ""
|
||||
LOG_FILE = "/opt/net_alerter/net_alerter.log"
|
||||
|
||||
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"))
|
||||
known_devices = {} # {mac: {ip, hostname, vendor, first_seen, last_seen}}
|
||||
known_lock = threading.Lock()
|
||||
|
||||
|
||||
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",
|
||||
def setup_logging():
|
||||
"""Setup logging to file + stdout."""
|
||||
formatter = logging.Formatter(
|
||||
fmt='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%dT%H:%M:%S'
|
||||
)
|
||||
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 ""
|
||||
|
||||
# File handler
|
||||
Path(LOG_FILE).parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(LOG_FILE)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Stdout handler
|
||||
stdout_handler = logging.StreamHandler(sys.stdout)
|
||||
stdout_handler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(file_handler)
|
||||
logger.addHandler(stdout_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
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 load_env():
|
||||
"""Parse .env file manually (key=value), no dependency required."""
|
||||
global MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID
|
||||
|
||||
env_path = Path(os.getenv("NET_ALERTER_ENV", "/opt/net_alerter/.env"))
|
||||
|
||||
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)
|
||||
if not env_path.exists():
|
||||
logging.warning(f".env not found at {env_path}, using env vars only")
|
||||
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", "")
|
||||
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:
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
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:
|
||||
if "=" not in line:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return devices
|
||||
key, val = line.split("=", 1)
|
||||
if key == "MATRIX_HOMESERVER":
|
||||
MATRIX_HOMESERVER = val
|
||||
elif key == "MATRIX_ACCESS_TOKEN":
|
||||
MATRIX_ACCESS_TOKEN = val
|
||||
elif key == "MATRIX_ROOM_ID":
|
||||
MATRIX_ROOM_ID = val
|
||||
logging.info(f"Loaded config from {env_path}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load .env: {e}")
|
||||
|
||||
|
||||
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.
|
||||
Searches /usr/share/hwdata/oui.txt or /usr/share/misc/oui.txt.
|
||||
Returns vendor name or empty string if not found.
|
||||
"""
|
||||
if not mac or mac == "00:00:00:00:00:00":
|
||||
@@ -224,13 +99,11 @@ def lookup_oui(mac: str) -> str:
|
||||
|
||||
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()
|
||||
@@ -240,109 +113,376 @@ def lookup_oui(mac: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def run() -> None:
|
||||
conn = get_db()
|
||||
ts = now_utc()
|
||||
hostname = socket.gethostname()
|
||||
def seed_from_arp_cache():
|
||||
"""Read /proc/net/arp, populate known_devices without alerting."""
|
||||
global known_devices
|
||||
|
||||
# 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
|
||||
try:
|
||||
lines = Path("/proc/net/arp").read_text().splitlines()
|
||||
for line in lines:
|
||||
if line.startswith("IP"):
|
||||
continue
|
||||
fields = line.split()
|
||||
if len(fields) < 4:
|
||||
continue
|
||||
|
||||
# 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)
|
||||
ip = fields[0]
|
||||
mac = fields[3].lower()
|
||||
flags = fields[2]
|
||||
|
||||
alerts: list[tuple[str, dict]] = [] # (event_type, device_dict)
|
||||
if mac == "00:00:00:00:00:00" or flags == "0x0":
|
||||
continue
|
||||
|
||||
# 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()
|
||||
vendor = lookup_oui(mac)
|
||||
with known_lock:
|
||||
if mac not in known_devices:
|
||||
known_devices[mac] = {
|
||||
'ip': ip,
|
||||
'hostname': ip,
|
||||
'vendor': vendor,
|
||||
'first_seen': time.time(),
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
logging.info(f"Seeded {len(known_devices)} devices from ARP cache")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to seed from ARP cache: {e}")
|
||||
|
||||
# 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()
|
||||
def send_alert(msg: str) -> None:
|
||||
"""Send alert to Matrix room."""
|
||||
if not MATRIX_ACCESS_TOKEN:
|
||||
logging.warning("No MATRIX_ACCESS_TOKEN set, skipping alert")
|
||||
return
|
||||
|
||||
# Send alerts
|
||||
for event, d in alerts:
|
||||
name = d.get("name", "")
|
||||
vendor = d.get("vendor", "")
|
||||
if not name:
|
||||
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",
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
pass
|
||||
logging.debug(f"Alert sent to Matrix")
|
||||
except urllib.error.HTTPError as e:
|
||||
logging.error(f"Matrix send failed with HTTP {e.code}")
|
||||
except Exception as e:
|
||||
logging.error(f"Matrix send exception: {e}")
|
||||
|
||||
|
||||
def fmt_duration(secs: float) -> str:
|
||||
"""Format duration in seconds to human-readable format."""
|
||||
if secs < 60:
|
||||
return f"{int(secs)}s"
|
||||
elif secs < 3600:
|
||||
m = int(secs // 60)
|
||||
s = int(secs % 60)
|
||||
return f"{m}m {s}s" if s else f"{m}m"
|
||||
else:
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
return f"{h}h {m}m" if m else f"{h}h"
|
||||
|
||||
|
||||
def on_arrival(mac: str, ip: str, hostname: str = "") -> None:
|
||||
"""Handle device arrival."""
|
||||
with known_lock:
|
||||
if mac in known_devices:
|
||||
known_devices[mac]['last_seen'] = time.time()
|
||||
return
|
||||
|
||||
vendor = lookup_oui(mac)
|
||||
known_devices[mac] = {
|
||||
'ip': ip,
|
||||
'hostname': hostname or ip,
|
||||
'vendor': vendor,
|
||||
'first_seen': time.time(),
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
label = hostname or ip
|
||||
msg = f"[NET] ARRIVED: {label} ({ip}) [{vendor or 'unknown'}] MAC:{mac}"
|
||||
logging.info(msg)
|
||||
send_alert(msg)
|
||||
|
||||
|
||||
def on_departure(mac: str) -> None:
|
||||
"""Handle device departure."""
|
||||
with known_lock:
|
||||
if mac not in known_devices:
|
||||
return
|
||||
dev = known_devices.pop(mac)
|
||||
|
||||
duration = fmt_duration(time.time() - dev['first_seen'])
|
||||
msg = f"[NET] DEPARTED: {dev['hostname']} ({dev['ip']}) [{dev['vendor'] or 'unknown'}] MAC:{mac} — present {duration}"
|
||||
logging.info(msg)
|
||||
send_alert(msg)
|
||||
|
||||
|
||||
def get_primary_interface() -> str:
|
||||
"""Get primary network interface (not lo)."""
|
||||
try:
|
||||
with open('/proc/net/route') as f:
|
||||
for line in f.readlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) > 1 and parts[0] != 'lo' and parts[1] == '00000000':
|
||||
return parts[0]
|
||||
|
||||
with open('/proc/net/route') as f:
|
||||
for line in f.readlines()[1:]:
|
||||
parts = line.split()
|
||||
if parts[0] != 'lo':
|
||||
return parts[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 'eth0'
|
||||
|
||||
|
||||
def dhcp_sniffer(iface: str) -> None:
|
||||
"""
|
||||
DHCP sniffer thread.
|
||||
AF_PACKET raw socket, captures DHCP traffic passively.
|
||||
"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
|
||||
s.bind((iface, 0))
|
||||
logging.info(f"DHCP sniffer started on {iface}")
|
||||
|
||||
while True:
|
||||
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)
|
||||
data, _ = s.recvfrom(65535)
|
||||
parse_dhcp(data)
|
||||
except Exception as e:
|
||||
logging.error(f"DHCP sniffer error: {e}")
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start DHCP sniffer: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
def parse_dhcp(data: bytes) -> None:
|
||||
"""Parse DHCP packet and trigger arrival/departure events."""
|
||||
try:
|
||||
if len(data) < 14:
|
||||
return
|
||||
|
||||
# Ethernet frame
|
||||
eth_type = struct.unpack('!H', data[12:14])[0]
|
||||
if eth_type != 0x0800: # not IPv4
|
||||
return
|
||||
|
||||
# IP header
|
||||
if len(data) < 23:
|
||||
return
|
||||
proto = data[23]
|
||||
if proto != 17: # not UDP
|
||||
return
|
||||
|
||||
# UDP ports
|
||||
if len(data) < 38:
|
||||
return
|
||||
src_port = struct.unpack('!H', data[34:36])[0]
|
||||
dst_port = struct.unpack('!H', data[36:38])[0]
|
||||
if not (src_port in (67, 68) and dst_port in (67, 68)):
|
||||
return
|
||||
|
||||
# DHCP payload (starts at byte 42)
|
||||
if len(data) < 42:
|
||||
return
|
||||
dhcp = data[42:]
|
||||
if len(dhcp) < 236:
|
||||
return
|
||||
|
||||
# MAC address (chaddr at offset 28, 6 bytes)
|
||||
mac_bytes = dhcp[28:34]
|
||||
mac = ':'.join(f'{b:02x}' for b in mac_bytes)
|
||||
if mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'):
|
||||
return
|
||||
|
||||
# Parse DHCP options (start at byte 240)
|
||||
if len(dhcp) < 240:
|
||||
return
|
||||
|
||||
msg_type = None
|
||||
hostname = ""
|
||||
requested_ip = ""
|
||||
ciaddr = socket.inet_ntoa(dhcp[12:16])
|
||||
|
||||
i = 240
|
||||
while i < len(dhcp):
|
||||
opt = dhcp[i]
|
||||
if opt == 255:
|
||||
break
|
||||
if opt == 0:
|
||||
i += 1
|
||||
continue
|
||||
if i + 1 >= len(dhcp):
|
||||
break
|
||||
|
||||
length = dhcp[i + 1]
|
||||
if i + 2 + length > len(dhcp):
|
||||
break
|
||||
|
||||
val = dhcp[i + 2:i + 2 + length]
|
||||
|
||||
if opt == 53 and length == 1: # DHCP message type
|
||||
msg_type = val[0]
|
||||
elif opt == 12: # Hostname
|
||||
hostname = val.decode('utf-8', errors='replace').strip('\x00')
|
||||
elif opt == 50 and length == 4: # Requested IP
|
||||
requested_ip = socket.inet_ntoa(val)
|
||||
|
||||
i += 2 + length
|
||||
|
||||
# Process based on message type
|
||||
if msg_type in (1, 3): # Discover or Request (arrival)
|
||||
ip = requested_ip or ciaddr
|
||||
if ip and ip != '0.0.0.0':
|
||||
on_arrival(mac, ip, hostname)
|
||||
elif msg_type == 7: # Release (departure)
|
||||
on_departure(mac)
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f"DHCP parse error: {e}")
|
||||
|
||||
|
||||
# Netlink constants
|
||||
NETLINK_ROUTE = 0
|
||||
RTMGRP_NEIGH = 0x4
|
||||
RTM_NEWNEIGH = 28
|
||||
RTM_DELNEIGH = 29
|
||||
NUD_REACHABLE = 0x02
|
||||
NUD_STALE = 0x04
|
||||
NUD_DELAY = 0x08
|
||||
NUD_PROBE = 0x10
|
||||
NDA_DST = 1
|
||||
NDA_LLADDR = 2
|
||||
|
||||
|
||||
def netlink_watcher() -> None:
|
||||
"""
|
||||
Netlink watcher thread.
|
||||
Kernel pushes RTM_NEWNEIGH and RTM_DELNEIGH events.
|
||||
"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_ROUTE)
|
||||
s.bind((os.getpid(), RTMGRP_NEIGH))
|
||||
logging.info("Netlink neighbor watcher started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = s.recv(65535)
|
||||
parse_netlink(data)
|
||||
except Exception as e:
|
||||
logging.error(f"Netlink watcher error: {e}")
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start Netlink watcher: {e}")
|
||||
|
||||
|
||||
def parse_netlink(data: bytes) -> None:
|
||||
"""Parse Netlink neighbor messages."""
|
||||
try:
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
if offset + 16 > len(data):
|
||||
break
|
||||
|
||||
nlmsg_len, nlmsg_type, _, _, _ = struct.unpack_from('=IHHII', data, offset)
|
||||
if nlmsg_len < 16:
|
||||
break
|
||||
|
||||
payload = data[offset + 16:offset + nlmsg_len]
|
||||
|
||||
if nlmsg_type in (RTM_NEWNEIGH, RTM_DELNEIGH):
|
||||
parse_ndmsg(payload, nlmsg_type)
|
||||
|
||||
offset += (nlmsg_len + 3) & ~3
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f"Netlink parse error: {e}")
|
||||
|
||||
|
||||
def parse_ndmsg(data: bytes, msg_type: int) -> None:
|
||||
"""Parse ndmsg (neighbor discovery message)."""
|
||||
try:
|
||||
if len(data) < 12:
|
||||
return
|
||||
|
||||
state = struct.unpack_from('=H', data, 8)[0]
|
||||
|
||||
mac = None
|
||||
ip = None
|
||||
offset = 12
|
||||
|
||||
while offset + 4 <= len(data):
|
||||
rta_len, rta_type = struct.unpack_from('=HH', data, offset)
|
||||
if rta_len < 4:
|
||||
break
|
||||
|
||||
val = data[offset + 4:offset + rta_len]
|
||||
|
||||
if rta_type == NDA_LLADDR and len(val) == 6:
|
||||
mac = ':'.join(f'{b:02x}' for b in val)
|
||||
elif rta_type == NDA_DST:
|
||||
if len(val) == 4:
|
||||
ip = socket.inet_ntoa(val)
|
||||
|
||||
offset += (rta_len + 3) & ~3
|
||||
|
||||
if not mac or mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'):
|
||||
return
|
||||
|
||||
if msg_type == RTM_NEWNEIGH and (state & (NUD_REACHABLE | NUD_STALE | NUD_DELAY | NUD_PROBE)):
|
||||
on_arrival(mac, ip or 'unknown')
|
||||
elif msg_type == RTM_DELNEIGH:
|
||||
on_departure(mac)
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f"ndmsg parse error: {e}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
logger = setup_logging()
|
||||
load_env()
|
||||
|
||||
iface = get_primary_interface()
|
||||
logging.info(f"Net alerter starting on interface {iface}")
|
||||
|
||||
seed_from_arp_cache()
|
||||
|
||||
# Start monitor threads
|
||||
threads = [
|
||||
threading.Thread(target=dhcp_sniffer, args=(iface,), daemon=True, name='dhcp-sniffer'),
|
||||
threading.Thread(target=netlink_watcher, daemon=True, name='netlink-watcher'),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
logging.info("Net alerter running — DHCP sniffer + Netlink neighbor watcher active")
|
||||
|
||||
# Keep main thread alive
|
||||
try:
|
||||
while True:
|
||||
time.sleep(60)
|
||||
with known_lock:
|
||||
logging.debug(f"Tracking {len(known_devices)} devices")
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Shutting down")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user