diff options
Diffstat (limited to 'nixos')
27 files changed, 1068 insertions, 25 deletions
diff --git a/nixos/doc/manual/configuration/profiles.chapter.md b/nixos/doc/manual/configuration/profiles.chapter.md index 9f1f48f742ac5..9f6c11b0d59d5 100644 --- a/nixos/doc/manual/configuration/profiles.chapter.md +++ b/nixos/doc/manual/configuration/profiles.chapter.md @@ -29,6 +29,7 @@ profiles/graphical.section.md profiles/hardened.section.md profiles/headless.section.md profiles/installation-device.section.md +profiles/perlless.section.md profiles/minimal.section.md profiles/qemu-guest.section.md ``` diff --git a/nixos/doc/manual/configuration/profiles/perlless.section.md b/nixos/doc/manual/configuration/profiles/perlless.section.md new file mode 100644 index 0000000000000..bf055971cfc42 --- /dev/null +++ b/nixos/doc/manual/configuration/profiles/perlless.section.md @@ -0,0 +1,11 @@ +# Perlless {#sec-perlless} + +::: {.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! +::: + +Render your system completely perlless (i.e. without the perl interpreter). This +includes a mechanism so that your build fails if it contains a Nix store path +that references the string "perl". diff --git a/nixos/doc/manual/configuration/user-mgmt.chapter.md b/nixos/doc/manual/configuration/user-mgmt.chapter.md index b35b38f6e964a..71d61ce4c641b 100644 --- a/nixos/doc/manual/configuration/user-mgmt.chapter.md +++ b/nixos/doc/manual/configuration/user-mgmt.chapter.md @@ -89,3 +89,18 @@ A user can be deleted using `userdel`: The flag `-r` deletes the user's home directory. Accounts can be modified using `usermod`. Unix groups can be managed using `groupadd`, `groupmod` and `groupdel`. + +## Create users and groups with `systemd-sysusers` {#sec-systemd-sysusers} + +::: {.note} +This is experimental. +::: + +Instead of using a custom perl script to create users and groups, you can use +systemd-sysusers: + +```nix +systemd.sysusers.enable = true; +``` + +The primary benefit of this is to remove a dependency on perl. diff --git a/nixos/doc/manual/development/etc-overlay.section.md b/nixos/doc/manual/development/etc-overlay.section.md new file mode 100644 index 0000000000000..e6f6d8d4ca1ef --- /dev/null +++ b/nixos/doc/manual/development/etc-overlay.section.md @@ -0,0 +1,36 @@ +# `/etc` via overlay filesystem {#sec-etc-overlay} + +::: {.note} +This is experimental and requires a kernel version >= 6.6 because it uses +new overlay features and relies on the new mount API. +::: + +Instead of using a custom perl script to activate `/etc`, you activate it via an +overlay filesystem: + +```nix +system.etc.overlay.enable = true; +``` + +Using an overlay has two benefits: + +1. it removes a dependency on perl +2. it makes activation faster (up to a few seconds) + +By default, the `/etc` overlay is mounted writable (i.e. there is a writable +upper layer). However, you can also mount `/etc` immutably (i.e. read-only) by +setting: + +```nix +system.etc.overlay.mutable = false; +``` + +The overlay is atomically replaced during system switch. However, files that +have been modified will NOT be overwritten. This is the biggest change compared +to the perl-based system. + +If you manually make changes to `/etc` on your system and then switch to a new +configuration where `system.etc.overlay.mutable = false;`, you will not be able +to see the previously made changes in `/etc` anymore. However the changes are +not completely gone, they are still in the upperdir of the previous overlay in +`/.rw-etc/upper`. diff --git a/nixos/doc/manual/development/what-happens-during-a-system-switch.chapter.md b/nixos/doc/manual/development/what-happens-during-a-system-switch.chapter.md index 5d17a9c98514c..28c06f999dac2 100644 --- a/nixos/doc/manual/development/what-happens-during-a-system-switch.chapter.md +++ b/nixos/doc/manual/development/what-happens-during-a-system-switch.chapter.md @@ -56,4 +56,5 @@ explained in the next sections. unit-handling.section.md activation-script.section.md non-switchable-systems.section.md +etc-overlay.section.md ``` diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md index f4434fd6b94ca..790b54fdd4ccf 100644 --- a/nixos/doc/manual/release-notes/rl-2405.section.md +++ b/nixos/doc/manual/release-notes/rl-2405.section.md @@ -18,6 +18,22 @@ In addition to numerous new and upgraded packages, this release has the followin - Julia environments can now be built with arbitrary packages from the ecosystem using the `.withPackages` function. For example: `julia.withPackages ["Plots"]`. +- A new option `systemd.sysusers.enable` was added. If enabled, users and + groups are created with systemd-sysusers instead of with a custom perl script. + +- A new option `system.etc.overlay.enable` was added. If enabled, `/etc` is + mounted via an overlayfs instead of being created by a custom perl script. + +- It is now possible to have a completely perlless system (i.e. a system + without perl). Previously, the NixOS activation depended on two perl scripts + which can now be replaced via an opt-in mechanism. To make your system + perlless, you can use the new perlless profile: + ``` + { modulesPath, ... }: { + imports = [ "${modulesPath}/profiles/perlless.nix" ]; + } + ``` + ## New Services {#sec-release-24.05-new-services} <!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. --> diff --git a/nixos/modules/config/users-groups.nix b/nixos/modules/config/users-groups.nix index 2aed620eb154c..967ad0846d75b 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 00e6240f531d5..b31154b9624eb 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 0000000000000..90abd14f077e4 --- /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/programs/ssh.nix b/nixos/modules/programs/ssh.nix index c39a3c8d509be..0c1461709c22b 100644 --- a/nixos/modules/programs/ssh.nix +++ b/nixos/modules/programs/ssh.nix @@ -12,6 +12,7 @@ let '' #! ${pkgs.runtimeShell} -e export DISPLAY="$(systemctl --user show-environment | ${pkgs.gnused}/bin/sed 's/^DISPLAY=\(.*\)/\1/; t; d')" + export XAUTHORITY="$(systemctl --user show-environment | ${pkgs.gnused}/bin/sed 's/^XAUTHORITY=\(.*\)/\1/; t; d')" export WAYLAND_DISPLAY="$(systemctl --user show-environment | ${pkgs.gnused}/bin/sed 's/^WAYLAND_DISPLAY=\(.*\)/\1/; t; d')" exec ${cfg.askPassword} "$@" ''; diff --git a/nixos/modules/services/networking/bird.nix b/nixos/modules/services/networking/bird.nix index 9deeb7694d2ac..e25f5c7b03794 100644 --- a/nixos/modules/services/networking/bird.nix +++ b/nixos/modules/services/networking/bird.nix @@ -18,6 +18,13 @@ in <http://bird.network.cz/> ''; }; + autoReload = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Whether bird2 should be automatically reloaded when the configuration changes. + ''; + }; checkConfig = mkOption { type = types.bool; default = true; @@ -68,7 +75,7 @@ in systemd.services.bird2 = { description = "BIRD Internet Routing Daemon"; wantedBy = [ "multi-user.target" ]; - reloadTriggers = [ config.environment.etc."bird/bird2.conf".source ]; + reloadTriggers = lib.optional cfg.autoReload config.environment.etc."bird/bird2.conf".source; serviceConfig = { Type = "forking"; Restart = "on-failure"; diff --git a/nixos/modules/services/system/dbus.nix b/nixos/modules/services/system/dbus.nix index b47ebc92f93a8..e8f8b48d0337f 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/services/web-servers/ttyd.nix b/nixos/modules/services/web-servers/ttyd.nix index 3b1d87ccb483e..e545869ca4320 100644 --- a/nixos/modules/services/web-servers/ttyd.nix +++ b/nixos/modules/services/web-servers/ttyd.nix @@ -180,10 +180,11 @@ in # Runs login which needs to be run as root # login: Cannot possibly work without effective root User = "root"; + LoadCredential = lib.optionalString (cfg.passwordFile != null) "TTYD_PASSWORD_FILE:${cfg.passwordFile}"; }; script = if cfg.passwordFile != null then '' - PASSWORD=$(cat ${escapeShellArg cfg.passwordFile}) + PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/TTYD_PASSWORD_FILE") ${pkgs.ttyd}/bin/ttyd ${lib.escapeShellArgs args} \ --credential ${escapeShellArg cfg.username}:"$PASSWORD" \ ${pkgs.shadow}/bin/login 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/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 ''; + }; } diff --git a/nixos/modules/testing/test-instrumentation.nix b/nixos/modules/testing/test-instrumentation.nix index 9ee77cd79a9b1..6aa718c1975d7 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; diff --git a/nixos/tests/activation/etc-overlay-immutable.nix b/nixos/tests/activation/etc-overlay-immutable.nix new file mode 100644 index 0000000000000..70c3623b929c5 --- /dev/null +++ b/nixos/tests/activation/etc-overlay-immutable.nix @@ -0,0 +1,30 @@ +{ lib, ... }: { + + name = "activation-etc-overlay-immutable"; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + + nodes.machine = { pkgs, ... }: { + system.etc.overlay.enable = true; + system.etc.overlay.mutable = false; + + # Prerequisites + systemd.sysusers.enable = true; + users.mutableUsers = false; + boot.initrd.systemd.enable = true; + boot.kernelPackages = pkgs.linuxPackages_latest; + + specialisation.new-generation.configuration = { + environment.etc."newgen".text = "newgen"; + }; + }; + + testScript = '' + machine.succeed("findmnt --kernel --type overlay /etc") + machine.fail("stat /etc/newgen") + + machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch") + + assert machine.succeed("cat /etc/newgen") == "newgen" + ''; +} diff --git a/nixos/tests/activation/etc-overlay-mutable.nix b/nixos/tests/activation/etc-overlay-mutable.nix new file mode 100644 index 0000000000000..cfe7604fceb84 --- /dev/null +++ b/nixos/tests/activation/etc-overlay-mutable.nix @@ -0,0 +1,30 @@ +{ lib, ... }: { + + name = "activation-etc-overlay-mutable"; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + + nodes.machine = { pkgs, ... }: { + system.etc.overlay.enable = true; + system.etc.overlay.mutable = true; + + # Prerequisites + boot.initrd.systemd.enable = true; + boot.kernelPackages = pkgs.linuxPackages_latest; + + specialisation.new-generation.configuration = { + environment.etc."newgen".text = "newgen"; + }; + }; + + testScript = '' + machine.succeed("findmnt --kernel --type overlay /etc") + machine.fail("stat /etc/newgen") + machine.succeed("echo -n 'mutable' > /etc/mutable") + + machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch") + + assert machine.succeed("cat /etc/newgen") == "newgen" + assert machine.succeed("cat /etc/mutable") == "mutable" + ''; +} diff --git a/nixos/tests/activation/perlless.nix b/nixos/tests/activation/perlless.nix new file mode 100644 index 0000000000000..4d784b4542f45 --- /dev/null +++ b/nixos/tests/activation/perlless.nix @@ -0,0 +1,24 @@ +{ lib, ... }: + +{ + + name = "activation-perlless"; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + + nodes.machine = { pkgs, modulesPath, ... }: { + imports = [ "${modulesPath}/profiles/perlless.nix" ]; + + boot.kernelPackages = pkgs.linuxPackages_latest; + + virtualisation.mountHostNixStore = false; + virtualisation.useNixStoreImage = true; + }; + + testScript = '' + perl_store_paths = machine.succeed("ls /nix/store | grep perl || true") + print(perl_store_paths) + assert len(perl_store_paths) == 0 + ''; + +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 9e27969190f75..4b6696e4fdbf3 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -285,6 +285,9 @@ in { activation = pkgs.callPackage ../modules/system/activation/test.nix { }; activation-var = runTest ./activation/var.nix; activation-nix-channel = runTest ./activation/nix-channel.nix; + activation-etc-overlay-mutable = runTest ./activation/etc-overlay-mutable.nix; + activation-etc-overlay-immutable = runTest ./activation/etc-overlay-immutable.nix; + activation-perlless = runTest ./activation/perlless.nix; etcd = handleTestOn ["x86_64-linux"] ./etcd.nix {}; etcd-cluster = handleTestOn ["x86_64-linux"] ./etcd-cluster.nix {}; etebase-server = handleTest ./etebase-server.nix {}; @@ -866,6 +869,8 @@ in { systemd-repart = handleTest ./systemd-repart.nix {}; systemd-shutdown = handleTest ./systemd-shutdown.nix {}; systemd-sysupdate = runTest ./systemd-sysupdate.nix; + systemd-sysusers-mutable = runTest ./systemd-sysusers-mutable.nix; + systemd-sysusers-immutable = runTest ./systemd-sysusers-immutable.nix; systemd-timesyncd = handleTest ./systemd-timesyncd.nix {}; systemd-timesyncd-nscd-dnssec = handleTest ./systemd-timesyncd-nscd-dnssec.nix {}; systemd-user-tmpfiles-rules = handleTest ./systemd-user-tmpfiles-rules.nix {}; @@ -905,6 +910,7 @@ in { trilium-server = handleTestOn ["x86_64-linux"] ./trilium-server.nix {}; tsja = handleTest ./tsja.nix {}; tsm-client-gui = handleTest ./tsm-client-gui.nix {}; + ttyd = handleTest ./web-servers/ttyd.nix {}; txredisapi = handleTest ./txredisapi.nix {}; tuptime = handleTest ./tuptime.nix {}; turbovnc-headless-server = handleTest ./turbovnc-headless-server.nix {}; diff --git a/nixos/tests/systemd-sysusers-immutable.nix b/nixos/tests/systemd-sysusers-immutable.nix new file mode 100644 index 0000000000000..42cbf84d175e4 --- /dev/null +++ b/nixos/tests/systemd-sysusers-immutable.nix @@ -0,0 +1,64 @@ +{ lib, ... }: + +let + rootPassword = "$y$j9T$p6OI0WN7.rSfZBOijjRdR.$xUOA2MTcB48ac.9Oc5fz8cxwLv1mMqabnn333iOzSA6"; + normaloPassword = "$y$j9T$3aiOV/8CADAK22OK2QT3/0$67OKd50Z4qTaZ8c/eRWHLIM.o3ujtC1.n9ysmJfv639"; + newNormaloPassword = "mellow"; +in + +{ + + name = "activation-sysusers-immutable"; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + + nodes.machine = { + systemd.sysusers.enable = true; + users.mutableUsers = false; + + # Override the empty root password set by the test instrumentation + users.users.root.hashedPasswordFile = lib.mkForce null; + users.users.root.initialHashedPassword = rootPassword; + users.users.normalo = { + isNormalUser = true; + initialHashedPassword = normaloPassword; + }; + + specialisation.new-generation.configuration = { + users.users.new-normalo = { + isNormalUser = true; + initialPassword = newNormaloPassword; + }; + }; + }; + + testScript = '' + with subtest("Users are not created with systemd-sysusers"): + machine.fail("systemctl status systemd-sysusers.service") + machine.fail("ls /etc/sysusers.d") + + with subtest("Correct mode on the password files"): + assert machine.succeed("stat -c '%a' /etc/passwd") == "644\n" + assert machine.succeed("stat -c '%a' /etc/group") == "644\n" + assert machine.succeed("stat -c '%a' /etc/shadow") == "0\n" + assert machine.succeed("stat -c '%a' /etc/gshadow") == "0\n" + + with subtest("root user has correct password"): + print(machine.succeed("getent passwd root")) + assert "${rootPassword}" in machine.succeed("getent shadow root"), "root user password is not correct" + + with subtest("normalo user is created"): + print(machine.succeed("getent passwd normalo")) + assert machine.succeed("stat -c '%U' /home/normalo") == "normalo\n" + assert "${normaloPassword}" in machine.succeed("getent shadow normalo"), "normalo user password is not correct" + + + machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch") + + + with subtest("new-normalo user is created after switching to new generation"): + print(machine.succeed("getent passwd new-normalo")) + print(machine.succeed("getent shadow new-normalo")) + assert machine.succeed("stat -c '%U' /home/new-normalo") == "new-normalo\n" + ''; +} diff --git a/nixos/tests/systemd-sysusers-mutable.nix b/nixos/tests/systemd-sysusers-mutable.nix new file mode 100644 index 0000000000000..e69cfe23a59a1 --- /dev/null +++ b/nixos/tests/systemd-sysusers-mutable.nix @@ -0,0 +1,71 @@ +{ lib, ... }: + +let + rootPassword = "$y$j9T$p6OI0WN7.rSfZBOijjRdR.$xUOA2MTcB48ac.9Oc5fz8cxwLv1mMqabnn333iOzSA6"; + normaloPassword = "hello"; + newNormaloPassword = "$y$j9T$p6OI0WN7.rSfZBOijjRdR.$xUOA2MTcB48ac.9Oc5fz8cxwLv1mMqabnn333iOzSA6"; +in + +{ + + name = "activation-sysusers-mutable"; + + meta.maintainers = with lib.maintainers; [ nikstur ]; + + nodes.machine = { pkgs, ... }: { + systemd.sysusers.enable = true; + users.mutableUsers = true; + + # Prerequisites + system.etc.overlay.enable = true; + boot.initrd.systemd.enable = true; + boot.kernelPackages = pkgs.linuxPackages_latest; + + # Override the empty root password set by the test instrumentation + users.users.root.hashedPasswordFile = lib.mkForce null; + users.users.root.initialHashedPassword = rootPassword; + users.users.normalo = { + isNormalUser = true; + initialPassword = normaloPassword; + }; + + specialisation.new-generation.configuration = { + users.users.new-normalo = { + isNormalUser = true; + initialHashedPassword = newNormaloPassword; + }; + }; + }; + + testScript = '' + machine.wait_for_unit("systemd-sysusers.service") + + with subtest("systemd-sysusers.service contains the credentials"): + sysusers_service = machine.succeed("systemctl cat systemd-sysusers.service") + print(sysusers_service) + assert "SetCredential=passwd.plaintext-password.normalo:${normaloPassword}" in sysusers_service + + with subtest("Correct mode on the password files"): + assert machine.succeed("stat -c '%a' /etc/passwd") == "644\n" + assert machine.succeed("stat -c '%a' /etc/group") == "644\n" + assert machine.succeed("stat -c '%a' /etc/shadow") == "0\n" + assert machine.succeed("stat -c '%a' /etc/gshadow") == "0\n" + + with subtest("root user has correct password"): + print(machine.succeed("getent passwd root")) + assert "${rootPassword}" in machine.succeed("getent shadow root"), "root user password is not correct" + + with subtest("normalo user is created"): + print(machine.succeed("getent passwd normalo")) + assert machine.succeed("stat -c '%U' /home/normalo") == "normalo\n" + + + machine.succeed("/run/current-system/specialisation/new-generation/bin/switch-to-configuration switch") + + + with subtest("new-normalo user is created after switching to new generation"): + print(machine.succeed("getent passwd new-normalo")) + assert machine.succeed("stat -c '%U' /home/new-normalo") == "new-normalo\n" + assert "${newNormaloPassword}" in machine.succeed("getent shadow new-normalo"), "new-normalo user password is not correct" + ''; +} diff --git a/nixos/tests/web-servers/stargazer.nix b/nixos/tests/web-servers/stargazer.nix index 6365d6a4fff10..f56d1b8c94545 100644 --- a/nixos/tests/web-servers/stargazer.nix +++ b/nixos/tests/web-servers/stargazer.nix @@ -1,4 +1,41 @@ { pkgs, lib, ... }: +let + test_script = pkgs.stdenv.mkDerivation rec { + pname = "stargazer-test-script"; + inherit (pkgs.stargazer) version src; + buildInputs = with pkgs; [ (python3.withPackages (ps: with ps; [ cryptography ])) ]; + dontBuild = true; + doCheck = false; + installPhase = '' + mkdir -p $out/bin + cp scripts/gemini-diagnostics $out/bin/test + ''; + }; + test_env = pkgs.stdenv.mkDerivation rec { + pname = "stargazer-test-env"; + inherit (pkgs.stargazer) version src; + buildPhase = '' + cc test_data/cgi-bin/loop.c -o test_data/cgi-bin/loop + ''; + doCheck = false; + installPhase = '' + mkdir -p $out + cp -r * $out/ + ''; + }; + scgi_server = pkgs.stdenv.mkDerivation rec { + pname = "stargazer-test-scgi-server"; + inherit (pkgs.stargazer) version src; + buildInputs = with pkgs; [ python3 ]; + dontConfigure = true; + dontBuild = true; + doCheck = false; + installPhase = '' + mkdir -p $out/bin + cp scripts/scgi-server $out/bin/scgi-server + ''; + }; +in { name = "stargazer"; meta = with lib.maintainers; { maintainers = [ gaykitty ]; }; @@ -7,25 +44,84 @@ geminiserver = { pkgs, ... }: { services.stargazer = { enable = true; + connectionLogging = false; + requestTimeout = 1; routes = [ { route = "localhost"; - root = toString (pkgs.writeTextDir "index.gmi" '' - # Hello NixOS! - ''); + root = "${test_env}/test_data/test_site"; + } + { + route = "localhost=/en.gmi"; + root = "${test_env}/test_data/test_site"; + lang = "en"; + charset = "ascii"; + } + { + route = "localhost~/(.*).gemini"; + root = "${test_env}/test_data/test_site"; + rewrite = "\\1.gmi"; + lang = "en"; + charset = "ascii"; + } + { + route = "localhost=/plain.txt"; + root = "${test_env}/test_data/test_site"; + lang = "en"; + charset = "ascii"; + cert-path = "/var/lib/gemini/certs/localhost.crt"; + key-path = "/var/lib/gemini/certs/localhost.key"; + } + { + route = "localhost:/cgi-bin"; + root = "${test_env}/test_data"; + cgi = true; + cgi-timeout = 5; + } + { + route = "localhost:/scgi"; + scgi = true; + scgi-address = "127.0.0.1:1099"; + } + { + route = "localhost=/root"; + redirect = ".."; + permanent = true; + } + { + route = "localhost=/priv.gmi"; + root = "${test_env}/test_data/test_site"; + client-cert = "${test_env}/test_data/client_cert/good.crt"; + } + { + route = "example.com~(.*)"; + redirect = "gemini://localhost"; + rewrite = "\\1"; + } + { + route = "localhost:/no-exist"; + root = "./does_not_exist"; } ]; }; + systemd.services.scgi_server = { + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${scgi_server}/bin/scgi-server"; + }; + }; }; }; testScript = { nodes, ... }: '' + geminiserver.wait_for_unit("scgi_server") + geminiserver.wait_for_open_port(1099) geminiserver.wait_for_unit("stargazer") geminiserver.wait_for_open_port(1965) - with subtest("check is serving over gemini"): - response = geminiserver.succeed("${pkgs.gemget}/bin/gemget --header -o - gemini://localhost:1965") + with subtest("stargazer test suite"): + response = geminiserver.succeed("sh -c 'cd ${test_env}; ${test_script}/bin/test'") print(response) - assert "Hello NixOS!" in response ''; } diff --git a/nixos/tests/web-servers/ttyd.nix b/nixos/tests/web-servers/ttyd.nix new file mode 100644 index 0000000000000..d161673684b31 --- /dev/null +++ b/nixos/tests/web-servers/ttyd.nix @@ -0,0 +1,19 @@ +import ../make-test-python.nix ({ lib, pkgs, ... }: { + name = "ttyd"; + meta.maintainers = with lib.maintainers; [ stunkymonkey ]; + + nodes.machine = { pkgs, ... }: { + services.ttyd = { + enable = true; + username = "foo"; + passwordFile = pkgs.writeText "password" "bar"; + }; + }; + + testScript = '' + machine.wait_for_unit("ttyd.service") + machine.wait_for_open_port(7681) + response = machine.succeed("curl -vvv -u foo:bar -s -H 'Host: ttyd' http://127.0.0.1:7681/") + assert '<title>ttyd - Terminal</title>' in response, "Page didn't load successfully" + ''; +}) |