OpenBSD version: 7.1 (this time for real) and some Ubuntu 22.04
Arch:            Any
NSFP:            This might be one of the most stupid things i ever did.

« Part 4: Fun with routing <> Part 6: TBD »

NAT, Network Address Translation, is the bane of IPv4, and alltogether a rather useful tool to do things you shouldn’t. Furthermore, it has a tendency of coming with $most sensible operating systems you’d want to deploy on any form of server-y host just out-of-the box. Hence, if we want to spread our little VPN endeavour over more entry points… well, it might be a rather nice tool.

Use-cases for spreading are plentyful. It is always a sensible idea to have rather easy-to-deploy endpoints all around the Internet, in case some non-ping people decide that dropping packets is in order for our funny VPN endpoints. So, whenever we have a system somewhere that can be reached by people needing a bit more of less filtered connectivity to the rest of the world, and a VPN endpoint that can not reached by those people directly, while being reachable by the system they can reach… well, we have a supreme case just begging to see some address translation. Luckily, especially wireguard is very forgiving when it comes to playing around with source and destination addresses. An added benefit of a network address translation based solution–apart from less effort–is that technically no key material has to be on that box. This, of course, changes a bit if we also want to proxy our webinterface. While, technically, we can make things work without key material on our NAT-proxy-box, an adversary always could get an additional set of keys…

Basic topology

So, our basic topology is rather simple; We have a NAT Reflector, which has a public IP address ( In addition, we have our VPN endpoint from the prior ‘experiments’. Between those two boxes is the wild wide Internet, and our users are also somewhere on the Internet. These users do have the additional drawback of only being able to connect to the NAT reflector.

                          |                      |
                          |      THE INTERNET    |
eth0:           |                      |             eth0:
|NAT Reflector|           |                      |             |  VPN Endpoint  |
+-------------+<----------+---------+  + - - - - + - - - - - ->+----------------+
                          |         |            |
                          |         |  |         |
                          |         |            |
                          |         |  |         |
                                    |  |
 Connecting to reflector instead -> |  | <- Direct connection not possible.
                                    |  |
                                |   Users   |

What we can now do to make things ping (or rather: wireguard packets flow through the Internet), is, for every packet destined from $somewhere to

  1. Replace the destination address (from to, DNAT)
  2. Replace the source address (from $whatever to, SNAT)
  3. Send the packet back on its way to
  4. If a reply comes in from, replace the source address (from to, resolving our DNAT)
  5. Replace the destination address (from to $somewhere, whereever our users are, resolving our SNAT; Of course we have to keep some state for that)

Thereby, people can just sent packets through our NAT reflector.

DNAT-SNAT on Linux

So, to get things working on Linux–assuming a freshly booted, newly installed and clean system–we have to do two things:

  1. Enable IP forwarding
  2. Set the DNAT and SNAT rules

Technically, this can be done rather simple (in a non-reboot-surviving way) by just hitting (as root, assuming our network interface on the reflector is eth0 with on it, and the VPN endpoint is

iptables -t nat -I PREROUTING -i eth0 -d -p udp -m multiport --dports 1:65535  -j DNAT --to

iptables -t nat -I PREROUTING -i eth0 -d -p tcp --dport 80 -j DNAT --to
iptables -t nat -I PREROUTING -i eth0 -d -p tcp --dport 443 -j DNAT --to

iptables -t nat -A POSTROUTING -p udp -j SNAT --to-source
iptables -t nat -A POSTROUTING -p tcp --dport 80 -j SNAT --to-source
iptables -t nat -A POSTROUTING -p tcp --dport 443 -j SNAT --to-source

echo '1' | sudo tee /proc/sys/net/ipv4/conf/eth0/forwarding

Note, that we do not only NAT the wireguard packets arriving at that host but–somewhat dangerously–also DNAT/SNAT port tcp/80 and tcp/443. That is a suprise-tool which will come in handy later.


On OpenBSD, things are even more simple; We enabled packet forwarding with:

sysctl net.inet.ip.forwarding=1
echo "net.inet.ip.forwarding=1" >> /etc/sysctl.conf # (if it should persist reboots)

and then add the following to the ‘right’ spot in our /etc/pf.conf:

match in on vio0 proto udp from any to rdr-to
match out on vio0 proto udp from ! to nat-to

match in on vio0 proto tcp from any to port 80 rdr-to port 80
match in on vio0 proto tcp from any to port 443 rdr-to port 443
match out on vio0 proto tcp from ! to nat-to

A simple pfctl -f /etc/pf.conf later we are all set and done.

Distributing Endpoints

Now, the main issue with all of this is a) enabling users to obtain credentials, and b) communicating the endpoints to them. With the setup above, our NAT reflector just reflects packets, and–as we are also forwarding for tcp/443–will let users access the wg-ui running on the endpoint (without a fitting certificate for now; more on that later). The problem now is that the endpoint does not have any keymaterial for the users’ sessions on tcp/443 (well, it could get some,… but do we really want that?); Hence, it can not rewrite the endpoint statement in wireguard configs downloaded from the webinterface (proxy rewrite n stuff). So, it seems like we have to do some patching

To enable the wg-ui to determine which endpoint it should put into a config file a user downloads, it needs to know:

  1. the ‘original’ hostname/IP address wg-ui is running on,
  2. the hostname/IP address a user put into their webbrowser, and
  3. the IP address a user is coming from.

Communicating the first thing to wg-ui is rather simple: We add two new command line parameters with which these values can be set:

+		wgHostName   = kingpin.Flag("wg-httpname", "WireGuard UI HTTP FQDN").Default("").String()
+		wgHttpPoint  = kingpin.Flag("wg-httpip", "WireGuard UI HTTP IP address").Default("").String()

Getting the other values is a bit more tricky. For that, we have to adjust our nginx config, so that nginx communicates these values to wg-ui. Hence, we add headers containing the $http_host and the $remote_addr to our wg-ui vhost:

                proxy_set_header X-Forwarded-Host $http_host;
                proxy_set_header X-Forwarded-For $remote_addr;
                proxy_set_header X-Request-ID $uuid;
                add_header X-Request-ID $uuid;

                location /assets {

Within wg-ui, we can then use r.Header.Get("X-Forwarded-Host") and r.Header.Get("X-Forwarded-For") to get the values of these headers. We can then determine the correct setting for the wireguard endpoint behind a NAT reflector (or at least make a pretty good educated guess);

  1. If the $http_host is equal to the $remote_addr, we know that there has been some forwarding going on.
  2. If the $http_host does neither match our own IP or our own hostname, we also know that some forwarding is happening.

Then, we can check if the $http_host is an IP, and if so set the endpoint to that (a bit more robustness, i guess, to try to guestimate the IP the user connects to, not the one used to forward packets). If that is not the case, we can still fall back to using $remote_addr as the endpoint. In all other cases, we just stick with the endpoint IP configured for wg-ui:

+	wgCustPoint := *wgEndpoint
+	fwd_host := r.Header.Get("X-Forwarded-Host")
+	fwd_ip := r.Header.Get("X-Forwarded-For")
+	if fwd_host == fwd_ip || ( fwd_host != *wgHostName && fwd_host != *wgHttpPoint ) {
+		log.Debugf("Endpoint update requested for %s", wgCustPoint )
+		log.WithField("fwd_host", fwd_host).Debug("Requested Host:")
+		log.WithField("fwd_ip", fwd_ip).Debug("Remote IP:")
+		epHostAsAddr := net.ParseIP(fwd_host)
+		if epHostAsAddr != nil {
+			log.Debugf("Requested URL is an IP, seetting endpoint to that value: %s", fwd_host )
+			wgCustPoint = fwd_host
+		} else {
+			log.Debugf("Requested URL is an FQDN, seetting endpoint to fwd_ip: %s", fwd_ip )
+			wgCustPoint = fwd_ip
+		}
+		log.WithField("endpoint_new", wgCustPoint).Debug("Updated Endpoint:")
+	} else {
+		log.Debugf("Endpoint update not necessary for %s", wgCustPoint )
+		log.WithField("fwd_host", fwd_host).Debug("Requested Host:")
+		log.WithField("req_ip", fwd_ip).Debug("Remote IP:")
+	}
 	if *randPortEnabled {
 		wgCustPort = rand.Intn(65534) + 1
-	wgCustEnd := fmt.Sprintf("%s:%d", *wgEndpoint, wgCustPort)
+	wgCustEnd := fmt.Sprintf("%s:%d", wgCustPoint, wgCustPort)

With that in place, users will now even get the right endpoint when downloading a config file via a NAT reflector. Simply re-compile wg-ui, and start it using:

./bin/wireguard-ui --wg-endpoint="" --wg-dns=", 2001:db8::53" --wg-allowed-ips=", ::/0" --listen-address="" --wg-keepalive="15" --auth-user-header="X-Request-ID" --client-ipv4-range="" --client-ipv6-range="2001:db8:a::/64" --wg-listen-port=51280 --v6 --random-port-nat --wg-httpip=

A note on certificates

As we also get tcp/80 and tcp/443 forwarded by the reflector, we can technically request let’s encrypt certificates for whatever hostname(s) point(s) to our reflector’s IP address. This, technically, is a bit mute, if we have $other people we may not know or trust run the endpoints, as they could just get certificates themselves (or already have them). Then again, in those cases it is probably wiser to not really rely on them anyway… but this is certainly something to be aware of. The most important thing, probably, is making sure that we have dedicated private keys per such added virtual host; Simply, to make fingerprinting a bit harder.

Generating certificates

This process can get even more simple by just having a small script and some files in place. Assuming a standard Debianoid setup with /etc/nginx/sites-enabled/* included by nginx, we can first get a template in place (prefixed with comments, just in case it is accidentally activated):

## /etc/nginx/sites-available/TEMPLATE
#server {
#	listen 443 ssl;
#	listen [::]:443 ssl;

#	ssl_certificate /etc/letsencrypt/live/HOSTNAME/fullchain.pem;
#	ssl_certificate_key /etc/letsencrypt/live/HOSTNAME/privkey.pem;
#	include /etc/letsencrypt/options-ssl-nginx.conf;
#	ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
#	server_name  HOSTNAME;
#	root /var/www/html;

#	error_page   500 502 503 504  /50x.html;
#	location = /50x.html {
#		root  /var/www/htdocs;
#	}
#	location ^~ /.well-known/acme-challenge/ {
#		alias /var/www/acme/;
#	}
#	location /assets/ {
#		root  /var/www/html;
#	}
#	proxy_set_header X-Forwarded-Host $http_host;
#	proxy_set_header X-Forwarded-For $remote_addr;
#	proxy_set_header X-Request-ID $uuid;
#	add_header X-Request-ID $uuid;

#	location ~* /api/v1/users/[a-z0-9]*/clients/[0-9]*$ {
#		proxy_pass;
#	}
#	location / {
#		auth_basic "auth";
#		auth_basic_user_file /etc/nginx/htpasswd;
#		proxy_pass;
#	}

We can then combine this with a small script that tries to get certificates and attempts to configure nginx; Invoke with ./ and things should(tm) work:

set -u


if ! [ -f "$CFG" ];
	# Configuration does not yet exist; Check if host exists
	if dig +short "$h" 2> /dev/null | grep -E '[0-9a-f]' > /dev/null;
		echo "Starting creation of: $CFG";
		echo "$h resolves; checking for certificates";
		if ! [ -L "/etc/letsencrypt/live/$h/fullchain.pem" ] || [ -L "/etc/letsencrypt/live/$h/privkey.pem" ];
			echo "At least one cert-part missing for $h; Ensuring absence of certs and attempting re-issue";
			rm -rf "/etc/letsencrypt/live/$h/";
			rm -f "/etc/letsencrypt/renewal/$h.conf";
			rm -rf "/etc/letsencrypt/archive/$h/";

			certbot certonly --nginx -n -d "$h";


			if ! [ $CERT_STATE -eq "0" ];
				echo "Cert retrieval failed for $h";

		if [ $CERT_STATE -eq "0" ];
			cat $TEMPLATE | sed s/'^#'// | sed s/'HOSTNAME'/"$h"/g > "$CFG";
			echo "Generating config file for $h.";
			service nginx reload;
			if ! [ "$NGINX_STATE" -eq "0" ];
				echo "Config generation for $h failed; Reverting.";
				rm -f "$CFG";
				service nginx start;
	echo "Configfile exists: $CFG; Skipping";


So… all things together, we can now go about and make little VPN endpoints spawn all over the Internet… time to make it ping?