Skip to main content

lean_ctx/dashboard/
mod.rs

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
45// Vendored third-party libraries — embedded so the dashboard works fully offline
46// (no external CDN). Served as text via the standard route pipeline.
47const 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
52// Self-hosted variable fonts (binary woff2). Served via a dedicated binary
53// branch in `handle_request` so the bytes are never corrupted by the
54// String-based route pipeline.
55const 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
59/// Maps a request path to an embedded binary font asset.
60fn 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    // Live model prices (#1179): the measured-spend card prices with the
82    // cached provider list — loaded from disk, kept fresh in the background.
83    crate::core::gain::live_pricing::spawn_background_refresh();
84
85    // How to reveal the URL once the server is up: --open= flag > env > browser.
86    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    // Reverse-proxy subpath (e.g. `/dashboard`). Normalized to "" or "/prefix".
101    // Shared across connections behind an Arc; "" means "no subpath" (#355).
102    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    // Whether the dashboard requires a Bearer token. Precedence:
113    // `--no-auth`/`--auth=` flag > LEAN_CTX_DASHBOARD_AUTH env > `dashboard_auth`
114    // config > default `true`. When disabled, no token is generated and the
115    // sensitive endpoints (`/api/*`, `/metrics`) are guarded by request-header
116    // checks (Sec-Fetch-Site / Origin / Host allowlist) instead — see
117    // `no_auth_request_ok`.
118    let auth_required = resolve_auth_enabled(auth_enabled);
119
120    // Host values accepted in no-auth mode (anti-DNS-rebinding allowlist). Built
121    // once and shared with every connection.
122    let allowed_hosts = Arc::new(build_allowed_hosts(&host, port));
123
124    // Resolve any *requested* fixed token (flag > LEAN_CTX_HTTP_TOKEN) up-front;
125    // `None` means "generate a random one". Done before the already-running check
126    // so we can warn when the requested token won't match a live instance (#377).
127    let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
128
129    // Avoid accidental multiple dashboard instances (common source of "it hangs").
130    // Only safe to auto-detect for local dashboards without auth.
131    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    // Auth defaults on (even on loopback) to prevent cross-origin reads of /api/*
154    // from a malicious website (CORS is not a reliable boundary for localhost
155    // services). When explicitly disabled, run token-less: cross-origin/CSRF and
156    // DNS-rebinding attacks are blocked by `no_auth_request_ok` instead.
157    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    // Bind BEFORE persisting the token: two racing `lean-ctx dashboard` starts
170    // both used to write their fresh token, the bind loser exited — leaving a
171    // token on disk that the surviving server never accepted. Every later
172    // "already running" browser open (and any tool reading dashboard.token)
173    // then got 401s. Binding first makes the loser exit without touching the
174    // file, so dashboard.token always belongs to the live listener.
175    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        // No-auth on loopback: header-based CSRF protection is the boundary.
211        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        // No-auth + non-loopback bind (e.g. Docker --host=0.0.0.0). Browser
217        // cross-origin/CSRF is still blocked, but non-browser clients that can
218        // reach the address have unauthenticated access — warn loudly.
219        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
279/// Name of the env var that pins the dashboard Bearer token (#377).
280const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
281/// Read-only token accepted **only** for `GET /metrics` (GL #401) so
282/// monitoring agents never hold the full dashboard credential.
283const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
284/// Toggles dashboard Bearer-token auth. `false`/`0`/`no`/`off` disable it.
285const DASHBOARD_AUTH_ENV: &str = "LEAN_CTX_DASHBOARD_AUTH";
286/// Extra `Host` header values accepted in no-auth mode (CSV, e.g.
287/// `box.local:3333,10.0.0.5:3333`). Extends the loopback/bound-host allowlist
288/// for Docker/reverse-proxy setups reached via a custom hostname.
289const ALLOWED_HOSTS_ENV: &str = "LEAN_CTX_DASHBOARD_ALLOWED_HOSTS";
290
291/// Parse a human boolean (`true/false/1/0/yes/no/on/off`, case-insensitive).
292fn 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
300/// Resolve whether the dashboard requires Bearer-token auth. Precedence:
301/// `--no-auth`/`--auth=` flag > `LEAN_CTX_DASHBOARD_AUTH` env > `dashboard_auth`
302/// config > default `true`.
303fn 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
315/// Build the `Host` header allowlist for no-auth mode (anti-DNS-rebinding).
316/// Always includes the loopback aliases for `port` (localhost is the intended
317/// audience), the actual bound `host:port`, and any `LEAN_CTX_DASHBOARD_ALLOWED_HOSTS`
318/// entries. Bare-host forms (no port) are added too so non-browser clients that
319/// omit the port aren't rejected. `0.0.0.0` is never added — browsers don't send
320/// `Host: 0.0.0.0`; operators expose reachable names via the env allowlist.
321fn 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
344/// Is the request `Host` header in the allowlist (case-insensitive)?
345fn host_allowed(host: &str, allowed: &[String]) -> bool {
346    allowed.iter().any(|a| a.eq_ignore_ascii_case(host))
347}
348
349/// True when the `Host` header's hostname is a loopback literal
350/// (`localhost`, `127.0.0.0/8`, or `::1`), **regardless of port**.
351///
352/// A loopback `Host` is never a DNS-rebinding vector: the browser only sends one
353/// when the user navigated to a loopback URL directly (an attacker can't make
354/// their own hostname resolve to — and report a `Host` of — `127.0.0.1`). So in
355/// no-auth mode we accept loopback on any port, not just the bound one. This is
356/// what makes a port-remapped Docker publish work out of the box — e.g. the
357/// container binds `0.0.0.0:3333`, Docker publishes it as `-p 60000:3333`, and
358/// the host browser reaches `http://127.0.0.1:60000`, so the `Host` header is
359/// `127.0.0.1:60000` (the *published* port), which the bind-port allowlist
360/// (`127.0.0.1:3333`) would otherwise reject. Cross-origin/CSRF stays blocked by
361/// the `Sec-Fetch-Site`/`Origin` checks in `no_auth_request_ok`.
362fn host_is_loopback(host: &str) -> bool {
363    // Extract the hostname, dropping any `:port`. IPv6 literals are bracketed
364    // (`[::1]` / `[::1]:port`); a bare IPv6 (`::1`) can't carry a port.
365    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        // No colon (bare host[:no-port]) or multiple colons (unbracketed IPv6).
374        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
388/// Token-free request gate for no-auth dashboards. Applied to the same sensitive
389/// endpoints the Bearer token guards (`/api/*`, `/metrics`). Blocks browser
390/// cross-origin/CSRF and DNS-rebinding without a credential:
391///  * `Sec-Fetch-Site`, when sent, must be `same-origin` or `none` (the header is
392///    set by the browser and cannot be forged by page JS).
393///  * `Host` must be in `allowed_hosts` (missing `Host` is rejected).
394///  * `Origin`, when sent and not `null`, must be same-origin as `Host`.
395///
396/// Non-browser clients (curl, Prometheus) omit `Sec-Fetch-Site`/`Origin` and pass
397/// those checks — they only need to target an allowlisted `Host`.
398fn 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    // Accept the explicit allowlist (loopback aliases for the bound port, the
412    // bound host, and any LEAN_CTX_DASHBOARD_ALLOWED_HOSTS entries) OR any
413    // loopback host on any port. The latter covers port-remapped Docker
414    // publishes (e.g. `-p 60000:3333` reached via `127.0.0.1:60000`) without a
415    // manual allowlist entry — loopback hosts are not a rebinding vector.
416    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
429/// Resolve the dashboard Bearer token.
430///
431/// Honors `LEAN_CTX_HTTP_TOKEN` (#377): when set to a non-empty value it is used
432/// verbatim so reverse-proxy / container deployments keep a stable token across
433/// restarts and redeploys (nginx can inject a fixed `Authorization: Bearer …`).
434/// When unset or empty, a fresh random token is generated (no behavior change).
435///
436/// Resolve a *requested* fixed token with precedence `--auth-token` flag >
437/// `LEAN_CTX_HTTP_TOKEN` (#377). The flag wins so it survives container/service
438/// environments that strip or fail to inherit the env var. Returns the trimmed,
439/// non-empty token and a human label of its source; `None` means "no fixed token
440/// requested → caller generates a random one".
441fn 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
503/// Adds `nonce="..."` to all inline `<script>` tags (those without a `src=` attribute).
504/// External scripts (`<script src="...">`) are left untouched.
505pub 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/// How `lean-ctx dashboard` reveals the URL after the server is up (#424).
535#[derive(Clone, Copy, PartialEq, Eq, Debug)]
536enum DashboardOpen {
537    /// Launch the system default browser (historical default).
538    Browser,
539    /// Don't auto-launch anything — just print the URL. For users who run the
540    /// dashboard inside an editor / reverse proxy and don't want a new window.
541    None,
542    /// Suppress the external browser and print the steps to open the URL in
543    /// VS Code's built-in browser. VS Code exposes no stable CLI flag to open
544    /// its Simple/Integrated Browser, so we guide rather than fake it.
545    Vscode,
546}
547
548/// Resolve the open mode from (in precedence order) the `--open=` flag, the
549/// `LEAN_CTX_DASHBOARD_OPEN` env var, else the `browser` default.
550fn 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
562/// Reveal `url` to the user according to `mode`.
563fn open_dashboard_url(url: &str, mode: DashboardOpen) {
564    match mode {
565        DashboardOpen::Browser => open_browser(url),
566        DashboardOpen::None => {}
567        DashboardOpen::Vscode => {
568            // Prefer the extension's native webview tab (#466 item 3): with the
569            // lean-ctx VS Code extension installed, one command opens the
570            // dashboard as a real editor tab — no URL copy/paste. Keep the
571            // Simple Browser path as the no-extension fallback.
572            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
604/// Probes `http://{host}:{port}/api/version` (auth-aware) and returns true only
605/// when it answers `200` with the lean-ctx dashboard's own version JSON. Single
606/// source of truth for "is *our* dashboard already live on this port": used both
607/// when opening the browser (avoids spawning a second instance) and by `doctor`'s
608/// port check, so port 3333 held by our own dashboard reads as healthy instead of
609/// a false conflict (#644). The body check is what tells our dashboard apart from
610/// an unrelated service that merely answers 200 on the same port.
611pub(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    // Read until the peer closes (Connection: close) so the JSON body — not just
639    // the status line — is captured, bounded so a rogue peer can't stream forever.
640    // Field markers mirror `version_check::version_info_json`.
641    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
670/// Loopback dashboards often use `localhost` vs `127.0.0.1` interchangeably in `Origin`.
671fn 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
698/// Defense-in-depth for browser POSTs: reject cross-site `Origin` on mutating `/api/*` calls.
699/// Non-browser clients (no `Origin`) remain allowed when Bearer auth succeeds.
700fn 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    // Strip the reverse-proxy subpath prefix (if any) so all downstream matching
814    // (fonts, auth, routing) works on root-relative paths whether or not the
815    // proxy already stripped it (#355).
816    let path = base_path::strip(&path, base_path.as_str()).to_string();
817
818    // Binary font assets are public (like CSS/JS) and bypass the String-based
819    // route pipeline so their bytes stay intact.
820    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        // Read-only scrape token (GL #401): lets a Prometheus/Datadog agent
843        // scrape `/metrics` without holding the full dashboard token. Valid
844        // for the metrics endpoint only — every other API stays gated on the
845        // dashboard token.
846        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        // No-auth mode: the Bearer token is gone, so cross-origin/CSRF and
888        // DNS-rebinding are blocked by request-header validation instead.
889        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    // Route handlers are synchronous and a few (graph/index builds) do seconds
904    // of disk work. Running them inline on an async worker thread lets one slow
905    // endpoint starve the small worker pool, so a trivial GET like
906    // `/api/settings` can wait minutes behind it (#431, Windows few-core). Run
907    // them on the blocking pool instead: the async workers stay free to serve
908    // light endpoints promptly. `spawn_blocking` also captures panics (returns
909    // a `JoinError`), so the previous `catch_unwind` is no longer needed.
910    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        // The blocking task panicked or was cancelled — surface a 500 rather
927        // than dropping the connection.
928        Err(_) => (
929            "500 Internal Server Error",
930            "application/json",
931            r#"{"error":"dashboard route panicked"}"#.to_string(),
932        ),
933    };
934    // Observability: a slow light endpoint is exactly the #431 symptom, so make
935    // any handler that crosses 1s visible in the logs for future diagnosis.
936    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    // Under a reverse-proxy subpath, rewrite root-absolute asset/API URLs in the
946    // served HTML/CSS/JS so the browser requests them under the prefix (#355).
947    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;