Skip to content

Commit e7c4a9b

Browse files
committed
Add forced DNS reconciliation for macOS collisions
Handle transitions where network changes create or clear DNS collisions without changing the system DNS fingerprint. This ensures services are properly restored when entering a collision and re-asserted when leaving one.
1 parent 5efd8fc commit e7c4a9b

1 file changed

Lines changed: 69 additions & 20 deletions

File tree

src/userspace_helper.rs

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,6 +1903,25 @@ fn capture_macos_dns_service(
19031903
})
19041904
}
19051905

1906+
/// Whether a DNS reconcile must act even though the observed fingerprint is
1907+
/// unchanged. A collision (a directly-connected LAN that shadows the tunnel
1908+
/// resolver) transitions ownership without necessarily moving the fingerprint,
1909+
/// because DHCP-provided resolvers are invisible to `networksetup -getdnsservers`
1910+
/// — joining or leaving such a LAN can leave every service's observed DNS
1911+
/// untouched. Two symmetric transitions:
1912+
/// * `must_pull_back`: a collision means we own services that now need
1913+
/// restoring to their originals.
1914+
/// * `must_reassert`: a cleared collision means services we should own are
1915+
/// currently unowned and need tunnel DNS (re-)applied. Without this, a
1916+
/// pull-back would never be undone until some other fingerprint-visible
1917+
/// event happened to knock it loose.
1918+
#[cfg(target_os = "macos")]
1919+
fn dns_reconcile_forced(collision_present: bool, owned: &[String], targets: &[String]) -> bool {
1920+
let must_pull_back = collision_present && !owned.is_empty();
1921+
let must_reassert = !collision_present && targets.iter().any(|t| !owned.contains(t));
1922+
must_pull_back || must_reassert
1923+
}
1924+
19061925
/// Re-apply DNS when the host DNS environment changed (roam, DHCP renewal, a new
19071926
/// or vanished service, a primary-service change). Cheap and silent when nothing
19081927
/// changed; expressive when it acts. Mirrors `macos_reconcile_routes`.
@@ -1939,10 +1958,22 @@ fn macos_reconcile_dns(state: &MacosCleanupState) -> bool {
19391958
.dns
19401959
.lock()
19411960
.unwrap_or_else(|poisoned| poisoned.into_inner());
1942-
// Normally skip when the DNS environment is unchanged. But still act when a
1943-
// collision means we own services that now need restoring to their originals.
1944-
let must_pull_back = collision.is_some() && !dns.services.is_empty();
1945-
if fingerprint == dns.fingerprint && !must_pull_back {
1961+
1962+
// On a collision, target nothing so every owned service is restored to its
1963+
// original DNS rather than left pointing at an unreachable (or
1964+
// LAN-impersonatable) address.
1965+
let owned: Vec<String> = dns.services.iter().map(|s| s.service.clone()).collect();
1966+
let targets = match &collision {
1967+
Some(_) => Vec::new(),
1968+
None => dns_target_services(DNS_POLICY, &fingerprint),
1969+
};
1970+
1971+
// Normally skip when the DNS environment is unchanged, but still act when a
1972+
// collision transition changed required ownership without moving the
1973+
// fingerprint (see `dns_reconcile_forced`).
1974+
if fingerprint == dns.fingerprint
1975+
&& !dns_reconcile_forced(collision.is_some(), &owned, &targets)
1976+
{
19461977
return false; // cheap identity check, same guard as routes.
19471978
}
19481979

@@ -1956,22 +1987,15 @@ fn macos_reconcile_dns(state: &MacosCleanupState) -> bool {
19561987
"userspace_helper_dns_change_detected"
19571988
);
19581989

1959-
// On a collision, target nothing so every owned service is restored to its
1960-
// original DNS rather than left pointing at an unreachable (or
1961-
// LAN-impersonatable) address.
1962-
let targets = match collision {
1963-
Some((server, (subnet_ip, subnet_prefix))) => {
1964-
warn!(
1965-
interface = inputs.interface,
1966-
dns_server = %server,
1967-
local_subnet = %format!("{subnet_ip}/{subnet_prefix}"),
1968-
"userspace_helper_dns_unreachable_local_lan"
1969-
);
1970-
Vec::new()
1971-
}
1972-
None => dns_target_services(DNS_POLICY, &fingerprint),
1973-
};
1974-
let owned: Vec<String> = dns.services.iter().map(|s| s.service.clone()).collect();
1990+
if let Some((server, (subnet_ip, subnet_prefix))) = &collision {
1991+
warn!(
1992+
interface = inputs.interface,
1993+
dns_server = %server,
1994+
local_subnet = %format!("{subnet_ip}/{subnet_prefix}"),
1995+
"userspace_helper_dns_unreachable_local_lan"
1996+
);
1997+
}
1998+
19751999
let actions = plan_dns_actions(&inputs.dns_servers, &fingerprint, &owned, &targets);
19762000

19772001
let (mut applied, mut captured, mut restored, mut dropped, mut errors) =
@@ -3279,6 +3303,31 @@ resolver #1
32793303
assert_eq!(actions, DnsActions::default());
32803304
}
32813305

3306+
#[test]
3307+
fn dns_reconcile_forced_covers_collision_transitions() {
3308+
let wifi = || vec!["Wi-Fi".to_string()];
3309+
3310+
// Steady state, no collision, already own the target: guard may skip.
3311+
assert!(!dns_reconcile_forced(false, &wifi(), &wifi()));
3312+
3313+
// Entering a colliding LAN while we own services: must pull back, even
3314+
// though the fingerprint need not have moved.
3315+
assert!(dns_reconcile_forced(true, &wifi(), &[]));
3316+
3317+
// In a collision but owning nothing: nothing to restore, don't force.
3318+
assert!(!dns_reconcile_forced(true, &[], &[]));
3319+
3320+
// Leaving a collision: we own nothing but should own the primary again.
3321+
// This is the roam-off-collision case that previously never re-applied.
3322+
assert!(dns_reconcile_forced(false, &[], &wifi()));
3323+
3324+
// No collision and every target already owned: don't force.
3325+
assert!(!dns_reconcile_forced(false, &wifi(), &wifi()));
3326+
3327+
// No collision, no targets (e.g. VPN promotes no DNS): don't force.
3328+
assert!(!dns_reconcile_forced(false, &[], &[]));
3329+
}
3330+
32823331
#[test]
32833332
fn plan_dns_adopts_leaked_tunnel_dns_on_unowned_target() {
32843333
// A prior run left tunnel DNS on the primary but we don't own it (its

0 commit comments

Comments
 (0)