I recently purchased some IoT device based on a pre-built ESP32 firmaware. I wanted to move the server part into the cloud, so I wanted to secure the HTTP calling request with TLS and some form of authentication.

Since the IoT device did not have any TLS or authentication in place, I decided on locally hosting a proxy which transforms HTTP into HTTPS and inject an authnetication in form of an API-KEY.

 ┌──────────┐┌─────┐       ┌───────┐
 │IoT Device││Proxy│       │Backend│
 └────┬─────┘└──┬──┘       └───┬───┘
      │         │              │    
      │HTTP call│              │    
      │────────>│              │    
      │         │              │    
      │         │HTTPS call    │    
      │         │(with API KEY)│    
      │         │─────────────>│    
      │         │              │    
      │         │   response   │    
      │         │<─────────────│    
      │         │              │    
      │response │              │    
      │<────────│              │    
 ┌────┴─────┐┌──┴──┐       ┌───┴───┐
 │IoT Device││Proxy│       │Backend│
 └──────────┘└─────┘       └───────┘

So I needed a Proxy, and starting looking around, since I could not be the first one to have this problem. I found various project, but all of them were a poor fit, either I could not get them to work or those implementation are full blown proxy servers. Since I did not want to maintain this workaround, I aborted my search and started to think, ‘how hard can it be doing this in Rust?’

How hard can a Rust implementation be?

Sticking to what I know, I took axum as service implementation and used reqwest for the http calling. Both framework are low maintenance and should have no issue handling the requests.

Cargo.toml

[package]
name = "proxy"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7" }
reqwest = { version = "0.12", default-features = false, features = [
    "http2",
    "rustls-tls",
] }

main.rs

use axum::{body::Bytes, http::HeaderMap, routing::get, Router};
use reqwest::StatusCode;
use std::time::Instant;

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(handler));
    // bind to port 8080 for request forwarding
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

async fn handler(headers: HeaderMap) -> Result<Bytes, StatusCode> {
    println!("receiving req");
    let now = Instant::now();
    let client = reqwest::Client::new();
    // construct a client with the API key infected into the header
    let mut builder = client
        .get("https://backend.run.app/")
        .header("X-API-KEY", "test123");
    // copy over existing headers
    for (key, val) in headers {
        let k = key.unwrap();
        if k == "host" {
            continue;
        }
        builder = builder.header(k, val.to_str().unwrap())
    }
    let resp = builder.send().await.unwrap();
    println!("elapsed: {:?}, resp: {:?}", now.elapsed(), resp.headers());
    // return the response after fully consuming the stream
    let x = resp.bytes().await.unwrap();
    Ok(x)
}

Conclusion

So in total it took me about 35 lines of code to implement a very basic HTTP proxy that could serve my purpose. Also with rust I can build this binary and have a no-dependency deployable. So no runtime, no package dependencies, just a single binary. This makes deployment much easier.