Hosting WireGuard over Wstunnel on NixOS

2023-12-15



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:

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:

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:

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

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.


← Back to all articles