Search by

ggcode / etunnel

topcheer

Expose local services to the internet in one line of code: instant public HTTPS URLs with automatic TLS, through NAT and firewalls, no signup. Webhook-friendly ngrok-style reverse tunnel, with SDKs in 20 languages and a single-binary Go server.

Package info

github.com/topcheer/etunnel

Language:Go

pkg:composer/ggcode/etunnel

Statistics

Installs: 42

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 6

1.6.2 2026-11-11 16:30 UTC

README

etunnel

npm crates.io PyPI NuGet Hex LuaRocks pub.dev Packagist Clojars JSR Go Maven Central tests

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-k3f9qz.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
Loading
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-k3f9qz.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
Loading

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-k3f9qz.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
/setlang UI language switch (sets the et_lang cookie, redirects back)
GET /me/domain-check?d=<sub> subdomain availability probe for the /me page (session); custom subdomains are 1-63 chars, cross-account duplicates rejected
POST /api/billing/checkout tier checkout, or premium short subdomains (tier: "short-domain-1/2/3", one-time $3/$2/$1 per 30-day period; crypto floors at $2; Free accounts may buy)
/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)

The web UI (landing, pricing, billing, login, /me, and the legal pages' chrome on /terms / /privacy) is localized in 11 languages — English, 简体中文, 日本語, 한국어, Deutsch, Français, Español, Português, Italiano, Русский and العربية (RTL). Language is negotiated from the et_lang cookie, then Accept-Language, then English; the footer switcher calls /setlang. Legal document bodies stay in English (the English version governs) with a localized governing-language note.

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.

Multi-instance (cluster)

One binary, three modes - ETUNNEL_CLUSTER=single (default, today's node), mesh (gossip + cross-peer forwarding, behind any L4 LB) and sticky (Redis routing table for L7 LBs). Shared services are env-driven: ETUNNEL_PG_DSN (accounts), ETUNNEL_REDIS_URL (exact global limits), SESSION_SECRET (signs cookies + resume tokens - required multi-instance). /healthz on the main port reports 200/drain; SIGTERM drains before sessions close, and SDKs re-land on any peer with the same URL.

Full guide: deploy/CLUSTER.md - modes, verified deployments (Docker Compose / k3s Helm / bare-metal systemd), scaling.

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 · CHANGELOG §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:

Document Contents
PROTOCOL.md frames, control messages, stream lifecycle, limits
sdks/README.md install matrix, capability comparison, protocol summary
examples/README.md runnable examples per language
PACKAGING.md how releases are cut and published to 20 registries
CREDENTIALS.md every environment variable and OAuth app

License

MIT - see LICENSE.