diff --git a/backend/data/mdns-service-vendor.json b/backend/data/mdns-service-vendor.json new file mode 100644 index 0000000..7d466a1 --- /dev/null +++ b/backend/data/mdns-service-vendor.json @@ -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." +} diff --git a/backend/src/data.rs b/backend/src/data.rs new file mode 100644 index 0000000..7f21a14 --- /dev/null +++ b/backend/src/data.rs @@ -0,0 +1,3 @@ +pub mod mac_vendor_finder; +pub mod service_vendor_finder; +pub mod vendor_device_type_finder; diff --git a/backend/src/mac_vendor_finder.rs b/backend/src/data/mac_vendor_finder.rs similarity index 95% rename from backend/src/mac_vendor_finder.rs rename to backend/src/data/mac_vendor_finder.rs index a43f10f..9c56fb1 100644 --- a/backend/src/mac_vendor_finder.rs +++ b/backend/src/data/mac_vendor_finder.rs @@ -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 = match serde_json::from_str(data) { Ok(value) => value, diff --git a/backend/src/data/service_vendor_finder.rs b/backend/src/data/service_vendor_finder.rs new file mode 100644 index 0000000..ddb94e6 --- /dev/null +++ b/backend/src/data/service_vendor_finder.rs @@ -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 = { + info!("Loading mDNS service vendor database into memory"); + + let data = include_str!("../../data/mdns-service-vendor.json"); + + let raw: HashMap = 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::>(); + + 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(&[]), ""); + } +} diff --git a/backend/src/vendor_device_type_finder.rs b/backend/src/data/vendor_device_type_finder.rs similarity index 95% rename from backend/src/vendor_device_type_finder.rs rename to backend/src/data/vendor_device_type_finder.rs index 4d91dfe..5c69e56 100644 --- a/backend/src/vendor_device_type_finder.rs +++ b/backend/src/data/vendor_device_type_finder.rs @@ -7,7 +7,7 @@ lazy_static! { static ref VENDOR_DEVICE_TYPE_DATABASE: HashMap = { 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 = match serde_json::from_str(data) { Ok(value) => value, diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index 6d021e1..6d9cda6 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -186,19 +186,25 @@ pub fn get_summary() -> Result { 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 = 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; diff --git a/backend/src/device_finders/arp/packet_send_receive.rs b/backend/src/device_finders/arp/packet_send_receive.rs index fce13d2..1016b62 100644 --- a/backend/src/device_finders/arp/packet_send_receive.rs +++ b/backend/src/device_finders/arp/packet_send_receive.rs @@ -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}; diff --git a/backend/src/device_finders/mdns.rs b/backend/src/device_finders/mdns.rs index b9dbb50..710d510 100644 --- a/backend/src/device_finders/mdns.rs +++ b/backend/src/device_finders/mdns.rs @@ -45,30 +45,52 @@ pub fn open_socket(interface: Option) -> Result Vec { +/// 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, + /// Service types advertised by PTR records (e.g. `_airplay._tcp.local`). + pub service_types: Vec, +} + +/// 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()); } } diff --git a/backend/src/events.rs b/backend/src/events.rs index 07eca85..d18a557 100644 --- a/backend/src/events.rs +++ b/backend/src/events.rs @@ -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> { 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.")); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index d91ce48..3618b18 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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)] diff --git a/backend/src/mdns_scanner.rs b/backend/src/mdns_scanner.rs index e47e4b0..27c8c37 100644 --- a/backend/src/mdns_scanner.rs +++ b/backend/src/mdns_scanner.rs @@ -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> { 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, interface: Option, 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(), diff --git a/backend/src/utils/network.rs b/backend/src/utils/network.rs index beb3bcc..ada187a 100644 --- a/backend/src/utils/network.rs +++ b/backend/src/utils/network.rs @@ -138,6 +138,16 @@ fn probe_mac(target_ip: Ipv4Addr, interface: Option, 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")); + } }