diff --git a/modules/sops/default.nix b/modules/sops/default.nix index d17fa9a..3245444 100644 --- a/modules/sops/default.nix +++ b/modules/sops/default.nix @@ -294,6 +294,7 @@ in { }; }; imports = [ + ./templates (mkRenamedOptionModule [ "sops" "gnupgHome" ] [ "sops" "gnupg" "home" ]) (mkRenamedOptionModule [ "sops" "sshKeyPaths" ] [ "sops" "gnupg" "sshKeyPaths" ]) ]; diff --git a/modules/sops/templates/default.nix b/modules/sops/templates/default.nix new file mode 100644 index 0000000..4a6dcf9 --- /dev/null +++ b/modules/sops/templates/default.nix @@ -0,0 +1,102 @@ +{ config, pkgs, lib, options, ... }: +with lib; +with lib.types; +with builtins; +let + cfg = config.sops; + secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets; +in { + options.sops = { + templates = mkOption { + type = attrsOf (submodule ({ config, ... }: { + options = { + name = mkOption { + type = str; + default = config._module.args.name; + description = '' + Name of the file used in /run/secrets-rendered + ''; + }; + path = mkOption { + type = str; + default = "/run/secrets-rendered/${config.name}"; + }; + content = mkOption { + type = lines; + default = ""; + description = '' + Content of the file + ''; + }; + mode = mkOption { + type = str; + default = "0400"; + description = '' + Permissions mode of the rendered secret file in octal. + ''; + }; + owner = mkOption { + type = str; + default = "root"; + description = '' + User of the file. + ''; + }; + group = mkOption { + type = str; + default = config.users.users.${config.owner}.group; + description = '' + Group of the file. + ''; + }; + file = mkOption { + type = types.path; + default = pkgs.writeText config.name config.content; + visible = false; + readOnly = true; + }; + }; + })); + default = { }; + }; + placeholder = mkOption { + type = attrsOf (mkOptionType { + name = "coercibleToString"; + description = "value that can be coerced to string"; + check = strings.isCoercibleToString; + merge = 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)} + ''); + }); +} diff --git a/modules/sops/templates/subs.py b/modules/sops/templates/subs.py new file mode 100644 index 0000000..d523719 --- /dev/null +++ b/modules/sops/templates/subs.py @@ -0,0 +1,25 @@ +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() + 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/pkgs/sops-install-secrets/nixos-test.nix b/pkgs/sops-install-secrets/nixos-test.nix index bb22f1b..d055abc 100644 --- a/pkgs/sops-install-secrets/nixos-test.nix +++ b/pkgs/sops-install-secrets/nixos-test.nix @@ -202,6 +202,54 @@ inherit (pkgs) system; }; + templates = makeTest { + name = "sops-templates"; + nodes.machine = { config, ... }: { + imports = [ ../../modules/sops ]; + sops = { + age.keyFile = ./test-assets/age-keys.txt; + defaultSopsFile = ./test-assets/secrets.yaml; + secrets.test_key = { }; + }; + + 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"; + }; + + 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' ]") + + 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") + + if rendered.strip() != expected.strip(): + raise Exception("Template is not rendered correctly") + ''; + } { + inherit pkgs; + inherit (pkgs) system; + }; + restart-and-reload = makeTest { name = "sops-restart-and-reload"; nodes.machine = { pkgs, lib, config, ... }: {