From 375fc85aea9a6328e759bb85729ec7b241b1a236 Mon Sep 17 00:00:00 2001 From: Adam Dinwoodie Date: Thu, 19 Jun 2025 11:44:36 +0100 Subject: [PATCH] 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. --- .../manual/release-notes/rl-2605.section.md | 2 +- .../modules/services/networking/ssh/sshd.nix | 436 +++++++++--------- nixos/tests/openssh.nix | 26 ++ 3 files changed, 254 insertions(+), 210 deletions(-) diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 0f7e00d6cb11..ce2cb43eef21 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -22,4 +22,4 @@ -- 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. diff --git a/nixos/modules/services/networking/ssh/sshd.nix b/nixos/modules/services/networking/ssh/sshd.nix index 1301c3648011..8c5c7de61fec 100644 --- a/nixos/modules/services/networking/ssh/sshd.nix +++ b/nixos/modules/services/networking/ssh/sshd.nix @@ -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"; - } - ); - }; + }) + ]; } diff --git a/nixos/tests/openssh.nix b/nixos/tests/openssh.nix index a59f49312c23..940db7139b32 100644 --- a/nixos/tests/openssh.nix +++ b/nixos/tests/openssh.nix @@ -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'") '';