@@ -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