etunnel

Your localhost, on the internet. etunnel gives a service running on your
machine a public HTTPS URL by opening one outbound WebSocket - no inbound port,
no router configuration, no firewall rule, no signup.
- Instant TLS - the server holds a wildcard certificate, so every tunnel is
https:// from the first request. Webhooks, OAuth callbacks and payment IPNs
work on the first try.
- Anonymous by default - a random subdomain, 4-hour lifetime, 1 Mbps, no
account. A paid API key adds custom subdomains, longer lifetimes and 10 Mbps.
- 20 SDKs, one protocol - embed the tunnel in your own process (Go, Node,
Python, Java, Kotlin, Scala, C#, Rust, Swift, Dart, Ruby, PHP, Perl, Lua,
Clojure, Elixir, Deno, TypeScript, C, C++).
- Self-hostable - the server is a single Go binary with ACME built in.
- WebSocket pass-through - the Go SDK relays 101 upgrades as a raw byte
pipe, so realtime apps work through the tunnel too.
Live deployment: wss://t.etun.uk/tunnel, tunnels at
https://<name>.t.etun.uk. Point any client at your own server with
-server / ETUNNEL_SERVER / the first connect() argument.
How it works
flowchart LR
V["visitor<br/>https://quiet-frog-42.t.etun.uk"] -->|HTTPS 443| E["etunneld<br/>wildcard TLS<br/>plan limits"]
E -->|"wss /tunnel<br/>one multiplexed socket"| C["SDK client<br/>inside your process"]
C -->|HTTP/1.1| A["localhost:3000<br/>your service"]
E -.->|"101 upgrade<br/>raw byte pipe"| A
sequenceDiagram
autonumber
participant App as Your app
participant SDK as SDK client
participant Edge as etunneld
participant Local as localhost:3000
participant V as Visitor
App->>SDK: connect(server, 3000)
SDK->>Edge: wss /tunnel (subprotocol etunnel-v1) + hello
Edge-->>SDK: welcome {url, hostname, plan, ttl}
SDK-->>App: public URL
V->>Edge: GET /webhook (Host: quiet-frog-42.t.etun.uk)
Edge->>SDK: {type: req, streamId: 1} + body frames
SDK->>Local: GET /webhook
Local-->>SDK: 200 OK + body
SDK->>Edge: data frames + fin
Edge-->>V: 200 OK
Nothing is stored: the server just matches the Host header to a live session,
allocates a stream id and relays frames. When the client disconnects (or the
plan TTL expires) the subdomain is released immediately.
Quickstart
CLI - one command, any SDK
npx @easytunnel/et-sdk 3000
# TUNNEL_URL: https://quiet-frog-42.t.etun.uk
# plan: Free, expires in 4h
Every SDK's file is also a runnable CLI: etunnel 3000 (Go), python -m etunnel 3000,
dotnet run Etunnel.cs 3000, swift run etunnel 3000, php etunnel.php 3000.
Each package README lists its own.
Embedded - the tunnel lives inside your process
Go:
tunnel, err := etunnel.Connect(ctx, etunnel.Options{
Server: "wss://t.etun.uk/tunnel",
LocalAddr: "127.0.0.1:3000",
// Token: "et_...", Subdomain: "myapp", // paid plan
})
if err != nil {
return err
}
defer tunnel.Close("app exiting")
log.Printf("public URL: %s", tunnel.URL())
Node:
const { connect } = require('@easytunnel/et-sdk');
const tunnel = await connect('wss://t.etun.uk/tunnel', 3000);
console.log(tunnel.url);
Python:
from etunnel import connect
tunnel = connect("wss://t.etun.uk/tunnel", 3000)
print(tunnel["url"])
PHP:
$tunnel = Etunnel::connect('wss://t.etun.uk/tunnel', 3000);
echo $tunnel->url;
Every snippet is published on the landing page of the deployment
(https://t.etun.uk), one tab per language, always in sync with the SDKs.
All 20 SDKs
| Language |
Install |
Embed |
Docs |
| Go |
go get github.com/topcheer/etunnel/sdk |
Connect(ctx, Options{...}) |
sdk/ |
| Node.js |
npm install @easytunnel/et-sdk |
connect(server, 3000) |
sdks/node |
| TypeScript |
npm install @easytunnel/et-sdk |
connect(server, 3000) |
sdks/typescript |
| Deno |
deno add jsr:@ggcode/etunnel |
connect(server, 3000) |
sdks/deno |
| Python |
pip install etunnel |
connect(server, 3000) |
sdks/python |
| Java |
dev.ggcode:etunnel |
Etunnel.connect(server, 3000, null) |
sdks/java |
| Kotlin |
dev.ggcode:etunnel |
Etunnel.connect(server, 3000) |
sdks/kotlin |
| Scala |
dev.ggcode:etunnel |
Etunnel.connect(server, 3000) |
sdks/scala |
| C# |
single file / etunnel NuGet |
await Etunnel.Connect(server, "3000") |
sdks/csharp |
| Rust |
cargo install etunnel |
binary - spawn as child |
sdks/rust |
| Swift |
single file (Foundation) |
try await Etunnel(...).connect() |
sdks/swift |
| Dart |
dart pub add etunnel |
Etunnel.connect(server, '3000') |
sdks/dart |
| Ruby |
gem install etunnel |
Etunnel.connect(server, 3000) |
sdks/ruby |
| PHP |
composer require ggcode/etunnel |
Etunnel::connect(...) |
sdks/php |
| Perl |
single file (core modules) |
etunnel_connect(...) |
sdks/perl |
| Lua |
luarocks install ggcode.etunnel |
script |
sdks/lua |
| Clojure |
dev.ggcode/etunnel-clj |
(et/connect! server 3000) |
sdks/clojure |
| Elixir |
{:etunnel, "~> 1.2"} |
Etunnel.start_link(...) |
sdks/elixir |
| C |
single file (make) |
binary - spawn as child |
sdks/c |
| C++ |
single file (cmake) |
binary - spawn as child |
sdks/cpp |
Capability differences (streaming bodies, WebSocket pass-through) are in the
SDK matrix.
Plans
|
Free (anonymous) |
Starter |
Pro |
| Registration |
none |
API key |
API key |
| Subdomain |
random word-word-42 |
1 custom |
5 custom |
| Tunnel lifetime |
4 hours |
72 hours |
30 days |
| Bandwidth |
1 Mbps |
10 Mbps |
10 Mbps |
| Concurrent tunnels |
2 per account (5 per IP when anonymous) |
5 |
20 |
Tiers are configuration, not code: etunneld -tiers tiers.json maps a tier id to
{name, ttlHours, customDomains, bandwidthMbps, maxTunnels} (maxTunnels: 0
means unlimited).
Paid plans
Sign in and subscribe from the portal - activation is immediate and mints your
API key:
open https://t.etun.uk/pricing # Google / GitLab / GitHub / Apple login
# then /me manages the key, custom subdomains and the subscription
etunnel -server wss://t.etun.uk/tunnel -token et_... -subdomain mydev 3000
Signing in claims the account bound to the verified email address. If that
account was created by checkout, claiming it rotates the previous API key: any
tunnel still running on the old key disconnects and must be restarted with the
key shown on /me.
Server (etunneld)
One static binary, four TLS modes:
-tls-mode |
Certificates |
When |
http |
none |
behind a load balancer that terminates TLS |
static |
-tls-cert / -tls-key, hot-reloaded |
you already have a wildcard cert |
ondemand |
ACME HTTP-01 per hostname |
needs :80 reachable, small deployments |
dns |
ACME DNS-01 wildcard (-dns-provider cloudflare|alidns|dnspod) |
no TLS handshake latency for new subdomains |
# wildcard certificate via DNS-01 (recommended)
etunneld -domain t.example.com -addr :443 -http-addr :80 \
-tls-mode dns -dns-provider cloudflare \
-acme-dir /var/lib/etunnel/acme -acme-email you@example.com
# per-hostname certificates via HTTP-01
etunneld -domain t.example.com -addr :443 -http-addr :80 \
-tls-mode ondemand -acme-dir /var/lib/etunnel/acme -acme-email you@example.com
# your own certificate (hot-reloaded) or plain HTTP behind an LB
etunneld -domain t.example.com -tls-mode static -tls-cert fullchain.pem -tls-key privkey.pem
-alias-domain t.old.com serves a legacy domain alongside -domain during a
migration (static wildcard cert from the ACME directory, not renewed).
Endpoints
| Path |
Purpose |
wss://<domain>/tunnel |
SDK WebSocket endpoint (subprotocol etunnel-v1) |
POST /api/register |
{"email"} -> free-tier API key |
GET /api/tiers |
plan catalog |
/ |
landing page (quickstart, pricing) |
/pricing, /login, /me |
subscription flow and account dashboard |
/webhooks/stripe, /webhooks/paypal |
payment provider callbacks |
127.0.0.1:9090/stats |
live tunnels, uptime, byte counters, billing status |
127.0.0.1:9090/admin/accounts |
account management (X-Admin-Token) |
Operational limits
- Per-IP concurrent anonymous tunnels (
-max-tunnels-per-ip) and a per-account
cap that scales with the tier (Tier.MaxTunnels, rejection code
limit_reached), per-tunnel stream cap (-max-streams), plan TTL and
bandwidth (token bucket, both directions).
- Heartbeats both ways (
ping/pong plus WS ping), stalled streams are reset
instead of freezing the tunnel.
- Accounts persist to a JSON file with atomic writes.
Operations: sessions and reconnect
The server signs two things with SESSION_SECRET: OAuth session cookies and
the resumeToken that lets a reconnecting client reclaim its subdomain.
SESSION_SECRET=$(openssl rand -hex 32) # set once, keep it stable
- Set it in production. Without it both keys are generated per process:
a restart logs every user out and makes resume tokens unverifiable, so
reconnecting clients come back on a fresh random subdomain.
- Rotating it invalidates existing sessions (users log in again) and any
outstanding resume tokens (in-flight reconnects get new subdomains).
Clients reconnect automatically and replay their resume token, so the server
hands back the same subdomain (skipping plan gating — the signed token is the
grant). Wire format: PROTOCOL.md §1.
Billing (optional, self-hosted)
Payments are enabled purely by environment variables - empty means off:
STRIPE_SECRET_KEY=sk_live_<key> # https://dashboard.stripe.com/apikeys
STRIPE_WEBHOOK_SECRET=whsec_<secret> # endpoint: https://<domain>/webhooks/stripe
PAYPAL_CLIENT_ID=<client id> # https://developer.paypal.com
PAYPAL_SECRET=<client secret>
PAYPAL_ENV=sandbox # or live
PAYPAL_WEBHOOK_ID=<webhook id> # endpoint: https://<domain>/webhooks/paypal
TIER_STARTER_PRICE_CENTS=300 # optional overrides (default $3 / $9)
TIER_PRO_PRICE_CENTS=900
First payment mints the account and API key for the payer's email;
renewals and upgrades keep the same key and change the tier. Stripe signatures
are verified with HMAC-SHA256 (5 minute tolerance), PayPal through the
verify-webhook-signature API.
Telegram admin bot (optional)
TELEGRAM_BOT_TOKEN=123456:ABC... # from @BotFather
TELEGRAM_ADMIN_CHAT_ID=123456789 # your chat id
/status, /tunnels, /accounts, /kick <subdomain>,
/tier <email> <tier>, /whois <subdomain>. Only the whitelisted chat is
answered; payments push a notification to it. Long polling - no public webhook.
Repository layout
protocol/ wire protocol: frames + JSON control messages, shared by server and Go SDK
server/ etunneld: sessions, registry, proxy, tiers, TLS/ACME, admin API, billing, portal
sdk/ Go SDK - reference client (streaming + WebSocket pass-through)
cmd/etunneld server CLI
cmd/etunnel client CLI
sdks/ 20 language clients, one README each (see sdks/README.md)
examples/ runnable examples (see examples/README.md)
scripts/ release, packaging and doc generation helpers
Development
make verify # gofmt check, go vet, go test -race ./...
go test ./... # unit + integration tests
The integration tests start a real etunneld in-process and drive it with the
Go SDK, the CLI and raw HTTP/WebSocket clients - including TLS, tiers, TTL
expiry, bandwidth limiting and upgrade pass-through.
Docs:
License
MIT - see LICENSE.