nixos/ssh: add programs.ssh.hosts to specify Host ... defintions

This new option is intentionally not `programs.ssh.settings`, as [that
option name is already used by home-manager][1], and there it works a
bit differently. The reasons for not taking home-manager's design as is,
are:

- Nixpkgs lacks the DAG implementation like [home-manager's][2] (See also
  [discourse discussion][3]).
- nixos/pam is an existing Nixpkgs module that already requires the exact
  same features as ssh, so model the behavior of `programs.ssh.hosts` after
  it. See [PR][4],

[1]: https://github.com/nix-community/home-manager/blob/master/modules/programs/ssh.nix
[2]: https://github.com/nix-community/home-manager/blob/master/modules/lib/dag.nix
[3]: https://discourse.nixos.org/t/an-equivalent-of-lib-hm-dag-or-some-other-mechanism-for-ordering-in-nixpkgs-lib/75195
[4]: https://github.com/NixOS/nixpkgs/pull/255547

Assisted-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Doron Behar
2026-10-05 06:51:19 +03:00
parent c226e0eaf3
commit ead0d2b8e4
2 changed files with 107 additions and 0 deletions

View File

@@ -409,6 +409,8 @@ The above example can be migrated via the following structured code block:
```
:::
- The new {option}`programs.ssh.hosts` option can be used to add `Host` blocks to the client {file}`ssh_config`, keyed by `Host` pattern, instead of writing them by hand via {option}`programs.ssh.extraConfig`. Each block declares an {option}`programs.ssh.hosts.<name>.order`, since `ssh_config` applies the first obtained value for each keyword.
- NixOS VM tests now prefer to express durations and timeouts as `datetime.timedelta` values instead of bare numbers. Methods such as `machine.wait_until_succeeds`, `machine.sleep`, `retry`, and `polling_condition` now accept a `timedelta` (e.g., `machine.wait_for_unit("sshd.service", timeout=datetime.timedelta(minutes=1))`). Passing an `int`/`float` as seconds still works but now emits a deprecation warning. Argument names that explicitly defined units were preserved but have had `timedelta` equivalents introduced (`timeout_seconds` → `timeout`, `secs` → `duration`, `seconds_interval` → `interval`).
- `darwin.linux-builder-vz` has been added: a variant of `darwin.linux-builder` that runs the builder guest on Apple's Virtualization.framework via the new `vzvm` package, translating `x86_64-linux` builds with Rosetta instead of emulating them. Apple silicon hosts only. As part of this, the `nixos/modules/profiles/nix-builder-vm.nix` profile has been split into the backend-neutral `nixos/modules/profiles/nix-builder.nix` and a QEMU-specific part. Existing imports of `nix-builder-vm.nix` keep working unchanged.

View File

@@ -35,6 +35,40 @@ let
]
++ map pkgs.copyPathToStore cfg.knownHostsFiles;
sshConfigValue = v: if builtins.isBool v then lib.boolToYesNo v else toString v;
hostSettingsText = lib.generators.toKeyValue {
mkKeyValue = lib.generators.mkKeyValueDefault {
mkValueString = sshConfigValue;
} " ";
indent = " ";
};
hostsText = lib.pipe cfg.hosts [
(lib.mapAttrsToList (
pattern: hostCfg:
# `order` is forced here so that it is required for a lone block too,
# which `sort` would otherwise never compare against anything.
lib.seq hostCfg.order {
inherit pattern;
inherit (hostCfg) order;
settings = removeAttrs hostCfg [ "order" ];
}
))
# `ssh_config(5)` applies the first obtained value for each keyword, so the
# blocks are written in ascending `order`, and sharing an `order` value is
# an error rather than an arbitrary choice between the two blocks.
(lib.sort (
a: b:
if a.order != b.order then
a.order < b.order
else
throw "programs.ssh.hosts: blocks '${a.pattern}' and '${b.pattern}' cannot have the same order value (${toString a.order})"
))
(map (host: "Host ${host.pattern}\n" + hostSettingsText host.settings))
(lib.concatStringsSep "\n")
];
in
{
###### interface
@@ -124,6 +158,76 @@ in
'';
};
hosts = lib.mkOption {
default = { };
type = lib.types.attrsOf (
lib.types.submodule {
freeformType = lib.types.attrsOf (
lib.types.oneOf [
lib.types.bool
lib.types.int
lib.types.str
]
);
options.order = lib.mkOption {
type = lib.types.int;
example = 100;
description = ''
Position of this block in {file}`ssh_config`. Blocks are
written in ascending order of this value, and two blocks
may not share a value.
Since {manpage}`ssh_config(5)` applies the *first* obtained
value for each keyword, a block with a lower value takes
precedence over the blocks that follow it. In particular, a
catch-all pattern such as `"*"` should have the highest
value, so that it does not shadow the more specific blocks.
To place a block relative to one that another module
declares, derive this value from that block's one instead of
hardcoding it, e.g.
`config.programs.ssh.hosts."*".order - 100`.
'';
};
}
);
description = ''
Per-host (or host-pattern) client configuration, added as `Host`
blocks to {file}`ssh_config`.
Attribute names are used as the `Host` pattern, as accepted by
{manpage}`ssh_config(5)`. Several space-separated patterns can be
given to apply one block to all of them, e.g.
`"foo bar *.baz.org"`.
Every attribute of a block besides {option}`order` becomes a
`Key Value` line within that block; boolean values are rendered as
`yes`/`no`.
'';
example = lib.literalExpression ''
{
jumphost = {
order = 100;
User = "deploy";
Port = 2222;
IdentityFile = "/root/.ssh/id_deploy";
IdentitiesOnly = true;
};
"*.example.org" = {
order = 200;
ControlMaster = "auto";
ControlPath = "/root/.ssh/master-%r@%n:%p";
ControlPersist = "5m";
};
# Written last, so that it does not shadow the blocks above.
"*" = {
order = 300;
ServerAliveInterval = 60;
};
}
'';
};
startAgent = lib.mkOption {
type = lib.types.bool;
default = false;
@@ -342,6 +446,7 @@ in
environment.etc."ssh/ssh_config".text = lib.concatStringsSep "\n" (
# Custom options from `extraConfig`, to override generated options
lib.optional (cfg.extraConfig != "") cfg.extraConfig
++ lib.optional (cfg.hosts != { }) hostsText
++ [
''
# Generated options from other settings