Skip to content

Commit 0cd76fc

Browse files
committed
Fix DNS resolution in ephemeral guests
QEMU's slirp reads /etc/resolv.conf from the container namespace, which contains unreachable bridge DNS servers. On systemd-resolved hosts, it only has 127.0.0.53 (stub resolver). Read upstream DNS servers from host's /run/systemd/resolve/resolv.conf, pass them to the container, and write /etc/resolv.conf before starting QEMU. Signed-off-by: gursewak1997 <gursmangat@gmail.com>
1 parent b664ecb commit 0cd76fc

File tree

4 files changed

+190
-17
lines changed

4 files changed

+190
-17
lines changed

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,67 @@ 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+
// Wait for network interface to be ready
368+
let network_ready = run_bcvk(&[
369+
"ephemeral",
370+
"run-ssh",
371+
"--label",
372+
INTEGRATION_TEST_LABEL,
373+
&get_test_image(),
374+
"--",
375+
"/bin/sh",
376+
"-c",
377+
r#"
378+
for i in $(seq 1 30); do
379+
ip -4 addr show | grep -q "inet " && break
380+
sleep 1
381+
done
382+
"#,
383+
])?;
384+
385+
assert!(
386+
network_ready.success(),
387+
"Network interface not ready: stdout: {}\nstderr: {}",
388+
network_ready.stdout,
389+
network_ready.stderr
390+
);
391+
// Use curl or wget, whichever is available
392+
// Test DNS + network by connecting to quay.io
393+
// Any HTTP response (including 401) proves DNS resolution and network connectivity work
394+
let network_test = run_bcvk(&[
395+
"ephemeral",
396+
"run-ssh",
397+
"--label",
398+
INTEGRATION_TEST_LABEL,
399+
&get_test_image(),
400+
"--",
401+
"/bin/sh",
402+
"-c",
403+
r#"
404+
if command -v curl >/dev/null 2>&1; then
405+
curl -sS --max-time 10 https://quay.io/v2/ >/dev/null
406+
elif command -v wget >/dev/null 2>&1; then
407+
wget -q --timeout=10 -O /dev/null https://quay.io/v2/
408+
else
409+
echo "Neither curl nor wget available"
410+
exit 1
411+
fi
412+
"#,
413+
])?;
414+
415+
assert!(
416+
network_test.success(),
417+
"Network connectivity test (HTTP request to quay.io) failed: stdout: {}\nstderr: {}",
418+
network_test.stdout,
419+
network_test.stderr
420+
);
421+
422+
Ok(())
423+
}
424+
integration_test!(test_run_ephemeral_dns_resolution);

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: 112 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,80 @@ 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, passed to container for QEMU configuration)
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, IPv6, and link-local addresses (169.254.x.x)
330+
// Link-local addresses are container bridges that won't work from inside QEMU
331+
// Keep private network addresses (they may work in corporate/home networks)
332+
let filtered_servers: Vec<String> = dns_servers
333+
.into_iter()
334+
.filter(|s| {
335+
// Parse as IPv4 address
336+
if let Ok(ip) = s.parse::<std::net::Ipv4Addr>() {
337+
// Reject loopback (127.x.x.x) and link-local (169.254.x.x)
338+
!ip.is_loopback() && !ip.is_link_local()
339+
} else {
340+
false // Reject IPv6 and invalid addresses
341+
}
342+
})
343+
.collect();
344+
345+
if !filtered_servers.is_empty() {
346+
debug!("Found DNS servers from {}: {:?}", path, filtered_servers);
347+
return Some(filtered_servers);
348+
} else {
349+
debug!("No usable DNS servers in {}, trying next", path);
350+
}
351+
}
352+
Err(e) => {
353+
debug!("Failed to read {}: {}, trying next", path, e);
354+
}
355+
}
356+
}
357+
358+
debug!("No DNS servers found in any resolv.conf file");
359+
None
286360
}
287361

288362
/// Launch privileged container with QEMU+KVM for ephemeral VM, spawning as subprocess.
@@ -499,8 +573,24 @@ fn prepare_run_command_with_temp(
499573
cmd.args(["-v", &format!("{}:/run/systemd-units:ro", units_dir)]);
500574
}
501575

576+
// Read host DNS servers before entering container
577+
// QEMU's slirp will use these instead of container's unreachable bridge DNS servers
578+
let host_dns_servers = read_host_dns_servers().or_else(|| {
579+
// Fallback to public DNS if no usable DNS found in system configuration
580+
// This ensures DNS works even when host has broken/unreachable DNS config
581+
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.");
582+
Some(vec!["8.8.8.8".to_string(), "1.1.1.1".to_string()])
583+
});
584+
585+
if let Some(ref dns) = host_dns_servers {
586+
debug!("Using DNS servers for ephemeral VM: {:?}", dns);
587+
}
588+
502589
// Pass configuration as JSON via BCK_CONFIG environment variable
503-
let config = serde_json::to_string(&opts).unwrap();
590+
// Include host DNS servers in the config so they're available inside the container
591+
let mut opts_with_dns = opts.clone();
592+
opts_with_dns.host_dns_servers = host_dns_servers;
593+
let config = serde_json::to_string(&opts_with_dns).unwrap();
504594
cmd.args(["-e", &format!("BCK_CONFIG={config}")]);
505595

506596
// Handle --execute output files and virtio-serial devices
@@ -1229,6 +1319,26 @@ Options=
12291319
qemu_config.add_virtio_serial_out("org.bcvk.journal", "/run/journal.log".to_string(), false);
12301320
debug!("Added virtio-serial device for journal streaming to /run/journal.log");
12311321

1322+
// Configure DNS servers from host's /etc/resolv.conf
1323+
// This fixes DNS resolution issues when QEMU runs inside containers.
1324+
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
1325+
// which contains unreachable bridge DNS servers (e.g., 169.254.1.1, 10.x.y.z).
1326+
// Solution: Write public DNS servers to /etc/resolv.conf in the bwrap namespace.
1327+
// Safe because we're in an ephemeral container that will be destroyed.
1328+
if let Some(dns_servers) = opts.host_dns_servers.clone() {
1329+
let mut resolv_content = String::new();
1330+
for server in &dns_servers {
1331+
resolv_content.push_str(&format!("nameserver {}\n", server));
1332+
}
1333+
1334+
std::fs::write("/etc/resolv.conf", &resolv_content)
1335+
.context("Failed to write /etc/resolv.conf for QEMU slirp")?;
1336+
1337+
debug!("Configured DNS servers for QEMU slirp: {:?}", dns_servers);
1338+
} else {
1339+
debug!("No host DNS servers available, QEMU slirp will use container's resolv.conf");
1340+
}
1341+
12321342
if opts.common.ssh_keygen {
12331343
qemu_config.enable_ssh_access(None); // Use default port 2222
12341344
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
@@ -430,6 +430,7 @@ pub fn run(opts: ToDiskOpts) -> Result<()> {
430430
// - Attach target disk via virtio-blk
431431
// - Disable networking (using local storage only)
432432
let ephemeral_opts = RunEphemeralOpts {
433+
host_dns_servers: None,
433434
image: opts.get_installer_image().to_string(),
434435
common: common_opts,
435436
podman: crate::run_ephemeral::CommonPodmanOptions {

0 commit comments

Comments
 (0)