diff --git a/litebox/src/broker/mod.rs b/litebox/src/broker/mod.rs index eee7afa7b..f62b4dd5c 100644 --- a/litebox/src/broker/mod.rs +++ b/litebox/src/broker/mod.rs @@ -760,6 +760,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, })) } } @@ -812,6 +813,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, })) } } diff --git a/litebox/src/event/counter.rs b/litebox/src/event/counter.rs index 347f04853..7f9a2e9f4 100644 --- a/litebox/src/event/counter.rs +++ b/litebox/src/event/counter.rs @@ -450,6 +450,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: litebox_broker_protocol::BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, })) } } diff --git a/litebox/src/fs/in_mem.rs b/litebox/src/fs/in_mem.rs index 94a3f8df1..f5e31fc03 100644 --- a/litebox/src/fs/in_mem.rs +++ b/litebox/src/fs/in_mem.rs @@ -70,16 +70,17 @@ impl FileSystem { /// /// This function primarily exists to initialize files. Most regular interaction with the file /// system should be done without this function. - pub fn with_root_privileges(&mut self, f: F) + pub fn with_root_privileges(&mut self, f: F) -> R where - F: FnOnce(&mut Self), + F: FnOnce(&mut Self) -> R, { let original_user = core::mem::replace(&mut self.current_user, UserInfo::ROOT); - f(self); + let result = f(self); let root_again = core::mem::replace(&mut self.current_user, original_user); if root_again.user != UserInfo::ROOT.user || root_again.group != UserInfo::ROOT.group { unreachable!() } + result } /// Initialize a primarily read-heavy file with static data. diff --git a/litebox/src/pipes.rs b/litebox/src/pipes.rs index 601c36e97..fab9423b9 100644 --- a/litebox/src/pipes.rs +++ b/litebox/src/pipes.rs @@ -1165,6 +1165,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, })) } } diff --git a/litebox_broker_core/src/lib.rs b/litebox_broker_core/src/lib.rs index 58d1b897e..a08c7f040 100644 --- a/litebox_broker_core/src/lib.rs +++ b/litebox_broker_core/src/lib.rs @@ -158,6 +158,12 @@ impl BrokerCore { self.limits } + /// Returns the immutable capabilities of this core's socket provider. + #[must_use] + pub fn capabilities(&self) -> litebox_broker_protocol::BrokerCapabilities { + self.socket_provider.capabilities() + } + pub(crate) fn allocate_reference_handle(&self) -> Result { let mut next_reference_handle = self.next_reference_handle.write(); let handle = ObjectHandle(*next_reference_handle); diff --git a/litebox_broker_core/src/socket.rs b/litebox_broker_core/src/socket.rs index 17655a296..0d0ed293f 100644 --- a/litebox_broker_core/src/socket.rs +++ b/litebox_broker_core/src/socket.rs @@ -10,7 +10,6 @@ use core::net::{Ipv4Addr, SocketAddrV4}; use core::sync::atomic::{AtomicUsize, Ordering}; use hashbrown::HashSet; -use litebox_broker_protocol::ObjectHandle; use litebox_broker_protocol::readiness::ReadinessFlags; use litebox_broker_protocol::socket::{ CreateSocketRequest, IpProtocol, MAX_SOCKET_TRANSFER_SIZE, MAX_TCP_LISTEN_BACKLOG, @@ -18,6 +17,7 @@ use litebox_broker_protocol::socket::{ SocketConnectionStatus, SocketError, SocketOutcome, SocketStatusResponse, SocketType, TcpOptionName, TcpOptionValue, }; +use litebox_broker_protocol::{BrokerCapabilities, ObjectHandle}; use spin::Mutex; use crate::readiness::{ReadinessRegistration, ReadinessSink}; @@ -34,6 +34,9 @@ pub const GUEST_IPV4_ADDRESS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 15); /// Fixed guest-visible address used to reach host-loopback services. pub const HOST_GATEWAY_IPV4_ADDRESS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 1); +/// Fixed guest-visible address of the broker-provided DNS service. +pub const BROKER_DNS_IPV4_ADDRESS: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 3); + fn is_concrete_internal_address(address: Ipv4Addr) -> bool { address.is_loopback() || address == GUEST_IPV4_ADDRESS } @@ -456,6 +459,18 @@ pub struct PlatformDatagramReceive { /// and accounting domains, not separate provider namespaces. Operations on an /// individual socket belong to [`PlatformSocket`], not this shared provider. pub trait SocketProvider: Send + Sync { + /// Returns immutable features implemented by this provider. + fn capabilities(&self) -> BrokerCapabilities; + + /// Resolves a normalized guest-requested address into a trusted platform route. + /// + /// The core authorizes the route's policy address before handing the route + /// to a platform socket. + fn route_destination( + &self, + destination: SocketAddrV4, + ) -> Result>; + /// Creates one nonblocking socket resource for a broker session. /// /// Any provider-retained clones must become inert when @@ -533,12 +548,11 @@ pub trait PlatformSocket: Send + Sync { /// [`AcceptedPlatformSocket`]; external paths may drop it. Returning /// [`PlatformConnectError::PeerUnchanged`] requires releasing the lease; /// [`PlatformSocket::retire`] must release one retained by an indeterminate - /// or in-progress operation. `address` is the normalized guest-visible - /// destination; the provider must validate it with - /// [`normalize_socket_destination`] before choosing a host-socket target. + /// or in-progress operation. `destination` is a provider-resolved route + /// already validated and authorized by core. fn connect( &self, - address: SocketAddrV4, + destination: PlatformSocketDestination, guest_source_lease: Option, ) -> core::result::Result; @@ -551,9 +565,8 @@ pub trait PlatformSocket: Send + Sync { /// Sends one complete datagram without waiting for platform readiness. /// /// A destination is required for an unconnected socket and omitted to use - /// the socket's connected peer. An explicit destination is normalized but - /// remains guest-visible; the provider must validate it with - /// [`normalize_socket_destination`] before choosing a host-socket target. + /// the socket's connected peer. An explicit destination is a + /// provider-resolved route already validated and authorized by core. /// A temporarily full socket returns [`BrokerError::WouldBlock`]. Partial /// successful sends are invalid. Resource failures are broker errors rather /// than ordinary socket failures. @@ -561,7 +574,7 @@ pub trait PlatformSocket: Send + Sync { &self, data: Vec, flags: SendFlags, - destination: Option, + destination: Option, ) -> Result>; /// Receives bytes without waiting for platform readiness. @@ -629,10 +642,99 @@ pub trait PlatformSocket: Send + Sync { fn readiness(&self) -> ReadinessFlags; } +/// A trusted platform route for a normalized guest-requested destination. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlatformSocketDestination { + /// A destination served by another guest socket in this broker core. + Internal(SocketAddrV4), + /// A destination served by the host networking stack. + External { + /// The address visible to the guest. + guest_address: SocketAddrV4, + /// The address against which broker policy is evaluated. + policy_address: SocketAddrV4, + /// The address passed to the host networking stack. + host_address: SocketAddrV4, + }, + /// The broker-provided DNS service. + BrokerDns(SocketAddrV4), +} + +impl PlatformSocketDestination { + /// Constructs the ordinary internal or external route for a normalized address. + #[must_use] + pub fn standard(destination: SocketAddrV4) -> Self { + if is_internal_socket_address(destination) { + Self::Internal(destination) + } else { + Self::External { + guest_address: destination, + policy_address: destination, + host_address: host_socket_destination(destination), + } + } + } + + /// Returns the address visible to the guest. + #[must_use] + pub fn guest_address(self) -> SocketAddrV4 { + match self { + Self::Internal(address) | Self::BrokerDns(address) => address, + Self::External { guest_address, .. } => guest_address, + } + } + + /// Returns the address against which broker policy is evaluated. + /// + /// Broker services do not require a native destination policy rule. + #[must_use] + pub fn policy_address(self) -> Option { + match self { + Self::Internal(address) => Some(address), + Self::External { policy_address, .. } => Some(policy_address), + Self::BrokerDns(_) => None, + } + } + + fn is_valid_for(self, requested: SocketAddrV4) -> bool { + if self.guest_address() != requested { + return false; + } + match self { + Self::Internal(address) => is_internal_socket_address(address), + Self::External { + policy_address, + host_address, + .. + } => { + !is_internal_socket_address(requested) + && policy_address.port() == requested.port() + && normalize_socket_destination(policy_address) == Ok(policy_address) + && !is_internal_socket_address(policy_address) + && host_address == host_socket_destination(policy_address) + } + Self::BrokerDns(address) => address == SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53), + } + } +} + /// Provider for broker configurations that deliberately disable sockets. pub struct UnsupportedSocketProvider; impl SocketProvider for UnsupportedSocketProvider { + fn capabilities(&self) -> BrokerCapabilities { + BrokerCapabilities::NONE + } + + fn route_destination( + &self, + destination: SocketAddrV4, + ) -> Result> { + Ok(SocketOutcome::Completed( + PlatformSocketDestination::standard(destination), + )) + } + fn create( &self, _session_id: SessionId, @@ -684,6 +786,62 @@ pub fn create( Ok(handle) } +fn route_and_authorize_destination( + session: &BrokerSession, + create_request: CreateSocketRequest, + requested: SocketAddrV4, +) -> Result> { + let destination = match session.core.socket_provider.route_destination(requested)? { + SocketOutcome::Completed(destination) => destination, + SocketOutcome::Failed(error) => return Ok(SocketOutcome::Failed(error)), + }; + let broker_dns_address = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + if requested == broker_dns_address { + match destination { + PlatformSocketDestination::BrokerDns(_) => { + if !is_udp(create_request) + || !session + .core + .socket_provider + .capabilities() + .contains(BrokerCapabilities::BROKER_DNS) + { + return Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)); + } + } + PlatformSocketDestination::Internal(_) | PlatformSocketDestination::External { .. } => { + return Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)); + } + } + } + if destination.policy_address() == Some(broker_dns_address) { + return Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)); + } + if !destination.is_valid_for(requested) { + return Err(BrokerError::Internal); + } + + let Some(policy_address) = destination.policy_address() else { + return if is_udp(create_request) { + Ok(SocketOutcome::Completed(destination)) + } else { + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + }; + }; + + // Destination denial is an operation-level socket failure. Failures while + // evaluating policy remain broker errors. + match session.core.policy.authorize_socket_connect( + session.caller_credential, + create_request.protocol, + policy_address, + ) { + Ok(()) => Ok(SocketOutcome::Completed(destination)), + Err(BrokerError::PolicyDenied) => Ok(SocketOutcome::Failed(SocketError::PolicyDenied)), + Err(error) => Err(error), + } +} + /// Starts a nonblocking connection attempt. /// /// An authorized attempt first binds an unbound socket. TCP connected and @@ -707,24 +865,16 @@ pub fn connect( if address.port() == 0 { return Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)); } - let destination = match normalize_socket_destination(address) { + let requested_destination = match normalize_socket_destination(address) { Ok(destination) => destination, Err(error) => return Ok(SocketOutcome::Failed(error)), }; - // Destination denial is an operation-level socket failure. Failures while - // evaluating policy remain broker errors. - match session.core.policy.authorize_socket_connect( - session.caller_credential, - create_request.protocol, - destination, - ) { - Ok(()) => {} - Err(BrokerError::PolicyDenied) => { - return Ok(SocketOutcome::Failed(SocketError::PolicyDenied)); - } - Err(error) => return Err(error), - } + let destination = + match route_and_authorize_destination(session, create_request, requested_destination)? { + SocketOutcome::Completed(destination) => destination, + SocketOutcome::Failed(error) => return Ok(SocketOutcome::Failed(error)), + }; if is_udp(create_request) { return connect_datagram(session, &object, create_request, destination); @@ -775,13 +925,13 @@ pub fn connect( } } if resource - .source_address_for_destination(destination) + .source_address_for_destination(destination.guest_address()) .is_none() { finish_connect(&object, SocketConnectionStatus::Unconnected); return Ok(SocketOutcome::Failed(SocketError::InvalidArgument)); } - let guest_source_lease = match resource.source_lease_for_connect(destination) { + let guest_source_lease = match resource.source_lease_for_connect(destination.guest_address()) { Ok(lease) => lease, Err(error) => { finish_retired_connect(&object); @@ -1074,24 +1224,18 @@ pub fn send_to( return Ok(SocketOutcome::Failed(SocketError::InvalidArgument)); } Some(destination) => match normalize_socket_destination(destination) { - Ok(destination) => Some(destination), + Ok(destination) => { + match route_and_authorize_destination(session, create_request, destination)? { + SocketOutcome::Completed(destination) => Some(destination), + SocketOutcome::Failed(error) => { + return Ok(SocketOutcome::Failed(error)); + } + } + } Err(error) => return Ok(SocketOutcome::Failed(error)), }, None => None, }; - if let Some(destination) = destination { - match session.core.policy.authorize_socket_connect( - session.caller_credential, - create_request.protocol, - destination, - ) { - Ok(()) => {} - Err(BrokerError::PolicyDenied) => { - return Ok(SocketOutcome::Failed(SocketError::PolicyDenied)); - } - Err(error) => return Err(error), - } - } let (resource, needs_bind) = { let mut object = object.write(); let ObjectEntry::Socket(socket) = &mut *object else { @@ -1137,7 +1281,7 @@ pub fn send_to( } if destination.is_some_and(|destination| { resource - .source_address_for_destination(destination) + .source_address_for_destination(destination.guest_address()) .is_none() }) { return Ok(SocketOutcome::Failed(SocketError::InvalidArgument)); @@ -1680,7 +1824,7 @@ fn connect_datagram( session: &BrokerSession, object: &spin::RwLock, create_request: CreateSocketRequest, - destination: SocketAddrV4, + destination: PlatformSocketDestination, ) -> Result> { let (resource, previous_status, needs_bind) = { let mut object = object.write(); @@ -1725,7 +1869,7 @@ fn connect_datagram( } } if resource - .source_address_for_destination(destination) + .source_address_for_destination(destination.guest_address()) .is_none() { finish_datagram_connect(object, previous_status, false); diff --git a/litebox_broker_core/src/socket/tests.rs b/litebox_broker_core/src/socket/tests.rs index fa31c1461..7d8b54b91 100644 --- a/litebox_broker_core/src/socket/tests.rs +++ b/litebox_broker_core/src/socket/tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::readiness::tests::TestReadinessSink; use crate::{BrokerCore, CallerCredential}; +use litebox_broker_protocol::BrokerCapabilities; use litebox_broker_protocol::socket::{AddressFamily, IpProtocol, SocketType}; use std::net::Ipv4Addr; use std::sync::{Mutex as StdMutex, mpsc}; @@ -160,6 +161,244 @@ fn gateway_destinations_reach_the_platform_untranslated() { ); } +#[test] +fn provider_routes_are_authorized_by_pinned_destination_before_platform_dispatch() { + let provider = Arc::new(TestSocketProvider::default()); + let synthetic = SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 1), 443); + let pinned = SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 443); + let route = PlatformSocketDestination::External { + guest_address: synthetic, + policy_address: pinned, + host_address: pinned, + }; + let policy = crate::SocketPolicy::guest_network() + .with_tcp_destination_rules(&[destination_rule(*pinned.ip(), pinned.port())]) + .unwrap(); + let broker = test_broker_with_policy(Arc::clone(&provider) as Arc, &policy); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let allowed = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + provider.return_next_route(SocketOutcome::Completed(route)); + + assert_eq!( + connect(&session, allowed, synthetic), + Ok(SocketOutcome::Completed(SocketConnectionStatus::Connecting)) + ); + assert_eq!( + provider + .state + .platform_destinations + .lock() + .unwrap() + .as_slice(), + [route] + ); + + let denied = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + let denied_route = PlatformSocketDestination::External { + guest_address: synthetic, + policy_address: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 8), 443), + host_address: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 8), 443), + }; + provider.return_next_route(SocketOutcome::Completed(denied_route)); + assert_eq!( + connect(&session, denied, synthetic), + Ok(SocketOutcome::Failed(SocketError::PolicyDenied)) + ); + assert_eq!( + provider + .state + .platform_destinations + .lock() + .unwrap() + .as_slice(), + [route] + ); + + let unmapped = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + provider.return_next_route(SocketOutcome::Failed(SocketError::ConnectionRefused)); + assert_eq!( + connect(&session, unmapped, synthetic), + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + ); + assert_eq!( + provider.state.route_requests.lock().unwrap().as_slice(), + [synthetic, synthetic, synthetic] + ); +} + +#[test] +fn broker_dns_route_is_udp_only_and_does_not_require_native_policy() { + let provider = Arc::new(TestSocketProvider::default()); + provider.enable_broker_dns(); + let broker = test_broker(Arc::clone(&provider) as Arc); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let dns = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + let udp = create( + &session, + create_udp_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + provider.return_next_route(SocketOutcome::Completed( + PlatformSocketDestination::BrokerDns(dns), + )); + assert_eq!( + send_to(&session, udp, b"query".to_vec(), SendFlags::NONE, Some(dns),), + Ok(SocketOutcome::Completed(5)) + ); + + let tcp = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + provider.return_next_route(SocketOutcome::Completed( + PlatformSocketDestination::BrokerDns(dns), + )); + assert_eq!( + connect(&session, tcp, dns), + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + ); +} + +#[test] +fn broker_dns_route_requires_provider_capability() { + let provider = Arc::new(TestSocketProvider::default()); + let broker = test_broker(Arc::clone(&provider) as Arc); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let dns = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + let udp = create( + &session, + create_udp_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + provider.return_next_route(SocketOutcome::Completed( + PlatformSocketDestination::BrokerDns(dns), + )); + + assert_eq!( + send_to(&session, udp, b"query".to_vec(), SendFlags::NONE, Some(dns)), + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + ); +} + +#[test] +fn broker_dns_endpoint_is_reserved_before_policy_for_standard_routes() { + let provider = Arc::new(TestSocketProvider::default()); + let dns = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + let policy = crate::SocketPolicy::guest_network() + .with_tcp_destination_rules(&[destination_rule(*dns.ip(), dns.port())]) + .unwrap() + .with_udp_destination_rules(&[destination_rule(*dns.ip(), dns.port())]) + .unwrap(); + let broker = test_broker_with_policy(Arc::clone(&provider) as Arc, &policy); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let tcp = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + let udp = create( + &session, + create_udp_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + + assert_eq!( + connect(&session, tcp, dns), + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + ); + assert_eq!( + send_to(&session, udp, b"x".to_vec(), SendFlags::NONE, Some(dns)), + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + ); + assert_eq!(provider.state.connect_calls.load(Ordering::Relaxed), 0); + assert_eq!(provider.state.send_calls.load(Ordering::Relaxed), 0); +} + +#[test] +fn provider_cannot_change_port_or_pin_to_broker_dns() { + let provider = Arc::new(TestSocketProvider::default()); + let requested = SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 1), 443); + let pinned_ip = Ipv4Addr::new(203, 0, 113, 7); + let dns = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + let policy = crate::SocketPolicy::guest_network() + .with_tcp_destination_rules(&[ + destination_rule(pinned_ip, 22), + destination_rule(*dns.ip(), dns.port()), + ]) + .unwrap(); + let broker = test_broker_with_policy(Arc::clone(&provider) as Arc, &policy); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + + let wrong_port = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + let pinned = SocketAddrV4::new(pinned_ip, 22); + provider.return_next_route(SocketOutcome::Completed( + PlatformSocketDestination::External { + guest_address: requested, + policy_address: pinned, + host_address: pinned, + }, + )); + assert_eq!( + connect(&session, wrong_port, requested), + Err(BrokerError::Internal) + ); + + let broker_dns_target = create( + &session, + create_request(), + Arc::new(TestReadinessSink::default()), + ) + .unwrap(); + provider.return_next_route(SocketOutcome::Completed( + PlatformSocketDestination::External { + guest_address: requested, + policy_address: dns, + host_address: dns, + }, + )); + assert_eq!( + connect(&session, broker_dns_target, requested), + Ok(SocketOutcome::Failed(SocketError::ConnectionRefused)) + ); + assert_eq!(provider.state.connect_calls.load(Ordering::Relaxed), 0); +} + #[test] fn guest_transport_port_namespaces_are_broker_wide_and_independent() { let ports = BrokerSocketPorts::default(); @@ -546,6 +785,7 @@ struct TestSocketState { next_send_count: StdMutex>, connect_calls: AtomicUsize, connect_destinations: StdMutex>, + platform_destinations: StdMutex>, connect_source_addresses: StdMutex>>, send_destinations: StdMutex>>, send_calls: AtomicUsize, @@ -580,6 +820,9 @@ struct TestSocketState { live_readiness: StdMutex>, queue_next_guest_connect: core::sync::atomic::AtomicBool, pending_accept: StdMutex>, + route_requests: StdMutex>, + next_route: StdMutex>>, + broker_dns_enabled: core::sync::atomic::AtomicBool, } struct PendingAcceptedConnection { @@ -631,6 +874,14 @@ fn invalid_address(address: SocketAddrV4, kind: TestInvalidAddress) -> SocketAdd } impl TestSocketProvider { + fn enable_broker_dns(&self) { + self.state.broker_dns_enabled.store(true, Ordering::Relaxed); + } + + fn return_next_route(&self, route: SocketOutcome) { + *self.state.next_route.lock().unwrap() = Some(route); + } + fn fail_next_create(&self) { self.state.fail_create.store(true, Ordering::Relaxed); } @@ -695,6 +946,30 @@ impl TestSocketProvider { } impl SocketProvider for TestSocketProvider { + fn capabilities(&self) -> BrokerCapabilities { + if self.state.broker_dns_enabled.load(Ordering::Relaxed) { + BrokerCapabilities::BROKER_DNS + } else { + BrokerCapabilities::NONE + } + } + + fn route_destination( + &self, + destination: SocketAddrV4, + ) -> Result> { + self.state.route_requests.lock().unwrap().push(destination); + Ok(self + .state + .next_route + .lock() + .unwrap() + .take() + .unwrap_or_else(|| { + SocketOutcome::Completed(PlatformSocketDestination::standard(destination)) + })) + } + fn create( &self, session_id: SessionId, @@ -861,9 +1136,15 @@ impl PlatformSocket for TestPlatformSocket { fn connect( &self, - address: SocketAddrV4, + destination: PlatformSocketDestination, guest_source_lease: Option, ) -> core::result::Result { + let address = destination.guest_address(); + self.state + .platform_destinations + .lock() + .unwrap() + .push(destination); self.state.connect_calls.fetch_add(1, Ordering::Relaxed); self.state .connect_destinations @@ -942,14 +1223,21 @@ impl PlatformSocket for TestPlatformSocket { &self, data: Vec, _flags: SendFlags, - destination: Option, + destination: Option, ) -> Result> { + if let Some(destination) = destination { + self.state + .platform_destinations + .lock() + .unwrap() + .push(destination); + } self.state.send_calls.fetch_add(1, Ordering::Relaxed); self.state .send_destinations .lock() .unwrap() - .push(destination); + .push(destination.map(PlatformSocketDestination::guest_address)); self.state.sent.lock().unwrap().extend_from_slice(&data); let sent = self .state diff --git a/litebox_broker_host/src/lib.rs b/litebox_broker_host/src/lib.rs index 24c114f86..43b08334b 100644 --- a/litebox_broker_host/src/lib.rs +++ b/litebox_broker_host/src/lib.rs @@ -218,6 +218,7 @@ where let response = if negotiated { BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: core.capabilities(), } } else { BrokerHandshakeResponse::VersionMismatch { @@ -698,7 +699,7 @@ mod tests { use litebox_broker_core::readiness::ReadinessRegistration; use litebox_broker_core::socket::{ AcceptedPlatformSocket, PlatformConnectError, PlatformDatagramReceive, PlatformSocket, - PlatformSocketStatus, PlatformStreamReceive, SocketProvider, + PlatformSocketDestination, PlatformSocketStatus, PlatformStreamReceive, SocketProvider, }; use litebox_broker_core::{ObjectRights, PolicyEngine, SessionId, SocketPolicy}; use litebox_broker_protocol::event::{ @@ -757,6 +758,23 @@ mod tests { struct TestSocketProvider; impl SocketProvider for TestSocketProvider { + fn capabilities(&self) -> litebox_broker_protocol::BrokerCapabilities { + litebox_broker_protocol::BrokerCapabilities::BROKER_DNS + } + + fn route_destination( + &self, + destination: core::net::SocketAddrV4, + ) -> litebox_broker_core::Result< + litebox_broker_protocol::socket::SocketOutcome< + litebox_broker_core::socket::PlatformSocketDestination, + >, + > { + Ok(litebox_broker_protocol::socket::SocketOutcome::Completed( + litebox_broker_core::socket::PlatformSocketDestination::standard(destination), + )) + } + fn create( &self, _session_id: SessionId, @@ -821,9 +839,10 @@ mod tests { fn connect( &self, - address: SocketAddrV4, + destination: PlatformSocketDestination, _guest_source_lease: Option, ) -> core::result::Result { + let address = destination.guest_address(); let source_address = self .binding .lock() @@ -855,7 +874,7 @@ mod tests { &self, data: Vec, _flags: SendFlags, - _destination: Option, + _destination: Option, ) -> litebox_broker_core::Result> { Ok(SocketOutcome::Completed(data.len())) } @@ -970,7 +989,8 @@ mod tests { assert_eq!( channel.handshake_responses[0], BrokerHandshakeResponse::Negotiated { - broker_protocol_version: BROKER_PROTOCOL_VERSION + broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::BROKER_DNS, } ); let handle = match &channel.results[0] { @@ -1004,7 +1024,8 @@ mod tests { broker_protocol_version: BROKER_PROTOCOL_VERSION }, BrokerHandshakeResponse::Negotiated { - broker_protocol_version: BROKER_PROTOCOL_VERSION + broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::BROKER_DNS, } ] ); @@ -1069,7 +1090,8 @@ mod tests { assert_eq!( channel.handshake_responses, [BrokerHandshakeResponse::Negotiated { - broker_protocol_version: BROKER_PROTOCOL_VERSION + broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::BROKER_DNS, }] ); assert!(channel.results.is_empty()); diff --git a/litebox_broker_local/src/lib.rs b/litebox_broker_local/src/lib.rs index 1f1509855..ee64fc1ed 100644 --- a/litebox_broker_local/src/lib.rs +++ b/litebox_broker_local/src/lib.rs @@ -35,7 +35,9 @@ use litebox_broker_protocol::message::{ }; use litebox_broker_protocol::readiness::ReadinessFlags; use litebox_broker_protocol::shared_buffer::SHARED_BUFFER_LAYOUT; -use litebox_broker_protocol::{BROKER_PROTOCOL_VERSION, ObjectHandle, RequestId}; +use litebox_broker_protocol::{ + BROKER_PROTOCOL_VERSION, BrokerCapabilities, ObjectHandle, RequestId, +}; use litebox_broker_transport::channel::{ LocalCallChannel, LocalNotificationChannel, LocalSetupChannel, }; @@ -74,7 +76,7 @@ impl BrokerLocal { /// response that does not match the negotiation request, or setup returns /// shared memory with an invalid size. pub fn negotiate, Activated>( - mut setup: Setup, + setup: Setup, activate: impl FnOnce( Setup, ) -> core::result::Result< @@ -82,6 +84,29 @@ impl BrokerLocal { Channel::Error, >, ) -> Result<(Self, Activated), Channel::Error> { + let (local, _capabilities, activated) = Self::negotiate_with_capabilities(setup, activate)?; + Ok((local, activated)) + } + + /// Negotiates the broker protocol and returns the negotiated capabilities. + /// + /// # Panics + /// + /// Panics if the broker reports an unrecoverable error, returns a protocol + /// response that does not match the negotiation request, or setup returns + /// shared memory with an invalid size. + pub fn negotiate_with_capabilities< + Setup: LocalSetupChannel, + Activated, + >( + mut setup: Setup, + activate: impl FnOnce( + Setup, + ) -> core::result::Result< + (Channel, Arc, Activated), + Channel::Error, + >, + ) -> Result<(Self, BrokerCapabilities, Activated), Channel::Error> { let requested = BROKER_PROTOCOL_VERSION; let request = BrokerHandshakeRequest { protocol_version: requested, @@ -96,6 +121,7 @@ impl BrokerLocal { { response @ BrokerHandshakeResponse::Negotiated { broker_protocol_version, + capabilities, } => { assert_eq!( requested, broker_protocol_version, @@ -111,6 +137,7 @@ impl BrokerLocal { shared_buffers, next_request_id: AtomicU64::new(0), }, + capabilities, activated, )) } @@ -254,6 +281,7 @@ mod tests { let channel = FakeControlChannel::new( Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: BrokerCapabilities::NONE, }), None, ); @@ -276,6 +304,25 @@ mod tests { assert_eq!(setup_calls.get(), 1); } + #[test] + fn negotiate_returns_broker_capabilities() { + let channel = FakeControlChannel::new( + Some(BrokerHandshakeResponse::Negotiated { + broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: BrokerCapabilities::BROKER_DNS, + }), + None, + ); + + let (_local, capabilities, ()) = + BrokerLocal::negotiate_with_capabilities(channel, |channel| { + Ok((channel, noop_shared_memory(), ())) + }) + .unwrap(); + + assert_eq!(capabilities, BrokerCapabilities::BROKER_DNS); + } + #[test] fn close_object_sends_close_object_request() { let handle = ObjectHandle(7); @@ -432,6 +479,7 @@ mod tests { let channel = FakeControlChannel::new( Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version, + capabilities: BrokerCapabilities::NONE, }), None, ); @@ -505,6 +553,7 @@ mod tests { let channel = FakeControlChannel::new( Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: BrokerCapabilities::NONE, }), None, ); @@ -527,6 +576,7 @@ mod tests { let channel = FakeControlChannel::new( Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: BrokerCapabilities::NONE, }), None, ); @@ -691,6 +741,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: BrokerCapabilities::NONE, })) } } diff --git a/litebox_broker_local/src/pipe.rs b/litebox_broker_local/src/pipe.rs index ac68055b4..185afc8ed 100644 --- a/litebox_broker_local/src/pipe.rs +++ b/litebox_broker_local/src/pipe.rs @@ -353,6 +353,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, })) } } diff --git a/litebox_broker_local/src/socket.rs b/litebox_broker_local/src/socket.rs index 97e6e8fd1..d445a7930 100644 --- a/litebox_broker_local/src/socket.rs +++ b/litebox_broker_local/src/socket.rs @@ -312,6 +312,7 @@ mod tests { ) -> core::result::Result, Self::Error> { Ok(Some(BrokerHandshakeResponse::Negotiated { broker_protocol_version: litebox_broker_protocol::BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, })) } } diff --git a/litebox_broker_platform_linux_userland/src/lib.rs b/litebox_broker_platform_linux_userland/src/lib.rs index 7be7622a8..baf2bcd14 100644 --- a/litebox_broker_platform_linux_userland/src/lib.rs +++ b/litebox_broker_platform_linux_userland/src/lib.rs @@ -11,4 +11,4 @@ mod socket; -pub use socket::LinuxSocketProvider; +pub use socket::{DnsARecord, LinuxSocketProvider}; diff --git a/litebox_broker_platform_linux_userland/src/socket.rs b/litebox_broker_platform_linux_userland/src/socket.rs index 304cd6e0b..95edd8804 100644 --- a/litebox_broker_platform_linux_userland/src/socket.rs +++ b/litebox_broker_platform_linux_userland/src/socket.rs @@ -17,10 +17,11 @@ use std::time::Duration; use litebox_broker_core::socket::{ AcceptedPlatformSocket, GuestSocketBinding, GuestSourceLease, PlatformConnectError, - PlatformDatagramReceive, PlatformSocket, PlatformSocketStatus, PlatformStreamReceive, - SocketProvider, + PlatformDatagramReceive, PlatformSocket, PlatformSocketDestination, PlatformSocketStatus, + PlatformStreamReceive, SocketProvider, }; use litebox_broker_core::{BrokerError, Result as BrokerResult, SessionId}; +use litebox_broker_protocol::BrokerCapabilities; use litebox_broker_protocol::readiness::ReadinessFlags; use litebox_broker_protocol::socket::{ AddressFamily, CreateSocketRequest, IpProtocol, MAX_UDP_DATAGRAM_SIZE, ReceiveFlags, @@ -36,9 +37,13 @@ use litebox_broker_core::readiness::ReadinessRegistration; #[cfg(test)] use litebox_broker_protocol::socket::{MAX_SOCKET_TRANSFER_SIZE, SocketStatusResponse}; +mod dns; mod tcp; mod udp; +pub use dns::DnsARecord; +use dns::DnsMappings; + #[cfg(test)] use tcp::TcpDescriptor; use tcp::{ @@ -165,18 +170,49 @@ impl SocketLifecycle { /// reactor acknowledgment, never for network readiness. pub struct LinuxSocketProvider { reactor: Arc, + dns: Arc, } impl LinuxSocketProvider { /// Starts a provider with global and per-session socket limits. pub fn new(max_sockets: usize, max_sockets_per_session: usize) -> IoResult { + Self::new_with_dns_records(max_sockets, max_sockets_per_session, &[]) + } + + /// Starts a provider with socket limits and immutable static DNS A records. + pub fn new_with_dns_records( + max_sockets: usize, + max_sockets_per_session: usize, + dns_records: &[DnsARecord], + ) -> IoResult { + let dns = Arc::new(DnsMappings::new(dns_records)?); Ok(Self { - reactor: Arc::new(ReactorClient::start(max_sockets, max_sockets_per_session)?), + reactor: Arc::new(ReactorClient::start( + max_sockets, + max_sockets_per_session, + Arc::clone(&dns), + )?), + dns, }) } } impl SocketProvider for LinuxSocketProvider { + fn capabilities(&self) -> BrokerCapabilities { + if self.dns.is_enabled() { + BrokerCapabilities::BROKER_DNS + } else { + BrokerCapabilities::NONE + } + } + + fn route_destination( + &self, + destination: SocketAddrV4, + ) -> BrokerResult> { + Ok(self.dns.route_destination(destination)) + } + fn create( &self, session_id: SessionId, @@ -277,10 +313,11 @@ impl PlatformSocket for LinuxSocket { fn connect( &self, - address: SocketAddrV4, + destination: PlatformSocketDestination, guest_source_lease: Option, ) -> core::result::Result { - self.reactor.connect(self.id, address, guest_source_lease) + self.reactor + .connect(self.id, destination, guest_source_lease) } fn send(&self, data: Vec, _flags: SendFlags) -> BrokerResult> { @@ -295,7 +332,7 @@ impl PlatformSocket for LinuxSocket { &self, data: Vec, _flags: SendFlags, - destination: Option, + destination: Option, ) -> BrokerResult> { self.reactor.request(|response| ReactorCommand::SendTo { id: self.id, @@ -417,7 +454,11 @@ struct ReactorClient { } impl ReactorClient { - fn start(max_sockets: usize, max_sockets_per_session: usize) -> IoResult { + fn start( + max_sockets: usize, + max_sockets_per_session: usize, + dns: Arc, + ) -> IoResult { let epoll_fd = epoll::create(epoll::CreateFlags::CLOEXEC)?; let wake = Arc::new(eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?); epoll::add( @@ -452,6 +493,7 @@ impl ReactorClient { sessions: HashMap::new(), max_sockets, max_sockets_per_session, + dns, events, }; if started.send(true).is_err() { @@ -518,13 +560,13 @@ impl ReactorClient { fn connect( &self, id: u64, - address: SocketAddrV4, + destination: PlatformSocketDestination, guest_source_lease: Option, ) -> core::result::Result { let (response, receive) = sync_channel(1); let command = ReactorCommand::Connect { id, - address, + destination, guest_source_lease, response, }; @@ -782,7 +824,7 @@ enum ReactorCommand { }, Connect { id: u64, - address: SocketAddrV4, + destination: PlatformSocketDestination, guest_source_lease: Option, response: SyncSender>, }, @@ -812,7 +854,7 @@ enum ReactorCommand { SendTo { id: u64, data: Vec, - destination: Option, + destination: Option, response: SyncSender>>, }, Receive { @@ -942,6 +984,7 @@ struct Reactor { sessions: HashMap, max_sockets: usize, max_sockets_per_session: usize, + dns: Arc, events: Vec, } @@ -1100,7 +1143,7 @@ impl Reactor { fn connect_socket( &mut self, id: u64, - requested_destination: SocketAddrV4, + requested_destination: PlatformSocketDestination, guest_source_lease: Option, ) -> core::result::Result { let kind = self.sockets.get(&id).map(SocketEntry::kind).ok_or( @@ -1115,7 +1158,7 @@ impl Reactor { }; let mut reused_native_endpoint = false; let mut staged_endpoint = match peer { - ReactorUdpPeer::Internal { .. } => None, + ReactorUdpPeer::Internal { .. } | ReactorUdpPeer::BrokerDns(_) => None, ReactorUdpPeer::External(destination) => { if self .sockets @@ -1164,7 +1207,8 @@ impl Reactor { ReactorUdpPeer::Internal { internal_address, .. } => internal_address, - ReactorUdpPeer::External(destination) => destination, + ReactorUdpPeer::External(destination) => destination.guest_address, + ReactorUdpPeer::BrokerDns(address) => address, }; binding .guest_binding @@ -1210,9 +1254,9 @@ impl Reactor { &mut self, id: u64, data: &[u8], - destination: Option, + destination: Option, ) -> BrokerResult> { - let (peer, authorize_external_reply) = { + let (peer, connected_peer, read_shutdown, authorize_external_reply) = { let socket = self.sockets.get(&id).ok_or(BrokerError::Internal)?; if socket.kind() != SocketKind::Udp || data.len() > MAX_UDP_DATAGRAM_SIZE as usize { return Ok(SocketOutcome::Failed(SocketError::InvalidArgument)); @@ -1223,11 +1267,13 @@ impl Reactor { let udp = socket.udp_state()?; match destination { Some(address) => match self.resolve_udp_destination(address) { - SocketOutcome::Completed(peer) => (peer, udp.peer.is_none()), + SocketOutcome::Completed(peer) => { + (peer, udp.peer, socket.read_shutdown, udp.peer.is_none()) + } SocketOutcome::Failed(error) => return Ok(SocketOutcome::Failed(error)), }, None => match udp.peer { - Some(peer) => (peer, false), + Some(peer) => (peer, udp.peer, socket.read_shutdown, false), None => return Ok(SocketOutcome::Failed(SocketError::NotConnected)), }, } @@ -1256,8 +1302,21 @@ impl Reactor { self.enqueue_internal_datagram(id, destination_id, source_address, data) } ReactorUdpPeer::External(destination) => { + if matches!( + connected_peer, + Some(ReactorUdpPeer::External(connected)) + if connected.host_address == destination.host_address + && connected.guest_address != destination.guest_address + ) { + return Ok(SocketOutcome::Failed(SocketError::AddressNotAvailable)); + } let external_peer_added = if authorize_external_reply { - self.reserve_udp_external_peer(id, destination)? + match self.reserve_udp_external_peer(id, destination)? { + SocketOutcome::Completed(added) => added, + SocketOutcome::Failed(error) => { + return Ok(SocketOutcome::Failed(error)); + } + } } else { false }; @@ -1293,6 +1352,26 @@ impl Reactor { } outcome } + ReactorUdpPeer::BrokerDns(source_address) => { + if read_shutdown + || matches!( + connected_peer, + Some(peer) if !matches!( + peer, + ReactorUdpPeer::BrokerDns(address) if address == source_address + ) + ) + { + return Ok(SocketOutcome::Completed(data.len())); + } + let Some(response) = self.dns.response(data)? else { + return Ok(SocketOutcome::Completed(data.len())); + }; + match self.enqueue_dns_response(id, source_address, &response)? { + SocketOutcome::Completed(_) => Ok(SocketOutcome::Completed(data.len())), + SocketOutcome::Failed(error) => Ok(SocketOutcome::Failed(error)), + } + } } } @@ -1527,11 +1606,11 @@ impl Reactor { } ReactorCommand::Connect { id, - address, + destination, guest_source_lease, response, } => { - let outcome = self.connect_socket(id, address, guest_source_lease); + let outcome = self.connect_socket(id, destination, guest_source_lease); let peer_may_have_changed = !matches!(&outcome, Err(PlatformConnectError::PeerUnchanged(_))); if response.send(outcome).is_err() && peer_may_have_changed { diff --git a/litebox_broker_platform_linux_userland/src/socket/dns.rs b/litebox_broker_platform_linux_userland/src/socket/dns.rs new file mode 100644 index 000000000..a8a9d652f --- /dev/null +++ b/litebox_broker_platform_linux_userland/src/socket/dns.rs @@ -0,0 +1,588 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +use std::fmt; +use std::io::{Error, ErrorKind, Result as IoResult}; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::str::FromStr; + +use litebox_broker_core::socket::{ + BROKER_DNS_IPV4_ADDRESS, PlatformSocketDestination, host_socket_destination, + is_internal_socket_address, normalize_socket_destination, +}; +use litebox_broker_core::{BrokerError, Result as BrokerResult}; +use litebox_broker_protocol::socket::{SocketError, SocketOutcome}; + +pub(super) const MAX_DNS_A_RECORDS: usize = 64; +const MAX_DNS_QUERY_SIZE: usize = 1232; +const DNS_HEADER_SIZE: usize = 12; +const DNS_TTL_SECONDS: u32 = 300; +const SYNTHETIC_NETWORK: [u8; 3] = [198, 51, 100]; + +/// One exact static IPv4 DNS record exposed by the broker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DnsARecord { + name: String, + address: Ipv4Addr, +} + +impl DnsARecord { + /// Returns the canonical record name without a trailing dot. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the pinned native IPv4 address. + #[must_use] + pub fn address(&self) -> Ipv4Addr { + self.address + } +} + +/// Error returned when parsing a static DNS A record. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DnsARecordParseError(&'static str); + +impl fmt::Display for DnsARecordParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for DnsARecordParseError {} + +impl FromStr for DnsARecord { + type Err = DnsARecordParseError; + + fn from_str(value: &str) -> Result { + let (name, address) = value + .split_once('=') + .ok_or(DnsARecordParseError("DNS record must use NAME=IP syntax"))?; + let name = canonical_dns_name(name)?; + let address = address + .parse() + .map_err(|_| DnsARecordParseError("DNS record address must be IPv4"))?; + Ok(Self { name, address }) + } +} + +struct DnsMapping { + name: String, + native_address: Ipv4Addr, + synthetic_address: Ipv4Addr, +} + +pub(super) struct DnsMappings { + records: Vec, +} + +impl DnsMappings { + pub(super) fn new(records: &[DnsARecord]) -> IoResult { + if records.len() > MAX_DNS_A_RECORDS { + return Err(Error::new( + ErrorKind::InvalidInput, + "too many static DNS records", + )); + } + + let mut mappings = Vec::new(); + mappings + .try_reserve_exact(records.len()) + .map_err(|_| Error::new(ErrorKind::OutOfMemory, "DNS mapping allocation failed"))?; + for (index, record) in records.iter().enumerate() { + if mappings + .iter() + .any(|mapping: &DnsMapping| mapping.name == record.name) + { + return Err(Error::new( + ErrorKind::InvalidInput, + "duplicate static DNS record name", + )); + } + if mappings + .iter() + .any(|mapping| mapping.native_address == record.address) + { + return Err(Error::new( + ErrorKind::InvalidInput, + "duplicate static DNS destination", + )); + } + if !is_valid_native_address(record.address) { + return Err(Error::new( + ErrorKind::InvalidInput, + "invalid static DNS destination", + )); + } + let host = u8::try_from(index + 1).expect("DNS record limit fits in one octet"); + let mut name = String::new(); + name.try_reserve_exact(record.name.len()) + .map_err(|_| Error::new(ErrorKind::OutOfMemory, "DNS name allocation failed"))?; + name.push_str(&record.name); + mappings.push(DnsMapping { + name, + native_address: record.address, + synthetic_address: Ipv4Addr::new( + SYNTHETIC_NETWORK[0], + SYNTHETIC_NETWORK[1], + SYNTHETIC_NETWORK[2], + host, + ), + }); + } + Ok(Self { records: mappings }) + } + + pub(super) fn route_destination( + &self, + destination: SocketAddrV4, + ) -> SocketOutcome { + if !self.is_enabled() { + return SocketOutcome::Completed(PlatformSocketDestination::standard(destination)); + } + if *destination.ip() == BROKER_DNS_IPV4_ADDRESS { + return if destination.port() == 53 { + SocketOutcome::Completed(PlatformSocketDestination::BrokerDns(destination)) + } else { + SocketOutcome::Failed(SocketError::ConnectionRefused) + }; + } + + if is_synthetic_address(*destination.ip()) { + let Some(mapping) = self + .records + .iter() + .find(|mapping| mapping.synthetic_address == *destination.ip()) + else { + return SocketOutcome::Failed(SocketError::ConnectionRefused); + }; + let policy_address = SocketAddrV4::new(mapping.native_address, destination.port()); + return SocketOutcome::Completed(PlatformSocketDestination::External { + guest_address: destination, + policy_address, + host_address: host_socket_destination(policy_address), + }); + } + SocketOutcome::Completed(PlatformSocketDestination::standard(destination)) + } + + pub(super) fn is_enabled(&self) -> bool { + !self.records.is_empty() + } + + pub(super) fn response(&self, query: &[u8]) -> BrokerResult>> { + if query.len() < DNS_HEADER_SIZE || query.len() > MAX_DNS_QUERY_SIZE { + return Ok(None); + } + let request_flags = read_u16(query, 2); + if request_flags & 0xf800 != 0 + || read_u16(query, 4) != 1 + || read_u16(query, 6) != 0 + || read_u16(query, 8) != 0 + { + return Ok(None); + } + let Some(question) = parse_question_name(query)? else { + return Ok(None); + }; + let question_end = question + .name_end + .checked_add(4) + .ok_or(BrokerError::Internal)?; + if question_end > query.len() { + return Ok(None); + } + let question_type = read_u16(query, question.name_end); + let question_class = read_u16(query, question.name_end + 2); + let (response_code, answer) = if question_class == 1 { + match question + .lookup_name + .as_deref() + .and_then(|name| self.records.iter().find(|mapping| mapping.name == name)) + { + Some(mapping) => (0, (question_type == 1).then_some(mapping.synthetic_address)), + None => (3, None), + } + } else { + (4, None) + }; + + let answer_size = if answer.is_some() { 16 } else { 0 }; + let mut response = Vec::new(); + response + .try_reserve_exact(question_end + answer_size) + .map_err(|_| BrokerError::OutOfMemory)?; + response.extend_from_slice(&query[..2]); + append_u16( + &mut response, + 0x8400 | (request_flags & 0x0100) | response_code, + ); + append_u16(&mut response, 1); + append_u16(&mut response, u16::from(answer.is_some())); + append_u16(&mut response, 0); + append_u16(&mut response, 0); + response.extend_from_slice(&query[DNS_HEADER_SIZE..question_end]); + if let Some(address) = answer { + append_u16(&mut response, 0xc00c); + append_u16(&mut response, 1); + append_u16(&mut response, 1); + response.extend_from_slice(&DNS_TTL_SECONDS.to_be_bytes()); + append_u16(&mut response, 4); + response.extend_from_slice(&address.octets()); + } + Ok(Some(response)) + } +} + +fn canonical_dns_name(name: &str) -> Result { + let name = name.strip_suffix('.').unwrap_or(name); + if name.is_empty() || name.len() > 253 || !name.is_ascii() { + return Err(DnsARecordParseError("invalid DNS record name")); + } + for label in name.split('.') { + if label.is_empty() + || label.len() > 63 + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || label.starts_with('-') + || label.ends_with('-') + { + return Err(DnsARecordParseError("invalid DNS record name")); + } + } + let canonical = name.to_ascii_lowercase(); + if canonical == "localhost" + || canonical == "litebox" + || canonical.ends_with(".localhost") + || is_numeric_looking_name(&canonical) + { + return Err(DnsARecordParseError("DNS record name bypasses broker DNS")); + } + Ok(canonical) +} + +fn is_numeric_looking_name(name: &str) -> bool { + name.split('.').all(|component| { + !component.is_empty() + && (component.bytes().all(|byte| byte.is_ascii_digit()) + || component.strip_prefix("0x").is_some_and(|hex| { + !hex.is_empty() && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + })) + }) +} + +struct ParsedQuestion { + lookup_name: Option, + name_end: usize, +} + +fn parse_question_name(query: &[u8]) -> BrokerResult> { + let mut offset = DNS_HEADER_SIZE; + let mut expanded_length = 0usize; + let mut label_count = 0usize; + let mut lookup_name_valid = true; + loop { + let Some(length) = query.get(offset).copied().map(usize::from) else { + return Ok(None); + }; + let Some(next_offset) = offset.checked_add(1) else { + return Ok(None); + }; + offset = next_offset; + if length == 0 { + break; + } + if length > 63 { + return Ok(None); + } + let Some(end) = offset.checked_add(length) else { + return Ok(None); + }; + let Some(label) = query.get(offset..end) else { + return Ok(None); + }; + lookup_name_valid &= label + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + && label.first() != Some(&b'-') + && label.last() != Some(&b'-'); + let Some(length) = expanded_length.checked_add(length + usize::from(label_count != 0)) + else { + return Ok(None); + }; + expanded_length = length; + if expanded_length > 253 { + return Ok(None); + } + label_count += 1; + offset = end; + } + if label_count == 0 { + return Ok(None); + } + let lookup_name = if lookup_name_valid { + let mut name = String::new(); + name.try_reserve_exact(expanded_length) + .map_err(|_| BrokerError::OutOfMemory)?; + let mut label_offset = DNS_HEADER_SIZE; + for label_index in 0..label_count { + let length = usize::from(query[label_offset]); + label_offset += 1; + if label_index != 0 { + name.push('.'); + } + for byte in &query[label_offset..label_offset + length] { + name.push(char::from(byte.to_ascii_lowercase())); + } + label_offset += length; + } + Some(name) + } else { + None + }; + Ok(Some(ParsedQuestion { + lookup_name, + name_end: offset, + })) +} + +fn is_valid_native_address(address: Ipv4Addr) -> bool { + let destination = SocketAddrV4::new(address, 1); + normalize_socket_destination(destination) == Ok(destination) + && !is_internal_socket_address(destination) + && address != BROKER_DNS_IPV4_ADDRESS + && !is_synthetic_address(address) +} + +fn is_synthetic_address(address: Ipv4Addr) -> bool { + let octets = address.octets(); + octets[..3] == SYNTHETIC_NETWORK +} + +fn read_u16(data: &[u8], offset: usize) -> u16 { + u16::from_be_bytes([data[offset], data[offset + 1]]) +} + +fn append_u16(data: &mut Vec, value: u16) { + data.extend_from_slice(&value.to_be_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(value: &str) -> DnsARecord { + value.parse().unwrap() + } + + fn query(name: &str, question_type: u16) -> Vec { + let labels = name.split('.').map(str::as_bytes).collect::>(); + raw_query(&labels, question_type, 1) + } + + fn raw_query(labels: &[&[u8]], question_type: u16, question_class: u16) -> Vec { + let mut query = Vec::from([ + 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + for label in labels { + query.push(u8::try_from(label.len()).unwrap()); + query.extend_from_slice(label); + } + query.push(0); + append_u16(&mut query, question_type); + append_u16(&mut query, question_class); + query + } + + #[test] + fn static_record_parser_canonicalizes_and_validates_names() { + let record = record("Service.Example.=203.0.113.7"); + assert_eq!(record.name(), "service.example"); + assert_eq!(record.address(), Ipv4Addr::new(203, 0, 113, 7)); + + for invalid in [ + "missing-address", + "=203.0.113.7", + "-service.example=203.0.113.7", + "service..example=203.0.113.7", + "service.example=not-an-address", + "localhost=203.0.113.7", + "LOCALHOST.=203.0.113.7", + "child.localhost=203.0.113.7", + "litebox=203.0.113.7", + "127.1=203.0.113.7", + "0177.1=203.0.113.7", + "0x7f.1=203.0.113.7", + "2130706433=203.0.113.7", + ] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + } + + #[test] + fn empty_mappings_preserve_standard_provider_routing() { + let mappings = DnsMappings::new(&[]).unwrap(); + for destination in [ + SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53), + SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 80), + SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 1), 443), + ] { + assert_eq!( + mappings.route_destination(destination), + SocketOutcome::Completed(PlatformSocketDestination::standard(destination)) + ); + } + assert!(!mappings.is_enabled()); + } + + #[test] + fn mappings_pin_names_and_fail_closed_for_unassigned_synthetic_addresses() { + let mappings = DnsMappings::new(&[record("service.example=203.0.113.7")]).unwrap(); + let synthetic = SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 1), 443); + assert_eq!( + mappings.route_destination(synthetic), + SocketOutcome::Completed(PlatformSocketDestination::External { + guest_address: synthetic, + policy_address: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 443), + host_address: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 7), 443), + }) + ); + assert_eq!( + mappings.route_destination(SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 64), 443,)), + SocketOutcome::Failed(SocketError::ConnectionRefused) + ); + assert_eq!( + mappings.route_destination(SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53)), + SocketOutcome::Completed(PlatformSocketDestination::BrokerDns(SocketAddrV4::new( + BROKER_DNS_IPV4_ADDRESS, + 53 + ))) + ); + } + + #[test] + fn mappings_reject_duplicates_reserved_destinations_and_excess_records() { + assert!( + DnsMappings::new(&[ + record("service.example=203.0.113.7"), + record("SERVICE.EXAMPLE=203.0.113.8"), + ]) + .is_err() + ); + assert!( + DnsMappings::new(&[ + record("first.example=203.0.113.7"), + record("second.example=203.0.113.7"), + ]) + .is_err() + ); + assert!(DnsMappings::new(&[record("service.example=127.0.0.1")]).is_err()); + assert!(DnsMappings::new(&[record("service.example=198.51.100.8")]).is_err()); + + let records = (0..=MAX_DNS_A_RECORDS) + .map(|index| record(&format!("service-{index}.example=203.0.113.{}", index + 1))) + .collect::>(); + assert!(DnsMappings::new(&records).is_err()); + } + + #[test] + fn mappings_assign_the_full_synthetic_range_through_dot_64() { + let records = (0..MAX_DNS_A_RECORDS) + .map(|index| record(&format!("service-{index}.example=203.0.113.{}", index + 1))) + .collect::>(); + let mappings = DnsMappings::new(&records).unwrap(); + let guest = SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 64), 443); + + assert_eq!( + mappings.route_destination(guest), + SocketOutcome::Completed(PlatformSocketDestination::External { + guest_address: guest, + policy_address: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 64), 443), + host_address: SocketAddrV4::new(Ipv4Addr::new(203, 0, 113, 64), 443), + }) + ); + let response = mappings + .response(&query("service-63.example", 1)) + .unwrap() + .unwrap(); + assert_eq!( + &response[response.len() - 4..], + &Ipv4Addr::new(198, 51, 100, 64).octets() + ); + } + + #[test] + fn dns_responder_returns_synthetic_a_and_empty_aaaa_answers() { + let mappings = DnsMappings::new(&[record("service.example=203.0.113.7")]).unwrap(); + let mixed_case_query = query("SeRvIcE.ExAmPlE", 1); + let a_response = mappings.response(&mixed_case_query).unwrap().unwrap(); + assert_eq!(read_u16(&a_response, 0), 0x1234); + assert_eq!(read_u16(&a_response, 2), 0x8500); + assert_eq!(read_u16(&a_response, 6), 1); + assert_eq!( + &a_response[a_response.len() - 4..], + &Ipv4Addr::new(198, 51, 100, 1).octets() + ); + assert_eq!( + &a_response[DNS_HEADER_SIZE..mixed_case_query.len()], + &mixed_case_query[DNS_HEADER_SIZE..] + ); + + let aaaa_response = mappings + .response(&query("service.example", 28)) + .unwrap() + .unwrap(); + assert_eq!(read_u16(&aaaa_response, 2) & 0x000f, 0); + assert_eq!(read_u16(&aaaa_response, 6), 0); + + let unknown_response = mappings + .response(&query("unknown.example", 1)) + .unwrap() + .unwrap(); + assert_eq!(read_u16(&unknown_response, 2) & 0x000f, 3); + assert_eq!(read_u16(&unknown_response, 6), 0); + + let unsupported_class = mappings + .response(&raw_query(&[b"unknown", b"example"], 1, 3)) + .unwrap() + .unwrap(); + assert_eq!(read_u16(&unsupported_class, 2) & 0x000f, 4); + } + + #[test] + fn dns_responder_drops_malformed_or_oversized_queries() { + let mappings = DnsMappings::new(&[]).unwrap(); + let mut compressed = query("service.example", 1); + compressed[DNS_HEADER_SIZE] = 0xc0; + assert_eq!(mappings.response(&compressed).unwrap(), None); + assert_eq!( + mappings.response(&vec![0; MAX_DNS_QUERY_SIZE + 1]).unwrap(), + None + ); + let mut root = Vec::from([ + 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + append_u16(&mut root, 1); + append_u16(&mut root, 1); + assert_eq!(mappings.response(&root).unwrap(), None); + } + + #[test] + fn dns_responder_answers_well_formed_unmatchable_names_with_nxdomain() { + let mappings = DnsMappings::new(&[record("service.example=203.0.113.7")]).unwrap(); + for labels in [ + &[&b"_service"[..], &b"example"[..]][..], + &[&b"\xff"[..]][..], + &[&b"service.example"[..]][..], + ] { + let response = mappings + .response(&raw_query(labels, 1, 1)) + .unwrap() + .unwrap(); + assert_eq!(read_u16(&response, 2) & 0x000f, 3); + assert_eq!(read_u16(&response, 6), 0); + } + } +} diff --git a/litebox_broker_platform_linux_userland/src/socket/tcp.rs b/litebox_broker_platform_linux_userland/src/socket/tcp.rs index 9be208fd7..c5758acc1 100644 --- a/litebox_broker_platform_linux_userland/src/socket/tcp.rs +++ b/litebox_broker_platform_linux_userland/src/socket/tcp.rs @@ -11,8 +11,7 @@ use std::time::Duration; use litebox_broker_core::readiness::ReadinessRegistration; use litebox_broker_core::socket::{ - GuestSocketBinding, GuestSourceLease, PlatformConnectError, host_socket_destination, - is_internal_socket_address, normalize_socket_destination, + GuestSocketBinding, GuestSourceLease, PlatformConnectError, PlatformSocketDestination, }; use litebox_broker_core::{BrokerError, Result as BrokerResult, SessionId}; use litebox_broker_protocol::readiness::ReadinessFlags; @@ -277,7 +276,10 @@ enum ResolvedTcpDestination { listener_id: u64, concrete_address: SocketAddrV4, }, - External(SocketAddrV4), + External { + guest_address: SocketAddrV4, + host_address: SocketAddrV4, + }, } impl ReactorTcpState { @@ -1217,7 +1219,7 @@ impl Reactor { pub(super) fn connect_tcp_destination( &mut self, id: u64, - requested_destination: SocketAddrV4, + requested_destination: PlatformSocketDestination, guest_source_lease: Option, ) -> core::result::Result { let session_id = self @@ -1265,13 +1267,16 @@ impl Reactor { ); } drop(guest_source_lease); - let ResolvedTcpDestination::External(external_destination) = destination else { + let ResolvedTcpDestination::External { + guest_address, + host_address, + } = destination + else { unreachable!("internal destination handled above"); }; - let host_destination = host_socket_destination(external_destination); let guest_local_address = binding .guest_binding - .source_address_for_destination(external_destination) + .source_address_for_destination(guest_address) .ok_or(PlatformConnectError::PeerUnchanged(BrokerError::Internal))?; let (status, readiness) = connect_external_tcp_socket( &self.epoll, @@ -1279,7 +1284,7 @@ impl Reactor { self.sockets .get_mut(&id) .ok_or(PlatformConnectError::PeerUnchanged(BrokerError::Internal))?, - host_destination, + host_address, )?; if matches!( status, @@ -1746,15 +1751,24 @@ impl Reactor { fn resolve_tcp_destination( &self, - requested_destination: SocketAddrV4, + destination: PlatformSocketDestination, ) -> SocketOutcome { - let destination = match normalize_socket_destination(requested_destination) { - Ok(destination) => destination, - Err(error) => return SocketOutcome::Failed(error), + let destination = match destination { + PlatformSocketDestination::Internal(destination) => destination, + PlatformSocketDestination::External { + guest_address, + host_address, + .. + } => { + return SocketOutcome::Completed(ResolvedTcpDestination::External { + guest_address, + host_address, + }); + } + PlatformSocketDestination::BrokerDns(_) => { + return SocketOutcome::Failed(SocketError::ConnectionRefused); + } }; - if !is_internal_socket_address(destination) { - return SocketOutcome::Completed(ResolvedTcpDestination::External(destination)); - } let Some(binding) = self.tcp.guest_binding(destination) else { return SocketOutcome::Failed(SocketError::ConnectionRefused); }; diff --git a/litebox_broker_platform_linux_userland/src/socket/tests/mod.rs b/litebox_broker_platform_linux_userland/src/socket/tests/mod.rs index c02774adc..96fe9fc2b 100644 --- a/litebox_broker_platform_linux_userland/src/socket/tests/mod.rs +++ b/litebox_broker_platform_linux_userland/src/socket/tests/mod.rs @@ -9,7 +9,9 @@ use std::time::{Duration, Instant}; use super::*; use litebox_broker_core::readiness::ReadinessSink; -use litebox_broker_core::socket::{GUEST_IPV4_ADDRESS, HOST_GATEWAY_IPV4_ADDRESS}; +use litebox_broker_core::socket::{ + BROKER_DNS_IPV4_ADDRESS, GUEST_IPV4_ADDRESS, HOST_GATEWAY_IPV4_ADDRESS, +}; use litebox_broker_core::{ BrokerCore, BrokerCoreLimits, BrokerSession, CallerCredential, DestinationPortRange, DestinationRule, Ipv4Cidr, ObjectRights, PolicyEngine, SocketPolicy, diff --git a/litebox_broker_platform_linux_userland/src/socket/tests/udp.rs b/litebox_broker_platform_linux_userland/src/socket/tests/udp.rs index fe96928a3..b884d1087 100644 --- a/litebox_broker_platform_linux_userland/src/socket/tests/udp.rs +++ b/litebox_broker_platform_linux_userland/src/socket/tests/udp.rs @@ -13,6 +13,266 @@ fn gateway_udp_policy() -> SocketPolicy { .unwrap() } +fn dns_query(name: &str) -> Vec { + let mut query = Vec::from([ + 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + for label in name.split('.') { + query.push(u8::try_from(label.len()).unwrap()); + query.extend_from_slice(label.as_bytes()); + } + query.extend_from_slice(&[0, 0, 1, 0, 1]); + query +} + +#[test] +fn broker_dns_answers_unconnected_and_connected_udp_without_native_endpoint() { + let provider = Arc::new( + LinuxSocketProvider::new_with_dns_records( + 1, + 1, + &["service.example=203.0.113.7".parse().unwrap()], + ) + .unwrap(), + ); + let broker = BrokerCore::new_with_limits( + PolicyEngine::with_unauthenticated_rights(ObjectRights::all()) + .with_socket_policy(SocketPolicy::guest_network()), + BrokerCoreLimits::new_with_all_limits(2, 0, 1, 1), + provider.clone(), + ) + .unwrap(); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let (published, publications) = channel(); + let (retired, _retirements) = channel(); + let socket = create_udp_socket(&session, Arc::new(TestReadinessSink { published, retired })); + let dns_address = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + let query = dns_query("service.example"); + + assert_eq!( + send_datagram(&session, socket, &query, SendFlags::NONE, Some(dns_address),), + Ok(SocketOutcome::Completed(query.len())) + ); + wait_until_ready(&session, &publications, socket, ReadinessFlags::READ); + let mut response = [0; 512]; + let received = + receive_datagram_into(&session, socket, &mut response, ReceiveFromFlags::NONE).unwrap(); + let SocketOutcome::Completed(received) = received else { + panic!("broker DNS response missing"); + }; + assert_eq!(received.source_address, dns_address); + assert_eq!( + &response[received.received - 4..received.received], + &Ipv4Addr::new(198, 51, 100, 1).octets() + ); + + assert_eq!( + litebox_broker_core::socket::connect(&session, socket, dns_address), + Ok(SocketOutcome::Completed(SocketConnectionStatus::Connected)) + ); + assert_eq!( + send_datagram(&session, socket, &query, SendFlags::NONE, None), + Ok(SocketOutcome::Completed(query.len())) + ); + wait_until_ready(&session, &publications, socket, ReadinessFlags::READ); + response.fill(0); + let received = + receive_datagram_into(&session, socket, &mut response, ReceiveFromFlags::NONE).unwrap(); + let SocketOutcome::Completed(received) = received else { + panic!("connected broker DNS response missing"); + }; + assert_eq!(received.source_address, dns_address); + assert_eq!( + &response[received.received - 4..received.received], + &Ipv4Addr::new(198, 51, 100, 1).octets() + ); + assert_eq!(provider.reactor.udp_native_endpoint_count(), 0); +} + +#[test] +fn udp_native_and_synthetic_alias_collision_is_an_operation_failure() { + let provider = Arc::new( + LinuxSocketProvider::new_with_dns_records( + 4, + 4, + &["service.example=10.0.2.1".parse().unwrap()], + ) + .unwrap(), + ); + let broker = BrokerCore::new_with_limits( + PolicyEngine::with_unauthenticated_rights(ObjectRights::all()) + .with_socket_policy(gateway_udp_policy()), + BrokerCoreLimits::new_with_all_limits(6, 0, 4, 4), + provider, + ) + .unwrap(); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + server.set_read_timeout(Some(TEST_TIMEOUT)).unwrap(); + let gateway = gateway_address(socket_address_v4(server.local_addr().unwrap())); + let synthetic = SocketAddrV4::new(Ipv4Addr::new(198, 51, 100, 1), gateway.port()); + let (published, _publications) = channel(); + let (retired, _retirements) = channel(); + let readiness = Arc::new(TestReadinessSink { published, retired }); + + let synthetic_first = create_udp_socket(&session, readiness.clone()); + assert_eq!( + send_datagram( + &session, + synthetic_first, + b"synthetic", + SendFlags::NONE, + Some(synthetic), + ), + Ok(SocketOutcome::Completed(9)) + ); + let mut payload = [0; 9]; + assert_eq!(server.recv(&mut payload).unwrap(), payload.len()); + assert_eq!(&payload, b"synthetic"); + assert_eq!( + send_datagram( + &session, + synthetic_first, + b"native", + SendFlags::NONE, + Some(gateway), + ), + Ok(SocketOutcome::Failed(SocketError::AddressNotAvailable)) + ); + + let native_first = create_udp_socket(&session, readiness.clone()); + assert_eq!( + send_datagram( + &session, + native_first, + b"native", + SendFlags::NONE, + Some(gateway), + ), + Ok(SocketOutcome::Completed(6)) + ); + let mut payload = [0; 6]; + assert_eq!(server.recv(&mut payload).unwrap(), payload.len()); + assert_eq!(&payload, b"native"); + assert_eq!( + send_datagram( + &session, + native_first, + b"synthetic", + SendFlags::NONE, + Some(synthetic), + ), + Ok(SocketOutcome::Failed(SocketError::AddressNotAvailable)) + ); + + let synthetic_connected = create_udp_socket(&session, readiness.clone()); + assert_eq!( + litebox_broker_core::socket::connect(&session, synthetic_connected, synthetic), + Ok(SocketOutcome::Completed(SocketConnectionStatus::Connected)) + ); + assert_eq!( + send_datagram( + &session, + synthetic_connected, + b"native", + SendFlags::NONE, + Some(gateway), + ), + Ok(SocketOutcome::Failed(SocketError::AddressNotAvailable)) + ); + + let native_connected = create_udp_socket(&session, readiness); + assert_eq!( + litebox_broker_core::socket::connect(&session, native_connected, gateway), + Ok(SocketOutcome::Completed(SocketConnectionStatus::Connected)) + ); + assert_eq!( + send_datagram( + &session, + native_connected, + b"synthetic", + SendFlags::NONE, + Some(synthetic), + ), + Ok(SocketOutcome::Failed(SocketError::AddressNotAvailable)) + ); +} + +#[test] +fn broker_dns_discards_unreceivable_replies_without_readiness() { + let provider = Arc::new( + LinuxSocketProvider::new_with_dns_records( + 2, + 2, + &["service.example=10.0.2.1".parse().unwrap()], + ) + .unwrap(), + ); + let broker = BrokerCore::new_with_limits( + PolicyEngine::with_unauthenticated_rights(ObjectRights::all()) + .with_socket_policy(gateway_udp_policy()), + BrokerCoreLimits::new_with_all_limits(4, 0, 2, 2), + provider, + ) + .unwrap(); + let session = broker + .create_session(CallerCredential::Unauthenticated) + .unwrap(); + let dns_address = SocketAddrV4::new(BROKER_DNS_IPV4_ADDRESS, 53); + let query = dns_query("service.example"); + + let (published, publications) = channel(); + let (retired, _retirements) = channel(); + let receive_shut = + create_udp_socket(&session, Arc::new(TestReadinessSink { published, retired })); + assert_eq!( + litebox_broker_core::socket::shutdown(&session, receive_shut, ShutdownMode::Read,), + Ok(SocketOutcome::Completed(())) + ); + while publications.try_recv().is_ok() {} + assert_eq!( + send_datagram( + &session, + receive_shut, + &query, + SendFlags::NONE, + Some(dns_address), + ), + Ok(SocketOutcome::Completed(query.len())) + ); + assert!(publications.try_recv().is_err()); + + let server = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let gateway = gateway_address(socket_address_v4(server.local_addr().unwrap())); + let (published, _publications) = channel(); + let (retired, _retirements) = channel(); + let external = create_udp_socket(&session, Arc::new(TestReadinessSink { published, retired })); + assert_eq!( + litebox_broker_core::socket::connect(&session, external, gateway), + Ok(SocketOutcome::Completed(SocketConnectionStatus::Connected)) + ); + assert_eq!( + send_datagram( + &session, + external, + &query, + SendFlags::NONE, + Some(dns_address), + ), + Ok(SocketOutcome::Completed(query.len())) + ); + assert!( + !session + .check_readiness(external) + .unwrap() + .contains(ReadinessFlags::READ) + ); +} + #[test] fn udp_gateway_translates_sources_filters_spoofing_and_reuses_endpoint() { let native_ip = non_loopback_local_ipv4(); diff --git a/litebox_broker_platform_linux_userland/src/socket/udp.rs b/litebox_broker_platform_linux_userland/src/socket/udp.rs index 0d5c29879..bfed2fc82 100644 --- a/litebox_broker_platform_linux_userland/src/socket/udp.rs +++ b/litebox_broker_platform_linux_userland/src/socket/udp.rs @@ -13,8 +13,7 @@ use std::net::{Ipv4Addr, SocketAddrV4}; use std::os::fd::OwnedFd; use litebox_broker_core::socket::{ - GUEST_IPV4_ADDRESS, GuestSocketBinding, PlatformConnectError, host_socket_destination, - is_internal_socket_address, normalize_socket_destination, + GUEST_IPV4_ADDRESS, GuestSocketBinding, PlatformConnectError, PlatformSocketDestination, }; use litebox_broker_core::{BrokerError, Result as BrokerResult, SessionId}; use litebox_broker_protocol::readiness::ReadinessFlags; @@ -220,7 +219,14 @@ pub(super) enum ReactorUdpPeer { socket_id: u64, internal_address: SocketAddrV4, }, - External(SocketAddrV4), + External(ExternalUdpPeer), + BrokerDns(SocketAddrV4), +} + +#[derive(Clone, Copy)] +pub(super) struct ExternalUdpPeer { + pub(super) guest_address: SocketAddrV4, + pub(super) host_address: SocketAddrV4, } impl ReactorUdpState { @@ -304,22 +310,31 @@ impl ReactorUdpState { } impl Reactor { - /// Revalidates and resolves a normalized guest-visible UDP destination. + /// Resolves a trusted platform UDP route against live guest bindings. pub(super) fn resolve_udp_destination( &self, - requested_destination: SocketAddrV4, + destination: PlatformSocketDestination, ) -> SocketOutcome { - let destination = match normalize_socket_destination(requested_destination) { - Ok(destination) => destination, - Err(error) => return SocketOutcome::Failed(error), + let destination = match destination { + PlatformSocketDestination::Internal(destination) => destination, + PlatformSocketDestination::External { + guest_address, + host_address, + .. + } => { + return if self.targets_broker_udp_endpoint(host_address) { + SocketOutcome::Failed(SocketError::ConnectionRefused) + } else { + SocketOutcome::Completed(ReactorUdpPeer::External(ExternalUdpPeer { + guest_address, + host_address, + })) + }; + } + PlatformSocketDestination::BrokerDns(address) => { + return SocketOutcome::Completed(ReactorUdpPeer::BrokerDns(address)); + } }; - if !is_internal_socket_address(destination) { - return if self.targets_broker_udp_endpoint(host_socket_destination(destination)) { - SocketOutcome::Failed(SocketError::ConnectionRefused) - } else { - SocketOutcome::Completed(ReactorUdpPeer::External(destination)) - }; - } let Some(binding) = self.udp.guest_binding(destination) else { return SocketOutcome::Failed(SocketError::ConnectionRefused); }; @@ -332,17 +347,18 @@ impl Reactor { pub(super) fn reserve_udp_external_peer( &mut self, socket_id: u64, - external_destination: SocketAddrV4, - ) -> BrokerResult { + external_destination: ExternalUdpPeer, + ) -> BrokerResult> { let session_id = { let socket = self.sockets.get(&socket_id).ok_or(BrokerError::Internal)?; let udp = socket.udp_state()?; - let host_destination = host_socket_destination(external_destination); - if let Some(existing_external_destination) = udp.external_peers.get(&host_destination) { - if *existing_external_destination != external_destination { - return Err(BrokerError::Internal); + if let Some(existing_guest_address) = + udp.external_peers.get(&external_destination.host_address) + { + if *existing_guest_address != external_destination.guest_address { + return Ok(SocketOutcome::Failed(SocketError::AddressNotAvailable)); } - return Ok(false); + return Ok(SocketOutcome::Completed(false)); } if udp.external_peers.len() >= MAX_UDP_EXTERNAL_PEERS_PER_SOCKET { return Err(BrokerError::ResourceExhausted); @@ -378,8 +394,8 @@ impl Reactor { .udp_state_mut()? .external_peers .insert( - host_socket_destination(external_destination), - external_destination, + external_destination.host_address, + external_destination.guest_address, ) .is_some() { @@ -398,13 +414,13 @@ impl Reactor { .udp_external_peer_count .checked_add(1) .ok_or(BrokerError::ResourceExhausted)?; - Ok(true) + Ok(SocketOutcome::Completed(true)) } pub(super) fn remove_udp_external_peer( &mut self, socket_id: u64, - external_destination: SocketAddrV4, + external_destination: ExternalUdpPeer, ) { let Some(socket) = self.sockets.get_mut(&socket_id) else { return; @@ -412,11 +428,13 @@ impl Reactor { let Ok(udp) = socket.udp_state_mut() else { return; }; - let host_destination = host_socket_destination(external_destination); - if udp.external_peers.get(&host_destination) != Some(&external_destination) { + if udp.external_peers.get(&external_destination.host_address) + != Some(&external_destination.guest_address) + { return; } - udp.external_peers.remove(&host_destination); + udp.external_peers + .remove(&external_destination.host_address); self.udp.external_peer_count = self .udp .external_peer_count @@ -706,15 +724,62 @@ impl Reactor { socket_id, internal_address, }) => socket_id == source_socket_id && internal_address == source_address, - Some(ReactorUdpPeer::External(_)) => false, + Some(ReactorUdpPeer::External(_) | ReactorUdpPeer::BrokerDns(_)) => false, } }; - if !accepts_source - || self.udp_queue_would_drop(destination_socket_id, source_session_id, payload.len())? - { + if !accepts_source { return Ok(SocketOutcome::Completed(payload.len())); } + self.enqueue_queued_datagram( + source_session_id, + destination_socket_id, + source_address, + payload, + ) + } + pub(super) fn enqueue_dns_response( + &mut self, + destination_socket_id: u64, + source_address: SocketAddrV4, + payload: &[u8], + ) -> BrokerResult> { + let source_session_id = { + let destination = self + .sockets + .get(&destination_socket_id) + .ok_or(BrokerError::Internal)?; + if destination.kind() != SocketKind::Udp || destination.read_shutdown { + return Ok(SocketOutcome::Completed(payload.len())); + } + let accepts_source = match destination.udp_state()?.peer { + None => true, + Some(ReactorUdpPeer::BrokerDns(address)) => address == source_address, + Some(ReactorUdpPeer::Internal { .. } | ReactorUdpPeer::External(_)) => false, + }; + if !accepts_source { + return Ok(SocketOutcome::Completed(payload.len())); + } + destination.session_id + }; + self.enqueue_queued_datagram( + source_session_id, + destination_socket_id, + source_address, + payload, + ) + } + + fn enqueue_queued_datagram( + &mut self, + source_session_id: SessionId, + destination_socket_id: u64, + source_address: SocketAddrV4, + payload: &[u8], + ) -> BrokerResult> { + if self.udp_queue_would_drop(destination_socket_id, source_session_id, payload.len())? { + return Ok(SocketOutcome::Completed(payload.len())); + } let mut stored_payload = Vec::new(); stored_payload .try_reserve_exact(payload.len()) @@ -919,8 +984,8 @@ impl Reactor { Ok(UDP_EVENT_TOKEN_FLAG | token_id) } - fn udp_port_conflicts(&self, port: u16, current_external_destination: SocketAddrV4) -> bool { - udp_destination_uses_local_port(host_socket_destination(current_external_destination), port) + fn udp_port_conflicts(&self, port: u16, current_external_destination: ExternalUdpPeer) -> bool { + udp_destination_uses_local_port(current_external_destination.host_address, port) || self.udp.native_endpoints.contains(&port) || self.sockets.values().any(|socket| { socket.udp_state().is_ok_and(|udp| { @@ -931,7 +996,7 @@ impl Reactor { udp.peer, Some(ReactorUdpPeer::External(peer)) if udp_destination_uses_local_port( - host_socket_destination(peer), + peer.host_address, port, ) ) @@ -942,8 +1007,8 @@ impl Reactor { pub(super) fn stage_udp_endpoint( &mut self, socket_id: u64, - current_external_destination: SocketAddrV4, - connected_external_destination: Option, + current_external_destination: ExternalUdpPeer, + connected_external_destination: Option, ) -> BrokerResult> { let read_enabled = !self .sockets @@ -986,7 +1051,7 @@ impl Reactor { } if let Some(external_destination) = connected_external_destination { loop { - match connect(&socket, &host_socket_destination(external_destination)) { + match connect(&socket, &external_destination.host_address) { Ok(()) => break, Err(Errno::INTR) => {} Err(error) => { @@ -1047,7 +1112,7 @@ impl Reactor { pub(super) fn connect_existing_udp_endpoint( &mut self, socket_id: u64, - external_destination: SocketAddrV4, + external_destination: ExternalUdpPeer, ) -> core::result::Result, PlatformConnectError> { { let socket = @@ -1065,10 +1130,7 @@ impl Reactor { BrokerError::Internal, ))?; loop { - match connect( - &endpoint.socket, - &host_socket_destination(external_destination), - ) { + match connect(&endpoint.socket, &external_destination.host_address) { Ok(()) => break, Err(Errno::INTR) => {} Err(error) => { @@ -1284,12 +1346,14 @@ impl Reactor { None } else { match udp.peer { - Some(ReactorUdpPeer::External(peer)) - if host_socket_destination(peer) == host_source => - { - Some(peer) + Some(ReactorUdpPeer::External(peer)) if peer.host_address == host_source => { + Some(peer.guest_address) } - Some(ReactorUdpPeer::Internal { .. } | ReactorUdpPeer::External(_)) => None, + Some( + ReactorUdpPeer::Internal { .. } + | ReactorUdpPeer::External(_) + | ReactorUdpPeer::BrokerDns(_), + ) => None, None => udp.external_peers.get(&host_source).copied(), } }) @@ -1421,9 +1485,9 @@ impl Reactor { &mut self, socket_id: u64, data: &[u8], - external_destination: SocketAddrV4, + external_destination: ExternalUdpPeer, ) -> BrokerResult> { - let destination = host_socket_destination(external_destination); + let destination = external_destination.host_address; let result = loop { let socket = self.sockets.get(&socket_id).ok_or(BrokerError::Internal)?; let endpoint = socket diff --git a/litebox_broker_protocol/src/lib.rs b/litebox_broker_protocol/src/lib.rs index 95f2e2ebb..3eb8da53b 100644 --- a/litebox_broker_protocol/src/lib.rs +++ b/litebox_broker_protocol/src/lib.rs @@ -40,5 +40,41 @@ pub struct RequestId(pub u64); #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ProtocolVersion(pub u16); +/// Broker features negotiated for one association. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrokerCapabilities(u64); + +impl BrokerCapabilities { + /// No optional broker features. + pub const NONE: Self = Self(0); + /// Broker-controlled DNS identity is available. + pub const BROKER_DNS: Self = Self(1 << 0); + + const KNOWN_BITS: u64 = Self::BROKER_DNS.0; + + /// Creates a capability set when every bit is understood. + #[must_use] + pub const fn from_bits(bits: u64) -> Option { + if bits & !Self::KNOWN_BITS == 0 { + Some(Self(bits)) + } else { + None + } + } + + /// Returns the encoded capability bits. + #[must_use] + pub const fn bits(self) -> u64 { + self.0 + } + + /// Returns whether every capability in `other` is present. + #[must_use] + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + /// Current broker protocol version. pub const BROKER_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion(1); diff --git a/litebox_broker_protocol/src/message.rs b/litebox_broker_protocol/src/message.rs index bd3610522..3ba16a8ed 100644 --- a/litebox_broker_protocol/src/message.rs +++ b/litebox_broker_protocol/src/message.rs @@ -20,7 +20,7 @@ use crate::socket::{ SendToSocketResponse, SetTcpOptionRequest, ShutdownSocketRequest, SocketError, SocketStatusRequest, SocketStatusResponse, }; -use crate::{ObjectHandle, ProtocolVersion, RequestId}; +use crate::{BrokerCapabilities, ObjectHandle, ProtocolVersion, RequestId}; /// Broker handshake request sent before the control channel is active. #[derive(Clone, Debug, PartialEq, Eq)] @@ -63,6 +63,8 @@ pub enum BrokerHandshakeResponse { /// The broker returns its supported version after validating that the /// requested version matches it. broker_protocol_version: ProtocolVersion, + /// Immutable features exposed by the broker for this association. + capabilities: BrokerCapabilities, }, /// Negotiation failed because the requested version is unsupported. /// diff --git a/litebox_broker_protocol/src/wire.rs b/litebox_broker_protocol/src/wire.rs index dbd2ceac3..0a7b98b45 100644 --- a/litebox_broker_protocol/src/wire.rs +++ b/litebox_broker_protocol/src/wire.rs @@ -18,6 +18,7 @@ use alloc::vec::Vec; use thiserror::Error; +use crate::BrokerCapabilities; use crate::error::ErrorCode; use crate::message::{ BrokerHandshakeRequest, BrokerHandshakeResponse, BrokerNotification, BrokerOperation, @@ -67,6 +68,8 @@ pub enum WireError { TrailingBytes, #[error("invalid broker wire tag")] InvalidTag, + #[error("broker wire capabilities contain unknown bits")] + UnknownCapabilities, #[error("broker wire message is not valid in this protocol phase")] WrongMessagePhase, #[error("broker wire offset overflow")] @@ -183,9 +186,11 @@ pub fn encode_handshake_response(response: BrokerHandshakeResponse) -> Vec { match response { BrokerHandshakeResponse::Negotiated { broker_protocol_version, + capabilities, } => { encoder.u8(RESPONSE_TAG_NEGOTIATED); encoder.protocol_version(broker_protocol_version); + encoder.u64(capabilities.bits()); } BrokerHandshakeResponse::VersionMismatch { broker_protocol_version, @@ -208,6 +213,8 @@ pub fn decode_handshake_response(frame: &[u8]) -> Result BrokerHandshakeResponse::Negotiated { broker_protocol_version: decoder.protocol_version()?, + capabilities: BrokerCapabilities::from_bits(decoder.u64()?) + .ok_or(WireError::UnknownCapabilities)?, }, RESPONSE_TAG_EVENT | RESPONSE_TAG_OBJECT_CLOSED @@ -337,6 +344,8 @@ pub fn decode_notification(frame: &[u8]) -> Result Result<(), Box> { configured_socket_policy(&args.allow_tcp_destination, &args.allow_udp_destination)?, ), limits, - Arc::new(LinuxSocketProvider::new( + Arc::new(LinuxSocketProvider::new_with_dns_records( limits.max_sockets, limits.max_sockets_per_session, + &args.dns_a_record, )?), )?; diff --git a/litebox_broker_userland/src/main.rs b/litebox_broker_userland/src/main.rs index a010799e5..768e6c972 100644 --- a/litebox_broker_userland/src/main.rs +++ b/litebox_broker_userland/src/main.rs @@ -21,6 +21,8 @@ use litebox_broker_core::{ SocketPolicyError, }; use litebox_broker_host::{BrokerHostAssociation, ConnectionTermination, setup_connection}; +#[cfg(target_os = "linux")] +use litebox_broker_platform_linux_userland::DnsARecord; use litebox_broker_protocol::message::{BrokerRequest, BrokerResponse}; use litebox_broker_protocol::shared_buffer::SHARED_BUFFER_LAYOUT; use litebox_broker_protocol::socket::{Ipv4Address, Port}; @@ -97,6 +99,14 @@ struct CliArgs { /// `0.0.0.0/0:1-65535` permits every nonzero IPv4 UDP destination. #[arg(long, value_name = "CIDR:PORT[-PORT]")] allow_udp_destination: Vec, + /// Publish an exact static IPv4 DNS record as a broker-pinned identity. + /// + /// May be repeated for up to 64 unique names and destination addresses. + /// Resolving the name does not grant access to the pinned destination; the + /// matching TCP or UDP destination policy must allow it. + #[cfg(target_os = "linux")] + #[arg(long, value_name = "NAME=IP")] + dns_a_record: Vec, /// Local runner executable to launch. #[arg(long, value_name = "PATH", value_hint = clap::ValueHint::ExecutablePath)] runner: PathBuf, @@ -578,6 +588,27 @@ mod cli_tests { assert_eq!(args.allow_udp_destination.len(), 1); } + #[cfg(target_os = "linux")] + #[test] + fn cli_accepts_dns_a_records() { + let args = CliArgs::try_parse_from([ + "litebox-broker-userland", + "--dns-a-record", + "service.test=203.0.113.7", + "--runner", + "runner", + "guest", + ]) + .unwrap(); + + assert_eq!(args.dns_a_record.len(), 1); + assert_eq!(args.dns_a_record[0].name(), "service.test"); + assert_eq!( + args.dns_a_record[0].address(), + Ipv4Addr::new(203, 0, 113, 7) + ); + } + #[test] fn destination_argument_parses_canonical_cidr_and_ports() { let allowed = "203.0.113.0/24:443-444" @@ -671,6 +702,7 @@ mod tests { control_channel .send_handshake_response(&BrokerHandshakeResponse::Negotiated { broker_protocol_version: BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, }) .unwrap(); local_setup.recv_handshake_response().unwrap().unwrap(); diff --git a/litebox_broker_userland/tests/userland_broker.rs b/litebox_broker_userland/tests/userland_broker.rs index f2180b31c..68c64a39d 100644 --- a/litebox_broker_userland/tests/userland_broker.rs +++ b/litebox_broker_userland/tests/userland_broker.rs @@ -72,6 +72,8 @@ fn run_parent_test() { .arg(format!("{gateway}/32:{tcp_port}")) .arg("--allow-udp-destination") .arg(format!("{gateway}/32:{udp_port}")) + .arg("--dns-a-record") + .arg(format!("gateway.test={gateway}")) .arg("--runner") .arg(std::env::current_exe().unwrap()) .arg(NETWORK_RUNNER_ARGUMENT) @@ -132,10 +134,13 @@ fn run_fake_runner(args: &[OsString]) { assert_eq!(args.len(), 6, "unexpected runner arguments: {args:?}"); let tcp_port = args[4].to_str().unwrap().parse::().unwrap(); let udp_port = args[5].to_str().unwrap().parse::().unwrap(); - let gateway = std::net::Ipv4Addr::new(10, 0, 2, 1); + let pinned_gateway = std::net::Ipv4Addr::new(198, 51, 100, 1); let handle = local.create_tcp_socket().unwrap(); let mut status = local - .connect_socket(handle, std::net::SocketAddrV4::new(gateway, tcp_port)) + .connect_socket( + handle, + std::net::SocketAddrV4::new(pinned_gateway, tcp_port), + ) .unwrap() .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); @@ -158,7 +163,7 @@ fn run_fake_runner(args: &[OsString]) { }, request, SendFlags::NONE, - Some(std::net::SocketAddrV4::new(gateway, udp_port)), + Some(std::net::SocketAddrV4::new(pinned_gateway, udp_port)), ) .unwrap(), Ok(request.len()) @@ -195,7 +200,7 @@ fn run_fake_runner(args: &[OsString]) { assert_eq!(&reply[..received.received as usize], b"gateway reply"); assert_eq!( received.source_address, - std::net::SocketAddrV4::new(gateway, udp_port) + std::net::SocketAddrV4::new(pinned_gateway, udp_port) ); local.close_object(handle).unwrap(); return; diff --git a/litebox_common_linux/src/lib.rs b/litebox_common_linux/src/lib.rs index 4dab6117e..fa8f400a8 100644 --- a/litebox_common_linux/src/lib.rs +++ b/litebox_common_linux/src/lib.rs @@ -810,6 +810,7 @@ pub enum UnixProtocol { #[derive(Debug, IntEnum, Clone, Copy)] pub enum IpOption { TOS = 1, + RECVERR = 11, } #[repr(u32)] diff --git a/litebox_runner_linux_userland/Cargo.toml b/litebox_runner_linux_userland/Cargo.toml index 502ec4815..5d24804c9 100644 --- a/litebox_runner_linux_userland/Cargo.toml +++ b/litebox_runner_linux_userland/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0.97" clap = { version = "4.5.33", features = ["derive"] } libc = { version = "0.2.169", default-features = false } litebox = { version = "0.1.0", path = "../litebox" } +litebox_broker_core = { version = "0.1.0", path = "../litebox_broker_core" } litebox_broker_local = { version = "0.1.0", path = "../litebox_broker_local" } litebox_broker_protocol = { version = "0.1.0", path = "../litebox_broker_protocol" } litebox_broker_transport = { version = "0.1.0", path = "../litebox_broker_transport" } @@ -24,7 +25,6 @@ litebox_util_log = { version = "0.1.0", path = "../litebox_util_log", features = sha2 = "0.10" walkdir = "2.0" glob = "0.3" -litebox_broker_core = { version = "0.1.0", path = "../litebox_broker_core" } litebox_broker_host = { version = "0.1.0", path = "../litebox_broker_host" } litebox_broker_platform_linux_userland = { version = "0.1.0", path = "../litebox_broker_platform_linux_userland" } litebox_broker_userland = { version = "0.1.0", path = "../litebox_broker_userland" } diff --git a/litebox_runner_linux_userland/src/broker.rs b/litebox_runner_linux_userland/src/broker.rs index 42aaef267..d91b2c825 100644 --- a/litebox_runner_linux_userland/src/broker.rs +++ b/litebox_runner_linux_userland/src/broker.rs @@ -25,6 +25,7 @@ const SETUP_TIMEOUT: Duration = Duration::from_secs(5); const RETRY_DELAY: Duration = Duration::from_millis(20); pub(crate) struct BrokerConnection { + pub(crate) capabilities: litebox_broker_protocol::BrokerCapabilities, pub(crate) local: BrokerLocal, pub(crate) notifications: BrokerNotifications, pub(crate) coordinator: Arc, @@ -47,8 +48,8 @@ pub(crate) fn connect(control_socket_path: &Path) -> Result { ) })?; let association_coordinator = Arc::new(BrokerAssociationFailureCoordinator::new()); - let (local, (notification_channel, positional_io_fds, shutdown_fd)) = - BrokerLocal::negotiate(setup_channel, |mut setup| { + let (local, capabilities, (notification_channel, positional_io_fds, shutdown_fd)) = + BrokerLocal::negotiate_with_capabilities(setup_channel, |mut setup| { let shared_memory = setup.receive_memfd(SHARED_BUFFER_POOL_SIZE, Some(setup_deadline))?; let control_memory = setup.receive_control_ring(Some(setup_deadline))?; @@ -79,6 +80,7 @@ pub(crate) fn connect(control_socket_path: &Path) -> Result { }) .context("broker negotiation failed")?; Ok(BrokerConnection { + capabilities, local, notifications: BrokerNotifications::new(notification_channel), coordinator: association_coordinator, @@ -247,6 +249,7 @@ mod tests { host.send_handshake_response( &litebox_broker_protocol::message::BrokerHandshakeResponse::Negotiated { broker_protocol_version: litebox_broker_protocol::BROKER_PROTOCOL_VERSION, + capabilities: litebox_broker_protocol::BrokerCapabilities::NONE, }, ) .unwrap(); diff --git a/litebox_runner_linux_userland/src/lib.rs b/litebox_runner_linux_userland/src/lib.rs index caf05dcc2..172ff80de 100644 --- a/litebox_runner_linux_userland/src/lib.rs +++ b/litebox_runner_linux_userland/src/lib.rs @@ -3,7 +3,8 @@ use anyhow::{Context as _, Result, anyhow}; use clap::Parser; -use litebox::fs::{FileSystem as _, Mode}; +use litebox::fs::{FileSystem as _, FileType, Mode, OFlags}; +use litebox_broker_core::socket::BROKER_DNS_IPV4_ADDRESS; use litebox_platform_linux_userland::LinuxUserland as Platform; use memmap2::Mmap; use std::os::linux::fs::MetadataExt as _; @@ -17,6 +18,301 @@ extern crate alloc; // credentials aligned with the in-memory filesystem default user and avoids truncating high host IDs. const DEFAULT_GUEST_UID: u16 = 1000; const DEFAULT_GUEST_GID: u16 = 1000; +const MAX_NSSWITCH_SIZE: usize = 64 * 1024; +const GENERATED_NSS_HOSTS: &[u8] = b"hosts: files dns\n"; + +fn path_is_missing_or_unreachable(error: &litebox::fs::errors::PathError) -> bool { + matches!( + error, + litebox::fs::errors::PathError::NoSuchFileOrDirectory + | litebox::fs::errors::PathError::MissingComponent + | litebox::fs::errors::PathError::ComponentNotADirectory + | litebox::fs::errors::PathError::NoSearchPerms { .. } + ) +} + +fn read_lower_nsswitch( + lower: &litebox_shim_linux::DefaultLowerFS, +) -> Result>> { + let status = match lower.file_status("/etc/nsswitch.conf") { + Ok(status) => status, + Err(litebox::fs::errors::FileStatusError::PathError(error)) + if path_is_missing_or_unreachable(&error) => + { + return Ok(None); + } + Err(error) => { + return Err(anyhow!( + "failed to inspect lower /etc/nsswitch.conf: {error}" + )); + } + }; + if status.file_type != FileType::RegularFile { + return Ok(None); + } + + let fd = match lower.open("/etc/nsswitch.conf", OFlags::RDONLY, Mode::empty()) { + Ok(fd) => fd, + Err(litebox::fs::errors::OpenError::AccessNotAllowed) => return Ok(None), + Err(litebox::fs::errors::OpenError::PathError(error)) + if path_is_missing_or_unreachable(&error) => + { + return Ok(None); + } + Err(error) => return Err(anyhow!("failed to open lower /etc/nsswitch.conf: {error}")), + }; + if status.size > MAX_NSSWITCH_SIZE { + lower + .close(&fd) + .map_err(|error| anyhow!("failed to close lower /etc/nsswitch.conf: {error}"))?; + anyhow::bail!("lower /etc/nsswitch.conf exceeds 64 KiB"); + } + + let read_result = (|| { + let mut contents = Vec::new(); + contents + .try_reserve_exact(status.size) + .map_err(|_| anyhow!("failed to allocate lower /etc/nsswitch.conf"))?; + let mut buffer = [0u8; 4096]; + loop { + let read = match lower.read(&fd, &mut buffer, None) { + Ok(read) => read, + Err(litebox::fs::errors::ReadError::NotAFile) => return Ok(None), + Err(error) => { + return Err(anyhow!("failed to read lower /etc/nsswitch.conf: {error}")); + } + }; + if read == 0 { + break; + } + if contents + .len() + .checked_add(read) + .is_none_or(|length| length > MAX_NSSWITCH_SIZE) + { + anyhow::bail!("lower /etc/nsswitch.conf exceeds 64 KiB"); + } + contents + .try_reserve_exact(read) + .map_err(|_| anyhow!("failed to grow lower /etc/nsswitch.conf buffer"))?; + contents.extend_from_slice(&buffer[..read]); + } + Ok(Some(contents)) + })(); + lower + .close(&fd) + .map_err(|error| anyhow!("failed to close lower /etc/nsswitch.conf: {error}"))?; + read_result +} + +fn is_nss_ascii_whitespace(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | 0x0b | 0x0c | b'\r') +} + +fn is_hosts_candidate(line: &[u8]) -> bool { + let line = line + .iter() + .position(|byte| !is_nss_ascii_whitespace(*byte)) + .map_or(&[][..], |start| &line[start..]); + if line.first() == Some(&b'#') { + return false; + } + line.strip_prefix(b"hosts").is_some_and(|remainder| { + remainder.is_empty() + || remainder.first() == Some(&b':') + || remainder + .first() + .is_some_and(|byte| is_nss_ascii_whitespace(*byte)) + }) +} + +fn append_nss_bytes(output: &mut Vec, bytes: &[u8]) -> Result<()> { + if output + .len() + .checked_add(bytes.len()) + .is_none_or(|length| length > MAX_NSSWITCH_SIZE) + { + anyhow::bail!("merged /etc/nsswitch.conf exceeds 64 KiB"); + } + output.extend_from_slice(bytes); + Ok(()) +} + +fn merge_nsswitch(contents: &[u8]) -> Result> { + if contents.len() > MAX_NSSWITCH_SIZE { + anyhow::bail!("lower /etc/nsswitch.conf exceeds 64 KiB"); + } + let mut output = Vec::new(); + output + .try_reserve_exact(MAX_NSSWITCH_SIZE) + .map_err(|_| anyhow!("failed to allocate merged /etc/nsswitch.conf"))?; + + let mut offset = 0usize; + let mut active_hosts_seen = false; + while let Some(relative_newline) = contents[offset..].iter().position(|byte| *byte == b'\n') { + let line_end = offset + relative_newline; + let terminated_end = line_end + 1; + if is_hosts_candidate(&contents[offset..line_end]) { + if !active_hosts_seen { + append_nss_bytes(&mut output, GENERATED_NSS_HOSTS)?; + active_hosts_seen = true; + } + } else { + append_nss_bytes(&mut output, &contents[offset..terminated_end])?; + } + offset = terminated_end; + } + + let tail = &contents[offset..]; + if active_hosts_seen { + append_nss_bytes(&mut output, tail)?; + } else { + append_nss_bytes(&mut output, GENERATED_NSS_HOSTS)?; + if !tail.is_empty() && !is_hosts_candidate(tail) { + append_nss_bytes(&mut output, tail)?; + } + } + Ok(output) +} + +fn guest_can_search(status: &litebox::fs::FileStatus) -> bool { + if status.owner.user == DEFAULT_GUEST_UID { + status.mode.contains(Mode::XUSR) + } else if status.owner.group == DEFAULT_GUEST_GID { + status.mode.contains(Mode::XGRP) + } else { + status.mode.contains(Mode::XOTH) + } +} + +fn write_broker_network_file( + fs: &mut litebox::fs::in_mem::FileSystem, + path: &str, + contents: Vec, +) -> Result<()> { + let file_mode = Mode::RUSR | Mode::WUSR | Mode::RGRP | Mode::ROTH; + let fd = fs + .open( + path, + OFlags::WRONLY | OFlags::CREAT | OFlags::TRUNC, + file_mode, + ) + .map_err(|error| anyhow!("failed to create {path}: {error}"))?; + fs.initialize_primarily_read_heavy_file(&fd, contents.into()); + fs.close(&fd) + .map_err(|error| anyhow!("failed to close {path}: {error}"))?; + fs.chmod(path, file_mode) + .map_err(|error| anyhow!("failed to set mode on {path}: {error}"))?; + fs.chown(path, Some(0), Some(0)) + .map_err(|error| anyhow!("failed to set owner on {path}: {error}"))?; + Ok(()) +} + +fn configure_broker_resolver_files( + lower: &litebox_shim_linux::DefaultLowerFS, + upper: &mut litebox::fs::in_mem::FileSystem, +) -> Result<()> { + let nsswitch = merge_nsswitch(read_lower_nsswitch(lower)?.as_deref().unwrap_or_default())?; + let etc_exists = match upper.file_status("/etc") { + Ok(status) => { + if status.file_type != FileType::Directory { + anyhow::bail!("broker-controlled /etc exists but is not a directory"); + } + if !guest_can_search(&status) { + anyhow::bail!("broker-controlled /etc is not searchable by the guest"); + } + true + } + Err(litebox::fs::errors::FileStatusError::PathError( + litebox::fs::errors::PathError::NoSuchFileOrDirectory + | litebox::fs::errors::PathError::MissingComponent, + )) => false, + Err(error) => return Err(anyhow!("failed to inspect broker-controlled /etc: {error}")), + }; + + upper.with_root_privileges(|fs| { + if !etc_exists { + let directory_mode = Mode::RWXU | Mode::RGRP | Mode::XGRP | Mode::ROTH | Mode::XOTH; + fs.mkdir("/etc", directory_mode) + .map_err(|error| anyhow!("failed to create broker-controlled /etc: {error}"))?; + fs.chmod("/etc", directory_mode) + .map_err(|error| anyhow!("failed to set mode on /etc: {error}"))?; + fs.chown("/etc", Some(0), Some(0)) + .map_err(|error| anyhow!("failed to set owner on /etc: {error}"))?; + } + write_broker_network_file( + fs, + "/etc/resolv.conf", + format!("nameserver {BROKER_DNS_IPV4_ADDRESS}\noptions timeout:1 attempts:2\n") + .into_bytes(), + )?; + write_broker_network_file( + fs, + "/etc/hosts", + b"127.0.0.1 localhost\n::1 localhost\n127.0.0.1 litebox\n".to_vec(), + )?; + write_broker_network_file(fs, "/etc/nsswitch.conf", nsswitch) + }) +} + +#[cfg(test)] +mod resolver_file_tests { + use super::*; + + #[test] + fn nss_merge_replaces_all_active_hosts_entries_and_preserves_other_bytes() { + let lower = b"passwd: files\r\n hosts : files\r\n# keep\nhosts ldap\nshadow: files"; + assert_eq!( + merge_nsswitch(lower).unwrap(), + b"passwd: files\r\nhosts: files dns\n# keep\nshadow: files" + ); + assert_eq!( + merge_nsswitch(b"hosts: first\nhosts: final").unwrap(), + b"hosts: files dns\nhosts: final" + ); + } + + #[test] + fn nss_merge_replaces_every_unterminated_hosts_candidate() { + for lower in [ + b"hosts".as_slice(), + b"hosts: files".as_slice(), + b"\t hosts ldap".as_slice(), + ] { + assert_eq!(merge_nsswitch(lower).unwrap(), GENERATED_NSS_HOSTS); + } + } + + #[test] + fn nss_merge_inserts_before_every_other_unterminated_tail() { + for tail in [ + b"#tail".as_slice(), + b" \t\r".as_slice(), + b"unknown: value".as_slice(), + b"\0binary".as_slice(), + b"\xff".as_slice(), + ] { + let mut expected = GENERATED_NSS_HOSTS.to_vec(); + expected.extend_from_slice(tail); + assert_eq!(merge_nsswitch(tail).unwrap(), expected); + } + } + + #[test] + fn nss_merge_appends_only_to_empty_or_newline_terminated_content() { + assert_eq!(merge_nsswitch(b"").unwrap(), GENERATED_NSS_HOSTS); + assert_eq!( + merge_nsswitch(b"passwd: files\n").unwrap(), + b"passwd: files\nhosts: files dns\n" + ); + } + + #[test] + fn nss_merge_enforces_input_and_output_bounds() { + assert!(merge_nsswitch(&vec![b'x'; MAX_NSSWITCH_SIZE + 1]).is_err()); + assert!(merge_nsswitch(&vec![b'x'; MAX_NSSWITCH_SIZE]).is_err()); + } +} /// Run Linux programs with LiteBox on unmodified Linux /// @@ -146,6 +442,11 @@ pub fn run(cli_args: CliArgs) -> Result<()> { Some(control_socket_path) => Some(broker::connect(control_socket_path)?), None => None, }; + let broker_controls_dns = broker_connection.as_ref().is_some_and(|connection| { + connection + .capabilities + .contains(litebox_broker_protocol::BrokerCapabilities::BROKER_DNS) + }); let mut cow_eligible_regions: Vec = Vec::new(); @@ -225,6 +526,7 @@ pub fn run(cli_args: CliArgs) -> Result<()> { let mut broker_shutdown_fds = Vec::new(); let shim_builder = if let Some(broker_connection) = broker_connection { let broker::BrokerConnection { + capabilities: _, local: broker_local, notifications: broker_notifications, coordinator: broker_association_coordinator, @@ -338,7 +640,15 @@ pub fn run(cli_args: CliArgs) -> Result<()> { } }); - shim_builder.default_fs(in_mem, tar_data.into()) + if broker_controls_dns { + shim_builder.default_fs_with_hook( + in_mem, + tar_data.into(), + configure_broker_resolver_files, + )? + } else { + shim_builder.default_fs(in_mem, tar_data.into()) + } }; // We need to get the file path before enabling seccomp. diff --git a/litebox_runner_linux_userland/tests/dns_broker.c b/litebox_runner_linux_userland/tests/dns_broker.c new file mode 100644 index 000000000..33a1a616f --- /dev/null +++ b/litebox_runner_linux_userland/tests/dns_broker.c @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void assert_file_contents(const char *path, const char *expected) { + FILE *file = fopen(path, "rb"); + assert(file != NULL); + char contents[256]; + size_t length = fread(contents, 1, sizeof(contents), file); + assert(!ferror(file)); + assert(feof(file)); + assert(length == strlen(expected)); + assert(memcmp(contents, expected, length) == 0); + assert(fclose(file) == 0); +} + +int main(int argc, char **argv) { + assert(argc == 2); + char *end = NULL; + unsigned long port = strtoul(argv[1], &end, 10); + assert(end != argv[1] && *end == '\0' && port > 0 && port <= UINT16_MAX); + + assert_file_contents( + "/etc/resolv.conf", + "nameserver 10.0.2.3\noptions timeout:1 attempts:2\n"); + assert_file_contents( + "/etc/hosts", + "127.0.0.1 localhost\n::1 localhost\n127.0.0.1 litebox\n"); + + FILE *nsswitch = fopen("/etc/nsswitch.conf", "r"); + assert(nsswitch != NULL); + char nsswitch_line[256]; + size_t generated_hosts_entries = 0; + while (fgets(nsswitch_line, sizeof(nsswitch_line), nsswitch) != NULL) { + generated_hosts_entries += + strcmp(nsswitch_line, "hosts: files dns\n") == 0; + } + assert(feof(nsswitch)); + assert(generated_hosts_entries == 1); + assert(fclose(nsswitch) == 0); + + struct addrinfo hints = { + .ai_family = AF_INET, + .ai_socktype = SOCK_STREAM, + .ai_protocol = IPPROTO_TCP, + }; + struct addrinfo *addresses = NULL; + int resolve_result = + getaddrinfo("service.example", NULL, &hints, &addresses); + if (resolve_result != 0) { + fprintf(stderr, "getaddrinfo failed: %s\n", gai_strerror(resolve_result)); + } + assert(resolve_result == 0); + assert(addresses != NULL); + assert(addresses->ai_family == AF_INET); + assert(addresses->ai_addrlen == sizeof(struct sockaddr_in)); + + struct sockaddr_in destination = + *(const struct sockaddr_in *)addresses->ai_addr; + freeaddrinfo(addresses); + assert(destination.sin_addr.s_addr == inet_addr("198.51.100.1")); + destination.sin_port = htons((uint16_t)port); + + int fd = socket(AF_INET, SOCK_STREAM, 0); + assert(fd >= 0); + assert(connect(fd, (const struct sockaddr *)&destination, + sizeof(destination)) == 0); + const char request[] = "dns-pinned request"; + assert(write(fd, request, sizeof(request)) == sizeof(request)); + assert(close(fd) == 0); + return 0; +} diff --git a/litebox_runner_linux_userland/tests/run.rs b/litebox_runner_linux_userland/tests/run.rs index 64f336be3..79ab4417a 100644 --- a/litebox_runner_linux_userland/tests/run.rs +++ b/litebox_runner_linux_userland/tests/run.rs @@ -23,6 +23,7 @@ const DEDICATED_C_TESTS: &[&str] = &[ const BROKER_ONLY_C_TESTS: &[&str] = &[ "eventfd.c", + "dns_broker.c", "pipe_broker.c", "tcp_broker.c", "tcp_broker_server.c", @@ -366,7 +367,29 @@ fn spawn_test_broker( policy: litebox_broker_core::PolicyEngine, connection_count: usize, ) -> TestBroker { - spawn_test_broker_with_mode(control_socket_path, policy, connection_count, false) + spawn_test_broker_with_mode( + control_socket_path, + policy, + connection_count, + Vec::new(), + false, + ) +} + +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +fn spawn_test_broker_with_dns( + control_socket_path: &Path, + policy: litebox_broker_core::PolicyEngine, + connection_count: usize, + dns_records: Vec, +) -> TestBroker { + spawn_test_broker_with_mode( + control_socket_path, + policy, + connection_count, + dns_records, + false, + ) } #[cfg(all(target_arch = "x86_64", target_os = "linux"))] @@ -375,7 +398,13 @@ fn spawn_concurrent_test_broker( policy: litebox_broker_core::PolicyEngine, connection_count: usize, ) -> TestBroker { - spawn_test_broker_with_mode(control_socket_path, policy, connection_count, true) + spawn_test_broker_with_mode( + control_socket_path, + policy, + connection_count, + Vec::new(), + true, + ) } #[cfg(all(target_arch = "x86_64", target_os = "linux"))] @@ -383,6 +412,7 @@ fn spawn_test_broker_with_mode( control_socket_path: &Path, policy: litebox_broker_core::PolicyEngine, connection_count: usize, + dns_records: Vec, concurrent: bool, ) -> TestBroker { let _ = std::fs::remove_file(control_socket_path); @@ -402,9 +432,10 @@ fn spawn_test_broker_with_mode( policy, limits, std::sync::Arc::new( - litebox_broker_platform_linux_userland::LinuxSocketProvider::new( + litebox_broker_platform_linux_userland::LinuxSocketProvider::new_with_dns_records( limits.max_sockets, limits.max_sockets_per_session, + &dns_records, ) .expect("failed to create broker test socket provider"), ), @@ -705,6 +736,48 @@ fn test_runner_broker_tcp_client_with_rewriter() { server.join().unwrap(); } +#[cfg(all(target_arch = "x86_64", target_os = "linux"))] +#[test] +fn test_runner_broker_dns_pins_hostname_to_gateway() { + use std::io::Read as _; + use std::net::{Ipv4Addr, TcpListener}; + + let target = common::compile( + "./tests/dns_broker.c", + "broker_dns_client_rewriter", + false, + false, + ); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, peer) = listener.accept().unwrap(); + assert!(peer.ip().is_loopback()); + let mut request = [0; 19]; + stream.read_exact(&mut request).unwrap(); + assert_eq!(&request, b"dns-pinned request\0"); + }); + + let control_socket_path = unique_test_socket_path("runner-broker-dns-control"); + let broker = spawn_test_broker_with_dns( + &control_socket_path, + litebox_broker_core::PolicyEngine::with_host_guaranteed_rights( + litebox_broker_core::ObjectRights::all(), + ) + .with_socket_policy(gateway_tcp_policy()), + 1, + vec!["service.example=10.0.2.1".parse().unwrap()], + ); + let mut runner = Runner::new(&target, "broker_dns_client_rewriter"); + runner + .arg(port.to_string()) + .broker_socket(&control_socket_path) + .run(); + assert!(broker.next_close_object_count() >= 2); + broker.join(); + server.join().unwrap(); +} + #[cfg(all(target_arch = "x86_64", target_os = "linux"))] #[test] fn test_runner_broker_udp_with_rewriter() { diff --git a/litebox_shim_linux/src/lib.rs b/litebox_shim_linux/src/lib.rs index 03d2db53e..668e9ef7d 100644 --- a/litebox_shim_linux/src/lib.rs +++ b/litebox_shim_linux/src/lib.rs @@ -62,18 +62,22 @@ mod wait; use crate::syscalls::file::get_file_descriptor_flags; -pub type DefaultFS = LinuxFS; +/// Lower filesystem used by [`LinuxShimBuilder::default_fs`]. +pub type DefaultLowerFS = litebox::fs::layered::FileSystem< + Platform, + litebox::fs::resolver::Resolver, + litebox::fs::resolver::Resolver, +>; -pub(crate) type LinuxFS = litebox::fs::layered::FileSystem< +/// Default layered filesystem used by the Linux shim. +pub type DefaultFS = litebox::fs::layered::FileSystem< Platform, litebox::fs::in_mem::FileSystem, - litebox::fs::layered::FileSystem< - Platform, - litebox::fs::resolver::Resolver, - litebox::fs::resolver::Resolver, - >, + DefaultLowerFS, >; +pub(crate) type LinuxFS = DefaultFS; + pub(crate) type FileFd = litebox::fd::TypedFd; /// A trait required for file systems to be used in the shim. @@ -249,6 +253,21 @@ impl LinuxShimBuilder { default_fs(&self.litebox, in_mem_fs, tar_data) } + /// Creates the default filesystem after configuring its in-memory upper. + pub fn default_fs_with_hook( + &self, + mut in_mem_fs: litebox::fs::in_mem::FileSystem, + tar_data: Cow<'static, [u8]>, + hook: impl FnOnce( + &DefaultLowerFS, + &mut litebox::fs::in_mem::FileSystem, + ) -> core::result::Result<(), Error>, + ) -> core::result::Result, Error> { + let lower = default_lower_fs(&self.litebox, tar_data); + hook(&lower, &mut in_mem_fs)?; + Ok(compose_default_fs(&self.litebox, in_mem_fs, lower)) + } + /// Build the shim. pub fn build(self) -> LinuxShim { let net = Network::new(&self.litebox); @@ -393,6 +412,14 @@ fn default_fs( in_mem_fs: litebox::fs::in_mem::FileSystem, tar_data: Cow<'static, [u8]>, ) -> LinuxFS { + let lower = default_lower_fs(litebox, tar_data); + compose_default_fs(litebox, in_mem_fs, lower) +} + +fn default_lower_fs( + litebox: &LiteBox, + tar_data: Cow<'static, [u8]>, +) -> DefaultLowerFS { let dev_stdio = litebox::fs::resolver::Resolver::new( litebox, litebox::fs::composer::Composer::builder() @@ -411,15 +438,23 @@ fn default_fs( .build() .unwrap(), ); + litebox::fs::layered::FileSystem::new( + litebox, + dev_stdio, + tar_ro, + litebox::fs::layered::LayeringSemantics::LowerLayerReadOnly, + ) +} + +fn compose_default_fs( + litebox: &LiteBox, + in_mem_fs: litebox::fs::in_mem::FileSystem, + lower: DefaultLowerFS, +) -> DefaultFS { litebox::fs::layered::FileSystem::new( litebox, in_mem_fs, - litebox::fs::layered::FileSystem::new( - litebox, - dev_stdio, - tar_ro, - litebox::fs::layered::LayeringSemantics::LowerLayerReadOnly, - ), + lower, litebox::fs::layered::LayeringSemantics::LowerLayerWritableFiles, ) } diff --git a/litebox_shim_linux/src/syscalls/net.rs b/litebox_shim_linux/src/syscalls/net.rs index a017a6cc9..26fa99511 100644 --- a/litebox_shim_linux/src/syscalls/net.rs +++ b/litebox_shim_linux/src/syscalls/net.rs @@ -271,10 +271,15 @@ impl SocketAddress { } #[derive(Default, Clone)] +#[expect( + clippy::struct_excessive_bools, + reason = "Linux socket options are independent boolean settings" +)] pub(super) struct SocketOptions { pub(super) reuse_address: bool, pub(super) keep_alive: bool, pub(super) broadcast: bool, + pub(super) ip_recverr: bool, /// Receiving timeout, None (default value) means no timeout pub(super) recv_timeout: Option, /// Sending timeout, None (default value) means no timeout @@ -600,6 +605,17 @@ impl GlobalState { litebox_util_log::debug!("accepting and ignoring setsockopt(IP_TOS)"); return Ok(()); } + // The broker already reports asynchronous network failures + // through normal socket error state, but does not expose + // Linux's ancillary extended-error records. Accept the option + // so libc resolvers can use the socket's ordinary error path. + litebox_common_linux::IpOption::RECVERR => { + let enabled = super::read_from_user::(optval, optlen)? != 0; + self.with_socket_options_mut(fd, |options| { + options.ip_recverr = enabled; + }); + return Ok(()); + } }, SocketOptionName::Socket(so) => match so { // handled by `setsockopt_common` @@ -773,7 +789,12 @@ impl GlobalState { let val: u32 = match optname { SocketOptionName::IP(ipopt) => match ipopt { - litebox_common_linux::IpOption::TOS => return Err(Errno::EOPNOTSUPP), + litebox_common_linux::IpOption::TOS => { + return Err(Errno::EOPNOTSUPP); + } + litebox_common_linux::IpOption::RECVERR => { + u32::from(self.with_socket_options(fd, |options| options.ip_recverr)) + } }, SocketOptionName::Socket(sopt) => match sopt { // handled by `getsockopt_common` @@ -1090,6 +1111,9 @@ impl GlobalState { context: ReceiveContext, mut source_addr: Option<&mut Option>, ) -> Result { + if flags.contains(ReceiveFlags::ERRQUEUE) { + return self.receive_socket_error(socket); + } let timeout = context .deadline .map(|deadline| { @@ -1113,7 +1137,6 @@ impl GlobalState { ReceiveFlags, litebox::net::ReceiveFlags, CMSG_CLOEXEC, - ERRQUEUE, OOB, PEEK, WAITALL, @@ -1359,6 +1382,19 @@ impl GlobalState { result } + fn receive_socket_error( + &self, + socket: &InetSocketPin<'_, Platform, FS>, + ) -> Result { + if !matches!(socket.socket_type, SockType::Datagram) { + return Err(Errno::EOPNOTSUPP); + } + match socket.proxy.get_async_error(true) { + Some(error) => Err(error.into()), + None => Err(Errno::EAGAIN), + } + } + fn close_unpublished_socket(&self, fd: &SocketFd) { self.close_network_socket(fd, CloseBehavior::Immediate) .expect("closing an unpublished socket must succeed"); @@ -1746,10 +1782,17 @@ impl Task { self.global .accept(&self.wait_cx(), fd, socket_addr.as_mut())?; let peer_addr = socket_addr.map(SocketAddress::Inet); + let ip_recverr = self + .global + .with_socket_options(fd, |options| options.ip_recverr); let proxy = self .global .initialize_socket(&accepted_file, sock_type, flags); + self.global + .with_socket_options_mut(&accepted_file, |options| { + options.ip_recverr = ip_recverr; + }); proxy.set_state(SocketState::Connected); let raw_fd = files .insert_raw_fd(accepted_file) @@ -2068,6 +2111,12 @@ impl Task { .files .borrow() .pin_receive_socket(&self.global, sockfd)?; + if flags.contains(ReceiveFlags::ERRQUEUE) { + return match &socket { + ReceiveSocket::Inet(socket) => self.global.receive_socket_error(socket), + ReceiveSocket::Unix(_) => Err(Errno::EOPNOTSUPP), + }; + } let (chunk_waitall, preflight_stream, deadline) = self.inet_stream_receive_plan(&socket, flags)?; let copy_received = Self::receive_copies_data(&socket, flags); @@ -2310,7 +2359,8 @@ impl Task { let supported_flags = ReceiveFlags::DONTWAIT | ReceiveFlags::PEEK | ReceiveFlags::TRUNC - | ReceiveFlags::WAITALL; + | ReceiveFlags::WAITALL + | ReceiveFlags::ERRQUEUE; if flags.intersects(supported_flags.complement()) { log_unsupported!("Unsupported recvmsg flags: {:?}", flags); return Err(Errno::EINVAL); @@ -2320,6 +2370,12 @@ impl Task { .files .borrow() .pin_receive_socket(&self.global, sockfd)?; + if flags.contains(ReceiveFlags::ERRQUEUE) { + return match &socket { + ReceiveSocket::Inet(socket) => self.global.receive_socket_error(socket), + ReceiveSocket::Unix(_) => Err(Errno::EOPNOTSUPP), + }; + } self.do_recvmsg(&socket, msg_ptr, flags) } fn do_recvmsg( @@ -2501,7 +2557,8 @@ impl Task { | ReceiveFlags::PEEK | ReceiveFlags::TRUNC | ReceiveFlags::WAITALL - | ReceiveFlags::WAITFORONE; + | ReceiveFlags::WAITFORONE + | ReceiveFlags::ERRQUEUE; if flags.intersects(supported_flags.complement()) { log_unsupported!("Unsupported recvmmsg flags: {:?}", flags); return Err(Errno::EINVAL); @@ -2527,6 +2584,12 @@ impl Task { if vlen == 0 { return Ok(0); } + if flags.contains(ReceiveFlags::ERRQUEUE) { + return match &socket { + ReceiveSocket::Inet(socket) => self.global.receive_socket_error(socket), + ReceiveSocket::Unix(_) => Err(Errno::EOPNOTSUPP), + }; + } // A `None` deadline means either no user-supplied timeout or a saturating overflow // — both are treated as "no deadline". @@ -2812,7 +2875,7 @@ mod tests { use alloc::string::ToString as _; use litebox::utils::TruncateExt as _; use litebox_common_linux::{ - AddressFamily, MapFlags, ProtFlags, ReceiveFlags, SendFlags, SockFlags, SockType, + AddressFamily, IpOption, MapFlags, ProtFlags, ReceiveFlags, SendFlags, SockFlags, SockType, SocketOption, SocketOptionName, errno::Errno, }; use zerocopy::FromZeros as _; @@ -3074,6 +3137,121 @@ mod tests { optval } + fn set_ip_recverr(task: &TestTask, sockfd: u32, enabled: bool) { + let value = u32::from(enabled); + task.do_setsockopt( + sockfd, + SocketOptionName::IP(IpOption::RECVERR), + UserPtr::from_usize((&raw const value).cast::() as usize), + core::mem::size_of_val(&value), + ) + .unwrap(); + } + + fn get_ip_recverr(task: &TestTask, sockfd: u32) -> bool { + let mut value = 0u32; + let len = task + .do_getsockopt( + sockfd, + SocketOptionName::IP(IpOption::RECVERR), + UserPtrMut::from_usize((&raw mut value).cast::() as usize), + core::mem::size_of_val(&value).trunc(), + ) + .unwrap(); + assert_eq!(len, core::mem::size_of::()); + value != 0 + } + + #[test] + #[ignore = "requires broker-backed socket test setup"] + fn ip_recverr_defaults_false_and_is_shared_by_duplicates() { + let task = init_platform(); + let socket_fd = task + .do_socket( + AddressFamily::INET, + SockType::Datagram, + SockFlags::empty(), + 0, + ) + .unwrap(); + assert!(!get_ip_recverr(&task, socket_fd)); + + set_ip_recverr(&task, socket_fd, true); + let duplicate = task + .sys_dup(i32::try_from(socket_fd).unwrap(), None, None) + .unwrap(); + assert!(get_ip_recverr(&task, duplicate)); + + set_ip_recverr(&task, duplicate, false); + assert!(!get_ip_recverr(&task, socket_fd)); + } + + #[test] + #[ignore = "requires broker-backed socket test setup"] + fn msg_errqueue_consumes_internet_datagram_errors_without_blocking() { + let task = init_platform(); + let socket_fd = task + .do_socket( + AddressFamily::INET, + SockType::Datagram, + SockFlags::empty(), + 0, + ) + .unwrap(); + { + let socket = task + .files + .borrow() + .try_pin_inet_socket(&task.global, socket_fd as usize) + .unwrap() + .unwrap(); + socket + .proxy + .set_async_error(litebox::net::errors::SocketAsyncError::ConnectionRefused); + } + + assert_eq!( + task.do_recvfrom(socket_fd, &mut [], ReceiveFlags::ERRQUEUE, None) + .unwrap_err(), + Errno::ECONNREFUSED + ); + assert_eq!( + task.do_recvfrom(socket_fd, &mut [], ReceiveFlags::ERRQUEUE, None) + .unwrap_err(), + Errno::EAGAIN + ); + } + + #[test] + #[ignore = "requires broker-backed socket test setup"] + fn msg_errqueue_rejects_streams_without_consuming_so_error() { + let task = init_platform(); + let socket_fd = task + .do_socket(AddressFamily::INET, SockType::Stream, SockFlags::empty(), 0) + .unwrap(); + { + let socket = task + .files + .borrow() + .try_pin_inet_socket(&task.global, socket_fd as usize) + .unwrap() + .unwrap(); + socket + .proxy + .set_async_error(litebox::net::errors::SocketAsyncError::ConnectionRefused); + } + + assert_eq!( + task.do_recvfrom(socket_fd, &mut [], ReceiveFlags::ERRQUEUE, None) + .unwrap_err(), + Errno::EOPNOTSUPP + ); + assert_eq!( + get_so_error(&task, socket_fd), + i32::from(Errno::ECONNREFUSED).cast_unsigned() + ); + } + fn epoll_add(task: &TestTask, epfd: i32, target_fd: u32, events: litebox::event::Events) { let ev = litebox_common_linux::EpollEvent::new(events.bits(), u64::from(target_fd)); let ev_ptr = (&raw const ev).cast::(); diff --git a/litebox_shim_linux/src/syscalls/unix.rs b/litebox_shim_linux/src/syscalls/unix.rs index 2c5ca54c7..b6b9a3b74 100644 --- a/litebox_shim_linux/src/syscalls/unix.rs +++ b/litebox_shim_linux/src/syscalls/unix.rs @@ -1562,7 +1562,7 @@ impl UnixSocket { match optname { SocketOptionName::IP(ip) => match ip { - IpOption::TOS => Err(Errno::EOPNOTSUPP), + IpOption::TOS | IpOption::RECVERR => Err(Errno::EOPNOTSUPP), }, SocketOptionName::Socket(so) => match so { // handled by `setsockopt_common` @@ -1619,7 +1619,7 @@ impl UnixSocket { let val: u32 = match optname { SocketOptionName::IP(ip) => match ip { - IpOption::TOS => return Err(Errno::EOPNOTSUPP), + IpOption::TOS | IpOption::RECVERR => return Err(Errno::EOPNOTSUPP), }, SocketOptionName::Socket(so) => match so { // handled by `getsockopt_common`