diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a765e3..54089e9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,8 +25,17 @@ jobs: - run: nix build .#doc - run: nix fmt . -- --check - run: nix flake check - - run: | + - name: "Install nix-darwin module" + run: | system=$(nix build --no-link --print-out-paths .#checks.x86_64-darwin.integration) ${system}/activate-user sudo ${system}/activate - - run: sudo /run/current-system/sw/bin/agenix-integration + - name: "Test nix-darwin module" + run: | + sudo /run/current-system/sw/bin/agenix-integration + - name: "Test home-manager module" + run: | + # Do the job of `home-manager switch` in-line to avoid rate limiting + nix build .#homeConfigurations.integration-darwin.activationPackage + ./result/activate + ~/agenix-home-integration/bin/agenix-home-integration diff --git a/flake.lock b/flake.lock index a73d62d..3be370f 100644 --- a/flake.lock +++ b/flake.lock @@ -21,6 +21,26 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1682203081, + "narHash": "sha256-kRL4ejWDhi0zph/FpebFYhzqlOBrk0Pl3dzGEKSAlEw=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "32d3e39c491e2f91152c84f8ad8b003420eab0a1", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1677676435, @@ -40,6 +60,7 @@ "root": { "inputs": { "darwin": "darwin", + "home-manager": "home-manager", "nixpkgs": "nixpkgs" } } diff --git a/flake.nix b/flake.nix index 6269b44..13d863f 100644 --- a/flake.nix +++ b/flake.nix @@ -7,12 +7,17 @@ url = "github:lnl7/nix-darwin/master"; inputs.nixpkgs.follows = "nixpkgs"; }; + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = { self, nixpkgs, darwin, + home-manager, }: let agenix = system: nixpkgs.legacyPackages.${system}.callPackage ./pkgs/agenix.nix {}; doc = system: nixpkgs.legacyPackages.${system}.callPackage ./pkgs/doc.nix {}; @@ -23,6 +28,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; @@ -49,24 +57,46 @@ packages.x86_64-linux.agenix = agenix "x86_64-linux"; packages.x86_64-linux.default = self.packages.x86_64-linux.agenix; packages.x86_64-linux.doc = doc "x86_64-linux"; - checks.x86_64-linux.integration = import ./test/integration.nix { - inherit nixpkgs; - pkgs = nixpkgs.legacyPackages.x86_64-linux; - system = "x86_64-linux"; - }; - checks."aarch64-darwin".integration = - (darwin.lib.darwinSystem { - system = "aarch64-darwin"; - modules = [./test/integration_darwin.nix "${darwin.outPath}/pkgs/darwin-installer/installer.nix"]; - }) - .system; - checks."x86_64-darwin".integration = - (darwin.lib.darwinSystem { - system = "x86_64-darwin"; - modules = [./test/integration_darwin.nix "${darwin.outPath}/pkgs/darwin-installer/installer.nix"]; - }) - .system; - darwinConfigurations.integration.system = self.checks."x86_64-darwin".integration; + checks = + nixpkgs.lib.genAttrs ["aarch64-darwin" "x86_64-darwin"] (system: { + integration = + (darwin.lib.darwinSystem { + inherit system; + modules = [ + ./test/integration_darwin.nix + "${darwin.outPath}/pkgs/darwin-installer/installer.nix" + home-manager.darwinModules.home-manager + { + home-manager = { + verbose = true; + useGlobalPkgs = true; + useUserPackages = true; + backupFileExtension = "hmbak"; + users.runner = ./test/integration_hm_darwin.nix; + }; + } + ]; + }) + .system; + }) + // { + x86_64-linux.integration = import ./test/integration.nix { + inherit nixpkgs home-manager; + pkgs = nixpkgs.legacyPackages.x86_64-linux; + system = "x86_64-linux"; + }; + }; + + darwinConfigurations.integration-x86_64.system = self.checks.x86_64-darwin.integration; + darwinConfigurations.integration-aarch64.system = self.checks.aarch64-darwin.integration; + + # Work-around for https://github.com/nix-community/home-manager/issues/3075 + legacyPackages = nixpkgs.lib.genAttrs ["aarch64-darwin" "x86_64-darwin"] (system: { + homeConfigurations.integration-darwin = home-manager.lib.homeManagerConfiguration { + pkgs = nixpkgs.legacyPackages.${system}; + modules = [./test/integration_hm_darwin.nix]; + }; + }); }; } 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"; + }; + }; + }; +} diff --git a/test/install_ssh_host_keys_darwin.nix b/test/install_ssh_host_keys_darwin.nix index 78e1567..c826fa8 100644 --- a/test/install_ssh_host_keys_darwin.nix +++ b/test/install_ssh_host_keys_darwin.nix @@ -1,10 +1,17 @@ # Do not copy this! It is insecure. This is only okay because we are testing. { system.activationScripts.extraUserActivation.text = '' - echo "Installing SSH host key" + echo "Installing system SSH host key" sudo cp ${../example_keys/system1.pub} /etc/ssh/ssh_host_ed25519_key.pub sudo cp ${../example_keys/system1} /etc/ssh/ssh_host_ed25519_key sudo chmod 644 /etc/ssh/ssh_host_ed25519_key.pub sudo chmod 600 /etc/ssh/ssh_host_ed25519_key + + echo "Installing user SSH host key" + mkdir -p $HOME/.ssh + cp ${../example_keys/user1.pub} $HOME/.ssh/id_ed25519.pub + cp ${../example_keys/user1} $HOME/.ssh/id_ed25519 + chmod 644 $HOME/.ssh/id_ed25519.pub + chmod 600 $HOME/.ssh/id_ed25519 ''; } diff --git a/test/integration.nix b/test/integration.nix index 64f5c50..f719356 100644 --- a/test/integration.nix +++ b/test/integration.nix @@ -6,6 +6,7 @@ config = {}; }, system ? builtins.currentSystem, + home-manager ? , }: pkgs.nixosTest { name = "agenix-integration"; @@ -18,6 +19,7 @@ pkgs.nixosTest { imports = [ ../modules/age.nix ./install_ssh_host_keys.nix + "${home-manager}/nixos" ]; services.openssh.enable = true; @@ -43,11 +45,28 @@ pkgs.nixosTest { }; }; }; + + home-manager.users.user1 = {options, ...}: { + imports = [ + ../modules/age-home.nix + ]; + + home.stateVersion = pkgs.lib.trivial.release; + + age = { + identityPaths = options.age.identityPaths.default ++ ["/home/user1/.ssh/this_key_wont_exist"]; + secrets.secret2 = { + # Only decryptable by user1's key + file = ../example/secret2.age; + }; + }; + }; }; testScript = let user = "user1"; password = "password1234"; + secret2 = "world!"; in '' system1.wait_for_unit("multi-user.target") system1.wait_until_succeeds("pgrep -f 'agetty.*tty1'") @@ -65,6 +84,9 @@ pkgs.nixosTest { system1.send_chars("whoami > /tmp/1\n") system1.wait_for_file("/tmp/1") assert "${user}" in system1.succeed("cat /tmp/1") + system1.send_chars("cat /run/user/$(id -u)/agenix/secret2 > /tmp/2\n") + system1.wait_for_file("/tmp/2") + assert "${secret2}" in system1.succeed("cat /tmp/2") userDo = lambda input : f"sudo -u user1 -- bash -c 'set -eou pipefail; cd /tmp/secrets; {input}'" diff --git a/test/integration_darwin.nix b/test/integration_darwin.nix index 4894caa..f309667 100644 --- a/test/integration_darwin.nix +++ b/test/integration_darwin.nix @@ -8,7 +8,7 @@ testScript = pkgs.writeShellApplication { name = "agenix-integration"; text = '' - grep ${secret} ${config.age.secrets.secret1.path} + grep "${secret}" "${config.age.secrets.system-secret.path}" ''; }; in { @@ -19,9 +19,10 @@ in { services.nix-daemon.enable = true; - age.identityPaths = options.age.identityPaths.default ++ ["/etc/ssh/this_key_wont_exist"]; - - age.secrets.secret1.file = ../example/secret1.age; + age = { + identityPaths = options.age.identityPaths.default ++ ["/etc/ssh/this_key_wont_exist"]; + secrets.system-secret.file = ../example/secret1.age; + }; environment.systemPackages = [testScript]; } diff --git a/test/integration_hm_darwin.nix b/test/integration_hm_darwin.nix new file mode 100644 index 0000000..d2e14b2 --- /dev/null +++ b/test/integration_hm_darwin.nix @@ -0,0 +1,33 @@ +{ + pkgs, + config, + options, + lib, + ... +}: { + imports = [../modules/age-home.nix]; + + age = { + identityPaths = options.age.identityPaths.default ++ ["/Users/user1/.ssh/this_key_wont_exist"]; + secrets.user-secret.file = ../example/secret2.age; + }; + + home = rec { + username = "runner"; + homeDirectory = lib.mkForce "/Users/${username}"; + stateVersion = lib.trivial.release; + }; + + home.file = let + name = "agenix-home-integration"; + in { + ${name}.source = pkgs.writeShellApplication { + inherit name; + text = let + secret = "world!"; + in '' + diff -q "${config.age.secrets.user-secret.path}" <(printf '${secret}\n') + ''; + }; + }; +}