Scope sshHosts to the machines that should receive them #54

Open
opened 2026-08-27 04:43:38 +01:00 by vnprc-agent · 0 comments

Give a machine only the sshHosts entries it is meant to reach, instead of the deployment's entire host inventory. Today every machine receives every entry, and the model has no way to say otherwise — which is why one host is already special-cased to work around it.

Primary goals:

  • Scope the inventory — an sshHosts entry can name the machines that receive it, defaulting to all so existing forks are unaffected.
  • Retire the hardcoded exception — the single-host special case in the consumer becomes an instance of the general rule rather than a carve-out.
  • Stop emitting unusable blocks — a machine no longer gets aliases whose identityFile exists only on another machine.
  • Least privilege as structure — what a machine learns about the fleet becomes a declared property of the data, not a side effect of how the module happens to consume it.

Current state and specifics

modules/home-shared.nix:20-24 maps the whole of identity.sshHosts into programs.ssh.matchBlocks, with exactly one host treated differently:

matchBlocks = builtins.mapAttrs (name: hostCfg:
  if name == identity.forgeHost
  then hostCfg // { identityFile = lib.mkDefault hostCfg.identityFile; }
  else hostCfg
) identity.sshHosts;

That mkDefault exists so a fork's per-machine home module can substitute the machine's own forge key. Every other entry is emitted verbatim at normal priority, identityFile included — so a machine cannot correct an identity path that does not apply to it, and the one place that needed correcting got a hardcoded exception instead of a mechanism.

Two consequences follow. A dev machine receives host blocks for infrastructure it is not meant to touch, including entries the template itself marks as external and operator-facing (example-backup-vps, example-offsite-console, example-provider-support in the identity template's sshHosts). And most of those blocks are inert: they name ~/.ssh/host, which by the template's own design is the hypervisor's identity, so on any other machine the alias resolves to a file that is not there. Measured on a deployment, one machine's generated config held fourteen host blocks of which one — the special-cased forge host — could authenticate.

The machine name is already in scope at both call sites, flake.nix:749 and flake.nix:1135, so the filter needs no new plumbing.

Exposure, not access

This is the same distinction allod/archetypes#21 draws for the secrets directory, and the same conclusion: which host a machine may actually reach is governed by key possession, and that is unaffected and correct. The defect is that the machine is told about hosts it has no business knowing.

The honest scale: on a deployment where allod/archetypes#21 is still open, this changes nothing measurable, because the store closure already carries the identity data the config is derived from. A deployment may also place the same facts on a machine through its own document checkouts, which is that deployment's content decision and outside this repo. What the change buys is that the framework stops broadcasting on a channel a deployment cannot opt out of — so the two fixes together make the remaining exposure something an operator chose rather than something the framework imposed. Treat this as least privilege applied structurally, not as a fix for a live leak.

The forward-looking half matters more than the current state. Any host added to sshHosts reaches every machine automatically, whether or not its name is something the fleet should hold.

Suggested default

Add an optional field naming the receiving machines, absent meaning all, and filter in the consumer before the mapAttrs. Apply lib.mkDefault to identityFile for every surviving entry, which makes the current forge-host branch unnecessary and lets any fork correct any host's identity per machine. Existing per-machine overrides set identityFile at normal priority and continue to win.

Preferred over the alternative of having each fork pre-filter its own sshHosts before handing it over: that keeps the consumer simple but pushes the same filtering logic into every fork, and leaves the special case in place.

Scope

In scope: modules/home-shared.nix, the optional field in the identity template's sshHosts with synthetic entries exercising it, and a check pinning that an out-of-scope host does not reach a machine.

Out of scope: the recipient graph; the store-closure carve-out, which is allod/archetypes#21; and any fork's data. Back-compatible by construction — a fork that names no machines keeps today's behavior.

Give a machine only the `sshHosts` entries it is meant to reach, instead of the deployment's entire host inventory. Today every machine receives every entry, and the model has no way to say otherwise — which is why one host is already special-cased to work around it. Primary goals: - **Scope the inventory** — an `sshHosts` entry can name the machines that receive it, defaulting to all so existing forks are unaffected. - **Retire the hardcoded exception** — the single-host special case in the consumer becomes an instance of the general rule rather than a carve-out. - **Stop emitting unusable blocks** — a machine no longer gets aliases whose `identityFile` exists only on another machine. - **Least privilege as structure** — what a machine learns about the fleet becomes a declared property of the data, not a side effect of how the module happens to consume it. ### Current state and specifics `modules/home-shared.nix:20-24` maps the whole of `identity.sshHosts` into `programs.ssh.matchBlocks`, with exactly one host treated differently: ```nix matchBlocks = builtins.mapAttrs (name: hostCfg: if name == identity.forgeHost then hostCfg // { identityFile = lib.mkDefault hostCfg.identityFile; } else hostCfg ) identity.sshHosts; ``` That `mkDefault` exists so a fork's per-machine home module can substitute the machine's own forge key. Every other entry is emitted verbatim at normal priority, `identityFile` included — so a machine cannot correct an identity path that does not apply to it, and the one place that needed correcting got a hardcoded exception instead of a mechanism. Two consequences follow. A dev machine receives host blocks for infrastructure it is not meant to touch, including entries the template itself marks as external and operator-facing (`example-backup-vps`, `example-offsite-console`, `example-provider-support` in the identity template's `sshHosts`). And most of those blocks are inert: they name `~/.ssh/host`, which by the template's own design is the hypervisor's identity, so on any other machine the alias resolves to a file that is not there. Measured on a deployment, one machine's generated config held fourteen host blocks of which one — the special-cased forge host — could authenticate. The machine name is already in scope at both call sites, `flake.nix:749` and `flake.nix:1135`, so the filter needs no new plumbing. ### Exposure, not access This is the same distinction `allod/archetypes#21` draws for the secrets directory, and the same conclusion: which host a machine may actually reach is governed by key possession, and that is unaffected and correct. The defect is that the machine is told about hosts it has no business knowing. The honest scale: on a deployment where `allod/archetypes#21` is still open, this changes nothing measurable, because the store closure already carries the identity data the config is derived from. A deployment may also place the same facts on a machine through its own document checkouts, which is that deployment's content decision and outside this repo. What the change buys is that the framework stops broadcasting on a channel a deployment cannot opt out of — so the two fixes together make the remaining exposure something an operator chose rather than something the framework imposed. Treat this as least privilege applied structurally, not as a fix for a live leak. The forward-looking half matters more than the current state. Any host added to `sshHosts` reaches every machine automatically, whether or not its name is something the fleet should hold. ### Suggested default Add an optional field naming the receiving machines, absent meaning all, and filter in the consumer before the `mapAttrs`. Apply `lib.mkDefault` to `identityFile` for every surviving entry, which makes the current forge-host branch unnecessary and lets any fork correct any host's identity per machine. Existing per-machine overrides set `identityFile` at normal priority and continue to win. Preferred over the alternative of having each fork pre-filter its own `sshHosts` before handing it over: that keeps the consumer simple but pushes the same filtering logic into every fork, and leaves the special case in place. ### Scope In scope: `modules/home-shared.nix`, the optional field in the identity template's `sshHosts` with synthetic entries exercising it, and a check pinning that an out-of-scope host does not reach a machine. Out of scope: the recipient graph; the store-closure carve-out, which is `allod/archetypes#21`; and any fork's data. Back-compatible by construction — a fork that names no machines keeps today's behavior.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
allod/archetypes#54
No description provided.