wg-manage-go (Portable WireGuard Manager)

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, and wg 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 and wg-quick in $PATH.
  • qrencode for QR output.
    • Alpine: apk add libqrencode-tools (ensure the community repository is enabled).
    • Arch: pacman -S qrencode.
  • 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: if wg0.base is missing but wg0.conf exists, the tool derives wg0.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 without wg-quick, install a wireguard-tools build that provides it (Homebrew on macOS does; most Linuxes do). Windows users can either run under WSL with Linux tools or provide a wg-quick equivalent; the tool calls wg-quick strip before wg 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 the babe hextet and tracks used values in index.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, install libqrencode-tools and re‑run.
  • “server public key not found”: ensure wg-quick up wg0 has been run once, and WG_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 includes wg-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)