mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-12 11:33:51 +08:00
* feat(supervisor): recover shared instances with hash-based HA Signed-off-by: Drew Newberry <anewberry@nvidia.com> * refactor(supervisor): keep HA coordination out of compute drivers Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): fence pool admission and reconciliation races Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(supervisor): keep pool mode stable across replica scaling Signed-off-by: Drew Newberry <anewberry@nvidia.com> * refactor(supervisor): default shared deployments to pool mode Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): fence refused launches and remove legacy mode Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): confirm live owners and fence stale observations Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): preserve takeover cause log formatting Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(helm): add memory-based supervisor autoscaling Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): retain acknowledgements and fence streamed input Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(supervisor): consolidate experimental multi-tenant guidance Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(supervisor): simplify multi-tenant supervisor guide Signed-off-by: Drew Newberry <anewberry@nvidia.com> * refactor(supervisor): simplify pool member identity Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(helm): make supervisor affinity configurable Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(helm): make supervisor disruption budget optional Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(supervisor): align HA fixtures with suspend support Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1966 lines
70 KiB
Rust
1966 lines
70 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Versioned control protocol shared by every remote isolation boundary.
|
|
//!
|
|
//! Drivers choose and provision the transport, but they do not redefine the
|
|
//! process lifecycle, streaming, identity, or authentication messages. The
|
|
//! supervisor and sandbox exchange typed protobuf messages over gRPC. The
|
|
//! length-delimited helpers below adapt only private, in-process Rust streams.
|
|
//! The driver chooses the underlying private Unix socket, TCP connection, or virtio-vsock stream.
|
|
|
|
use std::fmt;
|
|
use std::io;
|
|
use std::io::{Read, Write};
|
|
use std::path::PathBuf;
|
|
use std::str::FromStr;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use openshell_core::SandboxSessionId;
|
|
use openshell_core::policy::{
|
|
FilesystemPolicy, LandlockCompatibility, LandlockPolicy, NetworkMode, NetworkPolicy,
|
|
ProcessPolicy, ProxyPolicy, SandboxPolicy,
|
|
};
|
|
use openshell_isolation_interface::AgentSpec;
|
|
use openshell_isolation_interface::contract::Sha256Digest;
|
|
use openshell_isolation_interface::contract::{
|
|
BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus,
|
|
BoundarySignal, ExecSpec, ExecutableIdentity, ProvisioningEvidence, ResolveError, ShellSpec,
|
|
};
|
|
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyUsagePurpose};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
|
|
pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024;
|
|
/// Exec admission and recovery share one deadline; it never limits process runtime.
|
|
pub const EXEC_REQUEST_RETRY_WINDOW: Duration = Duration::from_secs(30);
|
|
pub const STREAM_STDIN: u8 = 0;
|
|
pub const STREAM_STDOUT: u8 = 1;
|
|
pub const STREAM_STDERR: u8 = 2;
|
|
pub const STREAM_EXIT: u8 = 3;
|
|
pub const STREAM_STDIN_CLOSED: u8 = 4;
|
|
/// Supervisor decision for a staged seccomp-mediated TCP open.
|
|
pub const STREAM_NETWORK_DECISION: u8 = 5;
|
|
pub const MAX_STREAM_FRAME_BYTES: usize = 64 * 1024;
|
|
|
|
// Every relay, exec, and control exchange shares one HTTP/2 connection. The
|
|
// connection window must exceed what all streams can hold unread, otherwise
|
|
// stalled relays starve DNS and control traffic of connection-level credit.
|
|
// h2 keeps at least two thirds of `connection - in-flight` advertised, so the
|
|
// reserve stays usable even with every stream stalled at its window.
|
|
pub const BOUNDARY_MAX_CONCURRENT_STREAMS: u32 = 128;
|
|
pub const BOUNDARY_STREAM_WINDOW_BYTES: u32 = 256 * 1024;
|
|
pub const BOUNDARY_CONNECTION_WINDOW_RESERVE_BYTES: u32 = 16 * 1024 * 1024;
|
|
pub const BOUNDARY_CONNECTION_WINDOW_BYTES: u32 = BOUNDARY_MAX_CONCURRENT_STREAMS
|
|
* BOUNDARY_STREAM_WINDOW_BYTES
|
|
+ BOUNDARY_CONNECTION_WINDOW_RESERVE_BYTES;
|
|
// HTTP/2 caps any flow-control window at 2^31 - 1.
|
|
const _: () = assert!(BOUNDARY_CONNECTION_WINDOW_BYTES <= i32::MAX as u32);
|
|
|
|
/// Capability masks measured from `/proc/<pid>/status` by the `OpenShell`
|
|
/// co-located runtime.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CapabilityEvidence {
|
|
pub inheritable: u64,
|
|
pub permitted: u64,
|
|
pub effective: u64,
|
|
pub bounding: u64,
|
|
pub ambient: u64,
|
|
}
|
|
|
|
impl CapabilityEvidence {
|
|
#[must_use]
|
|
pub const fn is_empty(self) -> bool {
|
|
self.inheritable == 0
|
|
&& self.permitted == 0
|
|
&& self.effective == 0
|
|
&& self.bounding == 0
|
|
&& self.ambient == 0
|
|
}
|
|
}
|
|
|
|
/// Active seccomp notification and socket-broker measurements specific to the
|
|
/// `OpenShell` co-located runtime.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[allow(
|
|
clippy::struct_excessive_bools,
|
|
reason = "each independently measured kernel operation is reported explicitly"
|
|
)]
|
|
pub struct SeccompEvidence {
|
|
pub new_listener: bool,
|
|
pub notification_round_trip: bool,
|
|
pub id_validation: bool,
|
|
pub addfd_send: bool,
|
|
pub retained_socket_operation: bool,
|
|
pub proc_fd_identity: bool,
|
|
pub task_memory_read: bool,
|
|
}
|
|
|
|
/// Mechanism-specific audit evidence for the native Linux sandbox adapter.
|
|
///
|
|
/// This schema belongs to this backend rather than the generic isolation
|
|
/// interface. The host-side backend validates it before constructing a
|
|
/// backend-neutral `ConfirmedBoundary`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[allow(
|
|
clippy::struct_excessive_bools,
|
|
reason = "audit evidence preserves independently measured security results"
|
|
)]
|
|
pub struct NativeLinuxSandboxAuditEvidence {
|
|
pub capabilities: CapabilityEvidence,
|
|
pub no_new_privileges: bool,
|
|
pub sandbox_dumpable: bool,
|
|
pub child_dumpable: bool,
|
|
pub core_limit_zero: bool,
|
|
pub native_architecture: String,
|
|
pub kernel_release: String,
|
|
pub seccomp: SeccompEvidence,
|
|
pub landlock_abi: u32,
|
|
pub landlock_allow_deny: bool,
|
|
pub udp_dns_round_trip: bool,
|
|
pub tcp_dns_round_trip: bool,
|
|
pub tcp_allow_round_trip: bool,
|
|
pub tcp_deny_round_trip: bool,
|
|
/// Workload INET sockets are bound to loopback before injection, the
|
|
/// binding cannot be changed from sandbox credentials, and accepted
|
|
/// sockets inherit it. Native local `accept` depends on this property.
|
|
pub socket_loopback_confinement: bool,
|
|
}
|
|
|
|
impl NativeLinuxSandboxAuditEvidence {
|
|
/// Validate the complete mechanism-specific posture required by this backend.
|
|
pub fn validate(&self) -> Result<(), BackendError> {
|
|
let complete = self.capabilities.is_empty()
|
|
&& self.no_new_privileges
|
|
&& !self.sandbox_dumpable
|
|
&& self.child_dumpable
|
|
&& self.core_limit_zero
|
|
&& !self.native_architecture.is_empty()
|
|
&& !self.kernel_release.is_empty()
|
|
&& self.seccomp.new_listener
|
|
&& self.seccomp.notification_round_trip
|
|
&& self.seccomp.id_validation
|
|
&& self.seccomp.addfd_send
|
|
&& self.seccomp.retained_socket_operation
|
|
&& self.seccomp.proc_fd_identity
|
|
&& self.seccomp.task_memory_read
|
|
&& self.landlock_abi >= 3
|
|
&& self.landlock_allow_deny
|
|
&& self.udp_dns_round_trip
|
|
&& self.tcp_dns_round_trip
|
|
&& self.tcp_allow_round_trip
|
|
&& self.tcp_deny_round_trip
|
|
&& self.socket_loopback_confinement;
|
|
if complete {
|
|
Ok(())
|
|
} else {
|
|
Err(BackendError::Confirm(
|
|
"native Linux sandbox audit evidence is incomplete".to_string(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ephemeral identity of the supervisor process that owns one sandbox runtime.
|
|
///
|
|
/// The supervisor generates this value in memory and presents it on every
|
|
/// attach, including transport reconnects. The sandbox pins the first value it
|
|
/// accepts, so a replacement supervisor cannot reuse launch credentials to
|
|
/// take over an existing runtime generation. Only a gateway-signed takeover
|
|
/// grant for a higher credential epoch, naming the replacement's identity,
|
|
/// moves the pin (see [`crate::sandbox_auth`]).
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct SupervisorInstanceId(uuid::Uuid);
|
|
|
|
impl SupervisorInstanceId {
|
|
/// Generate a fresh supervisor-process identity.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self(uuid::Uuid::new_v4())
|
|
}
|
|
}
|
|
|
|
impl Default for SupervisorInstanceId {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for SupervisorInstanceId {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
self.0.hyphenated().fmt(formatter)
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for SupervisorInstanceId {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("SupervisorInstanceId([REDACTED])")
|
|
}
|
|
}
|
|
|
|
impl FromStr for SupervisorInstanceId {
|
|
type Err = SupervisorInstanceIdError;
|
|
|
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
let parsed = uuid::Uuid::parse_str(value).map_err(|_| SupervisorInstanceIdError)?;
|
|
if parsed.is_nil() || parsed.hyphenated().to_string() != value {
|
|
return Err(SupervisorInstanceIdError);
|
|
}
|
|
Ok(Self(parsed))
|
|
}
|
|
}
|
|
|
|
impl Serialize for SupervisorInstanceId {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.collect_str(self)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for SupervisorInstanceId {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let value = String::deserialize(deserializer)?;
|
|
value.parse().map_err(|_| {
|
|
serde::de::Error::invalid_value(
|
|
serde::de::Unexpected::Str(&value),
|
|
&"a non-nil canonical lowercase UUID",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A supervisor instance ID was not a canonical non-nil UUID.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
|
#[error("supervisor instance ID must be a non-nil canonical lowercase UUID")]
|
|
pub struct SupervisorInstanceIdError;
|
|
|
|
/// Backend-owned transport for a driver-provisioned runtime. The serialized
|
|
/// variants preserve existing runtime descriptors independently of operator
|
|
/// endpoint registration in core.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
|
|
pub enum SandboxTransport {
|
|
Unix {
|
|
socket_path: PathBuf,
|
|
},
|
|
Tcp {
|
|
/// Logical HTTP authority used for routing.
|
|
authority: String,
|
|
/// Concrete connection candidates pinned by the operator or driver.
|
|
addresses: Vec<std::net::SocketAddr>,
|
|
},
|
|
Vsock {
|
|
guest_cid: u32,
|
|
port: u32,
|
|
},
|
|
/// The supervisor accepts a connection from its sandbox runtime. TLS and
|
|
/// gRPC roles stay unchanged.
|
|
RuntimeInitiatedTcp {
|
|
authority: String,
|
|
listen_address: std::net::SocketAddr,
|
|
},
|
|
RuntimeInitiatedUnix {
|
|
socket_path: PathBuf,
|
|
},
|
|
RuntimeInitiatedVsock {
|
|
guest_cid: u32,
|
|
port: u32,
|
|
},
|
|
/// The runtime dials a supervisor shared by several sandbox instances and
|
|
/// names this instance's opaque route handle before the pinned TLS
|
|
/// handshake.
|
|
/// Only a host that owns the shared listener can serve this transport.
|
|
RuntimeInitiatedRouted {
|
|
authority: String,
|
|
route: String,
|
|
},
|
|
}
|
|
|
|
impl SandboxTransport {
|
|
pub fn validate(&self) -> Result<(), &'static str> {
|
|
let valid = match self {
|
|
Self::Unix { socket_path } | Self::RuntimeInitiatedUnix { socket_path } => {
|
|
openshell_core::paths::is_posix_absolute(socket_path)
|
|
}
|
|
Self::Tcp {
|
|
authority,
|
|
addresses,
|
|
} => {
|
|
!authority.is_empty()
|
|
&& !addresses.is_empty()
|
|
&& addresses
|
|
.iter()
|
|
.all(|address| address.port() != 0 && !address.ip().is_unspecified())
|
|
}
|
|
Self::RuntimeInitiatedTcp {
|
|
authority,
|
|
listen_address,
|
|
} => !authority.is_empty() && listen_address.port() != 0,
|
|
Self::Vsock { guest_cid, port } | Self::RuntimeInitiatedVsock { guest_cid, port } => {
|
|
*guest_cid >= 3 && *guest_cid != u32::MAX && *port != 0 && *port != u32::MAX
|
|
}
|
|
Self::RuntimeInitiatedRouted { authority, route } => {
|
|
!authority.is_empty() && crate::runtime_routed::validate_route_handle(route).is_ok()
|
|
}
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err("invalid boundary control transport endpoint")
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<openshell_core::isolation_registration::SandboxTransport> for SandboxTransport {
|
|
fn from(endpoint: openshell_core::isolation_registration::SandboxTransport) -> Self {
|
|
use openshell_core::isolation_registration::SandboxTransport as RegisteredEndpoint;
|
|
match endpoint {
|
|
RegisteredEndpoint::Unix { socket_path } => Self::Unix { socket_path },
|
|
RegisteredEndpoint::Tcp {
|
|
authority,
|
|
addresses,
|
|
} => Self::Tcp {
|
|
authority,
|
|
addresses,
|
|
},
|
|
RegisteredEndpoint::Vsock { guest_cid, port } => Self::Vsock { guest_cid, port },
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Supervisor-side, generation-pinned TLS server authentication.
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SandboxTlsClientConfig {
|
|
pub server_name: String,
|
|
pub trust_anchor_pem: String,
|
|
}
|
|
|
|
impl fmt::Debug for SandboxTlsClientConfig {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("SandboxTlsClientConfig")
|
|
.field("server_name", &self.server_name)
|
|
.field("trust_anchor_pem", &"<redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Sandbox-side TLS server files staged by the compute driver.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SandboxTlsServerConfig {
|
|
pub certificate_chain_path: PathBuf,
|
|
pub private_key_path: PathBuf,
|
|
}
|
|
|
|
/// Fresh, server-only TLS material for one sandbox session.
|
|
#[derive(Clone)]
|
|
pub struct SandboxTlsMaterial {
|
|
pub server_name: String,
|
|
pub trust_anchor_pem: String,
|
|
pub certificate_chain_pem: String,
|
|
pub private_key_pem: String,
|
|
}
|
|
|
|
impl fmt::Debug for SandboxTlsMaterial {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("SandboxTlsMaterial")
|
|
.field("server_name", &self.server_name)
|
|
.field("trust_anchor_pem", &"<redacted>")
|
|
.field("certificate_chain_pem", &"<redacted>")
|
|
.field("private_key_pem", &"<redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Generate a generation-pinned TLS server identity for the Sandbox Protocol.
|
|
///
|
|
/// The CA private key is local to this function and is discarded after the
|
|
/// server leaf is signed. The JWT, rather than certificate time, determines
|
|
/// caller authorization and renewal.
|
|
pub fn generate_sandbox_tls_material(
|
|
session_id: SandboxSessionId,
|
|
) -> Result<SandboxTlsMaterial, BackendError> {
|
|
let server_name = format!("sandbox.{session_id}.openshell.internal");
|
|
let ca_key = openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519)
|
|
.map_err(|error| BackendError::Descriptor(format!("generate sandbox CA key: {error}")))?;
|
|
let mut ca_params = CertificateParams::default();
|
|
ca_params.not_before = rcgen::date_time_ymd(1975, 1, 1);
|
|
ca_params.not_after = rcgen::date_time_ymd(4096, 1, 1);
|
|
ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
|
ca_params
|
|
.distinguished_name
|
|
.push(DnType::CommonName, "OpenShell sandbox session CA");
|
|
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
|
let ca = openshell_crypto::pki::self_signed(ca_params, &ca_key).map_err(|error| {
|
|
BackendError::Descriptor(format!("generate sandbox CA certificate: {error}"))
|
|
})?;
|
|
|
|
let sandbox_key =
|
|
openshell_crypto::pki::generate_keypair_for(&rcgen::PKCS_ED25519).map_err(|error| {
|
|
BackendError::Descriptor(format!("generate sandbox TLS server key: {error}"))
|
|
})?;
|
|
let mut sandbox_params = CertificateParams::new(vec![server_name.clone()])
|
|
.map_err(|error| BackendError::Descriptor(format!("build sandbox certificate: {error}")))?;
|
|
sandbox_params.not_before = rcgen::date_time_ymd(1975, 1, 1);
|
|
sandbox_params.not_after = rcgen::date_time_ymd(4096, 1, 1);
|
|
sandbox_params
|
|
.distinguished_name
|
|
.push(DnType::CommonName, "OpenShell sandbox runtime");
|
|
sandbox_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
|
|
let sandbox = openshell_crypto::pki::signed_by(sandbox_params, &sandbox_key, &ca, &ca_key)
|
|
.map_err(|error| {
|
|
BackendError::Descriptor(format!("sign sandbox TLS server certificate: {error}"))
|
|
})?;
|
|
|
|
Ok(SandboxTlsMaterial {
|
|
server_name,
|
|
trust_anchor_pem: ca.pem(),
|
|
certificate_chain_pem: sandbox.pem(),
|
|
private_key_pem: sandbox_key
|
|
.serialize_pem()
|
|
.map_err(|error| BackendError::Descriptor(format!("export sandbox key: {error}")))?,
|
|
})
|
|
}
|
|
|
|
/// Boundary-side listener provisioned by a compute driver.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
|
|
pub enum BoundaryListener {
|
|
/// TLS over a private Unix socket shared with the host supervisor.
|
|
Unix {
|
|
socket_path: PathBuf,
|
|
tls: SandboxTlsServerConfig,
|
|
},
|
|
/// TLS over TCP. An unspecified IP is valid for the sandbox bind.
|
|
TlsTcp {
|
|
address: std::net::SocketAddr,
|
|
tls: SandboxTlsServerConfig,
|
|
},
|
|
/// TLS over guest `AF_VSOCK`.
|
|
Vsock {
|
|
control_port: u32,
|
|
tls: SandboxTlsServerConfig,
|
|
},
|
|
/// The boundary dials the supervisor's listener and serves TLS and gRPC
|
|
/// over that outbound connection. Only driver-resolved addresses are used.
|
|
RuntimeInitiatedTlsTcp {
|
|
/// Stable logical Service authority, for diagnostics.
|
|
authority: String,
|
|
/// Driver-resolved supervisor endpoints, tried in order.
|
|
addresses: Vec<std::net::SocketAddr>,
|
|
tls: SandboxTlsServerConfig,
|
|
/// Route handle sent before TLS to a supervisor shared by several
|
|
/// sandbox instances. Absent for a dedicated supervisor.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
route: Option<String>,
|
|
},
|
|
/// Dial the supervisor over the driver's protected shared Unix socket.
|
|
RuntimeInitiatedUnix {
|
|
socket_path: PathBuf,
|
|
tls: SandboxTlsServerConfig,
|
|
},
|
|
/// Dial the host supervisor through the VM's vsock device.
|
|
RuntimeInitiatedVsock {
|
|
host_port: u32,
|
|
tls: SandboxTlsServerConfig,
|
|
},
|
|
}
|
|
|
|
/// Public verification key staged in the sandbox's immutable auth bundle.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct GatewayVerificationKey {
|
|
pub key_id: String,
|
|
pub public_key_pem: String,
|
|
}
|
|
|
|
/// Protected runtime descriptor consumed by `openshell-supervisor`.
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SandboxRuntimeDescriptor {
|
|
/// Stable identity of the boundary, normally the sandbox ID.
|
|
pub boundary_id: String,
|
|
/// Immutable driver-owned workload generation.
|
|
pub generation: String,
|
|
/// Fresh gateway-issued identity for this exact launch.
|
|
pub session_id: SandboxSessionId,
|
|
/// Immutable numeric identity already applied to the sandbox workload.
|
|
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
|
|
/// Driver-provisioned byte-stream endpoint.
|
|
pub transport: SandboxTransport,
|
|
/// Per-generation pinned TLS server identity.
|
|
pub tls: SandboxTlsClientConfig,
|
|
/// Trusted dial target for well-known host-gateway aliases, when the
|
|
/// network supervisor cannot use the boundary's resolver view.
|
|
#[serde(default)]
|
|
pub host_gateway_ip: Option<std::net::IpAddr>,
|
|
/// Driver-specific immutable resource coordinates bound at attach (for
|
|
/// example pod UID, VM generation, or container ID).
|
|
#[serde(default)]
|
|
pub resource_claims: std::collections::BTreeMap<String, String>,
|
|
/// Backend-neutral projection of the validated outer network fence.
|
|
pub provisioning_evidence: ProvisioningEvidence,
|
|
}
|
|
|
|
impl fmt::Debug for SandboxRuntimeDescriptor {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("SandboxRuntimeDescriptor")
|
|
.field("boundary_id", &self.boundary_id)
|
|
.field("generation", &self.generation)
|
|
.field("session_id", &self.session_id)
|
|
.field("transport", &self.transport)
|
|
.field("tls", &self.tls)
|
|
.field("host_gateway_ip", &self.host_gateway_ip)
|
|
.field("resource_claims", &self.resource_claims)
|
|
.field("provisioning_evidence", &self.provisioning_evidence)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SandboxRuntimeDescriptor {
|
|
/// Encode this runtime configuration for the `openshell-sandbox` backend.
|
|
pub fn backend_descriptor(&self) -> Result<BackendDescriptor, BackendError> {
|
|
let payload = serde_json::to_vec(self).map_err(|error| {
|
|
BackendError::Descriptor(format!("encode runtime descriptor: {error}"))
|
|
})?;
|
|
Ok(BackendDescriptor {
|
|
backend_name: crate::BACKEND_NAME.to_string(),
|
|
payload,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Protected bootstrap configuration consumed by `openshell-sandbox`.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BoundaryConfig {
|
|
/// Stable identity expected in every authenticated request.
|
|
pub boundary_id: String,
|
|
/// Immutable driver-owned workload generation.
|
|
pub generation: String,
|
|
/// Fresh gateway-issued identity for this exact launch.
|
|
pub session_id: SandboxSessionId,
|
|
/// Monotonic signed supervisor replacement within this generation.
|
|
pub session_rotation: openshell_core::jwt::SessionRotation,
|
|
/// Durable authorization epoch required in every Sandbox Protocol JWT.
|
|
pub auth_epoch: openshell_core::jwt::CredentialEpoch,
|
|
/// Gateway identity expected in Sandbox Protocol JWTs.
|
|
pub gateway_id: String,
|
|
/// Immutable current and staged-next gateway verification keys.
|
|
pub verification_keys: Vec<GatewayVerificationKey>,
|
|
/// Driver-provisioned listener.
|
|
pub listener: BoundaryListener,
|
|
/// Immutable coordinates the boundary requires from the control-side
|
|
/// runtime descriptor before accepting attachment.
|
|
#[serde(default)]
|
|
pub resource_claims: std::collections::BTreeMap<String, String>,
|
|
/// Driver-provisioned, read-only runtime evidence for resource claims.
|
|
///
|
|
/// Each entry maps a claim key to an absolute file whose trimmed contents
|
|
/// must equal the corresponding value in `resource_claims` before the
|
|
/// boundary opens its listener. Kubernetes uses this to bind a one-use
|
|
/// bootstrap bundle to the admitted workload Pod UID exposed by the
|
|
/// Downward API. Other drivers may leave the map empty.
|
|
#[serde(default)]
|
|
pub resource_claim_files: std::collections::BTreeMap<String, PathBuf>,
|
|
/// Exact identity already applied by the runtime to the sandbox process.
|
|
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
|
|
/// Backend-neutral projection of the validated outer network fence.
|
|
pub provisioning_evidence: ProvisioningEvidence,
|
|
/// Driver-resolved environment exposed only to workload processes.
|
|
#[serde(default)]
|
|
pub child_env: std::collections::HashMap<String, String>,
|
|
}
|
|
|
|
/// Default time a runtime keeps a workload frozen, after losing its
|
|
/// supervisor, for an authenticated supervisor to recover it.
|
|
pub const DEFAULT_SUPERVISOR_RECOVERY_TIMEOUT: Duration = Duration::from_secs(30);
|
|
/// Accepted bounds, in seconds, of a recovery deadline a supervisor offers.
|
|
///
|
|
/// A supervisor offers it at attach (see [`Request::Attach`]). Recovery is
|
|
/// always bounded: a workload never runs unsupervised, and a frozen workload
|
|
/// is terminated once its deadline passes.
|
|
pub const SUPERVISOR_RECOVERY_TIMEOUT_RANGE: std::ops::RangeInclusive<u32> = 10..=300;
|
|
|
|
/// The recovery deadline of an offer of `seconds`, if
|
|
/// [`SUPERVISOR_RECOVERY_TIMEOUT_RANGE`] allows it.
|
|
#[must_use]
|
|
pub fn supervisor_recovery_timeout(seconds: u32) -> Option<Duration> {
|
|
SUPERVISOR_RECOVERY_TIMEOUT_RANGE
|
|
.contains(&seconds)
|
|
.then(|| Duration::from_secs(u64::from(seconds)))
|
|
}
|
|
|
|
impl fmt::Debug for BoundaryConfig {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("BoundaryConfig")
|
|
.field("boundary_id", &self.boundary_id)
|
|
.field("generation", &self.generation)
|
|
.field("session_id", &self.session_id)
|
|
.field("session_rotation", &self.session_rotation)
|
|
.field("auth_epoch", &self.auth_epoch)
|
|
.field("gateway_id", &self.gateway_id)
|
|
.field(
|
|
"verification_key_ids",
|
|
&self
|
|
.verification_keys
|
|
.iter()
|
|
.map(|key| key.key_id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.field("listener", &self.listener)
|
|
.field("resource_claims", &self.resource_claims)
|
|
.field("resource_claim_files", &self.resource_claim_files)
|
|
.field("workload_identity", &self.workload_identity)
|
|
.field("provisioning_evidence", &self.provisioning_evidence)
|
|
.field("child_env_keys", &self.child_env.keys().collect::<Vec<_>>())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl BoundaryConfig {
|
|
/// Serialize the protected driver-owned boundary configuration.
|
|
pub fn encode(&self) -> Result<Vec<u8>, BackendError> {
|
|
serde_json::to_vec(self)
|
|
.map_err(|error| BackendError::Descriptor(format!("encode boundary config: {error}")))
|
|
}
|
|
}
|
|
|
|
/// Validate driver-specific immutable coordinates before a boundary binds them.
|
|
///
|
|
/// Claim values are opaque to the common protocol, but empty or
|
|
/// whitespace-bearing identifiers cannot safely distinguish runtime objects.
|
|
pub fn validate_resource_claims(
|
|
claims: &std::collections::BTreeMap<String, String>,
|
|
) -> Result<(), BackendError> {
|
|
for (key, value) in claims {
|
|
if key.is_empty() || key.chars().any(char::is_whitespace) {
|
|
return Err(BackendError::Descriptor(
|
|
"boundary resource-claim keys must be non-empty and contain no whitespace"
|
|
.to_string(),
|
|
));
|
|
}
|
|
if value.is_empty() || value.chars().any(char::is_whitespace) {
|
|
return Err(BackendError::Descriptor(format!(
|
|
"boundary resource claim {key:?} must be non-empty and contain no whitespace"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn write_stream_frame(
|
|
writer: &mut (impl AsyncWrite + Unpin),
|
|
channel: u8,
|
|
payload: &[u8],
|
|
) -> io::Result<()> {
|
|
if payload.len() > MAX_STREAM_FRAME_BYTES {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"boundary stream frame exceeds limit",
|
|
));
|
|
}
|
|
writer.write_u8(channel).await?;
|
|
writer
|
|
.write_u32(payload.len().try_into().map_err(|_| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"boundary stream frame length overflow",
|
|
)
|
|
})?)
|
|
.await?;
|
|
writer.write_all(payload).await?;
|
|
writer.flush().await
|
|
}
|
|
|
|
pub async fn read_stream_frame(
|
|
reader: &mut (impl AsyncRead + Unpin),
|
|
) -> io::Result<Option<(u8, Vec<u8>)>> {
|
|
let channel = match reader.read_u8().await {
|
|
Ok(channel) => channel,
|
|
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
|
Err(error) => return Err(error),
|
|
};
|
|
let declared = reader.read_u32().await? as usize;
|
|
if declared > MAX_STREAM_FRAME_BYTES {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("boundary stream frame is too large: {declared} bytes"),
|
|
));
|
|
}
|
|
let mut payload = vec![0; declared];
|
|
reader.read_exact(&mut payload).await?;
|
|
Ok(Some((channel, payload)))
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RequestEnvelope {
|
|
/// Cryptographically random idempotency key scoped to one sandbox generation.
|
|
pub request_id: String,
|
|
/// Local replay fingerprint of decoded protobuf fields; never transmitted.
|
|
pub payload_digest: String,
|
|
/// Absolute exec admission deadline, preserved across retries and bound to
|
|
/// the payload digest. Other request kinds do not expire through this field.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub exec_admission_deadline_unix_ms: Option<u64>,
|
|
pub request: Request,
|
|
}
|
|
|
|
impl RequestEnvelope {
|
|
/// Build a request envelope with a fresh idempotency key and normalized
|
|
/// payload digest.
|
|
pub fn new(request: Request) -> Result<Self, FrameError> {
|
|
let exec_admission_deadline_unix_ms = if matches!(request, Request::Exec { .. }) {
|
|
let window_ms =
|
|
u64::try_from(EXEC_REQUEST_RETRY_WINDOW.as_millis()).map_err(io::Error::other)?;
|
|
Some(unix_time_millis()?.saturating_add(window_ms))
|
|
} else {
|
|
None
|
|
};
|
|
let payload_digest = request_envelope_digest(&request, exec_admission_deadline_unix_ms)?;
|
|
Ok(Self {
|
|
request_id: uuid::Builder::from_random_bytes(openshell_crypto::random_bytes::<16>()?)
|
|
.into_uuid()
|
|
.to_string(),
|
|
payload_digest,
|
|
exec_admission_deadline_unix_ms,
|
|
request,
|
|
})
|
|
}
|
|
|
|
/// Verify that the request body still matches the immutable digest bound
|
|
/// to this idempotency key.
|
|
pub fn validate_payload_digest(&self) -> Result<(), FrameError> {
|
|
let actual = request_envelope_digest(&self.request, self.exec_admission_deadline_unix_ms)?;
|
|
if actual == self.payload_digest {
|
|
Ok(())
|
|
} else {
|
|
Err(FrameError::PayloadDigestMismatch)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shared wall clock for the host's deadline and the boundary's admission check.
|
|
pub fn unix_time_millis() -> Result<u64, FrameError> {
|
|
let elapsed = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(io::Error::other)?;
|
|
u64::try_from(elapsed.as_millis()).map_err(|error| FrameError::Io(io::Error::other(error)))
|
|
}
|
|
|
|
pub(crate) fn request_envelope_digest(
|
|
request: &Request,
|
|
exec_admission_deadline_unix_ms: Option<u64>,
|
|
) -> Result<String, FrameError> {
|
|
use std::fmt::Write as _;
|
|
|
|
// Stable local replay fingerprint of decoded protobuf fields. Maps use
|
|
// BTreeMap in generated messages; neither clients nor the wire carry this.
|
|
let envelope = RequestEnvelope {
|
|
request_id: String::new(),
|
|
payload_digest: String::new(),
|
|
exec_admission_deadline_unix_ms,
|
|
request: request.clone(),
|
|
};
|
|
let payload = crate::protobuf::encode(&envelope)?;
|
|
let digest = openshell_crypto::sha256(&payload)?;
|
|
let mut encoded = String::with_capacity(64);
|
|
for byte in digest {
|
|
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
impl fmt::Debug for RequestEnvelope {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("RequestEnvelope")
|
|
.field("request_id", &self.request_id)
|
|
.field("request", &self.request)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "operation", rename_all = "snake_case")]
|
|
pub enum Request {
|
|
/// Read image policy without attaching or launching a workload.
|
|
DiscoverPolicy,
|
|
Attach {
|
|
supervisor_instance_id: SupervisorInstanceId,
|
|
policy: Box<SandboxPolicyWire>,
|
|
/// Gateway-signed takeover grant, present only when this process
|
|
/// replaces the generation's current owner.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
supervisor_takeover: Option<String>,
|
|
/// Seconds the runtime should keep the workload frozen after losing
|
|
/// this connection's supervision, offered by the supervisor and
|
|
/// bounded by [`SUPERVISOR_RECOVERY_TIMEOUT_RANGE`]. Absent keeps
|
|
/// [`DEFAULT_SUPERVISOR_RECOVERY_TIMEOUT`].
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
supervisor_recovery_timeout_secs: Option<u32>,
|
|
},
|
|
Confirm,
|
|
StartAgent {
|
|
sandbox_id: String,
|
|
spec: AgentSpecWire,
|
|
ca_cert: Option<String>,
|
|
ca_bundle: Option<String>,
|
|
provider_env_revision: u64,
|
|
provider_env: std::collections::HashMap<String, String>,
|
|
#[serde(default)]
|
|
provider_files: std::collections::HashMap<String, String>,
|
|
},
|
|
UpdateProviderEnvironment {
|
|
/// Ordered publication within this authenticated boundary session.
|
|
generation: u64,
|
|
revision: u64,
|
|
provider_env: std::collections::HashMap<String, String>,
|
|
#[serde(default)]
|
|
provider_files: std::collections::HashMap<String, String>,
|
|
},
|
|
AttachProcess {
|
|
process_id: String,
|
|
},
|
|
Wait {
|
|
process_id: String,
|
|
},
|
|
Signal {
|
|
process_id: String,
|
|
signal: SignalWire,
|
|
},
|
|
Terminate {
|
|
process_id: String,
|
|
},
|
|
/// Permanently end this sandbox session and acknowledge that every owned
|
|
/// workload process has been terminated.
|
|
TerminateBoundary,
|
|
Exec {
|
|
spec: ExecSpecWire,
|
|
},
|
|
Resize {
|
|
process_id: String,
|
|
cols: u16,
|
|
rows: u16,
|
|
},
|
|
LoopbackConnect {
|
|
host: std::net::IpAddr,
|
|
port: u16,
|
|
},
|
|
/// Upgrade one authenticated logical stream into the persistent DNS data
|
|
/// plane.
|
|
OpenMediation,
|
|
AcceptNetwork,
|
|
}
|
|
|
|
impl Request {
|
|
/// Whether this control-path request changes generation-owned sandbox
|
|
/// state and therefore must be replayed from the idempotency ledger.
|
|
#[must_use]
|
|
pub const fn is_replayable_mutation(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Attach { .. }
|
|
| Self::Confirm
|
|
| Self::StartAgent { .. }
|
|
| Self::UpdateProviderEnvironment { .. }
|
|
| Self::Exec { .. }
|
|
| Self::Signal { .. }
|
|
| Self::Terminate { .. }
|
|
| Self::TerminateBoundary
|
|
| Self::Resize { .. }
|
|
)
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Request {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::DiscoverPolicy => formatter.write_str("DiscoverPolicy"),
|
|
Self::Attach {
|
|
supervisor_instance_id: _,
|
|
policy: _,
|
|
supervisor_takeover,
|
|
supervisor_recovery_timeout_secs,
|
|
} => formatter
|
|
.debug_struct("Attach")
|
|
.field("policy", &"<redacted>")
|
|
.field("takeover", &supervisor_takeover.is_some())
|
|
.field(
|
|
"supervisor_recovery_timeout_secs",
|
|
supervisor_recovery_timeout_secs,
|
|
)
|
|
.finish(),
|
|
Self::Confirm => formatter.write_str("Confirm"),
|
|
Self::StartAgent {
|
|
sandbox_id,
|
|
spec,
|
|
ca_cert,
|
|
ca_bundle,
|
|
provider_env_revision,
|
|
provider_env,
|
|
provider_files,
|
|
} => formatter
|
|
.debug_struct("StartAgent")
|
|
.field("sandbox_id", sandbox_id)
|
|
.field("spec", spec)
|
|
.field("ca_cert_present", &ca_cert.is_some())
|
|
.field("ca_bundle_present", &ca_bundle.is_some())
|
|
.field("provider_env_revision", provider_env_revision)
|
|
.field("provider_file_count", &provider_files.len())
|
|
.field(
|
|
"provider_env_keys",
|
|
&provider_env.keys().collect::<Vec<_>>(),
|
|
)
|
|
.finish(),
|
|
Self::UpdateProviderEnvironment {
|
|
generation,
|
|
revision,
|
|
provider_env,
|
|
provider_files,
|
|
} => formatter
|
|
.debug_struct("UpdateProviderEnvironment")
|
|
.field("generation", generation)
|
|
.field("revision", revision)
|
|
.field("provider_file_count", &provider_files.len())
|
|
.field(
|
|
"provider_env_keys",
|
|
&provider_env.keys().collect::<Vec<_>>(),
|
|
)
|
|
.finish(),
|
|
Self::Wait { process_id } => formatter
|
|
.debug_struct("Wait")
|
|
.field("process_id", process_id)
|
|
.finish(),
|
|
Self::AttachProcess { process_id } => formatter
|
|
.debug_struct("AttachProcess")
|
|
.field("process_id", process_id)
|
|
.finish(),
|
|
Self::Signal { process_id, signal } => formatter
|
|
.debug_struct("Signal")
|
|
.field("process_id", process_id)
|
|
.field("signal", signal)
|
|
.finish(),
|
|
Self::Terminate { process_id } => formatter
|
|
.debug_struct("Terminate")
|
|
.field("process_id", process_id)
|
|
.finish(),
|
|
Self::TerminateBoundary => formatter.write_str("TerminateBoundary"),
|
|
Self::Exec { spec } => formatter.debug_tuple("Exec").field(spec).finish(),
|
|
Self::Resize {
|
|
process_id,
|
|
cols,
|
|
rows,
|
|
} => formatter
|
|
.debug_struct("Resize")
|
|
.field("process_id", process_id)
|
|
.field("cols", cols)
|
|
.field("rows", rows)
|
|
.finish(),
|
|
Self::LoopbackConnect { host, port } => formatter
|
|
.debug_struct("LoopbackConnect")
|
|
.field("host", host)
|
|
.field("port", port)
|
|
.finish(),
|
|
Self::OpenMediation => formatter.write_str("OpenMediation"),
|
|
Self::AcceptNetwork => formatter.write_str("AcceptNetwork"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ResponseEnvelope {
|
|
pub request_id: String,
|
|
pub response: Response,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "result", rename_all = "snake_case")]
|
|
pub enum Response {
|
|
ImagePolicy {
|
|
yaml: Option<String>,
|
|
invalid: bool,
|
|
},
|
|
Attached {
|
|
snapshot: SessionSnapshotWire,
|
|
},
|
|
Confirmed {
|
|
/// Backend-neutral properties and backend-owned audit evidence produced
|
|
/// before workload launch.
|
|
confirmation: Box<BoundaryConfirmation>,
|
|
},
|
|
Started {
|
|
process_id: String,
|
|
provider_env_revision: u64,
|
|
provider_env_generation: u64,
|
|
},
|
|
ProviderEnvironmentUpdated {
|
|
revision: u64,
|
|
generation: u64,
|
|
/// True only for this installed request or its exact idempotent replay.
|
|
applied: bool,
|
|
},
|
|
ProcessAttached {
|
|
terminal: bool,
|
|
},
|
|
Exited {
|
|
status: ExitStatusWire,
|
|
},
|
|
Signaled,
|
|
Terminated,
|
|
BoundaryTerminated,
|
|
ExecStarted {
|
|
process_id: String,
|
|
pty: bool,
|
|
},
|
|
Resized,
|
|
LoopbackConnected,
|
|
MediationReady,
|
|
NetworkConnected {
|
|
identity: BinaryIdentityWire,
|
|
destination: std::net::SocketAddr,
|
|
/// Native evidence is absent for guest host calls without kernel sockets.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
socket: Option<openshell_isolation_interface::contract::NetworkSocketMetadata>,
|
|
policy_generation: u64,
|
|
timing: MediationTimingWire,
|
|
},
|
|
Error {
|
|
kind: BoundaryErrorKind,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// Sandbox-monotonic timing carried across the boundary protocol.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct MediationTimingWire {
|
|
pub notification_to_queue_us: u64,
|
|
pub queue_wait_us: u64,
|
|
}
|
|
|
|
/// Boundary-owned process/session state returned on every supervisor attach.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SessionSnapshotWire {
|
|
pub generation: String,
|
|
pub processes: Vec<ProcessSnapshotWire>,
|
|
/// Launch controls bound by the first attach, if any. A replacement
|
|
/// supervisor reattaches with exactly these.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub bound_policy: Option<Box<SandboxPolicyWire>>,
|
|
}
|
|
|
|
/// Stable generation-scoped process state available to a replacement supervisor.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ProcessSnapshotWire {
|
|
pub process_id: String,
|
|
pub kind: ProcessKindWire,
|
|
pub terminal: bool,
|
|
pub status: Option<ExitStatusWire>,
|
|
pub retained_output: OutputWindowWire,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ProcessKindWire {
|
|
Main,
|
|
Exec,
|
|
}
|
|
|
|
/// Sequence range retained by the sandbox output ring.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct OutputWindowWire {
|
|
pub first_sequence: u64,
|
|
pub next_sequence: u64,
|
|
pub truncated: bool,
|
|
}
|
|
|
|
/// Completion of one sandbox-local DNS relay exchange.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "result", content = "value", rename_all = "snake_case")]
|
|
pub enum DnsQueryResultWire {
|
|
Response(Vec<u8>),
|
|
Error(String),
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BoundaryErrorKind {
|
|
Invalid,
|
|
Denied,
|
|
Unavailable,
|
|
Terminated,
|
|
Process,
|
|
Unsupported,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExecutableIdentityWire {
|
|
pub path: PathBuf,
|
|
pub digest: Option<Sha256Digest>,
|
|
}
|
|
|
|
impl From<ExecutableIdentity> for ExecutableIdentityWire {
|
|
fn from(identity: ExecutableIdentity) -> Self {
|
|
Self {
|
|
path: identity.path,
|
|
digest: identity.digest,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ExecutableIdentityWire> for ExecutableIdentity {
|
|
fn from(identity: ExecutableIdentityWire) -> Self {
|
|
Self {
|
|
path: identity.path,
|
|
digest: identity.digest,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum BinaryIdentityWire {
|
|
Resolved {
|
|
executable: ExecutableIdentityWire,
|
|
ancestors: Vec<ExecutableIdentityWire>,
|
|
cmdline_paths: Vec<PathBuf>,
|
|
},
|
|
Failed {
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
impl From<Result<BinaryIdentity, ResolveError>> for BinaryIdentityWire {
|
|
fn from(identity: Result<BinaryIdentity, ResolveError>) -> Self {
|
|
match identity {
|
|
Ok(identity) => Self::Resolved {
|
|
executable: identity.executable.into(),
|
|
ancestors: identity.ancestors.into_iter().map(Into::into).collect(),
|
|
cmdline_paths: identity.cmdline_paths,
|
|
},
|
|
Err(error) => Self::Failed {
|
|
message: error.to_string(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BinaryIdentityWire {
|
|
pub fn into_result(self) -> Result<BinaryIdentity, ResolveError> {
|
|
match self {
|
|
Self::Resolved {
|
|
executable,
|
|
ancestors,
|
|
cmdline_paths,
|
|
} => Ok(BinaryIdentity {
|
|
executable: executable.into(),
|
|
ancestors: ancestors.into_iter().map(Into::into).collect(),
|
|
cmdline_paths,
|
|
}),
|
|
Self::Failed { message } => Err(ResolveError::Failed(message)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ExecSpecWire {
|
|
pub program: String,
|
|
pub args: Vec<String>,
|
|
#[serde(default)]
|
|
pub shell: Option<ShellSpecWire>,
|
|
#[serde(default)]
|
|
pub runtime_helper: Option<RuntimeHelperWire>,
|
|
pub env: Vec<(String, String)>,
|
|
pub workdir: Option<String>,
|
|
pub pty: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RuntimeHelperWire {
|
|
Sftp,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ShellSpecWire {
|
|
pub command: Option<String>,
|
|
pub login: bool,
|
|
}
|
|
|
|
impl From<ShellSpec> for ShellSpecWire {
|
|
fn from(spec: ShellSpec) -> Self {
|
|
Self {
|
|
command: spec.command,
|
|
login: spec.login,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ShellSpecWire> for ShellSpec {
|
|
fn from(spec: ShellSpecWire) -> Self {
|
|
Self {
|
|
command: spec.command,
|
|
login: spec.login,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ExecSpec> for ExecSpecWire {
|
|
fn from(spec: ExecSpec) -> Self {
|
|
Self {
|
|
program: spec.program,
|
|
args: spec.args,
|
|
shell: spec.shell.map(ShellSpecWire::from),
|
|
runtime_helper: spec.runtime_helper.map(|helper| match helper {
|
|
openshell_isolation_interface::contract::RuntimeHelper::Sftp => {
|
|
RuntimeHelperWire::Sftp
|
|
}
|
|
}),
|
|
env: spec.env,
|
|
workdir: spec.workdir,
|
|
pty: spec.pty,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ExecSpecWire> for ExecSpec {
|
|
fn from(spec: ExecSpecWire) -> Self {
|
|
Self {
|
|
program: spec.program,
|
|
args: spec.args,
|
|
shell: spec.shell.map(ShellSpec::from),
|
|
runtime_helper: spec.runtime_helper.map(|helper| match helper {
|
|
RuntimeHelperWire::Sftp => {
|
|
openshell_isolation_interface::contract::RuntimeHelper::Sftp
|
|
}
|
|
}),
|
|
env: spec.env,
|
|
workdir: spec.workdir,
|
|
pty: spec.pty,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AgentSpecWire {
|
|
pub program: String,
|
|
pub args: Vec<String>,
|
|
pub workdir: Option<String>,
|
|
pub timeout_secs: u64,
|
|
pub interactive: bool,
|
|
}
|
|
|
|
impl From<AgentSpec> for AgentSpecWire {
|
|
fn from(spec: AgentSpec) -> Self {
|
|
Self {
|
|
program: spec.program,
|
|
args: spec.args,
|
|
workdir: spec.workdir,
|
|
timeout_secs: spec.timeout_secs,
|
|
interactive: spec.interactive,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SandboxPolicyWire {
|
|
pub version: u32,
|
|
pub read_only: Vec<PathBuf>,
|
|
pub read_write: Vec<PathBuf>,
|
|
pub include_workdir: bool,
|
|
pub network: NetworkModeWire,
|
|
pub proxy_addr: Option<std::net::SocketAddr>,
|
|
pub landlock: LandlockCompatibilityWire,
|
|
pub run_as_user: Option<String>,
|
|
pub run_as_group: Option<String>,
|
|
}
|
|
|
|
impl From<SandboxPolicy> for SandboxPolicyWire {
|
|
fn from(policy: SandboxPolicy) -> Self {
|
|
// Exhaustively destructure the policy so adding a `SandboxPolicy`
|
|
// field is a compile error here instead of a silently dropped field
|
|
// across the host-to-guest trust boundary.
|
|
let SandboxPolicy {
|
|
version,
|
|
filesystem,
|
|
network,
|
|
landlock,
|
|
process,
|
|
} = policy;
|
|
let FilesystemPolicy {
|
|
read_only,
|
|
read_write,
|
|
include_workdir,
|
|
} = filesystem;
|
|
let NetworkPolicy { mode, proxy } = network;
|
|
let LandlockPolicy { compatibility } = landlock;
|
|
let ProcessPolicy {
|
|
run_as_user,
|
|
run_as_group,
|
|
} = process;
|
|
Self {
|
|
version,
|
|
read_only,
|
|
read_write,
|
|
include_workdir,
|
|
network: NetworkModeWire::from(mode),
|
|
proxy_addr: proxy.and_then(|proxy| proxy.http_addr),
|
|
landlock: LandlockCompatibilityWire::from(compatibility),
|
|
run_as_user,
|
|
run_as_group,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SandboxPolicyWire> for SandboxPolicy {
|
|
fn from(policy: SandboxPolicyWire) -> Self {
|
|
let proxy = matches!(policy.network, NetworkModeWire::Proxy).then_some(ProxyPolicy {
|
|
http_addr: policy.proxy_addr,
|
|
});
|
|
Self {
|
|
version: policy.version,
|
|
filesystem: FilesystemPolicy {
|
|
read_only: policy.read_only,
|
|
read_write: policy.read_write,
|
|
include_workdir: policy.include_workdir,
|
|
},
|
|
network: NetworkPolicy {
|
|
mode: policy.network.into(),
|
|
proxy,
|
|
},
|
|
landlock: LandlockPolicy {
|
|
compatibility: policy.landlock.into(),
|
|
},
|
|
process: ProcessPolicy {
|
|
run_as_user: policy.run_as_user,
|
|
run_as_group: policy.run_as_group,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum NetworkModeWire {
|
|
Block,
|
|
Proxy,
|
|
Allow,
|
|
}
|
|
|
|
impl From<NetworkMode> for NetworkModeWire {
|
|
fn from(mode: NetworkMode) -> Self {
|
|
match mode {
|
|
NetworkMode::Block => Self::Block,
|
|
NetworkMode::Proxy => Self::Proxy,
|
|
NetworkMode::Allow => Self::Allow,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NetworkModeWire> for NetworkMode {
|
|
fn from(mode: NetworkModeWire) -> Self {
|
|
match mode {
|
|
NetworkModeWire::Block => Self::Block,
|
|
NetworkModeWire::Proxy => Self::Proxy,
|
|
NetworkModeWire::Allow => Self::Allow,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LandlockCompatibilityWire {
|
|
BestEffort,
|
|
HardRequirement,
|
|
}
|
|
|
|
impl From<LandlockCompatibility> for LandlockCompatibilityWire {
|
|
fn from(compatibility: LandlockCompatibility) -> Self {
|
|
match compatibility {
|
|
LandlockCompatibility::BestEffort => Self::BestEffort,
|
|
LandlockCompatibility::HardRequirement => Self::HardRequirement,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<LandlockCompatibilityWire> for LandlockCompatibility {
|
|
fn from(compatibility: LandlockCompatibilityWire) -> Self {
|
|
match compatibility {
|
|
LandlockCompatibilityWire::BestEffort => Self::BestEffort,
|
|
LandlockCompatibilityWire::HardRequirement => Self::HardRequirement,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SignalWire {
|
|
Term,
|
|
Kill,
|
|
Int,
|
|
Hup,
|
|
}
|
|
|
|
impl From<BoundarySignal> for SignalWire {
|
|
fn from(signal: BoundarySignal) -> Self {
|
|
match signal {
|
|
BoundarySignal::Term => Self::Term,
|
|
BoundarySignal::Kill => Self::Kill,
|
|
BoundarySignal::Int => Self::Int,
|
|
BoundarySignal::Hup => Self::Hup,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SignalWire> for BoundarySignal {
|
|
fn from(signal: SignalWire) -> Self {
|
|
match signal {
|
|
SignalWire::Term => Self::Term,
|
|
SignalWire::Kill => Self::Kill,
|
|
SignalWire::Int => Self::Int,
|
|
SignalWire::Hup => Self::Hup,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
|
pub enum ExitStatusWire {
|
|
Exited(i32),
|
|
Signaled(i32),
|
|
}
|
|
|
|
impl From<ExitStatusWire> for BoundaryExitStatus {
|
|
fn from(status: ExitStatusWire) -> Self {
|
|
match status {
|
|
ExitStatusWire::Exited(code) => Self::Exited(code),
|
|
ExitStatusWire::Signaled(signal) => Self::Signaled(signal),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BoundaryExitStatus> for ExitStatusWire {
|
|
fn from(status: BoundaryExitStatus) -> Self {
|
|
match status {
|
|
BoundaryExitStatus::Exited(code) => Self::Exited(code),
|
|
BoundaryExitStatus::Signaled(signal) => Self::Signaled(signal),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn encode_frame<T: crate::protobuf::WireMessage>(message: &T) -> Result<Vec<u8>, FrameError>
|
|
where
|
|
T::Proto: prost::Message + Default,
|
|
{
|
|
let payload = crate::protobuf::encode(message)?;
|
|
if payload.len() > MAX_CONTROL_FRAME_BYTES {
|
|
return Err(FrameError::TooLarge(payload.len()));
|
|
}
|
|
let length = u32::try_from(payload.len()).map_err(|_| FrameError::TooLarge(payload.len()))?;
|
|
let mut frame = Vec::with_capacity(4 + payload.len());
|
|
frame.extend_from_slice(&length.to_be_bytes());
|
|
frame.extend_from_slice(&payload);
|
|
Ok(frame)
|
|
}
|
|
|
|
pub fn decode_frame<T: crate::protobuf::WireMessage>(frame: &[u8]) -> Result<T, FrameError>
|
|
where
|
|
T::Proto: prost::Message + Default,
|
|
{
|
|
let header: [u8; 4] = frame
|
|
.get(..4)
|
|
.ok_or(FrameError::Truncated)?
|
|
.try_into()
|
|
.map_err(|_| FrameError::Truncated)?;
|
|
let declared = u32::from_be_bytes(header) as usize;
|
|
if declared > MAX_CONTROL_FRAME_BYTES {
|
|
return Err(FrameError::TooLarge(declared));
|
|
}
|
|
let payload = frame.get(4..).ok_or(FrameError::Truncated)?;
|
|
if payload.len() != declared {
|
|
return Err(FrameError::LengthMismatch {
|
|
declared,
|
|
actual: payload.len(),
|
|
});
|
|
}
|
|
crate::protobuf::decode(payload)
|
|
}
|
|
|
|
pub fn read_frame<T: crate::protobuf::WireMessage>(reader: &mut impl Read) -> Result<T, FrameError>
|
|
where
|
|
T::Proto: prost::Message + Default,
|
|
{
|
|
let mut header = [0_u8; 4];
|
|
reader.read_exact(&mut header)?;
|
|
let declared = u32::from_be_bytes(header) as usize;
|
|
if declared > MAX_CONTROL_FRAME_BYTES {
|
|
return Err(FrameError::TooLarge(declared));
|
|
}
|
|
let mut frame = Vec::with_capacity(4 + declared);
|
|
frame.extend_from_slice(&header);
|
|
frame.resize(4 + declared, 0);
|
|
reader.read_exact(&mut frame[4..])?;
|
|
decode_frame(&frame)
|
|
}
|
|
|
|
pub async fn read_frame_async<R, T>(reader: &mut R) -> Result<T, FrameError>
|
|
where
|
|
R: AsyncRead + Unpin,
|
|
T: crate::protobuf::WireMessage,
|
|
T::Proto: prost::Message + Default,
|
|
{
|
|
use tokio::io::AsyncReadExt as _;
|
|
|
|
let mut header = [0_u8; 4];
|
|
reader.read_exact(&mut header).await?;
|
|
let declared = u32::from_be_bytes(header) as usize;
|
|
if declared > MAX_CONTROL_FRAME_BYTES {
|
|
return Err(FrameError::TooLarge(declared));
|
|
}
|
|
let mut frame = Vec::with_capacity(4 + declared);
|
|
frame.extend_from_slice(&header);
|
|
frame.resize(4 + declared, 0);
|
|
reader.read_exact(&mut frame[4..]).await?;
|
|
decode_frame(&frame)
|
|
}
|
|
|
|
pub fn write_frame<T: crate::protobuf::WireMessage>(
|
|
writer: &mut impl Write,
|
|
message: &T,
|
|
) -> Result<(), FrameError>
|
|
where
|
|
T::Proto: prost::Message + Default,
|
|
{
|
|
let frame = encode_frame(message)?;
|
|
writer.write_all(&frame)?;
|
|
writer.flush()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum FrameError {
|
|
#[error("control frame cryptography failed: {0}")]
|
|
Crypto(#[from] openshell_crypto::CryptoError),
|
|
#[error("invalid protobuf message: {0}")]
|
|
Protobuf(String),
|
|
#[error("control frame is truncated")]
|
|
Truncated,
|
|
#[error("control frame is too large: {0} bytes")]
|
|
TooLarge(usize),
|
|
#[error("control frame declared {declared} bytes but contained {actual}")]
|
|
LengthMismatch { declared: usize, actual: usize },
|
|
#[error("control request payload digest does not match its envelope")]
|
|
PayloadDigestMismatch,
|
|
#[error("read or write control frame: {0}")]
|
|
Io(#[from] io::Error),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn complete_audit_evidence() -> NativeLinuxSandboxAuditEvidence {
|
|
NativeLinuxSandboxAuditEvidence {
|
|
capabilities: CapabilityEvidence {
|
|
inheritable: 0,
|
|
permitted: 0,
|
|
effective: 0,
|
|
bounding: 0,
|
|
ambient: 0,
|
|
},
|
|
no_new_privileges: true,
|
|
sandbox_dumpable: false,
|
|
child_dumpable: true,
|
|
core_limit_zero: true,
|
|
native_architecture: "x86_64".to_string(),
|
|
kernel_release: "6.12.0".to_string(),
|
|
seccomp: SeccompEvidence {
|
|
new_listener: true,
|
|
notification_round_trip: true,
|
|
id_validation: true,
|
|
addfd_send: true,
|
|
retained_socket_operation: true,
|
|
proc_fd_identity: true,
|
|
task_memory_read: true,
|
|
},
|
|
landlock_abi: 6,
|
|
landlock_allow_deny: true,
|
|
udp_dns_round_trip: true,
|
|
tcp_dns_round_trip: true,
|
|
tcp_allow_round_trip: true,
|
|
tcp_deny_round_trip: true,
|
|
socket_loopback_confinement: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn native_linux_audit_evidence_rejects_mechanism_failure() {
|
|
let mut audit = complete_audit_evidence();
|
|
audit.seccomp.addfd_send = false;
|
|
assert!(audit.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn audit_evidence_requires_socket_loopback_confinement() {
|
|
let mut audit = complete_audit_evidence();
|
|
audit.socket_loopback_confinement = false;
|
|
assert!(audit.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn binary_identity_wire_rejects_ambiguous_or_invalid_shapes() {
|
|
for encoded in [
|
|
r#"{"result":"resolved","ancestors":[],"cmdline_paths":[]}"#,
|
|
r#"{"result":"resolved","executable":{"path":"/bin/tool","digest":"invalid"},"ancestors":[],"cmdline_paths":[]}"#,
|
|
r#"{"result":"resolved","binary_path":"/bin/tool","binary_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ancestors":[],"cmdline_paths":[]}"#,
|
|
r#"{"result":"failed","message":"unavailable","binary_path":"/bin/tool"}"#,
|
|
] {
|
|
assert!(serde_json::from_str::<BinaryIdentityWire>(encoded).is_err());
|
|
}
|
|
let identity = BinaryIdentityWire::from(Ok(BinaryIdentity {
|
|
executable: ExecutableIdentity {
|
|
path: PathBuf::from("/bin/tool"),
|
|
digest: Some("a".repeat(64).parse().unwrap()),
|
|
},
|
|
ancestors: vec![ExecutableIdentity {
|
|
path: PathBuf::from("/bin/launcher"),
|
|
digest: Some("b".repeat(64).parse().unwrap()),
|
|
}],
|
|
cmdline_paths: Vec::new(),
|
|
}));
|
|
let encoded = serde_json::to_vec(&identity).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_slice::<BinaryIdentityWire>(&encoded).unwrap(),
|
|
identity
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_error_kind_rejects_unknown_values() {
|
|
assert!(serde_json::from_str::<BoundaryErrorKind>(r#""unkown""#).is_err());
|
|
for kind in [
|
|
BoundaryErrorKind::Invalid,
|
|
BoundaryErrorKind::Denied,
|
|
BoundaryErrorKind::Unavailable,
|
|
BoundaryErrorKind::Terminated,
|
|
BoundaryErrorKind::Process,
|
|
] {
|
|
let encoded = serde_json::to_vec(&kind).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_slice::<BoundaryErrorKind>(&encoded).unwrap(),
|
|
kind
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn supervisor_instance_id_round_trips_canonically_and_stays_redacted() {
|
|
let instance_id = SupervisorInstanceId::new();
|
|
let encoded = serde_json::to_string(&instance_id).expect("encode supervisor instance ID");
|
|
let decoded = serde_json::from_str::<SupervisorInstanceId>(&encoded)
|
|
.expect("decode supervisor instance ID");
|
|
assert_eq!(decoded, instance_id);
|
|
assert_eq!(
|
|
format!("{instance_id:?}"),
|
|
"SupervisorInstanceId([REDACTED])"
|
|
);
|
|
assert!(
|
|
serde_json::from_str::<SupervisorInstanceId>(
|
|
r#""00000000-0000-0000-0000-000000000000""#
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn request_round_trips_and_redacts_secrets() {
|
|
let request = RequestEnvelope {
|
|
request_id: "4e94636d-54f8-4d85-8e4e-58954fb5af0a".to_string(),
|
|
payload_digest: String::new(),
|
|
exec_admission_deadline_unix_ms: None,
|
|
request: Request::StartAgent {
|
|
provider_files: std::collections::HashMap::new(),
|
|
sandbox_id: "sandbox-1".to_string(),
|
|
spec: AgentSpecWire {
|
|
program: "/bin/true".to_string(),
|
|
args: Vec::new(),
|
|
workdir: Some("/sandbox".to_string()),
|
|
timeout_secs: 5,
|
|
interactive: false,
|
|
},
|
|
ca_cert: Some("test certificate".to_string()),
|
|
ca_bundle: Some("test bundle".to_string()),
|
|
provider_env_revision: 7,
|
|
provider_env: std::collections::HashMap::from([(
|
|
"OPENAI_API_KEY".to_string(),
|
|
"test credential".to_string(),
|
|
)]),
|
|
},
|
|
};
|
|
let request = RequestEnvelope {
|
|
payload_digest: request_envelope_digest(&request.request, None)
|
|
.expect("request digest"),
|
|
..request
|
|
};
|
|
let frame = encode_frame(&request).expect("encode request");
|
|
let decoded: RequestEnvelope = decode_frame(&frame).expect("decode request");
|
|
assert_eq!(decoded, request);
|
|
let debug = format!("{request:?}");
|
|
assert!(!debug.contains("test credential"));
|
|
assert!(!debug.contains("test certificate"));
|
|
assert!(!debug.contains("test bundle"));
|
|
assert!(debug.contains("OPENAI_API_KEY"));
|
|
assert!(request.validate_payload_digest().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn exec_deadline_round_trips_and_cannot_be_extended_on_retry() {
|
|
let before = unix_time_millis().unwrap();
|
|
let request = RequestEnvelope::new(Request::Exec {
|
|
spec: ExecSpecWire {
|
|
program: "/bin/true".to_string(),
|
|
args: Vec::new(),
|
|
shell: None,
|
|
runtime_helper: None,
|
|
env: Vec::new(),
|
|
workdir: None,
|
|
pty: false,
|
|
},
|
|
})
|
|
.unwrap();
|
|
let after = unix_time_millis().unwrap();
|
|
let window_ms = u64::try_from(EXEC_REQUEST_RETRY_WINDOW.as_millis()).unwrap();
|
|
let deadline = request.exec_admission_deadline_unix_ms.unwrap();
|
|
assert!((before + window_ms..=after + window_ms).contains(&deadline));
|
|
let frame = encode_frame(&request).unwrap();
|
|
let mut retry: RequestEnvelope = decode_frame(&frame).unwrap();
|
|
assert_eq!(retry, request);
|
|
retry.validate_payload_digest().unwrap();
|
|
retry.exec_admission_deadline_unix_ms = Some(deadline + 1);
|
|
assert!(matches!(
|
|
retry.validate_payload_digest(),
|
|
Err(FrameError::PayloadDigestMismatch)
|
|
));
|
|
retry.exec_admission_deadline_unix_ms = None;
|
|
assert!(retry.validate_payload_digest().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn non_exec_requests_have_no_deadline() {
|
|
let envelope = RequestEnvelope::new(Request::Confirm).unwrap();
|
|
assert!(envelope.exec_admission_deadline_unix_ms.is_none());
|
|
let encoded = serde_json::to_value(&envelope).unwrap();
|
|
assert!(encoded.get("exec_admission_deadline_unix_ms").is_none());
|
|
let decoded: RequestEnvelope = serde_json::from_value(encoded).unwrap();
|
|
decoded.validate_payload_digest().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn request_digest_is_stable_across_map_order_and_detects_mutation() {
|
|
let mut first = std::collections::HashMap::new();
|
|
first.insert("B".to_string(), "2".to_string());
|
|
first.insert("A".to_string(), "1".to_string());
|
|
let mut second = std::collections::HashMap::new();
|
|
second.insert("A".to_string(), "1".to_string());
|
|
second.insert("B".to_string(), "2".to_string());
|
|
let build = |provider_env| Request::UpdateProviderEnvironment {
|
|
provider_files: std::collections::HashMap::new(),
|
|
generation: 1,
|
|
revision: 2,
|
|
provider_env,
|
|
};
|
|
// The fingerprint is local: independently ordered protobuf maps must
|
|
// compare equally, while changed decoded fields must not replay.
|
|
let expected = request_envelope_digest(&build(first.clone()), None).unwrap();
|
|
for provider_env in [first, second] {
|
|
let request = build(provider_env);
|
|
assert_eq!(
|
|
request_envelope_digest(&request, None).expect("digest"),
|
|
expected
|
|
);
|
|
|
|
// Deserialization reconstructs the map with an independent hash
|
|
// seed; validation must retain the sender's canonical digest.
|
|
let envelope = RequestEnvelope::new(request).expect("request envelope");
|
|
let frame = encode_frame(&envelope).expect("encode envelope");
|
|
let mut decoded: RequestEnvelope = decode_frame(&frame).expect("decode envelope");
|
|
assert_eq!(decoded.payload_digest, expected);
|
|
decoded
|
|
.validate_payload_digest()
|
|
.expect("round-trip digest");
|
|
|
|
let Request::UpdateProviderEnvironment { provider_env, .. } = &mut decoded.request
|
|
else {
|
|
panic!("decoded the wrong request variant");
|
|
};
|
|
provider_env.insert("A".to_string(), "changed".to_string());
|
|
assert!(matches!(
|
|
decoded.validate_payload_digest(),
|
|
Err(FrameError::PayloadDigestMismatch)
|
|
));
|
|
}
|
|
|
|
let mut envelope = RequestEnvelope::new(build(std::collections::HashMap::new()))
|
|
.expect("request envelope");
|
|
envelope.request = Request::Terminate {
|
|
process_id: "different".to_string(),
|
|
};
|
|
assert!(matches!(
|
|
envelope.validate_payload_digest(),
|
|
Err(FrameError::PayloadDigestMismatch)
|
|
));
|
|
|
|
let mut request = build(std::collections::HashMap::new());
|
|
let Request::UpdateProviderEnvironment { provider_files, .. } = &mut request else {
|
|
unreachable!();
|
|
};
|
|
provider_files.insert(
|
|
"/run/openshell/providers/acme/client.toml".to_string(),
|
|
"version = 1".to_string(),
|
|
);
|
|
let mut envelope = RequestEnvelope::new(request).expect("file-bearing request envelope");
|
|
let Request::UpdateProviderEnvironment { provider_files, .. } = &mut envelope.request
|
|
else {
|
|
unreachable!();
|
|
};
|
|
provider_files.insert(
|
|
"/run/openshell/providers/acme/client.toml".to_string(),
|
|
"version = 2".to_string(),
|
|
);
|
|
assert!(matches!(
|
|
envelope.validate_payload_digest(),
|
|
Err(FrameError::PayloadDigestMismatch)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn start_agent_with_large_ca_bundle_fits_in_frame_limit() {
|
|
let request = RequestEnvelope::new(Request::StartAgent {
|
|
provider_files: std::collections::HashMap::new(),
|
|
sandbox_id: "sandbox-1".to_string(),
|
|
spec: AgentSpecWire {
|
|
program: "/bin/true".to_string(),
|
|
args: Vec::new(),
|
|
workdir: None,
|
|
timeout_secs: 5,
|
|
interactive: false,
|
|
},
|
|
ca_cert: Some("A".repeat(16 * 1024)),
|
|
ca_bundle: Some("B".repeat(400 * 1024)),
|
|
provider_env_revision: 0,
|
|
provider_env: std::collections::HashMap::new(),
|
|
})
|
|
.expect("request envelope");
|
|
let frame = encode_frame(&request).expect("large CA bundle must fit in frame limit");
|
|
let decoded: RequestEnvelope = decode_frame(&frame).expect("round-trip");
|
|
assert_eq!(decoded, request);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_declared_oversize() {
|
|
let oversized = u32::try_from(MAX_CONTROL_FRAME_BYTES + 1).expect("test size fits u32");
|
|
let mut frame = Vec::from(oversized.to_be_bytes());
|
|
frame.extend_from_slice(b"{}");
|
|
assert!(matches!(
|
|
decode_frame::<RequestEnvelope>(&frame),
|
|
Err(FrameError::TooLarge(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn resource_claims_reject_empty_or_ambiguous_identities() {
|
|
assert!(
|
|
validate_resource_claims(&std::collections::BTreeMap::from([(
|
|
"kubernetes.pod_uid".to_string(),
|
|
String::new()
|
|
),]))
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
validate_resource_claims(&std::collections::BTreeMap::from([(
|
|
"kubernetes.pod uid".to_string(),
|
|
"uid-1".to_string()
|
|
),]))
|
|
.is_err()
|
|
);
|
|
validate_resource_claims(&std::collections::BTreeMap::from([(
|
|
"kubernetes.pod_uid".to_string(),
|
|
"uid-1".to_string(),
|
|
)]))
|
|
.expect("opaque resource identity should be valid");
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_tls_identity_is_unique_per_session_and_redacted() {
|
|
let first_session = SandboxSessionId::new();
|
|
let second_session = SandboxSessionId::new();
|
|
let first = generate_sandbox_tls_material(first_session).expect("first TLS material");
|
|
let second = generate_sandbox_tls_material(second_session).expect("second TLS material");
|
|
|
|
assert_eq!(
|
|
first.server_name,
|
|
format!("sandbox.{first_session}.openshell.internal")
|
|
);
|
|
assert_ne!(first.server_name, second.server_name);
|
|
assert_ne!(first.trust_anchor_pem, second.trust_anchor_pem);
|
|
assert!(first.certificate_chain_pem.contains("BEGIN CERTIFICATE"));
|
|
assert!(first.private_key_pem.contains("BEGIN PRIVATE KEY"));
|
|
|
|
let debug = format!("{first:?}");
|
|
assert!(!debug.contains(&first.private_key_pem));
|
|
assert!(!debug.contains(&first.certificate_chain_pem));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_transport_does_not_embed_authentication_material() {
|
|
let transport = SandboxTransport::Tcp {
|
|
authority: "sandbox.default.svc.cluster.local".to_string(),
|
|
addresses: vec!["192.0.2.10:8443".parse().expect("test address")],
|
|
};
|
|
let encoded = serde_json::to_vec(&transport).expect("encode transport");
|
|
let decoded: SandboxTransport = serde_json::from_slice(&encoded).expect("decode transport");
|
|
assert_eq!(decoded, transport);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_wire_accepts_legacy_direct_exec_without_shell_intent() {
|
|
let wire: ExecSpecWire = serde_json::from_str(
|
|
r#"{"program":"/bin/true","args":[],"env":[],"workdir":null,"pty":false}"#,
|
|
)
|
|
.expect("decode legacy exec spec");
|
|
|
|
assert_eq!(wire.shell, None);
|
|
assert_eq!(wire.runtime_helper, None);
|
|
}
|
|
|
|
#[test]
|
|
fn exec_wire_preserves_trusted_runtime_helper_intent() {
|
|
let spec = ExecSpec {
|
|
program: String::new(),
|
|
args: Vec::new(),
|
|
shell: None,
|
|
runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp),
|
|
env: Vec::new(),
|
|
workdir: None,
|
|
pty: false,
|
|
};
|
|
|
|
let decoded = ExecSpec::from(ExecSpecWire::from(spec));
|
|
assert_eq!(
|
|
decoded.runtime_helper,
|
|
Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp)
|
|
);
|
|
}
|
|
}
|