diff --git a/.gitignore b/.gitignore
index d6b3327..9f3e5a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
/.direnv
/.nixos*
-/cli.html
/deploy
/flamegraph.svg
/log.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 227842a..6d985ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- Add `--pool-size` CLI flag.
- Add `--pool-timeout` CLI flag.
- Add `--no-domain` CLI flag.
+- Add `--max-simultaneous-connections-per-ip` CLI flag.
- Add `pool` option for remote forwarding connections.
- Add keepalive mechanism for proxied HTTP connections.
diff --git a/book/src/cli.md b/book/src/cli.md
index 997ec7e..58cf8ea 100644
--- a/book/src/cli.md
+++ b/book/src/cli.md
@@ -30,8 +30,8 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
[default: ./deploy/user_keys/]
--admin-keys-directory <DIRECTORY>
- Directory containing public keys of admin users. Each file must
- contain at least one key
+ Directory containing public keys of admin users. Each file must contain
+ at least one key
[default: ./deploy/admin_keys/]
@@ -119,8 +119,6 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
Beware that this can lead to domain takeovers if misused!
- [default: txt]
-
Possible values:
- all: Allow any hostnames unconditionally, including the
main domain
@@ -131,6 +129,8 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
- none: Don't allow user-provided hostnames, enforce
subdomains
+ [default: txt]
+
--load-balancing <STRATEGY>
Strategy for load-balancing when multiple services request the same
hostname/port.
@@ -138,8 +138,6 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
By default, traffic towards matching hostnames/ports will be
load-balanced.
- [default: allow]
-
Possible values:
- allow: Load-balance with all available handlers
- replace: Don't load-balance; When adding a new handler,
@@ -147,18 +145,20 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
- deny: Don't load-balance; Deny the new handler if
there's an existing one
+ [default: allow]
+
--load-balancing-algorithm <ALGORITHM>
Algorithm to use for service selection when load-balancing.
By default, traffic will be randomly distributed between services.
- [default: random]
-
Possible values:
- random: Choose randomly
- round-robin: Round robin
- ip-hash: Choose based on IP hash
+ [default: random]
+
--txt-record-prefix <PREFIX>
Prefix for TXT DNS records containing key fingerprints, for
authorization to bind under a specific domain.
@@ -228,8 +228,8 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
--random-subdomain-value-file <FILE>
Set a file containing a U64 value for random subdomains for use in
- conjunction with `--random-subdomain-seed` to allow binding to the same
- random address between Sandhole restarts.
+ conjunction with `--random-subdomain-seed` to allow binding to the
+ same random address between Sandhole restarts.
Beware that this can lead to collisions if misused!
@@ -251,7 +251,8 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
- ip-and-user: From IP address, SSH user, and requested address.
Recommended if unsure
- user: From SSH user and requested address
- - fingerprint: From SSH user, key fingerprint, and requested address
+ - fingerprint: From SSH user, key fingerprint, and requested
+ address
- address: From SSH connection socket (address + port) and
requested address
@@ -282,7 +283,7 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
unknown IPs to connect, unless --ip-allowlist is set
--buffer-size <SIZE>
- Size to use for bidirectional buffers, in bytes.
+ Size to use for bidirectional buffers.
A higher value will lead to higher memory consumption.
@@ -298,12 +299,21 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
[default: 256]
--pool-timeout <DURATION>
- How long to wait for a connection to be available in the pool
- before being timed out.
+ How long to wait for a connection to be available in the pool before
+ being timed out.
By default, connections are immediately timed out when the pool is
exhausted.
+ --max-simultaneous-connections-per-ip <SIZE>
+ Maximum number of simultaneous connections per IP to a proxied
+ service. The maximum is 65535.
+
+ A low value may lead to client side disruptions, while a high value
+ may lead to denial-of-service.
+
+ [default: 16]
+
--ssh-keepalive-interval <DURATION>
How long to wait between each keepalive message that is sent to an
unresponsive SSH connection
@@ -316,8 +326,8 @@ Expose HTTP/SSH/TCP services through SSH port forwarding.
A value of zero disables timeouts.
- The timeout is equal to this value plus one,
- times `--ssh-keepalive-interval`.
+ The timeout is equal to this value plus one, times
+ `--ssh-keepalive-interval`.
[default: 3]
diff --git a/book/src/nixos_options.md b/book/src/nixos_options.md
index fb63b42..1f45391 100644
--- a/book/src/nixos_options.md
+++ b/book/src/nixos_options.md
@@ -107,30 +107,21 @@ true
Attribute set of command line options for Sandhole, without the leading hyphens\.
-If Sandhole is enabled, then ` services.sandhole.settings.domain ` must be set\.
+If Sandhole is enabled, then either ` services.sandhole.settings.domain ` or ` services.sandhole.settings.no-domain ` must be set\.
**Note:** For all available settings, see [the Sandhole documentation](https://sandhole\.com\.br/cli\.html)\.
*Type:*
-attribute set of (null or boolean or (unsigned integer, meaning >=0) or absolute path or string)
+open submodule of attribute set of (null or boolean or (unsigned integer, meaning >=0) or absolute path or string)
*Default:*
```nix
-{
- disable-http = false;
- disable-https = false;
- disable-tcp = false;
- domain = null;
- http-port = 80;
- https-port = 443;
- no-domain = false;
- ssh-port = 2222;
-}
+{ }
```
@@ -156,6 +147,227 @@ attribute set of (null or boolean or (unsigned integer, meaning >=0) or absolute
+## services\.sandhole\.settings\.disable-http
+
+
+
+Disable all HTTP tunneling\. By default, this is enabled globally\.
+
+
+
+*Type:*
+boolean
+
+
+
+*Default:*
+
+```nix
+false
+```
+
+
+
+*Example:*
+
+```nix
+true
+```
+
+
+
+## services\.sandhole\.settings\.disable-https
+
+
+
+Disable all HTTPS tunneling\. By default, this is enabled globally\.
+
+
+
+*Type:*
+boolean
+
+
+
+*Default:*
+
+```nix
+false
+```
+
+
+
+*Example:*
+
+```nix
+true
+```
+
+
+
+## services\.sandhole\.settings\.disable-tcp
+
+
+
+Disable all TCP port tunneling except HTTP\. By default, this is enabled globally\.
+
+**Warning:** If this option is false or unset and ` services.sandhole.openFirewall ` is true,
+all unprivileged TCP ports (i\.e\. >= 1024) will be opened\.
+
+
+
+*Type:*
+boolean
+
+
+
+*Default:*
+
+```nix
+false
+```
+
+
+
+*Example:*
+
+```nix
+true
+```
+
+
+
+## services\.sandhole\.settings\.domain
+
+
+
+The root domain of the application\.
+
+
+
+*Type:*
+null or string
+
+
+
+*Default:*
+
+```nix
+null
+```
+
+
+
+*Example:*
+
+```nix
+"nixos.org"
+```
+
+
+
+## services\.sandhole\.settings\.http-port
+
+
+
+Port to listen for HTTP connections\.
+
+
+
+*Type:*
+16 bit unsigned integer; between 0 and 65535 (both inclusive)
+
+
+
+*Default:*
+
+```nix
+80
+```
+
+
+
+## services\.sandhole\.settings\.https-port
+
+
+
+Port to listen for HTTPS connections\.
+
+
+
+*Type:*
+16 bit unsigned integer; between 0 and 65535 (both inclusive)
+
+
+
+*Default:*
+
+```nix
+443
+```
+
+
+
+## services\.sandhole\.settings\.no-domain
+
+
+
+Whether to run Sandhole without a root domain\.
+
+This option disables subdomains\.
+
+
+
+*Type:*
+boolean
+
+
+
+*Default:*
+
+```nix
+false
+```
+
+
+
+*Example:*
+
+```nix
+true
+```
+
+
+
+## services\.sandhole\.settings\.ssh-port
+
+
+
+Port to listen for SSH connections\.
+
+
+
+*Type:*
+16 bit unsigned integer; between 0 and 65535 (both inclusive)
+
+
+
+*Default:*
+
+```nix
+2222
+```
+
+
+
+*Example:*
+
+```nix
+22
+```
+
+
+
## services\.sandhole\.user
diff --git a/default.nix b/default.nix
index 2a6b5c1..c7d9b92 100644
--- a/default.nix
+++ b/default.nix
@@ -1,4 +1,4 @@
{
system ? builtins.currentSystem,
}:
-(import ./nix/lib.nix { inherit system; }).sandhole
+(import ./nix { inherit system; }).sandhole
diff --git a/flake.nix b/flake.nix
index 8ffc792..b2b3c75 100644
--- a/flake.nix
+++ b/flake.nix
@@ -55,27 +55,20 @@
// eachSystem (
system:
let
- inherit (import ./nix/lib.nix { inherit system; })
- sandhole
- sandhole-book
- sandhole-cli
- optionsDoc
- lib
+ inherit (import ./nix { inherit system; })
+ pkgs
+ packages
checks
+ shell
;
+ inherit (pkgs) lib;
in
{
- packages.${system} = {
- inherit sandhole;
- default = sandhole;
- _book = sandhole-book;
- _cli = sandhole-cli;
- _docs = optionsDoc.optionsCommonMark;
- };
+ packages.${system} = packages;
apps.${system}.default = {
type = "app";
- program = lib.getExe sandhole;
+ program = lib.getExe self.packages.${system}.default;
meta = {
name = "sandhole";
description = "Expose HTTP/SSH/TCP services through SSH port forwarding";
@@ -88,7 +81,7 @@
checks.${system} = checks;
- devShells.${system}.default = import ./shell.nix { inherit system; };
+ devShells.${system}.default = shell;
}
);
}
diff --git a/justfile b/justfile
index a789efe..777e8f1 100644
--- a/justfile
+++ b/justfile
@@ -14,10 +14,17 @@ book:
mdbook serve book --open
cli:
- to-html --no-prompt "cargo run --quiet -- --help" > cli.html
+ nix-build ./nix -A packages._cli
+ echo "# Command-line interface options" > book/src/cli.md
+ echo "" >> book/src/cli.md
+ echo "Sandhole exposes several options, which you can see by running \`sandhole --help\`." >> book/src/cli.md
+ echo "" >> book/src/cli.md
+ echo "---" >> book/src/cli.md
+ echo "" >> book/src/cli.md
+ cat result >> book/src/cli.md
nixos-docs:
- nix build .#_docs
+ nix-build ./nix -A packages._docs
echo "# NixOS module options" > book/src/nixos_options.md
echo "" >> book/src/nixos_options.md
cat result >> book/src/nixos_options.md
@@ -35,14 +42,3 @@ minica:
minica -ca-cert tests/data/ca/rootCA.pem -ca-key tests/data/ca/rootCA-key.pem -domains 'sandhole.com.br'
mv sandhole.com.br/cert.pem tests/data/custom_certificate/fullchain.pem
mv sandhole.com.br/key.pem tests/data/custom_certificate/privkey.pem
-
-install-dev-deps: install-book-deps install-profiling-deps install-test-deps
-
-install-book-deps:
- cargo install mdbook mdbook-mermaid to-html
-
-install-profiling-deps:
- cargo install flamegraph
-
-install-test-deps:
- cargo install cargo-nextest
diff --git a/nix/checks.nix b/nix/checks.nix
new file mode 100644
index 0000000..d9cc741
--- /dev/null
+++ b/nix/checks.nix
@@ -0,0 +1,92 @@
+{
+ cargo-nextest,
+ cargoArtifacts,
+ commonArgs,
+ craneLib,
+ sandhole,
+ src,
+ testers,
+}:
+{
+ inherit sandhole;
+
+ sandhole-clippy = craneLib.cargoClippy (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ }
+ );
+
+ sandhole-doc = craneLib.cargoDoc (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ }
+ );
+
+ sandhole-fmt = craneLib.cargoFmt {
+ inherit src;
+ };
+
+ sandhole-test =
+ let
+ sandhole-nextest-archive = craneLib.mkCargoDerivation (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ pname = "sandhole-nextest-archive";
+ doCheck = false;
+ nativeBuildInputs = (commonArgs.nativeBuildInputs or [ ]) ++ [ cargo-nextest ];
+ buildPhaseCargoCommand = ''
+ cargo nextest archive --archive-format tar-zst --archive-file archive.tar.zst
+ '';
+ installPhaseCommand = ''
+ mkdir -p $out
+ cp archive.tar.zst $out
+ '';
+ }
+ );
+ in
+ testers.runNixOSTest {
+ name = "sandhole-nextest";
+ nodes = {
+ machine =
+ { pkgs, ... }:
+ {
+ virtualisation.diskSize = 4096;
+ environment.defaultPackages = [
+ pkgs.cargo
+ pkgs.rustc
+ ];
+ systemd.services.sandhole-nextest = {
+ description = "Sandhole tests";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ];
+ wants = [ "network-online.target" ];
+ path = [
+ pkgs.cargo
+ pkgs.cargo-nextest
+ ];
+ script = ''
+ cp -r ${src}/* .
+ cargo nextest run \
+ --archive-file ${sandhole-nextest-archive}/archive.tar.zst \
+ --workspace-remap .
+ '';
+ serviceConfig = {
+ StateDirectory = "sandhole-nextest";
+ StateDirectoryMode = "0750";
+ WorkingDirectory = "/var/lib/sandhole-nextest";
+ Type = "oneshot";
+ RemainAfterExit = "yes";
+ Restart = "no";
+ };
+ };
+ };
+ };
+ testScript = ''
+ machine.start()
+ machine.wait_for_unit("sandhole-nextest.service")
+ '';
+ };
+}
diff --git a/nix/default.nix b/nix/default.nix
new file mode 100644
index 0000000..0ed1a0f
--- /dev/null
+++ b/nix/default.nix
@@ -0,0 +1,96 @@
+{
+ system ? builtins.currentSystem,
+ rustChannel ? "stable",
+ rustVersion ? "latest",
+}:
+let
+ sources = import ../npins;
+
+ pkgs = import sources.nixpkgs {
+ inherit system;
+ overlays = [ (import sources.rust-overlay) ];
+ };
+
+ inherit (pkgs) lib;
+
+ craneLib = (import sources.crane { inherit pkgs; }).overrideToolchain (
+ p: p.rust-bin.${rustChannel}.${rustVersion}.default
+ );
+
+ src = lib.fileset.toSource {
+ root = ../.;
+ fileset = lib.fileset.unions [
+ (craneLib.fileset.commonCargoSources ../.)
+ ../README.md
+ ../.config/nextest.toml
+ ../tests/data
+ ];
+ };
+
+ commonArgs = {
+ inherit src;
+ strictDeps = true;
+
+ nativeBuildInputs = [
+ pkgs.cmake
+ pkgs.perl
+ ];
+ };
+
+ cargoArtifacts = craneLib.buildDepsOnly commonArgs;
+
+ sandhole = craneLib.buildPackage (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ doCheck = false;
+ meta.mainProgram = "sandhole";
+ }
+ );
+in
+{
+ inherit pkgs sandhole;
+
+ packages = import ./packages.nix {
+ inherit sandhole;
+ inherit (pkgs)
+ lib
+ mdbook
+ nixosOptionsDoc
+ stdenv
+ to-html
+ ;
+ };
+
+ checks = import ./checks.nix {
+ inherit
+ cargoArtifacts
+ commonArgs
+ craneLib
+ sandhole
+ src
+ ;
+ inherit (pkgs)
+ cargo-nextest
+ testers
+ ;
+ };
+
+ shell = craneLib.devShell {
+ packages = [
+ # General dependencies
+ pkgs.just
+
+ # Book dependencies
+ pkgs.mdbook
+ pkgs.to-html
+
+ # Profiling dependencies
+ pkgs.cargo-flamegraph
+
+ # Test dependencies
+ pkgs.cargo-nextest
+ pkgs.minica
+ ];
+ };
+}
diff --git a/nix/lib.nix b/nix/lib.nix
deleted file mode 100644
index 84666e6..0000000
--- a/nix/lib.nix
+++ /dev/null
@@ -1,191 +0,0 @@
-{
- system ? builtins.currentSystem,
- rustChannel ? "stable",
- rustVersion ? "latest",
-}:
-let
- sources = import ../npins;
-
- pkgs = import sources.nixpkgs {
- inherit system;
- overlays = [ (import sources.rust-overlay) ];
- };
-
- inherit (pkgs) lib;
-
- craneLib = (import sources.crane { inherit pkgs; }).overrideToolchain (
- p: p.rust-bin.${rustChannel}.${rustVersion}.default
- );
-
- src = lib.fileset.toSource {
- root = ../.;
- fileset = lib.fileset.unions [
- (craneLib.fileset.commonCargoSources ../.)
- ../README.md
- ../.config/nextest.toml
- ../tests/data
- ];
- };
-
- commonArgs = {
- inherit src;
- strictDeps = true;
-
- nativeBuildInputs = [
- pkgs.cmake
- pkgs.perl
- ];
- };
-
- cargoArtifacts = craneLib.buildDepsOnly commonArgs;
-
- sandhole = craneLib.buildPackage (
- commonArgs
- // {
- inherit cargoArtifacts;
- doCheck = false;
- meta.mainProgram = "sandhole";
- }
- );
-
- evalOptions = lib.evalModules {
- modules = [
- (
- { config, ... }:
- {
- options =
- (import ./modules/sandhole.nix {
- inherit
- pkgs
- lib
- config
- ;
- }).options;
- }
- )
- ];
- };
-in
-{
- inherit
- pkgs
- lib
- craneLib
- commonArgs
- cargoArtifacts
- sandhole
- ;
-
- optionsDoc = pkgs.nixosOptionsDoc {
- options = removeAttrs evalOptions.options [ "_module" ];
- };
-
- sandhole-cli = pkgs.stdenv.mkDerivation {
- name = "sandhole-cli";
- nativeBuildInputs = [ pkgs.to-html ];
- buildCommand = ''
- mkdir $out
- to-html --no-prompt "${lib.getExe sandhole} --help" > $out/cli.html
- '';
- };
-
- sandhole-book = pkgs.stdenv.mkDerivation {
- name = "sandhole-book";
- src = lib.fileset.toSource {
- root = ../.;
- fileset = lib.fileset.unions [
- ../book/book.toml
- ../book/src
- ../book/theme
- ];
- };
- nativeBuildInputs = [ pkgs.mdbook ];
- buildPhase = ''
- mdbook build book --dest-dir $out
- '';
- };
-
- checks = {
- inherit sandhole;
-
- sandhole-clippy = craneLib.cargoClippy (
- commonArgs
- // {
- inherit cargoArtifacts;
- }
- );
-
- sandhole-doc = craneLib.cargoDoc (
- commonArgs
- // {
- inherit cargoArtifacts;
- }
- );
-
- sandhole-fmt = craneLib.cargoFmt {
- inherit src;
- };
-
- sandhole-test =
- let
- sandhole-nextest-archive = craneLib.mkCargoDerivation (
- commonArgs
- // {
- inherit cargoArtifacts;
- pname = "sandhole-nextest-archive";
- doCheck = false;
- nativeBuildInputs = (commonArgs.nativeBuildInputs or [ ]) ++ [ pkgs.cargo-nextest ];
- buildPhaseCargoCommand = ''
- cargo nextest archive --archive-format tar-zst --archive-file archive.tar.zst
- '';
- installPhaseCommand = ''
- mkdir -p $out
- cp archive.tar.zst $out
- '';
- }
- );
- in
- pkgs.testers.runNixOSTest {
- name = "sandhole-nextest";
- nodes = {
- machine =
- { pkgs, ... }:
- {
- virtualisation.diskSize = 4096;
- environment.defaultPackages = [
- pkgs.cargo
- pkgs.rustc
- ];
- systemd.services.sandhole-nextest = {
- description = "Sandhole tests";
- wantedBy = [ "multi-user.target" ];
- after = [ "network-online.target" ];
- wants = [ "network-online.target" ];
- path = [
- pkgs.cargo
- pkgs.cargo-nextest
- ];
- script = ''
- cp -r ${src}/* .
- cargo nextest run \
- --archive-file ${sandhole-nextest-archive}/archive.tar.zst \
- --workspace-remap .
- '';
- serviceConfig = {
- StateDirectory = "sandhole-nextest";
- StateDirectoryMode = "0750";
- WorkingDirectory = "/var/lib/sandhole-nextest";
- Type = "oneshot";
- RemainAfterExit = "yes";
- Restart = "no";
- };
- };
- };
- };
- testScript = ''
- machine.start()
- machine.wait_for_unit("sandhole-nextest.service")
- '';
- };
- };
-}
diff --git a/nix/packages.nix b/nix/packages.nix
new file mode 100644
index 0000000..e6ab445
--- /dev/null
+++ b/nix/packages.nix
@@ -0,0 +1,60 @@
+{
+ lib,
+ mdbook,
+ nixosOptionsDoc,
+ sandhole,
+ stdenv,
+ to-html,
+}:
+let
+ evalOptions = lib.evalModules {
+ modules = [
+ (
+ { config, pkgs, ... }:
+ {
+ options =
+ (import ./modules/sandhole.nix {
+ inherit
+ pkgs
+ lib
+ config
+ ;
+ }).options;
+ }
+ )
+ ];
+ };
+in
+{
+ inherit sandhole;
+ default = sandhole;
+
+ _docs =
+ (nixosOptionsDoc {
+ options = removeAttrs evalOptions.options [ "_module" ];
+ }).optionsCommonMark;
+
+ _cli = stdenv.mkDerivation {
+ name = "sandhole-cli.html";
+ nativeBuildInputs = [ to-html ];
+ buildCommand = ''
+ to-html --no-prompt "${lib.getExe sandhole} --help" > $out
+ '';
+ };
+
+ _book = stdenv.mkDerivation {
+ name = "sandhole-book";
+ src = lib.fileset.toSource {
+ root = ../.;
+ fileset = lib.fileset.unions [
+ ../book/book.toml
+ ../book/src
+ ../book/theme
+ ];
+ };
+ nativeBuildInputs = [ mdbook ];
+ buildPhase = ''
+ mdbook build book --dest-dir $out
+ '';
+ };
+}
diff --git a/shell.nix b/shell.nix
index 961b459..440bf35 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,16 +1,4 @@
{
system ? builtins.currentSystem,
}:
-let
- inherit (import ./nix/lib.nix { inherit system; }) pkgs craneLib;
-in
-craneLib.devShell {
- packages = [
- pkgs.cargo-flamegraph
- pkgs.cargo-nextest
- pkgs.just
- pkgs.mdbook
- pkgs.minica
- pkgs.to-html
- ];
-}
+(import ./nix { inherit system; }).shell
diff --git a/src/config.rs b/src/config.rs
index aaff424..11d8a50 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -399,6 +399,14 @@ pub struct ApplicationConfig {
#[arg(long, value_parser = validate_duration, value_name = "DURATION")]
pub pool_timeout: Option,
+ /// Maximum number of simultaneous connections per IP to a proxied service.
+ /// The maximum is 65535.
+ ///
+ /// A low value may lead to client side disruptions,
+ /// while a high value may lead to denial-of-service.
+ #[arg(long, default_value_t = 16, value_name = "SIZE")]
+ pub max_simultaneous_connections_per_ip: u16,
+
/// How long to wait between each keepalive message that is sent to an unresponsive SSH connection.
#[arg(long, default_value = "15s", value_parser = validate_duration, value_name = "DURATION")]
pub ssh_keepalive_interval: Duration,
@@ -567,6 +575,7 @@ mod application_config_tests {
buffer_size: 32_768,
pool_size: 256,
pool_timeout: None,
+ max_simultaneous_connections_per_ip: 16,
ssh_keepalive_interval: Duration::from_secs(15),
ssh_keepalive_max: 3,
directory_poll_interval: Duration::from_secs(15),
@@ -628,6 +637,7 @@ mod application_config_tests {
"--buffer-size=4KB",
"--pool-size=2048",
"--pool-timeout=9s",
+ "--max-simultaneous-connections-per-ip=32",
"--ssh-keepalive-interval=10s",
"--ssh-keepalive-max=2",
"--directory-poll-interval=10s",
@@ -691,6 +701,7 @@ mod application_config_tests {
buffer_size: 4_000,
pool_size: 2_048,
pool_timeout: Some(Duration::from_secs(9)),
+ max_simultaneous_connections_per_ip: 32,
ssh_keepalive_interval: Duration::from_secs(10),
ssh_keepalive_max: 2,
directory_poll_interval: Duration::from_secs(10),
diff --git a/src/entrypoint.rs b/src/entrypoint.rs
index 09d38af..eee86da 100644
--- a/src/entrypoint.rs
+++ b/src/entrypoint.rs
@@ -658,6 +658,7 @@ pub async fn entrypoint(config: ApplicationConfig) -> color_eyre::Result<()> {
disable_aliasing: config.disable_aliasing,
buffer_size,
pool_size: usize::from(config.pool_size),
+ max_connections_per_ip: usize::from(config.max_simultaneous_connections_per_ip),
pool_timeout: config.pool_timeout,
rate_limit: config
.rate_limit_per_user
diff --git a/src/error.rs b/src/error.rs
index 93927d9..8d00c67 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -29,6 +29,8 @@ pub(crate) enum ServerError {
AliasingNotAllowed,
#[error("Pool limit reached")]
PoolLimitReached,
+ #[error("IP connection limit reached")]
+ IpConnectionLimitReached,
#[error("SSH error: {0}")]
Ssh(#[from] russh::Error),
}
diff --git a/src/lib.rs b/src/lib.rs
index 10f1cdb..968b21f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -154,6 +154,8 @@ pub(crate) struct SandholeServer {
pub(crate) buffer_size: usize,
// Pool size for SSH handlers.
pub(crate) pool_size: usize,
+ // Maximum simultaneous connections per IP for SSH handlers.
+ pub(crate) max_connections_per_ip: usize,
// Rate limit per second for services of a single user.
pub(crate) rate_limit: f64,
// How long until a login API request is timed out.
diff --git a/src/ssh/connection_handler.rs b/src/ssh/connection_handler.rs
index f21a345..b2d9a88 100644
--- a/src/ssh/connection_handler.rs
+++ b/src/ssh/connection_handler.rs
@@ -5,7 +5,9 @@ use std::{
time::Duration,
};
+use ahash::RandomState;
use async_speed_limit::{Limiter, Resource, clock::StandardClock};
+use dashmap::DashMap;
use russh::{ChannelStream, keys::ssh_key::Fingerprint, server::Msg};
use tokio::{
io::{AsyncRead, AsyncWrite},
@@ -20,10 +22,25 @@ use crate::{
ssh::{FingerprintFn, ServerHandlerSender},
};
+struct IpConnectionGuard {
+ ip: IpAddr,
+ _permit: OwnedSemaphorePermit,
+ ip_connections: Arc, RandomState>>,
+}
+
+impl Drop for IpConnectionGuard {
+ fn drop(&mut self) {
+ self.ip_connections
+ .remove_if(&self.ip, |_, semaphore| Arc::strong_count(semaphore) == 1);
+ }
+}
+
// Reference-counted wrapper of an SSH channel stream.
pub(crate) struct SshChannel {
// AsyncRead + AsyncWrite implementer being wrapped.
inner: Resource, StandardClock>,
+ // IP connection guard that the connection is being used until it is dropped.
+ _ip_connection_guard: IpConnectionGuard,
// Pool permit that signals that the connection is being used until it is dropped.
_pool_permit: OwnedSemaphorePermit,
}
@@ -75,6 +92,10 @@ pub(crate) struct SshTunnelHandler {
pub(crate) pool: Arc,
// How long should a connection wait for a spot in the pool before being timed out.
pub(crate) pool_timeout: Option,
+ // Track number of active connections per IP.
+ pub(crate) ip_connections: Arc, RandomState>>,
+ // Maximum connections allowed per IP.
+ pub(crate) max_connections_per_ip: usize,
// Optional IP filtering for this handler's tunneling and aliasing channels.
pub(crate) ip_filter: Arc>>,
// Handle to the SSH connection, in order to create remote forwarding channels.
@@ -119,6 +140,7 @@ impl ConnectionHandler for SshTunnelHandler {
.as_ref()
.is_none_or(|filter| filter.is_allowed(ip));
if tunneling_allowed {
+ let ip_connection_guard = self.acquire_ip_guard(ip)?;
let pool = Arc::clone(&self.pool);
let pool_permit = if let Some(duration) = self.pool_timeout {
let Ok(Ok(pool_permit)) =
@@ -145,6 +167,7 @@ impl ConnectionHandler for SshTunnelHandler {
.into_stream();
Ok(SshChannel {
inner: self.limiter.clone().limit(channel),
+ _ip_connection_guard: ip_connection_guard,
_pool_permit: pool_permit,
})
} else {
@@ -170,6 +193,7 @@ impl ConnectionHandler for SshTunnelHandler {
fingerprint: Option<&'_ Fingerprint>,
) -> Result {
if self.can_alias(ip, port, fingerprint) {
+ let ip_connection_guard = self.acquire_ip_guard(ip)?;
let pool = Arc::clone(&self.pool);
let pool_permit = if let Some(duration) = self.pool_timeout {
let Ok(Ok(pool_permit)) =
@@ -196,6 +220,7 @@ impl ConnectionHandler for SshTunnelHandler {
.into_stream();
Ok(SshChannel {
inner: self.limiter.clone().limit(channel),
+ _ip_connection_guard: ip_connection_guard,
_pool_permit: pool_permit,
})
} else {
@@ -213,3 +238,23 @@ impl ConnectionHandler for SshTunnelHandler {
)
}
}
+
+impl SshTunnelHandler {
+ fn acquire_ip_guard(&self, ip: IpAddr) -> Result {
+ let semaphore = {
+ let entry = self
+ .ip_connections
+ .entry(ip)
+ .or_insert(Arc::new(Semaphore::new(self.max_connections_per_ip)));
+ Arc::clone(&entry.value())
+ };
+ let Ok(_permit) = semaphore.try_acquire_owned() else {
+ return Err(ServerError::IpConnectionLimitReached);
+ };
+ Ok(IpConnectionGuard {
+ ip,
+ ip_connections: Arc::clone(&self.ip_connections),
+ _permit,
+ })
+ }
+}
diff --git a/src/ssh/forwarding.rs b/src/ssh/forwarding.rs
index e812928..08618b3 100644
--- a/src/ssh/forwarding.rs
+++ b/src/ssh/forwarding.rs
@@ -262,6 +262,8 @@ impl ForwardingHandlerStrategy for SshForwardingHandler {
http_data: None,
pool: Arc::clone(&semaphore),
pool_timeout: context.server.pool_timeout,
+ ip_connections: Arc::default(),
+ max_connections_per_ip: context.server.max_connections_per_ip,
ip_filter: Arc::clone(&context.user_data.ip_filter),
handle,
tx: context.tx.clone(),
@@ -540,6 +542,8 @@ impl ForwardingHandlerStrategy for HttpForwardingHandler {
http_data: Some(Arc::clone(&context.user_data.http_data)),
pool: Arc::clone(&semaphore),
pool_timeout: context.server.pool_timeout,
+ ip_connections: Arc::default(),
+ max_connections_per_ip: context.server.max_connections_per_ip,
ip_filter: Arc::clone(&context.user_data.ip_filter),
handle,
tx: context.tx.clone(),
@@ -615,6 +619,8 @@ impl ForwardingHandlerStrategy for HttpForwardingHandler {
http_data: Some(Arc::clone(&context.user_data.http_data)),
pool: Arc::clone(&semaphore),
pool_timeout: context.server.pool_timeout,
+ ip_connections: Arc::default(),
+ max_connections_per_ip: context.server.max_connections_per_ip,
ip_filter: Arc::clone(&context.user_data.ip_filter),
handle,
tx: context.tx.clone(),
@@ -729,6 +735,8 @@ impl ForwardingHandlerStrategy for HttpForwardingHandler {
http_data: Some(Arc::clone(&context.user_data.http_data)),
pool: Arc::clone(&semaphore),
pool_timeout: context.server.pool_timeout,
+ ip_connections: Arc::default(),
+ max_connections_per_ip: context.server.max_connections_per_ip,
ip_filter: Arc::clone(&context.user_data.ip_filter),
handle,
tx: context.tx.clone(),
@@ -1135,6 +1143,8 @@ impl ForwardingHandlerStrategy for AliasForwardingHandler {
http_data: None,
pool: Arc::clone(&semaphore),
pool_timeout: context.server.pool_timeout,
+ ip_connections: Arc::default(),
+ max_connections_per_ip: context.server.max_connections_per_ip,
ip_filter: Arc::clone(&context.user_data.ip_filter),
handle,
tx: context.tx.clone(),
@@ -1589,6 +1599,8 @@ impl ForwardingHandlerStrategy for TcpForwardingHandler {
http_data: None,
pool: Arc::clone(&semaphore),
pool_timeout: context.server.pool_timeout,
+ ip_connections: Arc::default(),
+ max_connections_per_ip: context.server.max_connections_per_ip,
ip_filter: Arc::clone(&context.user_data.ip_filter),
handle,
tx: context.tx.clone(),
diff --git a/tests/integration/alias_ip_connections_limit.rs b/tests/integration/alias_ip_connections_limit.rs
new file mode 100644
index 0000000..a7272b5
--- /dev/null
+++ b/tests/integration/alias_ip_connections_limit.rs
@@ -0,0 +1,252 @@
+use std::{sync::Arc, time::Duration};
+
+use clap::Parser;
+use rand::{Rng, SeedableRng};
+use rand_chacha::ChaCha20Rng;
+use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key};
+use russh::{
+ Channel,
+ client::{Msg, Session},
+ keys::ssh_key::private::Ed25519Keypair,
+};
+use sandhole::{ApplicationConfig, entrypoint};
+use tokio::{
+ net::TcpStream,
+ time::{sleep, timeout},
+};
+
+use crate::common::SandholeHandle;
+
+/// This test ensures that no more aliased connections from the same IP
+/// than the specified limit are able to connect at the same time.
+#[test_log::test(tokio::test(flavor = "multi_thread"))]
+async fn alias_ip_connections_limit() {
+ // 1. Initialize Sandhole
+ let config = ApplicationConfig::parse_from([
+ "sandhole",
+ "--domain=foobar.tld",
+ "--user-keys-directory",
+ &(format!(
+ "{}/tests/data/user_keys",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--admin-keys-directory",
+ &(format!(
+ "{}/tests/data/admin_keys",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--certificates-directory",
+ &(format!(
+ "{}/tests/data/certificates",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--private-key-file",
+ &(format!(
+ "{}/tests/data/server_keys/ssh",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--acme-cache-directory",
+ &(format!(
+ "{}/tests/data/acme_cache",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--disable-directory-creation",
+ "--listen-address=::",
+ "--ssh-port=18022",
+ "--http-port=18080",
+ "--https-port=18443",
+ "--acme-use-staging",
+ "--bind-hostnames=all",
+ "--idle-connection-timeout=1s",
+ "--authentication-request-timeout=5s",
+ "--max-simultaneous-connections-per-ip=1",
+ ]);
+ let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await }));
+ if timeout(Duration::from_secs(5), async {
+ while TcpStream::connect("127.0.0.1:18022").await.is_err() {
+ sleep(Duration::from_millis(100)).await;
+ }
+ })
+ .await
+ .is_err()
+ {
+ panic!("Timeout waiting for Sandhole to start.")
+ };
+
+ // 2. Start SSH client that will be proxied
+ let key = load_secret_key(
+ std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
+ .join("tests/data/private_keys/key1"),
+ None,
+ )
+ .expect("Missing file key1");
+ let ssh_client = SshClient;
+ let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
+ .await
+ .expect("Failed to connect to SSH server");
+ assert!(
+ session
+ .authenticate_publickey(
+ "user",
+ PrivateKeyWithHashAlg::new(
+ Arc::new(key),
+ session.best_supported_rsa_hash().await.unwrap().flatten()
+ )
+ )
+ .await
+ .expect("SSH authentication failed")
+ .success(),
+ "authentication didn't succeed"
+ );
+ session
+ .tcpip_forward("some.alias", 12345)
+ .await
+ .expect("tcpip_forward failed");
+
+ // 3. Start long-running request that takes the spot for the IP
+ let key = russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
+ &ChaCha20Rng::from_os_rng().random(),
+ ));
+ let ssh_client = SshClient;
+ let mut client_session =
+ russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
+ .await
+ .expect("Failed to connect to SSH server");
+ assert!(
+ client_session
+ .authenticate_publickey(
+ "user",
+ PrivateKeyWithHashAlg::new(
+ Arc::new(key),
+ client_session
+ .best_supported_rsa_hash()
+ .await
+ .unwrap()
+ .flatten()
+ )
+ )
+ .await
+ .expect("SSH authentication failed")
+ .success(),
+ "authentication didn't succeed"
+ );
+ let mut channel = client_session
+ .channel_open_direct_tcpip("some.alias", 12345, "::1", 23456)
+ .await
+ .expect("Local forwarding failed");
+ let jh = tokio::spawn(async move {
+ while let Some(msg) = channel.wait().await {
+ if let russh::ChannelMsg::Data { data } = msg {
+ assert_eq!(&data[..], &b"Ping"[..]);
+ break;
+ }
+ }
+ drop(client_session);
+ });
+
+ // 4. Start request that gets rate-limited from IP connection exhaustion
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ let key = russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
+ &ChaCha20Rng::from_os_rng().random(),
+ ));
+ let ssh_client = SshClient;
+ let mut client_session =
+ russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
+ .await
+ .expect("Failed to connect to SSH server");
+ assert!(
+ client_session
+ .authenticate_publickey(
+ "user",
+ PrivateKeyWithHashAlg::new(
+ Arc::new(key),
+ client_session
+ .best_supported_rsa_hash()
+ .await
+ .unwrap()
+ .flatten()
+ )
+ )
+ .await
+ .expect("SSH authentication failed")
+ .success(),
+ "authentication didn't succeed"
+ );
+ assert!(
+ client_session
+ .channel_open_direct_tcpip("some.alias", 12345, "::1", 23456)
+ .await
+ .is_err()
+ );
+
+ // 5. Start request from different IP that succeeds
+ let key = russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
+ &ChaCha20Rng::from_os_rng().random(),
+ ));
+ let ssh_client = SshClient;
+ let mut client_session = russh::client::connect(Default::default(), "[::1]:18022", ssh_client)
+ .await
+ .expect("Failed to connect to SSH server");
+ assert!(
+ client_session
+ .authenticate_publickey(
+ "user",
+ PrivateKeyWithHashAlg::new(
+ Arc::new(key),
+ client_session
+ .best_supported_rsa_hash()
+ .await
+ .unwrap()
+ .flatten()
+ )
+ )
+ .await
+ .expect("SSH authentication failed")
+ .success(),
+ "authentication didn't succeed"
+ );
+ assert!(
+ client_session
+ .channel_open_direct_tcpip("some.alias", 12345, "::1", 23456)
+ .await
+ .is_ok(),
+ "local forwarding failed"
+ );
+
+ timeout(Duration::from_secs(5), async move {
+ jh.await.unwrap();
+ })
+ .await
+ .expect("timeout waiting for join handle to finish");
+}
+
+struct SshClient;
+
+impl russh::client::Handler for SshClient {
+ type Error = color_eyre::eyre::Error;
+
+ async fn check_server_key(
+ &mut self,
+ _key: &russh::keys::PublicKey,
+ ) -> Result {
+ Ok(true)
+ }
+
+ async fn server_channel_open_forwarded_tcpip(
+ &mut self,
+ channel: Channel,
+ _connected_address: &str,
+ _connected_port: u32,
+ _originator_address: &str,
+ _originator_port: u32,
+ _session: &mut Session,
+ ) -> Result<(), Self::Error> {
+ tokio::spawn(async move {
+ tokio::time::sleep(Duration::from_secs(3)).await;
+ channel.data(&b"Ping"[..]).await.unwrap();
+ channel.eof().await.unwrap();
+ channel.close().await.unwrap();
+ });
+ Ok(())
+ }
+}
diff --git a/tests/integration/alias_pool_limit.rs b/tests/integration/alias_pool_limit.rs
index 5a1b844..31fe6d1 100644
--- a/tests/integration/alias_pool_limit.rs
+++ b/tests/integration/alias_pool_limit.rs
@@ -21,7 +21,7 @@ use tokio::{
use crate::common::SandholeHandle;
-/// This test ensures that no more alialiased connections than the specified pool limit
+/// This test ensures that no more aliased connections than the specified pool limit
/// are able to connect at the same time.
#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn alias_pool_limit() {
@@ -63,7 +63,6 @@ async fn alias_pool_limit() {
"--bind-hostnames=all",
"--idle-connection-timeout=1s",
"--authentication-request-timeout=5s",
- "--http-request-timeout=10s",
"--pool-size=10",
]);
let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await }));
diff --git a/tests/integration/alias_pool_timeout.rs b/tests/integration/alias_pool_timeout.rs
index 255653e..e08c6a1 100644
--- a/tests/integration/alias_pool_timeout.rs
+++ b/tests/integration/alias_pool_timeout.rs
@@ -168,7 +168,7 @@ async fn alias_pool_timeout() {
jhs.push(jh);
}
- // 3. Start request that gets rate-limited from pool exhaustion
+ // 4. Start request that gets rate-limited from pool exhaustion
tokio::time::sleep(Duration::from_millis(500)).await;
let key = russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
&ChaCha20Rng::from_os_rng().random(),
@@ -203,7 +203,7 @@ async fn alias_pool_timeout() {
.is_err()
);
- // 4. Start request that gets queued and eventually completes
+ // 5. Start request that gets queued and eventually completes
tokio::time::sleep(Duration::from_millis(1000)).await;
let key = russh::keys::PrivateKey::from(Ed25519Keypair::from_seed(
&ChaCha20Rng::from_os_rng().random(),
diff --git a/tests/integration/http_pool_limit.rs b/tests/integration/http_pool_limit.rs
index 9dad41e..f14f1f9 100644
--- a/tests/integration/http_pool_limit.rs
+++ b/tests/integration/http_pool_limit.rs
@@ -158,7 +158,7 @@ async fn http_pool_limit() {
jhs.push(jh);
}
- // 3. Start request that gets rate-limited from pool exhaustion
+ // 4. Start request that gets rate-limited from pool exhaustion
tokio::time::sleep(Duration::from_millis(500)).await;
let tcp_stream = TcpStream::connect("127.0.0.1:18080")
.await
diff --git a/tests/integration/http_pool_timeout.rs b/tests/integration/http_pool_timeout.rs
index ed90216..8b0675c 100644
--- a/tests/integration/http_pool_timeout.rs
+++ b/tests/integration/http_pool_timeout.rs
@@ -163,7 +163,7 @@ async fn http_pool_timeout() {
jhs.push(jh);
}
- // 3. Start request that gets rate-limited from pool exhaustion
+ // 4. Start request that gets rate-limited from pool exhaustion
tokio::time::sleep(Duration::from_millis(500)).await;
let tcp_stream = TcpStream::connect("127.0.0.1:18080")
.await
@@ -195,7 +195,7 @@ async fn http_pool_timeout() {
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
jh.abort();
- // 4. Start request that gets queued and eventually completes
+ // 5. Start request that gets queued and eventually completes
tokio::time::sleep(Duration::from_millis(1000)).await;
let tcp_stream = TcpStream::connect("127.0.0.1:18080")
.await
diff --git a/tests/integration/main.rs b/tests/integration/main.rs
index 1ce6480..d278cd7 100644
--- a/tests/integration/main.rs
+++ b/tests/integration/main.rs
@@ -11,6 +11,7 @@ mod admin_window_change;
mod alias_aliasing_tunnel;
mod alias_cannot_be_localhost;
mod alias_http_aliases;
+mod alias_ip_connections_limit;
mod alias_local_forward_existing_http;
mod alias_pool_limit;
mod alias_pool_timeout;
@@ -86,6 +87,7 @@ mod tcp_allow_requested_ports;
mod tcp_assign_random_port_0;
mod tcp_bind_random_ports;
mod tcp_fail_to_bind_port_if_taken;
+mod tcp_ip_connections_limit;
mod tcp_multi_stream_download;
mod tcp_multi_stream_upload;
mod tcp_no_valid_forwarding;
diff --git a/tests/integration/tcp_ip_connections_limit.rs b/tests/integration/tcp_ip_connections_limit.rs
new file mode 100644
index 0000000..d987bc0
--- /dev/null
+++ b/tests/integration/tcp_ip_connections_limit.rs
@@ -0,0 +1,174 @@
+use std::{
+ sync::Arc,
+ time::{Duration, Instant},
+};
+
+use clap::Parser;
+use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key};
+use russh::{
+ Channel,
+ client::{Msg, Session},
+};
+use sandhole::{ApplicationConfig, entrypoint};
+use tokio::{
+ io::AsyncReadExt,
+ net::TcpStream,
+ time::{sleep, timeout},
+};
+
+use crate::common::SandholeHandle;
+
+/// This test ensures that no more TCP connections from the same IP
+/// than the specified limit are able to connect at the same time.
+#[test_log::test(tokio::test(flavor = "multi_thread"))]
+async fn tcp_ip_connections_limit() {
+ // 1. Initialize Sandhole
+ let config = ApplicationConfig::parse_from([
+ "sandhole",
+ "--domain=foobar.tld",
+ "--user-keys-directory",
+ &(format!(
+ "{}/tests/data/user_keys",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--admin-keys-directory",
+ &(format!(
+ "{}/tests/data/admin_keys",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--certificates-directory",
+ &(format!(
+ "{}/tests/data/certificates",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--private-key-file",
+ &(format!(
+ "{}/tests/data/server_keys/ssh",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--acme-cache-directory",
+ &(format!(
+ "{}/tests/data/acme_cache",
+ std::env::var("CARGO_MANIFEST_DIR").unwrap()
+ )),
+ "--disable-directory-creation",
+ "--listen-address=::",
+ "--ssh-port=18022",
+ "--http-port=18080",
+ "--https-port=18443",
+ "--acme-use-staging",
+ "--allow-requested-ports",
+ "--idle-connection-timeout=1s",
+ "--authentication-request-timeout=5s",
+ "--max-simultaneous-connections-per-ip=1",
+ ]);
+ let _sandhole_handle = SandholeHandle(tokio::spawn(async move { entrypoint(config).await }));
+ if timeout(Duration::from_secs(5), async {
+ while TcpStream::connect("127.0.0.1:18022").await.is_err() {
+ sleep(Duration::from_millis(100)).await;
+ }
+ })
+ .await
+ .is_err()
+ {
+ panic!("Timeout waiting for Sandhole to start.")
+ };
+
+ // 2. Start SSH client that will be proxied
+ let key = load_secret_key(
+ std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
+ .join("tests/data/private_keys/key1"),
+ None,
+ )
+ .expect("Missing file key1");
+ let ssh_client = SshClient;
+ let mut session = russh::client::connect(Default::default(), "127.0.0.1:18022", ssh_client)
+ .await
+ .expect("Failed to connect to SSH server");
+ assert!(
+ session
+ .authenticate_publickey(
+ "user",
+ PrivateKeyWithHashAlg::new(
+ Arc::new(key),
+ session.best_supported_rsa_hash().await.unwrap().flatten()
+ )
+ )
+ .await
+ .expect("SSH authentication failed")
+ .success(),
+ "authentication didn't succeed"
+ );
+ session
+ .tcpip_forward("localhost", 12345)
+ .await
+ .expect("tcpip_forward failed");
+
+ // 3. Start long-running request that takes the spot for the IP
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ let started = Instant::now();
+ let mut tcp_stream = TcpStream::connect("127.0.0.1:12345")
+ .await
+ .expect("TCP connection failed");
+ let jh = tokio::spawn(async move {
+ let mut data = [0u8; 10];
+ tcp_stream.read_exact(&mut data).await.unwrap();
+ assert_eq!(data, b"0123456789"[..]);
+ });
+
+ // 4. Start request that gets rate-limited from IP connection exhaustion
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ let mut tcp_stream = TcpStream::connect("127.0.0.1:12345")
+ .await
+ .expect("TCP connection failed");
+ let mut data = [0u8; 10];
+ assert!(tcp_stream.read_exact(&mut data).await.is_err());
+
+ // 5. Start request from different IP that succeeds
+ tokio::time::sleep(Duration::from_millis(1000)).await;
+ let mut tcp_stream = TcpStream::connect("[::1]:12345")
+ .await
+ .expect("TCP connection failed");
+ let mut data = [0u8; 10];
+ assert!(started.elapsed() < Duration::from_secs(5));
+ tcp_stream.read_exact(&mut data).await.unwrap();
+ assert!(started.elapsed() > Duration::from_secs(5));
+ assert_eq!(data, b"0123456789"[..]);
+
+ timeout(Duration::from_secs(10), async move {
+ jh.await.unwrap();
+ })
+ .await
+ .expect("timeout waiting for join handle to finish");
+}
+
+struct SshClient;
+
+impl russh::client::Handler for SshClient {
+ type Error = color_eyre::eyre::Error;
+
+ async fn check_server_key(
+ &mut self,
+ _key: &russh::keys::PublicKey,
+ ) -> Result {
+ Ok(true)
+ }
+
+ async fn server_channel_open_forwarded_tcpip(
+ &mut self,
+ channel: Channel,
+ _connected_address: &str,
+ _connected_port: u32,
+ _originator_address: &str,
+ _originator_port: u32,
+ _session: &mut Session,
+ ) -> Result<(), Self::Error> {
+ tokio::spawn(async move {
+ tokio::time::sleep(Duration::from_secs(5)).await;
+ channel.data(&b"0123456789"[..]).await.unwrap();
+ channel.eof().await.unwrap();
+ channel.close().await.unwrap();
+ });
+ Ok(())
+ }
+}
diff --git a/tests/integration/tcp_pool_timeout.rs b/tests/integration/tcp_pool_timeout.rs
index 0fe816b..38861fa 100644
--- a/tests/integration/tcp_pool_timeout.rs
+++ b/tests/integration/tcp_pool_timeout.rs
@@ -60,7 +60,6 @@ async fn tcp_pool_timeout() {
"--allow-requested-ports",
"--idle-connection-timeout=1s",
"--authentication-request-timeout=5s",
- "--http-request-timeout=10s",
"--pool-size=2",
"--pool-timeout=3s",
]);
@@ -122,7 +121,7 @@ async fn tcp_pool_timeout() {
jhs.push(jh);
}
- // 3. Start request that gets rate-limited from pool exhaustion
+ // 4. Start request that gets rate-limited from pool exhaustion
tokio::time::sleep(Duration::from_millis(500)).await;
let mut tcp_stream = TcpStream::connect("127.0.0.1:12345")
.await
@@ -130,7 +129,7 @@ async fn tcp_pool_timeout() {
let mut data = [0u8; 10];
assert!(tcp_stream.read_exact(&mut data).await.is_err());
- // 4. Start request that gets queued and eventually completes
+ // 5. Start request that gets queued and eventually completes
tokio::time::sleep(Duration::from_millis(1000)).await;
let mut tcp_stream = TcpStream::connect("127.0.0.1:12345")
.await