Creating a working ngrok from scratch using Rust and Go [Part 1]

Creating a working ngrok from scratch using Rust and Go [Part 1]

ยท

4 min read

Hello everyone! today I will be starting a series of articles trying to implement a bare-bones working ngrok. If you're not familiar with ngrok it's a cross-platform application that enables developers to expose a local development server to the Internet with minimal effort.

Its usage is very easy all you do is download the executable, run it specifying the port you want to expose and it returns a URL that you can access from the web.

./ngrok http 3000 # exposing localhost 3000

# repsonse
Session Status                online
Account                       x (Plan: Free)
Update                        update available (version 3.3.1, Ctrl-U to update)
Version                       3.1.1
Region                        Europe (eu)
Latency                       -
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://de2a-154-180-33-154.ngrok-free.app -> http://localhost:3000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

If anyone in the world uses this URL https://de2a-154-180-33-154.ngrok-free.app, they can access the web server running on our localhost port 3000.

Every Part will have its series of steps. In this part, we'll mainly focus on creating a CLI using Rust that I can pass a port to.

To start a new Rust project cargo new ngrnotok (yes I named it ngrnotok ๐Ÿ˜…)

We'll need to add a few packages in our Cargo.toml file

[dependencies]
bytes = "1.4.0"
clap = { version = "4.3.11", features = ["derive"] }
httparse = "1.8.0"
reqwest = "0.11.18"
tokio = { version = "1.29.1", features = ["full"] }
tokio-util = "0.7.8"

The clap package is the CLI one that will help us create the CLI with commands.

Let's get started by creating a file in src/ called cli.rs

use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(author, version)]
#[command(about = "NGR IM NOT OK")]
pub struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    Http(Config),
}

#[derive(Args)]
struct Config {
    #[arg(short = 'p', long = "port")]
    port: Option<String>,
}

impl Cli {
    pub async fn run() {
        let cli = Cli::parse();

        match &cli.command {
            Some(Commands::Http(config)) => {
                match &config.port {
                    Some(port) => {
                        println!("port provided!")
                    }
                    None => {
                        println!("No port provided")
                    }
                }
            }
            None => {
                println!("No command provided")
            }
        }
    }
}

We do the following in the code above:

  1. Create a struct Cli which has a property called command where command is an enum of different commands

  2. We create a struct commands which only have one value for the time being http

  3. The http command takes arguments specified in a struct called config. It only takes in the port number of the server running on our local host

  4. We then implement run as a struct method which will execute Cli::parse(); and block until a command is typed in

  5. Once a command gets typed in if it's http we'll check if a port is present or not and just print it.

We need to add this module created to our main.rs and instantiate a new Cli instance.

#main.rs
mod cli;
use commands::Cli;
async fn main() {
    Cli::run();
}

To get this running all you need to do is cargo build which builds an executable binary. Then in target/debug execute ./ngrnotok http -p 3000 and it should print the port number.

Next, we'll move on to explaining the flow. What are we exactly planning to do? how will the data flow from one end to another? Let's take a look at the image below

Let's break the steps down:

  1. Start ngrnotok ./ngrnotok http -p 3000 . When starting a TCP connection stream is opened between the Rust client and the Server (later part of the series)

  2. The client will then return a unique URL where I can access the project foo-123-44f.ngrnotok.app

  3. Then I send this URL to my friend and he types it in his browser

  4. His request gets routed to the Server that we'll be creating in Golang.

  5. The Server checks for the unique identifier in the URL foo-123-44f and then forwards the request over the TCP stream created to the Rust client (the blue data store in the image above keeps a key value for each unique id and tcp connection).

  6. The Rust client receives the required request and forwards it to the local server.

  7. The response is returned from the local server to the Rust client

  8. Rust client returns the response to the Go server

  9. The Server then will forward this Response back to the client.

A brilliant way of port-forwarding without actually needing to configure any port-forwarding done by ngrok!

This will be it for this article because I want anyone reading to have a general idea before diving deep.

In the Next part we'll cover the following:

  1. Starting our Go server

  2. Creating a TCP connection between the rust client and Golang server

  3. Generating a URL using uuid for every Rust client

  4. Downloading and installing dnsmasq which is a useful tool for development environment to redirect DNS names using wildcards *.ngrnotok.app

Did you find this article valuable?

Support Amr Elhewy by becoming a sponsor. Any amount is appreciated!

ย