Add sync-host-keys.sh and create-proxmox-resource.sh
Check NixOS configurations / eval-hosts (push) Failing after 10m48s

sync-host-keys.sh: generates/registers SSH host keys and their
.sops.yaml/secrets/*.yaml recipients for flake targets, idempotently.
--all, <target>, --remove, --regenerate-all-keys, all with --dry-run
(verified zero-side-effect via a sandboxed git-status check across every
mode). Only ever touches anchors with a corresponding host-keys/ file --
&admin and any hand-registered real-host anchor are never listed,
removed, or regenerated. Supersedes running prepare-host-key.sh one host
at a time for any target that already has a flake entry.

create-proxmox-resource.sh: builds a lxc-*/proxmox-* target's
tarball/disk image and creates it on a real Proxmox node, or reconfigures
an existing resource's cores/memory/disk (--modify, always requires
typing the VMID back to confirm). Refuses to create a new resource for a
VMID that already exists, and refuses to duplicate a host identity that
already has a real deployment elsewhere (variables.nix's new
deployedTargets, checked by hostName so it also catches cross-platform
duplicates) unless --allow-duplicate-host is passed. --dry-run throughout.

scripts/env.sh centralizes the Proxmox connection config both scripts
(and future ones) share. Also fixes an unrelated gap found along the way:
proxmox-* Disko image builds write their .raw file straight into the
repo root, and .gitignore never covered it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01La55Nsss8jZ7ZuzUV9mfot
This commit is contained in:
2026-07-20 10:31:06 +10:00
co-authored by Claude Sonnet 5
parent 0e5aa044c0
commit 2858891c20
11 changed files with 1205 additions and 24 deletions
+10
View File
@@ -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/
+45 -1
View File
@@ -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.<host>` 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`, `<target>`, `--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
+11 -1
View File
@@ -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/<name>/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 <name>` 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
+25 -15
View File
@@ -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
(`<platform>-<buildtype>`, e.g. `proxmox-server`) and generate + register
its host key:
1. **Add the flake target**`hosts/<name>/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 <hostname>
./scripts/sync-host-keys.sh <flake-target>
```
This generates `host-keys/<hostname>_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/<flake-target>_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
+10 -3
View File
@@ -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 <name>` 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 <hostname>`, 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 <hostname>`, 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
+432
View File
@@ -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 <name> [options]
# scripts/create-proxmox-resource.sh --type lxc|vm --list
# scripts/create-proxmox-resource.sh --modify --vmid <n> [--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 <<EOF
Usage: $0 --type lxc|vm --host <name> [options] (create)
$0 --type lxc|vm --list (list --host values)
$0 --modify --vmid <n> [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 <name> 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 <name> Proxmox display name/hostname (default: the flake
target name, e.g. lxc-server)
--vmid <n> Numeric VMID (default: next free, via
\`pvesh get /cluster/nextid\` on the node).
Refuses to run if this ID already exists.
--disk-size <GB> lxc only, at create time: overrides the
restored rootfs's absolute size.
--image <path> 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 <n> Required: which existing resource to change.
Type/VM-vs-CT is auto-detected on the node.
--grow-disk <GB> 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 <n> create: default \$PROXMOX_DEFAULT_CORES (${PROXMOX_DEFAULT_CORES}).
modify: omit to leave unchanged.
--memory <MB> create: default \$PROXMOX_DEFAULT_MEMORY_MB (${PROXMOX_DEFAULT_MEMORY_MB}).
modify: omit to leave unchanged.
--storage <pool> (default: \$PROXMOX_STORAGE, ${PROXMOX_STORAGE})
--iso-storage <pool> (default: \$PROXMOX_ISO_STORAGE, ${PROXMOX_ISO_STORAGE})
--bridge <bridge> (default: \$PROXMOX_BRIDGE, ${PROXMOX_BRIDGE})
--node <host> 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="<next-free-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="<built-tarball>"
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="<built-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}:<parsed-disk-id>"
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}:<disk-id-from-output-above>" >&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
Executable
+30
View File
@@ -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.<homeDomain> 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
+180
View File
@@ -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 ' - &<name> 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()
+8 -4
View File
@@ -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 <target> 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
+437
View File
@@ -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 <target> 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/<name>_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 <<EOF
Usage: $0 --all [--dry-run]
$0 <flake-target> [--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.
<flake-target> 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 <<EOF
A brand-new age key was just generated -- it cannot decrypt anything that
already exists in secrets/*.yaml, since nothing was ever encrypted for it.
That trust can't be bootstrapped automatically (nobody can decrypt a file
for a recipient that didn't exist when it was last encrypted).
To actually use this key:
1. Have someone who currently CAN decrypt replace the &admin entry in
.sops.yaml with this public key:
${new_pub}
2. They re-encrypt every secrets/*.yaml:
sops updatekeys --yes secrets/common.yaml
sops updatekeys --yes secrets/nix-cache.yaml
sops updatekeys --yes secrets/server.yaml
3. Re-run this script.
Exiting without making any other changes.
EOF
exit 1
}
discover_targets() {
nix eval --json --no-use-registries --no-accept-flake-config \
"${repo_root}#nixosConfigurations" --apply builtins.attrNames \
| jq -r '.[] | select(. != "installer")'
# installer is the one nixosConfigurations target that doesn't import
# sops-nix at all (see CLAUDE.md's "Security Notes" -- hardcoded login
# password instead) -- config.sops.secrets doesn't exist for it.
}
locally_managed_hosts() {
for f in "$keydir"/*_ssh_host_ed25519_key.pub; do
[[ -e "$f" ]] || continue
basename "$f" _ssh_host_ed25519_key.pub
done
}
add_keys_json="[]"
add_aliases_json="[]"
dry_run=0
queue_host_sync() {
local host="$1"
local keyfile="${keydir}/${host}_ssh_host_ed25519_key"
local has_local_key=0 has_anchor=0
[[ -f "$keyfile" ]] && has_local_key=1
grep -qE "^ - &${host} age1" "$sops_yaml" && has_anchor=1
if [[ "$has_local_key" -eq 0 && "$has_anchor" -eq 1 ]]; then
echo "SKIP ${host}: .sops.yaml already has an &${host} anchor, but"
echo " host-keys/${host}_ssh_host_ed25519_key is missing locally."
echo " Not generating a replacement -- it wouldn't match whatever's"
echo " already registered (and possibly deployed). Remove the"
echo " &${host} line from .sops.yaml first if you really want a"
echo " fresh key, then re-run."
return 1
fi
if [[ "$has_local_key" -eq 0 ]]; then
if [[ "$dry_run" -eq 1 ]]; then
echo "[dry-run] ${host}: would generate host key"
else
echo "==> ${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 <target> --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 "$@"
+17
View File
@@ -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"
];
}