use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
extract::{connect_info::IntoMakeServiceWithConnectInfo, MatchedPath, Request, State},
http::{header::AUTHORIZATION, Method, StatusCode},
middleware::{self, Next},
response::Response,
routing::{any, get, post},
Router,
};
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use super::handlers::{
api_info, create_session, delete_session, execute_command, execute_oneshot, get_session,
health, list_sessions, AppState,
};
use super::websocket::{ws_handler, ws_oneshot_handler};
use crate::security::{
rate_limit_middleware, ApiKeyStore, AuthConfig, CapabilitySet, RateLimitConfig, RateLimiter,
};
#[derive(Debug, Clone, Default)]
pub struct CorsConfig {
pub allow_any: bool,
}
#[derive(Debug, Clone)]
pub struct SecurityConfig {
pub auth: AuthConfig,
pub rate_limit: RateLimitConfig,
pub api_keys: Vec<String>,
pub capabilities: Option<CapabilitySet>,
pub cors: CorsConfig,
pub allowed_hosts: Option<Vec<String>>,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
auth: AuthConfig::disabled(), rate_limit: RateLimitConfig::default(),
api_keys: Vec::new(),
capabilities: None, cors: CorsConfig::default(), allowed_hosts: None,
}
}
}
fn cors_layer(cfg: &CorsConfig) -> Option<CorsLayer> {
cfg.allow_any.then(|| {
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
})
}
impl SecurityConfig {
pub fn secure() -> Self {
Self {
auth: AuthConfig::default(),
rate_limit: RateLimitConfig::default(),
api_keys: Vec::new(),
capabilities: None,
cors: CorsConfig::default(),
allowed_hosts: None,
}
}
pub fn development() -> Self {
Self {
auth: AuthConfig::disabled(),
rate_limit: RateLimitConfig::relaxed(),
api_keys: Vec::new(),
capabilities: None,
cors: CorsConfig::default(),
allowed_hosts: None,
}
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_keys.push(key.into());
self
}
pub fn with_capabilities(mut self, capabilities: CapabilitySet) -> Self {
self.capabilities = Some(capabilities);
self
}
pub fn with_allowed_hosts(mut self, hosts: Vec<String>) -> Self {
self.allowed_hosts = Some(hosts);
self
}
pub fn with_cors_allow_any(mut self) -> Self {
self.cors.allow_any = true;
self
}
}
fn register_key(store: &ApiKeyStore, key: &str, capabilities: &Option<CapabilitySet>) {
match capabilities {
Some(caps) => store.add_key_with_capabilities(key, caps.clone(), "configured"),
None => store.add_key(key),
}
}
pub fn create_router() -> Router {
create_router_with_state(AppState::new())
}
pub fn create_router_with_state(state: AppState) -> Router {
let session_routes = Router::new()
.route("/", get(list_sessions).post(create_session))
.route("/{id}", get(get_session).delete(delete_session))
.route("/{id}/execute", post(execute_command))
.route("/{id}/ws", any(ws_handler));
let api_v1 = Router::new()
.route("/", get(api_info))
.route("/execute", post(execute_oneshot))
.route("/ws", any(ws_oneshot_handler))
.nest("/sessions", session_routes);
Router::new()
.route("/health", get(health))
.nest("/api/v1", api_v1)
.layer(TraceLayer::new_for_http())
.with_state(state)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequiredCapability {
Public,
Authenticated,
Capability(&'static str),
}
pub fn required_capability(method: &Method, matched_path: &str) -> RequiredCapability {
use RequiredCapability::{Authenticated, Capability, Public};
match (method, matched_path) {
(_, "/health") => Public,
(&Method::GET, "/api/v1") => Authenticated,
(&Method::POST, "/api/v1/execute") => Capability("exec"),
(_, "/api/v1/ws") => Capability("exec"),
(&Method::GET, "/api/v1/sessions") => Capability("session.read"),
(&Method::POST, "/api/v1/sessions") => Capability("session.manage"),
(&Method::GET, "/api/v1/sessions/{id}") => Capability("session.read"),
(&Method::DELETE, "/api/v1/sessions/{id}") => Capability("session.manage"),
(&Method::POST, "/api/v1/sessions/{id}/execute") => Capability("exec"),
(_, "/api/v1/sessions/{id}/ws") => Capability("exec"),
_ => Authenticated,
}
}
async fn capability_auth_middleware(
State((store, audit)): State<(
std::sync::Arc<ApiKeyStore>,
std::sync::Arc<crate::audit::AuditSink>,
)>,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if !store.is_enabled() {
return Ok(next.run(request).await);
}
let method = request.method().clone();
let matched = request
.extensions()
.get::<MatchedPath>()
.map(|m| m.as_str().to_owned())
.unwrap_or_default();
let required = required_capability(&method, &matched);
if required == RequiredCapability::Public {
return Ok(next.run(request).await);
}
let token = request
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|header| store.extract_key(header));
let identity = token.as_deref().and_then(|t| store.identity(t));
let capabilities = match token.as_deref().and_then(|t| store.capabilities(t)) {
Some(caps) => caps,
None => {
let reason = if token.is_none() {
"missing-token"
} else {
"invalid-token"
};
tracing::debug!(%method, path = %matched, reason, "auth rejected (401)");
audit.record(
crate::audit::AuditEvent::new("denied")
.with_route(format!("{method} {matched}"))
.with_denial(401, reason),
);
return Err(StatusCode::UNAUTHORIZED);
}
};
if let Some(identity) = identity.clone() {
request.extensions_mut().insert(identity);
}
match required {
RequiredCapability::Public => Ok(next.run(request).await),
RequiredCapability::Authenticated => Ok(next.run(request).await),
RequiredCapability::Capability(cap) => {
if capabilities.satisfies(cap) {
Ok(next.run(request).await)
} else {
audit.record(
crate::audit::AuditEvent::new("denied")
.with_identity(identity)
.with_route(format!("{method} {matched}"))
.with_denial(403, format!("missing-capability:{cap}")),
);
tracing::debug!(
%method,
path = %matched,
required = cap,
"authorization denied (403): insufficient capability"
);
Err(StatusCode::FORBIDDEN)
}
}
}
}
fn host_is_allowed(header: Option<&str>, allowed: &[String]) -> bool {
let Some(value) = header else {
return false;
};
let host = value
.rsplit_once(':')
.map_or(value, |(host, port)| {
if port.chars().all(|c| c.is_ascii_digit()) {
host
} else {
value
}
})
.trim_matches(|c| c == '[' || c == ']');
allowed
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(host))
}
async fn host_check_middleware(
State(allowed): State<Arc<Vec<String>>>,
request: Request,
next: Next,
) -> Result<Response, (StatusCode, String)> {
let header = request
.headers()
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok());
if host_is_allowed(header, &allowed) {
return Ok(next.run(request).await);
}
let seen = header.unwrap_or("(none)").to_string();
tracing::debug!(host = %seen, "request refused: host not allowed");
Err((
StatusCode::FORBIDDEN,
format!(
"host {seen} is not allowed; pass --allow-host {seen} to permit it
"
),
))
}
pub fn create_secure_router(
state: AppState,
security: SecurityConfig,
) -> (Router, Arc<ApiKeyStore>, Arc<RateLimiter>) {
let auth_store = Arc::new(ApiKeyStore::new(security.auth));
let rate_limiter = Arc::new(RateLimiter::new(security.rate_limit));
for key in &security.api_keys {
register_key(&auth_store, key, &security.capabilities);
}
let session_routes = Router::new()
.route("/", get(list_sessions).post(create_session))
.route("/{id}", get(get_session).delete(delete_session))
.route("/{id}/execute", post(execute_command))
.route("/{id}/ws", any(ws_handler));
let api_v1 = Router::new()
.route("/", get(api_info))
.route("/execute", post(execute_oneshot))
.route("/ws", any(ws_oneshot_handler))
.nest("/sessions", session_routes);
let allowed_hosts = security.allowed_hosts.clone();
let mut router = Router::new()
.route("/health", get(health))
.nest("/api/v1", api_v1)
.layer(middleware::from_fn_with_state(
(Arc::clone(&auth_store), Arc::clone(&state.audit)),
capability_auth_middleware,
))
.layer(middleware::from_fn_with_state(
Arc::clone(&rate_limiter),
rate_limit_middleware,
))
.layer(TraceLayer::new_for_http());
if let Some(hosts) = allowed_hosts {
router = router.layer(middleware::from_fn_with_state(
Arc::new(hosts),
host_check_middleware,
));
}
if let Some(cors) = cors_layer(&security.cors) {
router = router.layer(cors);
}
let router = router.with_state(state);
(router, auth_store, rate_limiter)
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub security: SecurityConfig,
pub graceful_shutdown: bool,
}
impl ServerConfig {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
security: SecurityConfig::default(),
graceful_shutdown: true,
}
}
pub fn bind_address(&self) -> String {
format!("{}:{}", self.host, self.port)
}
pub fn with_security(mut self, security: SecurityConfig) -> Self {
self.security = security;
self
}
pub fn without_graceful_shutdown(mut self) -> Self {
self.graceful_shutdown = false;
self
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 3000,
security: SecurityConfig::default(),
graceful_shutdown: true,
}
}
}
pub async fn serve(config: ServerConfig) -> crate::Result<()> {
serve_with_state(config, AppState::new()).await
}
pub async fn bind(config: &ServerConfig) -> crate::Result<tokio::net::TcpListener> {
tokio::net::TcpListener::bind(config.bind_address())
.await
.map_err(crate::error::ShellTunnelError::Io)
}
pub async fn serve_with_state(config: ServerConfig, state: AppState) -> crate::Result<()> {
let listener = bind(&config).await?;
serve_on(listener, config, state).await
}
pub async fn serve_on(
listener: tokio::net::TcpListener,
config: ServerConfig,
state: AppState,
) -> crate::Result<()> {
let addr = config.bind_address();
let (router, auth_store, _rate_limiter) = create_secure_router(state, config.security.clone());
if auth_store.is_enabled() {
if auth_store.count() == 0 {
let key = crate::security::generate_api_key();
register_key(&auth_store, &key, &config.security.capabilities);
tracing::info!("Generated API key: {}", key);
}
tracing::info!(
"Authentication enabled with {} API key(s)",
auth_store.count()
);
} else {
tracing::warn!("Authentication is DISABLED - server is open to all requests");
}
let _ = addr;
tracing::info!(
"Starting shell-tunnel API server on {}",
listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| config.bind_address())
);
let service: IntoMakeServiceWithConnectInfo<Router, SocketAddr> =
router.into_make_service_with_connect_info::<SocketAddr>();
if config.graceful_shutdown {
axum::serve(listener, service)
.with_graceful_shutdown(shutdown_signal())
.await
.map_err(|e| {
crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
})?;
tracing::info!("Server shutdown complete");
} else {
axum::serve(listener, service).await.map_err(|e| {
crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
})?;
}
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {
tracing::info!("Received Ctrl+C, initiating graceful shutdown...");
}
_ = terminate => {
tracing::info!("Received SIGTERM, initiating graceful shutdown...");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_server_config_default() {
let config = ServerConfig::default();
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 3000);
assert_eq!(config.bind_address(), "127.0.0.1:3000");
assert!(config.graceful_shutdown);
}
#[test]
fn test_server_config_custom() {
let config = ServerConfig::new("0.0.0.0", 8080);
assert_eq!(config.bind_address(), "0.0.0.0:8080");
}
#[test]
fn test_server_config_with_security() {
let config = ServerConfig::new("0.0.0.0", 8080)
.with_security(SecurityConfig::secure().with_api_key("test-key"));
assert!(config.security.auth.enabled);
assert_eq!(config.security.api_keys.len(), 1);
}
#[test]
fn test_security_config_default() {
let config = SecurityConfig::default();
assert!(!config.auth.enabled); assert!(config.rate_limit.enabled);
}
#[test]
fn test_security_config_secure() {
let config = SecurityConfig::secure();
assert!(config.auth.enabled);
assert!(config.rate_limit.enabled);
}
#[test]
fn test_cors_restrictive_by_default() {
assert!(!SecurityConfig::default().cors.allow_any);
assert!(!SecurityConfig::secure().cors.allow_any);
assert!(cors_layer(&CorsConfig::default()).is_none());
}
#[test]
fn test_cors_allow_any_opt_in() {
let config = SecurityConfig::development().with_cors_allow_any();
assert!(config.cors.allow_any);
assert!(cors_layer(&config.cors).is_some());
}
#[test]
fn test_security_config_development() {
let config = SecurityConfig::development();
assert!(!config.auth.enabled);
assert!(config.rate_limit.enabled);
}
#[test]
fn test_router_creation() {
let _router = create_router();
}
#[test]
fn test_required_capability_mapping() {
use RequiredCapability::{Authenticated, Capability, Public};
assert_eq!(required_capability(&Method::GET, "/health"), Public);
assert_eq!(required_capability(&Method::GET, "/api/v1"), Authenticated);
assert_eq!(
required_capability(&Method::POST, "/api/v1/execute"),
Capability("exec")
);
assert_eq!(
required_capability(&Method::GET, "/api/v1/ws"),
Capability("exec")
);
assert_eq!(
required_capability(&Method::POST, "/api/v1/sessions/{id}/execute"),
Capability("exec")
);
assert_eq!(
required_capability(&Method::GET, "/api/v1/sessions/{id}/ws"),
Capability("exec")
);
assert_eq!(
required_capability(&Method::GET, "/api/v1/sessions"),
Capability("session.read")
);
assert_eq!(
required_capability(&Method::POST, "/api/v1/sessions"),
Capability("session.manage")
);
assert_eq!(
required_capability(&Method::GET, "/api/v1/sessions/{id}"),
Capability("session.read")
);
assert_eq!(
required_capability(&Method::DELETE, "/api/v1/sessions/{id}"),
Capability("session.manage")
);
}
#[test]
fn test_required_capability_unknown_fails_closed() {
assert_eq!(
required_capability(&Method::GET, "/api/v1/unknown"),
RequiredCapability::Authenticated
);
}
#[test]
fn test_secure_router_creation() {
let state = AppState::new();
let security = SecurityConfig::secure().with_api_key("test-key");
let (router, auth_store, rate_limiter) = create_secure_router(state, security);
assert_eq!(auth_store.count(), 1);
assert!(auth_store.is_valid("test-key"));
assert!(rate_limiter.is_enabled());
drop(router);
}
}