Files
athenix/sw/secrets.nix
UGA Innovation Factory aa6d9d5691
Some checks failed
CI / Evaluate Key Configurations (nix-laptop1) (push) Has been cancelled
CI / Evaluate Artifacts (installer-iso-nix-laptop1) (push) Has been cancelled
CI / Evaluate Artifacts (lxc-nix-builder) (push) Has been cancelled
CI / Build and Publish Documentation (push) Has been cancelled
CI / Flake Check (push) Has been cancelled
CI / Evaluate Key Configurations (nix-builder) (push) Has been cancelled
CI / Evaluate Key Configurations (nix-desktop1) (push) Has been cancelled
CI / Format Check (push) Has been cancelled
Revert experimental changes, use ragenix defaults
2026-01-30 21:45:55 +00:00

207 lines
6.8 KiB
Nix

# ============================================================================
# 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,
pkgs,
...
}:
with lib;
let
cfg = config.athenix.sw;
secretsPath = ../secrets;
# Get the fleet-assigned hostname (avoids issues with LXC empty hostnames)
hostname = config.athenix.host.name;
# 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
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}/";
};
}