This commit is contained in:
2026-03-02 16:35:14 +01:00
parent d182532b34
commit 3eb7c5a979
13 changed files with 611 additions and 52 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ result-*
# Ignore automatically generated direnv output
.direnv
# Ignore generated seed ISOs
*.iso

69
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,69 @@
stages:
- build
- upload
- release
build-x86_64:
stage: build
image: ubuntu:24.04
script:
- apt-get update && apt-get install -y curl xz-utils sudo
- curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --no-confirm
- . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
- nix build .#nixosConfigurations.sandbox.config.system.build.image --out-link result-x86_64
- cp $(find -L result-x86_64 -name '*.qcow2') sandbox-x86_64.qcow2
artifacts:
paths:
- sandbox-x86_64.qcow2
expire_in: 1 week
build-aarch64:
stage: build
image: ubuntu:24.04
tags:
- aarch64
allow_failure: true
script:
- apt-get update && apt-get install -y curl xz-utils sudo
- curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --no-confirm
- . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
- nix build .#nixosConfigurations.sandbox-aarch64.config.system.build.image --out-link result-aarch64
- cp $(find -L result-aarch64 -name '*.qcow2') sandbox-aarch64.qcow2
artifacts:
paths:
- sandbox-aarch64.qcow2
expire_in: 1 week
upload:
stage: upload
image: curlimages/curl:latest
rules:
- if: $CI_COMMIT_TAG
script:
- |
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file sandbox-x86_64.qcow2 \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sandbox/${CI_COMMIT_TAG}/sandbox-x86_64.qcow2"
- |
if [ -f sandbox-aarch64.qcow2 ]; then
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file sandbox-aarch64.qcow2 \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sandbox/${CI_COMMIT_TAG}/sandbox-aarch64.qcow2"
fi
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
script:
- echo "Creating release $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
description: "Sandbox VM $CI_COMMIT_TAG"
assets:
links:
- name: sandbox-x86_64.qcow2
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sandbox/${CI_COMMIT_TAG}/sandbox-x86_64.qcow2"
- name: sandbox-aarch64.qcow2
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/sandbox/${CI_COMMIT_TAG}/sandbox-aarch64.qcow2"

39
dist/make-seed.sh vendored Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: make-seed.sh <pubkey-file> [output.iso]"
echo "Creates a seed ISO with the given SSH public key."
exit 1
}
[ "${1:-}" ] || usage
PUBKEY_FILE="$1"
OUTPUT="${2:-seed.iso}"
if [ ! -f "$PUBKEY_FILE" ]; then
echo "error: public key file not found: $PUBKEY_FILE"
exit 1
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cp "$PUBKEY_FILE" "$TMPDIR/authorized_keys"
if command -v mkisofs >/dev/null 2>&1; then
ISO_CMD="mkisofs"
elif command -v genisoimage >/dev/null 2>&1; then
ISO_CMD="genisoimage"
else
echo "error: mkisofs or genisoimage required"
echo " linux: sudo apt install genisoimage"
echo " macos: brew install cdrtools"
echo " nix: nix shell nixpkgs#cdrtools"
exit 1
fi
"$ISO_CMD" -quiet -V SEEDCONFIG -J -R -o "$OUTPUT" "$TMPDIR"
echo "seed ISO created: $OUTPUT"

182
dist/run.sh vendored Executable file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# defaults
SSH_PORT=2222
MEMORY=8G
CPUS=4
PROJECTS=""
CLAUDE_DIR=""
SSH_KEY=""
SEED_ISO=""
IMAGE=""
GUEST_ARCH=""
usage() {
cat <<EOF
Usage: run.sh <image.qcow2> [options]
Options:
--arch <arch> Guest architecture (auto-detected from image name)
--ssh-key <key.pub> SSH public key (auto-generates seed ISO)
--seed-iso <iso> Pre-built seed ISO (alternative to --ssh-key)
--projects <path> Mount host directory as ~/projects in VM
--claude-dir <path> Mount host .claude directory for auth
--memory <size> VM memory (default: 8G)
--cpus <n> VM CPUs (default: 4)
--ssh-port <port> SSH port forward (default: 2222)
EOF
exit 1
}
[ "${1:-}" ] || usage
IMAGE="$1"
shift
while [ $# -gt 0 ]; do
case "$1" in
--arch) GUEST_ARCH="$2"; shift 2 ;;
--ssh-key) SSH_KEY="$2"; shift 2 ;;
--seed-iso) SEED_ISO="$2"; shift 2 ;;
--projects) PROJECTS="$2"; shift 2 ;;
--claude-dir) CLAUDE_DIR="$2"; shift 2 ;;
--memory) MEMORY="$2"; shift 2 ;;
--cpus) CPUS="$2"; shift 2 ;;
--ssh-port) SSH_PORT="$2"; shift 2 ;;
*) echo "unknown option: $1"; usage ;;
esac
done
if [ ! -f "$IMAGE" ]; then
echo "error: image not found: $IMAGE"
exit 1
fi
# detect guest architecture from image filename if not specified
if [ -z "$GUEST_ARCH" ]; then
case "$IMAGE" in
*aarch64*|*arm64*) GUEST_ARCH="aarch64" ;;
*x86_64*|*amd64*) GUEST_ARCH="x86_64" ;;
*)
# fallback to host architecture
case "$(uname -m)" in
x86_64|amd64) GUEST_ARCH="x86_64" ;;
aarch64|arm64) GUEST_ARCH="aarch64" ;;
*) echo "error: cannot detect guest arch, use --arch"; exit 1 ;;
esac
;;
esac
fi
# normalize
case "$GUEST_ARCH" in
x86_64|amd64) GUEST_ARCH="x86_64" ;;
aarch64|arm64) GUEST_ARCH="aarch64" ;;
*) echo "error: unsupported architecture: $GUEST_ARCH"; exit 1 ;;
esac
# platform detection
HOST_ARCH=$(uname -m)
OS=$(uname -s)
ACCEL="tcg"
case "$OS" in
Linux)
[ -r /dev/kvm ] && ACCEL="kvm"
;;
Darwin)
# hvf only works when guest matches host
case "$HOST_ARCH" in
aarch64|arm64) [ "$GUEST_ARCH" = "aarch64" ] && ACCEL="hvf" ;;
x86_64|amd64) [ "$GUEST_ARCH" = "x86_64" ] && ACCEL="hvf" ;;
esac
;;
esac
case "$GUEST_ARCH" in
x86_64) QEMU_BIN="qemu-system-x86_64" ;;
aarch64) QEMU_BIN="qemu-system-aarch64" ;;
esac
# auto-generate seed ISO from SSH key
CLEANUP_SEED=""
if [ -n "$SSH_KEY" ] && [ -z "$SEED_ISO" ]; then
SEED_ISO=$(mktemp /tmp/seed-XXXXXX.iso)
CLEANUP_SEED="$SEED_ISO"
bash "$SCRIPT_DIR/make-seed.sh" "$SSH_KEY" "$SEED_ISO"
fi
cleanup() {
[ -n "$CLEANUP_SEED" ] && rm -f "$CLEANUP_SEED"
}
trap cleanup EXIT
# build qemu command
QEMU_ARGS=(
"$QEMU_BIN"
-accel "$ACCEL"
-m "$MEMORY"
-smp "$CPUS"
-drive "file=$IMAGE,format=qcow2,snapshot=on"
-nic "user,hostfwd=tcp::${SSH_PORT}-:22"
-nographic
)
# aarch64 guest needs machine type and uefi firmware
if [ "$GUEST_ARCH" = "aarch64" ]; then
if [ "$ACCEL" = "hvf" ]; then
QEMU_ARGS+=(-machine virt -cpu host)
else
QEMU_ARGS+=(-machine virt -cpu max)
fi
EFI_CODE=""
for p in \
/opt/homebrew/share/qemu/edk2-aarch64-code.fd \
/usr/local/share/qemu/edk2-aarch64-code.fd \
/usr/share/qemu-efi-aarch64/QEMU_EFI.fd \
/usr/share/AAVMF/AAVMF_CODE.fd; do
[ -f "$p" ] && EFI_CODE="$p" && break
done
if [ -z "$EFI_CODE" ]; then
echo "error: aarch64 EFI firmware not found"
echo " macos: brew install qemu"
echo " linux: apt install qemu-efi-aarch64"
exit 1
fi
QEMU_ARGS+=(-bios "$EFI_CODE")
fi
# seed ISO
if [ -n "$SEED_ISO" ]; then
QEMU_ARGS+=(-drive "file=$SEED_ISO,format=raw,media=cdrom,readonly=on")
fi
# 9p mounts
if [ -n "$PROJECTS" ]; then
QEMU_ARGS+=(
-virtfs "local,path=$PROJECTS,mount_tag=projects,security_model=mapped-xattr,id=fs0"
)
fi
if [ -n "$CLAUDE_DIR" ]; then
QEMU_ARGS+=(
-virtfs "local,path=$CLAUDE_DIR,mount_tag=hostclaude,security_model=mapped-xattr,id=fs1"
)
# also mount parent home for .claude.json
QEMU_ARGS+=(
-virtfs "local,path=$(dirname "$CLAUDE_DIR"),mount_tag=hosthome,security_model=mapped-xattr,id=fs2,readonly=on"
)
fi
echo "---"
echo "Guest: $GUEST_ARCH | Accel: $ACCEL"
echo "SSH: ssh -A -p $SSH_PORT gordaina@localhost"
echo "Password: sandbox"
echo "---"
exec "${QEMU_ARGS[@]}"

View File

@@ -93,6 +93,16 @@
system = "x86_64-linux";
users = [ ];
};
# nixos-rebuild build-image --image-variant qemu --flake .#sandbox
sandbox = mkHost "sandbox" {
system = "x86_64-linux";
users = [ "gordaina" ];
};
sandbox-aarch64 = mkHost "sandbox" {
system = "aarch64-linux";
users = [ "gordaina" ];
};
};
nixosModules = import ./modules/nixos {

View File

@@ -65,6 +65,7 @@ in
base16Scheme = "${pkgs.base16-schemes}/share/themes/gruvbox-material-dark-medium.yaml";
};
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;

View File

@@ -0,0 +1,128 @@
{
pkgs,
lib,
inputs,
config,
...
}:
{
imports = [
./hardware-configuration.nix
inputs.self.nixosModules.vm-guest
inputs.self.nixosModules.seed-ssh
inputs.self.nixosModules.zsh
inputs.self.nixosModules.localisation
];
vm-guest = {
enable = true;
headless = true;
};
seed-ssh = {
enable = true;
user = "gordaina";
};
zsh.enable = true;
localisation = {
enable = true;
timeZone = "UTC";
defaultLocale = "en_US.UTF-8";
};
users = {
groups.gordaina = {
gid = 1000;
};
users.gordaina = {
group = "gordaina";
uid = 1000;
isNormalUser = true;
home = "/home/gordaina";
createHome = true;
password = "sandbox";
shell = pkgs.zsh;
extraGroups = [
"wheel"
"users"
];
};
};
# 9p mounts — silently fail if shares not provided at runtime
fileSystems."/home/gordaina/projects" = {
device = "projects";
fsType = "9p";
options = [
"trans=virtio"
"version=9p2000.L"
"msize=65536"
"nofail"
"x-systemd.automount"
"x-systemd.device-timeout=2s"
];
};
fileSystems."/mnt/host-claude" = {
device = "hostclaude";
fsType = "9p";
options = [
"trans=virtio"
"version=9p2000.L"
"msize=65536"
"nofail"
"x-systemd.automount"
"x-systemd.device-timeout=2s"
];
};
fileSystems."/mnt/host-home" = {
device = "hosthome";
fsType = "9p";
options = [
"trans=virtio"
"version=9p2000.L"
"msize=65536"
"nofail"
"x-systemd.automount"
"x-systemd.device-timeout=2s"
"ro"
];
};
# pre-auth claude-code from host config
systemd.services.claude-auth = {
description = "Copy claude-code credentials from host mount";
after = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "claude-auth" ''
# skip if host mounts are not available
if ! mountpoint -q /mnt/host-claude && ! mountpoint -q /mnt/host-home; then
echo "no host mounts found, skipping"
exit 0
fi
mkdir -p /home/gordaina/.claude
if mountpoint -q /mnt/host-claude; then
cp -a /mnt/host-claude/. /home/gordaina/.claude/
fi
if mountpoint -q /mnt/host-home; then
cp /mnt/host-home/.claude.json /home/gordaina/.claude.json || true
fi
chown -R gordaina:gordaina /home/gordaina/.claude /home/gordaina/.claude.json 2>/dev/null || true
'';
};
};
environment.systemPackages = with pkgs; [
claude-code
git
];
system.stateVersion = "25.11";
}

View File

@@ -0,0 +1,23 @@
{
lib,
pkgs,
modulesPath,
...
}:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
];
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
autoResize = true;
fsType = "ext4";
};
# x86_64: bios/grub, aarch64: uefi/systemd-boot
boot.loader.grub.device = lib.mkIf pkgs.stdenv.hostPlatform.isx86_64 (lib.mkDefault "/dev/vda");
boot.loader.grub.enable = lib.mkIf pkgs.stdenv.hostPlatform.isAarch64 false;
boot.loader.systemd-boot.enable = lib.mkIf pkgs.stdenv.hostPlatform.isAarch64 true;
boot.loader.efi.canTouchEfiVariables = lib.mkIf pkgs.stdenv.hostPlatform.isAarch64 true;
}

View File

@@ -36,3 +36,15 @@ iso:
# garbage collect old generations
clean:
sudo nix-collect-garbage $(nix eval --raw -f ./nix.nix nix.gc.options)
# build sandbox VM image
sandbox-build:
nixos-rebuild build-image --image-variant qemu --flake .#sandbox
# run sandbox VM
sandbox-run *ARGS:
bash dist/run.sh $(find -L result -name '*.qcow2' | head -1) {{ARGS}}
# ssh into running sandbox
sandbox-ssh:
ssh -A -p 2222 gordaina@localhost

View File

@@ -0,0 +1,61 @@
{
pkgs,
lib,
config,
...
}:
{
options = {
seed-ssh = {
enable = lib.mkEnableOption "SSH key injection from seed ISO";
user = lib.mkOption {
type = lib.types.str;
description = "user to install authorized_keys for";
};
label = lib.mkOption {
type = lib.types.str;
default = "SEEDCONFIG";
description = "volume label of the seed ISO";
};
};
};
config = lib.mkIf config.seed-ssh.enable {
systemd.services.seed-ssh = {
description = "Install SSH authorized_keys from seed ISO";
after = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart =
let
cfg = config.seed-ssh;
inherit (cfg) user;
inherit (config.users.users.${user}) home;
in
pkgs.writeShellScript "seed-ssh" ''
DEVICE="/dev/disk/by-label/${cfg.label}"
if [ ! -e "$DEVICE" ]; then
echo "seed ISO not found, skipping"
exit 0
fi
MOUNT=$(mktemp -d)
mount -o ro "$DEVICE" "$MOUNT"
mkdir -p "${home}/.ssh"
cp "$MOUNT/authorized_keys" "${home}/.ssh/authorized_keys"
chmod 700 "${home}/.ssh"
chmod 600 "${home}/.ssh/authorized_keys"
chown -R ${user}:${user} "${home}/.ssh"
umount "$MOUNT"
rmdir "$MOUNT"
'';
};
};
};
}

View File

@@ -0,0 +1,62 @@
{
pkgs,
lib,
config,
...
}:
{
options = {
vm-guest = {
enable = lib.mkEnableOption "VM guest configuration";
headless = lib.mkOption {
type = lib.types.bool;
default = false;
description = "run without display, serial console only";
};
};
};
config = lib.mkIf config.vm-guest.enable {
services.qemuGuest.enable = true;
services.spice-vdagentd.enable = lib.mkIf (!config.vm-guest.headless) true;
boot.kernelParams = lib.mkIf config.vm-guest.headless [ "console=ttyS0,115200" ];
# 9p for host file mounting
boot.initrd.availableKernelModules = [
"9p"
"9pnet_virtio"
];
boot.kernelModules = [
"9p"
"9pnet_virtio"
];
# ssh with agent forwarding for git and hot-mount
services.openssh = {
enable = true;
ports = [ 22 ];
settings = {
PasswordAuthentication = true;
PermitRootLogin = "no";
AllowAgentForwarding = true;
StreamLocalBindUnlink = "yes";
};
};
networking = {
useDHCP = true;
firewall.allowedTCPPorts = [ 22 ];
};
security.sudo.wheelNeedsPassword = false;
environment.systemPackages = with pkgs; [
curl
wget
htop
sshfs
];
};
}

View File

@@ -1,52 +0,0 @@
{
config,
lib,
pkgs,
inputs,
...
}:
let
packages = inputs.self.outputs.packages.${pkgs.stdenv.hostPlatform.system};
in
{
home.stateVersion = "24.11";
home.packages = [
pkgs.git
];
programs.neovim = {
enable = true;
vimAlias = true;
defaultEditor = true;
package = inputs.neovim-nightly-overlay.packages.${pkgs.stdenv.hostPlatform.system}.default;
extraPackages = with pkgs; [
# runtime deps
fzf
ripgrep
gnumake
gcc
luajit
lua-language-server
nil
nixd
nixpkgs-fmt
stylua
];
extraWrapperArgs = [
"--suffix"
"LD_LIBRARY_PATH"
":"
"${lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ]}"
];
};
}

View File

@@ -0,0 +1,21 @@
{
pkgs,
...
}:
{
home.stateVersion = "25.11";
home.packages = with pkgs; [
git
tmux
ripgrep
fd
jq
];
programs.neovim = {
enable = true;
vimAlias = true;
defaultEditor = true;
};
}