Files
rustguac/src/main.rs
T
Dave Kempe ac77bfab98 v0.6.1: Vault mTLS support, comprehensive test suite (87 tests)
Vault/OpenBao mTLS:
- Add ca_cert, client_cert, client_key fields to [vault] config
- Supports custom CA certificates and mutual TLS authentication
- Clear error messages for missing files, invalid PEM, missing key
- Fixes #48 (OpenBao requiring client certificates)

Test suite (8 → 87 tests):
- vault: 13 tests (TLS client builder, config deserialization, name validation)
- auth: 11 tests (role hierarchy, effective role capping, XFF/trusted proxy, has_role)
- session: 12 tests (CIDR network checks, autofill JSON parsing, placeholder substitution)
- browser: 8 tests (Chromium password encryption, Login Data SQLite, RangeAllocator)
- config: 8 tests (preset resolution, theme overrides, defaults, vault config)
- api: 6 tests (HTML escaping, recording name path traversal protection)
- db: 7 tests (SHA-256 hashing, key generation, user groups parsing)
- import: 12 tests (already existed, now wired into module tree)
- protocol: 8 tests (already existed)

Other:
- Wire import.rs into module tree (fixes orphaned tests)
- Document mTLS config in configuration.md and integrations.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:44:46 +11:00

917 lines
33 KiB
Rust

mod api;
mod auth;
mod browser;
mod config;
mod db;
mod drive;
mod guacd;
mod import;
mod oidc;
mod protocol;
mod recording;
mod session;
mod tunnel;
mod vault;
mod websocket;
use crate::api::{AppState, OidcEnabled, SiteTitle, ThemeData, VaultConfigured, VaultState};
use crate::config::Config;
use crate::db::Db;
use crate::session::SessionManager;
use axum::extract::{DefaultBodyLimit, Request};
use axum::response::Html;
use axum::response::Response;
use axum::routing::{delete, get, post, put};
use axum::{middleware, Extension, Router};
use clap::{Parser, Subcommand};
use std::net::SocketAddr;
use std::sync::Arc;
use tower_governor::{
governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
};
use tower_http::services::ServeDir;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "rustguac", about = "Lightweight Guacamole SSH proxy")]
struct Cli {
/// Path to TOML config file
#[arg(short, long)]
config: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Run the server (default)
Serve,
/// Create a new admin with an API key
AddAdmin {
/// Admin name (unique)
#[arg(long)]
name: String,
/// Comma-separated allowed IP CIDRs (e.g. "10.0.0.0/8,192.168.1.0/24")
#[arg(long)]
allowed_ips: Option<String>,
/// Expiry date in ISO 8601 format (e.g. "2025-12-31T23:59:59Z")
#[arg(long)]
expires: Option<String>,
},
/// List all admin accounts
ListAdmins,
/// Disable an admin account
DisableAdmin {
#[arg(long)]
name: String,
},
/// Enable an admin account
EnableAdmin {
#[arg(long)]
name: String,
},
/// Delete an admin account permanently
DeleteAdmin {
#[arg(long)]
name: String,
},
/// Rotate an admin's API key (generates new key, invalidates old)
RotateKey {
#[arg(long)]
name: String,
},
/// Generate a self-signed TLS certificate for development/testing
GenerateCert {
/// Hostname for the certificate (e.g. "rustguac.example.com")
#[arg(long)]
hostname: String,
/// Output directory for cert.pem and key.pem
#[arg(long, default_value = ".")]
out_dir: String,
/// Additional Subject Alternative Names (hostnames or IPs). localhost and 127.0.0.1 are always included.
#[arg(long = "san")]
extra_sans: Vec<String>,
},
/// List all OIDC users
ListUsers,
/// Set a user's role
SetRole {
/// User email
#[arg(long)]
email: String,
/// Role: admin, poweruser, operator, or viewer
#[arg(long)]
role: String,
},
/// Disable an OIDC user
DisableUser {
#[arg(long)]
email: String,
},
/// Delete an OIDC user
DeleteUser {
#[arg(long)]
email: String,
},
}
#[tokio::main]
async fn main() {
// Install rustls crypto provider before any TLS usage (reqwest, axum-server, etc.)
// Required when both ring and aws-lc-rs features are present.
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let cli = Cli::parse();
// Load config
let config = Config::load(cli.config.as_deref());
// Open database
let database = db::init_db(&config.db_path).expect("Failed to open database");
match cli.command {
None | Some(Command::Serve) => run_server(config, database).await,
Some(Command::AddAdmin {
name,
allowed_ips,
expires,
}) => {
cmd_add_admin(&database, &name, allowed_ips.as_deref(), expires.as_deref());
}
Some(Command::ListAdmins) => cmd_list_admins(&database),
Some(Command::DisableAdmin { name }) => cmd_disable_admin(&database, &name),
Some(Command::EnableAdmin { name }) => cmd_enable_admin(&database, &name),
Some(Command::DeleteAdmin { name }) => cmd_delete_admin(&database, &name),
Some(Command::RotateKey { name }) => cmd_rotate_key(&database, &name),
Some(Command::GenerateCert {
hostname,
out_dir,
extra_sans,
}) => {
cmd_generate_cert(&hostname, &out_dir, &extra_sans);
}
Some(Command::ListUsers) => cmd_list_users(&database),
Some(Command::SetRole { email, role }) => cmd_set_role(&database, &email, &role),
Some(Command::DisableUser { email }) => cmd_disable_user(&database, &email),
Some(Command::DeleteUser { email }) => cmd_delete_user(&database, &email),
}
}
fn cmd_add_admin(database: &Db, name: &str, allowed_ips: Option<&str>, expires: Option<&str>) {
match db::add_admin(database, name, allowed_ips, expires) {
Ok(key) => {
println!("Admin '{}' created.", name);
println!("API Key: {}", key);
println!();
println!("Store this key securely — it cannot be retrieved again.");
}
Err(e) => {
eprintln!("Error creating admin: {}", e);
std::process::exit(1);
}
}
}
fn cmd_list_admins(database: &Db) {
match db::list_admins(database) {
Ok(admins) => {
if admins.is_empty() {
println!("No admins configured.");
return;
}
println!(
"{:<4} {:<20} {:<10} {:<24} {:<24} Allowed IPs",
"ID", "Name", "Status", "Expires", "Last Used",
);
println!("{}", "-".repeat(100));
for a in admins {
let status = if a.disabled { "disabled" } else { "active" };
let expires = a.expires_at.as_deref().unwrap_or("never");
let last_used = a.last_used_at.as_deref().unwrap_or("never");
let ips = a.allowed_ips.as_deref().unwrap_or("any");
println!(
"{:<4} {:<20} {:<10} {:<24} {:<24} {}",
a.id, a.name, status, expires, last_used, ips
);
}
}
Err(e) => {
eprintln!("Error listing admins: {}", e);
std::process::exit(1);
}
}
}
fn cmd_disable_admin(database: &Db, name: &str) {
match db::disable_admin(database, name) {
Ok(true) => println!("Admin '{}' disabled.", name),
Ok(false) => {
eprintln!("Admin '{}' not found.", name);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn cmd_enable_admin(database: &Db, name: &str) {
match db::enable_admin(database, name) {
Ok(true) => println!("Admin '{}' enabled.", name),
Ok(false) => {
eprintln!("Admin '{}' not found.", name);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn cmd_delete_admin(database: &Db, name: &str) {
match db::delete_admin(database, name) {
Ok(true) => println!("Admin '{}' deleted.", name),
Ok(false) => {
eprintln!("Admin '{}' not found.", name);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn cmd_rotate_key(database: &Db, name: &str) {
match db::rotate_key(database, name) {
Ok(Some(key)) => {
println!("API key rotated for '{}'.", name);
println!("New API Key: {}", key);
println!();
println!("Store this key securely — it cannot be retrieved again.");
}
Ok(None) => {
eprintln!("Admin '{}' not found.", name);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn cmd_generate_cert(hostname: &str, out_dir: &str, extra_sans: &[String]) {
use rcgen::{generate_simple_self_signed, CertifiedKey};
let mut sans = vec![
hostname.to_string(),
"localhost".to_string(),
"127.0.0.1".to_string(),
];
for san in extra_sans {
if !sans.contains(san) {
sans.push(san.clone());
}
}
let CertifiedKey { cert, signing_key } =
generate_simple_self_signed(sans.clone()).expect("Failed to generate certificate");
let cert_path = std::path::Path::new(out_dir).join("cert.pem");
let key_path = std::path::Path::new(out_dir).join("key.pem");
std::fs::write(&cert_path, cert.pem()).expect("Failed to write cert.pem");
std::fs::write(&key_path, signing_key.serialize_pem()).expect("Failed to write key.pem");
println!("Generated self-signed certificate:");
println!(" Certificate: {}", cert_path.display());
println!(" Private key: {}", key_path.display());
println!(" SANs: {}", sans.join(", "));
println!();
println!("Add to config.toml:");
println!(" [tls]");
println!(" cert_path = \"{}\"", cert_path.display());
println!(" key_path = \"{}\"", key_path.display());
}
fn cmd_list_users(database: &Db) {
match db::list_users(database) {
Ok(users) => {
if users.is_empty() {
println!("No OIDC users.");
return;
}
println!(
"{:<4} {:<30} {:<20} {:<10} {:<10} {:<24}",
"ID", "Email", "Name", "Role", "Status", "Last Login"
);
println!("{}", "-".repeat(100));
for u in users {
let status = if u.disabled { "disabled" } else { "active" };
let last_login = u.last_login_at.as_deref().unwrap_or("never");
println!(
"{:<4} {:<30} {:<20} {:<10} {:<10} {:<24}",
u.id, u.email, u.name, u.role, status, last_login
);
}
}
Err(e) => {
eprintln!("Error listing users: {}", e);
std::process::exit(1);
}
}
}
fn cmd_set_role(database: &Db, email: &str, role: &str) {
if !["admin", "poweruser", "operator", "viewer"].contains(&role) {
eprintln!("Role must be admin, poweruser, operator, or viewer.");
std::process::exit(1);
}
match db::set_user_role(database, email, role) {
Ok(true) => println!("User '{}' role set to '{}'.", email, role),
Ok(false) => {
eprintln!("User '{}' not found.", email);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn cmd_disable_user(database: &Db, email: &str) {
match db::disable_user(database, email) {
Ok(true) => println!("User '{}' disabled.", email),
Ok(false) => {
eprintln!("User '{}' not found.", email);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn cmd_delete_user(database: &Db, email: &str) {
match db::delete_user(database, email) {
Ok(true) => println!("User '{}' deleted.", email),
Ok(false) => {
eprintln!("User '{}' not found.", email);
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
/// Whether TLS is enabled (used by security headers middleware).
#[derive(Clone)]
struct TlsEnabled(bool);
async fn security_headers(
tls: Extension<TlsEnabled>,
request: Request,
next: middleware::Next,
) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
headers.insert("X-Frame-Options", "DENY".parse().unwrap());
headers.insert(
"Referrer-Policy",
"strict-origin-when-cross-origin".parse().unwrap(),
);
headers.insert(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()".parse().unwrap(),
);
headers.insert(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:".parse().unwrap(),
);
if tls.0 .0 {
headers.insert(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains".parse().unwrap(),
);
}
response
}
async fn run_server(config: Config, database: Db) {
// Initialize logging
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let listen_addr = config.listen_addr.clone();
let static_path = config.static_path.clone();
let tls_config = config.tls.clone();
// Initialize OIDC if configured
let oidc_state = if let Some(ref oidc_config) = config.oidc {
match oidc::init_oidc(oidc_config, config.auth_session_ttl_secs).await {
Ok(state) => {
tracing::info!("OIDC configured with issuer: {}", oidc_config.issuer_url);
Some(state)
}
Err(e) => {
tracing::error!("Failed to initialize OIDC: {}", e);
tracing::warn!("Continuing without OIDC — only API key auth will work");
None
}
}
} else {
None
};
// Initialize Vault client if configured
let vault_client: VaultState = Arc::new(tokio::sync::RwLock::new(None));
if let Some(ref vault_config) = config.vault {
let secret_id = match std::env::var("VAULT_SECRET_ID") {
Ok(s) => s,
Err(_) => {
tracing::error!("VAULT_SECRET_ID env var required when [vault] is configured");
tracing::error!("Address book and drive features will be unavailable");
String::new()
}
};
if !secret_id.is_empty() {
match vault::VaultClient::new(vault_config, &secret_id).await {
Ok(client) => {
let client = Arc::new(client);
client.spawn_renewal_task();
tracing::info!("Vault client initialized: {}", vault_config.addr);
*vault_client.write().await = Some(client);
}
Err(e) => {
tracing::error!("=============================================");
tracing::error!("VAULT CONNECTION FAILED");
tracing::error!(" Address: {}", vault_config.addr);
tracing::error!(" Error: {}", e);
tracing::error!(" Address book and drive features are UNAVAILABLE");
tracing::error!(" Sessions (SSH/RDP/VNC) will still work normally");
tracing::error!(" Retrying Vault connection every 30s in background");
tracing::error!("=============================================");
// Spawn background retry task
let retry_vault_config = vault_config.clone();
let retry_secret_id = secret_id.clone();
let retry_vault_state = vault_client.clone();
let retry_drive_config = config.drive.clone();
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(30));
interval.tick().await; // skip immediate tick
loop {
interval.tick().await;
tracing::debug!(
"Retrying Vault connection to {}...",
retry_vault_config.addr
);
match vault::VaultClient::new(&retry_vault_config, &retry_secret_id)
.await
{
Ok(client) => {
let client = Arc::new(client);
client.spawn_renewal_task();
tracing::info!(
"Vault client connected (retry succeeded): {}",
retry_vault_config.addr
);
*retry_vault_state.write().await = Some(client.clone());
// Mount LUKS now that Vault is available
if let Some(ref dc) = retry_drive_config {
if dc.enabled && drive::luks_configured(dc) {
match drive::mount_luks(dc, &client).await {
Ok(_) => tracing::info!(
"LUKS drive volume mounted (deferred)"
),
Err(e) => tracing::error!(
"Failed to mount LUKS drive volume: {}",
e
),
}
}
}
break;
}
Err(e) => {
tracing::warn!("Vault retry failed: {} — will retry in 30s", e);
}
}
}
});
}
}
}
}
// Initialize drive / LUKS if configured (and Vault is already available)
if let Some(ref drive_config) = config.drive {
if drive_config.enabled {
// Mount LUKS volume if configured and Vault is available now
if drive::luks_configured(drive_config) {
let vc = vault_client.read().await;
if let Some(ref client) = *vc {
match drive::mount_luks(drive_config, client).await {
Ok(_) => tracing::info!("LUKS drive volume mounted"),
Err(e) => {
tracing::error!("Failed to mount LUKS drive volume: {}", e);
}
}
} else {
tracing::warn!("LUKS configured but Vault not yet available — will mount when Vault connects");
}
}
// Ensure base drive directory exists
if let Err(e) = drive::ensure_base_dir(drive_config) {
tracing::warn!("Failed to create drive base directory: {}", e);
}
}
}
let oidc_enabled = OidcEnabled(oidc_state.is_some());
let vault_configured = VaultConfigured(config.vault.is_some());
let site_title = SiteTitle(config.site_title.clone());
let theme_data = {
let (admin_preset, admin_colors) = config
.theme
.as_ref()
.map(|t| t.resolve())
.unwrap_or_else(|| ("dark".into(), crate::config::builtin_presets()[0].1.clone()));
let logo_url = config.theme.as_ref().and_then(|t| t.logo_url.clone());
let presets: std::collections::HashMap<String, crate::config::ThemeColors> =
crate::config::builtin_presets()
.into_iter()
.map(|(name, colors)| (name.to_string(), colors))
.collect();
ThemeData {
admin_preset,
admin_colors,
logo_url,
presets,
}
};
let trusted_proxies = auth::TrustedProxies(config.trusted_proxies.clone());
// Periodically clean up expired auth sessions from the database
let cleanup_db = database.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
interval.tick().await; // first tick is immediate, skip it
loop {
interval.tick().await;
match db::cleanup_expired_sessions(&cleanup_db) {
Ok(n) if n > 0 => tracing::info!("Cleaned up {} expired auth sessions", n),
Err(e) => tracing::warn!("Failed to clean up expired sessions: {}", e),
_ => {}
}
match db::cleanup_expired_user_tokens(&cleanup_db) {
Ok(n) if n > 0 => tracing::info!("Cleaned up {} expired user API tokens", n),
Err(e) => tracing::warn!("Failed to clean up expired tokens: {}", e),
_ => {}
}
match db::cleanup_old_audit_log(&cleanup_db, 90) {
Ok(n) if n > 0 => tracing::info!("Cleaned up {} old audit log entries", n),
Err(e) => tracing::warn!("Failed to clean up audit log: {}", e),
_ => {}
}
}
});
// Log session max duration setting
let max_dur_hours = config.session_max_duration_secs as f64 / 3600.0;
tracing::info!(
"Session max duration: {:.1}h ({}s)",
max_dur_hours,
config.session_max_duration_secs
);
// Store drive config for shutdown cleanup (before config is moved)
let shutdown_drive_config = config.drive.clone();
// Build TLS connector for guacd if configured
let guacd_tls = build_guacd_tls(&config);
// Create session manager
let manager: AppState = Arc::new(SessionManager::new(config, guacd_tls));
// Spawn background task to reap sessions that exceed max duration
{
let reaper_manager = manager.clone();
let check_interval = std::cmp::max(manager.session_max_duration_secs() / 4, 60);
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(check_interval));
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
let reaped = reaper_manager.reap_expired_sessions().await;
if reaped > 0 {
tracing::info!("Reaped {} expired sessions", reaped);
}
}
});
}
// Spawn recording rotation background task
{
let rec_config = manager.recording_config();
if rec_config.max_disk_percent > 0 || rec_config.max_recordings > 0 {
let interval_secs = rec_config.rotation_interval_secs.max(30);
tracing::info!(
"Recording rotation enabled (max_disk={}%, max_count={}, interval={}s)",
rec_config.max_disk_percent,
rec_config.max_recordings,
interval_secs
);
tokio::spawn(async move {
let mut interval =
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
let cfg = rec_config.clone();
let _ = tokio::task::spawn_blocking(move || recording::rotate(&cfg)).await;
}
});
}
}
// Rate limit configs
let api_governor_conf = GovernorConfigBuilder::default()
.per_second(5)
.burst_size(30)
.key_extractor(SmartIpKeyExtractor)
.finish()
.expect("Failed to build API rate limit config");
let session_create_governor_conf = GovernorConfigBuilder::default()
.per_second(1)
.burst_size(5)
.key_extractor(SmartIpKeyExtractor)
.finish()
.expect("Failed to build session creation rate limit config");
let ws_governor_conf = GovernorConfigBuilder::default()
.per_second(1)
.burst_size(20)
.key_extractor(SmartIpKeyExtractor)
.finish()
.expect("Failed to build WebSocket rate limit config");
// Session creation route with extra rate limit layer
let session_create_route = Router::new()
.route("/api/sessions", post(api::create_session))
.with_state(manager.clone())
.layer(GovernorLayer::new(session_create_governor_conf));
// API routes that require authentication
let api_routes = Router::new()
.route("/api/sessions", get(api::list_sessions))
.route("/api/sessions/{id}", get(api::get_session))
.route("/api/sessions/{id}", delete(api::delete_session))
.route("/api/recordings", get(api::list_recordings))
.route("/api/recordings/{name}", get(api::serve_recording))
.route("/api/recordings/{name}", delete(api::delete_recording))
.route("/api/users", get(api::list_users))
.route("/api/users/{email}/role", put(api::set_user_role))
.route(
"/api/users/{email}/sessions",
delete(api::delete_user_sessions),
)
.route("/api/users/{email}", delete(api::delete_user))
.route("/api/users/{email}/disable", post(api::disable_user))
.route("/api/users/{email}/enable", post(api::enable_user))
.route("/api/admin/group-mappings", get(api::list_group_mappings))
.route("/api/admin/group-mappings", post(api::create_group_mapping))
.route(
"/api/admin/group-mappings/{id}",
put(api::update_group_mapping),
)
.route(
"/api/admin/group-mappings/{id}",
delete(api::delete_group_mapping),
)
.route("/api/me", get(api::me))
// User API token self-service
.route("/api/me/tokens", get(api::list_my_tokens))
.route("/api/me/tokens", post(api::create_my_token))
.route("/api/me/tokens/{id}", delete(api::revoke_my_token))
// Admin token management
.route("/api/admin/user-tokens", get(api::admin_list_user_tokens))
.route("/api/admin/user-tokens", post(api::admin_create_user_token))
.route(
"/api/admin/user-tokens/{id}",
delete(api::admin_revoke_user_token),
)
.route("/api/admin/token-audit", get(api::admin_token_audit))
// Address book routes
.route("/api/addressbook/folders", get(api::ab_list_folders))
.route("/api/addressbook/folders", post(api::ab_create_folder))
.route(
"/api/addressbook/folders/{scope}/{folder}",
put(api::ab_update_folder),
)
.route(
"/api/addressbook/folders/{scope}/{folder}",
delete(api::ab_delete_folder),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/entries",
get(api::ab_list_entries),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/entries",
post(api::ab_create_entry),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}",
put(api::ab_update_entry),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}",
delete(api::ab_delete_entry),
)
.route(
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}/connect",
post(api::ab_connect_entry),
)
.merge(session_create_route)
.with_state(manager.clone())
.layer(GovernorLayer::new(api_governor_conf))
.layer(middleware::from_fn(auth::require_auth))
.layer(Extension(vault_client.clone()))
.layer(Extension(vault_configured.clone()))
.layer(Extension(database.clone()));
// WebSocket route with optional auth and rate limiting
let ws_route = Router::new()
.route("/ws/{session_id}", get(websocket::ws_handler))
.with_state(manager.clone())
.layer(GovernorLayer::new(ws_governor_conf))
.layer(middleware::from_fn(auth::optional_auth))
.layer(Extension(database.clone()));
// Quick-connect route with optional auth (handles its own redirect-to-login)
let connect_route = Router::new()
.route("/api/connect", get(api::quick_connect))
.with_state(manager.clone())
.layer(middleware::from_fn(auth::optional_auth))
.layer(Extension(vault_client.clone()))
.layer(Extension(oidc_enabled.clone()))
.layer(Extension(database.clone()));
// Unauthenticated stateful routes
let unauth_routes = Router::new()
.route("/api/health", get(api::health))
.route("/api/docs", get(api::get_docs))
.route("/api/sessions/{id}/banner", get(api::get_session_banner))
.route("/client/{session_id}", get(serve_client_page))
.with_state(manager);
// Build full router (all Router<()> at this point)
let mut app: Router<()> = Router::new()
.route("/api/auth/status", get(api::auth_status))
.merge(api_routes)
.merge(ws_route)
.merge(connect_route)
.merge(unauth_routes);
// Add OIDC routes if configured
if let Some(ref oidc_st) = oidc_state {
let oidc_routes = Router::new()
.route("/auth/login", get(oidc::login))
.route("/auth/callback", get(oidc::callback))
.with_state(oidc_st.clone())
.layer(Extension(database.clone()));
let logout_route = Router::new()
.route("/auth/logout", get(oidc::logout))
.layer(Extension(database.clone()));
app = app.merge(oidc_routes).merge(logout_route);
}
// Add shared layers
let tls_enabled = TlsEnabled(tls_config.is_some());
app = app
.layer(DefaultBodyLimit::max(64 * 1024)) // 64 KB max request body
.layer(middleware::from_fn(security_headers))
.layer(Extension(tls_enabled))
.layer(Extension(oidc_enabled))
.layer(Extension(site_title))
.layer(Extension(theme_data))
.layer(Extension(trusted_proxies))
.fallback_service(ServeDir::new(&static_path));
let scheme = if tls_config.is_some() {
"https"
} else {
"http"
};
tracing::info!("rustguac starting on {}://{}", scheme, listen_addr);
tracing::info!("Static files served from {:?}", static_path);
if let Some(ref tls) = tls_config {
use axum_server::tls_rustls::RustlsConfig;
let rustls_config = RustlsConfig::from_pem_file(&tls.cert_path, &tls.key_path)
.await
.expect("Failed to load TLS certificates");
let addr: SocketAddr = listen_addr.parse().expect("Invalid listen address");
axum_server::bind_rustls(addr, rustls_config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.expect("Server error");
} else {
let listener = tokio::net::TcpListener::bind(&listen_addr)
.await
.expect("Failed to bind listener");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("Shutdown signal received");
})
.await
.expect("Server error");
}
// Cleanup LUKS on shutdown
if let Some(ref drive_config) = shutdown_drive_config {
if drive_config.enabled && drive::luks_configured(drive_config) {
tracing::info!("Unmounting LUKS drive volume...");
if let Err(e) = drive::unmount_luks(drive_config).await {
tracing::warn!("Failed to unmount LUKS volume on shutdown: {}", e);
}
}
}
}
/// Build a TLS connector for the guacd connection, if `tls.guacd_cert_path` is configured.
fn build_guacd_tls(config: &Config) -> Option<tokio_rustls::TlsConnector> {
let cert_path = config.tls.as_ref()?.guacd_cert_path.as_ref()?;
let pem_data = std::fs::read(cert_path)
.unwrap_or_else(|e| panic!("Failed to read guacd cert {}: {}", cert_path.display(), e));
let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
let certs: Vec<_> = rustls_pemfile::certs(&mut &pem_data[..])
.collect::<Result<Vec<_>, _>>()
.unwrap_or_else(|e| panic!("Failed to parse guacd cert PEM: {}", e));
for cert in certs {
root_store
.add(cert)
.unwrap_or_else(|e| panic!("Failed to add guacd cert to root store: {}", e));
}
let tls_config = tokio_rustls::rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
tracing::info!(
"guacd TLS enabled, trusting cert from {}",
cert_path.display()
);
Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
}
/// Serve the client HTML page for SSH sessions.
/// The session_id is extracted by the JS on the page, not by this handler.
async fn serve_client_page() -> Html<&'static str> {
Html(include_str!("../static/client.html"))
}