diff --git a/nixos/modules/services/networking/ssh/sshd.nix b/nixos/modules/services/networking/ssh/sshd.nix index b9b5d40c45742..15796a45d1d71 100644 --- a/nixos/modules/services/networking/ssh/sshd.nix +++ b/nixos/modules/services/networking/ssh/sshd.nix @@ -9,6 +9,15 @@ let nssModulesPath = config.system.nssModules.path; + yesNo = x: if x then "yes" else "no"; + + indent = c: x: + let + indentStr = concatStrings (builtins.genList (_: " ") c); + in + removeSuffix indentStr (concatStringsSep "\n" (flip map (splitString "\n" x) + (n: "${optionalString (n != "") indentStr}${n}"))); + userOptions = { options.openssh.authorizedKeys = { @@ -54,6 +63,86 @@ let )); in listToAttrs (map mkAuthKeyFile usersWithKeys); + # options that can be used globally or in a `Match` declaration + generalOptions = { + authorizedKeysFiles = mkOption { + type = types.listOf types.str; + default = []; + description = "Files from which authorized keys are read."; + }; + + gatewayPorts = mkOption { + type = types.str; + default = "no"; + description = '' + Specifies whether remote hosts are allowed to connect to + ports forwarded for the client. See + sshd_config + 5. + ''; + }; + + logLevel = mkOption { + type = types.enum [ "QUIET" "FATAL" "ERROR" "INFO" "VERBOSE" "DEBUG" "DEBUG1" "DEBUG2" "DEBUG3" ]; + default = "VERBOSE"; + description = '' + Gives the verbosity level that is used when logging messages from sshd(8). The possible values are: + QUIET, FATAL, ERROR, INFO, VERBOSE, DEBUG, DEBUG1, DEBUG2, and DEBUG3. The default is VERBOSE. DEBUG and DEBUG1 + are equivalent. DEBUG2 and DEBUG3 each specify higher levels of debugging output. Logging with a DEBUG level + violates the privacy of users and is not recommended. + + LogLevel VERBOSE logs user's key fingerprint on login. + Needed to have a clear audit track of which key was used to log in. + ''; + }; + + passwordAuthentication = mkOption { + type = types.bool; + default = true; + description = '' + Specifies whether password authentication is allowed. + ''; + }; + + permitRootLogin = mkOption { + default = "prohibit-password"; + type = types.enum ["yes" "without-password" "prohibit-password" "forced-commands-only" "no"]; + description = '' + Whether the root user can login using ssh. + ''; + }; + + forwardX11 = mkOption { + type = types.bool; + default = false; + description = '' + Whether to allow X11 connections to be forwarded. + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + description = "Verbatim contents of sshd_config."; + }; + }; + + renderGeneralOptions = cfg: '' + ${optionalString cfgc.setXAuthLocation '' + XAuthLocation ${pkgs.xorg.xauth}/bin/xauth + ''} + + X11Forwarding ${yesNo cfg.forwardX11} + + AuthorizedKeysFile ${toString cfg.authorizedKeysFiles} + + PermitRootLogin ${cfg.permitRootLogin} + GatewayPorts ${cfg.gatewayPorts} + PasswordAuthentication ${yesNo cfg.passwordAuthentication} + + LogLevel ${cfg.logLevel} + ''; + in { @@ -62,7 +151,7 @@ in options = { - services.openssh = { + services.openssh = generalOptions // { enable = mkOption { type = types.bool; @@ -83,14 +172,6 @@ in ''; }; - forwardX11 = mkOption { - type = types.bool; - default = false; - description = '' - Whether to allow X11 connections to be forwarded. - ''; - }; - allowSFTP = mkOption { type = types.bool; default = true; @@ -110,25 +191,6 @@ in ''; }; - permitRootLogin = mkOption { - default = "prohibit-password"; - type = types.enum ["yes" "without-password" "prohibit-password" "forced-commands-only" "no"]; - description = '' - Whether the root user can login using ssh. - ''; - }; - - gatewayPorts = mkOption { - type = types.str; - default = "no"; - description = '' - Specifies whether remote hosts are allowed to connect to - ports forwarded for the client. See - sshd_config - 5. - ''; - }; - ports = mkOption { type = types.listOf types.port; default = [22]; @@ -145,6 +207,52 @@ in ''; }; + matches = mkOption { + default = []; + example = literalExample '' + [ + { + match = { + "10.23.42.0/24" = "Address"; + "somebody" = "User"; + }; + config = { + forwardX11 = true; + permitRootLogin = "yes"; + }; + } + { + match."10.23.42.0/24" = "Address"; + config.logLevel = "ERROR"; + } + ] + ''; + + description = '' + Declaratively specify matches like Match Address 10.23.42.0/24. + ''; + + type = types.listOf (types.submodule { + options = { + match = mkOption { + type = types.attrsOf (types.enum [ "Address" "User" ]); + description = '' + Configure matches for openssh. + ''; + + apply = x: concatStringsSep " " (mapAttrsToList (match: type: "${type} ${match}") x); + }; + + config = mkOption { + type = types.submodule ({ options = generalOptions; }); + description = '' + The options that only apply if the match is true. + ''; + }; + }; + }); + }; + listenAddresses = mkOption { type = with types; listOf (submodule { options = { @@ -176,14 +284,6 @@ in ''; }; - passwordAuthentication = mkOption { - type = types.bool; - default = true; - description = '' - Specifies whether password authentication is allowed. - ''; - }; - challengeResponseAuthentication = mkOption { type = types.bool; default = true; @@ -211,12 +311,6 @@ in ''; }; - authorizedKeysFiles = mkOption { - type = types.listOf types.str; - default = []; - description = "Files from which authorized keys are read."; - }; - kexAlgorithms = mkOption { type = types.listOf types.str; default = [ @@ -276,20 +370,6 @@ in ''; }; - logLevel = mkOption { - type = types.enum [ "QUIET" "FATAL" "ERROR" "INFO" "VERBOSE" "DEBUG" "DEBUG1" "DEBUG2" "DEBUG3" ]; - default = "VERBOSE"; - description = '' - Gives the verbosity level that is used when logging messages from sshd(8). The possible values are: - QUIET, FATAL, ERROR, INFO, VERBOSE, DEBUG, DEBUG1, DEBUG2, and DEBUG3. The default is VERBOSE. DEBUG and DEBUG1 - are equivalent. DEBUG2 and DEBUG3 each specify higher levels of debugging output. Logging with a DEBUG level - violates the privacy of users and is not recommended. - - LogLevel VERBOSE logs user's key fingerprint on login. - Needed to have a clear audit track of which key was used to log in. - ''; - }; - useDns = mkOption { type = types.bool; default = false; @@ -301,12 +381,6 @@ in ''; }; - extraConfig = mkOption { - type = types.lines; - default = ""; - description = "Verbatim contents of sshd_config."; - }; - moduliFile = mkOption { example = "/etc/my-local-ssh-moduli;"; type = types.path; @@ -328,7 +402,7 @@ in ###### implementation - config = mkIf cfg.enable { + config = mkIf cfg.enable (mkMerge [{ users.users.sshd = { isSystemUser = true; @@ -435,6 +509,8 @@ in UsePAM yes + ${renderGeneralOptions cfg} + AddressFamily ${if config.networking.enableIPv6 then "any" else "inet"} ${concatMapStrings (port: '' Port ${toString port} @@ -444,29 +520,14 @@ in ListenAddress ${addr}${if port != null then ":" + toString port else ""} '') cfg.listenAddresses} - ${optionalString cfgc.setXAuthLocation '' - XAuthLocation ${pkgs.xorg.xauth}/bin/xauth - ''} - - ${if cfg.forwardX11 then '' - X11Forwarding yes - '' else '' - X11Forwarding no - ''} - ${optionalString cfg.allowSFTP '' Subsystem sftp ${cfgc.package}/libexec/sftp-server ${concatStringsSep " " cfg.sftpFlags} ''} - PermitRootLogin ${cfg.permitRootLogin} - GatewayPorts ${cfg.gatewayPorts} - PasswordAuthentication ${if cfg.passwordAuthentication then "yes" else "no"} - ChallengeResponseAuthentication ${if cfg.challengeResponseAuthentication then "yes" else "no"} + ChallengeResponseAuthentication ${yesNo cfg.challengeResponseAuthentication} PrintMotd no # handled by pam_motd - AuthorizedKeysFile ${toString cfg.authorizedKeysFiles} - ${flip concatMapStrings cfg.hostKeys (k: '' HostKey ${k.path} '')} @@ -475,14 +536,11 @@ in Ciphers ${concatStringsSep "," cfg.ciphers} MACs ${concatStringsSep "," cfg.macs} - LogLevel ${cfg.logLevel} - ${if cfg.useDns then '' UseDNS yes '' else '' UseDNS no ''} - ''; assertions = [{ assertion = if cfg.forwardX11 then cfgc.setXAuthLocation else true; @@ -492,6 +550,17 @@ in message = "addr must be specified in each listenAddresses entry"; }); - }; + } (mkIf (cfg.matches != {}) { + + # position matches at the very last position in the config. + # Otherwise all following options would be only used if the option applies. + services.openssh.extraConfig = mkOrder 9999 + (concatMapStringsSep "\n" (match: '' + Match ${match.match} + ${indent 2 (renderGeneralOptions match.config)} + ${indent 2 match.config.extraConfig} + '') cfg.matches); + + })]); } diff --git a/nixos/tests/openssh.nix b/nixos/tests/openssh.nix index 8b9e2170f1502..0caac1d11007c 100644 --- a/nixos/tests/openssh.nix +++ b/nixos/tests/openssh.nix @@ -52,9 +52,40 @@ in { }; }; + server_restricted_root = + { ... }: + + { + services.openssh = { + enable = true; + permitRootLogin = "no"; + + matches = pkgs.lib.singleton { + match."192.168.1.2" = "Address"; + config.permitRootLogin = "yes"; + }; + }; + + users.users.root.openssh.authorizedKeys.keys = [ + snakeOilPublicKey + ]; + }; + client = { ... }: { }; + client2 = + { ... }: + + { + networking = { + useDHCP = false; + interfaces.eth1.ipv4.addresses = [ + { address = "192.168.1.2"; prefixLength = 32; } + ]; + }; + }; + }; testScript = '' @@ -99,6 +130,23 @@ in { subtest "localhost-only", sub { $server_localhost_only->succeed("ss -nlt | grep '127.0.0.1:22'"); $server_localhost_only_lazy->succeed("ss -nlt | grep '127.0.0.1:22'"); - } + }; + + subtest "restricted root login", sub { + foreach (($client, $client2)) { + $_->succeed("cat ${snakeOilPrivateKey} > privkey.snakeoil"); + $_->succeed("chmod 600 privkey.snakeoil"); + } + + $client2->succeed("ssh -o UserKnownHostsFile=/dev/null" . + " -o StrictHostKeyChecking=no -i privkey.snakeoil" . + " -o BatchMode=yes" . + " server_restricted_root true"); + + $client->fail("ssh -o UserKnownHostsFile=/dev/null" . + " -o StrictHostKeyChecking=no -i privkey.snakeoil" . + " -o BatchMode=yes" . + " server_restricted_root true"); + }; ''; })