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)
This commit is contained in:
Cobra
2026-04-13 20:27:00 -04:00
parent 979ebe8441
commit 3f4e79dd68
+223 -19
View File
@@ -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 departure_lock = threading.Lock() # Protects departure_timers and last_departed_time
last_departed_time = {} # {mac: timestamp} when device was actually alerted as departed 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 # Interface flap detection — suppresses departure storms
_flap_window = [] # [(timestamp, mac)] _flap_window = [] # [(timestamp, mac)]
_flap_lock = threading.Lock() _flap_lock = threading.Lock()
@@ -73,6 +77,7 @@ _churn_tracker = {} # {mac: [timestamps of departure events]}
_churn_lock = threading.Lock() _churn_lock = threading.Lock()
CHURN_THRESHOLD = 3 # cycles in window before suppression CHURN_THRESHOLD = 3 # cycles in window before suppression
CHURN_WINDOW_SEC = 1800 # 30 minutes 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 OUI prefixes for personal device detection
MOBILE_DEVICE_OUIS = { MOBILE_DEVICE_OUIS = {
@@ -113,6 +118,7 @@ MOBILE_DEVICE_OUIS = {
# Personal device tracking # Personal device tracking
personal_devices = set() # {mac: ...} of detected personal device MACs personal_devices = set() # {mac: ...} of detected personal device MACs
personal_names = set() # {name: ...} device names for auto-discovery via mDNS
personal_lock = threading.Lock() personal_lock = threading.Lock()
location_occupancy = "UNKNOWN" # Current location state: VACANT, OCCUPIED, UNKNOWN location_occupancy = "UNKNOWN" # Current location state: VACANT, OCCUPIED, UNKNOWN
occupancy_lock = threading.Lock() occupancy_lock = threading.Lock()
@@ -375,20 +381,34 @@ def _normalize_mac(mac: str) -> str:
def load_personal_macs_from_env() -> None: 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.
macs_str = os.getenv("NET_ALERTER_PERSONAL_MACS", "")
if not macs_str.strip(): Adds MACs from NET_ALERTER_PERSONAL_MACS to personal_devices set.
return Adds device names from NET_ALERTER_PERSONAL_NAMES to personal_names set for mDNS discovery.
"""
macs_str = os.getenv("NET_ALERTER_PERSONAL_MACS", "")
names_str = os.getenv("NET_ALERTER_PERSONAL_NAMES", "")
macs = [m.strip() for m in macs_str.split(",") if m.strip()]
with personal_lock: with personal_lock:
for mac in macs: # Load explicit MACs
normalized = _normalize_mac(mac) if macs_str.strip():
if not normalized: # Skip invalid MACs macs = [m.strip() for m in macs_str.split(",") if m.strip()]
logging.warning(f"Skipping invalid MAC from NET_ALERTER_PERSONAL_MACS: {mac}") for mac in macs:
continue normalized = _normalize_mac(mac)
personal_devices.add(normalized) if not normalized: # Skip invalid MACs
logging.info(f"Added personal device MAC: {normalized}") 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: 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: def on_arrival(mac: str, ip: str, hostname: str = "") -> None:
"""Handle device arrival.""" """Handle device arrival."""
# Update passive observation tracking
_update_last_seen(mac)
# Infrastructure IPs never trigger alerts # Infrastructure IPs never trigger alerts
if ip in infrastructure_ips: if ip in infrastructure_ips:
logging.debug(f"Ignoring infrastructure IP {ip} (MAC:{mac})") logging.debug(f"Ignoring infrastructure IP {ip} (MAC:{mac})")
@@ -933,7 +956,7 @@ def dhcp_sniffer(iface: str) -> None:
while True: while True:
try: try:
data, _ = s.recvfrom(65535) data, _ = s.recvfrom(65535)
parse_dhcp(data) parse_frame(data)
except Exception as e: except Exception as e:
logging.error(f"DHCP sniffer error: {e}") logging.error(f"DHCP sniffer error: {e}")
time.sleep(1) time.sleep(1)
@@ -941,14 +964,142 @@ def dhcp_sniffer(iface: str) -> None:
logging.error(f"Failed to start DHCP sniffer: {e}") 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: try:
if len(data) < 14: if len(data) < 14:
return 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] 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 if eth_type != 0x0800: # not IPv4
return return
@@ -959,11 +1110,21 @@ def parse_dhcp(data: bytes) -> None:
if proto != 17: # not UDP if proto != 17: # not UDP
return return
# UDP ports # UDP header
if len(data) < 38: if len(data) < 38:
return return
src_port = struct.unpack('!H', data[34:36])[0] src_port = struct.unpack('!H', data[34:36])[0]
dst_port = struct.unpack('!H', data[36:38])[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)): if not (src_port in (67, 68) and dst_port in (67, 68)):
return 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'): if mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'):
return return
# Update last_seen for DHCP packets
_update_last_seen(mac)
# Parse DHCP options (start at byte 240) # Parse DHCP options (start at byte 240)
if len(dhcp) < 240: if len(dhcp) < 240:
return return
@@ -1024,7 +1188,7 @@ def parse_dhcp(data: bytes) -> None:
on_departure(mac) on_departure(mac)
except Exception as e: except Exception as e:
logging.debug(f"DHCP parse error: {e}") logging.debug(f"Frame parse error: {e}")
# Netlink constants # Netlink constants
@@ -1123,6 +1287,45 @@ def parse_ndmsg(data: bytes, msg_type: int) -> None:
logging.debug(f"ndmsg parse error: {e}") 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: def main() -> None:
"""Main entry point.""" """Main entry point."""
logger = setup_logging() logger = setup_logging()
@@ -1138,12 +1341,13 @@ def main() -> None:
threads = [ threads = [
threading.Thread(target=dhcp_sniffer, args=(iface,), daemon=True, name='dhcp-sniffer'), 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=netlink_watcher, daemon=True, name='netlink-watcher'),
threading.Thread(target=personal_watchdog, daemon=True, name='personal-watchdog'),
] ]
for t in threads: for t in threads:
t.start() 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 # Keep main thread alive
try: try: