From b4a5a3d28f06bb80b99ab5b54382a83d3232fe87 Mon Sep 17 00:00:00 2001 From: Arian van Putten Date: Thu, 25 Jun 2026 09:53:18 +0200 Subject: [PATCH 1/3] nixos/virtualisation/nspawn-container: add virtualisation.credentials support Assisted-by: Claude --- .../virtualisation/credentials-options.nix | 63 +++++++++++++ .../nspawn-container/default.nix | 7 +- nixos/modules/virtualisation/qemu-vm.nix | 88 +++---------------- 3 files changed, 83 insertions(+), 75 deletions(-) create mode 100644 nixos/modules/virtualisation/credentials-options.nix diff --git a/nixos/modules/virtualisation/credentials-options.nix b/nixos/modules/virtualisation/credentials-options.nix new file mode 100644 index 000000000000..3345f6933784 --- /dev/null +++ b/nixos/modules/virtualisation/credentials-options.nix @@ -0,0 +1,63 @@ +{ lib, pkgs, ... }: +{ + options.virtualisation.credentials = lib.mkOption { + description = '' + Credentials to pass to the VM or container using systemd's credential system. + + See {manpage}`systemd.exec(5)`, {manpage}`systemd-creds(1)` and https://systemd.io/CREDENTIALS/ for more + information about systemd credentials. + ''; + default = { }; + example = lib.literalExpression '' + { + database-password = { + text = "my-secret-password"; + }; + ssl-cert = { + source = "./cert.pem"; + }; + binary-key = { + source = "./private.der"; + }; + } + ''; + type = lib.types.attrsOf ( + lib.types.submodule ( + { + name, + options, + config, + ... + }: + { + options = { + source = lib.mkOption { + type = lib.types.nullOr (lib.types.pathWith { }); + default = null; + description = '' + Source file on the host containing the credential data. + ''; + }; + text = lib.mkOption { + default = null; + type = lib.types.nullOr lib.types.str; + description = '' + Text content of the credential. + + For binary data or when the credential content should come from + an existing file, use `source` instead. + + ::: {.warning} + The text here is stored in the host's nix store as a file. + ::: + ''; + }; + }; + config.source = lib.mkIf (config.text != null) ( + lib.mkDerivedConfig options.text (pkgs.writeText name) + ); + } + ) + ); + }; +} diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index 9100b580768b..82a06929e925 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -21,6 +21,10 @@ let cfg = config.virtualisation; in { + imports = [ + ../credentials-options.nix + ]; + options = { virtualisation.cmdline = lib.mkOption { @@ -131,7 +135,8 @@ in # Send a READY=1 notification to a socket when the container is fully booted. "--notify-ready=yes" - ]; + ] + ++ lib.mapAttrsToList (name: cred: "--load-credential=${name}:${cred.source}") cfg.credentials; system.build.nspawn = let diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index 8d2817c23bf0..8230da280b50 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -399,6 +399,7 @@ in imports = [ ../profiles/qemu-guest.nix ./disk-size-option.nix + ./credentials-options.nix (mkRenamedOptionModule [ "virtualisation" @@ -1126,81 +1127,20 @@ in }; virtualisation.credentials = mkOption { - description = '' - Credentials to pass to the VM using systemd's credential system. - - See {manpage}`systemd.exec(5)` , {manpage}`systemd-creds(1)` and https://systemd.io/CREDENTIALS/ for more - information about systemd credentials. - ''; - default = { }; - example = { - database-password = { - text = "my-secret-password"; - }; - ssl-cert = { - source = "./cert.pem"; - }; - binary-key = { - mechanism = "fw_cfg"; - source = "./private.der"; - }; - config-file = { - mechanism = "smbios"; - text = '' - [database] - host=localhost - port=5432 - ''; - }; - }; type = types.attrsOf ( - lib.types.submodule ( - { - name, - options, - config, - ... - }: - { - options = { - mechanism = lib.mkOption { - type = lib.types.enum [ - "fw_cfg" - "smbios" - ]; - default = if pkgs.stdenv.hostPlatform.isx86 then "smbios" else "fw_cfg"; - defaultText = lib.literalExpression ''if pkgs.stdenv.hostPlatform.isx86 then "smbios" else "fw_cfg"''; - description = '' - The mechanism used to pass the credential to the VM. - ''; - }; - source = lib.mkOption { - type = lib.types.nullOr (lib.types.pathWith { }); - default = null; - description = '' - Source file on the host containing the credential data. - ''; - }; - text = lib.mkOption { - default = null; - type = lib.types.nullOr lib.types.str; - description = '' - Text content of the credential. - - For binary data or when the credential content should come from - an existing file, use `source` instead. - - ::: {.warning} - The text here is stored in the host's nix store as a file. - ::: - ''; - }; - }; - config.source = lib.mkIf (config.text != null) ( - lib.mkDerivedConfig options.text (pkgs.writeText name) - ); - } - ) + lib.types.submodule { + options.mechanism = lib.mkOption { + type = lib.types.enum [ + "fw_cfg" + "smbios" + ]; + default = if pkgs.stdenv.hostPlatform.isx86 then "smbios" else "fw_cfg"; + defaultText = lib.literalExpression ''if pkgs.stdenv.hostPlatform.isx86 then "smbios" else "fw_cfg"''; + description = '' + The mechanism used to pass the credential to the VM. + ''; + }; + } ); }; From 612ff7f23c5bfffde6f1b6200941aafde22c9974 Mon Sep 17 00:00:00 2001 From: Arian van Putten Date: Thu, 25 Jun 2026 09:53:18 +0200 Subject: [PATCH 2/3] nixos/test-driver: support state_dir for container tests Assisted-by: Claude --- nixos/lib/test-driver/src/test_driver/machine/__init__.py | 1 + 1 file changed, 1 insertion(+) 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 db96445af912..b4eceee8c6fc 100644 --- a/nixos/lib/test-driver/src/test_driver/machine/__init__.py +++ b/nixos/lib/test-driver/src/test_driver/machine/__init__.py @@ -1684,6 +1684,7 @@ class NspawnMachine(BaseMachine): self.process = subprocess.Popen( [self.start_command], + cwd=self.state_dir, env={ "RUN_NSPAWN_ROOT_DIR": str(self.state_dir), "RUN_NSPAWN_SHARED_DIR": str(self.shared_dir), From 426413b862563b20afbc22ea9de00929942b77c0 Mon Sep 17 00:00:00 2001 From: Arian van Putten Date: Thu, 25 Jun 2026 09:53:18 +0200 Subject: [PATCH 3/3] nixos/tests/credentials: test both vm and container support Assisted-by: Claude --- nixos/tests/all-tests.nix | 16 ++--- nixos/tests/credentials.nix | 106 ++++++++++++++++++++++++++++ nixos/tests/qemu-vm-credentials.nix | 83 ---------------------- 3 files changed, 114 insertions(+), 91 deletions(-) create mode 100644 nixos/tests/credentials.nix delete mode 100644 nixos/tests/qemu-vm-credentials.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 09b0a4632d28..0e386407529f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -446,6 +446,14 @@ in }; coturn = runTest ./coturn.nix; couchdb = runTest ./couchdb.nix; + credentials-fwcfg = runTest { + imports = [ ./credentials.nix ]; + _module.args.mechanism = "fw_cfg"; + }; + credentials-smbios = runTestOn [ "x86_64-linux" ] { + imports = [ ./credentials.nix ]; + _module.args.mechanism = "smbios"; + }; cri-o = runTestOn [ "aarch64-linux" "x86_64-linux" ] ./cri-o.nix; croc = runTest ./croc.nix; cross-seed = runTest ./cross-seed.nix; @@ -1415,14 +1423,6 @@ in pykms = runTest ./pykms.nix; qbittorrent = runTest ./qbittorrent.nix; qboot = runTestOn [ "x86_64-linux" "i686-linux" ] ./qboot.nix; - qemu-vm-credentials-fwcfg = runTest { - imports = [ ./qemu-vm-credentials.nix ]; - _module.args.mechanism = "fw_cfg"; - }; - qemu-vm-credentials-smbios = runTestOn [ "x86_64-linux" ] { - imports = [ ./qemu-vm-credentials.nix ]; - _module.args.mechanism = "smbios"; - }; qemu-vm-external-disk-image = runTest ./qemu-vm-external-disk-image.nix; qemu-vm-restrictnetwork = handleTest ./qemu-vm-restrictnetwork.nix { }; qemu-vm-store = runTest ./qemu-vm-store.nix; diff --git a/nixos/tests/credentials.nix b/nixos/tests/credentials.nix new file mode 100644 index 000000000000..7984ffd03593 --- /dev/null +++ b/nixos/tests/credentials.nix @@ -0,0 +1,106 @@ +{ + lib, + pkgs, + mechanism, + ... +}: + +let + secret = '' + foo + bar + baz + ''; + secret-file = "bar"; + + common-credentials = { + secret-default-mechanism = { + text = "default-mechanism"; + }; + secret-file-nix-store = { + source = pkgs.writeText "secret-file-nix-store" secret-file; + }; + secret-file-host = { + source = "./secret-file-host"; + }; + secret-file-host-binary = { + source = "./secret-file-host-binary"; + }; + }; +in + +{ + name = "credentials-${mechanism}"; + + meta.maintainers = with lib.maintainers; [ arianvp ]; + + # No VM<->container traffic in this test; credentials are static. + requiredFeatures.devnet = lib.mkForce false; + + nodes.vm = { + virtualisation.credentials = common-credentials // { + secret = { + inherit mechanism; + text = secret; + }; + }; + }; + + containers.container = { + virtualisation.credentials = common-credentials // { + secret = { + text = secret; + }; + }; + }; + + testScript = '' + import base64 + + secret_file_host = "baz" + # Binary data with null bytes, high bytes, and other problematic characters. + secret_file_host_binary = bytes([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, + 0xDE, 0xAD, 0xBE, 0xEF, + 0xFF, 0xFE, 0xFD, 0xFC, + 0x00, 0x00, 0x00, 0x00, + 0x80, 0x81, 0x82, 0x83, + ]) + + def assert_credentials(m): + with open(m.state_dir / "secret-file-host", "w") as f: + f.write(secret_file_host) + with open(m.state_dir / "secret-file-host-binary", "wb") as f2: + f2.write(secret_file_host_binary) + + t.assertEqual( + m.succeed("systemd-creds --system cat secret").strip(), + "foo\nbar\nbaz", + ) + t.assertEqual( + m.succeed("systemd-creds --system cat secret-default-mechanism").strip(), + "default-mechanism", + ) + t.assertEqual( + m.succeed("systemd-creds --system cat secret-file-nix-store").strip(), + "${secret-file}", + ) + t.assertEqual( + m.succeed("systemd-creds --system cat secret-file-host").strip(), + secret_file_host, + ) + result = m.succeed( + "systemd-creds --system cat secret-file-host-binary --transcode=base64" + ).strip() + expected = base64.b64encode(secret_file_host_binary).decode("ascii") + t.assertEqual( + result, + expected, + f"Binary credential mismatch: got {result}, expected {expected}", + ) + + assert_credentials(vm) + assert_credentials(container) + ''; +} diff --git a/nixos/tests/qemu-vm-credentials.nix b/nixos/tests/qemu-vm-credentials.nix deleted file mode 100644 index 9ee2d270d8f0..000000000000 --- a/nixos/tests/qemu-vm-credentials.nix +++ /dev/null @@ -1,83 +0,0 @@ -{ - lib, - pkgs, - mechanism, - ... -}: - -let - secret = '' - foo - bar - baz - ''; - secret-file = "bar"; -in - -{ - name = "qemu-vm-credentials-${mechanism}"; - - meta.maintainers = with lib.maintainers; [ arianvp ]; - - nodes = { - machine = { - virtualisation.credentials = { - secret = { - inherit mechanism; - text = secret; - }; - secret-default-mechanism = { - text = "default-mechanism"; - }; - secret-file-nix-store = { - inherit mechanism; - source = pkgs.writeText "secret-file-nix-store" secret-file; - }; - secret-file-host = { - inherit mechanism; - source = "./secret-file-host"; - }; - secret-file-host-binary = { - inherit mechanism; - source = "./secret-file-host-binary"; - }; - }; - }; - }; - - testScript = '' - import base64 - secret_file_host = "baz" - # Binary data with null bytes, high bytes, and all sorts of problematic characters - secret_file_host_binary = bytes([ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, # null and control chars - 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, - 0xDE, 0xAD, 0xBE, 0xEF, # classic binary pattern - 0xFF, 0xFE, 0xFD, 0xFC, # high bytes - 0x00, 0x00, 0x00, 0x00, # multiple nulls - 0x80, 0x81, 0x82, 0x83, # more high bytes - ]) - - with open(machine.state_dir / "secret-file-host", "w") as f: - f.write(secret_file_host) - with open(machine.state_dir / "secret-file-host-binary", "wb") as f2: - f2.write(secret_file_host_binary) - - - # Test text credential - t.assertEqual(machine.succeed("systemd-creds --system cat secret").strip(), "foo\nbar\nbaz") - - t.assertEqual(machine.succeed("systemd-creds --system cat secret-default-mechanism").strip(), "default-mechanism") - - # Test credential from nix store - t.assertEqual(machine.succeed("systemd-creds --system cat secret-file-nix-store").strip(), "${secret-file}") - - # Test credential from host file - t.assertEqual(machine.succeed("systemd-creds --system cat secret-file-host").strip(), secret_file_host) - - # Test binary credential - verify exact binary content - result = machine.succeed("systemd-creds --system cat secret-file-host-binary --transcode=base64").strip() - expected = base64.b64encode(secret_file_host_binary).decode('ascii') - t.assertEqual(result, expected, f"Binary credential mismatch: got {result}, expected {expected}") - ''; -}