nixos/sshd: add generateHostKeys setting (#418136)

This commit is contained in:
tomf
2025-12-05 11:26:10 +00:00
committed by GitHub
3 changed files with 254 additions and 210 deletions
@@ -22,4 +22,4 @@
<!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
- Create the first release note entry in this section!
- `services.openssh` now supports generating host SSH keys by setting `services.openssh.generateHostKeys = true` while leaving `services.openssh.enable` disabled. This is particularly useful for systems that have no need of an SSH daemon but want SSH host keys for other purposes such as using agenix or sops-nix.
+227 -209
View File
@@ -381,6 +381,19 @@ in
'';
};
generateHostKeys = lib.mkOption {
type = lib.types.bool;
default = config.services.openssh.enable;
defaultText = lib.literalExpression "services.openssh.enable";
description = ''
Whether to generate SSH host keys.
This can be enabled explicitly if you want to generate host keys but
don't want to enable the SSH daemon.
'';
example = true;
};
banner = lib.mkOption {
type = lib.types.nullOr lib.types.lines;
default = null;
@@ -669,115 +682,232 @@ in
###### implementation
config = lib.mkIf cfg.enable {
config = lib.mkMerge [
(lib.mkIf cfg.enable {
users.users.sshd = {
isSystemUser = true;
group = "sshd";
description = "SSH privilege separation user";
};
users.groups.sshd = { };
services.openssh.moduliFile = lib.mkDefault "${cfg.package}/etc/ssh/moduli";
services.openssh.sftpServerExecutable = lib.mkDefault "${cfg.package}/libexec/sftp-server";
environment.etc =
authKeysFiles
// authPrincipalsFiles
// {
"ssh/moduli".source = cfg.moduliFile;
"ssh/sshd_config".source = sshconf;
users.users.sshd = {
isSystemUser = true;
group = "sshd";
description = "SSH privilege separation user";
};
users.groups.sshd = { };
systemd.tmpfiles.settings."ssh-root-provision" = {
"/root"."d-" = {
user = "root";
group = ":root";
mode = ":700";
};
"/root/.ssh"."d-" = {
user = "root";
group = ":root";
mode = ":700";
};
"/root/.ssh/authorized_keys"."f^" = {
user = "root";
group = ":root";
mode = ":600";
argument = "ssh.authorized_keys.root";
};
};
services.openssh.moduliFile = lib.mkDefault "${cfg.package}/etc/ssh/moduli";
services.openssh.sftpServerExecutable = lib.mkDefault "${cfg.package}/libexec/sftp-server";
systemd = {
sockets.sshd = lib.mkIf cfg.startWhenNeeded {
description = "SSH Socket";
wantedBy = [ "sockets.target" ];
socketConfig.ListenStream =
if cfg.listenAddresses != [ ] then
lib.concatMap (
{ addr, port }:
if port != null then [ "${addr}:${toString port}" ] else map (p: "${addr}:${toString p}") cfg.ports
) cfg.listenAddresses
else
cfg.ports;
socketConfig.Accept = true;
# Prevent brute-force attacks from shutting down socket
socketConfig.TriggerLimitIntervalSec = 0;
};
environment.etc =
authKeysFiles
// authPrincipalsFiles
// {
"ssh/moduli".source = cfg.moduliFile;
"ssh/sshd_config".source = sshconf;
};
services."sshd@" = {
description = "SSH per-connection Daemon";
after = [
"network.target"
"sshd-keygen.service"
];
wants = [ "sshd-keygen.service" ];
stopIfChanged = false;
path = [ cfg.package ];
environment.LD_LIBRARY_PATH = nssModulesPath;
serviceConfig = {
ExecStart = lib.concatStringsSep " " [
"-${lib.getExe' cfg.package "sshd"}"
"-i"
"-D"
"-f /etc/ssh/sshd_config"
];
KillMode = "process";
StandardInput = "socket";
StandardError = "journal";
systemd.tmpfiles.settings."ssh-root-provision" = {
"/root"."d-" = {
user = "root";
group = ":root";
mode = ":700";
};
"/root/.ssh"."d-" = {
user = "root";
group = ":root";
mode = ":700";
};
"/root/.ssh/authorized_keys"."f^" = {
user = "root";
group = ":root";
mode = ":600";
argument = "ssh.authorized_keys.root";
};
};
services.sshd = lib.mkIf (!cfg.startWhenNeeded) {
description = "SSH Daemon";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"sshd-keygen.service"
];
wants = [ "sshd-keygen.service" ];
stopIfChanged = false;
path = [ cfg.package ];
environment.LD_LIBRARY_PATH = nssModulesPath;
systemd = {
sockets.sshd = lib.mkIf cfg.startWhenNeeded {
description = "SSH Socket";
wantedBy = [ "sockets.target" ];
socketConfig.ListenStream =
if cfg.listenAddresses != [ ] then
lib.concatMap (
{ addr, port }:
if port != null then [ "${addr}:${toString port}" ] else map (p: "${addr}:${toString p}") cfg.ports
) cfg.listenAddresses
else
cfg.ports;
socketConfig.Accept = true;
# Prevent brute-force attacks from shutting down socket
socketConfig.TriggerLimitIntervalSec = 0;
};
restartTriggers = [ config.environment.etc."ssh/sshd_config".source ];
serviceConfig = {
Type = "notify-reload";
Restart = "always";
ExecStart = lib.concatStringsSep " " [
(lib.getExe' cfg.package "sshd")
"-D"
"-f"
"/etc/ssh/sshd_config"
services."sshd@" = {
description = "SSH per-connection Daemon";
after = [
"network.target"
"sshd-keygen.service"
];
KillMode = "process";
wants = lib.mkIf cfg.generateHostKeys [ "sshd-keygen.service" ];
stopIfChanged = false;
path = [ cfg.package ];
environment.LD_LIBRARY_PATH = nssModulesPath;
serviceConfig = {
ExecStart = lib.concatStringsSep " " [
"-${lib.getExe' cfg.package "sshd"}"
"-i"
"-D"
"-f /etc/ssh/sshd_config"
];
KillMode = "process";
StandardInput = "socket";
StandardError = "journal";
};
};
services.sshd = lib.mkIf (!cfg.startWhenNeeded) {
description = "SSH Daemon";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"sshd-keygen.service"
];
wants = lib.mkIf cfg.generateHostKeys [ "sshd-keygen.service" ];
stopIfChanged = false;
path = [ cfg.package ];
environment.LD_LIBRARY_PATH = nssModulesPath;
restartTriggers = [ config.environment.etc."ssh/sshd_config".source ];
serviceConfig = {
Type = "notify-reload";
Restart = "always";
ExecStart = lib.concatStringsSep " " [
(lib.getExe' cfg.package "sshd")
"-D"
"-f"
"/etc/ssh/sshd_config"
];
KillMode = "process";
};
};
};
services.sshd-keygen = {
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall cfg.ports;
security.pam.services.sshd = lib.mkIf cfg.settings.UsePAM {
startSession = true;
showMotd = true;
unixAuth = if cfg.settings.PasswordAuthentication == true then true else false;
};
# These values are merged with the ones defined externally, see:
# https://github.com/NixOS/nixpkgs/pull/10155
# https://github.com/NixOS/nixpkgs/pull/41745
services.openssh.authorizedKeysFiles =
lib.optional cfg.authorizedKeysInHomedir "%h/.ssh/authorized_keys"
++ [ "/etc/ssh/authorized_keys.d/%u" ];
services.openssh.settings.AuthorizedPrincipalsFile = lib.mkIf (
authPrincipalsFiles != { }
) "/etc/ssh/authorized_principals.d/%u";
services.openssh.extraConfig = lib.mkOrder 0 (
lib.concatStringsSep "\n" (
[
"Banner ${if cfg.banner == null then "none" else pkgs.writeText "ssh_banner" cfg.banner}"
"AddressFamily ${if config.networking.enableIPv6 then "any" else "inet"}"
]
++ lib.map (port: ''Port ${toString port}'') cfg.ports
++ lib.map (
{ port, addr, ... }:
''ListenAddress ${addr}${lib.optionalString (port != null) (":" + toString port)}''
) cfg.listenAddresses
++ lib.optional cfgc.setXAuthLocation "XAuthLocation ${lib.getExe pkgs.xorg.xauth}"
++ lib.optional cfg.allowSFTP ''Subsystem sftp ${cfg.sftpServerExecutable} ${lib.concatStringsSep " " cfg.sftpFlags}''
++ [
"AuthorizedKeysFile ${toString cfg.authorizedKeysFiles}"
]
++ lib.optional (cfg.authorizedKeysCommand != "none") ''
AuthorizedKeysCommand ${cfg.authorizedKeysCommand}
AuthorizedKeysCommandUser ${cfg.authorizedKeysCommandUser}
''
++ lib.map (k: "HostKey ${k.path}") cfg.hostKeys
)
);
system.checks = [
(pkgs.runCommand "check-sshd-config"
{
nativeBuildInputs = [ validationPackage ];
}
''
${lib.concatMapStringsSep "\n" (
lport: "sshd -G -T -C lport=${toString lport} -f ${sshconf} > /dev/null"
) cfg.ports}
${lib.concatMapStringsSep "\n" (
la:
lib.concatMapStringsSep "\n" (
port:
"sshd -G -T -C ${lib.escapeShellArg "laddr=${la.addr},lport=${toString port}"} -f ${sshconf} > /dev/null"
) (if la.port != null then [ la.port ] else cfg.ports)
) cfg.listenAddresses}
touch $out
''
)
];
assertions = [
{
assertion = if cfg.settings.X11Forwarding then cfgc.setXAuthLocation else true;
message = "cannot enable X11 forwarding without setting xauth location";
}
{
assertion =
(builtins.match "(.*\n)?(\t )*[Kk][Ee][Rr][Bb][Ee][Rr][Oo][Ss][Aa][Uu][Tt][Hh][Ee][Nn][Tt][Ii][Cc][Aa][Tt][Ii][Oo][Nn][ |\t|=|\"]+yes.*" "${configFile}\n${cfg.extraConfig}")
!= null
-> cfgc.package.withKerberos;
message = "cannot enable Kerberos authentication without using a package with Kerberos support";
}
{
assertion =
(builtins.match "(.*\n)?(\t )*[Gg][Ss][Ss][Aa][Pp][Ii][Aa][Uu][Tt][Hh][Ee][Nn][Tt][Ii][Cc][Aa][Tt][Ii][Oo][Nn][ |\t|=|\"]+yes.*" "${configFile}\n${cfg.extraConfig}")
!= null
-> cfgc.package.withKerberos;
message = "cannot enable GSSAPI authentication without using a package with Kerberos support";
}
(
let
duplicates =
# Filter out the groups with more than 1 element
lib.filter (l: lib.length l > 1) (
# Grab the groups, we don't care about the group identifiers
lib.attrValues (
# Group the settings that are the same in lower case
lib.groupBy lib.strings.toLower (lib.attrNames cfg.settings)
)
);
formattedDuplicates = lib.concatMapStringsSep ", " (
dupl: "(${lib.concatStringsSep ", " dupl})"
) duplicates;
in
{
assertion = lib.length duplicates == 0;
message = ''Duplicate sshd config key; does your capitalization match the option's? Duplicate keys: ${formattedDuplicates}'';
}
)
]
++ lib.forEach cfg.listenAddresses (
{ addr, ... }:
{
assertion = addr != null;
message = "addr must be specified in each listenAddresses entry";
}
);
})
(lib.mkIf cfg.generateHostKeys {
systemd.services.sshd-keygen = {
description = "SSH Host Keys Generation";
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionFileNotEmpty = map (k: "|!${k.path}") cfg.hostKeys;
};
@@ -802,119 +932,7 @@ in
fi
'');
};
};
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall cfg.ports;
security.pam.services.sshd = lib.mkIf cfg.settings.UsePAM {
startSession = true;
showMotd = true;
unixAuth = if cfg.settings.PasswordAuthentication == true then true else false;
};
# These values are merged with the ones defined externally, see:
# https://github.com/NixOS/nixpkgs/pull/10155
# https://github.com/NixOS/nixpkgs/pull/41745
services.openssh.authorizedKeysFiles =
lib.optional cfg.authorizedKeysInHomedir "%h/.ssh/authorized_keys"
++ [ "/etc/ssh/authorized_keys.d/%u" ];
services.openssh.settings.AuthorizedPrincipalsFile = lib.mkIf (
authPrincipalsFiles != { }
) "/etc/ssh/authorized_principals.d/%u";
services.openssh.extraConfig = lib.mkOrder 0 (
lib.concatStringsSep "\n" (
[
"Banner ${if cfg.banner == null then "none" else pkgs.writeText "ssh_banner" cfg.banner}"
"AddressFamily ${if config.networking.enableIPv6 then "any" else "inet"}"
]
++ lib.map (port: ''Port ${toString port}'') cfg.ports
++ lib.map (
{ port, addr, ... }:
''ListenAddress ${addr}${lib.optionalString (port != null) (":" + toString port)}''
) cfg.listenAddresses
++ lib.optional cfgc.setXAuthLocation "XAuthLocation ${lib.getExe pkgs.xorg.xauth}"
++ lib.optional cfg.allowSFTP ''Subsystem sftp ${cfg.sftpServerExecutable} ${lib.concatStringsSep " " cfg.sftpFlags}''
++ [
"AuthorizedKeysFile ${toString cfg.authorizedKeysFiles}"
]
++ lib.optional (cfg.authorizedKeysCommand != "none") ''
AuthorizedKeysCommand ${cfg.authorizedKeysCommand}
AuthorizedKeysCommandUser ${cfg.authorizedKeysCommandUser}
''
++ lib.map (k: "HostKey ${k.path}") cfg.hostKeys
)
);
system.checks = [
(pkgs.runCommand "check-sshd-config"
{
nativeBuildInputs = [ validationPackage ];
}
''
${lib.concatMapStringsSep "\n" (
lport: "sshd -G -T -C lport=${toString lport} -f ${sshconf} > /dev/null"
) cfg.ports}
${lib.concatMapStringsSep "\n" (
la:
lib.concatMapStringsSep "\n" (
port:
"sshd -G -T -C ${lib.escapeShellArg "laddr=${la.addr},lport=${toString port}"} -f ${sshconf} > /dev/null"
) (if la.port != null then [ la.port ] else cfg.ports)
) cfg.listenAddresses}
touch $out
''
)
];
assertions = [
{
assertion = if cfg.settings.X11Forwarding then cfgc.setXAuthLocation else true;
message = "cannot enable X11 forwarding without setting xauth location";
}
{
assertion =
(builtins.match "(.*\n)?(\t )*[Kk][Ee][Rr][Bb][Ee][Rr][Oo][Ss][Aa][Uu][Tt][Hh][Ee][Nn][Tt][Ii][Cc][Aa][Tt][Ii][Oo][Nn][ |\t|=|\"]+yes.*" "${configFile}\n${cfg.extraConfig}")
!= null
-> cfgc.package.withKerberos;
message = "cannot enable Kerberos authentication without using a package with Kerberos support";
}
{
assertion =
(builtins.match "(.*\n)?(\t )*[Gg][Ss][Ss][Aa][Pp][Ii][Aa][Uu][Tt][Hh][Ee][Nn][Tt][Ii][Cc][Aa][Tt][Ii][Oo][Nn][ |\t|=|\"]+yes.*" "${configFile}\n${cfg.extraConfig}")
!= null
-> cfgc.package.withKerberos;
message = "cannot enable GSSAPI authentication without using a package with Kerberos support";
}
(
let
duplicates =
# Filter out the groups with more than 1 element
lib.filter (l: lib.length l > 1) (
# Grab the groups, we don't care about the group identifiers
lib.attrValues (
# Group the settings that are the same in lower case
lib.groupBy lib.strings.toLower (lib.attrNames cfg.settings)
)
);
formattedDuplicates = lib.concatMapStringsSep ", " (
dupl: "(${lib.concatStringsSep ", " dupl})"
) duplicates;
in
{
assertion = lib.length duplicates == 0;
message = ''Duplicate sshd config key; does your capitalization match the option's? Duplicate keys: ${formattedDuplicates}'';
}
)
]
++ lib.forEach cfg.listenAddresses (
{ addr, ... }:
{
assertion = addr != null;
message = "addr must be specified in each listenAddresses entry";
}
);
};
})
];
}
+26
View File
@@ -250,6 +250,15 @@ in
};
};
server-no-sshd-with-key =
{ pkgs, ... }:
{
services.openssh.generateHostKeys = true;
users.users.root.openssh.authorizedKeys.keys = [
snakeOilPublicKey
];
};
client =
{ ... }:
{
@@ -276,6 +285,10 @@ in
server_localhost_only_lazy.wait_for_unit("sshd.socket", timeout=30)
server_lazy_socket.wait_for_unit("sshd.socket", timeout=30)
# sshd-keygen is a oneshot unit, so just wait for multi-user.target, which
# pulls it in.
server_no_sshd_with_key.wait_for_unit("multi-user.target", timeout=30)
with subtest("manual-authkey"):
client.succeed(
'${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""'
@@ -408,6 +421,19 @@ in
server_sftp.wait_for_file("/srv/sftp/uploads/test-file")
with subtest("keygen without sshd"):
client.fail(
"ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -i privkey.snakeoil root@server-no-sshd-with-key true",
timeout=30
)
server_no_sshd_with_key.succeed("test -e /etc/ssh/ssh_host_ed25519_key")
server_no_sshd_with_key.succeed("test -e /etc/ssh/ssh_host_ed25519_key.pub")
server_no_sshd_with_key.fail("pgrep sshd")
# Validate the above check for sshd using pgrep does pass on a server
# that should have sshd running, just to prove it's a useful test.
server.succeed("pgrep sshd")
# None of the per-connection units should have failed.
server_lazy.fail("systemctl is-failed 'sshd@*.service'")
'';