mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add SSDP/UPnP scanner
Introduce a new SSDP/UPnP network scanner alongside the existing ARP and mDNS scanners: - New scanner module under scanners/ssdp (finder, scanner, status) - Wire the scanner into the main scan loop - Add Ssdp variant to DeviceEventScanner - Add SsdpScanner settings with a configurable probe timeout - Expose /api/ssdp_scanner/status and wire it into OpenAPI generation - Add a lint.sh helper and point CLAUDE.md at it Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bf2f18b075
commit
936e9ffe79
@@ -0,0 +1,209 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user