diff --git a/Cargo.lock b/Cargo.lock index b74464b..bcc380b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,6 +246,23 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -730,6 +747,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "etherparse" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449" +dependencies = [ + "arrayvec 0.7.8", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -958,7 +984,31 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf31ab66ed8145a1c7427bd8e9b42a6131bd74ccf444f69b9e620c2e73ded832" dependencies = [ - "arrayvec", + "arrayvec 0.5.2", +] + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", ] [[package]] @@ -973,12 +1023,37 @@ dependencies = [ "jni", "once_cell", "rand", + "ring", + "serde", "thiserror", "tinyvec", "tracing", "url", ] +[[package]] +name = "hickory-server" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "130236ba6abba90da6a7acf7a87b27d862b592c3145dc74bc47bf86d8ff198ec" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-util", + "hickory-net", + "hickory-proto", + "ipnet", + "prefix-trie 0.8.4", + "serde", + "thiserror", + "time", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hostname" version = "0.4.0" @@ -1306,6 +1381,9 @@ name = "ipnet" version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -1811,6 +1889,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "prefix-trie" version = "0.9.2" @@ -1974,6 +2063,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -2408,11 +2511,14 @@ name = "softnet" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "bytes", "clap", "coarsetime", "dhcproto", + "etherparse", "futures-util", + "hickory-server", "ip_network", "ipnet", "jsonrpsee-types", @@ -2422,7 +2528,7 @@ dependencies = [ "network-interface", "nix 0.31.3", "oslog", - "prefix-trie", + "prefix-trie 0.9.2", "privdrop", "rand", "sentry", @@ -2809,6 +2915,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "ureq" version = "3.0.12" diff --git a/Cargo.toml b/Cargo.toml index 8b02c22..7acb354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,17 +13,20 @@ debug = true [dependencies] smoltcp = "0" +etherparse = "0.21" libc = "0" tokio = { version = "1", features = ["rt-multi-thread", "sync", "net", "signal", "time", "macros", "io-util"] } tokio-util = { version = "0.7.19", features = ["join-map", "codec"] } bytes = "1" futures-util = { version = "0.3", default-features = false, features = ["std"] } dhcproto = { git = "https://github.com/bluecatengineering/dhcproto.git", branch = "master" } +hickory-server = { version = "0.26.1", default-features = false } vmnet = "0.10.0" clap = { version = "4", features = ["derive"] } mac_address = "1" privdrop = "0" anyhow = { version = "1", features = ["backtrace"] } +async-trait = "0.1" thiserror = "2" ip_network = "0" uzers = "0" diff --git a/README.md b/README.md index 636ca1a..37c5db1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Softnet can start with just `--control-fd` and create VMs through RPC. `softnet.initialize` accepts `vmNetType` (`"nat"` or `"host"`, default `"nat"`) and a `vms` array containing one to five VMs. Each VM requires a distinct `vmMacAddress` and accepts optional `allow`, `block`, and `expose` arrays, which default to empty. Rules and exposed TCP ports use the same syntax as the corresponding CLI arguments. +Each VM may specify a unique single-label DNS `name`. Softnet answers queries for `.internal` with that VM's reserved IPv4 address. It serves the shared authoritative `internal.` zone over UDP only, bypassing allow/block rules for these DNS queries. Other DNS queries use the system's DNS server. + Attach exactly one VM socket descriptor per entry through `SCM_RIGHTS` to the first byte of the request frame, in the same order as `vms`. Keep the sender's descriptors open until the initialization response arrives. A successful response contains a `vms` array in request order; each entry includes a UUID string `vmId` and the corresponding `vmMacAddress`. Initialization drops privileges before responding. @@ -85,7 +87,7 @@ A successful response contains a `vms` array in request order; each entry includ With two attached VM socket descriptors, a request and response look like this (each occupies one line; VM IDs are illustrative): ```json -{"jsonrpc":"2.0","id":"42","method":"softnet.initialize","params":{"vmNetType":"nat","vms":[{"vmMacAddress":"02:00:00:00:00:02","allow":["@host"]},{"vmMacAddress":"02:00:00:00:00:03"}]}} +{"jsonrpc":"2.0","id":"42","method":"softnet.initialize","params":{"vmNetType":"nat","vms":[{"vmMacAddress":"02:00:00:00:00:02","name":"vm1","allow":["@host"]},{"vmMacAddress":"02:00:00:00:00:03","name":"vm2"}]}} {"jsonrpc":"2.0","id":"42","result":{"vms":[{"vmId":"019a1b2c-3d4e-7000-8000-000000000001","vmMacAddress":"02:00:00:00:00:02"},{"vmId":"019a1b2c-3d4e-7000-8000-000000000002","vmMacAddress":"02:00:00:00:00:03"}]}} ``` diff --git a/lib/control.rs b/lib/control.rs index 5dc36a3..2ca420b 100644 --- a/lib/control.rs +++ b/lib/control.rs @@ -4,6 +4,7 @@ use crate::manager::{Call, CallError, Reply, Request, VmConfig}; use crate::network::MAX_VMS; use crate::proxy::Rule; use anyhow::Result; +use hickory_server::proto::rr::Name; use jsonrpsee_types::{ ErrorObjectOwned, Id, Request as RpcRequest, Response, ResponsePayload, error::{ @@ -40,6 +41,9 @@ struct VmParams { // Unique MAC address used by this VM vm_mac_address: String, + // Single-label VM name registered in our DNS zone + name: Option, + // Initial packet filter allow rules #[serde(default)] allow: Vec, @@ -130,7 +134,7 @@ impl Control { "block": policy.block.iter().map(ToString::to_string).collect::>(), "ruleCount": policy.rule_count, })), - Err(CallError::InvalidVm(error)) => { + Err(CallError::InvalidParams(error)) => { Err(rpc_error(INVALID_PARAMS_CODE, error.to_string())) } Err(CallError::Failed(error)) => { @@ -306,6 +310,16 @@ fn parse_initialize( .vm_mac_address .parse() .map_err(|_| rpc_error(INVALID_PARAMS_CODE, "invalid vmMacAddress"))?; + + // Parse optional VM names for DNS lookup + let name = params + .name + .map(|name| { + Name::from_ascii(name) + .map_err(|_| rpc_error(INVALID_PARAMS_CODE, "invalid name")) + }) + .transpose()?; + let (allow, block) = parse_policy(params.allow, params.block)?; let exposed_ports = params .expose @@ -320,6 +334,7 @@ fn parse_initialize( Ok(VmConfig { vm_fd: fd, vm_mac_address, + name, allow, block, exposed_ports, diff --git a/lib/dhcp_server/mod.rs b/lib/dhcp_server/mod.rs index 1e44cb2..aef29cd 100644 --- a/lib/dhcp_server/mod.rs +++ b/lib/dhcp_server/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use dhcproto::v4::{ CLIENT_PORT, DhcpOption, Flags, HType, Message, MessageType, Opcode, OptionCode, SERVER_PORT, }; -use dhcproto::{Decodable, Encodable}; +use dhcproto::{Decodable, Encodable, Name}; use smoltcp::phy::ChecksumCapabilities; use smoltcp::wire::{ ETHERNET_HEADER_LEN, EthernetAddress, EthernetFrame, EthernetProtocol, IpAddress, IpProtocol, @@ -16,6 +16,7 @@ pub(crate) struct DhcpServer { vm_ip: Ipv4Address, subnet_mask: Ipv4Address, gateway_ip: Ipv4Address, + search_domains: Vec, } impl DhcpServer { @@ -25,12 +26,14 @@ impl DhcpServer { vm_ip: Ipv4Address, subnet_mask: Ipv4Address, gateway_ip: Ipv4Address, + search_domains: Vec, ) -> Self { Self { vm_mac, vm_ip, subnet_mask, gateway_ip, + search_domains, } } @@ -166,6 +169,11 @@ impl DhcpServer { options.insert(DhcpOption::DomainNameServer(vec![self.gateway_ip])); options.insert(DhcpOption::AddressLeaseTime(u32::MAX)); + // Advertise the configured DNS search domains for short-name resolution + if !self.search_domains.is_empty() { + options.insert(DhcpOption::DomainSearch(self.search_domains.clone())); + } + reply } @@ -275,7 +283,7 @@ mod tests { #[test] fn assigns_reserved_address() { // Create a server and a client request - let dhcp = DhcpServer::new(VM, ADDRESS, MASK, GATEWAY); + let dhcp = DhcpServer::new(VM, ADDRESS, MASK, GATEWAY, Vec::new()); let mut message = request(MessageType::Discover); // Discover the reserved address @@ -318,7 +326,7 @@ mod tests { #[test] fn renews_reserved_address_and_rejects_stale_address() { // Renew the reserved address - let dhcp = DhcpServer::new(VM, ADDRESS, MASK, GATEWAY); + let dhcp = DhcpServer::new(VM, ADDRESS, MASK, GATEWAY, Vec::new()); let mut message = request(MessageType::Request); message.set_ciaddr(ADDRESS); let (ack, destination) = exchange(&dhcp, &message, GATEWAY); diff --git a/lib/dns_server/mod.rs b/lib/dns_server/mod.rs new file mode 100644 index 0000000..ee89247 --- /dev/null +++ b/lib/dns_server/mod.rs @@ -0,0 +1,183 @@ +use anyhow::{Context, Result, bail, ensure}; +use etherparse::PacketBuilder; +use hickory_server::net::{NetError, runtime::TokioTime, xfer::Protocol}; +use hickory_server::proto::op::{DEFAULT_MAX_PAYLOAD_LEN, Header, MessageType, OpCode}; +use hickory_server::proto::rr::{ + Name, RData, Record, + rdata::{A, SOA}, +}; +use hickory_server::proto::serialize::binary::{BinEncoder, EncodedSize}; +use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo}; +use hickory_server::store::in_memory::InMemoryZoneHandler; +use hickory_server::zone_handler::{AxfrPolicy, Catalog, MessageResponse, ZoneType}; +use smoltcp::wire::{ + ETHERNET_HEADER_LEN, EthernetAddress, IPV4_HEADER_LEN, Ipv4Packet, UDP_HEADER_LEN, UdpPacket, +}; +use std::collections::BTreeSet; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::{Arc, OnceLock}; + +/// DNS domain for VM names. +pub(crate) const DNS_DOMAIN: &str = "internal"; + +/// Standard DNS server port. +pub(crate) const DNS_PORT: u16 = 53; + +pub(crate) struct DnsServer { + domain: Name, + catalog: Catalog, +} + +#[derive(Clone)] +struct Response { + bytes: Arc>>, + max_payload: u16, +} + +impl DnsServer { + pub(crate) fn new(records: Vec<(Name, Ipv4Addr)>) -> Result { + // Initialize the DNS zone + let mut domain = Name::from_ascii(DNS_DOMAIN).expect("valid VM DNS domain"); + domain.set_fqdn(true); + let mut zone: InMemoryZoneHandler = + InMemoryZoneHandler::empty(domain.clone(), ZoneType::Primary, AxfrPolicy::Deny); + ensure!( + zone.upsert_mut( + Record::from_rdata( + domain.clone(), + 0, + RData::SOA(SOA::new(domain.clone(), domain.clone(), 0, 0, 0, 0, 0)), + ), + 0, + ), + "failed to initialize the VM DNS zone" + ); + + let mut names = BTreeSet::new(); + + for (name, address) in records { + // Validate single-label VM names before checking for duplicates + if name.is_fqdn() || name.num_labels() != 1 || name.is_wildcard() { + bail!("invalid VM name {name}: expected a single DNS label"); + } + + if !names.insert(name.clone()) { + bail!("VM name {name} is already in use"); + } + + // Register the VM name in the DNS zone without caching its address + let name = name.append_domain(&domain).expect("valid VM DNS name"); + + ensure!( + zone.upsert_mut(Record::from_rdata(name, 0, RData::A(A(address))), 0), + "failed to register a VM DNS name" + ); + } + + let mut catalog = Catalog::new(); + catalog.upsert(domain.clone().into(), vec![Arc::new(zone)]); + + Ok(Self { domain, catalog }) + } + + /// Returns a copy of the served DNS domain. + pub(crate) fn domain(&self) -> Name { + self.domain.clone() + } + + /// Answers queries for Softnet's DNS zone from a validated IPv4 UDP datagram. + pub(crate) async fn respond( + &self, + source_mac: EthernetAddress, + destination_mac: EthernetAddress, + ip: &Ipv4Packet<&[u8]>, + udp: &UdpPacket<&[u8]>, + max_packet_size: usize, + ) -> Result>> { + // Only inspect standard DNS queries + let source = SocketAddr::from((ip.src_addr(), udp.src_port())); + let Ok(request) = Request::from_bytes(udp.payload().to_vec(), source, Protocol::Udp) else { + return Ok(None); + }; + + if request.metadata.message_type != MessageType::Query + || request.metadata.op_code != OpCode::Query + { + return Ok(None); + } + + // Handle only names within the DNS zone + let Ok(info) = request.request_info() else { + return Ok(None); + }; + + if self.catalog.find(info.query.name()).is_none() { + return Ok(None); + } + + // Keep replies within the client's limit and the VM's available frame size + let Some(available) = + max_packet_size.checked_sub(ETHERNET_HEADER_LEN + IPV4_HEADER_LEN + UDP_HEADER_LEN) + else { + return Ok(None); + }; + let max_payload = usize::from(request.max_payload()) + .min(usize::from(DEFAULT_MAX_PAYLOAD_LEN)) + .min(available); + + if max_payload < Header::LEN { + return Ok(None); + } + + let response = Response { + bytes: Arc::new(OnceLock::new()), + max_payload: max_payload as u16, + }; + + // Resolve without opening a listener or sending the reply through the host network + self.catalog + .handle_request::<_, TokioTime>(&request, response.clone()) + .await; + let payload = response + .bytes + .get() + .context("failed to encode DNS response")?; + + // Swap the request's addresses and emit fresh IPv4 and UDP checksums + let builder = PacketBuilder::ethernet2(destination_mac.0, source_mac.0) + .ipv4(ip.dst_addr().octets(), ip.src_addr().octets(), 64) + .udp(DNS_PORT, udp.src_port()); + + let mut bytes = Vec::with_capacity(builder.size(payload.len())); + builder.write(&mut bytes, payload)?; + + Ok(Some(bytes)) + } +} + +#[async_trait::async_trait] +impl ResponseHandler for Response { + async fn send_response<'a>( + &mut self, + response: MessageResponse< + '_, + 'a, + impl Iterator + Send + 'a, + impl Iterator + Send + 'a, + impl Iterator + Send + 'a, + impl Iterator + Send + 'a, + >, + ) -> Result { + // Hickory truncates whole records and sets TC when the datagram limit is reached + let mut bytes = Vec::new(); + let mut encoder = BinEncoder::new(&mut bytes); + encoder.set_max_size(self.max_payload); + let info = response.destructive_emit(&mut encoder)?; + + self.bytes + .set(bytes) + .map_err(|_| NetError::from("DNS response already encoded"))?; + + Ok(info) + } +} diff --git a/lib/manager.rs b/lib/manager.rs index c0bc359..ee8e9bd 100644 --- a/lib/manager.rs +++ b/lib/manager.rs @@ -1,5 +1,6 @@ use crate::control::Control; use crate::dhcp_server::DHCP_SERVER_MAC; +use crate::dns_server::DnsServer; use crate::network::{MAX_VMS, NetType, Network, SUBNET_PREFIX_LEN}; use crate::proxy::{Command, ExposedPort, Policy, Proxy, Rule}; use crate::subnet_finder; @@ -7,11 +8,13 @@ use crate::vm::VM; use anyhow::{Context, Result, ensure}; use futures_util::FutureExt; use futures_util::future::OptionFuture; +use hickory_server::proto::rr::Name; use mac_address::MacAddress; use privdrop::PrivDrop; use std::collections::{BTreeMap, BTreeSet}; use std::os::fd::{AsRawFd, OwnedFd}; use std::panic::{AssertUnwindSafe, resume_unwind}; +use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; use tokio::task::{JoinError, JoinHandle}; use tokio_util::task::JoinMap; @@ -34,6 +37,7 @@ pub struct Manager { pub struct VmConfig { pub vm_fd: Fd, pub vm_mac_address: MacAddress, + pub name: Option, pub allow: Vec, pub block: Vec, pub exposed_ports: Vec, @@ -46,9 +50,12 @@ pub struct InitializedVm { pub vm_mac_address: MacAddress, } -/// Distinguish retryable rejection from fatal activation failure. +/// Distinguish invalid parameters, retryable rejection, and fatal activation failure. #[derive(Debug, thiserror::Error)] pub enum InitializationError { + #[error(transparent)] + InvalidParams(anyhow::Error), + #[error(transparent)] Rejected(anyhow::Error), @@ -86,10 +93,10 @@ pub(crate) enum Reply { Policy(Policy), } -/// Keep VM selection errors distinct from operation failures. +/// Keep invalid parameters distinct from operation failures. #[derive(Debug)] pub(crate) enum CallError { - InvalidVm(anyhow::Error), + InvalidParams(anyhow::Error), Failed(anyhow::Error), } @@ -163,6 +170,9 @@ impl Manager { Request::Initialize { vm_net_type, vms } => { match self.initialize(vm_net_type, vms).await { Ok(vms) => Ok(Reply::Initialized(vms)), + Err(InitializationError::InvalidParams(error)) => { + Err(CallError::InvalidParams(error)) + } Err(InitializationError::Rejected(error)) => Err(CallError::Failed(error)), Err(InitializationError::ActivationFailed(error)) => { // Give control a chance to send the failure reply before ending this run @@ -179,7 +189,7 @@ impl Manager { .await .map(Reply::Policy) .map_err(CallError::Failed), - Err(error) => Err(CallError::InvalidVm(error)), + Err(error) => Err(CallError::InvalidParams(error)), }, Request::SetPolicy { vm_id, @@ -191,7 +201,7 @@ impl Manager { .await .map(Reply::Policy) .map_err(CallError::Failed), - Err(error) => Err(CallError::InvalidVm(error)), + Err(error) => Err(CallError::InvalidParams(error)), }, }; @@ -217,7 +227,7 @@ impl Manager { ))); } - // Validate the complete batch before creating any native resources + // Validate MAC addresses before creating any native resources let mut mac_addresses = BTreeSet::new(); for vm in &vms { @@ -243,6 +253,8 @@ impl Manager { // Reserve the first usable address for the gateway let mut hosts = subnet.hosts().skip(1); + + let mut dns_records = Vec::new(); let mut configured = Vec::with_capacity(vms.len()); let mut initialized = Vec::with_capacity(vms.len()); @@ -261,9 +273,21 @@ impl Manager { vm_id, vm_mac_address: config.vm_mac_address, }); + + // Collect DNS records for named VMs + if let Some(vm_name) = config.name.clone() { + dns_records.push((vm_name, vm_ip)); + } + configured.push((vm, config, vm_ip, vm_id)); } + // Validate and share the DNS zone before preparing VM interfaces + let dns_server = (!dns_records.is_empty()) + .then(|| DnsServer::new(dns_records).map(Arc::new)) + .transpose() + .map_err(InitializationError::InvalidParams)?; + // Register VM tasks before setup so failures are cleaned up through shutdown let mut proxy_senders = Vec::with_capacity(initialized.len()); let mut start_senders = Vec::with_capacity(initialized.len()); @@ -313,8 +337,10 @@ impl Manager { &network, config.allow, config.block, + dns_server.clone(), ) .with_context(|| format!("failed to initialize VM at index {index} ({vm_id})"))?; + proxies.push(proxy); } diff --git a/lib/manager_test.rs b/lib/manager_test.rs index 28d64a3..ca848ae 100644 --- a/lib/manager_test.rs +++ b/lib/manager_test.rs @@ -1,7 +1,12 @@ use super::{Call, InitializationError, Manager, ProxyHandle, Reply, Request, VmConfig}; use crate::NetType; use crate::control::Control; +use crate::dhcp_server::DHCP_SERVER_MAC; +use crate::dns_server::DNS_PORT; use crate::proxy::{Command, Policy}; +use etherparse::{PacketBuilder, SlicedPacket, TransportSlice}; +use hickory_server::proto::op::{Message, Query, ResponseCode}; +use hickory_server::proto::rr::{RData, RecordType, rdata::A}; use jsonrpsee_types::error::{CALL_EXECUTION_FAILED_CODE, INVALID_PARAMS_CODE}; use serde_json::{Value, json}; use std::io::{Read, Write}; @@ -33,6 +38,7 @@ fn vm_config(socket: &impl AsFd, mac: &str) -> VmConfig { VmConfig { vm_fd: socket.as_fd().try_clone_to_owned().unwrap(), vm_mac_address: mac.parse().unwrap(), + name: None, allow: Vec::new(), block: Vec::new(), exposed_ports: Vec::new(), @@ -818,7 +824,7 @@ async fn native_control_disconnect_keeps_vms_running() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial_test::serial] -async fn native_batch_rpc_preserves_order_and_independent_policies() { +async fn native_batch_rpc_shares_dns_and_preserves_independent_policies() { let (control_socket, control_peer) = UnixStream::pair().unwrap(); let mut manager = native_manager(Some(Control::new(control_socket.into()).unwrap())); let (first_socket, first_peer) = UnixDatagram::pair().unwrap(); @@ -831,8 +837,8 @@ async fn native_batch_rpc_preserves_order_and_independent_policies() { "params": { "vmNetType": "nat", "vms": [ - {"vmMacAddress": "02:00:00:00:00:02", "allow": ["@host"]}, - {"vmMacAddress": "02:00:00:00:00:03"}, + {"vmMacAddress": "02:00:00:00:00:02", "name": "vm1", "allow": ["@host"]}, + {"vmMacAddress": "02:00:00:00:00:03", "name": "vm2"}, ], }, }); @@ -849,6 +855,7 @@ async fn native_batch_rpc_preserves_order_and_independent_policies() { let reply = read_reply(&mut peer).await; drop(first_socket); drop(second_socket); + assert!(reply.get("error").is_none(), "{reply}"); let vms = reply["result"]["vms"].as_array().unwrap(); assert_eq!(vms.len(), 2); let first = vms[0]["vmId"].as_str().unwrap().to_owned(); @@ -910,6 +917,59 @@ async fn native_batch_rpc_preserves_order_and_independent_policies() { assert_ne!(addresses[0], addresses[1]); assert_eq!(u32::from(addresses[0]) >> 3, u32::from(addresses[1]) >> 3); + // Resolve both VM names through each proxy's shared DNS server + let subnet = ipnet::Ipv4Net::new(addresses[0], crate::network::SUBNET_PREFIX_LEN).unwrap(); + let gateway = subnet.hosts().next().unwrap(); + + for (socket, address, suffix) in [ + (&first_peer, addresses[0], 2), + (&second_peer, addresses[1], 3), + ] { + socket.set_nonblocking(true).unwrap(); + let socket = tokio::net::UnixDatagram::from_std(socket.try_clone().unwrap()).unwrap(); + + for (name, expected) in [ + ("vm1.internal.", addresses[0]), + ("vm2.internal.", addresses[1]), + ] { + // Send a DNS query as a complete Ethernet frame from this VM + let mut query = Message::query(); + query.add_query(Query::query(name.parse().unwrap(), RecordType::A)); + let payload = query.to_vec().unwrap(); + let builder = PacketBuilder::ethernet2([2, 0, 0, 0, 0, suffix], DHCP_SERVER_MAC.0) + .ipv4(address.octets(), gateway.octets(), 64) + .udp(53000, DNS_PORT); + let mut frame = Vec::with_capacity(builder.size(payload.len())); + builder.write(&mut frame, &payload).unwrap(); + socket.send(&frame).await.unwrap(); + + // Ignore unrelated native traffic while waiting for this DNS reply + let mut frame = [0; 2048]; + + let response = loop { + let len = socket.recv(&mut frame).await.unwrap(); + let packet = SlicedPacket::from_ethernet(&frame[..len]).unwrap(); + let Some(TransportSlice::Udp(udp)) = packet.transport else { + continue; + }; + + if udp.source_port() != DNS_PORT || udp.destination_port() != 53000 { + continue; + } + + let response = Message::from_vec(udp.payload()).unwrap(); + if response.metadata.id == query.metadata.id { + break response; + } + }; + + // Both proxies must return the correct reservation for either name + assert_eq!(response.metadata.response_code, ResponseCode::NoError); + assert_eq!(response.answers.len(), 1); + assert_eq!(response.answers[0].data, RData::A(A(expected))); + } + } + // Address each VM explicitly and keep policy changes independent let reply = rpc(&mut peer, "softnet.policy.get", json!({})).await; assert_eq!(reply["error"]["code"], INVALID_PARAMS_CODE); diff --git a/lib/mod.rs b/lib/mod.rs index 21689ec..63a1e69 100644 --- a/lib/mod.rs +++ b/lib/mod.rs @@ -1,6 +1,7 @@ pub mod control; mod control_transport; mod dhcp_server; +mod dns_server; mod host; mod network; pub use network::NetType; diff --git a/lib/proxy/mod.rs b/lib/proxy/mod.rs index bb8f9b7..a699f71 100644 --- a/lib/proxy/mod.rs +++ b/lib/proxy/mod.rs @@ -6,6 +6,7 @@ mod rules; mod vm; use crate::dhcp_server::DhcpServer; +use crate::dns_server::DnsServer; use crate::host::Host; use crate::network::Network; use crate::vm::VM; @@ -18,6 +19,7 @@ pub use rule::{Direction, Rule, Target}; pub(crate) use rules::{PolicyDecision, Rules}; use smoltcp::wire::{EthernetAddress, EthernetFrame, Ipv4Address}; use std::net::Ipv4Addr; +use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc, oneshot}; use tokio::time::{MissedTickBehavior, interval}; @@ -46,6 +48,7 @@ pub struct Proxy { host: Host, vm_mac_address: smoltcp::wire::EthernetAddress, dhcp_server: DhcpServer, + dns_server: Option>, rules: Rules, allow: Vec, block: Vec, @@ -62,6 +65,7 @@ impl Proxy { network: &Network, allow: Vec, block: Vec, + dns_server: Option>, ) -> Result { let vm_mac_address = EthernetAddress(vm_mac_address.bytes()); let allow = normalize_rules(allow); @@ -86,6 +90,7 @@ impl Proxy { host.vm_ip, network.subnet.netmask(), host.gateway_ip, + dns_server.iter().map(|server| server.domain()).collect(), ); Ok(Proxy { @@ -93,6 +98,7 @@ impl Proxy { host, vm_mac_address, dhcp_server, + dns_server, rules, allow, block, @@ -136,7 +142,7 @@ impl Proxy { coarsetime::Instant::update(); if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) { - self.process_frame_from_vm(frame)?; + self.process_frame_from_vm(frame).await?; } // Limit VM bursts to 128 packets (including the one we read above), @@ -147,7 +153,7 @@ impl Proxy { coarsetime::Instant::update(); if let Ok(frame) = EthernetFrame::new_checked(&buf[..n]) { - self.process_frame_from_vm(frame)?; + self.process_frame_from_vm(frame).await?; } tokio::task::consume_budget().await; @@ -285,7 +291,7 @@ mod tests { use mac_address::MacAddress; use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair}; use serial_test::serial; - use smoltcp::wire::{IpProtocol, Ipv4Address, Ipv4Packet, UdpPacket}; + use smoltcp::wire::{EthernetAddress, IpProtocol, Ipv4Address, Ipv4Packet, UdpPacket}; use std::os::fd::{AsRawFd, OwnedFd}; use std::str::FromStr; @@ -297,7 +303,11 @@ mod tests { assert_eq!(proxy.rules.len(), 1); - assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "66.66.66.66").is_none()); + assert!( + allowed_from_vm_ipv4(&mut proxy, vm_ip, "66.66.66.66") + .await + .is_none() + ); } #[tokio::test] @@ -308,9 +318,21 @@ mod tests { assert_eq!(proxy.rules.len(), 2); - assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.32").is_none()); - assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.33").is_some()); - assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.34").is_none()); + assert!( + allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.32") + .await + .is_none() + ); + assert!( + allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.33") + .await + .is_some() + ); + assert!( + allowed_from_vm_ipv4(&mut proxy, vm_ip, "33.33.33.34") + .await + .is_none() + ); } #[tokio::test] @@ -322,11 +344,19 @@ mod tests { assert_eq!(proxy.rules.len(), 2); // Access to global IPs should be disallowed because of --block=0.0.0.0/0 - assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, "8.8.8.8").is_none()); + assert!( + allowed_from_vm_ipv4(&mut proxy, vm_ip, "8.8.8.8") + .await + .is_none() + ); // Despite the above, access to host IP address should be possible because of --allow=@host let gateway_ip = proxy.host.gateway_ip.to_string(); - assert!(allowed_from_vm_ipv4(&mut proxy, vm_ip, &gateway_ip).is_some()); + assert!( + allowed_from_vm_ipv4(&mut proxy, vm_ip, &gateway_ip) + .await + .is_some() + ); } #[tokio::test] @@ -343,7 +373,12 @@ mod tests { let fallback_reply = udp_packet(vm_ip, 1_234, fallback_peer, 40_000); let fallback_reply = Ipv4Packet::new_checked(fallback_reply.as_slice()).unwrap(); - assert!(proxy.allowed_from_vm_ipv4(fallback_reply).is_none()); + assert!( + proxy + .allowed_from_vm_ipv4(EthernetAddress::BROADCAST, fallback_reply) + .await + .is_none() + ); let allowed_request = udp_packet(explicitly_allowed_peer, 40_000, vm_ip, 1_234); let allowed_request = Ipv4Packet::new_checked(allowed_request.as_slice()).unwrap(); @@ -351,7 +386,12 @@ mod tests { let allowed_reply = udp_packet(vm_ip, 1_234, explicitly_allowed_peer, 40_000); let allowed_reply = Ipv4Packet::new_checked(allowed_reply.as_slice()).unwrap(); - assert!(proxy.allowed_from_vm_ipv4(allowed_reply).is_some()); + assert!( + proxy + .allowed_from_vm_ipv4(EthernetAddress::BROADCAST, allowed_reply) + .await + .is_some() + ); } #[tokio::test] @@ -367,7 +407,12 @@ mod tests { let reply = udp_packet(vm_ip, 1_234, peer, 40_000); let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap(); - assert!(proxy.allowed_from_vm_ipv4(reply).is_some()); + assert!( + proxy + .allowed_from_vm_ipv4(EthernetAddress::BROADCAST, reply) + .await + .is_some() + ); } #[tokio::test] @@ -379,7 +424,12 @@ mod tests { let request = udp_packet(vm_ip, 1_234, peer, 40_000); let request = Ipv4Packet::new_checked(request.as_slice()).unwrap(); - assert!(proxy.allowed_from_vm_ipv4(request).is_some()); + assert!( + proxy + .allowed_from_vm_ipv4(EthernetAddress::BROADCAST, request) + .await + .is_some() + ); let reply = udp_packet(peer, 40_000, vm_ip, 1_234); let reply = Ipv4Packet::new_checked(reply.as_slice()).unwrap(); @@ -397,7 +447,12 @@ mod tests { let peer = Ipv4Address::new(10, 1, 2, 3); let request = udp_packet(vm_ip, 40_000, peer, 1_234); let request = Ipv4Packet::new_checked(request.as_slice()).unwrap(); - assert!(proxy.allowed_from_vm_ipv4(request).is_some()); + assert!( + proxy + .allowed_from_vm_ipv4(EthernetAddress::BROADCAST, request) + .await + .is_some() + ); // An equivalent policy must preserve the tracked permission for this reply let policy = proxy.policy_set( @@ -451,13 +506,14 @@ mod tests { .into_iter() .map(|value| value.parse().unwrap()) .collect(), + None, ) .unwrap(); (proxy, peer) } - fn allowed_from_vm_ipv4(proxy: &mut Proxy, src: Ipv4Address, dst: &str) -> Option<()> { + async fn allowed_from_vm_ipv4(proxy: &mut Proxy, src: Ipv4Address, dst: &str) -> Option<()> { let mut buf = vec![0; 1500]; let mut ipv4_pkt_mut = Ipv4Packet::new_unchecked(&mut buf[..]); @@ -466,7 +522,9 @@ mod tests { let ipv4_pkt = Ipv4Packet::new_unchecked(buf.as_slice()); - proxy.allowed_from_vm_ipv4(ipv4_pkt) + proxy + .allowed_from_vm_ipv4(EthernetAddress::BROADCAST, ipv4_pkt) + .await } fn udp_packet( diff --git a/lib/proxy/vm.rs b/lib/proxy/vm.rs index a1f2737..a34ee5c 100644 --- a/lib/proxy/vm.rs +++ b/lib/proxy/vm.rs @@ -1,3 +1,4 @@ +use crate::dns_server::DNS_PORT; use crate::proxy::flows::{FlowDirection, FlowMatch}; use crate::proxy::{Direction, PolicyDecision, Proxy}; use anyhow::Context; @@ -5,15 +6,18 @@ use anyhow::Result; use dhcproto::v4::SERVER_PORT; use smoltcp::phy::ChecksumCapabilities; use smoltcp::wire::{ - ArpOperation, ArpPacket, ArpRepr, EthernetFrame, EthernetProtocol, IpProtocol, Ipv4Address, - Ipv4Packet, Ipv4Repr, UdpPacket, + ArpOperation, ArpPacket, ArpRepr, EthernetAddress, EthernetFrame, EthernetProtocol, IpAddress, + IpProtocol, Ipv4Address, Ipv4Packet, Ipv4Repr, UdpPacket, }; const IPV4_HEADER_LEN_WITHOUT_OPTIONS: u8 = 20; impl Proxy { - pub(crate) fn process_frame_from_vm(&mut self, frame: EthernetFrame<&[u8]>) -> Result<()> { - if self.allowed_from_vm(&frame).is_none() { + pub(crate) async fn process_frame_from_vm( + &mut self, + frame: EthernetFrame<&[u8]>, + ) -> Result<()> { + if self.allowed_from_vm(&frame).await.is_none() { // Block packet by not forwarding it to the host return Ok(()); } @@ -24,7 +28,7 @@ impl Proxy { .context("failed to write to the host") } - fn allowed_from_vm(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { + async fn allowed_from_vm(&mut self, frame: &EthernetFrame<&[u8]>) -> Option<()> { if frame.src_addr() != self.vm_mac_address { return None; } @@ -43,7 +47,7 @@ impl Proxy { return None; } - self.allowed_from_vm_ipv4(ipv4_pkt) + self.allowed_from_vm_ipv4(frame.dst_addr(), ipv4_pkt).await } _ => None, } @@ -53,7 +57,11 @@ impl Proxy { vm_arp_allowed(arp_pkt, self.vm_mac_address, self.host.vm_ip) } - pub(crate) fn allowed_from_vm_ipv4(&mut self, ipv4_pkt: Ipv4Packet<&[u8]>) -> Option<()> { + pub(crate) async fn allowed_from_vm_ipv4( + &mut self, + destination_mac: EthernetAddress, + ipv4_pkt: Ipv4Packet<&[u8]>, + ) -> Option<()> { // Consume DHCP before enforcing the reserved source address if self.consume_dhcp(&ipv4_pkt) { return None; @@ -61,6 +69,11 @@ impl Proxy { // Is this packet coming from the VM's reserved IP address? if self.host.vm_ip == ipv4_pkt.src_addr() { + // Answer queries for Softnet's DNS zone locally + if self.consume_dns(destination_mac, &ipv4_pkt).await { + return None; + } + // Consult the flow table before evaluating outbound policy // so established flows are not treated as new traffic let pending = match self @@ -143,6 +156,72 @@ impl Proxy { // Consume the packet even if the request was rejected or the reply failed true } + + async fn consume_dns( + &mut self, + destination_mac: EthernetAddress, + ipv4_pkt: &Ipv4Packet<&[u8]>, + ) -> bool { + // Only inspect DNS packets when Softnet's DNS server is configured + let Some(dns_server) = &self.dns_server else { + return false; + }; + + // Accept only complete IPv4 UDP datagrams addressed to the gateway + if ipv4_pkt.dst_addr() != self.host.gateway_ip + || !ipv4_pkt.verify_checksum() + || ipv4_pkt.next_header() != IpProtocol::Udp + || ipv4_pkt.more_frags() + || ipv4_pkt.frag_offset() != 0 + { + return false; + } + + // Check the UDP header before reading its fields + let Ok(udp) = UdpPacket::new_checked(ipv4_pkt.payload()) else { + return false; + }; + + // Validate the UDP checksum, length, and DNS server port + if !udp.verify_checksum( + &IpAddress::Ipv4(ipv4_pkt.src_addr()), + &IpAddress::Ipv4(ipv4_pkt.dst_addr()), + ) || udp.dst_port() != DNS_PORT + || udp.len() as usize != ipv4_pkt.payload().len() + { + return false; + } + + // Pass the request to Softnet's DNS server and send the reply + let result = match dns_server + .respond( + self.vm_mac_address, + destination_mac, + ipv4_pkt, + &udp, + self.host.max_packet_size as usize, + ) + .await + { + Ok(Some(reply)) => self.write_to_vm(&reply), + Ok(None) => { + // Leave queries outside Softnet's DNS zone to the system DNS resolver + return false; + } + Err(err) => Err(err), + }; + + // Report failures to generate or send the reply + if let Err(err) = result { + sentry::capture_message( + &format!("Failed to reply to DNS request: {err:#}"), + sentry::Level::Warning, + ); + } + + // Consume the packet even if the request was rejected or the reply failed + true + } } fn vm_arp_allowed( diff --git a/src/main.rs b/src/main.rs index cd465b6..271baee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use anyhow::{Context, anyhow}; use clap::Parser; +use hickory_server::proto::rr::Name; use log::LevelFilter; use oslog::OsLogger; use softnet::NetType; @@ -52,6 +53,14 @@ struct Args { #[clap(long, requires = "vm_fd", help = "MAC address to enforce for the VM")] vm_mac_address: Option, + #[clap( + long, + requires = "vm_fd", + value_parser = |value: &str| Name::from_ascii(value), + help = "Single-label VM name to register as .internal" + )] + vm_name: Option, + #[clap(long, requires = "vm_fd", value_enum, help = "type of network to use for the VM", default_value_t=NetType::Nat)] vm_net_type: NetType, @@ -261,6 +270,7 @@ async fn try_main() -> anyhow::Result<()> { vec![VmConfig { vm_fd, vm_mac_address, + name: args.vm_name, allow: args.allow, block: args.block, exposed_ports: args.expose,