Expose localhost to the internet — one command, zero cost.
Self-hosted tunnel server in Rust. TLS encrypted. Auth protected. Works in any browser.
Quick Start • Installation • Usage • Deployment • Protocol • Development
# start server on a VM
tunnel-server --token s3cret --domain tunnel.example.com
# expose localhost:3000
tunnel http 3000 --server tunnel.example.com:9000 --token s3cretWorks for HTTP APIs, web apps, static sites, and anything that speaks HTTP. Visitors open https://<subdomain>.tunnel.example.com in any browser — no client-side install needed.
- TLS by default — all traffic between client and server is encrypted
- Token auth — only authorized clients can register tunnels
- Custom subdomains —
tunnel http 3000 -d myapp→https://myapp.example.com - Self-signed support — automatic dev certs,
-kto accept on the client - Stream multiplexing — one TLS connection handles many concurrent requests
- Heartbeat keep-alive — idle detection and cleanup
- Visitor dashboard — live tunnel list at
/_tunnel/dashboard - 80→443 redirect — built-in HTTP→HTTPS upgrade
- Quick Start
- Installation
- Usage
- Deployment
- Architecture
- Wire Protocol
- Authentication
- Why Rust?
- Development
- Contributing
- Roadmap
- License
git clone https://github.com/Open-Source-BD/tunnel.git
cd tunnel
cargo build --releaseThis creates two binaries:
target/release/tunnel-server— the server (invoked astunnel-server)target/release/tunnel-client— the CLI (invoked astunnel)
Make them available on your PATH:
# option A: copy to /usr/local/bin
cp target/release/tunnel-server target/release/tunnel-client /usr/local/bin/
# rename client binary for shorter command
mv /usr/local/bin/tunnel-client /usr/local/bin/tunnel
# option B: run directly from build dir
alias tunnel=./target/release/tunnel-clientecho "<h1>Hello from Tunnel!</h1>" > index.html
python3 -m http.server 3000Open a new terminal:
TUNNEL_TOKEN=devtoken TUNNEL_DOMAIN=localhost tunnel-serverOutput:
INFO visitor HTTPS server listening on 0.0.0.0:443
INFO tunnel server listening on 0.0.0.0:9000
Open another terminal:
tunnel http 3000 --server localhost:9000 --token devtoken -kOutput:
Tunnel URL: https://dev-a1b2c3d4.localhost
Visit https://dev-a1b2c3d4.localhost in your browser.
- Accept the self-signed TLS warning (click "Advanced" → "Proceed")
- You should see "Hello from Tunnel!"
Tip: The subdomain (
dev-a1b2c3d4) is random each time. Copy theTunnel URL:from the client output.
# 1. On your VM — start server with TLS certs
tunnel-server \
--token s3cret \
--domain tunnel.example.com \
--cert-file /etc/letsencrypt/live/tunnel.example.com/fullchain.pem \
--key-file /etc/letsencrypt/live/tunnel.example.com/privkey.pem
# 2. On your dev machine — expose localhost:3000
tunnel http 3000 -s tunnel.example.com:9000 -t s3cret -d myappTunnel is live at https://myapp.tunnel.example.com — no browser warnings.
Requires Rust 1.85+.
git clone https://github.com/Open-Source-BD/tunnel.git
cd tunnel
cargo build --release
# binaries at target/release/tunnel-{server,client}cargo install tunnel-client tunnel-server# server
docker run -d -p 443:443 -p 9000:9000 \
-e TUNNEL_TOKEN=s3cret -e TUNNEL_DOMAIN=example.com \
tunnel-server
# client
docker run --rm tunnel http 3000 \
-s tunnel.example.com:9000 -t s3cretPre-built binaries for Linux, macOS, and Windows are available on the releases page.
tunnel http <port> [options]| Option | Short | Env | Default | Description |
|---|---|---|---|---|
--subdomain |
-d |
— | random | Requested subdomain name |
--server |
-s |
— | localhost:9000 |
Tunnel server address |
--token |
-t |
TUNNEL_TOKEN |
— | Auth token (required) |
--insecure |
-k |
— | false |
Accept self-signed TLS certs |
--config |
-c |
— | — | Config file path |
Examples:
tunnel http 3000 -s tunnel.example.com:9000 -t s3cret # random subdomain
tunnel http 3000 -d api -s tunnel.example.com:9000 -t s3cret # custom subdomain
tunnel http 3000 -k # local dev, self-signedtunnel tcp <port> [options]Currently wraps TCP connections as HTTP tunnels with a tcp- prefixed subdomain. Full raw TCP framing is in development.
tunnel-server [options]| Option | Env | Default | Description |
|---|---|---|---|
--bind |
— | 0.0.0.0 |
Bind address |
--tunnel-port |
— | 9000 |
Port for tunnel client connections |
--http-port |
— | 443 |
Port for visitor HTTP traffic |
--http-redirect-port |
— | 80 |
HTTP→HTTPS redirect (0 to disable) |
--domain |
TUNNEL_DOMAIN |
localhost |
Public domain for tunnel URLs |
--token |
TUNNEL_TOKEN |
— | Auth token (required) |
--cert-file |
— | — | TLS certificate path |
--key-file |
— | — | TLS key path |
| Variable | Used by | Description |
|---|---|---|
TUNNEL_TOKEN |
client, server | Auth token |
TUNNEL_DOMAIN |
server | Public domain |
fly launch --name my-tunnel
fly secrets set TUNNEL_TOKEN=s3cret TUNNEL_DOMAIN=my-tunnel.fly.dev
fly deployfly.io provides free TLS termination and a *.fly.dev subdomain — no cert files needed.
docker build -t tunnel-server .
docker run -d --restart unless-stopped \
-p 443:443 -p 9000:9000 \
tunnel-server \
--token s3cret --domain tunnel.example.comPlace Caddy or Nginx in front of the HTTP listener for automatic TLS:
tunnel.example.com {
reverse_proxy localhost:8080
}Then start the server with --http-port 8080 and let Caddy handle 443.
┌──────────────┐ 1 TLS connection ┌──────────────┐ HTTP ┌──────────┐
│ tunnel-client │ ────── multiplexed ──────▶ │ tunnel-server │ ◀─────────── │ Visitor │
│ (dev machine) │ frames │ (free VM) │ :443 │ Browser │
└──────────────┘ └──────────────┘ └──────────┘
The client opens one persistent TLS connection to the server. All visitor requests are serialized into binary frames and multiplexed over this single connection using stream IDs. This avoids NAT/firewall issues and keeps the connection overhead minimal.
Request flow:
- Visitor hits
https://myapp.example.com/path - Server extracts
myappsubdomain from theHostheader - Server assigns a stream ID, serializes the request as an
HttpRequestframe - Frame is sent to the matching tunnel client
- Client forwards the request to
localhost:<port> - Client reads the response, sends it back as an
HttpResponseframe - Server relays the response to the visitor
Binary framing over TLS:
┌─────────┬──────────────┬────────┬──────────────────┐
│ version │ stream_id │ type │ payload_len │
│ u8 │ u32 BE │ u8 │ u32 BE │
├─────────┼──────────────┼────────┼──────────────────┤
│ 1 │ 0x00000001 │ 0x03 │ 0x0000009A │
└─────────┴──────────────┴────────┴──────────────────┘
┌───────────────────────────────────────┐
│ payload (JSON) │
│ N bytes │
└───────────────────────────────────────┘
Message types:
| Type | Frame | Direction | Payload |
|---|---|---|---|
0x01 |
Register | Client → Server | {subdomain, local_port, token} |
0x02 |
Registered | Server → Client | {assigned_url, tunnel_id} |
0x03 |
HttpRequest | Server → Client | {method, uri, headers, visitor} |
0x04 |
HttpResponse | Client → Server | {status, headers, body} |
0x05 |
TcpData | Bidirectional | raw bytes |
0x06 |
Error | Bidirectional | {message} |
0x07 |
CloseStream | Bidirectional | — |
0x08 |
Heartbeat | Bidirectional | — |
The version field (0x01) ensures forward compatibility. A future v2 could switch to HTTP/2 multiplexing while old clients still connect with v1.
The server requires a --token on startup. Every client must present the same token in its Register frame or the connection is rejected with an Error frame and dropped. Tokens are transmitted inside the TLS tunnel and never exposed on the wire.
Clients can pass the token via --token flag or TUNNEL_TOKEN environment variable.
All major tunnel tools (ngrok, frp, bore) are Go. Rust gives us:
- Memory safety without a garbage collector
- Zero-cost abstractions — no runtime overhead
- Small binaries (~5 MB stripped)
- Sub-millisecond startup — no VM warmup
- Ecosystem fit — tokio, axum, rustls are the best-in-class async stack
| Tool | Language | Binary | TLS | Auth |
|---|---|---|---|---|
| tunnel | Rust | ~5 MB | ✅ built-in | ✅ token |
| ngrok | Go | ~15 MB | ✅ built-in | ✅ account required |
| frp | Go | ~10 MB | ✅ built-in | ✅ token/OIDC |
| bore | Rust | ~3 MB | ❌ (tunnel only) | ✅ HMAC |
# build all crates
cargo build
# run tests
cargo test
# run with live output
TUNNEL_TOKEN=test123 TUNNEL_DOMAIN=localhost cargo run --bin tunnel-server
# in another terminal
cargo run --bin tunnel-client http 3000 -s localhost:9000 -d test -ktunnel/
├── Cargo.toml # workspace root
├── tunnel-proto/ # wire protocol: Frame types, Codec
│ ├── src/types.rs # message types, payload structs
│ └── src/codec.rs # async encode/decode framing
├── tunnel-server/ # axum HTTP server + TLS listener
│ ├── src/main.rs # CLI args, startup orchestration
│ ├── src/tunnel.rs # TunnelManager, multiplexing, proxying
│ └── src/tls.rs # TlsConfig, self-signed cert generation
└── tunnel-client/ # CLI tunnel client
├── src/main.rs # CLI args (http/tcp subcommands)
└── src/tunnel.rs # TLS connect, NoCertVerifier, forwarding
- Raw TCP tunnel (SSH, databases, custom protocols)
- WebSocket passthrough
- Client config file (
~/.tunnel/config.toml) - Prometheus metrics
- Tunnel management API
- Homebrew formula
- CI + cross-compilation releases
- Docker image on GHCR
PRs welcome. Please ensure cargo test passes and cargo clippy is clean.
- Fork the repo
- Create a feature branch (
git checkout -b feat/my-feature) - Commit your changes (
git commit -am 'add my feature') - Push (
git push origin feat/my-feature) - Open a Pull Request
MIT — see LICENSE.