diff --git a/Cargo.lock b/Cargo.lock index 195f7c15..d58e191e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,7 +417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -979,7 +979,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1227,7 +1227,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1294,7 +1294,9 @@ name = "tinybox-docker" version = "0.2.0" dependencies = [ "async-trait", + "ipnet", "serde_json", + "sha2 0.10.9", "tempfile", "tinybox-core", "tinybox-host", @@ -1637,9 +1639,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -1941,7 +1943,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index de0a0f2a..c3203ed6 100644 --- a/crates/tinybox-core/src/lib.rs +++ b/crates/tinybox-core/src/lib.rs @@ -85,7 +85,7 @@ pub use identity::{BoxId, HostRef, ProcessId, SandboxRef, SnapshotId, TemplateNa pub use passthrough::PassthroughSandbox; pub use runtime::{ BoxInfo, BoxState, ExecOutput, ExecRequest, ExecutionObserver, Forward, ForwardGuard, Host, - OutputStream, Sandbox, WorkspaceFileReader, WorkspaceFileWriter, + HostExecutionLocation, OutputStream, Sandbox, WorkspaceFileReader, WorkspaceFileWriter, }; pub use spec::{ BoxSpec, EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index 56e24d07..6fe8de95 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -41,6 +41,21 @@ mod types; pub use forward::{Forward, ForwardGuard}; pub use types::{BoxInfo, BoxState, ExecOutput, ExecRequest}; +/// Where a host executes commands relative to the tinybox process. +/// +/// Security-sensitive controllers use this explicit capability instead of +/// guessing from a host's registration name. Custom hosts default to +/// [`Unknown`](Self::Unknown) until they can state their location. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostExecutionLocation { + /// Commands execute in this process's operating-system environment. + Local, + /// Commands execute on another machine. + Remote, + /// The host cannot make a trustworthy locality claim. + Unknown, +} + /// A readable file opened beneath a host- or sandbox-mounted workspace. #[async_trait] pub trait WorkspaceFileReader: std::fmt::Debug + Send + Sync { @@ -90,6 +105,15 @@ pub trait Host: std::fmt::Debug + Send + Sync + 'static { /// [`HostRef`](crate::identity::HostRef). fn name(&self) -> &str; + /// The machine on which commands run. + /// + /// This defaults to [`HostExecutionLocation::Unknown`]; a controller that + /// requires local machine access must refuse hosts that do not explicitly + /// attest to it. + fn execution_location(&self) -> HostExecutionLocation { + HostExecutionLocation::Unknown + } + /// Run a command to completion and collect its output. /// /// # Errors diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index 5f892653..d7d1102a 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -228,8 +228,15 @@ fn allowlist_validation_rejects_malformed_and_metadata_hosts() { ); for host in [ "169.254.169.254", + "0251.0376.0251.0376", + "0xa9fea9fe", + "0xa9feaa02", + "0x646464c8", "[fd00:ec2::254]", "Metadata.Google.Internal", + "metadata.google.internal.", + "Metadata.Google.Internal.", + "metadata.", ] { assert!( EgressAllowlist::new([host], [80]).validate().is_err(), diff --git a/crates/tinybox-core/src/spec/types.rs b/crates/tinybox-core/src/spec/types.rs index d31430b3..5ee439dd 100644 --- a/crates/tinybox-core/src/spec/types.rs +++ b/crates/tinybox-core/src/spec/types.rs @@ -253,8 +253,9 @@ pub enum NetworkPolicy { /// `Unsupported`, never `Enforced`. /// /// The cloud metadata endpoints in [`EgressAllowlist::is_metadata_host`] are -/// refused at validation time and must also be blocked by the backend even -/// when a hostname resolves to one of them. +/// refused at validation time, including legacy numeric IPv4 spellings such as +/// `0251.0376.0251.0376` and `0xa9fea9fe` for `169.254.169.254`. They must also +/// be blocked by the backend even when a hostname resolves to one of them. /// /// Explicit loopback addresses and aliases are also invalid. This includes /// IPv4/IPv6 loopback literals, IPv4-mapped loopback literals, scoped IPv6 @@ -284,10 +285,15 @@ impl EgressAllowlist { } /// Whether `host` names a cloud instance-metadata endpoint, which no - /// policy may ever allow. + /// policy may ever allow. Matching ignores DNS case and terminal root dots, + /// and recognizes legacy numeric IPv4 spellings of metadata addresses. #[must_use] pub fn is_metadata_host(host: &str) -> bool { - let host = host.trim().trim_matches(['[', ']']).to_ascii_lowercase(); + let host = host + .trim() + .trim_end_matches('.') + .trim_matches(['[', ']']) + .to_ascii_lowercase(); matches!( host.as_str(), "169.254.169.254" @@ -297,6 +303,11 @@ impl EgressAllowlist { | "metadata.google.internal" | "metadata" ) || host.starts_with("::ffff:169.254.169.254") + || Self::parse_legacy_ipv4(&host).is_some_and(|address| { + address == Ipv4Addr::new(169, 254, 169, 254) + || address == Ipv4Addr::new(169, 254, 170, 2) + || address == Ipv4Addr::new(100, 100, 100, 200) + }) } /// Whether an allowlist entry explicitly names a loopback address or alias. diff --git a/crates/tinybox-docker/Cargo.toml b/crates/tinybox-docker/Cargo.toml index 1e74d8bd..9d17536d 100644 --- a/crates/tinybox-docker/Cargo.toml +++ b/crates/tinybox-docker/Cargo.toml @@ -15,7 +15,11 @@ publish = false [dependencies] tinybox-core.workspace = true async-trait.workspace = true +# Parse Docker-assigned CIDRs and reject pinned destinations within them. +ipnet = "2.12" serde_json.workspace = true +# SHA-256 keeps firewall cleanup ownership markers collision-resistant. +sha2 = "0.10.9" # `DockerCli` runs the local `docker` client; one-shot runs need process spawning # and a deadline. tokio = { workspace = true, features = ["process"] } diff --git a/crates/tinybox-docker/src/egress.rs b/crates/tinybox-docker/src/egress.rs new file mode 100644 index 00000000..e618cf7a --- /dev/null +++ b/crates/tinybox-docker/src/egress.rs @@ -0,0 +1,193 @@ +//! Resolve and pin network allowlist destinations for the Docker firewall. +//! +//! The native Linux controller consumes pinned answers for firewall rules and +//! container `/etc/hosts` entries. Planning alone does not enforce traffic. + +use std::{ + collections::{BTreeMap, BTreeSet}, + net::{IpAddr, SocketAddr}, +}; + +use tinybox_core::{EgressAllowlist, Error, Result}; + +/// Resolves one hostname to all of its addresses in the environment where an +/// enforcing backend will use the result. +pub trait EgressResolver: std::fmt::Debug + Send + Sync { + /// Resolve `hostname` once and return every address in the answer. + /// + /// # Errors + /// + /// Returns the resolver's error if the hostname cannot be resolved. + fn resolve(&self, hostname: &str) -> std::io::Result>; +} + +/// A resolved allowlist plan whose destinations no longer depend on DNS. +/// +/// The native Linux Docker controller uses each target both to install a +/// destination rule and to create a static hostname mapping for the workload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinnedEgressPlan { + targets: Vec, +} + +impl PinnedEgressPlan { + /// The host, port, and pinned IP combinations that an enforcer must apply. + #[must_use] + pub fn targets(&self) -> &[PinnedEgressTarget] { + &self.targets + } +} + +/// One permitted hostname and port paired with one pinned destination address. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PinnedEgressTarget { + host: String, + port: u16, + address: IpAddr, +} + +impl PinnedEgressTarget { + /// The normalized allowlist hostname. + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// The permitted destination port. + #[must_use] + pub const fn port(&self) -> u16 { + self.port + } + + /// The address returned by the resolver, retained without another lookup. + #[must_use] + pub const fn address(&self) -> IpAddr { + self.address + } + + /// The pinned socket address an enforcing backend can connect to directly. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + SocketAddr::new(self.address, self.port) + } +} + +/// Resolve and pin all destinations in an allowlist. +/// +/// Every distinct hostname is passed to `resolver` exactly once. If any answer +/// is a cloud metadata or loopback address, the whole plan is rejected. Safe +/// private addresses are retained because this policy does not forbid them. +/// +/// This function only prepares pinned data. It does not apply network rules; +/// the native Linux Docker controller consumes the result during preparation. +/// The resolver must answer in the environment where the eventual enforcer will +/// use the plan, which may be remote from the caller for an SSH-backed host. +/// +/// # Errors +/// +/// Returns [`Error::InvalidNetworkPolicy`] for a malformed allowlist, an empty +/// DNS answer, or any resolved metadata or loopback address. Returns +/// [`Error::ConstraintNotEnforced`] when an IPv6 link-local target needs an +/// interface scope that this backend cannot preserve. Resolver errors are +/// returned as [`Error::Backend`]. +pub fn plan_egress_allowlist( + allowlist: &EgressAllowlist, + resolver: &dyn EgressResolver, +) -> Result { + allowlist.validate()?; + + let mut addresses_by_host = BTreeMap::>::new(); + for configured_host in &allowlist.hosts { + let host = normalize_host(configured_host); + if addresses_by_host.contains_key(&host) { + continue; + } + + let addresses = match host.parse::() { + Ok(address) => vec![address], + Err(_) => resolver.resolve(&host).map_err(|error| Error::Backend { + sandbox: "docker".to_owned(), + operation: "resolve an egress allowlist host", + message: format!("{host}: {error}"), + })?, + }; + if addresses.is_empty() { + return Err(Error::InvalidNetworkPolicy { + reason: "allowlist host resolved to no addresses", + }); + } + let addresses = addresses + .into_iter() + .map(normalize_ip_address) + .collect::>(); + if addresses.iter().any(is_forbidden_address) { + return Err(Error::InvalidNetworkPolicy { + reason: "allowlist host resolves to a metadata or loopback address", + }); + } + if addresses.iter().any(is_ipv6_link_local) { + return Err(Error::ConstraintNotEnforced { + sandbox: "docker".to_owned(), + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + }); + } + addresses_by_host.insert(host, addresses); + } + + let ports: BTreeSet<_> = allowlist.ports.iter().copied().collect(); + let mut targets = Vec::new(); + for (host, addresses) in addresses_by_host { + for port in &ports { + for address in &addresses { + targets.push(PinnedEgressTarget { + host: host.clone(), + port: *port, + address: *address, + }); + } + } + } + + Ok(PinnedEgressPlan { targets }) +} + +fn normalize_host(host: &str) -> String { + host.trim().trim_matches(['[', ']']).to_ascii_lowercase() +} + +fn normalize_ip_address(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map_or(IpAddr::V6(address), IpAddr::V4), + IpAddr::V4(address) => IpAddr::V4(address), + } +} + +fn is_forbidden_address(address: &IpAddr) -> bool { + if EgressAllowlist::is_metadata_host(&address.to_string()) { + return true; + } + + match address { + IpAddr::V4(address) => address.is_loopback(), + IpAddr::V6(address) => { + address.is_loopback() + || address.to_ipv4_mapped().is_some_and(|mapped| { + mapped.is_loopback() || EgressAllowlist::is_metadata_host(&mapped.to_string()) + }) + } + } +} + +fn is_ipv6_link_local(address: &IpAddr) -> bool { + match address { + IpAddr::V4(_) => false, + IpAddr::V6(address) => address.segments()[0] & 0xffc0 == 0xfe80, + } +} + +#[cfg(test)] +#[path = "egress_tests.rs"] +mod tests; diff --git a/crates/tinybox-docker/src/egress_enforcement.rs b/crates/tinybox-docker/src/egress_enforcement.rs new file mode 100644 index 00000000..443c3e59 --- /dev/null +++ b/crates/tinybox-docker/src/egress_enforcement.rs @@ -0,0 +1,1203 @@ +//! Per-box Docker network filtering for explicitly supported native Linux hosts. +//! +//! The controller installs a host firewall before Docker starts the box. It +//! consumes pinned resolver answers and does not use proxy environment +//! variables as an enforcement boundary. + +use std::{collections::BTreeSet, fmt::Debug, net::IpAddr, sync::Arc}; + +use async_trait::async_trait; +use ipnet::IpNet; +use sha2::{Digest, Sha256}; +use tinybox_core::{ + BoxId, EgressAllowlist, Error, ExecOutput, ExecRequest, Host, HostExecutionLocation, Result, +}; +#[cfg(not(test))] +use uuid::Uuid; + +use crate::{EgressResolver, PinnedEgressPlan, plan_egress_allowlist}; + +const DOCKER_SOCKET: &str = "unix:///var/run/docker.sock"; +const OWNER_LABEL: &str = "ai.tinyhumans.tinybox.egress.owner"; +const SCOPE_LABEL: &str = "ai.tinyhumans.tinybox.egress.scope"; +const ATTEMPT_LABEL: &str = "ai.tinyhumans.tinybox.egress.attempt"; +const ALLOWLIST_PROTOCOLS: [&str; 1] = ["tcp"]; +const HEX: &[u8; 16] = b"0123456789abcdef"; + +/// A controller that installs and removes a per-box egress filter. +/// +/// Implementations are trusted security components. A controller must verify +/// that it acts on the same daemon and host that run the box, install a +/// default-drop filter before returning from [`prepare`](Self::prepare), and +/// make [`cleanup`](Self::cleanup) idempotent. `DockerSandbox` uses +/// [`docker_argv`](Self::docker_argv) for every Docker command while the +/// controller is installed, so lifecycle operations address the same daemon. +#[async_trait] +pub trait DockerEgressController: Debug + Send + Sync { + /// Verify the execution location and required daemon/filter capability. + async fn validate_host(&self, _host: &dyn Host) -> Result<()> { + Ok(()) + } + + /// Resolve, pin, and prepare a filtered network for this box. + /// + /// Implementations must not start the workload. The returned network name + /// is supplied to `docker run` only after this method succeeds. On error, + /// the controller must roll back every resource it created. If rollback + /// fails, it must return `Error::Backend` with operation + /// `"roll back egress network setup"`, allowing the sandbox to retain the + /// box record for a later cleanup retry. + async fn prepare( + &self, + host: &dyn Host, + namespace: &str, + id: &BoxId, + allowlist: &EgressAllowlist, + ) -> Result; + + /// Remove this box's filter and network after its container has stopped. + async fn cleanup(&self, host: &dyn Host, namespace: &str, id: &BoxId) -> Result<()>; + + /// Bind a Docker CLI command to the daemon this controller verifies. + fn docker_argv(&self, argv: Vec) -> Vec; +} + +/// A firewall-protected network and the hostname mappings pinned for it. +/// +/// The sandbox installs these mappings in `/etc/hosts` and points the +/// container's upstream resolver at its own loopback, so Docker's embedded +/// resolver cannot forward arbitrary DNS queries through the host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedEgressNetwork { + network: String, + host_mappings: Vec<(String, IpAddr)>, +} + +impl PreparedEgressNetwork { + /// Build a network result for a trusted egress controller. + #[must_use] + pub fn new(network: String, host_mappings: Vec<(String, IpAddr)>) -> Self { + Self { + network, + host_mappings, + } + } + + /// Docker network name to attach to the workload. + #[must_use] + pub fn network(&self) -> &str { + &self.network + } + + /// Pinned `/etc/hosts` entries used instead of upstream DNS. + #[must_use] + pub fn host_mappings(&self) -> &[(String, IpAddr)] { + &self.host_mappings + } +} + +/// Native Linux Engine support using the local rootful Docker socket and +/// `iptables`/`ip6tables` `DOCKER-USER` chains. +/// +/// The default [`DockerSandbox`](crate::DockerSandbox) has no controller and +/// continues to refuse allowlists. Install this controller explicitly only on +/// a local native Linux Docker Engine with non-interactive firewall privilege. +/// Docker Desktop, SSH/remote hosts, rootless Docker, Windows containers, and +/// hosts without the expected firewall chains are refused. +/// The controller also inspects Docker-assigned IPAM subnets and refuses a +/// pinned destination contained in one, avoiding on-link routing around the +/// intended gateway path. +#[derive(Debug)] +pub struct NativeLinuxDockerEgressController { + resolver: Arc, +} + +impl NativeLinuxDockerEgressController { + /// Use `resolver` to resolve allowlist names before network creation. + #[must_use] + pub fn new(resolver: Arc) -> Self { + Self { resolver } + } +} + +#[cfg(test)] +#[path = "egress_enforcement_tests.rs"] +mod tests; + +#[async_trait] +impl DockerEgressController for NativeLinuxDockerEgressController { + async fn validate_host(&self, host: &dyn Host) -> Result<()> { + ensure_supported_host(host)?; + ensure_linux_target()?; + verify_engine_and_firewall(host).await + } + + async fn prepare( + &self, + host: &dyn Host, + namespace: &str, + id: &BoxId, + allowlist: &EgressAllowlist, + ) -> Result { + ensure_supported_host(host)?; + ensure_linux_target()?; + verify_engine_and_firewall(host).await?; + + // Resolve before creating any Docker resources. Every installed rule is + // based on this one pinned answer set; workload DNS is not trusted for + // egress decisions. + let plan = plan_egress_allowlist(allowlist, self.resolver.as_ref())?; + let names = NetworkNames::for_box(namespace, id); + let create_network = docker_request( + host, + force_local_rootful_docker(vec![ + "docker".into(), + "network".into(), + "create".into(), + "--driver".into(), + "bridge".into(), + "--ipv6".into(), + "--opt".into(), + format!("com.docker.network.bridge.name={}", names.bridge), + "--label".into(), + format!("{OWNER_LABEL}={id}"), + "--label".into(), + format!("{SCOPE_LABEL}={}", names.owner_token), + "--label".into(), + format!("{ATTEMPT_LABEL}={}", names.attempt_token), + names.network.clone(), + ]), + ) + .await; + let network = match create_network { + Ok(output) if output.succeeded() => output.stdout_lossy().trim().to_owned(), + Ok(output) => { + let error = Error::Backend { + sandbox: "docker".to_owned(), + operation: "configure egress filtering", + message: output.stderr_lossy().trim().to_owned(), + }; + return Err(error); + } + Err(error) => { + return Err(reconcile_failed_network_create(host, &names, id, error).await); + } + }; + if network.is_empty() { + let error = unsupported("Docker did not confirm the egress network"); + return match cleanup_native_with_attempt(host, namespace, id, &BTreeSet::new(), false) + .await + { + Ok(()) => Err(error), + Err(cleanup_error) => Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "roll back egress network setup", + message: format!("{error}; cleanup failed: {cleanup_error}"), + }), + }; + } + + let mut attempt_owned_chains = BTreeSet::new(); + let setup = async { + verify_created_network(host, &names, id).await?; + verify_targets_outside_network_subnets(host, &names, &plan).await?; + verify_created_bridge_link(host, &names).await?; + install_firewall(host, &names, &plan, &mut attempt_owned_chains).await + } + .await; + if let Err(error) = setup { + let cleanup = + cleanup_native_with_attempt(host, namespace, id, &attempt_owned_chains, false) + .await; + return match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "roll back egress network setup", + message: format!("{error}; cleanup failed: {cleanup_error}"), + }), + }; + } + + let host_mappings = plan + .targets() + .iter() + .flat_map(|target| { + let host = target.host(); + if host.parse::().is_ok() { + return Vec::new(); + } + let unqualified = host.trim_end_matches('.'); + let mut names = vec![(host.to_owned(), target.address())]; + if unqualified != host { + names.push((unqualified.to_owned(), target.address())); + } + names + }) + .collect::>() + .into_iter() + .collect(); + Ok(PreparedEgressNetwork::new(names.network, host_mappings)) + } + + async fn cleanup(&self, host: &dyn Host, namespace: &str, id: &BoxId) -> Result<()> { + ensure_supported_host(host)?; + ensure_linux_target()?; + verify_engine_and_firewall(host).await?; + cleanup_native(host, namespace, id).await + } + + fn docker_argv(&self, argv: Vec) -> Vec { + force_local_rootful_docker(argv) + } +} + +#[derive(Debug)] +struct NetworkNames { + network: String, + bridge: String, + forward_chain: String, + input_chain: String, + owner_token: String, + attempt_token: String, + chain_prefix: String, + chain_owner_token: String, +} + +impl NetworkNames { + fn for_box(namespace: &str, id: &BoxId) -> Self { + let attempt_token = fresh_attempt_token(namespace, id); + Self::for_attempt(namespace, id, &attempt_token) + } + + fn for_attempt(namespace: &str, id: &BoxId, attempt_token: &str) -> Self { + let hash = stable_hash(namespace.as_bytes(), id.as_str().as_bytes()); + let short = format!("{hash:016x}"); + let chain_prefix = format!("TBX{}", &short[..4]); + let chain_token = &attempt_token[..20]; + Self { + network: format!("tinybox-egress-{}", &short[..12]), + // Linux interface names are limited to IFNAMSIZ - 1 (15 bytes). + bridge: format!("tbx{}", &short[..12]), + forward_chain: format!("{chain_prefix}{chain_token}F"), + input_chain: format!("{chain_prefix}{chain_token}I"), + owner_token: scope_token(namespace, id.as_str()), + attempt_token: attempt_token.to_owned(), + chain_prefix, + chain_owner_token: chain_owner_token(namespace, id.as_str(), attempt_token), + } + } +} + +fn fresh_attempt_token(namespace: &str, id: &BoxId) -> String { + #[cfg(test)] + { + scope_token(namespace, id.as_str())[..32].to_owned() + } + #[cfg(not(test))] + { + let _ = (namespace, id); + Uuid::new_v4().simple().to_string() + } +} + +fn chain_owner_token(namespace: &str, id: &str, attempt: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"tinybox-egress-chain-owner-v1\0"); + for value in [namespace, id, attempt] { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value.as_bytes()); + } + let digest = hasher.finalize(); + let mut token = String::with_capacity(64); + for byte in digest { + token.push(char::from(HEX[usize::from(byte >> 4)])); + token.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + token +} + +fn valid_attempt_token(attempt: &str) -> bool { + attempt.len() == 32 && attempt.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn scope_token(namespace: &str, id: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update((namespace.len() as u64).to_be_bytes()); + hasher.update(namespace.as_bytes()); + hasher.update((id.len() as u64).to_be_bytes()); + hasher.update(id.as_bytes()); + let digest = hasher.finalize(); + let mut token = String::with_capacity(64); + for byte in digest { + token.push(char::from(HEX[usize::from(byte >> 4)])); + token.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + token +} + +fn stable_hash(namespace: &[u8], id: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in namespace.iter().chain([0].iter()).chain(id) { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn ensure_supported_host(host: &dyn Host) -> Result<()> { + if host.execution_location() != HostExecutionLocation::Local { + return Err(unsupported( + "egress filtering requires an explicitly local execution host", + )); + } + Ok(()) +} + +fn ensure_linux_target() -> Result<()> { + if cfg!(target_os = "linux") { + Ok(()) + } else { + Err(unsupported( + "native Docker egress filtering is available only on Linux", + )) + } +} + +fn unsupported(reason: &str) -> Error { + let _ = reason; + Error::ConstraintNotEnforced { + sandbox: "docker".to_owned(), + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + } +} + +async fn verify_engine_and_firewall(host: &dyn Host) -> Result<()> { + ensure_supported_host(host)?; + ensure_linux_target()?; + + let info = docker_output( + host, + vec![ + "docker".into(), + "info".into(), + "--format".into(), + "{{.OSType}}|{{.OperatingSystem}}|{{json .SecurityOptions}}".into(), + ], + ) + .await?; + let mut fields = info.splitn(3, '|'); + let os_type = fields.next().unwrap_or_default().trim(); + let operating_system = fields.next().unwrap_or_default().trim(); + let security = fields.next().unwrap_or_default().to_ascii_lowercase(); + if os_type != "linux" + || operating_system.is_empty() + || operating_system.to_ascii_lowercase().contains("desktop") + || security.contains("rootless") + { + return Err(unsupported( + "Docker daemon is not a native rootful Linux Engine", + )); + } + + for firewall in ["iptables", "ip6tables"] { + for chain in ["DOCKER-USER", "INPUT"] { + let output = host + .run(&ExecRequest::new([ + "sudo", "-n", firewall, "-w", "5", "-S", chain, + ])) + .await?; + if !output.succeeded() { + return Err(unsupported("required root firewall chains are unavailable")); + } + } + let forward = firewall_output(host, firewall, ["-S", "FORWARD"]).await?; + let first_rule = forward.lines().find(|line| line.starts_with("-A FORWARD ")); + if first_rule != Some("-A FORWARD -j DOCKER-USER") { + return Err(unsupported( + "Docker egress firewall hook is missing or ordered after another FORWARD rule", + )); + } + } + Ok(()) +} + +async fn verify_created_network(host: &dyn Host, names: &NetworkNames, id: &BoxId) -> Result<()> { + let output = docker_output( + host, + vec![ + "docker".into(), + "network".into(), + "inspect".into(), + "--format".into(), + format!("{{{{.Driver}}}}|{{{{.EnableIPv6}}}}|{{{{index .Options \"com.docker.network.bridge.name\"}}}}|{{{{index .Labels \"{OWNER_LABEL}\"}}}}|{{{{index .Labels \"{SCOPE_LABEL}\"}}}}|{{{{index .Labels \"{ATTEMPT_LABEL}\"}}}}"), + names.network.clone(), + ], + ) + .await?; + let expected = format!( + "bridge|true|{}|{id}|{}|{}", + names.bridge, names.owner_token, names.attempt_token + ); + if output != expected { + return Err(unsupported( + "Docker egress network did not match its verified configuration", + )); + } + Ok(()) +} + +async fn verify_targets_outside_network_subnets( + host: &dyn Host, + names: &NetworkNames, + plan: &PinnedEgressPlan, +) -> Result<()> { + let output = docker_output( + host, + vec![ + "docker".into(), + "network".into(), + "inspect".into(), + "--format".into(), + "{{json .IPAM.Config}}".into(), + names.network.clone(), + ], + ) + .await?; + let configs = serde_json::from_str::>(&output) + .map_err(|_| unsupported("Docker egress network IPAM configuration is invalid"))?; + if configs.is_empty() { + return Err(unsupported( + "Docker egress network has no inspectable IPAM subnets", + )); + } + + let subnets = configs + .iter() + .map(|config| { + config + .get("Subnet") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| unsupported("Docker egress network subnet is missing"))? + .parse::() + .map_err(|_| unsupported("Docker egress network subnet is invalid")) + }) + .collect::>>()?; + if plan.targets().iter().any(|target| { + subnets + .iter() + .any(|subnet| subnet.contains(&target.address())) + }) { + return Err(unsupported( + "an egress destination overlaps the Docker network subnet", + )); + } + Ok(()) +} + +async fn verify_created_bridge_link(host: &dyn Host, names: &NetworkNames) -> Result<()> { + let output = host + .run(&ExecRequest::new([ + "ip", + "-o", + "link", + "show", + "dev", + &names.bridge, + ])) + .await + .map_err(|_| unsupported("Docker bridge is not visible in the local network namespace"))?; + let interface_exists = output.succeeded() + && output.stdout_lossy().lines().any(|line| { + line.split_whitespace() + .nth(1) + .is_some_and(|name| name.trim_end_matches(':') == names.bridge) + }); + if !interface_exists { + return Err(unsupported( + "Docker bridge is not visible in the local network namespace", + )); + } + Ok(()) +} + +async fn install_firewall( + host: &dyn Host, + names: &NetworkNames, + plan: &PinnedEgressPlan, + attempt_owned_chains: &mut BTreeSet<(String, String)>, +) -> Result<()> { + install_family(host, "iptables", names, plan, false, attempt_owned_chains).await?; + install_family(host, "ip6tables", names, plan, true, attempt_owned_chains).await?; + verify_hook( + host, + "iptables", + "DOCKER-USER", + &names.bridge, + &names.forward_chain, + ) + .await?; + verify_hook(host, "iptables", "INPUT", &names.bridge, &names.input_chain).await?; + verify_hook( + host, + "ip6tables", + "DOCKER-USER", + &names.bridge, + &names.forward_chain, + ) + .await?; + verify_hook( + host, + "ip6tables", + "INPUT", + &names.bridge, + &names.input_chain, + ) + .await?; + Ok(()) +} + +#[allow(clippy::too_many_lines)] // Preserve the ordered default-drop rule setup as one unit. +async fn install_family( + host: &dyn Host, + firewall: &str, + names: &NetworkNames, + plan: &PinnedEgressPlan, + ipv6: bool, + attempt_owned_chains: &mut BTreeSet<(String, String)>, +) -> Result<()> { + let existing = firewall_output(host, firewall, ["-S"]).await?; + if existing.contains(&names.forward_chain) || existing.contains(&names.input_chain) { + return Err(unsupported("a stale egress firewall chain already exists")); + } + + for chain in [&names.forward_chain, &names.input_chain] { + firewall_success(host, firewall, ["-N", chain]).await?; + attempt_owned_chains.insert((firewall.to_owned(), chain.clone())); + append_chain_owner_marker(host, firewall, chain, &names.chain_owner_token, ipv6).await?; + } + for rule in deny_rules(ipv6) { + let mut command = vec!["-A".to_owned(), names.forward_chain.clone()]; + command.extend(rule); + firewall_success(host, firewall, command.iter().map(String::as_str)).await?; + } + if ipv6 { + // IPv6 needs Neighbor Solicitation to resolve the bridge gateway's + // link-layer address. Permit this control message; established replies + // are added below, while new host-bound traffic remains dropped. + firewall_success( + host, + firewall, + [ + "-A", + &names.input_chain, + "-p", + "ipv6-icmp", + "--icmpv6-type", + "neighbor-solicitation", + "-j", + "ACCEPT", + ], + ) + .await?; + } + install_pinned_destinations(host, firewall, names, plan, ipv6).await?; + firewall_success( + host, + firewall, + [ + "-A", + &names.forward_chain, + "-m", + "conntrack", + "--ctstate", + "ESTABLISHED,RELATED", + "-j", + "ACCEPT", + ], + ) + .await?; + firewall_success(host, firewall, ["-A", &names.forward_chain, "-j", "DROP"]).await?; + // Permit only return traffic for host-initiated connections, such as + // responses to a published port. New bridge-to-host connections remain + // blocked regardless of the allowlist. + firewall_success( + host, + firewall, + [ + "-A", + &names.input_chain, + "-m", + "conntrack", + "--ctstate", + "ESTABLISHED,RELATED", + "-j", + "ACCEPT", + ], + ) + .await?; + firewall_success(host, firewall, ["-A", &names.input_chain, "-j", "DROP"]).await?; + + firewall_success( + host, + firewall, + [ + "-I", + "DOCKER-USER", + "1", + "-i", + &names.bridge, + "-j", + &names.forward_chain, + ], + ) + .await?; + firewall_success( + host, + firewall, + [ + "-I", + "INPUT", + "1", + "-i", + &names.bridge, + "-j", + &names.input_chain, + ], + ) + .await?; + + // Confirm both custom chains contain the final drop before allowing Docker + // to attach an image entrypoint to the network. + for chain in [&names.forward_chain, &names.input_chain] { + let rules = firewall_output(host, firewall, ["-S", chain]).await?; + let chain_prefix = format!("-A {chain} "); + let expected_drop = format!("-A {chain} -j DROP"); + let mut chain_rules = rules.lines().filter(|line| line.starts_with(&chain_prefix)); + if chain_rules.next_back() != Some(expected_drop.as_str()) { + return Err(unsupported("default-drop egress rules were not verified")); + } + } + Ok(()) +} + +async fn install_pinned_destinations( + host: &dyn Host, + firewall: &str, + names: &NetworkNames, + plan: &PinnedEgressPlan, + ipv6: bool, +) -> Result<()> { + for target in plan.targets() { + let address = target.address(); + if is_ipv6(&address) != ipv6 { + continue; + } + for protocol in ALLOWLIST_PROTOCOLS { + firewall_success( + host, + firewall, + [ + "-A", + &names.forward_chain, + "-d", + &address.to_string(), + "-p", + protocol, + "--dport", + &target.port().to_string(), + "-j", + "ACCEPT", + ], + ) + .await?; + } + } + Ok(()) +} + +async fn append_chain_owner_marker( + host: &dyn Host, + firewall: &str, + chain: &str, + owner_token: &str, + ipv6: bool, +) -> Result<()> { + let impossible_source = if ipv6 { "::/128" } else { "0.0.0.0/32" }; + firewall_success( + host, + firewall, + [ + "-A", + chain, + "-s", + impossible_source, + "-m", + "comment", + "--comment", + owner_token, + "-j", + "DROP", + ], + ) + .await +} + +fn chain_has_owner_marker(rules: &str, chain: &str, owner_token: &str) -> bool { + let chain_prefix = format!("-A {chain} "); + rules.lines().any(|line| { + line.starts_with(&chain_prefix) + && line + .split_whitespace() + .collect::>() + .windows(2) + .any(|pair| pair == ["--comment", owner_token]) + && line + .split_whitespace() + .collect::>() + .windows(2) + .any(|pair| pair == ["-j", "DROP"]) + }) +} + +fn chain_has_rules(rules: &str, chain: &str) -> bool { + let prefix = format!("-A {chain} "); + rules.lines().any(|line| line.starts_with(&prefix)) +} + +fn chain_is_referenced(rules: &str, chain: &str) -> bool { + rules.lines().any(|line| { + let mut fields = line.split_whitespace(); + let (Some("-A"), Some(parent)) = (fields.next(), fields.next()) else { + return false; + }; + parent != chain + && fields + .collect::>() + .windows(2) + .any(|pair| pair == ["-j", chain]) + }) +} + +fn deny_rules(ipv6: bool) -> Vec> { + if ipv6 { + ["::1/128", "fd00:ec2::254/128"] + .into_iter() + .map(|prefix| vec!["-d".into(), prefix.into(), "-j".into(), "DROP".into()]) + .collect() + } else { + [ + "127.0.0.0/8", + "169.254.169.254/32", + "169.254.170.2/32", + "100.100.100.200/32", + ] + .into_iter() + .map(|prefix| vec!["-d".into(), prefix.into(), "-j".into(), "DROP".into()]) + .collect() + } +} + +fn is_ipv6(address: &IpAddr) -> bool { + matches!(address, IpAddr::V6(_)) +} + +async fn verify_hook( + host: &dyn Host, + firewall: &str, + parent: &str, + bridge: &str, + chain: &str, +) -> Result<()> { + firewall_success(host, firewall, ["-C", parent, "-i", bridge, "-j", chain]).await +} + +#[allow(clippy::too_many_lines)] // Keep ownership preflight and cleanup order together. +async fn cleanup_native(host: &dyn Host, namespace: &str, id: &BoxId) -> Result<()> { + cleanup_native_with_attempt(host, namespace, id, &BTreeSet::new(), true).await +} + +/// Reconcile a network create whose command result is ambiguous. +/// +/// The network is removed by its inspected Docker ID only when owner, scope, +/// and attempt labels match this create attempt. A missing or foreign network is safe to leave alone; +/// an inconclusive inspection or removal error is returned so the sandbox +/// retains a cleanup record for a later retry. +async fn reconcile_ambiguous_network_create( + host: &dyn Host, + names: &NetworkNames, + id: &BoxId, +) -> Result<()> { + let inspect = docker_request( + host, + force_local_rootful_docker(vec![ + "docker".into(), + "network".into(), + "inspect".into(), + "--format".into(), + format!( + "{{{{.Id}}}}|{{{{index .Labels \"{OWNER_LABEL}\"}}}}|{{{{index .Labels \"{SCOPE_LABEL}\"}}}}|{{{{index .Labels \"{ATTEMPT_LABEL}\"}}}}" + ), + names.network.clone(), + ]), + ) + .await?; + if !inspect.succeeded() { + let stderr = inspect.stderr_lossy(); + if stderr.contains("No such network") || stderr.contains("not found") { + return Ok(()); + } + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "inspect egress network ownership", + message: stderr.trim().to_owned(), + }); + } + + let inspect_stdout = inspect.stdout_lossy(); + let (network_id, owner, scope, attempt) = parse_network_ownership(&inspect_stdout)?; + if owner != id.to_string() || scope != names.owner_token || attempt != names.attempt_token { + return Ok(()); + } + + let remove = docker_request( + host, + force_local_rootful_docker(vec![ + "docker".into(), + "network".into(), + "rm".into(), + network_id.clone(), + ]), + ) + .await?; + if remove.succeeded() { + return Ok(()); + } + + // Docker may report failure after deleting the network. Re-inspect the + // deterministic name to distinguish that case from an uncertain cleanup. + let verify = docker_request( + host, + force_local_rootful_docker(vec![ + "docker".into(), + "network".into(), + "inspect".into(), + "--format".into(), + format!( + "{{{{.Id}}}}|{{{{index .Labels \"{OWNER_LABEL}\"}}}}|{{{{index .Labels \"{SCOPE_LABEL}\"}}}}|{{{{index .Labels \"{ATTEMPT_LABEL}\"}}}}" + ), + names.network.clone(), + ]), + ) + .await?; + if !verify.succeeded() { + let stderr = verify.stderr_lossy(); + if stderr.contains("No such network") || stderr.contains("not found") { + return Ok(()); + } + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "inspect egress network ownership", + message: stderr.trim().to_owned(), + }); + } + let verify_stdout = verify.stdout_lossy(); + let (_network_id, owner, scope, attempt) = parse_network_ownership(&verify_stdout)?; + if owner != id.to_string() || scope != names.owner_token || attempt != names.attempt_token { + return Ok(()); + } + Err(unsupported("owned egress network rollback failed")) +} + +async fn reconcile_failed_network_create( + host: &dyn Host, + names: &NetworkNames, + id: &BoxId, + original_error: Error, +) -> Error { + match reconcile_ambiguous_network_create(host, names, id).await { + Ok(()) => original_error, + Err(cleanup_error) => Error::Backend { + sandbox: "docker".to_owned(), + operation: "roll back egress network setup", + message: format!("{original_error}; cleanup failed: {cleanup_error}"), + }, + } +} + +fn parse_network_ownership(output: &str) -> Result<(String, String, String, String)> { + let fields = output.trim().split('|').collect::>(); + if fields.len() != 4 || fields.iter().any(|field| field.is_empty()) { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "inspect egress network ownership", + message: "Docker returned missing or malformed network identity or owner labels" + .to_owned(), + }); + } + Ok(( + fields[0].to_owned(), + fields[1].to_owned(), + fields[2].to_owned(), + fields[3].to_owned(), + )) +} + +fn parse_network_labels(output: &str) -> Result<(String, String, String)> { + let fields = output.trim().split('|').collect::>(); + if fields.len() != 3 || fields.iter().any(|field| field.is_empty()) { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "inspect egress network ownership", + message: "Docker returned missing or malformed egress ownership labels".to_owned(), + }); + } + Ok(( + fields[0].to_owned(), + fields[1].to_owned(), + fields[2].to_owned(), + )) +} + +#[allow(clippy::too_many_lines)] // Keep ownership preflight and cleanup order together. +async fn cleanup_native_with_attempt( + host: &dyn Host, + namespace: &str, + id: &BoxId, + attempt_owned_chains: &BTreeSet<(String, String)>, + allow_empty_orphan_reclaim: bool, +) -> Result<()> { + let base_names = NetworkNames::for_box(namespace, id); + let inspect = docker_request( + host, + force_local_rootful_docker(vec![ + "docker".into(), + "network".into(), + "inspect".into(), + "--format".into(), + format!( + "{{{{index .Labels \"{OWNER_LABEL}\"}}}}|{{{{index .Labels \"{SCOPE_LABEL}\"}}}}|{{{{index .Labels \"{ATTEMPT_LABEL}\"}}}}" + ), + base_names.network.clone(), + ]), + ) + .await?; + let names = if inspect.succeeded() { + let fields = inspect.stdout_lossy(); + let (owner, scope, attempt) = parse_network_labels(&fields)?; + if owner != id.to_string() || scope != base_names.owner_token { + return Err(unsupported( + "egress network ownership could not be verified", + )); + } + if !valid_attempt_token(&attempt) { + return Err(unsupported("egress network attempt identity is malformed")); + } + NetworkNames::for_attempt(namespace, id, &attempt) + } else if inspect.stderr_lossy().contains("No such network") + || inspect.stderr_lossy().contains("not found") + { + for firewall in ["iptables", "ip6tables"] { + let rules = firewall_output(host, firewall, ["-S"]).await?; + if rules.lines().any(|line| { + line.split_whitespace() + .any(|word| word.starts_with(&base_names.chain_prefix)) + }) { + return Err(unsupported( + "orphaned egress firewall chain has no verifiable Docker network owner", + )); + } + } + return Ok(()); + } else { + return Err(unsupported("egress network cleanup could not be verified")); + }; + + let chains = [ + ("iptables", "DOCKER-USER", names.forward_chain.as_str()), + ("iptables", "INPUT", names.input_chain.as_str()), + ("ip6tables", "DOCKER-USER", names.forward_chain.as_str()), + ("ip6tables", "INPUT", names.input_chain.as_str()), + ]; + let mut owned_chains = BTreeSet::new(); + for (firewall, _, chain) in chains { + let rules = firewall_output(host, firewall, ["-S"]).await?; + let chain_prefix = format!("-A {chain} "); + let chain_exists = rules + .lines() + .any(|line| line == format!("-N {chain}") || line.starts_with(&chain_prefix)); + if chain_exists { + let created_by_attempt = + attempt_owned_chains.contains(&(firewall.to_owned(), chain.to_owned())); + let marked = chain_has_owner_marker(&rules, chain, &names.chain_owner_token); + let recoverable_empty_orphan = (created_by_attempt || allow_empty_orphan_reclaim) + && !chain_has_rules(&rules, chain) + && !chain_is_referenced(&rules, chain); + if !marked && !recoverable_empty_orphan { + return Err(unsupported( + "egress firewall chain ownership could not be verified", + )); + } + owned_chains.insert((firewall, chain)); + } + } + + for (firewall, parent, chain) in chains { + if !owned_chains.contains(&(firewall, chain)) { + continue; + } + let hook = host + .run(&ExecRequest::new([ + "sudo", + "-n", + firewall, + "-w", + "5", + "-C", + parent, + "-i", + &names.bridge, + "-j", + chain, + ])) + .await?; + if hook.succeeded() { + firewall_success( + host, + firewall, + ["-D", parent, "-i", &names.bridge, "-j", chain], + ) + .await?; + } + firewall_success(host, firewall, ["-F", chain]).await?; + if let Err(delete_error) = firewall_success(host, firewall, ["-X", chain]).await { + let ipv6 = firewall == "ip6tables"; + return match append_chain_owner_marker( + host, + firewall, + chain, + &names.chain_owner_token, + ipv6, + ) + .await + { + Ok(()) => Err(delete_error), + Err(restore_error) => Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "restore egress firewall ownership marker", + message: format!( + "{delete_error}; restoring ownership marker failed: {restore_error}" + ), + }), + }; + } + let remaining = firewall_output(host, firewall, ["-S"]).await?; + if remaining + .lines() + .any(|line| line == format!("-N {chain}") || line.starts_with(&format!("-A {chain} "))) + { + return Err(unsupported( + "egress firewall chain cleanup was not verified", + )); + } + } + + docker_success( + host, + vec![ + "docker".into(), + "network".into(), + "rm".into(), + names.network, + ], + ) + .await?; + Ok(()) +} + +async fn firewall_output( + host: &dyn Host, + firewall: &str, + arguments: [&str; N], +) -> Result { + let mut argv = vec!["sudo", "-n", firewall, "-w", "5"]; + argv.extend(arguments); + let output = host.run(&ExecRequest::new(argv)).await?; + if !output.succeeded() { + return Err(unsupported("required firewall command failed")); + } + Ok(output.stdout_lossy().into_owned()) +} + +async fn firewall_success(host: &dyn Host, firewall: &str, arguments: I) -> Result<()> +where + I: IntoIterator, + S: AsRef, +{ + let mut argv = vec![ + "sudo".to_owned(), + "-n".to_owned(), + firewall.to_owned(), + "-w".to_owned(), + "5".to_owned(), + ]; + argv.extend( + arguments + .into_iter() + .map(|argument| argument.as_ref().to_owned()), + ); + let output = host.run(&ExecRequest::new(argv)).await?; + if !output.succeeded() { + return Err(unsupported("required firewall command failed")); + } + Ok(()) +} + +async fn docker_output(host: &dyn Host, argv: Vec) -> Result { + let output = docker_request(host, force_local_rootful_docker(argv)).await?; + if !output.succeeded() { + return Err(unsupported( + "Docker daemon identity or configuration could not be verified", + )); + } + Ok(output.stdout_lossy().trim().to_owned()) +} + +async fn docker_success(host: &dyn Host, argv: Vec) -> Result { + let output = docker_request(host, force_local_rootful_docker(argv)).await?; + if !output.succeeded() { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "configure egress filtering", + message: output.stderr_lossy().trim().to_owned(), + }); + } + Ok(output.stdout_lossy().trim().to_owned()) +} + +async fn docker_request(host: &dyn Host, argv: Vec) -> Result { + host.run(&ExecRequest::new(argv)).await +} + +fn force_local_rootful_docker(argv: Vec) -> Vec { + if argv.first().is_some_and(|program| program == "env") { + return argv; + } + let mut forced = vec![ + "env".into(), + "-u".into(), + "DOCKER_HOST".into(), + "-u".into(), + "DOCKER_CONTEXT".into(), + "-u".into(), + "DOCKER_TLS_VERIFY".into(), + "-u".into(), + "DOCKER_CERT_PATH".into(), + "docker".into(), + "--host".into(), + DOCKER_SOCKET.into(), + ]; + forced.extend(argv.into_iter().skip(1)); + forced +} diff --git a/crates/tinybox-docker/src/egress_enforcement/README.md b/crates/tinybox-docker/src/egress_enforcement/README.md new file mode 100644 index 00000000..94ed7fc6 --- /dev/null +++ b/crates/tinybox-docker/src/egress_enforcement/README.md @@ -0,0 +1,49 @@ +# Docker egress enforcement + +`NativeLinuxDockerEgressController` is the opt-in implementation of +`DockerEgressController`. `DockerSandbox` has no controller by default, so an +allowlist remains Unsupported unless a host installs this implementation. + +## Setup and enforcement + +Before creating a network, the controller requires an explicitly local host, +the rootful Docker Engine socket, Linux Docker, `iptables` and `ip6tables`, +their restore tools, Docker's `DOCKER-USER` forwarding hooks, and a usable +bridge firewall interface. It resolves hostnames once through the injected +resolver, rejects loopback and metadata answers, and retains every pinned +address. Docker-assigned IPAM subnets are checked before the firewall is +installed; any pinned address inside one causes the whole setup to roll back. + +The controller installs a per-attempt bridge and default-drop input and forwarding +chains before Docker starts the workload. A fresh random attempt token is stored +on the Docker network and incorporated into the chain names. Pinned TCP destination addresses and +ports are accepted in `DOCKER-USER`; new host-bound traffic is dropped. The +input chain permits required IPv6 Neighbor Solicitation and established +replies. Docker receives static `/etc/hosts` mappings for approved names and a +loopback-only resolver, so its embedded resolver cannot forward arbitrary +queries. Firewall rules also block loopback and the known metadata endpoints. + +Each chain is created exclusively, then receives a cryptographic owner marker. +Cleanup first verifies the network owner, scope, and attempt labels and all +existing chain markers before changing firewall state. After a process restart, +an unmarked chain is reclaimable only when its attempt token matches the +network, the chain is empty, and no firewall hook references it. Nonempty or +otherwise ambiguous orphan chains are left untouched and cleanup fails closed. +The controller removes only the hooks, chains, and network owned by this +attempt. If verification or cleanup fails, the caller retains the box record so +cleanup can be retried. + +## Public surface and limits + +- `DockerEgressController` is the lifecycle interface consumed by + `DockerSandbox`. +- `PreparedEgressNetwork` carries the Docker network name and pinned host + mappings used in the workload's Docker arguments. +- `NativeLinuxDockerEgressController::new` accepts the resolver used to build + the pinned plan. + +Docker Desktop, remote or unknown daemons, rootless Docker, Windows, macOS, and +hosts without the required firewall or restore capability are refused. The +controller filters TCP egress only; this is not a general-purpose firewall or +a claim that DNS answers are monitored after the one-time plan is created. +The real firewall proof runs only in the disposable hosted Ubuntu Docker job. diff --git a/crates/tinybox-docker/src/egress_enforcement_tests.rs b/crates/tinybox-docker/src/egress_enforcement_tests.rs new file mode 100644 index 00000000..ef867f94 --- /dev/null +++ b/crates/tinybox-docker/src/egress_enforcement_tests.rs @@ -0,0 +1,1439 @@ +//! Tests for native Docker egress filter capability checks. + +use std::{ + fmt::Write as _, + net::IpAddr, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use tinybox_core::{BoxId, Error, ExecOutput, ExecRequest, Host, HostExecutionLocation, Result}; + +use super::{ + DockerEgressController, NativeLinuxDockerEgressController, NetworkNames, deny_rules, + scope_token, verify_created_bridge_link, +}; +use crate::EgressResolver; + +#[derive(Debug)] +struct FakeNativeHost { + missing_forward_hook: &'static str, + commands: Mutex>>, +} + +impl FakeNativeHost { + fn new(missing_forward_hook: &'static str) -> Self { + Self { + missing_forward_hook, + commands: Mutex::new(Vec::new()), + } + } + + fn commands(&self) -> Vec> { + self.commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +#[async_trait] +impl Host for FakeNativeHost { + fn name(&self) -> &'static str { + "fake-native" + } + + fn execution_location(&self) -> HostExecutionLocation { + HostExecutionLocation::Local + } + + async fn run(&self, request: &ExecRequest) -> Result { + self.commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request.argv.clone()); + + let is_missing_hook = request.argv.iter().any(|part| part == "-C") + && request.argv.iter().any(|part| part == "FORWARD") + && request.argv.iter().any(|part| part == "DOCKER-USER") + && request + .argv + .iter() + .any(|part| part == self.missing_forward_hook); + if request.argv.iter().any(|part| part == "docker") { + return Ok(ExecOutput::new( + 0, + b"linux|Ubuntu 26.04|[]".to_vec(), + Vec::new(), + )); + } + if is_missing_hook { + return Ok(ExecOutput::new(1, Vec::new(), b"rule not found".to_vec())); + } + if request.argv.iter().any(|part| part == "FORWARD") + && request.argv.iter().any(|part| part == "-S") + { + let firewall = if request.argv.iter().any(|part| part == "ip6tables") { + "ip6tables" + } else { + "iptables" + }; + let rules = if firewall == self.missing_forward_hook { + "-P FORWARD DROP\n-A FORWARD -j DOCKER-FORWARD\n" + } else { + "-P FORWARD DROP\n-A FORWARD -j DOCKER-USER\n-A FORWARD -j DOCKER-FORWARD\n" + }; + return Ok(ExecOutput::new(0, rules.as_bytes().to_vec(), Vec::new())); + } + Ok(ExecOutput::new(0, b"chain exists\n".to_vec(), Vec::new())) + } +} + +#[derive(Debug)] +struct Resolver; + +impl EgressResolver for Resolver { + fn resolve(&self, _hostname: &str) -> std::io::Result> { + Ok(vec![ + IpAddr::from([203, 0, 113, 8]), + IpAddr::from([203, 0, 113, 9]), + ]) + } +} + +#[derive(Debug)] +#[cfg(target_os = "linux")] +struct MappedIpv4Resolver; + +#[cfg(target_os = "linux")] +impl EgressResolver for MappedIpv4Resolver { + fn resolve(&self, _hostname: &str) -> std::io::Result> { + Ok(vec!["::ffff:203.0.113.11".parse().map_err(|error| { + std::io::Error::other(format!("invalid fixture: {error}")) + })?]) + } +} + +#[derive(Debug)] +struct BridgeLinkHost { + exists: bool, +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum NetworkCreateBehavior { + Succeed, + CommandFailure, + HostErrorAfterSideEffect, +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum NetworkInspectBehavior { + Owned, + Absent, + Foreign, + Uncertain, + Malformed, +} + +#[derive(Debug)] +struct PreparedNativeHost { + commands: Mutex>>, + chains: Mutex>, + marked_chains: Mutex>, + chains_with_rules: Mutex>, + network_create_behavior: NetworkCreateBehavior, + network_inspect_behavior: NetworkInspectBehavior, + missing_final_drop: Option, + empty_network_create: bool, + fail_chain_marker: bool, + #[cfg(target_os = "linux")] + no_matching_hooks: bool, + #[cfg(target_os = "linux")] + fail_chain_delete_once: Mutex, + #[cfg(target_os = "linux")] + fail_network_remove_once: Mutex, + network_owner: String, + network_scope: String, + network_attempt: String, + network_subnets: String, + chain_owner_token: String, + bridge: String, + forward_chain: String, + input_chain: String, +} + +impl PreparedNativeHost { + fn new(id: &BoxId, names: &NetworkNames) -> Self { + Self { + commands: Mutex::new(Vec::new()), + chains: Mutex::new(std::collections::BTreeSet::new()), + marked_chains: Mutex::new(std::collections::BTreeSet::new()), + chains_with_rules: Mutex::new(std::collections::BTreeSet::new()), + network_create_behavior: NetworkCreateBehavior::Succeed, + network_inspect_behavior: NetworkInspectBehavior::Owned, + missing_final_drop: None, + empty_network_create: false, + fail_chain_marker: false, + #[cfg(target_os = "linux")] + no_matching_hooks: false, + #[cfg(target_os = "linux")] + fail_chain_delete_once: Mutex::new(false), + #[cfg(target_os = "linux")] + fail_network_remove_once: Mutex::new(false), + network_owner: id.to_string(), + network_scope: names.owner_token.clone(), + network_attempt: names.attempt_token.clone(), + network_subnets: r#"[{"Subnet":"172.18.0.0/16"},{"Subnet":"fd00:dead:beef::/64"}]"# + .to_owned(), + chain_owner_token: names.chain_owner_token.clone(), + bridge: names.bridge.clone(), + forward_chain: names.forward_chain.clone(), + input_chain: names.input_chain.clone(), + } + } + + fn commands(&self) -> Vec> { + self.commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +#[async_trait] +#[allow(clippy::too_many_lines)] // The fake host models the firewall and Docker command surface. +impl Host for PreparedNativeHost { + fn name(&self) -> &'static str { + "prepared-native" + } + + fn execution_location(&self) -> HostExecutionLocation { + HostExecutionLocation::Local + } + + async fn run(&self, request: &ExecRequest) -> Result { + let argv = &request.argv; + self.commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(argv.clone()); + if let Some(docker_index) = argv.iter().position(|arg| arg == "docker") { + let docker_args = &argv[docker_index + 1..]; + if docker_args.iter().any(|arg| arg == "info") { + return Ok(ExecOutput::new( + 0, + b"linux|Ubuntu 26.04|[]".to_vec(), + Vec::new(), + )); + } + if docker_args.iter().any(|arg| arg == "network") { + #[cfg(target_os = "linux")] + if docker_args.iter().any(|arg| arg == "rm") + && *self + .fail_network_remove_once + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + { + *self + .fail_network_remove_once + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = false; + return Ok(ExecOutput::new( + 1, + Vec::new(), + b"injected network removal failure".to_vec(), + )); + } + if docker_args.iter().any(|arg| arg == "create") { + if matches!( + self.network_create_behavior, + NetworkCreateBehavior::CommandFailure + ) { + return Ok(ExecOutput::new( + 1, + Vec::new(), + b"network name already exists".to_vec(), + )); + } + if matches!( + self.network_create_behavior, + NetworkCreateBehavior::HostErrorAfterSideEffect + ) { + return Err(Error::Backend { + sandbox: "fake-native".to_owned(), + operation: "run Docker network create", + message: "simulated lost command response after create".to_owned(), + }); + } + let network_id = if self.empty_network_create { + Vec::new() + } else { + b"network-id".to_vec() + }; + return Ok(ExecOutput::new(0, network_id, Vec::new())); + } + if let Some(inspect_index) = docker_args.iter().position(|arg| arg == "inspect") { + match self.network_inspect_behavior { + NetworkInspectBehavior::Absent => { + return Ok(ExecOutput::new( + 1, + Vec::new(), + b"Error: No such network".to_vec(), + )); + } + NetworkInspectBehavior::Uncertain => { + return Ok(ExecOutput::new( + 1, + Vec::new(), + b"Error: daemon temporarily unavailable".to_vec(), + )); + } + NetworkInspectBehavior::Owned + | NetworkInspectBehavior::Foreign + | NetworkInspectBehavior::Malformed => {} + } + let format = docker_args + .get(inspect_index + 2) + .map_or("", String::as_str); + let owner = if matches!( + self.network_inspect_behavior, + NetworkInspectBehavior::Foreign + ) { + "another-box" + } else { + &self.network_owner + }; + let stdout = if matches!( + self.network_inspect_behavior, + NetworkInspectBehavior::Malformed + ) { + "network-id|owner-without-scope".to_owned() + } else if format.contains(".Id") { + format!( + "network-id|{owner}|{}|{}", + self.network_scope, self.network_attempt + ) + } else if format.contains(".Driver") { + format!( + "bridge|true|{}|{}|{}|{}", + self.bridge, owner, self.network_scope, self.network_attempt + ) + } else if format.contains(".IPAM.Config") { + self.network_subnets.clone() + } else { + format!("{owner}|{}|{}", self.network_scope, self.network_attempt) + }; + return Ok(ExecOutput::new(0, stdout.into_bytes(), Vec::new())); + } + } + } + + if argv.first().is_some_and(|arg| arg == "ip") { + return Ok(ExecOutput::new( + 0, + format!("17: {}: \n", self.bridge).into_bytes(), + Vec::new(), + )); + } + + if argv + .iter() + .any(|arg| arg == "iptables" || arg == "ip6tables") + { + #[cfg(target_os = "linux")] + if self.no_matching_hooks && argv.iter().any(|arg| arg == "-C") { + return Ok(ExecOutput::new(1, Vec::new(), b"rule not found".to_vec())); + } + #[cfg(target_os = "linux")] + if let Some(index) = argv.iter().position(|arg| arg == "-F") + && let Some(chain) = argv.get(index + 1) + { + let firewall = argv + .iter() + .find(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + .cloned() + .unwrap_or_default(); + self.marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(firewall.clone(), chain.clone())); + self.chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(firewall, chain.clone())); + } + #[cfg(target_os = "linux")] + if *self + .fail_chain_delete_once + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + && let Some(index) = argv.iter().position(|arg| arg == "-X") + && let Some(chain) = argv.get(index + 1) + { + *self + .fail_chain_delete_once + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = false; + if chain == &self.forward_chain { + return Ok(ExecOutput::new( + 1, + Vec::new(), + b"injected chain delete failure".to_vec(), + )); + } + } + if let Some(index) = argv.iter().position(|arg| arg == "-N") + && let Some(chain) = argv.get(index + 1) + { + let firewall = argv + .iter() + .find(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + .cloned() + .unwrap_or_default(); + self.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((firewall, chain.clone())); + } + if let Some(index) = argv.iter().position(|arg| arg == "-X") + && let Some(chain) = argv.get(index + 1) + { + let firewall = argv + .iter() + .find(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + .cloned() + .unwrap_or_default(); + self.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(firewall.clone(), chain.clone())); + self.marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(firewall.clone(), chain.clone())); + self.chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(firewall, chain.clone())); + } + if self.fail_chain_marker + && argv + .windows(2) + .any(|args| args == ["--comment", &self.chain_owner_token]) + { + return Ok(ExecOutput::new( + 1, + Vec::new(), + b"injected owner marker failure".to_vec(), + )); + } + if argv.iter().any(|arg| arg == "--comment") + && let Some(index) = argv.iter().position(|arg| arg == "-A") + && let Some(chain) = argv.get(index + 1) + { + let firewall = argv + .iter() + .find(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + .cloned() + .unwrap_or_default(); + self.marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((firewall.clone(), chain.clone())); + self.chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((firewall, chain.clone())); + } else if let Some(index) = argv.iter().position(|arg| arg == "-A") + && let Some(chain) = argv.get(index + 1) + { + let firewall = argv + .iter() + .find(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + .cloned() + .unwrap_or_default(); + self.chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((firewall, chain.clone())); + } + if argv.iter().any(|arg| arg == "FORWARD") { + return Ok(ExecOutput::new( + 0, + b"-P FORWARD DROP\n-A FORWARD -j DOCKER-USER\n-A FORWARD -j DOCKER-FORWARD\n" + .to_vec(), + Vec::new(), + )); + } + if argv.iter().any(|arg| arg == "-S") { + let firewall = argv + .iter() + .find(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + .cloned() + .unwrap_or_default(); + let chains = self + .chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let marked_chains = self + .marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let chains_with_rules = self + .chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let stdout = [&self.forward_chain, &self.input_chain] + .into_iter() + .filter(|chain| chains.contains(&(firewall.clone(), (*chain).clone()))) + .fold(String::new(), |mut stdout, chain| { + let _ = writeln!(stdout, "-N {chain}"); + if marked_chains.contains(&(firewall.clone(), chain.clone())) { + let _ = writeln!( + stdout, + "-A {chain} -s 0.0.0.0/32 -m comment --comment {} -j DROP", + self.chain_owner_token + ); + } + if chains_with_rules.contains(&(firewall.clone(), chain.clone())) + && self.missing_final_drop.as_deref() != Some(chain) + { + let _ = writeln!(stdout, "-A {chain} -j DROP"); + } + stdout + }); + return Ok(ExecOutput::new(0, stdout.into_bytes(), Vec::new())); + } + return Ok(ExecOutput::new(0, Vec::new(), Vec::new())); + } + + Ok(ExecOutput::new(0, Vec::new(), Vec::new())) + } +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn owner_marker_drop_does_not_satisfy_default_drop_verification() -> Result<()> { + let id = BoxId::new("box-marker-is-not-default-drop")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.missing_final_drop = Some(names.forward_chain.clone()); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert!(host.commands().iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") && argv.iter().any(|arg| arg == &names.network) + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn uncertain_network_create_removes_only_the_verified_owned_network() -> Result<()> { + let id = BoxId::new("box-create-response-lost")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::HostErrorAfterSideEffect; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + let result = controller.prepare(&host, namespace, &id, &allowlist).await; + + assert!(matches!( + result, + Err(Error::Backend { + operation: "run Docker network create", + ref message, + .. + }) if message.contains("simulated lost command response") + )); + let commands = host.commands(); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "inspect") && argv.iter().any(|arg| arg.contains("{{.Id}}")) + })); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") && argv.iter().any(|arg| arg == "network-id") + })); + assert!(!commands.iter().any(|argv| { + argv.iter() + .any(|arg| matches!(arg.as_str(), "-A" | "-D" | "-F" | "-X")) + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn ambiguous_create_retains_cleanup_error_when_owned_network_removal_fails() -> Result<()> { + let id = BoxId::new("box-create-remove-fails")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::HostErrorAfterSideEffect; + *host + .fail_network_remove_once + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + let result = controller.prepare(&host, namespace, &id, &allowlist).await; + assert!( + matches!( + result, + Err(Error::Backend { + operation: "roll back egress network setup", + ref message, + .. + }) if message.contains("simulated lost command response") + ), + "{result:?}; commands={:?}", + host.commands() + ); + assert_eq!( + host.commands() + .iter() + .filter(|argv| argv.iter().any(|arg| arg == "inspect") + && argv.iter().any(|arg| arg.contains("{{.Id}}"))) + .count(), + 2 + ); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn ambiguous_create_preserves_same_box_network_from_an_older_attempt() -> Result<()> { + let id = BoxId::new("box-create-old-attempt")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::HostErrorAfterSideEffect; + host.network_attempt = "older-attempt-token".to_owned(); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!( + controller + .prepare(&host, namespace, &id, &allowlist) + .await + .is_err() + ); + let commands = host.commands(); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "inspect") + && argv + .iter() + .any(|arg| arg.contains("ai.tinyhumans.tinybox.egress.attempt")) + })); + assert!( + !commands + .iter() + .any(|argv| argv.iter().any(|arg| arg == "rm")) + ); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn ambiguous_network_create_keeps_the_original_error_when_absent_or_foreign() -> Result<()> { + for absent in [false, true] { + let id = BoxId::new(if absent { + "box-create-absent" + } else { + "box-create-foreign" + })?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::HostErrorAfterSideEffect; + host.network_inspect_behavior = if absent { + NetworkInspectBehavior::Absent + } else { + NetworkInspectBehavior::Foreign + }; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::Backend { + operation: "run Docker network create", + ref message, + .. + }) if message.contains("simulated lost command response") + )); + assert!( + !host + .commands() + .iter() + .any(|argv| { argv.iter().any(|arg| arg == "rm") }) + ); + } + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn ambiguous_network_create_reports_rollback_uncertainty_when_inspection_fails() -> Result<()> +{ + let id = BoxId::new("box-create-inspect-uncertain")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::HostErrorAfterSideEffect; + host.network_inspect_behavior = NetworkInspectBehavior::Uncertain; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::Backend { + operation: "roll back egress network setup", + ref message, + .. + }) if message.contains("simulated lost command response") + && message.contains("daemon temporarily unavailable") + )); + assert!( + !host + .commands() + .iter() + .any(|argv| { argv.iter().any(|arg| arg == "rm") }) + ); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn ambiguous_network_create_retains_ownership_uncertainty_for_malformed_inspection() +-> Result<()> { + let id = BoxId::new("box-create-inspect-malformed")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::HostErrorAfterSideEffect; + host.network_inspect_behavior = NetworkInspectBehavior::Malformed; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::Backend { + operation: "roll back egress network setup", + ref message, + .. + }) if message.contains("missing or malformed network identity") + )); + assert!( + !host + .commands() + .iter() + .any(|argv| { argv.iter().any(|arg| arg == "rm") }) + ); + Ok(()) +} + +#[async_trait] +impl Host for BridgeLinkHost { + fn name(&self) -> &'static str { + "bridge-link-host" + } + + fn execution_location(&self) -> HostExecutionLocation { + HostExecutionLocation::Local + } + + async fn run(&self, request: &ExecRequest) -> Result { + let bridge = request.argv.last().map_or("", String::as_str); + if self.exists { + Ok(ExecOutput::new( + 0, + format!("17: {bridge}: \n").into_bytes(), + Vec::new(), + )) + } else { + Ok(ExecOutput::new(1, Vec::new(), b"Device not found".to_vec())) + } + } +} + +#[test] +fn allowlist_protocol_set_is_tcp_only() { + assert_eq!(&super::ALLOWLIST_PROTOCOLS[..], &["tcp"]); +} + +#[tokio::test] +async fn missing_local_bridge_link_refuses_enforcement() -> Result<()> { + let names = NetworkNames::for_box("test", &BoxId::new("box-0")?); + let host = BridgeLinkHost { exists: false }; + + assert!(matches!( + verify_created_bridge_link(&host, &names).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + Ok(()) +} + +#[tokio::test] +async fn existing_local_bridge_link_is_verified() -> Result<()> { + let names = NetworkNames::for_box("test", &BoxId::new("box-0")?); + let host = BridgeLinkHost { exists: true }; + + verify_created_bridge_link(&host, &names).await +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn native_controller_prepares_and_cleans_a_pinned_allowlist() -> Result<()> { + let id = BoxId::new("box-0")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let host = PreparedNativeHost::new(&id, &names); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + let prepared = controller.prepare(&host, namespace, &id, &allowlist).await; + assert!( + prepared.is_ok(), + "{prepared:?}; commands={:?}", + host.commands() + ); + let network = prepared?; + assert_eq!(network.network(), names.network); + assert_eq!( + network.host_mappings(), + [ + ("service.test".to_owned(), IpAddr::from([203, 0, 113, 8])), + ("service.test".to_owned(), IpAddr::from([203, 0, 113, 9])), + ] + ); + controller.cleanup(&host, namespace, &id).await?; + + let commands = host.commands(); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "-A") + && argv.iter().any(|arg| arg == "203.0.113.8") + && argv.iter().any(|arg| arg == "443") + && argv.iter().any(|arg| arg == "tcp") + && argv.iter().any(|arg| arg == "ACCEPT") + })); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "DROP") && argv.iter().any(|arg| arg == &names.input_chain) + })); + let established_position = commands + .iter() + .position(|argv| { + argv.windows(2) + .any(|args| args == ["--ctstate", "ESTABLISHED,RELATED"]) + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find established-flow firewall rule", + message: "rule was not installed".to_owned(), + })?; + let forward_drop_position = commands + .iter() + .position(|argv| { + argv.windows(4) + .any(|args| args == ["-A", &names.forward_chain, "-j", "DROP"]) + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find forward default-drop rule", + message: "rule was not installed".to_owned(), + })?; + assert!(established_position < forward_drop_position); + assert_input_return_rules(&commands, &names)?; + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "-D") && argv.iter().any(|arg| arg == "DOCKER-USER") + })); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") && argv.iter().any(|arg| arg == &names.network) + })); + assert!( + commands + .iter() + .filter(|argv| argv.iter().any(|arg| arg == "docker")) + .all(|argv| { + argv.first().is_some_and(|arg| arg == "env") + && argv.iter().any(|arg| arg == "unix:///var/run/docker.sock") + }) + ); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn cleanup_reclaims_an_empty_attempt_chain_after_restart() -> Result<()> { + let id = BoxId::new("box-empty-orphan")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.no_matching_hooks = true; + host.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(("iptables".to_owned(), names.forward_chain.clone())); + + // This host models a fresh process: no attempt-owned in-memory set exists. + NativeLinuxDockerEgressController::new(Arc::new(Resolver)) + .cleanup(&host, namespace, &id) + .await?; + assert!( + !host + .chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&("iptables".to_owned(), names.forward_chain.clone())) + ); + assert!(host.commands().iter().any(|argv| { + argv.iter().any(|arg| arg == "-X") && argv.iter().any(|arg| arg == &names.forward_chain) + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn cleanup_without_network_succeeds_when_no_orphaned_chains_exist() -> Result<()> { + let id = BoxId::new("box-network-already-absent")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_inspect_behavior = NetworkInspectBehavior::Absent; + host.no_matching_hooks = true; + + NativeLinuxDockerEgressController::new(Arc::new(Resolver)) + .cleanup(&host, namespace, &id) + .await?; + + let commands = host.commands(); + assert!( + commands + .iter() + .filter(|argv| argv.iter().any(|arg| arg == "-S")) + .count() + >= 2 + ); + assert!(!commands.iter().any(|argv| { + argv.iter() + .any(|arg| matches!(arg.as_str(), "-D" | "-F" | "-X" | "rm")) + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn cleanup_refuses_a_nonempty_unmarked_attempt_chain_after_restart() -> Result<()> { + let id = BoxId::new("box-nonempty-orphan")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.no_matching_hooks = true; + host.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(("iptables".to_owned(), names.forward_chain.clone())); + host.chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(("iptables".to_owned(), names.forward_chain.clone())); + + assert!( + NativeLinuxDockerEgressController::new(Arc::new(Resolver)) + .cleanup(&host, namespace, &id) + .await + .is_err() + ); + assert!(!host.commands().iter().any(|argv| { + argv.iter() + .any(|arg| matches!(arg.as_str(), "-D" | "-F" | "-X")) + })); + assert!( + host.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&("iptables".to_owned(), names.forward_chain.clone())) + ); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn pinned_targets_inside_auto_selected_network_subnets_are_rejected_and_rolled_back() +-> Result<()> { + for (id_text, address, subnet) in [ + ("box-v4-overlap", "172.28.1.5", "172.28.0.0/16"), + ("box-v6-overlap", "fd00:1234::42", "fd00:1234::/64"), + ] { + let id = BoxId::new(id_text)?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_subnets = format!(r#"[{{"Subnet":"{subnet}"}}]"#); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new([address], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + let commands = host.commands(); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "inspect") + && argv.iter().any(|arg| arg.contains(".IPAM.Config")) + })); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") && argv.iter().any(|arg| arg == &names.network) + })); + assert!(!commands.iter().any(|argv| { + argv.iter() + .any(|arg| matches!(arg.as_str(), "-N" | "-A" | "-I")) + && argv + .iter() + .any(|arg| matches!(arg.as_str(), "iptables" | "ip6tables")) + })); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn assert_input_return_rules(commands: &[Vec], names: &NetworkNames) -> Result<()> { + let input_established_position = commands + .iter() + .position(|argv| { + argv.iter().any(|arg| arg == "iptables") + && argv + .windows(4) + .any(|args| args == ["-A", &names.input_chain, "-m", "conntrack"]) + && argv + .windows(2) + .any(|args| args == ["--ctstate", "ESTABLISHED,RELATED"]) + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find established INPUT return rule", + message: "rule was not installed".to_owned(), + })?; + let ipv4_input_drop_position = commands + .iter() + .position(|argv| { + argv.iter().any(|arg| arg == "iptables") + && argv + .windows(4) + .any(|args| args == ["-A", &names.input_chain, "-j", "DROP"]) + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find IPv4 INPUT default-drop rule", + message: "rule was not installed".to_owned(), + })?; + assert!(input_established_position < ipv4_input_drop_position); + let neighbor_solicitation_position = commands + .iter() + .position(|argv| { + argv.iter().any(|arg| arg == "ip6tables") + && argv.iter().any(|arg| arg == "neighbor-solicitation") + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find IPv6 neighbor-discovery rule", + message: "rule was not installed".to_owned(), + })?; + let input_drop_position = commands + .iter() + .position(|argv| { + argv.iter().any(|arg| arg == "ip6tables") + && argv + .windows(4) + .any(|args| args == ["-A", &names.input_chain, "-j", "DROP"]) + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find INPUT default-drop rule", + message: "rule was not installed".to_owned(), + })?; + let ipv6_input_established_position = commands + .iter() + .position(|argv| { + argv.iter().any(|arg| arg == "ip6tables") + && argv + .windows(4) + .any(|args| args == ["-A", &names.input_chain, "-m", "conntrack"]) + && argv + .windows(2) + .any(|args| args == ["--ctstate", "ESTABLISHED,RELATED"]) + }) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "find IPv6 established INPUT return rule", + message: "rule was not installed".to_owned(), + })?; + assert!(neighbor_solicitation_position < ipv6_input_established_position); + assert!(ipv6_input_established_position < input_drop_position); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn native_controller_maps_ipv4_mapped_answers_as_ipv4() -> Result<()> { + let id = BoxId::new("box-mapped")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let host = PreparedNativeHost::new(&id, &names); + let controller = NativeLinuxDockerEgressController::new(Arc::new(MappedIpv4Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + let prepared = controller + .prepare(&host, namespace, &id, &allowlist) + .await?; + + assert_eq!( + prepared.host_mappings(), + [("service.test".to_owned(), IpAddr::from([203, 0, 113, 11]))] + ); + let commands = host.commands(); + assert!(commands.iter().any(|argv| { + argv.iter().any(|part| part == "iptables") + && argv.iter().any(|part| part == "203.0.113.11") + && argv.iter().any(|part| part == "ACCEPT") + })); + assert!(!commands.iter().any(|argv| { + argv.iter().any(|part| part == "ip6tables") + && argv.iter().any(|part| part.contains("203.0.113.11")) + })); + controller.cleanup(&host, namespace, &id).await?; + Ok(()) +} + +#[tokio::test] +async fn cleanup_refuses_a_different_network_owner_before_firewall_mutation() -> Result<()> { + let id = BoxId::new("box-foreign-owner")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_owner = "another-box".to_owned(); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + + assert!(controller.cleanup(&host, namespace, &id).await.is_err()); + + assert!(!host.commands().iter().any(|argv| { + argv.iter() + .any(|part| part == "iptables" || part == "ip6tables") + && argv + .iter() + .any(|part| part == "-D" || part == "-F" || part == "-X") + })); + Ok(()) +} + +#[tokio::test] +async fn cleanup_refuses_a_network_scope_hash_collision_before_firewall_mutation() -> Result<()> { + let id = BoxId::new("box-scope-collision")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_scope = "different-full-hash".to_owned(); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + + assert!(controller.cleanup(&host, namespace, &id).await.is_err()); + + assert!(!host.commands().iter().any(|argv| { + argv.iter() + .any(|part| part == "iptables" || part == "ip6tables") + && argv + .iter() + .any(|part| part == "-D" || part == "-F" || part == "-X") + })); + Ok(()) +} + +#[tokio::test] +async fn cleanup_refuses_firewall_chains_with_another_owner_marker() -> Result<()> { + let id = BoxId::new("box-chain-collision")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.chain_owner_token = "different-full-hash".to_owned(); + host.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend([ + ("iptables".to_owned(), names.forward_chain.clone()), + ("iptables".to_owned(), names.input_chain.clone()), + ]); + host.chains_with_rules + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend([ + ("iptables".to_owned(), names.forward_chain.clone()), + ("iptables".to_owned(), names.input_chain.clone()), + ]); + host.marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend([ + ("iptables".to_owned(), names.forward_chain.clone()), + ("iptables".to_owned(), names.input_chain.clone()), + ]); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + + assert!(controller.cleanup(&host, namespace, &id).await.is_err()); + + assert!(!host.commands().iter().any(|argv| { + argv.iter() + .any(|part| part == "iptables" || part == "ip6tables") + && argv + .iter() + .any(|part| part == "-D" || part == "-F" || part == "-X") + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn cleanup_restores_owner_marker_when_chain_delete_fails_after_flush() -> Result<()> { + let id = BoxId::new("box-delete-retry")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let host = PreparedNativeHost::new(&id, &names); + *host + .fail_chain_delete_once + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + host.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(("iptables".to_owned(), names.forward_chain.clone())); + host.marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(("iptables".to_owned(), names.forward_chain.clone())); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + + assert!(controller.cleanup(&host, namespace, &id).await.is_err()); + assert!( + host.marked_chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&("iptables".to_owned(), names.forward_chain.clone())) + ); + controller.cleanup(&host, namespace, &id).await?; + assert!( + !host + .chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&("iptables".to_owned(), names.forward_chain.clone())) + ); + Ok(()) +} + +#[test] +fn deny_rules_block_metadata_without_disabling_other_link_local_destinations() { + let ipv4 = deny_rules(false); + let ipv6 = deny_rules(true); + let has_destination = |rules: &[Vec], destination: &str| { + rules + .iter() + .any(|rule| rule.windows(2).any(|pair| pair == ["-d", destination])) + }; + + assert!(has_destination(&ipv4, "169.254.169.254/32")); + assert!(has_destination(&ipv4, "169.254.170.2/32")); + assert!(!has_destination(&ipv4, "169.254.0.0/16")); + assert!(has_destination(&ipv6, "fd00:ec2::254/128")); + assert!(!has_destination(&ipv6, "fe80::/10")); +} + +#[test] +fn firewall_owner_token_uses_unambiguous_sha256_identity() { + let left = scope_token("ab", "c"); + let right = scope_token("a", "bc"); + + assert_eq!(left.len(), 64); + assert_ne!(left, right); + assert_eq!(left, scope_token("ab", "c")); +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn network_name_conflict_does_not_clean_an_existing_owners_firewall() -> Result<()> { + let id = BoxId::new("box-0")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::CommandFailure; + host.network_inspect_behavior = NetworkInspectBehavior::Foreign; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::Backend { + operation: "configure egress filtering", + .. + }) + )); + assert!(host.commands().iter().all(|argv| { + !argv + .iter() + .any(|arg| matches!(arg.as_str(), "-D" | "-F" | "-X")) + })); + assert!( + !host + .commands() + .iter() + .any(|argv| argv.iter().any(|arg| arg == "inspect")) + ); + assert!( + !host + .commands() + .iter() + .any(|argv| { argv.iter().any(|arg| arg == "rm") }) + ); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn network_create_conflict_does_not_remove_an_existing_same_owner_network() -> Result<()> { + let id = BoxId::new("box-stale-owned-network")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.network_create_behavior = NetworkCreateBehavior::CommandFailure; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::Backend { + operation: "configure egress filtering", + ref message, + .. + }) if message.contains("network name already exists") + )); + assert!(!host.commands().iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") + || (argv.iter().any(|arg| arg == "inspect") + && argv.iter().any(|arg| arg.contains("{{.Id}}"))) + })); + assert!(!host.commands().iter().any(|argv| { + argv.iter() + .any(|arg| matches!(arg.as_str(), "-A" | "-D" | "-F" | "-X")) + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn empty_successful_network_create_rolls_back_the_owned_network() -> Result<()> { + let id = BoxId::new("box-0")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.empty_network_create = true; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + assert!(matches!( + controller.prepare(&host, namespace, &id, &allowlist).await, + Err(Error::ConstraintNotEnforced { + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert!(host.commands().iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") && argv.iter().any(|arg| arg == &names.network) + })); + Ok(()) +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn owner_marker_install_failure_rolls_back_created_chain_and_network() -> Result<()> { + let id = BoxId::new("box-marker-failure")?; + let namespace = "test"; + let names = NetworkNames::for_box(namespace, &id); + let mut host = PreparedNativeHost::new(&id, &names); + host.fail_chain_marker = true; + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + let allowlist = tinybox_core::EgressAllowlist::new(["service.test"], [443]); + + let result = controller.prepare(&host, namespace, &id, &allowlist).await; + + assert!(result.is_err()); + let commands = host.commands(); + assert!(commands.iter().any(|argv| { + argv.iter().any(|arg| arg == "rm") && argv.iter().any(|arg| arg == &names.network) + })); + assert!( + host.chains + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + ); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +#[tokio::test] +async fn native_controller_refuses_non_linux_before_host_commands() -> Result<()> { + let id = BoxId::new("box-non-linux")?; + let names = NetworkNames::for_box("test", &id); + let host = PreparedNativeHost::new(&id, &names); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + + assert!(matches!( + controller.validate_host(&host).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert!(host.commands().is_empty()); + Ok(()) +} + +#[tokio::test] +async fn missing_forward_hook_refuses_native_egress_before_resource_creation() { + for firewall in ["iptables", "ip6tables"] { + let host = FakeNativeHost::new(firewall); + let controller = NativeLinuxDockerEgressController::new(Arc::new(Resolver)); + + assert!(matches!( + controller.validate_host(&host).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert!( + host.commands() + .iter() + .all(|argv| !argv.contains(&"create".to_owned())) + ); + } +} diff --git a/crates/tinybox-docker/src/egress_tests.rs b/crates/tinybox-docker/src/egress_tests.rs new file mode 100644 index 00000000..44a00d5c --- /dev/null +++ b/crates/tinybox-docker/src/egress_tests.rs @@ -0,0 +1,279 @@ +//! Tests for resolver-backed egress allowlist planning. + +use super::*; + +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::sync::Mutex; + +use tinybox_core::{EgressAllowlist, Error}; + +#[derive(Debug)] +struct ChangingResolver { + calls: Mutex>, +} + +impl EgressResolver for ChangingResolver { + fn resolve(&self, hostname: &str) -> std::io::Result> { + let mut calls = self + .calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + calls.push(hostname.to_owned()); + if calls.len() == 1 { + Ok(vec!["203.0.113.8".parse().map_err(|error| { + std::io::Error::other(format!("invalid fixture: {error}")) + })?]) + } else { + Ok(vec![IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)]) + } + } +} + +#[derive(Debug)] +struct StaticResolver(BTreeMap>); + +impl EgressResolver for StaticResolver { + fn resolve(&self, hostname: &str) -> std::io::Result> { + self.0.get(hostname).cloned().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("no fixture for {hostname}"), + ) + }) + } +} + +fn addresses( + host: &str, + answers: &[&str], +) -> std::result::Result> { + let addresses = answers + .iter() + .map(|answer| answer.parse::()) + .collect::, _>>()?; + Ok(StaticResolver(BTreeMap::from([( + host.to_owned(), + addresses, + )]))) +} + +#[test] +fn resolves_each_distinct_hostname_once_and_retains_the_answer() -> tinybox_core::Result<()> { + let resolver = ChangingResolver { + calls: Mutex::new(Vec::new()), + }; + let allowlist = EgressAllowlist::new(["API.Example.Test.", "api.example.test."], [443, 8443]); + + let plan = plan_egress_allowlist(&allowlist, &resolver)?; + + assert_eq!( + resolver + .calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_slice(), + ["api.example.test."] + ); + assert_eq!(plan.targets().len(), 2); + assert!(plan.targets().iter().all(|target| { + target.host() == "api.example.test." + && [443, 8443].contains(&target.port()) + && target.address() == IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 8)) + })); + assert_eq!( + plan.targets()[0].socket_addr().ip(), + plan.targets()[0].address() + ); + Ok(()) +} + +#[test] +fn pins_ip_literals_without_resolving_and_rejects_forbidden_literals() -> tinybox_core::Result<()> { + let resolver = StaticResolver(BTreeMap::new()); + let allowlist = EgressAllowlist::new(["203.0.113.9"], [9443]); + + let plan = plan_egress_allowlist(&allowlist, &resolver)?; + + assert_eq!(plan.targets().len(), 1); + assert_eq!(plan.targets()[0].host(), "203.0.113.9"); + assert_eq!(plan.targets()[0].port(), 9443); + assert_eq!( + plan.targets()[0].address(), + IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)) + ); + + let forbidden = EgressAllowlist::new(["127.0.0.1"], [443]); + assert!(matches!( + plan_egress_allowlist(&forbidden, &resolver), + Err(Error::InvalidNetworkPolicy { .. }) + )); + Ok(()) +} + +#[test] +fn rejects_ipv6_link_local_targets_without_scope_ids() { + let resolver = StaticResolver(BTreeMap::new()); + let allowlist = EgressAllowlist::new(["fe80::1"], [443]); + + assert!(matches!( + plan_egress_allowlist(&allowlist, &resolver), + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + + let ipv4_link_local = EgressAllowlist::new(["169.254.1.1"], [443]); + assert!(plan_egress_allowlist(&ipv4_link_local, &resolver).is_ok()); +} + +#[test] +fn returns_resolver_errors_as_backend_errors() { + let resolver = StaticResolver(BTreeMap::new()); + let allowlist = EgressAllowlist::new(["unavailable.example"], [443]); + + assert!(matches!( + plan_egress_allowlist(&allowlist, &resolver), + Err(Error::Backend { .. }) + )); +} + +#[derive(Debug)] +struct AbsoluteAwareResolver { + calls: Mutex>, +} + +impl EgressResolver for AbsoluteAwareResolver { + fn resolve(&self, hostname: &str) -> std::io::Result> { + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(hostname.to_owned()); + let answer = match hostname { + "service." => "203.0.113.17", + "service" => "203.0.113.23", + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("no fixture for {hostname}"), + )); + } + }; + Ok(vec![answer.parse().map_err(|error| { + std::io::Error::other(format!("invalid fixture: {error}")) + })?]) + } +} + +#[test] +fn resolves_absolute_names_with_the_root_dot_and_keeps_them_distinct() -> tinybox_core::Result<()> { + let resolver = AbsoluteAwareResolver { + calls: Mutex::new(Vec::new()), + }; + let allowlist = EgressAllowlist::new(["Service.", "SERVICE.", "service"], [443]); + + let plan = plan_egress_allowlist(&allowlist, &resolver)?; + + assert_eq!( + resolver + .calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_slice(), + ["service.", "service"] + ); + assert_eq!(plan.targets().len(), 2); + assert_eq!(plan.targets()[0].host(), "service"); + assert_eq!( + plan.targets()[0].address(), + IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 23)) + ); + assert_eq!(plan.targets()[1].host(), "service."); + assert_eq!( + plan.targets()[1].address(), + IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 17)) + ); + Ok(()) +} + +#[test] +fn rejects_a_plan_if_any_answer_is_loopback_or_metadata() +-> std::result::Result<(), Box> { + for (hostname, answer) in [ + ("loopback-v4.example", "127.0.0.1"), + ("loopback-v6.example", "::1"), + ("mapped-loopback.example", "::ffff:127.0.0.1"), + ("aws-metadata.example", "169.254.169.254"), + ("alibaba-metadata.example", "100.100.100.200"), + ("mapped-alibaba-metadata.example", "::ffff:100.100.100.200"), + ("ec2-metadata.example", "fd00:ec2::254"), + ] { + let resolver = addresses(hostname, &[answer])?; + let allowlist = EgressAllowlist::new([hostname], [443]); + assert!( + matches!( + plan_egress_allowlist(&allowlist, &resolver), + Err(Error::InvalidNetworkPolicy { .. }) + ), + "{hostname} resolving to {answer} must reject the entire plan" + ); + } + Ok(()) +} + +#[test] +fn rejects_mixed_safe_and_forbidden_answers_instead_of_dropping_one() +-> std::result::Result<(), Box> { + let resolver = addresses("mixed.example", &["203.0.113.8", "127.0.0.1"])?; + let allowlist = EgressAllowlist::new(["mixed.example"], [443]); + + assert!(matches!( + plan_egress_allowlist(&allowlist, &resolver), + Err(Error::InvalidNetworkPolicy { .. }) + )); + Ok(()) +} + +#[test] +fn allows_private_addresses_when_the_policy_does_not_forbid_them() +-> std::result::Result<(), Box> { + let resolver = addresses("private.example", &["10.2.3.4"])?; + let allowlist = EgressAllowlist::new(["private.example"], [443]); + + let plan = plan_egress_allowlist(&allowlist, &resolver)?; + + assert_eq!(plan.targets().len(), 1); + assert_eq!(plan.targets()[0].address(), "10.2.3.4".parse::()?); + Ok(()) +} + +#[test] +fn normalizes_ipv4_mapped_ipv6_answers_to_ipv4_targets() +-> std::result::Result<(), Box> { + let resolver = addresses("mapped.example", &["::ffff:203.0.113.11"])?; + let allowlist = EgressAllowlist::new(["mapped.example"], [443]); + + let plan = plan_egress_allowlist(&allowlist, &resolver)?; + + assert_eq!(plan.targets().len(), 1); + assert_eq!( + plan.targets()[0].address(), + "203.0.113.11".parse::()? + ); + Ok(()) +} + +#[test] +fn rejects_a_hostname_with_no_resolver_answers() +-> std::result::Result<(), Box> { + let resolver = addresses("empty.example", &[])?; + let allowlist = EgressAllowlist::new(["empty.example"], [443]); + + assert!(matches!( + plan_egress_allowlist(&allowlist, &resolver), + Err(Error::InvalidNetworkPolicy { .. }) + )); + Ok(()) +} diff --git a/crates/tinybox-docker/src/lib.rs b/crates/tinybox-docker/src/lib.rs index fbbe1213..ca436575 100644 --- a/crates/tinybox-docker/src/lib.rs +++ b/crates/tinybox-docker/src/lib.rs @@ -33,6 +33,8 @@ //! # } //! ``` +mod egress; +mod egress_enforcement; mod oneshot; mod policy; mod sandbox; @@ -45,6 +47,11 @@ pub use oneshot::{ pub use policy::{DANGEROUS_MOUNTS, HOST_NETWORK, run_policy_issues}; +pub use egress::{EgressResolver, PinnedEgressPlan, PinnedEgressTarget, plan_egress_allowlist}; +pub use egress_enforcement::{ + DockerEgressController, NativeLinuxDockerEgressController, PreparedEgressNetwork, +}; + pub use sandbox::{ DEFAULT_BASE_IMAGE, DEFAULT_NAMESPACE, DockerSandbox, NAME, OWNER_LABEL, WORKSPACE_MOUNT, container_name, diff --git a/crates/tinybox-docker/src/sandbox/args.rs b/crates/tinybox-docker/src/sandbox/args.rs index bdf85cd9..b341723e 100644 --- a/crates/tinybox-docker/src/sandbox/args.rs +++ b/crates/tinybox-docker/src/sandbox/args.rs @@ -11,6 +11,8 @@ use tinybox_core::{ WorkspaceSource, }; +use crate::PreparedEgressNetwork; + use super::NAME; /// The label every tinybox container carries, so a stray container can be @@ -102,6 +104,7 @@ pub(super) fn run( id: &BoxId, attempt: &str, spec: &BoxSpec, + allowlist_network: Option<&PreparedEgressNetwork>, ) -> Result> { let mut argv = vec![ "docker".to_owned(), @@ -119,7 +122,22 @@ pub(super) fn run( ]; argv.extend(resource_flags(&spec.resources)); - argv.extend(network_flags(&spec.network)); + if let Some(network) = allowlist_network { + argv.extend(["--network".to_owned(), network.network().to_owned()]); + // Custom Docker bridges use an embedded resolver that can forward DNS + // outside our per-bridge firewall. Pin its sole upstream to the + // workload's own loopback; approved names arrive through --add-host. + argv.extend(["--dns".to_owned(), "127.0.0.1".to_owned()]); + for (hostname, address) in network.host_mappings() { + let address = match address { + std::net::IpAddr::V4(address) => address.to_string(), + std::net::IpAddr::V6(address) => format!("[{address}]"), + }; + argv.extend(["--add-host".to_owned(), format!("{hostname}:{address}")]); + } + } else { + argv.extend(network_flags(&spec.network)); + } argv.extend(port_flags(spec)); if let WorkspaceSource::LocalDir(path) = &spec.source { diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 2d0ff851..06749caa 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -2,6 +2,8 @@ use std::{ collections::HashMap, + net::SocketAddr, + path::Path, sync::{ Arc, Mutex, MutexGuard, PoisonError, atomic::{AtomicBool, Ordering}, @@ -12,10 +14,13 @@ use std::{ use async_trait::async_trait; use tinybox_core::{ BoxId, BoxInfo, BoxSpec, BoxState, Capability, Clock, Error, ExecOutput, ExecRequest, - ExecutionObserver, Host, IsolationLevel, OutputStream, PortMapping, ProcessId, Result, Sandbox, - SandboxCapabilities, SnapshotId, SnapshotSupport, Store, SystemClock, detach, + ExecutionObserver, Forward, Host, HostExecutionLocation, IsolationLevel, NetworkPolicy, + OutputStream, PortMapping, ProcessId, Result, Sandbox, SandboxCapabilities, SnapshotId, + SnapshotSupport, Store, SystemClock, WorkspaceFileReader, WorkspaceFileWriter, detach, }; +use crate::{DockerEgressController, PreparedEgressNetwork}; + mod args; mod observed; mod state; @@ -46,6 +51,7 @@ pub const NAME: &str = "docker"; pub struct DockerSandbox { host: Arc, store: Arc, + egress_controller: Option>, clock: Arc, namespace: String, failed_create_attempts: Arc>>, @@ -53,6 +59,82 @@ pub struct DockerSandbox { observed_boxes: Arc>>, } +#[derive(Debug)] +struct ControllerHost { + inner: Arc, + controller: Arc, +} + +impl ControllerHost { + fn request(&self, request: &ExecRequest) -> ExecRequest { + let mut request = request.clone(); + if request + .argv + .first() + .is_some_and(|program| program == "docker") + { + request.argv = self.controller.docker_argv(request.argv); + } + request + } +} + +#[async_trait] +impl Host for ControllerHost { + fn name(&self) -> &str { + self.inner.name() + } + + fn execution_location(&self) -> HostExecutionLocation { + self.inner.execution_location() + } + + async fn run(&self, request: &ExecRequest) -> Result { + self.inner.run(&self.request(request)).await + } + + async fn run_observed( + &self, + request: &ExecRequest, + observer: Arc, + ) -> Result { + self.inner + .run_observed(&self.request(request), observer) + .await + } + + fn has_pending_observed_cleanup(&self) -> bool { + self.inner.has_pending_observed_cleanup() + } + + async fn retry_observed_cleanup(&self) -> Result<()> { + self.inner.retry_observed_cleanup().await + } + + async fn open_workspace_file( + &self, + root: &Path, + relative: &Path, + ) -> Result> { + self.inner.open_workspace_file(root, relative).await + } + + async fn begin_workspace_file_write( + &self, + root: &Path, + relative: &Path, + transfer_id: &str, + ) -> Result> { + self.inner + .begin_workspace_file_write(root, relative, transfer_id) + .await + } + + async fn forward(&self, remote: SocketAddr) -> Result { + self.inner.forward(remote).await + } +} + #[derive(Debug, Clone)] struct CleanupHandle { id: BoxId, @@ -142,6 +224,148 @@ impl ExecutionObserver for DockerExecutionObserver { pub const DEFAULT_NAMESPACE: &str = "default"; impl DockerSandbox { + async fn validate_network_policy(&self, spec: &BoxSpec) -> Result<()> { + if let NetworkPolicy::Allowlist(_) = &spec.network { + let Some(controller) = &self.egress_controller else { + return Err(unsupported_egress_policy()); + }; + controller.validate_host(self.host.as_ref()).await?; + } else { + spec.network.refuse_allowlist(self.name())?; + } + Ok(()) + } + + async fn prepare_egress_network( + &self, + spec: &BoxSpec, + id: &BoxId, + attempt: &str, + ) -> Result> { + let NetworkPolicy::Allowlist(allowlist) = &spec.network else { + return Ok(None); + }; + let Some(controller) = &self.egress_controller else { + self.discard_failed_create_record(id, attempt); + return Err(unsupported_egress_policy()); + }; + match controller + .prepare(self.host.as_ref(), &self.namespace, id, allowlist) + .await + { + Ok(network) => Ok(Some(network)), + Err(error) => { + if matches!( + &error, + Error::Backend { + operation: "roll back egress network setup", + .. + } + ) { + self.remember_failed_create_attempt(id, attempt); + } else { + self.discard_failed_create_record(id, attempt); + } + Err(error) + } + } + } + + async fn rollback_failed_create( + &self, + id: &BoxId, + attempt: &str, + has_egress_network: bool, + error: Error, + ) -> Error { + if has_egress_network { + self.rollback_failed_egress_create(id, attempt, error).await + } else { + self.discard_failed_create_record(id, attempt); + error + } + } + + async fn reconcile_failed_create( + &self, + id: &BoxId, + attempt: &str, + has_egress_network: bool, + error: Error, + ) -> Error { + if is_name_conflict(&error) { + return self + .rollback_failed_create(id, attempt, has_egress_network, error) + .await; + } + + match self + .docker( + "inspect the failed create", + args::inspect_attempt(&self.namespace, id), + ) + .await + { + Ok(owner) if owner == attempt => { + if self + .remove_container(args::remove(&self.namespace, id)) + .await + .is_err() + { + self.remember_failed_create_attempt(id, attempt); + return error; + } + if !self + .cleanup_failed_egress_network(id, attempt, has_egress_network) + .await + { + return error; + } + self.discard_failed_create_record(id, attempt); + } + Ok(_) => { + if !self + .cleanup_failed_egress_network(id, attempt, has_egress_network) + .await + { + return error; + } + self.discard_failed_create_record(id, attempt); + } + Err(Error::Backend { message, .. }) if message.contains("No such container") => { + if !self + .cleanup_failed_egress_network(id, attempt, has_egress_network) + .await + { + return error; + } + self.discard_failed_create_record(id, attempt); + } + Err(_) => self.remember_failed_create_attempt(id, attempt), + } + error + } + + async fn cleanup_failed_egress_network( + &self, + id: &BoxId, + attempt: &str, + has_egress_network: bool, + ) -> bool { + if has_egress_network && self.cleanup_egress(id).await.is_err() { + self.remember_failed_create_attempt(id, attempt); + return false; + } + true + } + + fn remember_failed_create_attempt(&self, id: &BoxId, attempt: &str) { + self.failed_create_attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id.clone(), attempt.to_owned()); + } + /// Run containers via `docker` on `host`, recording boxes in `store`. /// /// Uses [`DEFAULT_NAMESPACE`]. Two stores on one machine that both allocate @@ -153,6 +377,7 @@ impl DockerSandbox { Self { host, store, + egress_controller: None, clock: Arc::new(SystemClock::new()), namespace: DEFAULT_NAMESPACE.to_owned(), failed_create_attempts: Arc::default(), @@ -195,6 +420,7 @@ impl DockerSandbox { Ok(Self { host, store, + egress_controller: None, clock: Arc::new(SystemClock::new()), namespace, failed_create_attempts: Arc::default(), @@ -215,9 +441,19 @@ impl DockerSandbox { /// different host. #[must_use] pub fn for_observed_host(&self, host: Arc) -> Self { + let host = self + .egress_controller + .as_ref() + .map_or(host.clone(), |controller| { + Arc::new(ControllerHost { + inner: host, + controller: controller.clone(), + }) as Arc + }); Self { host, store: self.store.clone(), + egress_controller: self.egress_controller.clone(), clock: self.clock.clone(), namespace: self.namespace.clone(), failed_create_attempts: self.failed_create_attempts.clone(), @@ -232,6 +468,26 @@ impl DockerSandbox { &self.namespace } + /// Install a controller for per-box egress allowlists. + /// + /// The default sandbox has no controller and continues to refuse + /// [`NetworkPolicy::Allowlist`]. The controller must verify the execution + /// host and Docker daemon, and install its default-drop filter before the + /// workload starts. + #[must_use] + pub fn with_egress_controller(mut self, controller: Arc) -> Self { + self.host = Arc::new(ControllerHost { + inner: self.host.clone(), + controller: controller.clone(), + }); + self.egress_controller = Some(controller); + self + } + + async fn run_docker(&self, request: &ExecRequest) -> Result { + self.host.run(request).await + } + /// What this sandbox declares. /// /// `PauseResume` is absent even though `docker pause` exists, because the @@ -272,7 +528,7 @@ impl DockerSandbox { operation: &'static str, request: ExecRequest, ) -> Result { - let output = self.host.run(&request).await?; + let output = self.run_docker(&request).await?; if !output.succeeded() { return Err(Error::Backend { sandbox: NAME.to_owned(), @@ -286,7 +542,7 @@ impl DockerSandbox { /// Remove a named container, treating Docker's explicit missing-container /// response as an already-completed cleanup. async fn remove_container(&self, argv: Vec) -> Result<()> { - let output = self.host.run(&ExecRequest::new(argv)).await?; + let output = self.run_docker(&ExecRequest::new(argv)).await?; if output.succeeded() || output.stderr_lossy().contains("No such container") { return Ok(()); } @@ -402,7 +658,7 @@ impl Sandbox for DockerSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; - spec.network.refuse_allowlist(self.name())?; + self.validate_network_policy(spec).await?; // The workspace is bind-mounted read-write, so it gets the one-shot // mount policy before any record or container exists. // ponytail: through an SSH host the daemon's filesystem is remote, so @@ -422,65 +678,30 @@ impl Sandbox for DockerSandbox { let info = tinybox_core::insert_new(self.store.as_ref(), BoxState::Ready, spec, self.clock.now())?; + // Resolve and install the default-drop filter before Docker starts the + // image entrypoint. The controller is the only path that can make an + // allowlist runnable. + let network = self + .prepare_egress_network(spec, &info.id, &attempt) + .await?; + // Build the command before running anything, so a source this backend // cannot handle leaves no container behind. - let argv = match args::run(&self.namespace, &info.id, &attempt, spec) { + let argv = match args::run(&self.namespace, &info.id, &attempt, spec, network.as_ref()) { Ok(argv) => argv, Err(error) => { - self.discard_failed_create_record(&info.id, &attempt); - return Err(error); + return Err(self + .rollback_failed_create(&info.id, &attempt, network.is_some(), error) + .await); } }; let mut create = ExecRequest::new(argv); create.env.clone_from(&spec.env); if let Err(error) = self.docker_request("create the container", create).await { - // Docker reports a name collision before creating a container. - // Never remove that name: it belongs to the pre-existing owner. - if is_name_conflict(&error) { - self.discard_failed_create_record(&info.id, &attempt); - return Err(error); - } - - // A lost response can hide a successful create. Reconcile only - // after the daemon confirms this exact attempt token; otherwise a - // deterministic name may refer to another store's container with - // the same box id. - match self - .docker( - "inspect the failed create", - args::inspect_attempt(&self.namespace, &info.id), - ) - .await - { - Ok(owner) if owner == attempt => { - if self - .remove_container(args::remove(&self.namespace, &info.id)) - .await - .is_ok() - { - self.discard_failed_create_record(&info.id, &attempt); - } else { - self.failed_create_attempts - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(info.id.clone(), attempt); - } - } - Ok(_) => { - self.discard_failed_create_record(&info.id, &attempt); - } - Err(Error::Backend { message, .. }) if message.contains("No such container") => { - self.discard_failed_create_record(&info.id, &attempt); - } - Err(_) => { - self.failed_create_attempts - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(info.id.clone(), attempt); - } - } - return Err(error); + return Err(self + .reconcile_failed_create(&info.id, &attempt, network.is_some(), error) + .await); } self.failed_create_attempts .lock() @@ -730,7 +951,11 @@ impl Sandbox for DockerSandbox { async fn destroy(&self, id: &BoxId) -> Result<()> { // Fail on an unknown box before touching Docker. - self.store.get(id)?; + let info = self.store.get(id)?; + let has_egress_network = matches!(info.spec.network, NetworkPolicy::Allowlist(_)); + if has_egress_network && self.egress_controller.is_none() { + return Err(unsupported_egress_policy()); + } let attempt = self .failed_create_attempts .lock() @@ -761,6 +986,9 @@ impl Sandbox for DockerSandbox { // recoverable, a container without a record is a leak. self.remove_container(args::remove(&self.namespace, id)) .await?; + if has_egress_network { + self.cleanup_egress(id).await?; + } self.mark_observed_box_destroyed(id); self.acknowledge_observed_cleanup_for_destroyed_box(id); self.store.remove(id)?; @@ -772,11 +1000,55 @@ impl Sandbox for DockerSandbox { } } +impl DockerSandbox { + async fn rollback_failed_egress_create( + &self, + id: &BoxId, + attempt: &str, + error: Error, + ) -> Error { + match self.cleanup_egress(id).await { + Ok(()) => { + self.discard_failed_create_record(id, attempt); + error + } + Err(cleanup_error) => { + self.failed_create_attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id.clone(), attempt.to_owned()); + Error::Backend { + sandbox: NAME.to_owned(), + operation: "roll back egress network setup", + message: format!("{error}; cleanup failed: {cleanup_error}"), + } + } + } + } + + async fn cleanup_egress(&self, id: &BoxId) -> Result<()> { + if let Some(controller) = &self.egress_controller { + controller + .cleanup(self.host.as_ref(), &self.namespace, id) + .await?; + } + Ok(()) + } +} + fn is_name_conflict(error: &Error) -> bool { matches!(error, Error::Backend { message, .. } if message.contains("container name") && message.contains("already in use")) } +fn unsupported_egress_policy() -> Error { + Error::ConstraintNotEnforced { + sandbox: NAME.to_owned(), + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + } +} + fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index b0fcab67..57999121 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -16,13 +16,488 @@ use std::sync::{ use async_trait::async_trait; use tinybox_core::{ - BoxId, BoxSpec, BoxState, Capability, Error, ExecOutput, ExecRequest, Host, HostRef, - IsolationLevel, MemoryStore, NetworkPolicy, Placement, PortMapping, Resources, Result, Sandbox, - SandboxRef, SnapshotId, SnapshotSupport, Store, WorkspaceSource, + BoxId, BoxSpec, BoxState, Capability, Error, ExecOutput, ExecRequest, Host, + HostExecutionLocation, HostRef, IsolationLevel, MemoryStore, NetworkPolicy, Placement, + PortMapping, Resources, Result, Sandbox, SandboxRef, SnapshotId, SnapshotSupport, Store, + WorkspaceSource, }; use super::{DockerSandbox, NAME, args, cleanup_timeout_error, state}; +#[derive(Debug)] +struct RecordingEgressController; + +#[async_trait] +impl super::DockerEgressController for RecordingEgressController { + async fn prepare( + &self, + host: &dyn Host, + _namespace: &str, + _id: &BoxId, + _allowlist: &tinybox_core::EgressAllowlist, + ) -> Result { + host.run(&ExecRequest::new(["controller", "prepare"])) + .await?; + Ok(crate::PreparedEgressNetwork::new( + "tinybox-allowlist-test".to_owned(), + vec![( + "api.example.test".to_owned(), + std::net::IpAddr::from([203, 0, 113, 10]), + )], + )) + } + + async fn cleanup(&self, host: &dyn Host, _namespace: &str, _id: &BoxId) -> Result<()> { + host.run(&ExecRequest::new(["controller", "cleanup"])) + .await?; + Ok(()) + } + + fn docker_argv(&self, argv: Vec) -> Vec { + argv + } +} + +#[derive(Debug)] +struct NonIdempotentArgController; + +#[async_trait] +impl super::DockerEgressController for NonIdempotentArgController { + async fn prepare( + &self, + _host: &dyn Host, + _namespace: &str, + _id: &BoxId, + _allowlist: &tinybox_core::EgressAllowlist, + ) -> Result { + Ok(crate::PreparedEgressNetwork::new( + "tinybox-allowlist-test".to_owned(), + Vec::new(), + )) + } + + async fn cleanup(&self, _host: &dyn Host, _namespace: &str, _id: &BoxId) -> Result<()> { + Ok(()) + } + + fn docker_argv(&self, mut argv: Vec) -> Vec { + argv.push("rewrite-once".to_owned()); + argv + } +} + +#[tokio::test] +async fn docker_argv_controller_rewrites_each_command_once() -> Result<()> { + let host = ScriptedHost::silent(); + let sandbox = DockerSandbox::new(host.clone(), Arc::new(MemoryStore::new())) + .with_egress_controller(Arc::new(NonIdempotentArgController)); + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + + sandbox + .create(&spec()?.with_network(NetworkPolicy::Allowlist(allowlist))) + .await?; + + let docker_commands = host + .commands() + .into_iter() + .filter(|argv| argv.first().is_some_and(|arg| arg == "docker")) + .collect::>(); + assert_ne!(docker_commands.len(), 0); + assert!(docker_commands.iter().all(|argv| { + argv.iter() + .filter(|arg| arg.as_str() == "rewrite-once") + .count() + == 1 + })); + Ok(()) +} + +#[tokio::test] +async fn allowlist_controller_prepares_before_start_and_cleans_after_destroy() -> Result<()> { + let host = ScriptedHost::silent(); + let sandbox = DockerSandbox::new(host.clone(), Arc::new(MemoryStore::new())) + .with_egress_controller(Arc::new(RecordingEgressController)); + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + let info = sandbox + .create(&spec()?.with_network(NetworkPolicy::Allowlist(allowlist))) + .await?; + + let commands = host.commands(); + assert_eq!(commands[0], ["controller", "prepare"]); + assert_eq!(commands[1][0], "docker"); + assert!(commands[1].contains(&"--network".to_owned())); + assert!(commands[1].contains(&"tinybox-allowlist-test".to_owned())); + assert_eq!(flag_value(&commands[1], "--dns"), Some("127.0.0.1")); + assert!( + commands[1] + .windows(2) + .any(|args| { args[0] == "--add-host" && args[1] == "api.example.test:203.0.113.10" }) + ); + + sandbox.destroy(&info.id).await?; + let commands = host.commands(); + assert_eq!(commands[2][0], "docker"); + assert_eq!(commands[3], ["controller", "cleanup"]); + Ok(()) +} + +#[tokio::test] +async fn destroy_skips_egress_cleanup_for_non_allowlist_boxes() -> Result<()> { + let host = ScriptedHost::silent(); + let sandbox = DockerSandbox::new(host.clone(), Arc::new(MemoryStore::new())) + .with_egress_controller(Arc::new(RecordingEgressController)); + let info = sandbox.create(&spec()?).await?; + + sandbox.destroy(&info.id).await?; + + assert!( + !host + .commands() + .iter() + .any(|command| command.first().is_some_and(|word| word == "controller")) + ); + Ok(()) +} + +#[tokio::test] +async fn destroying_persisted_allowlist_without_controller_refuses_before_removal() -> Result<()> { + let host = ScriptedHost::silent(); + let store = Arc::new(MemoryStore::new()); + let sandbox = DockerSandbox::new(host.clone(), store.clone()) + .with_egress_controller(Arc::new(RecordingEgressController)); + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + let info = sandbox + .create(&spec()?.with_network(NetworkPolicy::Allowlist(allowlist))) + .await?; + let commands_before_destroy = host.commands(); + let reconstructed = DockerSandbox::new(host.clone(), store.clone()); + + assert!(matches!( + reconstructed.destroy(&info.id).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert_eq!(host.commands(), commands_before_destroy); + assert_eq!(store.list()?.len(), 1); + Ok(()) +} + +#[tokio::test] +async fn controller_host_forwards_host_capabilities_and_pins_docker_commands() -> Result<()> { + let inner = ScriptedHost::silent(); + let host = super::ControllerHost { + inner: inner.clone(), + controller: Arc::new(RecordingEgressController), + }; + + assert_eq!(host.name(), "scripted"); + assert_eq!(host.execution_location(), HostExecutionLocation::Unknown); + assert!(!host.has_pending_observed_cleanup()); + host.retry_observed_cleanup().await?; + assert!(matches!( + host.open_workspace_file( + std::path::Path::new("/workspace"), + std::path::Path::new("file") + ) + .await, + Err(Error::UnsupportedHostFileTransfer { .. }) + )); + assert!(matches!( + host.begin_workspace_file_write( + std::path::Path::new("/workspace"), + std::path::Path::new("file"), + "transfer", + ) + .await, + Err(Error::UnsupportedHostFileTransfer { .. }) + )); + assert!(matches!( + host.forward(std::net::SocketAddr::from(([127, 0, 0, 1], 1234))) + .await, + Err(Error::Unsupported { .. }) + )); + + let observer: Arc = Arc::new(CapturingObserver::default()); + host.run_observed(&ExecRequest::new(["docker", "version"]), observer) + .await?; + assert_eq!(inner.commands()[0], ["docker", "version"]); + Ok(()) +} + +#[tokio::test] +async fn missing_controller_during_egress_preparation_rolls_back_reserved_record() -> Result<()> { + let (sandbox, _host, store) = sandbox(); + let id = BoxId::new("box-0")?; + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + let spec = spec()?.with_network(NetworkPolicy::Allowlist(allowlist)); + assert!(matches!( + sandbox.prepare_egress_network(&spec, &id, "attempt").await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert!(matches!(store.get(&id), Err(Error::UnknownBox { .. }))); + Ok(()) +} + +#[tokio::test] +async fn controller_failure_rolls_back_before_dropping_the_box_record() -> Result<()> { + #[derive(Debug)] + struct FailingController; + + #[async_trait] + impl super::DockerEgressController for FailingController { + async fn prepare( + &self, + host: &dyn Host, + _namespace: &str, + _id: &BoxId, + _allowlist: &tinybox_core::EgressAllowlist, + ) -> Result { + host.run(&ExecRequest::new(["controller", "prepare"])) + .await?; + host.run(&ExecRequest::new(["controller", "cleanup"])) + .await?; + Err(Error::ConstraintNotEnforced { + sandbox: NAME.to_owned(), + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + }) + } + + async fn cleanup(&self, host: &dyn Host, _namespace: &str, _id: &BoxId) -> Result<()> { + host.run(&ExecRequest::new(["controller", "cleanup"])) + .await?; + Ok(()) + } + + fn docker_argv(&self, argv: Vec) -> Vec { + argv + } + } + + let host = ScriptedHost::silent(); + let store = Arc::new(MemoryStore::new()); + let sandbox = DockerSandbox::new(host.clone(), store.clone()) + .with_egress_controller(Arc::new(FailingController)); + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + + assert!(matches!( + sandbox + .create(&spec()?.with_network(NetworkPolicy::Allowlist(allowlist))) + .await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert_eq!(store.list()?.len(), 0); + assert_eq!( + host.commands(), + [["controller", "prepare"], ["controller", "cleanup"]] + ); + Ok(()) +} + +#[test] +fn allowlist_run_args_pin_every_resolved_address_and_disable_upstream_dns() -> Result<()> { + let id = BoxId::new("box-0")?; + let prepared = crate::PreparedEgressNetwork::new( + "tinybox-egress-test".to_owned(), + vec![ + ( + "service.test".to_owned(), + std::net::IpAddr::from([203, 0, 113, 8]), + ), + ( + "service.test".to_owned(), + std::net::IpAddr::V6(std::net::Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 8)), + ), + ], + ); + + let argv = args::run("test", &id, "attempt", &spec()?, Some(&prepared))?; + assert_eq!(flag_value(&argv, "--dns"), Some("127.0.0.1")); + assert!( + argv.windows(2) + .any(|args| { args[0] == "--add-host" && args[1] == "service.test:203.0.113.8" }) + ); + assert!( + argv.windows(2) + .any(|args| { args[0] == "--add-host" && args[1] == "service.test:[2001:db8::8]" }) + ); + Ok(()) +} + +#[tokio::test] +async fn native_controller_refuses_hosts_without_explicit_local_identity() -> Result<()> { + #[derive(Debug)] + struct Resolver; + impl crate::EgressResolver for Resolver { + fn resolve(&self, _hostname: &str) -> std::io::Result> { + Ok(vec![std::net::IpAddr::V4(std::net::Ipv4Addr::new( + 203, 0, 113, 7, + ))]) + } + } + + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + for location in [ + HostExecutionLocation::Unknown, + HostExecutionLocation::Remote, + ] { + let host = Arc::new(ScriptedHost { + location: Some(location), + ..ScriptedHost::default() + }); + let sandbox = DockerSandbox::new(host.clone(), Arc::new(MemoryStore::new())) + .with_egress_controller(Arc::new(crate::NativeLinuxDockerEgressController::new( + Arc::new(Resolver), + ))); + + assert!(matches!( + sandbox + .create(&spec()?.with_network(NetworkPolicy::Allowlist(allowlist.clone()))) + .await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Network, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert_eq!(host.commands(), Vec::>::new()); + } + Ok(()) +} + +#[tokio::test] +async fn failed_egress_rollback_retains_box_record_for_cleanup_retry() -> Result<()> { + #[derive(Debug)] + struct CleanupFailureController; + + #[async_trait] + impl super::DockerEgressController for CleanupFailureController { + async fn prepare( + &self, + _host: &dyn Host, + _namespace: &str, + _id: &BoxId, + _allowlist: &tinybox_core::EgressAllowlist, + ) -> Result { + // The controller owns its partial setup rollback. If that rollback + // fails, it reports the distinct operation so the sandbox retains + // the box record for a later destroy retry without issuing a + // second, potentially unsafe cleanup. + Err(Error::Backend { + sandbox: NAME.to_owned(), + operation: "roll back egress network setup", + message: "cleanup failed: filter unavailable".to_owned(), + }) + } + + async fn cleanup(&self, _host: &dyn Host, _namespace: &str, _id: &BoxId) -> Result<()> { + Err(Error::Backend { + sandbox: NAME.to_owned(), + operation: "remove test egress filter", + message: "filter unavailable".to_owned(), + }) + } + + fn docker_argv(&self, argv: Vec) -> Vec { + argv + } + } + + let host = ScriptedHost::silent(); + let store = Arc::new(MemoryStore::new()); + let sandbox = DockerSandbox::new(host, store.clone()) + .with_egress_controller(Arc::new(CleanupFailureController)); + let allowlist = tinybox_core::EgressAllowlist::new(["api.example.test"], [443]); + + assert!(matches!( + sandbox + .create(&spec()?.with_network(NetworkPolicy::Allowlist(allowlist))) + .await, + Err(Error::Backend { operation: "roll back egress network setup", message, .. }) if message.contains("cleanup failed") + )); + assert_eq!(store.list()?.len(), 1); + Ok(()) +} + +#[tokio::test] +async fn failed_create_cleanup_retains_the_record_for_retry() -> Result<()> { + #[derive(Debug)] + struct CleanupFailureController; + + #[async_trait] + impl super::DockerEgressController for CleanupFailureController { + async fn prepare( + &self, + _host: &dyn Host, + _namespace: &str, + _id: &BoxId, + _allowlist: &tinybox_core::EgressAllowlist, + ) -> Result { + unreachable!("this test enters the rollback path directly") + } + + async fn cleanup(&self, _host: &dyn Host, _namespace: &str, _id: &BoxId) -> Result<()> { + Err(Error::Backend { + sandbox: NAME.to_owned(), + operation: "remove test egress filter", + message: "filter unavailable".to_owned(), + }) + } + + fn docker_argv(&self, argv: Vec) -> Vec { + argv + } + } + + let host = ScriptedHost::silent(); + let store = Arc::new(MemoryStore::new()); + let sandbox = DockerSandbox::new(host, store.clone()) + .with_egress_controller(Arc::new(CleanupFailureController)); + let info = tinybox_core::insert_new( + store.as_ref(), + BoxState::Ready, + &spec()?, + std::time::SystemTime::now(), + )?; + let original = Error::Backend { + sandbox: NAME.to_owned(), + operation: "start test container", + message: "start failed".to_owned(), + }; + + let error = sandbox + .rollback_failed_create(&info.id, "attempt", true, original) + .await; + + assert!(matches!( + error, + Error::Backend { operation: "roll back egress network setup", ref message, .. } + if message.contains("start failed") && message.contains("filter unavailable") + )); + assert_eq!(store.list()?.len(), 1); + assert_eq!( + sandbox + .failed_create_attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&info.id) + .map(String::as_str), + Some("attempt") + ); + Ok(()) +} + #[test] fn effective_published_ports_parse_dynamic_and_fixed_bindings() -> Result<()> { let ports = args::parse_published_ports( @@ -57,6 +532,7 @@ struct ScriptedHost { replies: Mutex>, seen: Mutex>>, seen_env: Mutex>>, + location: Option, cancel_observed: AtomicBool, refuse_observed: AtomicBool, block_observed: AtomicBool, @@ -124,6 +600,10 @@ impl Host for ScriptedHost { "scripted" } + fn execution_location(&self) -> HostExecutionLocation { + self.location.unwrap_or(HostExecutionLocation::Unknown) + } + async fn run(&self, request: &ExecRequest) -> Result { self.seen().push(request.argv.clone()); self.seen_env diff --git a/crates/tinybox-docker/tests/live_docker.rs b/crates/tinybox-docker/tests/live_docker.rs index d2b204dd..723e2da7 100644 --- a/crates/tinybox-docker/tests/live_docker.rs +++ b/crates/tinybox-docker/tests/live_docker.rs @@ -21,7 +21,7 @@ use std::{ io::{Read, Write}, - net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, + net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}, process::Command, sync::Arc, thread, @@ -29,10 +29,10 @@ use std::{ }; use tinybox_core::{ - BoxSpec, Error, ExecRequest, Host as _, HostRef, MemoryStore, NetworkPolicy, Placement, Result, - Sandbox, SandboxRef, WorkspaceSource, + BoxSpec, Error, ExecRequest, Host as _, HostRef, MemoryStore, NetworkPolicy, Placement, + PortMapping, Result, Sandbox, SandboxRef, WorkspaceSource, }; -use tinybox_docker::DockerSandbox; +use tinybox_docker::{DockerSandbox, EgressResolver, NativeLinuxDockerEgressController}; use tinybox_host::LocalHost; /// The image every live test uses. Small, and it ships a shell. @@ -112,6 +112,75 @@ fn accept_before(listener: &TcpListener, deadline: Instant) -> std::io::Result Result> { + loop { + match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + Ok(mut stream) => { + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .map_err(|error| Error::io("set published-port read timeout", &error))?; + stream + .write_all(b"published-port probe\n") + .map_err(|error| Error::io("request published-port response", &error))?; + let mut response = Vec::new(); + let mut chunk = [0; 1024]; + loop { + let read = match stream.read(&mut chunk) { + Ok(read) => read, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) && Instant::now() < deadline => + { + continue; + } + Err(error) + if error.kind() == std::io::ErrorKind::ConnectionReset + && Instant::now() < deadline => + { + break; + } + Err(error) => { + return Err(Error::io("read published-port response", &error)); + } + }; + if read == 0 { + return Ok(response); + } + response.extend_from_slice(&chunk[..read]); + if response + .windows(b"published-ok\n".len()) + .any(|window| window == b"published-ok\n") + { + return Ok(response); + } + } + thread::sleep(Duration::from_millis(20)); + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::WouldBlock + ) => + { + if Instant::now() >= deadline { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "connect to published container port", + message: error.to_string(), + }); + } + thread::sleep(Duration::from_millis(20)); + } + Err(error) => return Err(Error::io("connect to published container port", &error)), + } + } +} + /// Build the bounded HTTP request the container uses to probe the listener. fn http_probe_command(address: SocketAddr) -> String { format!("wget -q -T 3 -O - http://{address}/probe") @@ -373,6 +442,351 @@ async fn live_network_policy_controls_host_listener_connectivity() -> Result<()> no_denied_connection, "default-Denied container established a connection to the controlled listener" ); + live_allowlist_pins_destinations_and_blocks_other_raw_tcp().await?; + Ok(()) +} + +#[derive(Debug)] +struct FixedResolver(Vec); + +impl EgressResolver for FixedResolver { + fn resolve(&self, _hostname: &str) -> std::io::Result> { + Ok(self.0.clone()) + } +} + +fn docker_command(arguments: &[&str]) -> Result { + let output = Command::new("docker") + .args(arguments) + .output() + .map_err(|error| Error::Backend { + sandbox: "docker".to_owned(), + operation: "run the controlled egress listener", + message: error.to_string(), + })?; + if !output.status.success() { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "run the controlled egress listener", + message: String::from_utf8_lossy(&output.stderr).trim().to_owned(), + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +fn ipv6_address_inspect_template(network: &str) -> String { + format!("{{{{(index .NetworkSettings.Networks \"{network}\").GlobalIPv6Address}}}}") +} + +fn allowlist_probe_ports() -> tinybox_core::EgressAllowlist { + tinybox_core::EgressAllowlist::new(["listener.test"], [18373, 18376]) +} + +const TARGET_LISTENER_SCRIPT: &str = "(while true; do nc -l -p 18373 >/dev/null; echo hit >> /tmp/allowed; done) & (while true; do nc -l -p 18374 >/dev/null; echo hit >> /tmp/blocked; done) & (while true; do nc -l -p 18376 >/dev/null; echo hit >> /tmp/allowed-v6; done) & (while true; do nc -u -l -p 53 >/dev/null; echo hit >> /tmp/dns; done) & wait"; +fn ipv6_listener_probe(address: Ipv6Addr) -> String { + format!( + "unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy; nc -z -w 3 {address} 18376" + ) +} + +#[test] +fn live_allowlist_covers_both_listener_probe_ports() { + let allowlist = allowlist_probe_ports(); + assert!(allowlist.ports.contains(&18373)); + assert!(allowlist.ports.contains(&18376)); +} + +#[test] +fn alpine_busybox_nc_uses_literal_ipv6_without_unsupported_flag() { + let address = Ipv6Addr::new(0xfd42, 0, 0, 0, 0, 0, 0, 0x1234); + let probe = ipv6_listener_probe(address); + assert!(TARGET_LISTENER_SCRIPT.contains("nc -l -p 18376")); + assert!(!TARGET_LISTENER_SCRIPT.contains("nc -6")); + assert!(probe.contains("nc -z -w 3 fd42::1234 18376")); + assert!(!probe.contains("listener.test")); + assert!(!probe.contains("nc -6")); +} + +#[test] +fn ipv6_address_inspect_template_quotes_the_network_name() { + assert_eq!( + ipv6_address_inspect_template("controlled-v6"), + r#"{{(index .NetworkSettings.Networks "controlled-v6").GlobalIPv6Address}}"# + ); +} + +/// A disposable Linux Engine proof for the native firewall controller. +/// +/// Both listeners run in a separate, test-owned bridge container, so this +/// exercises raw TCP from the sandbox without opening a path to the Docker +/// host or relying on a public service. +// Keep setup, all three probes, assertions, and cleanup together so cleanup +// runs on every assertion path in the live integration test. +#[allow(clippy::too_many_lines)] +async fn live_allowlist_pins_destinations_and_blocks_other_raw_tcp() -> Result<()> { + const ALLOWED_PORT: &str = "18373"; + const BLOCKED_PORT: &str = "18374"; + + if !live_docker_enabled()? { + return Ok(()); + } + + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let target_name = format!("tinybox-egress-target-{}", &suffix[..12]); + let target_network = format!("tinybox-egress-v6-{}", &suffix[..12]); + let target_subnet = format!("fd42:{}:5678::/64", &suffix[..4]); + let gateway = docker_bridge_gateway()?; + let host_listener = TcpListener::bind((gateway, 0)).map_err(|error| Error::Backend { + sandbox: "docker".to_owned(), + operation: "bind the host-gateway negative-control listener", + message: error.to_string(), + })?; + host_listener + .set_nonblocking(true) + .map_err(|error| Error::io("configure host-gateway listener", &error))?; + let host_address = host_listener + .local_addr() + .map_err(|error| Error::io("read host-gateway listener address", &error))?; + let image = docker_command(&[ + "run", + "-d", + "--name", + &target_name, + IMAGE, + "sh", + "-c", + TARGET_LISTENER_SCRIPT, + ]); + let _ = image?; + if let Err(error) = docker_command(&[ + "network", + "create", + "--driver", + "bridge", + "--ipv6", + "--subnet", + &target_subnet, + &target_network, + ]) { + let _ = Command::new("docker") + .args(["rm", "-f", &target_name]) + .status(); + return Err(error); + } + let result = async { + docker_command(&["network", "connect", &target_network, &target_name])?; + let ipv4 = docker_command(&[ + "inspect", + "--format", + "{{(index .NetworkSettings.Networks \"bridge\").IPAddress}}", + &target_name, + ])? + .parse::() + .map_err(|error| Error::Backend { + sandbox: "docker".to_owned(), + operation: "read the controlled listener address", + message: error.to_string(), + })?; + let ipv6 = docker_command(&[ + "inspect", + "--format", + &ipv6_address_inspect_template(&target_network), + &target_name, + ])? + .parse::() + .map_err(|error| Error::Backend { + sandbox: "docker".to_owned(), + operation: "read the controlled IPv6 listener address", + message: error.to_string(), + })?; + + let resolver: Arc = + Arc::new(FixedResolver(vec![ipv4, std::net::IpAddr::V6(ipv6)])); + let namespace = format!("ns-egress-{}", &suffix[..8]); + let sandbox = sandbox(&namespace)? + .with_egress_controller(Arc::new(NativeLinuxDockerEgressController::new(resolver))); + let allowlist = allowlist_probe_ports(); + let info = sandbox + .create( + &spec()? + .with_network(NetworkPolicy::Allowlist(allowlist)) + .with_port(PortMapping::dynamic(18375)), + ) + .await?; + + let probes = async { + let container = format!("tinybox-{namespace}-{}", info.id); + docker_command(&[ + "exec", + "-d", + &container, + "sh", + "-c", + "while true; do printf 'published-ok\\n' | nc -l -p 18375; done", + ])?; + let published_port = sandbox + .published_ports(&info.id) + .await? + .into_iter() + .find(|mapping| mapping.guest == 18375) + .and_then(|mapping| mapping.host) + .ok_or_else(|| Error::Backend { + sandbox: "docker".to_owned(), + operation: "inspect published container port", + message: "Docker did not assign a host port".to_owned(), + })?; + let published_response = exchange_before( + SocketAddr::from((Ipv4Addr::LOCALHOST, published_port)), + Instant::now() + Duration::from_secs(5), + )?; + + let allowed = sandbox + .exec( + &info.id, + &ExecRequest::new([ + "sh", + "-c", + &format!("unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy; nc -z -w 3 listener.test {ALLOWED_PORT}"), + ]), + ) + .await; + let allowed_v6 = sandbox + .exec( + &info.id, + &ExecRequest::new([ + "sh", + "-c", + &ipv6_listener_probe(ipv6), + ]), + ) + .await; + let blocked = sandbox + .exec( + &info.id, + &ExecRequest::new([ + "sh", + "-c", + &format!("unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy; nc -z -w 2 {ipv4} {BLOCKED_PORT}"), + ]), + ) + .await; + let host_gateway = sandbox + .exec( + &info.id, + &ExecRequest::new([ + "sh", + "-c", + &format!("unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy; nc -z -w 2 {} {}", host_address.ip(), host_address.port()), + ]), + ) + .await; + let dns_blocked = sandbox + .exec( + &info.id, + &ExecRequest::new([ + "sh", + "-c", + "unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy; nslookup -timeout=1 -retry=1 unlisted-name.test >/dev/null 2>&1", + ]), + ) + .await; + let direct_dns = sandbox + .exec( + &info.id, + &ExecRequest::new([ + "sh", + "-c", + &format!("printf probe | nc -u -w 1 {ipv4} 53 || true"), + ]), + ) + .await; + let host_listener_untouched = matches!( + accept_before(&host_listener, Instant::now() + Duration::from_millis(250)), + Err(error) if error.kind() == std::io::ErrorKind::TimedOut + ); + let allowed_hits = + docker_command(&["exec", &target_name, "sh", "-c", "test -s /tmp/allowed"]); + let allowed_v6_hits = docker_command(&[ + "exec", + &target_name, + "sh", + "-c", + "test -s /tmp/allowed-v6", + ]); + let blocked_hits = Command::new("docker") + .args(["exec", &target_name, "sh", "-c", "test ! -s /tmp/blocked"]) + .status() + .map_err(|error| Error::io("check forbidden listener", &error)); + let dns_hits = Command::new("docker") + .args(["exec", &target_name, "sh", "-c", "test ! -s /tmp/dns"]) + .status() + .map_err(|error| Error::io("check forbidden DNS listener", &error)); + + let allowed = allowed?; + let allowed_v6 = allowed_v6?; + let blocked = blocked?; + let host_gateway = host_gateway?; + let dns_blocked = dns_blocked?; + direct_dns?; + allowed_hits?; + allowed_v6_hits?; + let blocked_hits = blocked_hits?; + let dns_hits = dns_hits?; + if allowed.exit_code != 0 + || allowed_v6.exit_code != 0 + || blocked.exit_code == 0 + || host_gateway.exit_code == 0 + || dns_blocked.exit_code == 0 + || !published_response + .windows(b"published-ok\n".len()) + .any(|window| window == b"published-ok\n") + || !blocked_hits.success() + || !dns_hits.success() + || !host_listener_untouched + { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "verify native egress allowlist", + message: format!( + "allowed v4 exit={}, allowed v6 exit={}, blocked exit={}, host-gateway exit={}, unlisted DNS lookup exit={}, published response={published_response:?}, forbidden TCP hit={}, forbidden DNS hit={}, host listener touched={}", + allowed.exit_code, + allowed_v6.exit_code, + blocked.exit_code, + host_gateway.exit_code, + dns_blocked.exit_code, + !blocked_hits.success(), + !dns_hits.success(), + !host_listener_untouched + ), + }); + } + Ok(()) + } + .await; + let destroy = sandbox.destroy(&info.id).await; + probes?; + destroy?; + Ok(()) + } + .await; + let cleanup = Command::new("docker") + .args(["rm", "-f", &target_name]) + .status() + .map_err(|error| Error::io("remove controlled listener", &error)); + let network_cleanup = Command::new("docker") + .args(["network", "rm", &target_network]) + .status() + .map_err(|error| Error::io("remove controlled IPv6 listener network", &error)); + result?; + let cleanup = cleanup?; + let network_cleanup = network_cleanup?; + if !cleanup.success() || !network_cleanup.success() { + return Err(Error::Backend { + sandbox: "docker".to_owned(), + operation: "remove controlled listeners", + message: "docker rm or docker network rm returned a failure status".to_owned(), + }); + } Ok(()) } diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index a3a081a9..146f1860 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -27,9 +27,13 @@ fn async_pipe(file: std::fs::File) -> Result { use nix::fcntl::fcntl; set_nonblocking_with(&file, |descriptor, argument| fcntl(descriptor, argument))?; - tokio::io::unix::AsyncFd::new(file) - .map(AsyncPipe) - .map_err(|error| Error::io("register native pipe", &error)) + // The deprecated constructor is retained because this workspace forbids + // unsafe code. `file` is an owned `std::fs::File` moved directly into + // AsyncFd, so its descriptor stays valid until AsyncFd is dropped. + #[allow(deprecated)] + let async_fd = tokio::io::unix::AsyncFd::new(file) + .map_err(|error| Error::io("register native pipe", &error))?; + Ok(AsyncPipe(async_fd)) } #[cfg(not(unix))] { @@ -437,6 +441,9 @@ impl Host for JailLocalHost { fn name(&self) -> &'static str { crate::LOCAL } + fn execution_location(&self) -> tinybox_core::HostExecutionLocation { + tinybox_core::HostExecutionLocation::Local + } async fn run(&self, request: &ExecRequest) -> Result { self.collect(request, None).await } diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 33e2a663..4e48655c 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -21,6 +21,22 @@ impl tinybox_jail::JailBackend for FixtureBackend { } } +#[test] +fn jailed_local_host_reports_local_execution_location() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let host = JailLocalHost::with_backend( + tinybox_jail::Jail::new(root.path(), "execution-location"), + Arc::new(FixtureBackend), + 64, + )?; + + assert_eq!( + host.execution_location(), + tinybox_core::HostExecutionLocation::Local + ); + Ok(()) +} + #[cfg(unix)] struct MissingStdinBackend; #[cfg(unix)] diff --git a/crates/tinybox-host/src/limited/mod.rs b/crates/tinybox-host/src/limited/mod.rs index edf22abf..971b6949 100644 --- a/crates/tinybox-host/src/limited/mod.rs +++ b/crates/tinybox-host/src/limited/mod.rs @@ -8,8 +8,8 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, }; use tinybox_core::{ - Error, ExecOutput, ExecRequest, ExecutionObserver, Forward, Host, OutputStream, Result, - WorkspaceFileReader, WorkspaceFileWriter, + Error, ExecOutput, ExecRequest, ExecutionObserver, Forward, Host, HostExecutionLocation, + OutputStream, Result, WorkspaceFileReader, WorkspaceFileWriter, }; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::sync::{oneshot, watch}; @@ -297,6 +297,10 @@ impl Host for LimitedLocalHost { crate::LOCAL } + fn execution_location(&self) -> HostExecutionLocation { + LocalHost::new().execution_location() + } + async fn run(&self, request: &ExecRequest) -> Result { self.collect(request, None).await } diff --git a/crates/tinybox-host/src/limited/mod_tests.rs b/crates/tinybox-host/src/limited/mod_tests.rs index 80de6eb1..ca3ed25b 100644 --- a/crates/tinybox-host/src/limited/mod_tests.rs +++ b/crates/tinybox-host/src/limited/mod_tests.rs @@ -1,6 +1,14 @@ //! Bounded collection, simultaneous draining, and supervisor cleanup. use super::*; +#[test] +fn limited_local_host_reports_local_execution_location() { + assert_eq!( + LimitedLocalHost::new(16).execution_location(), + tinybox_core::HostExecutionLocation::Local + ); +} + #[cfg(target_os = "linux")] #[tokio::test] async fn overflow_terminates_inherited_pipe_descendants_before_drain_returns() -> Result<()> { diff --git a/crates/tinybox-host/src/local/mod.rs b/crates/tinybox-host/src/local/mod.rs index b8b80231..5fbc5214 100644 --- a/crates/tinybox-host/src/local/mod.rs +++ b/crates/tinybox-host/src/local/mod.rs @@ -5,7 +5,8 @@ pub(crate) mod shell; use async_trait::async_trait; use std::path::Path; use tinybox_core::{ - Error, ExecOutput, ExecRequest, Forward, Host, Result, WorkspaceFileReader, WorkspaceFileWriter, + Error, ExecOutput, ExecRequest, Forward, Host, HostExecutionLocation, Result, + WorkspaceFileReader, WorkspaceFileWriter, }; use tokio::io::AsyncWriteExt as _; use tokio::process::Command; @@ -71,6 +72,10 @@ impl Host for LocalHost { NAME } + fn execution_location(&self) -> HostExecutionLocation { + HostExecutionLocation::Local + } + /// Run a command to completion and collect its output. /// /// Uses `tokio`'s `output()`, which reads both pipes concurrently. Reading diff --git a/crates/tinybox-host/src/local/mod_tests.rs b/crates/tinybox-host/src/local/mod_tests.rs index fce97621..682ac8a7 100644 --- a/crates/tinybox-host/src/local/mod_tests.rs +++ b/crates/tinybox-host/src/local/mod_tests.rs @@ -6,7 +6,7 @@ use std::path::Path; -use tinybox_core::{Error, ExecRequest, Host, Result}; +use tinybox_core::{Error, ExecRequest, Host, HostExecutionLocation, Result}; use super::{LocalHost, NAME}; @@ -16,6 +16,14 @@ fn it_reports_its_name() { assert_eq!(LocalHost.name(), NAME); } +#[test] +fn it_reports_local_execution_location() { + assert_eq!( + LocalHost::new().execution_location(), + HostExecutionLocation::Local + ); +} + #[tokio::test] async fn it_runs_a_command_and_captures_stdout() -> Result<()> { let output = LocalHost::new() diff --git a/crates/tinybox-ssh/src/host/mod.rs b/crates/tinybox-ssh/src/host/mod.rs index ecd0fb6e..1d021d64 100644 --- a/crates/tinybox-ssh/src/host/mod.rs +++ b/crates/tinybox-ssh/src/host/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; -use tinybox_core::{Error, ExecOutput, ExecRequest, Forward, Host, Result}; +use tinybox_core::{Error, ExecOutput, ExecRequest, Forward, Host, HostExecutionLocation, Result}; mod forward; mod observed; @@ -187,6 +187,10 @@ impl Host for SshHost { NAME } + fn execution_location(&self) -> HostExecutionLocation { + HostExecutionLocation::Remote + } + /// Run a command on the far machine and collect its output. /// /// # Errors diff --git a/crates/tinybox-ssh/src/host/mod_tests.rs b/crates/tinybox-ssh/src/host/mod_tests.rs index d4bdd39e..b18bc2a0 100644 --- a/crates/tinybox-ssh/src/host/mod_tests.rs +++ b/crates/tinybox-ssh/src/host/mod_tests.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use async_trait::async_trait; -use tinybox_core::{Error, ExecOutput, ExecRequest, Host, Result}; +use tinybox_core::{Error, ExecOutput, ExecRequest, Host, HostExecutionLocation, Result}; use super::{NAME, SshHost, SshTarget}; @@ -55,11 +55,13 @@ fn last_argv(inner: &RecordingHost) -> Vec { #[test] fn it_reports_its_name() -> Result<()> { - let (host, _inner) = host()?; + let (host, inner) = host()?; assert_eq!(host.name(), "ssh"); assert_eq!(host.name(), NAME); assert_eq!(host.target().destination(), "builder@example.invalid"); + assert_eq!(host.execution_location(), HostExecutionLocation::Remote); + assert_eq!(inner.execution_location(), HostExecutionLocation::Unknown); Ok(()) } diff --git a/docs/specs/contract-1-19.md b/docs/specs/contract-1-19.md index e969e765..3702be2a 100644 --- a/docs/specs/contract-1-19.md +++ b/docs/specs/contract-1-19.md @@ -16,13 +16,17 @@ least 19, which `tinybox_bus::is_compatible` already enforces. ## Allowlist semantics -- Names are resolved outside the box, once, and pinned. The box gets those - addresses (or a proxy that connects only to them) and no resolver of its own, - so a later DNS answer cannot widen the list. +- Names are resolved before filter installation, once, and the resulting + addresses are pinned into the firewall rules. A later DNS answer cannot widen + the set of destinations the box can reach. - Cloud metadata endpoints (`169.254.169.254`, `169.254.170.2`, `100.100.100.200`, `fd00:ec2::254`, `metadata.google.internal`) are refused when the spec is validated, and a backend must also block them when a name - resolves to one. + resolves to one. DNS names are matched case-insensitively with an optional + terminal root dot, so `Metadata.Google.Internal.` is refused as well. +- Legacy numeric IPv4 spellings that represent a listed metadata address are + refused too, including `0251.0376.0251.0376` and `0xa9fea9fe` for + `169.254.169.254`. - Explicit loopback entries are refused during validation: IPv4/IPv6 loopback literals (including IPv4-mapped IPv6 and scoped forms such as `::1%1`), `localhost` and `.localhost` names, and legacy numeric IPv4 spellings @@ -40,10 +44,29 @@ least 19, which `tinybox_bus::is_compatible` already enforces. ## Status per backend -No shipped backend enforces `Allowlist` yet: `passthrough`, `docker`, -`namespace` and `microvm` all refuse it. Enforcement (netns + nftables or an -egress proxy, Seatbelt or proxy, AppContainer or WFP) is tracked under -tinybox#27 phase 3. +`docker` can enforce `Allowlist` only when explicitly configured with +`NativeLinuxDockerEgressController` and a local, rootful Linux Docker Engine +whose firewall chains can be verified. It resolves names before creating the +per-box bridge, installs default-drop IPv4 and IPv6 filters before `docker run`, +and permits only pinned destination IP/port pairs. It writes the pinned +hostname/address pairs into the container's `/etc/hosts` and configures its +only DNS upstream as container-local `127.0.0.1`, so Docker's embedded resolver +cannot forward arbitrary queries to host DNS. Unlisted names therefore fail to +resolve unless the workload provides a resolver in its own loopback namespace. +Docker documents `--dns` as a per-container resolver setting, and its [embedded +DNS security advisory](https://github.com/moby/moby/security/advisories/GHSA-mq39-4gv4-mvpx) +explains that a custom upstream forces external lookups to originate from the +container network namespace. +New traffic from that bridge to the Docker host is dropped, except IPv6 +Neighbor Solicitation for bridge-gateway address resolution. Existing +`ESTABLISHED` or `RELATED` traffic is allowed so host-initiated connections, +including published-port requests, can receive replies. The ordinary Docker +sandbox continues to refuse allowlists unless this controller is installed. + +Docker Desktop, Windows, remote or unknown Hosts, rootless daemons, and daemons +without the required firewall support refuse `Allowlist` as Unsupported. +`passthrough`, `namespace` and `microvm` continue to refuse it. The workload +cannot fall back to host DNS. ## Deprecated diff --git a/docs/specs/tinybox-runtime.md b/docs/specs/tinybox-runtime.md index 6e22aa80..d4a61c97 100644 --- a/docs/specs/tinybox-runtime.md +++ b/docs/specs/tinybox-runtime.md @@ -211,7 +211,7 @@ composition in M4. ADR 0004 records the tradeoff. | `Snapshot` | The committed image. | | `GitRepo` | Refused by name — no clone step exists yet. | | Limits | `--memory`, `--cpus`, `--pids-limit`. Disk is **not** applied: `--storage-opt size=` works on a minority of storage drivers, and a flag that silently does nothing on most systems is worse than an honest omission. | -| Network | `Denied` → `--network none`. Docker has no outbound-only mode, so `Egress` uses the default bridge; nothing is published inbound because tinybox never passes `--publish`. | +| Network | `Denied` → `--network none`. Docker has no outbound-only mode, so `Egress` uses the default bridge. `Allowlist` requires an explicitly installed `NativeLinuxDockerEgressController` on a verified local rootful Linux Engine; it installs default-drop IPv4/IPv6 rules before the workload starts and allows only pinned IP/port pairs. Approved names map to pinned addresses in `/etc/hosts`; the only DNS upstream is container-local `127.0.0.1`, so unlisted names cannot fall back to host DNS. IPv6 neighbor solicitation and established published-port replies are the only exceptions to bridge-to-host drop rules. Desktop, remote, rootless, Windows, and unverified daemon configurations refuse it. | | State | Read from `docker inspect`, not from the store. A container can be stopped or removed by anything with daemon access, and reporting a stale `ready` would send commands to a box that is gone. Docker's `running` maps to `Ready`, because a tinybox box is `Running` only while a command executes and the keepalive loop is not a command. | `Sandbox::exec_observed` runs `docker exec -i` through the same `Host`, so diff --git a/rustc-ice-2026-10-23T18_31_29-185076.txt b/rustc-ice-2026-10-23T18_31_29-185076.txt new file mode 100644 index 00000000..8d5d1ace --- /dev/null +++ b/rustc-ice-2026-10-23T18_31_29-185076.txt @@ -0,0 +1,606 @@ +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: could not resolve trait item being implemented +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: no resolution for an import +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace +delayed bug: path with `Res::Err` but no error emitted +disabled backtrace + + +rustc version: 1.99.1 (58cf8f9c3 2026-10-13) +platform: x86_64-unknown-linux-gnu \ No newline at end of file