660 lines
29 KiB
Bash
Executable File
660 lines
29 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# arch-autoinstall.sh — automated Arch Linux base installer
|
||
#
|
||
# If /answerfile.json exists (e.g. embedded in the ISO via build.sh --preconf),
|
||
# all prompts are answered from it. Missing fields fall back to interactive prompts.
|
||
#
|
||
# Answerfile fields: drive, kernel, keymap, hostname, username, encrypt, fido2_root,
|
||
# fido2_user, run_tui (password always prompted interactively)
|
||
|
||
# -E: propagate ERR trap into functions and subshells; -e: abort on any error;
|
||
# -u: treat unset variables as errors; -o pipefail: catch failures inside pipes.
|
||
set -Eeuo pipefail
|
||
|
||
############################################
|
||
# LOGGING
|
||
############################################
|
||
LOGFILE="$HOME/arch-autoinstall.log"
|
||
{
|
||
echo
|
||
echo "############################################"
|
||
echo " Arch Auto-Install Log - Started $(date)"
|
||
echo "############################################"
|
||
echo
|
||
} >> "$LOGFILE"
|
||
# Redirect all subsequent stdout and stderr to both the terminal and the log.
|
||
# `tee -a` appends so repeated runs accumulate without overwriting; process
|
||
# substitution keeps the descriptor open for the full lifetime of the script.
|
||
exec > >(tee -a "$LOGFILE") 2>&1
|
||
|
||
############################################
|
||
# Error handler — TUI prompt to send log via croc
|
||
############################################
|
||
error_handler() {
|
||
# Capture exit code before any other command can overwrite $?.
|
||
# $LINENO is passed as $1 from the trap, so we default to '?' if missing.
|
||
local exit_code=$? line_num="${1:-?}"
|
||
echo "" >> "$LOGFILE"
|
||
echo "ERROR: installer failed at line $line_num (exit code $exit_code)" >> "$LOGFILE"
|
||
|
||
# Prefer a dialog TUI if available; fall back to plain readline on headless installs.
|
||
if command -v dialog &>/dev/null; then
|
||
if dialog --clear --title " Installer Error " \
|
||
--yesno \
|
||
"Installation failed at line $line_num (exit code: $exit_code).\n\nSend the log to another system via croc for analysis?" \
|
||
9 62; then
|
||
clear
|
||
# croc may not be present in the base live ISO — install it on demand.
|
||
if ! command -v croc &>/dev/null; then
|
||
echo "Installing croc..."
|
||
pacman -Sy --noconfirm croc 2>/dev/null || true
|
||
fi
|
||
if command -v croc &>/dev/null; then
|
||
croc send "$LOGFILE"
|
||
else
|
||
echo "croc unavailable — log is at: $LOGFILE"
|
||
fi
|
||
else
|
||
clear
|
||
echo "Log saved to: $LOGFILE"
|
||
fi
|
||
else
|
||
echo ""
|
||
echo "Installation failed at line $line_num (exit code $exit_code)."
|
||
read -rp "Send log via croc? [y/N]: " _croc_ans
|
||
# ${_croc_ans,,} lowercases the answer so "Y", "y" both match.
|
||
if [[ "${_croc_ans,,}" == "y" ]]; then
|
||
command -v croc &>/dev/null || pacman -Sy --noconfirm croc 2>/dev/null || true
|
||
croc send "$LOGFILE" || true
|
||
else
|
||
echo "Log saved to: $LOGFILE"
|
||
fi
|
||
fi
|
||
exit "$exit_code"
|
||
}
|
||
# Fire error_handler on any ERR signal, passing the current line number.
|
||
# Single quotes prevent $LINENO from being evaluated at trap-definition time.
|
||
trap 'error_handler $LINENO' ERR
|
||
|
||
############################################
|
||
# ANSWERFILE
|
||
############################################
|
||
# Allow an external override via the environment; default to /answerfile.json
|
||
# which is where build.sh --preconf embeds the file in the live ISO.
|
||
ANSWERFILE="${ANSWERFILE:-/answerfile.json}"
|
||
AF_MODE=false
|
||
[[ -f "$ANSWERFILE" ]] && AF_MODE=true
|
||
|
||
af_get() {
|
||
# af_get <jq-filter> [default]
|
||
# Reads one field from the answerfile with a jq expression.
|
||
# `// empty` returns empty string instead of "null" when the field is absent;
|
||
# the outer `|| true` prevents a jq parse error from aborting the script.
|
||
local val
|
||
val=$(jq -r "${1} // empty" "$ANSWERFILE" 2>/dev/null || true)
|
||
if [[ -z "$val" ]]; then printf '%s' "${2:-}"; else printf '%s' "$val"; fi
|
||
}
|
||
|
||
af_bool() {
|
||
# Returns YES or NO from a JSON boolean field
|
||
# `// false` provides a safe default when the key is absent entirely.
|
||
local val; val=$(jq -r "${1} // false" "$ANSWERFILE" 2>/dev/null || true)
|
||
[[ "$val" == "true" ]] && echo "YES" || echo "NO"
|
||
}
|
||
|
||
get_mac_suffix() {
|
||
# Produce a compact MAC address string (no colons) from the first non-loopback
|
||
# interface to create a unique per-machine hostname suffix at deploy time.
|
||
# The awk pattern skips "lo" by requiring the 3rd char to differ from 'o'.
|
||
local mac
|
||
mac=$(ip link show 2>/dev/null \
|
||
| awk '/^[0-9]+: [^l][^o]/{iface=1} iface && /link\/ether/{print $2; iface=0; exit}')
|
||
# Remove colons via parameter substitution: ${var//pattern/replacement}.
|
||
printf '%s' "${mac//:/}"
|
||
}
|
||
|
||
# Map drive paths to their correct partition suffix.
|
||
# NVMe (/dev/nvme0n1) and eMMC (/dev/mmcblk0) use a 'p' before the part number;
|
||
# conventional block devices (/dev/sda) do not.
|
||
part() { [[ "$1" == *nvme* || "$1" == *mmcblk* ]] && echo "${1}p${2}" || echo "${1}${2}"; }
|
||
|
||
if $AF_MODE; then
|
||
echo "Answerfile detected: $ANSWERFILE"
|
||
# jq is required to parse the answerfile; it may not be on the base live ISO.
|
||
command -v jq &>/dev/null || pacman -Sy --noconfirm jq
|
||
fi
|
||
|
||
############################################
|
||
# NETWORK CHECK
|
||
############################################
|
||
# Send one ICMP packet (-c1) with a 3-second timeout (-W3) to Cloudflare DNS.
|
||
# In AF_MODE we warn but continue — packages in pacstrap may already be cached.
|
||
if ! ping -c1 -W3 1.1.1.1 &>/dev/null; then
|
||
if $AF_MODE; then
|
||
echo "Warning: no internet connection detected — continuing in answerfile mode."
|
||
else
|
||
echo "No internet connection detected."
|
||
echo " Wired: ensure the cable is plugged in."
|
||
echo " WiFi: switch to another TTY (Alt+F2) and run: iwctl"
|
||
echo ""
|
||
read -rp "Press Enter once connected (or Ctrl+C to abort)..."
|
||
if ! ping -c1 -W3 1.1.1.1 &>/dev/null; then
|
||
echo "Warning: still offline. Packages cannot be downloaded without network."
|
||
read -rp "Continue anyway? [y/N]: " _net_ans
|
||
[[ "${_net_ans,,}" == "y" ]] || { echo "Aborted — no network."; exit 1; }
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
############################################
|
||
# KEYMAP
|
||
# To add more layouts: append "code|Display Name" to KEYMAPS
|
||
############################################
|
||
# Each entry is "keymap-code|Human-readable name"; the pipe acts as a cheap delimiter
|
||
# so both pieces of data live in a single array element without needing two arrays.
|
||
KEYMAPS=(
|
||
"us|English US"
|
||
"de|German"
|
||
)
|
||
|
||
if $AF_MODE; then
|
||
LIVE_KEYMAP=$(af_get '.keymap' 'us')
|
||
else
|
||
echo "Select keyboard layout:"
|
||
for i in "${!KEYMAPS[@]}"; do
|
||
# %%|* strips everything from the first '|' onward → the keymap code.
|
||
_km_code="${KEYMAPS[$i]%%|*}"
|
||
# ##*| strips everything up to and including the last '|' → the display name.
|
||
_km_name="${KEYMAPS[$i]##*|}"
|
||
printf " %d) %-14s (%s)\n" $((i+1)) "$_km_name" "$_km_code"
|
||
done
|
||
read -rp "Choice [1]: " _KM_IDX
|
||
# Convert 1-based user input to 0-based array index; default to 1 if blank.
|
||
_KM_IDX=$(( ${_KM_IDX:-1} - 1 ))
|
||
if (( _KM_IDX >= 0 && _KM_IDX < ${#KEYMAPS[@]} )); then
|
||
LIVE_KEYMAP="${KEYMAPS[$_KM_IDX]%%|*}"
|
||
else
|
||
# Out-of-range input silently falls back to the first entry (us).
|
||
LIVE_KEYMAP="${KEYMAPS[0]%%|*}"
|
||
fi
|
||
fi
|
||
# Apply the chosen layout to the live environment immediately so subsequent
|
||
# interactive prompts can be typed with the correct physical keyboard.
|
||
loadkeys "$LIVE_KEYMAP"
|
||
# Also export as KEYMAP so the chroot heredoc can write /etc/vconsole.conf.
|
||
KEYMAP="$LIVE_KEYMAP"
|
||
|
||
############################################
|
||
# SAFETY WARNING
|
||
############################################
|
||
if $AF_MODE; then
|
||
echo "WARNING: Automated install — all data on $(af_get '.drive' '/dev/?') will be ERASED."
|
||
# Give the operator a 5-second window to abort with Ctrl-C before destructive ops begin.
|
||
echo "Proceeding in 5 seconds... (Ctrl-C to abort)"
|
||
sleep 5
|
||
else
|
||
echo "WARNING: This will ERASE ALL DATA on the selected drive!"
|
||
read -rp "Type 'YES' to continue: " confirm
|
||
# Require the exact string "YES" (case-sensitive) to prevent accidental wipes.
|
||
[[ "$confirm" == "YES" ]] || { echo "Aborted."; exit 1; }
|
||
fi
|
||
|
||
############################################
|
||
# REQUIRED PACKAGES FOR INSTALL ENVIRONMENT
|
||
############################################
|
||
# Install tools needed by this script into the live environment before partitioning:
|
||
# parted — disk partitioning; cryptsetup — LUKS; libfido2/pam-u2f — FIDO2 enrollment.
|
||
pacman -Sy --noconfirm parted cryptsetup libfido2 pam-u2f
|
||
|
||
############################################
|
||
# DRIVE SELECTION
|
||
############################################
|
||
# Print current block device layout so the operator can confirm the target drive.
|
||
lsblk
|
||
if $AF_MODE && [[ -n "$(af_get '.drive')" ]]; then
|
||
DRIVE=$(af_get '.drive')
|
||
echo "Drive (from answerfile): $DRIVE"
|
||
else
|
||
read -rp "Enter target drive (e.g., /dev/sda): " DRIVE
|
||
fi
|
||
|
||
############################################
|
||
# USER INPUT
|
||
############################################
|
||
if $AF_MODE; then
|
||
KERNEL=$(af_get '.kernel' 'linux')
|
||
RAW_HOSTNAME=$(af_get '.hostname' '')
|
||
# Append MAC suffix to make the hostname unique when the same answerfile is
|
||
# deployed to multiple machines (e.g. a lab rollout or reinstall fleet).
|
||
if [[ -n "$RAW_HOSTNAME" ]]; then
|
||
HOSTNAME="${RAW_HOSTNAME}-$(get_mac_suffix)"
|
||
else
|
||
HOSTNAME="arch"
|
||
fi
|
||
USERNAME=$(af_get '.username' '')
|
||
ENCRYPT_DISK=$(af_bool '.encrypt')
|
||
FIDO_ROOT=$(af_bool '.fido2_root')
|
||
FIDO_USER=$(af_bool '.fido2_user')
|
||
RUN_TUI=$(af_bool '.run_tui')
|
||
echo "Kernel: $KERNEL"
|
||
echo "Hostname: $HOSTNAME"
|
||
echo "Username: $USERNAME"
|
||
echo "Encrypt: $ENCRYPT_DISK / FIDO2 root: $FIDO_ROOT / FIDO2 user: $FIDO_USER"
|
||
else
|
||
read -rp "Enter kernel package (e.g., linux, linux-lts): " KERNEL
|
||
read -rp "Enter hostname: " HOSTNAME
|
||
read -rp "Enter username: " USERNAME
|
||
read -rp "Enable disk encryption? (YES/NO): " ENCRYPT_DISK
|
||
# Default FIDO2 root to NO; only ask when encryption is active because FIDO2
|
||
# unlocking only makes sense on an encrypted root partition.
|
||
FIDO_ROOT="NO"
|
||
if [[ "$ENCRYPT_DISK" == "YES" ]]; then
|
||
read -rp "Enable FIDO2 unlocking for root partition? (YES/NO): " FIDO_ROOT
|
||
fi
|
||
read -rp "Enable FIDO2 authentication for user login? (YES/NO): " FIDO_USER
|
||
fi
|
||
|
||
# Password always prompted — never stored in answerfile
|
||
read -rp "Enter password for $USERNAME: " USERPASS
|
||
[[ -z "$USERPASS" ]] && { echo "Error: password cannot be empty."; exit 1; }
|
||
|
||
# In interactive mode, decide now whether to run the dotfiles TUI inside chroot.
|
||
# AF_MODE already has RUN_TUI set from the answerfile.
|
||
if ! $AF_MODE; then
|
||
read -rp "Run dotfiles TUI setup inside chroot now? [YES/no]: " _RUN_TUI_IN
|
||
# Default to YES if the user presses Enter without typing anything.
|
||
_RUN_TUI_IN="${_RUN_TUI_IN:-YES}"
|
||
[[ "${_RUN_TUI_IN^^}" == "YES" ]] && RUN_TUI="YES" || RUN_TUI="NO"
|
||
fi
|
||
|
||
############################################
|
||
# RAM / PARTITION SIZING
|
||
############################################
|
||
# Read installed RAM in GiB; used to size both the swap and to calculate remaining
|
||
# root space. `--giga` prints in GiB units; awk extracts the total column.
|
||
RAM_GB=$(free --giga | awk '/^Mem:/ {print $2}')
|
||
# 10 GiB boot partition: large enough for multiple kernels, initramfs, and UKIs.
|
||
BOOT_SIZE=10GiB
|
||
SWAP_SIZE="${RAM_GB}GiB"
|
||
|
||
# lsblk -b reports raw bytes; divide down to GiB for integer arithmetic.
|
||
DISK_SIZE=$(lsblk -b -dn -o SIZE "$DRIVE")
|
||
DISK_GIB=$((DISK_SIZE / 1024 / 1024 / 1024))
|
||
# Root gets whatever is left after reserving 10 GiB for boot and RAM_GB for swap.
|
||
ROOT_GIB=$((DISK_GIB - RAM_GB - 10))
|
||
if (( ROOT_GIB < 8 )); then
|
||
echo "ERROR: Not enough disk space. Root would be only ${ROOT_GIB}GiB (need ≥8GiB)."
|
||
exit 1
|
||
fi
|
||
|
||
echo "Partition plan:"
|
||
echo " Boot: ${BOOT_SIZE}"
|
||
echo " Root: ${ROOT_GIB}GiB"
|
||
echo " Swap: ${SWAP_SIZE}"
|
||
|
||
############################################
|
||
# PARTITION DISK
|
||
############################################
|
||
# --script suppresses interactive prompts; mklabel gpt wipes any existing table.
|
||
# Partition 1: ESP at 1MiB–10GiB, flagged boot/esp so firmware can find it.
|
||
# Partition 2: ROOT immediately after the ESP.
|
||
# Partition 3: SWAP fills the rest (100%).
|
||
# Starting at 1MiB aligns the first partition to a 1 MiB boundary, which is
|
||
# optimal for both SSD erase blocks and spinner track alignment.
|
||
parted "$DRIVE" --script mklabel gpt \
|
||
mkpart ESP fat32 1MiB 10GiB \
|
||
set 1 boot on \
|
||
mkpart ROOT 10GiB "$((10 + ROOT_GIB))"GiB \
|
||
mkpart SWAP "$((10 + ROOT_GIB))"GiB 100%
|
||
|
||
# Resolve partition device paths via the `part` helper (handles NVMe 'p' suffix).
|
||
BOOT_PART=$(part "$DRIVE" 1)
|
||
ROOT_PART=$(part "$DRIVE" 2)
|
||
SWAP_PART=$(part "$DRIVE" 3)
|
||
|
||
############################################
|
||
# FORMAT BOOT + SWAP
|
||
############################################
|
||
# FAT32 is required for the EFI System Partition; -F32 sets the FAT variant.
|
||
mkfs.fat -F32 "$BOOT_PART"
|
||
# mkswap writes the swap header; swapon activates it for use by pacstrap.
|
||
mkswap "$SWAP_PART"
|
||
swapon "$SWAP_PART"
|
||
|
||
############################################
|
||
# ENCRYPTION (OPTIONAL)
|
||
############################################
|
||
LUKS_BACKUP_KEY="" # path to key file, set only when encryption is active
|
||
|
||
if [[ "$ENCRYPT_DISK" == "YES" ]]; then
|
||
echo "Encrypting root partition..."
|
||
# --type luks2 is required for systemd-cryptenroll (FIDO2 token enrollment).
|
||
# -v (verbose) shows progress; prompts for the primary passphrase interactively.
|
||
cryptsetup -v luksFormat "$ROOT_PART" --type luks2
|
||
# Open the container and expose it as /dev/mapper/cryptroot for formatting.
|
||
cryptsetup open "$ROOT_PART" cryptroot
|
||
|
||
# ── Auto-generate backup LUKS key ──────────────────────────────────────────
|
||
# A random key is enrolled as a second LUKS slot so recovery is possible
|
||
# without the primary passphrase. It is written to /_LUKS_BACKUP_KEY in the
|
||
# new system (inside the encrypted container) where only root can read it.
|
||
LUKS_BACKUP_KEY=$(mktemp /tmp/luks-backup-key.XXXXXX)
|
||
# Read 64 bytes from /dev/urandom and base64-encode them (-w0 disables line
|
||
# wrapping) to produce a printable key with high entropy.
|
||
dd if=/dev/urandom bs=64 count=1 2>/dev/null | base64 -w0 > "$LUKS_BACKUP_KEY"
|
||
echo "Enrolling auto-generated backup LUKS key..."
|
||
# luksAddKey reads the existing passphrase interactively to authorise adding
|
||
# the new key file into a free LUKS slot.
|
||
cryptsetup luksAddKey "$ROOT_PART" "$LUKS_BACKUP_KEY"
|
||
|
||
# ── Optional FIDO2 enrollment ─────────────────────────────────────────────
|
||
if [[ "$FIDO_ROOT" == "YES" ]]; then
|
||
echo "Insert FIDO2 key for LUKS and touch when prompted..."
|
||
# --fido2-device=auto finds any connected FIDO2 token automatically.
|
||
# --fido2-with-client-pin=no skips PIN verification; the hardware
|
||
# user-presence tap alone is sufficient for this setup.
|
||
systemd-cryptenroll "$ROOT_PART" --fido2-device=auto --fido2-with-client-pin=no
|
||
fi
|
||
|
||
############################################
|
||
# BTRFS ON ENCRYPTED ROOT
|
||
############################################
|
||
# Format the decrypted mapper device, not the raw partition.
|
||
mkfs.btrfs /dev/mapper/cryptroot
|
||
# Mount flat (no subvolume) first so we can create the subvolume layout.
|
||
mount /dev/mapper/cryptroot /mnt
|
||
# @ is the conventional name for the root subvolume in btrfs-on-Arch setups;
|
||
# @home separates user data so it can be snapshotted or rolled back independently.
|
||
btrfs subvolume create /mnt/@
|
||
btrfs subvolume create /mnt/@home
|
||
# Unmount the flat view before remounting through the subvolumes.
|
||
umount /mnt
|
||
|
||
# Mount each subvolume at its final path for pacstrap to populate.
|
||
mount -o subvol=@ /dev/mapper/cryptroot /mnt
|
||
mkdir -p /mnt/home
|
||
mount -o subvol=@home /dev/mapper/cryptroot /mnt/home
|
||
|
||
else
|
||
echo "Skipping encryption — formatting root directly."
|
||
|
||
############################################
|
||
# BTRFS ON UNENCRYPTED ROOT
|
||
############################################
|
||
# Same subvolume layout as the encrypted branch for consistency.
|
||
mkfs.btrfs "$ROOT_PART"
|
||
mount "$ROOT_PART" /mnt
|
||
btrfs subvolume create /mnt/@
|
||
btrfs subvolume create /mnt/@home
|
||
umount /mnt
|
||
|
||
mount -o subvol=@ "$ROOT_PART" /mnt
|
||
mkdir -p /mnt/home
|
||
mount -o subvol=@home "$ROOT_PART" /mnt/home
|
||
fi
|
||
|
||
mkdir -p /mnt/boot
|
||
# Mount the ESP so GRUB and the kernel can be installed inside the new root tree.
|
||
mount "$BOOT_PART" /mnt/boot
|
||
|
||
# Place backup key inside the new system (only accessible when disk is unlocked)
|
||
if [[ -n "$LUKS_BACKUP_KEY" ]]; then
|
||
# `install -m 400` creates the destination file with mode 0400 (root read-only)
|
||
# in one atomic step, avoiding an intermediate world-readable state.
|
||
install -m 400 "$LUKS_BACKUP_KEY" /mnt/_LUKS_BACKUP_KEY
|
||
# Remove the temp file from the live environment immediately after copying.
|
||
rm -f "$LUKS_BACKUP_KEY"
|
||
echo "Backup LUKS key written to /_LUKS_BACKUP_KEY in new system."
|
||
fi
|
||
|
||
############################################
|
||
# GPU DETECTION
|
||
############################################
|
||
# lspci lists PCI devices; filter for VGA and 3D controllers to identify the GPU.
|
||
# `|| true` prevents set -e from aborting if no GPU line is found.
|
||
GPU_INFO=$(lspci | grep -E "VGA|3D" || true)
|
||
GPU_PKGS=""
|
||
if echo "$GPU_INFO" | grep -qi "NVIDIA"; then
|
||
GPU_PKGS="nvidia nvidia-utils"
|
||
elif echo "$GPU_INFO" | grep -qi "AMD"; then
|
||
# xf86-video-amdgpu provides the Xorg DDX driver; the kernel DRM driver ships in linux-firmware.
|
||
GPU_PKGS="xf86-video-amdgpu"
|
||
elif echo "$GPU_INFO" | grep -qi "Intel"; then
|
||
GPU_PKGS="xf86-video-intel"
|
||
fi
|
||
echo "Detected GPU: ${GPU_INFO:-none}"
|
||
|
||
############################################
|
||
# BASE INSTALL
|
||
############################################
|
||
# pacstrap installs packages into /mnt using pacman's keyring (-K initialises it).
|
||
# GPU_PKGS is intentionally unquoted so it expands to multiple words (or nothing).
|
||
# shellcheck disable=SC2086
|
||
pacstrap -K /mnt base base-devel "$KERNEL" linux-firmware vim bash zsh git less btop fastfetch \
|
||
networkmanager grub cryptsetup libfido2 pam-u2f efibootmgr sudo btrfs-progs lvm2 jq $GPU_PKGS
|
||
|
||
############################################
|
||
# FSTAB
|
||
############################################
|
||
# genfstab -U uses UUIDs (rather than device names or labels) so the fstab
|
||
# remains valid even if drive letter assignments change after a hardware swap.
|
||
genfstab -U /mnt >> /mnt/etc/fstab
|
||
|
||
############################################
|
||
# COPY ANSWERFILE INTO NEW SYSTEM
|
||
############################################
|
||
# Make the answerfile available inside the new system so the dotfiles TUI
|
||
# installer can read the same selections without re-prompting the user.
|
||
if $AF_MODE; then
|
||
install -m 644 "$ANSWERFILE" /mnt/answerfile.json
|
||
fi
|
||
|
||
############################################
|
||
# PASS VARIABLES INTO CHROOT
|
||
############################################
|
||
# Variables must be exported so the arch-chroot heredoc inherits them.
|
||
# The heredoc uses single-quote delimiter ('CHROOT_EOF') which prevents expansion
|
||
# outside the chroot, so env vars must be in the environment, not inline.
|
||
export HOSTNAME USERNAME USERPASS ROOT_PART KERNEL FIDO_ROOT FIDO_USER ENCRYPT_DISK KEYMAP
|
||
|
||
############################################
|
||
# CHROOT CONFIGURATION
|
||
############################################
|
||
# arch-chroot binds /proc, /sys, /dev and runs commands inside the new root.
|
||
# Single-quoted 'CHROOT_EOF' prevents the outer shell from expanding variables;
|
||
# the inner shell resolves them from the exported environment above.
|
||
arch-chroot /mnt /bin/bash <<'CHROOT_EOF'
|
||
set -euo pipefail
|
||
|
||
# Locale
|
||
# Uncomment (create) the en_US.UTF-8 entry so locale-gen can compile it.
|
||
echo "en_US.UTF-8 UTF-8" > /etc/locale.gen
|
||
locale-gen
|
||
echo "LANG=en_US.UTF-8" > /etc/locale.conf
|
||
# vconsole.conf sets the keymap for the virtual console (TTY) on every boot.
|
||
echo "KEYMAP=${KEYMAP}" > /etc/vconsole.conf
|
||
|
||
# Time / hostname
|
||
# Symlink the timezone file so /etc/localtime always points at the correct zoneinfo.
|
||
ln -sf /usr/share/zoneinfo/Europe/Vienna /etc/localtime
|
||
# --systohc writes the current system time to the hardware clock (RTC).
|
||
hwclock --systohc
|
||
echo "$HOSTNAME" > /etc/hostname
|
||
|
||
# NetworkManager
|
||
# Enable at boot so the system has networking on first login without manual setup.
|
||
systemctl enable NetworkManager
|
||
|
||
# Populate /etc/skel before user creation so useradd -m copies everything
|
||
echo "Cloning dotfiles into /etc/skel..."
|
||
git clone https://git.abdelbaki.eu/The_miro/Dotfiles.git /etc/skel/Dotfiles \
|
||
|| echo "Warning: dotfiles clone failed — clone manually after first boot."
|
||
mkdir -p /etc/skel/{Desktop,Documents,Downloads,Music,Pictures,Public,Templates,Videos}
|
||
|
||
# User
|
||
# -m: create home dir; -G wheel: add to sudoers group; -s /bin/zsh: default shell.
|
||
useradd -m -G wheel -s /bin/zsh "$USERNAME"
|
||
# chpasswd reads "user:pass" from stdin to set the password non-interactively.
|
||
echo "$USERNAME:$USERPASS" | chpasswd
|
||
# Grant wheel group full sudo access (ALL:ALL covers any user/group runas context).
|
||
# Use a drop-in rather than appending to /etc/sudoers: the default sudoers ends
|
||
# with '@includedir /etc/sudoers.d', so an appended '%wheel' rule would be parsed
|
||
# AFTER the drop-ins and — since the last matching rule wins — override the
|
||
# temporary 99-setup-nopasswd NOPASSWD rule used during the in-chroot TUI run,
|
||
# making the user re-enter their password on every sudo. A 10-wheel drop-in sorts
|
||
# before 99-setup-nopasswd, so NOPASSWD wins while it is present and password
|
||
# auth resumes once it is removed.
|
||
# Guard that drop-ins are actually read (the stock sudoers already includes this).
|
||
grep -q '^@includedir /etc/sudoers.d' /etc/sudoers || echo '@includedir /etc/sudoers.d' >> /etc/sudoers
|
||
echo '%wheel ALL=(ALL:ALL) ALL' > /etc/sudoers.d/10-wheel
|
||
chmod 0440 /etc/sudoers.d/10-wheel
|
||
|
||
###################################################
|
||
# INITRAMFS CONFIG
|
||
###################################################
|
||
# The HOOKS line controls which initramfs modules are compiled in.
|
||
# Three variants depending on encryption and FIDO2 choices:
|
||
# 1. FIDO2 root unlock: needs `systemd` + `sd-encrypt` for systemd-cryptsetup.
|
||
# 2. Password-only LUKS: uses classic `encrypt` hook (no systemd dependency).
|
||
# 3. Unencrypted: minimal hook set — no encrypt hook needed.
|
||
if [[ "$ENCRYPT_DISK" == "YES" && "$FIDO_ROOT" == "YES" ]]; then
|
||
sed -i 's/^HOOKS=.*/HOOKS=(base udev systemd autodetect microcode modconf kms consolefont block sd-encrypt btrfs filesystems keyboard keymap fsck)/' /etc/mkinitcpio.conf
|
||
elif [[ "$ENCRYPT_DISK" == "YES" ]]; then
|
||
sed -i 's/^HOOKS=.*/HOOKS=(base udev autodetect microcode modconf kms consolefont block encrypt btrfs filesystems keyboard keymap fsck)/' /etc/mkinitcpio.conf
|
||
else
|
||
sed -i 's/^HOOKS=.*/HOOKS=(base udev autodetect microcode modconf kms consolefont block btrfs filesystems keyboard fsck)/' /etc/mkinitcpio.conf
|
||
fi
|
||
|
||
# -P regenerates all installed kernel presets (more thorough than -p <name>).
|
||
mkinitcpio -P
|
||
|
||
###################################################
|
||
# GRUB CONFIG
|
||
###################################################
|
||
# Read the raw partition UUID; used in kernel cmdline for stable device identification.
|
||
UUID=$(blkid -s UUID -o value "$ROOT_PART")
|
||
if [[ "$ENCRYPT_DISK" == "YES" ]]; then
|
||
if [[ "$FIDO_ROOT" == "YES" ]]; then
|
||
# systemd-cryptsetup syntax: rd.luks.name=<UUID>=<name>.
|
||
# rd.luks.options=fido2-device=auto tells sd-encrypt to probe for the FIDO2 token.
|
||
KERNEL_CMD="rd.luks.name=${UUID}=cryptroot rd.luks.options=fido2-device=auto root=/dev/mapper/cryptroot"
|
||
else
|
||
# Classic initramfs encrypt hook syntax: cryptdevice=UUID=<UUID>:<name>.
|
||
KERNEL_CMD="cryptdevice=UUID=${UUID}:cryptroot root=/dev/mapper/cryptroot"
|
||
fi
|
||
else
|
||
# Unencrypted btrfs: root= by UUID and rootflags to select the @ subvolume.
|
||
KERNEL_CMD="root=UUID=${UUID} rootflags=subvol=@"
|
||
fi
|
||
|
||
# Inject the kernel command line into /etc/default/grub using sed.
|
||
# The `|` delimiter avoids conflicts with `/` in device paths.
|
||
sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"$KERNEL_CMD\"|" /etc/default/grub
|
||
|
||
# Install GRUB to the EFI partition; --bootloader-id sets the NVRAM entry name.
|
||
grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=M-Archy-GRUB
|
||
# Generate grub.cfg from /etc/default/grub and detected kernels.
|
||
grub-mkconfig -o /boot/grub/grub.cfg
|
||
|
||
###################################################
|
||
# USER FIDO2 LOGIN (directory + PAM only)
|
||
###################################################
|
||
# Key enrollment runs outside the chroot — pamu2fcfg cannot reach /dev/hidraw*
|
||
# from inside because the host's udev owns those device nodes.
|
||
if [[ "$FIDO_USER" == "YES" ]]; then
|
||
mkdir -p "/home/$USERNAME/.config/Yubico"
|
||
chown "$USERNAME:$USERNAME" "/home/$USERNAME/.config/Yubico"
|
||
# `cue` prints a prompt so the user knows to touch the key when challenged.
|
||
echo "auth required pam_u2f.so cue" >> /etc/pam.d/system-local-login
|
||
fi
|
||
|
||
CHROOT_EOF
|
||
|
||
############################################
|
||
# USER FIDO2 ENROLLMENT (outside chroot)
|
||
############################################
|
||
# pamu2fcfg must run outside arch-chroot: the host's udev manages /dev/hidraw*
|
||
# and the new user has no access to those nodes from inside the chroot.
|
||
if [[ "$FIDO_USER" == "YES" ]]; then
|
||
echo "Enrolling FIDO2 key for user login (outside chroot)..."
|
||
U2F_KEYFILE="/mnt/home/${USERNAME}/.config/Yubico/u2f_keys"
|
||
mkdir -p "/mnt/home/${USERNAME}/.config/Yubico"
|
||
# Scope the credential to this hostname so it cannot be replayed on another system.
|
||
pamu2fcfg -u "$USERNAME" -o "pam://$HOSTNAME" -i "pam://$HOSTNAME" > "$U2F_KEYFILE"
|
||
# Query numeric UID/GID from inside the chroot so ownership is correct.
|
||
_NEWUID=$(arch-chroot /mnt id -u "$USERNAME" 2>/dev/null || echo "1000")
|
||
_NEWGID=$(arch-chroot /mnt id -g "$USERNAME" 2>/dev/null || echo "1000")
|
||
chown -R "$_NEWUID:$_NEWGID" "/mnt/home/${USERNAME}/.config/Yubico"
|
||
chmod 600 "$U2F_KEYFILE"
|
||
echo "FIDO2 key enrolled for $USERNAME."
|
||
fi
|
||
|
||
############################################
|
||
# DOTFILES TUI SETUP (in-chroot, optional)
|
||
############################################
|
||
if [[ "${RUN_TUI^^}" == "YES" ]]; then
|
||
# Grant passwordless sudo temporarily so the TUI installer can call pacman/yay
|
||
# without needing a password inside the chroot (the real sudoers is already set).
|
||
# The file is removed immediately after the TUI exits.
|
||
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" \
|
||
| arch-chroot /mnt tee /etc/sudoers.d/99-setup-nopasswd > /dev/null
|
||
arch-chroot /mnt chmod 0440 /etc/sudoers.d/99-setup-nopasswd
|
||
|
||
echo "Running tui-install.sh as ${USERNAME} inside chroot..."
|
||
# `runuser -u` switches to the unprivileged user inside the chroot so that
|
||
# AUR helpers and dotfiles are owned/built by the correct UID.
|
||
arch-chroot /mnt runuser -u "${USERNAME}" -- \
|
||
bash "/home/${USERNAME}/Dotfiles/setup/tui-install.sh" \
|
||
|| echo "Warning: tui-install exited with errors — check ~/dotfiles-install.log in the new system."
|
||
|
||
# Remove the temporary no-password sudoers drop-in after setup is done.
|
||
arch-chroot /mnt rm -f /etc/sudoers.d/99-setup-nopasswd
|
||
fi
|
||
|
||
# Remove answerfile from new system after setup is complete (contains sensitive paths/config)
|
||
if $AF_MODE && [[ -f /mnt/answerfile.json ]]; then
|
||
rm -f /mnt/answerfile.json
|
||
fi
|
||
|
||
############################################
|
||
# SUMMARY
|
||
############################################
|
||
echo
|
||
echo "############################################"
|
||
echo " INSTALL SUMMARY"
|
||
echo "############################################"
|
||
echo "Drive: $DRIVE"
|
||
echo "Boot partition: $BOOT_PART"
|
||
echo "Root partition: $ROOT_PART"
|
||
echo "Swap partition: $SWAP_PART"
|
||
echo
|
||
echo "Hostname: $HOSTNAME"
|
||
echo "Username: $USERNAME"
|
||
echo "Kernel: $KERNEL"
|
||
echo "GPU detected: ${GPU_INFO:-none}"
|
||
echo
|
||
echo "Disk encryption: $ENCRYPT_DISK"
|
||
echo "FIDO2 root unlock: $FIDO_ROOT"
|
||
echo "FIDO2 user login: $FIDO_USER"
|
||
[[ "$ENCRYPT_DISK" == "YES" ]] && echo "LUKS backup key: /_LUKS_BACKUP_KEY (in new system)"
|
||
echo
|
||
echo "Boot size: $BOOT_SIZE"
|
||
echo "Root size: ${ROOT_GIB}GiB"
|
||
echo "Swap size: $SWAP_SIZE"
|
||
echo
|
||
echo "Log file: $LOGFILE"
|
||
echo "############################################"
|
||
echo
|
||
|
||
# Copy install log into /boot so it survives and is accessible before login.
|
||
# `2>/dev/null || true` silently ignores failures (e.g. /mnt/boot not writable).
|
||
cp "$LOGFILE" /mnt/boot/ 2>/dev/null || true
|
||
|
||
echo "Installation complete! Unmount and reboot:"
|
||
echo " umount -R /mnt && reboot"
|
||
if [[ "${RUN_TUI^^}" != "YES" ]]; then
|
||
echo
|
||
echo "After first boot, login as $USERNAME and run:"
|
||
echo " ~/Dotfiles/setup/tui-install.sh"
|
||
fi
|