{ config, lib, pkgs, ... }: with lib; let # The splicing information needed for nativeBuildInputs isn't available # on the derivations likely to be used as `cfg.package`. # This middle-ground solution ensures *an* sshd can do their basic validation # on the configuration. validationPackage = if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then cfg.package else pkgs.buildPackages.openssh; # dont use the "=" operator settingsFormat = let # reports boolean as yes / no mkValueString = with lib; v: if isInt v then toString v else if isString v then v else if true == v then "yes" else if false == v then "no" else throw "unsupported type ${builtins.typeOf v}: ${(lib.generators.toPretty {}) v}"; base = pkgs.formats.keyValue { mkKeyValue = lib.generators.mkKeyValueDefault { inherit mkValueString; } " "; }; # OpenSSH is very inconsistent with options that can take multiple values. # For some of them, they can simply appear multiple times and are appended, for others the # values must be separated by whitespace or even commas. # Consult either sshd_config(5) or, as last resort, the OpehSSH source for parsing # the options at servconf.c:process_server_config_line_depth() to determine the right "mode" # for each. But fortunaly this fact is documented for most of them in the manpage. commaSeparated = [ "Ciphers" "KexAlgorithms" "Macs" ]; spaceSeparated = [ "AuthorizedKeysFile" "AllowGroups" "AllowUsers" "DenyGroups" "DenyUsers" ]; in { inherit (base) type; generate = name: value: let transformedValue = mapAttrs (key: val: if isList val then if elem key commaSeparated then concatStringsSep "," val else if elem key spaceSeparated then concatStringsSep " " val else throw "list value for unknown key ${key}: ${(lib.generators.toPretty {}) val}" else val ) value; in base.generate name transformedValue; }; configFile = settingsFormat.generate "sshd.conf-settings" (filterAttrs (n: v: v != null) cfg.settings); sshconf = pkgs.runCommand "sshd.conf-final" { } '' cat ${configFile} - >$out < and ''; }; Macs = mkOption { type = types.listOf types.str; default = [ "hmac-sha2-512-etm@openssh.com" "hmac-sha2-256-etm@openssh.com" "umac-128-etm@openssh.com" ]; description = '' Allowed MACs Defaults to recommended settings from both and ''; }; StrictModes = mkOption { type = types.bool; default = true; description = '' Whether sshd should check file modes and ownership of directories ''; }; Ciphers = mkOption { type = types.listOf types.str; default = [ "chacha20-poly1305@openssh.com" "aes256-gcm@openssh.com" "aes128-gcm@openssh.com" "aes256-ctr" "aes192-ctr" "aes128-ctr" ]; description = '' Allowed ciphers Defaults to recommended settings from both and ''; }; AllowUsers = mkOption { type = with types; nullOr (listOf str); default = null; description = '' If specified, login is allowed only for the listed users. See {manpage}`sshd_config(5)` for details. ''; }; DenyUsers = mkOption { type = with types; nullOr (listOf str); default = null; description = '' If specified, login is denied for all listed users. Takes precedence over [](#opt-services.openssh.settings.AllowUsers). See {manpage}`sshd_config(5)` for details. ''; }; AllowGroups = mkOption { type = with types; nullOr (listOf str); default = null; description = '' If specified, login is allowed only for users part of the listed groups. See {manpage}`sshd_config(5)` for details. ''; }; DenyGroups = mkOption { type = with types; nullOr (listOf str); default = null; description = '' If specified, login is denied for all users part of the listed groups. Takes precedence over [](#opt-services.openssh.settings.AllowGroups). See {manpage}`sshd_config(5)` for details. ''; }; # Disabled by default, since pam_motd handles this. PrintMotd = mkEnableOption "printing /etc/motd when a user logs in interactively"; }; }); }; extraConfig = mkOption { type = types.lines; default = ""; description = "Verbatim contents of {file}`sshd_config`."; }; moduliFile = mkOption { example = "/etc/my-local-ssh-moduli;"; type = types.path; description = '' Path to `moduli` file to install in `/etc/ssh/moduli`. If this option is unset, then the `moduli` file shipped with OpenSSH will be used. ''; }; }; users.users = mkOption { type = with types; attrsOf (submodule userOptions); }; }; ###### implementation config = mkIf cfg.enable { users.users.sshd = { isSystemUser = true; group = "sshd"; description = "SSH privilege separation user"; }; users.groups.sshd = {}; services.openssh.moduliFile = mkDefault "${cfg.package}/etc/ssh/moduli"; services.openssh.sftpServerExecutable = mkDefault "${cfg.package}/libexec/sftp-server"; environment.etc = authKeysFiles // authPrincipalsFiles // { "ssh/moduli".source = cfg.moduliFile; "ssh/sshd_config".source = sshconf; }; systemd = let service = { description = "SSH Daemon"; wantedBy = optional (!cfg.startWhenNeeded) "multi-user.target"; after = [ "network.target" ]; stopIfChanged = false; path = [ cfg.package pkgs.gawk ]; environment.LD_LIBRARY_PATH = nssModulesPath; restartTriggers = optionals (!cfg.startWhenNeeded) [ config.environment.etc."ssh/sshd_config".source ]; preStart = '' # Make sure we don't write to stdout, since in case of # socket activation, it goes to the remote side (#19589). exec >&2 ${flip concatMapStrings cfg.hostKeys (k: '' if ! [ -s "${k.path}" ]; then if ! [ -h "${k.path}" ]; then rm -f "${k.path}" fi mkdir -m 0755 -p "$(dirname '${k.path}')" ssh-keygen \ -t "${k.type}" \ ${optionalString (k ? bits) "-b ${toString k.bits}"} \ ${optionalString (k ? rounds) "-a ${toString k.rounds}"} \ ${optionalString (k ? comment) "-C '${k.comment}'"} \ ${optionalString (k ? openSSHFormat && k.openSSHFormat) "-o"} \ -f "${k.path}" \ -N "" fi '')} ''; serviceConfig = { ExecStart = (optionalString cfg.startWhenNeeded "-") + "${cfg.package}/bin/sshd " + (optionalString cfg.startWhenNeeded "-i ") + "-D " + # don't detach into a daemon process "-f /etc/ssh/sshd_config"; KillMode = "process"; } // (if cfg.startWhenNeeded then { StandardInput = "socket"; StandardError = "journal"; } else { Restart = "always"; Type = "simple"; }); }; in if cfg.startWhenNeeded then { sockets.sshd = { description = "SSH Socket"; wantedBy = [ "sockets.target" ]; socketConfig.ListenStream = if cfg.listenAddresses != [] then 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; }; services."sshd@" = service; } else { services.sshd = service; }; networking.firewall.allowedTCPPorts = optionals cfg.openFirewall cfg.ports; security.pam.services.sshd = lib.mkIf cfg.settings.UsePAM { startSession = true; showMotd = true; unixAuth = cfg.settings.PasswordAuthentication; }; # 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 = mkIf (authPrincipalsFiles != {}) "/etc/ssh/authorized_principals.d/%u"; services.openssh.extraConfig = mkOrder 0 '' Banner ${if cfg.banner == null then "none" else pkgs.writeText "ssh_banner" cfg.banner} AddressFamily ${if config.networking.enableIPv6 then "any" else "inet"} ${concatMapStrings (port: '' Port ${toString port} '') cfg.ports} ${concatMapStrings ({ port, addr, ... }: '' ListenAddress ${addr}${optionalString (port != null) (":" + toString port)} '') cfg.listenAddresses} ${optionalString cfgc.setXAuthLocation '' XAuthLocation ${pkgs.xorg.xauth}/bin/xauth ''} ${optionalString cfg.allowSFTP '' Subsystem sftp ${cfg.sftpServerExecutable} ${concatStringsSep " " cfg.sftpFlags} ''} AuthorizedKeysFile ${toString cfg.authorizedKeysFiles} ${optionalString (cfg.authorizedKeysCommand != "none") '' AuthorizedKeysCommand ${cfg.authorizedKeysCommand} AuthorizedKeysCommandUser ${cfg.authorizedKeysCommandUser} ''} ${flip concatMapStrings cfg.hostKeys (k: '' HostKey ${k.path} '')} ''; system.checks = [ (pkgs.runCommand "check-sshd-config" { nativeBuildInputs = [ validationPackage ]; } '' ${concatMapStringsSep "\n" (lport: "sshd -G -T -C lport=${toString lport} -f ${sshconf} > /dev/null") cfg.ports} ${concatMapStringsSep "\n" (la: concatMapStringsSep "\n" (port: "sshd -G -T -C ${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";} (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 (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}''; })] ++ forEach cfg.listenAddresses ({ addr, ... }: { assertion = addr != null; message = "addr must be specified in each listenAddresses entry"; }); }; }