diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 634a991e3..4b279f82f 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -99,12 +99,18 @@ pub enum NetworkMode { User { /// Port forwarding rules: "tcp::2222-:22" format. hostfwd: Vec, + /// 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, + } } } @@ -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) -> &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 { @@ -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)); @@ -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"]); + } + } + } } diff --git a/crates/integration-tests/src/main.rs b/crates/integration-tests/src/main.rs index 296f1ce2a..c5bc52855 100644 --- a/crates/integration-tests/src/main.rs +++ b/crates/integration-tests/src/main.rs @@ -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; diff --git a/crates/integration-tests/src/tests/network_isolation.rs b/crates/integration-tests/src/tests/network_isolation.rs new file mode 100644 index 000000000..f80eb64ce --- /dev/null +++ b/crates/integration-tests/src/tests/network_isolation.rs @@ -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."); + 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); diff --git a/crates/kit/src/libvirt/run.rs b/crates/kit/src/libvirt/run.rs index 67eaa645e..7eadda869 100644 --- a/crates/kit/src/libvirt/run.rs +++ b/crates/kit/src/libvirt/run.rs @@ -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, @@ -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)) diff --git a/crates/kit/src/run_ephemeral.rs b/crates/kit/src/run_ephemeral.rs index fae2511b2..17a06c00a 100644 --- a/crates/kit/src/run_ephemeral.rs +++ b/crates/kit/src/run_ephemeral.rs @@ -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", @@ -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 @@ -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 { @@ -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()); diff --git a/docs/src/ephemeral-run.md b/docs/src/ephemeral-run.md index 5eaea3a08..3f2c85621 100644 --- a/docs/src/ephemeral-run.md +++ b/docs/src/ephemeral-run.md @@ -40,11 +40,23 @@ even though there won't be any block devices (by default). At the current time there is not a dedicated way to detect `bcvk ephemeral`, but `ConditionKernelCommandLine=!rootfstype=virtiofs` should work reliably in the future. +## Network Isolation + +Use `--network-isolation` to block all outbound network access from the VM. +SSH from the host is preserved, but processes inside the guest cannot reach +the internet. This is useful for hermetic CI/CD testing where all inputs +should be pre-staged: + +```bash +bcvk ephemeral run-ssh --network-isolation --bind-storage-ro \ + localhost/mybootc -- make test +``` + ## Use Cases - Quick testing of bootc images - Development environments - CI/CD integration -- Isolated experimentation +- Hermetic testing with `--network-isolation` See [ephemeral-ssh](./ephemeral-ssh.md) for SSH access details. \ No newline at end of file diff --git a/docs/src/libvirt-advanced.md b/docs/src/libvirt-advanced.md index dc109635f..bb7716274 100644 --- a/docs/src/libvirt-advanced.md +++ b/docs/src/libvirt-advanced.md @@ -21,6 +21,23 @@ Create custom libvirt networks for VM isolation: For direct host network access, use bridged networking or macvtap interfaces. +### Network Isolation + +Use `--network-isolation` to block all outbound traffic from the VM while +preserving SSH access from the host: + +```bash +bcvk libvirt run --name hermetic-test \ + --network-isolation \ + --bind-storage-ro \ + quay.io/centos-bootc/centos-bootc:stream10 +``` + +This is particularly useful for CI/CD pipelines where test execution should +not depend on external network resources. Pre-stage any required container +images or packages on the host and share them into the VM via `--bind`, +`--bind-ro`, or `--bind-storage-ro`. + ## Automation with Scripts Use shell scripts or configuration management tools to automate VM provisioning and management with bcvk libvirt commands. diff --git a/docs/src/man/bcvk-ephemeral-run-ssh.md b/docs/src/man/bcvk-ephemeral-run-ssh.md index 51680ae6d..8f9bb40fb 100644 --- a/docs/src/man/bcvk-ephemeral-run-ssh.md +++ b/docs/src/man/bcvk-ephemeral-run-ssh.md @@ -82,6 +82,10 @@ For longer-running VMs where you need to reconnect multiple times, use Generate SSH keypair and inject via systemd credentials +**--network-isolation** + + Isolate the VM from the network. SSH access from the host is preserved, but the VM cannot reach the internet or other hosts + **--virtiofsd**=*VIRTIOFSD_BINARY* Path to virtiofsd binary (overrides auto-detection) @@ -227,6 +231,15 @@ Mount source code for testing: bcvk ephemeral run-ssh --bind /home/user/project:src localhost/mybootc # Inside VM: ls /run/virtiofs-mnt-src +## Network Isolation + +Run tests in a network-isolated VM to avoid flakes from external services: + + bcvk ephemeral run-ssh --network-isolation localhost/mybootc -- make test + +SSH connectivity from the host is preserved; only outbound traffic from +within the guest is blocked. + ## Debugging Enable console output to see boot messages: diff --git a/docs/src/man/bcvk-ephemeral-run.md b/docs/src/man/bcvk-ephemeral-run.md index 7de1c3e04..d3c4b74b2 100644 --- a/docs/src/man/bcvk-ephemeral-run.md +++ b/docs/src/man/bcvk-ephemeral-run.md @@ -84,6 +84,10 @@ This design allows bcvk to provide VM-like isolation and boot behavior while lev Generate SSH keypair and inject via systemd credentials +**--network-isolation** + + Isolate the VM from the network. SSH access from the host is preserved, but the VM cannot reach the internet or other hosts + **--virtiofsd**=*VIRTIOFSD_BINARY* Path to virtiofsd binary (overrides auto-detection) @@ -272,6 +276,22 @@ For network-level port forwarding via podman, configure slirp4netns: --network slirp4netns:port_handler=slirp4netns,allow_host_loopback=true \ --name webvm localhost/mybootc +## Network Isolation + +Block all outbound network access from the VM while preserving SSH +connectivity from the host. This is useful for CI/CD pipelines where test +execution should not depend on external network resources: + + bcvk ephemeral run -d --rm -K \ + --network-isolation \ + --bind-storage-ro \ + --name testvm localhost/mybootc + +The VM can still be reached via **bcvk ephemeral ssh**, but processes inside +the guest cannot connect to the internet or other external hosts. Pre-stage +any required resources (container images, packages) on the host and share +them into the VM using **--bind**, **--ro-bind**, or **--bind-storage-ro**. + ## Instance Types Use predefined instance types for consistent resource allocation: diff --git a/docs/src/man/bcvk-libvirt-run.md b/docs/src/man/bcvk-libvirt-run.md index b9cbbe847..0379ae469 100644 --- a/docs/src/man/bcvk-libvirt-run.md +++ b/docs/src/man/bcvk-libvirt-run.md @@ -154,6 +154,10 @@ Run a bootable container as a persistent VM Enable graphical console (SPICE) for virt-manager access +**--network-isolation** + + Isolate the VM from the network. SSH access from the host is preserved, but the VM cannot reach the internet or other hosts + **--transient** Create a transient VM that disappears on shutdown/reboot @@ -162,6 +166,10 @@ Run a bootable container as a persistent VM Path to Ignition config file (JSON format) for first-boot provisioning +**--virtiofsd**=*VIRTIOFSD_BINARY* + + Path to virtiofsd binary (overrides auto-detection for disk creation) + **--console-log**=*CONSOLE_LOG* Log virtio console (OS/journald on hvc0) to this file (created if absent) @@ -219,6 +227,16 @@ Capture the platform console (UEFI/GRUB/serial) separately: --platform-console-log /var/home/user/vm-serial.log \ quay.io/fedora/fedora-bootc:42 +Create a network-isolated VM for hermetic testing: + + bcvk libvirt run --name test-runner \ + --network-isolation \ + --bind-storage-ro \ + quay.io/centos-bootc/centos-bootc:stream10 + +SSH access from the host is preserved, but processes inside the guest +cannot reach the internet or other external hosts. + Server management workflow: # Create a persistent server VM diff --git a/docs/src/man/bcvk-to-disk.md b/docs/src/man/bcvk-to-disk.md index f552c5440..e892280f2 100644 --- a/docs/src/man/bcvk-to-disk.md +++ b/docs/src/man/bcvk-to-disk.md @@ -115,6 +115,10 @@ The installation process: Generate SSH keypair and inject via systemd credentials +**--network-isolation** + + Isolate the VM from the network. SSH access from the host is preserved, but the VM cannot reach the internet or other hosts + **--virtiofsd**=*VIRTIOFSD_BINARY* Path to virtiofsd binary (overrides auto-detection)