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-quickare 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 
/128on the tunnel and optional routed /64 behind it, plus IPv4/32in 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.lstwith 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: 
wgandwg-quickin$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.baseis missing butwg0.confexists, the tool deriveswg0.basefrom 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-toolsbuild that provides it (Homebrew on macOS does; most Linuxes do). Windows users can either run under WSL with Linux tools or provide awg-quickequivalent; the tool callswg-quick stripbeforewg 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.alphaRebuild 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 autoallocates unique 4th‑hextets under the same/48. - Dual‑stack: add IPv4 
/32hosts from a/24(e.g.,10.10.10.0/24). 
The allocator deliberately skips thebabehextet 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 
0600and 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 
AllowedIPsmistakes and keeps a timestamped index for auditability. 
Troubleshooting quick notes
- QR message still appears: confirm 
command -v qrencoderesolves. On Alpine, installlibqrencode-toolsand re‑run. - “server public key not found”: ensure 
wg-quick up wg0has been run once, andWG_DEVmatches the interface name. - No per‑client /64: ensure 
--v6-prefixis a/48root, not a/64. - Windows/macOS: ensure the 
wireguard-toolsbuild 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)