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