Skip to content

Commit 94299c5

Browse files
committed
Fix VPN DNS overlap (and missing removal) with the currently connected LAN
1 parent 0fa65b3 commit 94299c5

1 file changed

Lines changed: 82 additions & 4 deletions

File tree

src/userspace_helper.rs

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,27 @@ fn dns_target_services(policy: DnsPolicy, fp: &MacosDnsFingerprint) -> Vec<Strin
17231723
}
17241724
}
17251725

1726+
/// A configured tunnel DNS server whose address falls inside a directly-connected
1727+
/// LAN subnet. When that happens the route covering the server is excluded from
1728+
/// the tunnel (see [`macos_route_excluded_by_local_subnet`]), so the server is no
1729+
/// longer reachable through the tunnel: queries would leak onto the LAN and hit
1730+
/// whatever host happens to hold that address. Returns the offending server and
1731+
/// the subnet it collides with so the reconciler can pull tunnel DNS back.
1732+
#[cfg(target_os = "macos")]
1733+
fn macos_dns_server_in_local_subnet(
1734+
dns_servers: &[String],
1735+
local_subnets: &[(IpAddr, u8)],
1736+
) -> Option<(IpAddr, (IpAddr, u8))> {
1737+
dns_servers.iter().find_map(|server| {
1738+
let ip: IpAddr = server.parse().ok()?;
1739+
let host_prefix = if ip.is_ipv4() { 32 } else { 128 };
1740+
local_subnets
1741+
.iter()
1742+
.find(|local| macos_subnet_contains(local, &(ip, host_prefix)))
1743+
.map(|local| (ip, *local))
1744+
})
1745+
}
1746+
17261747
/// The pure decision behind a DNS reconcile: given the tunnel's DNS, the freshly
17271748
/// observed environment, the services we currently own, and the services we want
17281749
/// to own, decide what to do. Kept free of I/O so it is unit-testable.
@@ -1838,11 +1859,21 @@ fn macos_reconcile_dns(state: &MacosCleanupState) -> bool {
18381859

18391860
let fingerprint = macos_current_dns_fingerprint();
18401861

1862+
// If the tunnel's own DNS server now lives inside a directly-connected LAN,
1863+
// its route is excluded from the tunnel and the server is unreachable through
1864+
// it. We must pull tunnel DNS back, but joining such a LAN need not change the
1865+
// DNS fingerprint at all, so this is computed before the identity guard below.
1866+
let local_subnets = macos_local_connected_subnets(&inputs.interface);
1867+
let collision = macos_dns_server_in_local_subnet(&inputs.dns_servers, &local_subnets);
1868+
18411869
let mut dns = state
18421870
.dns
18431871
.lock()
18441872
.unwrap_or_else(|poisoned| poisoned.into_inner());
1845-
if fingerprint == dns.fingerprint {
1873+
// Normally skip when the DNS environment is unchanged. But still act when a
1874+
// collision means we own services that now need restoring to their originals.
1875+
let must_pull_back = collision.is_some() && !dns.services.is_empty();
1876+
if fingerprint == dns.fingerprint && !must_pull_back {
18461877
return false; // cheap identity check, same guard as routes.
18471878
}
18481879

@@ -1856,7 +1887,21 @@ fn macos_reconcile_dns(state: &MacosCleanupState) -> bool {
18561887
"userspace_helper_dns_change_detected"
18571888
);
18581889

1859-
let targets = dns_target_services(DNS_POLICY, &fingerprint);
1890+
// On a collision, target nothing so every owned service is restored to its
1891+
// original DNS rather than left pointing at an unreachable (or
1892+
// LAN-impersonatable) address.
1893+
let targets = match collision {
1894+
Some((server, (subnet_ip, subnet_prefix))) => {
1895+
warn!(
1896+
interface = inputs.interface,
1897+
dns_server = %server,
1898+
local_subnet = %format!("{subnet_ip}/{subnet_prefix}"),
1899+
"userspace_helper_dns_unreachable_local_lan"
1900+
);
1901+
Vec::new()
1902+
}
1903+
None => dns_target_services(DNS_POLICY, &fingerprint),
1904+
};
18601905
let owned: Vec<String> = dns.services.iter().map(|s| s.service.clone()).collect();
18611906
let actions = plan_dns_actions(&inputs.dns_servers, &fingerprint, &owned, &targets);
18621907

@@ -2099,11 +2144,19 @@ fn format_macos_network_overview_table(
20992144
&macos_route_overview_rows(routes),
21002145
));
21012146
lines.push(String::new());
2102-
lines.push(format!(
2147+
let mut dns_header = format!(
21032148
"DNS tunnel_servers={} primary_service={}",
21042149
format_list(&inputs.dns_servers),
21052150
dns_fingerprint.primary_service.as_deref().unwrap_or("none")
2106-
));
2151+
);
2152+
if let Some((server, (subnet_ip, subnet_prefix))) =
2153+
macos_dns_server_in_local_subnet(&inputs.dns_servers, &route_fingerprint.local_subnets)
2154+
{
2155+
dns_header.push_str(&format!(
2156+
" UNREACHABLE: {server} is inside local LAN {subnet_ip}/{subnet_prefix}, tunnel DNS pulled back"
2157+
));
2158+
}
2159+
lines.push(dns_header);
21072160
lines.extend(format_table_with_dimmed_rows(
21082161
&[
21092162
"SERVICE",
@@ -2554,6 +2607,31 @@ mod tests {
25542607
assert!(macos_route_excluded_by_local_subnet(&gatewayed, &[local]).is_none());
25552608
}
25562609

2610+
#[test]
2611+
fn dns_server_collision_with_local_subnet_is_detected() {
2612+
let lan = subnet("55.56.57.0/24");
2613+
2614+
// Tunnel DNS sits inside the joined LAN -> unreachable through the tunnel.
2615+
let hit = macos_dns_server_in_local_subnet(&["55.56.57.2".to_string()], &[lan]);
2616+
assert_eq!(hit, Some(("55.56.57.2".parse().unwrap(), lan)));
2617+
2618+
// A DNS server outside every connected subnet is fine.
2619+
assert!(
2620+
macos_dns_server_in_local_subnet(&["9.9.9.9".to_string()], &[lan]).is_none(),
2621+
"off-LAN DNS must not be treated as a collision"
2622+
);
2623+
2624+
// No connected subnets at all -> never a collision.
2625+
assert!(macos_dns_server_in_local_subnet(&["55.56.57.2".to_string()], &[]).is_none());
2626+
2627+
// Any colliding server in the list triggers pull-back; reports the first.
2628+
let mixed = macos_dns_server_in_local_subnet(
2629+
&["9.9.9.9".to_string(), "55.56.57.2".to_string()],
2630+
&[lan],
2631+
);
2632+
assert_eq!(mixed, Some(("55.56.57.2".parse().unwrap(), lan)));
2633+
}
2634+
25572635
// --- DNS reconciler ---
25582636

25592637
fn dns_fp(primary: Option<&str>, observed: &[(&str, Option<&[&str]>)]) -> MacosDnsFingerprint {

0 commit comments

Comments
 (0)