breadcast/breadcast-core/src/http_server.rs
Breadway 8c745d18e0
Some checks failed
dev release / build (push) Failing after 12s
Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
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.
2026-08-03 09:07:21 +08:00

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")
}