Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions crates/integration-tests/src/tests/run_ephemeral_ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,67 @@ fn test_run_ephemeral_ssh_broken_image_cleanup() -> Result<()> {
Ok(())
}
integration_test!(test_run_ephemeral_ssh_broken_image_cleanup);

/// Test ephemeral VM network and DNS
///
/// Verifies that ephemeral bootc VMs can access the network and resolve DNS correctly.
/// Uses HTTP request to quay.io to test both DNS resolution and network connectivity.
fn test_run_ephemeral_dns_resolution() -> Result<()> {
// Wait for network interface to be ready
let network_ready = run_bcvk(&[
"ephemeral",
"run-ssh",
"--label",
INTEGRATION_TEST_LABEL,
&get_test_image(),
"--",
"/bin/sh",
"-c",
r#"
for i in $(seq 1 30); do
ip -4 addr show | grep -q "inet " && break
sleep 1
done
"#,
])?;

assert!(
network_ready.success(),
"Network interface not ready: stdout: {}\nstderr: {}",
network_ready.stdout,
network_ready.stderr
);
// Use curl or wget, whichever is available
// Test DNS + network by connecting to quay.io
// Any HTTP response (including 401) proves DNS resolution and network connectivity work
let network_test = run_bcvk(&[
"ephemeral",
"run-ssh",
"--label",
INTEGRATION_TEST_LABEL,
&get_test_image(),
"--",
"/bin/sh",
"-c",
r#"
if command -v curl >/dev/null 2>&1; then
curl -sS --max-time 10 https://quay.io/v2/ >/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -q --timeout=10 -O /dev/null https://quay.io/v2/
else
echo "Neither curl nor wget available"
exit 1
fi
"#,
])?;

assert!(
network_test.success(),
"Network connectivity test (HTTP request to quay.io) failed: stdout: {}\nstderr: {}",
network_test.stdout,
network_test.stderr
);

Ok(())
}
integration_test!(test_run_ephemeral_dns_resolution);
28 changes: 13 additions & 15 deletions crates/kit/src/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,22 +523,20 @@ fn spawn(
// Configure network (only User mode supported now)
match &config.network_mode {
NetworkMode::User { hostfwd } => {
if hostfwd.is_empty() {
cmd.args([
"-netdev",
"user,id=net0",
"-device",
"virtio-net-pci,netdev=net0",
]);
} else {
let hostfwd_arg = format!("user,id=net0,hostfwd={}", hostfwd.join(",hostfwd="));
cmd.args([
"-netdev",
&hostfwd_arg,
"-device",
"virtio-net-pci,netdev=net0",
]);
let mut netdev_parts = vec!["user".to_string(), "id=net0".to_string()];

// Add port forwarding rules
for fwd in hostfwd {
netdev_parts.push(format!("hostfwd={}", fwd));
}

let netdev_arg = netdev_parts.join(",");
cmd.args([
"-netdev",
&netdev_arg,
"-device",
"virtio-net-pci,netdev=net0",
]);
}
}

Expand Down
114 changes: 112 additions & 2 deletions crates/kit/src/run_ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ use color_eyre::Result;
use rustix::path::Arg;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
use tracing::debug;
use tracing::{debug, warn};

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

Expand Down Expand Up @@ -283,6 +283,80 @@ pub struct RunEphemeralOpts {

#[clap(long = "karg", help = "Additional kernel command line arguments")]
pub kernel_args: Vec<String>,

/// Host DNS servers (read on host, passed to container for QEMU configuration)
/// Not a CLI option - populated automatically from host's /etc/resolv.conf
#[clap(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub host_dns_servers: Option<Vec<String>>,
}

/// Parse DNS servers from resolv.conf format content
fn parse_resolv_conf(content: &str) -> Vec<String> {
let mut dns_servers = Vec::new();
for line in content.lines() {
let line = line.trim();
// Parse lines like "nameserver 8.8.8.8" or "nameserver 2001:4860:4860::8888"
if let Some(server) = line.strip_prefix("nameserver ") {
let server = server.trim();
if !server.is_empty() {
dns_servers.push(server.to_string());
}
}
}
dns_servers
}

/// Read DNS servers from host's resolv.conf
/// Returns a vector of DNS server IP addresses, or None if unable to read/parse
///
/// For systemd-resolved systems, reads from /run/systemd/resolve/resolv.conf
/// which contains actual upstream DNS servers, not the stub resolver (127.0.0.53).
/// Falls back to /etc/resolv.conf for non-systemd-resolved systems.
fn read_host_dns_servers() -> Option<Vec<String>> {
// Try systemd-resolved's upstream DNS file first
// This avoids reading 127.0.0.53 (stub resolver) from /etc/resolv.conf
let paths = [
"/run/systemd/resolve/resolv.conf", // systemd-resolved upstream servers
"/etc/resolv.conf", // traditional or fallback
];

for path in &paths {
match std::fs::read_to_string(path) {
Ok(content) => {
let dns_servers = parse_resolv_conf(&content);

// Filter out localhost, IPv6, and link-local addresses (169.254.x.x)
// Link-local addresses are container bridges that won't work from inside QEMU
// Keep private network addresses (they may work in corporate/home networks)
let filtered_servers: Vec<String> = dns_servers
.into_iter()
.filter(|s| {
// Parse as IPv4 address
if let Ok(ip) = s.parse::<std::net::Ipv4Addr>() {
// Reject loopback (127.x.x.x) and link-local (169.254.x.x)
!ip.is_loopback() && !ip.is_link_local()
} else {
false // Reject IPv6 and invalid addresses
}
})
.collect();

if !filtered_servers.is_empty() {
debug!("Found DNS servers from {}: {:?}", path, filtered_servers);
return Some(filtered_servers);
} else {
debug!("No usable DNS servers in {}, trying next", path);
}
}
Err(e) => {
debug!("Failed to read {}: {}, trying next", path, e);
}
}
}

debug!("No DNS servers found in any resolv.conf file");
None
}

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

// Read host DNS servers before entering container
// QEMU's slirp will use these instead of container's unreachable bridge DNS servers
let host_dns_servers = read_host_dns_servers().or_else(|| {
// Fallback to public DNS if no usable DNS found in system configuration
// This ensures DNS works even when host has broken/unreachable DNS config
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.");
Some(vec!["8.8.8.8".to_string(), "1.1.1.1".to_string()])
});

if let Some(ref dns) = host_dns_servers {
debug!("Using DNS servers for ephemeral VM: {:?}", dns);
}

// Pass configuration as JSON via BCK_CONFIG environment variable
let config = serde_json::to_string(&opts).unwrap();
// Include host DNS servers in the config so they're available inside the container
let mut opts_with_dns = opts.clone();
opts_with_dns.host_dns_servers = host_dns_servers;
let config = serde_json::to_string(&opts_with_dns).unwrap();
cmd.args(["-e", &format!("BCK_CONFIG={config}")]);

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

// Configure DNS servers from host's /etc/resolv.conf
// This fixes DNS resolution issues when QEMU runs inside containers.
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
// which contains unreachable bridge DNS servers (e.g., 169.254.1.1, 10.x.y.z).
// Solution: Write public DNS servers to /etc/resolv.conf in the bwrap namespace.
// Safe because we're in an ephemeral container that will be destroyed.
if let Some(dns_servers) = opts.host_dns_servers.clone() {
let mut resolv_content = String::new();
for server in &dns_servers {
resolv_content.push_str(&format!("nameserver {}\n", server));
}

std::fs::write("/etc/resolv.conf", &resolv_content)
.context("Failed to write /etc/resolv.conf for QEMU slirp")?;

debug!("Configured DNS servers for QEMU slirp: {:?}", dns_servers);
} else {
debug!("No host DNS servers available, QEMU slirp will use container's resolv.conf");
}

if opts.common.ssh_keygen {
qemu_config.enable_ssh_access(None); // Use default port 2222
debug!("Enabled SSH port forwarding: host port 2222 -> guest port 22");
Expand Down
1 change: 1 addition & 0 deletions crates/kit/src/to_disk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ pub fn run(opts: ToDiskOpts) -> Result<()> {
// - Attach target disk via virtio-blk
// - Disable networking (using local storage only)
let ephemeral_opts = RunEphemeralOpts {
host_dns_servers: None,
image: opts.get_installer_image().to_string(),
common: common_opts,
podman: crate::run_ephemeral::CommonPodmanOptions {
Expand Down
Loading