mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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>
213 lines
7.4 KiB
Rust
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"));
|
|
}
|
|
}
|