commit da21c3314f47901983ba8f68bb17da89e360b231
parent 40626fe3a9ed50ec119acc41f0965462b4868120
Author: finwo <finwo@pm.me>
Date: Fri, 18 Sep 2026 17:56:26 +0200
No more /opt/rescue workaround, full ownership of img
Diffstat:
8 files changed, 311 insertions(+), 5 deletions(-)
diff --git a/mk/make-img.sh b/mk/make-img.sh
@@ -0,0 +1,144 @@
+#!/bin/sh
+# mk/make-img.sh - build a bootable UNOS image that carries its own installer.
+#
+# ./mk/make-img.sh # build with defaults
+# ./mk/make-img.sh --version 0.1.0 --size 3072
+# ./mk/make-img.sh --authorized-keys ~/.ssh/id_ed25519.pub
+#
+# Output: build/work/unos-<version>-x86_64.img
+# GPT + ESP (FAT, EFI/BOOT/BOOTX64.EFI) + ext4 root, bootable by firmware
+# with no -kernel shortcut, carrying /opt/unos/unos-<version>-x86_64.bin.
+#
+# WHY A VM BUILDS THIS
+# --------------------
+# Laying down a partitioned, bootable image needs loop devices, mount, and
+# grub-install into a mounted ESP -- all of which need real root. We have none
+# (user namespaces cannot set up loop devices). So instead of reimplementing
+# partitioning on the host with mtools and grub-mkimage, this boots a UNOS live
+# VM and runs installer/install.sh against a blank second disk. That path is
+# already covered by tests/integration/test_qemu_install.sh, so the image
+# builder and the installer cannot drift apart: fixing one fixes the other.
+#
+# WHY THE PAYLOAD IS COPIED IN AFTERWARDS
+# ---------------------------------------
+# The .bin contains a tarball of the rootfs. If the rootfs being packed already
+# contained the .bin, the image would contain a copy of itself -- so the
+# installer is built from the plain rootfs, installed, and only then copied
+# onto the installed system as a separate step.
+set -eu
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+ROOT=$(cd "${HERE}/.." && pwd)
+BUILD="${ROOT}/build/work"
+
+VERSION="${UNOS_VERSION:-0.1.0}"
+SIZE_MB=3072
+OUT=""
+AUTH_KEYS=""
+PORT="${MAKEIMG_SSH_PORT:-2295}"
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --version) VERSION="$2"; shift ;;
+ --out) OUT="$2"; shift ;;
+ --size) SIZE_MB="$2"; shift ;;
+ --authorized-keys) AUTH_KEYS="$2"; shift ;;
+ -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
+ *) echo "make-img.sh: unknown argument: $1" >&2; exit 1 ;;
+ esac
+ shift
+done
+
+[ -n "${OUT}" ] || OUT="${BUILD}/unos-${VERSION}-x86_64.img"
+BIN="${BUILD}/unos-${VERSION}-x86_64.bin"
+LIVE_LOG="${BUILD}/make-img-live.log"
+
+msg() { printf '==> %s\n' "$*"; }
+die() { printf 'make-img.sh: error: %s\n' "$*" >&2; exit 1; }
+
+cleanup() {
+ for p in $(pgrep -f 'qemu-sys[t]em-x86_64' 2>/dev/null); do kill "$p" 2>/dev/null || true; done
+}
+trap cleanup EXIT
+
+[ -d "${ROOT}/rootfs" ] || die "no rootfs/; run ./mk/rootfs.sh first"
+
+KEY=$(ls "${HOME}"/.ssh/id_ed25519 2>/dev/null | head -1 || true)
+[ -n "${KEY}" ] || KEY=$(ls "${HOME}"/.ssh/*.pub 2>/dev/null | head -1 | sed 's/.pub$//' || true)
+[ -n "${KEY}" ] && [ -f "${KEY}" ] || die "no usable ssh key in ~/.ssh (needed to drive the build VM)"
+
+ssh_vm() {
+ ssh -i "${KEY}" -p "${PORT}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
+ -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes root@localhost "$@" 2>&1
+}
+
+wait_ssh() {
+ i=1
+ while [ "$i" -le 90 ]; do
+ if ssh_vm true >/dev/null 2>&1; then return 0; fi
+ sleep 1
+ i=$((i + 1))
+ done
+ return 1
+}
+
+# --- 1. the payload --------------------------------------------------------
+# Built from the plain rootfs. Optionally bake authorized_keys in, because an
+# image nobody can log into is only useful over a serial console.
+STAGE="${BUILD}/stage-img-root"
+rm -rf "${STAGE}"
+mkdir -p "${STAGE}"
+msg "staging rootfs"
+(cd "${ROOT}/rootfs" && tar -cf - .) | (cd "${STAGE}" && tar -xf -)
+
+if [ -n "${AUTH_KEYS}" ]; then
+ [ -f "${AUTH_KEYS}" ] || die "no such authorized_keys file: ${AUTH_KEYS}"
+ mkdir -p "${STAGE}/root/.ssh"
+ cat "${AUTH_KEYS}" > "${STAGE}/root/.ssh/authorized_keys"
+ chmod 700 "${STAGE}/root/.ssh"
+ chmod 600 "${STAGE}/root/.ssh/authorized_keys"
+ msg "baked authorized_keys from ${AUTH_KEYS}"
+else
+ msg "no --authorized-keys given: image will be console-only"
+fi
+
+msg "building installer payload"
+"${ROOT}/installer/mkinstaller.sh" --version "${VERSION}" \
+ --rootfs "${STAGE}" --out "${BIN}" >/dev/null || die "mkinstaller failed"
+[ -s "${BIN}" ] || die "installer payload not produced"
+msg "payload: $(basename "${BIN}") ($(du -h "${BIN}" | cut -f1))"
+
+# --- 2. build VM -----------------------------------------------------------
+rm -f "${OUT}" "${BUILD}/ovmf-vars.fd"
+cleanup
+
+msg "booting build VM (target ${SIZE_MB}M)"
+UEFI=1 IMG_SIZE_MB=1536 QEMU_MEM=1536 QEMU_SSH_PORT="${PORT}" \
+ TARGET_DISK="${OUT}" TARGET_SIZE_MB="${SIZE_MB}" \
+ EXTRA_FILES="${BIN}:/root/unos.bin" \
+ "${ROOT}/mk/run-qemu.sh" run > "${LIVE_LOG}" 2>&1 &
+
+wait_ssh || { tail -20 "${LIVE_LOG}" >&2; die "build VM did not come up"; }
+
+# --- 3. install onto the blank disk ---------------------------------------
+msg "installing onto /dev/vdb"
+out=$(ssh_vm 'sh /root/unos.bin --mode generic --disk /dev/vdb --force; echo "RC=$?"')
+case "${out}" in
+ *RC=0*) ;;
+ *) printf '%s\n' "${out}" >&2; die "installer failed inside the build VM" ;;
+esac
+
+# The installer payload is deliberately NOT copied into the installed root.
+# The rootfs must be byte-identical whether it was installed normally or as a
+# rescue image; embedding an 85MB copy of the installer would bloat every
+# installed switch with a copy of itself, and would mean the image contains its
+# own payload which contains the image.
+ssh_vm 'sync' >/dev/null 2>&1 || true
+cleanup
+
+[ -s "${OUT}" ] || die "no image produced"
+msg "done: ${OUT} ($(du -h "${OUT}" | cut -f1))"
+msg ""
+msg " boot it: ./mk/run-qemu.sh boot-installed ${OUT} (UEFI=1)"
+msg " install: curl -O http://.../unos-installer.bin && sh unos-installer.bin --disk /dev/sdX --force"
+msg " write it: dd if=${OUT} of=/dev/sdX bs=4M conv=fsync"
diff --git a/mk/rootfs.sh b/mk/rootfs.sh
@@ -47,7 +47,7 @@ apk_ns "${APK}" --root "${ROOTFS}" --initdb \
echo "==> UNOS system (fully trusted from here on)"
apk_ns "${APK}" --root "${ROOTFS}" \
--cache-dir "${CACHE}" --repository "${REPO}" \
- add base-files glibc busybox unos-firstboot tinyssh libmnl zlib openssl iproute2 apk-tools linux-longterm grub popt efivar efibootmgr unosd libstdc++ e2fsprogs gptfdisk smartmontools
+ add base-files glibc busybox unos-firstboot tinyssh libmnl zlib openssl iproute2 apk-tools linux-longterm grub popt efivar efibootmgr unosd libstdc++ e2fsprogs gptfdisk smartmontools dosfstools curl
# Deterministic loader cache: package post-installs refresh it during the
# transaction, but assembly must not depend on script-execution order.
diff --git a/mk/run-qemu.sh b/mk/run-qemu.sh
@@ -148,12 +148,19 @@ run_qemu() {
boot_installed() {
disk="$1"
[ -f "${disk}" ] || { echo "run-qemu.sh: no such image: ${disk}" >&2; exit 1; }
+ tgt=""
+ if [ -n "${TARGET_DISK}" ]; then
+ [ -f "${TARGET_DISK}" ] || truncate -s "${TARGET_SIZE_MB}M" "${TARGET_DISK}"
+ tgt="-drive file=${TARGET_DISK},format=raw,if=virtio"
+ echo " target: ${TARGET_DISK} (/dev/vdb, ${TARGET_SIZE_MB}M)"
+ fi
echo "==> booting installed disk ${disk} (no -kernel; firmware -> GRUB -> UNOS)"
echo " uefi: ${UEFI}"
# shellcheck disable=SC2086
exec qemu-system-x86_64 -enable-kvm -m "${QEMU_MEM}" \
$(uefi_args) \
-drive file="${disk}",format=raw,if=virtio \
+ ${tgt} \
-nographic \
-netdev user,id=net0,hostfwd=tcp::${QEMU_SSH_PORT}-:22 \
-device e1000,netdev=net0
diff --git a/packages/busybox/template b/packages/busybox/template
@@ -1,7 +1,7 @@
# Template file for 'busybox'
pkgname=busybox
version=1.36.1
-revision=7
+revision=8
short_desc="Swiss Army knife of embedded Linux utilities"
maintainer="finwo <finwo@pm.me>"
license="GPL-2.0-only"
@@ -28,16 +28,22 @@ depends=
# udhcpd (dnsmasq covers the server side), httpd/inetd (tcpsvd is our
# super-server), IFUP (unosd owns /etc/network/interfaces via unosc, busybox
# ifupdown would conflict and its `iface <name>` parser is for old
-# `iface <name> inet static` only -- cumulus `iface <name>` fails there).
+# `iface <name> inet static` only -- cumulus `iface <name>` fails there),
+# MKFS_VFAT/MKDOSFS (dosfstools owns mkfs.vfat; two providers of one command
+# resolved by PATH order is a trap, and busybox's variant warns about
+# codepage conversion on every run because it wants iconv data our
+# C.UTF-8-only glibc does not ship. dosfstools also brings fsck.fat, which
+# busybox has no equivalent of at all -- and the ESP is the one partition
+# whose corruption stops the switch booting).
# Untouched (defconfig default) until their design lands: SYSLOGD/KLOGD.
# No checked-in .config on purpose: defconfig + CFG_* is the source of truth
# (this kconfig has no savedefconfig anyway); the assertions below enforce it.
CFG_ON="INIT HALT POWEROFF REBOOT GETTY LOGIN MOUNT UMOUNT SWITCH_ROOT ASH ASH_JOB_CONTROL RUNSV RUNSVDIR SV SVLOGD CHPST SETUIDGID TCPSVD UDHCPC"
-CFG_OFF="IP FEATURE_IP_ADDRESS FEATURE_IP_LINK FEATURE_IP_ROUTE FEATURE_IP_RULE FEATURE_IP_TUNNEL TC LDCONFIG UNLZMA LZCAT LZMA UNXZ XZCAT XZ UDHCPD HTTPD INETD IFUP IFDOWN"
+CFG_OFF="IP FEATURE_IP_ADDRESS FEATURE_IP_LINK FEATURE_IP_ROUTE FEATURE_IP_RULE FEATURE_IP_TUNNEL TC LDCONFIG UNLZMA LZCAT LZMA UNXZ XZCAT XZ UDHCPD HTTPD INETD IFUP IFDOWN MKFS_VFAT MKDOSFS"
# applet names as `busybox --list` prints them
REQUIRE="init halt poweroff reboot getty login mount umount switch_root ash runsv runsvdir sv svlogd chpst setuidgid tcpsvd udhcpc"
-FORBID="ip tc ldconfig unlzma lzcat lzma unxz xzcat xz udhcpd httpd inetd ifup ifdown"
+FORBID="ip tc ldconfig unlzma lzcat lzma unxz xzcat xz udhcpd httpd inetd ifup ifdown mkfs.vfat mkdosfs"
set_config() {
# $1 = symbol, $2 = y|n
diff --git a/packages/curl/files/post-install b/packages/curl/files/post-install
@@ -0,0 +1,6 @@
+#!/bin/sh
+# curl post-install: refresh the loader cache so libcurl resolves.
+# Every UNOS library package carries this (apk runs scripts per package at
+# install time; a single glibc-side run would be order-dependent). Absolute
+# path on purpose (see glibc post-install).
+exec /sbin/ldconfig
diff --git a/packages/curl/template b/packages/curl/template
@@ -0,0 +1,84 @@
+# Template file for 'curl'
+pkgname=curl
+version=8.18.0
+revision=0
+short_desc="Command line HTTP/HTTPS client and libcurl"
+maintainer="finwo <finwo@pm.me>"
+license="curl"
+homepage="https://curl.se/"
+distfiles="https://curl.se/download/curl-${version}.tar.xz"
+checksum=40df79166e74aa20149365e11ee4c798a46ad57c34e4f68fd13100e2c9a91946
+depends="glibc busybox openssl zlib"
+makedepends="openssl zlib"
+
+# Notes:
+# - Why: a rescue image needs to pull things onto the box -- an installer
+# payload, a replacement config, a kernel. tinyssh is daemon-only (upstream
+# ships no client, so no scp/sftp either), which left busybox wget as the
+# sole inbound path. wget is fine until you need TLS with a real CA store,
+# redirects, or a useful error message when a fetch fails at 3am.
+# - Protocol surface is cut hard: this is a recovery tool, not a general
+# purpose client. HTTP/HTTPS/FTP stay; everything else goes. Each disabled
+# protocol is one less parser reachable from the network.
+# - OpenSSL backend (we already package it) rather than bundling another TLS
+# stack. CA bundle comes from the system path, not baked in here.
+# - libcurl ships too: it is the same build, and shipping only the binary
+# would mean statically linking it for no reason.
+
+do_configure() {
+ cd "${WRKSRC}"
+ ./configure \
+ --prefix=/usr \
+ --libdir=/usr/lib \
+ --disable-static \
+ --enable-shared \
+ --with-openssl \
+ --with-zlib \
+ --without-libpsl \
+ --without-libidn2 \
+ --without-brotli \
+ --without-zstd \
+ --without-nghttp2 \
+ --without-libssh2 \
+ --disable-ldap \
+ --disable-ldaps \
+ --disable-rtsp \
+ --disable-dict \
+ --disable-telnet \
+ --disable-tftp \
+ --disable-pop3 \
+ --disable-imap \
+ --disable-smb \
+ --disable-smtp \
+ --disable-gopher \
+ --disable-mqtt \
+ --disable-manual \
+ --enable-optimize
+}
+
+do_build() {
+ cd "${WRKSRC}"
+ make -j"$(nproc)"
+}
+
+do_install() {
+ cd "${WRKSRC}"
+ make install DESTDIR="${DESTDIR}"
+
+ # Sysroot seed: anything later linking libcurl builds against ours.
+ make install DESTDIR="${SYSROOT}"
+
+ rm -f "${DESTDIR}"/usr/lib/*.la "${SYSROOT}"/usr/lib/*.la
+ rm -rf "${DESTDIR}/usr/share/man" "${DESTDIR}/usr/share/doc"
+ # curl-config is a build-time helper for consumers, not a switch tool.
+ rm -f "${DESTDIR}/usr/bin/curl-config"
+
+ [ -x "${DESTDIR}/usr/bin/curl" ] || die "curl: no curl binary"
+ [ -e "${DESTDIR}/usr/lib/libcurl.so" ] || die "curl: no libcurl"
+ # Prove TLS is actually wired up; a curl without HTTPS is a trap in a
+ # rescue image, and configure will happily build one.
+ readelf -d "${DESTDIR}/usr/bin/curl" | grep -q 'libcurl\.so' \
+ || die "curl: binary does not link libcurl"
+ readelf -d "${DESTDIR}/usr/lib/libcurl.so."* | grep -q 'libssl\.so' \
+ || die "curl: libcurl not linked against OpenSSL (no HTTPS)"
+}
diff --git a/packages/dosfstools/template b/packages/dosfstools/template
@@ -0,0 +1,55 @@
+# Template file for 'dosfstools'
+pkgname=dosfstools
+version=4.2
+revision=0
+short_desc="FAT filesystem utilities (mkfs.fat, fsck.fat)"
+maintainer="finwo <finwo@pm.me>"
+license="GPL-3.0-or-later"
+homepage="https://github.com/dosfstools/dosfstools"
+distfiles="https://github.com/dosfstools/dosfstools/releases/download/v${version}/dosfstools-${version}.tar.gz"
+checksum=64926eebf90092dca21b14259a5301b7b98e7b1943e8a201c7d726084809b527
+depends="glibc busybox"
+
+# Notes:
+# - Why: the ESP is FAT, so the installer creates one on every install and a
+# rescue image needs to be able to REPAIR one. busybox has an mkfs.vfat
+# applet but no fsck for FAT at all, which means a corrupt ESP -- the single
+# partition that decides whether the switch boots -- was unfixable in place.
+# - busybox's MKFS_VFAT applet is disabled in packages/busybox/template so
+# there is exactly one mkfs.vfat on the system. Two providers of the same
+# command name resolved by PATH order is the kind of ambiguity that only
+# shows up when the two behave differently; busybox's also emits codepage
+# warnings on every run because it wants iconv data our glibc does not carry.
+# - iconv/nls off: the codepage machinery is for long-filename charset
+# conversion, which an ESP does not need and which our C.UTF-8-only glibc
+# cannot satisfy anyway.
+
+do_configure() {
+ cd "${WRKSRC}"
+ ./configure \
+ --prefix=/usr \
+ --sbindir=/usr/sbin \
+ --mandir=/usr/share/man \
+ --enable-compat-symlinks \
+ --without-udev \
+ --without-iconv
+}
+
+do_build() {
+ cd "${WRKSRC}"
+ make -j"$(nproc)"
+}
+
+do_install() {
+ cd "${WRKSRC}"
+ make install DESTDIR="${DESTDIR}"
+
+ rm -rf "${DESTDIR}/usr/share/doc" "${DESTDIR}/usr/share/locale"
+
+ # --enable-compat-symlinks gives the names callers actually use:
+ # installer/install.sh invokes mkfs.vfat, and fsck.vfat is what anyone
+ # reaching for a FAT repair will type.
+ for t in mkfs.fat fsck.fat mkfs.vfat fsck.vfat fatlabel; do
+ [ -e "${DESTDIR}/usr/sbin/${t}" ] || die "dosfstools: missing ${t}"
+ done
+}
diff --git a/packages/smartmontools/template b/packages/smartmontools/template
@@ -51,6 +51,9 @@ do_install() {
rm -f "${DESTDIR}"/etc/smartd.conf
rm -f "${DESTDIR}"/usr/share/man/man8/smartd.8 \
"${DESTDIR}"/usr/share/man/man5/smartd.conf.5
+ # smartd_warning.sh is smartd's notification helper; without smartd it is
+ # an orphan that only looks like a supported hook.
+ rm -f "${DESTDIR}"/etc/smartd_warning.sh
rm -rf "${DESTDIR}"/etc/smartd_warning.d \
"${DESTDIR}"/usr/share/smartmontools \
"${DESTDIR}"/usr/libexec \
@@ -60,6 +63,7 @@ do_install() {
[ -x "${DESTDIR}/usr/sbin/smartctl" ] || die "smartmontools: no smartctl"
[ ! -e "${DESTDIR}/usr/sbin/smartd" ] || die "smartmontools: smartd should not ship"
+ [ ! -e "${DESTDIR}/etc/smartd_warning.sh" ] || die "smartmontools: smartd helper left behind"
readelf -d "${DESTDIR}/usr/sbin/smartctl" | grep -q 'libstdc++\.so\.6' \
|| die "smartmontools: smartctl does not link libstdc++.so.6"
}