Skip to content
Merged
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
40 changes: 40 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,43 @@ 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<()> {
// Test DNS + network by connecting to quay.io
// Use curl or wget, whichever is available
// 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI: (low) This test requires external network access to quay.io, which may fail in air-gapped CI environments or during quay.io outages. Consider documenting this as an integration test requirement, or adding a skip mechanism for offline testing.

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);
6 changes: 6 additions & 0 deletions crates/kit/scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ init_tmproot() {
# Ensure we have /etc/passwd as ssh-keygen wants it for bad reasons
systemd-sysusers --root $(pwd) &>/dev/null

# Copy DNS configuration from container's /etc/resolv.conf (configured by podman --dns)
# into the bwrap namespace so QEMU's slirp can use it for DNS resolution
if [ -f /etc/resolv.conf ]; then
cp /etc/resolv.conf /run/tmproot/etc/resolv.conf
fi

# Shared directory between containers
mkdir /run/inner-shared
}
Expand Down
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
119 changes: 117 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,87 @@ pub struct RunEphemeralOpts {

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

/// Host DNS servers (read on host, configured via podman --dns flags)
/// 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI: Good structure - having parse_resolv_conf take &str makes it unit-testable without filesystem dependencies. Consider adding unit tests for edge cases (empty file, malformed lines, mixed IPv4/IPv6).

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, link-local, and private network addresses
// QEMU runs in user networking mode (slirp) inside a container, which cannot
// reach private network addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x for IPv4,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI: Important: The filtering of private network addresses (!ip.is_private() for IPv4, ULA check for IPv6) will break DNS for many enterprise/VPN users whose DNS servers are on private networks. Consider only filtering loopback and link-local. (Note: This was fixed in follow-up commit 8f8d301)

// fc00::/7 ULA for IPv6). These are often VPN-only DNS servers that won't work.
// We'll fall back to public DNS (8.8.8.8, 1.1.1.1) which is more reliable.
let filtered_servers: Vec<String> = dns_servers
.into_iter()
.filter(|s| {
// Try parsing as IPv4 first
if let Ok(ip) = s.parse::<std::net::Ipv4Addr>() {
// Reject loopback, link-local, and private addresses
!ip.is_loopback() && !ip.is_link_local() && !ip.is_private()
} else if let Ok(ip) = s.parse::<std::net::Ipv6Addr>() {
// Reject loopback (::1), link-local (fe80::/10), ULA (fc00::/7), and multicast
!ip.is_loopback() && !ip.is_multicast()
&& !(ip.segments()[0] & 0xffc0 == 0xfe80) // link-local fe80::/10
&& !(ip.segments()[0] & 0xfe00 == 0xfc00) // ULA fc00::/7 (private)
} else {
false // Reject 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 +580,32 @@ fn prepare_run_command_with_temp(
cmd.args(["-v", &format!("{}:/run/systemd-units:ro", units_dir)]);
}

// Read host DNS servers and configure them via podman --dns flags
// This fixes DNS resolution issues when QEMU runs inside containers.
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
// which would otherwise contain unreachable bridge DNS servers (e.g., 169.254.1.1).
// Using --dns properly configures /etc/resolv.conf in the container.
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);
// Configure DNS servers for the container using --dns flags
// This properly sets up /etc/resolv.conf in the container's network namespace
for server in dns {
cmd.args(["--dns", server]);
}
}

// 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 +1334,16 @@ 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");

// DNS is configured via podman --dns flags (see prepare_run_command_with_temp)
// This fixes DNS resolution issues when QEMU runs inside containers.
// QEMU's slirp reads /etc/resolv.conf from the container's network namespace,
// and podman properly sets it up using --dns instead of relying on bridge DNS.
if let Some(ref dns_servers) = opts.host_dns_servers {
debug!("DNS servers configured for QEMU slirp: {:?}", dns_servers);
} else {
warn!("No host DNS servers available, QEMU slirp will use container's resolv.conf which may not work");
}

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