1use 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
46fn 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
52const CHECK_EVERY: Duration = Duration::from_secs(5);
54const 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
66struct 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 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
118fn 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
170pub 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
187pub 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}