diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py index 13c9431..300c174 100644 --- a/net_alerter/net_alerter.py +++ b/net_alerter/net_alerter.py @@ -164,8 +164,61 @@ def _create_identity_record(identity_id: str) -> dict: } +def _fire_active_probes(identity_id: str, mac: str, signal_type: str, dhcp_fp: str) -> None: + """Fire active probes when a new device identity is created. + + Non-blocking: runs probes in a thread to avoid stalling the main loop. + Attempts ARP ping if IP available, or mDNS query if hostname available. + """ + def _probe_worker(): + try: + # For now, log that probes were fired. Real probe logic can use scapy or zeroconf if available. + # This is a non-blocking thread so we don't stall the correlator. + with device_store_lock: + if identity_id not in device_store: + return + dev = device_store[identity_id] + + # Collect all known MACs for this identity + all_macs = dev['wifi_macs'] | dev['ble_macs'] + + # Try to find IP from last_seen tracking + ip_for_probe = None + for m in all_macs: + if m in dev['last_seen']: + # Check if we can infer IP from known_devices (if MAC was previously seen) + with known_lock: + if m in known_devices: + ip_for_probe = known_devices[m].get('ip') + break + + if ip_for_probe: + logging.info(f"New device identity {identity_id}: firing active probes for IP {ip_for_probe}") + try: + # Attempt ARP ping using subprocess arping (lightweight, no extra deps) + subprocess.run(['arping', '-c', '1', '-w', '1', ip_for_probe], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2) + logging.debug(f"ARP probe sent to {ip_for_probe} (identity {identity_id})") + except (FileNotFoundError, subprocess.TimeoutExpired, Exception): + # arping not available or timed out — try ping as fallback + try: + subprocess.run(['ping', '-c', '1', '-W', '1', ip_for_probe], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2) + logging.debug(f"ICMP ping sent to {ip_for_probe} (identity {identity_id})") + except (subprocess.TimeoutExpired, Exception): + pass + else: + logging.debug(f"New device identity {identity_id}: no IP available for active probes yet") + except Exception as e: + logging.debug(f"Error in active probe thread for {identity_id}: {e}") + + # Fire probe in background thread to avoid blocking + probe_thread = threading.Thread(target=_probe_worker, daemon=True) + probe_thread.start() + + def _fire_enrollment_callback(identity_id: str) -> None: - """Called when a device meets enrollment criteria (2+ signal types). + """Called when a device meets enrollment criteria (2+ signal types or mdns present). Logs enrollment, sends no alert (silent enrollment). Caller must hold device_store_lock. @@ -194,8 +247,13 @@ def _correlate_or_create_identity(mac: str, signal_type: str, dhcp_fp: str = "", Returns the identity_id (stable key). """ with device_store_lock: - # Determine signal category: 'ble' or 'wifi' (for dhcp/arp/mdns) - signal_category = 'ble' if signal_type == 'ble' else 'wifi' + # Determine signal category: 'ble', 'mdns', or 'wifi' (for dhcp/arp) + if signal_type == 'ble': + signal_category = 'ble' + elif signal_type == 'mdns': + signal_category = 'mdns' + else: # dhcp, arp + signal_category = 'wifi' # Strategy 0: Check if MAC already exists in any identity for iid, dev in device_store.items(): @@ -204,8 +262,8 @@ def _correlate_or_create_identity(mac: str, signal_type: str, dhcp_fp: str = "", dev['last_seen'][mac] = time.time() dev['signal_types'].add(signal_category) logging.debug(f"MAC {mac} already linked to identity {iid}, updated {signal_category} signal") - # Check if meets enrollment criteria (2+ signal types) - if len(dev['signal_types']) >= 2 and not dev['enrolled']: + # Check if meets enrollment criteria: (2+ signal types) OR (mdns present) + if not dev['enrolled'] and (len(dev['signal_types']) >= 2 or 'mdns' in dev['signal_types']): _fire_enrollment_callback(iid) return iid @@ -233,6 +291,9 @@ def _correlate_or_create_identity(mac: str, signal_type: str, dhcp_fp: str = "", logging.debug(f"MAC rotation (DHCP fingerprint match): {old_macs} → {mac}, identity {iid}") # Add wifi signal type if not already present dev['signal_types'].add('wifi') + # Check if meets enrollment criteria: (2+ signal types) OR (mdns present) + if not dev['enrolled'] and (len(dev['signal_types']) >= 2 or 'mdns' in dev['signal_types']): + _fire_enrollment_callback(iid) return iid # Strategy 3: Create new provisional identity @@ -258,15 +319,16 @@ def _correlate_or_create_identity(mac: str, signal_type: str, dhcp_fp: str = "", dev['wifi_macs'].add(mac) if dhcp_fp: dev['dhcp_fingerprint'] = dhcp_fp - dev['signal_types'].add('wifi') + dev['signal_types'].add(signal_category) dev['last_seen'][mac] = time.time() - # Check if meets enrollment criteria (2+ signal types) - if len(dev['signal_types']) >= 2 and not dev['enrolled']: + # Check if meets enrollment criteria: (2+ signal types) OR (mdns present) + if not dev['enrolled'] and (len(dev['signal_types']) >= 2 or 'mdns' in dev['signal_types']): _fire_enrollment_callback(identity_id) logging.debug(f"Updated device identity {identity_id} with {signal_type} signal (MAC: {mac})") else: # Create new identity dev = _create_identity_record(identity_id) + is_new_identity = True # Add initial signal if signal_type == "ble": @@ -278,12 +340,15 @@ def _correlate_or_create_identity(mac: str, signal_type: str, dhcp_fp: str = "", dev['wifi_macs'].add(mac) if dhcp_fp: dev['dhcp_fingerprint'] = dhcp_fp - dev['signal_types'].add('wifi') + dev['signal_types'].add(signal_category) dev['last_seen'][mac] = time.time() device_store[identity_id] = dev logging.debug(f"Created new device identity {identity_id} from {signal_type} signal") + # Fire active probes for new identity (non-blocking) + _fire_active_probes(identity_id, mac, signal_type, dhcp_fp) + return identity_id @@ -292,7 +357,7 @@ def _ingest_signal(mac: str, signal_type: str, dhcp_fp: str = "", handoff_seq: i Ingest a signal from a device source (BLE, DHCP, ARP, mDNS). Returns the identity_id it was correlated to. - Handles enrollment when signal_count reaches 2+ from distinct types. + Handles enrollment when signal_count reaches 2+ from distinct types or when mdns signal is present. """ identity_id = _correlate_or_create_identity(mac, signal_type, dhcp_fp, handoff_seq) @@ -303,8 +368,8 @@ def _ingest_signal(mac: str, signal_type: str, dhcp_fp: str = "", handoff_seq: i dev = device_store[identity_id] dev['last_seen'][mac] = time.time() - # Enrollment check: if we have 2+ distinct signal types and not yet enrolled - if not dev['enrolled'] and len(dev['signal_types']) >= 2: + # Enrollment check: if we have (2+ distinct signal types) OR (mdns present) and not yet enrolled + if not dev['enrolled'] and (len(dev['signal_types']) >= 2 or 'mdns' in dev['signal_types']): _fire_enrollment_callback(identity_id) # Eviction: if device_store exceeds 500 entries, evict 100 oldest by first_seen