Render a dev VM's own external SSH aliases beside its forge entry #85

Merged
vnprc merged 3 commits from agent/dev-vm-ssh-aliases into master 2026-09-20 14:47:00 +01:00
Member

A dev VM can now carry SSH aliases for the few external hosts it reaches with its own key, declared in its own identity record and rendered beside its forge entry, without receiving the hypervisor's address book. On every running machine today nothing changes: no template or deployment record the public deploy lock pins declares an alias yet, so allod-dev's and nexus's rendered SSH configs are byte-identical to the current lock, and the hypervisor path is untouched. Once a deploy lock carries this together with a secrets record that declares aliases (the template's example-build-cache in allod/secrets#34, or a deployment's own), those aliases appear in that VM's config at its next rebuild, keyed to the VM's own key. An alias that names the forge host is refused at evaluation here as well as in secrets, so nothing can replace the framework's forge block silently. If it is wrong, revert this PR; a secrets record carrying the field is inert against the previous framework. Closes allod/archetypes#83 and, with the companion template change, allod/strategy#57; the one deployment that raised the need has its data merged already.

What changes

  • modules/host-ssh-hosts.nix is renamed modules/ssh-hosts.nix: the same field-name translation, now imported by the hypervisor builder with identity.sshHosts and by the dev VM builder with that VM's devIdentities.<vm>.sshHosts (empty when the secrets fork predates the field). It applies no defaults; the secrets projection has already filled in identityFile and identitiesOnly. Its diagnostics name the record the operator edits: identity.sshHosts.<name> on the hypervisor, devVMs.<machine>.sshHosts.<alias> on a dev VM.
  • mkDevVm refuses an alias whose name equals the forge host, compared case-insensitively, with an error naming the machine and the alias. This is the layer beneath the secrets projection's own refusal: a fork with a different projection still cannot redefine the block dev-ssh-forge.nix renders.
  • The ssh-settings check gains a dev fixture with one per-VM host, resolved with ssh -G: the alias resolves to the VM's key, IdentitiesOnly yes and its HostKeyAlias, the forge block beside it is unchanged, and the config holds exactly two Host lines. A fixture drives the production mkDevVm with an upper-cased forge-named alias and asserts evaluation fails; deleting the builder assertion fails this fixture rather than passing it vacuously, because the two differently-cased names are distinct attributes to the module system. The wiring assertion that reads every inventory dev machine through the production builder now expects the forge line plus one line per alias its identity declares, so the composed check covers the new import as soon as the template carries an entry.

Risk

R3: a new data path from secrets onto every dev VM's SSH client config, across two repositories. The worst credible failure after validation is a per-VM entry carrying a field the translation does not know, which is a loud evaluation error naming devVMs.<machine>.sshHosts.<alias> at the next lock bump, not a silent config change. Runtime landing order with allod/secrets#34 is free: this framework reads the field with an empty default, and the field is inert against the previous framework. Merge order is not free: merge allod/secrets#34 first, then this branch takes a lock bump of its own secrets input so the wiring assertion that reads the template through the production builder sees the template alias and pins the new import; until that bump, archetypes' own lock pins a template with no alias and that assertion is satisfied with or without the import (the fixtures cover it in isolation, and the composed run with the secrets branch overriding covers it end to end). Rollback is a revert.

Validation

Run by the integrator at 55f2f30:

  • nix build .#checks.x86_64-linux.ssh-settings --rebuild exits 0. The worker showed the per-VM fixture failing on a wrong expected HostKeyAlias, the forge-alias fixture failing with the case-insensitive comparison removed and again with the assertion deleted, then reverted each.
  • nix flake check --no-build exits 0 (worker run, alone).
  • Deploy template with --override-input archetypes <this branch> alone: allod-dev and nexus render byte-identical SSH configs to the current lock.
  • Deploy template with this branch and allod/secrets#34 overriding together: nexus is byte-identical to the current lock; allod-dev renders the forge block unchanged plus Host example-build-cache with HostName 192.0.2.40, User cache, IdentityFile ~/.ssh/allod_vm, IdentitiesOnly yes, HostKeyAlias example-build-cache, and ssh -G resolves both hosts to those values; allod-dev, nexus and privacy-1 evaluate to their toplevel derivations with no evaluation warnings.
  • This flake's ssh-settings check built with --override-input secrets <allod/secrets#34> exits 0, so the composed wiring assertion holds with two expected lines on the template's dev machine.
  • Not run: a deployment's own per-VM data against this branch, which only the deployer can render; expected result is each declared alias beside the forge block, keyed to that VM's key.

Cold cross-family review: gpt-5.6-sol via codex exec, reasoning effort xhigh, read this diff at 3d01544 together with allod/secrets#34 (Claude-family workers authored both). Four findings, three marked blocker, all confirmed by tracing and fixed in 55f2f30 (this repo) and the companion's fix commit. Blockers: a flattened extraOptions.header became the block's literal Host line and captured the forge, and a newline in any string field injected a block the same way, now refused in the translation on both paths; two aliases differing only by case both matched one host, now refused; the builder's fallback compared lower-cased raw names, so a trailing space or a literal Host prefix slipped past while OpenSSH still matched the forge, now refused by requiring one literal token plus the case-insensitive forge comparison, with the negative fixture switched to the upper-cased, trailing-space form that only both rules together reject. Should-fix: the standalone wiring assertion cannot pin the new import while this repo's lock pins a template without an alias; addressed by the merge order above rather than by a heavier fixture. The reviewer traced and cleared: identityFile as a default an entry may override; missing sshKeyName failing loudly; the projection's refusals forced with deepSeq and the builder negative tripping at weak-head evaluation; the shell assertions failing closed; no stale consumers of the renamed module or the check parameters; runtime landing order; RFC 5737 addresses only. No second reading follows the fix round; the fixes are settled by the rebuilt check, the by-hand capture witness before and after the refusal, and the renders re-run at 55f2f30.

Closes allod/archetypes#83
Closes allod/strategy#57

A dev VM can now carry SSH aliases for the few external hosts it reaches with its own key, declared in its own identity record and rendered beside its forge entry, without receiving the hypervisor's address book. On every running machine today nothing changes: no template or deployment record the public deploy lock pins declares an alias yet, so `allod-dev`'s and `nexus`'s rendered SSH configs are byte-identical to the current lock, and the hypervisor path is untouched. Once a deploy lock carries this together with a secrets record that declares aliases (the template's `example-build-cache` in allod/secrets#34, or a deployment's own), those aliases appear in that VM's config at its next rebuild, keyed to the VM's own key. An alias that names the forge host is refused at evaluation here as well as in secrets, so nothing can replace the framework's forge block silently. If it is wrong, revert this PR; a secrets record carrying the field is inert against the previous framework. Closes allod/archetypes#83 and, with the companion template change, allod/strategy#57; the one deployment that raised the need has its data merged already. ## What changes - `modules/host-ssh-hosts.nix` is renamed `modules/ssh-hosts.nix`: the same field-name translation, now imported by the hypervisor builder with `identity.sshHosts` and by the dev VM builder with that VM's `devIdentities.<vm>.sshHosts` (empty when the secrets fork predates the field). It applies no defaults; the secrets projection has already filled in `identityFile` and `identitiesOnly`. Its diagnostics name the record the operator edits: `identity.sshHosts.<name>` on the hypervisor, `devVMs.<machine>.sshHosts.<alias>` on a dev VM. - `mkDevVm` refuses an alias whose name equals the forge host, compared case-insensitively, with an error naming the machine and the alias. This is the layer beneath the secrets projection's own refusal: a fork with a different projection still cannot redefine the block `dev-ssh-forge.nix` renders. - The `ssh-settings` check gains a dev fixture with one per-VM host, resolved with `ssh -G`: the alias resolves to the VM's key, `IdentitiesOnly yes` and its `HostKeyAlias`, the forge block beside it is unchanged, and the config holds exactly two `Host` lines. A fixture drives the production `mkDevVm` with an upper-cased forge-named alias and asserts evaluation fails; deleting the builder assertion fails this fixture rather than passing it vacuously, because the two differently-cased names are distinct attributes to the module system. The wiring assertion that reads every inventory dev machine through the production builder now expects the forge line plus one line per alias its identity declares, so the composed check covers the new import as soon as the template carries an entry. ## Risk R3: a new data path from secrets onto every dev VM's SSH client config, across two repositories. The worst credible failure after validation is a per-VM entry carrying a field the translation does not know, which is a loud evaluation error naming `devVMs.<machine>.sshHosts.<alias>` at the next lock bump, not a silent config change. Runtime landing order with allod/secrets#34 is free: this framework reads the field with an empty default, and the field is inert against the previous framework. Merge order is not free: merge allod/secrets#34 first, then this branch takes a lock bump of its own `secrets` input so the wiring assertion that reads the template through the production builder sees the template alias and pins the new import; until that bump, archetypes' own lock pins a template with no alias and that assertion is satisfied with or without the import (the fixtures cover it in isolation, and the composed run with the secrets branch overriding covers it end to end). Rollback is a revert. ## Validation Run by the integrator at 55f2f30: - `nix build .#checks.x86_64-linux.ssh-settings --rebuild` exits 0. The worker showed the per-VM fixture failing on a wrong expected `HostKeyAlias`, the forge-alias fixture failing with the case-insensitive comparison removed and again with the assertion deleted, then reverted each. - `nix flake check --no-build` exits 0 (worker run, alone). - Deploy template with `--override-input archetypes <this branch>` alone: `allod-dev` and `nexus` render byte-identical SSH configs to the current lock. - Deploy template with this branch and allod/secrets#34 overriding together: `nexus` is byte-identical to the current lock; `allod-dev` renders the forge block unchanged plus `Host example-build-cache` with `HostName 192.0.2.40`, `User cache`, `IdentityFile ~/.ssh/allod_vm`, `IdentitiesOnly yes`, `HostKeyAlias example-build-cache`, and `ssh -G` resolves both hosts to those values; `allod-dev`, `nexus` and `privacy-1` evaluate to their toplevel derivations with no evaluation warnings. - This flake's `ssh-settings` check built with `--override-input secrets <allod/secrets#34>` exits 0, so the composed wiring assertion holds with two expected lines on the template's dev machine. - Not run: a deployment's own per-VM data against this branch, which only the deployer can render; expected result is each declared alias beside the forge block, keyed to that VM's key. Cold cross-family review: `gpt-5.6-sol` via `codex exec`, reasoning effort `xhigh`, read this diff at 3d01544 together with allod/secrets#34 (Claude-family workers authored both). Four findings, three marked blocker, all confirmed by tracing and fixed in 55f2f30 (this repo) and the companion's fix commit. Blockers: a flattened `extraOptions.header` became the block's literal `Host` line and captured the forge, and a newline in any string field injected a block the same way, now refused in the translation on both paths; two aliases differing only by case both matched one host, now refused; the builder's fallback compared lower-cased raw names, so a trailing space or a literal `Host ` prefix slipped past while OpenSSH still matched the forge, now refused by requiring one literal token plus the case-insensitive forge comparison, with the negative fixture switched to the upper-cased, trailing-space form that only both rules together reject. Should-fix: the standalone wiring assertion cannot pin the new import while this repo's lock pins a template without an alias; addressed by the merge order above rather than by a heavier fixture. The reviewer traced and cleared: `identityFile` as a default an entry may override; missing `sshKeyName` failing loudly; the projection's refusals forced with `deepSeq` and the builder negative tripping at weak-head evaluation; the shell assertions failing closed; no stale consumers of the renamed module or the check parameters; runtime landing order; RFC 5737 addresses only. No second reading follows the fix round; the fixes are settled by the rebuilt check, the by-hand capture witness before and after the refusal, and the renders re-run at 55f2f30. Closes allod/archetypes#83 Closes allod/strategy#57
mkDevVm now feeds `identity.sshHosts` through the shared field-name
translation, so a dev VM renders the few external hosts it reaches with
its own key beside the forge block dev-ssh-forge.nix writes. A secrets
fork that predates the field composes with an empty set and renders
nothing new.

Rename modules/host-ssh-hosts.nix to modules/ssh-hosts.nix: both
builders import it now, so the hypervisor-only name and header no longer
describe it.

Refuse a per-VM alias naming the machine's forge host in the builder,
compared case-insensitively because OpenSSH matches Host patterns that
way. The secrets projection refuses it first; this is the layer beneath,
and it catches data that never passed through one.

The ssh-settings check gains a dev fixture carrying one external host —
ssh -G resolves it to the VM's own key and its extra directive, with the
forge block unchanged beside it and exactly two Host blocks in the file
— and a fixture that drives the production builder with a forge-named
alias and asserts it fails evaluation. The composed wiring assertion now
expects the forge line plus one per alias the machine's identity record
declares, so it covers the new import the way it covers dev-ssh-forge.nix.

Refs allod/archetypes#83
Refs allod/strategy#57
Both builders import modules/ssh-hosts.nix now, but every throw in it was
prefixed with the literal identity.sshHosts — the hypervisor's address
book. A malformed per-VM entry therefore sent the operator to the wrong
record, and to the wrong repository's field.

Add a `source` argument defaulting to "identity.sshHosts", so the
hypervisor keeps its message, and have mkDevVm pass
"devVMs.${name}.sshHosts": the record in secrets the operator edits,
named by the machine's own inventory name, not the devIdentities
projection the entry reaches the builder through.

Refs allod/archetypes#83
A cold review found that neither the builder's name comparison nor the
translation module stopped an entry from writing into another entry's
block. Three holes, all demonstrated.

modules/ssh-hosts.nix: extraOptions is merged flat into Home Manager's
settings submodule, which reads `header` as the block's literal Host
line, so extraOptions.header = "Host <forge>" renders a second block on
the forge alias — sorted first, where OpenSSH's first-line-wins rule
hands it the hostname while the VM still offers its forge key. Refuse an
extraOptions key that is not a bare directive name, which covers
`header` and the `__`-prefixed submodule internals. Refuse a newline or
carriage return in any string an entry can reach, walked recursively
through attrsets and lists, because ssh_config is line-oriented and the
text after the break becomes its own directive line. Refuse two entry
names differing only in case, because OpenSSH matches Host patterns
that way and both blocks would match.

mkDevVm: an alias is a Host pattern, not a literal, so comparing
lower-cased names let "FORGE.ANARCH.DIY " (trailing space, two patterns
on one line), "Host forge.anarch.diy" and globs through while OpenSSH
still matched the forge. Require one literal token matching
[A-Za-z0-9][A-Za-z0-9._-]* alongside the name comparison.

Checks: the forge-alias negative is now two fixtures, one per builder
rule, because neither rule alone refuses both spellings — removing
either makes the check fail. A Home Manager fixture composes the
header-injection entry beside the real dev forge module and asserts the
render fails.

Refs allod/archetypes#83
vnprc approved these changes 2026-09-20 14:46:54 +01:00
vnprc merged commit 55f2f30a4f into master 2026-09-20 14:47:00 +01:00
vnprc deleted branch agent/dev-vm-ssh-aliases 2026-09-20 14:47:00 +01:00
Sign in to join this conversation.
No description provided.