Files
awg-profiler-golang/util.go
T

377 lines
12 KiB
Go
Raw Normal View History

2026-07-18 10:02:43 +03:00
package main
import (
"bufio"
"context"
crand "crypto/rand"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
func itoa(n int) string { return strconv.Itoa(n) }
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
// atoiOrDie parses a base-10 integer, aborting on malformed input.
func atoiOrDie(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
die("expected integer, got %q", s)
}
return n
}
// ─── logging helpers (mirror the bash die/info/warn) ────────────────────────────
// webMode makes die() panic instead of exiting the process, so the long-running
// web server can recover from an operation failure and turn it into an HTTP
// error response rather than crashing. It is set only by the `web` command.
var webMode bool
// dieError carries a die() message across a recover() in web mode.
type dieError struct{ msg string }
func (e dieError) Error() string { return e.msg }
// die prints an error to stderr and terminates with status 1. In web mode it
// panics with a dieError instead, to be recovered by the HTTP handler wrapper.
func die(format string, args ...any) {
if webMode {
panic(dieError{fmt.Sprintf(format, args...)})
}
fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...)
os.Exit(1)
}
func info(format string, args ...any) {
fmt.Printf("INFO: "+format+"\n", args...)
}
func warn(format string, args ...any) {
fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", args...)
}
// requireBinary aborts unless the named executable is on PATH.
func requireBinary(name string) {
if _, err := exec.LookPath(name); err != nil {
die("%s not installed", name)
}
}
// ─── interactive input ──────────────────────────────────────────────────────────
var stdinReader = bufio.NewReader(os.Stdin)
// confirm asks a yes/no question; returns true only for a bare y/Y.
func confirm(prompt string) bool {
fmt.Printf("%s [y/N] ", prompt)
answer, _ := stdinReader.ReadString('\n')
answer = strings.TrimSpace(answer)
return answer == "y" || answer == "Y"
}
// promptDefault reads a value, falling back to def when the user hits enter.
func promptDefault(prompt, def string) string {
fmt.Printf("%s [%s]: ", prompt, def)
value, _ := stdinReader.ReadString('\n')
value = strings.TrimRight(value, "\r\n")
if value == "" {
return def
}
return value
}
// ─── randomness (crypto/rand, replaces od < /dev/urandom) ───────────────────────
func randUint32() uint32 {
var b [4]byte
if _, err := crand.Read(b[:]); err != nil {
die("failed to read random bytes: %v", err)
}
return binary.BigEndian.Uint32(b[:])
}
// randMagic returns a random unsigned 32-bit integer in [5, 2^31-1].
func randMagic() int64 {
const span = 2147483647 - 5 + 1 // inclusive [5, 2^31-1]
n := uint64(randUint32())
return int64(n%span) + 5
}
// randRange returns a uniform integer in the inclusive range [min, max].
// If max < min it warns and returns min so the range stays sane.
func randRange(min, max int) int {
if max < min {
warn("Randomisation range [%d,%d] invalid (MTU too small?) — using %d", min, max, min)
return min
}
span := uint64(max - min + 1)
n := uint64(randUint32())
return int(n%span) + min
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// ─── name sanitisation ──────────────────────────────────────────────────────────
var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func sanitize(v string) string {
return sanitizeRe.ReplaceAllString(v, "_")
}
// ─── IP helpers ─────────────────────────────────────────────────────────────────
// networkBase strips the /CIDR suffix, returning the bare address portion.
func networkBase(network string) string {
if i := strings.Index(network, "/"); i >= 0 {
return network[:i]
}
return network
}
// requireSlash24 validates that network is an IPv4 CIDR with a /24 prefix.
// serverIP/getFirstClientIP/incrementIP only ever vary the last octet, and
// every written config hardcodes "Address = <ip>/24" regardless of what was
// typed in — so any other prefix would silently produce a broken, internally
// inconsistent config rather than the network the operator actually asked for.
func requireSlash24(network string) {
ip, ipnet, err := net.ParseCIDR(network)
if err != nil || ip.To4() == nil {
die("Invalid network %q: expected an IPv4 CIDR, e.g. 10.0.0.0/24", network)
}
ones, bits := ipnet.Mask.Size()
if bits != 32 || ones != 24 {
die("Network %q must be a /24 (only /24 subnets are supported) — e.g. 10.0.0.0/24", network)
}
if ipnet.IP.String() != networkBase(network) {
die("Network %q is not a valid /24 base address — did you mean %s/24?", network, ipnet.IP.String())
}
}
// getFirstClientIP derives the .2 host of the configured network.
func getFirstClientIP(network string) string {
base := networkBase(network)
parts := strings.Split(base, ".")
if len(parts) != 4 {
die("Invalid network: %s", network)
}
return fmt.Sprintf("%s.%s.%s.2", parts[0], parts[1], parts[2])
}
// incrementIP returns the next host IP; if current is empty it seeds from .2.
func incrementIP(current, network string) string {
if current == "" {
return getFirstClientIP(network)
}
parts := strings.Split(current, ".")
if len(parts) != 4 {
die("Invalid IP: %s", current)
}
o4 := atoiOrDie(parts[3])
o4++
if o4 >= 255 {
die("IP pool exhausted")
}
return fmt.Sprintf("%s.%s.%s.%d", parts[0], parts[1], parts[2], o4)
}
// ─── command execution ──────────────────────────────────────────────────────────
// run executes a command with inherited stdio and returns its error.
func run(name string, args ...string) error {
return runEnv(nil, name, args...)
}
func runEnv(extraEnv []string, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if extraEnv != nil {
cmd.Env = append(os.Environ(), extraEnv...)
}
return cmd.Run()
}
// runOrDie runs a command and aborts if it fails.
func runOrDie(name string, args ...string) {
if err := run(name, args...); err != nil {
die("%s failed: %v", name, err)
}
}
// runOrDieIn runs a command with its working directory set to dir (e.g. a
// cloned source tree) and aborts if it fails.
func runOrDieIn(dir, name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
die("%s failed: %v", name, err)
}
}
// enableIPForwarding turns on IPv4 forwarding and persists it. It is tolerant
// of container environments: if `sysctl -w` fails but forwarding is already on
// (e.g. set at namespace creation via `--sysctl`/compose, with /proc/sys mounted
// read-only) it warns and continues instead of aborting. It only dies if
// forwarding is genuinely off and cannot be enabled.
func enableIPForwarding() {
if err := run("sysctl", "-w", "net.ipv4.ip_forward=1"); err != nil {
if ipForwardingEnabled() {
warn("Could not write net.ipv4.ip_forward (%v), but it is already enabled — continuing", err)
} else {
die("Failed to enable IP forwarding: %v (set it on the host, e.g. --sysctl net.ipv4.ip_forward=1)", err)
}
}
// Best-effort persistence; /etc may be read-only in some containers.
if err := os.WriteFile("/etc/sysctl.d/99-amneziawg.conf", []byte("net.ipv4.ip_forward=1\n"), 0644); err != nil {
warn("Could not persist sysctl config: %v", err)
}
run("sysctl", "-p", "/etc/sysctl.d/99-amneziawg.conf")
}
// ipForwardingEnabled reads the live kernel flag directly.
func ipForwardingEnabled() bool {
data, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward")
return err == nil && strings.TrimSpace(string(data)) == "1"
}
// output runs a command and returns its trimmed stdout.
func output(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
return strings.TrimSpace(string(out)), err
}
// outputWithInput runs a command feeding stdin, returning trimmed stdout.
func outputWithInput(stdin, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Stdin = strings.NewReader(stdin)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
return strings.TrimSpace(string(out)), err
}
// silent reports whether a command succeeds, discarding all its output.
func silent(name string, args ...string) bool {
cmd := exec.Command(name, args...)
return cmd.Run() == nil
}
// ─── network detection ──────────────────────────────────────────────────────────
// defaultRouteIface parses `ip route` for the default outbound interface,
// mirroring `ip route | awk '/default/ {print $5; exit}'`.
func defaultRouteIface() string {
out, err := output("ip", "route")
if err != nil {
return ""
}
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "default") {
fields := strings.Fields(line)
if len(fields) >= 5 {
return fields[4]
}
}
}
return ""
}
// detectPublicIP mimics `curl -sf4 https://ifconfig.me` (IPv4 only), falling
// back to the placeholder used by the original script.
//
// It queries several plaintext "what is my IP" services in turn and validates
// each response with net.ParseIP. Validation is essential: services such as
// ifconfig.me serve a full HTML landing page (not the bare IP) to clients that
// don't send a curl-like User-Agent, so without the parse check the config
// would end up storing an HTML fragment instead of an address.
func detectPublicIP() string {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Force IPv4 so the detected address matches the udp4 listener.
return dialer.DialContext(ctx, "tcp4", addr)
},
}
client := &http.Client{Timeout: 8 * time.Second, Transport: transport}
// Plaintext IPv4 endpoints, tried in order until one yields a valid IP.
for _, url := range []string{
"https://ifconfig.me/ip",
"https://api.ipify.org",
"https://icanhazip.com",
} {
if ip := fetchIPv4(client, url); ip != "" {
return ip
}
}
return "YOUR_SERVER_IP"
}
// fetchIPv4 requests url and returns the trimmed body only if it is a valid
// IPv4 address; otherwise it returns "". A curl-like User-Agent is sent so
// services that content-negotiate (e.g. ifconfig.me) reply with the bare IP.
func fetchIPv4(client *http.Client, url string) string {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", "curl/8.0.0")
req.Header.Set("Accept", "text/plain")
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
// A valid IPv4 string is at most 15 bytes; cap the read to reject any
// unexpectedly large (e.g. HTML) response early.
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
if err != nil {
return ""
}
ip := strings.TrimSpace(string(body))
if parsed := net.ParseIP(ip); parsed == nil || parsed.To4() == nil {
return ""
}
return ip
}
// unameRelease returns the running kernel release (`uname -r`).
func unameRelease() string {
out, err := output("uname", "-r")
if err != nil {
die("uname -r failed: %v", err)
}
return out
}