OpenTunnel is a blind TLS tunnel. Each client receives a unique
<id>.opentunnel.xyz hostname and terminates TLS locally, so the server never
holds the certificate private key or sees HTTP plaintext.
Install the CLI with any of:
curl -fsSL https://opentunnel.xyz/install | sh
brew install anomalyco/tap/opentunnel
yay -S opentunnel-bin # Arch Linux (AUR)
npm install -g opentunnel # or bun / pnpm
cargo install opentunnel-cliPrebuilt binaries for Linux and macOS (x64 and arm64) are attached to each GitHub release.
Then route a local port. This creates the tunnel on first use, starts the background service, and prints the route's URL:
opentunnel route add 3000
# Added route 21992cc9713e5fc5 → 127.0.0.1:3000
# https://21992cc9713e5fc5.<id>.opentunnel.xyzRoutes get a random 16-character name by default. Route names never appear in
public certificate logs, so the URL can't be guessed, but anyone you share it
with can reach the service: it is not authentication. Pass --name api for a
readable name instead.
To let the application see each visitor's address, pass --proxy-protocol v1
or --proxy-protocol v2: the client then sends a PROXY protocol header with
the visitor's address before each connection's data. Enable it only if the
application expects that header; see
docs/trust-boundary.md.
See packages/cli for the full command reference.
The hosted service is one Rust binary, crates/opentunnel-server, on Fly.io
(docs/server.md):
- It accepts public TCP on 443 and reads only the TLS ClientHello. A tunnel
hostname (
<id>.opentunnel.xyzor<route>.<id>.opentunnel.xyz) is carried, still encrypted, over the tunnel's bridge WebSocket to the client that claimed the route. opentunnel.xyzitself is terminated with the server's own certificate and serves the HTTP API, the bridge WebSocket, and the website.- Tunnel records, certificate state, and durable issuance jobs live in MySQL (PlanetScale).
- Certificates come from ZeroSSL over ACME with DNS-01 challenges in the Cloudflare-hosted zone, and renew 30 days before expiry.
- The local client owns the certificate private key, terminates TLS, and forwards decrypted traffic to the local application unchanged. The application sees a loopback peer and the visitor's own headers, so it must not treat tunneled requests as local; see docs/trust-boundary.md. A route can opt in to a PROXY protocol header carrying the visitor's address.
There are two client implementations that share one protocol:
| Path | What it is |
|---|---|
crates/opentunnel |
Rust client library and wire types, published as opentunnel on crates.io |
crates/opentunnel-cli |
The opentunnel CLI and per-profile background service |
crates/opentunnel-server |
The hosted service: SNI routing, bridges, API, certificates (not published) |
packages/client |
TypeScript SDK (@opentunnel/client), compiled to JavaScript for Bun, Node, and Deno |
packages/protocol |
TypeScript schemas, bridge framing, and the HTTP API contract |
packages/website |
The landing page, built with Vite and served by the server |
packages/cli |
npm launcher and publish script for the Rust CLI |
migration |
Temporary tooling for the move off the Cloudflare Worker (docs/cutover.md) |
The wire protocol and on-disk layout are specified in
docs/protocol.md. Both clients are tested against the
shared vectors in spec/vectors, so a change to the protocol must update the
spec, the vectors, and both clients.
Dockerfile builds the server and the website into one image; fly.toml
runs it as the opentunnel Fly app in iad with PlanetScale MySQL, raw TCP
on 443 (with a dedicated IPv4) and 80. Deploy with fly deploy, or run the
manual "Deploy to Fly" GitHub workflow. Configuration and secrets are listed
in docs/server.md.
bun install
bun run ready # TypeScript builds and the website (dist/website)
bun run test # Rust and TypeScript testsRun the server locally against MySQL 8 in Docker, with its insecure built-in test CA, serving the whole app on plain HTTP:
docker run -d --name ot-mysql -e MYSQL_ROOT_PASSWORD=opentunnel -e MYSQL_DATABASE=opentunnel -p 3306:3306 mysql:8.0
mkdir -p .local
DATABASE_URL=mysql://root:opentunnel@127.0.0.1:3306/opentunnel \
ISSUER=local LOCAL_CA_FILE=.local/local-ca.pem OPENTUNNEL_DOMAIN=localhost \
WEBSITE_DIR=dist/website TLS_LISTEN='[::]:8443' HTTP_LISTEN='[::]:8080' HTTP_MODE=serve \
cargo run -p opentunnel-serverThe server tests use the same container: TEST_DATABASE_URL=mysql://root:opentunnel@127.0.0.1:3306/opentunnel cargo test -p opentunnel-server
(without it they skip).
*.localhost resolves to the loopback address on most systems, so tunnels are
reachable at https://<route>.<id>.localhost:8443 (trust
.local/local-ca.pem). Point the CLI at it in a second terminal after starting
a local HTTP application on port 4096:
export OPENTUNNEL_API=http://localhost:8080
bun run opentunnel route add 4096bun run dev serves the website with hot reload on http://127.0.0.1:4190
and proxies /api to that server.
We don't accept pull requests. If you've found a bug or have an idea, open an issue instead.