Compare commits

...

6 Commits

Author SHA1 Message Date
b11c1c285c merge: harden ephvm 2026-04-23 23:38:54 +02:00
0c17996d16 feat: tighten ephvm perms, zstd compress qcow2
lock /home/matej/.config to 0700 (was 0755). post-process qcow2
with parallel zstd on qcow2 v3 via qemu-img convert; smaller
image and faster decompress than the built-in qcow2-compressed.
2026-04-23 21:32:04 +00:00
9ffc640c44 feat: prune vm-guest module
drop services.qemuGuest.enable (unused — serial + ssh cover
everything), drop sshfs package (unused), drop boot.kernelModules
for 9p since initrd availableKernelModules autoloads on first
mount.
2026-04-23 21:30:32 +00:00
fbcded1f9d feat: ephvm-run.sh virtio devices, require kvm
explicit virtio-blk-pci (cache=writeback, discard=unmap,
detect-zeroes=unmap, aio=threads), virtio-net-pci, virtio-rng-pci
for guest entropy. hard-require /dev/kvm and always pass -cpu host;
drop the tcg fallback since this host always has kvm.
2026-04-23 21:29:57 +00:00
082057226d feat: ephvm-run.sh resilience
poll for real SSH banner instead of TCP accept (qemu's user-mode
nic accepts before guest sshd is listening), preserve qemu log
on abnormal exit for inspection, use a throwaway ed25519 key
since the guest accepts any key.
2026-04-23 21:29:24 +00:00
620acf68a6 feat: harden ephvm-run.sh
reject running as root, bind ssh hostfwd to 127.0.0.1 only,
reject commas in --mount and claude paths (prevents -virtfs csv
injection), pre-check --mount path exists, enable qemu seccomp
sandbox.
2026-04-23 21:28:51 +00:00
3 changed files with 83 additions and 32 deletions

View File

@@ -43,19 +43,15 @@
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.qemuGuest.enable = true;
services.spice-vdagentd.enable = lib.mkIf (!cfg.headless) true;
boot.kernelParams = lib.mkIf cfg.headless [ "console=ttyS0,115200" ];
# 9p autoloads on first mount
boot.initrd.availableKernelModules = [
"9p"
"9pnet_virtio"
];
boot.kernelModules = [
"9p"
"9pnet_virtio"
];
networking = {
useDHCP = true;
@@ -68,7 +64,6 @@
curl
wget
htop
sshfs
];
}

View File

@@ -13,19 +13,38 @@
documentation.enable = false;
environment.defaultPackages = [ ];
# compressed qcow2, no channel copy
# qcow2, no channel copy; post-processed with parallel zstd on qcow2 v3
# (~half the size of zlib v2, faster decompress)
image.modules.qemu =
{ config, modulesPath, ... }:
{
system.build.image = lib.mkForce (
import (modulesPath + "/../lib/make-disk-image.nix") {
let
rawImage = import (modulesPath + "/../lib/make-disk-image.nix") {
inherit lib config pkgs;
inherit (config.virtualisation) diskSize;
inherit (config.image) baseName;
format = "qcow2-compressed";
format = "qcow2";
copyChannel = false;
partitionTableType = "legacy";
}
};
inherit (config.image) baseName;
in
pkgs.runCommand baseName { nativeBuildInputs = [ pkgs.qemu-utils ]; } ''
mkdir -p $out
# qemu-img caps -m at 16
cores="''${NIX_BUILD_CORES:-4}"
[ "$cores" -gt 0 ] || cores=4
[ "$cores" -gt 16 ] && cores=16
qemu-img convert \
-f qcow2 \
-O qcow2 \
-c \
-o compression_type=zstd \
-m "$cores" \
${rawImage}/${baseName}.qcow2 \
$out/${baseName}.qcow2
''
);
};
@@ -70,7 +89,7 @@
features.neovim.dotfiles = inputs.nvim;
# ensure .config exists with correct ownership before automount
systemd.tmpfiles.rules = [ "d /home/matej/.config 0755 matej users -" ];
systemd.tmpfiles.rules = [ "d /home/matej/.config 0700 matej users -" ];
# TODO:(@janezicmatej) replace ssh with virtio-console (hvc0) when qemu 11.0 lands
# https://www.mail-archive.com/qemu-devel@nongnu.org/msg1162844.html

View File

@@ -27,14 +27,32 @@ info() {
# globals for cleanup trap
CLEANUP_OVERLAY=""
CLEANUP_TMPDIR=""
QEMU_PID=""
VM_READY=false
cleanup() {
[ -n "$QEMU_PID" ] && kill "$QEMU_PID" 2>/dev/null && wait "$QEMU_PID" 2>/dev/null
[ -n "$CLEANUP_OVERLAY" ] && rm -rf "$CLEANUP_OVERLAY"
# preserve tmpdir on abnormal exit so the qemu log survives for inspection
if [ -n "$CLEANUP_TMPDIR" ]; then
if [ "$VM_READY" = true ]; then
rm -rf "$CLEANUP_TMPDIR"
else
echo "qemu log preserved: $CLEANUP_TMPDIR/qemu.log" >&2
fi
fi
return 0
}
trap cleanup EXIT
# returns 0 once the guest's sshd is speaking (first bytes are "SSH-")
awaiting_ssh_banner() {
local port="$1"
local banner
banner=$(timeout 2 bash -c "exec 3<>/dev/tcp/localhost/$port; head -c 4 <&3" 2>/dev/null) || return 1
[ "$banner" = "SSH-" ]
}
usage() {
cat <<EOF
Usage: ephvm-run.sh [options]
@@ -55,6 +73,8 @@ EOF
main() {
setup_colors
[ "$EUID" -eq 0 ] && die "ephvm-run.sh must not run as root"
local ssh_port="" memory=4G cpus=2 claude=true disk_size="" serial=false
local -a mounts=()
@@ -110,15 +130,13 @@ main() {
CLEANUP_OVERLAY=$(mktemp -d)
local overlay="$CLEANUP_OVERLAY/overlay.qcow2"
qemu-img create -f qcow2 -b "$(realpath "$image")" -F qcow2 "$overlay" "$disk_size"
drive_arg="file=$overlay,format=qcow2"
drive_arg="if=none,id=hd0,file=$overlay,format=qcow2,cache=writeback,aio=threads,discard=unmap,detect-zeroes=unmap"
else
drive_arg="file=$image,format=qcow2,snapshot=on"
drive_arg="if=none,id=hd0,file=$image,format=qcow2,snapshot=on,cache=writeback,aio=threads,discard=unmap,detect-zeroes=unmap"
fi
command -v qemu-system-x86_64 &>/dev/null || die "qemu-system-x86_64 not found"
local accel="tcg"
[ -r /dev/kvm ] && accel="kvm"
[ -r /dev/kvm ] || die "/dev/kvm not readable; kvm is required"
# auto-allocate ssh port unless serial mode
if [ "$serial" = false ] && [ -z "$ssh_port" ]; then
@@ -128,28 +146,33 @@ main() {
done
fi
local nic_arg="user"
local nic_arg="user,model=virtio-net-pci"
if [ -n "$ssh_port" ]; then
nic_arg="user,hostfwd=tcp::${ssh_port}-:22"
nic_arg="user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:${ssh_port}-:22"
fi
local -a qemu_args=(
qemu-system-x86_64
-accel "$accel"
-accel kvm
-cpu host
-m "$memory"
-smp "$cpus"
-drive "$drive_arg"
-device "virtio-blk-pci,drive=hd0"
-device virtio-rng-pci
-nic "$nic_arg"
-nographic
-sandbox "on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny"
)
if [ "$accel" != "tcg" ]; then
qemu_args+=(-cpu host)
fi
local fs_id=0 mount_path name tag
for mount_path in "${mounts[@]}"; do
[ -e "$mount_path" ] || die "--mount path does not exist: $mount_path"
mount_path=$(realpath "$mount_path")
# qemu parses -virtfs as csv, a comma in the path would inject options
case "$mount_path" in
*,*) die "--mount path may not contain commas: $mount_path" ;;
esac
name=$(basename "$mount_path")
tag="m_${name:0:29}"
qemu_args+=(
@@ -163,6 +186,9 @@ main() {
mkdir -p "$CLAUDE_CONFIG_DIR"
local claude_dir
claude_dir=$(realpath "$CLAUDE_CONFIG_DIR")
case "$claude_dir" in
*,*) die "claude config dir may not contain commas: $claude_dir" ;;
esac
qemu_args+=(
-virtfs "local,path=$claude_dir,mount_tag=claude,security_model=none,id=fs${fs_id}"
@@ -171,27 +197,38 @@ main() {
fi
info "---"
info "Accel: $accel"
[ -n "$ssh_port" ] && info "SSH: ssh -p $ssh_port matej@localhost"
info "---"
if [ "$serial" = true ]; then
exec "${qemu_args[@]}"
fi
CLEANUP_TMPDIR=$(mktemp -d)
local qemu_log="$CLEANUP_TMPDIR/qemu.log"
# start qemu in background and auto-ssh
"${qemu_args[@]}" &>/dev/null &
"${qemu_args[@]}" &>"$qemu_log" &
QEMU_PID=$!
# throwaway ssh key (vm accepts any key via AuthorizedKeysCommand)
local ssh_key="$CLEANUP_TMPDIR/id_ed25519"
ssh-keygen -t ed25519 -f "$ssh_key" -N "" -q
info "waiting for vm (port $ssh_port)..."
local attempts=0
while ! (echo > /dev/tcp/localhost/"$ssh_port") 2>/dev/null; do
# poll for the real SSH banner, not TCP accept: qemu's user-mode nic
# accepts host-side the moment qemu starts, well before guest sshd is up
while ! awaiting_ssh_banner "$ssh_port"; do
attempts=$((attempts + 1))
[ $attempts -gt 60 ] && die "vm did not become ready in 60s"
[ $attempts -gt 120 ] && die "vm did not become ready in 60s"
kill -0 "$QEMU_PID" 2>/dev/null || die "qemu exited unexpectedly"
sleep 1
sleep 0.5
done
VM_READY=true
ssh -p "$ssh_port" -t \
-i "$ssh_key" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR \