├── 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 | --------------------------------------------------------------------------------