346 lines
11 KiB
Rust
346 lines
11 KiB
Rust
//! Serveur SSDP
|
|
|
|
use super::{MAX_AGE, SSDP_MULTICAST_ADDR, SSDP_PORT, SsdpDevice};
|
|
use std::collections::HashMap;
|
|
use std::net::{SocketAddr, UdpSocket};
|
|
use std::sync::{Arc, RwLock};
|
|
use std::time::Duration;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Serveur SSDP gérant les annonces et découvertes
|
|
pub struct SsdpServer {
|
|
/// Devices enregistrés (UUID -> Device)
|
|
devices: Arc<RwLock<HashMap<String, SsdpDevice>>>,
|
|
|
|
/// Socket UDP pour SSDP
|
|
socket: Option<Arc<UdpSocket>>,
|
|
}
|
|
|
|
impl SsdpServer {
|
|
/// Crée un nouveau serveur SSDP
|
|
pub fn new() -> Self {
|
|
Self {
|
|
devices: Arc::new(RwLock::new(HashMap::new())),
|
|
socket: None,
|
|
}
|
|
}
|
|
|
|
/// Démarre le serveur SSDP
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// `Ok(())` si le démarrage a réussi, `Err` sinon
|
|
pub fn start(&mut self) -> std::io::Result<()> {
|
|
let addr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT);
|
|
let socket = UdpSocket::bind(("0.0.0.0", SSDP_PORT))?;
|
|
|
|
// Rejoindre le groupe multicast
|
|
socket.join_multicast_v4(
|
|
&SSDP_MULTICAST_ADDR.parse().unwrap(),
|
|
&"0.0.0.0".parse().unwrap(),
|
|
)?;
|
|
|
|
socket.set_read_timeout(Some(Duration::from_secs(1)))?;
|
|
socket.set_multicast_loop_v4(false)?;
|
|
|
|
let socket = Arc::new(socket);
|
|
self.socket = Some(socket.clone());
|
|
|
|
info!("✅ SSDP server started on {}", addr);
|
|
|
|
// Lancer les goroutines d'annonces périodiques et d'écoute M-SEARCH
|
|
self.start_periodic_announcements(socket.clone());
|
|
self.start_msearch_listener(socket.clone());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Ajoute un device et envoie un alive initial
|
|
pub fn add_device(&self, device: SsdpDevice) {
|
|
let uuid = device.uuid.clone();
|
|
let mut devices = self.devices.write().unwrap();
|
|
devices.insert(uuid.clone(), device.clone());
|
|
drop(devices);
|
|
|
|
info!(
|
|
"🆕 SSDP device registered: {} ({} NTs)",
|
|
uuid,
|
|
device.get_notification_types().len()
|
|
);
|
|
debug!(
|
|
"🆕 SSDP device notification types for {}: {:?}",
|
|
uuid,
|
|
device.get_notification_types()
|
|
);
|
|
|
|
// Envoyer alive pour tous les NTs
|
|
if let Some(ref socket) = self.socket {
|
|
for nt in device.get_notification_types() {
|
|
self.send_alive(socket, &device, nt);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Supprime un device et envoie un byebye
|
|
pub fn remove_device(&self, uuid: &str) {
|
|
let mut devices = self.devices.write().unwrap();
|
|
if let Some(device) = devices.remove(uuid) {
|
|
drop(devices);
|
|
|
|
info!(
|
|
"🗑️ SSDP device removed: {} ({} NTs)",
|
|
uuid,
|
|
device.get_notification_types().len()
|
|
);
|
|
|
|
// Envoyer byebye pour tous les NTs
|
|
if let Some(ref socket) = self.socket {
|
|
for nt in device.get_notification_types() {
|
|
self.send_byebye(socket, &device, nt);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Envoie un NOTIFY alive
|
|
fn send_alive(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) {
|
|
let usn = if nt.starts_with("uuid:") {
|
|
format!("{}", nt)
|
|
} else {
|
|
format!("uuid:{}::{}", device.uuid, nt)
|
|
};
|
|
|
|
let msg = format!(
|
|
"NOTIFY * HTTP/1.1\r\n\
|
|
HOST: {}:{}\r\n\
|
|
CACHE-CONTROL: max-age={}\r\n\
|
|
LOCATION: {}\r\n\
|
|
NT: {}\r\n\
|
|
NTS: ssdp:alive\r\n\
|
|
SERVER: {}\r\n\
|
|
USN: {}\r\n\
|
|
\r\n",
|
|
SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn
|
|
);
|
|
|
|
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
|
.parse()
|
|
.unwrap();
|
|
|
|
match socket.send_to(msg.as_bytes(), addr) {
|
|
Ok(_) => {
|
|
info!("✅ NOTIFY alive: {} (NT={})", usn, nt);
|
|
debug!(
|
|
"📣 NOTIFY alive payload\n<details>\n\n```\n{}\n```\n</details>\n",
|
|
msg
|
|
);
|
|
}
|
|
Err(e) => warn!("❌ Failed to send NOTIFY alive for {}: {}", usn, e),
|
|
}
|
|
}
|
|
|
|
/// Envoie un NOTIFY byebye
|
|
fn send_byebye(&self, socket: &UdpSocket, device: &SsdpDevice, nt: &str) {
|
|
let usn = if nt.starts_with("uuid:") {
|
|
format!("{}", nt)
|
|
} else {
|
|
format!("uuid:{}::{}", device.uuid, nt)
|
|
};
|
|
|
|
let msg = format!(
|
|
"NOTIFY * HTTP/1.1\r\n\
|
|
HOST: {}:{}\r\n\
|
|
NT: {}\r\n\
|
|
NTS: ssdp:byebye\r\n\
|
|
USN: {}\r\n\
|
|
\r\n",
|
|
SSDP_MULTICAST_ADDR, SSDP_PORT, nt, usn
|
|
);
|
|
|
|
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
|
.parse()
|
|
.unwrap();
|
|
|
|
match socket.send_to(msg.as_bytes(), addr) {
|
|
Ok(_) => {
|
|
info!("👋 NOTIFY byebye: {} (NT={})", usn, nt);
|
|
debug!(
|
|
"📣 NOTIFY byebye payload\n<details>\n\n```\n{}\n```\n</details>\n",
|
|
msg
|
|
);
|
|
}
|
|
Err(e) => warn!("❌ Failed to send NOTIFY byebye for {}: {}", usn, e),
|
|
}
|
|
}
|
|
|
|
/// Démarre les annonces périodiques (toutes les MAX_AGE/2 secondes)
|
|
fn start_periodic_announcements(&self, socket: Arc<UdpSocket>) {
|
|
let devices = Arc::clone(&self.devices);
|
|
let period = Duration::from_secs((MAX_AGE / 2) as u64);
|
|
|
|
std::thread::spawn(move || {
|
|
loop {
|
|
debug!("⏰ SSDP periodic announcement tick");
|
|
std::thread::sleep(period);
|
|
|
|
let devices = devices.read().unwrap();
|
|
for device in devices.values() {
|
|
for nt in device.get_notification_types() {
|
|
Self::send_alive_static(&socket, device, nt);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Version statique de send_alive pour les threads
|
|
fn send_alive_static(socket: &UdpSocket, device: &SsdpDevice, nt: &str) {
|
|
let usn = if nt.starts_with("uuid:") {
|
|
format!("{}", nt)
|
|
} else {
|
|
format!("uuid:{}::{}", device.uuid, nt)
|
|
};
|
|
|
|
let msg = format!(
|
|
"NOTIFY * HTTP/1.1\r\n\
|
|
HOST: {}:{}\r\n\
|
|
CACHE-CONTROL: max-age={}\r\n\
|
|
LOCATION: {}\r\n\
|
|
NT: {}\r\n\
|
|
NTS: ssdp:alive\r\n\
|
|
SERVER: {}\r\n\
|
|
USN: {}\r\n\
|
|
\r\n",
|
|
SSDP_MULTICAST_ADDR, SSDP_PORT, MAX_AGE, device.location, nt, device.server, usn
|
|
);
|
|
|
|
let addr: SocketAddr = format!("{}:{}", SSDP_MULTICAST_ADDR, SSDP_PORT)
|
|
.parse()
|
|
.unwrap();
|
|
|
|
match socket.send_to(msg.as_bytes(), addr) {
|
|
Ok(_) => {
|
|
info!("✅ NOTIFY alive (periodic): {} (NT={})", usn, nt);
|
|
debug!(
|
|
"📣 NOTIFY alive (periodic) payload\n<details>\n\n```\n{}\n```\n</details>\n",
|
|
msg
|
|
);
|
|
}
|
|
Err(e) => warn!("❌ Failed to send periodic NOTIFY alive for {}: {}", usn, e),
|
|
}
|
|
}
|
|
|
|
/// Démarre l'écoute des M-SEARCH
|
|
fn start_msearch_listener(&self, socket: Arc<UdpSocket>) {
|
|
let devices = Arc::clone(&self.devices);
|
|
|
|
std::thread::spawn(move || {
|
|
let mut buf = [0u8; 8192];
|
|
loop {
|
|
match socket.recv_from(&mut buf) {
|
|
Ok((n, src)) => {
|
|
let data = String::from_utf8_lossy(&buf[..n]);
|
|
if data.starts_with("M-SEARCH") {
|
|
debug!(
|
|
"🔍 M-SEARCH received from {}\n<details>\n\n```\n{}\n```\n</details>\n",
|
|
src, data
|
|
);
|
|
if let Some(st) = Self::parse_st(&data) {
|
|
let devices = devices.read().unwrap();
|
|
for device in devices.values() {
|
|
Self::handle_msearch(&socket, &src, &st, device);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
// Timeout, continuer
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
warn!("❌ SSDP read error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Parse le champ ST d'un M-SEARCH
|
|
fn parse_st(data: &str) -> Option<String> {
|
|
for line in data.lines() {
|
|
if line.to_uppercase().starts_with("ST:") {
|
|
let st = line[3..].trim().to_string();
|
|
info!("✅ M-SEARCH received with ST={}", st);
|
|
return Some(st);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Répond à un M-SEARCH
|
|
fn handle_msearch(socket: &UdpSocket, src: &SocketAddr, st: &str, device: &SsdpDevice) {
|
|
let mut nts = Vec::new();
|
|
|
|
if st == "ssdp:all" {
|
|
nts.extend(device.get_notification_types().iter().cloned());
|
|
} else if device.get_notification_types().contains(&st.to_string()) {
|
|
nts.push(st.to_string());
|
|
} else {
|
|
return; // Pas de match
|
|
}
|
|
|
|
for nt in nts {
|
|
let usn = if nt.starts_with("uuid:") {
|
|
format!("{}", nt)
|
|
} else {
|
|
format!("uuid:{}::{}", device.uuid, nt)
|
|
};
|
|
|
|
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT");
|
|
|
|
let resp = format!(
|
|
"HTTP/1.1 200 OK\r\n\
|
|
CACHE-CONTROL: max-age={}\r\n\
|
|
DATE: {}\r\n\
|
|
EXT:\r\n\
|
|
LOCATION: {}\r\n\
|
|
SERVER: {}\r\n\
|
|
ST: {}\r\n\
|
|
USN: {}\r\n\
|
|
\r\n",
|
|
MAX_AGE, date, device.location, device.server, nt, usn
|
|
);
|
|
match socket.send_to(resp.as_bytes(), src) {
|
|
Ok(_) => {
|
|
debug!(
|
|
"📡 M-SEARCH response sent to {} with ST={}\n\n### payload\n\n<details>\n\n```\n{}\n```\n</details>\n",
|
|
src, nt, resp
|
|
);
|
|
}
|
|
Err(e) => warn!("❌ Failed to send M-SEARCH response to {}: {}", src, e),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for SsdpServer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Drop for SsdpServer {
|
|
fn drop(&mut self) {
|
|
// Envoyer byebye pour tous les devices
|
|
if let Some(ref socket) = self.socket {
|
|
info!("✅ Shutting down SSDP server, sending byebye for all devices");
|
|
let devices = self.devices.read().unwrap();
|
|
for device in devices.values() {
|
|
for nt in device.get_notification_types() {
|
|
self.send_byebye(socket, device, nt);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|