#!/usr/bin/env bash # Manage homelab SSH host keys and sops age keys in the Proton Pass HomeLab vault. # # Vault naming (HomeLab vault; override with HOMELAB_PASS_VAULT): # # ssh_host# ssh-key item ↔ ~/.config/homelab//ssh_host_ed25519_key{,.pub} # age# note item ↔ ~/.config/homelab//age.txt # age#admin note item ↔ ~/.config/sops/age/keys.txt # # Usage: # ./scripts/keys store [--force] [...] # ./scripts/keys restore [...] # # store: upload local keys to the vault. Skips items that already exist # unless --force is given (deletes the existing item first). # restore: download vault items to local files with correct permissions. # # With no args both subcommands operate on every hosts/ directory. # The admin age key is always included regardless of args. set -euo pipefail SCRIPT_DIR="$(dirname "$(realpath "$0")")" REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || dirname "$SCRIPT_DIR")" cd "$REPO" KEYDIR="${HOMELAB_KEY_DIR:-${HOME:-/root}/.config/homelab}" ADMIN_AGE="${HOME:-/root}/.config/sops/age/keys.txt" VAULT="${HOMELAB_PASS_VAULT:-HomeLab}" die() { echo "error: $*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } need pass-cli # Resolve an active vault item by title → item ID, or empty string. # Active-only filter avoids the trashed-item-shadows-active bug (see deploy). resolve_item() { local title="$1" pass-cli item list --vault-name "$VAULT" --filter-state active --output human 2>/dev/null \ | awk -v t="$title" ' { i = index($0, "]: "); if (i == 0) next id = substr($0, 4, i - 4) rest = substr($0, i + 3) sub(/ \(state=[^)]*\)$/, "", rest) if (rest == t) { print id; exit } }' || true } # All configs: every hosts/ that has a configuration.nix. all_configs() { for d in hosts/*/; do [ -f "${d}configuration.nix" ] && basename "$d" done } # --------------------------------------------------------------------------- # store helpers # --------------------------------------------------------------------------- # Returns 0 (skip) if item exists and --force was not given; 1 (proceed) otherwise. # Deletes the existing item when --force is set. should_store() { local title="$1" id id="$(resolve_item "$title")" if [ -n "$id" ]; then if [ -n "$FORCE" ]; then echo ">> $title: deleting existing item (--force)" pass-cli item delete --vault-name "$VAULT" --item-id "$id" else echo ">> $title: already in vault — skipping (use --force to overwrite)" return 0 fi fi return 1 } store_ssh_key() { local config="$1" keyfile="$KEYDIR/$1/ssh_host_ed25519_key" title="ssh_host#$1" [ -f "$keyfile" ] || { echo ">> $title: $keyfile not found — skipping"; return; } should_store "$title" && return echo ">> $title: uploading" pass-cli item create ssh-key import \ --from-private-key "$keyfile" \ --title "$title" \ --vault-name "$VAULT" } store_age_key() { local title="$1" src="$2" [ -f "$src" ] || { echo ">> $title: $src not found — skipping"; return; } should_store "$title" && return echo ">> $title: uploading" pass-cli item create note \ --title "$title" \ --note "$(cat "$src")" \ --vault-name "$VAULT" } # --------------------------------------------------------------------------- # restore helpers # --------------------------------------------------------------------------- restore_ssh_key() { local config="$1" title="ssh_host#$1" id private_key public_key id="$(resolve_item "$title")" if [ -z "$id" ]; then echo ">> $title: not in vault — skipping" return fi echo ">> $title: restoring" private_key="$(pass-cli item view --vault-name "$VAULT" --item-id "$id" \ --field private_key --output human 2>/dev/null)" public_key="$(pass-cli item view --vault-name "$VAULT" --item-id "$id" \ --field public_key --output human 2>/dev/null)" [ -n "$private_key" ] || die "$title: private_key field missing or empty in vault item" [ -n "$public_key" ] || die "$title: public_key field missing or empty in vault item" mkdir -p "$KEYDIR/$config" printf '%s' "$private_key" > "$KEYDIR/$config/ssh_host_ed25519_key" chmod 600 "$KEYDIR/$config/ssh_host_ed25519_key" printf '%s\n' "$public_key" > "$KEYDIR/$config/ssh_host_ed25519_key.pub" chmod 644 "$KEYDIR/$config/ssh_host_ed25519_key.pub" echo " → $KEYDIR/$config/ssh_host_ed25519_key{,.pub}" } restore_age_key() { local title="$1" dest="$2" id content id="$(resolve_item "$title")" if [ -z "$id" ]; then echo ">> $title: not in vault — skipping" return fi echo ">> $title: restoring" content="$(pass-cli item view --vault-name "$VAULT" --item-id "$id" \ --field note --output human 2>/dev/null)" [ -n "$content" ] || die "$title: note field is empty in vault item" mkdir -p "$(dirname "$dest")" printf '%s\n' "$content" > "$dest" chmod 600 "$dest" echo " → $dest" } # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- cmd="${1:-}" [ -n "$cmd" ] || die "usage: ./scripts/keys [--force] [...]" shift FORCE="" [ "${1:-}" = "--force" ] && { FORCE=1; shift; } configs=("$@") [ "${#configs[@]}" -gt 0 ] || mapfile -t configs < <(all_configs) case "$cmd" in store) for config in "${configs[@]}"; do store_ssh_key "$config" store_age_key "age#$config" "$KEYDIR/$config/age.txt" done store_age_key "age#admin" "$ADMIN_AGE" ;; restore) for config in "${configs[@]}"; do restore_ssh_key "$config" restore_age_key "age#$config" "$KEYDIR/$config/age.txt" done restore_age_key "age#admin" "$ADMIN_AGE" ;; *) die "unknown command '$cmd' — usage: ./scripts/keys [--force] [...]" ;; esac