Render each machine's SSH client config from its own identity record #81

Merged
vnprc merged 2 commits from agent/ssh-hosts-by-identity into master 2026-09-20 04:23:09 +01:00
Member

Every dev VM's SSH client config becomes one host block, the forge, keyed to that VM's own key; the hypervisor's SSH address book (identity.sshHosts) no longer reaches dev VMs at all. The hypervisor's own config is unchanged: byte-identical on the public template, and identical in effect for an entry that carries extraOptions (only the line order inside that block moves). The Home Manager 26.05 matchBlocks deprecation warnings this framework emitted stop. No sshHosts data changes and the nexus rotation scripts read the same fields. A new flake check resolves fixture configs with a real ssh -G, and against the deploy template the hypervisor renders identically while allod-dev keeps its forge block byte-for-byte and loses the five entries keyed to a key it does not hold. If it is wrong, revert this PR; nothing else depends on the two new modules. Landing order for a deploy lock: take this before or together with the profile deletions (allod/profiles#10), never after them, because a dev VM whose profile copy is already gone while this change is absent gets the address book's forge block with the hypervisor's key and cannot reach the forge.

What changes

  • modules/home-shared.nix keeps username, home directory, git and programs.ssh.enable; it no longer maps identity.sshHosts into programs.ssh.matchBlocks and no longer special-cases the forge host.
  • modules/host-ssh-hosts.nix (new, imported by mkHypervisor only) translates each sshHosts entry to programs.ssh.settings: hostname, user, port, identityFile, identitiesOnly to their directive names; localForwards to LocalForward, restating the defaults Home Manager's legacy submodule applied (bind address localhost, host address unset) so the rendered line is the same; extraOptions merged flat. An unknown key, an unknown forward sub-key, an extraOptions key colliding with a translated directive (compared case-insensitively, as OpenSSH does), or a string-valued extraOptions.SetEnv (the one directive Home Manager's settings renderer takes only as an attribute set) is an evaluation error naming the host and the key. No lib.mkDefault on this path.
  • modules/dev-ssh-forge.nix (new, imported by mkDevVm only) renders the forge block from runtimeIdentity.forgeHost, runtimeIdentity.forgePort and identity.sshKeyName. Its IdentityFile keeps lib.mkDefault for the transition: a profile that still carries its copy of the block wins without a clash. The follow-up that drops it is triggered by a deploy lock in which no profile input defines the block; after that a stray copy is a type clash at evaluation, not a silent winner.
  • checks/ssh-settings.nix (new, registered as ssh-settings): a hypervisor fixture with a plain host, a port+identitiesOnly host, an extraOptions host and a localForwards host, each resolved with ssh -G -F <generated config> and asserted by exact line; a dev fixture asserting exactly one Host block, the forge, resolving to ~/.ssh/<sshKeyName>; a dev fixture beside an unmigrated legacy matchBlocks profile copy asserting the copy's key wins and evaluation is clean; a fixture rendering the same localForwards data through the new module and through the legacy option and asserting equal text; assertions that neither production module defines matchBlocks and that the hypervisor and dev fixtures carry no warnings; one sabotage fixture (an entry with proxyJump) proving the translation fails loud. Four further assertions read the framework's own machines through the production builders, with names taken from inventory: every dev machine's generated config holds exactly one Host line naming its forge, and the hypervisor's holds one per sshHosts entry, so a builder that stops importing one of the modules fails the check. The dev half of that is masked while the pinned profiles input still defines an identical forge block (the two authors render the same bytes, which is the defect this arc removes); it becomes load-bearing when allod/profiles#10 lands, and a wrong forgeHost from the builder already fails it today.

The translation function and the ssh -G check shape are harvested from the closed allod/archetypes#80, extended with localForwards, which that branch rejected and which deployment address books carry. The companion template change is allod/secrets#33, adding localForwards and extraOptions entries so the composed hypervisor render exercises every shape the translation claims.

One correction to the issue text: the hypervisor's rendered ~/.ssh/config is byte-identical only for entries without extraOptions. The legacy option appended those after the typed directives; the flat merge sorts them in with the rest. Same directives, same ssh -G resolution, different line order inside that block. Measured below.

Risk

R3: a shared interface consumed by every dev VM and by the hypervisor's user profile, with cross-repo sequencing against the profile deletions. The worst credible failure after validation is a deployment address book carrying a field shape the translation does not know, which is a loud evaluation error at the next lock bump, not a silent config change. The authoritative data is untouched. Rollback is a revert.

Validation

Run by the integrator at c213a34:

  • nix build .#checks.x86_64-linux.ssh-settings --rebuild exits 0. The worker showed the check failing on a sabotaged expected key name and on a drifted forward default, then reverted.
  • nix flake check --no-build exits 0 (worker run, alone); the only matchBlocks traces are the two deliberate legacy fixtures.
  • Deploy template (allod/deploy master) with --override-input archetypes <this branch>: nexus's rendered ~/.ssh/config is byte-identical to master; allod-dev's keeps the forge block byte-for-byte and loses the five other blocks. The matchBlocks warnings are gone from both evaluations.
  • With the allod/secrets#33 branch overriding as well, nexus under master archetypes (Home Manager's legacy conversion) versus under this branch: ssh -G output is identical for all six hosts, including localforward [127.0.0.1]:8443 [192.0.2.31]:443, localforward [localhost]:5900 [192.0.2.31]:5900 and serveraliveinterval 30; the text differs only in the position of ServerAliveInterval inside example-provider-support.
  • With all three branches (archetypes, secrets, profiles) overriding: nexus, allod-dev and privacy-1 evaluate to their toplevel derivations with no evaluation warnings; allod-dev's config is the single forge block, identical with and without the profile copy present.
  • Not run: the same render against a private deployment's address book, which only the deployer can do. The command is the one above with the deployment's composition root; expected result is the hypervisor identical in ssh -G terms and each dev VM reduced to its forge block.

Cold cross-family review: gpt-5.6-sol via codex exec, reasoning effort xhigh, read the diff at 2a6d3f7 together with allod/secrets#33 and allod/profiles#10 (Claude-family workers authored all three). Four findings, all confirmed by tracing and fixed in c213a34. Blocker: the module comment claimed the framework change and the profile deletions may enter a deploy lock in either order; true of evaluation only, and the profiles-first order leaves a dev VM with the hypervisor's forge key. Comment corrected; the constraint is stated at the top of this body and of allod/profiles#10. Should-fix, three: an extraOptions.SetEnv string that the legacy option rendered verbatim fails inside Home Manager with an error naming no host, now a named evaluation error; the collision guard compared directive names case-sensitively while OpenSSH does not, so extraOptions.HOSTNAME beside hostname rendered both with the wrong one winning, now compared lower-cased; and the check pinned the two modules in isolation but not the builders' imports, now pinned through the framework's own machines as described above. The reviewer traced and cleared: string, list and null identityFile values and the forward endpoint defaults against the pinned Home Manager rendering; legacy and migrated profile copies both overriding the transitional mkDefault; the sabotage tryEval being forced; and the fixtures carrying only RFC 5737 addresses and no key material. No second reading follows the fix round: the fixes are settled by the rebuilt check, the worker's captured error texts for the two new throws, the hypervisor wiring assertion shown failing with its import removed, and the renders above re-run at c213a34 (unchanged).

Closes allod/archetypes#54
Refs allod/strategy#56

Every dev VM's SSH client config becomes one host block, the forge, keyed to that VM's own key; the hypervisor's SSH address book (`identity.sshHosts`) no longer reaches dev VMs at all. The hypervisor's own config is unchanged: byte-identical on the public template, and identical in effect for an entry that carries `extraOptions` (only the line order inside that block moves). The Home Manager 26.05 `matchBlocks` deprecation warnings this framework emitted stop. No `sshHosts` data changes and the nexus rotation scripts read the same fields. A new flake check resolves fixture configs with a real `ssh -G`, and against the deploy template the hypervisor renders identically while `allod-dev` keeps its forge block byte-for-byte and loses the five entries keyed to a key it does not hold. If it is wrong, revert this PR; nothing else depends on the two new modules. Landing order for a deploy lock: take this before or together with the profile deletions (allod/profiles#10), never after them, because a dev VM whose profile copy is already gone while this change is absent gets the address book's forge block with the hypervisor's key and cannot reach the forge. ## What changes - `modules/home-shared.nix` keeps username, home directory, git and `programs.ssh.enable`; it no longer maps `identity.sshHosts` into `programs.ssh.matchBlocks` and no longer special-cases the forge host. - `modules/host-ssh-hosts.nix` (new, imported by `mkHypervisor` only) translates each `sshHosts` entry to `programs.ssh.settings`: `hostname`, `user`, `port`, `identityFile`, `identitiesOnly` to their directive names; `localForwards` to `LocalForward`, restating the defaults Home Manager's legacy submodule applied (bind address `localhost`, host address unset) so the rendered line is the same; `extraOptions` merged flat. An unknown key, an unknown forward sub-key, an `extraOptions` key colliding with a translated directive (compared case-insensitively, as OpenSSH does), or a string-valued `extraOptions.SetEnv` (the one directive Home Manager's settings renderer takes only as an attribute set) is an evaluation error naming the host and the key. No `lib.mkDefault` on this path. - `modules/dev-ssh-forge.nix` (new, imported by `mkDevVm` only) renders the forge block from `runtimeIdentity.forgeHost`, `runtimeIdentity.forgePort` and `identity.sshKeyName`. Its `IdentityFile` keeps `lib.mkDefault` for the transition: a profile that still carries its copy of the block wins without a clash. The follow-up that drops it is triggered by a deploy lock in which no profile input defines the block; after that a stray copy is a type clash at evaluation, not a silent winner. - `checks/ssh-settings.nix` (new, registered as `ssh-settings`): a hypervisor fixture with a plain host, a `port`+`identitiesOnly` host, an `extraOptions` host and a `localForwards` host, each resolved with `ssh -G -F <generated config>` and asserted by exact line; a dev fixture asserting exactly one `Host` block, the forge, resolving to `~/.ssh/<sshKeyName>`; a dev fixture beside an unmigrated legacy `matchBlocks` profile copy asserting the copy's key wins and evaluation is clean; a fixture rendering the same `localForwards` data through the new module and through the legacy option and asserting equal text; assertions that neither production module defines `matchBlocks` and that the hypervisor and dev fixtures carry no warnings; one sabotage fixture (an entry with `proxyJump`) proving the translation fails loud. Four further assertions read the framework's own machines through the production builders, with names taken from inventory: every dev machine's generated config holds exactly one `Host` line naming its forge, and the hypervisor's holds one per `sshHosts` entry, so a builder that stops importing one of the modules fails the check. The dev half of that is masked while the pinned profiles input still defines an identical forge block (the two authors render the same bytes, which is the defect this arc removes); it becomes load-bearing when allod/profiles#10 lands, and a wrong `forgeHost` from the builder already fails it today. The translation function and the `ssh -G` check shape are harvested from the closed allod/archetypes#80, extended with `localForwards`, which that branch rejected and which deployment address books carry. The companion template change is allod/secrets#33, adding `localForwards` and `extraOptions` entries so the composed hypervisor render exercises every shape the translation claims. One correction to the issue text: the hypervisor's rendered `~/.ssh/config` is byte-identical only for entries without `extraOptions`. The legacy option appended those after the typed directives; the flat merge sorts them in with the rest. Same directives, same `ssh -G` resolution, different line order inside that block. Measured below. ## Risk R3: a shared interface consumed by every dev VM and by the hypervisor's user profile, with cross-repo sequencing against the profile deletions. The worst credible failure after validation is a deployment address book carrying a field shape the translation does not know, which is a loud evaluation error at the next lock bump, not a silent config change. The authoritative data is untouched. Rollback is a revert. ## Validation Run by the integrator at c213a34: - `nix build .#checks.x86_64-linux.ssh-settings --rebuild` exits 0. The worker showed the check failing on a sabotaged expected key name and on a drifted forward default, then reverted. - `nix flake check --no-build` exits 0 (worker run, alone); the only `matchBlocks` traces are the two deliberate legacy fixtures. - Deploy template (`allod/deploy` master) with `--override-input archetypes <this branch>`: `nexus`'s rendered `~/.ssh/config` is byte-identical to master; `allod-dev`'s keeps the forge block byte-for-byte and loses the five other blocks. The `matchBlocks` warnings are gone from both evaluations. - With the allod/secrets#33 branch overriding as well, `nexus` under master archetypes (Home Manager's legacy conversion) versus under this branch: `ssh -G` output is identical for all six hosts, including `localforward [127.0.0.1]:8443 [192.0.2.31]:443`, `localforward [localhost]:5900 [192.0.2.31]:5900` and `serveraliveinterval 30`; the text differs only in the position of `ServerAliveInterval` inside `example-provider-support`. - With all three branches (archetypes, secrets, profiles) overriding: `nexus`, `allod-dev` and `privacy-1` evaluate to their toplevel derivations with no evaluation warnings; `allod-dev`'s config is the single forge block, identical with and without the profile copy present. - Not run: the same render against a private deployment's address book, which only the deployer can do. The command is the one above with the deployment's composition root; expected result is the hypervisor identical in `ssh -G` terms and each dev VM reduced to its forge block. Cold cross-family review: `gpt-5.6-sol` via `codex exec`, reasoning effort `xhigh`, read the diff at 2a6d3f7 together with allod/secrets#33 and allod/profiles#10 (Claude-family workers authored all three). Four findings, all confirmed by tracing and fixed in c213a34. Blocker: the module comment claimed the framework change and the profile deletions may enter a deploy lock in either order; true of evaluation only, and the profiles-first order leaves a dev VM with the hypervisor's forge key. Comment corrected; the constraint is stated at the top of this body and of allod/profiles#10. Should-fix, three: an `extraOptions.SetEnv` string that the legacy option rendered verbatim fails inside Home Manager with an error naming no host, now a named evaluation error; the collision guard compared directive names case-sensitively while OpenSSH does not, so `extraOptions.HOSTNAME` beside `hostname` rendered both with the wrong one winning, now compared lower-cased; and the check pinned the two modules in isolation but not the builders' imports, now pinned through the framework's own machines as described above. The reviewer traced and cleared: string, list and null `identityFile` values and the forward endpoint defaults against the pinned Home Manager rendering; legacy and migrated profile copies both overriding the transitional `mkDefault`; the sabotage `tryEval` being forced; and the fixtures carrying only RFC 5737 addresses and no key material. No second reading follows the fix round: the fixes are settled by the rebuilt check, the worker's captured error texts for the two new throws, the hypervisor wiring assertion shown failing with its import removed, and the renders above re-run at c213a34 (unchanged). Closes allod/archetypes#54 Refs allod/strategy#56
home-shared.nix mapped the whole of identity.sshHosts into
programs.ssh.matchBlocks for every machine, so a dev VM carried the
hypervisor's address book: every block but the forge named a key the VM does
not hold. The forge block itself had two authors, the module and a profile
copy, held apart by a lib.mkDefault.

The mapping leaves home-shared.nix. The address book moves to
modules/host-ssh-hosts.nix, imported by mkHypervisor alone, which translates
the sshHosts field names to OpenSSH directive names on programs.ssh.settings
and fails evaluation on a key it has no translation for. The dev VM's forge
entry moves to modules/dev-ssh-forge.nix, rendered by mkDevVm from the VM's
own forgeHost, forgePort and sshKeyName. Its IdentityFile keeps a transitional
lib.mkDefault so a profile that still carries its own copy wins without a
clash whichever lands first.

The new ssh-settings check builds home-manager fixtures through both modules
and resolves each host with ssh -G, pins the localForwards endpoint defaults
by rendering the same data through the legacy option and comparing the text,
and pins the untranslatable-key throw with a sabotage fixture.

Public template fleet: nexus's generated ~/.ssh/config is byte-identical;
allod-dev keeps its forge block byte-for-byte and loses the five entries it
could not authenticate with.

Refs allod/archetypes#54
Refs allod/strategy#56
Four defects a cold review found in the previous commit.

The dev-ssh-forge.nix header claimed the framework change and the profile
deletions may land in either order. That is true of evaluation only. A deploy
lock must take this change before or together with the profile deletions: a
dev VM whose profile copy is gone while the framework still ships the whole
address book gets the address book's forge block, which names the
hypervisor's key.

OpenSSH directive names are case-insensitive, so extraOptions.HOSTNAME beside
a translated HostName was two names here but one directive to ssh, and the
flat merge rendered both with the alphabetically first winning silently.
Collisions are now compared lower-cased.

The legacy extraOptions option was attrsOf str and rendered SetEnv verbatim.
programs.ssh.settings renders SetEnv from an attribute set and fails on a
string with an error naming no host, so a string-valued extraOptions.SetEnv
is now a throw naming the host and the attribute-set form. It is the only
directive whose settings renderer refuses the legacy string.

The check pinned both modules in isolation, so a builder that stopped
importing one still passed. It now reads the framework's own machines through
machineConfigurations, with names taken from inventory: every dev machine's
generated config holds exactly one Host block and it is that machine's own
forge, and the hypervisor's holds one per identity.sshHosts entry.

Refs allod/archetypes#54
Refs allod/strategy#56
vnprc approved these changes 2026-09-20 04:23:03 +01:00
vnprc merged commit c213a34ff9 into master 2026-09-20 04:23:09 +01:00
vnprc deleted branch agent/ssh-hosts-by-identity 2026-09-20 04:23:09 +01:00
Sign in to join this conversation.
No description provided.