From a2b557c034548582a1386db19e4e5107716ce221 Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Mon, 20 Jul 2026 15:38:32 +0000 Subject: [PATCH 1/2] Lift duplicated sops/age and confirm-prompt logic into scripts/lib/ scripts/backup-admin-key.sh, rotate-admin-key.sh, and sync-host-keys.sh each independently resolved sops/age's default key-file path, derived an age pubkey from an identity file, and (two of them) ran `sops updatekeys` the same way -- now shared via scripts/lib/sops-age.sh. Also extracted the "type X to confirm" prompt duplicated across create-proxmox-resource.sh and sync-host-keys.sh into scripts/lib/confirm.sh. Pure extraction, no behavior change -- each call site produces identical commands/output to before. Co-Authored-By: Claude Sonnet 5 --- scripts/backup-admin-key.sh | 10 +++--- scripts/create-proxmox-resource.sh | 8 ++--- scripts/lib/confirm.sh | 21 ++++++++++++ scripts/lib/sops-age.sh | 52 ++++++++++++++++++++++++++++++ scripts/rotate-admin-key.sh | 35 +++++++++++--------- scripts/sync-host-keys.sh | 13 +++++--- 6 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 scripts/lib/confirm.sh create mode 100644 scripts/lib/sops-age.sh diff --git a/scripts/backup-admin-key.sh b/scripts/backup-admin-key.sh index 7e8941a..007643f 100755 --- a/scripts/backup-admin-key.sh +++ b/scripts/backup-admin-key.sh @@ -20,6 +20,8 @@ sops_yaml="${repo_root}/.sops.yaml" # shellcheck source=env.sh source "${repo_root}/scripts/env.sh" +# shellcheck source=lib/sops-age.sh +source "${repo_root}/scripts/lib/sops-age.sh" # Pin cwd for the same reason rotate-admin-key.sh does: age/sops calls # below should never depend on wherever the caller's shell happened to be. @@ -43,7 +45,7 @@ EOF dry_run=0 force=0 -key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" +key_file="$DEFAULT_SOPS_AGE_KEY_FILE" args=() while [[ $# -gt 0 ]]; do @@ -103,13 +105,13 @@ scratch="$(mktemp)" trap 'rm -f "$scratch"' EXIT ( umask 077; printf '%s\n' "$src_content" > "$scratch" ) -src_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$scratch'")" || { +src_pub="$(age_pubkey_from_identity_file "$scratch")" || { echo "ERROR: source doesn't look like a valid age identity (age-keygen -y failed)." >&2 exit 1 } echo " public key: ${src_pub}" -current_admin_pub="$(grep -E '^ - &admin age1' "$sops_yaml" 2>/dev/null | awk '{print $NF}' || true)" +current_admin_pub="$(sops_yaml_admin_pubkey "$sops_yaml")" if [[ -n "$current_admin_pub" && "$current_admin_pub" != "$src_pub" ]]; then echo "NOTE: this key does not match .sops.yaml's current &admin entry (${current_admin_pub})." echo " Backing it up anyway -- this script doesn't require it to be the admin key." @@ -131,7 +133,7 @@ fi mkdir -p "$(dirname "$dest")" install -m 600 "$scratch" "$dest" -dest_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$dest'")" +dest_pub="$(age_pubkey_from_identity_file "$dest")" if [[ "$dest_pub" != "$src_pub" ]]; then echo "ERROR: ${dest} was written but its public key doesn't match the source -- investigate before relying on this backup." >&2 exit 1 diff --git a/scripts/create-proxmox-resource.sh b/scripts/create-proxmox-resource.sh index e4fb715..33c2a4a 100755 --- a/scripts/create-proxmox-resource.sh +++ b/scripts/create-proxmox-resource.sh @@ -46,6 +46,8 @@ repo_root="$(cd "$(dirname "$0")/.." && pwd)" source "${repo_root}/scripts/env.sh" # shellcheck source=lib/nix-eval.sh source "${repo_root}/scripts/lib/nix-eval.sh" +# shellcheck source=lib/confirm.sh +source "${repo_root}/scripts/lib/confirm.sh" sync_keys="${repo_root}/scripts/sync-host-keys.sh" @@ -230,8 +232,7 @@ cmd_modify() { fi echo - read -rp "Type the VMID (${vmid}) to confirm these changes: " confirm - if [[ "$confirm" != "$vmid" ]]; then + if ! confirm_typed "$vmid" "Type the VMID (${vmid}) to confirm these changes: "; then echo "Cancelled -- input didn't match ${vmid}." exit 1 fi @@ -429,8 +430,7 @@ REMOTE_SCRIPT echo " - ${kind} VMID ${id} (${n})" done echo - read -rp "Type the hostname (${host}) to confirm destroying the above and replacing it: " confirm - if [[ "$confirm" != "$host" ]]; then + if ! confirm_typed "$host" "Type the hostname (${host}) to confirm destroying the above and replacing it: "; then echo "Cancelled -- input didn't match ${host}." >&2 exit 1 fi diff --git a/scripts/lib/confirm.sh b/scripts/lib/confirm.sh new file mode 100644 index 0000000..ebb3f7b --- /dev/null +++ b/scripts/lib/confirm.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Shared "type X to confirm" prompt for scripts/create-proxmox-resource.sh +# (--modify, and replacing an existing --allow-duplicate-host resource) and +# scripts/sync-host-keys.sh (--regenerate-all-keys) -- three destructive +# confirmations that all work the same way (echo the expected value back +# exactly), kept in one place so the prompt/comparison logic can't drift. +# Source alongside env.sh: +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/confirm.sh" +# +# Deliberately does NOT print anything on mismatch or decide exit-vs-return +# -- callers vary on both (a top-level script exits, a subcommand function +# returns; wording differs too), so that stays at the call site. + +# confirm_typed +# Prints via `read -rp`, then reports (via exit status) whether the +# typed input matched exactly. +confirm_typed() { + local expected="$1" prompt="$2" input + read -rp "$prompt" input + [[ "$input" == "$expected" ]] +} diff --git a/scripts/lib/sops-age.sh b/scripts/lib/sops-age.sh new file mode 100644 index 0000000..e0afa4f --- /dev/null +++ b/scripts/lib/sops-age.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Shared sops/age helpers for scripts/backup-admin-key.sh, +# scripts/rotate-admin-key.sh, and scripts/sync-host-keys.sh -- all three +# derive an age public key from a private identity file the same way, two +# of them resolve the same sops/age default key-file path, and two of them +# run `sops updatekeys` the same way. Kept in one place so they can't drift +# apart. Source alongside env.sh: +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/sops-age.sh" +# +# Uses NIX_OPTS (an array of extra `nix-shell` options -- see env.sh's +# nix_extra_opts) if the caller has already set it, same convention as +# lib/ssh-host-keys.sh. Falls back to no extra options if the caller never +# sourced env.sh. +if ! declare -p NIX_OPTS >/dev/null 2>&1; then + declare -a NIX_OPTS=() +fi + +# sops/age's own default identity-file resolution order, minus $SOPS_AGE_KEY +# itself (an inline identity, not a path -- callers that accept it check it +# separately, before falling back to this). +: "${DEFAULT_SOPS_AGE_KEY_FILE:=${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}}" + +# age_pubkey_from_identity_file +# Prints the age public key for a private identity file (age-keygen -y). +age_pubkey_from_identity_file() { + local identity_file="$1" + nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '${identity_file}'" +} + +# sops_yaml_admin_pubkey +# Prints .sops.yaml's current &admin age public key, or empty (not an error +# under set -e) if no such anchor line exists -- callers that need to treat +# "missing" as fatal check for an empty result themselves. +sops_yaml_admin_pubkey() { + local sops_yaml="$1" + grep -E '^ - &admin age1' "$sops_yaml" 2>/dev/null | awk '{print $NF}' || true +} + +# sops_updatekeys [key-file] +# Re-encrypts for .sops.yaml's current recipient set. If +# is given, decrypts with that identity (SOPS_AGE_KEY_FILE) +# instead of whatever's ambient -- needed when the ambient default key +# doesn't match yet (e.g. mid-rotation, decrypting with the outgoing key). +sops_updatekeys() { + local secrets_file="$1" key_file="${2:-}" + if [[ -n "$key_file" ]]; then + SOPS_AGE_KEY_FILE="$key_file" nix-shell "${NIX_OPTS[@]}" -p sops --run \ + "sops updatekeys --yes '${secrets_file}'" + else + nix-shell "${NIX_OPTS[@]}" -p sops --run "sops updatekeys --yes '${secrets_file}'" + fi +} diff --git a/scripts/rotate-admin-key.sh b/scripts/rotate-admin-key.sh index 2e2f033..bfcda23 100755 --- a/scripts/rotate-admin-key.sh +++ b/scripts/rotate-admin-key.sh @@ -24,6 +24,8 @@ sops_yaml="${repo_root}/.sops.yaml" # shellcheck source=env.sh source "${repo_root}/scripts/env.sh" +# shellcheck source=lib/sops-age.sh +source "${repo_root}/scripts/lib/sops-age.sh" # sops resolves .sops.yaml by walking up from the process's cwd, not from # the target file's own path -- if this script were invoked from somewhere @@ -53,7 +55,7 @@ EOF } dry_run=0 -new_key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" +new_key_file="$DEFAULT_SOPS_AGE_KEY_FILE" args=() while [[ $# -gt 0 ]]; do @@ -93,13 +95,9 @@ backup_key="${args[0]}" nix_extra_opts -age_pub() { - nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$1'" -} - echo "==> Deriving public keys..." -old_pub="$(age_pub "$backup_key")" -new_pub="$(age_pub "$new_key_file")" +old_pub="$(age_pubkey_from_identity_file "$backup_key")" +new_pub="$(age_pubkey_from_identity_file "$new_key_file")" echo " backup (old admin) key: ${old_pub}" echo " new admin key: ${new_pub}" @@ -108,12 +106,11 @@ if [[ "$old_pub" == "$new_pub" ]]; then exit 1 fi -current_admin_line="$(grep -E '^ - &admin age1' "$sops_yaml" || true)" -if [[ -z "$current_admin_line" ]]; then +current_admin_pub="$(sops_yaml_admin_pubkey "$sops_yaml")" +if [[ -z "$current_admin_pub" ]]; then echo "ERROR: couldn't find a '&admin age1...' line in ${sops_yaml}." >&2 exit 1 fi -current_admin_pub="$(awk '{print $NF}' <<<"$current_admin_line")" if [[ "$current_admin_pub" != "$old_pub" ]]; then echo "ERROR: ${backup_key} doesn't match the current &admin key in .sops.yaml." >&2 @@ -129,9 +126,17 @@ if [[ "${#secrets_files[@]}" -eq 0 ]]; then exit 1 fi +# sops_can_decrypt : used both to confirm the +# backup key still works before touching anything, and again after +# rotation to confirm the new key does too. +sops_can_decrypt() { + local key_file="$1" secrets_file="$2" + SOPS_AGE_KEY_FILE="$key_file" nix-shell "${NIX_OPTS[@]}" -p sops --run \ + "sops -d '${secrets_file}'" >/dev/null +} + echo "==> Confirming the backup key can actually decrypt..." -if ! SOPS_AGE_KEY_FILE="$backup_key" nix-shell "${NIX_OPTS[@]}" -p sops --run \ - "sops -d '${secrets_files[0]}'" >/dev/null; then +if ! sops_can_decrypt "$backup_key" "${secrets_files[0]}"; then echo "ERROR: backup key failed to decrypt $(basename "${secrets_files[0]}") -- aborting." >&2 exit 1 fi @@ -162,14 +167,12 @@ echo " Updated." echo "==> Re-encrypting secrets/*.yaml for the new recipient set..." for f in "${secrets_files[@]}"; do echo "==> $(basename "$f")" - SOPS_AGE_KEY_FILE="$backup_key" nix-shell "${NIX_OPTS[@]}" -p sops --run \ - "sops updatekeys --yes '${f}'" + sops_updatekeys "$f" "$backup_key" done echo "==> Verifying the new key can decrypt everything..." for f in "${secrets_files[@]}"; do - if ! SOPS_AGE_KEY_FILE="$new_key_file" nix-shell "${NIX_OPTS[@]}" -p sops --run \ - "sops -d '${f}'" >/dev/null; then + if ! sops_can_decrypt "$new_key_file" "$f"; then echo "ERROR: new key failed to decrypt $(basename "$f") after rotation -- investigate before committing." >&2 exit 1 fi diff --git a/scripts/sync-host-keys.sh b/scripts/sync-host-keys.sh index c9bf0b6..9834706 100755 --- a/scripts/sync-host-keys.sh +++ b/scripts/sync-host-keys.sh @@ -35,6 +35,10 @@ source "${repo_root}/scripts/env.sh" source "${repo_root}/scripts/lib/nix-eval.sh" # shellcheck source=lib/ssh-host-keys.sh source "${repo_root}/scripts/lib/ssh-host-keys.sh" +# shellcheck source=lib/sops-age.sh +source "${repo_root}/scripts/lib/sops-age.sh" +# shellcheck source=lib/confirm.sh +source "${repo_root}/scripts/lib/confirm.sh" mkdir -p "$keydir" @@ -78,7 +82,7 @@ ensure_admin_decrypt_key() { return fi - local key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" + local key_file="$DEFAULT_SOPS_AGE_KEY_FILE" if [[ -s "$key_file" ]]; then echo "Found existing sops age key at ${key_file}." @@ -97,7 +101,7 @@ ensure_admin_decrypt_key() { mkdir -p "$(dirname "$key_file")" nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -o '${key_file}'" 2>&1 | grep -v "^Public key:" || true local new_pub - new_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '${key_file}'")" + new_pub="$(age_pubkey_from_identity_file "$key_file")" cat < secrets/${basename}" - nix-shell "${NIX_OPTS[@]}" -p sops --run "sops updatekeys --yes '${repo_root}/secrets/${basename}'" + sops_updatekeys "${repo_root}/secrets/${basename}" done <<<"$changed" fi fi @@ -359,8 +363,7 @@ cmd_regenerate_all() { echo "image/tarball before it can decrypt secrets again." if [[ "$dry_run" -ne 1 ]]; then - read -rp "Type REGENERATE to confirm: " confirm - if [[ "$confirm" != "REGENERATE" ]]; then + if ! confirm_typed "REGENERATE" "Type REGENERATE to confirm: "; then echo "Cancelled." return fi From d8687d979ca6d375753c63d843ba0c65bc36435a Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Mon, 20 Jul 2026 16:20:45 +0000 Subject: [PATCH 2/2] Reorganize scripts/ into secrets/, proxmox/, and lib/ subfolders scripts/ had grown to 10 top-level scripts covering three distinct concerns (sops/age + SSH host-key management, Proxmox deployment, and repo-wide bootstrap/CI) with no grouping. Move the key-management scripts (backup-admin-key.sh, rotate-admin-key.sh, prepare-host-key.sh, sync-host-keys.sh) into scripts/secrets/, and the Proxmox scripts (create-proxmox-resource.sh, configure-nix-cache-client.sh) into scripts/proxmox/; leave env.sh, codex-setup.sh, codex-maintenance.sh, and bump-nixpkgs-release.sh at the top level (frequently hand-typed or pure shared config) and scripts/lib/ as-is. Updates every cross-reference: each moved script's repo_root computation (now one directory deeper), shellcheck source= directives, inter-script paths (create-proxmox-resource.sh's call into sync-host-keys.sh and its remote bootstrap of configure-nix-cache-client.sh on the Proxmox node), and every doc/module mention (CLAUDE.md's Scripts section reorganized to match, README.md, docs/auto-installer.md, docs/proxmox-images.md, modules/installer/common.nix, modules/platforms/lxc.nix). CI workflows need no change -- they only invoke codex-maintenance.sh, which didn't move. Verified via bash -n, shellcheck (no new warnings beyond the pre-existing SC1091/SC2029/SC2095 baseline), and live dry-runs of sync-host-keys.sh --all and create-proxmox-resource.sh --list from their new paths. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 118 ++++++++++++------ README.md | 4 +- docs/auto-installer.md | 12 +- docs/proxmox-images.md | 4 +- modules/installer/common.nix | 4 +- modules/platforms/lxc.nix | 4 +- scripts/env.sh | 6 +- scripts/lib/confirm.sh | 4 +- scripts/lib/sops-age.sh | 4 +- scripts/lib/ssh-host-keys.sh | 4 +- .../configure-nix-cache-client.sh | 0 .../{ => proxmox}/create-proxmox-resource.sh | 20 +-- scripts/{ => secrets}/backup-admin-key.sh | 10 +- scripts/{ => secrets}/prepare-host-key.sh | 10 +- scripts/{ => secrets}/rotate-admin-key.sh | 8 +- scripts/{ => secrets}/sync-host-keys.sh | 12 +- 16 files changed, 131 insertions(+), 93 deletions(-) rename scripts/{ => proxmox}/configure-nix-cache-client.sh (100%) rename scripts/{ => proxmox}/create-proxmox-resource.sh (98%) rename scripts/{ => secrets}/backup-admin-key.sh (94%) rename scripts/{ => secrets}/prepare-host-key.sh (91%) rename scripts/{ => secrets}/rotate-admin-key.sh (97%) rename scripts/{ => secrets}/sync-host-keys.sh (98%) diff --git a/CLAUDE.md b/CLAUDE.md index 0c613bb..52d2589 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,21 +73,55 @@ diff size; that's the point of it. ## Scripts -Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` also has: +Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` is +organized by purpose: `scripts/secrets/` (sops/age + SSH host-key +management), `scripts/proxmox/` (Proxmox deployment), `scripts/lib/` +(shared helpers, sourced by the scripts below — not run directly), and a +handful of repo-wide scripts left at the top level (`env.sh`, +`bump-nixpkgs-release.sh`, plus `codex-setup.sh`/`codex-maintenance.sh` +above). When adding a new script, put it in the matching subfolder rather +than the top level, and if it duplicates logic another script already has, +lift the shared part into `scripts/lib/` instead of copying it. -- `scripts/sync-host-keys.sh` — generates/registers SSH host keys and their - `.sops.yaml`/`secrets/*.yaml` recipients for flake targets, idempotently - (`--all`, ``, `--remove`, `--regenerate-all-keys`, all with - `--dry-run`). The primary tool for provisioning a new host's secrets - access — see "Creating a new machine" in `docs/auto-installer.md`. -- `scripts/prepare-host-key.sh` — narrower predecessor: generates a key by - an arbitrary name without touching `.sops.yaml`. Still useful to +### `scripts/secrets/` + +- `scripts/secrets/sync-host-keys.sh` — generates/registers SSH host keys + and their `.sops.yaml`/`secrets/*.yaml` recipients for flake targets, + idempotently (`--all`, ``, `--remove`, `--regenerate-all-keys`, + all with `--dry-run`). The primary tool for provisioning a new host's + secrets access — see "Creating a new machine" in `docs/auto-installer.md`. +- `scripts/secrets/prepare-host-key.sh` — narrower predecessor: generates a + key by an arbitrary name without touching `.sops.yaml`. Still useful to pre-generate a key before its flake target exists yet, since `sync-host-keys.sh` can only act on targets `nixosConfigurations` already has. -- `scripts/create-proxmox-resource.sh` — builds a `lxc-*`/`proxmox-*` - target's tarball/disk image and creates it on a real Proxmox node - (`pct create` against the tarball as a CT template / `qm create`+ +- `scripts/secrets/rotate-admin-key.sh [--new-key-file + ] [--dry-run]` — rotates `.sops.yaml`'s `&admin` age key: decrypts + with a backed-up copy of the key currently trusted as `&admin` (verified + by deriving its public key and comparing, not taken on faith), replaces + the `&admin` line with a new key already present in the environment + (defaults to wherever sops/age itself would look), and runs + `sops updatekeys` on every `secrets/*.yaml`. One-way: the old key can no + longer decrypt anything re-encrypted this way. This is the automation + for the manual steps `sync-host-keys.sh`/`create-proxmox-resource.sh` + print when they bootstrap a brand-new, not-yet-trusted key on a machine + with no prior admin access. +- `scripts/secrets/backup-admin-key.sh [--key-file ] + [--force] [--dry-run]` — copies the local sops age key (source + resolution matches sops/age itself: `$SOPS_AGE_KEY` inline, then + `--key-file`, then `$SOPS_AGE_KEY_FILE`, then the XDG default) to an + arbitrary destination path with `0600` permissions, validating it's a + real age identity and round-tripping the public key before and after the + write. Refuses to overwrite an existing `` without `--force`. + Purely a local filesystem copy — never touches `.sops.yaml`/ + `secrets/*.yaml` or the repo at all. The resulting file is exactly what + `rotate-admin-key.sh` expects as its backup-key argument. + +### `scripts/proxmox/` + +- `scripts/proxmox/create-proxmox-resource.sh` — builds a `lxc-*`/ + `proxmox-*` target's tarball/disk image and creates it on a real Proxmox + node (`pct create` against the tarball as a CT template / `qm create`+ `importdisk`), or reconfigures an existing resource's cores/memory/disk size (`--modify`, always requires typing the VMID back to confirm). Checks for an already-uploaded image on the node before building @@ -99,15 +133,12 @@ Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` also has: unless `--allow-duplicate-host` is passed. `--dry-run` throughout both modes. The first time it has to bootstrap build tooling on a node (i.e. `nix` wasn't already on its `PATH`), it also runs - `scripts/configure-nix-cache-client.sh` there (non-fatally — a failure - just falls back to building from source / `cache.nixos.org`) so the - node substitutes from and can offload builds to nix-cache on every + `scripts/proxmox/configure-nix-cache-client.sh` there (non-fatally — a + failure just falls back to building from source / `cache.nixos.org`) so + the node substitutes from and can offload builds to nix-cache on every subsequent run, not just this one. -- `scripts/env.sh` — shared config (`PROXMOX_HOST`, storage pool, bridge, - default cores/memory) sourced by `create-proxmox-resource.sh`. Add new - cross-script config here instead of duplicating it per-script. -- `scripts/configure-nix-cache-client.sh [--dry-run] [--no-remote-builder] - [--no-restart]` — the non-NixOS equivalent of +- `scripts/proxmox/configure-nix-cache-client.sh [--dry-run] + [--no-remote-builder] [--no-restart]` — the non-NixOS equivalent of `modules/nix-cache/client.nix`/`remote-builder-client.nix`, for a plain Debian machine with the Nix package manager (not NixOS) already installed: run as root *on that machine* to add nix-cache as a @@ -120,32 +151,39 @@ Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` also has: `/etc/ssh/ssh_known_hosts`. Idempotent (re-running replaces its own marked block rather than duplicating it); restarts `nix-daemon` by default so the change takes effect immediately. + +### `scripts/lib/` + +Sourced by the scripts above, never run directly: + +- `nix-bootstrap.sh` — `NIX_CONFIG`/`ensure_nix_profile`, shared by + `codex-setup.sh`/`codex-maintenance.sh` and the remote build commands + `create-proxmox-resource.sh` runs over SSH. +- `nix-eval.sh` — `NIX_EVAL_FLAGS` plus `list_flake_targets`/ + `flake_target_hostname` flake-introspection helpers. +- `ssh-host-keys.sh` — `generate_host_ed25519_key`/`ssh_pubkey_to_age`, + shared by `sync-host-keys.sh` and `prepare-host-key.sh`. +- `sops-age.sh` — `age_pubkey_from_identity_file`/`sops_yaml_admin_pubkey`/ + `sops_updatekeys` plus the shared sops/age default key-file resolution, + shared by `backup-admin-key.sh`, `rotate-admin-key.sh`, and + `sync-host-keys.sh`. +- `confirm.sh` — `confirm_typed`, the "type X back to confirm" destructive- + action prompt shared by `create-proxmox-resource.sh` and + `sync-host-keys.sh`. +- `sync-host-keys-edit-sops.py` — the `.sops.yaml` anchor/key_groups editor + `sync-host-keys.sh` shells out to (see that script for why: precise, + idempotent YAML edits are impractical in bash). + +### Top level + +- `scripts/env.sh` — shared config (`PROXMOX_HOST`, storage pool, bridge, + default cores/memory) sourced by `create-proxmox-resource.sh`. Add new + cross-script config here instead of duplicating it per-script. - `scripts/bump-nixpkgs-release.sh` — bumps `flake.nix`'s `nixpkgs.url`/ `home-manager.url` in place. Exists because flake input URLs can't reference `variables.nix` (confirmed empirically — `nix flake metadata` errors on it), so this is the closest equivalent to a single source of truth for the tracked release. -- `scripts/rotate-admin-key.sh [--new-key-file ] - [--dry-run]` — rotates `.sops.yaml`'s `&admin` age key: decrypts with a - backed-up copy of the key currently trusted as `&admin` (verified by - deriving its public key and comparing, not taken on faith), replaces the - `&admin` line with a new key already present in the environment - (defaults to wherever sops/age itself would look), and runs - `sops updatekeys` on every `secrets/*.yaml`. One-way: the old key can no - longer decrypt anything re-encrypted this way. This is the automation - for the manual steps `sync-host-keys.sh`/`create-proxmox-resource.sh` - print when they bootstrap a brand-new, not-yet-trusted key on a machine - with no prior admin access. -- `scripts/backup-admin-key.sh [--key-file ] [--force] - [--dry-run]` — copies the local sops age key (source resolution matches - sops/age itself: `$SOPS_AGE_KEY` inline, then `--key-file`, then - `$SOPS_AGE_KEY_FILE`, then the XDG default) to an arbitrary destination - path with `0600` permissions, validating it's a real age identity and - round-tripping the public key before and after the write. Refuses to - overwrite an existing `` without `--force`. Purely a local - filesystem copy — never touches `.sops.yaml`/`secrets/*.yaml` or the - repo at all. The resulting file is exactly what `rotate-admin-key.sh` - expects as its backup-key argument. `sync-host-keys.sh`, `create-proxmox-resource.sh`, and `rotate-admin-key.sh` genuinely mutate real state when run for real (not diff --git a/README.md b/README.md index c9dabd1..b47631f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ anywhere in this repo — that's live infrastructure state, not something a committed file can keep accurate, and it changes independently of the code. Check the Proxmox node itself, or `/etc/flake-target` on a running host (see below), if you need to know what's really out there right now. -`scripts/create-proxmox-resource.sh`'s duplicate-host guard works the same +`scripts/proxmox/create-proxmox-resource.sh`'s duplicate-host guard works the same way: it checks the Proxmox node directly rather than any file here. Each buildtype's `hosts//host.nix` carries the per-machine identity @@ -114,7 +114,7 @@ Three different paths depending on target, none of them involving a manual disk image and attached to a new VM with no install step — see `docs/proxmox-images.md`. -`scripts/create-proxmox-resource.sh --type lxc|vm --host ` automates +`scripts/proxmox/create-proxmox-resource.sh --type lxc|vm --host ` automates either of the last two end to end (host-key registration, building the image directly on the Proxmox node itself, `pct create`/`qm create`), with `--dry-run` and a guard against duplicating an already-deployed host's diff --git a/docs/auto-installer.md b/docs/auto-installer.md index b014f46..8e92f03 100644 --- a/docs/auto-installer.md +++ b/docs/auto-installer.md @@ -73,7 +73,7 @@ booting one: First boot runs `boot.postBootCommands` (registers the Nix store DB and system profile) — there's no separate activation step to run yourself. -`scripts/create-proxmox-resource.sh --type lxc --host ` automates all +`scripts/proxmox/create-proxmox-resource.sh --type lxc --host ` automates all of this (host-key handling, building the tarball directly on the Proxmox node itself, `pct create` with the flags above) — see its `--help`. @@ -102,7 +102,7 @@ groups required, got 0`, and *every* secret (including this host's own login) permanently fails to decrypt, silently — no error in the boot log at all, since the activation step that would install secrets only runs on a from-scratch first activation and skips silently once `/run/current-system` -already exists. `scripts/create-proxmox-resource.sh` always builds with +already exists. `scripts/proxmox/create-proxmox-resource.sh` always builds with `NIXOS_HOST_KEYS_DIR` set for this reason. ## Layout @@ -116,10 +116,10 @@ already exists. `scripts/create-proxmox-resource.sh` always builds with `docs/pxe-boot.md`). - `modules/installer/host-keys.nix` — optionally bakes pre-generated SSH host keys into the image; see "Host keys" below. -- `scripts/sync-host-keys.sh` — admin-workstation tool that generates, +- `scripts/secrets/sync-host-keys.sh` — admin-workstation tool that generates, registers, and (via `--remove`/`--regenerate-all-keys`) retires host keys; see "Creating a New Machine" below. -- `scripts/prepare-host-key.sh` — narrower predecessor: generates a single +- `scripts/secrets/prepare-host-key.sh` — narrower predecessor: generates a single key by an arbitrary name without touching `.sops.yaml`. Still useful for pre-generating a key *before* its flake target exists (`sync-host-keys.sh` can only act on targets `nixosConfigurations` already has); otherwise @@ -238,7 +238,7 @@ GitHub token behind sops-nix for all of them). 2. **On your admin workstation, generate and register its host key:** ```sh - ./scripts/sync-host-keys.sh + ./scripts/secrets/sync-host-keys.sh ``` This generates `host-keys/_ssh_host_ed25519_key(.pub)`, @@ -250,7 +250,7 @@ GitHub token behind sops-nix for all of them). Doing this for every host that needs one at once — after adding several new targets, or just to catch up any that were missed — is - `./scripts/sync-host-keys.sh --all`. See `scripts/sync-host-keys.sh --help` + `./scripts/secrets/sync-host-keys.sh --all`. See `scripts/secrets/sync-host-keys.sh --help` for its other modes (`--remove`, `--regenerate-all-keys`). 3. **Commit and push.** The flake build the installer uses has to see the diff --git a/docs/proxmox-images.md b/docs/proxmox-images.md index 5ce94f3..0c5c8e2 100644 --- a/docs/proxmox-images.md +++ b/docs/proxmox-images.md @@ -7,7 +7,7 @@ config (`modules/disko/proxmox.nix`) already used to format a real disk on install, so there's nothing host-specific to write; it's available for every `proxmox-*` target automatically. -`scripts/create-proxmox-resource.sh --type vm --host ` automates the +`scripts/proxmox/create-proxmox-resource.sh --type vm --host ` automates the whole walkthrough below (and the equivalent LXC one) end to end, including host-key handling and building the image directly on the Proxmox node itself (no local build, no image transfer) — see its `--help`. The steps @@ -51,7 +51,7 @@ sudo ./result \ --build-memory 2048 ``` -Generate the key first with `scripts/sync-host-keys.sh `, same +Generate the key first with `scripts/secrets/sync-host-keys.sh `, same as any other host — see `docs/auto-installer.md` for the full walkthrough (it registers the new key in `.sops.yaml` and re-encrypts the affected `secrets/*.yaml` files too, no manual editing needed). diff --git a/modules/installer/common.nix b/modules/installer/common.nix index 2951068..26a7d7e 100644 --- a/modules/installer/common.nix +++ b/modules/installer/common.nix @@ -130,7 +130,7 @@ # at *activation* time, which runs before systemd would otherwise # generate one on first boot. Without pre-seeding it here, secrets # (including the login password) fail to decrypt on first boot. - # Generate the key with scripts/prepare-host-key.sh first. + # Generate the key with scripts/secrets/prepare-host-key.sh first. # # Two places a key can come from, checked in order: # /etc/host-keys — baked into this image at build time (see @@ -150,7 +150,7 @@ else echo "WARNING: no SSH host key found for ''${choice} (checked /etc/host-keys and /root/host-keys)" echo "sops-nix secrets (including the login password) will NOT decrypt on first boot." - echo "Run scripts/prepare-host-key.sh for host ''${choice} on your admin workstation first," + echo "Run scripts/secrets/prepare-host-key.sh for host ''${choice} on your admin workstation first," echo "then either rebuild this image with NIXOS_HOST_KEYS_DIR set, or scp the result to" echo "/root/host-keys/ on this machine." read -rp "Continue without a pre-seeded key anyway? (y/N): " skip_key diff --git a/modules/platforms/lxc.nix b/modules/platforms/lxc.nix index 0c14bd2..ab3d10f 100644 --- a/modules/platforms/lxc.nix +++ b/modules/platforms/lxc.nix @@ -14,7 +14,7 @@ let # Without this, config.system.build.tarball's built-in system just # generates a fresh host key at first boot like any other host would -- # but sops-nix derives its decryption key from *this* file, and - # .sops.yaml only trusts whatever key scripts/sync-host-keys.sh already + # .sops.yaml only trusts whatever key scripts/secrets/sync-host-keys.sh already # registered for this exact target name. A freshly-generated key can # never match that, so every secret (including this host's own login) # permanently fails to decrypt. Confirmed live: sops-install-secrets @@ -26,7 +26,7 @@ let hostKeysDir = /. + hostKeysDirStr; # flakeTarget ("${platform}-${buildType}") comes in via specialArgs from - # flake.nix's mkTarget -- exactly the name scripts/sync-host-keys.sh + # flake.nix's mkTarget -- exactly the name scripts/secrets/sync-host-keys.sh # registers keys under. Deliberately not read back from # config.environment.etc."flake-target" (which is set to the same value) # -- this module also *contributes* to environment.etc below, and a diff --git a/scripts/env.sh b/scripts/env.sh index 7f6000d..dc864d6 100755 --- a/scripts/env.sh +++ b/scripts/env.sh @@ -3,10 +3,10 @@ # second copy of these values in every script: # source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" # Every variable can still be overridden per-invocation via the -# environment (e.g. PROXMOX_STORAGE=tank-nvme ./scripts/create-proxmox-resource.sh ...) +# environment (e.g. PROXMOX_STORAGE=tank-nvme ./scripts/proxmox/create-proxmox-resource.sh ...) # since each one only sets a default if unset. -# SSH-reachable Proxmox node that scripts/create-proxmox-resource.sh runs +# SSH-reachable Proxmox node that scripts/proxmox/create-proxmox-resource.sh runs # pct/qm on. Matches the Proxmox web UI hostname already used in # hosts/nixos/home.nix's desktop shortcuts (pve. from # variables.nix) -- change this if that's not actually reachable over SSH, @@ -15,7 +15,7 @@ : "${PROXMOX_SSH_USER:=root}" # Where this flake repo lives on the Proxmox node itself. -# scripts/create-proxmox-resource.sh builds images directly on the node +# scripts/proxmox/create-proxmox-resource.sh builds images directly on the node # instead of transferring them over the network -- it clones the repo here # (from this checkout's own `origin` remote) the first time it doesn't # find it, installing build tooling via scripts/codex-setup.sh, then diff --git a/scripts/lib/confirm.sh b/scripts/lib/confirm.sh index ebb3f7b..e46b421 100644 --- a/scripts/lib/confirm.sh +++ b/scripts/lib/confirm.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Shared "type X to confirm" prompt for scripts/create-proxmox-resource.sh +# Shared "type X to confirm" prompt for scripts/proxmox/create-proxmox-resource.sh # (--modify, and replacing an existing --allow-duplicate-host resource) and -# scripts/sync-host-keys.sh (--regenerate-all-keys) -- three destructive +# scripts/secrets/sync-host-keys.sh (--regenerate-all-keys) -- three destructive # confirmations that all work the same way (echo the expected value back # exactly), kept in one place so the prompt/comparison logic can't drift. # Source alongside env.sh: diff --git a/scripts/lib/sops-age.sh b/scripts/lib/sops-age.sh index e0afa4f..cde7624 100644 --- a/scripts/lib/sops-age.sh +++ b/scripts/lib/sops-age.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Shared sops/age helpers for scripts/backup-admin-key.sh, -# scripts/rotate-admin-key.sh, and scripts/sync-host-keys.sh -- all three +# Shared sops/age helpers for scripts/secrets/backup-admin-key.sh, +# scripts/secrets/rotate-admin-key.sh, and scripts/secrets/sync-host-keys.sh -- all three # derive an age public key from a private identity file the same way, two # of them resolve the same sops/age default key-file path, and two of them # run `sops updatekeys` the same way. Kept in one place so they can't drift diff --git a/scripts/lib/ssh-host-keys.sh b/scripts/lib/ssh-host-keys.sh index cd0b658..8605b15 100644 --- a/scripts/lib/ssh-host-keys.sh +++ b/scripts/lib/ssh-host-keys.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Shared SSH-host-key / age-conversion helpers for scripts/sync-host-keys.sh -# and scripts/prepare-host-key.sh -- both generate the same kind of key +# Shared SSH-host-key / age-conversion helpers for scripts/secrets/sync-host-keys.sh +# and scripts/secrets/prepare-host-key.sh -- both generate the same kind of key # (ed25519, no passphrase, the sops-nix age-derivation input) and convert it # to an age recipient the same way; kept in one place so the two can't # drift apart. diff --git a/scripts/configure-nix-cache-client.sh b/scripts/proxmox/configure-nix-cache-client.sh similarity index 100% rename from scripts/configure-nix-cache-client.sh rename to scripts/proxmox/configure-nix-cache-client.sh diff --git a/scripts/create-proxmox-resource.sh b/scripts/proxmox/create-proxmox-resource.sh similarity index 98% rename from scripts/create-proxmox-resource.sh rename to scripts/proxmox/create-proxmox-resource.sh index 33c2a4a..891c7d0 100755 --- a/scripts/create-proxmox-resource.sh +++ b/scripts/proxmox/create-proxmox-resource.sh @@ -13,9 +13,9 @@ # alone wouldn't carry it) before building. # # Usage: -# scripts/create-proxmox-resource.sh --type lxc|vm --host [options] -# scripts/create-proxmox-resource.sh --type lxc|vm --list -# scripts/create-proxmox-resource.sh --modify --vmid [--cores N] [--memory MB] [--grow-disk GB] +# scripts/proxmox/create-proxmox-resource.sh --type lxc|vm --host [options] +# scripts/proxmox/create-proxmox-resource.sh --type lxc|vm --list +# scripts/proxmox/create-proxmox-resource.sh --modify --vmid [--cores N] [--memory MB] [--grow-disk GB] # # SAFETY: # - The default (create) mode only ever creates a NEW resource -- it @@ -41,15 +41,15 @@ # See --help for the full option list. set -euo pipefail -repo_root="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=env.sh +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=../env.sh source "${repo_root}/scripts/env.sh" -# shellcheck source=lib/nix-eval.sh +# shellcheck source=../lib/nix-eval.sh source "${repo_root}/scripts/lib/nix-eval.sh" -# shellcheck source=lib/confirm.sh +# shellcheck source=../lib/confirm.sh source "${repo_root}/scripts/lib/confirm.sh" -sync_keys="${repo_root}/scripts/sync-host-keys.sh" +sync_keys="${repo_root}/scripts/secrets/sync-host-keys.sh" usage() { cat < Ensuring ${remote_repo_dir} exists and is current on ${node}..." if [[ "$dry_run" -eq 1 ]]; then - echo "[dry-run] would ensure ${remote_repo_dir} exists on ${node} (clone if missing, git pull if present), and would verify/bootstrap build tooling there (scripts/codex-setup.sh) if \`nix\` isn't already on PATH -- and if that bootstrap actually ran, would also configure ${node} as a nix-cache client (scripts/configure-nix-cache-client.sh)" + echo "[dry-run] would ensure ${remote_repo_dir} exists on ${node} (clone if missing, git pull if present), and would verify/bootstrap build tooling there (scripts/codex-setup.sh) if \`nix\` isn't already on PATH -- and if that bootstrap actually ran, would also configure ${node} as a nix-cache client (scripts/proxmox/configure-nix-cache-client.sh)" return fi @@ -583,7 +583,7 @@ ensure_remote_repo() { # above -- ssh's non-interactive command execution won't have picked # up a freshly single-user-installed `nix` otherwise. echo "==> Configuring ${node} as a nix-cache substituter/remote-builder client..." - if ! ssh "$ssh_target" "cd '${remote_repo_dir}' && . scripts/lib/nix-bootstrap.sh && ensure_nix_profile && bash scripts/configure-nix-cache-client.sh"; then + if ! ssh "$ssh_target" "cd '${remote_repo_dir}' && . scripts/lib/nix-bootstrap.sh && ensure_nix_profile && bash scripts/proxmox/configure-nix-cache-client.sh"; then echo "WARNING: configure-nix-cache-client.sh failed on ${node} -- continuing without it (${node} will build from source / against cache.nixos.org only)." >&2 fi fi diff --git a/scripts/backup-admin-key.sh b/scripts/secrets/backup-admin-key.sh similarity index 94% rename from scripts/backup-admin-key.sh rename to scripts/secrets/backup-admin-key.sh index 007643f..5178f48 100755 --- a/scripts/backup-admin-key.sh +++ b/scripts/secrets/backup-admin-key.sh @@ -6,7 +6,7 @@ # copy is ever lost, or to run either script from a different machine. # # Usage: -# scripts/backup-admin-key.sh [--key-file ] [--force] [--dry-run] +# scripts/secrets/backup-admin-key.sh [--key-file ] [--force] [--dry-run] # # Source key resolution matches sops/age's own default order: # $SOPS_AGE_KEY (inline identity text) if set, else @@ -15,12 +15,12 @@ # ${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt set -euo pipefail -repo_root="$(cd "$(dirname "$0")/.." && pwd)" +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" sops_yaml="${repo_root}/.sops.yaml" -# shellcheck source=env.sh +# shellcheck source=../env.sh source "${repo_root}/scripts/env.sh" -# shellcheck source=lib/sops-age.sh +# shellcheck source=../lib/sops-age.sh source "${repo_root}/scripts/lib/sops-age.sh" # Pin cwd for the same reason rotate-admin-key.sh does: age/sops calls @@ -146,5 +146,5 @@ Done. Backed up to: ${dest} This is a private key -- store it somewhere offline/secure, not in this repo or anywhere it'd get committed. Restore it with: - scripts/rotate-admin-key.sh ${dest} + scripts/secrets/rotate-admin-key.sh ${dest} EOF diff --git a/scripts/prepare-host-key.sh b/scripts/secrets/prepare-host-key.sh similarity index 91% rename from scripts/prepare-host-key.sh rename to scripts/secrets/prepare-host-key.sh index fb311ca..a148cf0 100755 --- a/scripts/prepare-host-key.sh +++ b/scripts/secrets/prepare-host-key.sh @@ -2,7 +2,7 @@ # Generates a new machine's SSH host key by an arbitrary name, before it # necessarily has a flake target yet -- prints the .sops.yaml snippet to # add by hand. For any host that already has a flake target, -# scripts/sync-host-keys.sh does this same job plus the +# scripts/secrets/sync-host-keys.sh does this same job plus the # .sops.yaml/key_groups registration and re-encryption automatically; use # this script only to pre-generate a key ahead of adding the flake target # itself. @@ -22,13 +22,13 @@ # new machine. set -euo pipefail -repo_root="$(cd "$(dirname "$0")/.." && pwd)" -# shellcheck source=env.sh +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=../env.sh source "${repo_root}/scripts/env.sh" -# shellcheck source=lib/ssh-host-keys.sh +# shellcheck source=../lib/ssh-host-keys.sh source "${repo_root}/scripts/lib/ssh-host-keys.sh" -hostname="${1:?usage: scripts/prepare-host-key.sh }" +hostname="${1:?usage: scripts/secrets/prepare-host-key.sh }" sops_yaml="${repo_root}/.sops.yaml" if [[ ! -f "$sops_yaml" ]]; then diff --git a/scripts/rotate-admin-key.sh b/scripts/secrets/rotate-admin-key.sh similarity index 97% rename from scripts/rotate-admin-key.sh rename to scripts/secrets/rotate-admin-key.sh index bfcda23..cd5358b 100755 --- a/scripts/rotate-admin-key.sh +++ b/scripts/secrets/rotate-admin-key.sh @@ -10,7 +10,7 @@ # sync-host-keys.sh print when they bootstrap a brand-new, not-yet-trusted # age key on a machine that's never had admin access before: # -# scripts/rotate-admin-key.sh /path/to/backed-up/admin/keys.txt +# scripts/secrets/rotate-admin-key.sh /path/to/backed-up/admin/keys.txt # # The backup key's *public* key must match .sops.yaml's current &admin # entry -- this script verifies that by deriving it, it doesn't just trust @@ -19,12 +19,12 @@ # the common case is just pointing this at the restored backup. set -euo pipefail -repo_root="$(cd "$(dirname "$0")/.." && pwd)" +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" sops_yaml="${repo_root}/.sops.yaml" -# shellcheck source=env.sh +# shellcheck source=../env.sh source "${repo_root}/scripts/env.sh" -# shellcheck source=lib/sops-age.sh +# shellcheck source=../lib/sops-age.sh source "${repo_root}/scripts/lib/sops-age.sh" # sops resolves .sops.yaml by walking up from the process's cwd, not from diff --git a/scripts/sync-host-keys.sh b/scripts/secrets/sync-host-keys.sh similarity index 98% rename from scripts/sync-host-keys.sh rename to scripts/secrets/sync-host-keys.sh index 9834706..eda2915 100755 --- a/scripts/sync-host-keys.sh +++ b/scripts/secrets/sync-host-keys.sh @@ -24,20 +24,20 @@ # ever touches keys it itself manages. set -euo pipefail -repo_root="$(cd "$(dirname "$0")/.." && pwd)" +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" sops_yaml="${repo_root}/.sops.yaml" keydir="${repo_root}/host-keys" editor="${repo_root}/scripts/lib/sync-host-keys-edit-sops.py" -# shellcheck source=env.sh +# shellcheck source=../env.sh source "${repo_root}/scripts/env.sh" -# shellcheck source=lib/nix-eval.sh +# shellcheck source=../lib/nix-eval.sh source "${repo_root}/scripts/lib/nix-eval.sh" -# shellcheck source=lib/ssh-host-keys.sh +# shellcheck source=../lib/ssh-host-keys.sh source "${repo_root}/scripts/lib/ssh-host-keys.sh" -# shellcheck source=lib/sops-age.sh +# shellcheck source=../lib/sops-age.sh source "${repo_root}/scripts/lib/sops-age.sh" -# shellcheck source=lib/confirm.sh +# shellcheck source=../lib/confirm.sh source "${repo_root}/scripts/lib/confirm.sh" mkdir -p "$keydir"