feat(amneziawg): add AmneziaWG as an outbound protocol (#6320)

* feat(amneziawg): add AmneziaWG as an outbound protocol

- AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI
- Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test
- Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions
- Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test
- Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go
- Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients)
- Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress)
- Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts)
- Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks

* fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys

- Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns
- Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound
- Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection
- Trim comment blocks exceeding 2 lines across modified files
- Fix Storybook test execution on environments with POSIX locale

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-09-10 16:20:48 +03:30
committed by GitHub
parent 876497db6e
commit d5ab84e8d5
50 changed files with 4169 additions and 33 deletions
+8 -1
View File
@@ -32,7 +32,14 @@ export function isUdpOutbound(outbound: unknown): boolean {
| undefined;
const p = o?.protocol;
const n = o?.streamSettings?.network;
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
return (
p === 'wireguard' ||
p === 'hysteria' ||
p === 'amneziawg' ||
n === 'hysteria' ||
n === 'kcp' ||
n === 'quic'
);
}
export type OutboundTestMode = 'tcp' | 'http' | 'real';
@@ -1,11 +1,13 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { Wireguard } from '@/utils';
import type { Sniffing, SniffingDest } from '@/schemas/primitives';
import type { OutboundDomainStrategy } from '@/schemas/protocols/outbound';
import type {
AmneziaWGOutboundFormSettings,
BlackholeOutboundFormSettings,
DnsOutboundFormSettings,
DnsRuleForm,
@@ -377,6 +379,104 @@ function loopbackFromWire(raw: Raw): LoopbackOutboundFormSettings {
};
}
function amneziawgPeerFromWire(p: unknown): AmneziaWGOutboundFormSettings['peers'][number] {
const pp = asObject(p);
const allowed = asArray(pp.allowedIPs).map((x) => asString(x));
return {
publicKey: asString(pp.publicKey),
presharedKey: asString(pp.presharedKey),
allowedIPs: allowed.length > 0 ? allowed : ['0.0.0.0/0', '::/0'],
endpoint: asString(pp.endpoint),
keepAlive: asNumber(pp.keepAlive, 0),
};
}
// The form state IS the wire shape; hydrate only to apply defaults for keys
// an older template may omit.
function amneziawgFromWire(raw: Raw): AmneziaWGOutboundFormSettings {
return AmneziaWGOutboundSettingsSchema.parse({
mtu: asNumber(raw.mtu, 0),
secretKey: asString(raw.secretKey),
address: asArray(raw.address).map((x) => asString(x)),
listenPort: asNumber(raw.listenPort, 0),
dns: asString(raw.dns),
jc: asNumber(raw.jc, 0),
jmin: asNumber(raw.jmin, 40),
jmax: asNumber(raw.jmax, 100),
s1: asNumber(raw.s1, 15),
s2: asNumber(raw.s2, 80),
s3: asNumber(raw.s3, 12),
s4: asNumber(raw.s4, 12),
h1: asString(raw.h1),
h2: asString(raw.h2),
h3: asString(raw.h3),
h4: asString(raw.h4),
i1: asString(raw.i1),
i2: asString(raw.i2),
i3: asString(raw.i3),
i4: asString(raw.i4),
i5: asString(raw.i5),
headerProtectionKey: asString(raw.headerProtectionKey),
contentPaddingAddition: asString(raw.contentPaddingAddition),
rekeyAfterTime: asString(raw.rekeyAfterTime),
rekeyTimeout: asString(raw.rekeyTimeout),
rejectAfterTime: asString(raw.rejectAfterTime),
keepaliveTimeout: asString(raw.keepaliveTimeout),
maxHandshakeAttempts: asString(raw.maxHandshakeAttempts),
randomTrailers: raw.randomTrailers === undefined ? false : asBool(raw.randomTrailers),
disableCookies: raw.disableCookies === undefined ? true : asBool(raw.disableCookies),
peers: asArray(raw.peers).map(amneziawgPeerFromWire),
});
}
function amneziawgToWire(s: AmneziaWGOutboundFormSettings): Raw {
const out: Raw = {
mtu: s.mtu || undefined,
secretKey: s.secretKey,
address: s.address,
jc: s.jc,
jmin: s.jmin,
jmax: s.jmax,
s1: s.s1,
s2: s.s2,
s3: s.s3,
s4: s.s4,
h1: s.h1,
h2: s.h2,
h3: s.h3,
h4: s.h4,
randomTrailers: s.randomTrailers,
disableCookies: s.disableCookies,
peers: s.peers.map((p) => ({
publicKey: p.publicKey,
presharedKey: p.presharedKey.length > 0 ? p.presharedKey : undefined,
allowedIPs: p.allowedIPs.length > 0 ? p.allowedIPs : undefined,
endpoint: p.endpoint,
keepAlive: p.keepAlive || undefined,
})),
};
if (s.listenPort > 0) out.listenPort = s.listenPort;
if (s.dns && s.dns.length > 0) out.dns = s.dns;
const optionalStrings = [
'i1',
'i2',
'i3',
'i4',
'i5',
'headerProtectionKey',
'contentPaddingAddition',
'rekeyAfterTime',
'rekeyTimeout',
'rejectAfterTime',
'keepaliveTimeout',
'maxHandshakeAttempts',
] as const;
for (const k of optionalStrings) {
if (s[k].length > 0) out[k] = s[k];
}
return out;
}
function muxFromWire(raw: unknown): MuxForm {
const m = asObject(raw);
return {
@@ -457,6 +557,9 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
case 'wireguard':
typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) };
break;
case 'amneziawg':
typed = { protocol: 'amneziawg', settings: amneziawgFromWire(settings) };
break;
case 'hysteria':
typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) };
break;
@@ -753,6 +856,9 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
case 'wireguard':
settings = wireguardToWire(values.settings);
break;
case 'amneziawg':
settings = amneziawgToWire(values.settings);
break;
case 'hysteria':
settings = hysteriaToWire(values.settings);
break;
@@ -45,6 +45,7 @@ import {
VmessFields,
WireguardFields,
} from './protocols';
import { AmneziawgFields } from './protocols';
import {
GrpcForm,
HttpUpgradeForm,
@@ -453,6 +454,7 @@ export default function OutboundFormModal({
)}
{protocol === 'wireguard' && <WireguardFields />}
{protocol === 'amneziawg' && <AmneziawgFields />}
{streamAllowed && network && (
<>
@@ -0,0 +1,228 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
// amneziawg outbound fields reuse the inbound i18n keys: identical protocol
// parameters on both tunnel ends, so one label set serves both forms.
export default function AmneziawgFields() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const {
fields: peerFields,
append: appendPeer,
remove: removePeer,
} = useFieldArray({ control, name: 'settings.peers' });
return (
<>
<FormField label={t('pages.xray.amneziawg.mtu')} name={['settings', 'mtu']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.xray.amneziawg.listenPort')}
name={['settings', 'listenPort']}
extra={t('pages.xray.amneziawg.listenPortHint')}
>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.inbounds.privatekey')}>
<FormField name={['settings', 'secretKey']} noStyle>
<Input
aria-label={t('pages.inbounds.privatekey')}
style={{ width: 'calc(100% - 32px)' }}
/>
</FormField>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => {
const pair = Wireguard.generateKeypair();
setValue('settings.secretKey', pair.privateKey);
}}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.address')} required>
<FormField name={['settings', 'address', 0]} noStyle>
<Input placeholder="10.8.0.2/32" aria-label={t('pages.inbounds.address')} />
</FormField>
</Form.Item>
<FormField label={t('pages.inbounds.info.dns')} name={['settings', 'dns']}>
<Input placeholder="1.1.1.1:53" />
</FormField>
<FormField
name={['settings', 'headerProtectionKey']}
label={t('pages.xray.amneziawg.headerProtectionKey')}
extra={t('pages.xray.amneziawg.headerProtectionKeyHint')}
>
<Input />
</FormField>
<Form.Item
label={t('pages.xray.amneziawg.obfuscation')}
extra={t('pages.xray.amneziawg.outboundObfuscationHint')}
/>
<ObfNumber name="jc" label={t('pages.xray.amneziawg.jc')} min={0} />
<ObfNumber name="jmin" label={t('pages.xray.amneziawg.jmin')} min={0} />
<ObfNumber name="jmax" label={t('pages.xray.amneziawg.jmax')} min={0} />
<ObfNumber name="s1" label={t('pages.xray.amneziawg.s1')} min={0} />
<ObfNumber name="s2" label={t('pages.xray.amneziawg.s2')} min={0} />
<ObfNumber name="s3" label={t('pages.xray.amneziawg.s3')} min={0} max={64} />
<ObfNumber name="s4" label={t('pages.xray.amneziawg.s4')} min={0} max={32} />
<ObfText name="h1" label={t('pages.xray.amneziawg.h1')} placeholder="100-800" />
<ObfText name="h2" label={t('pages.xray.amneziawg.h2')} placeholder="900-1600" />
<ObfText name="h3" label={t('pages.xray.amneziawg.h3')} placeholder="1700-2400" />
<ObfText name="h4" label={t('pages.xray.amneziawg.h4')} placeholder="2500-3200" />
<ObfText name="i1" label={t('pages.xray.amneziawg.i1')} placeholder="<r 64>" />
<ObfText
name="contentPaddingAddition"
label={t('pages.xray.amneziawg.contentPaddingAddition')}
placeholder="8-64"
/>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendPeer({
publicKey: '',
presharedKey: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
keepAlive: 25,
})
}
/>
</Form.Item>
{peerFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
{peerFields.length > 1 && (
<MinusOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removePeer(index)}
onKeyDown={activateOnKey(() => removePeer(index))}
/>
)}
</div>
</Form.Item>
<FormField
label={t('pages.xray.wireguard.endpoint')}
name={['settings', 'peers', index, 'endpoint']}
>
<Input placeholder="203.0.113.7:51820" />
</FormField>
<FormField
label={t('pages.inbounds.publicKey')}
name={['settings', 'peers', index, 'publicKey']}
>
<Input />
</FormField>
<FormField label="PSK" name={['settings', 'peers', index, 'presharedKey']}>
<Input />
</FormField>
<PeerAllowedIPs peerIndex={index} />
<FormField
label={t('pages.inbounds.info.keepAlive')}
name={['settings', 'peers', index, 'keepAlive']}
>
<InputNumber min={0} />
</FormField>
</div>
))}
<FormField
name={['settings', 'randomTrailers']}
label={t('pages.xray.amneziawg.randomTrailers')}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
name={['settings', 'disableCookies']}
label={t('pages.xray.amneziawg.disableCookies')}
valueProp="checked"
>
<Switch />
</FormField>
</>
);
}
function PeerAllowedIPs({ peerIndex }: { peerIndex: number }) {
const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name: `settings.peers.${peerIndex}.allowedIPs`,
});
return (
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
{fields.map((field, ipIdx) => (
<Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
<FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</FormField>
{fields.length > 1 && (
<InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append('')}
/>
</Form.Item>
);
}
function ObfNumber({
name,
label,
min,
max,
}: {
name: string;
label: string;
min?: number;
max?: number;
}) {
return (
<FormField label={label} name={['settings', name] as never}>
<InputNumber min={min} max={max} style={{ width: '100%' }} />
</FormField>
);
}
function ObfText({
name,
label,
placeholder,
}: {
name: string;
label: string;
placeholder?: string;
}) {
return (
<FormField label={label} name={['settings', name] as never}>
<Input placeholder={placeholder} />
</FormField>
);
}
@@ -6,6 +6,7 @@ export { default as ShadowsocksFields } from './shadowsocks';
export { default as HttpFields } from './http';
export { default as SocksFields } from './socks';
export { default as WireguardFields } from './wireguard';
export { default as AmneziawgFields } from './amneziawg';
export { default as FreedomFields } from './freedom';
export { default as LoopbackFields } from './loopback';
export { default as BlackholeFields } from './blackhole';
@@ -6,6 +6,7 @@ import { VmessSecuritySchema } from '@/schemas/protocols/shared/vmess';
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
import {
AmneziaWGOutboundSettingsSchema,
BlackholeResponseTypeSchema,
DNSRuleActionSchema,
FreedomFinalRuleActionSchema,
@@ -112,6 +113,11 @@ export const WireguardOutboundFormSettingsSchema = z.object({
});
export type WireguardOutboundFormSettings = z.infer<typeof WireguardOutboundFormSettingsSchema>;
// Re-export under the form name: the form state IS the wire shape (flat
// obfuscation fields, same as the inbound server block), so no rename layer.
export const AmneziaWGOutboundFormSettingsSchema = AmneziaWGOutboundSettingsSchema;
export type AmneziaWGOutboundFormSettings = z.infer<typeof AmneziaWGOutboundFormSettingsSchema>;
// Hysteria outbound carries the connect target only; transport-layer knobs
// (auth, congestion, up/down, hop port, timeouts) ride on stream.hysteria.
export const HysteriaOutboundFormSettingsSchema = z.object({
@@ -194,6 +200,7 @@ export const OutboundFormSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('socks'), settings: SocksOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('freedom'), settings: FreedomOutboundFormSettingsSchema }),
z.object({ protocol: z.literal('blackhole'), settings: BlackholeOutboundFormSettingsSchema }),
@@ -7,6 +7,7 @@ export const OutboundProtocols = Object.freeze({
Trojan: 'trojan',
Shadowsocks: 'shadowsocks',
Wireguard: 'wireguard',
AmneziaWG: 'amneziawg',
Hysteria: 'hysteria',
Socks: 'socks',
HTTP: 'http',
@@ -0,0 +1,49 @@
import { z } from 'zod';
// Wire format of an "amneziawg" OUTBOUND settings block; form-edited and
// backend-validated, swapped for a socks bridge at config generation.
export const AmneziaWGOutboundPeerSchema = z.object({
publicKey: z.string().default(''),
presharedKey: z.string().default(''),
allowedIPs: z.array(z.string()).default(['0.0.0.0/0', '::/0']),
endpoint: z.string().default(''),
keepAlive: z.number().int().min(0).default(0),
});
export type AmneziaWGOutboundPeer = z.infer<typeof AmneziaWGOutboundPeerSchema>;
export const AmneziaWGOutboundSettingsSchema = z.object({
// 0 = unset, so the backend derives MTU from S4 (amneziawg.EffectiveMTU);
// a pinned 1420 fragments every full-size packet once S4 exceeds 20.
mtu: z.number().int().min(0).default(0),
secretKey: z.string().default(''),
address: z.array(z.string()).default([]),
listenPort: z.number().int().min(0).max(65535).default(0),
dns: z.string().default(''),
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),
s4: z.number().int().min(0).max(32).default(12),
h1: z.string().default(''),
h2: z.string().default(''),
h3: z.string().default(''),
h4: z.string().default(''),
i1: z.string().default(''),
i2: z.string().default(''),
i3: z.string().default(''),
i4: z.string().default(''),
i5: z.string().default(''),
headerProtectionKey: z.string().default(''),
contentPaddingAddition: z.string().default(''),
rekeyAfterTime: z.string().default(''),
rekeyTimeout: z.string().default(''),
rejectAfterTime: z.string().default(''),
keepaliveTimeout: z.string().default(''),
maxHandshakeAttempts: z.string().default(''),
randomTrailers: z.boolean().default(false),
disableCookies: z.boolean().default(true),
peers: z.array(AmneziaWGOutboundPeerSchema).default([]),
});
export type AmneziaWGOutboundSettings = z.infer<typeof AmneziaWGOutboundSettingsSchema>;
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { BlackholeOutboundSettingsSchema } from './blackhole';
import { AmneziaWGOutboundSettingsSchema } from './amneziawg';
import { DNSOutboundSettingsSchema } from './dns';
import { FreedomOutboundSettingsSchema } from './freedom';
import { HttpOutboundSettingsSchema } from './http';
@@ -14,6 +15,7 @@ import { VmessOutboundSettingsSchema } from './vmess';
import { WireguardOutboundSettingsSchema } from './wireguard';
export * from './blackhole';
export * from './amneziawg';
export * from './dns';
export * from './freedom';
export * from './http';
@@ -32,6 +34,7 @@ export const OutboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('trojan'), settings: TrojanOutboundSettingsSchema }),
z.object({ protocol: z.literal('shadowsocks'), settings: ShadowsocksOutboundSettingsSchema }),
z.object({ protocol: z.literal('wireguard'), settings: WireguardOutboundSettingsSchema }),
z.object({ protocol: z.literal('amneziawg'), settings: AmneziaWGOutboundSettingsSchema }),
z.object({ protocol: z.literal('hysteria'), settings: HysteriaOutboundSettingsSchema }),
z.object({ protocol: z.literal('http'), settings: HttpOutboundSettingsSchema }),
z.object({ protocol: z.literal('socks'), settings: SocksOutboundSettingsSchema }),
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import type { AmneziaWGOutboundFormSettings } from '@/schemas/forms/outbound-form';
// amneziawg outbound: lossless form->wire->form; payload stays a raw row.
describe('amneziawg outbound adapter', () => {
const wire = {
mtu: 1380,
secretKey: '6Nn0ZB4C1Pj3TBEsXgLv7VdmSnYXGxS+HhVBDhvGgHE=',
address: ['10.8.0.2/32'],
listenPort: 40001,
jc: 5,
jmin: 40,
jmax: 90,
s1: 20,
s2: 90,
s3: 15,
s4: 13,
h1: '100-800',
h2: '900-1600',
h3: '1700-2400',
h4: '2500-3200',
i1: '<r 64>',
contentPaddingAddition: '8-40',
randomTrailers: true,
disableCookies: false,
peers: [
{
publicKey: 'Qk9fWqDqC7LzKpYvJq0m2b1tq8eF3uY6oPpRrSsTtUu=',
presharedKey: 'cHNo',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '203.0.113.7:51820',
keepAlive: 25,
},
],
};
it('hydrates defaults when the template omits optional keys', () => {
const values = rawOutboundToFormValues({ protocol: 'amneziawg', tag: 'awg-x' });
expect(values.protocol).toBe('amneziawg');
const s = values.settings as AmneziaWGOutboundFormSettings;
expect(s.mtu).toBe(0);
expect(s.randomTrailers).toBe(false);
expect(s.disableCookies).toBe(true);
expect(s.peers).toEqual([]);
expect(values.tag).toBe('awg-x');
});
// A blank MTU must reach the backend absent, not pinned to 1420: the Go
// EffectiveMTU subtracts S4 from the default only when the field is unset.
it('leaves a defaulted MTU out of the payload so the backend derives it', () => {
const values = rawOutboundToFormValues({ protocol: 'amneziawg', tag: 'awg-x' });
const payload = formValuesToWirePayload(values);
expect((payload.settings as Record<string, unknown>).mtu).toBeUndefined();
});
it('round-trips wire -> form -> wire losslessly', () => {
const values = rawOutboundToFormValues({ protocol: 'amneziawg', tag: 'awg-x', settings: wire });
const payload = formValuesToWirePayload(values);
expect(payload.protocol).toBe('amneziawg');
expect(payload.tag).toBe('awg-x');
// undefined-valued optionals are dropped by JSON semantics; compare the
// meaningful fields directly.
expect((payload.settings as Record<string, unknown>).mtu).toBe(1380);
expect((payload.settings as Record<string, unknown>).listenPort).toBe(40001);
expect((payload.settings as Record<string, unknown>).i1).toBe('<r 64>');
expect((payload.settings as Record<string, unknown>).peers).toEqual(wire.peers);
expect((payload.settings as Record<string, unknown>).disableCookies).toBe(false);
});
it('omits empty optional strings and zero listenPort from the payload', () => {
const values = rawOutboundToFormValues({ protocol: 'amneziawg', settings: wire });
const awg = values.settings as AmneziaWGOutboundFormSettings;
awg.i1 = '';
awg.listenPort = 0;
const payload = formValuesToWirePayload(values);
const s = payload.settings as Record<string, unknown>;
expect(s.i1).toBeUndefined();
expect(s.listenPort).toBeUndefined();
// always-present booleans survive so a true->false edit is diffable
expect(s.randomTrailers).toBe(true);
});
it('is included in every protocol-capability gate like wireguard (non-stream, non-mux)', () => {
const values = rawOutboundToFormValues({
protocol: 'amneziawg',
settings: wire,
streamSettings: { network: 'tcp', tcpSettings: {} },
});
const payload = formValuesToWirePayload(values);
// Non-stream protocol keeps only sockopt; here there is none.
expect(payload.streamSettings).toBeUndefined();
});
});