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:
+217
-13
@@ -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."""
|
||||
macs_str = os.getenv("NET_ALERTER_PERSONAL_MACS", "")
|
||||
if not macs_str.strip():
|
||||
return
|
||||
"""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", "")
|
||||
names_str = os.getenv("NET_ALERTER_PERSONAL_NAMES", "")
|
||||
|
||||
macs = [m.strip() for m in macs_str.split(",") if m.strip()]
|
||||
with personal_lock:
|
||||
# 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)
|
||||
logging.info(f"Added personal device MAC: {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:
|
||||
|
||||
Reference in New Issue
Block a user