ssh: add RFC 42 settings option

Add programs.ssh.settings as a freeform DAG for OpenSSH client configuration blocks. Render Host and Match blocks directly from the new settings option while preserving ordering support.

Render known ssh_config comma-list directives from Nix lists as single comma-separated lines and known space-list directives as single whitespace-separated lines. This keeps directives like KexAlgorithms, Ciphers, MACs, HostKeyAlgorithms, ProxyJump, SendEnv, GlobalKnownHostsFile, and PermitRemoteOpen from being emitted as duplicate directives where OpenSSH may only use the first value.

Migrate legacy matchBlocks into settings, keep root SSH option redirects pointed at the new option names, and hide the deprecated matchBlocks option from generated docs.

Update SSH tests, docs references, and news coverage for the new option.
This commit is contained in:
Austin Horstman
2026-05-15 13:42:39 -05:00
parent 7519f615df
commit 936ae1b1eb
32 changed files with 707 additions and 187 deletions

View File

@@ -2,9 +2,14 @@
ssh-old-defaults = ./old-defaults.nix;
ssh-old-defaults-extra-config = ./old-defaults-extra-config.nix;
ssh-extra-config-no-default-host = ./extra-config-no-default-host.nix;
ssh-extra-option-overrides = ./extra-option-overrides.nix;
ssh-renamed-options = ./renamed-options.nix;
ssh-includes = ./includes.nix;
ssh-settings = ./settings.nix;
ssh-settings-extra-options-assertion = ./settings-extra-options-assertion.nix;
ssh-settings-raw-forwards = ./settings-raw-forwards.nix;
ssh-match-blocks = ./match-blocks-attrs.nix;
ssh-match-blocks-extra-options-duplicates = ./match-blocks-extra-options-duplicates.nix;
ssh-match-blocks-match-and-hosts = ./match-blocks-match-and-hosts.nix;
ssh-forwards-dynamic-valid-bind-no-asserts = ./forwards-dynamic-valid-bind-no-asserts.nix;
ssh-forwards-dynamic-bind-path-with-port-asserts = ./forwards-dynamic-bind-path-with-port-asserts.nix;

View File

@@ -9,6 +9,6 @@
};
test.asserts.assertions.expected = [
''Cannot set `programs.ssh.extraConfig` if `programs.ssh.matchBlocks."*"` (default host config) is not declared.''
''Cannot set `programs.ssh.extraConfig` if `programs.ssh.settings."*"` (default host config) is not declared.''
];
}

View File

@@ -0,0 +1,9 @@
ForwardAgent no
GlobalKnownHostsFile /etc/ssh/ssh_known_hosts ~/.ssh/global_known_hosts
Host space-list
CanonicalDomains example.org corp.example
ChannelTimeout session=5m direct-tcpip=30s
GlobalKnownHostsFile ~/.ssh/known_hosts ~/.ssh/known_hosts2
PermitRemoteOpen localhost:8080 example.org:443
UserKnownHostsFile ~/.ssh/user_known_hosts ~/.ssh/user_known_hosts2

View File

@@ -0,0 +1,42 @@
{
programs.ssh = {
enable = true;
enableDefaultConfig = false;
extraOptionOverrides = {
ForwardAgent = false;
GlobalKnownHostsFile = [
"/etc/ssh/ssh_known_hosts"
"~/.ssh/global_known_hosts"
];
};
settings.space-list = {
CanonicalDomains = [
"example.org"
"corp.example"
];
ChannelTimeout = [
"session=5m"
"direct-tcpip=30s"
];
GlobalKnownHostsFile = [
"~/.ssh/known_hosts"
"~/.ssh/known_hosts2"
];
UserKnownHostsFile = [
"~/.ssh/user_known_hosts"
"~/.ssh/user_known_hosts2"
];
PermitRemoteOpen = [
"localhost:8080"
"example.org:443"
];
};
};
nmt.script = ''
assertFileExists home-files/.ssh/config
assertFileContent \
home-files/.ssh/config \
${./extra-option-overrides-expected.conf}
'';
}

View File

@@ -3,9 +3,9 @@
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks = {
settings = {
dynamicBindPathWithPort = {
dynamicForwards = [
DynamicForward = [
{
# Error:
address = "/run/user/1000/gnupg/S.gpg-agent.extra";

View File

@@ -3,7 +3,3 @@ Host dynamicBindAddressWithPort
Host dynamicBindPathNoPort
DynamicForward /run/user/1000/gnupg/S.gpg-agent.extra

View File

@@ -4,9 +4,9 @@
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks = {
settings = {
dynamicBindPathNoPort = {
dynamicForwards = [
DynamicForward = [
{
# OK:
address = "/run/user/1000/gnupg/S.gpg-agent.extra";
@@ -15,7 +15,7 @@
};
dynamicBindAddressWithPort = {
dynamicForwards = [
DynamicForward = [
{
# OK:
address = "127.0.0.1";

View File

@@ -3,9 +3,9 @@
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks = {
settings = {
localBindPathWithPort = {
localForwards = [
LocalForward = [
{
# OK:
host.address = "127.0.0.1";

View File

@@ -3,9 +3,9 @@
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks = {
settings = {
localHostPathWithPort = {
localForwards = [
LocalForward = [
{
# OK:
bind.address = "127.0.0.1";

View File

@@ -3,9 +3,9 @@
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks = {
settings = {
remoteBindPathWithPort = {
remoteForwards = [
RemoteForward = [
{
# OK:
host.address = "127.0.0.1";

View File

@@ -3,9 +3,9 @@
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks = {
settings = {
remoteHostPathWithPort = {
remoteForwards = [
RemoteForward = [
{
# OK:
bind.address = "127.0.0.1";

View File

@@ -1,25 +1,21 @@
Host * !github.com
Port 516
IdentityFile file1
IdentityFile file2
Port 516
Host abc
ProxyJump jump-host
Host xyz
SetEnv BAR="_bar_ 42" FOO="foo12"
ServerAliveInterval 60
ServerAliveCountMax 10
DynamicForward [localhost]:2839
IdentityFile file
KexAlgorithms sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,mlkem768x25519-sha256
LocalForward [localhost]:8080 [10.0.0.1]:80
RemoteForward [localhost]:8081 [10.0.0.2]:80
RemoteForward /run/user/1000/gnupg/S.gpg-agent.extra /run/user/1000/gnupg/S.gpg-agent
DynamicForward [localhost]:2839
KexAlgorithms sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,mlkem768x25519-sha256
ServerAliveCountMax 10
ServerAliveInterval 60
SetEnv BAR="_bar_ 42" BAZ="with \" some \\ very \\\" fun \\\\ escapes" FOO="foo12"
Host ordered
Port 1

View File

@@ -1,4 +1,9 @@
{ config, lib, ... }:
{
config,
lib,
options,
...
}:
{
config = {
programs.ssh = {
@@ -43,6 +48,7 @@
setEnv = {
FOO = "foo12";
BAR = "_bar_ 42";
BAZ = ''with " some \ very \" fun \\ escapes'';
};
};
@@ -60,6 +66,12 @@
map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)
);
test.asserts.warnings.expected = [
''
`programs.ssh.matchBlocks` defined in ${lib.showFiles options.programs.ssh.matchBlocks.files} is deprecated. Use `programs.ssh.settings`.
''
];
nmt.script = ''
assertFileExists home-files/.ssh/config
assertFileContent \

View File

@@ -0,0 +1,7 @@
Host legacy
HostName example.org
User typed-user
ForwardAgent yes
HostName extra.example.org
User extra-user

View File

@@ -0,0 +1,44 @@
{
config,
lib,
options,
...
}:
{
config = {
programs.ssh = {
enable = true;
enableDefaultConfig = false;
matchBlocks.legacy = {
user = "typed-user";
hostname = "example.org";
extraOptions = {
ForwardAgent = "yes";
HostName = "extra.example.org";
User = "extra-user";
};
};
};
home.file.assertions.text = builtins.toJSON (
map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)
);
test.asserts.warnings.expected = [
''
`programs.ssh.matchBlocks` defined in ${lib.showFiles options.programs.ssh.matchBlocks.files} is deprecated. Use `programs.ssh.settings`.
''
''
`programs.ssh.matchBlocks.legacy.extraOptions` defined in ${lib.showFiles options.programs.ssh.matchBlocks.files} is deprecated. Move these OpenSSH options to `programs.ssh.settings.legacy` using upstream directive names.
''
];
nmt.script = ''
assertFileExists home-files/.ssh/config
assertFileContent \
home-files/.ssh/config \
${./match-blocks-extra-options-duplicates-expected.conf}
assertFileContent home-files/assertions ${./no-assertions.json}
'';
};
}

View File

@@ -6,7 +6,3 @@ Host abc
Match host xyz canonical
Port 2223

View File

@@ -1,4 +1,9 @@
{ config, lib, ... }:
{
config,
lib,
options,
...
}:
{
config = {
programs.ssh = {
@@ -24,6 +29,12 @@
map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)
);
test.asserts.warnings.expected = [
''
`programs.ssh.matchBlocks` defined in ${lib.showFiles options.programs.ssh.matchBlocks.files} is deprecated. Use `programs.ssh.settings`.
''
];
nmt.script = ''
assertFileExists home-files/.ssh/config
assertFileContent \

View File

@@ -1,15 +1,11 @@
Host *
ForwardAgent no
ServerAliveInterval 0
ServerAliveCountMax 3
Compression no
AddKeysToAgent no
HashKnownHosts no
UserKnownHostsFile ~/.ssh/known_hosts
Compression no
ControlMaster no
ControlPath ~/.ssh/master-%r@%n:%p
ControlPersist no
ForwardAgent no
HashKnownHosts no
ServerAliveCountMax 3
ServerAliveInterval 0
UserKnownHostsFile ~/.ssh/known_hosts

View File

@@ -1,17 +1,14 @@
Host *
ForwardAgent no
ServerAliveInterval 0
ServerAliveCountMax 3
Compression no
AddKeysToAgent no
HashKnownHosts no
UserKnownHostsFile ~/.ssh/known_hosts
Compression no
ControlMaster no
ControlPath ~/.ssh/master-%r@%n:%p
ControlPersist no
ForwardAgent no
HashKnownHosts no
ServerAliveCountMax 3
ServerAliveInterval 0
UserKnownHostsFile ~/.ssh/known_hosts
MyExtraOption no
AnotherOption 3

View File

@@ -12,7 +12,7 @@
`programs.ssh` default values will be removed in the future.
Consider setting `programs.ssh.enableDefaultConfig` to false,
and manually set the default values you want to keep at
`programs.ssh.matchBlocks."*"`.
`programs.ssh.settings."*"`.
''
];

View File

@@ -6,7 +6,7 @@
`programs.ssh` default values will be removed in the future.
Consider setting `programs.ssh.enableDefaultConfig` to false,
and manually set the default values you want to keep at
`programs.ssh.matchBlocks."*"`.
`programs.ssh.settings."*"`.
''
];

View File

@@ -1,15 +1,11 @@
Host *
ForwardAgent yes
ServerAliveInterval 1
ServerAliveCountMax 2
Compression yes
AddKeysToAgent yes
HashKnownHosts yes
UserKnownHostsFile ~/.ssh/my_known_hosts
Compression yes
ControlMaster yes
ControlPath ~/.ssh/myfile-%r@%n:%p
ControlPersist 10m
ForwardAgent yes
HashKnownHosts yes
ServerAliveCountMax 2
ServerAliveInterval 1
UserKnownHostsFile ~/.ssh/my_known_hosts

View File

@@ -18,7 +18,19 @@
test.asserts.warnings.expected =
let
renamedOptions = [
renamedOptions = {
forwardAgent = "ForwardAgent";
addKeysToAgent = "AddKeysToAgent";
compression = "Compression";
serverAliveInterval = "ServerAliveInterval";
serverAliveCountMax = "ServerAliveCountMax";
hashKnownHosts = "HashKnownHosts";
userKnownHostsFile = "UserKnownHostsFile";
controlMaster = "ControlMaster";
controlPath = "ControlPath";
controlPersist = "ControlPersist";
};
renamedOptionOrder = [
"controlPersist"
"controlPath"
"controlMaster"
@@ -32,11 +44,14 @@
];
in
map (
o:
"The option `programs.ssh.${o}' defined in ${
lib.showFiles options.programs.ssh.${o}.files
} has been renamed to `programs.ssh.matchBlocks.*.${o}'."
) renamedOptions;
old:
let
new = renamedOptions.${old};
in
"The option `programs.ssh.${old}' defined in ${
lib.showFiles options.programs.ssh.${old}.files
} has been renamed to `programs.ssh.settings.*.${new}'."
) renamedOptionOrder;
nmt.script = ''
assertFileExists home-files/.ssh/config

View File

@@ -0,0 +1,31 @@
Host *.corp !bastion.corp
ProxyJump bastion-a.corp,bastion-b.corp
SendEnv LANG LC_*
User corp
Host github
CertificateFile ~/.ssh/github-cert.pub
CertificateFile ~/.ssh/github-alt-cert.pub
HostName github.com
IdentitiesOnly yes
IdentityAgent ~/.ssh/agent
IdentityAgent SSH_AUTH_SOCK
IdentityFile ~/.ssh/github
User git
Match host *.corp exec "test -f ~/.corp"
IgnoreUnknown PubkeyAcceptedAlgorithms,PubkeyAcceptedKeyTypes
CASignatureAlgorithms ssh-ed25519,rsa-sha2-512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
HostbasedAcceptedAlgorithms ssh-ed25519,rsa-sha2-512
HostbasedKeyTypes ssh-ed25519,rsa-sha2-512
KbdInteractiveDevices bsdauth,pam
KexAlgorithms sntrup761x25519-sha512,mlkem768x25519-sha256
LocalForward [localhost]:8080 [10.0.0.1]:80
LocalForward 9000 10.0.0.2:90
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
PreferredAuthentications publickey,keyboard-interactive
PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512
PubkeyAcceptedKeyTypes ssh-ed25519,rsa-sha2-512
SetEnv BAR="bar baz" BAZ="with \" some \\ very \\\" fun \\\\ escapes" FOO="foo"

View File

@@ -0,0 +1,17 @@
{ lib, options, ... }:
{
programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings.legacy.extraOptions = {
AddKeysToAgent = "yes";
HostName = "example.org";
};
};
test.asserts.assertions.expected = [
''
`programs.ssh.settings.*.extraOptions` defined in ${lib.showFiles options.programs.ssh.settings.files} is not supported. Move these OpenSSH options directly into `programs.ssh.settings.*` using upstream directive names.
''
];
}

View File

@@ -0,0 +1,4 @@
Host raw
DynamicForward 127.0.0.1:1080
LocalForward 9000 10.0.0.2:90
RemoteForward 9001 10.0.0.3:91

View File

@@ -0,0 +1,26 @@
{ config, lib, ... }:
{
config = {
programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings.raw = {
LocalForward = "9000 10.0.0.2:90";
RemoteForward = [ "9001 10.0.0.3:91" ];
DynamicForward = "127.0.0.1:1080";
};
};
home.file.assertions.text = builtins.toJSON (
map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)
);
nmt.script = ''
assertFileExists home-files/.ssh/config
assertFileContent \
home-files/.ssh/config \
${./settings-raw-forwards-expected.conf}
assertFileContent home-files/assertions ${./no-assertions.json}
'';
};
}

View File

@@ -0,0 +1,113 @@
{ config, lib, ... }:
{
config = {
programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings = {
github = {
HostName = "github.com";
User = "git";
IdentityFile = "~/.ssh/github";
IdentityAgent = [
"~/.ssh/agent"
"SSH_AUTH_SOCK"
];
CertificateFile = [
"~/.ssh/github-cert.pub"
"~/.ssh/github-alt-cert.pub"
];
IdentitiesOnly = true;
};
"Host *.corp !bastion.corp" = lib.hm.dag.entryBefore [ "github" ] {
User = "corp";
ProxyJump = [
"bastion-a.corp"
"bastion-b.corp"
];
SendEnv = [
"LANG"
"LC_*"
];
};
"Match host *.corp exec \"test -f ~/.corp\"" = lib.hm.dag.entryAfter [ "github" ] {
SetEnv = {
FOO = "foo";
BAR = "bar baz";
BAZ = ''with " some \ very \" fun \\ escapes'';
};
IgnoreUnknown = [
"PubkeyAcceptedAlgorithms"
"PubkeyAcceptedKeyTypes"
];
Ciphers = [
"chacha20-poly1305@openssh.com"
"aes256-gcm@openssh.com"
];
CASignatureAlgorithms = [
"ssh-ed25519"
"rsa-sha2-512"
];
HostbasedAcceptedAlgorithms = [
"ssh-ed25519"
"rsa-sha2-512"
];
HostbasedKeyTypes = [
"ssh-ed25519"
"rsa-sha2-512"
];
HostKeyAlgorithms = [
"ssh-ed25519"
"rsa-sha2-512"
];
KbdInteractiveDevices = [
"bsdauth"
"pam"
];
KexAlgorithms = [
"sntrup761x25519-sha512"
"mlkem768x25519-sha256"
];
MACs = [
"hmac-sha2-256-etm@openssh.com"
"hmac-sha2-512-etm@openssh.com"
];
PreferredAuthentications = [
"publickey"
"keyboard-interactive"
];
PubkeyAcceptedAlgorithms = [
"ssh-ed25519"
"rsa-sha2-512"
];
PubkeyAcceptedKeyTypes = [
"ssh-ed25519"
"rsa-sha2-512"
];
LocalForward = [
{
bind.port = 8080;
host.address = "10.0.0.1";
host.port = 80;
}
"9000 10.0.0.2:90"
];
};
};
};
home.file.assertions.text = builtins.toJSON (
map (a: a.message) (lib.filter (a: !a.assertion) config.assertions)
);
nmt.script = ''
assertFileExists home-files/.ssh/config
assertFileContent \
home-files/.ssh/config \
${./settings-expected.conf}
assertFileContent home-files/assertions ${./no-assertions.json}
'';
};
}