use std::sync::Arc;
use subtle::ConstantTimeEq;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const DEFAULT_PORT: u16 = 3333;
const DEFAULT_HOST: &str = "127.0.0.1";
const DASHBOARD_HTML: &str = include_str!("dashboard.html");
const COCKPIT_INDEX_HTML: &str = include_str!("static/index.html");
const COCKPIT_STYLE_CSS: &str = include_str!("static/style.css");
const COCKPIT_LIB_API_JS: &str = include_str!("static/lib/api.js");
const COCKPIT_LIB_FORMAT_JS: &str = include_str!("static/lib/format.js");
const COCKPIT_LIB_ROUTER_JS: &str = include_str!("static/lib/router.js");
const COCKPIT_LIB_CHARTS_JS: &str = include_str!("static/lib/charts.js");
const COCKPIT_LIB_SHARED_JS: &str = include_str!("static/lib/shared.js");
const COCKPIT_COMPONENT_NAV_JS: &str = include_str!("static/components/cockpit-nav.js");
const COCKPIT_COMPONENT_CONTEXT_JS: &str = include_str!("static/components/cockpit-context.js");
const COCKPIT_COMPONENT_OVERVIEW_JS: &str = include_str!("static/components/cockpit-overview.js");
const COCKPIT_COMPONENT_LIVE_JS: &str = include_str!("static/components/cockpit-live.js");
const COCKPIT_COMPONENT_KNOWLEDGE_JS: &str = include_str!("static/components/cockpit-knowledge.js");
const COCKPIT_COMPONENT_AGENTS_JS: &str = include_str!("static/components/cockpit-agents.js");
const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpit-memory.js");
const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
include_str!("static/components/cockpit-compression.js");
const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
const COCKPIT_COMPONENT_HEALTH_JS: &str = include_str!("static/components/cockpit-health.js");
const COCKPIT_COMPONENT_REMAINING_JS: &str = include_str!("static/components/cockpit-remaining.js");
pub mod routes;
pub async fn start(port: Option<u16>, host: Option<String>) {
let port = port.unwrap_or_else(|| {
std::env::var("LEAN_CTX_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT)
});
let host = host.unwrap_or_else(|| {
std::env::var("LEAN_CTX_HOST")
.ok()
.unwrap_or_else(|| DEFAULT_HOST.to_string())
});
let addr = format!("{host}:{port}");
let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
if is_local && dashboard_responding(&host, port) {
println!("\n lean-ctx dashboard already running → http://{host}:{port}");
println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
if let Some(t) = load_saved_token() {
open_browser(&format!("http://localhost:{port}/?token={t}"));
} else {
open_browser(&format!("http://localhost:{port}"));
}
return;
}
let t = generate_token();
save_token(&t);
let token = Some(Arc::new(t));
if let Some(t) = token.as_ref() {
if is_local {
println!(" Auth: enabled (local)");
println!(" Browser URL: http://localhost:{port}/?token={t}");
} else {
eprintln!(
" \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
Bearer token: \x1b[1;32m{t}\x1b[0m\n \
Browser URL: http://<your-ip>:{port}/?token={t}"
);
}
}
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("Failed to bind to {addr}: {e}");
std::process::exit(1);
}
};
let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
|_| "~/.lean-ctx/stats.json".to_string(),
|d| d.join("stats.json").display().to_string(),
);
if host == "0.0.0.0" {
println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
println!(" Local access: http://localhost:{port}");
} else {
println!("\n lean-ctx dashboard → http://{host}:{port}");
}
println!(" Stats file: {stats_path}");
println!(" Press Ctrl+C to stop\n");
if is_local {
if let Some(t) = token.as_ref() {
open_browser(&format!("http://localhost:{port}/?token={t}"));
} else {
open_browser(&format!("http://localhost:{port}"));
}
}
if crate::shell::is_container() && is_local {
println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
println!(" docker run ... -p {port}:{port} ...");
println!();
}
loop {
if let Ok((stream, _)) = listener.accept().await {
let token_ref = token.clone();
tokio::spawn(handle_request(stream, token_ref));
}
}
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("CSPRNG unavailable — cannot generate secure token");
format!("lctx_{}", hex_lower(&bytes))
}
fn save_token(token: &str) {
if let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() {
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("dashboard.token");
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let Ok(mut f) = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
else {
return;
};
let _ = f.write_all(token.as_bytes());
}
#[cfg(not(unix))]
{
let _ = std::fs::write(&path, token);
}
}
}
fn load_saved_token() -> Option<String> {
let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
let path = dir.join("dashboard.token");
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
}
pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
let mut result = String::with_capacity(html.len() + 128);
let mut remaining = html;
while let Some(pos) = remaining.find("<script") {
result.push_str(&remaining[..pos]);
let tag_start = &remaining[pos..];
let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
let tag = &tag_start[..=tag_end];
if tag.contains("src=") || tag.contains("nonce=") {
result.push_str(tag);
} else {
result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
}
remaining = &tag_start[tag_end + 1..];
}
result.push_str(remaining);
result
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn open_browser(url: &str) {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(url).spawn();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xdg-open")
.arg(url)
.stderr(std::process::Stdio::null())
.spawn();
}
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/C", "start", url])
.spawn();
}
}
fn dashboard_responding(host: &str, port: u16) -> bool {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
let addr = format!("{host}:{port}");
let Ok(mut s) = TcpStream::connect_timeout(
&addr
.parse()
.unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
Duration::from_millis(150),
) else {
return false;
};
let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
let auth_header = load_saved_token()
.map(|t| format!("Authorization: Bearer {t}\r\n"))
.unwrap_or_default();
let req = format!(
"GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
);
if s.write_all(req.as_bytes()).is_err() {
return false;
}
let mut buf = [0u8; 256];
let Ok(n) = s.read(&mut buf) else {
return false;
};
let head = String::from_utf8_lossy(&buf[..n]);
head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
}
const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
for line in header_section.lines() {
let Some((k, v)) = line.split_once(':') else {
continue;
};
if k.trim().eq_ignore_ascii_case(name) {
return Some(v.trim());
}
}
None
}
fn host_loopback_aliases(host: &str) -> Vec<String> {
let mut v = vec![host.to_string()];
if let Some(port) = host.strip_prefix("127.0.0.1:") {
v.push(format!("localhost:{port}"));
}
if let Some(port) = host.strip_prefix("localhost:") {
v.push(format!("127.0.0.1:{port}"));
}
if let Some(port) = host.strip_prefix("[::1]:") {
v.push(format!("127.0.0.1:{port}"));
v.push(format!("localhost:{port}"));
}
v
}
fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
let origin = origin.trim_end_matches('/');
for h in host_loopback_aliases(host) {
if origin.eq_ignore_ascii_case(&format!("http://{h}"))
|| origin.eq_ignore_ascii_case(&format!("https://{h}"))
{
return true;
}
}
false
}
fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
let uc = method.to_ascii_uppercase();
if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
return true;
}
if !path.starts_with("/api/") {
return true;
}
let Some(origin) = header_line_value(header_section, "Origin") else {
return true;
};
if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
return true;
}
let Some(host) = header_line_value(header_section, "Host") else {
return false;
};
origin_matches_dashboard_host(origin, host)
}
fn find_headers_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
}
fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(header_section);
for line in text.lines() {
let Some((k, v)) = line.split_once(':') else {
continue;
};
if k.trim().eq_ignore_ascii_case("content-length") {
return v.trim().parse::<usize>().ok();
}
}
Some(0)
}
async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
loop {
if let Some(end) = find_headers_end(&buf) {
let cl = parse_content_length_header(&buf[..end])?;
let total = end + 4 + cl;
if total > MAX_HTTP_MESSAGE {
return None;
}
if buf.len() >= total {
buf.truncate(total);
return Some(buf);
}
} else if buf.len() > 65_536 {
return None;
}
let n = stream.read(&mut tmp).await.ok()?;
if n == 0 {
return None;
}
buf.extend_from_slice(&tmp[..n]);
if buf.len() > MAX_HTTP_MESSAGE {
return None;
}
}
}
async fn handle_request(mut stream: tokio::net::TcpStream, token: Option<Arc<String>>) {
let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
let Some(buf) = read_http_message(&mut stream).await else {
return;
};
let Some(header_end) = find_headers_end(&buf) else {
return;
};
let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
let body_start = header_end + 4;
let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
return;
};
if buf.len() < body_start + content_len {
return;
}
let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
.unwrap_or("")
.to_string();
let first = header_text.lines().next().unwrap_or("");
let mut parts = first.split_whitespace();
let method = parts.next().unwrap_or("GET").to_string();
let raw_path = parts.next().unwrap_or("/").to_string();
let (path, query_token) = if let Some(idx) = raw_path.find('?') {
let p = &raw_path[..idx];
let qs = &raw_path[idx + 1..];
let tok = qs
.split('&')
.find_map(|pair| pair.strip_prefix("token="))
.map(std::string::ToString::to_string);
(p.to_string(), tok)
} else {
(raw_path.clone(), None)
};
let query_str = raw_path
.find('?')
.map_or(String::new(), |i| raw_path[i + 1..].to_string());
let is_api = path.starts_with("/api/");
let requires_auth = is_api || path == "/metrics";
if let Some(ref expected) = token {
let has_header_auth = check_auth(&header_text, expected);
if requires_auth && !has_header_auth {
let body = r#"{"error":"unauthorized"}"#;
let response = format!(
"HTTP/1.1 401 Unauthorized\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
WWW-Authenticate: Bearer\r\n\
Connection: close\r\n\
\r\n\
{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
return;
}
if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
let body = r#"{"error":"forbidden"}"#;
let response = format!(
"HTTP/1.1 403 Forbidden\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
return;
}
}
let path = path.as_str();
let query_str = query_str.as_str();
let method = method.as_str();
let compute = std::panic::catch_unwind(|| {
routes::route_response(
path,
query_str,
query_token.as_ref(),
token.as_ref(),
is_loopback,
method,
&body_str,
)
});
let (status, content_type, mut body) = match compute {
Ok(v) => v,
Err(_) => (
"500 Internal Server Error",
"application/json",
r#"{"error":"dashboard route panicked"}"#.to_string(),
),
};
let cache_header = if content_type.starts_with("application/json") {
"Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
} else if content_type.starts_with("application/javascript")
|| content_type.starts_with("text/css")
{
"Cache-Control: no-cache, must-revalidate\r\n"
} else {
""
};
let nonce = {
let mut nb = [0u8; 16];
getrandom::fill(&mut nb).expect("CSPRNG unavailable — cannot generate CSP nonce");
hex_lower(&nb)
};
if content_type.contains("text/html") {
body = add_nonce_to_inline_scripts(&body, &nonce);
}
let security_headers = format!(
"X-Content-Type-Options: nosniff\r\n\
X-Frame-Options: DENY\r\n\
Referrer-Policy: no-referrer\r\n\
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"
);
let response = format!(
"HTTP/1.1 {status}\r\n\
Content-Type: {content_type}\r\n\
Content-Length: {}\r\n\
{cache_header}\
{security_headers}\
Connection: close\r\n\
\r\n\
{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
}
fn check_auth(request: &str, expected_token: &str) -> bool {
for line in request.lines() {
let lower = line.to_lowercase();
if lower.starts_with("authorization:") {
let value = line["authorization:".len()..].trim();
if let Some(token) = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
{
return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
}
}
}
false
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
bool::from(a.ct_eq(b))
}
#[cfg(test)]
mod tests {
use super::routes::helpers::normalize_dashboard_demo_path;
use super::*;
use tempfile::tempdir;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn check_auth_with_valid_bearer() {
let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
assert!(check_auth(req, "lctx_abc123"));
}
#[test]
fn check_auth_with_invalid_bearer() {
let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
assert!(!check_auth(req, "lctx_abc123"));
}
#[test]
fn check_auth_missing_header() {
let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
assert!(!check_auth(req, "lctx_abc123"));
}
#[test]
fn check_auth_lowercase_bearer() {
let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
assert!(check_auth(req, "lctx_abc123"));
}
#[test]
fn query_token_parsing() {
let raw_path = "/index.html?token=lctx_abc123&other=val";
let idx = raw_path.find('?').unwrap();
let qs = &raw_path[idx + 1..];
let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
assert_eq!(tok, Some("lctx_abc123"));
}
#[test]
fn api_path_detection() {
assert!("/api/stats".starts_with("/api/"));
assert!("/api/version".starts_with("/api/"));
assert!(!"/".starts_with("/api/"));
assert!(!"/index.html".starts_with("/api/"));
assert!(!"/favicon.ico".starts_with("/api/"));
}
#[test]
fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
assert_eq!(
normalized,
format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
);
}
#[test]
fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
let input = r"C:\repo\backend\list_tables.js";
assert_eq!(normalize_dashboard_demo_path(input), input);
}
#[test]
fn normalize_dashboard_demo_path_preserves_unc_path() {
let input = r"\\server\share\backend\list_tables.js";
assert_eq!(normalize_dashboard_demo_path(input), input);
}
#[test]
fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
assert_eq!(
normalize_dashboard_demo_path("./src/main.rs"),
"src/main.rs"
);
assert_eq!(
normalize_dashboard_demo_path(r".\src\main.rs"),
format!("src{}main.rs", std::path::MAIN_SEPARATOR)
);
}
#[test]
fn api_profile_returns_json() {
let (_status, _ct, body) =
routes::route_response("/api/profile", "", None, None, false, "GET", "");
let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
assert!(v.get("active_name").is_some(), "missing active_name");
assert!(
v.pointer("/profile/profile/name")
.and_then(|n| n.as_str())
.is_some(),
"missing profile.profile.name"
);
assert!(v.get("available").and_then(|a| a.as_array()).is_some());
}
#[test]
fn api_episodes_returns_json() {
let (_status, _ct, body) =
routes::route_response("/api/episodes", "", None, None, false, "GET", "");
let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
assert!(v.get("project_hash").is_some());
assert!(v.get("stats").is_some());
assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
}
#[test]
fn api_procedures_returns_json() {
let (_status, _ct, body) =
routes::route_response("/api/procedures", "", None, None, false, "GET", "");
let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
assert!(v.get("project_hash").is_some());
assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
}
#[test]
fn api_compression_demo_heals_moved_file_paths() {
let _g = ENV_LOCK.lock().expect("env lock");
let td = tempdir().expect("tempdir");
let root = td.path();
std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
std::fs::write(
root.join("src").join("moved").join("foo.rs"),
"pub fn foo() { println!(\"hi\"); }\n",
)
.expect("write foo.rs");
let root_s = root.to_string_lossy().to_string();
std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
let (_status, _ct, body) = routes::route_response(
"/api/compression-demo",
"path=src/foo.rs",
None,
None,
false,
"GET",
"",
);
let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
assert!(v.get("error").is_none(), "unexpected error: {body}");
assert_eq!(
v.get("resolved_from").and_then(|x| x.as_str()),
Some("src/moved/foo.rs")
);
std::env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
let _ = std::fs::remove_dir_all(dir);
}
}
}