Build a TCP Tunnel CLI in Rust

Build a working ngrok-style TCP tunnel from scratch using async Rust.

Introduction

30 Second Summary

Ever shared a local app with a teammate using a tool like ngrok? Behind the scenes, it creates a tunnel that forwards internet traffic straight to your machine.

In this project, you will build your own TCP tunnel system called "MiniTunnel" from scratch in Rust. You'll create two binaries: a server that listens for connections on a public port, and a client that relays traffic to your local service.

What You'll Build

MiniTunnel is a two-binary TCP tunneling system that exposes a local service through a remote server, using a custom JSON wire protocol over length-delimited framing.

By the end of this project, you'll have:

  • A Cargo workspace with a shared protocol library and two binary crates.
  • A custom wire protocol using JSON messages with length-delimited framing for reliable communication.
  • A working TCP tunnel that forwards internet traffic from a public port to a local service on your machine.
  • Secret Mission: Deploy your tunnel to a VPS and add HMAC-based secret authentication.

Are there any prerequisites?

This project assumes you're familiar with Rust basics like ownership, enums, and traits. You should also have experience with async/await and Tokio.

Before We Start

Before We Start...

Set Up the Cargo Workspace

MiniTunnel is built from three separate Rust packages that work together. A shared protocol library defines the message types that the server and client exchange over the wire.

A Cargo workspace lets you manage all three crates under a single root. It shares one target directory and one lockfile across members, and supports dependency inheritance so you define versions once instead of repeating them everywhere.

In this step, get ready to:
  • Create a Cargo workspace with three member crates.
  • Define shared dependencies using workspace-level inheritance.
  • Verify everything compiles with cargo build.
Create the project folder and crate directories

Every Cargo workspace starts with a root folder that contains the member crates as subdirectories. You will use cargo new to scaffold each crate with the correct structure.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir minitunnel
cd minitunnel
  • Scaffold the three member crates by running these commands:
cargo new minitunnel-protocol --lib
cargo new minitunnel-server
cargo new minitunnel-client

What do these commands do?

  • cargo new minitunnel-protocol --lib creates a library crate. Library crates expose functions and types for other crates to use. This is where the shared wire protocol will live.
  • cargo new minitunnel-server and cargo new minitunnel-client create binary crates. Each gets a src/main.rs with a fn main() entry point.
  • Confirm the folders were created by running:
ls

You should see minitunnel-protocol, minitunnel-server, and minitunnel-client listed in the output.

Don't see all three folders?

Make sure you ran all three cargo new commands from inside the minitunnel folder. Run pwd to check your current directory. It should end with minitunnel.

Help me figure out why my cargo new commands didn't create the right folders.

  • Open the minitunnel folder in your text editor or IDE so you can edit the project files.
Configure the workspace dependencies

Right now, each crate is a standalone project with its own Cargo.toml. To tie them into a workspace, you need a root Cargo.toml that declares the members and their shared dependencies.

  • Create a new file called Cargo.toml in the root minitunnel folder (not inside any of the member directories).
  • Paste the following into it:
[workspace]
members = ["minitunnel-protocol", "minitunnel-server", "minitunnel-client"]
resolver = "2"

[workspace.dependencies]
tokio = { version = "1.52", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] }
tokio-util = { version = "0.7.18", features = ["codec"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1.23", features = ["v4", "serde"] }
bytes = "1"
futures-util = "0.3"
dashmap = "6"
clap = { version = "4.6", features = ["derive"] }

What is workspace dependency inheritance?

The [workspace.dependencies] section defines every crate version and feature flag once at the root. Each member then references these with workspace = true instead of repeating the version string.

This means updating a dependency version is a single-line change in the root, not a hunt through every member's manifest.

What are these dependencies for?

  • tokio is the async runtime that powers all networking and concurrency.
  • tokio-util provides LengthDelimitedCodec for framing messages over TCP.
  • serde and serde_json handle serializing messages to and from JSON bytes.
  • dashmap is a concurrent hashmap the server uses to match visitor connections with proxy connections.
  • clap parses command-line arguments for both binaries.
  • Save Cargo.toml.

Now each member crate needs its own Cargo.toml updated to reference these workspace dependencies. The cargo new commands generated default manifests, so you will replace their contents.

  • Open minitunnel-protocol/Cargo.toml and replace its entire contents with the following:
[package]
name = "minitunnel-protocol"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
bytes = { workspace = true }
futures-util = { workspace = true }
uuid = { workspace = true }

What does workspace = true mean?

Each workspace = true line tells Cargo to pull the version and features from the root [workspace.dependencies] section. The protocol crate needs networking (tokio), framing (tokio-util), serialization (serde), and UUID generation (uuid) because it handles all the message-level I/O.

  • Save minitunnel-protocol/Cargo.toml.
  • Open minitunnel-server/Cargo.toml and replace its entire contents with the following:
[package]
name = "minitunnel-server"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
dashmap = { workspace = true }
clap = { workspace = true }
minitunnel-protocol = { path = "../minitunnel-protocol" }

What is the path dependency?

The line minitunnel-protocol = { path = "../minitunnel-protocol" } tells Cargo that this binary depends on your local protocol library. Both binaries will use this path dependency to access the shared message types and transport helpers you build in the next step.

  • Save minitunnel-server/Cargo.toml.
  • Open minitunnel-client/Cargo.toml and replace its entire contents with the following:
[package]
name = "minitunnel-client"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true }
minitunnel-protocol = { path = "../minitunnel-protocol" }

Why different dependencies per crate?

Each crate only pulls in what it actually uses. The server needs dashmap for tracking pending connections and uuid for generating connection IDs. The client does not need either of those.

Keeping dependency lists minimal reduces compile times and final binary sizes.

  • Save minitunnel-client/Cargo.toml.

✔️ Awesome, I've got everything!

Double check you have saved all four Cargo.toml files before moving on.

ⓧ I'd like to double check the full code

Here are all four Cargo.toml files for comparison.

[workspace]
members = ["minitunnel-protocol", "minitunnel-server", "minitunnel-client"]
resolver = "2"

[workspace.dependencies]
tokio = { version = "1.52", features = ["rt-multi-thread", "io-util", "macros", "net", "time"] }
tokio-util = { version = "0.7.18", features = ["codec"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
uuid = { version = "1.23", features = ["v4", "serde"] }
bytes = "1"
futures-util = "0.3"
dashmap = "6"
clap = { version = "4.6", features = ["derive"] }
[package]
name = "minitunnel-protocol"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
bytes = { workspace = true }
futures-util = { workspace = true }
uuid = { workspace = true }
[package]
name = "minitunnel-server"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
dashmap = { workspace = true }
clap = { workspace = true }
minitunnel-protocol = { path = "../minitunnel-protocol" }
[package]
name = "minitunnel-client"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true }
minitunnel-protocol = { path = "../minitunnel-protocol" }
Verify the workspace compiles

The moment of truth. If Cargo can resolve all the workspace dependencies and compile every member, your project structure is solid.

  • Make sure your terminal is in the root minitunnel folder (the one that contains the workspace Cargo.toml).
  • Build the entire workspace by running:
cargo build

The first build downloads and compiles all dependencies. This takes a minute or two depending on your machine.

What should I see?

Cargo prints a series of Compiling lines as it processes each dependency. The final lines should look something like:

Compiling minitunnel-protocol v0.1.0
Compiling minitunnel-server v0.1.0
Compiling minitunnel-client v0.1.0
    Finished `dev` profile [unoptimized + debuginfo] target(s)

The key thing is the Finished line at the end with no errors.

Seeing a compilation error?

  • If you see error: failed to get `minitunnel-protocol` as a dependency, check that the path = "../minitunnel-protocol" in your server and client Cargo.toml files points to the correct relative location.
  • If you see error: current package believes it's in a workspace, make sure the root Cargo.toml lists all three members in the members array. The folder names must match exactly.
  • If you see error: failed to select a version for the requirement, double check the version numbers in [workspace.dependencies] match the code above. Use the double-check tab to compare.

Help me debug my cargo build error for the MiniTunnel workspace.

Your workspace structure is locked in and compiling. Next up, you will define the wire protocol that your server and client use to exchange messages over TCP.

Define the Wire Protocol

Your workspace is ready with three crates wired together. Now it's time to give the server and client a shared language so they can talk to each other.

Both binaries need to exchange structured messages over a TCP connection. Raw bytes are tricky to work with directly because TCP is a stream protocol with no built-in message boundaries. In this step, you'll define a set of message types and pair them with length-delimited framing to solve this.

In this step, get ready to:
  • Define the four message types your tunnel protocol uses.
  • Set up a length-delimited transport layer over TCP.
  • Write async send and receive functions for framed messages.
Define the message types and transport layer

Every interaction between the server and client is a message. The client says "hello", the server responds with a port assignment, and later the server requests proxy connections by sending a UUID. You need to define these message types as a Rust enum so both crates share the same vocabulary.

  • Open minitunnel-protocol/src/lib.rs in your editor. Replace whatever is there with the following code:
use anyhow::{Context, Result};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use uuid::Uuid;

// Every message that can be sent between the server and client
#[derive(Debug, Serialize, Deserialize)]
pub enum Message {
    Hello,
    HelloResponse { port: u16 },
    ConnectionRequest { id: Uuid },
    Accept { id: Uuid },
}

// A framed TCP stream that handles length-delimited encoding automatically
pub type Transport = Framed<TcpStream, LengthDelimitedCodec>;

// Wrap a raw TcpStream with length-delimited framing
pub fn transport(stream: TcpStream) -> Transport {
    Framed::new(stream, LengthDelimitedCodec::new())
}

What does this code do?

  • Message is an enum with four variants representing the entire conversation between server and client. Hello initiates a connection. HelloResponse carries the assigned tunnel port. ConnectionRequest asks the client to open a proxy for a specific visitor. Accept confirms the proxy connection.
  • Transport is a type alias for a TcpStream wrapped in a LengthDelimitedCodec. The transport() helper creates one from a raw stream so every caller doesn't have to set up framing manually.

Why length-delimited framing?

TCP is a byte stream with no concept of "messages." If you send two JSON blobs back to back, the receiver has no way to tell where one ends and the next begins.

LengthDelimitedCodec solves this by prefixing every message with a 4-byte big-endian length header. The receiver reads the length first, then reads exactly that many bytes. This is how production protocols handle framing. It also handles partial reads and buffering automatically.

  • Save minitunnel-protocol/src/lib.rs.
  • Verify the workspace compiles by running:
cargo build

You should see all three crates compile successfully with no errors.

Seeing a compile error?

  • If you see unresolved import errors, double check that minitunnel-protocol/Cargo.toml lists all the dependencies from Step 1 (tokio, tokio-util, serde, serde_json, anyhow, bytes, futures-util, uuid).
  • If you see Serialize is not implemented, make sure the workspace root Cargo.toml has features = ["derive"] on the serde dependency.
  • Why won't my protocol crate compile?
Write the send and receive functions

The message types define what the server and client say. Now you need two functions that handle how they say it: serializing a Message to JSON bytes and pushing it through the framed transport, then doing the reverse on the receiving side.

  • In minitunnel-protocol/src/lib.rs, add the send_message function below the transport function by pasting this code:
// Serialize a message to JSON and send it as a length-prefixed frame
pub async fn send_message(transport: &mut Transport, msg: &Message) -> Result<()> {
    let json = serde_json::to_vec(msg).context("failed to serialize message")?;
    transport
        .send(Bytes::from(json))
        .await
        .context("failed to send message")?;
    Ok(())
}

What does this code do?

  • serde_json::to_vec converts a Message into a Vec<u8> of JSON bytes.
  • transport.send(Bytes::from(json)) wraps those bytes in a Bytes buffer and pushes them through the framed transport. The codec automatically prepends the 4-byte length header before writing to the socket.

You won't see a change yet. The next function completes the pair.

  • Below send_message, add the recv_message function by pasting this code:
// Read the next length-prefixed frame and deserialize it from JSON
pub async fn recv_message(transport: &mut Transport) -> Result<Message> {
    let data = transport
        .next()
        .await
        .context("connection closed")?
        .context("failed to read frame")?;
    let msg: Message = serde_json::from_slice(&data).context("failed to deserialize message")?;
    Ok(msg)
}

What does this code do?

  • transport.next().await reads the next complete frame from the stream. The codec reads the 4-byte length header, then reads exactly that many bytes. No partial messages, no manual buffering.
  • The chained .context() calls handle two failure cases: the connection dropping (None from the stream) and a codec-level read error.
  • serde_json::from_slice deserializes the raw bytes back into a Message enum variant.
  • Save minitunnel-protocol/src/lib.rs.
  • Verify everything compiles by running:
cargo build

You should see all three crates compile with no errors. The protocol library is complete.

Build failing after adding the functions?

  • If you see cannot find function 'send' in this scope, make sure you have use futures_util::{SinkExt, StreamExt}; in your imports. SinkExt provides the .send() method and StreamExt provides .next().
  • If you see a type mismatch on the send call, make sure you wrap the JSON vector with Bytes::from(json). The codec expects Bytes, not a raw Vec<u8>.
  • Why is my protocol library failing to compile?

Extra for Experts: why two .context() calls on recv_message?

transport.next().await returns an Option<Result<BytesMut>>. The outer Option is None when the stream closes. The inner Result is an error when the codec fails to read a frame. Each .context() unwraps one layer, giving you a clear error message for each failure mode.

✔️ Awesome, I've got everything!

Make sure you've saved minitunnel-protocol/src/lib.rs and that cargo build succeeds.

ⓧ I'd like to double check the full code

Here is the complete minitunnel-protocol/src/lib.rs file. Compare it with yours line by line.

use anyhow::{Context, Result};
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use uuid::Uuid;

#[derive(Debug, Serialize, Deserialize)]
pub enum Message {
    Hello,
    HelloResponse { port: u16 },
    ConnectionRequest { id: Uuid },
    Accept { id: Uuid },
}

pub type Transport = Framed<TcpStream, LengthDelimitedCodec>;

pub fn transport(stream: TcpStream) -> Transport {
    Framed::new(stream, LengthDelimitedCodec::new())
}

pub async fn send_message(transport: &mut Transport, msg: &Message) -> Result<()> {
    let json = serde_json::to_vec(msg).context("failed to serialize message")?;
    transport
        .send(Bytes::from(json))
        .await
        .context("failed to send message")?;
    Ok(())
}

pub async fn recv_message(transport: &mut Transport) -> Result<Message> {
    let data = transport
        .next()
        .await
        .context("connection closed")?
        .context("failed to read frame")?;
    let msg: Message = serde_json::from_slice(&data).context("failed to deserialize message")?;
    Ok(msg)
}

Your wire protocol is locked in. The server and client now have a shared vocabulary and a reliable way to exchange messages over TCP. Next up, you'll build the server that listens for these messages and dynamically allocates tunnel ports.

Build the Server

Your wire protocol is in place. The Message enum, the send_message and recv_message helpers, and the length-delimited framing are all ready to use.

Now you need the program that actually listens for connections and stitches traffic together. The server is the public-facing half of your tunnel. It accepts a control connection from the client, dynamically opens a port for visitors, and then matches each visitor to a proxy connection using a UUID.

In this step, get ready to:
  • Set up the server binary with clap to accept a --port flag for the control port.
  • Implement the control connection handler that allocates a random tunnel port and manages visitor traffic.
  • Implement the proxy handler that matches visitor connections by UUID and relays bytes bidirectionally.
Set up the server entry point and connection dispatcher

The server needs to do three things when a TCP connection arrives: figure out what kind of connection it is, route it to the right handler, and share state across all connections. You will build this dispatch layer first, then fill in the handlers.

  • Open minitunnel-server/src/main.rs in your editor. Replace the placeholder fn main() {} with the following imports, CLI struct, and shared state struct:
use std::sync::Arc;
use std::time::Duration;

use anyhow::{bail, Context, Result};
use clap::Parser;
use dashmap::DashMap;
use minitunnel_protocol::{recv_message, send_message, transport, Message};
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info, warn};
use uuid::Uuid;

#[derive(Parser)]
#[command(name = "minitunnel-server")]
struct Args {
    /// Control port to listen on
    #[arg(short, long, default_value_t = 7835)]
    port: u16,
}

struct ServerState {
    pending: DashMap<Uuid, TcpStream>,
}

What does this code do?

  • The imports pull in your protocol crate (minitunnel_protocol), Tokio networking types, and the DashMap concurrent hashmap.
  • Args uses clap's derive macro to parse a --port flag from the command line, defaulting to 7835 (the same port bore uses).
  • ServerState holds a DashMap that maps UUIDs to visitor TCP streams waiting for a proxy match. DashMap is a concurrent HashMap that doesn't need wrapping in Arc<Mutex<>>.
  • Below the ServerState struct, add the main() function and the handle_connection() dispatcher:
#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    let args = Args::parse();

    let state = Arc::new(ServerState {
        pending: DashMap::new(),
    });

    let listener = TcpListener::bind(("0.0.0.0", args.port)).await?;
    info!("Server listening on 0.0.0.0:{}", args.port);

    loop {
        let (stream, addr) = listener.accept().await?;
        let state = state.clone();
        tokio::spawn(async move {
            if let Err(e) = handle_connection(stream, state).await {
                error!("Connection from {addr} error: {e:#}");
            }
        });
    }
}

async fn handle_connection(stream: TcpStream, state: Arc<ServerState>) -> Result<()> {
    let mut framed = transport(stream);
    let msg = recv_message(&mut framed).await?;

    match msg {
        Message::Hello => handle_control(framed, state).await,
        Message::Accept { id } => handle_proxy(framed, id, state).await,
        other => bail!("unexpected message: {other:?}"),
    }
}

What does this code do?

  • main() creates the shared ServerState wrapped in an Arc so it can be safely shared across spawned tasks. It binds a TCP listener on all interfaces and loops forever, spawning a new task for every incoming connection.
  • handle_connection() is the dispatcher. It reads the first message on every new TCP connection and decides what to do. A Hello message means a client is registering a new tunnel. An Accept message means a client is connecting a proxy for a specific visitor.

Why use one port for both control and proxy connections?

Both the client's control connection and its proxy connections arrive on the same control port. The first message determines the connection's purpose. This avoids needing a second listener port on the server and keeps the architecture simple.

You won't see a change yet. The file references handle_control and handle_proxy, which don't exist yet.

Handle control connections and visitor traffic

When a client sends a Hello message, the server needs to allocate a fresh public port, tell the client about it, and then start accepting visitor connections on that port. Each visitor gets a UUID that the client will use to wire up the proxy.

  • Below the handle_connection() function, add the handle_control() function:
async fn handle_control(
    mut control: minitunnel_protocol::Transport,
    state: Arc<ServerState>,
) -> Result<()> {
    let tunnel_listener = TcpListener::bind("0.0.0.0:0").await?;
    let tunnel_port = tunnel_listener.local_addr()?.port();
    info!("Allocated tunnel port {tunnel_port}");

    send_message(&mut control, &Message::HelloResponse { port: tunnel_port }).await?;

    loop {
        let (visitor_stream, visitor_addr) = tunnel_listener.accept().await?;
        info!("Visitor connected from {visitor_addr}");

        let id = Uuid::new_v4();
        state.pending.insert(id, visitor_stream);

        send_message(&mut control, &Message::ConnectionRequest { id }).await?;

        let state_clone = state.clone();
        tokio::spawn(async move {
            tokio::time::sleep(Duration::from_secs(10)).await;
            if state_clone.pending.remove(&id).is_some() {
                warn!("Connection {id} timed out");
            }
        });
    }
}

What does this code do?

  • TcpListener::bind("0.0.0.0:0") asks the OS to pick any available port. The server reads back the assigned port with local_addr()?.port() and sends it to the client in a HelloResponse.
  • The loop accepts visitor connections on the dynamically allocated port. For each visitor, it generates a Uuid, stores the visitor's TcpStream in the pending map, and sends a ConnectionRequest to the client so it knows to open a proxy.
  • The 10-second timeout task cleans up stale entries. If the client never connects a proxy for a visitor, the pending entry is removed so it doesn't leak memory.
Match proxy connections and relay bytes

When the client opens a proxy connection and sends an Accept message with a UUID, the server needs to find the matching visitor stream and weld the two sockets together. This is where copy_bidirectional does the heavy lifting.

  • Below the handle_control() function, add the handle_proxy() function:
async fn handle_proxy(
    framed: minitunnel_protocol::Transport,
    id: Uuid,
    state: Arc<ServerState>,
) -> Result<()> {
    let (_, mut visitor_stream) = state
        .pending
        .remove(&id)
        .context("no pending connection for this ID")?;

    info!("Proxy connected for {id}");

    let mut proxy_stream = framed.into_inner();

    let (bytes_to_visitor, bytes_to_proxy) =
        tokio::io::copy_bidirectional(&mut visitor_stream, &mut proxy_stream).await?;

    info!(
        "Connection {id} closed. Bytes: {bytes_to_visitor} to visitor, {bytes_to_proxy} to proxy"
    );
    Ok(())
}

What does this code do?

  • state.pending.remove(&id) looks up the visitor stream by UUID and removes it from the map in one atomic operation. If the UUID doesn't exist (timed out or invalid), the function bails with an error.
  • framed.into_inner() unwraps the Framed transport to get the raw TcpStream back. After the Accept handshake, you need raw bytes flowing between the visitor and the proxy with no framing overhead.
  • copy_bidirectional reads from both streams concurrently, forwarding data to the opposite stream. When one side closes, it shuts down the other. It returns the byte counts for both directions.
  • Save minitunnel-server/src/main.rs.
  • Verify the server compiles by running this command from your workspace root:
cargo build -p minitunnel-server
  • You should see Compiling minitunnel-server followed by Finished with no errors.
  • Test that the server starts by running:
cargo run -p minitunnel-server
  • You should see a log line containing Server listening on 0.0.0.0:7835. The server is now waiting for client connections.
  • Press Ctrl+C to stop the server.

Seeing a compile error?

  • If you see cannot find function errors for handle_control or handle_proxy, make sure you added all four functions (main, handle_connection, handle_control, handle_proxy) to the file.
  • If you see unresolved import errors, check that your use statements at the top of the file match the code above exactly. The crate name is minitunnel_protocol (underscores, not hyphens).
  • If the server starts but immediately panics with an address-in-use error, another process is using port 7835. Stop it or pass a different port with --port 8000.

Help me debug my minitunnel-server compile error

✔️ Awesome, I've got everything!

Great. Double check you have saved minitunnel-server/src/main.rs.

ⓧ I'd like to double check the full code

use std::sync::Arc;
use std::time::Duration;

use anyhow::{bail, Context, Result};
use clap::Parser;
use dashmap::DashMap;
use minitunnel_protocol::{recv_message, send_message, transport, Message};
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info, warn};
use uuid::Uuid;

#[derive(Parser)]
#[command(name = "minitunnel-server")]
struct Args {
    /// Control port to listen on
    #[arg(short, long, default_value_t = 7835)]
    port: u16,
}

struct ServerState {
    pending: DashMap<Uuid, TcpStream>,
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    let args = Args::parse();

    let state = Arc::new(ServerState {
        pending: DashMap::new(),
    });

    let listener = TcpListener::bind(("0.0.0.0", args.port)).await?;
    info!("Server listening on 0.0.0.0:{}", args.port);

    loop {
        let (stream, addr) = listener.accept().await?;
        let state = state.clone();
        tokio::spawn(async move {
            if let Err(e) = handle_connection(stream, state).await {
                error!("Connection from {addr} error: {e:#}");
            }
        });
    }
}

async fn handle_connection(stream: TcpStream, state: Arc<ServerState>) -> Result<()> {
    let mut framed = transport(stream);
    let msg = recv_message(&mut framed).await?;

    match msg {
        Message::Hello => handle_control(framed, state).await,
        Message::Accept { id } => handle_proxy(framed, id, state).await,
        other => bail!("unexpected message: {other:?}"),
    }
}

async fn handle_control(
    mut control: minitunnel_protocol::Transport,
    state: Arc<ServerState>,
) -> Result<()> {
    let tunnel_listener = TcpListener::bind("0.0.0.0:0").await?;
    let tunnel_port = tunnel_listener.local_addr()?.port();
    info!("Allocated tunnel port {tunnel_port}");

    send_message(&mut control, &Message::HelloResponse { port: tunnel_port }).await?;

    loop {
        let (visitor_stream, visitor_addr) = tunnel_listener.accept().await?;
        info!("Visitor connected from {visitor_addr}");

        let id = Uuid::new_v4();
        state.pending.insert(id, visitor_stream);

        send_message(&mut control, &Message::ConnectionRequest { id }).await?;

        let state_clone = state.clone();
        tokio::spawn(async move {
            tokio::time::sleep(Duration::from_secs(10)).await;
            if state_clone.pending.remove(&id).is_some() {
                warn!("Connection {id} timed out");
            }
        });
    }
}

async fn handle_proxy(
    framed: minitunnel_protocol::Transport,
    id: Uuid,
    state: Arc<ServerState>,
) -> Result<()> {
    let (_, mut visitor_stream) = state
        .pending
        .remove(&id)
        .context("no pending connection for this ID")?;

    info!("Proxy connected for {id}");

    let mut proxy_stream = framed.into_inner();

    let (bytes_to_visitor, bytes_to_proxy) =
        tokio::io::copy_bidirectional(&mut visitor_stream, &mut proxy_stream).await?;

    info!(
        "Connection {id} closed. Bytes: {bytes_to_visitor} to visitor, {bytes_to_proxy} to proxy"
    );
    Ok(())
}

Your server is compiled and listening. Next up, you will build the client that connects to this server, registers a tunnel, and relays traffic from your local service.

Build the Client

Your server is ready to accept control connections, allocate tunnel ports, and match visitors with proxies. Now you need the other half of the system.

The client is what runs on your local machine. It connects to the server, registers itself, and then relays traffic between the server and your local service. In this step, you will build the entire client binary from scratch.

In this step, get ready to:
  • Set up the client CLI with flags for server address, server port, and local port.
  • Implement the control connection that registers with the server and listens for proxy requests.
  • Implement proxy forwarding that relays bytes between the server and your local service.
Set up the client CLI with clap

The client needs three pieces of information to do its job: where the server is, which port the server listens on, and which local port to forward traffic to. You will use clap to parse these from the command line, just like you did for the server.

  • Open minitunnel-client/src/main.rs in your editor.
  • Replace everything in the file with these imports and the Args struct:
use anyhow::{bail, Context, Result};
use clap::Parser;
use minitunnel_protocol::{recv_message, send_message, transport, Message};
use tokio::net::TcpStream;
use tracing::{error, info};
use uuid::Uuid;

// CLI arguments for the client binary
#[derive(Parser)]
#[command(name = "minitunnel-client")]
struct Args {
    /// Server address (hostname or IP)
    #[arg(short = 'a', long, default_value = "127.0.0.1")]
    server_addr: String,

    /// Server control port
    #[arg(short = 's', long, default_value_t = 7835)]
    server_port: u16,

    /// Local port to forward to
    #[arg(short, long)]
    local_port: u16,
}

What does this code do?

  • --server-addr defaults to 127.0.0.1 for local testing. You will point this at a real IP when deploying to a VPS later.
  • --server-port defaults to 7835 to match the server's default control port.
  • --local-port has no default because it depends on whatever local service the learner wants to expose.
  • The short flags use explicit characters ('a' and 's') to avoid clashing with each other.

You won't see a change yet. The next chunk adds the main function that uses these arguments.

Implement the control connection

The control connection is the persistent link between client and server. The client opens a single TCP connection, sends a Hello message, reads back a HelloResponse with the assigned tunnel port, and then loops forever waiting for the server to request proxy connections.

  • Below the Args struct, add the main function:
#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    let args = Args::parse();

    // Build the server address string from CLI arguments
    let server_endpoint = format!("{}:{}", args.server_addr, args.server_port);
    info!("Connecting to server at {server_endpoint}");

    // Open a TCP connection to the server's control port
    let stream = TcpStream::connect(&server_endpoint)
        .await
        .context("failed to connect to server")?;
    let mut control = transport(stream);

    // Send Hello to register with the server
    send_message(&mut control, &Message::Hello).await?;

    // Read HelloResponse to learn which port was allocated
    let msg = recv_message(&mut control).await?;
    let tunnel_port = match msg {
        Message::HelloResponse { port } => port,
        other => bail!("expected HelloResponse, got {other:?}"),
    };

    info!(
        "Tunnel established! Forwarding {}:{} -> 127.0.0.1:{}",
        args.server_addr, tunnel_port, args.local_port
    );

    // Listen for ConnectionRequest messages and spawn proxy tasks
    loop {
        let msg = recv_message(&mut control).await?;
        match msg {
            Message::ConnectionRequest { id } => {
                let server_endpoint = server_endpoint.clone();
                let local_port = args.local_port;
                tokio::spawn(async move {
                    if let Err(e) = handle_proxy(id, &server_endpoint, local_port).await {
                        error!("Proxy {id} error: {e:#}");
                    }
                });
            }
            other => bail!("unexpected message: {other:?}"),
        }
    }
}

What does this code do?

This function follows the same handshake protocol the server expects.

  • It connects to the server and wraps the stream in a Transport (length-delimited framing) using the transport() helper from your protocol crate.
  • After the Hello/HelloResponse exchange, the server tells the client which port visitors will connect to.
  • The infinite loop reads ConnectionRequest messages. Each one triggers a new Tokio task that handles the actual proxying in parallel.
  • The server_endpoint and local_port are cloned/copied into each spawned task because Tokio tasks must own their data.

You won't see a change yet. The handle_proxy function referenced inside the loop does not exist yet.

Implement proxy forwarding

Each time the server sends a ConnectionRequest, a visitor is waiting on the other end. The client needs to open two new connections: one back to the server (to "claim" the visitor) and one to the local service. Then it welds them together so bytes flow in both directions.

  • Below the main function, add the handle_proxy function:
async fn handle_proxy(id: Uuid, server_endpoint: &str, local_port: u16) -> Result<()> {
    // Open a new TCP connection to the server for this proxy
    let stream = TcpStream::connect(server_endpoint)
        .await
        .context("failed to connect proxy to server")?;
    let mut framed = transport(stream);

    // Send Accept message with the UUID so the server can match this to the visitor
    send_message(&mut framed, &Message::Accept { id }).await?;

    // Get the raw TcpStream back from the framed transport
    let mut proxy_stream = framed.into_inner();

    // Connect to the local service the user wants to expose
    let mut local_stream = TcpStream::connect(("127.0.0.1", local_port))
        .await
        .context("failed to connect to local service")?;

    // Relay bytes bidirectionally between local service and server
    let (bytes_to_local, bytes_to_server) =
        tokio::io::copy_bidirectional(&mut local_stream, &mut proxy_stream).await?;

    info!("Proxy {id} closed. Bytes: {bytes_to_local} to local, {bytes_to_server} to server");
    Ok(())
}

What does this code do?

This is the core of the tunnel. Every proxy connection follows three phases.

  • Claim the visitor. The client opens a fresh TCP connection to the server's control port and sends an Accept { id } message. The server uses the UUID to look up the waiting visitor in its DashMap.
  • Unwrap the transport. After the Accept message, the connection switches to raw bytes. Calling framed.into_inner() strips off the length-delimited framing and gives you the underlying TcpStream.
  • Weld the two streams. copy_bidirectional reads from both streams concurrently and writes data to the opposing stream. When one side sends EOF, it shuts down the other side's writer.

Why into_inner() instead of keeping the Framed wrapper?

The Framed wrapper adds length-delimited framing to every read and write. That is useful for your JSON protocol messages, but the actual tunnel traffic is raw bytes from the local service.

If you left the framing on, every HTTP response from your local server would get a 4-byte length prefix injected, corrupting the data. Unwrapping gives copy_bidirectional a clean stream to work with.

  • Save minitunnel-client/src/main.rs.
  • Verify the entire workspace compiles by running:
cargo build

You should see all three crates compile successfully with no errors.

Seeing a compile error?

  • If you see cannot find function `handle_proxy`, make sure you added the handle_proxy function below main() in the same file.
  • If you see unresolved import errors, check that your use statements at the top match the code exactly. The import from minitunnel_protocol uses an underscore, not a hyphen.
  • If you see a type mismatch on bail!, ensure you imported bail from anyhow in the first line of the file.

Help me debug this cargo build error in my minitunnel-client

✔️ Awesome, I've got everything!

Great. Double check you have saved minitunnel-client/src/main.rs before moving on.

ⓧ I'd like to double check the full code

Here is the complete minitunnel-client/src/main.rs file. Compare it against yours and fix any differences.

use anyhow::{bail, Context, Result};
use clap::Parser;
use minitunnel_protocol::{recv_message, send_message, transport, Message};
use tokio::net::TcpStream;
use tracing::{error, info};
use uuid::Uuid;

#[derive(Parser)]
#[command(name = "minitunnel-client")]
struct Args {
    /// Server address (hostname or IP)
    #[arg(short = 'a', long, default_value = "127.0.0.1")]
    server_addr: String,

    /// Server control port
    #[arg(short = 's', long, default_value_t = 7835)]
    server_port: u16,

    /// Local port to forward to
    #[arg(short, long)]
    local_port: u16,
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    let args = Args::parse();

    let server_endpoint = format!("{}:{}", args.server_addr, args.server_port);
    info!("Connecting to server at {server_endpoint}");

    let stream = TcpStream::connect(&server_endpoint)
        .await
        .context("failed to connect to server")?;
    let mut control = transport(stream);

    send_message(&mut control, &Message::Hello).await?;

    let msg = recv_message(&mut control).await?;
    let tunnel_port = match msg {
        Message::HelloResponse { port } => port,
        other => bail!("expected HelloResponse, got {other:?}"),
    };

    info!(
        "Tunnel established! Forwarding {}:{} -> 127.0.0.1:{}",
        args.server_addr, tunnel_port, args.local_port
    );

    loop {
        let msg = recv_message(&mut control).await?;
        match msg {
            Message::ConnectionRequest { id } => {
                let server_endpoint = server_endpoint.clone();
                let local_port = args.local_port;
                tokio::spawn(async move {
                    if let Err(e) = handle_proxy(id, &server_endpoint, local_port).await {
                        error!("Proxy {id} error: {e:#}");
                    }
                });
            }
            other => bail!("unexpected message: {other:?}"),
        }
    }
}

async fn handle_proxy(id: Uuid, server_endpoint: &str, local_port: u16) -> Result<()> {
    let stream = TcpStream::connect(server_endpoint)
        .await
        .context("failed to connect proxy to server")?;
    let mut framed = transport(stream);

    send_message(&mut framed, &Message::Accept { id }).await?;

    let mut proxy_stream = framed.into_inner();

    let mut local_stream = TcpStream::connect(("127.0.0.1", local_port))
        .await
        .context("failed to connect to local service")?;

    let (bytes_to_local, bytes_to_server) =
        tokio::io::copy_bidirectional(&mut local_stream, &mut proxy_stream).await?;

    info!("Proxy {id} closed. Bytes: {bytes_to_local} to local, {bytes_to_server} to server");
    Ok(())
}

Both halves of your tunnel are built. Next up, you will wire them together and watch real HTTP traffic flow through your tunnel end-to-end.

Test End-to-End on Localhost

You have a protocol library, a server, and a client. All three compile. But code that compiles is not the same as code that works.

In this step, you will spin up a local HTTP service, run both binaries, and push real traffic through the tunnel. By the end, you will have proof that your tunnel can relay HTTP responses from a local service through the server and back to a visitor.

In this step, get ready to:
  • Start a local HTTP server to act as the service behind the tunnel.
  • Launch the server and client binaries in separate terminals.
  • Send traffic through the tunnel and verify concurrent connections work.
Start a local HTTP server

A tunnel is only useful if there is something on the other side. You need a local service that the client can forward traffic to.

Python's built-in HTTP server is perfect for this. It serves the contents of the current directory over HTTP with zero setup.

  • In the terminal you have been using for this project, start a local HTTP server on port 8000 by running this command:
python3 -m http.server 8000

You should see Serving HTTP on 0.0.0.0 port 8000 in the output. This terminal stays running for the rest of the step.

Seeing an error?

If you see Address already in use, another process is using port 8000. Close it or pick a different port (e.g., python3 -m http.server 9000). Remember to use the same port when you start the client later.

If python3 is not found, try python instead. On Windows, the command may be py -m http.server 8000.

Still stuck? Help me start a local HTTP server for testing my tunnel.

Launch the server and client binaries

The tunnel system needs three long-running processes: the Python server, the MiniTunnel server, and the MiniTunnel client. Each one stays running in its own terminal.

  • Open a new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).
  • Navigate to your MiniTunnel workspace folder (the folder containing your root Cargo.toml).
  • Start the server binary by running this command:
cargo run -p minitunnel-server

You should see two lines in the output. First, Server listening on 0.0.0.0:7835 confirms the server is waiting for client connections on the control port.

What is the control port?

Port 7835 is the control port. This is where the client connects to register itself, and also where proxy connections arrive. The server distinguishes between the two by reading the first message: a Hello message means it is a control connection, and an Accept message means it is a proxy connection.

Seeing "Address already in use"?

Another process is already using port 7835. You can specify a different port with cargo run -p minitunnel-server -- --port 7836. Use the same port when starting the client.

Need a hand? Help me fix a port conflict when starting my minitunnel-server.

Now you need to start the client in a third terminal.

  • Open another new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).
  • Navigate to your MiniTunnel workspace folder again.
  • Start the client binary, telling it to forward traffic to your local Python server on port 8000, by running this command:
cargo run -p minitunnel-client -- --local-port 8000

The client output shows the tunnel is established. Look for a line like Tunnel established! Forwarding 127.0.0.1:54321 -> 127.0.0.1:8000.

What is the tunnel port?

The port number in the client output (e.g. 54321) is the dynamically allocated tunnel port. The server picked a random available port by binding to 0.0.0.0:0 and letting the OS choose. This is the port visitors will connect to.

Write down this port number. You will use it in the next substep.

At this point, switch back to your server terminal tab. You should see a new line: Allocated tunnel port 54321 (with your actual port number). This confirms the server assigned a public-facing port for this client.

Client shows "failed to connect to server"?

Make sure the server terminal is still running and showing Server listening on 0.0.0.0:7835. If the server crashed or you closed that terminal, restart it first.

If you changed the server port, pass the same port to the client with --server-port (e.g. cargo run -p minitunnel-client -- --server-port 7836 --local-port 8000).

Still stuck? Help me connect my minitunnel-client to the server.

Send traffic through the tunnel

The tunnel is established. Now you will prove it actually relays data. When you send a request to the tunnel port, it should travel through the server, through the client, hit the local Python HTTP server, and return the response all the way back.

  • Open one more terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).
  • Send a request through the tunnel by running this command, replacing the port with the tunnel port from the client output:
curl http://localhost:[[TUNNEL_PORT="your tunnel port from the client output"]]

You should see an HTML directory listing from the Python HTTP server. It contains links to the files in whatever directory you started the server in.

What just happened?

Your curl request connected to the tunnel port on the server. The server generated a UUID for that connection and sent a ConnectionRequest to the client over the control connection.

The client then opened a new TCP connection back to the server, sent an Accept with the UUID, and connected to your local Python server on port 8000. The server matched the UUID, and copy_bidirectional welded the two sockets together so bytes flowed in both directions.

Curl returns "Connection refused"?

Double check that you are using the tunnel port from the client output, not port 7835 or 8000. The tunnel port is the dynamically allocated one (e.g. 54321).

Make sure all three other terminals are still running: the Python server, the MiniTunnel server, and the MiniTunnel client. If any of them crashed, restart them in order (Python server first, then server, then client).

Need help? Help me debug why curl can't reach my tunnel.

A single request proves the tunnel works. Now you need to verify that multiple concurrent connections are handled correctly.

  • Fire off three concurrent requests through the tunnel by running this command:
curl http://localhost:[[TUNNEL_PORT="your tunnel port"]] & curl http://localhost:[[TUNNEL_PORT="your tunnel port"]] & curl http://localhost:[[TUNNEL_PORT="your tunnel port"]] & wait

What does this command do?

The & after each curl sends it to the background so all three run at the same time. The wait at the end pauses until all three finish. This simulates three visitors hitting your tunneled service simultaneously.

All three requests should return the same HTML directory listing. Each one gets its own response because the server assigns a unique UUID to every visitor connection.

  • Switch to your server terminal tab and look at the log output.

You should see three pairs of log lines. Each pair has a Visitor connected line followed by a Proxy connected for <uuid> line. Each pair shows a different UUID, proving that the DashMap-based connection matching worked correctly.

Why does UUID matching matter?

Without UUIDs, the server would have no way to pair a visitor connection with the correct proxy connection from the client. Imagine three visitors arriving at the same time. The server stores each visitor's TCP stream in the DashMap keyed by a unique UUID.

When the client opens a proxy connection and sends an Accept with that UUID, the server looks it up, removes it from the map, and bridges the two streams. Each visitor gets their own isolated byte relay.

Only one or two responses came back?

Check the client terminal for errors. If you see failed to connect to local service, the Python HTTP server may have crashed or been closed. Restart it and try again.

If the server log shows Connection <id> timed out, the proxy connection did not arrive within 10 seconds. This can happen if the client was slow to spawn the proxy task. Try the concurrent test again.

Something else going wrong? Help me debug concurrent connections through my tunnel.

Secret mission

Deploy to Your VPS and Add Authentication

Your tunnel works on localhost, but anyone who knows the port can connect. Deploy it to a real VPS and lock it down with HMAC-SHA256 challenge-response authentication.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Cargo workspace folder on your Desktop containing minitunnel-protocol, minitunnel-server, and minitunnel-client crates.
  • Compiled binaries in the target/ directory inside your workspace.
  • Running processes: the MiniTunnel server, MiniTunnel client, and Python HTTP server (if still running).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend MiniTunnel with the Secret Mission.

  • Your Cargo workspace and compiled binaries stay on your Desktop.
  • If you left the server, client, or Python HTTP server running, they continue using system resources until you stop them.

Pause - I'll come back to this later

Stop any running processes to free up memory and ports. Your project files stay on disk so you can pick up where you left off.

  • Switch to any terminal window running the MiniTunnel server, client, or Python HTTP server and press Ctrl+C to stop each one.
  • Your Cargo workspace folder remains on your Desktop. When you are ready to resume, open the folder and run cargo build to recompile.

Delete - I don't want to use this again

Remove all project files and stop all running processes to start completely fresh.

  • Switch to any terminal window running the MiniTunnel server, client, or Python HTTP server and press Ctrl+C to stop each one.
  • Delete the entire Cargo workspace folder from your Desktop. On macOS, drag it to the Trash. On Windows, right-click and select Delete.
  • The Rust toolchain itself stays installed. Only the project folder and its compiled artifacts are removed.

Mission accomplished

That's a Wrap!

You just built a fully functional ngrok-style TCP tunnel from scratch in Rust. MiniTunnel can expose a local service through a remote server using a custom wire protocol, dynamic port allocation, and bidirectional byte relaying.

You've learned how to:

  • Design a custom wire protocol using JSON over LengthDelimitedCodec for reliable message framing over TCP streams.
  • Build an async server with dynamic port allocation and UUID-based connection matching using DashMap for concurrent state.
  • Build a tunnel client that manages control and proxy connections, relaying bytes between remote visitors and a local service with copy_bidirectional.
  • Secret Mission: Deploy the tunnel to a VPS and add HMAC-SHA256 challenge-response authentication to secure both control and proxy connections.

Ready to quiz yourself?