diff --git a/docs/content/docs/en/config/amneziawg.mdx b/docs/content/docs/en/config/amneziawg.mdx index 805ab2689..161352e03 100644 --- a/docs/content/docs/en/config/amneziawg.mdx +++ b/docs/content/docs/en/config/amneziawg.mdx @@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time. | ------------ | ---------------------------------------------------------------------------- | | **Jc** | Number of junk packets sent before the handshake. | | **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. | -| **S1/S2** | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. | -| **S3** | Cookie-reply padding, `0`-`64`. | +| **S1/S2** | Padding added to the handshake init/response packets, `0`-`1552` / `0`-`1608`: the packets are `148 + S1` and `92 + S2` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. The panel also rejects `S1 + 56 = S2`, which would give both packets the same size on the wire (amneziawg-go itself accepts it). An AmneziaWG outbound takes the remote server's values as they are, up to `65535`. | +| **S3** | Cookie-reply padding, `0`-`1636`: the reply is `64 + S3` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. An outbound, as with S1/S2, takes the remote server's value up to `65535`. | | **S4** | Transport (data) packet padding, `0`-`32`. | -| **H1-H4** | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. | +| **H1-H4** | Header values that replace WireGuard's message-type field. Each is a single integer or a `low-high` range, and the four must not overlap — amneziawg-go and the kernel module refuse the whole device otherwise. `1`-`4` are WireGuard's own types and the engine default for a blank field: valid, but without a HeaderProtectionKey the type field then reads like plain WireGuard. | | **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. ``. Generated sets fill `I1` only, matching Amnezia's own generator. | | **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. | | **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. | diff --git a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx index 5a6ac758c..39c13d065 100644 --- a/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx +++ b/frontend/src/pages/inbounds/form/protocols/amneziawg.tsx @@ -103,13 +103,13 @@ export default function AmneziawgFields({ - + - + - + diff --git a/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx b/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx index 14e2345dd..4fb1726db 100644 --- a/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx +++ b/frontend/src/pages/xray/outbounds/protocols/amneziawg.tsx @@ -70,9 +70,9 @@ export default function AmneziawgFields() { - - - + + + diff --git a/frontend/src/schemas/protocols/inbound/amneziawg.ts b/frontend/src/schemas/protocols/inbound/amneziawg.ts index 5bc8312d4..5c00ce0ef 100644 --- a/frontend/src/schemas/protocols/inbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/inbound/amneziawg.ts @@ -71,9 +71,9 @@ export const AmneziawgServerSchema = z.object({ jc: clearedToDefault(z.number().int().min(0).max(4294967295).default(5)), jmin: clearedToDefault(z.number().int().min(0).max(4294967295).default(10)), jmax: clearedToDefault(z.number().int().min(0).max(4294967295).default(50)), - s1: clearedToDefault(z.number().int().min(0).max(65535).default(30)), - s2: clearedToDefault(z.number().int().min(0).max(65535).default(45)), - s3: clearedToDefault(z.number().int().min(0).max(64).default(10)), + s1: clearedToDefault(z.number().int().min(0).max(1552).default(30)), + s2: clearedToDefault(z.number().int().min(0).max(1608).default(45)), + s3: clearedToDefault(z.number().int().min(0).max(1636).default(10)), s4: clearedToDefault(z.number().int().min(0).max(32).default(5)), h1: z.string().default(''), h2: z.string().default(''), diff --git a/frontend/src/schemas/protocols/outbound/amneziawg.ts b/frontend/src/schemas/protocols/outbound/amneziawg.ts index 8db3c8c9c..924208027 100644 --- a/frontend/src/schemas/protocols/outbound/amneziawg.ts +++ b/frontend/src/schemas/protocols/outbound/amneziawg.ts @@ -22,9 +22,10 @@ export const AmneziaWGOutboundSettingsSchema = z.object({ jc: z.number().int().min(0).default(0), jmin: z.number().int().min(0).default(40), jmax: z.number().int().min(0).default(100), - s1: z.number().int().min(0).default(15), - s2: z.number().int().min(0).default(80), - s3: z.number().int().min(0).max(64).default(12), + // The remote server sets S1-S3; only amneziawg-go's uint16 UAPI width bounds them here. + s1: z.number().int().min(0).max(65535).default(15), + s2: z.number().int().min(0).max(65535).default(80), + s3: z.number().int().min(0).max(65535).default(12), s4: z.number().int().min(0).max(32).default(12), h1: z.string().default(''), h2: z.string().default(''), diff --git a/frontend/src/test/amneziawg-schema-cleared.test.ts b/frontend/src/test/amneziawg-schema-cleared.test.ts index dcfb48fd8..7a70d007a 100644 --- a/frontend/src/test/amneziawg-schema-cleared.test.ts +++ b/frontend/src/test/amneziawg-schema-cleared.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AmneziawgServerSchema } from '@/schemas/protocols/inbound/amneziawg'; +import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound/amneziawg'; // AntD InputNumber emits null when cleared; a cleared numeric field must // refill its schema default instead of failing validation and blocking the save. @@ -33,27 +34,27 @@ describe('AmneziawgServerSchema cleared numeric fields', () => { }); }); -// The form must reject what amneziawg-go's UAPI parsers reject (device/uapi.go: -// jc/jmin/jmax uint32, s1-s4 uint16), or the save silently outlives the apply. +// The form must reject what cannot apply or be received: jc/jmin/jmax past uint32 (device/uapi.go), +// S1-S3 past amneziawg-go's 1700-byte iOS receive buffer, S4 past 32 (MTU headroom). describe('AmneziawgServerSchema obfuscation bounds', () => { const overWidth: Array<[string, number]> = [ - ['s1', 65536], - ['s2', 70000], - ['s3', 65], + ['s1', 1553], + ['s2', 1609], + ['s3', 1637], ['s4', 33], ['jc', 4294967296], ['jmin', 4294967296], ['jmax', 5000000000], ]; - it.each(overWidth)('rejects %s above the width amneziawg-go parses', (field, value) => { + it.each(overWidth)('rejects %s past what amneziawg-go can apply or receive', (field, value) => { expect(AmneziawgServerSchema.safeParse({ [field]: value }).success).toBe(false); }); const atLimit: Array<[string, number]> = [ - ['s1', 65535], - ['s2', 65535], - ['s3', 64], + ['s1', 1552], + ['s2', 1608], + ['s3', 1636], ['s4', 32], ['jc', 4294967295], ]; @@ -69,3 +70,15 @@ describe('AmneziawgServerSchema obfuscation bounds', () => { } }); }); + +// An outbound's S values come from the remote server and are received on Linux, +// so only amneziawg-go's uint16 UAPI width bounds them, not the iOS buffer. +describe('AmneziaWGOutboundSettingsSchema padding bounds', () => { + it.each(['s1', 's2', 's3'])('accepts %s past the inbound iOS cap', (field) => { + expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 2000 }).success).toBe(true); + }); + + it.each(['s1', 's2', 's3'])('rejects %s past uint16', (field) => { + expect(AmneziaWGOutboundSettingsSchema.safeParse({ [field]: 65536 }).success).toBe(false); + }); +}); diff --git a/internal/amneziawg/outbound_test.go b/internal/amneziawg/outbound_test.go index b24bce6af..9c796cd80 100644 --- a/internal/amneziawg/outbound_test.go +++ b/internal/amneziawg/outbound_test.go @@ -310,3 +310,25 @@ func TestValidateAmneziaWGOutbound_DNSField(t *testing.T) { } } } + +// An outbound's S values are dictated by the remote server and received here on +// Linux, so the iOS receive-buffer cap on inbounds must not refuse them. +func TestValidateAmneziaWGOutbound_AcceptsRemotePaddingPastTheIOSCap(t *testing.T) { + m := validOutboundMapT(t) + m["s1"], m["s2"], m["s3"] = 2000, 3000, 4000 + bs, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil { + t.Fatalf("remote server padding S1=2000 S2=3000 S3=4000 refused: %v", err) + } + + m["s1"] = 65536 + if bs, err = json.Marshal(m); err != nil { + t.Fatal(err) + } + if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil { + t.Fatal("S1=65536 is past amneziawg-go's uint16 UAPI width and must be refused") + } +} diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index 94dc27f7e..9fc912a73 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -64,7 +64,7 @@ func GenerateObfuscation31() Obfuscation31 { } // 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.S3 = randInt(12, 55) // cookie padding o.S4 = randInt(12, 27) // transport padding (max 32) h := generateHValues() @@ -134,6 +134,31 @@ func generateHValues() [4]string { 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 @@ -144,8 +169,8 @@ 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. + // 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 @@ -156,6 +181,7 @@ func ValidateObfuscation(o Obfuscation31) error { {"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) @@ -166,9 +192,6 @@ func ValidateObfuscation(o Obfuscation31) error { 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) } @@ -180,6 +203,9 @@ func ValidateObfuscation(o Obfuscation31) error { 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 } @@ -378,6 +404,27 @@ func validateUintRange(v string, minAllowed int64) error { 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) { diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 6327ed340..1b800b001 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -142,9 +142,9 @@ func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) { func TestValidateObfuscationRejectsBadS3S4(t *testing.T) { o := validObfuscation() - o.S3 = 65 + o.S3 = 65536 if err := ValidateObfuscation(o); err == nil { - t.Fatal("S3 > 64 must be rejected") + t.Fatal("S3 past uint16 must be rejected: amneziawg-go's UAPI parser refuses it") } o = validObfuscation() o.S4 = 33 @@ -158,6 +158,17 @@ func TestValidateObfuscationRejectsBadS3S4(t *testing.T) { } } +// Amnezia Premium ships S3=1045; 1636 is the largest cookie padding every platform can receive. +func TestValidateServerObfuscationAcceptsLargeS3(t *testing.T) { + for _, s3 := range []int{1045, 1636} { + o := validObfuscation() + o.S3 = s3 + if err := ValidateServerObfuscation(o); err != nil { + t.Fatalf("S3=%d must be accepted: %v", s3, err) + } + } +} + func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) { for field, set := range map[string]func(o *Obfuscation31){ "S1": func(o *Obfuscation31) { o.S1 = 11 }, @@ -415,7 +426,7 @@ func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) { } // TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths -// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4. +// amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s3. func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) { base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} tests := []struct { @@ -439,7 +450,55 @@ func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) { }) } if err := ValidateObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 65535, S2: 30, S3: 20, S4: 20}); err != nil { - t.Fatalf("S1 at the uint16 maximum must stay valid: %v", err) + t.Fatalf("S1 at the uint16 maximum must stay valid for an outbound: %v", err) + } +} + +// An inbound's handshakes must fit iOS's 1700-byte buffer: 148+S1, 92+S2 and 64+S3. +func TestValidateServerObfuscationBoundsHandshakesByTheIOSBuffer(t *testing.T) { + base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} + for name, mut := range map[string]func(*Obfuscation31){ + "S1 init over 1700 bytes": func(o *Obfuscation31) { o.S1 = 1553 }, + "S2 response over 1700 bytes": func(o *Obfuscation31) { o.S2 = 1609 }, + "S3 cookie over 1700 bytes": func(o *Obfuscation31) { o.S3 = 1637 }, + } { + o := base + mut(&o) + if err := ValidateServerObfuscation(o); err == nil { + t.Fatalf("%s: an iOS client could never receive it, so the inbound must not save", name) + } + } + // 148+1552 and 92+1608 are exactly 1700; Amnezia Premium ships S1=284 S2=659. + for _, s := range [][2]int{{1552, 30}, {20, 1608}, {284, 659}} { + if err := ValidateServerObfuscation(Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: s[0], S2: s[1], S3: 20, S4: 20}); err != nil { + t.Fatalf("S1=%d S2=%d must stay valid: %v", s[0], s[1], err) + } + } +} + +// amneziawg-go refuses the whole device when H1-H4 overlap ("headers must not overlap", +// device/uapi.go); 1-4 alone are legal and the engine's own default. +func TestValidateObfuscationHOverlap(t *testing.T) { + base := Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20} + reject := [][4]string{ + {"100-200", "150-300", "400", "500"}, + {"7", "7", "8", "9"}, + {"3", "", "", ""}, // blank H3 keeps the engine default 3 + } + for _, h := range reject { + o := base + o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3] + if err := ValidateObfuscation(o); err == nil { + t.Fatalf("H=%v overlaps, amneziawg-go rejects it, so the inbound must not save", h) + } + } + accept := [][4]string{{"1", "2", "3", "4"}, {"", "", "", ""}, {"5-10", "11-20", "21", "22-30"}} + for _, h := range accept { + o := base + o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3] + if err := ValidateObfuscation(o); err != nil { + t.Fatalf("H=%v must be accepted: %v", h, err) + } } } diff --git a/internal/amneziawgnet/device_test.go b/internal/amneziawgnet/device_test.go index 1eda9c71c..0cecc488e 100644 --- a/internal/amneziawgnet/device_test.go +++ b/internal/amneziawgnet/device_test.go @@ -584,6 +584,17 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) { {"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "" }}, {"I1 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "" }}, {"I1 no tags at all", func(o *amneziawg.Obfuscation31) { o.I1 = "plain text" }}, + {"H ranges overlap", func(o *amneziawg.Obfuscation31) { o.H1, o.H2 = "100-200", "150-300" }}, + {"H1 equals the blank H3 default", func(o *amneziawg.Obfuscation31) { o.H1 = "3" }}, + {"H1-H4 = WireGuard's 1-4", func(o *amneziawg.Obfuscation31) { o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4" }}, + // Separate cases: 1552+56 == 1608, so both maxima together trip the S1/S2 size rule. + {"S1 and S3 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S1, o.S3 = 1552, 1636 }}, + {"S2 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S2 = 1608 }}, + {"Amnezia Premium set", func(o *amneziawg.Obfuscation31) { + o.S1, o.S2, o.S3, o.S4 = 284, 659, 1045, 12 + o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4" + o.HeaderProtectionKey = "A2lG0Jm3m8u1WJt0qg3d7V6Qx8cFvH9pL1nR4sT6yZ0=" + }}, } for i, tc := range cases { diff --git a/internal/web/service/inbound_amneziawg.go b/internal/web/service/inbound_amneziawg.go index 7e00d414d..6c2de6907 100644 --- a/internal/web/service/inbound_amneziawg.go +++ b/internal/web/service/inbound_amneziawg.go @@ -238,7 +238,7 @@ func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound, oldS } { *f = amneziawg.CanonicalizeUintRange(*f) } - if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil { + if err := amneziawg.ValidateServerObfuscation(parsed.Server.Obfuscation()); err != nil { return fmt.Errorf("amneziawg: %w", err) } if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil { diff --git a/internal/web/service/inbound_amneziawg_test.go b/internal/web/service/inbound_amneziawg_test.go index 9be7ca6b3..4b0435912 100644 --- a/internal/web/service/inbound_amneziawg_test.go +++ b/internal/web/service/inbound_amneziawg_test.go @@ -188,6 +188,7 @@ func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) { }{ {"bad headerProtectionKey", `"headerProtectionKey":"short"`}, {"zero rekeyTimeout", `"rekeyTimeout":"0"`}, + {"S1 past what an iOS client can receive", `"s1":1553`}, {"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`}, {"control chars in i2", `"i2":"\nPostUp = evil"`}, {"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},