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

@@ -4,7 +4,7 @@ Overall the basic option types are the same in Home Manager as NixOS. A
few Home Manager options, however, make use of custom types that are
worth describing in more detail. These are the option types `dagOf` and
`gvariant` that are used, for example, by
[programs.ssh.matchBlocks](#opt-programs.ssh.matchBlocks) and [dconf.settings](#opt-dconf.settings).
[programs.ssh.settings](#opt-programs.ssh.settings) and [dconf.settings](#opt-dconf.settings).
[]{#sec-option-types-dag}`hm.types.dagOf`

View File

@@ -71,7 +71,7 @@ This release has the following notable changes:
no longer packages compton, and instead packages the (mostly)
compatible fork called picom.
- The list form of the [programs.ssh.matchBlocks](#opt-programs.ssh.matchBlocks) option has
- The list form of the `programs.ssh.matchBlocks` option has
been deprecated and configurations requiring match blocks in a
defined order should switch to using DAG entries instead. For
example, a configuration

View File

@@ -0,0 +1,10 @@
{ config, ... }:
{
time = "2026-05-15T18:12:18+00:00";
condition = config.programs.ssh.enable;
message = ''
`programs.ssh` now supports RFC 42-style configuration through
`programs.ssh.settings`. The existing `programs.ssh.matchBlocks`
option is deprecated and automatically migrated to the new option.
'';
}

View File

@@ -1,6 +1,7 @@
{
config,
lib,
options,
pkgs,
...
}:
@@ -11,6 +12,7 @@ let
mapAttrsToList
mkOption
optional
optionalString
types
;
@@ -20,13 +22,29 @@ let
addressPort =
entry:
if isPath entry.address then " ${entry.address}" else " [${entry.address}]:${toString entry.port}";
unwords = builtins.concatStringsSep " ";
let
address = entry.address or "localhost";
port = entry.port or null;
in
(lib.findFirst (candidate: candidate.when)
{
out = " [${address}]:${toString port}";
}
[
{
when = address == null;
out = " ${toString port}";
}
{
when = address != null && isPath address;
out = " ${address}";
}
]
).out;
mkSetEnvStr =
envStr:
unwords (
builtins.concatStringsSep " " (
mapAttrsToList (name: value: ''${name}="${lib.escape [ ''"'' "\\" ] (toString value)}"'') envStr
);
@@ -320,7 +338,11 @@ let
extraOptions = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Extra configuration options for the host.";
visible = false;
description = ''
Deprecated extra configuration options for this host. Use
{option}`programs.ssh.settings` instead.
'';
};
addKeysToAgent = mkOption {
@@ -398,62 +420,148 @@ let
# config.host = mkDefault dagName;
};
renderValue = value: if lib.isBool value then lib.hm.booleans.yesNo value else toString value;
renderValues = sep: values: concatStringsSep sep (map renderValue (lib.toList values));
renderForward =
f: if lib.isAttrs f then addressPort f.bind + addressPort f.host else " ${renderValue f}";
renderDynamicForward = f: if lib.isAttrs f then addressPort f else " ${renderValue f}";
renderDuplicateDirective =
indent: name: renderItem: values:
concatStringsSep "\n" (map (value: "${indent}${name}${renderItem value}") (lib.toList values));
# Per ssh_config(5), the first obtained value for most parameters wins.
# These directives take comma/space-separated lists, so rendering Nix lists as
# duplicate directives can silently ignore values after the first line.
# Reference: https://man.openbsd.org/ssh_config
commaListDirectives = [
"CASignatureAlgorithms"
"Ciphers"
"HostbasedAcceptedAlgorithms"
"HostbasedKeyTypes"
"HostKeyAlgorithms"
"IgnoreUnknown"
"KbdInteractiveDevices"
"KexAlgorithms"
"MACs"
"PreferredAuthentications"
"ProxyJump"
"PubkeyAcceptedAlgorithms"
"PubkeyAcceptedKeyTypes"
];
spaceListDirectives = [
"CanonicalDomains"
"CanonicalizePermittedCNAMEs"
"ChannelTimeout"
"GlobalKnownHostsFile"
"PermitRemoteOpen"
"SendEnv"
"UserKnownHostsFile"
];
directiveRenderers =
indent:
lib.genAttrs commaListDirectives (name: value: "${indent}${name} ${renderValues "," value}")
// lib.genAttrs spaceListDirectives (name: value: "${indent}${name} ${renderValues " " value}")
// {
SetEnv = value: "${indent}SetEnv ${mkSetEnvStr value}";
LocalForward = renderDuplicateDirective indent "LocalForward" renderForward;
RemoteForward = renderDuplicateDirective indent "RemoteForward" renderForward;
DynamicForward = renderDuplicateDirective indent "DynamicForward" renderDynamicForward;
};
sshDirectiveStrWithIndent =
indent: name: value:
let
renderDirective =
(directiveRenderers indent).${name}
or (values: renderDuplicateDirective indent name (v: " ${renderValue v}") values);
in
optionalString (value != null && value != [ ] && value != { }) (renderDirective value);
sshDirectiveStr = sshDirectiveStrWithIndent " ";
blockHeader =
name:
let
isLiteralHeader = lib.any (prefix: lib.hasPrefix prefix name) [
"Host "
"Match "
];
in
optionalString (!isLiteralHeader) "Host " + name;
matchBlockStr =
key: cf:
let
header = cf.__hmSshBlockHeader or (blockHeader key);
extraOptions = cf.__hmSshBlockExtraOptions or { };
settings = lib.removeAttrs cf [
"__hmSshBlockHeader"
"__hmSshBlockExtraOptions"
"extraOptions"
];
orderedNames =
# IgnoreUnknown only applies to unknown options that appear after it.
optional (builtins.hasAttr "IgnoreUnknown" settings) "IgnoreUnknown"
++ builtins.filter (name: name != "IgnoreUnknown") (lib.attrNames settings);
in
concatStringsSep "\n" (
let
hostOrDagName = if cf.host != null then cf.host else key;
matchHead = if cf.match != null then "Match ${cf.match}" else "Host ${hostOrDagName}";
in
[ "${matchHead}" ]
++ optional (cf.port != null) " Port ${toString cf.port}"
++ optional (cf.forwardAgent != null) " ForwardAgent ${lib.hm.booleans.yesNo cf.forwardAgent}"
++ optional cf.forwardX11 " ForwardX11 yes"
++ optional cf.forwardX11Trusted " ForwardX11Trusted yes"
++ optional (
cf.identitiesOnly != null
) " IdentitiesOnly ${lib.hm.booleans.yesNo cf.identitiesOnly}"
++ optional (cf.user != null) " User ${cf.user}"
++ optional (cf.hostname != null) " HostName ${cf.hostname}"
++ optional (cf.addressFamily != null) " AddressFamily ${cf.addressFamily}"
++ optional (cf.sendEnv != [ ]) " SendEnv ${unwords cf.sendEnv}"
++ optional (cf.setEnv != { }) " SetEnv ${mkSetEnvStr cf.setEnv}"
++ optional (
cf.serverAliveInterval != null
) " ServerAliveInterval ${toString cf.serverAliveInterval}"
++ optional (
cf.serverAliveCountMax != null
) " ServerAliveCountMax ${toString cf.serverAliveCountMax}"
++ optional (cf.compression != null) " Compression ${lib.hm.booleans.yesNo cf.compression}"
++ optional (!cf.checkHostIP) " CheckHostIP no"
++ optional (cf.proxyCommand != null) " ProxyCommand ${cf.proxyCommand}"
++ optional (cf.proxyJump != null) " ProxyJump ${cf.proxyJump}"
++ optional (cf.addKeysToAgent != null) " AddKeysToAgent ${cf.addKeysToAgent}"
++ optional (
cf.hashKnownHosts != null
) " HashKnownHosts ${lib.hm.booleans.yesNo cf.hashKnownHosts}"
++ optional (cf.userKnownHostsFile != null) " UserKnownHostsFile ${cf.userKnownHostsFile}"
++ optional (cf.controlMaster != null) " ControlMaster ${cf.controlMaster}"
++ optional (cf.controlPath != null) " ControlPath ${cf.controlPath}"
++ optional (cf.controlPersist != null) " ControlPersist ${cf.controlPersist}"
++ map (file: " IdentityFile ${file}") cf.identityFile
++ map (file: " IdentityAgent ${file}") cf.identityAgent
++ map (file: " CertificateFile ${file}") cf.certificateFile
++ map (f: " LocalForward" + addressPort f.bind + addressPort f.host) cf.localForwards
++ map (f: " RemoteForward" + addressPort f.bind + addressPort f.host) cf.remoteForwards
++ map (f: " DynamicForward" + addressPort f) cf.dynamicForwards
++ optional (
cf.kexAlgorithms != null
) " KexAlgorithms ${builtins.concatStringsSep "," cf.kexAlgorithms}"
++ [
(lib.generators.toKeyValue {
[ header ]
++ lib.filter (line: line != "") (map (name: sshDirectiveStr name settings.${name}) orderedNames)
++ optional (extraOptions != { }) (
lib.generators.toKeyValue {
mkKeyValue = lib.generators.mkKeyValueDefault { } " ";
listsAsDuplicateKeys = true;
indent = " ";
} cf.extraOptions)
]
} extraOptions
)
);
legacyBlockSettings =
cf:
let
headers =
optional (cf.match != null) "Match ${cf.match}" ++ optional (cf.host != null) "Host ${cf.host}";
in
lib.filterAttrs (_: v: v != null && v != [ ] && v != { }) {
__hmSshBlockHeader = lib.head (headers ++ [ null ]);
Port = cf.port;
ForwardAgent = cf.forwardAgent;
ForwardX11 = if cf.forwardX11 then true else null;
ForwardX11Trusted = if cf.forwardX11Trusted then true else null;
IdentitiesOnly = cf.identitiesOnly;
IdentityFile = cf.identityFile;
IdentityAgent = cf.identityAgent;
User = cf.user;
HostName = cf.hostname;
ServerAliveInterval = cf.serverAliveInterval;
ServerAliveCountMax = cf.serverAliveCountMax;
SendEnv = cf.sendEnv;
SetEnv = cf.setEnv;
Compression = cf.compression;
CheckHostIP = if cf.checkHostIP then null else false;
ProxyCommand = cf.proxyCommand;
ProxyJump = cf.proxyJump;
CertificateFile = cf.certificateFile;
AddressFamily = cf.addressFamily;
LocalForward = cf.localForwards;
RemoteForward = cf.remoteForwards;
DynamicForward = cf.dynamicForwards;
AddKeysToAgent = cf.addKeysToAgent;
HashKnownHosts = cf.hashKnownHosts;
UserKnownHostsFile = cf.userKnownHostsFile;
ControlMaster = cf.controlMaster;
ControlPath = cf.controlPath;
ControlPersist = cf.controlPersist;
KexAlgorithms = cf.kexAlgorithms;
__hmSshBlockExtraOptions = cf.extraOptions;
};
in
{
meta.maintainers = [ lib.maintainers.rycee ];
@@ -467,7 +575,7 @@ in
newPrefix = [
"programs"
"ssh"
"matchBlocks"
"settings"
"*"
];
renamedOptions = [
@@ -482,9 +590,21 @@ in
"controlPath"
"controlPersist"
];
oldOptionNameToSetting = {
forwardAgent = "ForwardAgent";
addKeysToAgent = "AddKeysToAgent";
compression = "Compression";
serverAliveInterval = "ServerAliveInterval";
serverAliveCountMax = "ServerAliveCountMax";
hashKnownHosts = "HashKnownHosts";
userKnownHostsFile = "UserKnownHostsFile";
controlMaster = "ControlMaster";
controlPath = "ControlPath";
controlPersist = "ControlPersist";
};
in
lib.hm.deprecations.mkSettingsRenamedOptionModules oldPrefix newPrefix {
transform = x: x;
transform = x: oldOptionNameToSetting.${x};
} renamedOptions;
options.programs.ssh = {
@@ -505,7 +625,7 @@ in
};
extraOptionOverrides = mkOption {
type = types.attrsOf types.str;
type = types.attrsOf types.anything;
default = { };
description = ''
Extra SSH configuration options that take precedence over any
@@ -526,9 +646,64 @@ in
'';
};
settings = mkOption {
type = lib.hm.types.dagOf (
types.submodule {
freeformType = types.attrsOf types.anything;
}
);
default = { };
example = literalExpression ''
{
"github.com" = {
HostName = "github.com";
User = "git";
IdentityFile = "~/.ssh/github";
};
"Host *.example.org" = lib.hm.dag.entryBefore [ "github.com" ] {
IdentityFile = "~/.ssh/example";
LocalForward = [
{
bind.port = 8080;
host.address = "10.0.0.13";
host.port = 80;
}
"9000 10.0.0.2:90"
];
DynamicForward = "127.0.0.1:1080";
};
"Match host *.corp exec \"test -f ~/.corp\"" = {
ProxyJump = "bastion";
RemoteForward = {
bind.port = 8081;
host.address = "10.0.0.14";
host.port = 80;
};
};
}
'';
description = ''
OpenSSH client configuration blocks written to
{file}`~/.ssh/config`.
Attribute names are interpreted as `Host` patterns unless they
start with `Host ` or `Match `, in which case they are written
literally as block headers. If the order of rules matter then
use the DAG functions to express the dependencies as shown in
the example.
See
{manpage}`ssh_config(5)`
for more information.
'';
};
matchBlocks = mkOption {
type = lib.hm.types.dagOf matchBlockModule;
default = { };
visible = false;
example = literalExpression ''
{
"john.example.com" = {
@@ -542,13 +717,7 @@ in
};
'';
description = ''
Specify per-host settings. Note, if the order of rules matter
then use the DAG functions to express the dependencies as
shown in the example.
See
{manpage}`ssh_config(5)`
for more information.
Deprecated alias for {option}`programs.ssh.settings`.
'';
};
@@ -562,17 +731,17 @@ in
For an equivalent, copy and paste the following
code snippet in your config:
programs.ssh.matchBlocks."*" = {
forwardAgent = false;
addKeysToAgent = "no";
compression = false;
serverAliveInterval = 0;
serverAliveCountMax = 3;
hashKnownHosts = false;
userKnownHostsFile = "~/.ssh/known_hosts";
controlMaster = "no";
controlPath = "~/.ssh/master-%r@%n:%p";
controlPersist = "no";
programs.ssh.settings."*" = {
ForwardAgent = false;
AddKeysToAgent = "no";
Compression = false;
ServerAliveInterval = 0;
ServerAliveCountMax = 3;
HashKnownHosts = false;
UserKnownHostsFile = "~/.ssh/known_hosts";
ControlMaster = "no";
ControlPath = "~/.ssh/master-%r@%n:%p";
ControlPersist = "no";
};
'';
};
@@ -581,19 +750,32 @@ in
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
programs.ssh.settings = lib.mapAttrs (
_name: entry:
entry
// {
data = legacyBlockSettings entry.data;
}
) cfg.matchBlocks;
assertions = [
{
assertion =
let
# `builtins.any`/`lib.lists.any` does not return `true` if there are no elements.
any' = pred: items: if items == [ ] then true else lib.any pred items;
# Check that if `entry.address` is defined, and is a path, that `entry.port` has not
# been defined.
noPathWithPort = entry: entry.address != null && isPath entry.address -> entry.port == null;
checkDynamic = block: any' noPathWithPort block.dynamicForwards;
noPathWithPort =
entry:
let
address = entry.address or null;
port = entry.port or null;
in
address != null && isPath address -> port == null;
checkForwardValues = pred: values: lib.all pred (lib.filter lib.isAttrs (lib.toList values));
checkDynamic = block: checkForwardValues noPathWithPort (block.DynamicForward or [ ]);
checkBindAndHost = fwd: noPathWithPort fwd.bind && noPathWithPort fwd.host;
checkLocal = block: any' checkBindAndHost block.localForwards;
checkRemote = block: any' checkBindAndHost block.remoteForwards;
checkLocal = block: checkForwardValues checkBindAndHost (block.LocalForward or [ ]);
checkRemote = block: checkForwardValues checkBindAndHost (block.RemoteForward or [ ]);
checkMatchBlock =
block:
lib.all (fn: fn block) [
@@ -602,12 +784,20 @@ in
checkDynamic
];
in
any' checkMatchBlock (map (block: block.data) (builtins.attrValues cfg.matchBlocks));
lib.all checkMatchBlock (map (block: block.data) (builtins.attrValues cfg.settings));
message = "Forwarded paths cannot have ports.";
}
{
assertion = (cfg.extraConfig != "") -> (cfg.matchBlocks ? "*");
message = ''Cannot set `programs.ssh.extraConfig` if `programs.ssh.matchBlocks."*"` (default host config) is not declared.'';
assertion = (cfg.extraConfig != "") -> (cfg.settings ? "*");
message = ''Cannot set `programs.ssh.extraConfig` if `programs.ssh.settings."*"` (default host config) is not declared.'';
}
{
assertion = lib.all (
block: !(builtins.hasAttr "extraOptions" block.data) || block.data.extraOptions == { }
) (builtins.attrValues cfg.settings);
message = ''
`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.
'';
}
];
@@ -615,32 +805,39 @@ in
home.file.".ssh/config".text =
let
sortedMatchBlocks = lib.hm.dag.topoSort (lib.removeAttrs cfg.matchBlocks [ "*" ]);
sortedMatchBlocks = lib.hm.dag.topoSort (lib.removeAttrs cfg.settings [ "*" ]);
sortedMatchBlocksStr = builtins.toJSON sortedMatchBlocks;
matchBlocks =
sortedMatchBlocks.result or (abort "Dependency cycle in SSH match blocks: ${sortedMatchBlocksStr}");
defaultHostBlock = cfg.matchBlocks."*" or null;
defaultHostBlock = cfg.settings."*" or null;
globalConfig =
(mapAttrsToList (sshDirectiveStrWithIndent "") cfg.extraOptionOverrides)
++ optional (cfg.includes != [ ]) "Include ${concatStringsSep " " cfg.includes}";
blockConfig =
(map (block: matchBlockStr block.name block.data) matchBlocks)
++ optional (defaultHostBlock != null) (matchBlockStr "*" defaultHostBlock.data);
extraConfig = optional (cfg.extraConfig != "") (
" " + lib.replaceStrings [ "\n" ] [ "\n " ] (lib.removeSuffix "\n" cfg.extraConfig)
);
sections =
optional (globalConfig != [ ]) (concatStringsSep "\n" globalConfig) ++ blockConfig ++ extraConfig;
in
''
${concatStringsSep "\n" (
(mapAttrsToList (n: v: "${n} ${v}") cfg.extraOptionOverrides)
++ (optional (cfg.includes != [ ]) ''
Include ${concatStringsSep " " cfg.includes}
'')
++ (map (block: matchBlockStr block.name block.data) matchBlocks)
)}
${if (defaultHostBlock != null) then (matchBlockStr "*" defaultHostBlock.data) else ""}
${lib.replaceStrings [ "\n" ] [ "\n " ] cfg.extraConfig}
'';
optionalString (sections != [ ]) (concatStringsSep "\n\n" sections + "\n");
warnings =
mapAttrsToList
(n: _v: ''
The SSH config match block `programs.ssh.matchBlocks.${n}` sets both of the host and match options.
The match option takes precedence.'')
(lib.filterAttrs (_n: v: v.data.host != null && v.data.match != null) cfg.matchBlocks);
optional (cfg.matchBlocks != { }) ''
`programs.ssh.matchBlocks` defined in ${lib.showFiles options.programs.ssh.matchBlocks.files} is deprecated. Use `programs.ssh.settings`.
''
++
mapAttrsToList
(n: _v: ''
The SSH config match block `programs.ssh.matchBlocks.${n}` sets both of the host and match options.
The match option takes precedence.'')
(lib.filterAttrs (_n: v: v.data.host != null && v.data.match != null) cfg.matchBlocks)
++ mapAttrsToList (n: _v: ''
`programs.ssh.matchBlocks.${n}.extraOptions` defined in ${lib.showFiles options.programs.ssh.matchBlocks.files} is deprecated. Move these OpenSSH options to `programs.ssh.settings.${n}` using upstream directive names.
'') (lib.filterAttrs (_n: v: v.data.extraOptions != { }) cfg.matchBlocks);
}
(lib.mkIf cfg.enableDefaultConfig {
warnings = [
@@ -648,21 +845,21 @@ in
`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."*"`.
''
];
programs.ssh.matchBlocks."*" = {
forwardAgent = lib.mkDefault false;
addKeysToAgent = lib.mkDefault "no";
compression = lib.mkDefault false;
serverAliveInterval = lib.mkDefault 0;
serverAliveCountMax = lib.mkDefault 3;
hashKnownHosts = lib.mkDefault false;
userKnownHostsFile = lib.mkDefault "~/.ssh/known_hosts";
controlMaster = lib.mkDefault "no";
controlPath = lib.mkDefault "~/.ssh/master-%r@%n:%p";
controlPersist = lib.mkDefault "no";
programs.ssh.settings."*" = {
ForwardAgent = lib.mkDefault false;
AddKeysToAgent = lib.mkDefault "no";
Compression = lib.mkDefault false;
ServerAliveInterval = lib.mkDefault 0;
ServerAliveCountMax = lib.mkDefault 3;
HashKnownHosts = lib.mkDefault false;
UserKnownHostsFile = lib.mkDefault "~/.ssh/known_hosts";
ControlMaster = lib.mkDefault "no";
ControlPath = lib.mkDefault "~/.ssh/master-%r@%n:%p";
ControlPersist = lib.mkDefault "no";
};
})
]

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}
'';
};
}