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 DASHBOARD_HTML: &str = include_str!("dashboard.html");
9
10const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
11const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
12const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
13const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
14const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
15const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
16const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
17const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
18const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
19const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
20const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
21const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
22const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
23const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
24const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
25const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
26 include_str!("static/components/cockpit-compression.js");
27const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
28const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
29const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
30
31pub mod routes;
32
33pub async fn start(port: Option<u16>, host: Option<String>) {
34 let port = port.unwrap_or_else(|| {
35 std::env::var("LEAN_CTX_PORT")
36 .ok()
37 .and_then(|p| p.parse().ok())
38 .unwrap_or(DEFAULT_PORT)
39 });
40
41 let host = host.unwrap_or_else(|| {
42 std::env::var("LEAN_CTX_HOST")
43 .ok()
44 .unwrap_or_else(|| DEFAULT_HOST.to_string())
45 });
46
47 let addr = format!("{host}:{port}");
48 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
49
50 if is_local && dashboard_responding(&host, port) {
53 println!("\n lean-ctx dashboard already running → http://{host}:{port}");
54 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
55 if let Some(t) = load_saved_token() {
56 open_browser(&format!("http://localhost:{port}/?token={t}"));
57 } else {
58 open_browser(&format!("http://localhost:{port}"));
59 }
60 return;
61 }
62
63 let t = generate_token();
66 save_token(&t);
67 let token = Some(Arc::new(t));
68
69 if let Some(t) = token.as_ref() {
70 if is_local {
71 println!(" Auth: enabled (local)");
72 println!(" Browser URL: http://localhost:{port}/?token={t}");
73 } else {
74 eprintln!(
75 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
76 Bearer token: \x1b[1;32m{t}\x1b[0m\n \
77 Browser URL: http://<your-ip>:{port}/?token={t}"
78 );
79 }
80 }
81
82 let listener = match TcpListener::bind(&addr).await {
83 Ok(l) => l,
84 Err(e) => {
85 eprintln!("Failed to bind to {addr}: {e}");
86 std::process::exit(1);
87 }
88 };
89
90 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
91 |_| "~/.lean-ctx/stats.json".to_string(),
92 |d| d.join("stats.json").display().to_string(),
93 );
94
95 if host == "0.0.0.0" {
96 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
97 println!(" Local access: http://localhost:{port}");
98 } else {
99 println!("\n lean-ctx dashboard → http://{host}:{port}");
100 }
101 println!(" Stats file: {stats_path}");
102 println!(" Press Ctrl+C to stop\n");
103
104 if is_local {
105 if let Some(t) = token.as_ref() {
106 open_browser(&format!("http://localhost:{port}/?token={t}"));
107 } else {
108 open_browser(&format!("http://localhost:{port}"));
109 }
110 }
111 if crate::shell::is_container() && is_local {
112 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
113 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
114 println!(" docker run ... -p {port}:{port} ...");
115 println!();
116 }
117
118 loop {
119 if let Ok((stream, _)) = listener.accept().await {
120 let token_ref = token.clone();
121 tokio::spawn(handle_request(stream, token_ref));
122 }
123 }
124}
125
126fn generate_token() -> String {
127 let mut bytes = [0u8; 32];
128 getrandom::fill(&mut bytes).expect("CSPRNG unavailable — cannot generate secure token");
129 format!("lctx_{}", hex_lower(&bytes))
130}
131
132fn save_token(token: &str) {
133 if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
134 let _ = std::fs::create_dir_all(&dir);
135 let path = dir.join("dashboard.token");
136 #[cfg(unix)]
137 {
138 use std::io::Write;
139 use std::os::unix::fs::OpenOptionsExt;
140 let Ok(mut f) = std::fs::OpenOptions::new()
141 .write(true)
142 .create(true)
143 .truncate(true)
144 .mode(0o600)
145 .open(&path)
146 else {
147 return;
148 };
149 let _ = f.write_all(token.as_bytes());
150 }
151 #[cfg(not(unix))]
152 {
153 let _ = std::fs::write(&path, token);
154 }
155 }
156}
157
158fn load_saved_token() -> Option<String> {
159 let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
160 let path = dir.join("dashboard.token");
161 std::fs::read_to_string(path)
162 .ok()
163 .map(|s| s.trim().to_string())
164}
165
166fn hex_lower(bytes: &[u8]) -> String {
167 const HEX: &[u8; 16] = b"0123456789abcdef";
168 let mut out = String::with_capacity(bytes.len() * 2);
169 for &b in bytes {
170 out.push(HEX[(b >> 4) as usize] as char);
171 out.push(HEX[(b & 0x0f) as usize] as char);
172 }
173 out
174}
175
176fn open_browser(url: &str) {
177 #[cfg(target_os = "macos")]
178 {
179 let _ = std::process::Command::new("open").arg(url).spawn();
180 }
181
182 #[cfg(target_os = "linux")]
183 {
184 let _ = std::process::Command::new("xdg-open")
185 .arg(url)
186 .stderr(std::process::Stdio::null())
187 .spawn();
188 }
189
190 #[cfg(target_os = "windows")]
191 {
192 let _ = std::process::Command::new("cmd")
193 .args(["/C", "start", url])
194 .spawn();
195 }
196}
197
198fn dashboard_responding(host: &str, port: u16) -> bool {
199 use std::io::{Read, Write};
200 use std::net::TcpStream;
201 use std::time::Duration;
202
203 let addr = format!("{host}:{port}");
204 let Ok(mut s) = TcpStream::connect_timeout(
205 &addr
206 .parse()
207 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
208 Duration::from_millis(150),
209 ) else {
210 return false;
211 };
212 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
213 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
214
215 let auth_header = load_saved_token()
216 .map(|t| format!("Authorization: Bearer {t}\r\n"))
217 .unwrap_or_default();
218
219 let req = format!(
220 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
221 );
222 if s.write_all(req.as_bytes()).is_err() {
223 return false;
224 }
225 let mut buf = [0u8; 256];
226 let Ok(n) = s.read(&mut buf) else {
227 return false;
228 };
229 let head = String::from_utf8_lossy(&buf[..n]);
230 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
231}
232
233const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
234
235fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
236 for line in header_section.lines() {
237 let Some((k, v)) = line.split_once(':') else {
238 continue;
239 };
240 if k.trim().eq_ignore_ascii_case(name) {
241 return Some(v.trim());
242 }
243 }
244 None
245}
246
247fn host_loopback_aliases(host: &str) -> Vec<String> {
249 let mut v = vec![host.to_string()];
250 if let Some(port) = host.strip_prefix("127.0.0.1:") {
251 v.push(format!("localhost:{port}"));
252 }
253 if let Some(port) = host.strip_prefix("localhost:") {
254 v.push(format!("127.0.0.1:{port}"));
255 }
256 if let Some(port) = host.strip_prefix("[::1]:") {
257 v.push(format!("127.0.0.1:{port}"));
258 v.push(format!("localhost:{port}"));
259 }
260 v
261}
262
263fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
264 let origin = origin.trim_end_matches('/');
265 for h in host_loopback_aliases(host) {
266 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
267 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
268 {
269 return true;
270 }
271 }
272 false
273}
274
275fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
278 let uc = method.to_ascii_uppercase();
279 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
280 return true;
281 }
282 if !path.starts_with("/api/") {
283 return true;
284 }
285 let Some(origin) = header_line_value(header_section, "Origin") else {
286 return true;
287 };
288 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
289 return true;
290 }
291 let Some(host) = header_line_value(header_section, "Host") else {
292 return false;
293 };
294 origin_matches_dashboard_host(origin, host)
295}
296
297fn find_headers_end(buf: &[u8]) -> Option<usize> {
298 buf.windows(4).position(|w| w == b"\r\n\r\n")
299}
300
301fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
302 let text = String::from_utf8_lossy(header_section);
303 for line in text.lines() {
304 let Some((k, v)) = line.split_once(':') else {
305 continue;
306 };
307 if k.trim().eq_ignore_ascii_case("content-length") {
308 return v.trim().parse::<usize>().ok();
309 }
310 }
311 Some(0)
312}
313
314async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
315 let mut buf = Vec::new();
316 let mut tmp = [0u8; 8192];
317 loop {
318 if let Some(end) = find_headers_end(&buf) {
319 let cl = parse_content_length_header(&buf[..end])?;
320 let total = end + 4 + cl;
321 if total > MAX_HTTP_MESSAGE {
322 return None;
323 }
324 if buf.len() >= total {
325 buf.truncate(total);
326 return Some(buf);
327 }
328 } else if buf.len() > 65_536 {
329 return None;
330 }
331
332 let n = stream.read(&mut tmp).await.ok()?;
333 if n == 0 {
334 return None;
335 }
336 buf.extend_from_slice(&tmp[..n]);
337 if buf.len() > MAX_HTTP_MESSAGE {
338 return None;
339 }
340 }
341}
342
343async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
344 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
345
346 let Some(buf) = read_http_message(&mut stream).await else {
347 return;
348 };
349 let Some(header_end) = find_headers_end(&buf) else {
350 return;
351 };
352 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
353 let body_start = header_end + 4;
354 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
355 return;
356 };
357 if buf.len() < body_start + content_len {
358 return;
359 }
360 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
361 .unwrap_or("")
362 .to_string();
363
364 let first = header_text.lines().next().unwrap_or("");
365 let mut parts = first.split_whitespace();
366 let method = parts.next().unwrap_or("GET").to_string();
367 let raw_path = parts.next().unwrap_or("/").to_string();
368
369 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
370 let p = &raw_path[..idx];
371 let qs = &raw_path[idx + 1..];
372 let tok = qs
373 .split('&')
374 .find_map(|pair| pair.strip_prefix("token="))
375 .map(std::string::ToString::to_string);
376 (p.to_string(), tok)
377 } else {
378 (raw_path.clone(), None)
379 };
380
381 let query_str = raw_path
382 .find('?')
383 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
384
385 let is_api = path.starts_with("/api/");
386 let requires_auth = is_api || path == "/metrics";
387
388 if let Some(ref expected) = token {
389 let has_header_auth = check_auth(&header_text, expected);
390
391 if requires_auth && !has_header_auth {
392 let body = r#"{"error":"unauthorized"}"#;
393 let response = format!(
394 "HTTP/1.1 401 Unauthorized\r\n\
395 Content-Type: application/json\r\n\
396 Content-Length: {}\r\n\
397 WWW-Authenticate: Bearer\r\n\
398 Connection: close\r\n\
399 \r\n\
400 {body}",
401 body.len()
402 );
403 let _ = stream.write_all(response.as_bytes()).await;
404 return;
405 }
406
407 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
408 let body = r#"{"error":"forbidden"}"#;
409 let response = format!(
410 "HTTP/1.1 403 Forbidden\r\n\
411 Content-Type: application/json\r\n\
412 Content-Length: {}\r\n\
413 Connection: close\r\n\
414 \r\n\
415 {body}",
416 body.len()
417 );
418 let _ = stream.write_all(response.as_bytes()).await;
419 return;
420 }
421 }
422
423 let path = path.as_str();
424 let query_str = query_str.as_str();
425 let method = method.as_str();
426
427 let compute = std::panic::catch_unwind(|| {
428 routes::route_response(
429 path,
430 query_str,
431 query_token.as_ref(),
432 token.as_ref(),
433 is_loopback,
434 method,
435 &body_str,
436 )
437 });
438 let (status, content_type, mut body) = match compute {
439 Ok(v) => v,
440 Err(_) => (
441 "500 Internal Server Error",
442 "application/json",
443 r#"{"error":"dashboard route panicked"}"#.to_string(),
444 ),
445 };
446
447 let cache_header = if content_type.starts_with("application/json") {
448 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
449 } else if content_type.starts_with("application/javascript")
450 || content_type.starts_with("text/css")
451 {
452 "Cache-Control: no-cache, must-revalidate\r\n"
453 } else {
454 ""
455 };
456
457 let nonce = {
458 let mut nb = [0u8; 16];
459 getrandom::fill(&mut nb).expect("CSPRNG unavailable — cannot generate CSP nonce");
460 hex_lower(&nb)
461 };
462 if body.contains("<script>window.__LEAN_CTX_TOKEN__") {
463 body = body.replace(
464 "<script>window.__LEAN_CTX_TOKEN__",
465 &format!("<script nonce=\"{nonce}\">window.__LEAN_CTX_TOKEN__"),
466 );
467 }
468 let security_headers = format!(
469 "X-Content-Type-Options: nosniff\r\n\
470 X-Frame-Options: DENY\r\n\
471 Referrer-Policy: no-referrer\r\n\
472 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'\r\n"
473 );
474
475 let response = format!(
476 "HTTP/1.1 {status}\r\n\
477 Content-Type: {content_type}\r\n\
478 Content-Length: {}\r\n\
479 {cache_header}\
480 {security_headers}\
481 Connection: close\r\n\
482 \r\n\
483 {body}",
484 body.len()
485 );
486
487 let _ = stream.write_all(response.as_bytes()).await;
488}
489
490fn check_auth(request: &str, expected_token: &str) -> bool {
491 for line in request.lines() {
492 let lower = line.to_lowercase();
493 if lower.starts_with("authorization:") {
494 let value = line["authorization:".len()..].trim();
495 if let Some(token) = value
496 .strip_prefix("Bearer ")
497 .or_else(|| value.strip_prefix("bearer "))
498 {
499 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
500 }
501 }
502 }
503 false
504}
505
506fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
507 if a.len() != b.len() {
508 return false;
509 }
510 bool::from(a.ct_eq(b))
511}
512
513#[cfg(test)]
514mod tests {
515 use super::routes::helpers::normalize_dashboard_demo_path;
516 use super::*;
517 use tempfile::tempdir;
518
519 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
520
521 #[test]
522 fn check_auth_with_valid_bearer() {
523 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
524 assert!(check_auth(req, "lctx_abc123"));
525 }
526
527 #[test]
528 fn check_auth_with_invalid_bearer() {
529 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
530 assert!(!check_auth(req, "lctx_abc123"));
531 }
532
533 #[test]
534 fn check_auth_missing_header() {
535 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
536 assert!(!check_auth(req, "lctx_abc123"));
537 }
538
539 #[test]
540 fn check_auth_lowercase_bearer() {
541 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
542 assert!(check_auth(req, "lctx_abc123"));
543 }
544
545 #[test]
546 fn query_token_parsing() {
547 let raw_path = "/index.html?token=lctx_abc123&other=val";
548 let idx = raw_path.find('?').unwrap();
549 let qs = &raw_path[idx + 1..];
550 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
551 assert_eq!(tok, Some("lctx_abc123"));
552 }
553
554 #[test]
555 fn api_path_detection() {
556 assert!("/api/stats".starts_with("/api/"));
557 assert!("/api/version".starts_with("/api/"));
558 assert!(!"/".starts_with("/api/"));
559 assert!(!"/index.html".starts_with("/api/"));
560 assert!(!"/favicon.ico".starts_with("/api/"));
561 }
562
563 #[test]
564 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
565 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
566 assert_eq!(
567 normalized,
568 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
569 );
570 }
571
572 #[test]
573 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
574 let input = r"C:\repo\backend\list_tables.js";
575 assert_eq!(normalize_dashboard_demo_path(input), input);
576 }
577
578 #[test]
579 fn normalize_dashboard_demo_path_preserves_unc_path() {
580 let input = r"\\server\share\backend\list_tables.js";
581 assert_eq!(normalize_dashboard_demo_path(input), input);
582 }
583
584 #[test]
585 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
586 assert_eq!(
587 normalize_dashboard_demo_path("./src/main.rs"),
588 "src/main.rs"
589 );
590 assert_eq!(
591 normalize_dashboard_demo_path(r".\src\main.rs"),
592 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
593 );
594 }
595
596 #[test]
597 fn api_profile_returns_json() {
598 let (_status, _ct, body) =
599 routes::route_response("/api/profile", "", None, None, false, "GET", "");
600 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
601 assert!(v.get("active_name").is_some(), "missing active_name");
602 assert!(
603 v.pointer("/profile/profile/name")
604 .and_then(|n| n.as_str())
605 .is_some(),
606 "missing profile.profile.name"
607 );
608 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
609 }
610
611 #[test]
612 fn api_episodes_returns_json() {
613 let (_status, _ct, body) =
614 routes::route_response("/api/episodes", "", None, None, false, "GET", "");
615 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
616 assert!(v.get("project_hash").is_some());
617 assert!(v.get("stats").is_some());
618 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
619 }
620
621 #[test]
622 fn api_procedures_returns_json() {
623 let (_status, _ct, body) =
624 routes::route_response("/api/procedures", "", None, None, false, "GET", "");
625 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
626 assert!(v.get("project_hash").is_some());
627 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
628 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
629 }
630
631 #[test]
632 fn api_compression_demo_heals_moved_file_paths() {
633 let _g = ENV_LOCK.lock().expect("env lock");
634 let td = tempdir().expect("tempdir");
635 let root = td.path();
636 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
637 std::fs::write(
638 root.join("src").join("moved").join("foo.rs"),
639 "pub fn foo() { println!(\"hi\"); }\n",
640 )
641 .expect("write foo.rs");
642
643 let root_s = root.to_string_lossy().to_string();
644 std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
645
646 let (_status, _ct, body) = routes::route_response(
647 "/api/compression-demo",
648 "path=src/foo.rs",
649 None,
650 None,
651 false,
652 "GET",
653 "",
654 );
655 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
656 assert!(v.get("error").is_none(), "unexpected error: {body}");
657 assert_eq!(
658 v.get("resolved_from").and_then(|x| x.as_str()),
659 Some("src/moved/foo.rs")
660 );
661
662 std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
663 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
664 let _ = std::fs::remove_dir_all(dir);
665 }
666 }
667}