aboutsummaryrefslogtreecommitdiff
path: root/installer/archangel
diff options
context:
space:
mode:
Diffstat (limited to 'installer/archangel')
-rwxr-xr-xinstaller/archangel1688
1 files changed, 1688 insertions, 0 deletions
diff --git a/installer/archangel b/installer/archangel
new file mode 100755
index 0000000..023115e
--- /dev/null
+++ b/installer/archangel
@@ -0,0 +1,1688 @@
+#!/usr/bin/env bash
+# archangel - Arch Linux Installer with Snapshot-Based Recovery
+# Craig Jennings (github.com/cjennings)
+#
+# Installs Arch Linux on ZFS or Btrfs root with snapshot support.
+# Choose your filesystem: ZFS (native encryption) or Btrfs (GRUB snapshots).
+#
+# Features:
+# - Filesystem choice: ZFS or Btrfs
+# - All questions asked upfront, then unattended installation
+# - Optional WiFi configuration with connection test
+# - Optional ZFS native encryption (passphrase required at boot)
+# - Pre-pacman snapshots for safe upgrades
+# - Genesis snapshot for factory reset
+#
+# UNATTENDED MODE:
+# Use --config-file /path/to/archangel.conf for automated installs.
+# Config file must be explicitly specified to prevent accidental disk wipes.
+# See /root/archangel.conf.example for a template with all options.
+
+set -e
+
+#############################
+# Source Library Functions
+#############################
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/lib/common.sh"
+source "$SCRIPT_DIR/lib/config.sh"
+source "$SCRIPT_DIR/lib/disk.sh"
+source "$SCRIPT_DIR/lib/zfs.sh"
+source "$SCRIPT_DIR/lib/btrfs.sh"
+
+#############################
+# Configuration
+#############################
+
+# Filesystem selection (zfs or btrfs)
+FILESYSTEM="zfs" # Default to ZFS, can be changed interactively or via config
+
+# These will be set interactively
+HOSTNAME=""
+TIMEZONE=""
+LOCALE="en_US.UTF-8"
+KEYMAP="us"
+ROOT_PASSWORD=""
+ZFS_PASSPHRASE=""
+WIFI_SSID=""
+WIFI_PASSWORD=""
+
+# ZFS Configuration
+POOL_NAME="zroot"
+COMPRESSION="zstd"
+ASHIFT="12" # 4K sectors (use 13 for 8K)
+
+# Multi-disk RAID support
+SELECTED_DISKS=() # Array of selected disk paths (/dev/sda, /dev/sdb, ...)
+ZFS_PARTS=() # Array of ZFS partition paths
+EFI_PARTS=() # Array of EFI partition paths
+RAID_LEVEL="" # "", "mirror", "raidz1", "raidz2", "raidz3"
+ENABLE_SSH="yes" # Enable SSH with root login (default yes for headless)
+NO_ENCRYPT="no" # Skip ZFS encryption (for testing only)
+
+# Logging
+LOGFILE="/tmp/archangel-$(date +'%Y-%m-%d-%H-%M-%S').log"
+exec > >(tee -a "$LOGFILE") 2>&1
+
+# Log header with timestamp
+echo ""
+echo "================================================================================"
+echo "archangel started @ $(date +'%Y-%m-%d %H:%M:%S')"
+echo "================================================================================"
+echo ""
+
+# Output functions now in lib/common.sh
+# Config functions now in lib/config.sh
+
+#############################
+# Pre-flight Checks
+#############################
+
+preflight_checks() {
+ require_root
+}
+
+# Filesystem-specific preflight (called after filesystem is selected)
+filesystem_preflight() {
+ if [[ "$FILESYSTEM" == "zfs" ]]; then
+ zfs_preflight
+ elif [[ "$FILESYSTEM" == "btrfs" ]]; then
+ btrfs_preflight
+ fi
+}
+
+#############################
+# Phase 1: Gather All Input
+#############################
+
+gather_input() {
+ if [[ "$UNATTENDED" == true ]]; then
+ # Validate required config values
+ if [[ -z "$HOSTNAME" ]]; then error "Config missing required: HOSTNAME"; fi
+ if [[ -z "$TIMEZONE" ]]; then error "Config missing required: TIMEZONE"; fi
+ if [[ -z "$ROOT_PASSWORD" ]]; then error "Config missing required: ROOT_PASSWORD"; fi
+ if [[ ${#SELECTED_DISKS[@]} -eq 0 ]]; then error "Config missing required: DISKS"; fi
+
+ # Set defaults for optional values
+ [[ -z "$FILESYSTEM" ]] && FILESYSTEM="zfs" || true
+ [[ -z "$LOCALE" ]] && LOCALE="en_US.UTF-8" || true
+ [[ -z "$KEYMAP" ]] && KEYMAP="us" || true
+ [[ -z "$ENABLE_SSH" ]] && ENABLE_SSH="yes" || true
+
+ # ZFS-specific validation
+ if [[ "$FILESYSTEM" == "zfs" ]]; then
+ if [[ "$NO_ENCRYPT" != "yes" && -z "$ZFS_PASSPHRASE" ]]; then
+ error "Config missing required: ZFS_PASSPHRASE (or set NO_ENCRYPT=yes)"
+ fi
+ fi
+
+ # Btrfs-specific validation
+ if [[ "$FILESYSTEM" == "btrfs" ]]; then
+ if [[ "$NO_ENCRYPT" != "yes" && -z "$LUKS_PASSPHRASE" ]]; then
+ error "Config missing required: LUKS_PASSPHRASE (or set NO_ENCRYPT=yes)"
+ fi
+ fi
+
+ # Validate filesystem choice
+ if [[ "$FILESYSTEM" != "zfs" && "$FILESYSTEM" != "btrfs" ]]; then
+ error "Invalid FILESYSTEM: $FILESYSTEM (must be 'zfs' or 'btrfs')"
+ fi
+
+ # Determine RAID level if not specified
+ if [[ -z "$RAID_LEVEL" && ${#SELECTED_DISKS[@]} -gt 1 ]]; then
+ RAID_LEVEL="mirror"
+ info "Defaulting to mirror for ${#SELECTED_DISKS[@]} disks"
+ fi
+
+ info "Configuration loaded:"
+ info " Filesystem: $FILESYSTEM"
+ info " Hostname: $HOSTNAME"
+ info " Timezone: $TIMEZONE"
+ info " Locale: $LOCALE"
+ info " Keymap: $KEYMAP"
+ info " Disks: ${SELECTED_DISKS[*]}"
+ [[ -n "$RAID_LEVEL" ]] && info " RAID: $RAID_LEVEL"
+ info " SSH: $ENABLE_SSH"
+ [[ "$NO_ENCRYPT" == "yes" ]] && warn " Encryption: DISABLED (testing mode)"
+ [[ -n "$WIFI_SSID" ]] && info " WiFi: $WIFI_SSID"
+ return 0
+ fi
+
+ echo ""
+ echo "╔═══════════════════════════════════════════════════════════════╗"
+ echo "║ Archangel ║"
+ echo "║ Arch Linux with Snapshot-Based Recovery ║"
+ echo "╚═══════════════════════════════════════════════════════════════╝"
+ echo ""
+ info "Answer all questions now. Installation will run unattended afterward."
+ echo ""
+
+ select_filesystem
+ get_hostname
+ get_timezone
+ get_locale
+ get_keymap
+ get_disks
+ get_raid_level
+ get_wifi
+
+ # Encryption handling (filesystem-specific)
+ if [[ "$FILESYSTEM" == "zfs" ]]; then
+ get_encryption_choice
+ [[ "$NO_ENCRYPT" != "yes" ]] && get_zfs_passphrase
+ elif [[ "$FILESYSTEM" == "btrfs" ]]; then
+ get_btrfs_encryption_choice
+ [[ "$NO_ENCRYPT" != "yes" ]] && get_luks_passphrase
+ fi
+
+ get_root_password
+ get_ssh_config
+ show_summary
+}
+
+get_hostname() {
+ step "Hostname"
+ prompt "Enter hostname for this system:"
+ read -p "> " HOSTNAME
+ while [[ -z "$HOSTNAME" || ! "$HOSTNAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$ ]]; do
+ warn "Invalid hostname. Use letters, numbers, and hyphens (no spaces)."
+ read -p "> " HOSTNAME
+ done
+}
+
+get_timezone() {
+ step "Timezone"
+ echo ""
+ info "Type to search, ENTER to select"
+ echo ""
+
+ TIMEZONE=$(find /usr/share/zoneinfo -type f ! -path '*/posix/*' ! -path '*/right/*' \
+ | sed 's|/usr/share/zoneinfo/||' \
+ | sort \
+ | fzf --height=20 --layout=reverse --border \
+ --header="Select Timezone" \
+ --preview='echo "Timezone: {}"; echo ""; TZ={} date "+Current time: %Y-%m-%d %H:%M:%S %Z"' \
+ --preview-window=right:40%)
+
+ if [[ -z "$TIMEZONE" ]]; then
+ error "No timezone selected!"
+ fi
+ info "Selected: $TIMEZONE"
+}
+
+get_locale() {
+ step "Locale"
+ echo ""
+ info "Type to search, ENTER to select"
+ echo ""
+
+ # Get available locales from locale.gen
+ LOCALE=$(grep -E "^#?[a-z]" /etc/locale.gen \
+ | sed 's/^#//' \
+ | awk '{print $1}' \
+ | sort -u \
+ | fzf --height=20 --layout=reverse --border \
+ --header="Select Locale (type to search, e.g. 'de_DE', 'fr_FR')" \
+ --preview='
+ loc={}
+ echo "Locale: $loc"
+ echo ""
+ lang=${loc%%_*}
+ country=${loc#*_}
+ country=${country%%.*}
+ echo "Language: $lang"
+ echo "Country: $country"
+ echo ""
+ echo "Example formats:"
+ echo " Date: $(LC_ALL={} date "+%x" 2>/dev/null || echo "N/A")"
+ echo " Currency: $(LC_ALL={} locale currency_symbol 2>/dev/null || echo "N/A")"
+ ' \
+ --preview-window=right:45%)
+
+ if [[ -z "$LOCALE" ]]; then
+ error "No locale selected!"
+ fi
+ info "Selected: $LOCALE"
+}
+
+get_keymap() {
+ step "Keyboard Layout"
+ echo ""
+ info "Type to search, ENTER to select"
+ echo ""
+
+ KEYMAP=$(localectl list-keymaps \
+ | fzf --height=20 --layout=reverse --border \
+ --header="Select Keyboard Layout (type to search)" \
+ --preview='
+ echo "Keymap: {}"
+ echo ""
+ echo "This will set your console keyboard layout."
+ echo ""
+ echo "Common layouts:"
+ echo " us - US English (QWERTY)"
+ echo " uk - UK English"
+ echo " de - German (QWERTZ)"
+ echo " fr - French (AZERTY)"
+ echo " dvorak - Dvorak"
+ ' \
+ --preview-window=right:45%)
+
+ if [[ -z "$KEYMAP" ]]; then
+ error "No keymap selected!"
+ fi
+ info "Selected: $KEYMAP"
+}
+
+get_disks() {
+ step "Disk Selection"
+ echo ""
+ info "TAB to select multiple disks, ENTER to confirm"
+ echo ""
+
+ # Get list of available disks with info
+ local disk_list
+ disk_list=$(lsblk -d -n -o NAME,SIZE,TYPE | awk '$3=="disk"{printf "/dev/%-8s %8s\n", $1, $2}')
+
+ if [[ -z "$disk_list" ]]; then
+ error "No disks found!"
+ fi
+
+ # Use fzf for multi-select with disk details preview
+ local selected
+ selected=$(echo "$disk_list" \
+ | fzf --multi --height=20 --layout=reverse --border \
+ --header="Select Disks (TAB to toggle, ENTER to confirm)" \
+ --preview='
+ disk=$(echo {} | awk "{print \$1}")
+ echo "Disk: $disk"
+ echo ""
+ echo "Details:"
+ lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT "$disk" 2>/dev/null
+ echo ""
+ echo "Disk info:"
+ udevadm info --query=property "$disk" 2>/dev/null | grep -E "ID_MODEL=|ID_SERIAL=" | sed "s/^/ /"
+ ' \
+ --preview-window=right:50%)
+
+ if [[ -z "$selected" ]]; then
+ error "No disks selected!"
+ fi
+
+ # Parse selected disks
+ SELECTED_DISKS=()
+ while IFS= read -r line; do
+ local disk
+ disk=$(echo "$line" | awk '{print $1}')
+ SELECTED_DISKS+=("$disk")
+ done <<< "$selected"
+
+ echo ""
+ warn "Selected ${#SELECTED_DISKS[@]} disk(s):"
+ for disk in "${SELECTED_DISKS[@]}"; do
+ local size
+ size=$(lsblk -d -n -o SIZE "$disk" | tr -d ' ')
+ echo " - $disk ($size)"
+ done
+ echo ""
+
+ read -p "This will DESTROY all data on these disks. Type 'yes' to continue: " confirm
+ if [[ "$confirm" != "yes" ]]; then
+ error "Aborted by user"
+ fi
+}
+
+get_raid_level() {
+ local disk_count=${#SELECTED_DISKS[@]}
+
+ if [[ $disk_count -eq 1 ]]; then
+ RAID_LEVEL=""
+ info "Single disk selected - no RAID"
+ return
+ fi
+
+ step "RAID Configuration"
+ echo ""
+ info "Select RAID level (ENTER to confirm)"
+ echo ""
+
+ # Calculate total raw size for preview
+ local total_bytes=0
+ local smallest_bytes=0
+ for disk in "${SELECTED_DISKS[@]}"; do
+ local bytes
+ bytes=$(lsblk -b -d -n -o SIZE "$disk")
+ total_bytes=$((total_bytes + bytes))
+ if [[ $smallest_bytes -eq 0 ]] || [[ $bytes -lt $smallest_bytes ]]; then
+ smallest_bytes=$bytes
+ fi
+ done
+ local total_gb=$((total_bytes / 1073741824))
+ local smallest_gb=$((smallest_bytes / 1073741824))
+
+ # Build options based on disk count
+ local options="mirror\nstripe"
+ [[ $disk_count -ge 3 ]] && options+="\nraidz1"
+ [[ $disk_count -ge 4 ]] && options+="\nraidz2"
+ [[ $disk_count -ge 5 ]] && options+="\nraidz3"
+
+ # Export variables for preview subshell
+ export RAID_DISK_COUNT=$disk_count
+ export RAID_TOTAL_GB=$total_gb
+ export RAID_SMALLEST_GB=$smallest_gb
+
+ RAID_LEVEL=$(echo -e "$options" \
+ | fzf --height=20 --layout=reverse --border \
+ --header="Select RAID Level ($disk_count disks, ${total_gb}GB total)" \
+ --preview='
+ n=$RAID_DISK_COUNT
+ total=$RAID_TOTAL_GB
+ small=$RAID_SMALLEST_GB
+
+ case {} in
+ mirror)
+ echo "MIRROR"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "All disks contain identical copies of data."
+ echo "Maximum redundancy - can survive loss of"
+ echo "all disks except one."
+ echo ""
+ echo "Redundancy: Can lose $((n-1)) of $n disks"
+ echo "Usable space: ~${small}GB (smallest disk)"
+ echo "Read speed: Fast (parallel reads)"
+ echo "Write speed: Normal"
+ echo ""
+ echo "Best for:"
+ echo " - Boot drives"
+ echo " - Critical data"
+ echo " - Maximum safety"
+ ;;
+ stripe)
+ echo "STRIPE (RAID0)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "WARNING: NO REDUNDANCY!"
+ echo "Data is striped across all disks."
+ echo "ANY disk failure = ALL data lost!"
+ echo ""
+ echo "Redundancy: NONE"
+ echo "Usable space: ~${total}GB (all disks)"
+ echo "Read speed: Very fast"
+ echo "Write speed: Very fast"
+ echo ""
+ echo "Best for:"
+ echo " - Scratch/temp space"
+ echo " - Replaceable data"
+ echo " - Maximum performance"
+ ;;
+ raidz1)
+ usable=$(( (n-1) * small ))
+ echo "RAIDZ1 (Single Parity)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "One disk worth of parity distributed"
+ echo "across all disks."
+ echo ""
+ echo "Redundancy: Can lose 1 disk"
+ echo "Usable space: ~${usable}GB ($((n-1)) of $n disks)"
+ echo "Read speed: Fast"
+ echo "Write speed: Good"
+ echo ""
+ echo "Best for:"
+ echo " - General storage"
+ echo " - Good balance of space/safety"
+ ;;
+ raidz2)
+ usable=$(( (n-2) * small ))
+ echo "RAIDZ2 (Double Parity)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Two disks worth of parity distributed"
+ echo "across all disks."
+ echo ""
+ echo "Redundancy: Can lose 2 disks"
+ echo "Usable space: ~${usable}GB ($((n-2)) of $n disks)"
+ echo "Read speed: Fast"
+ echo "Write speed: Good"
+ echo ""
+ echo "Best for:"
+ echo " - Large arrays (5+ disks)"
+ echo " - Important data"
+ ;;
+ raidz3)
+ usable=$(( (n-3) * small ))
+ echo "RAIDZ3 (Triple Parity)"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "Three disks worth of parity distributed"
+ echo "across all disks."
+ echo ""
+ echo "Redundancy: Can lose 3 disks"
+ echo "Usable space: ~${usable}GB ($((n-3)) of $n disks)"
+ echo "Read speed: Fast"
+ echo "Write speed: Moderate"
+ echo ""
+ echo "Best for:"
+ echo " - Very large arrays (8+ disks)"
+ echo " - Archival storage"
+ ;;
+ esac
+ ' \
+ --preview-window=right:50%)
+
+ # Clean up exported variables
+ unset RAID_DISK_COUNT RAID_TOTAL_GB RAID_SMALLEST_GB
+
+ if [[ -z "$RAID_LEVEL" ]]; then
+ error "No RAID level selected!"
+ fi
+ info "Selected: $RAID_LEVEL"
+}
+
+get_wifi() {
+ step "WiFi Configuration (Optional)"
+ echo ""
+ prompt "Do you want to configure WiFi? [Y/n]:"
+ read -p "> " configure_wifi
+
+ if [[ ! "$configure_wifi" =~ ^[Nn]$ ]]; then
+ # Ensure NetworkManager is running
+ systemctl start NetworkManager 2>/dev/null || true
+ sleep 2
+
+ echo ""
+ info "Scanning for networks..."
+ nmcli device wifi rescan 2>/dev/null || true
+ sleep 3
+
+ # Get list of networks for fzf
+ local networks
+ networks=$(nmcli -t -f SSID,SIGNAL,SECURITY device wifi list | grep -v '^$' | sort -t: -k2 -rn | uniq)
+
+ if [[ -z "$networks" ]]; then
+ warn "No WiFi networks found."
+ info "Skipping WiFi configuration."
+ return
+ fi
+
+ echo ""
+ info "Select network (ENTER to confirm, ESC to skip)"
+ echo ""
+
+ # Use fzf to select network
+ WIFI_SSID=$(echo "$networks" \
+ | fzf --height=15 --layout=reverse --border \
+ --header="Select WiFi Network" \
+ --delimiter=':' \
+ --with-nth=1 \
+ --preview='
+ IFS=":" read -r ssid signal security <<< {}
+ echo "Network: $ssid"
+ echo ""
+ echo "Signal: ${signal}%"
+ echo "Security: ${security:-Open}"
+ echo ""
+ if [[ -z "$security" ]]; then
+ echo "WARNING: Open network (no encryption)"
+ fi
+ ' \
+ --preview-window=right:40% \
+ | cut -d: -f1)
+
+ if [[ -z "$WIFI_SSID" ]]; then
+ info "Skipping WiFi configuration."
+ return
+ fi
+
+ prompt "Enter WiFi password for '$WIFI_SSID':"
+ read -s -p "> " WIFI_PASSWORD
+ echo ""
+
+ # Test the connection
+ info "Testing WiFi connection..."
+ if nmcli device wifi connect "$WIFI_SSID" password "$WIFI_PASSWORD" 2>/dev/null; then
+ info "WiFi connection successful!"
+ else
+ warn "WiFi connection failed. You can configure it manually after installation."
+ WIFI_SSID=""
+ WIFI_PASSWORD=""
+ fi
+ else
+ info "Skipping WiFi configuration."
+ fi
+}
+
+get_btrfs_encryption_choice() {
+ step "Btrfs Encryption (LUKS)"
+
+ echo ""
+ echo "LUKS encryption protects your data at rest."
+ echo "You'll need to enter a passphrase at each boot."
+ echo ""
+
+ prompt "Enable LUKS encryption? [Y/n]:"
+ read -p "> " encrypt_choice
+
+ if [[ "$encrypt_choice" =~ ^[Nn]$ ]]; then
+ NO_ENCRYPT="yes"
+ warn "Encryption DISABLED - data will not be encrypted at rest"
+ else
+ NO_ENCRYPT="no"
+ info "LUKS encryption enabled - you'll set a passphrase next"
+ fi
+}
+
+get_luks_passphrase() {
+ step "LUKS Encryption Passphrase"
+
+ echo ""
+ echo "Choose a strong passphrase for disk encryption."
+ echo "You'll need this passphrase every time you boot."
+ echo ""
+ echo "IMPORTANT: If you forget this passphrase, your data is UNRECOVERABLE!"
+ echo ""
+
+ while true; do
+ prompt "Enter LUKS encryption passphrase:"
+ read -rs LUKS_PASSPHRASE
+ echo ""
+
+ prompt "Confirm passphrase:"
+ read -rs confirm
+ echo ""
+
+ if [[ "$LUKS_PASSPHRASE" == "$confirm" ]]; then
+ if [[ ${#LUKS_PASSPHRASE} -lt 8 ]]; then
+ warn "Passphrase should be at least 8 characters. Try again."
+ else
+ info "Passphrase confirmed."
+ break
+ fi
+ else
+ warn "Passphrases don't match. Try again."
+ fi
+ done
+}
+
+get_encryption_choice() {
+ step "ZFS Encryption"
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "ZFS native encryption protects your data at rest."
+ echo ""
+ echo " - Passphrase required at every boot"
+ echo " - If forgotten, data is UNRECOVERABLE"
+ echo " - Recommended for laptops and sensitive data"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ prompt "Enable ZFS encryption? [Y/n]:"
+ read -p "> " encrypt_choice
+
+ if [[ "$encrypt_choice" =~ ^[Nn]$ ]]; then
+ NO_ENCRYPT="yes"
+ warn "Encryption DISABLED - data will not be encrypted at rest"
+ else
+ NO_ENCRYPT="no"
+ info "Encryption enabled - you'll set a passphrase next"
+ fi
+}
+
+get_zfs_passphrase() {
+ step "ZFS Encryption Passphrase"
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "This passphrase will be required at EVERY boot."
+ echo ""
+ echo "Requirements:"
+ echo " - Use a strong, memorable passphrase"
+ echo " - If forgotten, your data is UNRECOVERABLE"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ while true; do
+ prompt "Enter ZFS encryption passphrase:"
+ read -s -p "> " ZFS_PASSPHRASE
+ echo ""
+
+ prompt "Confirm passphrase:"
+ read -s -p "> " confirm_pass
+ echo ""
+
+ if [[ "$ZFS_PASSPHRASE" == "$confirm_pass" ]]; then
+ if [[ ${#ZFS_PASSPHRASE} -lt 8 ]]; then
+ warn "Passphrase should be at least 8 characters."
+ continue
+ fi
+ break
+ else
+ warn "Passphrases do not match. Try again."
+ fi
+ done
+}
+
+get_root_password() {
+ step "Root Password"
+ echo ""
+
+ while true; do
+ prompt "Enter root password:"
+ read -s -p "> " ROOT_PASSWORD
+ echo ""
+
+ prompt "Confirm root password:"
+ read -s -p "> " confirm_pass
+ echo ""
+
+ if [[ "$ROOT_PASSWORD" == "$confirm_pass" ]]; then
+ break
+ else
+ warn "Passwords do not match. Try again."
+ fi
+ done
+}
+
+get_ssh_config() {
+ step "SSH Configuration"
+ echo ""
+ info "SSH enables remote access after installation."
+ info "Recommended for headless servers. Harden after install (key auth, fail2ban)."
+ echo ""
+ prompt "Enable SSH with root login? [Y/n]:"
+ read -p "> " ssh_choice
+
+ if [[ "$ssh_choice" =~ ^[Nn]$ ]]; then
+ ENABLE_SSH="no"
+ info "SSH will not be enabled."
+ else
+ ENABLE_SSH="yes"
+ info "SSH will be enabled with root password login."
+ warn "Remember to harden SSH after install (key auth, fail2ban)!"
+ fi
+}
+
+show_summary() {
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Configuration Summary:"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo " Filesystem: $FILESYSTEM"
+ echo " Hostname: $HOSTNAME"
+ echo " Timezone: $TIMEZONE"
+ echo " Locale: $LOCALE"
+ echo " Keymap: $KEYMAP"
+ echo " Disks: ${#SELECTED_DISKS[@]} disk(s)"
+ for disk in "${SELECTED_DISKS[@]}"; do
+ local size
+ size=$(lsblk -d -n -o SIZE "$disk" | tr -d ' ')
+ echo " - $disk ($size)"
+ done
+ echo " RAID Level: ${RAID_LEVEL:-single (no RAID)}"
+ echo " WiFi: ${WIFI_SSID:-Not configured}"
+ echo " SSH: ${ENABLE_SSH:-yes} (root login)"
+ if [[ "$FILESYSTEM" == "zfs" ]]; then
+ if [[ "$NO_ENCRYPT" == "yes" ]]; then
+ echo " ZFS Pool: $POOL_NAME (NOT encrypted)"
+ else
+ echo " ZFS Pool: $POOL_NAME (encrypted)"
+ fi
+ echo " Boot: ZFSBootMenu on all disks (redundant)"
+ else
+ if [[ "$NO_ENCRYPT" == "yes" ]]; then
+ echo " Encryption: None"
+ else
+ echo " Encryption: LUKS2"
+ fi
+ echo " Boot: GRUB + grub-btrfs (snapshot boot)"
+ fi
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ read -p "Press Enter to begin installation, or Ctrl+C to abort..."
+}
+
+#############################
+# Phase 2: Installation
+#############################
+
+partition_disks() {
+ step "Partitioning ${#SELECTED_DISKS[@]} disk(s)"
+
+ EFI_PARTS=()
+ ZFS_PARTS=()
+
+ for disk in "${SELECTED_DISKS[@]}"; do
+ info "Partitioning $disk..."
+
+ # Wipe existing signatures
+ wipefs -af "$disk"
+ sgdisk --zap-all "$disk"
+
+ # Create partitions: 512M EFI + rest for ZFS
+ # EFI only needs to hold ZFSBootMenu binary (~64MB) - 512MB is plenty
+ sgdisk -n 1:0:+512M -t 1:ef00 -c 1:"EFI" "$disk"
+ sgdisk -n 2:0:0 -t 2:bf00 -c 2:"ZFS" "$disk"
+
+ # Determine partition names (handle nvme/mmcblk naming)
+ local efi_part zfs_part
+ if [[ "$disk" == *"nvme"* ]] || [[ "$disk" == *"mmcblk"* ]]; then
+ efi_part="${disk}p1"
+ zfs_part="${disk}p2"
+ else
+ efi_part="${disk}1"
+ zfs_part="${disk}2"
+ fi
+
+ EFI_PARTS+=("$efi_part")
+ ZFS_PARTS+=("$zfs_part")
+
+ sleep 1
+ partprobe "$disk"
+ done
+
+ sleep 2
+
+ # Format all EFI partitions
+ for i in "${!EFI_PARTS[@]}"; do
+ info "Formatting EFI partition ${EFI_PARTS[$i]}..."
+ mkfs.fat -F32 -n "EFI$i" "${EFI_PARTS[$i]}"
+ done
+
+ info "Partitioning complete. Created ${#EFI_PARTS[@]} EFI and ${#ZFS_PARTS[@]} ZFS partitions."
+}
+
+create_zfs_pool() {
+ step "Creating ZFS Pool with Native Encryption"
+
+ if zpool list "$POOL_NAME" &>/dev/null; then
+ warn "Pool $POOL_NAME already exists. Destroying..."
+ zpool destroy -f "$POOL_NAME"
+ fi
+
+ # Build pool configuration based on RAID level
+ local pool_config
+ if [[ "$RAID_LEVEL" == "stripe" ]]; then
+ # Stripe: just list devices without a vdev type (RAID0 equivalent)
+ pool_config="${ZFS_PARTS[*]}"
+ info "Creating striped pool with ${#ZFS_PARTS[@]} disks (NO redundancy)..."
+ warn "Data loss will occur if ANY disk fails!"
+ elif [[ -n "$RAID_LEVEL" ]]; then
+ pool_config="$RAID_LEVEL ${ZFS_PARTS[*]}"
+ info "Creating $RAID_LEVEL pool with ${#ZFS_PARTS[@]} disks..."
+ else
+ pool_config="${ZFS_PARTS[0]}"
+ info "Creating single-disk pool..."
+ fi
+
+ # Create pool (with or without encryption)
+ # Note: We use zfs-import-scan at boot which doesn't require a cachefile
+ if [[ "$NO_ENCRYPT" == "yes" ]]; then
+ warn "Creating pool WITHOUT encryption (testing mode)"
+ zpool create -f \
+ -o ashift="$ASHIFT" \
+ -o autotrim=on \
+ -O acltype=posixacl \
+ -O atime=off \
+ -O canmount=off \
+ -O compression="$COMPRESSION" \
+ -O dnodesize=auto \
+ -O normalization=formD \
+ -O relatime=on \
+ -O xattr=sa \
+ -O mountpoint=none \
+ -R /mnt \
+ "$POOL_NAME" $pool_config
+ else
+ echo "$ZFS_PASSPHRASE" | zpool create -f \
+ -o ashift="$ASHIFT" \
+ -o autotrim=on \
+ -O acltype=posixacl \
+ -O atime=off \
+ -O canmount=off \
+ -O compression="$COMPRESSION" \
+ -O dnodesize=auto \
+ -O normalization=formD \
+ -O relatime=on \
+ -O xattr=sa \
+ -O encryption=aes-256-gcm \
+ -O keyformat=passphrase \
+ -O keylocation=prompt \
+ -O mountpoint=none \
+ -R /mnt \
+ "$POOL_NAME" $pool_config
+ fi
+
+ info "ZFS pool created successfully."
+ zpool status "$POOL_NAME"
+}
+
+create_datasets() {
+ step "Creating ZFS Datasets"
+
+ # Root dataset container
+ zfs create -o mountpoint=none -o canmount=off "$POOL_NAME/ROOT"
+
+ # Main root filesystem
+ # Reserve 20% of pool or 20G max to prevent pool from filling completely
+ local pool_size_bytes
+ pool_size_bytes=$(zpool get -Hp size "$POOL_NAME" | awk '{print $3}')
+ local pool_size_gb=$((pool_size_bytes / 1024 / 1024 / 1024))
+ local reserve_gb=$((pool_size_gb / 5)) # 20%
+ [[ $reserve_gb -gt 20 ]] && reserve_gb=20
+ [[ $reserve_gb -lt 5 ]] && reserve_gb=5
+
+ zfs create -o mountpoint=/ -o canmount=noauto -o reservation=${reserve_gb}G "$POOL_NAME/ROOT/default"
+ zfs mount "$POOL_NAME/ROOT/default"
+
+ # Home (archsetup will create user subdataset)
+ zfs create -o mountpoint=/home "$POOL_NAME/home"
+ zfs create -o mountpoint=/root "$POOL_NAME/home/root"
+
+ # Media - compression off for already-compressed files
+ zfs create -o mountpoint=/media -o compression=off "$POOL_NAME/media"
+
+ # VMs - 64K recordsize for VM disk images
+ zfs create -o mountpoint=/vms -o recordsize=64K "$POOL_NAME/vms"
+
+ # Var datasets
+ zfs create -o mountpoint=/var -o canmount=off "$POOL_NAME/var"
+ zfs create -o mountpoint=/var/log "$POOL_NAME/var/log"
+ zfs create -o mountpoint=/var/cache "$POOL_NAME/var/cache"
+ zfs create -o mountpoint=/var/lib -o canmount=off "$POOL_NAME/var/lib"
+ zfs create -o mountpoint=/var/lib/pacman "$POOL_NAME/var/lib/pacman"
+ zfs create -o mountpoint=/var/lib/docker "$POOL_NAME/var/lib/docker"
+
+ # Temp directories - excluded from snapshots
+ zfs create -o mountpoint=/var/tmp -o com.sun:auto-snapshot=false "$POOL_NAME/var/tmp"
+ zfs create -o mountpoint=/tmp -o com.sun:auto-snapshot=false "$POOL_NAME/tmp"
+ chmod 1777 /mnt/tmp /mnt/var/tmp
+
+ info "Datasets created:"
+ zfs list -r "$POOL_NAME" -o name,mountpoint,compression
+}
+
+mount_efi() {
+ step "Mounting EFI Partition"
+ # EFI partition mounts at /efi - only holds ZFSBootMenu binary
+ # /boot is a directory on ZFS root (kernels live on ZFS for snapshot safety)
+ mkdir -p /mnt/efi
+ mount "${EFI_PARTS[0]}" /mnt/efi
+ info "EFI partition ${EFI_PARTS[0]} mounted at /mnt/efi"
+}
+
+install_base() {
+ step "Installing Base System"
+
+ info "Updating pacman keys..."
+ pacman-key --init
+ pacman-key --populate archlinux
+
+ # Add archzfs repo to pacman.conf for pacstrap
+ # SigLevel=Never: pacstrap -K creates empty keyring where key import fails;
+ # repo is explicitly added and served over HTTPS, GPG adds no real value here
+ if ! grep -q "\[archzfs\]" /etc/pacman.conf; then
+ cat >> /etc/pacman.conf << 'EOF'
+
+[archzfs]
+Server = https://archzfs.com/$repo/$arch
+SigLevel = Never
+EOF
+ fi
+
+ info "Installing base packages (this takes a while)..."
+ info "ZFS will be built from source via DKMS - this ensures kernel compatibility."
+ # Use yes to auto-select defaults for provider prompts
+ yes "" | pacstrap -K /mnt \
+ base \
+ base-devel \
+ linux-lts \
+ linux-lts-headers \
+ linux-firmware \
+ zfs-dkms \
+ zfs-utils \
+ efibootmgr \
+ networkmanager \
+ avahi \
+ nss-mdns \
+ openssh \
+ git \
+ vim \
+ sudo \
+ zsh \
+ nodejs \
+ npm \
+ ttf-dejavu \
+ fzf \
+ wget \
+ wireless-regdb
+
+ info "Base system installed."
+}
+
+install_base_btrfs() {
+ step "Installing Base System (Btrfs)"
+
+ info "Updating pacman keys..."
+ pacman-key --init
+ pacman-key --populate archlinux
+
+ info "Installing base packages (this takes a while)..."
+ yes "" | pacstrap -K /mnt \
+ base \
+ base-devel \
+ linux-lts \
+ linux-lts-headers \
+ linux-firmware \
+ btrfs-progs \
+ grub \
+ grub-btrfs \
+ efibootmgr \
+ snapper \
+ snap-pac \
+ networkmanager \
+ avahi \
+ nss-mdns \
+ openssh \
+ git \
+ vim \
+ sudo \
+ zsh \
+ nodejs \
+ npm \
+ ttf-dejavu \
+ fzf \
+ wget \
+ wireless-regdb
+
+ info "Base system installed."
+}
+
+configure_system() {
+ step "Configuring System"
+
+ # fstab (only for EFI - /boot is on ZFS root)
+ info "Generating fstab..."
+ echo "# /efi - EFI System Partition (ZFSBootMenu binary)" > /mnt/etc/fstab
+ echo "UUID=$(blkid -s UUID -o value "${EFI_PARTS[0]}") /efi vfat defaults,noatime 0 2" >> /mnt/etc/fstab
+
+ # Timezone
+ info "Setting timezone to $TIMEZONE..."
+ arch-chroot /mnt ln -sf "/usr/share/zoneinfo/$TIMEZONE" /etc/localtime
+ arch-chroot /mnt hwclock --systohc
+
+ # Locale
+ info "Configuring locale..."
+ echo "$LOCALE UTF-8" >> /mnt/etc/locale.gen
+ arch-chroot /mnt locale-gen
+ echo "LANG=$LOCALE" > /mnt/etc/locale.conf
+
+ # Keymap
+ echo "KEYMAP=$KEYMAP" > /mnt/etc/vconsole.conf
+
+ # Hostname
+ info "Setting hostname to $HOSTNAME..."
+ echo "$HOSTNAME" > /mnt/etc/hostname
+ cat > /mnt/etc/hosts << EOF
+127.0.0.1 localhost
+::1 localhost
+127.0.1.1 $HOSTNAME.localdomain $HOSTNAME
+EOF
+
+ # Add archzfs repo (SigLevel=Never — same rationale as install_base)
+ info "Adding archzfs repository..."
+ cat >> /mnt/etc/pacman.conf << 'EOF'
+
+[archzfs]
+Server = https://archzfs.com/$repo/$arch
+SigLevel = Never
+EOF
+
+ # Configure journald for ZFS
+ # Problem: journald starts before ZFS mounts /var/log, so journal files
+ # get created in tmpfs then hidden when ZFS mounts over it.
+ # Solution: Make journal-flush wait for zfs-mount, and enable persistent storage.
+ info "Configuring journald for ZFS..."
+ mkdir -p /mnt/etc/systemd/journald.conf.d
+ cat > /mnt/etc/systemd/journald.conf.d/persistent.conf << 'EOF'
+[Journal]
+Storage=persistent
+EOF
+
+ mkdir -p /mnt/etc/systemd/system/systemd-journal-flush.service.d
+ cat > /mnt/etc/systemd/system/systemd-journal-flush.service.d/zfs.conf << 'EOF'
+[Unit]
+After=zfs-mount.service
+EOF
+
+ # Set root password
+ info "Setting root password..."
+ echo "root:$ROOT_PASSWORD" | arch-chroot /mnt chpasswd
+}
+
+configure_wifi() {
+ if [[ -n "$WIFI_SSID" ]]; then
+ step "Configuring WiFi"
+
+ # Copy NetworkManager connection from live environment
+ if [[ -d /etc/NetworkManager/system-connections ]]; then
+ mkdir -p /mnt/etc/NetworkManager/system-connections
+ cp /etc/NetworkManager/system-connections/* /mnt/etc/NetworkManager/system-connections/ 2>/dev/null || true
+ chmod 600 /mnt/etc/NetworkManager/system-connections/* 2>/dev/null || true
+ fi
+
+ info "WiFi configuration copied to installed system."
+ fi
+}
+
+configure_ssh() {
+ if [[ "$ENABLE_SSH" == "yes" ]]; then
+ step "Configuring SSH"
+
+ # Ensure sshd config allows root login with password
+ sed -i 's/^#PermitRootLogin.*/PermitRootLogin yes/' /mnt/etc/ssh/sshd_config
+ sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /mnt/etc/ssh/sshd_config
+
+ # Enable sshd service
+ arch-chroot /mnt systemctl enable sshd
+
+ info "SSH enabled with root password login."
+ warn "Harden SSH after install (key auth, fail2ban)."
+ else
+ info "SSH not enabled. Enable manually if needed."
+ fi
+}
+
+configure_initramfs() {
+ step "Configuring Initramfs for ZFS"
+
+ cp /mnt/etc/mkinitcpio.conf /mnt/etc/mkinitcpio.conf.bak
+
+ # CRITICAL: Remove archiso drop-in that overrides mkinitcpio.conf HOOKS
+ # The archiso.conf contains live ISO-specific hooks that are incompatible with ZFS
+ # If not removed, it overrides our HOOKS setting and breaks boot after kernel updates
+ if [[ -f /mnt/etc/mkinitcpio.conf.d/archiso.conf ]]; then
+ info "Removing archiso drop-in config..."
+ rm -f /mnt/etc/mkinitcpio.conf.d/archiso.conf
+ fi
+
+ # CRITICAL: Fix linux-lts preset file
+ # The preset from archiso uses archiso-specific config that breaks mkinitcpio -P
+ info "Creating proper linux-lts preset..."
+ cat > /mnt/etc/mkinitcpio.d/linux-lts.preset << 'PRESET_EOF'
+# mkinitcpio preset file for linux-lts
+
+PRESETS=(default fallback)
+
+ALL_kver="/boot/vmlinuz-linux-lts"
+
+default_image="/boot/initramfs-linux-lts.img"
+
+fallback_image="/boot/initramfs-linux-lts-fallback.img"
+fallback_options="-S autodetect"
+PRESET_EOF
+
+ # Check for AMD ISP (Image Signal Processor) firmware needs
+ # ISP is used for camera processing on AMD APUs (Strix, Strix Halo, etc.)
+ # The firmware must be in initramfs since amdgpu loads before root is mounted
+ if lspci | grep -qi "amd.*display\|amd.*vga\|radeon"; then
+ local isp_firmware
+ isp_firmware=$(ls /mnt/usr/lib/firmware/amdgpu/isp_*.bin.zst 2>/dev/null | head -1)
+ if [[ -n "$isp_firmware" ]]; then
+ # Remove /mnt prefix - config is used inside chroot where root is /
+ local chroot_path="${isp_firmware#/mnt}"
+ info "AMD APU detected with ISP firmware - adding to initramfs"
+ mkdir -p /mnt/etc/mkinitcpio.conf.d
+ cat > /mnt/etc/mkinitcpio.conf.d/amd-isp.conf << EOF
+# AMD ISP (Image Signal Processor) firmware for camera support
+# Loaded early so amdgpu can initialize ISP before root is mounted
+FILES+=($chroot_path)
+EOF
+ fi
+ fi
+
+ # Configure hooks for ZFS
+ # - Use udev (not systemd): ZFS hook is busybox-based and incompatible with systemd init
+ # - Remove autodetect: it filters modules based on live ISO hardware, not target
+ # This ensures NVMe, AHCI, and other storage drivers are always included
+ # - Remove fsck: ZFS doesn't use it, avoids confusing error messages
+ # - Add zfs: required for ZFS root boot
+ sed -i 's/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block zfs filesystems)/' /mnt/etc/mkinitcpio.conf
+
+ # Get the installed kernel version (not the running kernel)
+ local kernel_ver
+ kernel_ver=$(ls /mnt/usr/lib/modules | grep lts | head -1)
+ if [[ -z "$kernel_ver" ]]; then
+ error "Could not find LTS kernel modules"
+ fi
+ info "Installed kernel: $kernel_ver"
+
+ # Ensure kernel module dependencies are up to date after DKMS build
+ # Must specify kernel version since running kernel differs from installed kernel
+ info "Updating module dependencies..."
+ arch-chroot /mnt depmod "$kernel_ver"
+
+ # Verify ZFS module exists
+ if ! [[ -f "/mnt/usr/lib/modules/$kernel_ver/updates/dkms/zfs.ko.zst" ]]; then
+ error "ZFS module not found! DKMS build may have failed."
+ fi
+ info "ZFS module verified for kernel $kernel_ver"
+
+ info "Regenerating initramfs..."
+ arch-chroot /mnt mkinitcpio -P
+}
+
+configure_zfsbootmenu() {
+ step "Configuring ZFSBootMenu"
+
+ # Ensure hostid exists BEFORE reading it
+ # This is critical: hostid command returns a value even without /etc/hostid,
+ # but zgenhostid creates a DIFFERENT value. We must generate first, then read.
+ if [[ ! -f /etc/hostid ]]; then
+ zgenhostid
+ fi
+
+ # Now get the consistent hostid for kernel parameter
+ local host_id
+ host_id=$(hostid)
+
+ # Copy hostid to installed system (ZFS uses this for pool ownership)
+ cp /etc/hostid /mnt/etc/hostid
+
+ # Create ZFSBootMenu directory on EFI
+ mkdir -p /mnt/efi/EFI/ZBM
+
+ # Download ZFSBootMenu release EFI binary
+ # Using the bundled release which includes everything needed
+ info "Downloading ZFSBootMenu..."
+ local zbm_url="https://get.zfsbootmenu.org/efi"
+ if ! curl -fsSL -o /mnt/efi/EFI/ZBM/zfsbootmenu.efi "$zbm_url"; then
+ error "Failed to download ZFSBootMenu"
+ fi
+ info "ZFSBootMenu binary installed."
+
+ # Set kernel command line on the ROOT PARENT dataset
+ # This allows inheritance to all boot environments (future-proofing)
+ # ZFSBootMenu reads org.zfsbootmenu:commandline property
+ local cmdline="rw loglevel=3"
+
+ # Add any AMD GPU workarounds if needed (detect Strix Halo etc)
+ if lspci | grep -qi "amd.*display\|amd.*vga"; then
+ info "AMD GPU detected - adding workaround parameters"
+ cmdline="$cmdline amdgpu.pg_mask=0 amdgpu.cwsr_enable=0"
+ fi
+
+ # Set on ROOT parent so all boot environments inherit it
+ zfs set org.zfsbootmenu:commandline="$cmdline" "$POOL_NAME/ROOT"
+ info "Kernel command line set on $POOL_NAME/ROOT (inherited by children)"
+
+ # Set bootfs property - tells ZFSBootMenu which dataset to boot by default
+ zpool set bootfs="$POOL_NAME/ROOT/default" "$POOL_NAME"
+ info "Default boot filesystem set to $POOL_NAME/ROOT/default"
+
+ # Create EFI boot entries for each disk
+ # ZFSBootMenu EFI parameters (passed via --unicode):
+ # spl_hostid=0x... - Required for pool import
+ # zbm.timeout=3 - Seconds before auto-boot (-1 = always show menu)
+ # zbm.prefer=POOLNAME - Preferred pool to boot from
+ # zbm.import_policy=hostid - How to handle pool imports
+ local zbm_cmdline="spl_hostid=0x${host_id} zbm.timeout=3 zbm.prefer=${POOL_NAME} zbm.import_policy=hostid"
+
+ for i in "${!SELECTED_DISKS[@]}"; do
+ local disk="${SELECTED_DISKS[$i]}"
+ local label="ZFSBootMenu"
+ if [[ ${#SELECTED_DISKS[@]} -gt 1 ]]; then
+ label="ZFSBootMenu-disk$((i+1))"
+ fi
+
+ # Determine partition number (always 1 - first partition is EFI)
+ local part_num=1
+
+ info "Creating EFI boot entry: $label on $disk"
+ efibootmgr --create \
+ --disk "$disk" \
+ --part "$part_num" \
+ --label "$label" \
+ --loader '\EFI\ZBM\zfsbootmenu.efi' \
+ --unicode "$zbm_cmdline" \
+ --quiet
+ done
+
+ # Get the boot entry number and set as first in boot order
+ local bootnum
+ bootnum=$(efibootmgr | grep "ZFSBootMenu" | head -1 | grep -oP 'Boot\K[0-9A-F]+')
+ if [[ -n "$bootnum" ]]; then
+ # Get current boot order, prepend our entry
+ local current_order
+ current_order=$(efibootmgr | grep "BootOrder" | cut -d: -f2 | tr -d ' ')
+ efibootmgr --bootorder "$bootnum,$current_order" --quiet
+ info "ZFSBootMenu set as primary boot option"
+ fi
+
+ info "ZFSBootMenu configuration complete."
+}
+
+configure_zfs_services() {
+ step "Configuring ZFS Services"
+
+ arch-chroot /mnt systemctl enable zfs.target
+
+ # Use zfs-import-scan instead of zfs-import-cache
+ # This is the recommended method - it uses blkid to scan for pools
+ # and doesn't require a cachefile
+ # Note: ZFS package preset enables zfs-import-cache by default, so we must
+ # explicitly disable it before enabling zfs-import-scan
+ arch-chroot /mnt systemctl disable zfs-import-cache.service
+ arch-chroot /mnt systemctl enable zfs-import-scan.service
+ arch-chroot /mnt systemctl enable zfs-mount.service
+ arch-chroot /mnt systemctl enable zfs-import.target
+
+ # Note: hostid and bootfs are already set by configure_zfsbootmenu()
+
+ # Disable cachefile - we use zfs-import-scan which doesn't need it
+ # Also remove any existing cachefile since zfs-import-scan has a condition
+ # that prevents it from running if /etc/zfs/zpool.cache exists
+ zpool set cachefile=none "$POOL_NAME"
+ rm -f /mnt/etc/zfs/zpool.cache
+
+ # Enable other services
+ arch-chroot /mnt systemctl enable NetworkManager
+ arch-chroot /mnt systemctl enable avahi-daemon
+ arch-chroot /mnt systemctl enable sshd
+
+ info "ZFS services configured."
+}
+
+configure_pacman_hook() {
+ step "Configuring Pacman Snapshot Hook"
+
+ mkdir -p /mnt/etc/pacman.d/hooks
+
+ cat > /mnt/etc/pacman.d/hooks/zfs-snapshot.hook << EOF
+[Trigger]
+Operation = Upgrade
+Operation = Install
+Operation = Remove
+Type = Package
+Target = *
+
+[Action]
+Description = Creating ZFS snapshot before pacman transaction...
+When = PreTransaction
+Exec = /usr/local/bin/zfs-pre-snapshot
+EOF
+
+ cat > /mnt/usr/local/bin/zfs-pre-snapshot << 'EOF'
+#!/bin/bash
+POOL="zroot"
+DATASET="$POOL/ROOT/default"
+TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
+SNAPSHOT_NAME="pre-pacman_$TIMESTAMP"
+
+if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then
+ echo "Created snapshot: $DATASET@$SNAPSHOT_NAME"
+else
+ echo "Warning: Failed to create snapshot" >&2
+fi
+
+EOF
+
+ chmod +x /mnt/usr/local/bin/zfs-pre-snapshot
+
+ info "Pacman hook configured."
+}
+
+configure_zfs_tools() {
+ step "Installing ZFS Management Tools"
+
+ # Copy ZFS management scripts
+ cp /usr/local/bin/zfssnapshot /mnt/usr/local/bin/zfssnapshot
+ cp /usr/local/bin/zfsrollback /mnt/usr/local/bin/zfsrollback
+ chmod +x /mnt/usr/local/bin/zfssnapshot
+ chmod +x /mnt/usr/local/bin/zfsrollback
+
+ info "ZFS management scripts installed: zfssnapshot, zfsrollback"
+ info "Tip: Install sanoid for automated snapshot retention."
+}
+
+sync_efi_partitions() {
+ # Skip if only one disk
+ if [[ ${#EFI_PARTS[@]} -le 1 ]]; then
+ return
+ fi
+
+ step "Syncing EFI Partitions for Redundancy"
+
+ local temp_mount="/mnt/efi_sync"
+
+ for i in "${!EFI_PARTS[@]}"; do
+ if [[ $i -eq 0 ]]; then
+ continue # Skip primary
+ fi
+
+ local efi_part="${EFI_PARTS[$i]}"
+ info "Syncing ZFSBootMenu to EFI partition $((i+1)): $efi_part"
+
+ mkdir -p "$temp_mount"
+ mount "$efi_part" "$temp_mount"
+
+ # Copy ZFSBootMenu binary to secondary EFI partitions
+ mkdir -p "$temp_mount/EFI/ZBM"
+ cp /mnt/efi/EFI/ZBM/zfsbootmenu.efi "$temp_mount/EFI/ZBM/"
+
+ umount "$temp_mount"
+ done
+
+ rmdir "$temp_mount" 2>/dev/null || true
+ info "All EFI partitions synchronized."
+}
+
+create_genesis_snapshot() {
+ step "Creating Genesis Snapshot"
+
+ # Create recursive snapshot of entire pool
+ info "Creating snapshot ${POOL_NAME}@genesis..."
+ zfs snapshot -r "${POOL_NAME}@genesis"
+
+ # Create rollback script in /root
+ info "Installing rollback-to-genesis script..."
+ cat > /mnt/root/rollback-to-genesis << 'ROLLBACK_EOF'
+#!/bin/bash
+# rollback-to-genesis - Roll back all datasets to the genesis snapshot
+#
+# This script rolls back the entire ZFS pool to its pristine post-install state.
+# WARNING: This will destroy all changes made since installation!
+
+set -e
+
+POOL_NAME="zroot"
+
+echo "╔═══════════════════════════════════════════════════════════════╗"
+echo "║ WARNING: Full System Rollback ║"
+echo "╚═══════════════════════════════════════════════════════════════╝"
+echo ""
+echo "This will roll back ALL datasets to the genesis snapshot!"
+echo "All changes since installation will be permanently lost."
+echo ""
+
+# Show what will be rolled back
+echo "Datasets to roll back:"
+zfs list -r -t snapshot -o name "${POOL_NAME}" 2>/dev/null | grep "@genesis" | while read snap; do
+ dataset="${snap%@genesis}"
+ echo " - $dataset"
+done
+echo ""
+
+read -p "Type 'ROLLBACK' to confirm: " confirm
+if [[ "$confirm" != "ROLLBACK" ]]; then
+ echo "Aborted."
+ exit 1
+fi
+
+echo ""
+echo "Rolling back to genesis..."
+
+# Roll back each dataset (must do in reverse order for dependencies)
+zfs list -r -H -o name "${POOL_NAME}" | tac | while read dataset; do
+ if zfs list -t snapshot "${dataset}@genesis" &>/dev/null; then
+ echo " Rolling back: $dataset"
+ zfs rollback -r "${dataset}@genesis"
+ fi
+done
+
+echo ""
+echo "Rollback complete!"
+echo "Reboot to complete the process: reboot"
+ROLLBACK_EOF
+
+ chmod +x /mnt/root/rollback-to-genesis
+ info "Genesis snapshot created. Rollback script: /root/rollback-to-genesis"
+}
+
+cleanup() {
+ step "Cleaning Up"
+
+ # Clear sensitive variables
+ ROOT_PASSWORD=""
+ ZFS_PASSPHRASE=""
+
+ info "Unmounting filesystems..."
+ umount /mnt/efi 2>/dev/null || true
+
+ info "Exporting ZFS pool..."
+ zpool export "$POOL_NAME"
+
+ info "Cleanup complete."
+}
+
+print_summary() {
+ echo ""
+ echo "╔═══════════════════════════════════════════════════════════════╗"
+ echo "║ Installation Complete! ║"
+ echo "╚═══════════════════════════════════════════════════════════════╝"
+ echo ""
+ echo "System Configuration:"
+ echo " Hostname: $HOSTNAME"
+ echo " Timezone: $TIMEZONE"
+ if [[ "$NO_ENCRYPT" == "yes" ]]; then
+ echo " ZFS Pool: $POOL_NAME (not encrypted)"
+ else
+ echo " ZFS Pool: $POOL_NAME (encrypted)"
+ fi
+ echo ""
+ echo "ZFSBootMenu Features:"
+ echo " - Boot from any snapshot (Ctrl+D at boot menu)"
+ echo " - Genesis snapshot: pristine post-install state"
+ echo " - Pre-pacman snapshots for safe upgrades"
+ echo " - Install sanoid/syncoid for automated retention"
+ echo ""
+ echo "Boot Menu Keys (at ZFSBootMenu):"
+ echo " Enter - Boot selected environment"
+ echo " e - Edit kernel command line"
+ echo " Ctrl+D - Show snapshot selector"
+ echo " Ctrl+R - Recovery shell"
+ echo ""
+ echo "Useful Commands:"
+ echo " List snapshots: zfs list -t snapshot"
+ echo " Manual snapshot: zfs snapshot zroot/home@my-backup"
+ echo " Rollback: zfs rollback zroot/home@my-backup"
+ echo " Factory reset: /root/rollback-to-genesis"
+ echo " Pool status: zpool status"
+ echo ""
+ info "Installation log: $LOGFILE"
+ echo ""
+}
+
+print_btrfs_summary() {
+ echo ""
+ echo "╔═══════════════════════════════════════════════════════════════╗"
+ echo "║ Installation Complete! ║"
+ echo "╚═══════════════════════════════════════════════════════════════╝"
+ echo ""
+ echo "System Configuration:"
+ echo " Hostname: $HOSTNAME"
+ echo " Timezone: $TIMEZONE"
+ echo " Filesystem: Btrfs"
+ if [[ "$NO_ENCRYPT" == "yes" ]]; then
+ echo " Encryption: None"
+ else
+ echo " Encryption: LUKS2"
+ fi
+ echo ""
+ echo "Btrfs Snapshot Features:"
+ echo " - Boot from any snapshot via GRUB menu"
+ echo " - Genesis snapshot: pristine post-install state"
+ echo " - Pre/post pacman snapshots via snap-pac"
+ echo " - Timeline snapshots: 6 hourly, 7 daily, 2 weekly, 1 monthly"
+ echo ""
+ echo "GRUB Boot Menu:"
+ echo " - Select 'Arch Linux snapshots' submenu to boot from snapshots"
+ echo " - Snapshots auto-added when created by snapper"
+ echo ""
+ echo "Useful Commands:"
+ echo " List snapshots: snapper -c root list"
+ echo " Manual snapshot: snapper -c root create -d 'description'"
+ echo " Rollback: snapper -c root rollback <number>"
+ echo " Compare: snapper -c root diff <num1>..<num2>"
+ echo ""
+ info "Installation log: $LOGFILE"
+ echo ""
+}
+
+#############################
+# Main
+#############################
+
+main() {
+ parse_args "$@"
+ preflight_checks
+ check_config
+ gather_input
+ filesystem_preflight
+
+ # Unattended installation begins
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Beginning unattended installation ($FILESYSTEM)..."
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+
+ if [[ "$FILESYSTEM" == "zfs" ]]; then
+ install_zfs
+ elif [[ "$FILESYSTEM" == "btrfs" ]]; then
+ install_btrfs
+ fi
+}
+
+#############################
+# ZFS Installation Path
+#############################
+
+install_zfs() {
+ partition_disks
+ create_zfs_pool
+ create_datasets
+ mount_efi
+ install_base
+ configure_system
+ configure_wifi
+ configure_ssh
+ configure_initramfs
+ configure_zfsbootmenu
+ configure_zfs_services
+ configure_pacman_hook
+ configure_zfs_tools
+ sync_efi_partitions
+ create_genesis_snapshot
+ cleanup
+ print_summary
+}
+
+#############################
+# Btrfs Installation Path
+#############################
+
+install_btrfs() {
+ local num_disks=${#SELECTED_DISKS[@]}
+ local btrfs_devices=()
+ local efi_parts=()
+ local root_parts=()
+
+ # Collect partition references for all disks
+ for disk in "${SELECTED_DISKS[@]}"; do
+ root_parts+=("$(get_root_partition "$disk")")
+ efi_parts+=("$(get_efi_partition "$disk")")
+ done
+
+ # Partition all disks
+ for disk in "${SELECTED_DISKS[@]}"; do
+ partition_disk "$disk"
+ done
+
+ # Format all EFI partitions
+ format_efi_partitions "${SELECTED_DISKS[@]}"
+
+ # LUKS encryption (if enabled)
+ if [[ "$NO_ENCRYPT" != "yes" ]]; then
+ if [[ $num_disks -eq 1 ]]; then
+ # Single disk LUKS
+ create_luks_container "${root_parts[0]}" "$LUKS_PASSPHRASE"
+ open_luks_container "${root_parts[0]}" "$LUKS_PASSPHRASE"
+ btrfs_devices=("/dev/mapper/$LUKS_MAPPER_NAME")
+ else
+ # Multi-disk LUKS - encrypt each partition
+ create_luks_containers "$LUKS_PASSPHRASE" "${root_parts[@]}"
+ open_luks_containers "$LUKS_PASSPHRASE" "${root_parts[@]}"
+ btrfs_devices=($(get_luks_devices $num_disks))
+ fi
+ else
+ # No encryption - use raw partitions
+ btrfs_devices=("${root_parts[@]}")
+ fi
+
+ # Create btrfs filesystem
+ if [[ $num_disks -eq 1 ]]; then
+ create_btrfs_volume "${btrfs_devices[0]}"
+ else
+ create_btrfs_volume "${btrfs_devices[@]}" --raid-level "$RAID_LEVEL"
+ fi
+
+ # Create and mount subvolumes (use first device for mount)
+ create_btrfs_subvolumes "${btrfs_devices[0]}"
+ mount_btrfs_subvolumes "${btrfs_devices[0]}"
+
+ # Mount primary EFI
+ mkdir -p /mnt/efi
+ mount "${efi_parts[0]}" /mnt/efi
+
+ # Install base system
+ install_base_btrfs
+
+ # Configure system
+ configure_system
+ configure_wifi
+ configure_ssh
+
+ # Configure encryption if enabled
+ if [[ "$NO_ENCRYPT" != "yes" ]]; then
+ setup_luks_testing_keyfile "$LUKS_PASSPHRASE" "${root_parts[@]}"
+ configure_crypttab "${root_parts[@]}"
+ configure_luks_grub "${root_parts[0]}"
+ configure_luks_initramfs
+ fi
+
+ generate_btrfs_fstab "${btrfs_devices[0]}" "${efi_parts[0]}"
+ configure_btrfs_initramfs
+
+ # GRUB installation
+ if [[ $num_disks -eq 1 ]]; then
+ configure_grub "${efi_parts[0]}"
+ else
+ # Multi-disk: install GRUB to all EFI partitions
+ configure_grub "${efi_parts[0]}"
+ install_grub_all_efi "${efi_parts[@]}"
+ create_grub_sync_hook "${efi_parts[@]}"
+ fi
+
+ configure_snapper
+ configure_btrfs_services
+ configure_btrfs_pacman_hook
+
+ # Genesis snapshot
+ create_btrfs_genesis_snapshot
+
+ # Cleanup
+ btrfs_cleanup
+ if [[ "$NO_ENCRYPT" != "yes" ]]; then
+ if [[ $num_disks -eq 1 ]]; then
+ close_luks_container
+ else
+ close_luks_containers $num_disks
+ fi
+ fi
+ print_btrfs_summary
+}
+
+trap 'error "Installation interrupted!"' INT TERM
+
+main "$@"