diff --git a/Cargo.lock b/Cargo.lock index e082d89..0c0a505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1333,6 +1333,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "iri-string" version = "0.7.10" @@ -1603,6 +1612,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no-std-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65" + [[package]] name = "num-conv" version = "0.2.0" @@ -1807,6 +1822,38 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "pnet_base" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc190d4067df16af3aba49b3b74c469e611cad6314676eaf1157f31aa0fb2f7" +dependencies = [ + "no-std-net", +] + +[[package]] +name = "pnet_datalink" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79e70ec0be163102a332e1d2d5586d362ad76b01cec86f830241f2b6452a7b7" +dependencies = [ + "ipnetwork", + "libc", + "pnet_base", + "pnet_sys", + "winapi", +] + +[[package]] +name = "pnet_sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4643d3d4db6b08741050c2f3afa9a892c4244c085a72fcda93c9c2c9a00f4b" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "polling" version = "3.11.0" @@ -2574,6 +2621,7 @@ dependencies = [ "nix 0.31.2", "num_enum 0.7.6", "oslog", + "pnet_datalink", "polling", "prefix-trie", "privdrop", diff --git a/Cargo.toml b/Cargo.toml index 00d701b..f7228ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ oslog = "0.2.0" log = "0.4.29" serial_test = "3" coarsetime = "0.1.37" +pnet_datalink = "0.35.0" [profile.release] debug = true diff --git a/lib/host.rs b/lib/host.rs index 7b2ee66..3636734 100644 --- a/lib/host.rs +++ b/lib/host.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result, anyhow}; use clap::ValueEnum; use log::info; +use smoltcp::wire::EthernetAddress; +use std::net::IpAddr; use std::net::Ipv4Addr; use std::os::unix::io::{AsRawFd, RawFd}; use std::os::unix::net::UnixDatagram; @@ -28,6 +30,7 @@ pub struct Host { new_packets_rx: UnixDatagram, callback_can_continue_tx: SyncSender<()>, pub gateway_ip: smoltcp::wire::Ipv4Address, + pub gateway_mac: EthernetAddress, pub max_packet_size: u64, pub read_max_packets: u64, finalized: bool, @@ -49,16 +52,43 @@ impl Host { .context("failed to initialize vmnet interface")?; // Retrieve first IP (gateway) used for this interface - let Some(Parameter::StartAddress(gateway_ip)) = + let Some(Parameter::StartAddress(start_address)) = interface.parameters().get(ParameterKind::StartAddress) else { return Err(anyhow!( "failed to retrieve vmnet's interface start address" )); }; - let gateway_ip = Ipv4Addr::from_str(&gateway_ip) + let start_address = Ipv4Addr::from_str(&start_address) .context("failed to parse vmnet's interface start address")?; + // Retrieve last IP used for this interface and calculate the prefix + let Some(Parameter::EndAddress(end_address)) = + interface.parameters().get(ParameterKind::EndAddress) + else { + return Err(anyhow!("failed to retrieve vmnet's interface end address")); + }; + let end_address = Ipv4Addr::from_str(&end_address) + .context("failed to parse vmnet's interface end address")?; + + let Some(prefix) = Self::ipv4_range_prefix(start_address, end_address) else { + return Err(anyhow!( + "failed to resolve vmnet's interface: prefix ambiguity for {}–{}", + start_address, + end_address + )); + }; + + // Figure out the gateway's interface MAC address + let Some(gateway_mac) = Self::interface_mac_for_ip(start_address, prefix) else { + return Err(anyhow!( + "failed to resolve vmnet's interface: no interface found with {}/{} CIDR", + start_address, + prefix + )); + }; + let gateway_mac = EthernetAddress(gateway_mac.octets()); + // Retrieve max packet size for this interface let Some(Parameter::MaxPacketSize(max_packet_size)) = interface.parameters().get(ParameterKind::MaxPacketSize) @@ -104,12 +134,39 @@ impl Host { interface, new_packets_rx, callback_can_continue_tx, - gateway_ip, + gateway_ip: start_address, + gateway_mac, max_packet_size, read_max_packets, finalized: false, }) } + + fn interface_mac_for_ip(ip: Ipv4Addr, prefix: u8) -> Option { + for iface in pnet_datalink::interfaces() { + if iface + .ips + .iter() + .any(|network| network.ip() == IpAddr::V4(ip) && network.prefix() == prefix) + && let Some(mac) = iface.mac + { + return Some(mac); + } + } + + None + } + + fn ipv4_range_prefix(start_address: Ipv4Addr, end_address: Ipv4Addr) -> Option { + let start_address = start_address.to_bits(); + let end_address = end_address.to_bits(); + + if start_address > end_address { + return None; + } + + Some((start_address ^ end_address).leading_zeros() as u8) + } } impl Host { @@ -198,3 +255,58 @@ impl AsRawFd for Host { self.new_packets_rx.as_raw_fd() } } + +#[cfg(test)] +mod tests { + use super::Host; + use std::net::Ipv4Addr; + + #[test] + fn ipv4_range_prefix_for_class_c_subnet() { + assert_eq!( + Host::ipv4_range_prefix( + Ipv4Addr::new(192, 168, 64, 0), + Ipv4Addr::new(192, 168, 64, 255), + ), + Some(24) + ); + } + + #[test] + fn ipv4_range_prefix_for_single_address() { + assert_eq!( + Host::ipv4_range_prefix(Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 1)), + Some(32) + ); + } + + #[test] + fn ipv4_range_prefix_for_class_c_usable_range() { + assert_eq!( + Host::ipv4_range_prefix( + Ipv4Addr::new(192, 168, 64, 1), + Ipv4Addr::new(192, 168, 64, 254), + ), + Some(24) + ); + } + + #[test] + fn ipv4_range_prefix_for_containing_class_c_subnet() { + assert_eq!( + Host::ipv4_range_prefix( + Ipv4Addr::new(192, 168, 64, 2), + Ipv4Addr::new(192, 168, 64, 254), + ), + Some(24) + ); + } + + #[test] + fn ipv4_range_prefix_rejects_inverted_range() { + assert_eq!( + Host::ipv4_range_prefix(Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(10, 0, 0, 1)), + None + ); + } +} diff --git a/lib/proxy/host.rs b/lib/proxy/host.rs index efcc2bd..dd2e893 100644 --- a/lib/proxy/host.rs +++ b/lib/proxy/host.rs @@ -37,6 +37,14 @@ impl Proxy<'_> { } fn allowed_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { + let from_gateway = frame.src_addr() == self.host.gateway_mac; + let peer_action = self.rules_mac.get(frame.src_addr().as_bytes()); + let from_allowed_peer = peer_action == Some(&crate::proxy::Action::Allow); + + if !from_gateway && !from_allowed_peer { + return None; + } + match frame.ethertype() { EthernetProtocol::Arp => Some(()), EthernetProtocol::Ipv4 => Some(()), diff --git a/lib/proxy/mod.rs b/lib/proxy/mod.rs index e32b338..8efcc46 100644 --- a/lib/proxy/mod.rs +++ b/lib/proxy/mod.rs @@ -16,6 +16,7 @@ use mac_address::MacAddress; use port_forwarder::PortForwarder; use prefix_trie::{Prefix, PrefixMap}; use smoltcp::wire::EthernetFrame; +use std::collections::HashMap; use std::io::ErrorKind; use std::os::unix::io::{AsRawFd, RawFd}; use std::str::FromStr; @@ -29,6 +30,7 @@ pub struct Proxy<'proxy> { vm_mac_address: smoltcp::wire::EthernetAddress, dhcp_snooper: DhcpSnooper, rules: PrefixMap, + rules_mac: HashMap<[u8; 6], Action>, enobufs_encountered: bool, port_forwarder: PortForwarder, } @@ -36,6 +38,7 @@ pub struct Proxy<'proxy> { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Target { Prefix(Ipv4Net), + MacAddress(MacAddress), Host, } @@ -47,6 +50,10 @@ impl FromStr for Target { return Ok(Target::Host); } + if let Ok(mac_address) = MacAddress::from_str(s) { + return Ok(Target::MacAddress(mac_address)); + } + Ipv4Net::from_str(s).map(Target::Prefix) } } @@ -66,11 +73,15 @@ impl Proxy<'_> { block: Vec, exposed_ports: Vec, ) -> Result> { + let allowing_all_ipv4 = allow.contains(&Target::Prefix(Ipv4Net::zero())); + let using_mac_filtering = allow + .iter() + .chain(block.iter()) + .any(|target| matches!(target, Target::MacAddress(_))); + let enable_isolation = !allowing_all_ipv4 && !using_mac_filtering; + let vm = VM::new(vm_fd)?; - let host = Host::new( - vm_net_type, - !allow.contains(&Target::Prefix(Ipv4Net::zero())), - )?; + let host = Host::new(vm_net_type, enable_isolation)?; let poller_timeout = Duration::from_millis(100); let poller = Poller::new(vm.as_raw_fd(), host.as_raw_fd(), poller_timeout)?; @@ -79,11 +90,17 @@ impl Proxy<'_> { // SECURITY: blocking rules must always take precedence // over allowing rules when prefixes are identical. let mut rules = PrefixMap::new(); + let mut rules_mac = HashMap::new(); for allow_target in allow { let allow_prefix = match allow_target { Target::Prefix(prefix) => prefix, Target::Host => host.gateway_ip.into(), + Target::MacAddress(mac_address) => { + rules_mac.insert(mac_address.bytes(), Action::Allow); + + continue; + } }; rules.insert(allow_prefix, Action::Allow); @@ -93,6 +110,11 @@ impl Proxy<'_> { let block_prefix = match block_target { Target::Prefix(prefix) => prefix, Target::Host => host.gateway_ip.into(), + Target::MacAddress(mac_address) => { + rules_mac.insert(mac_address.bytes(), Action::Block); + + continue; + } }; rules.insert(block_prefix, Action::Block); @@ -105,6 +127,7 @@ impl Proxy<'_> { vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()), dhcp_snooper: DhcpSnooper::new(poller_timeout), rules, + rules_mac, enobufs_encountered: false, port_forwarder: PortForwarder::new(exposed_ports), }) @@ -209,7 +232,7 @@ mod tests { use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair}; use prefix_trie::PrefixMap; use serial_test::serial; - use smoltcp::wire::{Ipv4Address, Ipv4Packet}; + use smoltcp::wire::{EthernetAddress, Ipv4Address, Ipv4Packet}; use std::collections::HashSet; use std::os::fd::AsRawFd; use std::str::FromStr; @@ -272,6 +295,30 @@ mod tests { assert!(allowed_from_vm_ipv4(&proxy, vm_ip, &proxy.host.gateway_ip.to_string()).is_some()); } + #[test] + #[serial] + fn test_allow_mac_takes_precedence_over_blocked_prefix() { + let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); + let peer_mac = EthernetAddress([0x02, 0, 0, 0, 0, 0x02]); + let proxy = create_proxy(vm_ip, vec!["02:00:00:00:00:02"], vec!["0.0.0.0/0"]); + + assert!(allowed_from_vm_ipv4_to_mac(&proxy, vm_ip, "192.168.0.3", peer_mac).is_some()); + } + + #[test] + #[serial] + fn test_block_mac_takes_precedence_over_allowed_prefix() { + let vm_ip = Ipv4Address::from_str("192.168.0.2").unwrap(); + let peer_mac = EthernetAddress([0x02, 0, 0, 0, 0, 0x02]); + let proxy = create_proxy( + vm_ip, + vec!["192.168.0.0/24", "02:00:00:00:00:02"], + vec!["02:00:00:00:00:02"], + ); + + assert!(allowed_from_vm_ipv4_to_mac(&proxy, vm_ip, "192.168.0.3", peer_mac).is_none()); + } + fn create_proxy<'test>(vm_ip: Ipv4Address, allow: Vec<&str>, block: Vec<&str>) -> Proxy<'test> { let (vm_fd, _) = socketpair( AddressFamily::Unix, @@ -308,6 +355,15 @@ mod tests { } fn allowed_from_vm_ipv4(proxy: &Proxy, src: Ipv4Address, dst: &str) -> Option<()> { + allowed_from_vm_ipv4_to_mac(proxy, src, dst, EthernetAddress([0x02, 0, 0, 0, 0, 0x03])) + } + + fn allowed_from_vm_ipv4_to_mac( + proxy: &Proxy, + src: Ipv4Address, + dst: &str, + dst_mac: EthernetAddress, + ) -> Option<()> { let mut buf = vec![0; 1500]; let mut ipv4_pkt_mut = Ipv4Packet::new_unchecked(&mut buf[..]); @@ -316,6 +372,6 @@ mod tests { let ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_slice()); - proxy.allowed_from_vm_ipv4(ipv4_pkt) + proxy.allowed_from_vm_ipv4(ipv4_pkt, dst_mac) } } diff --git a/lib/proxy/vm.rs b/lib/proxy/vm.rs index d6d0e86..980ad9e 100644 --- a/lib/proxy/vm.rs +++ b/lib/proxy/vm.rs @@ -4,7 +4,7 @@ use anyhow::Context; use anyhow::Result; use ipnet::Ipv4Net; use smoltcp::wire::{ - ArpPacket, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Packet, UdpPacket, + ArpPacket, EthernetAddress, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Packet, UdpPacket, }; use std::net::Ipv4Addr; @@ -29,21 +29,29 @@ impl Proxy<'_> { match frame.ethertype() { EthernetProtocol::Arp => { let arp_pkt = ArpPacket::new_checked(frame.payload()).ok()?; - self.allowed_from_vm_arp(arp_pkt) + self.allowed_from_vm_arp(arp_pkt, frame.dst_addr()) } EthernetProtocol::Ipv4 => { let ipv4_pkt = Ipv4Packet::new_checked(frame.payload()).ok()?; - self.allowed_from_vm_ipv4(ipv4_pkt) + self.allowed_from_vm_ipv4(ipv4_pkt, frame.dst_addr()) } _ => None, } } - fn allowed_from_vm_arp(&self, arp_pkt: ArpPacket<&[u8]>) -> Option<()> { + fn allowed_from_vm_arp( + &self, + arp_pkt: ArpPacket<&[u8]>, + dst_mac: EthernetAddress, + ) -> Option<()> { if arp_pkt.source_hardware_addr() != self.vm_mac_address.0 { return None; } + if self.rules_mac.get(dst_mac.as_bytes()) == Some(&Action::Block) { + return None; + } + let source_protocol_addr: [u8; 4] = arp_pkt.source_protocol_addr().try_into().unwrap(); let source_protocol_addr = Ipv4Addr::from(source_protocol_addr); @@ -58,13 +66,23 @@ impl Proxy<'_> { None } - pub(crate) fn allowed_from_vm_ipv4(&self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> { + pub(crate) fn allowed_from_vm_ipv4( + &self, + ipv4_pkt: Ipv4Packet<&[u8]>, + dst_mac: EthernetAddress, + ) -> Option<()> { // Is this packet coming from VM's IP address that we've learned from DHCP snooping? if let Some(lease) = &self.dhcp_snooper.lease() && lease.valid_ip_source(ipv4_pkt.src_addr()) { let dst_addr = ipv4_pkt.dst_addr(); + match self.rules_mac.get(dst_mac.as_bytes()) { + Some(Action::Block) => return None, + Some(Action::Allow) => return Some(()), + None => {} + } + // Filter traffic based on user-specified rules first if !self.rules.is_empty() { let dst_net = Ipv4Net::from(dst_addr); diff --git a/src/main.rs b/src/main.rs index 3dd9ba8..c4011be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,7 @@ struct Args { In case an identical prefix is both --allow'ed and --block'ed, \ blocking will take precedence. --allow=0.0.0.0/0 is a special case, \ it additionally disables bridge isolation (even when --block=0.0.0.0/0 is specified).", - value_name = "comma-separated CIDRs or @-alias", + value_name = "comma-separated CIDRs, MAC addresses or @-aliases", use_value_delimiter = true, action = clap::ArgAction::Set )] @@ -74,7 +74,7 @@ struct Args { When used with --allow, the longest prefix match always wins. \ In case an identical prefix is both --allow'ed and --block'ed, \ blocking will take precedence.", - value_name = "comma-separated CIDRs or @-alias", + value_name = "comma-separated CIDRs, MAC addresses or @-aliases", use_value_delimiter = true, action = clap::ArgAction::Set )]