diff --git a/README.md b/README.md index b5efa17..6202d33 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,20 @@ the service needs a token and a ssh private key to function: } ``` +## Restarting/reloading systemd units + +**With NixOS 21.11**, it is possible to restart or reload units when a secret changes or is newly initialized. +This behaviour can be configured per-secret: +```nix +{ + sops.secrets."home-assistant-secrets.yaml" = { + restartUnits = [ "home-assistant.service" ]; + }; +} +``` + +This logic respects units that prefer to be reloaded or not to be restarted at all. + ## Symlinks to other directories Some services might expect files in certain locations. @@ -813,11 +827,6 @@ You can also check out [nix-community infrastructure repository](https://github. ## Known limitations -### Restarting systemd services - -Right now systemd services are not restarted automatically. -We want to implement this in future. - ### Initrd secrets sops-nix does not fully support initrd secrets. diff --git a/modules/sops/default.nix b/modules/sops/default.nix index 727813b..889cb03 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -78,6 +78,15 @@ let Hash of the sops file, useful in . ''; }; + restartUnits = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "sshd.service" ]; + description = '' + Names of units that should be restarted when this secret changes. + This works the same way as . + ''; + }; }; }); manifest = pkgs.writeText "manifest.json" (builtins.toJSON { @@ -89,6 +98,10 @@ let sshKeyPaths = cfg.gnupg.sshKeyPaths; ageKeyFile = cfg.age.keyFile; ageSshKeyPaths = cfg.age.sshKeyPaths; + logging = { + keyImport = builtins.elem "keyImport" cfg.log; + secretChanges = builtins.elem "secretChanges" cfg.log; + }; }); checkedManifest = let @@ -133,6 +146,12 @@ in { ''; }; + log = mkOption { + type = types.listOf (types.enum [ "keyImport" "secretChanges" ]); + default = [ "keyImport" "secretChanges" ]; + description = "What to log"; + }; + age = { keyFile = mkOption { type = types.nullOr types.path; @@ -209,10 +228,12 @@ in { system.activationScripts.setup-secrets = let sops-install-secrets = (pkgs.callPackage ../.. {}).sops-install-secrets; - in stringAfter ([ "specialfs" "users" "groups" ] ++ optional cfg.age.generateKey "generate-age-key") '' - echo setting up secrets... + in (stringAfter ([ "specialfs" "users" "groups" ] ++ optional cfg.age.generateKey "generate-age-key") '' + [ -e /run/current-system ] || echo setting up secrets... ${optionalString (cfg.gnupg.home != null) "SOPS_GPG_EXEC=${pkgs.gnupg}/bin/gpg"} ${sops-install-secrets}/bin/sops-install-secrets ${checkedManifest} - ''; + '') // lib.optionalAttrs (config.system ? dryActivationScript) { + supportsDryActivation = true; + }; system.activationScripts.generate-age-key = (mkIf cfg.age.generateKey) (stringAfter [] '' if [[ ! -f '${cfg.age.keyFile}' ]]; then diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go index 82cd82d..bb57957 100644 --- a/pkgs/sops-install-secrets/main.go +++ b/pkgs/sops-install-secrets/main.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "encoding/hex" "encoding/json" "flag" @@ -26,30 +27,35 @@ import ( ) type secret struct { - Name string `json:"name"` - Key string `json:"key"` - Path string `json:"path"` - Owner string `json:"owner"` - Group string `json:"group"` - SopsFile string `json:"sopsFile"` - Format FormatType `json:"format"` - Mode string `json:"mode"` - RestartServices []string `json:"restartServices"` - ReloadServices []string `json:"reloadServices"` - value []byte - mode os.FileMode - owner int - group int + Name string `json:"name"` + Key string `json:"key"` + Path string `json:"path"` + Owner string `json:"owner"` + Group string `json:"group"` + SopsFile string `json:"sopsFile"` + Format FormatType `json:"format"` + Mode string `json:"mode"` + RestartUnits []string `json:"restartUnits"` + value []byte + mode os.FileMode + owner int + group int +} + +type loggingConfig struct { + KeyImport bool `json:"keyImport"` + SecretChanges bool `json:"secretChanges"` } type manifest struct { - Secrets []secret `json:"secrets"` - SecretsMountPoint string `json:"secretsMountpoint"` - SymlinkPath string `json:"symlinkPath"` - SSHKeyPaths []string `json:"sshKeyPaths"` - GnupgHome string `json:"gnupgHome"` - AgeKeyFile string `json:"ageKeyFile"` - AgeSshKeyPaths []string `json:"ageSshKeyPaths"` + Secrets []secret `json:"secrets"` + SecretsMountPoint string `json:"secretsMountpoint"` + SymlinkPath string `json:"symlinkPath"` + SSHKeyPaths []string `json:"sshKeyPaths"` + GnupgHome string `json:"gnupgHome"` + AgeKeyFile string `json:"ageKeyFile"` + AgeSshKeyPaths []string `json:"ageSshKeyPaths"` + Logging loggingConfig `json:"logging"` } type secretFile struct { @@ -549,7 +555,7 @@ func atomicSymlink(oldname, newname string) error { return os.RemoveAll(d) } -func importSSHKeys(keyPaths []string, gpgHome string) error { +func importSSHKeys(logcfg loggingConfig, keyPaths []string, gpgHome string) error { secringPath := filepath.Join(gpgHome, "secring.gpg") secring, err := os.OpenFile(secringPath, os.O_WRONLY|os.O_CREATE, 0600) @@ -570,7 +576,9 @@ func importSSHKeys(keyPaths []string, gpgHome string) error { return fmt.Errorf("Cannot write secring: %w", err) } - fmt.Printf("%s: Imported %s with fingerprint %s\n", path.Base(os.Args[0]), p, hex.EncodeToString(gpgKey.PrimaryKey.Fingerprint[:])) + if logcfg.KeyImport { + fmt.Printf("%s: Imported %s with fingerprint %s\n", path.Base(os.Args[0]), p, hex.EncodeToString(gpgKey.PrimaryKey.Fingerprint[:])) + } } return nil @@ -598,6 +606,157 @@ func importAgeSSHKeys(keyPaths []string, ageFile os.File) error { return nil } +// Like filepath.Walk but symlink-aware. +// Inspired by https://github.com/facebookarchive/symwalk +func symlinkWalk(filename string, linkDirname string, walkFn filepath.WalkFunc) error { + symWalkFunc := func(path string, info os.FileInfo, err error) error { + + if fname, err := filepath.Rel(filename, path); err == nil { + path = filepath.Join(linkDirname, fname) + } else { + return err + } + + if err == nil && info.Mode()&os.ModeSymlink == os.ModeSymlink { + finalPath, err := filepath.EvalSymlinks(path) + if err != nil { + return err + } + info, err := os.Lstat(finalPath) + if err != nil { + return walkFn(path, info, err) + } + if info.IsDir() { + return symlinkWalk(finalPath, path, walkFn) + } + } + + return walkFn(path, info, err) + } + return filepath.Walk(filename, symWalkFunc) +} + +func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret) error { + var restart []string + + newSecrets := make(map[string]bool) + modifiedSecrets := make(map[string]bool) + removedSecrets := 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. + if _, err := os.Stat(symlinkPath); os.IsNotExist(err) { + return nil + } + + // Find modified/new secrets + for _, secret := range secrets { + oldPath := filepath.Join(symlinkPath, secret.Name) + newPath := filepath.Join(secretDir, secret.Name) + + // Read the old file + oldData, err := ioutil.ReadFile(oldPath) + if err != nil { + if os.IsNotExist(err) { + // File did not exist before + restart = append(restart, secret.RestartUnits...) + newSecrets[secret.Name] = true + continue + } + return err + } + + // Read the new file + newData, err := ioutil.ReadFile(newPath) + if err != nil { + return err + } + + if !bytes.Equal(oldData, newData) { + restart = append(restart, secret.RestartUnits...) + modifiedSecrets[secret.Name] = true + } + } + + writeLines := func(list []string, file string) error { + if len(list) != 0 { + f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + defer f.Close() + for _, unit := range list { + if _, err = f.WriteString(unit + "\n"); err != nil { + return err + } + } + } + return nil + } + var dryPrefix string + if isDry { + dryPrefix = "/run/nixos/dry-activation" + } else { + dryPrefix = "/run/nixos/activation" + } + if err := writeLines(restart, dryPrefix+"-restart-list"); err != nil { + return err + } + + // Do not output changes if not requested + if !logcfg.SecretChanges { + return nil + } + + // Find removed secrets + err := symlinkWalk(symlinkPath, symlinkPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + path = strings.TrimPrefix(path, symlinkPath+string(os.PathSeparator)) + for _, secret := range secrets { + if secret.Name == path { + return nil + } + } + removedSecrets[path] = true + return nil + }) + if err != nil { + return err + } + + // Output new/modified/removed secrets + outputChanged := func(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) + } else { + fmt.Printf("%s secret%s: ", regularPrefix, s) + } + comma := "" + for name := range changed { + fmt.Printf("%s%s", comma, name) + comma = ", " + } + fmt.Println() + } + } + outputChanged(newSecrets, "adding", "would add") + outputChanged(modifiedSecrets, "modifying", "would modify") + outputChanged(removedSecrets, "removing", "would remove") + + return nil +} + type keyring struct { path string } @@ -607,14 +766,14 @@ func (k *keyring) Remove() { os.Unsetenv("GNUPGHOME") } -func setupGPGKeyring(sshKeys []string, parentDir string) (*keyring, error) { +func setupGPGKeyring(logcfg loggingConfig, sshKeys []string, parentDir string) (*keyring, error) { dir, err := ioutil.TempDir(parentDir, "gpg") if err != nil { return nil, fmt.Errorf("Cannot create gpg home in '%s': %s", parentDir, err) } k := keyring{dir} - if err := importSSHKeys(sshKeys, dir); err != nil { + if err := importSSHKeys(logcfg, sshKeys, dir); err != nil { os.RemoveAll(dir) return nil, err } @@ -681,12 +840,14 @@ func installSecrets(args []string) error { return err } + isDry := os.Getenv("NIXOS_ACTION") == "dry-activate" + if err := mountSecretFs(manifest.SecretsMountPoint, keysGid); err != nil { return fmt.Errorf("Failed to mount filesystem for secrets: %w", err) } if len(manifest.SSHKeyPaths) != 0 { - keyring, err := setupGPGKeyring(manifest.SSHKeyPaths, manifest.SecretsMountPoint) + keyring, err := setupGPGKeyring(manifest.Logging, manifest.SSHKeyPaths, manifest.SecretsMountPoint) if err != nil { return fmt.Errorf("Error setting up gpg keyring: %w", err) } @@ -740,6 +901,13 @@ func installSecrets(args []string) error { if err := writeSecrets(*secretDir, manifest.Secrets, keysGid); err != nil { return fmt.Errorf("Cannot write secrets: %w", err) } + if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil { + return fmt.Errorf("Cannot request units to restart: %w", err) + } + // No need to perform the actual symlinking + if isDry { + return nil + } if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets); err != nil { return fmt.Errorf("Failed to prepare symlinks to secret store: %w", err) } diff --git a/pkgs/sops-install-secrets/main_test.go b/pkgs/sops-install-secrets/main_test.go index e0bf2ef..2e673d1 100644 --- a/pkgs/sops-install-secrets/main_test.go +++ b/pkgs/sops-install-secrets/main_test.go @@ -99,15 +99,14 @@ func testGPG(t *testing.T) { // should create a symlink yamlSecret := secret{ - Name: "test", - Key: "test_key", - Owner: "nobody", - Group: "nogroup", - SopsFile: path.Join(assets, "secrets.yaml"), - Path: path.Join(testdir.path, "test-target"), - Mode: "0400", - RestartServices: []string{"affected-service"}, - ReloadServices: make([]string, 0), + Name: "test", + Key: "test_key", + Owner: "nobody", + Group: "nogroup", + SopsFile: path.Join(assets, "secrets.yaml"), + Path: path.Join(testdir.path, "test-target"), + Mode: "0400", + RestartUnits: []string{"affected-service"}, } var jsonSecret, binarySecret secret @@ -198,15 +197,14 @@ func testSSHKey(t *testing.T) { file.Close() s := secret{ - Name: "test", - Key: "test_key", - Owner: "nobody", - Group: "nogroup", - SopsFile: path.Join(assets, "secrets.yaml"), - Path: target, - Mode: "0400", - RestartServices: []string{"affected-service"}, - ReloadServices: make([]string, 0), + Name: "test", + Key: "test_key", + Owner: "nobody", + Group: "nogroup", + SopsFile: path.Join(assets, "secrets.yaml"), + Path: target, + Mode: "0400", + RestartUnits: []string{"affected-service"}, } m := manifest{ @@ -231,15 +229,14 @@ func TestAge(t *testing.T) { file.Close() s := secret{ - Name: "test", - Key: "test_key", - Owner: "nobody", - Group: "nogroup", - SopsFile: path.Join(assets, "secrets.yaml"), - Path: target, - Mode: "0400", - RestartServices: []string{"affected-service"}, - ReloadServices: make([]string, 0), + Name: "test", + Key: "test_key", + Owner: "nobody", + Group: "nogroup", + SopsFile: path.Join(assets, "secrets.yaml"), + Path: target, + Mode: "0400", + RestartUnits: []string{"affected-service"}, } m := manifest{ @@ -264,15 +261,14 @@ func TestAgeWithSSH(t *testing.T) { file.Close() s := secret{ - Name: "test", - Key: "test_key", - Owner: "nobody", - Group: "nogroup", - SopsFile: path.Join(assets, "secrets.yaml"), - Path: target, - Mode: "0400", - RestartServices: []string{"affected-service"}, - ReloadServices: make([]string, 0), + Name: "test", + Key: "test_key", + Owner: "nobody", + Group: "nogroup", + SopsFile: path.Join(assets, "secrets.yaml"), + Path: target, + Mode: "0400", + RestartUnits: []string{"affected-service"}, } m := manifest{ @@ -298,15 +294,14 @@ func TestValidateManifest(t *testing.T) { defer testdir.Remove() s := secret{ - Name: "test", - Key: "test_key", - Owner: "nobody", - Group: "nogroup", - SopsFile: path.Join(assets, "secrets.yaml"), - Path: path.Join(testdir.path, "test-target"), - Mode: "0400", - RestartServices: []string{}, - ReloadServices: make([]string, 0), + Name: "test", + Key: "test_key", + Owner: "nobody", + Group: "nogroup", + SopsFile: path.Join(assets, "secrets.yaml"), + Path: path.Join(testdir.path, "test-target"), + Mode: "0400", + RestartUnits: []string{}, } m := manifest{ diff --git a/pkgs/sops-install-secrets/nixos-test.nix b/pkgs/sops-install-secrets/nixos-test.nix index d9f6571..5f60dd9 100644 --- a/pkgs/sops-install-secrets/nixos-test.nix +++ b/pkgs/sops-install-secrets/nixos-test.nix @@ -1,4 +1,4 @@ -{ makeTest ? import , pkgs ? import }: +{ makeTest ? import , pkgs ? (import {}) }: { ssh-keys = makeTest { name = "sops-ssh-keys"; @@ -129,4 +129,110 @@ inherit pkgs; inherit (pkgs) system; }; + +} // pkgs.lib.optionalAttrs (pkgs.lib.versionAtLeast (pkgs.lib.versions.majorMinor pkgs.lib.version) "21.11") { + + restart-and-reload = makeTest { + name = "sops-restart-and-reload"; + machine = { pkgs, lib, config, ... }: { + imports = [ + ../../modules/sops + ]; + + sops = { + age.keyFile = ./test-assets/age-keys.txt; + defaultSopsFile = ./test-assets/secrets.yaml; + secrets.test_key = { + restartUnits = [ "restart-unit.service" "reload-unit.service" ]; + }; + }; + + systemd.services."restart-unit" = { + description = "Restart unit"; + # not started on boot + serviceConfig = { + ExecStart = "/bin/sh -c 'echo ok > /restarted'"; + }; + }; + systemd.services."reload-unit" = { + description = "Restart unit"; + wantedBy = [ "multi-user.target" ]; + reloadIfChanged = true; + 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 + ''; + } { + inherit pkgs; + inherit (pkgs) system; + }; }