@@ -42,11 +42,88 @@ async fn accept_mediated_dns(source: Arc<dyn NetworkMediationSource>) -> Pending
4242 }
4343}
4444
45+ /// Whether policy DNS answers AAAA queries with synthetic IPv6 addresses.
46+ ///
47+ /// When IPv6 egress is disabled, AAAA queries receive an empty successful
48+ /// answer without an upstream query so dual-stack clients fall back to A.
49+ /// That fallback cannot help on IPv6-only hosts (for example NAT64/DNS64
50+ /// networks), where the trusted resolver returns no A records at all.
51+ #[ derive( Debug , Clone , Copy , Default , PartialEq , Eq ) ]
52+ pub enum PolicyDnsIpv6Egress {
53+ /// Enable IPv6 answers only when the supervisor network namespace has an
54+ /// IPv6 default route and no IPv4 default route.
55+ #[ default]
56+ Auto ,
57+ /// Always resolve AAAA queries through the trusted resolver.
58+ Enabled ,
59+ /// Never resolve AAAA queries; answer them with NOERROR/NODATA.
60+ Disabled ,
61+ }
62+
63+ impl PolicyDnsIpv6Egress {
64+ /// Resolve the mode to a concrete decision for this supervisor.
65+ #[ must_use]
66+ pub fn resolve ( self ) -> bool {
67+ match self {
68+ Self :: Enabled => true ,
69+ Self :: Disabled => false ,
70+ Self :: Auto => ipv6_only_default_route (
71+ std:: fs:: read_to_string ( "/proc/net/route" ) . ok ( ) . as_deref ( ) ,
72+ std:: fs:: read_to_string ( "/proc/net/ipv6_route" )
73+ . ok ( )
74+ . as_deref ( ) ,
75+ ) ,
76+ }
77+ }
78+ }
79+
80+ const RTF_UP : u32 = 0x0001 ;
81+ const RTF_REJECT : u32 = 0x0200 ;
82+
83+ /// Report whether the routing tables describe an IPv6-only uplink. An
84+ /// unreadable table keeps the IPv4-only default.
85+ fn ipv6_only_default_route ( ipv4_routes : Option < & str > , ipv6_routes : Option < & str > ) -> bool {
86+ let ( Some ( ipv4_routes) , Some ( ipv6_routes) ) = ( ipv4_routes, ipv6_routes) else {
87+ return false ;
88+ } ;
89+ !has_ipv4_default_route ( ipv4_routes) && has_ipv6_default_route ( ipv6_routes)
90+ }
91+
92+ /// Parse `/proc/net/route` for a usable `0.0.0.0/0` route.
93+ fn has_ipv4_default_route ( table : & str ) -> bool {
94+ table. lines ( ) . skip ( 1 ) . any ( |line| {
95+ let fields = line. split_whitespace ( ) . collect :: < Vec < _ > > ( ) ;
96+ fields. len ( ) >= 8
97+ && fields[ 1 ] == "00000000"
98+ && fields[ 7 ] == "00000000"
99+ && route_flags_usable ( fields[ 3 ] )
100+ } )
101+ }
102+
103+ /// Parse `/proc/net/ipv6_route` for a usable `::/0` route. The kernel lists
104+ /// an unreachable `::/0` entry on `lo`, which is not an uplink.
105+ fn has_ipv6_default_route ( table : & str ) -> bool {
106+ table. lines ( ) . any ( |line| {
107+ let fields = line. split_whitespace ( ) . collect :: < Vec < _ > > ( ) ;
108+ fields. len ( ) >= 10
109+ && fields[ 0 ] . bytes ( ) . all ( |byte| byte == b'0' )
110+ && fields[ 0 ] . len ( ) == 32
111+ && fields[ 1 ] == "00"
112+ && fields[ 9 ] != "lo"
113+ && route_flags_usable ( fields[ 8 ] )
114+ } )
115+ }
116+
117+ fn route_flags_usable ( flags : & str ) -> bool {
118+ u32:: from_str_radix ( flags, 16 ) . is_ok_and ( |flags| flags & RTF_UP != 0 && flags & RTF_REJECT == 0 )
119+ }
120+
45121#[ derive( Debug , Clone ) ]
46122pub ( crate ) struct PolicyDnsRuntimeConfig {
47123 pub ( crate ) ipv4_cidr : ipnet:: Ipv4Net ,
48124 pub ( crate ) ipv6_cidr : ipnet:: Ipv6Net ,
49125 pools : SyntheticPools ,
126+ ipv6_egress : bool ,
50127}
51128
52129impl PolicyDnsRuntimeConfig {
@@ -74,8 +151,17 @@ impl PolicyDnsRuntimeConfig {
74151 ipv4_cidr,
75152 ipv6_cidr,
76153 pools,
154+ ipv6_egress : false ,
77155 } )
78156 }
157+
158+ /// Answer AAAA queries from the synthetic IPv6 pool instead of returning
159+ /// NOERROR/NODATA.
160+ #[ must_use]
161+ pub ( crate ) fn with_ipv6_egress ( mut self , enabled : bool ) -> Self {
162+ self . ipv6_egress = enabled;
163+ self
164+ }
79165}
80166
81167pub ( crate ) struct PolicyDnsRuntime {
@@ -94,6 +180,7 @@ impl PolicyDnsRuntime {
94180 mut engine_ready : tokio:: sync:: watch:: Receiver < bool > ,
95181 ) -> Result < Self > {
96182 let upstream = trusted_resolver_from_resolv_conf ( ) ?;
183+ let ipv6_egress = config. ipv6_egress ;
97184 let store = Arc :: new ( ResolvedEndpointStore :: new (
98185 StoreConfig :: new ( config. pools , MAX_MAPPINGS )
99186 . map_err ( |error| miette:: miette!( error. to_string( ) ) ) ?,
@@ -124,10 +211,12 @@ impl PolicyDnsRuntime {
124211 let timing = query. timing . clone ( ) ;
125212 let response = match query. transport {
126213 DnsTransport :: Udp => {
127- wire:: handle_udp_query_with_ipv6 ( & service, & query. message , false ) . await
214+ wire:: handle_udp_query_with_ipv6 ( & service, & query. message , ipv6_egress)
215+ . await
128216 }
129217 DnsTransport :: Tcp => {
130- wire:: handle_tcp_query_with_ipv6 ( & service, & query. message , false ) . await
218+ wire:: handle_tcp_query_with_ipv6 ( & service, & query. message , ipv6_egress)
219+ . await
131220 }
132221 }
133222 . map_err ( |error| {
@@ -166,7 +255,11 @@ impl PolicyDnsRuntime {
166255 . severity( SeverityId :: Informational )
167256 . status( StatusId :: Success )
168257 . state( StateId :: Enabled , "ready" )
169- . message( "Policy DNS connected to isolation boundary" )
258+ . unmapped( "ipv6_egress" , ipv6_egress)
259+ . message( format!(
260+ "Policy DNS connected to isolation boundary (IPv6 egress {})" ,
261+ if ipv6_egress { "enabled" } else { "disabled" }
262+ ) )
170263 . build( )
171264 ) ;
172265 Ok ( Self {
@@ -184,6 +277,7 @@ impl PolicyDnsRuntime {
184277 engine_ready : tokio:: sync:: watch:: Receiver < bool > ,
185278 ) -> Result < Self > {
186279 let upstream = trusted_resolver_from_resolv_conf ( ) ?;
280+ let ipv6_egress = config. ipv6_egress ;
187281 let store = Arc :: new ( ResolvedEndpointStore :: new (
188282 StoreConfig :: new ( config. pools , MAX_MAPPINGS )
189283 . map_err ( |error| miette:: miette!( error. to_string( ) ) ) ?,
@@ -217,11 +311,10 @@ impl PolicyDnsRuntime {
217311 let udp = udp. clone ( ) ;
218312 tokio:: spawn ( async move {
219313 let _permit = permit;
220- // Docker and Podman do not currently prove usable IPv6
221- // egress. Return NOERROR/NODATA for AAAA so dual-stack
222- // clients can fall back to the usable IPv4 path.
314+ // Without IPv6 egress, AAAA receives NOERROR/NODATA so
315+ // dual-stack clients fall back to the usable IPv4 path.
223316 if let Ok ( response) =
224- wire:: handle_udp_query_with_ipv6 ( & service, & request, false ) . await
317+ wire:: handle_udp_query_with_ipv6 ( & service, & request, ipv6_egress ) . await
225318 {
226319 let _ = udp. send_to ( & response, peer) . await ;
227320 }
@@ -256,7 +349,7 @@ impl PolicyDnsRuntime {
256349 return ;
257350 }
258351 let Ok ( response) =
259- wire:: handle_tcp_query_with_ipv6 ( & service, & frame, false ) . await
352+ wire:: handle_tcp_query_with_ipv6 ( & service, & frame, ipv6_egress ) . await
260353 else {
261354 return ;
262355 } ;
@@ -281,6 +374,7 @@ impl PolicyDnsRuntime {
281374 . severity( SeverityId :: Informational )
282375 . status( StatusId :: Success )
283376 . state( StateId :: Enabled , "ready" )
377+ . unmapped( "ipv6_egress" , ipv6_egress)
284378 . message( format!( "Policy DNS listening on {address}" ) )
285379 . build( )
286380 ) ;
@@ -399,6 +493,74 @@ mod tests {
399493 }
400494 }
401495
496+ const IPV4_ROUTE_HEADER : & str =
497+ "Iface\t Destination\t Gateway \t Flags\t RefCnt\t Use\t Metric\t Mask\t \t MTU\t Window\t IRTT\n " ;
498+ const IPV4_DEFAULT_ROUTE : & str =
499+ "eth0\t 00000000\t 0100A8C0\t 0003\t 0\t 0\t 100\t 00000000\t 0\t 0\t 0\n " ;
500+ const IPV4_SUBNET_ROUTE : & str =
501+ "eth0\t 0000A8C0\t 00000000\t 0001\t 0\t 0\t 100\t 00FFFFFF\t 0\t 0\t 0\n " ;
502+ const IPV6_DEFAULT_ROUTE : & str = "00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 eth0\n " ;
503+ const IPV6_LOOPBACK_UNREACHABLE : & str = "00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n " ;
504+ const IPV6_SUBNET_ROUTE : & str = "20010db8000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000001 00000000 00000001 eth0\n " ;
505+
506+ #[ test]
507+ fn explicit_ipv6_egress_modes_ignore_routing_tables ( ) {
508+ assert ! ( PolicyDnsIpv6Egress :: Enabled . resolve( ) ) ;
509+ assert ! ( !PolicyDnsIpv6Egress :: Disabled . resolve( ) ) ;
510+ assert_eq ! ( PolicyDnsIpv6Egress :: default ( ) , PolicyDnsIpv6Egress :: Auto ) ;
511+ }
512+
513+ #[ test]
514+ fn auto_ipv6_egress_enables_only_ipv6_only_uplinks ( ) {
515+ let ipv4_without_default = format ! ( "{IPV4_ROUTE_HEADER}{IPV4_SUBNET_ROUTE}" ) ;
516+ let ipv4_with_default = format ! ( "{IPV4_ROUTE_HEADER}{IPV4_DEFAULT_ROUTE}" ) ;
517+ let ipv6_with_default = format ! ( "{IPV6_SUBNET_ROUTE}{IPV6_DEFAULT_ROUTE}" ) ;
518+
519+ // IPv6-only (NAT64/DNS64) uplink.
520+ assert ! ( ipv6_only_default_route(
521+ Some ( & ipv4_without_default) ,
522+ Some ( & ipv6_with_default)
523+ ) ) ;
524+ assert ! ( ipv6_only_default_route(
525+ Some ( IPV4_ROUTE_HEADER ) ,
526+ Some ( IPV6_DEFAULT_ROUTE )
527+ ) ) ;
528+ // Dual-stack keeps the IPv4 fallback behavior.
529+ assert ! ( !ipv6_only_default_route(
530+ Some ( & ipv4_with_default) ,
531+ Some ( & ipv6_with_default)
532+ ) ) ;
533+ // IPv4-only, and the kernel's unreachable ::/0 entry on lo.
534+ assert ! ( !ipv6_only_default_route(
535+ Some ( & ipv4_with_default) ,
536+ Some ( IPV6_LOOPBACK_UNREACHABLE )
537+ ) ) ;
538+ assert ! ( !ipv6_only_default_route(
539+ Some ( IPV4_ROUTE_HEADER ) ,
540+ Some ( & format!( "{IPV6_SUBNET_ROUTE}{IPV6_LOOPBACK_UNREACHABLE}" ) )
541+ ) ) ;
542+ // Unreadable tables keep the IPv4-only default.
543+ assert ! ( !ipv6_only_default_route( None , Some ( IPV6_DEFAULT_ROUTE ) ) ) ;
544+ assert ! ( !ipv6_only_default_route( Some ( IPV4_ROUTE_HEADER ) , None ) ) ;
545+ }
546+
547+ #[ test]
548+ fn down_or_reject_default_routes_are_not_uplinks ( ) {
549+ let down_ipv4 = format ! (
550+ "{IPV4_ROUTE_HEADER}eth0\t 00000000\t 0100A8C0\t 0002\t 0\t 0\t 100\t 00000000\t 0\t 0\t 0\n "
551+ ) ;
552+ assert ! ( !has_ipv4_default_route( & down_ipv4) ) ;
553+ let reject_ipv6 = IPV6_DEFAULT_ROUTE . replace ( "00000003" , "00000201" ) ;
554+ assert ! ( !has_ipv6_default_route( & reject_ipv6) ) ;
555+ }
556+
557+ #[ test]
558+ fn runtime_config_keeps_ipv6_egress_disabled_by_default ( ) {
559+ let config = PolicyDnsRuntimeConfig :: for_epoch ( 3 ) . unwrap ( ) ;
560+ assert ! ( !config. ipv6_egress) ;
561+ assert ! ( config. with_ipv6_egress( true ) . ipv6_egress) ;
562+ }
563+
402564 #[ test]
403565 fn production_pools_and_store_capacity_expand_together ( ) {
404566 let config = PolicyDnsRuntimeConfig :: for_epoch ( 7 ) . unwrap ( ) ;
0 commit comments