wstunnel

Some time ago I documented how I configured WireGuard on my OpenWrt router at home, to connect securely to my home network (and the Internet, really) from wherever I am. I feel safer connecting to public Wi-Fi this way, also abroad when it allows me to save extra roaming costs.

However, during my travels I sometimes come across networks which seem to block VPN connections, sometimes including my own personal WireGuard tunnel. For such cases I managed to tunnel my WireGuard connection over HTTPS, which is typically (far) less often blocked.

This posts describes how I’m tunneling my WireGuard tunnels over HTTPS using Wstunnel, by tunneling the traffic over Websockets. I’m using my own website for this, which is already using HTTPS, and a small Wstunnel container which is listening to incoming connections from a specific path on my website. This is what it roughly looks like (PlantUML made a bit of a mess, sorry…​):

overview

First, configure Wstunnel on the server. I’m using a Docker container for this, which I update according to the latest distro’s Wstunnel package version on my laptop. This is the compose.yaml, which I’m running via Dockge on my TrueNAS Community (SCALE):

services:
  wstunnel:
    ports:
      - 33344:33344
    image: ghcr.io/erebe/wstunnel:v10.4.3
    command: /bin/sh -c "exec /home/app/wstunnel server --log-lvl OFF --restrict-to
      192.168.1.1:51820 ws://0.0.0.0:33344"
networks: {}

And this would be the Wstunnel configuration on the laptop, which is a Systemd service file at /etc/systemd/system/wstunnel.service:

[Unit]
Description=Tunnel WG UDP over websocket
After=network-online.target

[Service]
Type=simple
DynamicUser=yes
ExecStartPre=+/usr/bin/env sh -c '/usr/bin/ip route add 4.3.2.1 dev eth0 via $(ip route show default | awk "/default/ {print \$3}")'
ExecStart=/usr/bin/wstunnel client --log-lvl OFF -L 'udp://127.0.0.1:51820:192.168.1.1:51820' wss://domain.tld --http-upgrade-path-prefix sh4r3d-s3cr3t-p4th
ExecStopPost=+/usr/bin/env sh -c '/usr/bin/ip route del 4.3.2.1 dev eth0 via $(ip route show default | awk "/default/ {print \$3}")'
Restart=no

[Install]
WantedBy=multi-user.target
Note

I’m hard-coding a static route which is automatically added when the service starts, to make sure my router’s public IP address is always known to be reachable via the laptop’s default gateway. And when the service stops, the route is automatically cleaned up as well.

Also note the --http-upgrade-prefix denotes the path on the web server which, when hit, will be reverse proxied to the Wstunnel container on the server. This also servers as a shared secret, so please change this to a secret of your choosing.

Next, make sure your web server / reverse proxy can handle this incoming connection on that particular path. Here’s an example when using Caddy:

domain.tld {
  handle /sh4r3d-s3cr3t-p4th/* {
    reverse_proxy http://192.168.1.2:33344
  }
  handle {
    <your regular website config>
  }
  header {
    ...
  }
  tls {
    ...
  }
  log {
    ...
  }
}

Don’t forget to reload Caddy after you’re done.

Then configure a new VPN profile on the laptop, which will be using Wstunnel. I’m using a NetworkManager connection profile for this, at /etc/NetworkManager/system-connections/wg-wstunnel.nmconnection:

[connection]
id=wg-wstunnel
uuid=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
type=wireguard
interface-name=wg-wstunnel
timestamp=1750667207

[wireguard]
private-key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=

[wireguard-peer.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=]
endpoint=127.0.0.1:51820
preshared-key=ccccccccccccccccccccccccccccccccccccccccccc=
preshared-key-flags=0
allowed-ips=0.0.0.0/0;::/0;

[ipv4]
address1=192.168.2.2/32
dns=192.168.1.1;
dns-search=~;
method=manual

[ipv6]
addr-gen-mode=default
address1=2a10:aaaa:bbbb:1337::2/128
dns=fd7c:aaaa:bbbb::1;
dns-search=~;
method=manual

[proxy]
Note
The above is automatically generated when importing a existing WireGuard profile in NetworkManager. You may wish to do that instead to end up with such an automatically generate NetworkManager connection profile.

Then perform a Systemd reload, start Wstunnel, and then start the WireGuard tunnel:

root # systemctl daemon-reload
root # systemctl start wstunnel.service
root # nmcli connection up wg-wstunnel

When all is working as expected, you should see a working connection on your laptop:

root # wg
interface: wg-wstunnel
  public key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=
  private key: (hidden)
  listening port: 56993
  fwmark: 0xcba1

peer: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=
  preshared key: (hidden)
  endpoint: 127.0.0.1:51820
  allowed ips: 0.0.0.0/0, ::/0
  latest handshake: 2 seconds ago
  transfer: 1.26 KiB received, 1.16 KiB sent
Note
Notice the peer endpoint is using the local Wstunnel port on localhost. Wstunnel is transparently taking care of everything else.

So far I’ve managed to make this work on my laptop, but not on my phone. AFAIK there’s no easy way to use Wstunnel on Android. Perhaps it can be done with e.g. Termux, but I have not looked into this yet. If someone else knows, please let me know below.