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

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)

  1. 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.
  1. …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.
  1. 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.
  1. 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 space 2001: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 put sysctl lines (like forwarding) inside wg0.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 TunnelImport 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 TunnelAdd 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)

  1. Enable forwarding:
sysctl -w net.ipv6.conf.all.forwarding=1
  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
  };
};
  1. 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 synthesized 64: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 in wg 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: if wg0.base is missing, the script will derive it by taking everything in wg0.conf above the first [Peer] and write it to wg0.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 rebuilds wg0.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 edit wg0.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 and wg-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