diff --git a/modules/desk-os/default.nix b/modules/desk-os/default.nix index e6ffe92..3dd297a 100644 --- a/modules/desk-os/default.nix +++ b/modules/desk-os/default.nix @@ -4,6 +4,10 @@ config, ... }: { + imports = [ + ../systemd-boot + ]; + nixpkgs.config.allowUnfree = true; nix.settings.experimental-features = ["nix-command" "flakes"]; diff --git a/modules/systemd-boot/default.nix b/modules/systemd-boot/default.nix new file mode 100644 index 0000000..5f5eec0 --- /dev/null +++ b/modules/systemd-boot/default.nix @@ -0,0 +1,429 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.boot.loader.systemd-boot; + + efi = config.boot.loader.efi; + + # We check the source code in a derivation that does not depend on the + # system configuration so that most users don't have to redo the check and require + # the necessary dependencies. + checkedSource = pkgs.runCommand "systemd-boot" { + preferLocalBuild = true; + } '' + install -m755 -D ${./systemd-boot-builder.py} $out + ${lib.getExe pkgs.buildPackages.mypy} \ + --no-implicit-optional \ + --disallow-untyped-calls \ + --disallow-untyped-defs \ + $out + ''; + + systemdBootBuilder = pkgs.substituteAll rec { + name = "systemd-boot"; + + src = checkedSource; + + isExecutable = true; + + inherit (pkgs) python3; + + systemd = config.systemd.package; + + bootspecTools = pkgs.bootspec; + + nix = config.nix.package.out; + + timeout = if config.boot.loader.timeout == null then "menu-force" else config.boot.loader.timeout; + + configurationLimit = if cfg.configurationLimit == null then 0 else cfg.configurationLimit; + + inherit (cfg) consoleMode graceful editor rebootForBitlocker; + + inherit (efi) efiSysMountPoint canTouchEfiVariables; + + bootMountPoint = if cfg.xbootldrMountPoint != null + then cfg.xbootldrMountPoint + else efi.efiSysMountPoint; + + nixosDir = "/EFI/nixos"; + + inherit (config.system.nixos) distroName; + + memtest86 = optionalString cfg.memtest86.enable pkgs.memtest86plus; + + netbootxyz = optionalString cfg.netbootxyz.enable pkgs.netbootxyz-efi; + + checkMountpoints = pkgs.writeShellScript "check-mountpoints" '' + fail() { + echo "$1 = '$2' is not a mounted partition. Is the path configured correctly?" >&2 + exit 1 + } + ${pkgs.util-linuxMinimal}/bin/findmnt ${efiSysMountPoint} > /dev/null || fail efiSysMountPoint ${efiSysMountPoint} + ${lib.optionalString + (cfg.xbootldrMountPoint != null) + "${pkgs.util-linuxMinimal}/bin/findmnt ${cfg.xbootldrMountPoint} > /dev/null || fail xbootldrMountPoint ${cfg.xbootldrMountPoint}"} + ''; + + copyExtraFiles = pkgs.writeShellScript "copy-extra-files" '' + empty_file=$(${pkgs.coreutils}/bin/mktemp) + + ${concatStrings (mapAttrsToList (n: v: '' + ${pkgs.coreutils}/bin/install -Dp "${v}" "${bootMountPoint}/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -D $empty_file "${bootMountPoint}/${nixosDir}/.extra-files/"${escapeShellArg n} + '') cfg.extraFiles)} + + ${concatStrings (mapAttrsToList (n: v: '' + ${pkgs.coreutils}/bin/install -Dp "${pkgs.writeText n v}" "${bootMountPoint}/loader/entries/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -D $empty_file "${bootMountPoint}/${nixosDir}/.extra-files/loader/entries/"${escapeShellArg n} + '') cfg.extraEntries)} + ''; + }; + + finalSystemdBootBuilder = pkgs.writeScript "install-systemd-boot.sh" '' + #!${pkgs.runtimeShell} + ${systemdBootBuilder} "$@" + ${cfg.extraInstallCommands} + ''; +in { + # NOTE(m): This module overrides the default NixOS systemd-boot module to include + # a custom systemd-boot-builder.py script that generates more user friendly bootloader entries + disabledModules = [ "system/boot/loader/systemd-boot/systemd-boot.nix" ]; + meta.maintainers = with lib.maintainers; [ michaelshmitty ]; + + options.boot.loader.systemd-boot = { + enable = mkOption { + default = false; + + type = types.bool; + + description = '' + Whether to enable the systemd-boot (formerly gummiboot) EFI boot manager. + For more information about systemd-boot: + https://www.freedesktop.org/wiki/Software/systemd/systemd-boot/ + ''; + }; + + sortKey = mkOption { + default = "nixos"; + type = lib.types.str; + description = '' + The sort key used for the NixOS bootloader entries. + This key determines sorting relative to non-NixOS entries. + See also https://uapi-group.org/specifications/specs/boot_loader_specification/#sorting + + This option can also be used to control the sorting of NixOS specialisations. + + By default, specialisations inherit the sort key of their parent generation + and will have the same value for both the sort-key and the version (i.e. the generation number), + systemd-boot will therefore sort them based on their file name, meaning that + in your boot menu you will have each main generation directly followed by + its specialisations sorted alphabetically by their names. + + If you want a different ordering for a specialisation, you can override + its sort-key which will cause the specialisation to be uncoupled from its + parent generation. It will then be sorted by its new sort-key just like + any other boot entry. + + The sort-key is stored in the generation's bootspec, which means that + generations keep their sort-keys even if the original definition of the + generation was removed from the NixOS configuration. + It also means that updating the sort-key will only affect new generations, + while old ones will keep the sort-key that they were originally built with. + ''; + }; + + editor = mkOption { + default = true; + + type = types.bool; + + description = '' + Whether to allow editing the kernel command-line before + boot. It is recommended to set this to false, as it allows + gaining root access by passing init=/bin/sh as a kernel + parameter. However, it is enabled by default for backwards + compatibility. + ''; + }; + + xbootldrMountPoint = mkOption { + default = null; + type = types.nullOr types.str; + description = '' + Where the XBOOTLDR partition is mounted. + + If set, this partition will be used as $BOOT to store boot loader entries and extra files + instead of the EFI partition. As per the bootloader specification, it is recommended that + the EFI and XBOOTLDR partitions be mounted at `/efi` and `/boot`, respectively. + ''; + }; + + configurationLimit = mkOption { + default = null; + example = 120; + type = types.nullOr types.int; + description = '' + Maximum number of latest generations in the boot menu. + Useful to prevent boot partition running out of disk space. + + `null` means no limit i.e. all generations + that have not been garbage collected yet. + ''; + }; + + installDeviceTree = mkOption { + default = with config.hardware.deviceTree; enable && name != null; + defaultText = ''with config.hardware.deviceTree; enable && name != null''; + description = '' + Install the devicetree blob specified by `config.hardware.deviceTree.name` + to the ESP and instruct systemd-boot to pass this DTB to linux. + ''; + }; + + extraInstallCommands = mkOption { + default = ""; + example = '' + default_cfg=$(cat /boot/loader/loader.conf | grep default | awk '{print $2}') + init_value=$(cat /boot/loader/entries/$default_cfg | grep init= | awk '{print $2}') + sed -i "s|@INIT@|$init_value|g" /boot/custom/config_with_placeholder.conf + ''; + type = types.lines; + description = '' + Additional shell commands inserted in the bootloader installer + script after generating menu entries. It can be used to expand + on extra boot entries that cannot incorporate certain pieces of + information (such as the resulting `init=` kernel parameter). + ''; + }; + + consoleMode = mkOption { + default = "keep"; + + type = types.enum [ "0" "1" "2" "auto" "max" "keep" ]; + + description = '' + The resolution of the console. The following values are valid: + + - `"0"`: Standard UEFI 80x25 mode + - `"1"`: 80x50 mode, not supported by all devices + - `"2"`: The first non-standard mode provided by the device firmware, if any + - `"auto"`: Pick a suitable mode automatically using heuristics + - `"max"`: Pick the highest-numbered available mode + - `"keep"`: Keep the mode selected by firmware (the default) + ''; + }; + + memtest86 = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + Make Memtest86+ available from the systemd-boot menu. Memtest86+ is a + program for testing memory. + ''; + }; + + sortKey = mkOption { + default = "o_memtest86"; + type = types.str; + description = '' + `systemd-boot` orders the menu entries by their sort keys, + so if you want something to appear after all the NixOS entries, + it should start with {file}`o` or onwards. + + See also {option}`boot.loader.systemd-boot.sortKey`. + ''; + }; + }; + + netbootxyz = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + Make `netboot.xyz` available from the + `systemd-boot` menu. `netboot.xyz` + is a menu system that allows you to boot OS installers and + utilities over the network. + ''; + }; + + sortKey = mkOption { + default = "o_netbootxyz"; + type = types.str; + description = '' + `systemd-boot` orders the menu entries by their sort keys, + so if you want something to appear after all the NixOS entries, + it should start with {file}`o` or onwards. + + See also {option}`boot.loader.systemd-boot.sortKey`. + ''; + }; + }; + + extraEntries = mkOption { + type = types.attrsOf types.lines; + default = {}; + example = literalExpression '' + { "memtest86.conf" = ''' + title Memtest86+ + efi /efi/memtest86/memtest.efi + sort-key z_memtest + '''; } + ''; + description = '' + Any additional entries you want added to the `systemd-boot` menu. + These entries will be copied to {file}`$BOOT/loader/entries`. + Each attribute name denotes the destination file name, + and the corresponding attribute value is the contents of the entry. + + To control the ordering of the entry in the boot menu, use the sort-key + field, see + https://uapi-group.org/specifications/specs/boot_loader_specification/#sorting + and {option}`boot.loader.systemd-boot.sortKey`. + ''; + }; + + extraFiles = mkOption { + type = types.attrsOf types.path; + default = {}; + example = literalExpression '' + { "efi/memtest86/memtest.efi" = "''${pkgs.memtest86plus}/memtest.efi"; } + ''; + description = '' + A set of files to be copied to {file}`$BOOT`. + Each attribute name denotes the destination file name in + {file}`$BOOT`, while the corresponding + attribute value specifies the source file. + ''; + }; + + graceful = mkOption { + default = false; + + type = types.bool; + + description = '' + Invoke `bootctl install` with the `--graceful` option, + which ignores errors when EFI variables cannot be written or when the EFI System Partition + cannot be found. Currently only applies to random seed operations. + + Only enable this option if `systemd-boot` otherwise fails to install, as the + scope or implication of the `--graceful` option may change in the future. + ''; + }; + + rebootForBitlocker = mkOption { + default = false; + + type = types.bool; + + description = '' + Enable *EXPERIMENTAL* BitLocker support. + + Try to detect BitLocker encrypted drives along with an active + TPM. If both are found and Windows Boot Manager is selected in + the boot menu, set the "BootNext" EFI variable and restart the + system. The firmware will then start Windows Boot Manager + directly, leaving the TPM PCRs in expected states so that + Windows can unseal the encryption key. + ''; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = (hasPrefix "/" efi.efiSysMountPoint); + message = "The ESP mount point '${toString efi.efiSysMountPoint}' must be an absolute path"; + } + { + assertion = cfg.xbootldrMountPoint == null || (hasPrefix "/" cfg.xbootldrMountPoint); + message = "The XBOOTLDR mount point '${toString cfg.xbootldrMountPoint}' must be an absolute path"; + } + { + assertion = cfg.xbootldrMountPoint != efi.efiSysMountPoint; + message = "The XBOOTLDR mount point '${toString cfg.xbootldrMountPoint}' cannot be the same as the ESP mount point '${toString efi.efiSysMountPoint}'"; + } + { + assertion = (config.boot.kernelPackages.kernel.features or { efiBootStub = true; }) ? efiBootStub; + message = "This kernel does not support the EFI boot stub"; + } + { + assertion = cfg.installDeviceTree -> config.hardware.deviceTree.enable -> config.hardware.deviceTree.name != null; + message = "Cannot install devicetree without 'config.hardware.deviceTree.enable' enabled and 'config.hardware.deviceTree.name' set"; + } + ] ++ concatMap (filename: [ + { + assertion = !(hasInfix "/" filename); + message = "boot.loader.systemd-boot.extraEntries.${lib.strings.escapeNixIdentifier filename} is invalid: entries within folders are not supported"; + } + { + assertion = hasSuffix ".conf" filename; + message = "boot.loader.systemd-boot.extraEntries.${lib.strings.escapeNixIdentifier filename} is invalid: entries must have a .conf file extension"; + } + ]) (builtins.attrNames cfg.extraEntries) + ++ concatMap (filename: [ + { + assertion = !(hasPrefix "/" filename); + message = "boot.loader.systemd-boot.extraFiles.${lib.strings.escapeNixIdentifier filename} is invalid: paths must not begin with a slash"; + } + { + assertion = !(hasInfix ".." filename); + message = "boot.loader.systemd-boot.extraFiles.${lib.strings.escapeNixIdentifier filename} is invalid: paths must not reference the parent directory"; + } + { + assertion = !(hasInfix "nixos/.extra-files" (toLower filename)); + message = "boot.loader.systemd-boot.extraFiles.${lib.strings.escapeNixIdentifier filename} is invalid: files cannot be placed in the nixos/.extra-files directory"; + } + ]) (builtins.attrNames cfg.extraFiles); + + boot.loader.grub.enable = mkDefault false; + + boot.loader.supportsInitrdSecrets = true; + + boot.loader.systemd-boot.extraFiles = mkMerge [ + (mkIf cfg.memtest86.enable { + "efi/memtest86/memtest.efi" = "${pkgs.memtest86plus.efi}"; + }) + (mkIf cfg.netbootxyz.enable { + "efi/netbootxyz/netboot.xyz.efi" = "${pkgs.netbootxyz-efi}"; + }) + ]; + + boot.loader.systemd-boot.extraEntries = mkMerge [ + (mkIf cfg.memtest86.enable { + "memtest86.conf" = '' + title Memtest86+ + efi /efi/memtest86/memtest.efi + sort-key ${cfg.memtest86.sortKey} + ''; + }) + (mkIf cfg.netbootxyz.enable { + "netbootxyz.conf" = '' + title netboot.xyz + efi /efi/netbootxyz/netboot.xyz.efi + sort-key ${cfg.netbootxyz.sortKey} + ''; + }) + ]; + + boot.bootspec.extensions."org.nixos.systemd-boot" = { + inherit (config.boot.loader.systemd-boot) sortKey; + devicetree = lib.mkIf cfg.installDeviceTree "${config.hardware.deviceTree.package}/${config.hardware.deviceTree.name}"; + }; + + system = { + build.installBootLoader = finalSystemdBootBuilder; + + boot.loader.id = "systemd-boot"; + + requiredKernelConfig = with config.lib.kernelConfig; [ + (isYes "EFI_STUB") + ]; + }; + }; +} diff --git a/modules/systemd-boot/systemd-boot-builder.py b/modules/systemd-boot/systemd-boot-builder.py new file mode 100644 index 0000000..e5febe1 --- /dev/null +++ b/modules/systemd-boot/systemd-boot-builder.py @@ -0,0 +1,433 @@ +#! @python3@/bin/python3 -B +import argparse +import ctypes +import datetime +import errno +import glob +import os +import os.path +import re +import shutil +import subprocess +import sys +import warnings +import json +from typing import NamedTuple, Any +from dataclasses import dataclass + +# These values will be replaced with actual values during the package build +EFI_SYS_MOUNT_POINT = "@efiSysMountPoint@" +BOOT_MOUNT_POINT = "@bootMountPoint@" +LOADER_CONF = f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf" # Always stored on the ESP +NIXOS_DIR = "@nixosDir@" +TIMEOUT = "@timeout@" +EDITOR = "@editor@" == "1" # noqa: PLR0133 +CONSOLE_MODE = "@consoleMode@" +BOOTSPEC_TOOLS = "@bootspecTools@" +DISTRO_NAME = "@distroName@" +NIX = "@nix@" +SYSTEMD = "@systemd@" +CONFIGURATION_LIMIT = int("@configurationLimit@") +REBOOT_FOR_BITLOCKER = bool("@rebootForBitlocker@") +CAN_TOUCH_EFI_VARIABLES = "@canTouchEfiVariables@" +GRACEFUL = "@graceful@" +COPY_EXTRA_FILES = "@copyExtraFiles@" +CHECK_MOUNTPOINTS = "@checkMountpoints@" + +@dataclass +class BootSpec: + init: str + initrd: str + kernel: str + kernelParams: list[str] # noqa: N815 + label: str + system: str + toplevel: str + specialisations: dict[str, "BootSpec"] + sortKey: str # noqa: N815 + devicetree: str | None = None # noqa: N815 + initrdSecrets: str | None = None # noqa: N815 + + +libc = ctypes.CDLL("libc.so.6") + +FILE = None | int + +def run(cmd: list[str], stdout: FILE = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, check=True, text=True, stdout=stdout) + +class SystemIdentifier(NamedTuple): + profile: str | None + generation: int + specialisation: str | None + + +def copy_if_not_exists(source: str, dest: str) -> None: + if not os.path.exists(dest): + shutil.copyfile(source, dest) + + +def generation_dir(profile: str | None, generation: int) -> str: + if profile: + return "/nix/var/nix/profiles/system-profiles/%s-%d-link" % (profile, generation) + else: + return "/nix/var/nix/profiles/system-%d-link" % (generation) + +def system_dir(profile: str | None, generation: int, specialisation: str | None) -> str: + d = generation_dir(profile, generation) + if specialisation: + return os.path.join(d, "specialisation", specialisation) + else: + return d + +BOOT_ENTRY = """title {title} Update {generation} +sort-key {sort_key} +version {pretty_build_time} +linux {kernel} +initrd {initrd} +options {kernel_params} +""" + +def generation_conf_filename(profile: str | None, generation: int, specialisation: str | None) -> str: + pieces = [ + "nixos", + profile or None, + "generation", + str(generation), + f"specialisation-{specialisation}" if specialisation else None, + ] + return "-".join(p for p in pieces if p) + ".conf" + + +def write_loader_conf(profile: str | None, generation: int, specialisation: str | None) -> None: + with open(f"{LOADER_CONF}.tmp", 'w') as f: + f.write(f"timeout {TIMEOUT}\n") + f.write("default %s\n" % generation_conf_filename(profile, generation, specialisation)) + if not EDITOR: + f.write("editor 0\n") + if REBOOT_FOR_BITLOCKER: + f.write("reboot-for-bitlocker yes\n"); + f.write(f"console-mode {CONSOLE_MODE}\n") + f.flush() + os.fsync(f.fileno()) + os.rename(f"{LOADER_CONF}.tmp", LOADER_CONF) + + +def get_bootspec(profile: str | None, generation: int) -> BootSpec: + system_directory = system_dir(profile, generation, None) + boot_json_path = os.path.realpath("%s/%s" % (system_directory, "boot.json")) + if os.path.isfile(boot_json_path): + boot_json_f = open(boot_json_path, 'r') + bootspec_json = json.load(boot_json_f) + else: + boot_json_str = run( + [ + f"{BOOTSPEC_TOOLS}/bin/synthesize", + "--version", + "1", + system_directory, + "/dev/stdout", + ], + stdout=subprocess.PIPE, + ).stdout + bootspec_json = json.loads(boot_json_str) + return bootspec_from_json(bootspec_json) + +def bootspec_from_json(bootspec_json: dict[str, Any]) -> BootSpec: + specialisations = bootspec_json['org.nixos.specialisation.v1'] + specialisations = {k: bootspec_from_json(v) for k, v in specialisations.items()} + systemdBootExtension = bootspec_json.get('org.nixos.systemd-boot', {}) + sortKey = systemdBootExtension.get('sortKey', 'nixos') + devicetree = systemdBootExtension.get('devicetree') + return BootSpec( + **bootspec_json['org.nixos.bootspec.v1'], + specialisations=specialisations, + sortKey=sortKey, + devicetree=devicetree, + ) + + +def copy_from_file(file: str, dry_run: bool = False) -> str: + store_file_path = os.path.realpath(file) + suffix = os.path.basename(store_file_path) + store_dir = os.path.basename(os.path.dirname(store_file_path)) + efi_file_path = f"{NIXOS_DIR}/{store_dir}-{suffix}.efi" + if not dry_run: + copy_if_not_exists(store_file_path, f"{BOOT_MOUNT_POINT}{efi_file_path}") + return efi_file_path + +def write_entry(profile: str | None, generation: int, specialisation: str | None, + machine_id: str, bootspec: BootSpec, current: bool) -> None: + if specialisation: + bootspec = bootspec.specialisations[specialisation] + kernel = copy_from_file(bootspec.kernel) + initrd = copy_from_file(bootspec.initrd) + devicetree = copy_from_file(bootspec.devicetree) if bootspec.devicetree is not None else None + + title = "{name}{profile}{specialisation}".format( + name=DISTRO_NAME, + profile=" [" + profile + "]" if profile else "", + specialisation=" (%s)" % specialisation if specialisation else "") + + try: + if bootspec.initrdSecrets is not None: + run([bootspec.initrdSecrets, f"{BOOT_MOUNT_POINT}%s" % (initrd)]) + except subprocess.CalledProcessError: + if current: + print("failed to create initrd secrets!", file=sys.stderr) + sys.exit(1) + else: + print("warning: failed to create initrd secrets " + f'for "{title} - Configuration {generation}", an older generation', file=sys.stderr) + print("note: this is normal after having removed " + "or renamed a file in `boot.initrd.secrets`", file=sys.stderr) + entry_file = f"{BOOT_MOUNT_POINT}/loader/entries/%s" % ( + generation_conf_filename(profile, generation, specialisation)) + tmp_path = "%s.tmp" % (entry_file) + kernel_params = "init=%s " % bootspec.init + + kernel_params = kernel_params + " ".join(bootspec.kernelParams) + build_time = int(os.path.getctime(system_dir(profile, generation, specialisation))) + pretty_build_time = datetime.datetime.fromtimestamp(build_time).strftime('%x %X') + + with open(tmp_path, 'w') as f: + f.write(BOOT_ENTRY.format(title=title, + sort_key=bootspec.sortKey, + generation=generation, + kernel=kernel, + initrd=initrd, + kernel_params=kernel_params, + pretty_build_time=pretty_build_time)) + if machine_id is not None: + f.write("machine-id %s\n" % machine_id) + if devicetree is not None: + f.write("devicetree %s\n" % devicetree) + f.flush() + os.fsync(f.fileno()) + os.rename(tmp_path, entry_file) + + +def get_generations(profile: str | None = None) -> list[SystemIdentifier]: + gen_list = run( + [ + f"{NIX}/bin/nix-env", + "--list-generations", + "-p", + "/nix/var/nix/profiles/%s" + % ("system-profiles/" + profile if profile else "system"), + ], + stdout=subprocess.PIPE, + ).stdout + gen_lines = gen_list.split("\n") + gen_lines.pop() + + configurationLimit = CONFIGURATION_LIMIT + configurations = [ + SystemIdentifier( + profile=profile, + generation=int(line.split()[0]), + specialisation=None + ) + for line in gen_lines + ] + return configurations[-configurationLimit:] + + +def remove_old_entries(gens: list[SystemIdentifier]) -> None: + rex_profile = re.compile(r"^" + re.escape(BOOT_MOUNT_POINT) + r"/loader/entries/nixos-(.*)-generation-.*\.conf$") + rex_generation = re.compile(r"^" + re.escape(BOOT_MOUNT_POINT) + r"/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$") + known_paths = [] + for gen in gens: + bootspec = get_bootspec(gen.profile, gen.generation) + known_paths.append(copy_from_file(bootspec.kernel, True)) + known_paths.append(copy_from_file(bootspec.initrd, True)) + for path in glob.iglob(f"{BOOT_MOUNT_POINT}/loader/entries/nixos*-generation-[1-9]*.conf"): + if rex_profile.match(path): + prof = rex_profile.sub(r"\1", path) + else: + prof = None + try: + gen_number = int(rex_generation.sub(r"\1", path)) + except ValueError: + continue + if (prof, gen_number, None) not in gens: + os.unlink(path) + for path in glob.iglob(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/*"): + if path not in known_paths and not os.path.isdir(path): + os.unlink(path) + + +def cleanup_esp() -> None: + for path in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/loader/entries/nixos*"): + os.unlink(path) + if os.path.isdir(f"{EFI_SYS_MOUNT_POINT}/{NIXOS_DIR}"): + shutil.rmtree(f"{EFI_SYS_MOUNT_POINT}/{NIXOS_DIR}") + + +def get_profiles() -> list[str]: + if os.path.isdir("/nix/var/nix/profiles/system-profiles/"): + return [x + for x in os.listdir("/nix/var/nix/profiles/system-profiles/") + if not x.endswith("-link")] + else: + return [] + +def install_bootloader(args: argparse.Namespace) -> None: + try: + with open("/etc/machine-id") as machine_file: + machine_id = machine_file.readlines()[0] + except IOError as e: + if e.errno != errno.ENOENT: + raise + # Since systemd version 232 a machine ID is required and it might not + # be there on newly installed systems, so let's generate one so that + # bootctl can find it and we can also pass it to write_entry() later. + cmd = [f"{SYSTEMD}/bin/systemd-machine-id-setup", "--print"] + machine_id = run(cmd, stdout=subprocess.PIPE).stdout.rstrip() + + if os.getenv("NIXOS_INSTALL_GRUB") == "1": + warnings.warn("NIXOS_INSTALL_GRUB env var deprecated, use NIXOS_INSTALL_BOOTLOADER", DeprecationWarning) + os.environ["NIXOS_INSTALL_BOOTLOADER"] = "1" + + # flags to pass to bootctl install/update + bootctl_flags = [] + + if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: + bootctl_flags.append(f"--boot-path={BOOT_MOUNT_POINT}") + + if CAN_TOUCH_EFI_VARIABLES != "1": + bootctl_flags.append("--no-variables") + + if GRACEFUL == "1": + bootctl_flags.append("--graceful") + + if os.getenv("NIXOS_INSTALL_BOOTLOADER") == "1": + # bootctl uses fopen() with modes "wxe" and fails if the file exists. + if os.path.exists(LOADER_CONF): + os.unlink(LOADER_CONF) + + run( + [f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + + bootctl_flags + + ["install"] + ) + else: + # Update bootloader to latest if needed + available_out = run( + [f"{SYSTEMD}/bin/bootctl", "--version"], stdout=subprocess.PIPE + ).stdout.split()[2] + installed_out = run( + [f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}", "status"], + stdout=subprocess.PIPE, + ).stdout + + # See status_binaries() in systemd bootctl.c for code which generates this + # Matches + # Available Boot Loaders on ESP: + # ESP: /boot (/dev/disk/by-partuuid/9b39b4c4-c48b-4ebf-bfea-a56b2395b7e0) + # File: └─/EFI/systemd/systemd-bootx64.efi (systemd-boot 255.2) + # But also: + # Available Boot Loaders on ESP: + # ESP: /boot (/dev/disk/by-partuuid/9b39b4c4-c48b-4ebf-bfea-a56b2395b7e0) + # File: ├─/EFI/systemd/HashTool.efi + # └─/EFI/systemd/systemd-bootx64.efi (systemd-boot 255.2) + installed_match = re.search(r"^\W+.*/EFI/(?:BOOT|systemd)/.*\.efi \(systemd-boot ([\d.]+[^)]*)\)$", + installed_out, re.IGNORECASE | re.MULTILINE) + + available_match = re.search(r"^\((.*)\)$", available_out) + + if installed_match is None: + raise Exception("could not find any previously installed systemd-boot") + + if available_match is None: + raise Exception("could not determine systemd-boot version") + + installed_version = installed_match.group(1) + available_version = available_match.group(1) + + if installed_version < available_version: + print("updating systemd-boot from %s to %s" % (installed_version, available_version)) + run( + [f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + + bootctl_flags + + ["update"] + ) + + os.makedirs(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}", exist_ok=True) + os.makedirs(f"{BOOT_MOUNT_POINT}/loader/entries", exist_ok=True) + + gens = get_generations() + for profile in get_profiles(): + gens += get_generations(profile) + + remove_old_entries(gens) + + for gen in gens: + try: + bootspec = get_bootspec(gen.profile, gen.generation) + is_default = os.path.dirname(bootspec.init) == args.default_config + write_entry(*gen, machine_id, bootspec, current=is_default) + for specialisation in bootspec.specialisations.keys(): + write_entry(gen.profile, gen.generation, specialisation, machine_id, bootspec, current=is_default) + if is_default: + write_loader_conf(*gen) + except OSError as e: + # See https://github.com/NixOS/nixpkgs/issues/114552 + if e.errno == errno.EINVAL: + profile = f"profile '{gen.profile}'" if gen.profile else "default profile" + print("ignoring {} in the list of boot entries because of the following error:\n{}".format(profile, e), file=sys.stderr) + else: + raise e + + if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: + # Cleanup any entries in ESP if xbootldrMountPoint is set. + # If the user later unsets xbootldrMountPoint, entries in XBOOTLDR will not be cleaned up + # automatically, as we don't have information about the mount point anymore. + cleanup_esp() + + for root, _, files in os.walk(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/.extra-files", topdown=False): + relative_root = root.removeprefix(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/.extra-files").removeprefix("/") + actual_root = os.path.join(f"{BOOT_MOUNT_POINT}", relative_root) + + for file in files: + actual_file = os.path.join(actual_root, file) + + if os.path.exists(actual_file): + os.unlink(actual_file) + os.unlink(os.path.join(root, file)) + + if not len(os.listdir(actual_root)): + os.rmdir(actual_root) + os.rmdir(root) + + os.makedirs(f"{BOOT_MOUNT_POINT}/{NIXOS_DIR}/.extra-files", exist_ok=True) + + run([COPY_EXTRA_FILES]) + + +def main() -> None: + parser = argparse.ArgumentParser(description=f"Update {DISTRO_NAME}-related systemd-boot files") + parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help=f"The default {DISTRO_NAME} config to boot") + args = parser.parse_args() + + run([CHECK_MOUNTPOINTS]) + + try: + install_bootloader(args) + finally: + # Since fat32 provides little recovery facilities after a crash, + # it can leave the system in an unbootable state, when a crash/outage + # happens shortly after an update. To decrease the likelihood of this + # event sync the efi filesystem after each update. + rc = libc.syncfs(os.open(f"{BOOT_MOUNT_POINT}", os.O_RDONLY)) + if rc != 0: + print(f"could not sync {BOOT_MOUNT_POINT}: {os.strerror(rc)}", file=sys.stderr) + + if BOOT_MOUNT_POINT != EFI_SYS_MOUNT_POINT: + rc = libc.syncfs(os.open(EFI_SYS_MOUNT_POINT, os.O_RDONLY)) + if rc != 0: + print(f"could not sync {EFI_SYS_MOUNT_POINT}: {os.strerror(rc)}", file=sys.stderr) + + +if __name__ == '__main__': + main()