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
207 lines
6.8 KiB
Nix
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}/";
|
|
};
|
|
}
|