- Hyprland workspace rules: start-communications.sh launches telegram + discord into special:communications; qbz/discord/telegram switched to flatpak (nix-flatpak, Flathub) — removes qbz and proton-pass-cli flake inputs - proton-pass-cli and claude-code sourced from nixpkgs-unstable; unstable pkgs set threaded into home-manager via extraSpecialArgs - GTK/libadwaita dark theme fixed: dconf color-scheme = prefer-dark written declaratively instead of a per-session gsettings call - scripts/keys: store/restore SSH host keys and sops age keys via Proton Pass (ssh_host#<config> / age#<config> / age#admin naming); no jq dep, uses pass-cli --field directly - jq added to desktop-apps system packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
5.9 KiB
Bash
Executable File
176 lines
5.9 KiB
Bash
Executable File
#!/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#<config> ssh-key item ↔ ~/.config/homelab/<config>/ssh_host_ed25519_key{,.pub}
|
|
# age#<config> note item ↔ ~/.config/homelab/<config>/age.txt
|
|
# age#admin note item ↔ ~/.config/sops/age/keys.txt
|
|
#
|
|
# Usage:
|
|
# ./scripts/keys store [--force] [<config>...]
|
|
# ./scripts/keys restore [<config>...]
|
|
#
|
|
# 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 <config> args both subcommands operate on every hosts/ directory.
|
|
# The admin age key is always included regardless of <config> 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/<dir> 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 <store|restore> [--force] [<config>...]"
|
|
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 <store|restore> [--force] [<config>...]"
|
|
;;
|
|
esac
|