mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 07:44:59 +03:00
56ac8d0fc7
* feat(amneziawg): add opt-in Linux kernel datapath Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply. Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed. * fix(amneziawg): align readiness and endpoint reporting Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support. Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass. * style(amneziawg): format readiness test * test: reproduce kernel AWG client validation gaps * test(service): cover kernel AWG shared client fanout * fix(amneziawg): validate kernel client fanout and config values * test(amneziawgkern): cover owned interface lifecycle recovery * fix(amneziawgkern): recover owned interfaces on restart * test(amneziawgkern): retain visibility after recovered cleanup failure * fix(amneziawgkern): report recovered interfaces while cleanup retries * style(amneziawgkern): satisfy static analysis * test(amneziawgkern): cover long rendered peer comments * fix(amneziawgkern): accept long generated config comments * test(amneziawgkern): refuse foreign occupied interfaces Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries. * test(amneziawgkern): cover stale interface collisions Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown. * fix(amneziawgkern): verify interface ownership before mutation Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs. Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check. * build: update Go toolchain to 1.27.3 * test(amneziawg): reproduce unsafe key rotation recovery Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure. * fix(amneziawg): recover interrupted key rotations Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership. * fix(scanner): align Go image with module version * fix(docs): pin pnpm release with bootstrap fix pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely. * fix(xray): build core from pinned module The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets. * fix(tests): stabilize local network fixtures Keep the TUIC stream fragmentation check on a working UDP loopback path, allocate the AmneziaWG fixture ports dynamically, and wait for a real Xray stats response before starting API mutation assertions. Recreate failed readiness channels so an early connection cannot stall the probe. Verified host and offline parallel Go suites, repeated occupied-port and UDP tests, race/shuffle runs, and golangci-lint. Production behavior and dependency versions are unchanged. * fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK main gained the sk-SK locale after this branch added its kernel-mode keys to the other 13, so i18n-dead-keys.test.ts failed on the merged tree. * refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick awg-quick evaluates PostUp/PostDown through a root shell, so every value the panel rendered into its config was one validation slip away from root command execution. The interface is now built with direct ip/awg/nft execs from a setconf-only file: link add, awg setconf, address, mtu, an atomically replaced nftables table per inbound, and the IPv4 forwarding sysctl written directly. - Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables and configs under the panel's data dir, so nothing collides with an operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg. - FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel. - A full panel stop records the final counters and tears the interfaces down, like the embedded datapath; a stopped panel enforces no quota or expiry. Crash survivors are still adopted without a rebuild. - The job reads counters before reconciling, and from every managed interface, so a client disabled for quota, or a removed inbound, is charged for its last interval instead of losing it with the dropped peer. - An unchanged reconcile tick forks no process (it probed the live key with `awg show` every 10s); readiness no longer loads the module from a GET. Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM teardown leaving no link, table, FORWARD rule or config behind. * fix(amneziawg): gate kernel host readiness only when a save starts the datapath Every save of a kernel-mode inbound re-ran the host preflight, so a host that lost its module (a kernel upgrade without a DKMS rebuild) refused even disabling the inbound or switching it back to embedded mode. Host blockers now apply only when the save starts the kernel datapath: a new inbound, a switch from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded ports, pinned listen) still apply to every save. * feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it A node built before kernel mode drops the unknown kernelMode field and serves the inbound on the embedded datapath while the master's form shows kernel mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses to push a kernel-mode inbound to a node that has not advertised it, learning the caps with an inbound list first when this Remote has not heard from it. * fix(amneziawg): pin the kernel module installer to verified commits The installer ran as root and built whatever upstream tag sorted newest at install time. It now clones fixed tags (module v3.1.20260906, tools v3.1.20260812) and refuses unless each resolves to its recorded commit. The udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat upstream, so the patch never matched. * fix(i18n): translate the kernel-mode AmneziaWG strings All thirteen non-English locales carried the English text verbatim. * fix(amneziawg): skip the kernel traffic flush when no kernel interface runs Shutdown listed the desired AmneziaWG inbounds from the database even on a panel with no kernel interface, an unneeded query on every stop. A server stopped before its database was opened panicked on it, which turned TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
443 lines
14 KiB
Go
443 lines
14 KiB
Go
package amneziawg
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"net/netip"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// awgHMax caps generated H values at 2^31-1: the spec allows the full uint32,
|
|
// but the amneziawg-windows-client config editor rejects anything above.
|
|
const awgHMax = 2147483647
|
|
|
|
// hMaxValid is the largest value ValidateObfuscation accepts for an H
|
|
// parameter: uint32 max, the kernel's own limit.
|
|
const hMaxValid int64 = 4294967295
|
|
|
|
// randInt returns a uniform random int in [min, max] using crypto/rand. Falls
|
|
// back to min on the (practically impossible) RNG error.
|
|
func randInt(min, max int) int {
|
|
if max <= min {
|
|
return min
|
|
}
|
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min)+1))
|
|
if err != nil {
|
|
return min
|
|
}
|
|
return min + int(n.Int64())
|
|
}
|
|
|
|
// DefaultMTU is WireGuard/AmneziaWG's usual tunnel MTU on a 1500-byte host
|
|
// link, before AmneziaWG's own S4 transport junk is prepended.
|
|
const DefaultMTU = 1420
|
|
|
|
// EffectiveMTU is the admin's value when set, else DefaultMTU minus S4: s4 junk
|
|
// is prepended to every transport packet and never clamped against the MTU.
|
|
func EffectiveMTU(configuredMTU, s4 int) int {
|
|
if configuredMTU > 0 {
|
|
return configuredMTU
|
|
}
|
|
return max(DefaultMTU-max(s4, 0), 1280)
|
|
}
|
|
|
|
// GenerateObfuscation31 produces a randomized AmneziaWG 3.1 parameter set: a
|
|
// static value gets profiled by DPI, defeating the point.
|
|
func GenerateObfuscation31() Obfuscation31 {
|
|
var o Obfuscation31
|
|
|
|
o.Jc = randInt(3, 6)
|
|
o.Jmin = randInt(40, 89)
|
|
o.Jmax = o.Jmin + randInt(50, 250)
|
|
|
|
o.S1 = randInt(15, 150)
|
|
o.S2 = randInt(15, 150)
|
|
// Kernel constraint: S1+56 != S2, else init and response handshake
|
|
// packets end up the same size after padding.
|
|
for o.S1+56 == o.S2 {
|
|
o.S2 = randInt(15, 150)
|
|
}
|
|
// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
|
|
// rejects header protection unless every S1-S4 is >= 12.
|
|
o.S3 = randInt(12, 55) // cookie padding
|
|
o.S4 = randInt(12, 27) // transport padding (max 32)
|
|
|
|
h := generateHValues()
|
|
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
|
|
|
|
// CPS signature packet, N random bytes before each handshake. I2-I5 stay
|
|
// empty, matching Amnezia's own generator.
|
|
o.I1 = fmt.Sprintf("<r %d>", randInt(32, 256))
|
|
|
|
o.HeaderProtectionKey = generateHeaderProtectionKey()
|
|
|
|
// Total padding stays <= 64: it rides on full-size transport packets, the
|
|
// same MTU headroom that caps S4 at 32.
|
|
cpLo := randInt(8, 24)
|
|
o.ContentPaddingAddition = fmt.Sprintf("%d-%d", cpLo, cpLo+randInt(8, 40))
|
|
|
|
// Timing windows bracket WireGuard's own constants (rekey 120s, reject
|
|
// 180s) so sessions still renew before expiry.
|
|
rkLo := randInt(100, 120)
|
|
rkHi := rkLo + randInt(10, 40)
|
|
o.RekeyAfterTime = fmt.Sprintf("%d-%d", rkLo, rkHi)
|
|
|
|
// Every reject value exceeds every rekey value by >= 30s by construction.
|
|
rjLo := rkHi + randInt(30, 60)
|
|
o.RejectAfterTime = fmt.Sprintf("%d-%d", rjLo, rjLo+randInt(30, 90))
|
|
|
|
rtLo := randInt(3, 6)
|
|
o.RekeyTimeout = fmt.Sprintf("%d-%d", rtLo, rtLo+randInt(1, 4))
|
|
|
|
// Max 20s: under clients' typical 25s PersistentKeepalive and ~30s NAT UDP
|
|
// timeouts, or idle links lose their NAT mapping.
|
|
kaLo := randInt(8, 12)
|
|
o.KeepaliveTimeout = fmt.Sprintf("%d-%d", kaLo, kaLo+randInt(2, 8))
|
|
|
|
haLo := randInt(15, 25)
|
|
o.MaxHandshakeAttempts = fmt.Sprintf("%d-%d", haLo, haLo+randInt(5, 25))
|
|
|
|
o.RandomTrailers = true
|
|
// Cookie replies are DPI-fingerprintable; this stealth default trades away
|
|
// WG's handshake-flood mitigation and is toggleable per inbound.
|
|
o.DisableCookies = true
|
|
|
|
return o
|
|
}
|
|
|
|
// generateHeaderProtectionKey returns base64 of 32 crypto/rand bytes, the
|
|
// format amneziawg-tools' HeaderProtectionKey parser expects.
|
|
func generateHeaderProtectionKey() string {
|
|
key := make([]byte, 32)
|
|
if _, err := rand.Read(key); err != nil {
|
|
return ""
|
|
}
|
|
return base64.StdEncoding.EncodeToString(key)
|
|
}
|
|
|
|
// generateHValues returns one distinct value per H1-H4 band; low bound >= 5 (1-4 are vanilla WG message types).
|
|
// Single values, not ranges: with RandomTrailers on, a wide range misclassifies transport packets as handshakes (amnezia-vpn/amneziawg-go#183).
|
|
func generateHValues() [4]string {
|
|
const lo = 5
|
|
bandSize := (awgHMax - lo + 1) / 4
|
|
var out [4]string
|
|
for i := range 4 {
|
|
bandLo := lo + i*bandSize
|
|
bandHi := bandLo + bandSize - 1
|
|
out[i] = fmt.Sprintf("%d", randInt(bandLo, bandHi))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Padded handshake messages (148+S1, 92+S2, 64+S3 bytes) must fit the smallest receive
|
|
// buffer amneziawg-go has: MaxSegmentSize 1700 on iOS (device/queueconstants_ios.go).
|
|
const (
|
|
maxServerS1 = 1700 - 148
|
|
maxServerS2 = 1700 - 92
|
|
maxServerS3 = 1700 - 64
|
|
)
|
|
|
|
// ValidateServerObfuscation adds the receive-buffer bounds to ValidateObfuscation:
|
|
// an inbound's peers may be iOS clients, which cannot receive a larger handshake.
|
|
func ValidateServerObfuscation(o Obfuscation31) error {
|
|
if err := ValidateObfuscation(o); err != nil {
|
|
return err
|
|
}
|
|
for _, f := range []struct {
|
|
name string
|
|
v, max int
|
|
}{{"S1", o.S1, maxServerS1}, {"S2", o.S2, maxServerS2}, {"S3", o.S3, maxServerS3}} {
|
|
if f.v > f.max {
|
|
return fmt.Errorf("invalid %s value %d (must be 0..%d so every client can receive it)", f.name, f.v, f.max)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateObfuscation rejects malformed parameters before they are saved, so
|
|
// a bad manual entry can't break the embedded amneziawg-go device's own
|
|
// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
|
|
// produce a client config the official app rejects outright. Blank H values
|
|
// are allowed (they fall back to a default); each accepts an integer or a
|
|
// "100-800" range.
|
|
func ValidateObfuscation(o Obfuscation31) error {
|
|
if o.Jmin > o.Jmax {
|
|
return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
|
|
}
|
|
// amneziawg-go parses jc/jmin/jmax as uint32 and s1-s3 as uint16 (device/uapi.go);
|
|
// a wider value makes IpcSet reject the whole device.
|
|
for _, f := range []struct {
|
|
name string
|
|
v int
|
|
max int64
|
|
}{
|
|
{"Jc", o.Jc, math.MaxUint32},
|
|
{"Jmin", o.Jmin, math.MaxUint32},
|
|
{"Jmax", o.Jmax, math.MaxUint32},
|
|
{"S1", o.S1, math.MaxUint16},
|
|
{"S2", o.S2, math.MaxUint16},
|
|
{"S3", o.S3, math.MaxUint16},
|
|
} {
|
|
if int64(f.v) < 0 || int64(f.v) > f.max {
|
|
return fmt.Errorf("invalid %s value %d (must be 0..%d)", f.name, f.v, f.max)
|
|
}
|
|
}
|
|
for i, spec := range []string{o.I1, o.I2, o.I3, o.I4, o.I5} {
|
|
if err := validateObfChain(spec); err != nil {
|
|
return fmt.Errorf("invalid I%d: %w", i+1, err)
|
|
}
|
|
}
|
|
if o.S4 < 0 || o.S4 > 32 {
|
|
return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
|
|
}
|
|
if o.S1+56 == o.S2 {
|
|
return fmt.Errorf("invalid S1/S2: S1+56 must not equal S2 (%d+56 == %d)", o.S1, o.S2)
|
|
}
|
|
for i, h := range []string{o.H1, o.H2, o.H3, o.H4} {
|
|
if err := validateUintRange(h, 0); err != nil {
|
|
return fmt.Errorf("invalid H%d: %w", i+1, err)
|
|
}
|
|
}
|
|
if err := validateHNoOverlap([4]string{o.H1, o.H2, o.H3, o.H4}); err != nil {
|
|
return err
|
|
}
|
|
if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
|
|
return err
|
|
}
|
|
if o.HeaderProtectionKey != "" {
|
|
for i, s := range []int{o.S1, o.S2, o.S3, o.S4} {
|
|
if s < 12 {
|
|
return fmt.Errorf("invalid S%d value %d: header protection requires S1-S4 >= 12", i+1, s)
|
|
}
|
|
}
|
|
}
|
|
if err := validateUintRange(o.ContentPaddingAddition, 0); err != nil {
|
|
return fmt.Errorf("invalid contentPaddingAddition: %w", err)
|
|
}
|
|
timing := []struct{ field, v string }{
|
|
{"rekeyAfterTime", o.RekeyAfterTime},
|
|
{"rekeyTimeout", o.RekeyTimeout},
|
|
{"rejectAfterTime", o.RejectAfterTime},
|
|
{"keepaliveTimeout", o.KeepaliveTimeout},
|
|
{"maxHandshakeAttempts", o.MaxHandshakeAttempts},
|
|
}
|
|
for _, tf := range timing {
|
|
// Zero would disable the timer or retry loop outright, so min is 1.
|
|
if err := validateUintRange(tf.v, 1); err != nil {
|
|
return fmt.Errorf("invalid %s: %w", tf.field, err)
|
|
}
|
|
}
|
|
// Sessions must renew before hard expiry, so every possible rekey fires
|
|
// before the earliest reject. A blank side means WireGuard's own default.
|
|
if o.RekeyAfterTime != "" || o.RejectAfterTime != "" {
|
|
rekeyHi, rejectLo := int64(120), int64(180)
|
|
if o.RekeyAfterTime != "" {
|
|
_, rekeyHi, _ = parseUintRange(o.RekeyAfterTime)
|
|
}
|
|
if o.RejectAfterTime != "" {
|
|
rejectLo, _, _ = parseUintRange(o.RejectAfterTime)
|
|
}
|
|
if rekeyHi >= rejectLo {
|
|
return fmt.Errorf("invalid rekeyAfterTime/rejectAfterTime: max rekey %d must be below min reject %d", rekeyHi, rejectLo)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// obfChainTags mirrors amneziawg-go's own obfBuilders map (device/obf.go): an
|
|
// unknown tag makes newObfChain fail, and IpcSet then rejects the whole device.
|
|
var obfChainTags = map[string]bool{
|
|
"b": true, "t": true, "r": true, "rc": true,
|
|
"rd": true, "d": true, "ds": true, "dz": true,
|
|
}
|
|
|
|
// validateObfChain checks an I1-I5 signature-packet spec's "<tag value>"
|
|
// structure. Each tag's own value grammar stays amneziawg-go's to enforce.
|
|
func validateObfChain(spec string) error {
|
|
if strings.TrimSpace(spec) == "" {
|
|
return nil
|
|
}
|
|
remaining := spec
|
|
for {
|
|
start := strings.IndexByte(remaining, '<')
|
|
if start == -1 {
|
|
return nil
|
|
}
|
|
end := strings.IndexByte(remaining[start:], '>')
|
|
if end == -1 {
|
|
return fmt.Errorf("spec %q is missing an enclosing '>'", spec)
|
|
}
|
|
fields := strings.Fields(remaining[start+1 : start+end])
|
|
if len(fields) == 0 {
|
|
return fmt.Errorf("spec %q has an empty <> tag", spec)
|
|
}
|
|
if !obfChainTags[fields[0]] {
|
|
return fmt.Errorf("spec %q uses unknown tag <%s>", spec, fields[0])
|
|
}
|
|
remaining = remaining[start+end+1:]
|
|
}
|
|
}
|
|
|
|
// CanonicalizeUintRange stores a pasted "110 - 140" as "110-140", and
|
|
// collapses a whitespace-only value back to "feature off".
|
|
func CanonicalizeUintRange(v string) string {
|
|
return strings.ReplaceAll(strings.TrimSpace(v), " ", "")
|
|
}
|
|
|
|
// validateHeaderProtectionKey accepts blank (feature off) or a base64 32-byte
|
|
// key. Control chars are rejected up front: DecodeString silently ignores
|
|
// \r\n, so a line-wrapped pasted key would pass and then split client configs.
|
|
func validateHeaderProtectionKey(v string) error {
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
if err := ValidateConfigValue("headerProtectionKey", v); err != nil {
|
|
return err
|
|
}
|
|
key, err := base64.StdEncoding.DecodeString(v)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid headerProtectionKey: not base64: %w", err)
|
|
}
|
|
if len(key) != 32 {
|
|
return fmt.Errorf("invalid headerProtectionKey: got %d bytes, want 32", len(key))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateIPv6Subnet rejects a malformed subnet before it's saved. A blank
|
|
// value is only valid when IPv6 itself is disabled.
|
|
func ValidateIPv6Subnet(enabled bool, subnet string) error {
|
|
if !enabled {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(subnet) == "" {
|
|
return fmt.Errorf("ipv6Subnet is required when IPv6 is enabled")
|
|
}
|
|
prefix, err := netip.ParsePrefix(subnet)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid ipv6Subnet %q: %w", subnet, err)
|
|
}
|
|
if !prefix.Addr().Is6() {
|
|
return fmt.Errorf("invalid ipv6Subnet %q: not an IPv6 prefix", subnet)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// interfaceNamePattern matches a plausible Linux device name (eth0, br-lan,
|
|
// eno1.100, eth0:0), capped at 15 bytes (IFNAMSIZ-1).
|
|
var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`)
|
|
|
|
// ValidateInterfaceName guards the NIC names generateServerConfig interpolates
|
|
// unescaped into a root-executed PostUp/PostDown line. Blank is allowed and
|
|
// means auto-detect (or, for IPv6ExternalInterface, reuse the IPv4 one).
|
|
func ValidateInterfaceName(name string) error {
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if !interfaceNamePattern.MatchString(name) {
|
|
return fmt.Errorf("invalid interface name %q: must be 1-15 characters of letters, digits, '.', '_', '@', ':' or '-'", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateSubnetIPv4 guards subnetIP, which lands in the MASQUERADE rule the
|
|
// same way ExternalInterface does. subnetCIDR <= 0 means unset, mirroring
|
|
// serverAddress's own default-to-/24 leniency.
|
|
func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error {
|
|
cidr := subnetCIDR
|
|
if cidr <= 0 {
|
|
cidr = 24
|
|
}
|
|
if cidr > 32 {
|
|
return fmt.Errorf("invalid subnetCidr %d: must be 0..32", subnetCIDR)
|
|
}
|
|
prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
|
|
if err != nil {
|
|
return fmt.Errorf("invalid subnetIp %q: %w", subnetIP, err)
|
|
}
|
|
if !prefix.Addr().Is4() {
|
|
return fmt.Errorf("invalid subnetIp %q: not an IPv4 address", subnetIP)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateConfigValue rejects control characters in values written to AWG configs,
|
|
// preventing newline injection into config directives and awg-quick hooks.
|
|
func ValidateConfigValue(field, v string) error {
|
|
for _, r := range v {
|
|
if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f {
|
|
return fmt.Errorf("invalid %s: control characters are not allowed", field)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateUintRange checks a uint32-range parameter (H1-H4, the 3.x padding
|
|
// and timing fields): blank, an integer, or "low-high" within the bounds.
|
|
func validateUintRange(v string, minAllowed int64) error {
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil
|
|
}
|
|
// parseUintRange trims each half, so "110\n-140" would otherwise pass and
|
|
// then split a rendered config line in two.
|
|
if err := ValidateConfigValue("range", v); err != nil {
|
|
return fmt.Errorf("value %q must not contain control characters", v)
|
|
}
|
|
lo, hi, ok := parseUintRange(v)
|
|
if !ok {
|
|
return fmt.Errorf("value %q must be an integer or a low-high range", v)
|
|
}
|
|
if lo < minAllowed || hi > hMaxValid || lo > hi {
|
|
return fmt.Errorf("range %q must satisfy %d <= low <= high <= %d", v, minAllowed, hMaxValid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateHNoOverlap mirrors amneziawg-go's "headers must not overlap" (device/uapi.go).
|
|
// A blank Hn is never sent, so the engine keeps its default: WireGuard's own type n.
|
|
func validateHNoOverlap(hs [4]string) error {
|
|
var lo, hi [4]int64
|
|
for i, h := range hs {
|
|
l, u, ok := parseUintRange(h)
|
|
if !ok {
|
|
l, u = int64(i+1), int64(i+1)
|
|
}
|
|
lo[i], hi[i] = l, u
|
|
}
|
|
for i := range 4 {
|
|
for j := i + 1; j < 4; j++ {
|
|
if lo[i] <= hi[j] && lo[j] <= hi[i] {
|
|
return fmt.Errorf("invalid H%d/H%d: %d-%d and %d-%d overlap", i+1, j+1, lo[i], hi[i], lo[j], hi[j])
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
|
|
// or non-numeric. Bounds are NOT checked here.
|
|
func parseUintRange(v string) (lo, hi int64, ok bool) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return 0, 0, false
|
|
}
|
|
if loS, hiS, isRange := strings.Cut(v, "-"); isRange {
|
|
l, err1 := strconv.ParseInt(strings.TrimSpace(loS), 10, 64)
|
|
h, err2 := strconv.ParseInt(strings.TrimSpace(hiS), 10, 64)
|
|
if err1 != nil || err2 != nil {
|
|
return 0, 0, false
|
|
}
|
|
return l, h, true
|
|
}
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return n, n, true
|
|
}
|