Skip to content

Commit

Permalink
Merge branch 'remove-macos-apple-app-fix-nat-firewall-rules-for-macos…
Browse files Browse the repository at this point in the history
…-151-des-1359'
  • Loading branch information
MarkusPettersson98 committed Dec 2, 2024
2 parents adc7c4b + a24049a commit 8bc1412
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 114 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Line wrap the file at 100 chars. Th

#### macOS
- Fix packets being duplicated on LAN when split tunneling is enabled.
- Fix DNS issues caused by forcibly using a local DNS resolver in all states.
Note that this fix is not present on macOS versions between 14.6 and 15.1.

### Security
- Disable unix signal handler in release builds. The code was not signal safe and could potentially
Expand Down
8 changes: 7 additions & 1 deletion talpid-core/src/dns/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl State {
}
}

/// Construct [`DnsSettings`] from the arguments and apply the desired addresses to all network services.
fn apply_new_config(
&mut self,
store: &SCDynamicStore,
Expand Down Expand Up @@ -375,8 +376,9 @@ impl InterfaceSettings {
unsafe impl Send for InterfaceSettings {}

pub struct DnsMonitor {
/// The backing "System Configuration framework" store, which allow us to access and detect
/// changes to the device's network configuration.
store: SCDynamicStore,

/// The current DNS injection state. If this is `None` it means we are not injecting any DNS.
/// When it's `Some(state)` we are actively making sure `state.dns_settings` is configured
/// on all network interfaces.
Expand All @@ -403,6 +405,10 @@ impl super::DnsMonitorT for DnsMonitor {
})
}

/// Update the system config to use the DNS `config`.
///
/// Note that the `interface` parameter does nothing on macOS. Since we can't configure DNS
/// on the tunnel interface, we have to configure all interfaces.
fn set(&mut self, interface: &str, config: ResolvedDnsConfig) -> Result<()> {
let port = config.port;
let servers: Vec<_> = config.addresses().collect();
Expand Down
124 changes: 87 additions & 37 deletions talpid-core/src/firewall/macos.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
use super::{FirewallArguments, FirewallPolicy};
use std::env;
use std::io;
use std::net::{IpAddr, Ipv4Addr};
use std::ptr;
use std::sync::LazyLock;

use ipnetwork::IpNetwork;
use libc::{c_int, sysctlbyname};
use pfctl::{DropAction, FilterRuleAction, Ip, Uid};
use std::{
env, io,
net::{IpAddr, Ipv4Addr},
ptr,
};
use pfctl::{DropAction, FilterRuleAction, Ip, RedirectRule, Uid};
use subslice::SubsliceExt;
use talpid_types::net::{
self, AllowedEndpoint, AllowedTunnelTraffic, ALLOWED_LAN_MULTICAST_NETS, ALLOWED_LAN_NETS,
AllowedEndpoint, AllowedTunnelTraffic, TransportProtocol, ALLOWED_LAN_MULTICAST_NETS,
ALLOWED_LAN_NETS,
};

use super::{FirewallArguments, FirewallPolicy};

pub use pfctl::Error;

type Result<T> = std::result::Result<T, Error>;
Expand All @@ -20,6 +23,27 @@ type Result<T> = std::result::Result<T, Error>;
/// replaced by allowing the anchor name to be configured from the public API of this crate.
const ANCHOR_NAME: &str = "mullvad";

/// If NAT firewall rules should be applied to force Apple services through the tunnel.
///
/// macOS versions 14.6 <= x < 15.1 were affected by a bug where Apple services tried to bypass the
/// tunnel by going out on the physical interface instead. To mitigate this and force all traffic
/// to go through the tunnel we added NAT filtering rules to redirect traffic all deviating traffic
/// to the tunnel.
///
/// This is not something that we deem is necessary otherwise, and as such we disable NAT filtering
/// on macOS versions that are unaffected by this naughty bug, but keep it were it is necessary for
/// Apple services to function properly together with a VPN.
pub static NAT_WORKAROUND: LazyLock<bool> = LazyLock::new(|| {
use talpid_platform_metadata::MacosVersion;
let version = MacosVersion::new().expect("Could not detect macOS version");
let v = |s| MacosVersion::from_raw_version(s).unwrap();
let apply_workaround = v("14.6") <= version && version < v("15.1");
if apply_workaround {
log::debug!("Using NAT redirect workaround");
};
apply_workaround
});

pub struct Firewall {
pf: pfctl::PfCtl,
pf_was_enabled: Option<bool>,
Expand All @@ -33,7 +57,7 @@ impl Firewall {

pub fn new() -> Result<Self> {
// Allows controlling whether firewall rules should log to pflog0. Useful for debugging the
// rules.
// rules. The firewall rules can be inspected by running `tcpdump -netttti pflog0`.
let firewall_debugging = env::var("TALPID_FIREWALL_DEBUG");
let rule_logging = match firewall_debugging.as_ref().map(String::as_str) {
Ok("pass") => RuleLogging::Pass,
Expand Down Expand Up @@ -189,7 +213,9 @@ impl Firewall {
anchor_change.set_scrub_rules(Self::get_scrub_rules()?);
anchor_change.set_filter_rules(new_filter_rules);
anchor_change.set_redirect_rules(self.get_dns_redirect_rules(policy)?);
anchor_change.set_nat_rules(self.get_nat_rules(policy)?);
if *NAT_WORKAROUND {
anchor_change.set_nat_rules(self.get_nat_rules(policy)?);
}
self.pf.set_rules(ANCHOR_NAME, anchor_change)?;

Ok(())
Expand All @@ -208,24 +234,44 @@ impl Firewall {
&mut self,
policy: &FirewallPolicy,
) -> Result<Vec<pfctl::RedirectRule>> {
let redirect_rules = match policy {
FirewallPolicy::Connected { dns_config, .. } if dns_config.is_loopback() => vec![],
FirewallPolicy::Blocked {
dns_redirect_port, ..
}
| FirewallPolicy::Connecting {
dns_redirect_port, ..
/// Redirect DNS requests to `port`. Technically this redirects UDP on port 53 to `port`.
///
/// For this to work as expected, please make sure a DNS resolver is running on `port`.
fn redirect_dns_to(port: u16) -> Result<Vec<RedirectRule>> {
let redirect_dns = pfctl::RedirectRuleBuilder::default()
.action(pfctl::RedirectRuleAction::Redirect)
.interface("lo0")
.proto(pfctl::Proto::Udp)
.to(pfctl::Port::from(53))
.redirect_to(pfctl::Port::from(port))
.build()?;
Ok(vec![redirect_dns])
}

let redirect_rules = if *crate::resolver::LOCAL_DNS_RESOLVER {
match policy {
FirewallPolicy::Connected { dns_config, .. } if dns_config.is_loopback() => {
vec![]
}
FirewallPolicy::Blocked {
dns_redirect_port, ..
}
| FirewallPolicy::Connecting {
dns_redirect_port, ..
}
| FirewallPolicy::Connected {
dns_redirect_port, ..
} => redirect_dns_to(*dns_redirect_port)?,
}
| FirewallPolicy::Connected {
dns_redirect_port, ..
} => {
vec![pfctl::RedirectRuleBuilder::default()
.action(pfctl::RedirectRuleAction::Redirect)
.interface("lo0")
.proto(pfctl::Proto::Udp)
.to(pfctl::Port::from(53))
.redirect_to(pfctl::Port::from(*dns_redirect_port))
.build()?]
} else {
// Only apply redirect rules in the blocked state if we should *not* use our local DNS
// resolver, since it will be running in the blocked state to work with Apple's captive
// portal check.
match policy {
FirewallPolicy::Blocked {
dns_redirect_port, ..
} => redirect_dns_to(*dns_redirect_port)?,
FirewallPolicy::Connecting { .. } | FirewallPolicy::Connected { .. } => vec![],
}
};
Ok(redirect_rules)
Expand Down Expand Up @@ -402,7 +448,9 @@ impl Firewall {
&mut self.get_split_tunnel_rules(&tunnel.interface, redirect_interface)?,
);
} else {
rules.push(self.route_everything_to(&tunnel.interface)?);
if *NAT_WORKAROUND {
rules.push(self.route_everything_to(&tunnel.interface)?);
}
rules.extend(self.get_allow_tunnel_rules(
tunnel.interface.as_str(),
&AllowedTunnelTraffic::All,
Expand Down Expand Up @@ -530,10 +578,7 @@ impl Firewall {
Ok(rules)
}

fn get_allow_relay_rule(
&self,
relay_endpoint: &net::AllowedEndpoint,
) -> Result<pfctl::FilterRule> {
fn get_allow_relay_rule(&self, relay_endpoint: &AllowedEndpoint) -> Result<pfctl::FilterRule> {
let pfctl_proto = as_pfctl_proto(relay_endpoint.endpoint.protocol);

let mut builder = self.create_rule_builder(FilterRuleAction::Pass);
Expand Down Expand Up @@ -919,8 +964,10 @@ impl Firewall {
fn add_anchor(&mut self) -> Result<()> {
self.pf
.try_add_anchor(ANCHOR_NAME, pfctl::AnchorKind::Scrub)?;
self.pf
.try_add_anchor(ANCHOR_NAME, pfctl::AnchorKind::Nat)?;
if *NAT_WORKAROUND {
self.pf
.try_add_anchor(ANCHOR_NAME, pfctl::AnchorKind::Nat)?;
}
self.pf
.try_add_anchor(ANCHOR_NAME, pfctl::AnchorKind::Filter)?;
self.pf
Expand All @@ -931,6 +978,9 @@ impl Firewall {
fn remove_anchor(&mut self) -> Result<()> {
self.pf
.try_remove_anchor(ANCHOR_NAME, pfctl::AnchorKind::Scrub)?;
// Opportunistically remove Nat anchor.
// This won't fail because `try_remove_anchor` promises to convert
// `pfctl::Error::AnchorDoesNotExist` to an `Ok(())` value.
self.pf
.try_remove_anchor(ANCHOR_NAME, pfctl::AnchorKind::Nat)?;
self.pf
Expand All @@ -941,10 +991,10 @@ impl Firewall {
}
}

fn as_pfctl_proto(protocol: net::TransportProtocol) -> pfctl::Proto {
fn as_pfctl_proto(protocol: TransportProtocol) -> pfctl::Proto {
match protocol {
net::TransportProtocol::Udp => pfctl::Proto::Udp,
net::TransportProtocol::Tcp => pfctl::Proto::Tcp,
TransportProtocol::Udp => pfctl::Proto::Udp,
TransportProtocol::Tcp => pfctl::Proto::Tcp,
}
}

Expand Down
36 changes: 35 additions & 1 deletion talpid-core/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,40 @@ use hickory_server::{
};
use std::sync::LazyLock;

/// If a local DNS resolver should be used at all times.
///
/// This setting does not affect the error or blocked state. In those states, we will want to use
/// the local DNS resoler to work around Apple's captive portals check. Exactly how this is done is
/// documented elsewhere.
pub static LOCAL_DNS_RESOLVER: LazyLock<bool> = LazyLock::new(|| {
use talpid_platform_metadata::MacosVersion;
let version = MacosVersion::new().expect("Could not detect macOS version");
let v = |s| MacosVersion::from_raw_version(s).unwrap();
// Apple services tried to perform DNS lookups on the physical interface on some macOS
// versions, so we added redirect rules to always redirect DNS to our local DNS resolver.
// This seems to break some apps which do not like that we redirect DNS on port 53 to our local
// DNS resolver running on some other, arbitrary port, and so we disable this behaviour on
// macOS versions that are unaffected by this naughty bug.
//
// The workaround should only be applied to the affected macOS versions because some programs
// set the `skip filtering` pf flag on loopback, which meant that the pf filtering would break
// unexpectedly. We could clear the `skip filtering` flag to force pf filtering on loopback,
// but apparently it is good practice to enable `skip filtering` on loopback so we decided
// against this. Source: https://www.openbsd.org/faq/pf/filter.html
//
// It should be noted that most programs still works fine with this workaround enabled. Notably
// programs that use `getaddrinfo` would behave correctly when we redirect DNS to our local
// resolver, while some programs always used port 53 no matter what (nslookup for example).
// Also, most programs don't set the `skip filtering` pf flag on loopback, but some notable
// ones do for some reason. Orbstack is one such example, which meant that people running
// containers would run into the aforementioned issue.
let use_local_dns_resolver = v("14.6") <= version && version < v("15.1");
if use_local_dns_resolver {
log::debug!("Using local DNS resolver");
}
use_local_dns_resolver
});

const ALLOWED_RECORD_TYPES: &[RecordType] = &[RecordType::A, RecordType::CNAME];
const CAPTIVE_PORTAL_DOMAINS: &[&str] = &["captive.apple.com", "netcts.cdn-apple.com"];

Expand Down Expand Up @@ -238,7 +272,7 @@ impl ResolverHandle {
self.listening_port
}

/// Set the DNS server to forward queries to
/// Set the DNS server to forward queries to `dns_servers`
pub async fn enable_forward(&self, dns_servers: Vec<IpAddr>) {
let (response_tx, response_rx) = oneshot::channel();
let _ = self.tx.unbounded_send(ResolverMessage::SetConfig {
Expand Down
73 changes: 38 additions & 35 deletions talpid-core/src/tunnel_state_machine/connected_state.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
use super::{
AfterDisconnect, ConnectingState, DisconnectingState, ErrorState, EventConsequence,
EventResult, SharedTunnelStateValues, TunnelCommand, TunnelCommandReceiver, TunnelState,
TunnelStateTransition,
};
use crate::{
dns::ResolvedDnsConfig,
firewall::FirewallPolicy,
tunnel::{TunnelEvent, TunnelMetadata},
};
use futures::{
channel::{mpsc, oneshot},
stream::Fuse,
StreamExt,
};
use futures::channel::{mpsc, oneshot};
use futures::stream::Fuse;
use futures::StreamExt;

#[cfg(target_os = "android")]
use talpid_tunnel::tun_provider::Error;
use talpid_types::{
net::{AllowedClients, AllowedEndpoint, TunnelParameters},
tunnel::{ErrorStateCause, FirewallPolicyError},
BoxedError, ErrorExt,
};

use talpid_types::net::{AllowedClients, AllowedEndpoint, TunnelParameters};
use talpid_types::tunnel::{ErrorStateCause, FirewallPolicyError};
use talpid_types::{BoxedError, ErrorExt};

#[cfg(target_os = "macos")]
use crate::dns::DnsConfig;
use crate::dns::ResolvedDnsConfig;
use crate::firewall::FirewallPolicy;
#[cfg(target_os = "macos")]
use crate::resolver::LOCAL_DNS_RESOLVER;
#[cfg(windows)]
use crate::tunnel::TunnelMonitor;
use crate::tunnel::{TunnelEvent, TunnelMetadata};

use super::connecting_state::TunnelCloseEvent;
use super::{
AfterDisconnect, ConnectingState, DisconnectingState, ErrorState, EventConsequence,
EventResult, SharedTunnelStateValues, TunnelCommand, TunnelCommandReceiver, TunnelState,
TunnelStateTransition,
};

pub(crate) type TunnelEventsReceiver =
Fuse<mpsc::UnboundedReceiver<(TunnelEvent, oneshot::Sender<()>)>>;
Expand Down Expand Up @@ -171,27 +170,31 @@ impl ConnectedState {

// On macOS, configure only the local DNS resolver
#[cfg(target_os = "macos")]
if !dns_config.is_loopback() {
// We do not want to forward DNS queries to *our* local resolver if we do not run a local
// DNS resolver *or* if the DNS config points to a loopback address.
if dns_config.is_loopback() || !*LOCAL_DNS_RESOLVER {
log::debug!("Not enabling local DNS resolver");
shared_values
.dns_monitor
.set(&self.metadata.interface, dns_config)
.map_err(BoxedError::new)?;
} else {
log::debug!("Enabling local DNS resolver");
// Tell local DNS resolver to start forwarding DNS queries to whatever `dns_config`
// specifies as DNS.
shared_values.runtime.block_on(
shared_values
.filtering_resolver
.enable_forward(dns_config.addresses().collect()),
);
// Set system DNS to our local DNS resolver
let system_dns = DnsConfig::default().resolve(
&[std::net::Ipv4Addr::LOCALHOST.into()],
shared_values.filtering_resolver.listening_port(),
);
shared_values
.dns_monitor
.set(
"lo",
crate::dns::DnsConfig::default().resolve(
&[std::net::Ipv4Addr::LOCALHOST.into()],
shared_values.filtering_resolver.listening_port(),
),
)
.map_err(BoxedError::new)?;
} else {
log::debug!("Not enabling DNS forwarding since loopback is used");
shared_values
.dns_monitor
.set(&self.metadata.interface, dns_config)
.set("lo", system_dns)
.map_err(BoxedError::new)?;
}

Expand Down
Loading

0 comments on commit 8bc1412

Please sign in to comment.