mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-18 03:45:02 +03:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user