Skip to content

Commit 800e600

Browse files
authored
Fix DNS resolution in ephemeral guests (#167)
Fixes DNS resolution for ephemeral VMs running inside containers by switching from manually overwriting etc/resolv.conf to using podman's native --dns flags, which properly handles symlinks and bind mounts. The fix also adds IPv6 DNS support (previously filtered out) and copies the container's DNS configuration into the bwrap namespace where QEMU can access it. This resolves issues where QEMU's slirp would try to use unreachable bridge DNS servers from the container's network namespace. Signed-off-by: gursewak1997 <gursmangat@gmail.com>
1 parent d2ed636 commit 800e600

5 files changed

Lines changed: 177 additions & 17 deletions

File tree

crates/integration-tests/src/tests/run_ephemeral_ssh.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,43 @@ fn test_run_ephemeral_ssh_broken_image_cleanup() -> Result<()> {
358358
Ok(())
359359
}
360360
integration_test!(test_run_ephemeral_ssh_broken_image_cleanup);
361+
362+
/// Test ephemeral VM network and DNS
363+
///
364+
/// Verifies that ephemeral bootc VMs can access the network and resolve DNS correctly.
365+
/// Uses HTTP request to quay.io to test both DNS resolution and network connectivity.
366+
fn test_run_ephemeral_dns_resolution() -> Result<()> {
367+
// Test DNS + network by connecting to quay.io
368+
// Use curl or wget, whichever is available
369+
// Any HTTP response (including 401) proves DNS resolution and network connectivity work
370+
let network_test = run_bcvk(&[
371+
"ephemeral",
372+
"run-ssh",
373+
"--label",
374+
INTEGRATION_TEST_LABEL,
375+
&get_test_image(),
376+
"--",
377+
"/bin/sh",
378+
"-c",
379+
r#"
380+
if command -v curl >/dev/null 2>&1; then
381+
curl -sS --max-time 10 https://quay.io/v2/ >/dev/null
382+
elif command -v wget >/dev/null 2>&1; then
383+
wget -q --timeout=10 -O /dev/null https://quay.io/v2/
384+
else
385+
echo "Neither curl nor wget available"
386+
exit 1
387+
fi
388+
"#,
389+
])?;
390+
391+
assert!(
392+
network_test.success(),
393+
"Network connectivity test (HTTP request to quay.io) failed: stdout: {}\nstderr: {}",
394+
network_test.stdout,
395+
network_test.stderr
396+
);
397+
398+
Ok(())
399+
}
400+
integration_test!(test_run_ephemeral_dns_resolution);

crates/kit/scripts/entrypoint.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ init_tmproot() {
2525
# Ensure we have /etc/passwd as ssh-keygen wants it for bad reasons
2626
systemd-sysusers --root $(pwd) &>/dev/null
2727

28+
# Copy DNS configuration from container's /etc/resolv.conf (configured by podman --dns)
29+
# into the bwrap namespace so QEMU's slirp can use it for DNS resolution
30+
if [ -f /etc/resolv.conf ]; then
31+
cp /etc/resolv.conf /run/tmproot/etc/resolv.conf
32+
fi
33+
2834
# Shared directory between containers
2935
mkdir /run/inner-shared
3036
}

crates/kit/src/qemu.rs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -523,22 +523,20 @@ fn spawn(
523523
// Configure network (only User mode supported now)
524524
match &config.network_mode {
525525
NetworkMode::User { hostfwd } => {
526-
if hostfwd.is_empty() {
527-
cmd.args([
528-
"-netdev",
529-
"user,id=net0",
530-
"-device",
531-
"virtio-net-pci,netdev=net0",
532-
]);
533-
} else {
534-
let hostfwd_arg = format!("user,id=net0,hostfwd={}", hostfwd.join(",hostfwd="));
535-
cmd.args([
536-
"-netdev",
537-
&hostfwd_arg,
538-
"-device",
539-
"virtio-net-pci,netdev=net0",
540-
]);
526+
let mut netdev_parts = vec!["user".to_string(), "id=net0".to_string()];
527+
528+
// Add port forwarding rules
529+
for fwd in hostfwd {
530+
netdev_parts.push(format!("hostfwd={}", fwd));
541531
}
532+
533+
let netdev_arg = netdev_parts.join(",");
534+
cmd.args([
535+
"-netdev",
536+
&netdev_arg,
537+
"-device",
538+
"virtio-net-pci,netdev=net0",
539+
]);
542540
}
543541
}
544542

crates/kit/src/run_ephemeral.rs

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ use color_eyre::Result;
100100
use rustix::path::Arg;
101101
use serde::{Deserialize, Serialize};
102102
use tokio::io::AsyncReadExt;
103-
use tracing::debug;
103+
use tracing::{debug, warn};
104104

105105
const ENTRYPOINT: &str = "/var/lib/bcvk/entrypoint";
106106

@@ -283,6 +283,87 @@ pub struct RunEphemeralOpts {
283283

284284
#[clap(long = "karg", help = "Additional kernel command line arguments")]
285285
pub kernel_args: Vec<String>,
286+
287+
/// Host DNS servers (read on host, configured via podman --dns flags)
288+
/// Not a CLI option - populated automatically from host's /etc/resolv.conf
289+
#[clap(skip)]
290+
#[serde(skip_serializing_if = "Option::is_none")]
291+
pub host_dns_servers: Option<Vec<String>>,
292+
}
293+
294+
/// Parse DNS servers from resolv.conf format content
295+
fn parse_resolv_conf(content: &str) -> Vec<String> {
296+
let mut dns_servers = Vec::new();
297+
for line in content.lines() {
298+
let line = line.trim();
299+
// Parse lines like "nameserver 8.8.8.8" or "nameserver 2001:4860:4860::8888"
300+
if let Some(server) = line.strip_prefix("nameserver ") {
301+
let server = server.trim();
302+
if !server.is_empty() {
303+
dns_servers.push(server.to_string());
304+
}
305+
}
306+
}
307+
dns_servers
308+
}
309+
310+
/// Read DNS servers from host's resolv.conf
311+
/// Returns a vector of DNS server IP addresses, or None if unable to read/parse
312+
///
313+
/// For systemd-resolved systems, reads from /run/systemd/resolve/resolv.conf
314+
/// which contains actual upstream DNS servers, not the stub resolver (127.0.0.53).
315+
/// Falls back to /etc/resolv.conf for non-systemd-resolved systems.
316+
fn read_host_dns_servers() -> Option<Vec<String>> {
317+
// Try systemd-resolved's upstream DNS file first
318+
// This avoids reading 127.0.0.53 (stub resolver) from /etc/resolv.conf
319+
let paths = [
320+
"/run/systemd/resolve/resolv.conf", // systemd-resolved upstream servers
321+
"/etc/resolv.conf", // traditional or fallback
322+
];
323+
324+
for path in &paths {
325+
match std::fs::read_to_string(path) {
326+
Ok(content) => {
327+
let dns_servers = parse_resolv_conf(&content);
328+
329+
// Filter out localhost, link-local, and private network addresses
330+
// QEMU runs in user networking mode (slirp) inside a container, which cannot
331+
// reach private network addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x for IPv4,
332+
// fc00::/7 ULA for IPv6). These are often VPN-only DNS servers that won't work.
333+
// We'll fall back to public DNS (8.8.8.8, 1.1.1.1) which is more reliable.
334+
let filtered_servers: Vec<String> = dns_servers
335+
.into_iter()
336+
.filter(|s| {
337+
// Try parsing as IPv4 first
338+
if let Ok(ip) = s.parse::<std::net::Ipv4Addr>() {
339+
// Reject loopback, link-local, and private addresses
340+
!ip.is_loopback() && !ip.is_link_local() && !ip.is_private()
341+
} else if let Ok(ip) = s.parse::<std::net::Ipv6Addr>() {
342+
// Reject loopback (::1), link-local (fe80::/10), ULA (fc00::/7), and multicast
343+
!ip.is_loopback() && !ip.is_multicast()
344+
&& !(ip.segments()[0] & 0xffc0 == 0xfe80) // link-local fe80::/10
345+
&& !(ip.segments()[0] & 0xfe00 == 0xfc00) // ULA fc00::/7 (private)
346+
} else {
347+
false // Reject invalid addresses
348+
}
349+
})
350+
.collect();
351+
352+
if !filtered_servers.is_empty() {
353+
debug!("Found DNS servers from {}: {:?}", path, filtered_servers);
354+
return Some(filtered_servers);
355+
} else {
356+
debug!("No usable DNS servers in {}, trying next", path);
357+
}
358+
}
359+
Err(e) => {
360+
debug!("Failed to read {}: {}, trying next", path, e);
361+
}
362+
}
363+
}
364+
365+
debug!("No DNS servers found in any resolv.conf file");
366+
None
286367
}
287368

288369
/// Launch privileged container with QEMU+KVM for ephemeral VM, spawning as subprocess.
@@ -499,8 +580,32 @@ fn prepare_run_command_with_temp(
499580
cmd.args(["-v", &format!("{}:/run/systemd-units:ro", units_dir)]);
500581
}
501582

583+
// Read host DNS servers and configure them via podman --dns flags
584+
// This fixes DNS resolution issues when QEMU runs inside containers.
585+
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
586+
// which would otherwise contain unreachable bridge DNS servers (e.g., 169.254.1.1).
587+
// Using --dns properly configures /etc/resolv.conf in the container.
588+
let host_dns_servers = read_host_dns_servers().or_else(|| {
589+
// Fallback to public DNS if no usable DNS found in system configuration
590+
// This ensures DNS works even when host has broken/unreachable DNS config
591+
warn!("No usable DNS servers found in system configuration, falling back to public DNS (8.8.8.8, 1.1.1.1). This may not work in air-gapped environments.");
592+
Some(vec!["8.8.8.8".to_string(), "1.1.1.1".to_string()])
593+
});
594+
595+
if let Some(ref dns) = host_dns_servers {
596+
debug!("Using DNS servers for ephemeral VM: {:?}", dns);
597+
// Configure DNS servers for the container using --dns flags
598+
// This properly sets up /etc/resolv.conf in the container's network namespace
599+
for server in dns {
600+
cmd.args(["--dns", server]);
601+
}
602+
}
603+
502604
// Pass configuration as JSON via BCK_CONFIG environment variable
503-
let config = serde_json::to_string(&opts).unwrap();
605+
// Include host DNS servers in the config so they're available inside the container
606+
let mut opts_with_dns = opts.clone();
607+
opts_with_dns.host_dns_servers = host_dns_servers;
608+
let config = serde_json::to_string(&opts_with_dns).unwrap();
504609
cmd.args(["-e", &format!("BCK_CONFIG={config}")]);
505610

506611
// Handle --execute output files and virtio-serial devices
@@ -1229,6 +1334,16 @@ Options=
12291334
qemu_config.add_virtio_serial_out("org.bcvk.journal", "/run/journal.log".to_string(), false);
12301335
debug!("Added virtio-serial device for journal streaming to /run/journal.log");
12311336

1337+
// DNS is configured via podman --dns flags (see prepare_run_command_with_temp)
1338+
// This fixes DNS resolution issues when QEMU runs inside containers.
1339+
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
1340+
// and podman properly sets it up using --dns instead of relying on bridge DNS.
1341+
if let Some(ref dns_servers) = opts.host_dns_servers {
1342+
debug!("DNS servers configured for QEMU slirp: {:?}", dns_servers);
1343+
} else {
1344+
warn!("No host DNS servers available, QEMU slirp will use container's resolv.conf which may not work");
1345+
}
1346+
12321347
if opts.common.ssh_keygen {
12331348
qemu_config.enable_ssh_access(None); // Use default port 2222
12341349
debug!("Enabled SSH port forwarding: host port 2222 -> guest port 22");

crates/kit/src/to_disk.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ pub fn run(opts: ToDiskOpts) -> Result<()> {
493493
// - Attach target disk via virtio-blk
494494
// - Disable networking (using local storage only)
495495
let ephemeral_opts = RunEphemeralOpts {
496+
host_dns_servers: None,
496497
image: opts.get_installer_image().to_string(),
497498
common: common_opts,
498499
podman: crate::run_ephemeral::CommonPodmanOptions {

0 commit comments

Comments
 (0)