From c7a4c841e197529117c3261aefa24e08f38e7db3 Mon Sep 17 00:00:00 2001 From: Eric Rodrigues Pires Date: Fri, 20 Feb 2026 09:38:50 -0300 Subject: [PATCH] Multiple improvements - Add `--max-simultaneous-connections-per-ip` CLI flag (closes #47) - Refactor Nix --- .gitignore | 1 - CHANGELOG.md | 1 + book/src/cli.md | 42 +-- book/src/nixos_options.md | 236 +++++++++++++++- default.nix | 2 +- flake.nix | 23 +- justfile | 22 +- nix/checks.nix | 92 +++++++ nix/default.nix | 96 +++++++ nix/lib.nix | 191 ------------- nix/packages.nix | 60 +++++ shell.nix | 14 +- src/config.rs | 11 + src/entrypoint.rs | 1 + src/error.rs | 2 + src/lib.rs | 2 + src/ssh/connection_handler.rs | 45 ++++ src/ssh/forwarding.rs | 12 + .../integration/alias_ip_connections_limit.rs | 252 ++++++++++++++++++ tests/integration/alias_pool_limit.rs | 3 +- tests/integration/alias_pool_timeout.rs | 4 +- tests/integration/http_pool_limit.rs | 2 +- tests/integration/http_pool_timeout.rs | 4 +- tests/integration/main.rs | 2 + tests/integration/tcp_ip_connections_limit.rs | 174 ++++++++++++ tests/integration/tcp_pool_timeout.rs | 5 +- 26 files changed, 1027 insertions(+), 272 deletions(-) create mode 100644 nix/checks.nix create mode 100644 nix/default.nix delete mode 100644 nix/lib.nix create mode 100644 nix/packages.nix create mode 100644 tests/integration/alias_ip_connections_limit.rs create mode 100644 tests/integration/tcp_ip_connections_limit.rs 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 -- 2.51.2