Tutorial: Building a Port Scanner in Rust ๐Ÿฆ€

Tutorial: Building a Port Scanner in Rust ๐Ÿฆ€

ยท

7 min read

Introduction

Hello amazing people, welcome to our Rust programming tutorial on creating an IP sniffer! Let's learn how to build a basic network tool that can scan ports on a specified IP address to see which ones are open.

This is a practical project that can help you understand network programming, asynchronous Rust with Tokio, and handling command-line arguments using Bpaf. By the end of this tutorial, you will have a clearer insight into network operations and Rust's powerful asynchronous features.

The Code

The code for our IP sniffer is structured in a single main.rs file and utilizes dependencies from cargo.toml. Let's break down each part of the code to understand its purpose and functionality.

Dependencies and Imports

use bpaf::Bpaf;
use std::io::{self, Write};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::mpsc::{channel, Sender};
use tokio::net::TcpStream;
use tokio::task;

Here, we import the necessary modules and crates:

  • bpaf for parsing command-line arguments.

  • std::io and std::net for input/output operations and networking.

  • std::sync::mpsc for message passing between threads.

  • tokio for asynchronous programming.

Constants and CLI Arguments

This part of the code defines the structure for handling command-line arguments in your Rust application. It uses the bpaf crate to parse and validate these arguments efficiently. Here's what each component does:

const MAX: u16 = 65535;
const IPFALLBACK: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));

#[derive(Debug, Clone, Bpaf)]
#[bpaf(options)]
pub struct Arguments {
    #[bpaf(long, short, argument("Address"), fallback(IPFALLBACK))]
    pub address: IpAddr,
    #[bpaf(long("start"), short('s'), guard(start_port_guard, "Must be greater than 0"), fallback(1u16))]
    pub start_port: u16,
    #[bpaf(long("end"), short('e'), guard(end_port_guard, "Must be less than or equal to 65535"), fallback(MAX))]
    pub end_port: u16,
}
  1. Constants (MAXandIPFALLBACK): These are predefined values used as defaults. MAX sets the maximum value for the end port, ensuring it does not exceed the maximum allowable port number (65535). IPFALLBACK provides a default IP address (127.0.0.1, which is the localhost) in case the user does not specify one.

  2. Argumentsstruct: This structure defines the types and constraints of the command-line arguments your program will accept.

    • address: This field accepts an IP address as an input. If the user does not provide an address, it defaults to IPFALLBACK.

    • start_port and end_port: These fields define the range of ports to scan. The start_port must be greater than 0, and end_port must be less than or equal to 65535. Default values are provided for both, with start_port starting from 1 and end_port using the maximum port number possible.

The use of bpaf for argument parsing helps in making the command-line interface of your application robust, user-friendly, and less prone to errors, as it handles validation and defaults gracefully.

Port Scanning Function

The scan function in the provided code snippet is an asynchronous function designed to check if a specific port on a given IP address is open. Hereโ€™s a detailed breakdown of what each part of the function does and why itโ€™s necessary:

async fn scan(tx: Sender<u16>, start_port: u16, addr: IpAddr) {
    match TcpStream::connect(format!("{}:{}", addr, start_port)).await {
        Ok(_) => {
            print!(".");
            io::stdout().flush().unwrap();
            tx.send(start_port).unwrap();
        }
        Err(_) => {}
    }
}
  1. Function Signature:

    • async fn scan(tx: Sender<u16>, start_port: u16, addr: IpAddr): This defines an asynchronous function named scan that takes three parameters:

      • tx: A sender channel of type Sender<u16> which is used to send data (in this case, port numbers) to another part of your program.

      • start_port: The port number to check.

      • addr: The IP address on which to check the port.

  2. TCP Connection Attempt:

    • TcpStream::connect(format!("{}:{}", addr, start_port)).await: This line attempts to establish a TCP connection to the specified addr and start_port. The await keyword is used because TcpStream::connect is an asynchronous operation, and you need to wait for it to complete before moving on.
  3. Handling the Connection Result:

    • match TcpStream::connect(...): The match statement is used to handle the different outcomes of the connection attempt:

      • Ok(_): If the connection is successful (indicating the port is open):

        • print!("."): Prints a dot (.) to the standard output as a visual indication of a successful connection.

        • io::stdout().flush().unwrap(): Ensures that the dot is immediately displayed on the screen by flushing the standard output buffer.

        • tx.send(start_port).unwrap(): Sends the open port number back through the tx channel to be processed or recorded by another part of your program.

      • Err(_): If the connection fails (indicating the port is closed), nothing happens ({}).

This function is essential for performing port scanning by checking each port in a specified range to determine if it is open. It utilizes asynchronous programming to handle potentially long-running network operations efficiently, without blocking the execution of other parts of your program. This allows for scanning multiple ports concurrently, significantly speeding up the process.

Main Function

The main function sets up the asynchronous environment, collects arguments, and spawns tasks for scanning each port within the specified range. Results are collected, sorted, and printed.

#[tokio::main]
async fn main() {
    let opts = arguments().run();
    let (tx, rx) = channel();
    for i in opts.start_port..opts.end_port {
        let tx = tx.clone();
        task::spawn(async move { scan(tx, i, opts.address).await });
    }
    drop(tx);
    let mut out = vec![];
    for p in rx {
        out.push(p);
    }
    println!("");
    out.sort();
    for v in out {
        println!("{} is open", v);
    }
}
  1. Entry Point:

    • #[tokio::main]: This attribute macro converts the regular main function into an asynchronous main function. It sets up the Tokio runtime, which is necessary for running asynchronous code.

    • async fn main(): This declares an asynchronous main function, allowing the use of await within it.

  2. Argument Parsing:

    • let opts = arguments().run();: This line calls the arguments() function (presumed to be defined elsewhere in your code) which constructs and parses command-line arguments, returning an instance of the Arguments struct stored in opts.
  3. Channel Setup:

    • let (tx, rx) = channel();: Here, a multi-producer, single-consumer (MPSC) channel is created. tx is the transmitter or sender, and rx is the receiver. This channel is used to communicate between asynchronous tasks.
  4. Port Scanning Loop:

    • for i in opts.start_port..opts.end_port: This loop iterates over the range of ports from start_port to end_port as specified in the command-line arguments.

    • Inside the loop:

      • let tx = tx.clone();: Clones the sender part of the channel. This is necessary because the sender will be moved into the asynchronous task.

      • task::spawn(async move { scan(tx, i, opts.address).await });: Spawns a new asynchronous task for each port. The scan function is called with the current port number i, the cloned sender tx, and the target IP address opts.address. Each task will attempt to connect to its assigned port and send the results back through the channel.

  5. Closing the Sender:

    • drop(tx);: Explicitly drops the original sender. This is important because it signals that no more messages will be sent on this channel, allowing the receiver to exit its loop once all sent messages are processed.
  6. Collecting Results:

    • let mut out = vec![];: Initializes a vector to store the results.

    • for p in rx { out.push(p); }: This loop receives messages from the channel. Each message represents an open port number, which is added to the out vector.

  7. Output Results:

    • println!("");: Prints a newline for better formatting before outputting the results.

    • out.sort();: Sorts the vector of open ports.

    • for v in out { println!("{} is open", v); }: Iterates through the sorted list of open ports and prints each one.

Overall, this function orchestrates the entire port scanning operation using asynchronous programming to handle potentially large numbers of ports efficiently and concurrently.

You can find the code here:

Build and Run the Program

Use cargo run to build and run your program. cargo run

Provide Command-Line Arguments

cargo run -- --address 192.168.1.1 --start 1 --end 1000 Replace 192.168.1.1 with the IP address you want to scan, and adjust the --start and --end arguments to specify the range of ports to scan.

Conclusion

In this tutorial, you learned how to construct a simple IP sniffer using Rust. This project covered handling command-line arguments, performing network operations, and using asynchronous programming with Tokio. Such tools are not only useful for network diagnostics but also serve as great learning exercises for understanding the underlying principles of network communications and concurrent programming in Rust.

Happy Rust Coding! ๐Ÿคž๐Ÿฆ€


๐Ÿ‘‹ Hello, I'm Eleftheria, Community Manager, developer, public speaker, and content creator.

๐Ÿฅฐ If you liked this article, consider sharing it.

๐Ÿ”— All links | X | LinkedIn

Did you find this article valuable?

Support Eleftheria Batsou by becoming a sponsor. Any amount is appreciated!

ย