https://github.com/AlexXi19/rust-tcp-proxy
Context
In my personal Datacenter, the nginx server communicates with my kubernetes cluster via http, which is unsafe, because the request body is unencrypted. That’s why I had this idea of building a tcp proxy to secure the communication between the two servers.
I can build a layer 7 proxy to forward all http requests and encrypt the content of the requests. That would be extremely functional and easy to build, but that won’t be much fun. So I chose to go with a tcp proxy to learn a bit more about layer 4 networking.
Based on my understanding, the implementation should be a simple rust program that listens to data from a port, uses some encryption method to secure the bytes during the transport, send the bytes over to the target server that decrypts the data.
The process
Building a simple tcp proxy was easy, and the code would look something like this. You bind a listener, then you connect to the target address. You create inbound & outbound connections, then you use io::copy
to copy the bytes from the client connection to the server connection.
Pretty simple, my dummy server was able to receive the request!
127.0.0.1 - - [25/Nov/2023 23:14:41] "GET / HTTP/1.1" 200 -
However, the implementation has an issue, the server can receive the request but the client, in this case the curl
command, hangs.
Challenge 1: Connection management
To solve this issue, we need to understand how http knows if a request is terminated. One way for a client to know that a request has ended is when the server to closes the connection. However, if you want to keep the tcp connection alive and reuse it, like in HTTP/1.1, http uses special headers to indicate that a connection should be kept alive. In our case, the target server ended the connection by closing it, but the connection isn’t closed by the proxy server, hence the client hangs. There are a few ways to deal with this:
- Keep the long running connections between the client server and the proxy and somehow make the proxy Layer 7 aware and implement correct http request termination behavior by reading the http content or sending the correct end request headers.
- Create a new connection for each request, and close the connection when the server has finished sending the request.
To keep the proxy strictly layer 4 and agnostic to any higher level protocols, I chose the second method.
For one, we need to make the request handling multithreaded to handle multiple requests at the same time. Here, we use tokio::spawn
to create a thread for handling the inbound request.
For two, we need to detect when the server has finished the response and close the connection correspondingly. In this case, when there are no more bytes to read, we break
from the loop and close the incoming stream. With some slight refactoring from the first section, the code looks like this, where the process_data
function is a cryptography encrypt or decrypt function depending on whether it’s a proxy server or client.
Now that we curl the client proxy port, we get:
➜ curl localhost:7998
⠀⠀⢀⣠⠤⠶⠖⠒⠒⠶⠦⠤⣄⠀⠀⠀⣀⡤⠤⠤⠤⠤⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⣴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⣦⠞⠁⠀⠀⠀⠀⠀⠀⠉⠳⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⡾⠁⠀⠀⠀⠀⠀⠀⣀⣀⣀⣀⣀⣀⣘⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢀⡴⠚⠉⠁⠀⠀⠀⠀⠈⠉⠙⠲⣄⣤⠤⠶⠒⠒⠲⠦⢤⣜⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⡄⠀⠀⠀⠀⠀⠀⠀⠉⠳⢄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⠹⣆⠀⠀⠀⠀⠀⠀⣀⣀⣀⣹⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣠⠞⣉⣡⠤⠴⠿⠗⠳⠶⣬⣙⠓⢦⡈⠙⢿⡀⠀⠀⢀⣼⣿⣿⣿⣿⣿⡿⣷⣤⡀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⣾⣡⠞⣁⣀⣀⣀⣠⣤⣤⣤⣄⣭⣷⣦⣽⣦⡀⢻⡄⠰⢟⣥⣾⣿⣏⣉⡙⠓⢦⣻⠃⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠉⠉⠙⠻⢤⣄⣼⣿⣽⣿⠟⠻⣿⠄⠀⠀⢻⡝⢿⡇⣠⣿⣿⣻⣿⠿⣿⡉⠓⠮⣿⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠙⢦⡈⠛⠿⣾⣿⣶⣾⡿⠀⠀⠀⢀⣳⣘⢻⣇⣿⣿⣽⣿⣶⣾⠃⣀⡴⣿⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠙⠲⠤⢄⣈⣉⣙⣓⣒⣒⣚⣉⣥⠟⠀⢯⣉⡉⠉⠉⠛⢉⣉⣡⡾⠁⠀⠀⠀⠀⠀⠀⠀
⠀⠀⣠⣤⡤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢈⡿⠋⠀⠀⠀⠀⠈⠻⣍⠉⠀⠺⠿⠋⠙⣦⠀⠀⠀⠀⠀⠀⠀
That’s only half of our response. Where did the other half go?
Challenge 2: Buffer size
After doing some debugging, I noticed that the vec![0; 1024]
allocated for the response bytes could be an issue. It did not have enough space to fit the incoming bytes. If the proxy receives a package that’s larger than the buffer, the decryption fails, and the proxy closes the connection from the error. (Note: the buffer does not correspond to tcp packets)
To verify this, we change the buffer size to vec![0; 1_000_000]
the curl command returns the entire response.
However, setting it to an arbitrarily large buffer size does not fix the underlying issue. The main problem is that the encoding chunk size does not match the buffer read
chunk size, hence the decryption function is run on the incorrectly sized chunk. We can solve this issue by reading the buffer in sizes that it was encoded in.
To do this, we create a custom write and custom read protocol to include the chunk size in the start of the buffer.
In the custom write protocol, we include the encrypted chunk size in u16
at the start of the buffer.
In the custom read protocol, we read the u16
denoting the chunk size and use read_exact
to read the buffer to be decrypted.
With this last piece solved, we can start up our tcp proxy client and server, set them up with the appropriate ports, and see that the proxy is working correctly!
Simple Benchmarking
References: https://datatracker.ietf.org/doc/html/rfc7230