CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m46s
CI / Deploy Dashboard (push) Successful in 2m53s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
//! 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::ExecError;
|
|
|
|
/// 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<Output = Result<ProvisionedRuntime, ExecError>> + Send;
|
|
|
|
/// Tear an instance down. Best-effort and idempotent — never fails the scan.
|
|
fn teardown(&self, handle: &ProvisionedRuntime)
|
|
-> impl std::future::Future<Output = ()> + 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<ProvisionedRuntime, ExecError> {
|
|
// 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(ExecError::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-<epoch>-<target12>-<rand6>`.
|
|
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<u64> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<std::process::Output, ExecError> {
|
|
tokio::process::Command::new("docker")
|
|
.args(args)
|
|
.output()
|
|
.await
|
|
.map_err(ExecError::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()));
|
|
}
|
|
}
|