diff options
Diffstat (limited to 'nixos/modules/system')
-rwxr-xr-x | nixos/modules/system/activation/switch-to-configuration.pl | 12 | ||||
-rw-r--r-- | nixos/modules/system/boot/loader/grub/install-grub.pl | 6 | ||||
-rw-r--r-- | nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | 89 | ||||
-rw-r--r-- | nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | 6 | ||||
-rw-r--r-- | nixos/modules/system/boot/resolved.nix | 24 | ||||
-rw-r--r-- | nixos/modules/system/boot/systemd.nix | 53 | ||||
-rw-r--r-- | nixos/modules/system/boot/systemd/sysusers.nix | 169 | ||||
-rw-r--r-- | nixos/modules/system/boot/systemd/tmpfiles.nix | 35 | ||||
-rw-r--r-- | nixos/modules/system/boot/uki.nix | 85 | ||||
-rw-r--r-- | nixos/modules/system/etc/build-composefs-dump.py | 209 | ||||
-rwxr-xr-x | nixos/modules/system/etc/check-build-composefs-dump.sh | 8 | ||||
-rw-r--r-- | nixos/modules/system/etc/etc-activation.nix | 98 | ||||
-rw-r--r-- | nixos/modules/system/etc/etc.nix | 116 |
13 files changed, 840 insertions, 70 deletions
diff --git a/nixos/modules/system/activation/switch-to-configuration.pl b/nixos/modules/system/activation/switch-to-configuration.pl index e2f66a287bc4f..ba45231465fb4 100755 --- a/nixos/modules/system/activation/switch-to-configuration.pl +++ b/nixos/modules/system/activation/switch-to-configuration.pl @@ -889,9 +889,15 @@ while (my $f = <$list_active_users>) { close($list_active_users) || die("Unable to close the file handle to loginctl"); -# Set the new tmpfiles -print STDERR "setting up tmpfiles\n"; -system("$new_systemd/bin/systemd-tmpfiles", "--create", "--remove", "--exclude-prefix=/dev") == 0 or $res = 3; +# Restart sysinit-reactivation.target. +# This target only exists to restart services ordered before sysinit.target. We +# cannot use X-StopOnReconfiguration to restart sysinit.target because then ALL +# services of the system would be restarted since all normal services have a +# default dependency on sysinit.target. sysinit-reactivation.target ensures +# that services ordered BEFORE sysinit.target get re-started in the correct +# order. Ordering between these services is respected. +print STDERR "restarting sysinit-reactivation.target\n"; +system("$new_systemd/bin/systemctl", "restart", "sysinit-reactivation.target") == 0 or $res = 4; # Before reloading we need to ensure that the units are still active. They may have been # deactivated because one of their requirements got stopped. If they are inactive diff --git a/nixos/modules/system/boot/loader/grub/install-grub.pl b/nixos/modules/system/boot/loader/grub/install-grub.pl index d1e7a0cb81785..6f0f62546a018 100644 --- a/nixos/modules/system/boot/loader/grub/install-grub.pl +++ b/nixos/modules/system/boot/loader/grub/install-grub.pl @@ -136,7 +136,6 @@ sub GetFs { chomp $fs; my @fields = split / /, $fs; my $mountPoint = $fields[4]; - next unless -d $mountPoint; my @mountOptions = split /,/, $fields[5]; # Skip the optional fields. @@ -155,6 +154,11 @@ sub GetFs { # 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); } } diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py index 6cd46f30373b5..055afe95df60b 100644 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py @@ -15,6 +15,19 @@ import json from typing import NamedTuple, Dict, List from dataclasses import dataclass +# These values will be replaced with actual values during the package build +EFI_SYS_MOUNT_POINT = "@efiSysMountPoint@" +TIMEOUT = "@timeout@" +EDITOR = bool("@editor@") +CONSOLE_MODE = "@consoleMode@" +BOOTSPEC_TOOLS = "@bootspecTools@" +DISTRO_NAME = "@distroName@" +NIX = "@nix@" +SYSTEMD = "@systemd@" +CONFIGURATION_LIMIT = int("@configurationLimit@") +CAN_TOUCH_EFI_VARIABLES = "@canTouchEfiVariables@" +GRACEFUL = "@graceful@" +COPY_EXTRA_FILES = "@copyExtraFiles@" @dataclass class BootSpec: @@ -29,7 +42,6 @@ class BootSpec: initrdSecrets: str | None = None - libc = ctypes.CDLL("libc.so.6") class SystemIdentifier(NamedTuple): @@ -75,16 +87,16 @@ def generation_conf_filename(profile: str | None, generation: int, specialisatio def write_loader_conf(profile: str | None, generation: int, specialisation: str | None) -> None: - with open("@efiSysMountPoint@/loader/loader.conf.tmp", 'w') as f: - if "@timeout@" != "": - f.write("timeout @timeout@\n") + with open(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf.tmp", 'w') as f: + if TIMEOUT != "": + f.write(f"timeout {TIMEOUT}\n") f.write("default %s\n" % generation_conf_filename(profile, generation, specialisation)) - if not @editor@: + if not EDITOR: f.write("editor 0\n") - f.write("console-mode @consoleMode@\n") + f.write(f"console-mode {CONSOLE_MODE}\n") f.flush() os.fsync(f.fileno()) - os.rename("@efiSysMountPoint@/loader/loader.conf.tmp", "@efiSysMountPoint@/loader/loader.conf") + os.rename(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf.tmp", f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf") def get_bootspec(profile: str | None, generation: int) -> BootSpec: @@ -95,7 +107,7 @@ def get_bootspec(profile: str | None, generation: int) -> BootSpec: bootspec_json = json.load(boot_json_f) else: boot_json_str = subprocess.check_output([ - "@bootspecTools@/bin/synthesize", + f"{BOOTSPEC_TOOLS}/bin/synthesize", "--version", "1", system_directory, @@ -116,7 +128,7 @@ def copy_from_file(file: str, dry_run: bool = False) -> str: store_dir = os.path.basename(os.path.dirname(store_file_path)) efi_file_path = "/efi/nixos/%s-%s.efi" % (store_dir, suffix) if not dry_run: - copy_if_not_exists(store_file_path, "@efiSysMountPoint@%s" % (efi_file_path)) + copy_if_not_exists(store_file_path, f"{EFI_SYS_MOUNT_POINT}%s" % (efi_file_path)) return efi_file_path def write_entry(profile: str | None, generation: int, specialisation: str | None, @@ -126,13 +138,14 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None kernel = copy_from_file(bootspec.kernel) initrd = copy_from_file(bootspec.initrd) - title = "@distroName@{profile}{specialisation}".format( + 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: - subprocess.check_call([bootspec.initrdSecrets, "@efiSysMountPoint@%s" % (initrd)]) + subprocess.check_call([bootspec.initrdSecrets, f"{EFI_SYS_MOUNT_POINT}%s" % (initrd)]) except subprocess.CalledProcessError: if current: print("failed to create initrd secrets!", file=sys.stderr) @@ -142,7 +155,7 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None 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 = "@efiSysMountPoint@/loader/entries/%s" % ( + entry_file = f"{EFI_SYS_MOUNT_POINT}/loader/entries/%s" % ( generation_conf_filename(profile, generation, specialisation)) tmp_path = "%s.tmp" % (entry_file) kernel_params = "init=%s " % bootspec.init @@ -167,7 +180,7 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None def get_generations(profile: str | None = None) -> list[SystemIdentifier]: gen_list = subprocess.check_output([ - "@nix@/bin/nix-env", + f"{NIX}/bin/nix-env", "--list-generations", "-p", "/nix/var/nix/profiles/%s" % ("system-profiles/" + profile if profile else "system"), @@ -176,7 +189,7 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: gen_lines = gen_list.split('\n') gen_lines.pop() - configurationLimit = @configurationLimit@ + configurationLimit = CONFIGURATION_LIMIT configurations = [ SystemIdentifier( profile=profile, @@ -189,14 +202,14 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: def remove_old_entries(gens: list[SystemIdentifier]) -> None: - rex_profile = re.compile(r"^@efiSysMountPoint@/loader/entries/nixos-(.*)-generation-.*\.conf$") - rex_generation = re.compile(r"^@efiSysMountPoint@/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$") + rex_profile = re.compile(r"^" + re.escape(EFI_SYS_MOUNT_POINT) + "/loader/entries/nixos-(.*)-generation-.*\.conf$") + rex_generation = re.compile(r"^" + re.escape(EFI_SYS_MOUNT_POINT) + "/loader/entries/nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$") known_paths = [] for gen in gens: bootspec = get_bootspec(gen.profile, gen.generation) known_paths.append(copy_from_file(bootspec.kernel, True)) known_paths.append(copy_from_file(bootspec.initrd, True)) - for path in glob.iglob("@efiSysMountPoint@/loader/entries/nixos*-generation-[1-9]*.conf"): + for path in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/loader/entries/nixos*-generation-[1-9]*.conf"): if rex_profile.match(path): prof = rex_profile.sub(r"\1", path) else: @@ -207,7 +220,7 @@ def remove_old_entries(gens: list[SystemIdentifier]) -> None: continue if not (prof, gen_number, None) in gens: os.unlink(path) - for path in glob.iglob("@efiSysMountPoint@/efi/nixos/*"): + for path in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/*"): if not path in known_paths and not os.path.isdir(path): os.unlink(path) @@ -230,7 +243,7 @@ def install_bootloader(args: argparse.Namespace) -> None: # Since systemd version 232 a machine ID is required and it might not # be there on newly installed systems, so let's generate one so that # bootctl can find it and we can also pass it to write_entry() later. - cmd = ["@systemd@/bin/systemd-machine-id-setup", "--print"] + cmd = [f"{SYSTEMD}/bin/systemd-machine-id-setup", "--print"] machine_id = subprocess.run( cmd, text=True, check=True, stdout=subprocess.PIPE ).stdout.rstrip() @@ -242,22 +255,22 @@ def install_bootloader(args: argparse.Namespace) -> None: # flags to pass to bootctl install/update bootctl_flags = [] - if "@canTouchEfiVariables@" != "1": + if CAN_TOUCH_EFI_VARIABLES != "1": bootctl_flags.append("--no-variables") - if "@graceful@" == "1": + if GRACEFUL == "1": bootctl_flags.append("--graceful") if os.getenv("NIXOS_INSTALL_BOOTLOADER") == "1": # bootctl uses fopen() with modes "wxe" and fails if the file exists. - if os.path.exists("@efiSysMountPoint@/loader/loader.conf"): - os.unlink("@efiSysMountPoint@/loader/loader.conf") + if os.path.exists(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf"): + os.unlink(f"{EFI_SYS_MOUNT_POINT}/loader/loader.conf") - subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@efiSysMountPoint@"] + bootctl_flags + ["install"]) + subprocess.check_call([f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + bootctl_flags + ["install"]) else: # Update bootloader to latest if needed - available_out = subprocess.check_output(["@systemd@/bin/bootctl", "--version"], universal_newlines=True).split()[2] - installed_out = subprocess.check_output(["@systemd@/bin/bootctl", "--esp-path=@efiSysMountPoint@", "status"], universal_newlines=True) + available_out = subprocess.check_output([f"{SYSTEMD}/bin/bootctl", "--version"], universal_newlines=True).split()[2] + installed_out = subprocess.check_output([f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}", "status"], universal_newlines=True) # See status_binaries() in systemd bootctl.c for code which generates this installed_match = re.search(r"^\W+File:.*/EFI/(?:BOOT|systemd)/.*\.efi \(systemd-boot ([\d.]+[^)]*)\)$", @@ -276,10 +289,10 @@ def install_bootloader(args: argparse.Namespace) -> None: if installed_version < available_version: print("updating systemd-boot from %s to %s" % (installed_version, available_version)) - subprocess.check_call(["@systemd@/bin/bootctl", "--esp-path=@efiSysMountPoint@"] + bootctl_flags + ["update"]) + subprocess.check_call([f"{SYSTEMD}/bin/bootctl", f"--esp-path={EFI_SYS_MOUNT_POINT}"] + bootctl_flags + ["update"]) - os.makedirs("@efiSysMountPoint@/efi/nixos", exist_ok=True) - os.makedirs("@efiSysMountPoint@/loader/entries", exist_ok=True) + os.makedirs(f"{EFI_SYS_MOUNT_POINT}/efi/nixos", exist_ok=True) + os.makedirs(f"{EFI_SYS_MOUNT_POINT}/loader/entries", exist_ok=True) gens = get_generations() for profile in get_profiles(): @@ -302,9 +315,9 @@ def install_bootloader(args: argparse.Namespace) -> None: else: raise e - for root, _, files in os.walk('@efiSysMountPoint@/efi/nixos/.extra-files', topdown=False): - relative_root = root.removeprefix("@efiSysMountPoint@/efi/nixos/.extra-files").removeprefix("/") - actual_root = os.path.join("@efiSysMountPoint@", relative_root) + for root, _, files in os.walk(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/.extra-files", topdown=False): + relative_root = root.removeprefix(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/.extra-files").removeprefix("/") + actual_root = os.path.join(f"{EFI_SYS_MOUNT_POINT}", relative_root) for file in files: actual_file = os.path.join(actual_root, file) @@ -317,14 +330,14 @@ def install_bootloader(args: argparse.Namespace) -> None: os.rmdir(actual_root) os.rmdir(root) - os.makedirs("@efiSysMountPoint@/efi/nixos/.extra-files", exist_ok=True) + os.makedirs(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/.extra-files", exist_ok=True) - subprocess.check_call("@copyExtraFiles@") + subprocess.check_call(COPY_EXTRA_FILES) def main() -> None: - parser = argparse.ArgumentParser(description='Update @distroName@-related systemd-boot files') - parser.add_argument('default_config', metavar='DEFAULT-CONFIG', help='The default @distroName@ config to boot') + 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() try: @@ -334,9 +347,9 @@ def main() -> None: # 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("@efiSysMountPoint@", os.O_RDONLY)) + rc = libc.syncfs(os.open(f"{EFI_SYS_MOUNT_POINT}", os.O_RDONLY)) if rc != 0: - print("could not sync @efiSysMountPoint@: {}".format(os.strerror(rc)), file=sys.stderr) + print(f"could not sync {EFI_SYS_MOUNT_POINT}: {os.strerror(rc)}", file=sys.stderr) if __name__ == '__main__': diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix index 9d55c21077d13..3b140726c2d6a 100644 --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix @@ -81,7 +81,11 @@ in { type = types.bool; - description = lib.mdDoc "Whether to enable the systemd-boot (formerly gummiboot) EFI boot manager"; + description = lib.mdDoc '' + 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/ + ''; }; editor = mkOption { diff --git a/nixos/modules/system/boot/resolved.nix b/nixos/modules/system/boot/resolved.nix index 538f71cc0b9ae..c42c88163c564 100644 --- a/nixos/modules/system/boot/resolved.nix +++ b/nixos/modules/system/boot/resolved.nix @@ -95,6 +95,29 @@ in ''; }; + services.resolved.dnsovertls = mkOption { + default = "false"; + example = "true"; + type = types.enum [ "true" "opportunistic" "false" ]; + description = lib.mdDoc '' + If set to + - `"true"`: + all DNS lookups will be encrypted. This requires + that the DNS server supports DNS-over-TLS and + has a valid certificate. If the hostname was specified + via the `address#hostname` format in {option}`services.resolved.domains` + then the specified hostname is used to validate its certificate. + - `"opportunistic"`: + all DNS lookups will attempt to be encrypted, but will fallback + to unecrypted requests if the server does not support DNS-over-TLS. + Note that this mode does allow for a malicious party to conduct a + downgrade attack by immitating the DNS server and pretending to not + support encryption. + - `"false"`: + all DNS lookups are done unencrypted. + ''; + }; + services.resolved.extraConfig = mkOption { default = ""; type = types.lines; @@ -141,6 +164,7 @@ in "Domains=${concatStringsSep " " cfg.domains}"} LLMNR=${cfg.llmnr} DNSSEC=${cfg.dnssec} + DNSOverTLS=${cfg.dnsovertls} ${config.services.resolved.extraConfig} ''; diff --git a/nixos/modules/system/boot/systemd.nix b/nixos/modules/system/boot/systemd.nix index c3902007906ad..331ca5103ba61 100644 --- a/nixos/modules/system/boot/systemd.nix +++ b/nixos/modules/system/boot/systemd.nix @@ -451,20 +451,37 @@ in cfg.services ); - assertions = concatLists ( - mapAttrsToList - (name: service: - map (message: { - assertion = false; - inherit message; - }) (concatLists [ - (optional ((builtins.elem "network-interfaces.target" service.after) || (builtins.elem "network-interfaces.target" service.wants)) - "Service '${name}.service' is using the deprecated target network-interfaces.target, which no longer exists. Using network.target is recommended instead." - ) - ]) - ) - cfg.services - ); + assertions = let + mkOneAssert = typeStr: name: def: { + assertion = lib.elem "network-online.target" def.after -> lib.elem "network-online.target" (def.wants ++ def.requires ++ def.bindsTo); + message = "${name}.${typeStr} is ordered after 'network-online.target' but doesn't depend on it"; + }; + mkAsserts = typeStr: lib.mapAttrsToList (mkOneAssert typeStr); + mkMountAsserts = typeStr: map (m: mkOneAssert typeStr m.what m); + in mkMerge [ + (concatLists ( + mapAttrsToList + (name: service: + map (message: { + assertion = false; + inherit message; + }) (concatLists [ + (optional ((builtins.elem "network-interfaces.target" service.after) || (builtins.elem "network-interfaces.target" service.wants)) + "Service '${name}.service' is using the deprecated target network-interfaces.target, which no longer exists. Using network.target is recommended instead." + ) + ]) + ) + cfg.services + )) + (mkAsserts "target" cfg.targets) + (mkAsserts "service" cfg.services) + (mkAsserts "socket" cfg.sockets) + (mkAsserts "timer" cfg.timers) + (mkAsserts "path" cfg.paths) + (mkMountAsserts "mount" cfg.mounts) + (mkMountAsserts "automount" cfg.automounts) + (mkAsserts "slice" cfg.slices) + ]; system.build.units = cfg.units; @@ -569,6 +586,13 @@ in unitConfig.X-StopOnReconfiguration = true; }; + # This target only exists so that services ordered before sysinit.target + # are restarted in the correct order, notably BEFORE the other services, + # when switching configurations. + systemd.targets.sysinit-reactivation = { + description = "Reactivate sysinit units"; + }; + systemd.units = mapAttrs' (n: v: nameValuePair "${n}.path" (pathToUnit n v)) cfg.paths // mapAttrs' (n: v: nameValuePair "${n}.service" (serviceToUnit n v)) cfg.services @@ -634,7 +658,6 @@ in systemd.services.systemd-udev-settle.restartIfChanged = false; # Causes long delays in nixos-rebuild systemd.targets.local-fs.unitConfig.X-StopOnReconfiguration = true; systemd.targets.remote-fs.unitConfig.X-StopOnReconfiguration = true; - systemd.targets.network-online.wantedBy = [ "multi-user.target" ]; systemd.services.systemd-importd.environment = proxy_env; systemd.services.systemd-pstore.wantedBy = [ "sysinit.target" ]; # see #81138 diff --git a/nixos/modules/system/boot/systemd/sysusers.nix b/nixos/modules/system/boot/systemd/sysusers.nix new file mode 100644 index 0000000000000..c619c2d91eb09 --- /dev/null +++ b/nixos/modules/system/boot/systemd/sysusers.nix @@ -0,0 +1,169 @@ +{ config, lib, pkgs, utils, ... }: + +let + + cfg = config.systemd.sysusers; + userCfg = config.users; + + sysusersConfig = pkgs.writeTextDir "00-nixos.conf" '' + # Type Name ID GECOS Home directory Shell + + # Users + ${lib.concatLines (lib.mapAttrsToList + (username: opts: + let + uid = if opts.uid == null then "-" else toString opts.uid; + in + ''u ${username} ${uid}:${opts.group} "${opts.description}" ${opts.home} ${utils.toShellPath opts.shell}'' + ) + userCfg.users) + } + + # Groups + ${lib.concatLines (lib.mapAttrsToList + (groupname: opts: ''g ${groupname} ${if opts.gid == null then "-" else toString opts.gid}'') userCfg.groups) + } + + # Group membership + ${lib.concatStrings (lib.mapAttrsToList + (groupname: opts: (lib.concatMapStrings (username: "m ${username} ${groupname}\n")) opts.members ) userCfg.groups) + } + ''; + + staticSysusersCredentials = pkgs.runCommand "static-sysusers-credentials" { } '' + mkdir $out; cd $out + ${lib.concatLines ( + (lib.mapAttrsToList + (username: opts: "echo -n '${opts.initialHashedPassword}' > 'passwd.hashed-password.${username}'") + (lib.filterAttrs (_username: opts: opts.initialHashedPassword != null) userCfg.users)) + ++ + (lib.mapAttrsToList + (username: opts: "echo -n '${opts.initialPassword}' > 'passwd.plaintext-password.${username}'") + (lib.filterAttrs (_username: opts: opts.initialPassword != null) userCfg.users)) + ++ + (lib.mapAttrsToList + (username: opts: "cat '${opts.hashedPasswordFile}' > 'passwd.hashed-password.${username}'") + (lib.filterAttrs (_username: opts: opts.hashedPasswordFile != null) userCfg.users)) + ) + } + ''; + + staticSysusers = pkgs.runCommand "static-sysusers" + { + nativeBuildInputs = [ pkgs.systemd ]; + } '' + mkdir $out + export CREDENTIALS_DIRECTORY=${staticSysusersCredentials} + systemd-sysusers --root $out ${sysusersConfig}/00-nixos.conf + ''; + +in + +{ + + options = { + + # This module doesn't set it's own user options but reuses the ones from + # users-groups.nix + + systemd.sysusers = { + enable = lib.mkEnableOption (lib.mdDoc "systemd-sysusers") // { + description = lib.mdDoc '' + If enabled, users are created with systemd-sysusers instead of with + the custom `update-users-groups.pl` script. + + Note: This is experimental. + ''; + }; + }; + + }; + + config = lib.mkIf cfg.enable { + + assertions = [ + { + assertion = config.system.activationScripts.users == ""; + message = "system.activationScripts.users has to be empty to use systemd-sysusers"; + } + { + assertion = config.users.mutableUsers -> config.system.etc.overlay.enable; + message = "config.users.mutableUsers requires config.system.etc.overlay.enable."; + } + ]; + + systemd = lib.mkMerge [ + ({ + + # Create home directories, do not create /var/empty even if that's a user's + # home. + tmpfiles.settings.home-directories = lib.mapAttrs' + (username: opts: lib.nameValuePair opts.home { + d = { + mode = opts.homeMode; + user = username; + group = opts.group; + }; + }) + (lib.filterAttrs (_username: opts: opts.home != "/var/empty") userCfg.users); + }) + + (lib.mkIf config.users.mutableUsers { + additionalUpstreamSystemUnits = [ + "systemd-sysusers.service" + ]; + + services.systemd-sysusers = { + # Enable switch-to-configuration to restart the service. + unitConfig.ConditionNeedsUpdate = [ "" ]; + requiredBy = [ "sysinit-reactivation.target" ]; + before = [ "sysinit-reactivation.target" ]; + restartTriggers = [ "${config.environment.etc."sysusers.d".source}" ]; + + serviceConfig = { + LoadCredential = lib.mapAttrsToList + (username: opts: "passwd.hashed-password.${username}:${opts.hashedPasswordFile}") + (lib.filterAttrs (_username: opts: opts.hashedPasswordFile != null) userCfg.users); + SetCredential = (lib.mapAttrsToList + (username: opts: "passwd.hashed-password.${username}:${opts.initialHashedPassword}") + (lib.filterAttrs (_username: opts: opts.initialHashedPassword != null) userCfg.users)) + ++ + (lib.mapAttrsToList + (username: opts: "passwd.plaintext-password.${username}:${opts.initialPassword}") + (lib.filterAttrs (_username: opts: opts.initialPassword != null) userCfg.users)) + ; + }; + }; + }) + ]; + + environment.etc = lib.mkMerge [ + (lib.mkIf (!userCfg.mutableUsers) { + "passwd" = { + source = "${staticSysusers}/etc/passwd"; + mode = "0644"; + }; + "group" = { + source = "${staticSysusers}/etc/group"; + mode = "0644"; + }; + "shadow" = { + source = "${staticSysusers}/etc/shadow"; + mode = "0000"; + }; + "gshadow" = { + source = "${staticSysusers}/etc/gshadow"; + mode = "0000"; + }; + }) + + (lib.mkIf userCfg.mutableUsers { + "sysusers.d".source = sysusersConfig; + }) + ]; + + }; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + +} diff --git a/nixos/modules/system/boot/systemd/tmpfiles.nix b/nixos/modules/system/boot/systemd/tmpfiles.nix index 183e2033ecb01..dae23eddd1e2b 100644 --- a/nixos/modules/system/boot/systemd/tmpfiles.nix +++ b/nixos/modules/system/boot/systemd/tmpfiles.nix @@ -150,6 +150,41 @@ in "systemd-tmpfiles-setup.service" ]; + # Allow systemd-tmpfiles to be restarted by switch-to-configuration. This + # service is not pulled into the normal boot process. It only exists for + # switch-to-configuration. + # + # This needs to be a separate unit because it does not execute + # systemd-tmpfiles with `--boot` as that is supposed to only be executed + # once at boot time. + # + # Keep this aligned with the upstream `systemd-tmpfiles-setup.service` unit. + systemd.services."systemd-tmpfiles-resetup" = { + description = "Re-setup tmpfiles on a system that is already running."; + + requiredBy = [ "sysinit-reactivation.target" ]; + after = [ "local-fs.target" "systemd-sysusers.service" "systemd-journald.service" ]; + before = [ "sysinit-reactivation.target" "shutdown.target" ]; + conflicts = [ "shutdown.target" ]; + restartTriggers = [ config.environment.etc."tmpfiles.d".source ]; + + unitConfig.DefaultDependencies = false; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "systemd-tmpfiles --create --remove --exclude-prefix=/dev"; + SuccessExitStatus = "DATAERR CANTCREAT"; + ImportCredential = [ + "tmpfiles.*" + "loging.motd" + "login.issue" + "network.hosts" + "ssh.authorized_keys.root" + ]; + }; + }; + environment.etc = { "tmpfiles.d".source = (pkgs.symlinkJoin { name = "tmpfiles.d"; diff --git a/nixos/modules/system/boot/uki.nix b/nixos/modules/system/boot/uki.nix new file mode 100644 index 0000000000000..63c4e0c0e3913 --- /dev/null +++ b/nixos/modules/system/boot/uki.nix @@ -0,0 +1,85 @@ +{ config, lib, pkgs, ... }: + +let + + cfg = config.boot.uki; + + inherit (pkgs.stdenv.hostPlatform) efiArch; + + format = pkgs.formats.ini { }; + ukifyConfig = format.generate "ukify.conf" cfg.settings; + +in + +{ + options = { + + boot.uki = { + name = lib.mkOption { + type = lib.types.str; + description = lib.mdDoc "Name of the UKI"; + }; + + version = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = config.system.image.version; + defaultText = lib.literalExpression "config.system.image.version"; + description = lib.mdDoc "Version of the image or generation the UKI belongs to"; + }; + + settings = lib.mkOption { + type = format.type; + description = lib.mdDoc '' + The configuration settings for ukify. These control what the UKI + contains and how it is built. + ''; + }; + }; + + system.boot.loader.ukiFile = lib.mkOption { + type = lib.types.str; + internal = true; + description = lib.mdDoc "Name of the UKI file"; + }; + + }; + + config = { + + boot.uki.name = lib.mkOptionDefault (if config.system.image.id != null then + config.system.image.id + else + "nixos"); + + boot.uki.settings = lib.mkOptionDefault { + UKI = { + Linux = "${config.boot.kernelPackages.kernel}/${config.system.boot.loader.kernelFile}"; + Initrd = "${config.system.build.initialRamdisk}/${config.system.boot.loader.initrdFile}"; + Cmdline = "init=${config.system.build.toplevel}/init ${toString config.boot.kernelParams}"; + Stub = "${pkgs.systemd}/lib/systemd/boot/efi/linux${efiArch}.efi.stub"; + Uname = "${config.boot.kernelPackages.kernel.modDirVersion}"; + OSRelease = "@${config.system.build.etc}/etc/os-release"; + # This is needed for cross compiling. + EFIArch = efiArch; + }; + }; + + system.boot.loader.ukiFile = + let + name = config.boot.uki.name; + version = config.boot.uki.version; + versionInfix = if version != null then "_${version}" else ""; + in + name + versionInfix + ".efi"; + + system.build.uki = pkgs.runCommand config.system.boot.loader.ukiFile { } '' + mkdir -p $out + ${pkgs.buildPackages.systemdUkify}/lib/systemd/ukify build \ + --config=${ukifyConfig} \ + --output="$out/${config.system.boot.loader.ukiFile}" + ''; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + + }; +} diff --git a/nixos/modules/system/etc/build-composefs-dump.py b/nixos/modules/system/etc/build-composefs-dump.py new file mode 100644 index 0000000000000..923d40008b63f --- /dev/null +++ b/nixos/modules/system/etc/build-composefs-dump.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +"""Build a composefs dump from a Json config + +See the man page of composefs-dump for details about the format: +https://github.com/containers/composefs/blob/main/man/composefs-dump.md + +Ensure to check the file with the check script when you make changes to it: + +./check-build-composefs-dump.sh ./build-composefs_dump.py +""" + +import glob +import json +import os +import sys +from enum import Enum +from pathlib import Path +from typing import Any + +Attrs = dict[str, Any] + + +class FileType(Enum): + """The filetype as defined by the `st_mode` stat field in octal + + You can check the st_mode stat field of a path in Python with + `oct(os.stat("/path/").st_mode)` + """ + + directory = "4" + file = "10" + symlink = "12" + + +class ComposefsPath: + path: str + size: int + filetype: FileType + mode: str + uid: str + gid: str + payload: str + rdev: str = "0" + nlink: int = 1 + mtime: str = "1.0" + content: str = "-" + digest: str = "-" + + def __init__( + self, + attrs: Attrs, + size: int, + filetype: FileType, + mode: str, + payload: str, + path: str | None = None, + ): + if path is None: + path = attrs["target"] + self.path = "/" + path + self.size = size + self.filetype = filetype + self.mode = mode + self.uid = attrs["uid"] + self.gid = attrs["gid"] + self.payload = payload + + def write_line(self) -> str: + line_list = [ + str(self.path), + str(self.size), + f"{self.filetype.value}{self.mode}", + str(self.nlink), + str(self.uid), + str(self.gid), + str(self.rdev), + str(self.mtime), + str(self.payload), + str(self.content), + str(self.digest), + ] + return " ".join(line_list) + + +def eprint(*args, **kwargs) -> None: + print(args, **kwargs, file=sys.stderr) + + +def leading_directories(path: str) -> list[str]: + """Return the leading directories of path + + Given the path "alsa/conf.d/50-pipewire.conf", for example, this function + returns `[ "alsa", "alsa/conf.d" ]`. + """ + parents = list(Path(path).parents) + parents.reverse() + # remove the implicit `.` from the start of a relative path or `/` from an + # absolute path + del parents[0] + return [str(i) for i in parents] + + +def add_leading_directories( + target: str, attrs: Attrs, paths: dict[str, ComposefsPath] +) -> None: + """Add the leading directories of a target path to the composefs paths + + mkcomposefs expects that all leading directories are explicitly listed in + the dump file. Given the path "alsa/conf.d/50-pipewire.conf", for example, + this function adds "alsa" and "alsa/conf.d" to the composefs paths. + """ + path_components = leading_directories(target) + for component in path_components: + composefs_path = ComposefsPath( + attrs, + path=component, + size=4096, + filetype=FileType.directory, + mode="0755", + payload="-", + ) + paths[component] = composefs_path + + +def main() -> None: + """Build a composefs dump from a Json config + + This config describes the files that the final composefs image is supposed + to contain. + """ + config_file = sys.argv[1] + if not config_file: + eprint("No config file was supplied.") + sys.exit(1) + + with open(config_file, "rb") as f: + config = json.load(f) + + if not config: + eprint("Config is empty.") + sys.exit(1) + + eprint("Building composefs dump...") + + paths: dict[str, ComposefsPath] = {} + for attrs in config: + target = attrs["target"] + source = attrs["source"] + mode = attrs["mode"] + + if "*" in source: # Path with globbing + glob_sources = glob.glob(source) + for glob_source in glob_sources: + basename = os.path.basename(glob_source) + glob_target = f"{target}/{basename}" + + composefs_path = ComposefsPath( + attrs, + path=glob_target, + size=100, + filetype=FileType.symlink, + mode="0777", + payload=glob_source, + ) + + paths[glob_target] = composefs_path + add_leading_directories(glob_target, attrs, paths) + else: # Without globbing + if mode == "symlink": + composefs_path = ComposefsPath( + attrs, + # A high approximation of the size of a symlink + size=100, + filetype=FileType.symlink, + mode="0777", + payload=source, + ) + else: + if os.path.isdir(source): + composefs_path = ComposefsPath( + attrs, + size=4096, + filetype=FileType.directory, + mode=mode, + payload=source, + ) + else: + composefs_path = ComposefsPath( + attrs, + size=os.stat(source).st_size, + filetype=FileType.file, + mode=mode, + payload=target, + ) + paths[target] = composefs_path + add_leading_directories(target, attrs, paths) + + composefs_dump = ["/ 4096 40755 1 0 0 0 0.0 - - -"] # Root directory + for key in sorted(paths): + composefs_path = paths[key] + eprint(composefs_path.path) + composefs_dump.append(composefs_path.write_line()) + + print("\n".join(composefs_dump)) + + +if __name__ == "__main__": + main() diff --git a/nixos/modules/system/etc/check-build-composefs-dump.sh b/nixos/modules/system/etc/check-build-composefs-dump.sh new file mode 100755 index 0000000000000..da61651d1a5d6 --- /dev/null +++ b/nixos/modules/system/etc/check-build-composefs-dump.sh @@ -0,0 +1,8 @@ +#! /usr/bin/env nix-shell +#! nix-shell -i bash -p black ruff mypy + +file=$1 + +black --check --diff $file +ruff --line-length 88 $file +mypy --strict $file diff --git a/nixos/modules/system/etc/etc-activation.nix b/nixos/modules/system/etc/etc-activation.nix index 7801049501860..f47fd771c6592 100644 --- a/nixos/modules/system/etc/etc-activation.nix +++ b/nixos/modules/system/etc/etc-activation.nix @@ -1,12 +1,96 @@ { config, lib, ... }: -let - inherit (lib) stringAfter; -in { + +{ imports = [ ./etc.nix ]; - config = { - system.activationScripts.etc = - stringAfter [ "users" "groups" ] config.system.build.etcActivationCommands; - }; + config = lib.mkMerge [ + + { + system.activationScripts.etc = + lib.stringAfter [ "users" "groups" ] config.system.build.etcActivationCommands; + } + + (lib.mkIf config.system.etc.overlay.enable { + + assertions = [ + { + assertion = config.boot.initrd.systemd.enable; + message = "`system.etc.overlay.enable` requires `boot.initrd.systemd.enable`"; + } + { + assertion = (!config.system.etc.overlay.mutable) -> config.systemd.sysusers.enable; + message = "`system.etc.overlay.mutable = false` requires `systemd.sysusers.enable`"; + } + { + assertion = lib.versionAtLeast config.boot.kernelPackages.kernel.version "6.6"; + message = "`system.etc.overlay.enable requires a newer kernel, at least version 6.6"; + } + { + assertion = config.systemd.sysusers.enable -> (config.users.mutableUsers == config.system.etc.overlay.mutable); + message = '' + When using systemd-sysusers and mounting `/etc` via an overlay, users + can only be mutable when `/etc` is mutable and vice versa. + ''; + } + ]; + + boot.initrd.availableKernelModules = [ "loop" "erofs" "overlay" ]; + + boot.initrd.systemd = { + mounts = [ + { + where = "/run/etc-metadata"; + what = "/sysroot${config.system.build.etcMetadataImage}"; + type = "erofs"; + options = "loop"; + unitConfig.RequiresMountsFor = [ + "/sysroot/nix/store" + ]; + } + { + where = "/sysroot/etc"; + what = "overlay"; + type = "overlay"; + options = lib.concatStringsSep "," ([ + "relatime" + "redirect_dir=on" + "metacopy=on" + "lowerdir=/run/etc-metadata::/sysroot${config.system.build.etcBasedir}" + ] ++ lib.optionals config.system.etc.overlay.mutable [ + "rw" + "upperdir=/sysroot/.rw-etc/upper" + "workdir=/sysroot/.rw-etc/work" + ] ++ lib.optionals (!config.system.etc.overlay.mutable) [ + "ro" + ]); + wantedBy = [ "initrd-fs.target" ]; + before = [ "initrd-fs.target" ]; + requires = lib.mkIf config.system.etc.overlay.mutable [ "rw-etc.service" ]; + after = lib.mkIf config.system.etc.overlay.mutable [ "rw-etc.service" ]; + unitConfig.RequiresMountsFor = [ + "/sysroot/nix/store" + "/run/etc-metadata" + ]; + } + ]; + services = lib.mkIf config.system.etc.overlay.mutable { + rw-etc = { + unitConfig = { + DefaultDependencies = false; + RequiresMountsFor = "/sysroot"; + }; + serviceConfig = { + Type = "oneshot"; + ExecStart = '' + /bin/mkdir -p -m 0755 /sysroot/.rw-etc/upper /sysroot/.rw-etc/work + ''; + }; + }; + }; + }; + + }) + + ]; } diff --git a/nixos/modules/system/etc/etc.nix b/nixos/modules/system/etc/etc.nix index ea61e7384e60c..baf37ba6def34 100644 --- a/nixos/modules/system/etc/etc.nix +++ b/nixos/modules/system/etc/etc.nix @@ -62,6 +62,16 @@ let ]) etc'} ''; + etcHardlinks = filter (f: f.mode != "symlink") etc'; + + build-composefs-dump = pkgs.runCommand "build-composefs-dump.py" + { + buildInputs = [ pkgs.python3 ]; + } '' + install ${./build-composefs-dump.py} $out + patchShebangs --host $out + ''; + in { @@ -72,6 +82,30 @@ in options = { + system.etc.overlay = { + enable = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc '' + Mount `/etc` as an overlayfs instead of generating it via a perl script. + + Note: This is currently experimental. Only enable this option if you're + confident that you can recover your system if it breaks. + ''; + }; + + mutable = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether to mount `/etc` mutably (i.e. read-write) or immutably (i.e. read-only). + + If this is false, only the immutable lowerdir is mounted. If it is + true, a writable upperdir is mounted on top. + ''; + }; + }; + environment.etc = mkOption { default = {}; example = literalExpression '' @@ -190,12 +224,84 @@ in config = { system.build.etc = etc; - system.build.etcActivationCommands = - '' - # Set up the statically computed bits of /etc. - echo "setting up /etc..." - ${pkgs.perl.withPackages (p: [ p.FileSlurp ])}/bin/perl ${./setup-etc.pl} ${etc}/etc + system.build.etcActivationCommands = let + etcOverlayOptions = lib.concatStringsSep "," ([ + "relatime" + "redirect_dir=on" + "metacopy=on" + ] ++ lib.optionals config.system.etc.overlay.mutable [ + "upperdir=/.rw-etc/upper" + "workdir=/.rw-etc/work" + ]); + in if config.system.etc.overlay.enable then '' + # This script atomically remounts /etc when switching configuration. On a (re-)boot + # this should not run because /etc is mounted via a systemd mount unit + # instead. To a large extent this mimics what composefs does. Because + # it's relatively simple, however, we avoid the composefs dependency. + if [[ ! $IN_NIXOS_SYSTEMD_STAGE1 ]]; then + echo "remounting /etc..." + + tmpMetadataMount=$(mktemp --directory) + mount --type erofs ${config.system.build.etcMetadataImage} $tmpMetadataMount + + # Mount the new /etc overlay to a temporary private mount. + # This needs the indirection via a private bind mount because you + # cannot move shared mounts. + tmpEtcMount=$(mktemp --directory) + mount --bind --make-private $tmpEtcMount $tmpEtcMount + mount --type overlay overlay \ + --options lowerdir=$tmpMetadataMount::${config.system.build.etcBasedir},${etcOverlayOptions} \ + $tmpEtcMount + + # Move the new temporary /etc mount underneath the current /etc mount. + # + # This should eventually use util-linux to perform this move beneath, + # however, this functionality is not yet in util-linux. See this + # tracking issue: https://github.com/util-linux/util-linux/issues/2604 + ${pkgs.move-mount-beneath}/bin/move-mount --move --beneath $tmpEtcMount /etc + + # Unmount the top /etc mount to atomically reveal the new mount. + umount /etc + + fi + '' else '' + # Set up the statically computed bits of /etc. + echo "setting up /etc..." + ${pkgs.perl.withPackages (p: [ p.FileSlurp ])}/bin/perl ${./setup-etc.pl} ${etc}/etc + ''; + + system.build.etcBasedir = pkgs.runCommandLocal "etc-lowerdir" { } '' + set -euo pipefail + + makeEtcEntry() { + src="$1" + target="$2" + + mkdir -p "$out/$(dirname "$target")" + cp "$src" "$out/$target" + } + + mkdir -p "$out" + ${concatMapStringsSep "\n" (etcEntry: escapeShellArgs [ + "makeEtcEntry" + # Force local source paths to be added to the store + "${etcEntry.source}" + etcEntry.target + ]) etcHardlinks} + ''; + + system.build.etcMetadataImage = + let + etcJson = pkgs.writeText "etc-json" (builtins.toJSON etc'); + etcDump = pkgs.runCommand "etc-dump" { } "${build-composefs-dump} ${etcJson} > $out"; + in + pkgs.runCommand "etc-metadata.erofs" { + nativeBuildInputs = [ pkgs.composefs pkgs.erofs-utils ]; + } '' + mkcomposefs --from-file ${etcDump} $out + fsck.erofs $out ''; + }; } |