diff --git a/fleet/default.nix b/fleet/default.nix index 13cda54..b732f4c 100644 --- a/fleet/default.nix +++ b/fleet/default.nix @@ -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; diff --git a/fleet/fs.nix b/fleet/fs.nix index a689d3b..b1a2098 100644 --- a/fleet/fs.nix +++ b/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 [ { diff --git a/inventory.nix b/inventory.nix index 5dd75de..476ee96 100644 --- a/inventory.nix +++ b/inventory.nix @@ -129,7 +129,7 @@ }; "usda-dash".external = { url = "https://git.factory.uga.edu/MODEL/usda-dash-config.git"; - rev = "9d2783981de95bdaaf46a8f0743245566b028d64"; + rev = "b3c17434202aa4b4aa39ad5648c98c8c539e9a25"; submodules = false; }; }; diff --git a/secrets.nix b/secrets.nix new file mode 100644 index 0000000..2fd6786 --- /dev/null +++ b/secrets.nix @@ -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 diff --git a/secrets/README.md b/secrets/README.md index 2d81faf..8d683f3 100644 --- a/secrets/README.md +++ b/secrets/README.md @@ -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 diff --git a/secrets/admins/temp-admin.age.pub b/secrets/admins/temp-admin.age.pub new file mode 100644 index 0000000..1c5d568 --- /dev/null +++ b/secrets/admins/temp-admin.age.pub @@ -0,0 +1 @@ +age14emzyraytqzmre58c452t07rtcj87cwqwmd9z3gj7upugtxk8s3sda5tju diff --git a/secrets/core b/secrets/core new file mode 100644 index 0000000..fae4949 Binary files /dev/null and b/secrets/core differ diff --git a/secrets/create-secret.sh b/secrets/create-secret.sh new file mode 100755 index 0000000..3a739b9 --- /dev/null +++ b/secrets/create-secret.sh @@ -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 [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 [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 diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 0b33e79..880aa47 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -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 = diff --git a/secrets/usda-dash/default.nix b/secrets/usda-dash/default.nix new file mode 100644 index 0000000..e382653 --- /dev/null +++ b/secrets/usda-dash/default.nix @@ -0,0 +1,8 @@ +# Host-specific secret configuration for usda-dash +{ + usda-vision-azure-env = { + mode = "0600"; + owner = "root"; + group = "root"; + }; +} diff --git a/secrets/usda-dash/usda-vision-env.age b/secrets/usda-dash/usda-vision-env.age new file mode 100644 index 0000000..56dd50e Binary files /dev/null and b/secrets/usda-dash/usda-vision-env.age differ diff --git a/sw/secrets.nix b/sw/secrets.nix index 70df575..daca743 100644 --- a/sw/secrets.nix +++ b/sw/secrets.nix @@ -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 { };