diff --git a/.gitignore b/.gitignore index 85d9d70..62934e3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,19 @@ result result-* +# Disko's proxmox-* image-builder writes the finished .raw disk image +# directly into the current directory, not into a result-* symlink (see +# docs/proxmox-images.md, scripts/create-proxmox-resource.sh) — several GB +# each, never meant to be committed. +*.raw + # Ignore automatically generated direnv output .direnv +# Python bytecode cache (scripts/lib/*.py) +__pycache__/ +*.pyc + # Locally-generated SSH host keys staged for transfer to a new machine # during install (see scripts/prepare-host-key.sh) — never commit these. host-keys/ diff --git a/CLAUDE.md b/CLAUDE.md index 2fb3e6f..02be9f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ There is no test suite — "correctness" here means the flake evaluates and sweeps: after editing one or two hosts/modules, evaluate just the `nixosConfigurations.` you touched (plus any `config.system.build.tarball` /`diskoImagesScript`/package output affected) rather than looping over every -host — `codex-maintenance.sh` evaluates 19 hosts plus every package/tarball/ +host — `codex-maintenance.sh` evaluates 18 hosts plus every package/tarball/ image variant now and is slow to run after each small change. Reserve a full `codex-maintenance.sh` run for changes that plausibly affect every host (`modules/common/*`, `flake.nix`, `variables.nix`) or as a final check before @@ -70,6 +70,44 @@ committing. This is a session-workflow preference only — it does not apply to CI, which should keep running the full script on every push/PR regardless of diff size; that's the point of it. +## Scripts + +Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` also has: + +- `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 + 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 restore` / `qm create`+`importdisk`), or reconfigures an existing + resource's cores/memory/disk size (`--modify`, always requires typing the + VMID back to confirm). Refuses to create a target whose host identity + already has a real deployment elsewhere (`variables.nix`'s + `deployedTargets`) unless `--allow-duplicate-host` is passed. + `--dry-run` throughout both modes. +- `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. + +`sync-host-keys.sh` and `create-proxmox-resource.sh` genuinely mutate real +state when run for real (not `--dry-run`): real `secrets/*.yaml` +recipients, real Proxmox VMs/containers. They require the operator's own +SSH/sops access, which an agent session doesn't have — but don't suggest +running either non-dry-run without the operator's explicit go-ahead even +if it becomes technically reachable. + ## Architecture `flake.nix` is the single entry point. It generates one @@ -129,6 +167,12 @@ removing a host. `lxc-*`). Also carries `imageSize`/`imageName`, letting every `proxmox-*` host be built as a standalone, `qm importdisk`-ready `.raw` image with no install step — see `docs/proxmox-images.md`. +- `modules/disko/linode.nix` — `linode-*`'s disko config, deliberately + different in kind from the Proxmox one: Linode provisions and sizes + `/dev/sda`/`/dev/sdb` itself as whole, unpartitioned devices before the OS + boots, so this declares them with `destroy = false` (disko never wipes + them) and a bare `filesystem`/`swap` content type instead of a partition + table — idempotent against an already-provisioned disk, never destructive. - `modules/boot/efi.nix` — systemd-boot + EFI vars, paired with the disko module. - `modules/installer/` — the auto-installer environment (ISO, also served as PXE netboot): `common.nix` (shared config + the generated diff --git a/README.md b/README.md index 960577f..4da0d60 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ list: | `linode-gui` / `proxmox-gui` / `lxc-gui` | Cinnamon desktop workstation (`proxmox-gui` is the real, deployed one — previously the flat `nixos` target) | | `proxmox-pxe-boot` / `lxc-pxe-boot` | HTTP/iPXE boot asset host (`proxmox-pxe-boot` is the real, deployed one — previously the flat `pxe-boot` target) | +The "(real, deployed)" targets above are also tracked machine-readably in +`variables.nix`'s `deployedTargets` — keep both in sync when a deployment +changes. `scripts/create-proxmox-resource.sh` reads that list to refuse +creating a same-identity duplicate of an already-deployed host by accident. + Each buildtype's `hosts//host.nix` carries the per-machine identity (hostname, hostId, per-machine secrets, `system.stateVersion`) that must stay fixed regardless of which platform it's built for — see @@ -56,7 +61,7 @@ nix eval --json .#nixosConfigurations --apply builtins.attrNames | jq -r '.[]' | `modules/installer/` | Auto-installer environment (ISO, also served as PXE netboot) — see `docs/auto-installer.md` | | `host-keys/` | Gitignored, locally-generated SSH host keys for the auto-installer — see `docs/auto-installer.md` | | `docs/` | Operational notes for cache, builders, lock updates, boot services, the auto-installer, and Proxmox image builds | -| `scripts/` | Codex setup, validation, and host-key-prep helpers | +| `scripts/` | Codex setup, validation, host-key, release-bump, and Proxmox resource helpers | ## Validation @@ -104,6 +109,11 @@ 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 +either of the last two end to end (build, host-key registration, upload, +`pct restore`/`qm create`), with `--dry-run` and a guard against duplicating +an already-deployed host's identity. See its `--help`. + ## Security Notes Do not commit tokens, private keys, live credentials, or new password hashes diff --git a/docs/auto-installer.md b/docs/auto-installer.md index 8a30608..5c1050f 100644 --- a/docs/auto-installer.md +++ b/docs/auto-installer.md @@ -56,8 +56,14 @@ it does for the ISO/PXE installer images. `docs/pxe-boot.md`). - `modules/installer/host-keys.nix` — optionally bakes pre-generated SSH host keys into the image; see "Host keys" below. -- `scripts/prepare-host-key.sh` — admin-workstation pre-flight tool, see - "Creating a New Machine" below. +- `scripts/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 + 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 + `sync-host-keys.sh` does the same thing and more. Flake outputs: @@ -164,24 +170,28 @@ host consumes any sops-nix secret — as of this writing, that's every host (`modules/common/configuration.nix` puts the root/nixos password hash and the GitHub token behind sops-nix for all of them). -1. **On your admin workstation**, decide the new machine's flake target name - (`-`, e.g. `proxmox-server`) and generate + register - its host key: +1. **Add the flake target** — `hosts//host.nix` plus the matching + `mkTarget { ... }` entry in `flake.nix`'s `generatedTargets` (see + "Composition pattern" in `CLAUDE.md`). No secrets involved yet, so this + is safe to commit on its own if you want a clean history. + +2. **On your admin workstation, generate and register its host key:** ```sh - ./scripts/prepare-host-key.sh + ./scripts/sync-host-keys.sh ``` - This generates `host-keys/_ssh_host_ed25519_key(.pub)` and - prints the `.sops.yaml` snippet to add, which secrets files to - re-encrypt it into, and the next steps below. + This generates `host-keys/_ssh_host_ed25519_key(.pub)`, + adds it as a new `.sops.yaml` anchor, works out which `secrets/*.yaml` + files this specific host actually references (from its own + `config.sops.secrets`, not guessed), adds it to each one's + `key_groups`, and re-encrypts them with `sops updatekeys` — no manual + YAML editing. Safe to re-run; it only fills in what's missing. -2. **Edit `.sops.yaml`** with the printed snippet, then re-encrypt every - secrets file you added the new host to: - - ```sh - nix-shell -p sops --run 'sops updatekeys secrets/common.yaml' - ``` + 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` + for its other modes (`--remove`, `--regenerate-all-keys`). 3. **Commit and push.** The flake build the installer uses has to see the new recipient before you install, or decryption fails on first boot diff --git a/docs/proxmox-images.md b/docs/proxmox-images.md index 99b2bad..b362682 100644 --- a/docs/proxmox-images.md +++ b/docs/proxmox-images.md @@ -7,6 +7,12 @@ 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 +whole walkthrough below (and the equivalent LXC one) end to end, including +host-key handling and upload — see its `--help`. The steps here are what it +runs under the hood, useful for doing any of it by hand or understanding +what it does before you trust it against real infrastructure. + ## Building ```sh @@ -43,9 +49,10 @@ sudo ./result \ --build-memory 2048 ``` -Generate the key first with `scripts/prepare-host-key.sh `, same -as any other host — see `docs/auto-installer.md` for the full sops.yaml -registration walkthrough. +Generate the key first with `scripts/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). ## Deploying to Proxmox diff --git a/scripts/create-proxmox-resource.sh b/scripts/create-proxmox-resource.sh new file mode 100755 index 0000000..f21aeaf --- /dev/null +++ b/scripts/create-proxmox-resource.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +# Creates new Proxmox VMs/LXC containers from this flake, and reconfigures +# existing ones -- the manual workflows in docs/proxmox-images.md (VM) and +# docs/auto-installer.md's "LXC hosts" section (container), automated. +# +# 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] +# +# SAFETY: +# - The default (create) mode only ever creates a NEW resource -- it +# refuses to run if the target VMID already exists on the node. +# - --modify only ever touches a resource you name explicitly via +# --vmid, shows exactly what will change first, and (outside +# --dry-run) always requires typing that VMID back to confirm before +# anything is sent to the node. There is no bulk/implicit modify. +# - Neither mode can start/stop/delete a resource. Not implemented on +# purpose -- ask before adding it. +# +# See --help for the full option list. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=env.sh +source "${repo_root}/scripts/env.sh" + +sync_keys="${repo_root}/scripts/sync-host-keys.sh" + +usage() { + cat < [options] (create) + $0 --type lxc|vm --list (list --host values) + $0 --modify --vmid [options] (reconfigure) + +Create mode (default): + --type lxc|vm lxc = container, built as a pct-restorable tarball. + vm = VM, built as a Disko .raw disk image (UEFI/OVMF). + --host Which host identity to deploy -- matches + config.networking.hostName (server, docker, + nix-cache, nixos, pxe-boot, nix-minimal). Use + --list to see what's available for --type. + --name Proxmox display name/hostname (default: the flake + target name, e.g. lxc-server) + --vmid Numeric VMID (default: next free, via + \`pvesh get /cluster/nextid\` on the node). + Refuses to run if this ID already exists. + --disk-size lxc only, at create time: overrides the + restored rootfs's absolute size. + --image Use this local image/tarball instead of + building one from the flake. + --allow-duplicate-host Required if --host already has a real + deployment elsewhere (variables.nix's + deployedTargets) -- otherwise refused, since + it'd share that host's hostName/hostId. + +Modify mode (reconfigure an EXISTING resource -- requires --modify): + --modify Switch to modify mode. + --vmid Required: which existing resource to change. + Type/VM-vs-CT is auto-detected on the node. + --grow-disk Grow the primary disk by this many GB + (qm/pct resize; Proxmox only supports + growing, never shrinking, an existing disk). + At least one of --cores / --memory / --grow-disk is required. Always + prints the current -> new values and requires typing the VMID back to + confirm, even outside --dry-run. + +Shared: + --cores create: default \$PROXMOX_DEFAULT_CORES (${PROXMOX_DEFAULT_CORES}). + modify: omit to leave unchanged. + --memory create: default \$PROXMOX_DEFAULT_MEMORY_MB (${PROXMOX_DEFAULT_MEMORY_MB}). + modify: omit to leave unchanged. + --storage (default: \$PROXMOX_STORAGE, ${PROXMOX_STORAGE}) + --iso-storage (default: \$PROXMOX_ISO_STORAGE, ${PROXMOX_ISO_STORAGE}) + --bridge (default: \$PROXMOX_BRIDGE, ${PROXMOX_BRIDGE}) + --node Proxmox node to SSH into (default: + \$PROXMOX_HOST, ${PROXMOX_HOST}) + --dry-run Print the full plan; touch nothing + local or remote, no prompts. + -h, --help + +Config for --storage/--bridge/--node/etc. lives in scripts/env.sh -- edit +that instead of passing the same flag every time. +EOF +} + +dry_run=0 +modify=0 +type="" +host="" +name="" +vmid="" +cores="" +memory="" +disk_size="" +grow_disk="" +image="" +storage="$PROXMOX_STORAGE" +iso_storage="$PROXMOX_ISO_STORAGE" +bridge="$PROXMOX_BRIDGE" +node="$PROXMOX_HOST" +do_list=0 +allow_duplicate_host=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --type) type="$2"; shift 2 ;; + --host) host="$2"; shift 2 ;; + --name) name="$2"; shift 2 ;; + --vmid) vmid="$2"; shift 2 ;; + --cores) cores="$2"; shift 2 ;; + --memory) memory="$2"; shift 2 ;; + --disk-size) disk_size="$2"; shift 2 ;; + --grow-disk) grow_disk="$2"; shift 2 ;; + --image) image="$2"; shift 2 ;; + --storage) storage="$2"; shift 2 ;; + --iso-storage) iso_storage="$2"; shift 2 ;; + --bridge) bridge="$2"; shift 2 ;; + --node) node="$2"; shift 2 ;; + --list) do_list=1; shift ;; + --allow-duplicate-host) allow_duplicate_host=1; shift ;; + --modify) modify=1; shift ;; + --dry-run) dry_run=1; shift ;; + -h | --help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + esac +done + +ssh_target="${PROXMOX_SSH_USER}@${node}" + +remote() { + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] ssh ${ssh_target} -- $*" + else + ssh "$ssh_target" "$@" + fi +} + +# ============================================================ modify mode +cmd_modify() { + if [[ -z "$vmid" ]]; then + echo "ERROR: --modify requires --vmid." >&2 + exit 1 + fi + if [[ -z "$cores" && -z "$memory" && -z "$grow_disk" ]]; then + echo "ERROR: --modify needs at least one of --cores / --memory / --grow-disk." >&2 + exit 1 + fi + + echo "Looking up VMID ${vmid} on ${node}..." + local kind current_cores current_memory disk_key + if ssh "$ssh_target" "qm status ${vmid}" >/dev/null 2>&1; then + kind="vm" + disk_key="scsi0" + elif ssh "$ssh_target" "pct status ${vmid}" >/dev/null 2>&1; then + kind="lxc" + disk_key="rootfs" + else + echo "ERROR: VMID ${vmid} doesn't exist on ${node} -- nothing to modify." >&2 + exit 1 + fi + + local config_cmd="qm config ${vmid}" + [[ "$kind" == "lxc" ]] && config_cmd="pct config ${vmid}" + local current_config + current_config="$(ssh "$ssh_target" "$config_cmd")" + current_cores="$(echo "$current_config" | grep -oP '^cores:\s*\K\S+' || echo '?')" + current_memory="$(echo "$current_config" | grep -oP '^memory:\s*\K\S+' || echo '?')" + + echo + echo "VMID ${vmid} is a ${kind} on ${node}. Planned changes:" + [[ -n "$cores" ]] && echo " cores: ${current_cores} -> ${cores}" + [[ -n "$memory" ]] && echo " memory: ${current_memory} MB -> ${memory} MB" + [[ -n "$grow_disk" ]] && echo " ${disk_key}: grow by +${grow_disk}G (Proxmox can only grow, not shrink, an existing disk)" + + if [[ "$dry_run" -eq 1 ]]; then + echo + echo "[dry-run] Nothing was changed." + return + fi + + echo + read -rp "Type the VMID (${vmid}) to confirm these changes: " confirm + if [[ "$confirm" != "$vmid" ]]; then + echo "Cancelled -- input didn't match ${vmid}." + exit 1 + fi + + local set_cmd="qm set" + local resize_cmd="qm resize" + [[ "$kind" == "lxc" ]] && set_cmd="pct set" && resize_cmd="pct resize" + + if [[ -n "$cores" || -n "$memory" ]]; then + local args="" + [[ -n "$cores" ]] && args="${args} --cores ${cores}" + [[ -n "$memory" ]] && args="${args} --memory ${memory}" + remote "${set_cmd} ${vmid}${args}" + fi + if [[ -n "$grow_disk" ]]; then + remote "${resize_cmd} ${vmid} ${disk_key} +${grow_disk}G" + fi + + echo + echo "Done. VMID ${vmid} updated." +} + +if [[ "$modify" -eq 1 ]]; then + cmd_modify + exit 0 +fi + +# ============================================================= create mode +if [[ "$type" != "lxc" && "$type" != "vm" ]]; then + echo "ERROR: --type must be 'lxc' or 'vm'." >&2 + usage >&2 + exit 1 +fi + +platform_prefix="lxc" +[[ "$type" == "vm" ]] && platform_prefix="proxmox" +[[ -z "$cores" ]] && cores="$PROXMOX_DEFAULT_CORES" +[[ -z "$memory" ]] && memory="$PROXMOX_DEFAULT_MEMORY_MB" + +# --- discover / resolve the flake target from --host -------------------- +list_hosts() { + local target hostname + for target in $(nix eval --json --no-use-registries --no-accept-flake-config \ + "${repo_root}#nixosConfigurations" --apply builtins.attrNames 2>/dev/null \ + | jq -r --arg p "${platform_prefix}-" '.[] | select(startswith($p))'); do + hostname="$(nix eval --raw --no-use-registries --no-accept-flake-config \ + "${repo_root}#nixosConfigurations.${target}.config.networking.hostName" 2>/dev/null)" + printf ' %-12s -> %s\n' "$hostname" "$target" + done +} + +if [[ "$do_list" -eq 1 ]]; then + echo "Available --host values for --type ${type}:" + list_hosts + exit 0 +fi + +if [[ -z "$host" ]]; then + echo "ERROR: --host is required (or use --list to see options)." >&2 + exit 1 +fi + +flake_target="" +for target in $(nix eval --json --no-use-registries --no-accept-flake-config \ + "${repo_root}#nixosConfigurations" --apply builtins.attrNames \ + | jq -r --arg p "${platform_prefix}-" '.[] | select(startswith($p))'); do + hn="$(nix eval --raw --no-use-registries --no-accept-flake-config \ + "${repo_root}#nixosConfigurations.${target}.config.networking.hostName")" + if [[ "$hn" == "$host" ]]; then + flake_target="$target" + break + fi +done + +if [[ -z "$flake_target" ]]; then + echo "ERROR: no ${platform_prefix}-* target has hostName '${host}'." >&2 + echo "Available:" >&2 + list_hosts >&2 + exit 1 +fi + +[[ -z "$name" ]] && name="$flake_target" + +# --- refuse to duplicate a host that's already really deployed ---------- +# Checked by hostName, not exact flake target: proxmox-server being +# deployed also blocks --type lxc --host server, since both would carry +# the same hosts/server/host.nix identity (hostName, hostId). +if [[ "$allow_duplicate_host" -eq 0 ]]; then + deployed_targets_json="$(nix eval --json --no-use-registries --no-accept-flake-config \ + --file "${repo_root}/variables.nix" deployedTargets)" + for dt in $(echo "$deployed_targets_json" | jq -r '.[]'); do + dt_hostname="$(nix eval --raw --no-use-registries --no-accept-flake-config \ + "${repo_root}#nixosConfigurations.${dt}.config.networking.hostName" 2>/dev/null || true)" + if [[ "$dt_hostname" == "$host" ]]; then + echo "ERROR: '${host}' already has a real deployment (${dt}, per variables.nix's" >&2 + echo "deployedTargets). Creating ${flake_target} would share its hostName/hostId --" >&2 + echo "refusing by default. Pass --allow-duplicate-host if you really mean to spin" >&2 + echo "up a separate test instance of this host (it'll still get its own distinct" >&2 + echo "sops key and VMID, never touching ${dt})." >&2 + exit 1 + fi + done +fi + +echo "Target: ${flake_target} (host=${host}, type=${type}) -> Proxmox resource '${name}'" + +# --- make sure this target has a registered host key -------------------- +echo +echo "==> Ensuring host key exists and is registered..." +sync_args=("$flake_target") +[[ "$dry_run" -eq 1 ]] && sync_args+=(--dry-run) +bash "$sync_keys" "${sync_args[@]}" + +# --- VMID: pick one, and refuse to touch anything that already exists --- +echo +if [[ -z "$vmid" ]]; then + if [[ "$dry_run" -eq 1 ]]; then + vmid="" + echo "[dry-run] would ask ${node} for the next free VMID (pvesh get /cluster/nextid)" + else + vmid="$(ssh "$ssh_target" "pvesh get /cluster/nextid" | tr -d '[:space:]')" + echo "Auto-assigned VMID: ${vmid}" + fi +else + echo "Requested VMID: ${vmid}" +fi + +if [[ "$dry_run" -eq 0 ]]; then + # qm/pct status exits non-zero (and prints "does not exist") for a free + # ID on that resource type -- but a VMID could exist as the OTHER + # resource type (e.g. requested a CT id that's actually a VM), so check + # both. Any success here means something is already using this ID -- + # refuse to go anywhere near it. (Reconfiguring an existing resource is + # --modify's job, not this one's.) + if ssh "$ssh_target" "qm status ${vmid}" >/dev/null 2>&1 \ + || ssh "$ssh_target" "pct status ${vmid}" >/dev/null 2>&1; then + echo "ERROR: VMID ${vmid} already exists on ${node}. Refusing to touch an" >&2 + echo "existing resource here -- use --modify to reconfigure it, pick a" >&2 + echo "different --vmid, or omit it to auto-assign." >&2 + exit 1 + fi +fi + +# --- build (or reuse) the image ------------------------------------------ +echo +local_image="" +remote_dir="" +if [[ -n "$image" ]]; then + [[ -f "$image" ]] || { echo "ERROR: --image '${image}' not found." >&2; exit 1; } + local_image="$image" + echo "Using provided image: ${local_image}" +elif [[ "$type" == "lxc" ]]; then + remote_dir="/var/lib/vz/dump" + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] would build: NIXOS_HOST_KEYS_DIR=${repo_root}/host-keys nix build --impure \\" + echo "[dry-run] .#nixosConfigurations.${flake_target}.config.system.build.tarball" + local_image="" + else + echo "==> Building LXC tarball for ${flake_target}..." + NIXOS_HOST_KEYS_DIR="${repo_root}/host-keys" nix build --impure \ + --no-use-registries --no-accept-flake-config \ + ".#nixosConfigurations.${flake_target}.config.system.build.tarball" \ + --out-link "${repo_root}/result-${flake_target}" + local_image="$(find "${repo_root}/result-${flake_target}/tarball" -maxdepth 1 -type f | head -1)" + echo "Built: ${local_image}" + fi +else + remote_dir="/var/lib/vz/import" + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] would build: nix build .#nixosConfigurations.${flake_target}.config.system.build.diskoImagesScript" + echo "[dry-run] would run: sudo ./result-${flake_target} \\" + echo "[dry-run] --pre-format-files host-keys/${flake_target}_ssh_host_ed25519_key /etc/ssh/ssh_host_ed25519_key \\" + echo "[dry-run] --pre-format-files host-keys/${flake_target}_ssh_host_ed25519_key.pub /etc/ssh/ssh_host_ed25519_key.pub \\" + echo "[dry-run] --build-memory 2048" + local_image=".raw" + else + echo "==> Building Disko image script for ${flake_target}..." + nix build --no-use-registries --no-accept-flake-config \ + ".#nixosConfigurations.${flake_target}.config.system.build.diskoImagesScript" \ + --out-link "${repo_root}/result-${flake_target}" + echo "==> Running it (builds the .raw image in a temporary QEMU VM, needs sudo)..." + ( cd "$repo_root" && sudo "./result-${flake_target}" \ + --pre-format-files "host-keys/${flake_target}_ssh_host_ed25519_key" /etc/ssh/ssh_host_ed25519_key \ + --pre-format-files "host-keys/${flake_target}_ssh_host_ed25519_key.pub" /etc/ssh/ssh_host_ed25519_key.pub \ + --build-memory 2048 ) + local_image="$(find "$repo_root" -maxdepth 1 -name "*.raw" -newer "${repo_root}/result-${flake_target}" | head -1)" + if [[ -z "$local_image" ]]; then + echo "ERROR: expected a .raw image after the build but didn't find one in ${repo_root}." >&2 + exit 1 + fi + echo "Built: ${local_image}" + fi +fi + +# --- upload --------------------------------------------------------------- +echo +remote_path="${remote_dir}/$(basename "${local_image}")" +if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] would upload: scp ${local_image} ${ssh_target}:${remote_path}" +else + echo "==> Uploading to ${node}:${remote_path}..." + ssh "$ssh_target" "mkdir -p ${remote_dir}" + scp "$local_image" "${ssh_target}:${remote_path}" +fi + +# --- create ----------------------------------------------------------------- +echo +if [[ "$type" == "lxc" ]]; then + echo "==> Creating LXC container ${vmid} (${name})..." + restore_cmd="pct restore ${vmid} ${remote_path} --storage ${storage} --hostname ${name} --cores ${cores} --memory ${memory} --net0 name=eth0,bridge=${bridge},ip=dhcp" + [[ -n "$disk_size" ]] && restore_cmd="${restore_cmd} --rootfs ${storage}:${disk_size}" + remote "$restore_cmd" + remote "pct start ${vmid}" +else + echo "==> Creating VM ${vmid} (${name})..." + # pre-enrolled-keys=0 disables OVMF's Secure Boot key pre-enrollment -- + # required, or systemd-boot (unsigned) can't be trusted by the firmware. + remote "qm create ${vmid} --name ${name} --memory ${memory} --cores ${cores} \ +--net0 virtio,bridge=${bridge} --bios ovmf --machine q35 --scsihw virtio-scsi-pci \ +--efidisk0 ${storage}:1,efitype=4m,pre-enrolled-keys=0" + + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] ssh ${ssh_target} -- qm importdisk ${vmid} ${remote_path} ${storage}" + echo "[dry-run] (would parse the resulting disk identifier from that output)" + echo "[dry-run] ssh ${ssh_target} -- qm set ${vmid} --scsi0 ${storage}:" + else + importdisk_output="$(ssh "$ssh_target" "qm importdisk ${vmid} ${remote_path} ${storage}")" + echo "$importdisk_output" + disk_id="$(echo "$importdisk_output" | grep -oP "(?<=Successfully imported disk as ')[^']+" | sed 's/^unused[0-9]*://')" + if [[ -z "$disk_id" ]]; then + echo "ERROR: couldn't parse the imported disk identifier from qm importdisk's output above." >&2 + echo "The VM shell (${vmid}) and imported disk both exist -- finish attaching it by hand:" >&2 + echo " ssh ${ssh_target} -- qm set ${vmid} --scsi0 ${storage}:" >&2 + echo " ssh ${ssh_target} -- qm set ${vmid} --boot order=scsi0" >&2 + exit 1 + fi + remote "qm set ${vmid} --scsi0 ${disk_id}" + fi + remote "qm set ${vmid} --boot order=scsi0" + remote "qm start ${vmid}" +fi + +echo +if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] Nothing was built, uploaded, or created." +else + echo "Done. ${name} (VMID ${vmid}) should be booting on ${node}." +fi diff --git a/scripts/env.sh b/scripts/env.sh new file mode 100755 index 0000000..1109541 --- /dev/null +++ b/scripts/env.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Shared config for scripts/*.sh. Source this instead of hardcoding a +# 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 ...) +# since each one only sets a default if unset. + +# SSH-reachable Proxmox node that scripts/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, +# or if you're targeting a different node in a multi-node cluster. +: "${PROXMOX_HOST:=pve.sweet.home}" +: "${PROXMOX_SSH_USER:=root}" + +# Storage pool names -- Proxmox's own stock-install defaults, but this +# varies a lot by setup (ZFS pool name, custom LVM-thin volume, etc.). +# Verify with `pvesm status` on the node and correct these if wrong. +: "${PROXMOX_STORAGE:=local-lvm}" # VM disks / CT rootfs +: "${PROXMOX_ISO_STORAGE:=local}" # uploaded images/ISOs/CT templates + +: "${PROXMOX_BRIDGE:=vmbr0}" + +# Fallback resource sizing when a script doesn't get --cores/--memory. +: "${PROXMOX_DEFAULT_CORES:=2}" +: "${PROXMOX_DEFAULT_MEMORY_MB:=2048}" + +export PROXMOX_HOST PROXMOX_SSH_USER PROXMOX_STORAGE PROXMOX_ISO_STORAGE \ + PROXMOX_BRIDGE PROXMOX_DEFAULT_CORES PROXMOX_DEFAULT_MEMORY_MB diff --git a/scripts/lib/sync-host-keys-edit-sops.py b/scripts/lib/sync-host-keys-edit-sops.py new file mode 100644 index 0000000..b7e0d64 --- /dev/null +++ b/scripts/lib/sync-host-keys-edit-sops.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# Companion to scripts/sync-host-keys.sh. Applies a set of additive edits +# to .sops.yaml via targeted line insertion -- not a full YAML parse and +# re-serialize -- so every untouched byte of the file is guaranteed to stay +# exactly as it was (a full round-trip through a generic YAML library was +# tested and silently reformatted the whole file's indentation style). +# +# Reads a JSON edit plan from stdin: +# { +# "add_keys": [{"host": "lxc-server", "age_key": "age1..."}], +# "add_aliases": [{"host": "lxc-server", "basename": "common.yaml"}], +# "remove_keys": ["lxc-server"], +# "remove_aliases_for_hosts": ["lxc-server"] +# } +# Idempotent: an anchor or alias that's already present/absent is left +# alone -- adding what's already there, or removing what's already gone, +# is a no-op rather than an error. +# Prints a JSON summary of what actually changed to stdout, so the caller +# knows which secrets/*.yaml files need `sops updatekeys` and which don't. +import json +import re +import sys + +KEY_LINE_RE = re.compile(r"^ - &\S+ age1") + + +def add_keys(lines, add_keys): + existing_anchors = set() + for line in lines: + m = re.match(r"^ - &(\S+) age1", line) + if m: + existing_anchors.add(m.group(1)) + + new = [e for e in add_keys if e["host"] not in existing_anchors] + if not new: + return lines, [] + + last_idx = None + for i, line in enumerate(lines): + if KEY_LINE_RE.match(line): + last_idx = i + if last_idx is None: + print("ERROR: no ' - & age1...' line found under keys: in .sops.yaml", file=sys.stderr) + sys.exit(1) + + insert = [f" - &{e['host']} {e['age_key']}\n" for e in new] + lines = lines[: last_idx + 1] + insert + lines[last_idx + 1 :] + return lines, [e["host"] for e in new] + + +def add_aliases(lines, add_aliases): + changed_files = [] + for e in add_aliases: + host = e["host"] + basename = e["basename"] + target = "path_regex: secrets/" + basename.replace(".", r"\.") + "$" + + rule_start = None + for i, line in enumerate(lines): + if target in line: + rule_start = i + break + if rule_start is None: + print( + f"WARNING: no creation_rule for secrets/{basename} in .sops.yaml " + f"(needed by {host}) -- add one manually, then re-run.", + file=sys.stderr, + ) + continue + + rule_end = len(lines) + for i in range(rule_start + 1, len(lines)): + if re.match(r"^ - path_regex:", lines[i]): + rule_end = i + break + + block = lines[rule_start:rule_end] + if any(re.search(r"\*" + re.escape(host) + r"\s*$", line) for line in block): + continue # already present + + last_alias_idx = None + last_alias_indent = None + for i in range(rule_start, rule_end): + m = re.match(r"^(\s*)- \*\S+\s*$", lines[i]) + if m: + last_alias_idx = i + last_alias_indent = m.group(1) + if last_alias_idx is None: + print( + f"WARNING: creation_rule for secrets/{basename} has no existing " + f"'- *alias' line to anchor the insertion point -- add {host} manually.", + file=sys.stderr, + ) + continue + + new_line = f"{last_alias_indent}- *{host}\n" + lines = lines[: last_alias_idx + 1] + [new_line] + lines[last_alias_idx + 1 :] + changed_files.append(basename) + return lines, changed_files + + +def remove_keys(lines, hosts): + hosts = set(hosts) + removed = [] + kept = [] + for line in lines: + m = re.match(r"^ - &(\S+) age1", line) + if m and m.group(1) in hosts: + removed.append(m.group(1)) + continue + kept.append(line) + return kept, removed + + +def remove_aliases_for_hosts(lines, hosts): + hosts = set(hosts) + if not hosts: + return lines, [] + + changed_files = [] + result = [] + current_basename = None + current_block_changed = False + + def flush(): + if current_block_changed and current_basename: + changed_files.append(current_basename) + + for line in lines: + if re.match(r"^ - path_regex:", line): + flush() + current_block_changed = False + m = re.search(r"path_regex: secrets/(.+)\$", line) + current_basename = m.group(1).replace(r"\.", ".") if m else None + result.append(line) + continue + + if current_basename is not None and any( + re.search(r"\*" + re.escape(h) + r"\s*$", line) for h in hosts + ): + current_block_changed = True + continue # drop this alias line + + result.append(line) + + flush() + return result, changed_files + + +def main(): + sops_path = sys.argv[1] + edits = json.load(sys.stdin) + + with open(sops_path) as f: + lines = f.readlines() + + lines, added_keys = add_keys(lines, edits.get("add_keys", [])) + lines, added_alias_files = add_aliases(lines, edits.get("add_aliases", [])) + lines, removed_keys = remove_keys(lines, edits.get("remove_keys", [])) + lines, removed_alias_files = remove_aliases_for_hosts( + lines, edits.get("remove_aliases_for_hosts", []) + ) + + with open(sops_path, "w") as f: + f.writelines(lines) + + changed_files = sorted(set(added_alias_files) | set(removed_alias_files)) + json.dump( + { + "added_keys": added_keys, + "removed_keys": removed_keys, + "changed_secrets_files": changed_files, + }, + sys.stdout, + ) + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare-host-key.sh b/scripts/prepare-host-key.sh index 8e4390c..94f0b3d 100755 --- a/scripts/prepare-host-key.sh +++ b/scripts/prepare-host-key.sh @@ -1,9 +1,13 @@ #!/usr/bin/env bash -# Generates a new machine's SSH host key ahead of installing it, so -# sops-nix has something to derive an age key from before the target -# ever boots. +# 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 +# .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. # -# Why this is needed: sops-nix derives each host's decryption key from +# Why a host key is needed at all: sops-nix derives each host's decryption key from # its own /etc/ssh/ssh_host_ed25519_key at *activation* time, but that # activation runs before systemd would otherwise generate this key on # first boot (sshd-keygen is a normal systemd service gated behind diff --git a/scripts/sync-host-keys.sh b/scripts/sync-host-keys.sh new file mode 100755 index 0000000..cbe3bc6 --- /dev/null +++ b/scripts/sync-host-keys.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +# Manages host-keys/ + .sops.yaml + secrets/*.yaml recipients together, so +# a flake target's SSH host key and its sops registration never drift out +# of sync with each other or with the flake itself. +# +# sync-host-keys.sh --all Generate/register every flake +# target missing a key. +# sync-host-keys.sh Same, for just one target. +# sync-host-keys.sh --remove Interactively remove one +# locally-managed key. +# sync-host-keys.sh --regenerate-all-keys Remove and freshly regenerate +# every locally-managed key. +# +# "Generate/register" is idempotent and additive only: an existing +# host-keys/ file is never touched, and .sops.yaml only ever gains an +# anchor/alias it doesn't already have -- safe to re-run any time, e.g. +# right after adding a new host to flake.nix. +# +# --remove and --regenerate-all-keys only ever operate on anchors that have +# a corresponding host-keys/_ssh_host_ed25519_key file. Anchors +# without one (&admin, and any anchor for an already-deployed host whose +# real /etc/ssh key was registered by hand, e.g. &docker/&server/&nix-cache +# today) are never listed, removed, or regenerated -- this tooling only +# ever touches keys it itself manages. +set -euo pipefail + +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" + +mkdir -p "$keydir" + +usage() { + cat < [--dry-run] + $0 --remove [--dry-run] + $0 --regenerate-all-keys [--dry-run] + + --all Generate + register a host key for every flake + target that's missing one. + Same, for just one target (e.g. lxc-server). + Reports if it already has one. + --remove Interactively pick one locally-managed key to + remove from .sops.yaml and host-keys/. + --regenerate-all-keys Remove every locally-managed key and generate + fresh replacements for every current flake + target. Destructive -- requires typed + confirmation. + --dry-run Combine with any of the above: print what would + change (host-keys/ files, .sops.yaml anchors and + key_groups, which secrets/*.yaml would be + re-encrypted) without touching anything. No keys + generated, no files written, no sops calls, + no prompts for confirmation. +EOF +} + +# --- step 0: make sure we can actually decrypt anything at all ------------- +# +# Registering a host means editing .sops.yaml and then running +# `sops updatekeys`, which has to decrypt each secrets file with an +# existing recipient's key before it can re-encrypt it for the new one. +# Check this before doing anything else, the same order sops/age itself +# resolves a usable key in: SOPS_AGE_KEY (inline), then SOPS_AGE_KEY_FILE, +# then the XDG default path. +ensure_admin_decrypt_key() { + if [[ -n "${SOPS_AGE_KEY:-}" ]]; then + echo "Using SOPS_AGE_KEY from the environment." + return + fi + + local key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" + + if [[ -s "$key_file" ]]; then + echo "Found existing sops age key at ${key_file}." + return + fi + + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] No sops age decryption key found (checked \$SOPS_AGE_KEY, \$SOPS_AGE_KEY_FILE, ${key_file})." + echo "[dry-run] Would generate a new one here -- continuing the dry run without one; any" + echo "[dry-run] 'would re-encrypt' output below couldn't actually run for real yet." + return + fi + + echo "No sops age decryption key found (checked \$SOPS_AGE_KEY, \$SOPS_AGE_KEY_FILE, ${key_file})." + echo "Generating a new one at ${key_file}..." + mkdir -p "$(dirname "$key_file")" + nix-shell -p age --run "age-keygen -o '${key_file}'" 2>&1 | grep -v "^Public key:" || true + local new_pub + new_pub="$(nix-shell -p age --run "age-keygen -y '${key_file}'")" + + cat < ${host}: generating host key" + nix-shell -p openssh --run "ssh-keygen -t ed25519 -N '' -C '${host}' -f '${keyfile}'" >/dev/null + fi + else + echo "==> ${host}: host key already present" + fi + + if [[ "$has_anchor" -eq 0 ]]; then + local age_pub + if [[ "$dry_run" -eq 1 ]]; then + age_pub="dry-run-placeholder-not-a-real-key" + else + age_pub="$(nix-shell -p ssh-to-age --run "ssh-to-age -i '${keyfile}.pub'")" + fi + add_keys_json="$(jq --arg host "$host" --arg key "$age_pub" \ + '. + [{host: $host, age_key: $key}]' <<<"$add_keys_json")" + fi + + echo "==> ${host}: checking which secrets files it references" + local basenames + mapfile -t basenames < <( + nix eval --json --no-use-registries --no-accept-flake-config \ + "${repo_root}#nixosConfigurations.${host}.config.sops.secrets" \ + --apply 'builtins.mapAttrs (n: v: baseNameOf v.sopsFile)' \ + | jq -r '[.[]] | unique | .[]' + ) + local basename + for basename in "${basenames[@]}"; do + add_aliases_json="$(jq --arg host "$host" --arg basename "$basename" \ + '. + [{host: $host, basename: $basename}]' <<<"$add_aliases_json")" + done +} + +# In dry-run, this runs the exact same edit logic (so idempotency/what's- +# actually-new is determined for real, not guessed) but against a scratch +# copy of .sops.yaml that's discarded afterward -- the real file is never +# opened for writing, and `sops updatekeys` never runs. +apply_edit_plan() { + local plan="$1" + local target="$sops_yaml" + local tmpfile="" + if [[ "$dry_run" -eq 1 ]]; then + tmpfile="$(mktemp)" + cp "$sops_yaml" "$tmpfile" + target="$tmpfile" + fi + + local result + result="$(echo "$plan" | nix-shell -p python3 --run "python3 '${editor}' '${target}'")" + [[ -n "$tmpfile" ]] && rm -f "$tmpfile" + + local added removed changed + added="$(jq -r '.added_keys[]?' <<<"$result")" + removed="$(jq -r '.removed_keys[]?' <<<"$result")" + changed="$(jq -r '.changed_secrets_files[]?' <<<"$result")" + + if [[ -z "$added" && -z "$removed" && -z "$changed" ]]; then + echo "Nothing changed in .sops.yaml." + return + fi + + local prefix="" + [[ "$dry_run" -eq 1 ]] && prefix="[dry-run] would " + + [[ -n "$added" ]] && echo "${prefix}Add .sops.yaml anchors: $(tr '\n' ' ' <<<"$added")" + [[ -n "$removed" ]] && echo "${prefix}Remove .sops.yaml anchors: $(tr '\n' ' ' <<<"$removed")" + + if [[ -n "$changed" ]]; then + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] would re-encrypt:" + while IFS= read -r basename; do + [[ -z "$basename" ]] && continue + echo " secrets/${basename}" + done <<<"$changed" + else + echo "Re-encrypting affected secrets files..." + while IFS= read -r basename; do + [[ -z "$basename" ]] && continue + echo "==> secrets/${basename}" + nix-shell -p sops --run "sops updatekeys --yes '${repo_root}/secrets/${basename}'" + done <<<"$changed" + fi + fi +} + +flush_additions() { + if [[ "$add_keys_json" == "[]" && "$add_aliases_json" == "[]" ]]; then + echo "Nothing to do -- every requested target already has a fully registered host key." + return + fi + echo + echo "Applying .sops.yaml edits..." + local plan + plan="$(jq -n --argjson add_keys "$add_keys_json" --argjson add_aliases "$add_aliases_json" \ + '{add_keys: $add_keys, add_aliases: $add_aliases}')" + apply_edit_plan "$plan" + echo + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] Nothing was changed. Re-run without --dry-run to apply this." + else + echo "Done. Review the .sops.yaml / secrets/*.yaml diff, then commit and push --" + echo "the flake build the installer uses has to see the new recipient(s) before" + echo "any of these hosts can decrypt their secrets on first boot." + fi +} + +cmd_all() { + echo "Discovering flake targets..." + local targets + mapfile -t targets < <(discover_targets) + local host + for host in "${targets[@]}"; do + queue_host_sync "$host" || true + done + flush_additions +} + +cmd_target() { + local host="$1" + local targets + mapfile -t targets < <(discover_targets) + if ! printf '%s\n' "${targets[@]}" | grep -qxF "$host"; then + echo "ERROR: '${host}' is not a current nixosConfigurations target." >&2 + echo "Current targets:" >&2 + printf ' %s\n' "${targets[@]}" >&2 + exit 1 + fi + queue_host_sync "$host" || exit 1 + flush_additions +} + +cmd_remove() { + local hosts + mapfile -t hosts < <(locally_managed_hosts) + if [[ "${#hosts[@]}" -eq 0 ]]; then + echo "No locally-managed keys in host-keys/ -- nothing to remove." + return + fi + + echo "Locally-managed keys:" + local i=1 host + for host in "${hosts[@]}"; do + local registered="not registered in .sops.yaml" + grep -qE "^ - &${host} age1" "$sops_yaml" && registered="registered in .sops.yaml" + printf ' %d) %s (%s)\n' "$i" "$host" "$registered" + i=$((i + 1)) + done + + local choice + read -rp "Remove which one? (number, or blank to cancel): " choice + if [[ -z "$choice" ]]; then + echo "Cancelled." + return + fi + if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#hosts[@]} )); then + echo "ERROR: invalid selection." >&2 + exit 1 + fi + local target="${hosts[$((choice - 1))]}" + + if [[ "$dry_run" -ne 1 ]]; then + read -rp "Really remove '${target}'? Its host-keys/ files will be deleted and it will lose access to every secrets file it can currently decrypt. (y/N): " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Cancelled." + return + fi + fi + + local plan + plan="$(jq -n --arg host "$target" \ + '{remove_keys: [$host], remove_aliases_for_hosts: [$host]}')" + apply_edit_plan "$plan" + + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] would delete host-keys/${target}_ssh_host_ed25519_key(.pub)." + echo "[dry-run] Nothing was changed. Re-run without --dry-run to apply this." + else + rm -f "${keydir}/${target}_ssh_host_ed25519_key" "${keydir}/${target}_ssh_host_ed25519_key.pub" + echo "Removed host-keys/${target}_ssh_host_ed25519_key(.pub)." + echo + echo "Review the diff, then commit and push." + fi +} + +cmd_regenerate_all() { + local hosts + mapfile -t hosts < <(locally_managed_hosts) + if [[ "${#hosts[@]}" -eq 0 ]]; then + echo "No locally-managed keys in host-keys/ -- nothing to regenerate." + return + fi + + echo "This will remove and freshly regenerate ALL locally-managed keys:" + printf ' %s\n' "${hosts[@]}" + echo + echo "Every host above will need its new key baked into a rebuilt install" + 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 + echo "Cancelled." + return + fi + fi + + echo + local hosts_json + hosts_json="$(printf '%s\n' "${hosts[@]}" | jq -R . | jq -s .)" + local plan + plan="$(jq -n --argjson hosts "$hosts_json" \ + '{remove_keys: $hosts, remove_aliases_for_hosts: $hosts}')" + apply_edit_plan "$plan" + + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] would delete ${#hosts[@]} host-keys/ file pair(s)." + echo "[dry-run] would then generate fresh replacements for the same hosts" + echo "[dry-run] (not simulated further here -- run without --dry-run, or" + echo "[dry-run] preview a specific target with: $0 --dry-run)." + echo + echo "[dry-run] Nothing was changed. Re-run without --dry-run to apply this." + return + fi + + echo "Removing existing keys..." + local host + for host in "${hosts[@]}"; do + rm -f "${keydir}/${host}_ssh_host_ed25519_key" "${keydir}/${host}_ssh_host_ed25519_key.pub" + done + echo "Removed ${#hosts[@]} host-keys/ file pair(s)." + + echo + echo "Regenerating fresh keys for every current flake target..." + cmd_all +} + +main() { + local args=() + local arg + for arg in "$@"; do + if [[ "$arg" == "--dry-run" ]]; then + dry_run=1 + else + args+=("$arg") + fi + done + set -- "${args[@]+"${args[@]}"}" + + if [[ "$dry_run" -eq 1 ]]; then + echo "[dry-run] no changes will be made" + echo + fi + + ensure_admin_decrypt_key + + case "${1:-}" in + --all) + cmd_all + ;; + --remove) + cmd_remove + ;; + --regenerate-all-keys) + cmd_regenerate_all + ;; + -h | --help | "") + usage + ;; + --*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + cmd_target "$1" + ;; + esac +} + +main "$@" diff --git a/variables.nix b/variables.nix index d32f073..2e5c0db 100644 --- a/variables.nix +++ b/variables.nix @@ -146,4 +146,21 @@ maxSize = "100M"; # rotate once a log file exceeds this size keep = 20; # number of rotated logs to retain before deleting the oldest }; + + # Flake targets with a real, currently-running deployment somewhere — + # matches README.md's Hosts table "(real, deployed)" annotations; update + # both together. Not consumed by any NixOS module (nothing in the actual + # system config should behave differently because of this) — it's read + # by scripts/create-proxmox-resource.sh to refuse creating a same-identity + # duplicate of an already-deployed host (shared hostName/hostId) unless + # you explicitly pass --allow-duplicate-host. + deployedTargets = [ + "linode-minimal" + "proxmox-minimal" + "proxmox-nix-cache" + "proxmox-server" + "proxmox-docker" + "proxmox-gui" + "proxmox-pxe-boot" + ]; }