secrets.nix

  1# Fleet secrets (`servers.secrets`) from the age-encrypted `secrets` store,
  2# uploaded by Colmena. The same scheme as sovrn's and moods' modules, under
  3# the `servers/` prefix. A role or host declares what it needs:
  4#
  5#   servers.secrets."rclone.conf" = { scope = "host"; };
  6#
  7# and a service reads config.servers.secrets.<name>.path, ordering itself
  8# after config.servers.secrets.<name>.unit. Store naming:
  9#
 10#   shared: servers/shared/<name>           values several hosts use
 11#   host:   servers/hosts/<fqdn>/<name>     per-host values
 12#
 13# Colmena runs `secrets decrypt <store path>` on the deploying machine
 14# (keyCommand), so neither plaintext nor ciphertext enters the Nix store or
 15# the repo. The deployer needs `secrets`, its age identity and SECRETS_DIR.
 16#
 17# Keys land in /var/lib/servers-keys, not Colmena's default /run/keys: /run
 18# is a tmpfs, so a reboot would leave services without their secrets until
 19# the next deploy.
 20#
 21# Several projects' keys share one host's `deployment.keys`, and Colmena
 22# names each key's unit after its attribute (`<attr>-key.service`). The
 23# attribute is therefore prefixed (`servers-<name>`), while `name` keeps the
 24# on-host file name unprefixed.
 25{ config, lib, host, ... }:
 26
 27let
 28  keyDir = "/var/lib/servers-keys";
 29
 30  secretType = lib.types.submodule (
 31    { name, config, ... }:
 32    {
 33      options = {
 34        scope = lib.mkOption {
 35          type = lib.types.enum [ "shared" "host" ];
 36          description = "shared: servers/shared/<name>; host: servers/hosts/<fqdn>/<name>.";
 37        };
 38        name = lib.mkOption {
 39          type = lib.types.str;
 40          default = name;
 41          description = "Store entry and on-host file name.";
 42        };
 43        user = lib.mkOption {
 44          type = lib.types.str;
 45          default = "root";
 46        };
 47        group = lib.mkOption {
 48          type = lib.types.str;
 49          default = "root";
 50        };
 51        mode = lib.mkOption {
 52          type = lib.types.str;
 53          default = "0400";
 54        };
 55        storePath = lib.mkOption {
 56          type = lib.types.str;
 57          readOnly = true;
 58          default =
 59            if config.scope == "shared" then "servers/shared/${config.name}" else "servers/hosts/${host.name}/${config.name}";
 60          description = "Path passed to `secrets decrypt`.";
 61        };
 62        path = lib.mkOption {
 63          type = lib.types.str;
 64          readOnly = true;
 65          default = "${keyDir}/${config.name}";
 66          description = "Where the decrypted file lands on the host.";
 67        };
 68        unit = lib.mkOption {
 69          type = lib.types.str;
 70          readOnly = true;
 71          default = "servers-${config.name}-key.service";
 72          description = "Colmena's unit that uploads this key; order services after it.";
 73        };
 74      };
 75    }
 76  );
 77in
 78{
 79  options.servers.secrets = lib.mkOption {
 80    type = lib.types.attrsOf secretType;
 81    default = { };
 82    description = "Secrets this host receives from the `secrets` store.";
 83  };
 84
 85  config = lib.mkIf (config.servers.secrets != { }) {
 86    deployment.keys = lib.mapAttrs' (
 87      _: s:
 88      lib.nameValuePair "servers-${s.name}" {
 89        inherit (s) name user group;
 90        keyCommand = [ "secrets" "decrypt" s.storePath ];
 91        destDir = keyDir;
 92        permissions = s.mode;
 93        # Service users are created by activation, so a key they own can only
 94        # be chowned after it; root-owned keys go in before activation.
 95        uploadAt = if s.user == "root" && s.group == "root" then "pre-activation" else "post-activation";
 96      }
 97    ) config.servers.secrets;
 98
 99    # Traversable for service users, not listable.
100    systemd.tmpfiles.rules = [ "d ${keyDir} 0711 root root -" ];
101  };
102}