Implement mDNS as standalone enrollment signal and active probes on device discovery

Changes:
1. mDNS as separate signal category: Map 'mdns' signal_type to its own signal_category (not grouped with 'wifi'). Enrollment now triggers when EITHER (a) len(signal_types) >= 2 OR (b) 'mdns' in signal_types.

2. Active probes on device discovery: When new identity is created, fire non-blocking probes to collect additional signals. Uses arping or ping (no new deps) to probe IP if available. Runs in daemon thread to avoid stalling correlation loop.

This allows devices announcing via mDNS to be enrolled immediately without waiting for a second signal type, while still collecting additional signals for richer device profiles.
This commit is contained in:
Cobra
2026-04-14 14:12:36 -04:00
parent f73ef29c1e
commit b50fc4238b
+77 -12
View File
@@ -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