diff --git a/lib/default.nix b/lib/default.nix index f9ddd35e..4d63dd9a 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -261,7 +261,8 @@ rec { This is the Securix live offline installer image edition ${edition}. This installer will install your system in ${mainDisk}, if that's not what you want, - contact the system administrators. + contact the system administrators. If that disk is missing, the installer will ask + which disk to use instead. Run: `autoinstall-terminal` to start the automatic installation process. ''; @@ -366,11 +367,35 @@ rec { box_message "All preflight checks passed." - log_info "${mainDisk} will be re-initialized and formatted, please confirm this is the right target." + main_disk="${mainDisk}" + if [ ! -b "$main_disk" ]; then + log_warn "$main_disk does not exist on this machine." + candidates=() + while read -r name size type model; do + [ "$type" = "disk" ] || continue + # Skip the disks in use, such as the installer medium. + if lsblk -no MOUNTPOINTS "/dev/$name" | grep -q .; then + continue + fi + candidates+=("/dev/$name $size $model") + done < <(lsblk -dn -o NAME,SIZE,TYPE,MODEL -e 2,7,11) + if [ "''${#candidates[@]}" -eq 0 ]; then + log_error "No disk available for the installation." + exit 1 + fi + choice=$(${pkgs.gum}/bin/gum choose --header "Installation disk" "''${candidates[@]}") || { log_warn "Operation cancelled."; exit 0; } + main_disk="''${choice%% *}" + # The partitioning scripts only know the configured path, so point it at the chosen disk. + mkdir -p "$(dirname "${mainDisk}")" + ln -s "$main_disk" "${mainDisk}" + log_info "${mainDisk} now points to $main_disk." + fi + + log_info "$main_disk will be re-initialized and formatted, please confirm this is the right target." ${pkgs.gum}/bin/gum confirm "Proceed with reformatting?" || { log_warn "Operation cancelled."; exit 0; } - wipefs -fa "${mainDisk}" ; sudo dd if=/dev/zero of="${mainDisk}" bs=4M count=1024; - log_info "${mainDisk} re-initialized and formatted." + wipefs -fa "$main_disk" ; sudo dd if=/dev/zero of="$main_disk" bs=4M count=1024; + log_info "$main_disk re-initialized and formatted." ${pkgs.systemd}/bin/udevadm settle ${diskProcedureScript} @@ -383,7 +408,7 @@ rec { exit 1 fi ${optionalString createSecureBootKeys createSecureBootKeysScript} - box_message "Burning the image on ${mainDisk}..." + box_message "Burning the image on $main_disk..." ${installProcedureScript config} ${optionalString enrollSecureBootKeys secureBootEnrollmentScript} ${optionalString (preprovisionOptions.tpm2HostKeys or false) tpm2ProvisionScript} diff --git a/tests/autoinstall-missing-disk.nix b/tests/autoinstall-missing-disk.nix new file mode 100644 index 00000000..f9c77c40 --- /dev/null +++ b/tests/autoinstall-missing-disk.nix @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2026 Quentin Cazier +# +# SPDX-License-Identifier: MIT +# +# Regression test for https://github.com/cloud-gouv/securix/issues/131 +# +# Installs a system configured for a disk that does not exist on the disk +# picked at the installer prompt, runs the installer a second time, then +# boots a second VM from that disk alone. + +{ pkgs, libSecurix }: + +let + lib = pkgs.lib; + + qemu-common = import "${pkgs.path}/nixos/lib/qemu-common.nix" { + inherit lib; + inherit (pkgs) stdenv; + }; + + # Unlocks LUKS at install time and from the initrd at boot. + diskKey = pkgs.writeText "disk-key" "testpassphrase"; + + targetSystem = pkgs.nixos ( + { lib, modulesPath, ... }: { + imports = [ + "${pkgs.disko.src}/module.nix" + ../modules/filesystems + (modulesPath + "/testing/test-instrumentation.nix") + (modulesPath + "/profiles/qemu-guest.nix") + ]; + + options.securix.self.mainDisk = lib.mkOption { type = lib.types.str; }; + + config = { + securix.self.mainDisk = "/dev/nvme0n1"; + securix.filesystems.layout = "securix_v1"; + + disko.devices.disk."/dev/nvme0n1".content.partitions.luks.content.passwordFile = "${diskKey}"; + boot.initrd.secrets."/etc/secrets/disk.key" = diskKey; + boot.initrd.luks.devices.croot.keyFile = "/etc/secrets/disk.key"; + + # Securix uses lanzaboote, systemd-boot is enough to boot the disk in a VM. + # The installer VM is not booted with UEFI, hence graceful. + boot.loader.systemd-boot.enable = true; + boot.loader.systemd-boot.graceful = true; + + documentation.enable = false; + system.stateVersion = "26.05"; + }; + } + ); + + installer = libSecurix.buildInstallerSystem { + inherit targetSystem; + preprovisionOptions = { + secureBoot = "disabled"; + skipPreflightChecks = true; + tpm2HostKeys = false; + ageHostKeys = false; + }; + }; + + autoinstallPkg = + lib.findFirst (p: p.name or "" == "autoinstall-terminal") + (throw "autoinstall-terminal not found in installer packages") + installer.config.environment.systemPackages; + + # Runs autoinstall-terminal with the given answers and waits for it to finish. + mkInstallRun = + name: answers: + pkgs.writeText "${name}.exp" '' + set timeout 600 + spawn autoinstall-terminal + ${answers} + expect { + "Installation is complete" {} + timeout { puts "TIMEOUT"; exit 1 } + eof { puts "UNEXPECTED EOF"; exit 1 } + } + expect eof + lassign [wait] pid spawnid os_error value + exit $value + ''; + + firstRun = mkInstallRun "first-run" '' + expect "Installation disk" + sleep 1 + send "\r" + expect "Proceed with reformatting?" + send "\r" + ''; + + secondRun = mkInstallRun "second-run" '' + expect { + "Installation disk" { puts "ASKED AGAIN"; exit 1 } + "Proceed with reformatting?" { send "\r" } + timeout { puts "TIMEOUT"; exit 1 } + } + ''; + + # Boots the installed disk alone, with UEFI firmware. + bootInstalledDisk = lib.concatStringsSep " " [ + (qemu-common.qemuBinary pkgs.qemu_test) + "-m 1024" + "-device virtio-blk-pci,drive=installed" + "-drive if=pflash,format=raw,unit=0,readonly=on,file=${pkgs.OVMF.firmware}" + "-drive if=pflash,format=raw,unit=1,readonly=on,file=${pkgs.OVMF.variables}" + ]; +in +pkgs.testers.nixosTest { + name = "autoinstall-terminal-missing-disk"; + + nodes.machine = _: { + virtualisation.emptyDiskImages = [ 12288 ]; + virtualisation.memorySize = 2048; + boot.supportedFilesystems = [ + "btrfs" + "vfat" + ]; + environment.systemPackages = [ + autoinstallPkg + pkgs.expect + ]; + }; + + testScript = '' + machine.start() + machine.wait_for_unit("multi-user.target") + machine.fail("test -e /dev/nvme0n1") + + with subtest("the installer asks for a disk and installs on it"): + machine.succeed("expect ${firstRun}") + machine.succeed("test \"$(readlink /dev/nvme0n1)\" = /dev/vdb") + machine.succeed("lsblk -no PARTLABEL /dev/vdb | grep -q disk-_dev_nvme0n1-luks") + + with subtest("a second run does not ask again"): + machine.succeed("expect ${secondRun}") + + machine.succeed("sync") + machine.shutdown() + + disk = f"{machine.state_dir}/empty0.qcow2" + booted = create_machine( + start_command=f"${bootInstalledDisk} -drive file={disk},id=installed,if=none,werror=report", + name="booted", + ) + driver.machines_qemu.append(booted) + booted.start() + + with subtest("the installed system boots from the chosen disk"): + booted.wait_for_unit("multi-user.target") + booted.fail("test -e /dev/nvme0n1") + booted.succeed("findmnt -n -o SOURCE / | grep -q '^/dev/mapper/croot'") + booted.succeed("cryptsetup status croot | grep -q 'device:.*/dev/vda3'") + ''; +} diff --git a/tests/default.nix b/tests/default.nix index 3f6f74b8..4b60ab3f 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -7,6 +7,7 @@ minimal = import ./minimal.nix { inherit pkgs libSecurix; }; anssi-minimal = import ./anssi-minimal.nix { inherit pkgs libSecurix; }; idempotent-autoinstall = import ./idempotent-autoinstall.nix { inherit pkgs libSecurix; }; + autoinstall-missing-disk = import ./autoinstall-missing-disk.nix { inherit pkgs libSecurix; }; portail = import ./portail.nix { inherit pkgs libSecurix; }; tools = import ./tools.nix { inherit pkgs libSecurix; }; }