Skip to content

Commit 1ce3f48

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

1 file changed

Lines changed: 138 additions & 7 deletions

File tree

src/userspace_helper.rs

Lines changed: 138 additions & 7 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 dns_collision =
2148+
macos_dns_server_in_local_subnet(&inputs.dns_servers, &route_fingerprint.local_subnets);
2149+
let mut dns_header = format!(
21032150
"DNS tunnel_servers={} primary_service={}",
21042151
format_list(&inputs.dns_servers),
21052152
dns_fingerprint.primary_service.as_deref().unwrap_or("none")
2106-
));
2153+
);
2154+
if let Some((server, (subnet_ip, subnet_prefix))) = dns_collision {
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",
@@ -2113,7 +2166,7 @@ fn format_macos_network_overview_table(
21132166
"ORIGINAL DNS",
21142167
"STATUS",
21152168
],
2116-
&macos_dns_overview_rows(inputs, owned_dns, dns_fingerprint),
2169+
&macos_dns_overview_rows(inputs, owned_dns, dns_fingerprint, dns_collision.is_some()),
21172170
macos_log_table_ansi_enabled(),
21182171
));
21192172
lines.join("\n")
@@ -2151,6 +2204,7 @@ fn macos_dns_overview_rows(
21512204
inputs: &MacosReconcileInputs,
21522205
owned_dns: &[MacosDnsServiceState],
21532206
dns_fingerprint: &MacosDnsFingerprint,
2207+
dns_unreachable: bool,
21542208
) -> Vec<(Vec<String>, bool)> {
21552209
if dns_fingerprint.services.is_empty() {
21562210
return vec![(
@@ -2166,7 +2220,13 @@ fn macos_dns_overview_rows(
21662220
)];
21672221
}
21682222

2169-
let targets = dns_target_services(DNS_POLICY, dns_fingerprint);
2223+
// A collision pulls tunnel DNS back, so nothing is targeted; reflect that here
2224+
// too, otherwise the table contradicts the reconciler's actual decision.
2225+
let targets = if dns_unreachable {
2226+
Vec::new()
2227+
} else {
2228+
dns_target_services(DNS_POLICY, dns_fingerprint)
2229+
};
21702230
dns_fingerprint
21712231
.services
21722232
.iter()
@@ -2186,6 +2246,9 @@ fn macos_dns_overview_rows(
21862246
(true, false, true) => "already tunnel DNS",
21872247
(true, false, false) => "target; not owned",
21882248
(false, true, _) => "owned; pending restore",
2249+
// Live DNS still shows the tunnel server, but it now sits on the
2250+
// local LAN — that address is no longer the VPN resolver.
2251+
(false, false, true) if dns_unreachable => "stale tunnel DNS on LAN",
21892252
(false, false, _) => "not targeted",
21902253
};
21912254
(
@@ -2204,7 +2267,9 @@ fn macos_dns_overview_rows(
22042267
.map_or_else(|| "empty".to_string(), |servers| format_list(servers)),
22052268
status.to_string(),
22062269
],
2207-
status == "not targeted",
2270+
// Grey out inactive rows: everything when tunnel DNS is pulled
2271+
// back (the whole block is dormant), else just untargeted services.
2272+
dns_unreachable || status == "not targeted",
22082273
)
22092274
})
22102275
.collect()
@@ -2554,6 +2619,72 @@ mod tests {
25542619
assert!(macos_route_excluded_by_local_subnet(&gatewayed, &[local]).is_none());
25552620
}
25562621

2622+
#[test]
2623+
fn dns_server_collision_with_local_subnet_is_detected() {
2624+
let lan = subnet("55.56.57.0/24");
2625+
2626+
// Tunnel DNS sits inside the joined LAN -> unreachable through the tunnel.
2627+
let hit = macos_dns_server_in_local_subnet(&["55.56.57.2".to_string()], &[lan]);
2628+
assert_eq!(hit, Some(("55.56.57.2".parse().unwrap(), lan)));
2629+
2630+
// A DNS server outside every connected subnet is fine.
2631+
assert!(
2632+
macos_dns_server_in_local_subnet(&["9.9.9.9".to_string()], &[lan]).is_none(),
2633+
"off-LAN DNS must not be treated as a collision"
2634+
);
2635+
2636+
// No connected subnets at all -> never a collision.
2637+
assert!(macos_dns_server_in_local_subnet(&["55.56.57.2".to_string()], &[]).is_none());
2638+
2639+
// Any colliding server in the list triggers pull-back; reports the first.
2640+
let mixed = macos_dns_server_in_local_subnet(
2641+
&["9.9.9.9".to_string(), "55.56.57.2".to_string()],
2642+
&[lan],
2643+
);
2644+
assert_eq!(mixed, Some(("55.56.57.2".parse().unwrap(), lan)));
2645+
}
2646+
2647+
#[test]
2648+
fn overview_relabels_unreachable_dns_instead_of_claiming_it_is_active() {
2649+
let mut inputs = split_inputs();
2650+
inputs.dns_servers = vec!["55.56.57.2".to_string()];
2651+
// Every service shows the tunnel DNS; none captured (owned) by us.
2652+
let fp = dns_fp(
2653+
Some("Wi-Fi"),
2654+
&[
2655+
("Wi-Fi", Some(&["55.56.57.2"])),
2656+
("Thunderbolt Bridge", Some(&["55.56.57.2"])),
2657+
],
2658+
);
2659+
let status = |rows: &[(Vec<String>, bool)], svc: &str| -> (String, bool) {
2660+
rows.iter()
2661+
.find(|(cols, _)| cols[0] == svc)
2662+
.map(|(cols, dimmed)| (cols[5].clone(), *dimmed))
2663+
.expect("service row present")
2664+
};
2665+
2666+
// Reachable: the primary genuinely shows tunnel DNS; a secondary is just
2667+
// untargeted (and dimmed) under PrimaryOnly.
2668+
let healthy = macos_dns_overview_rows(&inputs, &[], &fp, false);
2669+
assert_eq!(status(&healthy, "Wi-Fi"), ("already tunnel DNS".into(), false));
2670+
assert_eq!(
2671+
status(&healthy, "Thunderbolt Bridge"),
2672+
("not targeted".into(), true)
2673+
);
2674+
2675+
// Collision: the same address is now a LAN host, so no row may claim the
2676+
// tunnel DNS is in use, and the whole dormant block is dimmed.
2677+
let pulled_back = macos_dns_overview_rows(&inputs, &[], &fp, true);
2678+
assert_eq!(
2679+
status(&pulled_back, "Wi-Fi"),
2680+
("stale tunnel DNS on LAN".into(), true)
2681+
);
2682+
assert_eq!(
2683+
status(&pulled_back, "Thunderbolt Bridge"),
2684+
("stale tunnel DNS on LAN".into(), true)
2685+
);
2686+
}
2687+
25572688
// --- DNS reconciler ---
25582689

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

0 commit comments

Comments
 (0)