From 9274b82816ae4450ff13f634113e99a83d1bd4b1 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Sat, 22 Apr 2023 20:24:07 +0100 Subject: [PATCH] Add home-manager module This is to update and fix the issues I saw in [1] and [2]. Using a service definition instead of an activation script should resolve the issue about the secrets disappearing after rebooting. Removed the `user` and `group` option as they do not make sense to me for a home-manager module, which should target a single user. They can always be added back if somebody comes screaming. This is somewhat modeled after sops-nix's own module [3]. [1]: https://github.com/ryantm/agenix/pull/58/ [2]: https://github.com/ryantm/agenix/pull/109 [3]: https://github.com/Mic92/sops-nix/blob/master/modules/home-manager/sops.nix --- flake.nix | 3 + modules/age-home.nix | 234 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 modules/age-home.nix diff --git a/flake.nix b/flake.nix index 6269b44..90783c9 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,9 @@ darwinModules.age = import ./modules/age.nix; darwinModules.default = self.darwinModules.age; + homeManagerModules.age = import ./modules/age-home.nix; + homeManagerModules.default = self.homeManagerModules.age; + overlays.default = import ./overlay.nix; formatter.x86_64-darwin = nixpkgs.legacyPackages.x86_64-darwin.alejandra; diff --git a/modules/age-home.nix b/modules/age-home.nix new file mode 100644 index 0000000..86bfbe0 --- /dev/null +++ b/modules/age-home.nix @@ -0,0 +1,234 @@ +{ + config, + options, + lib, + pkgs, + ... +}: +with lib; let + cfg = config.age; + + ageBin = lib.getExe config.age.package; + + newGeneration = '' + _agenix_generation="$(basename "$(readlink "${cfg.secretsDir}")" || echo 0)" + (( ++_agenix_generation )) + echo "[agenix] creating new generation in ${cfg.secretsMountPoint}/$_agenix_generation" + mkdir -p "${cfg.secretsMountPoint}" + chmod 0751 "${cfg.secretsMountPoint}" + mkdir -p "${cfg.secretsMountPoint}/$_agenix_generation" + chmod 0751 "${cfg.secretsMountPoint}/$_agenix_generation" + ''; + + 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" + + IDENTITIES=() + # shellcheck disable=2043 + for identity in ${toString cfg.identityPaths}; do + test -r "$identity" || continue + IDENTITIES+=(-i) + IDENTITIES+=("$identity") + done + + test "''${#IDENTITIES[@]}" -eq 0 && echo "[agenix] WARNING: no readable identities found!" + + 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 or "C"} ${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; + + cleanupAndLink = '' + _agenix_generation="$(basename "$(readlink "${cfg.secretsDir}")" || echo 0)" + (( ++_agenix_generation )) + echo "[agenix] symlinking new secrets to ${cfg.secretsDir} (generation $_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 ))" + } + ''; + + installSecrets = builtins.concatStringsSep "\n" ( + ["echo '[agenix] decrypting secrets...'"] + ++ testIdentities + ++ (map installSecret (builtins.attrValues cfg.secrets)) + ++ [cleanupAndLink] + ); + + secretType = types.submodule ({ + config, + name, + ... + }: { + options = { + name = mkOption { + type = types.str; + default = name; + description = '' + Name of the file used in ''${cfg.secretsDir} + ''; + }; + file = mkOption { + type = types.path; + 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. + ''; + }; + symlink = mkEnableOption "symlinking secrets to their destination" // {default = true;}; + }; + }); + + mountingScript = let + app = pkgs.writeShellApplication { + name = "agenix-home-manager-mount-secrets"; + runtimeInputs = with pkgs; [coreutils]; + text = '' + ${newGeneration} + ${installSecrets} + exit 0 + ''; + }; + in + lib.getExe app; + + userDirectory = dir: let + inherit (pkgs.stdenv.hostPlatform) isDarwin; + baseDir = + if isDarwin + then "$(getconf DARWIN_USER_TEMP_DIR)" + else "$XDG_RUNTIME_DIR"; + in "${baseDir}/${dir}"; + + userDirectoryDescription = dir: '' + "$XDG_RUNTIME_DIR"/${dir} on linux or "$(getconf DARWIN_USER_TEMP_DIR)"/${dir} on darwin. + ''; +in { + options.age = { + package = mkPackageOption pkgs "rage" {}; + + secrets = mkOption { + type = types.attrsOf secretType; + default = {}; + description = '' + Attrset of secrets. + ''; + }; + + identityPaths = mkOption { + type = types.listOf types.path; + default = [ + "${config.home.homeDirectory}/.ssh/id_ed25519" + "${config.home.homeDirectory}/.ssh/id_rsa" + ]; + defaultText = litteralExpression '' + [ + "''${config.home.homeDirectory}/.ssh/id_ed25519" + "''${config.home.homeDirectory}/.ssh/id_rsa" + ] + ''; + description = '' + Path to SSH keys to be used as identities in age decryption. + ''; + }; + + secretsDir = mkOption { + type = types.str; + default = userDirectory "agenix"; + defaultText = userDirectoryDescription "agenix"; + description = '' + Folder where secrets are symlinked to + ''; + }; + + secretsMountPoint = mkOption { + default = userDirectory "agenix.d"; + defaultText = userDirectoryDescription "agenix.d"; + description = '' + Where secrets are created before they are symlinked to ''${cfg.secretsDir} + ''; + }; + }; + + config = mkIf (cfg.secrets != {}) { + assertions = [ + { + assertion = cfg.identityPaths != []; + message = "age.identityPaths must be set."; + } + ]; + + systemd.user.services.agenix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { + Unit = { + Description = "agenix activation"; + }; + Service = { + Type = "oneshot"; + ExecStart = mountingScript; + }; + Install.WantedBy = ["default.target"]; + }; + + launchd.agents.activate-agenix = { + enable = true; + config = { + ProgramArguments = [mountingScript]; + KeepAlive = { + Crashed = false; + SuccessfulExit = false; + }; + RunAtLoad = true; + ProcessType = "Background"; + StandardOutPath = "${config.home.homeDirectory}/Library/Logs/agenix/stdout"; + StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/agenix/stderr"; + }; + }; + }; +}