diff --git a/hosts/gerg-desktop/zfs.nix b/hosts/gerg-desktop/zfs.nix index 8651445..067f7b6 100644 --- a/hosts/gerg-desktop/zfs.nix +++ b/hosts/gerg-desktop/zfs.nix @@ -57,11 +57,16 @@ _: { loader = { generationsDir.copyKernels = true; #override default - systemd-boot.enable = false; - + systemd-boot = { + enable = true; + mirroredBoots = [ + "/efi0E" + "/efi22" + ]; + }; efi.canTouchEfiVariables = false; grub = { - enable = true; + enable = false; copyKernels = true; efiInstallAsRemovable = true; efiSupport = true; diff --git a/modules/systemd-boot/default.nix b/modules/systemd-boot/default.nix new file mode 100644 index 0000000..104c91b --- /dev/null +++ b/modules/systemd-boot/default.nix @@ -0,0 +1,347 @@ +_: { + config, + lib, + pkgs, + modulesPath, + ... +}: +with lib; let + cfg = config.boot.loader.systemd-boot; + + inherit (config.boot.loader) efi; + + python3 = pkgs.python3.withPackages (ps: [ps.packaging]); + + systemdBootBuilder = mountPoint: + pkgs.substituteAll { + src = ./systemd-boot-builder.py; + + isExecutable = true; + + inherit python3; + + systemd = config.systemd.package; + + nix = config.nix.package.out; + + timeout = optionalString (config.boot.loader.timeout != null) config.boot.loader.timeout; + + editor = + if cfg.editor + then "True" + else "False"; + + configurationLimit = + if cfg.configurationLimit == null + then 0 + else cfg.configurationLimit; + + inherit (cfg) consoleMode graceful; + + inherit (efi) canTouchEfiVariables; + + inherit mountPoint; + + inherit (config.system.nixos) distroName; + + memtest86 = optionalString cfg.memtest86.enable pkgs.memtest86-efi; + + netbootxyz = optionalString cfg.netbootxyz.enable pkgs.netbootxyz-efi; + + copyExtraFiles = pkgs.writeShellScript "copy-extra-files" '' + empty_file=$(${pkgs.coreutils}/bin/mktemp) + + ${concatStrings (mapAttrsToList (n: v: '' + ${pkgs.coreutils}/bin/install -Dp "${v}" "${mountPoint}/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -D $empty_file "${mountPoint}/efi/nixos/.extra-files/"${escapeShellArg n} + '') + cfg.extraFiles)} + + ${concatStrings (mapAttrsToList (n: v: '' + ${pkgs.coreutils}/bin/install -Dp "${pkgs.writeText n v}" "${mountPoint}/loader/entries/"${escapeShellArg n} + ${pkgs.coreutils}/bin/install -D $empty_file "${mountPoint}/efi/nixos/.extra-files/loader/entries/"${escapeShellArg n} + '') + cfg.extraEntries)} + ''; + }; + + checkedSystemdBootBuilder = mountPoint: + pkgs.runCommand "systemd-boot" { + nativeBuildInputs = [pkgs.mypy python3]; + } '' + install -m755 ${systemdBootBuilder mountPoint} $out + mypy \ + --no-implicit-optional \ + --disallow-untyped-calls \ + --disallow-untyped-defs \ + $out + ''; + + finalSystemdBootBuilder = let + installDirs = + if cfg.mirroredBoots != [] + then cfg.mirroredBoots + else [efi.efiSysMountPoint]; + in + pkgs.writeShellScript "install-systemd-boot.sh" + (lib.concatMapStrings (x: "${checkedSystemdBootBuilder x} \"$@\"\n") installDirs) + + cfg.extraInstallCommands; +in { + disabledModules = ["${modulesPath}/system/boot/loader/systemd-boot/systemd-boot.nix"]; + + imports = [ + (mkRenamedOptionModule ["boot" "loader" "gummiboot" "enable"] ["boot" "loader" "systemd-boot" "enable"]) + ]; + + options.boot.loader.systemd-boot = { + enable = mkOption { + default = false; + + type = types.bool; + + description = lib.mdDoc "Whether to enable the systemd-boot (formerly gummiboot) EFI boot manager"; + }; + + editor = mkOption { + default = true; + + type = types.bool; + + description = lib.mdDoc '' + 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. + ''; + }; + + configurationLimit = mkOption { + default = null; + example = 120; + type = types.nullOr types.int; + description = lib.mdDoc '' + 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 were not garbage collected yet. + ''; + }; + + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + 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 = lib.mdDoc '' + Make MemTest86 available from the systemd-boot menu. MemTest86 is a + program for testing memory. MemTest86 is an unfree program, so + this requires `allowUnfree` to be set to + `true`. + ''; + }; + + entryFilename = mkOption { + default = "memtest86.conf"; + type = types.str; + description = lib.mdDoc '' + `systemd-boot` orders the menu entries by the config file names, + so if you want something to appear after all the NixOS entries, + it should start with {file}`o` or onwards. + ''; + }; + }; + + netbootxyz = { + enable = mkOption { + default = false; + type = types.bool; + description = lib.mdDoc '' + 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. + ''; + }; + + entryFilename = mkOption { + default = "o_netbootxyz.conf"; + type = types.str; + description = lib.mdDoc '' + `systemd-boot` orders the menu entries by the config file names, + so if you want something to appear after all the NixOS entries, + it should start with {file}`o` or onwards. + ''; + }; + }; + + extraEntries = mkOption { + type = types.attrsOf types.lines; + default = {}; + example = literalExpression '' + { "memtest86.conf" = ''' + title MemTest86 + efi /efi/memtest86/memtest86.efi + '''; } + ''; + description = lib.mdDoc '' + 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. + + `systemd-boot` orders the menu entries by the config file names, + so if you want something to appear after all the NixOS entries, + it should start with {file}`o` or onwards. + ''; + }; + + extraFiles = mkOption { + type = types.attrsOf types.path; + default = {}; + example = literalExpression '' + { "efi/memtest86/memtest86.efi" = "''${pkgs.memtest86-efi}/BOOTX64.efi"; } + ''; + description = lib.mdDoc '' + 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 = lib.mdDoc '' + 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. + ''; + }; + + mirroredBoots = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + example = '' + [ "/boot1" "/boot2" ] + ''; + description = lib.mdDoc '' + Mirror the boot configuration to multiple locations. + ''; + }; + }; + + config = mkIf cfg.enable { + assertions = + [ + { + assertion = (config.boot.kernelPackages.kernel.features or {efiBootStub = true;}) ? efiBootStub; + message = "This kernel does not support the EFI boot stub"; + } + ] + ++ 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 [ + # TODO: This is hard-coded to use the 64-bit EFI app, but it could probably + # be updated to use the 32-bit EFI app on 32-bit systems. The 32-bit EFI + # app filename is BOOTIA32.efi. + (mkIf cfg.memtest86.enable { + "efi/memtest86/BOOTX64.efi" = "${pkgs.memtest86-efi}/BOOTX64.efi"; + }) + (mkIf cfg.netbootxyz.enable { + "efi/netbootxyz/netboot.xyz.efi" = "${pkgs.netbootxyz-efi}"; + }) + ]; + + boot.loader.systemd-boot.extraEntries = mkMerge [ + (mkIf cfg.memtest86.enable { + "${cfg.memtest86.entryFilename}" = '' + title MemTest86 + efi /efi/memtest86/BOOTX64.efi + ''; + }) + (mkIf cfg.netbootxyz.enable { + "${cfg.netbootxyz.entryFilename}" = '' + title netboot.xyz + efi /efi/netbootxyz/netboot.xyz.efi + ''; + }) + ]; + + 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..85a349f --- /dev/null +++ b/modules/systemd-boot/systemd-boot-builder.py @@ -0,0 +1,341 @@ +#! @python3@/bin/python3 -B +import argparse +import shutil +import os +import sys +import errno +import subprocess +import glob +import tempfile +import errno +import warnings +import ctypes +libc = ctypes.CDLL("libc.so.6") +import re +import datetime +import glob +import os.path +from typing import NamedTuple, List, Optional +from packaging import version + +class SystemIdentifier(NamedTuple): + profile: Optional[str] + generation: int + specialisation: Optional[str] + + +def copy_if_not_exists(source: str, dest: str) -> None: + if not os.path.exists(dest): + shutil.copyfile(source, dest) + + +def generation_dir(profile: Optional[str], 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: Optional[str], generation: int, specialisation: Optional[str]) -> str: + d = generation_dir(profile, generation) + if specialisation: + return os.path.join(d, "specialisation", specialisation) + else: + return d + +BOOT_ENTRY = """title {title} +version Generation {generation} {description} +linux {kernel} +initrd {initrd} +options {kernel_params} +""" + +def generation_conf_filename(profile: Optional[str], generation: int, specialisation: Optional[str]) -> 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: Optional[str], generation: int, specialisation: Optional[str]) -> None: + with open("@mountPoint@/loader/loader.conf.tmp", 'w') as f: + if "@timeout@" != "": + f.write("timeout @timeout@\n") + f.write("default %s\n" % generation_conf_filename(profile, generation, specialisation)) + if not @editor@: + f.write("editor 0\n"); + f.write("console-mode @consoleMode@\n"); + os.rename("@mountPoint@/loader/loader.conf.tmp", "@mountPoint@/loader/loader.conf") + + +def profile_path(profile: Optional[str], generation: int, specialisation: Optional[str], name: str) -> str: + return os.path.realpath("%s/%s" % (system_dir(profile, generation, specialisation), name)) + + +def copy_from_profile(profile: Optional[str], generation: int, specialisation: Optional[str], name: str, dry_run: bool = False) -> str: + store_file_path = profile_path(profile, generation, specialisation, name) + suffix = os.path.basename(store_file_path) + store_dir = os.path.basename(os.path.dirname(store_file_path)) + efi_file_path = "/efi/nixos/%s-%s.efi" % (store_dir, suffix) + if not dry_run: + copy_if_not_exists(store_file_path, "@mountPoint@%s" % (efi_file_path)) + return efi_file_path + + +def describe_generation(profile: Optional[str], generation: int, specialisation: Optional[str]) -> str: + try: + with open(profile_path(profile, generation, specialisation, "nixos-version")) as f: + nixos_version = f.read() + except IOError: + nixos_version = "Unknown" + + kernel_dir = os.path.dirname(profile_path(profile, generation, specialisation, "kernel")) + module_dir = glob.glob("%s/lib/modules/*" % kernel_dir)[0] + kernel_version = os.path.basename(module_dir) + + build_time = int(os.path.getctime(system_dir(profile, generation, specialisation))) + build_date = datetime.datetime.fromtimestamp(build_time).strftime('%F') + + description = "@distroName@ {}, Linux Kernel {}, Built on {}".format( + nixos_version, kernel_version, build_date + ) + + return description + + +def write_entry(profile: Optional[str], generation: int, specialisation: Optional[str], + machine_id: str, current: bool) -> None: + kernel = copy_from_profile(profile, generation, specialisation, "kernel") + initrd = copy_from_profile(profile, generation, specialisation, "initrd") + + title = "@distroName@{profile}{specialisation}".format( + profile=" [" + profile + "]" if profile else "", + specialisation=" (%s)" % specialisation if specialisation else "") + + try: + append_initrd_secrets = profile_path(profile, generation, specialisation, "append-initrd-secrets") + subprocess.check_call([append_initrd_secrets, "@mountPoint@%s" % (initrd)]) + except FileNotFoundError: + pass + 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 = "@mountPoint@/loader/entries/%s" % ( + generation_conf_filename(profile, generation, specialisation)) + tmp_path = "%s.tmp" % (entry_file) + kernel_params = "init=%s " % profile_path(profile, generation, specialisation, "init") + + with open(profile_path(profile, generation, specialisation, "kernel-params")) as params_file: + kernel_params = kernel_params + params_file.read() + with open(tmp_path, 'w') as f: + f.write(BOOT_ENTRY.format(title=title, + generation=generation, + kernel=kernel, + initrd=initrd, + kernel_params=kernel_params, + description=describe_generation(profile, generation, specialisation))) + if machine_id is not None: + f.write("machine-id %s\n" % machine_id) + os.rename(tmp_path, entry_file) + + +def mkdir_p(path: str) -> None: + try: + os.makedirs(path) + except OSError as e: + if e.errno != errno.EEXIST or not os.path.isdir(path): + raise + + +def get_generations(profile: Optional[str] = None) -> List[SystemIdentifier]: + gen_list = subprocess.check_output([ + "@nix@/bin/nix-env", + "--list-generations", + "-p", + "/nix/var/nix/profiles/%s" % ("system-profiles/" + profile if profile else "system"), + "--option", "build-users-group", ""], + universal_newlines=True) + gen_lines = gen_list.split('\n') + gen_lines.pop() + + configurationLimit = @configurationLimit@ + configurations = [ + SystemIdentifier( + profile=profile, + generation=int(line.split()[0]), + specialisation=None + ) + for line in gen_lines + ] + return configurations[-configurationLimit:] + + +def get_specialisations(profile: Optional[str], generation: int, _: Optional[str]) -> List[SystemIdentifier]: + specialisations_dir = os.path.join( + system_dir(profile, generation, None), "specialisation") + if not os.path.exists(specialisations_dir): + return [] + return [SystemIdentifier(profile, generation, spec) for spec in os.listdir(specialisations_dir)] + + +def remove_old_entries(gens: List[SystemIdentifier]) -> None: + rex_profile = re.compile("^@mountPoint@/loader/entries/nixos-(.*)-generation-.*\.conf$") + rex_generation = re.compile("^@mountPoint@/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$") + known_paths = [] + for gen in gens: + known_paths.append(copy_from_profile(*gen, "kernel", True)) + known_paths.append(copy_from_profile(*gen, "initrd", True)) + for path in glob.iglob("@mountPoint@/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 not (prof, gen_number, None) in gens: + os.unlink(path) + for path in glob.iglob("@mountPoint@/efi/nixos/*"): + if not path in known_paths and not os.path.isdir(path): + os.unlink(path) + + +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 main() -> None: + parser = argparse.ArgumentParser(description='Update @distroName@-related systemd-boot files') + parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help='The default @distroName@ config to boot') + args = parser.parse_args() + + 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 = ["@systemd@/bin/systemd-machine-id-setup", "--print"] + machine_id = subprocess.run( + cmd, text=True, check=True, 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 "@canTouchEfiVariables@" != "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("@mountPoint@/loader/loader.conf"): + os.unlink("@mountPoint@/loader/loader.conf") + + subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@mountPoint@"] + bootctl_flags + ["install"]) + else: + # Update bootloader to latest if needed + available_out = subprocess.check_output(["@systemd@/bin/bootctl", "--version"], universal_newlines=True).split()[2] + installed_out = subprocess.check_output(["@systemd@/bin/bootctl", "--esp-path=@mountPoint@", "status"], universal_newlines=True) + + # See status_binaries() in systemd bootctl.c for code which generates this + installed_match = re.search(r"^\W+File:.*/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 = version.parse(installed_match.group(1)) + available_version = version.parse(available_match.group(1)) + + # systemd 252 has a regression that leaves some machines unbootable, so we skip that update. + # The fix is in 252.2 + # See https://github.com/systemd/systemd/issues/25363 and https://github.com/NixOS/nixpkgs/pull/201558#issuecomment-1348603263 + if installed_version < available_version: + if version.parse('252') <= available_version < version.parse('252.2'): + print("skipping systemd-boot update to %s because of known regression" % available_version) + else: + print("updating systemd-boot from %s to %s" % (installed_version, available_version)) + subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@mountPoint@"] + bootctl_flags + ["update"]) + + mkdir_p("@mountPoint@/efi/nixos") + mkdir_p("@mountPoint@/loader/entries") + + gens = get_generations() + for profile in get_profiles(): + gens += get_generations(profile) + remove_old_entries(gens) + for gen in gens: + try: + is_default = os.path.dirname(profile_path(*gen, "init")) == args.default_config + write_entry(*gen, machine_id, current=is_default) + for specialisation in get_specialisations(*gen): + write_entry(*specialisation, machine_id, 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 + + for root, _, files in os.walk('@mountPoint@/efi/nixos/.extra-files', topdown=False): + relative_root = root.removeprefix("@mountPoint@/efi/nixos/.extra-files").removeprefix("/") + actual_root = os.path.join("@mountPoint@", 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) + + mkdir_p("@mountPoint@/efi/nixos/.extra-files") + + subprocess.check_call("@copyExtraFiles@") + + # 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("@mountPoint@", os.O_RDONLY)) + if rc != 0: + print("could not sync @mountPoint@: {}".format(os.strerror(rc)), file=sys.stderr) + + +if __name__ == '__main__': + main()