diff --git a/Cargo.lock b/Cargo.lock index 2b996b3..959ffa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "bascule-auth-agent-id" version = "0.1.0" @@ -214,10 +266,13 @@ name = "bascule-server" version = "0.1.0" dependencies = [ "anyhow", + "axum", "bascule-auth-agent-id", "bascule-core", "clap", + "serde_json", "tokio", + "tower-http", "tracing", "tracing-subscriber", ] @@ -1636,12 +1691,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1655,6 +1722,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2062,6 +2130,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md5" version = "0.7.0" @@ -2083,6 +2157,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3114,6 +3204,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_qs" version = "0.12.0" @@ -3761,6 +3862,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3771,14 +3873,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3799,6 +3911,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3908,6 +4021,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/bascule-core/src/config.rs b/crates/bascule-core/src/config.rs index cce739a..756c777 100644 --- a/crates/bascule-core/src/config.rs +++ b/crates/bascule-core/src/config.rs @@ -51,6 +51,9 @@ pub struct BasculeConfig { /// Prometheus metrics. #[serde(default)] pub metrics: MetricsConfig, + + /// Dashboard / management API. + pub dashboard: Option, } #[derive(Debug, Deserialize, Clone)] @@ -125,6 +128,7 @@ impl Default for BasculeConfig { k8s: None, telemetry: TelemetryConfig::default(), metrics: MetricsConfig::default(), + dashboard: None, } } } @@ -350,6 +354,19 @@ fn default_metrics_port() -> u16 { 9090 } +/// Dashboard / management API configuration. +#[derive(Debug, Deserialize, Clone)] +pub struct DashboardConfig { + /// Enable the management API. + #[serde(default = "default_true")] + pub enabled: bool, + /// Listen address. + #[serde(default = "default_dashboard_listen")] + pub listen: String, +} + +fn default_dashboard_listen() -> String { "0.0.0.0:9090".to_string() } + fn default_runtime() -> String { "auto".to_string() } diff --git a/crates/bascule-core/src/store.rs b/crates/bascule-core/src/store.rs index 0b7b344..b7d4100 100644 --- a/crates/bascule-core/src/store.rs +++ b/crates/bascule-core/src/store.rs @@ -1,84 +1,142 @@ -//! Session store — tracks active and historical sessions for the dashboard. +//! Session store — tracks active and historical sessions for the management API. use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -/// A session as the dashboard sees it. +use crate::session::SessionInfo; + +/// A session as the API exposes it. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DashboardSession { - pub id: String, +pub struct StoredSession { + pub session_id: String, pub principal: String, pub auth_method: String, pub source_ip: String, pub backend: String, - pub container_image: Option, pub connected_at: String, pub tpm_attested: bool, pub attestation_hash: Option, pub commands_executed: u64, } -/// Aggregate stats for the dashboard. +/// Aggregate stats. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -pub struct DashboardStats { +pub struct SessionStats { pub active_sessions: usize, - pub total_sessions_24h: u64, + pub total_sessions: u64, pub auth_breakdown: HashMap, pub backend_breakdown: HashMap, pub attested_percentage: f64, - pub failed_auth_24h: u64, + pub failed_auth: u64, + pub peak_concurrent: usize, } -/// In-memory session store updated by SessionHandler. +/// In-memory session store shared between SSH handler and management API. #[derive(Clone)] pub struct SessionStore { - pub active: Arc>>, - pub stats: Arc>, + active: Arc>>, + history: Arc>>, + total: Arc>, + tpm_count: Arc>, + peak: Arc>, + failed_auth: Arc>, } impl SessionStore { pub fn new() -> Self { Self { active: Arc::new(RwLock::new(HashMap::new())), - stats: Arc::new(RwLock::new(DashboardStats::default())), + history: Arc::new(RwLock::new(Vec::new())), + total: Arc::new(RwLock::new(0)), + tpm_count: Arc::new(RwLock::new(0)), + peak: Arc::new(RwLock::new(0)), + failed_auth: Arc::new(RwLock::new(0)), } } - pub async fn add_session(&self, session: DashboardSession) { - let method = session.auth_method.clone(); - let backend = session.backend.clone(); - let attested = session.tpm_attested; + pub async fn session_started(&self, info: &SessionInfo) { + let session = StoredSession { + session_id: info.session_id.clone(), + principal: info.principal.clone(), + auth_method: info.auth_method.clone(), + source_ip: info.source_ip.clone(), + backend: "pty".to_string(), + connected_at: info.connected_at.to_rfc3339(), + tpm_attested: false, + attestation_hash: None, + commands_executed: 0, + }; - self.active.write().await.insert(session.id.clone(), session); + let mut active = self.active.write().await; + active.insert(info.session_id.clone(), session); - let mut stats = self.stats.write().await; - stats.active_sessions = self.active.read().await.len(); - stats.total_sessions_24h += 1; - *stats.auth_breakdown.entry(method).or_insert(0) += 1; - *stats.backend_breakdown.entry(backend).or_insert(0) += 1; + *self.total.write().await += 1; - // Recalculate attested percentage - let total = stats.total_sessions_24h as f64; - if attested { - stats.attested_percentage = ((stats.attested_percentage * (total - 1.0) + 100.0) / total).min(100.0); - } else if total > 0.0 { - stats.attested_percentage = (stats.attested_percentage * (total - 1.0)) / total; + let count = active.len(); + let mut peak = self.peak.write().await; + if count > *peak { *peak = count; } + } + + pub async fn session_ended(&self, session_id: &str) { + let mut active = self.active.write().await; + if let Some(session) = active.remove(session_id) { + let mut history = self.history.write().await; + history.push(session); + let excess = history.len().saturating_sub(500); + if excess > 0 { history.drain(0..excess); } } } - pub async fn remove_session(&self, id: &str) { - self.active.write().await.remove(id); - self.stats.write().await.active_sessions = self.active.read().await.len(); + pub async fn command_executed(&self, session_id: &str) { + let mut active = self.active.write().await; + if let Some(s) = active.get_mut(session_id) { + s.commands_executed += 1; + } } - pub async fn record_auth_failure(&self) { - self.stats.write().await.failed_auth_24h += 1; + pub async fn auth_failed(&self) { + *self.failed_auth.write().await += 1; + } + + pub async fn active_sessions(&self) -> Vec { + self.active.read().await.values().cloned().collect() + } + + pub async fn recent_history(&self, limit: usize) -> Vec { + let h = self.history.read().await; + let start = h.len().saturating_sub(limit); + h[start..].to_vec() + } + + pub async fn stats(&self) -> SessionStats { + let active = self.active.read().await; + let total = *self.total.read().await; + let peak = *self.peak.read().await; + let failed = *self.failed_auth.read().await; + let tpm = *self.tpm_count.read().await; + + let mut auth = HashMap::new(); + let mut backend = HashMap::new(); + for s in active.values() { + *auth.entry(s.auth_method.clone()).or_insert(0u64) += 1; + *backend.entry(s.backend.clone()).or_insert(0u64) += 1; + } + + let attested_pct = if total > 0 { (tpm as f64 / total as f64) * 100.0 } else { 0.0 }; + + SessionStats { + active_sessions: active.len(), + total_sessions: total, + auth_breakdown: auth, + backend_breakdown: backend, + attested_percentage: attested_pct, + failed_auth: failed, + peak_concurrent: peak, + } } } impl Default for SessionStore { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } diff --git a/crates/bascule-server/Cargo.toml b/crates/bascule-server/Cargo.toml index 21d696f..641390c 100644 --- a/crates/bascule-server/Cargo.toml +++ b/crates/bascule-server/Cargo.toml @@ -10,9 +10,9 @@ name = "bascule" path = "src/main.rs" [features] -default = [] +default = ["dashboard"] agent-id = ["dep:bascule-auth-agent-id"] -# telemetry = [] — OTel export deferred (version compatibility WIP) +dashboard = ["dep:axum", "dep:tower-http"] [dependencies] bascule-core = { path = "../bascule-core" } @@ -22,6 +22,8 @@ clap = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } anyhow = { workspace = true } +serde_json = { workspace = true } -# OTel export deferred — version compatibility WIP -# opentelemetry, opentelemetry-otlp, opentelemetry_sdk, tracing-opentelemetry +# Management API (optional, default on) +axum = { version = "0.8", optional = true } +tower-http = { version = "0.6", features = ["fs", "cors"], optional = true } diff --git a/crates/bascule-server/src/api.rs b/crates/bascule-server/src/api.rs new file mode 100644 index 0000000..e7a6f85 --- /dev/null +++ b/crates/bascule-server/src/api.rs @@ -0,0 +1,56 @@ +//! Management API — axum HTTP server for dashboard and monitoring. + +use axum::{routing::get, Json, Router}; +use bascule_core::store::SessionStore; +use std::sync::Arc; + +/// Build the management API router. +pub fn management_api(store: Arc) -> Router { + Router::new() + .route("/api/sessions", get(list_sessions)) + .route("/api/sessions/history", get(session_history)) + .route("/api/stats", get(get_stats)) + .route("/api/health", get(health)) + .route("/api/info", get(server_info)) + .with_state(store) +} + +async fn list_sessions( + axum::extract::State(store): axum::extract::State>, +) -> Json { + let sessions = store.active_sessions().await; + Json(serde_json::json!({ "sessions": sessions, "count": sessions.len() })) +} + +async fn session_history( + axum::extract::State(store): axum::extract::State>, +) -> Json { + let history = store.recent_history(100).await; + Json(serde_json::json!({ "sessions": history, "count": history.len() })) +} + +async fn get_stats( + axum::extract::State(store): axum::extract::State>, +) -> Json { + let stats = store.stats().await; + Json(serde_json::json!(stats)) +} + +async fn health() -> Json { + Json(serde_json::json!({ + "status": "healthy", + "version": env!("CARGO_PKG_VERSION"), + })) +} + +async fn server_info() -> Json { + Json(serde_json::json!({ + "name": "bascule", + "version": env!("CARGO_PKG_VERSION"), + "features": { + "backends": ["pty", "proxy", "container"], + "auth": ["authorized-keys", "accept-all"], + "dashboard": true, + } + })) +} diff --git a/crates/bascule-server/src/main.rs b/crates/bascule-server/src/main.rs index 091f4aa..a6cf1ec 100644 --- a/crates/bascule-server/src/main.rs +++ b/crates/bascule-server/src/main.rs @@ -17,6 +17,9 @@ use bascule_core::config::BasculeConfig; use bascule_core::hooks::DefaultHandler; use bascule_core::server::BasculeServer; +#[cfg(feature = "dashboard")] +mod api; + #[derive(Parser)] #[command(name = "bascule", about = "Identity-aware SSH proxy")] struct Cli { @@ -68,7 +71,6 @@ fn build_auth_provider(config: &BasculeConfig) -> Arc { } }; - // If agent_id is also configured, compose: SSH keys + Agent ID token-as-password #[cfg(feature = "agent-id")] if let Some(ref agent_config) = config.auth.agent_id { tracing::info!(tenant = %agent_config.tenant_id, "Entra Agent ID auth enabled (composite)"); @@ -102,27 +104,41 @@ async fn main() -> Result<()> { init_tracing(&config); - // Validate container config at startup (fail fast on bad values) if let Some(ref container_config) = config.container { container_config.validate()?; } - let backend = if config.proxy.is_some() { - "proxy" - } else if config.container.is_some() { - "container" - } else { - "pty" - }; + let backend = if config.proxy.is_some() { "proxy" } + else if config.container.is_some() { "container" } + else { "pty" }; tracing::info!( listen = %config.listen_addr, auth = %config.auth.mode, backend = %backend, - shell = ?config.shell_command, "Bascule starting" ); + // Start management API if dashboard feature is enabled + #[cfg(feature = "dashboard")] + { + let store = bascule_core::store::SessionStore::new(); + let store_arc = Arc::new(store); + + let mgmt_listen = config.dashboard.as_ref() + .map(|d| d.listen.clone()) + .unwrap_or_else(|| "0.0.0.0:9090".to_string()); + + let api_store = store_arc.clone(); + tokio::spawn(async move { + let router = api::management_api(api_store); + let listener = tokio::net::TcpListener::bind(&mgmt_listen).await + .expect("Failed to bind management API"); + tracing::info!(listen = %mgmt_listen, "Management API started"); + axum::serve(listener, router).await.expect("Management API failed"); + }); + } + let auth = build_auth_provider(&config); let server = BasculeServer::with_arc_auth(config, auth, DefaultHandler)?; server.run().await