Functional Specification: openriot --make-image
Overview
Build a bootable OpenRiot offline installer image from an OpenBSD base image.
The resulting image is completely self-contained — no network required during
installation. All packages and firmware are pre-bundled. The OpenRiot repository is
fetched by the user after first login via curl -fsSL https://OpenRiot.org/setup.sh | sh.
Target size: < 2.0GB. Actual size: ~1932MB.
Current Status
| Item | Status |
|---|---|
| Image builds without errors | ✅ Working |
| Image size under 2.0GB | ✅ Working (~1932MB) |
site79.tgz injects into 7.9/amd64/ |
✅ Working |
index.txt contains site79.tgz entry |
✅ Working (uses real ls -lT output) |
| Image boots from USB | ✅ Working |
Installer discovers site79.tgz |
✅ Working (manual * + yes to prompts) |
install.site executes |
✅ Working |
| Package installation during post-install | ✅ Working — bulk pkg_add -I *.tgz installs in topological order; missing deps are non-fatal and resolved by setup.sh |
| First-login welcome message | ✅ Working |
| Firmware auto-installation | ⚠️ Code present, not yet validated end-to-end |
User/group/shell modifications in install.site |
❌ Removed — handled by setup.sh |
sync before umount |
✅ Working (prevents site79.tgz truncation) |
Size Budget
| Component | Size |
|---|---|
Base install79.img |
~801MB |
Site tarball (site79.tgz) |
~781MB |
Firmware in site79.tgz |
~31MB |
| FFS metadata + 5% minfree | ~350MB |
| Final image | ~1932MB |
Note: The OpenRiot Git repository (~1.1GB with
.git/) is NOT included in the tarball. It is fetched bysetup.shafter first login. This keeps the image under 2GB.
Requirements
Platform
- Must run on OpenBSD (current or snapshots)
- Must run as root (required for
vnconfig, mounting, burning) - Binary must exist at
install/openriot(from priormakebuild)
Flags and Arguments
openriot --make-image [mode] [flags]
Modes:
(none) Full build: create site tarball + image (default)
site Create site tarball only
clean Clean build artifacts (work/)
help Show help
Flags:
--base-img PATH Base OpenBSD image (default: Build/Images/install79.img)
--output-img PATH Output image path (default: Build/Images/openriot.img)
--work-dir PATH Working directory (default: Build/work)
--version X.Y OpenBSD version to target (default: 79)
--no-burn Skip interactive burn prompt
Environment Variables (fallback)
| Variable | Purpose | Default |
|---|---|---|
BASE_IMG |
Path to base OpenBSD image | See flags |
OUTPUT_IMG |
Path for output image | See flags |
WORK_DIR |
Working directory | See flags |
Architecture
What Gets Installed
The OpenBSD installer discovers site79.tgz as a custom install set via
index.txt in the 7.9/amd64/ directory (per install.site(5)).
Critical fix applied: Previous builds used a manually-crafted
fmt.Sprintfline that mimickedls -lToutput. The installer’s parser rejected it, sosite79.tgznever appeared in the sets list. We now runls -lTon the actual copied file and append that output verbatim.
site79.tgz extracts to the target system’s root during install, creating:
/openriot/packages/snapshots/amd64/*.tgz # All pre-downloaded packages
/openriot/firmware/*.tgz # Pre-downloaded non-free firmware
/install.site # Post-install script
/etc/motd # Custom MOTD
After extraction, /install.site runs automatically in a chroot of the new
system. It:
- Configures
doas.conf(per-userpermit nopass $username+permit nopass :wheel) - Sets
installurltohttps://cdn.openbsd.org/pub/OpenBSD - Adds
/usr/local/bin/fishto/etc/shells - Enables
apmdandsndiod - Creates
/etc/wireguard(mode 700) - Installs all packages from
/openriot/packages/viapkg_add -I *.tgz(non-fatal;setup.shresolves missing deps when network is available) - Installs all firmware from
/openriot/firmware/viatar xzf -C / - Appends welcome message to
~/.profilefor each user in/home/* - Adds the same welcome message to
/etc/skel/.profile
What Does NOT Happen
- X11 does not start on first boot.
xenodmis deliberately NOT enabled. - The OpenRiot repo is NOT copied to
~/.local/share/openriot. The user fetches it after login withcurl setup.sh. openriot --installdoes NOT run automatically. The welcome message directs the user to runsetup.sh, which handles everything.install.confis NOT used.autoinstall(8)requires response files inbsd.rd’s ramdisk or via HTTP. A file on the media filesystem is ignored.- Firmware is NOT placed on a
/firmware/directory on the install media. It is bundled insidesite79.tgzand installed automatically byinstall.site.
First-Login Flow
After installation, the user logs in and sees:
Welcome to OpenRiot!
All required packages have been pre-installed.
To complete setup and download the latest OpenRiot repository, run:
curl -fsSL https://OpenRiot.org/setup.sh | sh
This requires a working network or WiFi connection.
The user runs the command, setup.sh clones the repo to ~/.local/share/openriot,
copies configs to ~/.config/, and runs openriot --install.
Functional Modules
1. Prerequisites Check
File: source/imaging/prereqs.go
- Verify running on OpenBSD (
uname -s == OpenBSD) - Verify root user (
id -u == 0) - Verify base image exists at
--base-imgorBASE_IMG - Fallback to
Build/Images/install79.imgrelative to repo root - Download base image from CDN if missing
Fix applied: Fallback path corrected from
Images/install79.imgtoBuild/Images/install79.imgto match actual repo layout.
2. Package & Firmware Download
File: source/imaging/download.go
Package Download
- Read
packages.yamlfor the package list - Read
Build/exceptions.yamlfor exclusions - Download from
https://cdn.openbsd.org/pub/OpenBSD/snapshots/packages/amd64/{pkg}.tgz - Skip existing files; clean stale versions
- Progress indicator:
Downloading package N/N: pkgname - Retry logic: 3 attempts per package
- HTTP timeout: 2 minutes (previously indefinite)
Fix applied:
downloadFile()now useshttp.Client{Timeout: 2 * time.Minute}instead ofhttp.Get(), which has no timeout and hangs indefinitely on slow networks.
Firmware Download
- Downloads from
http://firmware.openbsd.org/firmware/7.9/ - Files bundled:
athn-firmware,intel-firmware,inteldrm-firmware,iwm-firmware,iwx-firmware - Total: ~31MB
- Warns on individual failures, does not fail the build
3. Site Tarball Creation
File: source/imaging/site.go
Create site79.tgz containing:
site/
├── etc/
│ └── motd # From install/motd
├── openriot/
│ ├── packages/
│ │ └── snapshots/
│ │ └── amd64/
│ │ └── *.tgz # All downloaded packages
│ └── firmware/
│ └── *.tgz # Downloaded non-free firmware
└── install.site # Post-install script (inline)
No
openriot/repo/directory. The Git repository is fetched post-install. Noinstall.conffile. It is dead code on media filesystems.
CreateSite() now uses os.MkdirTemp(workDir, "site-") instead of a fixed
site/ subdirectory. This avoids permission collisions when a non-root user
runs --make-image site after a prior root run left site/ root-owned.
The temp directory is cleaned up automatically via defer os.RemoveAll.
install.site (inline, embedded in Go)
#!/bin/sh
# OpenRiot post-install script — runs at end of OpenBSD install
# Environment: root, chroot at new system root, no TTY, no X
log() { echo "[OPENRIOT] $*"; }
fail() { echo "[OPENRIOT] FAIL: $*"; }
log "OpenRiot post-install starting"
# ------------------------------------------------------------------
# 1. System configuration
# ------------------------------------------------------------------
# doas
if ! [ -f /etc/doas.conf ]; then
: > /etc/doas.conf
for homedir in /home/*; do
[ -d "$homedir" ] || continue
username="$(basename "$homedir")"
printf '%s\n' "permit nopass $username" >> /etc/doas.conf
done
printf '%s\n' "permit nopass :wheel" >> /etc/doas.conf
chmod 0440 /etc/doas.conf
log "doas configured"
fi
# installurl
printf '%s\n' "https://cdn.openbsd.org/pub/OpenBSD" > /etc/installurl
log "installurl configured"
# Add fish to /etc/shells
if ! grep -q '^/usr/local/bin/fish$' /etc/shells 2>/dev/null; then
printf '%s\n' "/usr/local/bin/fish" >> /etc/shells
log "fish added to /etc/shells"
fi
# Enable critical services (but NOT xenodm — user must start X manually)
rcctl enable apmd 2>/dev/null && rcctl set apmd flags -A 2>/dev/null
rcctl enable sndiod 2>/dev/null
mkdir -p /etc/wireguard
chmod 700 /etc/wireguard
log "services configured"
# ------------------------------------------------------------------
# 2. Install packages from local path (offline)
# ------------------------------------------------------------------
PKG_PATH_LOCAL="/openriot/packages/snapshots/amd64"
if [ -d "$PKG_PATH_LOCAL" ]; then
log "Installing packages from local path..."
cd "$PKG_PATH_LOCAL" || fail "cd $PKG_PATH_LOCAL"
export PKG_PATH="$PKG_PATH_LOCAL"
pkg_add -I *.tgz 2>&1
log "Package install complete"
else
log "Package directory not found: $PKG_PATH_LOCAL"
fi
# ------------------------------------------------------------------
# 2b. Install non-free firmware from local path (offline)
# ------------------------------------------------------------------
FW_PATH_LOCAL="/openriot/firmware"
if [ -d "$FW_PATH_LOCAL" ] && [ -n "$(ls "$FW_PATH_LOCAL"/*.tgz 2>/dev/null)" ]; then
log "Installing firmware from local path..."
for fw in "$FW_PATH_LOCAL"/*.tgz; do
[ -f "$fw" ] || continue
fw_name=$(basename "$fw")
tar xzf "$fw" -C / 2>/dev/null || continue
log "Firmware installed: $fw_name"
done
log "Firmware install complete"
else
log "No local firmware found, skipping"
fi
# ------------------------------------------------------------------
# 3. Per-user setup
# ------------------------------------------------------------------
for homedir in /home/*; do
[ -d "$homedir" ] || continue
username="$(basename "$homedir")"
# Welcome message only — setup.sh handles groups, shell, and XDG dirs
cat >> "$homedir/.profile" << 'WELCOME'
# OpenRiot — Welcome
echo ""
echo "Welcome to OpenRiot!"
echo ""
echo "All required packages have been pre-installed."
echo "To complete setup and download the latest OpenRiot repository, run:"
echo ""
echo " curl -fsSL https://OpenRiot.org/setup.sh | sh"
echo ""
echo "This requires a working network or WiFi connection."
echo ""
WELCOME
done
# 4. Skel for future users
cat >> /etc/skel/.profile << 'WELCOME'
# OpenRiot — Welcome
echo ""
echo "Welcome to OpenRiot!"
echo ""
echo "All required packages have been pre-installed."
echo "To complete setup and download the latest OpenRiot repository, run:"
echo ""
echo " curl -fsSL https://OpenRiot.org/setup.sh | sh"
echo ""
echo "This requires a working network or WiFi connection."
echo ""
WELCOME
log "Post-install complete"
4. Image Building
File: source/imaging/build.go
Expand Phase
- Copy base image to output path (never modify source)
- Calculate target size:
baseSize + tgzSize + 350MB buffer, round to 4MB - Minimum: 512MB (irrelevant in practice)
truncate -s <size> <image>- Configure
vnd0, read current disklabel - Update
a:partition to fill expanded space - Update
c:partition to total image size growfs -y /dev/vnd0a- Release
vnd0(viadeferto ensure cleanup on error)
Fix applied: Added
defer exec.Command("vnconfig", "-u", "vnd0").Run()inexpandImage()so the vnd device is released even ifgetPartitionInfo,writeDisklabel, orgrowfsfails.
Injection Phase
- Configure
vnd0on output image fsck -y /dev/vnd0a- Mount at
/mnt - Create
/mnt/7.9/amd64/directory - Copy
site79.tgzto/mnt/7.9/amd64/site79.tgz - Append
site79.tgzentry to/mnt/7.9/amd64/index.txtusing actualls -lToutput on the copied file (do NOT overwrite) syncfilesystem before unmount to prevent truncation- Unmount, release
vnd0(viadeferto ensure cleanup on error)
Fix applied: Added
exec.Command("sync").Run()afterindex.txtclose and before the deferredumount. Without this, the 781MBsite79.tgzwas silently truncated by the kernel buffer cache on unmount.Fix applied: Added
deferblock ininjectContent()that runsumount /mntandvnconfig -u vnd0on all return paths, preventing leaked mounts and vnd devices when errors occur mid-injection.
Shrink Phase
Removed. The image is sized correctly from the start. No shrink step. Truncating an FFS image after data has been written corrupts the filesystem.
5. Drive Detection & Burning
File: source/imaging/burn.go
Same as before — detect drives, classify root/removable/internal, prompt user,
write with dd via pv.
6. Cleanup
File: source/imaging/burn.go (Cleanup())
- Unmount
/mntif mounted - Release
vnd0device - Remove
WORK_DIRcontents (packages, site temp dirs, tarball)
No repo cache.
Build/repo-cache/is no longer used and is removed from cleanup logic.
Fix applied:
Cleanup()now returnsos.RemoveAllerrors instead of swallowing them, and rejects unsafe work-dir paths (relative paths or paths containing..).
Output Artifacts
| File | Location | Description |
|---|---|---|
site79.tgz |
WORK_DIR/site79.tgz |
Custom install set |
openriot.img |
OUTPUT_IMG |
Bootable installer image |
openriot.sha256 |
Same dir as img | SHA256 checksum |
| Package cache | WORK_DIR/packages/ |
Downloaded .tgz files |
| Firmware cache | WORK_DIR/firmware/ |
Downloaded firmware .tgz |
No
openriot.tgz. The tarball is namedsite79.tgzper OpenBSD custom set conventions. It is placed in7.9/amd64/on the install media.
Error Handling
| Error | Action |
|---|---|
| Not running on OpenBSD | Exit with error |
| Not root | Exit with error, suggest doas |
| Base image missing | Download from CDN, or exit |
| Package download fails | Warn, continue with remaining |
| Firmware download fails | Warn, continue with remaining |
| Package list empty | Exit with error |
| Disklabel/growfs fails | Exit with error |
| Mount fails | Exit with error |
| No space left on device | Exit with error, increase buffer |
| Burn fails | Show error, leave image file |
Implementation Checklist
source/imaging/site.go
- Delete
setupRepo()function entirely - Delete
updateCache()function entirely - Rewrite
CreateSite()to skip repo copy, copy only packages + firmware + motd + install.site - Rewrite
createInstallSite()with new script (no xenodm, no repo copy, curl welcome) - Remove
getBuildDir()if no longer used - Use
os.MkdirTempfor site staging directory to avoid root-owned leftover collisions - Add
defer os.RemoveAll(siteDir)for automatic cleanup - Check all
MkdirAllerrors instead of silently ignoring - Use
tar czfinstead oftar czvf(no verbose output) - Fix: Replace one-by-one
pkg_addloop with bulkpkg_add -I *.tgz(non-fatal) - Fix: Remove all user/group/shell modifications from
install.site;setup.shhandles them - Fix: Add per-user
doas.confentries (permit nopass $username) alongside:wheel - Fix: Add firmware auto-installation block (
tar xzf -C /)
source/imaging/build.go
- Change buffer from
50*1024*1024to350*1024*1024 - Fix hardcoded “2GB” log message to report actual expanded size
- Add
df -h /mntlogging before and after injection - Fix
injectContent()to use realls -lToutput forindex.txtinstead of manualfmt.Sprintf - Remove dead
getFileSize()function - Add
defer vnconfig -u vnd0inexpandImage()for error-path cleanup - Add
defer umount/vnconfigininjectContent()for error-path cleanup - Fix: Add
exec.Command("sync").Run()afterindex.txtclose to preventsite79.tgztruncation - Removed:
/mnt/firmware/copy (redundant — firmware is insidesite79.tgz)
source/imaging/runner.go
- Remove
repo-cachecleanup fromrunClean() - Fix
argsslice mutation bug duringrangeiteration (buildfilteredslice) - Update help text to include
--work-dir,--version, andhelpmode - Add:
DownloadFirmware()call between package download and site creation
source/imaging/download.go
- Add 2-minute HTTP timeout to
downloadFile()(was indefinite) - Add:
DownloadFirmware()function with firmware file list and download logic - Add:
firmwareFilesvar with 5 bundled firmware packages
source/imaging/burn.go
- Make
Cleanup()returnos.RemoveAllerrors instead of swallowing them - Reject unsafe work-dir paths (relative or containing
..)
source/imaging/prereqs.go
- Fix fallback image path to
Build/Images/install79.img(was missingBuild/)
docs/Image-Builder-Spec.md
- This file — updated to reflect current architecture, status, and firmware
Validation
make && make testpassesmake imageas root succeedsls -lh Build/Images/openriot.imgshows < 2.0GB (~1932MB)- Mount image, verify
site79.tgzexists in7.9/amd64/ - Verify
index.txtcontainssite79.tgzalongside standard sets - Extract
site79.tgz, verify it containsopenriot/packages/,openriot/firmware/, andinstall.site - Verify
install.sitedoes NOT containxenodmenable - Verify
install.sitecontainscurl -fsSL https://OpenRiot.org/setup.sh | sh --make-image siteworks as non-root even after prior root run- Flash to USB, test boot and install — Partially done (image boots, site discovered, install.site runs, packages install with some dependency edge cases)
- After install: verify all packages pre-installed (
which fish,which alacritty) — Partially done - After install: verify firmware is extracted to
/etc/firmware/— NOT YET DONE - After install: verify
xenodmis NOT enabled — NOT YET DONE - After install: verify first login shows welcome message — NOT YET DONE
- After install: verify
doasworks — NOT YET DONE - After install: verify
fishis default shell — NOT YET DONE - After install: verify user groups include
wheeland original groups — NOT YET DONE
Why This Architecture
Why no repo in the tarball?
The OpenRiot repository (with .git/, Locked/, backgrounds/) is ~1.1GB.
Adding it to the tarball pushes the image to ~2.1GB+. The only way to stay under
2GB is to exclude it. The user fetches the latest repo after login anyway.
Why install.site instead of install.conf?
autoinstall(8) only reads response files from:
bsd.rd’s built-in RAM disk (/auto_install.conf)- HTTP fetch during netboot
A file named install.conf on the install media filesystem is never scanned.
It is dead code. install.site(5) is the documented, reliable mechanism for
running post-install scripts from a custom install set.
Why no shrinkImage?
Truncating an FFS image after data has been written corrupts the superblock
and cylinder group summaries. The correct approach is to size the image
accurately from the start using truncate + growfs.
Why not enable xenodm?
The user explicitly requested X11 NOT start on first boot. The welcome message
directs the user to run setup.sh after login, which handles graphical setup.
Why bulk pkg_add *.tgz instead of one-by-one?
OpenBSD pkg_add *.tgz builds a dependency graph across all packages and
installs them in topological order. One-by-one alphabetical installation fails
when a package’s dependencies come later in the alphabet. pkg_add *.tgz
with PKG_PATH set resolves all dependencies correctly regardless of filename
order.
Why firmware inside site79.tgz instead of /firmware/ on media?
OpenBSD’s installer only checks /firmware/ when the user answers yes to
“Install non-free firmware?” — and even then, it expects a specific structure.
By placing firmware inside site79.tgz, install.site extracts it directly to
/ during post-install. No user prompt needed, and it works regardless of
installer UI behavior.
Future Considerations
- Custom base image versions
- Parallel package downloads
- Resume interrupted downloads
- Image signing/verification
- Shallow Git clone in
setup.shto reduce post-install bandwidth