ref:main
//! Docker backend — spawns shells inside Docker containers.
//!
//! Communicates with the Docker Engine API over the unix socket at
//! `/var/run/docker.sock`. Uses raw HTTP/1.1 over `UnixStream` — no
//! hyper/tokio/hyperlocal needed. With `Tty: true`, the attach stream
//! is raw bytes in both directions (no Docker multiplexing).
//!
//! Container lifecycle:
//! spawn() → POST /containers/create → POST /start → POST /attach (hijacked)
//! kill() → POST /containers/{id}/kill
//! Drop → POST /kill → DELETE /containers/{id}
use super::{BackendConfig, TerminalBackend, TerminalSession};
#[cfg(unix)]
use std::io::{BufRead, BufReader, Read, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
const DOCKER_SOCKET: &str = "/var/run/docker.sock";
/// Backend that spawns shells inside Docker containers.
pub struct DockerBackend;
impl TerminalBackend for DockerBackend {
fn spawn(&self, config: &BackendConfig) -> Result<Box<dyn TerminalSession>, String> {
#[cfg(unix)]
{
let image = config
.image
.as_deref()
.unwrap_or("ubuntu:24.04");
let memory = config
.memory_limit
.as_deref()
.unwrap_or("256m");
let cpu = config.cpu_limit.unwrap_or(0.5);
let network = config.network_enabled.unwrap_or(false);
// Parse memory limit (e.g., "256m" -> bytes)
let memory_bytes = parse_memory_limit(memory);
let nano_cpus = (cpu * 1_000_000_000.0) as u64;
let network_mode = if network { "bridge" } else { "none" };
// 1. Create container
let create_body = format!(
r#"{{
"Image": "{}",
"Cmd": ["/bin/bash"],
"Tty": true,
"OpenStdin": true,
"StdinOnce": false,
"AttachStdin": true,
"AttachStdout": true,
"AttachStderr": true,
"Env": ["TERM=xterm-256color", "COLORTERM=truecolor"],
"HostConfig": {{
"Memory": {},
"NanoCpus": {},
"NetworkMode": "{}"
}}
}}"#,
image, memory_bytes, nano_cpus, network_mode
);
let create_resp =
docker_post("/containers/create", Some(&create_body))?;
let container_id = extract_json_field(&create_resp, "Id")
.ok_or_else(|| format!("No container Id in response: {}", create_resp))?;
// 2. Start container
let start_path = format!("/containers/{}/start", container_id);
docker_post(&start_path, None)?;
// 3. Resize to requested dimensions
let resize_path = format!(
"/containers/{}/resize?h={}&w={}",
container_id, config.rows, config.cols
);
docker_post(&resize_path, None)?;
// 4. Attach (hijacked connection — becomes raw bidirectional stream)
let attach_path = format!(
"/containers/{}/attach?stream=1&stdin=1&stdout=1&stderr=1",
container_id
);
let stream = docker_attach(&attach_path)?;
stream
.set_nonblocking(true)
.map_err(|e| format!("Failed to set nonblocking: {}", e))?;
Ok(Box::new(DockerSession {
container_id,
stream,
alive: true,
}))
}
#[cfg(not(unix))]
{
let _ = config;
Err("Docker backend not supported on this platform".to_string())
}
}
fn name(&self) -> &'static str {
"docker"
}
fn is_available(&self) -> Result<bool, String> {
#[cfg(unix)]
{
if !std::path::Path::new(DOCKER_SOCKET).exists() {
return Ok(false);
}
// Ping the daemon
match docker_get("/_ping") {
Ok(resp) => Ok(resp.contains("OK")),
Err(_) => Ok(false),
}
}
#[cfg(not(unix))]
{
Ok(false)
}
}
}
// ==================== DockerSession ====================
#[cfg(unix)]
struct DockerSession {
container_id: String,
stream: UnixStream,
alive: bool,
}
#[cfg(unix)]
impl TerminalSession for DockerSession {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, std::io::Error> {
// With Tty: true, the stream is raw bytes (no Docker multiplexing)
match self.stream.read(buf) {
Ok(0) => {
self.alive = false;
Ok(0)
}
Ok(n) => Ok(n),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
Err(e)
}
Err(e) => {
self.alive = false;
Err(e)
}
}
}
fn write(&mut self, data: &[u8]) -> Result<usize, std::io::Error> {
self.stream.write_all(data)?;
self.stream.flush()?;
Ok(data.len())
}
fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String> {
let path = format!(
"/containers/{}/resize?h={}&w={}",
self.container_id, rows, cols
);
// Resize needs its own connection (the attach stream is hijacked)
docker_post(&path, None).map(|_| ())
}
fn kill(&mut self) -> Result<(), String> {
self.alive = false;
let kill_path = format!("/containers/{}/kill", self.container_id);
let _ = docker_post(&kill_path, None);
Ok(())
}
fn is_alive(&self) -> bool {
self.alive
}
}
#[cfg(unix)]
impl Drop for DockerSession {
fn drop(&mut self) {
// Kill and remove the container
let kill_path = format!("/containers/{}/kill", self.container_id);
let _ = docker_post(&kill_path, None);
// Wait briefly for container to stop
let wait_path = format!("/containers/{}/wait?condition=not-running", self.container_id);
let _ = docker_post_with_timeout(&wait_path, None, std::time::Duration::from_secs(3));
let rm_path = format!("/containers/{}/remove?force=true", self.container_id);
let _ = docker_delete(&rm_path);
}
}
// ==================== Docker Engine API helpers ====================
#[cfg(unix)]
fn docker_get(path: &str) -> Result<String, String> {
let mut sock = UnixStream::connect(DOCKER_SOCKET)
.map_err(|e| format!("Failed to connect to Docker socket: {}", e))?;
let request = format!("GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n", path);
sock.write_all(request.as_bytes())
.map_err(|e| format!("Failed to write request: {}", e))?;
read_http_response(&mut sock)
}
#[cfg(unix)]
fn docker_post(path: &str, body: Option<&str>) -> Result<String, String> {
let mut sock = UnixStream::connect(DOCKER_SOCKET)
.map_err(|e| format!("Failed to connect to Docker socket: {}", e))?;
let request = if let Some(body) = body {
format!(
"POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
path,
body.len(),
body
)
} else {
format!(
"POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n",
path
)
};
sock.write_all(request.as_bytes())
.map_err(|e| format!("Failed to write request: {}", e))?;
read_http_response(&mut sock)
}
#[cfg(unix)]
fn docker_post_with_timeout(
path: &str,
body: Option<&str>,
timeout: std::time::Duration,
) -> Result<String, String> {
let mut sock = UnixStream::connect(DOCKER_SOCKET)
.map_err(|e| format!("Failed to connect to Docker socket: {}", e))?;
sock.set_read_timeout(Some(timeout))
.map_err(|e| format!("Failed to set timeout: {}", e))?;
let request = if let Some(body) = body {
format!(
"POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
path,
body.len(),
body
)
} else {
format!(
"POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n",
path
)
};
sock.write_all(request.as_bytes())
.map_err(|e| format!("Failed to write request: {}", e))?;
read_http_response(&mut sock)
}
#[cfg(unix)]
fn docker_delete(path: &str) -> Result<String, String> {
let mut sock = UnixStream::connect(DOCKER_SOCKET)
.map_err(|e| format!("Failed to connect to Docker socket: {}", e))?;
let request = format!(
"DELETE {} HTTP/1.1\r\nHost: localhost\r\n\r\n",
path
);
sock.write_all(request.as_bytes())
.map_err(|e| format!("Failed to write request: {}", e))?;
read_http_response(&mut sock)
}
#[cfg(unix)]
fn docker_attach(path: &str) -> Result<UnixStream, String> {
let mut sock = UnixStream::connect(DOCKER_SOCKET)
.map_err(|e| format!("Failed to connect to Docker socket: {}", e))?;
// The attach endpoint hijacks the HTTP connection.
// After the HTTP 101 response, the socket becomes a raw stream.
let request = format!(
"POST {} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n",
path
);
sock.write_all(request.as_bytes())
.map_err(|e| format!("Failed to write attach request: {}", e))?;
// Read the HTTP response header (101 Switching Protocols)
let mut reader = BufReader::new(sock);
let mut status_line = String::new();
reader
.read_line(&mut status_line)
.map_err(|e| format!("Failed to read attach response: {}", e))?;
if !status_line.contains("101") && !status_line.contains("200") {
return Err(format!("Attach failed: {}", status_line.trim()));
}
// Skip remaining headers until empty line
loop {
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|e| format!("Failed to read header: {}", e))?;
if line.trim().is_empty() {
break;
}
}
// The underlying stream is now the raw container I/O
Ok(reader.into_inner())
}
#[cfg(unix)]
fn read_http_response(sock: &mut UnixStream) -> Result<String, String> {
let mut reader = BufReader::new(sock);
// Read status line
let mut status_line = String::new();
reader
.read_line(&mut status_line)
.map_err(|e| format!("Failed to read response: {}", e))?;
let status_code = status_line
.split_whitespace()
.nth(1)
.unwrap_or("0")
.parse::<u16>()
.unwrap_or(0);
// Read headers
let mut content_length: usize = 0;
let mut chunked = false;
loop {
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|e| format!("Failed to read header: {}", e))?;
let trimmed = line.trim();
if trimmed.is_empty() {
break;
}
if let Some(val) = trimmed.strip_prefix("Content-Length:") {
content_length = val.trim().parse().unwrap_or(0);
}
if trimmed.contains("chunked") {
chunked = true;
}
}
// Read body
let body = if chunked {
read_chunked_body(&mut reader)?
} else if content_length > 0 {
let mut body = vec![0u8; content_length];
reader
.read_exact(&mut body)
.map_err(|e| format!("Failed to read body: {}", e))?;
String::from_utf8_lossy(&body).to_string()
} else {
String::new()
};
if status_code >= 400 {
return Err(format!("Docker API error {}: {}", status_code, body));
}
Ok(body)
}
#[cfg(unix)]
fn read_chunked_body(reader: &mut BufReader<&mut UnixStream>) -> Result<String, String> {
let mut body = Vec::new();
loop {
let mut size_line = String::new();
reader
.read_line(&mut size_line)
.map_err(|e| format!("Failed to read chunk size: {}", e))?;
let size = usize::from_str_radix(size_line.trim(), 16).unwrap_or(0);
if size == 0 {
break;
}
let mut chunk = vec![0u8; size];
reader
.read_exact(&mut chunk)
.map_err(|e| format!("Failed to read chunk: {}", e))?;
body.extend_from_slice(&chunk);
// Read trailing \r\n
let mut crlf = [0u8; 2];
let _ = reader.read_exact(&mut crlf);
}
Ok(String::from_utf8_lossy(&body).to_string())
}
fn parse_memory_limit(limit: &str) -> u64 {
let limit = limit.trim().to_lowercase();
if let Some(num) = limit.strip_suffix('g') {
num.parse::<u64>().unwrap_or(256) * 1024 * 1024 * 1024
} else if let Some(num) = limit.strip_suffix('m') {
num.parse::<u64>().unwrap_or(256) * 1024 * 1024
} else if let Some(num) = limit.strip_suffix('k') {
num.parse::<u64>().unwrap_or(256) * 1024
} else {
limit.parse::<u64>().unwrap_or(256 * 1024 * 1024)
}
}
fn extract_json_field(json: &str, field: &str) -> Option<String> {
// Simple JSON field extraction — avoids serde_json dependency.
// Looks for "field":"value" pattern.
let pattern = format!("\"{}\":\"", field);
if let Some(start) = json.find(&pattern) {
let value_start = start + pattern.len();
if let Some(end) = json[value_start..].find('"') {
return Some(json[value_start..value_start + end].to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_memory_limit() {
assert_eq!(parse_memory_limit("256m"), 256 * 1024 * 1024);
assert_eq!(parse_memory_limit("1g"), 1024 * 1024 * 1024);
assert_eq!(parse_memory_limit("512k"), 512 * 1024);
assert_eq!(parse_memory_limit("1048576"), 1048576);
}
#[test]
fn test_extract_json_field() {
let json = r#"{"Id":"abc123","Warnings":[]}"#;
assert_eq!(extract_json_field(json, "Id"), Some("abc123".to_string()));
assert_eq!(extract_json_field(json, "Missing"), None);
}
}