Replace sops-nix secrets with OpenBao vault secrets across all hosts. Hardcode root password hash, add extractKey option to vault-secrets module, update Terraform with secrets/policies for all hosts, and create AppRole provisioning playbook. Hosts migrated: ha1, monitoring01, ns1, ns2, http-proxy, nix-cache01 Wave 1 hosts (nats1, jelly01, pgdb1) get AppRole policies only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
258 lines
8.0 KiB
Nix
258 lines
8.0 KiB
Nix
{ config, lib, pkgs, ... }:
|
|
|
|
with lib;
|
|
|
|
let
|
|
cfg = config.vault;
|
|
|
|
# Import vault-fetch package
|
|
vault-fetch = pkgs.callPackage ../scripts/vault-fetch { };
|
|
|
|
# Secret configuration type
|
|
secretType = types.submodule ({ name, config, ... }: {
|
|
options = {
|
|
secretPath = mkOption {
|
|
type = types.str;
|
|
description = ''
|
|
Path to the secret in Vault (without /v1/secret/data/ prefix).
|
|
Example: "hosts/monitoring01/grafana-admin"
|
|
'';
|
|
};
|
|
|
|
outputDir = mkOption {
|
|
type = types.str;
|
|
default = "/run/secrets/${name}";
|
|
description = ''
|
|
Directory where secret files will be written.
|
|
Each key in the secret becomes a separate file.
|
|
'';
|
|
};
|
|
|
|
cacheDir = mkOption {
|
|
type = types.str;
|
|
default = "/var/lib/vault/cache/${name}";
|
|
description = ''
|
|
Directory for caching secrets when Vault is unreachable.
|
|
'';
|
|
};
|
|
|
|
owner = mkOption {
|
|
type = types.str;
|
|
default = "root";
|
|
description = "Owner of the secret files";
|
|
};
|
|
|
|
group = mkOption {
|
|
type = types.str;
|
|
default = "root";
|
|
description = "Group of the secret files";
|
|
};
|
|
|
|
mode = mkOption {
|
|
type = types.str;
|
|
default = "0400";
|
|
description = "Permissions mode for secret files";
|
|
};
|
|
|
|
restartTrigger = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Whether to create a systemd timer that periodically restarts
|
|
services using this secret to rotate credentials.
|
|
'';
|
|
};
|
|
|
|
restartInterval = mkOption {
|
|
type = types.str;
|
|
default = "weekly";
|
|
description = ''
|
|
How often to restart services for secret rotation.
|
|
Uses systemd.time format (e.g., "daily", "weekly", "monthly").
|
|
Only applies if restartTrigger is true.
|
|
'';
|
|
};
|
|
|
|
extractKey = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
description = ''
|
|
Extract a single key from the vault secret JSON and write it as a
|
|
plain file instead of a directory of files. When set, outputDir
|
|
becomes a file path rather than a directory path.
|
|
'';
|
|
};
|
|
|
|
services = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [];
|
|
description = ''
|
|
List of systemd service names that depend on this secret.
|
|
Used for periodic restart if restartTrigger is enabled.
|
|
'';
|
|
};
|
|
};
|
|
});
|
|
|
|
in
|
|
{
|
|
options.vault = {
|
|
enable = mkEnableOption "Vault secrets management" // {
|
|
default = false;
|
|
};
|
|
|
|
secrets = mkOption {
|
|
type = types.attrsOf secretType;
|
|
default = {};
|
|
description = ''
|
|
Secrets to fetch from Vault.
|
|
Each attribute name becomes a secret identifier.
|
|
'';
|
|
example = literalExpression ''
|
|
{
|
|
grafana-admin = {
|
|
secretPath = "hosts/monitoring01/grafana-admin";
|
|
owner = "grafana";
|
|
group = "grafana";
|
|
restartTrigger = true;
|
|
restartInterval = "daily";
|
|
services = [ "grafana" ];
|
|
};
|
|
}
|
|
'';
|
|
};
|
|
|
|
criticalServices = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ "bind" "openbao" "step-ca" ];
|
|
description = ''
|
|
Services that should never get auto-restart timers for secret rotation.
|
|
These are critical infrastructure services where automatic restarts
|
|
could cause cascading failures.
|
|
'';
|
|
};
|
|
|
|
vaultAddress = mkOption {
|
|
type = types.str;
|
|
default = "https://vault01.home.2rjus.net:8200";
|
|
description = "Vault server address";
|
|
};
|
|
|
|
skipTlsVerify = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Skip TLS certificate verification (useful for self-signed certs)";
|
|
};
|
|
};
|
|
|
|
config = mkIf (cfg.enable && cfg.secrets != {}) {
|
|
# Create systemd services for fetching secrets and rotation
|
|
systemd.services =
|
|
# Fetch services
|
|
(mapAttrs' (name: secretCfg: nameValuePair "vault-secret-${name}" {
|
|
description = "Fetch Vault secret: ${name}";
|
|
before = map (svc: "${svc}.service") secretCfg.services;
|
|
wantedBy = [ "multi-user.target" ];
|
|
|
|
# Ensure vault-fetch is available
|
|
path = [ vault-fetch ];
|
|
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
|
|
# Fetch the secret
|
|
ExecStart = pkgs.writeShellScript "fetch-${name}" (''
|
|
set -euo pipefail
|
|
|
|
# Set Vault environment variables
|
|
export VAULT_ADDR="${cfg.vaultAddress}"
|
|
export VAULT_SKIP_VERIFY="${if cfg.skipTlsVerify then "1" else "0"}"
|
|
'' + (if secretCfg.extractKey != null then ''
|
|
# Fetch to temporary directory, then extract single key
|
|
TMPDIR=$(mktemp -d)
|
|
trap "rm -rf $TMPDIR" EXIT
|
|
|
|
${vault-fetch}/bin/vault-fetch \
|
|
"${secretCfg.secretPath}" \
|
|
"$TMPDIR" \
|
|
"${secretCfg.cacheDir}"
|
|
|
|
# Extract the specified key and write as a single file
|
|
if [ ! -f "$TMPDIR/${secretCfg.extractKey}" ]; then
|
|
echo "ERROR: Key '${secretCfg.extractKey}' not found in secret" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Ensure parent directory exists
|
|
mkdir -p "$(dirname "${secretCfg.outputDir}")"
|
|
cp "$TMPDIR/${secretCfg.extractKey}" "${secretCfg.outputDir}"
|
|
chown ${secretCfg.owner}:${secretCfg.group} "${secretCfg.outputDir}"
|
|
chmod ${secretCfg.mode} "${secretCfg.outputDir}"
|
|
'' else ''
|
|
# Fetch secret as directory of files
|
|
${vault-fetch}/bin/vault-fetch \
|
|
"${secretCfg.secretPath}" \
|
|
"${secretCfg.outputDir}" \
|
|
"${secretCfg.cacheDir}"
|
|
|
|
# Set ownership and permissions
|
|
chown -R ${secretCfg.owner}:${secretCfg.group} "${secretCfg.outputDir}"
|
|
chmod ${secretCfg.mode} "${secretCfg.outputDir}"/*
|
|
''));
|
|
|
|
# Logging
|
|
StandardOutput = "journal";
|
|
StandardError = "journal";
|
|
};
|
|
}) cfg.secrets)
|
|
//
|
|
# Rotation services
|
|
(mapAttrs' (name: secretCfg: nameValuePair "vault-secret-rotate-${name}"
|
|
(mkIf (secretCfg.restartTrigger && secretCfg.services != [] &&
|
|
!any (svc: elem svc cfg.criticalServices) secretCfg.services) {
|
|
description = "Rotate Vault secret and restart services: ${name}";
|
|
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
};
|
|
|
|
script = ''
|
|
# Restart the secret fetch service
|
|
systemctl restart vault-secret-${name}.service
|
|
|
|
# Restart all dependent services
|
|
${concatMapStringsSep "\n" (svc: "systemctl restart ${svc}.service") secretCfg.services}
|
|
'';
|
|
})
|
|
) cfg.secrets);
|
|
|
|
# Create systemd timers for periodic secret rotation (if enabled)
|
|
systemd.timers = mapAttrs' (name: secretCfg: nameValuePair "vault-secret-rotate-${name}"
|
|
(mkIf (secretCfg.restartTrigger && secretCfg.services != [] &&
|
|
!any (svc: elem svc cfg.criticalServices) secretCfg.services) {
|
|
description = "Rotate Vault secret and restart services: ${name}";
|
|
wantedBy = [ "timers.target" ];
|
|
|
|
timerConfig = {
|
|
OnCalendar = secretCfg.restartInterval;
|
|
Persistent = true;
|
|
RandomizedDelaySec = "1h";
|
|
};
|
|
})
|
|
) cfg.secrets;
|
|
|
|
# Ensure runtime and cache directories exist
|
|
systemd.tmpfiles.rules =
|
|
[ "d /run/secrets 0755 root root -" ] ++
|
|
[ "d /var/lib/vault/cache 0700 root root -" ] ++
|
|
flatten (mapAttrsToList (name: secretCfg: [
|
|
# When extractKey is set, outputDir is a file path - create parent dir instead
|
|
(if secretCfg.extractKey != null
|
|
then "d ${dirOf secretCfg.outputDir} 0755 root root -"
|
|
else "d ${secretCfg.outputDir} 0755 root root -")
|
|
"d ${secretCfg.cacheDir} 0700 root root -"
|
|
]) cfg.secrets);
|
|
};
|
|
}
|