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.
This commit is contained in:
YoungReckless4
2026-09-08 17:55:32 +03:00
committed by GitHub
parent 5a63d5d468
commit 3cd3836d77
12 changed files with 263 additions and 26 deletions
@@ -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
View File
@@ -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`;
@@ -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}`);
@@ -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');
});
});