From e778520f714aac4f2fee751a6f8ab83f1d777c38 Mon Sep 17 00:00:00 2001 From: Majiir Paktu Date: Sat, 28 Jun 2025 19:50:59 -0400 Subject: [PATCH 01/33] nixos: use full path to PAM modules PAM rules with non-absolute module paths are rejected when apparmor is used. In general, it helps (aside from readability) for all the module paths to be absolute, especially when the user overrides the PAM package. --- nixos/modules/security/pam.nix | 16 ++++---- .../modules/services/display-managers/gdm.nix | 40 +++++++++---------- .../services/display-managers/sddm.nix | 24 +++++------ nixos/modules/services/networking/vsftpd.nix | 4 +- nixos/modules/services/wayland/cage.nix | 8 ++-- .../services/x11/display-managers/lightdm.nix | 32 +++++++-------- 6 files changed, 62 insertions(+), 62 deletions(-) diff --git a/nixos/modules/security/pam.nix b/nixos/modules/security/pam.nix index d568b32a45a3..15a889ac7e11 100644 --- a/nixos/modules/security/pam.nix +++ b/nixos/modules/security/pam.nix @@ -2532,14 +2532,14 @@ in security.pam.services = { other.text = '' - auth required pam_warn.so - auth required pam_deny.so - account required pam_warn.so - account required pam_deny.so - password required pam_warn.so - password required pam_deny.so - session required pam_warn.so - session required pam_deny.so + auth required ${package}/lib/security/pam_warn.so + auth required ${package}/lib/security/pam_deny.so + account required ${package}/lib/security/pam_warn.so + account required ${package}/lib/security/pam_deny.so + password required ${package}/lib/security/pam_warn.so + password required ${package}/lib/security/pam_deny.so + session required ${package}/lib/security/pam_warn.so + session required ${package}/lib/security/pam_deny.so ''; # Most of these should be moved to specific modules. diff --git a/nixos/modules/services/display-managers/gdm.nix b/nixos/modules/services/display-managers/gdm.nix index 62b90efa92b9..18998ddeb34f 100644 --- a/nixos/modules/services/display-managers/gdm.nix +++ b/nixos/modules/services/display-managers/gdm.nix @@ -384,19 +384,19 @@ in # GDM LFS PAM modules, adapted somehow to NixOS security.pam.services = { gdm-launch-environment.text = '' - auth required pam_succeed_if.so audit quiet_success user ingroup gdm - auth optional pam_permit.so + auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user ingroup gdm + auth optional ${config.security.pam.package}/lib/security/pam_permit.so - account required pam_succeed_if.so audit quiet_success user ingroup gdm - account sufficient pam_unix.so + account required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user ingroup gdm + account sufficient ${config.security.pam.package}/lib/security/pam_unix.so - password required pam_deny.so + password required ${config.security.pam.package}/lib/security/pam_deny.so - session required pam_succeed_if.so audit quiet_success user ingroup gdm - session required pam_env.so conffile=/etc/pam/environment readenv=0 + session required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user ingroup gdm + session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 session optional ${config.systemd.package}/lib/security/pam_systemd.so - session optional pam_keyinit.so force revoke - session optional pam_permit.so + session optional ${config.security.pam.package}/lib/security/pam_keyinit.so force revoke + session optional ${config.security.pam.package}/lib/security/pam_permit.so ''; gdm-password.text = '' @@ -407,19 +407,19 @@ in ''; gdm-autologin.text = '' - auth requisite pam_nologin.so - auth required pam_succeed_if.so uid >= 1000 quiet + auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so + auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so uid >= 1000 quiet ${lib.optionalString (pamLogin.enable && pamLogin.enableGnomeKeyring) '' auth [success=ok default=1] ${gdm}/lib/security/pam_gdm.so auth optional ${pkgs.gnome-keyring}/lib/security/pam_gnome_keyring.so ''} - auth required pam_permit.so + auth required ${config.security.pam.package}/lib/security/pam_permit.so - account sufficient pam_unix.so + account sufficient ${config.security.pam.package}/lib/security/pam_unix.so - password requisite pam_unix.so nullok yescrypt + password requisite ${config.security.pam.package}/lib/security/pam_unix.so nullok yescrypt - session optional pam_keyinit.so revoke + session optional ${config.security.pam.package}/lib/security/pam_keyinit.so revoke session include login ''; @@ -428,11 +428,11 @@ in login.fprintAuth = lib.mkIf config.services.fprintd.enable false; gdm-fingerprint.text = lib.mkIf config.services.fprintd.enable '' - auth required pam_shells.so - auth requisite pam_nologin.so - auth requisite pam_faillock.so preauth + auth required ${config.security.pam.package}/lib/security/pam_shells.so + auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so + auth requisite ${config.security.pam.package}/lib/security/pam_faillock.so preauth auth required ${pkgs.fprintd}/lib/security/pam_fprintd.so - auth required pam_env.so conffile=/etc/pam/environment readenv=0 + auth required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 ${lib.optionalString (pamLogin.enable && pamLogin.enableGnomeKeyring) '' auth [success=ok default=1] ${gdm}/lib/security/pam_gdm.so auth optional ${pkgs.gnome-keyring}/lib/security/pam_gnome_keyring.so @@ -440,7 +440,7 @@ in account include login - password required pam_deny.so + password required ${config.security.pam.package}/lib/security/pam_deny.so session include login ''; diff --git a/nixos/modules/services/display-managers/sddm.nix b/nixos/modules/services/display-managers/sddm.nix index b960c7e63d8d..25f63007f9ac 100644 --- a/nixos/modules/services/display-managers/sddm.nix +++ b/nixos/modules/services/display-managers/sddm.nix @@ -376,25 +376,25 @@ in ''; sddm-greeter.text = '' - auth required pam_succeed_if.so audit quiet_success user = sddm - auth optional pam_permit.so + auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = sddm + auth optional ${config.security.pam.package}/lib/security/pam_permit.so - account required pam_succeed_if.so audit quiet_success user = sddm - account sufficient pam_unix.so + account required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = sddm + account sufficient ${config.security.pam.package}/lib/security/pam_unix.so - password required pam_deny.so + password required ${config.security.pam.package}/lib/security/pam_deny.so - session required pam_succeed_if.so audit quiet_success user = sddm - session required pam_env.so conffile=/etc/pam/environment readenv=0 + session required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = sddm + session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 session optional ${config.systemd.package}/lib/security/pam_systemd.so - session optional pam_keyinit.so force revoke - session optional pam_permit.so + session optional ${config.security.pam.package}/lib/security/pam_keyinit.so force revoke + session optional ${config.security.pam.package}/lib/security/pam_permit.so ''; sddm-autologin.text = '' - auth requisite pam_nologin.so - auth required pam_succeed_if.so uid >= ${toString cfg.autoLogin.minimumUid} quiet - auth required pam_permit.so + auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so + auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so uid >= ${toString cfg.autoLogin.minimumUid} quiet + auth required ${config.security.pam.package}/lib/security/pam_permit.so account include sddm diff --git a/nixos/modules/services/networking/vsftpd.nix b/nixos/modules/services/networking/vsftpd.nix index 3a129f1a610e..070f77115829 100644 --- a/nixos/modules/services/networking/vsftpd.nix +++ b/nixos/modules/services/networking/vsftpd.nix @@ -334,8 +334,8 @@ in }; security.pam.services.vsftpd.text = mkIf (cfg.enableVirtualUsers && cfg.userDbPath != null) '' - auth required pam_userdb.so db=${cfg.userDbPath} - account required pam_userdb.so db=${cfg.userDbPath} + auth required ${config.security.pam.package}/lib/security/pam_userdb.so db=${cfg.userDbPath} + account required ${config.security.pam.package}/lib/security/pam_userdb.so db=${cfg.userDbPath} ''; }; } diff --git a/nixos/modules/services/wayland/cage.nix b/nixos/modules/services/wayland/cage.nix index e39e31ef6272..76731622d50a 100644 --- a/nixos/modules/services/wayland/cage.nix +++ b/nixos/modules/services/wayland/cage.nix @@ -106,10 +106,10 @@ in security.polkit.enable = true; security.pam.services.cage.text = '' - auth required pam_unix.so nullok - account required pam_unix.so - session required pam_unix.so - session required pam_env.so conffile=/etc/pam/environment readenv=0 + auth required ${config.security.pam.package}/lib/security/pam_unix.so nullok + account required ${config.security.pam.package}/lib/security/pam_unix.so + session required ${config.security.pam.package}/lib/security/pam_unix.so + session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 session required ${config.systemd.package}/lib/security/pam_systemd.so ''; diff --git a/nixos/modules/services/x11/display-managers/lightdm.nix b/nixos/modules/services/x11/display-managers/lightdm.nix index 467207ecd92b..b0e757b21f5f 100644 --- a/nixos/modules/services/x11/display-managers/lightdm.nix +++ b/nixos/modules/services/x11/display-managers/lightdm.nix @@ -288,36 +288,36 @@ in # https://github.com/elementary/switchboard-plug-parental-controls/blob/8.0.1/src/daemon/Server.vala#L325 # Must specify conffile since pam_time defaults to ${linux-pam}/etc/security/time.conf. + lib.optionalString config.services.pantheon.parental-controls.enable '' - account required pam_time.so conffile=/etc/security/time.conf + account required ${config.security.pam.package}/lib/security/pam_time.so conffile=/etc/security/time.conf ''; security.pam.services.lightdm-greeter.text = '' - auth required pam_succeed_if.so audit quiet_success user = lightdm - auth optional pam_permit.so + auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = lightdm + auth optional ${config.security.pam.package}/lib/security/pam_permit.so - account required pam_succeed_if.so audit quiet_success user = lightdm - account sufficient pam_unix.so + account required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = lightdm + account sufficient ${config.security.pam.package}/lib/security/pam_unix.so - password required pam_deny.so + password required ${config.security.pam.package}/lib/security/pam_deny.so - session required pam_succeed_if.so audit quiet_success user = lightdm - session required pam_env.so conffile=/etc/pam/environment readenv=0 + session required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = lightdm + session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 session optional ${config.systemd.package}/lib/security/pam_systemd.so - session optional pam_keyinit.so force revoke - session optional pam_permit.so + session optional ${config.security.pam.package}/lib/security/pam_keyinit.so force revoke + session optional ${config.security.pam.package}/lib/security/pam_permit.so ''; security.pam.services.lightdm-autologin.text = '' - auth requisite pam_nologin.so + auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so - auth required pam_succeed_if.so uid >= 1000 quiet - auth required pam_permit.so + auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so uid >= 1000 quiet + auth required ${config.security.pam.package}/lib/security/pam_permit.so - account sufficient pam_unix.so + account sufficient ${config.security.pam.package}/lib/security/pam_unix.so - password requisite pam_unix.so nullok yescrypt + password requisite ${config.security.pam.package}/lib/security/pam_unix.so nullok yescrypt - session optional pam_keyinit.so revoke + session optional ${config.security.pam.package}/lib/security/pam_keyinit.so revoke session include login ''; From 6954501f5310e922889ccd935120ced246b0e84d Mon Sep 17 00:00:00 2001 From: Majiir Paktu Date: Sat, 28 Jun 2025 14:21:11 -0400 Subject: [PATCH 02/33] nixos/pam: add useDefaultRules option This option is enabled by default to preserve the current behavior when a new service is declared. Users may disable this option to more easily create a service without any rules. In nixpkgs, we can use this option to eliminate usage of the 'text' option where the entire service rule stack is replaced. --- nixos/modules/security/pam.nix | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/nixos/modules/security/pam.nix b/nixos/modules/security/pam.nix index 15a889ac7e11..720f2229d084 100644 --- a/nixos/modules/security/pam.nix +++ b/nixos/modules/security/pam.nix @@ -172,6 +172,28 @@ let }; }; + useDefaultRules = lib.mkOption { + # This option is experimental and subject to breaking changes without notice. + visible = false; + default = true; + type = lib.types.bool; + description = '' + Whether to set up the default NixOS rule stack for this service. + + Set this to `false` if you want to define the entire rule stack for this service. + + ::: {.warning} + This option is experimental and subject to breaking changes without notice. + + If you use this option in your system configuration, you will need to manually monitor this module for any changes. Otherwise, failure to adjust your configuration properly could lead to you being locked out of your system, or worse, your system could be left wide open to attackers. + + If you share configuration examples that use this option, you MUST include this warning so that users are informed. + + You may freely use this option within `nixpkgs`, and future changes will account for those use sites. + ::: + ''; + }; + unixAuth = lib.mkOption { default = true; type = lib.types.bool; @@ -884,7 +906,7 @@ let lib.listToAttrs ]; in - { + lib.optionalAttrs cfg.useDefaultRules { account = autoOrderRules [ { name = "ldap"; From a6144954c6283ac14692579a6c746afe3fedf233 Mon Sep 17 00:00:00 2001 From: Majiir Paktu Date: Sat, 3 Jan 2026 23:32:09 -0500 Subject: [PATCH 03/33] nixos/pam: add assertion for autoOrderRules --- nixos/modules/security/pam.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nixos/modules/security/pam.nix b/nixos/modules/security/pam.nix index 720f2229d084..ff996943efa1 100644 --- a/nixos/modules/security/pam.nix +++ b/nixos/modules/security/pam.nix @@ -901,7 +901,11 @@ let rules = let autoOrderRules = lib.flip lib.pipe [ - (lib.imap1 (index: rule: rule // { order = lib.mkDefault (10000 + index * 100); })) + (lib.imap1 ( + index: rule: + assert lib.assertMsg (!rule ? order) "the 'order' option may not be set when using autoOrderRules"; + rule // { order = lib.mkDefault (10000 + index * 100); } + )) (map (rule: lib.nameValuePair rule.name (removeAttrs rule [ "name" ]))) lib.listToAttrs ]; From ab27ce1f9647882076f3c7efa17d4d0bdce9be9b Mon Sep 17 00:00:00 2001 From: Majiir Paktu Date: Sat, 28 Jun 2025 17:28:12 -0400 Subject: [PATCH 04/33] nixos/pam: extract autoOrderRules to utils This function is used to convert an ordered list of rules into an attrset of rules with reasonable 'order' values. This reduces boilerplate to define 'order' and makes it simple to switch how ordering is managed in the future. --- nixos/lib/utils.nix | 27 +++++++++++++++++++++++++++ nixos/modules/security/pam.nix | 25 ++++++++----------------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/nixos/lib/utils.nix b/nixos/lib/utils.nix index 0a1c2fe50cdd..c716e7f98a59 100644 --- a/nixos/lib/utils.nix +++ b/nixos/lib/utils.nix @@ -541,6 +541,33 @@ let - https://github.com/qemu/qemu/blob/master/scripts/qemu-binfmt-conf.sh */ binfmtMagics = import ./binfmt-magics.nix; + + # Utilities for working with the security.pam module (pam.nix) + pam = { + /* + Set up the ordering for a set of PAM rules using an ordered list of rules. + + The input is an ordered list of PAM rules. Each rule is an attrset similar to the options + in `security.pam.services..rules.`, with two modifications: + + 1. The `order` option may not be given. + 2. The `name` option is required. + + The output is an attrset of rules suitable for `security.pam.services..rules`. + + The `order` option on the resulting rules will automatically be configured according to the + (implied) ordering of the input rules. + */ + autoOrderRules = lib.flip lib.pipe [ + (lib.imap1 ( + index: rule: + assert lib.assertMsg (!rule ? order) "the 'order' option may not be set when using autoOrderRules"; + rule // { order = lib.mkDefault (10000 + index * 100); } + )) + (map (rule: lib.nameValuePair rule.name (removeAttrs rule [ "name" ]))) + lib.listToAttrs + ]; + }; }; in utils diff --git a/nixos/modules/security/pam.nix b/nixos/modules/security/pam.nix index ff996943efa1..a9c2d5fe6413 100644 --- a/nixos/modules/security/pam.nix +++ b/nixos/modules/security/pam.nix @@ -3,6 +3,7 @@ { config, lib, + utils, pkgs, ... }: @@ -898,20 +899,9 @@ let # !!! TODO: move the LDAP stuff to the LDAP module, and the # Samba stuff to the Samba module. This requires that the PAM # module provides the right hooks. - rules = - let - autoOrderRules = lib.flip lib.pipe [ - (lib.imap1 ( - index: rule: - assert lib.assertMsg (!rule ? order) "the 'order' option may not be set when using autoOrderRules"; - rule // { order = lib.mkDefault (10000 + index * 100); } - )) - (map (rule: lib.nameValuePair rule.name (removeAttrs rule [ "name" ]))) - lib.listToAttrs - ]; - in + rules = ( lib.optionalAttrs cfg.useDefaultRules { - account = autoOrderRules [ + account = utils.pam.autoOrderRules [ { name = "ldap"; enable = use_ldap; @@ -992,7 +982,7 @@ let ]; - auth = autoOrderRules ( + auth = utils.pam.autoOrderRules ( [ { name = "oslogin_login"; @@ -1365,7 +1355,7 @@ let ] ); - password = autoOrderRules [ + password = utils.pam.autoOrderRules [ { name = "systemd_home"; enable = config.services.homed.enable; @@ -1450,7 +1440,7 @@ let } ]; - session = autoOrderRules [ + session = utils.pam.autoOrderRules [ { name = "env"; enable = cfg.setEnvironment; @@ -1682,7 +1672,8 @@ let modulePath = "${pkgs.intune-portal}/lib/security/pam_intune.so"; } ]; - }; + } + ); }; }; From 4b864991aa141958fc701e4925527fbf45ff1bf0 Mon Sep 17 00:00:00 2001 From: Majiir Paktu Date: Sat, 28 Jun 2025 19:53:19 -0400 Subject: [PATCH 05/33] nixos: replace 'text' with structured PAM rules Several modules define whole PAM service rule stacks by overwriting the default value of the 'text' option. Instead, we disable useDefaultRules for these services and declare a new set of rules using the 'rules' option. This option is considered experimental and hidden from users, but it is supported for use within nixpkgs. --- nixos/modules/security/pam.nix | 34 +- .../modules/services/display-managers/gdm.nix | 304 +++++++++++++++--- .../modules/services/display-managers/ly.nix | 58 +++- .../display-managers/plasma-login-manager.nix | 149 +++++++-- .../services/display-managers/sddm.nix | 197 ++++++++++-- nixos/modules/services/networking/vsftpd.nix | 23 +- nixos/modules/services/wayland/cage.nix | 47 ++- .../services/x11/display-managers/lightdm.nix | 221 +++++++++++-- 8 files changed, 873 insertions(+), 160 deletions(-) diff --git a/nixos/modules/security/pam.nix b/nixos/modules/security/pam.nix index a9c2d5fe6413..2c6b3419bf2f 100644 --- a/nixos/modules/security/pam.nix +++ b/nixos/modules/security/pam.nix @@ -2548,16 +2548,30 @@ in }; security.pam.services = { - other.text = '' - auth required ${package}/lib/security/pam_warn.so - auth required ${package}/lib/security/pam_deny.so - account required ${package}/lib/security/pam_warn.so - account required ${package}/lib/security/pam_deny.so - password required ${package}/lib/security/pam_warn.so - password required ${package}/lib/security/pam_deny.so - session required ${package}/lib/security/pam_warn.so - session required ${package}/lib/security/pam_deny.so - ''; + other = { + useDefaultRules = false; + rules = + let + rules = utils.pam.autoOrderRules [ + { + name = "warn"; + control = "required"; + modulePath = "${package}/lib/security/pam_warn.so"; + } + { + name = "deny"; + control = "required"; + modulePath = "${package}/lib/security/pam_deny.so"; + } + ]; + in + { + auth = rules; + account = rules; + password = rules; + session = rules; + }; + }; # Most of these should be moved to specific modules. i3lock.enable = lib.mkDefault config.programs.i3lock.enable; diff --git a/nixos/modules/services/display-managers/gdm.nix b/nixos/modules/services/display-managers/gdm.nix index 18998ddeb34f..6de21115eb2a 100644 --- a/nixos/modules/services/display-managers/gdm.nix +++ b/nixos/modules/services/display-managers/gdm.nix @@ -1,6 +1,7 @@ { config, lib, + utils, pkgs, ... }: @@ -383,67 +384,280 @@ in # GDM LFS PAM modules, adapted somehow to NixOS security.pam.services = { - gdm-launch-environment.text = '' - auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user ingroup gdm - auth optional ${config.security.pam.package}/lib/security/pam_permit.so + gdm-launch-environment = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "gdm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "ingroup" + "gdm" + ]; + } + { + name = "permit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; - account required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user ingroup gdm - account sufficient ${config.security.pam.package}/lib/security/pam_unix.so + account = utils.pam.autoOrderRules [ + { + name = "gdm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "ingroup" + "gdm" + ]; + } + { + name = "unix"; + control = "sufficient"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + ]; - password required ${config.security.pam.package}/lib/security/pam_deny.so + password = utils.pam.autoOrderRules [ + { + name = "deny"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_deny.so"; + } + ]; - session required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user ingroup gdm - session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 - session optional ${config.systemd.package}/lib/security/pam_systemd.so - session optional ${config.security.pam.package}/lib/security/pam_keyinit.so force revoke - session optional ${config.security.pam.package}/lib/security/pam_permit.so - ''; + session = utils.pam.autoOrderRules [ + { + name = "gdm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "ingroup" + "gdm" + ]; + } + { + name = "env"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_env.so"; + settings.conffile = "/etc/pam/environment"; + settings.readenv = 0; + } + { + name = "systemd"; + control = "optional"; + modulePath = "${config.systemd.package}/lib/security/pam_systemd.so"; + } + { + name = "keyinit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_keyinit.so"; + settings.force = true; + settings.revoke = true; + } + { + name = "permit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; + }; + }; - gdm-password.text = '' - auth substack login - account include login - password substack login - session include login - ''; + gdm-password = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + account = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + password = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + session = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; - gdm-autologin.text = '' - auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so - auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so uid >= 1000 quiet - ${lib.optionalString (pamLogin.enable && pamLogin.enableGnomeKeyring) '' - auth [success=ok default=1] ${gdm}/lib/security/pam_gdm.so - auth optional ${pkgs.gnome-keyring}/lib/security/pam_gnome_keyring.so - ''} - auth required ${config.security.pam.package}/lib/security/pam_permit.so + gdm-autologin = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "nologin"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_nologin.so"; + } + { + name = "gdm-normal-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.quiet = true; + args = lib.mkBefore [ + "uid" + ">=" + "1000" + ]; + } + { + name = "gdm"; + enable = pamLogin.enable && pamLogin.enableGnomeKeyring; + control = "[success=ok default=1]"; + modulePath = "${gdm}/lib/security/pam_gdm.so"; + } + { + name = "gnome_keyring"; + enable = pamLogin.enable && pamLogin.enableGnomeKeyring; + control = "optional"; + modulePath = "${pkgs.gnome-keyring}/lib/security/pam_gnome_keyring.so"; + } + { + name = "permit"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; - account sufficient ${config.security.pam.package}/lib/security/pam_unix.so + account = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "sufficient"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + ]; - password requisite ${config.security.pam.package}/lib/security/pam_unix.so nullok yescrypt + password = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + settings.nullok = true; + settings.yescrypt = true; + } + ]; - session optional ${config.security.pam.package}/lib/security/pam_keyinit.so revoke - session include login - ''; + session = utils.pam.autoOrderRules [ + { + name = "keyinit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_keyinit.so"; + settings.revoke = true; + } + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; # This would block password prompt when included by gdm-password. # GDM will instead run gdm-fingerprint in parallel. login.fprintAuth = lib.mkIf config.services.fprintd.enable false; - gdm-fingerprint.text = lib.mkIf config.services.fprintd.enable '' - auth required ${config.security.pam.package}/lib/security/pam_shells.so - auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so - auth requisite ${config.security.pam.package}/lib/security/pam_faillock.so preauth - auth required ${pkgs.fprintd}/lib/security/pam_fprintd.so - auth required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 - ${lib.optionalString (pamLogin.enable && pamLogin.enableGnomeKeyring) '' - auth [success=ok default=1] ${gdm}/lib/security/pam_gdm.so - auth optional ${pkgs.gnome-keyring}/lib/security/pam_gnome_keyring.so - ''} + gdm-fingerprint = lib.mkIf config.services.fprintd.enable { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "shells"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_shells.so"; + } + { + name = "nologin"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_nologin.so"; + } + { + name = "faillock"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_faillock.so"; + settings.preauth = true; + } + { + name = "fprintd"; + control = "required"; + modulePath = "${pkgs.fprintd}/lib/security/pam_fprintd.so"; + } + { + name = "env"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_env.so"; + settings.conffile = "/etc/pam/environment"; + settings.readenv = 0; + } + { + name = "gdm"; + enable = pamLogin.enable && pamLogin.enableGnomeKeyring; + control = "[success=ok default=1]"; + modulePath = "${gdm}/lib/security/pam_gdm.so"; + } + { + name = "gnome_keyring"; + enable = pamLogin.enable && pamLogin.enableGnomeKeyring; + control = "optional"; + modulePath = "${pkgs.gnome-keyring}/lib/security/pam_gnome_keyring.so"; + } + ]; - account include login + account = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; - password required ${config.security.pam.package}/lib/security/pam_deny.so + password = utils.pam.autoOrderRules [ + { + name = "deny"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_deny.so"; + } + ]; - session include login - ''; + session = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; }; }; diff --git a/nixos/modules/services/display-managers/ly.nix b/nixos/modules/services/display-managers/ly.nix index 8d671ca5242f..a75da6f68078 100644 --- a/nixos/modules/services/display-managers/ly.nix +++ b/nixos/modules/services/display-managers/ly.nix @@ -1,6 +1,7 @@ { config, lib, + utils, pkgs, ... }: @@ -105,17 +106,58 @@ in }; } // optionalAttrs dmcfg.autoLogin.enable { - ly-autologin.text = '' - auth requisite pam_nologin.so - auth required pam_succeed_if.so uid >= 1000 quiet - auth required pam_permit.so + ly-autologin = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "nologin"; + control = "requisite"; + modulePath = "pam_nologin.so"; + } + { + name = "ly-normal-user"; + control = "required"; + modulePath = "pam_succeed_if.so"; + settings.quiet = true; + args = lib.mkBefore [ + "uid" + ">=" + "1000" + ]; + } + { + name = "permit"; + control = "required"; + modulePath = "pam_permit.so"; + } + ]; - account include ly + account = utils.pam.autoOrderRules [ + { + name = "ly"; + control = "include"; + modulePath = "ly"; + } + ]; - password include ly + password = utils.pam.autoOrderRules [ + { + name = "ly"; + control = "include"; + modulePath = "ly"; + } + ]; - session include ly - ''; + session = utils.pam.autoOrderRules [ + { + name = "ly"; + control = "include"; + modulePath = "ly"; + } + ]; + }; + }; }; environment = { diff --git a/nixos/modules/services/display-managers/plasma-login-manager.nix b/nixos/modules/services/display-managers/plasma-login-manager.nix index 8bfcfc37f4f7..4c98f75b8973 100644 --- a/nixos/modules/services/display-managers/plasma-login-manager.nix +++ b/nixos/modules/services/display-managers/plasma-login-manager.nix @@ -1,6 +1,7 @@ { config, lib, + utils, pkgs, ... }: @@ -83,39 +84,133 @@ in environment.etc."plasmalogin.conf.d/99-user.conf".source = userConfigFile; security.pam.services = { - plasmalogin.text = '' - auth substack login - account include login - password substack login - session include login - ''; + plasmalogin = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + account = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + password = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + session = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; - plasmalogin-autologin.text = '' - auth requisite pam_nologin.so - auth required pam_permit.so + plasmalogin-autologin = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "nologin"; + control = "requisite"; + modulePath = "pam_nologin.so"; + } + { + name = "permit"; + control = "required"; + modulePath = "pam_permit.so"; + } + ]; - account include plasmalogin - password include plasmalogin - session include plasmalogin - ''; + account = utils.pam.autoOrderRules [ + { + name = "plasmalogin"; + control = "include"; + modulePath = "plasmalogin"; + } + ]; + password = utils.pam.autoOrderRules [ + { + name = "plasmalogin"; + control = "include"; + modulePath = "plasmalogin"; + } + ]; + session = utils.pam.autoOrderRules [ + { + name = "plasmalogin"; + control = "include"; + modulePath = "plasmalogin"; + } + ]; + }; + }; - plasmalogin-greeter.text = '' - # Load environment from /etc/environment and ~/.pam_environment - auth required pam_env.so conffile=/etc/pam/environment readenv=0 + plasmalogin-greeter = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + # Load environment from /etc/environment and ~/.pam_environment + name = "env"; + control = "required"; + modulePath = "pam_env.so"; + settings.conffile = "/etc/pam/environment"; + settings.readenv = 0; + } + { + # Always let the greeter start without authentication + name = "permit"; + control = "required"; + modulePath = "pam_permit.so"; + } + ]; - # Always let the greeter start without authentication - auth required pam_permit.so + account = utils.pam.autoOrderRules [ + { + # No action required for account management + name = "permit"; + control = "required"; + modulePath = "pam_permit.so"; + } + ]; - # No action required for account management - account required pam_permit.so + password = utils.pam.autoOrderRules [ + { + # Can't change password + name = "deny"; + control = "required"; + modulePath = "pam_deny.so"; + } + ]; - # Can't change password - password required pam_deny.so - - # Setup session - session required pam_unix.so - session optional ${config.systemd.package}/lib/security/pam_systemd.so - ''; + session = utils.pam.autoOrderRules [ + { + # Setup session + name = "unix"; + control = "required"; + modulePath = "pam_unix.so"; + } + { + name = "systemd"; + control = "optional"; + modulePath = "${config.systemd.package}/lib/security/pam_systemd.so"; + } + ]; + }; + }; }; # FIXME: use upstream sysusers diff --git a/nixos/modules/services/display-managers/sddm.nix b/nixos/modules/services/display-managers/sddm.nix index 25f63007f9ac..5cb750786b00 100644 --- a/nixos/modules/services/display-managers/sddm.nix +++ b/nixos/modules/services/display-managers/sddm.nix @@ -1,6 +1,7 @@ { config, lib, + utils, pkgs, ... }: @@ -368,40 +369,184 @@ in }; security.pam.services = { - sddm.text = '' - auth substack login - account include login - password substack login - session include login - ''; + sddm = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + account = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + password = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + session = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; - sddm-greeter.text = '' - auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = sddm - auth optional ${config.security.pam.package}/lib/security/pam_permit.so + sddm-greeter = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "sddm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "=" + "sddm" + ]; + } + { + name = "permit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; - account required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = sddm - account sufficient ${config.security.pam.package}/lib/security/pam_unix.so + account = utils.pam.autoOrderRules [ + { + name = "sddm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "=" + "sddm" + ]; + } + { + name = "unix"; + control = "sufficient"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + ]; - password required ${config.security.pam.package}/lib/security/pam_deny.so + password = utils.pam.autoOrderRules [ + { + name = "deny"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_deny.so"; + } + ]; - session required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = sddm - session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 - session optional ${config.systemd.package}/lib/security/pam_systemd.so - session optional ${config.security.pam.package}/lib/security/pam_keyinit.so force revoke - session optional ${config.security.pam.package}/lib/security/pam_permit.so - ''; + session = utils.pam.autoOrderRules [ + { + name = "sddm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "=" + "sddm" + ]; + } + { + name = "env"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_env.so"; + settings.conffile = "/etc/pam/environment"; + settings.readenv = 0; + } + { + name = "systemd"; + control = "optional"; + modulePath = "${config.systemd.package}/lib/security/pam_systemd.so"; + } + { + name = "keyinit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_keyinit.so"; + settings.force = true; + settings.revoke = true; + } + { + name = "permit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; + }; + }; - sddm-autologin.text = '' - auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so - auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so uid >= ${toString cfg.autoLogin.minimumUid} quiet - auth required ${config.security.pam.package}/lib/security/pam_permit.so + sddm-autologin = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "nologin"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_nologin.so"; + } + { + name = "sddm-autologin-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.quiet = true; + args = lib.mkBefore [ + "uid" + ">=" + (toString cfg.autoLogin.minimumUid) + ]; + } + { + name = "permit"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; - account include sddm + account = utils.pam.autoOrderRules [ + { + name = "sddm"; + control = "include"; + modulePath = "sddm"; + } + ]; - password include sddm + password = utils.pam.autoOrderRules [ + { + name = "sddm"; + control = "include"; + modulePath = "sddm"; + } + ]; - session include sddm - ''; + session = utils.pam.autoOrderRules [ + { + name = "sddm"; + control = "include"; + modulePath = "sddm"; + } + ]; + }; + }; }; users.users.sddm = { diff --git a/nixos/modules/services/networking/vsftpd.nix b/nixos/modules/services/networking/vsftpd.nix index 070f77115829..481ceb74886e 100644 --- a/nixos/modules/services/networking/vsftpd.nix +++ b/nixos/modules/services/networking/vsftpd.nix @@ -1,6 +1,7 @@ { config, lib, + utils, pkgs, ... }: @@ -333,9 +334,23 @@ in }; }; - security.pam.services.vsftpd.text = mkIf (cfg.enableVirtualUsers && cfg.userDbPath != null) '' - auth required ${config.security.pam.package}/lib/security/pam_userdb.so db=${cfg.userDbPath} - account required ${config.security.pam.package}/lib/security/pam_userdb.so db=${cfg.userDbPath} - ''; + security.pam.services.vsftpd = mkIf (cfg.enableVirtualUsers && cfg.userDbPath != null) { + useDefaultRules = false; + rules = + let + rules = utils.pam.autoOrderRules [ + { + name = "userdb"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_userdb.so"; + settings.db = cfg.userDbPath; + } + ]; + in + { + auth = rules; + account = rules; + }; + }; }; } diff --git a/nixos/modules/services/wayland/cage.nix b/nixos/modules/services/wayland/cage.nix index 76731622d50a..006520ea9b63 100644 --- a/nixos/modules/services/wayland/cage.nix +++ b/nixos/modules/services/wayland/cage.nix @@ -2,6 +2,7 @@ config, pkgs, lib, + utils, ... }: @@ -105,13 +106,45 @@ in security.polkit.enable = true; - security.pam.services.cage.text = '' - auth required ${config.security.pam.package}/lib/security/pam_unix.so nullok - account required ${config.security.pam.package}/lib/security/pam_unix.so - session required ${config.security.pam.package}/lib/security/pam_unix.so - session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 - session required ${config.systemd.package}/lib/security/pam_systemd.so - ''; + security.pam.services.cage = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + settings.nullok = true; + } + ]; + account = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + ]; + session = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + { + name = "env"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_env.so"; + settings.conffile = "/etc/pam/environment"; + settings.readenv = 0; + } + { + name = "systemd"; + control = "required"; + modulePath = "${config.systemd.package}/lib/security/pam_systemd.so"; + } + ]; + }; + }; hardware.graphics.enable = mkDefault true; diff --git a/nixos/modules/services/x11/display-managers/lightdm.nix b/nixos/modules/services/x11/display-managers/lightdm.nix index b0e757b21f5f..699a9a9a07f3 100644 --- a/nixos/modules/services/x11/display-managers/lightdm.nix +++ b/nixos/modules/services/x11/display-managers/lightdm.nix @@ -1,6 +1,7 @@ { config, lib, + utils, pkgs, ... }: @@ -279,47 +280,201 @@ in security.polkit.enable = true; - security.pam.services.lightdm.text = '' - auth substack login - account include login - password substack login - session include login - '' - # https://github.com/elementary/switchboard-plug-parental-controls/blob/8.0.1/src/daemon/Server.vala#L325 - # Must specify conffile since pam_time defaults to ${linux-pam}/etc/security/time.conf. - + lib.optionalString config.services.pantheon.parental-controls.enable '' - account required ${config.security.pam.package}/lib/security/pam_time.so conffile=/etc/security/time.conf - ''; + security.pam.services.lightdm = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + account = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + { + name = "time"; + # https://github.com/elementary/switchboard-plug-parental-controls/blob/8.0.1/src/daemon/Server.vala#L325 + enable = config.services.pantheon.parental-controls.enable; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_time.so"; + # Must specify conffile since pam_time defaults to ${linux-pam}/etc/security/time.conf. + settings.conffile = "/etc/security/time.conf"; + } + ]; + password = utils.pam.autoOrderRules [ + { + name = "login"; + control = "substack"; + modulePath = "login"; + } + ]; + session = utils.pam.autoOrderRules [ + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; - security.pam.services.lightdm-greeter.text = '' - auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = lightdm - auth optional ${config.security.pam.package}/lib/security/pam_permit.so + security.pam.services.lightdm-greeter = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "lightdm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "=" + "lightdm" + ]; + } + { + name = "permit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; - account required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = lightdm - account sufficient ${config.security.pam.package}/lib/security/pam_unix.so + account = utils.pam.autoOrderRules [ + { + name = "lightdm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "=" + "lightdm" + ]; + } + { + name = "unix"; + control = "sufficient"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + ]; - password required ${config.security.pam.package}/lib/security/pam_deny.so + password = utils.pam.autoOrderRules [ + { + name = "deny"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_deny.so"; + } + ]; - session required ${config.security.pam.package}/lib/security/pam_succeed_if.so audit quiet_success user = lightdm - session required ${config.security.pam.package}/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 - session optional ${config.systemd.package}/lib/security/pam_systemd.so - session optional ${config.security.pam.package}/lib/security/pam_keyinit.so force revoke - session optional ${config.security.pam.package}/lib/security/pam_permit.so - ''; + session = utils.pam.autoOrderRules [ + { + name = "lightdm-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.audit = true; + settings.quiet_success = true; + args = lib.mkAfter [ + "user" + "=" + "lightdm" + ]; + } + { + name = "env"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_env.so"; + settings.conffile = "/etc/pam/environment"; + settings.readenv = 0; + } + { + name = "systemd"; + control = "optional"; + modulePath = "${config.systemd.package}/lib/security/pam_systemd.so"; + } + { + name = "keyinit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_keyinit.so"; + settings.force = true; + settings.revoke = true; + } + { + name = "permit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; + }; + }; - security.pam.services.lightdm-autologin.text = '' - auth requisite ${config.security.pam.package}/lib/security/pam_nologin.so + security.pam.services.lightdm-autologin = { + useDefaultRules = false; + rules = { + auth = utils.pam.autoOrderRules [ + { + name = "nologin"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_nologin.so"; + } + { + name = "lightdm-normal-user"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_succeed_if.so"; + settings.quiet = true; + args = lib.mkBefore [ + "uid" + ">=" + "1000" + ]; + } + { + name = "permit"; + control = "required"; + modulePath = "${config.security.pam.package}/lib/security/pam_permit.so"; + } + ]; - auth required ${config.security.pam.package}/lib/security/pam_succeed_if.so uid >= 1000 quiet - auth required ${config.security.pam.package}/lib/security/pam_permit.so + account = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "sufficient"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + } + ]; - account sufficient ${config.security.pam.package}/lib/security/pam_unix.so + password = utils.pam.autoOrderRules [ + { + name = "unix"; + control = "requisite"; + modulePath = "${config.security.pam.package}/lib/security/pam_unix.so"; + settings.nullok = true; + settings.yescrypt = true; + } + ]; - password requisite ${config.security.pam.package}/lib/security/pam_unix.so nullok yescrypt - - session optional ${config.security.pam.package}/lib/security/pam_keyinit.so revoke - session include login - ''; + session = utils.pam.autoOrderRules [ + { + name = "keyinit"; + control = "optional"; + modulePath = "${config.security.pam.package}/lib/security/pam_keyinit.so"; + settings.revoke = true; + } + { + name = "login"; + control = "include"; + modulePath = "login"; + } + ]; + }; + }; users.users.lightdm = { home = "/var/lib/lightdm"; From f7f9148111fefa60549b791254786b4015d44359 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Mon, 13 Apr 2026 19:57:30 +0200 Subject: [PATCH 06/33] nixos/network-interfaces: don't write net.ipv4.conf.all.forwarding=0 This key is an alias for net.ipv4.ip_forward. The kernel default is already 0, so emitting `=0` from sysctl.d is at best a no-op. It is actively harmful when systemd-networkd manages forwarding via `networkd.conf [Network] IPv4Forwarding=yes`: on a cold boot systemd-sysctl runs before networkd and networkd's write wins, but on a `nixos-rebuild switch` that restarts both services (any systemd package change does, since kernel.poweroff_cmd in 60-nixos.conf follows the systemd store path) the ordering is undefined and sysctl can run last, silently flipping a router back to ip_forward=0. Only emit the sysctl when at least one interface has proxyARP=true, matching every other in-tree setter of this key (nat, docker, tailscale, netbird, ...) which only ever write `true`. The previous value was already mkDefault, so nothing could have been relying on it to force forwarding off anyway. --- nixos/modules/tasks/network-interfaces.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nixos/modules/tasks/network-interfaces.nix b/nixos/modules/tasks/network-interfaces.nix index 83d60ba80e40..2b01843b82dc 100644 --- a/nixos/modules/tasks/network-interfaces.nix +++ b/nixos/modules/tasks/network-interfaces.nix @@ -1780,7 +1780,9 @@ in optionalString hasBonds "options bonding max_bonds=0"; boot.kernel.sysctl = { - "net.ipv4.conf.all.forwarding" = mkDefault (any (i: i.proxyARP) interfaces); + # Only set when proxyARP needs it; never write =0 (the kernel default), + # which would race with systemd-networkd's IPv4Forwarding= on switch. + "net.ipv4.conf.all.forwarding" = mkIf (any (i: i.proxyARP) interfaces) (mkDefault true); "net.ipv6.conf.all.disable_ipv6" = mkDefault (!cfg.enableIPv6); "net.ipv6.conf.default.disable_ipv6" = mkDefault (!cfg.enableIPv6); # allow all users to do ICMP echo requests (ping) From 45e2f9a67e7b09529ea080b747c0baf8408a9516 Mon Sep 17 00:00:00 2001 From: Michael Schneider Date: Wed, 18 Feb 2026 13:35:44 +0200 Subject: [PATCH 07/33] nixos/test-driver: use log levels --- nixos/doc/manual/redirects.json | 3 ++ .../test-driver/src/test_driver/__init__.py | 13 ++++++ .../lib/test-driver/src/test_driver/logger.py | 45 ++++++++++++++++--- nixos/lib/testing/driver.nix | 12 +++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index feb535c2142c..71a137b0fa33 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -2207,6 +2207,9 @@ "test-opt-interactive": [ "index.html#test-opt-interactive" ], + "test-opt-logLevel": [ + "index.html#test-opt-logLevel" + ], "test-opt-meta": [ "index.html#test-opt-meta" ], diff --git a/nixos/lib/test-driver/src/test_driver/__init__.py b/nixos/lib/test-driver/src/test_driver/__init__.py index 0f42f2842c77..da527f46b824 100644 --- a/nixos/lib/test-driver/src/test_driver/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/__init__.py @@ -12,6 +12,7 @@ from test_driver.driver import Driver from test_driver.logger import ( CompositeLogger, JunitXMLLogger, + LogLevel, TerminalLogger, XMLLogger, ) @@ -151,6 +152,15 @@ def main() -> None: help="indicates that the interactive SSH backdoor is active and dumps information about it on start", type=int, ) + log_level_map = {level.name.lower(): level for level in LogLevel} + arg_parser.add_argument( + "--log-level", + metavar="LOG_LEVEL", + action=EnvDefault, + envvar="logLevel", + choices=log_level_map, + help="Set the log level", + ) args = arg_parser.parse_args() @@ -169,6 +179,9 @@ def main() -> None: if args.junit_xml: logger.add_logger(JunitXMLLogger(output_directory / args.junit_xml)) + if args.log_level: + logger.set_log_level(log_level_map[args.log_level]) + if not args.keep_machine_state: logger.info( "Machine state will be reset. To keep it, pass --keep-machine-state" diff --git a/nixos/lib/test-driver/src/test_driver/logger.py b/nixos/lib/test-driver/src/test_driver/logger.py index a218d234fe3f..3c2cb41d33b1 100644 --- a/nixos/lib/test-driver/src/test_driver/logger.py +++ b/nixos/lib/test-driver/src/test_driver/logger.py @@ -7,6 +7,7 @@ import unicodedata from abc import ABC, abstractmethod from collections.abc import Iterator from contextlib import ExitStack, contextmanager +from enum import IntEnum from pathlib import Path from queue import Empty, Queue from typing import Any @@ -17,6 +18,12 @@ from colorama import Fore, Style from junit_xml import TestCase, TestSuite +class LogLevel(IntEnum): + INFO = 1 + WARNING = 2 + ERROR = 3 + + class AbstractLogger(ABC): @abstractmethod def log(self, message: str, attributes: dict[str, str] = {}) -> None: @@ -56,6 +63,10 @@ class AbstractLogger(ABC): def print_serial_logs(self, enable: bool) -> None: pass + @abstractmethod + def set_log_level(self, level: LogLevel) -> None: + pass + class JunitXMLLogger(AbstractLogger): class TestCaseState: @@ -71,6 +82,7 @@ class JunitXMLLogger(AbstractLogger): self.currentSubtest = "main" self.outfile: Path = outfile self._print_serial_logs = True + self._log_level = LogLevel.INFO atexit.register(self.close) def log(self, message: str, attributes: dict[str, str] = {}) -> None: @@ -92,10 +104,12 @@ class JunitXMLLogger(AbstractLogger): yield def info(self, *args, **kwargs) -> None: # type: ignore - self.tests[self.currentSubtest].stdout += args[0] + os.linesep + if self._log_level <= LogLevel.INFO: + self.tests[self.currentSubtest].stdout += args[0] + os.linesep def warning(self, *args, **kwargs) -> None: # type: ignore - self.tests[self.currentSubtest].stdout += args[0] + os.linesep + if self._log_level <= LogLevel.WARNING: + self.tests[self.currentSubtest].stdout += args[0] + os.linesep def error(self, *args, **kwargs) -> None: # type: ignore self.tests[self.currentSubtest].stderr += args[0] + os.linesep @@ -113,6 +127,9 @@ class JunitXMLLogger(AbstractLogger): def print_serial_logs(self, enable: bool) -> None: self._print_serial_logs = enable + def set_log_level(self, level: LogLevel) -> None: + self._log_level = level + def close(self) -> None: with open(self.outfile, "w") as f: test_cases = [] @@ -180,10 +197,15 @@ class CompositeLogger(AbstractLogger): for logger in self.logger_list: logger.log_serial(message, machine) + def set_log_level(self, level: LogLevel) -> None: + for logger in self.logger_list: + logger.set_log_level(level) + class TerminalLogger(AbstractLogger): def __init__(self) -> None: self._print_serial_logs = True + self._log_level = LogLevel.INFO def maybe_prefix(self, message: str, attributes: dict[str, str]) -> str: if "machine" in attributes: @@ -216,10 +238,12 @@ class TerminalLogger(AbstractLogger): self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)", attributes) def info(self, *args, **kwargs) -> None: # type: ignore - self.log(*args, **kwargs) + if self._log_level <= LogLevel.INFO: + self.log(*args, **kwargs) def warning(self, *args, **kwargs) -> None: # type: ignore - self.log(*args, **kwargs) + if self._log_level <= LogLevel.WARNING: + self.log(*args, **kwargs) def error(self, *args, **kwargs) -> None: # type: ignore self.log(*args, **kwargs) @@ -227,6 +251,9 @@ class TerminalLogger(AbstractLogger): def print_serial_logs(self, enable: bool) -> None: self._print_serial_logs = enable + def set_log_level(self, level: LogLevel) -> None: + self._log_level = level + def log_serial(self, message: str, machine: str) -> None: if not self._print_serial_logs: return @@ -246,6 +273,7 @@ class XMLLogger(AbstractLogger): self.queue: Queue[dict[str, str]] = Queue() self._print_serial_logs = True + self._log_level = LogLevel.INFO self.xml.startDocument() self.xml.startElement("logfile", attrs=AttributesImpl({})) @@ -269,10 +297,12 @@ class XMLLogger(AbstractLogger): self.xml.endElement("line") def info(self, *args, **kwargs) -> None: # type: ignore - self.log(*args, **kwargs) + if self._log_level <= LogLevel.INFO: + self.log(*args, **kwargs) def warning(self, *args, **kwargs) -> None: # type: ignore - self.log(*args, **kwargs) + if self._log_level <= LogLevel.WARNING: + self.log(*args, **kwargs) def error(self, *args, **kwargs) -> None: # type: ignore self.log(*args, **kwargs) @@ -287,6 +317,9 @@ class XMLLogger(AbstractLogger): def print_serial_logs(self, enable: bool) -> None: self._print_serial_logs = enable + def set_log_level(self, level: LogLevel) -> None: + self._log_level = level + def log_serial(self, message: str, machine: str) -> None: if not self._print_serial_logs: return diff --git a/nixos/lib/testing/driver.nix b/nixos/lib/testing/driver.nix index 5cd7c2ebb806..d5638718dda7 100644 --- a/nixos/lib/testing/driver.nix +++ b/nixos/lib/testing/driver.nix @@ -119,6 +119,7 @@ let --set testScript "$out/test-script" \ --set globalTimeout "${toString config.globalTimeout}" \ --set vlans '${toString vlans}' \ + --set logLevel "${config.logLevel}" \ ${lib.escapeShellArgs ( lib.concatMap (arg: [ "--add-flags" @@ -219,6 +220,17 @@ in This may speed up your iteration cycle, unless you're working on the [{option}`testScript`](#test-opt-testScript). ''; }; + + logLevel = mkOption { + description = "Log level for the test driver."; + type = types.enum [ + "info" + "warning" + "error" + ]; + default = "info"; + example = "warning"; + }; }; config = { From 2cec6a0f4ef9a84963d3aba95c9b24b74e970a67 Mon Sep 17 00:00:00 2001 From: Elec3137 Date: Wed, 8 Apr 2026 16:59:13 -0700 Subject: [PATCH 08/33] nixos-rebuild-ng: add warning on no updated channels --- pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py index 0e89656ec7aa..c825320eae74 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/nix.py @@ -738,6 +738,7 @@ def upgrade_channels(all_channels: bool = False, sudo: bool = False) -> None: "also pass '--sudo' or run the command as root (e.g., with sudo)" ) + channel_updated = False for channel_path in Path("/nix/var/nix/profiles/per-user/root/channels/").glob("*"): if channel_path.is_dir() and ( all_channels @@ -749,3 +750,7 @@ def upgrade_channels(all_channels: bool = False, sudo: bool = False) -> None: check=False, sudo=sudo, ) + channel_updated = True + + if not channel_updated: + logger.warning("'--upgrade(-all)' flag passed but no channels to update") From a8367a7971f53507a5dfc3975f623a635c001557 Mon Sep 17 00:00:00 2001 From: Elec3137 Date: Sun, 12 Apr 2026 08:38:59 -0700 Subject: [PATCH 09/33] nixos-rebuild-ng: add warning on upgrading channels on flake system --- pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py index b5d927c51a3c..71480548fb50 100644 --- a/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py +++ b/pkgs/by-name/ni/nixos-rebuild-ng/src/nixos_rebuild/__init__.py @@ -342,6 +342,9 @@ def execute(argv: list[str]) -> None: build_attr = BuildAttr.from_arg(args.attr, args.file) flake = Flake.from_arg(args.flake, target_host) + if flake and (args.upgrade or args.upgrade_all): + logger.warning("'--upgrade(-all)' flag has no effect for flake-based systems") + if can_run and not flake and not args.store_path: services.write_version_suffix(grouped_nix_args) From 93788c99a628153ca96e19e56111171df655e265 Mon Sep 17 00:00:00 2001 From: SandaruKasa Date: Thu, 23 Oct 2025 21:44:16 +0300 Subject: [PATCH 10/33] nixos/fontconfig: default `useEmbeddedBitmaps` to `true` on 26.05 --- nixos/doc/manual/release-notes/rl-2605.section.md | 2 ++ nixos/modules/config/fonts/fontconfig.nix | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 72edc434546a..9437d3ab4fd8 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -322,6 +322,8 @@ See . - Budgie has been updated to 10.10, please check the [upstream announcement](https://buddiesofbudgie.org/blog/budgie-10-10-released) for more details. +- `fonts.fontconfig.useEmbeddedBitmaps` is now set to `true` by default. + - `stestrCheckHook` was added: This test hook runs `stestr run`. You can disable tests with `disabledTests` and `disabledTestsRegex`. - `services.frp` now supports multiple instances through `services.frp.instances` to make it possible to run multiple frp clients or servers at the same time. diff --git a/nixos/modules/config/fonts/fontconfig.nix b/nixos/modules/config/fonts/fontconfig.nix index 419de1a89fa8..9316b739fda2 100644 --- a/nixos/modules/config/fonts/fontconfig.nix +++ b/nixos/modules/config/fonts/fontconfig.nix @@ -518,7 +518,8 @@ in useEmbeddedBitmaps = lib.mkOption { type = lib.types.bool; - default = false; + default = lib.versionAtLeast config.system.stateVersion "26.05"; + defaultText = lib.literalExpression "lib.versionAtLeast config.system.stateVersion \"26.05\""; description = "Use embedded bitmaps in fonts like Calibri."; }; From bffdf7c2a86dd6f5a1169f404c691c2b2423ef0d Mon Sep 17 00:00:00 2001 From: Jacek Galowicz Date: Mon, 13 Apr 2026 19:09:44 +0100 Subject: [PATCH 11/33] nixos-test-driver: Use ty instead of mypy for test driver package --- nixos/lib/test-driver/default.nix | 10 ++-- nixos/lib/test-driver/src/pyproject.toml | 21 +------- .../test-driver/src/test_driver/__init__.py | 6 +-- .../lib/test-driver/src/test_driver/debug.py | 6 +-- .../lib/test-driver/src/test_driver/driver.py | 2 +- .../lib/test-driver/src/test_driver/logger.py | 50 +++++++++---------- .../src/test_driver/machine/__init__.py | 8 +-- .../src/test_driver/polling_condition.py | 8 +-- 8 files changed, 47 insertions(+), 64 deletions(-) diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index 72aa6b11bfe5..88fd70ee7f47 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -7,11 +7,11 @@ imagemagick_light, ipython, junit-xml, - mypy, ptpython, python, - ruff, remote-pdb, + ruff, + ty, netpbm, nixosTests, @@ -72,13 +72,13 @@ buildPythonApplication { doCheck = true; nativeCheckInputs = [ - mypy ruff + ty ]; checkPhase = '' - echo -e "\x1b[32m## run mypy\x1b[0m" - mypy test_driver extract-docstrings.py + echo -e "\x1b[32m## run ty\x1b[0m" + ty check --error-on-warning test_driver extract-docstrings.py echo -e "\x1b[32m## run ruff check\x1b[0m" ruff check . echo -e "\x1b[32m## run ruff format\x1b[0m" diff --git a/nixos/lib/test-driver/src/pyproject.toml b/nixos/lib/test-driver/src/pyproject.toml index fa4e6a2de127..c348bf1e4dfb 100644 --- a/nixos/lib/test-driver/src/pyproject.toml +++ b/nixos/lib/test-driver/src/pyproject.toml @@ -17,27 +17,8 @@ find = {} test_driver = ["py.typed"] [tool.ruff] -target-version = "py312" +target-version = "py313" line-length = 88 lint.select = ["E", "F", "I", "U", "N"] lint.ignore = ["E501", "N818"] - -# xxx: we can import https://pypi.org/project/types-colorama/ here -[[tool.mypy.overrides]] -module = "colorama.*" -ignore_missing_imports = true - -[[tool.mypy.overrides]] -module = "ptpython.*" -ignore_missing_imports = true - -[[tool.mypy.overrides]] -module = "junit_xml.*" -ignore_missing_imports = true - -[tool.mypy] -warn_redundant_casts = true -disallow_untyped_calls = true -disallow_untyped_defs = true -no_implicit_optional = true diff --git a/nixos/lib/test-driver/src/test_driver/__init__.py b/nixos/lib/test-driver/src/test_driver/__init__.py index da527f46b824..847ab0582448 100644 --- a/nixos/lib/test-driver/src/test_driver/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/__init__.py @@ -23,7 +23,7 @@ class EnvDefault(argparse.Action): environment variable as the flags default value. """ - def __init__(self, envvar, required=False, default=None, nargs=None, **kwargs): # type: ignore + def __init__(self, envvar, required=False, default=None, nargs=None, **kwargs): if not default and envvar: if envvar in os.environ: if nargs is not None and (nargs.isdigit() or nargs in ["*", "+"]): @@ -37,7 +37,7 @@ class EnvDefault(argparse.Action): required = False super().__init__(default=default, required=required, nargs=nargs, **kwargs) - def __call__(self, parser, namespace, values, option_string=None): # type: ignore + def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, values) @@ -219,7 +219,7 @@ def main() -> None: ptpython.ipython.embed( user_ns=driver.test_symbols(), history_filename=history_path, - ) # type:ignore + ) else: tic = time.time() driver.run_tests() diff --git a/nixos/lib/test-driver/src/test_driver/debug.py b/nixos/lib/test-driver/src/test_driver/debug.py index 7f783fe96de8..25423c35a3d8 100644 --- a/nixos/lib/test-driver/src/test_driver/debug.py +++ b/nixos/lib/test-driver/src/test_driver/debug.py @@ -6,7 +6,7 @@ import subprocess import sys from abc import ABC, abstractmethod -from remote_pdb import RemotePdb # type:ignore +from remote_pdb import RemotePdb from test_driver.logger import AbstractLogger @@ -42,12 +42,12 @@ class Debug(DebugAbstract): self.logger.log_test_error( f"Breakpoint reached, run 'sudo {self.attach} {pattern}'" ) - os.environ["bashInteractive"] = shutil.which("bash") # type:ignore + os.environ["bashInteractive"] = shutil.which("bash") # ty: ignore[invalid-assignment] if os.fork() == 0: subprocess.run(["sleep", pattern]) else: # RemotePdb writes log messages to both stderr AND the logger, # which is the same here. Hence, disabling the remote_pdb logger # to avoid duplicate messages in the build log. - logging.root.manager.loggerDict["remote_pdb"].disabled = True # type:ignore + logging.root.manager.loggerDict["remote_pdb"].disabled = True # ty: ignore[invalid-assignment] RemotePdb(host=host, port=port).set_trace(sys._getframe().f_back) diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index 9188e195cb35..abaa2cc71955 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -418,7 +418,7 @@ class Driver: def __enter__(self) -> None: driver.polling_conditions.append(self.condition) - def __exit__(self, a, b, c) -> None: # type: ignore + def __exit__(self, a, b, c) -> None: res = driver.polling_conditions.pop() assert res is self.condition diff --git a/nixos/lib/test-driver/src/test_driver/logger.py b/nixos/lib/test-driver/src/test_driver/logger.py index 3c2cb41d33b1..bce73a402234 100644 --- a/nixos/lib/test-driver/src/test_driver/logger.py +++ b/nixos/lib/test-driver/src/test_driver/logger.py @@ -1,5 +1,4 @@ import atexit -import codecs import os import sys import time @@ -40,19 +39,19 @@ class AbstractLogger(ABC): pass @abstractmethod - def info(self, *args, **kwargs) -> None: # type: ignore + def info(self, *args, **kwargs) -> None: pass @abstractmethod - def warning(self, *args, **kwargs) -> None: # type: ignore + def warning(self, *args, **kwargs) -> None: pass @abstractmethod - def error(self, *args, **kwargs) -> None: # type: ignore + def error(self, *args, **kwargs) -> None: pass @abstractmethod - def log_test_error(self, *args, **kwargs) -> None: # type:ignore + def log_test_error(self, *args, **kwargs) -> None: pass @abstractmethod @@ -103,19 +102,19 @@ class JunitXMLLogger(AbstractLogger): self.log(message) yield - def info(self, *args, **kwargs) -> None: # type: ignore + def info(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.INFO: self.tests[self.currentSubtest].stdout += args[0] + os.linesep - def warning(self, *args, **kwargs) -> None: # type: ignore + def warning(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.WARNING: self.tests[self.currentSubtest].stdout += args[0] + os.linesep - def error(self, *args, **kwargs) -> None: # type: ignore + def error(self, *args, **kwargs) -> None: self.tests[self.currentSubtest].stderr += args[0] + os.linesep self.tests[self.currentSubtest].failure = True - def log_test_error(self, *args, **kwargs) -> None: # type: ignore + def log_test_error(self, *args, **kwargs) -> None: self.error(*args, **kwargs) def log_serial(self, message: str, machine: str) -> None: @@ -172,19 +171,19 @@ class CompositeLogger(AbstractLogger): stack.enter_context(logger.nested(message, attributes)) yield - def info(self, *args, **kwargs) -> None: # type: ignore + def info(self, *args, **kwargs) -> None: for logger in self.logger_list: logger.info(*args, **kwargs) - def warning(self, *args, **kwargs) -> None: # type: ignore + def warning(self, *args, **kwargs) -> None: for logger in self.logger_list: logger.warning(*args, **kwargs) - def log_test_error(self, *args, **kwargs) -> None: # type: ignore + def log_test_error(self, *args, **kwargs) -> None: for logger in self.logger_list: logger.log_test_error(*args, **kwargs) - def error(self, *args, **kwargs) -> None: # type: ignore + def error(self, *args, **kwargs) -> None: for logger in self.logger_list: logger.error(*args, **kwargs) sys.exit(1) @@ -228,7 +227,8 @@ class TerminalLogger(AbstractLogger): def nested(self, message: str, attributes: dict[str, str] = {}) -> Iterator[None]: self._eprint( self.maybe_prefix( - Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, attributes + Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, # ty: ignore[unsupported-operator] + attributes, ) ) @@ -237,15 +237,15 @@ class TerminalLogger(AbstractLogger): toc = time.time() self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)", attributes) - def info(self, *args, **kwargs) -> None: # type: ignore + def info(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.INFO: self.log(*args, **kwargs) - def warning(self, *args, **kwargs) -> None: # type: ignore + def warning(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.WARNING: self.log(*args, **kwargs) - def error(self, *args, **kwargs) -> None: # type: ignore + def error(self, *args, **kwargs) -> None: self.log(*args, **kwargs) def print_serial_logs(self, enable: bool) -> None: @@ -258,17 +258,17 @@ class TerminalLogger(AbstractLogger): if not self._print_serial_logs: return - self._eprint(Style.DIM + f"{machine} # {message}" + Style.RESET_ALL) + self._eprint(Style.DIM + f"{machine} # {message}" + Style.RESET_ALL) # ty: ignore[unsupported-operator] - def log_test_error(self, *args, **kwargs) -> None: # type: ignore - prefix = Fore.RED + "!!! " + Style.RESET_ALL + def log_test_error(self, *args, **kwargs) -> None: + prefix = Fore.RED + "!!! " + Style.RESET_ALL # ty: ignore[unsupported-operator] # NOTE: using `warning` instead of `error` to ensure it does not exit after printing the first log self.warning(f"{prefix}{args[0]}", *args[1:], **kwargs) class XMLLogger(AbstractLogger): def __init__(self, outfile: str) -> None: - self.logfile_handle = codecs.open(outfile, "wb") + self.logfile_handle = open(outfile, "wb") self.xml = XMLGenerator(self.logfile_handle, encoding="utf-8") self.queue: Queue[dict[str, str]] = Queue() @@ -296,18 +296,18 @@ class XMLLogger(AbstractLogger): self.xml.characters(message) self.xml.endElement("line") - def info(self, *args, **kwargs) -> None: # type: ignore + def info(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.INFO: self.log(*args, **kwargs) - def warning(self, *args, **kwargs) -> None: # type: ignore + def warning(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.WARNING: self.log(*args, **kwargs) - def error(self, *args, **kwargs) -> None: # type: ignore + def error(self, *args, **kwargs) -> None: self.log(*args, **kwargs) - def log_test_error(self, *args, **kwargs) -> None: # type: ignore + def log_test_error(self, *args, **kwargs) -> None: self.log(*args, **kwargs) def log(self, message: str, attributes: dict[str, str] = {}) -> None: diff --git a/nixos/lib/test-driver/src/test_driver/machine/__init__.py b/nixos/lib/test-driver/src/test_driver/machine/__init__.py index 365826dc22ff..7edb72b76145 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -24,9 +24,11 @@ from typing import Any from test_driver.errors import MachineError, RequestedAssertionFailed from test_driver.logger import AbstractLogger - -from .ocr import perform_ocr_on_screenshot, perform_ocr_variants_on_screenshot -from .qmp import QMPSession +from test_driver.machine.ocr import ( + perform_ocr_on_screenshot, + perform_ocr_variants_on_screenshot, +) +from test_driver.machine.qmp import QMPSession CHAR_TO_KEY = { "A": "shift-a", diff --git a/nixos/lib/test-driver/src/test_driver/polling_condition.py b/nixos/lib/test-driver/src/test_driver/polling_condition.py index 1a8091cf4471..bebdeca29702 100644 --- a/nixos/lib/test-driver/src/test_driver/polling_condition.py +++ b/nixos/lib/test-driver/src/test_driver/polling_condition.py @@ -25,7 +25,7 @@ class PollingCondition: seconds_interval: float = 2.0, description: str | None = None, ): - self.condition = condition # type: ignore + self.condition = condition # ty: ignore[invalid-assignment] self.seconds_interval = seconds_interval self.logger = logger @@ -33,7 +33,7 @@ class PollingCondition: if condition.__doc__: self.description = condition.__doc__ else: - self.description = condition.__name__ + self.description = condition.__name__ # ty: ignore[unresolved-attribute] else: self.description = str(description) @@ -54,7 +54,7 @@ class PollingCondition: self.logger.info(last_message) try: - res = self.condition() # type: ignore + res = self.condition() except Exception: res = False res = res is None or res @@ -89,7 +89,7 @@ class PollingCondition: def __enter__(self) -> None: self.entry_count += 1 - def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore + def __exit__(self, exc_type, exc_value, traceback) -> None: assert self.entered self.entry_count -= 1 self.last_called = time.monotonic() From 5f5734db9d73fc3f4caa4be18995bba9b4c58c84 Mon Sep 17 00:00:00 2001 From: Michael Schneider Date: Wed, 15 Apr 2026 12:14:00 +0100 Subject: [PATCH 12/33] nixos/test-driver: add option to force kvm use --- nixos/doc/manual/redirects.json | 3 ++ nixos/lib/qemu-common.nix | 23 +++++---- .../lib/test-driver/src/test_driver/driver.py | 20 +++++++- .../src/test_driver/machine/__init__.py | 26 +++++++++- nixos/lib/testing/driver.nix | 10 ++++ nixos/lib/testing/nodes.nix | 4 +- nixos/modules/virtualisation/qemu-vm.nix | 47 ++++++++++++++++++- 7 files changed, 120 insertions(+), 13 deletions(-) diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index 71a137b0fa33..4dbb255682ed 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -2246,6 +2246,9 @@ "test-opt-passthru": [ "index.html#test-opt-passthru" ], + "test-opt-qemu.forceAccel": [ + "index.html#test-opt-qemu.forceAccel" + ], "test-opt-qemu.package": [ "index.html#test-opt-qemu.package" ], diff --git a/nixos/lib/qemu-common.nix b/nixos/lib/qemu-common.nix index a43b624cdbce..9ff482b83f3a 100644 --- a/nixos/lib/qemu-common.nix +++ b/nixos/lib/qemu-common.nix @@ -24,30 +24,37 @@ rec { else throw "Unknown QEMU serial device for system '${stdenv.hostPlatform.system}'"; - qemuBinary = - qemuPkg: + qemuBinary = qemuPkg: qemuBinaryWith { inherit qemuPkg; }; + + qemuBinaryWith = + { + qemuPkg, + forceAccel ? false, + }: let hostStdenv = qemuPkg.stdenv; hostSystem = hostStdenv.system; guestSystem = stdenv.hostPlatform.system; + accel = accelName: if forceAccel then accelName else "${accelName}:tcg"; + linuxHostGuestMatrix = { - x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=kvm:tcg -cpu max"; - armv7l-linux = "${qemuPkg}/bin/qemu-system-arm -machine virt,accel=kvm:tcg -cpu max"; - aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=max,accel=kvm:tcg -cpu max"; + x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=${accel "kvm"} -cpu max"; + armv7l-linux = "${qemuPkg}/bin/qemu-system-arm -machine virt,accel=${accel "kvm"} -cpu max"; + aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=max,accel=${accel "kvm"} -cpu max"; powerpc64le-linux = "${qemuPkg}/bin/qemu-system-ppc64 -machine powernv"; powerpc64-linux = "${qemuPkg}/bin/qemu-system-ppc64 -machine powernv"; riscv32-linux = "${qemuPkg}/bin/qemu-system-riscv32 -machine virt"; riscv64-linux = "${qemuPkg}/bin/qemu-system-riscv64 -machine virt"; - x86_64-darwin = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=kvm:tcg -cpu max"; + x86_64-darwin = "${qemuPkg}/bin/qemu-system-x86_64 -machine accel=${accel "kvm"} -cpu max"; }; otherHostGuestMatrix = { aarch64-darwin = { - aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=2,accel=hvf:tcg -cpu max"; + aarch64-linux = "${qemuPkg}/bin/qemu-system-aarch64 -machine virt,gic-version=2,accel=${accel "hvf"} -cpu max"; inherit (otherHostGuestMatrix.x86_64-darwin) x86_64-linux; }; x86_64-darwin = { - x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine type=q35,accel=hvf:tcg -cpu max"; + x86_64-linux = "${qemuPkg}/bin/qemu-system-x86_64 -machine type=q35,accel=${accel "hvf"} -cpu max"; }; }; diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index abaa2cc71955..714a11905306 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -336,10 +336,22 @@ class Driver: def start_all(self) -> None: """Start all machines""" with self.logger.nested("start all VMs"): + errors: list[tuple[str, BaseException]] = [] + + def start_machine(machine: BaseMachine) -> None: + try: + machine.start() + except Exception as e: + errors.append((machine.name, e)) + threads = [] for machine in self.machines: # Create a thread for each machine's start method - t = threading.Thread(target=machine.start, name=f"start-{machine.name}") + t = threading.Thread( + target=start_machine, + args=(machine,), + name=f"start-{machine.name}", + ) threads.append(t) t.start() @@ -347,6 +359,12 @@ class Driver: for t in threads: t.join() + if errors: + messages = [f"{name}: {e}" for name, e in errors] + raise MachineError( + "Failed to start the following machines:\n" + "\n".join(messages) + ) + def join_all(self) -> None: """Wait for all machines to shut down""" with self.logger.nested("wait for all VMs to finish"): diff --git a/nixos/lib/test-driver/src/test_driver/machine/__init__.py b/nixos/lib/test-driver/src/test_driver/machine/__init__.py index 7edb72b76145..62c10d425f86 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -212,6 +212,7 @@ class QemuStartCommand: ), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, shell=True, cwd=state_dir, env=self.build_environment(state_dir, shared_dir), @@ -1227,8 +1228,29 @@ class QemuMachine(BaseMachine): self.shell_path, allow_reboot, ) - self.monitor, _ = monitor_socket.accept() - self.shell, _ = shell_socket.accept() + + def accept_or_fail(sock: socket.socket, name: str) -> socket.socket: + """Accept a connection on a socket, polling the status to check + if the QEMU process is still alive. Without this, socket.accept() + would block forever if QEMU exits before connecting. + """ + assert self.process + while True: + readable, _, _ = select.select([sock], [], [], 1.0) + if readable: + conn, _ = sock.accept() + return conn + rc = self.process.poll() + if rc is not None: + output = "" + if self.process.stdout: + output = self.process.stdout.read().decode(errors="ignore") + raise MachineError( + f"QEMU process exited with code {rc} before connecting to {name} socket.\n{output}" + ) + + self.monitor = accept_or_fail(monitor_socket, "monitor") + self.shell = accept_or_fail(shell_socket, "shell") self.qmp_client = QMPSession.from_path(self.qmp_path) # Store last serial console lines for use diff --git a/nixos/lib/testing/driver.nix b/nixos/lib/testing/driver.nix index d5638718dda7..e9808907b2c5 100644 --- a/nixos/lib/testing/driver.nix +++ b/nixos/lib/testing/driver.nix @@ -159,6 +159,16 @@ in defaultText = "hostPkgs.qemu_test"; }; + qemu.forceAccel = mkOption { + description = '' + Whether to force the use of hardware-accelerated virtualisation. + When enabled, QEMU will not fall back to the slower software emulation + (TCG) and will instead error out if the accelerator is not available. + ''; + type = types.bool; + default = false; + }; + globalTimeout = mkOption { description = '' A global timeout for the complete test, expressed in seconds. diff --git a/nixos/lib/testing/nodes.nix b/nixos/lib/testing/nodes.nix index 7384dec68895..e1a148297e32 100644 --- a/nixos/lib/testing/nodes.nix +++ b/nixos/lib/testing/nodes.nix @@ -71,7 +71,9 @@ let config.nodeDefaults { key = "base-qemu"; - virtualisation.qemu.package = testModuleArgs.config.qemu.package; + virtualisation.qemu = { + inherit (testModuleArgs.config.qemu) package forceAccel; + }; virtualisation.host.pkgs = hostPkgs; } testModuleArgs.config.extraBaseNodeModules diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index 33bd759a1e3f..efc7921da00b 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -306,8 +306,42 @@ let (builtins.concatStringsSep "") ]} + ${lib.optionalString cfg.qemu.forceAccel ( + if hostPkgs.stdenv.hostPlatform.isLinux then + '' + # Check for hardware-accelerated virtualisation support (KVM) + if [ ! -e /dev/kvm ]; then + echo "forceAccel is enabled but /dev/kvm does not exist." >&2 + echo "Hardware-accelerated virtualisation (KVM) is not available on this system." >&2 + exit 1 + elif [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + echo "forceAccel is enabled but /dev/kvm is not accessible (permission denied)." >&2 + echo "Check that the nix build user is in the 'kvm' group or that /dev/kvm has the correct permissions." >&2 + exit 1 + fi + '' + else if hostPkgs.stdenv.hostPlatform.isDarwin then + '' + # Check for hardware-accelerated virtualisation support (HVF) + if ! sysctl -n kern.hv_support 2>/dev/null | grep -q 1; then + echo "forceAccel is enabled but Hypervisor.framework is not available on this system." >&2 + exit 1 + fi + '' + else + '' + echo "forceAccel is enabled but no known accelerator is available for this platform." >&2 + exit 1 + '' + )} + # Start QEMU. - exec ${qemu-common.qemuBinary qemu} \ + exec ${ + qemu-common.qemuBinaryWith { + qemuPkg = qemu; + forceAccel = cfg.qemu.forceAccel; + } + } \ -name ${config.system.name} \ -m ${toString config.virtualisation.memorySize} \ -smp ${toString config.virtualisation.cores} \ @@ -728,6 +762,17 @@ in description = "QEMU package to use."; }; + forceAccel = mkOption { + type = types.bool; + default = false; + description = '' + Whether to force the use of hardware-accelerated virtualisation. + When enabled, QEMU will not fall back to the slower software + emulation (TCG) and will instead error out if the accelerator is not + available. + ''; + }; + options = mkOption { type = types.listOf types.str; default = [ ]; From 1087cdd48b9db8c26e875c1bd9488a5f78aa649e Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Fri, 17 Oct 2025 09:46:44 +0200 Subject: [PATCH 13/33] vhost-device-vsock: init at 0.3.0 --- .../by-name/vh/vhost-device-vsock/package.nix | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 pkgs/by-name/vh/vhost-device-vsock/package.nix diff --git a/pkgs/by-name/vh/vhost-device-vsock/package.nix b/pkgs/by-name/vh/vhost-device-vsock/package.nix new file mode 100644 index 000000000000..ef717a777d26 --- /dev/null +++ b/pkgs/by-name/vh/vhost-device-vsock/package.nix @@ -0,0 +1,45 @@ +{ + lib, + fetchFromGitHub, + rustPlatform, + installShellFiles, +}: + +rustPlatform.buildRustPackage (finalAttrs: { + pname = "vhost-device-vsock"; + version = "0.3.0"; + + src = fetchFromGitHub { + owner = "rust-vmm"; + repo = "vhost-device"; + tag = "vhost-device-vsock-v${finalAttrs.version}"; + hash = "sha256-g+u6WBJtizIgQwC0kkWdAcTiYCM1zMI4YBLVRU4MOrs="; + }; + + __structuredAttrs = true; + + outputs = [ + "out" + "man" + ]; + + cargoBuildFlags = "-p vhost-device-vsock"; + cargoTestFlags = "-p vhost-device-vsock"; + cargoHash = "sha256-mtORRCY/TNeIEgRCQ1ZbjpsykteRm2FHRveKaQxD/Pw="; + + nativeBuildInputs = [ installShellFiles ]; + + postInstall = '' + installManPage vhost-device-vsock/*.1 + ''; + + meta = { + homepage = "https://github.com/rust-vmm/vhost-device/blob/main/vhost-device-vsock/README.md"; + maintainers = with lib.maintainers; [ ma27 ]; + license = with lib.licenses; [ + asl20 + bsd3 + ]; + platforms = lib.platforms.linux; + }; +}) From 28a450372a2a3fc2804bbda326734caf3623c3db Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Fri, 17 Oct 2025 18:14:43 +0200 Subject: [PATCH 14/33] nixos/testing: allow setting test-wide warnings and assertions This allows us to e.g. use `mkRemovedOptionModule` which will come in handy in the upcoming commits. --- nixos/lib/testing/run.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nixos/lib/testing/run.nix b/nixos/lib/testing/run.nix index 646832f71e62..fcdd81412123 100644 --- a/nixos/lib/testing/run.nix +++ b/nixos/lib/testing/run.nix @@ -80,6 +80,10 @@ in }; }; + imports = [ + ../../modules/misc/assertions.nix + ]; + config = { rawTestDerivation = hostPkgs.stdenv.mkDerivation config.rawTestDerivationArg; rawTestDerivationArg = @@ -131,7 +135,7 @@ in }; test = lib.lazyDerivation { # lazyDerivation improves performance when only passthru items and/or meta are used. - derivation = config.rawTestDerivation; + derivation = lib.asserts.checkAssertWarn config.assertions config.warnings config.rawTestDerivation; inherit (config) passthru meta; }; From d406be44b98f041b0de03ff7aa2c35c0baac63c0 Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Fri, 17 Oct 2025 18:15:20 +0200 Subject: [PATCH 15/33] nixos/qemu-vm: add `enableSharedMemory` option This option configures a memfd backend for the VM's memory with the size of `virtualisation.memorySize` and uses that as default memory backend. This is required for e.g. vhost-device-vsock. The motivation for making this an option is that I decided to enable this by default for all NixOS tests to avoid changing essential QEMU options based on whether or not debugging is enabled. However, there should be an easy way to turn it off which is what this option provides. --- nixos/modules/virtualisation/qemu-vm.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index 33bd759a1e3f..0a2646a3364e 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -716,6 +716,8 @@ in }; virtualisation.qemu = { + enableSharedMemory = mkEnableOption "shared memory"; + package = mkOption { type = types.package; default = @@ -1338,6 +1340,10 @@ in "-device usb-kbd" "-device usb-tablet" ]) + (mkIf cfg.qemu.enableSharedMemory [ + "-object memory-backend-memfd,id=mem0,size=${toString config.virtualisation.memorySize}M,share=on" + "-machine memory-backend=mem0" + ]) ( let alphaNumericChars = lowerChars ++ upperChars ++ (map toString (range 0 9)); From 9dcf7113035c7abed314431195ad27dff4fcf14d Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Tue, 21 Oct 2025 18:44:38 +0200 Subject: [PATCH 16/33] nixos/test-driver: push allocation into the context-manager The context manager's purpose is to allocate its resources in `__enter__` and release them again in `__exit__`. Right now, the approach is merely a hack since we allocate everything in the constructor, but use the context-manager protocol as way to reliably terminate everything. This isn't a functional change, but merely a correctness change by using the methods the way they were intended. --- .../lib/test-driver/src/test_driver/driver.py | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index 9188e195cb35..4d1bb899e712 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -68,12 +68,16 @@ class Driver: and runs the tests""" tests: str - vlans: list[VLan] - machines_qemu: list[QemuMachine] - machines_nspawn: list[NspawnMachine] + vlans: list[VLan] = [] + machines_qemu: list[QemuMachine] = [] + machines_nspawn: list[NspawnMachine] = [] polling_conditions: list[PollingCondition] global_timeout: int race_timer: threading.Timer + vm_start_scripts: dict[str, str] + container_start_scripts: dict[str, str] + vlan_ids: list[int] + keep_machine_state: bool logger: AbstractLogger debug: DebugAbstract @@ -94,15 +98,23 @@ class Driver: self.tests = tests self.out_dir = out_dir self.global_timeout = global_timeout - self.race_timer = threading.Timer(global_timeout, self.terminate_test) self.logger = logger self.debug = debug + self.vlan_ids = list(set(vlans)) + self.polling_conditions = [] + self.keep_machine_state = keep_machine_state + self.global_timeout = global_timeout + self.vm_start_scripts = dict(zip(vm_names, vm_start_scripts)) + self.container_start_scripts = dict( + zip(container_names, container_start_scripts) + ) + def __enter__(self) -> "Driver": + self.race_timer = threading.Timer(self.global_timeout, self.terminate_test) tmp_dir = get_tmp_dir() with self.logger.nested("start all VLans"): - vlans = list(set(vlans)) - self.vlans = [VLan(nr, tmp_dir, self.logger) for nr in vlans] + self.vlans = [VLan(nr, tmp_dir, self.logger) for nr in self.vlan_ids] self.polling_conditions = [] @@ -110,16 +122,16 @@ class Driver: QemuMachine( name=name, start_command=vm_start_script, - keep_machine_state=keep_machine_state, + keep_machine_state=self.keep_machine_state, tmp_dir=tmp_dir, callbacks=[self.check_polling_conditions], out_dir=self.out_dir, logger=self.logger, ) - for name, vm_start_script in zip(vm_names, vm_start_scripts) + for name, vm_start_script in self.vm_start_scripts.items() ] - if len(container_start_scripts) > 0: + if len(self.container_start_scripts) > 0: self._init_nspawn_environment() self.machines_nspawn = [ @@ -128,16 +140,15 @@ class Driver: start_command=container_start_script, tmp_dir=tmp_dir, logger=self.logger, - keep_machine_state=keep_machine_state, + keep_machine_state=self.keep_machine_state, callbacks=[self.check_polling_conditions], out_dir=self.out_dir, ) - for name, container_start_script in zip( - container_names, - container_start_scripts, - ) + for name, container_start_script in self.container_start_scripts.items() ] + return self + def _init_nspawn_environment(self) -> None: assert os.geteuid() == 0, ( f"systemd-nspawn requires root to work. You are {os.geteuid()}" @@ -193,9 +204,6 @@ class Driver: machines.sort(key=lambda machine: machine.name) return machines - def __enter__(self) -> "Driver": - return self - def __exit__(self, *_: Any) -> None: with self.logger.nested("cleanup"): self.race_timer.cancel() From 1987c483d8ee7242b7edd4455d2d7d5a137e935b Mon Sep 17 00:00:00 2001 From: Maximilian Bosch Date: Wed, 22 Oct 2025 11:17:46 +0200 Subject: [PATCH 17/33] nixos/test-driver: use vhost-device-vsock for SSH backdoor `vhost-device-vsock`[1] is a custom implementation of AF_VSOCK, but the application on the host-side uses a UNIX domain-socket. This gives us the following nice properties: * We don't need to do `--arg sandbox-paths /dev/vhost-vsock` anymore for debugging builds within the sandbox. That means, untrusted users can also debug these kinds of tests now. * This prevents CID conflicts on the host-side, i.e. there's no need for using `sshBackdoor.vsockOffset` for tests anymore. A big shout-out goes to Allison Karlitskaya, the developer of test.thing[2] who talked about this approach to do AF_VSOCK on All Systems Go 2025. This patch requires systemd 258[3] because this contains `vhost-mux` in its SSH config which is needed to connect to the VMs from now on. To not blow up the patches even more, this only uses AF_VSOCK for the debugger. A potential follow-up for the future would be a removal of the current `backdoor.service` and replace it entirely by this functionality. The internal implementation tries to be consistent with how VLANs and machines are handled, i.e. the processes are started when the Driver's context is entered and cleaned up in __exit__(). I decided to push the process management and creation of sockets for vhost-device-vsock into its own class, that's an implementation detail and not a concern for the test-driver. In fact, `vhost-device-vsock` is something we can drop once QEMU implements native support for using AF_UNIX on the host-side[4]. `VsockPair` is its own class since returning e.g. a triple of `(Path, Path, Int)` would be ambiguous in what is the guest and what the host path (and frankly, I found it hard to distinguish the two when reading the docs of `vhost-device-vsock` initially). Finally, now that we can do the SSH backdoor without adding additional devices to the sandbox, I figured, it's time to write a test-case for it. [1] https://github.com/rust-vmm/vhost-device/blob/main/vhost-device-vsock/README.md [2] https://codeberg.org/lis/test.thing [3] https://github.com/NixOS/nixpkgs/pull/427968 [4] https://gitlab.com/qemu-project/qemu/-/issues/2095 --- ...nning-nixos-tests-interactively.section.md | 48 +++----- .../writing-nixos-tests.section.md | 14 +-- nixos/doc/manual/redirects.json | 3 - nixos/lib/test-driver/default.nix | 2 + .../test-driver/src/test_driver/__init__.py | 9 +- .../lib/test-driver/src/test_driver/driver.py | 103 ++++++++++++++++-- .../src/test_driver/machine/__init__.py | 29 ++++- nixos/lib/test-script-prepend.py | 1 + nixos/lib/testing/driver.nix | 24 ++++ nixos/lib/testing/nodes.nix | 39 ++----- nixos/tests/all-tests.nix | 1 + .../tests/nixos-test-driver/ssh-backdoor.nix | 36 ++++++ 12 files changed, 213 insertions(+), 96 deletions(-) create mode 100644 nixos/tests/nixos-test-driver/ssh-backdoor.nix diff --git a/nixos/doc/manual/development/running-nixos-tests-interactively.section.md b/nixos/doc/manual/development/running-nixos-tests-interactively.section.md index 572173cb4fbf..121abd51c804 100644 --- a/nixos/doc/manual/development/running-nixos-tests-interactively.section.md +++ b/nixos/doc/manual/development/running-nixos-tests-interactively.section.md @@ -88,52 +88,34 @@ An SSH-based backdoor to log into machines can be enabled with } ``` -::: {.warning} -Make sure to only enable the backdoor for interactive tests -(i.e. by using `interactive.sshBackdoor.enable`)! This is the only -supported configuration. - -Running a test in a sandbox with this will fail because `/dev/vhost-vsock` isn't available -in the sandbox. -::: - This creates a [vsock socket](https://man7.org/linux/man-pages/man7/vsock.7.html) for each VM to log in with SSH. This configures root login with an empty password. -When the VMs get started interactively with the test-driver, it's possible to -connect to `machine` with +On the host-side a UNIX domain-socket is used with +[vhost-device-vsock](https://github.com/rust-vmm/vhost-device/blob/main/vhost-device-vsock/README.md). +That way, it's not necessary to assign system-wide unique vsock numbers. ``` -$ ssh vsock/3 -o User=root +$ ssh vsock-mux//tmp/path/to/host -o User=root ``` -The socket numbers correspond to the node number of the test VM, but start -at three instead of one because that's the lowest possible -vsock number. The exact SSH commands are also printed out when starting -`nixos-test-driver`. +The socket paths are printed when starting the test driver: + +``` +Note: this requires systemd-ssh-proxy(1) to be enabled (default on NixOS 25.05 and newer). + machine: ssh -o User=root vsock-mux//tmp/tmpg1rp9nti/machine_host.socket +``` On non-NixOS systems you'll probably need to enable the SSH config from {manpage}`systemd-ssh-proxy(1)` yourself. -If starting VM fails with an error like +During a test-run, it's possible to print the SSH commands again by running ``` -qemu-system-x86_64: -device vhost-vsock-pci,guest-cid=3: vhost-vsock: unable to set guest cid: Address already in use -``` - -it means that the vsock numbers for the VMs are already in use. This can happen -if another interactive test with SSH backdoor enabled is running on the machine. - -In that case, you need to assign another range of vsock numbers. You can pick another -offset with - -```nix -{ - sshBackdoor = { - enable = true; - vsockOffset = 23542; - }; -} +In [2]: dump_machine_ssh() +SSH backdoor enabled, the machines can be accessed like this: +Note: this requires systemd-ssh-proxy(1) to be enabled (default on NixOS 25.05 and newer). + machine: ssh -o User=root vsock-mux//tmp/tmpg1rp9nti/machine_host.socket ``` ## Port forwarding to NixOS test VMs {#sec-nixos-test-port-forwarding} diff --git a/nixos/doc/manual/development/writing-nixos-tests.section.md b/nixos/doc/manual/development/writing-nixos-tests.section.md index 7e7414bd61a9..8296b4904d3a 100644 --- a/nixos/doc/manual/development/writing-nixos-tests.section.md +++ b/nixos/doc/manual/development/writing-nixos-tests.section.md @@ -512,19 +512,11 @@ Once you are in the sandbox shell, you can access the VMs (for example, `machine with SSH over vsock: ``` -bash# ssh -F ./ssh_config vsock/3 +bash# ssh -F ./ssh_config -o User=root vsock-mux//tmp/.../machine_host.socket ``` -For the AF_VSOCK feature to work, `/dev/vhost-vsock` is needed in the sandbox -which can be done with e.g. - -``` -nix-build -A nixosTests.foo --option sandbox-paths /dev/vhost-vsock -``` - -As described in [](#sec-nixos-test-ssh-access), the numbers for vsock start at -`3` instead of `1`. So the first VM in the network (sorted alphabetically) can -be accessed with `vsock/3`. +The socket paths are printed at the beginning of the test. See +[](#sec-nixos-test-ssh-access) for more context. ### SSH access to test containers {#sec-test-container-ssh-access} diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index feb535c2142c..ad1ef882c85f 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -2174,9 +2174,6 @@ "test-opt-sshBackdoor.enable": [ "index.html#test-opt-sshBackdoor.enable" ], - "test-opt-sshBackdoor.vsockOffset": [ - "index.html#test-opt-sshBackdoor.vsockOffset" - ], "test-opt-enableDebugHook": [ "index.html#test-opt-enableDebugHook" ], diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index 72aa6b11bfe5..65ddb4049b0c 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -14,6 +14,7 @@ remote-pdb, netpbm, + vhost-device-vsock, nixosTests, qemu_pkg ? qemu_test, qemu_test, @@ -56,6 +57,7 @@ buildPythonApplication { socat util-linux vde2 + vhost-device-vsock ] ++ lib.optionals enableNspawn [ systemd diff --git a/nixos/lib/test-driver/src/test_driver/__init__.py b/nixos/lib/test-driver/src/test_driver/__init__.py index 0f42f2842c77..fca8a135d082 100644 --- a/nixos/lib/test-driver/src/test_driver/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/__init__.py @@ -147,9 +147,9 @@ def main() -> None: type=Path, ) arg_parser.add_argument( - "--dump-vsocks", + "--enable-ssh-backdoor", help="indicates that the interactive SSH backdoor is active and dumps information about it on start", - type=int, + action="store_true", ) args = arg_parser.parse_args() @@ -197,9 +197,10 @@ def main() -> None: keep_machine_state=args.keep_machine_state, global_timeout=args.global_timeout, debug=debugger, + enable_ssh_backdoor=args.enable_ssh_backdoor, ) as driver: - if offset := args.dump_vsocks: - driver.dump_machine_ssh(offset) + if args.enable_ssh_backdoor: + driver.dump_machine_ssh() if args.interactive: history_dir = os.getcwd() history_path = os.path.join(history_dir, ".nixos-test-history") diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index 4d1bb899e712..3233f6afc97e 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -8,6 +8,7 @@ import threading import traceback from collections.abc import Callable, Iterator from contextlib import AbstractContextManager, contextmanager +from dataclasses import dataclass from pathlib import Path from typing import Any from unittest import TestCase @@ -63,6 +64,45 @@ def pythonize_name(name: str) -> str: return re.sub(r"^[^A-Za-z_]|[^A-Za-z0-9_]", "_", name) +@dataclass +class VsockPair: + guest: Path + host: Path + cid: int + + +class VHostDeviceVsock: + def __init__(self, tmp_dir: Path, machines: list[str]): + self.temp_dir_handle = tempfile.TemporaryDirectory(dir=tmp_dir) + self.temp_dir = Path(self.temp_dir_handle.name) + self.sockets = { + machine: VsockPair( + self.temp_dir / f"{machine}_guest.socket", + self.temp_dir / f"{machine}_host.socket", + cid, + ) + for cid, machine in enumerate(machines, start=3) + } + + self.vhost_proc = subprocess.Popen( + [ + "vhost-device-vsock", + *( + arg + for vsock_pair in self.sockets.values() + for arg in ( + "--vm", + f"guest-cid={vsock_pair.cid},socket={vsock_pair.guest},uds-path={vsock_pair.host}", + ) + ), + ] + ) + + def __del__(self) -> None: + self.vhost_proc.kill() + self.temp_dir_handle.cleanup() + + class Driver: """A handle to the driver that sets up the environment and runs the tests""" @@ -80,6 +120,8 @@ class Driver: keep_machine_state: bool logger: AbstractLogger debug: DebugAbstract + vhost_vsock: VHostDeviceVsock | None = None + enable_ssh_backdoor: bool def __init__( self, @@ -94,6 +136,7 @@ class Driver: keep_machine_state: bool = False, global_timeout: int = 24 * 60 * 60 * 7, debug: DebugAbstract = DebugNop(), + enable_ssh_backdoor: bool = False, ): self.tests = tests self.out_dir = out_dir @@ -108,6 +151,7 @@ class Driver: self.container_start_scripts = dict( zip(container_names, container_start_scripts) ) + self.enable_ssh_backdoor = enable_ssh_backdoor def __enter__(self) -> "Driver": self.race_timer = threading.Timer(self.global_timeout, self.terminate_test) @@ -118,6 +162,12 @@ class Driver: self.polling_conditions = [] + if self.enable_ssh_backdoor and self.vm_start_scripts: + with self.logger.nested("start vhost-device-vsock"): + self.vhost_vsock = VHostDeviceVsock( + tmp_dir, list(self.vm_start_scripts.keys()) + ) + self.machines_qemu = [ QemuMachine( name=name, @@ -127,6 +177,16 @@ class Driver: callbacks=[self.check_polling_conditions], out_dir=self.out_dir, logger=self.logger, + vsock_host=( + self.vhost_vsock.sockets[name].host + if self.vhost_vsock is not None + else None + ), + vsock_guest=( + self.vhost_vsock.sockets[name].guest + if self.vhost_vsock is not None + else None + ), ) for name, vm_start_script in self.vm_start_scripts.items() ] @@ -219,6 +279,14 @@ class Driver: except Exception as e: self.logger.error(f"Error during cleanup of vlan{vlan.nr}: {e}") + if self.enable_ssh_backdoor: + try: + del self.vhost_vsock + except Exception as e: + self.logger.error( + f"Error during cleanup of vhost-device-vsock process: {e}" + ) + def subtest(self, name: str) -> Iterator[None]: """Group logs under a given test name""" with self.logger.subtest(name): @@ -256,6 +324,7 @@ class Driver: NspawnMachine=NspawnMachine, # for typing t=AssertionTester(), debug=self.debug, + dump_machine_ssh=self.dump_machine_ssh, ) machine_symbols = {pythonize_name(m.name): m for m in self.machines} # If there's exactly one machine, make it available under the name @@ -275,18 +344,28 @@ class Driver: ) return {**general_symbols, **machine_symbols, **vlan_symbols} - def dump_machine_ssh(self, offset: int) -> None: - print("SSH backdoor enabled, the machines can be accessed like this:") - print( - f"{Style.BRIGHT}Note:{Style.RESET_ALL} vsocks require {Style.BRIGHT}systemd-ssh-proxy(1){Style.RESET_ALL} to be enabled (default on NixOS 25.05 and newer)." - ) - longest_name = len(max((machine.name for machine in self.machines), key=len)) - for index, machine in enumerate(self.machines, start=offset + 1): - name = machine.name - spaces = " " * (longest_name - len(name) + 2) + def dump_machine_ssh(self) -> None: + if not self.enable_ssh_backdoor: + return + + assert self.vhost_vsock is not None + + if self.machines: + print("SSH backdoor enabled, the machines can be accessed like this:") print( - f" {name}:{spaces}{Style.BRIGHT}{machine.ssh_backdoor_command(index)}{Style.RESET_ALL}" + f"{Style.BRIGHT}Note:{Style.RESET_ALL} this requires {Style.BRIGHT}systemd-ssh-proxy(1){Style.RESET_ALL} to be enabled (default on NixOS 25.05 and newer)." ) + longest_name = len( + max((machine.name for machine in self.machines), key=len) + ) + for index, machine in enumerate(self.machines): + name = machine.name + spaces = " " * (longest_name - len(name) + 2) + print( + f" {name}:{spaces}{Style.BRIGHT}{machine.ssh_backdoor_command()}{Style.RESET_ALL}" + ) + else: + print("SSH backdoor enabled, but no machines defined") def test_script(self) -> None: """Run the test script""" @@ -386,6 +465,10 @@ class Driver: """ tmp_dir = get_tmp_dir() + if self.enable_ssh_backdoor: + self.logger.warning( + f"create_machine({name}): not enabling SSH backdoor, this is not supported for VMs created with create_machine!" + ) return QemuMachine( tmp_dir=tmp_dir, out_dir=self.out_dir, diff --git a/nixos/lib/test-driver/src/test_driver/machine/__init__.py b/nixos/lib/test-driver/src/test_driver/machine/__init__.py index 365826dc22ff..c384d5cd0457 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -147,6 +147,7 @@ class QemuStartCommand: qmp_socket_path: Path, shell_socket_path: Path, allow_reboot: bool = False, + vsock_guest: Path | None = None, ) -> str: display_opts = "" @@ -170,6 +171,12 @@ class QemuStartCommand: if not allow_reboot: qemu_opts += " -no-reboot" + if vsock_guest is not None: + qemu_opts += ( + f" -chardev socket,id=vsock_ssh,path={vsock_guest} " + f"-device vhost-user-vsock-pci,chardev=vsock_ssh " + ) + return ( f"{self._cmd}" f" -qmp unix:{qmp_socket_path},server=on,wait=off" @@ -203,10 +210,15 @@ class QemuStartCommand: qmp_socket_path: Path, shell_socket_path: Path, allow_reboot: bool, + vsock_guest: Path | None = None, ) -> subprocess.Popen: return subprocess.Popen( self.cmd( - monitor_socket_path, qmp_socket_path, shell_socket_path, allow_reboot + monitor_socket_path, + qmp_socket_path, + shell_socket_path, + allow_reboot, + vsock_guest, ), stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -724,6 +736,9 @@ class QemuMachine(BaseMachine): shell: socket.socket | None serial_thread: threading.Thread | None + vsock_guest: Path | None + vsock_host: Path | None + booted: bool connected: bool # Store last serial console lines for use @@ -741,6 +756,8 @@ class QemuMachine(BaseMachine): name: str | None = None, keep_machine_state: bool = False, callbacks: list[Callable] | None = None, + vsock_guest: Path | None = None, + vsock_host: Path | None = None, ) -> None: self.start_command = QemuStartCommand(start_command) super().__init__( @@ -753,6 +770,8 @@ class QemuMachine(BaseMachine): ) self.full_console_log = [] + self.vsock_guest = vsock_guest + self.vsock_host = vsock_host # set up directories self.monitor_path = self.state_dir / "monitor" @@ -769,8 +788,9 @@ class QemuMachine(BaseMachine): self.booted = False self.connected = False - def ssh_backdoor_command(self, index: int) -> str: - return f"ssh -o User=root vsock/{index}" + def ssh_backdoor_command(self) -> str: + assert self.vsock_host is not None + return f"ssh -o User=root vsock-mux/{self.vsock_host}" def is_up(self) -> bool: return self.booted and self.connected @@ -1224,6 +1244,7 @@ class QemuMachine(BaseMachine): self.qmp_path, self.shell_path, allow_reboot, + self.vsock_guest, ) self.monitor, _ = monitor_socket.accept() self.shell, _ = shell_socket.accept() @@ -1434,7 +1455,7 @@ class NspawnMachine(BaseMachine): self.machine_sock_path = self.tmp_dir / f"{self.name}-nspawn.sock" - def ssh_backdoor_command(self, index: int) -> str: + def ssh_backdoor_command(self) -> str: # documented in systemd-ssh-generator(8) and https://systemd.io/CONTAINER_INTERFACE/ socket_path = f"/run/systemd/nspawn/unix-export/{self.name}/ssh" proxy_cmd = f"socat - UNIX-CLIENT:{socket_path}" diff --git a/nixos/lib/test-script-prepend.py b/nixos/lib/test-script-prepend.py index e836e779300c..b1ca8e7b1678 100644 --- a/nixos/lib/test-script-prepend.py +++ b/nixos/lib/test-script-prepend.py @@ -58,3 +58,4 @@ serial_stdout_on: Callable[[], None] polling_condition: PollingConditionProtocol debug: DebugAbstract t: TestCase +dump_machine_ssh: Callable[[], None] diff --git a/nixos/lib/testing/driver.nix b/nixos/lib/testing/driver.nix index 5cd7c2ebb806..fa3abf5c0c7a 100644 --- a/nixos/lib/testing/driver.nix +++ b/nixos/lib/testing/driver.nix @@ -7,6 +7,8 @@ let inherit (lib) mkOption types literalMD; + inherit (config) sshBackdoor; + # Reifies and correctly wraps the python test driver for # the respective qemu version and with or without ocr support testDriver = config.pythonTestDriverPackage.override { @@ -232,5 +234,27 @@ in # make available on the test runner passthru.driver = config.driver; + + nodeDefaults = + { config, ... }: + { + # This is needed for the SSH backdoor to function. + # Set this to `true` by default to not change essential QEMU flags + # depending on whether debugging is enabled. + # + # If needed, this can still be turned off. + virtualisation.qemu.enableSharedMemory = lib.mkDefault true; + + assertions = [ + { + assertion = sshBackdoor.enable -> config.virtualisation.qemu.enableSharedMemory; + message = '' + When turning on the SSH backdoor of the NixOS test-framework, + `virtualisation.qemu.enableSharedMemory` MUST be `true` + (affected: ${config.networking.hostName}). + ''; + } + ]; + }; }; } diff --git a/nixos/lib/testing/nodes.nix b/nixos/lib/testing/nodes.nix index 7384dec68895..ab20d8505354 100644 --- a/nixos/lib/testing/nodes.nix +++ b/nixos/lib/testing/nodes.nix @@ -13,6 +13,7 @@ let mapAttrs mkIf mkMerge + mkRemovedOptionModule mkOption optionalAttrs types @@ -128,6 +129,12 @@ let in { + imports = [ + (mkRemovedOptionModule [ "sshBackdoor" "vsockOffset" ] '' + The option `sshBackdoor.vsockOffset` has been removed from the testing framework. + The functionality provided by it is not needed anymore. + '') + ]; options = { sshBackdoor = { @@ -137,22 +144,6 @@ in type = types.bool; description = "Whether to turn on the VSOCK-based access to all VMs. This provides an unauthenticated access intended for debugging."; }; - vsockOffset = mkOption { - default = 2; - type = types.ints.between 2 4294967296; - description = '' - This field is only relevant when multiple users run the (interactive) - driver outside the sandbox and with the SSH backdoor activated. - The typical symptom for this being a problem are error messages like this: - `vhost-vsock: unable to set guest cid: Address already in use` - - This option allows to assign an offset to each vsock number to - resolve this. - - This is a 32bit number. The lowest possible vsock number is `3` - (i.e. with the lowest node number being `1`, this is 2+1). - ''; - }; }; node.type = mkOption { @@ -318,7 +309,7 @@ in passthru.containers = config.containers; extraDriverArgs = mkIf config.sshBackdoor.enable [ - "--dump-vsocks=${toString config.sshBackdoor.vsockOffset}" + "--enable-ssh-backdoor" ]; defaults = mkMerge [ @@ -341,20 +332,6 @@ in }) ]; - nodeDefaults = mkIf config.sshBackdoor.enable ( - let - inherit (config.sshBackdoor) vsockOffset; - in - { config, ... }: - { - virtualisation.qemu.options = [ - "-device vhost-vsock-pci,guest-cid=${ - toString (config.virtualisation.test.nodeNumber + vsockOffset) - }" - ]; - } - ); - # Docs: nixos/doc/manual/development/writing-nixos-tests.section.md /** See https://nixos.org/manual/nixos/unstable#sec-override-nixos-test diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 9a059c30563c..6a3f2875a318 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -149,6 +149,7 @@ in lib-extend = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./nixos-test-driver/lib-extend.nix { }; node-name = runTest ./nixos-test-driver/node-name.nix; busybox = runTest ./nixos-test-driver/busybox.nix; + ssh-backdoor = runTestOn [ "x86_64-linux" ] ./nixos-test-driver/ssh-backdoor.nix; console-log = runTest ./nixos-test-driver/console-log.nix; containers = runTest ./nixos-test-driver/containers.nix; driver-timeout = diff --git a/nixos/tests/nixos-test-driver/ssh-backdoor.nix b/nixos/tests/nixos-test-driver/ssh-backdoor.nix new file mode 100644 index 000000000000..5c84b24474e9 --- /dev/null +++ b/nixos/tests/nixos-test-driver/ssh-backdoor.nix @@ -0,0 +1,36 @@ +{ pkgs, lib, ... }: +{ + name = "ssh-backdoor"; + sshBackdoor.enable = true; + + nodes.machine = { }; + + testScript = '' + import subprocess + + start_all() + machine.wait_for_unit("multi-user.target") + + assert driver.vhost_vsock is not None + host_socket = driver.vhost_vsock.sockets["machine"].host + + with subtest("ssh from the host via systemd-ssh-proxy"): + subprocess.run( + [ + "${lib.getExe pkgs.openssh}", + "-vvv", + f"vsock-mux/{host_socket}", + "-o", + "User=root", + "-F", + # The backdoor feature of the driver copies this into NIX_BUILD_TOP. + # We can't do this here since `enableDebugHook=true;` would halt + # instead of terminating the test execution if it fails. + "${pkgs.systemd}/lib/systemd/ssh_config.d/20-systemd-ssh-proxy.conf", + "--", + "true" + ], + check=True + ) + ''; +} From b787cf0ca38527e6174a3a59cc1c1d6b15125193 Mon Sep 17 00:00:00 2001 From: Michael Schneider Date: Tue, 14 Apr 2026 14:43:43 +0100 Subject: [PATCH 18/33] nixos/test-driver: Hide vde switch log messages --- .../lib/test-driver/src/test_driver/logger.py | 29 ++++++++++++++-- nixos/lib/test-driver/src/test_driver/vlan.py | 34 ++++++++++++++++--- nixos/lib/testing/driver.nix | 1 + 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/nixos/lib/test-driver/src/test_driver/logger.py b/nixos/lib/test-driver/src/test_driver/logger.py index bce73a402234..a5bf7c61c1a4 100644 --- a/nixos/lib/test-driver/src/test_driver/logger.py +++ b/nixos/lib/test-driver/src/test_driver/logger.py @@ -18,9 +18,10 @@ from junit_xml import TestCase, TestSuite class LogLevel(IntEnum): - INFO = 1 - WARNING = 2 - ERROR = 3 + DEBUG = 1 + INFO = 2 + WARNING = 3 + ERROR = 4 class AbstractLogger(ABC): @@ -38,6 +39,10 @@ class AbstractLogger(ABC): def nested(self, message: str, attributes: dict[str, str] = {}) -> Iterator[None]: pass + @abstractmethod + def debug(self, *args, **kwargs) -> None: + pass + @abstractmethod def info(self, *args, **kwargs) -> None: pass @@ -102,6 +107,10 @@ class JunitXMLLogger(AbstractLogger): self.log(message) yield + def debug(self, *args, **kwargs) -> None: + if self._log_level <= LogLevel.DEBUG: + self.tests[self.currentSubtest].stdout += args[0] + os.linesep + def info(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.INFO: self.tests[self.currentSubtest].stdout += args[0] + os.linesep @@ -171,6 +180,10 @@ class CompositeLogger(AbstractLogger): stack.enter_context(logger.nested(message, attributes)) yield + def debug(self, *args, **kwargs) -> None: + for logger in self.logger_list: + logger.debug(*args, **kwargs) + def info(self, *args, **kwargs) -> None: for logger in self.logger_list: logger.info(*args, **kwargs) @@ -237,6 +250,12 @@ class TerminalLogger(AbstractLogger): toc = time.time() self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)", attributes) + def debug(self, *args, **kwargs) -> None: + if self._log_level <= LogLevel.DEBUG: + self._eprint( + Style.DIM + self.maybe_prefix(args[0], kwargs) + Style.RESET_ALL # ty: ignore[unsupported-operator] + ) + def info(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.INFO: self.log(*args, **kwargs) @@ -296,6 +315,10 @@ class XMLLogger(AbstractLogger): self.xml.characters(message) self.xml.endElement("line") + def debug(self, *args, **kwargs) -> None: + if self._log_level <= LogLevel.DEBUG: + self.log(*args, **kwargs) + def info(self, *args, **kwargs) -> None: if self._log_level <= LogLevel.INFO: self.log(*args, **kwargs) diff --git a/nixos/lib/test-driver/src/test_driver/vlan.py b/nixos/lib/test-driver/src/test_driver/vlan.py index eed66dcd13cb..232749da7544 100644 --- a/nixos/lib/test-driver/src/test_driver/vlan.py +++ b/nixos/lib/test-driver/src/test_driver/vlan.py @@ -4,6 +4,7 @@ import io import os import select import subprocess +import threading import typing from pathlib import Path @@ -57,6 +58,11 @@ class VLan: def __repr__(self) -> str: return f"" + def _log_stream(self, stream: typing.IO[str], prefix: str) -> None: + """Read lines from a stream and log via debug().""" + for line in stream: + self.logger.debug(f"{prefix}: {line.rstrip()}") + def __init__(self, nr: int, tmp_dir: Path, logger: AbstractLogger): self.nr = nr self.socket_dir = tmp_dir / f"vde{self.nr}.ctl" @@ -66,7 +72,7 @@ class VLan: # TODO: don't side-effect environment here os.environ[f"QEMU_VDE_SOCKET_{self.nr}"] = str(self.socket_dir) - self.logger.info("start vlan") + self.logger.debug("start vlan") self.process = subprocess.Popen( [ @@ -84,7 +90,7 @@ class VLan: bufsize=1, # Line buffered. stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=None, # Do not swallow stderr. + stderr=subprocess.STDOUT, text=True, ) self.pid = self.process.pid @@ -117,19 +123,37 @@ class VLan: if "1000 Success" in line: break + self._stdout_thread = threading.Thread( + target=self._log_stream, + args=(self.process.stdout, f"vde_switch[{self.nr}]"), + daemon=True, + ) + self._stdout_thread.start() + # This is needed to allow systemd-nspawn containers to communicate # with VMs connected to the VLAN. - self.logger.info(f"creating tap interface {self.tap_name}") + self.logger.debug(f"creating tap interface {self.tap_name}") self.plug_process = subprocess.Popen( ["vde_plug2tap", "-s", self.socket_dir, self.tap_name], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, ) + assert self.plug_process.stdout is not None + self._plug_stdout_thread = threading.Thread( + target=self._log_stream, + args=(self.plug_process.stdout, f"vde_plug2tap[{self.nr}]"), + daemon=True, + ) + self._plug_stdout_thread.start() + assert (self.socket_dir / "ctl").exists(), "cannot start vde_switch" - self.logger.info(f"running vlan (pid {self.pid}; ctl {self.socket_dir})") + self.logger.debug(f"running vlan (pid {self.pid}; ctl {self.socket_dir})") def stop(self) -> None: - self.logger.info(f"kill vlan (pid {self.pid})") + self.logger.debug(f"kill vlan (pid {self.pid})") assert self.process.stdin is not None self.process.stdin.close() if self.plug_process: diff --git a/nixos/lib/testing/driver.nix b/nixos/lib/testing/driver.nix index e9808907b2c5..606a7e225b68 100644 --- a/nixos/lib/testing/driver.nix +++ b/nixos/lib/testing/driver.nix @@ -234,6 +234,7 @@ in logLevel = mkOption { description = "Log level for the test driver."; type = types.enum [ + "debug" "info" "warning" "error" From 9fe0c5a2bdae72128af203dcae9aa774dcadf43f Mon Sep 17 00:00:00 2001 From: Sergei Trofimovich Date: Wed, 15 Apr 2026 21:49:02 +0100 Subject: [PATCH 19/33] time: 1.9 -> 1.10 Changes: https://lists.gnu.org/archive/html/bug-time/2026-04/msg00003.html --- pkgs/by-name/ti/time/package.nix | 14 ++------- ...1.9-fix-sighandler-prototype-for-c23.patch | 30 ------------------- .../time-1.9-implicit-func-decl-clang.patch | 24 --------------- 3 files changed, 3 insertions(+), 65 deletions(-) delete mode 100644 pkgs/by-name/ti/time/time-1.9-fix-sighandler-prototype-for-c23.patch delete mode 100644 pkgs/by-name/ti/time/time-1.9-implicit-func-decl-clang.patch diff --git a/pkgs/by-name/ti/time/package.nix b/pkgs/by-name/ti/time/package.nix index 79b78c261fd9..8573ec48ca06 100644 --- a/pkgs/by-name/ti/time/package.nix +++ b/pkgs/by-name/ti/time/package.nix @@ -6,21 +6,13 @@ stdenv.mkDerivation (finalAttrs: { pname = "time"; - version = "1.9"; + version = "1.10"; src = fetchurl { - url = "mirror://gnu/time/time-${finalAttrs.version}.tar.gz"; - hash = "sha256-+6zwyB5iQp3z4zvaTO44dWYE8Y4B2XczjiMwaj47Uh4="; + url = "mirror://gnu/time/time-${finalAttrs.version}.tar.xz"; + hash = "sha256-cGv3uERMqeuQN+ntoY4dDrfCMnrn2MLOOkgjxfgMexE="; }; - patches = [ - # fixes cross-compilation to riscv64-linux - ./time-1.9-implicit-func-decl-clang.patch - # https://lists.gnu.org/archive/html/bug-time/2025-10/msg00000.html - # fix compilation with gcc15 - ./time-1.9-fix-sighandler-prototype-for-c23.patch - ]; - outputs = [ "out" "info" diff --git a/pkgs/by-name/ti/time/time-1.9-fix-sighandler-prototype-for-c23.patch b/pkgs/by-name/ti/time/time-1.9-fix-sighandler-prototype-for-c23.patch deleted file mode 100644 index d092fa39ef2f..000000000000 --- a/pkgs/by-name/ti/time/time-1.9-fix-sighandler-prototype-for-c23.patch +++ /dev/null @@ -1,30 +0,0 @@ -In C23 functions with empty argument list in the prototype are treated -as taking no arguments. This means that the `int` argument of the -sighandler must be specified explicitly or the code will fail to -compile due to mismatched function type. - -Signed-off-by: Marcin Serwin ---- -This fixes the same issue as - and - but does not -rely on `sighandler_t` which is a GNU extension. - - src/time.c | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/src/time.c b/src/time.c -index 7b401bc..88287dd 100644 ---- a/src/time.c -+++ b/src/time.c -@@ -77,7 +77,7 @@ enum - - - /* A Pointer to a signal handler. */ --typedef RETSIGTYPE (*sighandler) (); -+typedef RETSIGTYPE (*sighandler) (int); - - /* msec = milliseconds = 1/1,000 (1*10e-3) second. - usec = microseconds = 1/1,000,000 (1*10e-6) second. */ --- -2.51.0 diff --git a/pkgs/by-name/ti/time/time-1.9-implicit-func-decl-clang.patch b/pkgs/by-name/ti/time/time-1.9-implicit-func-decl-clang.patch deleted file mode 100644 index ca76b5a50050..000000000000 --- a/pkgs/by-name/ti/time/time-1.9-implicit-func-decl-clang.patch +++ /dev/null @@ -1,24 +0,0 @@ -https://lists.gnu.org/archive/html/bug-time/2022-08/msg00001.html - -From c8deae54f92d636878097063b411af9fb5262ad3 Mon Sep 17 00:00:00 2001 -From: Khem Raj -Date: Mon, 15 Aug 2022 07:24:24 -0700 -Subject: [PATCH] include string.h for memset() - -Fixes implicit function declaration warning e.g. - -resuse.c:103:3: error: call to undeclared library function 'memset' with type 'void *(void *, int, unsigned long)' - -Upstream-Status: Submitted [https://lists.gnu.org/archive/html/bug-time/2022-08/msg00001.html] -Signed-off-by: Khem Raj ---- a/src/resuse.c -+++ b/src/resuse.c -@@ -22,6 +22,7 @@ - */ - - #include "config.h" -+#include - #include - #include - #include - From dff412463b0971f07b0f89a5c746ea978d2764ad Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:32:11 +1000 Subject: [PATCH 20/33] mimalloc: 3.1.6 -> 3.3.0 Diff: https://github.com/microsoft/mimalloc/compare/v3.1.6...v3.3.0 --- pkgs/by-name/mi/mimalloc/package.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/by-name/mi/mimalloc/package.nix b/pkgs/by-name/mi/mimalloc/package.nix index 34cea94853a2..4e744c42ecad 100644 --- a/pkgs/by-name/mi/mimalloc/package.nix +++ b/pkgs/by-name/mi/mimalloc/package.nix @@ -12,13 +12,13 @@ let in stdenv.mkDerivation (finalAttrs: { pname = "mimalloc"; - version = "3.1.6"; + version = "3.3.0"; src = fetchFromGitHub { owner = "microsoft"; repo = "mimalloc"; tag = "v${finalAttrs.version}"; - hash = "sha256-7zG0Sqloanz/b+fkJ4wzO86uBmtf9fdYNAT9ixLouyY="; + hash = "sha256-xy9gPihw3xvhnd6BrCYfMnnRp5dPSodynKRToYwxuzg="; }; doCheck = !stdenv.hostPlatform.isStatic; From c62f8fff2eccf4c8101f176eb9cb1407ec8b0041 Mon Sep 17 00:00:00 2001 From: Jacek Galowicz Date: Thu, 16 Apr 2026 11:47:04 +0100 Subject: [PATCH 21/33] nixos-test-driver: drop unneeded sentinel object --- nixos/lib/test-driver/src/test_driver/driver.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index 50b6fdb2170d..0bbf80f76a12 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -27,8 +27,6 @@ from test_driver.machine import ( from test_driver.polling_condition import PollingCondition from test_driver.vlan import VLan -SENTINEL = object() - class AssertionTester(TestCase): """ From ca0c8d9aa2e66abeb2ca4bbf433d22e34fcfb42e Mon Sep 17 00:00:00 2001 From: Jacek Galowicz Date: Thu, 16 Apr 2026 11:55:45 +0100 Subject: [PATCH 22/33] nixos-test-driver: Don't document internal methods prefixed with underscore --- nixos/lib/test-driver/src/extract-docstrings.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/nixos/lib/test-driver/src/extract-docstrings.py b/nixos/lib/test-driver/src/extract-docstrings.py index 767f0ca9fbec..ae261cb8486e 100644 --- a/nixos/lib/test-driver/src/extract-docstrings.py +++ b/nixos/lib/test-driver/src/extract-docstrings.py @@ -66,17 +66,20 @@ def function_docstrings(functions: list[ast.FunctionDef]) -> str | None: def machine_methods( class_name: str, class_definitions: list[ast.ClassDef] ) -> list[ast.FunctionDef]: - """Given a class name and a list of class definitions, returns the list of function definitions - for the class matching the given name. + """ + Given a class name and a list of class definitions, returns the list of + function definitions for the class matching the given name. """ machine_class = next(filter(lambda x: x.name == class_name, class_definitions)) assert machine_class is not None - function_definitions = [ - node for node in machine_class.body if isinstance(node, ast.FunctionDef) - ] - function_definitions.sort(key=lambda x: x.name) - return function_definitions + methods = [node for node in machine_class.body if isinstance(node, ast.FunctionDef)] + methods.sort(key=lambda x: x.name) + + # Do not document internal functions prefixed with underscore + methods = [m for m in methods if not m.name.startswith("_")] + + return methods def main() -> None: From 73c38365527a611c2b9be8cc306f3661c23517af Mon Sep 17 00:00:00 2001 From: Jacek Galowicz Date: Thu, 16 Apr 2026 11:44:14 +0100 Subject: [PATCH 23/33] nixos-test-driver: don't prepare sandbox environemnt outside of sandbox --- nixos/lib/test-driver/src/test_driver/driver.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index 50b6fdb2170d..f87728f55a95 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -64,6 +64,14 @@ def pythonize_name(name: str) -> str: return re.sub(r"^[^A-Za-z_]|[^A-Za-z0-9_]", "_", name) +def in_nix_sandbox() -> bool: + # There seems to be no better method at the time + typical_nix_env_vars = "NIX_BUILD_TOP" in os.environ + nix_shell_marker = "IN_NIX_SHELL" in os.environ + + return typical_nix_env_vars and not nix_shell_marker + + @dataclass class VsockPair: guest: Path @@ -191,7 +199,7 @@ class Driver: for name, vm_start_script in self.vm_start_scripts.items() ] - if len(self.container_start_scripts) > 0: + if len(self.container_start_scripts) > 0 and in_nix_sandbox(): self._init_nspawn_environment() self.machines_nspawn = [ From df17d7a1494204c6a9475b3330ae82a99e7161d2 Mon Sep 17 00:00:00 2001 From: Michael Schneider Date: Thu, 16 Apr 2026 20:50:16 +0100 Subject: [PATCH 24/33] nixos/test-driver: add remaining tests to passthru.tests --- nixos/lib/test-driver/default.nix | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index 69967ccf325e..f68d1f041810 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -67,9 +67,8 @@ buildPythonApplication { tesseract4 ]; - passthru.tests = { - inherit (nixosTests.nixos-test-driver) driver-timeout; - }; + # containers test requires extra nix features that are not available in ofborg. + passthru.tests = removeAttrs nixosTests.nixos-test-driver [ "containers" ]; doCheck = true; From f1bcb61731224bd8440510fc620d3c51f3e51c85 Mon Sep 17 00:00:00 2001 From: Jacek Galowicz Date: Tue, 14 Apr 2026 16:51:45 +0100 Subject: [PATCH 25/33] nixos-test-driver: use configuration file instead of scattered env vars --- nixos/lib/test-driver/default.nix | 2 + .../test-driver/src/test_driver/__init__.py | 101 ++++-------------- .../lib/test-driver/src/test_driver/driver.py | 78 +++++++------- nixos/lib/testing/default.nix | 1 + nixos/lib/testing/driver-configuration.nix | 83 ++++++++++++++ nixos/lib/testing/driver.nix | 37 ++----- nixos/lib/testing/nodes.nix | 4 - 7 files changed, 156 insertions(+), 150 deletions(-) create mode 100644 nixos/lib/testing/driver-configuration.nix diff --git a/nixos/lib/test-driver/default.nix b/nixos/lib/test-driver/default.nix index f68d1f041810..69ba17863012 100644 --- a/nixos/lib/test-driver/default.nix +++ b/nixos/lib/test-driver/default.nix @@ -8,6 +8,7 @@ ipython, junit-xml, ptpython, + pydantic, python, remote-pdb, ruff, @@ -46,6 +47,7 @@ buildPythonApplication { ipython junit-xml ptpython + pydantic remote-pdb ] ++ extraPythonPackages python.pkgs; diff --git a/nixos/lib/test-driver/src/test_driver/__init__.py b/nixos/lib/test-driver/src/test_driver/__init__.py index 2000bcdbcc2c..20f668867da6 100644 --- a/nixos/lib/test-driver/src/test_driver/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/__init__.py @@ -8,7 +8,7 @@ from pathlib import Path import ptpython.ipython from test_driver.debug import Debug, DebugAbstract, DebugNop -from test_driver.driver import Driver +from test_driver.driver import Driver, DriverConfiguration, load_driver_configuration from test_driver.logger import ( CompositeLogger, JunitXMLLogger, @@ -57,6 +57,13 @@ def writeable_dir(arg: str) -> Path: def main() -> None: arg_parser = argparse.ArgumentParser(prog="nixos-test-driver") + arg_parser.add_argument( + "-c", + "--config", + help="the test driver configuration file", + type=Path, + required=True, + ) arg_parser.add_argument( "--keep-vm-state", help=argparse.SUPPRESS, @@ -79,54 +86,6 @@ def main() -> None: "--debug-hook-attach", help="Enable interactive debugging breakpoints for sandboxed runs", ) - arg_parser.add_argument( - "--vm-names", - metavar="VM-NAME", - action=EnvDefault, - envvar="vmNames", - nargs="*", - help="names of participating virtual machines", - ) - arg_parser.add_argument( - "--vm-start-scripts", - metavar="VM-START-SCRIPT", - action=EnvDefault, - envvar="vmStartScripts", - nargs="*", - help="start scripts for participating virtual machines", - ) - arg_parser.add_argument( - "--container-names", - metavar="CONTAINER-NAME", - action=EnvDefault, - envvar="containerNames", - nargs="*", - help="names of participating containers", - ) - arg_parser.add_argument( - "--container-start-scripts", - metavar="CONTAINER-START-SCRIPT", - action=EnvDefault, - envvar="containerStartScripts", - nargs="*", - help="start scripts for participating containers", - ) - arg_parser.add_argument( - "--vlans", - metavar="VLAN", - action=EnvDefault, - envvar="vlans", - nargs="*", - help="vlans to span by the driver", - ) - arg_parser.add_argument( - "--global-timeout", - type=int, - metavar="GLOBAL_TIMEOUT", - action=EnvDefault, - envvar="globalTimeout", - help="Timeout in seconds for the whole test", - ) arg_parser.add_argument( "-o", "--output_directory", @@ -140,18 +99,6 @@ def main() -> None: help="Enable JunitXML report generation to the given path", type=Path, ) - arg_parser.add_argument( - "testscript", - action=EnvDefault, - envvar="testScript", - help="the test script to run", - type=Path, - ) - arg_parser.add_argument( - "--enable-ssh-backdoor", - help="indicates that the interactive SSH backdoor is active and dumps information about it on start", - action="store_true", - ) log_level_map = {level.name.lower(): level for level in LogLevel} arg_parser.add_argument( "--log-level", @@ -191,28 +138,14 @@ def main() -> None: if args.debug_hook_attach is not None: debugger = Debug(logger, args.debug_hook_attach) - assert len(args.vm_names) == len(args.vm_start_scripts), ( - f"the number of vm names and vm start scripts must be the same: {args.vm_names} vs. {args.vm_start_scripts}" - ) - assert len(args.container_names) == len(args.container_start_scripts), ( - f"the number of container names and container start scripts must be the same: {args.container_names} vs. {args.container_start_scripts}" - ) - with Driver( - vm_names=args.vm_names, - vm_start_scripts=args.vm_start_scripts, - container_names=args.container_names, - container_start_scripts=args.container_start_scripts, - vlans=args.vlans, - tests=args.testscript.read_text(), + config=load_driver_configuration(args.config), out_dir=output_directory, logger=logger, keep_machine_state=args.keep_machine_state, - global_timeout=args.global_timeout, debug=debugger, - enable_ssh_backdoor=args.enable_ssh_backdoor, ) as driver: - if args.enable_ssh_backdoor: + if driver.config.enable_ssh_backdoor: driver.dump_machine_ssh() if args.interactive: history_dir = os.getcwd() @@ -235,12 +168,14 @@ def generate_driver_symbols() -> None: scripts. """ d = Driver( - vm_names=[], - vm_start_scripts=[], - container_names=[], - container_start_scripts=[], - vlans=[], - tests="", + config=DriverConfiguration( + vms=dict(), + containers=dict(), + vlans=[], + global_timeout=0, + enable_ssh_backdoor=False, + test_script=Path("testScriptWithTypes"), + ), out_dir=Path(), logger=CompositeLogger([]), ) diff --git a/nixos/lib/test-driver/src/test_driver/driver.py b/nixos/lib/test-driver/src/test_driver/driver.py index d36c3fcc52d3..8b6a5dfef5a0 100644 --- a/nixos/lib/test-driver/src/test_driver/driver.py +++ b/nixos/lib/test-driver/src/test_driver/driver.py @@ -1,3 +1,4 @@ +import json import os import re import signal @@ -14,6 +15,7 @@ from typing import Any from unittest import TestCase from colorama import Style +from pydantic import BaseModel from test_driver.debug import DebugAbstract, DebugNop from test_driver.errors import MachineError, RequestedAssertionFailed @@ -28,6 +30,26 @@ from test_driver.polling_condition import PollingCondition from test_driver.vlan import VLan +class NodeConfiguration(BaseModel): + name: str + start_script: Path + + +class DriverConfiguration(BaseModel): + vms: dict[str, NodeConfiguration] + containers: dict[str, NodeConfiguration] + vlans: list[int] + global_timeout: int + enable_ssh_backdoor: bool + test_script: Path + + +def load_driver_configuration(file_path: str) -> DriverConfiguration: + with open(file_path) as f: + data = json.load(f) + return DriverConfiguration.model_validate(data) + + class AssertionTester(TestCase): """ Subclass of `unittest.TestCase` which is used in the @@ -113,71 +135,55 @@ class Driver: """A handle to the driver that sets up the environment and runs the tests""" + config: DriverConfiguration tests: str vlans: list[VLan] = [] machines_qemu: list[QemuMachine] = [] machines_nspawn: list[NspawnMachine] = [] polling_conditions: list[PollingCondition] - global_timeout: int race_timer: threading.Timer - vm_start_scripts: dict[str, str] - container_start_scripts: dict[str, str] - vlan_ids: list[int] keep_machine_state: bool logger: AbstractLogger debug: DebugAbstract vhost_vsock: VHostDeviceVsock | None = None - enable_ssh_backdoor: bool def __init__( self, - vm_names: list[str], - vm_start_scripts: list[str], - container_names: list[str], - container_start_scripts: list[str], - vlans: list[int], - tests: str, + config: DriverConfiguration, out_dir: Path, logger: AbstractLogger, keep_machine_state: bool = False, - global_timeout: int = 24 * 60 * 60 * 7, debug: DebugAbstract = DebugNop(), - enable_ssh_backdoor: bool = False, ): - self.tests = tests + self.config = config + self.tests = config.test_script.read_text() self.out_dir = out_dir - self.global_timeout = global_timeout self.logger = logger self.debug = debug - self.vlan_ids = list(set(vlans)) self.polling_conditions = [] self.keep_machine_state = keep_machine_state - self.global_timeout = global_timeout - self.vm_start_scripts = dict(zip(vm_names, vm_start_scripts)) - self.container_start_scripts = dict( - zip(container_names, container_start_scripts) - ) - self.enable_ssh_backdoor = enable_ssh_backdoor def __enter__(self) -> "Driver": - self.race_timer = threading.Timer(self.global_timeout, self.terminate_test) + self.race_timer = threading.Timer( + self.config.global_timeout, self.terminate_test + ) tmp_dir = get_tmp_dir() with self.logger.nested("start all VLans"): - self.vlans = [VLan(nr, tmp_dir, self.logger) for nr in self.vlan_ids] + self.vlans = [VLan(nr, tmp_dir, self.logger) for nr in self.config.vlans] self.polling_conditions = [] - if self.enable_ssh_backdoor and self.vm_start_scripts: + if self.config.enable_ssh_backdoor and self.config.vms: with self.logger.nested("start vhost-device-vsock"): self.vhost_vsock = VHostDeviceVsock( - tmp_dir, list(self.vm_start_scripts.keys()) + tmp_dir, list(self.config.vms.keys()) ) self.machines_qemu = [ QemuMachine( name=name, - start_command=vm_start_script, + start_command=vm_config.start_script.as_posix(), keep_machine_state=self.keep_machine_state, tmp_dir=tmp_dir, callbacks=[self.check_polling_conditions], @@ -194,23 +200,23 @@ class Driver: else None ), ) - for name, vm_start_script in self.vm_start_scripts.items() + for name, vm_config in self.config.vms.items() ] - if len(self.container_start_scripts) > 0 and in_nix_sandbox(): + if self.config.containers and in_nix_sandbox(): self._init_nspawn_environment() self.machines_nspawn = [ NspawnMachine( name=name, - start_command=container_start_script, + start_command=container_config.start_script.as_posix(), tmp_dir=tmp_dir, logger=self.logger, keep_machine_state=self.keep_machine_state, callbacks=[self.check_polling_conditions], out_dir=self.out_dir, ) - for name, container_start_script in self.container_start_scripts.items() + for name, container_config in self.config.containers.items() ] return self @@ -285,7 +291,7 @@ class Driver: except Exception as e: self.logger.error(f"Error during cleanup of vlan{vlan.nr}: {e}") - if self.enable_ssh_backdoor: + if self.config.enable_ssh_backdoor: try: del self.vhost_vsock except Exception as e: @@ -309,7 +315,7 @@ class Driver: general_symbols = dict( start_all=self.start_all, - test_script=self.test_script, + test_script=self.config.test_script, machines=self.machines, machines_qemu=self.machines_qemu, machines_nspawn=self.machines_nspawn, @@ -351,7 +357,7 @@ class Driver: return {**general_symbols, **machine_symbols, **vlan_symbols} def dump_machine_ssh(self) -> None: - if not self.enable_ssh_backdoor: + if not self.config.enable_ssh_backdoor: return assert self.vhost_vsock is not None @@ -417,7 +423,7 @@ class Driver: def run_tests(self) -> None: """Run the test script (for non-interactive test runs)""" self.logger.info( - f"Test will time out and terminate in {self.global_timeout} seconds" + f"Test will time out and terminate in {self.config.global_timeout} seconds" ) self.race_timer.start() self.test_script() @@ -489,7 +495,7 @@ class Driver: """ tmp_dir = get_tmp_dir() - if self.enable_ssh_backdoor: + if self.config.enable_ssh_backdoor: self.logger.warning( f"create_machine({name}): not enabling SSH backdoor, this is not supported for VMs created with create_machine!" ) diff --git a/nixos/lib/testing/default.nix b/nixos/lib/testing/default.nix index 7fdd454c22b8..45e82693d265 100644 --- a/nixos/lib/testing/default.nix +++ b/nixos/lib/testing/default.nix @@ -20,6 +20,7 @@ let testModules = [ ./call-test.nix ./driver.nix + ./driver-configuration.nix ./interactive.nix ./legacy.nix ./meta.nix diff --git a/nixos/lib/testing/driver-configuration.nix b/nixos/lib/testing/driver-configuration.nix new file mode 100644 index 000000000000..36e512f94f6b --- /dev/null +++ b/nixos/lib/testing/driver-configuration.nix @@ -0,0 +1,83 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) types; + + nodeConfigurationAttrs = lib.mkOption { + internal = true; + type = types.attrsOf ( + types.submodule { + options = { + name = lib.mkOption { + internal = true; + type = types.str; + }; + start_script = lib.mkOption { + internal = true; + type = types.path; + }; + }; + } + ); + }; +in +{ + options = { + driverConfiguration = lib.mkOption { + description = "Configuration attribute set for test driver invocation"; + internal = true; + type = types.submodule { + options = { + vms = nodeConfigurationAttrs; + containers = nodeConfigurationAttrs; + vlans = lib.mkOption { + internal = true; + type = types.listOf types.ints.unsigned; + }; + global_timeout = lib.mkOption { + internal = true; + type = types.ints.unsigned; + }; + enable_ssh_backdoor = lib.mkOption { + internal = true; + type = types.bool; + }; + test_script = lib.mkOption { + internal = true; + type = types.path; + }; + }; + }; + }; + driverConfigurationFile = lib.mkOption { + internal = true; + type = types.path; + }; + }; + + config = { + driverConfiguration = { + vms = lib.mapAttrs (name: value: { + inherit name; + start_script = lib.getExe value.system.build.vm; + }) config.nodes; + containers = lib.mapAttrs (name: value: { + inherit name; + start_script = lib.getExe value.system.build.nspawn; + }) config.containers; + vlans = lib.unique ( + lib.concatMap ( + m: (m.virtualisation.vlans ++ (lib.mapAttrsToList (_: v: v.vlan) m.virtualisation.interfaces)) + ) (lib.attrValues config.nodes ++ lib.attrValues config.containers) + ); + global_timeout = config.globalTimeout; + test_script = pkgs.writeText "test-script" config.testScriptString; + enable_ssh_backdoor = config.sshBackdoor.enable; + }; + driverConfigurationFile = pkgs.writers.writeJSON "driverConfiguration.json" config.driverConfiguration; + }; +} diff --git a/nixos/lib/testing/driver.nix b/nixos/lib/testing/driver.nix index df150263407a..f49f1fe199b1 100644 --- a/nixos/lib/testing/driver.nix +++ b/nixos/lib/testing/driver.nix @@ -17,12 +17,6 @@ let enableNspawn = config.containers != { }; }; - vlans = map ( - m: (m.virtualisation.vlans ++ (lib.mapAttrsToList (_: v: v.vlan) m.virtualisation.interfaces)) - ) ((lib.attrValues config.nodes) ++ (lib.attrValues config.containers)); - vms = map (m: m.system.build.vm) (lib.attrValues config.nodes); - containers = map (m: m.system.build.nspawn) (lib.attrValues config.containers); - pythonizeName = name: let @@ -32,11 +26,12 @@ let (if builtins.match "[A-z_]" head == null then "_" else head) + lib.stringAsChars (c: if builtins.match "[A-z0-9_]" c == null then "_" else c) tail; - uniqueVlans = lib.unique (builtins.concatLists vlans); - vlanNames = map (i: "vlan${toString i}: VLan;") uniqueVlans; + vlanTypeHints = lib.strings.concatMapStringsSep "\n" ( + i: "vlan${toString i}: VLan" + ) config.driverConfiguration.vlans; - vmMachineNames = map (c: c.system.name) (lib.attrValues config.nodes); - containerMachineNames = map (c: c.system.name) (lib.attrValues config.containers); + vmMachineNames = lib.attrNames config.driverConfiguration.vms; + containerMachineNames = lib.attrNames config.driverConfiguration.containers; theOnlyMachine = let @@ -72,17 +67,13 @@ let '' mkdir -p $out/bin - vmNames=(${lib.escapeShellArgs vmMachineNames}) - vmStartScripts=(${lib.escapeShellArgs (map lib.getExe vms)}) - containerNames=(${lib.escapeShellArgs containerMachineNames}) - containerStartScripts=(${lib.escapeShellArgs (map lib.getExe containers)}) - ${lib.optionalString (!config.skipTypeCheck) '' # prepend type hints so the test script can be type checked with mypy + cat "${../test-script-prepend.py}" >> testScriptWithTypes echo "${toString vmMachineTypeHints}" >> testScriptWithTypes echo "${toString containerMachineTypeHints}" >> testScriptWithTypes - echo "${toString vlanNames}" >> testScriptWithTypes + echo "${toString vlanTypeHints}" >> testScriptWithTypes echo -n "$testScript" >> testScriptWithTypes echo "Running type check (enable/disable: config.skipTypeCheck)" @@ -94,7 +85,7 @@ let testScriptWithTypes ''} - echo -n "$testScript" >> $out/test-script + cp "${config.driverConfiguration.test_script}" $out/test-script ln -s ${testDriver}/bin/nixos-test-driver $out/bin/nixos-test-driver @@ -111,17 +102,9 @@ let )" ${hostPkgs.python3Packages.pyflakes}/bin/pyflakes $out/test-script ''} - # set defaults through environment - # see: ./test-driver/test-driver.py argparse implementation wrapProgram $out/bin/nixos-test-driver \ - --set vmStartScripts "''${vmStartScripts[*]}" \ - --set vmNames "''${vmNames[*]}" \ - --set containerStartScripts "''${containerStartScripts[*]}" \ - --set containerNames "''${containerNames[*]}" \ - --set testScript "$out/test-script" \ - --set globalTimeout "${toString config.globalTimeout}" \ - --set vlans '${toString vlans}' \ - --set logLevel "${config.logLevel}" \ + --add-flags "--config ${config.driverConfigurationFile}" \ + --add-flags "--log-level ${config.logLevel}" \ ${lib.escapeShellArgs ( lib.concatMap (arg: [ "--add-flags" diff --git a/nixos/lib/testing/nodes.nix b/nixos/lib/testing/nodes.nix index 474070c60e3e..dfd7a7278277 100644 --- a/nixos/lib/testing/nodes.nix +++ b/nixos/lib/testing/nodes.nix @@ -310,10 +310,6 @@ in passthru.nodes = config.nodesCompat; passthru.containers = config.containers; - extraDriverArgs = mkIf config.sshBackdoor.enable [ - "--enable-ssh-backdoor" - ]; - defaults = mkMerge [ (mkIf config.node.pkgsReadOnly { nixpkgs.pkgs = config.node.pkgs; From 5b7580af5a5c7607fa69f402da2f66e580699f10 Mon Sep 17 00:00:00 2001 From: Gaetan Lepage Date: Sat, 18 Apr 2026 05:13:21 +0000 Subject: [PATCH 26/33] ruff: 0.15.10 -> 0.15.11 Diff: https://github.com/astral-sh/ruff/compare/0.15.10...0.15.11 Changelog: https://github.com/astral-sh/ruff/releases/tag/0.15.11 --- pkgs/by-name/ru/ruff/package.nix | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkgs/by-name/ru/ruff/package.nix b/pkgs/by-name/ru/ruff/package.nix index 6241410abaf8..260a11e50f7f 100644 --- a/pkgs/by-name/ru/ruff/package.nix +++ b/pkgs/by-name/ru/ruff/package.nix @@ -16,18 +16,20 @@ rustPlatform.buildRustPackage (finalAttrs: { pname = "ruff"; - version = "0.15.10"; + version = "0.15.11"; + + __structuredAttrs = true; src = fetchFromGitHub { owner = "astral-sh"; repo = "ruff"; tag = finalAttrs.version; - hash = "sha256-x+zqgIJATDWimxbh/VGt94HFiUSD4H9QD/49hnHgfuQ="; + hash = "sha256-hFKUbgYrwiSPTqNZD7HlDaoHueZrJxbrL1g/v1WD6GA="; }; cargoBuildFlags = [ "--package=ruff" ]; - cargoHash = "sha256-EQERi5NSMUK7qfFYWilzhacYlK4ShQl9Koz8t/8I6+U="; + cargoHash = "sha256-gj0Ks9uyRE1Z8LELHmnpElHLCdP6lf/bE5ji+7qD9aA="; nativeBuildInputs = [ installShellFiles ]; From 16a9a62be022e9bedce7ab09874a578960761ef5 Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:26 +0000 Subject: [PATCH 27/33] linux_6_19: 6.19.12 -> 6.19.13 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 00b2bf8dba75..380f2d047280 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -35,8 +35,8 @@ "lts": true }, "6.19": { - "version": "6.19.12", - "hash": "sha256:1md8b270pdyk9d8cq0qyr8qmymcijmj3gc39nn394wpr0l94yp6f", + "version": "6.19.13", + "hash": "sha256:0j2ncikwi4mkx9v33ahzmi2qq2bx5f82701nnha1grs0lzzb2n85", "lts": false }, "7.0": { From 278635a3c0f1d769a1c2b8300b961de256348b44 Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:28 +0000 Subject: [PATCH 28/33] linux_6_18: 6.18.22 -> 6.18.23 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 380f2d047280..51bd26f14291 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -30,8 +30,8 @@ "lts": true }, "6.18": { - "version": "6.18.22", - "hash": "sha256:0nazlm6j5blyd4qgl0z6xc3qk00vz3cfvx5mqv18awv5ygx94g52", + "version": "6.18.23", + "hash": "sha256:0d2ihdz5hdy1ywhck76y9rnzzvkl2lhrb5xvc6w5l4ydpxv8wb9a", "lts": true }, "6.19": { From abc3f6ea6422231acdab1118c32c7571f5712196 Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:32 +0000 Subject: [PATCH 29/33] linux_6_12: 6.12.81 -> 6.12.82 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 51bd26f14291..caccec1a9e23 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -25,8 +25,8 @@ "lts": true }, "6.12": { - "version": "6.12.81", - "hash": "sha256:0iw84bqdbh9dlaqd1bqgldg50riw2b5is7ipqnbp0sll8cv9rc62", + "version": "6.12.82", + "hash": "sha256:1a8r1wzfssrnqbf4yvbcfynf5w6la4vy1w5wlns1p63krl2hnmqf", "lts": true }, "6.18": { From 54492bae65b1770eae2efcc9f09da964f7ec6d84 Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:33 +0000 Subject: [PATCH 30/33] linux_6_6: 6.6.134 -> 6.6.135 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index caccec1a9e23..0787b4cfe330 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -20,8 +20,8 @@ "lts": true }, "6.6": { - "version": "6.6.134", - "hash": "sha256:1grp1wqgzjsk6xyl0nvd2hxlxjj0wgz04x544zkz8srp6rxnjy33", + "version": "6.6.135", + "hash": "sha256:0ahklx827y6rnh77a77bf4qr3sbp2z5z12l98avfv78nwznkilnk", "lts": true }, "6.12": { From aec6f3e58f1b3f6404684a3a3890e5409602627a Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:35 +0000 Subject: [PATCH 31/33] linux_6_1: 6.1.168 -> 6.1.169 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 0787b4cfe330..e5c744905d8a 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -5,8 +5,8 @@ "lts": false }, "6.1": { - "version": "6.1.168", - "hash": "sha256:0vkp75sfnjvfqxjh6gqcx24h2m6qj6xkwlw6b118cja43vjnz1g0", + "version": "6.1.169", + "hash": "sha256:0b7g7awbn1zryrh0pnjsh00d7j7ivda8i380jddhfj8ph1sfdjz0", "lts": true }, "5.15": { From ffc652cfcbb1deedf4a73284b6f408f3e37be88d Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:38 +0000 Subject: [PATCH 32/33] linux_5_15: 5.15.202 -> 5.15.203 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index e5c744905d8a..18855e892c4e 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -10,8 +10,8 @@ "lts": true }, "5.15": { - "version": "5.15.202", - "hash": "sha256:1m6d53qx1ah4jwpa8hwjdmq0jn2hf7xmz1li6rwpdqjp97vvvh8b", + "version": "5.15.203", + "hash": "sha256:0r6w6glfpzp6qz0kbxzpmabxwgw1y5k9a407lj98gsap5bcfgsqb", "lts": true }, "5.10": { From 4a6918cffa39da6ed0d29852fb124960ca5ae081 Mon Sep 17 00:00:00 2001 From: zowoq <59103226+zowoq@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:14:40 +0000 Subject: [PATCH 33/33] linux_5_10: 5.10.252 -> 5.10.253 --- pkgs/os-specific/linux/kernel/kernels-org.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/os-specific/linux/kernel/kernels-org.json b/pkgs/os-specific/linux/kernel/kernels-org.json index 18855e892c4e..a6e58099a242 100644 --- a/pkgs/os-specific/linux/kernel/kernels-org.json +++ b/pkgs/os-specific/linux/kernel/kernels-org.json @@ -15,8 +15,8 @@ "lts": true }, "5.10": { - "version": "5.10.252", - "hash": "sha256:1yqa4zmvi5ihf50kxcff06abfi6xw0b9ajzagvy6gdzfr7igpcrl", + "version": "5.10.253", + "hash": "sha256:1j2sszv8j9s6qlrvbnyj1qf9aapl0srbps3g4bvf5s2hh29281zc", "lts": true }, "6.6": {