Small Rust scanner that tries SSH on an IP range

Here’s a small Rust scanner that tries SSH on an IP range, auto-accepts host keys (no prompt), and reports which hosts are reachable (even if auth fails with “Permission denied”). It shells out to your system’s ssh with safe options:

  • StrictHostKeyChecking=no → auto-accept fingerprints
  • UserKnownHostsFile=/dev/null → don’t pollute your ~/.ssh/known_hosts
  • BatchMode=yes → never wait for password prompts
Cargo.toml
[package]
name = "ssh_find_hosts"
version = "0.1.0"
edition = "2021"

[dependencies]
rayon = "1.10"
src/main.rs
use rayon::prelude::*;
use std::env;
use std::process::{Command, Stdio};

#[derive(Debug, Clone)]
struct Target {
    ip: String,
    user: String,
    port: u16,
}

#[derive(Debug)]
enum Outcome {
    ReachableOk,           // ssh command succeeded (you had access)
    ReachableAuthFailed,   // permission denied / publickey
    Refused,               // connection refused
    NoRoute,               // no route to host
    Timeout,               // connection timed out
    Unknown(String),       // anything else (capture stderr)
}

fn try_ssh(t: &Target) -> Outcome {
    let dest = format!("{}@{}", t.user, t.ip);

    // Run a harmless remote command; '-q' silences banners.
    let output = Command::new("ssh")
        .args([
            "-p", &t.port.to_string(),
            "-q",
            "-o", "StrictHostKeyChecking=no",
            "-o", "UserKnownHostsFile=/dev/null",
            "-o", "BatchMode=yes",
            "-o", "ConnectTimeout=3",
            &dest,
            "true",
        ])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped())
        .output();

    let out = match output {
        Ok(o) => {
            let stderr = String::from_utf8_lossy(&o.stderr).to_lowercase();

            if o.status.success() {
                Outcome::ReachableOk
            } else if stderr.contains("permission denied")
                || stderr.contains("publickey")
                || stderr.contains("authenticity of host")
            {
                // Host answered and we got to the auth step → reachable
                Outcome::ReachableAuthFailed
            } else if stderr.contains("connection refused") {
                Outcome::Refused
            } else if stderr.contains("no route to host") {
                Outcome::NoRoute
            } else if stderr.contains("operation timed out")
                || stderr.contains("connection timed out")
            {
                Outcome::Timeout
            } else {
                Outcome::Unknown(stderr.trim().to_string())
            }
        }
        Err(e) => Outcome::Unknown(e.to_string()),
    };

    out
}

fn main() {
    // Usage: ssh_find_hosts <prefix> <start> <end> <user> [port]
    // Example: ssh_find_hosts 192.168.1 100 150 kzorluoglu 22
    let args: Vec<String> = env::args().collect();
    if args.len() < 5 {
        eprintln!(
            "Usage: {} <prefix> <start> <end> <user> [port]\n\
             Example: {} 192.168.1 100 150 kzorluoglu 22",
            args[0], args[0]
        );
        std::process::exit(2);
    }
    let prefix = &args[1];
    let start: u16 = args[2].parse().expect("start must be number");
    let end: u16 = args[3].parse().expect("end must be number");
    let user = &args[4];
    let port: u16 = if args.len() > 5 {
        args[5].parse().expect("port must be number")
    } else {
        22
    };

    let targets: Vec<Target> = (start..=end)
        .map(|i| Target {
            ip: format!("{}.{}", prefix, i),
            user: user.clone(),
            port,
        })
        .collect();

    #[derive(Default)]
    struct Buckets {
        ok: Vec<String>,
        auth_failed: Vec<String>,
        refused: Vec<String>,
        no_route: Vec<String>,
        timeout: Vec<String>,
        other: Vec<(String, String)>,
    }

    let mut buckets = Buckets::default();

    targets.par_iter().for_each(|t| {
      let res = try_ssh(t);
      match res {
          Outcome::ReachableOk => println!("[OK] {}", t.ip),
          Outcome::ReachableAuthFailed => println!("[REACHABLE:AUTH-FAILED] {}", t.ip),
          Outcome::Refused => println!("[REFUSED] {}", t.ip),
          Outcome::NoRoute => println!("[NO-ROUTE] {}", t.ip),
          Outcome::Timeout => println!("[TIMEOUT] {}", t.ip),
          Outcome::Unknown(ref msg) => println!("[OTHER] {} :: {}", t.ip, msg),
      }
    });

    // Second pass to collect neatly (serial, quick repeat but deterministic).
    let mut b = Buckets::default();
    for t in targets {
        match try_ssh(&t) {
            Outcome::ReachableOk => b.ok.push(t.ip),
            Outcome::ReachableAuthFailed => b.auth_failed.push(t.ip),
            Outcome::Refused => b.refused.push(t.ip),
            Outcome::NoRoute => b.no_route.push(t.ip),
            Outcome::Timeout => b.timeout.push(t.ip),
            Outcome::Unknown(msg) => b.other.push((t.ip, msg)),
        }
    }

    println!("\n===== SUMMARY =====");
    println!("Reachable (OK):               {:?}", b.ok);
    println!("Reachable (auth failed):      {:?}", b.auth_failed);
    println!("Connection refused:           {:?}", b.refused);
    println!("No route:                     {:?}", b.no_route);
    println!("Timeout:                      {:?}", b.timeout);
    if !b.other.is_empty() {
        println!("Other:");
        for (ip, msg) in b.other {
            println!("  {} :: {}", ip, msg);
        }
    }
}
Build & run
cargo run --release -- 192.168.1 100 110 kzorluoglu 22

Views: 0