├── example
├── service_password.age
└── service_password2.age
├── example_keys
├── system1
└── system1.pub
├── modules
└── age.nix
└── test
├── install_ssh_host_keys.nix
└── integration.nix
/example/service_password.age:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/korfuri/agenix-systemd/abf6c934ba3af682a838670e51357ffa0069f9c0/example/service_password.age
--------------------------------------------------------------------------------
/example/service_password2.age:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/korfuri/agenix-systemd/abf6c934ba3af682a838670e51357ffa0069f9c0/example/service_password2.age
--------------------------------------------------------------------------------
/example_keys/system1:
--------------------------------------------------------------------------------
1 | -----BEGIN OPENSSH PRIVATE KEY-----
2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
3 | QyNTUxOQAAACDyQ8iK/xUs9XCXXKFuvUfja1s8Biv/t4Caag9bfC9sxAAAAJA3yvCWN8rw
4 | lgAAAAtzc2gtZWQyNTUxOQAAACDyQ8iK/xUs9XCXXKFuvUfja1s8Biv/t4Caag9bfC9sxA
5 | AAAEA+J2V6AG1NriAIvnNKRauIEh1JE9HSdhvKJ68a5Fm0w/JDyIr/FSz1cJdcoW69R+Nr
6 | WzwGK/+3gJpqD1t8L2zEAAAADHJ5YW50bUBob21lMQE=
7 | -----END OPENSSH PRIVATE KEY-----
8 |
--------------------------------------------------------------------------------
/example_keys/system1.pub:
--------------------------------------------------------------------------------
1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE
2 |
--------------------------------------------------------------------------------
/modules/age.nix:
--------------------------------------------------------------------------------
1 | { config, options, lib, pkgs, ... }:
2 | with lib;
3 | let
4 | cfg = config.age;
5 |
6 | # we need at least rage 0.5.0 to support ssh keys
7 | rage =
8 | if lib.versionOlder pkgs.rage.version "0.5.0"
9 | then pkgs.callPackage ../pkgs/rage.nix { }
10 | else pkgs.rage;
11 | ageBin = config.age.ageBin;
12 | systemdCredsBin = config.age.systemdCredsBin;
13 |
14 | identities = builtins.concatStringsSep " " (map (path: "-i ${path}") cfg.identityPaths);
15 |
16 | installSecret = secretType: ''
17 | set -xe
18 | echo "[agenix] Installing secret ${secretType.name}"
19 | _truePath="${secretType.path}"
20 | TMP_FILE="$_truePath.tmp"
21 | mkdir -p "$(dirname "$_truePath")"
22 | (
23 | umask u=r,g=,o=
24 | test -f "${secretType.file}" || echo '[agenix] WARNING: encrypted file ${secretType.file} does not exist!'
25 | test -d "$(dirname "$TMP_FILE")" || echo "[agenix] WARNING: $(dirname "$TMP_FILE") does not exist!"
26 | ${ageBin} --decrypt ${identities} -o - ${secretType.file} | ${systemdCredsBin} ${cfg.systemd-creds-flags} encrypt --name="${secretType.name}" - - > "$TMP_FILE" || echo "[agenix] Something went wrong!"
27 | )
28 | chmod 600 "$TMP_FILE"
29 | chown 0:0 "$TMP_FILE"
30 | mv -f "$TMP_FILE" "$_truePath"
31 | '';
32 |
33 | secretType = types.submodule ({ config, ... }: {
34 | options = {
35 | name = mkOption {
36 | type = types.str;
37 | default = config._module.args.name;
38 | description = ''
39 | Name of the file used in ''${cfg.secretsDir}
40 | '';
41 | };
42 | file = mkOption {
43 | type = types.path;
44 | description = ''
45 | Age file the secret is loaded from.
46 | '';
47 | };
48 | path = mkOption {
49 | type = types.str;
50 | default = "${cfg.secretsDir}/${config.name}";
51 | description = ''
52 | Path where the decrypted secret is installed.
53 | '';
54 | };
55 | unitConfig = mkOption {
56 | type = types.attrs;
57 | default = {};
58 | description = ''
59 | Attributes of the systemd unit that will install this secret.
60 | Useful to set reverse dependencies (WantedBy=, Before=).
61 | '';
62 | example = ''
63 | {
64 | serviceConfig = {
65 | wantedBy = [ "nginx.service" ];
66 | before = [ "nginx.service" ];
67 | };
68 | }
69 | '';
70 | };
71 |
72 | # Options below this line are meant to be read-only.
73 |
74 | serviceNameShort = mkOption {
75 | type = types.str;
76 | default = "agenix-systemd-${config.name}";
77 | description = ''
78 | Name of the systemd service installing this secret, without
79 | the .service extension.
80 | '';
81 | example = "agenix-systemd-service_password";
82 | };
83 | serviceName = mkOption {
84 | type = types.str;
85 | default = "${config.serviceNameShort}.service";
86 | description = ''
87 | Name of the systemd service installing this
88 | secret. Autogenerated, you can use this to depend on this
89 | secret being installed.
90 | '';
91 | example = "agenix-systemd-service_password.service";
92 | };
93 | credentialConfig = mkOption {
94 | type = types.str;
95 | default = "${config.name}:${config.path}";
96 | description = ''
97 | String to pass to
98 | LoadCredentialEncrypted= to enable a
99 | service to depend on this secret.
100 | '';
101 | example = "service_password:/var/run/agenix/service_password";
102 | };
103 | credentialPath = mkOption {
104 | type = types.str;
105 | default = "$CREDENTIALS_DIRECTORY/${config.name}";
106 | description = ''
107 | Path to the decrypted credential file, usable inside a
108 | service definition. Always under
109 | $CREDENTIALS_DIRECTORY.
110 | '';
111 | example = "$CREDENTIALS_DIRECTORY/service_password";
112 | };
113 | unitTemplate = mkOption {
114 | type = types.anything;
115 | default = {
116 | requires = [ config.serviceName ];
117 | after = [ config.serviceName ];
118 | serviceConfig = {
119 | LoadCredentialEncrypted = config.credentialConfig;
120 | };
121 | };
122 | description = ''
123 | Template of a service config that enables a service to depend on this secret.
124 |
125 | May be used as:
126 |
127 | systemd.services.nginx = config.age.mysecret.unitTemplate;
128 | .
129 |
130 | Do not use with //, use wrapUnit instead.
131 | '';
132 | };
133 | wrapUnit = mkOption {
134 | type = types.anything;
135 | default = x: lib.recursiveUpdate x config.unitTemplate;
136 | description = ''
137 | Function that transforms a systemd unit definition into one that will be able to access this secret.
138 | '';
139 | };
140 | };
141 | });
142 |
143 | mkService = _: secretType: lib.nameValuePair secretType.serviceNameShort (
144 | lib.recursiveUpdate {
145 | enable = true;
146 | description = "Install secret: ${secretType.name}";
147 | script = installSecret secretType;
148 | restartIfChanged = true;
149 | serviceConfig = {
150 | Type = "oneshot";
151 | RemainAfterExit = true;
152 | };
153 | } secretType.unitConfig);
154 |
155 | allServices = lib.mapAttrs' mkService cfg.secrets;
156 | in {
157 | options.age = {
158 | ageBin = mkOption {
159 | type = types.str;
160 | default = "${rage}/bin/rage";
161 | description = ''
162 | The age executable to use.
163 | '';
164 | };
165 | systemdCredsBin = mkOption {
166 | type = types.str;
167 | default = "${pkgs.systemd}/bin/systemd-creds";
168 | description = ''
169 | The systemd-creds executable to use.
170 | '';
171 | };
172 | systemd-creds-flags = mkOption {
173 | type = types.str;
174 | default = "";
175 | description = ''
176 | Extra flags to systemd-creds.
177 | '';
178 | example = "-H";
179 | };
180 | secrets = mkOption {
181 | type = types.attrsOf secretType;
182 | default = { };
183 | description = ''
184 | Attrset of secrets.
185 | '';
186 | };
187 | secretsDir = mkOption {
188 | type = types.path;
189 | default = "/run/agenix";
190 | description = ''
191 | Folder where secrets are symlinked to
192 | '';
193 | };
194 | identityPaths = mkOption {
195 | type = types.listOf types.path;
196 | default =
197 | if config.services.openssh.enable then
198 | map (e: e.path) (lib.filter (e: e.type == "rsa" || e.type == "ed25519") config.services.openssh.hostKeys)
199 | else [ ];
200 | description = ''
201 | Path to SSH keys to be used as identities in age decryption.
202 | '';
203 | };
204 | };
205 |
206 | config = mkIf (cfg.secrets != { }) {
207 | assertions = [{
208 | assertion = cfg.identityPaths != [ ];
209 | message = "age.identityPaths must be set.";
210 | }];
211 |
212 | systemd.services = allServices;
213 | };
214 | }
215 |
--------------------------------------------------------------------------------
/test/install_ssh_host_keys.nix:
--------------------------------------------------------------------------------
1 | # Do not copy this! It is insecure. This is only okay because we are testing.
2 | {
3 | system.activationScripts.installSSHHostKeys.text = ''
4 | mkdir -p /etc/ssh
5 | (umask u=rw,g=r,o=r; cp ${../example_keys/system1.pub} /etc/ssh/ssh_host_ed25519_key.pub)
6 | (
7 | umask u=rw,g=,o=
8 | cp ${../example_keys/system1} /etc/ssh/ssh_host_ed25519_key
9 | touch /etc/ssh/ssh_host_rsa_key
10 | )
11 |
12 | '';
13 | }
14 |
--------------------------------------------------------------------------------
/test/integration.nix:
--------------------------------------------------------------------------------
1 | {
2 | nixpkgs ? ,
3 | pkgs ? import { inherit system; config = {}; },
4 | system ? builtins.currentSystem
5 | } @args:
6 |
7 | import "${nixpkgs}/nixos/tests/make-test-python.nix" ({ pkgs, ...}: {
8 | name = "agenix-integration-systemd";
9 |
10 | nodes.system1 = { config, lib, ... }: {
11 |
12 | imports = [
13 | ../modules/age.nix
14 | ./install_ssh_host_keys.nix
15 | ];
16 |
17 | services.openssh.enable = true;
18 |
19 | age.systemd-creds-flags = "-H";
20 | age.secrets.service_password = {
21 | file = ../example/service_password.age;
22 | };
23 | age.secrets.service_password2 = {
24 | file = ../example/service_password2.age;
25 | unitConfig = {
26 | wantedBy = [ "dumpcred2.service" ];
27 | before = [ "dumpcred2.service" ];
28 | };
29 | };
30 |
31 | # Test that a unit that depends on the password being installed
32 | # can access the password.
33 | systemd.services.dumpcred = config.age.secrets.service_password.wrapUnit {
34 | enable = true;
35 | description = "Dumps credentials to /tmp";
36 | script = ''
37 | systemd-creds cat service_password | tee /tmp/1
38 | cat ${config.age.secrets.service_password.credentialPath} | tee /tmp/2
39 | '';
40 | serviceConfig = {
41 | Type = "oneshot";
42 | };
43 | };
44 |
45 | # This is a repeat of the previous test, but this depends on the
46 | # credentials through a reverse-dependency in systemd units.
47 | systemd.services.dumpcred2 = {
48 | enable = true;
49 | description = "Dumps credentials to /tmp";
50 | script = ''
51 | systemd-creds cat service_password2 | tee /tmp/3
52 | '';
53 |
54 | serviceConfig = {
55 | LoadCredentialEncrypted = config.age.secrets.service_password2.credentialConfig;
56 | Type = "oneshot";
57 | };
58 | };
59 |
60 | # Demonstrates and tests that we can make existing services depend
61 | # on a secret without modifying them. We use Caddy because it
62 | # allows substituting environment variables in its config. This is
63 | # not the case e.g. for nginx, unfortunately.
64 | services.caddy = {
65 | enable = true;
66 | configFile = pkgs.writeText "Caddyfile" ''
67 | http://localhost
68 |
69 | root * {$CREDENTIALS_DIRECTORY}
70 | file_server
71 | '';
72 | };
73 | systemd.services.caddy = config.age.secrets.service_password.unitTemplate;
74 | };
75 |
76 | testScript =
77 | let
78 | secret = "cleartextABCDEF";
79 | in ''
80 | system1.wait_for_unit("multi-user.target") # Uncomment to make the logs easier to read
81 | system1.succeed("systemctl start dumpcred")
82 | assert "${secret}" in system1.succeed("cat /tmp/1"), "systemd-creds could not cat the secret"
83 | assert "${secret}" in system1.succeed("cat /tmp/2"), "cat credentialPath could not access the secret"
84 |
85 | # TODO: Run the test again, ensure that the secret is not decrypted twice
86 |
87 | system1.succeed("systemctl start dumpcred2")
88 | assert "${secret}" in system1.succeed("cat /tmp/3"), "reverse-dependency dumpcred2 did not succeed"
89 |
90 | system1.wait_for_unit("caddy.service")
91 | assert "${secret}" in system1.succeed("curl -q http://localhost/service_password"), "nginx cannot serve the secret"
92 | '';
93 | }) args
94 |
--------------------------------------------------------------------------------