Skip to content

Commit e414141

Browse files
committed
feat(supervisor): make policy DNS IPv6 egress configurable
Mediated policy DNS always answered AAAA queries with NOERROR/NODATA and resolved A queries upstream as A only. On IPv6-only hosts behind NAT64/DNS64 the trusted resolver returns no A records, so every policy-allowed name failed with policy_dns_upstream_no_data and native TCP egress was unusable. Add a driver-owned supervisor flag, --policy-dns-ipv6-egress {auto,enabled,disabled}. The default auto mode enables AAAA answers only when the supervisor network namespace has an IPv6 default route and no IPv4 default route, so dual-stack and IPv4-only hosts keep the current A-record fallback. AAAA answers use the existing epoch-scoped synthetic IPv6 pool and are pinned and dialed through the same resolved-endpoint store and destination validation as IPv4 answers. Signed-off-by: Joffref <mjoffre@blaxel.ai>
1 parent 0854871 commit e414141

9 files changed

Lines changed: 393 additions & 10 deletions

File tree

‎architecture/sandbox.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,14 @@ descriptor-owner snapshot proves who sent an already queued query. Consumers
274274
must not use this unavailable identity to grant binary-specific access. TCP
275275
connection authorization still uses decision-time binary identity.
276276

277+
AAAA queries receive NOERROR/NODATA unless IPv6 egress is enabled. The
278+
driver-owned `--policy-dns-ipv6-egress` flag selects `auto` (default),
279+
`enabled`, or `disabled`; `auto` enables IPv6 answers only when the
280+
supervisor network namespace has an IPv6 default route and no IPv4 default
281+
route, so dual-stack and IPv4-only hosts keep the A-record fallback. IPv6
282+
answers come from the epoch-scoped synthetic IPv6 pool and are pinned and
283+
dialed like IPv4 answers.
284+
277285
The sandbox retains only bounded DNS socket-admission records, consumes TCP
278286
records on accept, and reclaims closed UDP records when capacity is reached.
279287
The kernel delivers replies from the configured nameserver address, including

‎crates/openshell-supervisor-network/src/policy_dns/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod wire;
2525

2626
pub(crate) use name::NormalizedName;
2727
pub(crate) use resolver::{AddressFamily, SocketTrustedResolver, TrustedAnswer, TrustedResolver};
28+
pub use runtime::PolicyDnsIpv6Egress;
2829
pub(crate) use runtime::{PolicyDnsRuntime, PolicyDnsRuntimeConfig};
2930
pub(crate) use store::{
3031
MappingLookup, MappingLookupError, PolicyEndpointId, PublishError, PublishRequest,

‎crates/openshell-supervisor-network/src/policy_dns/runtime.rs‎

Lines changed: 170 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
46122
pub(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

52129
impl 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

81167
pub(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\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n";
498+
const IPV4_DEFAULT_ROUTE: &str =
499+
"eth0\t00000000\t0100A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n";
500+
const IPV4_SUBNET_ROUTE: &str =
501+
"eth0\t0000A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\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\t00000000\t0100A8C0\t0002\t0\t0\t100\t00000000\t0\t0\t0\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();

‎crates/openshell-supervisor-network/src/policy_dns/wire.rs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,47 @@ process: { run_as_user: sandbox, run_as_group: sandbox }
325325
assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 0);
326326
}
327327

328+
#[tokio::test]
329+
async fn runtime_with_ipv6_egress_resolves_aaaa_to_synthetic_ipv6() {
330+
let service = service();
331+
let query = request("db.example.", RecordType::AAAA);
332+
let mut frame = Vec::with_capacity(query.len() + 2);
333+
frame.extend_from_slice(&u16::try_from(query.len()).unwrap().to_be_bytes());
334+
frame.extend_from_slice(&query);
335+
for wire in [
336+
handle_udp_query_with_ipv6(&service, &query, true)
337+
.await
338+
.unwrap(),
339+
handle_tcp_query_with_ipv6(&service, &frame, true)
340+
.await
341+
.unwrap()[2..]
342+
.to_vec(),
343+
] {
344+
let response = Message::from_vec(&wire).unwrap();
345+
assert_eq!(response.metadata.response_code, ResponseCode::NoError);
346+
let RData::AAAA(AAAA(address)) = response.answers[0].data else {
347+
panic!("expected an AAAA answer");
348+
};
349+
let pool =
350+
"fd00:1::1".parse::<Ipv6Addr>().unwrap()..="fd00:1::4".parse::<Ipv6Addr>().unwrap();
351+
assert!(pool.contains(&address));
352+
let mapping = service
353+
.store()
354+
.lookup(
355+
IpAddr::V6(address),
356+
5432,
357+
service.policy.current_generation(),
358+
Instant::now(),
359+
)
360+
.unwrap();
361+
assert_eq!(
362+
mapping.pinned_addresses(),
363+
["2001:4860:4860::8888".parse::<IpAddr>().unwrap()]
364+
);
365+
}
366+
assert_eq!(service.resolver.calls.load(Ordering::SeqCst), 2);
367+
}
368+
328369
#[tokio::test]
329370
async fn unsupported_type_is_not_implemented_and_malformed_tcp_is_rejected() {
330371
let service = service();

0 commit comments

Comments
 (0)