diff --git a/Cargo.lock b/Cargo.lock index e082d89..03184ee 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" @@ -2568,12 +2615,14 @@ dependencies = [ "dhcproto", "ip_network", "ipnet", + "ipnetwork", "libc", "log", "mac_address", "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..62234b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ oslog = "0.2.0" log = "0.4.29" serial_test = "3" coarsetime = "0.1.37" +pnet_datalink = "0.35.0" +ipnetwork = "0.20" [profile.release] debug = true diff --git a/lib/host.rs b/lib/host.rs index 7b2ee66..d5c87f2 100644 --- a/lib/host.rs +++ b/lib/host.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result, anyhow}; use clap::ValueEnum; use log::info; +use smoltcp::wire::EthernetAddress; use std::net::Ipv4Addr; use std::os::unix::io::{AsRawFd, RawFd}; use std::os::unix::net::UnixDatagram; @@ -28,14 +29,17 @@ pub struct Host { new_packets_rx: UnixDatagram, callback_can_continue_tx: SyncSender<()>, pub gateway_ip: smoltcp::wire::Ipv4Address, + pub gateway_mac: Option, pub max_packet_size: u64, pub read_max_packets: u64, finalized: bool, } impl Host { - pub fn new(vm_net_type: NetType, enable_isolation: bool) -> Result { - // Initialize a vmnet.framework NAT or Host interface with isolation enabled + pub fn new(vm_net_type: NetType, zero_cidr_allowed: bool, has_peers: bool) -> Result { + // Initialize vmnet.framework's NAT or Host interface + let enable_isolation = !zero_cidr_allowed && !has_peers; + let mut interface = vmnet::Interface::new( match vm_net_type { NetType::Nat => Mode::Shared(Default::default()), @@ -59,6 +63,35 @@ impl Host { let gateway_ip = Ipv4Addr::from_str(&gateway_ip) .context("failed to parse vmnet's interface start address")?; + // Determine gateway's MAC address in case we have any peers + let mut gateway_mac: Option = None; + + if has_peers { + for iface in pnet_datalink::interfaces() { + if !iface.ips.iter().any(|ip| ip.ip() == gateway_ip) { + continue; + } + + if gateway_mac.is_some() { + return Err(anyhow!( + "cannot enforce peers: multiple host interfaces have vmnet gateway IP {}", + gateway_ip + )); + } + + if let Some(iface_mac) = iface.mac { + gateway_mac = Some(EthernetAddress(iface_mac.octets())); + } + } + + if gateway_mac.is_none() { + return Err(anyhow!( + "cannot enforce peers: no host interface has vmnet gateway IP {}", + gateway_ip + )); + } + } + // Retrieve max packet size for this interface let Some(Parameter::MaxPacketSize(max_packet_size)) = interface.parameters().get(ParameterKind::MaxPacketSize) @@ -105,6 +138,7 @@ impl Host { new_packets_rx, callback_can_continue_tx, gateway_ip, + gateway_mac, max_packet_size, read_max_packets, finalized: false, diff --git a/lib/proxy/host.rs b/lib/proxy/host.rs index efcc2bd..53c4744 100644 --- a/lib/proxy/host.rs +++ b/lib/proxy/host.rs @@ -10,12 +10,6 @@ impl Proxy<'_> { return Ok(()); } - // Snoop bootpd(8) replies from the host to - // figure out the IP assigned to the VM - if frame.dst_addr() == self.vm_mac_address { - self.snoop(frame); - } - match self.vm.write(frame.as_ref()) { Ok(_) => Ok(()), Err(err) => { @@ -37,6 +31,51 @@ impl Proxy<'_> { } fn allowed_from_host(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { + if self.peer_mac_addresses.is_empty() { + // Peers unset → isolation between VMs is enabled → all frames are from gateway + return self.allowed_from_gateway(frame); + } + + // Peers set → isolation between VMs is disabled → can receive a frame from any VM + let from_gateway = Some(frame.src_addr()) == self.host.gateway_mac; + if from_gateway { + return self.allowed_from_gateway(frame); + } + + let from_peer = self.peer_mac_addresses.contains(&frame.src_addr()); + if from_peer { + return self.allowed_from_peer(frame); + } + + None + } + + fn allowed_from_gateway(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { + let decision = match frame.ethertype() { + EthernetProtocol::Arp => Some(()), + EthernetProtocol::Ipv4 => Some(()), + _ => None, + }; + + if decision.is_some() { + // Snoop bootpd(8) replies from the gateway to + // figure out the IP assigned to the VM + if frame.dst_addr() == self.vm_mac_address { + self.snoop(frame); + } + } + + decision + } + + fn allowed_from_peer(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { + if frame.dst_addr() != self.vm_mac_address + && !frame.dst_addr().is_broadcast() + && !frame.dst_addr().is_multicast() + { + 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..86b0a62 100644 --- a/lib/proxy/mod.rs +++ b/lib/proxy/mod.rs @@ -15,7 +15,9 @@ use ipnet::Ipv4Net; use mac_address::MacAddress; use port_forwarder::PortForwarder; use prefix_trie::{Prefix, PrefixMap}; +use smoltcp::wire::EthernetAddress; use smoltcp::wire::EthernetFrame; +use std::collections::HashSet; use std::io::ErrorKind; use std::os::unix::io::{AsRawFd, RawFd}; use std::str::FromStr; @@ -27,6 +29,7 @@ pub struct Proxy<'proxy> { host: Host, poller: Poller<'proxy>, vm_mac_address: smoltcp::wire::EthernetAddress, + peer_mac_addresses: HashSet, dhcp_snooper: DhcpSnooper, rules: PrefixMap, enobufs_encountered: bool, @@ -51,6 +54,23 @@ impl FromStr for Target { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Peer { + mac_address: EthernetAddress, +} + +impl FromStr for Peer { + type Err = mac_address::MacParseError; + + fn from_str(s: &str) -> std::result::Result { + let mac_address = MacAddress::from_str(s)?; + + Ok(Peer { + mac_address: EthernetAddress(mac_address.bytes()), + }) + } +} + #[derive(Debug, Clone, PartialEq)] pub(crate) enum Action { Block, @@ -64,12 +84,14 @@ impl Proxy<'_> { vm_net_type: NetType, allow: Vec, block: Vec, + peers: Vec, exposed_ports: Vec, ) -> Result> { let vm = VM::new(vm_fd)?; let host = Host::new( vm_net_type, - !allow.contains(&Target::Prefix(Ipv4Net::zero())), + allow.contains(&Target::Prefix(Ipv4Net::zero())), + !peers.is_empty(), )?; let poller_timeout = Duration::from_millis(100); let poller = Poller::new(vm.as_raw_fd(), host.as_raw_fd(), poller_timeout)?; @@ -103,6 +125,7 @@ impl Proxy<'_> { host, poller, vm_mac_address: smoltcp::wire::EthernetAddress(vm_mac_address.bytes()), + peer_mac_addresses: peers.into_iter().map(|peer| peer.mac_address).collect(), dhcp_snooper: DhcpSnooper::new(poller_timeout), rules, enobufs_encountered: false, @@ -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; @@ -295,6 +318,7 @@ mod tests { .map(|cidr| cidr.parse().unwrap()) .collect(), Vec::default(), + Vec::default(), ) .unwrap(); @@ -316,6 +340,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, EthernetAddress([0; 6])) } } diff --git a/lib/proxy/vm.rs b/lib/proxy/vm.rs index d6d0e86..fd4dd0f 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; @@ -26,6 +26,22 @@ impl Proxy<'_> { return None; } + // When peers are set the isolation between VMs is disabled, + // so we need to be stricter about what we'll emit + if !self.peer_mac_addresses.is_empty() { + // Destination check + let to_gateway = Some(frame.dst_addr()) == self.host.gateway_mac; + let to_peer = self.peer_mac_addresses.contains(&frame.dst_addr()); + + if !to_gateway + && !to_peer + && !frame.dst_addr().is_broadcast() + && !frame.dst_addr().is_multicast() + { + return None; + } + } + match frame.ethertype() { EthernetProtocol::Arp => { let arp_pkt = ArpPacket::new_checked(frame.payload()).ok()?; @@ -33,7 +49,7 @@ impl Proxy<'_> { } 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, } @@ -58,13 +74,22 @@ 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(); + // Communication with peers bypasses IP rules + if self.peer_mac_addresses.contains(&dst_mac) { + return Some(()); + } + // 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..340f504 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use oslog::OsLogger; use privdrop::PrivDrop; use softnet::NetType; use softnet::proxy::ExposedPort; +use softnet::proxy::Peer; use softnet::proxy::Proxy; use softnet::proxy::Target; use std::borrow::Cow; @@ -80,6 +81,16 @@ struct Args { )] block: Vec, + #[clap( + long = "peer", + help = "Comma-separated list of MAC addresses of the peer VMs to allow the traffic to \ + (e.g. --peer=AA:BB:CC:DD:EE:FF)", + value_name = "comma-separated MAC addresses", + use_value_delimiter = true, + action = clap::ArgAction::Set + )] + peers: Vec, + #[clap( long, help = "comma-separated list of TCP ports to expose (e.g. --expose 2222:22,8080:80)", @@ -201,6 +212,7 @@ fn try_main() -> anyhow::Result<()> { args.vm_net_type, args.allow, args.block, + args.peers, args.expose, ) .context("failed to initialize proxy")?;