Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added example/secret1-copy.age
Binary file not shown.
1 change: 1 addition & 0 deletions example/secrets.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ let
in
{
"secret1.age".publicKeys = [ user1 system1 ];
"secret1-copy.age".publicKeys = [ user1 system1 ];
"secret2.age".publicKeys = [ user1 ];
"passwordfile-user1.age".publicKeys = [ user1 system1 ];
}
8 changes: 4 additions & 4 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
description = "Secret management with age";

inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-21.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
};

outputs = { self, nixpkgs }:
Expand Down
71 changes: 63 additions & 8 deletions modules/age.nix
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{ config, options, lib, pkgs, ... }:
{ config, options, lib, utils, pkgs, ... }:

with lib;

Expand Down Expand Up @@ -33,11 +33,38 @@ let
)
chmod ${secretType.mode} "$TMP_FILE"
chown ${secretType.owner}:${secretType.group} "$TMP_FILE"

# path to the old version of the secret. cfg.secretsDir has already been
# updated to point at the latest generation, so we have to revert those
# paths.
outputPath="${cfg.secretsDir}/"
currentGenerationPath="${cfg.secretsMountPoint}/$_agenix_generation/"
previousGenerationPath="${cfg.secretsMountPoint}/$(( _agenix_generation - 1 ))/"
_oldPath=$(realpath ${secretType.path})
_oldPath=''${_oldPath/#$currentGenerationPath/$previousGenerationPath}
_oldPath=''${_oldPath/#$outputPath/$previousGenerationPath}
[ -f $_oldPath ] && {
changes=$(${lib.concatStringsSep " " [
"${pkgs.rsync}/bin/rsync"
"--dry-run -i" # just print what changed, don't do anything
"-aHAX" # care about everything (e.g. file permissions)
"--no-t -c" # but don't care about last modified timestamps
"$TMP_FILE" "$_oldPath"
]})
} || changes=true # _oldPath doesn't exist, so count it as a change

mv -f "$TMP_FILE" "$_truePath"

${optionalString secretType.symlink ''
[ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && ln -sfn "${cfg.secretsDir}/${secretType.name}" "${secretType.path}"
''}

# if /run/nixos doesn't exist, this is boot up and we don't need to activate the scripts.
[ "$changes" != "" ] && [ -d "/run/nixos" ] && {
echo '${lib.concatStringsSep "\n" secretType.reloadUnits}' >> /run/nixos/activation-reload-list
echo '${lib.concatStringsSep "\n" secretType.restartUnits}' >> /run/nixos/activation-restart-list
${secretType.onChange}
}
'';

testIdentities = map (path: ''
Expand Down Expand Up @@ -96,6 +123,23 @@ let
Group of the decrypted secret.
'';
};
onChange = mkOption {
type = types.str;
default = "";
description = "A script to run when secret is updated.";
};
reloadUnits = mkOption {
type = types.listOf utils.systemdUtils.lib.unitNameType;
default = [];
description = "The systemd services to reload when the secret changes.";
example = literalExpression ''[ "wireguard-wg0.service" ]'';
};
restartUnits = mkOption {
type = types.listOf utils.systemdUtils.lib.unitNameType;
default = [];
description = "The systemd services to restart when the secret changes.";
example = literalExpression ''[ "wireguard-wg0.service" ]'';
};
symlink = mkEnableOption "symlinking secrets to their destination" // { default = true; };
};
});
Expand Down Expand Up @@ -172,11 +216,6 @@ in
mkdir -p "${cfg.secretsMountPoint}/$_agenix_generation"
chmod 0751 "${cfg.secretsMountPoint}/$_agenix_generation"
ln -sfn "${cfg.secretsMountPoint}/$_agenix_generation" ${cfg.secretsDir}

(( _agenix_generation > 1 )) && {
echo "[agenix] removing old secrets (generation $(( _agenix_generation - 1 )))..."
rm -rf "${cfg.secretsMountPoint}/$(( _agenix_generation - 1 ))"
}
'';
deps = [
"specialfs"
Expand Down Expand Up @@ -205,7 +244,7 @@ in
};

# Other secrets need to wait for users and groups to exist.
system.activationScripts.agenix = {
system.activationScripts.agenixNonRoot = {
text = installNonRootSecrets;
deps = [
"users"
Expand All @@ -215,6 +254,22 @@ in
"agenixChownKeys"
];
};
};

# named "agenix" for others to depend on the last activationScript (and thereby all the rest)
system.activationScripts.agenix = {
# cleanup old generation
text = ''
(( _agenix_generation > 1 )) && {
echo "[agenix] removing old secrets (generation $(( _agenix_generation - 1 )))..."
rm -rf "${cfg.secretsMountPoint}/$(( _agenix_generation - 1 ))"
}
'';
deps = [
"specialfs"
"agenixMountSecrets"
"agenixRoot"
"agenixNonRoot"
];
};
};
}
1 change: 0 additions & 1 deletion test/install_ssh_host_keys.nix
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
(
umask u=rw,g=,o=
cp ${../example_keys/system1} /etc/ssh/ssh_host_ed25519_key
touch /etc/ssh/ssh_host_rsa_key
)

'';
Expand Down
222 changes: 173 additions & 49 deletions test/integration.nix
Original file line number Diff line number Diff line change
@@ -1,58 +1,182 @@
{
nixpkgs ? <nixpkgs>,
pkgs ? import <nixpkgs> { inherit system; config = {}; },
system ? builtins.currentSystem
} @args:
args@{ nixpkgs ? <nixpkgs>, ... }:

import "${nixpkgs}/nixos/tests/make-test-python.nix" ({ pkgs, ...}: {
name = "agenix-integration";
with (import "${nixpkgs}/lib");

nodes.system1 = { config, lib, ... }: {
import "${nixpkgs}/nixos/tests/make-test-python.nix"
(
let
sshdConf = {
enable = true;
hostKeys = [{ type = "ed25519"; path = "/etc/ssh/ssh_host_ed25519_key"; }];
};

testService = name: {
systemd.services.${name} = {
wantedBy = [ "multi-user.target" ];
reload = "touch /tmp/${name}-reloaded";
# restarting a serivice stops it
preStop = "touch /tmp/${name}-stopped";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
};
};

testSecret = name: {
imports = map testService [ "${name}-reloadUnit" "${name}-restartUnit" ];
age.secrets.${name} = {
file = ../example/secret1.age;
onChange = "touch /tmp/${name}-onChange-executed";
reloadUnits = [ "${name}-reloadUnit.service" ];
restartUnits = [ "${name}-restartUnit.service" ];
};
};
in
rec {
name = "agenix-integration";

imports = [
../modules/age.nix
./install_ssh_host_keys.nix
];
nodes.system1 = { config, ... }: {
imports = [
../modules/age.nix
./install_ssh_host_keys.nix
];

services.openssh.enable = true;
services.openssh = sshdConf;

age.secrets.passwordfile-user1 = {
file = ../example/passwordfile-user1.age;
};
age.secrets.passwordfile-user1.file = ../example/passwordfile-user1.age;

users = {
mutableUsers = false;
users = {
mutableUsers = false;

users = {
user1 = {
isNormalUser = true;
passwordFile = config.age.secrets.passwordfile-user1.path;
users = {
user1 = {
isNormalUser = true;
passwordFile = config.age.secrets.passwordfile-user1.path;
};
};
};
};
};

};

testScript =
let
user = "user1";
password = "password1234";
in ''
system1.wait_for_unit("multi-user.target")
system1.wait_until_succeeds("pgrep -f 'agetty.*tty1'")
system1.sleep(2)
system1.send_key("alt-f2")
system1.wait_until_succeeds("[ $(fgconsole) = 2 ]")
system1.wait_for_unit("[email protected]")
system1.wait_until_succeeds("pgrep -f 'agetty.*tty2'")
system1.wait_until_tty_matches(2, "login: ")
system1.send_chars("${user}\n")
system1.wait_until_tty_matches(2, "login: ${user}")
system1.wait_until_succeeds("pgrep login")
system1.sleep(2)
system1.send_chars("${password}\n")
system1.send_chars("whoami > /tmp/1\n")
system1.wait_for_file("/tmp/1")
assert "${user}" in system1.succeed("cat /tmp/1")
'';
}) args

nodes.system2 = { pkgs, ... }: {
imports = [
../modules/age.nix
./install_ssh_host_keys.nix
]
++ map testSecret [
"noChange"
"fileChange"
"secretChange"
"secretChangeWeirdPath"
"pathChange"
"pathChangeNoSymlink"
"modeChange"
"symlinkOn"
"symlinkOff"
]
# add these services so they get started before the secret is added
++ (testSecret "secretAdded").imports;


age.secrets.secretChangeWeirdPath.path = "/tmp/secretChangeWeirdPath";
age.secrets.pathChangeNoSymlink.symlink = false;
age.secrets.symlinkOn.symlink = false;

services.openssh = sshdConf;
};

nodes.system2After = { lib, ... }: {
imports = [
nodes.system2
# services have already been added
(builtins.removeAttrs (testSecret "secretAdded") [ "imports" ])
];
age.secrets.fileChange.file = lib.mkForce ../example/secret1-copy.age;
age.secrets.secretChange.file = lib.mkForce ../example/passwordfile-user1.age;
age.secrets.secretChangeWeirdPath.file = lib.mkForce ../example/passwordfile-user1.age;
age.secrets.pathChange.path = lib.mkForce "/tmp/pathChange";
age.secrets.pathChangeNoSymlink.path = lib.mkForce "/tmp/pathChangeNoSymlink";
age.secrets.modeChange.mode = lib.mkForce "0777";
age.secrets.symlinkOn.symlink = lib.mkForce true;
age.secrets.symlinkOff.symlink = lib.mkForce false;
};

testScript =
let
user = "user1";
password = "password1234";
in
{ nodes, ... }:
''
system1.start()
system2.start()

system1.wait_for_unit("multi-user.target")
system1.wait_until_succeeds("pgrep -f 'agetty.*tty1'")
system1.sleep(2)
system1.send_key("alt-f2")
system1.wait_until_succeeds("[ $(fgconsole) = 2 ]")
system1.wait_for_unit("[email protected]")
system1.wait_until_succeeds("pgrep -f 'agetty.*tty2'")
system1.wait_until_tty_matches(2, "login: ")
system1.send_chars("${user}\n")
system1.wait_until_tty_matches(2, "login: ${user}")
system1.wait_until_succeeds("pgrep login")
system1.sleep(2)
system1.send_chars("${password}\n")
system1.send_chars("whoami > /tmp/1\n")
system1.wait_for_file("/tmp/1")
assert "${user}" in system1.succeed("cat /tmp/1")

# test changing secret
system2.wait_for_unit("multi-user.target")
# for these secrets the content doesn't change at all
system2_noChange_secrets = [
"noChange",
"fileChange",
"symlinkOn",
"symlinkOff",
]
system2_change_secrets = [
"secretChange",
"secretChangeWeirdPath",
"pathChange",
"pathChangeNoSymlink",
"modeChange",
"secretAdded",
]
system2_secrets = system2_noChange_secrets + system2_change_secrets
system2.wait_for_unit("multi-user.target")
for secret in system2_secrets:
system2.wait_for_unit(secret + "-reloadUnit")
system2.wait_for_unit(secret + "-restartUnit")

def test_not_changed(secret):
system2.fail("test -f /tmp/" + secret + "-reloadUnit-reloaded")
system2.fail("test -f /tmp/" + secret + "-reloadUnit-restarted")
system2.fail("test -f /tmp/" + secret + "-restartUnit-reloaded")
system2.fail("test -f /tmp/" + secret + "-restartUnit-restarted")
system2.fail("test -f /tmp/" + secret + "-onChange-executed")
def test_changed(secret):
system2.wait_for_file("/tmp/" + secret + "-onChange-executed")
system2.wait_for_file("/tmp/" + secret + "-reloadUnit-reloaded")
system2.wait_for_file("/tmp/" + secret + "-restartUnit-stopped")
system2.fail("test -f /tmp/" + secret + "-reloadUnit-restarted")
system2.fail("test -f /tmp/" + secret + "-restartUnit-reloaded")

# nothing should happen at startup
for secret in system2_secrets:
test_not_changed(secret)

# apply changes
system2.succeed(
"${nodes.system2After.config.system.build.toplevel}/bin/switch-to-configuration test"
)
for secret in system2_noChange_secrets:
test_not_changed(secret)
for secret in system2_change_secrets:
test_changed(secret)
'';
}
)
args