diff --git a/modules/programs/ssh.nix b/modules/programs/ssh.nix index fdf02a2c14eb..ab61c0dcbc44 100644 --- a/modules/programs/ssh.nix +++ b/modules/programs/ssh.nix @@ -6,26 +6,39 @@ let cfg = config.programs.ssh; + isPath = x: builtins.substring 0 1 (toString x) == "/"; + + addressPort = entry: + if isPath entry.address + then " ${entry.address}" + else " [${entry.address}]:${toString entry.port}"; + yn = flag: if flag then "yes" else "no"; unwords = builtins.concatStringsSep " "; - localForwardModule = types.submodule ({ ... }: { - options = { - bind = { - address = mkOption { - type = types.str; - default = "localhost"; - example = "example.org"; - description = "The address where to bind the port."; - }; + bindOptions = { + address = mkOption { + type = types.str; + default = "localhost"; + example = "example.org"; + description = "The address where to bind the port."; + }; - port = mkOption { - type = types.port; - example = 8080; - description = "Specifies port number to bind on bind address."; - }; - }; + port = mkOption { + type = types.port; + example = 8080; + description = "Specifies port number to bind on bind address."; + }; + }; + + dynamicForwardModule = types.submodule { + options = bindOptions; + }; + + forwardModule = types.submodule { + options = { + bind = bindOptions; host = { address = mkOption { @@ -41,7 +54,7 @@ let }; }; }; - }); + }; matchBlockModule = types.submodule ({ name, ... }: { options = { @@ -186,7 +199,7 @@ let }; localForwards = mkOption { - type = types.listOf localForwardModule; + type = types.listOf forwardModule; default = []; example = literalExample '' [ @@ -202,7 +215,43 @@ let ssh_config 5 - for LocalForward. + for LocalForward. + ''; + }; + + remoteForwards = mkOption { + type = types.listOf forwardModule; + default = []; + example = literalExample '' + [ + { + bind.port = 8080; + host.address = "10.0.0.13"; + host.port = 80; + } + ]; + ''; + description = '' + Specify remote port forwardings. See + + ssh_config + 5 + for RemoteForward. + ''; + }; + + dynamicForwards = mkOption { + type = types.listOf dynamicForwardModule; + default = []; + example = literalExample '' + [ { port = 8080; } ]; + ''; + description = '' + Specify dynamic port forwardings. See + + ssh_config + 5 + for DynamicForward. ''; }; @@ -235,14 +284,9 @@ let ++ optional (cf.proxyCommand != null) " ProxyCommand ${cf.proxyCommand}" ++ optional (cf.proxyJump != null) " ProxyJump ${cf.proxyJump}" ++ map (file: " IdentityFile ${file}") cf.identityFile - ++ map (f: - let - addressPort = entry: " [${entry.address}]:${toString entry.port}"; - in - " LocalForward" - + addressPort f.bind - + addressPort f.host - ) cf.localForwards + ++ map (f: " LocalForward" + addressPort f.bind + addressPort f.host) cf.localForwards + ++ map (f: " RemoteForward" + addressPort f.bind + addressPort f.host) cf.remoteForwards + ++ map (f: " DynamicForward" + addressPort f) cf.dynamicForwards ++ mapAttrsToList (n: v: " ${n} ${v}") cf.extraOptions ); @@ -370,6 +414,25 @@ in }; config = mkIf cfg.enable { + assertions = [ + { + assertion = + let + # `builtins.any`/`lib.lists.any` does not return `true` if there are no elements. + any' = pred: items: if items == [] then true else any pred items; + # Check that if `entry.address` is defined, and is a path, that `entry.port` has not + # been defined. + noPathWithPort = entry: entry ? address && isPath entry.address -> !(entry ? port); + checkDynamic = block: any' noPathWithPort block.dynamicForwards; + checkBindAndHost = fwd: noPathWithPort fwd.bind && noPathWithPort fwd.host; + checkLocal = block: any' checkBindAndHost block.localForwards; + checkRemote = block: any' checkBindAndHost block.remoteForwards; + checkMatchBlock = block: all (fn: fn block) [ checkLocal checkRemote checkDynamic ]; + in any' checkMatchBlock (builtins.attrValues cfg.matchBlocks); + message = "Forwarded paths cannot have ports."; + } + ]; + home.file.".ssh/config".text = '' ${concatStringsSep "\n" ( mapAttrsToList (n: v: "${n} ${v}") cfg.extraOptionOverrides)} diff --git a/tests/modules/programs/ssh/default-config.nix b/tests/modules/programs/ssh/default-config.nix index e43ee3dc7692..266bc9d1f5d5 100644 --- a/tests/modules/programs/ssh/default-config.nix +++ b/tests/modules/programs/ssh/default-config.nix @@ -8,9 +8,16 @@ with lib; enable = true; }; + home.file.assertions.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + nmt.script = '' assertFileExists home-files/.ssh/config assertFileContent home-files/.ssh/config ${./default-config-expected.conf} + assertFileContent home-files/assertions ${./no-assertions.json} ''; }; } diff --git a/tests/modules/programs/ssh/default.nix b/tests/modules/programs/ssh/default.nix index d385e4ee9210..507eef0bdb8b 100644 --- a/tests/modules/programs/ssh/default.nix +++ b/tests/modules/programs/ssh/default.nix @@ -1,4 +1,17 @@ { ssh-defaults = ./default-config.nix; ssh-match-blocks = ./match-blocks-attrs.nix; + + ssh-forwards-dynamic-valid-bind-no-asserts = + ./forwards-dynamic-valid-bind-no-asserts.nix; + ssh-forwards-dynamic-bind-path-with-port-asserts = + ./forwards-dynamic-bind-path-with-port-asserts.nix; + ssh-forwards-local-bind-path-with-port-asserts = + ./forwards-local-bind-path-with-port-asserts.nix; + ssh-forwards-local-host-path-with-port-asserts = + ./forwards-local-host-path-with-port-asserts.nix; + ssh-forwards-remote-bind-path-with-port-asserts = + ./forwards-remote-bind-path-with-port-asserts.nix; + ssh-forwards-remote-host-path-with-port-asserts = + ./forwards-remote-host-path-with-port-asserts.nix; } diff --git a/tests/modules/programs/ssh/forwards-dynamic-bind-path-with-port-asserts.nix b/tests/modules/programs/ssh/forwards-dynamic-bind-path-with-port-asserts.nix new file mode 100644 index 000000000000..2e9082de378e --- /dev/null +++ b/tests/modules/programs/ssh/forwards-dynamic-bind-path-with-port-asserts.nix @@ -0,0 +1,32 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + config = { + programs.ssh = { + enable = true; + matchBlocks = { + dynamicBindPathWithPort = { + dynamicForwards = [ + { + # Error: + address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + port = 3000; + } + ]; + }; + }; + }; + + home.file.result.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + + nmt.script = '' + assertFileContent home-files/result ${./forwards-paths-with-ports-error.json} + ''; + }; +} diff --git a/tests/modules/programs/ssh/forwards-dynamic-valid-bind-no-asserts-expected.conf b/tests/modules/programs/ssh/forwards-dynamic-valid-bind-no-asserts-expected.conf new file mode 100644 index 000000000000..5213d282c28a --- /dev/null +++ b/tests/modules/programs/ssh/forwards-dynamic-valid-bind-no-asserts-expected.conf @@ -0,0 +1,19 @@ + + +Host dynamicBindAddressWithPort + DynamicForward [127.0.0.1]:3000 + +Host dynamicBindPathNoPort + DynamicForward /run/user/1000/gnupg/S.gpg-agent.extra + +Host * + ForwardAgent no + Compression no + ServerAliveInterval 0 + HashKnownHosts no + UserKnownHostsFile ~/.ssh/known_hosts + ControlMaster no + ControlPath ~/.ssh/master-%r@%n:%p + ControlPersist no + + diff --git a/tests/modules/programs/ssh/forwards-dynamic-valid-bind-no-asserts.nix b/tests/modules/programs/ssh/forwards-dynamic-valid-bind-no-asserts.nix new file mode 100644 index 000000000000..15ab59e82ca1 --- /dev/null +++ b/tests/modules/programs/ssh/forwards-dynamic-valid-bind-no-asserts.nix @@ -0,0 +1,45 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + config = { + programs.ssh = { + enable = true; + matchBlocks = { + dynamicBindPathNoPort = { + dynamicForwards = [ + { + # OK: + address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + } + ]; + }; + + dynamicBindAddressWithPort = { + dynamicForwards = [ + { + # OK: + address = "127.0.0.1"; + port = 3000; + } + ]; + }; + }; + }; + + home.file.result.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + + nmt.script = '' + assertFileExists home-files/.ssh/config + assertFileContent \ + home-files/.ssh/config \ + ${./forwards-dynamic-valid-bind-no-asserts-expected.conf} + assertFileContent home-files/result ${./no-assertions.json} + ''; + }; +} diff --git a/tests/modules/programs/ssh/forwards-local-bind-path-with-port-asserts.nix b/tests/modules/programs/ssh/forwards-local-bind-path-with-port-asserts.nix new file mode 100644 index 000000000000..c05cba82791c --- /dev/null +++ b/tests/modules/programs/ssh/forwards-local-bind-path-with-port-asserts.nix @@ -0,0 +1,36 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + config = { + programs.ssh = { + enable = true; + matchBlocks = { + localBindPathWithPort = { + localForwards = [ + { + # OK: + host.address = "127.0.0.1"; + host.port = 3000; + + # Error: + bind.address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + bind.port = 3000; + } + ]; + }; + }; + }; + + home.file.result.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + + nmt.script = '' + assertFileContent home-files/result ${./forwards-paths-with-ports-error.json} + ''; + }; +} diff --git a/tests/modules/programs/ssh/forwards-local-host-path-with-port-asserts.nix b/tests/modules/programs/ssh/forwards-local-host-path-with-port-asserts.nix new file mode 100644 index 000000000000..8cecc5e5121f --- /dev/null +++ b/tests/modules/programs/ssh/forwards-local-host-path-with-port-asserts.nix @@ -0,0 +1,36 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + config = { + programs.ssh = { + enable = true; + matchBlocks = { + localHostPathWithPort = { + localForwards = [ + { + # OK: + bind.address = "127.0.0.1"; + bind.port = 3000; + + # Error: + host.address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + host.port = 3000; + } + ]; + }; + }; + }; + + home.file.result.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + + nmt.script = '' + assertFileContent home-files/result ${./forwards-paths-with-ports-error.json} + ''; + }; +} diff --git a/tests/modules/programs/ssh/forwards-paths-with-ports-error.json b/tests/modules/programs/ssh/forwards-paths-with-ports-error.json new file mode 100644 index 000000000000..e7e3a374ecc7 --- /dev/null +++ b/tests/modules/programs/ssh/forwards-paths-with-ports-error.json @@ -0,0 +1 @@ +["Forwarded paths cannot have ports."] \ No newline at end of file diff --git a/tests/modules/programs/ssh/forwards-remote-bind-path-with-port-asserts.nix b/tests/modules/programs/ssh/forwards-remote-bind-path-with-port-asserts.nix new file mode 100644 index 000000000000..a0473147bd39 --- /dev/null +++ b/tests/modules/programs/ssh/forwards-remote-bind-path-with-port-asserts.nix @@ -0,0 +1,36 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + config = { + programs.ssh = { + enable = true; + matchBlocks = { + remoteBindPathWithPort = { + remoteForwards = [ + { + # OK: + host.address = "127.0.0.1"; + host.port = 3000; + + # Error: + bind.address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + bind.port = 3000; + } + ]; + }; + }; + }; + + home.file.result.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + + nmt.script = '' + assertFileContent home-files/result ${./forwards-paths-with-ports-error.json} + ''; + }; +} diff --git a/tests/modules/programs/ssh/forwards-remote-host-path-with-port-asserts.nix b/tests/modules/programs/ssh/forwards-remote-host-path-with-port-asserts.nix new file mode 100644 index 000000000000..770b8ab2870b --- /dev/null +++ b/tests/modules/programs/ssh/forwards-remote-host-path-with-port-asserts.nix @@ -0,0 +1,36 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + config = { + programs.ssh = { + enable = true; + matchBlocks = { + remoteHostPathWithPort = { + remoteForwards = [ + { + # OK: + bind.address = "127.0.0.1"; + bind.port = 3000; + + # Error: + host.address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + host.port = 3000; + } + ]; + }; + }; + }; + + home.file.result.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + + nmt.script = '' + assertFileContent home-files/result ${./forwards-paths-with-ports-error.json} + ''; + }; +} diff --git a/tests/modules/programs/ssh/match-blocks-attrs-expected.conf b/tests/modules/programs/ssh/match-blocks-attrs-expected.conf index 1bff480fdce1..f0d768375f09 100644 --- a/tests/modules/programs/ssh/match-blocks-attrs-expected.conf +++ b/tests/modules/programs/ssh/match-blocks-attrs-expected.conf @@ -12,6 +12,9 @@ Host xyz ServerAliveInterval 60 IdentityFile file LocalForward [localhost]:8080 [10.0.0.1]:80 + RemoteForward [localhost]:8081 [10.0.0.2]:80 + RemoteForward /run/user/1000/gnupg/S.gpg-agent.extra /run/user/1000/gnupg/S.gpg-agent + DynamicForward [localhost]:2839 Host * ForwardAgent no diff --git a/tests/modules/programs/ssh/match-blocks-attrs.nix b/tests/modules/programs/ssh/match-blocks-attrs.nix index 3e09cd2d5f2d..94263ef9d27b 100644 --- a/tests/modules/programs/ssh/match-blocks-attrs.nix +++ b/tests/modules/programs/ssh/match-blocks-attrs.nix @@ -22,6 +22,22 @@ with lib; host.port = 80; } ]; + remoteForwards = [ + { + bind.port = 8081; + host.address = "10.0.0.2"; + host.port = 80; + } + { + bind.address = "/run/user/1000/gnupg/S.gpg-agent.extra"; + host.address = "/run/user/1000/gnupg/S.gpg-agent"; + } + ]; + dynamicForwards = [ + { + port = 2839; + } + ]; }; "* !github.com" = { @@ -31,11 +47,18 @@ with lib; }; }; + home.file.assertions.text = + builtins.toJSON + (map (a: a.message) + (filter (a: !a.assertion) + config.assertions)); + nmt.script = '' assertFileExists home-files/.ssh/config assertFileContent \ home-files/.ssh/config \ ${./match-blocks-attrs-expected.conf} + assertFileContent home-files/assertions ${./no-assertions.json} ''; }; } diff --git a/tests/modules/programs/ssh/no-assertions.json b/tests/modules/programs/ssh/no-assertions.json new file mode 100644 index 000000000000..0637a088a01e --- /dev/null +++ b/tests/modules/programs/ssh/no-assertions.json @@ -0,0 +1 @@ +[] \ No newline at end of file