#!/usr/bin/env python3 """ 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 struct import sys import threading import time import urllib.parse import urllib.request from pathlib import Path # --- Config (from .env or env vars) --- MATRIX_HOMESERVER = "" MATRIX_ACCESS_TOKEN = "" MATRIX_ROOM_ID = "" LOG_FILE = "/opt/net_alerter/net_alerter.log" known_devices = {} # {mac: {ip, hostname, vendor, first_seen, last_seen}} known_lock = threading.Lock() 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' ) # 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 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")) 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 try: for line in env_path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" not in line: continue 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. 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() 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): parts = line.split(maxsplit=1) if len(parts) > 1: return parts[1].strip() except Exception: continue return "" def seed_from_arp_cache(): """Read /proc/net/arp, populate known_devices without alerting.""" global known_devices 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 ip = fields[0] mac = fields[3].lower() flags = fields[2] if mac == "00:00:00:00:00:00" or flags == "0x0": continue 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() } 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}") 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 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: 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}") 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()