//! Ephemeral soft-PLC container lifecycle (#183, sub-task 1 + 5). //! //! Provisions a throwaway OpenPLC container per scan, isolated on the agent's own //! Docker network with hard resource caps and **no host port exposure**, then //! guarantees teardown. The container is reachable in-cluster only, by its name //! (the shared user-defined network's embedded DNS resolves it); it is never //! published to the host. //! //! The `docker` argv is produced by pure functions so provisioning is unit-tested //! without a Docker daemon — only the thin [`run_docker`] wrapper touches the OS. //! It requires the agent's runtime to have Docker access (a socket mount), which //! is why the whole path is gated behind [`PlcRuntimeConfig::enabled`]. use std::time::{SystemTime, UNIX_EPOCH}; use compliance_core::PlcRuntimeConfig; use crate::error::AgentError; /// The Modbus/TCP port an OpenPLC instance opens once a program is running. const MODBUS_PORT: u16 = 502; /// The OpenPLC web-UI / WebVisu port. const WEBVISU_PORT: u16 = 8080; /// Label key marking a container as an ephemeral PLC runtime we own. const OWNER_LABEL_KEY: &str = "certifai.ephemeral"; /// Label value for our ephemeral PLC runtimes. const OWNER_LABEL_VALUE: &str = "plc-runtime"; /// A running ephemeral soft-PLC instance. Reachable in-cluster by `name`. #[derive(Debug, Clone)] pub struct ProvisionedRuntime { /// The container name — also its in-network DNS alias. pub name: String, /// `name:502` — the Modbus/TCP endpoint the ICS probe targets. pub modbus_endpoint: String, /// `http://name:8080` — the WebVisu / OpenPLC web UI. pub webvisu_url: String, } /// A source of ephemeral soft-PLC instances. Abstracted so the provision-and-test /// orchestration is unit-testable with a fake that never touches Docker. pub trait SoftPlc { /// Start a fresh instance for a target and return its handle. fn provision( &self, target_id: &str, ) -> impl std::future::Future> + Send; /// Tear an instance down. Best-effort and idempotent — never fails the scan. fn teardown(&self, handle: &ProvisionedRuntime) -> impl std::future::Future + Send; } /// Provisions OpenPLC instances by shelling out to the Docker CLI. pub struct DockerSoftPlc { cfg: PlcRuntimeConfig, } impl DockerSoftPlc { /// Build a provisioner from the PLC-runtime config. pub fn new(cfg: PlcRuntimeConfig) -> Self { Self { cfg } } } impl SoftPlc for DockerSoftPlc { async fn provision(&self, target_id: &str) -> Result { // Best-effort sweep of any container leaked by a crashed earlier run // before we add another. Only removes instances past their max lifetime, // so it can never disturb a concurrent run. reap_stale(&self.cfg, now_epoch()).await; let name = instance_name(target_id, now_epoch(), &random_suffix()); let args = run_args(&self.cfg, &name, target_id); let out = run_docker(&args).await?; if !out.status.success() { return Err(AgentError::Other(format!( "docker run for soft-PLC {name} failed: {}", String::from_utf8_lossy(&out.stderr).trim() ))); } Ok(ProvisionedRuntime { modbus_endpoint: format!("{name}:{MODBUS_PORT}"), webvisu_url: format!("http://{name}:{WEBVISU_PORT}"), name, }) } async fn teardown(&self, handle: &ProvisionedRuntime) { match run_docker(&rm_args(&handle.name)).await { Ok(out) if out.status.success() => { tracing::info!(instance = %handle.name, "soft-PLC instance torn down"); } Ok(out) => tracing::warn!( instance = %handle.name, "soft-PLC teardown non-zero exit: {}", String::from_utf8_lossy(&out.stderr).trim() ), Err(e) => { tracing::warn!(instance = %handle.name, error = %e, "soft-PLC teardown failed") } } } } /// Seconds since the Unix epoch (0 if the clock is before 1970, which never /// happens in practice). fn now_epoch() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// A short random, docker-name-safe suffix. fn random_suffix() -> String { uuid::Uuid::new_v4().simple().to_string() } /// A unique, docker-safe container name that encodes the creation epoch (for the /// stale reaper) and the target it belongs to. Shape: /// `certifai-plc---`. fn instance_name(target_id: &str, epoch: u64, rand: &str) -> String { let short: String = target_id .chars() .filter(char::is_ascii_alphanumeric) .take(12) .collect(); let rand: String = rand .chars() .filter(char::is_ascii_alphanumeric) .take(6) .collect(); format!("certifai-plc-{epoch}-{short}-{rand}") } /// The creation epoch encoded in an instance name, if it is one of ours. fn parse_epoch(name: &str) -> Option { name.strip_prefix("certifai-plc-")? .split('-') .next()? .parse() .ok() } /// The `docker run` argv for an ephemeral soft-PLC: detached, joined to the /// agent's network, resource-capped, hardened, labelled for reaping, and — by /// omitting any `-p` — never published to the host. fn run_args(cfg: &PlcRuntimeConfig, name: &str, target_id: &str) -> Vec { vec![ "run".into(), "-d".into(), "--name".into(), name.into(), "--network".into(), cfg.network.clone(), "--memory".into(), cfg.memory.clone(), "--cpus".into(), cfg.cpus.clone(), "--pids-limit".into(), "512".into(), "--security-opt".into(), "no-new-privileges".into(), "--stop-timeout".into(), "5".into(), "--label".into(), format!("{OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"), "--label".into(), format!("certifai.target={target_id}"), cfg.image.clone(), ] } /// The `docker rm -f` argv that stops and removes an instance. fn rm_args(name: &str) -> Vec { vec!["rm".into(), "-f".into(), name.into()] } /// The `docker ps` argv listing the names of every ephemeral PLC container we own. fn reap_list_args() -> Vec { vec![ "ps".into(), "-a".into(), "--filter".into(), format!("label={OWNER_LABEL_KEY}={OWNER_LABEL_VALUE}"), "--format".into(), "{{.Names}}".into(), ] } /// Remove any ephemeral PLC container older than twice the configured max /// lifetime — i.e. one a crashed run leaked. The generous threshold guarantees a /// container from a *live* run (still within its own deadline) is never swept. /// Best-effort: any Docker error (e.g. no daemon) is ignored. async fn reap_stale(cfg: &PlcRuntimeConfig, now: u64) { let cutoff = cfg.max_lifetime_secs.saturating_mul(2); let Ok(out) = run_docker(&reap_list_args()).await else { return; }; if !out.status.success() { return; } let names = String::from_utf8_lossy(&out.stdout); for name in names.lines().map(str::trim).filter(|n| !n.is_empty()) { let Some(epoch) = parse_epoch(name) else { continue; }; if now.saturating_sub(epoch) > cutoff { tracing::warn!(instance = %name, "reaping stale soft-PLC instance"); let _ = run_docker(&rm_args(name)).await; } } } /// Run a `docker` subcommand, capturing its output. async fn run_docker(args: &[String]) -> Result { tokio::process::Command::new("docker") .args(args) .output() .await .map_err(AgentError::Io) } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; fn cfg() -> PlcRuntimeConfig { PlcRuntimeConfig { enabled: true, image: "registry.example.com/openplc:latest".into(), network: "certifai".into(), memory: "512m".into(), cpus: "0.5".into(), max_lifetime_secs: 180, ..PlcRuntimeConfig::default() } } #[test] fn instance_name_is_unique_docker_safe_and_reaper_parseable() { let a = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "abcdef123456"); assert_eq!(a, "certifai-plc-1700000000-64f0aabbccdd-abcdef"); assert_eq!(parse_epoch(&a), Some(1_700_000_000)); // Docker names: only [A-Za-z0-9_.-]. assert!(a .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))); // A different random suffix yields a different name for the same target. let b = instance_name("64f0aabbccddeeff00112233", 1_700_000_000, "zzzzzz999999"); assert_ne!(a, b); } #[test] fn parse_epoch_rejects_foreign_names() { assert_eq!(parse_epoch("some-other-container"), None); assert_eq!(parse_epoch("certifai-plc-notanumber-x"), None); } #[test] fn run_args_cap_resources_harden_label_and_never_publish_a_port() { let args = run_args(&cfg(), "certifai-plc-1-t-r", "target-123"); // No host port publishing. assert!(!args.iter().any(|a| a == "-p" || a == "--publish")); // Detached. assert!(args.contains(&"-d".to_string())); // Joined to the agent's own network. let net = args.iter().position(|a| a == "--network").expect("network"); assert_eq!(args[net + 1], "certifai"); // Resource caps. let mem = args.iter().position(|a| a == "--memory").expect("memory"); assert_eq!(args[mem + 1], "512m"); let cpu = args.iter().position(|a| a == "--cpus").expect("cpus"); assert_eq!(args[cpu + 1], "0.5"); assert!(args.iter().any(|a| a == "--pids-limit")); // Hardening. let so = args .iter() .position(|a| a == "--security-opt") .expect("secopt"); assert_eq!(args[so + 1], "no-new-privileges"); // Ownership + target labels for reaping / attribution. assert!(args.contains(&"certifai.ephemeral=plc-runtime".to_string())); assert!(args.contains(&"certifai.target=target-123".to_string())); // Image is last. assert_eq!( args.last().map(String::as_str), Some("registry.example.com/openplc:latest") ); } #[test] fn rm_args_force_remove() { assert_eq!(rm_args("x"), vec!["rm", "-f", "x"]); } #[test] fn reap_list_filters_by_owner_label() { let args = reap_list_args(); assert!(args.contains(&"label=certifai.ephemeral=plc-runtime".to_string())); assert!(args.contains(&"{{.Names}}".to_string())); } }