terra: desktop setup, flatpak, unstable packages, key management

- 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)
This commit is contained in:
Erik Simon
2026-07-25 01:26:52 +02:00
parent ffeb6c1007
commit 3295fbbf0b
10 changed files with 580 additions and 51 deletions
Executable
+175
View File
@@ -0,0 +1,175 @@
#!/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
need jq
# 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 json
id="$(resolve_item "$title")"
if [ -z "$id" ]; then
echo ">> $title: not in vault — skipping"
return
fi
echo ">> $title: restoring"
json="$(pass-cli item view --vault-name "$VAULT" --item-id "$id" --output json)"
private_key="$(jq -r '.item.content.content.SshKey.private_key' <<< "$json")"
public_key="$(jq -r '.item.content.content.SshKey.public_key' <<< "$json")"
[ -n "$private_key" ] || die "$title: private_key missing from vault item JSON"
[ -n "$public_key" ] || die "$title: public_key missing from vault item JSON"
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" \
--output json | jq -r '.item.content.note')"
[ -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