#!/bin/sh # mkinstall.sh - rawnix.org Netinstall Image Builder # Run from /root/bin/ on the host build system. # # Requires in /root/bin/: # vmlinuz - installer kernel (EFI stub) # modules/ - kernel modules directory # install - installer script # setup-filesystem - filesystem skeleton builder # setup-help - one-screen installation summary # man/ - installer manual pages (installation.7, # network.7, ..., genluks.8); see "Installer # manual pages" below # genfstab - fstab generator # enter-chroot - chroot helper # setup-boot - cmdline generator # genluks - encrypted disk layout: format, mkfs, mount, # keyfiles, crypttab # ports.rsync - rsync config with token (active, no rename needed) # *.pub - signify public keys for the trust store # # Usage: ./mkinstall.sh [output.img] # # The image boots on both firmwares, with nothing to choose: UEFI starts # the kernel itself as EFI/BOOT/BOOTX64.EFI and never reads limine.conf; # legacy BIOS (SeaBIOS: Hetzner Cloud, qemu without OVMF) runs Limine's # stage 1 from the MBR, stage 2 from the 1 MiB BIOS boot partition, and # then limine-bios.sys and limine.conf from the ESP. Needs the limine # package built with --enable-bios on this host. set -e BINDIR="/root/bin" # --bios used to select the layout that is now the only one. Accepted # and ignored, so an old command line still builds the same image. if [ "${1:-}" = "--bios" ]; then shift fi IMG="${1:-/root/rawnix-install.img}" WORK="/root/mkinstall" ROOT="$WORK/rootfs" # ── Kernel ──────────────────────────────────────────────────── # The installer kernel is a package. Build it and unpack it here: # # cd /usr/local/src/linux-7.2.11-img && mkpkg -is # tar xf linux-7.2.11-img#7.2.11-1.pkg.tar.gz -C /root/ # # which gives /root/boot/vmlinuz-7.2.11-img, /root/boot/config-7.2.11-img # and /root/lib/modules/7.2.11-img/. All are read straight out of that # layout -- no copying or renaming, and no second place for the version # to be recorded. KERNEL_ROOT="/root" # ── Root partition UUID ─────────────────────────────────────── # This is NOT arbitrary and must not be changed on its own. # # The installer kernel is an EFI stub booted directly by the # firmware: there is no bootloader on the ESP, no limine.conf, and # therefore no way to pass a command line at boot. The kernel gets # its root device from CONFIG_CMDLINE, compiled in as # # root=PARTUUID=a1b2c3d4-0000-0000-0000-000012345678 # # Change this value without rebuilding the installer kernel and the # image builds cleanly, boots, and panics with "unable to mount root # fs". Change the kernel's CONFIG_CMDLINE without changing this and # you get the same panic from the other direction. Both directions # are now checked below, against the config shipped in the kernel # package, before anything is built. PARTUUID="a1b2c3d4-0000-0000-0000-000012345678" ESP_SIZE=64 # MiB, ESP GPT_SLACK=2 # MiB, 1 MiB alignment gap + backup GPT # Partitions: the BIOS boot partition first, because bios-install # records its location and Limine's own docs place it there; then the # ESP; then root. BIOS_SIZE=1 # MiB, BIOS boot partition (ef02): Limine stage 2 ESP_NR=2 ROOT_NR=3 LOOP="" die() { echo "ERROR: $*" >&2; exit 1; } # ── Cleanup ─────────────────────────────────────────────────── # Without this, any failure between losetup and losetup -d leaves # $WORK/mnt mounted and the loop device attached. The next run's # `rm -rf "$WORK"` then descends into the still-mounted image, # deletes its contents, fails to rmdir the mountpoint with EBUSY, # and aborts under set -e -- two runs away from the actual cause. cleanup() { if mountpoint -q "$WORK/esp" 2>/dev/null; then umount "$WORK/esp"; fi if mountpoint -q "$WORK/mnt" 2>/dev/null; then umount "$WORK/mnt"; fi [ -n "$LOOP" ] && losetup -d "$LOOP" 2>/dev/null return 0 } trap cleanup EXIT INT TERM [ "$(id -u)" = 0 ] || die "Must run as root" # Resolve the kernel out of the unpacked package. Extracting a # second kernel into /root leaves two vmlinuz-* and two module # trees; taking the newest version and then asserting the matching # module tree exists is what stops an image shipping a bzImage and # somebody else's modules. KERNEL=$(ls -1 "$KERNEL_ROOT"/boot/vmlinuz-* 2>/dev/null | sort -V | tail -1) [ -n "$KERNEL" ] && [ -f "$KERNEL" ] || \ die "No $KERNEL_ROOT/boot/vmlinuz-* — unpack the linux-installer package into $KERNEL_ROOT/ first" KVER=${KERNEL##*/vmlinuz-} MODULES="$KERNEL_ROOT" [ -d "$KERNEL_ROOT/lib/modules/$KVER" ] || \ die "$KERNEL is $KVER but $KERNEL_ROOT/lib/modules/$KVER does not exist" [ -f "$KERNEL_ROOT/lib/modules/$KVER/modules.dep" ] || \ die "$KERNEL_ROOT/lib/modules/$KVER has no modules.dep — incomplete kernel package?" # The kernel's own compiled-in command line, from the config the # package ships. This is the single source of truth: limine.conf below # is generated from it (so UEFI and BIOS boots get identical arguments, # console order and ro/rw included), and the PARTUUID is checked # against it so the two halves of the coupling cannot drift apart. KCONFIG="$KERNEL_ROOT/boot/config-$KVER" [ -f "$KCONFIG" ] || die "$KCONFIG missing — the package ships the config next to vmlinuz" KCMDLINE=$(sed -n 's/^CONFIG_CMDLINE="\(.*\)"$/\1/p' "$KCONFIG") [ -n "$KCMDLINE" ] || die "$KCONFIG has no CONFIG_CMDLINE — the kernel has no root device" case "$KCMDLINE" in *"root=PARTUUID=$PARTUUID"*) ;; *) die "CONFIG_CMDLINE ($KCMDLINE) does not carry root=PARTUUID=$PARTUUID" ;; esac [ -f "$BINDIR/install" ] || die "$BINDIR/install not found" [ -f "$BINDIR/setup-filesystem" ] || die "$BINDIR/setup-filesystem not found" [ -f "$BINDIR/setup-help" ] || die "$BINDIR/setup-help not found" [ -f "$BINDIR/genfstab" ] || die "$BINDIR/genfstab not found" [ -f "$BINDIR/enter-chroot" ] || die "$BINDIR/enter-chroot not found" [ -f "$BINDIR/setup-boot" ] || die "$BINDIR/setup-boot not found" [ -f "$BINDIR/genluks" ] || die "$BINDIR/genluks not found" [ -f "$BINDIR/ports.rsync" ] || die "$BINDIR/ports.rsync not found" [ -f "$BINDIR/man/installation.7" ] || die "$BINDIR/man/installation.7 not found — the image's manual pages" # The BIOS half uses this host's limine: the host tool for bios-install # and limine-bios.sys for the ESP. limine-bios.sys only exists when the # package was built with --enable-bios, so it doubles as the check that # bios-install will not refuse with "compiled without BIOS support". command -v limine >/dev/null 2>&1 || \ die "no limine on this host (pkg add limine)" [ -f /usr/share/limine/limine-bios.sys ] || \ die "/usr/share/limine/limine-bios.sys missing — limine was built without --enable-bios" # The ports.rsync in the setup repo is a template with a placeholder # token. An image built from it looks fine and fails every install at # the first rsync ("Cannot reach rsync.rawnix.org"), so insist on a # real token here: a token= line that is neither empty nor all X. if ! grep -q '^token=' "$BINDIR/ports.rsync" || \ grep -q '^token=X*$' "$BINDIR/ports.rsync"; then die "$BINDIR/ports.rsync has no real token — put the real one in before building" fi # The trust store is what makes a signature mean anything. Without a # public key in the target, mkpkg's trust anchor has nothing to check a # port's signify() block against and `pkg sysup` cannot verify what it # downloads -- so an image with no key produces installs that can fetch # ports but not trust them. have_pub="" for _pub in "$BINDIR"/*.pub; do if [ -f "$_pub" ]; then have_pub=1; fi done [ -n "$have_pub" ] || die "no $BINDIR/*.pub — the image would have an empty trust store" # A secret key in an image that gets written to USB sticks and handed # out is unrecoverable: the only fix is a new keypair and resigning the # whole tree. Refuse rather than rely on the glob above being careful. for _sec in "$BINDIR"/*.sec; do [ -e "$_sec" ] || continue die "$_sec is in $BINDIR — move signing keys out before building an image" done echo "=== rawnix.org Netinstall Image Builder ===" echo "Kernel: $KERNEL ($KVER)" echo "Modules: $KERNEL_ROOT/lib/modules/$KVER" echo "Cmdline: $KCMDLINE" echo "Output: $IMG" echo "" # Refuse to rm -rf a work tree that still has something mounted in # it, rather than deleting the contents of whatever that is. for d in "$WORK/esp" "$WORK/mnt"; do if mountpoint -q "$d" 2>/dev/null; then die "$d is still mounted from a previous run — umount it first" fi done rm -rf "$WORK" mkdir -p "$ROOT" # ============================================================ # Step 1: Filesystem skeleton # ============================================================ echo ">>> Setting up filesystem skeleton..." sh "$BINDIR/setup-filesystem" "$ROOT" # ============================================================ # Step 2: Install core packages into live rootfs # ============================================================ # addpkg's stderr is NOT discarded here. It is where the # "reserved group X is missing from $ROOT — broken filesystem # skeleton" warning comes out, which is the one diagnostic that # catches setup-filesystem and mkpkg's reserved_groups[] having # drifted apart. Failures are collected and the build aborts at # the end of the step rather than producing an image that builds, # reports success and does not boot. echo ">>> Installing core packages into live rootfs..." failed="" done_pkgs=" " add_one() { _name="$1"; _pkg="$2" case "$done_pkgs" in *" $_name "*) return 0 ;; esac echo " + $_name" if ! addpkg -f -r "$ROOT" "$_pkg"; then failed="$failed $_name" fi done_pkgs="$done_pkgs$_name " } add_from() { _dir="$1"; _name="$2"; _required="$3" _pkg=$(ls -t "$_dir/$_name"/*.pkg.tar.gz 2>/dev/null | head -1) if [ -f "$_pkg" ]; then add_one "$_name" "$_pkg" elif [ "$_required" = required ]; then die "no built package for $_name in $_dir" fi } # Order matters for the first two. # # `for portdir in /ports/core/*/` is alphabetical, which puts dhcpcd # (10th) ahead of musl (11th) and shadow (12th). Every package with # a groups= line that lands in that window fails twice: # # addpkg: useradd dhcpcd failed (exit 127) <- no shadow yet # addpkg: install -d /var/lib/dhcpcd failed (127) <- coreutils is # installed, but /lib/ld-musl-x86_64.so.1 is not, so nothing # in the target can exec at all # # Both are exit 127 and both are silent about the real cause. musl # first so the target can run its own binaries, shadow second so # useradd/groupadd exist before any groups= is processed. echo ">>> Installing bootstrap packages first..." for boot_pkg in musl shadow; do add_from /ports/core "$boot_pkg" required done for portdir in /ports/core/*/; do [ -d "$portdir" ] || continue name=$(basename "$portdir") case "$name" in openresolv) continue ;; esac pkg=$(ls -t "$portdir"*.pkg.tar.gz 2>/dev/null | head -1) [ -f "$pkg" ] || continue add_one "$name" "$pkg" done # udevd is systemd-udev; its libsystemd-shared needs libucontext, # which lives in libs/ and is not in /ports/core. Without it the # hwdb trigger dies at addpkg time with # # Error loading shared library libucontext.so.1 # # and the image ships with no hwdb at all. Install the library # before the package whose trigger needs it. echo ">>> Installing extra packages..." # libucontext: udevd's libsystemd-shared needs it, and udevd's hwdb # trigger runs at addpkg time. # json-c: cryptsetup links it. The installer now formats LUKS volumes, # so a cryptsetup that exits on startup is a failed install, not a # missing extra. for libpkg in libucontext json-c libusb; do add_from /usr/ports/libs "$libpkg" required done # which: `command -v` covers most of it, but enough of the tooling and # enough muscle memory expects `which` that leaving it out costs more # than the binary does. # iputils: ping. Note it needs either a setuid bit or a # net.ipv4.ping_group_range that includes the caller -- as root in the # installer neither matters, but it is why ping is not free elsewhere. # xfsprogs, btrfs-progs: genluks offers xfs and btrfs, so the mkfs for # each has to be here or the option is a trap -- it parses, root is # formatted, and the run dies at "mkfs.xfs: not found" with half a disk # done. Mounting one still needs the driver in the installer kernel. # # nfs-utils: a shared /home or /data over NFS is common enough that not # being able to mount one during an install is a real gap. Same # caveat: CONFIG_NFS_FS in the installer kernel is what makes it work. # # mandoc: the installation guide is a set of manual pages (Step 4b), # read with `man installation`. less, above, is its pager. # # wireless-regdb: regulatory.db and regulatory.db.p7s for cfg80211. The # kernel is built with CFG80211_REQUIRE_SIGNED_REGDB, so without them # wifi comes up on the restrictive world regulatory domain (no 5 GHz # channels where the world domain forbids them, no country settings). for optpkg in udevd openntpd which less pciutils usbutils iputils xfsprogs btrfs-progs nfs-utils mandoc iw wireless-regdb; do add_from /usr/ports/opt "$optpkg" required done [ -z "$failed" ] || die "addpkg failed for:$failed" # ============================================================ # Step 3: Kernel modules into live rootfs # ============================================================ # The installer kernel is modular (CONFIG_MODULES=y): cfg80211 is built # in, mac80211 and iwlwifi (iwldvm, iwlmvm, iwlmld) are modules. udev # loads them on the MODALIAS uevent, which needs modules.dep and the # alias maps, so the whole tree is copied and depmod is run again # against the image: the package ran it for the build host's paths, and # running it here is what guarantees the maps match what is shipped. # # The modules are signed with the key generated for this one kernel # build, so they only load into the vmlinuz they came with -- which is # why mkinstall.sh insists on the matching lib/modules/$KVER above. echo ">>> Installing kernel modules..." mkdir -p "$ROOT/lib/modules" if [ -d "$MODULES/lib/modules" ] && [ -n "$(ls -A "$MODULES/lib/modules" 2>/dev/null)" ]; then cp -a "$MODULES"/lib/modules/* "$ROOT/lib/modules/" for moddir in "$ROOT/lib/modules"/*/; do [ -d "$moddir" ] || continue kver=$(basename "$moddir") echo " + depmod $kver" depmod -b "$ROOT" "$kver" || warn_depmod=1 done [ -z "$warn_depmod" ] || echo " ! depmod reported problems" else die "$MODULES/lib/modules is empty — the -img kernel is modular, wifi would not load" fi # ============================================================ # Step 3a: Firmware # ============================================================ # CONFIG_EXTRA_FIRMWARE is empty in the installer kernel, and firmware # compression is off (FW_LOADER_COMPRESS=n), so everything here must be # the plain uncompressed file. These devices are probed well after # root is mounted, so FW_LOADER reads them from /lib/firmware the # ordinary way, and a missing file is a card that does not come up, not # a failed kernel build. # # The list is not kept by hand. It is what this kernel asks for: # # - the firmware= lines of every .ko in lib/modules/$KVER (iwlwifi, # iwldvm, iwlmvm, iwlmld), and # - the .firmware= lines of modules.builtin.modinfo, which # covers the built-in drivers (r8169's rtl_nic files, ...). # # A kernel update therefore changes the list with no edit here. # # iwlwifi lists only the newest API of each family it knows # (iwlwifi-so-a0-gf-a0-89.ucode) and, at run time, falls back through # lower numbers until one exists. linux-firmware may be older than the # kernel and carry only -83 and below. So for every requested # -.ucode the newest file <= N is taken, which is exactly # the one the kernel would end up loading; older API revisions the # kernel would never reach first are left out. Anything that is not a # numbered ucode (.pnvm, rtl_nic/*.fw) is matched by its exact path, or # by file name when the kernel names it without a directory and # linux-firmware keeps it in a subdirectory (intel/iwlwifi/). # # regulatory.db and regulatory.db.p7s come from the wireless-regdb # package above, not from here. # # FIRMWARE_RE adds files by hand: an extended regular expression # matched against the path under lib/firmware/, alternatives separated # by |, for a device whose driver does not declare its firmware. # # Source: the linux-firmware package first, since it is versioned and # does not require the firmware tree on the build host; the host's own # /lib/firmware as a fallback. echo ">>> Installing firmware..." FIRMWARE_RE='' command -v modinfo >/dev/null 2>&1 || \ die "no modinfo on this host (kmod) — cannot derive the firmware list" KMODDIR="$KERNEL_ROOT/lib/modules/$KVER" fwpkg=$(ls -t /usr/ports/opt/linux-firmware/*.pkg.tar.gz 2>/dev/null | head -1) if [ -n "$fwpkg" ] && [ -f "$fwpkg" ]; then fwsrc=pkg # tar -tv, not -t: linux-firmware keeps compatibility links at the # old paths (lib/firmware/iwlwifi-*.ucode -> intel/iwlwifi/...), and # those old paths are the names the kernel asks for. Extracting # only the link leaves it dangling, so every link is recorded with # its resolved target (fw.links: name target) and the target # is added to the selection below. : > "$WORK/fw.links" tar -tvf "$fwpkg" 2>/dev/null | awk -v avail="$WORK/fw.avail" -v links="$WORK/fw.links" ' { t = substr($1, 1, 1) if (t == "d") next name = "" for (i = 1; i <= NF; i++) if ($i ~ /^(\.\/)?lib\/firmware\//) { name = $i; k = i; break } if (name == "") next sub(/^\.\//, "", name) print name > avail if (t == "l" && $(k + 1) == "->") tgt = $(k + 2) else if (t == "h" && $(k + 1) == "link" && $(k + 2) == "to") tgt = $(k + 3) else next sub(/^\.\//, "", tgt) if (t == "l") { if (tgt ~ /^\//) sub(/^\//, "", tgt) else { d = name; sub(/\/[^\/]*$/, "", d); tgt = d "/" tgt } } n = split(tgt, a, "/"); m = 0 for (i = 1; i <= n; i++) { if (a[i] == "" || a[i] == ".") continue if (a[i] == "..") { if (m > 0) m--; continue } o[++m] = a[i] } r = o[1] for (i = 2; i <= m; i++) r = r "/" o[i] print name "\t" r > links }' || true [ -f "$WORK/fw.avail" ] || : > "$WORK/fw.avail" elif [ -d /lib/firmware ]; then fwsrc=host echo " . no linux-firmware package, using the host's /lib/firmware" ( cd /lib/firmware && find . \( -type f -o -type l \) | sed 's#^\./#lib/firmware/#' ) \ > "$WORK/fw.avail" || true : > "$WORK/fw.links" else fwsrc=none : > "$WORK/fw.avail" : > "$WORK/fw.links" fi # What the kernel will ask for. Names with a glob character are # skipped: modinfo prints them for drivers that load a family of files, # and there is no way to know which one a given card wants. { find "$KMODDIR" -name '*.ko' -exec modinfo -F firmware {} + 2>/dev/null tr '\0' '\n' < "$KMODDIR/modules.builtin.modinfo" | \ sed -n 's/^[^=]*\.firmware=//p' } | grep -v '[*?[]' | sort -u > "$WORK/fw.want" awk -v avail="$WORK/fw.avail" ' BEGIN { while ((getline p < avail) > 0) { path[p] = 1 b = p; sub(/^.*\//, "", b) all[b] = all[b] " " p if (b ~ /-[0-9]+\.ucode$/) { s = b; sub(/-[0-9]+\.ucode$/, "", s) v = b; sub(/\.ucode$/, "", v); sub(/^.*-/, "", v) fam[s] = fam[s] " " v "|" p } } } { b = $0; sub(/^.*\//, "", b) if (b ~ /-[0-9]+\.ucode$/) { s = b; sub(/-[0-9]+\.ucode$/, "", s) v = b; sub(/\.ucode$/, "", v); sub(/^.*-/, "", v) n = split(fam[s], e, " "); best = -1; bp = "" for (i = 1; i <= n; i++) { split(e[i], kv, "|") if (kv[1] + 0 <= v + 0 && kv[1] + 0 > best) { best = kv[1] + 0; bp = kv[2] } } if (bp == "") print "X " $0 else { print "M " bp if (best != v + 0) print "O " $0 " -> " bp } next } if (("lib/firmware/" $0) in path) print "M lib/firmware/" $0 else if (index($0, "/") == 0 && (b in all)) { n = split(all[b], e, " ") for (i = 1; i <= n; i++) print "M " e[i] } else print "X " $0 }' "$WORK/fw.want" > "$WORK/fw.sel" sed -n 's/^M //p' "$WORK/fw.sel" > "$WORK/fw.members" if [ -n "$FIRMWARE_RE" ]; then grep -E "^lib/firmware/($FIRMWARE_RE)\$" "$WORK/fw.avail" \ >> "$WORK/fw.members" || true fi # A selected link is useless without its target, and a selected real # file is useless if the kernel asks for it under a link name: add the # target of every selected link (following chains) and every link that # points at a selected file. awk -F'\t' ' NR == FNR { lnk[$1] = $2; rev[$2] = rev[$2] " " $1; next } { print p = $0 for (i = 0; i < 5 && (p in lnk); i++) { p = lnk[p]; print p } n = split(rev[$0], r, " ") for (i = 1; i <= n; i++) print r[i] }' "$WORK/fw.links" "$WORK/fw.members" | sort -u > "$WORK/fw.members.all" mv "$WORK/fw.members.all" "$WORK/fw.members" fw_want=$(wc -l < "$WORK/fw.want") fw_sel=$(wc -l < "$WORK/fw.members") fw_old=$(grep -c '^O ' "$WORK/fw.sel" || true) fw_miss=$(grep -c '^X ' "$WORK/fw.sel" || true) case "$fwsrc" in pkg) if [ -s "$WORK/fw.members" ]; then tar -xf "$fwpkg" -C "$ROOT" -T "$WORK/fw.members" || \ echo " ! tar reported errors extracting firmware" echo " + from $(basename "$fwpkg")" fi ;; host) while read -r fw; do mkdir -p "$(dirname "$ROOT/$fw")" cp "/$fw" "$ROOT/$fw" chmod 644 "$ROOT/$fw" done < "$WORK/fw.members" ;; esac echo " kernel asks for $fw_want names; $fw_sel files selected (links and their targets)" if [ "$fw_old" -gt 0 ]; then echo " $fw_old requested ucode newer than linux-firmware has (older API used):" sed -n 's/^O / /p' "$WORK/fw.sel" | head -n 10 fi if [ "$fw_miss" -gt 0 ]; then echo " ! $fw_miss requested files are not in linux-firmware (those devices" echo " ! will run without them or not at all):" sed -n 's/^X / /p' "$WORK/fw.sel" | head -n 15 fi # Every selected path must exist in the image, and -e follows links, so # a link whose target did not come along is counted here. fw_bad=0 : > "$WORK/fw.bad" while read -r _m; do [ -e "$ROOT/$_m" ] && continue fw_bad=$((fw_bad + 1)) echo "$_m" >> "$WORK/fw.bad" done < "$WORK/fw.members" if [ "$fw_bad" -gt 0 ]; then echo " ! $fw_bad selected firmware paths are missing or dangling in the image:" head -n 10 "$WORK/fw.bad" | sed 's/^/ /' fi fw_n=$(find "$ROOT/lib/firmware" \( -type f -o -type l \) 2>/dev/null | wc -l) fw_iwl=$(find "$ROOT/lib/firmware" \( -type f -o -type l \) -name 'iwlwifi-*' 2>/dev/null | wc -l) echo " $fw_n files in the image, $fw_iwl iwlwifi, $(du -sm "$ROOT/lib/firmware" 2>/dev/null | cut -f1)M" if [ "$fw_iwl" -eq 0 ] || [ "$fw_bad" -gt 0 ]; then die "firmware is incomplete (see above) — Intel wifi would not come up in this image" fi # ============================================================ # Step 4: Copy installer scripts to /bin # ============================================================ echo ">>> Copying installer scripts to /bin..." for script in install setup-filesystem setup-help genfstab enter-chroot setup-boot genluks; do cp "$BINDIR/$script" "$ROOT/bin/$script" chmod 755 "$ROOT/bin/$script" done # ============================================================ # Step 4 (cont.): Installer manual pages # ============================================================ # The installation guide: installation(7) and the pages it links to, # plus one page per installer script. Each file goes to the section # directory its suffix names, installation.7 -> man7/. # # makewhatis builds the index behind `man -k` and apropos. `man # ` finds a page by its file name without it, so a failure is # reported and not fatal. It runs inside the rootfs, with the image's # own mandoc, so the index matches the man that reads it. echo ">>> Installing manual pages..." for page in "$BINDIR"/man/*.[1-9]; do [ -f "$page" ] || continue _sec=${page##*.} mkdir -p "$ROOT/usr/share/man/man$_sec" cp "$page" "$ROOT/usr/share/man/man$_sec/" chmod 644 "$ROOT/usr/share/man/man$_sec/${page##*/}" echo " + ${page##*/}" done if chroot "$ROOT" env PATH=/sbin:/bin:/usr/sbin:/usr/bin makewhatis; then echo " + makewhatis" else echo " ! makewhatis failed — man -k will not work, man will" fi # ============================================================ # Step 4a: Autologin on the installer console # ============================================================ # The installer is a single-user appliance with no root password, so # the login prompt asks for a name, accepts any answer of "root", and # protects nothing. Autologin removes the step. # # This patches the rootfs copy, not the runit package, so installed # systems are unaffected -- only the image gets it. # # The tty name is read out of each existing agetty run script rather # than assumed: the runit port names these services, and a rename here # would silently produce an image with no console at all. ttyS0 is # included when the port ships it, which is what makes `qemu -nographic` # and a serial console on the VPS usable. # The runit port ships agetty-ttyS0 as well (2.3.1-2 onwards), enabled, # with a guard that sleeps when no UART is behind the port. So nothing # is created here any more; a missing service means an older runit, # and the image would have no serial console -- worth a loud warning on # a VPS, where that is the only console. echo ">>> Checking serial getty..." if [ -f "$ROOT/etc/sv/agetty-ttyS0/run" ]; then echo " = agetty-ttyS0 shipped by runit" else echo " ! agetty-ttyS0 missing — runit older than 2.3.1-2? no serial console in this image" fi echo ">>> Enabling root autologin on the installer console..." tty_found="" for run in "$ROOT"/etc/sv/*/run; do [ -f "$run" ] || continue grep -q agetty "$run" || continue if grep -q -e --autologin -e ' -a ' "$run"; then echo " = $(basename "$(dirname "$run")") already has autologin" tty_found="$tty_found $(basename "$(dirname "$run")")" continue fi # Insert the flag into the existing command rather than writing a # new one. A rewritten run script loses whatever wrapper the runit # port put in front of agetty -- `chpst -P`, `setsid` -- and without # a new session agetty never becomes the controlling terminal, so # the shell comes up with: # # bash: cannot set terminal process group (271) # bash: no job control on this shell # # which is a working login with no ^C, no ^Z and no fg/bg. It also # loses the tty name, the baud list and the TERM the port chose. sed -i 's#agetty[[:space:]]#& --autologin root #' "$run" grep -q -- --autologin "$run" || { echo " ! $(basename "$(dirname "$run")"): could not insert flag, left alone"; continue; } echo " + $(basename "$(dirname "$run")") -> autologin root" tty_found="$tty_found $(basename "$(dirname "$run")")" done [ -n "$tty_found" ] || echo " ! no agetty services found in $ROOT/etc/sv — image will have no console" # Enable the serial getty. Without a link in the runsvdir the service # exists but never runs, so a serial console shows the kernel and stage # 1 and then goes silent at the point you actually want a shell -- on a # VPS or under `qemu -nographic` that is the whole console. # # Linking rather than creating: the run script is the runit port's, with # whatever wrapper and baud list it chose. # Which directory runsvdir actually watches is the runit port's choice, # and guessing wrong fails silently: the link is created, nothing # supervises it, and the console is simply absent at boot. So follow # the tty1 service -- whatever directory already holds a working getty # link is by definition the live one. # # Found the hard way: this linked into /etc/runit/runsvdir/default while # the live directory was /service, and the symptom was a boot that # reached stage 2 and then went quiet. SVDIR="" for d in "$ROOT/service" "$ROOT/etc/runit/runsvdir/default" "$ROOT/var/service"; do [ -d "$d" ] || continue for l in "$d"/*tty1*; do if [ -L "$l" ] || [ -d "$l" ]; then SVDIR="$d"; break; fi done [ -n "$SVDIR" ] && break done if [ -z "$SVDIR" ]; then echo " ! no runsvdir holding a tty getty — serial getty not enabled" else echo " . service dir: ${SVDIR#$ROOT}" for svc in "$ROOT"/etc/sv/*ttyS0*; do [ -d "$svc" ] || continue _n=$(basename "$svc") # -L not -e: the link points at /etc/sv/... which is only valid # once the image is booted, so -e is false on a link that exists. if [ -L "$SVDIR/$_n" ] || [ -e "$SVDIR/$_n" ]; then echo " = $_n already enabled" else ln -s "/etc/sv/$_n" "$SVDIR/$_n" echo " + $_n enabled on serial" fi done fi # login refuses root on a tty that is not listed here, even with # --autologin: agetty execs `login -f root`, which still consults # securetty. The runit port ships tty1..tty6 services, so the skeleton # lists those and nothing else -- a serial getty comes up, respawns and # never gives a prompt. if [ -f "$ROOT/etc/securetty" ]; then for t in ttyS0 ttyS1; do if grep -qx "$t" "$ROOT/etc/securetty"; then echo " = $t already in securetty" else echo "$t" >> "$ROOT/etc/securetty" echo " + $t added to securetty" fi done else echo " ! no $ROOT/etc/securetty — root may be refused on serial" fi # ============================================================ # Step 4a: Nothing networked starts by itself # ============================================================ # The ports ship their own runsvdir links, and an installer that brings # up dhcpcd, wpa_supplicant and ntpd on its own is worse than one that # does not: # # - dhcpcd rewrites /etc/resolv.conf from the lease. openresolv is # not in the image, so its own hook runs, and a lease carrying no # domain_name_servers leaves the file empty -- then the installer # dies on rsync with "check your network connection" while the link # is up and the route is fine. # - wpa_supplicant with no configuration sits scanning, and dhcpcd # behind it loops carrier lost / carrier acquired forever, which # reads like a driver fault and is not one. # - ntpd steps the clock underneath a build that is already running. # # Started by hand, each one tells you what it is doing. setup-help # carries the commands. Removing the link and not the service keeps # `sv up ` available for anyone who wants it supervised. echo ">>> Removing auto-start links (network and clock are manual)..." _pruned=0 for _d in "$ROOT/service" "$ROOT/etc/runit/runsvdir/default" "$ROOT/var/service"; do [ -d "$_d" ] || continue for _pat in ntpd openntpd dhcpcd wpa_supplicant; do # $_pat and $_pat-* : the ports name per-interface instances # dhcpcd-eth0 / wpa_supplicant-wlan0 as well as the bare form. for _l in "$_d/$_pat" "$_d/$_pat"-*; do [ -L "$_l" ] || [ -d "$_l" ] || continue # rm on a symlink removes the link, never its target, so # /etc/sv/ survives for `sv up`. rm -rf "$_l" echo " - ${_l#$ROOT}" _pruned=$((_pruned + 1)) done done done [ "$_pruned" -gt 0 ] || echo " . none were linked" # ============================================================ # Step 4b: Trust store # ============================================================ # Public keys only, and only the top level: a per-collection key at # keys//.pub is trusted for that collection alone, # which is a scoping decision the image has no business making for the # installed system. echo ">>> Installing trust store..." mkdir -p "$ROOT/etc/ports/keys" for pub in "$BINDIR"/*.pub; do [ -f "$pub" ] || continue cp "$pub" "$ROOT/etc/ports/keys/" chmod 644 "$ROOT/etc/ports/keys/$(basename "$pub")" echo " + $(basename "$pub")" done # ============================================================ # Step 5: MOTD # ============================================================ cat > "$ROOT/etc/motd" << 'EOF' _ _ __ __ ___ ___ __ (_)_ __ | '__/ _` \ \ /\ / / '_ \| \ \/ / | | | (_| |\ V V /| | | | |> < |_| \__,_| \_/\_/ |_| |_|_/_/\_\ Welcome to rawnix Netinstaller https://rawnix.org Type man rawnix to learn what rawnix is. Type man installation for the installation guide. Type setup-help for a one-screen summary. Type cat /etc/motd to see this message again. EOF # ============================================================ # Step 6: Verify the rootfs can actually boot # ============================================================ # The input checks above are thorough; without this the output is # not checked at all. A missing /sbin/init costs a full build, a # dd to USB and a reboot to discover. echo ">>> Verifying rootfs..." [ -x "$ROOT/sbin/init" ] || [ -L "$ROOT/sbin/init" ] || \ die "no /sbin/init in rootfs — is runit installed?" [ -x "$ROOT/bin/sh" ] || [ -L "$ROOT/bin/sh" ] || \ die "no /bin/sh in rootfs" [ -x "$ROOT/bin/bash" ] || die "no /bin/bash in rootfs (enter-chroot needs it)" [ -x "$ROOT/bin/addpkg" ] || [ -x "$ROOT/usr/bin/addpkg" ] || \ die "no addpkg in rootfs — the installer cannot run" [ -s "$ROOT/etc/passwd" ] || die "empty /etc/passwd in rootfs" [ -n "$(find "$ROOT/etc/ports/keys" -name '*.pub' -print -quit)" ] || \ die "no public key in rootfs trust store" [ -z "$(find "$ROOT/etc/ports/keys" -name '*.sec' -print -quit)" ] || \ die "a .sec key reached the rootfs — do not ship this image" # An installer that cannot run cryptsetup discovers it at luksFormat # time, after the user has already destroyed the partition table. [ -x "$ROOT/sbin/cryptsetup" ] || [ -x "$ROOT/bin/cryptsetup" ] || \ die "no cryptsetup in rootfs — encrypted installs will not work" # The motd points at `man installation`; an image without man, or # without the page, greets the user with a command that fails. [ -x "$ROOT/usr/bin/man" ] || [ -x "$ROOT/bin/man" ] || \ die "no man in rootfs — is mandoc installed?" [ -f "$ROOT/usr/share/man/man7/installation.7" ] || \ die "no installation(7) in rootfs" [ -f "$ROOT/lib/modules/$KVER/modules.dep" ] || \ die "no modules.dep for $KVER in rootfs — udev could not load iwlwifi" [ -f "$ROOT/lib/modules/$KVER/modules.alias.bin" ] || \ die "no modules.alias.bin for $KVER in rootfs — depmod did not run" # Not fatal: wifi still works on the world regulatory domain, but it is # worth knowing before the image is on a stick. for _rd in regulatory.db regulatory.db.p7s; do if [ -f "$ROOT/lib/firmware/$_rd" ] || [ -f "$ROOT/usr/lib/firmware/$_rd" ]; then continue fi echo " ! $_rd not in rootfs — is wireless-regdb installing it elsewhere?" done echo " ok" # ============================================================ # Step 7: Build disk image # ============================================================ # Sizing. `du -sm` gives the payload; the filesystem takes its cut # before any of it lands. On the previous fixed "+32" margin a # 114M rootfs got a 144M partition, of which ext4 spent 4M on the # journal, 9M on inode tables and 7.2M on the 5% root reserve -- # leaving 8M of real headroom, not 30M. # # So: scale the margin with the payload, and pass -m 0 to mkfs. # Reserved blocks exist to keep a root shell usable on a full # system disk; on a read-mostly installer image they are dead space. echo ">>> Measuring rootfs..." ROOTFS_SIZE=$(du -sm "$ROOT" | cut -f1) ROOT_PART=$(( ROOTFS_SIZE * 5 / 4 + 32 )) IMG_SIZE=$(( BIOS_SIZE + ESP_SIZE + ROOT_PART + GPT_SLACK )) echo " rootfs: ${ROOTFS_SIZE}M" echo " root part: ${ROOT_PART}M" echo " image: ${IMG_SIZE}M" echo ">>> Creating disk image..." dd if=/dev/zero of="$IMG" bs=1M count="$IMG_SIZE" status=progress # type=uefi and type=linux are sfdisk's aliases for the two GPT type # GUIDs. They were spelled out in full here and the ESP one was wrong # in three places at once -- close enough to read as correct, and the # result was an image that booted from an NVRAM entry naming the # partition directly but was invisible to any firmware doing a # spec-compliant removable-media scan for a partition of type ESP. # A 36-character constant that is wrong by one character still looks # plausible; an alias cannot be. # sfdisk has no alias for the BIOS boot type, so it is the GUID here, # and asserted below like the ESP. sfdisk "$IMG" << SFDISKEOF label: gpt size=${BIOS_SIZE}M, type=21686148-6449-6E6F-744E-656564454649 size=${ESP_SIZE}M, type=uefi type=linux, uuid=$PARTUUID SFDISKEOF # Assert it rather than trust it: this is the failure that costs an # hour because everything else succeeds. _esptype=$(sfdisk --part-type "$IMG" "$ESP_NR") [ "$_esptype" = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" ] || \ die "partition $ESP_NR is type $_esptype, not an EFI System Partition" _biostype=$(sfdisk --part-type "$IMG" 1) [ "$_biostype" = "21686148-6449-6E6F-744E-656564454649" ] || \ die "partition 1 is type $_biostype, not a BIOS boot partition" LOOP=$(losetup --show -fP "$IMG") echo " loop: $LOOP" # Partition nodes are created by udev, not by losetup, so they can # lag behind the ioctl by enough to lose a race on a loaded box. i=0 while [ ! -b "${LOOP}p${ESP_NR}" ] || [ ! -b "${LOOP}p${ROOT_NR}" ]; do i=$((i + 1)) [ "$i" -gt 10 ] && die "partition nodes for $LOOP never appeared" sleep 1 done mkfs.fat -F 32 -n BOOT "${LOOP}p${ESP_NR}" mkdir -p "$WORK/esp" mount "${LOOP}p${ESP_NR}" "$WORK/esp" mkdir -p "$WORK/esp/EFI/BOOT" cp "$KERNEL" "$WORK/esp/EFI/BOOT/BOOTX64.EFI" # BIOS: Limine reads only FAT, and looks for limine-bios.sys and # limine.conf in the root of a partition it can read -- the ESP. The # entry boots the same file UEFI does: an EFI stub kernel is a plain # bzImage with a PE header, and Limine's linux protocol reads the # bzImage header, so the kernel is stored once. The command line is # the kernel's own CONFIG_CMDLINE, read from the package's config above: # the compiled-in line is placed in front of whatever the bootloader # passes, so repeating it here changes nothing -- and cannot disagree # with it about ro/rw or the order of the console= arguments. cp /usr/share/limine/limine-bios.sys "$WORK/esp/limine-bios.sys" cat > "$WORK/esp/limine.conf" << LIMINECONF # Generated by mkinstall.sh. Read only under BIOS: UEFI boots # EFI/BOOT/BOOTX64.EFI directly and never opens this file. timeout: 0 /rawnix installer protocol: linux path: boot():/EFI/BOOT/BOOTX64.EFI cmdline: $KCMDLINE LIMINECONF umount "$WORK/esp" mkfs.ext4 -m 0 -L rawnix-inst "${LOOP}p${ROOT_NR}" mkdir -p "$WORK/mnt" mount "${LOOP}p${ROOT_NR}" "$WORK/mnt" echo ">>> Copying rootfs to image..." # "$ROOT"/. rather than "$ROOT"/* so top-level dotfiles are not # silently dropped. The skeleton creates none today; that is not a # property worth depending on. cp -a "$ROOT"/. "$WORK/mnt"/ echo ">>> Copying ports.rsync..." mkdir -p "$WORK/mnt/etc/ports" # cp + chmod, not `install -m600`: $BINDIR/install is the netinstaller # and is copied to /bin/install in the image, so a bare `install` is # ambiguous at best on the build host and wrong inside the image. # 600 because it carries the rsync token and the image is handed out. cp "$BINDIR/ports.rsync" "$WORK/mnt/etc/ports/ports.rsync" chmod 600 "$WORK/mnt/etc/ports/ports.rsync" echo ">>> Filesystem usage:" df -h "$WORK/mnt" | tail -1 | awk '{printf " %s used of %s (%s), %s free\n", $3, $2, $5, $4}' sync umount "$WORK/mnt" losetup -d "$LOOP" LOOP="" # Stage 1 into the MBR boot code, stage 2 into partition 1. After the # loop device is detached, so nothing holds a stale view of the image # while its first sector is rewritten. echo ">>> Installing BIOS boot code..." limine bios-install "$IMG" 1 echo "" echo "=== Done ===" echo "Image: $IMG ($(du -h "$IMG" | cut -f1))" echo "" echo "Write to USB:" echo " dd if=$IMG of=/dev/sdX bs=4M status=progress" echo "" echo "Boots on UEFI and on legacy BIOS. A target installed under BIOS" echo "needs its own boot code afterwards; see bootloader(7), Legacy BIOS."