diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | consumers/users.nix | 11 | ||||
-rw-r--r-- | contracts/secrets.nix | 99 | ||||
-rw-r--r-- | default.nix | 41 | ||||
-rw-r--r-- | providers/asecret.nix | 127 |
5 files changed, 277 insertions, 2 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result diff --git a/consumers/users.nix b/consumers/users.nix new file mode 100644 index 0000000..9e4f401 --- /dev/null +++ b/consumers/users.nix @@ -0,0 +1,11 @@ +{ lib, config, ... }: +{ + options = { + userPasswords.secrets.consumer = lib.mkOption { + type = config.contracts.secrets.provider; + }; + }; + config = { + users.users.root.passwordFile = config.userPasswords.secrets.consumer.output.path; + }; +} diff --git a/contracts/secrets.nix b/contracts/secrets.nix new file mode 100644 index 0000000..89b6466 --- /dev/null +++ b/contracts/secrets.nix @@ -0,0 +1,99 @@ +{ lib, ... }: +{ + contracts.secrets = { + meta = { + maintainer = [ "cindi" ]; + description = "provide 'secrets'"; + }; + + input = { + options.type = lib.mkOption { + type = lib.types.str; + default = "random-password"; + }; + }; + + output = { + options.path = lib.mkOption { + type = lib.types.path; + description = '' + Path to the file containing the secret generated out of band. + This path will exist after deploying to a target host, + it is not available through the nix store. + ''; + }; + }; + + behaviorTest = { providerRoot, extraModules ? [] }: { + nodes.machine = { config, ... }: { + imports = extraModules; + + options.test = { + owner = lib.mkOption { + type = lib.types.str; + default = "root"; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "root"; + }; + + mode = lib.mkOption { + type = lib.types.str; + default = "0400"; + }; + + content = lib.mkOption { + type = lib.types.str; + default = "a super secret secret!"; + }; + }; + + config = lib.mkMerge [ + (lib.setAttrByPath providerRoot { + # We set consumer.input and not input directly because the latter is readOnly. + consumer.input = { + inherit (config.test) owner group mode; + }; + }) + (lib.mkIf (config.test.owner != "root") { + users.users.${config.test.owner}.isNormalUser = true; + }) + (lib.mkIf (config.test.group != "root") { + users.groups.${config.test.group} = {}; + }) + ]; + }; + + testScript = { nodes, ... }: + let + cfg = nodes.machine; + inherit (lib.getAttrFromPath providerRoot nodes.machine) output; + in + '' + owner = machine.succeed("stat -c '%U' ${output.path}").strip() + print(f"Got owner {owner}") + if owner != "${cfg.test.owner}": + raise Exception(f"Owner should be '${cfg.test.owner}' but got '{owner}'") + + group = machine.succeed("stat -c '%G' ${output.path}").strip() + print(f"Got group {group}") + if group != "${cfg.test.group}": + raise Exception(f"Group should be '${cfg.test.group}' but got '{group}'") + + mode = str(int(machine.succeed("stat -c '%a' ${output.path}").strip())) + print(f"Got mode {mode}") + wantedMode = str(int("${cfg.test.mode}")) + if mode != wantedMode: + raise Exception(f"Mode should be '{wantedMode}' but got '{mode}'") + + content = machine.succeed("cat ${output.path}").strip() + print(f"Got content {content}") + if content != "${cfg.test.content}": + raise Exception(f"Content should be '${cfg.test.content}' but got '{content}'") + ''; + }; + }; + +} diff --git a/default.nix b/default.nix index 744d2f0..5fe0306 100644 --- a/default.nix +++ b/default.nix @@ -3,10 +3,47 @@ }: with (import ./lib {}); eval { - machines.bob = { self, ... }: { + machines.bob = { self, config, ... }: { + imports = [ + ./consumers/users.nix + ./contracts/secrets.nix + ./providers/asecret.nix + ]; networking.hostName = "bob"; + asecret.secrets.provider = config.userPasswords.secrets; + userPasswords.secrets.consumer = config.asecret.secrets; }; machines.alice = { networking.hostName = "alice"; }; -} +} // +(let + lib = pkgs.lib; + config = {}; +in +{ + test = + let + inherit ((import ./contracts/secrets.nix { + inherit lib; + }).contracts.secrets) behaviorTest; + in + pkgs.testers.runNixOSTest ({ + name = "contracts-filebackup-restic"; + meta.maintainers = [ lib.maintainers.ibizaman ]; + # I tried using the following line but it leads to infinite recursion. + # Instead, I made a hacky import. pkgs.callPackage was also giving an + # infinite recursion. + # + # } // config.contracts.secret.behaviorTest { + # + } // behaviorTest { + providerRoot = [ "testing" "asecret" "mysecret" "secret" ]; + extraModules = [ + ./providers/asecret.nix + ({ config, ... }: { + testing.asecret.mysecret.content = config.test.content; + }) + ]; + }); +}) diff --git a/providers/asecret.nix b/providers/asecret.nix new file mode 100644 index 0000000..df7fe20 --- /dev/null +++ b/providers/asecret.nix @@ -0,0 +1,127 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.asecret; + + inherit (lib) mapAttrs' mkOption nameValuePair; + inherit (lib.types) attrsOf nullOr str submodule; + inherit (pkgs) writeText; +in +{ + options.asecret = mkOption { + default = {}; + description = '' + A secret. These should only be used in tests. + ''; + + example = lib.literalExpression '' + { + mySecret = { + secret.input = { + user = "me"; + mode = "0400"; + restartUnits = [ "myservice.service" ]; + }; + settings.content = "My Secret"; + }; + } + ''; + type = attrsOf (submodule (mod@{ name, options, ... }: { + options = { + mode = mkOption { + description = '' + Mode of the secret file. + ''; + type = str; + default = "0400"; + }; + + owner = mkOption { + description = '' + Linux user owning the secret file. + ''; + type = str; + }; + + group = mkOption { + description = '' + Linux group owning the secret file. + ''; + type = str; + default = options.user.default; + defaultText = "user"; + }; + + content = mkOption { + type = nullOr str; + description = '' + Content of the secret as a string. + + This will be stored in the nix store and should only be used for testing or maybe in dev. + ''; + default = null; + }; + + source = mkOption { + type = nullOr str; + description = '' + Source of the content of the secret as a path in the nix store. + ''; + default = null; + }; + + path = mkOption { + type = str; + description = '' + Path where the secret should be located. + ''; + default = "/run/hardcodedSecrets/hardcodedSecret_${name}"; + }; + + secrets = mkOption { + type = config.contracts.secrets.provider; + }; + }; + + config = { + inherit (mod.config.secret.input) mode owner group; + secret.output.path = mod.config.path; + }; + })); + }; + + config = { + system.activationScripts = mapAttrs' (n: cfg': + let + source = if cfg'.source != null + then cfg'.source + else writeText "hardcodedSecret_${n}_content" cfg'.content; + in + nameValuePair "hardcodedSecret_${n}" '' + ( + set -e + mkdir -p "$(dirname "${cfg'.path}")" + touch "${cfg'.path}" + chmod ${cfg'.mode} "${cfg'.path}" + chown ${cfg'.owner}:${cfg'.group} "${cfg'.path}" + cp ${source} "${cfg'.path}" + ) || echo "Failed to create hardcoded secret at ${cfg'.path}" + '' + ) cfg; + }; + + # Without `meta.buildDocsInSandbox = false;`, I get: + # + # > error: attribute 'contracts' missing + # > at /nix/store/2gd9yzcfpqqp00vskxlqq4ds48mpgdzv-nixos/modules/testing/hardcodedSecret.nix:81:18: + # > 80| secret = mkOption { + # > 81| type = config.contracts.secret.provider; + # > | ^ + # > 82| }; + # > Cacheable portion of option doc build failed. + # > Usually this means that an option attribute that ends up in documentation (eg `default` or `description`) depends on the restricted module arguments `config` or `pkgs`. + # > + # > Rebuild your configuration with `--show-trace` to find the offending location. Remove the references to restricted arguments (eg by escaping their antiquotations or adding a `defaultText`) or disable the sandboxed build for the failing module by setting `meta.buildDocsInSandbox = false`. + # + # With the line, I don't get the warning but still get the missing 'contracts' attribute error. + meta.buildDocsInSandbox = false; +} |