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
@@ -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;