1use std::io::{BufRead, BufReader, Write};
40use std::os::unix::fs::FileTypeExt;
41use std::os::unix::net::UnixListener;
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::sync::{Arc, Mutex};
44use std::time::Duration;
45
46use serde_json::json;
47use tiny_http::{Header, Response, Server};
48
49use crate::stats::WebMetrics;
50
51const PORT: u16 = 50005;
52const UNIX_SOCKET_PATH: &str = "/tmp/scx_mlfq.sock";
53const POLL_INTERVAL: Duration = Duration::from_millis(200);
54
55const RUNTIME_SYSTEM_DIR: &str = "/run/systemd/system";
60
61const RUNTIME_DROPIN_DIR: &str = "/run/systemd/system/scx_loader.service.d";
66
67const RUNTIME_DROPIN: &str = "/run/systemd/system/scx_loader.service.d/mlfq-webui.conf";
69
70const EPERM: i32 = 1;
77const EAFNOSUPPORT: i32 = 97;
78const EACCES: i32 = 13;
79
80static UNBLOCK_WRITTEN: AtomicBool = AtomicBool::new(false);
86
87struct WebState {
91 metrics: WebMetrics,
92}
93
94fn unix_handle_client(
99 mut stream: std::os::unix::net::UnixStream,
100 state: &Arc<Mutex<WebState>>,
101 html: &str,
102) {
103 let clone = match stream.try_clone() {
104 Ok(c) => c,
105 Err(_) => return,
106 };
107 let mut reader = BufReader::new(clone);
108 let mut request_line = String::new();
109 if reader.read_line(&mut request_line).is_err() {
110 return;
111 }
112
113 let parts: Vec<&str> = request_line.split_whitespace().collect();
114 if parts.len() < 2 {
115 return;
116 }
117 let path = parts[1];
118
119 let metrics = {
120 let st = match state.lock() {
121 Ok(s) => s,
122 Err(_) => return,
123 };
124 st.metrics.clone()
125 };
126
127 let (body, content_type) = match path {
128 "/" => (html.as_bytes().to_vec(), "text/html; charset=utf-8"),
129 "/api/stats" => {
130 let stats = serde_json::to_value(&metrics.stats).unwrap_or_default();
131 let per_cpu = serde_json::to_value(&metrics.per_cpu).unwrap_or_default();
132 let merged = json!({
133 "stats": stats,
134 "per_cpu": per_cpu,
135 "queue_runnable": metrics.queue_runnable,
136 "llc_runnable": metrics.llc_runnable,
137 "gpu_submit_total": metrics.gpu_submit_total,
138 "gpu_trace_mask": metrics.gpu_trace_mask,
139 });
140 let j = serde_json::to_string(&merged).unwrap_or_else(|_| "{}".into());
141 (j.into_bytes(), "application/json")
142 }
143 _ => {
144 let _ = write!(
145 stream,
146 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"
147 );
148 return;
149 }
150 };
151 let len = body.len();
152 let _ = write!(
153 stream,
154 "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
155 content_type, len
156 );
157 let _ = stream.write_all(&body);
158 let _ = stream.flush();
159}
160
161fn runtime_dropin_content() -> String {
168 "# scx_mlfq Web UI: runtime unblock of the loader network sandbox.\n\
169 # Owned by the scx_mlfq scheduler; removed on exit.\n\
170 # This run stays on the unix socket; the unblock serves the next start.\n\
171 [Service]\n\
172 RestrictAddressFamilies=\n\
173 SocketBindDeny=\n"
174 .to_string()
175}
176
177fn dropin_matches(content: &str) -> bool {
181 content == runtime_dropin_content()
182}
183
184fn sandbox_failure(err: &std::io::Error) -> bool {
190 matches!(
191 err.raw_os_error(),
192 Some(EPERM) | Some(EAFNOSUPPORT) | Some(EACCES)
193 )
194}
195
196fn boxed_io_error<'a>(
205 err: &'a (dyn std::error::Error + Send + Sync + 'static),
206) -> Option<&'a std::io::Error> {
207 let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(err);
208 while let Some(e) = cur {
209 if let Some(ioe) = e.downcast_ref::<std::io::Error>() {
210 if ioe.raw_os_error().is_some() {
211 return Some(ioe);
212 }
213 }
214 cur = e.source();
215 }
216 None
217}
218
219fn try_unblock_loader_sandbox() -> bool {
230 if !std::path::Path::new(RUNTIME_SYSTEM_DIR).is_dir() {
236 log::warn!(
237 "Web UI: {} is not a directory; runtime loader-sandbox unblock skipped",
238 RUNTIME_SYSTEM_DIR
239 );
240 return false;
241 }
242 if let Err(e) = std::fs::create_dir_all(RUNTIME_DROPIN_DIR) {
243 log::warn!(
244 "Web UI: cannot create {}: {e}; runtime loader-sandbox unblock skipped (are we root?)",
245 RUNTIME_DROPIN_DIR
246 );
247 return false;
248 }
249
250 let content = runtime_dropin_content();
255 let tmp = format!("{RUNTIME_DROPIN}.tmp.{}", std::process::id());
256 if let Err(e) = std::fs::write(&tmp, &content) {
257 log::warn!(
258 "Web UI: cannot write {}: {e}; runtime loader-sandbox unblock skipped",
259 tmp
260 );
261 let _ = std::fs::remove_file(&tmp);
262 return false;
263 }
264 if let Err(e) =
265 std::fs::set_permissions(&tmp, std::os::unix::fs::PermissionsExt::from_mode(0o644))
266 {
267 log::warn!(
268 "Web UI: cannot chmod {}: {e}; runtime loader-sandbox unblock skipped",
269 tmp
270 );
271 let _ = std::fs::remove_file(&tmp);
272 return false;
273 }
274 if let Err(e) = std::fs::rename(&tmp, RUNTIME_DROPIN) {
275 log::warn!(
276 "Web UI: cannot install {}: {e}; runtime loader-sandbox unblock skipped",
277 RUNTIME_DROPIN
278 );
279 let _ = std::fs::remove_file(&tmp);
280 return false;
281 }
282
283 match std::process::Command::new("/usr/bin/systemctl")
289 .arg("daemon-reload")
290 .status()
291 {
292 Ok(st) if st.success() => {}
293 Ok(st) => log::warn!(
294 "Web UI: systemctl daemon-reload exited with {st}; the runtime unblock applies at the next daemon-reload or boot"
295 ),
296 Err(e) => log::warn!(
297 "Web UI: cannot run systemctl daemon-reload: {e}; the runtime unblock applies at the next daemon-reload or boot"
298 ),
299 }
300
301 log::warn!(
302 "Web UI: wrote {} — the loader network sandbox is lifted for the NEXT scheduler start; this run stays on the unix socket because the seccomp filter cannot be lifted in-place",
303 RUNTIME_DROPIN
304 );
305 true
306}
307
308pub fn restore_loader_sandbox() {
317 if !UNBLOCK_WRITTEN.load(Ordering::SeqCst) {
318 return;
319 }
320
321 let content = match std::fs::read_to_string(RUNTIME_DROPIN) {
322 Ok(c) => c,
323 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
324 log::info!(
325 "Web UI: {} is already absent; the loader network restriction is already restored",
326 RUNTIME_DROPIN
327 );
328 return;
329 }
330 Err(e) => {
331 log::warn!(
332 "Web UI: cannot read {}: {e}; leaving it in place",
333 RUNTIME_DROPIN
334 );
335 return;
336 }
337 };
338 if !dropin_matches(&content) {
339 log::warn!(
340 "Web UI: {} differs from the file this run wrote; leaving the foreign edit untouched",
341 RUNTIME_DROPIN
342 );
343 return;
344 }
345 if let Err(e) = std::fs::remove_file(RUNTIME_DROPIN) {
346 log::warn!(
347 "Web UI: cannot remove {}: {e}; the runtime unblock stays in effect",
348 RUNTIME_DROPIN
349 );
350 return;
351 }
352
353 match std::process::Command::new("/usr/bin/systemctl")
356 .arg("daemon-reload")
357 .status()
358 {
359 Ok(st) if st.success() => {}
360 Ok(st) => log::warn!(
361 "Web UI: systemctl daemon-reload exited with {st}; the restore applies at the next daemon-reload or boot"
362 ),
363 Err(e) => log::warn!(
364 "Web UI: cannot run systemctl daemon-reload: {e}; the restore applies at the next daemon-reload or boot"
365 ),
366 }
367
368 log::info!(
369 "Web UI: removed {} — the loader network restriction is restored for the next scheduler start",
370 RUNTIME_DROPIN
371 );
372 UNBLOCK_WRITTEN.store(false, Ordering::SeqCst);
373}
374
375pub fn start(metrics_rx: crossbeam::channel::Receiver<WebMetrics>, shutdown: Arc<AtomicBool>) {
378 log::info!("Web UI thread started");
379
380 let html = include_str!("../ui/index.html").to_string();
381 let state = Arc::new(Mutex::new(WebState {
382 metrics: WebMetrics::default(),
383 }));
384
385 let state_clone = state.clone();
389 let shutdown_clone = shutdown.clone();
390 std::thread::spawn(move || {
391 while !shutdown_clone.load(Ordering::Relaxed) {
392 match metrics_rx.recv_timeout(POLL_INTERVAL) {
393 Ok(m) => {
394 if let Ok(mut st) = state_clone.lock() {
395 st.metrics = m;
396 }
397 }
398 Err(crossbeam::channel::RecvTimeoutError::Timeout) => {}
399 Err(_) => break,
400 }
401 }
402 });
403
404 let html_for_unix = html.to_owned();
405 let mut server: Option<tiny_http::Server> = None;
406 let mut tcp_addr = String::new();
407
408 let mut bind_err: Option<Box<dyn std::error::Error + Send + Sync + 'static>> = None;
412
413 match Server::http(format!("[::1]:{}", PORT)) {
414 Ok(s) => {
415 tcp_addr = format!("[::1]:{}", PORT);
416 server = Some(s);
417 }
418 Err(e) => bind_err = Some(e),
419 }
420
421 if server.is_none() {
422 match Server::http(format!("127.0.0.1:{}", PORT)) {
423 Ok(s) => {
424 tcp_addr = format!("127.0.0.1:{}", PORT);
425 server = Some(s);
426 }
427 Err(e) => bind_err = Some(e),
428 }
429 }
430
431 if let Some(server) = server {
432 log::info!(
433 "Web UI listening on http://{}/ — disable with --no-webui",
434 tcp_addr
435 );
436
437 let no_cache = Header::from_bytes("Cache-Control", "no-store").unwrap();
438 let html_type = Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap();
439 let json_type = Header::from_bytes("Content-Type", "application/json").unwrap();
440
441 while !shutdown.load(Ordering::Relaxed) {
442 if let Ok(Some(request)) = server.recv_timeout(Duration::from_millis(200)) {
443 let metrics = {
444 let st = match state.lock() {
445 Ok(s) => s,
446 Err(_) => continue,
447 };
448 st.metrics.clone()
449 };
450 match request.url() {
451 "/" => {
452 let resp = Response::from_string(&html)
453 .with_header(html_type.clone())
454 .with_header(no_cache.clone());
455 let _ = request.respond(resp);
456 }
457 "/api/stats" => {
458 let stats = serde_json::to_value(&metrics.stats).unwrap_or_default();
459 let per_cpu = serde_json::to_value(&metrics.per_cpu).unwrap_or_default();
460 let merged = json!({
461 "stats": stats,
462 "per_cpu": per_cpu,
463 "queue_runnable": metrics.queue_runnable,
464 "llc_runnable": metrics.llc_runnable,
465 "gpu_submit_total": metrics.gpu_submit_total,
466 "gpu_trace_mask": metrics.gpu_trace_mask,
467 });
468 let json = serde_json::to_string(&merged).unwrap_or_else(|_| "{}".into());
469 let resp = Response::from_string(json)
470 .with_header(json_type.clone())
471 .with_header(no_cache.clone());
472 let _ = request.respond(resp);
473 }
474 _ => {
475 let _ = request.respond(Response::empty(404));
476 }
477 }
478 }
479 }
480 } else {
481 let sandboxed = bind_err
491 .as_deref()
492 .and_then(boxed_io_error)
493 .is_some_and(sandbox_failure);
494 if sandboxed && try_unblock_loader_sandbox() {
495 UNBLOCK_WRITTEN.store(true, Ordering::SeqCst);
496 }
497
498 log::warn!(
499 "Web UI: TCP blocked (spawned by scx_loader?), falling back to {}",
500 UNIX_SOCKET_PATH
501 );
502 if let Ok(meta) = std::fs::symlink_metadata(UNIX_SOCKET_PATH) {
505 if meta.file_type().is_socket() {
506 let _ = std::fs::remove_file(UNIX_SOCKET_PATH);
507 }
508 }
509
510 let listener = match UnixListener::bind(UNIX_SOCKET_PATH) {
511 Ok(l) => l,
512 Err(e) => {
513 log::warn!("Web UI: Unix socket bind failed: {}", e);
514 log::warn!("Web UI disabled. Use --no-webui to silence.");
515 return;
516 }
517 };
518
519 if let Err(e) = std::fs::set_permissions(
523 UNIX_SOCKET_PATH,
524 std::os::unix::fs::PermissionsExt::from_mode(0o600),
525 ) {
526 log::warn!("Web UI: failed to set the unix socket mode to 0600: {e}");
527 }
528
529 log::info!(
530 "Web UI listening on unix:{} (mode 0600, root-only) — access via: sudo socat TCP-LISTEN:{} UNIX-CONNECT:{}",
531 UNIX_SOCKET_PATH,
532 PORT,
533 UNIX_SOCKET_PATH
534 );
535
536 if let Err(e) = listener.set_nonblocking(true) {
537 log::error!("Web UI: failed to set the unix socket nonblocking: {e}");
542 log::warn!("Web UI disabled. Use --no-webui to silence.");
543 return;
544 }
545 while !shutdown.load(Ordering::Relaxed) {
546 match listener.accept() {
547 Ok((stream, _)) => {
548 if let Err(e) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
554 log::warn!("Web UI: failed to set the unix-socket read timeout: {e}");
555 }
556 let state = state.clone();
557 let html = html_for_unix.clone();
558 std::thread::spawn(move || unix_handle_client(stream, &state, &html));
559 }
560 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
561 std::thread::sleep(Duration::from_millis(100));
562 }
563 Err(e) => {
564 log::warn!("Web UI: unix-socket accept failed: {e}");
569 std::thread::sleep(Duration::from_millis(100));
570 }
571 }
572 }
573 }
574
575 log::info!("Web UI stopped");
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn runtime_dropin_content_bytes() {
584 let content = runtime_dropin_content();
585
586 assert!(content.contains("Owned by the scx_mlfq scheduler"));
589 assert!(content.contains("removed on exit"));
590
591 assert!(content.contains("[Service]\n"));
595 assert!(content.contains("[Service]\nRestrictAddressFamilies=\nSocketBindDeny=\n"));
596
597 assert!(content.ends_with('\n'));
599 }
600
601 #[test]
602 fn sandbox_failure_classification_table() {
603 assert!(sandbox_failure(&std::io::Error::from_raw_os_error(EPERM)));
606 assert!(sandbox_failure(&std::io::Error::from_raw_os_error(
607 EAFNOSUPPORT
608 )));
609 assert!(sandbox_failure(&std::io::Error::from_raw_os_error(EACCES)));
610
611 assert!(!sandbox_failure(&std::io::Error::from_raw_os_error(98)));
613
614 assert!(!sandbox_failure(&std::io::Error::from_raw_os_error(110)));
617 assert!(!sandbox_failure(&std::io::Error::new(
618 std::io::ErrorKind::Other,
619 "no errno"
620 )));
621 }
622
623 #[test]
624 fn dropin_matches_is_byte_exact() {
625 assert!(dropin_matches(&runtime_dropin_content()));
626
627 assert!(!dropin_matches(""));
631 assert!(!dropin_matches(&runtime_dropin_content().replace(
632 "RestrictAddressFamilies=",
633 "RestrictAddressFamilies=AF_UNIX"
634 )));
635 let foreign_marker = runtime_dropin_content().replace("removed on exit", "edited by admin");
636 assert!(!dropin_matches(&foreign_marker));
637 }
638
639 #[test]
640 fn boxed_io_error_walks_source_chain() {
641 let direct: Box<dyn std::error::Error + Send + Sync + 'static> =
644 Box::new(std::io::Error::from_raw_os_error(EPERM));
645 let ioe = boxed_io_error(direct.as_ref()).expect("the direct io::Error is recovered");
646 assert!(sandbox_failure(ioe));
647
648 #[derive(Debug)]
652 struct Wrapper(Box<dyn std::error::Error + Send + Sync>);
653 impl std::fmt::Display for Wrapper {
654 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655 write!(f, "wraps: {}", self.0)
656 }
657 }
658 impl std::error::Error for Wrapper {
659 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
660 Some(self.0.as_ref())
661 }
662 }
663 let wrapped: Box<dyn std::error::Error + Send + Sync + 'static> = Box::new(Wrapper(
664 Box::new(std::io::Error::from_raw_os_error(EAFNOSUPPORT)),
665 ));
666 let ioe = boxed_io_error(wrapped.as_ref()).expect("the wrapped io::Error is recovered");
667 assert!(sandbox_failure(ioe));
668
669 let no_errno: Box<dyn std::error::Error + Send + Sync + 'static> =
672 Box::new(std::io::Error::new(std::io::ErrorKind::Other, "no errno"));
673 assert!(boxed_io_error(no_errno.as_ref()).is_none());
674
675 #[derive(Debug)]
678 struct PlainErr;
679 impl std::fmt::Display for PlainErr {
680 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
681 write!(f, "plain error")
682 }
683 }
684 impl std::error::Error for PlainErr {}
685 let unrelated: Box<dyn std::error::Error + Send + Sync + 'static> = Box::new(PlainErr);
686 assert!(boxed_io_error(unrelated.as_ref()).is_none());
687 }
688}