├── tests ├── async-ssh2-tokio │ ├── test-upload-file │ └── Dockerfile ├── sshd_debug.sh ├── run_unit_tests.sh ├── sshd-test │ └── Dockerfile ├── docker-compose.yml ├── generate_test_keys.sh ├── local_unit_tests.sh └── debug_sshd_config ├── .gitignore ├── .github └── workflows │ ├── ci.yml │ └── super_lint.yml ├── .env ├── Cargo.toml ├── src ├── error.rs ├── lib.rs ├── to_socket_addrs_with_hostname.rs └── client.rs ├── README.md └── LICENSE /tests/async-ssh2-tokio/test-upload-file: -------------------------------------------------------------------------------- 1 | this is a test file 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .vscode 4 | *.ed25519 5 | *.pub 6 | authorized_keys 7 | *id_ed25519* 8 | ssh_host_*_key* 9 | known_hosts 10 | /.idea/ 11 | -------------------------------------------------------------------------------- /tests/sshd_debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script runs another instance of sshd in verbose debug mode on port 2222 4 | # for debugging SSH connections. It does not use the system's host key. 5 | # sudo is required so it can access system and user folders. 6 | 7 | # change to script dir 8 | cd "${0%/*}" || exit 1 9 | 10 | ./generate_test_keys.sh || exit 1 11 | 12 | # NOTE the host key must be an absolute path 13 | sudo /usr/sbin/sshd -D -e -f "$PWD/debug_sshd_config" -h "$PWD/server.ed25519" -p 2222 14 | -------------------------------------------------------------------------------- /tests/run_unit_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # change to script dir 4 | cd "${0%/*}" || exit 1 5 | 6 | ./generate_test_keys.sh || exit 1 7 | 8 | cd .. 9 | 10 | docker compose -f ./tests/docker-compose.yml build --no-cache || exit 1 11 | 12 | docker compose -f ./tests/docker-compose.yml up -d || exit 1 13 | 14 | # Start ssh-agent in the container and run tests 15 | docker compose -f ./tests/docker-compose.yml exec -T async-ssh2-tokio bash -c ' 16 | eval $(ssh-agent -s) 17 | ssh-add /root/.ssh/id_ed25519 18 | cargo test -- --test-threads=2 19 | ' 20 | RET=$? 21 | 22 | docker compose -f ./tests/docker-compose.yml down 23 | 24 | exit $RET 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: [push, pull_request] 4 | env: 5 | RUSTFLAGS: "-Dwarnings" 6 | 7 | jobs: 8 | build-and-unit-test: 9 | name: Test unit-test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | - name: Run unit tests 15 | shell: bash 16 | run: ./tests/run_unit_tests.sh 17 | rust-checks: 18 | runs-on: ubuntu-latest 19 | name: Cargo Clippy and Format 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Rust Formatting 23 | run: cargo fmt --check 24 | - name: Run Clippy 25 | run: cargo clippy --all-targets --all-features 26 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ASYNC_SSH2_TEST_HOST_IP=10.10.10.2 2 | ASYNC_SSH2_TEST_HOST_PW=root 3 | ASYNC_SSH2_TEST_HOST_USER=root 4 | ASYNC_SSH2_TEST_KNOWN_HOSTS=./tests/async-ssh2-tokio/known_hosts 5 | ASYNC_SSH2_TEST_CLIENT_PRIV=./tests/client.ed25519 6 | ASYNC_SSH2_TEST_CLIENT_PUB=./tests/client.ed25519.pub 7 | ASYNC_SSH2_TEST_CLIENT_PROT_PRIV=./tests/client.prot.ed25519 8 | ASYNC_SSH2_TEST_CLIENT_PROT_PUB=./tests/client.prot.ed25519.pub 9 | ASYNC_SSH2_TEST_CLIENT_PROT_PASS=test 10 | ASYNC_SSH2_TEST_HOST_PORT=22 11 | ASYNC_SSH2_TEST_SERVER_PUB=./tests/sshd-test/ssh_host_ed25519_key.pub 12 | ASYNC_SSH2_TEST_UPLOAD_FILE=./tests/async-ssh2-tokio/test-upload-file 13 | ASYNC_SSH2_TEST_HTTP_SERVER_IP=10.10.10.4 14 | ASYNC_SSH2_TEST_HTTP_SERVER_PORT=8000 15 | ASYNC_SSH2_TEST_HOST_NAME=localhost 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "async-ssh2-tokio" 3 | version = "0.12.1" 4 | edition = "2024" 5 | license-file = "LICENSE" 6 | readme = "README.md" 7 | description = "Asynchronous and easy-to-use high level ssh client library for rust." 8 | keywords = ["async", "asynchronous", "ssh", "ssh2", "ssh-client"] 9 | repository = "https://github.com/Miyoshi-Ryota/async-ssh2-tokio" 10 | categories = ["network-programming"] 11 | authors = ["Miyoshi-Ryota "] 12 | 13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 14 | 15 | [features] 16 | openssl = [] 17 | 18 | [dependencies] 19 | russh = "0.55.0" 20 | log = "0.4" 21 | russh-sftp = "2.1.1" 22 | thiserror = "2.0.17" 23 | tokio = { version = "1.45.1", features = ["fs"] } 24 | 25 | [dev-dependencies] 26 | dotenv = "0.15.0" 27 | -------------------------------------------------------------------------------- /tests/sshd-test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/ubuntu:24.04 2 | 3 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 4 | 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | ENV TZ=Europe/Warsaw 7 | 8 | # hadolint ignore=DL3008 9 | RUN apt-get update && \ 10 | apt-get install -y --no-install-recommends openssh-server openssh-sftp-server && \ 11 | apt-get clean && \ 12 | rm -rf /var/lib/apt/lists/* 13 | 14 | RUN echo 'root:root' |chpasswd 15 | 16 | RUN sed -ri 's/^#?PermitRootLogin\s+.*/PermitRootLogin yes/g' /etc/ssh/sshd_config 17 | RUN sed -ri 's/^#?PasswordAuthentication.*$/PasswordAuthentication yes/g' /etc/ssh/sshd_config 18 | RUN sed -ri 's/^#?KbdInteractiveAuthentication.*$/KbdInteractiveAuthentication yes/g' /etc/ssh/sshd_config 19 | 20 | RUN mkdir /var/run/sshd 21 | 22 | COPY ssh_host_ed25519_key ssh_host_ed25519_key.pub /etc/ssh/ 23 | COPY authorized_keys /root/.ssh/authorized_keys 24 | 25 | RUN chmod 600 ~/.ssh/authorized_keys 26 | 27 | EXPOSE 22 28 | 29 | CMD ["/usr/sbin/sshd", "-D", "-e"] 30 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | ssh-server: 4 | platform: linux/amd64 5 | build: 6 | context: ./sshd-test 7 | container_name: ssh-server 8 | networks: 9 | ssh-network: 10 | ipv4_address: 10.10.10.2 11 | # The HTTP server is used for the `direct-tcpip` channel test. 12 | http-server: 13 | image: python:alpine 14 | command: > 15 | sh -c " 16 | echo -n Hello > index.html && 17 | python -m http.server 18 | " 19 | networks: 20 | ssh-network: 21 | ipv4_address: 10.10.10.4 22 | async-ssh2-tokio: 23 | build: # Change build context to be copy async-ssh2-tokio which is located parent directory. 24 | context: ../ 25 | dockerfile: ./tests/async-ssh2-tokio/Dockerfile 26 | tty: true 27 | networks: 28 | ssh-network: 29 | ipv4_address: 10.10.10.3 30 | depends_on: 31 | - ssh-server 32 | - http-server 33 | networks: 34 | ssh-network: 35 | driver: bridge 36 | ipam: 37 | config: 38 | - subnet: 10.10.10.0/24 39 | -------------------------------------------------------------------------------- /.github/workflows/super_lint.yml: -------------------------------------------------------------------------------- 1 | name: Super linter 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint-code-base: 7 | name: Apply Super Linter 8 | # Set the agent to run on 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Code 12 | uses: actions/checkout@v4 13 | with: 14 | # Full git history is needed to get a proper list of changed files within `super-linter` 15 | fetch-depth: 0 16 | ################################ 17 | # Run Linter against code base # 18 | ################################ 19 | - name: Lint Code Base 20 | uses: super-linter/super-linter@v7.4.0 21 | env: 22 | VALIDATE_JSCPD: false 23 | # Disable till Rust 1.8.5 24 | VALIDATE_RUST_CLIPPY: false 25 | VALIDATE_RUST_2015: false 26 | VALIDATE_RUST_2018: false 27 | VALIDATE_RUST_2021: false 28 | VALIDATE_SHELL_SHFMT: false 29 | VALIDATE_CHECKOV: false 30 | VALIDATE_ENV: false 31 | VALIDATE_MARKDOWN_PRETTIER: false 32 | DEFAULT_BRANCH: main 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /tests/async-ssh2-tokio/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.90.0 2 | 3 | ENV ASYNC_SSH2_TEST_HOST_IP=10.10.10.2 4 | ENV ASYNC_SSH2_TEST_HOST_USER=root 5 | ENV ASYNC_SSH2_TEST_HOST_PW=root 6 | ENV ASYNC_SSH2_TEST_HTTP_SERVER_IP=10.10.10.4 7 | ENV ASYNC_SSH2_TEST_HTTP_SERVER_PORT=8000 8 | ENV ASYNC_SSH2_TEST_CLIENT_PRIV=/root/.ssh/id_ed25519 9 | ENV ASYNC_SSH2_TEST_CLIENT_PROT_PRIV=/root/.ssh/prot.id_ed25519 10 | ENV ASYNC_SSH2_TEST_CLIENT_PROT_PASS=test 11 | ENV ASYNC_SSH2_TEST_SERVER_PUB=/root/server.ed25519.pub 12 | ENV ASYNC_SSH2_TEST_HOST_PORT=22 13 | ENV ASYNC_SSH2_TEST_HOST_NAME=ssh-server 14 | ENV ASYNC_SSH2_TEST_KNOWN_HOSTS=/root/.ssh/known_hosts 15 | ENV ASYNC_SSH2_TEST_UPLOAD_FILE=/async-ssh2-tokio/tests/async-ssh2-tokio/test-upload-file 16 | 17 | COPY tests/async-ssh2-tokio/id_ed25519 /root/.ssh/id_ed25519 18 | COPY tests/async-ssh2-tokio/id_ed25519.pub /root/.ssh/id_ed25519.pub 19 | COPY tests/async-ssh2-tokio/prot.id_ed25519 /root/.ssh/prot.id_ed25519 20 | COPY tests/async-ssh2-tokio/prot.id_ed25519.pub /root/.ssh/prot.id_ed25519.pub 21 | COPY tests/async-ssh2-tokio/server.ed25519.pub /root/server.ed25519.pub 22 | COPY tests/async-ssh2-tokio/known_hosts /root/.ssh/known_hosts 23 | 24 | WORKDIR /async-ssh2-tokio 25 | COPY . . 26 | -------------------------------------------------------------------------------- /tests/generate_test_keys.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # change to script dir 4 | cd "${0%/*}" || exit 1 5 | 6 | # generate keys if not present 7 | [ -e "server.ed25519" ] || ssh-keygen -t ed25519 -q -f "server.ed25519" -N "" || exit 1 8 | [ -e "client.ed25519" ] || ssh-keygen -t ed25519 -q -f "client.ed25519" -N "" || exit 1 9 | [ -e "client.prot.ed25519" ] || ssh-keygen -t ed25519 -q -f "client.prot.ed25519" -N "test" || exit 1 10 | 11 | # copy files into the Dockerfile folders 12 | cp server.ed25519 sshd-test/ssh_host_ed25519_key 13 | cp server.ed25519.pub sshd-test/ssh_host_ed25519_key.pub 14 | cp client.ed25519 async-ssh2-tokio/id_ed25519 15 | cp client.ed25519.pub async-ssh2-tokio/id_ed25519.pub 16 | cp client.prot.ed25519 async-ssh2-tokio/prot.id_ed25519 17 | cp client.prot.ed25519.pub async-ssh2-tokio/prot.id_ed25519.pub 18 | cp server.ed25519.pub async-ssh2-tokio 19 | 20 | # setup authorized keys 21 | rm -f authorized_keys 22 | cat client.ed25519.pub >> authorized_keys 23 | cat client.prot.ed25519.pub >> authorized_keys 24 | mv authorized_keys sshd-test 25 | 26 | # setup known_hosts 27 | export ASYNC_SSH2_TEST_HOST_IP=10.10.10.2 28 | export ASYNC_SSH2_TEST_HOST_NAME=ssh-server 29 | awk -v IP=$ASYNC_SSH2_TEST_HOST_IP '{print IP, $1, $2}' server.ed25519.pub > async-ssh2-tokio/known_hosts 30 | awk -v HOST=$ASYNC_SSH2_TEST_HOST_NAME '{print HOST, $1, $2}' server.ed25519.pub >> async-ssh2-tokio/known_hosts 31 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use tokio::sync::mpsc; 4 | 5 | /// This is the `thiserror` error for all crate errors. 6 | /// 7 | /// Most ssh related error is wrapped in the `SshError` variant, 8 | /// giving access to the underlying [`russh::Error`] type. 9 | #[derive(thiserror::Error, Debug)] 10 | #[non_exhaustive] 11 | pub enum Error { 12 | #[error("Keyboard-interactive authentication failed")] 13 | KeyboardInteractiveAuthFailed, 14 | #[error("No keyboard-interactive response for prompt: {0}")] 15 | KeyboardInteractiveNoResponseForPrompt(String), 16 | #[error("Key authentication failed")] 17 | KeyAuthFailed, 18 | #[error("Unable to load key, bad format or passphrase: {0}")] 19 | KeyInvalid(russh::keys::Error), 20 | #[error("Password authentication failed")] 21 | PasswordWrong, 22 | #[error("Invalid address was provided: {0}")] 23 | AddressInvalid(io::Error), 24 | #[error("The executed command didn't send an exit code")] 25 | CommandDidntExit, 26 | #[error("Server check failed")] 27 | ServerCheckFailed, 28 | #[error("Ssh error occured: {0}")] 29 | SshError(#[from] russh::Error), 30 | #[error("Send error")] 31 | SendError(#[from] russh::SendError), 32 | #[error("Agent auth error")] 33 | AgentAuthError(#[from] russh::AgentAuthError), 34 | #[error("Failed to connect to SSH agent")] 35 | AgentConnectionFailed, 36 | #[error("Failed to request identities from SSH agent")] 37 | AgentRequestIdentitiesFailed, 38 | #[error("SSH agent has no identities")] 39 | AgentNoIdentities, 40 | #[error("SSH agent authentication failed")] 41 | AgentAuthenticationFailed, 42 | #[error("SFTP error occured: {0}")] 43 | SftpError(#[from] russh_sftp::client::error::Error), 44 | #[error("I/O error")] 45 | IoError(#[from] io::Error), 46 | #[error("Channel send error")] 47 | ChannelSendError(#[from] mpsc::error::SendError>), 48 | } 49 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This library is an asynchronous and easy-to-use high level ssh client library 2 | //! for rust with the tokio runtime. Powered by the rust ssh implementation 3 | //! [russh](https://github.com/warp-tech/russh), a fork of thrussh. 4 | //! 5 | //! The heart of this library is [`client::Client`]. Use this for connection, authentification and execution. 6 | //! 7 | //! # Features 8 | //! * Connect to a SSH Host via IP 9 | //! * Execute commands on the remote host 10 | //! * Get the stdout and exit code of the command 11 | //! 12 | //! # Example 13 | //! ```no_run 14 | //! use async_ssh2_tokio::client::{Client, AuthMethod, ServerCheckMethod}; 15 | //! #[tokio::main] 16 | //! async fn main() -> Result<(), async_ssh2_tokio::Error> { 17 | //! // if you want to use key auth, then use following: 18 | //! // AuthMethod::with_key_file("key_file_name", Some("passphrase")); 19 | //! // or 20 | //! // AuthMethod::with_key_file("key_file_name", None); 21 | //! // or 22 | //! // AuthMethod::with_key(key: &str, passphrase: Option<&str>) 23 | //! // if you want to use SSH agent (Unix/Linux only), then use following: 24 | //! // AuthMethod::with_agent(); 25 | //! let auth_method = AuthMethod::with_password("root"); 26 | //! let mut client = Client::connect( 27 | //! ("10.10.10.2", 22), 28 | //! "root", 29 | //! auth_method, 30 | //! ServerCheckMethod::NoCheck, 31 | //! ).await?; 32 | //! 33 | //! let result = client.execute("echo Hello SSH").await?; 34 | //! assert_eq!(result.stdout, "Hello SSH\n"); 35 | //! assert_eq!(result.exit_status, 0); 36 | //! 37 | //! let result = client.execute("echo Hello Again :)").await?; 38 | //! assert_eq!(result.stdout, "Hello Again :)\n"); 39 | //! assert_eq!(result.exit_status, 0); 40 | //! 41 | //! Ok(()) 42 | //! } 43 | //! ``` 44 | 45 | pub mod client; 46 | pub mod error; 47 | mod to_socket_addrs_with_hostname; 48 | 49 | pub use client::{AuthMethod, Client, ServerCheckMethod}; 50 | pub use error::Error; 51 | pub use to_socket_addrs_with_hostname::ToSocketAddrsWithHostname; 52 | 53 | pub use russh::client::Config; 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-ssh2-tokio 2 | ![Unit Test Status](https://github.com/Miyoshi-Ryota/async-ssh2-tokio/actions/workflows/ci.yml/badge.svg) 3 | ![Lint Status](https://github.com/Miyoshi-Ryota/async-ssh2-tokio/actions/workflows/super_lint.yml/badge.svg) 4 | [![Docs.rs](https://docs.rs/async-ssh2-tokio/badge.svg)](https://docs.rs/async-ssh2-tokio/latest/async_ssh2_tokio/) 5 | [![Crates.io](https://img.shields.io/crates/v/async-ssh2-tokio.svg)](https://crates.io/crates/async-ssh2-tokio) 6 | 7 | This library is an asynchronous and easy-to-use high level SSH client library 8 | for rust with the tokio runtime. Powered by the rust SSH implementation 9 | [russh](https://github.com/warp-tech/russh). 10 | 11 | 12 | ## Features 13 | * Connect to an SSH Host 14 | * Execute commands on the remote host 15 | * Get the stdout and exit code of the command 16 | 17 | ## Install 18 | ```toml 19 | [dependencies] 20 | tokio = "1" 21 | async-ssh2-tokio = "0.12.1" 22 | ``` 23 | 24 | ## Usage 25 | ```rust 26 | use async_ssh2_tokio::client::{Client, AuthMethod, ServerCheckMethod}; 27 | 28 | #[tokio::main] 29 | async fn main() -> Result<(), async_ssh2_tokio::Error> { 30 | // if you want to use key auth, then use following: 31 | // AuthMethod::with_key_file("key_file_name", Some("passphrase")); 32 | // or 33 | // AuthMethod::with_key_file("key_file_name", None); 34 | // or 35 | // AuthMethod::with_key(key: &str, passphrase: Option<&str>) 36 | // if you want to use SSH agent (Unix/Linux only), then use following: 37 | // AuthMethod::with_agent(); 38 | let auth_method = AuthMethod::with_password("root"); 39 | let mut client = Client::connect( 40 | ("10.10.10.2", 22), 41 | "root", 42 | auth_method, 43 | ServerCheckMethod::NoCheck, 44 | ).await?; 45 | 46 | let result = client.execute("echo Hello SSH").await?; 47 | assert_eq!(result.stdout, "Hello SSH\n"); 48 | assert_eq!(result.exit_status, 0); 49 | 50 | let result = client.execute("echo Hello Again :)").await?; 51 | assert_eq!(result.stdout, "Hello Again :)\n"); 52 | assert_eq!(result.exit_status, 0); 53 | 54 | Ok(()) 55 | } 56 | ``` 57 | 58 | ## Running Tests 59 | 1. install docker and docker compose 60 | 2. run shell script `./tests/run_unit_tests.sh` 61 | -------------------------------------------------------------------------------- /tests/local_unit_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script makes it easy to run the cargo tests locally without dockers 4 | # a user "test" with password "test" is required 5 | # It will setup all the required environment variables the authorized_keys 6 | # By default, it will connect to the local SSH server on port 22 and use the system host key 7 | # If "debug" is passed in as argument, it will connect to port 2222 and use unique host key (see sshd_debug.sh) 8 | 9 | # verify sshpass is installed 10 | if ! command -v sshpass &> /dev/null 11 | then 12 | echo "sshpass not installed (sudo apt install sshpass)" 13 | exit 1 14 | fi 15 | 16 | cd "${0%/*}" || exit 1 17 | 18 | ./generate_test_keys.sh || exit 1 19 | 20 | # setup variables used by cargo test 21 | export ASYNC_SSH2_TEST_HOST_IP="127.0.0.1" 22 | export ASYNC_SSH2_TEST_HOST_PW="test" 23 | export ASYNC_SSH2_TEST_HOST_USER="test" 24 | export ASYNC_SSH2_TEST_CLIENT_PRIV="$PWD/client.ed25519" 25 | export ASYNC_SSH2_TEST_CLIENT_PUB="$PWD/client.ed25519.pub" 26 | export ASYNC_SSH2_TEST_CLIENT_PROT_PRIV="$PWD/client.prot.ed25519" 27 | export ASYNC_SSH2_TEST_CLIENT_PROT_PUB="$PWD/client.prot.ed25519.pub" 28 | export ASYNC_SSH2_TEST_CLIENT_PROT_PASS="test" 29 | if [ "$1" == debug ] ; then 30 | export ASYNC_SSH2_TEST_HOST_PORT="2222" 31 | export ASYNC_SSH2_TEST_SERVER_PUB="$PWD/server.ed25519.pub" 32 | else 33 | export ASYNC_SSH2_TEST_HOST_PORT="22" 34 | export ASYNC_SSH2_TEST_SERVER_PUB="/etc/ssh/ssh_host_ed25519_key.pub" 35 | fi 36 | 37 | # make sure client pub key is in authorized keys for test user 38 | 39 | # use ssh-copy-id for the non-protected one since it will make all folders and files 40 | sshpass -p "$ASYNC_SSH2_TEST_HOST_PW" ssh-copy-id -o StrictHostKeyChecking=no -p "$ASYNC_SSH2_TEST_HOST_PORT" -i "$ASYNC_SSH2_TEST_CLIENT_PRIV" "$ASYNC_SSH2_TEST_HOST_USER"@"$ASYNC_SSH2_TEST_HOST_IP" || exit 1 41 | 42 | # manually copy the protected one since ssh-copy-id has issues with it and we are going to use the non-protected one to do it 43 | ssh -i "$ASYNC_SSH2_TEST_CLIENT_PRIV" -p "$ASYNC_SSH2_TEST_HOST_PORT" "$ASYNC_SSH2_TEST_HOST_USER"@"$ASYNC_SSH2_TEST_HOST_IP" \ 44 | 'cat - >> ~/.ssh/authorized_keys; sort -u ~/.ssh/authorized_keys -o ~/.ssh/authorized_keys' < "$ASYNC_SSH2_TEST_CLIENT_PROT_PUB" || exit 1 45 | 46 | cargo test -- --test-threads=2 47 | -------------------------------------------------------------------------------- /src/to_socket_addrs_with_hostname.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6, ToSocketAddrs}; 3 | 4 | pub trait ToSocketAddrsWithHostname { 5 | fn to_socket_addrs(&self) -> io::Result>; 6 | fn hostname(&self) -> String; 7 | } 8 | 9 | impl ToSocketAddrsWithHostname for String { 10 | fn to_socket_addrs(&self) -> io::Result> { 11 | self.as_str().to_socket_addrs().map(|iter| iter.collect()) 12 | } 13 | fn hostname(&self) -> String { 14 | self.clone() 15 | } 16 | } 17 | 18 | impl ToSocketAddrsWithHostname for &str { 19 | fn to_socket_addrs(&self) -> io::Result> { 20 | ToSocketAddrs::to_socket_addrs(self).map(|iter| iter.collect()) 21 | } 22 | fn hostname(&self) -> String { 23 | self.to_string() 24 | } 25 | } 26 | 27 | impl ToSocketAddrsWithHostname for (&str, u16) { 28 | fn to_socket_addrs(&self) -> io::Result> { 29 | ToSocketAddrs::to_socket_addrs(self).map(|iter| iter.collect()) 30 | } 31 | fn hostname(&self) -> String { 32 | self.0.to_string() 33 | } 34 | } 35 | 36 | impl ToSocketAddrsWithHostname for (String, u16) { 37 | fn to_socket_addrs(&self) -> io::Result> { 38 | ToSocketAddrs::to_socket_addrs(self).map(|iter| iter.collect()) 39 | } 40 | fn hostname(&self) -> String { 41 | self.0.clone() 42 | } 43 | } 44 | 45 | impl ToSocketAddrsWithHostname for (IpAddr, u16) { 46 | fn to_socket_addrs(&self) -> io::Result> { 47 | ToSocketAddrs::to_socket_addrs(self).map(|iter| iter.collect()) 48 | } 49 | fn hostname(&self) -> String { 50 | format!("{}", self.0) 51 | } 52 | } 53 | 54 | impl ToSocketAddrsWithHostname for (Ipv4Addr, u16) { 55 | fn to_socket_addrs(&self) -> io::Result> { 56 | ToSocketAddrs::to_socket_addrs(self).map(|iter| iter.collect()) 57 | } 58 | fn hostname(&self) -> String { 59 | format!("{}", self.0) 60 | } 61 | } 62 | 63 | impl ToSocketAddrsWithHostname for (Ipv6Addr, u16) { 64 | fn to_socket_addrs(&self) -> io::Result> { 65 | ToSocketAddrs::to_socket_addrs(self).map(|iter| iter.collect()) 66 | } 67 | fn hostname(&self) -> String { 68 | format!("{}", self.0) 69 | } 70 | } 71 | 72 | impl ToSocketAddrsWithHostname for SocketAddr { 73 | fn to_socket_addrs(&self) -> io::Result> { 74 | Ok(vec![*self]) 75 | } 76 | fn hostname(&self) -> String { 77 | format!("{}", self.ip()) 78 | } 79 | } 80 | 81 | impl ToSocketAddrsWithHostname for SocketAddrV4 { 82 | fn to_socket_addrs(&self) -> io::Result> { 83 | Ok(vec![SocketAddr::V4(*self)]) 84 | } 85 | fn hostname(&self) -> String { 86 | format!("{}", self.ip()) 87 | } 88 | } 89 | 90 | impl ToSocketAddrsWithHostname for SocketAddrV6 { 91 | fn to_socket_addrs(&self) -> io::Result> { 92 | Ok(vec![SocketAddr::V6(*self)]) 93 | } 94 | fn hostname(&self) -> String { 95 | format!("{}", self.ip()) 96 | } 97 | } 98 | 99 | impl ToSocketAddrsWithHostname for &[SocketAddr] { 100 | fn to_socket_addrs(&self) -> io::Result> { 101 | Ok(self.to_vec()) 102 | } 103 | 104 | fn hostname(&self) -> String { 105 | self.iter() 106 | .map(|addr| addr.ip().to_string()) 107 | .collect::>() 108 | .join(",") 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/debug_sshd_config: -------------------------------------------------------------------------------- 1 | 2 | # This is the sshd server system-wide configuration file. See 3 | # sshd_config(5) for more information. 4 | 5 | # This sshd was compiled with PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games 6 | 7 | # The strategy used for options in the default sshd_config shipped with 8 | # OpenSSH is to specify options with their default value where 9 | # possible, but leave them commented. Uncommented options override the 10 | # default value. 11 | 12 | # Include /etc/ssh/sshd_config.d/*.conf 13 | 14 | # Port 2222 15 | #AddressFamily any 16 | #ListenAddress 0.0.0.0 17 | #ListenAddress :: 18 | 19 | #HostKey /etc/ssh/ssh_host_rsa_key 20 | #HostKey /etc/ssh/ssh_host_ecdsa_key 21 | #HostKey /etc/ssh/ssh_host_ed25519_key 22 | 23 | # Ciphers and keying 24 | #RekeyLimit default none 25 | 26 | # Logging 27 | #SyslogFacility AUTH 28 | LogLevel DEBUG 29 | 30 | # Authentication: 31 | 32 | #LoginGraceTime 2m 33 | #PermitRootLogin prohibit-password 34 | #StrictModes yes 35 | #MaxAuthTries 6 36 | #MaxSessions 10 37 | 38 | #PubkeyAuthentication yes 39 | 40 | # Expect .ssh/authorized_keys2 to be disregarded by default in future. 41 | AuthorizedKeysFile .ssh/authorized_keys 42 | 43 | #AuthorizedPrincipalsFile none 44 | 45 | #AuthorizedKeysCommand none 46 | #AuthorizedKeysCommandUser nobody 47 | 48 | # For this to work you will also need host keys in /etc/ssh/ssh_known_hosts 49 | #HostbasedAuthentication no 50 | # Change to yes if you don't trust ~/.ssh/known_hosts for 51 | # HostbasedAuthentication 52 | #IgnoreUserKnownHosts no 53 | # Don't read the user's ~/.rhosts and ~/.shosts files 54 | #IgnoreRhosts yes 55 | 56 | # To disable tunneled clear text passwords, change to no here! 57 | PasswordAuthentication yes 58 | PermitEmptyPasswords yes 59 | 60 | # Change to yes to enable challenge-response passwords (beware issues with 61 | # some PAM modules and threads) 62 | KbdInteractiveAuthentication no 63 | 64 | # Kerberos options 65 | #KerberosAuthentication no 66 | #KerberosOrLocalPasswd yes 67 | #KerberosTicketCleanup yes 68 | #KerberosGetAFSToken no 69 | 70 | # GSSAPI options 71 | #GSSAPIAuthentication no 72 | #GSSAPICleanupCredentials yes 73 | #GSSAPIStrictAcceptorCheck yes 74 | #GSSAPIKeyExchange no 75 | 76 | # Set this to 'yes' to enable PAM authentication, account processing, 77 | # and session processing. If this is enabled, PAM authentication will 78 | # be allowed through the KbdInteractiveAuthentication and 79 | # PasswordAuthentication. Depending on your PAM configuration, 80 | # PAM authentication via KbdInteractiveAuthentication may bypass 81 | # the setting of "PermitRootLogin without-password". 82 | # If you just want the PAM account and session checks to run without 83 | # PAM authentication, then enable this but set PasswordAuthentication 84 | # and KbdInteractiveAuthentication to 'no'. 85 | UsePAM yes 86 | 87 | #AllowAgentForwarding yes 88 | #AllowTcpForwarding yes 89 | #GatewayPorts no 90 | X11Forwarding yes 91 | #X11DisplayOffset 10 92 | #X11UseLocalhost yes 93 | #PermitTTY yes 94 | PrintMotd no 95 | #PrintLastLog yes 96 | #TCPKeepAlive yes 97 | #PermitUserEnvironment no 98 | #Compression delayed 99 | #ClientAliveInterval 0 100 | #ClientAliveCountMax 3 101 | #UseDNS no 102 | #PidFile /run/sshd.pid 103 | #MaxStartups 10:30:100 104 | #PermitTunnel no 105 | #ChrootDirectory none 106 | #VersionAddendum none 107 | 108 | # no default banner path 109 | #Banner none 110 | 111 | # Allow client to pass locale environment variables 112 | AcceptEnv LANG LC_* 113 | 114 | # override default of no subsystems 115 | Subsystem sftp /usr/lib/openssh/sftp-server 116 | 117 | # Example of overriding settings on a per-user basis 118 | #Match User anoncvs 119 | # X11Forwarding no 120 | # AllowTcpForwarding no 121 | # PermitTTY no 122 | # ForceCommand cvs server 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use russh::client::KeyboardInteractiveAuthResponse; 2 | use russh::{ 3 | Channel, 4 | client::{Config, Handle, Handler, Msg}, 5 | }; 6 | use russh_sftp::{client::SftpSession, protocol::OpenFlags}; 7 | use std::net::SocketAddr; 8 | use std::sync::Arc; 9 | use std::time::Instant; 10 | use std::{fmt::Debug, path::Path}; 11 | use std::{io, path::PathBuf}; 12 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 13 | use tokio::sync::mpsc; 14 | 15 | use crate::ToSocketAddrsWithHostname; 16 | 17 | /// An authentification token. 18 | /// 19 | /// Used when creating a [`Client`] for authentification. 20 | /// Supports password, private key, public key, SSH agent, and keyboard interactive authentication. 21 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 22 | #[non_exhaustive] 23 | pub enum AuthMethod { 24 | Password(String), 25 | PrivateKey { 26 | /// entire contents of private key file 27 | key_data: String, 28 | key_pass: Option, 29 | }, 30 | PrivateKeyFile { 31 | key_file_path: PathBuf, 32 | key_pass: Option, 33 | }, 34 | #[cfg(not(target_os = "windows"))] 35 | PublicKeyFile { 36 | key_file_path: PathBuf, 37 | }, 38 | #[cfg(not(target_os = "windows"))] 39 | Agent, 40 | KeyboardInteractive(AuthKeyboardInteractive), 41 | } 42 | 43 | #[derive(Debug, Clone, PartialEq, Eq)] 44 | pub enum SteamingOutput { 45 | Stdout(Vec), 46 | Stderr(Vec), 47 | ExitStatus(u32), 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 51 | struct PromptResponse { 52 | exact: bool, 53 | prompt: String, 54 | response: String, 55 | } 56 | 57 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] 58 | #[non_exhaustive] 59 | pub struct AuthKeyboardInteractive { 60 | /// Hnts to the server the preferred methods to be used for authentication. 61 | submethods: Option, 62 | responses: Vec, 63 | } 64 | 65 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 66 | #[non_exhaustive] 67 | pub enum ServerCheckMethod { 68 | NoCheck, 69 | /// base64 encoded key without the type prefix or hostname suffix (type is already encoded) 70 | PublicKey(String), 71 | PublicKeyFile(String), 72 | DefaultKnownHostsFile, 73 | KnownHostsFile(String), 74 | } 75 | 76 | impl AuthMethod { 77 | /// Convenience method to create a [`AuthMethod`] from a string literal. 78 | pub fn with_password(password: &str) -> Self { 79 | Self::Password(password.to_string()) 80 | } 81 | 82 | pub fn with_key(key: &str, passphrase: Option<&str>) -> Self { 83 | Self::PrivateKey { 84 | key_data: key.to_string(), 85 | key_pass: passphrase.map(str::to_string), 86 | } 87 | } 88 | 89 | pub fn with_key_file>(key_file_path: T, passphrase: Option<&str>) -> Self { 90 | Self::PrivateKeyFile { 91 | key_file_path: key_file_path.as_ref().to_path_buf(), 92 | key_pass: passphrase.map(str::to_string), 93 | } 94 | } 95 | 96 | #[cfg(not(target_os = "windows"))] 97 | pub fn with_public_key_file>(key_file_path: T) -> Self { 98 | Self::PublicKeyFile { 99 | key_file_path: key_file_path.as_ref().to_path_buf(), 100 | } 101 | } 102 | 103 | /// Creates a new SSH agent authentication method. 104 | /// 105 | /// This will attempt to authenticate using all identities available in the SSH agent. 106 | /// The SSH agent must be running and the SSH_AUTH_SOCK environment variable must be set. 107 | /// 108 | /// # Example 109 | /// ```no_run 110 | /// use async_ssh2_tokio::client::AuthMethod; 111 | /// 112 | /// let auth = AuthMethod::with_agent(); 113 | /// ``` 114 | /// 115 | /// # Platform Support 116 | /// This method is only available on Unix-like systems (Linux, macOS, etc.). 117 | /// It is not available on Windows. 118 | #[cfg(not(target_os = "windows"))] 119 | pub fn with_agent() -> Self { 120 | Self::Agent 121 | } 122 | 123 | pub const fn with_keyboard_interactive(auth: AuthKeyboardInteractive) -> Self { 124 | Self::KeyboardInteractive(auth) 125 | } 126 | } 127 | 128 | impl AuthKeyboardInteractive { 129 | pub fn new() -> Self { 130 | Default::default() 131 | } 132 | 133 | /// Hnts to the server the preferred methods to be used for authentication. 134 | pub fn with_submethods(mut self, submethods: impl Into) -> Self { 135 | self.submethods = Some(submethods.into()); 136 | self 137 | } 138 | 139 | /// Adds a response to the list of responses for a given prompt. 140 | /// 141 | /// The comparison for the prompt is done using a "contains". 142 | pub fn with_response(mut self, prompt: impl Into, response: impl Into) -> Self { 143 | self.responses.push(PromptResponse { 144 | exact: false, 145 | prompt: prompt.into(), 146 | response: response.into(), 147 | }); 148 | 149 | self 150 | } 151 | 152 | /// Adds a response to the list of responses for a given exact prompt. 153 | pub fn with_response_exact( 154 | mut self, 155 | prompt: impl Into, 156 | response: impl Into, 157 | ) -> Self { 158 | self.responses.push(PromptResponse { 159 | exact: true, 160 | prompt: prompt.into(), 161 | response: response.into(), 162 | }); 163 | 164 | self 165 | } 166 | } 167 | 168 | impl PromptResponse { 169 | fn matches(&self, received_prompt: &str) -> bool { 170 | if self.exact { 171 | self.prompt.eq(received_prompt) 172 | } else { 173 | received_prompt.contains(&self.prompt) 174 | } 175 | } 176 | } 177 | 178 | impl From for AuthMethod { 179 | fn from(value: AuthKeyboardInteractive) -> Self { 180 | Self::with_keyboard_interactive(value) 181 | } 182 | } 183 | 184 | impl ServerCheckMethod { 185 | /// Convenience method to create a [`ServerCheckMethod`] from a string literal. 186 | pub fn with_public_key(key: &str) -> Self { 187 | Self::PublicKey(key.to_string()) 188 | } 189 | 190 | /// Convenience method to create a [`ServerCheckMethod`] from a string literal. 191 | pub fn with_public_key_file(key_file_name: &str) -> Self { 192 | Self::PublicKeyFile(key_file_name.to_string()) 193 | } 194 | 195 | /// Convenience method to create a [`ServerCheckMethod`] from a string literal. 196 | pub fn with_known_hosts_file(known_hosts_file: &str) -> Self { 197 | Self::KnownHostsFile(known_hosts_file.to_string()) 198 | } 199 | } 200 | 201 | /// A ssh connection to a remote server. 202 | /// 203 | /// After creating a `Client` by [`connect`]ing to a remote host, 204 | /// use [`execute`] to send commands and receive results through the connections. 205 | /// 206 | /// [`connect`]: Client::connect 207 | /// [`execute`]: Client::execute 208 | /// 209 | /// # Examples 210 | /// 211 | /// ```no_run 212 | /// use async_ssh2_tokio::{Client, AuthMethod, ServerCheckMethod}; 213 | /// #[tokio::main] 214 | /// async fn main() -> Result<(), async_ssh2_tokio::Error> { 215 | /// let mut client = Client::connect( 216 | /// ("10.10.10.2", 22), 217 | /// "root", 218 | /// AuthMethod::with_password("root"), 219 | /// ServerCheckMethod::NoCheck, 220 | /// ).await?; 221 | /// 222 | /// let result = client.execute("echo Hello SSH").await?; 223 | /// assert_eq!(result.stdout, "Hello SSH\n"); 224 | /// assert_eq!(result.exit_status, 0); 225 | /// 226 | /// Ok(()) 227 | /// } 228 | #[derive(Clone)] 229 | pub struct Client { 230 | connection_handle: Arc>, 231 | username: String, 232 | address: SocketAddr, 233 | } 234 | 235 | impl Client { 236 | /// Open a ssh connection to a remote host. 237 | /// 238 | /// `addr` is an address of the remote host. Anything which implements 239 | /// [`ToSocketAddrsWithHostname`] trait can be supplied for the address; 240 | /// ToSocketAddrsWithHostname reimplements all of [`ToSocketAddrs`]; 241 | /// see this trait's documentation for concrete examples. 242 | /// 243 | /// If `addr` yields multiple addresses, `connect` will be attempted with 244 | /// each of the addresses until a connection is successful. 245 | /// Authentification is tried on the first successful connection and the whole 246 | /// process aborted if this fails. 247 | pub async fn connect( 248 | addr: impl ToSocketAddrsWithHostname, 249 | username: &str, 250 | auth: AuthMethod, 251 | server_check: ServerCheckMethod, 252 | ) -> Result { 253 | Self::connect_with_config(addr, username, auth, server_check, Config::default()).await 254 | } 255 | 256 | /// Same as `connect`, but with the option to specify a non default 257 | /// [`russh::client::Config`]. 258 | pub async fn connect_with_config( 259 | addr: impl ToSocketAddrsWithHostname, 260 | username: &str, 261 | auth: AuthMethod, 262 | server_check: ServerCheckMethod, 263 | config: Config, 264 | ) -> Result { 265 | let config = Arc::new(config); 266 | 267 | // Connection code inspired from std::net::TcpStream::connect and std::net::each_addr 268 | let socket_addrs = addr 269 | .to_socket_addrs() 270 | .map_err(crate::Error::AddressInvalid)?; 271 | let mut connect_res = Err(crate::Error::AddressInvalid(io::Error::new( 272 | io::ErrorKind::InvalidInput, 273 | "could not resolve to any addresses", 274 | ))); 275 | for socket_addr in socket_addrs { 276 | let handler = ClientHandler { 277 | hostname: addr.hostname(), 278 | host: socket_addr, 279 | server_check: server_check.clone(), 280 | }; 281 | match russh::client::connect(config.clone(), socket_addr, handler).await { 282 | Ok(h) => { 283 | connect_res = Ok((socket_addr, h)); 284 | break; 285 | } 286 | Err(e) => connect_res = Err(e), 287 | } 288 | } 289 | let (address, mut handle) = connect_res?; 290 | let username = username.to_string(); 291 | 292 | Self::authenticate(&mut handle, &username, auth).await?; 293 | 294 | Ok(Self { 295 | connection_handle: Arc::new(handle), 296 | username, 297 | address, 298 | }) 299 | } 300 | 301 | /// This takes a handle and performs authentification with the given method. 302 | async fn authenticate( 303 | handle: &mut Handle, 304 | username: &String, 305 | auth: AuthMethod, 306 | ) -> Result<(), crate::Error> { 307 | match auth { 308 | AuthMethod::Password(password) => { 309 | let is_authentificated = handle.authenticate_password(username, password).await?; 310 | if !is_authentificated.success() { 311 | return Err(crate::Error::PasswordWrong); 312 | } 313 | } 314 | AuthMethod::PrivateKey { key_data, key_pass } => { 315 | let cprivk = russh::keys::decode_secret_key(key_data.as_str(), key_pass.as_deref()) 316 | .map_err(crate::Error::KeyInvalid)?; 317 | let is_authentificated = handle 318 | .authenticate_publickey( 319 | username, 320 | russh::keys::PrivateKeyWithHashAlg::new( 321 | Arc::new(cprivk), 322 | handle.best_supported_rsa_hash().await?.flatten(), 323 | ), 324 | ) 325 | .await?; 326 | if !is_authentificated.success() { 327 | return Err(crate::Error::KeyAuthFailed); 328 | } 329 | } 330 | AuthMethod::PrivateKeyFile { 331 | key_file_path, 332 | key_pass, 333 | } => { 334 | let cprivk = russh::keys::load_secret_key(key_file_path, key_pass.as_deref()) 335 | .map_err(crate::Error::KeyInvalid)?; 336 | let is_authentificated = handle 337 | .authenticate_publickey( 338 | username, 339 | russh::keys::PrivateKeyWithHashAlg::new( 340 | Arc::new(cprivk), 341 | handle.best_supported_rsa_hash().await?.flatten(), 342 | ), 343 | ) 344 | .await?; 345 | if !is_authentificated.success() { 346 | return Err(crate::Error::KeyAuthFailed); 347 | } 348 | } 349 | #[cfg(not(target_os = "windows"))] 350 | AuthMethod::PublicKeyFile { key_file_path } => { 351 | let cpubk = russh::keys::load_public_key(key_file_path) 352 | .map_err(crate::Error::KeyInvalid)?; 353 | let mut agent = russh::keys::agent::client::AgentClient::connect_env() 354 | .await 355 | .unwrap(); 356 | let mut auth_identity: Option = None; 357 | for identity in agent 358 | .request_identities() 359 | .await 360 | .map_err(crate::Error::KeyInvalid)? 361 | { 362 | if identity == cpubk { 363 | auth_identity = Some(identity.clone()); 364 | break; 365 | } 366 | } 367 | 368 | if auth_identity.is_none() { 369 | return Err(crate::Error::KeyAuthFailed); 370 | } 371 | 372 | let is_authentificated = handle 373 | .authenticate_publickey_with( 374 | username, 375 | cpubk, 376 | handle.best_supported_rsa_hash().await?.flatten(), 377 | &mut agent, 378 | ) 379 | .await?; 380 | if !is_authentificated.success() { 381 | return Err(crate::Error::KeyAuthFailed); 382 | } 383 | } 384 | #[cfg(not(target_os = "windows"))] 385 | AuthMethod::Agent => { 386 | let mut agent = russh::keys::agent::client::AgentClient::connect_env() 387 | .await 388 | .map_err(|_| crate::Error::AgentConnectionFailed)?; 389 | 390 | let identities = agent 391 | .request_identities() 392 | .await 393 | .map_err(|_| crate::Error::AgentRequestIdentitiesFailed)?; 394 | 395 | if identities.is_empty() { 396 | return Err(crate::Error::AgentNoIdentities); 397 | } 398 | 399 | let mut auth_success = false; 400 | for identity in identities { 401 | let result = handle 402 | .authenticate_publickey_with( 403 | username, 404 | identity.clone(), 405 | handle.best_supported_rsa_hash().await?.flatten(), 406 | &mut agent, 407 | ) 408 | .await; 409 | 410 | if let Ok(auth_result) = result 411 | && auth_result.success() 412 | { 413 | auth_success = true; 414 | break; 415 | } 416 | } 417 | 418 | if !auth_success { 419 | return Err(crate::Error::AgentAuthenticationFailed); 420 | } 421 | } 422 | AuthMethod::KeyboardInteractive(mut kbd) => { 423 | let mut res = handle 424 | .authenticate_keyboard_interactive_start(username, kbd.submethods) 425 | .await?; 426 | loop { 427 | let prompts = match res { 428 | KeyboardInteractiveAuthResponse::Success => break, 429 | KeyboardInteractiveAuthResponse::Failure { .. } => { 430 | return Err(crate::Error::KeyboardInteractiveAuthFailed); 431 | } 432 | KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => prompts, 433 | }; 434 | 435 | let mut responses = vec![]; 436 | for prompt in prompts { 437 | let Some(pos) = kbd 438 | .responses 439 | .iter() 440 | .position(|pr| pr.matches(&prompt.prompt)) 441 | else { 442 | return Err(crate::Error::KeyboardInteractiveNoResponseForPrompt( 443 | prompt.prompt, 444 | )); 445 | }; 446 | let pr = kbd.responses.remove(pos); 447 | responses.push(pr.response); 448 | } 449 | 450 | res = handle 451 | .authenticate_keyboard_interactive_respond(responses) 452 | .await?; 453 | } 454 | } 455 | }; 456 | Ok(()) 457 | } 458 | 459 | pub async fn get_channel(&self) -> Result, crate::Error> { 460 | self.connection_handle 461 | .channel_open_session() 462 | .await 463 | .map_err(crate::Error::SshError) 464 | } 465 | 466 | /// Open a TCP/IP forwarding channel. 467 | /// 468 | /// This opens a `direct-tcpip` channel to the given target. 469 | pub async fn open_direct_tcpip_channel< 470 | T: ToSocketAddrsWithHostname, 471 | S: Into>, 472 | >( 473 | &self, 474 | target: T, 475 | src: S, 476 | ) -> Result, crate::Error> { 477 | let targets = target 478 | .to_socket_addrs() 479 | .map_err(crate::Error::AddressInvalid)?; 480 | let src = src 481 | .into() 482 | .map(|src| (src.ip().to_string(), src.port().into())) 483 | .unwrap_or_else(|| ("127.0.0.1".to_string(), 22)); 484 | 485 | let mut connect_err = crate::Error::AddressInvalid(io::Error::new( 486 | io::ErrorKind::InvalidInput, 487 | "could not resolve to any addresses", 488 | )); 489 | for target in targets { 490 | match self 491 | .connection_handle 492 | .channel_open_direct_tcpip( 493 | target.ip().to_string(), 494 | target.port().into(), 495 | src.0.clone(), 496 | src.1, 497 | ) 498 | .await 499 | { 500 | Ok(channel) => return Ok(channel), 501 | Err(err) => connect_err = crate::Error::SshError(err), 502 | } 503 | } 504 | 505 | Err(connect_err) 506 | } 507 | 508 | /// Upload a file with sftp to the remote server. 509 | /// 510 | /// `src_file_path` is the path to the file on the local machine. 511 | /// `dest_file_path` is the path to the file on the remote machine. 512 | /// 'timeout_seconds' is the timeout, in seconds, for the operation, passed on to the sftp session. 513 | /// If not specified it will default to the underlying value in the sftp code, which as of this writing is 10 seconds. 514 | /// 'buffer_size_in_bytes' is the value this function will buffer the file through. it defaults to 4KB. 515 | /// 'show_progress' if true, logs will be emitted every 5% of the file upload, measured in bytes 516 | /// Some sshd_config does not enable sftp by default, so make sure it is enabled. 517 | /// A config line like a `Subsystem sftp internal-sftp` or 518 | /// `Subsystem sftp /usr/lib/openssh/sftp-server` is needed in the sshd_config in remote machine. 519 | pub async fn upload_file( 520 | &self, 521 | src_file_path: T, 522 | //fa993: This cannot be AsRef because of underlying lib constraints as described here 523 | //https://github.com/AspectUnk/russh-sftp/issues/7#issuecomment-1738355245 524 | dest_file_path: U, 525 | timeout_seconds: Option, 526 | buffer_size_in_bytes: Option, 527 | show_progress: bool, 528 | ) -> Result<(), crate::Error> 529 | where 530 | T: AsRef + std::fmt::Display, 531 | U: Into, 532 | { 533 | // start sftp session 534 | let channel = self.get_channel().await?; 535 | channel.request_subsystem(true, "sftp").await?; 536 | let sftp = SftpSession::new_opts(channel.into_stream(), timeout_seconds).await?; 537 | 538 | let file_size = tokio::fs::metadata(&src_file_path).await?.len(); 539 | // read file contents locally 540 | let local_file = tokio::fs::File::open(&src_file_path) 541 | .await 542 | .map_err(crate::Error::IoError)?; 543 | let mut local_file_buffered = tokio::io::BufReader::new(local_file); 544 | 545 | let dest_file_path = dest_file_path.into(); 546 | let mut remote_file = sftp 547 | .open_with_flags( 548 | dest_file_path.clone(), 549 | OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE | OpenFlags::READ, 550 | ) 551 | .await?; 552 | 553 | let buffer_size_in_bytes = buffer_size_in_bytes.unwrap_or(4096); 554 | let mut buffer = vec![0; buffer_size_in_bytes]; 555 | 556 | let mut total_bytes_copied = 0; 557 | let mut next_progress_marker = 5.0; 558 | 559 | let start_time = Instant::now(); 560 | if show_progress { 561 | log::info!( 562 | "Starting file upload from {src_file_path} to {dest_file_path}, total bytes to be transferred: {}", 563 | file_size 564 | ); 565 | } 566 | loop { 567 | let n = local_file_buffered.read(&mut buffer).await?; 568 | if n == 0 { 569 | break; 570 | } 571 | remote_file 572 | .write_all(&buffer[..n]) 573 | .await 574 | .map_err(crate::Error::IoError)?; 575 | if show_progress { 576 | total_bytes_copied += n as u64; 577 | let progress = (total_bytes_copied as f64 / file_size as f64) * 100.0; 578 | if progress >= next_progress_marker { 579 | log::info!( 580 | "Progress of upload from {src_file_path} to {dest_file_path}: {:.0}% in elapsed time: {}s", 581 | next_progress_marker, 582 | start_time.elapsed().as_secs_f64() 583 | ); 584 | next_progress_marker += 5.0; 585 | } 586 | } 587 | } 588 | 589 | if show_progress { 590 | log::info!( 591 | "file upload comprising {file_size} bytes from {src_file_path} to {dest_file_path} completed successfully in {}s", 592 | start_time.elapsed().as_secs_f64() 593 | ); 594 | } 595 | remote_file 596 | .shutdown() 597 | .await 598 | .map_err(crate::Error::IoError)?; 599 | 600 | Ok(()) 601 | } 602 | 603 | /// Download a file from the remote server using sftp. 604 | /// 605 | /// `remote_file_path` is the path to the file on the remote machine. 606 | /// `local_file_path` is the path to the file on the local machine. 607 | /// Some sshd_config does not enable sftp by default, so make sure it is enabled. 608 | /// A config line like a `Subsystem sftp internal-sftp` or 609 | /// `Subsystem sftp /usr/lib/openssh/sftp-server` is needed in the sshd_config in remote machine. 610 | pub async fn download_file, U: Into>( 611 | &self, 612 | remote_file_path: U, 613 | local_file_path: T, 614 | ) -> Result<(), crate::Error> { 615 | // start sftp session 616 | let channel = self.get_channel().await?; 617 | channel.request_subsystem(true, "sftp").await?; 618 | let sftp = SftpSession::new(channel.into_stream()).await?; 619 | 620 | // open remote file for reading 621 | let mut remote_file = sftp 622 | .open_with_flags(remote_file_path, OpenFlags::READ) 623 | .await?; 624 | 625 | // read remote file contents 626 | let mut contents = Vec::new(); 627 | remote_file.read_to_end(contents.as_mut()).await?; 628 | 629 | // write contents to local file 630 | let mut local_file = tokio::fs::File::create(local_file_path.as_ref()) 631 | .await 632 | .map_err(crate::Error::IoError)?; 633 | 634 | local_file 635 | .write_all(&contents) 636 | .await 637 | .map_err(crate::Error::IoError)?; 638 | local_file.flush().await.map_err(crate::Error::IoError)?; 639 | 640 | Ok(()) 641 | } 642 | 643 | /// Execute a remote command via the ssh connection. 644 | /// 645 | /// Returns stdout, stderr and the exit code of the command, 646 | /// packaged in a [`CommandExecutedResult`] struct. 647 | /// If you need the stderr output interleaved within stdout, you should postfix the command with a redirection, 648 | /// e.g. `echo foo 2>&1`. 649 | /// If you dont want any output at all, use something like `echo foo >/dev/null 2>&1`. 650 | /// 651 | /// Make sure your commands don't read from stdin and exit after bounded time. 652 | /// 653 | /// Can be called multiple times, but every invocation is a new shell context. 654 | /// Thus `cd`, setting variables and alike have no effect on future invocations. 655 | pub async fn execute(&self, command: &str) -> Result { 656 | let mut stdout_buffer = vec![]; 657 | let mut stderr_buffer = vec![]; 658 | let mut channel = self.connection_handle.channel_open_session().await?; 659 | channel.exec(true, command).await?; 660 | 661 | let mut result: Option = None; 662 | 663 | // While the channel has messages... 664 | while let Some(msg) = channel.wait().await { 665 | //dbg!(&msg); 666 | match msg { 667 | // If we get data, add it to the buffer 668 | russh::ChannelMsg::Data { ref data } => { 669 | stdout_buffer.write_all(data).await.unwrap() 670 | } 671 | russh::ChannelMsg::ExtendedData { ref data, ext } => { 672 | if ext == 1 { 673 | stderr_buffer.write_all(data).await.unwrap() 674 | } 675 | } 676 | 677 | // If we get an exit code report, store it, but crucially don't 678 | // assume this message means end of communications. The data might 679 | // not be finished yet! 680 | russh::ChannelMsg::ExitStatus { exit_status } => result = Some(exit_status), 681 | 682 | // We SHOULD get this EOF messagge, but 4254 sec 5.3 also permits 683 | // the channel to close without it being sent. And sometimes this 684 | // message can even precede the Data message, so don't handle it 685 | // russh::ChannelMsg::Eof => break, 686 | _ => {} 687 | } 688 | } 689 | 690 | // If we received an exit code, report it back 691 | if let Some(result) = result { 692 | Ok(CommandExecutedResult { 693 | stdout: String::from_utf8_lossy(&stdout_buffer).to_string(), 694 | stderr: String::from_utf8_lossy(&stderr_buffer).to_string(), 695 | exit_status: result, 696 | }) 697 | 698 | // Otherwise, report an error 699 | } else { 700 | Err(crate::Error::CommandDidntExit) 701 | } 702 | } 703 | 704 | /// Execute a remote command via the ssh connection. 705 | /// 706 | /// Command output is stream to the provided channel. Returns the exit code. 707 | /// The channel sends `SteamingOutput` enum variants to distinguish stdout, 708 | /// stderr and exit code so message arrive interleaved and in the order 709 | /// they are received. See `execute` for more details. 710 | /// 711 | #[deprecated( 712 | since = "0.11.0", 713 | note = "Use execute_io with channels directly for more flexibility.\n\ 714 | This method will be removed or introduced breaking changes in future versions.\n\ 715 | At minimum, SteamingOutput will be renamed to StreamingOutput" 716 | )] 717 | pub async fn execute_streaming( 718 | &self, 719 | command: &str, 720 | ch: tokio::sync::mpsc::Sender, 721 | ) -> Result { 722 | let (stdout_tx, mut stdout_rx) = tokio::sync::mpsc::channel(1); 723 | let (stderr_tx, mut stderr_rx) = tokio::sync::mpsc::channel::>(1); 724 | 725 | let exec_future = self.execute_io(command, stdout_tx, Some(stderr_tx), None, false, None); 726 | tokio::pin!(exec_future); 727 | let result = loop { 728 | tokio::select! { 729 | result = &mut exec_future => break result, 730 | Some(stdout) = stdout_rx.recv() => { 731 | ch.send(SteamingOutput::Stdout(stdout)).await.unwrap(); 732 | }, 733 | Some(stderr) = stderr_rx.recv() => { 734 | ch.send(SteamingOutput::Stderr(stderr)).await.unwrap(); 735 | }, 736 | }; 737 | }?; 738 | // see if any output is left in the channels 739 | if let Some(stdout) = stdout_rx.recv().await { 740 | ch.send(SteamingOutput::Stdout(stdout)).await.unwrap(); 741 | } 742 | if let Some(stderr) = stderr_rx.recv().await { 743 | ch.send(SteamingOutput::Stderr(stderr)).await.unwrap(); 744 | } 745 | ch.send(SteamingOutput::ExitStatus(result)).await.unwrap(); 746 | Ok(result) 747 | } 748 | 749 | /// Execute a remote command via the ssh connection and perform i/o via channels. 750 | /// 751 | /// `execute_io` does the same as `execute`, but ties stdin and stdout/stderr to channels. 752 | /// Giving a stdin channel is optional. If there is only a stdout channel, stderr will be 753 | /// sent to the stdout channel. Sending an empty string to the stdin channel will send an 754 | /// EOF to the remote side. 755 | /// If `request_pty` is true, a pseudo terminal is requested for the session. This is 756 | /// sometime necessary for example to enter a password, which is not request via stdin 757 | /// but directly from the terminal. NOTE: A pty has no stderr, so stderr output is 758 | /// sent to the stdout channel. 759 | /// The exit code of the command is returned as a result. If the remote ssh server 760 | /// does not report an exit code, a default exit code can be passed, otherwise an error 761 | /// is returned. 762 | /// 763 | /// Example: 764 | /// 765 | /// ```no_run 766 | /// use async_ssh2_tokio::{Client, AuthMethod, ServerCheckMethod}; 767 | /// use tokio::sync::mpsc; 768 | /// 769 | /// #[tokio::main] 770 | /// async fn main() -> Result<(), async_ssh2_tokio::Error> { 771 | /// let mut client = Client::connect( 772 | /// ("10.10.10.2", 22), 773 | /// "root", 774 | /// AuthMethod::with_password("root"), 775 | /// ServerCheckMethod::NoCheck, 776 | /// ).await?; 777 | /// let mut result_stdout = vec![]; 778 | /// let mut result_stderr = vec![]; 779 | /// 780 | /// let (stdout_tx, mut stdout_rx) = mpsc::channel(10); 781 | /// let (stderr_tx, mut stderr_rx) = mpsc::channel(10); 782 | /// let cmd = "date"; 783 | /// let exec_future = client.execute_io(&cmd, stdout_tx, Some(stderr_tx), None, false, None); 784 | /// tokio::pin!(exec_future); 785 | /// let result = loop { 786 | /// tokio::select! { 787 | /// result = &mut exec_future => break result, 788 | /// Some(stdout) = stdout_rx.recv() => { 789 | /// println!("ssh stdout: {}", String::from_utf8_lossy(&stdout)); 790 | /// result_stdout.push(stdout); 791 | /// }, 792 | /// Some(stderr) = stderr_rx.recv() => { 793 | /// println!("ssh stderr: {}", String::from_utf8_lossy(&stderr)); 794 | /// result_stderr.push(stderr); 795 | /// }, 796 | /// }; 797 | /// }?; 798 | /// 799 | /// // see if any output is left in the channels 800 | /// if let Some(stdout) = stdout_rx.recv().await { 801 | /// println!("ssh stdout: {}", String::from_utf8_lossy(&stdout)); 802 | /// result_stdout.push(stdout); 803 | /// } 804 | /// if let Some(stderr) = stderr_rx.recv().await { 805 | /// println!("ssh stderr: {}", String::from_utf8_lossy(&stderr)); 806 | /// result_stderr.push(stderr); 807 | /// } 808 | /// Ok(()) 809 | /// } 810 | /// ``` 811 | pub async fn execute_io( 812 | &self, 813 | command: &str, 814 | stdout_channel: mpsc::Sender>, 815 | stderr_channel: Option>>, 816 | mut stdin_channel: Option>>, 817 | request_pty: bool, 818 | default_exit_code: Option, 819 | ) -> Result { 820 | let mut channel = self.connection_handle.channel_open_session().await?; 821 | 822 | let mut result: Option = None; 823 | if request_pty { 824 | channel 825 | .request_pty(false, "xterm", 80_u32, 24_u32, 0, 0, &[]) 826 | .await?; 827 | } 828 | 829 | channel.exec(true, command).await?; 830 | 831 | // While the channel has messages... 832 | loop { 833 | let recv_stdin = async { 834 | if let Some(ch) = stdin_channel.as_mut() { 835 | Some(ch.recv().await) 836 | } else { 837 | None 838 | } 839 | }; 840 | tokio::select! { 841 | Some(input) = recv_stdin => { 842 | if let Some(input) = input { 843 | if input.is_empty() { 844 | channel.eof().await? ; 845 | } else { 846 | channel.data(&input as &[u8]).await?; 847 | } 848 | } 849 | }, 850 | msg = channel.wait() => { 851 | //dbg!(&msg); 852 | match msg { 853 | // If we get data, add it to the buffer 854 | Some(russh::ChannelMsg::Data { ref data }) => { 855 | //dbg!("sending stdout"); 856 | stdout_channel 857 | .send(data.to_vec()) 858 | .await 859 | .map_err(crate::Error::ChannelSendError)?; 860 | } 861 | Some (russh::ChannelMsg::ExtendedData { ref data, ext }) => { 862 | if ext == 1 { 863 | if let Some(stderr_channel) = &stderr_channel { 864 | //dbg!("sending stderr"); 865 | stderr_channel 866 | .send(data.to_vec()) 867 | .await 868 | .map_err(crate::Error::ChannelSendError)?; 869 | } else { 870 | //dbg!("sending stderr to stdout"); 871 | stdout_channel 872 | .send(data.to_vec()) 873 | .await 874 | .map_err(crate::Error::ChannelSendError)?; 875 | } 876 | } 877 | } 878 | 879 | // If we get an exit code report, store it, but crucially don't 880 | // assume this message means end of communications. The data might 881 | // not be finished yet! 882 | Some (russh::ChannelMsg::ExitStatus { exit_status }) => result = Some(exit_status), 883 | 884 | // We SHOULD get this EOF messagge, but 4254 sec 5.3 also permits 885 | // the channel to close without it being sent. And sometimes this 886 | // message can even precede the Data message, so don't handle it 887 | // russh::ChannelMsg::Eof => break, 888 | Some (_) => {}, 889 | None => break, 890 | } 891 | } 892 | } 893 | } 894 | 895 | // If we received an exit code, report it back 896 | if let Some(result) = result { 897 | Ok(result) 898 | // If we have an default exit code, report it back 899 | } else if let Some(default_exit_code) = default_exit_code { 900 | Ok(default_exit_code) 901 | // Otherwise, report an error 902 | } else { 903 | Err(crate::Error::CommandDidntExit) 904 | } 905 | } 906 | 907 | /// A debugging function to get the username this client is connected as. 908 | pub fn get_connection_username(&self) -> &String { 909 | &self.username 910 | } 911 | 912 | /// A debugging function to get the address this client is connected to. 913 | pub fn get_connection_address(&self) -> &SocketAddr { 914 | &self.address 915 | } 916 | 917 | pub async fn disconnect(&self) -> Result<(), crate::Error> { 918 | self.connection_handle 919 | .disconnect(russh::Disconnect::ByApplication, "", "") 920 | .await 921 | .map_err(crate::Error::SshError) 922 | } 923 | 924 | pub fn is_closed(&self) -> bool { 925 | self.connection_handle.is_closed() 926 | } 927 | } 928 | 929 | impl Debug for Client { 930 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 931 | f.debug_struct("Client") 932 | .field("username", &self.username) 933 | .field("address", &self.address) 934 | .field("connection_handle", &"Handle") 935 | .finish() 936 | } 937 | } 938 | 939 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 940 | pub struct CommandExecutedResult { 941 | /// The stdout output of the command. 942 | pub stdout: String, 943 | /// The stderr output of the command. 944 | pub stderr: String, 945 | /// The unix exit status (`$?` in bash). 946 | pub exit_status: u32, 947 | } 948 | 949 | #[derive(Debug, Clone)] 950 | struct ClientHandler { 951 | hostname: String, 952 | host: SocketAddr, 953 | server_check: ServerCheckMethod, 954 | } 955 | 956 | impl Handler for ClientHandler { 957 | type Error = crate::Error; 958 | 959 | async fn check_server_key( 960 | &mut self, 961 | server_public_key: &russh::keys::PublicKey, 962 | ) -> Result { 963 | match &self.server_check { 964 | ServerCheckMethod::NoCheck => Ok(true), 965 | ServerCheckMethod::PublicKey(key) => { 966 | let pk = russh::keys::parse_public_key_base64(key) 967 | .map_err(|_| crate::Error::ServerCheckFailed)?; 968 | 969 | Ok(pk == *server_public_key) 970 | } 971 | ServerCheckMethod::PublicKeyFile(key_file_name) => { 972 | let pk = russh::keys::load_public_key(key_file_name) 973 | .map_err(|_| crate::Error::ServerCheckFailed)?; 974 | 975 | Ok(pk == *server_public_key) 976 | } 977 | ServerCheckMethod::KnownHostsFile(known_hosts_path) => { 978 | let result = russh::keys::check_known_hosts_path( 979 | &self.hostname, 980 | self.host.port(), 981 | server_public_key, 982 | known_hosts_path, 983 | ) 984 | .map_err(|_| crate::Error::ServerCheckFailed)?; 985 | 986 | Ok(result) 987 | } 988 | ServerCheckMethod::DefaultKnownHostsFile => { 989 | let result = russh::keys::check_known_hosts( 990 | &self.hostname, 991 | self.host.port(), 992 | server_public_key, 993 | ) 994 | .map_err(|_| crate::Error::ServerCheckFailed)?; 995 | 996 | Ok(result) 997 | } 998 | } 999 | } 1000 | } 1001 | 1002 | #[cfg(test)] 1003 | mod tests { 1004 | #![allow(deprecated, clippy::useless_vec)] 1005 | 1006 | use crate::client::*; 1007 | use core::time; 1008 | use dotenv::dotenv; 1009 | use std::path::Path; 1010 | use std::sync::Once; 1011 | use tokio::io::AsyncReadExt; 1012 | static INIT: Once = Once::new(); 1013 | 1014 | fn initialize() { 1015 | // Perform your initialization tasks here 1016 | println!("Running initialization code before tests..."); 1017 | // Example: load .env file if we are using non-docker environment 1018 | if is_running_in_docker() { 1019 | println!("Running inside Docker."); 1020 | } else { 1021 | println!("Not running inside Docker. Load env from file"); 1022 | dotenv().ok(); 1023 | } 1024 | } 1025 | fn is_running_in_docker() -> bool { 1026 | Path::new("/.dockerenv").exists() || check_cgroup() 1027 | } 1028 | 1029 | fn check_cgroup() -> bool { 1030 | match std::fs::read_to_string("/proc/1/cgroup") { 1031 | Ok(contents) => contents.contains("docker"), 1032 | Err(_) => false, 1033 | } 1034 | } 1035 | 1036 | fn env(name: &str) -> String { 1037 | INIT.call_once(|| { 1038 | initialize(); 1039 | }); 1040 | std::env::var(name).unwrap_or_else(|_| { 1041 | panic!( 1042 | "Failed to get env var needed for test, make sure to set the following env var: {name}", 1043 | ) 1044 | }) 1045 | } 1046 | 1047 | fn test_address() -> SocketAddr { 1048 | format!( 1049 | "{}:{}", 1050 | env("ASYNC_SSH2_TEST_HOST_IP"), 1051 | env("ASYNC_SSH2_TEST_HOST_PORT") 1052 | ) 1053 | .parse() 1054 | .unwrap() 1055 | } 1056 | 1057 | fn test_hostname() -> impl ToSocketAddrsWithHostname { 1058 | ( 1059 | env("ASYNC_SSH2_TEST_HOST_NAME"), 1060 | env("ASYNC_SSH2_TEST_HOST_PORT").parse().unwrap(), 1061 | ) 1062 | } 1063 | 1064 | async fn establish_test_host_connection() -> Client { 1065 | Client::connect( 1066 | ( 1067 | env("ASYNC_SSH2_TEST_HOST_IP"), 1068 | env("ASYNC_SSH2_TEST_HOST_PORT").parse().unwrap(), 1069 | ), 1070 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1071 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1072 | ServerCheckMethod::NoCheck, 1073 | ) 1074 | .await 1075 | .expect("Connection/Authentification failed") 1076 | } 1077 | 1078 | #[tokio::test] 1079 | async fn connect_with_password() { 1080 | let client = establish_test_host_connection().await; 1081 | assert_eq!( 1082 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1083 | client.get_connection_username(), 1084 | ); 1085 | assert_eq!(test_address(), *client.get_connection_address(),); 1086 | } 1087 | 1088 | #[tokio::test] 1089 | async fn execute_command_result() { 1090 | let client = establish_test_host_connection().await; 1091 | let output = client.execute("echo test!!!").await.unwrap(); 1092 | assert_eq!("test!!!\n", output.stdout); 1093 | assert_eq!("", output.stderr); 1094 | assert_eq!(0, output.exit_status); 1095 | } 1096 | 1097 | #[tokio::test] 1098 | async fn execute_streaming_command_result() { 1099 | let (tx, mut rx) = tokio::sync::mpsc::channel(10); 1100 | let client = establish_test_host_connection().await; 1101 | let result = client.execute_streaming("echo test!!!", tx).await.unwrap(); 1102 | let mut output = Vec::new(); 1103 | while let Some(msg) = rx.recv().await { 1104 | output.push(msg); 1105 | } 1106 | assert_eq!(0, result); 1107 | assert_eq!( 1108 | &[ 1109 | SteamingOutput::Stdout(b"test!!!\n".to_vec()), 1110 | SteamingOutput::ExitStatus(0), 1111 | ], 1112 | output.as_slice(), 1113 | ); 1114 | } 1115 | 1116 | #[tokio::test] 1117 | async fn execute_command_result_stderr() { 1118 | let client = establish_test_host_connection().await; 1119 | let output = client.execute("echo test!!! 1>&2").await.unwrap(); 1120 | assert_eq!("", output.stdout); 1121 | assert_eq!("test!!!\n", output.stderr); 1122 | assert_eq!(0, output.exit_status); 1123 | } 1124 | 1125 | #[tokio::test] 1126 | async fn execute_streaming_command_result_stderr() { 1127 | let client = establish_test_host_connection().await; 1128 | let (tx, mut rx) = tokio::sync::mpsc::channel(10); 1129 | let result = client 1130 | .execute_streaming("echo test!!! 1>&2", tx) 1131 | .await 1132 | .unwrap(); 1133 | let mut output = Vec::new(); 1134 | while let Some(msg) = rx.recv().await { 1135 | output.push(msg); 1136 | } 1137 | assert_eq!(0, result); 1138 | assert_eq!( 1139 | &[ 1140 | SteamingOutput::Stderr(b"test!!!\n".to_vec()), 1141 | SteamingOutput::ExitStatus(0), 1142 | ], 1143 | output.as_slice() 1144 | ); 1145 | } 1146 | 1147 | #[tokio::test] 1148 | async fn unicode_output() { 1149 | let client = establish_test_host_connection().await; 1150 | let output = client.execute("echo To thḙ moon! 🚀").await.unwrap(); 1151 | assert_eq!("To thḙ moon! 🚀\n", output.stdout); 1152 | assert_eq!(0, output.exit_status); 1153 | } 1154 | 1155 | #[tokio::test] 1156 | async fn execute_command_status() { 1157 | let client = establish_test_host_connection().await; 1158 | let output = client.execute("exit 42").await.unwrap(); 1159 | assert_eq!(42, output.exit_status); 1160 | } 1161 | 1162 | #[tokio::test] 1163 | async fn execute_streaming_command_status() { 1164 | let client = establish_test_host_connection().await; 1165 | let (tx, mut rx) = tokio::sync::mpsc::channel(10); 1166 | let result = client.execute_streaming("exit 42", tx).await.unwrap(); 1167 | let mut output = Vec::new(); 1168 | while let Some(msg) = rx.recv().await { 1169 | output.push(msg); 1170 | } 1171 | assert_eq!(42, result); 1172 | assert_eq!(&[SteamingOutput::ExitStatus(42),], output.as_slice()); 1173 | } 1174 | 1175 | #[tokio::test] 1176 | async fn execute_io_command() { 1177 | let client = establish_test_host_connection().await; 1178 | let (stdout_tx, mut stdout_rx) = tokio::sync::mpsc::channel(10); 1179 | let (stderr_tx, mut stderr_rx) = tokio::sync::mpsc::channel(10); 1180 | let cmd = "echo out1; echo err1 1>&2; echo out2; echo err2 1>&2; exit 7"; 1181 | let exec_future = client.execute_io(cmd, stdout_tx, Some(stderr_tx), None, false, None); 1182 | tokio::pin!(exec_future); 1183 | let mut result: Option = None; 1184 | let mut stdout_output = vec![]; 1185 | let mut stderr_output = vec![]; 1186 | loop { 1187 | tokio::select! { 1188 | result_inner = &mut exec_future => { 1189 | result = Some(result_inner.unwrap()); 1190 | }, 1191 | Some(stdout) = stdout_rx.recv() => { 1192 | stdout_output.push(stdout); 1193 | }, 1194 | Some(stderr) = stderr_rx.recv() => { 1195 | stderr_output.push(stderr); 1196 | }, 1197 | }; 1198 | if result.is_some() { 1199 | break; 1200 | } 1201 | } 1202 | assert_eq!(Some(7), result); 1203 | assert_eq!( 1204 | vec![b"out1\n".to_vec(), b"out2\n".to_vec()].concat(), 1205 | stdout_output.concat() 1206 | ); 1207 | assert_eq!( 1208 | vec![b"err1\n".to_vec(), b"err2\n".to_vec()].concat(), 1209 | stderr_output.concat() 1210 | ); 1211 | } 1212 | 1213 | #[tokio::test] 1214 | async fn execute_multiple_commands() { 1215 | let client = establish_test_host_connection().await; 1216 | let output = client.execute("echo test!!!").await.unwrap().stdout; 1217 | assert_eq!("test!!!\n", output); 1218 | 1219 | let output = client.execute("echo Hello World").await.unwrap().stdout; 1220 | assert_eq!("Hello World\n", output); 1221 | } 1222 | 1223 | #[tokio::test] 1224 | async fn direct_tcpip_channel() { 1225 | let client = establish_test_host_connection().await; 1226 | let channel = client 1227 | .open_direct_tcpip_channel( 1228 | format!( 1229 | "{}:{}", 1230 | env("ASYNC_SSH2_TEST_HTTP_SERVER_IP"), 1231 | env("ASYNC_SSH2_TEST_HTTP_SERVER_PORT"), 1232 | ), 1233 | None, 1234 | ) 1235 | .await 1236 | .unwrap(); 1237 | 1238 | let mut stream = channel.into_stream(); 1239 | stream.write_all(b"GET / HTTP/1.0\r\n\r\n").await.unwrap(); 1240 | 1241 | let mut response = String::new(); 1242 | stream.read_to_string(&mut response).await.unwrap(); 1243 | 1244 | let body = response.split_once("\r\n\r\n").unwrap().1; 1245 | assert_eq!("Hello", body); 1246 | } 1247 | 1248 | #[tokio::test] 1249 | async fn stderr_redirection() { 1250 | let client = establish_test_host_connection().await; 1251 | 1252 | let output = client.execute("echo foo >/dev/null").await.unwrap(); 1253 | assert_eq!("", output.stdout); 1254 | 1255 | let output = client.execute("echo foo >>/dev/stderr").await.unwrap(); 1256 | assert_eq!("", output.stdout); 1257 | 1258 | let output = client.execute("2>&1 echo foo >>/dev/stderr").await.unwrap(); 1259 | assert_eq!("foo\n", output.stdout); 1260 | } 1261 | 1262 | #[tokio::test] 1263 | async fn sequential_commands() { 1264 | let client = establish_test_host_connection().await; 1265 | 1266 | for i in 0..100 { 1267 | std::thread::sleep(time::Duration::from_millis(100)); 1268 | let res = client 1269 | .execute(&format!("echo {i}")) 1270 | .await 1271 | .unwrap_or_else(|_| panic!("Execution failed in iteration {i}")); 1272 | assert_eq!(format!("{i}\n"), res.stdout); 1273 | } 1274 | } 1275 | 1276 | #[tokio::test] 1277 | async fn execute_multiple_context() { 1278 | // This is maybe not expected behaviour, thus documenting this via a test is important. 1279 | let client = establish_test_host_connection().await; 1280 | let output = client 1281 | .execute("export VARIABLE=42; echo $VARIABLE") 1282 | .await 1283 | .unwrap() 1284 | .stdout; 1285 | assert_eq!("42\n", output); 1286 | 1287 | let output = client.execute("echo $VARIABLE").await.unwrap().stdout; 1288 | assert_eq!("\n", output); 1289 | } 1290 | 1291 | #[tokio::test] 1292 | async fn connect_second_address() { 1293 | let client = Client::connect( 1294 | &[SocketAddr::from(([127, 0, 0, 1], 23)), test_address()][..], 1295 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1296 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1297 | ServerCheckMethod::NoCheck, 1298 | ) 1299 | .await 1300 | .expect("Resolution to second address failed"); 1301 | 1302 | assert_eq!(test_address(), *client.get_connection_address(),); 1303 | } 1304 | 1305 | #[tokio::test] 1306 | async fn connect_with_wrong_password() { 1307 | let error = Client::connect( 1308 | test_address(), 1309 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1310 | AuthMethod::with_password("hopefully the wrong password"), 1311 | ServerCheckMethod::NoCheck, 1312 | ) 1313 | .await 1314 | .expect_err("Client connected with wrong password"); 1315 | 1316 | match error { 1317 | crate::Error::PasswordWrong => {} 1318 | _ => panic!("Wrong error type"), 1319 | } 1320 | } 1321 | 1322 | #[tokio::test] 1323 | async fn invalid_address() { 1324 | let no_client = Client::connect( 1325 | "this is definitely not an address", 1326 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1327 | AuthMethod::with_password("hopefully the wrong password"), 1328 | ServerCheckMethod::NoCheck, 1329 | ) 1330 | .await; 1331 | assert!(no_client.is_err()); 1332 | } 1333 | 1334 | #[tokio::test] 1335 | async fn connect_to_wrong_port() { 1336 | let no_client = Client::connect( 1337 | (env("ASYNC_SSH2_TEST_HOST_IP"), 23), 1338 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1339 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1340 | ServerCheckMethod::NoCheck, 1341 | ) 1342 | .await; 1343 | assert!(no_client.is_err()); 1344 | } 1345 | 1346 | #[tokio::test] 1347 | #[ignore = "This times out only after 20 seconds"] 1348 | async fn connect_to_wrong_host() { 1349 | let no_client = Client::connect( 1350 | "172.16.0.6:22", 1351 | "xxx", 1352 | AuthMethod::with_password("xxx"), 1353 | ServerCheckMethod::NoCheck, 1354 | ) 1355 | .await; 1356 | assert!(no_client.is_err()); 1357 | } 1358 | 1359 | #[tokio::test] 1360 | async fn auth_key_file() { 1361 | let client = Client::connect( 1362 | test_address(), 1363 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1364 | AuthMethod::with_key_file(env("ASYNC_SSH2_TEST_CLIENT_PRIV"), None), 1365 | ServerCheckMethod::NoCheck, 1366 | ) 1367 | .await; 1368 | assert!(client.is_ok()); 1369 | } 1370 | 1371 | #[tokio::test] 1372 | #[cfg(not(target_os = "windows"))] 1373 | async fn auth_with_agent() { 1374 | // This test requires SSH agent to be running with the test key loaded 1375 | // In Docker environment, the agent is always properly configured 1376 | let client = Client::connect( 1377 | test_address(), 1378 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1379 | AuthMethod::with_agent(), 1380 | ServerCheckMethod::NoCheck, 1381 | ) 1382 | .await 1383 | .expect("Agent authentication should succeed with correct key loaded"); 1384 | 1385 | // Verify we can execute a command 1386 | let output = client.execute("echo test").await.unwrap(); 1387 | assert_eq!("test\n", output.stdout); 1388 | } 1389 | 1390 | #[tokio::test] 1391 | #[cfg(not(target_os = "windows"))] 1392 | async fn auth_with_agent_wrong_user() { 1393 | // This test verifies that agent auth fails with wrong username 1394 | let result = Client::connect( 1395 | test_address(), 1396 | "wrong_user_that_does_not_exist", 1397 | AuthMethod::with_agent(), 1398 | ServerCheckMethod::NoCheck, 1399 | ) 1400 | .await; 1401 | 1402 | // Should fail with authentication error 1403 | assert!(matches!( 1404 | result, 1405 | Err(crate::Error::AgentAuthenticationFailed) 1406 | )); 1407 | } 1408 | 1409 | #[tokio::test] 1410 | #[cfg(not(target_os = "windows"))] 1411 | async fn auth_with_agent_no_sock() { 1412 | // Test behavior when SSH_AUTH_SOCK is not set 1413 | // Temporarily unset SSH_AUTH_SOCK for this test 1414 | let original_sock = std::env::var("SSH_AUTH_SOCK").ok(); 1415 | unsafe { 1416 | std::env::remove_var("SSH_AUTH_SOCK"); 1417 | } 1418 | 1419 | let result = Client::connect( 1420 | test_address(), 1421 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1422 | AuthMethod::with_agent(), 1423 | ServerCheckMethod::NoCheck, 1424 | ) 1425 | .await; 1426 | 1427 | // Restore original SSH_AUTH_SOCK if it was set 1428 | if let Some(sock) = original_sock { 1429 | unsafe { 1430 | std::env::set_var("SSH_AUTH_SOCK", sock); 1431 | } 1432 | } 1433 | 1434 | // Should fail with connection error 1435 | assert!(matches!(result, Err(crate::Error::AgentConnectionFailed))); 1436 | } 1437 | 1438 | #[tokio::test] 1439 | async fn auth_key_file_with_passphrase() { 1440 | let client = Client::connect( 1441 | test_address(), 1442 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1443 | AuthMethod::with_key_file( 1444 | env("ASYNC_SSH2_TEST_CLIENT_PROT_PRIV"), 1445 | Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS")), 1446 | ), 1447 | ServerCheckMethod::NoCheck, 1448 | ) 1449 | .await; 1450 | if client.is_err() { 1451 | println!("{:?}", client.err()); 1452 | panic!(); 1453 | } 1454 | assert!(client.is_ok()); 1455 | } 1456 | 1457 | #[tokio::test] 1458 | async fn auth_key_str() { 1459 | let key = std::fs::read_to_string(env("ASYNC_SSH2_TEST_CLIENT_PRIV")).unwrap(); 1460 | 1461 | let client = Client::connect( 1462 | test_address(), 1463 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1464 | AuthMethod::with_key(key.as_str(), None), 1465 | ServerCheckMethod::NoCheck, 1466 | ) 1467 | .await; 1468 | assert!(client.is_ok()); 1469 | } 1470 | 1471 | #[tokio::test] 1472 | async fn auth_key_str_with_passphrase() { 1473 | let key = std::fs::read_to_string(env("ASYNC_SSH2_TEST_CLIENT_PROT_PRIV")).unwrap(); 1474 | 1475 | let client = Client::connect( 1476 | test_address(), 1477 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1478 | AuthMethod::with_key(key.as_str(), Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS"))), 1479 | ServerCheckMethod::NoCheck, 1480 | ) 1481 | .await; 1482 | assert!(client.is_ok()); 1483 | } 1484 | 1485 | #[tokio::test] 1486 | async fn auth_keyboard_interactive() { 1487 | let client = Client::connect( 1488 | test_address(), 1489 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1490 | AuthKeyboardInteractive::new() 1491 | .with_response("Password", env("ASYNC_SSH2_TEST_HOST_PW")) 1492 | .into(), 1493 | ServerCheckMethod::NoCheck, 1494 | ) 1495 | .await; 1496 | assert!(client.is_ok()); 1497 | } 1498 | 1499 | #[tokio::test] 1500 | async fn auth_keyboard_interactive_exact() { 1501 | let client = Client::connect( 1502 | test_address(), 1503 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1504 | AuthKeyboardInteractive::new() 1505 | .with_response_exact("Password: ", env("ASYNC_SSH2_TEST_HOST_PW")) 1506 | .into(), 1507 | ServerCheckMethod::NoCheck, 1508 | ) 1509 | .await; 1510 | assert!(client.is_ok()); 1511 | } 1512 | 1513 | #[tokio::test] 1514 | async fn auth_keyboard_interactive_wrong_response() { 1515 | let client = Client::connect( 1516 | test_address(), 1517 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1518 | AuthKeyboardInteractive::new() 1519 | .with_response_exact("Password: ", "wrong password") 1520 | .into(), 1521 | ServerCheckMethod::NoCheck, 1522 | ) 1523 | .await; 1524 | match client { 1525 | Err(crate::error::Error::KeyboardInteractiveAuthFailed) => {} 1526 | Err(e) => { 1527 | panic!("Expected KeyboardInteractiveAuthFailed error. Got error: {e:?}") 1528 | } 1529 | Ok(_) => panic!("Expected KeyboardInteractiveAuthFailed error."), 1530 | } 1531 | } 1532 | 1533 | #[tokio::test] 1534 | async fn auth_keyboard_interactive_no_response() { 1535 | let client = Client::connect( 1536 | test_address(), 1537 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1538 | AuthKeyboardInteractive::new() 1539 | .with_response_exact("Password:", "123") 1540 | .into(), 1541 | ServerCheckMethod::NoCheck, 1542 | ) 1543 | .await; 1544 | match client { 1545 | Err(crate::error::Error::KeyboardInteractiveNoResponseForPrompt(prompt)) => { 1546 | assert_eq!(prompt, "Password: "); 1547 | } 1548 | Err(e) => { 1549 | panic!("Expected KeyboardInteractiveNoResponseForPrompt error. Got error: {e:?}") 1550 | } 1551 | Ok(_) => panic!("Expected KeyboardInteractiveNoResponseForPrompt error."), 1552 | } 1553 | } 1554 | 1555 | #[tokio::test] 1556 | async fn server_check_file() { 1557 | let client = Client::connect( 1558 | test_address(), 1559 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1560 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1561 | ServerCheckMethod::with_public_key_file(&env("ASYNC_SSH2_TEST_SERVER_PUB")), 1562 | ) 1563 | .await; 1564 | assert!(client.is_ok()); 1565 | } 1566 | 1567 | #[tokio::test] 1568 | async fn server_check_str() { 1569 | let line = std::fs::read_to_string(env("ASYNC_SSH2_TEST_SERVER_PUB")).unwrap(); 1570 | let mut split = line.split_whitespace(); 1571 | let key = match (split.next(), split.next()) { 1572 | (Some(_), Some(k)) => k, 1573 | (Some(k), None) => k, 1574 | _ => panic!("Failed to parse pub key file"), 1575 | }; 1576 | 1577 | let client = Client::connect( 1578 | test_address(), 1579 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1580 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1581 | ServerCheckMethod::with_public_key(key), 1582 | ) 1583 | .await; 1584 | assert!(client.is_ok()); 1585 | } 1586 | 1587 | #[tokio::test] 1588 | async fn server_check_by_known_hosts_for_ip() { 1589 | let client = Client::connect( 1590 | test_address(), 1591 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1592 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1593 | ServerCheckMethod::with_known_hosts_file(&env("ASYNC_SSH2_TEST_KNOWN_HOSTS")), 1594 | ) 1595 | .await; 1596 | assert!(client.is_ok()); 1597 | } 1598 | 1599 | #[tokio::test] 1600 | async fn server_check_by_known_hosts_for_hostname() { 1601 | let client = Client::connect( 1602 | test_hostname(), 1603 | &env("ASYNC_SSH2_TEST_HOST_USER"), 1604 | AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), 1605 | ServerCheckMethod::with_known_hosts_file(&env("ASYNC_SSH2_TEST_KNOWN_HOSTS")), 1606 | ) 1607 | .await; 1608 | if is_running_in_docker() { 1609 | assert!(client.is_ok()); 1610 | } else { 1611 | assert!(client.is_err()); // DNS can't find the docker hostname if the rust running without docker container 1612 | } 1613 | } 1614 | 1615 | #[tokio::test] 1616 | async fn client_can_be_cloned() { 1617 | let client = establish_test_host_connection().await; 1618 | let client2 = client.clone(); 1619 | 1620 | let result1 = client.execute("echo test clone").await.unwrap(); 1621 | let result2 = client2.execute("echo test clone2").await.unwrap(); 1622 | 1623 | assert_eq!(result1.stdout, "test clone\n"); 1624 | assert_eq!(result2.stdout, "test clone2\n"); 1625 | } 1626 | 1627 | #[tokio::test] 1628 | async fn client_can_upload_file() { 1629 | let client = establish_test_host_connection().await; 1630 | client 1631 | .upload_file( 1632 | &env("ASYNC_SSH2_TEST_UPLOAD_FILE"), 1633 | "/tmp/uploaded", 1634 | None, 1635 | None, 1636 | false, 1637 | ) 1638 | .await 1639 | .unwrap(); 1640 | let result = client.execute("cat /tmp/uploaded").await.unwrap(); 1641 | assert_eq!(result.stdout, "this is a test file\n"); 1642 | } 1643 | 1644 | #[tokio::test] 1645 | async fn client_can_download_file() { 1646 | let client = establish_test_host_connection().await; 1647 | 1648 | client 1649 | .execute("echo 'this is a downloaded test file' > /tmp/test_download") 1650 | .await 1651 | .unwrap(); 1652 | 1653 | let local_path = std::env::temp_dir().join("downloaded_test_file"); 1654 | client 1655 | .download_file("/tmp/test_download", &local_path) 1656 | .await 1657 | .unwrap(); 1658 | 1659 | let contents = tokio::fs::read_to_string(&local_path).await.unwrap(); 1660 | assert_eq!(contents, "this is a downloaded test file\n"); 1661 | } 1662 | } 1663 | --------------------------------------------------------------------------------