mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-09 19:57:14 +00:00
3cd3836d77
* fix(amneziawg): account for S4 junk in the default tunnel MTU
amneziawg prepends S4 random bytes to every transport packet
(device.NewOutboundElement) and, unlike content padding and random trailers,
never clamps them against the tunnel MTU. A full-size packet therefore lands on
the wire at MTU + 60 + S4 bytes: 20 IPv4 + 8 UDP + S4 + 16 transport header +
16 poly1305 tag.
With the 1420 default that overflows a 1500-byte link once S4 exceeds 20, and
GenerateObfuscation31 draws S4 from 12..27 inclusive -- so roughly 44% of newly
created inbounds fragment every full-size packet they send.
Measured on a live pair of interfaces, predicted against observed:
MTU 1380 S4 12 -> 1452 on the wire (fits)
MTU 1420 S4 12 -> 1492 (fits)
MTU 1420 S4 20 -> 1500 (exactly at the limit)
MTU 1420 S4 21 -> 1501 (fragments)
MTU 1420 S4 27 -> 1507 (fragments)
EffectiveMTU now subtracts S4 from the default; an explicit MTU is untouched.
Client configs carry the same number. They previously omitted the MTU line
whenever the server had no explicit value, which left the client on its own
1420 default and fragmented the client-to-server direction even after the
server side was fixed -- silently, and only in one direction. All three
emitters (the Go subscription text and the two TypeScript ones) now agree,
which is what the existing parity test exists to protect.
* fix(amneziawg): rebuild the device when S4 changes the derived MTU
Addresses review feedback on the previous commit.
Deriving the default MTU from S4 made a construction-time-only property depend
on a hot-reloadable input, but addressFingerprint -- ensureLocked's only rebuild
trigger -- still hashed the raw inst.MTU. S4 is a UAPI field, so an S4-only edit
took the in-place IpcSet branch and the gVisor netstack kept the MTU derived
from the old S4 while all three client emitters already advertised the new one.
Every panel-created inbound leaves mtu unset, so that was the normal case, not
an edge one: with S4 raised far enough the fragmentation this fix exists to
remove came straight back, and stayed until a panel restart or an unrelated
address edit.
Folding EffectiveMTU into the fingerprint fixes it. An explicit MTU still takes
the in-place branch on an S4 edit, since it does not move the interface MTU.
Also trims four comment blocks to the 2-line cap in CLAUDE.md, and points
NewDevice's doc comment at EffectiveMTU instead of the deleted defaultMTU.
402 lines
14 KiB
Go
402 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 (max 64)
|
|
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
|
|
}
|
|
|
|
// 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-s4 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},
|
|
} {
|
|
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.S3 < 0 || o.S3 > 64 {
|
|
return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3)
|
|
}
|
|
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 := 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 any value interpolated
|
|
// verbatim into a rendered .conf: a newline re-opens an [Interface] section
|
|
// whose "PostUp = ..." runs as root the moment whoever downloaded that
|
|
// config -- the client app, or an admin importing it into the official
|
|
// awg-quick CLI directly -- applies it. The panel's own server side never
|
|
// runs awg-quick itself (internal/amneziawgnet applies config via
|
|
// amneziawg-go's UAPI, not a parsed text file), but this exact value still
|
|
// reaches a real text-based config downstream. field names the value.
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|