Implementing a VPN Server in Rust

Interested in understanding the inner workings of a VPN? Thinking about setting up your own VPN server? Today, we’re taking a…

Implementing a VPN Server in Rust

Interested in understanding the inner workings of a VPN? Thinking about setting up your own VPN server? Today, we’re taking a straightforward look at how to set up a basic VPN server using Rust.

In this article, we’ll break down the core concepts and guide you through the process with some clear-cut Rust code. By the end, you’ll have a foundational understanding and a basic VPN server to show for it.

Let’s get started!

How a VPN Works

A VPN is essentially a private tunnel between your computer (or another device) and a remote server, usually operated by a VPN service. Here’s how it all breaks down:

  1. Initiation: When you launch a VPN client on your device, it reaches out to a VPN server to establish a secure connection.
  2. Authentication: The client and server go through a handshake process. They exchange credentials, ensure they’re talking to the right entities, and set up encryption protocols for the session.
  3. Tunneling: Once authenticated, a secure tunnel forms between the client and server. This tunnel ensures that data passing through remains confidential and intact.
  4. Data Transfer:
  • Encryption: Before data leaves your device, the VPN client encrypts it. This turns readable data into scrambled code.
  • Transit: The encrypted data travels through the internet, passing through various routers and servers. But to any prying eyes, the data looks like gibberish.
  • Decryption: Once the data reaches the VPN server, it’s decrypted, turning it back into a readable format.

5. Exiting to the Internet: The VPN server then sends the data out to the internet to reach its destination (like a website). Importantly, to the outside world (like the website or your ISP), it appears as though the data is coming from the VPN server, not your device. This masks your real IP address.

6. Receiving Data: When data is sent back from the internet, the process reverses. The VPN server receives the data, encrypts it, sends it through the tunnel to your device, where it’s then decrypted for you to see.

7. Disconnection: Once you’re done, the VPN client will close the connection with the server, shutting down the secure tunnel.

The Crates we will use

  • clap (v2.33): A popular crate that aids in creating command-line interfaces for Rust applications. It assists with parsing arguments, displaying help messages, and managing configurations.
  • aes-gcm (v0.10.3) and aes-soft (v0.6): These crates relate to encryption. aes-gcm provides authenticated encryption using the AES-GCM mode, offering both confidentiality and data integrity. aes-soft is a software-based (as opposed to hardware-accelerated) implementation of the AES algorithm.
  • tokio (v1): A runtime for writing asynchronous applications with Rust. The full and signal features suggest a full suite of asynchronous features, including support for handling signals (like shutting down gracefully).
  • tun (v0.6.1) and tun-tap (v0.1.4): Both these crates pertain to virtual networking. The tun crate provides a platform-agnostic interface for creating and managing TUN devices, whereas tun-tap is a library to manage TUN and TAP devices, allowing for network-layer (TUN) and link-layer (TAP) traffic respectively.
  • serde (v1.0) and serde_derive (v1.0.190): Serialization and deserialization are at the heart of these crates. serde is a generic serialization/deserialization framework, while serde_derive enables the use of procedural macros to automatically generate the necessary code for serialization and deserialization.
  • bincode (v1.3): A binary serialization and deserialization strategy. When combined with Serde, it allows you to encode and decode Rust data structures in a compact binary format.
  • rand (v0.8): This crate provides a suite of randomization utilities, whether you’re generating random numbers, picking random elements, or shuffling data.
  • anyhow (v1.0): A flexible and easy-to-use crate for error handling, anyhow allows for creating and managing custom errors while also ensuring nice error reporting.
  • ctrlc (v3.1): Handling interruptions is essential, especially for long-running tasks. The ctrlc crate offers functionality to gracefully handle the Ctrl-C signal, allowing for clean shutdowns or specific actions upon interruption.
  • aes (v0.7): A crate dedicated to the AES (Advanced Encryption Standard) symmetric encryption algorithm. It serves as the foundation for various encryption-related tasks.
  • env_logger (v0.9): Logging is a critical component of many applications. The env_logger crate is a logger which is configured via an environment variable, allowing dynamic control over log output.

Project Setup

Add the following dependencies to your Cargo.toml:

[dependencies] 
clap = "2.33" 
aes-gcm = "0.10.3" 
aes-soft = "0.6" 
tokio = { version = "1", features = ["full", "signal"] } 
tun = "0.6.1" 
tun-tap = "0.1.4" 
serde = "1.0" 
serde_derive = "1.0.190" 
bincode = "1.3" 
rand = "0.8" 
anyhow = "1.0" 
ctrlc = "3.1" 
aes = "0.7" 
block-modes = "0.8" 
block-padding = "0.2" 
generic-array = "0.14" 
socket2 = "0.4" 
env_logger = "0.9" 
log = "0.4.20"

The Server Logic

This function server_mode represents the primary logic for a server that listens for incoming TCP connections, manages a TUN virtual network interface, and shuffles data between the TUN interface and its clients. Let's break down the code to understand each part:

Initialization:

let listener = TcpListener::bind("0.0.0.0:12345").unwrap();

This sets up a TCP listener that listens on all available interfaces (0.0.0.0) on port 12345.

let clients: Arc<Mutex<HashMap<usize, TcpStream>>> = Arc::new(Mutex::new(HashMap::new()));

Here, an atomic reference-counted (Arc) HashMap is initialized to keep track of connected clients. This HashMap maps client IDs (of type usize) to their respective TcpStream. The use of Arc and Mutex ensures that this map can be safely accessed and modified from multiple threads.

Setting up the TUN interface:

let mut config = tun::Configuration::default(); 
config.name("tun0"); 
let tun_device = tun::create(&config).unwrap();

This initializes the configuration for a TUN device named “tun0” and creates it.

if let Err(e) = setup_tun_interface() { 
    eprintln!("Failed to set up TUN interface: {}", e); 
    return; 
}

Here, the TUN interface “tun0” is being set up using the setup_tun_interface function. If there's an error, the function will print an error message and return early.


Download Now!


Share the TUN device among threads:

let shared_tun = Arc::new(Mutex::new(tun_device));

This wraps the TUN device in an Arc and Mutex so it can be shared and safely accessed among multiple threads.

Start server and handle data flow:

info!("Server started on 0.0.0.0:12345");

This logs the message indicating that the server has started.

The block below spawns a new thread. Within this thread, the server attempts to fetch the client with the key 0 from the clients map and read data from the TUN device, sending it to this client.

let tun_device_clone = shared_tun.clone(); 
let clients_clone = clients.clone(); 
 
thread::spawn(move || { 
    ... 
});

Listen for incoming connections:

for (client_id, stream) in listener.incoming().enumerate() { 
    ... 
}

The server continuously listens for incoming client connections. For each client, it assigns an ID (client_id), which is just an enumeration of the incoming connection.

Inside the loop:

  • The server spawns a new thread for each client to handle data exchange between the TUN device and the client.
  • It adds the client’s TcpStream to the clients map.
  • Another thread is spawned to handle the client’s overall functionality, presumably reading data from the client and writing it to the TUN device.

Error Handling: Throughout the function, there are several error checks using either pattern matching or if let. These error checks handle cases like:

  • Failure to set up the TUN interface.
  • Failure to clone a client’s TcpStream.
  • A client with a specific ID not found in the clients map.
  • Connection failures.

Cleanup:

let _ = destroy_tun_interface();

Before the function ends, the server attempts to destroy or clean up the “tun0” interface using the destroy_tun_interface function.

To summarize, the server_mode function sets up a server that listens for incoming TCP client connections and manages data exchange between these clients and a TUN virtual network interface. The server uses multithreading to handle multiple clients and TUN device operations concurrently.

The VPN Client Logic

Here, we’re looking at the client-side logic for connecting to a VPN server and managing data exchange between the client and a TUN virtual network interface. Let’s break down and explain each part:

Function: client_mode:

This asynchronous function represents the primary logic for the VPN client.

  • Connect to the VPN Server:
let mut stream = TcpStream::connect(vpn_server_ip).unwrap();

Here, the client establishes a TCP connection to the VPN server using its IP address.

  • Clone the Stream:
let mut stream_clone = stream.try_clone().unwrap();

The TCP stream is cloned so that it can be used in multiple places, particularly in the subsequent loops where data will be read from the server and written to the TUN device.

  • Initialize the TUN Interface:
let mut config = tun::Configuration::default(); 
config.name(TUN_INTERFACE_NAME); 
let mut tun_device = tun::create(&config).unwrap();

The client sets up its TUN interface using a predefined interface name (TUN_INTERFACE_NAME).

  • Setup the Client’s IP Address and Routing:
set_client_ip_and_route();

This function call presumably sets the IP address for the client’s TUN interface and configures the necessary routes.

  • Data Exchange Loop:
  • let mut buffer = [0; 1024]; loop { ... }
  • Inside this loop, the client continuously reads data from the VPN server and writes it to the TUN interface. If there’s any error while reading from the server, the loop terminates.
  • Function: read_from_client_and_write_to_tun:
  • This asynchronous function handles the data exchange between the client’s TCP stream (from the VPN server) and the TUN interface.
  • Data Reading Loop:
let mut buffer = [0u8; 1500]; 
loop { 
    ... 
}

Within this loop, the client:

Reads from the VPN server:

match client.read(&mut buffer) { 
    ... 
}

The client attempts to read data packets from the VPN server into a buffer.

Deserialize and Decrypt the Packet:

let vpn_packet: VpnPacket = bincode::deserialize(&buffer[..n]).unwrap(); 
let decrypted_data = decrypt(&vpn_packet.data);

The received data is then deserialized into a VpnPacket structure. This structure presumably encapsulates the VPN data packet, which is then decrypted to retrieve the original data.

Write to TUN Interface:

tun.write(&decrypted_data).unwrap();

The decrypted data is then written to the TUN interface.

  • Error Handling: If there’s an error while reading from the VPN server, an error message is logged, and the loop continues to the next iteration.

Networking and Tunneling

Let’s now see the two essential functions that set up and configure the VPN interfaces on both the client and server ends, ensuring proper communication and routing through the VPN tunnel.

set_client_ip_and_route()

This function sets up the client’s end of a VPN connection.

Assigning an IP Address to the TUN Interface:

let ip_output = Command::new("ip") 
    .arg("addr") 
    .arg("add") 
    .arg("10.8.0.2/24") 
    .arg("dev") 
    .arg("tun0") 
    .output() 
    .expect("Failed to execute IP command");

This uses the ip command to assign the IP address 10.8.0.2 (with a subnet mask of 255.255.255.0 or /24) to the tun0 interface. If the command fails, an error message is displayed.

Checking the IP assignment status:

if !ip_output.status.success() { 
    eprintln!("Failed to set IP: {}", String::from_utf8_lossy(&ip_output.stderr)); 
    return; 
}

If the IP address assignment was unsuccessful, an error is printed with the command’s output.

Activating the TUN Interface:

let link_output = Command::new("ip") 
    .arg("link") 
    .arg("set") 
    .arg("up") 
    .arg("dev") 
    .arg("tun0") 
    .output() 
    .expect("Failed to execute IP LINK command");

This command activates the tun0 interface.

Checking the activation status:

if !link_output.status.success() { 
    eprintln!("Failed to set link up: {}", String::from_utf8_lossy(&link_output.stderr)); 
    return; 
}

If the activation was unsuccessful, an error message is displayed.

Setting the Default Route:

let route_output = Command::new("ip") 
    .arg("route") 
    .arg("add") 
    .arg("0.0.0.0/0") 
    .arg("via") 
    .arg("10.8.0.1") 
    .arg("dev") 
    .arg("tun0") 
    .output() 
    .expect("Failed to execute IP ROUTE command");

This sets the default route for all traffic to go via 10.8.0.1 (likely the server's end of the VPN) using the tun0 interface.

Checking the routing status:

if !route_output.status.success() { 
    eprintln!("Failed to set route: {}", String::from_utf8_lossy(&route_output.stderr)); 
}

If setting the route was unsuccessful, an error is displayed.

setup_tun_interface()

This function sets up the server’s end of a VPN connection.

Activating the TUN Interface:

let output = Command::new("sudo") 
    .arg("ip") 
    .arg("link") 
    .arg("set") 
    .arg("dev") 
    .arg("tun0") 
    .arg("up") 
    .output()?;

This command, with elevated permissions via sudo, activates the tun0 interface on the server side.

Checking activation status:

if !output.status.success() { 
    return Err(format!("Failed to bring up tun0: {:?}", output.stderr).into()); 
}

If the activation was unsuccessful, the function returns an error with the command’s output.

Assigning an IP Address to the TUN Interface:

let output = Command::new("sudo") 
    .arg("ip") 
    .arg("addr") 
    .arg("add") 
    .arg("10.8.0.1/24") 
    .arg("dev") 
    .arg("tun0") 
    .output()?;

This command assigns the IP address 10.8.0.1 (with the same subnet mask /24) to the tun0 interface on the server side.

Checking the IP assignment status:

if !output.status.success() { 
    return Err(format!("Failed to assign IP to tun0: {:?}", output.stderr).into()); 
}

If the assignment was unsuccessful, the function returns an error.

Returning success:

Ok(())

If all the commands were successful, the function returns a success status.

Encryption and Decryption

The encrypt and decrypt functions utilize AES-GCM (Galois/Counter Mode) for encryption and decryption, respectively. AES-GCM is an authenticated encryption with associated data (AEAD) cipher, providing both data confidentiality and data integrity. Let's dive deeper into how these functions work and the mechanisms they employ:

Key Concepts:

  1. AES: The Advanced Encryption Standard (AES) is a symmetric encryption algorithm established by the U.S. National Institute of Standards and Technology (NIST). In this context, “symmetric” means the same key is used for both encryption and decryption.
  2. GCM: Galois/Counter Mode (GCM) is a mode of operation for symmetric block ciphers. It provides both encryption and authentication, ensuring both data confidentiality and data integrity.
  3. Nonce: A nonce, which stands for “number used once,” is an arbitrary value that should only be used once. It is used in the encryption process to provide an additional layer of security and ensure that repeating patterns in the plaintext don’t produce repeating patterns in the ciphertext.

encrypt Function:

Using a fixed hard coded KEY for testing purposes only:

const KEY: [u8; 32] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];

This key size corresponds to the AES-256 encryption standard, which is one of the most secure symmetric encryption standards used today. The KEY constant is a hardcoded encryption key used for the encryption and decryption processes within the application.

Key & Nonce Initialization:

let key = GenericArray::from_slice(&KEY); 
let nonce = GenericArray::from_slice(&NONCE);

The encryption key and nonce are both converted into generic arrays using the from_slice method. It's critical in practice to use unique nonces for each encryption operation to maintain security.

Cipher Initialization:

let cipher = Aes256Gcm::new(key);

An instance of the Aes256Gcm cipher is created using the encryption key. The "256" in its name implies that it uses a 256-bit encryption key.

Encryption:

match cipher.encrypt(nonce, data.as_ref()) { 
    Ok(ciphertext) => Ok(ciphertext), 
    Err(_) => Err("Encryption failure!".to_string()) 
}

The data is encrypted using the cipher, key, and nonce. If successful, the function returns the encrypted data (ciphertext). Otherwise, it returns an error indicating encryption failure.

decrypt Function:

  1. Key & Nonce Initialization: The key and nonce are again initialized in the same manner as the encrypt function.
  2. Cipher Initialization: Just like in the encrypt function, an instance of the Aes256Gcm cipher is created.
  3. Decryption:
let decrypted_data = cipher.decrypt(nonce, encrypted_data.as_ref()).expect("decryption failure!");

The encrypted data is decrypted using the cipher, key, and nonce. If the decryption is successful, the original plaintext data is returned. If an error occurs (e.g., if the data was tampered with or the nonce/key combination is incorrect), the code will panic with the message “decryption failure!”

Putting it All Together before testing

The main function is the entry point of a simple VPN application built in Rust. It begins by initializing a logger, which will be used to record application events.

The application uses the clap crate to define and parse command-line arguments, which determine whether the program runs in "server" mode or "client" mode. If in "server" mode, it starts the server-side logic of the VPN. If in "client" mode, it requires an additional argument for the VPN server's IP address and starts the client-side logic, attempting to connect to the given VPN server:

#[tokio::main] 
async fn  main() { 
 
    // Initialize the logger with 'info' as the default level 
    Builder::new() 
        .filter(None, LevelFilter::Info) 
        .init(); 
 
    let matches = App::new("Simple VPN") 
        .version("1.0") 
        .author("Luis Soares") 
        .about("A simple VPN tunnel in Rust") 
        .arg(Arg::with_name("mode") 
            .required(true) 
            .index(1) 
            .possible_values(&["server", "client"]) 
            .help("Runs the program in either server or client mode")) 
        .arg(Arg::with_name("vpn-server") 
            .long("vpn-server") 
            .value_name("IP") 
            .help("The IP address of the VPN server to connect to (client mode only)") 
            .takes_value(true)) 
        .get_matches(); 
 
    let is_server_mode = matches.value_of("mode").unwrap() == "server"; 
 
    if is_server_mode { 
        server_mode(); 
    } else { 
        if let Some(vpn_server_ip) = matches.value_of("vpn-server") { 
            let server_address = format!("{}:12345", vpn_server_ip); 
            client_mode(server_address.as_str()).await; 
        } else { 
            eprintln!("Error: For client mode, you must provide the '--vpn-server' argument."); 
        } 
    } 
}

How to Run and Test the VPN

  • Compile and Run: Assuming you have Rust and Cargo installed, navigate to the directory where the code resides and run cargo build to compile the application. Once compiled, the VPN application can be run using cargo run followed by the desired arguments.
  • Server Mode: To start the application in server mode, use:
  • cargo run server
  • This will initiate the VPN in server mode, listening for incoming client connections.
  • Client Mode: To start the application in client mode, use:
  • cargo run client --vpn-server <VPN_SERVER_IP>
  • Replace <VPN_SERVER_IP> with the IP address of the VPN server you want to connect to. The application will attempt to connect to the specified VPN server on port 12345.

Testing:

  • Start two instances of the application: one in server mode and one in client mode (on the same machine or different machines).
  • Once the server is running, initiate the client by providing the server’s IP address.
  • Observe logs to ensure that the client successfully connects to the server.
  • For a comprehensive test, you’d want to send data across the VPN tunnel and ensure it’s transmitted securely and reliably. This might involve setting up tools to inspect network traffic, ensure encryption, measure performance, and more.

Notes:

  • Ensure that the port 12345 is open and accessible if running across different machines or networks.
  • This is a simple VPN implementation, so testing should consider various scenarios like reconnections, large data transfers, and edge cases.
  • If you encounter any issues, the logger should provide insights, given it was initialized to capture info level messages by default.

And there you have it! You’ve built a functional VPN solution. With a bit more polish and additional features, you’ll be well on your way to having a production-ready tool.

You can find the complete implementation over at my GitHub repository: https://github.com/luishsr/rust-vpn/

Your feedback, suggestions, or contributions are always welcome.


Download Now!


Check out some interesting hands-on Rust articles!

🌟 Developing a Fully Functional API Gateway in Rust — Discover how to set up a robust and scalable gateway that stands as the frontline for your microservices.

🌟 Implementing a Network Traffic Analyzer — Ever wondered about the data packets zooming through your network? Unravel their mysteries with this deep dive into network analysis.

🌟 Building an Application Container in Rust — Join us in creating a lightweight, performant, and secure container from scratch! Docker’s got nothing on this. 😉

🌟 Crafting a Secure Server-to-Server Handshake with Rust & OpenSSL — 
If you’ve been itching to give your servers a unique secret handshake that only they understand, you’ve come to the right place. Today, we’re venturing into the world of secure server-to-server handshakes, using the powerful combo of Rust and OpenSSL.

🌟 Building a Function-as-a-Service (FaaS) in Rust: If you’ve been exploring cloud computing, you’ve likely come across FaaS platforms like AWS Lambda or Google Cloud Functions. In this article, we’ll be creating our own simple FaaS platform using Rust.

🌟 Implementing a P2P Database in Rust: Today, we’re going to roll up our sleeves and get our hands dirty building a Peer-to-Peer (P2P) key-value database.


Read more articles about Rust in my Rust Programming Library!


Visit my Blog for more articles, news, and software engineering stuff!

Follow me on Medium, LinkedIn, and Twitter.

Leave a comment, and drop me a message!

All the best,

Luis Soares

CTO | Tech Lead | Senior Software Engineer | Cloud Solutions Architect | Rust 🦀 | Golang | Java | ML AI & Statistics | Web3 & Blockchain

Read more