agenix/modules/age.nix
2023-01-10 17:55:28 +01:00

271 lines
8.1 KiB
Nix

{ config, options, lib, pkgs, ... }:
with lib;
let
cfg = config.age;
# we need at least rage 0.5.0 to support ssh keys
rage =
if lib.versionOlder pkgs.rage.version "0.5.0"
then pkgs.callPackage ../pkgs/rage.nix { }
else pkgs.rage;
ageBin = config.age.ageBin;
users = config.users.users;
newGeneration = ''
_agenix_last_generation=$(basename "$(readlink ${cfg.secretsDir})" || true)
if [[ $_agenix_last_generation == ${secretsHash} ]]; then
_agenix_is_current=1
else
_agenix_is_current=
fi
if [[ ! $_agenix_is_current ]]; then
_agenix_generation="${secretsHash}"
echo "[agenix] creating new generation in ${cfg.secretsMountPoint}/$_agenix_generation"
mkdir -p "${cfg.secretsMountPoint}"
chmod 0751 "${cfg.secretsMountPoint}"
grep -q "${cfg.secretsMountPoint} ramfs" /proc/mounts || mount -t ramfs none "${cfg.secretsMountPoint}" -o nodev,nosuid,mode=0751
mkdir -p "${cfg.secretsMountPoint}/$_agenix_generation"
chmod 0751 "${cfg.secretsMountPoint}/$_agenix_generation"
fi
'';
identities = builtins.concatStringsSep " " (map (path: "-i ${path}") cfg.identityPaths);
setTruePath = secretType: ''
${if secretType.symlink then ''
_truePath="${cfg.secretsMountPoint}/$_agenix_generation/${secretType.name}"
'' else ''
_truePath="${secretType.path}"
''}
'';
installSecret = secretType: ''
${setTruePath secretType}
echo "decrypting '${secretType.file}' to '$_truePath'..."
TMP_FILE="$_truePath.tmp"
mkdir -p "$(dirname "$_truePath")"
[ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && mkdir -p "$(dirname "${secretType.path}")"
(
umask u=r,g=,o=
test -f "${secretType.file}" || echo '[agenix] WARNING: encrypted file ${secretType.file} does not exist!'
test -d "$(dirname "$TMP_FILE")" || echo "[agenix] WARNING: $(dirname "$TMP_FILE") does not exist!"
LANG=${config.i18n.defaultLocale} ${ageBin} --decrypt ${identities} -o "$TMP_FILE" "${secretType.file}"
)
chmod ${secretType.mode} "$TMP_FILE"
mv -f "$TMP_FILE" "$_truePath"
${optionalString secretType.symlink ''
[ "${secretType.path}" != "${cfg.secretsDir}/${secretType.name}" ] && ln -sfn "${cfg.secretsDir}/${secretType.name}" "${secretType.path}"
''}
'';
testIdentities = map (path: ''
test -f ${path} || echo '[agenix] WARNING: config.age.identityPaths entry ${path} not present!'
'') cfg.identityPaths;
# Add suffix `-incomplete` to the generation when creating some secrets has failed.
# This ensures that we can try to re-create the generation on subsequent runs.
renameOnFailure = ''
if (( _localstatus > 0 )); then
mv "${cfg.secretsMountPoint}/$_agenix_generation"{,-incomplete}
_agenix_generation+=-incomplete
fi
'';
cleanupAndLink = ''
echo "[agenix] symlinking new secrets to ${cfg.secretsDir} (generation $_agenix_generation)..."
ln -sfn "${cfg.secretsMountPoint}/$_agenix_generation" ${cfg.secretsDir}
[[ $_agenix_last_generation ]] && {
echo "[agenix] removing old secrets (generation $_agenix_last_generation)..."
rm -rf "${cfg.secretsMountPoint}/$_agenix_last_generation"
}
'';
installSecrets = mkInstallScript (
[ "echo '[agenix] decrypting secrets...'" ]
++ testIdentities
++ (map installSecret (builtins.attrValues cfg.secrets))
++ [ renameOnFailure cleanupAndLink ]
);
chownSecret = secretType: ''
${setTruePath secretType}
chown ${secretType.owner}:${secretType.group} "$_truePath"
'';
# chown the secrets mountpoint and the current generation to the keys group
# instead of leaving it root:root.
chownMountPoint = ''
chown :keys "${cfg.secretsMountPoint}" "${cfg.secretsMountPoint}/$_agenix_generation"
'';
chownSecrets = mkInstallScript (
[ "echo '[agenix] chowning...'" ]
++ [ chownMountPoint ]
++ (map chownSecret (builtins.attrValues cfg.secrets)));
mkInstallScript = strings: ''
[[ $_agenix_is_current ]] || {
${builtins.concatStringsSep "\n" strings}
}
'';
secretsHash = let
sha256-base16 = builtins.hashString "sha256" (builtins.toJSON cfg.secrets);
in
# Truncate to 128 bits to increase readability
substring 0 32 sha256-base16;
secretType = types.submodule ({ config, ... }: {
options = {
name = mkOption {
type = types.str;
default = config._module.args.name;
description = ''
Name of the file used in ''${cfg.secretsDir}
'';
};
file = mkOption {
type = mkOptionType {
name = "nix-path";
descriptionClass = "noun";
check = builtins.isPath;
merge = mergeEqualOption;
};
description = ''
Age file the secret is loaded from.
'';
};
path = mkOption {
type = types.str;
default = "${cfg.secretsDir}/${config.name}";
description = ''
Path where the decrypted secret is installed.
'';
};
mode = mkOption {
type = types.str;
default = "0400";
description = ''
Permissions mode of the decrypted secret in a format understood by chmod.
'';
};
owner = mkOption {
type = types.str;
default = "0";
description = ''
User of the decrypted secret.
'';
};
group = mkOption {
type = types.str;
default = users.${config.owner}.group or "0";
description = ''
Group of the decrypted secret.
'';
};
symlink = mkEnableOption "symlinking secrets to their destination" // { default = true; };
};
});
in
{
imports = [
(mkRenamedOptionModule [ "age" "sshKeyPaths" ] [ "age" "identityPaths" ])
];
options.age = {
ageBin = mkOption {
type = types.str;
default = "${rage}/bin/rage";
description = ''
The age executable to use.
'';
};
secrets = mkOption {
type = types.attrsOf secretType;
default = { };
description = ''
Attrset of secrets.
'';
};
secretsDir = mkOption {
type = types.path;
default = "/run/agenix";
description = ''
Folder where secrets are symlinked to
'';
};
secretsMountPoint = mkOption {
type = types.addCheck types.str
(s:
(builtins.match "[ \t\n]*" s) == null # non-empty
&& (builtins.match ".+/" s) == null) # without trailing slash
// { description = "${types.str.description} (with check: non-empty without trailing slash)"; };
default = "/run/agenix.d";
defaultText = "/run/agenix.d";
description = ''
Where secrets are created before they are symlinked to ''${cfg.secretsDir}
'';
};
identityPaths = mkOption {
type = types.listOf types.path;
default =
if config.services.openssh.enable then
map (e: e.path) (lib.filter (e: e.type == "rsa" || e.type == "ed25519") config.services.openssh.hostKeys)
else [ ];
description = ''
Path to SSH keys to be used as identities in age decryption.
'';
};
};
config = mkIf (cfg.secrets != { }) {
assertions = [{
assertion = cfg.identityPaths != [ ];
message = "age.identityPaths must be set.";
}];
# Create a new directory full of secrets for symlinking (this helps
# ensure removed secrets are actually removed, or at least become
# invalid symlinks).
system.activationScripts.agenixNewGeneration = {
text = newGeneration;
deps = [
"specialfs"
];
};
system.activationScripts.agenixInstall = {
text = installSecrets;
deps = [
"agenixNewGeneration"
"specialfs"
];
};
# So user passwords can be encrypted.
system.activationScripts.users.deps = [ "agenixInstall" ];
# Change ownership and group after users and groups are made.
system.activationScripts.agenixChown = {
text = chownSecrets;
deps = [
"users"
"groups"
];
};
# So other activation scripts can depend on agenix being done.
system.activationScripts.agenix = {
text = "";
deps = [ "agenixChown"];
};
};
}