Files
rustguac/src/session.rs
T
Lindsay Harvey 66bc3722fc feat(vdi): host port range + container lifecycle hooks (#137)
Contributed by @vk2amv (Lindsay). Adds two related operator features to the Docker VDI driver:

1. Bounded host port range via [vdi] port_range_start / port_range_end. Currently Docker picks an arbitrary high port for the container's RDP listener; this lets operators constrain it which matters for firewalls, reverse proxies, and identity-aware gates that need to know in advance which ports rustguac will use. Port selection inside the range is deterministic-from-username (FNV-1a hash), so reconnects from the same user get the same port. Falls through to the next port on collision.

2. Container lifecycle hook script via [vdi] container_hook_script. Called as 'up <port> <container_id> <container_name>' after Docker assigns the port, again as 'down ...' before removal. Lets deployments wire external preparation/cleanup (firewall opens, service mesh registration, identity-aware gates) without baking the logic into rustguac itself. Bounded timeout (default 10s). Script is invoked via Command::new (no shell), so no injection risk from container metadata.

3 new tests covering port-candidate behaviour. Docs in docs/configuration.md and docs/vdi.md.

Thanks Lindsay.
2026-05-20 18:52:22 +10:00

2328 lines
88 KiB
Rust

use crate::browser::{BrowserManager, BrowserSession};
use crate::config::{Config, DriveConfig};
use crate::drive;
use crate::guacd;
use crate::guacd::GuacdStream;
use crate::tunnel;
use chrono::{DateTime, Utc};
use ipnetwork::IpNetwork;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tokio::time;
use tokio_rustls::TlsConnector;
use tokio_util::sync::CancellationToken;
use url::Url;
use uuid::Uuid;
/// Session type: SSH terminal, web browser, RDP, VNC, or VDI container.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SessionType {
#[default]
Ssh,
Web,
Rdp,
Vnc,
Vdi,
}
/// Parameters for creating a new session.
#[derive(Debug, Deserialize)]
pub struct CreateSessionRequest {
#[serde(default)]
pub session_type: SessionType,
// SSH fields (optional for backwards compat)
pub hostname: Option<String>,
pub port: Option<u16>,
pub username: Option<String>,
pub password: Option<String>,
pub private_key: Option<String>,
pub generate_keypair: Option<bool>,
// Web fields
pub url: Option<String>,
// RDP fields
pub domain: Option<String>,
pub security: Option<String>,
pub ignore_cert: Option<bool>,
/// NLA auth package: "kerberos", "ntlm", or empty (negotiate).
pub auth_pkg: Option<String>,
/// Kerberos KDC URL (optional).
pub kdc_url: Option<String>,
/// Kerberos ticket cache path (optional).
pub kerberos_cache: Option<String>,
// VNC fields
pub color_depth: Option<u8>,
// SSH tunnel / jump host fields (multi-hop)
pub jump_hosts: Option<Vec<tunnel::JumpHost>>,
// Legacy flat fields for backward compat (single jump host)
pub jump_host: Option<String>,
pub jump_port: Option<u16>,
pub jump_username: Option<String>,
pub jump_password: Option<String>,
pub jump_private_key: Option<String>,
// Common
pub width: Option<u32>,
pub height: Option<u32>,
pub dpi: Option<u32>,
pub banner: Option<String>,
/// Override drive/file transfer setting for this session.
pub enable_drive: Option<bool>,
// RDP RemoteApp (RAIL)
pub remote_app: Option<String>,
pub remote_app_dir: Option<String>,
pub remote_app_args: Option<String>,
// Recording overrides
pub enable_recording: Option<bool>,
/// Address book entry key (e.g. "shared/folder/entry") for recording metadata.
pub address_book_entry: Option<String>,
/// Address book folder name (for reporting).
pub address_book_folder: Option<String>,
/// Display name of the address book entry (for reporting).
pub entry_display_name: Option<String>,
/// Per-entry max recordings to keep.
pub max_recordings: Option<u32>,
/// Login script filename to run after browser spawns (web sessions only).
pub login_script: Option<String>,
/// Autofill credentials JSON for web sessions.
/// Array of {"url", "username", "password"} with $USERNAME/$PASSWORD placeholders.
pub autofill: Option<String>,
/// Allowed domains for web sessions. When set, Chromium can only reach these domains.
pub allowed_domains: Option<Vec<String>>,
/// Disable clipboard copy (server → client).
pub disable_copy: Option<bool>,
/// Disable clipboard paste (client → server).
pub disable_paste: Option<bool>,
/// Enable RDP Graphics Pipeline Extension (GFX).
pub enable_gfx: Option<bool>,
/// Enable desktop composition (DWM) for RDP.
pub enable_desktop_composition: Option<bool>,
/// Force lossless encoding (PNG only) for RDP.
pub force_lossless: Option<bool>,
/// Enable H.264 passthrough for RDP.
pub enable_h264: Option<bool>,
// VDI fields
/// Docker image for VDI sessions (e.g. "myregistry/desktop:latest").
pub container_image: Option<String>,
/// CPU limit override for VDI container (fractional cores).
pub container_cpu_limit: Option<f64>,
/// Memory limit override for VDI container in MB.
pub container_memory_limit: Option<u64>,
/// Extra environment variables for VDI container.
pub container_env: Option<std::collections::HashMap<String, String>>,
/// Override idle timeout for VDI container in minutes.
pub container_idle_timeout_mins: Option<u64>,
/// Fixed VDI container username override (matches the baked-in account
/// in container images that don't honour VDI_USERNAME). Auto-derived
/// from the operator's identity when unset.
pub container_username: Option<String>,
/// Fixed VDI container password override matching `container_username`.
/// Ephemerally generated when unset.
pub container_password: Option<String>,
/// Allow the owner to generate a Share URL for this session.
/// Default false. For entry-derived sessions this is populated from
/// the entry's `allow_sharing` flag; ad-hoc sessions are never
/// shareable (per GitHub-less admin gating requirement).
pub allow_sharing: Option<bool>,
}
/// Session status in the lifecycle.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SessionStatus {
/// guacd connected, waiting for browser
Pending,
/// Browser connected, session active
Active,
/// Session ended normally
Completed,
/// Session ended due to error
Error,
/// Session expired (no browser connected in time)
Expired,
}
/// Public session info returned by the API.
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub session_id: Uuid,
pub session_type: SessionType,
pub status: SessionStatus,
pub created_at: DateTime<Utc>,
pub client_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub share_url: Option<String>,
pub ws_url: String,
pub hostname: String,
pub username: String,
pub active_connections: u32,
pub created_by: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub banner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_book_entry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address_book_folder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entry_display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option<String>,
}
/// Internal session state including the guacd connection.
pub struct Session {
pub id: Uuid,
pub session_type: SessionType,
pub status: SessionStatus,
pub created_at: DateTime<Utc>,
pub hostname: String,
pub username: String,
pub url: Option<String>,
pub banner: Option<String>,
pub guacd_stream: Option<GuacdStream>,
pub connection_id: String,
pub share_token: String,
pub width: u32,
pub height: u32,
pub active_connections: u32,
pub created_by: String,
pub cancel: CancellationToken,
pub browser_session: Option<BrowserSession>,
/// Connection params for deferred guacd connection (ephemeral keypair sessions).
/// When set, the guacd connection is established when the WebSocket connects
/// instead of at session creation time.
pub deferred_params: Option<guacd::ConnectionParams>,
/// Per-session drive directory path (RDP sessions with drive enabled).
pub drive_path: Option<std::path::PathBuf>,
/// SSH tunnel chain (jump hosts) — kept alive for the session duration.
pub tunnels: Vec<tunnel::SshTunnel>,
/// Docker container ID for VDI sessions.
pub container_id: Option<String>,
/// Whether recording is enabled for this session.
pub recording_enabled: bool,
/// Address book entry key (e.g. "shared/folder/entry") for recording metadata.
pub address_book_entry: Option<String>,
/// Address book folder name (for reporting).
pub address_book_folder: Option<String>,
/// Display name of the address book entry (for reporting).
pub entry_display_name: Option<String>,
/// Per-entry max recordings to keep (from address book entry).
pub max_recordings: Option<u32>,
/// Login script task handle (aborted on session cleanup).
pub login_script_handle: Option<tokio::task::JoinHandle<()>>,
/// Short-lived admin-issued viewer tokens (Shadow — plan in
/// project_shadow_sessions_plan.md). Stores only a sha256 hex of the
/// raw token, the admin that issued it, and expiry. Validated
/// alongside share_token in validate_share_token; expired entries
/// are pruned when new tokens are minted.
pub shadow_tokens: Vec<ShadowToken>,
/// Admin-controlled: does this session allow user-initiated
/// sharing? Copied from the source entry's `allow_sharing` at
/// creation. When false, `SessionInfo.share_url` is `None` — the
/// Connections card hides its Share button. Does not block admin
/// shadow (`/shadow`), which has its own audit trail.
pub share_allowed: bool,
}
/// A short-lived viewer token issued by an admin to shadow an active session.
/// The raw token is handed to the admin once; only the hash is persisted in
/// memory so the token can't be lifted from a runtime snapshot.
#[derive(Debug, Clone)]
pub struct ShadowToken {
pub token_hash: String,
pub issued_by: String,
pub expires_at: DateTime<Utc>,
}
/// Result of validating a share-or-shadow token. Callers use this to tell
/// owner traffic from admin-minted shadow viewers, and to audit each shadow
/// use (the raw mint is audited separately; re-use is logged per connection
/// so a leaked token's blast radius is observable after the fact).
#[derive(Debug, Clone, PartialEq)]
pub enum ShareTokenValidation {
Invalid,
Owner,
Shadow { issued_by: String },
}
impl ShareTokenValidation {
pub fn is_valid(&self) -> bool {
!matches!(self, ShareTokenValidation::Invalid)
}
}
fn generate_share_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; 16] = rng.random();
hex::encode(bytes)
}
/// Resolve the RDP NLA authentication package for this session.
///
/// Precedence: per-entry (or per-request) value if non-empty, else the
/// server-wide `[rdp] default_auth_pkg`, else `"ntlm"`. We default to
/// NTLM because Kerberos requires a KDC reachable via DNS (often over
/// TCP) and its failure mode is a silent hang that looks like a stuck
/// RDP connection. Admins who actually run Kerberos-integrated hosts
/// can set `default_auth_pkg = "kerberos"` or `"negotiate"` in
/// `config.toml`.
fn resolve_rdp_auth_pkg(entry_value: Option<&str>, config: &Config) -> Option<String> {
if let Some(v) = entry_value {
let trimmed = v.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
if let Some(ref rdp) = config.rdp {
if let Some(ref pkg) = rdp.default_auth_pkg {
let trimmed = pkg.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
Some("ntlm".to_string())
}
#[cfg(test)]
mod auth_pkg_tests {
use super::*;
fn cfg(default_auth_pkg: Option<&str>) -> Config {
Config {
rdp: Some(crate::config::RdpConfig {
default_auth_pkg: default_auth_pkg.map(|s| s.to_string()),
}),
..Config::default()
}
}
#[test]
fn entry_value_wins_over_server_default() {
let c = cfg(Some("ntlm"));
assert_eq!(
resolve_rdp_auth_pkg(Some("kerberos"), &c),
Some("kerberos".into())
);
}
#[test]
fn empty_entry_value_falls_through_to_server_default() {
let c = cfg(Some("kerberos"));
assert_eq!(resolve_rdp_auth_pkg(Some(""), &c), Some("kerberos".into()));
assert_eq!(
resolve_rdp_auth_pkg(Some(" "), &c),
Some("kerberos".into())
);
}
#[test]
fn no_entry_no_config_defaults_to_ntlm() {
let c = Config::default();
assert_eq!(resolve_rdp_auth_pkg(None, &c), Some("ntlm".into()));
}
#[test]
fn empty_config_default_falls_through_to_ntlm() {
let c = cfg(Some(""));
assert_eq!(resolve_rdp_auth_pkg(None, &c), Some("ntlm".into()));
}
#[test]
fn server_default_applies_when_entry_none() {
let c = cfg(Some("negotiate"));
assert_eq!(resolve_rdp_auth_pkg(None, &c), Some("negotiate".into()));
}
}
/// Check that a host resolves to an IP within the allowed CIDR networks.
fn check_allowed_network(host: &str, port: u16, allowed: &[String]) -> Result<(), SessionError> {
let networks: Vec<IpNetwork> = allowed
.iter()
.filter_map(|s| s.parse::<IpNetwork>().ok())
.collect();
if networks.is_empty() {
return Err(SessionError::ValidationError(
"no valid CIDR networks configured in allowlist".into(),
));
}
// Try parsing host as an IP address directly first
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if networks.iter().any(|net| net.contains(ip)) {
return Ok(());
}
return Err(SessionError::ValidationError(format!(
"host {} is not in the allowed network list",
host
)));
}
// Resolve hostname to IP addresses
let addrs: Vec<std::net::SocketAddr> = format!("{}:{}", host, port)
.to_socket_addrs()
.map_err(|e| {
SessionError::ValidationError(format!("failed to resolve host '{}': {}", host, e))
})?
.collect();
if addrs.is_empty() {
return Err(SessionError::ValidationError(format!(
"host '{}' did not resolve to any addresses",
host
)));
}
for addr in &addrs {
if networks.iter().any(|net| net.contains(addr.ip())) {
return Ok(());
}
}
Err(SessionError::ValidationError(format!(
"host '{}' resolves to addresses not in the allowed network list",
host
)))
}
impl Session {
pub fn info(&self) -> SessionInfo {
SessionInfo {
session_id: self.id,
session_type: self.session_type.clone(),
status: self.status.clone(),
created_at: self.created_at,
client_url: format!("/client/{}", self.id),
share_url: if self.share_allowed {
Some(format!("/client/{}?token={}", self.id, self.share_token))
} else {
None
},
ws_url: format!("/ws/{}", self.id),
hostname: self.hostname.clone(),
username: self.username.clone(),
active_connections: self.active_connections,
created_by: self.created_by.clone(),
banner: self.banner.clone(),
url: self.url.clone(),
address_book_entry: self.address_book_entry.clone(),
address_book_folder: self.address_book_folder.clone(),
entry_display_name: self.entry_display_name.clone(),
thumbnail_url: Some(format!("/api/sessions/{}/thumbnail", self.id)),
}
}
}
/// Manages all active sessions.
pub struct SessionManager {
sessions: Arc<RwLock<HashMap<Uuid, Arc<Mutex<Session>>>>>,
config: Config,
browser_manager: Arc<BrowserManager>,
guacd_tls: Option<TlsConnector>,
db: Option<crate::db::Db>,
vdi_driver: Option<Arc<dyn crate::vdi::VdiDriver>>,
}
impl SessionManager {
pub fn new_with_db(config: Config, guacd_tls: Option<TlsConnector>, db: crate::db::Db) -> Self {
let mut mgr = Self::new(config, guacd_tls);
mgr.db = Some(db);
mgr
}
pub fn new(config: Config, guacd_tls: Option<TlsConnector>) -> Self {
// Ensure recording directory exists with restrictive permissions
let rec_path = config.effective_recording_path();
if let Err(e) = std::fs::create_dir_all(rec_path) {
tracing::warn!("Failed to create recording directory: {}", e);
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(rec_path, std::fs::Permissions::from_mode(0o750));
}
}
let browser_manager = Arc::new(BrowserManager::new(
config.xvnc_path.clone(),
config.chromium_path.clone(),
config.display_range_start,
config.display_range_end,
config.cdp_port_range_start,
config.cdp_port_range_end,
std::path::PathBuf::from(&config.login_scripts_dir),
config.login_script_timeout_secs,
));
let vdi_driver = Self::init_vdi_driver(&config);
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
config,
browser_manager,
guacd_tls,
db: None,
vdi_driver,
}
}
fn init_vdi_driver(config: &Config) -> Option<Arc<dyn crate::vdi::VdiDriver>> {
let vdi_cfg = config.vdi.as_ref()?;
if !vdi_cfg.enabled {
return None;
}
match crate::vdi::DockerDriver::new(&vdi_cfg.docker_socket) {
Ok(driver) => {
let mut driver = driver
.with_ready_timeout(vdi_cfg.ready_timeout_secs)
.with_container_hook(
vdi_cfg.container_hook_script.clone(),
vdi_cfg.container_hook_timeout_secs,
);
match (vdi_cfg.port_range_start, vdi_cfg.port_range_end) {
(Some(start), Some(end)) => {
driver = match driver.with_host_port_range(start, end) {
Ok(driver) => driver,
Err(e) => {
tracing::error!("Failed to initialize VDI Docker driver: {}", e);
return None;
}
};
}
(None, None) => {}
_ => {
tracing::error!(
"Failed to initialize VDI Docker driver: port_range_start and port_range_end must be set together"
);
return None;
}
}
tracing::info!(
socket = %vdi_cfg.docker_socket,
idle_timeout_mins = vdi_cfg.idle_timeout_mins,
port_range_start = ?vdi_cfg.port_range_start,
port_range_end = ?vdi_cfg.port_range_end,
container_hook_script = ?vdi_cfg.container_hook_script,
"VDI Docker driver initialized"
);
Some(Arc::new(driver))
}
Err(e) => {
tracing::error!("Failed to initialize VDI Docker driver: {}", e);
None
}
}
}
/// Get the VDI driver (if enabled).
pub fn vdi_driver(&self) -> Option<&dyn crate::vdi::VdiDriver> {
self.vdi_driver.as_deref()
}
/// Read-only access to the config.
pub fn config(&self) -> &Config {
&self.config
}
/// Create a new session: connect to guacd, perform handshake, return session info.
pub async fn create_session(
&self,
req: CreateSessionRequest,
created_by: String,
) -> Result<SessionInfo, SessionError> {
// Enforce session limits (only count active/pending sessions)
{
let sessions = self.sessions.read().await;
let max_global = self.config.max_sessions;
let max_per_user = self.config.max_sessions_per_user;
if max_global > 0 {
let active_count = sessions.values().count(); // includes all states still in HashMap
if active_count >= max_global {
return Err(SessionError::ValidationError(format!(
"maximum concurrent sessions reached ({})",
max_global
)));
}
}
if max_per_user > 0 {
let mut user_count = 0usize;
for session in sessions.values() {
let s = session.lock().await;
if s.created_by == created_by
&& matches!(s.status, SessionStatus::Pending | SessionStatus::Active)
{
user_count += 1;
}
}
if user_count >= max_per_user {
return Err(SessionError::ValidationError(format!(
"maximum sessions per user reached ({})",
max_per_user
)));
}
}
}
let session_id = Uuid::new_v4();
let raw_width = req.width.unwrap_or(1920);
let raw_height = req.height.unwrap_or(1080);
let raw_dpi = req.dpi.unwrap_or(96);
let width = raw_width.clamp(640, 8192);
let height = raw_height.clamp(480, 8192);
let dpi = raw_dpi.clamp(16, 384);
if width != raw_width || height != raw_height || dpi != raw_dpi {
tracing::warn!(
session_id = %session_id,
raw_width, raw_height, raw_dpi,
clamped_width = width, clamped_height = height, clamped_dpi = dpi,
"Clamped session dimensions to safe range"
);
}
let (
mut conn_params,
hostname,
username,
url,
mut browser_session,
banner_override,
session_drive_path,
container_id,
) = match req.session_type {
SessionType::Ssh => {
let hostname = req.hostname.ok_or_else(|| {
SessionError::ValidationError("hostname is required for SSH sessions".into())
})?;
let port = req.port.unwrap_or(22);
let username = req.username.clone().unwrap_or_default();
check_allowed_network(&hostname, port, &self.config.ssh_allowed_networks)?;
tracing::info!(
session_id = %session_id,
hostname = %hostname,
username = %username,
"Creating new SSH session"
);
let (private_key, ssh_banner) = if req.generate_keypair.unwrap_or(false) {
let keypair = ssh_key::PrivateKey::random(
&mut ssh_key::rand_core::OsRng,
ssh_key::Algorithm::Ed25519,
)
.map_err(|e| {
SessionError::ValidationError(format!("keypair generation failed: {}", e))
})?;
let private_pem = keypair.to_openssh(ssh_key::LineEnding::LF).map_err(|e| {
SessionError::ValidationError(format!("private key export failed: {}", e))
})?;
let public_key = format!(
"{} rustguac-ephemeral",
keypair.public_key().to_openssh().map_err(|e| {
SessionError::ValidationError(format!(
"public key export failed: {}",
e
))
})?
);
let auth_keys_path = if username.is_empty() {
"~/.ssh/authorized_keys".to_string()
} else {
format!("~{}/.ssh/authorized_keys", username)
};
let mut banner = format!(
"Add this public key to {} on the target host:\n\n{}\n\nDo not click Continue until the key is installed — authentication will fail.",
auth_keys_path, public_key
);
if let Some(ref user_banner) = req.banner {
banner = format!("{}\n\n{}", user_banner, banner);
}
tracing::info!(session_id = %session_id, "Generated ephemeral SSH keypair");
(Some(private_pem.to_string()), Some(banner))
} else {
(req.private_key.clone(), None)
};
let drive_enabled = drive::is_drive_enabled(&self.config.drive, req.enable_drive);
let drive_cfg = drive::drive_config_or_default(&self.config.drive);
let params = guacd::ConnectionParams::Ssh(guacd::SshParams {
hostname: hostname.clone(),
port,
username: username.clone(),
password: req.password.clone(),
private_key,
width,
height,
dpi,
enable_sftp: drive_enabled,
sftp_disable_download: !drive_cfg.allow_download,
sftp_disable_upload: !drive_cfg.allow_upload,
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
});
(
params, hostname, username, None, None, ssh_banner, None, None,
)
}
SessionType::Rdp => {
let hostname = req.hostname.ok_or_else(|| {
SessionError::ValidationError("hostname is required for RDP sessions".into())
})?;
let port = req.port.unwrap_or(3389);
let username = req.username.clone().unwrap_or_default();
check_allowed_network(&hostname, port, &self.config.rdp_allowed_networks)?;
tracing::info!(
session_id = %session_id,
hostname = %hostname,
username = %username,
width, height, dpi,
"Creating new RDP session"
);
let drive_enabled = drive::is_drive_enabled(&self.config.drive, req.enable_drive);
let drive_cfg = drive::drive_config_or_default(&self.config.drive);
tracing::info!(
%session_id,
drive_enabled,
entry_enable_drive = ?req.enable_drive,
has_drive_config = self.config.drive.is_some(),
drive_path = ?drive_cfg.drive_path,
"Drive configuration"
);
// Create per-session drive directory for RDP
let session_drive_path = if drive_enabled {
match drive::create_session_dir(&drive_cfg, session_id) {
Ok(path) => Some(path),
Err(e) => {
tracing::warn!(session_id = %session_id, "Failed to create drive dir: {}", e);
None
}
}
} else {
None
};
let rdp_ignore_cert = req.ignore_cert.unwrap_or(false);
let rdp_security = req.security.clone();
let rdp_enable_drive = session_drive_path.is_some();
tracing::info!(
%session_id,
ignore_cert = rdp_ignore_cert,
security = ?rdp_security,
enable_drive = rdp_enable_drive,
drive_path = ?session_drive_path,
domain = ?req.domain,
has_password = req.password.is_some(),
"RDP session params"
);
let params = guacd::ConnectionParams::Rdp(Box::new(guacd::RdpParams {
hostname: hostname.clone(),
port,
username: username.clone(),
password: req.password.clone(),
domain: req.domain.clone(),
security: rdp_security,
width,
height,
dpi,
ignore_cert: rdp_ignore_cert,
enable_drive: rdp_enable_drive,
drive_path: session_drive_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
drive_name: drive_cfg.drive_name.clone(),
disable_download: !drive_cfg.allow_download,
disable_upload: !drive_cfg.allow_upload,
auth_pkg: resolve_rdp_auth_pkg(req.auth_pkg.as_deref(), &self.config),
kdc_url: req.kdc_url.clone(),
kerberos_cache: req.kerberos_cache.clone(),
remote_app: req.remote_app.clone(),
remote_app_dir: req.remote_app_dir.clone(),
remote_app_args: req.remote_app_args.clone(),
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
enable_gfx: req.enable_gfx.unwrap_or(false),
enable_desktop_composition: req.enable_desktop_composition.unwrap_or(false),
force_lossless: req.force_lossless.unwrap_or(false),
enable_h264: req.enable_h264.unwrap_or(false),
}));
(
params,
hostname,
username,
None,
None,
None,
session_drive_path,
None,
)
}
SessionType::Vnc => {
let hostname = req.hostname.ok_or_else(|| {
SessionError::ValidationError("hostname is required for VNC sessions".into())
})?;
let port = req.port.unwrap_or(5900);
let username = req.username.clone().unwrap_or_default();
check_allowed_network(&hostname, port, &self.config.vnc_allowed_networks)?;
tracing::info!(
session_id = %session_id,
hostname = %hostname,
width, height, dpi,
"Creating new VNC session"
);
let params = guacd::ConnectionParams::Vnc(guacd::VncParams {
hostname: hostname.clone(),
port,
password: req.password.clone(),
color_depth: req.color_depth,
width,
height,
dpi,
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
});
(params, hostname, username, None, None, None, None, None)
}
SessionType::Web => {
let raw_url = req.url.ok_or_else(|| {
SessionError::ValidationError("url is required for web sessions".into())
})?;
// Step 1: Validate raw URL template (scheme must be http/https)
let parsed = Url::parse(&raw_url)
.map_err(|e| SessionError::ValidationError(format!("invalid URL: {}", e)))?;
match parsed.scheme() {
"http" | "https" => {}
s => {
return Err(SessionError::ValidationError(format!(
"URL scheme '{}' not allowed (must be http or https)",
s
)))
}
}
// Step 2: URL-encode and substitute credential placeholders
let enc_user = urlencoding::encode(req.username.as_deref().unwrap_or(""));
let enc_pass = urlencoding::encode(req.password.as_deref().unwrap_or(""));
let url = raw_url
.replace("$RUSTGUAC_USERNAME", &enc_user)
.replace("$RUSTGUAC_PASSWORD", &enc_pass);
// Step 3: Re-validate substituted URL
let parsed = Url::parse(&url).map_err(|e| {
SessionError::ValidationError(format!(
"URL invalid after credential substitution: {}",
e
))
})?;
match parsed.scheme() {
"http" | "https" => {}
s => {
return Err(SessionError::ValidationError(format!(
"URL scheme '{}' after substitution not allowed",
s
)))
}
}
let url_host = parsed
.host_str()
.ok_or_else(|| SessionError::ValidationError("URL has no host".into()))?;
let url_port =
parsed
.port()
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
check_allowed_network(url_host, url_port, &self.config.web_allowed_networks)?;
tracing::info!(
session_id = %session_id,
url = %url,
has_login_script = req.login_script.is_some(),
"Creating new web session"
);
// Defer browser spawning — we may need to rewrite the URL
// if jump hosts are configured (tunnel gets set up below).
// Store a placeholder VNC params with port 0; will be updated
// after browser spawn.
let params = guacd::ConnectionParams::Vnc(guacd::VncParams {
hostname: "127.0.0.1".into(),
port: 0, // placeholder — updated after browser spawn
password: None,
color_depth: None,
width,
height,
dpi,
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
});
(
params,
"localhost".into(),
String::new(),
Some(url),
None, // browser spawned after tunnel setup
None,
None,
None,
)
}
SessionType::Vdi => {
let vdi_cfg = self
.config
.vdi
.as_ref()
.filter(|v| v.enabled)
.ok_or_else(|| SessionError::VdiError("VDI feature is not enabled".into()))?;
let vdi = self
.vdi_driver
.as_ref()
.ok_or_else(|| SessionError::VdiError("VDI driver not initialized".into()))?;
let image = req.container_image.clone().ok_or_else(|| {
SessionError::ValidationError(
"container_image is required for VDI sessions".into(),
)
})?;
// Check allowed images whitelist
if !vdi_cfg.allowed_images.is_empty() && !vdi_cfg.allowed_images.contains(&image) {
return Err(SessionError::VdiError(format!(
"image '{}' is not in the allowed list",
image
)));
}
// Username: per-entry override if set (for images with baked-in
// accounts that don't honour VDI_USERNAME), otherwise derive
// from the operator's identity. The derived form is also used
// as the deterministic container-name suffix, so containers
// are scoped per-operator. When the override is set the same
// container is shared by everyone connecting with that entry,
// which is the desired behaviour for shared baked-in accounts.
let vdi_username = req
.container_username
.as_ref()
.filter(|s| !s.is_empty())
.cloned()
.unwrap_or_else(|| {
created_by
.split('@')
.next()
.unwrap_or(&created_by)
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>()
});
let vdi_password = req
.container_password
.as_ref()
.filter(|s| !s.is_empty())
.cloned()
.unwrap_or_else(generate_share_token); // 32 hex chars
// Merge env vars. We still set VDI_USERNAME/VDI_PASSWORD even
// when the override is in use - that way images which DO read
// the env vars get the right values, and images which ignore
// them aren't affected. User-provided env never overrides the
// core VDI vars.
let mut env = req.container_env.unwrap_or_default();
env.insert("VDI_USERNAME".into(), vdi_username.clone());
env.insert("VDI_PASSWORD".into(), vdi_password.clone());
// Resolve resource limits: entry overrides > config defaults
let cpu_limit = req.container_cpu_limit.unwrap_or(vdi_cfg.default_cpu_limit);
let memory_limit_mb = req
.container_memory_limit
.unwrap_or(vdi_cfg.default_memory_limit);
let spec = crate::vdi::ContainerSpec {
image: image.clone(),
username: vdi_username.clone(),
password: vdi_password.clone(),
cpu_limit,
memory_limit: memory_limit_mb * 1024 * 1024, // MB to bytes
env,
home_base: vdi_cfg.home_base.clone(),
entry_key: req.address_book_entry.clone(),
idle_timeout_mins: req.container_idle_timeout_mins,
};
// Clear stale VDI thumbnail before starting/reusing container
let container_name = format!("rustguac-vdi-{}", vdi_username);
let stale_thumb = self.vdi_thumbnail_path(&container_name);
let _ = std::fs::remove_file(&stale_thumb);
tracing::info!(
session_id = %session_id,
image = %image,
username = %vdi_username,
"Creating VDI session"
);
let info = vdi
.start_or_reuse(&spec)
.await
.map_err(|e| SessionError::VdiError(e.to_string()))?;
if info.reused {
tracing::info!(
session_id = %session_id,
container_id = %info.container_id,
"Reusing existing VDI container"
);
}
let params = guacd::ConnectionParams::Rdp(Box::new(guacd::RdpParams {
hostname: info.rdp_host,
port: info.rdp_port,
username: vdi_username.clone(),
password: Some(vdi_password),
domain: None,
security: None,
width,
height,
dpi,
ignore_cert: true,
enable_drive: false,
drive_path: None,
drive_name: String::new(),
disable_download: true,
disable_upload: true,
auth_pkg: None,
kdc_url: None,
kerberos_cache: None,
remote_app: None,
remote_app_dir: None,
remote_app_args: None,
disable_copy: req.disable_copy.unwrap_or(false),
disable_paste: req.disable_paste.unwrap_or(false),
enable_gfx: true,
enable_desktop_composition: true,
force_lossless: false,
enable_h264: false,
}));
(
params,
image,
vdi_username,
None,
None,
None,
None,
Some(info.container_id),
)
}
};
// Resolve jump hosts: prefer jump_hosts array, fall back to legacy flat fields
let jump_hops: Vec<tunnel::JumpHost> = if let Some(hops) = req.jump_hosts {
hops
} else if let Some(ref jh) = req.jump_host {
if !jh.is_empty() {
vec![tunnel::JumpHost {
hostname: jh.clone(),
port: req.jump_port.unwrap_or(22),
username: req.jump_username.clone().unwrap_or_default(),
password: req.jump_password.clone(),
private_key: req.jump_private_key.clone(),
host_key: None,
}]
} else {
Vec::new()
}
} else {
Vec::new()
};
// Set up SSH tunnel chain if jump hosts are configured.
// For SSH/RDP/VNC: overrides hostname/port in conn_params so guacd
// connects to the local tunnel listener instead of the real target.
// For Web: tunnels to the URL's host:port and rewrites the browser URL.
let is_web = url.is_some() && browser_session.is_none();
let ssh_tunnels = if !jump_hops.is_empty() {
let (target_host, target_port) = if is_web {
// Web session: tunnel to the URL's host:port
let parsed = Url::parse(url.as_ref().unwrap())
.map_err(|e| SessionError::ValidationError(format!("invalid URL: {}", e)))?;
let host = parsed.host_str().unwrap_or("localhost").to_string();
let port = parsed.port_or_known_default().unwrap_or(80);
(host, port)
} else {
match &conn_params {
guacd::ConnectionParams::Ssh(p) => (p.hostname.clone(), p.port),
guacd::ConnectionParams::Rdp(p) => (p.hostname.clone(), p.port),
guacd::ConnectionParams::Vnc(p) => (p.hostname.clone(), p.port),
}
};
let (tunnels, final_addr) = tunnel::start_chain(&jump_hops, &target_host, target_port)
.await
.map_err(|e| SessionError::ValidationError(format!("SSH tunnel failed: {}", e)))?;
if !is_web {
// Override connection params to point at the final tunnel endpoint
match &mut conn_params {
guacd::ConnectionParams::Ssh(p) => {
p.hostname = final_addr.ip().to_string();
p.port = final_addr.port();
}
guacd::ConnectionParams::Rdp(p) => {
p.hostname = final_addr.ip().to_string();
p.port = final_addr.port();
}
guacd::ConnectionParams::Vnc(p) => {
p.hostname = final_addr.ip().to_string();
p.port = final_addr.port();
}
}
}
let hop_names: Vec<&str> = jump_hops.iter().map(|h| h.hostname.as_str()).collect();
tracing::info!(
session_id = %session_id,
final_addr = %final_addr,
hops = ?hop_names,
"SSH tunnel chain established ({} hops)",
tunnels.len()
);
Some((tunnels, final_addr))
} else {
None
};
// For web sessions, spawn the browser now (after tunnels are set up).
// If a tunnel is active, rewrite the URL to go through it.
if is_web {
let browser_url = if let Some((_, ref final_addr)) = ssh_tunnels {
let parsed = Url::parse(url.as_ref().unwrap()).unwrap();
let scheme = parsed.scheme();
let path_and_query = if let Some(q) = parsed.query() {
format!("{}?{}", parsed.path(), q)
} else {
parsed.path().to_string()
};
let rewritten = format!(
"{}://127.0.0.1:{}{}",
scheme,
final_addr.port(),
path_and_query,
);
tracing::info!(
session_id = %session_id,
original_url = %url.as_ref().unwrap(),
rewritten_url = %rewritten,
"Rewrote web session URL to use SSH tunnel"
);
rewritten
} else {
url.as_ref().unwrap().clone()
};
let need_cdp = req.login_script.is_some();
// Parse autofill credentials JSON and substitute placeholders
let autofill_creds = parse_autofill_credentials(
req.autofill.as_deref(),
req.username.as_deref(),
req.password.as_deref(),
);
let browser = self
.browser_manager
.spawn(
&browser_url,
width,
height,
need_cdp,
autofill_creds.as_deref(),
req.allowed_domains.as_deref(),
)
.await
.map_err(|e| SessionError::BrowserSpawn(e.to_string()))?;
let vnc_port = browser.vnc_port;
tracing::info!(
session_id = %session_id,
vnc_port = %vnc_port,
display = %browser.display,
"Browser processes ready, connecting guacd via VNC"
);
// Update the VNC params with the actual port
if let guacd::ConnectionParams::Vnc(ref mut p) = conn_params {
p.port = vnc_port;
}
browser_session = Some(browser);
}
let ssh_tunnels = ssh_tunnels.map(|(t, _)| t).unwrap_or_default();
// For ephemeral keypair sessions, defer the guacd connection until
// the user dismisses the banner (i.e. when the WebSocket connects).
// This gives the user time to copy the public key and add it to
// authorized_keys before guacd attempts SSH authentication.
let deferred = banner_override.is_some();
let (guacd_stream, connection_id, deferred_params) = if deferred {
tracing::info!(
session_id = %session_id,
"Deferring guacd connection (ephemeral keypair — waiting for user to add public key)"
);
(None, String::new(), Some(conn_params))
} else {
// Connect to guacd and perform handshake
let handshake_result = guacd::connect_and_handshake(
&self.config.guacd_addr,
&conn_params,
self.guacd_tls.as_ref(),
)
.await;
// If handshake fails, clean up browser processes
let (stream, connection_id) = match handshake_result {
Ok(result) => result,
Err(e) => {
if let Some(mut bs) = browser_session {
self.browser_manager.kill(&mut bs).await;
}
tracing::error!(session_id = %session_id, error = %e, "Failed to connect to guacd");
return Err(SessionError::GuacdConnection(e.to_string()));
}
};
tracing::info!(
session_id = %session_id,
connection_id = %connection_id,
"guacd connection established"
);
(Some(stream), connection_id, None)
};
let recording_enabled = req
.enable_recording
.unwrap_or(self.config.recording_enabled());
// Spawn login script if configured (web sessions with CDP port)
let login_script_handle =
if let (Some(ref script), Some(ref bs)) = (&req.login_script, &browser_session) {
if let Some(cdp_port) = bs.cdp_port {
match self.browser_manager.run_login_script(
script,
bs.display,
cdp_port,
url.as_deref().unwrap_or(""),
req.username.as_deref(),
req.password.as_deref(),
&session_id.to_string(),
) {
Ok(handle) => Some(handle),
Err(e) => {
tracing::warn!(
session_id = %session_id,
error = %e,
"Login script failed to start (session continues)"
);
None
}
}
} else {
tracing::warn!(
session_id = %session_id,
"Login script configured but no CDP port allocated"
);
None
}
} else {
None
};
// Gate:
// - If the request explicitly sets allow_sharing, honour it.
// - Otherwise: entry-derived sessions default off (admin opt-in
// per entry via allow_sharing), ad-hoc sessions default on
// (preserves the long-standing API-key session-creation
// behaviour where share_url is expected in the response).
let share_allowed = req
.allow_sharing
.unwrap_or(req.address_book_entry.is_none());
let session = Session {
id: session_id,
session_type: req.session_type,
status: SessionStatus::Pending,
created_at: Utc::now(),
hostname,
username,
url,
banner: banner_override.or(req.banner),
guacd_stream,
connection_id,
share_token: generate_share_token(),
width,
height,
active_connections: 0,
created_by,
cancel: CancellationToken::new(),
browser_session,
deferred_params,
drive_path: session_drive_path,
tunnels: ssh_tunnels,
container_id,
recording_enabled,
address_book_entry: req.address_book_entry,
address_book_folder: req.address_book_folder,
entry_display_name: req.entry_display_name,
max_recordings: req.max_recordings,
login_script_handle,
shadow_tokens: Vec::new(),
share_allowed,
};
let info = session.info();
let session = Arc::new(Mutex::new(session));
self.sessions
.write()
.await
.insert(session_id, session.clone());
// Record in session history
if let Some(ref db) = self.db {
let st = format!("{:?}", info.session_type).to_lowercase();
if let Err(e) = crate::db::insert_session_history(
db,
&session_id.to_string(),
&st,
&info.hostname,
None,
&info.username,
&info.created_by,
info.address_book_entry.as_deref(),
info.address_book_folder.as_deref(),
info.entry_display_name.as_deref(),
) {
tracing::warn!(session_id = %session_id, error = %e, "Failed to record session history");
}
}
// Spawn timeout task for pending sessions
let sessions_ref = Arc::clone(&self.sessions);
let browser_mgr = Arc::clone(&self.browser_manager);
let timeout_secs = self.config.session_pending_timeout_secs;
let (cleanup_on_close, retention_secs) = drive_cleanup_settings(&self.config.drive);
tokio::spawn(async move {
time::sleep(time::Duration::from_secs(timeout_secs)).await;
let sessions_read = sessions_ref.read().await;
if let Some(session) = sessions_read.get(&session_id) {
let mut session = session.lock().await;
if session.status == SessionStatus::Pending {
tracing::warn!(session_id = %session_id, "Session expired (no browser connected)");
session.status = SessionStatus::Expired;
session.guacd_stream = None;
cleanup_browser(&browser_mgr, &mut session, cleanup_on_close, retention_secs)
.await;
}
}
});
Ok(info)
}
/// List all sessions.
pub async fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions = self.sessions.read().await;
let mut result = Vec::new();
for session in sessions.values() {
let session = session.lock().await;
result.push(session.info());
}
result
}
/// Get a specific session's info.
pub async fn get_session(&self, id: Uuid) -> Option<SessionInfo> {
let sessions = self.sessions.read().await;
let session = sessions.get(&id)?;
let session = session.lock().await;
Some(session.info())
}
/// Take the guacd stream from a session (for the owner/first WebSocket connection).
/// Transitions the session to Active. Returns the stream and a cancellation token.
/// For deferred connections (ephemeral keypair), connects to guacd here.
pub async fn take_guacd_stream(&self, id: Uuid) -> Option<(GuacdStream, CancellationToken)> {
let sessions = self.sessions.read().await;
let session_arc = sessions.get(&id)?;
let mut session = session_arc.lock().await;
if session.status != SessionStatus::Pending {
return None;
}
// If this is a deferred connection, connect to guacd now
if let Some(params) = session.deferred_params.take() {
tracing::info!(session_id = %id, "Establishing deferred guacd connection");
match guacd::connect_and_handshake(
&self.config.guacd_addr,
&params,
self.guacd_tls.as_ref(),
)
.await
{
Ok((stream, connection_id)) => {
tracing::info!(
session_id = %id,
connection_id = %connection_id,
"Deferred guacd connection established"
);
session.guacd_stream = Some(stream);
session.connection_id = connection_id;
}
Err(e) => {
tracing::error!(session_id = %id, error = %e, "Deferred guacd connection failed");
session.status = SessionStatus::Error;
return None;
}
}
}
let stream = session.guacd_stream.take()?;
let cancel = session.cancel.clone();
session.status = SessionStatus::Active;
session.active_connections += 1;
tracing::info!(session_id = %id, "Session now active (owner connected)");
Some((stream, cancel))
}
/// Join an active session by opening a new guacd connection.
/// Returns a new GuacdStream and the session's cancellation token.
pub async fn join_session(
&self,
id: Uuid,
) -> Result<(GuacdStream, CancellationToken), SessionError> {
let (connection_id, width, height, cancel) = {
let sessions = self.sessions.read().await;
let session = sessions.get(&id).ok_or(SessionError::NotFound)?;
let session = session.lock().await;
if session.status != SessionStatus::Active {
return Err(SessionError::NotActive);
}
(
session.connection_id.clone(),
session.width,
session.height,
session.cancel.clone(),
)
};
let stream = guacd::join_connection(
&self.config.guacd_addr,
&connection_id,
width,
height,
96,
self.guacd_tls.as_ref(),
)
.await
.map_err(|e| {
tracing::error!(session_id = %id, error = %e, "Failed to join guacd session");
SessionError::GuacdConnection(e.to_string())
})?;
// Increment active connections
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&id) {
let mut session = session.lock().await;
session.active_connections += 1;
}
tracing::info!(session_id = %id, "Viewer joined session");
Ok((stream, cancel))
}
/// Validate a share-or-shadow token for a session (constant-time
/// comparison). Returns which kind of token matched so callers can
/// audit shadow uses; returns `Invalid` if neither matches or the
/// session is unknown.
pub async fn validate_share_token(&self, id: Uuid, token: &str) -> ShareTokenValidation {
let sessions = self.sessions.read().await;
let Some(session) = sessions.get(&id) else {
return ShareTokenValidation::Invalid;
};
let session = session.lock().await;
check_share_token_match(
&session.share_token,
&session.shadow_tokens,
token,
Utc::now(),
)
}
/// Mint a new short-lived (10 min) shadow token for a session.
/// Returns the raw token (hand to admin once) and its expiry.
/// Expired tokens on the session are pruned on mint.
pub async fn mint_shadow_token(
&self,
id: Uuid,
issued_by: &str,
) -> Option<(String, DateTime<Utc>)> {
use sha2::{Digest, Sha256};
let sessions = self.sessions.read().await;
let session = sessions.get(&id)?;
let mut session = session.lock().await;
let now = Utc::now();
session.shadow_tokens.retain(|t| t.expires_at > now);
let mut rng = rand::rng();
let bytes: [u8; 16] = rng.random();
let raw = hex::encode(bytes);
let hash = hex::encode(Sha256::digest(raw.as_bytes()));
let expires_at = now + chrono::Duration::minutes(10);
session.shadow_tokens.push(ShadowToken {
token_hash: hash,
issued_by: issued_by.to_string(),
expires_at,
});
Some((raw, expires_at))
}
/// Decrement active connection count when a WebSocket disconnects.
pub async fn disconnect_viewer(&self, id: Uuid) {
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&id) {
let mut session = session.lock().await;
session.active_connections = session.active_connections.saturating_sub(1);
}
}
/// Mark a session as completed.
pub async fn complete_session(&self, id: Uuid) {
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&id) {
let mut session = session.lock().await;
if session.status == SessionStatus::Active {
session.status = SessionStatus::Completed;
let (c, r) = drive_cleanup_settings(&self.config.drive);
cleanup_browser(&self.browser_manager, &mut session, c, r).await;
tracing::info!(session_id = %id, "Session completed");
}
}
}
/// Mark a session as errored.
pub async fn error_session(&self, id: Uuid) {
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&id) {
let mut session = session.lock().await;
session.status = SessionStatus::Error;
session.guacd_stream = None;
let (c, r) = drive_cleanup_settings(&self.config.drive);
cleanup_browser(&self.browser_manager, &mut session, c, r).await;
}
}
/// Record session end in history table.
pub fn end_session_history(&self, id: Uuid, status: &str, duration_secs: u64, recording: bool) {
if let Some(ref db) = self.db {
let rec_file = if recording {
Some(format!("{}.guac", id))
} else {
None
};
if let Err(e) = crate::db::end_session_history(
db,
&id.to_string(),
status,
duration_secs,
rec_file.as_deref(),
) {
tracing::warn!(session_id = %id, error = %e, "Failed to update session history");
}
}
}
/// Check if a session is in Pending status (owner not yet connected).
pub async fn is_session_pending(&self, id: Uuid) -> bool {
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&id) {
let session = session.lock().await;
session.status == SessionStatus::Pending
} else {
false
}
}
/// Get the creator of a session.
pub async fn get_session_creator(&self, id: Uuid) -> Option<String> {
let sessions = self.sessions.read().await;
let session = sessions.get(&id)?;
let session = session.lock().await;
Some(session.created_by.clone())
}
/// Get session type and container_id for a session (used for VDI cleanup).
pub async fn get_vdi_info(&self, id: Uuid) -> Option<(SessionType, Option<String>)> {
let sessions = self.sessions.read().await;
let session = sessions.get(&id)?;
let session = session.lock().await;
Some((session.session_type.clone(), session.container_id.clone()))
}
/// Stop and remove the VDI container for a session.
pub async fn stop_vdi_container(&self, id: Uuid) {
let container_id = {
let sessions = self.sessions.read().await;
let session = sessions.get(&id);
if let Some(session) = session {
let mut session = session.lock().await;
session.container_id.take()
} else {
None
}
};
if let Some(cid) = container_id {
if let Some(ref vdi) = self.vdi_driver {
tracing::info!(session_id = %id, container_id = %cid, "Stopping VDI container (session ended by server)");
if let Err(e) = vdi.stop_container(&cid).await {
tracing::warn!(container_id = %cid, "Failed to stop VDI container: {}", e);
}
}
}
}
/// Terminate a session. Cancels all active proxy connections.
pub async fn delete_session(&self, id: Uuid) -> bool {
let mut sessions = self.sessions.write().await;
if let Some(session) = sessions.remove(&id) {
let mut session = session.lock().await;
session.cancel.cancel();
session.status = SessionStatus::Completed;
session.guacd_stream = None;
let (c, r) = drive_cleanup_settings(&self.config.drive);
cleanup_browser(&self.browser_manager, &mut session, c, r).await;
tracing::info!(session_id = %id, "Session terminated by API");
true
} else {
false
}
}
/// Reap active sessions that have exceeded the max duration.
/// Returns the number of sessions reaped.
pub async fn reap_expired_sessions(&self) -> usize {
let max_duration = std::time::Duration::from_secs(self.config.session_max_duration_secs);
let now = Utc::now();
let mut to_delete = Vec::new();
{
let sessions = self.sessions.read().await;
for (id, session) in sessions.iter() {
let session = session.lock().await;
if session.status == SessionStatus::Active
|| session.status == SessionStatus::Pending
{
let age = now.signed_duration_since(session.created_at);
if age.to_std().unwrap_or_default() > max_duration {
to_delete.push(*id);
}
}
}
}
let count = to_delete.len();
for id in to_delete {
tracing::warn!(session_id = %id, "Reaping session (exceeded max duration)");
self.delete_session(id).await;
}
count
}
/// Remove sessions in terminal states (Completed, Error, Expired) that have
/// been in that state longer than the configured cleanup delay. The session
/// history in SQLite is not affected — this only frees in-memory state.
pub async fn reap_completed_sessions(&self) -> usize {
let delay = std::time::Duration::from_secs(self.config.session_cleanup_delay_secs);
let now = Utc::now();
let mut to_remove = Vec::new();
{
let sessions = self.sessions.read().await;
for (id, session) in sessions.iter() {
let session = session.lock().await;
match session.status {
SessionStatus::Completed | SessionStatus::Error | SessionStatus::Expired => {
let age = now.signed_duration_since(session.created_at);
if age.to_std().unwrap_or_default() > delay {
to_remove.push(*id);
}
}
_ => {}
}
}
}
if !to_remove.is_empty() {
let mut sessions = self.sessions.write().await;
for id in &to_remove {
sessions.remove(id);
}
}
to_remove.len()
}
pub fn recording_path(&self) -> &std::path::Path {
self.config.effective_recording_path()
}
/// Path to the thumbnails directory (under recording_path).
pub fn thumbnails_dir(&self) -> std::path::PathBuf {
self.config.effective_recording_path().join("thumbnails")
}
/// Path to a specific session's thumbnail file.
pub fn thumbnail_path(&self, session_id: Uuid) -> std::path::PathBuf {
self.thumbnails_dir().join(format!("{}.jpg", session_id))
}
/// Path to a VDI container's thumbnail (persists across sessions).
pub fn vdi_thumbnail_path(&self, container_name: &str) -> std::path::PathBuf {
self.thumbnails_dir()
.join(format!("vdi-{}.jpg", container_name))
}
/// Check if recording is enabled for a given session.
pub async fn is_recording_enabled(&self, id: Uuid) -> bool {
let sessions = self.sessions.read().await;
if let Some(session) = sessions.get(&id) {
let session = session.lock().await;
session.recording_enabled
} else {
false
}
}
/// Get recording metadata for a session (address_book_entry, max_recordings).
pub async fn get_recording_meta(&self, id: Uuid) -> Option<(Option<String>, Option<u32>)> {
let sessions = self.sessions.read().await;
let session = sessions.get(&id)?;
let session = session.lock().await;
Some((session.address_book_entry.clone(), session.max_recordings))
}
/// Check if any active session references the given Docker container ID.
pub async fn has_active_vdi_session(&self, container_id: &str) -> bool {
let sessions = self.sessions.read().await;
for session in sessions.values() {
let session = session.lock().await;
if session.container_id.as_deref() == Some(container_id)
&& (session.status == SessionStatus::Active
|| session.status == SessionStatus::Pending)
{
return true;
}
}
false
}
pub fn session_max_duration_secs(&self) -> u64 {
self.config.session_max_duration_secs
}
pub fn recording_config(&self) -> crate::config::RecordingConfig {
self.config.recording_config()
}
}
/// Parse autofill credentials JSON and substitute $USERNAME/$PASSWORD placeholders.
/// Returns None if autofill is not configured or the JSON is invalid.
fn parse_autofill_credentials(
autofill_json: Option<&str>,
username: Option<&str>,
password: Option<&str>,
) -> Option<Vec<(String, String, String)>> {
let json_str = autofill_json?;
if json_str.is_empty() {
return None;
}
let entries: Vec<serde_json::Value> = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "Invalid autofill JSON, ignoring");
return None;
}
};
let user = username.unwrap_or("");
let pass = password.unwrap_or("");
let creds: Vec<(String, String, String)> = entries
.iter()
.filter_map(|entry| {
let url = entry.get("url")?.as_str()?;
let u = entry.get("username")?.as_str()?;
let p = entry.get("password")?.as_str()?;
let url = url.to_string();
let u = u.replace("$USERNAME", user);
let p = p.replace("$PASSWORD", pass);
Some((url, u, p))
})
.collect();
if creds.is_empty() {
None
} else {
Some(creds)
}
}
/// Kill browser processes if this is a web session, clean up drive directory,
/// abort any running login script, and shut down any SSH tunnel.
///
/// `cleanup_on_close` and `retention_secs` are sourced from `[drive]` config
/// at each call site (see `drive_cleanup_settings`). Per-session drive dirs
/// are only removed when `cleanup_on_close = true`. `retention_secs > 0`
/// schedules the removal that long after session end. With `cleanup_on_close
/// = false` the directory is left in place; the field is also cleared from
/// the session struct so subsequent reads don't think we still own it.
///
/// Note: cross-session persistence (the new session reading the old
/// session's files on disk) is NOT what these flags control. Each session
/// gets its own per-UUID subdirectory under `drive_path`, so even with
/// cleanup_on_close=false the next session sees an empty drive view.
async fn cleanup_browser(
browser_manager: &BrowserManager,
session: &mut Session,
cleanup_on_close: bool,
retention_secs: u64,
) {
// Abort login script if still running
if let Some(handle) = session.login_script_handle.take() {
handle.abort();
}
if let Some(ref mut bs) = session.browser_session {
browser_manager.kill(bs).await;
}
session.browser_session = None;
// Clean up per-session drive directory if configured to do so.
if let Some(drive_path) = session.drive_path.take() {
if cleanup_on_close {
drive::cleanup_session_dir(drive_path, session.id, retention_secs).await;
} else {
tracing::debug!(
session_id = %session.id,
"drive cleanup_on_close=false; leaving session drive directory on disk"
);
}
}
// Shut down SSH tunnel chain (reverse order)
tunnel::shutdown_chain(&session.tunnels);
session.tunnels.clear();
}
/// Resolve cleanup behaviour from optional `[drive]` config. Falls back to
/// the historical "always wipe immediately" defaults when no config is set
/// (preserves existing behaviour for installs that never enabled drive).
fn drive_cleanup_settings(drive: &Option<DriveConfig>) -> (bool, u64) {
match drive {
Some(d) => (d.cleanup_on_close, d.retention_secs),
None => (true, 0),
}
}
#[derive(Debug)]
pub enum SessionError {
GuacdConnection(String),
NotFound,
NotActive,
ValidationError(String),
BrowserSpawn(String),
VdiError(String),
}
/// Pure token-matching helper — constant-time comparison of a provided
/// token against the owner's long-lived `share_token` and the in-memory
/// set of short-lived admin shadow tokens. Factored out so the logic is
/// unit-testable without spinning up a full `SessionManager`.
pub(crate) fn check_share_token_match(
share_token: &str,
shadow_tokens: &[ShadowToken],
provided: &str,
now: DateTime<Utc>,
) -> ShareTokenValidation {
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
let provided_digest = Sha256::digest(provided.as_bytes());
// 1. Owner's long-lived share token.
let expected = Sha256::digest(share_token.as_bytes());
if bool::from(expected.ct_eq(&provided_digest)) {
return ShareTokenValidation::Owner;
}
// 2. Short-lived admin shadow tokens (sha256 compared to the
// pre-hashed hex stored on the session).
let provided_hex = hex::encode(provided_digest);
for t in shadow_tokens {
if t.expires_at <= now {
continue;
}
if t.token_hash.len() == provided_hex.len()
&& t.token_hash
.as_bytes()
.ct_eq(provided_hex.as_bytes())
.into()
{
return ShareTokenValidation::Shadow {
issued_by: t.issued_by.clone(),
};
}
}
ShareTokenValidation::Invalid
}
#[cfg(test)]
mod tests {
use super::*;
fn make_shadow(raw: &str, issued_by: &str, expires_at: DateTime<Utc>) -> ShadowToken {
use sha2::{Digest, Sha256};
ShadowToken {
token_hash: hex::encode(Sha256::digest(raw.as_bytes())),
issued_by: issued_by.to_string(),
expires_at,
}
}
#[test]
fn share_token_owner_match() {
let now = Utc::now();
let v = check_share_token_match("owner-secret", &[], "owner-secret", now);
assert_eq!(v, ShareTokenValidation::Owner);
}
#[test]
fn share_token_wrong_returns_invalid() {
let now = Utc::now();
let v = check_share_token_match("owner-secret", &[], "wrong", now);
assert_eq!(v, ShareTokenValidation::Invalid);
}
#[test]
fn share_token_empty_provided_invalid() {
let now = Utc::now();
let v = check_share_token_match("owner-secret", &[], "", now);
assert_eq!(v, ShareTokenValidation::Invalid);
}
#[test]
fn share_token_shadow_hit_returns_issued_by() {
let now = Utc::now();
let shadow = make_shadow(
"shadow-raw",
"admin@example.com",
now + chrono::Duration::minutes(5),
);
let v = check_share_token_match("owner-secret", &[shadow], "shadow-raw", now);
assert_eq!(
v,
ShareTokenValidation::Shadow {
issued_by: "admin@example.com".into()
}
);
}
#[test]
fn share_token_expired_shadow_rejected() {
let now = Utc::now();
let expired = make_shadow("shadow-raw", "admin", now - chrono::Duration::minutes(1));
let v = check_share_token_match("owner-secret", &[expired], "shadow-raw", now);
assert_eq!(v, ShareTokenValidation::Invalid);
}
#[test]
fn share_token_expires_at_now_treated_as_expired() {
// Boundary: expires_at <= now must reject.
let now = Utc::now();
let at_boundary = make_shadow("shadow-raw", "admin", now);
let v = check_share_token_match("owner-secret", &[at_boundary], "shadow-raw", now);
assert_eq!(v, ShareTokenValidation::Invalid);
}
#[test]
fn share_token_multiple_shadows_one_matches() {
let now = Utc::now();
let ttl = now + chrono::Duration::minutes(5);
let a = make_shadow("aaa", "admin1", ttl);
let b = make_shadow("bbb", "admin2", ttl);
let c = make_shadow("ccc", "admin3", ttl);
let shadows = vec![a, b, c];
let v = check_share_token_match("owner", &shadows, "bbb", now);
assert_eq!(
v,
ShareTokenValidation::Shadow {
issued_by: "admin2".into()
}
);
}
#[test]
fn share_token_owner_wins_over_shadow_of_same_string() {
// If somehow a shadow's raw value equalled the owner's share token,
// the owner path takes precedence (owner is checked first).
let now = Utc::now();
let shadow = make_shadow("collide", "admin", now + chrono::Duration::minutes(5));
let v = check_share_token_match("collide", &[shadow], "collide", now);
assert_eq!(v, ShareTokenValidation::Owner);
}
#[test]
fn share_token_validation_is_valid_helper() {
assert!(ShareTokenValidation::Owner.is_valid());
assert!(ShareTokenValidation::Shadow {
issued_by: "x".into()
}
.is_valid());
assert!(!ShareTokenValidation::Invalid.is_valid());
}
// ── SessionManager async tests (in-memory, no disk/browser/guacd) ──
//
// These exercise the mint → validate → audit path end-to-end without
// spinning up guacd, a browser, or touching the real recording dir.
fn seed_test_session(share_token: &str) -> Session {
Session {
id: Uuid::new_v4(),
session_type: SessionType::Ssh,
status: SessionStatus::Active,
created_at: Utc::now(),
hostname: "test-host".into(),
username: "alice".into(),
url: None,
banner: None,
guacd_stream: None,
connection_id: "conn-test".into(),
share_token: share_token.to_string(),
width: 1024,
height: 768,
active_connections: 0,
created_by: "alice".into(),
cancel: CancellationToken::new(),
browser_session: None,
deferred_params: None,
drive_path: None,
tunnels: Vec::new(),
container_id: None,
recording_enabled: false,
address_book_entry: None,
address_book_folder: None,
entry_display_name: None,
max_recordings: None,
login_script_handle: None,
shadow_tokens: Vec::new(),
share_allowed: true,
}
}
fn new_manager_for_tests() -> SessionManager {
// Build a config pointing at a unique temp recording dir so the
// real dir isn't touched and parallel tests don't collide.
let mut config = crate::config::Config::default();
let tmp = std::env::temp_dir().join(format!("rustguac-sessmgr-test-{}", Uuid::new_v4()));
config.recording_path = tmp.clone();
// xvnc/chromium paths are only stored, not exec'd — placeholders are fine.
config.xvnc_path = "/bin/true".into();
config.chromium_path = "/bin/true".into();
config.login_scripts_dir = "/tmp".into();
SessionManager::new(config, None)
}
async fn insert_session(mgr: &SessionManager, session: Session) -> Uuid {
let id = session.id;
mgr.sessions
.write()
.await
.insert(id, Arc::new(Mutex::new(session)));
id
}
#[tokio::test]
async fn manager_owner_token_validates() {
let mgr = new_manager_for_tests();
let id = insert_session(&mgr, seed_test_session("owner-secret")).await;
assert_eq!(
mgr.validate_share_token(id, "owner-secret").await,
ShareTokenValidation::Owner
);
assert_eq!(
mgr.validate_share_token(id, "wrong").await,
ShareTokenValidation::Invalid
);
}
#[tokio::test]
async fn manager_mint_shadow_then_validate() {
let mgr = new_manager_for_tests();
let id = insert_session(&mgr, seed_test_session("owner-secret")).await;
let (raw, expires_at) = mgr
.mint_shadow_token(id, "admin@example.com")
.await
.expect("mint");
assert!(expires_at > Utc::now());
// 10-minute TTL — allow small scheduling drift.
let ttl_ms = (expires_at - Utc::now()).num_milliseconds();
assert!(ttl_ms > 9 * 60 * 1000 && ttl_ms <= 10 * 60 * 1000);
// Shadow validates and returns the issuer.
match mgr.validate_share_token(id, &raw).await {
ShareTokenValidation::Shadow { issued_by } => {
assert_eq!(issued_by, "admin@example.com")
}
other => panic!("expected Shadow variant, got {:?}", other),
}
}
#[tokio::test]
async fn manager_shadow_token_is_session_scoped() {
// IDOR guard: a shadow token minted for session A must NOT validate
// against session B.
let mgr = new_manager_for_tests();
let id_a = insert_session(&mgr, seed_test_session("owner-a")).await;
let id_b = insert_session(&mgr, seed_test_session("owner-b")).await;
let (raw, _) = mgr.mint_shadow_token(id_a, "admin").await.expect("mint");
assert_eq!(
mgr.validate_share_token(id_b, &raw).await,
ShareTokenValidation::Invalid
);
}
#[tokio::test]
async fn manager_mint_prunes_expired_shadow_tokens() {
let mgr = new_manager_for_tests();
let mut session = seed_test_session("owner");
let id = session.id;
// Seed an already-expired shadow token directly.
session.shadow_tokens.push(ShadowToken {
token_hash: "deadbeef".into(),
issued_by: "stale".into(),
expires_at: Utc::now() - chrono::Duration::hours(1),
});
mgr.sessions
.write()
.await
.insert(id, Arc::new(Mutex::new(session)));
// Mint pruning is documented on mint_shadow_token.
let _ = mgr.mint_shadow_token(id, "admin").await.unwrap();
let sessions = mgr.sessions.read().await;
let guard = sessions.get(&id).unwrap().lock().await;
assert_eq!(guard.shadow_tokens.len(), 1, "expired should be pruned");
assert_ne!(guard.shadow_tokens[0].issued_by, "stale");
}
#[tokio::test]
async fn manager_validate_rejects_unknown_session() {
let mgr = new_manager_for_tests();
let phantom = Uuid::new_v4();
assert_eq!(
mgr.validate_share_token(phantom, "anything").await,
ShareTokenValidation::Invalid
);
}
#[tokio::test]
async fn manager_disconnect_viewer_saturating_decrement() {
let mgr = new_manager_for_tests();
let mut seed = seed_test_session("owner");
seed.active_connections = 2;
let id = insert_session(&mgr, seed).await;
mgr.disconnect_viewer(id).await;
mgr.disconnect_viewer(id).await;
// One extra call must NOT underflow to u32::MAX.
mgr.disconnect_viewer(id).await;
let sessions = mgr.sessions.read().await;
let guard = sessions.get(&id).unwrap().lock().await;
assert_eq!(guard.active_connections, 0);
}
#[test]
fn test_check_allowed_network_ipv4_match() {
assert!(check_allowed_network("127.0.0.1", 22, &["127.0.0.0/8".into()]).is_ok());
assert!(check_allowed_network("10.1.2.3", 80, &["10.0.0.0/8".into()]).is_ok());
}
#[test]
fn test_check_allowed_network_ipv4_denied() {
let err = check_allowed_network("8.8.8.8", 22, &["127.0.0.0/8".into()]);
assert!(err.is_err());
}
#[test]
fn test_check_allowed_network_empty_allowlist() {
let err = check_allowed_network("127.0.0.1", 22, &[]);
assert!(err.is_err());
let msg = format!("{}", err.unwrap_err());
assert!(msg.contains("no valid CIDR"), "got: {}", msg);
}
#[test]
fn test_check_allowed_network_multiple_cidrs() {
let cidrs = vec!["10.0.0.0/8".into(), "192.168.0.0/16".into()];
assert!(check_allowed_network("10.1.1.1", 22, &cidrs).is_ok());
assert!(check_allowed_network("192.168.1.1", 22, &cidrs).is_ok());
assert!(check_allowed_network("172.16.0.1", 22, &cidrs).is_err());
}
#[test]
fn test_check_allowed_network_localhost_resolves() {
// "localhost" should resolve to 127.0.0.1 or ::1
let cidrs = vec!["127.0.0.0/8".into(), "::1/128".into()];
assert!(check_allowed_network("localhost", 80, &cidrs).is_ok());
}
#[test]
fn test_parse_autofill_none() {
assert!(parse_autofill_credentials(None, None, None).is_none());
}
#[test]
fn test_parse_autofill_empty_string() {
assert!(parse_autofill_credentials(Some(""), None, None).is_none());
}
#[test]
fn test_parse_autofill_invalid_json() {
assert!(parse_autofill_credentials(Some("not json"), None, None).is_none());
}
#[test]
fn test_parse_autofill_empty_array() {
assert!(parse_autofill_credentials(Some("[]"), None, None).is_none());
}
#[test]
fn test_parse_autofill_basic() {
let json = r#"[{"url":"https://example.com","username":"alice","password":"secret"}]"#;
let creds = parse_autofill_credentials(Some(json), None, None).unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].0, "https://example.com");
assert_eq!(creds[0].1, "alice");
assert_eq!(creds[0].2, "secret");
}
#[test]
fn test_parse_autofill_placeholder_substitution() {
let json = r#"[{"url":"https://ex.com","username":"$USERNAME","password":"$PASSWORD"}]"#;
let creds = parse_autofill_credentials(Some(json), Some("bob"), Some("pass123")).unwrap();
assert_eq!(creds[0].1, "bob");
assert_eq!(creds[0].2, "pass123");
}
#[test]
fn test_parse_autofill_placeholder_no_credentials() {
// Placeholders with no username/password should substitute empty strings
let json = r#"[{"url":"https://ex.com","username":"$USERNAME","password":"$PASSWORD"}]"#;
let creds = parse_autofill_credentials(Some(json), None, None).unwrap();
assert_eq!(creds[0].1, "");
assert_eq!(creds[0].2, "");
}
#[test]
fn test_parse_autofill_multiple_entries() {
let json = r#"[
{"url":"https://app.com","username":"$USERNAME","password":"$PASSWORD"},
{"url":"https://idp.com","username":"$USERNAME","password":"$PASSWORD"}
]"#;
let creds = parse_autofill_credentials(Some(json), Some("alice"), Some("secret")).unwrap();
assert_eq!(creds.len(), 2);
assert_eq!(creds[0].0, "https://app.com");
assert_eq!(creds[1].0, "https://idp.com");
}
#[test]
fn test_parse_autofill_missing_fields_skipped() {
// Entries missing required fields are silently skipped
let json =
r#"[{"url":"https://ex.com"},{"url":"https://ok.com","username":"a","password":"b"}]"#;
let creds = parse_autofill_credentials(Some(json), None, None).unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].0, "https://ok.com");
}
#[test]
fn drive_cleanup_settings_no_config_uses_legacy_defaults() {
// No drive config = legacy "always wipe immediately" so existing
// installs that never touched [drive] keep prior behaviour.
assert_eq!(drive_cleanup_settings(&None), (true, 0));
}
#[test]
fn drive_cleanup_settings_passes_through_config() {
let cfg = DriveConfig {
enabled: true,
cleanup_on_close: false,
retention_secs: 600,
..DriveConfig::default()
};
assert_eq!(drive_cleanup_settings(&Some(cfg)), (false, 600));
}
#[test]
fn drive_cleanup_settings_default_drive_config() {
// The default DriveConfig uses cleanup_on_close=true, retention=0.
let cfg = DriveConfig::default();
assert_eq!(drive_cleanup_settings(&Some(cfg)), (true, 0));
}
}
impl std::fmt::Display for SessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionError::GuacdConnection(msg) => write!(f, "guacd connection failed: {}", msg),
SessionError::NotFound => write!(f, "session not found"),
SessionError::NotActive => write!(f, "session is not active"),
SessionError::ValidationError(msg) => write!(f, "validation error: {}", msg),
SessionError::BrowserSpawn(msg) => write!(f, "browser spawn failed: {}", msg),
SessionError::VdiError(msg) => write!(f, "VDI error: {}", msg),
}
}
}