diff --git a/Cargo.toml b/Cargo.toml index 2e7b60573..df19b28f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,7 +108,7 @@ built = { version = "0.8", features = ["chrono"] } insta = { version = "1", features = ["json"] } tempfile = "3" pretty_assertions = "1" -reqwest = { version = "0.12", default-features = false } +reqwest = { version = "0.12", default-features = false, features = ["stream"] } [lints.rust] unsafe_code = "warn" diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 9fcfd44e2..8c51150d2 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -12,10 +12,14 @@ pub struct ServeArgs { /// Open the dashboard in your browser once the server is up. #[arg(long)] pub open: bool, + /// Acknowledge exposing all PM/cost/webhook data with no authentication. + /// Required whenever --host is not 127.0.0.1/localhost/::1. + #[arg(long)] + pub yes_expose: bool, } impl ServeArgs { pub fn run(self) { - crate::dashboard::serve(&self.host, self.port, self.open); + crate::dashboard::serve(&self.host, self.port, self.open, self.yes_expose); } } diff --git a/src/dashboard/data.rs b/src/dashboard/data.rs index 89656592e..397e5247d 100644 --- a/src/dashboard/data.rs +++ b/src/dashboard/data.rs @@ -26,7 +26,10 @@ pub fn claims_json() -> String { }); match result { Ok(claims) => serde_json::to_string(&claims).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] claims_json: {e}"); + "[]".into() + } } } @@ -35,14 +38,20 @@ pub fn claims_json() -> String { pub fn workspaces_json() -> String { match pm_db_readonly() { Ok(conn) => workspaces_json_from(&conn), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] workspaces_json: {e}"); + "[]".into() + } } } fn workspaces_json_from(conn: &Connection) -> String { match agentflare_backend::workspace::list(conn) { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] workspaces_json_from: {e}"); + "[]".into() + } } } @@ -51,14 +60,20 @@ fn workspaces_json_from(conn: &Connection) -> String { pub fn projects_json(workspace_id: &str) -> String { match pm_db_readonly() { Ok(conn) => projects_json_from(&conn, workspace_id), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] projects_json: {e}"); + "[]".into() + } } } fn projects_json_from(conn: &Connection, workspace_id: &str) -> String { match agentflare_backend::project::list_by_workspace(conn, workspace_id) { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] projects_json_from: {e}"); + "[]".into() + } } } @@ -67,14 +82,20 @@ fn projects_json_from(conn: &Connection, workspace_id: &str) -> String { pub fn items_json(project_id: &str) -> String { match pm_db_readonly() { Ok(conn) => items_json_from(&conn, project_id), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] items_json: {e}"); + "[]".into() + } } } fn items_json_from(conn: &Connection, project_id: &str) -> String { match agentflare_backend::item::list_by_project(conn, project_id) { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] items_json_from: {e}"); + "[]".into() + } } } @@ -83,14 +104,20 @@ fn items_json_from(conn: &Connection, project_id: &str) -> String { pub fn states_json(project_id: &str) -> String { match pm_db_readonly() { Ok(conn) => states_json_from(&conn, project_id), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] states_json: {e}"); + "[]".into() + } } } fn states_json_from(conn: &Connection, project_id: &str) -> String { match agentflare_backend::state::list_by_project(conn, project_id) { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] states_json_from: {e}"); + "[]".into() + } } } @@ -99,14 +126,20 @@ fn states_json_from(conn: &Connection, project_id: &str) -> String { pub fn comments_json(item_id: &str) -> String { match pm_db_readonly() { Ok(conn) => comments_json_from(&conn, item_id), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] comments_json: {e}"); + "[]".into() + } } } fn comments_json_from(conn: &Connection, item_id: &str) -> String { match agentflare_backend::comment::list_by_item(conn, item_id) { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] comments_json_from: {e}"); + "[]".into() + } } } @@ -117,7 +150,10 @@ fn comments_json_from(conn: &Connection, item_id: &str) -> String { pub fn labels_json(workspace_id: Option<&str>, project_id: Option<&str>) -> String { match pm_db_readonly() { Ok(conn) => labels_json_from(&conn, workspace_id, project_id), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] labels_json: {e}"); + "[]".into() + } } } @@ -133,7 +169,10 @@ fn labels_json_from( }; match result { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] labels_json_from: {e}"); + "[]".into() + } } } @@ -143,14 +182,20 @@ fn labels_json_from( pub fn webhooks_json(workspace_id: &str) -> String { match pm_db_readonly() { Ok(conn) => webhooks_json_from(&conn, workspace_id), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] webhooks_json: {e}"); + "[]".into() + } } } fn webhooks_json_from(conn: &Connection, workspace_id: &str) -> String { match agentflare_backend::webhook::list_logs_by_workspace(conn, workspace_id) { Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), - Err(_) => "[]".into(), + Err(e) => { + eprintln!("[dashboard/data] webhooks_json_from: {e}"); + "[]".into() + } } } diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 5d7b8faed..45db62a2b 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -2,9 +2,9 @@ mod artifacts; mod data; mod server; -pub fn serve(host: &str, port: u16, open: bool) { +pub fn serve(host: &str, port: u16, open: bool, yes_expose: bool) { let runtime = tokio::runtime::Runtime::new().expect("failed to build tokio runtime"); - runtime.block_on(server::run(host, port, open)); + runtime.block_on(server::run(host, port, open, yes_expose)); } /// Best-effort: open a URL in the default browser. Never panics. diff --git a/src/dashboard/server.rs b/src/dashboard/server.rs index b747d11ef..9b03c1507 100644 --- a/src/dashboard/server.rs +++ b/src/dashboard/server.rs @@ -106,7 +106,17 @@ struct CostQuery { async fn cost_handler(Query(q): Query) -> Response { let days = q.days.unwrap_or(1); - let by = q.by.as_deref().unwrap_or("model"); + let by = match q.by.as_deref() { + None | Some("model") => "model", + Some("project") => "project", + Some(other) => { + return ( + StatusCode::BAD_REQUEST, + format!("invalid `by` value {other:?}; expected \"model\" or \"project\""), + ) + .into_response(); + } + }; ( [(header::CONTENT_TYPE, "application/json")], crate::dashboard::data::cost_json(days, by), @@ -232,14 +242,25 @@ pub fn router() -> Router { .fallback(static_handler) } -pub async fn run(host: &str, port: u16, open: bool) { +fn is_local_bind(host: &str) -> bool { + matches!(host, "127.0.0.1" | "localhost" | "::1") +} + +pub async fn run(host: &str, port: u16, open: bool, yes_expose: bool) { + if !is_local_bind(host) && !yes_expose { + eprintln!( + "refusing to bind to {host}: this would expose all PM/cost/webhook data with no authentication." + ); + eprintln!("pass --yes-expose to bind anyway (trusted networks only)."); + std::process::exit(1); + } let listener = tokio::net::TcpListener::bind((host, port)) .await .expect("failed to bind dashboard server"); let addr = listener.local_addr().expect("no local addr"); let url = format!("http://{addr}"); eprintln!("agentflare dashboard listening on {url}"); - if host != "127.0.0.1" && host != "localhost" { + if !is_local_bind(host) { eprintln!(" warning: bound to {host} — anyone on your network can view this"); } if open { @@ -271,4 +292,144 @@ mod tests { .unwrap(); assert!(body.starts_with('['), "expected JSON array, got: {body}"); } + + #[test] + fn is_local_bind_recognizes_loopback_only() { + assert!(is_local_bind("127.0.0.1")); + assert!(is_local_bind("localhost")); + assert!(is_local_bind("::1")); + assert!(!is_local_bind("0.0.0.0")); + assert!(!is_local_bind("192.168.1.5")); + } + + #[test] + fn mime_for_maps_known_extensions() { + assert_eq!(mime_for("index.html"), "text/html; charset=utf-8"); + assert_eq!(mime_for("app.js"), "text/javascript; charset=utf-8"); + assert_eq!(mime_for("app.css"), "text/css; charset=utf-8"); + assert_eq!(mime_for("logo.png"), "application/octet-stream"); + } + + #[tokio::test] + async fn static_handler_serves_index_at_root() { + let resp = static_handler(Uri::from_static("/")).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE).unwrap(), + "text/html; charset=utf-8" + ); + } + + #[tokio::test] + async fn static_handler_404s_unknown_path() { + let resp = static_handler(Uri::from_static("/does-not-exist.xyz")).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn static_handler_404s_dot_dot_path_instead_of_escaping_assets() { + // Embedded assets have no ".." keys, so a literal ".." in the path can + // never escape `dashboard/web/` — it just misses the lookup. + let resp = static_handler(Uri::from_static("/../Cargo.toml")).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn cost_handler_defaults_to_one_day_grouped_by_model() { + let resp = cost_handler(Query(CostQuery { + days: None, + by: None, + })) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + // Live analytics data (this machine's own ~/.claude/projects usage) + // can change between calls, so assert on shape rather than an exact + // second snapshot — this still proves the days=1/by=model defaults + // reached `cost_json` without a 400/500. + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(v.get("groups").is_some(), "expected groups in {v}"); + assert!( + v.get("total_cost_usd").is_some(), + "expected total_cost_usd in {v}" + ); + assert!( + v.get("any_unpriced").is_some(), + "expected any_unpriced in {v}" + ); + } + + #[tokio::test] + async fn cost_handler_rejects_unknown_by_value() { + let resp = cost_handler(Query(CostQuery { + days: None, + by: Some("projct".into()), + })) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn events_endpoint_streams_claims_and_cost_snapshot() { + use tokio_stream::StreamExt as _; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router()).await.unwrap(); + }); + let resp = reqwest::get(format!("http://{addr}/events")).await.unwrap(); + let mut stream = resp.bytes_stream(); + let first = tokio::time::timeout(std::time::Duration::from_secs(10), stream.next()) + .await + .expect("first SSE frame within 10s") + .expect("stream item") + .unwrap(); + let text = String::from_utf8(first.to_vec()).unwrap(); + let data_line = text + .lines() + .find_map(|l| l.strip_prefix("data: ")) + .expect("SSE data line"); + let v: serde_json::Value = serde_json::from_str(data_line).unwrap(); + assert!(v.get("claims").is_some(), "expected claims field in {v}"); + assert!( + v.get("cost_today").is_some(), + "expected cost_today field in {v}" + ); + } + + #[tokio::test] + async fn run_serves_normally_on_local_bind_without_yes_expose() { + // Probe an ephemeral port, then hand that exact port to `run()` — + // `run()` takes a fixed port rather than returning the bound address, + // so this is the only way to know where to connect afterward. Only + // the local-bind path is exercised here; the non-local refusal path + // calls `std::process::exit`, which isn't safe to trigger in-process + // inside the test binary. + let port = { + let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + probe.local_addr().unwrap().port() + }; + tokio::spawn(run("127.0.0.1", port, false, false)); + let mut started = false; + for _ in 0..50 { + if reqwest::get(format!("http://127.0.0.1:{port}/api/claims")) + .await + .is_ok() + { + started = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!( + started, + "expected dashboard to serve on a local bind without --yes-expose" + ); + } }