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
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 # Ignore automatically generated direnv output
.direnv .direnv
# Python bytecode cache (scripts/lib/*.py)
__pycache__/
*.pyc
# Locally-generated SSH host keys staged for transfer to a new machine # Locally-generated SSH host keys staged for transfer to a new machine
# during install (see scripts/prepare-host-key.sh) — never commit these. # during install (see scripts/prepare-host-key.sh) — never commit these.
host-keys/ 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 sweeps: after editing one or two hosts/modules, evaluate just the
`nixosConfigurations.<host>` you touched (plus any `config.system.build.tarball` `nixosConfigurations.<host>` you touched (plus any `config.system.build.tarball`
/`diskoImagesScript`/package output affected) rather than looping over every /`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 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 `codex-maintenance.sh` run for changes that plausibly affect every host
(`modules/common/*`, `flake.nix`, `variables.nix`) or as a final check before (`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 CI, which should keep running the full script on every push/PR regardless of
diff size; that's the point of it. 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 ## Architecture
`flake.nix` is the single entry point. It generates one `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-*` `lxc-*`). Also carries `imageSize`/`imageName`, letting every `proxmox-*`
host be built as a standalone, `qm importdisk`-ready `.raw` image with no host be built as a standalone, `qm importdisk`-ready `.raw` image with no
install step — see `docs/proxmox-images.md`. 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/boot/efi.nix` — systemd-boot + EFI vars, paired with the disko module.
- `modules/installer/` — the auto-installer environment (ISO, also served as - `modules/installer/` — the auto-installer environment (ISO, also served as
PXE netboot): `common.nix` (shared config + the generated 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) | | `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) | | `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 Each buildtype's `hosts/<name>/host.nix` carries the per-machine identity
(hostname, hostId, per-machine secrets, `system.stateVersion`) that must stay (hostname, hostId, per-machine secrets, `system.stateVersion`) that must stay
fixed regardless of which platform it's built for — see 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` | | `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` | | `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 | | `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 ## 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 disk image and attached to a new VM with no install step — see
`docs/proxmox-images.md`. `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 ## Security Notes
Do not commit tokens, private keys, live credentials, or new password hashes 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`). `docs/pxe-boot.md`).
- `modules/installer/host-keys.nix` — optionally bakes pre-generated SSH - `modules/installer/host-keys.nix` — optionally bakes pre-generated SSH
host keys into the image; see "Host keys" below. host keys into the image; see "Host keys" below.
- `scripts/prepare-host-key.sh` — admin-workstation pre-flight tool, see - `scripts/sync-host-keys.sh` — admin-workstation tool that generates,
"Creating a New Machine" below. 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: 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 (`modules/common/configuration.nix` puts the root/nixos password hash and the
GitHub token behind sops-nix for all of them). GitHub token behind sops-nix for all of them).
1. **On your admin workstation**, decide the new machine's flake target name 1. **Add the flake target**`hosts/<name>/host.nix` plus the matching
(`<platform>-<buildtype>`, e.g. `proxmox-server`) and generate + register `mkTarget { ... }` entry in `flake.nix`'s `generatedTargets` (see
its host key: "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 ```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 This generates `host-keys/<flake-target>_ssh_host_ed25519_key(.pub)`,
prints the `.sops.yaml` snippet to add, which secrets files to adds it as a new `.sops.yaml` anchor, works out which `secrets/*.yaml`
re-encrypt it into, and the next steps below. 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 Doing this for every host that needs one at once — after adding several
secrets file you added the new host to: 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`
```sh for its other modes (`--remove`, `--regenerate-all-keys`).
nix-shell -p sops --run 'sops updatekeys secrets/common.yaml'
```
3. **Commit and push.** The flake build the installer uses has to see the 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 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 install, so there's nothing host-specific to write; it's available for every
`proxmox-*` target automatically. `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 ## Building
```sh ```sh
@@ -43,9 +49,10 @@ sudo ./result \
--build-memory 2048 --build-memory 2048
``` ```
Generate the key first with `scripts/prepare-host-key.sh <hostname>`, same Generate the key first with `scripts/sync-host-keys.sh <hostname>`, same
as any other host — see `docs/auto-installer.md` for the full sops.yaml as any other host — see `docs/auto-installer.md` for the full walkthrough
registration 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 ## 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 #!/usr/bin/env bash
# Generates a new machine's SSH host key ahead of installing it, so # Generates a new machine's SSH host key by an arbitrary name, before it
# sops-nix has something to derive an age key from before the target # necessarily has a flake target yet -- prints the .sops.yaml snippet to
# ever boots. # 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 # its own /etc/ssh/ssh_host_ed25519_key at *activation* time, but that
# activation runs before systemd would otherwise generate this key on # activation runs before systemd would otherwise generate this key on
# first boot (sshd-keygen is a normal systemd service gated behind # 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 maxSize = "100M"; # rotate once a log file exceeds this size
keep = 20; # number of rotated logs to retain before deleting the oldest 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"
];
} }