diff --git a/flake.lock b/flake.lock index 15a8200..335d05f 100644 --- a/flake.lock +++ b/flake.lock @@ -239,6 +239,24 @@ "inputs": { "systems": "systems_4" }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_4": { + "inputs": { + "systems": "systems_5" + }, "locked": { "lastModified": 1681202837, "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", @@ -636,6 +654,7 @@ "nixos-wsl": "nixos-wsl", "nixpkgs": "nixpkgs_2", "nixpkgs-old-kernel": "nixpkgs-old-kernel", + "usda-vision": "usda-vision", "vscode-server": "vscode-server" } }, @@ -720,6 +739,21 @@ "type": "github" } }, + "systems_5": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "treefmt-nix": { "inputs": { "nixpkgs": [ @@ -742,13 +776,34 @@ "type": "github" } }, - "vscode-server": { + "usda-vision": { "inputs": { "flake-utils": "flake-utils_3", "nixpkgs": [ "nixpkgs" ] }, + "locked": { + "lastModified": 1769795328, + "narHash": "sha256-zrmwORj8qk2ONDYFs0ZpzdVLxnolMebNrDIfV8KoNao=", + "ref": "refs/heads/main", + "rev": "20a01c89afd8e54fba2f345fb9d72eec1cbafda4", + "revCount": 118, + "type": "git", + "url": "https://git.factory.uga.edu/MODEL/usda-vision.git" + }, + "original": { + "type": "git", + "url": "https://git.factory.uga.edu/MODEL/usda-vision.git" + } + }, + "vscode-server": { + "inputs": { + "flake-utils": "flake-utils_4", + "nixpkgs": [ + "nixpkgs" + ] + }, "locked": { "lastModified": 1753541826, "narHash": "sha256-foGgZu8+bCNIGeuDqQ84jNbmKZpd+JvnrL2WlyU4tuU=", diff --git a/flake.nix b/flake.nix index 1801d7d..5dd3570 100644 --- a/flake.nix +++ b/flake.nix @@ -59,6 +59,12 @@ url = "github:nix-community/NixOS-WSL/main"; inputs.nixpkgs.follows = "nixpkgs"; }; + + # USDA Vision Dashboard application + usda-vision = { + url = "git+https://git.factory.uga.edu/MODEL/usda-vision.git"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = diff --git a/inventory.nix b/inventory.nix index 764d073..5dd75de 100644 --- a/inventory.nix +++ b/inventory.nix @@ -129,8 +129,8 @@ }; "usda-dash".external = { url = "https://git.factory.uga.edu/MODEL/usda-dash-config.git"; - rev = "dab32f5884895cead0fae28cb7d88d17951d0c12"; - submodules = true; + rev = "9d2783981de95bdaaf46a8f0743245566b028d64"; + submodules = false; }; }; overrides = { diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..ee12cbc --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,225 @@ +# Secrets Management with Agenix + +This directory contains age-encrypted secrets for Athenix hosts. Secrets are automatically loaded based on directory structure. + +## Directory Structure + +``` +secrets/ +├── global/ # Secrets installed on ALL systems +│ ├── default.nix # Optional: Custom config for global secrets +│ └── example.age # Decrypted to /run/agenix/example on all hosts +├── nix-builder/ # Secrets only for nix-builder host +│ ├── default.nix # Optional: Custom config for nix-builder secrets +│ └── ssh_host_ed25519_key.age +└── usda-dash/ # Secrets only for usda-dash host + └── ssh_host_ed25519_key.age +``` + +## How It Works + +1. **Global secrets** (`./secrets/global/*.age`) are installed on every system +2. **Host-specific secrets** (`./secrets/{hostname}/*.age`) are only installed on matching hosts +3. Only `.age` encrypted files are loaded; `.pub` public keys are ignored +4. Secrets are decrypted at boot to `/run/agenix/{secret-name}` with mode `0400` and owner `root:root` +5. **Custom configurations** can be defined in `default.nix` files within each directory + +## Creating Secrets + +### 1. Generate Age Keys + +For a new host, generate an age identity: +```bash +# On the target system +mkdir -p /etc/age +age-keygen -o /etc/age/identity.key +chmod 600 /etc/age/identity.key +``` + +Or use SSH host keys (automatically done by Athenix): +```bash +# Get the age public key from SSH host key +nix shell nixpkgs#ssh-to-age -c sh -c 'cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age' +``` + +### 2. Store Public Keys + +Save the public key to `secrets/{hostname}/` for reference: +```bash +# Example for nix-builder +echo "age1..." > secrets/nix-builder/identity.pub +``` + +Or from SSH host key: +```bash +cat /etc/ssh/ssh_host_ed25519_key.pub > secrets/nix-builder/ssh_host_ed25519_key.pub +``` + +### 3. Encrypt Secrets + +Encrypt a secret for specific hosts: +```bash +# For a single host +age -r age1publickey... -o secrets/nix-builder/my-secret.age <<< "secret value" + +# For multiple hosts (recipient list) +age -R recipients.txt -o secrets/global/shared-secret.age < plaintext-file + +# Using SSH public keys +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) + +The `ragenix` CLI tool simplifies secret management: + +```bash +# Install ragenix +nix shell github:yaxitech/ragenix + +# Edit a secret (creates if doesn't exist) +ragenix -e secrets/global/example.age + +# Re-key secrets after adding/removing hosts +ragenix -r +``` + +Create a `secrets.nix` file in the repository root to define recipients: +```nix +# secrets.nix +let + # System public keys (age format) + nix-builder = "age1..."; + usda-dash = "age1..."; + + # User keys for editing secrets + admin = "age1..."; + + allHosts = [ nix-builder usda-dash ]; +in +{ + "secrets/global/example.age".publicKeys = allHosts ++ [ admin ]; + "secrets/nix-builder/ssh_host_key.age".publicKeys = [ nix-builder admin ]; + "secrets/usda-dash/ssh_host_key.age".publicKeys = [ usda-dash admin ]; +} +``` + +## Using Secrets in Configuration + +Secrets are automatically loaded. Reference them in your NixOS configuration: + +```nix +# Example: Using a secret for a service +services.myservice = { + enable = true; + passwordFile = config.age.secrets.my-password.path; # /run/agenix/my-password +}; + +# Example: Setting up SSH host key from secret +services.openssh = { + hostKeys = [{ + path = config.age.secrets.ssh_host_ed25519_key.path; + type = "ed25519"; + }]; +}; +``` + +## Custom Secret Configuration + +For secrets needing custom permissions, use `athenix.sw.secrets.extraSecrets`: + +```nix +# In inventory.nix or host config +athenix.sw.secrets.extraSecrets = { + "nginx-cert" = { + file = ./secrets/custom/cert.age; + mode = "0440"; + owner = "nginx"; + group = "nginx"; + }; +}; +``` + +### Using default.nix in Secret Directories + +Alternatively, create a `default.nix` file in the secret directory to configure all secrets in that directory: + +```nix +# secrets/global/default.nix +{ + "example" = { + mode = "0440"; # Custom file mode (default: "0400") + owner = "nginx"; # Custom owner (default: "root") + group = "nginx"; # Custom group (default: "root") + path = "/run/secrets/example"; # Custom path (default: /run/agenix/{name}) + }; + + "api-key" = { + mode = "0400"; + owner = "myservice"; + group = "myservice"; + }; +} +``` + +The `default.nix` file should return an attribute set where: +- **Keys** are secret names (without the `.age` extension) +- **Values** are configuration objects with optional fields: + - `mode` - File permissions (string, e.g., `"0440"`) + - `owner` - File owner (string, e.g., `"nginx"`) + - `group` - File group (string, e.g., `"nginx"`) + - `path` - Custom installation path (string, e.g., `"/custom/path"`) + +Secrets not listed in `default.nix` will use default settings. + +## Security Best Practices + +1. **Never commit unencrypted secrets** - Only `.age` and `.pub` files belong in this directory +2. **Use host-specific secrets** when possible - Limit exposure by using hostname directories +3. **Rotate secrets regularly** - Re-encrypt with new keys periodically +4. **Backup age identity keys** - Store `/etc/age/identity.key` securely offline +5. **Use SSH keys** - Leverage existing SSH host keys for age encryption when possible +6. **Pin to commits** - When using external secrets modules, always use `rev = "commit-hash"` + +## Converting SSH Keys to Age Format + +```bash +# Convert SSH public key to age public key +nix shell nixpkgs#ssh-to-age -c ssh-to-age < secrets/nix-builder/ssh_host_ed25519_key.pub + +# Convert SSH private key to age identity (for editing secrets) +nix shell nixpkgs#ssh-to-age -c ssh-to-age -private-key -i ~/.ssh/id_ed25519 +``` + +## Disabling Automatic Secrets + +To disable automatic secret loading: + +```nix +# In inventory.nix or host config +athenix.sw.secrets.enable = false; +``` + +## Troubleshooting + +### Secret not found +- Ensure the `.age` file exists in `secrets/global/` or `secrets/{hostname}/` +- Check `hostname` matches directory name: `echo $HOSTNAME` on the target system +- Run `nix flake check` to verify secrets are discovered + +### Permission denied +- Verify secret permissions in `/run/agenix/` +- Check if custom permissions are needed (use `extraSecrets`) +- Ensure the service user/group has access to the secret file + +### Age decrypt failed +- Verify the host's age identity exists: `ls -l /etc/age/identity.key` +- Check that the secret was encrypted with the host's public key +- Confirm SSH host key hasn't changed (would change derived age key) + +## References + +- [ragenix GitHub](https://github.com/yaxitech/ragenix) +- [agenix upstream](https://github.com/ryantm/agenix) +- [age encryption tool](https://age-encryption.org/) diff --git a/secrets/global/default.nix b/secrets/global/default.nix new file mode 100644 index 0000000..1dbd446 --- /dev/null +++ b/secrets/global/default.nix @@ -0,0 +1,10 @@ +# Custom configuration for global secrets +# This file defines additional options for secrets beyond the auto-discovered .age files +{ + # Example: Custom permissions for the example secret + example = { + mode = "0440"; + owner = "root"; + group = "root"; + }; +} diff --git a/secrets/global/example.age b/secrets/global/example.age new file mode 100644 index 0000000..02c175a --- /dev/null +++ b/secrets/global/example.age @@ -0,0 +1,6 @@ +age-encryption.org/v1 +-> X25519 2lgS/8zamn6WD6eKM+Mcms+TY/PlzLr43WoWk6EZ7BU +/H6DRcTul3ZtlfyYRFS6v+YZIYYi/sNdkaqZYdKbBIE +--- NSMzaAQArlhVdSuRICYD6lAiq/nOd2c5n/wcye4FXao +D]t=m +@ Antͱn5/-ߞC|0'!ܻqjmoW diff --git a/secrets/global/hunter_halloran_key.pub b/secrets/global/hunter_halloran_key.pub new file mode 100644 index 0000000..efdbdcc --- /dev/null +++ b/secrets/global/hunter_halloran_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBC7xzHxY2BfFUybMvG4wHSF9oEAGzRiLTFEndLvWV/X hdh20267@engr733847d.engr.uga.edu \ No newline at end of file diff --git a/secrets/nix-builder/default.nix b/secrets/nix-builder/default.nix new file mode 100644 index 0000000..bac0b64 --- /dev/null +++ b/secrets/nix-builder/default.nix @@ -0,0 +1,10 @@ +# Host-specific secret configuration for nix-builder +{ + # SSH host key should be readable by sshd + ssh_host_ed25519_key = { + mode = "0600"; + owner = "root"; + group = "root"; + path = "/etc/ssh/ssh_host_ed25519_key"; + }; +} diff --git a/secrets/nix-builder/ssh_host_ed25519_key.pub b/secrets/nix-builder/ssh_host_ed25519_key.pub new file mode 100644 index 0000000..8483a03 --- /dev/null +++ b/secrets/nix-builder/ssh_host_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHW0Hxldo3EkniotitzJ2XiZbIq9Rfo27yI1+sBrgG39 root@nix-builder \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix new file mode 100644 index 0000000..08ef0a4 --- /dev/null +++ b/secrets/secrets.nix @@ -0,0 +1,52 @@ +# ============================================================================ +# Agenix Secret Recipients Configuration +# ============================================================================ +# This file defines which age public keys can decrypt which secrets. +# Used by the ragenix CLI tool for encrypting/editing secrets. +# +# Usage: +# ragenix -e secrets/global/example.age # Edit/create secret +# ragenix -r # Re-key all secrets + +let + # ========== System Public Keys (Age Format) ========== + # Convert SSH host keys to age format: + # ssh-to-age < secrets/{hostname}/ssh_host_ed25519_key.pub + + # Example (replace with actual age keys): + # nix-builder = "age1..."; + # usda-dash = "age1..."; + + # ========== User Public Keys (for editing secrets) ========== + # These are personal age keys for administrators who need to edit secrets + # Generate with: age-keygen + + # Example: + # admin1 = "age1..."; + # admin2 = "age1..."; + + # ========== Host Groups ========== + allHosts = [ + # Add all system keys here + # nix-builder + # usda-dash + ]; + + admins = [ + # Add all admin user keys here + # admin1 + # admin2 + ]; + +in +{ + # Global secrets (encrypted for all hosts + admins) + # "secrets/global/example.age".publicKeys = allHosts ++ admins; + + # Host-specific secrets (encrypted for specific host + admins) + # "secrets/nix-builder/ssh_host_ed25519_key.age".publicKeys = [ nix-builder ] ++ admins; + # "secrets/usda-dash/ssh_host_ed25519_key.age".publicKeys = [ usda-dash ] ++ admins; + + # NOTE: Until you populate the keys above, you can create secrets with: + # age -r -o secrets/path/to/secret.age <<< "secret content" +} diff --git a/secrets/usda-dash/ssh_host_ed25519_key.pub b/secrets/usda-dash/ssh_host_ed25519_key.pub new file mode 100644 index 0000000..13d977d --- /dev/null +++ b/secrets/usda-dash/ssh_host_ed25519_key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHI73LOEK2RgfjhZWpryntlLbx0LouHrhQ6v0vZu4Etr root@usda-dash \ No newline at end of file diff --git a/sw/default.nix b/sw/default.nix index b98c6a1..4403c67 100644 --- a/sw/default.nix +++ b/sw/default.nix @@ -26,6 +26,7 @@ in ./gc.nix ./updater.nix ./update-ref.nix + ./secrets.nix ./desktop ./headless ./builders diff --git a/sw/secrets.nix b/sw/secrets.nix new file mode 100644 index 0000000..d58ce23 --- /dev/null +++ b/sw/secrets.nix @@ -0,0 +1,210 @@ +# ============================================================================ +# 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, + ... +}: + +with lib; + +let + cfg = config.athenix.sw; + secretsPath = ../secrets; + + # Get the current hostname + hostname = config.networking.hostName; + + # 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 + # These are added in addition to agenix's defaults + 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}/"; + }; +}