diff options
Diffstat (limited to 'nixos/modules')
-rw-r--r-- | nixos/modules/config/users-groups.nix | 10 | ||||
-rw-r--r-- | nixos/modules/module-list.nix | 1 | ||||
-rw-r--r-- | nixos/modules/profiles/perlless.nix | 31 | ||||
-rw-r--r-- | nixos/modules/services/system/dbus.nix | 1 | ||||
-rw-r--r-- | nixos/modules/system/boot/systemd/sysusers.nix | 169 | ||||
-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 | ||||
-rw-r--r-- | nixos/modules/testing/test-instrumentation.nix | 5 |
10 files changed, 631 insertions, 17 deletions
diff --git a/nixos/modules/config/users-groups.nix b/nixos/modules/config/users-groups.nix index 2aed620eb154..967ad0846d75 100644 --- a/nixos/modules/config/users-groups.nix +++ b/nixos/modules/config/users-groups.nix @@ -685,7 +685,7 @@ in { shadow.gid = ids.gids.shadow; }; - system.activationScripts.users = { + system.activationScripts.users = if !config.systemd.sysusers.enable then { supportsDryActivation = true; text = '' install -m 0700 -d /root @@ -694,7 +694,7 @@ in { ${pkgs.perl.withPackages (p: [ p.FileSlurp p.JSON ])}/bin/perl \ -w ${./update-users-groups.pl} ${spec} ''; - }; + } else ""; # keep around for backwards compatibility system.activationScripts.update-lingering = let lingerDir = "/var/lib/systemd/linger"; @@ -711,7 +711,9 @@ in { ''; # Warn about user accounts with deprecated password hashing schemes - system.activationScripts.hashes = { + # This does not work when the users and groups are created by + # systemd-sysusers because the users are created too late then. + system.activationScripts.hashes = if !config.systemd.sysusers.enable then { deps = [ "users" ]; text = '' users=() @@ -729,7 +731,7 @@ in { printf ' - %s\n' "''${users[@]}" fi ''; - }; + } else ""; # keep around for backwards compatibility # for backwards compatibility system.activationScripts.groups = stringAfter [ "users" ] ""; diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 00e6240f531d..b31154b9624e 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1488,6 +1488,7 @@ ./system/boot/systemd/repart.nix ./system/boot/systemd/shutdown.nix ./system/boot/systemd/sysupdate.nix + ./system/boot/systemd/sysusers.nix ./system/boot/systemd/tmpfiles.nix ./system/boot/systemd/user.nix ./system/boot/systemd/userdbd.nix diff --git a/nixos/modules/profiles/perlless.nix b/nixos/modules/profiles/perlless.nix new file mode 100644 index 000000000000..90abd14f077e --- /dev/null +++ b/nixos/modules/profiles/perlless.nix @@ -0,0 +1,31 @@ +# WARNING: If you enable this profile, you will NOT be able to switch to a new +# configuration and thus you will not be able to rebuild your system with +# nixos-rebuild! + +{ lib, ... }: + +{ + + # Disable switching to a new configuration. This is not a necessary + # limitation of a perlless system but just a current one. In the future, + # perlless switching might be possible. + system.switch.enable = lib.mkDefault false; + + # Remove perl from activation + boot.initrd.systemd.enable = lib.mkDefault true; + system.etc.overlay.enable = lib.mkDefault true; + systemd.sysusers.enable = lib.mkDefault true; + + # Random perl remnants + system.disableInstallerTools = lib.mkDefault true; + programs.less.lessopen = lib.mkDefault null; + programs.command-not-found.enable = lib.mkDefault false; + boot.enableContainers = lib.mkDefault false; + environment.defaultPackages = lib.mkDefault [ ]; + documentation.info.enable = lib.mkDefault false; + + # Check that the system does not contain a Nix store path that contains the + # string "perl". + system.forbiddenDependenciesRegex = "perl"; + +} diff --git a/nixos/modules/services/system/dbus.nix b/nixos/modules/services/system/dbus.nix index b47ebc92f93a..e8f8b48d0337 100644 --- a/nixos/modules/services/system/dbus.nix +++ b/nixos/modules/services/system/dbus.nix @@ -95,6 +95,7 @@ in uid = config.ids.uids.messagebus; description = "D-Bus system message bus daemon user"; home = homeDir; + homeMode = "0755"; group = "messagebus"; }; diff --git a/nixos/modules/system/boot/systemd/sysusers.nix b/nixos/modules/system/boot/systemd/sysusers.nix new file mode 100644 index 000000000000..c619c2d91eb0 --- /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/etc/build-composefs-dump.py b/nixos/modules/system/etc/build-composefs-dump.py new file mode 100644 index 000000000000..923d40008b63 --- /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 000000000000..da61651d1a5d --- /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 780104950186..f47fd771c659 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 ea61e7384e60..baf37ba6def3 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 ''; + }; } diff --git a/nixos/modules/testing/test-instrumentation.nix b/nixos/modules/testing/test-instrumentation.nix index 9ee77cd79a9b..6aa718c1975d 100644 --- a/nixos/modules/testing/test-instrumentation.nix +++ b/nixos/modules/testing/test-instrumentation.nix @@ -207,7 +207,10 @@ in networking.usePredictableInterfaceNames = false; # Make it easy to log in as root when running the test interactively. - users.users.root.initialHashedPassword = mkOverride 150 ""; + # This needs to be a file because of a quirk in systemd credentials, + # where you cannot specify an empty string as a value. systemd-sysusers + # uses credentials to set passwords on users. + users.users.root.hashedPasswordFile = mkOverride 150 "${pkgs.writeText "hashed-password.root" ""}"; services.xserver.displayManager.job.logToJournal = true; |