diff --git a/modules/sops/default.nix b/modules/sops/default.nix index f0d0fd3..5e28962 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -110,6 +110,7 @@ let # 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; @@ -164,6 +165,14 @@ in { ''; }; + keepGenerations = mkOption { + type = types.ints.unsigned; + default = 1; + description = '' + Number of secrets generations to keep. Setting this to 0 disables pruning. + ''; + }; + log = mkOption { type = types.listOf (types.enum [ "keyImport" "secretChanges" ]); default = [ "keyImport" "secretChanges" ]; diff --git a/pkgs/sops-install-secrets/main.go b/pkgs/sops-install-secrets/main.go index 2949c5e..e911bc8 100644 --- a/pkgs/sops-install-secrets/main.go +++ b/pkgs/sops-install-secrets/main.go @@ -51,6 +51,7 @@ 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"` @@ -554,6 +555,47 @@ func atomicSymlink(oldname, newname string) error { return os.RemoveAll(d) } +func pruneGenerations(secretsMountPoint, secretsDir string, keepGenerations int) error { + if keepGenerations == 0 { + return nil // Nothing to prune + } + + // Prepare our failsafe + currentGeneration, err := strconv.Atoi(path.Base(secretsDir)) + if err != nil { + return fmt.Errorf("Logic error, current generation is not numeric: %w", err) + } + + // Read files in the mount directory + file, err := os.Open(secretsMountPoint) + if err != nil { + return fmt.Errorf("Cannot open %s: %w", secretsMountPoint, err) + } + defer file.Close() + + generations, err := file.Readdirnames(0) + if err != nil { + return fmt.Errorf("Cannot read %s: %w", secretsMountPoint, err) + } + for _, generationName := range generations { + generationNum, err := strconv.Atoi(generationName) + // Not a number? Not relevant + if err != nil { + continue + } + // Not strictly necessary but a good failsafe to + // make sure we don't prune the current generation + if generationNum == currentGeneration { + continue + } + if currentGeneration-keepGenerations >= generationNum { + os.RemoveAll(path.Join(secretsMountPoint, generationName)) + } + } + + return nil +} + func importSSHKeys(logcfg loggingConfig, keyPaths []string, gpgHome string) error { secringPath := filepath.Join(gpgHome, "secring.gpg") @@ -920,6 +962,9 @@ func installSecrets(args []string) error { if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil { return fmt.Errorf("Cannot update secrets symlink: %w", err) } + if err := pruneGenerations(manifest.SecretsMountPoint, *secretDir, manifest.KeepGenerations); err != nil { + return fmt.Errorf("Cannot prune old secrets generations: %w", err) + } return nil diff --git a/pkgs/sops-install-secrets/nixos-test.nix b/pkgs/sops-install-secrets/nixos-test.nix index 228dafe..eed8d57 100644 --- a/pkgs/sops-install-secrets/nixos-test.nix +++ b/pkgs/sops-install-secrets/nixos-test.nix @@ -51,6 +51,47 @@ inherit (pkgs) system; }; + pruning = makeTest { + name = "sops-pruning"; + machine = { lib, ... }: { + imports = [ ../../modules/sops ]; + sops = { + age.keyFile = ./test-assets/age-keys.txt; + defaultSopsFile = ./test-assets/secrets.yaml; + secrets.test_key = {}; + keepGenerations = lib.mkDefault 0; + }; + + 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") + ''; + } { + inherit pkgs; + inherit (pkgs) system; + }; + age-keys = makeTest { name = "sops-age-keys"; machine = {