1use crate::{PackageOptions, PresentationMode, package};
13use anyhow::{Context, Result, bail};
14use std::collections::HashMap;
15use std::fs;
16use std::io::{Read, Write};
17use std::net::{TcpListener, TcpStream};
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20use std::thread;
21
22#[derive(Debug, Clone)]
24pub struct OriginConfig {
25 pub bind: String,
27 pub media_root: PathBuf,
29 pub cache_dir: PathBuf,
31 pub segment_duration: f64,
33 pub tls_cert: Option<PathBuf>,
35 pub tls_key: Option<PathBuf>,
37 pub basic_auth: Option<String>,
39}
40
41impl Default for OriginConfig {
42 fn default() -> Self {
43 Self {
44 bind: "127.0.0.1:8787".into(),
45 media_root: PathBuf::from("."),
46 cache_dir: PathBuf::from("/tmp/sheathe-origin"),
47 segment_duration: 6.0,
48 tls_cert: None,
49 tls_key: None,
50 basic_auth: None,
51 }
52 }
53}
54
55pub fn serve(cfg: OriginConfig) -> Result<()> {
57 fs::create_dir_all(&cfg.cache_dir)?;
58 let listener = TcpListener::bind(&cfg.bind).with_context(|| format!("bind {}", cfg.bind))?;
59 serve_listener(listener, cfg)
60}
61
62fn serve_listener(listener: TcpListener, cfg: OriginConfig) -> Result<()> {
63 let tls = match (&cfg.tls_cert, &cfg.tls_key) {
64 (None, None) => None,
65 (Some(cert), Some(key)) => Some(crate::tls::server_config(cert, key)?),
66 _ => bail!("origin TLS requires both --tls-cert and --tls-key"),
67 };
68 let scheme = if tls.is_some() { "https" } else { "http" };
69 let addr = listener.local_addr().map(|a| a.to_string()).unwrap_or_else(|_| cfg.bind.clone());
70 eprintln!("sheathe origin listening on {scheme}://{addr}/");
71 let cfg = Arc::new(cfg);
72 let lock = Arc::new(Mutex::new(()));
73 for conn in listener.incoming() {
74 let stream = conn.context("accept")?;
75 let cfg = Arc::clone(&cfg);
76 let lock = Arc::clone(&lock);
77 let tls = tls.clone();
78 thread::spawn(move || {
79 if let Err(e) = dispatch_client(stream, tls, &cfg, &lock) {
80 eprintln!("origin: {e:#}");
81 }
82 });
83 }
84 Ok(())
85}
86
87fn dispatch_client(
88 stream: TcpStream,
89 tls: Option<Arc<rustls::ServerConfig>>,
90 cfg: &OriginConfig,
91 lock: &Mutex<()>,
92) -> Result<()> {
93 match tls {
94 Some(tls) => {
95 let conn = rustls::ServerConnection::new(tls).context("TLS server")?;
96 let mut tls_stream = rustls::StreamOwned::new(conn, stream);
97 handle_client(&mut tls_stream, cfg, lock)
98 }
99 None => {
100 let mut stream = stream;
101 handle_client(&mut stream, cfg, lock)
102 }
103 }
104}
105
106fn handle_client<S: Read + Write>(
107 stream: &mut S,
108 cfg: &OriginConfig,
109 lock: &Mutex<()>,
110) -> Result<()> {
111 let mut buf = [0u8; 8192];
112 let n = stream.read(&mut buf)?;
113 let req = String::from_utf8_lossy(&buf[..n]);
114 if let Some(auth) = &cfg.basic_auth
115 && !authorized(&req, auth)
116 {
117 return respond(
118 stream,
119 401,
120 "text/plain",
121 b"unauthorized\n",
122 "WWW-Authenticate: Basic realm=\"sheathe\"\r\n",
123 );
124 }
125 let line = req.lines().next().unwrap_or("");
126 let mut parts = line.split_whitespace();
127 let method = parts.next().unwrap_or("");
128 let target = parts.next().unwrap_or("/");
129 if method != "GET" && method != "HEAD" {
130 return respond(stream, 405, "text/plain", b"method not allowed", "");
131 }
132
133 let (path, query) = target.split_once('?').unwrap_or((target, ""));
134 let q = parse_query(query);
135
136 match path {
137 "/health" | "/healthz" => respond(stream, 200, "text/plain", b"ok\n", ""),
138 "/package" => {
139 let input = q.get("input").map(String::as_str).context("missing input=")?;
140 let media = resolve_media(&cfg.media_root, input)?;
141 let format = q.get("format").map(String::as_str).unwrap_or("hls");
142 let key = cache_key(&media, format, cfg.segment_duration);
143 let out_dir = cfg.cache_dir.join(&key);
144 {
145 let _g = lock.lock().unwrap_or_else(|e| e.into_inner());
146 if !out_dir.join("master.m3u8").exists() && !out_dir.join("manifest.mpd").exists() {
147 let opts = PackageOptions {
148 out_dir: out_dir.clone(),
149 segment_duration: cfg.segment_duration,
150 dash: format == "dash" || format == "both",
151 hls: format == "hls" || format == "both",
152 presentation: PresentationMode::Vod,
153 ..PackageOptions::default()
154 };
155 package(&[media], &opts)?;
156 }
157 }
158 let body_path = if format == "dash" {
159 out_dir.join("manifest.mpd")
160 } else {
161 out_dir.join("master.m3u8")
162 };
163 let body =
164 fs::read(&body_path).with_context(|| format!("read {}", body_path.display()))?;
165 let ctype = if format == "dash" {
166 "application/dash+xml"
167 } else {
168 "application/vnd.apple.mpegurl"
169 };
170 respond(stream, 200, ctype, &body, "")
171 }
172 p if p.starts_with("/out/") => {
173 let rel = &p["/out/".len()..];
174 let path = cfg.cache_dir.join(rel);
175 let canon_cache =
177 cfg.cache_dir.canonicalize().unwrap_or_else(|_| cfg.cache_dir.clone());
178 let canon = path.canonicalize().with_context(|| format!("missing {rel}"))?;
179 if !canon.starts_with(&canon_cache) {
180 bail!("path escape");
181 }
182 let body = fs::read(&canon)?;
183 respond(stream, 200, guess_ctype(rel), &body, "")
184 }
185 _ => respond(
186 stream,
187 404,
188 "text/plain",
189 b"not found\nGET /health | /package?input=FILE&format=hls|dash | /out/<cache-rel>\n",
190 "",
191 ),
192 }
193}
194
195fn resolve_media(root: &Path, input: &str) -> Result<PathBuf> {
196 let p = Path::new(input);
197 let full = if p.is_absolute() { p.to_path_buf() } else { root.join(p) };
198 let canon = full.canonicalize().with_context(|| format!("input not found: {input}"))?;
199 let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
200 if !canon.starts_with(&root_canon) && !Path::new(input).is_absolute() {
201 bail!("input escapes media_root");
202 }
203 Ok(canon)
204}
205
206fn cache_key(media: &Path, format: &str, seg: f64) -> String {
207 use std::collections::hash_map::DefaultHasher;
208 use std::hash::{Hash, Hasher};
209 let mut h = DefaultHasher::new();
210 media.hash(&mut h);
211 format.hash(&mut h);
212 seg.to_bits().hash(&mut h);
213 format!("{:016x}", h.finish())
214}
215
216fn parse_query(q: &str) -> HashMap<String, String> {
217 let mut m = HashMap::new();
218 for pair in q.split('&').filter(|s| !s.is_empty()) {
219 if let Some((k, v)) = pair.split_once('=') {
220 m.insert(url_decode(k), url_decode(v));
221 }
222 }
223 m
224}
225
226fn url_decode(s: &str) -> String {
227 let mut out = String::new();
228 let b = s.as_bytes();
229 let mut i = 0;
230 while i < b.len() {
231 match b[i] {
232 b'+' => {
233 out.push(' ');
234 i += 1;
235 }
236 b'%' if i + 2 < b.len() => {
237 let hex = &s[i + 1..i + 3];
238 if let Ok(v) = u8::from_str_radix(hex, 16) {
239 out.push(v as char);
240 i += 3;
241 } else {
242 out.push('%');
243 i += 1;
244 }
245 }
246 c => {
247 out.push(c as char);
248 i += 1;
249 }
250 }
251 }
252 out
253}
254
255fn guess_ctype(path: &str) -> &'static str {
256 if path.ends_with(".m3u8") {
257 "application/vnd.apple.mpegurl"
258 } else if path.ends_with(".mpd") {
259 "application/dash+xml"
260 } else if path.ends_with(".mp4") || path.ends_with(".m4s") {
261 "video/mp4"
262 } else if path.ends_with(".ts") {
263 "video/mp2t"
264 } else {
265 "application/octet-stream"
266 }
267}
268
269fn authorized(req: &str, user_pass: &str) -> bool {
270 let want = b64_basic(user_pass.as_bytes());
271 req.lines().any(|line| {
272 let Some((name, value)) = line.split_once(':') else { return false };
273 if !name.eq_ignore_ascii_case("authorization") {
274 return false;
275 }
276 let value = value.trim();
277 value
278 .strip_prefix("Basic ")
279 .or_else(|| value.strip_prefix("basic "))
280 .is_some_and(|b64| b64.trim() == want)
281 })
282}
283
284fn b64_basic(raw: &[u8]) -> String {
285 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
286 let mut b64 = String::new();
287 for chunk in raw.chunks(3) {
288 let x = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
289 let n = (u32::from(x[0]) << 16) | (u32::from(x[1]) << 8) | u32::from(x[2]);
290 b64.push(A[(n >> 18 & 0x3f) as usize] as char);
291 b64.push(A[(n >> 12 & 0x3f) as usize] as char);
292 b64.push(if chunk.len() > 1 { A[(n >> 6 & 0x3f) as usize] as char } else { '=' });
293 b64.push(if chunk.len() > 2 { A[(n & 0x3f) as usize] as char } else { '=' });
294 }
295 b64
296}
297
298fn respond<S: Write>(
299 stream: &mut S,
300 status: u16,
301 ctype: &str,
302 body: &[u8],
303 extra_headers: &str,
304) -> Result<()> {
305 let reason = match status {
306 200 => "OK",
307 401 => "Unauthorized",
308 404 => "Not Found",
309 405 => "Method Not Allowed",
310 _ => "Error",
311 };
312 let header = format!(
313 "HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\n{extra_headers}\r\n",
314 body.len()
315 );
316 stream.write_all(header.as_bytes())?;
317 stream.write_all(body)?;
318 Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use std::net::SocketAddr;
325 use std::time::Duration;
326
327 fn spawn_origin(cfg: OriginConfig) -> SocketAddr {
328 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
329 let addr = listener.local_addr().unwrap();
330 thread::spawn(move || {
331 let _ = serve_listener(listener, cfg);
332 });
333 addr
335 }
336
337 fn http_exchange(addr: SocketAddr, req: &str) -> String {
338 let mut s = TcpStream::connect(addr).unwrap();
339 s.set_read_timeout(Some(Duration::from_secs(2))).ok();
340 s.set_write_timeout(Some(Duration::from_secs(2))).ok();
341 s.write_all(req.as_bytes()).unwrap();
342 let mut buf = Vec::new();
343 s.read_to_end(&mut buf).ok();
344 String::from_utf8_lossy(&buf).into_owned()
345 }
346
347 #[test]
348 fn health_over_plain_http() {
349 let cfg = OriginConfig { bind: "127.0.0.1:0".into(), ..OriginConfig::default() };
350 let addr = spawn_origin(cfg);
351 let resp = http_exchange(
352 addr,
353 "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
354 );
355 assert!(resp.contains("HTTP/1.1 200"), "{resp}");
356 assert!(resp.contains("ok"), "{resp}");
357 }
358
359 #[test]
360 fn basic_auth_rejects_and_accepts() {
361 let cfg = OriginConfig {
362 bind: "127.0.0.1:0".into(),
363 basic_auth: Some("alice:secret".into()),
364 ..OriginConfig::default()
365 };
366 let addr = spawn_origin(cfg);
367 let denied = http_exchange(
368 addr,
369 "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n",
370 );
371 assert!(denied.contains("401"), "{denied}");
372 assert!(denied.contains("WWW-Authenticate: Basic"), "{denied}");
373
374 let cred = b64_basic(b"alice:secret");
375 let ok = http_exchange(
376 addr,
377 &format!(
378 "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Basic {cred}\r\nConnection: close\r\n\r\n"
379 ),
380 );
381 assert!(ok.contains("HTTP/1.1 200"), "{ok}");
382 }
383
384 #[test]
385 fn tls_requires_both_cert_and_key() {
386 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
387 let cfg = OriginConfig {
388 tls_cert: Some(PathBuf::from("/tmp/sheathe-missing.pem")),
389 ..OriginConfig::default()
390 };
391 let err = serve_listener(listener, cfg).unwrap_err();
392 assert!(err.to_string().contains("both"), "{err:#}");
393 }
394
395 #[test]
396 fn health_over_https() {
397 let ck = rcgen::generate_simple_self_signed(["localhost".into()]).unwrap();
398 let dir = std::env::temp_dir().join(format!("sheathe-origin-tls-{}", std::process::id()));
399 fs::create_dir_all(&dir).unwrap();
400 let cert_path = dir.join("cert.pem");
401 let key_path = dir.join("key.pem");
402 fs::write(&cert_path, ck.cert.pem()).unwrap();
403 fs::write(&key_path, ck.key_pair.serialize_pem()).unwrap();
404
405 let cfg = OriginConfig {
406 bind: "127.0.0.1:0".into(),
407 tls_cert: Some(cert_path.clone()),
408 tls_key: Some(key_path),
409 ..OriginConfig::default()
410 };
411 let addr = spawn_origin(cfg);
412
413 let mut roots = rustls::RootCertStore::empty();
414 let pem = fs::read(&cert_path).unwrap();
415 for cert in rustls_pemfile::certs(&mut pem.as_slice()).flatten() {
416 roots.add(cert).unwrap();
417 }
418 let config =
419 rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth();
420 let tcp = TcpStream::connect(addr).unwrap();
421 tcp.set_read_timeout(Some(Duration::from_secs(2))).ok();
422 let name = rustls::pki_types::ServerName::try_from("localhost").unwrap();
423 let conn = rustls::ClientConnection::new(Arc::new(config), name).unwrap();
424 let mut tls = rustls::StreamOwned::new(conn, tcp);
425 tls.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
426 .unwrap();
427 let mut buf = Vec::new();
428 tls.read_to_end(&mut buf).ok();
429 let resp = String::from_utf8_lossy(&buf);
430 assert!(resp.contains("HTTP/1.1 200"), "{resp}");
431 assert!(resp.contains("ok"), "{resp}");
432 }
433}