Skip to main content

tiny_dlna/
ssdp.rs

1//! SSDP: answering M-SEARCH for local renders, and searching for devices.
2
3use std::io;
4use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpStream, UdpSocket};
5use std::thread;
6use std::time::{Duration, Instant, SystemTime};
7
8use log::{debug, error, info};
9use socket2::{Domain, Protocol, Socket, Type};
10
11use crate::{config, net};
12
13pub const MULTICAST_IP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
14pub const PORT: u16 = 1900;
15
16const ST_MEDIA_RENDERER: &str = "urn:schemas-upnp-org:device:MediaRenderer:1";
17const ST_AVTRANSPORT: &str = "urn:schemas-upnp-org:service:AVTransport:1";
18
19fn contains(haystack: &[u8], needle: &[u8]) -> bool {
20    haystack.windows(needle.len()).any(|w| w == needle)
21}
22
23fn search_target(data: &[u8]) -> &'static str {
24    if contains(data, b":device:MediaRenderer:1") {
25        ST_MEDIA_RENDERER
26    } else {
27        ST_AVTRANSPORT
28    }
29}
30
31fn build_response(st: &str, ip: Ipv4Addr, port: u16, uuid: &str) -> String {
32    format!(
33        "HTTP/1.1 200 OK\r\n\
34         ST: {st}\r\n\
35         USN: uuid:{uuid}::{st}\r\n\
36         Location: http://{ip}:{port}/description.xml\r\n\
37         EXT: \r\n\
38         Server: UPnP/1.0 TinyRender/{version}\r\n\
39         Cache-Control: max-age=70\r\n\
40         Date: {date}\r\n\r\n",
41        version = crate::VERSION,
42        date = httpdate::fmt_http_date(SystemTime::now()),
43    )
44}
45
46/// Skip registry entries left behind by renders that died without cleanup.
47fn is_listening(port: u16) -> bool {
48    let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
49    TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
50}
51
52/// How often the listener wakes up to check its multicast membership.
53const CHECK_EVERY: Duration = Duration::from_secs(5);
54/// Re-join this often even when nothing seems to have changed.
55const REJOIN_EVERY: Duration = Duration::from_secs(60);
56
57fn bind_listener() -> io::Result<UdpSocket> {
58    let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
59    sock.set_reuse_address(true)?;
60    sock.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, PORT).into())?;
61    let sock: UdpSocket = sock.into();
62    sock.set_read_timeout(Some(CHECK_EVERY))?;
63    Ok(sock)
64}
65
66/// Keeps the listener joined to the SSDP group on every LAN interface.
67///
68/// The OS can silently drop a socket's multicast membership when the
69/// network goes away, e.g. Wi-Fi turning off while a Mac sleeps. The socket
70/// then still gets unicast traffic but never sees M-SEARCH again, so we
71/// re-join when the interfaces change, after a sleep, and periodically.
72struct Membership {
73    ifaces: Vec<Ipv4Addr>,
74    joined_at: Instant,
75    checked_at: Instant,
76    checked_wall: SystemTime,
77}
78
79impl Membership {
80    fn new(sock: &UdpSocket) -> Self {
81        let ifaces = net::lan_ips();
82        join_group(sock, &ifaces);
83        let now = Instant::now();
84        Membership {
85            ifaces,
86            joined_at: now,
87            checked_at: now,
88            checked_wall: SystemTime::now(),
89        }
90    }
91
92    fn refresh(&mut self, sock: &UdpSocket) {
93        let now = Instant::now();
94        if now - self.checked_at < CHECK_EVERY {
95            return;
96        }
97        let wall = SystemTime::now();
98        // Instant stops while the machine sleeps; wall-clock time doesn't.
99        let wall_gap = wall.duration_since(self.checked_wall).unwrap_or_default();
100        let slept = wall_gap > (now - self.checked_at) + CHECK_EVERY;
101        self.checked_at = now;
102        self.checked_wall = wall;
103
104        let ifaces = net::lan_ips();
105        let changed = ifaces != self.ifaces;
106        if !(changed || slept || now - self.joined_at >= REJOIN_EVERY) {
107            return;
108        }
109        if changed || slept {
110            info!("network changed, re-joining SSDP group on {ifaces:?}");
111        }
112        join_group(sock, &ifaces);
113        self.ifaces = ifaces;
114        self.joined_at = now;
115    }
116}
117
118/// (Re-)join the SSDP group on each interface. Leaving first clears any
119/// stale membership the kernel still holds for this socket.
120fn join_group(sock: &UdpSocket, ifaces: &[Ipv4Addr]) {
121    for ip in ifaces {
122        let _ = sock.leave_multicast_v4(&MULTICAST_IP, ip);
123        if let Err(e) = sock.join_multicast_v4(&MULTICAST_IP, ip) {
124            debug!("failed to join SSDP group on {ip}: {e}");
125        }
126    }
127}
128
129fn listen(sock: &UdpSocket) -> io::Result<()> {
130    debug!("SSDP server running at {PORT}");
131    let mut membership = Membership::new(sock);
132    let mut buf = [0u8; 2048];
133    loop {
134        membership.refresh(sock);
135        let (n, addr) = match sock.recv_from(&mut buf) {
136            Ok(r) => r,
137            Err(e)
138                if matches!(
139                    e.kind(),
140                    io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
141                ) =>
142            {
143                continue;
144            }
145            Err(e) => return Err(e),
146        };
147        let data = &buf[..n];
148        if !(contains(data, b"M-SEARCH") && contains(data, b"ssdp:discover")) {
149            continue;
150        }
151        let st = search_target(data);
152        info!("Received M-SEARCH from {addr}, sending response...");
153        let Some(ip) = net::local_ip_for(addr.ip()) else {
154            error!("failed to find host IP");
155            continue;
156        };
157        for port in config::live_render_ports() {
158            if !is_listening(port) {
159                debug!("skip render on port {port}: not listening");
160                continue;
161            }
162            let resp = build_response(st, ip, port, &config::render_uuid(port));
163            if let Err(e) = sock.send_to(resp.as_bytes(), addr) {
164                debug!("failed to answer {addr}: {e}");
165            }
166        }
167    }
168}
169
170/// Answer M-SEARCH requests forever. If the SSDP port can't be bound
171/// (e.g. another listener holds it exclusively), keep retrying so this
172/// process takes over once the other one exits.
173pub fn serve_forever() {
174    loop {
175        match bind_listener() {
176            Ok(sock) => {
177                if let Err(e) = listen(&sock) {
178                    error!("SSDP listener failed: {e}");
179                }
180            }
181            Err(e) => debug!("SSDP bind failed: {e}"),
182        }
183        thread::sleep(Duration::from_millis(500));
184    }
185}
186
187/// Send an M-SEARCH for AVTransport services and collect raw replies.
188pub fn search(wait: Duration) -> io::Result<Vec<(SocketAddr, String)>> {
189    let msg = format!(
190        "M-SEARCH * HTTP/1.1\r\n\
191         HOST: {MULTICAST_IP}:{PORT}\r\n\
192         MAN: \"ssdp:discover\"\r\n\
193         MX: 1\r\n\
194         ST: {ST_AVTRANSPORT}\r\n\r\n"
195    );
196    let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
197    sock.set_multicast_ttl_v4(2)?;
198    sock.send_to(msg.as_bytes(), (MULTICAST_IP, PORT))?;
199
200    let deadline = Instant::now() + wait;
201    let mut replies = Vec::new();
202    let mut buf = [0u8; 2048];
203    loop {
204        let left = deadline.saturating_duration_since(Instant::now());
205        if left.is_zero() {
206            break;
207        }
208        sock.set_read_timeout(Some(left))?;
209        match sock.recv_from(&mut buf) {
210            Ok((n, addr)) => {
211                replies.push((addr, String::from_utf8_lossy(&buf[..n]).into_owned()));
212            }
213            Err(e)
214                if matches!(
215                    e.kind(),
216                    io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
217                ) =>
218            {
219                break;
220            }
221            Err(e) => return Err(e),
222        }
223    }
224    Ok(replies)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn response_format() {
233        let r = build_response(
234            ST_MEDIA_RENDERER,
235            Ipv4Addr::new(10, 0, 0, 2),
236            59876,
237            "abc-59876",
238        );
239        assert!(r.starts_with("HTTP/1.1 200 OK\r\n"));
240        assert!(r.contains("USN: uuid:abc-59876::urn:schemas-upnp-org:device:MediaRenderer:1\r\n"));
241        assert!(r.contains("Location: http://10.0.0.2:59876/description.xml\r\n"));
242        assert!(r.ends_with("\r\n\r\n"));
243    }
244
245    #[test]
246    fn target_detection() {
247        assert_eq!(
248            search_target(b"ST: urn:schemas-upnp-org:device:MediaRenderer:1"),
249            ST_MEDIA_RENDERER
250        );
251        assert_eq!(search_target(b"ST: ssdp:all"), ST_AVTRANSPORT);
252    }
253}