feat: embedded management API (axum, port 9090)

Same binary, same process, two listeners:
  Port 2222: SSH proxy (russh)
  Port 9090: Management API (axum)

API endpoints:
  GET /api/sessions         — active sessions
  GET /api/sessions/history — recent history (last 500)
  GET /api/stats            — aggregate analytics
  GET /api/health           — server health + version
  GET /api/info             — server capabilities

Session tracking:
  Arc<SessionStore> shared between SSH handler and API
  In-memory: active sessions + 500-session history ring buffer
  Tracks: auth breakdown, peak concurrent, TPM attested %

Feature flag:
  --features dashboard (default on) — includes axum + tower-http
  --no-default-features — SSH-only, no HTTP dependency

Config:
  [dashboard] section: enabled, listen address

All smoke tests pass. 0 substrate deps.

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler King 2026-04-05 15:09:26 -04:00
parent 04dd74d15f
commit 72fa8cee92
6 changed files with 319 additions and 51 deletions

119
Cargo.lock generated
View file

@ -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"

View file

@ -51,6 +51,9 @@ pub struct BasculeConfig {
/// Prometheus metrics.
#[serde(default)]
pub metrics: MetricsConfig,
/// Dashboard / management API.
pub dashboard: Option<DashboardConfig>,
}
#[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()
}

View file

@ -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<String>,
pub connected_at: String,
pub tpm_attested: bool,
pub attestation_hash: Option<String>,
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<String, u64>,
pub backend_breakdown: HashMap<String, u64>,
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<RwLock<HashMap<String, DashboardSession>>>,
pub stats: Arc<RwLock<DashboardStats>>,
active: Arc<RwLock<HashMap<String, StoredSession>>>,
history: Arc<RwLock<Vec<StoredSession>>>,
total: Arc<RwLock<u64>>,
tpm_count: Arc<RwLock<u64>>,
peak: Arc<RwLock<usize>>,
failed_auth: Arc<RwLock<u64>>,
}
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<StoredSession> {
self.active.read().await.values().cloned().collect()
}
pub async fn recent_history(&self, limit: usize) -> Vec<StoredSession> {
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() }
}

View file

@ -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 }

View file

@ -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<SessionStore>) -> 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<Arc<SessionStore>>,
) -> Json<serde_json::Value> {
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<Arc<SessionStore>>,
) -> Json<serde_json::Value> {
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<Arc<SessionStore>>,
) -> Json<serde_json::Value> {
let stats = store.stats().await;
Json(serde_json::json!(stats))
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "healthy",
"version": env!("CARGO_PKG_VERSION"),
}))
}
async fn server_info() -> Json<serde_json::Value> {
Json(serde_json::json!({
"name": "bascule",
"version": env!("CARGO_PKG_VERSION"),
"features": {
"backends": ["pty", "proxy", "container"],
"auth": ["authorized-keys", "accept-all"],
"dashboard": true,
}
}))
}

View file

@ -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<dyn AuthProvider> {
}
};
// 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