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