nixos/sshd: add generateHostKeys setting

If a user doesn't want to enable the SSH daemon, but does want to have
SSH host keys configured for some other reason (e.g. they're used for
host identification in some other way), provide a `generateHostKeys`
setting that will generate the keys without otherwise setting up sshd.
This commit is contained in:
Adam Dinwoodie
2025-12-05 09:47:34 +00:00
parent ece6e266ca
commit 375fc85aea
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'")
'';