Extract Bonjour hostname from mDNS and use it in arrival/departure alerts

_extract_mdns_hostname scans A records in mDNS payload before on_arrival fires,
so the ARRIVED alert shows the device's actual name instead of the IP.
_parse_mdns_for_names also now updates known_devices hostname on subsequent frames,
so departure alerts get the real name even if the first frame had no A record.
This commit is contained in:
Cobra
2026-04-14 23:23:07 -04:00
parent c46d70b3a4
commit 2f3ab82dae
+63 -5
View File
@@ -1456,6 +1456,43 @@ def _update_last_seen(mac: str) -> None:
with last_seen_lock:
last_seen[mac] = time.time()
def _extract_mdns_hostname(payload: bytes) -> str:
"""Scan mDNS DNS payload for an A record with a .local name. Returns display name (sans .local) or ''."""
try:
if len(payload) < 12:
return ""
qdcount = struct.unpack('!H', payload[4:6])[0]
ancount = struct.unpack('!H', payload[6:8])[0]
nscount = struct.unpack('!H', payload[8:10])[0]
arcount = struct.unpack('!H', payload[10:12])[0]
offset = 12
for _ in range(min(qdcount, 50)):
offset = _skip_dns_name(payload, offset)
if offset is None or offset + 4 > len(payload):
return ""
offset += 4
for _ in range(min(ancount + nscount + arcount, 200)):
if offset is None or offset + 10 > len(payload):
break
name_offset = offset
offset = _skip_dns_name(payload, offset)
if offset is None or offset + 10 > len(payload):
break
record_type = struct.unpack('!H', payload[offset:offset+2])[0]
rdlen = struct.unpack('!H', payload[offset+8:offset+10])[0]
offset += 10
if record_type == 1: # A record — name is the device hostname
name = _extract_dns_name(payload, name_offset)
if name and name.endswith('.local'):
return name[:-6]
if offset + rdlen > len(payload):
break
offset += rdlen
except Exception:
pass
return ""
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.
@@ -1501,12 +1538,31 @@ def _parse_mdns_for_names(payload: bytes, mac: str) -> None:
# Extract the name string from the record
name = _extract_dns_name(payload, name_offset)
if name and personal_names:
if name:
# A record (type 1): record name IS the Bonjour hostname (e.g. Johns-iPhone.local)
# Update known_devices hostname if currently unset or just an IP address
if record_type == 1 and name.endswith('.local'):
display_name = name[:-6] # strip .local
mac_normalized = _normalize_mac(mac)
if mac_normalized and display_name:
with known_lock:
dev = known_devices.get(mac_normalized)
if dev:
current = dev.get('hostname', '')
# Only overwrite if hostname is an IP or unset
try:
socket.inet_aton(current)
is_ip = True
except Exception:
is_ip = not current or current == mac_normalized
if is_ip or not current:
dev['hostname'] = display_name
logging.debug(f"mDNS hostname update: {mac_normalized}{display_name}")
if 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)
@@ -1637,10 +1693,12 @@ def parse_frame(data: bytes) -> None:
with known_lock:
already_known = src_mac in known_devices
if not already_known:
on_arrival(src_mac, src_ip)
# Try to extract Bonjour hostname from this frame before alerting
mdns_hostname = _extract_mdns_hostname(data[42:]) if len(data) >= 42 else ""
on_arrival(src_mac, src_ip, mdns_hostname)
# Ingest mDNS signal for multi-source device identity
_ingest_signal(src_mac, 'mdns')
# Parse mDNS DNS message for device names
# Parse mDNS DNS message for device names (updates hostname on subsequent frames)
if len(data) >= 42:
_parse_mdns_for_names(data[42:], src_mac)
return