fix(installer): harden multi-disk LUKS, password handling, revision pinning

Several installer reliability fixes that were left uncommitted:

- Impermanence + multi-disk LUKS: disko-config.nix names the main LUKS
  mapping `crypted` for single-disk and `crypted_main` once extraDrives is
  non-empty. The impermanence rollback hook used to hardcode `crypted`,
  which made every multi-disk install fail to mount root in initrd. Added
  a `nomarchy.system.impermanence.mainLuksName` option and wired the
  installer to write the correct value into the generated system.nix
  based on the drive count.

- Password no longer cleartext in /etc/nixos: installer now hashes the
  user password with `mkpasswd -m sha-512` and emits
  `initialHashedPassword` instead of `initialPassword`. Added mkpasswd to
  the live ISO. Cleartext is unset immediately after hashing.
  USER_PASSWORD_HASH is deliberately not persisted in --resume state —
  configure_user re-prompts on resume.

- Revision pinning that actually works on the live ISO: `inputs.self`
  strips .git in the Nix store copy, so `git rev-parse HEAD` would silently
  return empty on a real install and the generated flake would track main.
  Live ISO now writes `/etc/nomarchy-rev` from `inputs.self.rev` at build
  time; install.sh reads it first, falls back to git, and aborts with a
  loud confirmation prompt if both are empty (instead of silently
  installing an unpinned system).

- Generated `/mnt/etc/nixos/state.json`: toggle scripts (nomarchy-tz-select,
  nomarchy-setup-{fido2,fingerprint}, nomarchy-toggle-hybrid-gpu,
  nomarchy-wifi-powersave) `jq` this file in place and fail hard if it
  doesn't exist. Fresh installs now ship a schema-conformant file matching
  lib/state-schema.nix.

- Unmount /mnt before exiting `finish()` regardless of reboot choice. Clean
  unmount avoids dirty BTRFS on reboot; on "no", leaving /mnt mounted
  blocked a second installer run on the same live ISO.

- Removed obsolete `installer/disko-btrfs-luks.nix` (superseded by
  `disko-config.nix` per commit 3aadc36) and dropped its dangling
  `docs/STRUCTURE.md` reference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-05-18 17:01:15 +01:00
parent 158ae308cc
commit 098cd42ac8
5 changed files with 122 additions and 77 deletions

View File

@@ -1,68 +0,0 @@
{
disko.devices = {
disk = {
main = {
type = "disk";
device = "@TARGET_DRIVE@";
content = {
type = "gpt";
partitions = {
ESP = {
priority = 1;
name = "ESP";
start = "1M";
end = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted";
settings.allowDiscards = true;
content = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"@" = {
mountpoint = "/";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@persist" = {
mountpoint = "/persist";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@home" = {
mountpoint = "/home";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@nix" = {
mountpoint = "/nix";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@log" = {
mountpoint = "/var/log";
mountOptions = [ "compress=zstd" "noatime" ];
};
};
postCreateHook = ''
MNTPOINT=$(mktemp -d)
mount -t btrfs /dev/mapper/crypted $MNTPOINT
btrfs subvolume snapshot -r $MNTPOINT/@ $MNTPOINT/root-blank
umount $MNTPOINT
'';
};
};
};
};
};
};
};
};
}

View File

@@ -32,6 +32,7 @@ TARGET_DRIVE=""
USERNAME=""
LUKS_PASSWORD=""
USER_PASSWORD=""
USER_PASSWORD_HASH=""
TIMEZONE="UTC"
KEYMAP_LAYOUT=""
KEYMAP_VARIANT=""
@@ -92,6 +93,14 @@ parse_args() {
# Persist non-secret answers so an interrupted install can pick up where it
# left off. Uses `declare -p` so each line is a self-contained `declare --`
# statement that `source` re-establishes verbatim.
#
# USER_PASSWORD_HASH is intentionally NOT persisted, even though a SHA-512
# crypt hash isn't reversible. The contract is: after --resume, the password
# prompt re-runs. configure_user's early-return guard at the top of the
# function checks `[[ -n "$USER_PASSWORD_HASH" ]]` for exactly this reason —
# if you ever change that guard to skip on USERNAME+HOSTNAME alone, --resume
# will silently install a system with an empty password hash and lock the
# user out. Keep the guard checking the hash.
save_state() {
declare -p \
TARGET_DRIVE USERNAME HOSTNAME TIMEZONE \
@@ -128,7 +137,7 @@ clear_step_state() {
case "$1" in
select_disk) TARGET_DRIVE="" ;;
get_luks_passphrase) LUKS_PASSWORD="" ;;
configure_user) USERNAME=""; HOSTNAME=""; USER_PASSWORD="" ;;
configure_user) USERNAME=""; HOSTNAME=""; USER_PASSWORD=""; USER_PASSWORD_HASH="" ;;
select_keymap_locale) KEYMAP_LAYOUT=""; KEYMAP_VARIANT=""; LOCALE="" ;;
select_timezone) TIMEZONE="" ;;
select_hardware) HARDWARE_MODULES=""; NOMARCHY_HW_OPTS="" ;;
@@ -206,13 +215,32 @@ check_environment() {
# Capture the exact commit we're installing from. The generated flake
# pins `nomarchy.url` to this revision so the installed system can't
# silently drift onto a newer (possibly breaking) main.
if command -v git >/dev/null 2>&1 && [[ -d "$NOMARCHY_REPO/.git" ]]; then
#
# Three sources, in priority order:
# 1. /etc/nomarchy-rev — written at ISO build time from `inputs.self.rev`
# (the only source that works on a normal live-ISO install, because
# `inputs.self` strips .git from the Nix store copy at /etc/nomarchy).
# 2. `git rev-parse HEAD` in the repo — works when running the installer
# from a dev checkout instead of the live ISO.
# 3. Empty → unpinned, user gets a loud confirmation prompt below.
if [[ -z "$NOMARCHY_REV" ]] && [[ -f /etc/nomarchy-rev ]]; then
NOMARCHY_REV=$(tr -d '[:space:]' < /etc/nomarchy-rev)
fi
if [[ -z "$NOMARCHY_REV" ]] && command -v git >/dev/null 2>&1 && [[ -d "$NOMARCHY_REPO/.git" ]]; then
NOMARCHY_REV=$(git -C "$NOMARCHY_REPO" rev-parse HEAD 2>/dev/null || echo "")
fi
if [[ -n "$NOMARCHY_REV" ]]; then
success "Pinning Nomarchy to $NOMARCHY_REV"
else
info "Could not determine Nomarchy revision; downstream flake will track main."
error "Could not determine Nomarchy revision."
info "The installed system would silently track upstream main, which"
info "defeats the point of locking inputs at install time."
if [[ "$DRY_RUN" != "true" ]]; then
if ! nrun gum confirm --default=false \
"Continue anyway with an unpinned (tracking main) configuration?"; then
exit 1
fi
fi
fi
# Check internet
@@ -452,8 +480,8 @@ configure_user() {
section "User Configuration"
if [[ -n "$USERNAME" && -n "$HOSTNAME" ]]; then
# Password check skipped in dry run or if already set
if [[ "$DRY_RUN" == "true" ]] || [[ -n "$USER_PASSWORD" ]]; then
# Password check skipped in dry run or if already hashed in this session.
if [[ "$DRY_RUN" == "true" ]] || [[ -n "$USER_PASSWORD_HASH" ]]; then
success "User $USERNAME @ $HOSTNAME configured"
return 0
fi
@@ -493,6 +521,9 @@ configure_user() {
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run: skipping user password prompt."
USER_PASSWORD="dryrun-not-used"
# Stable placeholder hash so generated system.nix still parses as Nix.
# Never lands on a real install — dry-run skips nixos-install.
USER_PASSWORD_HASH='$6$dryrun$3xxK3aQ.0bGcv0fM2RhV4Q9oN3p1mYxz5kSjQ.bC8tZpZ7QnFv2cN0Yhd5lDqJ8X9mP2K1L0vR6BqWqzNk7Yo/'
save_state
return
fi
@@ -521,6 +552,20 @@ configure_user() {
fi
done
# Hash now so we never have to embed the cleartext in /etc/nixos/system.nix
# (where it would be world-readable and break Nix parsing on quotes/backslash).
# mkpasswd is added to the live ISO via hosts/nomarchy-live.nix.
if ! command -v mkpasswd >/dev/null 2>&1; then
error "mkpasswd not found on the live ISO — cannot hash the user password."
exit 1
fi
USER_PASSWORD_HASH=$(printf '%s' "$USER_PASSWORD" | mkpasswd -m sha-512 -s)
if [[ -z "$USER_PASSWORD_HASH" ]]; then
error "mkpasswd produced an empty hash."
exit 1
fi
unset pass1 pass2 USER_PASSWORD
success "User password set"
save_state
}
@@ -1346,9 +1391,17 @@ EOF
# ============================================================================
generate_flake_config() {
# Impermanence must mount the same /dev/mapper name that disko created.
# disko-config.nix uses "crypted" for single-disk and "crypted_main" once
# extraDrives is non-empty — keep these in sync.
local impermanence_opt=""
if [[ "$ENABLE_IMPERMANENCE" == "true" ]]; then
impermanence_opt="nomarchy.system.impermanence.enable = true;"
local _main_luks_name="crypted"
local _drives=($TARGET_DRIVE)
if (( ${#_drives[@]} > 1 )); then
_main_luks_name="crypted_main"
fi
impermanence_opt=$'nomarchy.system.impermanence.enable = true;\n nomarchy.system.impermanence.mainLuksName = "'"$_main_luks_name"$'";'
fi
local PROFILE_SYSTEM_OPTS=""
@@ -1534,7 +1587,9 @@ $xkb_variant_line
users.users."$USERNAME" = {
isNormalUser = true;
initialPassword = "$USER_PASSWORD";
# SHA-512 crypt hash generated by mkpasswd during install. Cleartext
# never touches /etc/nixos. Change later with \`passwd\`.
initialHashedPassword = "$USER_PASSWORD_HASH";
extraGroups = [ "networkmanager" "wheel" "video" "audio" "render" ];
};
@@ -1685,6 +1740,31 @@ EOF
# Extra Home Manager modules go here (program configs, services, etc.).
}
EOF
# state.json — consumed by core/system/state.nix at every nixos-rebuild and
# mutated by toggle scripts (nomarchy-tz-select, nomarchy-setup-fido2,
# nomarchy-toggle-hybrid-gpu, nomarchy-wifi-powersave, ...). Those scripts
# `jq` the file in place and fail hard if it doesn't exist or isn't valid
# JSON, so a fresh install MUST ship one. Shape must match lib/state-schema.nix.
# Quoted heredoc — no shell expansion except the explicit $TIMEZONE below.
local _state_tz="${TIMEZONE:-UTC}"
cat > /mnt/etc/nixos/state.json <<JSON_EOF
{
"theme": "nord",
"timezone": "${_state_tz}",
"dns": "DHCP",
"customDns": [],
"wifi": {
"powersave": true
},
"features": {
"fingerprint": false,
"fido2": false,
"hybridGPU": false,
"makima": false
}
}
JSON_EOF
}
# ============================================================================
@@ -1712,8 +1792,18 @@ finish() {
echo " variant. Theme switches after that are instant (no rebuild)."
echo ""
# Unmount /mnt before either reboot or returning to the live shell:
# - Reboot: clean unmount avoids dirty BTRFS, which would otherwise
# force a longer first-boot fsck/replay.
# - Decline: leaving /mnt mounted blocks a second `install.sh` run on
# the same live ISO (disko refuses to wipe a busy device).
# `-R` recursively unmounts /mnt/boot, /mnt/home, /mnt/nix, etc.; the
# `|| true` absorbs the case where /mnt was already torn down.
if nrun gum confirm "Reboot now?"; then
umount -R /mnt 2>/dev/null || true
reboot
else
umount -R /mnt 2>/dev/null || true
fi
}