377 lines
12 KiB
Go
377 lines
12 KiB
Go
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
|
|
}
|