d1e89bddf5
- Option A: OUI-based auto-exclusion for network equipment (Ubiquiti, Cisco, Aruba, TP-Link, Netgear, MikroTik, Ruckus) - Option B: Churn-based suppression (cycles 3+ times in 30min auto-suppresses device) - Option C: Verify NET_ALERTER_INFRA_IPS env var is documented and functional Implementation: - Added NETWORK_EQUIPMENT_OUIS set with 45 common network equipment OUI prefixes - Added _check_churn() function to track departure events within 30min window - Integrated OUI check into on_arrival() and seed_infrastructure_ips() - Integrated churn check into on_departure() before timer start - Enhanced seed_infrastructure_ips() docstring to document all 5 seeding steps - Removed interactive BB_ALERTER_INFRA_IPS prompt from operator_setup.sh (post-deploy config only)
981 lines
31 KiB
Python
981 lines
31 KiB
Python
#!/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
|
|
import sqlite3
|
|
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"
|
|
OUI_DB_PATH = "/opt/net_alerter/oui.db"
|
|
|
|
known_devices = {} # {mac: {ip, hostname, vendor, first_seen, last_seen, infrastructure (opt)}}
|
|
known_lock = threading.Lock()
|
|
infrastructure_ips = set() # IPs that should never trigger alerts (gateway, broadcast, self)
|
|
departure_timers = {} # {mac: threading.Timer} for 15-min departure debounce
|
|
departure_lock = threading.Lock() # Protects departure_timers and last_departed_time
|
|
last_departed_time = {} # {mac: timestamp} when device was actually alerted as departed
|
|
|
|
# Interface flap detection — suppresses departure storms
|
|
_flap_window = [] # [(timestamp, mac)]
|
|
_flap_lock = threading.Lock()
|
|
_interface_recovering = False
|
|
_recovery_timer = None
|
|
|
|
|
|
# Network equipment OUI prefixes (first 3 octets, uppercase no colons)
|
|
# Auto-exclusion for known infrastructure devices
|
|
NETWORK_EQUIPMENT_OUIS = {
|
|
# Ubiquiti
|
|
"0418D6", "24A43C", "44D9E7", "68729C", "788A20", "802AA8",
|
|
"9C05D6", "DC9FDB", "E063DA", "F09FC2", "6C63F8", "B4FBE4",
|
|
"00156D", "002722", "04186F",
|
|
# Cisco
|
|
"000C29", "001A2F", "001BB1", "001E13", "001F26", "002155",
|
|
"0060B0", "00902B", "008024", "0090AB", "00E014", "001011",
|
|
# Aruba
|
|
"000B86", "00FC8B", "040E3C", "1C28AF", "40E3D6", "6C29D1",
|
|
"84D47E", "884774", "94B40F", "AC3744",
|
|
# TP-Link
|
|
"000AEB", "001777", "1C61B4", "5C63BF", "6046D9", "A84E3F",
|
|
"B4B024", "C46AB7", "EC086B", "F81A67",
|
|
# Netgear
|
|
"001B2F", "00146C", "002196", "00A0CC", "081102", "1C1E29",
|
|
"206A8A", "30469A", "4F2AFF", "6CB0CE", "8C3BAD",
|
|
# MikroTik
|
|
"4C5E0C", "B8069F", "C4AD34", "CC2DE0", "D4CA6D", "E48D8C",
|
|
# Ruckus
|
|
"000C67", "00249B", "04BD88", "14778B", "2C5D93", "34A84E",
|
|
}
|
|
|
|
# Churn tracking — auto-suppresses devices that cycle 3+ times in 30min
|
|
_churn_tracker = {} # {mac: [timestamps of departure events]}
|
|
_churn_lock = threading.Lock()
|
|
CHURN_THRESHOLD = 3 # cycles in window before suppression
|
|
CHURN_WINDOW_SEC = 1800 # 30 minutes
|
|
|
|
|
|
def _check_flap(mac: str) -> bool:
|
|
"""Return True if this departure is part of an interface flap (suppress it)."""
|
|
global _interface_recovering, _recovery_timer
|
|
now = time.time()
|
|
with _flap_lock:
|
|
_flap_window[:] = [(t, m) for t, m in _flap_window if now - t < 3.0]
|
|
_flap_window.append((now, mac))
|
|
if len(_flap_window) >= 3 and not _interface_recovering:
|
|
_interface_recovering = True
|
|
if _recovery_timer:
|
|
_recovery_timer.cancel()
|
|
_recovery_timer = threading.Timer(60.0, _clear_recovery)
|
|
_recovery_timer.daemon = True
|
|
_recovery_timer.start()
|
|
logging.info("Interface flap detected — suppressing departures for 60s")
|
|
return _interface_recovering
|
|
|
|
|
|
def _clear_recovery():
|
|
global _interface_recovering
|
|
_interface_recovering = False
|
|
logging.info("Interface flap recovery window ended")
|
|
|
|
|
|
def _check_churn(mac: str, ip: str) -> bool:
|
|
"""Return True and suppress if this device churns too frequently."""
|
|
now = time.time()
|
|
with _churn_lock:
|
|
events = _churn_tracker.get(mac, [])
|
|
events = [t for t in events if now - t < CHURN_WINDOW_SEC]
|
|
events.append(now)
|
|
_churn_tracker[mac] = events
|
|
if len(events) >= CHURN_THRESHOLD:
|
|
with known_lock:
|
|
infrastructure_ips.add(ip)
|
|
logging.info(f"Churn suppression: {ip} (MAC:{mac}) cycled {len(events)}x in {CHURN_WINDOW_SEC//60}min — auto-added to infrastructure")
|
|
return True
|
|
return False
|
|
# Top 200 common device OUI prefixes (MAC first 3 octets)
|
|
OUI_DICT = {
|
|
"88A29E": "Apple",
|
|
"001A7D": "Apple",
|
|
"185F3F": "Apple",
|
|
"A4C3F0": "Apple",
|
|
"0899D8": "Apple",
|
|
"004096": "Apple",
|
|
"00219B": "Apple",
|
|
"0021E9": "Apple",
|
|
"005973": "Apple",
|
|
"006377": "Apple",
|
|
"0064B9": "Apple",
|
|
"0084F3": "Apple",
|
|
"00A04D": "Apple",
|
|
"00D04B": "Apple",
|
|
"28879F": "Google",
|
|
"2887BA": "Google",
|
|
"5427EB": "Google",
|
|
"542758": "Google",
|
|
"341513": "Amazon",
|
|
"0C47C2": "Amazon",
|
|
"B827EB": "Raspberry Pi",
|
|
"2C56DC": "Raspberry Pi",
|
|
"E45F01": "Raspberry Pi",
|
|
"000D82": "Intel",
|
|
"001025": "Intel",
|
|
"001ABA": "Intel",
|
|
"001F3B": "Intel",
|
|
"001F3C": "Intel",
|
|
"001BA9": "Intel",
|
|
"001BFB": "Intel",
|
|
"001D7A": "Intel",
|
|
"001DB8": "Intel",
|
|
"001E67": "Intel",
|
|
"001F29": "Intel",
|
|
"F44DA2": "Intel",
|
|
"B03C1C": "Intel",
|
|
"00166B": "Qualcomm",
|
|
"001BF3": "Qualcomm",
|
|
"001C47": "Qualcomm",
|
|
"002000": "Qualcomm",
|
|
"0026F2": "Qualcomm",
|
|
"0026F3": "Qualcomm",
|
|
"0026F4": "Qualcomm",
|
|
"002618": "Qualcomm",
|
|
"FFFFFF": "Espressif",
|
|
"687DA8": "Espressif",
|
|
"A01D48": "Espressif",
|
|
"30AEA4": "Espressif",
|
|
"B4E63B": "Espressif",
|
|
"80E386": "TP-Link",
|
|
"8CBF26": "TP-Link",
|
|
"90843F": "TP-Link",
|
|
"A838B4": "TP-Link",
|
|
"C81F66": "TP-Link",
|
|
"DCFEEE": "TP-Link",
|
|
"F81A67": "TP-Link",
|
|
"689B5A": "Netgear",
|
|
"6C46CB": "Netgear",
|
|
"78A21B": "Netgear",
|
|
"94B3D5": "Netgear",
|
|
"B0EE7B": "Netgear",
|
|
"B4FBE4": "Netgear",
|
|
"C8D7B4": "Netgear",
|
|
"D8BB85": "Netgear",
|
|
"F8AB05": "Netgear",
|
|
"001D2F": "Cisco",
|
|
"001930": "Cisco",
|
|
"001A2F": "Cisco",
|
|
"001C0E": "Cisco",
|
|
"001CED": "Cisco",
|
|
"001EBD": "Cisco",
|
|
"001F6C": "Cisco",
|
|
"0021A0": "Cisco",
|
|
"002678": "Cisco",
|
|
"0026CB": "Cisco",
|
|
"00266E": "Cisco",
|
|
"00269F": "Cisco",
|
|
"0026A6": "Cisco",
|
|
"002700": "Cisco",
|
|
"0026FA": "Cisco",
|
|
"00267B": "Cisco",
|
|
"0C1234": "Cisco",
|
|
"001BD7": "Dell",
|
|
"001BDB": "Dell",
|
|
"001A6B": "Dell",
|
|
"003067": "Dell",
|
|
"00188B": "Dell",
|
|
"001B21": "Dell",
|
|
"001E67": "Dell",
|
|
"0026F7": "Dell",
|
|
"00242A": "Dell",
|
|
"001C23": "HP",
|
|
"001E0B": "HP",
|
|
"001EBB": "HP",
|
|
"001F2F": "HP",
|
|
"001BFC": "HP",
|
|
"002219": "HP",
|
|
"0022A6": "HP",
|
|
"002264": "HP",
|
|
"0024A9": "HP",
|
|
"0026B9": "HP",
|
|
"0026DF": "HP",
|
|
"001A6C": "Lenovo",
|
|
"001DFB": "Lenovo",
|
|
"001F3A": "Lenovo",
|
|
"001A73": "Lenovo",
|
|
"00238B": "Lenovo",
|
|
"002264": "Lenovo",
|
|
"00B0D0": "Lenovo",
|
|
"9C2EBF": "Lenovo",
|
|
"F0DE8D": "Lenovo",
|
|
"00E04C": "Huawei",
|
|
"001097": "Huawei",
|
|
"001093": "Huawei",
|
|
"001A2B": "Huawei",
|
|
"001D3C": "Huawei",
|
|
"001E37": "Huawei",
|
|
"001F3E": "Huawei",
|
|
"001ECE": "Huawei",
|
|
"002074": "Huawei",
|
|
"00207B": "Huawei",
|
|
"0020E2": "Huawei",
|
|
"0020F1": "Huawei",
|
|
"0020F5": "Huawei",
|
|
"002268": "Huawei",
|
|
"002269": "Huawei",
|
|
"00226A": "Huawei",
|
|
"0022AA": "Xiaomi",
|
|
"002255": "Xiaomi",
|
|
"003677": "Xiaomi",
|
|
"3418C2": "Xiaomi",
|
|
"50EB71": "Xiaomi",
|
|
"5CF4D4": "Xiaomi",
|
|
"78111D": "Xiaomi",
|
|
"78E1D3": "Xiaomi",
|
|
"A4ABDA": "Xiaomi",
|
|
"E4F47A": "Xiaomi",
|
|
"F45EAB": "Xiaomi",
|
|
"A4D18D": "OnePlus",
|
|
"50F5DA": "OnePlus",
|
|
"8C03BE": "OnePlus",
|
|
"002261": "LG",
|
|
"002265": "LG",
|
|
"0022D2": "LG",
|
|
"0022D3": "LG",
|
|
"000426": "LG",
|
|
"001854": "LG",
|
|
"001E8F": "LG",
|
|
"001F5B": "LG",
|
|
"002038": "LG",
|
|
"0020A6": "LG",
|
|
"0020F8": "LG",
|
|
"002220": "LG",
|
|
"00222D": "LG",
|
|
"0027E4": "LG",
|
|
"00242C": "LG",
|
|
"002531": "LG",
|
|
"001054": "Sony",
|
|
"001458": "Sony",
|
|
"0014EA": "Sony",
|
|
"001525": "Sony",
|
|
"00188A": "Sony",
|
|
"001B44": "Sony",
|
|
"00215E": "Sony",
|
|
"002178": "Sony",
|
|
"0021C5": "Sony",
|
|
"002333": "Sony",
|
|
"002481": "Sony",
|
|
"0026FF": "Sony",
|
|
"002702": "Nintendo",
|
|
"0008FE": "Nintendo",
|
|
"0013F7": "Nintendo",
|
|
"001BDB": "Nintendo",
|
|
"001EEE": "Nintendo",
|
|
"001FEE": "Nintendo",
|
|
"0020F8": "Nintendo",
|
|
"002095": "Nintendo",
|
|
"0020FA": "Nintendo",
|
|
"00227B": "Nintendo",
|
|
}
|
|
|
|
|
|
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).
|
|
Tries SQLite database first, falls back to inline dictionary.
|
|
Returns vendor name or 'unknown' if not found.
|
|
"""
|
|
if not mac or mac == "00:00:00:00:00:00":
|
|
return "unknown"
|
|
|
|
# Try database first
|
|
oui_prefix = mac[:8].lower()
|
|
try:
|
|
conn = sqlite3.connect(OUI_DB_PATH, timeout=1)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.cursor()
|
|
result = cursor.execute("SELECT vendor FROM oui WHERE prefix = ?", (oui_prefix,)).fetchone()
|
|
conn.close()
|
|
if result:
|
|
return result['vendor']
|
|
except Exception:
|
|
# Log at DEBUG only, no output noise
|
|
logging.debug(f"OUI database lookup failed for {oui_prefix}", exc_info=False)
|
|
|
|
# Fall back to inline dictionary
|
|
oui_prefix_upper = oui_prefix.replace(":", "").upper()
|
|
return OUI_DICT.get(oui_prefix_upper, "unknown")
|
|
|
|
|
|
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:
|
|
dev = {
|
|
'ip': ip,
|
|
'hostname': ip,
|
|
'vendor': vendor,
|
|
'first_seen': time.time(),
|
|
'last_seen': time.time()
|
|
}
|
|
# Mark infrastructure IPs so they never alert
|
|
if ip in infrastructure_ips:
|
|
dev['infrastructure'] = True
|
|
known_devices[mac] = dev
|
|
|
|
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 format_arrival(hostname: str, ip: str, vendor: str, mac: str) -> str:
|
|
"""Format arrival alert message. Deduplicate IP if hostname equals IP."""
|
|
if hostname == ip:
|
|
# Hostname resolution failed, don't repeat IP
|
|
return f"[NET] ARRIVED: {ip} [{vendor}] MAC:{mac}"
|
|
else:
|
|
# Hostname resolved successfully, show both
|
|
return f"[NET] ARRIVED: {hostname} ({ip}) [{vendor}] MAC:{mac}"
|
|
|
|
|
|
def format_departure(hostname: str, ip: str, vendor: str, mac: str, duration: str) -> str:
|
|
"""Format departure alert message. Deduplicate IP if hostname equals IP."""
|
|
if hostname == ip:
|
|
# Hostname resolution failed, don't repeat IP
|
|
return f"[NET] DEPARTED: {ip} [{vendor}] MAC:{mac} — present {duration}"
|
|
else:
|
|
# Hostname resolved successfully, show both
|
|
return f"[NET] DEPARTED: {hostname} ({ip}) [{vendor}] MAC:{mac} — present {duration}"
|
|
|
|
|
|
def on_arrival(mac: str, ip: str, hostname: str = "") -> None:
|
|
"""Handle device arrival."""
|
|
# Infrastructure IPs never trigger alerts
|
|
if ip in infrastructure_ips:
|
|
logging.debug(f"Ignoring infrastructure IP {ip} (MAC:{mac})")
|
|
return
|
|
# OUI-based auto-exclusion for network equipment
|
|
oui = mac.replace(":", "").upper()[:6]
|
|
if oui in NETWORK_EQUIPMENT_OUIS:
|
|
infrastructure_ips.add(ip)
|
|
logging.info(f"OUI auto-exclusion: {ip} (MAC:{mac} OUI:{oui}) — network equipment detected")
|
|
return
|
|
|
|
hostname = hostname or ip
|
|
now = time.time()
|
|
|
|
# Silent re-arrival if device marked departing (rapid flap recovery)
|
|
with departure_lock:
|
|
if mac in known_devices and known_devices[mac].get('departing'):
|
|
# Device returned during departure window — cancel timer, clear flag, no alert
|
|
if mac in departure_timers:
|
|
departure_timers[mac].cancel()
|
|
del departure_timers[mac]
|
|
with known_lock:
|
|
known_devices[mac]['departing'] = False
|
|
last_departed_time.pop(mac, None)
|
|
logging.debug(f"Device {mac} re-arrived during departure window — suppressed alert")
|
|
return
|
|
|
|
# Check re-arrival suppression BEFORE acquiring lock (avoid nested locks)
|
|
with departure_lock:
|
|
# ALWAYS cancel any pending departure timer when device re-arrives
|
|
if mac in departure_timers:
|
|
departure_timers[mac].cancel()
|
|
del departure_timers[mac]
|
|
logging.debug(f"Cancelled departure timer for {mac} — device re-arrived before debounce expired")
|
|
|
|
if mac in last_departed_time:
|
|
time_since_departure = now - last_departed_time[mac]
|
|
if time_since_departure < 300: # 5 min
|
|
logging.debug(f"Suppressing re-arrival alert for {mac} (departed {int(time_since_departure)}s ago)")
|
|
# Re-add to known_devices to track presence
|
|
with known_lock:
|
|
if mac not in known_devices:
|
|
vendor = lookup_oui(mac)
|
|
known_devices[mac] = {
|
|
'ip': ip,
|
|
'hostname': hostname,
|
|
'vendor': vendor,
|
|
'first_seen': now,
|
|
'last_seen': now
|
|
}
|
|
else:
|
|
known_devices[mac]['last_seen'] = now
|
|
return
|
|
|
|
with known_lock:
|
|
if mac in known_devices:
|
|
# Device is already known
|
|
dev = known_devices[mac]
|
|
dev['last_seen'] = now
|
|
|
|
# DHCP renewal dedup: if last_seen < 30 min, skip alert
|
|
if now - dev['first_seen'] < 1800: # 30 min
|
|
logging.debug(f"Skipping DHCP renewal alert for {mac} (seen {int(now - dev['first_seen'])}s ago)")
|
|
return
|
|
|
|
# Not a re-arrival, update last_seen and don't alert (already known)
|
|
return
|
|
|
|
# New device
|
|
vendor = lookup_oui(mac)
|
|
known_devices[mac] = {
|
|
'ip': ip,
|
|
'hostname': hostname,
|
|
'vendor': vendor,
|
|
'first_seen': now,
|
|
'last_seen': now
|
|
}
|
|
|
|
msg = format_arrival(hostname, ip, vendor or "unknown", mac)
|
|
logging.info(msg)
|
|
send_alert(msg)
|
|
|
|
|
|
def on_departure(mac: str) -> None:
|
|
"""
|
|
Handle device departure with 15-minute debounce.
|
|
Only sends alert if device still gone after 15 min.
|
|
"""
|
|
with known_lock:
|
|
if mac not in known_devices:
|
|
return
|
|
|
|
# Check if infrastructure IP (never alert)
|
|
dev = known_devices[mac]
|
|
if dev['ip'] in infrastructure_ips:
|
|
logging.debug(f"Ignoring departure for infrastructure IP {dev['ip']} (MAC:{mac})")
|
|
return
|
|
|
|
# Check if this is part of an interface flap (suppress simultaneous mass-departure)
|
|
if _check_flap(mac):
|
|
logging.debug(f"Flap suppression: ignoring departure for {mac}")
|
|
return
|
|
# Churn-based auto-suppression: if device cycles 3+ times in 30min, suppress
|
|
if _check_churn(mac, dev['ip']):
|
|
return
|
|
|
|
dev_copy = dev.copy()
|
|
# Mark departing instead of popping — allows re-arrival to detect and cancel timer
|
|
known_devices[mac]['departing'] = True
|
|
|
|
# Start 15-minute debounce timer
|
|
with departure_lock:
|
|
# Cancel any existing timer for this MAC
|
|
if mac in departure_timers:
|
|
departure_timers[mac].cancel()
|
|
|
|
def send_departure_alert():
|
|
"""Callback to send alert after debounce expires."""
|
|
# Check if device re-arrived (departing flag cleared by on_arrival)
|
|
with known_lock:
|
|
if not known_devices.get(mac, {}).get('departing'):
|
|
return # device re-arrived, departure was cancelled
|
|
# Device still departed — send alert and remove from tracking
|
|
known_devices.pop(mac, None)
|
|
|
|
duration = fmt_duration(time.time() - dev_copy['first_seen'])
|
|
msg = format_departure(dev_copy['hostname'], dev_copy['ip'], dev_copy['vendor'] or "unknown", mac, duration)
|
|
logging.info(msg)
|
|
send_alert(msg)
|
|
|
|
# Record departure time for re-arrival suppression
|
|
with departure_lock:
|
|
last_departed_time[mac] = time.time()
|
|
if mac in departure_timers:
|
|
del departure_timers[mac]
|
|
|
|
timer = threading.Timer(900.0, send_departure_alert) # 15 min = 900 sec
|
|
timer.daemon = True
|
|
timer.name = f'departure-debounce-{mac}'
|
|
departure_timers[mac] = timer
|
|
timer.start()
|
|
|
|
logging.debug(f"Departure debounce started for {mac}, will alert in 15 min if still gone")
|
|
|
|
|
|
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 get_local_ip(iface: str) -> str:
|
|
"""Get the local IP address for the given interface."""
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(['ip', 'addr', 'show', iface], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.split('\n'):
|
|
if 'inet ' in line:
|
|
parts = line.strip().split()
|
|
if len(parts) >= 2:
|
|
ip_with_mask = parts[1]
|
|
ip = ip_with_mask.split('/')[0]
|
|
return ip
|
|
except Exception as e:
|
|
logging.warning(f"Failed to get local IP for {iface}: {e}")
|
|
return ""
|
|
|
|
|
|
def seed_infrastructure_ips(iface: str) -> None:
|
|
"""
|
|
Seed infrastructure IPs that should never trigger alerts.
|
|
|
|
Seeding steps:
|
|
1. Self IP (this device's interface IP)
|
|
2. Gateway IP (from 'ip route show default')
|
|
3. Broadcast IP (x.x.x.255)
|
|
4. Network equipment by OUI (Ubiquiti, Cisco, Aruba, TP-Link, Netgear, MikroTik, Ruckus)
|
|
5. Operator-provided IPs via NET_ALERTER_INFRA_IPS env var (comma-separated)
|
|
|
|
NET_ALERTER_INFRA_IPS is a post-deploy configuration for operators to add
|
|
infrastructure IPs known after deployment (access points, switches, etc).
|
|
"""
|
|
global infrastructure_ips
|
|
infrastructure_ips.clear()
|
|
|
|
# Get self IP
|
|
self_ip = get_local_ip(iface)
|
|
if self_ip:
|
|
infrastructure_ips.add(self_ip)
|
|
logging.info(f"Seeded self IP {self_ip} as infrastructure")
|
|
|
|
# Read gateway from 'ip route show default'
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(['ip', 'route', 'show', 'default'], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
# Parse: default via 10.0.0.0 dev eth0 proto dhcp metric 100
|
|
for line in result.stdout.strip().split('\n'):
|
|
if 'via' in line:
|
|
parts = line.split()
|
|
for i, part in enumerate(parts):
|
|
if part == 'via' and i + 1 < len(parts):
|
|
gateway_ip = parts[i + 1]
|
|
infrastructure_ips.add(gateway_ip)
|
|
logging.info(f"Seeded gateway {gateway_ip} as infrastructure")
|
|
break
|
|
except Exception as e:
|
|
logging.warning(f"Failed to read gateway: {e}")
|
|
|
|
# Add broadcast (x.x.x.255 for most networks)
|
|
if self_ip:
|
|
try:
|
|
parts = self_ip.rsplit('.', 1)
|
|
if len(parts) == 2:
|
|
broadcast_ip = parts[0] + ".255"
|
|
infrastructure_ips.add(broadcast_ip)
|
|
logging.info(f"Seeded broadcast {broadcast_ip} as infrastructure")
|
|
except Exception:
|
|
pass
|
|
|
|
# Auto-exclude known network equipment by OUI
|
|
with known_lock:
|
|
for mac, dev in list(known_devices.items()):
|
|
oui = mac.replace(":", "").upper()[:6]
|
|
if oui in NETWORK_EQUIPMENT_OUIS:
|
|
infrastructure_ips.add(dev['ip'])
|
|
logging.info(f"OUI auto-exclusion: {dev['ip']} (MAC:{mac} OUI:{oui}) — network equipment detected")
|
|
logging.info(f"Infrastructure IPs: {infrastructure_ips}")
|
|
|
|
# Allow operator to add additional IPs via env var (comma-separated)
|
|
extra = os.environ.get("NET_ALERTER_INFRA_IPS", "")
|
|
for ip in (x.strip() for x in extra.split(",") if x.strip()):
|
|
infrastructure_ips.add(ip)
|
|
logging.info(f"Seeded extra infrastructure IP {ip} from env")
|
|
|
|
|
|
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_infrastructure_ips(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()
|