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

« Part 2: Random Endpoint Ports <> Part 4: Routing Shenenigans »

One of the glaring issues of wg-ui is the lacking support for modern networking technology, i.e., IPv6. This is–given our usecase–somewhat of an issue. See, even though one might think that this is a somewhat optional feature, happy eyeballs can get seriously confused if there is not IPv6 connectivity, but local interfaces do have public IPv6 addresses configured. More simply put: A user behind a dual-stack network might end up with a bad user experience if the VPN we offer does not provide IPv6, because the wireguard client makes sure all non-VPN traffic gets dropped. Happy Eyeballs trying things with v6 then leads to timeouts and unhappyness. Even worse, allowing non-VPN traffic (to make things work) may then even leak stuff that should not be leaked.

So, off we go with making wg-ui support v6 (in a non-production-ready manner). The good thing here is that we should have kind’a enough IPv6 addresses, so we can just skimp on all the funny NAT stuff…

Patching wg-ui

Patching wg-ui to support IPv6 is actually rather straight-forward. Golang’s net.IP is (comparatively) protocol agnostic, which means that we can mostly reuse previously in-place v4 code. We first have to add new variables (IPv4 and IPv6 instead of just IP) to config.go.

Next up are new command line arguments; We remove the old --client-ip-range in favor of a new --client-ipv4-range and --client-ipv6-range. In the same process, we introduce a new boolean to enable v6 (--v6) and change the default value of --nat from true to false (for unrelated firewalling reasons, see before, this does not get easier with v6). Thereafter, it is basically just a bit of copy-and-duplicate for the former v4 only code to also handle v6.

Dealing with legacy

The biggest problem here is certainly handling a pre-existing VPN user base. Technically, we want to enable existing users to use v6 as well. Similarly, if one turns of v6, addresses should be cleanly de-provisioned (not on the clients though, only for configuration downloading).

The part of the diff that makes that happen is this in server.go:

+	log.Debug("Cleaning up client configuration.")
+	for user, tmpCfg := range config.Users {
+		log.Debug("Cleaning clients for: ", user)
+		for id, dev := range tmpCfg.Clients {
+			log.Debug("Cleaning ID: ", id)
+			if dev.IPv4 == nil {
+				if dev.IP != nil {
+					log.Debug("Client lacks IPv4 addr., setting to legacy: ", dev.IP)
+					dev.IPv4 = dev.IP
+				} else {
+					dev.IPv4 = s.allocateIPv4()
+					log.Debug("Client lacks IP/IPv4 addr., generating: ", dev.IPv4)
+				}
+			}
+			if *v6Enabled {
+				if dev.IPv6 == nil || dev.IPv6.To4() != nil {
+					dev.IPv6 = s.allocateIPv6()
+					log.Debug("Stale config from v6 disabled, allocated: ", dev.IPv6)
+				}
+			} else {
+				dev.IPv6 = dev.IPv4
+				log.Debug("IPv6 disabled, setting dev.IPv6 to: ", dev.IPv6)
+			}
+		}
+	}
+	log.Debug("Configuration Cleaned; Writing.")
+	config.Write()

What we essentially do, upon import, is going through each client in the configurations. First, we check if the loaded client has an IPv4 address. If not, we import that from the legacy IP field. Next, if IPv6 is disabled, we set all IPv6 addresses to the IPv4 address, to be able to check if IPv4 == IPv6 in the web-ui to know whether IPv6 is disabled. If v6 is enabled, and the import IPv6 address field is an IPv6 address, we just go with that. Otherwise, we assign a new IPv6 address. Finally, the new config is flushed to disk again, to ensure consistency.

Allocating IPv6 addresses

For address allocation, I just re-used the IPv4 allocation code:

+func (s *Server) allocateIPv6() net.IP {
 	allocated := make(map[string]bool)
-	allocated[s.ipAddr.String()] = true
+	allocated[s.ipv6Addr.String()] = true
 	for _, cfg := range s.Config.Users {
 		for _, dev := range cfg.Clients {
-			allocated[dev.IP.String()] = true
+			allocated[dev.IPv6.String()] = true

-	for ip := s.ipAddr.Mask(s.clientIPRange.Mask); s.clientIPRange.Contains(ip); {
-		for i := len(ip) - 1; i >= 0; i-- {
-			ip[i]++
-			if ip[i] > 0 {
+	for ipv6 := s.ipv6Addr.Mask(s.clientIPv6Range.Mask); s.clientIPv6Range.Contains(ipv6); {
+		for i := len(ipv6) - 1; i >= 0; i-- {
+			ipv6[i]++
+			if ipv6[i] > 0 {
-		if !allocated[ip.String()] {
-			log.Debug("Allocated IP: ", ip)
-			return ip
+		if !allocated[ipv6.String()] {
+			log.Debug("Allocated IPv6: ", ipv6)
+			return ipv6

What this does is take the IPv6 network supplied via --client-ipv6-range, use the first address in the network for the wg server, and then allocate the first so-far-unallocated IPv6 address in that network to the new request (incrementing by one until a free one is found). This is certainly not the cleanest mode of allocation (we could shell out /64’s for example… or /48’s even), but to provide exactly one client with IPv6 connectivity, it is enough (also, spoilers, we will do some NAT66-y stuff later on).

Seeing things work

With some additional features added, we can now hit make build again, and run our wg server. Note, that we have to add ::/0 to --wg-allowed-ips; Adding a v6 DNS server to --wg-dns is optional, though.

./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:af45:23::/64" --wg-listen-port=51280 --v6 --random-port-nat &

Thanks to the change disabling NAT-ing (and the associated flushing of existing NFTables rules), we can even skip on

iptables -t nat -I PREROUTING -i eth0 -d -p udp -m multiport --dports 1:51819,51821:65535  -j REDIRECT --to-ports 51820

this time around. (still, check if the rule is still in place ;-))

If our v6 network is correctly routed to our machine’s eth0, clients–after downloading a new config–should now have IPv6 connectivity.