فهرست منبع

fix(amneziawg): account for S4 junk in the default tunnel MTU (#6376)

* 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.
YoungReckless4 4 ساعت پیش
والد
کامیت
3cd3836d77

+ 15 - 0
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.

+ 2 - 3
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`;

+ 2 - 1
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}`);

+ 76 - 0
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');
+  });
+});

+ 13 - 0
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 {

+ 36 - 0
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) {

+ 2 - 10
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 {

+ 9 - 8
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

+ 63 - 0
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{{

+ 3 - 3
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)

+ 40 - 0
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)
+			}
+		})
+	}
+}

+ 2 - 1
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