diff --git a/example/alpine/init b/example/alpine/init index b652ec5..6166119 100644 --- a/example/alpine/init +++ b/example/alpine/init @@ -1,11 +1,13 @@ #!/bin/sh -# Mount special filesystems -mkdir -p /dev /proc /sys +# Create console device +# TODO: is this necessary? [ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 + +# Mount special filesystems +mkdir -p /proc /sys mount -t proc proc /proc mount -t sysfs sysfs /sys -mount -t devtmpfs dev /dev # Networking echo slim > /proc/sys/kernel/hostname diff --git a/example/archlinux/init b/example/archlinux/init index 28c7a54..b00d629 100644 --- a/example/archlinux/init +++ b/example/archlinux/init @@ -5,7 +5,6 @@ mkdir -p /dev /proc /sys [ -c /dev/console ] || mknod -m 600 /dev/console c 5 1 mount -t proc proc /proc mount -t sysfs sysfs /sys -mount -t devtmpfs dev /dev # Networking echo slim > /proc/sys/kernel/hostname diff --git a/src/build.rs b/src/build.rs index db4aff4..b758fda 100644 --- a/src/build.rs +++ b/src/build.rs @@ -43,10 +43,10 @@ pub(crate) fn build(BuildCmd { kind, name, init }: BuildCmd) -> Result<()> { ImageKind::Qcow2 => build_qcow2(image, &mount_path), }; - let unmounted = Command::new("podman") - .args(["unshare", "--", "podman", "image", "unmount", image]) - .output() - .is_ok_and(|out| out.status.success()); + let unmounted = cmd(&[ + "podman", "unshare", "--", "podman", "image", "unmount", image, + ]) + .is_ok(); if unmounted { println!("Unmounted image."); } else { @@ -137,19 +137,15 @@ fn build_initrd(image: &str, mount_path: &Path) -> Result<()> { println!("Registry: {}", reg_dir.display()); let vmlinuz_dst = reg_dir.join("vmlinuz"); - let cp_status = Command::new("podman") - .args([ - "unshare", - "--", - "cp", - &vmlinuz_src, - vmlinuz_dst.to_str().context("registry path is not UTF-8")?, - ]) - .status() - .context("Failed to copy vmlinuz out of the image mount")?; - if !cp_status.success() { - bail!("Failed to copy vmlinuz to registry"); - } + cmd(&[ + "podman", + "unshare", + "--", + "cp", + &vmlinuz_src, + vmlinuz_dst.to_str().context("registry path is not UTF-8")?, + ]) + .context("Failed to copy vmlinuz out of the image mount")?; println!("Copied vmlinuz -> {}", vmlinuz_dst.display()); // Pack the rootfs as a gzipped newc cpio archive; vmlinuz is excluded diff --git a/src/command.rs b/src/command.rs index 8ac1d53..f1f0c26 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2,6 +2,10 @@ use std::process::Command; use anyhow::{Context, anyhow, bail}; +/// Spawn a subprocess with arguments. +/// +/// Collects `stdout` and returns it on success. +/// Returns `Err` if the exit code isn't 0, or if the process failed to spawn. pub fn cmd(cmd: &[&str]) -> anyhow::Result { let [cmd, args @ ..] = cmd else { bail!("missing command"); diff --git a/src/forward.rs b/src/forward.rs new file mode 100644 index 0000000..0bd650e --- /dev/null +++ b/src/forward.rs @@ -0,0 +1,121 @@ +use std::{ + fmt::{self, Display}, + net::{Ipv4Addr, SocketAddr}, + str::FromStr, +}; + +use anyhow::{Context, anyhow, bail}; +use serde::{Deserialize, Serialize, de::Error}; + +#[derive(Clone, Copy, Debug)] +pub enum Protocol { + Tcp, + Udp, +} + +#[derive(Clone, Copy, Debug)] +pub struct PortForward { + pub protocol: Protocol, + pub from: SocketAddr, + pub to_addr: Option, + pub to_port: u16, +} + +impl Display for Protocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Protocol::Tcp => "tcp", + Protocol::Udp => "udp", + }) + } +} + +impl Display for PortForward { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}-", self.protocol, self.from)?; + if let Some(to_addr) = self.to_addr { + write!(f, ":{}", to_addr)?; + } + write!(f, ":{}", self.to_port) + } +} + +impl FromStr for PortForward { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let result = || { + let (protocol, s) = s.split_once(':').context("Missing ':'")?; + let (from, to) = s.split_once('-').context("Missing '-'")?; + let (to_addr, to_port) = to.split_once(':').context("Missing ':'")?; + + let from = if let Some(from) = from.strip_prefix(':') { + let port: u16 = from.parse().context("Invalid port number")?; + SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port) + } else { + from.parse()? + }; + + let to_addr = if to_addr.is_empty() { + None + } else { + Some(to_addr.parse()?) + }; + + Ok(PortForward { + from, + to_addr, + to_port: to_port.parse()?, + protocol: match protocol { + "udp" => Protocol::Udp, + "tcp" => Protocol::Tcp, + _ => bail!("Invalid protocol, expected 'udp' or 'tcp'"), + }, + }) + }; + + result() + .with_context(|| anyhow!("Expected '(tcp|udp):
-
', got {s:?}")) + .context("Malformed PortForward string") + } +} + +impl Serialize for PortForward { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + format_args!("{}", self).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PortForward { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + s.parse().map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use super::PortForward; + + #[test] + fn parse_port_forwards() { + let valid = [ + "tcp:0.0.0.0:80-:8080", + "udp::1234-:1234", + "tcp:0.0.0.0:80-1.2.3.4:8080", + "udp::1234-255.255.255.255:1234", + ]; + + for valid in valid { + PortForward::from_str(valid).expect("Failed to parse a valid port forward string"); + } + } +} diff --git a/src/main.rs b/src/main.rs index 2cb6a59..626642a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod build; mod command; +mod forward; mod image; mod meta; mod qemu; diff --git a/src/qemu.rs b/src/qemu.rs index d8e1c04..b617382 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -3,9 +3,10 @@ use anyhow::{Context, Result, bail}; use clap::Args; -use std::process::Command; +use std::{fmt::Write as _, process::Command}; use crate::{ + forward::PortForward, meta::Meta, registry::{registry_base_dir, registry_dir}, }; @@ -18,9 +19,19 @@ pub struct RunCmd { /// Amount of memory to give the VM, in qemu's format. #[clap(short, long, default_value = "1024M")] memory: String, + + /// Forward ports from host to guest. Example: `tcp:0.0.0.0:80-:8080` + #[clap(long)] + forward: Vec, } -pub(crate) fn run(RunCmd { name, memory }: RunCmd) -> Result<()> { +pub(crate) fn run( + RunCmd { + name, + memory, + forward, + }: RunCmd, +) -> Result<()> { let reg_dir = registry_dir(&name)?; let vmlinuz_path = registry_base_dir()?.join("vmlinuz"); let initrd_path = reg_dir.join("initrd"); @@ -41,6 +52,7 @@ pub(crate) fn run(RunCmd { name, memory }: RunCmd) -> Result<()> { "earlyprintk=serial", "nokaslr", &init_arg, + "devtmpfs.mount=1", // Automatically mount /dev at boot ]; let qcow2_arg = format!("file={},format=qcow2,if=virtio", qcow2_path.display()); @@ -63,15 +75,26 @@ pub(crate) fn run(RunCmd { name, memory }: RunCmd) -> Result<()> { let cmdline = cmdline.join(" "); + // add NIC and forward any user specified ports + let mut network = "user,model=virtio-net-pci".to_string(); + for forward in &forward { + _ = write!(&mut network, ",hostfwd={forward}"); + } + let mut command = Command::new("qemu-system-x86_64"); + + // TODO: make a lot of this configurable. especially -cpus command + .args(["-accel", "kvm"]) + .args(["-cpu", "host", "-smp", "cpus=8"]) .args(["-m", &memory]) .arg("-kernel") .arg(vmlinuz_path.as_os_str()) .args(fs_args) + .args(["-snapshot"]) .args(["-append", &cmdline]) .args(["-nographic"]) - .args(["-nic", "user,model=virtio-net-pci"]); + .args(["-nic", &network]); println!("{command:?}");