#!/usr/bin/env bash set -euo pipefail # ============================================================================== # Configuration # ============================================================================== VERSION="3.24.1" BRANCH="3.24" CONFIG_FILE="NenjimHub.conf" IMAGE="alpine-rpi-${VERSION}-aarch64.img" HEADLESS_OVERLAY="headless.apkovl.tar.gz" WPA_CONFIG="wpa_supplicant.conf" UNATTENDED_SCRIPT="unattended.sh" ZEROTIER_INSTALLER="installZeroTier.sh" BUILD_TIME_FILE="nenjim-build-time" BUILDER_IMAGE="alpine:${BRANCH}" KERNEL_MKSQUASHFS_MEMORY="128M" BOOT_MOUNT="/mnt/usb" TEMP_DIRECTORY="" SOURCE_MOUNT="" LOOP_DEVICE="" ZEROTIER_PREBUILT_AVAILABLE=0 # ============================================================================== # Helpers # ============================================================================== die() { echo "ERROR: $*" >&2 exit 1 } require_uint() { local name="$1" local value="${!name-}" [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be a non-negative integer." } require_swap_priority() { local name="$1" local value="${!name-}" require_uint "$name" (( value <= 32767 )) || die "$name must be between 0 and 32767." } partition_path() { local number="$1" if [[ "$DEVICE" =~ [0-9]$ ]]; then printf '%sp%s\n' "$DEVICE" "$number" else printf '%s%s\n' "$DEVICE" "$number" fi } wait_for_block_device() { local path="$1" local attempt for attempt in {1..50}; do [[ -b "$path" ]] && return 0 sleep 0.2 done return 1 } cleanup() { set +e if [[ -n "$SOURCE_MOUNT" ]] && mountpoint -q "$SOURCE_MOUNT" 2>/dev/null; then umount "$SOURCE_MOUNT" fi if mountpoint -q "$BOOT_MOUNT" 2>/dev/null; then umount "$BOOT_MOUNT" fi if [[ -n "$LOOP_DEVICE" ]]; then losetup -d "$LOOP_DEVICE" 2>/dev/null || true fi if [[ -n "$TEMP_DIRECTORY" && -d "$TEMP_DIRECTORY" ]]; then case "$TEMP_DIRECTORY" in /tmp/nenjim-flash.*) rm -rf -- "$TEMP_DIRECTORY" ;; *) echo "WARNING: Refusing to remove unexpected path: $TEMP_DIRECTORY" >&2 ;; esac fi } preserve_sysop_home() { local overlay overlay_count extract_root local -a final_overlays=() mapfile -t final_overlays < <( find "$BOOT_MOUNT" \ -maxdepth 1 \ -type f \ -name '*.apkovl.tar.gz' \ ! -name 'headless.apkovl.tar.gz' \ -print ) overlay_count="${#final_overlays[@]}" if (( overlay_count == 0 )); then echo "No final APKoVL found; this appears to be a fresh installation." return 0 fi if (( overlay_count > 1 )); then printf 'Found more than one final APKoVL:\n' >&2 printf ' %s\n' "${final_overlays[@]}" >&2 die "Keep only the active final APKoVL before updating partition 1." fi overlay="${final_overlays[0]}" extract_root="$TEMP_DIRECTORY/old-overlay" mkdir -p "$extract_root" echo "Preserving /sysop from $(basename "$overlay")" tar \ --extract \ --gzip \ --file "$overlay" \ --directory "$extract_root" \ --no-same-owner if [[ ! -d "$extract_root/sysop" ]]; then echo "The previous APKoVL does not contain /sysop; nothing to preserve." return 0 fi tar \ --create \ --gzip \ --file "$TEMP_DIRECTORY/sysop-preserve.tar.gz" \ --directory "$extract_root" \ sysop } run_kernel_builder() { local runtime="$1" local kernel_work="$TEMP_DIRECTORY/kernel-work" mkdir -p "$kernel_work" "$runtime" run \ --rm \ --volume "$BOOT_MOUNT:/media/boot" \ --volume "$kernel_work:/work" \ --env KERNEL_MKSQUASHFS_MEMORY="$KERNEL_MKSQUASHFS_MEMORY" \ "$BUILDER_IMAGE" \ /bin/sh -euxc ' apk add --no-cache alpine-conf alpine-keys squashfs-tools xz # update-kernel creates a temporary aarch64 APK root. The # architecture-specific keys are required when the builder # container itself is x86_64. The minimal Docker image can have # the shared key files removed even though alpine-keys is marked # as installed, so restore all package-owned key files first. apk fix --no-cache alpine-keys APK_KEYS_DIR=/usr/share/apk/keys find "$APK_KEYS_DIR" -maxdepth 1 -type f -name "*.rsa.pub" -print test -n "$(find "$APK_KEYS_DIR" -maxdepth 1 -type f -name "*.rsa.pub" -print -quit)" apk \ --arch aarch64 \ --keys-dir "$APK_KEYS_DIR" \ update MKSQUASHFS_OPTS="-processors 1 -mem $KERNEL_MKSQUASHFS_MEMORY" \ TMPDIR=/work \ update-kernel \ -a aarch64 \ -f rpi \ -p zfs-rpi \ --keys-dir "$APK_KEYS_DIR" \ --repositories-file /etc/apk/repositories \ -M /media/boot test -s /media/boot/boot/vmlinuz-rpi test -s /media/boot/boot/initramfs-rpi test -s /media/boot/boot/modloop-rpi unsquashfs -ll /media/boot/boot/modloop-rpi | grep -E "/zfs[.]ko([.](gz|xz|zst))?$" ' } # ============================================================================== # Initialization and validation # ============================================================================== cd -- "$(dirname -- "${BASH_SOURCE[0]}")" [[ -f "$CONFIG_FILE" ]] || die "Missing shared configuration: $CONFIG_FILE" # shellcheck source=NenjimHub.conf source "./$CONFIG_FILE" [[ $EUID -eq 0 ]] || die "This script must be run as root." for name in \ BOOT_PARTITION_SIZE_MIB VIRT_ZVOL_SIZE_MIB \ SWAP_SIZE_MIB_PART SWAP_SIZE_MIB_ZFS SWAP_SIZE_PCT_ZRAM \ DISKLESS_ROOT_SIZE_MIB ZFS_ARC_MAX_MIB ZEROTIER_REQUIRED_SWAP_MIB \ ZEROTIER_MAKE_JOBS ZEROTIER_ENABLE_SSO do require_uint "$name" done for name in SWAP_PRI_ZRAM SWAP_PRI_ZFS SWAP_PRI_PART; do require_swap_priority "$name" done (( BOOT_PARTITION_SIZE_MIB > 0 )) || die "BOOT_PARTITION_SIZE_MIB must be greater than zero." (( VIRT_ZVOL_SIZE_MIB > 0 )) || die "VIRT_ZVOL_SIZE_MIB must be greater than zero." (( DISKLESS_ROOT_SIZE_MIB > 0 )) || die "DISKLESS_ROOT_SIZE_MIB must be greater than zero." (( ZFS_ARC_MAX_MIB > 0 )) || die "ZFS_ARC_MAX_MIB must be greater than zero." (( ZEROTIER_REQUIRED_SWAP_MIB > 0 )) || die "ZEROTIER_REQUIRED_SWAP_MIB must be greater than zero." (( ZEROTIER_MAKE_JOBS > 0 )) || die "ZEROTIER_MAKE_JOBS must be greater than zero." (( ZEROTIER_ENABLE_SSO == 0 || ZEROTIER_ENABLE_SSO == 1 )) || die "ZEROTIER_ENABLE_SSO must be either 0 or 1." (( SWAP_SIZE_PCT_ZRAM <= 100 )) || die "SWAP_SIZE_PCT_ZRAM must be between 0 and 100." [[ "$NENJIMHUB_HOSTNAME" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || die "NENJIMHUB_HOSTNAME must be one 1-63 character DNS hostname label." [[ "$CONSOLE_KEYMAP_LAYOUT" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || die "Invalid CONSOLE_KEYMAP_LAYOUT: $CONSOLE_KEYMAP_LAYOUT" [[ "$CONSOLE_KEYMAP_VARIANT" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || die "Invalid CONSOLE_KEYMAP_VARIANT: $CONSOLE_KEYMAP_VARIANT" [[ "$ZEROTIER_VERSION" =~ ^[0-9]+([.][0-9]+){2}([._-][A-Za-z0-9]+)*$ ]] || die "Invalid ZEROTIER_VERSION: $ZEROTIER_VERSION" [[ "$ZEROTIER_PREBUILT_BINARY" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "ZEROTIER_PREBUILT_BINARY must be a plain filename without directories." DISKLESS_ROOT_SIZE="${DISKLESS_ROOT_SIZE_MIB}M" for command in \ blkid blockdev cp date find install losetup lsblk mkfs.vfat mount mountpoint \ sed sync tar udevadm umount do command -v "$command" >/dev/null 2>&1 || die "Missing required command: $command" done for file in \ "$CONFIG_FILE" \ "$IMAGE" \ "$HEADLESS_OVERLAY" \ "$WPA_CONFIG" \ "$UNATTENDED_SCRIPT" \ "$ZEROTIER_INSTALLER" do [[ -f "$file" ]] || die "Missing input file: $file" done if [[ -f "$ZEROTIER_PREBUILT_BINARY" ]]; then [[ -s "$ZEROTIER_PREBUILT_BINARY" ]] || die "Optional ZeroTier binary is empty: $ZEROTIER_PREBUILT_BINARY" ZEROTIER_PREBUILT_AVAILABLE=1 echo "Using optional precompiled ZeroTier binary: $ZEROTIER_PREBUILT_BINARY" elif [[ -e "$ZEROTIER_PREBUILT_BINARY" ]]; then die "Optional ZeroTier binary is not a regular file: $ZEROTIER_PREBUILT_BINARY" else echo "No precompiled ZeroTier binary found; the Pi will compile it on first boot." fi if command -v podman >/dev/null 2>&1; then CONTAINER_RUNTIME="podman" elif command -v docker >/dev/null 2>&1; then CONTAINER_RUNTIME="docker" else die "Podman or Docker is required to build the matched ARM64 kernel/modloop." fi [[ -b "$DEVICE" ]] || die "Not a block device: $DEVICE" [[ "$(lsblk -dnro PTTYPE "$DEVICE" 2>/dev/null || true)" == "dos" ]] || die "The card does not use the Raspberry Pi-compatible MBR layout. Run the updated prepare-storage.sh first." if mountpoint -q "$BOOT_MOUNT" 2>/dev/null; then die "$BOOT_MOUNT is already in use. Unmount it before continuing." fi if lsblk -nrpo MOUNTPOINT "$DEVICE" | grep -q '[^[:space:]]'; then die "A partition on $DEVICE is mounted. Export rpool and unmount the card first." fi BOOT_PARTITION="$(partition_path 1)" SWAP_PARTITION="$(partition_path 2)" ZFS_PARTITION="$(partition_path 3)" [[ -b "$BOOT_PARTITION" ]] || die "Missing partition 1: run prepare-storage.sh first." [[ -b "$ZFS_PARTITION" ]] || die "Missing ZFS partition 3: run the updated prepare-storage.sh first." EXPECTED_BOOT_PARTITION_BYTES=$((BOOT_PARTITION_SIZE_MIB * 1024 * 1024)) ACTUAL_BOOT_PARTITION_BYTES="$(blockdev --getsize64 "$BOOT_PARTITION")" [[ "$ACTUAL_BOOT_PARTITION_BYTES" == "$EXPECTED_BOOT_PARTITION_BYTES" ]] || die "$BOOT_PARTITION is $ACTUAL_BOOT_PARTITION_BYTES bytes, but $CONFIG_FILE specifies $EXPECTED_BOOT_PARTITION_BYTES bytes." [[ "$(blkid -s TYPE -o value "$BOOT_PARTITION" 2>/dev/null || true)" == "vfat" ]] || die "$BOOT_PARTITION is not the expected FAT32 Alpine partition." if (( SWAP_SIZE_MIB_PART > 0 )); then [[ -b "$SWAP_PARTITION" ]] || die "Missing configured raw swap partition 2: run prepare-storage.sh first." EXPECTED_SWAP_PARTITION_BYTES=$((SWAP_SIZE_MIB_PART * 1024 * 1024)) ACTUAL_SWAP_PARTITION_BYTES="$(blockdev --getsize64 "$SWAP_PARTITION")" [[ "$ACTUAL_SWAP_PARTITION_BYTES" == "$EXPECTED_SWAP_PARTITION_BYTES" ]] || die "$SWAP_PARTITION is $ACTUAL_SWAP_PARTITION_BYTES bytes, but $CONFIG_FILE specifies $EXPECTED_SWAP_PARTITION_BYTES bytes." [[ "$(blkid -s TYPE -o value "$SWAP_PARTITION" 2>/dev/null || true)" == "swap" ]] || die "$SWAP_PARTITION is not a Linux swap area." [[ "$(blkid -s LABEL -o value "$SWAP_PARTITION" 2>/dev/null || true)" == "$SWAP_PART_LABEL" ]] || die "$SWAP_PARTITION does not have the expected label $SWAP_PART_LABEL." elif [[ -b "$SWAP_PARTITION" ]]; then die "Partition 2 exists although SWAP_SIZE_MIB_PART=0; rerun prepare-storage.sh with the selected layout." fi [[ "$(blkid -s TYPE -o value "$ZFS_PARTITION" 2>/dev/null || true)" == "zfs_member" ]] || die "$ZFS_PARTITION is not an OpenZFS pool member." TEMP_DIRECTORY="$(mktemp -d /tmp/nenjim-flash.XXXXXX)" SOURCE_MOUNT="$TEMP_DIRECTORY/image-boot" mkdir -p "$SOURCE_MOUNT" "$BOOT_MOUNT" trap cleanup EXIT # ============================================================================== # Confirmation: only partition 1 is replaced # ============================================================================== echo echo "Partition 1 (${BOOT_PARTITION}) will be replaced with Alpine ${VERSION}." if (( SWAP_SIZE_MIB_PART > 0 )); then echo "Partition 2 (${SWAP_PARTITION}) is raw swap and will NOT be changed." else echo "Partition 2 is unused." fi echo "Partition 3 (${ZFS_PARTITION}) and $POOL_NAME will NOT be changed." echo lsblk -f "$DEVICE" echo read -r -p "Type NENJIMHUB to update partition 1: " answer [[ "$answer" == "NENJIMHUB" ]] || die "Aborted." # ============================================================================== # Attach the Alpine image read-only # ============================================================================== echo echo "=== Reading the Alpine image ===" LOOP_DEVICE="$(losetup --find --show --partscan --read-only "$IMAGE")" SOURCE_PARTITION="${LOOP_DEVICE}p1" wait_for_block_device "$SOURCE_PARTITION" || die "The image partition did not appear: $SOURCE_PARTITION" mount -o ro "$SOURCE_PARTITION" "$SOURCE_MOUNT" [[ -f "$SOURCE_MOUNT/cmdline.txt" ]] || die "The image does not contain a Raspberry Pi cmdline.txt." # ============================================================================== # Preserve the rescue home from the previous Alpine overlay # ============================================================================== echo echo "=== Preserving the sysop rescue home, if present ===" mount "$BOOT_PARTITION" "$BOOT_MOUNT" preserve_sysop_home ACTUAL_BOOT_LABEL="$(blkid -s LABEL -o value "$BOOT_PARTITION" 2>/dev/null || true)" BOOT_UUID="$(blkid -s UUID -o value "$BOOT_PARTITION" 2>/dev/null || true)" [[ "$ACTUAL_BOOT_LABEL" == "$BOOT_LABEL" ]] || die "$BOOT_PARTITION has label '${ACTUAL_BOOT_LABEL:-none}', expected '$BOOT_LABEL'." umount "$BOOT_MOUNT" # ============================================================================== # Replace only the FAT32 Alpine partition # ============================================================================== echo echo "=== Replacing Alpine partition 1 ===" BOOT_VOLUME_ID="$(printf '%s' "$BOOT_UUID" | tr -d '-')" if [[ "$BOOT_VOLUME_ID" =~ ^[[:xdigit:]]{8}$ ]]; then mkfs.vfat -F 32 -n "$BOOT_LABEL" -i "$BOOT_VOLUME_ID" "$BOOT_PARTITION" else mkfs.vfat -F 32 -n "$BOOT_LABEL" "$BOOT_PARTITION" fi mount "$BOOT_PARTITION" "$BOOT_MOUNT" cp -a "$SOURCE_MOUNT/." "$BOOT_MOUNT/" CMDLINE_FILE="$BOOT_MOUNT/cmdline.txt" CMDLINE_WITHOUT_ROOTFLAGS="$( sed 's/rootflags=[^[:space:]]*[[:space:]]*//g' "$CMDLINE_FILE" )" printf '%s rootflags=size=%s\n' \ "$CMDLINE_WITHOUT_ROOTFLAGS" \ "$DISKLESS_ROOT_SIZE" \ >"$CMDLINE_FILE" # ============================================================================== # Build a matched Raspberry Pi kernel/modloop with zfs-rpi on the PC # ============================================================================== echo echo "=== Adding Alpine's matched Raspberry Pi OpenZFS module ===" if ! run_kernel_builder "$CONTAINER_RUNTIME"; then die "Kernel/modloop build failed. The SD card is not ready to boot." fi # ============================================================================== # Install first-boot inputs # ============================================================================== echo echo "=== Installing unattended first-boot files ===" install -m 0644 \ "$HEADLESS_OVERLAY" \ "$BOOT_MOUNT/headless.apkovl.tar.gz" install -m 0600 \ "$WPA_CONFIG" \ "$BOOT_MOUNT/wpa_supplicant.conf" install -m 0755 \ "$UNATTENDED_SCRIPT" \ "$BOOT_MOUNT/unattended.sh" install -m 0755 \ "$ZEROTIER_INSTALLER" \ "$BOOT_MOUNT/installZeroTier.sh" install -m 0644 \ "$CONFIG_FILE" \ "$BOOT_MOUNT/NenjimHub.conf" if (( ZEROTIER_PREBUILT_AVAILABLE == 1 )); then install -m 0755 \ "$ZEROTIER_PREBUILT_BINARY" \ "$BOOT_MOUNT/$ZEROTIER_PREBUILT_BINARY" fi # Raspberry Pi Zero 2 W has no battery-backed RTC. Give first boot a recent, # trusted starting point before it contacts HTTPS package repositories. date -u '+%Y-%m-%d %H:%M:%S' >"$BOOT_MOUNT/$BUILD_TIME_FILE" chmod 0644 "$BOOT_MOUNT/$BUILD_TIME_FILE" if [[ -f "$TEMP_DIRECTORY/sysop-preserve.tar.gz" ]]; then install -m 0600 \ "$TEMP_DIRECTORY/sysop-preserve.tar.gz" \ "$BOOT_MOUNT/sysop-preserve.tar.gz" fi # ============================================================================== # Verification and finish # ============================================================================== echo echo "=== Verifying and syncing partition 1 ===" for boot_file in \ bootcode.bin \ config.txt \ fixup.dat \ start.elf \ boot/vmlinuz-rpi \ boot/initramfs-rpi \ boot/modloop-rpi \ headless.apkovl.tar.gz \ unattended.sh \ installZeroTier.sh \ NenjimHub.conf \ nenjim-build-time \ wpa_supplicant.conf do [[ -s "$BOOT_MOUNT/$boot_file" ]] || die "Missing prepared boot file: $boot_file" done if (( ZEROTIER_PREBUILT_AVAILABLE == 1 )); then [[ -s "$BOOT_MOUNT/$ZEROTIER_PREBUILT_BINARY" ]] || die "Missing copied ZeroTier binary: $ZEROTIER_PREBUILT_BINARY" fi sync umount "$SOURCE_MOUNT" umount "$BOOT_MOUNT" losetup -d "$LOOP_DEVICE" LOOP_DEVICE="" cleanup trap - EXIT echo echo "============================================================================" echo "Alpine partition 1 is ready. $POOL_NAME on partition 3 was left untouched." if (( ZEROTIER_PREBUILT_AVAILABLE == 1 )); then echo "ZeroTier $ZEROTIER_VERSION will use the supplied precompiled binary." else echo "ZeroTier $ZEROTIER_VERSION will be compiled by the Pi." fi echo echo "First boot performs the unattended setup and reboots." echo "After the second boot, use the sysop rescue/admin account:" echo " user: sysop" echo " password: sysop" echo "============================================================================"