1use std::sync::Arc;
2use subtle::ConstantTimeEq;
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::net::TcpListener;
5
6const DEFAULT_PORT: u16 = 3333;
7const DEFAULT_HOST: &str = "127.0.0.1";
8const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
9const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
10const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
11const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
12const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
13const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
14const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
15const COCKPIT_LIB_DOCTOR_JS: &str = include_str!("static/lib/doctor.js");
16const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
17const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
18const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
19const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
20const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
21const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
22const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
23const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
24const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
25 include_str!("static/components/cockpit-compression.js");
26const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
27const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
28const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
29 include_str!("static/components/cockpit-architecture.js");
30const COCKPIT_COMPONENT_EXPLORER_JS: &str = include_str!("static/components/cockpit-explorer.js");
31const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
32const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
33const COCKPIT_COMPONENT_COMMANDER_JS: &str = include_str!("static/components/cockpit-commander.js");
34const COCKPIT_COMPONENT_PALETTE_JS: &str = include_str!("static/components/cockpit-palette.js");
35const COCKPIT_COMPONENT_ROI_JS: &str = include_str!("static/components/cockpit-roi.js");
36const COCKPIT_COMPONENT_REPLAY_JS: &str = include_str!("static/components/cockpit-replay.js");
37const COCKPIT_COMPONENT_LEADERBOARD_JS: &str =
38 include_str!("static/components/cockpit-leaderboard.js");
39const COCKPIT_COMPONENT_AREA_TABS_JS: &str = include_str!("static/components/cockpit-area-tabs.js");
40const COCKPIT_COMPONENT_PROTECTION_JS: &str =
41 include_str!("static/components/cockpit-protection.js");
42const COCKPIT_COMPONENT_SETTINGS_JS: &str = include_str!("static/components/cockpit-settings.js");
43const COCKPIT_COMPONENT_TELEMETRY_JS: &str = include_str!("static/components/cockpit-telemetry.js");
44
45const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
48const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
49const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
50const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
51
52const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
56const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
57const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
58
59fn match_font_asset(path: &str) -> Option<&'static [u8]> {
61 match path {
62 "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
63 "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
64 "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
65 _ => None,
66 }
67}
68
69pub mod base_path;
70pub mod routes;
71pub(crate) mod vscode_open;
72
73pub async fn start(
74 port: Option<u16>,
75 host: Option<String>,
76 base_path: Option<String>,
77 auth_token: Option<String>,
78 open_mode: Option<String>,
79 auth_enabled: Option<bool>,
80) {
81 crate::core::gain::live_pricing::spawn_background_refresh();
84
85 let open = resolve_open_mode(open_mode.as_deref());
87 let port = port.unwrap_or_else(|| {
88 std::env::var("LEAN_CTX_PORT")
89 .ok()
90 .and_then(|p| p.parse().ok())
91 .unwrap_or(DEFAULT_PORT)
92 });
93
94 let host = host.unwrap_or_else(|| {
95 std::env::var("LEAN_CTX_HOST")
96 .ok()
97 .unwrap_or_else(|| DEFAULT_HOST.to_string())
98 });
99
100 let base_path = Arc::new(
103 base_path
104 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
105 .map(|b| base_path::normalize(&b))
106 .unwrap_or_default(),
107 );
108
109 let addr = format!("{host}:{port}");
110 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
111
112 let auth_required = resolve_auth_enabled(auth_enabled);
119
120 let allowed_hosts = Arc::new(build_allowed_hosts(&host, port));
123
124 let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
128
129 if is_local && dashboard_responding(&host, port) {
132 println!("\n lean-ctx dashboard already running → http://{host}:{port}{base_path}");
133 if let Some(req) = requested_token.as_deref()
134 && load_saved_token().as_deref() != Some(req)
135 {
136 eprintln!(
137 " \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
138 will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
139 );
140 }
141 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
142 if let Some(t) = load_saved_token() {
143 open_dashboard_url(
144 &format!("http://localhost:{port}{base_path}/?token={t}"),
145 open,
146 );
147 } else {
148 open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
149 }
150 return;
151 }
152
153 let token = if auth_required {
158 let t = requested_token.unwrap_or_else(generate_token);
159 Some(Arc::new(t))
160 } else {
161 if requested_token.is_some() {
162 eprintln!(
163 " \x1b[33m⚠\x1b[0m Ignoring the pinned token ({token_src}) — auth is disabled."
164 );
165 }
166 None
167 };
168
169 let listener = match TcpListener::bind(&addr).await {
176 Ok(l) => l,
177 Err(e) => {
178 eprintln!("Failed to bind to {addr}: {e}");
179 std::process::exit(1);
180 }
181 };
182
183 if let Some(t) = token.as_ref() {
184 save_token(t);
185 let masked = if t.len() > 12 {
186 format!(
187 "{}…{}",
188 &t[..t.floor_char_boundary(8)],
189 &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
190 )
191 } else {
192 t.to_string()
193 };
194 let src = if token_src.is_empty() {
195 String::new()
196 } else {
197 format!(" (from {token_src})")
198 };
199 if is_local {
200 println!(" Auth: enabled (local){src}");
201 println!(" Browser URL: http://localhost:{port}{base_path}/?token={t}");
202 } else {
203 eprintln!(
204 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
205 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n \
206 Browser URL: http://<your-ip>:{port}{base_path}/?token={t}"
207 );
208 }
209 } else if is_local {
210 println!(
212 " Auth: \x1b[1;33mDISABLED\x1b[0m (no-auth) — CSRF protected via Sec-Fetch-Site/Origin/Host"
213 );
214 println!(" Browser URL: http://localhost:{port}{base_path}/");
215 } else {
216 eprintln!(
220 " \x1b[33m⚠\x1b[0m Auth \x1b[1;31mDISABLED\x1b[0m and binding to {host} (not loopback).\n \
221 Browser cross-origin/CSRF stays blocked (Sec-Fetch-Site/Origin/Host),\n \
222 but ANY non-browser client that can reach {host}:{port} has full access.\n \
223 Docker: publish only to the host loopback → -p 127.0.0.1:{port}:{port}\n \
224 Add reachable hostnames via LEAN_CTX_DASHBOARD_ALLOWED_HOSTS=host:port,…\n \
225 Browser URL: http://<your-ip>:{port}{base_path}/"
226 );
227 }
228
229 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
230 |_| "~/.lean-ctx/stats.json".to_string(),
231 |d| d.join("stats.json").display().to_string(),
232 );
233
234 if host == "0.0.0.0" {
235 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
236 println!(" Local access: http://localhost:{port}");
237 } else {
238 println!("\n lean-ctx dashboard → http://{host}:{port}");
239 }
240 println!(" Stats file: {stats_path}");
241 println!(" Press Ctrl+C to stop");
242 println!(
243 " \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
244 );
245
246 if is_local {
247 if let Some(t) = token.as_ref() {
248 open_dashboard_url(
249 &format!("http://localhost:{port}{base_path}/?token={t}"),
250 open,
251 );
252 } else {
253 open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
254 }
255 }
256 if crate::shell::is_container() && is_local {
257 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
258 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
259 println!(" docker run ... -p {port}:{port} ...");
260 println!();
261 }
262
263 if crate::core::datadog_push::spawn_if_enabled() {
264 println!(
265 " Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
266 );
267 }
268
269 loop {
270 if let Ok((stream, _)) = listener.accept().await {
271 let token_ref = token.clone();
272 let base_ref = base_path.clone();
273 let allowed_ref = allowed_hosts.clone();
274 tokio::spawn(handle_request(stream, token_ref, base_ref, allowed_ref));
275 }
276 }
277}
278
279const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
281const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
284const DASHBOARD_AUTH_ENV: &str = "LEAN_CTX_DASHBOARD_AUTH";
286const ALLOWED_HOSTS_ENV: &str = "LEAN_CTX_DASHBOARD_ALLOWED_HOSTS";
290
291fn parse_human_bool(s: &str) -> Option<bool> {
293 match s.trim().to_ascii_lowercase().as_str() {
294 "true" | "1" | "yes" | "on" => Some(true),
295 "false" | "0" | "no" | "off" => Some(false),
296 _ => None,
297 }
298}
299
300fn resolve_auth_enabled(flag: Option<bool>) -> bool {
304 if let Some(v) = flag {
305 return v;
306 }
307 if let Ok(raw) = std::env::var(DASHBOARD_AUTH_ENV)
308 && let Some(v) = parse_human_bool(&raw)
309 {
310 return v;
311 }
312 crate::core::config::Config::load().dashboard_auth
313}
314
315fn build_allowed_hosts(host: &str, port: u16) -> Vec<String> {
322 let mut allowed: Vec<String> = Vec::new();
323 let mut push = |h: String| {
324 if !h.is_empty() && !allowed.iter().any(|e| e.eq_ignore_ascii_case(&h)) {
325 allowed.push(h);
326 }
327 };
328 for base in ["127.0.0.1", "localhost", "[::1]", "::1"] {
329 push(base.to_string());
330 push(format!("{base}:{port}"));
331 }
332 if host != "0.0.0.0" && host != "::" {
333 push(host.to_string());
334 push(format!("{host}:{port}"));
335 }
336 if let Ok(raw) = std::env::var(ALLOWED_HOSTS_ENV) {
337 for entry in raw.split(',') {
338 push(entry.trim().to_string());
339 }
340 }
341 allowed
342}
343
344fn host_allowed(host: &str, allowed: &[String]) -> bool {
346 allowed.iter().any(|a| a.eq_ignore_ascii_case(host))
347}
348
349fn host_is_loopback(host: &str) -> bool {
363 let hostname = if let Some(rest) = host.strip_prefix('[') {
366 match rest.split_once(']') {
367 Some((inner, _)) => inner,
368 None => return false,
369 }
370 } else if host.matches(':').count() == 1 {
371 host.rsplit_once(':').map_or(host, |(h, _)| h)
372 } else {
373 host
375 };
376 if hostname.eq_ignore_ascii_case("localhost") {
377 return true;
378 }
379 if let Ok(v4) = hostname.parse::<std::net::Ipv4Addr>() {
380 return v4.is_loopback();
381 }
382 if let Ok(v6) = hostname.parse::<std::net::Ipv6Addr>() {
383 return v6.is_loopback();
384 }
385 false
386}
387
388fn no_auth_request_ok(header_section: &str, allowed_hosts: &[String]) -> bool {
399 if let Some(sfs) = header_line_value(header_section, "Sec-Fetch-Site") {
400 let sfs = sfs.trim();
401 if !sfs.is_empty()
402 && !sfs.eq_ignore_ascii_case("same-origin")
403 && !sfs.eq_ignore_ascii_case("none")
404 {
405 return false;
406 }
407 }
408 let Some(host) = header_line_value(header_section, "Host") else {
409 return false;
410 };
411 if !host_allowed(host, allowed_hosts) && !host_is_loopback(host) {
417 return false;
418 }
419 if let Some(origin) = header_line_value(header_section, "Origin")
420 && !origin.is_empty()
421 && !origin.eq_ignore_ascii_case("null")
422 && !origin_matches_dashboard_host(origin, host)
423 {
424 return false;
425 }
426 true
427}
428
429fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
442 if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
443 return (Some(t.to_string()), "--auth-token");
444 }
445 if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
446 let trimmed = raw.trim();
447 if !trimmed.is_empty() {
448 return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
449 }
450 }
451 (None, "")
452}
453
454fn generate_token() -> String {
455 let mut bytes = [0u8; 32];
456 if getrandom::fill(&mut bytes).is_err() {
457 tracing::warn!("CSPRNG unavailable — falling back to time-based token");
458 let ts = std::time::SystemTime::now()
459 .duration_since(std::time::UNIX_EPOCH)
460 .unwrap_or_default()
461 .as_nanos();
462 for (i, b) in bytes.iter_mut().enumerate() {
463 *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
464 }
465 }
466 format!("lctx_{}", hex_lower(&bytes))
467}
468
469fn save_token(token: &str) {
470 if let Ok(dir) = crate::core::paths::state_dir() {
471 let _ = std::fs::create_dir_all(&dir);
472 let path = dir.join("dashboard.token");
473 #[cfg(unix)]
474 {
475 use std::io::Write;
476 use std::os::unix::fs::OpenOptionsExt;
477 let Ok(mut f) = std::fs::OpenOptions::new()
478 .write(true)
479 .create(true)
480 .truncate(true)
481 .mode(0o600)
482 .open(&path)
483 else {
484 return;
485 };
486 let _ = f.write_all(token.as_bytes());
487 }
488 #[cfg(not(unix))]
489 {
490 let _ = std::fs::write(&path, token);
491 }
492 }
493}
494
495fn load_saved_token() -> Option<String> {
496 let dir = crate::core::paths::state_dir().ok()?;
497 let path = dir.join("dashboard.token");
498 std::fs::read_to_string(path)
499 .ok()
500 .map(|s| s.trim().to_string())
501}
502
503pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
506 let mut result = String::with_capacity(html.len() + 128);
507 let mut remaining = html;
508 while let Some(pos) = remaining.find("<script") {
509 result.push_str(&remaining[..pos]);
510 let tag_start = &remaining[pos..];
511 let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
512 let tag = &tag_start[..=tag_end];
513 if tag.contains("src=") || tag.contains("nonce=") {
514 result.push_str(tag);
515 } else {
516 result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
517 }
518 remaining = &tag_start[tag_end + 1..];
519 }
520 result.push_str(remaining);
521 result
522}
523
524fn hex_lower(bytes: &[u8]) -> String {
525 const HEX: &[u8; 16] = b"0123456789abcdef";
526 let mut out = String::with_capacity(bytes.len() * 2);
527 for &b in bytes {
528 out.push(HEX[(b >> 4) as usize] as char);
529 out.push(HEX[(b & 0x0f) as usize] as char);
530 }
531 out
532}
533
534#[derive(Clone, Copy, PartialEq, Eq, Debug)]
536enum DashboardOpen {
537 Browser,
539 None,
542 Vscode,
546}
547
548fn resolve_open_mode(flag: Option<&str>) -> DashboardOpen {
551 let raw = flag
552 .map(str::to_string)
553 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_OPEN").ok())
554 .unwrap_or_default();
555 match raw.trim().to_ascii_lowercase().as_str() {
556 "none" | "off" | "false" | "no" => DashboardOpen::None,
557 "vscode" | "code" | "editor" => DashboardOpen::Vscode,
558 _ => DashboardOpen::Browser,
559 }
560}
561
562fn open_dashboard_url(url: &str, mode: DashboardOpen) {
564 match mode {
565 DashboardOpen::Browser => open_browser(url),
566 DashboardOpen::None => {}
567 DashboardOpen::Vscode => {
568 println!(
573 " \x1b[2mNative tab: run ⇧⌘P → \"lean-ctx: Open Web Dashboard\" (needs the lean-ctx VS Code extension)\x1b[0m"
574 );
575 println!(
576 " \x1b[2mNo extension? ⇧⌘P → \"Simple Browser: Show\" → paste the URL above\x1b[0m"
577 );
578 }
579 }
580}
581
582fn open_browser(url: &str) {
583 #[cfg(target_os = "macos")]
584 {
585 let _ = std::process::Command::new("open").arg(url).spawn();
586 }
587
588 #[cfg(target_os = "linux")]
589 {
590 let _ = std::process::Command::new("xdg-open")
591 .arg(url)
592 .stderr(std::process::Stdio::null())
593 .spawn();
594 }
595
596 #[cfg(target_os = "windows")]
597 {
598 let _ = std::process::Command::new("cmd")
599 .args(["/C", "start", url])
600 .spawn();
601 }
602}
603
604pub(crate) fn dashboard_responding(host: &str, port: u16) -> bool {
612 use std::io::{Read, Write};
613 use std::net::TcpStream;
614 use std::time::Duration;
615
616 let addr = format!("{host}:{port}");
617 let Ok(mut s) = TcpStream::connect_timeout(
618 &addr
619 .parse()
620 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
621 Duration::from_millis(150),
622 ) else {
623 return false;
624 };
625 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
626 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
627
628 let auth_header = load_saved_token()
629 .map(|t| format!("Authorization: Bearer {t}\r\n"))
630 .unwrap_or_default();
631 let req = format!(
632 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
633 );
634 if s.write_all(req.as_bytes()).is_err() {
635 return false;
636 }
637
638 let mut resp = Vec::new();
642 let mut buf = [0u8; 1024];
643 while resp.len() < 8 * 1024 {
644 match s.read(&mut buf) {
645 Ok(0) | Err(_) => break,
646 Ok(n) => resp.extend_from_slice(&buf[..n]),
647 }
648 }
649 let resp = String::from_utf8_lossy(&resp);
650 (resp.starts_with("HTTP/1.1 200") || resp.starts_with("HTTP/1.0 200"))
651 && resp.contains(r#""current":"#)
652 && resp.contains(r#""latest":"#)
653 && resp.contains(r#""update_available":"#)
654}
655
656const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
657
658fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
659 for line in header_section.lines() {
660 let Some((k, v)) = line.split_once(':') else {
661 continue;
662 };
663 if k.trim().eq_ignore_ascii_case(name) {
664 return Some(v.trim());
665 }
666 }
667 None
668}
669
670fn host_loopback_aliases(host: &str) -> Vec<String> {
672 let mut v = vec![host.to_string()];
673 if let Some(port) = host.strip_prefix("127.0.0.1:") {
674 v.push(format!("localhost:{port}"));
675 }
676 if let Some(port) = host.strip_prefix("localhost:") {
677 v.push(format!("127.0.0.1:{port}"));
678 }
679 if let Some(port) = host.strip_prefix("[::1]:") {
680 v.push(format!("127.0.0.1:{port}"));
681 v.push(format!("localhost:{port}"));
682 }
683 v
684}
685
686fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
687 let origin = origin.trim_end_matches('/');
688 for h in host_loopback_aliases(host) {
689 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
690 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
691 {
692 return true;
693 }
694 }
695 false
696}
697
698fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
701 let uc = method.to_ascii_uppercase();
702 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
703 return true;
704 }
705 if !path.starts_with("/api/") {
706 return true;
707 }
708 let Some(origin) = header_line_value(header_section, "Origin") else {
709 return true;
710 };
711 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
712 return true;
713 }
714 let Some(host) = header_line_value(header_section, "Host") else {
715 return false;
716 };
717 origin_matches_dashboard_host(origin, host)
718}
719
720fn find_headers_end(buf: &[u8]) -> Option<usize> {
721 buf.windows(4).position(|w| w == b"\r\n\r\n")
722}
723
724fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
725 let text = String::from_utf8_lossy(header_section);
726 for line in text.lines() {
727 let Some((k, v)) = line.split_once(':') else {
728 continue;
729 };
730 if k.trim().eq_ignore_ascii_case("content-length") {
731 return v.trim().parse::<usize>().ok();
732 }
733 }
734 Some(0)
735}
736
737async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
738 let mut buf = Vec::new();
739 let mut tmp = [0u8; 8192];
740 loop {
741 if let Some(end) = find_headers_end(&buf) {
742 let cl = parse_content_length_header(&buf[..end])?;
743 let total = end + 4 + cl;
744 if total > MAX_HTTP_MESSAGE {
745 return None;
746 }
747 if buf.len() >= total {
748 buf.truncate(total);
749 return Some(buf);
750 }
751 } else if buf.len() > 65_536 {
752 return None;
753 }
754
755 let n = stream.read(&mut tmp).await.ok()?;
756 if n == 0 {
757 return None;
758 }
759 buf.extend_from_slice(&tmp[..n]);
760 if buf.len() > MAX_HTTP_MESSAGE {
761 return None;
762 }
763 }
764}
765
766async fn handle_request(
767 mut stream: tokio::net::TcpStream,
768 token: Option<Arc<String>>,
769 base_path: Arc<String>,
770 allowed_hosts: Arc<Vec<String>>,
771) {
772 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
773
774 let Some(buf) = read_http_message(&mut stream).await else {
775 return;
776 };
777 let Some(header_end) = find_headers_end(&buf) else {
778 return;
779 };
780 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
781 let body_start = header_end + 4;
782 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
783 return;
784 };
785 if buf.len() < body_start + content_len {
786 return;
787 }
788 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
789 .unwrap_or("")
790 .to_string();
791
792 let first = header_text.lines().next().unwrap_or("");
793 let mut parts = first.split_whitespace();
794 let method = parts.next().unwrap_or("GET").to_string();
795 let raw_path = parts.next().unwrap_or("/").to_string();
796
797 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
798 let p = &raw_path[..idx];
799 let qs = &raw_path[idx + 1..];
800 let tok = qs
801 .split('&')
802 .find_map(|pair| pair.strip_prefix("token="))
803 .map(std::string::ToString::to_string);
804 (p.to_string(), tok)
805 } else {
806 (raw_path.clone(), None)
807 };
808
809 let query_str = raw_path
810 .find('?')
811 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
812
813 let path = base_path::strip(&path, base_path.as_str()).to_string();
817
818 if let Some(bytes) = match_font_asset(&path) {
821 let header = format!(
822 "HTTP/1.1 200 OK\r\n\
823 Content-Type: font/woff2\r\n\
824 Content-Length: {}\r\n\
825 Cache-Control: public, max-age=31536000, immutable\r\n\
826 X-Content-Type-Options: nosniff\r\n\
827 Connection: close\r\n\
828 \r\n",
829 bytes.len()
830 );
831 let _ = stream.write_all(header.as_bytes()).await;
832 let _ = stream.write_all(bytes).await;
833 return;
834 }
835
836 let is_api = path.starts_with("/api/");
837 let requires_auth = is_api || path == "/metrics";
838
839 if let Some(ref expected) = token {
840 let mut has_header_auth = check_auth(&header_text, expected);
841
842 if !has_header_auth
847 && path == "/metrics"
848 && let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV)
849 {
850 let scrape = scrape.trim();
851 if !scrape.is_empty() && check_auth(&header_text, scrape) {
852 has_header_auth = true;
853 }
854 }
855
856 if requires_auth && !has_header_auth {
857 let body = r#"{"error":"unauthorized"}"#;
858 let response = format!(
859 "HTTP/1.1 401 Unauthorized\r\n\
860 Content-Type: application/json\r\n\
861 Content-Length: {}\r\n\
862 WWW-Authenticate: Bearer\r\n\
863 Connection: close\r\n\
864 \r\n\
865 {body}",
866 body.len()
867 );
868 let _ = stream.write_all(response.as_bytes()).await;
869 return;
870 }
871
872 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
873 let body = r#"{"error":"forbidden"}"#;
874 let response = format!(
875 "HTTP/1.1 403 Forbidden\r\n\
876 Content-Type: application/json\r\n\
877 Content-Length: {}\r\n\
878 Connection: close\r\n\
879 \r\n\
880 {body}",
881 body.len()
882 );
883 let _ = stream.write_all(response.as_bytes()).await;
884 return;
885 }
886 } else if requires_auth && !no_auth_request_ok(&header_text, &allowed_hosts) {
887 let body = r#"{"error":"forbidden"}"#;
890 let response = format!(
891 "HTTP/1.1 403 Forbidden\r\n\
892 Content-Type: application/json\r\n\
893 Content-Length: {}\r\n\
894 Connection: close\r\n\
895 \r\n\
896 {body}",
897 body.len()
898 );
899 let _ = stream.write_all(response.as_bytes()).await;
900 return;
901 }
902
903 let route_started = std::time::Instant::now();
911 let route_label = path.clone();
912 let compute = tokio::task::spawn_blocking(move || {
913 routes::route_response(
914 &path,
915 &query_str,
916 query_token.as_ref(),
917 token.as_ref(),
918 is_loopback,
919 &method,
920 &body_str,
921 )
922 })
923 .await;
924 let (status, content_type, mut body) = match compute {
925 Ok(v) => v,
926 Err(_) => (
929 "500 Internal Server Error",
930 "application/json",
931 r#"{"error":"dashboard route panicked"}"#.to_string(),
932 ),
933 };
934 let route_elapsed = route_started.elapsed();
937 if route_elapsed >= std::time::Duration::from_secs(1) {
938 tracing::warn!(
939 target: "lean_ctx::dashboard",
940 "slow dashboard route {route_label} took {} ms",
941 route_elapsed.as_millis()
942 );
943 }
944
945 if !base_path.is_empty()
948 && (content_type.contains("text/html")
949 || content_type.contains("text/css")
950 || content_type.contains("javascript"))
951 {
952 body = base_path::rewrite_asset_urls(&body, base_path.as_str());
953 }
954
955 let cache_header = if content_type.starts_with("application/json") {
956 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
957 } else if content_type.starts_with("application/javascript")
958 || content_type.starts_with("text/css")
959 {
960 "Cache-Control: no-cache, must-revalidate\r\n"
961 } else {
962 ""
963 };
964
965 let nonce = {
966 let mut nb = [0u8; 16];
967 if getrandom::fill(&mut nb).is_err() {
968 nb.iter_mut().enumerate().for_each(|(i, b)| {
969 *b = (std::time::SystemTime::now()
970 .duration_since(std::time::UNIX_EPOCH)
971 .unwrap_or_default()
972 .subsec_nanos()
973 .wrapping_add(i as u32)) as u8;
974 });
975 }
976 hex_lower(&nb)
977 };
978 if content_type.contains("text/html") {
979 body = add_nonce_to_inline_scripts(&body, &nonce);
980 }
981 let security_headers = format!(
982 "X-Content-Type-Options: nosniff\r\n\
983 X-Frame-Options: DENY\r\n\
984 Referrer-Policy: no-referrer\r\n\
985 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'\r\n"
986 );
987
988 let response = format!(
989 "HTTP/1.1 {status}\r\n\
990 Content-Type: {content_type}\r\n\
991 Content-Length: {}\r\n\
992 {cache_header}\
993 {security_headers}\
994 Connection: close\r\n\
995 \r\n\
996 {body}",
997 body.len()
998 );
999
1000 let _ = stream.write_all(response.as_bytes()).await;
1001}
1002
1003fn check_auth(request: &str, expected_token: &str) -> bool {
1004 for line in request.lines() {
1005 let lower = line.to_lowercase();
1006 if lower.starts_with("authorization:") {
1007 let value = line["authorization:".len()..].trim();
1008 if let Some(token) = value
1009 .strip_prefix("Bearer ")
1010 .or_else(|| value.strip_prefix("bearer "))
1011 {
1012 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
1013 }
1014 }
1015 }
1016 false
1017}
1018
1019fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1020 if a.len() != b.len() {
1021 return false;
1022 }
1023 bool::from(a.ct_eq(b))
1024}
1025
1026#[cfg(test)]
1027mod tests;