mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00: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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user