mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.7
parent
7fb457ca4a
commit
98a23aa7fd
@@ -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."
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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(&[]), "");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user