# ============================================================================ # Agenix Secret Recipients Configuration (Auto-Generated) # ============================================================================ # This file automatically discovers hosts and their public keys from the # secrets/ directory structure and generates recipient configurations. # # Directory structure: # secrets/{hostname}/*.pub -> SSH/age public keys for that host # secrets/global/*.pub -> Keys accessible to all hosts # # Usage: # ragenix -e secrets/global/example.age # Edit/create secret # ragenix -r # Re-key all secrets # # To add admin keys for editing secrets, create secrets/admins/*.pub files # with your personal age public keys (generated with: age-keygen) let lib = builtins; # Helper functions not in builtins filterAttrs = pred: set: lib.listToAttrs ( lib.filter (item: pred item.name item.value) ( lib.map (name: { inherit name; value = set.${name}; }) (lib.attrNames set) ) ); concatLists = lists: lib.foldl' (acc: list: acc ++ list) [ ] lists; unique = list: let go = acc: remaining: if remaining == [ ] then acc else if lib.elem (lib.head remaining) acc then go acc (lib.tail remaining) else go (acc ++ [ (lib.head remaining) ]) (lib.tail remaining); in go [ ] list; hasSuffix = suffix: str: let lenStr = lib.stringLength str; lenSuffix = lib.stringLength suffix; in lenStr >= lenSuffix && lib.substring (lenStr - lenSuffix) lenSuffix str == suffix; hasPrefix = prefix: str: let lenPrefix = lib.stringLength prefix; in lib.stringLength str >= lenPrefix && lib.substring 0 lenPrefix str == prefix; nameValuePair = name: value: { inherit name value; }; secretsPath = ./secrets; # Helper to convert SSH public key content to age public key sshToAge = sshPubKey: let # This is a simple check - in practice, use ssh-to-age tool # For now, we'll just use the keys as-is if they look like age keys trimmed = lib.replaceStrings [ "\n" ] [ "" ] sshPubKey; in if lib.substring 0 4 trimmed == "age1" then trimmed else null; # Will need manual conversion with ssh-to-age # Read all directories in secrets/ secretDirs = if lib.pathExists secretsPath then lib.readDir secretsPath else { }; # Filter to only directories (excludes files) isDirectory = name: type: type == "directory"; directories = lib.filter (name: isDirectory name secretDirs.${name}) (lib.attrNames secretDirs); # Read public keys from a directory and convert to age format readHostKeys = dirName: let dirPath = secretsPath + "/${dirName}"; files = if lib.pathExists dirPath then lib.readDir dirPath else { }; # Prefer .age.pub files (pre-converted), fall back to .pub files agePubFiles = filterAttrs (name: type: type == "regular" && hasSuffix ".age.pub" name) files; sshPubFiles = filterAttrs ( name: type: type == "regular" && hasSuffix ".pub" name && !(hasSuffix ".age.pub" name) ) files; # Read age public keys (already in correct format) ageKeys = lib.map ( name: let content = lib.readFile (dirPath + "/${name}"); # Trim whitespace/newlines trimmed = lib.replaceStrings [ "\n" " " "\r" "\t" ] [ "" "" "" "" ] content; in trimmed ) (lib.attrNames agePubFiles); # For SSH keys, just include them as-is (user needs to convert with ssh-to-age) # Or they can run the update-age-keys.sh script sshKeys = if (lib.length (lib.attrNames sshPubFiles)) > 0 then lib.trace "Warning: ${dirName} has unconverted SSH keys. Run secrets/update-age-keys.sh" [ ] else [ ]; in lib.filter (k: k != null && k != "") (ageKeys ++ sshKeys); # Build host key mappings: { hostname = [ "age1..." "age2..." ]; } hostKeys = lib.listToAttrs ( lib.map (dir: nameValuePair dir (readHostKeys dir)) ( lib.filter (d: d != "global" && d != "admins") directories ) ); # Global keys that all hosts can use globalKeys = if lib.elem "global" directories then readHostKeys "global" else [ ]; # Admin keys for editing secrets adminKeys = if lib.elem "admins" directories then readHostKeys "admins" else [ ]; # All host keys combined allHostKeys = concatLists (lib.attrValues hostKeys); # Find all .age files in the secrets directory findSecrets = dir: let dirPath = secretsPath + "/${dir}"; files = if lib.pathExists dirPath then lib.readDir dirPath else { }; ageFiles = filterAttrs (name: type: type == "regular" && hasSuffix ".age" name) files; in lib.map (name: "secrets/${dir}/${name}") (lib.attrNames ageFiles); # Generate recipient list for a secret based on its location getRecipients = secretPath: let # Extract directory name from path: "secrets/nix-builder/foo.age" -> "nix-builder" pathParts = lib.split "/" secretPath; dirName = lib.elemAt pathParts 2; in if dirName == "global" then # Global secrets: all hosts + admins allHostKeys ++ globalKeys ++ adminKeys else if hostKeys ? ${dirName} then # Host-specific secrets: that host + global keys + admins hostKeys.${dirName} ++ globalKeys ++ adminKeys else # Fallback: just admins adminKeys; # Find all secrets across all directories allSecrets = concatLists (lib.map findSecrets directories); # Generate the configuration secretsConfig = lib.listToAttrs ( lib.map ( secretPath: let recipients = getRecipients secretPath; # Remove duplicates and empty keys uniqueRecipients = unique (lib.filter (k: k != null && k != "") recipients); in nameValuePair secretPath { publicKeys = uniqueRecipients; } ) allSecrets ); # Generate wildcard rules for each directory to allow creating new secrets wildcardRules = lib.listToAttrs ( lib.concatMap ( dir: [ # Match with and without .age extension for ragenix compatibility (nameValuePair "secrets/${dir}/*" { publicKeys = let recipients = getRecipients "secrets/${dir}/dummy.age"; in unique (lib.filter (k: k != null && k != "") recipients); }) (nameValuePair "secrets/${dir}/*.age" { publicKeys = let recipients = getRecipients "secrets/${dir}/dummy.age"; in unique (lib.filter (k: k != null && k != "") recipients); }) ] ) (lib.filter (d: d != "admins") directories) ); in secretsConfig // wildcardRules