Files
oott/backend/src/scanners/ssdp/finder.rs
T
rzuastiandClaude Opus 4.8 42310a36e9 Consolidate active-scan notifications and scrub private data
Active scanners (ARP, SNMP) now accumulate every change across a whole
scan and emit one notification per type via events::notify: a single
device produces the usual single-device notification (carrying its MAC),
while two or more produce one consolidated summary with an empty
mac_address. Device events are still recorded per device.

Notification bodies no longer include MAC or IP addresses; the title
MAC fallback is masked to the last two octets. Summaries list up to
three devices then "…and N more devices".

Split sighting handling so record_sighting persists + records the event
and returns Vec<DeviceChange>; passive listeners (mDNS, SSDP, DHCP) use
record_and_notify since they see one device per event.

Also fixes NotificationType::from_str never mapping "DeviceChanged",
which made those notifications round-trip from the DB as Other.

Frontend: the card already hides the device link when mac_address is
null; added widget tests for the present/absent link cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:31:13 -04:00

213 lines
7.4 KiB
Rust

use std::net::{Ipv4Addr, SocketAddrV4};
use log::{debug, info};
use pnet::datalink;
use pnet::ipnetwork::IpNetwork;
use socket2::{Domain, Protocol, Socket, Type};
use tokio::net::UdpSocket;
use crate::utils::network::select_interface;
const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
const SSDP_PORT: u16 = 1900;
/// Open a UDP socket that passively listens for SSDP/UPnP multicast announcements on the given
/// interface. The socket is bound with address/port reuse so it coexists with other SSDP
/// responders (e.g. minidlna, gssdp-scan) already using port 1900.
pub fn open_socket(interface: Option<String>) -> Result<UdpSocket, Box<dyn std::error::Error>> {
let all_interfaces = datalink::interfaces();
let iface = select_interface(&all_interfaces, &interface)
.ok_or("No suitable interface found for the SSDP listener")?;
let iface_ip = iface
.ips
.iter()
.find_map(|el| match el {
IpNetwork::V4(v4) => Some(v4.ip()),
_ => None,
})
.ok_or("Selected interface has no IPv4 address")?;
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
socket.set_reuse_address(true)?;
#[cfg(unix)]
socket.set_reuse_port(true)?;
socket.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, SSDP_PORT).into())?;
socket.join_multicast_v4(&SSDP_GROUP, &iface_ip)?;
socket.set_nonblocking(true)?;
let udp = UdpSocket::from_std(socket.into())?;
info!(
"SSDP listener bound to port {SSDP_PORT} on interface {} ({iface_ip})",
iface.name
);
Ok(udp)
}
/// The contents of an SSDP NOTIFY announcement relevant to device discovery.
pub struct Announcement {
/// SERVER header value (e.g. `Linux/3.14 UPnP/1.1 MiniDLNA/1.3.0`). Used as a name hint.
pub server: Option<String>,
/// NT header values describing the device/service type
/// (e.g. `urn:schemas-upnp-org:device:MediaServer:1`).
pub device_types: Vec<String>,
}
/// Parse a raw SSDP packet into an `Announcement`. Returns `None` for anything that is not a
/// live device announcement (byebye, M-SEARCH requests, HTTP responses, unparseable garbage).
///
/// SSDP messages are text-based HTTP/1.x style. We accept both `ssdp:alive` and `ssdp:update`
/// (UPnP 1.1 bootID change) as "device is alive" signals.
pub fn parse_announcement(buf: &[u8]) -> Option<Announcement> {
let text = match std::str::from_utf8(buf) {
Ok(t) => t,
Err(err) => {
debug!("Ignoring non-UTF8 SSDP packet: {err}");
return None;
}
};
// Tolerate bare-LF line endings (some embedded devices) by splitting on `\n` and stripping
// any trailing `\r`.
let mut lines = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l));
let first_line = lines.next().unwrap_or("");
if !first_line.to_ascii_uppercase().starts_with("NOTIFY ") {
return None;
}
let mut server: Option<String> = None;
let mut device_types: Vec<String> = Vec::new();
let mut nts: Option<String> = None;
for line in lines {
if line.is_empty() {
continue;
}
let mut parts = line.splitn(2, ':');
let key = match parts.next() {
Some(k) => k.trim().to_ascii_lowercase(),
None => continue,
};
let value = match parts.next() {
Some(v) => v.trim().to_string(),
None => continue,
};
if value.is_empty() {
continue;
}
match key.as_str() {
"nts" => nts = Some(value.to_ascii_lowercase()),
"nt" => device_types.push(value),
"server" => server = Some(value),
_ => {}
}
}
match nts.as_deref() {
Some("ssdp:alive") | Some("ssdp:update") => Some(Announcement {
server,
device_types,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn alive_packet() -> Vec<u8> {
let body = "NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
CACHE-CONTROL: max-age=1800\r\n\
LOCATION: http://192.168.1.100:49152/rootDesc.xml\r\n\
NT: urn:schemas-upnp-org:device:MediaServer:1\r\n\
NTS: ssdp:alive\r\n\
SERVER: Linux/3.14 UPnP/1.1 MiniDLNA/1.3.0\r\n\
USN: uuid:abcd::urn:schemas-upnp-org:device:MediaServer:1\r\n\r\n";
body.as_bytes().to_vec()
}
#[test]
fn test_parse_alive_extracts_server_and_nt() {
let announcement = parse_announcement(&alive_packet()).expect("alive packet");
assert_eq!(
announcement.server.as_deref(),
Some("Linux/3.14 UPnP/1.1 MiniDLNA/1.3.0")
);
assert_eq!(
announcement.device_types,
vec!["urn:schemas-upnp-org:device:MediaServer:1".to_string()]
);
}
#[test]
fn test_parse_alive_preserves_location_with_embedded_colons() {
// Just ensure LOCATION with `:` in the value does not corrupt subsequent header parsing.
let announcement = parse_announcement(&alive_packet()).expect("alive packet");
assert!(announcement.server.is_some());
assert!(!announcement.device_types.is_empty());
}
#[test]
fn test_parse_update_accepted() {
let body = "NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
NT: upnp:rootdevice\r\n\
NTS: ssdp:update\r\n\
SERVER: Foo/1.0\r\n\
USN: uuid:abcd::upnp:rootdevice\r\n\r\n";
let announcement = parse_announcement(body.as_bytes()).expect("update packet");
assert_eq!(announcement.server.as_deref(), Some("Foo/1.0"));
}
#[test]
fn test_parse_byebye_returns_none() {
let body = "NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
NT: upnp:rootdevice\r\n\
NTS: ssdp:byebye\r\n\
USN: uuid:abcd::upnp:rootdevice\r\n\r\n";
assert!(parse_announcement(body.as_bytes()).is_none());
}
#[test]
fn test_parse_http_response_returns_none() {
let body = "HTTP/1.1 200 OK\r\n\
CACHE-CONTROL: max-age=1800\r\n\
SERVER: Foo/1.0\r\n\r\n";
assert!(parse_announcement(body.as_bytes()).is_none());
}
#[test]
fn test_parse_garbage_returns_none() {
assert!(parse_announcement(&[0xff, 0x00, 0x13]).is_none());
}
#[test]
fn test_parse_missing_server_header() {
let body = "NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
NT: upnp:rootdevice\r\n\
NTS: ssdp:alive\r\n\
USN: uuid:abcd::upnp:rootdevice\r\n\r\n";
let announcement = parse_announcement(body.as_bytes()).expect("alive packet");
assert!(announcement.server.is_none());
assert_eq!(
announcement.device_types,
vec!["upnp:rootdevice".to_string()]
);
}
#[test]
fn test_parse_bare_lf_line_endings() {
let body = "NOTIFY * HTTP/1.1\n\
HOST: 239.255.255.250:1900\n\
NT: upnp:rootdevice\n\
NTS: ssdp:alive\n\
SERVER: Bar/2.0\n\n";
let announcement = parse_announcement(body.as_bytes()).expect("alive packet");
assert_eq!(announcement.server.as_deref(), Some("Bar/2.0"));
}
}