1pub mod actions;
15
16use anyhow::Result;
17use std::net::SocketAddr;
18use std::sync::Arc;
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use tokio::sync::mpsc;
22use tracing::{error, warn};
23
24use crate::llm::action_helper::call_llm;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::connection::ConnectionId;
29use crate::server::HlsProtocol;
30use crate::state::app_state::AppState;
31use crate::utils::WireFailure;
32use crate::{console_error, console_trace};
33use actions::{HLS_PLAYLIST_EVENT, HLS_SEGMENT_EVENT};
34
35struct HlsResponse {
41 status: u16,
42 content_type: String,
43 body: Vec<u8>,
44 retry_after: bool,
45}
46
47impl HlsResponse {
48 fn new(status: u16, content_type: impl Into<String>, body: Vec<u8>) -> Self {
49 Self {
50 status,
51 content_type: content_type.into(),
52 body,
53 retry_after: false,
54 }
55 }
56
57 fn failure(failure: WireFailure) -> Self {
64 Self {
65 status: if failure.is_overloaded() { 503 } else { 500 },
66 content_type: "text/plain; charset=utf-8".to_string(),
67 body: failure.prefixed_text().as_bytes().to_vec(),
68 retry_after: failure.is_overloaded(),
69 }
70 }
71}
72
73const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
78
79const MAX_PATH_LEN: usize = 512;
81
82pub const MAX_REQUEST_HEAD_BYTES: usize = 64 * 1024;
90
91const LINGER_AFTER_REFUSAL: std::time::Duration = std::time::Duration::from_secs(2);
96
97const HEAD_TOO_LARGE_BODY: &[u8] = b"netget: request head too large\n";
99
100const BODY_NOT_ACCEPTED_BODY: &[u8] = b"netget: this server accepts no request body\n";
103
104const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
112
113const CONNECTION_CAP_BODY: &[u8] = b"Too many connections, try again later.\n";
115
116static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> =
128 std::sync::LazyLock::new(|| build_http_response(503, "text/plain", CONNECTION_CAP_BODY, true));
129
130const DECISION_MODEL_ANSWER: &str = "model_answer";
136const DECISION_MODEL_REJECT: &str = "model_reject";
137const DECISION_NO_ANSWER: &str = "fail_closed_no_answer";
138const DECISION_BAD_ACTION: &str = "fail_closed_bad_action";
139const DECISION_LLM_ERROR: &str = "fail_closed_llm_error";
140const DECISION_LLM_OVERLOADED: &str = "fail_closed_llm_overloaded";
141
142fn decision_for_model_status(status: u16) -> &'static str {
144 if status >= 400 {
145 DECISION_MODEL_REJECT
146 } else {
147 DECISION_MODEL_ANSWER
148 }
149}
150
151pub struct HlsServer;
152
153impl HlsServer {
154 pub async fn spawn_with_llm_actions(
157 listen_addr: SocketAddr,
158 llm_client: OllamaClient,
159 app_state: Arc<AppState>,
160 status_tx: mpsc::UnboundedSender<String>,
161 server_id: crate::state::ServerId,
162 ) -> Result<SocketAddr> {
163 let listener =
164 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
165 let local_addr = listener.local_addr()?;
166 Log::new(Some(&status_tx)).info(format!("HLS server listening on {}", local_addr));
167
168 let protocol = Arc::new(HlsProtocol::new());
169 let task_registrar = app_state.clone();
170
171 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
172 let accept_handle = tokio::spawn(async move {
173 loop {
174 match crate::server::accept_bounded::accept_bounded(
175 &listener,
176 &limiter,
177 &CONNECTION_CAP_REFUSAL,
178 "HLS",
179 Some(&status_tx),
180 )
181 .await
182 {
183 Ok((stream, remote_addr, permit)) => {
184 let connection_id =
185 ConnectionId::new(app_state.get_next_unified_id().await);
186 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
187
188 use crate::state::server::{
189 ConnectionState as ServerConnectionState, ConnectionStatus,
190 ProtocolConnectionInfo,
191 };
192 let now = crate::utils::clock::Instant::now();
193 let conn_state = ServerConnectionState {
194 id: connection_id,
195 remote_addr,
196 local_addr: local_addr_conn,
197 bytes_sent: 0,
198 bytes_received: 0,
199 packets_sent: 0,
200 packets_received: 0,
201 last_activity: now,
202 status: ConnectionStatus::Active,
203 status_changed_at: now,
204 protocol_info: ProtocolConnectionInfo::empty(),
205 };
206 app_state
207 .add_connection_to_server(server_id, conn_state)
208 .await;
209 let _ = status_tx.send("__UPDATE_UI__".to_string());
210
211 let llm = llm_client.clone();
212 let state = app_state.clone();
213 let stx = status_tx.clone();
214 let proto = protocol.clone();
215 let task_owner = app_state.clone();
217 task_owner
218 .spawn_server_task(server_id, async move {
219 let _permit = permit;
223 if let Err(e) = Self::handle_connection(
224 stream,
225 remote_addr,
226 connection_id,
227 server_id,
228 llm,
229 state,
230 stx.clone(),
231 proto,
232 )
233 .await
234 {
235 Log::new(Some(&stx))
236 .debug(format!("HLS connection ended: {}", e));
237 }
238 })
239 .await;
240 }
241 Err(e) => {
242 console_error!(status_tx, "HLS accept error: {}", e);
243 }
244 }
245 }
246 });
247
248 task_registrar
249 .register_server_task(server_id, accept_handle)
250 .await;
251 Ok(local_addr)
252 }
253
254 #[allow(clippy::too_many_arguments)]
255 async fn handle_connection(
256 stream: TcpStream,
257 remote_addr: SocketAddr,
258 connection_id: ConnectionId,
259 server_id: crate::state::ServerId,
260 llm: OllamaClient,
261 state: Arc<AppState>,
262 status_tx: mpsc::UnboundedSender<String>,
263 protocol: Arc<HlsProtocol>,
264 ) -> Result<()> {
265 let (mut read_half, mut write_half) = tokio::io::split(stream);
266 let mut buffer = Vec::new();
267 let mut chunk = [0u8; 8192];
268
269 let deadline = tokio::time::Instant::now() + HEADER_READ_TIMEOUT;
280 let (method, path) = loop {
281 let want = (MAX_REQUEST_HEAD_BYTES + 1 - buffer.len()).min(chunk.len());
282 let n =
283 match tokio::time::timeout_at(deadline, read_half.read(&mut chunk[..want])).await {
284 Ok(r) => r?,
285 Err(_) => anyhow::bail!(
286 "HLS request headers not complete within {}s",
287 HEADER_READ_TIMEOUT.as_secs()
288 ),
289 };
290 if n == 0 {
291 return Ok(());
292 }
293 buffer.extend_from_slice(&chunk[..n]);
294 match head_len(&buffer) {
295 Some(len) if len <= MAX_REQUEST_HEAD_BYTES => {
296 let head = &buffer[..len];
297 if let Some(declared) = declares_body(head) {
298 warn!(
299 "HLS {} declared a request body ({}) decision=fail_closed_body_not_accepted; \
300 answering 413 without reading it",
301 remote_addr, declared
302 );
303 refuse(
304 &mut read_half,
305 &mut write_half,
306 &mut chunk,
307 413,
308 BODY_NOT_ACCEPTED_BODY,
309 )
310 .await;
311 return Ok(());
312 }
313 if let Some(req) = parse_request_line(head) {
314 break req;
315 }
316 warn!(
318 "HLS {} sent an unparseable request line; closing",
319 remote_addr
320 );
321 refuse(&mut read_half, &mut write_half, &mut chunk, 400, b"").await;
322 return Ok(());
323 }
324 Some(_) => {
325 warn!(
326 "HLS {} request head over {} bytes decision=fail_closed_head_too_large",
327 remote_addr, MAX_REQUEST_HEAD_BYTES
328 );
329 refuse(
330 &mut read_half,
331 &mut write_half,
332 &mut chunk,
333 431,
334 HEAD_TOO_LARGE_BODY,
335 )
336 .await;
337 return Ok(());
338 }
339 None if buffer.len() > MAX_REQUEST_HEAD_BYTES => {
340 warn!(
341 "HLS {} request head over {} bytes decision=fail_closed_head_too_large",
342 remote_addr, MAX_REQUEST_HEAD_BYTES
343 );
344 refuse(
345 &mut read_half,
346 &mut write_half,
347 &mut chunk,
348 431,
349 HEAD_TOO_LARGE_BODY,
350 )
351 .await;
352 return Ok(());
353 }
354 None => {}
355 }
356 };
357 let path = crate::utils::truncate_for_log(&path, MAX_PATH_LEN);
361 console_trace!(status_tx, "HLS {} {}", method, path);
362
363 state
368 .update_connection_stats(
369 server_id,
370 connection_id,
371 Some(buffer.len() as u64),
372 None,
373 Some(1),
374 None,
375 )
376 .await;
377
378 let is_playlist = path.contains(".m3u8");
379 let base = serde_json::json!({
380 "peer_addr": remote_addr.to_string(),
381 "connection_id": connection_id.to_string(),
382 "path": path,
383 "method": method,
384 });
385 let event = if is_playlist {
386 Event {
387 event_type: &HLS_PLAYLIST_EVENT,
388 data: base,
389 }
390 } else {
391 Event {
392 event_type: &HLS_SEGMENT_EVENT,
393 data: base,
394 }
395 };
396
397 let (reply, decision): (HlsResponse, &str) = match call_llm(
398 &llm,
399 &state,
400 server_id,
401 Some(connection_id),
402 &event,
403 protocol.as_ref(),
404 )
405 .await
406 {
407 Ok(result) => {
408 let action = result.raw_actions.into_iter().next();
409 if is_playlist {
410 render_playlist(&action)
411 } else {
412 render_segment(&action)
413 }
414 }
415 Err(e) => {
416 let failure = WireFailure::classify(&e);
419 let decision = if failure.is_overloaded() {
420 DECISION_LLM_OVERLOADED
421 } else {
422 DECISION_LLM_ERROR
423 };
424 error!("HLS {} decision={} error={}", path, decision, e);
425 Log::new(Some(&status_tx)).error(format!(
426 "HLS fail-closed for {} (decision={}): {}",
427 path, decision, e
428 ));
429 (HlsResponse::failure(failure), decision)
430 }
431 };
432
433 let HlsResponse {
434 status,
435 content_type,
436 body,
437 retry_after,
438 } = reply;
439 let response = build_http_response(status, &content_type, &body, retry_after);
440 write_half.write_all(&response).await?;
441 write_half.flush().await?;
442
443 state
444 .update_connection_stats(
445 server_id,
446 connection_id,
447 None,
448 Some(response.len() as u64),
449 None,
450 Some(1),
451 )
452 .await;
453 let _ = status_tx.send(format!(
454 "→ HLS {} {} ({} bytes, decision={})",
455 status,
456 path,
457 body.len(),
458 decision
459 ));
460 Ok(())
461 }
462}
463
464fn render_playlist(action: &Option<serde_json::Value>) -> (HlsResponse, &'static str) {
470 let ct = "application/vnd.apple.mpegurl".to_string();
471 let Some(action) = action else {
472 warn!(
473 "HLS playlist decision={} (no action returned)",
474 DECISION_NO_ANSWER
475 );
476 return (
477 HlsResponse::failure(WireFailure::Unavailable),
478 DECISION_NO_ANSWER,
479 );
480 };
481 let status = action
482 .get("status_code")
483 .and_then(|v| v.as_u64())
484 .unwrap_or(200) as u16;
485
486 if let Some(playlist) = action.get("playlist").and_then(|v| v.as_str()) {
487 return (
488 HlsResponse::new(status, ct, playlist.as_bytes().to_vec()),
489 decision_for_model_status(status),
490 );
491 }
492
493 if let Some(segments) = action.get("segments").and_then(|v| v.as_array()) {
494 let version = action.get("version").and_then(|v| v.as_u64()).unwrap_or(3);
495 let target = action
496 .get("target_duration")
497 .and_then(|v| v.as_u64())
498 .unwrap_or_else(|| {
499 segments
500 .iter()
501 .filter_map(|s| s.get("duration").and_then(|d| d.as_f64()))
502 .fold(0.0_f64, f64::max)
503 .ceil() as u64
504 })
505 .max(1);
506 let media_sequence = action
507 .get("media_sequence")
508 .and_then(|v| v.as_u64())
509 .unwrap_or(0);
510 let mut m = String::new();
511 m.push_str("#EXTM3U\n");
512 m.push_str(&format!("#EXT-X-VERSION:{}\n", version));
513 m.push_str(&format!("#EXT-X-TARGETDURATION:{}\n", target));
514 m.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", media_sequence));
515 for seg in segments {
516 let dur = seg
517 .get("duration")
518 .and_then(|d| d.as_f64())
519 .unwrap_or(target as f64);
520 let uri = seg
521 .get("uri")
522 .and_then(|u| u.as_str())
523 .unwrap_or("segment.ts");
524 m.push_str(&format!("#EXTINF:{:.3},\n{}\n", dur, uri));
525 }
526 let ended = action
527 .get("ended")
528 .and_then(|v| v.as_bool())
529 .unwrap_or(true);
530 if ended {
531 m.push_str("#EXT-X-ENDLIST\n");
532 }
533 return (
534 HlsResponse::new(status, ct, m.into_bytes()),
535 decision_for_model_status(status),
536 );
537 }
538
539 warn!(
542 "HLS playlist decision={} (action lacked both 'playlist' and 'segments')",
543 DECISION_NO_ANSWER
544 );
545 (
546 HlsResponse::failure(WireFailure::Unavailable),
547 DECISION_NO_ANSWER,
548 )
549}
550
551fn render_segment(action: &Option<serde_json::Value>) -> (HlsResponse, &'static str) {
554 let Some(action) = action else {
555 warn!(
556 "HLS segment decision={} (no action returned)",
557 DECISION_NO_ANSWER
558 );
559 return (
560 HlsResponse::failure(WireFailure::Unavailable),
561 DECISION_NO_ANSWER,
562 );
563 };
564 let status = action
565 .get("status_code")
566 .and_then(|v| v.as_u64())
567 .unwrap_or(200) as u16;
568 let content_type = action
569 .get("content_type")
570 .and_then(|v| v.as_str())
571 .unwrap_or("video/mp2t")
572 .to_string();
573
574 if let Some(data) = action.get("data").and_then(|v| v.as_str()) {
576 let encoding = action
577 .get("encoding")
578 .and_then(|v| v.as_str())
579 .unwrap_or("utf8");
580 match encoding {
581 "hex" => match hex::decode(data.trim()) {
582 Ok(bytes) => {
583 return (
584 HlsResponse::new(status, content_type, bytes),
585 decision_for_model_status(status),
586 )
587 }
588 Err(e) => {
589 warn!(
592 "HLS segment decision={} (hex decode failed): {}",
593 DECISION_BAD_ACTION, e
594 );
595 return (
596 HlsResponse::failure(WireFailure::Unavailable),
597 DECISION_BAD_ACTION,
598 );
599 }
600 },
601 "utf8" => {
602 return (
603 HlsResponse::new(status, content_type, data.as_bytes().to_vec()),
604 decision_for_model_status(status),
605 )
606 }
607 other => {
608 warn!(
609 "HLS segment decision={} (unknown encoding {:?}; expected \"utf8\" or \"hex\")",
610 DECISION_BAD_ACTION, other
611 );
612 return (
613 HlsResponse::failure(WireFailure::Unavailable),
614 DECISION_BAD_ACTION,
615 );
616 }
617 }
618 }
619
620 if let Some(content) = action.get("content").and_then(|v| v.as_str()) {
621 return (
622 HlsResponse::new(status, content_type, content.as_bytes().to_vec()),
623 decision_for_model_status(status),
624 );
625 }
626
627 warn!(
631 "HLS segment decision={} (action carried neither 'data' nor 'content')",
632 DECISION_NO_ANSWER
633 );
634 (
635 HlsResponse::failure(WireFailure::Unavailable),
636 DECISION_NO_ANSWER,
637 )
638}
639
640fn build_http_response(status: u16, content_type: &str, body: &[u8], retry_after: bool) -> Vec<u8> {
643 let reason = reason_phrase(status);
644 let retry_header = if retry_after {
647 "Retry-After: 5\r\n"
648 } else {
649 ""
650 };
651 let mut resp = format!(
652 "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}Cache-Control: no-cache\r\nConnection: close\r\n\r\n",
653 status,
654 reason,
655 content_type,
656 body.len(),
657 retry_header
658 )
659 .into_bytes();
660 resp.extend_from_slice(body);
661 resp
662}
663
664fn reason_phrase(status: u16) -> &'static str {
669 match status {
670 200 => "OK",
671 204 => "No Content",
672 206 => "Partial Content",
673 301 => "Moved Permanently",
674 302 => "Found",
675 304 => "Not Modified",
676 400 => "Bad Request",
677 401 => "Unauthorized",
678 403 => "Forbidden",
679 404 => "Not Found",
680 410 => "Gone",
681 413 => "Content Too Large",
682 416 => "Range Not Satisfiable",
683 429 => "Too Many Requests",
684 431 => "Request Header Fields Too Large",
685 500 => "Internal Server Error",
686 502 => "Bad Gateway",
687 503 => "Service Unavailable",
688 504 => "Gateway Timeout",
689 100..=199 => "Informational",
690 200..=299 => "Success",
691 300..=399 => "Redirection",
692 400..=499 => "Client Error",
693 500..=599 => "Server Error",
694 _ => "Unknown",
695 }
696}
697
698fn head_len(buf: &[u8]) -> Option<usize> {
700 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
701}
702
703fn declares_body(head: &[u8]) -> Option<String> {
709 let head = String::from_utf8_lossy(head);
710 for line in head.split("\r\n").skip(1) {
711 let Some((name, value)) = line.split_once(':') else {
712 continue;
713 };
714 let name = name.trim();
715 let value = value.trim();
716 if name.eq_ignore_ascii_case("transfer-encoding") {
717 return Some(format!(
718 "Transfer-Encoding: {}",
719 crate::utils::truncate_for_log(value, 64)
720 ));
721 }
722 if name.eq_ignore_ascii_case("content-length") && value != "0" {
723 return Some(format!(
724 "Content-Length: {}",
725 crate::utils::truncate_for_log(value, 32)
726 ));
727 }
728 }
729 None
730}
731
732async fn refuse<R, W>(
738 read_half: &mut R,
739 write_half: &mut W,
740 chunk: &mut [u8],
741 status: u16,
742 body: &[u8],
743) where
744 R: tokio::io::AsyncRead + Unpin,
745 W: tokio::io::AsyncWrite + Unpin,
746{
747 let response = build_http_response(status, "text/plain; charset=utf-8", body, false);
748 if write_half.write_all(&response).await.is_err() {
749 return;
750 }
751 let _ = write_half.flush().await;
752 let _ = write_half.shutdown().await;
753 let deadline = tokio::time::Instant::now() + LINGER_AFTER_REFUSAL;
754 while let Ok(Ok(n)) = tokio::time::timeout_at(deadline, read_half.read(chunk)).await {
755 if n == 0 {
756 break;
757 }
758 }
759}
760
761fn parse_request_line(buf: &[u8]) -> Option<(String, String)> {
764 let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
771 let head = std::str::from_utf8(&buf[..header_end]).ok()?;
772 let first = head.lines().next()?;
773 let mut parts = first.split_whitespace();
774 let method = parts.next()?.to_string();
775 let path = parts.next()?.to_string();
776 Some((method, path))
777}