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");
43
44const COCKPIT_VENDOR_CHART_JS: &str = include_str!("static/vendor/chart.umd.min.js");
47const COCKPIT_VENDOR_D3_JS: &str = include_str!("static/vendor/d3.min.js");
48const COCKPIT_FONTS_CSS: &str = include_str!("static/fonts/fonts.css");
49const COCKPIT_FAVICON_SVG: &str = include_str!("static/favicon.svg");
50
51const FONT_INTER_WOFF2: &[u8] = include_bytes!("static/fonts/inter-variable.woff2");
55const FONT_JETBRAINS_WOFF2: &[u8] = include_bytes!("static/fonts/jetbrains-mono-variable.woff2");
56const FONT_SPACE_GROTESK_WOFF2: &[u8] = include_bytes!("static/fonts/space-grotesk-variable.woff2");
57
58fn match_font_asset(path: &str) -> Option<&'static [u8]> {
60 match path {
61 "/static/fonts/inter-variable.woff2" => Some(FONT_INTER_WOFF2),
62 "/static/fonts/jetbrains-mono-variable.woff2" => Some(FONT_JETBRAINS_WOFF2),
63 "/static/fonts/space-grotesk-variable.woff2" => Some(FONT_SPACE_GROTESK_WOFF2),
64 _ => None,
65 }
66}
67
68pub mod base_path;
69pub mod routes;
70pub(crate) mod vscode_open;
71
72pub async fn start(
73 port: Option<u16>,
74 host: Option<String>,
75 base_path: Option<String>,
76 auth_token: Option<String>,
77 open_mode: Option<String>,
78 auth_enabled: Option<bool>,
79) {
80 let open = resolve_open_mode(open_mode.as_deref());
82 let port = port.unwrap_or_else(|| {
83 std::env::var("LEAN_CTX_PORT")
84 .ok()
85 .and_then(|p| p.parse().ok())
86 .unwrap_or(DEFAULT_PORT)
87 });
88
89 let host = host.unwrap_or_else(|| {
90 std::env::var("LEAN_CTX_HOST")
91 .ok()
92 .unwrap_or_else(|| DEFAULT_HOST.to_string())
93 });
94
95 let base_path = Arc::new(
98 base_path
99 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_BASE_PATH").ok())
100 .map(|b| base_path::normalize(&b))
101 .unwrap_or_default(),
102 );
103
104 let addr = format!("{host}:{port}");
105 let is_local = host == "127.0.0.1" || host == "localhost" || host == "::1";
106
107 let auth_required = resolve_auth_enabled(auth_enabled);
114
115 let allowed_hosts = Arc::new(build_allowed_hosts(&host, port));
118
119 let (requested_token, token_src) = resolve_requested_token(auth_token.as_deref());
123
124 if is_local && dashboard_responding(&host, port) {
127 println!("\n lean-ctx dashboard already running → http://{host}:{port}{base_path}");
128 if let Some(req) = requested_token.as_deref()
129 && load_saved_token().as_deref() != Some(req)
130 {
131 eprintln!(
132 " \x1b[33m⚠\x1b[0m The running instance uses a different token — your {token_src} \
133 will be rejected. Stop it (Ctrl+C) and restart to apply the new token."
134 );
135 }
136 println!(" Tip: use Ctrl+C in the existing terminal to stop it.\n");
137 if let Some(t) = load_saved_token() {
138 open_dashboard_url(
139 &format!("http://localhost:{port}{base_path}/?token={t}"),
140 open,
141 );
142 } else {
143 open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
144 }
145 return;
146 }
147
148 let token = if auth_required {
153 let t = requested_token.unwrap_or_else(generate_token);
154 Some(Arc::new(t))
155 } else {
156 if requested_token.is_some() {
157 eprintln!(
158 " \x1b[33m⚠\x1b[0m Ignoring the pinned token ({token_src}) — auth is disabled."
159 );
160 }
161 None
162 };
163
164 let listener = match TcpListener::bind(&addr).await {
171 Ok(l) => l,
172 Err(e) => {
173 eprintln!("Failed to bind to {addr}: {e}");
174 std::process::exit(1);
175 }
176 };
177
178 if let Some(t) = token.as_ref() {
179 save_token(t);
180 let masked = if t.len() > 12 {
181 format!(
182 "{}…{}",
183 &t[..t.floor_char_boundary(8)],
184 &t[t.ceil_char_boundary(t.len().saturating_sub(4))..]
185 )
186 } else {
187 t.to_string()
188 };
189 let src = if token_src.is_empty() {
190 String::new()
191 } else {
192 format!(" (from {token_src})")
193 };
194 if is_local {
195 println!(" Auth: enabled (local){src}");
196 println!(" Browser URL: http://localhost:{port}{base_path}/?token={t}");
197 } else {
198 eprintln!(
199 " \x1b[33m⚠\x1b[0m Binding to {host} — authentication enabled.\n \
200 Bearer token{src}: \x1b[1;32m{masked}\x1b[0m\n \
201 Browser URL: http://<your-ip>:{port}{base_path}/?token={t}"
202 );
203 }
204 } else if is_local {
205 println!(
207 " Auth: \x1b[1;33mDISABLED\x1b[0m (no-auth) — CSRF protected via Sec-Fetch-Site/Origin/Host"
208 );
209 println!(" Browser URL: http://localhost:{port}{base_path}/");
210 } else {
211 eprintln!(
215 " \x1b[33m⚠\x1b[0m Auth \x1b[1;31mDISABLED\x1b[0m and binding to {host} (not loopback).\n \
216 Browser cross-origin/CSRF stays blocked (Sec-Fetch-Site/Origin/Host),\n \
217 but ANY non-browser client that can reach {host}:{port} has full access.\n \
218 Docker: publish only to the host loopback → -p 127.0.0.1:{port}:{port}\n \
219 Add reachable hostnames via LEAN_CTX_DASHBOARD_ALLOWED_HOSTS=host:port,…\n \
220 Browser URL: http://<your-ip>:{port}{base_path}/"
221 );
222 }
223
224 let stats_path = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
225 |_| "~/.lean-ctx/stats.json".to_string(),
226 |d| d.join("stats.json").display().to_string(),
227 );
228
229 if host == "0.0.0.0" {
230 println!("\n lean-ctx dashboard → http://0.0.0.0:{port} (all interfaces)");
231 println!(" Local access: http://localhost:{port}");
232 } else {
233 println!("\n lean-ctx dashboard → http://{host}:{port}");
234 }
235 println!(" Stats file: {stats_path}");
236 println!(" Press Ctrl+C to stop");
237 println!(
238 " \x1b[2m💡 Join the public leaderboard at https://leanctx.com/metrics: lean-ctx gain --publish --leaderboard\x1b[0m\n"
239 );
240
241 if is_local {
242 if let Some(t) = token.as_ref() {
243 open_dashboard_url(
244 &format!("http://localhost:{port}{base_path}/?token={t}"),
245 open,
246 );
247 } else {
248 open_dashboard_url(&format!("http://localhost:{port}{base_path}/"), open);
249 }
250 }
251 if crate::shell::is_container() && is_local {
252 println!(" Tip (Docker): bind 0.0.0.0 + publish port:");
253 println!(" lean-ctx dashboard --host=0.0.0.0 --port={port}");
254 println!(" docker run ... -p {port}:{port} ...");
255 println!();
256 }
257
258 if crate::core::datadog_push::spawn_if_enabled() {
259 println!(
260 " Datadog push: enabled (agentless, every LEAN_CTX_DATADOG_INTERVAL_SECS or 60s)"
261 );
262 }
263
264 loop {
265 if let Ok((stream, _)) = listener.accept().await {
266 let token_ref = token.clone();
267 let base_ref = base_path.clone();
268 let allowed_ref = allowed_hosts.clone();
269 tokio::spawn(handle_request(stream, token_ref, base_ref, allowed_ref));
270 }
271 }
272}
273
274const HTTP_TOKEN_ENV: &str = "LEAN_CTX_HTTP_TOKEN";
276const SCRAPE_TOKEN_ENV: &str = "LEAN_CTX_SCRAPE_TOKEN";
279const DASHBOARD_AUTH_ENV: &str = "LEAN_CTX_DASHBOARD_AUTH";
281const ALLOWED_HOSTS_ENV: &str = "LEAN_CTX_DASHBOARD_ALLOWED_HOSTS";
285
286fn parse_human_bool(s: &str) -> Option<bool> {
288 match s.trim().to_ascii_lowercase().as_str() {
289 "true" | "1" | "yes" | "on" => Some(true),
290 "false" | "0" | "no" | "off" => Some(false),
291 _ => None,
292 }
293}
294
295fn resolve_auth_enabled(flag: Option<bool>) -> bool {
299 if let Some(v) = flag {
300 return v;
301 }
302 if let Ok(raw) = std::env::var(DASHBOARD_AUTH_ENV)
303 && let Some(v) = parse_human_bool(&raw)
304 {
305 return v;
306 }
307 crate::core::config::Config::load().dashboard_auth
308}
309
310fn build_allowed_hosts(host: &str, port: u16) -> Vec<String> {
317 let mut allowed: Vec<String> = Vec::new();
318 let mut push = |h: String| {
319 if !h.is_empty() && !allowed.iter().any(|e| e.eq_ignore_ascii_case(&h)) {
320 allowed.push(h);
321 }
322 };
323 for base in ["127.0.0.1", "localhost", "[::1]", "::1"] {
324 push(base.to_string());
325 push(format!("{base}:{port}"));
326 }
327 if host != "0.0.0.0" && host != "::" {
328 push(host.to_string());
329 push(format!("{host}:{port}"));
330 }
331 if let Ok(raw) = std::env::var(ALLOWED_HOSTS_ENV) {
332 for entry in raw.split(',') {
333 push(entry.trim().to_string());
334 }
335 }
336 allowed
337}
338
339fn host_allowed(host: &str, allowed: &[String]) -> bool {
341 allowed.iter().any(|a| a.eq_ignore_ascii_case(host))
342}
343
344fn no_auth_request_ok(header_section: &str, allowed_hosts: &[String]) -> bool {
355 if let Some(sfs) = header_line_value(header_section, "Sec-Fetch-Site") {
356 let sfs = sfs.trim();
357 if !sfs.is_empty()
358 && !sfs.eq_ignore_ascii_case("same-origin")
359 && !sfs.eq_ignore_ascii_case("none")
360 {
361 return false;
362 }
363 }
364 let Some(host) = header_line_value(header_section, "Host") else {
365 return false;
366 };
367 if !host_allowed(host, allowed_hosts) {
368 return false;
369 }
370 if let Some(origin) = header_line_value(header_section, "Origin")
371 && !origin.is_empty()
372 && !origin.eq_ignore_ascii_case("null")
373 && !origin_matches_dashboard_host(origin, host)
374 {
375 return false;
376 }
377 true
378}
379
380fn resolve_requested_token(flag: Option<&str>) -> (Option<String>, &'static str) {
393 if let Some(t) = flag.map(str::trim).filter(|s| !s.is_empty()) {
394 return (Some(t.to_string()), "--auth-token");
395 }
396 if let Ok(raw) = std::env::var(HTTP_TOKEN_ENV) {
397 let trimmed = raw.trim();
398 if !trimmed.is_empty() {
399 return (Some(trimmed.to_string()), HTTP_TOKEN_ENV);
400 }
401 }
402 (None, "")
403}
404
405fn generate_token() -> String {
406 let mut bytes = [0u8; 32];
407 if getrandom::fill(&mut bytes).is_err() {
408 tracing::warn!("CSPRNG unavailable — falling back to time-based token");
409 let ts = std::time::SystemTime::now()
410 .duration_since(std::time::UNIX_EPOCH)
411 .unwrap_or_default()
412 .as_nanos();
413 for (i, b) in bytes.iter_mut().enumerate() {
414 *b = ((ts >> (i % 16 * 8)) & 0xFF) as u8;
415 }
416 }
417 format!("lctx_{}", hex_lower(&bytes))
418}
419
420fn save_token(token: &str) {
421 if let Ok(dir) = crate::core::paths::state_dir() {
422 let _ = std::fs::create_dir_all(&dir);
423 let path = dir.join("dashboard.token");
424 #[cfg(unix)]
425 {
426 use std::io::Write;
427 use std::os::unix::fs::OpenOptionsExt;
428 let Ok(mut f) = std::fs::OpenOptions::new()
429 .write(true)
430 .create(true)
431 .truncate(true)
432 .mode(0o600)
433 .open(&path)
434 else {
435 return;
436 };
437 let _ = f.write_all(token.as_bytes());
438 }
439 #[cfg(not(unix))]
440 {
441 let _ = std::fs::write(&path, token);
442 }
443 }
444}
445
446fn load_saved_token() -> Option<String> {
447 let dir = crate::core::paths::state_dir().ok()?;
448 let path = dir.join("dashboard.token");
449 std::fs::read_to_string(path)
450 .ok()
451 .map(|s| s.trim().to_string())
452}
453
454pub fn add_nonce_to_inline_scripts(html: &str, nonce: &str) -> String {
457 let mut result = String::with_capacity(html.len() + 128);
458 let mut remaining = html;
459 while let Some(pos) = remaining.find("<script") {
460 result.push_str(&remaining[..pos]);
461 let tag_start = &remaining[pos..];
462 let tag_end = tag_start.find('>').unwrap_or(tag_start.len());
463 let tag = &tag_start[..=tag_end];
464 if tag.contains("src=") || tag.contains("nonce=") {
465 result.push_str(tag);
466 } else {
467 result.push_str(&tag.replacen("<script", &format!("<script nonce=\"{nonce}\""), 1));
468 }
469 remaining = &tag_start[tag_end + 1..];
470 }
471 result.push_str(remaining);
472 result
473}
474
475fn hex_lower(bytes: &[u8]) -> String {
476 const HEX: &[u8; 16] = b"0123456789abcdef";
477 let mut out = String::with_capacity(bytes.len() * 2);
478 for &b in bytes {
479 out.push(HEX[(b >> 4) as usize] as char);
480 out.push(HEX[(b & 0x0f) as usize] as char);
481 }
482 out
483}
484
485#[derive(Clone, Copy, PartialEq, Eq, Debug)]
487enum DashboardOpen {
488 Browser,
490 None,
493 Vscode,
497}
498
499fn resolve_open_mode(flag: Option<&str>) -> DashboardOpen {
502 let raw = flag
503 .map(str::to_string)
504 .or_else(|| std::env::var("LEAN_CTX_DASHBOARD_OPEN").ok())
505 .unwrap_or_default();
506 match raw.trim().to_ascii_lowercase().as_str() {
507 "none" | "off" | "false" | "no" => DashboardOpen::None,
508 "vscode" | "code" | "editor" => DashboardOpen::Vscode,
509 _ => DashboardOpen::Browser,
510 }
511}
512
513fn open_dashboard_url(url: &str, mode: DashboardOpen) {
515 match mode {
516 DashboardOpen::Browser => open_browser(url),
517 DashboardOpen::None => {}
518 DashboardOpen::Vscode => {
519 println!(
524 " \x1b[2mNative tab: run ⇧⌘P → \"lean-ctx: Open Web Dashboard\" (needs the lean-ctx VS Code extension)\x1b[0m"
525 );
526 println!(
527 " \x1b[2mNo extension? ⇧⌘P → \"Simple Browser: Show\" → paste the URL above\x1b[0m"
528 );
529 }
530 }
531}
532
533fn open_browser(url: &str) {
534 #[cfg(target_os = "macos")]
535 {
536 let _ = std::process::Command::new("open").arg(url).spawn();
537 }
538
539 #[cfg(target_os = "linux")]
540 {
541 let _ = std::process::Command::new("xdg-open")
542 .arg(url)
543 .stderr(std::process::Stdio::null())
544 .spawn();
545 }
546
547 #[cfg(target_os = "windows")]
548 {
549 let _ = std::process::Command::new("cmd")
550 .args(["/C", "start", url])
551 .spawn();
552 }
553}
554
555fn dashboard_responding(host: &str, port: u16) -> bool {
556 use std::io::{Read, Write};
557 use std::net::TcpStream;
558 use std::time::Duration;
559
560 let addr = format!("{host}:{port}");
561 let Ok(mut s) = TcpStream::connect_timeout(
562 &addr
563 .parse()
564 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], port))),
565 Duration::from_millis(150),
566 ) else {
567 return false;
568 };
569 let _ = s.set_read_timeout(Some(Duration::from_millis(150)));
570 let _ = s.set_write_timeout(Some(Duration::from_millis(150)));
571
572 let auth_header = load_saved_token()
573 .map(|t| format!("Authorization: Bearer {t}\r\n"))
574 .unwrap_or_default();
575
576 let req = format!(
577 "GET /api/version HTTP/1.1\r\nHost: localhost\r\n{auth_header}Connection: close\r\n\r\n"
578 );
579 if s.write_all(req.as_bytes()).is_err() {
580 return false;
581 }
582 let mut buf = [0u8; 256];
583 let Ok(n) = s.read(&mut buf) else {
584 return false;
585 };
586 let head = String::from_utf8_lossy(&buf[..n]);
587 head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")
588}
589
590const MAX_HTTP_MESSAGE: usize = 2 * 1024 * 1024;
591
592fn header_line_value<'a>(header_section: &'a str, name: &str) -> Option<&'a str> {
593 for line in header_section.lines() {
594 let Some((k, v)) = line.split_once(':') else {
595 continue;
596 };
597 if k.trim().eq_ignore_ascii_case(name) {
598 return Some(v.trim());
599 }
600 }
601 None
602}
603
604fn host_loopback_aliases(host: &str) -> Vec<String> {
606 let mut v = vec![host.to_string()];
607 if let Some(port) = host.strip_prefix("127.0.0.1:") {
608 v.push(format!("localhost:{port}"));
609 }
610 if let Some(port) = host.strip_prefix("localhost:") {
611 v.push(format!("127.0.0.1:{port}"));
612 }
613 if let Some(port) = host.strip_prefix("[::1]:") {
614 v.push(format!("127.0.0.1:{port}"));
615 v.push(format!("localhost:{port}"));
616 }
617 v
618}
619
620fn origin_matches_dashboard_host(origin: &str, host: &str) -> bool {
621 let origin = origin.trim_end_matches('/');
622 for h in host_loopback_aliases(host) {
623 if origin.eq_ignore_ascii_case(&format!("http://{h}"))
624 || origin.eq_ignore_ascii_case(&format!("https://{h}"))
625 {
626 return true;
627 }
628 }
629 false
630}
631
632fn csrf_origin_ok(header_section: &str, method: &str, path: &str) -> bool {
635 let uc = method.to_ascii_uppercase();
636 if !matches!(uc.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
637 return true;
638 }
639 if !path.starts_with("/api/") {
640 return true;
641 }
642 let Some(origin) = header_line_value(header_section, "Origin") else {
643 return true;
644 };
645 if origin.is_empty() || origin.eq_ignore_ascii_case("null") {
646 return true;
647 }
648 let Some(host) = header_line_value(header_section, "Host") else {
649 return false;
650 };
651 origin_matches_dashboard_host(origin, host)
652}
653
654fn find_headers_end(buf: &[u8]) -> Option<usize> {
655 buf.windows(4).position(|w| w == b"\r\n\r\n")
656}
657
658fn parse_content_length_header(header_section: &[u8]) -> Option<usize> {
659 let text = String::from_utf8_lossy(header_section);
660 for line in text.lines() {
661 let Some((k, v)) = line.split_once(':') else {
662 continue;
663 };
664 if k.trim().eq_ignore_ascii_case("content-length") {
665 return v.trim().parse::<usize>().ok();
666 }
667 }
668 Some(0)
669}
670
671async fn read_http_message(stream: &mut tokio::net::TcpStream) -> Option<Vec<u8>> {
672 let mut buf = Vec::new();
673 let mut tmp = [0u8; 8192];
674 loop {
675 if let Some(end) = find_headers_end(&buf) {
676 let cl = parse_content_length_header(&buf[..end])?;
677 let total = end + 4 + cl;
678 if total > MAX_HTTP_MESSAGE {
679 return None;
680 }
681 if buf.len() >= total {
682 buf.truncate(total);
683 return Some(buf);
684 }
685 } else if buf.len() > 65_536 {
686 return None;
687 }
688
689 let n = stream.read(&mut tmp).await.ok()?;
690 if n == 0 {
691 return None;
692 }
693 buf.extend_from_slice(&tmp[..n]);
694 if buf.len() > MAX_HTTP_MESSAGE {
695 return None;
696 }
697 }
698}
699
700async fn handle_request(
701 mut stream: tokio::net::TcpStream,
702 token: Option<Arc<String>>,
703 base_path: Arc<String>,
704 allowed_hosts: Arc<Vec<String>>,
705) {
706 let is_loopback = stream.peer_addr().is_ok_and(|a| a.ip().is_loopback());
707
708 let Some(buf) = read_http_message(&mut stream).await else {
709 return;
710 };
711 let Some(header_end) = find_headers_end(&buf) else {
712 return;
713 };
714 let header_text = String::from_utf8_lossy(&buf[..header_end]).to_string();
715 let body_start = header_end + 4;
716 let Some(content_len) = parse_content_length_header(&buf[..header_end]) else {
717 return;
718 };
719 if buf.len() < body_start + content_len {
720 return;
721 }
722 let body_str = std::str::from_utf8(&buf[body_start..body_start + content_len])
723 .unwrap_or("")
724 .to_string();
725
726 let first = header_text.lines().next().unwrap_or("");
727 let mut parts = first.split_whitespace();
728 let method = parts.next().unwrap_or("GET").to_string();
729 let raw_path = parts.next().unwrap_or("/").to_string();
730
731 let (path, query_token) = if let Some(idx) = raw_path.find('?') {
732 let p = &raw_path[..idx];
733 let qs = &raw_path[idx + 1..];
734 let tok = qs
735 .split('&')
736 .find_map(|pair| pair.strip_prefix("token="))
737 .map(std::string::ToString::to_string);
738 (p.to_string(), tok)
739 } else {
740 (raw_path.clone(), None)
741 };
742
743 let query_str = raw_path
744 .find('?')
745 .map_or(String::new(), |i| raw_path[i + 1..].to_string());
746
747 let path = base_path::strip(&path, base_path.as_str()).to_string();
751
752 if let Some(bytes) = match_font_asset(&path) {
755 let header = format!(
756 "HTTP/1.1 200 OK\r\n\
757 Content-Type: font/woff2\r\n\
758 Content-Length: {}\r\n\
759 Cache-Control: public, max-age=31536000, immutable\r\n\
760 X-Content-Type-Options: nosniff\r\n\
761 Connection: close\r\n\
762 \r\n",
763 bytes.len()
764 );
765 let _ = stream.write_all(header.as_bytes()).await;
766 let _ = stream.write_all(bytes).await;
767 return;
768 }
769
770 let is_api = path.starts_with("/api/");
771 let requires_auth = is_api || path == "/metrics";
772
773 if let Some(ref expected) = token {
774 let mut has_header_auth = check_auth(&header_text, expected);
775
776 if !has_header_auth
781 && path == "/metrics"
782 && let Ok(scrape) = std::env::var(SCRAPE_TOKEN_ENV)
783 {
784 let scrape = scrape.trim();
785 if !scrape.is_empty() && check_auth(&header_text, scrape) {
786 has_header_auth = true;
787 }
788 }
789
790 if requires_auth && !has_header_auth {
791 let body = r#"{"error":"unauthorized"}"#;
792 let response = format!(
793 "HTTP/1.1 401 Unauthorized\r\n\
794 Content-Type: application/json\r\n\
795 Content-Length: {}\r\n\
796 WWW-Authenticate: Bearer\r\n\
797 Connection: close\r\n\
798 \r\n\
799 {body}",
800 body.len()
801 );
802 let _ = stream.write_all(response.as_bytes()).await;
803 return;
804 }
805
806 if !csrf_origin_ok(&header_text, method.as_str(), path.as_str()) {
807 let body = r#"{"error":"forbidden"}"#;
808 let response = format!(
809 "HTTP/1.1 403 Forbidden\r\n\
810 Content-Type: application/json\r\n\
811 Content-Length: {}\r\n\
812 Connection: close\r\n\
813 \r\n\
814 {body}",
815 body.len()
816 );
817 let _ = stream.write_all(response.as_bytes()).await;
818 return;
819 }
820 } else if requires_auth && !no_auth_request_ok(&header_text, &allowed_hosts) {
821 let body = r#"{"error":"forbidden"}"#;
824 let response = format!(
825 "HTTP/1.1 403 Forbidden\r\n\
826 Content-Type: application/json\r\n\
827 Content-Length: {}\r\n\
828 Connection: close\r\n\
829 \r\n\
830 {body}",
831 body.len()
832 );
833 let _ = stream.write_all(response.as_bytes()).await;
834 return;
835 }
836
837 let route_started = std::time::Instant::now();
845 let route_label = path.clone();
846 let compute = tokio::task::spawn_blocking(move || {
847 routes::route_response(
848 &path,
849 &query_str,
850 query_token.as_ref(),
851 token.as_ref(),
852 is_loopback,
853 &method,
854 &body_str,
855 )
856 })
857 .await;
858 let (status, content_type, mut body) = match compute {
859 Ok(v) => v,
860 Err(_) => (
863 "500 Internal Server Error",
864 "application/json",
865 r#"{"error":"dashboard route panicked"}"#.to_string(),
866 ),
867 };
868 let route_elapsed = route_started.elapsed();
871 if route_elapsed >= std::time::Duration::from_secs(1) {
872 tracing::warn!(
873 target: "lean_ctx::dashboard",
874 "slow dashboard route {route_label} took {} ms",
875 route_elapsed.as_millis()
876 );
877 }
878
879 if !base_path.is_empty()
882 && (content_type.contains("text/html")
883 || content_type.contains("text/css")
884 || content_type.contains("javascript"))
885 {
886 body = base_path::rewrite_asset_urls(&body, base_path.as_str());
887 }
888
889 let cache_header = if content_type.starts_with("application/json") {
890 "Cache-Control: no-cache, no-store, must-revalidate\r\nPragma: no-cache\r\n"
891 } else if content_type.starts_with("application/javascript")
892 || content_type.starts_with("text/css")
893 {
894 "Cache-Control: no-cache, must-revalidate\r\n"
895 } else {
896 ""
897 };
898
899 let nonce = {
900 let mut nb = [0u8; 16];
901 if getrandom::fill(&mut nb).is_err() {
902 nb.iter_mut().enumerate().for_each(|(i, b)| {
903 *b = (std::time::SystemTime::now()
904 .duration_since(std::time::UNIX_EPOCH)
905 .unwrap_or_default()
906 .subsec_nanos()
907 .wrapping_add(i as u32)) as u8;
908 });
909 }
910 hex_lower(&nb)
911 };
912 if content_type.contains("text/html") {
913 body = add_nonce_to_inline_scripts(&body, &nonce);
914 }
915 let security_headers = format!(
916 "X-Content-Type-Options: nosniff\r\n\
917 X-Frame-Options: DENY\r\n\
918 Referrer-Policy: no-referrer\r\n\
919 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"
920 );
921
922 let response = format!(
923 "HTTP/1.1 {status}\r\n\
924 Content-Type: {content_type}\r\n\
925 Content-Length: {}\r\n\
926 {cache_header}\
927 {security_headers}\
928 Connection: close\r\n\
929 \r\n\
930 {body}",
931 body.len()
932 );
933
934 let _ = stream.write_all(response.as_bytes()).await;
935}
936
937fn check_auth(request: &str, expected_token: &str) -> bool {
938 for line in request.lines() {
939 let lower = line.to_lowercase();
940 if lower.starts_with("authorization:") {
941 let value = line["authorization:".len()..].trim();
942 if let Some(token) = value
943 .strip_prefix("Bearer ")
944 .or_else(|| value.strip_prefix("bearer "))
945 {
946 return constant_time_eq(token.trim().as_bytes(), expected_token.as_bytes());
947 }
948 }
949 }
950 false
951}
952
953fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
954 if a.len() != b.len() {
955 return false;
956 }
957 bool::from(a.ct_eq(b))
958}
959
960#[cfg(test)]
961mod tests {
962 use super::routes::helpers::normalize_dashboard_demo_path;
963 use super::*;
964 use tempfile::tempdir;
965
966 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
967
968 #[test]
969 fn check_auth_with_valid_bearer() {
970 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer lctx_abc123\r\n\r\n";
971 assert!(check_auth(req, "lctx_abc123"));
972 }
973
974 #[test]
975 fn check_auth_with_invalid_bearer() {
976 let req = "GET /api/stats HTTP/1.1\r\nAuthorization: Bearer wrong_token\r\n\r\n";
977 assert!(!check_auth(req, "lctx_abc123"));
978 }
979
980 #[test]
981 fn open_mode_flag_parses_all_variants() {
982 assert_eq!(resolve_open_mode(Some("none")), DashboardOpen::None);
984 assert_eq!(resolve_open_mode(Some("off")), DashboardOpen::None);
985 assert_eq!(resolve_open_mode(Some("no")), DashboardOpen::None);
986 assert_eq!(resolve_open_mode(Some("vscode")), DashboardOpen::Vscode);
987 assert_eq!(resolve_open_mode(Some("code")), DashboardOpen::Vscode);
988 assert_eq!(resolve_open_mode(Some("editor")), DashboardOpen::Vscode);
989 assert_eq!(resolve_open_mode(Some("VSCode")), DashboardOpen::Vscode);
990 assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
991 assert_eq!(resolve_open_mode(Some("wat")), DashboardOpen::Browser);
993 }
994
995 #[test]
996 fn open_mode_env_is_used_when_no_flag() {
997 let _guard = ENV_LOCK.lock().unwrap();
998 crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "none");
999 assert_eq!(resolve_open_mode(None), DashboardOpen::None);
1000 crate::test_env::set_var("LEAN_CTX_DASHBOARD_OPEN", "vscode");
1001 assert_eq!(resolve_open_mode(None), DashboardOpen::Vscode);
1002 assert_eq!(resolve_open_mode(Some("browser")), DashboardOpen::Browser);
1004 crate::test_env::remove_var("LEAN_CTX_DASHBOARD_OPEN");
1005 assert_eq!(resolve_open_mode(None), DashboardOpen::Browser);
1006 }
1007
1008 #[test]
1009 fn check_auth_missing_header() {
1010 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost\r\n\r\n";
1011 assert!(!check_auth(req, "lctx_abc123"));
1012 }
1013
1014 #[test]
1015 fn check_auth_lowercase_bearer() {
1016 let req = "GET /api/stats HTTP/1.1\r\nauthorization: bearer lctx_abc123\r\n\r\n";
1017 assert!(check_auth(req, "lctx_abc123"));
1018 }
1019
1020 #[test]
1021 fn query_token_parsing() {
1022 let raw_path = "/index.html?token=lctx_abc123&other=val";
1023 let idx = raw_path.find('?').unwrap();
1024 let qs = &raw_path[idx + 1..];
1025 let tok = qs.split('&').find_map(|pair| pair.strip_prefix("token="));
1026 assert_eq!(tok, Some("lctx_abc123"));
1027 }
1028
1029 #[test]
1030 fn api_path_detection() {
1031 assert!("/api/stats".starts_with("/api/"));
1032 assert!("/api/version".starts_with("/api/"));
1033 assert!(!"/".starts_with("/api/"));
1034 assert!(!"/index.html".starts_with("/api/"));
1035 assert!(!"/favicon.ico".starts_with("/api/"));
1036 }
1037
1038 #[test]
1039 fn normalize_dashboard_demo_path_strips_rooted_relative_windows_path() {
1040 let normalized = normalize_dashboard_demo_path(r"\backend\list_tables.js");
1041 assert_eq!(
1042 normalized,
1043 format!("backend{}list_tables.js", std::path::MAIN_SEPARATOR)
1044 );
1045 }
1046
1047 #[test]
1048 fn normalize_dashboard_demo_path_preserves_absolute_windows_path() {
1049 let input = r"C:\repo\backend\list_tables.js";
1050 assert_eq!(normalize_dashboard_demo_path(input), input);
1051 }
1052
1053 #[test]
1054 fn normalize_dashboard_demo_path_preserves_unc_path() {
1055 let input = r"\\server\share\backend\list_tables.js";
1056 assert_eq!(normalize_dashboard_demo_path(input), input);
1057 }
1058
1059 #[test]
1060 fn normalize_dashboard_demo_path_strips_dot_slash_prefix() {
1061 assert_eq!(
1062 normalize_dashboard_demo_path("./src/main.rs"),
1063 "src/main.rs"
1064 );
1065 assert_eq!(
1066 normalize_dashboard_demo_path(r".\src\main.rs"),
1067 format!("src{}main.rs", std::path::MAIN_SEPARATOR)
1068 );
1069 }
1070
1071 #[test]
1072 fn api_profile_returns_json() {
1073 let (_status, _ct, body) =
1074 routes::route_response("/api/profile", "", None, None, false, "GET", "");
1075 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1076 assert!(v.get("active_name").is_some(), "missing active_name");
1077 assert!(
1078 v.pointer("/profile/profile/name")
1079 .and_then(|n| n.as_str())
1080 .is_some(),
1081 "missing profile.profile.name"
1082 );
1083 assert!(v.get("available").and_then(|a| a.as_array()).is_some());
1084 }
1085
1086 #[test]
1087 fn api_billing_badge_returns_cosmetic_shape() {
1088 let (status, ct, body) =
1089 routes::route_response("/api/billing-badge", "", None, None, false, "GET", "");
1090 assert_eq!(status, "200 OK");
1091 assert_eq!(ct, "application/json");
1092 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1093 assert!(v.get("plan").and_then(|p| p.as_str()).is_some());
1094 assert!(
1095 v.get("supporter")
1096 .and_then(serde_json::Value::as_bool)
1097 .is_some()
1098 );
1099 assert!(
1100 matches!(
1101 v.get("source").and_then(|s| s.as_str()),
1102 Some("live" | "cached" | "expired" | "none")
1103 ),
1104 "unexpected source: {body}"
1105 );
1106 }
1107
1108 #[test]
1109 fn api_episodes_returns_json() {
1110 let (_status, _ct, body) =
1111 routes::route_response("/api/episodes", "", None, None, false, "GET", "");
1112 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1113 assert!(v.get("project_hash").is_some());
1114 assert!(v.get("stats").is_some());
1115 assert!(v.get("recent").and_then(|a| a.as_array()).is_some());
1116 }
1117
1118 #[test]
1119 fn api_procedures_returns_json() {
1120 let (_status, _ct, body) =
1121 routes::route_response("/api/procedures", "", None, None, false, "GET", "");
1122 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1123 assert!(v.get("project_hash").is_some());
1124 assert!(v.get("procedures").and_then(|a| a.as_array()).is_some());
1125 assert!(v.get("suggestions").and_then(|a| a.as_array()).is_some());
1126 }
1127
1128 #[test]
1129 fn api_compression_demo_heals_moved_file_paths() {
1130 let _g = ENV_LOCK.lock().expect("env lock");
1131 let td = tempdir().expect("tempdir");
1132 let root = td.path();
1133 std::fs::create_dir_all(root.join("src").join("moved")).expect("mkdir");
1134 std::fs::write(
1135 root.join("src").join("moved").join("foo.rs"),
1136 "pub fn foo() { println!(\"hi\"); }\n",
1137 )
1138 .expect("write foo.rs");
1139
1140 let root_s = root.to_string_lossy().to_string();
1141 crate::test_env::set_var("LEAN_CTX_DASHBOARD_PROJECT", &root_s);
1142
1143 let (_status, _ct, body) = routes::route_response(
1144 "/api/compression-demo",
1145 "path=src/foo.rs",
1146 None,
1147 None,
1148 false,
1149 "GET",
1150 "",
1151 );
1152 let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON");
1153 assert!(v.get("error").is_none(), "unexpected error: {body}");
1154 assert_eq!(
1155 v.get("resolved_from").and_then(|x| x.as_str()),
1156 Some("src/moved/foo.rs")
1157 );
1158
1159 crate::test_env::remove_var("LEAN_CTX_DASHBOARD_PROJECT");
1160 if let Some(dir) = crate::core::graph_index::ProjectIndex::index_dir(&root_s) {
1161 let _ = std::fs::remove_dir_all(dir);
1162 }
1163 }
1164
1165 #[test]
1166 fn resolve_token_uses_env_var_verbatim() {
1167 let _g = ENV_LOCK.lock().expect("env lock");
1168 crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_mystatic");
1169 let (token, src) = resolve_requested_token(None);
1170 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1171 assert_eq!(
1172 src, HTTP_TOKEN_ENV,
1173 "token should be reported as env-sourced"
1174 );
1175 assert_eq!(token.as_deref(), Some("lctx_mystatic"));
1176 }
1177
1178 #[test]
1179 fn resolve_token_trims_env_var() {
1180 let _g = ENV_LOCK.lock().expect("env lock");
1181 crate::test_env::set_var(HTTP_TOKEN_ENV, " lctx_padded ");
1182 let (token, src) = resolve_requested_token(None);
1183 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1184 assert_eq!(src, HTTP_TOKEN_ENV);
1185 assert_eq!(token.as_deref(), Some("lctx_padded"));
1186 }
1187
1188 #[test]
1189 fn resolve_token_falls_back_to_random_when_unset() {
1190 let _g = ENV_LOCK.lock().expect("env lock");
1191 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1192 let (token, src) = resolve_requested_token(None);
1193 assert!(token.is_none(), "unset env requests no fixed token");
1194 assert!(src.is_empty());
1195 let generated = token.unwrap_or_else(generate_token);
1197 assert!(
1198 generated.starts_with("lctx_"),
1199 "generated token prefix, got {generated}"
1200 );
1201 assert!(
1202 generated.len() > 12,
1203 "generated token should be 32-byte hex"
1204 );
1205 }
1206
1207 #[test]
1208 fn resolve_token_ignores_empty_env() {
1209 let _g = ENV_LOCK.lock().expect("env lock");
1210 crate::test_env::set_var(HTTP_TOKEN_ENV, " ");
1211 let (token, src) = resolve_requested_token(None);
1212 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1213 assert!(
1214 token.is_none(),
1215 "whitespace-only env requests no fixed token"
1216 );
1217 assert!(src.is_empty());
1218 }
1219
1220 #[test]
1221 fn resolve_token_flag_overrides_env() {
1222 let _g = ENV_LOCK.lock().expect("env lock");
1225 crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1226 let (token, src) = resolve_requested_token(Some("lctx_fromflag"));
1227 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1228 assert_eq!(src, "--auth-token");
1229 assert_eq!(token.as_deref(), Some("lctx_fromflag"));
1230 }
1231
1232 #[test]
1233 fn resolve_token_uses_flag_when_env_unset() {
1234 let _g = ENV_LOCK.lock().expect("env lock");
1235 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1236 let (token, src) = resolve_requested_token(Some(" lctx_flag_padded "));
1237 assert_eq!(src, "--auth-token");
1238 assert_eq!(token.as_deref(), Some("lctx_flag_padded"));
1239 }
1240
1241 #[test]
1242 fn resolve_token_empty_flag_falls_back_to_env() {
1243 let _g = ENV_LOCK.lock().expect("env lock");
1244 crate::test_env::set_var(HTTP_TOKEN_ENV, "lctx_fromenv");
1245 let (token, src) = resolve_requested_token(Some(" "));
1246 crate::test_env::remove_var(HTTP_TOKEN_ENV);
1247 assert_eq!(src, HTTP_TOKEN_ENV);
1248 assert_eq!(token.as_deref(), Some("lctx_fromenv"));
1249 }
1250
1251 #[test]
1252 fn parse_human_bool_accepts_common_forms() {
1253 for s in ["true", "TRUE", "1", "yes", "on", " On "] {
1254 assert_eq!(parse_human_bool(s), Some(true), "{s}");
1255 }
1256 for s in ["false", "FALSE", "0", "no", "off", " Off "] {
1257 assert_eq!(parse_human_bool(s), Some(false), "{s}");
1258 }
1259 assert_eq!(parse_human_bool("maybe"), None);
1260 }
1261
1262 #[test]
1263 fn build_allowed_hosts_covers_loopback_and_bound_host() {
1264 let _g = ENV_LOCK.lock().expect("env lock");
1265 crate::test_env::remove_var(ALLOWED_HOSTS_ENV);
1266 let allowed = build_allowed_hosts("0.0.0.0", 3333);
1267 assert!(host_allowed("127.0.0.1:3333", &allowed));
1268 assert!(host_allowed("localhost:3333", &allowed));
1269 assert!(host_allowed("[::1]:3333", &allowed));
1270 assert!(host_allowed("127.0.0.1", &allowed)); assert!(!host_allowed("0.0.0.0:3333", &allowed));
1273 assert!(!host_allowed("evil.com", &allowed));
1274 }
1275
1276 #[test]
1277 fn build_allowed_hosts_honors_env_extra_hosts() {
1278 let _g = ENV_LOCK.lock().expect("env lock");
1279 crate::test_env::set_var(ALLOWED_HOSTS_ENV, "box.local:3333, 10.0.0.5:3333");
1280 let allowed = build_allowed_hosts("127.0.0.1", 3333);
1281 crate::test_env::remove_var(ALLOWED_HOSTS_ENV);
1282 assert!(host_allowed("box.local:3333", &allowed));
1283 assert!(host_allowed("10.0.0.5:3333", &allowed));
1284 }
1285
1286 fn allowed_loopback() -> Vec<String> {
1287 vec![
1288 "127.0.0.1:3333".into(),
1289 "localhost:3333".into(),
1290 "127.0.0.1".into(),
1291 "localhost".into(),
1292 ]
1293 }
1294
1295 #[test]
1296 fn no_auth_allows_non_browser_client() {
1297 let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\r\n";
1299 assert!(no_auth_request_ok(req, &allowed_loopback()));
1300 }
1301
1302 #[test]
1303 fn no_auth_allows_same_origin_browser_request() {
1304 let req = "GET /api/stats HTTP/1.1\r\nHost: localhost:3333\r\n\
1305 Origin: http://localhost:3333\r\nSec-Fetch-Site: same-origin\r\n\r\n";
1306 assert!(no_auth_request_ok(req, &allowed_loopback()));
1307 }
1308
1309 #[test]
1310 fn no_auth_allows_direct_navigation() {
1311 let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\nSec-Fetch-Site: none\r\n\r\n";
1313 assert!(no_auth_request_ok(req, &allowed_loopback()));
1314 }
1315
1316 #[test]
1317 fn no_auth_rejects_cross_site_fetch() {
1318 let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\
1319 Sec-Fetch-Site: cross-site\r\n\r\n";
1320 assert!(!no_auth_request_ok(req, &allowed_loopback()));
1321 }
1322
1323 #[test]
1324 fn no_auth_rejects_same_site_fetch() {
1325 let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\
1326 Sec-Fetch-Site: same-site\r\n\r\n";
1327 assert!(!no_auth_request_ok(req, &allowed_loopback()));
1328 }
1329
1330 #[test]
1331 fn no_auth_rejects_foreign_origin() {
1332 let req = "POST /api/settings HTTP/1.1\r\nHost: 127.0.0.1:3333\r\n\
1333 Origin: http://evil.com\r\n\r\n";
1334 assert!(!no_auth_request_ok(req, &allowed_loopback()));
1335 }
1336
1337 #[test]
1338 fn no_auth_rejects_dns_rebinding_host() {
1339 let req = "GET /api/stats HTTP/1.1\r\nHost: evil.com\r\n\
1341 Sec-Fetch-Site: same-origin\r\n\r\n";
1342 assert!(!no_auth_request_ok(req, &allowed_loopback()));
1343 }
1344
1345 #[test]
1346 fn no_auth_rejects_missing_host() {
1347 let req = "GET /api/stats HTTP/1.1\r\n\r\n";
1348 assert!(!no_auth_request_ok(req, &allowed_loopback()));
1349 }
1350
1351 #[test]
1352 fn no_auth_allows_null_origin() {
1353 let req = "GET /api/stats HTTP/1.1\r\nHost: 127.0.0.1:3333\r\nOrigin: null\r\n\r\n";
1356 assert!(no_auth_request_ok(req, &allowed_loopback()));
1357 }
1358}