summary refs log tree commit diff
path: root/host
diff options
context:
space:
mode:
authorAlyssa Ross <hi@alyssa.is>2022-02-02 15:19:20 +0000
committerAlyssa Ross <hi@alyssa.is>2022-02-14 14:17:19 +0000
commit12e4cd012bd6338713c870b8116dd1ea9db7a354 (patch)
tree01db6e492df1e0de0b9fb640f54dd159753671cd /host
parent42c9aaaf111269624e751e3140ccc9460c365b3d (diff)
downloadspectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.tar
spectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.tar.gz
spectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.tar.bz2
spectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.tar.lz
spectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.tar.xz
spectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.tar.zst
spectrum-12e4cd012bd6338713c870b8116dd1ea9db7a354.zip
host/rootfs: dynamically generate s6-rc services
This way, we don't allow arbitrary code from the ext partition to run
on the host system, which gives us better integrity guarantees when
paired with Secure Boot.  This new scheme also makes it easy to
introspect VMs, since they're defined using a very limited
configuration language.
Diffstat (limited to 'host')
-rw-r--r--host/rootfs/Makefile1
-rw-r--r--host/rootfs/default.nix17
-rwxr-xr-xhost/rootfs/etc/graphical-console18
-rw-r--r--host/rootfs/etc/s6-rc/ext-rc-init/up34
-rw-r--r--host/rootfs/etc/s6-rc/ext-rc/up2
-rw-r--r--host/start-vm/default.nix29
-rw-r--r--host/start-vm/meson.build12
-rw-r--r--host/start-vm/modprobe.rs49
-rw-r--r--host/start-vm/net-util.c89
-rw-r--r--host/start-vm/net-util.h11
-rw-r--r--host/start-vm/net.c222
-rw-r--r--host/start-vm/net.rs31
-rw-r--r--host/start-vm/shell.nix13
-rw-r--r--host/start-vm/start-vm.rs114
14 files changed, 626 insertions, 16 deletions
diff --git a/host/rootfs/Makefile b/host/rootfs/Makefile
index 88b8d63..522e189 100644
--- a/host/rootfs/Makefile
+++ b/host/rootfs/Makefile
@@ -16,6 +16,7 @@ build/rootfs.ext4: build/rootfs.tar
 FILES = \
 	etc/fonts/fonts.conf \
 	etc/fstab \
+	etc/graphical-console \
 	etc/group \
 	etc/init \
 	etc/login \
diff --git a/host/rootfs/default.nix b/host/rootfs/default.nix
index 9c7f910..e5abfe2 100644
--- a/host/rootfs/default.nix
+++ b/host/rootfs/default.nix
@@ -11,6 +11,8 @@
 let
   inherit (lib) cleanSource cleanSourceWith concatMapStringsSep;
 
+  start-vm = import ../start-vm { pkgs = pkgs.pkgsStatic; };
+
   pkgsGui = pkgs.pkgsMusl.extend (final: super: {
     systemd = final.libudev-zero;
   });
@@ -19,7 +21,7 @@ let
 
   packages = [
     cloud-hypervisor curl execline jq mdevd mktuntap s6 s6-linux-utils
-    s6-portable-utils s6-rc screen
+    s6-portable-utils s6-rc screen start-vm
     pkgs.pkgsMusl.cryptsetup
     (busybox.override {
       extraConfig = ''
@@ -81,18 +83,7 @@ let
   extFs = runCommand "ext.ext4" {
     nativeBuildInputs = [ tar2ext4 s6-rc ];
   } ''
-    mkdir s6-rc svc
-
-    tar -C ${netvm}/s6-rc -c . | tar -C s6-rc -x
-    chmod +w s6-rc
-    tar -C ${appvm-catgirl}/s6-rc -c . | tar -C s6-rc -x
-    chmod +w s6-rc
-    tar -C ${appvm-lynx}/s6-rc -c . | tar -C s6-rc -x
-    chmod +w s6-rc
-    mkdir s6-rc/default
-    echo bundle > s6-rc/default/type
-    printf "appvm-catgirl\nappvm-lynx\n" > s6-rc/default/contents
-    s6-rc-compile svc/s6-rc s6-rc
+    mkdir svc
 
     tar -C ${netvm} -c data | tar -C svc -x
     chmod +w svc/data
diff --git a/host/rootfs/etc/graphical-console b/host/rootfs/etc/graphical-console
new file mode 100755
index 0000000..94a6726
--- /dev/null
+++ b/host/rootfs/etc/graphical-console
@@ -0,0 +1,18 @@
+#!/bin/execlineb -P
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2021-2022 Alyssa Ross <hi@alyssa.is>
+
+export WAYLAND_DISPLAY wayland-1
+export XDG_RUNTIME_DIR /run/user/0
+
+backtick -E name {
+  backtick -E pwd { pwd }
+  basename $pwd
+}
+
+backtick -E pty {
+  pipeline -w { jq -r .config.console.file }
+  ch-remote --api-socket ../${name}-vmm/env/cloud-hypervisor.sock info
+}
+
+foot -T $name --pty $pty
diff --git a/host/rootfs/etc/s6-rc/ext-rc-init/up b/host/rootfs/etc/s6-rc/ext-rc-init/up
index a4470c2..80cf932 100644
--- a/host/rootfs/etc/s6-rc/ext-rc-init/up
+++ b/host/rootfs/etc/s6-rc/ext-rc-init/up
@@ -1,4 +1,34 @@
 # SPDX-License-Identifier: EUPL-1.2
-# SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+# SPDX-FileCopyrightText: 2021-2022 Alyssa Ross <hi@alyssa.is>
 
-s6-rc-init -c /ext/svc/s6-rc -l /run/s6-rc.ext -p ext- /run/service
+if { mkdir -p /run/s6-rc.ext.src }
+
+if {
+  elglob -0 dirs /ext/svc/data/*/
+  forx -E dir { $dirs }
+  backtick -E name { basename -- $dir }
+
+  cd /run/s6-rc.ext.src
+
+  if {
+    mkdir -- $name ${name}/dependencies.d ${name}-vmm ${name}-vmm/dependencies.d
+      ${name}-vmm/env
+  }
+  if { redirfd -w 1 ${name}-vmm/type echo longrun }
+  if { redirfd -w 1 ${name}-vmm/notification-fd echo 3 }
+  if { ln -s -- /bin/start-vm ${name}-vmm/run }
+
+  if {
+    elglob -0 paths /ext/svc/data/${name}/providers/net/*
+    forx -pE path { $paths }
+    backtick -E dep { basename -- $path }
+    touch -- ${name}-vmm/dependencies.d/${dep}
+  }
+
+  if { redirfd -w 1 ${name}/type echo longrun }
+  if { ln -s -- /etc/graphical-console ${name}/run }
+  if { touch -- ${name}/dependencies.d/${name}-vmm }
+}
+
+if { s6-rc-compile /run/s6-rc.ext.db /run/s6-rc.ext.src }
+s6-rc-init -c /run/s6-rc.ext.db -l /run/s6-rc.ext -p ext- /run/service
diff --git a/host/rootfs/etc/s6-rc/ext-rc/up b/host/rootfs/etc/s6-rc/ext-rc/up
index bed07a2..fe1baae 100644
--- a/host/rootfs/etc/s6-rc/ext-rc/up
+++ b/host/rootfs/etc/s6-rc/ext-rc/up
@@ -1,4 +1,4 @@
 # SPDX-License-Identifier: EUPL-1.2
 # SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
 
-s6-rc -l /run/s6-rc.ext -u change default
+s6-rc -l /run/s6-rc.ext -u change appvm-catgirl appvm-lynx
diff --git a/host/start-vm/default.nix b/host/start-vm/default.nix
new file mode 100644
index 0000000..8fd4933
--- /dev/null
+++ b/host/start-vm/default.nix
@@ -0,0 +1,29 @@
+# SPDX-License-Identifier: MIT
+# SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+{ pkgs ? import <nixpkgs> {} }: pkgs.callPackage (
+{ pkgsBuildHost, lib, stdenv, fetchpatch, rust, ninja, rustc }:
+
+let
+  inherit (lib) cleanSource;
+
+  meson' = pkgsBuildHost.meson_0_60.overrideAttrs ({ patches ? [], ... }: {
+    patches = patches ++ [
+      (fetchpatch {
+        url = "https://github.com/alyssais/meson/commit/e8464d47fa8971098d626744b14db5d066ebf753.patch";
+        sha256 = "0naxj0s16w6ffk6d7xg1m6kkx2a7zd0hz8mbvn70xy1k12a0c5gy";
+      })
+    ];
+  });
+in
+
+stdenv.mkDerivation {
+  name = "start-vm";
+
+  src = cleanSource ./.;
+
+  nativeBuildInputs = [ meson' ninja rustc ];
+
+  dontStrip = true;
+}
+) { }
diff --git a/host/start-vm/meson.build b/host/start-vm/meson.build
new file mode 100644
index 0000000..5a6f72b
--- /dev/null
+++ b/host/start-vm/meson.build
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: EUPL-1.2
+# SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+project('start-vm', 'rust', 'c',
+  default_options : ['rust_std=2018', 'warning_level=3'])
+
+c_lib = static_library('start-vm-c', 'net.c', 'net-util.c',
+  c_args : ['-D_GNU_SOURCE'])
+
+executable('start-vm', 'start-vm.rs', 'net.rs', 'modprobe.rs',
+  link_with : c_lib,
+  install : true)
diff --git a/host/start-vm/modprobe.rs b/host/start-vm/modprobe.rs
new file mode 100644
index 0000000..6186820
--- /dev/null
+++ b/host/start-vm/modprobe.rs
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+use std::ffi::OsStr;
+use std::fmt::{self, Display, Formatter};
+use std::io;
+use std::os::unix::prelude::*;
+use std::process::{Command, ExitStatus};
+
+#[derive(Debug)]
+pub enum ModprobeError {
+    Spawn(io::Error),
+    Fail(ExitStatus),
+}
+
+impl Display for ModprobeError {
+    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+        use ModprobeError::*;
+        match self {
+            Spawn(e) => write!(f, "failed to spawn modprobe: {}", e),
+            Fail(status) => {
+                if let Some(code) = status.code() {
+                    write!(f, "modprobe exited with status {}", code)
+                } else {
+                    write!(f, "modprobe killed by signal {}", status.signal().unwrap())
+                }
+            }
+        }
+    }
+}
+
+pub fn modprobe<I, S>(module_names: I) -> Result<(), ModprobeError>
+where
+    I: IntoIterator<Item = S>,
+    S: AsRef<OsStr>,
+{
+    let status = Command::new("modprobe")
+        .arg("-q")
+        .arg("--")
+        .args(module_names)
+        .status()
+        .map_err(ModprobeError::Spawn)?;
+
+    if status.success() {
+        Ok(())
+    } else {
+        Err(ModprobeError::Fail(status))
+    }
+}
diff --git a/host/start-vm/net-util.c b/host/start-vm/net-util.c
new file mode 100644
index 0000000..70d9385
--- /dev/null
+++ b/host/start-vm/net-util.c
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+#include <fcntl.h>
+#include <net/if.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/ioctl.h>
+
+#include <linux/if_tun.h>
+#include <linux/sockios.h>
+
+// ifr_name doesn't have to be null terminated.
+#pragma GCC diagnostic ignored "-Wstringop-truncation"
+
+int if_up(const char *name)
+{
+	struct ifreq req;
+	int fd, r = -1;
+
+	if ((fd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0)) == -1)
+		return -1;
+
+	strncpy(req.ifr_name, name, IFNAMSIZ);
+	if (ioctl(fd, SIOCGIFFLAGS, &req) == -1)
+		goto out;
+	req.ifr_flags |= IFF_UP;
+	r = ioctl(fd, SIOCSIFFLAGS, &req);
+out:
+	close(fd);
+	return r;
+}
+
+int bridge_add(const char *name)
+{
+	int fd, r;
+	if ((fd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0)) == -1)
+		return -1;
+	r = ioctl(fd, SIOCBRADDBR, name);
+	close(fd);
+	return r;
+}
+
+int bridge_add_if(const char *brname, const char *ifname)
+{
+	struct ifreq ifr;
+	int fd, r;
+
+	strncpy(ifr.ifr_name, brname, IFNAMSIZ);
+	if (!(ifr.ifr_ifindex = if_nametoindex(ifname)))
+		return -1;
+
+	if ((fd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0)) == -1)
+		return -1;
+
+	r = ioctl(fd, SIOCBRADDIF, &ifr);
+	close(fd);
+	return r;
+}
+
+int bridge_delete(const char *name)
+{
+	int fd, r;
+
+	if ((fd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0)) == -1)
+		return -1;
+
+	r = ioctl(fd, SIOCBRDELBR, name);
+	close(fd);
+	return r;
+}
+
+int tap_open(const char *name, int flags)
+{
+	struct ifreq ifr;
+	int fd;
+
+	if ((fd = open("/dev/net/tun", O_RDWR)) == -1)
+		return -1;
+
+	strncpy(ifr.ifr_name, name, IFNAMSIZ);
+	ifr.ifr_flags = IFF_TAP|flags;
+	if (!ioctl(fd, TUNSETIFF, &ifr))
+		return fd;
+
+	close(fd);
+	return -1;
+}
diff --git a/host/start-vm/net-util.h b/host/start-vm/net-util.h
new file mode 100644
index 0000000..79ee903
--- /dev/null
+++ b/host/start-vm/net-util.h
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+int if_up(const char *name);
+
+int bridge_add(const char *name);
+int bridge_add_if(const char *brname, const char *ifname);
+int bridge_delete(const char *name);
+
+int tap_open(const char *name, int flags);
+int tap_delete(const char *name);
diff --git a/host/start-vm/net.c b/host/start-vm/net.c
new file mode 100644
index 0000000..9a094aa
--- /dev/null
+++ b/host/start-vm/net.c
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+#include "net-util.h"
+
+#include <errno.h>
+#include <inttypes.h>
+#include <net/if.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <sys/uio.h>
+#include <sys/un.h>
+
+#include <linux/if_tun.h>
+
+#define MAC_STR_LEN 17
+
+int format_mac(char s[static MAC_STR_LEN + 1], const uint8_t mac[6])
+{
+	return snprintf(s, MAC_STR_LEN + 1,
+			"%.2hhX:%.2hhX:%.2hhX:%.2hhX:%.2hhX:%.2hhX",
+			mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+}
+
+static int dial_un(const char *sun_path)
+{
+	struct sockaddr_un addr = { 0 };
+	int fd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0);
+	if (fd == -1)
+		return -1;
+
+	addr.sun_family = AF_UNIX;
+	strncpy(addr.sun_path, sun_path, sizeof addr.sun_path);
+
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Warray-bounds"
+	// Safe because if the last byte of addr.sun_path is non-zero,
+	// sun_path must be at least one byte longer.
+	if (addr.sun_path[sizeof addr.sun_path - 1] &&
+	    sun_path[sizeof addr.sun_path]) {
+#pragma GCC diagnostic pop
+		errno = E2BIG;
+		goto fail;
+	}
+
+	if (connect(fd, (struct sockaddr *)&addr, sizeof addr) == -1)
+		goto fail;
+
+	return fd;
+fail:
+	close(fd);
+	return -1;
+}
+
+static int sendv_with_fd(int sock, const struct iovec iov[], size_t iovlen,
+			 int fd, int flags)
+{
+	struct msghdr msg = { 0 };
+	struct cmsghdr *cmsg;
+	union {
+		char buf[CMSG_SPACE(sizeof fd)];
+		struct cmsghdr _align;
+	} u;
+
+	msg.msg_iov = (struct iovec *)iov;
+	msg.msg_iovlen = iovlen;
+	msg.msg_control = u.buf;
+	msg.msg_controllen = sizeof u.buf;
+
+	cmsg = CMSG_FIRSTHDR(&msg);
+	cmsg->cmsg_level = SOL_SOCKET;
+	cmsg->cmsg_type = SCM_RIGHTS;
+	cmsg->cmsg_len = CMSG_LEN(sizeof fd);
+	memcpy(CMSG_DATA(cmsg), &fd, sizeof fd);
+
+	return sendmsg(sock, &msg, flags);
+}
+
+static int ch_add_net(const char *vm_name, int tap, const uint8_t mac[6])
+{
+	char mac_s[MAC_STR_LEN + 1];
+	char path[sizeof ((struct sockaddr_un *)0)->sun_path] = { 0 };
+	int sock = -1;
+	uint16_t status = 0;
+	FILE *f = NULL;
+	static const char buf1[] =
+		"PUT /api/v1/vm.add-net HTTP/1.1\r\n"
+		"Host: localhost\r\n"
+		"Content-Type: application/json\r\n"
+		"Content-Length: 27\r\n"
+		"\r\n"
+		"{\"mac\":\"";
+	static const char buf2[] = "\"}";
+
+	if (format_mac(mac_s, mac) == -1)
+		return -1;
+
+	struct iovec iov[] = {
+		{ .iov_base = (void *)buf1, .iov_len = sizeof buf1 - 1 },
+		{ .iov_base = (void *)mac_s, .iov_len = MAC_STR_LEN },
+		{ .iov_base = (void *)buf2, .iov_len = sizeof buf2 - 1 },
+	};
+
+	if (snprintf(path, sizeof path,
+		     "/run/service/ext-%s-vmm/env/cloud-hypervisor.sock",
+		     vm_name) >= (ssize_t)sizeof path) {
+		errno = E2BIG;
+		return -1;
+	}
+
+	if ((sock = dial_un(path)) == -1)
+		goto out;
+
+	if (sendv_with_fd(sock, iov, sizeof iov / sizeof *iov, tap, 0) == -1)
+		goto out;
+
+	f = fdopen(sock, "r");
+	sock = -1; // now owned by f
+	if (!f)
+		goto out;
+
+	if (fscanf(f, "%*s %" SCNu16, &status) != 1)
+		status = 0;
+
+	if (status < 200 || status >= 300) {
+		fputs("Failed cloud-hypervisor API request:\n", stderr);
+		fflush(stderr);
+		writev(STDERR_FILENO, iov, sizeof iov / sizeof *iov);
+		fputs("\n", stderr);
+	}
+out:
+	close(sock);
+	if (f)
+		fclose(f);
+	return (200 <= status && status < 300) - 1;
+}
+
+static int setup_tap(const char *bridge_name, const char *tap_prefix)
+{
+	int fd;
+	char tap_name[IFNAMSIZ];
+
+	// We assume ≤16-bit pids.
+	if (snprintf(tap_name, sizeof tap_name, "%s%d",
+		     tap_prefix, getpid()) == -1)
+		return -1;
+	if ((fd = tap_open(tap_name, IFF_NO_PI|IFF_VNET_HDR|IFF_TUN_EXCL)) == -1)
+		goto out;
+	if (bridge_add_if(bridge_name, tap_name) == -1)
+		goto fail;
+	if (if_up(tap_name) == -1)
+		goto fail;
+
+	goto out;
+fail:
+	close(fd);
+	fd = -1;
+out:
+	return fd;
+}
+
+static int client_net_setup(const char *bridge_name)
+{
+	return setup_tap(bridge_name, "client");
+}
+
+static int router_net_setup(const char *bridge_name, const char *router_vm_name,
+			    const uint8_t mac[6])
+{
+	int r, fd = setup_tap(bridge_name, "router");
+	if (fd == -1)
+		return -1;
+
+	r = ch_add_net(router_vm_name, fd, mac);
+	close(fd);
+	return r;
+}
+
+struct net_config {
+	int fd;
+	char mac[6];
+};
+
+struct net_config net_setup(const char *router_vm_name)
+{
+	struct net_config r = { .fd = -1, .mac = { 0 } };
+	char bridge_name[IFNAMSIZ];
+	pid_t pid = getpid();
+	// We assume ≤16-bit pids.
+	uint8_t router_mac[6] = { 0x0A, 0xB3, 0xEC, 0x80, pid >> 8, pid };
+
+	memcpy(r.mac, router_mac, 6);
+	r.mac[3] = 0x00;
+
+	if (snprintf(bridge_name, sizeof bridge_name, "br%d", pid) == -1)
+		return r;
+
+	if (bridge_add(bridge_name) == -1)
+		goto out;
+	if (if_up(bridge_name) == -1)
+		goto fail_bridge;
+
+	if ((r.fd = client_net_setup(bridge_name)) == -1)
+		goto fail_bridge;
+
+	if (router_net_setup(bridge_name, router_vm_name, router_mac) == -1)
+		goto fail_bridge;
+
+	goto out;
+
+fail_bridge:
+	bridge_delete(bridge_name);
+	close(r.fd);
+	r.fd = -1;
+out:
+	return r;
+}
diff --git a/host/start-vm/net.rs b/host/start-vm/net.rs
new file mode 100644
index 0000000..d126d4a
--- /dev/null
+++ b/host/start-vm/net.rs
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+use std::os::raw::{c_char, c_int};
+
+#[repr(C)]
+pub struct NetConfig {
+    pub fd: i32,
+    pub mac: [u8; 6],
+}
+
+extern "C" {
+    pub fn net_setup(provider_vm_name: *const c_char) -> NetConfig;
+}
+
+pub fn format_mac(mac: &[u8; 6]) -> String {
+    extern "C" {
+        fn format_mac(s: *mut c_char, mac: *const [u8; 6]) -> c_int;
+    }
+
+    let mut s = vec![0; 18];
+
+    // Safe because s and mac are correctly sized.
+    assert_ne!(unsafe { format_mac(s.as_mut_ptr() as _, mac) }, -1);
+
+    // Drop the null byte.
+    s.pop();
+
+    // Safe because a formatted MAC address is always UTF-8.
+    unsafe { String::from_utf8_unchecked(s) }
+}
diff --git a/host/start-vm/shell.nix b/host/start-vm/shell.nix
new file mode 100644
index 0000000..c7f6365
--- /dev/null
+++ b/host/start-vm/shell.nix
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: MIT
+# SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
+
+{ pkgs ? import <nixpkgs> {} }:
+
+with pkgs;
+
+(import ./. { inherit pkgs; }).overrideAttrs (
+{ nativeBuildInputs ? [], ... }:
+
+{
+  nativeBuildInputs = nativeBuildInputs ++ [ rustfmt ];
+})
diff --git a/host/start-vm/start-vm.rs b/host/start-vm/start-vm.rs
new file mode 100644
index 0000000..7dba702
--- /dev/null
+++ b/host/start-vm/start-vm.rs
@@ -0,0 +1,114 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2022 Alyssa Ross <hi@alyssa.is>
+
+mod modprobe;
+mod net;
+
+use std::env::{args, current_dir};
+use std::ffi::{CString, OsString};
+use std::io::{self, ErrorKind};
+use std::os::unix::prelude::*;
+use std::path::PathBuf;
+use std::process::{exit, Command};
+
+use modprobe::modprobe;
+use net::{format_mac, net_setup, NetConfig};
+
+macro_rules! errx {
+    ($code:expr, $fmt:expr $(,$args:expr)*) => ({
+        let argv0_option = args().next();
+        let argv0 = argv0_option.as_ref().map(String::as_str).unwrap_or("start-vm");
+        eprintln!(concat!("{}: ", $fmt), argv0 $(,$args)*);
+        exit($code);
+    })
+}
+
+macro_rules! err {
+    ($code:expr, $fmt:expr $(,$args:expr)*) =>
+        (|e| errx!($code, concat!($fmt, ": {}") $(,$args)*, e))
+}
+
+fn main() {
+    modprobe(&["kvm-intel"]).unwrap_or_else(err!(1, "modprobe kvm-intel"));
+
+    let mut command = Command::new("s6-notifyoncheck");
+    command.args(&["-dc", "test -S env/cloud-hypervisor.sock"]);
+    command.arg("cloud-hypervisor");
+    command.args(&["--api-socket", "env/cloud-hypervisor.sock"]);
+    command.args(&["--cmdline", "console=ttyS0 root=/dev/vda"]);
+    command.args(&["--memory", "size=128M"]);
+    command.args(&["--console", "pty"]);
+
+    let mut dir = current_dir()
+        .unwrap_or_else(err!(1, "getting current directory"))
+        .into_os_string()
+        .into_vec();
+
+    if dir.ends_with(b"-vmm") && !dir.ends_with(b"/-vmm") {
+        dir.truncate(dir.len() - b"-vmm".len());
+    }
+    let dir = PathBuf::from(OsString::from_vec(dir));
+
+    let vm_name = dir
+        .file_name()
+        .unwrap_or_else(|| errx!(1, "current directory has no name"));
+
+    let mut net_providers_dir = PathBuf::new();
+    net_providers_dir.push("/ext/svc/data");
+    net_providers_dir.push(vm_name);
+    net_providers_dir.push("providers/net");
+
+    match net_providers_dir.read_dir() {
+        Ok(entries) => {
+            for r in entries {
+                let entry = r
+                    .unwrap_or_else(err!(1, "examining directory entry"))
+                    .file_name();
+
+                // Safe because prov is the name of a directory entry, so
+                // con't contain a null byte.
+                let provider_name = unsafe { CString::from_vec_unchecked(entry.into_vec()) };
+
+                // Safe because we pass a valid pointer and check the result.
+                let NetConfig { fd, mac } = unsafe { net_setup(provider_name.as_ptr()) };
+                if fd == -1 {
+                    let e = io::Error::last_os_error();
+                    errx!(1, "setting up networking failed: {}", e);
+                }
+
+                command
+                    .arg("--net")
+                    .arg(format!("fd={},mac={}", fd, format_mac(&mac)));
+
+                // TODO: to support multiple net providers, we'll need
+                // a better naming scheme for tap and bridge devices.
+                break;
+            }
+        }
+        Err(e) if e.kind() == ErrorKind::NotFound => {}
+        Err(e) => errx!(1, "reading directory {:?}: {}", net_providers_dir, e),
+    }
+
+    command.arg("--kernel").arg({
+        let mut kernel = OsString::from("/ext/svc/data/");
+        kernel.push(&vm_name);
+        kernel.push("/vmlinux");
+        kernel
+    });
+
+    command.arg("--disk").arg({
+        let mut disk = OsString::from("path=/ext/svc/data/");
+        disk.push(&vm_name);
+        disk.push("/rootfs.ext4,readonly=on");
+        disk
+    });
+
+    command.arg("--serial").arg({
+        let mut serial = OsString::from("file=/run/");
+        serial.push(&vm_name);
+        serial.push(".log");
+        serial
+    });
+
+    errx!(1, "failed to exec: {}", command.exec());
+}