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}