1use std::sync::Arc;
2use tokio::io::{AsyncReadExt, AsyncWriteExt};
3use tokio::net::TcpListener;
4
5const DEFAULT_PORT: u16 = 3333;
6const DEFAULT_HOST: &str = "127.0.0.1";
7const DASHBOARD_HTML: &str = include_str!("dashboard.html");
8
9const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
10const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
11const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
12const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
13const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
14const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
15const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.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_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
27const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
28const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
29
30pub mod routes;
31
32pub async fn start(port: Option<u16>, host: Option<String>) {
33 let port = port.unwrap_or_else(|| {
34 std::env::var("LEAN_CTX_PORT")
35 .ok()
36 .and_then(|p| p.parse().ok())
37 .unwrap_or(DEFAULT_PORT)
38 });
39
40 let host = host.unwrap_or_else(|| {
41 std::env::var("LEAN_CTX_HOST")
42 .ok()
43 .unwrap_or_else(|| DEFAULT_HOST.to_string())
44 });
45
46 let addr = format!("{host}:{port}");
47 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
48
49 if is_local && dashboard_responding(&host, port) {
52 println!("\n lean-ctx dashboard already running → http://{host}:{port}");
53 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
54 let saved = load_saved_token();
55 if let Some(ref t) = saved {
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 let _ = getrandom::fill(&mut bytes);
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 let _ = std::fs::write(&path, token);
137 #[cfg(unix)]
138 {
139 use std::os::unix::fs::PermissionsExt;
140 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
141 }
142 }
143}
144
145fn load_saved_token() -> Option<String> {
146 let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
147 let path = dir.join("dashboard.token");
148 std::fs::read_to_string(path)
149 .ok()
150 .map(|s| s.trim().to_string())
151}
152
153fn hex_lower(bytes: &[u8]) -> String {
154 const HEX: &[u8; 16] = b"0123456789abcdef";
155 let mut out = String::with_capacity(bytes.len() * 2);
156 for &b in bytes {
157 out.push(HEX[(b >> 4) as usize] as char);
158 out.push(HEX[(b & 0x0f) as usize] as char);
159 }
160 out
161}
162
163fn open_browser(url: &str) {
164 #[cfg(target_os = "macos")]
165 {
166 let _ = std::process::Command::new("open").arg(url).spawn();
167 }
168
169 #[cfg(target_os = "linux")]
170 {
171 let _ = std::process::Command::new("xdg-open")
172 .arg(url)
173 .stderr(std::process::Stdio::null())
174 .spawn();
175 }
176
177 #[cfg(target_os = "windows")]
178 {
179 let _ = std::process::Command::new("cmd")
180 .args(["/C", "start", url])
181 .spawn();
182 }
183}
184
185fn dashboard_responding(host: &str, port: u16) -> bool {
186 use std::io::{Read, Write};
187 use std::net::TcpStream;
188 use std::time::Duration;
189
190 let addr = format!("{host}:{port}");
191 let Ok(mut s) = TcpStream::connect_timeout(
192 &addr
193 .parse()
194 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
195 Duration::from_millis(150),
196 ) else {
197 return false;
198 };
199 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
200 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
201
202 let req = "GET /api/version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
203 if s.write_all(req.as_bytes()).is_err() {
204 return false;
205 }
206 let mut buf = [0u8; 256];
207 let Ok(n) = s.read(&mut buf) else {
208 return false;
209 };
210 let head = String::from_utf8_lossy(&buf[..n]);
211 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
212}
213
214const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
215
216fn find_headers_end(buf: &[u8]) -> Option<usize> {
217 buf.windows(4).position(|w| w == b"\r\n\r\n")
218}
219
220fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
221 let text = String::from_utf8_lossy(header_section);
222 for line in text.lines() {
223 let Some((k, v)) = line.split_once(':') else {
224 continue;
225 };
226 if k.trim().eq_ignore_ascii_case("content-length") {
227 return v.trim().parse::<usize>().ok();
228 }
229 }
230 Some(0)
231}
232
233async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
234 let mut buf = Vec::new();
235 let mut tmp = [0u8; 8192];
236 loop {
237 if let Some(end) = find_headers_end(&buf) {
238 let cl = parse_content_length_header(&buf[..end])?;
239 let total = end + 4 + cl;
240 if total > MAX_HTTP_MESSAGE {
241 return None;
242 }
243 if buf.len() >= total {
244 buf.truncate(total);
245 return Some(buf);
246 }
247 } else if buf.len() > 65_536 {
248 return None;
249 }
250
251 let n = stream.read(&mut tmp).await.ok()?;
252 if n == 0 {
253 return None;
254 }
255 buf.extend_from_slice(&tmp[..n]);
256 if buf.len() > MAX_HTTP_MESSAGE {
257 return None;
258 }
259 }
260}
261
262async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
263 let Some(buf) = read_http_message(&mut stream).await else {
264 return;
265 };
266 let Some(header_end) = find_headers_end(&buf) else {
267 return;
268 };
269 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
270 let body_start = header_end + 4;
271 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
272 return;
273 };
274 if buf.len() < body_start + content_len {
275 return;
276 }
277 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
278 .unwrap_or("")
279 .to_string();
280
281 let first = header_text.lines().next().unwrap_or("");
282 let mut parts = first.split_whitespace();
283 let method = parts.next().unwrap_or("GET").to_string();
284 let raw_path = parts.next().unwrap_or("/").to_string();
285
286 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
287 let p = &raw_path[..idx];
288 let qs = &raw_path[idx + 1..];
289 let tok = qs
290 .split('&')
291 .find_map(|pair| pair.strip_prefix("token="))
292 .map(std::string::ToString::to_string);
293 (p.to_string(), tok)
294 } else {
295 (raw_path.clone(), None)
296 };
297
298 let query_str = raw_path
299 .find('?')
300 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
301
302 let is_api = path.starts_with("/api/");
303 let requires_auth = is_api || path == "/metrics";
304
305 if let Some(ref expected) = token {
306 let has_header_auth = check_auth(&header_text, expected);
307
308 if requires_auth && !has_header_auth {
309 let body = r#"{"error":"unauthorized"}"#;
310 let response = format!(
311 "HTTP/1.1 401 Unauthorized\r\n\
312 Content-Type: application/json\r\n\
313 Content-Length: {}\r\n\
314 WWW-Authenticate: Bearer\r\n\
315 Connection: close\r\n\
316 \r\n\
317 {body}",
318 body.len()
319 );
320 let _ = stream.write_all(response.as_bytes()).await;
321 return;
322 }
323 }
324
325 let path = path.as_str();
326 let query_str = query_str.as_str();
327 let method = method.as_str();
328
329 let compute = std::panic::catch_unwind(|| {
330 routes::route_response(
331 path,
332 query_str,
333 query_token.as_ref(),
334 token.as_ref(),
335 method,
336 &body_str,
337 )
338 });
339 let (status, content_type, body) = match compute {
340 Ok(v) => v,
341 Err(_) => (
342 "500 Internal Server Error",
343 "application/json",
344 r#"{"error":"dashboard route panicked"}"#.to_string(),
345 ),
346 };
347
348 let cache_header = if content_type.starts_with("application/json") {
349 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
350 } else {
351 ""
352 };
353
354 let security_headers = "\
355 X-Content-Type-Options: nosniff\r\n\
356 X-Frame-Options: DENY\r\n\
357 Referrer-Policy: no-referrer\r\n\
358 Content-Security-Policy: default-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com\r\n";
359
360 let response = format!(
361 "HTTP/1.1 {status}\r\n\
362 Content-Type: {content_type}\r\n\
363 Content-Length: {}\r\n\
364 {cache_header}\
365 {security_headers}\
366 Connection: close\r\n\
367 \r\n\
368 {body}",
369 body.len()
370 );
371
372 let _ = stream.write_all(response.as_bytes()).await;
373}
374
375fn check_auth(request: &str, expected_token: &str) -> bool {
376 for line in request.lines() {
377 let lower = line.to_lowercase();
378 if lower.starts_with("authorization:") {
379 let value = line["authorization:".len()..].trim();
380 if let Some(token) = value
381 .strip_prefix("Bearer ")
382 .or_else(|| value.strip_prefix("bearer "))
383 {
384 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
385 }
386 }
387 }
388 false
389}
390
391fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
392 if a.len() != b.len() {
393 return false;
394 }
395 a.iter()
396 .zip(b.iter())
397 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
398 == 0
399}
400
401#[cfg(test)]
402mod tests {
403 use super::routes::helpers::normalize_dashboard_demo_path;
404 use super::*;
405 use tempfile::tempdir;
406
407 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
408
409 #[test]
410 fn check_auth_with_valid_bearer() {
411 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
412 assert!(check_auth(req, "lctx_abc123"));
413 }
414
415 #[test]
416 fn check_auth_with_invalid_bearer() {
417 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
418 assert!(!check_auth(req, "lctx_abc123"));
419 }
420
421 #[test]
422 fn check_auth_missing_header() {
423 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
424 assert!(!check_auth(req, "lctx_abc123"));
425 }
426
427 #[test]
428 fn check_auth_lowercase_bearer() {
429 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
430 assert!(check_auth(req, "lctx_abc123"));
431 }
432
433 #[test]
434 fn query_token_parsing() {
435 let raw_path = "/index.html?token=lctx_abc123&other=val";
436 let idx = raw_path.find('?').unwrap();
437 let qs = &raw_path[idx + 1..];
438 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
439 assert_eq!(tok, Some("lctx_abc123"));
440 }
441
442 #[test]
443 fn api_path_detection() {
444 assert!("/api/stats".starts_with("/api/"));
445 assert!("/api/version".starts_with("/api/"));
446 assert!(!"/".starts_with("/api/"));
447 assert!(!"/index.html".starts_with("/api/"));
448 assert!(!"/favicon.ico".starts_with("/api/"));
449 }
450
451 #[test]
452 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
453 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
454 assert_eq!(
455 normalized,
456 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
457 );
458 }
459
460 #[test]
461 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
462 let input = r"C:\repo\backend\list_tables.js";
463 assert_eq!(normalize_dashboard_demo_path(input), input);
464 }
465
466 #[test]
467 fn normalize_dashboard_demo_path_preserves_unc_path() {
468 let input = r"\\server\share\backend\list_tables.js";
469 assert_eq!(normalize_dashboard_demo_path(input), input);
470 }
471
472 #[test]
473 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
474 assert_eq!(
475 normalize_dashboard_demo_path("./src/main.rs"),
476 "src/main.rs"
477 );
478 assert_eq!(
479 normalize_dashboard_demo_path(r".\src\main.rs"),
480 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
481 );
482 }
483
484 #[test]
485 fn api_profile_returns_json() {
486 let (_status, _ct, body) =
487 routes::route_response("/api/profile", "", None, None, "GET", "");
488 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
489 assert!(v.get("active_name").is_some(), "missing active_name");
490 assert!(
491 v.pointer("/profile/profile/name")
492 .and_then(|n| n.as_str())
493 .is_some(),
494 "missing profile.profile.name"
495 );
496 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
497 }
498
499 #[test]
500 fn api_episodes_returns_json() {
501 let (_status, _ct, body) =
502 routes::route_response("/api/episodes", "", None, None, "GET", "");
503 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
504 assert!(v.get("project_hash").is_some());
505 assert!(v.get("stats").is_some());
506 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
507 }
508
509 #[test]
510 fn api_procedures_returns_json() {
511 let (_status, _ct, body) =
512 routes::route_response("/api/procedures", "", None, None, "GET", "");
513 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
514 assert!(v.get("project_hash").is_some());
515 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
516 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
517 }
518
519 #[test]
520 fn api_compression_demo_heals_moved_file_paths() {
521 let _g = ENV_LOCK.lock().expect("env lock");
522 let td = tempdir().expect("tempdir");
523 let root = td.path();
524 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
525 std::fs::write(
526 root.join("src").join("moved").join("foo.rs"),
527 "pub fn foo() { println!(\"hi\"); }\n",
528 )
529 .expect("write foo.rs");
530
531 let root_s = root.to_string_lossy().to_string();
532 std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
533
534 let (_status, _ct, body) = routes::route_response(
535 "/api/compression-demo",
536 "path=src/foo.rs",
537 None,
538 None,
539 "GET",
540 "",
541 );
542 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
543 assert!(v.get("error").is_none(), "unexpected error: {body}");
544 assert_eq!(
545 v.get("resolved_from").and_then(|x| x.as_str()),
546 Some("src/moved/foo.rs")
547 );
548
549 std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
550 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
551 let _ = std::fs::remove_dir_all(dir);
552 }
553 }
554}