Skip to content
Draft
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
80 changes: 78 additions & 2 deletions crates/bcvk-qemu/src/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,18 @@ pub enum NetworkMode {
User {
/// Port forwarding rules: "tcp::2222-:22" format.
hostfwd: Vec<String>,
/// When true, block all guest-initiated outbound traffic except
/// explicit hostfwd rules (QEMU slirp `restrict=on`).
restrict: bool,
},
}

impl Default for NetworkMode {
fn default() -> Self {
NetworkMode::User { hostfwd: vec![] }
NetworkMode::User {
hostfwd: vec![],
restrict: false,
}
}
}

Expand Down Expand Up @@ -449,15 +455,34 @@ impl QemuConfig {
}

/// Enable SSH access by configuring port forwarding.
///
/// Preserves the current `restrict` setting so that callers can
/// enable isolation and SSH in either order.
pub fn enable_ssh_access(&mut self, host_port: Option<u16>) -> &mut Self {
let port = host_port.unwrap_or(2222); // Default to port 2222 on host
let hostfwd = format!("tcp::{}-:22", port); // Forward host port to guest port 22
let restrict =
matches!(&self.network_mode, NetworkMode::User { restrict, .. } if *restrict);
self.network_mode = NetworkMode::User {
hostfwd: vec![hostfwd],
restrict,
};
self
}

/// Enable or disable network isolation (QEMU slirp `restrict=on`).
///
/// When enabled, the guest cannot initiate outbound connections
/// except through explicit `hostfwd` rules (e.g. SSH).
pub fn set_network_restrict(&mut self, restrict: bool) -> &mut Self {
match &mut self.network_mode {
NetworkMode::User {
restrict: current, ..
} => *current = restrict,
}
self
}

/// Add a fw_cfg entry to pass a file to the guest.
/// The file will be accessible in the guest via the fw_cfg interface.
pub fn add_fw_cfg(&mut self, name: String, file_path: Utf8PathBuf) -> &mut Self {
Expand Down Expand Up @@ -679,9 +704,13 @@ fn spawn(

// Configure network (only User mode supported now)
match &config.network_mode {
NetworkMode::User { hostfwd } => {
NetworkMode::User { hostfwd, restrict } => {
let mut netdev_parts = vec!["user".to_string(), "id=net0".to_string()];

if *restrict {
netdev_parts.push("restrict=on".to_string());
}

// Add port forwarding rules
for fwd in hostfwd {
netdev_parts.push(format!("hostfwd={}", fwd));
Expand Down Expand Up @@ -1051,4 +1080,51 @@ mod tests {
assert_eq!(config.fw_cfg_entries[0].0, "opt/com.coreos/config");
assert_eq!(config.fw_cfg_entries[0].1.as_str(), "/test/ignition.json");
}

#[test]
fn test_network_mode_default_no_restrict() {
let config = QemuConfig::default();
assert!(
matches!(&config.network_mode, NetworkMode::User { restrict, .. } if !restrict),
"Default network mode should not restrict"
);
}

#[test]
fn test_network_mode_restrict() {
let mut config = QemuConfig::default();
config.set_network_restrict(true);
assert!(matches!(&config.network_mode, NetworkMode::User { restrict, .. } if *restrict),);

config.set_network_restrict(false);
assert!(matches!(&config.network_mode, NetworkMode::User { restrict, .. } if !restrict),);
}

#[test]
fn test_enable_ssh_preserves_restrict() {
let mut config = QemuConfig::default();
config.set_network_restrict(true);
config.enable_ssh_access(Some(2222));

match &config.network_mode {
NetworkMode::User { hostfwd, restrict } => {
assert!(restrict, "enable_ssh_access should preserve restrict=true");
assert_eq!(hostfwd, &["tcp::2222-:22"]);
}
}
}

#[test]
fn test_enable_ssh_then_restrict() {
let mut config = QemuConfig::default();
config.enable_ssh_access(Some(3000));
config.set_network_restrict(true);

match &config.network_mode {
NetworkMode::User { hostfwd, restrict } => {
assert!(restrict);
assert_eq!(hostfwd, &["tcp::3000-:22"]);
}
}
}
}
1 change: 1 addition & 0 deletions crates/integration-tests/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod tests {
pub mod libvirt_upload_disk;
pub mod libvirt_verb;
pub mod mount_feature;
pub mod network_isolation;
pub mod run_ephemeral;
pub mod run_ephemeral_ignition;
pub mod run_ephemeral_ssh;
Expand Down
139 changes: 139 additions & 0 deletions crates/integration-tests/src/tests/network_isolation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Integration tests for network isolation (--network-isolation flag)
//!
//! Verifies that VMs launched with --network-isolation cannot reach
//! external hosts, while SSH from the host into the VM still works.

use integration_tests::integration_test;
use itest::TestResult;
use xshell::cmd;

use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL};

/// Well-known external IP used to verify outbound connectivity.
/// Google Public DNS; chosen because it is highly reliable and
/// responds to ICMP echo.
const EXTERNAL_PROBE_IP: &str = "8.8.8.8";

/// Check whether the host can reach the external probe IP.
fn host_can_reach_external() -> bool {
std::process::Command::new("ping")
.args(["-c1", "-W5", EXTERNAL_PROBE_IP])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}

/// Test that a guest WITHOUT --network-isolation can reach the internet.
///
/// This is the positive control for the isolation test: it proves that
/// run-ssh works, that the guest has a working network stack, and that
/// the external probe IP is reachable from inside the guest.
///
/// If the host itself cannot reach the probe IP, the test prints a
/// warning and passes without booting a VM.
fn test_run_ephemeral_network_reachable() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let image = get_test_image();
let label = INTEGRATION_TEST_LABEL;

if !host_can_reach_external() {
eprintln!();
eprintln!("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
eprintln!("WARNING: Host cannot reach {EXTERNAL_PROBE_IP}");
eprintln!(" Skipping guest connectivity check.");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should require internet connectivity by default, but allow disabling tests that require it with an env var or so

eprintln!(" Re-run with internet access for full coverage.");
eprintln!("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
eprintln!();
return Ok(());
}

// Boot a normal VM (no isolation) and verify the guest can ping
// the external probe IP. This proves run-ssh, guest networking,
// and external reachability all work.
cmd!(
sh,
"{bck} ephemeral run-ssh --label {label} {image} -- ping -c1 -W10 {EXTERNAL_PROBE_IP}"
)
.run()?;

eprintln!("Guest successfully reached {EXTERNAL_PROBE_IP} (positive control passed)");

Ok(())
}
integration_test!(test_run_ephemeral_network_reachable);

/// Test that --network-isolation blocks outbound traffic from the guest.
///
/// 1. Verify the host itself can reach 8.8.8.8. If it cannot, skip with
/// a loud warning (so offline environments are not broken).
/// 2. Boot an ephemeral VM WITHOUT --network-isolation and ping the
/// probe IP. This positive control proves that run-ssh and guest
/// networking work correctly. If this step fails, the test
/// environment is broken and we bail out rather than risk a false
/// pass on the isolation check.
/// 3. Boot an ephemeral VM WITH --network-isolation and ping the same
/// IP. This must fail, proving outbound traffic is blocked.
/// 4. The fact that run-ssh itself succeeds in step 3 proves that SSH
/// (host-to-guest via hostfwd) is preserved under isolation.
fn test_run_ephemeral_network_isolation() -> TestResult {
let sh = shell()?;
let bck = get_bck_command()?;
let image = get_test_image();
let label = INTEGRATION_TEST_LABEL;

if !host_can_reach_external() {
eprintln!();
eprintln!("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
eprintln!("WARNING: Host cannot reach {EXTERNAL_PROBE_IP}");
eprintln!(" Network isolation test CANNOT verify that the");
eprintln!(" guest is actually blocked. Skipping.");
eprintln!(" Re-run with internet access for full coverage.");
eprintln!("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
eprintln!();
return Ok(());
}

// Step 1: Positive control -- verify the guest can reach the
// external probe IP without isolation. This guards against a false
// pass where the isolated test "succeeds" because networking is
// broken for an unrelated reason (e.g. SSH itself is not working).
eprintln!(
"Positive control: verifying guest can reach {EXTERNAL_PROBE_IP} without isolation..."
);
cmd!(
sh,
"{bck} ephemeral run-ssh --label {label} {image} -- ping -c1 -W10 {EXTERNAL_PROBE_IP}"
)
.run()?;
eprintln!("Positive control passed");

// Step 2: Isolation check -- the same ping must fail with
// --network-isolation. run-ssh propagates the guest command's exit
// code, so we use ignore_status() and check manually.
eprintln!("Isolation check: verifying guest CANNOT reach {EXTERNAL_PROBE_IP} with --network-isolation...");
let output = cmd!(
sh,
"{bck} ephemeral run-ssh --network-isolation --label {label} {image} -- ping -c1 -W10 {EXTERNAL_PROBE_IP}"
)
.ignore_status()
.output()?;

assert!(
!output.status.success(),
"Guest ping to {EXTERNAL_PROBE_IP} succeeded despite --network-isolation; \
expected it to be blocked. stdout: {} stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);

eprintln!(
"Isolation check passed: guest ping failed as expected (exit code {:?})",
output.status.code()
);

Ok(())
}
integration_test!(test_run_ephemeral_network_isolation);
12 changes: 11 additions & 1 deletion crates/kit/src/libvirt/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ pub struct LibvirtRunOpts {
#[clap(long)]
pub graphical_console: bool,

/// Isolate the VM from the network. SSH access from the host is
/// preserved, but the VM cannot reach the internet or other hosts.
#[clap(long)]
pub network_isolation: bool,

/// Create a transient VM that disappears on shutdown/reboot
#[clap(long)]
pub transient: bool,
Expand Down Expand Up @@ -1550,8 +1555,13 @@ fn create_libvirt_domain_from_disk(
));
}

let restrict_opt = if opts.network_isolation {
",restrict=on"
} else {
""
};
let netdev_config = format!(
"user,id=ssh0,{}",
"user,id=ssh0{restrict_opt},{}",
hostfwd_args
.iter()
.map(|fwd| format!("hostfwd={}", fwd))
Expand Down
47 changes: 33 additions & 14 deletions crates/kit/src/run_ephemeral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ pub struct CommonVmOpts {
)]
pub ssh_keygen: bool,

/// Isolate the VM from the network. SSH access from the host is
/// preserved, but the VM cannot reach the internet or other hosts.
#[clap(long)]
pub network_isolation: bool,

#[clap(
long = "virtiofsd",
env = "VIRTIOFSD_BIN",
Expand Down Expand Up @@ -905,21 +910,28 @@ fn prepare_run_command_with_temp(
]);
}

// Read host DNS servers and configure them via podman --dns flags
// 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();

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]);
//
// Skip DNS injection when network isolation is enabled: the guest
// cannot reach external DNS servers anyway, and the unreachable
// entries would only cause unnecessary timeouts.
let host_dns_servers = if opts.common.network_isolation {
debug!("Network isolation enabled, skipping DNS server injection");
None
} else {
let servers = read_host_dns_servers();
if let Some(ref dns) = servers {
debug!("Using DNS servers for ephemeral VM: {:?}", dns);
for server in dns {
cmd.args(["--dns", server]);
}
}
}
servers
};

// Pass configuration as JSON via BCK_CONFIG environment variable
// Include host DNS servers in the config so they're available inside the container
Expand Down Expand Up @@ -1991,10 +2003,12 @@ Options=
// 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.network_isolation {
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 {
Expand All @@ -2006,6 +2020,11 @@ Options=
// TODO: Add proper SMBIOS credential injection if needed
}

if opts.common.network_isolation {
qemu_config.set_network_restrict(true);
debug!("Network isolation enabled: guest outbound traffic blocked (QEMU restrict=on)");
}

// Set main virtiofs configuration for root filesystem (will be spawned by QEMU)
qemu_config.set_main_virtiofs(main_virtiofsd_config.clone());

Expand Down
Loading
Loading