A hostname-routed reverse tunnel over QUIC: expose local services on a public HTTPS endpoint without opening inbound ports. Shaped like Cloudflare Tunnel, but open source and self-hostable, with an optional passthrough mode that never decrypts the tunneled traffic.
A public tuyau-server terminates (or peeks, in passthrough) public TLS on
port 443 and forwards each connection over a QUIC tunnel to the
tuyau-client that owns the requested hostname. The client forwards it on to
a local service.
internet your machine / cluster
──────── ─────────────────────
│
TLS │ alpha.example.com ┌──────────────┐
└─────► tuyau-server ──QUIC──► │ tuyau-client │ ──► 127.0.0.1:8080
(public 443) └──────────────┘
(tunnel 4433/UDP)
Status: alpha. Running in production, but the wire protocol may still change between releases.
- Hostname routing: one server fronts many services on shared 443, routed by SNI / Host header. Server config is the single source of truth for the hostname → client binding.
- Per-client balancing: when several tunnels share one client token, the
server either hands over to the newest (
last-write-wins, default — clean reconnects) or spreads public connections across them (round-robin— active-active fan-out / HA), set per[[clients]]entry. - Three TLS modes per hostname:
terminated(server decrypts and reverse-proxies HTTP — h2→h1 bridge),tls_offload(server decrypts and byte-pipes the plaintext to a cleartext, non-HTTP backend — Postgres, Redis, MQTT… — so the backend never deals with certificates), orpassthrough(server only peeks the SNI, raw TLS bytes flow over the tunnel — the server never sees plaintext). - Library-first client:
tuyau-clientis a Rust crate. With theaxumfeature, swap aTcpListenerfor aTunnelListenerin one line and serve your existing axum/hyper app through the tunnel. CLI is a thin wrapper around the library. - Container-native CLI: client config via flags +
TUYAU_*env vars, token via--token-file/TUYAU_TOKEN_FILE(Docker secrets), no bare hex on the command line. TOML config is opt-in via--config. - Pinned tunnel cert: the client pins the server's tunnel cert by SHA-256 fingerprint — no PKI, no LE for the tunnel itself.
- ACME / Let's Encrypt for public certs: enable an optional
[acme]block inserver.tomland the server fetches real browser-trusted certs via TLS-ALPN-01 — per hostname, on demand, including hostnames added at runtime. Without it, the public listener serves a dev self-signed multi-SAN cert (curl -kworks; browsers complain). - Bring your own cert: when inbound TLS-ALPN-01 can't run (public 443 is
held by another service, or inbound IPv6 is unreliable for the CA's
validation), set
tls_cert_file/tls_key_fileto serve a cert obtained out-of-band — e.g. Let's Encrypt via DNS-01. No inbound validation needed. - Custom 502 page: when a terminated hostname has no live tunnel, the
server answers with a proper 502 page instead of dropping the connection
— overridable per deployment via
error_502_file. - Built to stay up: non-poisoning routing locks, global + per-IP
connection caps, accept-loop backpressure, and a liveness probe
(
TunnelServer::healthy()) ready to wire into a systemd watchdog.
Requires Rust 1.85+ (edition 2024). Both server and client ship as
subcommands of the same tuyau binary.
cargo build --release # binary at target/release/tuyau
cargo install --path crates/tuyau-cli # or put `tuyau` on PATHContainer image (server + client in one):
docker build -t tuyau .Embedding the client as a library: add tuyau-client as a dependency and
enable the axum feature for the axum::serve
adapter.
# server.toml
listen_addr = "0.0.0.0:4433" # QUIC tunnel listener
public_listen_addr = "0.0.0.0:443" # public TLS listener
[[clients]]
name = "service-a"
token = "<openssl rand -hex 32>"
[[hostnames]]
host = "alpha.example.com"
client = "service-a"
# tls_mode = "terminated" # default; also "tls_offload" or "passthrough"tuyau server --config server.toml
# logs the cert SHA-256 fingerprint at startup — copy itTUYAU_SERVER=tunnel.example.com:4433 \
TUYAU_FINGERPRINT=<copied from server logs> \
TUYAU_TOKEN_FILE=./tuyau-token.hex \
tuyau client --ingress alpha.example.com=127.0.0.1:8080Or via TOML (see examples/client.toml). See examples/docker-compose.yml
for a complete Compose setup with Docker secrets.
use tuyau_client::{ClientConfig, TunnelClient};
let tunnel = TunnelClient::connect(cfg).await?;
let listener = tunnel.listener();
let shutdown = listener.closed();
axum::serve(listener, my_router)
.with_graceful_shutdown(shutdown)
.await?;Requires the axum feature on tuyau-client. Full example:
crates/tuyau-client/examples/axum.rs.
Tuyau is designed for three distinct shapes:
- Self-host (OSS) — you run both ends on your own infrastructure.
- Cloud Marketplace (planned) — pre-built AMI / container image you launch on your own AWS / Scaleway / OVH account. You stay sovereign.
- Managed (planned) — passthrough-only hosted service for teams that want the tunnel without operating the server, where "the operator cannot read your traffic" is a structural property of the OSS code.
crates/
├── tuyau-protocol/ wire frames + length-delimited CBOR codec
├── tuyau-server/ QUIC listener + public TLS listener + routing table
├── tuyau-client/ QUIC dialer + listener adapter (axum, lib-first)
└── tuyau-cli/ binary `tuyau` with `server` and `client` subcommands
sdk/
└── ts/ TypeScript client SDK (Bun) — serve a local handler
through the tunnel without the Rust CLI
Dual-licensed under MIT or Apache-2.0 at your option.