WireGuard is a fantastic VPN: fast, simple, silent. But it speaks UDP, and that is exactly its weakness in the wild. Hotel Wi-Fi, corporate firewalls, mobile carriers behind CGNAT — plenty of networks either drop UDP outright or rate-limit it into uselessness. Some middleboxes even try to be clever about classifying WireGuard’s handshake and block it specifically.
The fix is to stop fighting: wrap the whole tunnel inside a WebSocket over TLS. To anyone watching, your traffic is an ordinary HTTPS connection to a web server — because it literally is one, terminated by nginx. This post describes the setup I run at home:
- Server: a NixOS box (
base1) running WireGuard, wstunnel and nginx - Client: laptops with a small
wgctl/wgexecpair of shell scripts that manage WireGuard inside Linux network namespaces
The big picture
client wg1 (192.168.55.2)
│ UDP
▼
127.0.0.1:51820 ← wstunnel client
│ WebSocket inside TLS (looks like HTTPS)
▼
nginx :443/secret_path/ ──► wstunnel server :33344 (localhost)
│ --restrict-to
▼
127.0.0.1:51820 (WireGuard listenPort)
│
▼
wg1 (192.168.55.1)
│
▼
MASQUERADE
│
▼
WAN interface (internet)
The client’s WireGuard peer endpoint is 127.0.0.1:51820 — a local UDP
port where the wstunnel client listens. Every WG packet is wrapped in a
WebSocket frame, carried over TLS through nginx, unwrapped by the wstunnel
server and delivered to the real WireGuard socket on localhost. Nobody on
the path ever sees a UDP packet that isn’t loopback.
Server: the WireGuard hub
First, the tunnel itself — a classic “router” style WireGuard interface from
wg1.nix:
{ pkgs, ... }:
{
networking.wireguard.interfaces = {
# "wg1" is the network interface name. You can name the interface arbitrarily.
wg1 = {
# Determines the IP address and subnet of the server's end of the tunnel interface.
ips = [ "192.168.55.1/24" ];
# The port that WireGuard listens to. Must be accessible by the client.
listenPort = 51820;
# This allows the wireguard server to route your traffic to the internet and hence be like a VPN
# For this to work you have to set the dnsserver IP of your router (or dnsserver of choice) in your clients
postSetup = ''
${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -s 192.168.55.0/24 -o br0 -j MASQUERADE
'';
# This undoes the above command
postShutdown = ''
${pkgs.iptables}/bin/iptables -t nat -D POSTROUTING -s 192.168.55.0/24 -o br0 -j MASQUERADE
'';
# Path to the private key file.
#
# Note: The private key can also be included inline via the privateKey option,
# but this makes the private key world-readable; thus, using privateKeyFile is
# recommended.
privateKey = "<server-private-key>"; # keep this secret!
# List of allowed peers.
peers = [
{
publicKey = "<public-key>";
allowedIPs = [ "192.168.55.2/32" ];
}
{
publicKey = "<public-key>";
allowedIPs = [ "192.168.55.3/32" ];
}
];
};
};
}
A few things worth pointing out:
- Peers get
/32routes.allowedIPshere is the crypto-key routing table towards the client — each peer owns exactly one address, so client-to-client traffic has to go through the box (you can add tighter filtering later if you care). MASQUERADEonbr0is what turns the WG subnet into a real VPN: packets leaving for the internet are NATted behind the server’s LAN address.postSetup/postShutdownkeep the iptables rule in sync with the interface’s lifetime.- In the actual module the key sits inline for historical reasons; as the
comment says, prefer
privateKeyFile— anything inprivateKeyends up in the world-readable Nix store. (The same applies to the client keys in the comments, which I redacted here.)
Note that listenPort = 51820 does not need to be reachable from the
internet anymore. The only thing exposed on the WAN is nginx’s 443.
Server: wstunnel as a systemd service
wstunnel is a tiny Rust binary with a client and a server mode. On the
server we only need this, from the same wg1.nix:
systemd.services."wstunnel-wg1" = {
enable = true;
description = "wstunnel-server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "simple";
Restart = "always";
ExecStart = "${pkgs.wstunnel}/bin/wstunnel server --restrict-to 127.0.0.1:51820 ws://127.0.0.1:33344";
};
};
Two details do the security work here:
ws://127.0.0.1:33344— the WebSocket listener binds to loopback only. It never sees TLS because nginx already terminated it; there is simply no reason to expose plaintext WS to the network.--restrict-to 127.0.0.1:51820— the server will only open connections to that exact address, i.e. the WireGuard socket. Even if someone finds your WebSocket endpoint, they cannot abuse it as an open proxy or SSRF gadget; the worst they can do is talk to your WireGuard port, which discards anything that isn’t a valid WG packet for a known peer.
Server: nginx, TLS and a secret path
Finally the public face, from nginx.nix:
{
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
# Virtual Host Configuration
virtualHosts."yourdomain" = {
forceSSL = true;
# SSL Certificates
sslCertificate = "/etc/letsencrypt/archive/yourdomain/fullchain.pem";
sslCertificateKey = "/etc/letsencrypt/archive/yourdomain/privkey.pem";
# Enable HTTP/2
http2 = true;
# Root directory
root = "/var/www/htdocs/yourblog";
# Locations
locations."/" = {
extraConfig = ''
autoindex off;
'';
};
# wstunnel path
locations."/secret_path/" = {
proxyPass = "http://127.0.0.1:33344";
proxyWebsockets = true;
};
# get_ip endpoint
locations."/get_ip" = {
extraConfig = ''
default_type text/plain;
return 200 "$remote_addr\n";
'';
};
};
};
}
The interesting part is:
locations."/secret_path/" = {
proxyPass = "http://127.0.0.1:33344";
proxyWebsockets = true;
};
proxyWebsockets = true makes nginx speak the Upgrade/Connection: upgrade dance required for WebSockets. The random-looking path acts as a
poor-man’s shared secret — be honest about what it is: obscurity, not
authentication. It keeps scanners and opportunistic bots from wasting your
wstunnel’s time, but the real guarantees are TLS, --restrict-to, and
WireGuard’s own cryptokey routing. Treat the path like you’d treat any
other credential in configs and rotate it if it leaks.
The /get_ip endpoint is a free bonus for debugging: curl https://yourdomain/get_ip tells you which address the server sees
you from — perfect for a quick “is my traffic really going through the
tunnel?” check.
Client side
The tunnel
On the client, wstunnel runs in the opposite direction. It listens on a local UDP port and forwards everything through the WebSocket:
$ wstunnel client -L 'udp://127.0.0.1:51820:127.0.0.1:51820' \
wss://yourdomain:443/secret_path/
(On NixOS I just have wstunnel in my environment.systemPackages, on
other systems grab a release binary.)
And the matching WireGuard config, /etc/wireguard/networks/wg1/wg1.conf:
[Interface]
PrivateKey = <client-private-key>
Address = 192.168.55.2/24
# keep the NAT mapping alive through idle periods
PersistentKeepalive = 25
[Peer]
# server's public key
PublicKey = <server-public-key>
# the local wstunnel listener
Endpoint = 127.0.0.1:51820
AllowedIPs = 0.0.0.0/0
AllowedIPs = 0.0.0.0/0 routes everything through the tunnel; narrow it
down (e.g. 192.168.0.0/16) if you only want to reach home LAN.
At this point wg-quick up wg1 already works. But I wanted more than
“works” — I wanted to choose, per boot or per session, whether the whole
machine goes through the tunnel or only specific programs do.
Managing it all with wgctl
wgctl is a small POSIX shell script that wraps
all of this into up, down, restart, ls and status subcommands and
implements three modes via network namespaces:
$ sudo wgctl ls # list configured networks
$ sudo wgctl up wg1 # bring one up
$ sudo wgctl status # wg show + which namespace we're in
Its per-network configuration lives under
/etc/wireguard/networks/<name>/:
/etc/wireguard/networks/wg1/
├── wg1.conf # the wireguard config (wg setconf format)
├── config # shell vars: namespace, reverse_netns, ip
├── preup.sh # optional hooks, run if executable
├── postup.sh
├── predown.sh
└── postdown.sh
Plus a global /etc/wireguard/interfaces exporting the machine’s physical
links (eth, wifi, optional br and dhcp). The config vars decide
the mode:
1. namespace=false — plain wg-quick. The interface lives in the root
namespace, nothing fancy. Good default.
2. namespace=true, reverse_netns=false — “VPN mode”. This is the
invasive one: wgctl up stops the native wpa_supplicant/dhcpcd
services, moves the physical interfaces (including the whole wifi PHY
via iw phy … set netns) into a new physical namespace, restarts
DHCP/wifi management inside it, creates the WireGuard interface in the
root namespace and points the default route at the tunnel. The machine now
has exactly one way to reach the internet: through wg1. Take it down and
everything is moved back and the services restarted. Full-tunnel on a
laptop, with no leaked traffic while it’s up.
3. namespace=true, reverse_netns=true — “app mode”. The mirror
image: the physical interfaces stay put in the root namespace, and a new
wg namespace gets the WireGuard interface and a default route through
it. The host’s connectivity is untouched; only programs you explicitly
run inside the namespace use the tunnel.
Which brings us to the companion script, wgexec:
#!/bin/sh
sudo -E ip netns exec wg sudo -E -u \#$(id -u) -g \#$(id -g) -- "$@"
Nine lines, and the reason the whole setup is ergonomic. It executes any
command inside the wg namespace as your own uid/gid (not root), with
your environment preserved (-E). So:
$ wgexec curl https://yourdomain/get_ip # → 192.168.55.1 saw us via tunnel
$ wgexec ssh some-host
$ wgexec firefox # why not...
Root is only used for the ip netns exec hop itself; sudo -u #uid drops
straight back to you inside the namespace.
Gotchas and notes
- TCP-over-TCP. Wrapping WG’s UDP inside a WebSocket puts TCP reliability under TCP reliability; on lossy links this can interact badly if the inner traffic is also TCP (the classic meltdown). In practice, over decent links, I don’t notice it — but don’t expect magic on 3% loss. If it hurts, consider lowering the tunnel MTU.
- MTU. WebSocket + TLS + HTTP headers eat ~60–70 bytes; WireGuard
already accounts for 80. If PMTUD blackholes appear, set
MTU = 1280in the client[Interface]. PersistentKeepalivematters more than usual: NAT mappings for the loopback UDP socket are cheap to keep, but idle WebSockets behind middleboxes appreciate traffic too.- Key hygiene. Server key via
privateKeyFile; client keys live in/etc/wireguard/networks/…/withchmod 600— rememberwgctlsources these files as root. - The secret path is not auth. It’s fine as a filter, nothing more. Real access control is WG’s own public-key pinning.
wstunnelCLI drift. The flags above match the 5.x/6.x era; newer releases changed syntax (--restrictTo, URI-based-L). Pin a version and read its--help.
Does it work?
$ sudo wgctl up wg1
$ wgexec curl -s https://yourdomain/get_ip
192.168.55.1
$ sudo wgctl status
interface: wg1
public key: <public-key>
private key: (hidden)
listening port: 51820
peer: <peer-key>
endpoint: 127.0.0.1:51820
allowed ips: 0.0.0.0/0
latest handshake: 12 seconds ago
transfer: 1.24 MiB received, 390 KiB sent
netns: wg
A handshake over what any observer would call “HTTPS traffic to some website”. Restrictive networks included.