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 --versionDocker
Images are published to GitHub's container registry. Both modes are available since it is the same binary.
docker pull ghcr.io/erebe/wstunnel:latestThe 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 thereBIND 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
| Option | What 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
| Option | What it does |
|---|---|
-p, --http-proxy <USER:PASS@HOST:PORT>env HTTP_PROXY | Connect 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_IPV4 | Try 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
| Option | What it does |
|---|---|
-P, --http-upgrade-path-prefix <PREFIX>env WSTUNNEL_HTTP_UPGRADE_PATH_PREFIX | Path 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
| Option | What 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-disable | Send no SNI at all. Most reverse proxies rely on it. |
--tls-ech-enable | Use Encrypted Client Hello toward the server. The ECH configuration is fetched from DNS once at startup and not refreshed. |
--tls-verify-certificate | Verify 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
| Option | What 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-frame | Mask WebSocket frames. Only useful with a plain ws:// server that misbehaves without it; otherwise it is overhead. |
Logging and runtime
| Option | What it does |
|---|---|
--log-lvl <LEVEL>env RUST_LOG | TRACE, DEBUG, INFO, WARN, ERROR or OFF; tracing-subscriber filter syntax is accepted. Default INFO. |
--no-colorenv NO_COLOR | Plain log output. |
--nb-worker-threads <N>env TOKIO_WORKER_THREADS | Number 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
| Option | What it does |
|---|---|
--enable-webtransport | Also 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
| Option | What 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_PREFIX | Only 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
| Option | What 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
| Option | What 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_IPV4 | Try IPv4 before IPv6 when resolving destinations. |
-p, --http-proxy <USER:PASS@HOST:PORT>env HTTP_PROXY; login and password overrides as on the client | Route 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-frame | Mask 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.
| Parameter | Applies to |
|---|---|
?proxy_protocol | tcp forward tunnels. Send a PROXY protocol v2 header when connecting to the destination, so it learns the original client address. |
?timeout_sec=N | udp and tproxy+udp. Close the tunnel after N idle seconds. Default 30; 0 disables the timeout, which VPN traffic usually wants. |
?login=USER&password=PASS | socks5 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://[::]:8080Then on the client:
wstunnel client -L socks5://127.0.0.1:8888 --connection-min-idle 5 wss://myRemoteHost:8080This 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-serverPut 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:22The 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:443Local port 9999 now leads to the server's SSH daemon:
ssh -p 9999 login@127.0.0.1WireGuard
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:51820On the server, run wstunnel restricted to the local WireGuard port:
wstunnel server --restrict-to localhost:51820 wss://[::]:443On the client, forward UDP 51820 and disable the idle timeout:
wstunnel client -L 'udp://51820:localhost:51820?timeout_sec=0' wss://my.server.com:443Change 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 = 20Because 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.1If it misbehaves
- Tunnels closing after 30 seconds: you forgot
?timeout_sec=0on 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:443Then 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 -- bashReverse 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:443Start something local to serve:
python3 -m http.serverFrom the server, or anything that can reach the server's port 8000:
curl http://localhost:8000Reverse 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://[::]:443wstunnel client --http-upgrade-path-prefix h3GywpDrP6gJEdZ6xbJbZZVFmvFZDCa4KcRd -L socks5://127.0.0.1:8888 wss://myRemoteHostThe 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
!PathPrefixwith a regex against the upgrade path,!Authorizationwith 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.
!Tunnelcovers forward tunnels and can constrainprotocol,port,host(regex) andcidr.!ReverseTunnelcovers reverse tunnels and addsport_mappingandunix_path. An empty list means everything of that kind is allowed; an emptyallowforbids 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:8443You 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 -debugClient
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:8443Caveats
- 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=0for 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-resolverexplicitly to avoid excessive DNS queries. --nb-worker-threadsdoes nothing. SetTOKIO_WORKER_THREADSinstead.
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:8080HTTP/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://[::]:8080The wts:// scheme is shorthand for the same thing:
wstunnel server wts://[::]:8080Either 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:8080UDP 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-proxydoes not work: an HTTP CONNECT proxy tunnels TCP, and QUIC is UDP. Usewss://orhttps://when you must go through a proxy.--tls-sni-disableand--tls-ech-enableare not supported.--tls-sni-overrideworks.- 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-prefixso your upgrade requests do not share a URL with every other wstunnel user. - Override the SNI with
--tls-sni-overrideto 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 | shThen, 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 --helpThe 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.