Documentation

Everything from the first tunnel to locking down a server. Commands are shown for a Unix shell; the same flags work on Windows.

Overview

Most public networks sit behind a firewall or proxy whose job is to let only certain protocols through, and HTTP is the one nearly everything allows. wstunnel uses the WebSocket protocol, which is negotiated over HTTP, to carry any other traffic through that opening. From the outside it looks like a browser holding a long-lived connection to a website.

It ships as one static binary that runs in two modes. wstunnel server listens on a host you control. wstunnel client runs on the constrained machine, connects out to the server, and exposes local ports (or a SOCKS5/HTTP proxy, or stdin) whose traffic comes out on the server's side. Reverse tunnels flip the direction so the server listens and forwards back to you.

The project began as a Rust reimplementation of an npm tool of the same name, written so that nothing more than a single binary is needed. It now supports:

  • Static forward and reverse tunnels for TCP, UDP, Unix sockets and stdio
  • Dynamic tunnels: SOCKS5 proxy, HTTP proxy, and transparent proxy on Linux
  • Connecting out through an HTTP proxy when you are behind one
  • PROXY protocol headers toward the destination
  • TLS with an embedded self-signed certificate or your own, reloaded on change
  • Mutual TLS (client certificates), reloaded on change
  • IPv6 throughout
  • WebSocket, HTTP/2 or WebTransport (HTTP/3 over QUIC) as the transport; WebSocket is the fastest
  • Static binaries you can copy anywhere

The tunnel protocol is deliberately shaped to resemble normal traffic so it is less likely to be flagged, and a single connection is fast enough to saturate a gigabit link.

Install

Static binary

Every release publishes archives for Linux, macOS, Windows and Android on the releases page. Download the one for your platform, unpack it, and put wstunnel somewhere on your PATH.

tar -xzf wstunnel_*_linux_amd64.tar.gz
chmod +x wstunnel
sudo mv wstunnel /usr/local/bin/
wstunnel --version

Docker

Images are published to GitHub's container registry. Both modes are available since it is the same binary.

docker pull ghcr.io/erebe/wstunnel:latest

The package page is at github.com/erebe/wstunnel/pkgs/container/wstunnel.

From source

See Build from source.

Try the demo server

A public demo server lets you check that a tunnel can get out of your network before you set anything up. It allows exactly one tunnel, to a page that returns a short greeting.

# in one terminal; the SNI can be any domain you like
wstunnel client -L 'tcp://4443:10.43.0.11:444' -P demo --tls-sni-override=google.fr wss://49.13.58.9

# in another
curl -k https://localhost:4443
> Memento mori !

If the greeting comes back, WebSocket over TLS is getting through, and the same client command with your own server address will work.

Tunnel syntax

Tunnels follow the shape of SSH's -L and -R flags. Each one is a URL whose scheme picks the protocol, followed by where to listen and where to forward:

scheme://[BIND:]PORT:HOST:PORT
#        ^ listen here          ^ forward there

BIND defaults to the loopback address. Wrap IPv6 addresses in brackets. Dynamic schemes (socks5, http, tproxy) have no fixed destination, so they take only a listen address. Both flags can be repeated to open several tunnels from one client.

Schemes for -L

tcp://1212:google.com:443Listen on local TCP 1212, forward to google.com:443 through the server.
tcp://2:n.lan:4?proxy_protocolSame, but send a PROXY protocol v2 header when connecting to the destination.
udp://1212:1.1.1.1:53Listen on local UDP 1212, forward to Cloudflare DNS.
udp://1212:1.1.1.1:53?timeout_sec=10Close the UDP tunnel after 10 seconds idle. Default 30; 0 disables.
socks5://[::1]:1212Start a SOCKS5 proxy; each connection asks for its own destination.
socks5://[::1]:1212?login=admin&password=adminSOCKS5 with required credentials.
http://[::1]:1212Start an HTTP proxy. Accepts the same login and password parameters.
tproxy+tcp://[::1]:1212Transparent TCP proxy. Linux only, needs root or CAP_NET_ADMIN.
tproxy+udp://[::1]:1212?timeout_sec=10Transparent UDP proxy, same requirements.
stdio://google.com:443Read and write on stdin/stdout instead of a port. Made for ssh -o ProxyCommand.
unix:///tmp/wstunnel.sock:g.com:443Listen on a Unix socket, forward to g.com:443.

Schemes for -R

Reverse tunnels use the same schemes but the listen side is the server and the forward side is your machine.

tcp://1212:google.com:443Server listens on TCP 1212; connections are forwarded to google.com:443 from your machine.
udp://1212:1.1.1.1:53Server listens on UDP 1212, forwards to 1.1.1.1:53 from your machine.
socks5://[::1]:1212Server runs a SOCKS5 proxy whose exits are on your side.
http://[::1]:1212Server runs an HTTP proxy whose exits are on your side. Credentials supported.
unix://wstunnel.sock:g.com:443Server listens on a Unix socket, forwards to g.com:443 from your machine.

For a refresher on the direction of SSH-style tunnels, this article lays it out with diagrams.

Client options

wstunnel client [OPTIONS] <ws[s]|http[s]|wts://wstunnel.server.com[:port]>

The positional argument is the server address, and its scheme picks the transport. wss:// is WebSocket over TLS and ws:// plain WebSocket; https:// or http:// select HTTP/2; wts:// selects WebTransport, which is always over TLS. Use WebSocket unless you have a reason not to; see HTTP/2 and WebTransport.

Tunnels

OptionWhat it does
-L, --local-to-remote <URL>Listen locally and forward through the server. Repeatable. Schemes: tcp, udp, socks5, http, tproxy+tcp, tproxy+udp, stdio, unix.
-R, --remote-to-local <URL>Listen on the server and forward back here. Repeatable. Schemes: tcp, udp, socks5, http, unix.
-c, --connection-min-idle <N>Keep N connections to the server open and ready so new tunnels skip the TCP and TLS handshake. Worth setting when tunnels come and go quickly, as with a browser on SOCKS5. Default 0.
--connection-retry-max-backoff <DURATION>How long to keep retrying a connection to the server before failing the tunnel request. Durations take an s, m or h suffix. Default 5m.
--reverse-tunnel-connection-retry-max-backoff <DURATION>With reverse tunnels the client keeps a connection open to wait for new tunnels; this caps the exponential back-off between reconnect attempts when it drops. Default 1s.

Reaching the server

OptionWhat it does
-p, --http-proxy <USER:PASS@HOST:PORT>env HTTP_PROXYConnect to the server through this HTTP proxy.
--http-proxy-login <LOGIN>
--http-proxy-password <PASSWORD>env WSTUNNEL_HTTP_PROXY_LOGIN, WSTUNNEL_HTTP_PROXY_PASSWORD
Proxy credentials, overriding any in --http-proxy.
--dns-resolver <URL>Resolver for destination names. Repeatable. dns://1.1.1.1 for UDP, dns+https://1.1.1.1?sni=cloudflare-dns.com for DNS over HTTPS, dns+tls://8.8.8.8?sni=dns.google for DNS over TLS, system://0.0.0.0 for the libc resolver. DoH and DoT go through the HTTP proxy if one is configured. On Windows, set this explicitly to avoid a flood of DNS queries.
--dns-resolver-prefer-ipv4env WSTUNNEL_DNS_PREFER_IPV4Try IPv4 addresses before IPv6. Useful when IPv6 is configured but broken and every connection would otherwise wait for it to fail.
--socket-so-mark <INT>Linux only. Set SO_MARK on the client's sockets, for policy routing. Needs root or the right capability.

The upgrade request

OptionWhat it does
-P, --http-upgrade-path-prefix <PREFIX>env WSTUNNEL_HTTP_UPGRADE_PATH_PREFIXPath prefix in the upgrade URL. Lets a server route without virtual hosts, and doubles as a shared secret when the server restricts on it. Default v1.
--http-upgrade-credentials <USER[:PASS]>Send an Authorization header with basic auth during the upgrade.
-H, --http-headers <NAME: VALUE>Add custom headers to the upgrade request. Repeatable.
--http-headers-file <FILE>Same, read from a file of NAME: VALUE lines. Re-read every time and overrides -H.

TLS

OptionWhat it does
--tls-sni-override <DOMAIN>Domain to send as SNI. Behind a CDN such as Cloudflare, also set the same value in the Host header or the request is rejected.
--tls-sni-disableSend no SNI at all. Most reverse proxies rely on it.
--tls-ech-enableUse Encrypted Client Hello toward the server. The ECH configuration is fetched from DNS once at startup and not refreshed.
--tls-verify-certificateVerify the server certificate. Off by default, so the client accepts self-signed certificates.
--tls-certificate <FILE>
--tls-private-key <FILE>
Client certificate (PEM) and key to present for mTLS. Reloaded automatically when the files change.

WebSocket

OptionWhat it does
--websocket-ping-frequency <DURATION>Interval between pings to the server, with an s, m or h suffix. Zero disables them. Default 30s.
--websocket-mask-frameMask WebSocket frames. Only useful with a plain ws:// server that misbehaves without it; otherwise it is overhead.

Logging and runtime

OptionWhat it does
--log-lvl <LEVEL>env RUST_LOGTRACE, DEBUG, INFO, WARN, ERROR or OFF; tracing-subscriber filter syntax is accepted. Default INFO.
--no-colorenv NO_COLORPlain log output.
--nb-worker-threads <N>env TOKIO_WORKER_THREADSNumber of runtime threads, defaulting to the CPU count. The flag is documented but has no effect: set the environment variable.

Server options

wstunnel server [OPTIONS] <ws[s]|wts://0.0.0.0[:port]>

The positional argument is the bind address: wss://0.0.0.0:8080 for TLS, ws://[::]:8080 without. The server detects whether each incoming request is WebSocket or HTTP/2, so nothing needs to be configured for those. wts://0.0.0.0:8080 additionally serves WebTransport on UDP; it is the same as wss://0.0.0.0:8080 --enable-webtransport.

Transports

OptionWhat it does
--enable-webtransportAlso serve WebTransport (HTTP/3 over QUIC) by binding UDP on the same port as the TCP listener. The TCP listener keeps serving WebSocket and HTTP/2, so one server handles all three. Requires wss://, since QUIC mandates TLS 1.3; implied by wts://. Make sure your firewall and any container port mapping forward UDP as well as TCP, or wts:// clients time out during the handshake.

Access control

OptionWhat it does
--restrict-to <HOST:PORT>Only allow tunnels toward these destinations. Repeatable, e.g. --restrict-to google.com:443 --restrict-to localhost:22.
-r, --restrict-http-upgrade-path-prefix <PREFIX>env WSTUNNEL_RESTRICT_HTTP_UPGRADE_PATH_PREFIXOnly accept clients whose upgrade request uses this path prefix. The prefix acts as a shared secret. Repeatable. Off by default, meaning any prefix is accepted.
--restrict-config <FILE>YAML file of restriction rules for finer control. Reloaded automatically when it changes.

TLS

OptionWhat it does
--tls-certificate <FILE>Certificate (PEM) to serve instead of the embedded self-signed one. Reloaded on change.
--tls-private-key <FILE>Matching private key (PEM, EC or RSA). Reloaded on change.
--tls-client-ca-certs <FILE>Turn on mTLS. A PEM bundle of one or more CA certificates; clients must present a certificate signed by one of them. Reloaded on change.

Networking

OptionWhat it does
--dns-resolver <URL>Same forms as on the client. Not used when the client is running a transparent proxy.
--dns-resolver-prefer-ipv4env WSTUNNEL_DNS_PREFER_IPV4Try IPv4 before IPv6 when resolving destinations.
-p, --http-proxy <USER:PASS@HOST:PORT>env HTTP_PROXY; login and password overrides as on the clientRoute the server's outbound connections through an HTTP proxy.
--socket-so-mark <INT>Linux only. Set SO_MARK on the server's sockets. Needs root or a capability.
--websocket-ping-frequency <DURATION>Interval between pings to clients. Zero disables them.
--websocket-mask-frameMask frames; see the client option.
--remote-to-local-server-idle-timeout <DURATION>How long a reverse-tunnel listener stays bound on the server with no client connected before it is torn down. Default 3m.

Logging and runtime

--log-lvl, --no-color and --nb-worker-threads behave exactly as on the client.

URL parameters

A few behaviours are set per tunnel with query parameters on the tunnel URL. Quote the URL in your shell so ? and & survive.

ParameterApplies to
?proxy_protocoltcp forward tunnels. Send a PROXY protocol v2 header when connecting to the destination, so it learns the original client address.
?timeout_sec=Nudp and tproxy+udp. Close the tunnel after N idle seconds. Default 30; 0 disables the timeout, which VPN traffic usually wants.
?login=USER&password=PASSsocks5 and http proxies. Require these credentials from programs using the proxy.

Browse through SOCKS5

The simplest useful setup: a SOCKS5 proxy on your machine whose traffic exits on the server. Start the server where it has open internet access.

wstunnel server wss://[::]:8080

Then on the client:

wstunnel client -L socks5://127.0.0.1:8888 --connection-min-idle 5 wss://myRemoteHost:8080

This opens a SOCKS5 server on the loopback interface, port 8888. --connection-min-idle 5 keeps five connections to the server pre-established so each new browser connection does not pay for a TCP and TLS handshake.

In Firefox, open the network settings, choose manual proxy, and enter 127.0.0.1 port 8888 as a SOCKS v5 proxy. Tick Proxy DNS when using SOCKS v5; otherwise names are resolved by your machine, which is usually what you are trying to avoid. With curl, the h in socks5h does the same:

curl -x socks5h://127.0.0.1:8888 http://google.com/

SSH ProxyCommand

With stdio as the source, wstunnel moves bytes between its standard streams and the tunnel, which makes it a drop-in ProxyCommand for ssh. Turn logging off so nothing is written to the stream.

ssh -o ProxyCommand="wstunnel client --log-lvl=off -L stdio://%h:%p ws://myRemoteHost:8080" my-server

Put the same line under a Host block in ~/.ssh/config to make it permanent.

Behind a corporate proxy

When the only way out is an HTTP proxy, run the server on port 443 with TLS and let wstunnel negotiate through the proxy. Here the server is limited to forwarding SSH.

wstunnel server wss://[::]:443 --restrict-to 127.0.0.1:22

The default certificate is self-signed and uses deliberately weak cryptography to keep overhead low while staying valid TLS. Do not rely on wstunnel itself to protect your privacy: forward traffic that is already secure by design, such as SSH, HTTPS or a VPN.

On the client, name the proxy with -p:

wstunnel client -L tcp://9999:127.0.0.1:22 -p http://mycorporateproxy:8080 wss://myRemoteHost:443

Local port 9999 now leads to the server's SSH daemon:

ssh -p 9999 login@127.0.0.1

WireGuard

WireGuard is UDP, which many networks block outright. Wrapping it in wstunnel gets the VPN through. Hetzner's community has a detailed tutorial; the short version follows.

Start from a working client configuration such as:

[Interface]
Address = 10.200.0.2/32, fd00:cafe::2/128
PrivateKey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=

[Peer]
PublicKey = 9iicV7Stdl/U0RH1BNf3VvlVjaa4Eus6QPEfEz6cR0c=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = my.server.com:51820

On the server, run wstunnel restricted to the local WireGuard port:

wstunnel server --restrict-to localhost:51820 wss://[::]:443

On the client, forward UDP 51820 and disable the idle timeout:

wstunnel client -L 'udp://51820:localhost:51820?timeout_sec=0' wss://my.server.com:443

Change the WireGuard configuration so the peer endpoint is the local tunnel, the MTU leaves room for the extra layers, and DNS points somewhere the server can reach:

[Interface]
Address = 10.200.0.2/32, fd00:cafe::2/128
PrivateKey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=
# a resolver your server can reach
DNS = 8.8.8.8
# https://github.com/nitred/nr-wg-mtu-finder helps find the best value
MTU = 1400

[Peer]
PublicKey = 9iicV7Stdl/U0RH1BNf3VvlVjaa4Eus6QPEfEz6cR0c=
AllowedIPs = 0.0.0.0/0, ::/0
# where the wstunnel client is listening
Endpoint = localhost:51820
# not needed if websocket pings are on
PersistentKeepalive = 20

Because AllowedIPs catches everything, add a route so packets to the wstunnel server bypass the VPN. Without it the traffic loops: WireGuard into wstunnel into WireGuard.

# replace eth0 and 192.168.0.1 with the interface and gateway from `ip route get ip.of.my.server.com`
sudo ip route add ip.of.my.server.com dev eth0 via 192.168.0.1

sudo wg-quick up wg0
ping 10.200.0.1

If it misbehaves

  • Tunnels closing after 30 seconds: you forgot ?timeout_sec=0 on the UDP tunnel.
  • Poor throughput: lower the WireGuard MTU further, to around 1300. Fragmented UDP is always trouble.
  • wstunnel cannot reach the server once WireGuard is up: the static route is missing and the traffic is looping.
  • On Windows, see issue #252.

Android

Releases include an Android build. Run it inside Termux the same way as on a desktop. The WireGuard tutorial covers an Android setup end to end.

Transparent proxy

Linux only. A transparent proxy lets you route any program through the tunnel without teaching it about proxies. Start the client with both TCP and UDP tproxy listeners; this needs root or CAP_NET_ADMIN.

sudo wstunnel client -L 'tproxy+tcp://1080' -L 'tproxy+udp://1080' wss://my.server.com:443

Then use cproxy to send a command's traffic to that port. It works with a single command or a whole shell.

cproxy --port 1080 --mode tproxy -- curl https://google.com

# everything started from this shell is proxied
cproxy --port 1080 --mode tproxy -- bash

Reverse tunneling

With -R the server listens and forwards back through the tunnel to your machine. This is how you expose something behind NAT or a firewall you cannot open.

wstunnel client -R 'tcp://[::]:8000:localhost:8000' wss://my.server.com:443

Start something local to serve:

python3 -m http.server

From the server, or anything that can reach the server's port 8000:

curl http://localhost:8000

Reverse tunnels are allowed by default. A server operator can forbid them, restrict the ports, or remap the requested port with a restriction file.

Secret path prefix

Without restrictions, anyone who finds your server can open tunnels through it. The cheapest fix is to agree on a secret that clients send as the path prefix of the upgrade request. Generate something long and random:

wstunnel server --restrict-http-upgrade-path-prefix h3GywpDrP6gJEdZ6xbJbZZVFmvFZDCa4KcRd wss://[::]:443
wstunnel client --http-upgrade-path-prefix h3GywpDrP6gJEdZ6xbJbZZVFmvFZDCa4KcRd -L socks5://127.0.0.1:8888 wss://myRemoteHost

The server now refuses any upgrade whose path does not start with the secret. Combine it with --restrict-to to also limit destinations, or move to a restriction file for anything more involved.

Restriction rules

--restrict-config points at a YAML file describing what is allowed. The model is a whitelist: every request is denied unless a restriction matches it and allows the tunnel. The file is watched and reloaded when it changes, so rules can be adjusted without restarting.

Each restriction has a match list and an allow list.

  • match is a logical AND. Matchers are !PathPrefix with a regex against the upgrade path, !Authorization with a regex against the Authorization header (which must then be present), or !Any. Regexes match anywhere, so anchor with ^ and $ for an exact match.
  • allow is checked in order; the first entry that fits permits the request. !Tunnel covers forward tunnels and can constrain protocol, port, host (regex) and cidr. !ReverseTunnel covers reverse tunnels and adds port_mapping and unix_path. An empty list means everything of that kind is allowed; an empty allow forbids everything.

A permissive example with every field shown:

restrictions:
  - name: "Allow all"
    description: "This restriction allows all requests"
    match:
      - !PathPrefix "^.*$"
      # - !Authorization "^[Bb]earer +actual_bearer_token_to_match$"
      # - !Any
    allow:
      - !Tunnel
        protocol: [Tcp, Udp]        # empty list means any protocol
        port: [80, 443, 8080..8089] # single ports or inclusive ranges
        host: ^.*$                  # regex on the requested hostname
        cidr: [0.0.0.0/0, ::/0]     # requested IP must fall in one of these
      - !ReverseTunnel
        protocol: [Tcp, Udp, Socks5, Unix]
        port: [1..65535]
        # a client asking for -R tcp://10001:... gets the server listening on 8080
        # the requested port (10001) must still be allowed above
        port_mapping: [10001:8080]
        cidr: [0.0.0.0/0, ::/0]
        unix_path: "^.*$"

Smaller rules for common needs:

# only forward tunnels to local ssh, no reverse tunnels
restrictions:
  - name: "ssh only"
    match: [!PathPrefix "^.*$"]
    allow:
      - !Tunnel
        protocol: [Tcp]
        port: [22]
        host: ^localhost$
        cidr: [127.0.0.1/32]

---
# clients that know the secret path can do anything
restrictions:
  - name: "trusted"
    match: [!PathPrefix "^my-super-secret-path$"]
    allow: [!Tunnel, !ReverseTunnel]

---
# reverse socks5 only, on ports 1080 to 1443, bound to the LAN
restrictions:
  - name: "lan socks"
    match: [!PathPrefix "^.*$"]
    allow:
      - !ReverseTunnel
        protocol: [Socks5]
        port: [1080..1443]
        cidr: [192.168.0.0/16]

The full annotated file lives in the repository as restrictions.yaml.

mTLS

Mutual TLS makes the server demand a client certificate signed by a CA you choose, which is a much stronger gate than a path prefix. You need a CA, a server certificate and one certificate per client; the repository's mTLS guide walks through creating all three with OpenSSL for development. For production, use a real PKI such as OpenBao, EJBCA or Dogtag.

One detail matters when issuing client certificates: the common name must contain only characters valid in a URL.

Server

Serve your own certificate and name the CA bundle that client certificates must chain to. All three files are reloaded when they change.

wstunnel server \
   --tls-certificate ./certs/wstunnel-server.cert.pem \
   --tls-private-key ./private/wstunnel-server.pem \
   --tls-client-ca-certs ./certs/ca.cert.pem \
   wss://0.0.0.0:8443

You can check the handshake with OpenSSL before involving a wstunnel client. A certificate from another CA, or no certificate, is rejected.

openssl s_client -connect 127.0.0.1:8443 \
   -key ./private/wstunnel-client-1.pem \
   -cert ./certs/wstunnel-client-1.cert.pem \
   -cert_chain ./certs/ca.cert.pem \
   -state -debug

Client

wstunnel client \
   --tls-certificate ./certs/wstunnel-client-1.cert.pem \
   --tls-private-key ./private/wstunnel-client-1.pem \
   -L tcp://1212:localhost:1313 \
   wss://127.0.0.1:8443

Caveats

  • Not a privacy tool. wstunnel gets traffic through; it does not make it secret. Tunnel protocols that are already encrypted end to end.
  • The embedded certificate is shared. It is self-signed, intentionally weak, and the same in every copy of wstunnel, so it can be fingerprinted. Serve your own certificate, ideally one from a public CA.
  • The client trusts any server by default. Certificate verification is off unless you pass --tls-verify-certificate.
  • UDP tunnels time out. After 30 idle seconds by default. Use ?timeout_sec=0 for VPNs and other long-lived flows.
  • HTTP/2 needs a direct path. Reverse proxies and CDNs buffer or downgrade it; see HTTP/2.
  • WebTransport needs UDP. If the port is only forwarded for TCP, the QUIC handshake times out after 10 seconds. It also cannot go through --http-proxy; see WebTransport.
  • Windows resolves aggressively. Set --dns-resolver explicitly to avoid excessive DNS queries.
  • --nb-worker-threads does nothing. Set TOKIO_WORKER_THREADS instead.

HTTP/2

WebSocket is the default transport and the fastest one. HTTP/2 exists for networks that block WebSocket upgrades. The server needs no change; on the client, use https:// instead of wss://.

wstunnel server wss://[::]:8080

wstunnel client -L socks5://127.0.0.1:8888 https://myRemoteHost:8080

HTTP/2 is harder to get working. A reverse proxy or CDN in front of the server, Cloudflare included, will usually buffer the entire request body before forwarding it, which cannot work for a stream. Most reverse proxies such as nginx also convert HTTP/2 to HTTP/1 toward the backend, and HTTP/1 has no natural streaming.

The only reliable arrangement is a wstunnel server exposed directly to the internet with nothing in front of it.

Some middleboxes also dislike requests with no Content-Length, or with a Content-Type of application/octet-stream. Use -H to set headers that look more like the traffic they expect.

WebTransport

WebTransport runs on HTTP/3, which runs on QUIC, which runs on UDP. Reach for it when TCP is throttled or blocked but UDP is not, or when a lossy link (mobile, long-haul Wi-Fi) makes TCP's head-of-line blocking hurt.

The server does not serve it by default because it needs a UDP socket. Turn it on with --enable-webtransport, and it listens on UDP on the same port as its TCP listener:

wstunnel server --enable-webtransport wss://[::]:8080

The wts:// scheme is shorthand for the same thing:

wstunnel server wts://[::]:8080

Either way the TCP listener keeps running, so one server serves WebSocket, HTTP/2 and WebTransport clients at the same time. On the client, the only change is wts:// instead of wss://:

wstunnel client -L socks5://127.0.0.1:8888 wts://myRemoteHost:8080

UDP must be reachable end to end on that port. A firewall, cloud security group or docker -p mapping that only forwards TCP makes the handshake time out after 10 seconds with a message asking whether UDP is reachable. This is by far the most common reason a wts:// tunnel does not connect.

  • TLS is always used, because QUIC mandates TLS 1.3. There is no cleartext variant and no wt://.
  • --http-proxy does not work: an HTTP CONNECT proxy tunnels TCP, and QUIC is UDP. Use wss:// or https:// when you must go through a proxy.
  • --tls-sni-disable and --tls-ech-enable are not supported. --tls-sni-override works.
  • A reverse proxy in front of wstunnel would have to forward QUIC/UDP rather than terminate HTTP/3. Most do not, so as with HTTP/2 you generally want the server directly exposed.

mTLS, the path-prefix secret, restriction rules and certificate auto-reload all behave exactly as they do over WebSocket.

Staying discreet

If the point is to avoid being noticed, a few choices make the tunnel blend in better.

  • Use TLS (wss://) with your own certificate. The embedded one is shared by every install and easily recognised, and self-signed certificates look suspicious on their own. A certificate from Let's Encrypt is ideal.
  • Set a custom --http-upgrade-path-prefix so your upgrade requests do not share a URL with every other wstunnel user.
  • Override the SNI with --tls-sni-override to a domain the network already permits. This only works when the server is exposed directly; a reverse proxy or CDN will route on the SNI and send the request elsewhere.

Build from source

Install Rust from rust-lang.org, or with rustup directly:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Then, at the root of the repository:

cargo build --package wstunnel-cli
target/debug/wstunnel --help

# optimized build
cargo build --release --package wstunnel-cli
target/release/wstunnel --help

The repository also carries a justfile, a Cross.toml for cross-compilation and a Dockerfile for the published image.

The Rust rewrite

Version 7.0.0 was a complete rewrite from Haskell to Rust and is not compatible with earlier releases. The Haskell code is preserved on the haskell branch.

What changed with the rewrite:

  • Higher throughput and less jitter without the Haskell garbage collector; a single connection can now saturate a gigabit link.
  • A more consistent command line, where every tunnel flag can be repeated.
  • A tunnel protocol shaped to resemble ordinary traffic.
  • Reverse tunneling.
  • ARMv7 builds again, for Raspberry Pi and similar, after newer GHC releases dropped that target.
  • Easier maintenance for the author, who no longer works in Haskell day to day.

Bug reports and questions go to the issue tracker and discussions. If wstunnel is useful to you, the author accepts support on Ko-fi.