#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::dns_resolver::Resolution;
use crate::network_policy::{NetworkAllowRule, RequestTarget};
pub(crate) const EGRESS_DNS_PORT: u16 = 53;
pub(crate) const MAX_EGRESS_TCP_PORTS: usize = 16;
const DNS_TTL_SECONDS: u32 = 60;
const NAME_BINDING_LIFETIME: Duration = Duration::from_secs(300);
const MAX_DNS_MESSAGE_BYTES: usize = 4096;
pub(crate) fn egress_listen_ports(rules: &[NetworkAllowRule]) -> Vec<u16> {
let mut ports: Vec<u16> = Vec::new();
for rule in rules {
for port in implied_tcp_ports(rule) {
if !ports.contains(&port) {
ports.push(port);
}
}
}
ports.sort_unstable();
ports.truncate(MAX_EGRESS_TCP_PORTS);
ports
}
pub(crate) fn implied_tcp_ports(rule: &NetworkAllowRule) -> impl Iterator<Item = u16> {
let implied: [Option<u16>; 2] = match rule.port {
Some(port) => [Some(port), None],
None => [Some(80), Some(443)],
};
implied.into_iter().flatten()
}
#[derive(Debug, Clone)]
pub(crate) struct EgressPolicy {
rules: Vec<NetworkAllowRule>,
allow_ips: Vec<IpAddr>,
}
impl EgressPolicy {
pub(crate) fn new(rules: Vec<NetworkAllowRule>, allow_ips: Vec<IpAddr>) -> Self {
Self { rules, allow_ips }
}
pub(crate) fn allows_name(&self, name: &str) -> bool {
let name = normalize_host(name);
if name.parse::<IpAddr>().is_ok() {
return false;
}
self.rules.iter().any(|rule| rule.host == name)
}
pub(crate) fn allows_connection(&self, names: &[String], address: IpAddr, port: u16) -> bool {
for name in names {
let host = normalize_host(name);
if self
.rules
.iter()
.any(|rule| rule_permits_connection(rule, &host, port))
{
return true;
}
}
crate::sandbox::network_ip_allowed(address, &self.allow_ips)
}
}
fn rule_permits_connection(rule: &NetworkAllowRule, host: &str, port: u16) -> bool {
let target = RequestTarget {
scheme: rule.scheme.clone().unwrap_or_default(),
host: host.to_string(),
port: Some(port),
};
rule.matches(&target)
}
fn normalize_host(host: &str) -> String {
host.trim_end_matches('.').to_ascii_lowercase()
}
#[derive(Debug, Default)]
pub(crate) struct ResolvedNames {
entries: Mutex<HashMap<IpAddr, Vec<(String, Instant)>>>,
}
impl ResolvedNames {
pub(crate) fn record(&self, name: &str, addresses: &[IpAddr]) {
let Ok(mut entries) = self.entries.lock() else {
return;
};
let now = Instant::now();
for address in addresses {
let bindings = entries.entry(*address).or_default();
bindings.retain(|(bound, at)| {
bound != name && now.duration_since(*at) < NAME_BINDING_LIFETIME
});
bindings.push((name.to_string(), now));
}
}
pub(crate) fn names_for(&self, address: IpAddr) -> Vec<String> {
let Ok(entries) = self.entries.lock() else {
return Vec::new();
};
let now = Instant::now();
entries
.get(&address)
.map(|bindings| {
bindings
.iter()
.filter(|(_, at)| now.duration_since(*at) < NAME_BINDING_LIFETIME)
.map(|(name, _)| name.clone())
.collect()
})
.unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DnsQuery {
pub(crate) id: u16,
pub(crate) recursion_desired: bool,
pub(crate) name: String,
pub(crate) qtype: u16,
pub(crate) question: Vec<u8>,
}
pub(crate) const DNS_TYPE_A: u16 = 1;
pub(crate) const DNS_TYPE_AAAA: u16 = 28;
pub(crate) const DNS_RCODE_NOERROR: u8 = 0;
pub(crate) const DNS_RCODE_FORMERR: u8 = 1;
pub(crate) const DNS_RCODE_SERVFAIL: u8 = 2;
pub(crate) const DNS_RCODE_NXDOMAIN: u8 = 3;
pub(crate) const DNS_RCODE_REFUSED: u8 = 5;
pub(crate) fn parse_dns_query(bytes: &[u8]) -> Option<DnsQuery> {
if bytes.len() < 12 || bytes.len() > MAX_DNS_MESSAGE_BYTES {
return None;
}
let id = u16::from_be_bytes([bytes[0], bytes[1]]);
let flags = u16::from_be_bytes([bytes[2], bytes[3]]);
if flags & 0x8000 != 0 {
return None;
}
if u16::from_be_bytes([bytes[4], bytes[5]]) != 1 {
return None;
}
let mut offset = 12;
let mut labels: Vec<String> = Vec::new();
loop {
let length = *bytes.get(offset)? as usize;
offset += 1;
if length == 0 {
break;
}
if length & 0xc0 != 0 {
return None;
}
let label = bytes.get(offset..offset + length)?;
labels.push(String::from_utf8_lossy(label).into_owned());
offset += length;
}
let qtype_bytes = bytes.get(offset..offset + 4)?;
let qtype = u16::from_be_bytes([qtype_bytes[0], qtype_bytes[1]]);
offset += 4;
Some(DnsQuery {
id,
recursion_desired: flags & 0x0100 != 0,
name: labels.join("."),
qtype,
question: bytes[12..offset].to_vec(),
})
}
pub(crate) fn build_dns_response(query: &DnsQuery, rcode: u8, answers: &[IpAddr]) -> Vec<u8> {
let mut message = Vec::with_capacity(64 + answers.len() * 16);
message.extend_from_slice(&query.id.to_be_bytes());
let mut flags: u16 = 0x8000 | 0x0080;
if query.recursion_desired {
flags |= 0x0100;
}
flags |= u16::from(rcode & 0x0f);
message.extend_from_slice(&flags.to_be_bytes());
message.extend_from_slice(&1u16.to_be_bytes()); message.extend_from_slice(&(answers.len() as u16).to_be_bytes()); message.extend_from_slice(&0u16.to_be_bytes()); message.extend_from_slice(&0u16.to_be_bytes()); message.extend_from_slice(&query.question);
for answer in answers {
message.extend_from_slice(&[0xc0, 0x0c]);
let (rtype, raw): (u16, Vec<u8>) = match answer {
IpAddr::V4(v4) => (DNS_TYPE_A, v4.octets().to_vec()),
IpAddr::V6(v6) => (DNS_TYPE_AAAA, v6.octets().to_vec()),
};
message.extend_from_slice(&rtype.to_be_bytes());
message.extend_from_slice(&1u16.to_be_bytes()); message.extend_from_slice(&DNS_TTL_SECONDS.to_be_bytes());
message.extend_from_slice(&(raw.len() as u16).to_be_bytes());
message.extend_from_slice(&raw);
}
message
}
pub(crate) fn reply_without_lookup(policy: &EgressPolicy, query: &DnsQuery) -> Option<Vec<u8>> {
if !policy.allows_name(&query.name) {
return Some(build_dns_response(query, DNS_RCODE_REFUSED, &[]));
}
if query.qtype != DNS_TYPE_A && query.qtype != DNS_TYPE_AAAA {
return Some(build_dns_response(query, DNS_RCODE_NOERROR, &[]));
}
None
}
pub(crate) fn answer_dns_query(
policy: &EgressPolicy,
resolved: &ResolvedNames,
query: &DnsQuery,
resolve: impl FnOnce(&str) -> Resolution,
) -> Vec<u8> {
if let Some(reply) = reply_without_lookup(policy, query) {
return reply;
}
let all = match resolve(&query.name) {
Resolution::Resolved(addresses) => addresses,
Resolution::DoesNotExist => return build_dns_response(query, DNS_RCODE_NXDOMAIN, &[]),
Resolution::DidNotAnswer(_) => return build_dns_response(query, DNS_RCODE_SERVFAIL, &[]),
};
resolved.record(&query.name, &all);
let addresses: Vec<IpAddr> = all
.into_iter()
.filter(|address| match query.qtype {
DNS_TYPE_A => address.is_ipv4(),
_ => address.is_ipv6(),
})
.collect();
build_dns_response(query, DNS_RCODE_NOERROR, &addresses)
}
#[cfg(target_os = "linux")]
pub(crate) use linux::{start_egress_proxy, EgressProxyHandle};
#[cfg(target_os = "linux")]
mod linux {
use std::io;
use std::net::{IpAddr, SocketAddr, SocketAddrV4, TcpListener, TcpStream, UdpSocket};
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::{
answer_dns_query, parse_dns_query, reply_without_lookup, EgressPolicy, ResolvedNames,
DNS_RCODE_FORMERR, MAX_DNS_MESSAGE_BYTES,
};
use crate::dns_resolver::{DnsResolver, NoAnswer, Resolution};
const IO_TIMEOUT: Duration = Duration::from_secs(300);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL_MS: libc::c_int = 200;
const MAX_CONCURRENT_CONNECTIONS: usize = 128;
#[derive(Debug)]
pub(crate) struct EgressProxyHandle {
stop: Arc<AtomicBool>,
threads: Vec<std::thread::JoinHandle<()>>,
}
impl EgressProxyHandle {
pub(crate) fn shutdown(self) {
self.stop.store(true, Ordering::SeqCst);
for thread in self.threads {
let _ = thread.join();
}
}
}
pub(crate) fn start_egress_proxy(
sockets: Vec<OwnedFd>,
policy: EgressPolicy,
) -> io::Result<EgressProxyHandle> {
let Some((resolver, listeners)) = sockets.split_last() else {
return Err(io::Error::other(
"egress-proxy: the capsule namespace handed over no sockets at all",
));
};
let stop = Arc::new(AtomicBool::new(false));
let resolved = Arc::new(ResolvedNames::default());
let in_flight = Arc::new(AtomicUsize::new(0));
let mut threads = Vec::with_capacity(sockets.len());
for listener in listeners {
let listener = TcpListener::from(listener.try_clone()?);
let policy = policy.clone();
let resolved = Arc::clone(&resolved);
let stop = Arc::clone(&stop);
let in_flight = Arc::clone(&in_flight);
threads.push(std::thread::spawn(move || {
serve_tcp(listener, policy, resolved, stop, in_flight);
}));
}
{
let resolver = UdpSocket::from(resolver.try_clone()?);
let policy = policy.clone();
let resolved = Arc::clone(&resolved);
let stop = Arc::clone(&stop);
let names = crate::dns_resolver::shared().cloned();
threads.push(std::thread::spawn(move || {
serve_dns(resolver, policy, resolved, stop, names);
}));
}
Ok(EgressProxyHandle { stop, threads })
}
fn wait_readable(fd: RawFd) -> bool {
let mut pollfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
#[allow(unsafe_code)]
let rc = unsafe { libc::poll(&mut pollfd, 1, POLL_INTERVAL_MS) };
rc > 0 && pollfd.revents & libc::POLLIN != 0
}
fn serve_tcp(
listener: TcpListener,
policy: EgressPolicy,
resolved: Arc<ResolvedNames>,
stop: Arc<AtomicBool>,
in_flight: Arc<AtomicUsize>,
) {
while !stop.load(Ordering::SeqCst) {
if !wait_readable(listener.as_raw_fd()) {
continue;
}
let Ok((client, _)) = listener.accept() else {
continue;
};
let Ok(SocketAddr::V4(destination)) = client.local_addr() else {
continue;
};
if in_flight.load(Ordering::SeqCst) >= MAX_CONCURRENT_CONNECTIONS {
continue;
}
in_flight.fetch_add(1, Ordering::SeqCst);
let policy = policy.clone();
let resolved = Arc::clone(&resolved);
let done = Arc::clone(&in_flight);
std::thread::spawn(move || {
relay_connection(client, destination, &policy, &resolved);
done.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn relay_connection(
client: TcpStream,
destination: SocketAddrV4,
policy: &EgressPolicy,
resolved: &ResolvedNames,
) {
let address = IpAddr::V4(*destination.ip());
let names = resolved.names_for(address);
if !policy.allows_connection(&names, address, destination.port()) {
return;
}
let Ok(upstream) =
TcpStream::connect_timeout(&SocketAddr::V4(destination), CONNECT_TIMEOUT)
else {
return;
};
let bounded = [
client.set_read_timeout(Some(IO_TIMEOUT)),
client.set_write_timeout(Some(IO_TIMEOUT)),
upstream.set_read_timeout(Some(IO_TIMEOUT)),
upstream.set_write_timeout(Some(IO_TIMEOUT)),
];
if bounded.iter().any(|result| result.is_err()) {
return;
}
splice(client, upstream);
}
fn splice(client: TcpStream, upstream: TcpStream) {
let (Ok(client_read), Ok(upstream_read)) = (client.try_clone(), upstream.try_clone())
else {
return;
};
let mut client_write = client;
let mut upstream_write = upstream;
let uplink = std::thread::spawn(move || {
let mut source = client_read;
let _ = io::copy(&mut source, &mut upstream_write);
let _ = upstream_write.shutdown(std::net::Shutdown::Write);
});
let mut source = upstream_read;
let _ = io::copy(&mut source, &mut client_write);
let _ = client_write.shutdown(std::net::Shutdown::Write);
let _ = uplink.join();
}
#[repr(C, align(8))]
struct CmsgBuffer([u8; 64]);
pub(crate) fn serve_dns(
socket: UdpSocket,
policy: EgressPolicy,
resolved: Arc<ResolvedNames>,
stop: Arc<AtomicBool>,
names: Option<DnsResolver>,
) {
let socket = Arc::new(socket);
let fd = socket.as_raw_fd();
let mut buffer = vec![0u8; MAX_DNS_MESSAGE_BYTES];
while !stop.load(Ordering::SeqCst) {
if !wait_readable(fd) {
continue;
}
let Some((len, peer, local)) = recv_query(fd, &mut buffer) else {
continue;
};
let Some(query) = parse_dns_query(&buffer[..len]) else {
if len < 12 {
continue;
}
let mut header = [0u8; 12];
header.copy_from_slice(&buffer[..12]);
send_reply(fd, &formerr_reply(&header), peer, local);
continue;
};
if let Some(reply) = reply_without_lookup(&policy, &query) {
send_reply(fd, &reply, peer, local);
continue;
}
match (names.as_ref(), crate::dns_resolver::runtime_handle()) {
(Some(names), Some(runtime)) => {
let names = names.clone();
let policy = policy.clone();
let resolved = Arc::clone(&resolved);
let socket = Arc::clone(&socket);
runtime.spawn(async move {
let resolution = names.lookup(&query.name).await;
let reply = answer_dns_query(&policy, &resolved, &query, |_| resolution);
send_reply(socket.as_raw_fd(), &reply, peer, local);
});
}
_ => {
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::DidNotAnswer(NoAnswer::ResolverUnavailable)
});
send_reply(fd, &reply, peer, local);
}
}
}
}
fn recv_query(
fd: RawFd,
buffer: &mut [u8],
) -> Option<(usize, libc::sockaddr_in, Option<libc::in_addr>)> {
#[allow(unsafe_code)]
unsafe {
let mut peer: libc::sockaddr_in = std::mem::zeroed();
let mut iov = libc::iovec {
iov_base: buffer.as_mut_ptr().cast(),
iov_len: buffer.len(),
};
let mut control = CmsgBuffer([0u8; 64]);
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_name = std::ptr::addr_of_mut!(peer).cast();
msg.msg_namelen = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = control.0.as_mut_ptr().cast();
msg.msg_controllen = control.0.len() as _;
let received = libc::recvmsg(fd, &mut msg, 0);
if received <= 0 {
return None;
}
let mut local = None;
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
while !cmsg.is_null() {
if (*cmsg).cmsg_level == libc::IPPROTO_IP && (*cmsg).cmsg_type == libc::IP_PKTINFO {
let info =
std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast::<libc::in_pktinfo>());
local = Some(info.ipi_addr);
}
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
}
Some((received as usize, peer, local))
}
}
fn send_reply(fd: RawFd, reply: &[u8], peer: libc::sockaddr_in, local: Option<libc::in_addr>) {
#[allow(unsafe_code)]
unsafe {
let mut peer = peer;
let mut iov = libc::iovec {
iov_base: reply.as_ptr() as *mut libc::c_void,
iov_len: reply.len(),
};
let mut control = CmsgBuffer([0u8; 64]);
let mut msg: libc::msghdr = std::mem::zeroed();
msg.msg_name = std::ptr::addr_of_mut!(peer).cast();
msg.msg_namelen = std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
if let Some(address) = local {
let bytes = std::mem::size_of::<libc::in_pktinfo>() as u32;
msg.msg_control = control.0.as_mut_ptr().cast();
msg.msg_controllen = libc::CMSG_SPACE(bytes) as _;
let cmsg = libc::CMSG_FIRSTHDR(&msg);
if !cmsg.is_null() {
(*cmsg).cmsg_level = libc::IPPROTO_IP;
(*cmsg).cmsg_type = libc::IP_PKTINFO;
(*cmsg).cmsg_len = libc::CMSG_LEN(bytes) as _;
let mut info: libc::in_pktinfo = std::mem::zeroed();
info.ipi_spec_dst = address;
std::ptr::copy_nonoverlapping(
std::ptr::addr_of!(info).cast::<u8>(),
libc::CMSG_DATA(cmsg),
bytes as usize,
);
}
}
libc::sendmsg(fd, &msg, 0);
}
}
fn formerr_reply(header: &[u8; 12]) -> Vec<u8> {
let mut reply = header.to_vec();
let flags = 0x8000u16 | 0x0080 | u16::from(DNS_RCODE_FORMERR);
reply[2..4].copy_from_slice(&flags.to_be_bytes());
reply[4..12].copy_from_slice(&[0; 8]);
reply
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
use std::sync::Arc;
use super::*;
use crate::dns_resolver::scripted::{Behaviour, ScriptedResolver};
use crate::dns_resolver::{DnsResolver, NoAnswer};
use crate::network_policy::parse_network_allow_rules;
fn rules(entries: &[&str]) -> Vec<NetworkAllowRule> {
let owned: Vec<String> = entries.iter().map(|entry| (*entry).to_string()).collect();
parse_network_allow_rules(&owned).unwrap()
}
fn policy(entries: &[&str], ips: &[IpAddr]) -> EgressPolicy {
EgressPolicy::new(rules(entries), ips.to_vec())
}
const ADDRESS: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7));
const OTHER_ADDRESS: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 8));
#[test]
fn an_https_entry_opens_only_443() {
assert_eq!(
egress_listen_ports(&rules(&["https://api.example.com"])),
vec![443]
);
}
#[test]
fn an_http_entry_opens_only_80() {
assert_eq!(
egress_listen_ports(&rules(&["http://api.example.com"])),
vec![80]
);
}
#[test]
fn a_bare_host_entry_opens_both_scheme_defaults() {
assert_eq!(
egress_listen_ports(&rules(&["api.example.com"])),
vec![80, 443]
);
}
#[test]
fn an_explicit_port_opens_exactly_that_port() {
assert_eq!(
egress_listen_ports(&rules(&["api.example.com:8443"])),
vec![8443]
);
}
#[test]
fn ports_are_deduplicated_and_sorted() {
let ports = egress_listen_ports(&rules(&[
"https://a.example.com",
"https://b.example.com",
"http://c.example.com",
]));
assert_eq!(ports, vec![80, 443]);
}
#[test]
fn an_empty_allowlist_opens_no_listener_at_all() {
assert!(egress_listen_ports(&[]).is_empty());
}
#[test]
fn the_port_count_is_capped_so_the_pre_exec_fd_array_cannot_overflow() {
let entries: Vec<String> = (1..=40)
.map(|port| format!("api.example.com:{}", 9000 + port))
.collect();
let parsed = parse_network_allow_rules(&entries).unwrap();
assert_eq!(egress_listen_ports(&parsed).len(), MAX_EGRESS_TCP_PORTS);
}
#[test]
fn a_listed_name_bound_to_the_destination_is_allowed() {
let policy = policy(&["https://api.example.com"], &[]);
assert!(policy.allows_connection(&["api.example.com".to_string()], ADDRESS, 443));
}
#[test]
fn an_unlisted_name_bound_to_the_destination_is_refused() {
let policy = policy(&["https://api.example.com"], &[]);
assert!(!policy.allows_connection(&["evil.example.com".to_string()], ADDRESS, 443));
}
#[test]
fn a_scheme_pinned_entry_refuses_the_other_scheme() {
let policy = policy(&["https://api.example.com"], &[]);
assert!(!policy.allows_connection(&["api.example.com".to_string()], ADDRESS, 80));
}
#[test]
fn a_bare_host_entry_spans_both_schemes_and_ports() {
let policy = policy(&["api.example.com"], &[]);
let names = vec!["api.example.com".to_string()];
assert!(policy.allows_connection(&names, ADDRESS, 443));
assert!(policy.allows_connection(&names, ADDRESS, 80));
}
#[test]
fn a_port_pinned_entry_refuses_another_port() {
let policy = policy(&["api.example.com:8443"], &[]);
let names = vec!["api.example.com".to_string()];
assert!(policy.allows_connection(&names, ADDRESS, 8443));
assert!(!policy.allows_connection(&names, ADDRESS, 443));
}
#[test]
fn a_scheme_bearing_entry_with_an_explicit_port_is_reachable_on_that_port() {
let policy = policy(&["https://api.example.com:8443"], &[]);
let names = vec!["api.example.com".to_string()];
assert_eq!(
egress_listen_ports(&rules(&["https://api.example.com:8443"])),
vec![8443]
);
assert!(policy.allows_connection(&names, ADDRESS, 8443));
assert!(!policy.allows_connection(&names, ADDRESS, 443));
assert!(!policy.allows_connection(&names, ADDRESS, 80));
}
#[test]
fn an_http_entry_with_an_explicit_port_is_reachable_on_that_port() {
let policy = policy(&["http://api.example.com:8080"], &[]);
let names = vec!["api.example.com".to_string()];
assert!(policy.allows_connection(&names, ADDRESS, 8080));
assert!(!policy.allows_connection(&names, ADDRESS, 80));
}
#[test]
fn a_shared_address_is_decided_by_whichever_bound_name_is_listed() {
let policy = policy(&["https://api.example.com"], &[]);
let both = vec![
"api.example.com".to_string(),
"other.example.com".to_string(),
];
assert!(policy.allows_connection(&both, ADDRESS, 443));
assert!(!policy.allows_connection(&["other.example.com".to_string()], ADDRESS, 443));
}
#[test]
fn an_unbound_address_falls_back_to_the_launch_time_ip_set() {
let policy = policy(&["api.example.com"], &[ADDRESS]);
assert!(policy.allows_connection(&[], ADDRESS, 443));
assert!(!policy.allows_connection(&[], OTHER_ADDRESS, 443));
}
#[test]
fn an_empty_allowlist_permits_nothing() {
let policy = policy(&[], &[]);
assert!(!policy.allows_connection(&["api.example.com".to_string()], ADDRESS, 443));
assert!(!policy.allows_connection(&[], ADDRESS, 443));
assert!(!policy.allows_name("api.example.com"));
}
#[test]
fn a_name_is_matched_case_insensitively_and_without_the_root_label() {
let policy = policy(&["https://api.example.com"], &[]);
assert!(policy.allows_name("API.Example.COM."));
assert!(policy.allows_connection(&["API.Example.COM.".to_string()], ADDRESS, 443));
}
#[test]
fn the_resolver_ignores_port_and_scheme_but_the_connection_does_not() {
let policy = policy(&["https://api.example.com:443"], &[]);
assert!(policy.allows_name("api.example.com"));
assert!(!policy.allows_connection(&["api.example.com".to_string()], ADDRESS, 8443));
}
#[test]
fn the_resolver_never_resolves_an_address_literal() {
let policy = policy(&["203.0.113.7"], &[ADDRESS]);
assert!(!policy.allows_name("203.0.113.7"));
}
#[test]
fn a_recorded_binding_is_readable_for_every_address_of_the_name() {
let resolved = ResolvedNames::default();
resolved.record("api.example.com", &[ADDRESS, OTHER_ADDRESS]);
assert_eq!(resolved.names_for(ADDRESS), vec!["api.example.com"]);
assert_eq!(resolved.names_for(OTHER_ADDRESS), vec!["api.example.com"]);
}
#[test]
fn an_address_no_query_produced_has_no_names() {
let resolved = ResolvedNames::default();
assert!(resolved.names_for(ADDRESS).is_empty());
}
#[test]
fn recording_the_same_name_twice_does_not_duplicate_it() {
let resolved = ResolvedNames::default();
resolved.record("api.example.com", &[ADDRESS]);
resolved.record("api.example.com", &[ADDRESS]);
assert_eq!(resolved.names_for(ADDRESS).len(), 1);
}
fn query_bytes(name: &str, qtype: u16) -> Vec<u8> {
let mut message = Vec::new();
message.extend_from_slice(&0x1234u16.to_be_bytes());
message.extend_from_slice(&0x0100u16.to_be_bytes()); message.extend_from_slice(&1u16.to_be_bytes());
message.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
for label in name.split('.') {
message.push(label.len() as u8);
message.extend_from_slice(label.as_bytes());
}
message.push(0);
message.extend_from_slice(&qtype.to_be_bytes());
message.extend_from_slice(&1u16.to_be_bytes()); message
}
#[test]
fn a_dns_query_parses_into_its_name_and_type() {
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_A)).unwrap();
assert_eq!(query.id, 0x1234);
assert!(query.recursion_desired);
assert_eq!(query.name, "api.example.com");
assert_eq!(query.qtype, DNS_TYPE_A);
}
#[test]
fn a_truncated_or_response_shaped_message_is_rejected() {
assert_eq!(parse_dns_query(&[0u8; 4]), None);
let mut response = query_bytes("api.example.com", DNS_TYPE_A);
response[2] |= 0x80; assert_eq!(parse_dns_query(&response), None);
}
#[test]
fn a_compression_pointer_in_a_question_is_rejected() {
let mut message = query_bytes("api.example.com", DNS_TYPE_A);
message[12] = 0xc0;
assert_eq!(parse_dns_query(&message), None);
}
#[test]
fn an_unlisted_name_is_refused_with_an_answer_not_silence() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("evil.example.com", DNS_TYPE_A)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(vec![ADDRESS])
});
assert_eq!(
u16::from_be_bytes([reply[0], reply[1]]),
0x1234,
"the reply must answer the query it was asked"
);
assert!(reply[2] & 0x80 != 0, "QR must be set — this is a response");
assert_eq!(reply[3] & 0x0f, DNS_RCODE_REFUSED);
assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 0, "no answers");
}
#[test]
fn an_unlisted_name_leaves_no_binding_behind() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("evil.example.com", DNS_TYPE_A)).unwrap();
answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(vec![ADDRESS])
});
assert!(resolved.names_for(ADDRESS).is_empty());
}
#[test]
fn a_listed_name_is_resolved_forwarded_and_bound() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_A)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(vec![ADDRESS])
});
assert_eq!(reply[3] & 0x0f, DNS_RCODE_NOERROR);
assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 1);
assert_eq!(&reply[reply.len() - 4..], &[203, 0, 113, 7]);
assert_eq!(resolved.names_for(ADDRESS), vec!["api.example.com"]);
}
#[test]
fn a_listed_name_that_does_not_resolve_is_nxdomain() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_A)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| Resolution::DoesNotExist);
assert_eq!(reply[3] & 0x0f, DNS_RCODE_NXDOMAIN);
}
#[test]
fn a_listed_name_nothing_answered_for_is_servfail_and_leaves_no_binding() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_A)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::DidNotAnswer(NoAnswer::DeadlineElapsed)
});
assert_eq!(reply[3] & 0x0f, DNS_RCODE_SERVFAIL);
assert_eq!(DNS_RCODE_SERVFAIL, 2);
assert_ne!(DNS_RCODE_SERVFAIL, DNS_RCODE_NXDOMAIN);
assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 0, "no answers");
assert!(resolved.names_for(ADDRESS).is_empty());
}
#[test]
fn a_listed_name_with_no_address_record_is_noerror_not_nxdomain() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_A)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(Vec::new())
});
assert_eq!(reply[3] & 0x0f, DNS_RCODE_NOERROR);
assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 0);
assert!(resolved.names_for(ADDRESS).is_empty());
}
#[test]
fn a_txt_query_for_a_listed_name_carries_no_answer() {
let policy = policy(&["https://api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", 16)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(vec![ADDRESS])
});
assert_eq!(reply[3] & 0x0f, DNS_RCODE_NOERROR);
assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 0);
}
#[test]
fn an_aaaa_query_answers_empty_so_a_dual_stack_client_falls_back_to_ipv4() {
let policy = policy(&["api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_AAAA)).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(vec![ADDRESS])
});
assert_eq!(u16::from_be_bytes([reply[6], reply[7]]), 0);
assert_eq!(
reply[3] & 0x0f,
DNS_RCODE_NOERROR,
"the name exists, just not in a family this namespace routes"
);
}
#[test]
fn an_aaaa_query_still_binds_the_ipv4_address_it_learned() {
let policy = policy(&["api.example.com"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("api.example.com", DNS_TYPE_AAAA)).unwrap();
answer_dns_query(&policy, &resolved, &query, |_| {
Resolution::Resolved(vec![ADDRESS])
});
assert_eq!(resolved.names_for(ADDRESS), vec!["api.example.com"]);
}
#[test]
fn the_response_echoes_the_question_verbatim() {
let policy = policy(&["api.example.com"], &[]);
let resolved = ResolvedNames::default();
let raw = query_bytes("api.example.com", DNS_TYPE_A);
let query = parse_dns_query(&raw).unwrap();
let reply = answer_dns_query(&policy, &resolved, &query, |_| Resolution::DoesNotExist);
assert_eq!(&reply[12..12 + query.question.len()], &raw[12..]);
}
#[test]
fn a_blackholed_name_is_servfail_and_a_missing_one_is_nxdomain() {
let blackhole = ScriptedResolver::start(&[], Behaviour::Blackhole).unwrap();
let missing = ScriptedResolver::start(&[], Behaviour::DoesNotExist).unwrap();
let slow = DnsResolver::from_config(blackhole.config()).unwrap();
let absent = DnsResolver::from_config(missing.config()).unwrap();
let policy = policy(&["slow.example.test", "gone.example.test"], &[]);
let resolved = ResolvedNames::default();
let query = parse_dns_query(&query_bytes("slow.example.test", DNS_TYPE_A)).unwrap();
let started = Instant::now();
let blackholed = answer_dns_query(&policy, &resolved, &query, |name| slow.resolve(name));
let waited = started.elapsed();
assert_eq!(blackholed[3] & 0x0f, DNS_RCODE_SERVFAIL);
assert!(
waited > Duration::from_secs(4) && waited < Duration::from_secs(8),
"the blackholed lookup must be given its whole deadline and no more, waited {waited:?}"
);
let query = parse_dns_query(&query_bytes("gone.example.test", DNS_TYPE_A)).unwrap();
let started = Instant::now();
let gone = answer_dns_query(&policy, &resolved, &query, |name| absent.resolve(name));
let waited = started.elapsed();
assert_eq!(gone[3] & 0x0f, DNS_RCODE_NXDOMAIN);
assert!(
waited < Duration::from_secs(1),
"an answered lookup must not wait on a deadline, waited {waited:?}"
);
assert_ne!(blackholed[3] & 0x0f, gone[3] & 0x0f);
assert!(
blackholed.len() >= 12 && gone.len() >= 12,
"neither is silence"
);
}
#[cfg(target_os = "linux")]
fn serve_dns_on_loopback(
upstream: &ScriptedResolver,
entries: &[&str],
) -> (
std::net::SocketAddr,
Arc<std::sync::atomic::AtomicBool>,
std::thread::JoinHandle<()>,
) {
use std::net::{Ipv4Addr, UdpSocket};
use std::sync::atomic::AtomicBool;
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let address = socket.local_addr().unwrap();
let policy = policy(entries, &[]);
let names = DnsResolver::from_config(upstream.config()).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let thread = {
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
linux::serve_dns(
socket,
policy,
Arc::new(ResolvedNames::default()),
stop,
Some(names),
);
})
};
(address, stop, thread)
}
#[cfg(target_os = "linux")]
#[test]
fn a_slow_name_does_not_hold_up_the_one_asked_after_it() {
use std::net::{Ipv4Addr, UdpSocket};
let upstream = ScriptedResolver::start(
&[
("slow.example.test.", Behaviour::Blackhole),
("fast.example.test.", Behaviour::Answer(vec![ADDRESS])),
],
Behaviour::DoesNotExist,
)
.unwrap();
let (server, stop, thread) =
serve_dns_on_loopback(&upstream, &["slow.example.test", "fast.example.test"]);
let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
client
.set_read_timeout(Some(Duration::from_secs(15)))
.unwrap();
let started = Instant::now();
client
.send_to(&query_bytes("slow.example.test", DNS_TYPE_A), server)
.unwrap();
client
.send_to(&query_bytes("fast.example.test", DNS_TYPE_A), server)
.unwrap();
let mut buffer = [0u8; 512];
let (len, _) = client.recv_from(&mut buffer).unwrap();
let first = started.elapsed();
assert_eq!(
buffer[3] & 0x0f,
DNS_RCODE_NOERROR,
"the first reply back must be the name that resolved"
);
assert_eq!(u16::from_be_bytes([buffer[6], buffer[7]]), 1);
assert_eq!(&buffer[len - 4..len], &[203, 0, 113, 7]);
assert!(
first < Duration::from_secs(1),
"the fast name waited on the slow one, {first:?}"
);
let (_, _) = client.recv_from(&mut buffer).unwrap();
let second = started.elapsed();
assert_eq!(buffer[3] & 0x0f, DNS_RCODE_SERVFAIL);
assert!(
second > Duration::from_secs(4) && second < Duration::from_secs(8),
"the blackholed name must answer at its deadline, {second:?}"
);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
thread.join().unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn an_unlisted_name_is_refused_without_its_qname_reaching_an_upstream() {
use std::net::{Ipv4Addr, UdpSocket};
let upstream = ScriptedResolver::start(&[], Behaviour::Blackhole).unwrap();
let (server, stop, thread) = serve_dns_on_loopback(&upstream, &["listed.example.test"]);
let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
client
.set_read_timeout(Some(Duration::from_secs(15)))
.unwrap();
let started = Instant::now();
client
.send_to(
&query_bytes("secret-payload.evil.example", DNS_TYPE_A),
server,
)
.unwrap();
client
.send_to(&query_bytes("listed.example.test", 16), server)
.unwrap();
let mut buffer = [0u8; 512];
let (_, _) = client.recv_from(&mut buffer).unwrap();
assert_eq!(buffer[3] & 0x0f, DNS_RCODE_REFUSED);
let (_, _) = client.recv_from(&mut buffer).unwrap();
assert_eq!(buffer[3] & 0x0f, DNS_RCODE_NOERROR);
let waited = started.elapsed();
assert!(
waited < Duration::from_secs(1),
"a query answered from policy alone must not wait on a lookup deadline, {waited:?}"
);
assert_eq!(
upstream.asked(),
Vec::new(),
"no query the allowlist refuses may reach an upstream nameserver"
);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
thread.join().unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn shutdown_does_not_wait_on_a_lookup_in_flight() {
use std::net::{Ipv4Addr, UdpSocket};
let upstream = ScriptedResolver::start(&[], Behaviour::Blackhole).unwrap();
let (server, stop, thread) = serve_dns_on_loopback(&upstream, &["slow.example.test"]);
let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
client
.send_to(&query_bytes("slow.example.test", DNS_TYPE_A), server)
.unwrap();
std::thread::sleep(Duration::from_millis(300));
let started = Instant::now();
stop.store(true, std::sync::atomic::Ordering::SeqCst);
thread.join().unwrap();
let waited = started.elapsed();
assert!(
waited < Duration::from_secs(1),
"teardown is bounded by the poll interval, not by the lookup deadline, {waited:?}"
);
}
}