Why a Stealth Proxy
In the previous post I described how to build an HTTPS forward proxy with Caddy plus acme.sh. That setup is practical enough, but it still depends on several pieces glued together: Caddy itself, a special fork of the forwardproxy plugin, and acme.sh renewing certificates from cron. If any one of them breaks, the proxy goes down.
https_proxy is a single-binary HTTPS forward proxy written in Rust. Certificate issuance, TLS termination, proxy forwarding and stealth disguise all live in one executable of under 7 MB. The idea behind it is simple:
- From the outside it looks like an ordinary nginx server
- Only clients holding the right credentials can use the proxy
- Certificates are issued and renewed automatically by Let's Encrypt, with no extra tooling
Architecture and How It Works
The whole request path looks like this:
1. TLS Termination and Automatic Certificates
https_proxy uses the tokio-rustls-acme crate to do ACME TLS-ALPN-01 validation. Unlike the more common HTTP-01 challenge, TLS-ALPN-01 only needs port 443, so you never have to open port 80. Issuance and renewal are fully automatic, driven by a background async task:
let acme_config = AcmeConfig::new([domain])
.contact_push(format!("mailto:{}", config.acme.email))
.cache(DirCache::new(cache_dir))
.directory_lets_encrypt(!config.acme.staging);
The TLS config supports both TLS 1.2 and TLS 1.3, and ALPN negotiation offers h2 and http/1.1, so browsers and command line tools can both connect normally.
2. The Stealth Layer: Pretending to Be nginx
This is the most interesting part of the design. The stealth check is quite precise:
- If the request method is
CONNECT→ this is a proxy request - If it is HTTP/2 but not
CONNECT→ it is definitely not a proxy request (the HTTP/2:authoritypseudo-header is always present, so it cannot be used to tell them apart) - If it is HTTP/1.x and the URI contains an authority (the absolute-URI form) → this is a proxy request
The flowchart below shows the full stealth check and authentication decision:
Every request that fails those conditions gets a 404 page byte-identical to nginx's:
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.24.0</center>
</body>
</html>
The Server response header is also set to whatever the config file says (nginx/1.24.0 by default). Whether a scanner probes over HTTP/1.1 or HTTP/2, what it sees is an ordinary nginx. A 404 says "no site configured here".
3. The Auth Layer
Once a request passes the stealth check, it goes through authentication. https_proxy uses standard HTTP Basic Auth and supports multiple username/password pairs. On failure it returns 407 Proxy Authentication Required with a Proxy-Authenticate header, so Chrome and friends pop up a credential prompt instead of showing some baffling error page.
4. Proxy Forwarding
After authentication there are two paths, depending on the request type:
CONNECT tunnel: the client sends CONNECT host:port, the proxy replies 200 and upgrades the connection, then uses tokio::io::copy_bidirectional to shuttle bytes between the client and the target server. The tunnel buffer is set to 128 KiB (16 times the default) to match TLS record sizes and cut down on syscalls.
HTTP forwarding: for plaintext HTTP requests, the proxy strips hop-by-hop headers such as Proxy-Authorization and Proxy-Connection, then forwards the request upstream. With TCP Fast Open off it reuses connections from a pool; with TFO on it connects manually so that TFO can be used.
5. HTTP/2 Support
https_proxy implements HTTP/2 fully, including the extended CONNECT protocol from RFC 8441. That means modern browsers like Chrome can use the proxy over HTTP/2 without falling back to HTTP/1.1.
Deployment Guide
Prerequisites
- A server with a public IP
- A domain whose A record points at that IP
- Port 443 free on the server
Building
https_proxy is written in Rust and needs Rust 1.70+ and a C compiler.
Building directly on a Linux server:
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install build dependencies (Debian/Ubuntu)
apt install build-essential cmake
# Clone and build
git clone https://github.com/madeye/https_proxy.git
cd https_proxy
cargo build --release
Cross-compiling a Linux build on macOS:
docker run --platform linux/amd64 --rm -v "$(pwd)":/src -w /src \
rust:latest cargo build --release --target x86_64-unknown-linux-gnu
The release build turns on LTO and strip, so the final binary is around 7 MB.
Configuration
You can generate a config with the built-in TUI wizard:
./target/release/https_proxy setup
Or edit config.yaml by hand:
listen: "0.0.0.0:443"
domain: "proxy.example.com"
acme:
email: "[email protected]"
staging: false
cache_dir: "/var/lib/https_proxy/acme"
users:
- username: "alice"
password: "hunter2"
- username: "bob"
password: "correct-horse-battery-staple"
stealth:
server_name: "nginx/1.24.0"
fast_open: true
What the fields mean:
| Field | Description |
|---|---|
listen | Listen address, 0.0.0.0:443 by default |
domain | The domain the ACME certificate is issued for |
acme.email | Contact email for Let's Encrypt |
acme.staging | Whether to use the staging environment (worth turning on while testing, to avoid hitting rate limits) |
acme.cache_dir | Certificate cache directory |
users | List of authorized users, multiple credentials supported |
stealth.server_name | The Server response header to impersonate |
fast_open | Enable TCP Fast Open to cut connection setup latency |
Running It
# Run it directly
./target/release/https_proxy run --config config.yaml
# Or use the default config.yaml
./target/release/https_proxy
Installing as a System Service
On Linux you can install it as a systemd service in one command:
sudo ./target/release/https_proxy install
To uninstall:
sudo ./target/release/https_proxy uninstall
Using It from a Client
Command Line
# Reach a site through the proxy
curl --proxy https://alice:[email protected]:443 https://httpbin.org/ip
# Set the environment variables so every command uses the proxy
export https_proxy=https://alice:[email protected]:443
export http_proxy=https://alice:[email protected]:443
curl https://www.google.com
Browsers
Chrome, Firefox and others can point at an HTTPS proxy through the system proxy settings, or through an extension like SwitchyOmega. Pick HTTPS as the protocol and fill in the domain, port (443), username and password. The browser will prompt for credentials on the first request.
Checking the Disguise
Visit the proxy domain directly and you should get the nginx-style 404 page:
curl https://proxy.example.com/
# Returns: 404 Not Found (Server: nginx/1.24.0)
Compared with the Caddy Setup
| https_proxy | Caddy + forwardproxy | |
|---|---|---|
| Number of components | One binary | Caddy + an xcaddy build + acme.sh |
| Certificate management | Built-in ACME (TLS-ALPN-01) | Depends on external acme.sh |
| HTTP/2 proxying | Yes (RFC 8441 extended CONNECT) | Yes |
| Stealth disguise | Built-in nginx 404 decoy | Via probe_resistance + file_server |
| TCP Fast Open | Built in | Not supported |
| Config complexity | A single YAML file | Caddyfile + acme.sh config + systemd |
| Binary size | ~7 MB | ~40 MB (Caddy) |
Each has its strengths. The Caddy setup has the more mature ecosystem and can host websites and other reverse proxies at the same time; https_proxy wins on how little there is to deploy. One binary, one config file, ready to go.
Wrap-up
The idea behind https_proxy is to keep everything in one place:
- Single-binary deployment: no external tools, usable as soon as it compiles
- Automatic certificates: Let's Encrypt certificates issued and renewed on their own, no cron job
- Precise stealth: correctly identifies and disguises for both HTTP/1.1 and HTTP/2, so simple scanners find nothing
- Performance work: TCP Fast Open, a 128 KiB tunnel buffer, connection pooling
- Real authentication: multi-user Basic Auth, with native browser credential prompts
