diff --git a/aci_edge_sandboxes/README.md b/aci_edge_sandboxes/README.md index b180b134b..167ed600c 100644 --- a/aci_edge_sandboxes/README.md +++ b/aci_edge_sandboxes/README.md @@ -341,10 +341,13 @@ provisioned again to accept the change. path. - A policy that denies egress without allow rules or a proxy attaches no network device. Otherwise the guest gets its spec's `guestNetwork`, or the setup's default (`10.0.0.2/24` - unless set), behind OpenVMM's NAT gateway, the network's first address, and names that - gateway as its DNS server in `/etc/resolv.conf` when the policy allows TCP or UDP port 53 to - it. Choose a guest network that contains no address the guest must reach. Ingress must be - `deny`, and host-loopback access must be `deny` unless ports are forwarded, as described next. + unless set), behind OpenVMM's NAT gateway, the network's first address. OpenVMM also gives + the guest the IPv6 network that embeds this one in `fd00::/96`, such as `fd00::a00:2/120`, + behind the gateway's counterpart, such as `fd00::a00:1`. The guest names the gateway as its + DNS server in `/etc/resolv.conf` when the policy allows TCP or UDP port 53 to it, and + otherwise the gateway's IPv6 address when the policy allows DNS to that one. Choose a guest + network that contains no address the guest must reach. Ingress must be `deny`, and + host-loopback access must be `deny` unless ports are forwarded, as described next. ### Native proxy and forwarded ports @@ -655,23 +658,27 @@ rest of the export, so place mapped paths under one directory when possible. ### Network rules -OpenVMM's portable network profile enforces an egress default plus IPv4 allow -and deny rules; deny rules take precedence. Each rule lists destination -networks (`to`, each a CIDR with optional `except` sub-networks) and destination -`ports` (protocol, `port`, optional `endPort`). An empty `to` matches every -destination, and an empty `ports` matches every protocol and port. A `tcp` or -`udp` entry without a `port` matches every port of that protocol, one with a -`port` and an `endPort` matches every port from `port` through `endPort`, -an `icmp` entry matches ICMP alone, and `any` matches every protocol or, with a -port or a port range, TCP and UDP on those ports. +OpenVMM's portable network profile enforces an egress default plus IPv4 and +IPv6 allow and deny rules; deny rules take precedence, and a rule matches only +destinations of its networks' family, so a denied default blocks the other +family. Each rule lists destination networks (`to`, each a CIDR with optional +`except` sub-networks) and destination `ports` (protocol, `port`, optional +`endPort`). An empty `to` matches every destination of both families, such as +`0.0.0.0/0` and `::/0` together, and an empty `ports` matches every protocol +and port. A `tcp` or `udp` entry without a `port` matches every port of that +protocol, one with a `port` and an `endPort` matches every port from `port` +through `endPort`, an `icmp` entry matches ICMP alone, or ICMPv6 for IPv6 +networks, and `any` matches every protocol or, with a port or a port range, +TCP and UDP on those ports. Rules are expanded into OpenVMM rules exactly: exceptions are subtracted from their networks, a port range becomes one OpenVMM range rule for each network, and protocol `any` with a port or a port range becomes a TCP and a UDP rule. -IPv6 networks and rules that would expand to more than 256 OpenVMM rules are -rejected. Egress denied without allow rules, with ingress denied, attaches no -network device; otherwise the guest gets `10.0.0.2/24` behind the profile's NAT -gateway `10.0.0.1`, which also serves DNS. +Rules that would expand to more than 256 OpenVMM rules are rejected. Egress +denied without allow rules, with ingress denied, attaches no network device; +otherwise the guest gets `10.0.0.2/24` behind the profile's NAT gateway +`10.0.0.1`, which also serves DNS, and `fd00::a00:2/120` behind the gateway's +IPv6 address `fd00::a00:1`. ### Idempotence and concurrency diff --git a/aci_edge_sandboxes/model/src/cidr.rs b/aci_edge_sandboxes/model/src/cidr.rs index afbe81838..4ee65d558 100644 --- a/aci_edge_sandboxes/model/src/cidr.rs +++ b/aci_edge_sandboxes/model/src/cidr.rs @@ -1,10 +1,12 @@ //! IP networks in CIDR notation, as used by egress rules. use std::fmt; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; /// An IPv4 or IPv6 network whose host bits are zero. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// Networks order by address, IPv4 before IPv6, and then by prefix length. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Cidr { address: IpAddr, prefix: u8, @@ -37,101 +39,64 @@ impl Cidr { Ok(cidr) } - /// Returns the IPv4 network as an address and prefix length. - pub fn ipv4(self) -> Option { - match self.address { - IpAddr::V4(address) => Some(Ipv4Cidr { - address: u32::from(address), - prefix: self.prefix, - }), - IpAddr::V6(_) => None, - } - } - - /// Returns whether `other` lies within this network. - pub fn contains(self, other: Self) -> bool { - match (self.address, other.address) { - (IpAddr::V4(_), IpAddr::V4(_)) => { - let (outer, inner) = (self.ipv4().unwrap(), other.ipv4().unwrap()); - outer.contains(inner) - } - (IpAddr::V6(outer), IpAddr::V6(inner)) => { - self.prefix <= other.prefix - && mask128(u128::from(inner), self.prefix) == u128::from(outer) - } - _ => false, - } + /// Returns the network of every address of one family: `::/0` with `ipv6`, and `0.0.0.0/0` + /// without. + pub fn everything(ipv6: bool) -> Self { + let address = if ipv6 { + IpAddr::V6(Ipv6Addr::UNSPECIFIED) + } else { + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + }; + Self { address, prefix: 0 } } - fn network(self) -> IpAddr { - match self.address { - IpAddr::V4(address) => { - IpAddr::V4(Ipv4Addr::from(mask32(u32::from(address), self.prefix))) - } - IpAddr::V6(address) => IpAddr::V6(mask128(u128::from(address), self.prefix).into()), - } + /// Returns whether this is an IPv6 network. + pub fn is_ipv6(self) -> bool { + self.address.is_ipv6() } -} -/// Parses a prefix length in canonical form: decimal digits without a sign or leading zeros. -pub fn parse_prefix_length(text: &str) -> Option { - let digits = !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()); - // `u8::from_str` alone would also accept a leading `+`. - if digits && (text.len() == 1 || !text.starts_with('0')) { - text.parse().ok() - } else { - None + /// Returns whether `other` lies within this network. Networks of different families never + /// contain each other. + pub fn contains(self, other: Self) -> bool { + self.is_ipv6() == other.is_ipv6() + && self.prefix <= other.prefix + && mask(other.bits(), self.prefix, self.width()) == self.bits() } -} -fn mask32(address: u32, prefix: u8) -> u32 { - if prefix == 0 { - 0 - } else { - address & (u32::MAX << (32 - u32::from(prefix))) + fn overlaps(self, other: Self) -> bool { + self.contains(other) || other.contains(self) } -} -fn mask128(address: u128, prefix: u8) -> u128 { - if prefix == 0 { - 0 - } else { - address & (u128::MAX << (128 - u32::from(prefix))) + fn width(self) -> u8 { + if self.is_ipv6() { 128 } else { 32 } } -} - -/// An IPv4 network. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct Ipv4Cidr { - address: u32, - prefix: u8, -} -impl Ipv4Cidr { - fn contains(self, other: Self) -> bool { - self.prefix <= other.prefix && mask32(other.address, self.prefix) == self.address + fn bits(self) -> u128 { + match self.address { + IpAddr::V4(address) => u128::from(u32::from(address)), + IpAddr::V6(address) => u128::from(address), + } } - fn overlaps(self, other: Self) -> bool { - self.contains(other) || other.contains(self) + /// Returns the network of this one's family at `bits` with `prefix`. + fn with_bits(self, bits: u128, prefix: u8) -> Self { + let address = match self.address { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::from(bits as u32)), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::from(bits)), + }; + Self { address, prefix } } fn halves(self) -> [Self; 2] { let prefix = self.prefix + 1; [ - Self { - address: self.address, - prefix, - }, - Self { - address: self.address | (1 << (32 - u32::from(prefix))), - prefix, - }, + self.with_bits(self.bits(), prefix), + self.with_bits(self.bits() | (1 << (self.width() - prefix)), prefix), ] } /// Returns the smallest set of networks that cover this network except `excluded`, or `None` - /// if that set has more than `limit` networks. + /// if that set has more than `limit` networks. Exclusions of the other family exclude nothing. /// /// Each exclusion is followed only into the half of a network that contains it, and expansion /// stops as soon as the result exceeds `limit`, so the work grows linearly with the number of @@ -166,16 +131,36 @@ impl Ipv4Cidr { low.subtract_sorted(&excluded[..split], limit, remainder) && high.subtract_sorted(&excluded[split..], limit, remainder) } + + fn network(self) -> IpAddr { + self.with_bits(mask(self.bits(), self.prefix, self.width()), self.prefix) + .address + } } -impl fmt::Display for Ipv4Cidr { +impl fmt::Display for Cidr { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "{}/{}", - Ipv4Addr::from(self.address), - self.prefix - ) + write!(formatter, "{}/{}", self.address, self.prefix) + } +} + +/// Parses a prefix length in canonical form: decimal digits without a sign or leading zeros. +pub fn parse_prefix_length(text: &str) -> Option { + let digits = !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()); + // `u8::from_str` alone would also accept a leading `+`. + if digits && (text.len() == 1 || !text.starts_with('0')) { + text.parse().ok() + } else { + None + } +} + +/// Clears the host bits of an address of `width` bits. +fn mask(address: u128, prefix: u8, width: u8) -> u128 { + if prefix == 0 { + 0 + } else { + address & !((1u128 << (width - prefix)) - 1) } } @@ -183,16 +168,28 @@ impl fmt::Display for Ipv4Cidr { mod tests { use super::*; - fn v4(value: &str) -> Ipv4Cidr { - Cidr::parse(value).unwrap().ipv4().unwrap() + fn cidr(value: &str) -> Cidr { + Cidr::parse(value).unwrap() + } + + fn from_ipv4_bits(bits: u32, prefix: u8) -> Cidr { + Cidr { + address: IpAddr::V4(Ipv4Addr::from(bits)), + prefix, + } } #[test] fn parsing_requires_canonical_networks() { - assert_eq!(v4("192.0.2.1").to_string(), "192.0.2.1/32"); - assert_eq!(v4("10.0.0.0/8").to_string(), "10.0.0.0/8"); - assert_eq!(v4("0.0.0.0/0").to_string(), "0.0.0.0/0"); - assert!(Cidr::parse("2001:db8::/32").unwrap().ipv4().is_none()); + assert_eq!(cidr("192.0.2.1").to_string(), "192.0.2.1/32"); + assert_eq!(cidr("10.0.0.0/8").to_string(), "10.0.0.0/8"); + assert_eq!(cidr("0.0.0.0/0").to_string(), "0.0.0.0/0"); + assert_eq!(cidr("2001:db8::/32").to_string(), "2001:db8::/32"); + assert_eq!(cidr("2001:db8::1").to_string(), "2001:db8::1/128"); + assert_eq!(cidr("::/0").to_string(), "::/0"); + assert!(cidr("2001:db8::/32").is_ipv6() && !cidr("10.0.0.0/8").is_ipv6()); + assert_eq!(Cidr::everything(false), cidr("0.0.0.0/0")); + assert_eq!(Cidr::everything(true), cidr("::/0")); for invalid in [ "10.0.0.1/8", "10.0.0.0/33", @@ -202,9 +199,11 @@ mod tests { "192.0.2.1/+32", "0.0.0.0/+0", "2001:db8::/+32", + "2001:db8::/129", "example.com", "10.0.0.0/", "2001:db8::1/32", + "fe80::1%eth0", ] { assert!(Cidr::parse(invalid).is_err(), "{invalid}"); } @@ -212,37 +211,45 @@ mod tests { #[test] fn containment_is_family_aware() { - let outer = Cidr::parse("10.0.0.0/8").unwrap(); - assert!(outer.contains(Cidr::parse("10.1.0.0/16").unwrap())); + let outer = cidr("10.0.0.0/8"); + assert!(outer.contains(cidr("10.1.0.0/16"))); assert!(outer.contains(outer)); - assert!(!outer.contains(Cidr::parse("11.0.0.0/16").unwrap())); - assert!(!outer.contains(Cidr::parse("::/0").unwrap())); - let v6 = Cidr::parse("2001:db8::/32").unwrap(); - assert!(v6.contains(Cidr::parse("2001:db8:1::/48").unwrap())); + assert!(!outer.contains(cidr("11.0.0.0/16"))); + assert!(!outer.contains(cidr("::/0"))); + let v6 = cidr("2001:db8::/32"); + assert!(v6.contains(cidr("2001:db8:1::/48"))); + assert!(!v6.contains(cidr("2001:db9::/48"))); + // ::/96 and 0.0.0.0/0 span the same integers but never contain each other. + assert!(!cidr("::/96").contains(cidr("0.0.0.0/0"))); + assert!(!cidr("0.0.0.0/0").contains(cidr("::/96"))); } #[test] fn subtraction_covers_exactly_the_remainder() { - let subtract = |network: &str, excluded: &[Ipv4Cidr]| { - v4(network).subtract(excluded, usize::MAX).unwrap() + let subtract = |network: &str, excluded: &[Cidr]| { + cidr(network).subtract(excluded, usize::MAX).unwrap() }; - let remainder: Vec = subtract("10.0.0.0/8", &[v4("10.0.0.0/9")]) - .iter() - .map(ToString::to_string) - .collect(); - assert_eq!(remainder, ["10.128.0.0/9"]); - - let remainder = subtract("10.0.0.0/30", &[v4("10.0.0.1")]); - let rendered: Vec = remainder.iter().map(ToString::to_string).collect(); - assert_eq!(rendered, ["10.0.0.0/32", "10.0.0.2/31"]); - - assert!(subtract("10.1.0.0/16", &[v4("10.0.0.0/8")]).is_empty()); + let rendered = |networks: Vec| -> Vec { + networks.iter().map(ToString::to_string).collect() + }; + assert_eq!( + rendered(subtract("10.0.0.0/8", &[cidr("10.0.0.0/9")])), + ["10.128.0.0/9"] + ); + assert_eq!( + rendered(subtract("10.0.0.0/30", &[cidr("10.0.0.1")])), + ["10.0.0.0/32", "10.0.0.2/31"] + ); + assert!(subtract("10.1.0.0/16", &[cidr("10.0.0.0/8")]).is_empty()); assert_eq!( - subtract("10.0.0.0/8", &[v4("11.0.0.0/8")]), - [v4("10.0.0.0/8")] + subtract("10.0.0.0/8", &[cidr("11.0.0.0/8")]), + [cidr("10.0.0.0/8")] ); + // An exclusion of the other family excludes nothing. + assert_eq!(subtract("0.0.0.0/0", &[cidr("::/96")]), [cidr("0.0.0.0/0")]); + assert_eq!(subtract("::/96", &[cidr("0.0.0.0/0")]), [cidr("::/96")]); // Every remaining address is outside the exclusions, and the sizes add up. - let excluded = [v4("10.0.0.0/24"), v4("10.0.7.0/24")]; + let excluded = [cidr("10.0.0.0/24"), cidr("10.0.7.0/24")]; let remainder = subtract("10.0.0.0/16", &excluded); let covered: u64 = remainder .iter() @@ -256,8 +263,50 @@ mod tests { ); } + #[test] + fn ipv6_subtraction_covers_exactly_the_remainder() { + let remainder = cidr("2001:db8::/32") + .subtract( + &[cidr("2001:db8:1::/48"), cidr("2001:db8:8000::/33")], + usize::MAX, + ) + .unwrap(); + let rendered: Vec = remainder.iter().map(ToString::to_string).collect(); + assert_eq!( + rendered, + [ + "2001:db8::/48", + "2001:db8:2::/47", + "2001:db8:4::/46", + "2001:db8:8::/45", + "2001:db8:10::/44", + "2001:db8:20::/43", + "2001:db8:40::/42", + "2001:db8:80::/41", + "2001:db8:100::/40", + "2001:db8:200::/39", + "2001:db8:400::/38", + "2001:db8:800::/37", + "2001:db8:1000::/36", + "2001:db8:2000::/35", + "2001:db8:4000::/34", + ] + ); + let host = cidr("2001:db8::1:123"); + assert_eq!( + cidr("2001:db8::1:122/127") + .subtract(&[host], usize::MAX) + .unwrap(), + [cidr("2001:db8::1:122")] + ); + assert_eq!( + cidr("::/0").subtract(&[cidr("::/1")], usize::MAX).unwrap(), + [cidr("8000::/1")] + ); + } + /// The direct recursion that rescans every exclusion at each split, as a reference. - fn reference_subtract(network: Ipv4Cidr, excluded: &[Ipv4Cidr]) -> Vec { + fn reference_subtract(network: Cidr, excluded: &[Cidr]) -> Vec { if excluded.iter().any(|exclusion| exclusion.contains(network)) { return Vec::new(); } @@ -279,42 +328,46 @@ mod tests { state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); state }; - let network = v4("10.0.0.0/16"); - for _ in 0..500 { - let count = next() % 32; - let excluded: Vec = (0..count) - .map(|_| { - let prefix = 12 + (next() % 21) as u8; - // Most exclusions fall inside the network; some cover it or lie elsewhere. - let address = if next() % 4 == 0 { - next() - } else { - network.address | (next() & 0xffff) - }; - Ipv4Cidr { - address: mask32(address, prefix), - prefix, - } - }) - .collect(); - assert_eq!( - network.subtract(&excluded, usize::MAX).unwrap(), - reference_subtract(network, &excluded), - "{excluded:?}" - ); + for network in [cidr("10.0.0.0/16"), cidr("2001:db8::/112")] { + let low_bits = network.bits() as u32; + for _ in 0..500 { + let count = next() % 32; + let excluded: Vec = (0..count) + .map(|_| { + let prefix = 12 + (next() % 21) as u8; + // Most exclusions fall inside the network; some cover it or lie + // elsewhere. + let bits = if next() % 4 == 0 { + next() + } else { + low_bits | (next() & 0xffff) + }; + if network.is_ipv6() { + let prefix = prefix + 96; + let address = + (network.bits() & !u128::from(u32::MAX)) | u128::from(bits); + network.with_bits(mask(address, prefix, 128), prefix) + } else { + from_ipv4_bits(mask(u128::from(bits), prefix, 32) as u32, prefix) + } + }) + .collect(); + assert_eq!( + network.subtract(&excluded, usize::MAX).unwrap(), + reference_subtract(network, &excluded), + "{excluded:?}" + ); + } } } #[test] fn subtraction_stops_once_the_remainder_exceeds_its_limit() { // 512 evenly spaced addresses split the address space into 512 * 23 networks. - let excluded: Vec = (0..512u32) - .map(|index| Ipv4Cidr { - address: index << 23, - prefix: 32, - }) + let excluded: Vec = (0..512u32) + .map(|index| from_ipv4_bits(index << 23, 32)) .collect(); - let everything = v4("0.0.0.0/0"); + let everything = Cidr::everything(false); let size = |limit| { everything .subtract(&excluded, limit) diff --git a/aci_edge_sandboxes/src/openvmm/config.rs b/aci_edge_sandboxes/src/openvmm/config.rs index e084f1c73..45a1e743b 100644 --- a/aci_edge_sandboxes/src/openvmm/config.rs +++ b/aci_edge_sandboxes/src/openvmm/config.rs @@ -46,6 +46,9 @@ pub struct OpenVmmConfig { /// UTS hostname of workloads. pub hostname: String, /// Static guest IPv4 address and prefix used when a network device is attached. + /// + /// OpenVMM derives the guest's IPv6 address by embedding this one in `fd00::/96`: the default + /// `10.0.0.2/24` yields `fd00::a00:2/120`, whose gateway is `fd00::a00:1`. pub guest_network: String, /// Extra kernel parameters. `nvx_*`, `tsc=`, and `hostname=` tokens are reserved. /// diff --git a/aci_edge_sandboxes/src/openvmm/mod.rs b/aci_edge_sandboxes/src/openvmm/mod.rs index 97221dd2b..f11c01652 100644 --- a/aci_edge_sandboxes/src/openvmm/mod.rs +++ b/aci_edge_sandboxes/src/openvmm/mod.rs @@ -30,7 +30,7 @@ //! | --- | --- | --- | //! | `filesystem.readonlyPaths`, `readwritePaths` | mapped at their [`guest_path`] | n/a | //! | `filesystem.deniedPaths` | hidden inside mapped paths | n/a | -//! | `network.egress` | default and IPv4 rules applied | n/a | +//! | `network.egress` | default and IPv4 and IPv6 rules applied | n/a | //! | `network.ingress`, `hostLoopback` | `deny` only | n/a | //! | `microvm.provision.memoryMib` | applied | n/a | //! | `process.commandLine` | n/a | run as `/bin/sh -c `, at most 4096 bytes | @@ -49,7 +49,7 @@ //! Host paths share OpenVMM's single virtio-fs export: the backend exports the deepest directory //! that contains every mapped path to a guest directory that only the guest's root can enter, and //! the guest agent bind-mounts each mapped path, read-only or read-write. Egress rules are expanded -//! into OpenVMM's IPv4 rules exactly; IPv6 and rules that would need more than 256 OpenVMM rules +//! into OpenVMM's IPv4 and IPv6 rules exactly; rules that would need more than 256 OpenVMM rules //! are rejected. Egress denied without allow rules, with ingress denied, //! attaches no network device. Workloads run as the fixed non-root identity of //! [`OpenVmmConfig::workload_uid`] and [`OpenVmmConfig::workload_gid`] (see diff --git a/aci_edge_sandboxes/src/openvmm/network.rs b/aci_edge_sandboxes/src/openvmm/network.rs index 8a3e9e1ea..dcb218556 100644 --- a/aci_edge_sandboxes/src/openvmm/network.rs +++ b/aci_edge_sandboxes/src/openvmm/network.rs @@ -1,13 +1,13 @@ //! Translation of the contract's network posture into OpenVMM options. //! -//! OpenVMM's portable profile enforces an egress default plus IPv4 allow and deny rules that -//! match a network and, optionally, one protocol (TCP, UDP, or ICMP) and, for TCP or UDP, one -//! port or an inclusive range of ports; deny rules take precedence. Contract rules are expanded -//! into that form exactly: exceptions are subtracted from their networks, and `any` protocol with -//! a port or a port range becomes a TCP and a UDP rule. Rules that cannot be expressed exactly are -//! rejected. +//! OpenVMM's portable profile enforces an egress default plus IPv4 and IPv6 allow and deny rules +//! that match a network of one family and, optionally, one protocol (TCP, UDP, or ICMP) and, for +//! TCP or UDP, one port or an inclusive range of ports; deny rules take precedence. Contract rules +//! are expanded into that form exactly: exceptions are subtracted from their networks, a rule +//! without destinations matches `0.0.0.0/0` and `::/0`, and `any` protocol with a port or a port +//! range becomes a TCP and a UDP rule. Rules that cannot be expressed exactly are rejected. -use crate::cidr::{Cidr, Ipv4Cidr}; +use crate::cidr::Cidr; use crate::error::{Error, Result}; use crate::model::{Access, NetworkPolicy, NetworkRule, Protocol}; @@ -82,21 +82,19 @@ fn expand_rule(rule: &NetworkRule, field: &str, output: &mut Vec) -> Res let unsupported = |message: String| Err(Error::policy_validation(format!("{field}{message}"))); let mut networks = Vec::new(); if rule.to.is_empty() { - networks.push(ipv4(&Cidr::parse("0.0.0.0/0").expect("valid network"))); + networks.extend([Cidr::everything(false), Cidr::everything(true)]); } for (index, peer) in rule.to.iter().enumerate() { - let Some(network) = Cidr::parse(&peer.cidr).ok().and_then(Cidr::ipv4) else { - return unsupported(format!( - ".to[{index}]: the openvmm backend supports only IPv4 destinations" - )); + let Ok(network) = Cidr::parse(&peer.cidr) else { + return unsupported(format!(".to[{index}]: the destination is not a network")); }; let mut excluded = Vec::new(); for value in &peer.except { - match Cidr::parse(value).ok().and_then(Cidr::ipv4) { - Some(exclusion) => excluded.push(exclusion), - None => { + match Cidr::parse(value) { + Ok(exclusion) if network.contains(exclusion) => excluded.push(exclusion), + _ => { return unsupported(format!( - ".to[{index}].except: the openvmm backend supports only IPv4 networks" + ".to[{index}].except: an exception is not a network within the destination" )); } } @@ -176,10 +174,6 @@ enum Selector { Ports(&'static str, u16, u16), } -fn ipv4(cidr: &Cidr) -> Ipv4Cidr { - cidr.ipv4().expect("an IPv4 network") -} - #[cfg(test)] mod tests { use super::*; @@ -271,9 +265,47 @@ mod tests { }], }); let arguments = network_arguments(Some(&policy(egress)), "10.0.0.2/24").unwrap(); + // A rule without destinations matches both address families. + assert_eq!( + rules(&arguments, "--network-egress-deny"), + ["0.0.0.0/0:udp:53", "::/0:udp:53"] + ); + } + + #[test] + fn ipv6_rules_expand_exactly() { + let egress = EgressPolicy::new(Access::Deny) + .with_allow(NetworkRule::to("2001:db8:1::/64").on_port(Protocol::Tcp, 443)) + .with_allow(NetworkRule { + to: vec![NetworkPeer { + cidr: "2001:db8:2::/126".to_owned(), + except: vec!["2001:db8:2::1".to_owned()], + }], + ports: vec![NetworkPort { + protocol: Protocol::Any, + port: Some(53), + end_port: None, + }], + }) + .with_allow(NetworkRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp)) + .with_allow(NetworkRule::to("::/0").on_protocol(Protocol::Icmp)) + .with_deny(NetworkRule::to("2001:db8:1::123")); + let arguments = network_arguments(Some(&policy(egress)), "10.0.0.2/24").unwrap(); + assert_eq!( + rules(&arguments, "--network-egress-allow"), + [ + "192.0.2.0/24:icmp", + "2001:db8:1::/64:tcp:443", + "2001:db8:2::/128:tcp:53", + "2001:db8:2::/128:udp:53", + "2001:db8:2::2/127:tcp:53", + "2001:db8:2::2/127:udp:53", + "::/0:icmp", + ] + ); assert_eq!( rules(&arguments, "--network-egress-deny"), - ["0.0.0.0/0:udp:53"] + ["2001:db8:1::123/128"] ); } @@ -376,7 +408,6 @@ mod tests { }], }; for rule in [ - NetworkRule::to("2001:db8::/32"), port(Protocol::Icmp, Some(8), None), // Structural validation rejects these first, unless the backend is called directly. port(Protocol::Tcp, Some(90), Some(80)), diff --git a/aci_edge_sandboxes/tests/openvmm_e2e.rs b/aci_edge_sandboxes/tests/openvmm_e2e.rs index 28fc7f8d3..90449abc2 100644 --- a/aci_edge_sandboxes/tests/openvmm_e2e.rs +++ b/aci_edge_sandboxes/tests/openvmm_e2e.rs @@ -861,6 +861,79 @@ fn network_protocol_selectors_are_enforced() { } } +/// Reports, on one line each, whether TCP reaches the gateway's DNS service over IPv4 and over +/// IPv6. OpenVMM derives the guest's IPv6 network from its IPv4 one, so the default +/// `10.0.0.2/24` makes the IPv6 gateway `fd00::a00:1`. +const GATEWAY_DUAL_STACK_PROBE: &str = "for gateway in 10.0.0.1 fd00::a00:1; do \ + nc -w 3 $gateway 53 = command_line diff --git a/aci_edge_sandboxes/tests/validation.rs b/aci_edge_sandboxes/tests/validation.rs index 1ac959cf2..5687750de 100644 --- a/aci_edge_sandboxes/tests/validation.rs +++ b/aci_edge_sandboxes/tests/validation.rs @@ -236,7 +236,6 @@ fn provision_validation_rejects_backend_policies_before_probing() { let (directory, client) = client(); let before = state_entries(&directory); for request in [ - network_request(NetworkRule::to("::/0")), network_request(separate_port_ranges(Protocol::Tcp, 257)), network_request(separate_port_ranges(Protocol::Any, 129)), ] { @@ -284,6 +283,9 @@ fn validation_is_side_effect_free_and_host_checks_remain_in_operations() { network_request(NetworkRule::to("192.0.2.0/24").on_protocol(Protocol::Tcp)), network_request(NetworkRule::to("192.0.2.0/24").on_protocol(Protocol::Udp)), network_request(NetworkRule::to("192.0.2.0/24").on_protocol(Protocol::Icmp)), + network_request(NetworkRule::to("::/0")), + network_request(NetworkRule::to("2001:db8:1::/64").on_port(Protocol::Tcp, 443)), + network_request(NetworkRule::default().on_port(Protocol::Any, 53)), ProvisionRequest::new().with_filesystem(FilesystemPolicy { readonly_paths: vec![directory.path().join("missing-mapped-file")], ..FilesystemPolicy::default() @@ -349,7 +351,7 @@ fn async_execution_uses_the_same_backend_validation_hooks() { .unwrap_err(); assert_eq!(error.code(), ErrorCode::PolicyValidation); let error = client - .provision(network_request(NetworkRule::to("::/0"))) + .provision(network_request(separate_port_ranges(Protocol::Tcp, 257))) .await .unwrap_err(); assert_eq!(error.code(), ErrorCode::PolicyValidation); diff --git a/doc/design/cold-boot.md b/doc/design/cold-boot.md index c1fcbf3cb..983e0b746 100644 --- a/doc/design/cold-boot.md +++ b/doc/design/cold-boot.md @@ -100,7 +100,8 @@ tokens. A fresh boot then appends host-owned tokens in this order: by `nvx_control_tty=hvc2`, and the second filesystem slot when a second HostFs export is attached; and 5. network bootstrap tokens, including gateway DNS only when the egress - policy permits it, followed by + policy permits it, which names the IPv4 gateway or else the IPv6 one, + followed by [filesystem bootstrap tokens](machine-and-device-abi.md#filesystem) for each attached HostFs export, in slot order. @@ -110,10 +111,10 @@ LAPIC rates from the [time ABI](time-abi.md#rates) MSRs, and a fails with `E_CMDLINE_CLOCK_TOKEN`. Callers may not supply `earlycon=`, `console=`, `virtio_mmio.device=`, -`virtnet_ip=`, `virtnet_mask=`, `virtnet_gw=`, `virtnet_dns=`, `virtfs_dir=`, -`virtfs_tag=`, `virtfs_mode=`, `nvx_snapshot_tier=`, `nr_cpus=`, -`nvx_workload_uid=`, `nvx_workload_gid=`, or `nvx_lifecycle=` tokens. A -machine with a control console also applies the +`virtnet_ip=`, `virtnet_mask=`, `virtnet_gw=`, `virtnet_dns=`, `virtnet_ip6=`, +`virtnet_gw6=`, `virtfs_dir=`, `virtfs_tag=`, `virtfs_mode=`, +`nvx_snapshot_tier=`, `nr_cpus=`, `nvx_workload_uid=`, `nvx_workload_gid=`, +or `nvx_lifecycle=` tokens. A machine with a control console also applies the [control-console command-line rules](machine-and-device-abi.md#control-console). Embedded NULs are rejected, and the complete NUL-terminated command line must fit in 64 KiB. The same effective string and its SHA-256 digest become part of diff --git a/doc/design/integrated-implementation-status.md b/doc/design/integrated-implementation-status.md index e64da5c1d..258527495 100644 --- a/doc/design/integrated-implementation-status.md +++ b/doc/design/integrated-implementation-status.md @@ -10,7 +10,7 @@ The implementation and remaining proposals separate as follows: | Snapshot and restore | Guest-requested version-5 publication, same-backend restore, exact saved inventory, private COW RAM, optional fresh RAM expansion, fresh/paired scratch, three sandbox tiers, input gating, single-use resume claims, a single-use restore readiness event, and management exclusion at the capture boundary. The management RPC drives the same capture and restore for blockless machines without a NIC or control console. Older supported manifests remain readable subject to their recorded capabilities. | | Resource activation | Opt-in CPU-prefix and 128-MiB-aligned RAM targets within immutable capacity. Explicit MSHV targets materialize only that VP prefix and cannot be saved again; KVM, WHP, and untargeted MSHV retain full VP capacity. | | Console | Boot virtio-console with private RX/TX state and reconnect policies; bounded portb/host-relay drain at process exit, with guest exit status propagated to management-RPC waiters. The separate control console has authenticated Linux and Windows endpoints, bounded framing with receive credits, per-instance epochs, broker saved state, and managed workload RPC. | -| Network | Static identity, fixed transport, the portable in-process Consomme endpoint, directional egress `allow`/`deny`, fixed ingress `deny`, L3/L4 egress rules, host-loopback deny or explicit port publishing, an exact proxy exception, and quiesced restore are implemented. Stateful replies remain available; unrestricted ingress is rejected before launch. Capture drains packet ownership instead of serializing arbitrary pending packets or host flow state. | +| Network | Static dual-stack identity, fixed transport, the portable in-process Consomme endpoint, directional egress `allow`/`deny`, fixed ingress `deny`, IPv4 and IPv6 L3/L4 egress rules, host-loopback deny or explicit port publishing, an exact proxy exception, and quiesced restore are implemented. Stateful replies remain available; unrestricted ingress is rejected before launch. Capture drains packet ownership instead of serializing arbitrary pending packets or host flow state. | | Guest filesystem | Kernel features and shell bootstrap for EROFS over ext4 scratch, overlayfs, namespace/cgroup isolation, capability stripping, supervision, and low-level snapshot hooks. Not a complete OCI agent or public sandbox restore workflow. | | Host filesystem | Fixed no-DAX HostFs in a slot that command-line and management-RPC cold boots always expose and that stays dormant without an attachment, live attachment revalidation, server-side denied subtrees with alias-identity enforcement, allowed paths that expose subtrees of denied paths again through traverse-only directories, write narrowing to declared writable paths of a read-write export, guest-created symbolic links on read-write exports with exact targets that the host never follows, and optional caller ownership on Linux hosts, which performs each request as its guest caller's UID and GID with root squashed to the export owner and fails closed with `EPERM`. Provider-backed immutable filesystem generations remain outside the current profile. | | Workload policy and operations | Host-owned non-root workload identity, one-shot or managed lifecycle selection, bounded local outcome reports, and opt-in lifecycle profiling that does not change ordinary runs. | diff --git a/doc/design/machine-and-device-abi.md b/doc/design/machine-and-device-abi.md index 51a2583da..849dbd2de 100644 --- a/doc/design/machine-and-device-abi.md +++ b/doc/design/machine-and-device-abi.md @@ -312,20 +312,39 @@ the MAC-address feature and virtio version 1. deterministic guest and gateway MAC addresses from the final three IPv4 octets. The profile is mandatory and selects the same in-process Consomme data plane on KVM, MSHV, and WHP; TAP attachments are rejected for this profile. -The static identity advertises no routable IPv6 prefix. - -The portable profile provides gateway DNS over UDP and TCP, ICMP echo, -outbound TCP and UDP, deterministic rejection of fragmented IPv4 packets, and -bounded flow, DNS, buffer, and packet-queue state. At most 128 TCP, 256 UDP, -and 16 ICMP guest flows are active at once, and excess flows are rejected -before a host socket is created. It applies one canonical egress policy before -externally visible transmission: +The NIC is dual-stack: its static IPv6 identity embeds the guest and gateway +IPv4 addresses in `fd00::/96`, with a prefix of 96 plus the IPv4 prefix, so +`10.0.0.2/24` becomes `fd00::a00:2/120` behind `fd00::a00:1`. The gateway +shares the IPv4 gateway's MAC address, and the guest configures the identity +from the `virtnet_ip6=` and `virtnet_gw6=` tokens without router +advertisements, autoconfiguration from them, or duplicate address detection. +The guest kernel's EUI-64 link-local address is its only other IPv6 address, +and egress rules admit it only as the source of Neighbor Solicitations. +The `virtnet_dns=` token names the IPv4 gateway when the egress policy permits +DNS to it, and otherwise the IPv6 gateway when the policy permits DNS to that +one. The static identity advertises no routable IPv6 prefix. The snapshot records +the IPv6 identity; a snapshot whose network predates it restores IPv4-only. + +The portable profile provides gateway DNS over UDP and TCP, ICMP and ICMPv6 +echo, outbound TCP and UDP over IPv4 and IPv6, deterministic rejection of +fragmented IPv4 packets, IPv6 extension headers, and IPv4-mapped IPv6 +destinations, and bounded flow, DNS, buffer, and packet-queue state. At most +128 TCP, 256 UDP, and 16 ICMP guest flows are active at once, and excess flows +are rejected before a host socket is created. It applies one canonical egress +policy before externally visible transmission: - allow only listed IPv4 hosts or CIDRs; - allow IPv4 except listed hosts or CIDRs; or - allow only exact IPv4 TCP endpoints; or -- combine canonical IPv4/CIDR allow and deny rules with optional TCP or UDP - destination ports and deny precedence. +- combine canonical IPv4 or IPv6 CIDR allow and deny rules with an optional + TCP, UDP, or ICMP selector, optional TCP or UDP destination ports, and deny + precedence. + +The legacy modes deny all IPv6 traffic. In the generic rule mode, a rule +matches only destinations of its own address family, and the IPv6 gateway's +Neighbor Discovery is authorized like the IPv4 gateway's ARP. A policy bound to +an IPv6 identity uses canonical encoding version 4, while one restored from a +snapshot that predates IPv6 keeps version 3 and its recorded digest. The legacy modes are mutually exclusive. The generic rule mode requires an explicit default action, accepts at most 256 allow and 256 deny rules, and diff --git a/doc/design/validation.md b/doc/design/validation.md index a770d6529..ba2a19835 100644 --- a/doc/design/validation.md +++ b/doc/design/validation.md @@ -43,7 +43,17 @@ port below nor the port above them nor another protocol, both as allow rules and as deny rules that take precedence over an allow rule; that a deny rule for one port inside an allowed range blocks that port alone; that each range reaches OpenVMM as one native range rule; and that OpenVMM and `nvx.py run` -reject reversed ranges and ranges without a first port before launch. +reject reversed ranges and ranges without a first port before launch. IPv6 +coverage verifies, with MXC-shaped policy files, the guest's derived IPv6 +address and default route, TCP, UDP, and ICMPv6 to the IPv6 gateway, a `deny` +default without rules that blocks both families, port and protocol rules, an +IPv6 port range, deny precedence within `::/0`, a rule without destinations that +admits both families, a `deny` default that IPv4 rules do not widen to IPv6, an +IPv6 deny rule under an `allow` default that leaves IPv4 alone, the DNS server +that each policy gives the guest, the IPv6 path across snapshot restore, +rejection of a restore whose policy differs, and pre-boot rejection of an +invalid IPv6 prefix. The Ubuntu and Azure Linux virtio-net smoke tests also +verify the IPv6 identity. Host-loopback coverage verifies general guest-to-host denial, an exact proxy exception, explicit localhost-to-guest forwarding under allow, and pre-boot rejection when both diff --git a/doc/guests/ubuntu-guest.md b/doc/guests/ubuntu-guest.md index f10c772c8..b0ca8486c 100644 --- a/doc/guests/ubuntu-guest.md +++ b/doc/guests/ubuntu-guest.md @@ -45,7 +45,7 @@ what Ubuntu userland needs on this machine: Its [patches](../../kernel/patches) add the xe9 early and interactive consoles and the shared virtio-mmio interrupt status. The -[kernel configuration](../../kernel/config-microvm) omits PCI, IPv6, user +[kernel configuration](../../kernel/config-microvm) omits PCI, user namespaces, fanotify, and SquashFS. It enables module support only so that strict module memory protection keeps runtime code read-only; NVX ships no modules, and `/init` disables module loading before it starts any other @@ -111,9 +111,10 @@ measured minimum. A restore takes the guest from the captured RAM and machine contract, so it needs no guest selection. The initramfs supports the console, `nvx_exec`, one-shot and managed -lifecycles, static IPv4 networking, HostFs, SMP, and blockless snapshots, -including the guest obligations of the time ABI. The test harness rejects the -`console-snapshot` scenario for Ubuntu instead of substituting Alpine. +lifecycles, static IPv4 and IPv6 networking, HostFs, SMP, and blockless +snapshots, including the guest obligations of the time ABI. The test harness +rejects the `console-snapshot` scenario for Ubuntu instead of substituting +Alpine. ## Ubuntu sandbox layer diff --git a/doc/run.md b/doc/run.md index d06f0448f..03c2a2375 100644 --- a/doc/run.md +++ b/doc/run.md @@ -124,6 +124,18 @@ responses to guest-initiated connections remain available, while new inbound connections do not. The portable profile supports egress `allow` or `deny` but rejects ingress `allow` before the workload starts. +The NIC is dual-stack. The guest's IPv6 address embeds its IPv4 address in the +unique local prefix `fd00::/96`, with a prefix of 96 plus the IPv4 prefix, and +its IPv6 gateway embeds the IPv4 gateway in the same way: `10.0.0.2/24` gives +the guest `fd00::a00:2/120` behind the gateway `fd00::a00:1`. The guest +configures both identities statically; it neither solicits router +advertisements nor autoconfigures addresses from them, so its only other IPv6 +address is the kernel's link-local one in `fe80::/64`. Like the IPv4 gateway, +the IPv6 gateway answers ICMPv6 echo requests and maps TCP and UDP flows onto +host loopback, here `::1`, when host-loopback access is allowed. Because the +guest's only IPv6 source address beyond its link is a unique local one, +standard address selection prefers IPv4 for destinations that have both. + For destination and port rules, select an explicit default and repeat generic allow/deny options: @@ -142,19 +154,30 @@ allow/deny options: --network-egress-deny 140.82.114.0/24:tcp:443 ``` -Rules match IPv4 addresses or CIDRs and may select one protocol: `tcp`, `udp`, -or `icmp`. A TCP or UDP rule may add one destination port, or an inclusive -range of them written `FIRST-LAST`; without a port, it matches every port of -that protocol. Ports are `1` through `65535`, and a range cannot end below its -first port. ICMP rules take no port. For example, `192.0.2.0/24:udp` matches -UDP on every port, `192.0.2.1:tcp:8000-8010` matches TCP ports 8000 through -8010 but not UDP, and `192.0.2.1:icmp` matches ICMP but not TCP or UDP. Deny -matches take precedence over allow matches, so adding -`--network-egress-deny 192.0.2.1:tcp:8005` keeps port 8005 blocked inside that -range. +Rules match IPv4 or IPv6 addresses or CIDRs and may select one protocol: `tcp`, +`udp`, or `icmp`. A TCP or UDP rule may add one destination port, or an +inclusive range of them written `FIRST-LAST`; without a port, it matches every +port of that protocol. Ports are `1` through `65535`, and a range cannot end +below its first port. ICMP rules take no port, and an IPv6 ICMP rule matches +ICMPv6. For example, `192.0.2.0/24:udp` matches UDP on every port, +`192.0.2.1:tcp:8000-8010` matches TCP ports 8000 through 8010 but not UDP, +`192.0.2.1:icmp` matches ICMP but not TCP or UDP, and `2001:db8::/32:tcp:443` +matches TCP port 443 on that IPv6 network. Deny matches take precedence over +allow matches, so adding `--network-egress-deny 192.0.2.1:tcp:8005` keeps port +8005 blocked inside that range. + +A rule matches only destinations of its own address family, so IPv4 rules never +admit IPv6 traffic, or the reverse, and a `deny` default blocks every IPv6 +destination that no IPv6 rule allows. Use `0.0.0.0/0` and `::/0` to match +every destination of one family. Like ARP for the IPv4 gateway, Neighbor +Discovery for the IPv6 gateway is allowed under an `allow` default or with any +IPv6 allow rule. The gateway never forwards IPv6 packets that carry extension +headers or that target IPv4-mapped addresses. The guest names the IPv4 gateway +as its DNS server when the policy allows TCP or UDP port 53 to it, and +otherwise the IPv6 gateway when the policy allows DNS to that one. NVX can lower protocol selectors, inclusive TCP/UDP port ranges, and rule-local -IPv4 exclusions to those native rules: +IPv4 or IPv6 exclusions to those native rules: ```json { @@ -205,6 +228,20 @@ IPv4 exclusions to those native rules: "protocol": "icmp" } ] + }, + { + "to": [ + { + "cidr": "2001:db8::/48", + "except": ["2001:db8::/56"] + } + ], + "ports": [ + { + "protocol": "tcp", + "port": 443 + } + ] } ], "deny": [ @@ -231,24 +268,27 @@ Pass the file with `--network-egress-policy-file PATH` on `run`, one-shot `--network-egress-allow` or `--network-egress-deny`. The root accepts only `allow` and `deny` arrays. Each element uses the MXC -`NetworkRule` shape: optional `to` and `ports` arrays. Omitting `to` matches all -IPv4 destinations. Each `to` entry requires one IPv4 `cidr`; optional `except` -entries must be IPv4 CIDRs contained by that parent. Host bits are normalized -like the native CIDR syntax: `10.0.0.5/24` means `10.0.0.0/24`, not one host. -Use `/32` to select one IPv4 address. - -Omitting `ports` matches every IPv4 transport supported by the native rule. -Each port selector follows MXC: `protocol` defaults to `any`. Without `port`, -`tcp` and `udp` match every port of that protocol, `icmp` matches ICMP alone, -and `any` matches every IPv4 protocol. A `port` in `1..65535` applies to -`tcp`, `udp`, or `any`, which expands to TCP and UDP on that port but not ICMP. -Optional inclusive `endPort` requires `port` and cannot be below it. IPv6 is -not supported. +`NetworkRule` shape: optional `to` and `ports` arrays. Omitting `to` matches +every IPv4 and IPv6 destination. Each `to` entry requires one IPv4 or IPv6 +`cidr`; optional `except` entries must be CIDRs of the same family contained by +that parent, and the entries of each family lower to native rules of that +family. Host bits are normalized like the native CIDR syntax: `10.0.0.5/24` +means `10.0.0.0/24`, not one host. Use `/32` to select one IPv4 address and +`/128` to select one IPv6 address; IPv6 scope IDs are rejected. + +Omitting `ports` matches every transport supported by the native rule. Each +port selector follows MXC: `protocol` defaults to `any`. Without `port`, `tcp` +and `udp` match every port of that protocol, `icmp` matches ICMP alone, or +ICMPv6 for IPv6 destinations, and `any` matches every protocol. A `port` in +`1..65535` applies to `tcp`, `udp`, or `any`, which expands to TCP and UDP on +that port but not ICMP. Optional inclusive `endPort` requires `port` and cannot +be below it. Duplicate JSON properties, unknown fields, and explicit `null` protocol values are rejected. Policy files are limited to 1 MiB of UTF-8 input. The previous flat `cidr`/`except`/`protocol`/`port` rule form remains accepted for -compatibility, but new policy files should use the MXC shape. +compatibility, with an IPv4 or IPv6 `cidr`, but new policy files should use the +MXC shape. A port range lowers to one native `FIRST-LAST` rule for each destination network. The policy above therefore allows `192.0.2.0/25:tcp:8000-8010`, which @@ -264,11 +304,11 @@ precedence over allow matches. NVX canonicalizes safely equivalent prefixes, merges the adjacent and overlapping port ranges of each network, and rejects policies that lower to -more than 256 allow rules or 256 deny rules; protocol `any` with a port or a -port range lowers to one TCP and one UDP rule. NVX rejects oversized expansions -before launch rather than truncating or widening them. Managed provision stores -the validated lowered rules in sandbox state, so later starts do not reread a -mutable source policy file. +more than 256 allow rules or 256 deny rules, counting both families together; +protocol `any` with a port or a port range lowers to one TCP and one UDP rule. +NVX rejects oversized expansions before launch rather than truncating or +widening them. Managed provision stores the validated lowered rules in sandbox +state, so later starts do not reread a mutable source policy file. Host-loopback denial and deliberate localhost port publishing are separately controlled from ordinary egress: diff --git a/doc/usage.md b/doc/usage.md index 39a1832e5..699022469 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -506,13 +506,13 @@ python3 scripts/nvx.py run | `--mount-allow HOST_PATH` | none | Expose one existing file or directory inside a `--mount-deny` path again; the hidden directories on the way list only the entries that lead to it. Repeat to allow multiple paths. With two mappings, the path must be absolute. See [Access policy](run.md#access-policy). | | `--mount-write HOST_PATH` | none | Make one existing file or directory one of the only writable parts of an `rw` mapping; the guest's writes anywhere else in it fail with `EROFS`. Repeat to declare multiple writable paths. With two mappings, the path must be absolute. Active snapshot restore requires the same denied, allowed, and writable paths. | | `--mount-owner {vmm,caller}` | `vmm` | Select the host identity of the guest's operations on every `--mount` directory. `caller` performs each one as the guest caller's UID and GID and squashes guest root to the directory owner; it requires a Linux host. See [Run](run.md#file-ownership). | -| `--net IPV4/PREFIX` | none | Enable virtio-net with the static guest IPv4 address and prefix. | +| `--net IPV4/PREFIX` | none | Enable virtio-net with the static guest IPv4 address and prefix. The guest also gets the IPv6 address that embeds it in `fd00::/96`, such as `fd00::a00:2/120` for `10.0.0.2/24`. | | `--network-profile {portable}` | none | Select the required cross-platform network behavior contract; must be specified with `--net`. | | `--network-egress {allow,deny}` | `allow` | Set the default guest egress policy. | | `--network-ingress {allow,deny}` | `deny` | Set the default host ingress policy. The portable profile currently supports only `deny`; `allow` is rejected before launch. | -| `--network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]]` | none | Allow matching guest egress; repeat to add rules. `PROTOCOL` is `tcp`, `udp`, or `icmp` and matches every port when `PORT` is omitted; only `tcp` and `udp` take a `PORT` or an inclusive `PORT-PORT` range, such as `8000-8010`. | +| `--network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]]` | none | Allow matching guest egress; repeat to add rules. `CIDR` is an IPv4 or IPv6 address or network, and a rule matches only its own family. `PROTOCOL` is `tcp`, `udp`, or `icmp` and matches every port when `PORT` is omitted; only `tcp` and `udp` take a `PORT` or an inclusive `PORT-PORT` range, such as `8000-8010`. | | `--network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]]` | none | Deny matching guest egress; repeat to add rules. Deny rules take precedence. `PROTOCOL` and `PORT` select traffic as for `--network-egress-allow`. | -| `--network-egress-policy-file PATH` | none | Load bounded IPv4 ranges and rule-local CIDR exclusions from JSON. Requires explicit `--network-egress`; cannot be mixed with explicit allow/deny rule flags. | +| `--network-egress-policy-file PATH` | none | Load bounded IPv4 and IPv6 ranges and rule-local CIDR exclusions from JSON. Requires explicit `--network-egress`; cannot be mixed with explicit allow/deny rule flags. | | `--host-loopback {allow,deny}` | existing mapping | Control guest access to host loopback services. | | `--network-proxy IPV4:TCP-PORT` | none | Allow one explicit host TCP proxy endpoint. | | `--host-loopback-forward PROTOCOL:HOST_PORT:GUEST_PORT` | none | Publish one TCP or UDP localhost port to the guest; repeat to add forwards. | @@ -614,13 +614,13 @@ launches. | `--mount-allow HOST_PATH` | none | Expose one existing file or directory inside a `--mount-deny` path of the same share again, for example a readable `mcp-payloads` directory inside hidden logs. The hidden directories on the way list only the entries that lead to it, and the workload cannot modify them. Relative paths are resolved inside the share; with two shares, it applies to the `--mount` before it. Repeat to allow multiple paths. See [Access policy](run.md#access-policy). | | `--mount-write HOST_PATH` | none | Make one existing file or directory one of the only writable parts of an `rw` share; the workload's writes anywhere else in the share fail with `EROFS`. Relative paths are resolved inside the share; with two shares, it applies to the `--mount` before it, which must be `rw`. Repeat to declare multiple writable paths. `provision` persists the denied, allowed, and writable paths. | | `--mount-owner {vmm,caller}` | `vmm` | Select the host identity of the shares' file operations for `run` or `provision`. `vmm` performs them as OpenVMM; `caller` performs them as the workload's UID and GID, squashes guest root to the owner of each host directory, and fails them with `EPERM` when OpenVMM cannot assume that identity. `caller` requires a Linux host and is persisted by `provision`. See [File ownership](run.md#file-ownership). | -| `--net IPV4/PREFIX` | none | Enable virtio-net with a static guest address. | +| `--net IPV4/PREFIX` | none | Enable virtio-net with a static guest address. The guest also gets the IPv6 address that embeds it in `fd00::/96`. | | `--network-profile {portable}` | none | Select the required cross-platform network behavior contract; must be specified with `--net`. | | `--network-egress {allow,deny}` | `allow` | Set the default guest egress policy for `run` or `provision`. | | `--network-ingress {allow,deny}` | `deny` | Set the host ingress policy for `run` or `provision`. The portable profile supports only `deny`. | -| `--network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]]` | none | Allow matching guest egress; repeat to add rules. Requires explicit `--network-egress`. `PROTOCOL` is `tcp`, `udp`, or `icmp` and matches every port when `PORT` is omitted; only `tcp` and `udp` take a `PORT` or an inclusive `PORT-PORT` range, such as `8000-8010`. | +| `--network-egress-allow CIDR[:PROTOCOL[:PORT[-PORT]]]` | none | Allow matching guest egress; repeat to add rules. Requires explicit `--network-egress`. `CIDR` is an IPv4 or IPv6 address or network, and a rule matches only its own family. `PROTOCOL` is `tcp`, `udp`, or `icmp` and matches every port when `PORT` is omitted; only `tcp` and `udp` take a `PORT` or an inclusive `PORT-PORT` range, such as `8000-8010`. | | `--network-egress-deny CIDR[:PROTOCOL[:PORT[-PORT]]]` | none | Deny matching guest egress; repeat to add rules. Requires explicit `--network-egress`; deny rules take precedence. `PROTOCOL` and `PORT` select traffic as for `--network-egress-allow`. | -| `--network-egress-policy-file PATH` | none | Load bounded IPv4 ranges and rule-local CIDR exclusions for `run` or `provision`. Managed provision persists lowered rules, not this path. Requires explicit `--network-egress`; cannot be mixed with explicit allow/deny rule flags. | +| `--network-egress-policy-file PATH` | none | Load bounded IPv4 and IPv6 ranges and rule-local CIDR exclusions for `run` or `provision`. Managed provision persists lowered rules, not this path. Requires explicit `--network-egress`; cannot be mixed with explicit allow/deny rule flags. | | `--host-loopback {allow,deny}` | existing mapping | Control guest access to host loopback services for `run` or `provision`. | | `--network-proxy IPV4:TCP-PORT` | none | Allow one explicit host TCP proxy endpoint; the IPv4 address must match the guest gateway. | | `--host-loopback-forward PROTOCOL:HOST_PORT:GUEST_PORT` | none | Publish one TCP or UDP localhost port to the guest; repeat to add forwards and set `--host-loopback allow`. | diff --git a/guest/common/init b/guest/common/init index 0a0977a22..787c9cef2 100644 --- a/guest/common/init +++ b/guest/common/init @@ -178,6 +178,8 @@ if [ -n "$nip" ]; then nmask=$(sed -n 's/.*virtnet_mask=\([^ ]*\).*/\1/p' /proc/cmdline) ngw=$(sed -n 's/.*virtnet_gw=\([^ ]*\).*/\1/p' /proc/cmdline) ndns=$(sed -n 's/.*virtnet_dns=\([^ ]*\).*/\1/p' /proc/cmdline) + nip6=$(sed -n 's/.*virtnet_ip6=\([^ ]*\).*/\1/p' /proc/cmdline) + ngw6=$(sed -n 's/.*virtnet_gw6=\([^ ]*\).*/\1/p' /proc/cmdline) virtnet_error() { echo "virtnet: $*" >&2 } @@ -199,6 +201,18 @@ if [ -n "$nip" ]; then virtnet_error "no network interface found after 30s" else [ -n "$nmask" ] || nmask=255.255.255.0 + if [ -n "$nip6" ]; then + # Besides the kernel's link-local address, the static identity is + # the link's only IPv6 configuration: the interface neither + # solicits routers nor autoconfigures addresses from their + # advertisements, and needs no duplicate address detection on its + # private link. + for nsetting in accept_ra autoconf accept_dad; do + if ! echo 0 >"/proc/sys/net/ipv6/conf/$ndev/$nsetting"; then + virtnet_error "failed to disable IPv6 $nsetting on $ndev" + fi + done + fi if ! ifconfig "$ndev" "$nip" netmask "$nmask" up; then virtnet_error "failed to configure $ndev as $nip/$nmask" fi @@ -209,6 +223,14 @@ if [ -n "$nip" ]; then virtnet_error "failed to install standalone default route via $ngw" fi fi + if [ -n "$nip6" ]; then + if ! busybox ip -6 addr add "$nip6" dev "$ndev"; then + virtnet_error "failed to configure $ndev as $nip6" + elif [ -n "$ngw6" ] && + ! busybox ip -6 route add default via "$ngw6" dev "$ndev"; then + virtnet_error "failed to install IPv6 default route via $ngw6" + fi + fi if [ -n "$ndns" ]; then if ! : >/etc/resolv.conf; then diff --git a/kernel/config-microvm b/kernel/config-microvm index 5c9421b6f..e8cf884b5 100644 --- a/kernel/config-microvm +++ b/kernel/config-microvm @@ -892,13 +892,14 @@ CONFIG_PT_RECLAIM=y # end of Memory Management options # -# Networking support (Unix sockets, IPv4, and guest-internal network namespaces) +# Networking support (Unix sockets, IPv4, IPv6, and guest-internal network namespaces) # CONFIG_NET=y CONFIG_NET_NS=y CONFIG_UNIX=y CONFIG_INET=y -# CONFIG_IPV6 is not set +CONFIG_IPV6=y +# CONFIG_IPV6_SIT is not set CONFIG_NETDEVICES=y CONFIG_NET_CORE=y CONFIG_VETH=y diff --git a/openvmm b/openvmm index f4566bab0..e5bf79776 160000 --- a/openvmm +++ b/openvmm @@ -1 +1 @@ -Subproject commit f4566bab0a30324de0a2ac292a65d0081ba319e0 +Subproject commit e5bf79776bb32f8fa29a69e7b3c9bf668477a13a diff --git a/scripts/nvx.py b/scripts/nvx.py index dea9cfc0d..c25996f20 100644 --- a/scripts/nvx.py +++ b/scripts/nvx.py @@ -108,8 +108,8 @@ NETWORK_PROFILES = ("portable",) NETWORK_EGRESS_RULE_METAVAR = "CIDR[:PROTOCOL[:PORT[-PORT]]]" NETWORK_EGRESS_RULE_HELP = ( - "{action} guest egress to an IPv4 CIDR, optionally only for PROTOCOL tcp, " - "udp, or icmp, and for tcp or udp only on PORT or on the inclusive range " + "{action} guest egress to an IPv4 or IPv6 CIDR, optionally only for PROTOCOL " + "tcp, udp, or icmp, and for tcp or udp only on PORT or on the inclusive range " "PORT-PORT; repeat to add rules" ) MAX_ENVIRONMENT_FILE_BYTES = 1024 * 1024 @@ -1126,7 +1126,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: "--network-egress-policy-file", type=Path, metavar="PATH", - help="load bounded MXC-shaped IPv4 egress rules from JSON", + help="load bounded MXC-shaped IPv4 and IPv6 egress rules from JSON", ) run.add_argument("--host-loopback", choices=("allow", "deny")) run.add_argument("--network-proxy", metavar="IPV4:TCP-PORT") @@ -1299,7 +1299,10 @@ def sandbox_identity(value: str) -> tuple[int, int]: "--network-egress-policy-file", type=Path, metavar="PATH", - help="load bounded IPv4 ranges and rule-local exclusions for run/provision", + help=( + "load bounded IPv4 and IPv6 ranges and rule-local exclusions for " + "run/provision" + ), ) sandbox.add_argument("--host-loopback", choices=("allow", "deny")) sandbox.add_argument("--network-proxy", metavar="IPV4:TCP-PORT") diff --git a/scripts/nvx_tools/build.py b/scripts/nvx_tools/build.py index dd917075b..36dbc6238 100644 --- a/scripts/nvx_tools/build.py +++ b/scripts/nvx_tools/build.py @@ -111,6 +111,14 @@ def _assert_sandbox_kernel_config(path: Path) -> None: ) +def _assert_network_kernel_config(path: Path) -> None: + _assert_kernel_config( + path, + KernelBuildConstants.REQUIRED_NETWORK_CONFIG, + "kernel configuration cannot support the portable network profile: ", + ) + + def _assert_shared_status_kernel_config(path: Path) -> None: _assert_kernel_config( path, @@ -171,6 +179,7 @@ def assert_required_kernel_config(path: Path, *, debug: bool = False) -> None: _assert_direct_boot_kernel_config(path) _assert_virtio_console_kernel_config(path) _assert_sandbox_kernel_config(path) + _assert_network_kernel_config(path) _assert_shared_status_kernel_config(path) _assert_time_abi_kernel_config(path) _assert_hardening_kernel_config(path) diff --git a/scripts/nvx_tools/build_constants.py b/scripts/nvx_tools/build_constants.py index 54c48c204..cc30f9b68 100644 --- a/scripts/nvx_tools/build_constants.py +++ b/scripts/nvx_tools/build_constants.py @@ -79,6 +79,14 @@ class KernelBuildConstants: "CONFIG_VETH=y", "CONFIG_VIRTIO_BLK=y", ) + # The portable network profile configures an IPv6 identity next to the + # IPv4 one. SIT would add an sit0 device that decapsulates IPv6 from IPv4. + REQUIRED_NETWORK_CONFIG: Final = ( + "CONFIG_INET=y", + "CONFIG_IPV6=y", + "# CONFIG_IPV6_SIT is not set", + "CONFIG_VIRTIO_NET=y", + ) REQUIRED_TIME_ABI_CONFIG: Final = ( "CONFIG_HYPERVISOR_GUEST=y", "CONFIG_PARAVIRT=y", diff --git a/scripts/nvx_tools/egress_policy.py b/scripts/nvx_tools/egress_policy.py index 746a4bd1a..fd178be62 100644 --- a/scripts/nvx_tools/egress_policy.py +++ b/scripts/nvx_tools/egress_policy.py @@ -1,11 +1,12 @@ -"""Compile structured IPv4 egress policies to the native OpenVMM rule grammar.""" +"""Compile structured IPv4 and IPv6 egress policies to the native OpenVMM rule +grammar.""" from __future__ import annotations import ipaddress import json from collections import Counter -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from dataclasses import dataclass from pathlib import Path from typing import cast @@ -24,14 +25,22 @@ # Protocols that a native rule can select on every port, and those with ports. _NATIVE_PROTOCOLS = ("icmp", "tcp", "udp") _PORT_PROTOCOLS = ("tcp", "udp") +_IP_VERSIONS = (4, 6) _AddressInterval = tuple[int, int] _AddressIntervals = tuple[_AddressInterval, ...] +_Network = ipaddress.IPv4Network | ipaddress.IPv6Network _PortSelector = tuple[str | None, int | None, int | None] -_ALL_IPV4: _AddressIntervals = ((0, (1 << 32) - 1),) +# Each IP version with its addresses; a rule without destinations matches every +# address of both families. +_Destinations = tuple[tuple[int, _AddressIntervals], ...] +_ALL_ADDRESSES: _Destinations = ( + (4, ((0, (1 << 32) - 1),)), + (6, ((0, (1 << 128) - 1),)), +) # An inclusive range of destination ports. _PortRange = tuple[int, int] # A native rule: a network, and optionally a protocol and its port range. -_NativeRule = tuple[ipaddress.IPv4Network, str | None, _PortRange | None] +_NativeRule = tuple[_Network, str | None, _PortRange | None] @dataclass(frozen=True) @@ -42,8 +51,10 @@ class CompiledEgressPolicy: @dataclass(frozen=True) class _Rule: + # IP version of the addresses, each an interval of integers in its family. + version: int addresses: _AddressIntervals - # None matches every IPv4 protocol. + # None matches every protocol. protocol: str | None # None matches every port of the protocol. start_port: int | None @@ -65,18 +76,42 @@ def _array(value: object, description: str) -> list[object]: return cast(list[object], value) -def _network(value: object, description: str) -> ipaddress.IPv4Network: +def _network(value: object, description: str) -> _Network: if not isinstance(value, str): - raise ScriptError(f"{description} must be an IPv4 CIDR string") + raise ScriptError(f"{description} must be an IPv4 or IPv6 CIDR string") try: parsed = ipaddress.ip_network(value, strict=False) except ValueError as error: - raise ScriptError(f"{description} is not a valid IPv4 CIDR: {value}") from error - if not isinstance(parsed, ipaddress.IPv4Network): - raise ScriptError(f"{description} must be an IPv4 CIDR") + raise ScriptError( + f"{description} is not a valid IPv4 or IPv6 CIDR: {value}" + ) from error + if isinstance(parsed, ipaddress.IPv6Network) and parsed.network_address.scope_id: + raise ScriptError(f"{description} must not name an IPv6 scope: {value}") return parsed +def _contains(parent: _Network, child: _Network) -> bool: + if isinstance(parent, ipaddress.IPv4Network) and isinstance( + child, ipaddress.IPv4Network + ): + return child.subnet_of(parent) + if isinstance(parent, ipaddress.IPv6Network) and isinstance( + child, ipaddress.IPv6Network + ): + return child.subnet_of(parent) + return False + + +def _summarize(start: int, end: int, version: int) -> Iterator[_Network]: + if version == 4: + return ipaddress.summarize_address_range( + ipaddress.IPv4Address(start), ipaddress.IPv4Address(end) + ) + return ipaddress.summarize_address_range( + ipaddress.IPv6Address(start), ipaddress.IPv6Address(end) + ) + + def _port(value: object, description: str) -> int: if isinstance(value, bool) or not isinstance(value, int): raise ScriptError(f"{description} must be an integer") @@ -136,14 +171,14 @@ def _subtract_intervals( def _subtract_exclusions( - parent: ipaddress.IPv4Network, + parent: _Network, exclusions: list[object], description: str, ) -> _AddressIntervals: parsed: list[_AddressInterval] = [] for index, value in enumerate(exclusions): exclusion = _network(value, f"{description}.except[{index}]") - if not exclusion.subnet_of(parent): + if not _contains(parent, exclusion): raise ScriptError( f"{description}.except[{index}] must be contained in {parent}" ) @@ -158,7 +193,8 @@ def _subtract_exclusions( return _subtract_intervals((parent_interval,), _merge_intervals(parsed)) -def _parse_peer(value: object, description: str) -> _AddressIntervals: +def _parse_peer(value: object, description: str) -> tuple[int, _AddressIntervals]: + """Return a destination's IP version and the addresses that it selects.""" peer = _object(value, description) unknown = sorted(set(peer) - _PEER_FIELDS) if unknown: @@ -167,7 +203,7 @@ def _parse_peer(value: object, description: str) -> _AddressIntervals: raise ScriptError(f"{description}.cidr is required") parent = _network(peer["cidr"], f"{description}.cidr") exclusions = _array(peer.get("except", []), f"{description}.except") - return _subtract_exclusions(parent, exclusions, description) + return parent.version, _subtract_exclusions(parent, exclusions, description) def _parse_legacy_rule(rule: dict[str, object], description: str) -> tuple[_Rule, ...]: @@ -176,7 +212,7 @@ def _parse_legacy_rule(rule: dict[str, object], description: str) -> tuple[_Rule raise ScriptError(f"{description} has unknown field '{unknown[0]}'") if "cidr" not in rule: raise ScriptError(f"{description}.cidr is required") - addresses = _parse_peer( + version, addresses = _parse_peer( {field: rule[field] for field in ("cidr", "except") if field in rule}, description, ) @@ -185,12 +221,20 @@ def _parse_legacy_rule(rule: dict[str, object], description: str) -> tuple[_Rule if "protocol" not in rule: if "port" in rule: raise ScriptError(f"{description}.port requires protocol") - return (_Rule(addresses, None, None, None),) + return (_Rule(version, addresses, None, None, None),) protocol = rule["protocol"] if not isinstance(protocol, str) or protocol not in _RULE_PROTOCOLS: raise ScriptError(f"{description}.protocol must be tcp, udp, icmp, or any") if "port" not in rule: - return (_Rule(addresses, None if protocol == "any" else protocol, None, None),) + return ( + _Rule( + version, + addresses, + None if protocol == "any" else protocol, + None, + None, + ), + ) if protocol == "icmp": raise ScriptError(f"{description}.port is not supported with icmp") start = _port(rule["port"], f"{description}.port") @@ -198,7 +242,9 @@ def _parse_legacy_rule(rule: dict[str, object], description: str) -> tuple[_Rule if end < start: raise ScriptError(f"{description}.endPort {end} cannot be below port {start}") protocols = _PORT_PROTOCOLS if protocol == "any" else (protocol,) - return tuple(_Rule(addresses, selected, start, end) for selected in protocols) + return tuple( + _Rule(version, addresses, selected, start, end) for selected in protocols + ) def _parse_mxc_port(value: object, description: str) -> tuple[_PortSelector, ...]: @@ -237,15 +283,29 @@ def _parse_mxc_rule( if unknown: raise ScriptError(f"{description} has unknown field '{unknown[0]}'") - addresses = _ALL_IPV4 + destinations = _ALL_ADDRESSES if "to" in rule: peers = _array(rule["to"], f"{description}.to") if not peers: raise ScriptError(f"{description}.to must contain at least one destination") - addresses = _merge_intervals( - interval + parsed = [ + _parse_peer(peer, f"{description}.to[{index}]") for index, peer in enumerate(peers) - for interval in _parse_peer(peer, f"{description}.to[{index}]") + ] + # A native rule names one family, so the peers of each family form + # their own destination set. + destinations = tuple( + ( + version, + _merge_intervals( + interval + for peer_version, intervals in parsed + if peer_version == version + for interval in intervals + ), + ) + for version in _IP_VERSIONS + if any(peer_version == version for peer_version, _ in parsed) ) selectors: list[_PortSelector] = [(None, None, None)] @@ -259,7 +319,8 @@ def _parse_mxc_rule( for selector in _parse_mxc_port(port, f"{description}.ports[{index}]") ] return tuple( - _Rule(addresses, protocol, start_port, end_port) + _Rule(version, addresses, protocol, start_port, end_port) + for version, addresses in destinations for protocol, start_port, end_port in selectors ) @@ -279,15 +340,13 @@ def _parse_rule(value: object, description: str) -> tuple[_Rule, ...]: def _intervals_to_networks( intervals: _AddressIntervals, + version: int, maximum: int, category: str, -) -> tuple[ipaddress.IPv4Network, ...]: - networks: list[ipaddress.IPv4Network] = [] +) -> tuple[_Network, ...]: + networks: list[_Network] = [] for start, end in intervals: - summarized = ipaddress.summarize_address_range( - ipaddress.IPv4Address(start), - ipaddress.IPv4Address(end), - ) + summarized = _summarize(start, end, version) for network in summarized: if len(networks) >= maximum: raise ScriptError( @@ -300,18 +359,16 @@ def _intervals_to_networks( def _protocol_intervals_to_networks( protocol_intervals: _AddressIntervals, covered: _AddressIntervals, + version: int, maximum: int, category: str, -) -> tuple[ipaddress.IPv4Network, ...]: +) -> tuple[_Network, ...]: # Rules over `covered` already match this selector, so widening prefixes # into it keeps the output compact without changing what matches. combined = _merge_intervals((*protocol_intervals, *covered)) - networks: list[ipaddress.IPv4Network] = [] + networks: list[_Network] = [] for start, end in combined: - summarized = ipaddress.summarize_address_range( - ipaddress.IPv4Address(start), - ipaddress.IPv4Address(end), - ) + summarized = _summarize(start, end, version) for network in summarized: network_interval = ( int(network.network_address), @@ -330,6 +387,7 @@ def _protocol_intervals_to_networks( def _lower_protocol_rules( rules: list[_Rule], protocol: str, + version: int, category: str, covered: _AddressIntervals, remaining_budget: int, @@ -352,7 +410,7 @@ def _lower_protocol_rules( active: Counter[_AddressIntervals] = Counter() # Each network's port ranges in port order. Every network of one segment # is a distinct native rule, so a segment cannot exceed the budget alone. - port_ranges: dict[ipaddress.IPv4Network, list[_PortRange]] = {} + port_ranges: dict[_Network, list[_PortRange]] = {} rule_count = 0 previous_port: int | None = None for port in sorted(events): @@ -363,6 +421,7 @@ def _lower_protocol_rules( networks = _protocol_intervals_to_networks( addresses, covered, + version, remaining_budget, category, ) @@ -392,7 +451,7 @@ def _lower_protocol_rules( def _native_rule( - network: ipaddress.IPv4Network, protocol: str | None, ports: _PortRange | None + network: _Network, protocol: str | None, ports: _PortRange | None ) -> str: if protocol is None: return str(network) @@ -404,13 +463,12 @@ def _native_rule( return f"{network}:{protocol}:{start}-{end}" -def _compile_category(value: object, category: str) -> tuple[str, ...]: - values = _array(value, category) - rules = [ - rule - for index, item in enumerate(values) - for rule in _parse_rule(item, f"{category}[{index}]") - ] +def _compile_family( + rules: list[_Rule], + version: int, + category: str, + budget: int, +) -> list[_NativeRule]: address_only = _merge_intervals( interval for rule in rules @@ -419,7 +477,8 @@ def _compile_category(value: object, category: str) -> tuple[str, ...]: ) address_only_networks = _intervals_to_networks( address_only, - MAX_RULES_PER_ACTION, + version, + budget, category, ) lowered: list[_NativeRule] = [ @@ -441,7 +500,8 @@ def _compile_category(value: object, category: str) -> tuple[str, ...]: for network in _protocol_intervals_to_networks( protocol_wide, address_only, - MAX_RULES_PER_ACTION - len(lowered), + version, + budget - len(lowered), category, ) ) @@ -451,13 +511,39 @@ def _compile_category(value: object, category: str) -> tuple[str, ...]: _lower_protocol_rules( rules, protocol, + version, category, covered[protocol], - MAX_RULES_PER_ACTION - len(lowered), + budget - len(lowered), ) ) + return lowered + + +def _compile_category(value: object, category: str) -> tuple[str, ...]: + values = _array(value, category) + rules = [ + rule + for index, item in enumerate(values) + for rule in _parse_rule(item, f"{category}[{index}]") + ] + # A native rule matches only its own address family, so each family is + # lowered on its own, within the action's shared rule limit. + lowered: list[_NativeRule] = [] + for version in _IP_VERSIONS: + family = [rule for rule in rules if rule.version == version] + if family: + lowered.extend( + _compile_family( + family, + version, + category, + MAX_RULES_PER_ACTION - len(lowered), + ) + ) lowered.sort( key=lambda item: ( + item[0].version, int(item[0].network_address), item[0].prefixlen, "" if item[1] is None else item[1], diff --git a/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in b/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in index 743526d58..f39b770bb 100644 --- a/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in +++ b/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in @@ -39,9 +39,10 @@ allow) listener_pid=$! port_hex=$(printf '%04X' @GUEST_PORT@) tries=0 + # The listener is dual-stack, so the kernel lists it among IPv6 sockets. while ! awk -v port=":$port_hex" \ '$2 ~ port "$" && $4 == "0A" { found = 1 } END { exit !found }' \ - /proc/net/tcp; do + /proc/net/tcp /proc/net/tcp6; do kill -0 "$listener_pid" 2>/dev/null || fail 95 [ "$tries" -lt 100 ] || fail 95 tries=$((tries + 1)) diff --git a/scripts/nvx_tools/microvm_test_scripts/ipv6-egress-policy.sh.in b/scripts/nvx_tools/microvm_test_scripts/ipv6-egress-policy.sh.in new file mode 100644 index 000000000..900cc74ea --- /dev/null +++ b/scripts/nvx_tools/microvm_test_scripts/ipv6-egress-policy.sh.in @@ -0,0 +1,68 @@ +set -eu +fail() { + code="$1" + echo "NVX-IPV6-EGRESS-FAIL code=$code" + nvx-exit "$code" + exit "$code" +} + +# The kernel's tables show the IPv6 identity, whichever userland configured it. +grep -q 'virtnet_ip6=@GUEST_IPV6@/@PREFIX_LENGTH@' /proc/cmdline || fail 60 +grep -q 'virtnet_gw6=@GATEWAY_IPV6@' /proc/cmdline || fail 61 +grep -q '^@GUEST_IPV6_HEX@ [0-9a-f]* @PREFIX_LENGTH_HEX@ ' /proc/net/if_inet6 || + fail 62 +grep -q '^@ANY_IPV6_HEX@ 00 @ANY_IPV6_HEX@ 00 @GATEWAY_IPV6_HEX@ ' \ + /proc/net/ipv6_route || fail 63 +# The guest names the gateway whose DNS service the policy admits, if any. +dns=$(sed -n 's/.*virtnet_dns=\([^ ]*\).*/\1/p' /proc/cmdline) +if [ -n "$dns" ]; then + grep -Fqx "nameserver $dns" /etc/resolv.conf || fail 64 +fi +echo "NVX-IPV6-EGRESS-DNS ${dns:-none}" + +probe_tcp() { + if response=$(wget -T 2 -qO- "$2" 2>/dev/null); then + if [ "$response" = "NVX-EGRESS-PROBE-$1" ]; then + echo reached + else + echo unexpected + fi + else + echo blocked + fi >"/tmp/nvx-egress-$1" +} + +# Only the host can observe UDP delivery. A denied flow may return 1; sender +# execution failures must not pass. +probe_udp() { + status=0 + printf 'NVX-EGRESS-PROBE-%s' "$1" | + nc -u -w 1 "$2" "$3" >/dev/null 2>&1 || status=$? + case "$status" in + 0 | 1) echo sent ;; + *) echo "failed-$status" ;; + esac >"/tmp/nvx-egress-$1" +} + +probe_icmp() { + if ping -c 1 -W 2 "$2" >/dev/null 2>&1; then + echo reached + else + echo blocked + fi >"/tmp/nvx-egress-$1" +} + +rm -f /tmp/nvx-egress-* +probe_tcp tcp6-named 'http://[@GATEWAY_IPV6@]:@NAMED_PORT@/tcp6-named' & +probe_tcp tcp6-other 'http://[@GATEWAY_IPV6@]:@OTHER_PORT@/tcp6-other' & +probe_udp udp6-named @GATEWAY_IPV6@ @NAMED_PORT@ & +probe_icmp icmp6 @GATEWAY_IPV6@ & +probe_tcp tcp4-named 'http://@GATEWAY_IPV4@:@NAMED_PORT@/tcp4-named' & +wait +for probe in tcp6-named tcp6-other udp6-named icmp6 tcp4-named; do + [ -s "/tmp/nvx-egress-$probe" ] || fail 70 + echo "NVX-L3-L4-PROBE $probe=$(cat "/tmp/nvx-egress-$probe")" +done + +echo NVX-IPV6-EGRESS-OK +nvx-exit 0 diff --git a/scripts/nvx_tools/microvm_test_scripts/ipv6-egress-snapshot.sh.in b/scripts/nvx_tools/microvm_test_scripts/ipv6-egress-snapshot.sh.in new file mode 100644 index 000000000..e875d9e6a --- /dev/null +++ b/scripts/nvx_tools/microvm_test_scripts/ipv6-egress-snapshot.sh.in @@ -0,0 +1,21 @@ +set -eu +fail() { + code="$1" + echo "NVX-IPV6-EGRESS-FAIL code=$code" + nvx-exit "$code" + exit "$code" +} + +# The restored NIC must keep the IPv6 identity and the policy that admits it. +probe() { + response=$(wget -T 5 -qO- 'http://[@GATEWAY_IPV6@]:@NAMED_PORT@/tcp6-named') || + fail "$1" + [ "$response" = NVX-EGRESS-PROBE-tcp6-named ] || fail "$1" +} + +probe 80 +echo NVX-IPV6-EGRESS-BEFORE +nvx-snapshot +probe 81 +echo NVX-IPV6-EGRESS-AFTER +nvx-exit 0 diff --git a/scripts/nvx_tools/microvm_test_scripts/virtio-net.sh b/scripts/nvx_tools/microvm_test_scripts/virtio-net.sh index 400a6ed9c..6d7c7b3e9 100644 --- a/scripts/nvx_tools/microvm_test_scripts/virtio-net.sh +++ b/scripts/nvx_tools/microvm_test_scripts/virtio-net.sh @@ -24,6 +24,13 @@ grep -q 'virtio_mmio.device=0x1000@0xd0000000:' /proc/cmdline || fail 21 grep -q 'virtnet_ip=10.0.0.2' /proc/cmdline || fail 22 grep -q 'virtnet_mask=255.255.255.0' /proc/cmdline || fail 23 grep -q 'virtnet_gw=10.0.0.1' /proc/cmdline || fail 24 +# The guest configures the IPv6 identity that embeds the IPv4 one, which the +# kernel's tables show whichever userland configured it. +grep -q 'virtnet_ip6=fd00::a00:2/120' /proc/cmdline || fail 31 +grep -q 'virtnet_gw6=fd00::a00:1' /proc/cmdline || fail 32 +grep -q '^fd00000000000000000000000a000002 [0-9a-f]* 78 ' /proc/net/if_inet6 || fail 33 +grep -q '^00000000000000000000000000000000 00 00000000000000000000000000000000 00 fd00000000000000000000000a000001 ' \ + /proc/net/ipv6_route || fail 34 [ "$(cat /sys/class/net/$device/address)" = '52:54:00:00:00:02' ] || fail 25 grep -qi 'd0000000-d0000fff.*virtio' /proc/iomem || fail 26 ifconfig "$device" 10.0.0.2 netmask 255.255.255.0 up || fail 27 diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index c858cf97e..605d5ab13 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import ipaddress import json import os import queue @@ -101,6 +102,7 @@ "guest-boot", "guest-identity", "host-loopback-policy", + "ipv6-egress-policy", "lifecycle", "l3-l4-egress-policy", "l3-l4-egress-port-ranges", @@ -182,6 +184,19 @@ for protocol in ("tcp", "udp") for position in L3_L4_EGRESS_RANGE_POSITIONS ) +# The portable profile embeds the NIC's IPv4 addresses in fd00::/96, so the +# directional network's guest is fd00::c000:202/120 behind fd00::c000:201. +DIRECTIONAL_NETWORK_GUEST_IPV6 = "fd00::c000:202" +DIRECTIONAL_NETWORK_GATEWAY_IPV6 = "fd00::c000:201" +DIRECTIONAL_NETWORK_IPV6_PREFIX_LENGTH = 120 +IPV6_EGRESS_COMPLETION_MARKER = b"NVX-IPV6-EGRESS-OK" +IPV6_EGRESS_BEFORE_MARKER = b"NVX-IPV6-EGRESS-BEFORE" +IPV6_EGRESS_AFTER_MARKER = b"NVX-IPV6-EGRESS-AFTER" +IPV6_EGRESS_DNS_PREFIX = "NVX-IPV6-EGRESS-DNS " +# The IPv6 scenario's guest probes toward the gateway's IPv6 address: TCP to a +# named port and to the next port, UDP to the named port, and ICMPv6 echo, +# plus TCP to the named port over IPv4. +IPV6_EGRESS_PROBES = ("tcp6-named", "tcp6-other", "udp6-named", "icmp6", "tcp4-named") HOST_LOOPBACK_DENY_MARKER = b"NVX-HOST-LOOPBACK-DENY-OK" HOST_LOOPBACK_UDP_CONTROL_MARKER = b"NVX-HOST-LOOPBACK-UDP-CONTROL-OK" HOST_LOOPBACK_ALLOW_MARKER = b"NVX-HOST-LOOPBACK-ALLOW-OK" @@ -2172,6 +2187,7 @@ class _EgressCase: allow: tuple[dict[str, object], ...] deny: tuple[dict[str, object], ...] reached: frozenset[str] + default: str = "deny" def _egress_protocol_cases(gateway: str, named_port: int) -> tuple[_EgressCase, ...]: @@ -2325,7 +2341,7 @@ def _check_egress_case( # Consomme answers echo requests to the gateway itself, so only the # guest observes ICMP. observed = probe in host_reached - if not guest_matches or (probe != "icmp" and observed != expected): + if not guest_matches or (not probe.startswith("icmp") and observed != expected): raise RuntimeError( f"L3/L4 {kind} case {case.name}: {probe} should be " f"{'reached' if expected else 'blocked'}, but the guest reported " @@ -2721,6 +2737,415 @@ def run_l3_l4_egress_port_ranges( endpoint.close() +@dataclass(frozen=True) +class _Ipv6EgressCase(_EgressCase): + # The gateway address that the guest names as its DNS server, if any. + dns: str | None = None + + +def _ipv6_egress_cases( + gateway_ipv4: str, gateway_ipv6: str, named_port: int, other_port: int +) -> tuple[_Ipv6EgressCase, ...]: + """Return the IPv6 scenario's MXC-shaped policies, the probes that each + admits, and the DNS server that each gives the guest: the IPv4 gateway when + the policy allows DNS to it, and otherwise the IPv6 gateway when it allows + DNS to that one. The other port follows the named port.""" + gateway = [{"cidr": f"{gateway_ipv6}/128"}] + every_probe = frozenset(IPV6_EGRESS_PROBES) + named_tcp: dict[str, object] = {"protocol": "tcp", "port": named_port} + return ( + # An unfiltered NIC carries every probe, so the IPv6 path itself works. + _Ipv6EgressCase( + "allow-all", (), (), every_probe, default="allow", dns=gateway_ipv4 + ), + # A denied default without rules blocks IPv6 as well as IPv4. + _Ipv6EgressCase("deny-all", (), (), frozenset()), + _Ipv6EgressCase( + "allow-ipv6-tcp-port", + ({"to": gateway, "ports": [named_tcp]},), + (), + frozenset(("tcp6-named",)), + ), + # A port range reaches OpenVMM as one IPv6 range rule. + _Ipv6EgressCase( + "allow-ipv6-tcp-range", + ( + { + "to": gateway, + "ports": [ + {"protocol": "tcp", "port": named_port, "endPort": other_port} + ], + }, + ), + (), + frozenset(("tcp6-named", "tcp6-other")), + ), + _Ipv6EgressCase( + "allow-ipv6-udp-icmp", + ({"to": gateway, "ports": [{"protocol": "udp"}, {"protocol": "icmp"}]},), + (), + frozenset(("udp6-named", "icmp6")), + dns=gateway_ipv6, + ), + # The IPv6 wildcard admits IPv6 alone, and the deny rule takes + # precedence over it. + _Ipv6EgressCase( + "deny-ipv6-tcp-port", + ({"to": [{"cidr": "::/0"}]},), + ({"to": gateway, "ports": [named_tcp]},), + every_probe - {"tcp6-named", "tcp4-named"}, + dns=gateway_ipv6, + ), + # A rule without destinations matches both families. + _Ipv6EgressCase( + "allow-tcp-port-without-to", + ({"ports": [named_tcp]},), + (), + frozenset(("tcp6-named", "tcp4-named")), + ), + # Rules match only their own family, so IPv4 rules leave IPv6 to the + # denied default, and IPv6 rules leave IPv4 to the allowed one. + _Ipv6EgressCase( + "allow-ipv4-only", + ({"to": [{"cidr": "0.0.0.0/0"}]},), + (), + frozenset(("tcp4-named",)), + dns=gateway_ipv4, + ), + _Ipv6EgressCase( + "deny-ipv6-only", + (), + ({"to": [{"cidr": "fd00::/8"}]},), + frozenset(("tcp4-named",)), + default="allow", + dns=gateway_ipv4, + ), + ) + + +def _ipv6_egress_dns_report(output: str) -> str | None: + """Return the DNS server that the guest reported, or ``none``.""" + reports = [ + line.strip()[len(IPV6_EGRESS_DNS_PREFIX) :] + for line in output.splitlines() + if line.strip().startswith(IPV6_EGRESS_DNS_PREFIX) + ] + return reports[-1] if reports else None + + +def _bind_ipv6_egress_ports() -> dict[str, socket.socket]: + """Reserve the IPv6 scenario's host endpoints on two consecutive ports. + + The gateway maps guest flows onto host loopback, so the IPv6 probes reach + ``::1`` and the IPv4 probe reaches ``127.0.0.1``. + """ + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as loopback: + loopback.bind(("::1", 0)) + except OSError as error: + raise RuntimeError("the host has no IPv6 loopback address") from error + layout = ( + ("tcp6-named", socket.AF_INET6, socket.SOCK_STREAM, "::1", 0), + ("tcp6-other", socket.AF_INET6, socket.SOCK_STREAM, "::1", 1), + ("udp6-named", socket.AF_INET6, socket.SOCK_DGRAM, "::1", 0), + ("tcp4-named", socket.AF_INET, socket.SOCK_STREAM, "127.0.0.1", 0), + ) + for base in range(20000, 59999): + endpoints: dict[str, socket.socket] = {} + try: + for name, family, kind, host, offset in layout: + endpoint = socket.socket(family, kind) + endpoints[name] = endpoint + endpoint.bind((host, base + offset)) + if kind == socket.SOCK_STREAM: + endpoint.listen(8) + return endpoints + except OSError: + for endpoint in endpoints.values(): + endpoint.close() + raise RuntimeError("could not reserve consecutive IPv6 and IPv4 loopback ports") + + +def _ipv6_identity_values() -> dict[str, str]: + """Return the guest scripts' IPv6 identity, including the kernel's spelling + of addresses in ``/proc/net/if_inet6`` and ``/proc/net/ipv6_route``.""" + + def kernel(address: str) -> str: + return ipaddress.IPv6Address(address).exploded.replace(":", "") + + return { + "GUEST_IPV6": DIRECTIONAL_NETWORK_GUEST_IPV6, + "GATEWAY_IPV6": DIRECTIONAL_NETWORK_GATEWAY_IPV6, + "PREFIX_LENGTH": str(DIRECTIONAL_NETWORK_IPV6_PREFIX_LENGTH), + "GUEST_IPV6_HEX": kernel(DIRECTIONAL_NETWORK_GUEST_IPV6), + "GATEWAY_IPV6_HEX": kernel(DIRECTIONAL_NETWORK_GATEWAY_IPV6), + "PREFIX_LENGTH_HEX": f"{DIRECTIONAL_NETWORK_IPV6_PREFIX_LENGTH:02x}", + "ANY_IPV6_HEX": kernel("::"), + } + + +def _ipv6_egress_rule_arguments(*rules: str) -> list[str]: + arguments = ["--network-egress", "deny"] + for rule in rules: + arguments.extend(("--network-egress-allow", rule)) + return arguments + + +def _run_ipv6_egress_snapshot( + executable: Path, + kernel: Path, + initrd: Path, + backend: str, + *, + memory_mib: int, + timeout: float, + output_dir: Path, + endpoint: socket.socket, + named_port: int, +) -> None: + """Check that a restored NIC keeps its IPv6 identity and policy.""" + ipv6_rule = f"{DIRECTIONAL_NETWORK_GATEWAY_IPV6}/128:tcp:{named_port}" + with ( + tempfile.TemporaryDirectory(prefix="nvx-ipv6-egress-") as temporary, + _EgressProbeServer({"tcp6-named": endpoint}, timeout) as server, + ): + snapshot_path = Path(temporary) / "snapshot" + capture_command = [ + *workload_boot_command( + executable, + backend, + kernel, + initrd, + memory_mib, + "quiet loglevel=0", + network=DIRECTIONAL_NETWORK_CIDR, + ), + "--snapshot-destination", + str(snapshot_path), + *_ipv6_egress_rule_arguments(ipv6_rule), + ] + with OpenvmmProcess( + capture_command, + output_dir / "ipv6-egress-capture.log", + ) as process: + process.wait_for(BOOT_MARKER, timeout) + _stage_script( + process, + "/tmp/nvx-ipv6-egress-snapshot", + "NVX_IPV6_EGRESS_SNAPSHOT", + _render_script( + "ipv6-egress-snapshot.sh.in", + GATEWAY_IPV6=DIRECTIONAL_NETWORK_GATEWAY_IPV6, + NAMED_PORT=str(named_port), + ), + ) + source = process.wait(timeout) + if source.returncode != 0: + raise RuntimeError( + f"IPv6 egress capture source exited with {source.returncode}" + ) + source_lines = _output_lines(source.output) + if source_lines.count(IPV6_EGRESS_BEFORE_MARKER) != 1: + raise RuntimeError("IPv6 egress source did not reach capture exactly once") + if IPV6_EGRESS_AFTER_MARKER in source_lines: + raise RuntimeError("IPv6 egress source crossed the capture boundary") + + # The policy digest binds the IPv6 rule, so an IPv4 rule for the same + # port does not satisfy it. + changed_command = [ + *snapshot_restore_command( + executable, backend, snapshot_path, network_profile="portable" + ), + *_ipv6_egress_rule_arguments( + f"{DIRECTIONAL_NETWORK_GATEWAY_IPV4}/32:tcp:{named_port}" + ), + ] + with OpenvmmProcess( + changed_command, + output_dir / "ipv6-egress-restore-changed.log", + ) as process: + changed = process.wait(timeout) + if changed.returncode == 0 or ( + b"restore-time egress policy does not match" not in changed.output + ): + raise RuntimeError( + "IPv6 egress restore with a changed rule was not rejected" + ) + + restore_command = [ + *snapshot_restore_command( + executable, backend, snapshot_path, network_profile="portable" + ), + *_ipv6_egress_rule_arguments(ipv6_rule), + ] + with OpenvmmProcess( + restore_command, + output_dir / "ipv6-egress-restore.log", + ) as process: + process.wait_for(IPV6_EGRESS_AFTER_MARKER, timeout) + restored = process.wait(timeout) + if restored.returncode != 0: + raise RuntimeError(f"IPv6 egress restore exited with {restored.returncode}") + if _output_lines(restored.output).count(IPV6_EGRESS_AFTER_MARKER) != 1: + raise RuntimeError("IPv6 egress restore did not reach the host once") + if server.finish() != frozenset(("tcp6-named",)): + raise RuntimeError("IPv6 egress snapshot probes did not reach the host") + + +def run_ipv6_egress_policy( + executable: Path, + kernel: Path, + initrd: Path, + backend: str, + *, + memory_mib: int, + timeout: float, + output_dir: Path, + guest: str = "alpine", +) -> None: + endpoints = _bind_ipv6_egress_ports() + try: + named_port = int(endpoints["tcp6-named"].getsockname()[1]) + other_port = int(endpoints["tcp6-other"].getsockname()[1]) + script = _render_script( + "ipv6-egress-policy.sh.in", + **_ipv6_identity_values(), + GATEWAY_IPV4=DIRECTIONAL_NETWORK_GATEWAY_IPV4, + NAMED_PORT=str(named_port), + OTHER_PORT=str(other_port), + ) + results: list[dict[str, object]] = [] + for case in _ipv6_egress_cases( + DIRECTIONAL_NETWORK_GATEWAY_IPV4, + DIRECTIONAL_NETWORK_GATEWAY_IPV6, + named_port, + other_port, + ): + policy_path = output_dir / f"ipv6-egress-{case.name}-policy.json" + policy_path.write_text( + json.dumps({"allow": case.allow, "deny": case.deny}, indent=2) + "\n", + encoding="utf-8", + ) + native = compile_policy_file(policy_path) + command = [ + sys.executable, + str(Path(__file__).parents[1] / "nvx.py"), + "run", + "--guest", + guest, + "--hypervisor", + backend, + "--memory-mib", + str(memory_mib), + "--net", + DIRECTIONAL_NETWORK_CIDR, + "--network-profile", + "portable", + "--network-egress", + case.default, + "--network-ingress", + "deny", + "--network-egress-policy-file", + str(policy_path), + "--cmdline", + "quiet loglevel=0", + ] + with _EgressProbeServer(endpoints, timeout) as server: + result = run_guest_script( + command, + script, + IPV6_EGRESS_COMPLETION_MARKER, + timeout=timeout, + log_path=output_dir / f"ipv6-egress-{case.name}.log", + contain_process_tree=True, + ) + host_reached = server.finish() + guest_reports = _egress_probe_reports(result["text"]) + _check_egress_case( + "IPv6 egress", case, IPV6_EGRESS_PROBES, guest_reports, host_reached + ) + dns = _ipv6_egress_dns_report(result["text"]) + if dns != (case.dns or "none"): + raise RuntimeError( + f"IPv6 egress case {case.name}: the guest named {dns!r} as its " + f"DNS server instead of {case.dns or 'none'!r}" + ) + results.append( + { + "case": case.name, + "default": case.default, + "policy_file": policy_path.name, + "native_rules": { + "allow": list(native.allow), + "deny": list(native.deny), + }, + "expected_reached": sorted(case.reached), + "guest": guest_reports, + "host_reached": sorted(host_reached), + "dns": dns, + } + ) + + _run_ipv6_egress_snapshot( + executable, + kernel, + initrd, + backend, + memory_mib=memory_mib, + timeout=timeout, + output_dir=output_dir, + endpoint=endpoints["tcp6-named"], + named_port=named_port, + ) + + invalid = workload_boot_command( + executable, + backend, + kernel, + initrd, + memory_mib, + "quiet loglevel=0", + network=DIRECTIONAL_NETWORK_CIDR, + ) + invalid.extend( + _ipv6_egress_rule_arguments(f"{DIRECTIONAL_NETWORK_GATEWAY_IPV6}/129") + ) + with OpenvmmProcess( + invalid, + output_dir / "ipv6-egress-invalid-prefix.log", + ) as process: + rejected = process.wait(timeout) + if ( + rejected.returncode == 0 + or b"IPv6 prefix /129 is outside the supported range" not in rejected.output + or BOOT_MARKER in rejected.output + ): + raise RuntimeError( + "an invalid IPv6 egress rule was not rejected before boot" + ) + + (output_dir / "ipv6-egress-policy-results.json").write_text( + json.dumps( + { + "interface": "nvx.py run", + "guest_ipv6": ( + f"{DIRECTIONAL_NETWORK_GUEST_IPV6}/" + f"{DIRECTIONAL_NETWORK_IPV6_PREFIX_LENGTH}" + ), + "gateway_ipv6": DIRECTIONAL_NETWORK_GATEWAY_IPV6, + "ports": {"named": named_port, "other": other_port}, + "cases": results, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + finally: + for endpoint in endpoints.values(): + endpoint.close() + + def _http_server( listener: socket.socket, expected_path: bytes, @@ -6389,6 +6814,21 @@ def run(args: argparse.Namespace) -> int: output_dir=output_dir, guest=descriptor.name, ) + if "ipv6-egress-policy" in scenarios: + print( + "Running public nvx.py IPv6 egress policy acceptance " + f"on OpenVMM/{args.backend}" + ) + run_ipv6_egress_policy( + executable, + kernel, + initrd, + args.backend, + memory_mib=args.memory_mib, + timeout=args.timeout, + output_dir=output_dir, + guest=descriptor.name, + ) if "host-loopback-policy" in scenarios: print(f"Running microVM host-loopback policy on OpenVMM/{args.backend}") run_host_loopback_policy( diff --git a/scripts/nvx_tools/release.py b/scripts/nvx_tools/release.py index 328de0e06..d9a4c38a9 100644 --- a/scripts/nvx_tools/release.py +++ b/scripts/nvx_tools/release.py @@ -1746,6 +1746,7 @@ def verify_source_tree() -> None: "CONFIG_VIRTIO_FS=y", "CONFIG_FUSE_FS=y", *KernelBuildConstants.REQUIRED_SANDBOX_CONFIG, + *KernelBuildConstants.REQUIRED_NETWORK_CONFIG, *KernelBuildConstants.REQUIRED_TIME_ABI_CONFIG, *KernelBuildConstants.REQUIRED_HARDENING_CONFIG, ): @@ -1764,6 +1765,7 @@ def verify_source_tree() -> None: *KernelBuildConstants.REQUIRED_DIRECT_BOOT_CONFIG, "CONFIG_HVC_XE9=y", *KernelBuildConstants.REQUIRED_SANDBOX_CONFIG, + *KernelBuildConstants.REQUIRED_NETWORK_CONFIG, ): if setting not in generated: raise ScriptError(f"{generated_config} is missing {setting}") diff --git a/scripts/test_egress_policy.py b/scripts/test_egress_policy.py index 56cc652a7..8a25147dc 100644 --- a/scripts/test_egress_policy.py +++ b/scripts/test_egress_policy.py @@ -411,9 +411,18 @@ def test_compiles_mxc_destination_and_port_unions(self): } ) + # A rule without destinations matches every destination of both + # families. self.assertEqual( compiled.allow, - ("0.0.0.0/0:icmp", "0.0.0.0/0:tcp:53", "0.0.0.0/0:udp:53"), + ( + "0.0.0.0/0:icmp", + "0.0.0.0/0:tcp:53", + "0.0.0.0/0:udp:53", + "::/0:icmp", + "::/0:tcp:53", + "::/0:udp:53", + ), ) self.assertEqual( compiled.deny, @@ -457,9 +466,15 @@ def test_deduplicates_overlapping_exclusions_and_collapses_safe_prefixes(self): ) def test_rejects_exclusions_outside_parent_or_wrong_family(self): - for excluded in ("198.51.100.0/24", "2001:db8::/32"): + for cidr, excluded in ( + ("192.0.2.0/24", "198.51.100.0/24"), + ("192.0.2.0/24", "2001:db8::/32"), + ("2001:db8::/32", "2001:db9::/48"), + ("2001:db8::/32", "192.0.2.0/24"), + ("::/96", "0.0.0.0/8"), + ): with ( - self.subTest(excluded=excluded), + self.subTest(cidr=cidr, excluded=excluded), self.assertRaisesRegex(ScriptError, "except"), ): self.compile( @@ -468,7 +483,7 @@ def test_rejects_exclusions_outside_parent_or_wrong_family(self): { "to": [ { - "cidr": "192.0.2.0/24", + "cidr": cidr, "except": [excluded], } ], @@ -477,6 +492,179 @@ def test_rejects_exclusions_outside_parent_or_wrong_family(self): } ) + def test_compiles_ipv6_networks_with_every_selector(self): + selectors: tuple[tuple[dict[str, object], tuple[str, ...]], ...] = ( + ({"ports": [{"protocol": "tcp"}]}, ("2001:db8::7/128:tcp",)), + ({"ports": [{"protocol": "udp"}]}, ("2001:db8::7/128:udp",)), + ({"ports": [{"protocol": "icmp"}]}, ("2001:db8::7/128:icmp",)), + ({}, ("2001:db8::7/128",)), + ( + {"ports": [{"protocol": "any", "port": 443}]}, + ("2001:db8::7/128:tcp:443", "2001:db8::7/128:udp:443"), + ), + ( + {"ports": [{"protocol": "tcp", "port": 8000, "endPort": 8010}]}, + ("2001:db8::7/128:tcp:8000-8010",), + ), + ) + for category in ("allow", "deny"): + for selector, expected in selectors: + with self.subTest(category=category, selector=selector): + compiled = self.compile( + {category: [{"to": [{"cidr": "2001:db8::7"}], **selector}]} + ) + self.assertEqual(getattr(compiled, category), expected) + + def test_compiles_one_allowed_ipv6_network_with_a_denied_address(self): + compiled = self.compile( + { + "allow": [ + { + "to": [{"cidr": "2001:db8:1::/64"}], + "ports": [{"protocol": "tcp", "port": 443}], + }, + { + "to": [{"cidr": "::/0"}], + "ports": [{"protocol": "udp", "port": 53}], + }, + ], + "deny": [{"to": [{"cidr": "2001:db8:1::123"}]}], + } + ) + self.assertEqual(compiled.allow, ("::/0:udp:53", "2001:db8:1::/64:tcp:443")) + self.assertEqual(compiled.deny, ("2001:db8:1::123/128",)) + + def test_flat_rules_accept_ipv6_networks(self): + compiled = self.compile( + { + "allow": [ + { + "cidr": "2001:db8::/32", + "except": ["2001:db8::/33"], + "protocol": "tcp", + "port": 443, + } + ] + } + ) + self.assertEqual(compiled.allow, ("2001:db8:8000::/33:tcp:443",)) + + def test_keeps_each_address_family_separate(self): + # IPv4 0.0.0.0/0 and IPv6 ::/96 span the same integers, so lowering + # both families together would merge or cover one with the other. + compiled = self.compile( + { + "allow": [ + { + "to": [{"cidr": "::/96"}, {"cidr": "0.0.0.0/0"}], + "ports": [{"protocol": "tcp", "port": 80}], + }, + {"to": [{"cidr": "192.0.2.0/24"}]}, + { + "to": [{"cidr": "::c000:200/120"}], + "ports": [{"protocol": "udp", "port": 53}], + }, + ], + "deny": [ + {"to": [{"cidr": "0.0.0.0/0"}]}, + {"to": [{"cidr": "::/96"}], "ports": [{"protocol": "icmp"}]}, + ], + } + ) + self.assertEqual( + compiled.allow, + ( + "0.0.0.0/0:tcp:80", + "192.0.2.0/24", + "::/96:tcp:80", + "::c000:200/120:udp:53", + ), + ) + self.assertEqual(compiled.deny, ("0.0.0.0/0", "::/96:icmp")) + + def test_lowers_the_destinations_of_each_family_separately(self): + compiled = self.compile( + { + "allow": [ + { + "to": [ + {"cidr": "192.0.2.0/25"}, + {"cidr": "2001:db8::/32", "except": ["2001:db8::/33"]}, + {"cidr": "192.0.2.128/25"}, + ], + "ports": [{"protocol": "tcp", "port": 443}], + } + ] + } + ) + self.assertEqual( + compiled.allow, + ("192.0.2.0/24:tcp:443", "2001:db8:8000::/33:tcp:443"), + ) + + def test_subtracts_ipv6_exclusions(self): + compiled = self.compile( + { + "deny": [ + { + "to": [ + { + "cidr": "2001:db8::/32", + "except": ["2001:db8:1::/48", "2001:db8:1::/64"], + } + ], + "ports": [{"protocol": "tcp", "port": 22}], + } + ] + } + ) + expected = sorted( + ipaddress.IPv6Network("2001:db8::/32").address_exclude( + ipaddress.IPv6Network("2001:db8:1::/48") + ), + key=lambda network: int(network.network_address), + ) + self.assertEqual( + compiled.deny, tuple(f"{network}:tcp:22" for network in expected) + ) + self.assertEqual(len(compiled.deny), 16) + + def test_rejects_ipv6_scopes(self): + for cidr in ("fe80::%eth0/64", "fe80::1%1"): + with ( + self.subTest(cidr=cidr), + self.assertRaisesRegex(ScriptError, "scope"), + ): + self.compile({"allow": [{"to": [{"cidr": cidr}]}]}) + + def test_both_families_share_the_native_rule_budget(self): + # Ports that are not adjacent cannot form one range, so each lowers to + # its own native rule. + def rules(ipv4_ports: int, ipv6_ports: int) -> dict[str, object]: + return { + "allow": [ + { + "to": [{"cidr": cidr}], + "ports": [ + {"protocol": "tcp", "port": 2 * index + 1} + for index in range(ports) + ], + } + for cidr, ports in ( + ("192.0.2.1", ipv4_ports), + ("2001:db8::1", ipv6_ports), + ) + ] + } + + self.assertEqual(len(self.compile(rules(128, 128)).allow), 256) + for ipv4_ports, ipv6_ports in ((129, 128), (128, 129)): + with ( + self.subTest(ipv4_ports=ipv4_ports, ipv6_ports=ipv6_ports), + self.assertRaisesRegex(ScriptError, "at most 256"), + ): + self.compile(rules(ipv4_ports, ipv6_ports)) + def test_rejects_invalid_mxc_rule_shapes(self): invalid_rules: tuple[object, ...] = ( {"to": list[object]()}, diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index ad6bc5e27..82b171b59 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -5,6 +5,7 @@ import ast import inspect import io +import ipaddress import json import os import queue @@ -2553,6 +2554,56 @@ def test_host_loopback_script_probes_udp_on_proxy_and_general_ports(self): self.assertIn("nc -u -w 1 10.0.0.1 8444", script) self.assertIn("http://10.0.0.1:8443/proxy", script) + def test_host_loopback_allow_waits_for_a_dual_stack_listener(self): + shell = _posix_shell() + if shell is None: + self.skipTest("POSIX shell is unavailable") + header = " sl local_address rem_address st\n" + listener = ( + f" 0: {'0' * 32}:1F90 {'0' * 32}:0000 0A 00000000:00000000 00:00000000\n" + ) + for label, tcp6, expected, marker in ( + ("listed", header + listener, 0, "NVX-HOST-LOOPBACK-ALLOW-OK"), + ("missing", header, 95, "NVX-HOST-LOOPBACK-FAIL code=95"), + ): + with self.subTest(label), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "proc" / "net").mkdir(parents=True) + (root / "proc" / "net" / "tcp").write_text(header, encoding="utf-8") + (root / "proc" / "net" / "tcp6").write_text(tcp6, encoding="utf-8") + script = ( + microvm_tests._render_script( + "host-loopback-policy.sh.in", + MODE="allow", + GATEWAY_IPV4="10.0.0.1", + GENERAL_PORT="8444", + PROXY_PORT="8443", + GUEST_PORT="8080", + ) + .replace("nvx-exit", "nvx_exit") + .replace("/proc/net/", f"{root.as_posix()}/proc/net/") + .replace( + "/tmp/nvx-host-loopback-inbound", + f"{root.as_posix()}/nvx-host-loopback-inbound", + ) + ) + result = subprocess.run( + [shell, "-s"], + input=( + "wget() { echo NVX-HOST-LOOPBACK-GENERAL; }\n" + "nc() { sleep 0.3; echo NVX-HOST-LOOPBACK-INBOUND; }\n" + 'nvx_exit() { exit "$1"; }\n' + script + ), + text=True, + capture_output=True, + timeout=10, + check=False, + ) + self.assertEqual( + result.returncode, expected, result.stdout + result.stderr + ) + self.assertIn(marker, result.stdout) + @staticmethod def _outcome_report( backend: str, @@ -5572,6 +5623,718 @@ def require(path: Path, _description: str) -> Path: ) self.assertIn("l3-l4-egress-port-ranges", microvm_tests.MICROVM_TEST_SCENARIOS) + def test_ipv6_egress_identity_embeds_the_directional_network(self): + prefix = int(ipaddress.IPv6Address("fd00::")) + for ipv4, ipv6 in ( + ( + microvm_tests.DIRECTIONAL_NETWORK_GUEST_IPV4, + microvm_tests.DIRECTIONAL_NETWORK_GUEST_IPV6, + ), + ( + microvm_tests.DIRECTIONAL_NETWORK_GATEWAY_IPV4, + microvm_tests.DIRECTIONAL_NETWORK_GATEWAY_IPV6, + ), + ): + self.assertEqual( + ipaddress.IPv6Address(prefix | int(ipaddress.IPv4Address(ipv4))), + ipaddress.IPv6Address(ipv6), + ) + ipv4_prefix_length = int(microvm_tests.DIRECTIONAL_NETWORK_CIDR.split("/")[1]) + self.assertEqual( + microvm_tests.DIRECTIONAL_NETWORK_IPV6_PREFIX_LENGTH, + 96 + ipv4_prefix_length, + ) + self.assertEqual( + microvm_tests._ipv6_identity_values(), + { + "GUEST_IPV6": "fd00::c000:202", + "GATEWAY_IPV6": "fd00::c000:201", + "PREFIX_LENGTH": "120", + "GUEST_IPV6_HEX": "fd0000000000000000000000c0000202", + "GATEWAY_IPV6_HEX": "fd0000000000000000000000c0000201", + "PREFIX_LENGTH_HEX": "78", + "ANY_IPV6_HEX": "0" * 32, + }, + ) + + def test_ipv6_egress_cases_cover_families_and_precedence(self): + cases = microvm_tests._ipv6_egress_cases( + "192.0.2.1", "fd00::c000:201", 20000, 20001 + ) + gateway = "fd00::c000:201/128" + named_tcp = f"{gateway}:tcp:20000" + every_probe = set(microvm_tests.IPV6_EGRESS_PROBES) + lowered = {} + for case in cases: + compiled = egress_policy.compile_policy( + {"allow": list(case.allow), "deny": list(case.deny)} + ) + lowered[case.name] = ( + case.default, + compiled.allow, + compiled.deny, + set(case.reached), + case.dns, + ) + # The guest names the IPv4 gateway as its DNS server when the policy + # allows DNS to it, and otherwise the IPv6 gateway when it allows DNS + # to that one. + self.assertEqual( + lowered, + { + "allow-all": ("allow", (), (), every_probe, "192.0.2.1"), + "deny-all": ("deny", (), (), set(), None), + "allow-ipv6-tcp-port": ( + "deny", + (named_tcp,), + (), + {"tcp6-named"}, + None, + ), + # The range lowers to one native range rule. + "allow-ipv6-tcp-range": ( + "deny", + (f"{gateway}:tcp:20000-20001",), + (), + {"tcp6-named", "tcp6-other"}, + None, + ), + "allow-ipv6-udp-icmp": ( + "deny", + (f"{gateway}:icmp", f"{gateway}:udp"), + (), + {"udp6-named", "icmp6"}, + "fd00::c000:201", + ), + "deny-ipv6-tcp-port": ( + "deny", + ("::/0",), + (named_tcp,), + {"tcp6-other", "udp6-named", "icmp6"}, + "fd00::c000:201", + ), + "allow-tcp-port-without-to": ( + "deny", + ("0.0.0.0/0:tcp:20000", "::/0:tcp:20000"), + (), + {"tcp6-named", "tcp4-named"}, + None, + ), + "allow-ipv4-only": ( + "deny", + ("0.0.0.0/0",), + (), + {"tcp4-named"}, + "192.0.2.1", + ), + "deny-ipv6-only": ( + "allow", + (), + ("fd00::/8",), + {"tcp4-named"}, + "192.0.2.1", + ), + }, + ) + + def _run_ipv6_egress( + self, + output_dir: Path, + *, + leaked: tuple[str, str] | None = None, + guest_report: tuple[str, str, str] | None = None, + dns_report: tuple[str, str] | None = None, + changed_restore: bytes = b"restore-time egress policy does not match", + snapshot_reached: frozenset[str] = frozenset(("tcp6-named",)), + ) -> tuple[MagicMock, MagicMock]: + endpoints = { + name: MagicMock(spec=socket.socket) + for name in ("tcp6-named", "tcp6-other", "udp6-named", "tcp4-named") + } + for name, endpoint in endpoints.items(): + endpoint.getsockname.return_value = ( + "::1", + 20001 if name == "tcp6-other" else 20000, + ) + cases = { + case.name: case + for case in microvm_tests._ipv6_egress_cases( + microvm_tests.DIRECTIONAL_NETWORK_GATEWAY_IPV4, + microvm_tests.DIRECTIONAL_NETWORK_GATEWAY_IPV6, + 20000, + 20001, + ) + } + current: list[str] = [] + + def guest( + command: list[str], _script: str, _marker: bytes, **_kwargs: object + ) -> dict[str, str]: + policy = Path(command[command.index("--network-egress-policy-file") + 1]) + name = policy.name.removeprefix("ipv6-egress-").removesuffix("-policy.json") + current.append(name) + reports = { + probe: "sent" + if probe.startswith("udp") + else "reached" + if probe in cases[name].reached + else "blocked" + for probe in microvm_tests.IPV6_EGRESS_PROBES + } + if guest_report is not None and guest_report[0] == name: + reports[guest_report[1]] = guest_report[2] + dns = cases[name].dns or "none" + if dns_report is not None and dns_report[0] == name: + dns = dns_report[1] + # The tty echoes script lines, which must not count as reports. + text = 'echo "NVX-IPV6-EGRESS-DNS ${dns:-none}"\r\n' + text += f"NVX-IPV6-EGRESS-DNS {dns}\r\n" + text += "".join( + f"NVX-L3-L4-PROBE {probe}={value}\r\n" + for probe, value in reports.items() + ) + return {"text": text + "NVX-IPV6-EGRESS-OK\r\n"} + + class FakeServer: + def __init__( + self, served: dict[str, socket.socket], _timeout: float + ) -> None: + self.snapshot = list(served) == ["tcp6-named"] + if self.snapshot: + assert served["tcp6-named"] is endpoints["tcp6-named"] + else: + assert served == endpoints + + def __enter__(self) -> "FakeServer": + return self + + def __exit__(self, *_: object) -> None: + pass + + def finish(self) -> frozenset[str]: + if self.snapshot: + return snapshot_reached + name = current[-1] + reached = set(cases[name].reached - {"icmp6"}) + if leaked is not None and leaked[0] == name: + reached.add(leaked[1]) + return frozenset(reached) + + with ( + patch.object( + microvm_tests, "_bind_ipv6_egress_ports", return_value=endpoints + ), + patch.object(microvm_tests, "_EgressProbeServer", FakeServer), + patch.object( + microvm_tests, "run_guest_script", side_effect=guest + ) as run_guest_script, + patch.object(microvm_tests, "OpenvmmProcess") as openvmm_process, + ): + wait = openvmm_process.return_value.__enter__.return_value.wait + wait.side_effect = ( + MagicMock(returncode=0, output=b"NVX-IPV6-EGRESS-BEFORE\r\n"), + MagicMock(returncode=1, output=changed_restore), + MagicMock(returncode=0, output=b"NVX-IPV6-EGRESS-AFTER\r\n"), + MagicMock( + returncode=2, + output=b"IPv6 prefix /129 is outside the supported range", + ), + ) + try: + microvm_tests.run_ipv6_egress_policy( + Path("openvmm"), + Path("vmlinux"), + Path("initramfs"), + "whp", + memory_mib=128, + timeout=1, + output_dir=output_dir, + ) + finally: + for endpoint in endpoints.values(): + endpoint.close.assert_called_once_with() + return run_guest_script, openvmm_process + + def test_ipv6_egress_policy_runs_every_case_through_public_nvx_run(self): + with tempfile.TemporaryDirectory() as temporary: + output_dir = Path(temporary) + run_guest_script, openvmm_process = self._run_ipv6_egress(output_dir) + + cases = microvm_tests._ipv6_egress_cases( + microvm_tests.DIRECTIONAL_NETWORK_GATEWAY_IPV4, + microvm_tests.DIRECTIONAL_NETWORK_GATEWAY_IPV6, + 20000, + 20001, + ) + self.assertEqual(run_guest_script.call_count, len(cases)) + nvx_path = str(Path(microvm_tests.__file__).parents[1] / "nvx.py") + for call, case in zip(run_guest_script.call_args_list, cases, strict=True): + policy_path = output_dir / f"ipv6-egress-{case.name}-policy.json" + self.assertEqual( + call.args[0], + [ + sys.executable, + nvx_path, + "run", + "--guest", + "alpine", + "--hypervisor", + "whp", + "--memory-mib", + "128", + "--net", + microvm_tests.DIRECTIONAL_NETWORK_CIDR, + "--network-profile", + "portable", + "--network-egress", + case.default, + "--network-ingress", + "deny", + "--network-egress-policy-file", + str(policy_path), + "--cmdline", + "quiet loglevel=0", + ], + ) + self.assertEqual( + json.loads(policy_path.read_text(encoding="utf-8")), + {"allow": list(case.allow), "deny": list(case.deny)}, + ) + script = call.args[1] + self.assertNotIn("@", script) + self.assertIn("'http://[fd00::c000:201]:20000/tcp6-named' &\n", script) + self.assertIn("'http://[fd00::c000:201]:20001/tcp6-other' &\n", script) + self.assertIn("'http://192.0.2.1:20000/tcp4-named' &\n", script) + self.assertEqual( + call.args[2], microvm_tests.IPV6_EGRESS_COMPLETION_MARKER + ) + self.assertIs(call.kwargs["contain_process_tree"], True) + + launched = [call.args[0] for call in openvmm_process.call_args_list] + self.assertEqual(len(launched), 4) + capture, changed, restore, invalid = launched + ipv6_rule = "fd00::c000:201/128:tcp:20000" + self.assertEqual( + capture[capture.index("--snapshot-destination") + 2 :], + ["--network-egress", "deny", "--network-egress-allow", ipv6_rule], + ) + self.assertEqual( + capture[capture.index("--net") + 1], + microvm_tests.DIRECTIONAL_NETWORK_CIDR, + ) + for restored, rule in ( + (changed, "192.0.2.1/32:tcp:20000"), + (restore, ipv6_rule), + ): + self.assertIn("--restore-snapshot", restored) + self.assertNotIn("--net", restored) + self.assertEqual( + restored[restored.index("--network-profile") :], + [ + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + rule, + ], + ) + self.assertEqual( + invalid[-4:], + [ + "--network-egress", + "deny", + "--network-egress-allow", + "fd00::c000:201/129", + ], + ) + process = openvmm_process.return_value.__enter__.return_value + process.wait_for.assert_any_call(microvm_tests.BOOT_MARKER, 1) + process.wait_for.assert_any_call(microvm_tests.IPV6_EGRESS_AFTER_MARKER, 1) + staged = process.send_bytes.call_args.args[0].decode() + self.assertIn("http://[fd00::c000:201]:20000/tcp6-named", staged) + self.assertIn("nvx-snapshot\n", staged) + + results = json.loads( + (output_dir / "ipv6-egress-policy-results.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(results["guest_ipv6"], "fd00::c000:202/120") + self.assertEqual(results["gateway_ipv6"], "fd00::c000:201") + self.assertEqual(results["ports"], {"named": 20000, "other": 20001}) + by_case = {case["case"]: case for case in results["cases"]} + self.assertEqual(list(by_case), [case.name for case in cases]) + self.assertEqual( + by_case["deny-ipv6-tcp-port"]["host_reached"], + ["tcp6-other", "udp6-named"], + ) + self.assertEqual(by_case["deny-ipv6-tcp-port"]["guest"]["icmp6"], "reached") + self.assertEqual(by_case["allow-ipv4-only"]["host_reached"], ["tcp4-named"]) + self.assertEqual(by_case["deny-all"]["host_reached"], []) + tcp_range = by_case["allow-ipv6-tcp-range"] + self.assertEqual(tcp_range["host_reached"], ["tcp6-named", "tcp6-other"]) + self.assertEqual( + tcp_range["native_rules"], + {"allow": ["fd00::c000:201/128:tcp:20000-20001"], "deny": []}, + ) + # A rule without destinations reaches both families. + without_to = by_case["allow-tcp-port-without-to"] + self.assertEqual(without_to["host_reached"], ["tcp4-named", "tcp6-named"]) + self.assertEqual( + without_to["native_rules"], + {"allow": ["0.0.0.0/0:tcp:20000", "::/0:tcp:20000"], "deny": []}, + ) + self.assertEqual(by_case["deny-ipv6-only"]["default"], "allow") + self.assertEqual( + {name: case["dns"] for name, case in by_case.items()}, + { + "allow-all": "192.0.2.1", + "deny-all": "none", + "allow-ipv6-tcp-port": "none", + "allow-ipv6-tcp-range": "none", + "allow-ipv6-udp-icmp": "fd00::c000:201", + "deny-ipv6-tcp-port": "fd00::c000:201", + "allow-tcp-port-without-to": "none", + "allow-ipv4-only": "192.0.2.1", + "deny-ipv6-only": "192.0.2.1", + }, + ) + + def test_ipv6_egress_policy_requires_the_expected_dns_server(self): + for name, reported, expected in ( + ("allow-ipv6-udp-icmp", "none", "fd00::c000:201"), + ("allow-ipv6-udp-icmp", "192.0.2.1", "fd00::c000:201"), + ("allow-ipv6-tcp-port", "fd00::c000:201", "none"), + ("allow-all", "fd00::c000:201", "192.0.2.1"), + ): + with ( + self.subTest(name=name, reported=reported), + tempfile.TemporaryDirectory() as temporary, + ): + with self.assertRaisesRegex( + RuntimeError, + re.escape( + f"{name}: the guest named {reported!r} as its DNS server " + f"instead of {expected!r}" + ), + ): + self._run_ipv6_egress(Path(temporary), dns_report=(name, reported)) + + def test_ipv6_egress_policy_rejects_leaks_and_missed_traffic(self): + failures: tuple[ + tuple[tuple[str, str] | None, tuple[str, str, str] | None, str], ... + ] = ( + ( + ("allow-ipv6-tcp-port", "tcp6-other"), + None, + "L3/L4 IPv6 egress case allow-ipv6-tcp-port: tcp6-other", + ), + (("deny-all", "tcp6-named"), None, "deny-all: tcp6-named"), + ( + ("allow-ipv6-tcp-range", "udp6-named"), + None, + "allow-ipv6-tcp-range: udp6-named", + ), + ( + None, + ("allow-ipv6-tcp-range", "tcp6-other", "blocked"), + "allow-ipv6-tcp-range: tcp6-other should be reached", + ), + (("allow-ipv4-only", "udp6-named"), None, "allow-ipv4-only: udp6-named"), + (("deny-ipv6-only", "tcp6-named"), None, "deny-ipv6-only: tcp6-named"), + ( + None, + ("allow-all", "icmp6", "blocked"), + "allow-all: icmp6 should be reached", + ), + ( + None, + ("allow-ipv4-only", "tcp6-named", "reached"), + "allow-ipv4-only: tcp6-named should be blocked", + ), + ( + None, + ("allow-tcp-port-without-to", "tcp4-named", "blocked"), + "allow-tcp-port-without-to: tcp4-named should be reached", + ), + ( + None, + ("deny-ipv6-tcp-port", "tcp4-named", "unexpected"), + "deny-ipv6-tcp-port: tcp4-named", + ), + ) + for leaked, guest_report, message in failures: + with ( + self.subTest(message=message), + tempfile.TemporaryDirectory() as temporary, + ): + output_dir = Path(temporary) + with self.assertRaisesRegex(RuntimeError, re.escape(message)): + self._run_ipv6_egress( + output_dir, leaked=leaked, guest_report=guest_report + ) + self.assertFalse( + (output_dir / "ipv6-egress-policy-results.json").exists() + ) + + def test_ipv6_egress_policy_requires_the_restored_policy_and_flow(self): + with ( + tempfile.TemporaryDirectory() as temporary, + self.assertRaisesRegex( + RuntimeError, "restore with a changed rule was not rejected" + ), + ): + self._run_ipv6_egress(Path(temporary), changed_restore=b"restored") + with ( + tempfile.TemporaryDirectory() as temporary, + self.assertRaisesRegex( + RuntimeError, "snapshot probes did not reach the host" + ), + ): + self._run_ipv6_egress(Path(temporary), snapshot_reached=frozenset()) + + def _run_ipv6_guest_script( + self, + shell: str, + root: Path, + name: str, + stubs: str, + **values: str, + ) -> subprocess.CompletedProcess[str]: + script = ( + microvm_tests._render_script(name, **values) + .replace("nvx-exit", "record_exit") + .replace("nvx-snapshot", "record_snapshot") + .replace("/proc/", f"{root.as_posix()}/proc/") + .replace("/etc/resolv.conf", f"{root.as_posix()}/etc/resolv.conf") + ) + return subprocess.run( + [shell], + input="record_exit() { printf 'NVX-EXIT %s\\n' \"$1\"; }\n" + + stubs + + script, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + + def test_ipv6_egress_script_checks_the_identity_and_reports_each_probe(self): + shell = _posix_shell() + if shell is None: + self.skipTest("POSIX shell is unavailable") + values = { + **microvm_tests._ipv6_identity_values(), + "GATEWAY_IPV4": "192.0.2.1", + "NAMED_PORT": "20000", + "OTHER_PORT": "20001", + } + any_ipv6 = "0" * 32 + route = ( + f"{any_ipv6} 00 {any_ipv6} 00 fd0000000000000000000000c0000201 " + "00000400 00000001 00000000 00000003 eth0\n" + ) + address = "fd0000000000000000000000c0000202 02 78 00 80 eth0\n" + stubs = ( + "wget() {\n" + " for url; do :; done\n" + ' case "$url" in\n' + " *]:20000/tcp6-named) printf NVX-EGRESS-PROBE-tcp6-named ;;\n" + " */tcp6-other) return 1 ;;\n" + " */tcp4-named) printf WRONG ;;\n" + " esac\n" + "}\n" + "nc() {\n" + " cat >/dev/null\n" + ' [ "$4" = fd00::c000:201 ] && [ "$5" = 20000 ] || return 2\n' + "}\n" + 'ping() { [ "$5" = fd00::c000:201 ]; }\n' + ) + ipv6_dns = " virtnet_dns=fd00::c000:201" + resolver = "nameserver fd00::c000:201\n" + # Each case: kernel tables, DNS token and resolver, and the exit status + # with the reported DNS server. + cases: tuple[tuple[str, str, str, str, str, int, str], ...] = ( + ("complete", address, route, ipv6_dns, resolver, 0, "fd00::c000:201"), + ("no dns", address, route, "", "", 0, "none"), + ( + "stale resolver", + address, + route, + ipv6_dns, + "nameserver 192.0.2.1\n", + 64, + "", + ), + ("no address", "", route, "", "", 62, ""), + ("other prefix", address.replace(" 78 ", " 40 "), route, "", "", 62, ""), + ("no route", address, "", "", "", 63, ""), + ) + for label, if_inet6, ipv6_route, dns, resolv, status, reported in cases: + with self.subTest(label), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "proc" / "net").mkdir(parents=True) + (root / "etc").mkdir() + (root / "proc" / "cmdline").write_text( + "quiet virtnet_ip=192.0.2.2 virtnet_mask=255.255.255.0 " + f"virtnet_gw=192.0.2.1{dns} virtnet_ip6=fd00::c000:202/120 " + "virtnet_gw6=fd00::c000:201\n", + encoding="utf-8", + ) + (root / "proc" / "net" / "if_inet6").write_text( + if_inet6, encoding="utf-8" + ) + (root / "proc" / "net" / "ipv6_route").write_text( + ipv6_route, encoding="utf-8" + ) + (root / "etc" / "resolv.conf").write_text(resolv, encoding="utf-8") + result = self._run_ipv6_guest_script( + shell, root, "ipv6-egress-policy.sh.in", stubs, **values + ) + self.assertEqual(result.returncode, status, result.stderr) + if status: + self.assertEqual( + result.stdout.splitlines(), + [f"NVX-IPV6-EGRESS-FAIL code={status}", f"NVX-EXIT {status}"], + ) + continue + self.assertEqual( + result.stdout.splitlines(), + [ + f"NVX-IPV6-EGRESS-DNS {reported}", + "NVX-L3-L4-PROBE tcp6-named=reached", + "NVX-L3-L4-PROBE tcp6-other=blocked", + "NVX-L3-L4-PROBE udp6-named=sent", + "NVX-L3-L4-PROBE icmp6=reached", + "NVX-L3-L4-PROBE tcp4-named=unexpected", + "NVX-IPV6-EGRESS-OK", + "NVX-EXIT 0", + ], + ) + + def test_ipv6_egress_snapshot_script_probes_across_the_snapshot(self): + shell = _posix_shell() + if shell is None: + self.skipTest("POSIX shell is unavailable") + for label, wget, expected in ( + ( + "reached", + "printf NVX-EGRESS-PROBE-tcp6-named", + [ + "NVX-IPV6-EGRESS-BEFORE", + "SNAPSHOT", + "NVX-IPV6-EGRESS-AFTER", + "NVX-EXIT 0", + ], + ), + ( + "blocked after restore", + '[ ! -e "$restored" ] && printf NVX-EGRESS-PROBE-tcp6-named', + [ + "NVX-IPV6-EGRESS-BEFORE", + "SNAPSHOT", + "NVX-IPV6-EGRESS-FAIL code=81", + "NVX-EXIT 81", + ], + ), + ): + with self.subTest(label), tempfile.TemporaryDirectory() as temporary: + restored = (Path(temporary) / "restored").as_posix() + stubs = ( + f"restored={restored}\n" + "wget() {\n" + ' [ "$4" = "http://[fd00::c000:201]:20000/tcp6-named" ] ||' + " return 9\n" + f" {wget}\n" + "}\n" + 'record_snapshot() { echo SNAPSHOT; : >"$restored"; }\n' + ) + result = self._run_ipv6_guest_script( + shell, + Path(temporary), + "ipv6-egress-snapshot.sh.in", + stubs, + GATEWAY_IPV6="fd00::c000:201", + NAMED_PORT="20000", + ) + self.assertEqual(result.stdout.splitlines(), expected, result.stderr) + + def test_ipv6_egress_ports_reserve_both_loopback_families(self): + try: + endpoints = microvm_tests._bind_ipv6_egress_ports() + except RuntimeError as error: + self.skipTest(str(error)) + try: + ports = { + name: endpoint.getsockname()[:2] for name, endpoint in endpoints.items() + } + named = ports["tcp6-named"][1] + self.assertEqual( + ports, + { + "tcp6-named": ("::1", named), + "tcp6-other": ("::1", named + 1), + "udp6-named": ("::1", named), + "tcp4-named": ("127.0.0.1", named), + }, + ) + self.assertEqual(endpoints["udp6-named"].type, socket.SOCK_DGRAM) + finally: + for endpoint in endpoints.values(): + endpoint.close() + + def test_ipv6_egress_ports_require_host_ipv6_loopback(self): + unavailable = MagicMock(spec=socket.socket) + unavailable.__enter__.return_value = unavailable + unavailable.bind.side_effect = OSError("address family not supported") + with ( + patch.object(microvm_tests.socket, "socket", return_value=unavailable), + self.assertRaisesRegex(RuntimeError, "no IPv6 loopback address"), + ): + microvm_tests._bind_ipv6_egress_ports() + + def test_runner_dispatches_ipv6_egress_policy(self): + def require(path: Path, _description: str) -> Path: + return path + + with ( + tempfile.TemporaryDirectory() as temporary, + patch.object(microvm_tests, "validate_openvmm_test_backend"), + patch.object(microvm_tests, "require_file", side_effect=require), + patch.object( + microvm_tests, "run_ipv6_egress_policy" + ) as run_ipv6_egress_policy, + ): + args = nvx.parse_args( + [ + "test-microvm", + "--backend", + "kvm", + "--scenario", + "ipv6-egress-policy", + "--output-dir", + temporary, + ] + ) + self.assertEqual(microvm_tests.run(args), 0) + + run_ipv6_egress_policy.assert_called_once_with( + microvm_tests.openvmm_binary_path(), + microvm_tests.artifact_path( + microvm_tests.KernelBuildConstants.BINARY_NAME + ), + microvm_tests.artifact_path( + microvm_tests.guest_descriptor("alpine").initramfs_name + ), + "kvm", + memory_mib=128, + timeout=60.0, + output_dir=Path(temporary), + guest="alpine", + ) + self.assertIn("ipv6-egress-policy", microvm_tests.MICROVM_TEST_SCENARIOS) + def test_sandbox_blocks_use_fixed_roles_and_access(self): with ( patch.object( diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 190a23c52..8e1c806c9 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -298,6 +298,7 @@ def _write_release_fixture( *KernelBuildConstants.REQUIRED_VIRTIO_CONSOLE_CONFIG, *KernelBuildConstants.REQUIRED_SHARED_STATUS_CONFIG, *KernelBuildConstants.REQUIRED_SANDBOX_CONFIG, + *KernelBuildConstants.REQUIRED_NETWORK_CONFIG, *KernelBuildConstants.REQUIRED_TIME_ABI_CONFIG, *KernelBuildConstants.REQUIRED_HARDENING_CONFIG, ) @@ -6891,6 +6892,7 @@ def test_kernel_build_rejects_input_config_mutation_during_build(self): *KernelBuildConstants.REQUIRED_VIRTIO_CONSOLE_CONFIG, *KernelBuildConstants.REQUIRED_SHARED_STATUS_CONFIG, *KernelBuildConstants.REQUIRED_SANDBOX_CONFIG, + *KernelBuildConstants.REQUIRED_NETWORK_CONFIG, *KernelBuildConstants.REQUIRED_TIME_ABI_CONFIG, *KernelBuildConstants.REQUIRED_HARDENING_CONFIG, ) @@ -8306,6 +8308,50 @@ def test_sandbox_kernel_config_requires_every_feature(self): with self.assertRaisesRegex(common.ScriptError, missing): build._assert_sandbox_kernel_config(config) + def test_network_kernel_config_requires_every_feature(self): + with tempfile.TemporaryDirectory() as temporary: + config = Path(temporary) / ".config" + config.write_text( + "\n".join(KernelBuildConstants.REQUIRED_NETWORK_CONFIG) + "\n", + encoding="utf-8", + ) + build._assert_network_kernel_config(config) + + for missing in KernelBuildConstants.REQUIRED_NETWORK_CONFIG: + with self.subTest(missing=missing): + config.write_text( + "\n".join( + setting + for setting in KernelBuildConstants.REQUIRED_NETWORK_CONFIG + if setting != missing + ) + + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + common.ScriptError, "portable network profile" + ) as raised: + build._assert_network_kernel_config(config) + self.assertIn(missing, str(raised.exception)) + + # An enabled SIT device leaves the "is not set" line out. + config.write_text( + "\n".join( + "CONFIG_IPV6_SIT=y" + if setting == "# CONFIG_IPV6_SIT is not set" + else setting + for setting in KernelBuildConstants.REQUIRED_NETWORK_CONFIG + ) + + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(common.ScriptError, "CONFIG_IPV6_SIT"): + build._assert_network_kernel_config(config) + + def test_checked_in_config_provides_the_portable_network_profile(self): + config = BuildConstants.REPO_ROOT / KernelBuildConstants.INPUT_CONFIG + build._assert_network_kernel_config(config) + def test_checked_in_config_preserves_generic_sandbox_capabilities(self): config = BuildConstants.REPO_ROOT / "kernel" / "config-microvm" build._assert_sandbox_kernel_config(config) @@ -8500,6 +8546,7 @@ def test_debug_kernel_build_applies_fragment_and_records_provenance(self): *KernelBuildConstants.REQUIRED_VIRTIO_CONSOLE_CONFIG, *KernelBuildConstants.REQUIRED_SHARED_STATUS_CONFIG, *KernelBuildConstants.REQUIRED_SANDBOX_CONFIG, + *KernelBuildConstants.REQUIRED_NETWORK_CONFIG, *KernelBuildConstants.REQUIRED_TIME_ABI_CONFIG, *KernelBuildConstants.REQUIRED_HARDENING_CONFIG, "# CONFIG_DEBUG_KERNEL is not set",