Archived
Add sync-host-keys.sh and create-proxmox-resource.sh
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:
+10
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Executable
+432
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
Executable
+437
@@ -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 "$@"
|
||||||
@@ -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"
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user