push sheeet
Some checks failed
Periodic Merges (6h) / master → staging-nixos (push) Failing after 12m50s
Periodic Merges (6h) / master → staging-next (push) Failing after 12m54s
Periodic Merges (24h) / merge-base(master,staging) → haskell-updates (push) Failing after 11m54s
Periodic Merges (6h) / staging-next → staging (push) Failing after 12m13s
Periodic Merges (24h) / staging-next-25.05 → staging-25.05 (push) Failing after 13m24s
Periodic Merges (24h) / release-25.05 → staging-next-25.05 (push) Failing after 14m28s

This commit is contained in:
Dark Steveneq
2025-10-09 14:15:47 +02:00
commit 646b892680
49168 changed files with 5897842 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
{ lib, ... }:
{
options.boot.loader.efi = {
canTouchEfiVariables = lib.mkOption {
default = false;
type = lib.types.bool;
description = "Whether the installation process is allowed to modify EFI boot variables.";
};
efiSysMountPoint = lib.mkOption {
default = "/boot";
type = lib.types.str;
description = "Where the EFI System Partition is mounted.";
};
};
}

View File

@@ -0,0 +1,27 @@
# External Bootloader Backends {#sec-bootloader-external}
NixOS has support for several bootloader backends by default: systemd-boot, grub, uboot, etc.
The built-in bootloader backend support is generic and supports most use cases.
Some users may prefer to create advanced workflows around managing the bootloader and bootable entries.
You can replace the built-in bootloader support with your own tooling using the "external" bootloader option.
Imagine you have created a new package called FooBoot.
FooBoot provides a program at `${pkgs.fooboot}/bin/fooboot-install` which takes the system closure's path as its only argument and configures the system's bootloader.
You can enable FooBoot like this:
```nix
{ pkgs, ... }:
{
boot.loader.external = {
enable = true;
installHook = "${pkgs.fooboot}/bin/fooboot-install";
};
}
```
## Developing Custom Bootloader Backends {#sec-bootloader-external-developing}
Bootloaders should use [RFC-0125](https://github.com/NixOS/rfcs/pull/125)'s Bootspec format and synthesis tools to identify the key properties for bootable system generations.

View File

@@ -0,0 +1,45 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.boot.loader.external;
in
{
meta = {
maintainers = with maintainers; [
cole-h
grahamc
raitobezarius
];
doc = ./external.md;
};
options.boot.loader.external = {
enable = mkEnableOption "using an external tool to install your bootloader";
installHook = mkOption {
type = with types; path;
description = ''
The full path to a program of your choosing which performs the bootloader installation process.
The program will be called with an argument pointing to the output of the system's toplevel.
'';
};
};
config = mkIf cfg.enable {
boot.loader = {
grub.enable = mkDefault false;
systemd-boot.enable = mkDefault false;
supportsInitrdSecrets = mkDefault false;
};
system.build.installBootLoader = cfg.installHook;
};
}

View File

@@ -0,0 +1,105 @@
#! @bash@/bin/sh -e
shopt -s nullglob
export PATH=/empty:@path@
default=$1
if test -z "$1"; then
echo "Syntax: generations-dir-builder.sh <DEFAULT-CONFIG>"
exit 1
fi
echo "updating the boot generations directory..."
mkdir -p /boot
rm -Rf /boot/system* || true
target=/boot/grub/menu.lst
tmp=$target.tmp
# Convert a path to a file in the Nix store such as
# /nix/store/<hash>-<name>/file to <hash>-<name>-<file>.
cleanName() {
local path="$1"
echo "$path" | sed 's|^/nix/store/||' | sed 's|/|-|g'
}
# Copy a file from the Nix store to /boot/kernels.
declare -A filesCopied
copyToKernelsDir() {
local src="$1"
local dst="/boot/kernels/$(cleanName $src)"
# Don't copy the file if $dst already exists. This means that we
# have to create $dst atomically to prevent partially copied
# kernels or initrd if this script is ever interrupted.
if ! test -e $dst; then
local dstTmp=$dst.tmp.$$
cp $src $dstTmp
mv $dstTmp $dst
fi
filesCopied[$dst]=1
result=$dst
}
# Copy its kernel and initrd to /boot/kernels.
addEntry() {
local path="$1"
local generation="$2"
local outdir=/boot/system-$generation
if ! test -e $path/kernel -a -e $path/initrd; then
return
fi
local kernel=$(readlink -f $path/kernel)
local initrd=$(readlink -f $path/initrd)
if test -n "@copyKernels@"; then
copyToKernelsDir $kernel; kernel=$result
copyToKernelsDir $initrd; initrd=$result
fi
mkdir -p $outdir
ln -sf $(readlink -f $path) $outdir/system
ln -sf $(readlink -f $path/init) $outdir/init
ln -sf $initrd $outdir/initrd
ln -sf $kernel $outdir/kernel
if test $(readlink -f "$path") = "$default"; then
cp "$kernel" /boot/nixos-kernel
cp "$initrd" /boot/nixos-initrd
cp "$(readlink -f "$path/init")" /boot/nixos-init
mkdir -p /boot/default
# ln -sfT: overrides target even if it exists.
ln -sfT $(readlink -f $path) /boot/default/system
ln -sfT $(readlink -f $path/init) /boot/default/init
ln -sfT $initrd /boot/default/initrd
ln -sfT $kernel /boot/default/kernel
fi
}
if test -n "@copyKernels@"; then
mkdir -p /boot/kernels
fi
# Add all generations of the system profile to the menu, in reverse
# (most recent to least recent) order.
for generation in $(
(cd /nix/var/nix/profiles && ls -d system-*-link) \
| sed 's/system-\([0-9]\+\)-link/\1/' \
| sort -n -r); do
link=/nix/var/nix/profiles/system-$generation-link
addEntry $link $generation
done
# Remove obsolete files from /boot/kernels.
for fn in /boot/kernels/*; do
if ! test "${filesCopied[$fn]}" = 1; then
rm -vf -- "$fn"
fi
done

View File

@@ -0,0 +1,72 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
generationsDirBuilder = pkgs.replaceVarsWith {
src = ./generations-dir-builder.sh;
isExecutable = true;
replacements = {
inherit (pkgs) bash;
path = lib.makeBinPath [
pkgs.coreutils
pkgs.gnused
pkgs.gnugrep
];
inherit (config.boot.loader.generationsDir) copyKernels;
};
};
in
{
options = {
boot.loader.generationsDir = {
enable = mkOption {
default = false;
type = types.bool;
description = ''
Whether to create symlinks to the system generations under
`/boot`. When enabled,
`/boot/default/kernel`,
`/boot/default/initrd`, etc., are updated to
point to the current generation's kernel image, initial RAM
disk, and other bootstrap files.
This optional is not necessary with boot loaders such as GNU GRUB
for which the menu is updated to point to the latest bootstrap
files. However, it is needed for U-Boot on platforms where the
boot command line is stored in flash memory rather than in a
menu file.
'';
};
copyKernels = mkOption {
default = false;
type = types.bool;
description = ''
Whether to copy the necessary boot files into /boot, so
/nix/store is not needed by the boot loader.
'';
};
};
};
config = mkIf config.boot.loader.generationsDir.enable {
system.build.installBootLoader = generationsDirBuilder;
system.boot.loader.id = "generationsDir";
system.boot.loader.kernelFile = pkgs.stdenv.hostPlatform.linux-kernel.target;
};
}

View File

@@ -0,0 +1,137 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
blCfg = config.boot.loader;
dtCfg = config.hardware.deviceTree;
cfg = blCfg.generic-extlinux-compatible;
timeoutStr = if blCfg.timeout == null then "-1" else toString blCfg.timeout;
# The builder used to write during system activation
builder = import ./extlinux-conf-builder.nix { inherit lib pkgs; };
# The builder exposed in populateCmd, which runs on the build architecture
populateBuilder = import ./extlinux-conf-builder.nix {
inherit lib;
pkgs = pkgs.buildPackages;
};
in
{
options = {
boot.loader.generic-extlinux-compatible = {
enable = mkOption {
default = false;
type = types.bool;
description = ''
Whether to generate an extlinux-compatible configuration file
under `/boot/extlinux.conf`. For instance,
U-Boot's generic distro boot support uses this file format.
See [U-boot's documentation](https://u-boot.readthedocs.io/en/latest/develop/distro.html)
for more information.
'';
};
useGenerationDeviceTree = mkOption {
default = true;
type = types.bool;
description = ''
Whether to generate Device Tree-related directives in the
extlinux configuration.
When enabled, the bootloader will attempt to load the device
tree binaries from the generation's kernel.
Note that this affects all generations, regardless of the
setting value used in their configurations.
'';
};
configurationLimit = mkOption {
default = 20;
example = 10;
type = types.int;
description = ''
Maximum number of configurations in the boot menu.
'';
};
mirroredBoots = mkOption {
default = [ { path = "/boot"; } ];
example = [
{ path = "/boot1"; }
{ path = "/boot2"; }
];
description = ''
Mirror the boot configuration to multiple paths.
'';
type =
with types;
listOf (submodule {
options = {
path = mkOption {
example = "/boot1";
type = types.str;
description = ''
The path to the boot directory where the extlinux-compatible
configuration files will be written.
'';
};
};
});
};
populateCmd = mkOption {
type = types.str;
readOnly = true;
description = ''
Contains the builder command used to populate an image,
honoring all options except the `-c <path-to-default-configuration>`
argument.
Useful to have for sdImage.populateRootCommands
'';
};
};
};
config =
let
builderArgs =
"-g ${toString cfg.configurationLimit} -t ${timeoutStr}"
+ lib.optionalString (dtCfg.name != null) " -n ${dtCfg.name}"
+ lib.optionalString (!cfg.useGenerationDeviceTree) " -r";
installBootLoader = pkgs.writeScript "install-extlinux-conf.sh" (
''
#!${pkgs.runtimeShell}
set -e
''
+ flip concatMapStrings cfg.mirroredBoots (args: ''
${builder} ${builderArgs} -d '${args.path}' -c "$@"
'')
);
in
mkIf cfg.enable {
system.build.installBootLoader = installBootLoader;
system.boot.loader.id = "generic-extlinux-compatible";
boot.loader.generic-extlinux-compatible.populateCmd = "${populateBuilder} ${builderArgs}";
assertions = [
{
assertion = cfg.mirroredBoots != [ ];
message = ''
You must not remove all elements from option 'boot.loader.generic-extlinux-compatible.mirroredBoots',
otherwise the system will not be bootable.
'';
}
];
};
}

View File

@@ -0,0 +1,14 @@
{ lib, pkgs }:
pkgs.replaceVarsWith {
src = ./extlinux-conf-builder.sh;
isExecutable = true;
replacements = {
path = lib.makeBinPath [
pkgs.coreutils
pkgs.gnused
pkgs.gnugrep
];
inherit (pkgs) bash;
};
}

View File

@@ -0,0 +1,170 @@
#! @bash@/bin/sh -e
shopt -s nullglob
export PATH=/empty:@path@
usage() {
echo "usage: $0 -t <timeout> -c <path-to-default-configuration> [-d <boot-dir>] [-g <num-generations>] [-n <dtbName>] [-r]" >&2
exit 1
}
timeout= # Timeout in centiseconds
menu=1 # Enable menu by default
default= # Default configuration
target=/boot # Target directory
numGenerations=0 # Number of other generations to include in the menu
while getopts "t:c:d:g:n:r" opt; do
case "$opt" in
t) # U-Boot interprets '0' as infinite
if [ "$OPTARG" -lt 0 ]; then
# When negative (or null coerced to -1), disable timeout which means that we wait forever for input
timeout=0
elif [ "$OPTARG" = 0 ]; then
# When zero, which means disabled in Nix module, disable menu which results in instant boot of the default item
# .. timeout is actually ignored by u-Boot but set here for the rest of the script
timeout=1
menu=0
else
# Positive results in centi-seconds of timeout, which when passed with no input results in boot of the default item
timeout=$((OPTARG * 10))
fi
;;
c) default="$OPTARG" ;;
d) target="$OPTARG" ;;
g) numGenerations="$OPTARG" ;;
n) dtbName="$OPTARG" ;;
r) noDeviceTree=1 ;;
\?) usage ;;
esac
done
[ "$timeout" = "" -o "$default" = "" ] && usage
mkdir -p $target/nixos
mkdir -p $target/extlinux
# Convert a path to a file in the Nix store such as
# /nix/store/<hash>-<name>/file to <hash>-<name>-<file>.
cleanName() {
local path="$1"
echo "$path" | sed 's|^/nix/store/||' | sed 's|/|-|g'
}
# Copy a file from the Nix store to $target/nixos.
declare -A filesCopied
copyToKernelsDir() {
local src=$(readlink -f "$1")
local dst="$target/nixos/$(cleanName $src)"
# Don't copy the file if $dst already exists. This means that we
# have to create $dst atomically to prevent partially copied
# kernels or initrd if this script is ever interrupted.
if ! test -e $dst; then
local dstTmp=$dst.tmp.$$
cp -r $src $dstTmp
mv $dstTmp $dst
fi
filesCopied[$dst]=1
result=$dst
}
# Copy its kernel, initrd and dtbs to $target/nixos, and echo out an
# extlinux menu entry
addEntry() {
local path=$(readlink -f "$1")
local tag="$2" # Generation number or 'default'
if ! test -e $path/kernel -a -e $path/initrd; then
return
fi
copyToKernelsDir "$path/kernel"; kernel=$result
copyToKernelsDir "$path/initrd"; initrd=$result
dtbDir=$(readlink -m "$path/dtbs")
if [ -e "$dtbDir" ]; then
copyToKernelsDir "$dtbDir"; dtbs=$result
fi
timestampEpoch=$(stat -L -c '%Z' $path)
timestamp=$(date "+%Y-%m-%d %H:%M" -d @$timestampEpoch)
nixosLabel="$(cat $path/nixos-version)"
extraParams="$(cat $path/kernel-params)"
echo
echo "LABEL nixos-$tag"
if [ "$tag" = "default" ]; then
echo " MENU LABEL NixOS - Default"
else
echo " MENU LABEL NixOS - Configuration $tag ($timestamp - $nixosLabel)"
fi
echo " LINUX ../nixos/$(basename $kernel)"
echo " INITRD ../nixos/$(basename $initrd)"
echo " APPEND init=$path/init $extraParams"
if [ -n "$noDeviceTree" ]; then
return
fi
if [ -d "$dtbDir" ]; then
# if a dtbName was specified explicitly, use that, else use FDTDIR
if [ -n "$dtbName" ]; then
echo " FDT ../nixos/$(basename $dtbs)/${dtbName}"
else
echo " FDTDIR ../nixos/$(basename $dtbs)"
fi
else
if [ -n "$dtbName" ]; then
echo "Explicitly requested dtbName $dtbName, but there's no FDTDIR - bailing out." >&2
exit 1
fi
fi
}
tmpFile="$target/extlinux/extlinux.conf.tmp.$$"
cat > $tmpFile <<EOF
# Generated file, all changes will be lost on nixos-rebuild!
# Change this to e.g. nixos-42 to temporarily boot to an older configuration.
DEFAULT nixos-default
TIMEOUT $timeout
EOF
[ "$menu" == "1" ] \
&& echo "MENU TITLE ------------------------------------------------------------" >> $tmpFile
addEntry $default default >> $tmpFile
if [ "$numGenerations" -gt 0 ]; then
# Add up to $numGenerations generations of the system profile to the menu,
# in reverse (most recent to least recent) order.
for generation in $(
(cd /nix/var/nix/profiles && ls -d system-*-link) \
| sed 's/system-\([0-9]\+\)-link/\1/' \
| sort -n -r \
| head -n $numGenerations); do
link=/nix/var/nix/profiles/system-$generation-link
addEntry $link "${generation}-default"
for specialisation in $(
ls /nix/var/nix/profiles/system-$generation-link/specialisation \
| sort -n -r); do
link=/nix/var/nix/profiles/system-$generation-link/specialisation/$specialisation
addEntry $link "${generation}-${specialisation}"
done
done >> $tmpFile
fi
mv -f $tmpFile $target/extlinux/extlinux.conf
# Remove obsolete files from $target/nixos.
for fn in $target/nixos/*; do
if ! test "${filesCopied[$fn]}" = 1; then
echo "Removing no longer needed boot file: $fn"
chmod +w -- "$fn"
rm -rf -- "$fn"
fi
done

View File

@@ -0,0 +1,956 @@
{
config,
options,
lib,
pkgs,
...
}:
let
inherit (lib)
all
concatMap
concatMapStrings
concatStrings
escapeShellArg
flip
foldr
forEach
hasPrefix
mapAttrsToList
literalExpression
makeBinPath
mkDefault
mkIf
mkMerge
mkOption
mkRemovedOptionModule
mkRenamedOptionModule
optional
optionals
optionalString
replaceStrings
types
;
cfg = config.boot.loader.grub;
efi = config.boot.loader.efi;
grubPkgs =
# Package set of targeted architecture
if cfg.forcei686 then pkgs.pkgsi686Linux else pkgs;
realGrub =
if cfg.zfsSupport then
grubPkgs.grub2.override {
zfsSupport = true;
zfs = cfg.zfsPackage;
}
else
grubPkgs.grub2;
grub =
# Don't include GRUB if we're only generating a GRUB menu (e.g.,
# in EC2 instances).
if cfg.devices == [ "nodev" ] then null else realGrub;
grubEfi = if cfg.efiSupport then realGrub.override { efiSupport = cfg.efiSupport; } else null;
f = x: optionalString (x != null) ("" + x);
grubConfig =
args:
let
efiSysMountPoint = if args.efiSysMountPoint == null then args.path else args.efiSysMountPoint;
efiSysMountPoint' = replaceStrings [ "/" ] [ "-" ] efiSysMountPoint;
in
pkgs.writeText "grub-config.xml" (
builtins.toXML {
splashImage = f cfg.splashImage;
splashMode = f cfg.splashMode;
backgroundColor = f cfg.backgroundColor;
entryOptions = f cfg.entryOptions;
subEntryOptions = f cfg.subEntryOptions;
# PC platforms (like x86_64-linux) have a non-EFI target (`grubTarget`), but other platforms
# (like aarch64-linux) have an undefined `grubTarget`. Avoid providing the path to a non-EFI
# GRUB on those platforms.
grub = f (if (grub.grubTarget or "") != "" then grub else "");
grubTarget = f (grub.grubTarget or "");
shell = "${pkgs.runtimeShell}";
fullName = lib.getName realGrub;
fullVersion = lib.getVersion realGrub;
grubEfi = f grubEfi;
grubTargetEfi = optionalString cfg.efiSupport (f (grubEfi.grubTarget or ""));
bootPath = args.path;
storePath = config.boot.loader.grub.storePath;
bootloaderId =
if args.efiBootloaderId == null then
"${config.system.nixos.distroName}${efiSysMountPoint'}"
else
args.efiBootloaderId;
timeout = if config.boot.loader.timeout == null then -1 else config.boot.loader.timeout;
theme = f cfg.theme;
inherit efiSysMountPoint;
inherit (args) devices;
inherit (efi) canTouchEfiVariables;
inherit (cfg)
extraConfig
extraPerEntryConfig
extraEntries
forceInstall
useOSProber
extraGrubInstallArgs
extraEntriesBeforeNixOS
extraPrepareConfig
configurationLimit
copyKernels
default
fsIdentifier
efiSupport
efiInstallAsRemovable
gfxmodeEfi
gfxmodeBios
gfxpayloadEfi
gfxpayloadBios
users
timeoutStyle
;
path =
with pkgs;
makeBinPath (
[
coreutils
gnused
gnugrep
findutils
diffutils
btrfs-progs
util-linux
mdadm
]
++ optional cfg.efiSupport efibootmgr
++ optionals cfg.useOSProber [
busybox
os-prober
]
);
font = lib.optionalString (cfg.font != null) (
if lib.last (lib.splitString "." cfg.font) == "pf2" then cfg.font else "${convertedFont}"
);
}
);
bootDeviceCounters = foldr (device: attr: attr // { ${device} = (attr.${device} or 0) + 1; }) { } (
concatMap (args: args.devices) cfg.mirroredBoots
);
convertedFont = (
pkgs.runCommand "grub-font-converted.pf2" { } (
builtins.concatStringsSep " " (
[
"${realGrub}/bin/grub-mkfont"
cfg.font
"--output"
"$out"
]
++ (optional (cfg.fontSize != null) "--size ${toString cfg.fontSize}")
)
)
);
defaultSplash = pkgs.nixos-artwork.wallpapers.simple-dark-gray-bootloader.gnomeFilePath;
in
{
###### interface
options = {
boot.loader.grub = {
enable = mkOption {
default = !config.boot.isContainer;
defaultText = literalExpression "!config.boot.isContainer";
type = types.bool;
description = ''
Whether to enable the GNU GRUB boot loader.
'';
};
version = mkOption {
visible = false;
type = types.int;
};
device = mkOption {
default = "";
example = "/dev/disk/by-id/wwn-0x500001234567890a";
type = types.str;
description = ''
The device on which the GRUB boot loader will be installed.
The special value `nodev` means that a GRUB
boot menu will be generated, but GRUB itself will not
actually be installed. To install GRUB on multiple devices,
use `boot.loader.grub.devices`.
'';
};
devices = mkOption {
default = [ ];
example = [ "/dev/disk/by-id/wwn-0x500001234567890a" ];
type = types.listOf types.str;
description = ''
The devices on which the boot loader, GRUB, will be
installed. Can be used instead of `device` to
install GRUB onto multiple devices.
'';
};
users = mkOption {
default = { };
example = {
root = {
hashedPasswordFile = "/path/to/file";
};
};
description = ''
User accounts for GRUB. When specified, the GRUB command line and
all boot options except the default are password-protected.
All passwords and hashes provided will be stored in /boot/grub/grub.cfg,
and will be visible to any local user who can read this file. Additionally,
any passwords and hashes provided directly in a Nix configuration
(as opposed to external files) will be copied into the Nix store, and
will be visible to all local users.
'';
type = types.attrsOf (
types.submodule {
options = {
hashedPasswordFile = mkOption {
example = "/path/to/file";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the path to a file containing the password hash
for the account, generated with grub-mkpasswd-pbkdf2.
This hash will be stored in /boot/grub/grub.cfg, and will
be visible to any local user who can read this file.
'';
};
hashedPassword = mkOption {
example = "grub.pbkdf2.sha512.10000.674DFFDEF76E13EA...2CC972B102CF4355";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the password hash for the account,
generated with grub-mkpasswd-pbkdf2.
This hash will be copied to the Nix store, and will be visible to all local users.
'';
};
passwordFile = mkOption {
example = "/path/to/file";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the path to a file containing the
clear text password for the account.
This password will be stored in /boot/grub/grub.cfg, and will
be visible to any local user who can read this file.
'';
};
password = mkOption {
example = "Pa$$w0rd!";
default = null;
type = with types; uniq (nullOr str);
description = ''
Specifies the clear text password for the account.
This password will be copied to the Nix store, and will be visible to all local users.
'';
};
};
}
);
};
mirroredBoots = mkOption {
default = [ ];
example = [
{
path = "/boot1";
devices = [ "/dev/disk/by-id/wwn-0x500001234567890a" ];
}
{
path = "/boot2";
devices = [ "/dev/disk/by-id/wwn-0x500009876543210a" ];
}
];
description = ''
Mirror the boot configuration to multiple partitions and install grub
to the respective devices corresponding to those partitions.
'';
type =
with types;
listOf (submodule {
options = {
path = mkOption {
example = "/boot1";
type = types.str;
description = ''
The path to the boot directory where GRUB will be written. Generally
this boot path should double as an EFI path.
'';
};
efiSysMountPoint = mkOption {
default = null;
example = "/boot1/efi";
type = types.nullOr types.str;
description = ''
The path to the efi system mount point. Usually this is the same
partition as the above path and can be left as null.
'';
};
efiBootloaderId = mkOption {
default = null;
example = "NixOS-fsid";
type = types.nullOr types.str;
description = ''
The id of the bootloader to store in efi nvram.
The default is to name it NixOS and append the path or efiSysMountPoint.
This is only used if `boot.loader.efi.canTouchEfiVariables` is true.
'';
};
devices = mkOption {
default = [ ];
example = [
"/dev/disk/by-id/wwn-0x500001234567890a"
"/dev/disk/by-id/wwn-0x500009876543210a"
];
type = types.listOf types.str;
description = ''
The path to the devices which will have the GRUB MBR written.
Note these are typically device paths and not paths to partitions.
'';
};
};
});
};
configurationName = mkOption {
default = "";
example = "Stable 2.6.21";
type = types.str;
description = ''
GRUB entry name instead of default.
'';
};
storePath = mkOption {
default = "/nix/store";
type = types.str;
description = ''
Path to the Nix store when looking for kernels at boot.
Only makes sense when copyKernels is false.
'';
};
extraPrepareConfig = mkOption {
default = "";
type = types.lines;
description = ''
Additional bash commands to be run at the script that
prepares the GRUB menu entries.
'';
};
extraConfig = mkOption {
default = "";
example = ''
serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1
terminal_input --append serial
terminal_output --append serial
'';
type = types.lines;
description = ''
Additional GRUB commands inserted in the configuration file
just before the menu entries.
'';
};
extraGrubInstallArgs = mkOption {
default = [ ];
example = [ "--modules=nativedisk ahci pata part_gpt part_msdos diskfilter mdraid1x lvm ext2" ];
type = types.listOf types.str;
description = ''
Additional arguments passed to `grub-install`.
A use case for this is to build specific GRUB2 modules
directly into the GRUB2 kernel image, so that they are available
and activated even in the `grub rescue` shell.
They are also necessary when the BIOS/UEFI is bugged and cannot
correctly read large disks (e.g. above 2 TB), so GRUB2's own
`nativedisk` and related modules can be used
to use its own disk drivers. The example shows one such case.
This is also useful for booting from USB.
See the
[
GRUB source code
](https://git.savannah.gnu.org/cgit/grub.git/tree/grub-core/commands/nativedisk.c?h=grub-2.04#n326)
for which disk modules are available.
The list elements are passed directly as `argv`
arguments to the `grub-install` program, in order.
'';
};
extraInstallCommands = mkOption {
default = "";
example = ''
# the example below generates detached signatures that GRUB can verify
# https://www.gnu.org/software/grub/manual/grub/grub.html#Using-digital-signatures
''${pkgs.findutils}/bin/find /boot -not -path "/boot/efi/*" -type f -name '*.sig' -delete
old_gpg_home=$GNUPGHOME
export GNUPGHOME="$(mktemp -d)"
''${pkgs.gnupg}/bin/gpg --import ''${priv_key} > /dev/null 2>&1
''${pkgs.findutils}/bin/find /boot -not -path "/boot/efi/*" -type f -exec ''${pkgs.gnupg}/bin/gpg --detach-sign "{}" \; > /dev/null 2>&1
rm -rf $GNUPGHOME
export GNUPGHOME=$old_gpg_home
'';
type = types.lines;
description = ''
Additional shell commands inserted in the bootloader installer
script after generating menu entries.
'';
};
extraPerEntryConfig = mkOption {
default = "";
example = "root (hd0)";
type = types.lines;
description = ''
Additional GRUB commands inserted in the configuration file
at the start of each NixOS menu entry.
'';
};
extraEntries = mkOption {
default = "";
type = types.lines;
example = ''
# GRUB 2 example
menuentry "Windows 7" {
chainloader (hd0,4)+1
}
# GRUB 2 with UEFI example, chainloading another distro
menuentry "Fedora" {
set root=(hd1,1)
chainloader /efi/fedora/grubx64.efi
}
'';
description = ''
Any additional entries you want added to the GRUB boot menu.
'';
};
extraEntriesBeforeNixOS = mkOption {
default = false;
type = types.bool;
description = ''
Whether extraEntries are included before the default option.
'';
};
extraFiles = mkOption {
type = types.attrsOf types.path;
default = { };
example = literalExpression ''
{ "memtest.bin" = "''${pkgs.memtest86plus}/memtest.bin"; }
'';
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.
'';
};
useOSProber = mkOption {
default = false;
type = types.bool;
description = ''
If set to true, append entries for other OSs detected by os-prober.
'';
};
splashImage = mkOption {
type = types.nullOr types.path;
example = literalExpression "./my-background.png";
description = ''
Background image used for GRUB.
Set to `null` to run GRUB in text mode.
::: {.note}
File must be one of .png, .tga, .jpg, or .jpeg. JPEG images must
not be progressive.
The image will be scaled if necessary to fit the screen.
:::
'';
};
backgroundColor = mkOption {
type = types.nullOr types.str;
example = "#7EBAE4";
default = null;
description = ''
Background color to be used for GRUB to fill the areas the image isn't filling.
'';
};
timeoutStyle = mkOption {
default = "menu";
type = types.enum [
"menu"
"countdown"
"hidden"
];
description = ''
- `menu` shows the menu.
- `countdown` uses a text-mode countdown.
- `hidden` hides GRUB entirely.
When using a theme, the default value (`menu`) is appropriate for the graphical countdown.
When attempting to do flicker-free boot, `hidden` should be used.
See the [GRUB documentation section about `timeout_style`](https://www.gnu.org/software/grub/manual/grub/html_node/timeout.html).
::: {.note}
If this option is set to countdown or hidden [...] and ESC or F4 are pressed, or SHIFT is held down during that time, it will display the menu and wait for input.
:::
From: [Simple configuration handling page, under GRUB_TIMEOUT_STYLE](https://www.gnu.org/software/grub/manual/grub/html_node/Simple-configuration.html).
'';
};
entryOptions = mkOption {
default = "--class nixos --unrestricted";
type = types.nullOr types.str;
description = ''
Options applied to the primary NixOS menu entry.
'';
};
subEntryOptions = mkOption {
default = "--class nixos";
type = types.nullOr types.str;
description = ''
Options applied to the secondary NixOS submenu entry.
'';
};
theme = mkOption {
type = types.nullOr types.path;
example = literalExpression ''"''${pkgs.kdePackages.breeze-grub}/grub/themes/breeze"'';
default = null;
description = ''
Path to the grub theme to be used.
'';
};
splashMode = mkOption {
type = types.enum [
"normal"
"stretch"
];
default = "stretch";
description = ''
Whether to stretch the image or show the image in the top-left corner unstretched.
'';
};
font = mkOption {
type = types.nullOr types.path;
default = "${realGrub}/share/grub/unicode.pf2";
defaultText = literalExpression ''"''${pkgs.grub2}/share/grub/unicode.pf2"'';
description = ''
Path to a TrueType, OpenType, or pf2 font to be used by Grub.
'';
};
fontSize = mkOption {
type = types.nullOr types.int;
example = 16;
default = null;
description = ''
Font size for the grub menu. Ignored unless `font`
is set to a ttf or otf font.
'';
};
gfxmodeEfi = mkOption {
default = "auto";
example = "1024x768";
type = types.str;
description = ''
The gfxmode to pass to GRUB when loading a graphical boot interface under EFI.
'';
};
gfxmodeBios = mkOption {
default = "1024x768";
example = "auto";
type = types.str;
description = ''
The gfxmode to pass to GRUB when loading a graphical boot interface under BIOS.
'';
};
gfxpayloadEfi = mkOption {
default = "keep";
example = "text";
type = types.str;
description = ''
The gfxpayload to pass to GRUB when loading a graphical boot interface under EFI.
'';
};
gfxpayloadBios = mkOption {
default = "text";
example = "keep";
type = types.str;
description = ''
The gfxpayload to pass to GRUB when loading a graphical boot interface under BIOS.
'';
};
configurationLimit = mkOption {
default = 100;
example = 120;
type = types.int;
description = ''
Maximum of configurations in boot menu. GRUB has problems when
there are too many entries.
'';
};
copyKernels = mkOption {
default = false;
type = types.bool;
description = ''
Whether the GRUB menu builder should copy kernels and initial
ramdisks to /boot. This is done automatically if /boot is
on a different partition than /.
'';
};
default = mkOption {
default = "0";
type = types.either types.int types.str;
apply = toString;
description = ''
Index of the default menu item to be booted.
Can also be set to "saved", which will make GRUB select
the menu item that was used at the last boot.
'';
};
fsIdentifier = mkOption {
default = "uuid";
type = types.enum [
"uuid"
"label"
"provided"
];
description = ''
Determines how GRUB will identify devices when generating the
configuration file. A value of uuid / label signifies that grub
will always resolve the uuid or label of the device before using
it in the configuration. A value of provided means that GRUB will
use the device name as show in {command}`df` or
{command}`mount`. Note, zfs zpools / datasets are ignored
and will always be mounted using their labels.
'';
};
zfsSupport = mkOption {
default = false;
type = types.bool;
description = ''
Whether GRUB should be built against libzfs.
'';
};
zfsPackage = mkOption {
type = types.package;
internal = true;
default = pkgs.zfs;
defaultText = literalExpression "pkgs.zfs";
description = ''
Which ZFS package to use if `config.boot.loader.grub.zfsSupport` is true.
'';
};
efiSupport = mkOption {
default = false;
type = types.bool;
description = ''
Whether GRUB should be built with EFI support.
'';
};
efiInstallAsRemovable = mkOption {
default = false;
type = types.bool;
description = ''
Whether to invoke `grub-install` with
`--removable`.
Unless you turn this on, GRUB will install itself somewhere in
`boot.loader.efi.efiSysMountPoint` (exactly where
depends on other config variables). If you've set
`boot.loader.efi.canTouchEfiVariables` *AND* you
are currently booted in UEFI mode, then GRUB will use
`efibootmgr` to modify the boot order in the
EFI variables of your firmware to include this location. If you are
*not* booted in UEFI mode at the time GRUB is being installed, the
NVRAM will not be modified, and your system will not find GRUB at
boot time. However, GRUB will still return success so you may miss
the warning that gets printed ("`efibootmgr: EFI variables
are not supported on this system.`").
If you turn this feature on, GRUB will install itself in a
special location within `efiSysMountPoint` (namely
`EFI/boot/boot$arch.efi`) which the firmwares
are hardcoded to try first, regardless of NVRAM EFI variables.
To summarize, turn this on if:
- You are installing NixOS and want it to boot in UEFI mode,
but you are currently booted in legacy mode
- You want to make a drive that will boot regardless of
the NVRAM state of the computer (like a USB "removable" drive)
- You simply dislike the idea of depending on NVRAM
state to make your drive bootable
'';
};
enableCryptodisk = mkOption {
default = false;
type = types.bool;
description = ''
Enable support for encrypted partitions. GRUB should automatically
unlock the correct encrypted partition and look for filesystems.
'';
};
forceInstall = mkOption {
default = false;
type = types.bool;
description = ''
Whether to try and forcibly install GRUB even if problems are
detected. It is not recommended to enable this unless you know what
you are doing.
'';
};
forcei686 = mkOption {
default = false;
type = types.bool;
description = ''
Whether to force the use of a ia32 boot loader on x64 systems. Required
to install and run NixOS on 64bit x86 systems with 32bit (U)EFI.
'';
};
};
};
###### implementation
config = mkMerge [
{ boot.loader.grub.splashImage = mkDefault defaultSplash; }
(mkIf (cfg.splashImage == defaultSplash) {
boot.loader.grub.backgroundColor = mkDefault "#2F302F";
boot.loader.grub.splashMode = mkDefault "normal";
})
(mkIf cfg.enable {
boot.loader.grub.devices = mkIf (cfg.device != "") [ cfg.device ];
boot.loader.grub.mirroredBoots = mkIf (cfg.devices != [ ]) [
{
path = "/boot";
inherit (cfg) devices;
inherit (efi) efiSysMountPoint;
}
];
boot.loader.supportsInitrdSecrets = true;
system.systemBuilderArgs.configurationName = cfg.configurationName;
system.systemBuilderCommands = ''
echo -n "$configurationName" > $out/configuration-name
'';
system.build.installBootLoader =
let
install-grub-pl = pkgs.replaceVars ./install-grub.pl {
utillinux = pkgs.util-linux;
btrfsprogs = pkgs.btrfs-progs;
inherit (config.system.nixos) distroName;
# targets of a replacement in code
bootPath = null;
bootRoot = null;
};
perl = pkgs.perl.withPackages (
p: with p; [
FileSlurp
FileCopyRecursive
XMLLibXML
XMLSAX
XMLSAXBase
ListCompare
JSON
]
);
in
pkgs.writeScript "install-grub.sh" (
''
#!${pkgs.runtimeShell}
set -e
${optionalString cfg.enableCryptodisk "export GRUB_ENABLE_CRYPTODISK=y"}
''
+ flip concatMapStrings cfg.mirroredBoots (args: ''
${perl}/bin/perl ${install-grub-pl} ${grubConfig args} $@
'')
+ cfg.extraInstallCommands
);
system.build.grub = grub;
# Common attribute for boot loaders so only one of them can be
# set at once.
system.boot.loader.id = "grub";
environment.systemPackages = mkIf (grub != null) [ grub ];
boot.loader.grub.extraPrepareConfig = concatStrings (
mapAttrsToList (
fileName: sourcePath:
flip concatMapStrings cfg.mirroredBoots (
args:
let
efiSysMountPoint = if args.efiSysMountPoint == null then args.path else args.efiSysMountPoint;
in
''
${pkgs.coreutils}/bin/install -Dp ${escapeShellArg sourcePath} ${escapeShellArg efiSysMountPoint}/${escapeShellArg fileName}
''
)
) config.boot.loader.grub.extraFiles
);
assertions = [
{
assertion = cfg.mirroredBoots != [ ];
message =
"You must set the option boot.loader.grub.devices or "
+ "'boot.loader.grub.mirroredBoots' to make the system bootable.";
}
{
assertion =
cfg.efiSupport
|| all (c: c < 2) (mapAttrsToList (n: c: if n == "nodev" then 0 else c) bootDeviceCounters);
message = "You cannot have duplicated devices in mirroredBoots";
}
{
assertion = cfg.efiInstallAsRemovable -> cfg.efiSupport;
message = "If you wish to to use boot.loader.grub.efiInstallAsRemovable, then turn on boot.loader.grub.efiSupport";
}
{
assertion = cfg.efiInstallAsRemovable -> !config.boot.loader.efi.canTouchEfiVariables;
message = "If you wish to to use boot.loader.grub.efiInstallAsRemovable, then turn off boot.loader.efi.canTouchEfiVariables";
}
{
assertion = !(options.boot.loader.grub.version.isDefined && cfg.version == 1);
message = "Support for version 0.9x of GRUB was removed after being unsupported upstream for around a decade";
}
]
++ flip concatMap cfg.mirroredBoots (
args:
[
{
assertion = args.devices != [ ];
message = "A boot path cannot have an empty devices string in ${args.path}";
}
{
assertion = hasPrefix "/" args.path;
message = "Boot paths must be absolute, not ${args.path}";
}
{
assertion = if args.efiSysMountPoint == null then true else hasPrefix "/" args.efiSysMountPoint;
message = "EFI paths must be absolute, not ${args.efiSysMountPoint}";
}
]
++ forEach args.devices (device: {
assertion = device == "nodev" || hasPrefix "/" device;
message = "GRUB devices must be absolute paths, not ${device} in ${args.path}";
})
);
})
(mkIf options.boot.loader.grub.version.isDefined {
warnings = [
''
The boot.loader.grub.version option does not have any effect anymore, please remove it from your configuration.
''
];
})
];
imports = [
(mkRemovedOptionModule [ "boot" "loader" "grub" "bootDevice" ] "")
(mkRenamedOptionModule [ "boot" "copyKernels" ] [ "boot" "loader" "grub" "copyKernels" ])
(mkRenamedOptionModule [ "boot" "extraGrubEntries" ] [ "boot" "loader" "grub" "extraEntries" ])
(mkRenamedOptionModule
[ "boot" "extraGrubEntriesBeforeNixos" ]
[ "boot" "loader" "grub" "extraEntriesBeforeNixOS" ]
)
(mkRenamedOptionModule [ "boot" "grubDevice" ] [ "boot" "loader" "grub" "device" ])
(mkRenamedOptionModule [ "boot" "bootMount" ] [ "boot" "loader" "grub" "bootDevice" ])
(mkRenamedOptionModule [ "boot" "grubSplashImage" ] [ "boot" "loader" "grub" "splashImage" ])
(mkRemovedOptionModule [ "boot" "loader" "grub" "trustedBoot" ] ''
Support for Trusted GRUB has been removed, because the project
has been retired upstream.
'')
(mkRemovedOptionModule [ "boot" "loader" "grub" "extraInitrd" ] ''
This option has been replaced with the bootloader agnostic
boot.initrd.secrets option. To migrate to the initrd secrets system,
extract the extraInitrd archive into your main filesystem:
# zcat /boot/extra_initramfs.gz | cpio -idvmD /etc/secrets/initrd
/path/to/secret1
/path/to/secret2
then replace boot.loader.grub.extraInitrd with boot.initrd.secrets:
boot.initrd.secrets = {
"/path/to/secret1" = "/etc/secrets/initrd/path/to/secret1";
"/path/to/secret2" = "/etc/secrets/initrd/path/to/secret2";
};
See the boot.initrd.secrets option documentation for more information.
'')
];
}

View File

@@ -0,0 +1,806 @@
use strict;
use warnings;
use Class::Struct;
use XML::LibXML;
use File::Basename;
use File::Path qw(make_path rmtree);
use File::stat;
use File::Copy;
use File::Copy::Recursive qw(rcopy pathrm);
use File::Slurp;
use File::Temp;
use JSON;
use File::Find;
require List::Compare;
use POSIX;
use Cwd;
# system.build.toplevel path
my $defaultConfig = $ARGV[1] or die;
# Grub config XML generated by grubConfig function in grub.nix
my $dom = XML::LibXML->load_xml(location => $ARGV[0]);
sub get { my ($name) = @_; return $dom->findvalue("/expr/attrs/attr[\@name = '$name']/*/\@value"); }
sub getList {
my ($name) = @_;
my @list = ();
foreach my $entry ($dom->findnodes("/expr/attrs/attr[\@name = '$name']/list/string/\@value")) {
$entry = $entry->findvalue(".") or die;
push(@list, $entry);
}
return @list;
}
sub readFile {
my ($fn) = @_;
# enable slurp mode: read entire file in one go
local $/ = undef;
open my $fh, "<", $fn
or return;
my $s = <$fh>;
close $fh;
# disable slurp mode
local $/ = "\n";
chomp $s;
return $s;
}
sub writeFile {
my ($fn, $s) = @_;
open my $fh, ">", $fn or die "cannot create $fn: $!\n";
print $fh $s or die "cannot write to $fn: $!\n";
close $fh or die "cannot close $fn: $!\n";
}
sub runCommand {
open(my $fh, "-|", @_) or die "Failed to execute: $@_\n";
my @ret = $fh->getlines();
close $fh;
return ($?, @ret);
}
my $grub = get("grub");
my $grubTarget = get("grubTarget");
my $extraConfig = get("extraConfig");
my $extraPrepareConfig = get("extraPrepareConfig");
my $extraPerEntryConfig = get("extraPerEntryConfig");
my $extraEntries = get("extraEntries");
my $extraEntriesBeforeNixOS = get("extraEntriesBeforeNixOS") eq "true";
my $splashImage = get("splashImage");
my $splashMode = get("splashMode");
my $entryOptions = get("entryOptions");
my $subEntryOptions = get("subEntryOptions");
my $backgroundColor = get("backgroundColor");
my $configurationLimit = int(get("configurationLimit"));
my $copyKernels = get("copyKernels") eq "true";
my $timeout = int(get("timeout"));
my $timeoutStyle = get("timeoutStyle");
my $defaultEntry = get("default");
my $fsIdentifier = get("fsIdentifier");
my $grubEfi = get("grubEfi");
my $grubTargetEfi = get("grubTargetEfi");
my $bootPath = get("bootPath");
my $storePath = get("storePath");
my $canTouchEfiVariables = get("canTouchEfiVariables");
my $efiInstallAsRemovable = get("efiInstallAsRemovable");
my $efiSysMountPoint = get("efiSysMountPoint");
my $gfxmodeEfi = get("gfxmodeEfi");
my $gfxmodeBios = get("gfxmodeBios");
my $gfxpayloadEfi = get("gfxpayloadEfi");
my $gfxpayloadBios = get("gfxpayloadBios");
my $bootloaderId = get("bootloaderId");
my $forceInstall = get("forceInstall");
my $font = get("font");
my $theme = get("theme");
my $saveDefault = $defaultEntry eq "saved";
$ENV{'PATH'} = get("path");
print STDERR "updating GRUB 2 menu...\n";
make_path("$bootPath/grub", { mode => 0700 });
# Discover whether the bootPath is on the same filesystem as / and
# /nix/store. If not, then all kernels and initrds must be copied to
# the bootPath.
if (stat($bootPath)->dev != stat("/nix/store")->dev) {
$copyKernels = 1;
}
# Discover information about the location of the bootPath
struct(Fs => {
device => '$',
type => '$',
mount => '$',
});
sub PathInMount {
my ($path, $mount) = @_;
my @splitMount = split /\//, $mount;
my @splitPath = split /\//, $path;
if ($#splitPath < $#splitMount) {
return 0;
}
for (my $i = 0; $i <= $#splitMount; $i++) {
if ($splitMount[$i] ne $splitPath[$i]) {
return 0;
}
}
return 1;
}
# Figure out what filesystem is used for the directory with init/initrd/kernel files
sub GetFs {
my ($dir) = @_;
my $bestFs = Fs->new(device => "", type => "", mount => "");
foreach my $fs (read_file("/proc/self/mountinfo")) {
chomp $fs;
my @fields = split / /, $fs;
my $mountPoint = $fields[4];
my @mountOptions = split /,/, $fields[5];
# Skip the optional fields.
my $n = 6; $n++ while $fields[$n] ne "-"; $n++;
my $fsType = $fields[$n];
my $device = $fields[$n + 1];
my @superOptions = split /,/, $fields[$n + 2];
# Skip the bind-mount on /nix/store.
next if $mountPoint eq "/nix/store" && (grep { $_ eq "rw" } @superOptions);
# Skip mount point generated by systemd-efi-boot-generator?
next if $fsType eq "autofs";
# Ensure this matches the intended directory
next unless PathInMount($dir, $mountPoint);
# Is it better than our current match?
if (length($mountPoint) > length($bestFs->mount)) {
# -d performs a stat, which can hang forever on network file systems,
# so we only make this call last, when it's likely that this is the mount point we need.
next unless -d $mountPoint;
$bestFs = Fs->new(device => $device, type => $fsType, mount => $mountPoint);
}
}
return $bestFs;
}
struct (Grub => {
path => '$',
search => '$',
});
my $driveid = 1;
sub GrubFs {
my ($dir) = @_;
my $fs = GetFs($dir);
my $path = substr($dir, length($fs->mount));
if (substr($path, 0, 1) ne "/") {
$path = "/$path";
}
my $search = "";
# ZFS is completely separate logic as zpools are always identified by a label
# or custom UUID
if ($fs->type eq 'zfs') {
my $sid = index($fs->device, '/');
if ($sid < 0) {
$search = '--label ' . $fs->device;
$path = '/@' . $path;
} else {
$search = '--label ' . substr($fs->device, 0, $sid);
$path = '/' . substr($fs->device, $sid) . '/@' . $path;
}
} else {
my %types = ('uuid' => '--fs-uuid', 'label' => '--label');
if ($fsIdentifier eq 'provided') {
# If the provided dev is identifying the partition using a label or uuid,
# we should get the label / uuid and do a proper search
my @matches = $fs->device =~ m/\/dev\/disk\/by-(label|uuid)\/(.*)/;
if ($#matches > 1) {
die "Too many matched devices"
} elsif ($#matches == 1) {
$search = "$types{$matches[0]} $matches[1]"
}
} else {
# Determine the identifying type
$search = $types{$fsIdentifier} . ' ';
# Based on the type pull in the identifier from the system
my ($status, @devInfo) = runCommand("@utillinux@/bin/blkid", "-o", "export", @{[$fs->device]});
if ($status != 0) {
die "Failed to get blkid info (returned $status) for @{[$fs->mount]} on @{[$fs->device]}";
}
my @matches = join("", @devInfo) =~ m/@{[uc $fsIdentifier]}=([^\n]*)/;
if ($#matches != 0) {
die "Couldn't find a $types{$fsIdentifier} for @{[$fs->device]}\n"
}
$search .= $matches[0];
}
# BTRFS is a special case in that we need to fix the referenced path based on subvolumes
if ($fs->type eq 'btrfs') {
my ($status, @id_info) = runCommand("@btrfsprogs@/bin/btrfs", "subvol", "show", @{[$fs->mount]});
if ($status != 0) {
die "Failed to retrieve subvolume info for @{[$fs->mount]}\n";
}
my @ids = join("\n", @id_info) =~ m/^(?!\/\n).*Subvolume ID:[ \t\n]*([0-9]+)/s;
if ($#ids > 0) {
die "Btrfs subvol name for @{[$fs->device]} listed multiple times in mount\n"
} elsif ($#ids == 0) {
my ($status, @path_info) = runCommand("@btrfsprogs@/bin/btrfs", "subvol", "list", @{[$fs->mount]});
if ($status != 0) {
die "Failed to find @{[$fs->mount]} subvolume id from btrfs\n";
}
my @paths = join("", @path_info) =~ m/ID $ids[0] [^\n]* path ([^\n]*)/;
if ($#paths > 0) {
die "Btrfs returned multiple paths for a single subvolume id, mountpoint @{[$fs->mount]}\n";
} elsif ($#paths != 0) {
die "Btrfs did not return a path for the subvolume at @{[$fs->mount]}\n";
}
$path = "/$paths[0]$path";
}
}
}
if (not $search eq "") {
$search = "search --set=drive$driveid " . $search;
$path = "(\$drive$driveid)$path";
$driveid += 1;
}
return Grub->new(path => $path, search => $search);
}
my $grubBoot = GrubFs($bootPath);
my $grubStore;
if ($copyKernels == 0) {
$grubStore = GrubFs($storePath);
}
# Generate the header.
my $conf .= "# Automatically generated. DO NOT EDIT THIS FILE!\n";
my @users = ();
foreach my $user ($dom->findnodes('/expr/attrs/attr[@name = "users"]/attrs/attr')) {
my $name = $user->findvalue('@name') or die;
my $hashedPassword = $user->findvalue('./attrs/attr[@name = "hashedPassword"]/string/@value');
my $hashedPasswordFile = $user->findvalue('./attrs/attr[@name = "hashedPasswordFile"]/string/@value');
my $password = $user->findvalue('./attrs/attr[@name = "password"]/string/@value');
my $passwordFile = $user->findvalue('./attrs/attr[@name = "passwordFile"]/string/@value');
if ($hashedPasswordFile) {
open(my $f, '<', $hashedPasswordFile) or die "Can't read file '$hashedPasswordFile'!";
$hashedPassword = <$f>;
chomp $hashedPassword;
}
if ($passwordFile) {
open(my $f, '<', $passwordFile) or die "Can't read file '$passwordFile'!";
$password = <$f>;
chomp $password;
}
if ($hashedPassword) {
if (index($hashedPassword, "grub.pbkdf2.") == 0) {
$conf .= "\npassword_pbkdf2 $name $hashedPassword";
}
else {
die "Password hash for GRUB user '$name' is not valid!";
}
}
elsif ($password) {
$conf .= "\npassword $name $password";
}
else {
die "GRUB user '$name' has no password!";
}
push(@users, $name);
}
if (@users) {
$conf .= "\nset superusers=\"" . join(' ',@users) . "\"\n";
}
if ($copyKernels == 0) {
$conf .= "
" . $grubStore->search;
}
# FIXME: should use grub-mkconfig.
my $defaultEntryText = $defaultEntry;
if ($saveDefault) {
$defaultEntryText = "\"\${saved_entry}\"";
}
$conf .= "
" . $grubBoot->search . "
if [ -s \$prefix/grubenv ]; then
load_env
fi
# grub-reboot sets a one-time saved entry, which we process here and
# then delete.
if [ \"\${next_entry}\" ]; then
set default=\"\${next_entry}\"
set next_entry=
save_env next_entry
set timeout=1
set boot_once=true
else
set default=$defaultEntryText
set timeout=$timeout
fi
set timeout_style=$timeoutStyle
function savedefault {
if [ -z \"\${boot_once}\"]; then
saved_entry=\"\${chosen}\"
save_env saved_entry
fi
}
# Setup the graphics stack for bios and efi systems
if [ \"\${grub_platform}\" = \"efi\" ]; then
insmod efi_gop
insmod efi_uga
else
insmod vbe
fi
";
if ($font) {
copy $font, "$bootPath/converted-font.pf2" or die "cannot copy $font to $bootPath: $!\n";
$conf .= "
insmod font
if loadfont " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/converted-font.pf2; then
insmod gfxterm
if [ \"\${grub_platform}\" = \"efi\" ]; then
set gfxmode=$gfxmodeEfi
set gfxpayload=$gfxpayloadEfi
else
set gfxmode=$gfxmodeBios
set gfxpayload=$gfxpayloadBios
fi
terminal_output gfxterm
fi
";
}
if ($splashImage) {
# Keeps the image's extension.
my ($filename, $dirs, $suffix) = fileparse($splashImage, qr"\..[^.]*$");
# The module for jpg is jpeg.
if ($suffix eq ".jpg") {
$suffix = ".jpeg";
}
if ($backgroundColor) {
$conf .= "
background_color '$backgroundColor'
";
}
copy $splashImage, "$bootPath/background$suffix" or die "cannot copy $splashImage to $bootPath: $!\n";
$conf .= "
insmod " . substr($suffix, 1) . "
if background_image --mode '$splashMode' " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/background$suffix; then
set color_normal=white/black
set color_highlight=black/white
else
set menu_color_normal=cyan/blue
set menu_color_highlight=white/blue
fi
";
}
rmtree("$bootPath/theme") or die "cannot clean up theme folder in $bootPath\n" if -e "$bootPath/theme";
if ($theme) {
# Copy theme
rcopy($theme, "$bootPath/theme") or die "cannot copy $theme to $bootPath\n";
# Detect which modules will need to be loaded
my $with_png = 0;
my $with_jpeg = 0;
find({ wanted => sub {
if ($_ =~ /\.png$/i) {
$with_png = 1;
}
elsif ($_ =~ /\.jpe?g$/i) {
$with_jpeg = 1;
}
}, no_chdir => 1 }, $theme);
if ($with_png) {
$conf .= "
insmod png
"
}
if ($with_jpeg) {
$conf .= "
insmod jpeg
"
}
$conf .= "
# Sets theme.
set theme=" . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/theme/theme.txt
export theme
# Load theme fonts, if any
";
find( { wanted => sub {
if ($_ =~ /\.pf2$/i) {
$font = File::Spec->abs2rel($File::Find::name, $theme);
$conf .= "
loadfont " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/theme/$font
";
}
}, no_chdir => 1 }, $theme );
}
$conf .= "$extraConfig\n";
# Generate the menu entries.
$conf .= "\n";
my %copied;
make_path("$bootPath/kernels", { mode => 0755 }) if $copyKernels;
sub copyToKernelsDir {
my ($path) = @_;
return $grubStore->path . substr($path, length("/nix/store")) unless $copyKernels;
$path =~ /\/nix\/store\/(.*)/ or die;
my $name = $1; $name =~ s/\//-/g;
my $dst = "$bootPath/kernels/$name";
# Don't copy the file if $dst already exists. This means that we
# have to create $dst atomically to prevent partially copied
# kernels or initrd if this script is ever interrupted.
if (! -e $dst) {
my $tmp = "$dst.tmp";
copy $path, $tmp or die "cannot copy $path to $tmp: $!\n";
rename $tmp, $dst or die "cannot rename $tmp to $dst: $!\n";
}
$copied{$dst} = 1;
return ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/kernels/$name";
}
sub addEntry {
my ($name, $path, $options, $current) = @_;
return unless -e "$path/kernel" && -e "$path/initrd";
my $kernel = copyToKernelsDir(Cwd::abs_path("$path/kernel"));
my $initrd = copyToKernelsDir(Cwd::abs_path("$path/initrd"));
# Include second initrd with secrets
if (-e -x "$path/append-initrd-secrets") {
# Name the initrd secrets after the system from which they're derived.
my $systemName = basename(Cwd::abs_path("$path"));
my $initrdSecretsPath = "$bootPath/kernels/$systemName-secrets";
make_path(dirname($initrdSecretsPath), { mode => 0755 });
my $oldUmask = umask;
# Make sure initrd is not world readable (won't work if /boot is FAT)
umask 0137;
my $initrdSecretsPathTemp = File::Temp::mktemp("$initrdSecretsPath.XXXXXXXX");
if (system("$path/append-initrd-secrets", $initrdSecretsPathTemp) != 0) {
if ($current) {
die "failed to create initrd secrets $!\n";
} else {
say STDERR "warning: failed to create initrd secrets for \"$name\", an older generation";
say STDERR "note: this is normal after having removed or renamed a file in `boot.initrd.secrets`";
}
}
# Check whether any secrets were actually added
if (-e $initrdSecretsPathTemp && ! -z _) {
rename $initrdSecretsPathTemp, $initrdSecretsPath or die "failed to move initrd secrets into place: $!\n";
$copied{$initrdSecretsPath} = 1;
$initrd .= " " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/kernels/$systemName-secrets";
} else {
unlink $initrdSecretsPathTemp;
rmdir dirname($initrdSecretsPathTemp);
}
umask $oldUmask;
}
my $xen = -e "$path/xen.gz" ? copyToKernelsDir(Cwd::abs_path("$path/xen.gz")) : undef;
# FIXME: $confName
my $kernelParams =
"init=" . Cwd::abs_path("$path/init") . " " .
readFile("$path/kernel-params");
my $xenParams = $xen && -e "$path/xen-params" ? readFile("$path/xen-params") : "";
$conf .= "menuentry \"$name\" " . $options . " {\n";
if ($saveDefault) {
$conf .= " savedefault\n";
}
$conf .= $grubBoot->search . "\n";
if ($copyKernels == 0) {
$conf .= $grubStore->search . "\n";
}
$conf .= " $extraPerEntryConfig\n" if $extraPerEntryConfig;
$conf .= " multiboot $xen $xenParams\n" if $xen;
$conf .= " " . ($xen ? "module" : "linux") . " $kernel $kernelParams\n";
$conf .= " " . ($xen ? "module" : "initrd") . " $initrd\n";
$conf .= "}\n\n";
}
sub addGeneration {
my ($name, $nameSuffix, $path, $options, $current) = @_;
# Do not search for grand children
my @links = sort (glob "$path/specialisation/*");
if ($current != 1 && scalar(@links) != 0) {
$conf .= "submenu \"$name$nameSuffix\" --class submenu {\n";
}
addEntry("$name" . (scalar(@links) == 0 ? "" : " - Default") . $nameSuffix, $path, $options, $current);
# Find all the children of the current default configuration
# Do not search for grand children
foreach my $link (@links) {
my $entryName = "";
my $cfgName = readFile("$link/configuration-name");
my $date = strftime("%F", localtime(lstat($link)->mtime));
my $version =
-e "$link/nixos-version"
? readFile("$link/nixos-version")
: basename((glob(dirname(Cwd::abs_path("$link/kernel")) . "/lib/modules/*"))[0]);
if ($cfgName) {
$entryName = $cfgName;
} else {
my $linkname = basename($link);
$entryName = "($linkname - $date - $version)";
}
addEntry("$name - $entryName", $link, "", 1);
}
if ($current != 1 && scalar(@links) != 0) {
$conf .= "}\n";
}
}
# Add default entries.
$conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS;
addGeneration("@distroName@", "", $defaultConfig, $entryOptions, 1);
$conf .= "$extraEntries\n" unless $extraEntriesBeforeNixOS;
my $grubBootPath = $grubBoot->path;
# extraEntries could refer to @bootRoot@, which we have to substitute
$conf =~ s/\@bootRoot\@/$grubBootPath/g;
# Emit submenus for all system profiles.
sub addProfile {
my ($profile, $description) = @_;
# Add entries for all generations of this profile.
$conf .= "submenu \"$description\" --class submenu {\n";
sub nrFromGen { my ($x) = @_; $x =~ /\/\w+-(\d+)-link/; return $1; }
my @links = sort
{ nrFromGen($b) <=> nrFromGen($a) }
(glob "$profile-*-link");
my $curEntry = 0;
foreach my $link (@links) {
last if $curEntry++ >= $configurationLimit;
if (! -e "$link/nixos-version") {
warn "skipping corrupt system profile entry $link\n";
next;
}
my $date = strftime("%F", localtime(lstat($link)->mtime));
my $version =
-e "$link/nixos-version"
? readFile("$link/nixos-version")
: basename((glob(dirname(Cwd::abs_path("$link/kernel")) . "/lib/modules/*"))[0]);
addGeneration("@distroName@ - Configuration " . nrFromGen($link), " ($date - $version)", $link, $subEntryOptions, 0);
}
$conf .= "}\n";
}
addProfile "/nix/var/nix/profiles/system", "@distroName@ - All configurations";
for my $profile (glob "/nix/var/nix/profiles/system-profiles/*") {
my $name = basename($profile);
next unless $name =~ /^\w+$/;
addProfile $profile, "@distroName@ - Profile '$name'";
}
# extraPrepareConfig could refer to @bootPath@, which we have to substitute
$extraPrepareConfig =~ s/\@bootPath\@/$bootPath/g;
# Run extraPrepareConfig in sh
if ($extraPrepareConfig ne "") {
system((get("shell"), "-c", $extraPrepareConfig));
}
# write the GRUB config.
my $confFile = "$bootPath/grub/grub.cfg";
my $tmpFile = $confFile . ".tmp";
writeFile($tmpFile, $conf);
# check whether to install GRUB EFI or not
sub getEfiTarget {
if (($grub ne "") && ($grubEfi ne "")) {
# EFI can only be installed when target is set;
# A target is also required then for non-EFI grub
if (($grubTarget eq "") || ($grubTargetEfi eq "")) { die }
else { return "both" }
} elsif (($grub ne "") && ($grubEfi eq "")) {
# TODO: It would be safer to disallow non-EFI grub installation if no target is given.
# If no target is given, then grub auto-detects the target which can lead to errors.
# E.g. it seems as if grub would auto-detect a EFI target based on the availability
# of a EFI partition.
# However, it seems as auto-detection is currently relied on for non-x86_64 and non-i386
# architectures in NixOS. That would have to be fixed in the nixos modules first.
return "no"
} elsif (($grub eq "") && ($grubEfi ne "")) {
# EFI can only be installed when target is set;
if ($grubTargetEfi eq "") { die }
else {return "only" }
} else {
# prevent an installation if neither grub nor grubEfi is given
return "neither"
}
}
my $efiTarget = getEfiTarget();
# Append entries detected by os-prober
if (get("useOSProber") eq "true") {
if ($saveDefault) {
# os-prober will read this to determine if "savedefault" should be added to generated entries
$ENV{'GRUB_SAVEDEFAULT'} = "true";
}
my $targetpackage = ($efiTarget eq "no") ? $grub : $grubEfi;
system(get("shell"), "-c", "pkgdatadir=$targetpackage/share/grub $targetpackage/etc/grub.d/30_os-prober >> $tmpFile");
}
# Atomically switch to the new config
rename $tmpFile, $confFile or die "cannot rename $tmpFile to $confFile: $!\n";
# Remove obsolete files from $bootPath/kernels.
foreach my $fn (glob "$bootPath/kernels/*") {
next if defined $copied{$fn};
print STDERR "removing obsolete file $fn\n";
unlink $fn;
}
#
# Install GRUB if the parameters changed from the last time we installed it.
#
struct(GrubState => {
name => '$',
version => '$',
efi => '$',
devices => '$',
efiMountPoint => '$',
extraGrubInstallArgs => '@',
});
# If you add something to the state file, only add it to the end
# because it is read line-by-line.
sub readGrubState {
my $defaultGrubState = GrubState->new(name => "", version => "", efi => "", devices => "", efiMountPoint => "", extraGrubInstallArgs => () );
open my $fh, "<", "$bootPath/grub/state" or return $defaultGrubState;
local $/ = "\n";
my $name = <$fh>;
chomp($name);
my $version = <$fh>;
chomp($version);
my $efi = <$fh>;
chomp($efi);
my $devices = <$fh>;
chomp($devices);
my $efiMountPoint = <$fh>;
chomp($efiMountPoint);
# Historically, arguments in the state file were one per each line, but that
# gets really messy when newlines are involved, structured arguments
# like lists are needed (they have to have a separator encoding), or even worse,
# when we need to remove a setting in the future. Thus, the 6th line is a JSON
# object that can store structured data, with named keys, and all new state
# should go in there.
my $jsonStateLine = <$fh>;
# For historical reasons we do not check the values above for un-definedness
# (that is, when the state file has too few lines and EOF is reached),
# because the above come from the first version of this logic and are thus
# guaranteed to be present.
$jsonStateLine = defined $jsonStateLine ? $jsonStateLine : '{}'; # empty JSON object
chomp($jsonStateLine);
if ($jsonStateLine eq "") {
$jsonStateLine = '{}'; # empty JSON object
}
my %jsonState = %{decode_json($jsonStateLine)};
my @extraGrubInstallArgs = exists($jsonState{'extraGrubInstallArgs'}) ? @{$jsonState{'extraGrubInstallArgs'}} : ();
close $fh;
my $grubState = GrubState->new(name => $name, version => $version, efi => $efi, devices => $devices, efiMountPoint => $efiMountPoint, extraGrubInstallArgs => \@extraGrubInstallArgs );
return $grubState
}
my @deviceTargets = getList('devices');
my $prevGrubState = readGrubState();
my @prevDeviceTargets = split/,/, $prevGrubState->devices;
my @extraGrubInstallArgs = getList('extraGrubInstallArgs');
my @prevExtraGrubInstallArgs = @{$prevGrubState->extraGrubInstallArgs};
my $devicesDiffer = scalar (List::Compare->new( '-u', '-a', \@deviceTargets, \@prevDeviceTargets)->get_symmetric_difference());
my $extraGrubInstallArgsDiffer = scalar (List::Compare->new( '-u', '-a', \@extraGrubInstallArgs, \@prevExtraGrubInstallArgs)->get_symmetric_difference());
my $nameDiffer = get("fullName") ne $prevGrubState->name;
my $versionDiffer = get("fullVersion") ne $prevGrubState->version;
my $efiDiffer = $efiTarget ne $prevGrubState->efi;
my $efiMountPointDiffer = $efiSysMountPoint ne $prevGrubState->efiMountPoint;
if (($ENV{'NIXOS_INSTALL_GRUB'} // "") eq "1") {
warn "NIXOS_INSTALL_GRUB env var deprecated, use NIXOS_INSTALL_BOOTLOADER";
$ENV{'NIXOS_INSTALL_BOOTLOADER'} = "1";
}
my $requireNewInstall = $devicesDiffer || $extraGrubInstallArgsDiffer || $nameDiffer || $versionDiffer || $efiDiffer || $efiMountPointDiffer || (($ENV{'NIXOS_INSTALL_BOOTLOADER'} // "") eq "1");
# install a symlink so that grub can detect the boot drive
my $tmpDir = File::Temp::tempdir(CLEANUP => 1) or die "Failed to create temporary space: $!";
symlink "$bootPath", "$tmpDir/boot" or die "Failed to symlink $tmpDir/boot: $!";
# install non-EFI GRUB
if (($requireNewInstall != 0) && ($efiTarget eq "no" || $efiTarget eq "both")) {
foreach my $dev (@deviceTargets) {
next if $dev eq "nodev";
print STDERR "installing the GRUB 2 boot loader on $dev...\n";
my @command = ("$grub/sbin/grub-install", "--recheck", "--root-directory=$tmpDir", Cwd::abs_path($dev), @extraGrubInstallArgs);
if ($forceInstall eq "true") {
push @command, "--force";
}
if ($grubTarget ne "") {
push @command, "--target=$grubTarget";
}
(system @command) == 0 or die "$0: installation of GRUB on $dev failed: $!\n";
}
}
# install EFI GRUB
if (($requireNewInstall != 0) && ($efiTarget eq "only" || $efiTarget eq "both")) {
print STDERR "installing the GRUB 2 boot loader into $efiSysMountPoint...\n";
my @command = ("$grubEfi/sbin/grub-install", "--recheck", "--target=$grubTargetEfi", "--boot-directory=$bootPath", "--efi-directory=$efiSysMountPoint", @extraGrubInstallArgs);
if ($forceInstall eq "true") {
push @command, "--force";
}
push @command, "--bootloader-id=$bootloaderId";
if ($canTouchEfiVariables ne "true") {
push @command, "--no-nvram";
push @command, "--removable" if $efiInstallAsRemovable eq "true";
}
(system @command) == 0 or die "$0: installation of GRUB EFI into $efiSysMountPoint failed: $!\n";
}
# update GRUB state file
if ($requireNewInstall != 0) {
# Temp file for atomic rename.
my $stateFile = "$bootPath/grub/state";
my $stateFileTmp = $stateFile . ".tmp";
open my $fh, ">", "$stateFileTmp" or die "cannot create $stateFileTmp: $!\n";
print $fh get("fullName"), "\n" or die;
print $fh get("fullVersion"), "\n" or die;
print $fh $efiTarget, "\n" or die;
print $fh join( ",", @deviceTargets ), "\n" or die;
print $fh $efiSysMountPoint, "\n" or die;
my %jsonState = (
extraGrubInstallArgs => \@extraGrubInstallArgs
);
my $jsonStateLine = encode_json(\%jsonState);
print $fh $jsonStateLine, "\n" or die;
close $fh or die;
# Atomically switch to the new state file
rename $stateFileTmp, $stateFile or die "cannot rename $stateFileTmp to $stateFile: $!\n";
}

View File

@@ -0,0 +1,65 @@
# This module adds a scripted iPXE entry to the GRUB boot menu.
{
config,
lib,
pkgs,
...
}:
with lib;
let
scripts = builtins.attrNames config.boot.loader.grub.ipxe;
grubEntry = name: ''
menuentry "iPXE - ${name}" {
linux16 @bootRoot@/ipxe.lkrn
initrd16 @bootRoot@/${name}.ipxe
}
'';
scriptFile =
name:
let
value = builtins.getAttr name config.boot.loader.grub.ipxe;
in
if builtins.typeOf value == "path" then value else builtins.toFile "${name}.ipxe" value;
in
{
options = {
boot.loader.grub.ipxe = mkOption {
type = types.attrsOf (types.either types.path types.str);
description = ''
Set of iPXE scripts available for
booting from the GRUB boot menu.
'';
default = { };
example = literalExpression ''
{ demo = '''
#!ipxe
dhcp
chain http://boot.ipxe.org/demo/boot.php
''';
}
'';
};
};
config = mkIf (builtins.length scripts != 0) {
boot.loader.grub.extraEntries = toString (map grubEntry scripts);
boot.loader.grub.extraFiles = {
"ipxe.lkrn" = "${pkgs.ipxe}/ipxe.lkrn";
}
// builtins.listToAttrs (
map (name: {
name = name + ".ipxe";
value = scriptFile name;
}) scripts
);
};
}

View File

@@ -0,0 +1,74 @@
# This module adds Memtest86+ to the GRUB boot menu.
{
config,
lib,
pkgs,
...
}:
with lib;
let
memtest86 = pkgs.memtest86plus;
cfg = config.boot.loader.grub.memtest86;
in
{
options = {
boot.loader.grub.memtest86 = {
enable = mkOption {
default = false;
type = types.bool;
description = ''
Make Memtest86+, a memory testing program, available from the GRUB
boot menu.
'';
};
params = mkOption {
default = [ ];
example = [ "console=ttyS0,115200" ];
type = types.listOf types.str;
description = ''
Parameters added to the Memtest86+ command line. As of memtest86+ 5.01
the following list of (apparently undocumented) parameters are
accepted:
- `console=...`, set up a serial console.
Examples:
`console=ttyS0`,
`console=ttyS0,9600` or
`console=ttyS0,115200n8`.
- `btrace`, enable boot trace.
- `maxcpus=N`, limit number of CPUs.
- `onepass`, run one pass and exit if there
are no errors.
- `tstlist=...`, list of tests to run.
Example: `0,1,2`.
- `cpumask=...`, set a CPU mask, to select CPUs
to use for testing.
This list of command line options was obtained by reading the
Memtest86+ source code.
'';
};
};
};
config = mkIf cfg.enable {
boot.loader.grub.extraEntries = ''
menuentry "Memtest86+" {
linux @bootRoot@/memtest.bin ${toString cfg.params}
}
'';
boot.loader.grub.extraFiles."memtest.bin" = "${memtest86}/memtest.bin";
};
}

View File

@@ -0,0 +1,88 @@
#! @bash@/bin/sh -e
shopt -s nullglob
export PATH=/empty:@path@
if test $# -ne 1; then
echo "Usage: init-script-builder.sh DEFAULT-CONFIG"
exit 1
fi
defaultConfig="$1"
[ "$(stat -f -c '%i' /)" = "$(stat -f -c '%i' /boot)" ] || {
# see grub-menu-builder.sh
echo "WARNING: /boot being on a different filesystem not supported by init-script-builder.sh"
}
target="/sbin/init"
targetOther="/boot/init-other-configurations-contents.txt"
tmp="$target.tmp"
tmpOther="$targetOther.tmp"
configurationCounter=0
# Add an entry to $targetOther
addEntry() {
local name="$1"
local path="$2"
local shortSuffix="$3"
configurationCounter=$((configurationCounter + 1))
local stage2=$path/init
content="$(
echo "#!/bin/sh"
echo "# $name"
echo "# created by init-script-builder.sh"
echo "exec $stage2"
)"
[ "$path" != "$defaultConfig" ] || {
echo "$content" > $tmp
echo "# older configurations: $targetOther" >> $tmp
chmod +x $tmp
}
echo -e "$content\n\n" >> $tmpOther
}
mkdir -p /boot /sbin
addEntry "@distroName@ - Default" $defaultConfig ""
# Add all generations of the system profile to the menu, in reverse
# (most recent to least recent) order.
for link in $((ls -d $defaultConfig/specialisation/* ) | sort -n); do
date=$(stat --printf="%y\n" $link | sed 's/\..*//')
addEntry "@distroName@ - variation" $link ""
done
for generation in $(
(cd /nix/var/nix/profiles && ls -d system-*-link) \
| sed 's/system-\([0-9]\+\)-link/\1/' \
| sort -n -r); do
link=/nix/var/nix/profiles/system-$generation-link
date=$(stat --printf="%y\n" $link | sed 's/\..*//')
if [ -d $link/kernel ]; then
kernelVersion=$(cd $(dirname $(readlink -f $link/kernel))/lib/modules && echo *)
suffix="($date - $kernelVersion)"
else
suffix="($date)"
fi
addEntry "@distroName@ - Configuration $generation $suffix" $link "$generation ($date)"
done
mv $tmpOther $targetOther
mv $tmp $target

View File

@@ -0,0 +1,62 @@
{
config,
lib,
pkgs,
...
}:
with lib;
let
initScriptBuilder = pkgs.replaceVarsWith {
src = ./init-script-builder.sh;
isExecutable = true;
replacements = {
inherit (pkgs) bash;
inherit (config.system.nixos) distroName;
path = lib.makeBinPath [
pkgs.coreutils
pkgs.gnused
pkgs.gnugrep
];
};
};
in
{
###### interface
options = {
boot.loader.initScript = {
enable = mkOption {
default = false;
type = types.bool;
description = ''
Some systems require a /sbin/init script which is started.
Or having it makes starting NixOS easier.
This applies to some kind of hosting services and user mode linux.
Additionally this script will create
/boot/init-other-configurations-contents.txt containing
contents of remaining configurations. You can copy paste them into
/sbin/init manually running a rescue system or such.
'';
};
};
};
###### implementation
config = mkIf config.boot.loader.initScript.enable {
system.build.installBootLoader = initScriptBuilder;
};
}

View File

@@ -0,0 +1,661 @@
#!@python3@/bin/python3 -B
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
import datetime
import hashlib
import json
from ctypes import CDLL
import os
import psutil
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
@dataclass
class XenBootSpec:
"""Represent the bootspec extension for Xen dom0 kernels"""
efiPath: str
multibootPath: str
params: List[str]
version: str
@dataclass
class BootSpec:
system: str
init: str
kernel: str
kernelParams: List[str]
label: str
toplevel: str
specialisations: Dict[str, "BootSpec"]
xen: XenBootSpec | None
initrd: str | None = None
initrdSecrets: str | None = None
install_config = json.load(open('@configPath@', 'r'))
libc = CDLL("libc.so.6")
limine_install_dir: Optional[str] = None
can_use_direct_paths = False
paths: Dict[str, bool] = {}
def config(*path: str) -> Optional[Any]:
result = install_config
for component in path:
result = result[component]
return result
def get_system_path(profile: str = 'system', gen: Optional[str] = None, spec: Optional[str] = None) -> str:
basename = f'{profile}-{gen}-link' if gen is not None else profile
profiles_dir = '/nix/var/nix/profiles'
if profile == 'system':
result = os.path.join(profiles_dir, basename)
else:
result = os.path.join(profiles_dir, 'system-profiles', basename)
if spec is not None:
result = os.path.join(result, 'specialisation', spec)
return result
def get_profiles() -> List[str]:
profiles_dir = '/nix/var/nix/profiles/system-profiles/'
dirs = os.listdir(profiles_dir) if os.path.isdir(profiles_dir) else []
return [path for path in dirs if not path.endswith('-link')]
def get_gens(profile: str = 'system') -> List[Tuple[int, List[str]]]:
nix_env = os.path.join(str(config('nixPath')), 'bin', 'nix-env')
output = subprocess.check_output([
nix_env, '--list-generations',
'-p', get_system_path(profile),
'--option', 'build-users-group', '',
], universal_newlines=True)
gen_lines = output.splitlines()
gen_nums = [int(line.split()[0]) for line in gen_lines]
return [gen for gen in gen_nums][-config('maxGenerations'):]
def is_encrypted(device: str) -> bool:
for name in config('luksDevices'):
if os.readlink(os.path.join('/dev/mapper', name)) == os.readlink(device):
return True
return False
def is_fs_type_supported(fs_type: str) -> bool:
return fs_type.startswith('vfat')
def get_dest_file(path: str) -> str:
package_id = os.path.basename(os.path.dirname(path))
suffix = os.path.basename(path)
return f'{package_id}-{suffix}'
def get_dest_path(path: str, target: str) -> str:
dest_file = get_dest_file(path)
return os.path.join(str(limine_install_dir), target, dest_file)
def get_copied_path_uri(path: str, target: str) -> str:
result = ''
dest_file = get_dest_file(path)
dest_path = get_dest_path(path, target)
if not os.path.exists(dest_path):
copy_file(path, dest_path)
else:
paths[dest_path] = True
path_with_prefix = os.path.join('/limine', target, dest_file)
result = f'boot():{path_with_prefix}'
if config('validateChecksums'):
with open(path, 'rb') as file:
b2sum = hashlib.blake2b()
b2sum.update(file.read())
result += f'#{b2sum.hexdigest()}'
return result
def get_path_uri(path: str) -> str:
return get_copied_path_uri(path, "")
def get_file_uri(profile: str, gen: Optional[str], spec: Optional[str], name: str) -> str:
gen_path = get_system_path(profile, gen, spec)
path_in_store = os.path.realpath(os.path.join(gen_path, name))
return get_path_uri(path_in_store)
def get_kernel_uri(kernel_path: str) -> str:
return get_copied_path_uri(kernel_path, "kernels")
def bootjson_to_bootspec(bootjson: dict) -> BootSpec:
specialisations = bootjson['org.nixos.specialisation.v1']
specialisations = {k: bootjson_to_bootspec(v) for k, v in specialisations.items()}
xen = None
if 'org.xenproject.bootspec.v2' in bootjson:
xen = bootjson['org.xenproject.bootspec.v2']
return BootSpec(
**bootjson['org.nixos.bootspec.v1'],
specialisations=specialisations,
xen=xen,
)
def generate_xen_efi_files(
bootspec: BootSpec,
gen: str
) -> str:
"""Generate a Xen EFI xen.cfg file, and copy required files in place.
Assumes the bootspec has already been validated as having the requried
Xen keys.
Arguments:
bootspec -- the NixOS BootSpec requiring Xen EFI configuration
gen -- The system generation requiring Xen EFI configuration
Returns the path to the Xen EFI binary
"""
xen_efi_boot_path = get_copied_path_uri(bootspec.xen['efiPath'], f'xen/{gen}')
xen_efi_path = get_dest_path(bootspec.xen['efiPath'], f'xen/{gen}')
xen_efi_cfg_dir = os.path.dirname(xen_efi_path)
xen_efi_cfg_path = xen_efi_path[:-4] + '.cfg'
if not os.path.exists(xen_efi_cfg_dir):
os.makedirs(xen_efi_cfg_dir)
xen_efi_cfg = (
f'default=nixos{gen}\n\n' +
f'[nixos{gen}]\n'
)
# set xen dom0 parameters
if 'params' in bootspec.xen and len(bootspec.xen['params']) > 0:
xen_efi_cfg += 'options=' + ' '.join(bootspec.xen['params']).strip() + '\n'
# set kernel and copy in-place
xen_efi_kernel_path = get_dest_path(bootspec.kernel, f'xen/{gen}')
copy_file(bootspec.kernel, xen_efi_kernel_path)
xen_efi_cfg += (
'kernel=' + os.path.basename(xen_efi_kernel_path) + ' '
+ ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip()
+ '\n'
)
# set ramdisk and copy initrd in-place
if bootspec.initrd:
xen_efi_initrd_path = get_dest_path(bootspec.initrd, f'xen/{gen}')
copy_file(bootspec.initrd, xen_efi_initrd_path)
xen_efi_cfg += 'ramdisk=' + os.path.basename(xen_efi_initrd_path) + '\n'
with open(xen_efi_cfg_path, 'w') as xen_efi_cfg_file:
xen_efi_cfg_file.write(xen_efi_cfg)
return xen_efi_boot_path
def xen_config_entry(
levels: int, bootspec: BootSpec, xenVersion: str, gen: str, time: str, efi: bool
) -> str:
"""Generate EFI and BIOS entries for Xen dom0 kernels.
Arguments:
levels -- The number of Limine menu levels for entries
bootspec -- The NixOS BootSpec used for generating this Limine configuration
xenVersion -- The version of Xen the entry is generated for, from the boot extension
gen -- The system generation these entries are generated for
time -- The build time for the configuration
efi -- True if EFI protocol should be used for this entry
"""
# generate Xen menu label for the current generation
entry = '/' * levels + f'Generation {gen} with Xen {xenVersion}' + (' EFI\n' if efi else '\n')
entry += f'comment: Xen {xenVersion} {bootspec.label}, built on {time}\n'
# load Xen dom0 as the executable, using multiboot for EFI & BIOS
if (
efi and
'multibootPath' in bootspec.xen and
len(bootspec.xen['multibootPath']) > 0 and
os.path.exists(bootspec.xen['multibootPath'])
):
# Use the EFI protocol and generate Xen EFI configuration
# files and directories which are loaded by Xen's EFI binary
# directly.
# Ideally both EFI and BIOS booting would use multiboot2,
# however Limine's multiboot2 module has trouble finding
# an entry-point in Xen's multiboot binary, and multiboot1
# doesn't work under EFI.
# Upstream Limine issue #482
entry += 'protocol: efi\n'
entry += (
'path: ' + generate_xen_efi_files(bootspec, gen) + '\n'
)
elif (
'multibootPath' in bootspec.xen and
len(bootspec.xen['multibootPath']) > 0 and
os.path.exists(bootspec.xen['multibootPath'])
):
# Use multiboot1 if not generating an EFI entry, as multiboot2
# doesn't work under Limine for booting Xen.
# Upstream Limine issue #483
entry += 'protocol: multiboot\n'
entry += (
'path: ' + get_copied_path_uri(bootspec.xen['multibootPath'], f'xen/{gen}') + '\n'
)
# set params as the multiboot executable's parameters
if 'params' in bootspec.xen and len(bootspec.xen['params']) > 0:
# TODO: Understand why the first argument is ignored below?
# --- to work around first argument being ignored
entry += (
'cmdline: -- ' + ' '.join(bootspec.xen['params']).strip() + '\n'
)
# load the linux kernel as the second module
entry += 'module_path: ' + get_kernel_uri(bootspec.kernel) + '\n'
# set kernel parameters as the parameters to the first module
# TODO: Understand why the first argument is ignored below?
# --- to work around first argument being ignored
entry += (
'module_string: -- '
+ ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip()
+ '\n'
)
if bootspec.initrd:
# the final module is the initrd
entry += 'module_path: ' + get_kernel_uri(bootspec.initrd) + '\n'
return entry
def config_entry(levels: int, bootspec: BootSpec, label: str, time: str) -> str:
entry = '/' * levels + label + '\n'
entry += 'protocol: linux\n'
entry += f'comment: {bootspec.label}, built on {time}\n'
entry += 'kernel_path: ' + get_kernel_uri(bootspec.kernel) + '\n'
entry += 'cmdline: ' + ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip() + '\n'
if bootspec.initrd:
entry += f'module_path: ' + get_kernel_uri(bootspec.initrd) + '\n'
if bootspec.initrdSecrets:
base_path = str(limine_install_dir) + '/kernels/'
initrd_secrets_path = base_path + os.path.basename(bootspec.toplevel) + '-secrets'
if not os.path.exists(base_path):
os.makedirs(base_path)
old_umask = os.umask(0o137)
initrd_secrets_path_temp = tempfile.mktemp(os.path.basename(bootspec.toplevel) + '-secrets')
if os.system(bootspec.initrdSecrets + " " + initrd_secrets_path_temp) != 0:
print(f'warning: failed to create initrd secrets for "{label}"', file=sys.stderr)
print(f'note: if this is an older generation there is nothing to worry about')
if os.path.exists(initrd_secrets_path_temp):
copy_file(initrd_secrets_path_temp, initrd_secrets_path)
os.unlink(initrd_secrets_path_temp)
entry += 'module_path: ' + get_kernel_uri(initrd_secrets_path) + '\n'
os.umask(old_umask)
return entry
def generate_config_entry(profile: str, gen: str, special: bool) -> str:
time = datetime.datetime.fromtimestamp(os.stat(get_system_path(profile,gen), follow_symlinks=False).st_mtime).strftime("%F %H:%M:%S")
boot_json = json.load(open(os.path.join(get_system_path(profile, gen), 'boot.json'), 'r'))
boot_spec = bootjson_to_bootspec(boot_json)
specialisation_list = boot_spec.specialisations.items()
depth = 2
entry = ""
# Xen, if configured, should be listed first for each generation
if boot_spec.xen and 'version' in boot_spec.xen:
xen_version = boot_spec.xen['version']
if config('efiSupport'):
entry += xen_config_entry(2, boot_spec, xen_version, gen, time, True)
entry += xen_config_entry(2, boot_spec, xen_version, gen, time, False)
if len(specialisation_list) > 0:
depth += 1
entry += '/' * (depth-1)
if special:
entry += '+'
entry += f'Generation {gen}' + '\n'
entry += config_entry(depth, boot_spec, f'Default', str(time))
else:
entry += config_entry(depth, boot_spec, f'Generation {gen}', str(time))
for spec, spec_boot_spec in specialisation_list:
entry += config_entry(depth, spec_boot_spec, f'{spec}', str(time))
return entry
def find_disk_device(part: str) -> str:
part = os.path.realpath(part)
part = part.removeprefix('/dev/')
disk = os.path.realpath(os.path.join('/sys', 'class', 'block', part))
disk = os.path.dirname(disk)
return os.path.join('/dev', os.path.basename(disk))
def find_mounted_device(path: str) -> str:
path = os.path.abspath(path)
while not os.path.ismount(path):
path = os.path.dirname(path)
devices = [x for x in psutil.disk_partitions() if x.mountpoint == path]
assert len(devices) == 1
return devices[0].device
def copy_file(from_path: str, to_path: str):
dirname = os.path.dirname(to_path)
if not os.path.exists(dirname):
os.makedirs(dirname)
shutil.copyfile(from_path, to_path + ".tmp")
os.rename(to_path + ".tmp", to_path)
paths[to_path] = True
def option_from_config(name: str, config_path: List[str], conversion: Callable[[str], str] | None = None) -> str:
if config(*config_path):
return f'{name}: {conversion(config(*config_path)) if conversion else config(*config_path)}\n'
return ''
def install_bootloader() -> None:
global limine_install_dir
boot_fs = None
for mount_point, fs in config('fileSystems').items():
if mount_point == '/boot':
boot_fs = fs
if config('efiSupport'):
limine_install_dir = os.path.join(str(config('efiMountPoint')), 'limine')
elif boot_fs and is_fs_type_supported(boot_fs['fsType']) and not is_encrypted(boot_fs['device']):
limine_install_dir = '/boot/limine'
else:
possible_causes = []
if not boot_fs:
possible_causes.append(f'/limine on the boot partition (not present)')
else:
is_boot_fs_type_ok = is_fs_type_supported(boot_fs['fsType'])
is_boot_fs_encrypted = is_encrypted(boot_fs['device'])
possible_causes.append(f'/limine on the boot partition ({is_boot_fs_type_ok=} {is_boot_fs_encrypted=})')
causes_str = textwrap.indent('\n'.join(possible_causes), ' - ')
raise Exception(textwrap.dedent('''
Could not find a valid place for Limine configuration files!'
Possible candidates that were ruled out:
''') + causes_str + textwrap.dedent('''
Limine cannot be installed on a system without an unencrypted
partition formatted as FAT.
'''))
if config('secureBoot', 'enable') and not config('secureBoot', 'createAndEnrollKeys') and not os.path.exists("/var/lib/sbctl"):
print("There are no sbctl secure boot keys present. Please generate some.")
sys.exit(1)
if not os.path.exists(limine_install_dir):
os.makedirs(limine_install_dir)
else:
for dir, dirs, files in os.walk(limine_install_dir, topdown=True):
for file in files:
paths[os.path.join(dir, file)] = False
limine_xen_dir = os.path.join(limine_install_dir, 'xen')
if os.path.exists(limine_xen_dir):
print(f'cleaning {limine_xen_dir}')
shutil.rmtree(limine_xen_dir)
os.makedirs(limine_xen_dir)
profiles = [('system', get_gens())]
for profile in get_profiles():
profiles += [(profile, get_gens(profile))]
timeout = config('timeout')
editor_enabled = 'yes' if config('enableEditor') else 'no'
hash_mismatch_panic = 'yes' if config('panicOnChecksumMismatch') else 'no'
last_gen = get_gens()[-1]
last_gen_json = json.load(open(os.path.join(get_system_path('system', last_gen), 'boot.json'), 'r'))
last_gen_boot_spec = bootjson_to_bootspec(last_gen_json)
config_file = str(config('extraConfig')) + '\n'
config_file += textwrap.dedent(f'''
timeout: {timeout}
editor_enabled: {editor_enabled}
hash_mismatch_panic: {hash_mismatch_panic}
graphics: yes
default_entry: {3 if len(last_gen_boot_spec.specialisations.items()) > 0 else 2}
''')
for wallpaper in config('style', 'wallpapers'):
config_file += f'''wallpaper: {get_copied_path_uri(wallpaper, 'wallpapers')}\n'''
config_file += option_from_config('wallpaper_style', ['style', 'wallpaperStyle'])
config_file += option_from_config('backdrop', ['style', 'backdrop'])
config_file += option_from_config('interface_resolution', ['style', 'interface', 'resolution'])
config_file += option_from_config('interface_branding', ['style', 'interface', 'branding'])
config_file += option_from_config('interface_branding_colour', ['style', 'interface', 'brandingColor'])
config_file += option_from_config('interface_help_hidden', ['style', 'interface', 'helpHidden'])
config_file += option_from_config('term_font_scale', ['style', 'graphicalTerminal', 'font', 'scale'])
config_file += option_from_config('term_font_spacing', ['style', 'graphicalTerminal', 'font', 'spacing'])
config_file += option_from_config('term_palette', ['style', 'graphicalTerminal', 'palette'])
config_file += option_from_config('term_palette_bright', ['style', 'graphicalTerminal', 'brightPalette'])
config_file += option_from_config('term_foreground', ['style', 'graphicalTerminal', 'foreground'])
config_file += option_from_config('term_background', ['style', 'graphicalTerminal', 'background'])
config_file += option_from_config('term_foreground_bright', ['style', 'graphicalTerminal', 'brightForeground'])
config_file += option_from_config('term_background_bright', ['style', 'graphicalTerminal', 'brightBackground'])
config_file += option_from_config('term_margin', ['style', 'graphicalTerminal', 'margin'])
config_file += option_from_config('term_margin_gradient', ['style', 'graphicalTerminal', 'marginGradient'])
config_file += textwrap.dedent('''
# NixOS boot entries start here
''')
for (profile, gens) in profiles:
group_name = 'default profile' if profile == 'system' else f"profile '{profile}'"
config_file += f'/+NixOS {group_name}\n'
isFirst = True
for gen in sorted(gens, key=lambda x: x, reverse=True):
config_file += generate_config_entry(profile, gen, isFirst)
isFirst = False
config_file_path = os.path.join(limine_install_dir, 'limine.conf')
config_file += '\n# NixOS boot entries end here\n\n'
config_file += str(config('extraEntries'))
with open(f"{config_file_path}.tmp", 'w') as file:
file.truncate()
file.write(config_file.strip())
file.flush()
os.fsync(file.fileno())
os.rename(f"{config_file_path}.tmp", config_file_path)
paths[config_file_path] = True
for dest_path, source_path in config('additionalFiles').items():
dest_path = os.path.join(limine_install_dir, dest_path)
copy_file(source_path, dest_path)
limine_binary = os.path.join(str(config('liminePath')), 'bin', 'limine')
cpu_family = config('hostArchitecture', 'family')
if config('efiSupport'):
boot_file = ""
if cpu_family == 'x86':
if config('hostArchitecture', 'bits') == 32:
boot_file = 'BOOTIA32.EFI'
elif config('hostArchitecture', 'bits') == 64:
boot_file = 'BOOTX64.EFI'
elif cpu_family == 'arm':
if config('hostArchitecture', 'arch') == 'armv8-a' and config('hostArchitecture', 'bits') == 64:
boot_file = 'BOOTAA64.EFI'
else:
raise Exception(f'Unsupported CPU arch: {config("hostArchitecture", "arch")}')
else:
raise Exception(f'Unsupported CPU family: {cpu_family}')
efi_path = os.path.join(str(config('liminePath')), 'share', 'limine', boot_file)
dest_path = os.path.join(str(config('efiMountPoint')), 'efi', 'boot' if config('efiRemovable') else 'limine', boot_file)
copy_file(efi_path, dest_path)
if config('enrollConfig'):
b2sum = hashlib.blake2b()
b2sum.update(config_file.strip().encode())
try:
subprocess.run([limine_binary, 'enroll-config', dest_path, b2sum.hexdigest()])
except:
print('error: failed to enroll limine config.', file=sys.stderr)
sys.exit(1)
if config('secureBoot', 'enable'):
sbctl = os.path.join(str(config('secureBoot', 'sbctl')), 'bin', 'sbctl')
if config('secureBoot', 'createAndEnrollKeys'):
print("TEST MODE: creating and enrolling keys")
try:
subprocess.run([sbctl, 'create-keys'])
except:
print('error: failed to create keys', file=sys.stderr)
sys.exit(1)
try:
subprocess.run([sbctl, 'enroll-keys', '--yes-this-might-brick-my-machine'])
except:
print('error: failed to enroll keys', file=sys.stderr)
sys.exit(1)
print('signing limine...')
try:
subprocess.run([sbctl, 'sign', dest_path])
except:
print('error: failed to sign limine', file=sys.stderr)
sys.exit(1)
if not config('efiRemovable') and not config('canTouchEfiVariables'):
print('warning: boot.loader.efi.canTouchEfiVariables is set to false while boot.loader.limine.efiInstallAsRemovable.\n This may render the system unbootable.')
if config('canTouchEfiVariables'):
if config('efiRemovable'):
print('note: boot.loader.limine.efiInstallAsRemovable is true, no need to add EFI entry.')
else:
efibootmgr = os.path.join(str(config('efiBootMgrPath')), 'bin', 'efibootmgr')
efi_partition = find_mounted_device(str(config('efiMountPoint')))
efi_disk = find_disk_device(efi_partition)
efibootmgr_output = subprocess.check_output([efibootmgr], stderr=subprocess.STDOUT, universal_newlines=True)
# Check the output of `efibootmgr` to find if limine is already installed and present in the boot record
limine_boot_entry = None
if matches := re.findall(r'Boot([0-9a-fA-F]{4})\*? Limine', efibootmgr_output):
limine_boot_entry = matches[0]
# If there's already a Limine entry, replace it
if limine_boot_entry:
boot_order = re.findall(r'BootOrder: ((?:[0-9a-fA-F]{4},?)*)', efibootmgr_output)[0]
efibootmgr_output = subprocess.check_output([
efibootmgr,
'-b', limine_boot_entry,
'-B',
], stderr=subprocess.STDOUT, universal_newlines=True)
efibootmgr_output = subprocess.check_output([
efibootmgr,
'-c',
'-b', limine_boot_entry,
'-d', efi_disk,
'-p', efi_partition.removeprefix(efi_disk).removeprefix('p'),
'-l', f'\\efi\\limine\\{boot_file}',
'-L', 'Limine',
'-o', boot_order,
], stderr=subprocess.STDOUT, universal_newlines=True)
else:
efibootmgr_output = subprocess.check_output([
efibootmgr,
'-c',
'-d', efi_disk,
'-p', efi_partition.removeprefix(efi_disk).removeprefix('p'),
'-l', f'\\efi\\limine\\{boot_file}',
'-L', 'Limine',
], stderr=subprocess.STDOUT, universal_newlines=True)
if config('biosSupport'):
if cpu_family != 'x86':
raise Exception(f'Unsupported CPU family for BIOS install: {cpu_family}')
limine_sys = os.path.join(str(config('liminePath')), 'share', 'limine', 'limine-bios.sys')
limine_sys_dest = os.path.join(limine_install_dir, 'limine-bios.sys')
copy_file(limine_sys, limine_sys_dest)
device = str(config('biosDevice'))
if device == 'nodev':
print("note: boot.loader.limine.biosSupport is set, but device is set to nodev, only the stage 2 bootloader will be installed.", file=sys.stderr)
return
limine_deploy_args: List[str] = [limine_binary, 'bios-install', device]
if config('partitionIndex'):
limine_deploy_args.append(str(config('partitionIndex')))
if config('force'):
limine_deploy_args.append('--force')
try:
subprocess.run(limine_deploy_args)
except:
raise Exception(
'Failed to deploy BIOS stage 1 Limine bootloader!\n' +
'You might want to try enabling the `boot.loader.limine.force` option.')
print("removing unused boot files...")
for path in paths:
if not paths[path] and os.path.exists(path):
os.remove(path)
def main() -> None:
try:
install_bootloader()
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"{str(config('efiMountPoint'))}", os.O_RDONLY))
if rc != 0:
print(f"could not sync {str(config('efiMountPoint'))}: {os.strerror(rc)}", file=sys.stderr)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,467 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.boot.loader.limine;
efi = config.boot.loader.efi;
limineInstallConfig = pkgs.writeText "limine-install.json" (
builtins.toJSON {
nixPath = config.nix.package;
efiBootMgrPath = pkgs.efibootmgr;
liminePath = cfg.package;
efiMountPoint = efi.efiSysMountPoint;
fileSystems = config.fileSystems;
luksDevices = builtins.attrNames config.boot.initrd.luks.devices;
canTouchEfiVariables = efi.canTouchEfiVariables;
efiSupport = cfg.efiSupport;
efiRemovable = cfg.efiInstallAsRemovable;
secureBoot = cfg.secureBoot;
biosSupport = cfg.biosSupport;
biosDevice = cfg.biosDevice;
partitionIndex = cfg.partitionIndex;
force = cfg.force;
enrollConfig = cfg.enrollConfig;
style = cfg.style;
maxGenerations = if cfg.maxGenerations == null then 0 else cfg.maxGenerations;
hostArchitecture = pkgs.stdenv.hostPlatform.parsed.cpu;
timeout = if config.boot.loader.timeout != null then config.boot.loader.timeout else 10;
enableEditor = cfg.enableEditor;
extraConfig = cfg.extraConfig;
extraEntries = cfg.extraEntries;
additionalFiles = cfg.additionalFiles;
validateChecksums = cfg.validateChecksums;
panicOnChecksumMismatch = cfg.panicOnChecksumMismatch;
}
);
defaultWallpaper = pkgs.nixos-artwork.wallpapers.simple-dark-gray-bootloader.gnomeFilePath;
in
{
meta = {
inherit (pkgs.limine.meta) maintainers;
};
imports = [
(lib.mkRenamedOptionModule
[ "boot" "loader" "limine" "forceMbr" ]
[ "boot" "loader" "limine" "force" ]
)
];
options.boot.loader.limine = {
enable = lib.mkEnableOption "the Limine Bootloader";
package = lib.mkPackageOption pkgs "limine" { };
enableEditor = lib.mkEnableOption null // {
description = ''
Whether to allow editing the boot entries before booting them.
It is recommended to set this to false, as it allows gaining root
access by passing `init=/bin/sh` as a kernel parameter.
'';
};
maxGenerations = lib.mkOption {
default = null;
example = 50;
type = lib.types.nullOr lib.types.int;
description = ''
Maximum number of latest generations in the boot menu.
Useful to prevent boot partition of running out of disk space.
`null` means no limit i.e. all generations that were not
garbage collected yet.
'';
};
extraConfig = lib.mkOption {
default = "";
type = lib.types.lines;
example = lib.literalExpression ''
serial: yes
'';
description = ''
A string which is prepended to limine.conf. The config format can be found [here](https://github.com/limine-bootloader/limine/blob/trunk/CONFIG.md).
'';
};
extraEntries = lib.mkOption {
default = "";
type = lib.types.lines;
example = lib.literalExpression ''
/memtest86
protocol: chainload
path: boot():///efi/memtest86/memtest86.efi
'';
description = ''
A string which is appended to the end of limine.conf. The config format can be found [here](https://github.com/limine-bootloader/limine/blob/trunk/CONFIG.md).
'';
};
additionalFiles = lib.mkOption {
default = { };
type = lib.types.attrsOf lib.types.path;
example = lib.literalExpression ''
{ "efi/memtest86/memtest86.efi" = "''${pkgs.memtest86-efi}/BOOTX64.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.
'';
};
validateChecksums = lib.mkEnableOption null // {
default = true;
description = ''
Whether to validate file checksums before booting.
'';
};
panicOnChecksumMismatch = lib.mkEnableOption null // {
description = ''
Whether or not checksum validation failure should be a fatal
error at boot time.
'';
};
efiSupport = lib.mkEnableOption null // {
default = pkgs.stdenv.hostPlatform.isEfi;
defaultText = lib.literalExpression "pkgs.stdenv.hostPlatform.isEfi";
description = ''
Whether or not to install the limine EFI files.
'';
};
efiInstallAsRemovable = lib.mkEnableOption null // {
default = !efi.canTouchEfiVariables;
defaultText = lib.literalExpression "!config.boot.loader.efi.canTouchEfiVariables";
description = ''
Whether or not to install the limine EFI files as removable.
See {option}`boot.loader.grub.efiInstallAsRemovable`
'';
};
biosSupport = lib.mkEnableOption null // {
default = !cfg.efiSupport && pkgs.stdenv.hostPlatform.isx86;
defaultText = lib.literalExpression "!config.boot.loader.limine.efiSupport && pkgs.stdenv.hostPlatform.isx86";
description = ''
Whether or not to install limine for BIOS.
'';
};
biosDevice = lib.mkOption {
default = "nodev";
type = lib.types.str;
description = ''
Device to install the BIOS version of limine on.
'';
};
partitionIndex = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.int;
description = ''
The 1-based index of the dedicated partition for limine's second stage.
'';
};
enrollConfig = lib.mkEnableOption null // {
default = cfg.panicOnChecksumMismatch;
defaultText = lib.literalExpression "boot.loader.limine.panicOnChecksumMismatch";
description = ''
Whether or not to enroll the config.
Only works on EFI!
'';
};
force = lib.mkEnableOption null // {
description = ''
Force installation even if the safety checks fail, use absolutely only if necessary!
'';
};
secureBoot = {
enable = lib.mkEnableOption null // {
description = ''
Whether to use sign the limine binary with sbctl.
::: {.note}
This requires you to already have generated the keys and enrolled them with {command}`sbctl`.
To create keys use {command}`sbctl create-keys`.
To enroll them first reset secure boot to "Setup Mode". This is device specific.
Then enroll them using {command}`sbctl enroll-keys -m -f`.
You can now rebuild your system with this option enabled.
Afterwards turn setup mode off and enable secure boot.
:::
'';
};
createAndEnrollKeys = lib.mkEnableOption null // {
internal = true;
description = ''
Creates secure boot signing keys and enrolls them during bootloader installation.
::: {.note}
This is used for automated nixos tests.
NOT INTENDED to be used on a real system.
:::
'';
};
sbctl = lib.mkPackageOption pkgs "sbctl" { };
};
style = {
wallpapers = lib.mkOption {
default = [ ];
example = lib.literalExpression "[ pkgs.nixos-artwork.wallpapers.simple-dark-gray-bootloader.gnomeFilePath ]";
type = lib.types.listOf lib.types.path;
description = ''
A list of wallpapers.
If more than one is specified, a random one will be selected at boot.
'';
};
wallpaperStyle = lib.mkOption {
default = "streched";
type = lib.types.enum [
"centered"
"streched"
"tiled"
];
description = ''
How the wallpaper should be fit to the screen.
'';
};
backdrop = lib.mkOption {
default = null;
example = "7EBAE4";
type = lib.types.nullOr lib.types.str;
description = ''
Color to fill the rest of the screen with when wallpaper_style is centered in RRGGBB format.
'';
};
interface = {
resolution = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
The resolution of the interface.
'';
};
branding = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
The title at the top of the screen.
'';
};
brandingColor = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.int;
description = ''
Color index of the title at the top of the screen in the range of 0-7 (Limine defaults to 6 (cyan)).
'';
};
helpHidden = lib.mkEnableOption null // {
description = ''
Whether or not to hide the keybinds at the top of the screen.
'';
};
};
graphicalTerminal = {
font = {
scale = lib.mkOption {
default = null;
example = lib.literalExpression "2x2";
type = lib.types.nullOr lib.types.str;
description = ''
The scale of the font in the format <width>x<height>.
'';
};
spacing = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.int;
description = ''
The horizontal spacing between characters in pixels.
'';
};
};
palette = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
A ; seperated array of 8 colors in the format RRGGBB:
black, red, green, brown, blue, magenta, cyan, and gray.
'';
};
brightPalette = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
A ; seperated array of 8 colors in the format RRGGBB:
dark gray, bright red, bright green, yellow, bright blue, bright magenta, bright cyan, and white.
'';
};
foreground = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
Text foreground color (RRGGBB).
'';
};
background = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
Text background color (TTRRGGBB). TT is transparency.
'';
};
brightForeground = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
Text foreground bright color (RRGGBB).
'';
};
brightBackground = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.str;
description = ''
Text background bright color (RRGGBB).
'';
};
margin = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.int;
description = ''
The amount of margin around the terminal.
'';
};
marginGradient = lib.mkOption {
default = null;
type = lib.types.nullOr lib.types.int;
description = ''
The thickness in pixels for the margin around the terminal.
'';
};
};
};
};
config = lib.mkMerge [
{
boot.loader.limine.style.wallpapers = lib.mkDefault [ defaultWallpaper ];
}
(lib.mkIf (cfg.style.wallpapers == [ defaultWallpaper ]) {
boot.loader.limine.style.backdrop = lib.mkDefault "2F302F";
boot.loader.limine.style.wallpaperStyle = lib.mkDefault "streched";
})
(lib.mkIf cfg.enable {
assertions = [
{
assertion =
pkgs.stdenv.hostPlatform.isx86_64
|| pkgs.stdenv.hostPlatform.isi686
|| pkgs.stdenv.hostPlatform.isAarch64;
message = "Limine can only be installed on aarch64 & x86 platforms";
}
{
assertion = cfg.efiSupport || cfg.biosSupport;
message = "Both UEFI support and BIOS support for Limine are disabled, this will result in an unbootable system";
}
];
boot.loader.grub.enable = lib.mkDefault false;
boot.loader.supportsInitrdSecrets = true;
system = {
boot.loader.id = "limine";
build.installBootLoader = pkgs.replaceVarsWith {
src = ./limine-install.py;
isExecutable = true;
replacements = {
python3 = pkgs.python3.withPackages (python-packages: [ python-packages.psutil ]);
configPath = limineInstallConfig;
};
};
};
})
(lib.mkIf (cfg.enable && cfg.secureBoot.enable) {
assertions = [
{
assertion = cfg.enrollConfig;
message = "Disabling enrollConfig allows bypassing secure boot.";
}
{
assertion = cfg.validateChecksums;
message = "Disabling validateChecksums allows bypassing secure boot.";
}
{
assertion = cfg.panicOnChecksumMismatch;
message = "Disabling panicOnChecksumMismatch allows bypassing secure boot.";
}
{
assertion = cfg.efiSupport;
message = "Secure boot is only supported on EFI systems.";
}
];
boot.loader.limine.enrollConfig = true;
boot.loader.limine.validateChecksums = true;
boot.loader.limine.panicOnChecksumMismatch = true;
})
# Fwupd binary needs to be signed in secure boot mode
(lib.mkIf (cfg.enable && cfg.secureBoot.enable && config.services.fwupd.enable) {
systemd.services.fwupd = {
environment.FWUPD_EFIAPPDIR = "/run/fwupd-efi";
};
systemd.services.fwupd-efi = {
description = "Sign fwupd EFI app for secure boot";
wantedBy = [ "fwupd.service" ];
partOf = [ "fwupd.service" ];
before = [ "fwupd.service" ];
unitConfig.ConditionPathIsDirectory = "/var/lib/sbctl";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
RuntimeDirectory = "fwupd-efi";
};
script = ''
cp ${config.services.fwupd.package.fwupd-efi}/libexec/fwupd/efi/fwupd*.efi /run/fwupd-efi/
chmod +w /run/fwupd-efi/fwupd*.efi
${lib.getExe cfg.secureBoot.sbctl} sign /run/fwupd-efi/fwupd*.efi
'';
};
services.fwupd.uefiCapsuleSettings = {
DisableShimForSecureBoot = true;
};
})
];
}

View File

@@ -0,0 +1,20 @@
{ lib, ... }:
with lib;
{
imports = [
(mkRenamedOptionModule [ "boot" "loader" "grub" "timeout" ] [ "boot" "loader" "timeout" ])
(mkRenamedOptionModule [ "boot" "loader" "gummiboot" "timeout" ] [ "boot" "loader" "timeout" ])
];
options = {
boot.loader.timeout = mkOption {
default = 5;
type = types.nullOr types.int;
description = ''
Timeout (in seconds) until loader boots the default menu item. Use null if the loader menu should be displayed indefinitely.
'';
};
};
}

View File

@@ -0,0 +1,344 @@
#!@python3@/bin/python3 -B
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
import datetime
import json
from ctypes import CDLL
import os
import psutil
import re
import shutil
import subprocess
import textwrap
refind_dir = None
libc = CDLL("libc.so.6")
install_config = json.load(open('@configPath@', 'r'))
def config(*path: str) -> Optional[Any]:
result = install_config
for component in path:
result = result[component]
return result
def get_system_path(profile: str = 'system', gen: Optional[str] = None, spec: Optional[str] = None) -> str:
basename = f'{profile}-{gen}-link' if gen is not None else profile
profiles_dir = '/nix/var/nix/profiles'
if profile == 'system':
result = os.path.join(profiles_dir, basename)
else:
result = os.path.join(profiles_dir, 'system-profiles', basename)
if spec is not None:
result = os.path.join(result, 'specialisation', spec)
return result
def get_profiles() -> List[str]:
profiles_dir = '/nix/var/nix/profiles/system-profiles/'
dirs = os.listdir(profiles_dir) if os.path.isdir(profiles_dir) else []
return [path for path in dirs if not path.endswith('-link')]
def get_gens(profile: str = 'system') -> List[Tuple[int, List[str]]]:
nix_env = os.path.join(config('nixPath'), 'bin', 'nix-env')
output = subprocess.check_output([
nix_env, '--list-generations',
'-p', get_system_path(profile),
'--option', 'build-users-group', '',
], universal_newlines=True)
gen_lines = output.splitlines()
gen_nums = [int(line.split()[0]) for line in gen_lines]
return [gen for gen in gen_nums][-config('maxGenerations'):]
def is_encrypted(device: str) -> bool:
for name, _ in config('luksDevices'):
if os.readlink(os.path.join('/dev/mapper', name)) == os.readlink(device):
return True
return False
def is_fs_type_supported(fs_type: str) -> bool:
return fs_type.startswith('vfat')
paths = {}
def get_copied_path_uri(path: str, target: str) -> str:
package_id = os.path.basename(os.path.dirname(path))
suffix = os.path.basename(path)
dest_file = f'{package_id}-{suffix}'
dest_path = os.path.join(refind_dir, target, dest_file)
if not os.path.exists(dest_path):
copy_file(path, dest_path)
else:
paths[dest_path] = True
return os.path.join('/efi/refind', target, dest_file)
def get_path_uri(path: str) -> str:
return get_copied_path_uri(path, "")
def get_file_uri(profile: str, gen: Optional[str], spec: Optional[str], name: str) -> str:
gen_path = get_system_path(profile, gen, spec)
path_in_store = os.path.realpath(os.path.join(gen_path, name))
return get_path_uri(path_in_store)
def get_kernel_uri(kernel_path: str) -> str:
return get_copied_path_uri(kernel_path, "kernels")
@dataclass
class BootSpec:
system: str
init: str
kernel: str
kernelParams: List[str]
label: str
toplevel: str
specialisations: Dict[str, "BootSpec"]
initrd: str | None = None
initrdSecrets: str | None = None
def bootjson_to_bootspec(bootjson: dict) -> BootSpec:
specialisations = bootjson['org.nixos.specialisation.v1']
specialisations = {k: bootjson_to_bootspec(v) for k, v in specialisations.items()}
return BootSpec(
**bootjson['org.nixos.bootspec.v1'],
specialisations=specialisations,
)
def config_entry(is_sub: bool, bootspec: BootSpec, label: str, time: str) -> str:
entry = ""
if is_sub:
entry += 'sub'
entry += f'menuentry "{label}" {{\n'
entry += ' loader ' + get_kernel_uri(bootspec.kernel) + '\n'
if bootspec.initrd:
entry += ' initrd ' + get_kernel_uri(bootspec.initrd) + '\n'
entry += ' options "' + ' '.join(['init=' + bootspec.init] + bootspec.kernelParams).strip() + '"\n'
entry += '}\n'
return entry
def generate_config_entry(profile: str, gen: str, special: bool, group_name: str) -> str:
time = datetime.datetime.fromtimestamp(os.stat(get_system_path(profile,gen), follow_symlinks=False).st_mtime).strftime("%F %H:%M:%S")
boot_json = json.load(open(os.path.join(get_system_path(profile, gen), 'boot.json'), 'r'))
boot_spec = bootjson_to_bootspec(boot_json)
specialisation_list = boot_spec.specialisations.items()
entry = ""
if len(specialisation_list) > 0:
entry += f'menuentry "NixOS {group_name} Generation {gen}" {{\n'
entry += config_entry(True, boot_spec, f'Default', str(time))
for spec, spec_boot_spec in specialisation_list:
entry += config_entry(True, spec_boot_spec, f'{spec}', str(time))
entry += '}\n'
else:
entry += config_entry(False, boot_spec, f'NixOS {group_name} Generation {gen}', str(time))
return entry
def find_disk_device(part: str) -> str:
part = os.path.realpath(part)
part = part.removeprefix('/dev/')
disk = os.path.realpath(os.path.join('/sys', 'class', 'block', part))
disk = os.path.dirname(disk)
return os.path.join('/dev', os.path.basename(disk))
def find_mounted_device(path: str) -> str:
path = os.path.abspath(path)
while not os.path.ismount(path):
path = os.path.dirname(path)
devices = [x for x in psutil.disk_partitions() if x.mountpoint == path]
assert len(devices) == 1
return devices[0].device
def copy_file(from_path: str, to_path: str):
dirname = os.path.dirname(to_path)
if not os.path.exists(dirname):
os.makedirs(dirname)
shutil.copyfile(from_path, to_path + ".tmp")
os.rename(to_path + ".tmp", to_path)
paths[to_path] = True
def install_bootloader() -> None:
global refind_dir
refind_dir = os.path.join(str(config('efiMountPoint')), 'efi', 'refind')
if not os.path.exists(refind_dir):
os.makedirs(refind_dir)
else:
for dir, dirs, files in os.walk(refind_dir, topdown=True):
for file in files:
paths[os.path.join(dir, file)] = False
profiles = [('system', get_gens())]
for profile in get_profiles():
profiles += [(profile, get_gens(profile))]
timeout = config('timeout')
last_gen = get_gens()[-1]
last_gen_json = json.load(open(os.path.join(get_system_path('system', last_gen), 'boot.json'), 'r'))
last_gen_boot_spec = bootjson_to_bootspec(last_gen_json)
config_file = str(config('extraConfig')) + '\n'
config_file += textwrap.dedent(f'''
timeout {timeout}
default_selection {3 if len(last_gen_boot_spec.specialisations.items()) > 0 else 2}
''')
config_file += textwrap.dedent('''
# NixOS boot entries start here
''')
for (profile, gens) in profiles:
group_name = 'default profile' if profile == 'system' else f"profile '{profile}'"
isFirst = True
for gen in sorted(gens, key=lambda x: x, reverse=True):
config_file += generate_config_entry(profile, gen, isFirst, group_name)
isFirst = False
config_file_path = os.path.join(refind_dir, 'refind.conf')
config_file += '\n# NixOS boot entries end here\n\n'
with open(f"{config_file_path}.tmp", 'w') as file:
file.truncate()
file.write(config_file.strip())
file.flush()
os.fsync(file.fileno())
os.rename(f"{config_file_path}.tmp", config_file_path)
paths[config_file_path] = True
for dest_path, source_path in config('additionalFiles').items():
dest_path = os.path.join(refind_dir, dest_path)
copy_file(source_path, dest_path)
cpu_family = config('hostArchitecture', 'family')
if cpu_family == 'x86':
if config('hostArchitecture', 'bits') == 32:
boot_file = 'BOOTIA32.EFI'
efi_file = 'refind_ia32.efi'
elif config('hostArchitecture', 'bits') == 64:
boot_file = 'BOOTX64.EFI'
efi_file = 'refind_x64.efi'
elif cpu_family == 'arm':
if config('hostArchitecture', 'arch') == 'armv8-a' and config('hostArchitecture', 'bits') == 64:
boot_file = 'BOOTAA64.EFI'
efi_file = 'refind_aa64.efi'
else:
raise Exception(f'Unsupported CPU arch: {config("hostArchitecture", "arch")}')
else:
raise Exception(f'Unsupported CPU family: {cpu_family}')
efi_path = os.path.join(config('refindPath'), 'share', 'refind', efi_file)
dest_path = os.path.join(config('efiMountPoint'), 'efi', 'boot' if config('efiRemovable') else 'refind', boot_file)
copy_file(efi_path, dest_path)
if not config('efiRemovable') and not config('canTouchEfiVariables'):
print('warning: boot.loader.efi.canTouchEfiVariables is set to false while boot.loader.limine.efiInstallAsRemovable.\n This may render the system unbootable.')
if config('canTouchEfiVariables'):
if config('efiRemovable'):
print('note: boot.loader.limine.efiInstallAsRemovable is true, no need to add EFI entry.')
else:
efibootmgr = os.path.join(str(config('efiBootMgrPath')), 'bin', 'efibootmgr')
efi_partition = find_mounted_device(str(config('efiMountPoint')))
efi_disk = find_disk_device(efi_partition)
efibootmgr_output = subprocess.check_output([efibootmgr], stderr=subprocess.STDOUT, universal_newlines=True)
# Check the output of `efibootmgr` to find if rEFInd is already installed and present in the boot record
refind_boot_entry = None
if matches := re.findall(r'Boot([0-9a-fA-F]{4})\*? rEFInd', efibootmgr_output):
refind_boot_entry = matches[0]
# If there's already a Limine entry, replace it
if refind_boot_entry:
boot_order = re.findall(r'BootOrder: ((?:[0-9a-fA-F]{4},?)*)', efibootmgr_output)[0]
efibootmgr_output = subprocess.check_output([
efibootmgr,
'-b', refind_boot_entry,
'-B',
], stderr=subprocess.STDOUT, universal_newlines=True)
efibootmgr_output = subprocess.check_output([
efibootmgr,
'-c',
'-b', refind_boot_entry,
'-d', efi_disk,
'-p', efi_partition.removeprefix(efi_disk).removeprefix('p'),
'-l', f'\\efi\\refind\\{boot_file}',
'-L', 'rEFInd',
'-o', boot_order,
], stderr=subprocess.STDOUT, universal_newlines=True)
else:
efibootmgr_output = subprocess.check_output([
efibootmgr,
'-c',
'-d', efi_disk,
'-p', efi_partition.removeprefix(efi_disk).removeprefix('p'),
'-l', f'\\efi\\refind\\{boot_file}',
'-L', 'rEFInd',
], stderr=subprocess.STDOUT, universal_newlines=True)
print("removing unused boot files...")
for path in paths:
if not paths[path]:
os.remove(path)
def main() -> None:
try:
install_bootloader()
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"{config('efiMountPoint')}", os.O_RDONLY))
if rc != 0:
print(f"could not sync {config('efiMountPoint')}: {os.strerror(rc)}", file=sys.stderr)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,117 @@
{
config,
options,
lib,
pkgs,
...
}:
let
inherit (lib)
mkIf
mkEnableOption
mkOption
literalExpression
types
;
cfg = config.boot.loader.refind;
efi = config.boot.loader.efi;
refindInstallConfig = pkgs.writeText "refind-install.json" (
builtins.toJSON {
nixPath = config.nix.package;
efiBootMgrPath = pkgs.efibootmgr;
refindPath = cfg.package;
efiMountPoint = efi.efiSysMountPoint;
fileSystems = config.fileSystems;
luksDevices = config.boot.initrd.luks.devices;
canTouchEfiVariables = efi.canTouchEfiVariables;
efiRemovable = cfg.efiInstallAsRemovable;
maxGenerations = if cfg.maxGenerations == null then 0 else cfg.maxGenerations;
hostArchitecture = pkgs.stdenv.hostPlatform.parsed.cpu;
timeout = if config.boot.loader.timeout != null then config.boot.loader.timeout else 10;
extraConfig = cfg.extraConfig;
additionalFiles = cfg.additionalFiles;
}
);
in
{
meta = {
inherit (pkgs.refind.meta) maintainers;
};
options = {
boot.loader.refind = {
enable = mkEnableOption "the rEFInd boot loader";
extraConfig = lib.mkOption {
default = "";
type = types.lines;
description = ''
A string which is prepended to refind.conf.
'';
};
package = lib.mkPackageOption pkgs "refind" { };
maxGenerations = lib.mkOption {
default = null;
example = 50;
type = types.nullOr types.int;
description = ''
Maximum number of latest generations in the boot menu.
Useful to prevent boot partition of running out of disk space.
`null` means no limit i.e. all generations that were not
garbage collected yet.
'';
};
additionalFiles = mkOption {
default = { };
type = types.attrsOf types.path;
example = literalExpression ''
{ "efi/memtest86/memtest86.efi" = "''${pkgs.memtest86-efi}/BOOTX64.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.
'';
};
efiInstallAsRemovable = mkEnableOption null // {
default = !efi.canTouchEfiVariables;
defaultText = literalExpression "!config.boot.loader.efi.canTouchEfiVariables";
description = ''
Whether or not to install the rEFInd EFI files as removable.
See {option}`boot.loader.grub.efiInstallAsRemovable`
'';
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion =
pkgs.stdenv.hostPlatform.isx86_64
|| pkgs.stdenv.hostPlatform.isi686
|| pkgs.stdenv.hostPlatform.isAarch64;
message = "rEFInd can only be installed on aarch64 & x86 platforms";
}
{
assertion = pkgs.stdenv.hostPlatform.isEfi;
message = "rEFInd can only be installed on UEFI platforms";
}
];
# Common attribute for boot loaders so only one of them can be
# set at once.
system = {
boot.loader.id = "refind";
build.installBootLoader = pkgs.replaceVarsWith {
src = ./refind-install.py;
isExecutable = true;
replacements = {
python3 = pkgs.python3.withPackages (python-packages: [ python-packages.psutil ]);
configPath = refindInstallConfig;
};
};
};
};
}

View File

@@ -0,0 +1,452 @@
#! @python3@/bin/python3 -B
import argparse
import ctypes
import datetime
import errno
import os
import re
import shutil
import subprocess
import sys
import tempfile
import warnings
import json
from typing import NamedTuple, Any, Sequence
from dataclasses import dataclass
from pathlib import Path
# These values will be replaced with actual values during the package build
EFI_SYS_MOUNT_POINT = Path("@efiSysMountPoint@")
BOOT_MOUNT_POINT = Path("@bootMountPoint@")
LOADER_CONF = EFI_SYS_MOUNT_POINT / "loader/loader.conf" # Always stored on the ESP
NIXOS_DIR = Path("@nixosDir@".strip("/")) # Path relative to the XBOOTLDR or ESP mount point
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@"
STORE_DIR = "@storeDir@"
@dataclass
class BootSpec:
init: Path
initrd: Path
kernel: Path
kernelParams: list[str] # noqa: N815
label: str
system: str
toplevel: Path
specialisations: dict[str, "BootSpec"]
sortKey: str # noqa: N815
devicetree: Path | None = None # noqa: N815
initrdSecrets: str | None = None # noqa: N815
libc = ctypes.CDLL("libc.so.6")
FILE = None | int
def run(cmd: Sequence[str | Path], 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: Path, dest: Path) -> None:
if not dest.exists():
tmpfd, tmppath = tempfile.mkstemp(dir=dest.parent, prefix=dest.name, suffix='.tmp.')
shutil.copyfile(source, tmppath)
os.fsync(tmpfd)
shutil.move(tmppath, dest)
def generation_dir(profile: str | None, generation: int) -> Path:
if profile:
return Path(f"/nix/var/nix/profiles/system-profiles/{profile}-{generation}-link")
else:
return Path(f"/nix/var/nix/profiles/system-{generation}-link")
def system_dir(profile: str | None, generation: int, specialisation: str | None) -> Path:
d = generation_dir(profile, generation)
if specialisation:
return d / "specialisation" / specialisation
else:
return d
BOOT_ENTRY = """title {title}
sort-key {sort_key}
version Generation {generation} {description}
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:
tmp = LOADER_CONF.with_suffix(".tmp")
with tmp.open('x') 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(tmp, LOADER_CONF)
def get_bootspec(profile: str | None, generation: int) -> BootSpec:
system_directory = system_dir(profile, generation, None)
boot_json_path = (system_directory / "boot.json").resolve()
if boot_json_path.is_file():
with boot_json_path.open("r") as f:
# check if json is well-formed, else throw error with filepath
try:
bootspec_json = json.load(f)
except ValueError as e:
print(f"error: Malformed Json: {e}, in {boot_json_path}", file=sys.stderr)
sys.exit(1)
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')
if devicetree:
devicetree = Path(devicetree)
main_json = bootspec_json['org.nixos.bootspec.v1']
for attr in ("kernel", "initrd", "toplevel"):
if attr in main_json:
main_json[attr] = Path(main_json[attr])
return BootSpec(
**main_json,
specialisations=specialisations,
sortKey=sortKey,
devicetree=devicetree,
)
def copy_from_file(file: Path, dry_run: bool = False) -> Path:
"""
Copy a file to the boot filesystem (XBOOTLDR if in use, otherwise ESP), basing the destination filename on the store path that's being copied from. Return the destination path, relative to the boot filesystem mountpoint.
"""
store_file_path = file.resolve()
suffix = store_file_path.name
store_subdir = store_file_path.relative_to(STORE_DIR).parts[0]
efi_file_path = NIXOS_DIR / (f"{suffix}.efi" if suffix == store_subdir else f"{store_subdir}-{suffix}.efi")
if not dry_run:
copy_if_not_exists(store_file_path, BOOT_MOUNT_POINT / efi_file_path)
return efi_file_path
def write_entry(profile: str | None, generation: int, specialisation: str | None,
machine_id: str | None, 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, BOOT_MOUNT_POINT / 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 = BOOT_MOUNT_POINT / "loader/entries" / generation_conf_filename(profile, generation, specialisation)
tmp_path = entry_file.with_suffix(".tmp")
kernel_params = "init=%s " % bootspec.init
kernel_params = kernel_params + " ".join(bootspec.kernelParams)
build_time = int(system_dir(profile, generation, specialisation).stat().st_ctime)
build_date = datetime.datetime.fromtimestamp(build_time).strftime('%F')
with tmp_path.open("w") as f:
f.write(BOOT_ENTRY.format(title=title,
sort_key=bootspec.sortKey,
generation=generation,
kernel=f"/{kernel}",
initrd=f"/{initrd}",
kernel_params=kernel_params,
description=f"{bootspec.label}, built on {build_date}"))
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())
tmp_path.rename(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"^nixos-(.*)-generation-.*\.conf$")
rex_generation = re.compile(r"^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).name)
known_paths.append(copy_from_file(bootspec.initrd, True).name)
if bootspec.devicetree is not None:
known_paths.append(copy_from_file(bootspec.devicetree, True).name)
for path in (BOOT_MOUNT_POINT / "loader/entries").glob("nixos*-generation-[1-9]*.conf", case_sensitive=False):
if rex_profile.match(path.name):
prof = rex_profile.sub(r"\1", path.name)
else:
prof = None
try:
gen_number = int(rex_generation.sub(r"\1", path.name))
except ValueError:
continue
if (prof, gen_number, None) not in gens:
path.unlink()
for path in (BOOT_MOUNT_POINT / NIXOS_DIR).iterdir():
if path.name not in known_paths and not path.is_dir():
path.unlink()
def cleanup_esp() -> None:
for path in (EFI_SYS_MOUNT_POINT / "loader/entries").glob("nixos*"):
path.unlink()
nixos_dir = EFI_SYS_MOUNT_POINT / NIXOS_DIR
if nixos_dir.is_dir():
shutil.rmtree(nixos_dir)
def get_profiles() -> list[str]:
system_profiles = Path("/nix/var/nix/profiles/system-profiles/")
if system_profiles.is_dir():
return [x.name
for x in system_profiles.iterdir()
if not x.name.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].strip()
except IOError as e:
if e.errno != errno.ENOENT:
raise
machine_id = None
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.
LOADER_CONF.unlink(missing_ok=True)
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 you are switching to systemd-boot from a different bootloader, you need to run `nixos-rebuild switch --install-bootloader`")
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), file=sys.stderr)
run(
[f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"]
+ bootctl_flags
+ ["update"]
)
(BOOT_MOUNT_POINT / NIXOS_DIR).mkdir(parents=True, exist_ok=True)
(BOOT_MOUNT_POINT / "loader/entries").mkdir(parents=True, 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 = Path(bootspec.init).parent == Path(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()
extra_files_dir = BOOT_MOUNT_POINT / NIXOS_DIR / ".extra-files"
for root, _, files in extra_files_dir.walk(top_down=False):
relative_root = root.relative_to(extra_files_dir)
actual_root = BOOT_MOUNT_POINT / relative_root
for file in files:
actual_file = actual_root / file
actual_file.unlink(missing_ok=True)
(root / file).unlink()
if not list(actual_root.iterdir()):
actual_root.rmdir()
root.rmdir()
extra_files_dir.mkdir(parents=True, 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()

View File

@@ -0,0 +1,642 @@
{
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
'';
edk2ShellEspPath = "efi/edk2-uefi-shell/shell.efi";
systemdBootBuilder = pkgs.replaceVarsWith {
name = "systemd-boot";
dir = "bin";
src = checkedSource;
isExecutable = true;
replacements = rec {
inherit (builtins) storeDir;
inherit (pkgs) python3;
systemd = config.systemd.package;
bootspecTools = config.boot.bootspec.package;
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;
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}/bin/systemd-boot "$@"
${cfg.extraInstallCommands}
'';
in
{
meta.maintainers = with lib.maintainers; [ julienmalka ];
imports = [
(mkRenamedOptionModule
[
"boot"
"loader"
"gummiboot"
"enable"
]
[
"boot"
"loader"
"systemd-boot"
"enable"
]
)
(lib.mkChangedOptionModule
[
"boot"
"loader"
"systemd-boot"
"memtest86"
"entryFilename"
]
[
"boot"
"loader"
"systemd-boot"
"memtest86"
"sortKey"
]
(config: lib.strings.removeSuffix ".conf" config.boot.loader.systemd-boot.memtest86.entryFilename)
)
(lib.mkChangedOptionModule
[
"boot"
"loader"
"systemd-boot"
"netbootxyz"
"entryFilename"
]
[
"boot"
"loader"
"systemd-boot"
"netbootxyz"
"sortKey"
]
(config: lib.strings.removeSuffix ".conf" config.boot.loader.systemd-boot.netbootxyz.entryFilename)
)
];
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 = 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"
"5"
"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
- `"5"`: Applicable for SteamDeck where this mode represent horizontal mode
- `"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`.
'';
};
};
edk2-uefi-shell = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Make the EDK2 UEFI Shell available from the systemd-boot menu.
It can be used to manually boot other operating systems or for debugging.
'';
};
sortKey = mkOption {
type = types.str;
default = "o_edk2-uefi-shell";
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.
'';
};
windows = mkOption {
default = { };
description = ''
Make Windows bootable from systemd-boot. This option is not necessary when Windows and
NixOS use the same EFI System Partition (ESP). In that case, Windows will automatically be
detected by systemd-boot.
However, if Windows is installed on a separate drive or ESP, you can use this option to add
a menu entry for each installation manually.
The attribute name is used for the title of the menu entry and internal file names.
'';
example = literalExpression ''
{
"10".efiDeviceHandle = "HD0c3";
"11-ame" = {
title = "Windows 11 Ameliorated Edition";
efiDeviceHandle = "HD0b1";
};
"11-home" = {
title = "Windows 11 Home";
efiDeviceHandle = "FS1";
sortKey = "z_windows";
};
}
'';
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = {
efiDeviceHandle = mkOption {
type = types.str;
example = "HD1b3";
description = ''
The device handle of the EFI System Partition (ESP) where the Windows bootloader is
located. This is the device handle that the EDK2 UEFI Shell uses to load the
bootloader.
To find this handle, follow these steps:
1. Set {option}`boot.loader.systemd-boot.edk2-uefi-shell.enable` to `true`
2. Run `nixos-rebuild boot`
3. Reboot and select "EDK2 UEFI Shell" from the systemd-boot menu
4. Run `map -c` to list all consistent device handles
5. For each device handle (for example, `HD0c1`), run `ls HD0c1:\EFI`
6. If the output contains the directory `Microsoft`, you might have found the correct device handle
7. Run `HD0c1:\EFI\Microsoft\Boot\Bootmgfw.efi` to check if Windows boots correctly
8. If it does, this device handle is the one you need (in this example, `HD0c1`)
This option is required, there is no useful default.
'';
};
title = mkOption {
type = types.str;
example = "Michaelsoft Binbows";
default = "Windows ${name}";
defaultText = ''attribute name of this entry, prefixed with "Windows "'';
description = ''
The title of the boot menu entry.
'';
};
sortKey = mkOption {
type = types.str;
default = "o_windows_${name}";
defaultText = ''attribute name of this entry, prefixed with "o_windows_"'';
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`..
'';
};
};
}
)
);
};
};
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)
++ concatMap (winVersion: [
{
assertion = lib.match "^[-_0-9A-Za-z]+$" winVersion != null;
message = "boot.loader.systemd-boot.windows.${winVersion} is invalid: key must only contain alphanumeric characters, hyphens, and underscores";
}
]) (builtins.attrNames cfg.windows);
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}";
})
(mkIf (cfg.edk2-uefi-shell.enable || cfg.windows != { }) {
${edk2ShellEspPath} = "${pkgs.edk2-uefi-shell}/shell.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}
'';
})
(mkIf cfg.edk2-uefi-shell.enable {
"edk2-uefi-shell.conf" = ''
title EDK2 UEFI Shell
efi /${edk2ShellEspPath}
sort-key ${cfg.edk2-uefi-shell.sortKey}
'';
})
]
++ (mapAttrsToList (winVersion: cfg: {
"windows_${winVersion}.conf" = ''
title ${cfg.title}
efi /${edk2ShellEspPath}
options -nointerrupt -nomap -noversion ${cfg.efiDeviceHandle}:EFI\Microsoft\Boot\Bootmgfw.efi
sort-key ${cfg.sortKey}
'';
}) cfg.windows)
);
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")
];
};
};
}