The Path to Seamless Connectivity: Full IPv6 /64 VPN Routing From Anywhere (Tech Scroll 104)

“When your address travels with you, the city has no gates.” — Tech Scrolls 7:12
Who this is for
Office managers, remote workers, field engineers, creators on the road, families with dispersed devices — anyone who wants their own secure network everywhere, without buying a commercial VPN. If you can follow a recipe and copy‑paste a few commands, you can do this. If your parents once laughed at your grandparents wrestling a VHS timer… well, it’s our turn. This guide is readable, step‑by‑step, and forgiving.
What you’ll build
A WireGuard® VPN that gives each roaming device a globally routed IPv6 /64 subnet, just like it’s plugged into your office/home LAN. All traffic can flow through your base site; you keep consistent addressing, consistent security, and first‑class reachability in both directions.
Highlights
- Your own addresses: IPv6 from your ISP (/48 or /56) or HE.net Tunnelbroker. This is globally routable IPv6 address space, meaning it is visible on the public internet. Treat it as a true routed network—secure it with a firewall to control access, especially if colleagues, friends, or family will connect to the endpoints. Without protection, these addresses will be openly reachable.
- Your own tunnel: WireGuard with strong crypto, fast roaming, and a configuration so compact it can fit in about 10 lines per server instance (with one client endpoint) and roughly 9 lines per additional client — a level of simplicity that’s both powerful and approachable.
- Your own DNS: Here you can configure a local naming world of your own—names that do not exist on the public internet but work perfectly within your network. For example, you could have harry.happy.child, larry.lovely.father, mary.lovely.mother, or even sarah.grumpy.little.sister, all resolvable via your DNS server. Optionally, you can enable DNS64 + NAT64 so IPv6‑only clients can still reach IPv4‑only sites, blending your private namespace with full internet compatibility.
- Your own subnets: Hand each person/device a dedicated /64; optionally advertise it to their local Wi‑Fi so nearby devices join too.
Internet ↔ (ISP /48) ↔ Base Router (Alpine) ─── wg0 (babe::/64)
╰── LANs (…:0000::/64, …:0001::/64, etc.)
Remote Mac/PC/Phone ─── WireGuard ─── gets :babe::2/128 and its own :cafe::/64
Remote LAN via client ──(optional RA)── devices auto‑join over the tunnel
Prerequisites (pick your path)
- IPv6 at the base site (recommended)
- An ISP‑delegated /48 or /56 (e.g.,
2001:db8:1234::/48
). - Public IPv6 on your base router’s WAN so clients can connect via AAAA.
- …or Tunnelbroker (works great too)
- HE.net Tunnelbroker gives you a free /64 and optional /48.
- Terminate the HE tunnel on your base router; everything else is the same.
- A base machine (router, server, or even a desktop/laptop)
- Alpine Linux (this guide’s commands) is our example, but WireGuard runs on any supported OS: Windows (installer), macOS (App Store), Ubuntu/Debian (package install), Android (Play Store or APK), iOS (App Store), and more.
- UDP/51820 must be reachable from the internet (we’ll add nftables rules). This could be as simple as placing the chosen machine in a DMZ on your internet router, whether that’s a dedicated Linux box, a Raspberry Pi, or even your own Windows gaming PC.
- Clients
- macOS, Windows, Linux, Android, iOS — WireGuard apps exist for all.
Terminology we’ll useBase = your office/home router/server.babe::/64 = the point‑to‑point tunnel subnet.cafe::/64 = a subnet routed to the client for their local LAN.
Use your own prefixes from your assigned /48 (we’ll show with documentation space2001:db8::/48
).
Step 1 — Plan your addressing
From your /48 (example 2001:db8:1234::/48
), carve:
- Base LANs:
2001:db8:1234:0000::/64
,2001:db8:1234:0001::/64
, … - WireGuard tunnel:
2001:db8:1234:babe::/64
(server. . . :babe::1
, clients get. . . :babe::2
, etc.) - Client‑LAN example:
2001:db8:1234:cafe::/64
routed to a specific client.
You have 65,536 /64s inside a /48. Plenty for people, vehicles, kits, labs, events.
Step 2 — Install WireGuard on Alpine (base)
apk add wireguard-tools wireguard-tools-openrc nftables # nftables if you don’t have it
rc-update add nftables default
service nftables start
Generate keys (keep them secret):
umask 077
mkdir -p /etc/wireguard/keys && cd /etc/wireguard/keys
wg genkey | tee server-private.key | wg pubkey > server-public.key
wg genkey | tee client1-private.key | wg pubkey > client1-public.key
Create /etc/wireguard/wg0.conf
(server):
[Interface]
PrivateKey = <contents of server-private.key>
Address = 2001:db8:1234:babe::1/64
ListenPort = 51820
# Optional: set MTU if PPPoE or path MTU issues
# MTU = 1412
SaveConfig = true
# Client 1 gets a /128 for itself and a /64 routed behind it
[Peer]
PublicKey = <client-public.key>
AllowedIPs = 2001:db8:1234:babe::2/128, 2001:db8:1234:cafe::/64
# Optional if client has static public endpoint:
# Endpoint = client.example.net:51820
# Roaming clients should NOT set Endpoint here.
Enable on boot and start:
ln -s /etc/init.d/wg-quick /etc/init.d/wg-quick.wg0
rc-update add wg-quick.wg0 default
rc-service wg-quick.wg0 start
wg show
Don’t putsysctl
lines (like forwarding) insidewg0.conf
. That file is WireGuard config only.
Step 3 — Allow WireGuard through the firewall (nftables)
A minimal, safe set (merge into your existing policy):
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
ct state established,related accept
iifname lo accept
udp dport 51820 accept # WireGuard
# ICMPv6 essentials (neighbor discovery, PMTU, etc.)
icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert,
destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
echo-request, echo-reply } accept
# Default policy
reject with icmpx type admin-prohibited
}
chain forward {
type filter hook forward priority 0;
ct state established,related accept
# Allow traffic between wg0 and your LANs
iifname wg0 accept
oifname wg0 accept
# RFC-compliant: Allow ICMP for IPv4 and IPv6 (ping)
ip protocol icmp accept
ip6 nexthdr ipv6-icmp icmpv6 type { echo-request, echo-reply } accept
# Allow UDP traceroute range for IPv4/IPv6
udp dport 33434-33523 accept
# Default policy
reject with icmpx type admin-prohibited
}
}
Load it:
nft -f /etc/nftables.nft
service nftables save
Step 4 — (Optional but powerful) DNS64 + NAT64 at the base
If your remote clients run IPv6‑only, they still need to reach IPv4‑only sites. Two clean ways:
A) Dual‑stack the tunnel — give clients IPv4 in WireGuard and NAT IPv4 at the base. Simple and universal.
B) IPv6‑only with DNS64/NAT64 — keep clients pure IPv6; your base synthesizes AAAA (DNS64) and translates packets (NAT64).
- DNS64 prefix: use the well‑known
64:ff9b::/96
or a network‑specific /96 from your own IPv6 space (NSP), e.g.,2a02:8012:bc57:c0ff:ee:babe::/96
. Whatever you choose must be routed to your NAT64 box and the exact same value set in Unbound (dns64-prefix
) and Jool/Tayga (--pool6
). You can’t use a random…:babe::/96
unless it lies inside a prefix you control. - Tools: Unbound (DNS64) + Jool or Tayga (NAT64)
Unbound DNS64 (snippet):
server:
interface: ::0
do-ip6: yes
do-ip4: yes
prefer-ip6: yes
module-config: "dns64 validator iterator"
# Here you can customise the DNS64 prefix to match your allocation.
# For example, instead of the well-known 64:ff9b::/96, you could use part of your /48.
# This example uses 2a02:8012:bc57:c0ff:ee:babe::/96 within the /48 allocation.
dns64-prefix: 2a02:8012:bc57:c0ff:ee:babe::/96
forward-zone:
name: "."
forward-addr: 2620:fe::fe # Quad9 IPv6
forward-addr: 2001:4860:4860::8888 # Google IPv6
Jool NAT64 (concept):
apk add jool-tools jool-tools-openrc jool-modules-lts
modprobe jool # load kernel module matching your kernel
# Use the same custom prefix here so NAT64 works in harmony with your DNS64
jool instance add "default" --netfilter --pool6 2a02:8012:bc57:c0ff:ee:babe::/96
# Configure pool4, BIB, etc., as needed for translation
Example custom DNS mapping (Unbound local-zone):
local-zone: "grumpy.little.sister." static
local-data: "sarah.grumpy.little.sister. IN AAAA 2a02:8012:bc57:c001::1234"
This allows you to have fun, human-readable names inside your network — like harry.happy.child
or sarah.grumpy.little.sister
— pointing directly to your tunnel endpoints.
C) You can also use these names as part of your safety and security plan, especially when children are involved. By configuring your DNS or firewall, you can block or redirect requests from certain endpoints (for example, sarah.grumpy.little.sister
) to prevent access to age-restricted or unsuitable sites. You might even implement schedules — for instance, allowing certain sites only at specific times of the day — to support healthy online habits. Below is a simple example using Unbound to block or redirect a site such as facebook.com
when accessed from a specific VPN endpoint:
# Block facebook.com entirely
local-zone: \"facebook.com\" refuse
# Or redirect facebook.com to an internal page
local-data: \"facebook.com A 1:babe:51fe::1\" # internal web server IP
In this way, parents can ensure that children do not have access to unsuitable content, while still maintaining flexibility and personalisation across the network.
If you don’t need/want NAT64, skip this section and just use dual‑stack on the tunnel.
Step 5 — Create the first client
You can make clients for macOS, Windows, Linux, Android, iOS. They all share the same fields.
5.0 Server keypair
wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
5.1 Client keypair
On the base (or on the client itself), generate keys for each client:
wg genkey | tee client1-private.key | wg pubkey > client1-public.key
Add the client’s public key to the server [Peer]
(already shown). Keep the client private key with the client.
5.2 Client config
[Interface]
PrivateKey = <client-private.key>
Address = 2001:db8:1234:babe::2/128, 2001:db8:1234:cafe::1/64
DNS = 2001:db8:1234:babe::1 # point DNS to base (for DNS64 or internal names)
# If dual-stack tunnel wanted, also add: Address = 10.10.10.2/32 and DNS = 10.10.10.1
[Peer]
PublicKey = <server-public.key>
Endpoint = vpn.example.com:51820 # use a hostname with both A + AAAA
PersistentKeepalive = 25 # helps through NATs/hotspots
# Full-tunnel over IPv6; also route your office /48 explicitly
AllowedIPs = ::/0, 2001:db8:1234::/48
# If dual-stack too, add IPv4: AllowedIPs = 0.0.0.0/0, ::/0, 2001:db8:1234::/48
Tip: Publish your base as vpn.example.com
with A and AAAA. The client will use IPv6 when available, IPv4 when not.
5.3 macOS
- Install WireGuard from App Store or
brew install --cask wireguard
. - Click Add Tunnel → Import from file (paste config above).
- Toggle Activate. Done.
- Verify:
ping6 2001:db8:1234:babe::1
(server),curl -6 https://ipv6-test.com/api/myip.php
.
5.4 Windows
- Install WireGuard for Windows.
- Add Tunnel → Add empty tunnel → paste config.
- Activate; use
ping -6
and your browser to test.
5.5 Linux (wg-quick)
Save as /etc/wireguard/wg0.conf
and run:
sudo wg-quick up wg0
sudo wg # show status
Enable at boot as you prefer (systemd or OpenRC).
5.6 Android
- Install WireGuard from Play Store.
- Tap + → Create from file or archive (or QR, see below).
- Ensure DNS points to your base.
- Always‑on VPN + Block connections without VPN (optional for privacy).
5.7 iOS
- Install WireGuard from App Store.
- Add a new tunnel from file/QR. Toggle on. Test with Safari.
5.8 Make a QR from a client config
apk add qrencode
qrencode -t ansiutf8 < client1.conf
# or create PNG
qrencode -o client1.png < client1.conf
Step 6 — Give the client its own /64 for a local LAN (optional, powerful)
This lets a laptop or small box on the road share a proper IPv6 subnet with nearby devices (for a pop‑up office, camera rig, or vehicle Wi‑Fi). While a single /64 is the simplest to route, nothing stops you from assigning a larger allocation—such as a /56 or even multiple /64s—directly to the endpoint. Within a /48, you could technically provide tens of thousands of distinct /64 networks, giving enormous flexibility: segment IoT devices from workstations, create isolated VLANs for different teams, or dedicate entire subnets to specific applications. Whether you choose one, ten, or a thousand networks depends on the operational needs and routing plan, but the capability is there.
On the server
- Ensure the client’s
[Peer]
AllowedIPs includes the routed /64, e.g.2001:db8:1234:cafe::/64
. - No extra static routes are needed; WireGuard programs the kernel route via
wg0
.
On the client (Linux)
- Enable forwarding:
sysctl -w net.ipv6.conf.all.forwarding=1
- On the client’s local interface (e.g.,
wlan0
), run radvd or dnsmasq to advertise the /64:
/etc/radvd.conf
(client side):
interface wlan0 {
AdvSendAdvert on; # Enable sending of Router Advertisements
MaxRtrAdvInterval 30; # Maximum time (in seconds) between advertisements
prefix 2001:db8:1234:cafe::/64 { # The IPv6 prefix to advertise to clients
AdvOnLink on; # Clients consider this prefix as directly reachable
AdvAutonomous on; # Allow clients to configure their own addresses from this prefix (SLAAC)
};
RDNSS 2001:db8:1234:babe::1 { # Recursive DNS Server address advertised to clients
AdvRDNSSLifetime 600; # Time (in seconds) that the RDNSS information is valid
};
DNSSL example.local { # Optional: advertise a DNS search list domain
AdvDNSSLLifetime 600; # Lifetime of the DNS search list info
};
};
- Connect a second device to the client’s Wi‑Fi — it will get an address in
…:cafe::/64
and route over the tunnel.
Note (Android/iOS): Mobile OSs generally do not allow you to run RA. Use a tiny Linux box (Pi/NUC/laptop) if you want to present a /64 to others over your phone/hotspot.
Step 7 — Testing & proving it works
- Reach the base:
ping6 2001:db8:1234:babe::1
- Reach an internal server:
ping6 2001:db8:1234:0000::10
- External IPv6:
curl -6 https://ipv6-test.com/api/myip.php
- If using DNS64/NAT64:
dig AAAA zen.co.uk @2001:db8:1234:babe::1
→ you should see synthesized64:ff9b::…
AAAA records; browsing to IPv4‑only sites works over IPv6. - Traceroute:
traceroute6 google.com
(macOS:traceroute -6
). You should see hops through your base.
If a venue says “VPN blocked”: With WireGuard on UDP/51820, most networks allow it. If UDP is fully blocked, try moving the server to UDP/53 or UDP/443 (check your policies), or fall back to a quick SSH SOCKS proxy as a contingency. Your traffic remains encrypted end‑to‑end either way.
Troubleshooting (quick fixes)
- No connection: Check DNS of
vpn.example.com
, port 51820 open, and that the server sees the peer inwg show
. - No DNS: You have no control of your DNS yet you own a domain name, head over to he.net https://dns.he.net/ where you get FREE DNS Management.
- no IPv6: Not an issue if your ISP does not issue native IPv6 then head over to https://tunnelbroker.net/ from he.net who will give you FREE /64 and /48 tunnels per endpoint, You wont be limited to a single /48 if there is more than one internet connection involved.
- Flaps on public Wi‑Fi: Set
PersistentKeepalive = 25
on the client. - Slow/broken sites: Lower MTU (PPPoE path? try
1412
on Linux server and clients). - IPv4 sites fail on IPv6‑only client: Either dual‑stack the tunnel or enable DNS64/NAT64 at base and set client DNS to the base.
- Can’t reach internal hosts: Confirm the server’s nftables forward chain allows
iif wg0
↔LAN and the LAN hosts’ firewalls allow inbound from your prefixes.
Security notes
- Treat private keys like passwords. Rotate if shared accidentally.
- Least privilege: give each peer only the routes (AllowedIPs) it needs.
- Log and alert on WireGuard interface up/down.
- Segment sensitive subnets even inside your /48; defense in depth.
Performance tips
- WireGuard is fast; most bottlenecks are MTU and WAN. Tune MTU.
- On Linux routers, enable BBR and sensible TCP buffers if you do bulk transfers.
- Use a hostname with both A + AAAA so clients choose the best path.
Why WireGuard over classic IPv6 tunnels?
- Roaming: Seamless IP changes (coffee shop → train hotspot → hotel).
- Simplicity: Tiny configs, no brittle PKI.
- Speed: Kernel‑space on Linux, modern crypto.
- Flexibility: Hand out a /64 to each peer; even advertise it locally with RA.
(If you only need IPv6 and have a fixed endpoint, HE.net Tunnelbroker is great. For moving clients, WireGuard wins.)
Appendix A — Minimal dual‑stack example
Server gives the client both IPv6 and IPv4, NATs IPv4 at the base.
Server /etc/wireguard/wg0.conf
(diff):
[Interface]
Address = 2001:db8:1234:babe::1/64, 10.10.10.1/24
[Peer]
AllowedIPs = 2001:db8:1234:babe::2/128, 10.10.10.2/32
Client:
[Interface]
Address = 2001:db8:1234:babe::2/128, 10.10.10.2/32
DNS = 2001:db8:1234:babe::1, 10.10.10.1
[Peer]
AllowedIPs = 0.0.0.0/0, ::/0, 2001:db8:1234::/48
Add NAT44 on the base for 10.10.10.0/24
out the WAN (nftables masquerade
).
Full lab configs used in this Scroll
Note: These are the exact lab configs used during testing. Keys will be rotated when the article is published.
Server — /etc/wireguard/wg0.conf
[Interface]
Address = 2a02:8012:bc57:babe::1/64, 10.10.10.1/24
SaveConfig = true
ListenPort = 51820
PrivateKey = aFgaNrrIQqzydRTsyfdIcDCaUhFz/kKlqZxeZO+m9UM=
[Peer]
# Client public key
PublicKey = nWU+w94EYWgNPq62OLYgJBYH8lV5vNSsmYJQYv13tk4=
# Client /128 and routed /64, plus dual-stack IPv4 for the tunnel
AllowedIPs = 2a02:8012:bc57:babe::2/128, 2a02:8012:bc57:babe::/64, 10.10.10.2/24
# During roaming tests the client provided a public endpoint; not required for roaming
Endpoint = 82.132.212.115:64583
Client — (macOS/iOS/Android/Windows)
[Interface]
PrivateKey = oLiHWpZLofGaSWnfW78ZxPBeBVRHBY1hNCp8BJ0EiH0=
# The client gets a /128 on the tunnel and a /64 to advertise locally if desired
Address = 2a02:8012:bc57:babe::2/64
Address = 2a02:8012:bc57:babe::2/128, 2a02:8012:bc57:cafe::1/64
# Use base DNS (enables internal names and DNS64 if configured)
DNS = 2a02:8012:bc57:babe::1
[Peer]
# Server public key
PublicKey = DvineBI9/xN5WcUAziQRN29r/UJzoZzhAXXH7He/j1s=
# Use a hostname with both A and AAAA so it works on IPv4-only and IPv6 networks
Endpoint = roam.ipv6.breathtechnology.co.uk:51820
# Route everything through the tunnel and explicitly include the /48 at base
AllowedIPs = 2a02:8012:bc57::/48, 0.0.0.0/0, ::/0
# Keep NATs/hotspots happy
PersistentKeepalive = 25
Appendix B — Sample scripts you can extend
Generate keys for N clients (bash skeleton):
#!/bin/sh
set -eu
BASE_DIR=/etc/wireguard/clients
mkdir -p "$BASE_DIR"
for name in "$@"; do
umask 077
wg genkey | tee "$BASE_DIR/$name.key" | wg pubkey > "$BASE_DIR/$name.pub"
echo "Created: $name"
done
Create a QR for a client config:
qrencode -t ansiutf8 < /etc/wireguard/client1.conf

Before Closing, let’s walk that extra mile and include a complete automation script.
Below is a drop-in Python rewrite that keeps the same features as our depreciated bash script, now removed from the article to save confusion and if you wish we have a companion article with a version written in go here
IPv6 /48 + optional routed /64 per-client, IPv4 /24, rotating endpoints, QR output with a new server-side model:
- The server config is built from a base file + per-client snippets (
peers.d/NAME.conf
). - “Revoke” is just moving a snippet to
revoked/
and rebuilding the server config. - Live state is synced with
wg syncconf
using the freshly rebuilt config (no partial edits). - This version fully tested and just works, is faster and well its the snake language everyone loves.
It uses only the Python standard library and the wg
/wg-quick
binaries supplied by wireguard-tools
.
Files & Layout
/etc/wireguard/
wg0.base # server base (everything above the first [Peer])
wg0.conf # generated (base + peers.d/*), owned by the script
clients/
index.tsv # allocations
NAME.key / NAME.pub / NAME.conf
revoked/ # archived client files
peers.d/
NAME.conf # server-side [Peer] snippet (active)
endpoints.lst # optional (one "host:port" per line)
.endpoint.ptr # auto-rotating pointer for endpoints.lst
.client-id-counter # integer
.v6routed-counter # hex hextet counter (0000..ffff)
First run: ifwg0.base
is missing, the script will derive it by taking everything inwg0.conf
above the first[Peer]
and write it towg0.base
.
Python script: wg_manage.py
#!/usr/bin/env python3
# Portable WireGuard manager: create/list/revoke with IPv6 (/48) + IPv4 (/24)
# Server config = base + peers.d/*.conf, so revoke is atomic + reliable.
# Alpine/Arch friendly. Edit with vim.
import argparse, os, sys, subprocess, shutil, time, json
from datetime import datetime, timezone
from pathlib import Path
WG_DEV = os.environ.get("WG_DEV", "wg0")
WG_DIR = Path(os.environ.get("WG_DIR", "/etc/wireguard"))
SERVER_CONF = WG_DIR / f"{WG_DEV}.conf"
SERVER_BASE = WG_DIR / f"{WG_DEV}.base"
PEERS_DIR = WG_DIR / "peers.d"
CLIENT_DIR = WG_DIR / "clients"
REVOKED_DIR = CLIENT_DIR / "revoked"
INDEX_FILE = CLIENT_DIR / "index.tsv"
COUNTER_FILE = WG_DIR / ".client-id-counter"
V6R_COUNTER = WG_DIR / ".v6routed-counter"
ENDPOINTS_FILE = WG_DIR / "endpoints.lst"
ENDPOINT_PTR = WG_DIR / ".endpoint.ptr"
APK_HINT = "apk add wireguard-tools qrencode # Arch: pacman -S wireguard-tools qrencode"
# ---------- utils ----------
def sh(cmd, input_bytes=None, check=True):
return subprocess.run(cmd, input=input_bytes, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=check, text=True)
def now_iso():
# RFC-3339 UTC like 2025-08-09T21:17:00Z
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def ensure_dirs():
for d in (PEERS_DIR, CLIENT_DIR, REVOKED_DIR):
d.mkdir(parents=True, exist_ok=True)
def read_int(path, default):
try:
return int(path.read_text().strip())
except Exception:
return default
def write_text(path: Path, s: str, mode=0o600):
path.write_text(s)
os.chmod(path, mode)
def server_pubkey():
try:
return sh(["wg", "show", WG_DEV, "public-key"], check=False).stdout.strip()
except FileNotFoundError:
sys.exit("wg not found. " + APK_HINT)
def have_cmd(name):
return shutil.which(name) is not None
def wg_syncconf():
# Use 'wg-quick strip' to sanitize; then 'wg syncconf'
try:
strip = sh(["wg-quick", "strip", str(SERVER_CONF)]).stdout
except FileNotFoundError:
sys.exit("wg-quick not found. " + APK_HINT)
sh(["wg", "syncconf", WG_DEV, "/proc/self/fd/0"], input_bytes=strip)
def derive_base_if_missing():
if SERVER_BASE.exists():
return
if not SERVER_CONF.exists():
sys.exit(f"Missing {SERVER_BASE} and {SERVER_CONF}; provide a base or an initial conf.")
lines = SERVER_CONF.read_text().splitlines()
base = []
for line in lines:
if line.strip() == "[Peer]":
break
base.append(line)
if not base:
sys.exit(f"Unable to derive {SERVER_BASE}; {SERVER_CONF} has no header above [Peer].")
write_text(SERVER_BASE, "\n".join(base).rstrip() + "\n", 0o600)
def rebuild_server_conf():
derive_base_if_missing()
parts = [SERVER_BASE.read_text().rstrip()]
# append all active peer snippets in lexical order for stability
for p in sorted(PEERS_DIR.glob("*.conf")):
parts.append("") # spacer
parts.append(p.read_text().rstrip())
write_text(SERVER_CONF, "\n".join(parts).rstrip() + "\n", 0o600)
def choose_endpoint(explicit: str|None, idx: str|None):
if explicit:
return explicit.strip()
if idx:
if not ENDPOINTS_FILE.exists():
sys.exit(f"--endpoint-index used but {ENDPOINTS_FILE} missing")
lines = [l.strip() for l in ENDPOINTS_FILE.read_text().splitlines() if l.strip()]
i = int(idx)
if i < 1 or i > len(lines):
sys.exit("--endpoint-index out of range")
return lines[i-1]
if ENDPOINTS_FILE.exists():
lines = [l.strip() for l in ENDPOINTS_FILE.read_text().splitlines() if l.strip()]
if not lines:
sys.exit(f"{ENDPOINTS_FILE} is empty")
cur = read_int(ENDPOINT_PTR, 1)
if cur < 1 or cur > len(lines):
cur = 1
nxt = (cur % len(lines)) + 1
write_text(ENDPOINT_PTR, str(nxt), 0o600)
return lines[cur-1]
sys.exit("Endpoint required (use --endpoint or provide endpoints.lst)")
def next_id():
cur = read_int(COUNTER_FILE, 1)
new = cur + 1
if new > 250:
sys.exit("Too many clients for /24")
write_text(COUNTER_FILE, str(new), 0o600)
return new
def parse_v6_48(prefix48: str):
# "aaaa:bbbb:cccc:dddd::/48" -> ("aaaa:bbbb:cccc", "dddd")
root = prefix48.split("/")[0]
h = (root.split(":") + ["0"]*8)[:8]
return f"{h[0]}:{h[1]}:{h[2]}", h[3]
def validate_v6_64_in_48(want64: str, root48: str):
if not want64.endswith("/64"):
return False
root = want64.split("/")[0]
h = (root.split(":") + ["0"]*8)[:8]
return f"{h[0]}:{h[1]}:{h[2]}" == root48
def load_used_routed_hextets():
used = set()
if INDEX_FILE.exists():
for ln in INDEX_FILE.read_text().splitlines()[1:]:
if not ln.strip():
continue
parts = ln.split("\t")
if len(parts) < 8:
continue
routed = parts[4].strip()
if routed == "-" or "/64" not in routed:
continue
root = routed.split("/")[0]
h = (root.split(":") + ["0"]*8)[:8]
used.add(h[3].lower())
return used
def next_v6routed_hextet(skip_words=("babe",)):
start_hex = V6R_COUNTER.read_text().strip() if V6R_COUNTER.exists() else "2000"
cur = int(start_hex, 16)
used = load_used_routed_hextets()
while cur <= 0xFFFF:
hex4 = f"{cur:04x}"
if hex4 not in used and hex4 not in skip_words:
write_text(V6R_COUNTER, hex4, 0o600)
return hex4
cur += 1
sys.exit("Exhausted /64 allocations under this /48")
def ensure_index_header():
if not INDEX_FILE.exists():
write_text(INDEX_FILE, "id\tname\tv4\tv6_tun\tv6_routed\tpubkey\tstatus\tcreated\n", 0o600)
def index_mark_revoked(name: str):
if not INDEX_FILE.exists():
return
rows = INDEX_FILE.read_text().splitlines()
out = [rows[0]] if rows else []
for ln in rows[1:]:
parts = ln.split("\t")
if len(parts) >= 8 and parts[1] == name:
parts[6] = "revoked"
ln = "\t".join(parts)
out.append(ln)
write_text(INDEX_FILE, "\n".join(out) + "\n", 0o600)
def build_client_conf(name, privkey, server_pub, client_v6_tun, client_v4, routed64, client_v6_lan_gw, endpoint):
addr = [f"{client_v6_tun}/128", f"{client_v4}/32"]
if routed64:
addr.append(f"{client_v6_lan_gw}/64")
dns = [] # optional; leave empty unless specific resolver desired
lines = []
lines.append(f"# {name} — generated {now_iso()}")
lines.append("[Interface]")
lines.append(f"PrivateKey = {privkey}")
lines.append("Address = " + ", ".join(addr))
if dns:
lines.append("DNS = " + ", ".join(dns))
lines.append("")
lines.append("[Peer]")
lines.append(f"PublicKey = {server_pub}")
lines.append(f"Endpoint = {endpoint}")
lines.append("AllowedIPs = ::/0, 0.0.0.0/0")
lines.append("PersistentKeepalive = 25")
return "\n".join(lines) + "\n"
def build_server_peer_snippet(name, pubkey, client_v6_tun, client_v4, routed64):
lines = []
lines.append(f"# {name}")
lines.append("[Peer]")
lines.append(f"PublicKey = {pubkey}")
if routed64:
lines.append("# Client /128 on tunnel and a routed /64 behind it; plus dual-stack IPv4")
lines.append(f"AllowedIPs = {client_v6_tun}/128, {routed64}, {client_v4}/32")
else:
lines.append("# Client /128 on tunnel; plus dual-stack IPv4 (no routed /64)")
lines.append(f"AllowedIPs = {client_v6_tun}/128, {client_v4}/32")
return "\n".join(lines) + "\n"
# ---------- commands ----------
def cmd_list(_args):
if INDEX_FILE.exists():
print("id\tname\tv4\tv6_tun\tv6_routed\tpubkey\tstatus\tcreated")
with INDEX_FILE.open() as f:
for i, ln in enumerate(f):
if i == 0: # header already printed
continue
print(ln.rstrip())
else:
print("No clients yet.")
def cmd_create(args):
ensure_dirs()
derive_base_if_missing()
name = args.name
v6_prefix = args.v6_prefix
v4_subnet = args.v4_subnet
endpoint = choose_endpoint(args.endpoint, args.endpoint_index)
v6r_mode = args.v6_routed or "none"
# IPv4 (/24)
v4_net, plen = v4_subnet.split("/")
if plen != "24":
sys.exit("--v4-subnet must be a /24")
o = v4_net.split(".")
if len(o) != 4:
sys.exit("Invalid IPv4 subnet")
server_v4 = f"{o[0]}.{o[1]}.{o[2]}.1"
cid = next_id()
client_v4 = f"{o[0]}.{o[1]}.{o[2]}.{cid}"
client_id_hex = f"{cid:x}"
# IPv6 (/48 → tunnel :babe::/64)
v6_root, v6_4th = parse_v6_48(v6_prefix)
tun64 = f"{v6_root}:babe::/64"
server_v6_tun = f"{v6_root}:babe::1"
client_v6_tun = f"{v6_root}:babe::{client_id_hex}"
# Routed /64
routed64 = None
client_v6_lan_gw = None
if v6r_mode == "auto":
hex4 = next_v6routed_hextet()
routed64 = f"{v6_root}:{hex4}::/64"
client_v6_lan_gw = f"{v6_root}:{hex4}::1"
elif v6r_mode and v6r_mode not in ("none", ""):
if not validate_v6_64_in_48(v6r_mode, v6_root):
sys.exit(f"Invalid --v6-routed (must be /64 within {v6_root}::/48)")
routed64 = v6r_mode
client_v6_lan_gw = v6r_mode.split("/")[0] + "1"
# Keys
CLIENT_DIR.mkdir(parents=True, exist_ok=True)
c_key = CLIENT_DIR / f"{name}.key"
c_pub = CLIENT_DIR / f"{name}.pub"
try:
priv = sh(["wg", "genkey"]).stdout.strip()
pub = sh(["wg", "pubkey"], input_bytes=(priv + "\n")).stdout.strip()
except FileNotFoundError:
sys.exit("wg not found. " + APK_HINT)
write_text(c_key, priv + "\n", 0o600)
write_text(c_pub, pub + "\n", 0o600)
srv_pub = server_pubkey()
if not srv_pub:
sys.exit(f"Server public key not found; is {WG_DEV} up?")
# Client config
c_conf = CLIENT_DIR / f"{name}.conf"
conf_txt = build_client_conf(
name, priv, srv_pub, client_v6_tun, client_v4,
routed64, client_v6_lan_gw, endpoint
)
write_text(c_conf, conf_txt, 0o600)
# Server peer snippet
peer_snippet = build_server_peer_snippet(
name, pub, client_v6_tun, client_v4, routed64
)
write_text(PEERS_DIR / f"{name}.conf", peer_snippet, 0o600)
# Rebuild + apply
# Safety guard: refuse malformed /24 host or malformed v6 list
if "AllowedIPs = " in peer_snippet and "/24" in peer_snippet and "/32" not in peer_snippet:
sys.exit("Refusing to apply: found IPv4 host with /24")
if ":/64:babe::" in peer_snippet:
sys.exit("Refusing to apply: malformed IPv6 list (missing comma)")
rebuild_server_conf()
wg_syncconf()
# index
ensure_index_header()
with INDEX_FILE.open("a") as f:
f.write(f"{cid}\t{name}\t{client_v4}/32\t{client_v6_tun}/128\t{(routed64 or '-')}\t{pub}\tactive\t{now_iso()}\n")
# Summary
print(f"Created client: {name} (ID {cid})")
print(f" Endpoint: {endpoint}")
print(f" Client tunnel v6: {client_v6_tun}/128")
print(f" Routed v6 /64: {routed64 or '-'}")
print(f" Client tunnel v4: {client_v4}/32")
print(f" Config: {c_conf}")
if args.qr:
if have_cmd("qrencode"):
print("\nQR (import in WireGuard mobile):")
p = subprocess.run(["qrencode", "-t", "ansiutf8"], input=conf_txt, text=True)
else:
print("Install qrencode to print a QR (", APK_HINT, ")", sep="")
def cmd_revoke(args):
name = args.name
ensure_dirs()
# Remove live peer (best-effort)
pub_path = CLIENT_DIR / f"{name}.pub"
if not pub_path.exists():
sys.exit(f"No such client: {name}")
pub = pub_path.read_text().strip()
sh(["wg", "set", WG_DEV, "peer", pub, "remove"], check=False)
# Move snippet out of peers.d
p = PEERS_DIR / f"{name}.conf"
if p.exists():
ts = int(time.time())
write_to = REVOKED_DIR / f"{name}.{ts}.server.conf"
shutil.move(str(p), str(write_to))
# Archive client files
ts = int(time.time())
for ext in ("pub", "key", "conf"):
f = CLIENT_DIR / f"{name}.{ext}"
if f.exists():
shutil.move(str(f), str(REVOKED_DIR / f"{name}.{ts}.{ext}"))
index_mark_revoked(name)
rebuild_server_conf()
wg_syncconf()
print(f"Revoked: {name}")
def cmd_build(_args):
ensure_dirs()
derive_base_if_missing()
rebuild_server_conf()
print(f"Rebuilt {SERVER_CONF} from {SERVER_BASE} + peers.d/*.conf")
def main():
p = argparse.ArgumentParser(description="WireGuard manager (create/list/revoke) with robust config handling.")
sub = p.add_subparsers(dest="cmd", required=True)
pc = sub.add_parser("create", help="Create a client")
pc.add_argument("--name", required=True)
pc.add_argument("--v6-prefix", required=True, help="e.g. 2a02:8012:bc57:1000::/48")
pc.add_argument("--v4-subnet", required=True, help="e.g. 10.10.10.0/24")
pc.add_argument("--endpoint", help="host:port")
pc.add_argument("--endpoint-index", help="1-based line index into endpoints.lst")
pc.add_argument("--v6-routed", choices=["none","auto"], help="none|auto or explicit V6/64")
pc.add_argument("--qr", action="store_true")
pc.set_defaults(func=cmd_create)
pr = sub.add_parser("revoke", help="Revoke a client")
pr.add_argument("--name", required=True)
pr.set_defaults(func=cmd_revoke)
pl = sub.add_parser("list", help="List clients")
pl.set_defaults(func=cmd_list)
pb = sub.add_parser("build", help="Rebuild server conf from base + peers")
pb.set_defaults(func=cmd_build)
args = p.parse_args()
# allow explicit /64 string in --v6-routed (beyond choices)
if hasattr(args, "v6_routed") and args.v6_routed:
if args.v6_routed not in ("none", "auto") and not args.v6_routed.endswith("/64"):
sys.exit("--v6-routed must be 'none', 'auto', or an explicit /64")
args.func(args)
if __name__ == "__main__":
main()
How this fixes revocation (the root of the issue)
- Each client lives in its own server snippet (
peers.d/NAME.conf
). - Revoking moves that file into
clients/revoked/
and then rebuildswg0.conf
from the clean base + remaining snippets. - Live kernel state is reconciled with
wg syncconf
so the peer is gone immediately. - No regex chopping of a monolithic file, so no partial or broken state.
Usage (Alpine / Arch)
# Packages
apk add wireguard-tools qrencode
# Arch: pacman -S wireguard-tools qrencode
# Place the script
install -m 0755 wg_manage.py /usr/local/sbin/wg-manage
# First run: ensure wg0.base exists (or let the tool derive it from your current wg0.conf)
# Then create clients
wg-manage create --name c1.alpha \
--v6-prefix 2a02:8012:bc57:1000::/48 \
--v4-subnet 10.10.10.0/24 \
--endpoint roam.ip.example:51820 \
--v6-routed auto --qr
# List
wg-manage list
# Revoke
wg-manage revoke --name c1.alpha
# Rebuild server config (e.g., after editing base or removing a stray file)
wg-manage build

Notes and small choices
- Base vs. full file: keeping
wg0.base
separate avoids ever trying to surgically editwg0.conf
. The base can include[Interface]
,Address
,ListenPort
,PostUp
/PreUp
, etc. - Endpoint rotation mirrors the original behaviour via
endpoints.lst
+ pointer file. - Routed /64 allocator persists a hex counter and skips “babe” by default (keep the easter egg intact).
- No external Python deps. QR is printed with
qrencode
if present. - Cross-platform: works anywhere
wg
andwg-quick
exist (Linux, macOS, *BSD, Windows with wireguard-tools).
Closing
With a few commands, your network stops being a place and becomes a companion. Your address space is yours — you route it, you secure it, you extend it. Whether you’re at home with a remote family, in a café, a cab, or on a construction site, your tools and data are a tunnel away. We’ve now shown how to keep your babe safe, create custom DNS per endpoint, and even have fun with unique, human-readable names. More guides like this are coming — and if you enjoyed this or need it implemented at scale for your business, get in touch. These solutions are not just for show, they’re built to work. With global GSLB, health checks, and creative thinking, the only limits are those the mind accepts — and those limits do not truly exist when the right questions are asked.
Scroll Marker: “A lamp lit in one house may guide many travelers.” — Tech Scrolls 7:13