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) -> Result> { 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, /// NT header values describing the device/service type /// (e.g. `urn:schemas-upnp-org:device:MediaServer:1`). pub device_types: Vec, } /// 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 { 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 = None; let mut device_types: Vec = Vec::new(); let mut nts: Option = 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 { 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")); } }