From 25099b60691bf704f82bc3a52acddb508fd44e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Christ?= Date: Mon, 11 Nov 2024 11:55:53 +0100 Subject: [PATCH] Initial commit --- .envrc | 1 + .github/workflows/test.yml | 23 ++++ .gitignore | 12 ++ README.md | 44 ++++++ default.nix | 14 ++ examples/basic.nix | 33 +++++ flake.lock | 144 ++++++++++++++++++++ flake.nix | 106 +++++++++++++++ nix/dm-verity.nix | 49 +++++++ nix/lib/mkExt.nix | 82 ++++++++++++ nix/module.nix | 226 +++++++++++++++++++++++++++++++ nix/outputs.nix | 13 ++ nix/sources.json | 14 ++ nix/sources.nix | 250 +++++++++++++++++++++++++++++++++++ nix/tests/basic.nix | 39 ++++++ nix/tests/default.nix | 32 +++++ nix/tests/dm-verity.nix | 67 ++++++++++ nix/tests/fixtures/cert.pem | 29 ++++ nix/tests/fixtures/privk.pem | 52 ++++++++ 19 files changed, 1230 insertions(+) create mode 100644 .envrc create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 default.nix create mode 100644 examples/basic.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/dm-verity.nix create mode 100644 nix/lib/mkExt.nix create mode 100644 nix/module.nix create mode 100644 nix/outputs.nix create mode 100644 nix/sources.json create mode 100644 nix/sources.nix create mode 100644 nix/tests/basic.nix create mode 100644 nix/tests/default.nix create mode 100644 nix/tests/dm-verity.nix create mode 100644 nix/tests/fixtures/cert.pem create mode 100644 nix/tests/fixtures/privk.pem diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3fef490 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Run nix flake check + +on: + push: + pull_request: + +jobs: + build: + name: Flake Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@v15 + with: + diagnostic-endpoint: "" + source-url: "https://install.lix.systems/lix/lix-installer-x86_64-linux" + - uses: DeterminateSystems/magic-nix-cache-action@v8 + with: + diagnostic-endpoint: "" + - run: nix flake check --log-format raw-with-logs -L + +env: + FORCE_COLOR: 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16666d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.vscode +result* +.direnv/ +.pre-commit-config.yaml +.nixos-test-history +TODO.MD + +# Local debugging +img +*.raw +mnt/ +tree/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4952c10 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# naext: Nix Appliance Extension Tools + +Extending Appliance Images. + +NixOS allows for building [Appliance Images](https://nixos.org/manual/nixos/unstable/#sec-image-repart-appliance). Since Appliance Images are immutable they typically contain everything necessary to make use of such an image. + +## Problem + +Appliances are usually built generically. To be useful an appliance is augmented with specifics (think of configuration, programs) for a certain use case. + +## Offered Solution + +This project offers solutions to extend immutable appliances in a lightweight manner leveraging technologies provided by systemd and the kernel. + +### `naext` Module + +Allows for building extension images (`sysext`, `confext`). + +#### Example + +Create a confext image that provides the file `/etc/test` containing `Hello`. + +```nix +naext = { + seed = "12345678-1234-1234-1234-123456789123"; + extensions = { + "hello" = { + extensionType = "confext"; + imageFormat = "raw"; + files = { + "/etc/test".source = pkgs.writeText "example" ''Hello''; + }; + }; + }; +}; +``` + +## Tour + +Check out: + +- [Building an Image](./examples/basic.nix) with `nix-build ./examples/basic.nix` +- [Basic Integration Test](./nix/tests/basic.nix) with `nix-build ./examples/basic.nix` +- [Integration Test with verity protected extension image](./nix/tests/basic.nix) with `nix-build ./examples/basic.nix` diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..8177780 --- /dev/null +++ b/default.nix @@ -0,0 +1,14 @@ +{ + system ? builtins.currentSystem, +}: +let + sources = import ./nix/sources.nix; + pkgs = import sources.nixpkgs { + inherit system; + config.allowAliases = false; + }; + outputs = import ./nix/outputs.nix { inherit pkgs; }; +in +{ + inherit (outputs) checks; +} diff --git a/examples/basic.nix b/examples/basic.nix new file mode 100644 index 0000000..eab0e18 --- /dev/null +++ b/examples/basic.nix @@ -0,0 +1,33 @@ +{ + system ? builtins.currentSystem, +}: +let + sources = import ../nix/sources.nix; + pkgs = import sources.nixpkgs { + inherit system; + config.allowAliases = false; + }; + outputs = import ../nix/outputs.nix { inherit pkgs; }; +in +(pkgs.lib.evalModules { + modules = [ + outputs.nixosModules.default + (_: { + naext = { + seed = "12345678-1234-1234-1234-123456789123"; + extensions = { + "hello" = { + extensionType = "confext"; + imageFormat = "raw"; + files = { + "/etc/test".source = pkgs.writeText "example" ''Hello''; + }; + }; + }; + }; + }) + ]; + specialArgs = { + inherit pkgs; + }; +}).config.naext.extensions."hello".image diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6eb1301 --- /dev/null +++ b/flake.lock @@ -0,0 +1,144 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1730504689, + "narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "506278e768c2a08bec68eb62932193e341f55c90", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1731139594, + "narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1730741070, + "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1731363552, + "narHash": "sha256-vFta1uHnD29VUY4HJOO/D6p6rxyObnf+InnSMT4jlMU=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "cd1af27aa85026ac759d5d3fccf650abe7e1bbf0", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2941af5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,106 @@ +{ + description = "Extension Images built with Nix"; + + inputs = { + flake-compat = { + url = "github:edolstra/flake-compat"; + flake = false; + }; + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + pre-commit-hooks-nix = { + url = "github:cachix/pre-commit-hooks.nix"; + inputs = { + nixpkgs.follows = "nixpkgs"; + flake-compat.follows = "flake-compat"; + }; + }; + systems.url = "github:nix-systems/default"; + }; + outputs = + + inputs: + inputs.flake-parts.lib.mkFlake { inherit inputs; } { + systems = import inputs.systems; + imports = [ inputs.pre-commit-hooks-nix.flakeModule ]; + flake.nixosModules = { + naext = import ./nix/module.nix; + dm-verity = import ./nix/dm-verity.nix; + }; + perSystem = + { + config, + pkgs, + ... + }: + { + checks = + { } + // (import ./nix/tests { + inherit (inputs.self) nixosModules; + inherit pkgs; + enableHeavyTests = false; + }); + + pre-commit = { + check.enable = true; + settings = { + hooks = { + nixfmt-rfc-style.enable = true; + statix.enable = true; + }; + }; + }; + devShells.default = + let + example-basic-mount = + pkgs.writeShellScriptBin "example-basic-mount" # bash + '' + partition=p1 # assume the data partition is p1 + top=$(git rev-parse --show-toplevel) + set -eux + + # Build the example image and mount it as a loop device + nix-build $top/examples/basic.nix --out-link $top/result "$@" + cp -rL $top/result $top/basic.raw + loopdev=$(systemd-dissect --attach $top/basic.raw) + + # Wait until the data partition becomes available + while [ ! -e "''\${loopdev}''\${partition}" ]; do + sleep 0.1 # adjust the delay as necessary + done + + # Create the mount point + if [ ! -e $top/mnt ]; then + mkdir $top/mnt + fi + mount "''\${loopdev}''\${partition}" $top/mnt + ''; + example-basic-umount = + pkgs.writeShellScriptBin "example-basic-umount" # bash + '' + top=$(git rev-parse --show-toplevel) + set -eux + umount $top/mnt + systemd-dissect --detach $top/basic.raw + rm $top/result $top/basic.raw + ''; + in + pkgs.mkShell { + shellHook = '' + ${config.pre-commit.installationScript} + ''; + packages = with pkgs; [ + example-basic-mount + example-basic-umount + nixfmt-rfc-style + statix + util-linux + ]; + }; + }; + }; +} diff --git a/nix/dm-verity.nix b/nix/dm-verity.nix new file mode 100644 index 0000000..bce273c --- /dev/null +++ b/nix/dm-verity.nix @@ -0,0 +1,49 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.dm-verity; + enableVerityRoothashSig = { + name = "enable_dm_verity_verify_roothash_sig"; + patch = null; + extraStructuredConfig = { + DM_VERITY_VERIFY_ROOTHASH_SIG = lib.kernel.yes; + }; + }; + provideTrustedKeys = keys: { + name = "provide_config_system_trusted_keys"; + patch = null; + extraStructuredConfig = { + SYSTEM_TRUSTED_KEYS.freeform = "${keys}"; + }; + }; + + mergeCertificates = + certFiles: + pkgs.runCommand "merged-certificates.pem" { } ( + lib.concatMapStringsSep "\n" (c: "cat ${c} >> $out") certFiles + ); +in +{ + options.dm-verity = { + enable = lib.mkEnableOption "support for the dm-verity roothash signature verification mechanism."; + trustedKeys = lib.mkOption { + type = with lib.types; listOf path; + default = [ ]; + example = [ ./certificate.pem ]; + description = '' + Trusted certificates for the signature verification of dm-verity partitions. The listed + certificate files will be provided as input for the kernel build process in order + to embed them in the kernel keyring. + ''; + }; + }; + config = lib.mkIf cfg.enable { + boot.kernelPatches = [ + enableVerityRoothashSig + ] ++ lib.optional (cfg.trustedKeys != [ ]) (provideTrustedKeys (mergeCertificates cfg.trustedKeys)); + }; +} diff --git a/nix/lib/mkExt.nix b/nix/lib/mkExt.nix new file mode 100644 index 0000000..a3ea420 --- /dev/null +++ b/nix/lib/mkExt.nix @@ -0,0 +1,82 @@ +# Create extension images +{ pkgs, lib, ... }: + +{ + # Create a verity-protected extension image + verity = + { + name, + ddiType, + sourceTree, + privateKey, + certificate, + seed, + }: + + pkgs.runCommand "${name}.${ddiType}.raw" + { + nativeBuildInputs = with pkgs; [ + erofs-utils + tree + ]; + } + '' + set -eux + # TODO: Investigate why repart needs tree to be writable and relatively accessible + mkdir tree + cp -rL ${sourceTree}/. tree + chmod -R 755 tree + ${pkgs.systemd}/bin/systemd-repart \ + --make-ddi=${ddiType} \ + --copy-source=tree \ + --private-key=${privateKey} \ + --certificate=${certificate} \ + --seed=${seed} \ + $out + ''; + # Create a plain erofs extension image + raw = + let + # Reproducibly derive UUID from a seed and a name + mkUuid = + name: seed: + pkgs.runCommand "hmac-256" { nativeBuildInputs = [ pkgs.openssl ]; } '' + set -eux + # Compute HMAC using OpenSSL + hmac=$(echo -n '${name}' | openssl dgst -sha256 -hmac '${seed}' | awk '{print $2}') + + # Format the HMAC as a UUID (first 32 characters of the HMAC) + uuid=''${hmac:0:8}-''${hmac:8:4}-''${hmac:12:4}-''${hmac:16:4}-''${hmac:20:12} + + # Write the UUID to the output file + echo -n $uuid > $out + ''; + in + { + name, + ddiType, + sourceTree, + seed ? "12345678-1234-1234-1234-123456789123", + ... + }: + + pkgs.runCommand "${name}.${ddiType}.raw" + { + nativeBuildInputs = with pkgs; [ + erofs-utils + tree + ]; + } + '' + set -eux + truncate -s 100M ${name}.${ddiType}.raw + ${pkgs.erofs-utils}/bin/mkfs.erofs \ + --quiet \ + --force-uid=0 \ + --force-gid=0 \ + -U "${lib.readFile (mkUuid name seed)}" \ + -T 0 \ + $out \ + ${sourceTree} + ''; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..22d25d0 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,226 @@ +{ + config, + lib, + options, + pkgs, + ... +}: +let + mkExt = pkgs.callPackage ./lib/mkExt.nix { }; + + # Allowed root paths for each extension type + pathMappings = { + sysext = "/opt"; + confext = "/etc"; + }; + + # Helper function to fetch the permitted root path for a given extension type + getPathPrefix = + extensionType: + let + default = throw "[naext] Unsupported extension type '${extensionType}'"; + in + lib.attrByPath [ extensionType ] default pathMappings; + + # Ensures a string starts with a given prefix. Otherwise, throw an error. + ensurePrefix = + prefix: str: + if lib.strings.hasPrefix prefix str then + str + else + throw "[naext] The string '${str}' must start with '${prefix}'"; + + # Helper function to format extension release metadata. + createExtensionReleaseFile = name: '' + mkdir -p $out/etc/extension-release.d + + cat >$out/etc/extension-release.d/extension-release.${name} < $src" + if [ "$(readlink "$out/$target")" != "$src" ]; then + echo "mismatched duplicate entry $(readlink "$out/$target") <-> $src" + ret=1 + continue + fi + fi + fi + } + + ${lib.concatMapStringsSep "\n" ( + fileEntry: + lib.escapeShellArgs [ + "mkFile" + # Force local source paths to be added to the store + "${fileEntry.source}" + (ensurePrefix (getPathPrefix type) fileEntry.target) + ] + ) (lib.attrValues files)} + ''; + + fileSubmodule = lib.types.submodule ( + { + name, + config, + options, + ... + }: + { + options = { + target = lib.mkOption { + type = lib.types.str; + description = "File target relative to the extension image's root"; + }; + source = lib.mkOption { + type = lib.types.path; + description = "Path of the source file"; + }; + }; + config.target = lib.mkDefault name; + } + ); + + cfg = config.naext; +in +{ + options.naext = { + privateKey = lib.mkOption { + description = "Signing key to use for the verity signature"; + type = lib.types.path; + }; + certificate = lib.mkOption { + description = "PEM encoded X.509 certificate to create the verity signature"; + type = lib.types.path; + }; + seed = lib.mkOption { + description = "Used to derive UUIDs to assign to partitions and the partition table. Takes a UUID as argument or the special value random."; + example = "12345678-1234-1234-1234-123456789123"; + type = lib.types.str; + }; + extensions = lib.mkOption { + description = "Extension Image"; + type = lib.types.attrsOf ( + lib.types.submodule ( + { + name, + config, + options, + ... + }: + { + options = { + extensionType = lib.mkOption { + description = "Extension Image Type"; + type = lib.types.enum [ + "sysext" + "confext" + ]; + }; + imageFormat = lib.mkOption { + description = "Extension Image Type"; + type = lib.types.enum [ + "verity" # verity-protected raw erofs image + "raw" # raw erofs image + ]; + }; + extension-release = lib.mkOption { + description = "Contents of the Extension Release file."; + type = lib.types.lines; + default = '' + ID=nixos + VERSION_ID="24.11" + CONFEXT_SCOPE=system initrd + ''; + example = lib.literalExpression '' + ID=nixos + VERSION_ID="24.11" + CONFEXT_SCOPE=system initrd + ''; + }; + files = lib.mkOption { + description = "Extension Image Files"; + type = lib.types.attrsOf fileSubmodule; + default = { }; + }; + image = lib.mkOption { + readOnly = true; + description = "Resulting Extension Image"; + type = lib.types.package; + }; + json = lib.mkOption { + readOnly = true; + description = "JSON description of the resulting Extension Image"; + type = lib.types.package; + }; + name = lib.mkOption { + description = "Name of the Extension Image"; + type = lib.types.str; + }; + }; + config = { + + name = lib.mkDefault name; + json = pkgs.writeText "json" (builtins.toJSON (lib.attrValues cfg.extensions."${name}".files)); + + image = + let + # The configuration of the current extension we're dealing with. + self = cfg.extensions."${name}"; + # Add the extension-release to the files. + mergedFiles = self.files // { + "/etc/extension-release.d/extension-release.${name}" = { + source = self.extension-release; + target = "/etc/extension-release.d/extension-release.${name}"; + }; + }; + # The tree to create the extension image from. + tree = mkExtTree self.extensionType mergedFiles self.name; + # Determin the function to use for creating the extension image. + mkExtMethod = + if self.imageFormat == "verity" then + mkExt.verity + else if self.imageFormat == "raw" then + mkExt.raw + else + abort "[naext] `imageFormat` has a wrong value defined."; + in + mkExtMethod { + inherit name; + ddiType = cfg.extensions."${name}".extensionType; + sourceTree = tree; + inherit (cfg) certificate privateKey seed; + }; + }; + } + ) + ); + }; + }; +} diff --git a/nix/outputs.nix b/nix/outputs.nix new file mode 100644 index 0000000..3547fc1 --- /dev/null +++ b/nix/outputs.nix @@ -0,0 +1,13 @@ +{ + pkgs, + ... +}: +rec { + nixosModules = { + default = import ./module.nix; + dm-verity = import ./dm-verity.nix; + }; + checks.tests = import ./tests { + inherit nixosModules pkgs; + }; +} diff --git a/nix/sources.json b/nix/sources.json new file mode 100644 index 0000000..9135ba0 --- /dev/null +++ b/nix/sources.json @@ -0,0 +1,14 @@ +{ + "nixpkgs": { + "branch": "nixos-unstable", + "description": "Nix Packages collection", + "homepage": null, + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2", + "sha256": "03pmy2dv212mmxgcvwxinf3xy6m6zzr8ri71pda1lqggmll2na12", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/76612b17c0ce71689921ca12d9ffdc9c23ce40b2.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" + } +} diff --git a/nix/sources.nix b/nix/sources.nix new file mode 100644 index 0000000..6f55a85 --- /dev/null +++ b/nix/sources.nix @@ -0,0 +1,250 @@ +# This file has been generated by Niv. + +let + + # + # The fetchers. fetch_ fetches specs of type . + # + + fetch_file = + pkgs: name: spec: + let + name' = sanitizeName name + "-src"; + in + if spec.builtin or true then + builtins_fetchurl { + inherit (spec) url sha256; + name = name'; + } + else + pkgs.fetchurl { + inherit (spec) url sha256; + name = name'; + }; + + fetch_tarball = + pkgs: name: spec: + let + name' = sanitizeName name + "-src"; + in + if spec.builtin or true then + builtins_fetchTarball { + name = name'; + inherit (spec) url sha256; + } + else + pkgs.fetchzip { + name = name'; + inherit (spec) url sha256; + }; + + fetch_git = + name: spec: + let + ref = + spec.ref or ( + if spec ? branch then + "refs/heads/${spec.branch}" + else if spec ? tag then + "refs/tags/${spec.tag}" + else + abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!" + ); + submodules = spec.submodules or false; + submoduleArg = + let + nixSupportsSubmodules = builtins.compareVersions builtins.nixVersion "2.4" >= 0; + emptyArgWithWarning = + if submodules then + builtins.trace ( + "The niv input \"${name}\" uses submodules " + + "but your nix's (${builtins.nixVersion}) builtins.fetchGit " + + "does not support them" + ) { } + else + { }; + in + if nixSupportsSubmodules then { inherit submodules; } else emptyArgWithWarning; + in + builtins.fetchGit ( + { + url = spec.repo; + inherit (spec) rev; + inherit ref; + } + // submoduleArg + ); + + fetch_local = spec: spec.path; + + fetch_builtin-tarball = + name: + throw '' + [${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`. + $ niv modify ${name} -a type=tarball -a builtin=true''; + + fetch_builtin-url = + name: + throw '' + [${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`. + $ niv modify ${name} -a type=file -a builtin=true''; + + # + # Various helpers + # + + # https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695 + sanitizeName = + name: + (concatMapStrings (s: if builtins.isList s then "-" else s) ( + builtins.split "[^[:alnum:]+._?=-]+" ((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name) + )); + + # The set of packages used when specs are fetched using non-builtins. + mkPkgs = + sources: system: + let + sourcesNixpkgs = import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { + inherit system; + }; + hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath; + hasThisAsNixpkgsPath = == ./.; + in + if builtins.hasAttr "nixpkgs" sources then + sourcesNixpkgs + else if hasNixpkgsPath && !hasThisAsNixpkgsPath then + import { } + else + abort '' + Please specify either (through -I or NIX_PATH=nixpkgs=...) or + add a package called "nixpkgs" to your sources.json. + ''; + + # The actual fetching function. + fetch = + pkgs: name: spec: + + if !builtins.hasAttr "type" spec then + abort "ERROR: niv spec ${name} does not have a 'type' attribute" + else if spec.type == "file" then + fetch_file pkgs name spec + else if spec.type == "tarball" then + fetch_tarball pkgs name spec + else if spec.type == "git" then + fetch_git name spec + else if spec.type == "local" then + fetch_local spec + else if spec.type == "builtin-tarball" then + fetch_builtin-tarball name + else if spec.type == "builtin-url" then + fetch_builtin-url name + else + abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}"; + + # If the environment variable NIV_OVERRIDE_${name} is set, then use + # the path directly as opposed to the fetched source. + replace = + name: drv: + let + saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name; + ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}"; + in + if ersatz == "" then + drv + else + # this turns the string into an actual Nix path (for both absolute and + # relative paths) + if builtins.substring 0 1 ersatz == "/" then + /. + ersatz + else + /. + builtins.getEnv "PWD" + "/${ersatz}"; + + # Ports of functions for older nix versions + + # a Nix version of mapAttrs if the built-in doesn't exist + mapAttrs = + builtins.mapAttrs or ( + f: set: + with builtins; + listToAttrs ( + map (attr: { + name = attr; + value = f attr set.${attr}; + }) (attrNames set) + ) + ); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295 + range = + first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257 + stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1)); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269 + stringAsChars = f: s: concatStrings (map f (stringToCharacters s)); + concatMapStrings = f: list: concatStrings (map f list); + concatStrings = builtins.concatStringsSep ""; + + # https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331 + optionalAttrs = cond: as: if cond then as else { }; + + # fetchTarball version that is compatible between all the versions of Nix + builtins_fetchTarball = + { + url, + name ? null, + sha256, + }@attrs: + let + inherit (builtins) lessThan nixVersion fetchTarball; + in + if lessThan nixVersion "1.12" then + fetchTarball ({ inherit url; } // (optionalAttrs (name != null) { inherit name; })) + else + fetchTarball attrs; + + # fetchurl version that is compatible between all the versions of Nix + builtins_fetchurl = + { + url, + name ? null, + sha256, + }@attrs: + let + inherit (builtins) lessThan nixVersion fetchurl; + in + if lessThan nixVersion "1.12" then + fetchurl ({ inherit url; } // (optionalAttrs (name != null) { inherit name; })) + else + fetchurl attrs; + + # Create the final "sources" from the config + mkSources = + config: + mapAttrs ( + name: spec: + if builtins.hasAttr "outPath" spec then + abort "The values in sources.json should not have an 'outPath' attribute" + else + spec // { outPath = replace name (fetch config.pkgs name spec); } + ) config.sources; + + # The "config" used by the fetchers + mkConfig = + { + sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null, + sources ? if sourcesFile == null then { } else builtins.fromJSON (builtins.readFile sourcesFile), + system ? builtins.currentSystem, + pkgs ? mkPkgs sources system, + }: + rec { + # The sources, i.e. the attribute set of spec name to spec + inherit sources; + + # The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers + inherit pkgs; + }; + +in +mkSources (mkConfig { }) // { __functor = _: settings: mkSources (mkConfig settings); } diff --git a/nix/tests/basic.nix b/nix/tests/basic.nix new file mode 100644 index 0000000..d923a43 --- /dev/null +++ b/nix/tests/basic.nix @@ -0,0 +1,39 @@ +# 1. Copy confext created with naext into confext search path +# 2. systemd-confext refresh +# 3. Assert that the confext has been activated and the provided file has the expected content +{ + pkgs, + ... +}: +{ + name = "confext-basic"; + nodes = { + machine = _: { + naext = { + seed = "12345678-1234-1234-1234-123456789123"; + extensions = { + test = { + extensionType = "confext"; + imageFormat = "raw"; + files = { + "/etc/test".source = pkgs.writeText "example" ''Hello''; + }; + }; + }; + }; + }; + }; + testScript = + { nodes, ... }: + let + ext = nodes.machine.naext.extensions.test; + in + # python + '' + machine.copy_from_host("${ext.image}", "/var/lib/confexts/${ext.name}.${ext.extensionType}.raw") + machine.succeed("systemd-confext refresh") + machine.wait_for_file("/etc/test") + content=machine.succeed("cat /etc/test") + assert content=="Hello", "File provided by confext has expected content" + ''; +} diff --git a/nix/tests/default.nix b/nix/tests/default.nix new file mode 100644 index 0000000..340b602 --- /dev/null +++ b/nix/tests/default.nix @@ -0,0 +1,32 @@ +{ + pkgs, + nixosModules, + enableHeavyTests ? true, +}: +let + # Those tests require recompiling large components like the kernel. Therefore we currently + # disable them in places like the check attribute of the flake. + heavyTests = + if enableHeavyTests then + { + dm-verity = runNixosTest ./dm-verity.nix; + } + else + { }; + + runNixosTest = + module: + pkgs.testers.runNixOSTest { + imports = [ + module + ]; + extraBaseModules = { + imports = builtins.attrValues nixosModules; + }; + }; +in +{ + basic = runNixosTest ./basic.nix; + +} +// heavyTests diff --git a/nix/tests/dm-verity.nix b/nix/tests/dm-verity.nix new file mode 100644 index 0000000..e3bdace --- /dev/null +++ b/nix/tests/dm-verity.nix @@ -0,0 +1,67 @@ +# 1. Copy confext created with naext into confext search path +# 2. systemd-confext refresh +# 3. Assert that the confext has been activated and the provided file has the expected content +{ + pkgs, + ... +}: +let + cert_id = "a6b64f7fdadc8ba80554f9da58363cbee9b48d88"; + cert = ./fixtures/cert.pem; + privk = ./fixtures/privk.pem; +in +{ + name = "confext-dm-verity"; + nodes = { + machine = + { + modulesPath, + ... + }: + { + imports = [ + "${modulesPath}/image/repart.nix" + ]; + + # Embed certificate in the kernel's keyring + dm-verity = { + enable = true; + trustedKeys = [ ./fixtures/cert.pem ]; + }; + + # Define our extension image. + naext = { + seed = "12345678-1234-1234-1234-123456789123"; + privateKey = privk; + certificate = cert; + extensions = { + test = { + extensionType = "confext"; + imageFormat = "verity"; + files = { + "/etc/test".source = pkgs.writeText "example" ''Hello''; + }; + }; + }; + }; + }; + }; + testScript = + { nodes, ... }: + let + testExt = nodes.machine.naext.extensions.test; + in + # python + '' + with subtest("Kernel was built with an additional certificate"): + keyring = machine.succeed("cat /proc/keys") + assert "${cert_id}" in keyring, "expected key not in kernel keyring" + + with subtest("Confext gets applied successfully"): + machine.copy_from_host("${testExt.image}", "/var/lib/confexts/${testExt.name}.${testExt.extensionType}.raw") + machine.succeed("systemd-confext refresh") + machine.wait_for_file("/etc/test") + content=machine.succeed("cat /etc/test") + assert content=="Hello", "File provided by confext has expected content" + ''; +} diff --git a/nix/tests/fixtures/cert.pem b/nix/tests/fixtures/cert.pem new file mode 100644 index 0000000..bc75a95 --- /dev/null +++ b/nix/tests/fixtures/cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFADCCAuigAwIBAgIUP6VStZELXGhID3YNEZ05YEhdH/YwDQYJKoZIhvcNAQEN +BQAwGzEZMBcGA1UEAwwQRGVtbyBTaWduaW5nIEtleTAeFw0yNDA1MjkwOTU5MDFa +Fw0yNTA1MjkwOTU5MDFaMBsxGTAXBgNVBAMMEERlbW8gU2lnbmluZyBLZXkwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0RfIIun6FfBKqjsqCOSy05ckr +5VR0AH7+lFxjVS5bThPqRlhPFQVtgEpCJ1I6MmpWMqTtnEG2JcnL4BMx7dlmKPVF +M6oypuXDzp3WDE7uCwF9xbD9c77c149vsUIploxKCJb7MA3+Sb6L652h+uPNWn3h +CmE4oz4MvaCA7z65un5jVP0SC3J92a/MbKELsdtf7d46i3lkoHLGMDU5ddmH3nN5 +A1XkoYFDHVb/X1gk+ym4jbQXulNmMXKQG75BXdM4UFbhTX6M4ATMBKYnL1dy0ZiR +QxN2cDc/VsmtxSvOAoqVvB4FtgRUIEYGQteUHYOaEgE8gdR1xI8KiWYTVidtCLRt +nS/B2D/2rUE7srPPSClt0/RWFSMJrQa/alyfbJR8GVO5LqN3fgPpCVZiRa4OvNOy +jhEhr1rEA2y+ATDIrod492odeI0+uZr9L45z7sDNIkpEhk//4GWcZQq9uGKsVfTW +DsAZsC5Umc1yaF8RTYdDNEyw3Rqf+B1F6ddDiiaSg9ICPphmwamTydYXB+8jlSBe +1Py8v2IssfoasQmk+k4XXVlbs6or588blu6nG1G/FQ+QgkMA90wmAUciQVVPMCs/ +bq9ihTvDy4VcPCw5Ft6B9Gpp3qx5e0ojyatiJo+q1gpO0ya0+otqhMn9L5maNJ3F +CdLhCGsBZ7pncVn1oQIDAQABozwwOjAMBgNVHRMBAf8EAjAAMAsGA1UdDwQEAwIH +gDAdBgNVHQ4EFgQUprZPf9rci6gFVPnaWDY8vum0jYgwDQYJKoZIhvcNAQENBQAD +ggIBAI7+dz1k4O12gNQm6NXEyUbM4wk3Vxik9gohGTW5365u2U3dhn4AKpa7cQKt +CfAUZ/GQ+jTtY3YPkxgEniU4gUDK5yUQ8Usg8m4v6M0BPnoWjrq+ebg9NhpDtY1c +kfIBh8/zln4PRC+A6Vb/gbpLPf2xWrXVsIXk70LdxlWwnSgTnzGaQCaLHGHzqnJi +4XRROEkfSJuco+7Qvm8y5dBFhDQkL3s8W2R/F5L7vrmIjMWRueMB+8b6WVzLtQqR +sTM2J7qZPl0sCAkZER7OuFGvRyWiyCk+5ze4LIEf+od5Sr92PR7asQ1MZD/Ns1hh +ZyhZVxsaT7k3MXz5YPH8qRyxVVH0DlBV3A/ql5ScBuyNQKXWwtc56mYP5hwLhu9z +ddvypsWDBhpVfTcGz5UhBT5OLCUsxmPLlY8vBiX61XpUEn7zSaNVIRyYSmovcX9s +m0UoUMVrZUeGjbK9xQIgw2/jutyx0Ueijvv82D5CusZlIbiPMvtBlGmOm9NWKf69 +f2oiU4BoqlgcQ6LuC4EB6ASjF23+Z3Hi8Iwy88VsjDTVZlUh21p7enq0UXo5KpKn +IETRkpDltY5mM7A80ov4rVnUF7BU4Go6IUZiToPVw6gZkqpqZfCA5uLWYUnPbliT +owQFNUrCChp5jYeaWX2pzuV3oyp42tDlUOZR7aW1O9WM4ZNo +-----END CERTIFICATE----- diff --git a/nix/tests/fixtures/privk.pem b/nix/tests/fixtures/privk.pem new file mode 100644 index 0000000..5aae4ef --- /dev/null +++ b/nix/tests/fixtures/privk.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +taMJku3KErjc86CUFF6gclYlOLpEhw== +-----END PRIVATE KEY-----