diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index dd53fa625..7dd07ff27 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -24,7 +24,7 @@ //! `attach` is atomic from the caller's perspective: it establishes and binds //! the boundary, returns `Bound`, or fails closed. It never binds a resource //! already bound to an active boundary. Binary identity travels on every -//! [`PendingNetworkOpen`], resolved for that exact socket and process +//! [`PendingTcpOpen`], resolved for that exact socket and process //! generation; an unresolved identity denies the open. //! //! The contract is transport-neutral. Concrete topology implementations keep @@ -352,17 +352,12 @@ pub trait IsolationBackend: Send + Sync { /// code is running. #[async_trait] pub trait BoundBoundary: Send { - /// The mediation service's backend-neutral source of workload connections. + /// The mediation service's backend-neutral source of workload network + /// requests. TCP and DNS remain typed operations so consumers cannot mix + /// their framing, decisions, or response semantics. /// Retained by the supervisor before consuming `Bound`. fn network_mediation_source(&self) -> Arc; - /// Optional transport for workload DNS exchanges. Backends that expose - /// this source keep DNS inside the supervisor-owned policy path rather - /// than granting the workload access to a resolver socket. - fn dns_mediation_source(&self) -> Option> { - None - } - /// Trusted host-side dial target for the well-known host-gateway aliases. /// /// Backends return this when the mediation service runs outside the @@ -703,7 +698,7 @@ pub trait BoundaryLoopbackConnector: Send + Sync { // ============================================================================ /// Executable identity for one accepted connection, resolved by the backend and -/// delivered on [`PendingNetworkOpen`] before the mediation service evaluates +/// delivered on [`PendingTcpOpen`] before the mediation service evaluates /// policy. /// /// A missing digest is `None`, never an empty value; policy that requires an @@ -777,21 +772,36 @@ pub struct NetworkSocketMetadata { /// Typed supervisor decision for one pending TCP open. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NetworkOpenResult { +pub enum TcpOpenDecision { /// L4 authorization and a bounded relay handler are ready. L7 policy still /// applies to bytes after the local connection commits. RelayReady, /// The socket remains unchanged and connect returns this positive errno. - Denied { errno: i32 }, + Denied(TcpOpenDenial), +} + +/// Placement-neutral reason why a staged TCP open was not committed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TcpOpenDenial { + /// The admitted network policy rejected the request. + PolicyDenied, + /// The backend could not resolve authoritative executable identity. + IdentityUnavailable, + /// The requested destination could not be validated. + InvalidDestination, + /// A bounded mediation resource was exhausted. + ResourceExhausted, + /// The mediation path became unavailable before commit. + MediationUnavailable, } /// A staged workload TCP open delivered before its local relay is committed. /// /// An `Err` identity must be denied and audited. The supervisor owns /// `result`; dropping it cancels the open without changing the workload socket. -pub struct PendingNetworkOpen { +pub struct PendingTcpOpen { /// Staged byte stream whose workload side is committed only after - /// [`NetworkOpenResult::RelayReady`]. + /// [`TcpOpenDecision::RelayReady`]. pub stream: BoundaryDuplexStream, /// Executable identity, resolved by the backend for this connection. pub binary_identity: Result, @@ -802,7 +812,7 @@ pub struct PendingNetworkOpen { /// Policy generation under which the request was created. pub policy_generation: u64, /// Single-use completion channel back to the sandbox broker. - pub result: oneshot::Sender, + pub decision: oneshot::Sender, } /// A logical per-boundary stream of workload connections, consumed by the @@ -811,13 +821,17 @@ pub struct PendingNetworkOpen { /// It may wrap a dedicated listener or a demultiplexed view over shared /// transport; how it reaches a co-located proxy, a sidecar, or a shared /// mediation service is backend-private. A trusted backend component associates -/// every returned connection with its active boundary without relying solely on -/// a transport tuple or workload-provided identifier. An `Err` from `accept` -/// means the source itself is unusable and fails the boundary closed. +/// every returned request with its active boundary without relying solely on a +/// transport tuple or workload-provided identifier. TCP and DNS use separate +/// accepts so they can be consumed concurrently with independent backpressure. +/// An `Err` means that mediation lane is unusable and fails closed. #[async_trait] pub trait NetworkMediationSource: Send + Sync { /// Await the next staged workload TCP open. - async fn accept(&self) -> Result; + async fn accept_tcp(&self) -> Result; + + /// Await the next workload DNS query. + async fn accept_dns(&self) -> Result; } /// DNS transport used by one workload exchange. @@ -825,14 +839,15 @@ pub trait NetworkMediationSource: Send + Sync { pub enum DnsTransport { /// One DNS wire datagram without a TCP length prefix. Udp, - /// One two-byte-length-prefixed DNS message. + /// One DNS message received over a TCP resolver connection. Tcp, } /// One workload DNS request and its fail-closed response channel. -pub struct MediatedDnsQuery { - /// DNS request bytes in the framing selected by [`Self::transport`]. - pub request: Vec, +pub struct PendingDnsQuery { + /// Exactly one DNS wire message, without a DNS-over-TCP length prefix. + /// The backend removes and restores transport framing. + pub message: Vec, /// Workload DNS transport. pub transport: DnsTransport, /// Identity of the process that issued the DNS request when the backend @@ -844,11 +859,3 @@ pub struct MediatedDnsQuery { /// Single-use response channel owned by the backend adapter. pub response: oneshot::Sender, BackendError>>, } - -/// Logical per-boundary stream of DNS exchanges. The backend handles syscall, -/// packet, or guest-agent transport details; the supervisor owns policy DNS. -#[async_trait] -pub trait DnsMediationSource: Send + Sync { - /// Await the next DNS query from this boundary. - async fn accept(&self) -> Result; -} diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 4852a4f6a..3265d6138 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -90,10 +90,10 @@ struct MockSource(PhantomData); #[async_trait] impl NetworkMediationSource for MockSource { - async fn accept(&self) -> Result { + async fn accept_tcp(&self) -> Result { let (near, _far) = tokio::io::duplex(64); - let (result, _result_rx) = oneshot::channel(); - Ok(PendingNetworkOpen { + let (decision, _decision_rx) = oneshot::channel(); + Ok(PendingTcpOpen { stream: Box::new(near), binary_identity: Ok(BinaryIdentity { binary_path: PathBuf::from("/usr/bin/agent"), @@ -109,7 +109,19 @@ impl NetworkMediationSource for MockSource { process_generation: 1, }, policy_generation: 1, - result, + decision, + }) + } + + async fn accept_dns(&self) -> Result { + let (response, _response_rx) = oneshot::channel(); + Ok(PendingDnsQuery { + message: vec![0; 12], + transport: DnsTransport::Udp, + binary_identity: Err(ResolveError::Failed( + "mock DNS attribution unavailable".to_string(), + )), + response, }) } } @@ -121,10 +133,10 @@ struct UnattributedSource; #[async_trait] impl NetworkMediationSource for UnattributedSource { - async fn accept(&self) -> Result { + async fn accept_tcp(&self) -> Result { let (near, _far) = tokio::io::duplex(64); - let (result, _result_rx) = oneshot::channel(); - Ok(PendingNetworkOpen { + let (decision, _decision_rx) = oneshot::channel(); + Ok(PendingTcpOpen { stream: Box::new(near), binary_identity: Err(ResolveError::Failed("hash unavailable".to_string())), destination: "203.0.113.10:443".parse().unwrap(), @@ -134,9 +146,15 @@ impl NetworkMediationSource for UnattributedSource { process_generation: 1, }, policy_generation: 1, - result, + decision, }) } + + async fn accept_dns(&self) -> Result { + Err(BackendError::Unavailable( + "mock DNS mediation unavailable".to_string(), + )) + } } struct MockExec; @@ -560,7 +578,7 @@ async fn runtime_interfaces_survive_lifecycle_consumption() { .await .expect("start"); - let conn = source.accept().await.expect("accept after consumption"); + let conn = source.accept_tcp().await.expect("accept after consumption"); let identity = conn.binary_identity.expect("identity resolves"); assert_eq!(identity.binary_path, PathBuf::from("/usr/bin/agent")); } @@ -721,7 +739,7 @@ async fn pending_network_open_carries_socket_bound_identity() { .expect("attach"); let conn = bound .network_mediation_source() - .accept() + .accept_tcp() .await .expect("accept"); let identity = conn.binary_identity.expect("identity resolves"); @@ -740,7 +758,7 @@ async fn missing_digest_is_none_never_empty() { // The secondary backend resolves path-only identity: the digest is `None`, // so policy that requires a digest cannot authorize the connection. let source = MockSource::(PhantomData); - let conn = source.accept().await.expect("accept"); + let conn = source.accept_tcp().await.expect("accept"); let identity = conn.binary_identity.expect("identity resolves"); assert!(identity.binary_digest.is_none()); } @@ -757,7 +775,7 @@ async fn unresolved_identity_travels_with_the_pending_open_and_fails_closed() { // Attribution failure does not tear down the source: the connection is // delivered carrying `Err`, and the mediation service denies it. let source = UnattributedSource; - let conn = source.accept().await.expect("accept"); + let conn = source.accept_tcp().await.expect("accept"); assert!(conn.binary_identity.is_err()); } diff --git a/rfc/0012-isolation-backend/README.md b/rfc/0012-isolation-backend/README.md index d665744ed..0f1115496 100644 --- a/rfc/0012-isolation-backend/README.md +++ b/rfc/0012-isolation-backend/README.md @@ -251,16 +251,28 @@ trait BoundaryTerminal: Send + Sync { ```rust #[async_trait] trait NetworkMediationSource: Send + Sync { - async fn accept(&self) -> Result; + async fn accept_tcp(&self) -> Result; + async fn accept_dns(&self) -> Result; } -struct MediatedConnection { +struct PendingTcpOpen { stream: BoundaryDuplexStream, binary_identity: Result, + destination: SocketAddr, + socket: NetworkSocketMetadata, + policy_generation: u64, + decision: oneshot::Sender, +} + +struct PendingDnsQuery { + message: Vec, // exactly one DNS message; no TCP length prefix + transport: DnsTransport, + binary_identity: Result, + response: oneshot::Sender, BackendError>>, } ``` -`NetworkMediationSource` supplies outbound connections from one boundary to supervisor-owned network mediation. The backend routes all workload egress through that source and authoritatively associates each connection with the boundary without relying solely on workload-provided data. Capture, transport, placement, and coordination are backend-private. +`NetworkMediationSource` supplies typed TCP opens and DNS queries from one boundary to supervisor-owned network mediation. The two accepts are independent so the supervisor can consume them concurrently without coupling their ordering or backpressure. The backend routes all workload egress through these sources and authoritatively associates each request with the boundary without relying solely on workload-provided data. Capture, transport, framing, placement, and coordination are backend-private. General UDP mediation is outside this contract. Every topology may use the same supervisor-owned mediation libraries or services; the source does not require a backend-specific policy engine.