diff --git a/README.md b/README.md index 8284e7e0..a4c62969 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This project contains two parts: * [fetchTarball](#install-via-fetchtarball) * [flakes](#install-via-flakes) * [Tutorial](#tutorial) +* [Systemd Service Integration](#systemd-service-integration) * [Reference](#reference) * [`age` module reference](#age-module-reference) * [`age-home` module reference](#age-home-module-reference) @@ -423,6 +424,58 @@ The home-manager module follows the same general principles as the NixOS module When you run `home-manager switch`, your secrets will be decrypted to a user-specific directory (usually `$XDG_RUNTIME_DIR/agenix` on Linux or a temporary directory on Darwin) and can be referenced in your configuration. +## Systemd Service Integration + +On Linux, when secrets are defined (`age.secrets` is non-empty), agenix provides a systemd service (`agenix-install-secrets.service`) that other services can depend on. The behavior of this service depends on `age.installationMode`: + +- **`"activation"` mode (default)**: Secrets are decrypted during NixOS activation. The systemd service acts as a barrier that verifies secrets exist and provides a dependency point for other services. + +- **`"systemd"` mode**: Secrets are decrypted by the systemd service itself. Use this when your decryption key requires external resources (USB drive, network mount). + +### Basic Usage + +Other systemd services can depend on agenix: + +```nix +{ + systemd.services.my-service = { + after = [ "agenix-install-secrets.service" ]; + wants = [ "agenix-install-secrets.service" ]; + serviceConfig = { + ExecStart = "${pkgs.my-service}/bin/my-service --config ${config.age.secrets.my-secret.path}"; + }; + }; +} +``` + +### Waiting for External Resources + +If your decryption key is on a USB drive or network mount, use `"systemd"` mode: + +```nix +{ + age.installationMode = "systemd"; + + systemd.services.agenix-install-secrets = { + after = [ "mnt-usb.mount" ]; + requires = [ "mnt-usb.mount" ]; + }; + + age.identityPaths = [ "/mnt/usb/age-key" ]; +} +``` + +**Important**: With `"systemd"` mode, an assertion will fail if you try to use `hashedPasswordFile` with agenix secrets, since user passwords must be set during activation before systemd starts. + +### Installation Modes Summary + +| Mode | When Are Secrets Decrypted | Systemd Service Role | `hashedPasswordFile` | +|------|---------------------------|---------------------|---------------------| +| `"activation"` (default) | During NixOS activation | Barrier/verification | Supported | +| `"systemd"` | By systemd service | Actual decryption | Not supported (assertion fails) | + +**Note**: When `systemd.sysusers.enable` or `services.userborn.enable` is active, you MUST use `"systemd"` mode because activation scripts run before sysusers creates users. + ## Reference ### `age` module reference @@ -647,6 +700,36 @@ Overriding `age.secretsMountPoint` example: } ``` +#### `age.installationMode` + +`age.installationMode` controls when secrets are decrypted. Defaults +to `"activation"`. + +| Value | Behavior | +|-------|----------| +| `"activation"` | Secrets are decrypted during NixOS activation. A systemd service (`agenix-install-secrets.service`) acts as a barrier for other services to depend on. This is compatible with `hashedPasswordFile`. | +| `"systemd"` | Secrets are ONLY decrypted by the systemd service, not during activation. Use this when your decryption key requires external resources (USB drive, network mount). **Note**: An assertion will fail if you try to use this with `hashedPasswordFile`. | + +**Important**: When `systemd.sysusers.enable` or `services.userborn.enable` is +active, you MUST use `"systemd"` mode because activation scripts run before +sysusers creates users. + +Example for USB-mounted keys: + +```nix +{ + age.installationMode = "systemd"; + + # Make agenix wait for the USB mount + systemd.services.agenix-install-secrets = { + after = [ "mnt-usb.mount" ]; + requires = [ "mnt-usb.mount" ]; + }; + + age.identityPaths = [ "/mnt/usb/secret-key" ]; +} +``` + ### `age-home` module reference The home-manager module provides options similar to the NixOS module but scoped to a single user. diff --git a/flake.nix b/flake.nix index ee8340b2..1ca17bf8 100644 --- a/flake.nix +++ b/flake.nix @@ -75,6 +75,11 @@ pkgs = nixpkgs.legacyPackages.x86_64-linux; system = "x86_64-linux"; }; + x86_64-linux.integration-systemd = import ./test/integration_systemd.nix { + inherit nixpkgs; + pkgs = nixpkgs.legacyPackages.x86_64-linux; + system = "x86_64-linux"; + }; }; darwinConfigurations.integration-x86_64.system = self.checks.x86_64-darwin.integration; diff --git a/modules/age.nix b/modules/age.nix index acae79fe..005b9b27 100644 --- a/modules/age.nix +++ b/modules/age.nix @@ -21,6 +21,36 @@ let else options.systemd ? sysusers && (config.systemd.sysusers.enable || config.services.userborn.enable); + # Whether to decrypt during activation (vs only via systemd) + # When sysusers is enabled, we MUST use systemd-only mode because activation + # scripts run before sysusers creates users/groups. + decryptDuringActivation = cfg.installationMode == "activation" && !sysusersEnabled; + + # Collect all paths that need to be mounted for RequiresMountsFor + # Includes: secretsDir, secretsMountPoint, identityPaths, AND all custom secret destination paths + secretMountPaths = lib.unique ( + [ + cfg.secretsDir + cfg.secretsMountPoint + ] + ++ cfg.identityPaths + ++ map (s: s.path) (builtins.attrValues cfg.secrets) + ); + + # Check if any user's hashedPasswordFile references an agenix secret + # Use toString for robust path comparison (handles both path and string types) + agenixSecretPaths = map (s: toString s.path) (builtins.attrValues cfg.secrets); + # Use filterAttrs to preserve user names for error messages + usersWithAgenixPasswords = + if isDarwin then + { } + else + lib.filterAttrs ( + name: u: + (u.hashedPasswordFile or null) != null + && builtins.elem (toString u.hashedPasswordFile) agenixSecretPaths + ) config.users.users; + mountCommand = if isDarwin then '' @@ -134,6 +164,17 @@ let ++ (map chownSecret (builtins.attrValues cfg.secrets)) ); + # Generate barrier verification script that checks ALL secrets exist + # Use escapeShellArg for safety with special characters in names/paths + barrierChecks = builtins.concatStringsSep "\n" ( + map (secret: '' + if ! [ -r ${lib.escapeShellArg secret.path} ]; then + echo "[agenix] ERROR: secret ${lib.escapeShellArg secret.name} not found or not readable at ${lib.escapeShellArg secret.path}" >&2 + _missing=1 + fi + '') (builtins.attrValues cfg.secrets) + ); + secretType = types.submodule ( { config, ... }: { @@ -238,6 +279,46 @@ in Where secrets are created before they are symlinked to {option}`age.secretsDir` ''; }; + installationMode = mkOption { + type = types.enum [ + "activation" + "systemd" + ]; + default = "activation"; + description = '' + Controls when secrets are decrypted: + + - `"activation"` (default): Secrets are decrypted during NixOS system + activation. This is required for {option}`users.users..hashedPasswordFile` + and other activation-time features that depend on secrets. + A systemd service (`agenix-install-secrets.service`) is also created + as a barrier/marker that other services can depend on for ordering. + + - `"systemd"`: Secrets are ONLY decrypted by the systemd service, not + during activation. Use this when your decryption key is not available + until a systemd service runs (e.g., key on a USB drive that needs to + be mounted first). + + **Warning**: This mode is INCOMPATIBLE with + {option}`users.users..hashedPasswordFile` since user creation + happens before systemd services run. An assertion will fail if you + try to use both. + + To add dependencies for the decryption key: + ```nix + systemd.services.agenix-install-secrets = { + requires = [ "mnt-usb.mount" ]; + after = [ "mnt-usb.mount" ]; + }; + ``` + + Note: When {option}`systemd.sysusers.enable` or {option}`services.userborn.enable` + is active, you MUST use `"systemd"` mode because activation scripts run + before sysusers creates users. + + This option only affects Linux systems; Darwin always uses launchd. + ''; + }; identityPaths = mkOption { type = types.listOf types.path; default = @@ -275,37 +356,173 @@ in assertion = cfg.identityPaths != [ ]; message = "age.identityPaths must be set, for example by enabling openssh."; } + { + # Hard assertion: cannot use hashedPasswordFile with systemd-only mode + assertion = isDarwin || decryptDuringActivation || usersWithAgenixPasswords == { }; + message = '' + agenix: Cannot use age.installationMode = "systemd" with users.users..hashedPasswordFile. + + The following users have hashedPasswordFile pointing to agenix secrets: + ${builtins.concatStringsSep "\n" ( + lib.mapAttrsToList ( + name: u: " - ${name}: ${toString u.hashedPasswordFile}" + ) usersWithAgenixPasswords + )} + + User passwords must be set during NixOS activation, before systemd services run. + With installationMode = "systemd", secrets are not available during activation. + + Either: + 1. Set age.installationMode = "activation" (default), or + 2. Use a different mechanism for user passwords (e.g., passwordFile with a non-agenix path) + ''; + } + { + # When sysusers is enabled, activation-time decryption doesn't work + # because activation scripts run before sysusers creates users + assertion = isDarwin || !sysusersEnabled || cfg.installationMode == "systemd"; + message = '' + agenix: systemd.sysusers.enable or services.userborn.enable is active, but + age.installationMode is set to "activation". + + When sysusers/userborn is enabled, user creation happens via systemd after + activation scripts complete. This means activation-time secret decryption + cannot set proper ownership because users don't exist yet. + + Please set: + age.installationMode = "systemd"; + + Note: This means hashedPasswordFile will not work with agenix secrets. + ''; + } ]; + + warnings = optional (!isDarwin && !decryptDuringActivation) '' + agenix: installationMode is set to "systemd". Secrets will NOT be + available during NixOS activation. + + - Systemd services can depend on agenix-install-secrets.service + - User passwords (hashedPasswordFile) cannot use agenix secrets + - Activation scripts cannot depend on secrets + + Add custom dependencies for the decryption key: + systemd.services.agenix-install-secrets.requires = [ "mnt-usb.mount" ]; + systemd.services.agenix-install-secrets.after = [ "mnt-usb.mount" ]; + ''; } (optionalAttrs (!isDarwin) { - # When using sysusers we no longer be started as an activation script - # because those are started in initrd while sysusers is started later. - systemd.services.agenix-install-secrets = mkIf sysusersEnabled { - wantedBy = [ "sysinit.target" ]; - after = [ "systemd-sysusers.service" ]; - unitConfig.DefaultDependencies = "no"; - - path = [ pkgs.mount ]; - serviceConfig = { - Type = "oneshot"; - ExecStart = pkgs.writeShellScript "agenix-install" (concatLines [ - newGeneration - installSecrets - chownSecrets - ]); - RemainAfterExit = true; + # Systemd service for secret installation. + # Behavior depends on installationMode: + # - "activation" (and !sysusers): This is a BARRIER unit that starts after + # NixOS activation completes. Secrets are decrypted by activation scripts. + # Other services can depend on this unit for ordering. + # - "systemd" (or sysusers): This unit performs the actual decryption. + # Use this when decryption keys require external resources (USB, network). + systemd.services.agenix-install-secrets = { + description = + if decryptDuringActivation then + "Agenix secrets barrier (secrets decrypted during activation)" + else + "Decrypt agenix secrets"; + + wantedBy = [ "multi-user.target" ]; + + # Ordering dependencies + after = + if decryptDuringActivation then + # Barrier mode: start after NixOS activation is complete + # (secrets are already available from activation scripts) + [ "nixos-activation.service" ] + else + # Decryption mode: wait for filesystems and user creation + # nixos-activation.service ensures users/groups exist for chown + [ + "local-fs.target" + "nixos-activation.service" + ] + ++ optionals (options.systemd ? sysusers && config.systemd.sysusers.enable) [ + "systemd-sysusers.service" + ] + ++ optionals (config.services ? userborn && config.services.userborn.enable) [ "userborn.service" ]; + + # Ensure all paths we need are mounted (in decryption mode) + unitConfig = mkIf (!decryptDuringActivation) { + # RequiresMountsFor ensures the unit waits for these paths to be available + RequiresMountsFor = secretMountPaths; }; + + serviceConfig = mkMerge [ + { + Type = "oneshot"; + ExecStart = lib.getExe ( + if decryptDuringActivation then + # Barrier mode: verify ALL secrets exist and are readable + pkgs.writeShellApplication { + name = "agenix-barrier"; + runtimeInputs = with pkgs; [ + coreutils + gnugrep + mount + ]; + text = '' + set -uo pipefail + _missing=0 + _secretsDir=${lib.escapeShellArg cfg.secretsDir} + + # Check that secretsDir symlink exists and resolves + if ! [ -L "$_secretsDir" ] || ! [ -d "$_secretsDir" ]; then + echo "[agenix] ERROR: secrets directory not found at $_secretsDir" >&2 + echo "[agenix] This indicates activation scripts failed to decrypt secrets." >&2 + exit 1 + fi + + # Verify each expected secret exists and is readable + ${barrierChecks} + + if [ "$_missing" -ne 0 ]; then + echo "[agenix] ERROR: one or more secrets are missing or unreadable" >&2 + exit 1 + fi + + echo "[agenix] all secrets available (decrypted during activation)" + ''; + } + else + # Decryption mode: perform actual decryption + pkgs.writeShellApplication { + name = "agenix-install"; + runtimeInputs = with pkgs; [ + coreutils + gnugrep + mount + ]; + excludeShellChecks = [ "2050" ]; + text = '' + set -euo pipefail + ${newGeneration} + ${installSecrets} + ${chownSecrets} + ''; + } + ); + RemainAfterExit = true; + } + # Add retry logic for systemd decryption mode + (mkIf (!decryptDuringActivation) { + Restart = "on-failure"; + RestartSec = "2s"; + }) + ]; }; - # 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 = mkIf (!sysusersEnabled) { + # Activation scripts for secret decryption. + # Only defined when decryptDuringActivation is true. + # When using systemd-only mode, NO activation scripts are created + # (to avoid false confidence that secrets exist during activation). + system.activationScripts = mkIf decryptDuringActivation { agenixNewGeneration = { text = newGeneration; - deps = [ - "specialfs" - ]; + deps = [ "specialfs" ]; }; agenixInstall = { @@ -316,7 +533,7 @@ in ]; }; - # So user passwords can be encrypted. + # So user passwords can be set from secrets. users.deps = [ "agenixInstall" ]; # Change ownership and group after users and groups are made. @@ -328,7 +545,7 @@ in ]; }; - # So other activation scripts can depend on agenix being done. + # Marker so other activation scripts can depend on agenix being done. agenix = { text = ""; deps = [ "agenixChown" ]; diff --git a/test/install_ssh_host_keys.nix b/test/install_ssh_host_keys.nix index 8152814c..c9b03f56 100644 --- a/test/install_ssh_host_keys.nix +++ b/test/install_ssh_host_keys.nix @@ -1,6 +1,8 @@ # Do not copy this! It is insecure. This is only okay because we are testing. { config, ... }: { + # Install SSH host keys via activation script (must run before agenix activation) + # This ensures keys are available for decryption during activation. system.activationScripts.agenixInstall.deps = [ "installSSHHostKeys" ]; system.activationScripts.installSSHHostKeys.text = '' diff --git a/test/install_ssh_host_keys_simple.nix b/test/install_ssh_host_keys_simple.nix new file mode 100644 index 00000000..569dd1f6 --- /dev/null +++ b/test/install_ssh_host_keys_simple.nix @@ -0,0 +1,17 @@ +# Minimal SSH host key installation for testing (no user-specific keys) +# Do not copy this! It is insecure. This is only okay because we are testing. +{ ... }: +{ + system.activationScripts.installSSHHostKeys.text = '' + mkdir -p /etc/ssh + ( + umask u=rw,g=r,o=r + cp ${../example_keys/system1.pub} /etc/ssh/ssh_host_ed25519_key.pub + ) + ( + umask u=rw,g=,o= + cp ${../example_keys/system1} /etc/ssh/ssh_host_ed25519_key + touch /etc/ssh/ssh_host_rsa_key + ) + ''; +} diff --git a/test/integration_systemd.nix b/test/integration_systemd.nix new file mode 100644 index 00000000..1b96f4ec --- /dev/null +++ b/test/integration_systemd.nix @@ -0,0 +1,90 @@ +# Test for age.installationMode = "systemd" +# This verifies that secrets are correctly decrypted by the systemd service +# and that dependent services can access them. +{ + nixpkgs ? , + pkgs ? import { + inherit system; + config = { }; + }, + system ? builtins.currentSystem, +}: +pkgs.nixosTest { + name = "agenix-systemd-mode"; + nodes.system1 = + { + config, + pkgs, + options, + ... + }: + { + imports = [ + ../modules/age.nix + ./install_ssh_host_keys_simple.nix + ]; + + services.openssh.enable = true; + + # Use systemd mode for secret decryption + age.installationMode = "systemd"; + + age.secrets = { + testsecret = { + file = ../example/secret1.age; + mode = "0400"; + owner = "root"; + group = "root"; + }; + }; + + age.identityPaths = options.age.identityPaths.default; + + # Create a service that depends on agenix and reads the secret + systemd.services.secret-consumer = { + description = "Test service that consumes agenix secrets"; + after = [ "agenix-install-secrets.service" ]; + wants = [ "agenix-install-secrets.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "consume-secret" '' + set -euo pipefail + if [ -r "${config.age.secrets.testsecret.path}" ]; then + cat "${config.age.secrets.testsecret.path}" > /tmp/secret-consumed + echo "Secret consumed successfully" + else + echo "ERROR: Secret not readable" + exit 1 + fi + ''; + }; + }; + }; + + testScript = '' + # Wait for the system to boot + system1.wait_for_unit("multi-user.target") + + # Verify agenix-install-secrets.service succeeded + system1.succeed("systemctl is-active agenix-install-secrets.service") + + # Verify the secret was decrypted + system1.succeed("test -f /run/agenix/testsecret") + + # Verify the dependent service ran and consumed the secret + system1.succeed("systemctl is-active secret-consumer.service") + system1.succeed("test -f /tmp/secret-consumed") + + # Verify the secret content is correct + secret_content = system1.succeed("cat /run/agenix/testsecret").strip() + assert secret_content == "hello", f"Expected 'hello', got '{secret_content}'" + + # Verify consumed content matches + consumed_content = system1.succeed("cat /tmp/secret-consumed").strip() + assert consumed_content == "hello", f"Expected 'hello', got '{consumed_content}'" + + print("All systemd mode tests passed!") + ''; +}