feat: Use age for env secret managment
Some checks failed
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 / 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 / Format Check (push) Has been cancelled
Some checks failed
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 / 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 / Format Check (push) Has been cancelled
This commit is contained in:
@@ -159,6 +159,8 @@ let
|
||||
./common.nix
|
||||
overrideModule
|
||||
{ networking.hostName = hostName; }
|
||||
# Set athenix.host.name for secrets and other modules to use
|
||||
{ athenix.host.name = hostName; }
|
||||
{
|
||||
# Inject user definitions from flake-parts level
|
||||
config.athenix.users = lib.mapAttrs (_: user: lib.mapAttrs (_: lib.mkDefault) user) users;
|
||||
|
||||
15
fleet/fs.nix
15
fleet/fs.nix
@@ -17,8 +17,16 @@ let
|
||||
in
|
||||
{
|
||||
options.athenix = {
|
||||
host.filesystem = {
|
||||
device = lib.mkOption {
|
||||
host = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = ''
|
||||
Fleet-assigned hostname for this system.
|
||||
Used for secrets discovery and other host-specific configurations.
|
||||
'';
|
||||
};
|
||||
filesystem = {
|
||||
device = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
@@ -54,8 +62,7 @@ in
|
||||
'';
|
||||
example = "32G";
|
||||
};
|
||||
};
|
||||
};
|
||||
}; }; };
|
||||
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
};
|
||||
"usda-dash".external = {
|
||||
url = "https://git.factory.uga.edu/MODEL/usda-dash-config.git";
|
||||
rev = "9d2783981de95bdaaf46a8f0743245566b028d64";
|
||||
rev = "b3c17434202aa4b4aa39ad5648c98c8c539e9a25";
|
||||
submodules = false;
|
||||
};
|
||||
};
|
||||
|
||||
203
secrets.nix
Normal file
203
secrets.nix
Normal file
@@ -0,0 +1,203 @@
|
||||
# ============================================================================
|
||||
# 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
|
||||
@@ -78,29 +78,48 @@ age -R secrets/nix-builder/ssh_host_ed25519_key.pub \
|
||||
-o secrets/nix-builder/ssh_host_key.age < /etc/ssh/ssh_host_ed25519_key
|
||||
```
|
||||
|
||||
### 4. Using ragenix CLI (Recommended)
|
||||
### 4. Creating and Editing Secrets
|
||||
|
||||
The `ragenix` CLI tool simplifies secret management. The `secrets/secrets.nix` file **automatically discovers** hosts and their keys from the directory structure:
|
||||
**For new secrets**, use the helper script (automatically determines recipients):
|
||||
|
||||
```bash
|
||||
cd secrets/
|
||||
|
||||
# Create a host-specific secret
|
||||
./create-secret.sh usda-dash/database-url.age <<< "postgresql://..."
|
||||
|
||||
# Create a global secret
|
||||
echo "shared-api-key" | ./create-secret.sh global/api-key.age
|
||||
|
||||
# From a file
|
||||
./create-secret.sh nix-builder/ssh-key.age < ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
The script automatically includes the correct recipients:
|
||||
- **Host-specific**: that host's keys + global keys + admin keys
|
||||
- **Global**: all host keys + admin keys
|
||||
|
||||
**To edit existing secrets**, use `ragenix`:
|
||||
|
||||
```bash
|
||||
# Install ragenix
|
||||
nix shell github:yaxitech/ragenix
|
||||
|
||||
# Edit a secret (creates if doesn't exist)
|
||||
# Recipients are automatically determined based on the path:
|
||||
# - secrets/global/*.age -> encrypted for ALL hosts + admins
|
||||
# - secrets/{hostname}/*.age -> encrypted for that host + global keys + admins
|
||||
ragenix -e secrets/global/example.age
|
||||
# Edit an existing secret (you must have a decryption key)
|
||||
ragenix -e secrets/global/existing-secret.age
|
||||
|
||||
# Re-key all secrets after adding/removing hosts
|
||||
# Re-key all secrets after adding new hosts
|
||||
ragenix -r
|
||||
```
|
||||
|
||||
The `secrets.nix` file automatically:
|
||||
- **Discovers hosts** from directory names in `secrets/`
|
||||
- **Reads age public keys** from `.age.pub` files in each directory
|
||||
- **Generates recipient lists** based on secret location (global vs host-specific)
|
||||
- **Includes admin keys** from `secrets/admins/*.age.pub` for editing
|
||||
**Why create with `age` first?** Ragenix requires the `.age` file to exist before editing. The `secrets/secrets.nix` configuration auto-discovers recipients from the directory structure, but ragenix doesn't support wildcard patterns for creating new files.
|
||||
|
||||
**Recipient management** is automatic:
|
||||
- **Global secrets** (`secrets/global/*.age`): encrypted for ALL hosts + admins
|
||||
- **Host secrets** (`secrets/{hostname}/*.age`): encrypted for that host + global keys + admins
|
||||
- **Admin keys** from `secrets/admins/*.age.pub` allow editing from your workstation
|
||||
|
||||
After creating new .age files with `age`, use `ragenix -r` to re-key all secrets with the updated recipient configuration.
|
||||
|
||||
To add admin keys for editing secrets:
|
||||
```bash
|
||||
|
||||
1
secrets/admins/temp-admin.age.pub
Normal file
1
secrets/admins/temp-admin.age.pub
Normal file
@@ -0,0 +1 @@
|
||||
age14emzyraytqzmre58c452t07rtcj87cwqwmd9z3gj7upugtxk8s3sda5tju
|
||||
BIN
secrets/core
Normal file
BIN
secrets/core
Normal file
Binary file not shown.
121
secrets/create-secret.sh
Executable file
121
secrets/create-secret.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Create a new age-encrypted secret with auto-determined recipients
|
||||
# Usage: ./create-secret.sh <path> [content]
|
||||
# path: relative to secrets/ (e.g., "usda-dash/my-secret.age" or "global/shared.age")
|
||||
# content: stdin if not provided
|
||||
|
||||
SECRETS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <path> [content]" >&2
|
||||
echo "Examples:" >&2
|
||||
echo " $0 usda-dash/database-url.age <<< 'postgresql://...'" >&2
|
||||
echo " $0 global/api-key.age < secret-file.txt" >&2
|
||||
echo " echo 'secret' | $0 nix-builder/token.age" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SECRET_PATH="$1"
|
||||
shift
|
||||
|
||||
# Extract directory from path (e.g., "usda-dash/file.age" -> "usda-dash")
|
||||
SECRET_DIR="$(dirname "$SECRET_PATH")"
|
||||
SECRET_FILE="$(basename "$SECRET_PATH")"
|
||||
|
||||
# Ensure .age extension
|
||||
if [[ ! "$SECRET_FILE" =~ \.age$ ]]; then
|
||||
echo "Error: Secret file must have .age extension" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_FILE="$SECRETS_DIR/$SECRET_PATH"
|
||||
|
||||
# Ensure target directory exists
|
||||
mkdir -p "$(dirname "$TARGET_FILE")"
|
||||
|
||||
# Collect recipient keys
|
||||
RECIPIENTS=()
|
||||
|
||||
if [ "$SECRET_DIR" = "global" ]; then
|
||||
echo "Creating global secret (encrypted for all hosts + admins)..." >&2
|
||||
|
||||
# Add all host keys
|
||||
for host_dir in "$SECRETS_DIR"/*/; do
|
||||
host_name="$(basename "$host_dir")"
|
||||
# Skip non-host directories
|
||||
if [ "$host_name" = "admins" ] || [ "$host_name" = "global" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Add all .age.pub files from this host
|
||||
while IFS= read -r -d '' key_file; do
|
||||
RECIPIENTS+=("$key_file")
|
||||
done < <(find "$host_dir" -maxdepth 1 -name "*.age.pub" -print0)
|
||||
done
|
||||
|
||||
# Add global keys
|
||||
while IFS= read -r -d '' key_file; do
|
||||
RECIPIENTS+=("$key_file")
|
||||
done < <(find "$SECRETS_DIR/global" -maxdepth 1 -name "*.age.pub" -print0 2>/dev/null || true)
|
||||
|
||||
else
|
||||
echo "Creating host-specific secret for $SECRET_DIR..." >&2
|
||||
|
||||
# Check if host directory exists
|
||||
if [ ! -d "$SECRETS_DIR/$SECRET_DIR" ]; then
|
||||
echo "Error: Host directory $SECRET_DIR does not exist" >&2
|
||||
echo "Create it first: mkdir -p secrets/$SECRET_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add this host's keys
|
||||
while IFS= read -r -d '' key_file; do
|
||||
RECIPIENTS+=("$key_file")
|
||||
done < <(find "$SECRETS_DIR/$SECRET_DIR" -maxdepth 1 -name "*.age.pub" -print0)
|
||||
|
||||
# Add global keys (so global hosts can also decrypt)
|
||||
while IFS= read -r -d '' key_file; do
|
||||
RECIPIENTS+=("$key_file")
|
||||
done < <(find "$SECRETS_DIR/global" -maxdepth 1 -name "*.age.pub" -print0 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Add admin keys (for editing from workstations)
|
||||
if [ -d "$SECRETS_DIR/admins" ]; then
|
||||
while IFS= read -r -d '' key_file; do
|
||||
RECIPIENTS+=("$key_file")
|
||||
done < <(find "$SECRETS_DIR/admins" -maxdepth 1 -name "*.age.pub" -print0 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Check if we have any recipients
|
||||
if [ ${#RECIPIENTS[@]} -eq 0 ]; then
|
||||
echo "Error: No recipient keys found!" >&2
|
||||
echo "Run ./update-age-keys.sh first to generate .age.pub files" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found ${#RECIPIENTS[@]} recipient key(s):" >&2
|
||||
for key in "${RECIPIENTS[@]}"; do
|
||||
echo " - $(basename "$key")" >&2
|
||||
done
|
||||
|
||||
# Create recipient list file (temporary)
|
||||
RECIPIENT_LIST=$(mktemp)
|
||||
trap "rm -f $RECIPIENT_LIST" EXIT
|
||||
|
||||
for key in "${RECIPIENTS[@]}"; do
|
||||
cat "$key" >> "$RECIPIENT_LIST"
|
||||
done
|
||||
|
||||
# Encrypt the secret
|
||||
if [ $# -gt 0 ]; then
|
||||
# Content provided as argument
|
||||
echo "$@" | age -R "$RECIPIENT_LIST" -o "$TARGET_FILE"
|
||||
else
|
||||
# Content from stdin
|
||||
age -R "$RECIPIENT_LIST" -o "$TARGET_FILE"
|
||||
fi
|
||||
|
||||
echo "✓ Created $TARGET_FILE" >&2
|
||||
echo " Edit with: ragenix -e secrets/$SECRET_PATH" >&2
|
||||
@@ -63,7 +63,7 @@ let
|
||||
|
||||
nameValuePair = name: value: { inherit name value; };
|
||||
|
||||
secretsPath = ./.;
|
||||
secretsPath = ./secrets;
|
||||
|
||||
# Helper to convert SSH public key content to age public key
|
||||
sshToAge =
|
||||
|
||||
8
secrets/usda-dash/default.nix
Normal file
8
secrets/usda-dash/default.nix
Normal file
@@ -0,0 +1,8 @@
|
||||
# Host-specific secret configuration for usda-dash
|
||||
{
|
||||
usda-vision-azure-env = {
|
||||
mode = "0600";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
};
|
||||
}
|
||||
BIN
secrets/usda-dash/usda-vision-env.age
Normal file
BIN
secrets/usda-dash/usda-vision-env.age
Normal file
Binary file not shown.
@@ -20,8 +20,8 @@ let
|
||||
cfg = config.athenix.sw;
|
||||
secretsPath = ../secrets;
|
||||
|
||||
# Get the current hostname
|
||||
hostname = config.networking.hostName;
|
||||
# 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 { };
|
||||
|
||||
Reference in New Issue
Block a user