756 lines
28 KiB
Rust
756 lines
28 KiB
Rust
use std::fs::File;
|
|
use std::io::{self, Read, Seek, SeekFrom};
|
|
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream, UdpSocket};
|
|
use std::os::fd::{AsFd, AsRawFd, OwnedFd};
|
|
use std::path::Path;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use lazaret_core::codec::decode;
|
|
use lazaret_core::identity::{
|
|
ConfigurationFingerprint, DerivedPolicyDigest, DomainId, GenerationId, NetworkSidecarId,
|
|
PidfsProcessIdentityV1,
|
|
};
|
|
use lazaret_core::network::{CanonicalDerivedNetworkPolicy, derive_policy};
|
|
use lazaret_core::records::{
|
|
DeniedDestinationObservation, DerivedNetworkPolicy, DerivedNetworkPolicyFields, DnsObservation,
|
|
HostProcessOwnershipSlot, IpAddress, IpNetwork, NetworkObservation, NetworkReachabilityProbe,
|
|
NetworkReadinessAttestation, NetworkReadinessAttestationFields,
|
|
PermittedDestinationObservation, PolicyAction, PolicyDelivery, ProcessOwnershipCommitAck,
|
|
StagedProcessOwnership,
|
|
};
|
|
use lazaret_linux::filesystem::{namespace_identity, require_write_sealed};
|
|
use lazaret_linux::process::{
|
|
ChildStandardIo, ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker,
|
|
};
|
|
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
|
use rustix::io::{FdFlags, fcntl_setfd, read};
|
|
use rustix::mount::{MountPropagationFlags, mount_change};
|
|
use rustix::pipe::{PipeFlags, pipe_with};
|
|
use rustix::process::{Signal, getgid, getuid};
|
|
use rustix::thread::UnshareFlags;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
const MAX_POLICY_BYTES: u64 = 64 * 1024;
|
|
const HELPER_READY_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const ROUTE_READY_TIMEOUT: Duration = Duration::from_secs(15);
|
|
const SLIRP4NETNS: &str = "/usr/bin/slirp4netns";
|
|
const IP: &str = "/usr/bin/ip";
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
|
const MAX_DNS_PACKET: usize = 4096;
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub struct TcpProbeTarget {
|
|
pub address: IpAddress,
|
|
pub port: u16,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub struct DnsProbeTarget {
|
|
pub resolver: IpAddress,
|
|
pub query_name: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
pub struct NetworkProbeRequest {
|
|
pub domain_id: DomainId,
|
|
pub generation_id: GenerationId,
|
|
pub enforcement_namespace: lazaret_core::identity::NamespaceIdentity,
|
|
pub denied: Vec<TcpProbeTarget>,
|
|
pub permitted: Vec<TcpProbeTarget>,
|
|
pub dns: Vec<DnsProbeTarget>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct NetworkAttestationContext {
|
|
pub domain_id: DomainId,
|
|
pub generation_id: GenerationId,
|
|
pub configuration_fingerprint: ConfigurationFingerprint,
|
|
pub network_sidecar_id: NetworkSidecarId,
|
|
pub runtime_supervisor: PidfsProcessIdentityV1,
|
|
pub coordinator_id: lazaret_core::identity::CoordinatorId,
|
|
}
|
|
|
|
pub struct PreparedNetwork {
|
|
helper: Option<RetainedProcess>,
|
|
helper_exit: Option<OwnedFd>,
|
|
attestation: NetworkReadinessAttestation,
|
|
}
|
|
|
|
impl PreparedNetwork {
|
|
#[must_use]
|
|
pub const fn attestation(&self) -> &NetworkReadinessAttestation {
|
|
&self.attestation
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn helper_identity(&self) -> Option<PidfsProcessIdentityV1> {
|
|
self.helper.as_ref().map(RetainedProcess::identity)
|
|
}
|
|
|
|
/// # Errors
|
|
/// Returns an error when exact helper liveness cannot be inspected.
|
|
pub fn helper_exited(&self) -> io::Result<bool> {
|
|
self.helper
|
|
.as_ref()
|
|
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?
|
|
.has_exited()
|
|
}
|
|
|
|
/// # Errors
|
|
/// Returns an error unless the exact helper has exited and can be reaped.
|
|
pub fn reap_exited(mut self) -> io::Result<()> {
|
|
self.helper_exit.take();
|
|
let helper = self
|
|
.helper
|
|
.take()
|
|
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?;
|
|
if !helper.has_exited()? {
|
|
return Err(io::Error::other("connectivity helper is still present"));
|
|
}
|
|
child_broker().reap(helper)
|
|
}
|
|
|
|
/// # Errors
|
|
/// Returns an error when the exact helper cannot be stopped and reaped.
|
|
pub fn stop(mut self) -> io::Result<()> {
|
|
self.helper_exit.take();
|
|
let helper = self
|
|
.helper
|
|
.take()
|
|
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?;
|
|
let terminate = if helper.has_exited()? {
|
|
None
|
|
} else {
|
|
helper.send_signal(Signal::TERM).err()
|
|
};
|
|
let reaped = child_broker().reap(helper);
|
|
match (terminate, reaped) {
|
|
(None, Ok(())) => Ok(()),
|
|
(Some(error), Ok(())) | (None, Err(error)) => Err(error),
|
|
(Some(error), Err(reap)) => Err(io::Error::new(
|
|
error.kind(),
|
|
format!("{error}; exact connectivity-helper reap failed: {reap}"),
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Verifies sealed policy delivery, supervises slirp4netns, installs routes, and attests state.
|
|
///
|
|
/// # Errors
|
|
/// Refuses mutable or mismatched policy, helper acquisition/readiness, route, or namespace state.
|
|
pub fn prepare_network<Stage, Acknowledge>(
|
|
policy_file: &File,
|
|
expected_digest: DerivedPolicyDigest,
|
|
context: NetworkAttestationContext,
|
|
stage: Stage,
|
|
acknowledge: Acknowledge,
|
|
) -> io::Result<PreparedNetwork>
|
|
where
|
|
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
|
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
|
{
|
|
let policy = read_sealed_policy(policy_file, expected_digest)?;
|
|
let (helper_ready, helper_ready_signal) = pipe_with(PipeFlags::CLOEXEC)?;
|
|
let (helper_exit, helper_exit_signal) = pipe_with(PipeFlags::CLOEXEC)?;
|
|
fcntl_setfd(helper_ready_signal.as_fd(), FdFlags::empty())?;
|
|
fcntl_setfd(helper_exit.as_fd(), FdFlags::empty())?;
|
|
let target = slirp_target(
|
|
context.runtime_supervisor,
|
|
helper_exit.as_raw_fd(),
|
|
helper_ready_signal.as_raw_fd(),
|
|
);
|
|
let helper = child_broker().acquire_with_stdio_after_ack(
|
|
context.runtime_supervisor,
|
|
ProcessOwnershipContext {
|
|
domain_id: context.domain_id,
|
|
generation_id: context.generation_id,
|
|
coordinator_id: context.coordinator_id,
|
|
slot: HostProcessOwnershipSlot::HelperConnectivityHelper,
|
|
},
|
|
&target,
|
|
ChildStandardIo::silenced(),
|
|
stage,
|
|
acknowledge,
|
|
enter_domain_namespaces,
|
|
)?;
|
|
drop(helper_ready_signal);
|
|
drop(helper_exit);
|
|
let enforcement_namespace = match namespace_identity(Path::new("/proc/self/ns/net")) {
|
|
Ok(namespace) => namespace,
|
|
Err(error) => return cleanup_helper(helper, helper_exit_signal, error),
|
|
};
|
|
if let Err(error) = wait_for_helper_ready(&helper_ready, &helper) {
|
|
return cleanup_helper(helper, helper_exit_signal, error);
|
|
}
|
|
let observed_routes = match install_policy_routes(&policy) {
|
|
Ok(routes) => routes,
|
|
Err(error) => return cleanup_helper(helper, helper_exit_signal, error),
|
|
};
|
|
let attestation = NetworkReadinessAttestation::v1(NetworkReadinessAttestationFields {
|
|
domain_id: context.domain_id,
|
|
generation_id: context.generation_id,
|
|
configuration_fingerprint: context.configuration_fingerprint,
|
|
network_sidecar_id: context.network_sidecar_id,
|
|
derived_policy_digest: policy.digest(),
|
|
enforcement_namespace,
|
|
runtime_supervisor: context.runtime_supervisor,
|
|
connectivity_helper: helper.identity(),
|
|
observation: NetworkObservation {
|
|
policy_delivery: PolicyDelivery::SealedMemfd,
|
|
helper_ready: true,
|
|
observed_routes,
|
|
routes_attested: true,
|
|
},
|
|
});
|
|
Ok(PreparedNetwork {
|
|
helper: Some(helper),
|
|
helper_exit: Some(helper_exit_signal),
|
|
attestation,
|
|
})
|
|
}
|
|
|
|
#[allow(deprecated)]
|
|
fn enter_domain_namespaces() -> io::Result<()> {
|
|
let uid = getuid().as_raw();
|
|
let gid = getgid().as_raw();
|
|
rustix::thread::unshare(UnshareFlags::NEWUSER)?;
|
|
std::fs::write("/proc/self/setgroups", "deny")?;
|
|
std::fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
|
|
std::fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
|
|
rustix::thread::unshare(UnshareFlags::NEWNS | UnshareFlags::NEWNET)?;
|
|
mount_change(
|
|
"/",
|
|
MountPropagationFlags::PRIVATE | MountPropagationFlags::REC,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Executes the generation-bound reachability probe in the current enforcement namespace.
|
|
///
|
|
/// # Errors
|
|
/// Refuses changed namespace identity, empty coverage, non-IPv4 targets, or malformed DNS names.
|
|
pub fn probe_current_network(
|
|
request: &NetworkProbeRequest,
|
|
) -> io::Result<NetworkReachabilityProbe> {
|
|
if namespace_identity(Path::new("/proc/self/ns/net"))? != request.enforcement_namespace {
|
|
return Err(io::Error::other("network probe namespace identity changed"));
|
|
}
|
|
if request.denied.is_empty() || request.permitted.is_empty() || request.dns.is_empty() {
|
|
return Err(io::Error::other("network probe coverage is incomplete"));
|
|
}
|
|
let denied_destinations = request
|
|
.denied
|
|
.iter()
|
|
.map(|target| {
|
|
Ok(DeniedDestinationObservation {
|
|
destination: target.address,
|
|
unreachable: TcpStream::connect_timeout(&socket_address(*target)?, PROBE_TIMEOUT)
|
|
.is_err(),
|
|
})
|
|
})
|
|
.collect::<io::Result<Vec<_>>>()?;
|
|
let permitted_destinations = request
|
|
.permitted
|
|
.iter()
|
|
.map(|target| {
|
|
Ok(PermittedDestinationObservation {
|
|
destination: target.address,
|
|
reachable: TcpStream::connect_timeout(&socket_address(*target)?, PROBE_TIMEOUT)
|
|
.is_ok(),
|
|
})
|
|
})
|
|
.collect::<io::Result<Vec<_>>>()?;
|
|
let dns_observations = request
|
|
.dns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, target)| dns_probe(target, u16::try_from(index).unwrap_or(u16::MAX)))
|
|
.collect::<io::Result<Vec<_>>>()?;
|
|
Ok(NetworkReachabilityProbe {
|
|
domain_id: request.domain_id,
|
|
generation_id: request.generation_id,
|
|
enforcement_namespace: request.enforcement_namespace,
|
|
denied_destinations,
|
|
permitted_destinations,
|
|
dns_observations,
|
|
})
|
|
}
|
|
|
|
fn socket_address(target: TcpProbeTarget) -> io::Result<std::net::SocketAddr> {
|
|
let IpAddress::V4(address) = target.address else {
|
|
return Err(io::Error::other("v1 network probes require IPv4"));
|
|
};
|
|
if target.port == 0 {
|
|
return Err(io::Error::other("network probe port is invalid"));
|
|
}
|
|
Ok(SocketAddrV4::new(Ipv4Addr::from(address), target.port).into())
|
|
}
|
|
|
|
fn dns_probe(target: &DnsProbeTarget, transaction_id: u16) -> io::Result<DnsObservation> {
|
|
let IpAddress::V4(resolver) = target.resolver else {
|
|
return Err(io::Error::other("v1 DNS probes require IPv4"));
|
|
};
|
|
let query = dns_query(transaction_id, &target.query_name)?;
|
|
let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))?;
|
|
socket.set_read_timeout(Some(PROBE_TIMEOUT))?;
|
|
socket.set_write_timeout(Some(PROBE_TIMEOUT))?;
|
|
socket.send_to(&query, SocketAddrV4::new(Ipv4Addr::from(resolver), 53))?;
|
|
let mut response = [0_u8; MAX_DNS_PACKET];
|
|
let (length, source) = socket.recv_from(&mut response).map_err(|error| {
|
|
io::Error::new(
|
|
error.kind(),
|
|
format!(
|
|
"DNS probe receive from {}.{}.{}.{}:53 for {} failed: {error}",
|
|
resolver[0], resolver[1], resolver[2], resolver[3], target.query_name
|
|
),
|
|
)
|
|
})?;
|
|
if source.ip() != Ipv4Addr::from(resolver) || source.port() != 53 {
|
|
return Err(io::Error::other(
|
|
"DNS probe response source mismatched resolver",
|
|
));
|
|
}
|
|
let resolved_addresses = dns_answers(&response[..length], transaction_id)?;
|
|
Ok(DnsObservation {
|
|
resolver: target.resolver,
|
|
query_name: target.query_name.clone(),
|
|
resolved_addresses,
|
|
})
|
|
}
|
|
|
|
fn dns_query(transaction_id: u16, name: &str) -> io::Result<Vec<u8>> {
|
|
if name.is_empty() || name.len() > 253 || !name.is_ascii() {
|
|
return Err(io::Error::other("DNS probe name is invalid"));
|
|
}
|
|
let mut query = Vec::with_capacity(name.len() + 18);
|
|
query.extend_from_slice(&transaction_id.to_be_bytes());
|
|
query.extend_from_slice(&[0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
|
for label in name.trim_end_matches('.').split('.') {
|
|
if label.is_empty()
|
|
|| label.len() > 63
|
|
|| !label
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
|
{
|
|
return Err(io::Error::other("DNS probe label is invalid"));
|
|
}
|
|
query.push(
|
|
u8::try_from(label.len()).map_err(|_| io::Error::other("DNS label is oversized"))?,
|
|
);
|
|
query.extend_from_slice(label.as_bytes());
|
|
}
|
|
query.extend_from_slice(&[0, 0, 1, 0, 1]);
|
|
Ok(query)
|
|
}
|
|
|
|
fn dns_answers(response: &[u8], transaction_id: u16) -> io::Result<Vec<IpAddress>> {
|
|
if response.len() < 12
|
|
|| response[..2] != transaction_id.to_be_bytes()
|
|
|| response[2] & 0x80 == 0
|
|
|| response[3] & 0x0f != 0
|
|
|| u16::from_be_bytes([response[4], response[5]]) != 1
|
|
{
|
|
return Err(io::Error::other("DNS probe response header is invalid"));
|
|
}
|
|
let answers = u16::from_be_bytes([response[6], response[7]]);
|
|
let mut offset = skip_dns_name(response, 12)?;
|
|
offset = offset
|
|
.checked_add(4)
|
|
.filter(|offset| *offset <= response.len())
|
|
.ok_or_else(|| io::Error::other("DNS probe question is truncated"))?;
|
|
let mut addresses = Vec::new();
|
|
for _ in 0..answers {
|
|
offset = skip_dns_name(response, offset)?;
|
|
let header_end = offset
|
|
.checked_add(10)
|
|
.filter(|end| *end <= response.len())
|
|
.ok_or_else(|| io::Error::other("DNS probe answer is truncated"))?;
|
|
let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]);
|
|
let class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]);
|
|
let data_length = usize::from(u16::from_be_bytes([
|
|
response[offset + 8],
|
|
response[offset + 9],
|
|
]));
|
|
let data_end = header_end
|
|
.checked_add(data_length)
|
|
.filter(|end| *end <= response.len())
|
|
.ok_or_else(|| io::Error::other("DNS probe record data is truncated"))?;
|
|
if record_type == 1 && class == 1 && data_length == 4 {
|
|
addresses.push(IpAddress::V4(
|
|
response[header_end..data_end]
|
|
.try_into()
|
|
.map_err(|_| io::Error::other("DNS A record is malformed"))?,
|
|
));
|
|
}
|
|
offset = data_end;
|
|
}
|
|
if addresses.is_empty() {
|
|
return Err(io::Error::other("DNS probe returned no IPv4 addresses"));
|
|
}
|
|
Ok(addresses)
|
|
}
|
|
|
|
fn skip_dns_name(packet: &[u8], mut offset: usize) -> io::Result<usize> {
|
|
loop {
|
|
let length = *packet
|
|
.get(offset)
|
|
.ok_or_else(|| io::Error::other("DNS name is truncated"))?;
|
|
offset += 1;
|
|
if length == 0 {
|
|
return Ok(offset);
|
|
}
|
|
if length & 0xc0 == 0xc0 {
|
|
return offset
|
|
.checked_add(1)
|
|
.filter(|end| *end <= packet.len())
|
|
.ok_or_else(|| io::Error::other("DNS compression pointer is truncated"));
|
|
}
|
|
if length & 0xc0 != 0 || length > 63 {
|
|
return Err(io::Error::other("DNS name label is malformed"));
|
|
}
|
|
offset = offset
|
|
.checked_add(usize::from(length))
|
|
.filter(|end| *end <= packet.len())
|
|
.ok_or_else(|| io::Error::other("DNS name label is truncated"))?;
|
|
}
|
|
}
|
|
|
|
fn read_sealed_policy(
|
|
policy_file: &File,
|
|
expected_digest: DerivedPolicyDigest,
|
|
) -> io::Result<CanonicalDerivedNetworkPolicy> {
|
|
require_write_sealed(policy_file)?;
|
|
let length = policy_file.metadata()?.len();
|
|
if length == 0 || length > MAX_POLICY_BYTES {
|
|
return Err(io::Error::other("sealed network policy has invalid size"));
|
|
}
|
|
let mut policy_file = policy_file.try_clone()?;
|
|
policy_file.seek(SeekFrom::Start(0))?;
|
|
let mut bytes = Vec::with_capacity(
|
|
usize::try_from(length).map_err(|_| io::Error::other("network policy is oversized"))?,
|
|
);
|
|
policy_file
|
|
.take(MAX_POLICY_BYTES + 1)
|
|
.read_to_end(&mut bytes)?;
|
|
if u64::try_from(bytes.len()).ok() != Some(length) {
|
|
return Err(io::Error::other(
|
|
"sealed network policy read was incomplete",
|
|
));
|
|
}
|
|
let decoded: DerivedNetworkPolicy =
|
|
decode(&bytes).map_err(|error| io::Error::other(error.to_string()))?;
|
|
let canonical = derive_policy(DerivedNetworkPolicyFields {
|
|
address_families: decoded.address_families,
|
|
default_actions: decoded.default_actions,
|
|
prefix_rules: decoded.prefix_rules,
|
|
resolvers: decoded.resolvers,
|
|
})
|
|
.map_err(io::Error::other)?;
|
|
if canonical.digest() != expected_digest || canonical.canonical_bytes() != bytes {
|
|
return Err(io::Error::other(
|
|
"sealed network policy digest or canonical bytes mismatch",
|
|
));
|
|
}
|
|
Ok(canonical)
|
|
}
|
|
|
|
fn slirp_target(runtime: PidfsProcessIdentityV1, exit_fd: i32, ready_fd: i32) -> TargetCommand {
|
|
TargetCommand::new(SLIRP4NETNS)
|
|
.argument("--configure")
|
|
.argument("--mtu=65520")
|
|
.argument("--disable-host-loopback")
|
|
.argument("--enable-sandbox")
|
|
.argument("--enable-seccomp")
|
|
.argument(format!("--exit-fd={exit_fd}"))
|
|
.argument(format!("--ready-fd={ready_fd}"))
|
|
.argument(format!(
|
|
"--userns-path=/proc/{}/ns/user",
|
|
runtime.host_pid().get()
|
|
))
|
|
.argument(runtime.host_pid().get().to_string())
|
|
.argument("tap0")
|
|
.with_parent_death_kill_edge()
|
|
}
|
|
|
|
fn wait_for_helper_ready(ready: &OwnedFd, helper: &RetainedProcess) -> io::Result<()> {
|
|
let deadline = Instant::now() + HELPER_READY_TIMEOUT;
|
|
loop {
|
|
if helper.has_exited()? {
|
|
return Err(io::Error::other(
|
|
"slirp4netns exited before signaling readiness",
|
|
));
|
|
}
|
|
if poll_helper_ready(ready)? {
|
|
return Ok(());
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(io::Error::other("slirp4netns readiness timed out"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn poll_helper_ready(ready: &OwnedFd) -> io::Result<bool> {
|
|
let mut descriptors = [PollFd::new(ready, PollFlags::IN)];
|
|
poll(
|
|
&mut descriptors,
|
|
Some(&Timespec {
|
|
tv_sec: 0,
|
|
tv_nsec: 10_000_000,
|
|
}),
|
|
)?;
|
|
let events = descriptors[0].revents();
|
|
if events.intersects(PollFlags::IN) {
|
|
let mut byte = [0_u8; 1];
|
|
return match read(ready.as_fd(), &mut byte) {
|
|
Ok(0) => Err(io::Error::other("slirp4netns readiness pipe closed")),
|
|
Ok(_) => Ok(true),
|
|
Err(rustix::io::Errno::INTR) => Ok(false),
|
|
Err(error) => Err(error.into()),
|
|
};
|
|
}
|
|
if events.intersects(PollFlags::HUP | PollFlags::ERR) {
|
|
return Err(io::Error::other("slirp4netns readiness pipe closed"));
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn install_policy_routes(policy: &CanonicalDerivedNetworkPolicy) -> io::Result<Vec<IpNetwork>> {
|
|
run_ip(["link", "set", "lo", "up"])?;
|
|
wait_for_default_route()?;
|
|
let mut denied = Vec::new();
|
|
for rule in &policy.policy().prefix_rules {
|
|
let IpAddress::V4(address) = rule.network.address else {
|
|
continue;
|
|
};
|
|
let cidr = ipv4_cidr(address, rule.network.prefix_length);
|
|
match rule.action {
|
|
PolicyAction::Deny => {
|
|
run_ip(["-4", "route", "replace", &cidr, "dev", "lo"])?;
|
|
denied.push(rule.network);
|
|
}
|
|
PolicyAction::Allow => {
|
|
install_tap_route(&cidr, address, rule.network.prefix_length)?;
|
|
}
|
|
}
|
|
}
|
|
for resolver in &policy.policy().resolvers {
|
|
let IpAddress::V4(address) = resolver.address else {
|
|
continue;
|
|
};
|
|
let cidr = ipv4_cidr(address, 32);
|
|
install_tap_route(&cidr, address, 32)?;
|
|
}
|
|
install_tap_route("10.0.2.3/32", [10, 0, 2, 3], 32)?;
|
|
verify_loopback_routes(&denied)?;
|
|
Ok(denied)
|
|
}
|
|
|
|
fn install_tap_route(cidr: &str, address: [u8; 4], prefix_length: u8) -> io::Result<()> {
|
|
if ipv4_network_contains(address, prefix_length, [10, 0, 2, 2]) {
|
|
run_ip(["-4", "route", "replace", cidr, "dev", "tap0"])
|
|
} else {
|
|
run_ip([
|
|
"-4", "route", "replace", cidr, "via", "10.0.2.2", "dev", "tap0",
|
|
])
|
|
}
|
|
}
|
|
|
|
fn ipv4_network_contains(network: [u8; 4], prefix_length: u8, address: [u8; 4]) -> bool {
|
|
let mask = if prefix_length == 0 {
|
|
0
|
|
} else {
|
|
u32::MAX << (32 - prefix_length)
|
|
};
|
|
u32::from_be_bytes(network) & mask == u32::from_be_bytes(address) & mask
|
|
}
|
|
|
|
fn wait_for_default_route() -> io::Result<()> {
|
|
let deadline = Instant::now() + ROUTE_READY_TIMEOUT;
|
|
loop {
|
|
let routes = std::fs::read_to_string("/proc/self/net/route")?;
|
|
if routes.lines().skip(1).any(|line| {
|
|
let mut columns = line.split_whitespace();
|
|
columns.next() == Some("tap0") && columns.next() == Some("00000000")
|
|
}) {
|
|
return Ok(());
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(io::Error::other("slirp4netns default route timed out"));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
|
|
fn verify_loopback_routes(expected: &[IpNetwork]) -> io::Result<()> {
|
|
let routes = std::fs::read_to_string("/proc/self/net/route")?;
|
|
for network in expected {
|
|
let IpAddress::V4(address) = network.address else {
|
|
continue;
|
|
};
|
|
let destination = u32::from_le_bytes(address);
|
|
let mask = if network.prefix_length == 0 {
|
|
0
|
|
} else {
|
|
u32::MAX << (32 - network.prefix_length)
|
|
};
|
|
let route_table_mask = u32::from_le_bytes(mask.to_be_bytes());
|
|
let found = routes.lines().skip(1).any(|line| {
|
|
let columns = line.split_whitespace().collect::<Vec<_>>();
|
|
columns.len() >= 8
|
|
&& columns[0] == "lo"
|
|
&& u32::from_str_radix(columns[1], 16).ok() == Some(destination)
|
|
&& u32::from_str_radix(columns[7], 16).ok() == Some(route_table_mask)
|
|
});
|
|
if !found {
|
|
return Err(io::Error::other(format!(
|
|
"loopback-unicast route is absent for {}",
|
|
ipv4_cidr(address, network.prefix_length)
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn run_ip<const N: usize>(arguments: [&str; N]) -> io::Result<()> {
|
|
let mut target = TargetCommand::new(IP);
|
|
for argument in arguments {
|
|
target = target.argument(argument);
|
|
}
|
|
child_broker()
|
|
.run_transient(&target.with_parent_death_kill_edge())
|
|
.map(|_| ())
|
|
}
|
|
|
|
fn cleanup_helper<T>(helper: RetainedProcess, exit: OwnedFd, error: io::Error) -> io::Result<T> {
|
|
drop(exit);
|
|
let _ = helper.send_signal(Signal::KILL);
|
|
match child_broker().reap(helper) {
|
|
Ok(()) => Err(error),
|
|
Err(cleanup) => Err(io::Error::new(
|
|
error.kind(),
|
|
format!("{error}; exact connectivity-helper cleanup failed: {cleanup}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn ipv4_cidr(address: [u8; 4], prefix_length: u8) -> String {
|
|
format!(
|
|
"{}.{}.{}.{}/{}",
|
|
address[0], address[1], address[2], address[3], prefix_length
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use lazaret_core::network::derive_policy;
|
|
use lazaret_core::records::{
|
|
AddressFamily, AddressFamilyDisposition, AddressFamilyDispositionValue, DefaultAction,
|
|
DerivedNetworkPolicyFields, PolicyAction, PrefixRule, Resolver,
|
|
};
|
|
use lazaret_linux::filesystem::sealed_memfd;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sealed_policy_requires_exact_canonical_digest() {
|
|
let policy = policy();
|
|
let file = sealed_memfd("network-test", policy.canonical_bytes()).unwrap();
|
|
assert_eq!(read_sealed_policy(&file, policy.digest()).unwrap(), policy);
|
|
assert!(read_sealed_policy(&file, DerivedPolicyDigest::new([9; 32])).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn dns_codec_accepts_matching_ipv4_answer_and_refuses_mutation() {
|
|
let transaction = 17;
|
|
let query = dns_query(transaction, "example.com").unwrap();
|
|
let mut response = query.clone();
|
|
response[2] = 0x81;
|
|
response[3] = 0x80;
|
|
response[6..8].copy_from_slice(&1_u16.to_be_bytes());
|
|
response.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 93, 184, 216, 34]);
|
|
assert_eq!(
|
|
dns_answers(&response, transaction).unwrap(),
|
|
vec![IpAddress::V4([93, 184, 216, 34])]
|
|
);
|
|
assert!(dns_answers(&response, transaction + 1).is_err());
|
|
assert!(dns_query(transaction, "bad..name").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn probe_refuses_a_changed_enforcement_namespace_before_network_io() {
|
|
let mut namespace = namespace_identity(Path::new("/proc/self/ns/net")).unwrap();
|
|
namespace.inode = lazaret_core::identity::InodeId::new(namespace.inode.get() + 1);
|
|
let request = NetworkProbeRequest {
|
|
domain_id: DomainId::new([1; 32]),
|
|
generation_id: GenerationId::new(7),
|
|
enforcement_namespace: namespace,
|
|
denied: vec![TcpProbeTarget {
|
|
address: IpAddress::V4([10, 0, 0, 1]),
|
|
port: 1,
|
|
}],
|
|
permitted: vec![TcpProbeTarget {
|
|
address: IpAddress::V4([1, 1, 1, 1]),
|
|
port: 443,
|
|
}],
|
|
dns: vec![DnsProbeTarget {
|
|
resolver: IpAddress::V4([8, 8, 8, 8]),
|
|
query_name: "example.com".into(),
|
|
}],
|
|
};
|
|
assert!(probe_current_network(&request).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn readiness_consumes_a_pending_byte_before_observing_writer_close() {
|
|
let (reader, writer) = pipe_with(PipeFlags::empty()).unwrap();
|
|
rustix::io::write(&writer, &[1]).unwrap();
|
|
drop(writer);
|
|
assert!(poll_helper_ready(&reader).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn proc_route_mask_uses_little_endian_network_bytes() {
|
|
let mask = u32::MAX << (32 - 8);
|
|
assert_eq!(u32::from_le_bytes(mask.to_be_bytes()), 0x0000_00ff);
|
|
}
|
|
|
|
fn policy() -> CanonicalDerivedNetworkPolicy {
|
|
derive_policy(DerivedNetworkPolicyFields {
|
|
address_families: vec![
|
|
AddressFamilyDisposition {
|
|
family: AddressFamily::Ipv4,
|
|
disposition: AddressFamilyDispositionValue::Enabled,
|
|
},
|
|
AddressFamilyDisposition {
|
|
family: AddressFamily::Ipv6,
|
|
disposition: AddressFamilyDispositionValue::Disabled,
|
|
},
|
|
],
|
|
default_actions: vec![DefaultAction {
|
|
family: AddressFamily::Ipv4,
|
|
action: PolicyAction::Allow,
|
|
}],
|
|
prefix_rules: [
|
|
([10, 0, 0, 0], 8),
|
|
([172, 16, 0, 0], 12),
|
|
([192, 168, 0, 0], 16),
|
|
]
|
|
.into_iter()
|
|
.map(|(address, prefix_length)| PrefixRule {
|
|
family: AddressFamily::Ipv4,
|
|
network: IpNetwork {
|
|
address: IpAddress::V4(address),
|
|
prefix_length,
|
|
},
|
|
action: PolicyAction::Deny,
|
|
})
|
|
.collect(),
|
|
resolvers: vec![Resolver {
|
|
family: AddressFamily::Ipv4,
|
|
address: IpAddress::V4([8, 8, 8, 8]),
|
|
}],
|
|
})
|
|
.unwrap()
|
|
}
|
|
}
|