aboutsummaryrefslogtreecommitdiff
path: root/installer
diff options
context:
space:
mode:
Diffstat (limited to 'installer')
-rwxr-xr-xinstaller/archangel62
-rw-r--r--installer/lib/btrfs.sh69
-rw-r--r--installer/lib/common.sh41
-rw-r--r--installer/lib/config.sh22
4 files changed, 152 insertions, 42 deletions
diff --git a/installer/archangel b/installer/archangel
index ec3017a..b9817e8 100755
--- a/installer/archangel
+++ b/installer/archangel
@@ -791,9 +791,24 @@ EOF
info "Exposing baked AUR repo to pacstrap..."
append_aur_repo /etc/pacman.conf "file://$aur_repo_dir"
mapfile -t aur_packages < <(aur_manifest_names "$aur_repo_dir/manifest.tsv")
+ # Drop ZFS-only AUR tooling on a non-ZFS target. The baked repo carries
+ # the full set on every ISO, but e.g. zfs-auto-snapshot has a hard zfs
+ # dependency that can't resolve on a btrfs install and would abort the
+ # whole pacstrap transaction.
+ mapfile -t aur_packages < <(filter_aur_for_fs "$FILESYSTEM" "${aur_packages[@]}")
info "Baked AUR packages to install: ${aur_packages[*]:-none}"
fi
+ # Force-refresh package databases before pacstrap. The ISO bakes an
+ # archzfs sync db at build time, so as the ISO ages that db pins an older
+ # zfs-dkms while linux-lts is pulled current from the live mirror, and the
+ # DKMS build then fails against the newer kernel. A single -y can be
+ # skipped by pacman's freshness check against the GitHub-served archzfs db
+ # (no reliable timestamps), so -yy forces the refresh. This keeps the
+ # "zfs-dkms always matches the kernel" guarantee true regardless of ISO age.
+ info "Refreshing package databases..."
+ pacman -Syy --noconfirm || error "Failed to refresh package databases"
+
info "Installing base packages (this takes a while)..."
local packages
@@ -910,6 +925,47 @@ configure_ssh() {
fi
}
+# Close the second passphrase prompt on encrypted ZFS boots.
+#
+# ZFSBootMenu unlocks the pool to read this kernel and initramfs, then kexecs
+# into it — and the loaded key does not survive kexec. With no key inside the
+# initramfs, the zfs hook re-imports the pool, finds keylocation=prompt, and
+# asks for the same passphrase a second time.
+#
+# Pointing the encryption root at a keyfile that lives inside the encrypted
+# dataset closes it. ZFSBootMenu cannot read a file in a dataset it has not
+# unlocked, so it overrides the file:// URI and prompts once — documented
+# upstream behavior, not a side effect. The booted initramfs carries the
+# keyfile and loads the key silently.
+#
+# keyformat stays passphrase: it is what lets ZFSBootMenu accept the typed
+# value, and a raw key would leave it no way in at all. keylocation alone is
+# settable with `zfs set` (zfsprops(7)), so this never reaches for `zfs
+# change-key`, which would rekey the pool and prompt for new key material
+# mid-install.
+#
+# Never relocate this keyfile onto the ESP or into a custom ZFSBootMenu image.
+# Both are unencrypted; the protection here comes entirely from the keyfile and
+# the initramfs living inside the encrypted dataset.
+configure_zfs_keyfile() {
+ local passphrase="$1"
+ local pool="$2"
+ local keyfile="/etc/zfs/zroot.key"
+
+ mkdir -p "$MNTPOINT$(dirname "$keyfile")"
+
+ # No trailing newline: ZFS reads the file's bytes as the passphrase, so a
+ # stray newline would not match what the user types at the ZBM prompt.
+ printf '%s' "$passphrase" > "$MNTPOINT$keyfile"
+ chmod 000 "$MNTPOINT$keyfile"
+
+ zfs set keylocation="file://$keyfile" "$pool" \
+ || error "Failed to point $pool at $keyfile"
+
+ ensure_initramfs_files "$keyfile" "$MNTPOINT/etc/mkinitcpio.conf"
+ info "Keyfile embedded in initramfs - one passphrase prompt at boot."
+}
+
configure_initramfs() {
step "Configuring Initramfs for ZFS"
@@ -969,6 +1025,12 @@ EOF
# system. (Audited 2026-04-27 against silent-sed pattern.)
sed -i 's/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block zfs filesystems)/' $MNTPOINT/etc/mkinitcpio.conf
+ # Embed the pool key so the booted initramfs doesn't re-prompt. Must run
+ # before mkinitcpio -P below, which is what bakes FILES= into the image.
+ if [[ "$NO_ENCRYPT" != "yes" ]]; then
+ configure_zfs_keyfile "$ZFS_PASSPHRASE" "$POOL_NAME"
+ fi
+
# Get the installed kernel version (not the running kernel)
local kernel_ver
kernel_ver=$(ls $MNTPOINT/usr/lib/modules | grep lts | head -1)
diff --git a/installer/lib/btrfs.sh b/installer/lib/btrfs.sh
index 0a34be0..67c96a0 100644
--- a/installer/lib/btrfs.sh
+++ b/installer/lib/btrfs.sh
@@ -340,6 +340,36 @@ create_btrfs_subvolumes() {
# Btrfs Mount Functions
#############################
+# Compose the mount-option string for a single subvolume: the shared
+# BTRFS_OPTS prefixed with subvol=<name>, then the per-subvol extra
+# flags applied. compress=no and nodatacow both drop the default
+# compress=zstd; nodatacow also appends nodatacow; nosuid appends
+# nosuid,nodev. Pure string transform — no I/O. Shared by
+# mount_btrfs_subvolumes and generate_btrfs_fstab so the two stay in sync.
+# Usage: parse_btrfs_subvol_opts NAME EXTRA
+parse_btrfs_subvol_opts() {
+ local name="$1" extra="$2"
+ local opts="subvol=$name,$BTRFS_OPTS"
+
+ if [[ -n "$extra" ]]; then
+ # compress=no: drop the default compression, don't add anything
+ if [[ "$extra" == *"compress=no"* ]]; then
+ opts=$(echo "$opts" | sed 's/,compress=zstd//')
+ fi
+ # nodatacow implies no compression (incompatible), so drop it too
+ if [[ "$extra" == *"nodatacow"* ]]; then
+ opts="$opts,nodatacow"
+ opts=$(echo "$opts" | sed 's/,compress=zstd//')
+ fi
+ # nosuid,nodev hardening for tmp subvolumes
+ if [[ "$extra" == *"nosuid"* ]]; then
+ opts="$opts,nosuid,nodev"
+ fi
+ fi
+
+ echo "$opts"
+}
+
mount_btrfs_subvolumes() {
local partition="$1"
@@ -356,25 +386,8 @@ mount_btrfs_subvolumes() {
# Skip root, already mounted
[[ "$name" == "@" ]] && continue
- # Build mount options
- local opts="subvol=$name,$BTRFS_OPTS"
-
- # Apply extra options (override defaults where specified)
- if [[ -n "$extra" ]]; then
- # Handle compress=no by removing compress from opts and not adding it
- if [[ "$extra" == *"compress=no"* ]]; then
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- # Handle nodatacow
- if [[ "$extra" == *"nodatacow"* ]]; then
- opts="$opts,nodatacow"
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- # Handle nosuid,nodev for tmp
- if [[ "$extra" == *"nosuid"* ]]; then
- opts="$opts,nosuid,nodev"
- fi
- fi
+ local opts
+ opts=$(parse_btrfs_subvol_opts "$name" "$extra")
info "Mounting $name -> $MNTPOINT$mountpoint"
mkdir -p "$MNTPOINT$mountpoint"
@@ -412,22 +425,8 @@ EOF
for subvol_spec in "${BTRFS_SUBVOLS[@]}"; do
IFS=':' read -r name mountpoint extra <<< "$subvol_spec"
- # Build mount options
- local opts="subvol=$name,$BTRFS_OPTS"
-
- # Apply extra options
- if [[ -n "$extra" ]]; then
- if [[ "$extra" == *"compress=no"* ]]; then
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- if [[ "$extra" == *"nodatacow"* ]]; then
- opts="$opts,nodatacow"
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- if [[ "$extra" == *"nosuid"* ]]; then
- opts="$opts,nosuid,nodev"
- fi
- fi
+ local opts
+ opts=$(parse_btrfs_subvol_opts "$name" "$extra")
echo "UUID=$uuid $mountpoint btrfs $opts 0 0" >> $MNTPOINT/etc/fstab
done
diff --git a/installer/lib/common.sh b/installer/lib/common.sh
index 0317034..0378756 100644
--- a/installer/lib/common.sh
+++ b/installer/lib/common.sh
@@ -166,6 +166,38 @@ aur_manifest_names() {
awk -F'\t' 'NR>1 {print $1}' "$manifest"
}
+# Print the baked AUR packages that are ZFS-only tooling, one per line. The ISO
+# bakes the full AUR set on every build, but these require a ZFS root:
+# zfs-auto-snapshot has a hard `zfs` dependency, and zrepl is ZFS replication.
+# On a non-ZFS install neither dependency exists, so installing them is at best
+# pointless and at worst aborts pacstrap (zfs-auto-snapshot's unmet `zfs` dep
+# fails the whole transaction). Keep in lockstep with build-aur.sh's
+# aur_v1_packages: a new ZFS-only AUR package added there belongs here too.
+aur_zfs_only_packages() {
+ printf '%s\n' \
+ zfs-auto-snapshot \
+ zrepl
+}
+
+# Filter a list of AUR package names for the target filesystem, printing the
+# kept names one per line in input order. On a ZFS target every package passes
+# through. On any other filesystem the ZFS-only tooling (aur_zfs_only_packages)
+# is dropped so it never reaches pacstrap. install_base runs the baked manifest
+# names through this before appending them to the pacstrap set.
+filter_aur_for_fs() {
+ local fs="$1"; shift
+ local -A drop=()
+ if [[ "$fs" != zfs ]]; then
+ local z
+ while IFS= read -r z; do drop["$z"]=1; done < <(aur_zfs_only_packages)
+ fi
+ local pkg
+ for pkg in "$@"; do
+ [[ -n "${drop[$pkg]:-}" ]] && continue
+ printf '%s\n' "$pkg"
+ done
+}
+
# Remove the named repo's stanza (its [name] header and the config lines up to
# the next [section] or EOF) from the pacman.conf at $2. Used to ensure the
# installed target never references the baked [aur] repo, whose
@@ -181,7 +213,14 @@ strip_repo_stanza() {
skip { next }
{ print }
' "$pacman_conf" > "$tmp"
- mv "$tmp" "$pacman_conf"
+ # Truncate-write in place rather than `mv` the temp over the target: mktemp
+ # creates the temp 0600, and a mv would carry that onto pacman.conf,
+ # clobbering its pristine 0644 and leaving the installed config root-only.
+ # That broke every user-level makepkg/yay ("config file /etc/pacman.conf
+ # could not be read: Permission denied"). Writing through the existing file
+ # keeps its inode and mode.
+ cat "$tmp" > "$pacman_conf"
+ rm -f "$tmp"
}
#############################
diff --git a/installer/lib/config.sh b/installer/lib/config.sh
index 3ba2bb3..ed54e36 100644
--- a/installer/lib/config.sh
+++ b/installer/lib/config.sh
@@ -116,20 +116,30 @@ check_config() {
validate_config() {
local errors=0
- [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((errors++)); }
- [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((errors++)); }
- [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((errors++)); }
- [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((errors++)); }
+ [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((++errors)); }
+ [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((++errors)); }
+ [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((++errors)); }
+ [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((++errors)); }
# Validate disks exist
for disk in "${SELECTED_DISKS[@]}"; do
- [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((errors++)); }
+ [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((++errors)); }
done
# Validate timezone
if [[ -n "$TIMEZONE" && ! -f "/usr/share/zoneinfo/$TIMEZONE" ]]; then
warn "Invalid timezone: $TIMEZONE"
- ((errors++))
+ ((++errors))
+ fi
+
+ # Validate the RAID level against the selected disk count. The
+ # interactive path only offers levels valid for the count, so this
+ # guards the unattended config, where RAID_LEVEL is set by hand and
+ # can name a level the disk count can't support. raid_is_valid treats
+ # an empty level on a single disk (no RAID) as valid.
+ if ! raid_is_valid "$RAID_LEVEL" "${#SELECTED_DISKS[@]}"; then
+ warn "Invalid RAID_LEVEL '$RAID_LEVEL' for ${#SELECTED_DISKS[@]} disk(s)"
+ ((++errors))
fi
if [[ $errors -gt 0 ]]; then