use std::fmt::Write as _;
pub const SSDP_GROUP_V4: std::net::Ipv4Addr = std::net::Ipv4Addr::new(239, 255, 255, 250);
pub const SSDP_GROUP_V6: std::net::Ipv6Addr =
std::net::Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0xc);
pub const SSDP_PORT: u16 = 1900;
pub const MAX_MX_SECONDS: u32 = 5;
pub const MAX_MESSAGE_LEN: usize = 8192;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpuMessage {
pub start_line: String,
pub method: Option<String>,
pub headers: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Empty,
TooLong(usize),
NotUtf8,
MalformedHeader(String),
MalformedStartLine(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Empty => write!(f, "datagram is empty"),
ParseError::TooLong(n) => {
write!(
f,
"datagram is {n} bytes, more than the {MAX_MESSAGE_LEN} accepted"
)
}
ParseError::NotUtf8 => {
write!(f, "datagram is not valid UTF-8; SSDP is a text protocol")
}
ParseError::MalformedHeader(line) => {
write!(f, "header line has no ':' separator: {line:?}")
}
ParseError::MalformedStartLine(line) => write!(f, "unusable start line: {line:?}"),
}
}
}
impl std::error::Error for ParseError {}
impl HttpuMessage {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn headers_json(&self) -> serde_json::Value {
let mut map = serde_json::Map::new();
for (k, v) in &self.headers {
map.entry(k.clone())
.or_insert_with(|| serde_json::Value::String(v.clone()));
}
serde_json::Value::Object(map)
}
pub fn mx(&self) -> Option<u32> {
self.header("MX")
.and_then(|v| v.trim().parse::<u32>().ok())
.map(|v| v.min(MAX_MX_SECONDS))
}
}
pub fn parse(data: &[u8]) -> Result<HttpuMessage, ParseError> {
if data.len() > MAX_MESSAGE_LEN {
return Err(ParseError::TooLong(data.len()));
}
let text = std::str::from_utf8(data).map_err(|_| ParseError::NotUtf8)?;
let mut lines = text.split('\n').map(|l| l.trim_end_matches('\r'));
let start_line = loop {
match lines.next() {
Some(l) if l.trim().is_empty() => continue,
Some(l) => break l.trim().to_string(),
None => return Err(ParseError::Empty),
}
};
let method = classify_start_line(&start_line)?;
let mut headers = Vec::new();
for line in lines {
if line.trim().is_empty() {
break;
}
let (name, value) = line
.split_once(':')
.ok_or_else(|| ParseError::MalformedHeader(line.to_string()))?;
let name = name.trim();
if name.is_empty() {
return Err(ParseError::MalformedHeader(line.to_string()));
}
headers.push((name.to_ascii_uppercase(), value.trim().to_string()));
}
Ok(HttpuMessage {
start_line,
method,
headers,
})
}
fn classify_start_line(line: &str) -> Result<Option<String>, ParseError> {
if line.starts_with("HTTP/") {
return Ok(None);
}
let mut parts = line.split_whitespace();
let method = parts
.next()
.ok_or_else(|| ParseError::MalformedStartLine(line.to_string()))?
.to_ascii_uppercase();
let target = parts.next();
let version = parts.next();
match (target, version) {
(Some(_), Some(v)) if v.starts_with("HTTP/") => Ok(Some(method)),
_ => Err(ParseError::MalformedStartLine(line.to_string())),
}
}
pub fn validate_header_piece(kind: &str, name: &str, s: &str) -> anyhow::Result<()> {
if s.contains('\r') || s.contains('\n') {
return Err(anyhow::anyhow!(
"SSDP header {kind} for '{name}' contains a carriage return or newline, which \
would split the message into two. Remove it."
));
}
Ok(())
}
pub fn http_date(now: chrono::DateTime<chrono::Utc>) -> String {
now.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
#[derive(Debug, Clone)]
pub struct SearchResponse {
pub st: String,
pub usn: String,
pub location: String,
pub server: String,
pub max_age: u32,
pub date: String,
pub extra_headers: Vec<(String, String)>,
}
pub fn render_search_response(r: &SearchResponse) -> String {
let mut out = String::with_capacity(256);
out.push_str("HTTP/1.1 200 OK\r\n");
let _ = writeln!(out, "CACHE-CONTROL: max-age={}\r", r.max_age);
let _ = writeln!(out, "DATE: {}\r", r.date);
out.push_str("EXT:\r\n");
let _ = writeln!(out, "LOCATION: {}\r", r.location);
let _ = writeln!(out, "SERVER: {}\r", r.server);
let _ = writeln!(out, "ST: {}\r", r.st);
let _ = writeln!(out, "USN: {}\r", r.usn);
for (k, v) in &r.extra_headers {
let _ = writeln!(out, "{k}: {v}\r");
}
out.push_str("\r\n");
out
}
#[derive(Debug, Clone)]
pub struct NotifyMessage {
pub host: String,
pub nt: String,
pub nts: String,
pub usn: String,
pub location: Option<String>,
pub server: Option<String>,
pub max_age: Option<u32>,
}
pub fn render_notify(n: &NotifyMessage) -> String {
let mut out = String::with_capacity(256);
out.push_str("NOTIFY * HTTP/1.1\r\n");
let _ = writeln!(out, "HOST: {}\r", n.host);
if let Some(max_age) = n.max_age {
let _ = writeln!(out, "CACHE-CONTROL: max-age={max_age}\r");
}
if let Some(location) = &n.location {
let _ = writeln!(out, "LOCATION: {location}\r");
}
let _ = writeln!(out, "NT: {}\r", n.nt);
let _ = writeln!(out, "NTS: {}\r", n.nts);
if let Some(server) = &n.server {
let _ = writeln!(out, "SERVER: {server}\r");
}
let _ = writeln!(out, "USN: {}\r", n.usn);
out.push_str("\r\n");
out
}
pub fn response_delay_bound_ms(mx: Option<u32>, cap_ms: u64) -> u64 {
let mx_ms = u64::from(mx.unwrap_or(0).min(MAX_MX_SECONDS)) * 1000;
mx_ms.min(cap_ms)
}
pub fn response_delay_ms(mx: Option<u32>, cap_ms: u64) -> u64 {
use rand::Rng as _;
let bound = response_delay_bound_ms(mx, cap_ms);
if bound == 0 {
return 0;
}
rand::thread_rng().gen_range(0..=bound)
}