Some checks failed
dev release / build (push) Failing after 12s
Builds out the full v1 scope: a vendored+patched openscreen subset for low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's Idle/Casting state machine with a private IPC socket, the breadcast GTK4 popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd integration (device discovery, start/stop, mirroring lifecycle events). Also adds bakery/systemd/Forgejo CI packaging. Validated end-to-end against a real Chromecast/Google TV: negotiated Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/ stop through the actual popup.
266 lines
12 KiB
Rust
266 lines
12 KiB
Rust
use std::net::SocketAddr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
/// Number of worker threads pulling requests off the server's shared queue.
|
|
/// tiny_http's `Server::recv()` takes `&self` specifically so it can be
|
|
/// called from multiple threads concurrently (its own docs' recommended
|
|
/// pattern) — bounding this at a small fixed number, instead of spawning a
|
|
/// fresh OS thread per request, keeps the request volume any single LAN
|
|
/// host can inflict on this process capped, since this server ends up
|
|
/// reachable by every device on the LAN, not just the Cast receiver it's
|
|
/// meant for.
|
|
const WORKER_THREADS: usize = 8;
|
|
|
|
/// Serves `root` (the `hlssink3` output directory: `playlist.m3u8` +
|
|
/// `segment*.ts`) over plain HTTP. Runs a small fixed pool of worker
|
|
/// threads; dropping the handle does not stop the server (there is no clean
|
|
/// shutdown yet — matches the smoke-testing scope of the rest of Phase 2).
|
|
///
|
|
/// Every servable path is namespaced under a random token
|
|
/// (`/<token>/playlist.m3u8`, etc. — see [`HttpServer::token`]) rather than
|
|
/// served at the root. There's no way to add an `Authorization` header a
|
|
/// Cast receiver will send back, so this is the standard mitigation for a
|
|
/// server that must stay unauthenticated but shouldn't let every other
|
|
/// device on the LAN casually load `/playlist.m3u8` and watch this
|
|
/// machine's screen.
|
|
///
|
|
/// Sets `Content-Type` per Google's Cast media docs (the receiver's HTTP
|
|
/// client needs a correct type to load segments reliably), `Cache-Control`
|
|
/// (critical for `playlist.m3u8`, which is rewritten every segment — a
|
|
/// cached stale copy stalls playback with no error anywhere), Range/206
|
|
/// support, and a permissive CORS header — get this right from the start
|
|
/// since a Chromecast has no devtools console to debug a silent load
|
|
/// failure against.
|
|
pub struct HttpServer {
|
|
addr: SocketAddr,
|
|
token: String,
|
|
}
|
|
|
|
impl HttpServer {
|
|
/// Binds on `bind_addr` (use `0.0.0.0:0` to let the OS pick a free
|
|
/// port — read the actual port back via [`HttpServer::addr`]) and
|
|
/// starts serving `root` in the background. Build URLs to hand to a
|
|
/// Cast device with [`HttpServer::url`], not by hand — it includes the
|
|
/// required path token.
|
|
pub fn start(bind_addr: &str, root: PathBuf) -> Result<Self> {
|
|
let root = root
|
|
.canonicalize()
|
|
.with_context(|| format!("failed to canonicalize HLS root {}", root.display()))?;
|
|
|
|
let server = tiny_http::Server::http(bind_addr)
|
|
.map_err(|e| anyhow::anyhow!("{e}"))
|
|
.with_context(|| format!("failed to bind HTTP server on {bind_addr}"))?;
|
|
let addr = match server.server_addr() {
|
|
tiny_http::ListenAddr::IP(addr) => addr,
|
|
other => anyhow::bail!("HTTP server bound to a non-IP address: {other:?}"),
|
|
};
|
|
|
|
let server = Arc::new(server);
|
|
let token = random_token();
|
|
|
|
for _ in 0..WORKER_THREADS {
|
|
let server = Arc::clone(&server);
|
|
let root = root.clone();
|
|
let token = token.clone();
|
|
std::thread::spawn(move || {
|
|
while let Ok(request) = server.recv() {
|
|
if let Err(e) = handle_request(request, &root, &token) {
|
|
tracing::warn!(error = %e, "HLS HTTP request failed");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Ok(Self { addr, token })
|
|
}
|
|
|
|
/// The bound address, e.g. `0.0.0.0:41823`. Combine with this
|
|
/// machine's LAN IP (not `0.0.0.0` itself) to build the URL handed to
|
|
/// the Cast device — `0.0.0.0` only means anything to sockets on this
|
|
/// host.
|
|
pub fn addr(&self) -> SocketAddr {
|
|
self.addr
|
|
}
|
|
|
|
/// Builds a full URL for `relative` (e.g. `"playlist.m3u8"`), rooted at
|
|
/// `host` (this machine's LAN-reachable IP — see [`HttpServer::addr`]'s
|
|
/// doc for why that can't just be `self.addr()`), including the
|
|
/// unguessable path token every request must carry.
|
|
pub fn url(&self, host: std::net::IpAddr, relative: &str) -> String {
|
|
format!("http://{host}:{}/{}/{relative}", self.addr.port(), self.token)
|
|
}
|
|
}
|
|
|
|
/// Generates a 32-hex-character unguessable token from `/dev/urandom`. This
|
|
/// is a Linux-only project already (PipeWire, Hyprland's portal, VA-API) so
|
|
/// reaching for the platform's random device directly is fine — no `rand`
|
|
/// crate dependency for one call site.
|
|
fn random_token() -> String {
|
|
let mut bytes = [0u8; 16];
|
|
let read_ok = std::fs::File::open("/dev/urandom")
|
|
.and_then(|mut f| {
|
|
use std::io::Read;
|
|
f.read_exact(&mut bytes)
|
|
})
|
|
.is_ok();
|
|
if !read_ok {
|
|
// Unreachable in practice on Linux, but better than a zero-entropy
|
|
// token if it ever happened.
|
|
use std::hash::{Hash, Hasher};
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
std::time::SystemTime::now().hash(&mut hasher);
|
|
std::process::id().hash(&mut hasher);
|
|
bytes[..8].copy_from_slice(&hasher.finish().to_le_bytes());
|
|
}
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
}
|
|
|
|
/// Parses a single-range `Range: bytes=start-end` header value against a
|
|
/// body of `len` bytes. Returns `None` for anything absent, malformed, or
|
|
/// multi-range (multipart ranges aren't needed for HLS segment fetches, and
|
|
/// falling back to a full 200 response for those is always a valid
|
|
/// response under the HTTP spec).
|
|
fn parse_range(value: &str, len: usize) -> Option<(usize, usize)> {
|
|
let spec = value.strip_prefix("bytes=")?;
|
|
if spec.contains(',') || len == 0 {
|
|
return None;
|
|
}
|
|
let (start, end) = spec.split_once('-')?;
|
|
let last = len - 1;
|
|
match (start.trim(), end.trim()) {
|
|
("", "") => None,
|
|
("", suffix_len) => {
|
|
let n: usize = suffix_len.parse().ok()?;
|
|
Some((len.saturating_sub(n), last))
|
|
}
|
|
(start, "") => {
|
|
let start: usize = start.parse().ok()?;
|
|
Some((start, last))
|
|
}
|
|
(start, end) => {
|
|
let start: usize = start.parse().ok()?;
|
|
let end: usize = end.parse().ok()?;
|
|
Some((start, end))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_request(request: tiny_http::Request, root: &Path, token: &str) -> Result<()> {
|
|
// `Split::next()` on a non-empty pattern always yields at least one
|
|
// item, so this never actually hits a `None` case.
|
|
let url_path = request.url().split('?').next().expect("split always yields at least one item").to_string();
|
|
let remote = request
|
|
.remote_addr()
|
|
.map(|a| a.to_string())
|
|
.unwrap_or_else(|| "?".to_string());
|
|
|
|
// Reject anything not under the unguessable token prefix before even
|
|
// touching the filesystem — see the struct docs for why this exists.
|
|
let Some(relative) = url_path
|
|
.trim_start_matches('/')
|
|
.strip_prefix(token)
|
|
.and_then(|rest| rest.strip_prefix('/'))
|
|
else {
|
|
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (bad or missing path token)");
|
|
return respond_status(request, 404);
|
|
};
|
|
|
|
// Reject any path that could escape `root` (e.g. `../../etc/passwd`) —
|
|
// the URL is attacker-controlled input the moment this server is
|
|
// reachable from the LAN, which it is by design (the Cast device is a
|
|
// different host). `root` itself is canonicalized once in `start()`, so
|
|
// this comparison is meaningful even if `root` was originally relative
|
|
// or contained a symlinked component — comparing a canonical path
|
|
// against a non-canonical one would make `starts_with` spuriously fail
|
|
// and 404 every request.
|
|
let requested = root.join(relative);
|
|
let Ok(canonical) = requested.canonicalize() else {
|
|
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (not found)");
|
|
return respond_status(request, 404);
|
|
};
|
|
if !canonical.starts_with(root) || !canonical.is_file() {
|
|
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (outside root or not a file)");
|
|
return respond_status(request, 404);
|
|
}
|
|
|
|
let data = match std::fs::read(&canonical) {
|
|
Ok(data) => data,
|
|
Err(_) => {
|
|
// hlssink3 rotates out old segments (`max-files`) concurrently
|
|
// with requests for them — a file that existed at
|
|
// canonicalize() above but is gone by the time it's read is
|
|
// routine for a live stream, not a server fault. Answer with a
|
|
// normal 404 (a receiver skips it and asks for the next
|
|
// segment) instead of letting the request drop unanswered,
|
|
// which tiny_http turns into an unexplained bare 500.
|
|
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (file removed before read, likely segment rotation)");
|
|
return respond_status(request, 404);
|
|
}
|
|
};
|
|
|
|
let extension = canonical.extension().and_then(|e| e.to_str());
|
|
let content_type = match extension {
|
|
Some("m3u8") => "application/vnd.apple.mpegurl",
|
|
Some("ts") => "video/mp2t",
|
|
_ => "application/octet-stream",
|
|
};
|
|
// The playlist is rewritten in place on every segment — must never be
|
|
// cached, or a receiver/intermediary replaying a stale copy stalls
|
|
// playback with no error anywhere. Segments are written once under a
|
|
// unique numbered filename and never modified after that, so they're
|
|
// safe to cache aggressively.
|
|
let cache_control = match extension {
|
|
Some("m3u8") => "no-cache, no-store, must-revalidate",
|
|
_ => "public, max-age=3600, immutable",
|
|
};
|
|
|
|
let range = request
|
|
.headers()
|
|
.iter()
|
|
.find(|h| h.field.equiv("Range"))
|
|
.and_then(|h| parse_range(h.value.as_str(), data.len()));
|
|
|
|
let mut headers = vec![
|
|
("Content-Type".to_string(), content_type.to_string()),
|
|
("Cache-Control".to_string(), cache_control.to_string()),
|
|
("Access-Control-Allow-Origin".to_string(), "*".to_string()),
|
|
("Access-Control-Allow-Headers".to_string(), "Range, Accept-Encoding".to_string()),
|
|
("Access-Control-Expose-Headers".to_string(), "Content-Length, Content-Range".to_string()),
|
|
("Accept-Ranges".to_string(), "bytes".to_string()),
|
|
];
|
|
|
|
let (status, body) = match range {
|
|
Some((start, end)) if start <= end && end < data.len() => {
|
|
headers.push(("Content-Range".to_string(), format!("bytes {start}-{end}/{}", data.len())));
|
|
(206u16, data[start..=end].to_vec())
|
|
}
|
|
Some(_) => {
|
|
headers.push(("Content-Range".to_string(), format!("bytes */{}", data.len())));
|
|
tracing::info!(%remote, path = %url_path, status = 416, "HLS request (unsatisfiable range)");
|
|
return respond(request, 416, Vec::new(), &headers);
|
|
}
|
|
None => (200u16, data),
|
|
};
|
|
|
|
tracing::info!(%remote, path = %url_path, status, "HLS request");
|
|
respond(request, status, body, &headers)
|
|
}
|
|
|
|
fn respond(request: tiny_http::Request, status: u16, body: Vec<u8>, headers: &[(String, String)]) -> Result<()> {
|
|
let mut response = tiny_http::Response::from_data(body).with_status_code(status);
|
|
for (name, value) in headers {
|
|
if let Ok(header) = tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) {
|
|
response.add_header(header);
|
|
}
|
|
}
|
|
request.respond(response).context("failed to write HTTP response")
|
|
}
|
|
|
|
fn respond_status(request: tiny_http::Request, status: u16) -> Result<()> {
|
|
request
|
|
.respond(tiny_http::Response::empty(status))
|
|
.context("failed to write HTTP error response")
|
|
}
|