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 index 17b3668..b3e026d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@V27 + - uses: cachix/install-nix-action@v30 - name: Add keys group (needed for go tests) run: sudo groupadd keys - name: Run unit tests diff --git a/.github/workflows/update-vendor-hash.yml b/.github/workflows/update-vendor-hash.yml index fa351c2..bf6b787 100644 --- a/.github/workflows/update-vendor-hash.yml +++ b/.github/workflows/update-vendor-hash.yml @@ -14,7 +14,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Install Nix - uses: cachix/install-nix-action@V27 + uses: cachix/install-nix-action@v30 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/upgrade-flakes.yml b/.github/workflows/upgrade-flakes.yml index 3aa7dd5..ed3d891 100644 --- a/.github/workflows/upgrade-flakes.yml +++ b/.github/workflows/upgrade-flakes.yml @@ -4,19 +4,26 @@ on: workflow_dispatch: schedule: - cron: '51 2 * * 0' + +permissions: + pull-requests: write + jobs: createPullRequest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Nix - uses: cachix/install-nix-action@V27 + uses: cachix/install-nix-action@v30 with: extra_nix_config: | access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} - - name: Update flake.lock - uses: DeterminateSystems/update-flake-lock@v24 - with: - token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} - pr-labels: | - merge-queue + - name: Update flakes + run: | + nix flake update + pushd dev/private + nix flake update + popd + nix run .#update-dev-private-narHash + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 diff --git a/.mergify.yml b/.mergify.yml index dbe75ec..2fc1e2e 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,16 +1,14 @@ queue_rules: - name: default - merge_conditions: - - check-success=buildbot/nix-eval -defaults: - actions: - queue: - merge_method: rebase -pull_request_rules: - - name: merge using the merge queue - conditions: + queue_conditions: - base=master - label~=merge-queue|dependencies + merge_conditions: + - check-success=buildbot/nix-build + merge_method: rebase + +pull_request_rules: + - name: refactored queue action rule + conditions: [] actions: queue: - diff --git a/README.md b/README.md index 44242ef..5d5769f 100644 --- a/README.md +++ b/README.md @@ -674,8 +674,7 @@ JSON/YAML files. Unlike the other two formats, for binary files, one file corres To encrypt an binary file use the following command: ``` console -$ cp /etc/krb5/krb5.keytab krb5.keytab -$ sops -e krb5.keytab +$ sops -e /etc/krb5/krb5.keytab > krb5.keytab # an example of what this might result in: $ head krb5.keytab { @@ -708,6 +707,44 @@ This is how it can be included in your `configuration.nix`: } ``` +## Emit plain file for yaml and json formats + +By default, sops-nix extracts a single key from yaml and json files. If you +need the plain file instead of extracting a specific key from the input document, +you can set `key` to an empty string. + +For example, the input document `my-config.yaml` likes this: + +```yaml +my-secret1: ENC[AES256_GCM,data:tkyQPQODC3g=,iv:yHliT2FJ74EtnLIeeQtGbOoqVZnF0q5HiXYMJxYx6HE=,tag:EW5LV4kG4lcENaN2HIFiow==,type:str] +my-secret2: ENC[AES256_GCM,data:tkyQPQODC3g=,iv:yHliT2FJ74EtnLIeeQtGbOoqVZnF0q5HiXYMJxYx6HE=,tag:EW5LV4kG4lcENaN2HIFiow==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] +... +``` + +This is how it can be included in your NixOS module: + +```nix +{ + sops.secrets.my-config = { + format = "yaml"; + sopsFile = ./my-config.yaml; + key = ""; + }; +} +``` + +Then, it will be mounted as `/run/secrets/my-config`: + +```yaml +my-secret1: hello +my-secret2: hello +``` + ## Use with home manager sops-nix also provides a home-manager module. @@ -786,6 +823,31 @@ The secrets are decrypted in a systemd user service called `sops-nix`, so other } ``` +### Qubes Split GPG support + +If you are using Qubes with the [Split GPG](https://www.qubes-os.org/doc/split-gpg), +then you can configure sops to utilize the `qubes-gpg-client-wrapper` with the `sops.gnupg.qubes-split-gpg` options. +The example above updated looks like this: +```nix +{ + sops = { + gnupg.qubes-split-gpg = { + enable = true; + domain = "vault-gpg"; + }; + defaultSopsFile = ./secrets.yaml; + secrets.test = { + # sopsFile = ./secrets.yml.enc; # optionally define per-secret files + + # %r gets replaced with a runtime directory, use %% to specify a '%' + # sign. Runtime dir is $XDG_RUNTIME_DIR on linux and $(getconf + # DARWIN_USER_TEMP_DIR) on darwin. + path = "%r/test.txt"; + }; + }; +} +``` + ## Use with GPG instead of SSH keys If you prefer having a separate GPG key, sops-nix also comes with a helper tool, `sops-init-gpg-key`: diff --git a/checks/darwin.nix b/checks/darwin.nix new file mode 100644 index 0000000..8b74e33 --- /dev/null +++ b/checks/darwin.nix @@ -0,0 +1,11 @@ + +{ + imports = [ + ../modules/nix-darwin/default.nix + ]; + documentation.enable = false; + sops.secrets.test_key = { }; + sops.defaultSopsFile = ../pkgs/sops-install-secrets/test-assets/secrets.yaml; + sops.age.generateKey = true; + system.stateVersion = 5; +} diff --git a/checks/home-manager.nix b/checks/home-manager.nix new file mode 100644 index 0000000..d1e6355 --- /dev/null +++ b/checks/home-manager.nix @@ -0,0 +1,15 @@ + +{ config, ... }: { + imports = [ + ../modules/home-manager/sops.nix + ]; + home.stateVersion = "25.05"; + home.username = "sops-user"; + home.homeDirectory = "/home/sops-user"; + home.enableNixpkgsReleaseCheck = false; + + sops.age.generateKey = true; + sops.age.keyFile = "${config.home.homeDirectory}/.age-key.txt"; + sops.secrets.test_key = { }; + sops.defaultSopsFile = ../pkgs/sops-install-secrets/test-assets/secrets.yaml; +} diff --git a/checks/nixos-test.nix b/checks/nixos-test.nix new file mode 100644 index 0000000..1d70bde --- /dev/null +++ b/checks/nixos-test.nix @@ -0,0 +1,596 @@ +{ lib, testers }: +let + testAssets = ../pkgs/sops-install-secrets/test-assets; + + userPasswordTest = + name: extraConfig: + testers.runNixOSTest { + inherit name; + nodes.machine = + { config, lib, ... }: + { + imports = [ + ../modules/sops + extraConfig + ]; + sops = { + age.keyFile = "/run/age-keys.txt"; + defaultSopsFile = testAssets + "/secrets.yaml"; + secrets.test_key.neededForUsers = true; + secrets."nested/test/file".owner = "example-user"; + }; + system.switch.enable = true; + + users.users.example-user = lib.mkMerge [ + (lib.mkIf (!config.systemd.sysusers.enable) { + isNormalUser = true; + hashedPasswordFile = config.sops.secrets.test_key.path; + }) + (lib.mkIf config.systemd.sysusers.enable { + isSystemUser = true; + group = "users"; + hashedPasswordFile = config.sops.secrets.test_key.path; + }) + ]; + }; + + testScript = + '' + start_all() + machine.wait_for_unit("multi-user.target") + + machine.succeed("getent shadow example-user | grep -q :test_value:") # password was set + machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # regular secrets work... + user = machine.succeed("stat -c%U /run/secrets/nested/test/file").strip() # ...and are owned... + assert user == "example-user", f"Expected 'example-user', got '{user}'" + machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password still exists + + # BUG in nixos's overlayfs... systemd crashes on switch-to-configuration test + '' + + lib.optionalString (!(extraConfig ? system.etc.overlay.enable)) '' + machine.succeed("/run/current-system/bin/switch-to-configuration test") + machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # the regular secrets still work after a switch + machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password is still present after a switch + ''; + }; +in +{ + ssh-keys = testers.runNixOSTest { + name = "sops-ssh-keys"; + nodes.server = + { ... }: + { + imports = [ ../modules/sops ]; + services.openssh.enable = true; + services.openssh.hostKeys = [ + { + type = "rsa"; + bits = 4096; + path = testAssets + "/ssh-key"; + } + ]; + sops.defaultSopsFile = testAssets + "/secrets.yaml"; + sops.secrets.test_key = { }; + }; + + testScript = '' + start_all() + server.succeed("cat /run/secrets/test_key | grep -q test_value") + ''; + }; + + pruning = testers.runNixOSTest { + name = "sops-pruning"; + nodes.machine = + { lib, ... }: + { + imports = [ ../modules/sops ]; + sops = { + age.keyFile = "/run/age-keys.txt"; + defaultSopsFile = testAssets + "/secrets.yaml"; + secrets.test_key = { }; + keepGenerations = lib.mkDefault 0; + }; + + # must run before sops sets up keys + boot.initrd.postDeviceCommands = '' + cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + + specialisation.pruning.configuration.sops.keepGenerations = 10; + }; + + testScript = '' + # Force us to generation 100 + machine.succeed("mkdir /run/secrets.d/{2..99} /run/secrets.d/non-numeric") + machine.succeed("ln -fsn /run/secrets.d/99 /run/secrets") + machine.succeed("/run/current-system/activate") + machine.succeed("test -d /run/secrets.d/100") + + # Ensure nothing is pruned, these are just random numbers + machine.succeed("test -d /run/secrets.d/1") + machine.succeed("test -d /run/secrets.d/90") + machine.succeed("test -d /run/secrets.d/non-numeric") + + machine.succeed("/run/current-system/specialisation/pruning/bin/switch-to-configuration test") + print(machine.succeed("ls -la /run/secrets.d/")) + + # Ensure stuff was properly pruned. + # We are now at generation 101 so 92 must exist when we keep 10 generations + # and 91 must not. + machine.fail("test -d /run/secrets.d/91") + machine.succeed("test -d /run/secrets.d/92") + machine.succeed("test -d /run/secrets.d/non-numeric") + ''; + }; + + age-keys = testers.runNixOSTest { + name = "sops-age-keys"; + nodes.machine = + { config, ... }: + { + imports = [ ../modules/sops ]; + sops = { + age.keyFile = "/run/age-keys.txt"; + defaultSopsFile = testAssets + "/secrets.yaml"; + secrets = { + test_key = { }; + + test_key_someuser_somegroup = { + uid = config.users.users."someuser".uid; + gid = config.users.groups."somegroup".gid; + key = "test_key"; + }; + test_key_someuser_root = { + uid = config.users.users."someuser".uid; + key = "test_key"; + }; + test_key_root_root = { + key = "test_key"; + }; + test_key_1001_1001 = { + uid = 1001; + gid = 1001; + key = "test_key"; + }; + }; + }; + + users.users."someuser" = { + uid = 1000; + group = "somegroup"; + isNormalUser = true; + }; + users.groups."somegroup" = { + gid = 1000; + }; + + # must run before sops sets up keys + boot.initrd.postDeviceCommands = '' + cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + }; + + testScript = '' + start_all() + machine.succeed("cat /run/secrets/test_key | grep -q test_value") + + with subtest("test ownership"): + machine.succeed("[ $(stat -c%u /run/secrets/test_key_someuser_somegroup) = '1000' ]") + machine.succeed("[ $(stat -c%g /run/secrets/test_key_someuser_somegroup) = '1000' ]") + machine.succeed("[ $(stat -c%U /run/secrets/test_key_someuser_somegroup) = 'someuser' ]") + machine.succeed("[ $(stat -c%G /run/secrets/test_key_someuser_somegroup) = 'somegroup' ]") + + machine.succeed("[ $(stat -c%u /run/secrets/test_key_someuser_root) = '1000' ]") + machine.succeed("[ $(stat -c%g /run/secrets/test_key_someuser_root) = '0' ]") + machine.succeed("[ $(stat -c%U /run/secrets/test_key_someuser_root) = 'someuser' ]") + machine.succeed("[ $(stat -c%G /run/secrets/test_key_someuser_root) = 'root' ]") + + machine.succeed("[ $(stat -c%u /run/secrets/test_key_1001_1001) = '1001' ]") + machine.succeed("[ $(stat -c%g /run/secrets/test_key_1001_1001) = '1001' ]") + machine.succeed("[ $(stat -c%U /run/secrets/test_key_1001_1001) = 'UNKNOWN' ]") + machine.succeed("[ $(stat -c%G /run/secrets/test_key_1001_1001) = 'UNKNOWN' ]") + ''; + }; + + age-ssh-keys = testers.runNixOSTest { + name = "sops-age-ssh-keys"; + nodes.machine = { + imports = [ ../modules/sops ]; + services.openssh.enable = true; + services.openssh.hostKeys = [ + { + type = "ed25519"; + path = testAssets + "/ssh-ed25519-key"; + } + ]; + + sops = { + defaultSopsFile = testAssets + "/secrets.yaml"; + secrets.test_key = { }; + # Generate a key and append it to make sure it appending doesn't break anything + age = { + keyFile = "/tmp/testkey"; + generateKey = true; + }; + }; + }; + + testScript = '' + start_all() + machine.succeed("cat /run/secrets/test_key | grep -q test_value") + ''; + }; + + pgp-keys = testers.runNixOSTest { + name = "sops-pgp-keys"; + nodes.server = + { lib, config, ... }: + { + imports = [ ../modules/sops ]; + + users.users.someuser = { + isSystemUser = true; + group = "nogroup"; + }; + + sops.gnupg.home = "/run/gpghome"; + sops.defaultSopsFile = testAssets + "/secrets.yaml"; + sops.secrets.test_key.owner = config.users.users.someuser.name; + sops.secrets."nested/test/file".owner = config.users.users.someuser.name; + sops.secrets.existing-file = { + key = "test_key"; + path = "/run/existing-file"; + }; + # must run before sops + system.activationScripts.gnupghome = lib.stringAfter [ "etc" ] '' + cp -r ${testAssets + "/gnupghome"} /run/gpghome + chmod -R 700 /run/gpghome + + touch /run/existing-file + ''; + # Useful for debugging + #environment.systemPackages = [ pkgs.gnupg pkgs.sops ]; + #environment.variables = { + # GNUPGHOME = "/run/gpghome"; + # SOPS_GPG_EXEC="${pkgs.gnupg}/bin/gpg"; + # SOPSFILE = "${testAssets + "/secrets.yaml"}"; + #}; + }; + testScript = '' + def assertEqual(exp: str, act: str) -> None: + if exp != act: + raise Exception(f"{exp!r} != {act!r}") + + + start_all() + + value = server.succeed("cat /run/secrets/test_key") + assertEqual("test_value", value) + + server.succeed("runuser -u someuser -- cat /run/secrets/test_key >&2") + value = server.succeed("cat /run/secrets/nested/test/file") + assertEqual(value, "another value") + + target = server.succeed("readlink -f /run/existing-file") + assertEqual("/run/secrets.d/1/existing-file", target.strip()) + ''; + }; + + templates = testers.runNixOSTest { + name = "sops-templates"; + nodes.machine = + { config, ... }: + { + imports = [ ../modules/sops ]; + sops = { + age.keyFile = "/run/age-keys.txt"; + defaultSopsFile = testAssets + "/secrets.yaml"; + secrets.test_key = { }; + + # Verify that things work even with `neededForUsers` secrets. See + # . + secrets."nested/test/file".neededForUsers = true; + }; + + # must run before sops sets up keys + boot.initrd.postDeviceCommands = '' + cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + + sops.templates.test_template = { + content = '' + This line is not modified. + The next value will be replaced by ${config.sops.placeholder.test_key} + This line is also not modified. + ''; + mode = "0400"; + owner = "someuser"; + group = "somegroup"; + }; + sops.templates.test_default = { + content = '' + Test value: ${config.sops.placeholder.test_key} + ''; + path = "/etc/externally/linked"; + }; + + users.groups.somegroup = { }; + users.users.someuser = { + isSystemUser = true; + group = "somegroup"; + }; + }; + + testScript = '' + def assertEqual(exp: str, act: str) -> None: + if exp != act: + raise Exception(f"{exp!r} != {act!r}") + + + start_all() + machine.succeed("[ $(stat -c%U /run/secrets/rendered/test_template) = 'someuser' ]") + machine.succeed("[ $(stat -c%G /run/secrets/rendered/test_template) = 'somegroup' ]") + machine.succeed("[ $(stat -c%U /run/secrets/rendered/test_default) = 'root' ]") + machine.succeed("[ $(stat -c%G /run/secrets/rendered/test_default) = 'root' ]") + + expected = """\ + This line is not modified. + The next value will be replaced by test_value + This line is also not modified. + """ + rendered = machine.succeed("cat /run/secrets/rendered/test_template") + + expected_default = """\ + Test value: test_value + """ + rendered_default = machine.succeed("cat /run/secrets/rendered/test_default") + + assertEqual(expected, rendered) + assertEqual(expected_default, rendered_default) + + # Confirm that `test_default` was symlinked to the appropriate place. + realpath = machine.succeed("realpath /etc/externally/linked").strip() + assertEqual(realpath, "/run/secrets.d/1/rendered/test_default") + ''; + }; + + restart-and-reload = testers.runNixOSTest { + name = "sops-restart-and-reload"; + nodes.machine = + { config, ... }: + { + imports = [ ../modules/sops ]; + + sops = { + age.keyFile = "/run/age-keys.txt"; + defaultSopsFile = testAssets + "/secrets.yaml"; + secrets.test_key = { + restartUnits = [ + "restart-unit.service" + "reload-unit.service" + ]; + reloadUnits = [ "reload-trigger.service" ]; + }; + + templates.test_template = { + content = '' + this is a template with + a secret: ${config.sops.placeholder.test_key} + ''; + restartUnits = [ + "restart-unit.service" + "reload-unit.service" + ]; + reloadUnits = [ "reload-trigger.service" ]; + }; + }; + system.switch.enable = true; + + # must run before sops sets up keys + boot.initrd.postDeviceCommands = '' + cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + + systemd.services."restart-unit" = { + description = "Restart unit"; + # not started on boot + serviceConfig = { + ExecStart = "/bin/sh -c 'echo ok > /restarted'"; + }; + }; + systemd.services."reload-unit" = { + description = "Reload unit"; + wantedBy = [ "multi-user.target" ]; + reloadIfChanged = true; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "/bin/sh -c true"; + ExecReload = "/bin/sh -c 'echo ok > /reloaded'"; + }; + }; + systemd.services."reload-trigger" = { + description = "Reload trigger unit"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "/bin/sh -c true"; + ExecReload = "/bin/sh -c 'echo ok > /reloaded'"; + }; + }; + + }; + testScript = '' + def assertOutput(output, *expected_lines): + expected_lines = list(expected_lines) + + # Remove unrelated fluff that shows up in the output of `switch-to-configuration`. + prefix = "setting up /etc...\n" + if output.startswith(prefix): + output = output.removeprefix(prefix) + + actual_lines = output.splitlines(keepends=False) + + if actual_lines != expected_lines: + raise Exception(f"{actual_lines} != {expected_lines}") + + machine.wait_for_unit("multi-user.target") + machine.fail("test -f /restarted") + machine.fail("test -f /reloaded") + + # Nothing is to be restarted after boot + machine.fail("ls /run/nixos/*-list") + + # Nothing happens when the secret is not changed + machine.succeed("/run/current-system/bin/switch-to-configuration test") + machine.fail("test -f /restarted") + machine.fail("test -f /reloaded") + + # Ensure the secret is changed + machine.succeed(": > /run/secrets/test_key") + + # The secret is changed, now something should happen + machine.succeed("/run/current-system/bin/switch-to-configuration test") + + # Ensure something happened + machine.succeed("test -f /restarted") + machine.succeed("test -f /reloaded") + + # Cleanup the marker files. + machine.succeed("rm /restarted /reloaded") + + # Ensure the template is changed + machine.succeed(": > /run/secrets/rendered/test_template") + + # The template is changed, now something should happen + machine.succeed("/run/current-system/bin/switch-to-configuration test") + + # Ensure something happened + machine.succeed("test -f /restarted") + machine.succeed("test -f /reloaded") + + # Cleanup the marker files. + machine.succeed("rm /restarted /reloaded") + + with subtest("change detection"): + machine.succeed("rm /run/secrets/test_key") + machine.succeed("rm /run/secrets/rendered/test_template") + out = machine.succeed("/run/current-system/bin/switch-to-configuration test") + assertOutput( + out, + "adding secret: test_key", + "adding rendered secret: test_template", + ) + + machine.succeed(": > /run/secrets/test_key") + machine.succeed(": > /run/secrets/rendered/test_template") + out = machine.succeed("/run/current-system/bin/switch-to-configuration test") + assertOutput( + out, + "modifying secret: test_key", + "modifying rendered secret: test_template", + ) + + machine.succeed(": > /run/secrets/another_key") + machine.succeed(": > /run/secrets/rendered/another_template") + out = machine.succeed("/run/current-system/bin/switch-to-configuration test") + assertOutput( + out, + "removing secret: another_key", + "removing rendered secret: another_template", + ) + + with subtest("dry activation"): + machine.succeed("rm /run/secrets/test_key") + machine.succeed("rm /run/secrets/rendered/test_template") + machine.succeed(": > /run/secrets/another_key") + machine.succeed(": > /run/secrets/rendered/another_template") + out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate") + assertOutput( + out, + "would add secret: test_key", + "would remove secret: another_key", + "would add rendered secret: test_template", + "would remove rendered secret: another_template", + ) + + # Verify that we did not actually activate the new configuration. + machine.fail("test -f /run/secrets/test_key") + machine.fail("test -f /run/secrets/rendered/test_template") + machine.succeed("test -f /run/secrets/another_key") + machine.succeed("test -f /run/secrets/rendered/another_template") + + # Now actually activate and sanity check the resulting secrets. + machine.succeed("/run/current-system/bin/switch-to-configuration test") + machine.succeed("test -f /run/secrets/test_key") + machine.succeed("test -f /run/secrets/rendered/test_template") + machine.fail("test -f /run/secrets/another_key") + machine.fail("test -f /run/secrets/rendered/another_template") + + # Remove the restarted/reloaded indicators so we can confirm a + # dry-activate doesn't trigger systemd units. + machine.succeed("rm /restarted /reloaded") + + machine.succeed(": > /run/secrets/test_key") + out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate") + assertOutput( + out, + "would modify secret: test_key", + ) + machine.succeed("[ $(cat /run/secrets/test_key | wc -c) = 0 ]") + + machine.fail("test -f /restarted") # not done in dry mode + machine.fail("test -f /reloaded") # not done in dry mode + ''; + }; + + user-passwords = userPasswordTest "sops-user-passwords" { + # must run before sops sets up keys + boot.initrd.postDeviceCommands = '' + cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + }; +} +// lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.05") { + user-passwords-sysusers = userPasswordTest "sops-user-passwords-sysusers" ( + { pkgs, ... }: + { + systemd.sysusers.enable = true; + users.mutableUsers = true; + system.etc.overlay.enable = true; + boot.initrd.systemd.enable = true; + boot.kernelPackages = pkgs.linuxPackages_latest; + + # must run before sops sets up keys + systemd.services."sops-install-secrets-for-users".preStart = '' + printf '${builtins.readFile (testAssets + "/age-keys.txt")}' > /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + } + ); +} +// lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.11") { + user-passwords-userborn = userPasswordTest "sops-user-passwords-userborn" ( + { pkgs, ... }: + { + services.userborn.enable = true; + users.mutableUsers = false; + system.etc.overlay.enable = true; + boot.initrd.systemd.enable = true; + boot.kernelPackages = pkgs.linuxPackages_latest; + + # must run before sops sets up keys + systemd.services."sops-install-secrets-for-users".preStart = '' + printf '${builtins.readFile (testAssets + "/age-keys.txt")}' > /run/age-keys.txt + chmod -R 700 /run/age-keys.txt + ''; + } + ); +} diff --git a/default.nix b/default.nix index cbff37d..cc61b7f 100644 --- a/default.nix +++ b/default.nix @@ -1,18 +1,17 @@ -{ pkgs ? import {} -, vendorHash ? "sha256-kFDRjAqUOcTma5qLQz9YKRfP85A1Z9AXm/jThssP5wU=" -}: let +{ + pkgs ? import { }, + vendorHash ? "sha256-7xnbw5tH3MYD/aA8yBNG327IONjUoBarTluLeqTH/8A=", +}: +let sops-install-secrets = pkgs.callPackage ./pkgs/sops-install-secrets { inherit vendorHash; }; -in rec { +in +rec { inherit sops-install-secrets; - sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key {}; + sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key { }; default = sops-init-gpg-key; - sops-pgp-hook = pkgs.lib.warn '' - sops-pgp-hook is deprecated, use sops-import-keys-hook instead. - Also see https://github.com/Mic92/sops-nix/issues/98 - '' pkgs.callPackage ./pkgs/sops-pgp-hook { }; sops-import-keys-hook = pkgs.callPackage ./pkgs/sops-import-keys-hook { }; # backwards compatibility @@ -22,8 +21,9 @@ in rec { sops-pgp-hook-test = pkgs.callPackage ./pkgs/sops-pgp-hook-test.nix { inherit vendorHash; }; - unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix {}; -} // (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { + unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { }; +} +// (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { lint = pkgs.callPackage ./pkgs/lint.nix { inherit sops-install-secrets; }; diff --git a/dev/private.narHash b/dev/private.narHash new file mode 100644 index 0000000..0765e20 --- /dev/null +++ b/dev/private.narHash @@ -0,0 +1 @@ +sha256-rXlTQPa9c8Ou52KO5S36sOyKUzurr5fuZcXnHr7g6YY= \ No newline at end of file diff --git a/dev/private/flake.lock b/dev/private/flake.lock new file mode 100644 index 0000000..8e31b4f --- /dev/null +++ b/dev/private/flake.lock @@ -0,0 +1,90 @@ +{ + "nodes": { + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs-stable" + ] + }, + "locked": { + "lastModified": 1731832479, + "narHash": "sha256-icDDuYwJ0avTMZTxe1qyU/Baht5JOqw4pb5mWpR+hT0=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "5056a1cf0ce7c2a08ab50713b6c4af77975f6111", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nix-darwin": { + "inputs": { + "nixpkgs": [ + "nixpkgs-stable" + ] + }, + "locked": { + "lastModified": 1731809072, + "narHash": "sha256-pOsDJQR0imnFLfpvTmRpHcP0tflyxtP/QIzokrKSP8U=", + "owner": "LnL7", + "repo": "nix-darwin", + "rev": "34588d57cfc41c6953c54c93b6b685cab3b548ee", + "type": "github" + }, + "original": { + "owner": "LnL7", + "repo": "nix-darwin", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1731842749, + "narHash": "sha256-aNc8irVBH7sM5cGDvqdOueg8S+fGakf0rEMRGfGwWZw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bf6132dc791dbdff8b6894c3a85eb27ad8255682", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "release-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "home-manager": "home-manager", + "nix-darwin": "nix-darwin", + "nixpkgs-stable": "nixpkgs-stable", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs-stable" + ] + }, + "locked": { + "lastModified": 1730321837, + "narHash": "sha256-vK+a09qq19QNu2MlLcvN4qcRctJbqWkX7ahgPZ/+maI=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "746901bb8dba96d154b66492a29f5db0693dbfcc", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/dev/private/flake.nix b/dev/private/flake.nix new file mode 100644 index 0000000..53b2439 --- /dev/null +++ b/dev/private/flake.nix @@ -0,0 +1,15 @@ +{ + description = "private inputs"; + inputs.nixpkgs-stable.url = "github:NixOS/nixpkgs/release-24.05"; + + inputs.treefmt-nix.url = "github:numtide/treefmt-nix"; + inputs.treefmt-nix.inputs.nixpkgs.follows = "nixpkgs-stable"; + + inputs.nix-darwin.url = "github:LnL7/nix-darwin"; + inputs.nix-darwin.inputs.nixpkgs.follows = "nixpkgs-stable"; + + inputs.home-manager.url = "github:nix-community/home-manager"; + inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs-stable"; + + outputs = _: { }; +} diff --git a/flake.lock b/flake.lock index 7200b9f..eda6a5f 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1725534445, - "narHash": "sha256-Yd0FK9SkWy+ZPuNqUgmVPXokxDgMJoGuNpMEtkfcf84=", + "lastModified": 1731763621, + "narHash": "sha256-ddcX4lQL0X05AYkrkV2LMFgGdRvgap7Ho8kgon3iWZk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9bb1e7571aadf31ddb4af77fc64b2d59580f9a39", + "rev": "c69a9bffbecde46b4b939465422ddc59493d3e4d", "type": "github" }, "original": { @@ -16,26 +16,9 @@ "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1725762081, - "narHash": "sha256-vNv+aJUW5/YurRy1ocfvs4q/48yVESwlC/yHzjkZSP8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "dc454045f5b5d814e5862a6d057e7bb5c29edc05", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "release-24.05", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { - "nixpkgs": "nixpkgs", - "nixpkgs-stable": "nixpkgs-stable" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 287ac86..b389c09 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,155 @@ { description = "Integrates sops into nixos"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - inputs.nixpkgs-stable.url = "github:NixOS/nixpkgs/release-24.05"; - nixConfig.extra-substituters = ["https://cache.thalheim.io"]; - nixConfig.extra-trusted-public-keys = ["cache.thalheim.io-1:R7msbosLEZKrxk/lKxf9BTjOOH7Ax3H0Qj0/6wiHOgc="]; - outputs = { - self, - nixpkgs, - nixpkgs-stable - }: let - systems = [ - "x86_64-linux" - "x86_64-darwin" - "aarch64-darwin" - "aarch64-linux" - ]; - forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); - suffix-version = version: attrs: nixpkgs.lib.mapAttrs' (name: value: nixpkgs.lib.nameValuePair (name + version) value) attrs; - suffix-stable = suffix-version "-24_05"; - in { - overlays.default = final: prev: let - localPkgs = import ./default.nix {pkgs = final;}; - in { - inherit (localPkgs) sops-install-secrets sops-init-gpg-key sops-pgp-hook sops-import-keys-hook sops-ssh-to-age; - # backward compatibility - inherit (prev) ssh-to-pgp; - }; - nixosModules = { - sops = import ./modules/sops; - default = self.nixosModules.sops; - }; - homeManagerModules.sops = import ./modules/home-manager/sops.nix; - homeManagerModule = self.homeManagerModules.sops; - packages = forAllSystems (system: - import ./default.nix { - pkgs = import nixpkgs {inherit system;}; - }); - checks = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"] - (system: let - tests = self.packages.${system}.sops-install-secrets.tests; - packages-stable = import ./default.nix { - pkgs = import nixpkgs-stable {inherit system;}; - }; - tests-stable = packages-stable.sops-install-secrets.tests; - in tests // - (suffix-stable tests-stable) // - (suffix-stable packages-stable)); - devShells = forAllSystems (system: let - pkgs = nixpkgs.legacyPackages.${system}; - in { - unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix {}; - default = pkgs.callPackage ./shell.nix {}; - }); - }; + nixConfig.extra-substituters = [ "https://cache.thalheim.io" ]; + nixConfig.extra-trusted-public-keys = [ + "cache.thalheim.io-1:R7msbosLEZKrxk/lKxf9BTjOOH7Ax3H0Qj0/6wiHOgc=" + ]; + outputs = + { + self, + nixpkgs, + }@inputs: + let + loadPrivateFlake = + path: + let + flakeHash = builtins.readFile "${toString path}.narHash"; + flakePath = "path:${toString path}?narHash=${flakeHash}"; + in + builtins.getFlake (builtins.unsafeDiscardStringContext flakePath); + + privateFlake = loadPrivateFlake ./dev/private; + + privateInputs = privateFlake.inputs; + + systems = [ + "x86_64-linux" + "x86_64-darwin" + "aarch64-darwin" + "aarch64-linux" + ]; + + eachSystem = + f: + builtins.listToAttrs ( + builtins.map (system: { + name = system; + value = f { + pkgs = inputs.nixpkgs.legacyPackages.${system}; + inherit system; + }; + }) systems + ); + + in + # public outputs + { + overlays.default = + final: prev: + let + localPkgs = import ./default.nix { pkgs = final; }; + in + { + inherit (localPkgs) + sops-install-secrets + sops-init-gpg-key + sops-pgp-hook + sops-import-keys-hook + sops-ssh-to-age + ; + # backward compatibility + inherit (prev) ssh-to-pgp; + }; + nixosModules = { + sops = ./modules/sops; + default = self.nixosModules.sops; + }; + homeManagerModules.sops = ./modules/home-manager/sops.nix; + homeManagerModule = self.homeManagerModules.sops; + darwinModules = { + sops = ./modules/nix-darwin; + default = self.darwinModules.sops; + }; + packages = eachSystem ({ pkgs, ... }: import ./default.nix { inherit pkgs; }); + } + // + # dev outputs + { + checks = eachSystem ( + { pkgs, system, ... }: + let + packages-stable = import ./default.nix { + pkgs = privateInputs.nixpkgs-stable.legacyPackages.${system}; + }; + dropOverride = attrs: nixpkgs.lib.removeAttrs attrs [ "override" ]; + tests = dropOverride (pkgs.callPackage ./checks/nixos-test.nix { }); + tests-stable = dropOverride ( + privateInputs.nixpkgs-stable.legacyPackages.${system}.callPackage ./checks/nixos-test.nix { } + ); + suffix-version = + version: attrs: + nixpkgs.lib.mapAttrs' (name: value: nixpkgs.lib.nameValuePair (name + version) value) attrs; + suffix-stable = suffix-version "-24_05"; + in + { + home-manager = self.legacyPackages.${system}.homeConfigurations.sops.activation-script; + } + // (suffix-stable packages-stable) + // nixpkgs.lib.optionalAttrs pkgs.stdenv.isLinux tests + // nixpkgs.lib.optionalAttrs pkgs.stdenv.isLinux (suffix-stable tests-stable) + // nixpkgs.lib.optionalAttrs pkgs.stdenv.isDarwin { + darwin-sops = + self.darwinConfigurations."sops-${pkgs.hostPlatform.darwinArch}".config.system.build.toplevel; + } + ); + + darwinConfigurations.sops-arm64 = privateInputs.nix-darwin.lib.darwinSystem { + modules = [ + ./checks/darwin.nix + { nixpkgs.hostPlatform = "aarch64-darwin"; } + ]; + }; + + darwinConfigurations.sops-x86_64 = privateInputs.nix-darwin.lib.darwinSystem { + modules = [ + ./checks/darwin.nix + { nixpkgs.hostPlatform = "x86_64-darwin"; } + ]; + }; + + legacyPackages = eachSystem ( + { pkgs, ... }: + { + homeConfigurations.sops = privateInputs.home-manager.lib.homeManagerConfiguration { + modules = [ + ./checks/home-manager.nix + ]; + inherit pkgs; + }; + } + ); + + apps = eachSystem ( + { pkgs, ... }: + { + update-dev-private-narHash = { + type = "app"; + program = "${pkgs.writeShellScript "update-dev-private-narHash" '' + nix --extra-experimental-features "nix-command flakes" flake lock ./dev/private + nix --extra-experimental-features "nix-command flakes" hash path ./dev/private | tr -d '\n' > ./dev/private.narHash + ''}"; + }; + } + ); + + devShells = eachSystem ( + { pkgs, ... }: + { + unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { }; + default = pkgs.callPackage ./shell.nix { }; + } + ); + }; } diff --git a/go.mod b/go.mod index 08c753a..64fd8c9 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,12 @@ go 1.18 require ( github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0 - github.com/ProtonMail/go-crypto v1.1.0-alpha.5-proton + github.com/ProtonMail/go-crypto v1.1.3 github.com/getsops/sops/v3 v3.8.1 github.com/joho/godotenv v1.5.1 github.com/mozilla-services/yaml v0.0.0-20201007153854-c369669a6625 - golang.org/x/crypto v0.27.0 - golang.org/x/sys v0.25.0 + golang.org/x/crypto v0.29.0 + golang.org/x/sys v0.27.0 gopkg.in/ini.v1 v1.67.0 ) @@ -90,9 +90,9 @@ require ( go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.17.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/term v0.26.0 // indirect + golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/api v0.167.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index 8d3aaba..7431234 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0 h1:zF3WQbETL3cLvt github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0/go.mod h1:OUOla4dJLQ5FfdB07jnjawnMEqI0M3Q4WuD2W/DjhLo= github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= -github.com/ProtonMail/go-crypto v1.1.0-alpha.5-proton h1:KVBEgU3CJpmzLChnLiSuEyCuhGhcMt3eOST+7A+ckto= -github.com/ProtonMail/go-crypto v1.1.0-alpha.5-proton/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= +github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/aws/aws-sdk-go-v2 v1.25.2 h1:/uiG1avJRgLGiQM9X3qJM8+Qa6KRGK5rRPuXE0HUM+w= github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo= github.com/aws/aws-sdk-go-v2/config v1.27.4 h1:AhfWb5ZwimdsYTgP7Od8E9L1u4sKmDW2ZVeLcf2O42M= @@ -240,8 +240,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -270,8 +270,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -288,15 +288,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -304,8 +304,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/modules/home-manager/sops.nix b/modules/home-manager/sops.nix index d62ff58..68ca842 100644 --- a/modules/home-manager/sops.nix +++ b/modules/home-manager/sops.nix @@ -1,66 +1,81 @@ -{ config, lib, pkgs, ... }: +{ + config, + lib, + pkgs, + ... +}: let cfg = config.sops; - sops-install-secrets = (pkgs.callPackage ../.. {}).sops-install-secrets; - secretType = lib.types.submodule ({ config, name, ... }: { - options = { - name = lib.mkOption { - type = lib.types.str; - default = name; - description = '' - Name of the file used in /run/user/*/secrets - ''; - }; + sops-install-secrets = (pkgs.callPackage ../.. { }).sops-install-secrets; + secretType = lib.types.submodule ( + { name, ... }: + { + options = { + name = lib.mkOption { + type = lib.types.str; + default = name; + description = '' + Name of the file used in /run/user/*/secrets + ''; + }; - key = lib.mkOption { - type = lib.types.str; - default = name; - description = '' - Key used to lookup in the sops file. - No tested data structures are supported right now. - This option is ignored if format is binary. - ''; - }; + key = lib.mkOption { + type = lib.types.str; + default = if cfg.defaultSopsKey != null then cfg.defaultSopsKey else name; + description = '' + Key used to lookup in the sops file. + No tested data structures are supported right now. + This option is ignored if format is binary. + "" means whole file. + ''; + }; - path = lib.mkOption { - type = lib.types.str; - default = "${cfg.defaultSymlinkPath}/${name}"; - description = '' - Path where secrets are symlinked to. - If the default is kept no other symlink is created. - `%r` is replaced by $XDG_RUNTIME_DIR on linux or `getconf - DARWIN_USER_TEMP_DIR` on darwin. - ''; - }; + path = lib.mkOption { + type = lib.types.str; + default = "${cfg.defaultSymlinkPath}/${name}"; + description = '' + Path where secrets are symlinked to. + If the default is kept no other symlink is created. + `%r` is replaced by $XDG_RUNTIME_DIR on linux or `getconf + DARWIN_USER_TEMP_DIR` on darwin. + ''; + }; - format = lib.mkOption { - type = lib.types.enum [ "yaml" "json" "binary" "ini" "dotenv" ]; - default = cfg.defaultSopsFormat; - description = '' - File format used to decrypt the sops secret. - Binary files are written to the target file as is. - ''; - }; + format = lib.mkOption { + type = lib.types.enum [ + "yaml" + "json" + "binary" + "ini" + "dotenv" + ]; + default = cfg.defaultSopsFormat; + description = '' + File format used to decrypt the sops secret. + Binary files are written to the target file as is. + ''; + }; - mode = lib.mkOption { - type = lib.types.str; - default = "0400"; - description = '' - Permissions mode of the in octal. - ''; - }; + mode = lib.mkOption { + type = lib.types.str; + default = "0400"; + description = '' + Permissions mode of the in octal. + ''; + }; - sopsFile = lib.mkOption { - type = lib.types.path; - default = cfg.defaultSopsFile; - defaultText = "\${config.sops.defaultSopsFile}"; - description = '' - Sops file the secret is loaded from. - ''; + sopsFile = lib.mkOption { + type = lib.types.path; + default = cfg.defaultSopsFile; + defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}"; + description = '' + Sops file the secret is loaded from. + ''; + }; }; - }; - }); + } + ); pathNotInStore = lib.mkOptionType { name = "pathNotInStore"; @@ -70,50 +85,62 @@ let merge = lib.mergeEqualOption; }; - manifestFor = suffix: secrets: pkgs.writeTextFile { - name = "manifest${suffix}.json"; - text = builtins.toJSON { - secrets = builtins.attrValues secrets; - secretsMountPoint = cfg.defaultSecretsMountPoint; - symlinkPath = cfg.defaultSymlinkPath; - keepGenerations = cfg.keepGenerations; - gnupgHome = cfg.gnupg.home; - sshKeyPaths = cfg.gnupg.sshKeyPaths; - ageKeyFile = cfg.age.keyFile; - ageSshKeyPaths = cfg.age.sshKeyPaths; - userMode = true; - logging = { - keyImport = builtins.elem "keyImport" cfg.log; - secretChanges = builtins.elem "secretChanges" cfg.log; + manifestFor = + suffix: secrets: templates: + pkgs.writeTextFile { + name = "manifest${suffix}.json"; + text = builtins.toJSON { + secrets = builtins.attrValues secrets; + templates = builtins.attrValues templates; + secretsMountPoint = cfg.defaultSecretsMountPoint; + symlinkPath = cfg.defaultSymlinkPath; + keepGenerations = cfg.keepGenerations; + gnupgHome = cfg.gnupg.home; + sshKeyPaths = cfg.gnupg.sshKeyPaths; + ageKeyFile = cfg.age.keyFile; + ageSshKeyPaths = cfg.age.sshKeyPaths; + userMode = true; + logging = { + keyImport = builtins.elem "keyImport" cfg.log; + secretChanges = builtins.elem "secretChanges" cfg.log; + }; }; + checkPhase = '' + ${sops-install-secrets}/bin/sops-install-secrets -check-mode=${ + if cfg.validateSopsFiles then "sopsfile" else "manifest" + } "$out" + ''; }; - checkPhase = '' - ${sops-install-secrets}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out" - ''; - }; - manifest = manifestFor "" cfg.secrets; + manifest = manifestFor "" cfg.secrets cfg.templates; escapedAgeKeyFile = lib.escapeShellArg cfg.age.keyFile; - script = toString (pkgs.writeShellScript "sops-nix-user" ((lib.optionalString (cfg.gnupg.home != null) '' - export SOPS_GPG_EXEC=${pkgs.gnupg}/bin/gpg - '') - + (lib.optionalString cfg.age.generateKey '' - if [[ ! -f ${escapedAgeKeyFile} ]]; then - echo generating machine-specific age key... - ${pkgs.coreutils}/bin/mkdir -p $(${pkgs.coreutils}/bin/dirname ${escapedAgeKeyFile}) - # age-keygen sets 0600 by default, no need to chmod. - ${pkgs.age}/bin/age-keygen -o ${escapedAgeKeyFile} - fi - '' + '' - ${sops-install-secrets}/bin/sops-install-secrets -ignore-passwd ${manifest} - ''))); -in { + script = toString ( + pkgs.writeShellScript "sops-nix-user" ( + lib.optionalString cfg.age.generateKey '' + if [[ ! -f ${escapedAgeKeyFile} ]]; then + echo generating machine-specific age key... + ${pkgs.coreutils}/bin/mkdir -p $(${pkgs.coreutils}/bin/dirname ${escapedAgeKeyFile}) + # age-keygen sets 0600 by default, no need to chmod. + ${pkgs.age}/bin/age-keygen -o ${escapedAgeKeyFile} + fi + '' + + '' + ${sops-install-secrets}/bin/sops-install-secrets -ignore-passwd ${manifest} + '' + ) + ); +in +{ + imports = [ + ./templates.nix + ]; + options.sops = { secrets = lib.mkOption { type = lib.types.attrsOf secretType; - default = {}; + default = { }; description = '' Secrets to decrypt. ''; @@ -134,6 +161,16 @@ in { ''; }; + defaultSopsKey = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Default key used to lookup in all secrets. + This option is ignored if format is binary. + "" means whole file. + ''; + }; + validateSopsFiles = lib.mkOption { type = lib.types.bool; default = true; @@ -169,11 +206,29 @@ in { }; log = lib.mkOption { - type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]); - default = [ "keyImport" "secretChanges" ]; + type = lib.types.listOf ( + lib.types.enum [ + "keyImport" + "secretChanges" + ] + ); + default = [ + "keyImport" + "secretChanges" + ]; description = "What to log"; }; + environment = lib.mkOption { + type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path); + default = { }; + description = '' + Environment variables to set before calling sops-install-secrets. + + To properly quote strings with quotes use lib.escapeShellArg. + ''; + }; + age = { keyFile = lib.mkOption { type = lib.types.nullOr pathNotInStore; @@ -196,7 +251,7 @@ in { sshKeyPaths = lib.mkOption { type = lib.types.listOf lib.types.path; - default = []; + default = [ ]; description = '' Paths to ssh keys added as age keys during sops description. ''; @@ -213,9 +268,22 @@ in { ''; }; + qubes-split-gpg = { + enable = lib.mkEnableOption "Enable support for Qubes Split GPG feature: https://www.qubes-os.org/doc/split-gpg"; + + domain = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "vault-gpg"; + description = '' + It tells Qubes OS which secure Qube holds your GPG keys for isolated cryptographic operations. + ''; + }; + }; + sshKeyPaths = lib.mkOption { type = lib.types.listOf lib.types.path; - default = []; + default = [ ]; description = '' Path to ssh keys added as GPG keys during sops description. This option must be explicitly unset if config.sops.gnupg.sshKeyPaths is set. @@ -224,24 +292,73 @@ in { }; }; - config = lib.mkIf (cfg.secrets != {}) { - assertions = [{ - assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != []; - message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home"; - } { - assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []); - message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set"; - }] ++ lib.optionals cfg.validateSopsFiles ( - lib.concatLists (lib.mapAttrsToList (name: secret: [{ - assertion = builtins.pathExists secret.sopsFile; - message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile"; - } { - assertion = - builtins.isPath secret.sopsFile || - (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile); - message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false"; - }]) cfg.secrets) - ); + config = lib.mkIf (cfg.secrets != { }) { + assertions = + [ + { + assertion = + cfg.gnupg.home != null + || cfg.gnupg.sshKeyPaths != [ ] + || cfg.gnupg.qubes-split-gpg.enable == true + || cfg.age.keyFile != null + || cfg.age.sshKeyPaths != [ ]; + message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home or sops.gnupg.qubes-split-gpg.enable"; + } + { + assertion = + !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != [ ]) + && !(cfg.gnupg.home != null && cfg.gnupg.qubes-split-gpg.enable == true) + && !(cfg.gnupg.sshKeyPaths != [ ] && cfg.gnupg.qubes-split-gpg.enable == true); + message = "Exactly one of sops.gnupg.home, sops.gnupg.qubes-split-gpg.enable and sops.gnupg.sshKeyPaths must be set"; + } + { + assertion = + cfg.gnupg.qubes-split-gpg.enable == false + || ( + cfg.gnupg.qubes-split-gpg.enable == true + && cfg.gnupg.qubes-split-gpg.domain != null + && cfg.gnupg.qubes-split-gpg.domain != "" + ); + message = "sops.gnupg.qubes-split-gpg.domain is required when sops.gnupg.qubes-split-gpg.enable is set to true"; + } + ] + ++ lib.optionals cfg.validateSopsFiles ( + lib.concatLists ( + lib.mapAttrsToList (name: secret: [ + { + assertion = builtins.pathExists secret.sopsFile; + message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile"; + } + { + assertion = + builtins.isPath secret.sopsFile + || (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile); + message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false"; + } + ]) cfg.secrets + ) + ); + + home.sessionVariables = lib.mkIf cfg.gnupg.qubes-split-gpg.enable { + # TODO: Add this package to nixpkgs and use it from the store + # https://github.com/QubesOS/qubes-app-linux-split-gpg + SOPS_GPG_EXEC = "qubes-gpg-client-wrapper"; + }; + + sops.environment = { + SOPS_GPG_EXEC = lib.mkMerge [ + (lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [ ]) ( + lib.mkDefault "${pkgs.gnupg}/bin/gpg" + )) + (lib.mkIf cfg.gnupg.qubes-split-gpg.enable ( + lib.mkDefault config.home.sessionVariables.SOPS_GPG_EXEC + )) + ]; + + QUBES_GPG_DOMAIN = lib.mkIf cfg.gnupg.qubes-split-gpg.enable ( + lib.mkDefault cfg.gnupg.qubes-split-gpg.domain + ); + }; systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { Unit = { @@ -249,9 +366,13 @@ in { }; Service = { Type = "oneshot"; + Environment = builtins.concatStringsSep " " ( + lib.mapAttrsToList (name: value: "'${name}=${value}'") cfg.environment + ); ExecStart = script; }; - Install.WantedBy = if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ]; + Install.WantedBy = + if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ]; }; # Darwin: load secrets once on login @@ -259,6 +380,7 @@ in { enable = true; config = { Program = script; + EnvironmentVariables = cfg.environment; KeepAlive = false; RunAtLoad = true; StandardOutPath = "${config.home.homeDirectory}/Library/Logs/SopsNix/stdout"; @@ -267,28 +389,36 @@ in { }; # [re]load secrets on home-manager activation - home.activation = let - darwin = let - domain-target = "gui/$(id -u ${config.home.username})"; - in '' - /bin/launchctl bootout ${domain-target}/org.nix-community.home.sops-nix && true - /bin/launchctl bootstrap ${domain-target} ${config.home.homeDirectory}/Library/LaunchAgents/org.nix-community.home.sops-nix.plist - ''; + home.activation = + let + darwin = + let + domain-target = "gui/$(id -u ${config.home.username})"; + in + '' + /bin/launchctl bootout ${domain-target}/org.nix-community.home.sops-nix && true + /bin/launchctl bootstrap ${domain-target} ${config.home.homeDirectory}/Library/LaunchAgents/org.nix-community.home.sops-nix.plist + ''; - linux = let systemctl = config.systemd.user.systemctlPath; in '' - systemdStatus=$(${systemctl} --user is-system-running 2>&1 || true) + linux = + let + systemctl = config.systemd.user.systemctlPath; + in + '' + systemdStatus=$(${systemctl} --user is-system-running 2>&1 || true) - if [[ $systemdStatus == 'running' ]]; then - ${systemctl} restart --user sops-nix - else - echo "User systemd daemon not running. Probably executed on boot where no manual start/reload is needed." - fi + if [[ $systemdStatus == 'running' || $systemdStatus == 'degraded' ]]; then + ${systemctl} restart --user sops-nix + else + echo "User systemd daemon not running. Probably executed on boot where no manual start/reload is needed." + fi - unset systemdStatus - ''; - - in { - sops-nix = if pkgs.stdenv.isLinux then linux else darwin; - }; + unset systemdStatus + ''; + + in + { + sops-nix = if pkgs.stdenv.isLinux then linux else darwin; + }; }; } diff --git a/modules/home-manager/templates.nix b/modules/home-manager/templates.nix new file mode 100644 index 0000000..c64802a --- /dev/null +++ b/modules/home-manager/templates.nix @@ -0,0 +1,106 @@ +{ + config, + pkgs, + lib, + options, + ... +}: +let + inherit (lib) + mkOption + mkDefault + mapAttrs + types + ; +in +{ + options.sops = { + templates = mkOption { + description = "Templates for secret files"; + type = types.attrsOf ( + types.submodule ( + { config, ... }: + { + options = { + name = mkOption { + type = types.singleLineStr; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets/rendered + ''; + }; + path = mkOption { + description = "Path where the rendered file will be placed"; + type = types.singleLineStr; + # Keep this in sync with `RenderedSubdir` in `pkgs/sops-install-secrets/main.go` + default = "${config.xdg.configHome}/sops-nix/secrets/rendered/${config.name}"; + }; + content = mkOption { + type = types.lines; + default = ""; + description = '' + Content of the file + ''; + }; + mode = mkOption { + type = types.singleLineStr; + default = "0400"; + description = '' + Permissions mode of the rendered secret file in octal. + ''; + }; + file = mkOption { + type = types.path; + default = pkgs.writeText config.name config.content; + defaultText = lib.literalExpression ''pkgs.writeText config.name config.content''; + example = "./configuration-template.conf"; + description = '' + File used as the template. When this value is specified, `sops.templates..content` is ignored. + ''; + }; + restartUnits = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be restarted when the rendered template changes. + This works the same way as . + ''; + }; + reloadUnits = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be reloaded when the rendered template changes. + This works the same way as . + ''; + }; + }; + } + ) + ); + default = { }; + }; + placeholder = mkOption { + type = types.attrsOf ( + types.mkOptionType { + name = "coercibleToString"; + description = "value that can be coerced to string"; + check = lib.strings.isConvertibleWithToString; + merge = lib.mergeEqualOption; + } + ); + default = { }; + visible = false; + }; + }; + + config = lib.optionalAttrs (options ? sops.secrets) ( + lib.mkIf (config.sops.templates != { }) { + sops.placeholder = mapAttrs ( + name: _: mkDefault "" + ) config.sops.secrets; + } + ); +} diff --git a/modules/nix-darwin/default.nix b/modules/nix-darwin/default.nix new file mode 100644 index 0000000..9e2f9e4 --- /dev/null +++ b/modules/nix-darwin/default.nix @@ -0,0 +1,401 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.sops; + sops-install-secrets = cfg.package; + manifestFor = pkgs.callPackage ./manifest-for.nix { + inherit cfg; + inherit (pkgs) writeTextFile; + }; + manifest = manifestFor "" regularSecrets regularTemplates { }; + + # Currently, all templates are "regular" (there's no support for `neededForUsers` for templates.) + regularTemplates = cfg.templates; + + pathNotInStore = lib.mkOptionType { + name = "pathNotInStore"; + description = "path not in the Nix store"; + descriptionClass = "noun"; + check = x: !lib.path.hasStorePathPrefix (/. + x); + merge = lib.mergeEqualOption; + }; + + regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets; + + withEnvironment = import ./with-environment.nix { + inherit cfg lib; + }; + secretType = lib.types.submodule ( + { config, ... }: + { + config = { + sopsFile = lib.mkOptionDefault cfg.defaultSopsFile; + sopsFileHash = lib.mkOptionDefault ( + lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}" + ); + }; + options = { + name = lib.mkOption { + type = lib.types.str; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets + ''; + }; + key = lib.mkOption { + type = lib.types.str; + default = config._module.args.name; + description = '' + Key used to lookup in the sops file. + No tested data structures are supported right now. + This option is ignored if format is binary. + ''; + }; + path = lib.mkOption { + type = lib.types.str; + default = + if config.neededForUsers then + "/run/secrets-for-users/${config.name}" + else + "/run/secrets/${config.name}"; + defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise."; + description = '' + Path where secrets are symlinked to. + If the default is kept no symlink is created. + ''; + }; + format = lib.mkOption { + type = lib.types.enum [ + "yaml" + "json" + "binary" + "dotenv" + "ini" + ]; + default = cfg.defaultSopsFormat; + description = '' + File format used to decrypt the sops secret. + Binary files are written to the target file as is. + ''; + }; + mode = lib.mkOption { + type = lib.types.str; + default = "0400"; + description = '' + Permissions mode of the in octal. + ''; + }; + owner = lib.mkOption { + type = with lib.types; nullOr str; + default = "root"; + description = '' + User of the file. Can only be set if uid is 0. + ''; + }; + uid = lib.mkOption { + type = with lib.types; nullOr int; + default = 0; + description = '' + UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist. + ''; + }; + group = lib.mkOption { + type = with lib.types; nullOr str; + default = "staff"; + defaultText = "staff"; + description = '' + Group of the file. Can only be set if gid is 0. + ''; + }; + gid = lib.mkOption { + type = with lib.types; nullOr int; + default = 0; + description = '' + GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist. + ''; + }; + sopsFile = lib.mkOption { + type = lib.types.path; + defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}"; + description = '' + Sops file the secret is loaded from. + ''; + }; + sopsFileHash = lib.mkOption { + type = lib.types.str; + readOnly = true; + description = '' + Hash of the sops file. + ''; + }; + neededForUsers = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + **Warning** This option doesn't have any effect on macOS, as nix-darwin cannot manage user passwords on macOS. + This can be used to retrieve user's passwords from sops-nix. + Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root. + ''; + }; + }; + } + ); + + darwinSSHKeys = [ + { + type = "rsa"; + path = "/etc/ssh/ssh_host_rsa_key"; + } + { + type = "ed25519"; + path = "/etc/ssh/ssh_host_ed25519_key"; + } + ]; + + escapedKeyFile = lib.escapeShellArg cfg.age.keyFile; + # Skip ssh keys deployed with sops to avoid a catch 22 + defaultImportKeys = + algo: + map (e: e.path) ( + lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) darwinSSHKeys + ); + + installScript = '' + ${ + if cfg.age.generateKey then + '' + if [[ ! -f ${escapedKeyFile} ]]; then + echo generating machine-specific age key... + mkdir -p "$(dirname ${escapedKeyFile})" + # age-keygen sets 0600 by default, no need to chmod. + ${pkgs.age}/bin/age-keygen -o ${escapedKeyFile} + fi + '' + else + "" + } + echo "Setting up secrets..." + ${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"} + ''; + +in +{ + options.sops = { + secrets = lib.mkOption { + type = lib.types.attrsOf secretType; + default = { }; + description = '' + Path where the latest secrets are mounted to. + ''; + }; + + defaultSopsFile = lib.mkOption { + type = lib.types.path; + description = '' + Default sops file used for all secrets. + ''; + }; + + defaultSopsFormat = lib.mkOption { + type = lib.types.str; + default = "yaml"; + description = '' + Default sops format used for all secrets. + ''; + }; + + validateSopsFiles = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Check all sops files at evaluation time. + This requires sops files to be added to the nix store. + ''; + }; + + keepGenerations = lib.mkOption { + type = lib.types.ints.unsigned; + default = 1; + description = '' + Number of secrets generations to keep. Setting this to 0 disables pruning. + ''; + }; + + log = lib.mkOption { + type = lib.types.listOf ( + lib.types.enum [ + "keyImport" + "secretChanges" + ] + ); + default = [ + "keyImport" + "secretChanges" + ]; + description = "What to log"; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path); + default = { }; + description = '' + Environment variables to set before calling sops-install-secrets. + + The values are placed in single quotes and not escaped any further to + allow usage of command substitutions for more flexibility. To properly quote + strings with quotes use lib.escapeShellArg. + + This will be evaluated twice when using secrets that use neededForUsers but + in a subshell each time so the environment variables don't collide. + ''; + }; + + package = lib.mkOption { + type = lib.types.package; + default = (pkgs.callPackage ../.. { }).sops-install-secrets; + defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets"; + description = '' + sops-install-secrets package to use. + ''; + }; + + validationPackage = lib.mkOption { + type = lib.types.package; + default = + if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then + sops-install-secrets + else + (pkgs.pkgsBuildHost.callPackage ../.. { }).sops-install-secrets; + defaultText = lib.literalExpression "config.sops.package"; + + description = '' + sops-install-secrets package to use when validating configuration. + + Defaults to sops.package if building natively, and a native version of sops-install-secrets if cross compiling. + ''; + }; + + age = { + keyFile = lib.mkOption { + type = lib.types.nullOr pathNotInStore; + default = null; + example = "/var/lib/sops-nix/key.txt"; + description = '' + Path to age key file used for sops decryption. + ''; + }; + + generateKey = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether or not to generate the age key. If this + option is set to false, the key must already be + present at the specified location. + ''; + }; + + sshKeyPaths = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = defaultImportKeys "ed25519"; + defaultText = lib.literalMD "The ed25519 keys from {option}`config.services.openssh.hostKeys`"; + description = '' + Paths to ssh keys added as age keys during sops description. + ''; + }; + }; + + gnupg = { + home = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/root/.gnupg"; + description = '' + Path to gnupg database directory containing the key for decrypting the sops file. + ''; + }; + + sshKeyPaths = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = defaultImportKeys "rsa"; + defaultText = lib.literalMD "The rsa keys from {option}`config.services.openssh.hostKeys`"; + description = '' + Path to ssh keys added as GPG keys during sops description. + This option must be explicitly unset if config.sops.gnupg.home is set. + ''; + }; + }; + }; + imports = [ + ./templates + ./secrets-for-users + ]; + + config = lib.mkMerge [ + (lib.mkIf (cfg.secrets != { }) { + assertions = + [ + { + assertion = + cfg.gnupg.home != null + || cfg.gnupg.sshKeyPaths != [ ] + || cfg.age.keyFile != null + || cfg.age.sshKeyPaths != [ ]; + message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home"; + } + { + assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != [ ]); + message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set"; + } + ] + ++ lib.optionals cfg.validateSopsFiles ( + lib.concatLists ( + lib.mapAttrsToList (name: secret: [ + { + assertion = builtins.pathExists secret.sopsFile; + message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile"; + } + { + assertion = + builtins.isPath secret.sopsFile + || (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile); + message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false"; + } + { + assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null; + message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set"; + } + { + assertion = secret.gid != null && secret.gid != 0 -> secret.group == null; + message = "In ${secret.name} exactly one of sops.group and sops.gid must be set"; + } + ]) cfg.secrets + ) + ); + + system.build.sops-nix-manifest = manifest; + system.activationScripts = { + postActivation.text = lib.mkAfter installScript; + }; + + launchd.daemons.sops-install-secrets = { + command = installScript; + serviceConfig = { + RunAtLoad = true; + KeepAlive = false; + }; + }; + }) + + { + sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [ ]) ( + lib.mkDefault "${pkgs.gnupg}/bin/gpg" + ); + } + ]; +} diff --git a/modules/nix-darwin/manifest-for.nix b/modules/nix-darwin/manifest-for.nix new file mode 100644 index 0000000..5015659 --- /dev/null +++ b/modules/nix-darwin/manifest-for.nix @@ -0,0 +1,34 @@ +{ writeTextFile, cfg }: + +suffix: secrets: templates: extraJson: + +writeTextFile { + name = "manifest${suffix}.json"; + text = builtins.toJSON ( + { + secrets = builtins.attrValues secrets; + templates = builtins.attrValues templates; + # Does this need to be configurable? + secretsMountPoint = "/run/secrets.d"; + symlinkPath = "/run/secrets"; + keepGenerations = cfg.keepGenerations; + gnupgHome = cfg.gnupg.home; + sshKeyPaths = cfg.gnupg.sshKeyPaths; + ageKeyFile = cfg.age.keyFile; + ageSshKeyPaths = cfg.age.sshKeyPaths; + useTmpfs = false; + placeholderBySecretName = cfg.placeholder; + userMode = false; + logging = { + keyImport = builtins.elem "keyImport" cfg.log; + secretChanges = builtins.elem "secretChanges" cfg.log; + }; + } + // extraJson + ); + checkPhase = '' + ${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${ + if cfg.validateSopsFiles then "sopsfile" else "manifest" + } "$out" + ''; +} diff --git a/modules/nix-darwin/secrets-for-users/default.nix b/modules/nix-darwin/secrets-for-users/default.nix new file mode 100644 index 0000000..b00395a --- /dev/null +++ b/modules/nix-darwin/secrets-for-users/default.nix @@ -0,0 +1,53 @@ +{ + lib, + config, + pkgs, + ... +}: +let + cfg = config.sops; + secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets; + templatesForUsers = { }; # We do not currently support `neededForUsers` for templates. + manifestFor = pkgs.callPackage ../manifest-for.nix { + inherit cfg; + inherit (pkgs) writeTextFile; + }; + withEnvironment = import ../with-environment.nix { + inherit cfg lib; + }; + manifestForUsers = manifestFor "-for-users" secretsForUsers templatesForUsers { + secretsMountPoint = "/run/secrets-for-users.d"; + symlinkPath = "/run/secrets-for-users"; + }; + + installScript = '' + echo "Setting up secrets for users" + ${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"} + ''; +in +{ + + assertions = [ + { + assertion = + (lib.filterAttrs ( + _: v: (v.uid != 0 && v.owner != "root") || (v.gid != 0 && v.group != "root") + ) secretsForUsers) == { }; + message = "neededForUsers cannot be used for secrets that are not root-owned"; + } + ]; + + system.activationScripts = lib.mkIf (secretsForUsers != [ ]) { + postActivation.text = lib.mkAfter installScript; + }; + + launchd.daemons.sops-install-secrets-for-users = lib.mkIf (secretsForUsers != [ ]) { + command = installScript; + serviceConfig = { + RunAtLoad = true; + KeepAlive = false; + }; + }; + + system.build.sops-nix-users-manifest = manifestForUsers; +} diff --git a/modules/nix-darwin/templates/default.nix b/modules/nix-darwin/templates/default.nix new file mode 100644 index 0000000..da1dcbb --- /dev/null +++ b/modules/nix-darwin/templates/default.nix @@ -0,0 +1,102 @@ +{ + config, + pkgs, + lib, + options, + ... +}: +let + inherit (lib) + mkOption + mkDefault + mapAttrs + types + ; +in +{ + options.sops = { + templates = mkOption { + description = "Templates for secret files"; + type = types.attrsOf ( + types.submodule ( + { config, ... }: + { + options = { + name = mkOption { + type = types.singleLineStr; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets/rendered + ''; + }; + path = mkOption { + description = "Path where the rendered file will be placed"; + type = types.singleLineStr; + default = "/run/secrets/rendered/${config.name}"; + }; + content = mkOption { + type = types.lines; + default = ""; + description = '' + Content of the file + ''; + }; + mode = mkOption { + type = types.singleLineStr; + default = "0400"; + description = '' + Permissions mode of the rendered secret file in octal. + ''; + }; + owner = mkOption { + type = types.singleLineStr; + default = "root"; + description = '' + User of the file. + ''; + }; + group = mkOption { + type = types.singleLineStr; + default = "staff"; + defaultText = "staff"; + description = '' + Group of the file. Default on darwin in staff. + ''; + }; + file = mkOption { + type = types.path; + default = pkgs.writeText config.name config.content; + defaultText = lib.literalExpression ''pkgs.writeText config.name config.content''; + example = "./configuration-template.conf"; + description = '' + File used as the template. When this value is specified, `sops.templates..content` is ignored. + ''; + }; + }; + } + ) + ); + default = { }; + }; + placeholder = mkOption { + type = types.attrsOf ( + types.mkOptionType { + name = "coercibleToString"; + description = "value that can be coerced to string"; + check = lib.strings.isConvertibleWithToString; + merge = lib.mergeEqualOption; + } + ); + default = { }; + visible = false; + }; + }; + + config = lib.optionalAttrs (options ? sops.secrets) ( + lib.mkIf (config.sops.templates != { }) { + sops.placeholder = mapAttrs ( + name: _: mkDefault "" + ) config.sops.secrets; + } + ); +} diff --git a/modules/nix-darwin/with-environment.nix b/modules/nix-darwin/with-environment.nix new file mode 100644 index 0000000..30d64f5 --- /dev/null +++ b/modules/nix-darwin/with-environment.nix @@ -0,0 +1,14 @@ +{ cfg, lib }: + +sopsCall: + +if cfg.environment == { } then + sopsCall +else + '' + ( + # shellcheck disable=SC2030,SC2031 + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)} + ${sopsCall} + ) + '' diff --git a/modules/sops/default.nix b/modules/sops/default.nix index e2d3824..860a9a9 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -1,4 +1,10 @@ -{ config, options, lib, pkgs, ... }: +{ + config, + options, + lib, + pkgs, + ... +}: let cfg = config.sops; @@ -8,7 +14,7 @@ let inherit cfg; inherit (pkgs) writeTextFile; }; - manifest = manifestFor "" regularSecrets {}; + manifest = manifestFor "" regularSecrets regularTemplates { }; pathNotInStore = lib.mkOptionType { name = "pathNotInStore"; @@ -20,128 +26,168 @@ let regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets; - useSystemdActivation = (options.systemd ? sysusers && config.systemd.sysusers.enable) || - (options.services ? userborn && config.services.userborn.enable); + # Currently, all templates are "regular" (there's no support for `neededForUsers` for templates.) + regularTemplates = cfg.templates; + + useSystemdActivation = + (options.systemd ? sysusers && config.systemd.sysusers.enable) + || (options.services ? userborn && config.services.userborn.enable); withEnvironment = import ./with-environment.nix { inherit cfg lib; }; - secretType = lib.types.submodule ({ config, ... }: { - config = { - sopsFile = lib.mkOptionDefault cfg.defaultSopsFile; - sopsFileHash = lib.mkOptionDefault (lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}"); - }; - options = { - name = lib.mkOption { - type = lib.types.str; - default = config._module.args.name; - description = '' - Name of the file used in /run/secrets - ''; + secretType = lib.types.submodule ( + { config, ... }: + { + config = { + sopsFile = lib.mkOptionDefault cfg.defaultSopsFile; + sopsFileHash = lib.mkOptionDefault ( + lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}" + ); }; - key = lib.mkOption { - type = lib.types.str; - default = config._module.args.name; - description = '' - Key used to lookup in the sops file. - No tested data structures are supported right now. - This option is ignored if format is binary. - ''; + options = { + name = lib.mkOption { + type = lib.types.str; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets + ''; + }; + key = lib.mkOption { + type = lib.types.str; + default = if cfg.defaultSopsKey != null then cfg.defaultSopsKey else config._module.args.name; + description = '' + Key used to lookup in the sops file. + No tested data structures are supported right now. + This option is ignored if format is binary. + "" means whole file. + ''; + }; + path = lib.mkOption { + type = lib.types.str; + default = + if config.neededForUsers then + "/run/secrets-for-users/${config.name}" + else + "/run/secrets/${config.name}"; + defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise."; + description = '' + Path where secrets are symlinked to. + If the default is kept no symlink is created. + ''; + }; + format = lib.mkOption { + type = lib.types.enum [ + "yaml" + "json" + "binary" + "dotenv" + "ini" + ]; + default = cfg.defaultSopsFormat; + description = '' + File format used to decrypt the sops secret. + Binary files are written to the target file as is. + ''; + }; + mode = lib.mkOption { + type = lib.types.str; + default = "0400"; + description = '' + Permissions mode of the in octal. + ''; + }; + owner = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + User of the file. Can only be set if uid is 0. + ''; + }; + uid = lib.mkOption { + type = with lib.types; nullOr int; + default = 0; + description = '' + UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist. + ''; + }; + group = lib.mkOption { + type = with lib.types; nullOr str; + default = if config.owner != null then users.${config.owner}.group else null; + defaultText = lib.literalMD "{option}`config.users.users.\${owner}.group`"; + description = '' + Group of the file. Can only be set if gid is 0. + ''; + }; + gid = lib.mkOption { + type = with lib.types; nullOr int; + default = 0; + description = '' + GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist. + ''; + }; + sopsFile = lib.mkOption { + type = lib.types.path; + defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}"; + description = '' + Sops file the secret is loaded from. + ''; + }; + sopsFileHash = lib.mkOption { + type = lib.types.str; + readOnly = true; + description = '' + Hash of the sops file, useful in . + ''; + }; + restartUnits = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be restarted when this secret changes. + This works the same way as . + ''; + }; + reloadUnits = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be reloaded when this secret changes. + This works the same way as . + ''; + }; + neededForUsers = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Enabling this option causes the secret to be decrypted before users and groups are created. + This can be used to retrieve user's passwords from sops-nix. + Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root. + ''; + }; }; - path = lib.mkOption { - type = lib.types.str; - default = if config.neededForUsers then "/run/secrets-for-users/${config.name}" else "/run/secrets/${config.name}"; - defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise."; - description = '' - Path where secrets are symlinked to. - If the default is kept no symlink is created. - ''; - }; - format = lib.mkOption { - type = lib.types.enum ["yaml" "json" "binary" "dotenv" "ini"]; - default = cfg.defaultSopsFormat; - description = '' - File format used to decrypt the sops secret. - Binary files are written to the target file as is. - ''; - }; - mode = lib.mkOption { - type = lib.types.str; - default = "0400"; - description = '' - Permissions mode of the in octal. - ''; - }; - owner = lib.mkOption { - type = lib.types.str; - default = "root"; - description = '' - User of the file. - ''; - }; - group = lib.mkOption { - type = lib.types.str; - default = users.${config.owner}.group; - defaultText = lib.literalMD "{option}`config.users.users.\${owner}.group`"; - description = '' - Group of the file. - ''; - }; - sopsFile = lib.mkOption { - type = lib.types.path; - defaultText = "\${config.sops.defaultSopsFile}"; - description = '' - Sops file the secret is loaded from. - ''; - }; - sopsFileHash = lib.mkOption { - type = lib.types.str; - readOnly = true; - description = '' - Hash of the sops file, useful in . - ''; - }; - restartUnits = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "sshd.service" ]; - description = '' - Names of units that should be restarted when this secret changes. - This works the same way as . - ''; - }; - reloadUnits = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "sshd.service" ]; - description = '' - Names of units that should be reloaded when this secret changes. - This works the same way as . - ''; - }; - neededForUsers = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Enabling this option causes the secret to be decrypted before users and groups are created. - This can be used to retrieve user's passwords from sops-nix. - Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root. - ''; - }; - }; - }); + } + ); # Skip ssh keys deployed with sops to avoid a catch 22 - defaultImportKeys = algo: + defaultImportKeys = + algo: if config.services.openssh.enable then - map (e: e.path) (lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) config.services.openssh.hostKeys) + map (e: e.path) ( + lib.filter ( + e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path) + ) config.services.openssh.hostKeys + ) else - []; -in { + [ ]; +in +{ options.sops = { secrets = lib.mkOption { type = lib.types.attrsOf secretType; - default = {}; + default = { }; description = '' Path where the latest secrets are mounted to. ''; @@ -162,6 +208,16 @@ in { ''; }; + defaultSopsKey = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Default key used to lookup in all secrets. + This option is ignored if format is binary. + "" means whole file. + ''; + }; + validateSopsFiles = lib.mkOption { type = lib.types.bool; default = true; @@ -180,14 +236,22 @@ in { }; log = lib.mkOption { - type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]); - default = [ "keyImport" "secretChanges" ]; + type = lib.types.listOf ( + lib.types.enum [ + "keyImport" + "secretChanges" + ] + ); + default = [ + "keyImport" + "secretChanges" + ]; description = "What to log"; }; environment = lib.mkOption { type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path); - default = {}; + default = { }; description = '' Environment variables to set before calling sops-install-secrets. @@ -202,7 +266,7 @@ in { package = lib.mkOption { type = lib.types.package; - default = (pkgs.callPackage ../.. {}).sops-install-secrets; + default = (pkgs.callPackage ../.. { }).sops-install-secrets; defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets"; description = '' sops-install-secrets package to use. @@ -212,9 +276,10 @@ in { validationPackage = lib.mkOption { type = lib.types.package; default = - if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform - then sops-install-secrets - else (pkgs.pkgsBuildHost.callPackage ../.. {}).sops-install-secrets; + if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then + sops-install-secrets + else + (pkgs.pkgsBuildHost.callPackage ../.. { }).sops-install-secrets; defaultText = lib.literalExpression "config.sops.package"; description = '' @@ -298,34 +363,78 @@ in { imports = [ ./templates ./secrets-for-users - (lib.mkRenamedOptionModule [ "sops" "gnupgHome" ] [ "sops" "gnupg" "home" ]) - (lib.mkRenamedOptionModule [ "sops" "sshKeyPaths" ] [ "sops" "gnupg" "sshKeyPaths" ]) + (lib.mkRenamedOptionModule + [ + "sops" + "gnupgHome" + ] + [ + "sops" + "gnupg" + "home" + ] + ) + (lib.mkRenamedOptionModule + [ + "sops" + "sshKeyPaths" + ] + [ + "sops" + "gnupg" + "sshKeyPaths" + ] + ) ]; config = lib.mkMerge [ - (lib.mkIf (cfg.secrets != {}) { - assertions = [{ - assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != []; - message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home"; - } { - assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []); - message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set"; - }] ++ lib.optionals cfg.validateSopsFiles ( - lib.concatLists (lib.mapAttrsToList (name: secret: [{ - assertion = builtins.pathExists secret.sopsFile; - message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile"; - } { - assertion = - builtins.isPath secret.sopsFile || - (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile); - message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false"; - }]) cfg.secrets) + (lib.mkIf (cfg.secrets != { }) { + assertions = + [ + { + assertion = + cfg.gnupg.home != null + || cfg.gnupg.sshKeyPaths != [ ] + || cfg.age.keyFile != null + || cfg.age.sshKeyPaths != [ ]; + message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home"; + } + { + assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != [ ]); + message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set"; + } + ] + ++ lib.optionals cfg.validateSopsFiles ( + lib.concatLists ( + lib.mapAttrsToList (name: secret: [ + { + assertion = builtins.pathExists secret.sopsFile; + message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile"; + } + { + assertion = + builtins.isPath secret.sopsFile + || (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile); + message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false"; + } + { + assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null; + message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set"; + } + { + assertion = secret.gid != null && secret.gid != 0 -> secret.group == null; + message = "In ${secret.name} exactly one of sops.group and sops.gid must be set"; + } + ]) cfg.secrets + ) + ); + + sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [ ]) ( + lib.mkDefault "${pkgs.gnupg}/bin/gpg" ); - sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != []) (lib.mkDefault "${pkgs.gnupg}/bin/gpg"); - - # When using sysusers we no longer be started as an activation script because those are started in initrd while sysusers is started later. + # When using sysusers we no longer are started as an activation script because those are started in initrd while sysusers is started later. systemd.services.sops-install-secrets = lib.mkIf (regularSecrets != { } && useSystemdActivation) { - wantedBy = [ "sysinit.target" ]; + wantedBy = [ "sysinit.target" ]; after = [ "systemd-sysusers.service" ]; environment = cfg.environment; unitConfig.DefaultDependencies = "no"; @@ -338,27 +447,43 @@ in { }; system.activationScripts = { - setupSecrets = lib.mkIf (regularSecrets != {} && !useSystemdActivation) (lib.stringAfter ([ "specialfs" "users" "groups" ] ++ lib.optional cfg.age.generateKey "generate-age-key") '' - [ -e /run/current-system ] || echo setting up secrets... - ${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"} - '' // lib.optionalAttrs (config.system ? dryActivationScript) { - supportsDryActivation = true; - }); + setupSecrets = lib.mkIf (regularSecrets != { } && !useSystemdActivation) ( + lib.stringAfter + ( + [ + "specialfs" + "users" + "groups" + ] + ++ lib.optional cfg.age.generateKey "generate-age-key" + ) + '' + [ -e /run/current-system ] || echo setting up secrets... + ${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"} + '' + // lib.optionalAttrs (config.system ? dryActivationScript) { + supportsDryActivation = true; + } + ); - generate-age-key = let - escapedKeyFile = lib.escapeShellArg cfg.age.keyFile; - in lib.mkIf cfg.age.generateKey (lib.stringAfter [] '' - if [[ ! -f ${escapedKeyFile} ]]; then - echo generating machine-specific age key... - mkdir -p $(dirname ${escapedKeyFile}) - # age-keygen sets 0600 by default, no need to chmod. - ${pkgs.age}/bin/age-keygen -o ${escapedKeyFile} - fi - ''); + generate-age-key = + let + escapedKeyFile = lib.escapeShellArg cfg.age.keyFile; + in + lib.mkIf cfg.age.generateKey ( + lib.stringAfter [ ] '' + if [[ ! -f ${escapedKeyFile} ]]; then + echo generating machine-specific age key... + mkdir -p $(dirname ${escapedKeyFile}) + # age-keygen sets 0600 by default, no need to chmod. + ${pkgs.age}/bin/age-keygen -o ${escapedKeyFile} + fi + '' + ); }; }) { - system.build.sops-nix-manifest = manifest; + system.build.sops-nix-manifest = manifest; } ]; } diff --git a/modules/sops/manifest-for.nix b/modules/sops/manifest-for.nix index 0752909..c4ecea4 100644 --- a/modules/sops/manifest-for.nix +++ b/modules/sops/manifest-for.nix @@ -1,27 +1,34 @@ { writeTextFile, cfg }: -suffix: secrets: extraJson: +suffix: secrets: templates: extraJson: writeTextFile { name = "manifest${suffix}.json"; - text = builtins.toJSON ({ - secrets = builtins.attrValues secrets; - # Does this need to be configurable? - secretsMountPoint = "/run/secrets.d"; - symlinkPath = "/run/secrets"; - keepGenerations = cfg.keepGenerations; - gnupgHome = cfg.gnupg.home; - sshKeyPaths = cfg.gnupg.sshKeyPaths; - ageKeyFile = cfg.age.keyFile; - ageSshKeyPaths = cfg.age.sshKeyPaths; - useTmpfs = cfg.useTmpfs; - userMode = false; - logging = { - keyImport = builtins.elem "keyImport" cfg.log; - secretChanges = builtins.elem "secretChanges" cfg.log; - }; - } // extraJson); + text = builtins.toJSON ( + { + secrets = builtins.attrValues secrets; + templates = builtins.attrValues templates; + # Does this need to be configurable? + secretsMountPoint = "/run/secrets.d"; + symlinkPath = "/run/secrets"; + keepGenerations = cfg.keepGenerations; + gnupgHome = cfg.gnupg.home; + sshKeyPaths = cfg.gnupg.sshKeyPaths; + ageKeyFile = cfg.age.keyFile; + ageSshKeyPaths = cfg.age.sshKeyPaths; + useTmpfs = cfg.useTmpfs; + placeholderBySecretName = cfg.placeholder; + userMode = false; + logging = { + keyImport = builtins.elem "keyImport" cfg.log; + secretChanges = builtins.elem "secretChanges" cfg.log; + }; + } + // extraJson + ); checkPhase = '' - ${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out" + ${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${ + if cfg.validateSopsFiles then "sopsfile" else "manifest" + } "$out" ''; } diff --git a/modules/sops/secrets-for-users/default.nix b/modules/sops/secrets-for-users/default.nix index bb65532..cca6a15 100644 --- a/modules/sops/secrets-for-users/default.nix +++ b/modules/sops/secrets-for-users/default.nix @@ -1,7 +1,14 @@ -{ lib, options, config, pkgs, ... }: +{ + lib, + options, + config, + pkgs, + ... +}: let cfg = config.sops; secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets; + templatesForUsers = { }; # We do not currently support `neededForUsers` for templates. manifestFor = pkgs.callPackage ../manifest-for.nix { inherit cfg; inherit (pkgs) writeTextFile; @@ -9,49 +16,59 @@ let withEnvironment = import ../with-environment.nix { inherit cfg lib; }; - manifestForUsers = manifestFor "-for-users" secretsForUsers { + manifestForUsers = manifestFor "-for-users" secretsForUsers templatesForUsers { secretsMountPoint = "/run/secrets-for-users.d"; symlinkPath = "/run/secrets-for-users"; }; sysusersEnabled = options.systemd ? sysusers && config.systemd.sysusers.enable; - useSystemdActivation = sysusersEnabled || - (options.services ? userborn && config.services.userborn.enable); + useSystemdActivation = + sysusersEnabled || (options.services ? userborn && config.services.userborn.enable); in { - systemd.services.sops-install-secrets-for-users = lib.mkIf (secretsForUsers != { } && useSystemdActivation) { - wantedBy = [ "systemd-sysusers.service" ]; - before = [ "systemd-sysusers.service" ]; - environment = cfg.environment; - unitConfig.DefaultDependencies = "no"; + systemd.services.sops-install-secrets-for-users = + lib.mkIf (secretsForUsers != { } && useSystemdActivation) + { + wantedBy = [ "systemd-sysusers.service" ]; + before = [ "systemd-sysusers.service" ]; + environment = cfg.environment; + unitConfig.DefaultDependencies = "no"; - serviceConfig = { - Type = "oneshot"; - ExecStart = [ "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}" ]; - RemainAfterExit = true; - }; - }; + serviceConfig = { + Type = "oneshot"; + ExecStart = [ "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}" ]; + RemainAfterExit = true; + }; + }; system.activationScripts = lib.mkIf (secretsForUsers != { } && !useSystemdActivation) { - setupSecretsForUsers = lib.stringAfter ([ "specialfs" ] ++ lib.optional cfg.age.generateKey "generate-age-key") '' - [ -e /run/current-system ] || echo setting up secrets for users... - ${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"} - '' // lib.optionalAttrs (config.system ? dryActivationScript) { - supportsDryActivation = true; - }; + setupSecretsForUsers = + lib.stringAfter ([ "specialfs" ] ++ lib.optional cfg.age.generateKey "generate-age-key") '' + [ -e /run/current-system ] || echo setting up secrets for users... + ${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"} + '' + // lib.optionalAttrs (config.system ? dryActivationScript) { + supportsDryActivation = true; + }; users.deps = [ "setupSecretsForUsers" ]; }; - assertions = [{ - assertion = (lib.filterAttrs (_: v: v.owner != "root" || v.group != "root") secretsForUsers) == { }; - message = "neededForUsers cannot be used for secrets that are not root-owned"; - } { - assertion = secretsForUsers != { } && sysusersEnabled -> config.users.mutableUsers; - message = '' - systemd.sysusers.enable in combination with sops.secrets..neededForUsers can only work with config.users.mutableUsers enabled. - See https://github.com/Mic92/sops-nix/issues/475 - ''; - }]; + assertions = [ + { + assertion = + (lib.filterAttrs ( + _: v: (v.uid != 0 && v.owner != "root") || (v.gid != 0 && v.group != "root") + ) secretsForUsers) == { }; + message = "neededForUsers cannot be used for secrets that are not root-owned"; + } + { + assertion = secretsForUsers != { } && sysusersEnabled -> config.users.mutableUsers; + message = '' + systemd.sysusers.enable in combination with sops.secrets..neededForUsers can only work with config.users.mutableUsers enabled. + See https://github.com/Mic92/sops-nix/issues/475 + ''; + } + ]; system.build.sops-nix-users-manifest = manifestForUsers; } diff --git a/modules/sops/templates/default.nix b/modules/sops/templates/default.nix index 5b6e692..fb3fb2c 100644 --- a/modules/sops/templates/default.nix +++ b/modules/sops/templates/default.nix @@ -1,109 +1,123 @@ -{ config, pkgs, lib, options, ... }: -with lib; -with lib.types; -with builtins; +{ + config, + pkgs, + lib, + options, + ... +}: let - cfg = config.sops; - secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets; + inherit (lib) + mkOption + mkDefault + mapAttrs + types + ; + users = config.users.users; -in { +in +{ options.sops = { templates = mkOption { description = "Templates for secret files"; - type = attrsOf (submodule ({ config, ... }: { - options = { - name = mkOption { - type = singleLineStr; - default = config._module.args.name; - description = '' - Name of the file used in /run/secrets-rendered - ''; - }; - path = mkOption { - description = "Path where the rendered file will be placed"; - type = singleLineStr; - default = "/run/secrets-rendered/${config.name}"; - }; - content = mkOption { - type = lines; - default = ""; - description = '' - Content of the file - ''; - }; - mode = mkOption { - type = singleLineStr; - default = "0400"; - description = '' - Permissions mode of the rendered secret file in octal. - ''; - }; - owner = mkOption { - type = singleLineStr; - default = "root"; - description = '' - User of the file. - ''; - }; - group = mkOption { - type = singleLineStr; - default = users.${config.owner}.group; - defaultText = ''config.users.users.''${cfg.owner}.group''; - description = '' - Group of the file. - ''; - }; - file = mkOption { - type = types.path; - default = pkgs.writeText config.name config.content; - defaultText = ''pkgs.writeText config.name config.content''; - example = "./configuration-template.conf"; - description = '' - File used as the template. When this value is specified, `sops.templates..content` is ignored. - ''; - }; - }; - })); + type = types.attrsOf ( + types.submodule ( + { config, ... }: + { + options = { + name = mkOption { + type = types.singleLineStr; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets/rendered + ''; + }; + path = mkOption { + description = "Path where the rendered file will be placed"; + type = types.singleLineStr; + # Keep this in sync with `RenderedSubdir` in `pkgs/sops-install-secrets/main.go` + default = "/run/secrets/rendered/${config.name}"; + }; + content = mkOption { + type = types.lines; + default = ""; + description = '' + Content of the file + ''; + }; + mode = mkOption { + type = types.singleLineStr; + default = "0400"; + description = '' + Permissions mode of the rendered secret file in octal. + ''; + }; + owner = mkOption { + type = types.singleLineStr; + default = "root"; + description = '' + User of the file. + ''; + }; + group = mkOption { + type = types.singleLineStr; + default = users.${config.owner}.group; + defaultText = lib.literalExpression ''config.users.users.''${cfg.owner}.group''; + description = '' + Group of the file. + ''; + }; + file = mkOption { + type = types.path; + default = pkgs.writeText config.name config.content; + defaultText = lib.literalExpression ''pkgs.writeText config.name config.content''; + example = "./configuration-template.conf"; + description = '' + File used as the template. When this value is specified, `sops.templates..content` is ignored. + ''; + }; + restartUnits = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be restarted when the rendered template changes. + This works the same way as . + ''; + }; + reloadUnits = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be reloaded when the rendered template changes. + This works the same way as . + ''; + }; + }; + } + ) + ); default = { }; }; placeholder = mkOption { - type = attrsOf (mkOptionType { - name = "coercibleToString"; - description = "value that can be coerced to string"; - check = strings.isConvertibleWithToString; - merge = mergeEqualOption; - }); + type = types.attrsOf ( + types.mkOptionType { + name = "coercibleToString"; + description = "value that can be coerced to string"; + check = lib.strings.isConvertibleWithToString; + merge = lib.mergeEqualOption; + } + ); default = { }; visible = false; }; }; - config = optionalAttrs (options ? sops.secrets) - (mkIf (config.sops.templates != { }) { - sops.placeholder = mapAttrs - (name: _: mkDefault "") - config.sops.secrets; - - system.activationScripts.renderSecrets = mkIf (cfg.templates != { }) - (stringAfter ([ "setupSecrets" ] - ++ optional (secretsForUsers != { }) "setupSecretsForUsers") '' - echo Setting up sops templates... - ${concatMapStringsSep "\n" (name: - let - tpl = config.sops.templates.${name}; - substitute = pkgs.writers.writePython3 "substitute" { } - (readFile ./subs.py); - subst-pairs = pkgs.writeText "pairs" (concatMapStringsSep "\n" - (name: - "${toString config.sops.placeholder.${name}} ${ - config.sops.secrets.${name}.path - }") (attrNames config.sops.secrets)); - in '' - mkdir -p "${dirOf tpl.path}" - (umask 077; ${substitute} ${tpl.file} ${subst-pairs} > ${tpl.path}) - chmod "${tpl.mode}" "${tpl.path}" - chown "${tpl.owner}:${tpl.group}" "${tpl.path}" - '') (attrNames config.sops.templates)} - ''); - }); + config = lib.optionalAttrs (options ? sops.secrets) ( + lib.mkIf (config.sops.templates != { }) { + sops.placeholder = mapAttrs ( + name: _: mkDefault "" + ) config.sops.secrets; + } + ); } diff --git a/modules/sops/templates/subs.py b/modules/sops/templates/subs.py deleted file mode 100644 index 778b690..0000000 --- a/modules/sops/templates/subs.py +++ /dev/null @@ -1,26 +0,0 @@ -from sys import argv - - -def substitute(target: str, subst: str) -> str: - with open(target) as f: - content = f.read() - - with open(subst) as f: - subst_pairs = f.read().splitlines() - - for pair in subst_pairs: - placeholder, path = pair.split() - if placeholder in content: - with open(path) as f: - content = content.replace(placeholder, f.read()) - - return content - - -def main() -> None: - target = argv[1] - subst = argv[2] - print(substitute(target, subst)) - - -main() diff --git a/modules/sops/with-environment.nix b/modules/sops/with-environment.nix index d19d5fd..1306e97 100644 --- a/modules/sops/with-environment.nix +++ b/modules/sops/with-environment.nix @@ -2,11 +2,12 @@ sopsCall: -if cfg.environment == {} then +if cfg.environment == { } then sopsCall -else '' - ( - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)} - ${sopsCall} - ) -'' +else + '' + ( + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)} + ${sopsCall} + ) + '' diff --git a/pkgs/sops-import-keys-hook/default.nix b/pkgs/sops-import-keys-hook/default.nix index 1e6d10d..940968c 100644 --- a/pkgs/sops-import-keys-hook/default.nix +++ b/pkgs/sops-import-keys-hook/default.nix @@ -1,13 +1,25 @@ -{ makeSetupHook, gnupg, sops, lib }: +{ + makeSetupHook, + gnupg, + sops, + lib, +}: let # FIXME: drop after 23.05 - propagatedBuildInputs = if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then "deps" else "propagatedBuildInputs"; + propagatedBuildInputs = + if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then + "deps" + else + "propagatedBuildInputs"; in (makeSetupHook { name = "sops-import-keys-hook"; substitutions = { gpg = "${gnupg}/bin/gpg"; }; - ${propagatedBuildInputs} = [ sops gnupg ]; + ${propagatedBuildInputs} = [ + sops + gnupg + ]; } ./sops-import-keys-hook.bash) diff --git a/pkgs/sops-import-keys-hook/test-assets/shell.nix b/pkgs/sops-import-keys-hook/test-assets/shell.nix index 88c4b25..6c8fec6 100644 --- a/pkgs/sops-import-keys-hook/test-assets/shell.nix +++ b/pkgs/sops-import-keys-hook/test-assets/shell.nix @@ -1,5 +1,5 @@ # shell.nix -with import {}; +with import { }; mkShell { sopsPGPKeyDirs = [ "./keys" @@ -10,6 +10,6 @@ mkShell { ]; sopsCreateGPGHome = "1"; nativeBuildInputs = [ - (pkgs.callPackage ../../.. {}).sops-import-keys-hook + (pkgs.callPackage ../../.. { }).sops-import-keys-hook ]; } diff --git a/pkgs/sops-init-gpg-key/default.nix b/pkgs/sops-init-gpg-key/default.nix index 9e0ed1a..1860aae 100644 --- a/pkgs/sops-init-gpg-key/default.nix +++ b/pkgs/sops-init-gpg-key/default.nix @@ -1,4 +1,12 @@ -{ stdenv, lib, makeWrapper, gnupg, coreutils, util-linux, unixtools }: +{ + stdenv, + lib, + makeWrapper, + gnupg, + coreutils, + util-linux, + unixtools, +}: stdenv.mkDerivation { name = "sops-init-gpg-key"; @@ -11,9 +19,14 @@ stdenv.mkDerivation { installPhase = '' install -m755 -D $src $out/bin/sops-init-gpg-key wrapProgram $out/bin/sops-init-gpg-key \ - --prefix PATH : ${lib.makeBinPath [ - coreutils util-linux gnupg unixtools.hostname - ]} + --prefix PATH : ${ + lib.makeBinPath [ + coreutils + util-linux + gnupg + unixtools.hostname + ] + } ''; doInstallCheck = true; diff --git a/pkgs/sops-install-secrets/darwin.go b/pkgs/sops-install-secrets/darwin.go index b56064c..05cbf75 100644 --- a/pkgs/sops-install-secrets/darwin.go +++ b/pkgs/sops-install-secrets/darwin.go @@ -6,7 +6,6 @@ package main import ( "errors" "fmt" - "log" "os" "os/exec" "strings" @@ -71,21 +70,16 @@ func MountSecretFs(mountpoint string, keysGID int, _useTmpfs bool, userMode bool size := mb * 1024 * 1024 / 512 // size in sectors a 512 bytes cmd := exec.Command("hdiutil", "attach", "-nomount", fmt.Sprintf("ram://%d", int(size))) out, err := cmd.Output() // /dev/diskN - log.Printf("%q\n", string(out)) diskpath := strings.TrimRight(string(out[:]), " \t\n") - log.Printf("%q\n", diskpath) - log.Printf("hdiutil attach ret %v. out: %s", err, diskpath) // format as hfs out, err = exec.Command("newfs_hfs", "-s", diskpath).Output() - log.Printf("newfs_hfs ret %v. out: %s", err, out) // "posix" mount takes `struct hfs_mount_args` which we dont have bindings for at hand. // See https://stackoverflow.com/a/49048846/4108673 // err = unix.Mount("hfs", mountpoint, unix.MNT_NOEXEC|unix.MNT_NODEV, mount_args) // Instead we call: out, err = exec.Command("mount", "-t", "hfs", "-o", "nobrowse,nodev,nosuid,-m=0751", diskpath, mountpoint).Output() - log.Printf("mount ret %v. out: %s", err, out) // There is no documented way to check for memfs mountpoint. Thus we place a file. path := mountpoint + "/sops-nix-secretfs" diff --git a/pkgs/sops-install-secrets/default.nix b/pkgs/sops-install-secrets/default.nix index fbe1fbd..66289ce 100644 --- a/pkgs/sops-install-secrets/default.nix +++ b/pkgs/sops-install-secrets/default.nix @@ -1,30 +1,39 @@ -{ lib, buildGoModule, stdenv, vendorHash, go, callPackages }: +{ + lib, + buildGoModule, + stdenv, + vendorHash, + go, +}: buildGoModule { pname = "sops-install-secrets"; version = "0.0.1"; - src = lib.sourceByRegex ../.. [ "go\.(mod|sum)" "pkgs" "pkgs/sops-install-secrets.*" ]; + src = lib.sourceByRegex ../.. [ + "go\.(mod|sum)" + "pkgs" + "pkgs/sops-install-secrets.*" + ]; subPackages = [ "pkgs/sops-install-secrets" ]; # requires root privileges for tests doCheck = false; - passthru.tests = callPackages ./nixos-test.nix { }; + outputs = [ "out" ] ++ lib.lists.optionals (stdenv.isLinux) [ "unittest" ]; - outputs = [ "out" ] ++ - lib.lists.optionals (stdenv.isLinux) [ "unittest" ]; - - postInstall = '' - go test -c ./pkgs/sops-install-secrets - '' + lib.optionalString (stdenv.isLinux) '' - # *.test is only tested on linux. $unittest does not exist on darwin. - install -D ./sops-install-secrets.test $unittest/bin/sops-install-secrets.test - # newer versions of nixpkgs no longer require this step - if command -v remove-references-to; then - remove-references-to -t ${go} $unittest/bin/sops-install-secrets.test - fi - ''; + postInstall = + '' + go test -c ./pkgs/sops-install-secrets + '' + + lib.optionalString (stdenv.isLinux) '' + # *.test is only tested on linux. $unittest does not exist on darwin. + install -D ./sops-install-secrets.test $unittest/bin/sops-install-secrets.test + # newer versions of nixpkgs no longer require this step + if command -v remove-references-to; then + remove-references-to -t ${go} $unittest/bin/sops-install-secrets.test + fi + ''; inherit vendorHash; diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go index f096250..4f1c68b 100644 --- a/pkgs/sops-install-secrets/main.go +++ b/pkgs/sops-install-secrets/main.go @@ -11,6 +11,7 @@ import ( "os/user" "path" "path/filepath" + "sort" "strconv" "strings" "syscall" @@ -29,8 +30,10 @@ type secret struct { Name string `json:"name"` Key string `json:"key"` Path string `json:"path"` - Owner string `json:"owner"` - Group string `json:"group"` + Owner *string `json:"owner,omitempty"` + UID int `json:"uid"` + Group *string `json:"group,omitempty"` + GID int `json:"gid"` SopsFile string `json:"sopsFile"` Format FormatType `json:"format"` Mode string `json:"mode"` @@ -47,18 +50,39 @@ type loggingConfig struct { SecretChanges bool `json:"secretChanges"` } +type template struct { + Name string `json:"name"` + Content string `json:"content"` + Path string `json:"path"` + Mode string `json:"mode"` + Owner *string `json:"owner,omitempty"` + UID int `json:"uid"` + Group *string `json:"group,omitempty"` + GID int `json:"gid"` + File string `json:"file"` + RestartUnits []string `json:"restartUnits"` + ReloadUnits []string `json:"reloadUnits"` + value []byte + mode os.FileMode + content string + owner int + group int +} + type manifest struct { - Secrets []secret `json:"secrets"` - SecretsMountPoint string `json:"secretsMountPoint"` - SymlinkPath string `json:"symlinkPath"` - KeepGenerations int `json:"keepGenerations"` - SSHKeyPaths []string `json:"sshKeyPaths"` - GnupgHome string `json:"gnupgHome"` - AgeKeyFile string `json:"ageKeyFile"` - AgeSSHKeyPaths []string `json:"ageSshKeyPaths"` - UseTmpfs bool `json:"useTmpfs"` - UserMode bool `json:"userMode"` - Logging loggingConfig `json:"logging"` + Secrets []secret `json:"secrets"` + Templates []template `json:"templates"` + PlaceholderBySecretName map[string]string `json:"placeholderBySecretName"` + SecretsMountPoint string `json:"secretsMountPoint"` + SymlinkPath string `json:"symlinkPath"` + KeepGenerations int `json:"keepGenerations"` + SSHKeyPaths []string `json:"sshKeyPaths"` + GnupgHome string `json:"gnupgHome"` + AgeKeyFile string `json:"ageKeyFile"` + AgeSSHKeyPaths []string `json:"ageSshKeyPaths"` + UseTmpfs bool `json:"useTmpfs"` + UserMode bool `json:"userMode"` + Logging loggingConfig `json:"logging"` } type secretFile struct { @@ -126,12 +150,16 @@ type options struct { } type appContext struct { - manifest manifest - secretFiles map[string]secretFile - checkMode CheckMode - ignorePasswd bool + manifest manifest + secretFiles map[string]secretFile + secretByPlaceholder map[string]*secret + checkMode CheckMode + ignorePasswd bool } +// Keep this in sync with `modules/sops/templates/default.nix` +const RenderedSubdir string = "rendered" + func readManifest(path string) (*manifest, error) { file, err := os.Open(path) if err != nil { @@ -146,51 +174,51 @@ func readManifest(path string) (*manifest, error) { return &m, nil } -func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, secret *secret) bool { +func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, owner int, group int) bool { validUG := true if stat, ok := info.Sys().(*syscall.Stat_t); ok { - validUG = validUG && int(stat.Uid) == secret.owner - validUG = validUG && int(stat.Gid) == secret.group + validUG = validUG && int(stat.Uid) == owner + validUG = validUG && int(stat.Gid) == group } else { panic("Failed to cast fileInfo Sys() to *syscall.Stat_t. This is possibly an unsupported OS.") } return linkTarget == targetFile && validUG } -func symlinkSecret(targetFile string, secret *secret, userMode bool) error { +func createSymlink(targetFile string, path string, owner int, group int, userMode bool) error { for { - stat, err := os.Lstat(secret.Path) + stat, err := os.Lstat(path) if os.IsNotExist(err) { - if err = os.Symlink(targetFile, secret.Path); err != nil { - return fmt.Errorf("cannot create symlink '%s': %w", secret.Path, err) + if err = os.Symlink(targetFile, path); err != nil { + return fmt.Errorf("cannot create symlink '%s': %w", path, err) } if !userMode { - if err = SecureSymlinkChown(secret.Path, targetFile, secret.owner, secret.group); err != nil { - return fmt.Errorf("cannot chown symlink '%s': %w", secret.Path, err) + if err = SecureSymlinkChown(path, targetFile, owner, group); err != nil { + return fmt.Errorf("cannot chown symlink '%s': %w", path, err) } } return nil } else if err != nil { - return fmt.Errorf("cannot stat '%s': %w", secret.Path, err) + return fmt.Errorf("cannot stat '%s': %w", path, err) } if stat.Mode()&os.ModeSymlink == os.ModeSymlink { - linkTarget, err := os.Readlink(secret.Path) + linkTarget, err := os.Readlink(path) if os.IsNotExist(err) { continue } else if err != nil { - return fmt.Errorf("cannot read symlink '%s': %w", secret.Path, err) - } else if linksAreEqual(linkTarget, targetFile, stat, secret) { + return fmt.Errorf("cannot read symlink '%s': %w", path, err) + } else if linksAreEqual(linkTarget, targetFile, stat, owner, group) { return nil } } - if err := os.Remove(secret.Path); err != nil { - return fmt.Errorf("cannot override %s: %w", secret.Path, err) + if err := os.Remove(path); err != nil { + return fmt.Errorf("cannot override %s: %w", path, err) } } } -func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error { - for i, secret := range secrets { +func symlinkSecretsAndTemplates(targetDir string, secrets []secret, templates []template, userMode bool) error { + for _, secret := range secrets { targetFile := filepath.Join(targetDir, secret.Name) if targetFile == secret.Path { continue @@ -199,10 +227,25 @@ func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error { if err := os.MkdirAll(parent, os.ModePerm); err != nil { return fmt.Errorf("cannot create parent directory of '%s': %w", secret.Path, err) } - if err := symlinkSecret(targetFile, &secrets[i], userMode); err != nil { + if err := createSymlink(targetFile, secret.Path, secret.owner, secret.group, userMode); err != nil { return fmt.Errorf("failed to symlink secret '%s': %w", secret.Path, err) } } + + for _, template := range templates { + targetFile := filepath.Join(targetDir, RenderedSubdir, template.Name) + if targetFile == template.Path { + continue + } + parent := filepath.Dir(template.Path) + if err := os.MkdirAll(parent, os.ModePerm); err != nil { + return fmt.Errorf("cannot create parent directory of '%s': %w", template.Path, err) + } + if err := createSymlink(targetFile, template.Path, template.owner, template.group, userMode); err != nil { + return fmt.Errorf("failed to symlink template '%s': %w", template.Path, err) + } + } + return nil } @@ -294,12 +337,20 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error { case Binary, Dotenv, Ini: sourceFile.binary = plain case Yaml: - if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil { - return fmt.Errorf("cannot parse yaml of '%s': %w", s.SopsFile, err) + if s.Key == "" { + sourceFile.binary = plain + } else { + if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil { + return fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err) + } } case JSON: - if err := json.Unmarshal(plain, &sourceFile.data); err != nil { - return fmt.Errorf("cannot parse json of '%s': %w", s.SopsFile, err) + if s.Key == "" { + sourceFile.binary = plain + } else { + if err := json.Unmarshal(plain, &sourceFile.data); err != nil { + return fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err) + } } default: return fmt.Errorf("secret of type %s in %s is not supported", s.Format, s.SopsFile) @@ -309,11 +360,15 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error { case Binary, Dotenv, Ini: s.value = sourceFile.binary case Yaml, JSON: - strVal, err := recurseSecretKey(s.Format, sourceFile.data, s.Key) - if err != nil { - return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err) + if s.Key == "" { + s.value = sourceFile.binary + } else { + strVal, err := recurseSecretKey(s.Format, sourceFile.data, s.Key) + if err != nil { + return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err) + } + s.value = []byte(strVal) } - s.value = []byte(strVal) } sourceFiles[s.SopsFile] = sourceFile return nil @@ -366,24 +421,30 @@ func prepareSecretsDir(secretMountpoint string, linkName string, keysGID int, us return &dir, nil } +func createParentDirs(parent string, target string, keysGID int, userMode bool) error { + dirs := strings.Split(filepath.Dir(target), "/") + pathSoFar := parent + for _, dir := range dirs { + pathSoFar = filepath.Join(pathSoFar, dir) + if err := os.MkdirAll(pathSoFar, 0o751); err != nil { + return fmt.Errorf("cannot create directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err) + } + if !userMode { + if err := os.Chown(pathSoFar, 0, int(keysGID)); err != nil { + return fmt.Errorf("cannot own directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err) + } + } + } + return nil +} + func writeSecrets(secretDir string, secrets []secret, keysGID int, userMode bool) error { for _, secret := range secrets { fp := filepath.Join(secretDir, secret.Name) - dirs := strings.Split(filepath.Dir(secret.Name), "/") - pathSoFar := secretDir - for _, dir := range dirs { - pathSoFar = filepath.Join(pathSoFar, dir) - if err := os.MkdirAll(pathSoFar, 0o751); err != nil { - return fmt.Errorf("cannot create directory '%s' for %s: %w", pathSoFar, fp, err) - } - if !userMode { - if err := os.Chown(pathSoFar, 0, int(keysGID)); err != nil { - return fmt.Errorf("cannot own directory '%s' for %s: %w", pathSoFar, fp, err) - } - } + if err := createParentDirs(secretDir, secret.Name, keysGID, userMode); err != nil { + return err } - if err := os.WriteFile(fp, []byte(secret.value), secret.mode); err != nil { return fmt.Errorf("cannot write %s: %w", fp, err) } @@ -460,7 +521,7 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) { if err != nil { return nil, fmt.Errorf("cannot parse ini of '%s': %w", s.SopsFile, err) } - // TODO: we do not acctually check the contents of the ini here... + // TODO: we do not actually check the contents of the ini here... } return &secretFile{ @@ -476,7 +537,7 @@ func (app *appContext) validateSopsFile(s *secret, file *secretFile) error { s.Name, s.SopsFile, s.Format, file.firstSecret.Format, file.firstSecret.Name) } - if app.checkMode != Manifest && (s.Format != Binary && s.Format != Dotenv && s.Format != Ini) { + if app.checkMode != Manifest && !(s.Format == Binary || s.Format == Dotenv || s.Format == Ini) && s.Key != "" { _, err := recurseSecretKey(s.Format, file.keys, s.Key) if err != nil { return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err) @@ -485,37 +546,68 @@ func (app *appContext) validateSopsFile(s *secret, file *secretFile) error { return nil } -func (app *appContext) validateSecret(secret *secret) error { - mode, err := strconv.ParseUint(secret.Mode, 8, 16) +func validateMode(mode string) (os.FileMode, error) { + parsed, err := strconv.ParseUint(mode, 8, 16) if err != nil { - return fmt.Errorf("invalid number in mode: %d: %w", mode, err) + return 0, fmt.Errorf("invalid number in mode: %s: %w", mode, err) } - secret.mode = os.FileMode(mode) + return os.FileMode(parsed), nil +} + +func validateOwner(owner string) (int, error) { + lookedUp, err := user.Lookup(owner) + if err != nil { + return 0, fmt.Errorf("failed to lookup user '%s': %w", owner, err) + } + ownerNr, err := strconv.ParseUint(lookedUp.Uid, 10, 64) + if err != nil { + return 0, fmt.Errorf("cannot parse uid %s: %w", lookedUp.Uid, err) + } + return int(ownerNr), nil +} + +func validateGroup(group string) (int, error) { + lookedUp, err := user.LookupGroup(group) + if err != nil { + return 0, fmt.Errorf("failed to lookup group '%s': %w", group, err) + } + groupNr, err := strconv.ParseUint(lookedUp.Gid, 10, 64) + if err != nil { + return 0, fmt.Errorf("cannot parse gid %s: %w", lookedUp.Gid, err) + } + return int(groupNr), nil +} + +func (app *appContext) validateSecret(secret *secret) error { + mode, err := validateMode(secret.Mode) + if err != nil { + return err + } + secret.mode = mode if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" { secret.owner = 0 secret.group = 0 } else if app.checkMode == Off || app.ignorePasswd { - // we only access to the user/group during deployment - owner, err := user.Lookup(secret.Owner) - if err != nil { - return fmt.Errorf("failed to lookup user '%s': %w", secret.Owner, err) + if secret.Owner == nil { + secret.owner = secret.UID + } else { + owner, err := validateOwner(*secret.Owner) + if err != nil { + return err + } + secret.owner = owner } - ownerNr, err := strconv.ParseUint(owner.Uid, 10, 64) - if err != nil { - return fmt.Errorf("cannot parse uid %s: %w", owner.Uid, err) - } - secret.owner = int(ownerNr) - group, err := user.LookupGroup(secret.Group) - if err != nil { - return fmt.Errorf("failed to lookup group '%s': %w", secret.Group, err) + if secret.Group == nil { + secret.group = secret.GID + } else { + group, err := validateGroup(*secret.Group) + if err != nil { + return err + } + secret.group = group } - groupNr, err := strconv.ParseUint(group.Gid, 10, 64) - if err != nil { - return fmt.Errorf("cannot parse gid %s: %w", group.Gid, err) - } - secret.group = int(groupNr) } if secret.Format == "" { @@ -533,12 +625,79 @@ func (app *appContext) validateSecret(secret *secret) error { return err } app.secretFiles[secret.SopsFile] = *maybeFile + file = *maybeFile } return app.validateSopsFile(secret, &file) } +func renderTemplates(templates []template, secretByPlaceholder map[string]*secret) { + for i := range templates { + template := &templates[i] + rendered := renderTemplate(&template.content, secretByPlaceholder) + template.value = []byte(rendered) + } +} + +func renderTemplate(content *string, secretByPlaceholder map[string]*secret) string { + rendered := *content + for placeholder, secret := range secretByPlaceholder { + rendered = strings.ReplaceAll(rendered, placeholder, string(secret.value)) + } + return rendered +} + +func (app *appContext) validateTemplate(template *template) error { + mode, err := validateMode(template.Mode) + if err != nil { + return err + } + template.mode = mode + + if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" { + template.owner = 0 + template.group = 0 + } else if app.checkMode == Off || app.ignorePasswd { + if template.Owner == nil { + template.owner = template.UID + } else { + owner, err := validateOwner(*template.Owner) + if err != nil { + return err + } + template.owner = owner + } + + if template.Group == nil { + template.group = template.GID + } else { + group, err := validateGroup(*template.Group) + if err != nil { + return err + } + template.group = group + } + } + + var templateText string + if template.Content != "" { + templateText = template.Content + } else if template.File != "" { + templateBytes, err := os.ReadFile(template.File) + if err != nil { + return fmt.Errorf("cannot read %s: %w", template.File, err) + } + templateText = string(templateBytes) + } else { + return fmt.Errorf("neither content nor file was specified for template %s", template.Name) + } + + template.content = templateText + + return nil +} + func (app *appContext) validateManifest() error { m := &app.manifest if m.GnupgHome != "" { @@ -553,7 +712,22 @@ func (app *appContext) validateManifest() error { } for i := range m.Secrets { - if err := app.validateSecret(&m.Secrets[i]); err != nil { + secret := &m.Secrets[i] + if err := app.validateSecret(secret); err != nil { + return err + } + + // The Nix module only defines placeholders for secrets if there are + // templates. + if len(m.Templates) > 0 { + placeholder := m.PlaceholderBySecretName[secret.Name] + app.secretByPlaceholder[placeholder] = secret + } + } + + for i := range m.Templates { + template := &m.Templates[i] + if err := app.validateTemplate(template); err != nil { return err } } @@ -743,7 +917,7 @@ func symlinkWalk(filename string, linkDirname string, walkFn filepath.WalkFunc) return filepath.Walk(filename, symWalkFunc) } -func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret) error { +func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret, templates []template) error { var restart []string var reload []string @@ -751,6 +925,10 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s modifiedSecrets := make(map[string]bool) removedSecrets := make(map[string]bool) + newTemplates := make(map[string]bool) + modifiedTemplates := make(map[string]bool) + removedTemplates := make(map[string]bool) + // When the symlink path does not exist yet, we are being run in stage-2-init.sh // where switch-to-configuration is not run so the services would only be restarted // the next time switch-to-configuration is run. @@ -789,8 +967,46 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s } } + // Find modified/new templates + for _, template := range templates { + oldPath := filepath.Join(symlinkPath, RenderedSubdir, template.Name) + newPath := filepath.Join(secretDir, RenderedSubdir, template.Name) + + // Read the old file + oldData, err := os.ReadFile(oldPath) + if err != nil { + if os.IsNotExist(err) { + // File did not exist before + restart = append(restart, template.RestartUnits...) + reload = append(reload, template.ReloadUnits...) + newTemplates[template.Name] = true + continue + } + return err + } + + // Read the new file + newData, err := os.ReadFile(newPath) + if err != nil { + return err + } + + if !bytes.Equal(oldData, newData) { + restart = append(restart, template.RestartUnits...) + reload = append(reload, template.ReloadUnits...) + modifiedTemplates[template.Name] = true + } + } + writeLines := func(list []string, file string) error { if len(list) != 0 { + if _, err := os.Stat(filepath.Dir(file)); err != nil { + if os.IsNotExist(err) { + return nil + } else { + return err + } + } f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err != nil { return err @@ -822,7 +1038,8 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s return nil } - // Find removed secrets + // Find removed secrets/templates. + symlinkRenderedPath := filepath.Join(symlinkPath, RenderedSubdir) err := symlinkWalk(symlinkPath, symlinkPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -830,42 +1047,67 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s if info.IsDir() { return nil } - path = strings.TrimPrefix(path, symlinkPath+string(os.PathSeparator)) - for _, secret := range secrets { - if secret.Name == path { - return nil - } + + // If the path we're looking at isn't in `symlinkRenderedPath`, then + // it's a secret. + rel, err := filepath.Rel(symlinkRenderedPath, path) + if err != nil { + return err + } + isSecret := strings.HasPrefix(rel, "..") + + if isSecret { + path = strings.TrimPrefix(path, symlinkPath+string(os.PathSeparator)) + for _, secret := range secrets { + if secret.Name == path { + return nil + } + } + removedSecrets[path] = true + } else { + path = strings.TrimPrefix(path, symlinkRenderedPath+string(os.PathSeparator)) + for _, template := range templates { + if template.Name == path { + return nil + } + } + removedTemplates[path] = true } - removedSecrets[path] = true return nil }) if err != nil { return err } - // Output new/modified/removed secrets - outputChanged := func(changed map[string]bool, regularPrefix, dryPrefix string) { + // Output new/modified/removed secrets/templates + outputChanged := func(noun string, changed map[string]bool, regularPrefix, dryPrefix string) { if len(changed) > 0 { s := "" if len(changed) != 1 { s = "s" } if isDry { - fmt.Printf("%s secret%s: ", dryPrefix, s) + fmt.Printf("%s %s%s: ", dryPrefix, noun, s) } else { - fmt.Printf("%s secret%s: ", regularPrefix, s) + fmt.Printf("%s %s%s: ", regularPrefix, noun, s) } - comma := "" - for name := range changed { - fmt.Printf("%s%s", comma, name) - comma = ", " + + // Sort the output for deterministic behavior. + keys := make([]string, 0, len(changed)) + for key := range changed { + keys = append(keys, key) } - fmt.Println() + sort.Strings(keys) + + fmt.Println(strings.Join(keys, ", ")) } } - outputChanged(newSecrets, "adding", "would add") - outputChanged(modifiedSecrets, "modifying", "would modify") - outputChanged(removedSecrets, "removing", "would remove") + outputChanged("secret", newSecrets, "adding", "would add") + outputChanged("secret", modifiedSecrets, "modifying", "would modify") + outputChanged("secret", removedSecrets, "removing", "would remove") + outputChanged("rendered secret", newTemplates, "adding", "would add") + outputChanged("rendered secret", modifiedTemplates, "modifying", "would modify") + outputChanged("rendered secret", removedTemplates, "removing", "would remove") return nil } @@ -937,6 +1179,26 @@ func replaceRuntimeDir(path, rundir string) (ret string) { return } +func writeTemplates(targetDir string, templates []template, keysGID int, userMode bool) error { + for _, template := range templates { + fp := filepath.Join(targetDir, template.Name) + + if err := createParentDirs(targetDir, template.Name, keysGID, userMode); err != nil { + return err + } + + if err := os.WriteFile(fp, []byte(template.value), template.mode); err != nil { + return fmt.Errorf("cannot write %s: %w", fp, err) + } + if !userMode { + if err := os.Chown(fp, template.owner, template.group); err != nil { + return fmt.Errorf("cannot change owner/group of '%s' to %d/%d: %w", fp, template.owner, template.group, err) + } + } + } + return nil +} + func installSecrets(args []string) error { opts, err := parseFlags(args) if err != nil { @@ -965,10 +1227,11 @@ func installSecrets(args []string) error { } app := appContext{ - manifest: *manifest, - checkMode: opts.checkMode, - ignorePasswd: opts.ignorePasswd, - secretFiles: make(map[string]secretFile), + manifest: *manifest, + checkMode: opts.checkMode, + ignorePasswd: opts.ignorePasswd, + secretFiles: make(map[string]secretFile), + secretByPlaceholder: make(map[string]*secret), } if err = app.validateManifest(); err != nil { @@ -1042,10 +1305,13 @@ func installSecrets(args []string) error { } } - if err = decryptSecrets(manifest.Secrets); err != nil { + if err := decryptSecrets(manifest.Secrets); err != nil { return err } + // Now that the secrets are decrypted, we can render the templates. + renderTemplates(manifest.Templates, app.secretByPlaceholder) + secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGID, manifest.UserMode) if err != nil { return fmt.Errorf("failed to prepare new secrets directory: %w", err) @@ -1053,8 +1319,13 @@ func installSecrets(args []string) error { if err := writeSecrets(*secretDir, manifest.Secrets, keysGID, manifest.UserMode); err != nil { return fmt.Errorf("cannot write secrets: %w", err) } + + if err := writeTemplates(path.Join(*secretDir, RenderedSubdir), manifest.Templates, keysGID, manifest.UserMode); err != nil { + return fmt.Errorf("cannot render templates: %w", err) + } + if !manifest.UserMode { - if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil { + if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets, manifest.Templates); err != nil { return fmt.Errorf("cannot request units to restart: %w", err) } } @@ -1062,7 +1333,7 @@ func installSecrets(args []string) error { if isDry { return nil } - if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets, manifest.UserMode); err != nil { + if err := symlinkSecretsAndTemplates(manifest.SymlinkPath, manifest.Secrets, manifest.Templates, manifest.UserMode); err != nil { return fmt.Errorf("failed to prepare symlinks to secret store: %w", err) } if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil { diff --git a/pkgs/sops-install-secrets/main_test.go b/pkgs/sops-install-secrets/main_test.go index e001c65..8c09e68 100644 --- a/pkgs/sops-install-secrets/main_test.go +++ b/pkgs/sops-install-secrets/main_test.go @@ -98,12 +98,14 @@ func testGPG(t *testing.T) { } }() + nobody := "nobody" + nogroup := "nogroup" // should create a symlink yamlSecret := secret{ Name: "test", Key: "test_key", - Owner: "nobody", - Group: "nogroup", + Owner: &nobody, + Group: &nogroup, SopsFile: path.Join(assets, "secrets.yaml"), Path: path.Join(testdir.path, "test-target"), Mode: "0400", @@ -112,12 +114,13 @@ func testGPG(t *testing.T) { } var jsonSecret, binarySecret, dotenvSecret, iniSecret secret + root := "root" // should not create a symlink jsonSecret = yamlSecret jsonSecret.Name = "test2" - jsonSecret.Owner = "root" + jsonSecret.Owner = &root jsonSecret.Format = "json" - jsonSecret.Group = "root" + jsonSecret.Group = &root jsonSecret.SopsFile = path.Join(assets, "secrets.json") jsonSecret.Path = path.Join(testdir.secretsPath, "test2") jsonSecret.Mode = "0700" @@ -130,16 +133,16 @@ func testGPG(t *testing.T) { dotenvSecret = yamlSecret dotenvSecret.Name = "test4" - dotenvSecret.Owner = "root" - dotenvSecret.Group = "root" + dotenvSecret.Owner = &root + dotenvSecret.Group = &root dotenvSecret.Format = "dotenv" dotenvSecret.SopsFile = path.Join(assets, "secrets.env") dotenvSecret.Path = path.Join(testdir.secretsPath, "test4") iniSecret = yamlSecret iniSecret.Name = "test5" - iniSecret.Owner = "root" - iniSecret.Group = "root" + iniSecret.Owner = &root + iniSecret.Group = &root iniSecret.Format = "ini" iniSecret.SopsFile = path.Join(assets, "secrets.ini") iniSecret.Path = path.Join(testdir.secretsPath, "test5") @@ -214,11 +217,13 @@ func testSSHKey(t *testing.T) { ok(t, err) file.Close() + nobody := "nobody" + nogroup := "nogroup" s := secret{ Name: "test", Key: "test_key", - Owner: "nobody", - Group: "nogroup", + Owner: &nobody, + Group: &nogroup, SopsFile: path.Join(assets, "secrets.yaml"), Path: target, Mode: "0400", @@ -247,11 +252,13 @@ func TestAge(t *testing.T) { ok(t, err) file.Close() + nobody := "nobody" + nogroup := "nogroup" s := secret{ Name: "test", Key: "test_key", - Owner: "nobody", - Group: "nogroup", + Owner: &nobody, + Group: &nogroup, SopsFile: path.Join(assets, "secrets.yaml"), Path: target, Mode: "0400", @@ -280,11 +287,13 @@ func TestAgeWithSSH(t *testing.T) { ok(t, err) file.Close() + nobody := "nobody" + nogroup := "nogroup" s := secret{ Name: "test", Key: "test_key", - Owner: "nobody", - Group: "nogroup", + Owner: &nobody, + Group: &nogroup, SopsFile: path.Join(assets, "secrets.yaml"), Path: target, Mode: "0400", @@ -314,11 +323,13 @@ func TestValidateManifest(t *testing.T) { testdir := newTestDir(t) defer testdir.Remove() + nobody := "nobody" + nogroup := "nogroup" s := secret{ Name: "test", Key: "test_key", - Owner: "nobody", - Group: "nogroup", + Owner: &nobody, + Group: &nogroup, SopsFile: path.Join(assets, "secrets.yaml"), Path: path.Join(testdir.path, "test-target"), Mode: "0400", diff --git a/pkgs/sops-install-secrets/nixos-test.nix b/pkgs/sops-install-secrets/nixos-test.nix deleted file mode 100644 index 0d9b0bd..0000000 --- a/pkgs/sops-install-secrets/nixos-test.nix +++ /dev/null @@ -1,424 +0,0 @@ -{ lib, testers }: -let - userPasswordTest = name: extraConfig: testers.runNixOSTest { - inherit name; - nodes.machine = { config, lib, ... }: { - imports = [ - ../../modules/sops - extraConfig - ]; - sops = { - age.keyFile = "/run/age-keys.txt"; - defaultSopsFile = ./test-assets/secrets.yaml; - secrets.test_key.neededForUsers = true; - secrets."nested/test/file".owner = "example-user"; - }; - - users.users.example-user = lib.mkMerge [ - (lib.mkIf (! config.systemd.sysusers.enable) { - isNormalUser = true; - hashedPasswordFile = config.sops.secrets.test_key.path; - }) - (lib.mkIf config.systemd.sysusers.enable { - isSystemUser = true; - group = "users"; - hashedPasswordFile = config.sops.secrets.test_key.path; - }) - ]; - }; - - testScript = '' - start_all() - machine.wait_for_unit("multi-user.target") - - machine.succeed("getent shadow example-user | grep -q :test_value:") # password was set - machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # regular secrets work... - user = machine.succeed("stat -c%U /run/secrets/nested/test/file").strip() # ...and are owned... - assert user == "example-user", f"Expected 'example-user', got '{user}'" - machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password still exists - - # BUG in nixos's overlayfs... systemd crashes on switch-to-configuration test - '' + lib.optionalString (!(extraConfig ? system.etc.overlay.enable)) '' - machine.succeed("/run/current-system/bin/switch-to-configuration test") - machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # the regular secrets still work after a switch - machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password is still present after a switch - ''; - }; -in { - ssh-keys = testers.runNixOSTest { - name = "sops-ssh-keys"; - nodes.server = { ... }: { - imports = [ ../../modules/sops ]; - services.openssh.enable = true; - services.openssh.hostKeys = [{ - type = "rsa"; - bits = 4096; - path = ./test-assets/ssh-key; - }]; - sops.defaultSopsFile = ./test-assets/secrets.yaml; - sops.secrets.test_key = { }; - }; - - testScript = '' - start_all() - server.succeed("cat /run/secrets/test_key | grep -q test_value") - ''; - }; - - pruning = testers.runNixOSTest { - name = "sops-pruning"; - nodes.machine = { lib, ... }: { - imports = [ ../../modules/sops ]; - sops = { - age.keyFile = "/run/age-keys.txt"; - defaultSopsFile = ./test-assets/secrets.yaml; - secrets.test_key = { }; - keepGenerations = lib.mkDefault 0; - }; - - # must run before sops sets up keys - boot.initrd.postDeviceCommands = '' - cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - - specialisation.pruning.configuration.sops.keepGenerations = 10; - }; - - testScript = '' - # Force us to generation 100 - machine.succeed("mkdir /run/secrets.d/{2..99} /run/secrets.d/non-numeric") - machine.succeed("ln -fsn /run/secrets.d/99 /run/secrets") - machine.succeed("/run/current-system/activate") - machine.succeed("test -d /run/secrets.d/100") - - # Ensure nothing is pruned, these are just random numbers - machine.succeed("test -d /run/secrets.d/1") - machine.succeed("test -d /run/secrets.d/90") - machine.succeed("test -d /run/secrets.d/non-numeric") - - machine.succeed("/run/current-system/specialisation/pruning/bin/switch-to-configuration test") - print(machine.succeed("ls -la /run/secrets.d/")) - - # Ensure stuff was properly pruned. - # We are now at generation 101 so 92 must exist when we keep 10 generations - # and 91 must not. - machine.fail("test -d /run/secrets.d/91") - machine.succeed("test -d /run/secrets.d/92") - machine.succeed("test -d /run/secrets.d/non-numeric") - ''; - }; - - age-keys = testers.runNixOSTest { - name = "sops-age-keys"; - nodes.machine = { lib, ... }: { - imports = [ ../../modules/sops ]; - sops = { - age.keyFile = "/run/age-keys.txt"; - defaultSopsFile = ./test-assets/secrets.yaml; - secrets.test_key = { }; - }; - - # must run before sops sets up keys - boot.initrd.postDeviceCommands = '' - cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - }; - - testScript = '' - start_all() - machine.succeed("cat /run/secrets/test_key | grep -q test_value") - ''; - }; - - age-ssh-keys = testers.runNixOSTest { - name = "sops-age-ssh-keys"; - nodes.machine = { - imports = [ ../../modules/sops ]; - services.openssh.enable = true; - services.openssh.hostKeys = [{ - type = "ed25519"; - path = ./test-assets/ssh-ed25519-key; - }]; - sops = { - defaultSopsFile = ./test-assets/secrets.yaml; - secrets.test_key = { }; - # Generate a key and append it to make sure it appending doesn't break anything - age = { - keyFile = "/tmp/testkey"; - generateKey = true; - }; - }; - }; - - testScript = '' - start_all() - machine.succeed("cat /run/secrets/test_key | grep -q test_value") - ''; - }; - - pgp-keys = testers.runNixOSTest { - name = "sops-pgp-keys"; - nodes.server = { pkgs, lib, config, ... }: { - imports = [ ../../modules/sops ]; - - users.users.someuser = { - isSystemUser = true; - group = "nogroup"; - }; - - sops.gnupg.home = "/run/gpghome"; - sops.defaultSopsFile = ./test-assets/secrets.yaml; - sops.secrets.test_key.owner = config.users.users.someuser.name; - sops.secrets."nested/test/file".owner = config.users.users.someuser.name; - sops.secrets.existing-file = { - key = "test_key"; - path = "/run/existing-file"; - }; - # must run before sops - system.activationScripts.gnupghome = lib.stringAfter [ "etc" ] '' - cp -r ${./test-assets/gnupghome} /run/gpghome - chmod -R 700 /run/gpghome - - touch /run/existing-file - ''; - # Useful for debugging - #environment.systemPackages = [ pkgs.gnupg pkgs.sops ]; - #environment.variables = { - # GNUPGHOME = "/run/gpghome"; - # SOPS_GPG_EXEC="${pkgs.gnupg}/bin/gpg"; - # SOPSFILE = "${./test-assets/secrets.yaml}"; - #}; - }; - testScript = '' - def assertEqual(exp: str, act: str) -> None: - if exp != act: - raise Exception(f"'{exp}' != '{act}'") - - - start_all() - - value = server.succeed("cat /run/secrets/test_key") - assertEqual("test_value", value) - - server.succeed("runuser -u someuser -- cat /run/secrets/test_key >&2") - value = server.succeed("cat /run/secrets/nested/test/file") - assertEqual(value, "another value") - - target = server.succeed("readlink -f /run/existing-file") - assertEqual("/run/secrets.d/1/existing-file", target.strip()) - ''; - }; - - templates = testers.runNixOSTest { - name = "sops-templates"; - nodes.machine = { config, lib, ... }: { - imports = [ ../../modules/sops ]; - sops = { - age.keyFile = "/run/age-keys.txt"; - defaultSopsFile = ./test-assets/secrets.yaml; - secrets.test_key = { }; - }; - - # must run before sops sets up keys - boot.initrd.postDeviceCommands = '' - cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - - sops.templates.test_template = { - content = '' - This line is not modified. - The next value will be replaced by ${config.sops.placeholder.test_key} - This line is also not modified. - ''; - mode = "0400"; - owner = "someuser"; - group = "somegroup"; - }; - sops.templates.test_default.content = '' - Test value: ${config.sops.placeholder.test_key} - ''; - - users.groups.somegroup = {}; - users.users.someuser = { - isSystemUser = true; - group = "somegroup"; - }; - }; - - testScript = '' - start_all() - machine.succeed("[ $(stat -c%U /run/secrets-rendered/test_template) = 'someuser' ]") - machine.succeed("[ $(stat -c%G /run/secrets-rendered/test_template) = 'somegroup' ]") - machine.succeed("[ $(stat -c%U /run/secrets-rendered/test_default) = 'root' ]") - machine.succeed("[ $(stat -c%G /run/secrets-rendered/test_default) = 'root' ]") - - expected = """ - This line is not modified. - The next value will be replaced by test_value - This line is also not modified. - """ - rendered = machine.succeed("cat /run/secrets-rendered/test_template") - - expected_default = """ - Test value: test_value - """ - rendered_default = machine.succeed("cat /run/secrets-rendered/test_default") - - if rendered.strip() != expected.strip() or rendered_default.strip() != expected_default.strip(): - raise Exception("Template is not rendered correctly") - ''; - }; - - restart-and-reload = testers.runNixOSTest { - name = "sops-restart-and-reload"; - nodes.machine = { pkgs, lib, config, ... }: { - imports = [ ../../modules/sops ]; - - sops = { - age.keyFile = "/run/age-keys.txt"; - defaultSopsFile = ./test-assets/secrets.yaml; - secrets.test_key = { - restartUnits = [ "restart-unit.service" "reload-unit.service" ]; - reloadUnits = [ "reload-trigger.service" ]; - }; - }; - - # must run before sops sets up keys - boot.initrd.postDeviceCommands = '' - cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - - systemd.services."restart-unit" = { - description = "Restart unit"; - # not started on boot - serviceConfig = { ExecStart = "/bin/sh -c 'echo ok > /restarted'"; }; - }; - systemd.services."reload-unit" = { - description = "Reload unit"; - wantedBy = [ "multi-user.target" ]; - reloadIfChanged = true; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = "/bin/sh -c true"; - ExecReload = "/bin/sh -c 'echo ok > /reloaded'"; - }; - }; - systemd.services."reload-trigger" = { - description = "Reload trigger unit"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = "/bin/sh -c true"; - ExecReload = "/bin/sh -c 'echo ok > /reloaded'"; - }; - }; - }; - testScript = '' - machine.wait_for_unit("multi-user.target") - machine.fail("test -f /restarted") - machine.fail("test -f /reloaded") - - # Nothing is to be restarted after boot - machine.fail("ls /run/nixos/*-list") - - # Nothing happens when the secret is not changed - machine.succeed("/run/current-system/bin/switch-to-configuration test") - machine.fail("test -f /restarted") - machine.fail("test -f /reloaded") - - # Ensure the secret is changed - machine.succeed(": > /run/secrets/test_key") - - # The secret is changed, now something should happen - machine.succeed("/run/current-system/bin/switch-to-configuration test") - - # Ensure something happened - machine.succeed("test -f /restarted") - machine.succeed("test -f /reloaded") - - with subtest("change detection"): - machine.succeed("rm /run/secrets/test_key") - out = machine.succeed("/run/current-system/bin/switch-to-configuration test") - if "adding secret" not in out: - raise Exception("Addition detection does not work") - - machine.succeed(": > /run/secrets/test_key") - out = machine.succeed("/run/current-system/bin/switch-to-configuration test") - if "modifying secret" not in out: - raise Exception("Modification detection does not work") - - machine.succeed(": > /run/secrets/another_key") - out = machine.succeed("/run/current-system/bin/switch-to-configuration test") - if "removing secret" not in out: - raise Exception("Removal detection does not work") - - with subtest("dry activation"): - machine.succeed("rm /run/secrets/test_key") - machine.succeed(": > /run/secrets/another_key") - out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate") - if "would add secret" not in out: - raise Exception("Dry addition detection does not work") - if "would remove secret" not in out: - raise Exception("Dry removal detection does not work") - - machine.fail("test -f /run/secrets/test_key") - machine.succeed("test -f /run/secrets/another_key") - - machine.succeed("/run/current-system/bin/switch-to-configuration test") - machine.succeed("test -f /run/secrets/test_key") - machine.succeed("rm /restarted /reloaded") - machine.fail("test -f /run/secrets/another_key") - - machine.succeed(": > /run/secrets/test_key") - out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate") - if "would modify secret" not in out: - raise Exception("Dry modification detection does not work") - machine.succeed("[ $(cat /run/secrets/test_key | wc -c) = 0 ]") - - machine.fail("test -f /restarted") # not done in dry mode - machine.fail("test -f /reloaded") # not done in dry mode - ''; - }; - - user-passwords = userPasswordTest "sops-user-passwords" { - # must run before sops sets up keys - boot.initrd.postDeviceCommands = '' - cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - }; -} // lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.05") { - user-passwords-sysusers = userPasswordTest "sops-user-passwords-sysusers" ({ pkgs, ... }: { - systemd.sysusers.enable = true; - users.mutableUsers = true; - system.etc.overlay.enable = true; - boot.initrd.systemd.enable = true; - boot.kernelPackages = pkgs.linuxPackages_latest; - - # must run before sops sets up keys - systemd.services."sops-install-secrets-for-users".preStart = '' - printf '${builtins.readFile ./test-assets/age-keys.txt}' > /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - }); -} // lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.11") { - user-passwords-userborn = userPasswordTest "sops-user-passwords-userborn" ({ pkgs, ... }: { - services.userborn.enable = true; - users.mutableUsers = false; - system.etc.overlay.enable = true; - boot.initrd.systemd.enable = true; - boot.kernelPackages = pkgs.linuxPackages_latest; - - # must run before sops sets up keys - systemd.services."sops-install-secrets-for-users".preStart = '' - printf '${builtins.readFile ./test-assets/age-keys.txt}' > /run/age-keys.txt - chmod -R 700 /run/age-keys.txt - ''; - }); -} diff --git a/pkgs/sops-install-secrets/shell.nix b/pkgs/sops-install-secrets/shell.nix index 59b0edf..10e4e81 100644 --- a/pkgs/sops-install-secrets/shell.nix +++ b/pkgs/sops-install-secrets/shell.nix @@ -1,4 +1,11 @@ -{ pkgs ? import {} }: +{ + pkgs ? import { }, +}: pkgs.mkShell { - nativeBuildInputs = with pkgs; [ go delve util-linux gnupg ]; + nativeBuildInputs = with pkgs; [ + go + delve + util-linux + gnupg + ]; } diff --git a/pkgs/sops-pgp-hook/default.nix b/pkgs/sops-pgp-hook/default.nix index b088d39..300b3c4 100644 --- a/pkgs/sops-pgp-hook/default.nix +++ b/pkgs/sops-pgp-hook/default.nix @@ -1,13 +1,25 @@ -{ makeSetupHook, gnupg, sops, lib }: +{ + makeSetupHook, + gnupg, + sops, + lib, +}: let # FIXME: drop after 23.05 - propagatedBuildInputs = if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then "deps" else "propagatedBuildInputs"; + propagatedBuildInputs = + if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then + "deps" + else + "propagatedBuildInputs"; in (makeSetupHook { name = "sops-pgp-hook"; substitutions = { gpg = "${gnupg}/bin/gpg"; }; - ${propagatedBuildInputs} = [ sops gnupg ]; + ${propagatedBuildInputs} = [ + sops + gnupg + ]; } ./sops-pgp-hook.bash) diff --git a/pkgs/sops-pgp-hook/test-assets/shell.nix b/pkgs/sops-pgp-hook/test-assets/shell.nix index 628a019..71173fd 100644 --- a/pkgs/sops-pgp-hook/test-assets/shell.nix +++ b/pkgs/sops-pgp-hook/test-assets/shell.nix @@ -1,5 +1,5 @@ # shell.nix -with import {}; +with import { }; mkShell { sopsPGPKeyDirs = [ "./keys" @@ -9,6 +9,6 @@ mkShell { "./non-existing-key.gpg" ]; nativeBuildInputs = [ - (pkgs.callPackage ../../.. {}).sops-pgp-hook + (pkgs.callPackage ../../.. { }).sops-pgp-hook ]; } diff --git a/pkgs/unit-tests.nix b/pkgs/unit-tests.nix index dce7294..f3d3678 100644 --- a/pkgs/unit-tests.nix +++ b/pkgs/unit-tests.nix @@ -1,16 +1,21 @@ -{ pkgs ? import {} +{ + pkgs ? import { }, }: let sopsPkgs = import ../. { inherit pkgs; }; -in pkgs.stdenv.mkDerivation { +in +pkgs.stdenv.mkDerivation { name = "env"; - nativeBuildInputs = with pkgs; [ - bashInteractive - gnupg - util-linux - nix - sopsPkgs.sops-pgp-hook-test - ] ++ pkgs.lib.optional (pkgs.stdenv.isLinux) sopsPkgs.sops-install-secrets.unittest; + nativeBuildInputs = + with pkgs; + [ + bashInteractive + gnupg + util-linux + nix + sopsPkgs.sops-pgp-hook-test + ] + ++ pkgs.lib.optional (pkgs.stdenv.isLinux) sopsPkgs.sops-install-secrets.unittest; # allow to prefetch shell dependencies in build phase dontUnpack = true; installPhase = '' diff --git a/shell.nix b/shell.nix index 11bb906..966d586 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,15 @@ -{ pkgs ? import {} }: -pkgs.mkShell { - nativeBuildInputs = with pkgs; [ +{ + mkShell, + bashInteractive, + go, + delve, + gnupg, + util-linux, + nix, + golangci-lint, +}: +mkShell { + nativeBuildInputs = [ bashInteractive go delve