wavetunnel
v0.7.6
Published
WaveTunnel client — expose a local HTTP or TCP service through your self-hosted gateway, with a built-in request-inspection dashboard
Maintainers
Readme
wavetunnel
Self-hosted HTTP and raw TCP reverse tunnel CLI. Connects to a
WaveTunnel gateway (default https://gateway.warungkeys.com) and exposes a
local service to the internet: HTTP through a random *.warungkeys.com
subdomain, or any TCP byte-stream (Postgres, SSH, Redis, …) through a public
gateway.warungkeys.com:<port>.
Install
npm install -g wavetunnelRequires Node.js ≥ 20.
Quick start
wavetunnelThe first run prompts for your email, sends a 6-digit verification code,
asks for the code, then asks for the local port (default 3000). After that
your access token is cached in ~/.wavetunnel/config.json (chmod 600) and
you skip straight to the port prompt.
Common commands
| Command | What it does |
|---|---|
| wavetunnel | Login if needed, prompt port, run a tunnel |
| wavetunnel 8080 | Same, target http://localhost:8080 |
| wavetunnel --port 8080 | Equivalent |
| wavetunnel --target http://127.0.0.1:8080 | Custom target URL |
| wavetunnel --tcp --port 5432 | Raw TCP tunnel to 127.0.0.1:5432 |
| wavetunnel --tcp --target host:port | Raw TCP tunnel to an arbitrary host:port |
| wavetunnel --basic-auth user:pass | Require HTTP Basic auth in front of the tunnel |
| wavetunnel start | Run every tunnel in ~/.wavetunnel/tunnels.json |
| wavetunnel start --config ./tunnels.json | Run tunnels from a specific file |
| wavetunnel --no-inspect | Disable the local request-inspection dashboard |
| wavetunnel --inspect-port 4050| Bind the inspection dashboard to a custom loopback port |
| wavetunnel --gateway https://gateway.warungkeys.com | Override gateway |
| wavetunnel --email [email protected] | Pre-fill email for the OTP prompt |
| wavetunnel --token <ACCESS> | Manual mode, no interactive login |
| wavetunnel --no-save-token | Don't persist token to disk |
| wavetunnel login | Force a fresh OTP login |
| wavetunnel logout | Revoke + clear local token |
| wavetunnel whoami | Show account email + active-tunnel count |
TCP tunnels (--tcp)
By default wavetunnel exposes a local HTTP service. Add --tcp to tunnel
a raw byte-stream instead — databases, SSH, Redis, anything TCP. The gateway
allocates a public port and bridges every connection to your local target:
wavetunnel --tcp --port 5432 # 127.0.0.1:5432
wavetunnel --tcp --target 192.168.1.10:22 # arbitrary host:portThe banner prints the allocated public endpoint:
Type : TCP (raw)
Public : tcp://gateway.warungkeys.com:20000
Forwarding : tcp://127.0.0.1:5432
Connect : point your client at gateway.warungkeys.com:20000Connect with any TCP client, e.g.
psql "host=gateway.warungkeys.com port=20000 …" or
ssh -p <port> [email protected].
The public port is server-allocated (you can't choose it yet). --tcp needs the
gateway to have TCP enabled — otherwise the CLI prints
TCP tunnels are disabled on this gateway. Connections to the public port are
unauthenticated at the transport layer (like ngrok TCP), so rely on the exposed
service's own auth.
WebSocket and streaming
HTTP tunnels forward WebSocket connections and stream responses, so realtime and SSE apps work like they do on ngrok — no extra flags.
- WebSocket — a visitor that upgrades to WebSocket on your public subdomain is bridged through to your local app's WebSocket endpoint, preserving the request path, cookies/auth headers, the negotiated subprotocol, and both text and binary frames. This covers socket.io, Vite/Next.js HMR, and other realtime transports.
- Streaming responses — Server-Sent Events (SSE), chunked transfer, and large downloads flow incrementally instead of being buffered until the response finishes. Bytes arrive at the visitor as your local app emits them.
- Visitor disconnect — if the visitor hangs up mid-response, the gateway tells the client to abort the in-flight local request rather than keep reading into a dead socket.
Both require a gateway running 0.6.0+. The client negotiates capabilities from the gateway's welcome message: against an older gateway it transparently falls back to the previous one-shot (buffered) behaviour. The inspection dashboard captures streamed responses too (up to the body cap, then marked truncated).
Request inspection
For HTTP tunnels, wavetunnel runs a local inspection dashboard — the
equivalent of ngrok's http://localhost:4040. It shows every request and
response flowing through the tunnel and lets you replay any of them against your
local service:
Public URL : https://abc12xyz.warungkeys.com
Inspect : http://127.0.0.1:4040Open http://127.0.0.1:4040 in a browser. You get a live list of captured
transactions (method, path, status, timing, size), a detail view with full
request/response headers and bodies (JSON is pretty-printed), and a Replay
button that re-issues the request to your local target — handy for iterating on
a webhook handler without re-triggering the upstream sender.
Edit & Replay: alongside plain Replay, the detail view has an "Edit &
Replay" panel where you can change the method, path, headers (as JSON), and body
before re-sending — ngrok's "Replay with modifications". Edited replays are
tagged in the list. Replays hit your local target directly and never traverse
the gateway, so a --basic-auth gate does not block them.
Captures live in a bounded in-memory ring buffer (last 200 transactions); nothing is written to disk.
| Control | Effect |
|---|---|
| wavetunnel --no-inspect | Disable the dashboard for this run |
| wavetunnel --inspect-port <port> | Bind the dashboard to a different loopback port |
| WAVETUNNEL_INSPECT=0 | Disable the dashboard (also false/off/no) |
| WAVETUNNEL_INSPECT_PORT=<port> | Dashboard loopback port (default 4040) |
Security: captured bodies routinely contain auth tokens, cookies, and PII, so
the dashboard binds to loopback only (127.0.0.1) and is never exposed on
0.0.0.0. The loopback interface is the trust boundary — there's no separate
auth, by design. The dashboard is HTTP-only; raw TCP tunnels (--tcp) carry
opaque byte streams with nothing to render, so it's disabled for them.
Multi-port
Open one terminal per service. Each invocation gets a unique random subdomain:
# terminal 1
wavetunnel 8080
# → https://abc12xyz.warungkeys.com
# terminal 2
wavetunnel 3000
# → https://qrs98pdg.warungkeys.comThe gateway caps active tunnels per account
(AUTH_MAX_ACTIVE_TUNNELS_PER_EMAIL, default 5).
Multi-tunnel (wavetunnel start)
Instead of one terminal per service, define them once and run them all from a
single process. Create ~/.wavetunnel/tunnels.json (or pass --config <path>):
{
"gateway": "https://gateway.warungkeys.com",
"tunnels": [
{ "name": "web", "port": 3000 },
{ "name": "api", "target": "http://127.0.0.1:8080", "inspectPort": 4041, "basicAuth": "admin:s3cret" },
{ "name": "db", "type": "tcp", "port": 5432, "inspect": false }
]
}Then:
wavetunnel startEach tunnel gets its own reconnect loop and inspection dashboard, with logs
prefixed by [name]. You log in once; the token is shared across all tunnels on
that gateway. Per-entry fields:
| Field | Default | Notes |
|---|---|---|
| name | tunnel-N | Label used in logs |
| type | http | http or tcp |
| port | — | Local port (one of port/target required) |
| target | — | Explicit target (http URL, or host:port for tcp) |
| inspect | true | Disable the dashboard for this tunnel (http only) |
| inspectPort | 4040 | Dashboard loopback port — give each tunnel a distinct one |
| basicAuth | — | user:pass gate (http only) |
The active-tunnel cap still applies, so keep the list within
AUTH_MAX_ACTIVE_TUNNELS_PER_EMAIL (default 5).
Access control (--basic-auth)
Put HTTP Basic auth in front of an HTTP tunnel:
wavetunnel 3000 --basic-auth admin:s3cretRequests without valid credentials get a 401 with a WWW-Authenticate
challenge and never reach your local service. Set it per-tunnel in the tunnels
file, or globally via WAVETUNNEL_BASIC_AUTH=user:pass.
Two honest caveats:
- Enforcement happens at the client edge, not the gateway edge. An unauthenticated request still travels to the gateway and down the tunnel; the client rejects it before it touches your local service. This protects the service, but the request does reach your machine. (ngrok enforces at its edge.)
- Credentials are compared in constant time. Inspector replays bypass the gate — they're developer-initiated from loopback, like ngrok.
Manual / CI usage
# Linux/macOS
WAVETUNNEL_TOKEN=<access-token> \
WAVETUNNEL_GATEWAY=https://gateway.warungkeys.com \
wavetunnel --port 8080
# Windows CMD
set WAVETUNNEL_TOKEN=<access-token>
set WAVETUNNEL_GATEWAY=https://gateway.warungkeys.com
wavetunnel --port 8080
# PowerShell
$env:WAVETUNNEL_TOKEN = "<access-token>"
$env:WAVETUNNEL_GATEWAY = "https://gateway.warungkeys.com"
wavetunnel --port 8080Environment variables
| Variable | Default | Notes |
|---|---|---|
| WAVETUNNEL_GATEWAY | https://gateway.warungkeys.com | Gateway base URL |
| WAVETUNNEL_TOKEN | (none) | Access token (skips login) |
| WAVETUNNEL_TARGET | http://localhost:3000 | Local target (HTTP URL, or host:port with --tcp) |
| WAVETUNNEL_REQUEST_TIMEOUT_MS| 30000 | Per-request timeout |
| WAVETUNNEL_MAX_BODY_BYTES | 10485760 | Max body forwarded (10 MB) |
| WAVETUNNEL_INSPECT | (enabled) | Set to 0/false/off to disable the inspection dashboard |
| WAVETUNNEL_INSPECT_PORT | 4040 | Inspection dashboard loopback port |
| WAVETUNNEL_BASIC_AUTH | (none) | user:pass Basic-auth gate for HTTP tunnels |
| WAVETUNNEL_TUNNELS_FILE | ~/.wavetunnel/tunnels.json | Multi-tunnel config used by wavetunnel start |
| WAVETUNNEL_NO_SAVE_TOKEN | (unset) | Set to 1 to skip persistence |
Local config
~/.wavetunnel/config.json (Linux/macOS) or
%USERPROFILE%\.wavetunnel\config.json (Windows). Mode 0600. Stores one
entry per gateway URL. Delete the file to force a re-login.
Auto-update
When installed globally (npm install -g wavetunnel), the CLI keeps itself
up to date automatically. On each run it checks the npm registry for a newer
version — at most once every 24h — and if one exists, runs
npm install -g wavetunnel@latest and transparently restarts on the new
version.
The check is best-effort and never blocks a tunnel: if the registry is unreachable or the install fails, the CLI continues on the current version.
| Control | Effect |
|---|---|
| wavetunnel --no-update | Skip the update check for this run |
| WAVETUNNEL_UPDATE_MODE=auto | Check, install, and restart (default) |
| WAVETUNNEL_UPDATE_MODE=notify | Only print a hint when an update exists; don't install |
| WAVETUNNEL_UPDATE_MODE=off | Disable auto-update entirely |
| WAVETUNNEL_NO_UPDATE=1 | Disable auto-update entirely (same as off) |
| WAVETUNNEL_UPDATE_INTERVAL_HOURS=N | Change the check interval (default 24) |
| CI env set | Auto-update is disabled in CI |
Dev checkouts (running from a cloned repo rather than a global install) are never auto-updated.
Security notes
- Access tokens never appear in CLI output. Use
wavetunnel whoamito inspect your session, not the file. - The gateway uses a per-tunnel session secret to authorize the WebSocket —
the long-lived access token is only used for the control-plane call to
/api/tunnels. The session secret never leaves disk. wavetunnel logoutrevokes the token server-side and clears it locally.- If you suspect a token leak, run
wavetunnel logoutfrom any session that has it, or revoke viaDELETE /api/auth/tokendirectly.
License
MIT
