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.
This commit is contained in:
Lindsay Harvey
2026-05-20 18:52:22 +10:00
committed by GitHub
parent 9cc78b4490
commit 66bc3722fc
5 changed files with 461 additions and 73 deletions
+8
View File
@@ -240,6 +240,10 @@ Enables VDI (Virtual Desktop Infrastructure) sessions using Docker containers. E
| `default_cpu_limit` | float | `0` | Default CPU limit for containers (fractional cores, e.g. 2.0). 0 = no limit. |
| `default_memory_limit` | integer | `0` | Default memory limit in MB. 0 = no limit. |
| `ready_timeout_secs` | integer | `30` | Seconds to wait for xrdp to become ready in a new container. |
| `port_range_start` | integer | *(none)* | First localhost port Docker may bind VDI RDP to. Must be set with `port_range_end`. |
| `port_range_end` | integer | *(none)* | Last localhost port Docker may bind VDI RDP to. Must be set with `port_range_start`. |
| `container_hook_script` | string | *(none)* | Optional VDI container hook script. Called as `<script> up <port> <container_id> <container_name>` before readiness checks and `<script> down <port> <container_id> <container_name>` before removal. |
| `container_hook_timeout_secs` | integer | `10` | Seconds to wait for the VDI container hook script. |
| `idle_timeout_mins` | integer | `60` | Minutes a container persists after last session disconnect. 0 = remove immediately. |
| `allowed_images` | list | `[]` | Allowed Docker images (exact match). Empty = allow all. |
| `home_base` | string | *(none)* | Base directory for persistent user home dirs. Each user gets `{home_base}/{username}` mounted into the container. |
@@ -248,6 +252,10 @@ Enables VDI (Virtual Desktop Infrastructure) sessions using Docker containers. E
[vdi]
enabled = true
idle_timeout_mins = 60
# port_range_start = 39000
# port_range_end = 39999
# container_hook_script = "/opt/rustguac/vdi-container-hook.sh"
# container_hook_timeout_secs = 10
home_base = "/vdi-homes"
# allowed_images = ["myregistry/desktop:latest"]
```
+24
View File
@@ -38,6 +38,10 @@ enabled = true
# default_cpu_limit = 2.0 # cores, 0 = no limit
# default_memory_limit = 2048 # MB, 0 = no limit
# ready_timeout_secs = 30 # wait for xrdp to start
# port_range_start = 39000 # optional localhost RDP port range
# port_range_end = 39999
# container_hook_script = "/opt/rustguac/vdi-container-hook.sh"
# container_hook_timeout_secs = 10
# idle_timeout_mins = 60 # container lifetime after disconnect
# home_base = "/vdi-homes" # persistent home directories
# allowed_images = ["myregistry/desktop:latest"] # whitelist, empty = allow all
@@ -141,6 +145,26 @@ The connections shows an **Active Sessions** section with thumbnail previews of
Dormant VDI containers (running but no active browser session) also appear with their last captured thumbnail.
## Container hook
Set `container_hook_script` when Rustguac needs an external command to prepare
or tear down access to a container's mapped RDP port. This can be used for
deployment-specific setup that must happen after Docker has assigned the port
and before Rustguac starts probing xrdp.
Rustguac calls the script as:
```bash
/opt/rustguac/vdi-container-hook.sh up <port> <container_id> <container_name>
/opt/rustguac/vdi-container-hook.sh down <port> <container_id> <container_name>
```
`up` runs after Docker inspect finds the mapped RDP port and before Rustguac
checks whether xrdp is ready on `127.0.0.1:<port>`. The script should return
only after the local listener is available. `down` runs before Rustguac stops
and removes the container. Hook execution is limited by
`container_hook_timeout_secs` (default: 10 seconds).
## Per-entry settings
Each VDI connections entry can override:
+19
View File
@@ -241,6 +241,21 @@ pub struct VdiConfig {
/// Seconds to wait for xrdp to become ready in a new container. Default: 30.
#[serde(default = "default_ready_timeout_secs")]
pub ready_timeout_secs: u64,
/// First localhost port Docker may bind VDI RDP to. Unset = Docker chooses any random port.
#[serde(default)]
pub port_range_start: Option<u16>,
/// Last localhost port Docker may bind VDI RDP to. Unset = Docker chooses any random port.
#[serde(default)]
pub port_range_end: Option<u16>,
/// Optional script called when a VDI container's mapped RDP port should be
/// prepared or torn down. Called as:
/// <script> up <port> <container_id> <container_name>
/// <script> down <port> <container_id> <container_name>
#[serde(default)]
pub container_hook_script: Option<String>,
/// Seconds to wait for the VDI container hook script. Default: 10.
#[serde(default = "default_container_hook_timeout_secs")]
pub container_hook_timeout_secs: u64,
/// Minutes a container persists after last session disconnect. Default: 60.
/// Containers are kept running for reconnection. Set to 0 for immediate removal.
#[serde(default = "default_idle_timeout_mins")]
@@ -263,6 +278,10 @@ fn default_ready_timeout_secs() -> u64 {
30
}
fn default_container_hook_timeout_secs() -> u64 {
10
}
fn default_idle_timeout_mins() -> u64 {
60
}
+27 -1
View File
@@ -479,10 +479,36 @@ impl SessionManager {
}
match crate::vdi::DockerDriver::new(&vdi_cfg.docker_socket) {
Ok(driver) => {
let driver = driver.with_ready_timeout(vdi_cfg.ready_timeout_secs);
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))
+383 -72
View File
@@ -10,11 +10,15 @@ use bollard::query_parameters::{
use bollard::Docker;
use std::collections::HashMap;
use std::time::Duration;
use tokio::process::Command;
/// Docker-based VDI driver. Connects to the local Docker daemon via unix socket.
pub struct DockerDriver {
client: Docker,
ready_timeout: Duration,
host_port_range: Option<(u16, u16)>,
container_hook_script: Option<String>,
container_hook_timeout: Duration,
}
impl DockerDriver {
@@ -30,6 +34,9 @@ impl DockerDriver {
Ok(Self {
client,
ready_timeout: Duration::from_secs(30),
host_port_range: None,
container_hook_script: None,
container_hook_timeout: Duration::from_secs(10),
})
}
@@ -39,6 +46,25 @@ impl DockerDriver {
self
}
/// Restrict Docker's published host port for RDP to an inclusive range.
pub fn with_host_port_range(mut self, start: u16, end: u16) -> Result<Self, VdiError> {
if start == 0 || start > end {
return Err(VdiError::Docker(format!(
"invalid VDI port range: {}-{}",
start, end
)));
}
self.host_port_range = Some((start, end));
Ok(self)
}
/// Configure an external VDI container hook script.
pub fn with_container_hook(mut self, script: Option<String>, timeout_secs: u64) -> Self {
self.container_hook_script = script.filter(|s| !s.trim().is_empty());
self.container_hook_timeout = Duration::from_secs(timeout_secs.max(1));
self
}
/// Sanitize a username into a valid Docker container name suffix.
fn sanitize_username(username: &str) -> String {
username
@@ -55,6 +81,126 @@ impl DockerDriver {
format!("rustguac-vdi-{}", Self::sanitize_username(username))
}
/// Candidate host ports to ask Docker to bind. Without a configured range,
/// `0` preserves Docker's default random-port allocation.
fn host_port_candidates(host_port_range: Option<(u16, u16)>, username: &str) -> Vec<String> {
let Some((start, end)) = host_port_range else {
return vec!["0".into()];
};
let len = end as u32 - start as u32 + 1;
let hash = username.bytes().fold(0xcbf29ce484222325_u64, |hash, b| {
hash.wrapping_mul(0x100000001b3) ^ b as u64
});
let offset = (hash % len as u64) as u32;
(0..len)
.map(|i| {
let port = start as u32 + ((offset + i) % len);
port.to_string()
})
.collect()
}
fn port_in_configured_range(&self, port: u16) -> bool {
match self.host_port_range {
Some((start, end)) => (start..=end).contains(&port),
None => true,
}
}
fn extract_container_name(
inspect: &bollard::models::ContainerInspectResponse,
fallback: &str,
) -> String {
inspect
.name
.as_deref()
.unwrap_or(fallback)
.trim_start_matches('/')
.to_string()
}
async fn run_container_hook(
&self,
action: &str,
port: u16,
container_id: &str,
container_name: &str,
) -> Result<(), VdiError> {
let Some(script) = self.container_hook_script.as_deref() else {
return Ok(());
};
let mut child = Command::new(script);
child
.arg(action)
.arg(port.to_string())
.arg(container_id)
.arg(container_name)
.env("RUSTGUAC_VDI_HOOK_ACTION", action)
.env("RUSTGUAC_VDI_PORT", port.to_string())
.env("RUSTGUAC_VDI_CONTAINER_ID", container_id)
.env("RUSTGUAC_VDI_CONTAINER_NAME", container_name);
let output = tokio::time::timeout(self.container_hook_timeout, child.output())
.await
.map_err(|_| {
VdiError::Timeout(format!(
"VDI container hook '{}' timed out after {}s",
script,
self.container_hook_timeout.as_secs()
))
})?
.map_err(|e| {
VdiError::Docker(format!(
"failed to run VDI container hook '{}': {}",
script, e
))
})?;
if output.status.success() {
tracing::info!(
hook = %script,
action,
port,
container = %container_name,
"VDI container hook completed"
);
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(VdiError::Docker(format!(
"VDI container hook '{}' failed for action '{}' with status {}{}{}",
script,
action,
output.status,
if stdout.trim().is_empty() {
String::new()
} else {
format!("; stdout: {}", stdout.trim())
},
if stderr.trim().is_empty() {
String::new()
} else {
format!("; stderr: {}", stderr.trim())
}
)))
}
async fn prepare_rdp_endpoint(
&self,
port: u16,
container_id: &str,
container_name: &str,
) -> Result<(), VdiError> {
self.run_container_hook("up", port, container_id, container_name)
.await?;
self.wait_for_ready("127.0.0.1", port).await
}
/// Extract the host-mapped port for container port 3389/tcp from inspect data.
fn extract_mapped_port(
inspect: &bollard::models::ContainerInspectResponse,
@@ -229,6 +375,18 @@ impl DockerDriver {
if running {
// Container is running — reuse it, but update password
let port = Self::extract_mapped_port(&inspect)?;
let container_id = inspect.id.as_deref().unwrap_or(&name).to_string();
let container_name = Self::extract_container_name(&inspect, &name);
if !self.port_in_configured_range(port) {
tracing::info!(
container = %name,
port,
"VDI container port is outside configured range — replacing container"
);
let cid = inspect.id.as_deref().unwrap_or(&name);
let _ = self.do_stop_container(cid).await;
return Box::pin(self.do_start_or_reuse(spec)).await;
}
self.update_container_password(&name, &spec.username, &spec.password)
.await?;
tracing::info!(
@@ -236,8 +394,24 @@ impl DockerDriver {
port,
"Reusing existing VDI container"
);
if let Err(e) = self
.prepare_rdp_endpoint(port, &container_id, &container_name)
.await
{
if self.host_port_range.is_some() {
tracing::warn!(
container = %name,
port,
"Reused VDI container endpoint failed; replacing container: {}",
e
);
let _ = self.do_stop_container(&container_id).await;
return Box::pin(self.do_start_or_reuse(spec)).await;
}
return Err(e);
}
return Ok(ContainerInfo {
container_id: inspect.id.unwrap_or_default(),
container_id,
rdp_host: "127.0.0.1".into(),
rdp_port: port,
reused: true,
@@ -245,27 +419,64 @@ impl DockerDriver {
}
// Container exists but stopped — start it
let port = Self::extract_mapped_port(&inspect)?;
let container_id = inspect.id.as_deref().unwrap_or(&name).to_string();
let container_name = Self::extract_container_name(&inspect, &name);
if !self.port_in_configured_range(port) {
tracing::info!(
container = %name,
port,
"Stopped VDI container port is outside configured range — replacing container"
);
let cid = inspect.id.as_deref().unwrap_or(&name);
let _ = self.do_stop_container(cid).await;
return Box::pin(self.do_start_or_reuse(spec)).await;
}
// Container exists but stopped with an acceptable port — start it
tracing::info!(container = %name, "Starting stopped VDI container");
self.client
if let Err(e) = self
.client
.start_container(&name, None::<StartContainerOptions>)
.await
.map_err(|e| VdiError::Docker(format!("failed to start container: {}", e)))?;
{
if self.host_port_range.is_some() {
tracing::warn!(
container = %name,
port,
"Stopped VDI container failed to start; replacing container: {}",
e
);
let _ = self.do_stop_container(&container_id).await;
return Box::pin(self.do_start_or_reuse(spec)).await;
}
return Err(VdiError::Docker(format!(
"failed to start container: {}",
e
)));
}
// Re-inspect to get port mapping
let inspect = self
.client
.inspect_container(&name, None)
if let Err(e) = self
.prepare_rdp_endpoint(port, &container_id, &container_name)
.await
.map_err(|e| {
VdiError::Docker(format!("failed to inspect after start: {}", e))
})?;
let port = Self::extract_mapped_port(&inspect)?;
self.wait_for_ready("127.0.0.1", port).await?;
{
if self.host_port_range.is_some() {
tracing::warn!(
container = %name,
port,
"Started VDI container endpoint failed; replacing container: {}",
e
);
let _ = self.do_stop_container(&container_id).await;
return Box::pin(self.do_start_or_reuse(spec)).await;
}
return Err(e);
}
self.update_container_password(&name, &spec.username, &spec.password)
.await?;
Ok(ContainerInfo {
container_id: inspect.id.unwrap_or_default(),
container_id,
rdp_host: "127.0.0.1".into(),
rdp_port: port,
reused: true,
@@ -304,16 +515,6 @@ impl DockerDriver {
env_vec.push(format!("{}={}", k, v));
}
// Port binding: 3389/tcp → random host port
let mut port_bindings = HashMap::new();
port_bindings.insert(
"3389/tcp".to_string(),
Some(vec![PortBinding {
host_ip: Some("127.0.0.1".into()),
host_port: Some("0".into()), // random port
}]),
);
// Labels
let mut labels = HashMap::new();
labels.insert("rustguac.managed".to_string(), "true".to_string());
@@ -364,62 +565,108 @@ impl DockerDriver {
None
};
let host_config = HostConfig {
port_bindings: Some(port_bindings),
nano_cpus,
memory,
binds,
..Default::default()
};
let mut last_error = None;
for host_port in Self::host_port_candidates(self.host_port_range, &spec.username) {
// Port binding: 3389/tcp → selected localhost host port.
// A configured range uses explicit ports; no range uses
// Docker's random allocation via host port 0.
let mut port_bindings = HashMap::new();
port_bindings.insert(
"3389/tcp".to_string(),
Some(vec![PortBinding {
host_ip: Some("127.0.0.1".into()),
host_port: Some(host_port.clone()),
}]),
);
let config = ContainerCreateBody {
image: Some(spec.image.clone()),
env: Some(env_vec),
labels: Some(labels),
host_config: Some(host_config),
..Default::default()
};
let host_config = HostConfig {
port_bindings: Some(port_bindings),
nano_cpus,
memory,
binds: binds.clone(),
..Default::default()
};
let opts = CreateContainerOptions {
name: Some(name.clone()),
..Default::default()
};
let config = ContainerCreateBody {
image: Some(spec.image.clone()),
env: Some(env_vec.clone()),
labels: Some(labels.clone()),
host_config: Some(host_config),
..Default::default()
};
let created = self
.client
.create_container(Some(opts), config)
.await
.map_err(|e| VdiError::Docker(format!("failed to create container: {}", e)))?;
let opts = CreateContainerOptions {
name: Some(name.clone()),
..Default::default()
};
self.client
.start_container(&name, None::<StartContainerOptions>)
.await
.map_err(|e| VdiError::Docker(format!("failed to start container: {}", e)))?;
let created = match self.client.create_container(Some(opts), config).await {
Ok(created) => created,
Err(e) => {
last_error = Some(format!("failed to create container: {}", e));
break;
}
};
// Inspect to get the mapped port
let inspect = self
.client
.inspect_container(&name, None)
.await
.map_err(|e| {
VdiError::Docker(format!("failed to inspect after create: {}", e))
})?;
let port = Self::extract_mapped_port(&inspect)?;
self.wait_for_ready("127.0.0.1", port).await?;
if let Err(e) = self
.client
.start_container(&name, None::<StartContainerOptions>)
.await
{
last_error = Some(format!(
"failed to start container with host port {}: {}",
host_port, e
));
let _ = self.do_stop_container(&created.id).await;
if self.host_port_range.is_some() {
continue;
}
break;
}
tracing::info!(
container = %name,
container_id = %created.id,
port,
"VDI container ready"
);
// Inspect to get the mapped port
let inspect =
self.client
.inspect_container(&name, None)
.await
.map_err(|e| {
VdiError::Docker(format!("failed to inspect after create: {}", e))
})?;
let port = Self::extract_mapped_port(&inspect)?;
let container_name = Self::extract_container_name(&inspect, &name);
if let Err(e) = self
.prepare_rdp_endpoint(port, &created.id, &container_name)
.await
{
last_error = Some(format!(
"failed to prepare VDI endpoint with host port {}: {}",
port, e
));
let _ = self.do_stop_container(&created.id).await;
if self.host_port_range.is_some() {
continue;
}
break;
}
Ok(ContainerInfo {
container_id: created.id,
rdp_host: "127.0.0.1".into(),
rdp_port: port,
reused: false,
})
tracing::info!(
container = %name,
container_id = %created.id,
port,
"VDI container ready"
);
return Ok(ContainerInfo {
container_id: created.id,
rdp_host: "127.0.0.1".into(),
rdp_port: port,
reused: false,
});
}
Err(VdiError::Docker(last_error.unwrap_or_else(|| {
"failed to allocate a VDI host port".into()
})))
}
Err(e) => Err(VdiError::Docker(format!(
"failed to inspect container: {}",
@@ -429,6 +676,37 @@ impl DockerDriver {
}
async fn do_stop_container(&self, container_id: &str) -> Result<(), VdiError> {
let hook_info = match self.client.inspect_container(container_id, None).await {
Ok(inspect) => {
let port = Self::extract_mapped_port(&inspect).ok();
let name = Self::extract_container_name(&inspect, container_id);
port.map(|port| (port, name))
}
Err(e) => {
tracing::debug!(
container_id,
"Skipping VDI container hook teardown; inspect failed: {}",
e
);
None
}
};
if let Some((port, name)) = hook_info {
if let Err(e) = self
.run_container_hook("down", port, container_id, &name)
.await
{
tracing::warn!(
container_id,
container = %name,
port,
"VDI container hook teardown failed: {}",
e
);
}
}
// Stop with 5s grace period
let _ = self
.client
@@ -654,4 +932,37 @@ mod tests {
}
}
}
#[test]
fn host_port_candidates_defaults_to_docker_random() {
assert_eq!(
DockerDriver::host_port_candidates(None, "alice"),
vec!["0".to_string()]
);
}
#[test]
fn host_port_candidates_stay_within_configured_range() {
let ports = DockerDriver::host_port_candidates(Some((39000, 39003)), "alice");
assert_eq!(ports.len(), 4);
for port in ports {
let port = port.parse::<u16>().unwrap();
assert!((39000..=39003).contains(&port));
}
}
#[test]
fn host_port_candidates_try_each_port_once() {
let mut ports = DockerDriver::host_port_candidates(Some((39000, 39003)), "alice");
ports.sort();
assert_eq!(
ports,
vec![
"39000".to_string(),
"39001".to_string(),
"39002".to_string(),
"39003".to_string()
]
);
}
}