diff options
| author | David Lönnhager <david.l@mullvad.net> | 2024-01-26 09:37:50 +0100 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2024-10-03 15:53:55 +0200 |
| commit | a83f25cb6ef2eeea26fa0baa2939bce928b25fb8 (patch) | |
| tree | d03a1199468dd016860c07e2cdec12c7d9ced21d /talpid-core/src | |
| parent | 421b5dc09a57b60cc2cef1c00cd64b0c5049b73f (diff) | |
| download | mullvadvpn-a83f25cb6ef2eeea26fa0baa2939bce928b25fb8.tar.xz mullvadvpn-a83f25cb6ef2eeea26fa0baa2939bce928b25fb8.zip | |
Send DNS queries via local resolver on macOS
Diffstat (limited to 'talpid-core/src')
| -rw-r--r-- | talpid-core/src/firewall/macos.rs | 9 | ||||
| -rw-r--r-- | talpid-core/src/firewall/mod.rs | 8 | ||||
| -rw-r--r-- | talpid-core/src/resolver.rs | 347 | ||||
| -rw-r--r-- | talpid-core/src/tunnel_state_machine/connected_state.rs | 18 | ||||
| -rw-r--r-- | talpid-core/src/tunnel_state_machine/connecting_state.rs | 13 | ||||
| -rw-r--r-- | talpid-core/src/tunnel_state_machine/disconnected_state.rs | 4 |
6 files changed, 343 insertions, 56 deletions
diff --git a/talpid-core/src/firewall/macos.rs b/talpid-core/src/firewall/macos.rs index ef26ebb97f..c9549fa756 100644 --- a/talpid-core/src/firewall/macos.rs +++ b/talpid-core/src/firewall/macos.rs @@ -177,6 +177,12 @@ impl Firewall { let redirect_rules = match policy { FirewallPolicy::Blocked { dns_redirect_port, .. + } + | FirewallPolicy::Connecting { + dns_redirect_port, .. + } + | FirewallPolicy::Connected { + dns_redirect_port, .. } => { vec![pfctl::RedirectRuleBuilder::default() .action(pfctl::RedirectRuleAction::Redirect) @@ -186,7 +192,6 @@ impl Firewall { .redirect_to(pfctl::Port::from(*dns_redirect_port)) .build()?] } - _ => vec![], }; Ok(redirect_rules) } @@ -204,6 +209,7 @@ impl Firewall { allowed_tunnel_traffic, redirect_interface, apple_services_bypass, + dns_redirect_port: _, } => { let mut rules = vec![self.get_allow_relay_rule(peer_endpoint)?]; rules.push(self.get_allowed_endpoint_rule(allowed_endpoint)?); @@ -253,6 +259,7 @@ impl Firewall { dns_config, redirect_interface, apple_services_bypass, + dns_redirect_port: _, } => { let mut rules = vec![]; diff --git a/talpid-core/src/firewall/mod.rs b/talpid-core/src/firewall/mod.rs index 2596f330e3..af3db8dcb3 100644 --- a/talpid-core/src/firewall/mod.rs +++ b/talpid-core/src/firewall/mod.rs @@ -98,6 +98,10 @@ pub enum FirewallPolicy { /// Flag setting if we should leak traffic to apple services. #[cfg(target_os = "macos")] apple_services_bypass: bool, + /// Destination port for DNS traffic redirection. Traffic destined to `127.0.0.1:53` will + /// be redirected to `127.0.0.1:$dns_redirect_port`. + #[cfg(target_os = "macos")] + dns_redirect_port: u16, }, /// Allow traffic only to server and over tunnel interface @@ -118,6 +122,10 @@ pub enum FirewallPolicy { /// Flag setting if we should leak traffic to apple services. #[cfg(target_os = "macos")] apple_services_bypass: bool, + /// Destination port for DNS traffic redirection. Traffic destined to `127.0.0.1:53` will + /// be redirected to `127.0.0.1:$dns_redirect_port`. + #[cfg(target_os = "macos")] + dns_redirect_port: u16, }, /// Block all network traffic in and out from the computer. diff --git a/talpid-core/src/resolver.rs b/talpid-core/src/resolver.rs index 97f1f4b79c..9fcada1a0b 100644 --- a/talpid-core/src/resolver.rs +++ b/talpid-core/src/resolver.rs @@ -1,14 +1,15 @@ use std::{ io, - net::{Ipv4Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, str::FromStr, sync::{Arc, Weak}, + time::{Duration, Instant}, }; -use std::time::{Duration, Instant}; - use futures::{ channel::{mpsc, oneshot}, + future::Either, SinkExt, StreamExt, }; @@ -24,13 +25,20 @@ use hickory_server::{ op::{header::MessageType, op_code::OpCode, Header}, rr::{domain::Name, rdata, record_data::RData, Record}, }, - resolver::lookup::Lookup, + resolver::{ + config::{NameServerConfigGroup, ResolverConfig, ResolverOpts}, + error::{ResolveError, ResolveErrorKind}, + lookup::Lookup, + TokioAsyncResolver, + }, server::{Request, RequestHandler, ResponseHandler, ResponseInfo}, ServerFuture, }; +use libc::{c_void, kill, pid_t, proc_listallpids, proc_pidpath, SIGHUP}; use std::sync::LazyLock; -const ALLOWED_RECORD_TYPES: &[RecordType] = &[RecordType::A, RecordType::CNAME]; +const ALLOWED_RECORD_TYPES: &[RecordType] = + &[RecordType::A, /*RecordType::AAAA,*/ RecordType::CNAME]; const CAPTIVE_PORTAL_DOMAINS: &[&str] = &["captive.apple.com", "netcts.cdn-apple.com"]; static ALLOWED_DOMAINS: LazyLock<Vec<LowerName>> = LazyLock::new(|| { @@ -48,7 +56,7 @@ const RESOLVED_ADDR: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 1); /// Starts a resolver. Returns a cloneable handle, which can activate, deactivate and shut down the /// resolver. When all instances of a handle are dropped, the server will stop. pub(crate) async fn start_resolver() -> Result<ResolverHandle, Error> { - let (resolver, resolver_handle) = FilteringResolver::new().await?; + let (resolver, resolver_handle) = ForwardingResolver::new().await?; tokio::spawn(resolver.run()); Ok(resolver_handle) } @@ -65,43 +73,187 @@ pub enum Error { GetSocketAddrError(#[source] io::Error), } -/// A filtering resolver. Listens on a specified port for DNS queries and responds queries for -/// `catpive.apple.com`. Can be toggled to unbind, be bound but not respond or bound and responding -/// to some queries. -struct FilteringResolver { - rx: mpsc::Receiver<ResolverMessage>, +/// A forwarding resolver +struct ForwardingResolver { + rx: mpsc::UnboundedReceiver<ResolverMessage>, dns_server: Option<(tokio::task::JoinHandle<()>, oneshot::Receiver<()>)>, + forward_resolver: LocalResolver, +} + +/// Resolver message +enum ResolverMessage { + /// Set config + SetConfig { + /// New DNS config to use + new_config: LocalConfig, + /// Response channel when resolvers have been updated + response_tx: oneshot::Sender<()>, + }, + /// Query + Query( + LowerQuery, + oneshot::Sender<std::result::Result<Box<dyn LookupObject>, ResolveError>>, + ), +} + +/// Resolver config +#[derive(Debug, Default, Clone)] +enum LocalConfig { + /// Drop DNS queries. For captive portal domains, return faux records + #[default] + Blocked, + /// Forward DNS queries to a configured server + ForwardDns { + /// Remote DNS server to use + dns_servers: Vec<IpAddr>, + }, +} + +enum LocalResolver { + /// Drop DNS queries. For captive portal domains, return faux records + Blocked, + /// Forward DNS queries to a configured server + ForwardDns(TokioAsyncResolver), +} + +impl From<LocalConfig> for LocalResolver { + fn from(config: LocalConfig) -> Self { + match config { + LocalConfig::Blocked => LocalResolver::Blocked, + LocalConfig::ForwardDns { ref dns_servers } => { + let forward_server_config = + NameServerConfigGroup::from_ips_clear(dns_servers, 53, true); + + let forward_config = + ResolverConfig::from_parts(None, vec![], forward_server_config); + let resolver_opts = ResolverOpts::default(); + + let resolver = TokioAsyncResolver::tokio(forward_config, resolver_opts); + + LocalResolver::ForwardDns(resolver) + } + } + } } -/// The `FilteringResolver` is an actor responding to DNS queries. -type ResolverMessage = (LowerQuery, oneshot::Sender<Box<dyn LookupObject>>); +impl LocalResolver { + pub fn resolve( + &self, + query: LowerQuery, + tx: oneshot::Sender<std::result::Result<Box<dyn LookupObject>, ResolveError>>, + ) { + let lookup = match self { + LocalResolver::Blocked => Either::Left(async move { Self::resolve_blocked(query) }), + LocalResolver::ForwardDns(resolver) => { + Either::Right(Self::resolve_forward(resolver.clone(), query)) + } + }; + + tokio::spawn(async move { + let _ = tx.send(lookup.await); + }); + } + + /// Resolution in blocked state will return spoofed records for captive portal domains. + fn resolve_blocked( + query: LowerQuery, + ) -> std::result::Result<Box<dyn LookupObject>, ResolveError> { + if !Self::is_captive_portal_domain(&query) { + return Ok(Box::new(EmptyLookup)); + } + + let return_query = query.original().clone(); + let mut return_record = Record::with( + return_query.name().clone(), + return_query.query_type(), + TTL_SECONDS, + ); + return_record.set_data(Some(RData::A(rdata::A(RESOLVED_ADDR)))); + + log::debug!( + "Spoofing query for captive portal domain: {}", + return_query.name() + ); + + let lookup = Lookup::new_with_deadline( + return_query, + Arc::new([return_record]), + Instant::now() + Duration::from_secs(3), + ); + Ok(Box::new(ForwardLookup(lookup)) as Box<_>) + } + + /// Determines whether a DNS query is allowable. Currently, this implies that the query is + /// either a `A` or a `CNAME` query for `captive.apple.com`. + fn is_captive_portal_domain(query: &LowerQuery) -> bool { + ALLOWED_RECORD_TYPES.contains(&query.query_type()) && ALLOWED_DOMAINS.contains(query.name()) + } + + /// Forward DNS queries to the specified DNS resolver. + async fn resolve_forward( + resolver: TokioAsyncResolver, + query: LowerQuery, + ) -> std::result::Result<Box<dyn LookupObject>, ResolveError> { + let return_query = query.original().clone(); + + let lookup = resolver + .lookup(return_query.name().clone(), return_query.query_type()) + .await; + + lookup.map(|lookup| Box::new(ForwardLookup(lookup)) as Box<_>) + } +} -/// A handle to control a filtering resolver. When all resolver handles are dropped, custom +/// A handle to control a forwarding resolver. When all resolver handles are dropped, custom /// resolver will stop. #[derive(Clone)] pub(crate) struct ResolverHandle { - _tx: Arc<mpsc::Sender<ResolverMessage>>, + tx: Arc<mpsc::UnboundedSender<ResolverMessage>>, listening_port: u16, } impl ResolverHandle { - fn new(tx: Arc<mpsc::Sender<ResolverMessage>>, listening_port: u16) -> Self { - Self { - _tx: tx, - listening_port, - } + fn new(tx: Arc<mpsc::UnboundedSender<ResolverMessage>>, listening_port: u16) -> Self { + Self { tx, listening_port } } /// Get listening port for resolver handle pub fn listening_port(&self) -> u16 { self.listening_port } + + /// Set the DNS server to forward queries to + pub async fn enable_forward(&self, dns_servers: Vec<IpAddr>) { + let dns_servers = dns_servers + .into_iter() + .filter(|addr| !addr.is_loopback()) + .collect(); + + let (response_tx, response_rx) = oneshot::channel(); + let _ = self.tx.unbounded_send(ResolverMessage::SetConfig { + new_config: LocalConfig::ForwardDns { dns_servers }, + response_tx, + }); + + let _ = response_rx.await; + } + + // Disable forwarding + pub async fn disable_forward(&self) { + let (response_tx, response_rx) = oneshot::channel(); + let _ = self.tx.unbounded_send(ResolverMessage::SetConfig { + new_config: LocalConfig::Blocked, + response_tx, + }); + + let _ = response_rx.await; + } } -impl FilteringResolver { +impl ForwardingResolver { /// Constructs a new filtering resolver and it's handle. async fn new() -> Result<(Self, ResolverHandle), Error> { - let (tx, rx) = mpsc::channel(0); + let (tx, rx) = mpsc::unbounded(); let command_tx = Arc::new(tx); let weak_tx = Arc::downgrade(&command_tx); @@ -131,9 +283,11 @@ impl FilteringResolver { let _ = server_done_tx.send(()); }); + let resolver = Self { rx, dns_server: Some((server_handle, server_done_rx)), + forward_resolver: LocalResolver::from(LocalConfig::Blocked), }; Ok((resolver, ResolverHandle::new(command_tx, port))) @@ -141,7 +295,7 @@ impl FilteringResolver { async fn new_server( port: u16, - command_tx: Weak<mpsc::Sender<ResolverMessage>>, + command_tx: Weak<mpsc::UnboundedSender<ResolverMessage>>, ) -> Result<(ServerFuture<ResolverImpl>, u16), Error> { let mut server = ServerFuture::new(ResolverImpl { tx: command_tx }); @@ -162,8 +316,22 @@ impl FilteringResolver { /// related [ResolverHandle] instances are dropped, this function will return, closing the DNS /// server. async fn run(mut self) { - while let Some((query, tx)) = self.rx.next().await { - self.resolve(query, tx); + while let Some(request) = self.rx.next().await { + match request { + ResolverMessage::SetConfig { + new_config, + response_tx, + } => { + log::debug!("Updating config: {new_config:?}"); + + self.forward_resolver = LocalResolver::from(new_config); + flush_system_cache(); + let _ = response_tx.send(()); + } + ResolverMessage::Query(query, tx) => { + self.forward_resolver.resolve(query, tx); + } + } } if let Some((server_handle, done_rx)) = self.dns_server.take() { @@ -171,35 +339,86 @@ impl FilteringResolver { let _ = done_rx.await; } } +} - /// Resolvers a query to nothing or a documentation address - fn resolve(&mut self, query: LowerQuery, tx: oneshot::Sender<Box<dyn LookupObject>>) { - if !self.allow_query(&query) { - let _ = tx.send(Box::new(EmptyLookup) as Box<dyn LookupObject>); - return; +/// Flush the DNS cache. +fn flush_system_cache() { + if let Err(error) = kill_mdnsresponder() { + log::error!("Failed to kill mDNSResponder: {error}"); + } +} + +const MDNS_RESPONDER_PATH: &str = "/usr/sbin/mDNSResponder"; + +/// Find and kill mDNSResponder. The OS will restart the service. +fn kill_mdnsresponder() -> io::Result<()> { + if let Some(mdns_pid) = pid_of_path(MDNS_RESPONDER_PATH) { + if unsafe { kill(mdns_pid as i32, SIGHUP) } != 0 { + return Err(io::Error::last_os_error()); } + } + Ok(()) +} - let return_query = query.original().clone(); - let mut return_record = Record::with( - return_query.name().clone(), - return_query.query_type(), - TTL_SECONDS, - ); - return_record.set_data(Some(RData::A(rdata::A(RESOLVED_ADDR)))); +/// Return the first process identifier matching a specified path, if one exists. +fn pid_of_path(find_path: impl AsRef<Path>) -> Option<pid_t> { + match list_pids() { + Ok(pids) => { + for pid in pids { + if let Ok(path) = process_path(pid) { + if path == find_path.as_ref() { + return Some(pid); + } + } + } + None + } + Err(error) => { + log::error!("Failed to list processes: {error}"); + None + } + } +} - let lookup = Lookup::new_with_deadline( - return_query, - Arc::new([return_record]), - Instant::now() + Duration::from_secs(3), - ); - let _ = tx.send(Box::new(ForwardLookup(lookup))); +/// Obtain a list of all pids +fn list_pids() -> io::Result<Vec<pid_t>> { + // SAFETY: Passing in null and 0 returns the number of processes + let num_pids = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; + if num_pids <= 0 { + return Err(io::Error::last_os_error()); } + let num_pids = usize::try_from(num_pids).unwrap(); + let mut pids = vec![0i32; num_pids]; - /// Determines whether a DNS query is allowable. Currently, this implies that the query is - /// either a `A`, `AAAA` or a `CNAME` query for `captive.apple.com`. - fn allow_query(&self, query: &LowerQuery) -> bool { - ALLOWED_RECORD_TYPES.contains(&query.query_type()) && ALLOWED_DOMAINS.contains(query.name()) + let buf_sz = (num_pids * std::mem::size_of::<pid_t>()) as i32; + // SAFETY: 'pids' is large enough to contain 'num_pids' processes + let num_pids = unsafe { proc_listallpids(pids.as_mut_ptr() as *mut c_void, buf_sz) }; + if num_pids == -1 { + return Err(io::Error::last_os_error()); } + + pids.resize(usize::try_from(num_pids).unwrap(), 0); + + Ok(pids) +} + +fn process_path(pid: pid_t) -> io::Result<PathBuf> { + let mut buffer = [0u8; libc::MAXPATHLEN as usize]; + // SAFETY: `proc_pidpath` returns at most `buffer.len()` bytes + let buf_len = unsafe { + proc_pidpath( + pid, + buffer.as_mut_ptr() as *mut c_void, + u32::try_from(buffer.len()).unwrap(), + ) + }; + if buf_len == -1 { + return Err(io::Error::last_os_error()); + } + Ok(PathBuf::from( + std::str::from_utf8(&buffer[0..buf_len as usize]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid process path"))?, + )) } type LookupResponse<'a> = MessageResponse< @@ -214,7 +433,7 @@ type LookupResponse<'a> = MessageResponse< /// An implementation of [hickory_server::server::RequestHandler] that forwards queries to /// `FilteringResolver`. struct ResolverImpl { - tx: Weak<mpsc::Sender<ResolverMessage>>, + tx: Weak<mpsc::UnboundedSender<ResolverMessage>>, } impl ResolverImpl { @@ -238,19 +457,37 @@ impl ResolverImpl { ) } + /// This function is called when a DNS query is sent to the local resolver async fn lookup<R: ResponseHandler>(&self, message: &Request, mut response_handler: R) { if let Some(tx_ref) = self.tx.upgrade() { let mut tx = (*tx_ref).clone(); let query = message.query(); let (lookup_tx, lookup_rx) = oneshot::channel(); - let _ = tx.send((query.clone(), lookup_tx)).await; - let lookup_result: Box<dyn LookupObject> = lookup_rx - .await - .unwrap_or_else(|_| Box::new(EmptyLookup) as Box<dyn LookupObject>); - let response = Self::build_response(message, lookup_result.as_ref()); + let _ = tx + .send(ResolverMessage::Query(query.clone(), lookup_tx)) + .await; - if let Err(err) = response_handler.send_response(response).await { - log::error!("Failed to send response: {}", err); + let lookup_result = lookup_rx.await; + let response_result = match lookup_result { + Ok(Ok(ref lookup)) => { + let response = Self::build_response(message, lookup.as_ref()); + response_handler.send_response(response).await + } + Err(_error) => return, + Ok(Err(resolve_err)) => match resolve_err.kind() { + ResolveErrorKind::NoRecordsFound { response_code, .. } => { + let response = MessageResponseBuilder::from_message_request(message) + .error_msg(message.header(), *response_code); + response_handler.send_response(response).await + } + _other => { + let response = Self::build_response(message, &EmptyLookup); + response_handler.send_response(response).await + } + }, + }; + if let Err(err) = response_result { + log::error!("Failed to send response: {err}"); } } } diff --git a/talpid-core/src/tunnel_state_machine/connected_state.rs b/talpid-core/src/tunnel_state_machine/connected_state.rs index abb97d282d..f614b58267 100644 --- a/talpid-core/src/tunnel_state_machine/connected_state.rs +++ b/talpid-core/src/tunnel_state_machine/connected_state.rs @@ -144,6 +144,8 @@ impl ConnectedState { redirect_interface, #[cfg(target_os = "macos")] apple_services_bypass: shared_values.apple_services_bypass, + #[cfg(target_os = "macos")] + dns_redirect_port: shared_values.filtering_resolver.listening_port(), } } @@ -157,18 +159,34 @@ impl ConnectedState { fn set_dns(&self, shared_values: &mut SharedTunnelStateValues) -> Result<(), BoxedError> { let dns_config = Self::resolve_dns(&self.metadata, shared_values); + #[cfg(not(target_os = "macos"))] shared_values .dns_monitor .set(&self.metadata.interface, dns_config) .map_err(BoxedError::new)?; + // On macOS, configure only the local DNS resolver + #[cfg(target_os = "macos")] + shared_values.runtime.block_on( + shared_values + .filtering_resolver + .enable_forward(dns_config.addresses().collect()), + ); + Ok(()) } fn reset_dns(shared_values: &mut SharedTunnelStateValues) { + #[cfg(not(target_os = "macos"))] if let Err(error) = shared_values.dns_monitor.reset_before_interface_removal() { log::error!("{}", error.display_chain_with_msg("Unable to reset DNS")); } + + // On macOS, configure only the local DNS resolver + #[cfg(target_os = "macos")] + shared_values + .runtime + .block_on(shared_values.filtering_resolver.disable_forward()); } fn reset_routes( diff --git a/talpid-core/src/tunnel_state_machine/connecting_state.rs b/talpid-core/src/tunnel_state_machine/connecting_state.rs index e5d9dcb6cb..783769251a 100644 --- a/talpid-core/src/tunnel_state_machine/connecting_state.rs +++ b/talpid-core/src/tunnel_state_machine/connecting_state.rs @@ -57,6 +57,17 @@ impl ConnectingState { shared_values: &mut SharedTunnelStateValues, retry_attempt: u32, ) -> (Box<dyn TunnelState>, TunnelStateTransition) { + #[cfg(target_os = "macos")] + if let Err(err) = shared_values.dns_monitor.set( + "lo", + crate::dns::DnsConfig::default().resolve(&[std::net::Ipv4Addr::LOCALHOST.into()]), + ) { + log::error!( + "{}", + err.display_chain_with_msg("Failed to configure system to use filtering resolver") + ); + } + if shared_values.connectivity.is_offline() { // FIXME: Temporary: Nudge route manager to update the default interface #[cfg(target_os = "macos")] @@ -174,6 +185,8 @@ impl ConnectingState { redirect_interface, #[cfg(target_os = "macos")] apple_services_bypass: shared_values.apple_services_bypass, + #[cfg(target_os = "macos")] + dns_redirect_port: shared_values.filtering_resolver.listening_port(), }; shared_values .firewall diff --git a/talpid-core/src/tunnel_state_machine/disconnected_state.rs b/talpid-core/src/tunnel_state_machine/disconnected_state.rs index f7bc5e712b..f66bac4e76 100644 --- a/talpid-core/src/tunnel_state_machine/disconnected_state.rs +++ b/talpid-core/src/tunnel_state_machine/disconnected_state.rs @@ -133,6 +133,10 @@ impl DisconnectedState { fn setup_local_dns_config( shared_values: &mut SharedTunnelStateValues, ) -> Result<(), dns::Error> { + shared_values + .runtime + .block_on(shared_values.filtering_resolver.disable_forward()); + shared_values.dns_monitor.set( "lo", dns::DnsConfig::default().resolve(&[Ipv4Addr::LOCALHOST.into()]), |
