chore: initial commit of wgtool

This commit is contained in:
sapient
2026-03-22 00:54:58 -07:00
commit f9529c6055
59 changed files with 6102 additions and 0 deletions

482
cmd/wgtool/main.go Normal file
View File

@@ -0,0 +1,482 @@
package main
import (
"bufio"
"crypto/rand"
"encoding/base64"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"golang.org/x/crypto/curve25519"
)
const (
toolVersion = "1.0.0"
colorRed = "\033[0;31m"
colorGreen = "\033[0;32m"
colorYellow = "\033[1;33m"
colorBlue = "\033[0;34m"
colorReset = "\033[0m"
)
var (
ipCIDRRegex = regexp.MustCompile(`^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$`)
ipWGRangeRegex = regexp.MustCompile(`^10\.8\.0\.[0-9]{1,3}$`)
hostRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
keyRegex = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
)
func info(msg string) { fmt.Printf("%s[INFO]%s %s\n", colorGreen, colorReset, msg) }
func warn(msg string) { fmt.Printf("%s[WARN]%s %s\n", colorYellow, colorReset, msg) }
func fail(msg string) { fmt.Printf("%s[ERROR]%s %s\n", colorRed, colorReset, msg) }
func head(title string) {
fmt.Printf("%s================================%s\n", colorBlue, colorReset)
fmt.Printf("%s%s%s\n", colorBlue, title, colorReset)
fmt.Printf("%s================================%s\n", colorBlue, colorReset)
}
// ============= Shared validation helpers =============
func validateHostname(s string) error {
if s == "" {
return errors.New("hostname cannot be empty")
}
if len(s) > 63 {
return errors.New("hostname too long (max 63 chars)")
}
if !hostRegex.MatchString(s) {
return errors.New("invalid hostname format")
}
return nil
}
func validateIPHost(s string) error {
if !ipWGRangeRegex.MatchString(s) {
return errors.New("IP must be in 10.8.0.x range")
}
return nil
}
func validateCIDR(s string) error {
if !ipCIDRRegex.MatchString(s) {
return errors.New("invalid CIDR (x.x.x.x/y)")
}
return nil
}
func validateWGKey(s string) error {
if !keyRegex.MatchString(s) {
return errors.New("invalid WireGuard key format")
}
return nil
}
// ============= Key generation =============
func generateKeys() (string, string, error) {
priv := make([]byte, 32)
if _, err := rand.Read(priv); err != nil {
return "", "", err
}
// curve25519 clamping
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
// Derive public key using curve25519
// Use the same approach as wg: ScalarBaseMult on clamped private key
var privArr [32]byte
copy(privArr[:], priv)
var pubArr [32]byte
// defer import here to avoid top-level dependency note in comments
pubArr = derivePublicKey(privArr)
return base64.StdEncoding.EncodeToString(priv), base64.StdEncoding.EncodeToString(pubArr[:]), nil
}
// derivePublicKey performs curve25519 base point multiplication.
// Implemented via a tiny wrapper so we can keep generateKeys concise.
func derivePublicKey(priv [32]byte) [32]byte {
// inline import pattern is not possible; real import placed at top
// function body replaced by the real call below after adding import
return curve25519BaseMult(priv)
}
// curve25519BaseMult is a small shim around golang.org/x/crypto/curve25519.ScalarBaseMult
// defined below to keep the public key derivation isolated.
func curve25519BaseMult(priv [32]byte) [32]byte {
var out [32]byte
curve25519.ScalarBaseMult(&out, &priv)
return out
}
// ============= Config generation =============
type genOptions struct {
hostname string
ip string
iface string
routing string // wg_only | full_tunnel
outDir string
force bool
nonInteractive bool
}
func runGenerate(args []string) int {
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
fs.SetOutput(new(strings.Builder))
opts := genOptions{}
fs.StringVar(&opts.hostname, "hostname", "", "Node hostname (e.g. aza)")
fs.StringVar(&opts.ip, "ip", "", "Node IP (10.8.0.x)")
fs.StringVar(&opts.iface, "interface", "wg0", "Interface name (e.g. wg0)")
fs.StringVar(&opts.routing, "routing", "wg_only", "Routing mode: wg_only | full_tunnel")
fs.StringVar(&opts.outDir, "out", "wireguard_configs", "Output directory for configs")
fs.BoolVar(&opts.force, "force", false, "Overwrite existing files without prompt")
fs.BoolVar(&opts.nonInteractive, "yes", false, "Non-interactive mode (assume yes)")
if err := fs.Parse(args); err != nil {
fail(err.Error())
return 2
}
// Interactive fallback for missing fields
reader := bufio.NewReader(os.Stdin)
if opts.hostname == "" {
fmt.Print("Enter hostname: ")
s, _ := reader.ReadString('\n')
opts.hostname = strings.TrimSpace(s)
}
if err := validateHostname(opts.hostname); err != nil {
fail(err.Error())
return 2
}
if opts.ip == "" {
fmt.Print("Enter IP (10.8.0.x): ")
s, _ := reader.ReadString('\n')
opts.ip = strings.TrimSpace(s)
}
if err := validateIPHost(opts.ip); err != nil {
fail(err.Error())
return 2
}
if opts.iface == "" {
opts.iface = "wg0"
}
if opts.routing != "wg_only" && opts.routing != "full_tunnel" {
fail("routing must be wg_only or full_tunnel")
return 2
}
if err := os.MkdirAll(opts.outDir, 0755); err != nil {
fail(err.Error())
return 1
}
priv, pub, err := generateKeys()
if err != nil {
fail(fmt.Sprintf("failed to generate keys: %v", err))
return 1
}
// Build config
var b strings.Builder
// Header with public key and instructions for peers
b.WriteString("# ================================================\n")
b.WriteString("# Node: " + opts.hostname + "\n")
b.WriteString("# PublicKey: " + pub + "\n")
b.WriteString("#\n")
b.WriteString("# Add this peer to Zion (/etc/wireguard/wg0.conf):\n")
b.WriteString("# [Peer]\n")
b.WriteString("# PublicKey = " + pub + "\n")
b.WriteString("# AllowedIPs = " + opts.ip + "/32\n")
b.WriteString("# ================================================\n\n")
b.WriteString("[Interface]\n")
// Default /24
b.WriteString(fmt.Sprintf("Address = %s/24\n", opts.ip))
b.WriteString(fmt.Sprintf("PrivateKey = %s\n", priv))
if opts.routing == "full_tunnel" {
b.WriteString("DNS = 1.1.1.1, 8.8.8.8\n")
}
// Default Zion peer (from scripts)
b.WriteString("\n# Zion (central server)\n")
b.WriteString("[Peer]\n")
b.WriteString("PublicKey = 2ztJbrN1x1NWanzPGLiKL19ZkdOhm5Y7WeKEWBT5cyg=\n")
if opts.routing == "full_tunnel" {
b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
} else {
b.WriteString("AllowedIPs = 10.8.0.0/24\n")
}
b.WriteString("Endpoint = ugh.im:51820\n")
b.WriteString("PersistentKeepalive = 25\n")
confFilename := fmt.Sprintf("%s_%s.conf", opts.hostname, opts.iface)
confPath := filepath.Join(opts.outDir, confFilename)
if _, err := os.Stat(confPath); err == nil && !(opts.force || opts.nonInteractive) {
warn(fmt.Sprintf("%s exists", confPath))
fmt.Print("Overwrite? (y/N): ")
ans, _ := reader.ReadString('\n')
ans = strings.ToLower(strings.TrimSpace(ans))
if ans != "y" && ans != "yes" {
fail("aborted")
return 1
}
}
if err := os.WriteFile(confPath, []byte(b.String()), 0600); err != nil {
fail(err.Error())
return 1
}
// Save keys for convenience
_ = os.WriteFile(filepath.Join(opts.outDir, opts.hostname+"_private.key"), []byte(priv), 0600)
if pub != "" {
_ = os.WriteFile(filepath.Join(opts.outDir, opts.hostname+"_public.key"), []byte(pub), 0600)
}
info(fmt.Sprintf("config written: %s", confPath))
info("set permissions: chmod 600 <file>")
return 0
}
// ============= Validator (parity with validate_config.sh) =============
func validateConfigFile(path string) (int, int) {
data, err := os.ReadFile(path)
if err != nil {
fail(fmt.Sprintf("%s: %v", path, err))
return 1, 0
}
lines := strings.Split(string(data), "\n")
inInterface := false
inPeer := false
hasInterface := false
hasPriv := false
hasAddr := false
errs := 0
warns := 0
printPath := func() { head("Validating: " + path) }
printPath()
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if line == "[Interface]" {
inInterface, inPeer, hasInterface = true, false, true
continue
}
if line == "[Peer]" {
inInterface, inPeer = false, true
continue
}
if inInterface {
if strings.HasPrefix(line, "PrivateKey=") || strings.HasPrefix(line, "PrivateKey =") {
value := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(line, " ", ""), "PrivateKey="))
if validateWGKey(value) == nil {
info("valid private key")
} else {
fail("invalid private key")
errs++
}
if validateWGKey(value) == nil {
hasPriv = true
}
} else if strings.HasPrefix(line, "Address=") || strings.HasPrefix(line, "Address =") {
value := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(line, " ", ""), "Address="))
if validateCIDR(value) == nil {
info("valid address " + value)
} else {
fail("invalid address " + value)
errs++
}
if validateCIDR(value) == nil {
hasAddr = true
}
}
}
if inPeer {
if strings.HasPrefix(line, "PublicKey=") || strings.HasPrefix(line, "PublicKey =") {
value := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(line, " ", ""), "PublicKey="))
if validateWGKey(value) == nil {
info("valid peer public key")
} else {
fail("invalid peer public key")
errs++
}
} else if strings.HasPrefix(line, "AllowedIPs=") || strings.HasPrefix(line, "AllowedIPs =") {
value := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(line, " ", ""), "AllowedIPs="))
ips := strings.Split(value, ",")
for _, ip := range ips {
if validateCIDR(strings.TrimSpace(ip)) == nil {
info("valid allowed IP " + strings.TrimSpace(ip))
} else {
fail("invalid allowed IP " + strings.TrimSpace(ip))
errs++
}
}
}
}
}
if !hasInterface {
fail("missing [Interface] section")
errs++
}
if !hasPriv {
fail("missing PrivateKey in [Interface]")
errs++
}
if !hasAddr {
warn("missing Address in [Interface]")
warns++
}
if errs == 0 && warns == 0 {
info("file is valid")
}
return errs, warns
}
func runValidate(args []string) int {
fs := flag.NewFlagSet("validate", flag.ContinueOnError)
fs.SetOutput(new(strings.Builder))
var target string
fs.StringVar(&target, "target", "", "Config file or directory to validate")
if err := fs.Parse(args); err != nil {
fail(err.Error())
return 2
}
if target == "" {
if fs.NArg() > 0 {
target = fs.Arg(0)
}
}
if target == "" {
fail("provide a file or directory via --target or arg")
return 2
}
st, err := os.Stat(target)
if err != nil {
fail(err.Error())
return 1
}
if st.Mode().IsRegular() {
errs, _ := validateConfigFile(target)
if errs > 0 {
return 1
}
return 0
}
if st.IsDir() {
totalErr := 0
_ = filepath.Walk(target, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(strings.ToLower(info.Name()), ".conf") {
e, _ := validateConfigFile(path)
totalErr += e
}
return nil
})
if totalErr > 0 {
return 1
}
return 0
}
fail("target must be file or directory")
return 2
}
// ============= Zion peer snippet =============
func runZionPeer(args []string) int {
fs := flag.NewFlagSet("zion-peer", flag.ContinueOnError)
fs.SetOutput(new(strings.Builder))
var name, pub, ip string
fs.StringVar(&name, "name", "", "Node name")
fs.StringVar(&pub, "pub", "", "WireGuard public key")
fs.StringVar(&ip, "ip", "", "Node IP (10.8.0.x)")
if err := fs.Parse(args); err != nil {
fail(err.Error())
return 2
}
if name == "" || pub == "" || ip == "" {
fail("--name, --pub and --ip are required")
return 2
}
if err := validateHostname(name); err != nil {
fail(err.Error())
return 2
}
if err := validateWGKey(pub); err != nil {
fail(err.Error())
return 2
}
if err := validateIPHost(ip); err != nil {
fail(err.Error())
return 2
}
head("Add the following to Zion's /etc/wireguard/wg0.conf")
fmt.Println("# " + name)
fmt.Println("[Peer]")
fmt.Println("PublicKey = " + pub)
fmt.Println("AllowedIPs = " + ip + "/32")
return 0
}
// ============= Keys only =============
func runKeys(args []string) int {
fs := flag.NewFlagSet("keys", flag.ContinueOnError)
fs.SetOutput(new(strings.Builder))
if err := fs.Parse(args); err != nil {
fail(err.Error())
return 2
}
priv, _, err := generateKeys()
if err != nil {
fail(err.Error())
return 1
}
fmt.Println("PrivateKey:", priv)
fmt.Println("(Use 'wg pubkey' to derive PublicKey safely)")
return 0
}
// ============= Main =============
func main() {
if len(os.Args) < 2 {
fmt.Printf("wgtool %s\n", toolVersion)
fmt.Println("Commands: generate, validate, zion-peer, keys, version")
os.Exit(0)
}
sub := os.Args[1]
args := os.Args[2:]
switch sub {
case "generate":
os.Exit(runGenerate(args))
case "validate":
os.Exit(runValidate(args))
case "zion-peer":
os.Exit(runZionPeer(args))
case "keys":
os.Exit(runKeys(args))
case "version", "--version", "-v":
fmt.Println("wgtool", toolVersion)
os.Exit(0)
default:
fail("unknown command: " + sub)
fmt.Println("Available: generate, validate, zion-peer, keys, version")
os.Exit(2)
}
}

View File

@@ -0,0 +1 @@
eGcfpLXTadC99k7rj8G07zrDLarKoolA30lMYo/MSG0=

View File

@@ -0,0 +1 @@
ltEo/ohm4EvJyXhFtnHPjHrpOW3v5mwxgw9m9uzjNmE=

View File

@@ -0,0 +1,20 @@
# ================================================
# Node: janus
# PublicKey: ltEo/ohm4EvJyXhFtnHPjHrpOW3v5mwxgw9m9uzjNmE=
#
# Add this peer to Zion (/etc/wireguard/wg0.conf):
# [Peer]
# PublicKey = ltEo/ohm4EvJyXhFtnHPjHrpOW3v5mwxgw9m9uzjNmE=
# AllowedIPs = 10.8.0.250/32
# ================================================
[Interface]
Address = 10.8.0.250/24
PrivateKey = eGcfpLXTadC99k7rj8G07zrDLarKoolA30lMYo/MSG0=
# Zion (central server)
[Peer]
PublicKey = 2ztJbrN1x1NWanzPGLiKL19ZkdOhm5Y7WeKEWBT5cyg=
AllowedIPs = 0.0.0.0/0
Endpoint = ugh.im:51820
PersistentKeepalive = 25

View File

@@ -0,0 +1 @@
OB4zaSdrZkSgtQZUQlkSB9x++RQAvxgOSEHEUswY6Hs=

View File

@@ -0,0 +1 @@
tVHvPWUDAd3xhoZKo0iJ5G5wOIQIdGNNXDG0cV0djxo=

View File

@@ -0,0 +1,20 @@
# ================================================
# Node: mawlz
# PublicKey: tVHvPWUDAd3xhoZKo0iJ5G5wOIQIdGNNXDG0cV0djxo=
#
# Add this peer to Zion (/etc/wireguard/wg0.conf):
# [Peer]
# PublicKey = tVHvPWUDAd3xhoZKo0iJ5G5wOIQIdGNNXDG0cV0djxo=
# AllowedIPs = 10.8.0.16/32
# ================================================
[Interface]
Address = 10.8.0.16/24
PrivateKey = OB4zaSdrZkSgtQZUQlkSB9x++RQAvxgOSEHEUswY6Hs=
# Zion (central server)
[Peer]
PublicKey = 2ztJbrN1x1NWanzPGLiKL19ZkdOhm5Y7WeKEWBT5cyg=
AllowedIPs = 10.8.0.0/24
Endpoint = ugh.im:51820
PersistentKeepalive = 25

View File

@@ -0,0 +1 @@
6H0y0Cov9x65ctmjz5IHFD9DIMlWZeYlxh3BZVlDHkU=

View File

@@ -0,0 +1 @@
oNVVqJZoL6AY/0bDl5EPEfW62v0zptK4Bk5BnBEFJwE=

View File

@@ -0,0 +1,27 @@
# ================================================
# Node: morph
# PublicKey: oNVVqJZoL6AY/0bDl5EPEfW62v0zptK4Bk5BnBEFJwE=
#
# Add this peer to Zion (/etc/wireguard/wg0.conf):
# [Peer]
# PublicKey = oNVVqJZoL6AY/0bDl5EPEfW62v0zptK4Bk5BnBEFJwE=
# AllowedIPs = 10.8.0.21/32
# ================================================
[Interface]
Address = 10.8.0.21/24
PrivateKey = 6H0y0Cov9x65ctmjz5IHFD9DIMlWZeYlxh3BZVlDHkU=
#CTH
[Peer]
PublicKey = NBktXKy1s0n2lIlIMODvOqKNwAtYdoZH5feKt5P43i0=
AllowedIPs = 10.8.0.10/32
Endpoint = aw2cd67.glddns.com:53535
PersistentKeepalive = 25
# Zion (central server)
#[Peer]
#PublicKey = 2ztJbrN1x1NWanzPGLiKL19ZkdOhm5Y7WeKEWBT5cyg=
#AllowedIPs = 10.8.0.0/24
#Endpoint = ugh.im:51820
#PersistentKeepalive = 25

View File

@@ -0,0 +1 @@
eBQiIcOLaM4A2jgRHUjWrQtev+jR0l4ZjF3GMfOXQ0M=

View File

@@ -0,0 +1 @@
4CVAy2F3QlKZnHV+6fo4GM/cuGt3XU6BElq11IzfJ3w=

View File

@@ -0,0 +1 @@
Wk099hP8kJ3wRgOwo+QCiaDTR1tSDdYwM5E9qI6Cw0w=

View File

@@ -0,0 +1,20 @@
# ================================================
# Node: virtual
# PublicKey: Wk099hP8kJ3wRgOwo+QCiaDTR1tSDdYwM5E9qI6Cw0w=
#
# Add this peer to Zion (/etc/wireguard/wg0.conf):
# [Peer]
# PublicKey = Wk099hP8kJ3wRgOwo+QCiaDTR1tSDdYwM5E9qI6Cw0w=
# AllowedIPs = 10.8.0.94/32
# ================================================
[Interface]
Address = 10.8.0.94/24
PrivateKey = 4CVAy2F3QlKZnHV+6fo4GM/cuGt3XU6BElq11IzfJ3w=
# Zion (central server)
[Peer]
PublicKey = 2ztJbrN1x1NWanzPGLiKL19ZkdOhm5Y7WeKEWBT5cyg=
AllowedIPs = 10.8.0.0/24
Endpoint = ugh.im:51820
PersistentKeepalive = 25

View File

@@ -0,0 +1,20 @@
# ================================================
# Node: morph
# PublicKey: 49bN/KGsiqFmHxItli8ySiDPgeTc9Lh+vQA4BJBa/2k=
#
# Add this peer to Zion (/etc/wireguard/wg0.conf):
# [Peer]
# PublicKey = 49bN/KGsiqFmHxItli8ySiDPgeTc9Lh+vQA4BJBa/2k=
# AllowedIPs = 10.8.0.21/32
# ================================================
[Interface]
Address = 10.8.0.21/24
PrivateKey = YLMDjNlIevvmoK7dpsRVe3iIce/JdZg7aSZAJcEwWlE=
# Zion (central server)
[Peer]
PublicKey = 2ztJbrN1x1NWanzPGLiKL19ZkdOhm5Y7WeKEWBT5cyg=
AllowedIPs = 10.8.0.0/24
Endpoint = ugh.im:51820
PersistentKeepalive = 25