A minimal, scripted environment for spinning up OpenBSD and FreeBSD guests under QEMU on macOS or Linux, with enough structure around it that you can focus on writing kernel-space code instead of babysitting the hypervisor.
- Hosts supported: macOS (Apple Silicon / Intel), Linux (amd64, arm64).
- Target guest archs:
amd64,arm64,riscv64. - Guest distros: OpenBSD (releases + snapshots) and FreeBSD (releases + pre-built VM images).
- Policy: hardware acceleration (HVF / KVM / WHPX / NVMM) is mandatory
whenever it is technically available; falling back to software emulation
(TCG) requires an explicit
--emulationflag.
# 1. One-time: create and activate the bsd_go Python virtualenv.
source env.sh # bootstraps on first run
# 2. Install QEMU + UEFI firmware on the host (see "Prerequisites" below).
# macOS: brew install qemu
# Debian/Ubuntu: sudo apt install qemu-system qemu-utils ovmf qemu-efi-aarch64
# Fedora: sudo dnf install qemu edk2-ovmf edk2-aarch64 edk2-riscv
# Arch: sudo pacman -S qemu-full edk2-ovmf edk2-armvirt
# 3. Confirm everything is wired up.
./bsd_go.py qemu info --all-archs
# 4. Download the latest OpenBSD ISO for the host architecture.
# The mirror is auto-picked from your region (IP geolocation →
# same-country → same-continent → distro CDN). Override with
# --region CC or --mirror URL if needed.
./bsd_go.py images get
# 5. Create a 40 GiB qcow2 disk for a new VM called "openbsd".
./bsd_go.py qemu disk create openbsd --size 40G
# 6. Boot the installer in the terminal (text-console mode). The OpenBSD /
# FreeBSD installers talk to the serial console, so --tty puts the
# installer in your terminal where you can actually type. Without --tty
# you get a GUI window that usually shows nothing interactive and the
# installer prompt ends up on serial where no one is listening.
./bsd_go.py qemu install openbsd --tty \
--cdrom images/openbsd/7.8/arm64/install78.iso
# 7. After the installer finishes and halts the VM, boot normally in text
# mode with SSH forwarded from localhost:2222 to guest:22.
./bsd_go.py qemu run openbsd --tty --ssh-port 2222
# Escape: Ctrl-A x to exit QEMU, Ctrl-A c to drop into the QEMU monitor.
# Cleanup when you're done:
./bsd_go.py qemu disk delete openbsd --yes
./bsd_go.py images delete --distro openbsd --version 7.8 --yes
# or nuke everything:
./bsd_go.py qemu disk erase --yes
./bsd_go.py images erase --yesWant FreeBSD instead? Swap step 4 for:
./bsd_go.py images get --distro freebsd --type vm-qcow2 --decompress
./bsd_go.py qemu disk create freebsd --distro freebsd --size 40G
# point --disk at the decompressed qcow2, or convert the pre-built image in
# place — see "Recipes" below.bsd_go/
├── bsd_go.py # top-level wrapper around scripts/images.py + scripts/qemu.py
├── env.sh # sourceable shell hook: activates the venv (bootstraps if missing)
├── env/
│ └── install_deps.sh # creates ~/opt/python_venv/bsd_go and pip-installs requirements
├── images/ # downloaded ISOs / VM images (gitignored contents, .gitkeep tracked)
│ └── .gitkeep
├── disks/ # per-VM qcow2 + UEFI NVRAM + sockets (gitignored contents)
│ └── .gitkeep
└── scripts/
├── _common.py # host/arch detection, download helper, QEMU discovery
├── _distros.py # mirror + URL + file-naming knowledge for OpenBSD / FreeBSD
├── images.py # release-image manager (get / list / delete / erase / verify)
├── qemu.py # VM + disk + snapshot manager
└── requirements.txt # typer + rich
You can either drive everything through the unified wrapper:
./bsd_go.py <group> <subcommand> ...or invoke the sub-scripts directly:
./scripts/images.py <subcommand> ...
./scripts/qemu.py <subcommand> ...Both paths are identical — bsd_go.py just mounts the two apps under
images and qemu subcommand groups.
The repo ships with an env.sh hook that creates an isolated venv at
~/opt/python_venv/bsd_go on first use and activates it in your current
shell:
source env.sh # bash
. env.sh # any POSIX shellOn first invocation this calls env/install_deps.sh, which:
- Verifies Python 3.10+ is on
PATH, - Creates
~/opt/python_venv/bsd_goviapython3 -m venv, pip install -r scripts/requirements.txt(typer+rich).
Subsequent source env.sh invocations just activate the venv — no pip
reinstall. Override the venv path with BSD_GO_VENV or re-run
env/install_deps.sh directly if you want to upgrade packages.
If you prefer your own venv / system install:
python3 -m venv .venv
. .venv/bin/activate
pip install -r scripts/requirements.txtPython 3.10 or newer is required.
| Host | Install |
|---|---|
| macOS (Homebrew) | brew install qemu — ships edk2 firmware for amd64, arm64, riscv64 under share/qemu/. |
| Debian / Ubuntu | sudo apt install qemu-system qemu-utils ovmf qemu-efi-aarch64 |
| Fedora / RHEL | sudo dnf install qemu edk2-ovmf edk2-aarch64 edk2-riscv |
| Arch Linux | sudo pacman -S qemu-full edk2-ovmf edk2-armvirt (riscv edk2 is community/AUR) |
If a target arch's UEFI firmware is missing the scripts print a distro-aware
hint. riscv64 also falls back to QEMU's bundled OpenSBI firmware (fine for
non-UEFI boots).
Make sure your user can open /dev/kvm:
sudo usermod -aG kvm "$USER" # then log back inWithout a writable /dev/kvm, native-arch runs require --emulation (TCG).
Top-level CLI. It delegates to scripts/images.py and scripts/qemu.py,
plus a small paths command for sanity checks.
$ ./bsd_go.py --help
Usage: bsd_go.py [OPTIONS] COMMAND [ARGS]...
Unified CLI for OpenBSD / FreeBSD VM workflows
(wraps scripts/images.py and scripts/qemu.py).
Commands:
paths Print the repo-root directories the tools read and write.
images Manage OpenBSD / FreeBSD release images: get / list / delete / erase.
qemu Manage QEMU VMs: disks, snapshots, installer, run, console, ps/kill.
$ ./bsd_go.py paths
bsd_go :: paths
repo root /path/to/bsd_go
scripts dir /path/to/bsd_go/scripts
images dir /path/to/bsd_go/images (downloaded ISOs / VM images)
disks dir /path/to/bsd_go/disks (per-VM qcow2 / NVRAM / sockets)
...
$ ./bsd_go.py images --help
Commands:
info Print detected host and default download targets.
versions List releases visible on the mirror.
mirrors List configured mirrors for the selected distro.
archs List architectures supported by the distro.
files List files available for a release / architecture.
get Download a BSD release for an arch / version combination.
verify Verify a single file's SHA256 against a manifest.
list List cached images under the images directory, grouped by distro.
delete Delete cached images, filtered by distro / version / arch / file.
erase Wipe every cached image; keeps the images/ directory + .gitkeep.
The info, versions, mirrors, archs, files, get and verify
commands all accept --distro openbsd|freebsd (default openbsd),
--mirror URL, and --region CC.
The first time any command needs to talk to a mirror it calls a free IP
geolocation service (tries ipinfo.io, ifconfig.co, api.country.is in
order) to learn the user's ISO alpha-2 country code, then picks a mirror
using this priority list:
- Same country — e.g. a Singapore user picks
https://mirror.freedif.org/pub/OpenBSD. - Same continent — e.g. a Singapore FreeBSD user (no
.sgmirror in the FreeBSD project) falls through to a nearby Asian mirror (mirrors.ustc.edu.cn/freebsd). - Distro default CDN —
cdn.openbsd.org/download.freebsd.org.
Overrides (all via flags or env vars):
| want this | pass this |
|---|---|
| Explicit mirror (highest priority) | --mirror URL or BSD_MIRROR=URL |
| Explicit region (skips IP lookup) | --region CC or BSD_REGION=CC |
| Skip region matching entirely | --region none or BSD_REGION=none |
| See which mirror would be auto-selected | ./bsd_go.py images mirrors (★ marks pick) |
| Confirm all mirrors are reachable | ./bsd_go.py images mirrors --probe |
The per-process result is cached, so repeated subcommands in one invocation don't do extra HTTP lookups. If geolocation fails (no network, every endpoint down), the scripts print a warning and fall back to the distro's default CDN.
Host detection, detected region, and the mirror that would be auto-selected.
./bsd_go.py images info # OpenBSD defaults, auto region
./bsd_go.py images info --distro freebsd # FreeBSD defaults, auto region
./bsd_go.py images info --region DE # force Germany region
./bsd_go.py images info --region none # skip geolocation, use CDN
./bsd_go.py images info --offline # skip the HTTP probes entirelySample output for a Singapore host:
images :: openbsd defaults
host OS macos
host arch (raw) arm64
guest arch (mapped) arm64
distro openbsd
detected region SG
selected mirror https://mirror.freedif.org/pub/OpenBSD (auto)
default CDN mirror https://cdn.openbsd.org/pub/OpenBSD
default images dir /path/to/bsd_go/images
latest release 7.8
./bsd_go.py images versions --distro freebsd --limit 10
./bsd_go.py images mirrors --distro openbsd --probe # HEAD each mirror
./bsd_go.py images archs --distro freebsd --probe --version 14.4
./bsd_go.py images files --distro openbsd --version 7.8 --arch arm64
./bsd_go.py images files --distro freebsd --variant vm --version 14.4--probe HEADs every URL so the tables mark entries reachable/not reachable;
files --variant vm lists the pre-built VM-IMAGES tree for FreeBSD.
The mirrors command marks the entry that would be auto-picked for the
current host with ★:
openbsd mirrors (★ = auto-select for this host, country=SG)
# ★ URL country region
1 ★ https://mirror.freedif.org/pub/OpenBSD SG Singapore
2 https://ftp.jaist.ac.jp/pub/OpenBSD JP Japan (Nomi, Ishikawa)
...
Pass --region or --mirror here to see what would be picked under a
different policy without actually changing anything.
The main download command. Combines presets, explicit file names, glob filters, and optional post-processing.
Presets (--type / -t):
| distro | preset | files expanded |
|---|---|---|
| OpenBSD | iso* |
install<V>.iso |
| OpenBSD | img |
install<V>.img |
| OpenBSD | full |
install<V>.iso + install<V>.img |
| OpenBSD | miniroot |
miniroot<V>.img |
| OpenBSD | kernel |
bsd, bsd.mp, bsd.rd |
| OpenBSD | sets |
base<V>.tgz, comp<V>.tgz, man<V>.tgz, game<V>.tgz, x*<V>.tgz |
| OpenBSD | sums |
SHA256, SHA256.sig |
| OpenBSD | all |
every ISO, IMG, miniroot, kernel, set |
| FreeBSD | iso* |
FreeBSD-<VER>-RELEASE-<A>-disc1.iso |
| FreeBSD | dvd |
...-dvd1.iso |
| FreeBSD | bootonly |
...-bootonly.iso |
| FreeBSD | memstick |
...-memstick.img |
| FreeBSD | mini |
...-mini-memstick.img |
| FreeBSD | full |
disc1 ISO + memstick IMG |
| FreeBSD | sums |
CHECKSUM.SHA256-..., CHECKSUM.SHA512-... (ISO-IMAGES suffixed names) |
| FreeBSD | all |
all ISOs and mem-stick images |
| FreeBSD | vm, vm-qcow2 |
FreeBSD-<VER>-RELEASE-<A>.qcow2.xz (VM-IMAGES subtree) |
| FreeBSD | vm-raw |
...raw.xz |
| FreeBSD | vm-vmdk |
...vmdk.xz |
| FreeBSD | vm-vhd |
...vhd.xz |
| FreeBSD | vm-all |
every qcow2/raw/vmdk/vhd in VM-IMAGES |
*Default when --type is omitted.
Examples:
# Latest OpenBSD ISO for host arch, SHA256-verified into images/openbsd/<ver>/<arch>/
./bsd_go.py images get
# Full USB image for OpenBSD amd64 7.7 (override arch + version)
./bsd_go.py images get --arch amd64 --version 7.7 --type img
# OpenBSD -CURRENT (snapshots); auto-detects the real version tag from the mirror
./bsd_go.py images get --snapshots --arch arm64
# Just the sets (base/comp/man/X), parallel download, verified
./bsd_go.py images get --type sets --jobs 4
# FreeBSD pre-built qcow2 for arm64, decompressed in place
./bsd_go.py images get --distro freebsd --type vm-qcow2 \
--arch arm64 --decompress
# Explicit file(s) via --file (one or more), bypasses presets
./bsd_go.py images get --file install78.iso --file bsd.rd
# Glob filter against the mirror listing
./bsd_go.py images get --include 'install*.iso' --exclude '*.sig'
# Dry-run: show the plan, download nothing
./bsd_go.py images get --dry-run
# Just list what would be fetched, then exit
./bsd_go.py images get --listSwitches worth knowing:
--output DIR/$BSD_IMAGES_DIR— override the target directory (defaultimages/at the repo root).--flat— skip the<distro>/<version>/<arch>/subdirs.--force/-f— re-download existing files.--resume/--no-resume— toggle HTTP range resume (on by default).--jobs N— parallel downloads (default 1, max 16). Note: the per-file progress bar is rendered only when--jobs 1.--verify/--no-verify— SHA256 manifest verification (on by default).--signify(+--signify-key) — additionally run signify verification on OpenBSDSHA256.sigfiles; requires thesignifybinary (brew install signify-osx,apt install signify-openbsd).--decompress— transparentlyunxzany.xzoutputs (mostly FreeBSD VM-IMAGES). Use--delete-compressedto drop the.xzafter.--mirror URL/$BSD_MIRROR— override the default mirror.--timeout SECONDS— HTTP timeout per request.
Verify a single file against an adjacent SHA256 / CHECKSUM.SHA256:
./bsd_go.py images verify images/openbsd/7.8/arm64/install78.isoInventory of what's already cached on disk, grouped by distro / version / arch:
./bsd_go.py images listcached images @ /path/to/bsd_go/images
distro version arch files size
openbsd 7.8 arm64 3 60.6 MiB
freebsd 14.4 amd64 3 1.2 GiB
i total: 6 files, 1.3 GiB
--output DIR / $BSD_IMAGES_DIR points the command at a different cache
root.
Remove images filtered by distro / version / arch / file. At least --distro
is required; the other filters let you zero in on a specific subtree.
# Delete everything for one distro
./bsd_go.py images delete --distro freebsd
# Delete one release (all archs under it)
./bsd_go.py images delete --distro openbsd --version 7.7
# Delete one arch inside a release
./bsd_go.py images delete --distro openbsd --version 7.8 --arch arm64
# Delete a single file
./bsd_go.py images delete --distro openbsd --version 7.8 --arch arm64 \
--file install78.iso
# Preview without touching anything
./bsd_go.py images delete --distro openbsd --version 7.7 --dry-runFlags:
--yes/-y— skip the "are you sure?" prompt.--dry-run— print the target path (and size), then exit.--output DIR— act on a non-default images root.
Empty parent directories (e.g. images/openbsd/ after its last version is
gone) are pruned automatically; the top-level images/ and its .gitkeep
sentinel are always preserved.
The nuclear option — wipes every cached image:
./bsd_go.py images erase # interactive prompt
./bsd_go.py images erase --yes # no prompt
./bsd_go.py images erase --dry-run # show what would goThe images/ directory and its .gitkeep are left behind; everything else
inside (distros, versions, archs, files) is removed.
$ ./bsd_go.py qemu --help
Commands:
info Show host, QEMU, and acceleration details.
check Verify required tools for the guest architecture(s).
run Boot a VM (no installer media unless --cdrom passed).
install Run the installer: boot the VM with an ISO attached as a CD-ROM.
ps List VMs that appear to be running.
kill Stop a running VM (by sending a signal to its PID).
console Attach to the VM's HMP monitor Unix socket.
eject Eject a CD-ROM via QMP.
disk Create / inspect / resize / delete / erase disks.
snapshot Manage internal qcow2 snapshots.
Each VM lives in a single directory under disks/:
disks/<name>/
├── meta.json # arch, distro, memory, smp, uefi flag, disk list, last_run
├── disk-0.qcow2 # primary disk (virtio-blk)
├── efi-vars.fd # UEFI NVRAM (arm64 / riscv64 / --uefi on amd64)
├── qemu.pid # present only while the VM is running
├── monitor.sock # HMP Unix socket
└── qmp.sock # QMP Unix socket (eject, etc.)
Every successful qemu run / qemu install writes the effective options
back to meta.json under last_run. The next qemu run <name> reads that
as its defaults, so you don't have to re-type --tty, --ssh-port 2222,
--port-forward tcp::8080-:80, --memory 8G, etc. every time. CLI flags you
pass this time always beat the stored values.
Typical flow:
# First time: pin the options you want.
./bsd_go.py qemu run obsd --tty --ssh-port 2222 --memory 4G
# Later: inherits everything from last_run automatically.
./bsd_go.py qemu run obsd
# Override one thing without touching the rest.
./bsd_go.py qemu run obsd --ssh-port 3333 # tty + memory still 4G, ssh now 3333
# Start fresh (discard saved config, keep the disk).
./bsd_go.py qemu disk reset obsd
# Run without persisting this invocation's choices.
./bsd_go.py qemu run obsd --snapshot --no-save
# Inspect what's stored:
python3 -c 'import json; print(json.dumps(json.load(open("disks/obsd/meta.json"))["last_run"], indent=2))'What's persisted: memory/smp/cpu/machine, accel + emulation, headless/tty,
display, vga, vnc, serial, monitor, network mode/model/forwards/ssh-port/mac,
uefi/firmware/nvram, rng/balloon/tablet/keyboard/usb, audio, virtiofs, boot,
exit-on-reboot, and raw --extra args. Not persisted: --cdrom (install
media is one-shot), --snapshot, --detach, --dry-run, --qmp-port.
Saves happen just before QEMU launches, so even if a boot fails you still
get "the last thing I asked for". Skip with --no-save for a one-shot run;
clear with qemu disk reset <name>.
You can also edit meta.json directly — it's plain JSON. Three shortcuts
keep the round-trip safer than a plain text editor:
# Print the file (syntax-highlighted).
./bsd_go.py qemu disk show obsd
# Raw JSON for piping into jq / python / whatever.
./bsd_go.py qemu disk show obsd --raw | jq '.last_run'
# Single-field tweak (JSON-parsed by default):
./bsd_go.py qemu disk set obsd memory 8G
./bsd_go.py qemu disk set obsd smp 4
./bsd_go.py qemu disk set obsd last_run.ssh_port 3333
./bsd_go.py qemu disk set obsd last_run.headless true
./bsd_go.py qemu disk set obsd last_run.port_forward '["tcp::8080-:80"]'
./bsd_go.py qemu disk set obsd last_run null # clear wholesale
# Full edit in your $EDITOR (vim / nvim / code -w / ...).
./bsd_go.py qemu disk edit obsd # uses $EDITOR, then $VISUAL, then vi
./bsd_go.py qemu disk edit obsd --editor nvimBoth edit and set refuse to run while the VM is live, and both roll back
to a backup if the resulting file is no longer valid JSON / no longer loads
as a VMMeta. Type-correctness of individual fields isn't checked — if you
put smp: [1,2,3] you'll get a QEMU launch error next time, not a save
error.
./bsd_go.py qemu info # single-arch (host arch by default)
./bsd_go.py qemu info --arch riscv64 # drill into a specific target
./bsd_go.py qemu info --all-archs # table across amd64/arm64/riscv64
./bsd_go.py qemu check --arch amd64
./bsd_go.py qemu check --all-archs # fail/warn per missing pieceinfo --all-archs prints a table like:
arch qemu-system accel native firmware CODE vars tmpl
amd64 ok tcg (TCG) no ok ok
arm64 ok hvf (HW) yes ok ok
riscv64 ok tcg (TCG) no ok ok
Commands:
create Create a new VM and its primary disk.
list List VMs and their disks.
info Run `qemu-img info` on a VM's disk.
resize Resize a VM's disk (extending is safe, shrinking is not).
convert Convert a disk to another format.
show Pretty-print meta.json (or `--raw` for `| jq` pipes).
edit Open meta.json in $EDITOR; rolls back on invalid JSON.
set Set a single field (dot-notation into last_run), JSON-parsed.
reset Clear the VM's stored last_run config (see "Per-VM run config").
delete Delete a single VM's directory (disks + metadata + sockets).
erase Wipe every VM directory; keeps disks/ + .gitkeep.
./bsd_go.py qemu disk create <name> # create qcow2 + metadata (+ NVRAM for UEFI)
./bsd_go.py qemu disk list # all VMs: distro, arch, running, disks, memory
./bsd_go.py qemu disk info <name> # qemu-img info on the primary disk
./bsd_go.py qemu disk resize <name> SIZE # +10G, 80G, etc. (VM must be stopped)
./bsd_go.py qemu disk convert <name> OUT --format qcow2
./bsd_go.py qemu disk delete <name> # rm -rf disks/<name>/ (confirms; --yes to skip)
./bsd_go.py qemu disk erase # rm -rf every disks/*/ (confirms; --yes to skip)disk create options worth knowing:
--arch {amd64,arm64,riscv64}(default: host's canonical arch).--force-arch— allow uncommon target arches (i386, sparc64, ...); scripts are still best-effort outside the supported three.--distro openbsd|freebsd— stored in metadata so later tables can tag the VM.--size 40G— disk size (K/M/G/Tunits).--memory 2G,--smp 2,--cpu host— stored as defaults forrun/install.--uefi/--no-uefi— default is on for arm64/riscv64, off for amd64. Flip--uefion amd64 if you want UEFI firmware (OVMF) instead of SeaBIOS.--machine q35— override the default QEMU machine type.--preallocation metadata|falloc|full— pass through toqemu-img create.--force— overwrite an existing VM directory.
disk delete options:
--yes/-y— skip the "are you sure?" prompt.--keep-config— delete the disk files but keepmeta.json(so you candisk create --forceback with the same settings).
disk erase options:
--yes/-y— skip the prompt.--dry-run— list every VM that would be wiped (and its size), then exit.--stop-running— if any listed VM is still running, SIGTERM it first. Without this flag,eraserefuses to proceed.--force— combined with--stop-running, escalates to SIGKILL for VMs that don't exit within 10 seconds of SIGTERM.
Uses qcow2 internal snapshots (must be stopped):
./bsd_go.py qemu snapshot create <name> fresh-install
./bsd_go.py qemu snapshot list <name>
./bsd_go.py qemu snapshot revert <name> fresh-install
./bsd_go.py qemu snapshot delete <name> fresh-installPair with qemu run --snapshot (a separate flag) if you want a run that
discards all disk writes on exit without creating a snapshot at all.
./bsd_go.py qemu install <name> --cdrom <path/to.iso>Attaches the ISO as a virtio-scsi CD-ROM with bootindex=0 so it boots
ahead of the disk. All run options below are available too.
./bsd_go.py qemu run <name> # use meta's defaults
./bsd_go.py qemu run <name> --tty --ssh-port 2222
./bsd_go.py qemu run <name> --detach # background, via -daemonize + pidfile
./bsd_go.py qemu run <name> --cdrom <path> # attach an ISO alongside the disk
./bsd_go.py qemu run <name> --snapshot # discard disk writes on exit
./bsd_go.py qemu run <name> --dry-run # print the QEMU command, exit
./bsd_go.py qemu run --disk /some/external.qcow2 # anonymous VM, no metadataOption groups (complete list via --help):
- CPU / memory:
--arch,--machine,--cpu,--smp,--memory,--uuid. - Acceleration:
--accel auto|hvf|kvm|whpx|tcg,--emulation(required to allow TCG fallback). - Disks / media:
--disk(repeatable),--extra-disk(repeatable),--cdrom(repeatable),--boot cdn,--snapshot. - Display (see "Display modes" below for the full matrix):
--tty/--text/--headless,--display cocoa|gtk|sdl|vnc|curses|none,--vga virtio|std|cirrus|qxl,--vnc :1. - Serial / monitor:
--serial mon:stdio|pty|...,--monitor ...,--qmp-port 4444. - Network:
--network user|bridge|tap|none,--net-model virtio-net-pci,--port-forward tcp::8080-:80(repeatable),--ssh-port 2222,--mac .... - UEFI:
--uefi/--bios,--firmware PATH,--nvram PATH. - Devices:
--rng/--no-rng,--balloon/--no-balloon,--tablet/--no-tablet,--usb/--no-usb,--audio none|coreaudio|pa|alsa,--virtiofs PATH:TAG(repeatable). - Escape hatches:
--extra <raw QEMU arg>(repeatable),--dry-run,--detach.
| mode | flags | how to interact | when to use |
|---|---|---|---|
| GUI window (default) | (no flag) | Click the window to capture keyboard. | Guest that has a real graphical console. |
| Text / serial (recommended) | --tty (aliases: --text, --headless) |
Type in the current terminal; Ctrl-A x exits. | OpenBSD / FreeBSD installer, day-to-day dev. |
| Curses framebuffer | --display curses |
In-terminal ncurses rendering of the guest VGA. | BIOS / text-mode output without giving up the terminal. |
| Detached background | --detach (+ --tty) |
Reattach via qemu.py console <name> or SSH. |
Long-running VMs; driver dev with scp/ssh loops. |
| VNC | --vnc :1 or --vnc 127.0.0.1:5900 |
Connect a VNC viewer to the advertised port. | Remote graphical access over SSH tunnel. |
Why --tty exists: the OpenBSD and FreeBSD installers print to com0
(serial) by default. In the default GUI window the installer output is
invisible and anything you type there goes nowhere useful, so the VM
appears "frozen" even though it's actually sitting at a serial prompt.
--tty wires QEMU's serial port to the terminal you launched from, so the
prompt lands in front of you and you can type at it directly. The three
flags (--tty, --text, --headless) are all aliases for the same mode —
pick whichever reads best in your command history.
Escape sequences while in --tty mode:
Ctrl-A x— exit QEMU (sends ACPI powerdown; hard-kill with Ctrl-A then x twice if needed).Ctrl-A c— toggle between the guest serial and the QEMU monitor (info cpus,info mem,sendkey ctrl-alt-f1,quit, ...).Ctrl-A h— list all escape sequences.
./bsd_go.py qemu ps # list running VMs (pid, qmp/monitor paths)
./bsd_go.py qemu console <name> # attach to monitor.sock (Ctrl-D to detach)
./bsd_go.py qemu eject <name> # eject cd0 via QMP (after install)
./bsd_go.py qemu kill <name> # SIGTERM; --force to SIGKILL| guest | machine | UEFI CODE default path (macOS Homebrew) |
|---|---|---|
amd64 |
q35 |
/opt/homebrew/share/qemu/edk2-x86_64-code.fd |
arm64 |
virt,highmem=on |
/opt/homebrew/share/qemu/edk2-aarch64-code.fd |
riscv64 |
virt (+ OpenSBI) |
/opt/homebrew/share/qemu/edk2-riscv-code.fd |
On Linux the same file names live under /usr/share/OVMF/,
/usr/share/AAVMF/, /usr/share/qemu-efi-aarch64/, /usr/share/edk2/* or
/usr/share/edk2-ovmf/, /usr/share/edk2-armvirt/, /usr/share/edk2-riscv/
depending on your distro — the scripts search all of them.
qemu.py will refuse to start a VM whose selected accelerator is tcg
unless you pass --emulation. The check cascades like this:
- Same-arch host + guest (e.g. arm64 Mac running an arm64 guest) → pick
the host's accelerator (
hvf/kvm/whpx/nvmm). If none are usable (/dev/kvmmissing, wrong user, etc.) you get a pointed error unless you confirm TCG with--emulation. - Cross-arch (e.g. amd64 guest on arm64 host) → hardware accel is
impossible. You must opt in with
--emulation. TCG cross-arch boots routinely take 3–10× longer. - Explicit
--accel tcgis also guarded by--emulationso you can never hit TCG by accident.
If you supply --cpu host in metadata but end up on TCG, the scripts
auto-downgrade to -cpu max and warn (because -cpu host refuses to start
under TCG on amd64 and riscv64).
source env.sh # activate venv
./bsd_go.py images get # install78.iso + SHA256
./bsd_go.py qemu disk create obsd --size 40G # UEFI on by default
./bsd_go.py qemu install obsd \
--cdrom images/openbsd/7.8/arm64/install78.iso
# ...go through installer, pick sd0, halt when done...
./bsd_go.py qemu run obsd --tty --ssh-port 2222
ssh -p 2222 root@localhostAfter a known-good install, snapshot it:
./bsd_go.py qemu snapshot create obsd fresh-install
./bsd_go.py qemu snapshot revert obsd fresh-install # rollback any time./bsd_go.py images get --distro freebsd \
--type vm-qcow2 --arch arm64 --decompress
./bsd_go.py qemu disk create fbsd --distro freebsd --size 40G
# Replace the empty primary disk with the downloaded qcow2:
cp images/freebsd/15.0/arm64/FreeBSD-15.0-RELEASE-arm64-aarch64.qcow2 \
disks/fbsd/disk-0.qcow2
./bsd_go.py qemu run fbsd --tty --ssh-port 2222./bsd_go.py qemu disk create obsd-x86 --arch amd64 --size 40G --uefi
./bsd_go.py qemu install obsd-x86 --emulation \
--cdrom images/openbsd/7.8/amd64/install78.iso --uefi
./bsd_go.py qemu run obsd-x86 --emulation --tty --ssh-port 2223./bsd_go.py images get --arch riscv64 # OpenBSD riscv64 ISO
./bsd_go.py qemu disk create obsd-rv --arch riscv64 --size 40G
./bsd_go.py qemu install obsd-rv --emulation \
--cdrom images/openbsd/7.8/riscv64/install78.iso# In one terminal: VM running, serial + SSH exposed
./bsd_go.py qemu run obsd --tty --ssh-port 2222 --detach
# In another terminal:
scp -P 2222 -r /path/to/driver/src root@localhost:/root/
ssh -p 2222 root@localhost 'cd /root/src && make && make load'--detach parks QEMU in the background; qemu.py ps shows its pid,
qemu.py console obsd attaches to the monitor socket, and
qemu.py kill obsd stops it cleanly.
# See what you've got cached
./bsd_go.py images list
./bsd_go.py qemu disk list
# Drop one release
./bsd_go.py images delete --distro openbsd --version 7.7
# Drop everything (images + VMs)
./bsd_go.py images erase --yes
./bsd_go.py qemu disk erase --yesimages/.gitkeep and disks/.gitkeep survive both operations — the
top-level directories stay tracked in git.
| var | effect |
|---|---|
BSD_MIRROR |
explicit mirror for every images command (highest priority) |
BSD_REGION |
ISO alpha-2 country code for images auto-selection (none to skip) |
BSD_IMAGES_DIR |
default output dir for images {get,list,delete,erase} (overridden by --output) |
BSD_GO_VENV |
venv path used by env.sh and env/install_deps.sh (default ~/opt/python_venv/bsd_go) |
PYTHON |
Python interpreter used by env/install_deps.sh (default python3) |
OPENBSD_MIRROR* |
legacy alias, still accepted |
*Prefer BSD_MIRROR going forward.
"Guest arch ... differs from host ...; hardware acceleration is impossible."
You asked for a cross-arch boot. Add --emulation to confirm you want the
TCG path, or drop --arch to run a native-arch guest.
"UEFI firmware for arm64 not found on standard paths."
Install edk2 for your host's package manager (see the Prerequisites table) or
pass --firmware /path/to/edk2-aarch64-code.fd explicitly.
"/dev/kvm not available (check kvm module / group)." (Linux)
Add your user to the kvm group (sudo usermod -aG kvm $USER), reboot or log
out/in, and make sure the kvm_intel / kvm_amd module is loaded.
FreeBSD VM-IMAGES checksum file not matching.
FreeBSD uses two naming schemes for manifests: plain CHECKSUM.SHA256 inside
the VM-IMAGES/Latest/ tree, and CHECKSUM.SHA256-FreeBSD-<VER>-RELEASE-<A>
inside ISO-IMAGES/. The scripts handle both — just make sure --type matches
what you actually want (vm-qcow2 for VM-IMAGES, iso/dvd/etc. for the
installer tree).
Signature verification requested but signify is missing.
Either install it (brew install signify-osx, apt install signify-openbsd)
and point --signify-key at the matching openbsd-<VER>-base.pub, or drop
--signify and rely on SHA256 alone.
"images delete --distro openbsd --file install78.iso" fails.
--file is relative to a single <distro>/<version>/<arch>/ tuple, so it
needs --version and --arch too. Examples:
./bsd_go.py images delete --distro openbsd --version 7.8 --arch arm64 \
--file install78.iso"disk erase" refuses because a VM is running.
Either stop the VM first (qemu kill <name>) or pass --stop-running (and
optionally --force to SIGKILL any VM that won't SIGTERM within 10 s).
Auto-mirror picked something strange / far away.
Pin it with --region CC (ISO alpha-2, e.g. --region SG) or --mirror URL;
or force the distro CDN with --region none. Run
./bsd_go.py images mirrors to see the full list plus which entry would be
auto-picked (marked ★).
"I get a GUI window and can't type anything."
The default display is a cocoa (macOS) / gtk (Linux) window; BSD installers
print to com0 (serial), so the window shows nothing interactive and
keystrokes don't go anywhere useful. Pass --tty (or --text / --headless,
they're aliases) on qemu run or qemu install to put the serial console
into your terminal where you can type at it directly. See "Display modes"
above for the full comparison.
Geolocation is blocked on my network.
Same fix: --region CC skips the HTTP lookup entirely, as does
--region none (which uses the distro's default CDN regardless of where you
are).
OpenBSD installer boots but says "no installable disk"
Check that the CD attaches via virtio-scsi (it does in this project) and
that your disk is big enough for OpenBSD's minimum layout (1 GiB works, 4 GiB
is saner). Serial console: at the boot> prompt, type set tty com0 if you
want text install via --tty (alias: --headless).
--serial stdio + --detach refused
That combination can't work (stdio has no parent when daemonized). Either
drop --detach or pass --serial unix:<path>.sock.
Personal / internal project; no license file yet. OpenBSD and FreeBSD release images are redistributed under their respective licenses — consult https://www.openbsd.org/faq/faq1.html and https://www.freebsd.org/copyright/ before rehosting.
Hardware acceleration is a correctness choice, not a performance one: cross-arch TCG is slow and can mask bugs in lock-step drivers (IPI timing, MMIO ordering, cache-line races). Run on a native host whenever practical.