Compare commits

..
Author SHA1 Message Date
beatzaplenty 5e2ff76cf7 Merge pull request 'refactor(provision): Phase 3 — remove legacy host-keys/ fallback' (#59) from worktree-phase0-provision-ordering-fix into main
Check NixOS configurations / eval-hosts (push) Successful in 10m23s
Merge PR #59: Phase 3 — remove legacy host-keys/ fallback
2026-07-25 11:24:55 +00:00
beatzaplentyandClaude Sonnet 4.6 e8c4122460 refactor(provision): Phase 3 — remove legacy host-keys/ fallback
Check NixOS configurations / eval-hosts (pull_request) Successful in 10m22s
All actively deployed lxc-* hosts now have clan vars. Remove the legacy
scp -pr host-keys/ fallback in sync_remote_host_keys(): instead of
silently copying the gitignored directory, error clearly if no clan var
exists for the target and tell the operator how to generate one.

Also extend the uncommitted-changes check to cover vars/per-machine/ in
addition to .sops.yaml and secrets/, since clan vars must be committed
before the remote build git-pulls them.

Update the script header and sync_remote_host_keys comment to reflect
the new clan-only key flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2EJ4qTsM5KUqhS5c3GAwx
2026-07-25 21:24:32 +10:00
beatzaplenty 5db41b1166 Merge pull request 'chore(vars): clan vars SSH host key for lxc-nix-cache' (#58) from worktree-phase0-provision-ordering-fix into main
Check NixOS configurations / eval-hosts (push) Successful in 10m22s
Merge PR #58: clan vars SSH host key for lxc-nix-cache
2026-07-25 11:18:19 +00:00
beatzaplentyandClaude Sonnet 4.6 ea7794dc05 chore(vars): commit clan vars SSH host key for lxc-nix-cache
Check NixOS configurations / eval-hosts (pull_request) Successful in 10m27s
Encrypted private key matches the running container's key and the
vars.nixCacheHostKey in variables.nix (no rotation). Age fingerprint
age1ufg390... matches the &lxc-nix-cache anchor in .sops.yaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2EJ4qTsM5KUqhS5c3GAwx
2026-07-25 21:10:46 +10:00
beatzaplenty 67752fb1e8 Merge pull request 'fix(lxc): clan vars for lxc-tor-relay + sops-reinstall service fix (network.target)' (#57) from worktree-phase0-provision-ordering-fix into main
Check NixOS configurations / eval-hosts (push) Successful in 10m30s
Merge PR #57: fix(lxc): sops-reinstall to network.target + clan vars for lxc-tor-relay
2026-07-25 09:55:25 +00:00
beatzaplentyandClaude Sonnet 4.6 1a14b1d4d3 fix(lxc): move sops-reinstall service from sysinit to network.target
Check NixOS configurations / eval-hosts (pull_request) Successful in 10m34s
nixos-lxc-sops-reinstall.service called switch-to-configuration test at
sysinit.target time (DefaultDependencies=false), before D-Bus was up.
D-Bus is required to restart systemd targets after activation scripts
run. The service reported failure on every boot (exit 1: "Failed to open
dbus connection") even though secrets were correctly installed, because
the D-Bus call happens after activation scripts complete.

Move the service to network.target so basic.target (which includes
dbus-broker.service) runs first. Also drop DefaultDependencies=false so
systemd auto-adds After=basic.target. Add SuccessExitStatus=11 to handle
the edge case where switch-to-configuration holds the lock during a
concurrent rebuild (exit 11 = "Could not acquire lock" -- the rebuild's
own activation already installed the secrets, so treating it as success
is correct).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2EJ4qTsM5KUqhS5c3GAwx
2026-07-25 19:50:35 +10:00
beatzaplentyandClaude Sonnet 4.6 68aea4cdcc chore(vars): commit clan vars SSH host key for lxc-tor-relay
The key was generated in a prior session but not committed — the clan vars
files existed only in that session's working tree. Recovered the original
private key from pve1's host-keys/ backup (fingerprint age16kqf... matches
the &lxc-tor-relay anchor already in .sops.yaml), re-encrypted for admin
age key only, and stored in the canonical clan vars layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2EJ4qTsM5KUqhS5c3GAwx
2026-07-25 19:29:21 +10:00
beatzaplenty 0853952269 Merge pull request 'feat(provision): Phase 0-2 + fix — clan-core SSH host keys, activation ordering, boot-time sops' (#56) from worktree-phase0-provision-ordering-fix into main
Check NixOS configurations / eval-hosts (push) Successful in 10m33s
Merge feat(provision): Phase 0-2 + fix — clan-core SSH host keys, activation ordering, boot-time sops
2026-07-25 09:16:43 +00:00
beatzaplentyandClaude Sonnet 4.6 055577ee91 fix(lxc): fix activation ordering and add boot-time sops reinstall
Check NixOS configurations / eval-hosts (pull_request) Successful in 10m33s
Two bugs prevented nixos-rebuild switch from working on lxc-* hosts after
first boot, both confirmed live on a deployed lxc-tor-relay container:

1. Ordering bug: preserveSshHostKey had no explicit deps, so the topological
   sort placed it at position 7 — after etc at position 5. By the time it
   tried to save the SSH key, etc had already removed it as "obsolete"
   (absent from the current generation's environment.etc when built without
   NIXOS_HOST_KEYS_DIR). Consolidate all four system.activationScripts entries
   into one block and add etc = { deps = ["preserveSshHostKey"]; } and
   setupSecrets = { deps = ["restoreSshHostKey"]; } to enforce the correct
   save→etc→restore→sops chain.

2. No boot-time secrets: /run/secrets is a tmpfs cleared on every reboot, and
   sops-nix does NOT generate a boot-time service in this configuration
   (confirmed live: no sops-nix.service in systemctl list-unit-files).
   Add nixos-lxc-sops-reinstall.service, modelled after sops-nix's own service
   placement (wantedBy/before sysinit.target, DefaultDependencies=false), so
   secrets are reinstalled before basic.target on every non-first boot.
   ConditionPathExists skips it on first boot; nixos-lxc-first-boot-activate
   handles that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2EJ4qTsM5KUqhS5c3GAwx
2026-07-25 19:07:30 +10:00
beatzaplentyandClaude Sonnet 4.6 a63e1c70c3 feat(provision): Phase 2 — migrate SSH host keys to clan vars
Replaces the gitignored host-keys/ directory with clan vars as the
authoritative storage for SSH host keys. Keys are now generated as
sops-binary-encrypted clan var files (admin-key only) and checked into
vars/per-machine/<target>/openssh/, eliminating the plaintext private
key that previously had to live outside the repo.

Changes:
- modules/clan/ssh-host-key.nix: clan vars generator for the ed25519
  SSH host key pair (neededFor="activation" — not mapped to sops.secrets,
  delivered via tarball baking for LXC or --pre-format-files for VMs)
- flake.nix: add clanCore module + required settings to every mkTarget;
  deduplicate bundled disko/sops-nix via follows; all 27 hosts eval clean
- flake.lock: updated to reflect the new follows constraints
- scripts/lib/clan-vars.sh: new helper library with
  clan_ssh_key_exists / clan_ssh_pubkey_path / clan_decrypt_ssh_key /
  clan_generate_ssh_key for use by the provisioning and sync scripts
- scripts/secrets/sync-host-keys.sh: queue_host_sync() now checks clan
  vars first; generates via clan_generate_ssh_key if no key exists;
  derives age fingerprint from clan pub key for .sops.yaml registration
- scripts/proxmox/create-proxmox-resource.sh: key management simplified
  (sync-host-keys.sh now generates the key if missing, so the inline
  prepare-host-key.sh call is gone); sync_remote_host_keys() decrypts
  the clan key into a temp dir and scps just the two files to the node
  when a clan key exists, falling back to the old host-keys/ scp for
  any remaining legacy entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2EJ4qTsM5KUqhS5c3GAwx
2026-07-25 18:22:33 +10:00
beatzaplentyandClaude Sonnet 4.6 a9745b594b feat(flake): add clan-core 26.05 as a flake input (Phase 1, no behavior change)
Introduces clan-core pinned to its 26.05 release alongside the existing nixpkgs
26.05 input. No host configuration is changed — this is a pure dependency
addition so Phase 2 (per-host vars/secret management migration) has the input
available without a separate flake.lock bump.

clan-core.inputs.nixpkgs.follows = "nixpkgs" keeps a single nixpkgs closure.
sops-nix remains as a flake input; vars layers on top of it rather than
replacing it (clan's sops storage backend still needs sops-nix).

All hosts evaluate cleanly (codex-maintenance.sh --full-check equivalent
triggered by the flake.nix change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 17:29:29 +10:00
beatzaplentyandClaude Sonnet 4.6 78bb784265 fix(provision): block build until sops changes are committed, guard missing host keys
Three ordering-related fixes to the Proxmox provisioning flow:

1. prepare-host-key.sh: make idempotent -- if the key already exists, print
   a note and exit 0 instead of erroring. The caller (create-proxmox-resource.sh)
   already guards standalone calls, but the script itself should be safe to
   run directly on a host that was already keyed.

2. create-proxmox-resource.sh: after sync-host-keys.sh updates .sops.yaml /
   secrets/, detect uncommitted changes and block with a prompt until the
   operator confirms they've committed and pushed. The PVE node's git pull
   only picks up committed+pushed state; without this gate, a new host's sops
   recipient is missing from the secrets files the image build uses, so the
   host can't decrypt secrets on first boot.

3. create-proxmox-resource.sh: add an explicit existence check for the host
   key in both the LXC and VM remote build heredocs, before it's passed as
   --pre-format-files / NIXOS_HOST_KEYS_DIR input. Gives a clear error
   pointing at sync-host-keys.sh instead of a raw `cp: cannot stat` from
   disko deep in the build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 17:14:25 +10:00
beatzaplentyandClaude Sonnet 4.6 784e064fa2 fix(create-proxmox-resource): use absolute path for --pre-format-files
Check NixOS configurations / eval-hosts (push) Successful in 10m20s
The disko images script does `cd "$TMPDIR"` before parsing its arguments,
so relative paths passed to --pre-format-files resolve against the temp
dir instead of the repo root. Use $(pwd) to capture the absolute repo
path before the disko script changes directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 16:45:04 +10:00
beatzaplenty 4a5afa6c1c Merge pull request 'fix(tailscale): rename tailscale-subnet-router to tailscale-router everywhere' (#55) from worktree-fix-tailscale-host-dir into main
Check NixOS configurations / eval-hosts (push) Successful in 10m33s
fix(tailscale): rename tailscale-subnet-router to tailscale-router everywhere
2026-07-25 06:37:18 +00:00
beatzaplentyandClaude Sonnet 4.6 9a0aea8f89 fix(create-proxmox-resource): auto-generate missing host key before sync
Check NixOS configurations / eval-hosts (push) Successful in 10m19s
If host-keys/<target>_ssh_host_ed25519_key doesn't exist, run
prepare-host-key.sh to generate it before sync-host-keys.sh runs.
Prevents sync-host-keys.sh from hitting its SKIP/exit-1 path (anchor
in .sops.yaml but no local key) and the downstream disko build failure
when --pre-format-files can't find the key file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 16:26:49 +10:00
beatzaplenty b013e28dcd Merge pull request 'fix(tailscale): rename hosts/tailscale-subnet-router → hosts/tailscale-router' (#54) from worktree-fix-tailscale-host-dir into main
Check NixOS configurations / eval-hosts (push) Successful in 10m32s
Reviewed-on: #54
2026-07-25 06:24:59 +00:00
12 changed files with 512 additions and 50 deletions

No files matched your search

Generated
+151 -1
View File
@@ -1,5 +1,62 @@
{
"nodes": {
"clan-core": {
"inputs": {
"data-mesher": "data-mesher",
"disko": [
"disko"
],
"flake-parts": "flake-parts",
"nix-darwin": "nix-darwin",
"nix-select": "nix-select",
"nixpkgs": [
"nixpkgs"
],
"sops-nix": [
"sops-nix"
],
"systems": "systems",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1783497933,
"narHash": "sha256-TxmwEews6URFPqOWEHNychtXbFDgLZjbOfEXtvtOm6U=",
"rev": "3dc0221ca09033599fe98055e9bbc81bdf32732a",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/clan-core/archive/3dc0221ca09033599fe98055e9bbc81bdf32732a.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://git.clan.lol/clan/clan-core/archive/26.05.tar.gz"
}
},
"data-mesher": {
"inputs": {
"flake-parts": [
"clan-core",
"flake-parts"
],
"nixpkgs": [
"clan-core",
"nixpkgs"
],
"treefmt-nix": [
"clan-core",
"treefmt-nix"
]
},
"locked": {
"lastModified": 1778718524,
"narHash": "sha256-pXLoI6Ax0EnUK6r34UM1vibVC7CfTu6j72R2692ZzPs=",
"rev": "12c552ad547d87254f33f33bddd1a2cdbeac754d",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/12c552ad547d87254f33f33bddd1a2cdbeac754d.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://git.clan.lol/clan/data-mesher/archive/main.tar.gz"
}
},
"disko": {
"inputs": {
"nixpkgs": [
@@ -51,9 +108,30 @@
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"clan-core",
"nixpkgs"
]
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
"systems": "systems_2"
},
"locked": {
"lastModified": 1694529238,
@@ -109,6 +187,40 @@
"type": "github"
}
},
"nix-darwin": {
"inputs": {
"nixpkgs": [
"clan-core",
"nixpkgs"
]
},
"locked": {
"lastModified": 1779036909,
"narHash": "sha256-zXcwYQGCT6pzinK+1dBB2ekTVtfxGZAapb3Evdcu4fY=",
"owner": "nix-darwin",
"repo": "nix-darwin",
"rev": "56c666e108467d87d13508936aade6d567f2a501",
"type": "github"
},
"original": {
"owner": "nix-darwin",
"repo": "nix-darwin",
"type": "github"
}
},
"nix-select": {
"locked": {
"lastModified": 1763303120,
"narHash": "sha256-yxcNOha7Cfv2nhVpz9ZXSNKk0R7wt4AiBklJ8D24rVg=",
"rev": "3d1e3860bef36857a01a2ddecba7cdb0a14c35a9",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/nix-select/archive/3d1e3860bef36857a01a2ddecba7cdb0a14c35a9.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://git.clan.lol/clan/nix-select/archive/main.tar.gz"
}
},
"nixos-conf-editor": {
"inputs": {
"flake-compat": "flake-compat",
@@ -163,6 +275,7 @@
},
"root": {
"inputs": {
"clan-core": "clan-core",
"disko": "disko",
"home-manager": "home-manager",
"nixos-conf-editor": "nixos-conf-editor",
@@ -214,6 +327,22 @@
}
},
"systems": {
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"owner": "nix-systems",
"repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"type": "github"
},
"original": {
"owner": "nix-systems",
"ref": "future-26.11",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
@@ -227,6 +356,27 @@
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"clan-core",
"nixpkgs"
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
+29
View File
@@ -16,6 +16,19 @@
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
clan-core = {
url = "https://git.clan.lol/clan/clan-core/archive/26.05.tar.gz";
# Deduplicate modules: clan-core bundles its own disko and sops-nix
# (both imported by nixosModules.clanCore). Without follows, we'd get
# two different versions of each, and disko's _module.args.diskoLib
# unique option would conflict. With follows, clan-core uses the same
# store paths as us, so NixOS deduplicates the imports.
inputs = {
nixpkgs.follows = "nixpkgs";
disko.follows = "disko";
sops-nix.follows = "sops-nix";
};
};
};
outputs = { self, nixpkgs, nixos-conf-editor, home-manager, sops-nix, ... } @ inputs:
@@ -41,6 +54,22 @@
modules = [
inputs.disko.nixosModules.disko
sops-nix.nixosModules.sops
inputs.clan-core.nixosModules.clanCore
{
# Required clan settings. directory is the flake root (where
# vars/ and sops/ directories live); machine.name is the flake
# target name (matches what clan vars generate uses as the key
# under vars/per-machine/). enableRecommendedDefaults = false
# is mandatory: without it, clan unconditionally enables
# networking.useNetworkd, adds packages, and tweaks nix settings
# -- none of which belong here.
clan.core = {
settings.directory = self;
settings.machine.name = flakeTarget;
enableRecommendedDefaults = false;
};
}
./modules/clan/ssh-host-key.nix
./modules/common/configuration.nix
./modules/platforms/${platform}.nix
./modules/build-types/${buildType}.nix
+30
View File
@@ -0,0 +1,30 @@
{ pkgs, ... }: {
# Defines the SSH host key as a clan vars generator so that:
# - `clan vars generate <target>` creates and encrypts the key pair
# - The private key lives at vars/per-machine/<target>/openssh/ssh_host_ed25519_key/secret
# (sops binary-encrypted, admin-key-only; decrypted by the build script)
# - The public key lives at vars/per-machine/<target>/openssh/ssh_host_ed25519_key.pub/value
# (plaintext; used by sync-host-keys.sh to derive the sops age fingerprint)
#
# neededFor = "activation" means clan's deployment tool would upload this
# before running nixos-rebuild/nixos-install (for VM/baremetal via
# nixos-anywhere). For lxc-* hosts, the build script bakes it into the
# tarball directly via NIXOS_HOST_KEYS_DIR -- the neededFor value here
# simply ensures it is NOT mapped to sops.secrets (which would try to
# decrypt it at runtime as a regular service secret, which is wrong: the
# SSH host key reaches the container via the tarball, not sops).
clan.core.vars.generators.openssh = {
files."ssh_host_ed25519_key" = {
secret = true;
neededFor = "activation";
};
files."ssh_host_ed25519_key.pub" = {
secret = false;
neededFor = "activation";
};
runtimeInputs = [ pkgs.openssh ];
script = ''
ssh-keygen -t ed25519 -N "" -C "" -f "$out/ssh_host_ed25519_key"
'';
};
}
+74 -25
View File
@@ -114,30 +114,39 @@ in
# /etc/ssh/ssh_host_ed25519_key; deletion cascades into every sops secret
# failing with "Error getting data key: 0 successful groups required, got 0".
#
# Fix: two activation scripts that bracket the etc step.
# preserveSshHostKey — no deps, runs before etc — saves the live key to
# /run (tmpfs) before etc can delete it.
# restoreSshHostKey — deps=[etc], runs after etc — reinstalls the key via
# `install` (atomic, sets mode) if etc removed it.
# The resulting file is not registered in environment.etc
# for either the previous or current generation, so
# subsequent rebuilds leave it alone permanently.
system.activationScripts.preserveSshHostKey = ''
if [ -f /etc/ssh/ssh_host_ed25519_key ]; then
cp /etc/ssh/ssh_host_ed25519_key /run/sshd-host-key-preserve.tmp
cp /etc/ssh/ssh_host_ed25519_key.pub /run/sshd-host-key-preserve.pub.tmp
fi
'';
system.activationScripts.restoreSshHostKey = {
deps = [ "etc" ];
text = ''
if [ ! -f /etc/ssh/ssh_host_ed25519_key ] && [ -f /run/sshd-host-key-preserve.tmp ]; then
install -m 0600 /run/sshd-host-key-preserve.tmp /etc/ssh/ssh_host_ed25519_key
install -m 0644 /run/sshd-host-key-preserve.pub.tmp /etc/ssh/ssh_host_ed25519_key.pub
# Fix: activation scripts that bracket the etc step, with explicit deps
# to enforce the correct ordering. Without deps the topological sort places
# preserveSshHostKey AFTER etc (confirmed live on a deployed lxc-tor-relay:
# position 7 vs etc's position 5) -- the key is already gone by the time it
# tries to save it. The etc/setupSecrets entries ADD to existing deps
# (types.listOf concatenates across module definitions).
system.activationScripts = {
# Saves the live key to /run before etc can delete it.
preserveSshHostKey = ''
if [ -f /etc/ssh/ssh_host_ed25519_key ]; then
cp /etc/ssh/ssh_host_ed25519_key /run/sshd-host-key-preserve.tmp
cp /etc/ssh/ssh_host_ed25519_key.pub /run/sshd-host-key-preserve.pub.tmp
fi
rm -f /run/sshd-host-key-preserve.tmp /run/sshd-host-key-preserve.pub.tmp
'';
# Reinstalls the key after etc runs if it was removed as "obsolete".
# The resulting file is not registered in environment.etc for either
# generation, so subsequent rebuilds leave it alone permanently.
restoreSshHostKey = {
deps = [ "etc" ];
text = ''
if [ ! -f /etc/ssh/ssh_host_ed25519_key ] && [ -f /run/sshd-host-key-preserve.tmp ]; then
install -m 0600 /run/sshd-host-key-preserve.tmp /etc/ssh/ssh_host_ed25519_key
install -m 0644 /run/sshd-host-key-preserve.pub.tmp /etc/ssh/ssh_host_ed25519_key.pub
fi
rm -f /run/sshd-host-key-preserve.tmp /run/sshd-host-key-preserve.pub.tmp
'';
};
# Force etc to wait until the key is saved, and sops to wait until the
# key is restored. Without these the topological sort breaks the chain.
etc = { deps = [ "preserveSshHostKey" ]; };
setupSecrets = { deps = [ "restoreSshHostKey" ]; };
};
# virtualisation/proxmox-lxc.nix (imported above) registers the Nix
@@ -146,9 +155,12 @@ in
# sops-nix's "for users" secrets (password hashes -- installed by the
# activation script itself, not a systemd service, since they need to
# exist *before* user creation) nor the user-creation step that
# consumes them ever run on a real lxc-* boot. Regular secrets
# (nix-serve's key, beszel's token, etc.) work anyway because sops-nix
# provides its own systemd service for those.
# consumes them ever run on a real lxc-* boot. In this config sops-nix
# does NOT generate its own boot-time service (confirmed live: no
# sops-nix.service in systemctl list-unit-files on a deployed
# lxc-tor-relay container); /run/secrets is a tmpfs cleared on every
# reboot, so secrets must be reinstalled on each non-first boot by
# nixos-lxc-sops-reinstall (below).
#
# A systemd service, not boot.postBootCommands: tried that first (it's
# a genuine, generally-invoked hook -- nixos/modules/system/boot/stage-2-init.sh,
@@ -199,4 +211,41 @@ in
touch /var/lib/nixos-lxc-first-boot-activated
'';
};
# Reinstalls sops secrets on every non-first boot. /run/secrets is a
# tmpfs that is cleared on each reboot; without this service, secrets
# are permanently absent after the first boot and every service that
# reads from /run/secrets fails on start.
#
# wantedBy/before network.target: switch-to-configuration test requires
# D-Bus to restart systemd targets after running activation scripts. D-Bus
# is available once basic.target completes (the default After=basic.target
# that DefaultDependencies would otherwise add). Placing the service before
# network.target ensures secrets are ready before any network-dependent
# service (including beszel-agent and nix-serve) starts, while running late
# enough that D-Bus is already up.
#
# ConditionPathExists=... skips this service on the genuine first boot
# (the marker doesn't exist yet); nixos-lxc-first-boot-activate handles
# that case. On every subsequent boot the condition passes and secrets
# are reinstalled before user services start.
#
# SuccessExitStatus=11: switch-to-configuration exits 11 when it cannot
# acquire the activation lock (another switch is already in progress).
# During a nixos-rebuild switch the activation already installs secrets, so
# treating the lock-held case as success is correct.
systemd.services.nixos-lxc-sops-reinstall = {
description = "Reinstall sops secrets on each non-first boot (LXC, /run is tmpfs)";
wantedBy = [ "network.target" ];
before = [ "network.target" ];
unitConfig.ConditionPathExists = "/var/lib/nixos-lxc-first-boot-activated";
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
SuccessExitStatus = "11";
};
script = ''
/run/current-system/bin/switch-to-configuration test
'';
};
}
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Clan vars helpers: manage SSH host keys stored as clan vars (sops-encrypted
# binary files under vars/per-machine/<target>/openssh/) instead of the
# gitignored host-keys/ directory.
#
# Layout (per clan's convention):
# vars/per-machine/<target>/openssh/ssh_host_ed25519_key/secret -- sops binary (admin-encrypted)
# vars/per-machine/<target>/openssh/ssh_host_ed25519_key.pub/value -- plaintext SSH pubkey
#
# Sourced by create-proxmox-resource.sh and sync-host-keys.sh.
# Depends on sops-age.sh and ssh-host-keys.sh being sourced first (for
# sops_yaml_admin_pubkey, ssh_pubkey_to_age, and NIX_OPTS).
if ! declare -p NIX_OPTS >/dev/null 2>&1; then
declare -a NIX_OPTS=()
fi
# clan_ssh_key_exists <target> <repo_root>
# Returns 0 if clan vars hold a SSH host key for <target>, 1 otherwise.
clan_ssh_key_exists() {
local target="$1" repo_root="$2"
[[ -f "${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key/secret" ]]
}
# clan_ssh_pubkey_path <target> <repo_root>
# Prints the path to the plaintext SSH public key value file.
clan_ssh_pubkey_path() {
local target="$1" repo_root="$2"
echo "${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key.pub/value"
}
# clan_decrypt_ssh_key <target> <repo_root> <dest_dir>
# Decrypts the sops-encrypted SSH host private key for <target> into <dest_dir>,
# naming it <target>_ssh_host_ed25519_key (to match NIXOS_HOST_KEYS_DIR
# conventions that lxc.nix and the disko build already expect). Also copies
# the plaintext public key. The caller is responsible for protecting and
# cleaning up <dest_dir>.
clan_decrypt_ssh_key() {
local target="$1" repo_root="$2" dest_dir="$3"
local secret="${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key/secret"
local pubval="${repo_root}/vars/per-machine/${target}/openssh/ssh_host_ed25519_key.pub/value"
local dest_priv="${dest_dir}/${target}_ssh_host_ed25519_key"
local dest_pub="${dest_dir}/${target}_ssh_host_ed25519_key.pub"
nix-shell "${NIX_OPTS[@]}" -p sops --run \
"sops -d --output-type binary '${secret}'" > "$dest_priv"
chmod 0600 "$dest_priv"
cp "$pubval" "$dest_pub"
}
# clan_generate_ssh_key <target> <repo_root>
# Generates a new SSH host key pair and stores it in clan vars format:
# - private key: sops binary-encrypted for the admin age key
# - public key: plaintext value file
# Idempotent: if the secret already exists, prints a note and returns 0.
# Requires sops_yaml_admin_pubkey (from sops-age.sh) to be available.
clan_generate_ssh_key() {
local target="$1" repo_root="$2"
local var_base="${repo_root}/vars/per-machine/${target}/openssh"
local secret_dir="${var_base}/ssh_host_ed25519_key"
local pubval_dir="${var_base}/ssh_host_ed25519_key.pub"
if [[ -f "${secret_dir}/secret" ]]; then
echo "Clan SSH host key for ${target} already exists -- skipping generation."
return 0
fi
# Resolve admin age public key from .sops.yaml
local admin_pubkey
admin_pubkey="$(sops_yaml_admin_pubkey "${repo_root}/.sops.yaml")"
if [[ -z "$admin_pubkey" ]]; then
echo "ERROR: Could not find &admin age key in ${repo_root}/.sops.yaml" >&2
return 1
fi
# Generate the SSH key pair in a secure temp directory
local tmpdir
tmpdir="$(mktemp -d)"
local priv_tmp="${tmpdir}/ssh_host_ed25519_key"
# shellcheck disable=SC2064
trap "rm -rf '${tmpdir}'" RETURN
nix-shell "${NIX_OPTS[@]}" -p openssh --run \
"ssh-keygen -t ed25519 -N '' -C '${target}' -f '${priv_tmp}'" >/dev/null
# Create a minimal sops config that uses only the admin age key -- this
# prevents sops from merging in ALL recipients from .sops.yaml (which
# would unnecessarily encrypt for every host's key, not just admin).
local sops_cfg="${tmpdir}/sops-config.json"
printf '{"creation_rules":[{"key_groups":[{"age":["%s"]}]}]}\n' \
"$admin_pubkey" > "$sops_cfg"
# Encrypt the private key in sops binary format (admin-only recipient)
mkdir -p "$secret_dir" "$pubval_dir"
nix-shell "${NIX_OPTS[@]}" -p sops --run \
"sops -e --config '${sops_cfg}' --input-type binary '${priv_tmp}'" \
> "${secret_dir}/secret"
# Store the public key as a plaintext value file
cp "${priv_tmp}.pub" "${pubval_dir}/value"
echo "Generated and stored clan SSH host key for ${target}."
echo " Private key: ${secret_dir}/secret (sops binary, admin-key encrypted)"
echo " Public key: ${pubval_dir}/value"
}
+72 -14
View File
@@ -8,9 +8,12 @@
# script -- there's no multi-gigabyte image to transfer afterward. The first
# time a node doesn't have that repo path yet, it's bootstrapped: cloned from
# this checkout's own `origin` remote, then scripts/codex-setup.sh installs
# the build tooling (Nix, etc.). Every run after that just `git pull`s it and
# copies over the locally-managed host-keys/ (gitignored, so a git pull
# alone wouldn't carry it) before building.
# the build tooling (Nix, etc.). Every run after that just `git pull`s it.
# SSH host keys are stored as clan vars (vars/per-machine/<target>/openssh/,
# committed and sops-encrypted) -- the script decrypts them locally and
# copies only the two files for this target to the node's host-keys/ before
# building. A target with no clan var is an error (generate one first with
# scripts/secrets/sync-host-keys.sh <target>).
#
# --node (default: $PROXMOX_HOST, see scripts/env.sh) picks which of the two
# LAN Proxmox nodes this runs against: production, pve1.sweet.home
@@ -59,6 +62,10 @@ source "${repo_root}/scripts/env.sh"
source "${repo_root}/scripts/lib/nix-eval.sh"
# shellcheck source=../lib/confirm.sh
source "${repo_root}/scripts/lib/confirm.sh"
# shellcheck source=../lib/sops-age.sh
source "${repo_root}/scripts/lib/sops-age.sh"
# shellcheck source=../lib/clan-vars.sh
source "${repo_root}/scripts/lib/clan-vars.sh"
sync_keys="${repo_root}/scripts/secrets/sync-host-keys.sh"
@@ -485,12 +492,37 @@ echo "Target: ${flake_target} (host=${host}, type=${type}) -> Proxmox resource '
nix_extra_opts
# --- make sure this target has a registered host key --------------------
# sync-host-keys.sh is idempotent and generates the key (via clan vars) if
# no key exists yet -- the old inline prepare-host-key.sh call is gone.
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[@]}"
# If sync-host-keys.sh changed .sops.yaml, secrets/, or vars/per-machine/,
# those changes must be committed and pushed before the remote `git pull`
# below picks them up -- the PVE node builds from whatever HEAD is checked
# out there, not the local working tree. Uncommitted clan vars or sops
# recipients mean the image builds fine but the host cannot decrypt its
# secrets on first boot. Block until the operator confirms they've pushed.
if [[ "$dry_run" -eq 0 ]]; then
_dirty="$(git -C "$repo_root" status --porcelain -- .sops.yaml secrets/ vars/per-machine/ 2>/dev/null || true)"
if [[ -n "$_dirty" ]]; then
echo
echo "==> COMMIT + PUSH REQUIRED before the remote build can succeed:"
echo " Uncommitted changes in .sops.yaml, secrets/, or vars/per-machine/."
echo " The PVE node builds from the git-tracked flake, so these changes"
echo " must be committed and pushed first -- otherwise the image build will"
echo " succeed but the host cannot decrypt its secrets on first boot."
echo
git -C "$repo_root" status --short -- .sops.yaml secrets/ vars/per-machine/ || true
echo
read -rp " Commit and push those changes, then press Enter to continue (Ctrl-C to abort): "
fi
unset _dirty
fi
# --- VMID: pick one, and refuse to touch anything that already exists ---
echo
if [[ -z "$vmid" ]]; then
@@ -613,21 +645,35 @@ ensure_remote_repo() {
fi
}
# --- sync locally-managed host-keys/ to the node ---------------------------
# Gitignored (see .gitignore), so `git pull` above never carries it -- both
# build paths need it present as NIXOS_HOST_KEYS_DIR / --pre-format-files
# input on the node itself now that the build runs there. scp (not rsync,
# not already a dependency anywhere else in this repo) mirrors how this
# script already transfers the --image case below.
# --- sync host key to the node ---------------------------------------------
# SSH host keys are stored as clan vars (vars/per-machine/<target>/openssh/).
# Decrypt locally and scp just the two files for this target to the node's
# host-keys/ directory, where the remote build script picks them up via
# NIXOS_HOST_KEYS_DIR (LXC) or --pre-format-files (VM). A target with no
# clan var is an error -- generate one first with sync-host-keys.sh.
sync_remote_host_keys() {
echo
echo "==> Syncing host-keys/ to ${node}..."
echo "==> Syncing host key for ${flake_target} to ${node}..."
if [[ "$dry_run" -eq 1 ]]; then
echo "[dry-run] would copy ${repo_root}/host-keys/ to ${ssh_target}:${remote_repo_dir}/host-keys/"
echo "[dry-run] would decrypt clan SSH key for ${flake_target} and copy to ${ssh_target}:${remote_repo_dir}/host-keys/"
return
fi
if ! clan_ssh_key_exists "$flake_target" "$repo_root"; then
echo "ERROR: no clan SSH key found for ${flake_target}" >&2
echo " (expected: ${repo_root}/vars/per-machine/${flake_target}/openssh/ssh_host_ed25519_key/secret)" >&2
echo " Generate one first: bash scripts/secrets/sync-host-keys.sh ${flake_target}" >&2
exit 1
fi
local tmpdir
tmpdir="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '${tmpdir}'" RETURN
echo " Decrypting clan SSH key for ${flake_target}..."
clan_decrypt_ssh_key "$flake_target" "$repo_root" "$tmpdir"
ssh "$ssh_target" "mkdir -p '${remote_repo_dir}/host-keys'"
scp -pr "${repo_root}/host-keys/." "${ssh_target}:${remote_repo_dir}/host-keys/"
scp -p "${tmpdir}/${flake_target}_ssh_host_ed25519_key" \
"${tmpdir}/${flake_target}_ssh_host_ed25519_key.pub" \
"${ssh_target}:${remote_repo_dir}/host-keys/"
}
# --- build (or reuse an image already on the node) ------------------------
@@ -696,6 +742,12 @@ cd "$repo_dir"
# right after a successful install.
. scripts/lib/nix-bootstrap.sh
ensure_nix_profile
if [[ ! -f "host-keys/${target}_ssh_host_ed25519_key" ]]; then
echo "ERROR: host-keys/${target}_ssh_host_ed25519_key not found in ${repo_dir}." >&2
echo "Generate the key locally (scripts/secrets/sync-host-keys.sh ${target})" >&2
echo "and ensure it was synced here before starting the build." >&2
exit 1
fi
NIXOS_HOST_KEYS_DIR="$(pwd)/host-keys" nix build --impure \
--no-use-registries --no-accept-flake-config "${NIX_OPTS[@]}" \
".#nixosConfigurations.${target}.config.system.build.tarball" \
@@ -736,12 +788,18 @@ declare -a NIX_OPTS=()
cd "$repo_dir"
. scripts/lib/nix-bootstrap.sh
ensure_nix_profile
if [[ ! -f "host-keys/${target}_ssh_host_ed25519_key" ]]; then
echo "ERROR: host-keys/${target}_ssh_host_ed25519_key not found in ${repo_dir}." >&2
echo "Generate the key locally (scripts/secrets/sync-host-keys.sh ${target})" >&2
echo "and ensure it was synced here before starting the build." >&2
exit 1
fi
nix build --no-use-registries --no-accept-flake-config "${NIX_OPTS[@]}" \
".#nixosConfigurations.${target}.config.system.build.diskoImagesScript" \
--out-link "result-${target}"
$sudo_pfx "./result-${target}" \
--pre-format-files "host-keys/${target}_ssh_host_ed25519_key" /etc/ssh/ssh_host_ed25519_key \
--pre-format-files "host-keys/${target}_ssh_host_ed25519_key.pub" /etc/ssh/ssh_host_ed25519_key.pub \
--pre-format-files "$(pwd)/host-keys/${target}_ssh_host_ed25519_key" /etc/ssh/ssh_host_ed25519_key \
--pre-format-files "$(pwd)/host-keys/${target}_ssh_host_ed25519_key.pub" /etc/ssh/ssh_host_ed25519_key.pub \
--build-memory 2048
built="$(find . -maxdepth 1 -name '*.raw' -newer "result-${target}" | head -1)"
if [[ -z "$built" ]]; then
+3 -2
View File
@@ -41,8 +41,9 @@ mkdir -p "$keydir"
keyfile="${keydir}/${hostname}_ssh_host_ed25519_key"
if [[ -f "$keyfile" ]]; then
echo "ERROR: $keyfile already exists. Remove it first if you want to regenerate." >&2
exit 1
echo "Key already exists: ${keyfile}"
echo "Reusing the existing key. Remove it first if you want to regenerate."
exit 0
fi
nix_extra_opts
+17 -8
View File
@@ -39,6 +39,8 @@ source "${repo_root}/scripts/lib/ssh-host-keys.sh"
source "${repo_root}/scripts/lib/sops-age.sh"
# shellcheck source=../lib/confirm.sh
source "${repo_root}/scripts/lib/confirm.sh"
# shellcheck source=../lib/clan-vars.sh
source "${repo_root}/scripts/lib/clan-vars.sh"
mkdir -p "$keydir"
@@ -146,13 +148,15 @@ 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
local has_local_key=0 has_clan_key=0 has_anchor=0
[[ -f "$keyfile" ]] && has_local_key=1
clan_ssh_key_exists "$host" "$repo_root" && has_clan_key=1
grep -qE "^ - &${host} age1" "$sops_yaml" && has_anchor=1
if [[ "$has_local_key" -eq 0 && "$has_anchor" -eq 1 ]]; then
if [[ "$has_local_key" -eq 0 && "$has_clan_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 " neither host-keys/${host}_ssh_host_ed25519_key nor"
echo " vars/per-machine/${host}/openssh/ exist 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"
@@ -160,21 +164,26 @@ queue_host_sync() {
return 1
fi
if [[ "$has_local_key" -eq 0 ]]; then
if [[ "$has_local_key" -eq 0 && "$has_clan_key" -eq 0 ]]; then
if [[ "$dry_run" -eq 1 ]]; then
echo "[dry-run] ${host}: would generate host key"
echo "[dry-run] ${host}: would generate host key via clan vars"
else
echo "==> ${host}: generating host key"
generate_host_ed25519_key "$host" "$keyfile"
echo "==> ${host}: generating host key via clan vars"
clan_generate_ssh_key "$host" "$repo_root"
has_clan_key=1
fi
elif [[ "$has_clan_key" -eq 1 ]]; then
echo "==> ${host}: clan-managed SSH host key already present"
else
echo "==> ${host}: host key already present"
echo "==> ${host}: host key already present (host-keys/)"
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"
elif [[ "$has_clan_key" -eq 1 ]]; then
age_pub="$(ssh_pubkey_to_age "$(clan_ssh_pubkey_path "$host" "$repo_root")")"
else
age_pub="$(ssh_pubkey_to_age "${keyfile}.pub")"
fi
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICuHUxGNH6ei3BZD+EfZs3l4X8uJNcjQiOsM/G4yo4O/ lxc-nix-cache
@@ -0,0 +1,14 @@
{
"data": "ENC[AES256_GCM,data:nm1TQswDfqLgzwOZTpllM96/nqW1xyOoUmbmxL3kEbygnU9kKruuijhoLMSigti8Cgb4onyI3OFkOaNnBShGaXsrFHwIH4rwd4CRv4mHLFTqs/hTXxMxo98gKdhJYuCLXOOdhWrOfOO0GofmxLEX12AUhjS/cxtJWXqINIbmOzPm0rHVVx56r6kj1MojGssV3y68gbioSF1StGG+OIQIXL1LRBWknyTJ+NK3aViXn7oBglHzL9D74Lz5v8iFQQroZ4sBPi+ov0CKAFaghRxl7T7jGA9pO354ZbLdiGmpqtYFazCGq21hwjpWyobAog/Iidngm9Mz0wleB1pEpkCaEpUsjVYQkAoknITasCGhwpmFH0F9v8Du87rJ4jdWT3W8qh3uPlCHw7e5QwQYE/I9v1HU6Au+liQJWeAbbwt9wRC5NMETCqH1PDDpRjt19Le2ECXjstBRR4b9wQQkeDefgcFibQK156/i2twhAqIgz+RqIZ05aQYfy8Kfj3c7G34PEdOdz1P3bsAgISVpgMQO,iv:Cth8/Wlf7vJo050aDNt1XyFUDiihyuMxv6WH3yEZGXM=,tag:qM1/MVU3J2Kso8w0fzEvIQ==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqWFF4ekdNRG05bysxMGRz\nbkt0QmRKT2hsUzZWVXRxY0ZjenRXMXhrT1JNClE4WlRmU0grRG5BSjM5NWZwc1o0\nSFJ3dGRsR0pwYlJhRkV4MjJqNVFWL2MKLS0tIFBSVTU5aFVZNm5qZGtIU05XbDd1\neTd5Y1puYjBkKzlVRDVvaW9pL1B0R28KZbutmxm33H8qOE0jSDr+mrx6nFESmKLg\nTfjZBp1W0SDRVJfUbRQJvcp1ECMJIS5cWzXDoXmLnaeLFzawBPbIeQ==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
}
],
"lastmodified": "2026-07-25T11:10:21Z",
"mac": "ENC[AES256_GCM,data:IQ/XcH2boEt97exSgTUxuZwH+IwFHA0ota3Y15T9DP3tpN/Tvp4zWOqHYtiPfRf/Ea8Si/LDHwlVnuLIhFYtrMmTzJjKf8Xa+eN2bACzn1BAGKKkY1uQH2tIJ1HIxC08x0L7YhkGPVNMFcu2FsNB0bk57+iwUb/w5p4m5O4AZ6Q=,iv:/BkRQEGvUImCewxsfrpV+EYZVnfRlIChASpBXBAtppA=,tag:DAkApHsRza9DXOnKhhVNNA==,type:str]",
"version": "3.13.2"
}
}
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOAzES7wlgL7gIyxOHbmwhzI8SJ5uMSEd26u+ek6knVh lxc-tor-relay
@@ -0,0 +1,14 @@
{
"data": "ENC[AES256_GCM,data:Gx0ZxQ4OZUKUDFEPYjOEgTERHOFDYO+O36qA6h7+wp6G3ld4hTs4NhODdeCpz3xq7/EZ09Jej/ZZriIhiTUp7IjDIjR+hOsIWW7XQNUqBDfq/nO2Fz6nOd7ph1fgQfaSUvmUBo5qr68VmDs6ArnkDJ5TFNTwqypC9Ou/HXvKEvqfG+XjL8FT05ZG3iTqH68O73DlBhePhVuWby/8txyCL+fz9pXaqyFIGxmsMDE3d0Itveyi/bReOnf4g6UvCjnrA1nMpTB3rQLzVF0TvxfTv9XfaeV3On6eu8M+qUk9fqv0ytKS6+IcozuCax5kLMIGz7Y4kbw+yolgotWaM33Wu+e+UAZMHSksWAJAI/zqxHwRcyY5uEH8DJdqz6EtA06Yssup89iqMA4Nz88l+LpZ+1BPeY6PlhbubP+X1x34mbDixO8EguuUG908vd2jKi8f7GtYzDXm3ECN1iodiAXVJZ3TEGfYPfhvH6p9kSkwS9Jug1GVP5pDaPPkwvtsLjq2fV0HB/4qJQAkVJmwRz2L,iv:3sN+t1HvduJjlj+l0Jp9jqWIh0q1bwz4WiPPDLXyNzg=,tag:Qk3P3xoHoOhYQL3xzmJpaw==,type:str]",
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBETjR1YzVoaUtGTlo4QnUv\nbVE4ZXZmMmJ4ZzFhcXlaVWNEWHAxVW4xSm44CmlOaEVxSGpKelZwTEFCcitvcThE\na1B5bVdDUk56L2ZMVDJNVkpJalZ3eTgKLS0tIGU5NUM0bDVyaVc0WGVrbFF1VzFx\nT0p3a1Z0czd4bTRzUVRURmh1MzhUelUKaaL1TewLGICnl/gdAFQHHBM/ik2qUjAy\n2dNemNJrMuX7Rd9DCZPeFIqNHRVX1sIhJo3/dC1UJc235l1NLcSsSA==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
}
],
"lastmodified": "2026-07-25T09:28:42Z",
"mac": "ENC[AES256_GCM,data:h/MkejHqwuJbc63HyJs8ki1aTt7bx7HVhsQCI45hHjN/WeEJxiZVcmHEXsL/dwPg/Q7H8OImC2sl6Gt7FtGwfN9O6JwYwl6BxBBoD+lZscgrPrNqp1I9u3KhgyOF7ziQhiWH2Lk3/OVa9kuFs3xYjcKxVIA+vW6VB54QkLNBcPM=,iv:BjtKnZ/B7RvWq1MBzWzudPGvntXnk9kYpzULTpK62EU=,tag:f71WOUHcjbcHHC7KIjHqrA==,type:str]",
"version": "3.13.2"
}
}