# ============================================================================ # Automatic Secret Management with Agenix # ============================================================================ # This module automatically loads age-encrypted secrets from ./secrets based on # the hostname. Secrets are organized by directory: # - ./secrets/global/ -> Installed on ALL systems # - ./secrets/{hostname}/ -> Installed only on matching host # # Secret files should be .age encrypted files. Public keys (.pub) are ignored. { config, lib, ... }: with lib; let cfg = config.athenix.sw; secretsPath = ../secrets; # Get the current hostname hostname = config.networking.hostName; # Read all directories in ./secrets secretDirs = if builtins.pathExists secretsPath then builtins.readDir secretsPath else { }; # Filter to only directories (excludes files) isDirectory = name: type: type == "directory"; directories = lib.filterAttrs isDirectory secretDirs; # Read secrets from a specific directory readSecretsFromDir = dirName: let dirPath = secretsPath + "/${dirName}"; files = builtins.readDir dirPath; # Check if there's a default.nix with custom secret configurations hasDefaultNix = files ? "default.nix"; customConfigs = if hasDefaultNix then import (dirPath + "/default.nix") else { }; # Only include .age files (exclude .pub public keys and other files) secretFiles = lib.filterAttrs ( name: type: type == "regular" && lib.hasSuffix ".age" name ) files; in lib.mapAttrs' ( name: _: let # Remove .age extension for the secret name secretName = lib.removeSuffix ".age" name; # Get custom config for this secret if defined customConfig = customConfigs.${secretName} or { }; # Base configuration with file path baseConfig = { file = dirPath + "/${name}"; }; in lib.nameValuePair secretName (baseConfig // customConfig) ) secretFiles; # Read public keys from a specific directory and map to private key paths readIdentityPathsFromDir = dirName: let dirPath = secretsPath + "/${dirName}"; files = if builtins.pathExists dirPath then builtins.readDir dirPath else { }; # Only include .pub public key files pubKeyFiles = lib.filterAttrs ( name: type: type == "regular" && lib.hasSuffix ".pub" name ) files; in lib.mapAttrsToList ( name: _: let # Map public key filename to expected private key location baseName = lib.removeSuffix ".pub" name; filePath = dirPath + "/${name}"; fileContent = builtins.readFile filePath; # Check if it's an SSH key by looking at the content isSSHKey = lib.hasPrefix "ssh-" fileContent || lib.hasPrefix "ecdsa-" fileContent; in if lib.hasPrefix "ssh_host_" name then # SSH host keys: ssh_host_ed25519_key.pub -> /etc/ssh/ssh_host_ed25519_key "/etc/ssh/${baseName}" else if name == "identity.pub" then # Standard age identity: identity.pub -> /etc/age/identity.key "/etc/age/identity.key" else if isSSHKey then # Other SSH keys (user keys, etc.): hunter_halloran_key.pub -> /etc/ssh/hunter_halloran_key "/etc/ssh/${baseName}" else # Generic age keys: key.pub -> /etc/age/key "/etc/age/${baseName}" ) pubKeyFiles; # Determine which secrets apply to this host applicableSecrets = let # Global secrets apply to all hosts globalSecrets = if directories ? "global" then readSecretsFromDir "global" else { }; # Host-specific secrets hostSecrets = if directories ? ${hostname} then readSecretsFromDir hostname else { }; in globalSecrets // hostSecrets; # Host-specific secrets override global if same name # Determine which identity paths (private keys) to use for decryption identityPaths = let # Global identity paths (keys in global/ that all hosts can use) globalPaths = if directories ? "global" then readIdentityPathsFromDir "global" else [ ]; # Host-specific identity paths hostPaths = if directories ? ${hostname} then readIdentityPathsFromDir hostname else [ ]; # Default paths that NixOS/agenix use defaultPaths = [ "/etc/ssh/ssh_host_rsa_key" "/etc/ssh/ssh_host_ed25519_key" "/etc/age/identity.key" ]; # Combine all paths and remove duplicates allPaths = lib.unique (defaultPaths ++ globalPaths ++ hostPaths); in allPaths; in { options.athenix.sw.secrets = { enable = mkOption { type = types.bool; default = true; description = '' Enable automatic secret management using agenix. Secrets are loaded from ./secrets based on directory structure: - ./secrets/global/ -> All systems - ./secrets/{hostname}/ -> Specific host only Only .age encrypted files are loaded; .pub files are ignored. ''; }; extraSecrets = mkOption { type = types.attrsOf (types.submodule { options = { file = mkOption { type = types.path; description = "Path to the encrypted secret file"; }; mode = mkOption { type = types.str; default = "0400"; description = "Permissions mode for the decrypted secret"; }; owner = mkOption { type = types.str; default = "root"; description = "Owner of the decrypted secret file"; }; group = mkOption { type = types.str; default = "root"; description = "Group of the decrypted secret file"; }; }; }); default = { }; description = '' Additional secrets to define manually, beyond the auto-discovered ones. Use this for secrets that need custom permissions or are stored elsewhere. ''; example = lib.literalExpression '' { "my-secret" = { file = ./secrets/custom/secret.age; mode = "0440"; owner = "nginx"; group = "nginx"; }; } ''; }; }; config = mkIf (cfg.enable && cfg.secrets.enable) { # Auto-discovered secrets with default permissions age.secrets = applicableSecrets // cfg.secrets.extraSecrets; # Configure identity paths for decryption based on discovered public keys # These are added in addition to agenix's defaults age.identityPaths = identityPaths; # Optional: Add assertion to warn if no secrets found warnings = let hasSecrets = (builtins.length (builtins.attrNames applicableSecrets)) > 0; in lib.optional (!hasSecrets) "No age-encrypted secrets found in ./secrets/global/ or ./secrets/${hostname}/"; }; }