Ability for the VMs to communicate in groups by providing peer MACs

This commit is contained in:
Nikolay Edigaryev 2026-05-28 09:47:53 +01:00
parent df84a30016
commit 22cdfa7aee
7 changed files with 199 additions and 14 deletions

49
Cargo.lock generated
View File

@ -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",

View File

@ -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

View File

@ -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<EthernetAddress>,
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<Host> {
// 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<Host> {
// 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<EthernetAddress> = 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,

View File

@ -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(()),

View File

@ -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<EthernetAddress>,
dhcp_snooper: DhcpSnooper,
rules: PrefixMap<Ipv4Net, Action>,
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<Self, Self::Err> {
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<Target>,
block: Vec<Target>,
peers: Vec<Peer>,
exposed_ports: Vec<ExposedPort>,
) -> Result<Proxy<'proxy>> {
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]))
}
}

View File

@ -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);

View File

@ -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<Target>,
#[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<Peer>,
#[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")?;