diff --git a/flash.sh b/flash.sh new file mode 100755 index 0000000..4d5e600 --- /dev/null +++ b/flash.sh @@ -0,0 +1,561 @@ +#!/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 "============================================================================" diff --git a/installZeroTier.sh b/installZeroTier.sh new file mode 100755 index 0000000..7ef7ad7 --- /dev/null +++ b/installZeroTier.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash + +set -euo pipefail + + +# ============================================================================== +# Configuration (may be overridden by unattended.sh) +# ============================================================================== + +ZEROTIER_VERSION="${ZEROTIER_VERSION:-1.16.2}" +SOFTWARE_ROOT="${SOFTWARE_ROOT:-/usr/local/software}" +SYSOP_DATA_ROOT="${SYSOP_DATA_ROOT:-/media/sysop}" +REQUIRED_SWAP_MIB="${REQUIRED_SWAP_MIB:-2048}" +ROOTFS_BUILD_SIZE="${ROOTFS_BUILD_SIZE:-1400M}" +MAKE_JOBS="${MAKE_JOBS:-1}" +ENABLE_SSO="${ENABLE_SSO:-0}" +PREBUILT_BINARY="${PREBUILT_BINARY:-}" + +ZEROTIER_ROOT="$SOFTWARE_ROOT/zerotier" +ZEROTIER_VERSIONS="$ZEROTIER_ROOT/versions" +ZEROTIER_BUILD_PROFILE="${ZEROTIER_VERSION}-linux-musl-aarch64-sso${ENABLE_SSO}" +ZEROTIER_INSTALL="$ZEROTIER_VERSIONS/$ZEROTIER_BUILD_PROFILE" +ZEROTIER_STATE="$SYSOP_DATA_ROOT/zerotier" + +BUILD_DIRECTORY="$SOFTWARE_ROOT/.zerotier-build-$ZEROTIER_BUILD_PROFILE" +SOURCE_DIRECTORY="$BUILD_DIRECTORY/ZeroTierOne" + +BUILD_DEPS=( + build-base + linux-headers + openssl-dev + pkgconf +) + +if [[ "$ENABLE_SSO" == "1" ]]; then + BUILD_DEPS+=(cargo) +fi + + +# ============================================================================== +# Helpers and cleanup +# ============================================================================== + +BUILD_DEPS_INSTALLED=0 +BUILD_REQUIRED=1 +PREBUILT_AVAILABLE=0 +STATE_WAS_PRESENT=0 + +die() +{ + echo "ERROR: $*" >&2 + exit 1 +} + +safe_remove_build_directory() +{ + case "$BUILD_DIRECTORY" in + "$SOFTWARE_ROOT"/.zerotier-build-*) + rm -rf -- "$BUILD_DIRECTORY" + ;; + *) + echo "WARNING: Refusing to remove unexpected path: $BUILD_DIRECTORY" >&2 + ;; + esac +} + +release_build_resources() +{ + local result=0 + + if (( BUILD_DEPS_INSTALLED == 1 )); then + echo "=== Removing ZeroTier build dependencies ===" + + if apk del .zerotier-builddeps; then + BUILD_DEPS_INSTALLED=0 + else + result=1 + fi + fi + + safe_remove_build_directory + return "$result" +} + +cleanup_on_exit() +{ + local status=$? + + trap - EXIT + set +e + release_build_resources + exit "$status" +} + +migrate_directory_to_state() +{ + local source="$1" + + if [[ -L "$source" ]]; then + rm -f -- "$source" + return 0 + fi + + if [[ -d "$source" ]]; then + cp -a "$source/." "$ZEROTIER_STATE/" 2>/dev/null || true + rm -rf -- "$source" + elif [[ -e "$source" ]]; then + die "Refusing to replace non-directory path: $source" + fi +} + +trap cleanup_on_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + + +# ============================================================================== +# Validate the Alpine diskless/ZFS environment +# ============================================================================== + +[[ $EUID -eq 0 ]] || die "This installer must be run as root." + +for command in apk git lbu mount rc-update +do + command -v "$command" >/dev/null 2>&1 || + die "Missing required command: $command" +done + +[[ -d "$SOFTWARE_ROOT" ]] || die "Missing software dataset mount: $SOFTWARE_ROOT" +[[ -d "$SYSOP_DATA_ROOT" ]] || die "Missing sysop dataset mount: $SYSOP_DATA_ROOT" + +SOFTWARE_FILESYSTEM="$({ + awk -v path="$SOFTWARE_ROOT" '$2 == path { print $3; exit }' /proc/mounts +} || true)" + +[[ "$SOFTWARE_FILESYSTEM" == "zfs" ]] || + die "$SOFTWARE_ROOT must be a mounted ZFS dataset, not '${SOFTWARE_FILESYSTEM:-unknown}'." + +case "$REQUIRED_SWAP_MIB" in + ''|*[!0-9]*) die "REQUIRED_SWAP_MIB must be a positive integer." ;; +esac + +case "$MAKE_JOBS" in + ''|*[!0-9]*) die "MAKE_JOBS must be a positive integer." ;; +esac + +case "$ENABLE_SSO" in + 0|1) ;; + *) die "ENABLE_SSO must be either 0 or 1." ;; +esac + +(( REQUIRED_SWAP_MIB > 0 )) || die "REQUIRED_SWAP_MIB must be greater than zero." +(( MAKE_JOBS > 0 )) || die "MAKE_JOBS must be greater than zero." + +if [[ -x "$ZEROTIER_INSTALL/zerotier-one" ]]; then + EXISTING_VERSION="$( + "$ZEROTIER_INSTALL/zerotier-one" -v 2>/dev/null || true + )" + + if [[ "$EXISTING_VERSION" == "$ZEROTIER_VERSION" ]]; then + BUILD_REQUIRED=0 + else + echo "WARNING: Existing ZeroTier binary reports version '${EXISTING_VERSION:-unknown}'; replacing it." + fi +fi + +if (( BUILD_REQUIRED == 1 )) && + [[ -n "$PREBUILT_BINARY" && -s "$PREBUILT_BINARY" ]] +then + PREBUILT_AVAILABLE=1 + BUILD_REQUIRED=0 +fi + + +# ============================================================================== +# Check swap and free space when compilation is needed +# ============================================================================== + +if (( BUILD_REQUIRED == 1 )); then + ACTIVE_SWAP_KB="$(awk 'NR > 1 { total += $3 } END { print total + 0 }' /proc/swaps)" + REQUIRED_SWAP_KB=$((REQUIRED_SWAP_MIB * 1024)) + + (( ACTIVE_SWAP_KB >= REQUIRED_SWAP_KB )) || + die "ZeroTier compilation requires at least ${REQUIRED_SWAP_MIB} MiB active swap." + + AVAILABLE_KB="$(df -Pk "$SOFTWARE_ROOT" | awk 'NR == 2 { print $4 }')" + REQUIRED_FREE_KB=$((1200 * 1024)) + + (( AVAILABLE_KB >= REQUIRED_FREE_KB )) || + die "At least 1200 MB must be free on $SOFTWARE_ROOT for the ZeroTier build." + + echo + echo "=== Reusing all active swap devices for compilation ===" + grep -E 'MemTotal|SwapTotal|SwapFree' /proc/meminfo +elif (( PREBUILT_AVAILABLE == 1 )); then + echo + echo "=== Using precompiled ZeroTier ${ZEROTIER_VERSION} binary ===" + echo "Source: $PREBUILT_BINARY" +else + echo + echo "=== Reusing the existing ZeroTier ${ZEROTIER_VERSION} binary ===" +fi + + +# ============================================================================== +# Enlarge the temporary RAM root filesystem +# ============================================================================== + +if (( BUILD_REQUIRED == 1 )); then + ROOT_FILESYSTEM="$(awk '$2 == "/" { print $3; exit }' /proc/mounts)" + + if [[ "$ROOT_FILESYSTEM" == "tmpfs" ]]; then + echo + echo "=== Enlarging temporary rootfs limit to $ROOTFS_BUILD_SIZE ===" + mount -o "remount,size=$ROOTFS_BUILD_SIZE" / + fi +fi + + +# ============================================================================== +# Install build-only packages +# ============================================================================== + +if (( BUILD_REQUIRED == 1 )); then + echo + echo "=== Installing ZeroTier build dependencies ===" + + if [[ "$ENABLE_SSO" == "1" ]]; then + echo "ZeroTier SSO is enabled; Rust/Cargo will be installed." + else + echo "ZeroTier SSO is disabled; Rust/Cargo are not required." + fi + + # --no-cache keeps the compiler toolchain out of the persistent APK cache. + # Retry transient Wi-Fi/download failures, cleaning a partial virtual + # package before each new attempt. + APK_ADD_SUCCEEDED=0 + for attempt in 1 2 3; do + if apk add \ + --no-cache \ + --virtual .zerotier-builddeps \ + "${BUILD_DEPS[@]}" + then + APK_ADD_SUCCEEDED=1 + break + fi + + echo "WARNING: Build dependency installation attempt $attempt failed." + apk del .zerotier-builddeps >/dev/null 2>&1 || true + + if (( attempt < 3 )); then + echo "Retrying after $((attempt * 10)) seconds..." + sleep $((attempt * 10)) + fi + done + + (( APK_ADD_SUCCEEDED == 1 )) || + die "Could not install ZeroTier build dependencies after 3 attempts." + + BUILD_DEPS_INSTALLED=1 + + for command in gcc g++ make pkgconf strip + do + command -v "$command" >/dev/null 2>&1 || + die "Build dependency did not provide required command: $command" + done + + if [[ "$ENABLE_SSO" == "1" ]]; then + command -v cargo >/dev/null 2>&1 || + die "SSO build dependency did not provide cargo." + fi +fi + + +# ============================================================================== +# Download the exact pinned ZeroTier source tag +# ============================================================================== + +if (( BUILD_REQUIRED == 1 )); then + echo + echo "=== Downloading ZeroTier ${ZEROTIER_VERSION} source ===" + + safe_remove_build_directory + mkdir -p "$BUILD_DIRECTORY" + + git init "$SOURCE_DIRECTORY" + git -C "$SOURCE_DIRECTORY" remote add \ + origin \ + https://github.com/zerotier/ZeroTierOne.git + + # Fetch the exact tag ref. This avoids both the earlier git-describe error + # on a shallow checkout and ambiguity between branch and tag names. + git -C "$SOURCE_DIRECTORY" fetch \ + --depth 1 \ + origin \ + "refs/tags/$ZEROTIER_VERSION:refs/tags/$ZEROTIER_VERSION" + + git -C "$SOURCE_DIRECTORY" checkout \ + --detach \ + "refs/tags/$ZEROTIER_VERSION" + + ACTUAL_COMMIT="$(git -C "$SOURCE_DIRECTORY" rev-parse HEAD)" + TAG_COMMIT="$( + git -C "$SOURCE_DIRECTORY" \ + rev-parse --verify "refs/tags/$ZEROTIER_VERSION^{commit}" + )" + + [[ "$ACTUAL_COMMIT" == "$TAG_COMMIT" ]] || + die "Checked-out commit does not match tag $ZEROTIER_VERSION." +fi + + +# ============================================================================== +# Build conservatively for the Raspberry Pi Zero 2W +# ============================================================================== + +if (( BUILD_REQUIRED == 1 )); then + echo + echo "=== Building ZeroTier with ${MAKE_JOBS} make job(s) ===" + + ( + cd "$SOURCE_DIRECTORY" + + # BCM2710A1 does not provide the optional ARMv8 crypto extensions that + # ZeroTier's aarch64 makefile otherwise enables by default. + sed -i \ + 's/-march=armv8-a+crypto/-march=armv8-a/g' \ + make-linux.mk + + if grep -q -- '-march=armv8-a+crypto' make-linux.mk; then + die "Could not disable ARM crypto-extension instructions." + fi + + export TMPDIR="$BUILD_DIRECTORY/tmp" + + if [[ "$ENABLE_SSO" == "1" ]]; then + export CARGO_BUILD_JOBS=1 + export CARGO_INCREMENTAL=0 + export CARGO_HOME="$BUILD_DIRECTORY/cargo-home" + mkdir -p "$CARGO_HOME" + fi + + mkdir -p "$TMPDIR" + + make \ + -j "$MAKE_JOBS" \ + CC=gcc \ + CXX=g++ \ + ZT_CONTROLLER=0 \ + ZT_SSO_SUPPORTED="$ENABLE_SSO" \ + one + + strip zerotier-one + ) +fi + + +# ============================================================================== +# Install the binary on rpool/software +# ============================================================================== + +if (( PREBUILT_AVAILABLE == 1 )); then + echo + echo "=== Installing precompiled ZeroTier on rpool/software ===" + + mkdir -p "$ZEROTIER_INSTALL" + install -m 0755 \ + "$PREBUILT_BINARY" \ + "$ZEROTIER_INSTALL/zerotier-one" +elif (( BUILD_REQUIRED == 1 )); then + echo + echo "=== Installing ZeroTier on rpool/software ===" + + mkdir -p "$ZEROTIER_INSTALL" + install -m 0755 \ + "$SOURCE_DIRECTORY/zerotier-one" \ + "$ZEROTIER_INSTALL/zerotier-one" +fi + +mkdir -p "$ZEROTIER_ROOT" +ln -sfn "versions/$ZEROTIER_BUILD_PROFILE" "$ZEROTIER_ROOT/current" + +rm -f \ + /usr/sbin/zerotier-one \ + /usr/sbin/zerotier-cli \ + /usr/sbin/zerotier-idtool + +ln -s "$ZEROTIER_ROOT/current/zerotier-one" /usr/sbin/zerotier-one +ln -s zerotier-one /usr/sbin/zerotier-cli +ln -s zerotier-one /usr/sbin/zerotier-idtool + + +# ============================================================================== +# Keep identity and future network state under sysop's ZFS-backed area +# ============================================================================== + +if [[ -d "$ZEROTIER_STATE" ]] && + find "$ZEROTIER_STATE" -mindepth 1 -print -quit | grep -q . +then + STATE_WAS_PRESENT=1 +fi + +mkdir -p "$ZEROTIER_STATE" /sysop /var/lib +chmod 0700 "$ZEROTIER_STATE" + +if id sysop >/dev/null 2>&1; then + chown sysop:sysop "$ZEROTIER_STATE" +fi + +migrate_directory_to_state /sysop/zerotier +migrate_directory_to_state /var/lib/zerotier-one + +ln -s "$ZEROTIER_STATE" /sysop/zerotier +ln -s /sysop/zerotier /var/lib/zerotier-one + + +# ============================================================================== +# OpenRC service +# ============================================================================== + +cat >/etc/init.d/zerotier-one <<'OPENRC' +#!/sbin/openrc-run + +description="ZeroTier One network virtualization service" + +command="/usr/sbin/zerotier-one" +command_background="yes" +pidfile="/run/${RC_SVCNAME}.pid" + +depend() +{ + need zfs-mount + use net logger + after networking +} + +start_pre() +{ + if [ ! -d /var/lib/zerotier-one ]; then + eerror "Persistent ZeroTier state is unavailable" + return 1 + fi + + modprobe tun >/dev/null 2>&1 || true +} +OPENRC + +chmod 0755 /etc/init.d/zerotier-one + +grep -qxF tun /etc/modules 2>/dev/null || echo tun >>/etc/modules +rc-update add zerotier-one default + + +# ============================================================================== +# Persist diskless symlinks and service files +# ============================================================================== + +for path in \ + /usr/sbin/zerotier-one \ + /usr/sbin/zerotier-cli \ + /usr/sbin/zerotier-idtool \ + /sysop/zerotier \ + /var/lib/zerotier-one \ + /etc/init.d/zerotier-one +do + lbu include "$path" +done + + +# ============================================================================== +# Verify without joining a network +# ============================================================================== + +echo +echo "=== Verifying the ZeroTier installation ===" + +if ! INSTALLED_VERSION="$(/usr/sbin/zerotier-one -v 2>/dev/null)"; then + die "The ZeroTier binary cannot run on this Raspberry Pi/Alpine system." +fi + +echo "$INSTALLED_VERSION" +[[ "$INSTALLED_VERSION" == "$ZEROTIER_VERSION" ]] || + die "ZeroTier reports version '$INSTALLED_VERSION', expected '$ZEROTIER_VERSION'." + +if ldd /usr/sbin/zerotier-one 2>&1 | grep -q 'not found'; then + ldd /usr/sbin/zerotier-one >&2 || true + die "The ZeroTier binary has missing runtime libraries." +fi + +if (( STATE_WAS_PRESENT == 0 )) && [[ -d "$ZEROTIER_STATE/networks.d" ]]; then + die "A fresh installation unexpectedly contains joined-network configuration." +fi + + +# ============================================================================== +# Remove toolchain and source tree +# ============================================================================== + +echo +release_build_resources + +trap - EXIT HUP INT TERM + +echo +echo "ZeroTier ${ZEROTIER_VERSION} is installed and enabled for the next boot." +echo "Existing identity/configuration is preserved; a fresh install is not joined." diff --git a/prepare-storage.sh b/prepare-storage.sh new file mode 100755 index 0000000..57a414a --- /dev/null +++ b/prepare-storage.sh @@ -0,0 +1,530 @@ +#!/usr/bin/env bash + +set -euo pipefail + + +# ============================================================================== +# Configuration +# ============================================================================== + +CONFIG_FILE="NenjimHub.conf" + +JAVA_ARCHIVE="zulu25.36.205-ca-jre25.0.4.1-linux_musl_aarch64.tar.gz" +JAVA_DIRECTORY="zulu25.36.205-ca-jre25.0.4.1-linux_musl_aarch64" +JAVA_VERSION_DIRECTORY="jre-25.0.4.1" +JAVA_MAJOR_DIRECTORY="jre-25" + +ZREPL_VERSION="v0.7.0" +ZREPL_ASSET="zrepl-linux-arm64" +ZREPL_LOCAL_BINARY="$ZREPL_ASSET" + +# Cards marketed as 16 GB are commonly a little smaller than 16,000,000,000 +# usable bytes. 14 GiB actual capacity is the baseline; the configured +# partition and zvol sizes are also checked below. +MINIMUM_DEVICE_BYTES=$((14 * 1024 * 1024 * 1024)) + +# Raspberry Pi Zero 2 W boots most reliably from a classic MBR partition table. +# 0x0c is FAT32 LBA, 0x82 is Linux swap, and 0xbf identifies Solaris/ZFS. +BOOT_PARTITION_TYPE="c" +SWAP_PARTITION_TYPE="82" +ZFS_PARTITION_TYPE="bf" + +TEMP_DIRECTORY="" +STAGING_ROOT="" +VIRT_MOUNT="" +POOL_CREATED=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 "$VIRT_MOUNT" ]] && mountpoint -q "$VIRT_MOUNT" 2>/dev/null; then + umount "$VIRT_MOUNT" + fi + + if (( POOL_CREATED == 1 )) && zpool list -H -o name "$POOL_NAME" >/dev/null 2>&1; then + zpool export "$POOL_NAME" + fi + + if [[ -n "$TEMP_DIRECTORY" && -d "$TEMP_DIRECTORY" ]]; then + case "$TEMP_DIRECTORY" in + /tmp/nenjim-storage.*) + rm -rf -- "$TEMP_DIRECTORY" + ;; + *) + echo "WARNING: Refusing to remove unexpected path: $TEMP_DIRECTORY" >&2 + ;; + esac + fi +} + +download_zrepl() +{ + local destination="$1" + local metadata asset_data asset_url asset_digest actual_digest + + echo "Downloading official zrepl ${ZREPL_VERSION} ARM64 binary" + + metadata="$({ + curl \ + --fail \ + --location \ + --proto '=https' \ + --silent \ + --show-error \ + "https://api.github.com/repos/zrepl/zrepl/releases/tags/${ZREPL_VERSION}" + })" + + asset_data="$({ + printf '%s' "$metadata" | + ZREPL_ASSET="$ZREPL_ASSET" python3 -c ' +import json +import os +import sys + +release = json.load(sys.stdin) +name = os.environ["ZREPL_ASSET"] + +for asset in release.get("assets", []): + if asset.get("name") == name: + print(asset.get("browser_download_url", "")) + print(asset.get("digest", "")) + break +else: + raise SystemExit(f"Release asset not found: {name}") +' + })" || die "Could not read the zrepl release metadata." + + asset_url="$(printf '%s\n' "$asset_data" | sed -n '1p')" + asset_digest="$(printf '%s\n' "$asset_data" | sed -n '2p')" + + [[ "$asset_url" == https://github.com/zrepl/zrepl/releases/download/* ]] || + die "Unexpected zrepl download URL returned by GitHub." + + [[ "$asset_digest" =~ ^sha256:[[:xdigit:]]{64}$ ]] || + die "GitHub did not provide a SHA-256 digest for $ZREPL_ASSET." + + curl \ + --fail \ + --location \ + --proto '=https' \ + --silent \ + --show-error \ + --output "$destination" \ + "$asset_url" + + actual_digest="sha256:$(sha256sum "$destination" | awk '{print $1}')" + + [[ "$actual_digest" == "$asset_digest" ]] || + die "SHA-256 validation failed for the downloaded zrepl binary." +} + + +# ============================================================================== +# 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 command in \ + awk blkid blockdev curl file findmnt install lsblk mkfs.ext4 mkfs.vfat mkswap \ + mount mountpoint partprobe python3 sed sfdisk sha256sum tar udevadm \ + umount wipefs zfs zpool +do + command -v "$command" >/dev/null 2>&1 || + die "Missing required command: $command" +done + +[[ -f "$JAVA_ARCHIVE" ]] || die "Missing input file: $JAVA_ARCHIVE" +[[ -b "$DEVICE" ]] || die "Not a block device: $DEVICE" + +for name in \ + BOOT_PARTITION_SIZE_MIB VIRT_ZVOL_SIZE_MIB \ + SWAP_SIZE_MIB_PART SWAP_SIZE_MIB_ZFS SWAP_SIZE_PCT_ZRAM +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." +(( 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" +[[ "$BOOT_LABEL" =~ ^[[:alnum:]_-]{1,11}$ ]] || + die "BOOT_LABEL must be 1-11 letters, digits, underscores or hyphens." +[[ "$SWAP_PART_LABEL" =~ ^[[:alnum:]_-]{1,15}$ ]] || + die "SWAP_PART_LABEL must be 1-15 letters, digits, underscores or hyphens." + +DEVICE_BYTES="$(blockdev --getsize64 "$DEVICE")" + +CONFIGURED_MINIMUM_DEVICE_BYTES=$(( + (1 + BOOT_PARTITION_SIZE_MIB + SWAP_SIZE_MIB_PART + + VIRT_ZVOL_SIZE_MIB + SWAP_SIZE_MIB_ZFS + 2048) * 1024 * 1024 +)) + +if (( CONFIGURED_MINIMUM_DEVICE_BYTES > MINIMUM_DEVICE_BYTES )); then + REQUIRED_DEVICE_BYTES="$CONFIGURED_MINIMUM_DEVICE_BYTES" +else + REQUIRED_DEVICE_BYTES="$MINIMUM_DEVICE_BYTES" +fi + +(( DEVICE_BYTES >= REQUIRED_DEVICE_BYTES )) || + die "The card has $DEVICE_BYTES bytes; the selected layout needs at least $REQUIRED_DEVICE_BYTES bytes." + +if lsblk -nrpo MOUNTPOINT "$DEVICE" | grep -q '[^[:space:]]'; then + die "A partition on $DEVICE is mounted. Unmount it before continuing." +fi + +if zpool list -H -o name "$POOL_NAME" >/dev/null 2>&1; then + die "A pool named $POOL_NAME is already imported on this PC." +fi + +if zpool status -P 2>/dev/null | grep -Fq "$DEVICE"; then + die "$DEVICE belongs to a pool that is currently imported. Export it first." +fi + +HOST_ZFS_VERSION="$(zpool --version | sed -n 's/^zfs-\([^ -]*\).*/\1/p' | head -1)" + +[[ "$HOST_ZFS_VERSION" == "$EXPECTED_ZFS_VERSION" ]] || + die "This design expects OpenZFS $EXPECTED_ZFS_VERSION; the PC has ${HOST_ZFS_VERSION:-unknown}." + +COMPATIBILITY_FILE="/usr/share/zfs/compatibility.d/$POOL_COMPATIBILITY" +[[ -f "$COMPATIBILITY_FILE" ]] || + die "Missing OpenZFS compatibility file: $COMPATIBILITY_FILE" + +BOOT_PARTITION="$(partition_path 1)" +SWAP_PARTITION="$(partition_path 2)" +ZFS_PARTITION="$(partition_path 3)" + +TEMP_DIRECTORY="$(mktemp -d /tmp/nenjim-storage.XXXXXX)" +STAGING_ROOT="$TEMP_DIRECTORY/root" +VIRT_MOUNT="$TEMP_DIRECTORY/virt" + +mkdir -p "$STAGING_ROOT" "$VIRT_MOUNT" +trap cleanup EXIT + + +# ============================================================================== +# Destructive-action confirmation +# ============================================================================== + +echo +echo "WARNING: ALL DATA ON ${DEVICE} WILL BE DESTROYED." +echo +echo "The new layout will be:" +echo " partition 1: ${BOOT_PARTITION_SIZE_MIB} MiB FAT32 Alpine boot partition" +if (( SWAP_SIZE_MIB_PART > 0 )); then + echo " partition 2: ${SWAP_SIZE_MIB_PART} MiB raw Linux swap (priority ${SWAP_PRI_PART})" +else + echo " partition 2: omitted (raw swap is disabled)" +fi +echo " partition 3: remaining space, OpenZFS pool $POOL_NAME" +if (( SWAP_SIZE_MIB_ZFS > 0 )); then + echo " $POOL_NAME/swap: ${SWAP_SIZE_MIB_ZFS} MiB (priority ${SWAP_PRI_ZFS})" +else + echo " $POOL_NAME/swap: disabled" +fi +echo " ZRAM: ${SWAP_SIZE_PCT_ZRAM}% of RAM (priority ${SWAP_PRI_ZRAM})" +echo +lsblk "$DEVICE" +echo +read -r -p "Type YES to create the new storage layout: " answer + +[[ "$answer" == "YES" ]] || die "Aborted." +[[ -b "$DEVICE" ]] || die "The block device disappeared: $DEVICE" + + +# ============================================================================== +# Create the physical partitions +# ============================================================================== + +echo +echo "=== Creating FAT32, optional raw swap and OpenZFS partitions ===" + +wipefs --all "$DEVICE" + +LOGICAL_SECTOR_BYTES="$(blockdev --getss "$DEVICE")" + +case "$LOGICAL_SECTOR_BYTES" in + 512|4096) + ;; + *) + die "Unsupported logical sector size: $LOGICAL_SECTOR_BYTES bytes." + ;; +esac + +BOOT_START_SECTOR=$((1024 * 1024 / LOGICAL_SECTOR_BYTES)) +BOOT_SIZE_SECTORS=$((BOOT_PARTITION_SIZE_MIB * 1024 * 1024 / LOGICAL_SECTOR_BYTES)) +SWAP_SIZE_SECTORS=$((SWAP_SIZE_MIB_PART * 1024 * 1024 / LOGICAL_SECTOR_BYTES)) +NEXT_START_SECTOR=$((BOOT_START_SECTOR + BOOT_SIZE_SECTORS)) + +if (( SWAP_SIZE_MIB_PART > 0 )); then + SWAP_START_SECTOR="$NEXT_START_SECTOR" + ZFS_START_SECTOR=$((SWAP_START_SECTOR + SWAP_SIZE_SECTORS)) +else + ZFS_START_SECTOR="$NEXT_START_SECTOR" +fi + +{ + echo 'label: dos' + echo 'unit: sectors' + echo + echo "$BOOT_PARTITION : start=$BOOT_START_SECTOR, size=$BOOT_SIZE_SECTORS, type=$BOOT_PARTITION_TYPE, bootable" + if (( SWAP_SIZE_MIB_PART > 0 )); then + echo "$SWAP_PARTITION : start=$SWAP_START_SECTOR, size=$SWAP_SIZE_SECTORS, type=$SWAP_PARTITION_TYPE" + fi + echo "$ZFS_PARTITION : start=$ZFS_START_SECTOR, type=$ZFS_PARTITION_TYPE" +} | sfdisk --wipe always "$DEVICE" + +partprobe "$DEVICE" +udevadm settle + +wait_for_block_device "$BOOT_PARTITION" || + die "Boot partition did not appear: $BOOT_PARTITION" + +if (( SWAP_SIZE_MIB_PART > 0 )); then + wait_for_block_device "$SWAP_PARTITION" || + die "Swap partition did not appear: $SWAP_PARTITION" +fi + +wait_for_block_device "$ZFS_PARTITION" || + die "ZFS partition did not appear: $ZFS_PARTITION" + +mkfs.vfat -F 32 -n "$BOOT_LABEL" "$BOOT_PARTITION" + +if (( SWAP_SIZE_MIB_PART > 0 )); then + mkswap -L "$SWAP_PART_LABEL" "$SWAP_PARTITION" +fi + + +# ============================================================================== +# Create the ZFS pool and datasets +# ============================================================================== + +echo +echo "=== Creating OpenZFS pool $POOL_NAME ===" + +zpool create \ + -f \ + -R "$STAGING_ROOT" \ + -o "compatibility=$POOL_COMPATIBILITY" \ + -o ashift=12 \ + -O xattr=sa \ + -O compression=lz4 \ + -O atime=off \ + -O canmount=off \ + -O mountpoint=none \ + "$POOL_NAME" \ + "$ZFS_PARTITION" + +POOL_CREATED=1 + +zfs create -o mountpoint=/home "$POOL_NAME/home" +zfs create -o mountpoint=/usr/local/software "$POOL_NAME/software" +zfs create -o mountpoint=/media/sysop "$POOL_NAME/sysop" + + +# ============================================================================== +# Install Java on rpool/software +# ============================================================================== + +echo +echo "=== Installing Azul Zulu JRE on rpool/software ===" + +SOFTWARE_ROOT="$STAGING_ROOT/usr/local/software" +mkdir -p "$SOFTWARE_ROOT" + +tar -xzf "$JAVA_ARCHIVE" -C "$SOFTWARE_ROOT" + +[[ -d "$SOFTWARE_ROOT/$JAVA_DIRECTORY" ]] || + die "The Java archive did not create $JAVA_DIRECTORY." + +ln -s "$JAVA_DIRECTORY" "$SOFTWARE_ROOT/$JAVA_VERSION_DIRECTORY" +ln -s "$JAVA_VERSION_DIRECTORY" "$SOFTWARE_ROOT/$JAVA_MAJOR_DIRECTORY" + + +# ============================================================================== +# Install the official static zrepl ARM64 binary on rpool/software +# ============================================================================== + +echo +echo "=== Installing zrepl ${ZREPL_VERSION} on rpool/software ===" + +ZREPL_VERSION_NUMBER="${ZREPL_VERSION#v}" +ZREPL_VERSION_DIRECTORY="$SOFTWARE_ROOT/zrepl/versions/$ZREPL_VERSION_NUMBER" +ZREPL_TEMP_BINARY="$TEMP_DIRECTORY/$ZREPL_ASSET" + +mkdir -p "$ZREPL_VERSION_DIRECTORY" + +if [[ -f "$ZREPL_LOCAL_BINARY" ]]; then + echo "Using local zrepl binary: $ZREPL_LOCAL_BINARY" + cp "$ZREPL_LOCAL_BINARY" "$ZREPL_TEMP_BINARY" +else + download_zrepl "$ZREPL_TEMP_BINARY" +fi + +file "$ZREPL_TEMP_BINARY" | grep -Eq 'ELF 64-bit.*ARM aarch64' || + die "$ZREPL_ASSET is not an ARM64 ELF binary." + +install -m 0755 "$ZREPL_TEMP_BINARY" "$ZREPL_VERSION_DIRECTORY/zrepl" +ln -s "versions/$ZREPL_VERSION_NUMBER" "$SOFTWARE_ROOT/zrepl/current" + + +# ============================================================================== +# Create and format the shared Docker/containerd zvol +# ============================================================================== + +echo +echo "=== Creating ${POOL_NAME}/virt (${VIRT_ZVOL_SIZE_MIB} MiB, ext4) ===" + +zfs create \ + -V "${VIRT_ZVOL_SIZE_MIB}M" \ + -o volmode=dev \ + -o logbias=throughput \ + -o primarycache=metadata \ + -o secondarycache=none \ + "$POOL_NAME/virt" + +udevadm settle + +VIRT_DEVICE="/dev/zvol/$POOL_NAME/virt" +wait_for_block_device "$VIRT_DEVICE" || + die "The zvol device did not appear: $VIRT_DEVICE" + +mkfs.ext4 -F -L NENJIMVIRT "$VIRT_DEVICE" +mount -t ext4 -o rw,noatime "$VIRT_DEVICE" "$VIRT_MOUNT" + +mkdir -p "$VIRT_MOUNT/docker" "$VIRT_MOUNT/containerd" +chmod 0711 "$VIRT_MOUNT/docker" "$VIRT_MOUNT/containerd" + +sync +umount "$VIRT_MOUNT" + + +# ============================================================================== +# Verification and clean export +# ============================================================================== + +echo +echo "=== Verifying and exporting the pool ===" + +zpool get -H -o value compatibility "$POOL_NAME" | + grep -qx "$POOL_COMPATIBILITY" || + die "The pool compatibility setting is incorrect." + +for dataset in home software sysop virt; do + zfs list "$POOL_NAME/$dataset" >/dev/null || + die "Missing ZFS object: $POOL_NAME/$dataset" +done + +JAVA_BINARY="$SOFTWARE_ROOT/$JAVA_DIRECTORY/bin/java" + +[[ -x "$JAVA_BINARY" ]] || + die "The Java archive did not provide an executable bin/java." + +# The preparation PC is normally amd64 and therefore cannot execute this +# Raspberry Pi binary. Verify its format and target architecture instead. +file "$JAVA_BINARY" | grep -Eq 'ELF 64-bit.*ARM aarch64' || + die "The installed Java binary is not an ARM64 ELF executable." + +zpool set cachefile=none "$POOL_NAME" +zpool export "$POOL_NAME" +POOL_CREATED=0 + +sync + + +# ============================================================================== +# Finished +# ============================================================================== + +cleanup +trap - EXIT + +echo +echo "============================================================================" +echo "Storage preparation is complete." +echo +echo "Partition 1 is ready for flash.sh." +if (( SWAP_SIZE_MIB_PART > 0 )); then + echo "Partition 2 is raw swap and will not be touched by flash.sh." +else + echo "Partition 2 is intentionally unused." +fi +echo "Partition 3 contains $POOL_NAME and will not be touched by flash.sh." +if (( SWAP_SIZE_MIB_ZFS > 0 )); then + echo "The Pi will create ${POOL_NAME}/swap (${SWAP_SIZE_MIB_ZFS} MiB) using its real page size." +fi +echo "============================================================================" diff --git a/unattended.sh b/unattended.sh new file mode 100755 index 0000000..2fba539 --- /dev/null +++ b/unattended.sh @@ -0,0 +1,1712 @@ +#!/bin/sh + +set -eu + +# Monotonic time is unaffected by the RTC seed and later network time updates. +INSTALL_START_UPTIME_SECONDS="$(awk '{ print int($1); exit }' /proc/uptime)" + + +# ============================================================================== +# Configuration +# ============================================================================== + +NENJIM_USER="nenjim" +NENJIM_PASS="nenjim" +NENJIM_UID="1001" + +SYSOP_USER="sysop" +SYSOP_PASS="sysop" +SYSOP_UID="1000" + +MY_IFACE="wlan0" +MY_TIMEZONE="Europe/Copenhagen" + +ALPINE_BRANCH="3.24" +ALPINE_MIRROR="https://dl-cdn.alpinelinux.org/alpine" +APK_MAIN_REPOSITORY="$ALPINE_MIRROR/v$ALPINE_BRANCH/main" +APK_COMMUNITY_REPOSITORY="$ALPINE_MIRROR/v$ALPINE_BRANCH/community" +BUILD_TIME_FILE_NAME="nenjim-build-time" +CONFIG_FILE_NAME="NenjimHub.conf" + +SOFTWARE_ROOT="/usr/local/software" +SYSOP_DATA_ROOT="/media/sysop" +VIRT_MOUNT="/media/virt" + +ZRAM_SWAP_DEVICE="/dev/zram0" +ZRAM_INSTALL_MEMORY_PERCENT="100" + +ZREPL_CONFIG_DIRECTORY="$SYSOP_DATA_ROOT/zrepl" +ZREPL_CONFIG_FILE="$ZREPL_CONFIG_DIRECTORY/zrepl.yml" + +EARLY_PACKAGES=" +btop +eudev +eudev-openrc +htop +kitty-terminfo +musl-locales +tmux +udev-init-scripts +udev-init-scripts-openrc +zfs~2.4.3 +zfs-openrc~2.4.3 +zfs-scripts~2.4.3 +zfs-udev~2.4.3 +zram-init +zram-init-openrc +" + +RUNTIME_PACKAGES=" +bash +ca-certificates +containerd +containerd-openrc +doas +docker-cli +docker-engine +docker-openrc +e2fsprogs +git +libcrypto3 +libssl3 +libstdc++ +openssh-server +openssh-server-common-openrc +wpa_supplicant +wpa_supplicant-openrc +" + + +# ============================================================================== +# Helpers +# ============================================================================== + +die() +{ + echo "ERROR: $*" >&2 + exit 1 +} + +require_uint() +{ + name="$1" + value="$2" + + case "$value" in + ''|*[!0-9]*) die "$name must be a non-negative integer." ;; + esac +} + +require_swap_priority() +{ + name="$1" + value="$2" + + require_uint "$name" "$value" + [ "$value" -le 32767 ] || + die "$name must be between 0 and 32767." +} + +format_duration() +{ + total_seconds="$1" + hours=$((total_seconds / 3600)) + minutes=$(((total_seconds % 3600) / 60)) + seconds=$((total_seconds % 60)) + + printf '%02d:%02d:%02d' "$hours" "$minutes" "$seconds" +} + +is_mounted() +{ + awk -v path="$1" '$2 == path { found=1 } END { exit !found }' /proc/mounts +} + +wait_for_block_device() +{ + path="$1" + attempt=0 + + while [ "$attempt" -lt 50 ]; do + [ -b "$path" ] && return 0 + sleep 1 + attempt=$((attempt + 1)) + done + + return 1 +} + +blkid_value() +{ + blkid_device="$1" + blkid_key="$2" + blkid_output="$(blkid "$blkid_device" 2>/dev/null || true)" + + printf '%s\n' "$blkid_output" | + sed -n "s/.*[[:space:]]${blkid_key}=\"\([^\"]*\)\".*/\1/p" | + head -1 +} + +enable_service() +{ + service="$1" + runlevel="$2" + + [ -x "/etc/init.d/$service" ] || + die "Missing OpenRC service: $service" + + rc-update add "$service" "$runlevel" +} + +write_zram_config() +{ + memory_percent="$1" + + case "$memory_percent" in + ''|*[!0-9]*) die "Invalid ZRAM memory percentage: $memory_percent" ;; + esac + + mkdir -p /etc/conf.d + + cat >/etc/conf.d/zram-init </dev/null 2>&1; then + actual_uid="$(id -u "$username")" + [ "$actual_uid" = "$uid" ] || + die "$username has UID $actual_uid, expected $uid." + else + adduser \ + -D \ + -u "$uid" \ + -h "$home" \ + -s "$shell" \ + "$username" + fi + + sed -i \ + "s#^\($username:[^:]*:[^:]*:[^:]*:[^:]*:\)[^:]*:[^:]*#\1$home:$shell#" \ + /etc/passwd +} + + +# ============================================================================== +# Locate the FAT boot media and first-boot inputs +# ============================================================================== + +ovl="$( + dmesg | + grep -o 'Loading user settings from .*:' | + awk '{print $5}' | + sed 's/:.*$//' | + head -1 +)" + +if [ -n "${ovl:-}" ] && [ -f "$ovl" ]; then + BOOT_MEDIA="$(dirname "$ovl")" +else + BOOT_MEDIA="$( + find /media \ + -maxdepth 3 \ + -type f \ + -name headless.apkovl.tar.gz \ + -exec dirname {} \; | + head -1 + )" +fi + +[ -n "${BOOT_MEDIA:-}" ] && [ -d "$BOOT_MEDIA" ] || + die "Could not locate the Alpine boot media." + +mount -o remount,rw "$BOOT_MEDIA" 2>/dev/null || true + +[ -f "$BOOT_MEDIA/wpa_supplicant.conf" ] || + die "Missing wpa_supplicant.conf on the boot partition." + +[ -f "$BOOT_MEDIA/installZeroTier.sh" ] || + die "Missing installZeroTier.sh on the boot partition." + +[ -f "$BOOT_MEDIA/$CONFIG_FILE_NAME" ] || + die "Missing $CONFIG_FILE_NAME on the boot partition; rerun flash.sh." + + +# ============================================================================== +# Logging +# ============================================================================== + +INSTALL_LOG="$BOOT_MEDIA/NenjimHub-Install.log" +LEGACY_INSTALL_LOG="$BOOT_MEDIA/nenjim-install.log" + +if [ -f "$LEGACY_INSTALL_LOG" ] && [ ! -e "$INSTALL_LOG" ]; then + mv "$LEGACY_INSTALL_LOG" "$INSTALL_LOG" +fi + +exec >>"$INSTALL_LOG" 2>&1 + +logger -t nenjim-install "Starting unattended Alpine installation" + +echo +echo "============================================================================" +echo "Nenjim Alpine/ZFS bootstrap" +echo "Boot media: $BOOT_MEDIA" +echo "Log: $INSTALL_LOG" +echo "============================================================================" + + +# ============================================================================== +# Load and validate the shared provisioning configuration +# ============================================================================== + +CONFIG_PATH="$BOOT_MEDIA/$CONFIG_FILE_NAME" +# shellcheck source=NenjimHub.conf +. "$CONFIG_PATH" + +require_uint BOOT_PARTITION_SIZE_MIB "${BOOT_PARTITION_SIZE_MIB:-}" +require_uint VIRT_ZVOL_SIZE_MIB "${VIRT_ZVOL_SIZE_MIB:-}" +require_uint SWAP_SIZE_MIB_PART "${SWAP_SIZE_MIB_PART:-}" +require_uint SWAP_SIZE_MIB_ZFS "${SWAP_SIZE_MIB_ZFS:-}" +require_uint SWAP_SIZE_PCT_ZRAM "${SWAP_SIZE_PCT_ZRAM:-}" +require_uint DISKLESS_ROOT_SIZE_MIB "${DISKLESS_ROOT_SIZE_MIB:-}" +require_uint ZFS_ARC_MAX_MIB "${ZFS_ARC_MAX_MIB:-}" +require_uint ZEROTIER_REQUIRED_SWAP_MIB "${ZEROTIER_REQUIRED_SWAP_MIB:-}" +require_uint ZEROTIER_MAKE_JOBS "${ZEROTIER_MAKE_JOBS:-}" +require_uint ZEROTIER_ENABLE_SSO "${ZEROTIER_ENABLE_SSO:-}" + +require_swap_priority SWAP_PRI_ZRAM "${SWAP_PRI_ZRAM:-}" +require_swap_priority SWAP_PRI_ZFS "${SWAP_PRI_ZFS:-}" +require_swap_priority SWAP_PRI_PART "${SWAP_PRI_PART:-}" + +[ "$BOOT_PARTITION_SIZE_MIB" -gt 0 ] || + die "BOOT_PARTITION_SIZE_MIB must be greater than zero." +[ "$VIRT_ZVOL_SIZE_MIB" -gt 0 ] || + die "VIRT_ZVOL_SIZE_MIB must be greater than zero." +[ "$DISKLESS_ROOT_SIZE_MIB" -gt 0 ] || + die "DISKLESS_ROOT_SIZE_MIB must be greater than zero." +[ "$ZFS_ARC_MAX_MIB" -gt 0 ] || + die "ZFS_ARC_MAX_MIB must be greater than zero." +[ "$ZEROTIER_REQUIRED_SWAP_MIB" -gt 0 ] || + die "ZEROTIER_REQUIRED_SWAP_MIB must be greater than zero." +[ "$ZEROTIER_MAKE_JOBS" -gt 0 ] || + die "ZEROTIER_MAKE_JOBS must be greater than zero." +case "$ZEROTIER_ENABLE_SSO" in + 0|1) ;; + *) die "ZEROTIER_ENABLE_SSO must be either 0 or 1." ;; +esac +[ -n "${ZEROTIER_VERSION:-}" ] || + die "ZEROTIER_VERSION must not be empty." +printf '%s\n' "$ZEROTIER_VERSION" | + grep -Eq '^[0-9]+([.][0-9]+){2}([._-][[:alnum:]]+)*$' || + die "Invalid ZEROTIER_VERSION: $ZEROTIER_VERSION" +printf '%s\n' "${ZEROTIER_PREBUILT_BINARY:-}" | + grep -Eq '^[[:alnum:]][[:alnum:]._-]*$' || + die "ZEROTIER_PREBUILT_BINARY must be a plain filename without directories." +[ "$SWAP_SIZE_PCT_ZRAM" -le 100 ] || + die "SWAP_SIZE_PCT_ZRAM must be between 0 and 100." + +printf '%s\n' "${NENJIMHUB_HOSTNAME:-}" | + grep -Eq '^[[:alnum:]]([[:alnum:]-]{0,61}[[:alnum:]])?$' || + die "NENJIMHUB_HOSTNAME must be one 1-63 character DNS hostname label." +printf '%s\n' "${CONSOLE_KEYMAP_LAYOUT:-}" | + grep -Eq '^[A-Za-z0-9][A-Za-z0-9_-]*$' || + die "Invalid CONSOLE_KEYMAP_LAYOUT: ${CONSOLE_KEYMAP_LAYOUT:-empty}" +printf '%s\n' "${CONSOLE_KEYMAP_VARIANT:-}" | + grep -Eq '^[A-Za-z0-9][A-Za-z0-9_-]*$' || + die "Invalid CONSOLE_KEYMAP_VARIANT: ${CONSOLE_KEYMAP_VARIANT:-empty}" +printf '%s\n' "${SYSTEM_LOCALE:-}" | + grep -Eq '^[A-Za-z][A-Za-z0-9_.@-]*$' || + die "Invalid SYSTEM_LOCALE: ${SYSTEM_LOCALE:-empty}" +printf '%s\n' "$POOL_NAME" | grep -Eq '^[A-Za-z][A-Za-z0-9_.:-]*$' || + die "Invalid POOL_NAME: $POOL_NAME" +printf '%s\n' "$SWAP_PART_LABEL" | grep -Eq '^[[:alnum:]_-]{1,15}$' || + die "Invalid SWAP_PART_LABEL: $SWAP_PART_LABEL" + +SWAP_ZVOL="$POOL_NAME/swap" +SWAP_SIZE_BYTES=$((SWAP_SIZE_MIB_ZFS * 1024 * 1024)) +SWAP_DEVICE="/dev/zvol/$POOL_NAME/swap" +SWAP_PART_DEVICE="/dev/disk/by-label/$SWAP_PART_LABEL" +VIRT_ZVOL="$POOL_NAME/virt" +VIRT_DEVICE="/dev/zvol/$POOL_NAME/virt" +VIRT_SIZE_BYTES=$((VIRT_ZVOL_SIZE_MIB * 1024 * 1024)) +DISKLESS_ROOT_SIZE="${DISKLESS_ROOT_SIZE_MIB}M" +ZFS_ARC_MAX_BYTES=$((ZFS_ARC_MAX_MIB * 1024 * 1024)) + +echo "Swap profile:" +echo " ZRAM: ${SWAP_SIZE_PCT_ZRAM}% at priority $SWAP_PRI_ZRAM" +echo " raw p2: ${SWAP_SIZE_MIB_PART} MiB at priority $SWAP_PRI_PART" +echo " ZFS zvol: ${SWAP_SIZE_MIB_ZFS} MiB at priority $SWAP_PRI_ZFS" + +if [ "$SWAP_SIZE_MIB_PART" -gt 0 ] && \ + [ "$SWAP_SIZE_MIB_ZFS" -gt 0 ] && \ + [ "$SWAP_PRI_ZFS" -gt "$SWAP_PRI_PART" ]; then + echo "WARNING: ZFS swap has higher priority than raw partition swap." + echo " This is allowed, but raw swap is safer under severe memory pressure." +fi + + +# ============================================================================== +# Activate stable raw swap before setup-alpine and package installation +# ============================================================================== + +if [ "$SWAP_SIZE_MIB_PART" -gt 0 ]; then + echo + echo "=== Activating early raw partition swap ===" + + BOOT_BLOCK_DEVICE="$( + awk -v path="$BOOT_MEDIA" '$2 == path { print $1; exit }' /proc/mounts + )" + + case "$BOOT_BLOCK_DEVICE" in + *p1) EARLY_SWAP_PARTITION="${BOOT_BLOCK_DEVICE%p1}p2" ;; + *1) EARLY_SWAP_PARTITION="${BOOT_BLOCK_DEVICE%1}2" ;; + *) die "Could not derive partition 2 from boot device $BOOT_BLOCK_DEVICE." ;; + esac + + wait_for_block_device "$EARLY_SWAP_PARTITION" || + die "Raw swap partition did not appear: $EARLY_SWAP_PARTITION" + + [ "$(blkid_value "$EARLY_SWAP_PARTITION" TYPE)" = "swap" ] || + die "$EARLY_SWAP_PARTITION is not formatted as Linux swap." + [ "$(blkid_value "$EARLY_SWAP_PARTITION" LABEL)" = "$SWAP_PART_LABEL" ] || + die "$EARLY_SWAP_PARTITION does not have label $SWAP_PART_LABEL." + + EARLY_SWAP_KERNEL_DEVICE="$(readlink -f "$EARLY_SWAP_PARTITION")" + if ! awk -v device="$EARLY_SWAP_KERNEL_DEVICE" \ + 'NR > 1 && $1 == device { found=1 } END { exit !found }' /proc/swaps + then + swapon -p "$SWAP_PRI_PART" "$EARLY_SWAP_PARTITION" + fi +fi + + +# ============================================================================== +# Seed a trustworthy clock before HTTPS (the Raspberry Pi has no RTC) +# ============================================================================== + +echo +echo "=== Seeding the clock for HTTPS package access ===" + +BUILD_TIME_FILE="$BOOT_MEDIA/$BUILD_TIME_FILE_NAME" +[ -f "$BUILD_TIME_FILE" ] || + die "Missing $BUILD_TIME_FILE_NAME; rerun the updated flash.sh." + +BUILD_TIME="$(sed -n '1p' "$BUILD_TIME_FILE")" +printf '%s\n' "$BUILD_TIME" | grep -Eq \ + '^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$' || + die "Invalid UTC build time in $BUILD_TIME_FILE." + +echo "Clock before seed: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +date -u -s "$BUILD_TIME" >/dev/null +echo "Clock after seed: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + + +# ============================================================================== +# Persistent diskless root size +# ============================================================================== + +echo +echo "=== Configuring diskless root size: $DISKLESS_ROOT_SIZE ===" + +CMDLINE_FILE="$BOOT_MEDIA/cmdline.txt" +[ -f "$CMDLINE_FILE" ] || die "Missing Raspberry Pi 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" + + +# ============================================================================== +# Base Alpine diskless configuration +# ============================================================================== + +echo +echo "=== Validating console keymap: $CONSOLE_KEYMAP_LAYOUT $CONSOLE_KEYMAP_VARIANT ===" + +# setup-keymap installs this package itself, but an invalid non-interactive +# variant would fall back to an interactive prompt. Keep a temporary reference +# while validating so setup-keymap can use the same already-installed files. +apk add \ + --quiet \ + --no-cache \ + --virtual .nenjim-keymap-check \ + kbd-bkeymaps + +KEYMAP_SOURCE_FOUND=0 +for keymap_source in \ + "/usr/share/bkeymaps/$CONSOLE_KEYMAP_LAYOUT/$CONSOLE_KEYMAP_VARIANT.bmap" \ + "/usr/share/bkeymaps/$CONSOLE_KEYMAP_LAYOUT/$CONSOLE_KEYMAP_VARIANT.bmap.gz" +do + if [ -f "$keymap_source" ]; then + KEYMAP_SOURCE_FOUND=1 + break + fi +done + +if [ "$KEYMAP_SOURCE_FOUND" -ne 1 ]; then + echo "Available variants for layout $CONSOLE_KEYMAP_LAYOUT:" >&2 + ls -1 "/usr/share/bkeymaps/$CONSOLE_KEYMAP_LAYOUT" 2>/dev/null >&2 || true + die "Console keymap does not exist: $CONSOLE_KEYMAP_LAYOUT $CONSOLE_KEYMAP_VARIANT" +fi + +cat >/tmp/ANSWERFILE </etc/lbu/lbu.conf </etc/profile.d/99-nenjim-utf8.sh <>/etc/rc.conf + +export LANG="$SYSTEM_LOCALE" +export LC_CTYPE="$SYSTEM_LOCALE" + +lbu include /etc/profile.d/99-nenjim-utf8.sh +lbu include /etc/rc.conf + +echo "Early monitoring tools are ready:" +echo " tmux new-session -s install" +echo " htop" +echo " btop" +echo " tail -f $INSTALL_LOG" + +mkdir -p /etc/modprobe.d /etc/zfs +cat >/etc/modprobe.d/zfs.conf </dev/null || true + udevadm settle + wait_for_block_device "$SWAP_PART_DEVICE" || + die "Raw swap partition did not appear as $SWAP_PART_DEVICE." + + PART_SWAP_KERNEL_DEVICE="$(readlink -f "$SWAP_PART_DEVICE")" + [ -b "$PART_SWAP_KERNEL_DEVICE" ] || + die "Could not resolve raw swap partition $SWAP_PART_DEVICE." + + [ "$(blkid_value "$SWAP_PART_DEVICE" TYPE)" = "swap" ] || + die "$SWAP_PART_DEVICE is not formatted as Linux swap." + + if ! awk -v device="$PART_SWAP_KERNEL_DEVICE" \ + 'NR > 1 && $1 == device { found=1 } END { exit !found }' /proc/swaps + then + swapon -p "$SWAP_PRI_PART" "$SWAP_PART_DEVICE" + fi + + cat >/etc/init.d/nenjim-part-swap < 1 && \$1 == device { found=1 } END { exit !found }' /proc/swaps + then + eend 0 + return 0 + fi + + swapon -p "\$priority" "\$device_link" + eend \$? +} + +stop() +{ + ebegin "Disabling raw partition swap" + device="\$(readlink -f "\$device_link")" + + if [ ! -b "\$device" ] || ! awk -v device="\$device" \ + 'NR > 1 && \$1 == device { found=1 } END { exit !found }' /proc/swaps + then + eend 0 + return 0 + fi + + swapoff "\$device" + eend \$? +} +EOF + + chmod 0755 /etc/init.d/nenjim-part-swap + enable_service nenjim-part-swap boot + lbu include /etc/init.d/nenjim-part-swap +else + echo "=== Raw partition swap is disabled ===" +fi + + +# ============================================================================== +# Start optional provisioning ZRAM before importing ZFS +# ============================================================================== + +if [ "$SWAP_SIZE_PCT_ZRAM" -gt 0 ]; then + echo + echo "=== Starting temporary $ZRAM_INSTALL_MEMORY_PERCENT% provisioning ZRAM ===" + + write_zram_config "$ZRAM_INSTALL_MEMORY_PERCENT" + enable_service zram-init boot + + if ! rc-service zram-init start; then + # Some kernels need a short moment after loading the zram module before + # /dev/zram0 becomes available. + sleep 1 + rc-service zram-init start + fi + + awk -v device="$ZRAM_SWAP_DEVICE" \ + 'NR > 1 && $1 == device { found=1 } END { exit !found }' /proc/swaps || + die "$ZRAM_SWAP_DEVICE did not become active." + + zramctl +else + echo + echo "=== ZRAM swap is disabled ===" +fi + + +# ============================================================================== +# Import rpool without enabling any newer feature flags +# ============================================================================== + +echo +echo "=== Importing $POOL_NAME from partition 3 ===" + +if ! zpool list -H -o name "$POOL_NAME" >/dev/null 2>&1; then + zpool import -N -d /dev "$POOL_NAME" +fi + +[ "$(zpool get -H -o value compatibility "$POOL_NAME")" = "$POOL_COMPATIBILITY" ] || + die "$POOL_NAME does not use compatibility=$POOL_COMPATIBILITY." + +zpool set cachefile=/etc/zfs/zpool.cache "$POOL_NAME" + +for dataset_mount in \ + "$POOL_NAME/home:/home" \ + "$POOL_NAME/software:$SOFTWARE_ROOT" \ + "$POOL_NAME/sysop:$SYSOP_DATA_ROOT" +do + dataset="${dataset_mount%%:*}" + expected_mount="${dataset_mount#*:}" + actual_mount="$(zfs get -H -o value mountpoint "$dataset")" + + [ "$actual_mount" = "$expected_mount" ] || + die "$dataset has mountpoint $actual_mount, expected $expected_mount." +done + +zfs mount -a + +for mount_path in /home "$SOFTWARE_ROOT" "$SYSOP_DATA_ROOT"; do + is_mounted "$mount_path" || die "ZFS dataset is not mounted at $mount_path." +done + + +# ============================================================================== +# Create, format and activate optional ZFS swap using the Pi's page size +# ============================================================================== + +if [ "$SWAP_SIZE_MIB_ZFS" -gt 0 ]; then + echo + echo "=== Configuring ${SWAP_SIZE_MIB_ZFS} MiB ZFS swap zvol ===" + + PAGE_SIZE="$(getconf PAGESIZE)" + + case "$PAGE_SIZE" in + ''|*[!0-9]*) die "Could not determine the Raspberry Pi page size." ;; + esac + + if ! zfs list -H "$SWAP_ZVOL" >/dev/null 2>&1; then + zfs create \ + -V "${SWAP_SIZE_MIB_ZFS}M" \ + -b "$PAGE_SIZE" \ + -o volmode=dev \ + -o compression=off \ + -o dedup=off \ + -o sync=always \ + -o logbias=throughput \ + -o primarycache=metadata \ + -o secondarycache=none \ + -o com.sun:auto-snapshot=false \ + "$SWAP_ZVOL" + else + [ "$(zfs get -Hp -o value volsize "$SWAP_ZVOL")" = "$SWAP_SIZE_BYTES" ] || + die "$SWAP_ZVOL is not exactly ${SWAP_SIZE_MIB_ZFS} MiB." + + [ "$(zfs get -Hp -o value volblocksize "$SWAP_ZVOL")" = "$PAGE_SIZE" ] || + die "$SWAP_ZVOL volblocksize does not match getconf PAGESIZE ($PAGE_SIZE)." + fi + + zfs set volmode=dev "$SWAP_ZVOL" + + udevadm trigger --subsystem-match=block 2>/dev/null || true + udevadm settle + wait_for_block_device "$SWAP_DEVICE" || + die "Swap zvol device did not appear: $SWAP_DEVICE" + + SWAP_KERNEL_DEVICE="$(readlink -f "$SWAP_DEVICE")" + [ -b "$SWAP_KERNEL_DEVICE" ] || + die "Could not resolve $SWAP_DEVICE to its kernel block device." + + if [ "$(blkid_value "$SWAP_DEVICE" TYPE)" != "swap" ]; then + mkswap "$SWAP_DEVICE" + fi + + if ! awk -v device="$SWAP_KERNEL_DEVICE" \ + 'NR > 1 && $1 == device { found=1 } END { exit !found }' /proc/swaps + then + swapon -p "$SWAP_PRI_ZFS" "$SWAP_DEVICE" + fi + + cat >/etc/init.d/nenjim-zfs-swap </dev/null 2>&1; then + eend 1 "Required zvol does not exist: \$zvol" + return 1 + fi + + udevadm trigger --subsystem-match=block >/dev/null 2>&1 || true + + attempt=0 + while [ "\$attempt" -lt "\$wait_seconds" ]; do + [ -b "\$device_link" ] && break + sleep 1 + attempt=\$((attempt + 1)) + done + + device="\$(readlink -f "\$device_link")" + + [ -b "\$device" ] || { + eend 1 "Could not resolve \$device_link" + return 1 + } + + if awk -v device="\$device" \\ + 'NR > 1 && \$1 == device { found=1 } END { exit !found }' /proc/swaps + then + eend 0 + return 0 + fi + + swapon -p "\$priority" "\$device_link" + eend \$? +} + +stop() +{ + ebegin "Disabling ZFS swap" + + device="\$(readlink -f "\$device_link")" + + if [ ! -b "\$device" ]; then + eend 0 + return 0 + fi + + if ! awk -v device="\$device" \\ + 'NR > 1 && \$1 == device { found=1 } END { exit !found }' /proc/swaps + then + eend 0 + return 0 + fi + + swapoff "\$device" + eend \$? +} +EOF + + chmod 0755 /etc/init.d/nenjim-zfs-swap + enable_service nenjim-zfs-swap boot + lbu include /etc/init.d/nenjim-zfs-swap +else + echo + echo "=== ZFS swap is disabled ===" + if zfs list -H "$SWAP_ZVOL" >/dev/null 2>&1; then + echo "An existing $SWAP_ZVOL is left intact but is not enabled as swap." + fi +fi + +echo "Active swap devices before runtime package installation:" +cat /proc/swaps +if [ "$SWAP_SIZE_PCT_ZRAM" -gt 0 ]; then + zramctl +fi + +ROOT_FILESYSTEM="$(awk '$2 == "/" { print $3; exit }' /proc/mounts)" +if [ "$ROOT_FILESYSTEM" = "tmpfs" ]; then + mount -o "remount,size=$DISKLESS_ROOT_SIZE" / +fi + +grep -E 'MemTotal|SwapTotal|SwapFree' /proc/meminfo + + +# ============================================================================== +# Install the remaining runtime packages with swap active +# ============================================================================== + +echo +echo "=== Installing runtime packages ===" + +# shellcheck disable=SC2086 +apk add $RUNTIME_PACKAGES + +# A blanket apk upgrade is deliberately avoided. Diskless kernel and modloop +# updates belong in flash.sh so kernel and zfs-rpi remain matched. + + +# ============================================================================== +# Wi-Fi, DHCP and SSH persistence +# ============================================================================== + +echo +echo "=== Configuring Wi-Fi and DHCP ===" + +mkdir -p /etc/wpa_supplicant /etc/conf.d /etc/network /etc/modprobe.d + +# Load the Raspberry Pi Broadcom Wi-Fi driver deterministically. The headless +# bootstrap uses these stability options only on first boot and then removes +# its temporary file, so keep the final system's own copy. +grep -qxF brcmfmac /etc/modules 2>/dev/null || echo brcmfmac >>/etc/modules + +cat >/etc/modprobe.d/nenjim-brcmfmac.conf <<'EOF' +options brcmfmac roamoff=1 feature_disable=0x282000 +EOF + +cat >/etc/init.d/nenjim-wifi-ready </dev/null 2>&1 || true + + attempt=0 + while [ "\$attempt" -lt "\$wait_seconds" ]; do + if [ -d "/sys/class/net/\$interface" ]; then + eend 0 + return 0 + fi + + sleep 1 + attempt=\$((attempt + 1)) + done + + eerror "Wi-Fi interface \$interface did not appear after \$wait_seconds seconds" + dmesg | grep -Ei 'brcm|firmware|wlan' | tail -20 >&2 || true + eend 1 + return 1 +} +EOF + +chmod 0755 /etc/init.d/nenjim-wifi-ready +enable_service nenjim-wifi-ready boot +lbu include /etc/init.d/nenjim-wifi-ready + +install \ + -m 0600 \ + "$BOOT_MEDIA/wpa_supplicant.conf" \ + /etc/wpa_supplicant/wpa_supplicant.conf + +cat >/etc/conf.d/wpa_supplicant <>/etc/conf.d/networking + +cat >/etc/network/interfaces </dev/null || echo zfs >>/etc/modules + +enable_service modules boot +enable_service zfs-import boot +enable_service zfs-mount boot +enable_service zfs-zed default + + +# ============================================================================== +# Mount rpool/virt as ext4 before Docker and containerd +# ============================================================================== + +echo +echo "=== Configuring shared Docker/containerd storage ===" + +zfs list -H "$VIRT_ZVOL" >/dev/null 2>&1 || + die "Missing zvol created by prepare-storage.sh: $VIRT_ZVOL" + +[ "$(zfs get -Hp -o value volsize "$VIRT_ZVOL")" = "$VIRT_SIZE_BYTES" ] || + die "$VIRT_ZVOL does not match VIRT_ZVOL_SIZE_MIB=$VIRT_ZVOL_SIZE_MIB." + +# This is a host block device, not a guest disk with partitions. Persist an +# explicit visible device mode instead of relying on the platform default. +zfs set volmode=dev "$VIRT_ZVOL" + +udevadm trigger --subsystem-match=block 2>/dev/null || true +udevadm settle +wait_for_block_device "$VIRT_DEVICE" || + die "Virt zvol device did not appear: $VIRT_DEVICE" + +FSCK_STATUS=0 +e2fsck -p "$VIRT_DEVICE" || FSCK_STATUS=$? +[ "$FSCK_STATUS" -le 1 ] || + die "e2fsck could not repair $VIRT_DEVICE (status $FSCK_STATUS)." + +mkdir -p "$VIRT_MOUNT" +if ! is_mounted "$VIRT_MOUNT"; then + mount -t ext4 -o rw,noatime "$VIRT_DEVICE" "$VIRT_MOUNT" +fi + +mkdir -p "$VIRT_MOUNT/docker" "$VIRT_MOUNT/containerd" +chmod 0711 "$VIRT_MOUNT/docker" "$VIRT_MOUNT/containerd" + +if [ -f /etc/fstab ]; then + sed -i '\|[[:space:]]/media/virt[[:space:]]|d' /etc/fstab +fi +echo "$VIRT_DEVICE $VIRT_MOUNT ext4 noauto,rw,noatime 0 0" >>/etc/fstab + +cat >/etc/init.d/nenjim-virt </dev/null 2>&1; then + eerror "ZFS pool \$pool was not imported by zfs-import" + eend 1 + return 1 + fi + + if ! zfs list -H -o name "\$zvol" >/dev/null 2>&1; then + eerror "Required zvol does not exist: \$zvol" + eend 1 + return 1 + fi + + # ZFS creates the kernel zvol asynchronously and udev subsequently creates + # /dev/zvol//. Wait for the exact link with a finite timeout so + # a udev problem cannot block the boot forever. + udevadm trigger --subsystem-match=block >/dev/null 2>&1 || true + + attempt=0 + while [ "\$attempt" -lt "\$wait_seconds" ]; do + [ -b "\$device" ] && break + sleep 1 + attempt=\$((attempt + 1)) + done + + if [ ! -b "\$device" ]; then + eerror "Zvol device \$device did not appear after \$wait_seconds seconds" + ls -l /dev/zd* /dev/zvol/"\$pool" 2>/dev/null >&2 || true + eend 1 + return 1 + fi + + e2fsck -p "\$device" + status=\$? + [ "\$status" -le 1 ] || { + eend "\$status" + return "\$status" + } + + mount -t ext4 -o rw,noatime "\$device" "\$mountpoint" + eend \$? +} + +stop() +{ + ebegin "Unmounting Nenjim virt storage" + umount "\$mountpoint" + eend \$? +} +EOF + +chmod 0755 /etc/init.d/nenjim-virt +enable_service nenjim-virt boot +lbu include /etc/init.d/nenjim-virt + + +# ============================================================================== +# User accounts and persistent homes +# ============================================================================== + +echo +echo "=== Configuring sysop and nenjim accounts ===" + +if [ -f "$BOOT_MEDIA/sysop-preserve.tar.gz" ]; then + echo "Restoring the preserved /sysop rescue home" + tar -xzf "$BOOT_MEDIA/sysop-preserve.tar.gz" -C / +fi + +mkdir -p /sysop "/home/$NENJIM_USER" "$SYSOP_DATA_ROOT" + +create_or_validate_user "$SYSOP_USER" "$SYSOP_UID" /sysop /bin/bash +create_or_validate_user "$NENJIM_USER" "$NENJIM_UID" "/home/$NENJIM_USER" /bin/bash + +addgroup "$SYSOP_USER" wheel 2>/dev/null || true +addgroup "$SYSOP_USER" docker 2>/dev/null || true +delgroup "$NENJIM_USER" wheel 2>/dev/null || true +delgroup "$NENJIM_USER" docker 2>/dev/null || true + +echo "$SYSOP_USER:$SYSOP_PASS" | chpasswd +echo "$NENJIM_USER:$NENJIM_PASS" | chpasswd + +chown "$SYSOP_USER:$SYSOP_USER" /sysop +chmod 0750 /sysop + +chown "$NENJIM_USER:$NENJIM_USER" "/home/$NENJIM_USER" +chmod 0750 "/home/$NENJIM_USER" + +cat >/etc/profile.d/nenjim-java.sh <<'PROFILE' +export JAVA_HOME=/usr/local/software/jre-25 +export PATH="${JAVA_HOME}/bin:${PATH}" +PROFILE +chmod 0644 /etc/profile.d/nenjim-java.sh + +mkdir -p /var/log +rm -f /var/log/NenjimHub-Install.log +ln -s "$INSTALL_LOG" /var/log/NenjimHub-Install.log + +lbu include /sysop +lbu include /var/log/NenjimHub-Install.log + + +# ============================================================================== +# Password-required doas for sysop only +# ============================================================================== + +echo +echo "=== Configuring password-required doas for sysop ===" + +mkdir -p /etc/doas.d +rm -f /etc/doas.d/20-wheel.conf /etc/doas.d/nenjim.conf /etc/doas.d/99-nenjim.conf + +cat >/etc/doas.d/99-sysop.conf </etc/ssh/sshd_config.new + + mv /etc/ssh/sshd_config.new /etc/ssh/sshd_config +fi + +cat >/etc/ssh/sshd_config.d/10-nenjim.conf </etc/init.d/nenjim-clean-login <<'OPENRC' +#!/sbin/openrc-run + +description="Remove the default Alpine MOTD" + +depend() +{ + before sshd +} + +start() +{ + rm -f /etc/motd +} +OPENRC + +chmod 0755 /etc/init.d/nenjim-clean-login +enable_service nenjim-clean-login boot +lbu include /etc/init.d/nenjim-clean-login + +# Alpine starts its physical console login directly from BusyBox inittab; it is +# not an OpenRC service. Ensure tty1 has a respawning login prompt and persist +# the inittab entry in the diskless overlay. Root remains locked, while sysop +# can use the physical console for recovery. +if ! grep -Eq '^[[:space:]]*tty1::respawn:/sbin/(a?getty|busybox[[:space:]]+getty)([[:space:]]|$)' /etc/inittab; then + echo 'tty1::respawn:/sbin/getty 38400 tty1' >>/etc/inittab +fi +lbu include /etc/inittab + + +# ============================================================================== +# Docker and standalone containerd on rpool/virt +# ============================================================================== + +echo +echo "=== Configuring Docker and containerd ===" + +mkdir -p /var/lib /etc/conf.d + +ensure_empty_path_can_be_linked /var/lib/docker +ensure_empty_path_can_be_linked /var/lib/containerd + +ln -s "$VIRT_MOUNT/docker" /var/lib/docker +ln -s "$VIRT_MOUNT/containerd" /var/lib/containerd + +for service in docker containerd; do + conf_file="/etc/conf.d/$service" + touch "$conf_file" + sed -i '/^[[:space:]]*rc_need=/d' "$conf_file" + echo 'rc_need="nenjim-virt"' >>"$conf_file" +done + +lbu include /var/lib/docker +lbu include /var/lib/containerd +lbu include /etc/conf.d/docker +lbu include /etc/conf.d/containerd + +enable_service cgroups default +enable_service containerd default +enable_service docker default + +# Docker group access is root-equivalent. It is granted only to sysop; nenjim +# deliberately remains outside both wheel and docker. + + +# ============================================================================== +# Verify Java installed by prepare-storage.sh +# ============================================================================== + +echo +echo "=== Verifying Java on rpool/software ===" + +[ -x "$SOFTWARE_ROOT/jre-25/bin/java" ] || + die "Java is missing from $SOFTWARE_ROOT; rerun prepare-storage.sh on a fresh card." + +"$SOFTWARE_ROOT/jre-25/bin/java" -version + + +# ============================================================================== +# Build/install ZeroTier; state lives in sysop's ZFS-backed area +# ============================================================================== + +echo +echo "=== Running the ZeroTier build/install module ===" +echo "nenjim can follow this with: tail -f /var/log/NenjimHub-Install.log" + +SOFTWARE_ROOT="$SOFTWARE_ROOT" \ +SYSOP_DATA_ROOT="$SYSOP_DATA_ROOT" \ +ZEROTIER_VERSION="$ZEROTIER_VERSION" \ +REQUIRED_SWAP_MIB="$ZEROTIER_REQUIRED_SWAP_MIB" \ +ROOTFS_BUILD_SIZE="$DISKLESS_ROOT_SIZE" \ +MAKE_JOBS="$ZEROTIER_MAKE_JOBS" \ +ENABLE_SSO="$ZEROTIER_ENABLE_SSO" \ +PREBUILT_BINARY="$BOOT_MEDIA/$ZEROTIER_PREBUILT_BINARY" \ + /bin/bash "$BOOT_MEDIA/installZeroTier.sh" + + +# ============================================================================== +# zrepl binary, no-op configuration and daemon +# ============================================================================== + +echo +echo "=== Configuring zrepl with an empty job list ===" + +ZREPL_BINARY="$SOFTWARE_ROOT/zrepl/current/zrepl" +[ -x "$ZREPL_BINARY" ] || + die "zrepl is missing from $SOFTWARE_ROOT; rerun prepare-storage.sh on a fresh card." + +mkdir -p /usr/local/bin "$ZREPL_CONFIG_DIRECTORY" /etc/zrepl /sysop + +rm -f /usr/local/bin/zrepl +ln -s "$ZREPL_BINARY" /usr/local/bin/zrepl + +if [ -e /sysop/zrepl ] || [ -L /sysop/zrepl ]; then + if [ -L /sysop/zrepl ]; then + rm -f /sysop/zrepl + elif [ -d /sysop/zrepl ] && + ! find /sysop/zrepl -mindepth 1 -print -quit | grep -q . + then + rmdir /sysop/zrepl + else + die "Refusing to replace non-empty /sysop/zrepl." + fi +fi + +ln -s "$ZREPL_CONFIG_DIRECTORY" /sysop/zrepl + +if [ ! -f "$ZREPL_CONFIG_FILE" ]; then + cat >"$ZREPL_CONFIG_FILE" <<'ZREPL' +jobs: [] +ZREPL +fi + +chown -R "$SYSOP_USER:$SYSOP_USER" "$ZREPL_CONFIG_DIRECTORY" +chmod 0750 "$ZREPL_CONFIG_DIRECTORY" +chmod 0640 "$ZREPL_CONFIG_FILE" + +rm -f /etc/zrepl/zrepl.yml +ln -s /sysop/zrepl/zrepl.yml /etc/zrepl/zrepl.yml + +/usr/local/bin/zrepl configcheck + +cat >/etc/init.d/zrepl <<'OPENRC' +#!/sbin/openrc-run + +description="zrepl ZFS replication daemon" + +command="/usr/local/bin/zrepl" +command_args="daemon" +command_background="yes" +pidfile="/run/${RC_SVCNAME}.pid" + +depend() +{ + need zfs-mount + use net logger + after networking +} + +start_pre() +{ + mkdir -p /run/zrepl + chmod 0700 /run/zrepl + "$command" configcheck +} +OPENRC + +chmod 0755 /etc/init.d/zrepl +enable_service zrepl default +lbu include /etc/init.d/zrepl + +echo "Starting zrepl with the no-op configuration" +rc-service zrepl start + +ZREPL_CONTROL_SOCKET="/var/run/zrepl/control" +ZREPL_READY=0 +attempt=0 +while [ "$attempt" -lt 30 ]; do + if [ -S "$ZREPL_CONTROL_SOCKET" ]; then + ZREPL_READY=1 + break + fi + + sleep 1 + attempt=$((attempt + 1)) +done + +[ "$ZREPL_READY" -eq 1 ] || + die "zrepl started, but its control socket did not appear: $ZREPL_CONTROL_SOCKET" + +lbu include /usr/local/bin/zrepl +lbu include /sysop/zrepl + + +# ============================================================================== +# Synchronize the diskless package cache and verify the finished design +# ============================================================================== + +echo +echo "=== Synchronizing the persistent APK cache ===" + +# setup-alpine may have installed eudev before /etc/apk/cache pointed at the +# boot partition. `apk add` then sees it as already installed and does not +# download its APK. A full cache sync is therefore required so every installed +# package can be reconstructed in RAM before networking exists on the next boot. +apk cache -v sync + +echo +echo "=== Verifying packages, pool, users and services ===" + +for package in \ + bash btop containerd containerd-openrc doas docker-cli docker-engine \ + docker-openrc e2fsprogs eudev eudev-openrc git htop kitty-terminfo \ + musl-locales openssh-server tmux \ + udev-init-scripts udev-init-scripts-openrc \ + wpa_supplicant zfs zfs-openrc zfs-scripts zfs-udev \ + zram-init zram-init-openrc +do + apk info -e "$package" >/dev/null || + die "Required package is not installed: $package" +done + +for service in udev udev-trigger udev-settle +do + [ -x "/etc/init.d/$service" ] || + die "Required udev service is missing after APK cache sync: $service" +done + +zpool status "$POOL_NAME" +zfs list -r "$POOL_NAME" + +is_mounted /home || die "$POOL_NAME/home is not mounted." +is_mounted "$SOFTWARE_ROOT" || die "$POOL_NAME/software is not mounted." +is_mounted "$SYSOP_DATA_ROOT" || die "$POOL_NAME/sysop is not mounted." +is_mounted "$VIRT_MOUNT" || die "$POOL_NAME/virt ext4 is not mounted." + +if [ "$SWAP_SIZE_MIB_PART" -gt 0 ]; then + PART_SWAP_KERNEL_DEVICE="$(readlink -f "$SWAP_PART_DEVICE")" + [ -b "$PART_SWAP_KERNEL_DEVICE" ] || + die "Could not resolve $SWAP_PART_DEVICE during final verification." + + ACTIVE_PART_PRIORITY="$( + awk -v device="$PART_SWAP_KERNEL_DEVICE" \ + 'NR > 1 && $1 == device { print $5; exit }' /proc/swaps + )" + + [ "$ACTIVE_PART_PRIORITY" = "$SWAP_PRI_PART" ] || + die "$SWAP_PART_DEVICE has priority ${ACTIVE_PART_PRIORITY:-inactive}, expected $SWAP_PRI_PART." +fi + +if [ "$SWAP_SIZE_PCT_ZRAM" -gt 0 ]; then + ACTIVE_ZRAM_PRIORITY="$( + awk -v device="$ZRAM_SWAP_DEVICE" \ + 'NR > 1 && $1 == device { print $5; exit }' /proc/swaps + )" + + [ "$ACTIVE_ZRAM_PRIORITY" = "$SWAP_PRI_ZRAM" ] || + die "$ZRAM_SWAP_DEVICE has priority ${ACTIVE_ZRAM_PRIORITY:-inactive}, expected $SWAP_PRI_ZRAM." +fi + +if [ "$SWAP_SIZE_MIB_ZFS" -gt 0 ]; then + SWAP_KERNEL_DEVICE="$(readlink -f "$SWAP_DEVICE")" + [ -b "$SWAP_KERNEL_DEVICE" ] || + die "Could not resolve $SWAP_DEVICE during final verification." + + ACTIVE_ZFS_PRIORITY="$( + awk -v device="$SWAP_KERNEL_DEVICE" \ + 'NR > 1 && $1 == device { print $5; exit }' /proc/swaps + )" + + [ "$ACTIVE_ZFS_PRIORITY" = "$SWAP_PRI_ZFS" ] || + die "$SWAP_DEVICE has priority ${ACTIVE_ZFS_PRIORITY:-inactive}, expected $SWAP_PRI_ZFS." +fi + +[ "$(readlink /var/lib/docker)" = "$VIRT_MOUNT/docker" ] || + die "Docker data is not linked to rpool/virt." + +[ "$(readlink /var/lib/containerd)" = "$VIRT_MOUNT/containerd" ] || + die "containerd data is not linked to rpool/virt." + +[ "$(readlink /sysop/zerotier)" = "$SYSOP_DATA_ROOT/zerotier" ] || + die "ZeroTier state is not linked into /sysop." + +[ "$(readlink /sysop/zrepl)" = "$ZREPL_CONFIG_DIRECTORY" ] || + die "zrepl configuration is not linked into /sysop." + +ROOT_PASSWORD_FIELD="$(awk -F: '$1 == "root" { print $2; exit }' /etc/shadow)" +case "$ROOT_PASSWORD_FIELD" in + '!'*|'*'*) echo "Root password login is locked." ;; + *) die "Root password login is not locked." ;; +esac + +REQUIRED_SERVICES=" +cgroups +containerd +docker +nenjim-clean-login +nenjim-virt +nenjim-wifi-ready +networking +sshd +wpa_supplicant +zerotier-one +zfs-import +zfs-mount +zfs-zed +zrepl +" + +if [ "$SWAP_SIZE_MIB_PART" -gt 0 ]; then + REQUIRED_SERVICES="$REQUIRED_SERVICES nenjim-part-swap" +fi +if [ "$SWAP_SIZE_MIB_ZFS" -gt 0 ]; then + REQUIRED_SERVICES="$REQUIRED_SERVICES nenjim-zfs-swap" +fi +if [ "$SWAP_SIZE_PCT_ZRAM" -gt 0 ]; then + REQUIRED_SERVICES="$REQUIRED_SERVICES zram-init" +fi + +for service in $REQUIRED_SERVICES +do + [ -x "/etc/init.d/$service" ] || + die "Required OpenRC service file is missing: /etc/init.d/$service" + rc-update show | grep -Eq "^[[:space:]]*$service[[:space:]]+\\|" || + die "Required boot service is not enabled: $service" +done + +/usr/sbin/zerotier-one -v +/usr/local/bin/zrepl configcheck +/usr/local/bin/zrepl version + + +# ============================================================================== +# Persist the requested final ZRAM size for normal boots +# ============================================================================== + +if [ "$SWAP_SIZE_PCT_ZRAM" -gt 0 ]; then + echo + echo "=== Persisting final $SWAP_SIZE_PCT_ZRAM% ZRAM policy ===" + + # The active provisioning ZRAM remains larger until the imminent reboot. + # OpenRC reads the requested final policy on the next boot. + write_zram_config "$SWAP_SIZE_PCT_ZRAM" +else + rm -f /etc/conf.d/zram-init +fi + + +# ============================================================================== +# Commit the final APKoVL +# ============================================================================== + +echo +echo "=== Creating final APKoVL ===" + +lbu status -a || true +lbu commit -d +sync + + +# ============================================================================== +# Remove one-time bootstrap inputs +# ============================================================================== + +echo +echo "=== Removing one-time bootstrap files ===" + +rm -f "$BOOT_MEDIA/headless.apkovl.tar.gz" +rm -f "$BOOT_MEDIA/unattended.sh" +rm -f "$BOOT_MEDIA/installZeroTier.sh" +rm -f "$BOOT_MEDIA/$ZEROTIER_PREBUILT_BINARY" +rm -f "$BOOT_MEDIA/$BUILD_TIME_FILE_NAME" +rm -f "$BOOT_MEDIA/wpa_supplicant.conf" +rm -f "$BOOT_MEDIA/sysop-preserve.tar.gz" + +sync + + +# ============================================================================== +# Finished +# ============================================================================== + +echo +echo "============================================================================" +echo "Installation complete." +echo +echo "sysop/sysop is the rescue, password-doas and Docker administrator account." +echo "nenjim/nenjim cannot use doas, su or Docker directly." +echo "Root password login and SSH root login are disabled." +echo "ZeroTier is enabled but a fresh installation is not joined to a network." +echo "Docker, containerd, OpenZFS and no-op zrepl are enabled for next boot." +echo "The system will now reboot." +echo "============================================================================" + +logger -t nenjim-install "Installation complete - rebooting" +INSTALL_END_UPTIME_SECONDS="$(awk '{ print int($1); exit }' /proc/uptime)" +INSTALL_DURATION_SECONDS=$((INSTALL_END_UPTIME_SECONDS - INSTALL_START_UPTIME_SECONDS)) +INSTALL_DURATION="$(format_duration "$INSTALL_DURATION_SECONDS")" +echo "Total installation time: $INSTALL_DURATION (HH:MM:SS, $INSTALL_DURATION_SECONDS seconds)." +sync +reboot