A fast, modern reverse tunnel that exposes your local services to the internet. Think ngrok or Cloudflare Tunnel, but built from scratch in Rust with a focus on performance and reliability.
- Expose local development servers to the internet for testing webhooks, sharing demos, or mobile testing
- Access services behind NAT/firewalls without port forwarding
- Self-hosted — run your own tunnel infrastructure with full control
- Optimized for multiplexed traffic — handles many concurrent connections efficiently
- QUIC-first with HTTP/2 fallback — Uses QUIC (UDP) by default for best performance, automatically falls back to HTTP/2 (TCP) when UDP is blocked
- Head-of-line blocking mitigation — Independent QUIC streams mean packet loss on one connection doesn't stall others
- Connection pooling — Multiple parallel connections in HTTP/2 mode for better throughput
- TLS everywhere — End-to-end encryption with publicly trusted Let's Encrypt certificates (ACME) on both server and per-client endpoints
- Client identity via certificates — Each client gets a unique subdomain based on its keypair fingerprint
- Dual endpoints per client — Optional second connection using a separate keypair and a self-signed certificate, alongside the primary ACME-backed endpoint
- Multi-server clients — A single client can connect to several relay servers in parallel
| Crate | Kind | Purpose |
|---|---|---|
tunnel-common |
lib | Shared protocol/cert/utility code |
tunnel-server |
lib | Relay server runtime (QUIC + H2 listeners, public router, ACME) |
tunnel-client |
lib | Reusable client runtime (multi-server, ACME, dual-endpoint) |
tunnel-client-ffi |
cdylib + uniffi | Android/iOS FFI surface; produces libtunnel_client_ffi.so |
relay |
bin (server) |
Standalone relay server binary |
client |
bin (client) |
Standalone CLI client binary |
┌─────────────────────────────────────────────────────────────────────────────┐
│ INTERNET │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ TUNNEL SERVER (relay) │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────────┐ │
│ │ API Port │ │ Public Port │ │ Agent Registry │ │
│ │ (QUIC + TCP) │ │ (TCP/TLS) │ │ client_id → [connections] │ │
│ │ :4433 │ │ :8443 │ └────────────────────────────┘ │
│ └───────┬────────┘ └───────┬────────┘ │
│ │ │ ┌────────────────────────────┐ │
│ │ │ │ ALPN Port (TLS-ALPN-01)│ │
│ │ │ │ :443 │ │
│ │ │ │ Server's own LE cert │ │
│ │ │ │ (auto-renewed) │ │
│ │ │ └────────────────────────────┘ │
│ │ Agents connect │ Users connect via │
│ │ and register │ {client_id}.<suffix>:8443 │
└──────────┼─────────────────────┼────────────────────────────────────────────┘
│ │
QUIC streams SNI routing
or H2 streams to correct agent
│ │
▼ │
┌─────────────────────┐ │
│ TUNNEL CLIENT │◄─────────┘
│ ┌───────────────┐ │ Tunnel stream opened
│ │ TLS Acceptor │ │ for each user request
│ │ (ACME LE cert)│ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Local Service │ │
│ │ :3000 │ │
│ └───────────────┘ │
└─────────────────────┘
Client Server
│ │
│──── QUIC/H2 + Client Cert ────▶│
│ │
│ Extract cert fingerprint
│ Register as agent
│ │
│◀─── Connection Established ────│
│ │
▼ ▼
Ready to accept tunnels client_id registered
User Server Client Local
│ │ │ │
│── TLS ClientHello ─────▶│ │ │
│ (SNI: abc123.<suffix>) │ │
│ │ │ │
│ Extract SNI, lookup agent │ │
│ │ │ │
│ │── Open QUIC/H2 stream ─▶│ │
│ │ │ │
│◀────── Bidirectional tunnel (terminated by client's LE cert) ─────────▶│
│ │ │ │
Two independent ACME flows, used at three different listeners:
- Server cert — presented to agents on the API port (
:4433, QUIC + H2). Also briefly presented on:443during the server's own TLS-ALPN-01 challenge while it provisions/renews its cert. Provisioned via TLS-ALPN-01 when--acme-domainis set (:443must be reachable from the public internet); otherwise loaded from a--tls-certPEM, or self-signed if neither is provided. - Per-client cert — each tunnel client provisions its own publicly trusted LE cert for
{client_id}.<domain-suffix>via TLS-ALPN-01 with the challenge proxied through the relay's:443listener. The client terminates user TLS itself using this cert.
The public port (:8443) does not terminate TLS at the relay — it peeks the ClientHello, routes by SNI, and forwards raw TCP bytes through the tunnel; TLS is terminated at the client.
Optional secondary endpoint per client (--secondary-key) opens a second connection that terminates user TLS with a self-signed cert (no ACME).
Traditional TCP-based tunnels suffer from head-of-line (HOL) blocking: if a packet is lost, all subsequent packets must wait for retransmission, even if they belong to different logical connections.
User A ─────┐ ┌───── Tunnel to A
User B ─────┼── Single TCP ─────┼───── Tunnel to B (blocked by A's lost packet!)
User C ─────┘ connection └───── Tunnel to C (blocked by A's lost packet!)
QUIC multiplexes streams over UDP with independent loss recovery per stream:
User A ─────── QUIC Stream 1 ─────── Tunnel to A (packet loss only affects A)
User B ─────── QUIC Stream 2 ─────── Tunnel to B (unaffected)
User C ─────── QUIC Stream 3 ─────── Tunnel to C (unaffected)
Each user's connection is an independent QUIC stream. Packet loss or congestion on one stream doesn't block others — they continue flowing independently.
Standard HTTP/2 over a single TCP connection still suffers from TCP-level HOL blocking. Our implementation mitigates this with a connection pool:
┌─── TCP Conn 1 ───── H2 Streams ─────▶ Users A, E, I...
│
Client ─────────────┼─── TCP Conn 2 ───── H2 Streams ─────▶ Users B, F, J...
(--pool-size 4) │
├─── TCP Conn 3 ───── H2 Streams ─────▶ Users C, G, K...
│
└─── TCP Conn 4 ───── H2 Streams ─────▶ Users D, H, L...
How it helps:
- Distributed impact: Packet loss on TCP Conn 1 only blocks users routed through that connection — users on Conn 2, 3, 4 are unaffected
- Parallel recovery: Multiple TCP connections can retransmit independently
- Load spreading: Incoming requests are distributed across the pool via random agent selection
While not as granular as QUIC (where each stream is independent), connection pooling significantly reduces the blast radius of TCP HOL blocking. With --pool-size 4, a single packet loss event affects at most ~25% of concurrent connections instead of 100%.
- Protocol: QUIC over UDP
- Port: Single port for control + data
- Streams: Native multiplexed streams with independent flow control
- Best for: Most scenarios, especially high-latency or lossy networks
Automatically activates when QUIC connection fails (e.g., UDP blocked by firewall).
- Protocol: HTTP/2 over TLS/TCP
- Streams: HTTP/2 multiplexed streams
- Connection Pool: Multiple parallel connections (configurable via
--pool-size) - Best for: Networks that block UDP (corporate firewalls, some mobile networks)
The client automatically detects UDP availability and falls back seamlessly:
Attempt QUIC ──▶ Success? ──▶ Use QUIC
│
▼ Failed
Use HTTP/2 pool
cargo build --release
# Binaries land at target/release/server and target/release/client# Build both targets
docker compose build
# Or individually
docker build --target server -t quic-tunnel-server .
docker build --target client -t quic-tunnel-client .The provided helper script builds the FFI cdylib for the standard Android ABIs:
./build-android.sh # arm64-v8a + armeabi-v7a
COPY_TO=/path/to/android-app ./build-android.shTo build the full AAR via Gradle (regenerates Kotlin bindings via uniffi):
cd android
./gradlew assembleRelease
# AAR at android/app/build/outputs/aar/app-release.aar# Self-signed dev mode (no public domain)
./target/release/server
# With externally managed cert (e.g. certbot on host, mounted via volume)
./target/release/server \
--tls-cert /etc/letsencrypt/live/yourserver.com/fullchain.pem \
--tls-key /etc/letsencrypt/live/yourserver.com/privkey.pem \
--domain-suffix yourserver.com
# With server-provisioned ACME cert (TLS-ALPN-01; :443 must be reachable)
./target/release/server \
--acme-domain yourserver.com \
--acme-email you@example.com \
--domain-suffix yourserver.comServer CLI flags (./target/release/server --help):
| Flag | Default | Purpose |
|---|---|---|
--bind-addr |
0.0.0.0 |
Bind address for all listeners |
--api-port |
4433 |
QUIC + H2 agent port |
--pub-port |
8443 |
Public user-facing TLS port |
--alpn-port |
443 |
TLS-ALPN-01 challenge port (must be reachable as 443) |
--domain-suffix |
(any) | Allowlisted client suffix; repeatable |
--tls-cert, --tls-key |
(none) | PEM cert + key paths |
--acme-domain |
(none) | Enables server ACME provisioning |
--acme-email |
(none) | ACME account contact |
--acme-creds-path |
server_acme_creds.json |
ACME account persistence |
--acme-staging |
off | Use Let's Encrypt staging |
--acme-renew-days |
30 |
Days-before-expiry renewal trigger |
# Single relay
./target/release/client \
--server yourserver.com:4433 \
--local 127.0.0.1:3000 \
--domain-suffix yourserver.com \
--acme-email you@example.com
# Multiple relays in parallel
./target/release/client \
--server eu.yourserver.com:4433 --server us.yourserver.com:4433 \
--local 127.0.0.1:3000 --domain-suffix yourserver.com \
--acme-email you@example.comThe client logs:
ID: a1b2c3d4...
URL: https://a1b2c3d4....yourserver.com:8443
Client CLI flags (./target/release/client --help):
| Flag | Default | Purpose |
|---|---|---|
--server |
(required, repeatable) | Relay address(es) |
--local |
127.0.0.1:3000 |
Local service address |
--domain-suffix |
localhost |
Suffix used to form {client_id}.<suffix> |
--primary-key |
client.key |
Primary keypair (auto-generated if missing) |
--force-h2 |
off | Skip QUIC, use H2 pool only |
--pool-size |
4 |
H2 connections per relay |
--acme-email |
(none) | LE account contact |
--acme-creds-path |
acme_credentials.json |
LE account persistence |
--acme-staging |
off | Use LE staging |
--cert-pem |
acme_cert.pem |
Cached LE cert path |
--primary-cert-extension-hex |
(none) | Custom bytes embedded in primary cert |
--secondary-key |
(none) | Enables a second self-signed endpoint |
--secondary-cert-extension-hex |
(none) | Custom bytes for secondary cert |
./target/release/client \
--server yourserver.com:4433 --local 127.0.0.1:3000 \
--domain-suffix yourserver.com --acme-email you@example.com \
--primary-key client.key --secondary-key client2.keyExposes two endpoints for the same local service:
https://{primary_id}.yourserver.com:8443— publicly trusted (Let's Encrypt)https://{secondary_id}.yourserver.com:8443— self-signed (usecurl -kor pin the cert)
docker-compose.yml ships server + client targets. Set env in your shell or .env:
DOMAIN_SUFFIX=yourserver.com \
ACME_DOMAIN=yourserver.com \
ACME_EMAIL=you@example.com \
SERVER_ADDR=server:4433 \
LOCAL_ADDR=host.docker.internal:3000 \
docker compose upThe entrypoint scripts (docker/server-entrypoint.sh, docker/client-entrypoint.sh) translate env vars into CLI flags. See docker-compose.yml for the full env-var surface.
- Server cert can be externally managed or auto-provisioned via TLS-ALPN-01. Pure self-signed mode exists for local dev only.
- Per-client certs are publicly trusted LE certs; the client owns its private key, the server only proxies HTTP-01 challenges.
- Client identity is the SHA-256 fingerprint of the client's keypair, providing cryptographic identity binding independent of the cert lifecycle.
The architecture is optimized for high concurrency:
- QUIC mode: Up to 1000 concurrent bidirectional streams per connection
- HTTP/2 mode: Connection pooling with configurable pool size
- Zero-copy where possible: Uses
tokio::io::copy_bidirectionalfor efficient data transfer - Non-blocking I/O: Fully async with Tokio runtime
MIT