wg-manage-go (Portable WireGuard Manager)

Tech Scroll Companion, tech scroll 103
Parable: “Build the gate once, and travellers may pass safely a thousand times.”
In networks as in cities, reliability comes from order: one foundation, many doors. A clean base + clear peer files gives order that endures.
What this companion adds
This companion article introduces wg-manage-go, a tiny Go program that manages WireGuard® peers with the same model as the Python tool: a single server base plus one file per peer. It is:
- Truly portable: builds to a single binary for Linux, macOS, *BSD, and Windows (where
wg
/wg-quick
are available). - Minimal dependencies: needs only wireguard-tools (
wg
,wg-quick
) and qrencode (for terminal QR output). - Robust by design: revocation = move a file out of
peers.d/
, rebuild, andwg syncconf
.
This pairs with The Path to Seamless Connectivity: Full IPv6 /64 VPN Routing From Anywhere (Tech Scroll 103) and assumes the same addressing plan: a routed IPv6 /48, a tunnel :babe::/64
, and optional per‑client routed /64.
Features at a glance
- Create a peer with IPv6
/128
on the tunnel and optional routed /64 behind it, plus IPv4/32
in a/24
(dual‑stack tunnel). - Revoke a peer atomically (remove live peer, move its snippet to
revoked/
, rebuild and sync). - List peers from
clients/index.tsv
. - Build the server config from
wg0.base
+peers.d/*.conf
(idempotent). - Endpoint rotation: optional
endpoints.lst
with a pointer file for round‑robin selection. - Safety guards: refuses malformed
AllowedIPs
(e.g., IPv4 host/24
, or broken IPv6 commas).
Requirements
- WireGuard tools on the host:
wg
andwg-quick
in$PATH
. - qrencode for QR output.
- Alpine:
apk add libqrencode-tools
(ensure the community repository is enabled). - Arch:
pacman -S qrencode
.
- Alpine:
- Go toolchain on the build machine.
Directory layout (managed by the tool)
/etc/wireguard/
wg0.base # server base (everything above the first [Peer])
wg0.conf # generated from base + peers.d/*.conf
peers.d/ # one [Peer] snippet per client (ACTIVE)
clients/
index.tsv # allocations & status
NAME.key/.pub/.conf
revoked/ # archived client materials
endpoints.lst # optional; one host:port per line
.endpoint.ptr # auto‑rotating pointer
.client-id-counter # IPv4 host ID & IPv6 IID counter
.v6routed-counter # allocator for routed /64s
First run convenience: ifwg0.base
is missing butwg0.conf
exists, the tool deriveswg0.base
from the header above the first[Peer]
.
Get the code
The complete source is wg-manage-go.go
. Save it as that filename on the target build machine.
Please note Line 91, you will need to change this to your base /48 network, look for 2a02:8012:bc57:babe::/64
and change to your own subnet! In hindsight we should have given this a configuration file to reference
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Breath Technology
//
// wg-manage-go: WireGuard manager in Go (create/list/revoke/build)
// Model: server config = wg0.base + peers.d/*.conf (atomic, robust)
// Requires: wireguard-tools (wg, wg-quick). Optional: qrencode for --qr.
// Alpine/Arch friendly. Edit with vim.
//
// Env vars:
// WG_DEV (default: wg0)
// WG_DIR (default: /etc/wireguard)
//
// Build (static-ish):
// CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /usr/local/sbin/wg-manage-go wg-manage-go.go
/*
MIT License
Copyright (c) 2025 Breath Technology
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
var (
wgDev = getenv("WG_DEV", "wg0")
wgDir = filepath.Clean(getenv("WG_DIR", "/etc/wireguard"))
serverConf = filepath.Join(wgDir, wgDev+".conf")
serverBase = filepath.Join(wgDir, wgDev+".base")
peersDir = filepath.Join(wgDir, "peers.d")
clientDir = filepath.Join(wgDir, "clients")
revokedDir = filepath.Join(clientDir, "revoked")
indexFile = filepath.Join(clientDir, "index.tsv")
counterFile = filepath.Join(wgDir, ".client-id-counter")
v6rCounter = filepath.Join(wgDir, ".v6routed-counter")
endpointsLst = filepath.Join(wgDir, "endpoints.lst")
endpointPtr = filepath.Join(wgDir, ".endpoint.ptr")
apkHint = "apk add wireguard-tools qrencode # Arch: pacman -S wireguard-tools qrencode"
)
func main() {
if len(os.Args) < 2 {
usage()
return
}
switch os.Args[1] {
case "create":
createCmd(os.Args[2:])
case "revoke":
revokeCmd(os.Args[2:])
case "list":
listCmd()
case "build":
buildCmd()
case "-h", "--help", "help":
usage()
default:
fail("unknown subcommand: %s", os.Args[1])
}
}
func usage() {
fmt.Println(`wg-manage-go — create/list/revoke WireGuard clients with IPv6 (/48) + IPv4 (/24)
Usage:
wg-manage-go create --name NAME --v6-prefix V6/48 --v4-subnet V4/24 [--endpoint HOST:PORT | --endpoint-index N] [--v6-routed auto|none|V6/64] [--qr]
wg-manage-go revoke --name NAME
wg-manage-go list
wg-manage-go build
Env:
WG_DEV=` + wgDev + ` WG_DIR=` + wgDir + `
` + apkHint)
}
func createCmd(args []string) {
fs := flag.NewFlagSet("create", flag.ExitOnError)
name := fs.String("name", "", "")
// CHANGE YOUR PREVIX for v6 and v4-prefix
v6prefix := fs.String("v6-prefix", "", "e.g. 2a02:0000:0000:babe::/64")
v4subnet := fs.String("v4-subnet", "", "e.g. 10.10.10.0/24")
endpoint := fs.String("endpoint", "", "host:port")
epIndex := fs.Int("endpoint-index", 0, "1-based index in endpoints.lst")
v6routed := fs.String("v6-routed", "none", "none|auto or explicit /64 inside the /48")
showQR := fs.Bool("qr", false, "print QR in terminal (requires qrencode)")
_ = fs.Parse(args)
require(*name != "", "--name required")
require(*v6prefix != "", "--v6-prefix required")
require(*v4subnet != "", "--v4-subnet required")
ensureDirs()
deriveBaseIfMissing()
ep := chooseEndpoint(strings.TrimSpace(*endpoint), *epIndex)
// IPv4 (/24)
v4net, plen, ok := strings.Cut(*v4subnet, "/")
require(ok, "--v4-subnet must be CIDR")
require(plen == "24", "--v4-subnet must be a /24")
oct := strings.Split(v4net, ".")
require(len(oct) == 4, "invalid IPv4 subnet")
cid := nextID()
clientV4 := fmt.Sprintf("%s.%s.%s.%d", oct[0], oct[1], oct[2], cid)
// serverV4 := fmt.Sprintf("%s.%s.%s.1", oct[0], oct[1], oct[2])
// IPv6 (/48 → :babe::/64)
v6root, _ := parseV6_48(*v6prefix)
clientIDHex := fmt.Sprintf("%x", cid)
clientV6Tun := v6root + ":babe::" + clientIDHex
// serverV6Tun := v6root + ":babe::1"
// Routed /64
var routed64 string
var clientV6LanGW string
switch {
case *v6routed == "auto":
hex4 := nextV6routedHextet()
routed64 = fmt.Sprintf("%s:%s::/64", v6root, hex4)
clientV6LanGW = fmt.Sprintf("%s:%s::1", v6root, hex4)
case *v6routed == "none" || *v6routed == "":
// none
default:
require(validateV6_64_in_48(*v6routed, v6root),
"Invalid --v6-routed (must be /64 within %s::/48)", v6root)
routed64 = *v6routed
root := strings.Split(routed64, "/")[0]
clientV6LanGW = root + "1"
}
// Keys
priv, pub := genKeypair()
// Server pubkey (wg must exist and interface up for this)
srvPub := serverPubKey()
require(srvPub != "", "server public key not found; is %s up? (%s)", wgDev, apkHint)
// Client files
clientKey := filepath.Join(clientDir, *name+".key")
clientPub := filepath.Join(clientDir, *name+".pub")
clientConf := filepath.Join(clientDir, *name+".conf")
writeFile0600(clientKey, []byte(priv+"\n"))
writeFile0600(clientPub, []byte(pub+"\n"))
confTxt := buildClientConf(*name, priv, srvPub, clientV6Tun, clientV4, routed64, clientV6LanGW, ep)
writeFile0600(clientConf, []byte(confTxt))
// Server snippet
peerSnippet := buildServerPeerSnippet(*name, pub, clientV6Tun, clientV4, routed64)
// Hygiene checks like the Python version
if strings.Contains(peerSnippet, "AllowedIPs = ") && strings.Contains(peerSnippet, "/24") && !strings.Contains(peerSnippet, "/32") {
fail("Refusing to apply: found IPv4 host with /24")
}
if strings.Contains(peerSnippet, ":/64:babe::") {
fail("Refusing to apply: malformed IPv6 list (missing comma)")
}
writeFile0600(filepath.Join(peersDir, *name+".conf"), []byte(peerSnippet))
// Rebuild + apply
rebuildServerConf()
wgSyncConf()
// index.tsv
ensureIndexHeader()
appendIndex(fmt.Sprintf("%d\t%s\t%s/32\t%s/128\t%s\t%s\tactive\t%s\n",
cid, *name, clientV4, clientV6Tun, nz(routed64, "-"), pub, nowISO()))
// Summary
fmt.Printf("Created client: %s (ID %d)\n", *name, cid)
fmt.Printf(" Endpoint: %s\n", ep)
fmt.Printf(" Client tunnel v6: %s/128\n", clientV6Tun)
fmt.Printf(" Routed v6 /64: %s\n", nz(routed64, "-"))
fmt.Printf(" Client tunnel v4: %s/32\n", clientV4)
fmt.Printf(" Config: %s\n", clientConf)
if *showQR {
showQRFromString(confTxt)
}
}
func revokeCmd(args []string) {
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
name := fs.String("name", "", "")
_ = fs.Parse(args)
require(*name != "", "--name required")
ensureDirs()
pubPath := filepath.Join(clientDir, *name+".pub")
if !exists(pubPath) {
fail("No such client: %s", *name)
}
pub := strings.TrimSpace(readFile(pubPath))
_, _, _ = runCmd("wg", "set", wgDev, "peer", pub, "remove") // best-effort
// Move server snippet
ts := strconv.FormatInt(time.Now().Unix(), 10)
peerPath := filepath.Join(peersDir, *name+".conf")
if exists(peerPath) {
mv(peerPath, filepath.Join(revokedDir, *name+"."+ts+".server.conf"))
}
// Archive client files
for _, ext := range []string{"pub", "key", "conf"} {
p := filepath.Join(clientDir, *name+"."+ext)
if exists(p) {
mv(p, filepath.Join(revokedDir, *name+"."+ts+"."+ext))
}
}
indexMarkRevoked(*name)
rebuildServerConf()
wgSyncConf()
fmt.Printf("Revoked: %s\n", *name)
}
func listCmd() {
if !exists(indexFile) {
fmt.Println("No clients yet.")
return
}
fmt.Println("id\tname\tv4\tv6_tun\tv6_routed\tpubkey\tstatus\tcreated")
f := readFile(indexFile)
lines := strings.Split(strings.TrimRight(f, "\n"), "\n")
for i, ln := range lines {
if i == 0 {
continue // header already printed
}
fmt.Println(ln)
}
}
func buildCmd() {
ensureDirs()
deriveBaseIfMissing()
rebuildServerConf()
fmt.Printf("Rebuilt %s from %s + peers.d/*.conf\n", serverConf, serverBase)
}
// ------------------ Helpers ------------------
func getenv(k, def string) string {
if v, ok := os.LookupEnv(k); ok && v != "" {
return v
}
return def
}
func ensureDirs() {
_ = os.MkdirAll(peersDir, 0o755)
_ = os.MkdirAll(clientDir, 0o755)
_ = os.MkdirAll(revokedDir, 0o755)
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func readFile(p string) string {
b, err := os.ReadFile(p)
if err != nil {
fail("read %s: %v", p, err)
}
return string(b)
}
func writeFile0600(p string, b []byte) {
if err := os.WriteFile(p, b, 0o600); err != nil {
fail("write %s: %v", p, err)
}
}
func appendIndex(s string) {
f, err := os.OpenFile(indexFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
fail("open %s: %v", indexFile, err)
}
defer f.Close()
if _, err := f.WriteString(s); err != nil {
fail("write %s: %v", indexFile, err)
}
}
func nz(s, def string) string {
if s == "" {
return def
}
return s
}
func nowISO() string {
// RFC3339 UTC without subsec, e.g., 2025-08-10T22:04:00Z
return time.Now().UTC().Truncate(time.Second).Format(time.RFC3339)
}
func runCmd(cmd string, args ...string) (string, string, error) {
c := exec.Command(cmd, args...)
var outb, erb bytes.Buffer
c.Stdout = &outb
c.Stderr = &erb
err := c.Run()
return outb.String(), erb.String(), err
}
func runCmdInput(stdin string, cmd string, args ...string) (string, string, error) {
c := exec.Command(cmd, args...)
c.Stdin = strings.NewReader(stdin)
var outb, erb bytes.Buffer
c.Stdout = &outb
c.Stderr = &erb
err := c.Run()
return outb.String(), erb.String(), err
}
func serverPubKey() string {
out, _, _ := runCmd("wg", "show", wgDev, "public-key")
return strings.TrimSpace(out)
}
func wgSyncConf() {
stripOut, _, err := runCmd("wg-quick", "strip", serverConf)
if err != nil {
fail("wg-quick strip failed. %s", apkHint)
}
tmp := serverConf + ".sync.tmp"
writeFile0600(tmp, []byte(stripOut))
_, _, _ = runCmd("wg", "syncconf", wgDev, tmp)
_ = os.Remove(tmp)
}
func parseV6_48(prefix48 string) (string, string) {
root := strings.Split(prefix48, "/")[0]
h := strings.Split(root, ":")
for len(h) < 8 {
h = append(h, "0")
}
return strings.ToLower(fmt.Sprintf("%s:%s:%s", h[0], h[1], h[2])), strings.ToLower(h[3])
}
func validateV6_64_in_48(want64, root48 string) bool {
if !strings.HasSuffix(want64, "/64") {
return false
}
nopl := strings.Split(want64, "/")[0]
h := strings.Split(nopl, ":")
for len(h) < 8 {
h = append(h, "0")
}
return strings.ToLower(fmt.Sprintf("%s:%s:%s", h[0], h[1], h[2])) == strings.ToLower(root48)
}
func loadUsedRoutedHextets() map[string]struct{} {
used := make(map[string]struct{})
if !exists(indexFile) {
return used
}
content := readFile(indexFile)
lines := strings.Split(content, "\n")
for i, ln := range lines {
if i == 0 || strings.TrimSpace(ln) == "" {
continue
}
parts := strings.Split(ln, "\t")
if len(parts) < 8 {
continue
}
routed := strings.TrimSpace(parts[4])
if routed == "-" || !strings.Contains(routed, "/64") {
continue
}
root := strings.Split(routed, "/")[0]
h := strings.Split(root, ":")
for len(h) < 8 {
h = append(h, "0")
}
used[strings.ToLower(h[3])] = struct{}{}
}
return used
}
func nextV6routedHextet() string {
startHex := "2000"
if exists(v6rCounter) {
sh := strings.TrimSpace(readFile(v6rCounter))
if sh != "" {
startHex = sh
}
}
cur, err := strconv.ParseInt(startHex, 16, 64)
if err != nil {
cur = 0x2000
}
used := loadUsedRoutedHextets()
skip := map[string]struct{}{"babe": {}}
for ; cur <= 0xFFFF; cur++ {
h := fmt.Sprintf("%04x", cur)
if _, bad := skip[h]; bad {
continue
}
if _, seen := used[h]; !seen {
writeFile0600(v6rCounter, []byte(h))
return h
}
}
fail("Exhausted /64 allocations under this /48")
return "" // unreachable
}
func ensureIndexHeader() {
if !exists(indexFile) {
writeFile0600(indexFile, []byte("id\tname\tv4\tv6_tun\tv6_routed\tpubkey\tstatus\tcreated\n"))
}
}
func indexMarkRevoked(name string) {
if !exists(indexFile) {
return
}
content := readFile(indexFile)
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
if len(lines) == 0 {
return
}
out := []string{lines[0]}
for _, ln := range lines[1:] {
if strings.TrimSpace(ln) == "" {
continue
}
parts := strings.Split(ln, "\t")
if len(parts) >= 8 && parts[1] == name {
parts[6] = "revoked"
ln = strings.Join(parts, "\t")
}
out = append(out, ln)
}
writeFile0600(indexFile, []byte(strings.Join(out, "\n")+"\n"))
}
func buildClientConf(name, privkey, serverPub, clientV6Tun, clientV4, routed64, clientV6LanGW, endpoint string) string {
addr := []string{clientV6Tun + "/128", clientV4 + "/32"}
if routed64 != "" {
addr = append(addr, clientV6LanGW+"/64")
}
var b strings.Builder
b.WriteString(fmt.Sprintf("# %s — generated %s\n", name, nowISO()))
b.WriteString("[Interface]\n")
b.WriteString("PrivateKey = " + privkey + "\n")
b.WriteString("Address = " + strings.Join(addr, ", ") + "\n")
b.WriteString("\n[Peer]\n")
b.WriteString("PublicKey = " + serverPub + "\n")
b.WriteString("Endpoint = " + endpoint + "\n")
b.WriteString("AllowedIPs = ::/0, 0.0.0.0/0\n")
b.WriteString("PersistentKeepalive = 25\n")
return b.String()
}
func buildServerPeerSnippet(name, pubkey, clientV6Tun, clientV4, routed64 string) string {
var b strings.Builder
b.WriteString("# " + name + "\n")
b.WriteString("[Peer]\n")
b.WriteString("PublicKey = " + pubkey + "\n")
if routed64 != "" {
b.WriteString("# Client /128 on tunnel and a routed /64 behind it; plus dual-stack IPv4\n")
b.WriteString("AllowedIPs = " + clientV6Tun + "/128, " + routed64 + ", " + clientV4 + "/32\n")
} else {
b.WriteString("# Client /128 on tunnel; plus dual-stack IPv4 (no routed /64)\n")
b.WriteString("AllowedIPs = " + clientV6Tun + "/128, " + clientV4 + "/32\n")
}
return b.String()
}
func ensureFile(path string) error {
if exists(path) {
return nil
}
return os.WriteFile(path, []byte{}, 0o600)
}
func deriveBaseIfMissing() {
if exists(serverBase) {
return
}
if !exists(serverConf) {
fail("Missing %s and %s; provide a base or an initial conf.", serverBase, serverConf)
}
lines := strings.Split(readFile(serverConf), "\n")
base := make([]string, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(line) == "[Peer]" {
break
}
base = append(base, line)
}
if len(base) == 0 {
fail("Unable to derive %s; %s has no header above [Peer].", serverBase, serverConf)
}
writeFile0600(serverBase, []byte(strings.TrimRight(strings.Join(base, "\n"), "\n")+"\n"))
}
func rebuildServerConf() {
deriveBaseIfMissing()
parts := []string{strings.TrimRight(readFile(serverBase), "\n")}
entries, _ := os.ReadDir(peersDir)
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, n := range names {
parts = append(parts, "") // spacer
parts = append(parts, strings.TrimRight(readFile(filepath.Join(peersDir, n)), "\n"))
}
writeFile0600(serverConf, []byte(strings.Join(parts, "\n")+"\n"))
}
func chooseEndpoint(explicit string, idx int) string {
if explicit != "" {
return explicit
}
if idx > 0 {
if !exists(endpointsLst) {
fail("--endpoint-index used but %s missing", endpointsLst)
}
lines := nonEmptyLines(readFile(endpointsLst))
if idx < 1 || idx > len(lines) {
fail("--endpoint-index out of range")
}
return strings.TrimSpace(lines[idx-1])
}
if exists(endpointsLst) {
lines := nonEmptyLines(readFile(endpointsLst))
if len(lines) == 0 {
fail("%s is empty", endpointsLst)
}
cur := 1
if exists(endpointPtr) {
if v, err := strconv.Atoi(strings.TrimSpace(readFile(endpointPtr))); err == nil && v >= 1 {
cur = v
}
}
if cur > len(lines) {
cur = 1
}
nxt := (cur % len(lines)) + 1
writeFile0600(endpointPtr, []byte(strconv.Itoa(nxt)))
return strings.TrimSpace(lines[cur-1])
}
fail("Endpoint required (use --endpoint or provide endpoints.lst)")
return ""
}
func nonEmptyLines(s string) []string {
raw := strings.Split(s, "\n")
out := make([]string, 0, len(raw))
for _, l := range raw {
l = strings.TrimSpace(l)
if l != "" {
out = append(out, l)
}
}
return out
}
func nextID() int {
cur := 1
if exists(counterFile) {
if v, err := strconv.Atoi(strings.TrimSpace(readFile(counterFile))); err == nil {
cur = v
}
}
newv := cur + 1
if newv > 250 {
fail("Too many clients for /24")
}
writeFile0600(counterFile, []byte(strconv.Itoa(newv)))
return newv
}
func mv(src, dst string) {
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
if err := os.Rename(src, dst); err != nil {
fail("move %s -> %s: %v", src, dst, err)
}
}
func showQRFromString(s string) {
// LookPath avoids false negatives: qrencode exits non-zero if stdin is empty.
if _, err := exec.LookPath("qrencode"); err != nil {
fmt.Println("qrencode not found. Install it to print a QR (", apkHint, ")")
return
}
c := exec.Command("qrencode", "-t", "ansiutf8")
c.Stdin = strings.NewReader(s)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
_ = c.Run()
}
func genKeypair() (priv, pub string) {
out, _, err := runCmd("wg", "genkey")
if err != nil {
fail("wg not found or genkey failed. %s", apkHint)
}
priv = strings.TrimSpace(out)
out2, _, err2 := runCmdInput(priv+"\n", "wg", "pubkey")
if err2 != nil {
fail("wg pubkey failed")
}
pub = strings.TrimSpace(out2)
if priv == "" || pub == "" {
fail("empty keypair generated")
}
return priv, pub
}
func require(cond bool, format string, a ...any) {
if !cond {
fail(format, a...)
}
}
func fail(format string, a ...any) {
msg := fmt.Sprintf(format, a...)
_, _ = fmt.Fprintln(os.Stderr, "Error:", msg)
os.Exit(1)
}
// -------------- optional small errors --------------
var _ = errors.New // silence import if unused on some Go versions
Build (single binary)
Alpine / Arch (x86_64 and aarch64)
# Tooling
apk add go wireguard-tools # Arch: pacman -S go wireguard-tools
# Build a lean binary (no CGO)
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /usr/local/sbin/wg-manage-go wg-manage-go.go
Cross‑compile examples (run on any Go host):
# Linux amd64 / arm64
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o wg-manage-go-linux-amd64 wg-manage-go.go
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o wg-manage-go-linux-arm64 wg-manage-go.go
# macOS amd64 / arm64
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o wg-manage-go-darwin-amd64 wg-manage-go.go
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o wg-manage-go-darwin-arm64 wg-manage-go.go
# Windows amd64
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o wg-manage-go.exe wg-manage-go.go
On platforms withoutwg-quick
, install awireguard-tools
build that provides it (Homebrew on macOS does; most Linuxes do). Windows users can either run under WSL with Linux tools or provide awg-quick
equivalent; the tool callswg-quick strip
beforewg syncconf
.
Bootstrap the server (from a clean /etc/wireguard/
)
# 1) Create directories
install -d -m 0700 /etc/wireguard/clients /etc/wireguard/peers.d
# 2) Server keypair
umask 077
wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
chmod 600 /etc/wireguard/server_private.key
# 3) Create /etc/wireguard/wg0.base (edit with vim)
vim /etc/wireguard/wg0.base
# Paste and adjust:
# [Interface]
# PrivateKey = <contents of /etc/wireguard/server_private.key>
# Address = 2a02:0000:0000:babe::1/64, 10.10.10.1/24
# ListenPort = 51820
# SaveConfig = true
chmod 600 /etc/wireguard/wg0.base
# 4) Bring the interface up once so the server’s public key is readable
wg-quick up wg0
Use your own IPv6 /64 for production. Documentation ranges use 2a02:0000:0000::/48
.
Usage
Create a client
wg-manage-go create \
--name c1.alpha \
--v6-prefix 2a02:8012:bc57:babe::/48 \
--v4-subnet 10.10.10.0/24 \
--endpoint roam.breathtechnology.co.:51820 \
--v6-routed auto --qr
This writes clients/c1.alpha.key|pub|conf
, adds peers.d/c1.alpha.conf
, rebuilds wg0.conf
, and runs wg syncconf
. If --qr
is used and qrencode
is present, a terminal QR appears for mobile import.

List peers
wg-manage-go list

Revoke a client
wg-manage-go revoke --name c1.alpha
Revocation removes the live peer, moves the snippet to clients/revoked/
, rebuilds, and syncs.
> ./wg-manage-go revoke --name c1.alpha
Revoked: c1.alpha
Rebuild config (idempotent)
wg-manage-go build
Addressing recap
From a single IPv6 /48 (e.g., 2001:db8:1234::/48
) reserve:
- Tunnel
/64
:…:babe::/64
(server…:babe::1
, clients…:babe::<id>
as/128
). - Optional per‑client /64:
--v6-routed auto
allocates unique 4th‑hextets under the same/48
. - Dual‑stack: add IPv4
/32
hosts from a/24
(e.g.,10.10.10.0/24
).
The allocator deliberately skips thebabe
hextet and tracks used values inindex.tsv
.
QR on Alpine and Arch
- Alpine:
apk add libqrencode-tools
(with community repo enabled). - Arch:
pacman -S qrencode
.
Manual QR if preferred:
qrencode -t ansiutf8 < /etc/wireguard/clients/c1.alpha.conf
Security and hygiene
- Keep private keys
0600
and off backups where not required. - Segment routes per‑peer via
AllowedIPs
(least privilege). - Keep firewall rules explicit and allow established/related in both input and forward chains.
- The tool guards against common
AllowedIPs
mistakes and keeps a timestamped index for auditability.
Troubleshooting quick notes
- QR message still appears: confirm
command -v qrencode
resolves. On Alpine, installlibqrencode-tools
and re‑run. - “server public key not found”: ensure
wg-quick up wg0
has been run once, andWG_DEV
matches the interface name. - No per‑client /64: ensure
--v6-prefix
is a/48
root, not a/64
. - Windows/macOS: ensure the
wireguard-tools
build includeswg-quick
. If not, run under WSL/macOS Homebrew where it is provided.
Closing thought
“A city with clear roads welcomes many; a network with clear paths welcomes every allowed device.”
A tidy base file, one peer per file, and a rebuild‑and‑sync workflow turn management from brittle edits into calm routine. Steward the address space; the travellers will find the way. (Tech Scroll 1.04)