#!/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