From 10dea06ec153e4746a116c366cb8bedc4c4deed1 Mon Sep 17 00:00:00 2001 From: Pierre Penninckx Date: Thu, 22 Aug 2024 12:48:36 -0700 Subject: [PATCH] Fix backup contract secrets (#280) --- lib/default.nix | 45 +++++++++++++- modules/blocks/monitoring.nix | 2 +- modules/blocks/restic.nix | 87 ++++++++++++++++++++++----- modules/blocks/restic/docs/default.md | 4 +- modules/services/arr.nix | 2 +- modules/services/nextcloud-server.nix | 3 +- test/blocks/restic.nix | 24 ++++++++ 7 files changed, 145 insertions(+), 22 deletions(-) diff --git a/lib/default.nix b/lib/default.nix index dde07f6..2b8ccd1 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,4 +1,8 @@ { pkgs, lib }: +let + inherit (builtins) isAttrs hasAttr; + inherit (lib) concatStringsSep; +in rec { # Replace secrets in a file. # - userConfig is an attrset that will produce a config file. @@ -228,8 +232,47 @@ rec { results = pkgs.lib.runTests tests; in if results != [ ] then - builtins.throw (builtins.concatStringsSep "\n" (map resultToString results)) + builtins.throw (builtins.concatStringsSep "\n" (map resultToString (lib.traceValSeqN 3 results))) else pkgs.runCommand "nix-flake-tests-success" { } "echo > $out"; + + genConfigOutOfBandSystemd = { config, configLocation, generator }: + { + loadCredentials = getLoadCredentials "source" config; + preStart = lib.mkBefore (replaceSecrets { + userConfig = updateToLoadCredentials "source" "$CREDENTIALS_DIRECTORY" config; + resultPath = configLocation; + inherit generator; + }); + }; + + updateToLoadCredentials = sourceField: rootDir: attrs: + let + hasPlaceholderField = v: isAttrs v && hasAttr sourceField v; + + valueOrLoadCredential = path: value: + if ! (hasPlaceholderField value) + then value + else value // { ${sourceField} = rootDir + "/" + concatStringsSep "_" path; }; + in + mapAttrsRecursiveCond (v: ! (hasPlaceholderField v)) valueOrLoadCredential attrs; + + getLoadCredentials = sourceField: attrs: + let + hasPlaceholderField = v: isAttrs v && hasAttr sourceField v; + + addPathField = path: value: + if ! (hasPlaceholderField value) + then value + else value // { inherit path; }; + + secretsWithPath = mapAttrsRecursiveCond (v: ! (hasPlaceholderField v)) addPathField attrs; + + allSecrets = collect (v: hasPlaceholderField v) secretsWithPath; + + genLoadCredentials = secret: + "${concatStringsSep "_" secret.path}:${secret.${sourceField}}"; + in + map genLoadCredentials allSecrets; } diff --git a/modules/blocks/monitoring.nix b/modules/blocks/monitoring.nix index 7d813ec..ca2630f 100644 --- a/modules/blocks/monitoring.nix +++ b/modules/blocks/monitoring.nix @@ -1,4 +1,4 @@ -{ config, options, pkgs, lib, ... }: +{ config, pkgs, lib, ... }: let cfg = config.shb.monitoring; diff --git a/modules/blocks/restic.nix b/modules/blocks/restic.nix index 24014b3..849925d 100644 --- a/modules/blocks/restic.nix +++ b/modules/blocks/restic.nix @@ -13,6 +13,26 @@ let type = lib.types.path; }; + user = lib.mkOption { + description = '' + Unix user doing the backups. + + For Restic, the same user must be used for all instances. + ''; + type = lib.types.str; + default = cfg.user; + }; + + group = lib.mkOption { + description = '' + Unix group doing the backups. + + For Restic, the same group must be used for all instances. + ''; + type = lib.types.str; + default = cfg.group; + }; + sourceDirectories = lib.mkOption { description = "Source directories."; type = lib.types.nonEmptyListOf lib.types.str; @@ -33,13 +53,15 @@ let description = "Repository location"; }; - extraSecrets = lib.mkOption { + secrets = lib.mkOption { type = lib.types.attrsOf shblib.secretFileType; default = {}; description = '' - Extra secrets needed to access the repository where the backups will be stored. + Secrets needed to access the repository where the backups will be stored. + + See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example + and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets. - See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example. ''; example = lib.literalExpression '' { @@ -154,6 +176,17 @@ in enabledInstances = lib.attrsets.filterAttrs (k: i: i.enable) cfg.instances; in lib.mkMerge [ { + assertions = [ + { + assertion = lib.all (x: x.user == cfg.user) (lib.mapAttrsToList (n: v: v)cfg.instances); + message = "All Restic instances must have the same user as 'shb.restic.user'."; + } + { + assertion = lib.all (x: x.group == cfg.group) (lib.mapAttrsToList (n: v: v) cfg.instances); + message = "All Restic instances must have the same group as 'shb.restic.group'."; + } + ]; + users.users = { ${cfg.user} = { name = cfg.user; @@ -195,12 +228,6 @@ in backupPrepareCommand = lib.strings.concatStringsSep "\n" instance.hooks.before_backup; backupCleanupCommand = lib.strings.concatStringsSep "\n" instance.hooks.after_backup; - } // lib.attrsets.optionalAttrs (repository.extraSecrets != {}) { - environmentFile = shblib.replaceSecrets { - userConfig = repository.extraSecrets; - resultPath = "/var/lib/backup/${name}"; - generator = name: v: pkgs.writeText "template" (lib.generators.toINIWithGlobalSection {} v); - }; } // lib.attrsets.optionalAttrs (builtins.length instance.excludePatterns > 0) { exclude = instance.excludePatterns; }; @@ -212,12 +239,42 @@ in systemd.services = let - mkRepositorySettings = name: instance: repository: { - "restic-backups-${name}_${repoSlugName repository.path}".serviceConfig = { - Nice = cfg.performance.niceness; - IOSchedulingClass = cfg.performance.ioSchedulingClass; - IOSchedulingPriority = cfg.performance.ioPriority; - }; + mkRepositorySettings = name: instance: repository: + let + serviceName = "restic-backups-${name}_${repoSlugName repository.path}"; + in + { + ${serviceName} = lib.mkMerge [ + { + serviceConfig = { + Nice = cfg.performance.niceness; + IOSchedulingClass = cfg.performance.ioSchedulingClass; + IOSchedulingPriority = cfg.performance.ioPriority; + }; + } + (lib.attrsets.optionalAttrs (repository.secrets != {}) + { + serviceConfig.EnvironmentFile = [ + "/run/secrets/restic/${serviceName}" + ]; + after = [ "${serviceName}-pre.service" ]; + requires = [ "${serviceName}-pre.service" ]; + }) + ]; + + "${serviceName}-pre" = lib.mkIf (repository.secrets != {}) + (let + script = shblib.genConfigOutOfBandSystemd { + config = repository.secrets; + configLocation = "/run/secrets/restic/${serviceName}"; + generator = name: v: pkgs.writeText "template" (lib.generators.toINIWithGlobalSection {} { globalSection = v; }); + }; + in + { + script = script.preStart; + serviceConfig.Type = "oneshot"; + serviceConfig.LoadCredential = script.loadCredentials; + }); }; mkSettings = name: instance: builtins.map (mkRepositorySettings name instance) instance.repositories; in diff --git a/modules/blocks/restic/docs/default.md b/modules/blocks/restic/docs/default.md index 7e09165..477aa7c 100644 --- a/modules/blocks/restic/docs/default.md +++ b/modules/blocks/restic/docs/default.md @@ -76,8 +76,8 @@ This assumes you have access to such a remote S3 store, for example by using [Ba }; + extraSecrets = { -+ AWS_ACCESS_KEY_ID=""; -+ AWS_SECRET_ACCESS_KEY=""; ++ AWS_ACCESS_KEY_ID.source=""; ++ AWS_SECRET_ACCESS_KEY.source=""; + }; }]; } diff --git a/modules/services/arr.nix b/modules/services/arr.nix index 3cbb4cc..2452794 100644 --- a/modules/services/arr.nix +++ b/modules/services/arr.nix @@ -1,4 +1,4 @@ -{ config, options, pkgs, lib, ... }: +{ config, pkgs, lib, ... }: let cfg = config.shb.arr; diff --git a/modules/services/nextcloud-server.nix b/modules/services/nextcloud-server.nix index c252164..7e0f7a4 100644 --- a/modules/services/nextcloud-server.nix +++ b/modules/services/nextcloud-server.nix @@ -1,8 +1,7 @@ -{ config, options, pkgs, lib, ... }: +{ config, pkgs, lib, ... }: let cfg = config.shb.nextcloud; - opt = options.shb.nextcloud; fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}"; diff --git a/test/blocks/restic.nix b/test/blocks/restic.nix index 12863c5..79ab5e5 100644 --- a/test/blocks/restic.nix +++ b/test/blocks/restic.nix @@ -3,6 +3,7 @@ let pkgs' = pkgs; testLib = pkgs.callPackage ../common.nix {}; + shblib = pkgs.callPackage ../../lib {}; base = testLib.base [ ../../modules/blocks/restic.nix @@ -38,6 +39,12 @@ in OnCalendar = "00:00:00"; RandomizedDelaySec = "5h"; }; + # Those are not needed by the repository but are still included + # so we can test them in the hooks section. + secrets = { + A.source = pkgs.writeText "A" "secretA"; + B.source = pkgs.writeText "B" "secretB"; + }; } { path = "/opt/repos/B"; @@ -47,6 +54,23 @@ in }; } ]; + + hooks.before_backup = ['' + echo $RUNTIME_DIRECTORY + if [ "$RUNTIME_DIRECTORY" = /run/restic-backups-testinstance_opt_repos_A ]; then + if ! [ -f /run/secrets/restic/restic-backups-testinstance_opt_repos_A ]; then + exit 10 + fi + if [ -z "$A" ] || ! [ "$A" = "secretA" ]; then + echo "A:$A" + exit 11 + fi + if [ -z "$B" ] || ! [ "$B" = "secretB" ]; then + echo "A:$A" + exit 12 + fi + fi + '']; }; };