lsnet 0.8.0

See what's on your local network: fast, zero-config device discovery and identification
//! SSDP / UPnP discovery. Routers, TVs, media players and NASes answer an
//! M-SEARCH with the URL of an XML description that names their maker and model.

use crate::http;
use ipnetwork::Ipv4Network;
use serde::Serialize;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::task::JoinSet;
use tokio::time::{Instant, timeout_at};

const SSDP: (Ipv4Addr, u16) = (Ipv4Addr::new(239, 255, 255, 250), 1900);
const SEARCH: &str = "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n\
    MAN: \"ssdp:discover\"\r\nMX: 1\r\nST: ssdp:all\r\n\r\n";

#[derive(Default, Clone, Serialize)]
pub struct SsdpInfo {
    pub server: Option<String>,
    pub friendly_name: Option<String>,
    pub manufacturer: Option<String>,
    pub model_name: Option<String>,
    pub model_number: Option<String>,
    pub device_type: Option<String>,
}

/// Collect responses for `wait`, fetching each device's description as soon
/// as it answers, then allow `fetch_grace` for the last fetches to finish.
pub async fn discover(
    local_ip: Ipv4Addr,
    net: Ipv4Network,
    own_ips: &[Ipv4Addr],
    wait: Duration,
    fetch_grace: Duration,
) -> HashMap<Ipv4Addr, SsdpInfo> {
    let Ok(sock) = UdpSocket::bind((local_ip, 0)).await else {
        return HashMap::new();
    };
    let _ = sock.send_to(SEARCH.as_bytes(), SSDP).await;
    let start = Instant::now();
    collect(
        &sock,
        net,
        own_ips,
        start + wait,
        Some(start + wait / 3),
        start + wait + fetch_grace,
    )
    .await
}

/// Search each of `targets` directly, for devices whose answers to the
/// multicast search were lost: Wi-Fi access points often filter multicast,
/// and a unicast search gets through. Everything, description fetches
/// included, finishes within `wait`.
pub async fn query(
    local_ip: Ipv4Addr,
    net: Ipv4Network,
    own_ips: &[Ipv4Addr],
    targets: &[Ipv4Addr],
    wait: Duration,
) -> HashMap<Ipv4Addr, SsdpInfo> {
    if targets.is_empty() {
        return HashMap::new();
    }
    let Ok(sock) = UdpSocket::bind((local_ip, 0)).await else {
        return HashMap::new();
    };
    for &ip in targets {
        let _ = sock
            .send_to(unicast_search(ip).as_bytes(), (ip, 1900))
            .await;
    }
    let end = Instant::now() + wait;
    collect(&sock, net, own_ips, end, None, end).await
}

/// An M-SEARCH addressed to one device. Unicast searches are answered at
/// once, so MX only matters to devices that ignore that rule.
fn unicast_search(ip: Ipv4Addr) -> String {
    format!(
        "M-SEARCH * HTTP/1.1\r\nHOST: {ip}:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 1\r\nST: upnp:rootdevice\r\n\r\n"
    )
}

/// Read search responses until `listen_end`, repeating the multicast search
/// at `resend_at` if given, and fetch each responder's description as soon as
/// it answers. Descriptions still arriving at `fetch_end` are dropped.
async fn collect(
    sock: &UdpSocket,
    net: Ipv4Network,
    own_ips: &[Ipv4Addr],
    listen_end: Instant,
    mut resend_at: Option<Instant>,
    fetch_end: Instant,
) -> HashMap<Ipv4Addr, SsdpInfo> {
    let mut seen: HashMap<Ipv4Addr, Option<String>> = HashMap::new();
    let mut fetches = JoinSet::new();
    let mut buf = vec![0u8; 4096];

    loop {
        let until = resend_at.unwrap_or(listen_end);
        match timeout_at(until, sock.recv_from(&mut buf)).await {
            Ok(Ok((n, SocketAddr::V4(src)))) => {
                let ip = *src.ip();
                if !net.contains(ip) || seen.contains_key(&ip) {
                    continue;
                }
                let resp = String::from_utf8_lossy(&buf[..n]).to_string();
                let header = |name: &str| {
                    resp.lines().find_map(|l| {
                        let (k, v) = l.split_once(':')?;
                        k.trim()
                            .eq_ignore_ascii_case(name)
                            .then(|| v.trim().to_string())
                    })
                };
                seen.insert(ip, header("server"));
                if let Some((host, port, path)) =
                    header("location").and_then(|l| description_url(&l, ip, own_ips))
                {
                    let wait = fetch_end.saturating_duration_since(Instant::now());
                    fetches.spawn(async move { (ip, http::get(host, port, &path, wait).await) });
                }
            }
            Ok(_) => {}
            Err(_) if resend_at.is_some() => {
                resend_at = None;
                let _ = sock.send_to(SEARCH.as_bytes(), SSDP).await;
            }
            Err(_) => break,
        }
    }

    let mut out: HashMap<Ipv4Addr, SsdpInfo> = seen
        .into_iter()
        .map(|(ip, server)| {
            (
                ip,
                SsdpInfo {
                    server,
                    ..Default::default()
                },
            )
        })
        .collect();

    while let Ok(Some(joined)) = timeout_at(fetch_end, fetches.join_next()).await {
        let Ok((ip, Some(resp))) = joined else {
            continue;
        };
        let info = out.entry(ip).or_default();
        let x = &resp.body;
        info.friendly_name = http::tag(x, "friendlyName");
        info.manufacturer = http::tag(x, "manufacturer");
        info.model_name = http::tag(x, "modelName");
        info.model_number = http::tag(x, "modelNumber");
        info.device_type = http::tag(x, "deviceType");
    }
    out
}

/// Where to fetch a responder's description, if its LOCATION is safe to follow.
///
/// Only the device that answered is asked, and never this machine; otherwise
/// anyone on the LAN could point us at 127.0.0.1 or the internet. Queries are
/// refused too: description URLs don't have them, but "do something on GET"
/// endpoints usually take their parameters that way.
fn description_url(
    location: &str,
    responder: Ipv4Addr,
    own_ips: &[Ipv4Addr],
) -> Option<(Ipv4Addr, u16, String)> {
    let (host, port, path) = http::parse_url(location)?;
    (host == responder && !own_ips.contains(&host) && !path.contains('?'))
        .then_some((host, port, path))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unicast_search_names_its_target() {
        let s = unicast_search("192.168.1.57".parse().unwrap());
        assert!(s.starts_with("M-SEARCH * HTTP/1.1\r\nHOST: 192.168.1.57:1900\r\n"));
        assert!(s.ends_with("ST: upnp:rootdevice\r\n\r\n"));
    }

    #[test]
    fn follows_only_safe_locations() {
        let dev: Ipv4Addr = "192.168.1.57".parse().unwrap();
        let me: Ipv4Addr = "192.168.1.179".parse().unwrap();
        let ok = |l: &str, from: Ipv4Addr| description_url(l, from, &[me]).is_some();

        assert!(ok("http://192.168.1.57:49152/description.xml", dev));
        assert!(!ok("http://127.0.0.1:8080/description.xml", dev));
        assert!(!ok("http://192.168.1.1/description.xml", dev));
        assert!(!ok("http://192.168.1.57/cgi-bin/reboot?now=1", dev));
        assert!(!ok("http://192.168.1.179/description.xml", me));
    }
}