diff --git a/frontend/src/lib/xray/amneziawg-obfuscation.ts b/frontend/src/lib/xray/amneziawg-obfuscation.ts index b80d58344..477cba046 100644 --- a/frontend/src/lib/xray/amneziawg-obfuscation.ts +++ b/frontend/src/lib/xray/amneziawg-obfuscation.ts @@ -40,6 +40,21 @@ export type AwgObfuscation = Pick< const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1)); +// WireGuard's usual tunnel MTU on a 1500-byte host link. +export const DEFAULT_MTU = 1420; + +/** Floor for the S4-adjusted default, so a large s4 cannot shrink the tunnel + * below what clients reliably tolerate. */ +export const MIN_MTU = 1280; + +// s4 junk is prepended to every transport packet and never clamped to the MTU, +// so a plain 1420 tunnel fragments once s4 passes 20. Mirrors Go's EffectiveMTU. +export function effectiveMtu(configuredMtu: number | undefined, s4: number | undefined): number { + if (configuredMtu && configuredMtu > 0) return configuredMtu; + const junk = Math.max(s4 ?? 0, 0); + return Math.max(DEFAULT_MTU - junk, MIN_MTU); +} + /* * base64 of 32 crypto-grade random bytes — the exact HeaderProtectionKey * shape amneziawg-tools parses and the Go backend validates. diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 4b4020179..230c611cd 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -1,4 +1,5 @@ import { Base64, Wireguard } from '@/utils'; +import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation'; import type { Inbound } from '@/schemas/api/inbound'; import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg'; @@ -985,9 +986,7 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string { txt += `Address = ${(client.allowedIPs ?? []).join(', ')}\n`; const dns = [server.primaryDns, server.secondaryDns].filter((v) => !!v && v.trim() !== ''); if (dns.length > 0) txt += `DNS = ${dns.join(', ')}\n`; - if (typeof server.mtu === 'number' && server.mtu > 0) { - txt += `MTU = ${server.mtu}\n`; - } + txt += `MTU = ${effectiveMtu(server.mtu, server.s4)}\n`; txt += `Jc = ${server.jc}\n`; txt += `Jmin = ${server.jmin}\n`; txt += `Jmax = ${server.jmax}\n`; diff --git a/frontend/src/pages/clients/amneziawgConfig.ts b/frontend/src/pages/clients/amneziawgConfig.ts index cea30b916..74e5d0919 100644 --- a/frontend/src/pages/clients/amneziawgConfig.ts +++ b/frontend/src/pages/clients/amneziawgConfig.ts @@ -1,5 +1,6 @@ import { formatInboundLabel } from '@/lib/inbounds/label'; import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link'; +import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; // AmneziaWG clients are wire-identical to WireGuard clients (same @@ -65,7 +66,7 @@ export function buildAmneziaWGClientConfig( const dnsParts = [server?.primaryDns, server?.secondaryDns].filter((v) => !!v && v.trim() !== ''); const lines = ['[Interface]', `PrivateKey = ${privateKey}`, `Address = ${address}`]; if (dnsParts.length > 0) lines.push(`DNS = ${dnsParts.join(', ')}`); - if (server?.mtu && server.mtu > 0) lines.push(`MTU = ${server.mtu}`); + lines.push(`MTU = ${effectiveMtu(server?.mtu, server?.s4)}`); // AmneziaWG obfuscation parameters — must match the server's values. lines.push(`Jc = ${server?.jc ?? 5}`); diff --git a/frontend/src/test/amneziawg-conf-parity.test.ts b/frontend/src/test/amneziawg-conf-parity.test.ts index ed350e73b..4bec1bfea 100644 --- a/frontend/src/test/amneziawg-conf-parity.test.ts +++ b/frontend/src/test/amneziawg-conf-parity.test.ts @@ -113,3 +113,79 @@ describe('AmneziaWG .conf emitters agree on the peer block', () => { ).toEqual(want); }); }); + +// s4 junk is prepended to every transport packet and never clamped to the MTU, +// so both emitters must write the same S4-aware value the server interface uses. +describe('AmneziaWG .conf emitters agree on MTU', () => { + function build(mtu: number | undefined, s4: number) { + const settings = { + server: { + publicKey: 'serverPubKey==', + primaryDns: '8.8.8.8', + secondaryDns: '', + mtu, + jc: 4, + jmin: 40, + jmax: 100, + s1: 30, + s2: 90, + s3: 0, + s4, + h1: '', + h2: '', + h3: '', + h4: '', + }, + clients: [{ email: 'peer-1', privateKey: 'clientPrivKey==', allowedIPs: ['10.8.1.2/32'] }], + } as unknown as AmneziawgInboundSettings; + + const link = genAmneziaWGConfig({ + settings, + address: 'awg.example.test', + port: 51820, + remark: 'awg-peer-1', + peerIndex: 0, + }); + const download = buildAmneziaWGClientConfig( + { + email: 'peer-1', + privateKey: 'clientPrivKey==', + allowedIPs: '10.8.1.2/32', + } as unknown as ClientRecord, + { + id: 1, + tag: 'awg-1', + remark: 'awg', + protocol: 'amneziawg', + port: 51820, + awgServer: settings.server, + } as unknown as InboundOption, + 'awg.example.test', + ); + return { link, download }; + } + + function mtuLine(conf: string): string | undefined { + return conf.split('\n').find((l) => l.startsWith('MTU = ')); + } + + it('always emits an MTU, even when the inbound has none set', () => { + const { link, download } = build(undefined, 27); + // 1420 - 27: without this the client stays on its own 1420 default and + // fragments every full-size packet it sends. + expect(mtuLine(link)).toBe('MTU = 1393'); + expect(mtuLine(download)).toBe('MTU = 1393'); + }); + + it('keeps an explicit MTU untouched', () => { + const { link, download } = build(1380, 27); + expect(mtuLine(link)).toBe('MTU = 1380'); + expect(mtuLine(download)).toBe('MTU = 1380'); + }); + + it('falls back to the plain default when there is no s4', () => { + const { link, download } = build(undefined, 0); + expect(mtuLine(link)).toBe('MTU = 1420'); + expect(mtuLine(download)).toBe('MTU = 1420'); + }); +}); diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index de11e12d7..94dc27f7e 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -33,6 +33,19 @@ func randInt(min, max int) int { 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 { diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 09757dda4..6327ed340 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -378,6 +378,42 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) { } } +// The plain 1420 default left no headroom for s4: it put full-size packets at +// 1480+S4 on the wire and fragmented every one of them once S4 passed 20. +func TestEffectiveMTUKeepsFullSizePacketsUnfragmented(t *testing.T) { + t.Parallel() + + // 20 IPv4 + 8 UDP + 16 transport header + 16 poly1305 tag. + const encapOverhead = 60 + const hostLinkMTU = 1500 + + for s4 := 0; s4 <= 32; s4++ { + mtu := EffectiveMTU(0, s4) + if wire := mtu + encapOverhead + s4; wire > hostLinkMTU { + t.Errorf("s4=%d: MTU %d puts a full-size transport packet at %d bytes on the wire, over the %d-byte host link", s4, mtu, wire, hostLinkMTU) + } + } +} + +// TestEffectiveMTUPrefersTheAdminsValue: the S4-aware default is a fallback, +// not an override -- an explicit MTU must survive untouched. +func TestEffectiveMTUPrefersTheAdminsValue(t *testing.T) { + t.Parallel() + + if got := EffectiveMTU(1380, 27); got != 1380 { + t.Errorf("EffectiveMTU(1380, 27) = %d, want the configured 1380", got) + } + if got := EffectiveMTU(0, 27); got != DefaultMTU-27 { + t.Errorf("EffectiveMTU(0, 27) = %d, want %d", got, DefaultMTU-27) + } + if got := EffectiveMTU(0, 0); got != DefaultMTU { + t.Errorf("EffectiveMTU(0, 0) = %d, want %d", got, DefaultMTU) + } + if got := EffectiveMTU(-5, 12); got != DefaultMTU-12 { + t.Errorf("a nonsense configured MTU must fall back, got %d", got) + } +} + // TestValidateObfuscationRejectsOutOfRangeJunkAndPadding pins the widths // amneziawg-go's UAPI actually parses: uint32 for jc/jmin/jmax, uint16 for s1-s4. func TestValidateObfuscationRejectsOutOfRangeJunkAndPadding(t *testing.T) { diff --git a/internal/amneziawgnet/device.go b/internal/amneziawgnet/device.go index a817d6e75..8527f62ed 100644 --- a/internal/amneziawgnet/device.go +++ b/internal/amneziawgnet/device.go @@ -13,11 +13,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" ) -// defaultMTU matches internal/amneziawg's own kernel-module interface -// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel -// encapsulation overhead on a standard 1500-byte-MTU host link. -const defaultMTU = 1420 - // DeviceOptions carries AmneziaWG 3.0's device-wide fields (header // protection, content padding, and the five session-timing knobs) -- // mirrored from amneziawg.Instance's identically named fields by every @@ -78,7 +73,7 @@ type Device struct { // NewDevice constructs, configures, and brings up an embedded AmneziaWG // interface for inst in one call: a gVisor-backed tun.Device sized to -// inst.MTU (or defaultMTU), addressed with inst.Address, configured via +// amneziawg.EffectiveMTU, addressed with inst.Address, configured via // UAPI with inst.Obfuscation, inst.PrivateKey, opts' AWG 3.0 fields, and one // UAPI peer per inst.Peers entry. It does not attach a forwarder or start // relaying traffic -- that's the caller's job (see AttachTCPForwarder / @@ -122,10 +117,7 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device return nil, fmt.Errorf("amneziawgnet: %w", err) } - mtu := inst.MTU - if mtu <= 0 { - mtu = defaultMTU - } + mtu := amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4) tun, gstack, err := createNetTUNWithStack(addrs, mtu) if err != nil { diff --git a/internal/amneziawgnet/manager.go b/internal/amneziawgnet/manager.go index 81054d177..616b05fb8 100644 --- a/internal/amneziawgnet/manager.go +++ b/internal/amneziawgnet/manager.go @@ -126,9 +126,10 @@ func (m *Manager) Ensure(d Desired) error { // tearing down every peer's live handshake/session state on every single // reconcile, so no connection could ever survive past one tick); only // peers/obfuscation/keys/listen_port changed (reconfigure the existing -// Device in place via IpcSet); or the interface's own address(es)/MTU -// changed (these are fixed at netstack-construction time, so the only -// option is closing the old Device and building a fresh one). +// Device in place via IpcSet); or the interface's own address(es)/effective +// MTU changed -- S4 counts, the default MTU derives from it (these are fixed +// at netstack-construction time, so the only option is closing the old +// Device and building a fresh one). func (m *Manager) ensureLocked(d Desired) error { inst, opts := d.Instance, d.Options if opts.Logger == nil { @@ -256,12 +257,12 @@ func socksRelayForInstance(inst amneziawg.Instance) SocksRelay { } } -// addressFingerprint captures the two Instance fields that can't be changed -// on a running Device via IpcSet alone (they're fixed when the gVisor -// netstack is built) -- everything else (keys, listen port, obfuscation, -// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure. +// addressFingerprint captures what IpcSet can't change on a running Device, +// fixed when the netstack is built: address, and the S4-derived effective MTU. func addressFingerprint(inst amneziawg.Instance) string { - return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ",")) + return fmt.Sprintf("%d|%s", + amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4), + strings.Join(inst.Address, ",")) } // Reconcile brings every desired instance's embedded interface up to date diff --git a/internal/amneziawgnet/manager_test.go b/internal/amneziawgnet/manager_test.go index b8b64f3ef..f067852f2 100644 --- a/internal/amneziawgnet/manager_test.go +++ b/internal/amneziawgnet/manager_test.go @@ -90,6 +90,69 @@ func TestManagerLifecycle(t *testing.T) { } } +// An inbound with no explicit MTU derives it from S4, so an S4-only edit is +// structural: leave it out of the fingerprint and the netstack keeps the old MTU +// while every client emitter already advertises the new one. +func TestEnsureRebuildsWhenS4ChangesTheDerivedMTU(t *testing.T) { + priv, pub, err := wireguard.GenerateWireguardKeypair() + if err != nil { + t.Fatalf("generate keypair: %v", err) + } + + tests := []struct { + name string + mtu int + wantRebuild bool + }{ + {"derived MTU", 0, true}, + {"explicit MTU", 1420, false}, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &Manager{ifaces: map[int]*managed{}} + defer m.StopAll() + + inst := amneziawg.Instance{ + Id: 9 + i, + InterfaceName: fmt.Sprintf("awgtest%d", 9+i), + ListenPort: 58719 + i, + PrivateKey: priv, + PublicKey: pub, + Address: []string{"10.209.0.1/24"}, + MTU: tt.mtu, + Obfuscation: amneziawg.Obfuscation31{ + Jc: 4, Jmin: 40, Jmax: 70, + S1: 20, S2: 30, S3: 20, S4: 5, + }, + } + if err := m.Ensure(Desired{Instance: inst}); err != nil { + t.Fatalf("Ensure (create): %v", err) + } + before, _, ok := m.Lookup(inst.Id) + if !ok { + t.Fatal("Lookup after create: not found") + } + + edited := inst + edited.Obfuscation.S4 = 27 + if err := m.Ensure(Desired{Instance: edited}); err != nil { + t.Fatalf("Ensure (S4 changed): %v", err) + } + after, _, ok := m.Lookup(inst.Id) + if !ok { + t.Fatal("Lookup after S4 edit: not found") + } + + if rebuilt := before != after; rebuilt != tt.wantRebuild { + t.Errorf("S4 5->27 rebuilt the Device = %v, want %v (MTU %d -> %d)", + rebuilt, tt.wantRebuild, + amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4), + amneziawg.EffectiveMTU(edited.MTU, edited.Obfuscation.S4)) + } + }) + } +} + func TestManagedUDPHandlerDoesNotWaitForManagerLock(t *testing.T) { cur := &managed{udpRelay: NewUDPRelay(SocksRelay{Addr: "invalid"}, nil)} cur.peers.Store(NewPeerIndex([]amneziawg.Peer{{ diff --git a/internal/sub/service.go b/internal/sub/service.go index 24dfebb90..c9e6cdaf1 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -729,9 +729,9 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, if len(dns) > 0 { fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", ")) } - if server.MTU > 0 { - fmt.Fprintf(&b, "MTU = %d\n", server.MTU) - } + // Always emitted: a missing MTU line leaves the client on its own 1420 + // default and fragments the client-to-server direction once S4 passes 20. + fmt.Fprintf(&b, "MTU = %d\n", amneziawg.EffectiveMTU(server.MTU, server.S4)) fmt.Fprintf(&b, "Jc = %d\n", server.Jc) fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin) diff --git a/internal/sub/service_amneziawg_test.go b/internal/sub/service_amneziawg_test.go index 83265043a..cfa1f66da 100644 --- a/internal/sub/service_amneziawg_test.go +++ b/internal/sub/service_amneziawg_test.go @@ -3,6 +3,7 @@ package sub import ( "encoding/base64" "slices" + "strconv" "strings" "testing" @@ -275,3 +276,42 @@ func TestAmneziaWGConfigTextRejectsNewlineInjection(t *testing.T) { }) } } + +// Guards an asymmetry: the server derives its MTU from S4, but a config with no +// MTU line leaves the client at 1420 and fragments client-to-server only. +func TestAmneziaWGConfigTextAlwaysCarriesTheServerMTU(t *testing.T) { + t.Parallel() + + client := &model.Client{ + Email: "peer-1", + PrivateKey: "clientPrivateKeyBase64ValueForTests00000000=", + AllowedIPs: []string{"10.8.1.2/32"}, + } + cases := []struct { + name string + serverMTU int + s4 int + want string + }{ + {"unset falls back to the S4-aware default", 0, 27, "MTU = 1393"}, + {"unset with no S4 keeps the plain default", 0, 0, "MTU = 1420"}, + {"an explicit MTU wins", 1380, 27, "MTU = 1380"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := &amneziawg.ServerSettings{ + PublicKey: "serverPubKeyBase64ValueForTests000000000000=", + MTU: tc.serverMTU, + S4: tc.s4, + } + got := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "peer-1") + if !strings.Contains(got, tc.want+"\n") { + t.Errorf("expected %q in the client config\n%s", tc.want, got) + } + want := "MTU = " + strconv.Itoa(amneziawg.EffectiveMTU(tc.serverMTU, tc.s4)) + if !strings.Contains(got, want+"\n") { + t.Errorf("client MTU must equal the server's effective MTU (%s)", want) + } + }) + } +} diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go index 5efd49e66..8298eaada 100644 --- a/internal/web/runtime/local.go +++ b/internal/web/runtime/local.go @@ -161,7 +161,8 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In // AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison // can reconfigure the running embedded Device in place via IpcSet instead // of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked -- -// only an address/MTU change forces a rebuild there, not a peer edit). +// only an address or effective-MTU change forces a rebuild there, S4 +// included, not a peer edit). // // Every exit path below only touches the embedded Device via // amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which