From 814085c0b34b0604991504f369528d933565e14b Mon Sep 17 00:00:00 2001 From: Cobra Date: Mon, 13 Apr 2026 20:27:00 -0400 Subject: [PATCH] Add iOS departure detection via passive ARP/mDNS capture and watchdog Implement passive observation tracking for iOS devices: - Add last_seen dict and lock for tracking passive frame observations - Parse ARP frames (ethertype 0x0806) for MAC observation - Parse mDNS frames (UDP port 5353) for device name auto-discovery - Rename parse_dhcp to parse_frame to reflect broader scope - Add personal_names set for device name matching - Parse NET_ALERTER_PERSONAL_NAMES env var (comma-separated device names) - Add mDNS DNS message parsing to extract PTR/SRV/A record names - Auto-add MACs to personal_devices when mDNS name matches personal_names - Call _update_last_seen on all frame types (ARP, mDNS, DHCP) - Add personal_watchdog thread to fire on_departure after WATCHDOG_TIMEOUT_SEC - Add NET_ALERTER_WATCHDOG_TIMEOUT env config (default 900s) - Fix load_personal_macs_from_env logging to show counts instead of MACs Changes are minimal and focused: - No socket changes (existing AF_PACKET socket already sees all frames) - No new dependencies (stdlib only) - Deadlock prevention (watchdog does not hold locks when calling on_departure) - Backward compatible (personal_names optional, watchdog is passive) --- net_alerter/net_alerter.py | 242 ++++++++++++++++++++++++++++++++++--- 1 file changed, 223 insertions(+), 19 deletions(-) diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py index 6f79466..ef037b9 100644 --- a/net_alerter/net_alerter.py +++ b/net_alerter/net_alerter.py @@ -36,6 +36,10 @@ 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 +# Passive observation tracking for iOS departure detection +last_seen = {} # {mac: float} — updated by passive ARP/mDNS capture +last_seen_lock = threading.Lock() + # Interface flap detection — suppresses departure storms _flap_window = [] # [(timestamp, mac)] _flap_lock = threading.Lock() @@ -73,6 +77,7 @@ _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 +WATCHDOG_TIMEOUT_SEC = int(os.getenv("NET_ALERTER_WATCHDOG_TIMEOUT", "900")) # Mobile device OUI prefixes for personal device detection MOBILE_DEVICE_OUIS = { @@ -113,6 +118,7 @@ MOBILE_DEVICE_OUIS = { # Personal device tracking personal_devices = set() # {mac: ...} of detected personal device MACs +personal_names = set() # {name: ...} device names for auto-discovery via mDNS personal_lock = threading.Lock() location_occupancy = "UNKNOWN" # Current location state: VACANT, OCCUPIED, UNKNOWN occupancy_lock = threading.Lock() @@ -375,20 +381,34 @@ def _normalize_mac(mac: str) -> str: def load_personal_macs_from_env() -> None: - """Parse NET_ALERTER_PERSONAL_MACS env var and add to personal_devices set.""" + """Parse NET_ALERTER_PERSONAL_MACS and NET_ALERTER_PERSONAL_NAMES env vars. + + Adds MACs from NET_ALERTER_PERSONAL_MACS to personal_devices set. + Adds device names from NET_ALERTER_PERSONAL_NAMES to personal_names set for mDNS discovery. + """ macs_str = os.getenv("NET_ALERTER_PERSONAL_MACS", "") - if not macs_str.strip(): - return - - macs = [m.strip() for m in macs_str.split(",") if m.strip()] + names_str = os.getenv("NET_ALERTER_PERSONAL_NAMES", "") + with personal_lock: - for mac in macs: - normalized = _normalize_mac(mac) - if not normalized: # Skip invalid MACs - logging.warning(f"Skipping invalid MAC from NET_ALERTER_PERSONAL_MACS: {mac}") - continue - personal_devices.add(normalized) - logging.info(f"Added personal device MAC: {normalized}") + # Load explicit MACs + if macs_str.strip(): + macs = [m.strip() for m in macs_str.split(",") if m.strip()] + for mac in macs: + normalized = _normalize_mac(mac) + if not normalized: # Skip invalid MACs + logging.warning(f"Skipping invalid MAC from NET_ALERTER_PERSONAL_MACS: {mac}") + continue + personal_devices.add(normalized) + + # Load device names for mDNS auto-discovery + if names_str.strip(): + names = [n.strip() for n in names_str.split(",") if n.strip()] + for name in names: + personal_names.add(name) + + # Log summary + if personal_devices or personal_names: + logging.info(f"Loaded {len(personal_devices)} personal device MAC(s) and {len(personal_names)} personal name(s)") def _read_env_value(key: str) -> str: @@ -646,6 +666,9 @@ def update_occupancy_state() -> None: def on_arrival(mac: str, ip: str, hostname: str = "") -> None: """Handle device arrival.""" + # Update passive observation tracking + _update_last_seen(mac) + # Infrastructure IPs never trigger alerts if ip in infrastructure_ips: logging.debug(f"Ignoring infrastructure IP {ip} (MAC:{mac})") @@ -933,7 +956,7 @@ def dhcp_sniffer(iface: str) -> None: while True: try: data, _ = s.recvfrom(65535) - parse_dhcp(data) + parse_frame(data) except Exception as e: logging.error(f"DHCP sniffer error: {e}") time.sleep(1) @@ -941,14 +964,142 @@ def dhcp_sniffer(iface: str) -> None: logging.error(f"Failed to start DHCP sniffer: {e}") -def parse_dhcp(data: bytes) -> None: - """Parse DHCP packet and trigger arrival/departure events.""" + +def _update_last_seen(mac: str) -> None: + """Update the last_seen timestamp for a MAC address (passive observation).""" + with last_seen_lock: + last_seen[mac] = time.time() + +def _parse_mdns_for_names(payload: bytes, mac: str) -> None: + """Parse mDNS DNS message to extract PTR/SRV/A record names and check against personal_names. + + If a record name matches a name in personal_names, add the source MAC to personal_devices. + """ + try: + if len(payload) < 12: # DNS header minimum size + return + + # DNS header: ID(2) Flags(2) QDCOUNT(2) ANCOUNT(2) NSCOUNT(2) ARCOUNT(2) + qdcount = struct.unpack('!H', payload[4:6])[0] + ancount = struct.unpack('!H', payload[6:8])[0] + + # Skip questions section (we only care about answers/records) + offset = 12 + for _ in range(qdcount): + offset = _skip_dns_name(payload, offset) + if offset is None or offset + 4 > len(payload): + return + offset += 4 # QTYPE + QCLASS + + # Parse answers/authority/additional records + total_records = ancount + struct.unpack('!H', payload[8:10])[0] + struct.unpack('!H', payload[10:12])[0] + for _ in range(total_records): + if offset is None or offset + 10 > len(payload): + break + + # Parse record name + name_offset = offset + offset = _skip_dns_name(payload, offset) + if offset is None: + break + + # Parse TYPE(2) CLASS(2) TTL(4) RDLEN(2) + if offset + 10 > len(payload): + break + + record_type, record_class, ttl, rdlen = struct.unpack('!HHIH', payload[offset:offset+10]) + offset += 10 + + # Extract the name string from the record + name = _extract_dns_name(payload, name_offset) + if name and personal_names: + with personal_lock: + # Check if the record name matches any personal device names + for pname in personal_names: + if pname.lower() in name.lower(): + # Found a match - add this MAC to personal_devices + mac_normalized = _normalize_mac(mac) + if mac_normalized: + personal_devices.add(mac_normalized) + logging.debug(f"mDNS auto-discovery: added {mac_normalized} for name match '{pname}' in '{name}'") + break + + # Skip RDATA + if offset + rdlen > len(payload): + break + offset += rdlen + + except Exception as e: + logging.debug(f"mDNS parse error: {e}") + + +def _skip_dns_name(payload: bytes, offset: int) -> int: + """Skip a DNS name in the payload, handling compression pointers. Returns new offset or None on error.""" + try: + while offset < len(payload): + length = payload[offset] + if length == 0: + return offset + 1 + if (length & 0xC0) == 0xC0: # Pointer + return offset + 2 + offset += 1 + length + return None + except Exception: + return None + + +def _extract_dns_name(payload: bytes, offset: int) -> str: + """Extract a DNS name from payload at offset. Handles compression pointers.""" + try: + parts = [] + visited = set() + + while offset < len(payload): + if offset in visited: # Prevent infinite loops + break + visited.add(offset) + + length = payload[offset] + offset += 1 + + if length == 0: + break + elif (length & 0xC0) == 0xC0: # Pointer + if offset >= len(payload): + break + pointer_offset = struct.unpack('!H', bytes([length & 0x3F]) + bytes([payload[offset]]))[0] + offset = pointer_offset + continue + + # Regular label + if offset + length > len(payload): + break + label = payload[offset:offset + length].decode('utf-8', errors='replace') + parts.append(label) + offset += length + + return '.'.join(parts) if parts else "" + except Exception: + return "" + +def parse_frame(data: bytes) -> None: + """Parse Ethernet frame: ARP, mDNS, and DHCP packets for passive observation and event triggering.""" try: if len(data) < 14: return - # Ethernet frame + # Ethernet frame — extract source MAC (bytes 6:12) + src_mac = ':'.join(f'{b:02x}' for b in data[6:12]) + + # Ethernet type field eth_type = struct.unpack('!H', data[12:14])[0] + + # Branch A: ARP frame (ethertype 0x0806) + if eth_type == 0x0806: + _update_last_seen(src_mac) + return + + # For IP frames, extract IP header info if eth_type != 0x0800: # not IPv4 return @@ -959,11 +1110,21 @@ def parse_dhcp(data: bytes) -> None: if proto != 17: # not UDP return - # UDP ports + # UDP header if len(data) < 38: return src_port = struct.unpack('!H', data[34:36])[0] dst_port = struct.unpack('!H', data[36:38])[0] + + # Branch B: mDNS frame (IPv4 UDP dst port 5353) + if dst_port == 5353: + _update_last_seen(src_mac) + # Parse mDNS DNS message for device names + if len(data) >= 42: + _parse_mdns_for_names(data[42:], src_mac) + return + + # Branch C: DHCP frame (UDP ports 67/68) if not (src_port in (67, 68) and dst_port in (67, 68)): return @@ -980,6 +1141,9 @@ def parse_dhcp(data: bytes) -> None: if mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'): return + # Update last_seen for DHCP packets + _update_last_seen(mac) + # Parse DHCP options (start at byte 240) if len(dhcp) < 240: return @@ -1024,7 +1188,7 @@ def parse_dhcp(data: bytes) -> None: on_departure(mac) except Exception as e: - logging.debug(f"DHCP parse error: {e}") + logging.debug(f"Frame parse error: {e}") # Netlink constants @@ -1123,6 +1287,45 @@ def parse_ndmsg(data: bytes, msg_type: int) -> None: logging.debug(f"ndmsg parse error: {e}") +def personal_watchdog() -> None: + """Watchdog thread: fires on_departure for personal devices silent > WATCHDOG_TIMEOUT_SEC. + + Monitors last_seen timestamps for personal devices and triggers departure alerts + if a device goes silent for longer than the configured timeout. + """ + while True: + try: + time.sleep(60) # Check every minute + now = time.time() + + # Get current set of personal devices without holding lock for long + with personal_lock: + tracked = set(personal_devices) + + for mac in tracked: + # Get last_seen timestamp + with last_seen_lock: + ts = last_seen.get(mac) + + if ts is None: + continue + + # Check if device is silent + if now - ts > WATCHDOG_TIMEOUT_SEC: + # Only fire if device is currently known and not already departing + # Don't hold lock when calling on_departure + with known_lock: + if mac in known_devices and not known_devices[mac].get('departing'): + # Copy the device info before releasing lock + dev_info = known_devices[mac].copy() + + # Fire departure outside the lock + logging.info(f"Watchdog: personal device {mac} silent >{WATCHDOG_TIMEOUT_SEC}s, triggering departure") + on_departure(mac) + + except Exception as e: + logging.error(f"Watchdog thread error: {e}") + def main() -> None: """Main entry point.""" logger = setup_logging() @@ -1138,12 +1341,13 @@ def main() -> None: threads = [ threading.Thread(target=dhcp_sniffer, args=(iface,), daemon=True, name='dhcp-sniffer'), threading.Thread(target=netlink_watcher, daemon=True, name='netlink-watcher'), + threading.Thread(target=personal_watchdog, daemon=True, name='personal-watchdog'), ] for t in threads: t.start() - logging.info("Net alerter running — DHCP sniffer + Netlink neighbor watcher active") + logging.info("Net alerter running — DHCP sniffer + Netlink neighbor watcher + personal device watchdog active") # Keep main thread alive try: