Deduce vendor from mDNS services for privacy MACs

Modern devices increasingly use randomized ("Private WiFi Address") MACs,
which are locally administered and have no real OUI, so the MAC-prefix
vendor lookup returns nothing. Such devices still advertise distinctive
mDNS service types, which we now use to deduce their vendor.

- Extend the mDNS parser to also capture PTR service types alongside
  A-record hostnames.
- Add a service-type -> vendor mapping (data/mdns-service-vendor.json) and
  a service_vendor_finder, using canonical vendor names so device-type
  resolution still chains.
- Add utils::network::is_locally_administered to detect randomized MACs.
- In the mDNS scanner, fall back to service-based deduction only when the
  OUI lookup is empty and the MAC is locally administered.
- Group the three finders under a new data module.
- Preserve a known vendor (and its derived device_type) when a later
  sighting cannot deduce one, and suppress the spurious "vendor changed"
  notification in that case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-29 13:56:39 -04:00
co-authored by Claude Opus 4.7
parent 7fb457ca4a
commit 98a23aa7fd
12 changed files with 308 additions and 32 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"_apple-mobdev2._tcp.local": "Apple, Inc.",
"_companion-link._tcp.local": "Apple, Inc.",
"_sleep-proxy._udp.local": "Apple, Inc.",
"_airport._tcp.local": "Apple, Inc.",
"_googlecast._tcp.local": "Google, Inc.",
"_googlezone._tcp.local": "Google, Inc.",
"_sonos._tcp.local": "Sonos, Inc.",
"_amzn-wplay._tcp.local": "Amazon Technologies Inc.",
"_amzn-alexa._tcp.local": "Amazon Technologies Inc.",
"_samsungmsf._tcp.local": "Samsung Electronics Co.,Ltd",
"_roku-rcp._tcp.local": "Roku, Inc."
}
+3
View File
@@ -0,0 +1,3 @@
pub mod mac_vendor_finder;
pub mod service_vendor_finder;
pub mod vendor_device_type_finder;
@@ -19,7 +19,7 @@ lazy_static! {
info!("Loading MAC vendor database into memory");
let mut database = HashMap::new();
let data = include_str!("../data/mac-vendors-export.json");
let data = include_str!("../../data/mac-vendors-export.json");
let json: Vec<MacRecord> = match serde_json::from_str(data) {
Ok(value) => value,
+89
View File
@@ -0,0 +1,89 @@
use lazy_static::lazy_static;
use log::{debug, error, info};
use std::collections::HashMap;
// Initialize the mDNS service-type -> vendor database as a static lazy loaded unit
lazy_static! {
static ref SERVICE_VENDOR_DATABASE: HashMap<String, String> = {
info!("Loading mDNS service vendor database into memory");
let data = include_str!("../../data/mdns-service-vendor.json");
let raw: HashMap<String, String> = match serde_json::from_str(data) {
Ok(value) => value,
Err(error) => {
error!(
"Error parsing mDNS service vendor database (data/mdns-service-vendor.json): {error}"
);
panic!(
"Error parsing mDNS service vendor database (data/mdns-service-vendor.json): {error}"
);
}
};
let database = raw
.into_iter()
.map(|(service, vendor)| (normalize(&service), vendor))
.collect::<HashMap<String, String>>();
debug!("Found {} records in the database", database.len());
info!("mDNS service vendor database loaded");
database
};
}
// Normalize a service type for case- and trailing-dot-insensitive matching.
fn normalize(service_type: &str) -> String {
service_type.trim_end_matches('.').to_lowercase()
}
/// Deduce a vendor from the mDNS service types a device advertises. Returns the vendor of the
/// first service type that matches a known vendor-specific signature, or an empty string if none
/// match.
pub fn find(service_types: &[String]) -> String {
service_types
.iter()
.find_map(|service_type| SERVICE_VENDOR_DATABASE.get(&normalize(service_type)))
.cloned()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_apple_service_returns_vendor() {
let services = vec!["_companion-link._tcp.local".to_string()];
assert_eq!(find(&services), "Apple, Inc.");
}
#[test]
fn matching_is_case_and_trailing_dot_insensitive() {
let services = vec!["_GoogleCast._tcp.local.".to_string()];
assert_eq!(find(&services), "Google, Inc.");
}
#[test]
fn first_matching_service_wins() {
let services = vec![
"_http._tcp.local".to_string(),
"_sonos._tcp.local".to_string(),
];
assert_eq!(find(&services), "Sonos, Inc.");
}
#[test]
fn unknown_services_return_empty_string() {
let services = vec![
"_http._tcp.local".to_string(),
"_ipp._tcp.local".to_string(),
];
assert_eq!(find(&services), "");
}
#[test]
fn empty_input_returns_empty_string() {
assert_eq!(find(&[]), "");
}
}
@@ -7,7 +7,7 @@ lazy_static! {
static ref VENDOR_DEVICE_TYPE_DATABASE: HashMap<String, String> = {
info!("Loading vendor device type database into memory");
let data = include_str!("../data/vendor-device-type.json");
let data = include_str!("../../data/vendor-device-type.json");
let database: HashMap<String, String> = match serde_json::from_str(data) {
Ok(value) => value,
+48 -3
View File
@@ -186,19 +186,25 @@ pub fn get_summary() -> Result<DeviceSummary, DbError> {
pub fn update(device: Device) -> Result<(), DbError> {
let conn = db::get_db_connection();
let mut sql = "UPDATE devices SET ipv4_address=?, vendor=?, last_seen=?, is_registered=?, owner=?, device_type=?".to_string();
let mut sql = "UPDATE devices SET ipv4_address=?, last_seen=?, is_registered=?, owner=?".to_string();
let mut params: Vec<rusqlite::types::Value> = vec![
device.ipv4_address.clone().into(),
device.vendor.clone().into(),
device
.last_seen
.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false)
.into(),
device.is_registered.into(),
device.owner.clone().into(),
device.device_type.clone().into(),
];
// Only write the vendor (and its derived device_type) when deduced, so a sighting that
// could not determine a vendor (empty string) never clobbers a previously known one.
if !device.vendor.is_empty() {
sql.push_str(", vendor=?, device_type=?");
params.push(device.vendor.clone().into());
params.push(device.device_type.clone().into());
}
// Only write the name column when it is set, so an ARP rescan (name: None) never
// clobbers a hostname previously stored by the mDNS scanner.
if let Some(name) = &device.name {
@@ -476,6 +482,45 @@ mod tests {
);
}
#[tokio::test]
async fn test_update_vendor_set_and_preserve() {
tests_common::setup().await;
let last_seen = Utc::now();
insert(Device::new(
"vv:vv:vv:vv:vv:01".to_string(),
"192.168.220.1".to_string(),
"Apple, Inc.".to_string(),
last_seen,
))
.unwrap();
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
device.device_type = "phone".to_string();
update(device).unwrap();
// A re-sighting that could not deduce a vendor (empty) must NOT clobber the known one,
// nor its derived device_type, but other fields still update.
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
device.vendor = "".to_string();
device.device_type = "".to_string();
device.ipv4_address = "192.168.220.99".to_string();
update(device).unwrap();
let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
assert_eq!(device.vendor, "Apple, Inc.".to_string());
assert_eq!(device.device_type, "phone".to_string());
assert_eq!(device.ipv4_address, "192.168.220.99".to_string());
// A later sighting that deduces a different vendor updates both vendor and device_type.
let mut device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
device.vendor = "Google, Inc.".to_string();
device.device_type = "tablet".to_string();
update(device).unwrap();
let device = read("vv:vv:vv:vv:vv:01".to_string()).unwrap();
assert_eq!(device.vendor, "Google, Inc.".to_string());
assert_eq!(device.device_type, "tablet".to_string());
}
#[tokio::test]
async fn test_insert() {
tests_common::setup().await;
@@ -1,6 +1,6 @@
use crate::mac_vendor_finder;
use crate::data::mac_vendor_finder;
use crate::data::vendor_device_type_finder;
use crate::model::devices::Device;
use crate::vendor_device_type_finder;
use chrono::Local;
use duration_string::DurationString;
use log::{debug, info, trace};
+65 -13
View File
@@ -45,30 +45,52 @@ pub fn open_socket(interface: Option<String>) -> Result<UdpSocket, Box<dyn std::
Ok(udp)
}
/// Parse a raw mDNS/DNS message and return the hostnames advertised by its A records.
pub fn parse_announcement(buf: &[u8]) -> Vec<String> {
/// The contents of an mDNS/DNS announcement relevant to device discovery.
pub struct Announcement {
/// Hostnames advertised by A records (e.g. `Test-Device.local`).
pub hostnames: Vec<String>,
/// Service types advertised by PTR records (e.g. `_airplay._tcp.local`).
pub service_types: Vec<String>,
}
/// Parse a raw mDNS/DNS message into the hostnames (A records) and service types (PTR records)
/// it advertises. Returns an empty `Announcement` if the packet cannot be parsed.
pub fn parse_announcement(buf: &[u8]) -> Announcement {
let packet = match Packet::parse(buf) {
Ok(p) => p,
Err(err) => {
debug!("Ignoring unparseable mDNS packet: {err}");
return Vec::new();
return Announcement {
hostnames: Vec::new(),
service_types: Vec::new(),
};
}
};
packet
.answers
.iter()
.chain(packet.additional_records.iter())
let records = || packet.answers.iter().chain(packet.additional_records.iter());
let hostnames = records()
.filter(|record| matches!(record.rdata, RData::A(_)))
.map(|record| record.name.to_string())
.filter(|host| !host.is_empty())
.collect()
.collect();
let service_types = records()
.filter(|record| matches!(record.rdata, RData::PTR(_)))
.map(|record| record.name.to_string())
.filter(|service| !service.is_empty())
.collect();
Announcement {
hostnames,
service_types,
}
}
#[cfg(test)]
mod tests {
use super::*;
use simple_dns::rdata::A;
use simple_dns::rdata::{A, PTR};
use simple_dns::{CLASS, Name, Packet, ResourceRecord};
#[test]
@@ -85,15 +107,45 @@ mod tests {
));
let bytes = packet.build_bytes_vec().unwrap();
let hosts = parse_announcement(&bytes);
let announcement = parse_announcement(&bytes);
assert!(
hosts.iter().any(|h| h.contains("Test-Device.local")),
"expected to extract Test-Device.local, got {hosts:?}"
announcement
.hostnames
.iter()
.any(|h| h.contains("Test-Device.local")),
"expected to extract Test-Device.local, got {:?}",
announcement.hostnames
);
}
#[test]
fn test_parse_announcement_extracts_service_type() {
let mut packet = Packet::new_reply(1);
let service = Name::new("_airplay._tcp.local").unwrap();
let target = Name::new("Apple-TV._airplay._tcp.local").unwrap();
packet.answers.push(ResourceRecord::new(
service,
CLASS::IN,
120,
RData::PTR(PTR(target)),
));
let bytes = packet.build_bytes_vec().unwrap();
let announcement = parse_announcement(&bytes);
assert!(
announcement
.service_types
.iter()
.any(|s| s.contains("_airplay._tcp.local")),
"expected to extract _airplay._tcp.local, got {:?}",
announcement.service_types
);
}
#[test]
fn test_parse_announcement_ignores_garbage() {
assert!(parse_announcement(&[0xff, 0x00, 0x13]).is_empty());
let announcement = parse_announcement(&[0xff, 0x00, 0x13]);
assert!(announcement.hostnames.is_empty());
assert!(announcement.service_types.is_empty());
}
}
+38 -5
View File
@@ -24,6 +24,13 @@ fn display_name(device: &Device) -> &str {
.unwrap_or("(unknown)")
}
// Whether a re-sighting represents a real vendor change. A scanner that cannot deduce a vendor
// reports an empty string; that is not a change (db::devices::update keeps the known vendor), so
// it must not raise a "vendor changed" notification either.
fn vendor_changed(existing: &str, new: &str) -> bool {
!new.is_empty() && existing != new
}
// Private helper function to deliver messages
fn send_notification(notification: Notification) -> Result<(), Box<dyn Error>> {
debug!("About to record notification in database");
@@ -126,9 +133,10 @@ pub fn trigger_existing_device(
}
// Notify if the devices vendor and/or IP changed
if (existing_device.ipv4_address != new_device.ipv4_address)
&& (existing_device.vendor != new_device.vendor)
{
let ip_changed = existing_device.ipv4_address != new_device.ipv4_address;
let vendor_changed = vendor_changed(&existing_device.vendor, &new_device.vendor);
if ip_changed && vendor_changed {
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
@@ -147,7 +155,7 @@ pub fn trigger_existing_device(
);
send_notification(notification)?;
} else if existing_device.ipv4_address != new_device.ipv4_address {
} else if ip_changed {
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
@@ -165,7 +173,7 @@ pub fn trigger_existing_device(
);
send_notification(notification)?;
} else if existing_device.vendor != new_device.vendor {
} else if vendor_changed {
let notification = Notification::new(
Utc::now(),
NotificationType::DeviceChanged,
@@ -187,3 +195,28 @@ pub fn trigger_existing_device(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_new_vendor_is_not_a_change() {
assert!(!vendor_changed("Apple, Inc.", ""));
}
#[test]
fn different_non_empty_vendor_is_a_change() {
assert!(vendor_changed("Apple, Inc.", "Google, Inc."));
}
#[test]
fn same_vendor_is_not_a_change() {
assert!(!vendor_changed("Apple, Inc.", "Apple, Inc."));
}
#[test]
fn newly_deduced_vendor_from_empty_is_a_change() {
assert!(vendor_changed("", "Apple, Inc."));
}
}
+1 -2
View File
@@ -4,17 +4,16 @@ use log::{LevelFilter, info};
mod arp_scanner;
mod arp_scanner_status;
mod data;
mod db;
mod device_finders;
mod events;
mod mac_vendor_finder;
mod mdns_scanner;
mod mdns_scanner_status;
mod model;
mod retention;
mod settings;
mod utils;
mod vendor_device_type_finder;
mod web_server;
#[cfg(test)]
+19 -5
View File
@@ -7,11 +7,11 @@ use log::{debug, error, info, warn};
use crate::db;
use crate::device_finders::mdns;
use crate::events;
use crate::mac_vendor_finder;
use crate::data::mac_vendor_finder;
use crate::mdns_scanner_status;
use crate::model::devices::Device;
use crate::settings::get_settings;
use crate::vendor_device_type_finder;
use crate::data::vendor_device_type_finder;
/// Passively listen for mDNS/Bonjour announcements and feed discovered devices into the same
/// pipeline used by the ARP scanner (devices table + events + notifications).
@@ -38,18 +38,27 @@ pub async fn listen() -> Result<(), Box<dyn std::error::Error>> {
IpAddr::V6(_) => continue, // the device model is IPv4-only
};
let hostname = match mdns::parse_announcement(&buf[..len]).into_iter().next() {
let announcement = mdns::parse_announcement(&buf[..len]);
let hostname = match announcement.hostnames.into_iter().next() {
Some(host) => host,
None => continue,
};
process_announcement(src_ip, hostname, interface.clone(), probe_timeout).await;
process_announcement(
src_ip,
hostname,
announcement.service_types,
interface.clone(),
probe_timeout,
)
.await;
}
}
async fn process_announcement(
src_ip: Ipv4Addr,
hostname: String,
service_types: Vec<String>,
interface: Option<String>,
probe_timeout: Duration,
) {
@@ -61,7 +70,12 @@ async fn process_announcement(
}
};
let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
let mut vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string());
// Privacy MACs are locally administered and have no real OUI, so the lookup above fails.
// Fall back to the vendor-specific mDNS services the device advertises.
if vendor.is_empty() && crate::utils::network::is_locally_administered(&mac) {
vendor = crate::data::service_vendor_finder::find(&service_types);
}
let mut device = Device::new(
mac.clone(),
src_ip.to_string(),
+28
View File
@@ -138,6 +138,16 @@ fn probe_mac(target_ip: Ipv4Addr, interface: Option<String>, timeout: Duration)
None
}
/// Return whether a MAC address is locally administered, i.e. the second-least-significant bit of
/// its first octet is set. Randomized/private MACs (e.g. Apple "Private WiFi Address") are locally
/// administered and have no real OUI. Returns `false` for malformed input.
pub fn is_locally_administered(mac: &str) -> bool {
mac.split(':')
.next()
.and_then(|octet| u8::from_str_radix(octet, 16).ok())
.is_some_and(|first_octet| first_octet & 0x02 != 0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -245,4 +255,22 @@ mod tests {
let mac = parse_proc_net_arp(SAMPLE, Ipv4Addr::new(10, 0, 0, 1));
assert_eq!(mac, None);
}
#[test]
fn test_locally_administered_mac_is_detected() {
assert!(is_locally_administered("f2:00:a9:35:91:fc"));
assert!(is_locally_administered("a6:11:22:33:44:55"));
}
#[test]
fn test_globally_administered_mac_is_not_locally_administered() {
assert!(!is_locally_administered("00:1a:2b:3c:4d:5e"));
assert!(!is_locally_administered("3c:22:fb:00:11:22"));
}
#[test]
fn test_malformed_mac_is_not_locally_administered() {
assert!(!is_locally_administered(""));
assert!(!is_locally_administered("zz:00:00:00:00:00"));
}
}