Creating small CLI tools is a fun way to get more familiar with a programming language. If you are coming from an infrastructure background, a CLI tool that you can use to send commands to devices/servers might be considered a neat starting point getting into Rust. This is how I started off learning Python, by writing small things that were usefull in a context that I was familiar with. Back then, I used argparse, getpass and netmiko. Starting with Rust is pretty daunting, and I have found that using similar tactic used to learn Python can also be applied when learning Rust.

In this article, I explore a tiny bit of Rust by building a CLI tool that will let you log into a router or a server and issue a command over SSH. I hope the examples here can offer you a nice running start exploring Rust.

Running a CLI command over SSH

In case you do not have Rust installed, one easy way of going about things is to grab the rust Docker container:

docker pull rust
docker run --name='ru' --hostname='ru' -di rust /bin/sh
docker exec -it ru bash
apt-get update
apt-get install vi

The Rust image is 1.22GB đŸ˜³

After this, we use cargo new to start a new project:

cargo new ssh
cd ssh/
vi Cargo.toml

We put the following in our Cargo.toml:

[package]
name = "ssh_example"
version = "0.1.0"
edition = "2018"

[dependencies]
ssh2 = "0.9"

This let’s us pull in the ssh2-rs, which offers Rust bindings to libssh2, the ssh client library written in C.

Now we put in our first example script. We edit the main.rs file in the src directory:

vi src/main.rs

Then we put in the following:

use ssh2::Session;
use std::io::prelude::*;
use std::net::TcpStream;

fn main() {
    let tcp = TcpStream::connect("192.168.1.1:22").unwrap();
    let mut sess = Session::new().unwrap();
    sess.set_tcp_stream(tcp);
    sess.handshake().unwrap();
    sess.userauth_password("username", "password")
        .unwrap();
    let mut channel = sess.channel_session().unwrap();
    channel.exec("show version").unwrap();
    let mut s = String::new();
    channel.read_to_string(&mut s).unwrap();
    println!("{}", s);
    channel.wait_close().ok();
    println!("{}", channel.exit_status().unwrap());
}

This will use SSH to connect to a system configured with IP address 192.168.1.1 and issue the show version command.

After putting in the file, we can use cargo run to run the code:

root@ru:/ssh# cargo run 

    Finished dev [unoptimized + debuginfo] target(s) in 0.83s
     Running `target/debug/ssh`
Arista DCS-7050TX-64-R
Hardware version:    01.01
Serial number:       KLF80800800
System MAC address:  001c.84ec.cb72

Software image version: 4.20.12M
Architecture:           i386
Internal build version: 4.20.12M-11527863.42012M
Internal build ID:      3a8329a8-af7b-4a7e-ae1c-cad006c5540d

Uptime:                 114 weeks, 1 days, 7 hours and 2 minutes
Total memory:           3818208 kB
Free memory:            2258276 kB


0

Collecting user input

We can add some CLI options to running this script by using structopt. First, change our Cargo.toml to the following:

[package]
name = "ssh_example"
version = "0.1.0"
edition = "2018"

[dependencies]
ssh2 = "0.9"
structopt = "0.3.21"

This brings in the structopt crate and let’s us define some arguments for our CLI script. In case you are familiar with Python, think argparse.

Let’s first create a script that only collects the arguments:

use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt(name = "structopt example", about = "using structop")]
struct Args {
    #[structopt(short = "h", long)]
    host: String,
    #[structopt(short = "c", long)]
    command: String,
    #[structopt(short = "u", long)]
    username: String,
}

fn main() {
    println!("Structopt example");
    let args = Args::from_args();
    println!(
        "{:?}\nHost: {}\nCommand: {}\nUsername: {}",
        args, args.host, args.command, args.username
    );
}

We can run the above like so:

root@ru:/ssh# cargo run -- -h 192.168.1.50  -c 'show version' -u said
   Compiling ssh_example v0.1.0 (/ssh)
    Finished dev [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/ssh_example -h 192.168.1.50 -c 'show version' -u said`
Structopt example
Args { host: "192.168.1.50", command: "show version", username: "said" }
Host: 192.168.1.50
Command: show version
Username: said

Looks good.

Though, we would also need to collect the password. For that, we include the rpassword crate by adding the following to our Cargo.toml:

rpassword = "5.0"

The rpassword crate allows us to ask users for a password without echoing it to screen. To collect the arguments as well as the password, we now have the following:

extern crate rpassword;
use rpassword::read_password;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt(name = "structopt example", about = "using structop")]
struct Args {
    #[structopt(short = "h", long)]
    host: String,
    #[structopt(short = "c", long)]
    command: String,
    #[structopt(short = "u", long)]
    username: String,
}

fn main() {
    println!("Enter your password: ");
    let password = read_password().unwrap();

    println!("The password is: '{}'", password);
    println!("Structopt example");
    let args = Args::from_args();
    println!(
        "{:?}\nHost: {}\nCommand: {}\nUsername: {}",
        args, args.host, args.command, args.username
    );
}

When we run this, we see the following:

root@ru:/ssh# cargo run -- -h 192.168.1.50  -c 'show version' -u said
    Finished dev [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/ssh_example -h 192.168.1.50 -c 'show version' -u said`
Enter your password: 

The password is: 'mypassw0rd'
Structopt example
Args { host: "192.168.1.50", command: "show version", username: "said" }
Host: 192.168.1.50
Command: show version
Username: said

Great! Now we can move on and combine the two.

Tying things together

We create the following Cargo.toml:

[package]
name = "ssh_example"
version = "0.1.0"
edition = "2018"

[dependencies]
ssh2 = "0.9"
structopt = "0.3.21"
rpassword = "5.0"

Then we update the SSH script:

use ssh2::Session;
use std::io::prelude::*;
use std::net::TcpStream;
extern crate rpassword;
use rpassword::read_password;
use structopt::StructOpt;

#[derive(Debug, StructOpt)]
#[structopt(name = "structopt example", about = "using structop")]
struct Args {
    #[structopt(short = "h", long)]
    host: String,
    #[structopt(short = "c", long)]
    command: String,
    #[structopt(short = "u", long)]
    username: String,
}

fn main() {
    println!("Enter your password: ");
    let password = read_password().unwrap();
    let args = Args::from_args();
    println!(
        "Running command {} against host {}:\n",
        args.command, args.host
    );
    let tcp = TcpStream::connect(args.host + ":22").unwrap();
    let mut sess = Session::new().unwrap();
    sess.set_tcp_stream(tcp);
    sess.handshake().unwrap();
    sess.userauth_password(&args.username, &password).unwrap();
    let mut channel = sess.channel_session().unwrap();
    channel.exec(&args.command).unwrap();
    let mut command_output = String::new();
    channel.read_to_string(&mut command_output).unwrap();
    println!("{}", command_output);
    channel.wait_close().ok();
    println!("{}", channel.exit_status().unwrap());
}

Running the above gives us the following:

root@ru:/ssh# cargo run -- -h 192.168.1.50  -c 'show version' -u said

    Finished dev [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/ssh_example -h 192.168.1.50 -c 'show version' -u said`
Enter your password: 

Running command show version against host 192.168.1.50:

Arista DCS-7050TX-64-R
Hardware version:    01.01
Serial number:       KLF80800800
System MAC address:  001c.84ec.cb72

Software image version: 4.20.12M
Architecture:           i386
Internal build version: 4.20.12M-11527863.42012M
Internal build ID:      3a8329a8-af7b-4a7e-ae1c-cad006c5540d

Uptime:                 114 weeks, 1 days, 7 hours and 2 minutes
Total memory:           3818208 kB
Free memory:            2258276 kB


0

Instead of targeting a router, we can also play around targeting Linux servers:

root@ru:/ssh# cargo run -- -h 10.0.0.1  -c 'ls -ltr' -u said           

    Finished dev [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/ssh_example -h 10.0.0.1 -c 'ls -ltr' -u said`
Enter your password: 

Running command ls -ltr against host 10.0.0.1:

total 25032
-rw-r--r--.  1 said UnixUsers 25627989 May  3 11:11 Python-3.9.5.tgz
drwxr-xr-x. 16 said UnixUsers     4096 Jun  8 06:38 Python-3.9.5

0

Wrapping up

We created a small CLI tool that allows us to send a command over SSH to a server or a router. In the examples, I send a command to Linux server and an Arista switch. I did not get into all the specifics and this is not something that is ready for production. For one, I do not properly handle the Result, instead I used unwrap() everywhere. But the main point in this article was to give you a running start sending CLI commands to systems over SSH. I hope this gives you a nice starting point.