mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 20:27:15 +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:
@@ -600,3 +600,30 @@ only - it changes no code), `claude-issue-analyst.yml` (issue triage).
|
||||
- **Tests live next to code** (`foo.go` ↔ `foo_test.go`), plus golden snapshots in
|
||||
`frontend/src/test/golden/fixtures/` for config generation — update fixtures intentionally,
|
||||
not blindly, when output changes.
|
||||
|
||||
## AmneziaWG outbound pseudo-protocol
|
||||
|
||||
The template stores `protocol: "amneziawg"` rows verbatim; Xray-core has no
|
||||
such proxy. At config generation (`GetXrayConfig` and the outbound latency
|
||||
probe's batch config) each row is swapped by `amneziawgnet.BuildSocksBridge`
|
||||
into a loopback socks outbound pointed at the panel's egress server (port
|
||||
`EgressBasePort`), authenticating with the row's tag as username. Sibling keys
|
||||
(`mux`, `sendThrough`, `targetStrategy`, `streamSettings.sockopt`) survive the
|
||||
swap. The embedded amneziawg-go client device lives in the panel process; an
|
||||
unbridgeable entry (unreadable settings, empty/non-string tag) fails config
|
||||
generation instead of skipping, because a skipped entry leaves
|
||||
`protocol: "amneziawg"` behind -- which makes Xray refuse the whole config.
|
||||
|
||||
Traffic flow: Xray socks client -> egress SOCKS5 server (tag = username) ->
|
||||
per-tag device netstack -> amneziawg-go tunnel. Domain targets are resolved by
|
||||
a DNS exchange through that same netstack (`resolveTunnelVia`, default server
|
||||
`DefaultTunnelDNSServer`), so names never leak to the panel host's resolver and
|
||||
answers are valid at the tunnel's location; results cache for 60s. UDP flows
|
||||
key sessions on the resolved address:port. Peer endpoints may be hostnames:
|
||||
`resolvingBind.ParseEndpoint` resolves once at configure time (kernel
|
||||
`wg setconf` semantics); a hostname whose DNS dies later needs a template
|
||||
re-save or job restart to re-resolve.
|
||||
|
||||
`randomTrailers` defaults to false wherever the panel does not control the
|
||||
peer (outbound form/schema): a receiver without 3.1 trailers silently drops
|
||||
oversized packets from a sender with it enabled.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script>
|
||||
if (typeof navigator !== 'undefined' && (!navigator.language || navigator.language.includes('@'))) {
|
||||
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
|
||||
}
|
||||
if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
|
||||
if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
|
||||
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
package amneziawg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// OutboundPeer is one remote AmneziaWG server: its public key, the routes
|
||||
// AllowedIPs steers into the tunnel, and its "host:port" Endpoint.
|
||||
type OutboundPeer struct {
|
||||
PublicKey string
|
||||
PresharedKey string
|
||||
AllowedIPs []string
|
||||
Endpoint string
|
||||
KeepAlive int
|
||||
}
|
||||
|
||||
// OutboundInstance is the desired runtime config of one client-mode
|
||||
// AmneziaWG outbound -- the mirror of Instance, consumed by amneziawgnet.
|
||||
type OutboundInstance struct {
|
||||
Tag string
|
||||
Address []string
|
||||
MTU int
|
||||
PrivateKey string
|
||||
Obfuscation Obfuscation31
|
||||
Peers []OutboundPeer
|
||||
ListenPort int
|
||||
DNS string
|
||||
}
|
||||
|
||||
// OutboundSettings is the Settings JSON stored on an "amneziawg" outbound
|
||||
// row; flat obfuscation keys mirror ServerSettings so values paste 1:1.
|
||||
type OutboundSettings struct {
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
SecretKey string `json:"secretKey"`
|
||||
Address []string `json:"address"`
|
||||
ListenPort int `json:"listenPort,omitempty"`
|
||||
DNS string `json:"dns,omitempty"`
|
||||
|
||||
// Flat Obfuscation31 mirror -- see OutboundSettings' doc comment.
|
||||
Jc int `json:"jc"`
|
||||
Jmin int `json:"jmin"`
|
||||
Jmax int `json:"jmax"`
|
||||
S1 int `json:"s1"`
|
||||
S2 int `json:"s2"`
|
||||
S3 int `json:"s3"`
|
||||
S4 int `json:"s4"`
|
||||
H1 string `json:"h1"`
|
||||
H2 string `json:"h2"`
|
||||
H3 string `json:"h3"`
|
||||
H4 string `json:"h4"`
|
||||
I1 string `json:"i1,omitempty"`
|
||||
I2 string `json:"i2,omitempty"`
|
||||
I3 string `json:"i3,omitempty"`
|
||||
I4 string `json:"i4,omitempty"`
|
||||
I5 string `json:"i5,omitempty"`
|
||||
|
||||
HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
|
||||
ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
|
||||
RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
|
||||
RekeyTimeout string `json:"rekeyTimeout,omitempty"`
|
||||
RejectAfterTime string `json:"rejectAfterTime,omitempty"`
|
||||
KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
|
||||
MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
|
||||
RandomTrailers bool `json:"randomTrailers"`
|
||||
DisableCookies bool `json:"disableCookies"`
|
||||
|
||||
Peers []OutboundSettingsPeer `json:"peers"`
|
||||
}
|
||||
|
||||
// OutboundSettingsPeer is one entry of OutboundSettings.Peers.
|
||||
type OutboundSettingsPeer struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
PresharedKey string `json:"presharedKey,omitempty"`
|
||||
AllowedIPs []string `json:"allowedIPs"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
KeepAlive int `json:"keepAlive,omitempty"`
|
||||
}
|
||||
|
||||
// Obfuscation folds the flat wire fields back into the grouped type, matching
|
||||
// ServerSettings.Obfuscation.
|
||||
func (s OutboundSettings) Obfuscation() Obfuscation31 {
|
||||
return Obfuscation31{
|
||||
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,
|
||||
I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
|
||||
HeaderProtectionKey: s.HeaderProtectionKey,
|
||||
ContentPaddingAddition: s.ContentPaddingAddition,
|
||||
RekeyAfterTime: s.RekeyAfterTime,
|
||||
RekeyTimeout: s.RekeyTimeout,
|
||||
RejectAfterTime: s.RejectAfterTime,
|
||||
KeepaliveTimeout: s.KeepaliveTimeout,
|
||||
MaxHandshakeAttempts: s.MaxHandshakeAttempts,
|
||||
RandomTrailers: s.RandomTrailers,
|
||||
DisableCookies: s.DisableCookies,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAmneziaWGOutbound reports whether a raw outbound JSON object from the
|
||||
// Xray template carries the panel's amneziawg pseudo-protocol.
|
||||
func IsAmneziaWGOutbound(raw []byte) bool {
|
||||
var probe struct {
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return false
|
||||
}
|
||||
return probe.Protocol == "amneziawg"
|
||||
}
|
||||
|
||||
// outboundSettingsOf extracts the nested "settings" block from a raw
|
||||
// amneziawg template outbound.
|
||||
func outboundSettingsOf(raw []byte) (json.RawMessage, bool) {
|
||||
var wrapper struct {
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapper); err != nil || len(wrapper.Settings) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return wrapper.Settings, true
|
||||
}
|
||||
|
||||
// InstanceFromOutbound derives a client-mode instance from one raw template
|
||||
// outbound; false when unusable or a peer lacks key/endpoint/allowedIPs.
|
||||
func InstanceFromOutbound(tag string, raw []byte) (OutboundInstance, bool) {
|
||||
settingsRaw, ok := outboundSettingsOf(raw)
|
||||
if !ok {
|
||||
return OutboundInstance{}, false
|
||||
}
|
||||
var parsed OutboundSettings
|
||||
if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
|
||||
return OutboundInstance{}, false
|
||||
}
|
||||
inst := OutboundInstance{
|
||||
Tag: tag,
|
||||
Address: parsed.Address,
|
||||
MTU: parsed.MTU,
|
||||
PrivateKey: parsed.SecretKey,
|
||||
ListenPort: parsed.ListenPort,
|
||||
DNS: NormalizeDNSServer(parsed.DNS),
|
||||
Obfuscation: Obfuscation31{
|
||||
Jc: parsed.Jc, Jmin: parsed.Jmin, Jmax: parsed.Jmax,
|
||||
S1: parsed.S1, S2: parsed.S2, S3: parsed.S3, S4: parsed.S4,
|
||||
H1: parsed.H1, H2: parsed.H2, H3: parsed.H3, H4: parsed.H4,
|
||||
I1: parsed.I1, I2: parsed.I2, I3: parsed.I3, I4: parsed.I4, I5: parsed.I5,
|
||||
HeaderProtectionKey: parsed.HeaderProtectionKey,
|
||||
ContentPaddingAddition: parsed.ContentPaddingAddition,
|
||||
RekeyAfterTime: parsed.RekeyAfterTime,
|
||||
RekeyTimeout: parsed.RekeyTimeout,
|
||||
RejectAfterTime: parsed.RejectAfterTime,
|
||||
KeepaliveTimeout: parsed.KeepaliveTimeout,
|
||||
MaxHandshakeAttempts: parsed.MaxHandshakeAttempts,
|
||||
RandomTrailers: parsed.RandomTrailers,
|
||||
DisableCookies: parsed.DisableCookies,
|
||||
},
|
||||
}
|
||||
for _, p := range parsed.Peers {
|
||||
if p.PublicKey == "" || len(p.AllowedIPs) == 0 || p.Endpoint == "" {
|
||||
continue
|
||||
}
|
||||
peer := OutboundPeer(p)
|
||||
peer.AllowedIPs = peer.AllowedIPs[:0:0]
|
||||
for _, a := range p.AllowedIPs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(a))
|
||||
if err != nil {
|
||||
return OutboundInstance{}, false
|
||||
}
|
||||
peer.AllowedIPs = append(peer.AllowedIPs, prefix.String())
|
||||
}
|
||||
inst.Peers = append(inst.Peers, peer)
|
||||
}
|
||||
if len(inst.Address) == 0 || len(inst.Peers) == 0 {
|
||||
return OutboundInstance{}, false
|
||||
}
|
||||
return inst, true
|
||||
}
|
||||
|
||||
// validateEndpoint accepts "host:port" with a numeric port and no control
|
||||
// characters; hostnames resolve at IpcSet time via resolvingBind.
|
||||
func validateEndpoint(ep string) error {
|
||||
if ep == "" {
|
||||
return fmt.Errorf("endpoint is required")
|
||||
}
|
||||
if err := ValidateConfigValue("endpoint", ep); err != nil {
|
||||
return err
|
||||
}
|
||||
host, portS, err := net.SplitHostPort(ep)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid endpoint %q: must be host:port", ep)
|
||||
}
|
||||
port, err := strconv.Atoi(portS)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return fmt.Errorf("invalid endpoint %q: bad port", ep)
|
||||
}
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return fmt.Errorf("invalid endpoint %q: empty host", ep)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTunnelAddresses requires every entry to be a parseable IP prefix
|
||||
// (the outbound's own tunnel address(es), e.g. "10.8.1.2/32").
|
||||
func validateTunnelAddresses(addrs []string) error {
|
||||
if len(addrs) == 0 {
|
||||
return fmt.Errorf("at least one tunnel address is required")
|
||||
}
|
||||
for _, a := range addrs {
|
||||
prefix, err := netip.ParsePrefix(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid tunnel address %q: %w", a, err)
|
||||
}
|
||||
_ = prefix
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeDNSServer converts a bare IP or IP:port into a standard host:port.
|
||||
func NormalizeDNSServer(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if addr, err := netip.ParseAddr(s); err == nil {
|
||||
return netip.AddrPortFrom(addr, 53).String()
|
||||
}
|
||||
if ap, err := netip.ParseAddrPort(s); err == nil {
|
||||
return ap.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ValidateDNSServer checks that dns is empty or a valid IP or IP:port.
|
||||
func ValidateDNSServer(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if err := ValidateConfigValue("dns", s); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := netip.ParseAddr(s); err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := netip.ParseAddrPort(s); err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("must be an IP address or IP:port")
|
||||
}
|
||||
|
||||
// ValidateAmneziaWGOutbound rejects settings that could break the embedded
|
||||
// device's UAPI apply or smuggle control characters downstream.
|
||||
func ValidateAmneziaWGOutbound(tag string, raw []byte) error {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return fmt.Errorf("amneziawg outbound: tag must be a non-empty string")
|
||||
}
|
||||
settingsRaw, ok := outboundSettingsOf(raw)
|
||||
if !ok {
|
||||
return fmt.Errorf("amneziawg outbound %q: missing settings block", tag)
|
||||
}
|
||||
var parsed OutboundSettings
|
||||
if err := json.Unmarshal(settingsRaw, &parsed); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: invalid settings: %w", tag, err)
|
||||
}
|
||||
if err := validateTunnelAddresses(parsed.Address); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
||||
}
|
||||
if err := ValidateDNSServer(parsed.DNS); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: invalid dns: %w", tag, err)
|
||||
}
|
||||
if strings.TrimSpace(parsed.SecretKey) == "" {
|
||||
return fmt.Errorf("amneziawg outbound %q: privateKey is required", tag)
|
||||
}
|
||||
if _, err := wireguard.KeyToHex(parsed.SecretKey); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: invalid privateKey: %w", tag, err)
|
||||
}
|
||||
if err := ValidateObfuscation(parsed.Obfuscation()); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
||||
}
|
||||
for n, iv := range map[string]string{
|
||||
"i1": parsed.I1, "i2": parsed.I2, "i3": parsed.I3, "i4": parsed.I4, "i5": parsed.I5,
|
||||
} {
|
||||
if err := ValidateConfigValue(n, iv); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
||||
}
|
||||
}
|
||||
if err := validateHeaderProtectionKey(parsed.HeaderProtectionKey); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: %w", tag, err)
|
||||
}
|
||||
if len(parsed.Peers) == 0 {
|
||||
return fmt.Errorf("amneziawg outbound %q: at least one peer is required", tag)
|
||||
}
|
||||
for i, p := range parsed.Peers {
|
||||
if strings.TrimSpace(p.PublicKey) == "" {
|
||||
return fmt.Errorf("amneziawg outbound %q: peer %d: publicKey is required", tag, i)
|
||||
}
|
||||
if _, err := wireguard.KeyToHex(p.PublicKey); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid publicKey: %w", tag, i, err)
|
||||
}
|
||||
if p.PresharedKey != "" {
|
||||
if _, err := wireguard.KeyToHex(p.PresharedKey); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid presharedKey: %w", tag, i, err)
|
||||
}
|
||||
}
|
||||
if err := validateEndpoint(p.Endpoint); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: peer %d: %w", tag, i, err)
|
||||
}
|
||||
if len(p.AllowedIPs) == 0 {
|
||||
return fmt.Errorf("amneziawg outbound %q: peer %d: at least one allowedIPs entry is required", tag, i)
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
if _, err := netip.ParsePrefix(a); err != nil {
|
||||
return fmt.Errorf("amneziawg outbound %q: peer %d: invalid allowedIP %q: %w", tag, i, a, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package amneziawg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// validOutboundJSON is a fully valid amneziawg outbound settings payload.
|
||||
func validOutboundJSON(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw := map[string]any{
|
||||
"mtu": 1420,
|
||||
"secretKey": validPrivKey(t),
|
||||
"address": []string{"10.8.0.2/32"},
|
||||
"jc": 4,
|
||||
"jmin": 40,
|
||||
"jmax": 100,
|
||||
"s1": 15,
|
||||
"s2": 80,
|
||||
"s3": 12,
|
||||
"s4": 12,
|
||||
"h1": "100-800",
|
||||
"h2": "900-1600",
|
||||
"h3": "1700-2400",
|
||||
"h4": "2500-3200",
|
||||
"peers": []map[string]any{{
|
||||
"publicKey": validPubKey(t),
|
||||
"allowedIPs": []string{"0.0.0.0/0", "::/0"},
|
||||
"endpoint": "203.0.113.7:51820",
|
||||
"keepAlive": 25,
|
||||
}},
|
||||
}
|
||||
bs, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bs
|
||||
}
|
||||
|
||||
func validPubKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, pub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub
|
||||
}
|
||||
|
||||
func validPrivKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
priv, _, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return priv
|
||||
}
|
||||
|
||||
func TestInstanceFromOutbound_OK(t *testing.T) {
|
||||
wrapped, err := json.Marshal(map[string]any{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "awg-out-test",
|
||||
"settings": json.RawMessage(validOutboundJSON(t)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inst, ok := InstanceFromOutbound("awg-out-test", wrapped)
|
||||
if !ok {
|
||||
t.Fatal("InstanceFromOutbound returned false for a valid outbound")
|
||||
}
|
||||
if inst.Tag != "awg-out-test" {
|
||||
t.Fatalf("Tag = %q, want awg-out-test", inst.Tag)
|
||||
}
|
||||
if len(inst.Peers) != 1 {
|
||||
t.Fatalf("len(Peers) = %d, want 1", len(inst.Peers))
|
||||
}
|
||||
p := inst.Peers[0]
|
||||
if p.Endpoint != "203.0.113.7:51820" {
|
||||
t.Fatalf("Endpoint = %q", p.Endpoint)
|
||||
}
|
||||
if p.KeepAlive != 25 {
|
||||
t.Fatalf("KeepAlive = %d, want 25", p.KeepAlive)
|
||||
}
|
||||
if len(p.AllowedIPs) != 2 {
|
||||
t.Fatalf("AllowedIPs = %v", p.AllowedIPs)
|
||||
}
|
||||
if inst.MTU != 1420 {
|
||||
t.Fatalf("MTU = %d, want 1420", inst.MTU)
|
||||
}
|
||||
if inst.Obfuscation.Jc != 4 || inst.Obfuscation.S1 != 15 {
|
||||
t.Fatalf("Obfuscation not carried: %+v", inst.Obfuscation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromOutbound_RejectsIncompletePeer(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
m["address"] = []any{}
|
||||
bs, _ := json.Marshal(m)
|
||||
wrapped, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs)})
|
||||
if _, ok := InstanceFromOutbound("t", wrapped); ok {
|
||||
t.Fatal("expected false when address list is empty")
|
||||
}
|
||||
|
||||
m2 := validOutboundMapT(t)
|
||||
m2["peers"].([]any)[0].(map[string]any)["endpoint"] = ""
|
||||
bs2, _ := json.Marshal(m2)
|
||||
wrapped2, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs2)})
|
||||
// The only peer is incomplete -> skipped -> zero usable peers -> false,
|
||||
// mirroring InstanceFromInbound's "nothing to serve" contract.
|
||||
if _, ok := InstanceFromOutbound("t", wrapped2); ok {
|
||||
t.Fatal("outbound whose only peer lacks an endpoint must be unusable")
|
||||
}
|
||||
|
||||
// With a second, complete peer the instance stays usable and only the
|
||||
// broken entry disappears.
|
||||
m3 := validOutboundMapT(t)
|
||||
brokenPeer := validOutboundMapT(t)["peers"].([]any)[0].(map[string]any)
|
||||
brokenPeer["endpoint"] = ""
|
||||
m3["peers"] = []any{brokenPeer, validOutboundMapT(t)["peers"].([]any)[0]}
|
||||
bs3, _ := json.Marshal(m3)
|
||||
wrapped3, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs3)})
|
||||
inst, ok := InstanceFromOutbound("t", wrapped3)
|
||||
if !ok {
|
||||
t.Fatal("one good peer should keep the outbound usable")
|
||||
}
|
||||
if len(inst.Peers) != 1 {
|
||||
t.Fatalf("broken peer must be dropped; got %d peers", len(inst.Peers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAmneziaWGOutbound_AcceptsValidAndRejectsBroken(t *testing.T) {
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(validOutboundJSON(t))); err != nil {
|
||||
t.Fatalf("valid outbound rejected: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
breakF func(m map[string]any)
|
||||
}{
|
||||
{"empty secretKey", func(m map[string]any) { m["secretKey"] = "" }},
|
||||
{"empty peer publicKey", func(m map[string]any) { peer(m)["publicKey"] = "" }},
|
||||
{"whitespace secretKey", func(m map[string]any) { m["secretKey"] = " " }},
|
||||
{"whitespace peer publicKey", func(m map[string]any) { peer(m)["publicKey"] = " " }},
|
||||
{"bad endpoint no port", func(m map[string]any) { peer(m)["endpoint"] = "203.0.113.7" }},
|
||||
{"endpoint control char", func(m map[string]any) { peer(m)["endpoint"] = "host:51820\nPostUp=x" }},
|
||||
{"empty allowedIPs", func(m map[string]any) { peer(m)["allowedIPs"] = []string{} }},
|
||||
{"no peers", func(m map[string]any) { m["peers"] = []any{} }},
|
||||
{"bad allowedIP", func(m map[string]any) { peer(m)["allowedIPs"] = []string{"not-a-prefix"} }},
|
||||
{"no address", func(m map[string]any) { m["address"] = []any{} }},
|
||||
{"bad jc/jmin order", func(m map[string]any) { m["jmin"] = 200; m["jmax"] = 100 }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
tc.breakF(m)
|
||||
bs, _ := json.Marshal(m)
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
|
||||
t.Fatalf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// wrapOutboundSettings embeds a settings payload the way the template stores
|
||||
// it: as the nested "settings" of an amneziawg outbound row.
|
||||
func wrapOutboundSettings(settings json.RawMessage) []byte {
|
||||
bs, err := json.Marshal(map[string]any{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "t",
|
||||
"settings": settings,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bs
|
||||
}
|
||||
|
||||
func peer(m map[string]any) map[string]any {
|
||||
return m["peers"].([]any)[0].(map[string]any)
|
||||
}
|
||||
|
||||
func validOutboundMapT(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(validOutboundJSON(t), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestIsAmneziaWGOutbound(t *testing.T) {
|
||||
yes := []byte(`{"protocol":"amneziawg","tag":"x"}`)
|
||||
if !IsAmneziaWGOutbound(yes) {
|
||||
t.Fatal("amneziawg protocol not detected")
|
||||
}
|
||||
no := []byte(`{"protocol":"freedom","tag":"x"}`)
|
||||
if IsAmneziaWGOutbound(no) {
|
||||
t.Fatal("freedom misdetected as amneziawg")
|
||||
}
|
||||
if IsAmneziaWGOutbound([]byte(`{broken`)) {
|
||||
t.Fatal("garbage misdetected as amneziawg")
|
||||
}
|
||||
}
|
||||
|
||||
// A blank line terminates IpcSetOperation, silently truncating the peer set;
|
||||
// validation rejects a trailing newline, parsing normalizes it away.
|
||||
func TestValidateAmneziaWGOutbound_RejectsAllowedIPWithNewline(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
peer(m)["allowedIPs"] = []string{"0.0.0.0/0\n", "::/0"}
|
||||
bs, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
|
||||
t.Fatal("allowedIP with trailing newline: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromOutbound_NormalizesAllowedIPs(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
peer(m)["allowedIPs"] = []string{" 0.0.0.0/0\n", "::/0"}
|
||||
bs, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inst, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs))
|
||||
if !ok {
|
||||
t.Fatal("InstanceFromOutbound returned false for trimmable allowedIPs")
|
||||
}
|
||||
got := inst.Peers[0].AllowedIPs
|
||||
want := []string{"0.0.0.0/0", "::/0"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("AllowedIPs = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("AllowedIPs[%d] = %q, want %q (newline must not survive)", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceFromOutbound_RejectsUnparseableAllowedIP(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
peer(m)["allowedIPs"] = []string{"not-a-prefix"}
|
||||
bs, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs)); ok {
|
||||
t.Fatal("unparseable allowedIP must make InstanceFromOutbound return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAmneziaWGOutbound_RejectsControlCharInIParams(t *testing.T) {
|
||||
for _, field := range []string{"i1", "i2", "i3", "i4", "i5"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
m := validOutboundMapT(t)
|
||||
m[field] = "<r 64>\nPostUp=x"
|
||||
bs, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
|
||||
t.Fatalf("%s with embedded newline: expected error, got nil", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAmneziaWGOutbound_RejectsEmptyTag(t *testing.T) {
|
||||
raw := []byte(`{"protocol":"amneziawg","tag":"","settings":{"secretKey":"x"}}`)
|
||||
for _, tag := range []string{"", " "} {
|
||||
if err := ValidateAmneziaWGOutbound(tag, raw); err == nil {
|
||||
t.Fatalf("tag %q accepted", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAmneziaWGOutbound_DNSField(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"": "",
|
||||
"1.1.1.1": "1.1.1.1:53",
|
||||
"8.8.8.8:53": "8.8.8.8:53",
|
||||
"2606:4700:4700::1111": "[2606:4700:4700::1111]:53",
|
||||
"[2606:4700:4700::1111]:53": "[2606:4700:4700::1111]:53",
|
||||
}
|
||||
for d, expected := range valid {
|
||||
m := validOutboundMapT(t)
|
||||
if d != "" {
|
||||
m["dns"] = d
|
||||
}
|
||||
bs, _ := json.Marshal(m)
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil {
|
||||
t.Fatalf("valid dns %q rejected: %v", d, err)
|
||||
}
|
||||
inst, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs))
|
||||
if !ok || inst.DNS != expected {
|
||||
t.Fatalf("InstanceFromOutbound dns=%q, want %q", inst.DNS, expected)
|
||||
}
|
||||
}
|
||||
invalid := []string{"not-an-ip", "1.1.1.1\nPostUp=x", "999.999.999.999"}
|
||||
for _, d := range invalid {
|
||||
m := validOutboundMapT(t)
|
||||
m["dns"] = d
|
||||
bs, _ := json.Marshal(m)
|
||||
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
|
||||
t.Fatalf("invalid dns %q accepted", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// buildClientUAPIConfig renders a client-mode UAPI set string: the device
|
||||
// lines of buildUAPIConfig plus per-peer endpoint/keepalive for dialing.
|
||||
func buildClientUAPIConfig(inst amneziawg.OutboundInstance, opts DeviceOptions) (string, error) {
|
||||
var b strings.Builder
|
||||
|
||||
privHex, err := wireguard.KeyToHex(inst.PrivateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
fmt.Fprintf(&b, "private_key=%s\n", privHex)
|
||||
if inst.ListenPort > 0 {
|
||||
fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
|
||||
}
|
||||
b.WriteString("replace_peers=true\n")
|
||||
|
||||
o := inst.Obfuscation
|
||||
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
|
||||
fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
|
||||
writeOptionalLine(&b, "h1", o.H1)
|
||||
writeOptionalLine(&b, "h2", o.H2)
|
||||
writeOptionalLine(&b, "h3", o.H3)
|
||||
writeOptionalLine(&b, "h4", o.H4)
|
||||
writeOptionalLine(&b, "i1", o.I1)
|
||||
writeOptionalLine(&b, "i2", o.I2)
|
||||
writeOptionalLine(&b, "i3", o.I3)
|
||||
writeOptionalLine(&b, "i4", o.I4)
|
||||
writeOptionalLine(&b, "i5", o.I5)
|
||||
|
||||
// An omitted line means "unchanged" to amneziawg-go, so a cleared key can
|
||||
// only reach a live device as the all-zero one that disables the feature.
|
||||
hpHex := strings.Repeat("0", 64)
|
||||
if opts.HeaderProtectionKey != "" {
|
||||
var err error
|
||||
hpHex, err = wireguard.KeyToHex(opts.HeaderProtectionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid header protection key: %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
|
||||
if opts.ContentPaddingAddition != "" {
|
||||
fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
|
||||
}
|
||||
if opts.RekeyAfterTime != "" {
|
||||
fmt.Fprintf(&b, "rekey_after_time=%s\n", opts.RekeyAfterTime)
|
||||
}
|
||||
if opts.RekeyTimeout != "" {
|
||||
fmt.Fprintf(&b, "rekey_timeout=%s\n", opts.RekeyTimeout)
|
||||
}
|
||||
if opts.RejectAfterTime != "" {
|
||||
fmt.Fprintf(&b, "reject_after_time=%s\n", opts.RejectAfterTime)
|
||||
}
|
||||
if opts.KeepaliveTimeout != "" {
|
||||
fmt.Fprintf(&b, "keepalive_timeout=%s\n", opts.KeepaliveTimeout)
|
||||
}
|
||||
if opts.MaxHandshakeAttempts != "" {
|
||||
fmt.Fprintf(&b, "max_handshake_attempts=%s\n", opts.MaxHandshakeAttempts)
|
||||
}
|
||||
fmt.Fprintf(&b, "random_trailers=%t\n", opts.RandomTrailers)
|
||||
fmt.Fprintf(&b, "disable_cookies=%t\n", opts.DisableCookies)
|
||||
|
||||
for _, p := range inst.Peers {
|
||||
pubHex, err := wireguard.KeyToHex(p.PublicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("peer %q: invalid public key: %w", p.Endpoint, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "public_key=%s\n", pubHex)
|
||||
if p.PresharedKey != "" {
|
||||
pskHex, err := wireguard.KeyToHex(p.PresharedKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Endpoint, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
|
||||
}
|
||||
fmt.Fprintf(&b, "endpoint=%s\n", p.Endpoint)
|
||||
if p.KeepAlive > 0 {
|
||||
fmt.Fprintf(&b, "persistent_keepalive_interval=%d\n", p.KeepAlive)
|
||||
}
|
||||
for _, allowedIP := range p.AllowedIPs {
|
||||
fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// newUnconfiguredClientDevice builds the tun/netstack/device trio for a
|
||||
// client-mode instance; same construction rules as newUnconfiguredDevice.
|
||||
func newUnconfiguredClientDevice(inst amneziawg.OutboundInstance, opts DeviceOptions) (*Device, error) {
|
||||
addrs, err := hostAddresses(inst.Address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amneziawgnet: %w", err)
|
||||
}
|
||||
|
||||
mtu := amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4)
|
||||
|
||||
tun, gstack, err := createNetTUNWithStack(addrs, mtu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
|
||||
}
|
||||
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = device.NewLogger(device.LogLevelSilent, fmt.Sprintf("(awg-out %s) ", inst.Tag))
|
||||
}
|
||||
dev := device.NewDevice(tun, newResolvingBind(), logger)
|
||||
|
||||
return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
|
||||
}
|
||||
|
||||
// ConfigureClient applies inst/opts via UAPI and brings the interface up;
|
||||
// same single-call contract as Configure.
|
||||
func (d *Device) ConfigureClient(inst amneziawg.OutboundInstance, opts DeviceOptions) error {
|
||||
conf, err := buildClientUAPIConfig(inst, opts)
|
||||
if err != nil {
|
||||
d.Close()
|
||||
return fmt.Errorf("amneziawgnet: %w", err)
|
||||
}
|
||||
if err := d.IpcSet(conf); err != nil {
|
||||
d.Close()
|
||||
return fmt.Errorf("amneziawgnet: IpcSet for outbound %q: %w", inst.Tag, err)
|
||||
}
|
||||
if err := d.Up(); err != nil {
|
||||
d.Close()
|
||||
return fmt.Errorf("amneziawgnet: bring up outbound %q: %w", inst.Tag, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// clientDeviceTestInstance builds a minimal valid client instance with one
|
||||
// peer and a non-zero keepalive -- the exact shape the outbound form seeds.
|
||||
func clientDeviceTestInstance(t *testing.T) amneziawg.OutboundInstance {
|
||||
t.Helper()
|
||||
priv, pub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return amneziawg.OutboundInstance{
|
||||
Tag: "awg-out-test",
|
||||
Address: []string{"10.8.0.2/32"},
|
||||
MTU: 1420,
|
||||
PrivateKey: priv,
|
||||
Peers: []amneziawg.OutboundPeer{{
|
||||
PublicKey: pub,
|
||||
AllowedIPs: []string{"0.0.0.0/0", "::/0"},
|
||||
Endpoint: "203.0.113.7:51820",
|
||||
KeepAlive: 25,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientUAPIConfig_KeepAliveKeyIsValidUAPIPeerKey(t *testing.T) {
|
||||
inst := clientDeviceTestInstance(t)
|
||||
conf, err := buildClientUAPIConfig(inst, DeviceOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "persistent_keepalive_interval=25\n"
|
||||
if !strings.Contains(conf, want) {
|
||||
t.Fatalf("UAPI config missing %q:\n%s", want, conf)
|
||||
}
|
||||
if strings.Contains(conf, "persistent_keepalive_seconds") {
|
||||
t.Fatalf("UAPI config contains invalid peer key persistent_keepalive_seconds:\n%s", conf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientUAPIConfig_ZeroKeepAliveOmitsLine(t *testing.T) {
|
||||
inst := clientDeviceTestInstance(t)
|
||||
inst.Peers[0].KeepAlive = 0
|
||||
conf, err := buildClientUAPIConfig(inst, DeviceOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(conf, "persistent_keepalive") {
|
||||
t.Fatalf("zero KeepAlive must not emit a keepalive line:\n%s", conf)
|
||||
}
|
||||
}
|
||||
|
||||
// amneziawg-go reads an absent UAPI line as "keep the current value", and
|
||||
// ensureLocked reconfigures in place, so a cleared key must be sent as zero.
|
||||
func TestBuildClientUAPIConfig_ClearedHeaderProtectionKeyIsSentAsZero(t *testing.T) {
|
||||
inst := clientDeviceTestInstance(t)
|
||||
inst.Obfuscation = amneziawg.Obfuscation31{S1: 20, S2: 20, S3: 20, S4: 20}
|
||||
|
||||
key, err := wgutil.GenerateWireguardPSK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withKey, err := buildClientUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyHex, err := wgutil.KeyToHex(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(withKey, "header_protection_key="+keyHex+"\n") {
|
||||
t.Fatalf("a set key must be emitted verbatim, got:\n%s", withKey)
|
||||
}
|
||||
|
||||
cleared, err := buildClientUAPIConfig(inst, DeviceOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zero := "header_protection_key=" + strings.Repeat("0", 64) + "\n"
|
||||
if !strings.Contains(cleared, zero) {
|
||||
t.Fatalf("an unset key must be emitted as the all-zero key, got:\n%s", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// With no explicit MTU the netstack is built from S4, so an S4-only edit must
|
||||
// move the fingerprint or ensureLocked reconfigures in place and keeps the old.
|
||||
func TestOutboundFingerprintTracksTheS4DerivedMTU(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mtu int
|
||||
wantChange bool
|
||||
}{
|
||||
{"derived MTU", 0, true},
|
||||
{"explicit MTU", 1420, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inst := clientDeviceTestInstance(t)
|
||||
inst.MTU = tt.mtu
|
||||
inst.Obfuscation.S4 = 12
|
||||
before := outboundFingerprint(inst)
|
||||
inst.Obfuscation.S4 = 28
|
||||
after := outboundFingerprint(inst)
|
||||
if changed := before != after; changed != tt.wantChange {
|
||||
t.Fatalf("fingerprint changed = %v, want %v (%q -> %q)", changed, tt.wantChange, before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||
|
||||
@@ -69,8 +68,15 @@ type DeviceOptions struct {
|
||||
type Device struct {
|
||||
*device.Device
|
||||
Stack *stack.Stack
|
||||
|
||||
// localAddrs snapshots the netstack interface addresses (gVisor exposes
|
||||
// no read-back); set once at construction, read-only afterwards.
|
||||
localAddrs []netip.Addr
|
||||
}
|
||||
|
||||
// LocalAddresses returns the configured tunnel-local address(es).
|
||||
func (d *Device) LocalAddresses() []netip.Addr { return d.localAddrs }
|
||||
|
||||
// NewDevice constructs, configures, and brings up an embedded AmneziaWG
|
||||
// interface for inst in one call: a gVisor-backed tun.Device sized to
|
||||
// amneziawg.EffectiveMTU, addressed with inst.Address, configured via
|
||||
@@ -128,9 +134,9 @@ func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device
|
||||
if logger == nil {
|
||||
logger = device.NewLogger(device.LogLevelSilent, "")
|
||||
}
|
||||
dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
|
||||
dev := device.NewDevice(tun, newResolvingBind(), logger)
|
||||
|
||||
return &Device{Device: dev, Stack: gstack}, nil
|
||||
return &Device{Device: dev, Stack: gstack, localAddrs: addrs}, nil
|
||||
}
|
||||
|
||||
// Configure applies inst/opts to d via UAPI and brings the interface up.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// DefaultTunnelDNSServer resolves domain targets through outbound netstack.
|
||||
const (
|
||||
DefaultTunnelDNSServer = "1.1.1.1:53"
|
||||
DefaultTunnelDNSServerV6 = "[2606:4700:4700::1111]:53"
|
||||
)
|
||||
|
||||
func deviceHasV4(addrs []netip.Addr) bool {
|
||||
for _, a := range addrs {
|
||||
if a.Is4() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deviceHasV6(addrs []netip.Addr) bool {
|
||||
for _, a := range addrs {
|
||||
if a.Is6() && !a.Is4In6() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultDNSFor picks a resolver matching the tunnel address family:
|
||||
// IPv4 default (or empty), or IPv6 default when IPv6-only.
|
||||
func defaultDNSFor(addrs []netip.Addr) string {
|
||||
if deviceHasV4(addrs) || len(addrs) == 0 {
|
||||
return DefaultTunnelDNSServer
|
||||
}
|
||||
return DefaultTunnelDNSServerV6
|
||||
}
|
||||
|
||||
const (
|
||||
// tunnelResolveTimeout bounds one lookup inside a live connection handler.
|
||||
tunnelResolveTimeout = 4 * time.Second
|
||||
tunnelDNSPacketTimeout = 1200 * time.Millisecond
|
||||
tunnelDNSAttempts = 3
|
||||
)
|
||||
|
||||
type tunnelDNSCacheEntry struct {
|
||||
addr netip.Addr
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
var tunnelDNSCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]tunnelDNSCacheEntry
|
||||
}{m: map[string]tunnelDNSCacheEntry{}}
|
||||
|
||||
const (
|
||||
tunnelDNSCacheTTL = 60 * time.Second
|
||||
tunnelDNSCacheMaxSize = 1024
|
||||
)
|
||||
|
||||
// dnsCacheKey computes cache key scoped by outbound tag, server, and host.
|
||||
func dnsCacheKey(tag, dnsServer, host string) string {
|
||||
return tag + "|" + dnsServer + "|" + host
|
||||
}
|
||||
|
||||
func resolveTunnelVia(ctx context.Context, dev *Device, tag string, dnsServer string, host string) (netip.Addr, error) {
|
||||
normDNS := normalizeDNSServer(dnsServer)
|
||||
if normDNS == "" {
|
||||
normDNS = defaultDNSFor(dev.LocalAddresses())
|
||||
}
|
||||
key := dnsCacheKey(tag, normDNS, host)
|
||||
now := time.Now()
|
||||
tunnelDNSCache.mu.Lock()
|
||||
if e, ok := tunnelDNSCache.m[key]; ok && now.Before(e.exp) {
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
return e.addr, nil
|
||||
}
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
|
||||
server, err := netip.ParseAddrPort(normDNS)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("bad tunnel DNS server %q: %w", normDNS, err)
|
||||
}
|
||||
raddr := tcpip.FullAddress{
|
||||
NIC: 1,
|
||||
Addr: tcpip.AddrFromSlice(server.Addr().AsSlice()),
|
||||
Port: server.Port(),
|
||||
}
|
||||
conn, derr := gonet.DialUDP(dev.Stack, nil, &raddr, tunnelNetwork(server.Addr()))
|
||||
if derr != nil {
|
||||
logger.Warningf("amneziawgnet: resolveTunnel tag=%q host=%q server=%s localAddrs=%v err=%v", tag, host, server, dev.LocalAddresses(), derr)
|
||||
return netip.Addr{}, fmt.Errorf("dns dial %s: %w", server, derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
addr, rerr := exchangeTunnelDNSWithFallback(ctx, conn, dev.LocalAddresses(), host)
|
||||
if rerr != nil {
|
||||
return netip.Addr{}, rerr
|
||||
}
|
||||
|
||||
tunnelDNSCache.mu.Lock()
|
||||
if len(tunnelDNSCache.m) >= tunnelDNSCacheMaxSize {
|
||||
tunnelDNSCache.m = map[string]tunnelDNSCacheEntry{}
|
||||
}
|
||||
tunnelDNSCache.m[key] = tunnelDNSCacheEntry{addr: addr, exp: now.Add(tunnelDNSCacheTTL)}
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
logger.Debugf("amneziawgnet: resolved tag=%q %q -> %s via tunnel", tag, host, addr)
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// flushTunnelDNSCacheForTag purges all cached DNS entries for an outbound tag.
|
||||
func flushTunnelDNSCacheForTag(tag string) {
|
||||
tunnelDNSCache.mu.Lock()
|
||||
defer tunnelDNSCache.mu.Unlock()
|
||||
prefix := tag + "|"
|
||||
for k := range tunnelDNSCache.m {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(tunnelDNSCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeTunnelDNSWithFallback queries A and/or AAAA depending on the local
|
||||
// address families configured on the device stack.
|
||||
func exchangeTunnelDNSWithFallback(ctx context.Context, conn *gonet.UDPConn, addrs []netip.Addr, host string) (netip.Addr, error) {
|
||||
hasV4 := deviceHasV4(addrs)
|
||||
hasV6 := deviceHasV6(addrs)
|
||||
|
||||
// If the tunnel is IPv6-only, query AAAA first; else query A first.
|
||||
types := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
|
||||
if hasV6 && !hasV4 {
|
||||
types = []dnsmessage.Type{dnsmessage.TypeAAAA, dnsmessage.TypeA}
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, qType := range types {
|
||||
// Skip AAAA if device has no IPv6 capability and has IPv4, unless A failed.
|
||||
addr, err := exchangeTunnelDNSQuery(ctx, conn, host, qType)
|
||||
if err == nil {
|
||||
return addr, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, firstErr
|
||||
}
|
||||
|
||||
func exchangeTunnelDNSQuery(ctx context.Context, conn *gonet.UDPConn, host string, qType dnsmessage.Type) (netip.Addr, error) {
|
||||
name, err := dnsmessage.NewName(host + ".")
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("dns name %q: %w", host, err)
|
||||
}
|
||||
id := uint16(rand.Intn(1 << 16))
|
||||
query := dnsmessage.Message{
|
||||
Header: dnsmessage.Header{ID: id, RecursionDesired: true},
|
||||
Questions: []dnsmessage.Question{{
|
||||
Name: name,
|
||||
Type: qType,
|
||||
Class: dnsmessage.ClassINET,
|
||||
}},
|
||||
}
|
||||
wire, err := query.Pack()
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("dns pack %q: %w", host, err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 512)
|
||||
for attempt := 0; attempt < tunnelDNSAttempts; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return netip.Addr{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if _, werr := conn.Write(wire); werr != nil {
|
||||
return netip.Addr{}, fmt.Errorf("dns send %q: %w", host, werr)
|
||||
}
|
||||
if derr := conn.SetReadDeadline(time.Now().Add(tunnelDNSPacketTimeout)); derr != nil {
|
||||
return netip.Addr{}, fmt.Errorf("dns deadline %q: %w", host, derr)
|
||||
}
|
||||
for {
|
||||
n, rerr := conn.Read(buf)
|
||||
if rerr != nil {
|
||||
break // per-attempt timeout -> next attempt
|
||||
}
|
||||
var resp dnsmessage.Message
|
||||
if uerr := resp.Unpack(buf[:n]); uerr != nil || resp.ID != id {
|
||||
continue
|
||||
}
|
||||
for _, ans := range resp.Answers {
|
||||
if a, ok := ans.Body.(*dnsmessage.AResource); ok && qType == dnsmessage.TypeA {
|
||||
return netip.AddrFrom4(a.A), nil
|
||||
}
|
||||
if aaaa, ok := ans.Body.(*dnsmessage.AAAAResource); ok && qType == dnsmessage.TypeAAAA {
|
||||
return netip.AddrFrom16(aaaa.AAAA), nil
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, fmt.Errorf("dns %q (type %v): rcode=%d answers=%d", host, qType, resp.RCode, len(resp.Answers))
|
||||
}
|
||||
}
|
||||
return netip.Addr{}, fmt.Errorf("dns lookup %q (type %v): no answer after %d attempts", host, qType, tunnelDNSAttempts)
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// EgressBasePort is the fixed loopback port of the panel's SOCKS5 egress
|
||||
// server; it appears in every generated amneziawg socks bridge.
|
||||
const EgressBasePort = 64900
|
||||
|
||||
// socks5EgressServer is a minimal loopback SOCKS5 server routing Xray's
|
||||
// bridged amneziawg outbounds into their embedded devices' netstacks.
|
||||
type socks5EgressServer struct {
|
||||
mu sync.Mutex
|
||||
stacks map[string]*Device // outbound tag -> its device
|
||||
dns map[string]string // outbound tag -> its DNS server
|
||||
tracked map[net.Conn]struct{}
|
||||
|
||||
// dnsServer resolves domain targets through the outbound netstack.
|
||||
dnsServer string
|
||||
|
||||
listener net.Listener // nil when stopped; acceptLoop takes it as an arg
|
||||
closing chan struct{} // per-listener lifetime signal, rearmed by Listen
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
var (
|
||||
egressOnce sync.Once
|
||||
egressServer *socks5EgressServer
|
||||
)
|
||||
|
||||
// GetEgressServer returns the process-wide SOCKS5 egress server singleton.
|
||||
func GetEgressServer() *socks5EgressServer {
|
||||
egressOnce.Do(func() {
|
||||
egressServer = &socks5EgressServer{
|
||||
stacks: map[string]*Device{},
|
||||
dns: map[string]string{},
|
||||
tracked: map[net.Conn]struct{}{},
|
||||
}
|
||||
})
|
||||
return egressServer
|
||||
}
|
||||
|
||||
// currentDNSServer reads dnsServer under lock -- custom per-tag DNS preferred.
|
||||
func (s *socks5EgressServer) currentDNSServer(tag ...string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(tag) > 0 && tag[0] != "" {
|
||||
if custom, ok := s.dns[tag[0]]; ok && custom != "" {
|
||||
return custom
|
||||
}
|
||||
}
|
||||
return s.dnsServer
|
||||
}
|
||||
|
||||
// SetDNSServer overrides the domain-target resolver (tests).
|
||||
func (s *socks5EgressServer) SetDNSServer(addr string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.dnsServer = addr
|
||||
}
|
||||
|
||||
// SetStack registers or replaces the device backing an outbound tag.
|
||||
func (s *socks5EgressServer) SetStack(tag string, dev *Device, dnsServer ...string) {
|
||||
norm := ""
|
||||
if len(dnsServer) > 0 && dnsServer[0] != "" {
|
||||
norm = normalizeDNSServer(dnsServer[0])
|
||||
}
|
||||
s.mu.Lock()
|
||||
prevDev := s.stacks[tag]
|
||||
prevDNS := s.dns[tag]
|
||||
s.stacks[tag] = dev
|
||||
if norm != "" {
|
||||
s.dns[tag] = norm
|
||||
} else {
|
||||
delete(s.dns, tag)
|
||||
}
|
||||
changed := prevDev != dev || prevDNS != norm
|
||||
s.mu.Unlock()
|
||||
if changed {
|
||||
flushTunnelDNSCacheForTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteStack drops an outbound tag's registration (outbound removed).
|
||||
func (s *socks5EgressServer) DeleteStack(tag string) {
|
||||
s.mu.Lock()
|
||||
delete(s.stacks, tag)
|
||||
delete(s.dns, tag)
|
||||
s.mu.Unlock()
|
||||
flushTunnelDNSCacheForTag(tag)
|
||||
}
|
||||
|
||||
// Listen starts accepting on the loopback listener. Idempotent; a bind
|
||||
// failure is returned and retried by the caller's reconcile tick.
|
||||
func (s *socks5EgressServer) Listen() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.listener != nil {
|
||||
return nil
|
||||
}
|
||||
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", EgressBasePort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("amneziawgnet: egress listen: %w", err)
|
||||
}
|
||||
s.listener = ln
|
||||
s.closing = make(chan struct{})
|
||||
logger.Infof("amneziawgnet: egress socks listening on %s", ln.Addr())
|
||||
s.wg.Add(1)
|
||||
go s.acceptLoop(ln, s.closing)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops the listener and in-flight handlers; signal first so an accept
|
||||
// error always observes closing.
|
||||
func (s *socks5EgressServer) Close() {
|
||||
s.mu.Lock()
|
||||
ln := s.listener
|
||||
s.listener = nil
|
||||
if ln == nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
close(s.closing)
|
||||
tracked := s.tracked
|
||||
s.tracked = map[net.Conn]struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
ln.Close()
|
||||
for conn := range tracked {
|
||||
conn.Close()
|
||||
}
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *socks5EgressServer) acceptLoop(ln net.Listener, closing chan struct{}) {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-closing:
|
||||
return
|
||||
default:
|
||||
}
|
||||
logger.Warningf("amneziawgnet: egress accept: %v", err)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-closing:
|
||||
conn.Close()
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.listener == nil {
|
||||
s.mu.Unlock()
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
s.tracked[conn] = struct{}{}
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
go func(c net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.tracked, c)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
s.handleConn(c)
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// stackFor resolves a tag to its live device at use time, so rebuilds take
|
||||
// effect for new connections without touching the listener.
|
||||
func (s *socks5EgressServer) stackFor(tag string) (*Device, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dev, ok := s.stacks[tag]
|
||||
return dev, ok
|
||||
}
|
||||
|
||||
func (s *socks5EgressServer) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// Bound the pre-auth handshake so a silent client never pins a handler
|
||||
// indefinitely across Close() and wg.Wait().
|
||||
_ = conn.SetDeadline(time.Now().Add(portForwardDialTimeout))
|
||||
method, err := socks5Greeting(conn)
|
||||
if err != nil || method == 0xFF {
|
||||
return
|
||||
}
|
||||
user := ""
|
||||
if method == 0x02 {
|
||||
// RFC 1929 sub-negotiation: VER(1) | ULEN(1) | UNAME | PLEN(1) |
|
||||
// PASSWD -- the leading 0x01 version byte must be consumed first.
|
||||
var ver [1]byte
|
||||
if _, err := io.ReadFull(conn, ver[:]); err != nil {
|
||||
return
|
||||
}
|
||||
var ulen [1]byte
|
||||
if _, err := io.ReadFull(conn, ulen[:]); err != nil {
|
||||
return
|
||||
}
|
||||
uname := make([]byte, ulen[0])
|
||||
if _, err := io.ReadFull(conn, uname); err != nil {
|
||||
return
|
||||
}
|
||||
user = string(uname)
|
||||
var plen [1]byte
|
||||
if _, err := io.ReadFull(conn, plen[:]); err != nil {
|
||||
return
|
||||
}
|
||||
pass := make([]byte, plen[0])
|
||||
if _, err := io.ReadFull(conn, pass); err != nil {
|
||||
return
|
||||
}
|
||||
if !hmac.Equal(pass, []byte(SocksPassword())) {
|
||||
_, _ = conn.Write([]byte{0x01, 0x01})
|
||||
return
|
||||
}
|
||||
if _, err := conn.Write([]byte{0x01, 0x00}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var req [4]byte
|
||||
if _, err := io.ReadFull(conn, req[:]); err != nil {
|
||||
return
|
||||
}
|
||||
// Handshake complete: clear deadline for the relay phase.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
target, err := readSocksRequestTarget(conn, req[3])
|
||||
if err != nil {
|
||||
writeSocksReply(conn, 0x01, netip.AddrPort{})
|
||||
return
|
||||
}
|
||||
|
||||
switch req[1] {
|
||||
case 0x01: // CONNECT
|
||||
dev, ok := s.stackFor(user)
|
||||
if !ok {
|
||||
writeSocksReply(conn, 0x05, netip.AddrPort{})
|
||||
return
|
||||
}
|
||||
dest, err := target.resolveTunnelVia(s.currentDNSServer(user), user, dev)
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: resolve %s: %v", user, target, err)
|
||||
writeSocksReply(conn, 0x04, netip.AddrPort{})
|
||||
return
|
||||
}
|
||||
s.relayTCP(dev, user, conn, dest)
|
||||
case 0x03: // UDP ASSOCIATE
|
||||
dev, ok := s.stackFor(user)
|
||||
if !ok {
|
||||
writeSocksReply(conn, 0x05, netip.AddrPort{})
|
||||
return
|
||||
}
|
||||
s.relayUDP(dev, user, udpControl{conn: conn}, target)
|
||||
default:
|
||||
writeSocksReply(conn, 0x07, netip.AddrPort{})
|
||||
}
|
||||
}
|
||||
|
||||
// socks5Greeting requires RFC 1929 username/password auth (0x02).
|
||||
// Returns 0xFF when unauthenticated or unsupported.
|
||||
func socks5Greeting(conn net.Conn) (byte, error) {
|
||||
var hdr [2]byte
|
||||
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
|
||||
return 0xFF, err
|
||||
}
|
||||
methods := make([]byte, hdr[1])
|
||||
if _, err := io.ReadFull(conn, methods); err != nil {
|
||||
return 0xFF, err
|
||||
}
|
||||
hasUserPass := false
|
||||
for _, m := range methods {
|
||||
if m == 0x02 {
|
||||
hasUserPass = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUserPass {
|
||||
_, _ = conn.Write([]byte{0x05, 0xFF})
|
||||
return 0xFF, nil
|
||||
}
|
||||
if _, err := conn.Write([]byte{0x05, 0x02}); err != nil {
|
||||
return 0xFF, err
|
||||
}
|
||||
return 0x02, nil
|
||||
}
|
||||
|
||||
// socksTarget is a parsed SOCKS5 request address: an IP, or the raw hostname
|
||||
// for ATYP 0x03 (resolved through the outbound's tunnel, never host-side).
|
||||
type socksTarget struct {
|
||||
host string
|
||||
ip netip.Addr
|
||||
port uint16
|
||||
}
|
||||
|
||||
func readSocksRequestTarget(r io.Reader, atyp byte) (socksTarget, error) {
|
||||
var t socksTarget
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
var b [4]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.ip = netip.AddrFrom4(b)
|
||||
case 0x04:
|
||||
var b [16]byte
|
||||
if _, err := io.ReadFull(r, b[:]); err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.ip = netip.AddrFrom16(b)
|
||||
case 0x03:
|
||||
var l [1]byte
|
||||
if _, err := io.ReadFull(r, l[:]); err != nil {
|
||||
return t, err
|
||||
}
|
||||
name := make([]byte, l[0])
|
||||
if _, err := io.ReadFull(r, name); err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.host = string(name)
|
||||
default:
|
||||
return t, fmt.Errorf("unsupported SOCKS5 request address type %d", atyp)
|
||||
}
|
||||
var portBytes [2]byte
|
||||
if _, err := io.ReadFull(r, portBytes[:]); err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.port = binary.BigEndian.Uint16(portBytes[:])
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t socksTarget) String() string {
|
||||
if t.ip.IsValid() {
|
||||
return netip.AddrPortFrom(t.ip, t.port).String()
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", t.host, t.port)
|
||||
}
|
||||
|
||||
// Domain targets resolve via the tunnel; reply-side helper must not be used here.
|
||||
func (t socksTarget) resolveTunnelVia(dnsServer, tag string, dev *Device) (netip.AddrPort, error) {
|
||||
if t.ip.IsValid() {
|
||||
return netip.AddrPortFrom(t.ip, t.port), nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
|
||||
defer cancel()
|
||||
addr, err := resolveTunnelVia(ctx, dev, tag, dnsServer, t.host)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, err
|
||||
}
|
||||
return netip.AddrPortFrom(addr, t.port), nil
|
||||
}
|
||||
|
||||
// writeSocksReply emits a reply with an empty v4 bind address; Xray only
|
||||
// reads the code byte.
|
||||
func writeSocksReply(w io.Writer, code byte, _ netip.AddrPort) {
|
||||
out := []byte{0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0}
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
// relayTCP dials dest inside the tagged outbound's netstack and pipes both
|
||||
// directions until either side closes.
|
||||
func (s *socks5EgressServer) relayTCP(dev *Device, tag string, upstream net.Conn, dest netip.AddrPort) {
|
||||
fa := tcpip.FullAddress{
|
||||
NIC: 1,
|
||||
Addr: tcpip.AddrFromSlice(dest.Addr().AsSlice()),
|
||||
Port: dest.Port(),
|
||||
}
|
||||
// Bound dial with portForwardDialTimeout so unreachable peers do not
|
||||
// pin goroutines and netstack endpoints in s.tracked.
|
||||
dctx, dcancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
|
||||
defer dcancel()
|
||||
tunnelConn, err := gonet.DialContextTCP(dctx, dev.Stack, fa, tunnelNetwork(dest.Addr()))
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: dial tunnel %s: %v", tag, dest, err)
|
||||
writeSocksReply(upstream, 0x01, netip.AddrPort{})
|
||||
return
|
||||
}
|
||||
defer tunnelConn.Close()
|
||||
|
||||
if _, err := upstream.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(tunnelConn, upstream); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(upstream, tunnelConn); done <- struct{}{} }()
|
||||
<-done
|
||||
}
|
||||
|
||||
// udpControl is the control half of one UDP ASSOCIATE: the TCP connection
|
||||
// whose lifetime bounds the association (RFC 1928).
|
||||
type udpControl struct{ conn net.Conn }
|
||||
|
||||
// egressUDPSession is one UDP ASSOCIATE flow: host-facing socket plus a
|
||||
// connected tunnel endpoint whose source port makes replies answerable.
|
||||
type egressUDPSession struct {
|
||||
dst netip.AddrPort
|
||||
conn *gonet.UDPConn
|
||||
}
|
||||
|
||||
// relayUDP answers the associate request and relays datagrams to
|
||||
// per-destination tunnel endpoints until the control connection closes.
|
||||
func (s *socks5EgressServer) relayUDP(dev *Device, tag string, ctl udpControl, _ socksTarget) {
|
||||
udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: udp bind: %v", tag, err)
|
||||
writeSocksReply(ctl.conn, 0x01, netip.AddrPort{})
|
||||
return
|
||||
}
|
||||
defer udpConn.Close()
|
||||
|
||||
local := udpConn.LocalAddr().(*net.UDPAddr)
|
||||
ip4 := local.IP.To4()
|
||||
reply := []byte{
|
||||
0x05, 0x00, 0x00, 0x01, ip4[0], ip4[1], ip4[2], ip4[3],
|
||||
byte(local.Port >> 8), byte(local.Port),
|
||||
}
|
||||
if _, err := ctl.conn.Write(reply); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sessions := &udpEgressSessions{m: map[netip.AddrPort]*egressUDPSession{}}
|
||||
|
||||
// Reader: strip per-datagram SOCKS5 headers and forward into the tunnel;
|
||||
// only the associated client's address is accepted.
|
||||
go func() {
|
||||
var client netip.AddrPort
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
n, from, err := udpConn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if src, ok := udpAddrPort(from); ok {
|
||||
if client.IsValid() && src != client {
|
||||
continue // RFC 1928: only the associated client may send
|
||||
}
|
||||
client = src
|
||||
}
|
||||
data := buf[:n]
|
||||
if len(data) < 4 {
|
||||
continue
|
||||
}
|
||||
atyp := data[3]
|
||||
var dst netip.AddrPort
|
||||
var payloadOff int
|
||||
if atyp == 0x03 {
|
||||
name, port, hdrLen, perr := parseDatagramDomainHeader(data)
|
||||
if perr != nil {
|
||||
continue
|
||||
}
|
||||
// Resolve off reader loop so slow tunnel DNS lookups do not
|
||||
// stall other destinations on this association.
|
||||
go func(client netip.AddrPort, name string, port uint16, hdrLen int, datagram []byte) {
|
||||
dnsSrv := s.currentDNSServer(tag)
|
||||
rctx, rcancel := context.WithTimeout(context.Background(), tunnelResolveTimeout)
|
||||
daddr, rerr := resolveTunnelVia(rctx, dev, tag, dnsSrv, name)
|
||||
rcancel()
|
||||
if rerr != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: resolve udp %q (dns=%s): %v", tag, name, dnsSrv, rerr)
|
||||
return
|
||||
}
|
||||
s.deliverUDPDatagram(dev, tag, udpConn, client, sessions, netip.AddrPortFrom(daddr, port), datagram[hdrLen:])
|
||||
}(client, name, port, hdrLen, append([]byte(nil), data...))
|
||||
continue
|
||||
} else {
|
||||
hdrLen := 4 + addrLen(atyp) + 2
|
||||
if hdrLen <= 6 || len(data) < hdrLen {
|
||||
continue
|
||||
}
|
||||
d, derr := parseDatagramHeader(data[:hdrLen])
|
||||
if derr != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: udp header: %v", tag, derr)
|
||||
continue
|
||||
}
|
||||
dst = d
|
||||
payloadOff = hdrLen
|
||||
}
|
||||
s.deliverUDPDatagram(dev, tag, udpConn, client, sessions, dst, data[payloadOff:])
|
||||
}
|
||||
}()
|
||||
|
||||
// Control-conn close tears down relaying -- relay.go UDPRelay contract.
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
if _, err := ctl.conn.Read(buf); err != nil {
|
||||
sessions.closeAll()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// udpEgressSessions guards the association's session map: the reader
|
||||
// goroutine inserts while the control-conn teardown iterates.
|
||||
type udpEgressSessions struct {
|
||||
mu sync.Mutex
|
||||
m map[netip.AddrPort]*egressUDPSession
|
||||
}
|
||||
|
||||
func (s *udpEgressSessions) getOrDial(dev *Device, tag string, udpConn *net.UDPConn, client netip.AddrPort, dst netip.AddrPort) *egressUDPSession {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if sess, ok := s.m[dst]; ok {
|
||||
return sess
|
||||
}
|
||||
raddr := tcpip.FullAddress{
|
||||
NIC: 1,
|
||||
Addr: tcpip.AddrFromSlice(dst.Addr().AsSlice()),
|
||||
Port: dst.Port(),
|
||||
}
|
||||
conn, err := gonet.DialUDP(dev.Stack, nil, &raddr, tunnelNetwork(dst.Addr()))
|
||||
if err != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: dial udp %s: %v", tag, dst, err)
|
||||
return nil
|
||||
}
|
||||
sess := &egressUDPSession{dst: dst, conn: conn}
|
||||
s.m[dst] = sess
|
||||
go pumpUDPEgress(udpConn, client, sess, s)
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *udpEgressSessions) closeAll() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, sess := range s.m {
|
||||
sess.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// deliverUDPDatagram forwards one payload to dst through the tunnel endpoint.
|
||||
// Safe for concurrent use across resolver and direct-path goroutines.
|
||||
func (s *socks5EgressServer) deliverUDPDatagram(dev *Device, tag string, udpConn *net.UDPConn, client netip.AddrPort, sessions *udpEgressSessions, dst netip.AddrPort, payload []byte) {
|
||||
if !client.IsValid() {
|
||||
return // nothing to reply to yet
|
||||
}
|
||||
sess := sessions.getOrDial(dev, tag, udpConn, client, dst)
|
||||
if sess == nil {
|
||||
return
|
||||
}
|
||||
if _, werr := sess.conn.Write(payload); werr != nil {
|
||||
logger.Warningf("amneziawgnet: egress %q: send udp to %s: %v", tag, dst, werr)
|
||||
}
|
||||
}
|
||||
|
||||
// pumpUDPEgress reads replies from one connected tunnel endpoint and writes
|
||||
// them back to the associated client as SOCKS5 UDP datagrams.
|
||||
func pumpUDPEgress(udpConn *net.UDPConn, client netip.AddrPort, sess *egressUDPSession, sessions *udpEgressSessions) {
|
||||
defer func() {
|
||||
sessions.mu.Lock()
|
||||
delete(sessions.m, sess.dst)
|
||||
sessions.mu.Unlock()
|
||||
sess.conn.Close()
|
||||
}()
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
// Reap idle egress sessions to avoid holding them indefinitely.
|
||||
_ = sess.conn.SetReadDeadline(time.Now().Add(portForwardUDPIdleTimeout))
|
||||
n, err := sess.conn.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hdr := make([]byte, 0, 3+1+16+2+n)
|
||||
hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0)
|
||||
if sess.dst.Addr().Is4() {
|
||||
b := sess.dst.Addr().As4()
|
||||
hdr = append(hdr, 0x01)
|
||||
hdr = append(hdr, b[:]...)
|
||||
} else {
|
||||
b := sess.dst.Addr().As16()
|
||||
hdr = append(hdr, 0x04)
|
||||
hdr = append(hdr, b[:]...)
|
||||
}
|
||||
var portBytes [2]byte
|
||||
binary.BigEndian.PutUint16(portBytes[:], sess.dst.Port())
|
||||
hdr = append(hdr, portBytes[:]...)
|
||||
hdr = append(hdr, buf[:n]...)
|
||||
if _, err := udpConn.WriteTo(hdr, net.UDPAddrFromAddrPort(client)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseDatagramDomainHeader decodes a domain SOCKS5 UDP header (RSV RSV FRAG
|
||||
// 0x03 LEN NAME PORT) into name, port, and header length.
|
||||
func parseDatagramDomainHeader(data []byte) (name string, port uint16, hdrLen int, err error) {
|
||||
if len(data) < 5 {
|
||||
return "", 0, 0, fmt.Errorf("short domain header")
|
||||
}
|
||||
l := int(data[4])
|
||||
hdrLen = 4 + 1 + l + 2
|
||||
if l == 0 || len(data) < hdrLen {
|
||||
return "", 0, 0, fmt.Errorf("short domain payload")
|
||||
}
|
||||
name = string(data[5 : 5+l])
|
||||
port = binary.BigEndian.Uint16(data[5+l : 7+l])
|
||||
return name, port, hdrLen, nil
|
||||
}
|
||||
|
||||
// addrLen returns the wire length of a SOCKS5 address of the given ATYP.
|
||||
func addrLen(atyp byte) int {
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
return 4
|
||||
case 0x04:
|
||||
return 16
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// parseDatagramHeader decodes the destination from the front of a SOCKS5 UDP
|
||||
// datagram header block (RSV RSV FRAG ATYP ADDR PORT).
|
||||
func parseDatagramHeader(hdr []byte) (netip.AddrPort, error) {
|
||||
if len(hdr) < 4 {
|
||||
return netip.AddrPort{}, fmt.Errorf("short header")
|
||||
}
|
||||
atyp := hdr[3]
|
||||
body := hdr[4:]
|
||||
switch atyp {
|
||||
case 0x01:
|
||||
if len(body) < 6 {
|
||||
return netip.AddrPort{}, fmt.Errorf("short v4")
|
||||
}
|
||||
return netip.AddrPortFrom(netip.AddrFrom4([4]byte(body[:4])), binary.BigEndian.Uint16(body[4:6])), nil
|
||||
case 0x04:
|
||||
if len(body) < 18 {
|
||||
return netip.AddrPort{}, fmt.Errorf("short v6")
|
||||
}
|
||||
return netip.AddrPortFrom(netip.AddrFrom16([16]byte(body[:16])), binary.BigEndian.Uint16(body[16:18])), nil
|
||||
default:
|
||||
return netip.AddrPort{}, fmt.Errorf("unsupported atyp %d", atyp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gvisor.dev/gvisor/pkg/tcpip"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
|
||||
|
||||
"github.com/amnezia-vpn/amneziawg-go/v3/device"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
func verboseLoggerForTest(prefix string) *device.Logger {
|
||||
return device.NewLogger(device.LogLevelVerbose, prefix)
|
||||
}
|
||||
|
||||
const (
|
||||
tunnelTestClientAddr = "10.203.0.2"
|
||||
tunnelTestServerAddr = "10.203.0.1"
|
||||
tunnelTestClientAddrV6 = "fd00:203::2"
|
||||
tunnelTestServerAddrV6 = "fd00:203::1"
|
||||
egressTestDialTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// pairedTunnel wires an outbound client device to an embedded server device
|
||||
// over host UDP; the server stack hosts the far-end services under test.
|
||||
type pairedTunnel struct {
|
||||
client *Device
|
||||
server *Device
|
||||
serverIP netip.Addr
|
||||
}
|
||||
|
||||
func newPairedTunnelForTest(t *testing.T) *pairedTunnel {
|
||||
t.Helper()
|
||||
slog := verboseLoggerForTest("(tsrv) ")
|
||||
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listenPort := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
pc.Close()
|
||||
|
||||
obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
|
||||
serverInst := amneziawg.Instance{
|
||||
Id: 1,
|
||||
InterfaceName: "awg-dnstest",
|
||||
ListenPort: listenPort,
|
||||
PrivateKey: serverPriv,
|
||||
PublicKey: serverPub,
|
||||
Address: []string{tunnelTestServerAddr + "/24"},
|
||||
MTU: 1420,
|
||||
Obfuscation: obf,
|
||||
Peers: []amneziawg.Peer{{
|
||||
PublicKey: clientPub,
|
||||
AllowedIPs: []string{tunnelTestClientAddr + "/32"},
|
||||
}},
|
||||
}
|
||||
server, err := newUnconfiguredDevice(serverInst, DeviceOptions{Logger: slog})
|
||||
if err != nil {
|
||||
t.Fatalf("server device: %v", err)
|
||||
}
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Server Up before client exists: the first handshake fires at
|
||||
// ConfigureClient; a missed initiation costs a 5s REKEY_TIMEOUT.
|
||||
if err := server.Configure(serverInst, DeviceOptions{Logger: slog}); err != nil {
|
||||
t.Fatalf("server Configure: %v", err)
|
||||
}
|
||||
|
||||
clientInst := amneziawg.OutboundInstance{
|
||||
Tag: "awg-dom-test",
|
||||
Address: []string{tunnelTestClientAddr + "/32"},
|
||||
MTU: 1420,
|
||||
PrivateKey: clientPriv,
|
||||
Obfuscation: obf,
|
||||
Peers: []amneziawg.OutboundPeer{{
|
||||
PublicKey: serverPub,
|
||||
Endpoint: net.JoinHostPort("127.0.0.1", strconv.Itoa(listenPort)),
|
||||
AllowedIPs: []string{"0.0.0.0/0", "::/0"},
|
||||
KeepAlive: 1,
|
||||
}},
|
||||
}
|
||||
clog := verboseLoggerForTest("(tcli) ")
|
||||
client, err := newUnconfiguredClientDevice(clientInst, DeviceOptions{Logger: clog})
|
||||
if err != nil {
|
||||
t.Fatalf("client device: %v", err)
|
||||
}
|
||||
if err := client.ConfigureClient(clientInst, DeviceOptions{Logger: clog}); err != nil {
|
||||
client.Close()
|
||||
t.Fatalf("ConfigureClient: %v", err)
|
||||
}
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
return &pairedTunnel{
|
||||
client: client,
|
||||
server: server,
|
||||
serverIP: netip.MustParseAddr(tunnelTestServerAddr),
|
||||
}
|
||||
}
|
||||
|
||||
func newPairedTunnelV6ForTest(t *testing.T) *pairedTunnel {
|
||||
t.Helper()
|
||||
slog := verboseLoggerForTest("(tsrv6) ")
|
||||
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientPriv, clientPub, err := wgutil.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listenPort := pc.LocalAddr().(*net.UDPAddr).Port
|
||||
pc.Close()
|
||||
|
||||
obf := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
|
||||
serverInst := amneziawg.Instance{
|
||||
Id: 2,
|
||||
InterfaceName: "awg-dnstest6",
|
||||
ListenPort: listenPort,
|
||||
PrivateKey: serverPriv,
|
||||
PublicKey: serverPub,
|
||||
Address: []string{tunnelTestServerAddrV6 + "/64", "2606:4700:4700::1111/128"},
|
||||
MTU: 1420,
|
||||
Obfuscation: obf,
|
||||
Peers: []amneziawg.Peer{{
|
||||
PublicKey: clientPub,
|
||||
AllowedIPs: []string{tunnelTestClientAddrV6 + "/128"},
|
||||
}},
|
||||
}
|
||||
server, err := newUnconfiguredDevice(serverInst, DeviceOptions{Logger: slog})
|
||||
if err != nil {
|
||||
t.Fatalf("server device: %v", err)
|
||||
}
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
if err := server.Configure(serverInst, DeviceOptions{Logger: slog}); err != nil {
|
||||
t.Fatalf("server Configure: %v", err)
|
||||
}
|
||||
|
||||
clientInst := amneziawg.OutboundInstance{
|
||||
Tag: "awg-dom-v6-test",
|
||||
Address: []string{tunnelTestClientAddrV6 + "/128"},
|
||||
MTU: 1420,
|
||||
PrivateKey: clientPriv,
|
||||
Obfuscation: obf,
|
||||
Peers: []amneziawg.OutboundPeer{{
|
||||
PublicKey: serverPub,
|
||||
Endpoint: net.JoinHostPort("127.0.0.1", strconv.Itoa(listenPort)),
|
||||
AllowedIPs: []string{"::/0"},
|
||||
KeepAlive: 1,
|
||||
}},
|
||||
}
|
||||
clog := verboseLoggerForTest("(tcli6) ")
|
||||
client, err := newUnconfiguredClientDevice(clientInst, DeviceOptions{Logger: clog})
|
||||
if err != nil {
|
||||
t.Fatalf("client device: %v", err)
|
||||
}
|
||||
if err := client.ConfigureClient(clientInst, DeviceOptions{Logger: clog}); err != nil {
|
||||
client.Close()
|
||||
t.Fatalf("ConfigureClient: %v", err)
|
||||
}
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
return &pairedTunnel{
|
||||
client: client,
|
||||
server: server,
|
||||
serverIP: netip.MustParseAddr(tunnelTestServerAddrV6),
|
||||
}
|
||||
}
|
||||
|
||||
func registerEgressDeviceForTest(t *testing.T, dev *Device) {
|
||||
t.Helper()
|
||||
srv := GetEgressServer()
|
||||
srv.SetStack("awg-dom-test", dev)
|
||||
if err := srv.Listen(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { srv.DeleteStack("awg-dom-test") })
|
||||
}
|
||||
|
||||
// startTunnelDNS answers A/AAAA queries from INSIDE the server's netstack;
|
||||
// reaching it proves DNS rode the tunnel, not the host resolver.
|
||||
func (p *pairedTunnel) startDNS(t *testing.T, answer netip.Addr) chan string {
|
||||
t.Helper()
|
||||
proto := ipv4.ProtocolNumber
|
||||
if p.serverIP.Is6() {
|
||||
proto = ipv6.ProtocolNumber
|
||||
}
|
||||
ln, err := gonet.DialUDP(p.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 53}, nil, proto)
|
||||
if err != nil {
|
||||
t.Fatalf("bind fake dns in server stack: %v", err)
|
||||
}
|
||||
got := make(chan string, 8)
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
n, from, rerr := ln.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
q := buf[:n]
|
||||
if name := dnsQuestionName(q); name != "" {
|
||||
select {
|
||||
case got <- name:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if resp := buildARecordReply(q, answer); resp != nil {
|
||||
if _, werr := ln.WriteTo(resp, from); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return got
|
||||
}
|
||||
|
||||
func (p *pairedTunnel) overrideDNS(t *testing.T, answer netip.Addr) chan string {
|
||||
t.Helper()
|
||||
srv := GetEgressServer()
|
||||
prev := srv.currentDNSServer()
|
||||
srv.SetDNSServer(net.JoinHostPort(p.serverIP.String(), "53"))
|
||||
t.Cleanup(func() { srv.SetDNSServer(prev) })
|
||||
resetTunnelDNSCacheForTest()
|
||||
return p.startDNS(t, answer)
|
||||
}
|
||||
|
||||
func resetTunnelDNSCacheForTest() {
|
||||
tunnelDNSCache.mu.Lock()
|
||||
tunnelDNSCache.m = map[string]tunnelDNSCacheEntry{}
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
}
|
||||
|
||||
func dnsQuestionName(q []byte) string {
|
||||
if len(q) < 12 {
|
||||
return ""
|
||||
}
|
||||
i := 12
|
||||
var parts []byte
|
||||
for i < len(q) {
|
||||
l := int(q[i])
|
||||
i++
|
||||
if l == 0 {
|
||||
break
|
||||
}
|
||||
if i+l > len(q) || l > 63 {
|
||||
return ""
|
||||
}
|
||||
parts = append(parts, q[i:i+l]...)
|
||||
parts = append(parts, '.')
|
||||
i += l
|
||||
}
|
||||
for len(parts) > 0 && parts[len(parts)-1] == '.' {
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
return string(parts)
|
||||
}
|
||||
|
||||
func buildARecordReply(q []byte, answer netip.Addr) []byte {
|
||||
if len(q) < 17 {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, 0, len(q)+16)
|
||||
header := make([]byte, 12)
|
||||
copy(header[0:2], q[0:2])
|
||||
header[2] = 0x81 // QR=1 RD=1
|
||||
header[3] = 0x80 // RA=1 RCODE=0
|
||||
binary.BigEndian.PutUint16(header[4:], 1)
|
||||
binary.BigEndian.PutUint16(header[6:], 1)
|
||||
out = append(out, header...)
|
||||
end := len(q)
|
||||
for end >= 5 && q[end-4] == 0 && q[end-3] == 0 && q[end-2] == 0 && q[end-1] == 0 {
|
||||
end -= 4
|
||||
}
|
||||
out = append(out, q[12:end]...)
|
||||
if answer.Is4() {
|
||||
a := answer.As4()
|
||||
rr := make([]byte, 16)
|
||||
rr[0], rr[1] = 0xc0, 0x0c
|
||||
binary.BigEndian.PutUint16(rr[2:], 1) // Type A
|
||||
binary.BigEndian.PutUint16(rr[4:], 1) // IN
|
||||
binary.BigEndian.PutUint32(rr[6:], 30) // TTL
|
||||
binary.BigEndian.PutUint16(rr[10:], 4)
|
||||
copy(rr[12:], a[:])
|
||||
out = append(out, rr...)
|
||||
} else if answer.Is6() {
|
||||
a16 := answer.As16()
|
||||
rr := make([]byte, 28)
|
||||
rr[0], rr[1] = 0xc0, 0x0c
|
||||
binary.BigEndian.PutUint16(rr[2:], 28) // Type AAAA
|
||||
binary.BigEndian.PutUint16(rr[4:], 1) // IN
|
||||
binary.BigEndian.PutUint32(rr[6:], 30) // TTL
|
||||
binary.BigEndian.PutUint16(rr[10:], 16)
|
||||
copy(rr[12:], a16[:])
|
||||
out = append(out, rr...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func socksAuthUser(t *testing.T, ctl net.Conn, user string) {
|
||||
t.Helper()
|
||||
ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
|
||||
if _, err := ctl.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := make([]byte, 2)
|
||||
if _, err := io.ReadFull(ctl, r); err != nil {
|
||||
t.Fatalf("greeting read: %v", err)
|
||||
}
|
||||
pass := SocksPassword()
|
||||
req := make([]byte, 0, 3+len(user)+len(pass))
|
||||
req = append(req, 0x01, byte(len(user)))
|
||||
req = append(req, user...)
|
||||
req = append(req, byte(len(pass)))
|
||||
req = append(req, pass...)
|
||||
if _, err := ctl.Write(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 2)
|
||||
if _, err := io.ReadFull(ctl, auth); err != nil || auth[1] != 0x00 {
|
||||
t.Fatalf("auth rejected: %v %v", err, auth)
|
||||
}
|
||||
}
|
||||
|
||||
func socksAuth(t *testing.T, ctl net.Conn) {
|
||||
t.Helper()
|
||||
socksAuthUser(t, ctl, "awg-dom-test")
|
||||
}
|
||||
|
||||
func TestEgressGreetingRejectsNoAuthClient(t *testing.T) {
|
||||
tun := newPairedTunnelForTest(t)
|
||||
registerEgressDeviceForTest(t, tun.client)
|
||||
|
||||
ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctl.Close()
|
||||
ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
|
||||
// Client offers only NO-AUTH; server must answer 0xFF.
|
||||
if _, err := ctl.Write([]byte{0x05, 0x01, 0x00}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := make([]byte, 2)
|
||||
if _, err := io.ReadFull(ctl, r); err != nil {
|
||||
t.Fatalf("greeting read: %v", err)
|
||||
}
|
||||
if r[0] != 0x05 || r[1] != 0xFF {
|
||||
t.Fatalf("greeting reply = %v, want 05 FF (auth required)", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressConnectDomainResolvesThroughTunnel(t *testing.T) {
|
||||
tun := newPairedTunnelForTest(t)
|
||||
registerEgressDeviceForTest(t, tun.client)
|
||||
// Resolving to the server's own tunnel address makes the follow-up dial
|
||||
// fail fast (nothing listens on :80), while proving resolution happened.
|
||||
gotQuery := tun.overrideDNS(t, tun.serverIP)
|
||||
|
||||
ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctl.Close()
|
||||
|
||||
socksAuth(t, ctl)
|
||||
name := "example.internal"
|
||||
req := make([]byte, 0, 7+len(name))
|
||||
req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(name)))
|
||||
req = append(req, name...)
|
||||
req = append(req, 0x00, 0x50)
|
||||
if _, err := ctl.Write(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case queried := <-gotQuery:
|
||||
if len(queried) < len(name) || queried[:len(name)] != name {
|
||||
t.Fatalf("resolver queried %q, want prefix %q -- DNS did not ride the tunnel", queried, name)
|
||||
}
|
||||
case <-time.After(egressTestDialTimeout):
|
||||
t.Fatal("no DNS query reached the in-tunnel resolver")
|
||||
}
|
||||
|
||||
reply := make([]byte, 10)
|
||||
ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
|
||||
if _, err := io.ReadFull(ctl, reply); err != nil {
|
||||
t.Fatalf("read reply: %v", err)
|
||||
}
|
||||
if reply[1] == 0x00 {
|
||||
t.Fatal("unexpected success: nothing should be listening on the resolved address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressConnectDomainIPv6OnlyTunnelResolvesThroughTunnel(t *testing.T) {
|
||||
tun := newPairedTunnelV6ForTest(t)
|
||||
srv := GetEgressServer()
|
||||
srv.SetStack("awg-dom-v6-test", tun.client)
|
||||
if err := srv.Listen(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { srv.DeleteStack("awg-dom-v6-test") })
|
||||
|
||||
// No override: a blank dns has to fall through currentDNSServer to
|
||||
// defaultDNSFor, which the server stack answers on its own v6 /128.
|
||||
prevDNS := srv.currentDNSServer()
|
||||
srv.SetDNSServer("")
|
||||
t.Cleanup(func() { srv.SetDNSServer(prevDNS) })
|
||||
resetTunnelDNSCacheForTest()
|
||||
gotQuery := tun.startDNS(t, tun.serverIP)
|
||||
|
||||
ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctl.Close()
|
||||
|
||||
socksAuthUser(t, ctl, "awg-dom-v6-test")
|
||||
name := "v6.example.internal"
|
||||
req := make([]byte, 0, 7+len(name))
|
||||
req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(name)))
|
||||
req = append(req, name...)
|
||||
req = append(req, 0x00, 0x50)
|
||||
if _, err := ctl.Write(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case queried := <-gotQuery:
|
||||
if len(queried) < len(name) || queried[:len(name)] != name {
|
||||
t.Fatalf("resolver queried %q, want prefix %q -- DNS did not ride the v6 tunnel", queried, name)
|
||||
}
|
||||
case <-time.After(egressTestDialTimeout):
|
||||
t.Fatal("no DNS query reached the in-tunnel v6 resolver")
|
||||
}
|
||||
|
||||
reply := make([]byte, 10)
|
||||
ctl.SetDeadline(time.Now().Add(egressTestDialTimeout))
|
||||
if _, err := io.ReadFull(ctl, reply); err != nil {
|
||||
t.Fatalf("read reply: %v", err)
|
||||
}
|
||||
if reply[1] == 0x00 {
|
||||
t.Fatal("unexpected success: nothing should be listening on the resolved address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressUDPDatagramDomainForwardedIntoTunnel(t *testing.T) {
|
||||
tun := newPairedTunnelForTest(t)
|
||||
registerEgressDeviceForTest(t, tun.client)
|
||||
gotQuery := tun.overrideDNS(t, tun.serverIP)
|
||||
|
||||
in, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 9999}, nil, ipv4.ProtocolNumber)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctl.Close()
|
||||
|
||||
socksAuth(t, ctl)
|
||||
if _, err := ctl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply := make([]byte, 10)
|
||||
if _, err := io.ReadFull(ctl, reply); err != nil || reply[1] != 0x00 {
|
||||
t.Fatalf("associate failed: %v %v", err, reply)
|
||||
}
|
||||
bindPort := binary.BigEndian.Uint16(reply[8:10])
|
||||
|
||||
udp, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(bindPort)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer udp.Close()
|
||||
udp.SetDeadline(time.Now().Add(egressTestDialTimeout))
|
||||
|
||||
// Plain-IP control datagram isolates domain parsing from transport.
|
||||
// Retried to avoid warmup race on slow -race runners.
|
||||
ctrl := []byte{0x00, 0x00, 0x00, 0x01, 10, 203, 0, 1, 0x27, 0x0f, 'c', 't', 'r', 'l'}
|
||||
rcv := make([]byte, 64)
|
||||
var nr int
|
||||
var rerr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if _, err := udp.Write(ctrl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
nr, _, rerr = in.ReadFrom(rcv)
|
||||
if rerr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if rerr != nil {
|
||||
t.Fatalf("CONTROL datagram never reached the tunnel target: %v", rerr)
|
||||
}
|
||||
if string(rcv[:nr]) != "ctrl" {
|
||||
t.Fatalf("control payload = %q", rcv[:nr])
|
||||
}
|
||||
|
||||
name := "quic.internal"
|
||||
dgram := make([]byte, 0, 5+len(name)+2+4)
|
||||
dgram = append(dgram, 0x00, 0x00, 0x00, 0x03, byte(len(name)))
|
||||
dgram = append(dgram, name...)
|
||||
dgram = append(dgram, 0x27, 0x0f)
|
||||
dgram = append(dgram, 'p', 'i', 'n', 'g')
|
||||
|
||||
var queried string
|
||||
for attempt := 0; attempt < 3 && queried == ""; attempt++ {
|
||||
if _, err := udp.Write(dgram); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case q := <-gotQuery:
|
||||
queried = q
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if len(queried) < len(name) || queried[:len(name)] != name {
|
||||
t.Fatalf("resolver queried %q, want prefix %q -- DNS did not ride the tunnel", queried, name)
|
||||
}
|
||||
|
||||
in.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
nr, _, rerr = in.ReadFrom(rcv)
|
||||
if rerr != nil {
|
||||
t.Fatalf("domain datagram never reached the tunnel target: %v", rerr)
|
||||
}
|
||||
if nr < 4 || string(rcv[:4]) != "ping" {
|
||||
t.Fatalf("payload = %q (n=%d)", rcv[:nr], nr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressUDPDatagramDomainInterleavedClients ensures datagrams pass client
|
||||
// address by value into resolver goroutines so responses route correctly.
|
||||
func TestEgressUDPDatagramDomainInterleavedClients(t *testing.T) {
|
||||
tun := newPairedTunnelForTest(t)
|
||||
registerEgressDeviceForTest(t, tun.client)
|
||||
gotQuery := tun.overrideDNS(t, tun.serverIP)
|
||||
|
||||
in, err := gonet.DialUDP(tun.server.Stack, &tcpip.FullAddress{NIC: 1, Port: 9999}, nil, ipv4.ProtocolNumber)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
n, from, rerr := in.ReadFrom(buf)
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = in.WriteTo(append([]byte("echo:"), buf[:n]...), from)
|
||||
}
|
||||
}()
|
||||
|
||||
dialUDP := func() *net.UDPConn {
|
||||
ctl, err := (&net.Dialer{Timeout: egressTestDialTimeout}).Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(EgressBasePort)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ctl.Close() })
|
||||
socksAuth(t, ctl)
|
||||
if _, err := ctl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply := make([]byte, 10)
|
||||
if _, err := io.ReadFull(ctl, reply); err != nil || reply[1] != 0x00 {
|
||||
t.Fatalf("associate failed: %v %v", err, reply)
|
||||
}
|
||||
bindPort := binary.BigEndian.Uint16(reply[8:10])
|
||||
udp, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: int(bindPort)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { udp.Close() })
|
||||
udp.SetDeadline(time.Now().Add(egressTestDialTimeout))
|
||||
return udp
|
||||
}
|
||||
|
||||
name := func(i int) string { return fmt.Sprintf("interleaved-%d.internal", i) }
|
||||
dgram := func(i int, payload string) []byte {
|
||||
n := name(i)
|
||||
d := make([]byte, 0, 5+len(n)+2+len(payload))
|
||||
d = append(d, 0x00, 0x00, 0x00, 0x03, byte(len(n)))
|
||||
d = append(d, n...)
|
||||
d = append(d, 0x27, 0x0f)
|
||||
return append(d, payload...)
|
||||
}
|
||||
|
||||
for seq := 0; seq < 4; seq++ {
|
||||
udp := dialUDP()
|
||||
payload := fmt.Sprintf("p-%d", seq)
|
||||
if _, err := udp.Write(dgram(seq, payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case q := <-gotQuery:
|
||||
if !strings.HasPrefix(q, "interleaved-") {
|
||||
t.Fatalf("resolver queried %q, want an interleaved-* name", q)
|
||||
}
|
||||
case <-time.After(4 * time.Second):
|
||||
t.Fatalf("query %d not observed", seq)
|
||||
}
|
||||
rcv := make([]byte, 512)
|
||||
nr, _, rerr := udp.ReadFrom(rcv)
|
||||
if rerr != nil {
|
||||
t.Fatalf("reply %d never reached client: %v", seq, rerr)
|
||||
}
|
||||
if nr < 10 || !strings.Contains(string(rcv[:nr]), "echo:"+payload) {
|
||||
t.Fatalf("reply payload = %q, want echo:%s", rcv[:nr], payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDNSFor(t *testing.T) {
|
||||
v4 := netip.MustParseAddr("10.8.0.2")
|
||||
v6 := netip.MustParseAddr("2001:db8::2")
|
||||
|
||||
if got := defaultDNSFor([]netip.Addr{v4}); got != DefaultTunnelDNSServer {
|
||||
t.Errorf("defaultDNSFor(v4) = %q, want %q", got, DefaultTunnelDNSServer)
|
||||
}
|
||||
if got := defaultDNSFor([]netip.Addr{v4, v6}); got != DefaultTunnelDNSServer {
|
||||
t.Errorf("defaultDNSFor(dual) = %q, want %q", got, DefaultTunnelDNSServer)
|
||||
}
|
||||
if got := defaultDNSFor([]netip.Addr{v6}); got != DefaultTunnelDNSServerV6 {
|
||||
t.Errorf("defaultDNSFor(v6-only) = %q, want %q", got, DefaultTunnelDNSServerV6)
|
||||
}
|
||||
if got := defaultDNSFor(nil); got != DefaultTunnelDNSServer {
|
||||
t.Errorf("defaultDNSFor(nil) = %q, want %q", got, DefaultTunnelDNSServer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDatagramDomainHeader(t *testing.T) {
|
||||
hdr := []byte{0, 0, 0, 0x03, 4, 'a', 'b', '.', 'd', 0x00, 0x35, 'x'}
|
||||
name, port, hdrLen, err := parseDatagramDomainHeader(hdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "ab.d" || port != 53 || hdrLen != 11 {
|
||||
t.Fatalf("name=%q port=%d hdrLen=%d", name, port, hdrLen)
|
||||
}
|
||||
truncated := []byte{0, 0, 0, 0x03, 200, 'a'}
|
||||
if _, _, _, err := parseDatagramDomainHeader(truncated); err == nil {
|
||||
t.Fatal("truncated domain accepted")
|
||||
}
|
||||
empty := []byte{0, 0, 0, 0x03, 0, 0x00, 0x35}
|
||||
if _, _, _, err := parseDatagramDomainHeader(empty); err == nil {
|
||||
t.Fatal("empty domain accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSocksRequestTargetKeepsHostnameUnresolved(t *testing.T) {
|
||||
payload := append([]byte{byte(len("invalid."))}, []byte("invalid.")...)
|
||||
payload = append(payload, 0x01, 0xbb)
|
||||
tr, err := readSocksRequestTarget(bytes.NewReader(payload), 0x03)
|
||||
if err != nil {
|
||||
t.Fatalf("domain request rejected: %v", err)
|
||||
}
|
||||
if tr.host != "invalid." || tr.port != 443 || tr.ip.IsValid() {
|
||||
t.Fatalf("target = %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelDNSCache_ScopedPerTagAndServer(t *testing.T) {
|
||||
resetTunnelDNSCacheForTest()
|
||||
tagA, tagB := "out-a", "out-b"
|
||||
dns1, dns2 := "1.1.1.1:53", "8.8.8.8:53"
|
||||
host := "example.com"
|
||||
|
||||
addrA := netip.MustParseAddr("10.0.0.1")
|
||||
addrB := netip.MustParseAddr("10.0.0.2")
|
||||
|
||||
keyA := dnsCacheKey(tagA, dns1, host)
|
||||
keyB := dnsCacheKey(tagB, dns1, host)
|
||||
keyA2 := dnsCacheKey(tagA, dns2, host)
|
||||
|
||||
tunnelDNSCache.mu.Lock()
|
||||
tunnelDNSCache.m[keyA] = tunnelDNSCacheEntry{addr: addrA, exp: time.Now().Add(time.Hour)}
|
||||
tunnelDNSCache.m[keyB] = tunnelDNSCacheEntry{addr: addrB, exp: time.Now().Add(time.Hour)}
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
|
||||
tunnelDNSCache.mu.Lock()
|
||||
eA, okA := tunnelDNSCache.m[keyA]
|
||||
eB, okB := tunnelDNSCache.m[keyB]
|
||||
_, okA2 := tunnelDNSCache.m[keyA2]
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
|
||||
if !okA || eA.addr != addrA {
|
||||
t.Fatalf("tagA cache entry mismatch: %v, %v", okA, eA)
|
||||
}
|
||||
if !okB || eB.addr != addrB {
|
||||
t.Fatalf("tagB cache entry mismatch: %v, %v", okB, eB)
|
||||
}
|
||||
if okA2 {
|
||||
t.Fatal("key with different DNS server should not match")
|
||||
}
|
||||
|
||||
flushTunnelDNSCacheForTag(tagA)
|
||||
tunnelDNSCache.mu.Lock()
|
||||
_, okAAfter := tunnelDNSCache.m[keyA]
|
||||
_, okBAfter := tunnelDNSCache.m[keyB]
|
||||
tunnelDNSCache.mu.Unlock()
|
||||
|
||||
if okAAfter {
|
||||
t.Fatal("tagA entry should be flushed")
|
||||
}
|
||||
if !okBAfter {
|
||||
t.Fatal("tagB entry should survive flush of tagA")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// OutboundDesired pairs an instance with inbound-path DeviceOptions; AWG
|
||||
// parameters must be identical on both ends of a tunnel.
|
||||
type OutboundDesired struct {
|
||||
Instance amneziawg.OutboundInstance
|
||||
Options DeviceOptions
|
||||
}
|
||||
|
||||
// managedOutbound is one running interface plus its rendered UAPI config
|
||||
// (no-op/reconfigure decision) and an address/MTU fingerprint.
|
||||
type managedOutbound struct {
|
||||
dev *Device
|
||||
uapiConfig string
|
||||
structFP string
|
||||
}
|
||||
|
||||
// OutboundManager owns the running AmneziaWG client interfaces keyed by tag,
|
||||
// keeping the egress registry and listener current; callers just Reconcile.
|
||||
type OutboundManager struct {
|
||||
mu sync.Mutex
|
||||
iface map[string]*managedOutbound
|
||||
}
|
||||
|
||||
var (
|
||||
outboundManagerOnce sync.Once
|
||||
outboundManager *OutboundManager
|
||||
)
|
||||
|
||||
// GetOutboundManager returns the process-wide outbound manager singleton.
|
||||
func GetOutboundManager() *OutboundManager {
|
||||
outboundManagerOnce.Do(func() {
|
||||
outboundManager = &OutboundManager{iface: map[string]*managedOutbound{}}
|
||||
})
|
||||
return outboundManager
|
||||
}
|
||||
|
||||
// outboundFingerprint captures what IpcSet can't change on a running Device,
|
||||
// fixed when the netstack is built: address, and the S4-derived effective MTU.
|
||||
func outboundFingerprint(inst amneziawg.OutboundInstance) string {
|
||||
return fmt.Sprintf("%d|%s",
|
||||
amneziawg.EffectiveMTU(inst.MTU, inst.Obfuscation.S4),
|
||||
strings.Join(inst.Address, ","))
|
||||
}
|
||||
|
||||
// normalizeDNSServer normalizes a configured DNS server to host:port.
|
||||
func normalizeDNSServer(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if addr, err := netip.ParseAddr(s); err == nil {
|
||||
return netip.AddrPortFrom(addr, 53).String()
|
||||
}
|
||||
if ap, err := netip.ParseAddrPort(s); err == nil {
|
||||
return ap.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Reconcile converges devices to desired and stops removed tags; per-tick
|
||||
// contract of Manager.Reconcile -- errors log, never abort the batch.
|
||||
func (m *OutboundManager) Reconcile(desired []OutboundDesired) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Empty desired converges to "no tunnels": close egress listener so
|
||||
// 127.0.0.1:64900 stays free on installs without AWG outbounds.
|
||||
if len(desired) == 0 {
|
||||
for tag, cur := range m.iface {
|
||||
cur.dev.Close()
|
||||
GetEgressServer().DeleteStack(tag)
|
||||
delete(m.iface, tag)
|
||||
logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
|
||||
}
|
||||
GetEgressServer().Close()
|
||||
return
|
||||
}
|
||||
|
||||
if err := GetEgressServer().Listen(); err != nil {
|
||||
logger.Warningf("amneziawgnet: egress listener unavailable: %v", err)
|
||||
}
|
||||
|
||||
want := make(map[string]struct{}, len(desired))
|
||||
for _, d := range desired {
|
||||
want[d.Instance.Tag] = struct{}{}
|
||||
}
|
||||
for tag, cur := range m.iface {
|
||||
if _, ok := want[tag]; ok {
|
||||
continue
|
||||
}
|
||||
cur.dev.Close()
|
||||
GetEgressServer().DeleteStack(tag)
|
||||
delete(m.iface, tag)
|
||||
logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
|
||||
}
|
||||
|
||||
for _, d := range desired {
|
||||
if err := m.ensureLocked(d); err != nil {
|
||||
logger.Warningf("amneziawgnet: reconcile failed for outbound %q: %v", d.Instance.Tag, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureLocked picks no-op / reconfigure-in-place / rebuild for one desired
|
||||
// outbound (address/MTU are fixed at netstack build time).
|
||||
func (m *OutboundManager) ensureLocked(d OutboundDesired) error {
|
||||
inst, opts := d.Instance, d.Options
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = verboseLoggerIfEnabled(0)
|
||||
}
|
||||
|
||||
fp := outboundFingerprint(inst)
|
||||
conf, err := buildClientUAPIConfig(inst, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render UAPI config: %w", err)
|
||||
}
|
||||
|
||||
cur, exists := m.iface[inst.Tag]
|
||||
if exists && cur.structFP == fp {
|
||||
if conf == cur.uapiConfig {
|
||||
GetEgressServer().SetStack(inst.Tag, cur.dev, inst.DNS)
|
||||
return nil
|
||||
}
|
||||
if err := cur.dev.IpcSet(conf); err != nil {
|
||||
return fmt.Errorf("reconfigure outbound %q: %w", inst.Tag, err)
|
||||
}
|
||||
cur.uapiConfig = conf
|
||||
GetEgressServer().SetStack(inst.Tag, cur.dev, inst.DNS)
|
||||
return nil
|
||||
}
|
||||
|
||||
if exists {
|
||||
cur.dev.Close()
|
||||
// A failed rebuild must not leave stackFor handing out a closed device.
|
||||
GetEgressServer().DeleteStack(inst.Tag)
|
||||
delete(m.iface, inst.Tag)
|
||||
}
|
||||
dev, err := newUnconfiguredClientDevice(inst, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dev.ConfigureClient(inst, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
m.iface[inst.Tag] = &managedOutbound{dev: dev, uapiConfig: conf, structFP: fp}
|
||||
GetEgressServer().SetStack(inst.Tag, dev, inst.DNS)
|
||||
logger.Infof("amneziawgnet: started embedded outbound %s (%d peers)", inst.Tag, len(inst.Peers))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove tears down one outbound's device by tag.
|
||||
func (m *OutboundManager) Remove(tag string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cur, exists := m.iface[tag]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
cur.dev.Close()
|
||||
GetEgressServer().DeleteStack(tag)
|
||||
delete(m.iface, tag)
|
||||
logger.Infof("amneziawgnet: stopped embedded outbound %q", tag)
|
||||
}
|
||||
|
||||
// StopAll tears down every managed outbound device and the egress listener;
|
||||
// m.mu stays held across Close so a cron tick cannot re-bind mid-teardown.
|
||||
func (m *OutboundManager) StopAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for tag, cur := range m.iface {
|
||||
cur.dev.Close()
|
||||
GetEgressServer().DeleteStack(tag)
|
||||
delete(m.iface, tag)
|
||||
}
|
||||
GetEgressServer().Close()
|
||||
}
|
||||
|
||||
// HasRunning reports whether any outbound device is currently managed.
|
||||
func (m *OutboundManager) HasRunning() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.iface) > 0
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
)
|
||||
|
||||
// egressPortBound reports whether 127.0.0.1:<EgressBasePort> accepts TCP.
|
||||
func egressPortBound(t *testing.T) bool {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))), 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [8]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// newTestOutboundDesired builds one runnable outbound desired state.
|
||||
func newTestOutboundDesired(t *testing.T, tag string) OutboundDesired {
|
||||
t.Helper()
|
||||
priv, _, err := wireguard.GenerateWireguardKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate keypair: %v", err)
|
||||
}
|
||||
return OutboundDesired{
|
||||
Instance: amneziawg.OutboundInstance{
|
||||
Tag: tag,
|
||||
Address: []string{"10.204.0.1/24"},
|
||||
MTU: 1420,
|
||||
PrivateKey: priv,
|
||||
ListenPort: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboundManagerReconcileEmptyDesiredClosesEgress verifies that an empty
|
||||
// desired set tears down interfaces and releases 127.0.0.1:64900.
|
||||
func TestOutboundManagerReconcileEmptyDesiredClosesEgress(t *testing.T) {
|
||||
m := &OutboundManager{iface: map[string]*managedOutbound{}}
|
||||
defer m.Reconcile(nil)
|
||||
|
||||
// Other tests in this package may leave the process-wide egress
|
||||
// singleton bound; converge to a known-free state before pinning.
|
||||
GetEgressServer().Close()
|
||||
if egressPortBound(t) {
|
||||
t.Fatal("egress port still bound after Close; Close() failed to release it")
|
||||
}
|
||||
|
||||
// Non-empty: listener must come up.
|
||||
d := newTestOutboundDesired(t, "t1")
|
||||
m.Reconcile([]OutboundDesired{d})
|
||||
if !egressPortBound(t) {
|
||||
t.Fatal("egress port not bound after Reconcile with a desired outbound")
|
||||
}
|
||||
|
||||
// Empty: listener must be released so other listeners can take the port.
|
||||
m.Reconcile(nil)
|
||||
if egressPortBound(t) {
|
||||
t.Fatal("egress port still bound after Reconcile(nil)")
|
||||
}
|
||||
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))))
|
||||
if err != nil {
|
||||
t.Fatalf("egress port must be free after Reconcile(nil): %v", err)
|
||||
}
|
||||
ln.Close()
|
||||
|
||||
// Back to non-empty and empty again: Close/Listen must be repeatable.
|
||||
m.Reconcile([]OutboundDesired{d})
|
||||
if !egressPortBound(t) {
|
||||
t.Fatal("egress port not re-bound after a second non-empty Reconcile")
|
||||
}
|
||||
m.Reconcile(nil)
|
||||
if egressPortBound(t) {
|
||||
t.Fatal("egress port still bound after a second Reconcile(nil)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEgressServerCloseDuringConcurrentAccepts ensures Close during
|
||||
// concurrent accepts shuts down cleanly without hanging wg.Wait().
|
||||
func TestEgressServerCloseDuringConcurrentAccepts(t *testing.T) {
|
||||
srv := GetEgressServer()
|
||||
if err := srv.Listen(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var clientWg sync.WaitGroup
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
c, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(int(EgressBasePort))), 50*time.Millisecond)
|
||||
if err == nil {
|
||||
clientWg.Add(1)
|
||||
go func(conn net.Conn) {
|
||||
defer clientWg.Done()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
conn.Close()
|
||||
}(c)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
closeChan := make(chan struct{})
|
||||
go func() {
|
||||
srv.Close()
|
||||
close(closeChan)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-closeChan:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("srv.Close() hung waiting for connection handlers to exit")
|
||||
}
|
||||
close(stop)
|
||||
<-done
|
||||
clientWg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
)
|
||||
|
||||
// endpointResolveTimeout bounds the one-time DNS lookup in ParseEndpoint.
|
||||
const endpointResolveTimeout = 5 * time.Second
|
||||
|
||||
// resolvingBind lets peer endpoints be hostnames: StdNetBind has no DNS and
|
||||
// an unresolved name kills the whole IpcSet. Resolved once at configure.
|
||||
type resolvingBind struct {
|
||||
awgconn.Bind
|
||||
}
|
||||
|
||||
var lookupEndpointHost = defaultLookupEndpointHost
|
||||
|
||||
func defaultLookupEndpointHost(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]netip.Addr, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, a.Unmap())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newResolvingBind() *resolvingBind {
|
||||
return &resolvingBind{Bind: awgconn.NewDefaultBind()}
|
||||
}
|
||||
|
||||
// ParseEndpoint resolves hostnames before handing the address to amneziawg-go
|
||||
// (whose own implementation accepts literal IPs only).
|
||||
func (b *resolvingBind) ParseEndpoint(s string) (awgconn.Endpoint, error) {
|
||||
host, portStr, err := net.SplitHostPort(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("endpoint %q: %w", s, err)
|
||||
}
|
||||
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil || port64 == 0 {
|
||||
return nil, fmt.Errorf("endpoint %q: bad port", s)
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), endpointResolveTimeout)
|
||||
defer cancel()
|
||||
addrs, rerr := lookupEndpointHost(ctx, host)
|
||||
if rerr != nil {
|
||||
return nil, fmt.Errorf("endpoint %q: resolve host: %w", s, rerr)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("endpoint %q: host resolved to no addresses", s)
|
||||
}
|
||||
addr = addrs[0]
|
||||
}
|
||||
return &awgconn.StdNetEndpoint{AddrPort: netip.AddrPortFrom(addr.Unmap(), uint16(port64))}, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
|
||||
)
|
||||
|
||||
func endpointAddrPort(ep awgconn.Endpoint) netip.AddrPort {
|
||||
std, ok := ep.(*awgconn.StdNetEndpoint)
|
||||
if !ok {
|
||||
panic("unexpected endpoint type")
|
||||
}
|
||||
return std.AddrPort
|
||||
}
|
||||
|
||||
func TestResolvingBind_ParseEndpointIPLiteral(t *testing.T) {
|
||||
b := newResolvingBind()
|
||||
ep, err := b.ParseEndpoint("203.0.113.7:51820")
|
||||
if err != nil {
|
||||
t.Fatalf("IP endpoint rejected: %v", err)
|
||||
}
|
||||
got := endpointAddrPort(ep)
|
||||
if got.Addr().String() != "203.0.113.7" || got.Port() != 51820 {
|
||||
t.Fatalf("endpoint = %v, want 203.0.113.7:51820", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvingBind_ParseEndpointHostnameResolves(t *testing.T) {
|
||||
orig := lookupEndpointHost
|
||||
lookupEndpointHost = func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if host != "peer.example.test" {
|
||||
t.Errorf("unexpected lookup host %q", host)
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("198.51.100.9")}, nil
|
||||
}
|
||||
defer func() { lookupEndpointHost = orig }()
|
||||
|
||||
b := newResolvingBind()
|
||||
ep, err := b.ParseEndpoint("peer.example.test:443")
|
||||
if err != nil {
|
||||
t.Fatalf("hostname endpoint rejected: %v", err)
|
||||
}
|
||||
if got := endpointAddrPort(ep); got.Addr().String() != "198.51.100.9" || got.Port() != 443 {
|
||||
t.Fatalf("endpoint = %v, want 198.51.100.9:443", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvingBind_ParseEndpointResolveFailureIsAnError(t *testing.T) {
|
||||
orig := lookupEndpointHost
|
||||
lookupEndpointHost = func(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return nil, errors.New("no such host")
|
||||
}
|
||||
defer func() { lookupEndpointHost = orig }()
|
||||
|
||||
b := newResolvingBind()
|
||||
if _, err := b.ParseEndpoint("missing.example.test:80"); err == nil {
|
||||
t.Fatal("expected resolve failure to surface as an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvingBind_ParseEndpointBadPortRejected(t *testing.T) {
|
||||
b := newResolvingBind()
|
||||
if _, err := b.ParseEndpoint("203.0.113.7:none"); err == nil {
|
||||
t.Fatal("expected bad port to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package amneziawgnet
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// BuildSocksBridge swaps an "amneziawg" outbound for its loopback socks
|
||||
// form, preserving sibling keys; false = unbridgeable, fail loudly upstream.
|
||||
func BuildSocksBridge(raw []byte) ([]byte, bool) {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal(raw, &ob); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
tag, _ := ob["tag"].(string)
|
||||
if tag == "" {
|
||||
return nil, false
|
||||
}
|
||||
settings := map[string]any{
|
||||
"address": "127.0.0.1",
|
||||
"port": EgressBasePort,
|
||||
"user": tag,
|
||||
"pass": SocksPassword(),
|
||||
}
|
||||
bs, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
ob["protocol"] = "socks"
|
||||
ob["settings"] = json.RawMessage(bs)
|
||||
out, err := json.Marshal(ob)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package amneziawgnet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildSocksBridge_BridgesAndPreservesSiblings(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "awg-hop",
|
||||
"sendThrough": "0.0.0.0",
|
||||
"targetStrategy": {"strategy": "UseIPv4"},
|
||||
"mux": {"enabled": false},
|
||||
"streamSettings": {"sockopt": {"tcpFastOpen": true}},
|
||||
"settings": {"secretKey": "x"}
|
||||
}`)
|
||||
out, ok := BuildSocksBridge(raw)
|
||||
if !ok {
|
||||
t.Fatal("valid entry rejected")
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["protocol"] != "socks" {
|
||||
t.Fatalf("protocol = %v", got["protocol"])
|
||||
}
|
||||
if got["tag"] != "awg-hop" || got["sendThrough"] != "0.0.0.0" {
|
||||
t.Fatalf("siblings dropped: %v", got)
|
||||
}
|
||||
if _, ok := got["targetStrategy"].(map[string]any); !ok {
|
||||
t.Fatalf("targetStrategy dropped: %v", got["targetStrategy"])
|
||||
}
|
||||
settings, _ := got["settings"].(map[string]any)
|
||||
if settings == nil || settings["user"] != "awg-hop" || settings["address"] != "127.0.0.1" {
|
||||
t.Fatalf("bridge settings wrong: %v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSocksBridge_RejectsUnusableTags(t *testing.T) {
|
||||
for name, raw := range map[string][]byte{
|
||||
"missing tag": []byte(`{"protocol":"amneziawg","settings":{}}`),
|
||||
"empty tag": []byte(`{"protocol":"amneziawg","tag":"","settings":{}}`),
|
||||
"non-string tag": []byte(`{"protocol":"amneziawg","tag":123,"settings":{}}`),
|
||||
"not an object": []byte(`[1,2,3]`),
|
||||
} {
|
||||
if _, ok := BuildSocksBridge(raw); ok {
|
||||
t.Fatalf("%s: expected rejection", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// AmneziaWGJob reconciles the running embedded AmneziaWG interfaces
|
||||
// (internal/amneziawgnet -- amneziawg-go over a gVisor netstack, no kernel
|
||||
// module) against the enabled AmneziaWG inbounds in the database,
|
||||
// rebuilding/reconfiguring any that drifted. Unlike the retired
|
||||
// kernel-module Manager this job used to drive, there is no traffic/
|
||||
// online-status accounting here at all: once a peer's decapsulated traffic
|
||||
// is relayed into Xray's own SOCKS5 inbound (see
|
||||
// internal/web/service/xray.go's injectAmneziawgnetSocks, and
|
||||
// internal/amneziawgnet.Manager's automatic forwarder/relay wiring), it's
|
||||
// an ordinary Xray user, and XrayTrafficJob's existing, protocol-blind
|
||||
// stats/online-status polling already picks it up for free.
|
||||
// AmneziaWGJob converges embedded AmneziaWG interfaces (inbounds AND the
|
||||
// template's "amneziawg" outbounds) every 10s; stats stay with Xray.
|
||||
type AmneziaWGJob struct {
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
// NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
|
||||
@@ -52,4 +48,64 @@ func (j *AmneziaWGJob) Run() {
|
||||
})
|
||||
}
|
||||
amneziawgnet.GetManager().Reconcile(wanted)
|
||||
|
||||
outboundDesired, err := j.desiredOutboundInstances()
|
||||
if err != nil {
|
||||
logger.Warning("amneziawg job: get desired outbound instances failed:", err)
|
||||
return
|
||||
}
|
||||
amneziawgnet.GetOutboundManager().Reconcile(outboundDesired)
|
||||
}
|
||||
|
||||
// desiredOutboundInstances derives client instances per template "amneziawg" outbound.
|
||||
func (j *AmneziaWGJob) desiredOutboundInstances() ([]amneziawgnet.OutboundDesired, error) {
|
||||
template, err := j.settingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if template == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := &xray.Config{}
|
||||
if err := json.Unmarshal([]byte(template), cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.OutboundConfigs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var raws []json.RawMessage
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &raws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]amneziawgnet.OutboundDesired, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
if !amneziawg.IsAmneziaWGOutbound(raw) {
|
||||
continue
|
||||
}
|
||||
var probe struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &probe); err != nil || probe.Tag == "" {
|
||||
continue
|
||||
}
|
||||
inst, ok := amneziawg.InstanceFromOutbound(probe.Tag, raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, amneziawgnet.OutboundDesired{
|
||||
Instance: inst,
|
||||
Options: amneziawgnet.DeviceOptions{
|
||||
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
|
||||
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
|
||||
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
|
||||
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
|
||||
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
|
||||
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
|
||||
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
|
||||
RandomTrailers: inst.Obfuscation.RandomTrailers,
|
||||
DisableCookies: inst.Obfuscation.DisableCookies,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
|
||||
// dial neither proves reachability nor measures latency. Such outbounds
|
||||
// must go through the real xray handshake probe instead.
|
||||
func outboundTransportIsUDP(ob map[string]any) bool {
|
||||
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
|
||||
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" || protocol == "amneziawg" {
|
||||
return true
|
||||
}
|
||||
if stream, ok := ob["streamSettings"].(map[string]any); ok {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -384,6 +385,33 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
|
||||
outbounds = append(outbounds, it.outbound)
|
||||
}
|
||||
}
|
||||
// Bridge amneziawg entries like GetXrayConfig does -- one raw entry fails
|
||||
// the whole temp config; drop unbridgeable ones, not unrelated items.
|
||||
bridged := make([]any, 0, len(outbounds))
|
||||
for _, ob := range outbounds {
|
||||
m, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
bridged = append(bridged, ob)
|
||||
continue
|
||||
}
|
||||
if p, _ := m["protocol"].(string); p != "amneziawg" {
|
||||
bridged = append(bridged, ob)
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
repl, ok := amneziawgnet.BuildSocksBridge(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var replacement any
|
||||
if json.Unmarshal(repl, &replacement) == nil {
|
||||
bridged = append(bridged, replacement)
|
||||
}
|
||||
}
|
||||
outbounds = bridged
|
||||
for _, ob := range outbounds {
|
||||
outbound, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -557,6 +557,33 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsTCPModeForcesAmneziaWGToHTTPProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
|
||||
return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{"tag": "awg", "protocol": "amneziawg"}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
r := results[0]
|
||||
if !r.Success || r.Mode != "http" {
|
||||
t.Errorf("amneziawg outbound in tcp mode = %+v, want success with mode %q", r, "http")
|
||||
}
|
||||
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
|
||||
t.Errorf("amneziawg outbound egress = %+v", r.Egress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeModeLabel(t *testing.T) {
|
||||
cases := []struct{ mode, want string }{
|
||||
{"tcp", "tcp"},
|
||||
|
||||
@@ -185,6 +185,18 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Egress SOCKS server holds loopback EgressBasePort when AWG outbounds are
|
||||
// active; conflict check prevents inbounds from colliding with it.
|
||||
if inbound.NodeID == nil && inbound.Port == int(amneziawgnet.EgressBasePort) &&
|
||||
newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
|
||||
return &portConflictDetail{
|
||||
Tag: "amneziawg-egress",
|
||||
Listen: "127.0.0.1",
|
||||
Port: inbound.Port,
|
||||
Transports: transportTCP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Every enabled local AmneziaWG inbound gets its own automatic Xray
|
||||
// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
|
||||
// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
|
||||
|
||||
@@ -739,10 +739,27 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
|
||||
// it's now ignored -- see the "RouteThroughXrayOff" test below.
|
||||
const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
|
||||
|
||||
// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound
|
||||
// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database
|
||||
// row, so checkPortConflict needs its own check to catch a collision --
|
||||
// exactly the same shape of problem as the reserved API port above.
|
||||
// A local TCP inbound on EgressBasePort must conflict with the AmneziaWG
|
||||
// egress SOCKS server (which is not in the database).
|
||||
func TestCheckPortConflict_EgressPortBlockedLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: int(amneziawgnet.EgressBasePort),
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("a local inbound on the egress port %d must conflict", amneziawgnet.EgressBasePort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
||||
|
||||
@@ -161,6 +161,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
// still carry sessionPlacement/sessionKey; lift them too (same reason as
|
||||
// the per-inbound lift below).
|
||||
xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
|
||||
// Bridge amneziawg outbounds before anything else reads OutboundConfigs;
|
||||
// the core has no amneziawg proxy and would reject the raw entry.
|
||||
if err := transformAmneziaWGOutbounds(xrayConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
json_util "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// transformAmneziaWGOutbounds swaps each template "amneziawg" outbound for
|
||||
// its socks bridge; unbridgeable entries fail generation rather than skip.
|
||||
func transformAmneziaWGOutbounds(cfg *xray.Config) error {
|
||||
if len(cfg.OutboundConfigs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var outbounds []json.RawMessage
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
for i, raw := range outbounds {
|
||||
if !amneziawg.IsAmneziaWGOutbound(raw) {
|
||||
continue
|
||||
}
|
||||
var probe struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
tagErr := json.Unmarshal(raw, &probe)
|
||||
replacement, ok := amneziawgnet.BuildSocksBridge(raw)
|
||||
if !ok {
|
||||
if tagErr != nil {
|
||||
return fmt.Errorf("amneziawg outbound %d: unreadable tag: %w", i, tagErr)
|
||||
}
|
||||
return fmt.Errorf("amneziawg outbound %d (%q): cannot bridge: tag must be a non-empty string", i, probe.Tag)
|
||||
}
|
||||
outbounds[i] = replacement
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
bs, err := json.Marshal(outbounds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.OutboundConfigs = json_util.RawMessage(bs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func amneziawgnetEgressPortForTest() int { return amneziawgnet.EgressBasePort }
|
||||
|
||||
func wgKeypairForTest() (priv, pub string, err error) {
|
||||
return wgutil.GenerateWireguardKeypair()
|
||||
}
|
||||
|
||||
func makeAWGOutboundConfig(t *testing.T) *xray.Config {
|
||||
t.Helper()
|
||||
cfg := &xray.Config{}
|
||||
err := json.Unmarshal([]byte(`{
|
||||
"outbounds": [
|
||||
{"protocol": "freedom", "tag": "direct"},
|
||||
{"protocol": "amneziawg", "tag": "awg-hop", "settings": {"secretKey": "x"}}
|
||||
]
|
||||
}`), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestTransformAmneziaWGOutbounds(t *testing.T) {
|
||||
cfg := makeAWGOutboundConfig(t)
|
||||
if err := transformAmneziaWGOutbounds(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var outbounds []struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
Settings struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
} `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(outbounds) != 2 {
|
||||
t.Fatalf("outbound count = %d, want 2 (no additions or drops)", len(outbounds))
|
||||
}
|
||||
if outbounds[0].Protocol != "freedom" || outbounds[0].Tag != "direct" {
|
||||
t.Fatalf("first outbound disturbed: %+v", outbounds[0])
|
||||
}
|
||||
got := outbounds[1]
|
||||
if got.Protocol != "socks" {
|
||||
t.Fatalf("amneziawg outbound not swapped to socks: %q", got.Protocol)
|
||||
}
|
||||
if got.Tag != "awg-hop" {
|
||||
t.Fatalf("tag not preserved: %q", got.Tag)
|
||||
}
|
||||
if got.Settings.Address != "127.0.0.1" {
|
||||
t.Fatalf("bridge address = %q, want 127.0.0.1", got.Settings.Address)
|
||||
}
|
||||
if got.Settings.Port != amneziawgnetEgressPortForTest() {
|
||||
t.Fatalf("bridge port = %d", got.Settings.Port)
|
||||
}
|
||||
if got.Settings.User != "awg-hop" {
|
||||
t.Fatalf("SOCKS username = %q, want the outbound tag", got.Settings.User)
|
||||
}
|
||||
if got.Settings.Pass == "" {
|
||||
t.Fatal("SOCKS password must be set (egress server enforces it)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformAmneziaWGOutbounds_NoopWithoutAWG(t *testing.T) {
|
||||
before := &xray.Config{}
|
||||
if err := json.Unmarshal([]byte(`{"outbounds":[{"protocol":"freedom","tag":"direct"}]}`), before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &xray.Config{}
|
||||
if err := json.Unmarshal(before.OutboundConfigs, &cfg.OutboundConfigs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orig := json_util.RawMessage(append([]byte(nil), cfg.OutboundConfigs...))
|
||||
if err := transformAmneziaWGOutbounds(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(cfg.OutboundConfigs) != string(orig) {
|
||||
t.Fatalf("config without amneziawg outbounds must stay byte-identical:\nbefore=%s\nafter=%s", orig, cfg.OutboundConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckXrayConfig_AcceptsValidAWGOutbound(t *testing.T) {
|
||||
// A syntactically valid AWG outbound must pass panel-side validation --
|
||||
// the Xray-core loader would reject the unknown protocol outright.
|
||||
priv, pub, err := wgKeypairForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := `{
|
||||
"outbounds": [{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "awg-hop",
|
||||
"settings": {
|
||||
"mtu": 1420,
|
||||
"secretKey": "` + priv + `",
|
||||
"address": ["10.8.0.2/32"],
|
||||
"jc": 4, "jmin": 40, "jmax": 100, "s1": 15, "s2": 80, "s3": 12, "s4": 12,
|
||||
"h1": "100-800", "h2": "900-1600", "h3": "1700-2400", "h4": "2500-3200",
|
||||
"peers": [{
|
||||
"publicKey": "` + pub + `",
|
||||
"allowedIPs": ["0.0.0.0/0"],
|
||||
"endpoint": "203.0.113.7:51820",
|
||||
"keepAlive": 25
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}`
|
||||
svc := &XraySettingService{}
|
||||
if err := svc.CheckXrayConfig(template); err != nil {
|
||||
t.Fatalf("valid amneziawg outbound rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckXrayConfig_RejectsBrokenAWGOutbound(t *testing.T) {
|
||||
// The emptied field's partner must be a real key, or the case is decided
|
||||
// by that partner and stays green with the empty-key guard removed.
|
||||
priv, pub, err := wgKeypairForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
template string
|
||||
}{
|
||||
{
|
||||
name: "not a key",
|
||||
template: `{
|
||||
"outbounds": [{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "awg-bad",
|
||||
"settings": {
|
||||
"secretKey": "not-a-key",
|
||||
"address": ["10.8.0.2/32"],
|
||||
"peers": [{"publicKey": "alsobad", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
|
||||
}
|
||||
}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "empty secretKey",
|
||||
template: `{
|
||||
"outbounds": [{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "awg-empty-sec",
|
||||
"settings": {
|
||||
"secretKey": "",
|
||||
"address": ["10.8.0.2/32"],
|
||||
"peers": [{"publicKey": "` + pub + `", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
|
||||
}
|
||||
}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "empty peer publicKey",
|
||||
template: `{
|
||||
"outbounds": [{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "awg-empty-pub",
|
||||
"settings": {
|
||||
"secretKey": "` + priv + `",
|
||||
"address": ["10.8.0.2/32"],
|
||||
"peers": [{"publicKey": "", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
|
||||
}
|
||||
}]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
svc := &XraySettingService{}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := svc.CheckXrayConfig(tc.template); err == nil {
|
||||
t.Fatalf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformAmneziaWGOutbounds_PreservesSiblingKeys(t *testing.T) {
|
||||
cfg := &xray.Config{}
|
||||
err := json.Unmarshal([]byte(`{
|
||||
"outbounds": [
|
||||
{"protocol": "amneziawg", "tag": "awg-hop", "sendThrough": "0.0.0.0",
|
||||
"targetStrategy": "UseIPv4",
|
||||
"mux": {"enabled": false},
|
||||
"streamSettings": {"sockopt": {"tcpFastOpen": true}},
|
||||
"settings": {"secretKey": "x"}}
|
||||
]
|
||||
}`), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := transformAmneziaWGOutbounds(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var outbounds []struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
SendThrough string `json:"sendThrough"`
|
||||
TargetStrategy string `json:"targetStrategy"`
|
||||
Mux map[string]any `json:"mux"`
|
||||
StreamSettings map[string]any `json:"streamSettings"`
|
||||
}
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(outbounds) != 1 {
|
||||
t.Fatalf("outbound count = %d, want 1", len(outbounds))
|
||||
}
|
||||
got := outbounds[0]
|
||||
if got.SendThrough != "0.0.0.0" {
|
||||
t.Fatalf("sendThrough dropped: %q", got.SendThrough)
|
||||
}
|
||||
if got.TargetStrategy != "UseIPv4" {
|
||||
t.Fatalf("targetStrategy dropped: %q", got.TargetStrategy)
|
||||
}
|
||||
if got.Mux == nil {
|
||||
t.Fatal("mux dropped")
|
||||
}
|
||||
if got.StreamSettings == nil {
|
||||
t.Fatal("streamSettings.sockopt dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformAmneziaWGOutbounds_EmptyTagIsAnError(t *testing.T) {
|
||||
cfg := &xray.Config{}
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"outbounds": [
|
||||
{"protocol": "freedom", "tag": "direct"},
|
||||
{"protocol": "amneziawg", "tag": "", "settings": {"secretKey": "x"}}
|
||||
]
|
||||
}`), cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := transformAmneziaWGOutbounds(cfg); err == nil {
|
||||
t.Fatal("empty-tag amneziawg outbound must fail config generation, not silently pass through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckXrayConfig_RejectsEmptyTagAWGOutbound(t *testing.T) {
|
||||
priv, pub, err := wgKeypairForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := `{
|
||||
"outbounds": [{
|
||||
"protocol": "amneziawg",
|
||||
"tag": "",
|
||||
"settings": {
|
||||
"secretKey": "` + priv + `",
|
||||
"address": ["10.8.0.2/32"],
|
||||
"peers": [{"publicKey": "` + pub + `", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
|
||||
}
|
||||
}]
|
||||
}`
|
||||
svc := &XraySettingService{}
|
||||
if err := svc.CheckXrayConfig(template); err == nil {
|
||||
t.Fatal("empty-tag amneziawg outbound accepted by CheckXrayConfig")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckXrayConfig_RejectsNonStringTagAWGOutbound(t *testing.T) {
|
||||
template := `{
|
||||
"outbounds": [{
|
||||
"protocol": "amneziawg",
|
||||
"tag": 123,
|
||||
"settings": {"secretKey": "x"}
|
||||
}]
|
||||
}`
|
||||
svc := &XraySettingService{}
|
||||
if err := svc.CheckXrayConfig(template); err == nil {
|
||||
t.Fatal("non-string tag amneziawg outbound accepted by CheckXrayConfig")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
@@ -58,6 +59,20 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
coreVersion = process.GetXrayVersion()
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
// Panel pseudo-protocol: validated panel-side because the core's
|
||||
// loader would reject it outright.
|
||||
if amneziawg.IsAmneziaWGOutbound(outbound) {
|
||||
var probe struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(outbound, &probe); err != nil {
|
||||
return common.NewError("xray template config invalid: amneziawg outbound tag unreadable:", err)
|
||||
}
|
||||
if err := amneziawg.ValidateAmneziaWGOutbound(probe.Tag, outbound); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
|
||||
continue
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "يضيف بايتات عشوائية إلى نهاية كل حزمة. يتطلب AmneziaWG 3.1+ على الطرفين.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "عدم إرسال ردود الكوكي — يزيل بصمة DPI لكنه يضعف الحماية من الفيضانات."
|
||||
"disableCookiesHint": "عدم إرسال ردود الكوكي — يزيل بصمة DPI لكنه يضعف الحماية من الفيضانات.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "مستوى المستخدم"
|
||||
|
||||
@@ -1975,7 +1975,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Appends random bytes to every packet. Both ends need AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Never send cookie replies — removes a DPI fingerprint; weakens flood mitigation."
|
||||
"disableCookiesHint": "Never send cookie replies — removes a DPI fingerprint; weakens flood mitigation.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "User Level"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Añade bytes aleatorios a cada paquete. Ambos extremos necesitan AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "No enviar cookie replies — elimina una huella para DPI, pero debilita la mitigación de inundaciones."
|
||||
"disableCookiesHint": "No enviar cookie replies — elimina una huella para DPI, pero debilita la mitigación de inundaciones.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Nivel de Usuario"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "به انتهای هر بسته بایتهای تصادفی میافزاید. هر دو طرف باید AmneziaWG 3.1+ باشند.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "هرگز پاسخ کوکی ارسال نشود — اثر انگشت DPI را حذف میکند اما دفاع در برابر سیلآسا را ضعیف میکند."
|
||||
"disableCookiesHint": "هرگز پاسخ کوکی ارسال نشود — اثر انگشت DPI را حذف میکند اما دفاع در برابر سیلآسا را ضعیف میکند.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "سطح کاربر"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Menambahkan byte acak ke setiap paket. Kedua sisi butuh AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Tidak pernah mengirim cookie reply — menghapus sidik jari DPI, tetapi melemahkan mitigasi banjir."
|
||||
"disableCookiesHint": "Tidak pernah mengirim cookie reply — menghapus sidik jari DPI, tetapi melemahkan mitigasi banjir.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Level Pengguna"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "各パケットにランダムなバイトを追加します。両端にAmneziaWG 3.1+が必要です。",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "cookie replyを送信しません。DPIの指紋を消しますが、フラッド緩和は弱まります。"
|
||||
"disableCookiesHint": "cookie replyを送信しません。DPIの指紋を消しますが、フラッド緩和は弱まります。",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "ユーザーレベル"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Acrescenta bytes aleatórios a cada pacote. Ambos os lados precisam do AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Nunca enviar cookie replies — remove uma impressão digital de DPI, mas enfraquece a mitigação de inundações."
|
||||
"disableCookiesHint": "Nunca enviar cookie replies — remove uma impressão digital de DPI, mas enfraquece a mitigação de inundações.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Nível do Usuário"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Добавляет случайные байты в конец каждого пакета. Обе стороны должны поддерживать AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Не отправлять cookie reply — убирает сигнатуру для DPI, но ослабляет защиту от флуда."
|
||||
"disableCookiesHint": "Не отправлять cookie reply — убирает сигнатуру для DPI, но ослабляет защиту от флуда.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Уровень пользователя"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Her paketin sonuna rastgele baytlar ekler. Her iki uç da AmneziaWG 3.1+ gerektirir.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Cookie reply asla gönderilmez — bir DPI parmak izini kaldırır ancak taşma korumasını zayıflatır."
|
||||
"disableCookiesHint": "Cookie reply asla gönderilmez — bir DPI parmak izini kaldırır ancak taşma korumasını zayıflatır.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Kullanıcı Seviyesi"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Додає випадкові байти в кінець кожного пакета. Обидві сторони мають підтримувати AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Ніколи не надсилати cookie reply — прибирає відбиток для DPI, але послаблює захист від флуду."
|
||||
"disableCookiesHint": "Ніколи не надсилати cookie reply — прибирає відбиток для DPI, але послаблює захист від флуду.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Рівень користувача"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "Thêm các byte ngẫu nhiên vào cuối mỗi gói. Cả hai đầu cần AmneziaWG 3.1+.",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "Không bao giờ gửi cookie reply — xóa một dấu vết DPI nhưng làm yếu khả năng chống flood."
|
||||
"disableCookiesHint": "Không bao giờ gửi cookie reply — xóa một dấu vết DPI nhưng làm yếu khả năng chống flood.",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "Mức Người Dùng"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "在每个数据包末尾追加随机字节。两端都需要 AmneziaWG 3.1+。",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "从不发送 cookie reply——消除一个 DPI 指纹,但会削弱抗洪泛能力。"
|
||||
"disableCookiesHint": "从不发送 cookie reply——消除一个 DPI 指纹,但会削弱抗洪泛能力。",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "用户级别"
|
||||
|
||||
@@ -1857,7 +1857,10 @@
|
||||
"randomTrailers": "RandomTrailers",
|
||||
"randomTrailersHint": "在每個封包結尾附加隨機位元組。兩端都需要 AmneziaWG 3.1+。",
|
||||
"disableCookies": "DisableCookies",
|
||||
"disableCookiesHint": "永不傳送 cookie reply——消除一個 DPI 指紋,但會削弱抗洪泛能力。"
|
||||
"disableCookiesHint": "永不傳送 cookie reply——消除一個 DPI 指紋,但會削弱抗洪泛能力。",
|
||||
"listenPort": "Listen Port (optional)",
|
||||
"listenPortHint": "Fixed local UDP source port. Leave 0 to pick one automatically.",
|
||||
"outboundObfuscationHint": "Must exactly match the server side parameters."
|
||||
},
|
||||
"tun": {
|
||||
"userLevel": "用戶級別"
|
||||
|
||||
@@ -699,6 +699,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
_ = s.xrayService.StopXray()
|
||||
mtproto.GetManager().StopAll()
|
||||
amneziawgnet.GetManager().StopAll()
|
||||
amneziawgnet.GetOutboundManager().StopAll()
|
||||
}
|
||||
if s.cron != nil {
|
||||
s.cron.Stop()
|
||||
|
||||
Reference in New Issue
Block a user