Files
3x-ui/frontend/src/test/amneziawg-outbound-adapter.test.ts
T
Rouzbeh† d5ab84e8d5 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>
2026-09-10 14:50:48 +02:00

96 lines
3.7 KiB
TypeScript

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();
});
});