mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-12 08:55:01 +03:00
fix(xray): keep XDRIVE storage and Vision flow within what xray-core runs
xray-core opens an XDRIVE stream's storage only when the inbound starts listening, so a Google Drive inbound saved with the form's defaults (no folder, no secrets), a local one without a folder, or a template one whose template xray cannot use stopped xray and every other inbound with it. validateXDriveStorage applies the core's storage rules at save, on inbound add/update and on the xray template's inbounds and outbounds: the service must be local, Google Drive or template; local and Google Drive need a remoteFolder; Google Drive needs its three non-empty secrets; a template must pass newTemplateStorage's checks (put, get, list and delete urls, and a list namesRegex with one capture group). A VLESS client kept its xtls-rprx-vision flow when its inbound moved to a transport that cannot carry it, and GetXrayConfig only honoured DisableFlow, so the server demanded Vision there and refused every client. GetXrayConfig and the outbound form now apply the Vision gate, and that gate now follows the core in the backend, the subscription service and the frontend: TLS/REALITY on raw TCP, or VLESS encryption on any transport (xray runs Vision inside encryption.CommonConn whatever the transport, not only on XHTTP). The outbound modal passes the VLESS encryption into the gate and gates Mux on the effective flow.
This commit is contained in:
@@ -204,7 +204,7 @@ The `xtls-rprx-vision` flow is fast and DPI-resistant. It's available for
|
||||
|
||||
- the transport is raw **TCP** with **TLS** or **REALITY** security (classic
|
||||
XTLS-Vision), or
|
||||
- the transport is **XHTTP** with VLESS encryption enabled (see below).
|
||||
- VLESS encryption is enabled, on any transport (see below).
|
||||
|
||||
Set the flow on the VLESS **client**, not the inbound. With classic Vision on
|
||||
TCP, the panel can also offer a **Vision seed** once a client uses the flow.
|
||||
@@ -213,7 +213,7 @@ TCP, the panel can also offer a **Vision seed** once a client uses the flow.
|
||||
|
||||
VLESS supports post-quantum **encryption** (ML-KEM / `mlkem768x25519`), stored in
|
||||
the inbound's `decryption` (server) and clients' `encryption` (for link
|
||||
generation). When enabled, it unlocks the Vision flow over XHTTP. Generate the
|
||||
generation). When enabled, it unlocks the Vision flow on any transport. Generate the
|
||||
keys from the panel's VLESS settings.
|
||||
|
||||
## Shadowsocks ciphers
|
||||
|
||||
@@ -205,7 +205,7 @@ mKCP و Hysteria لایه TLS/REALITY جداگانهای نمیگیرند
|
||||
|
||||
- انتقال، **TCP** خام با امنیت **TLS** یا **REALITY** باشد (XTLS-Vision
|
||||
کلاسیک)، یا
|
||||
- انتقال، **XHTTP** با رمزنگاری VLESS فعال باشد (به ادامه مراجعه کنید).
|
||||
- رمزنگاری VLESS فعال باشد، روی هر انتقالی (به ادامه مراجعه کنید).
|
||||
|
||||
جریان را روی **کلاینت** VLESS تنظیم کنید، نه روی inbound. با Vision کلاسیک روی
|
||||
TCP، پنل میتواند پس از آنکه یک کلاینت از جریان استفاده کرد، یک **Vision seed** نیز
|
||||
@@ -215,7 +215,7 @@ TCP، پنل میتواند پس از آنکه یک کلاینت از جری
|
||||
|
||||
VLESS از **رمزنگاری** پساکوانتومی (ML-KEM / `mlkem768x25519`) پشتیبانی میکند که در
|
||||
`decryption` مربوط به inbound (سمت سرور) و `encryption` کلاینتها (برای تولید
|
||||
لینک) ذخیره میشود. وقتی فعال باشد، جریان Vision را روی XHTTP باز میکند. کلیدها
|
||||
لینک) ذخیره میشود. وقتی فعال باشد، جریان Vision را روی هر انتقالی باز میکند. کلیدها
|
||||
را از تنظیمات VLESS در پنل تولید کنید.
|
||||
|
||||
## رمزهای Shadowsocks
|
||||
|
||||
@@ -207,7 +207,7 @@ mKCP и Hysteria не используют отдельный уровень TLS
|
||||
|
||||
- транспорт — это «сырой» **TCP** с безопасностью **TLS** или **REALITY**
|
||||
(классический XTLS-Vision), либо
|
||||
- транспорт — это **XHTTP** с включённым шифрованием VLESS (см. ниже).
|
||||
- включено шифрование VLESS, на любом транспорте (см. ниже).
|
||||
|
||||
Поток задаётся на **клиенте** VLESS, а не на inbound. При классическом Vision
|
||||
поверх TCP панель также может предложить **Vision seed** после того, как клиент
|
||||
@@ -218,7 +218,7 @@ mKCP и Hysteria не используют отдельный уровень TLS
|
||||
VLESS поддерживает постквантовое **шифрование** (ML-KEM / `mlkem768x25519`),
|
||||
которое хранится в поле `decryption` у inbound (сервер) и в поле `encryption` у
|
||||
клиентов (для генерации ссылок). Когда оно включено, становится доступен поток
|
||||
Vision поверх XHTTP. Сгенерируйте ключи в настройках VLESS на панели.
|
||||
Vision на любом транспорте. Сгенерируйте ключи в настройках VLESS на панели.
|
||||
|
||||
## Шифры Shadowsocks
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ mKCP 和 Hysteria 不使用单独的 TLS/REALITY 层——mKCP 以明文运行
|
||||
`xtls-rprx-vision` 流控既快速又能抵抗 DPI。在以下任一情况下,它都可用于 **VLESS**:
|
||||
|
||||
- 传输方式为裸 **TCP**,安全层为 **TLS** 或 **REALITY**(经典的 XTLS-Vision),或者
|
||||
- 传输方式为 **XHTTP** 且启用了 VLESS 加密(见下文)。
|
||||
- 启用了 VLESS 加密,任意传输方式均可(见下文)。
|
||||
|
||||
请在 VLESS **客户端**上设置流控,而不是在入站上。在 TCP 上使用经典 Vision 时,一旦某个
|
||||
客户端使用该流控,面板还可以提供一个 **Vision seed**。
|
||||
@@ -193,7 +193,7 @@ mKCP 和 Hysteria 不使用单独的 TLS/REALITY 层——mKCP 以明文运行
|
||||
## VLESS 加密(ML-KEM)
|
||||
|
||||
VLESS 支持后量子**加密**(ML-KEM / `mlkem768x25519`),它存储在入站的 `decryption`
|
||||
(服务器)以及客户端的 `encryption`(用于生成链接)中。启用后,它会解锁在 XHTTP
|
||||
(服务器)以及客户端的 `encryption`(用于生成链接)中。启用后,它会解锁任意传输方式
|
||||
之上的 Vision 流控。请从面板的 VLESS 设置中生成密钥。
|
||||
|
||||
## Shadowsocks 加密方式
|
||||
|
||||
@@ -503,10 +503,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
||||
params.set('security', 'none');
|
||||
}
|
||||
|
||||
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
|
||||
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
|
||||
// vlessFlowAllowed and the form's flow-field gating so panel link, share
|
||||
// link and subscription agree.
|
||||
// Same gate as the backend's vlessFlowAllowed and the form's flow field, so the
|
||||
// panel link, share link and subscription agree.
|
||||
if (
|
||||
flow.length > 0 &&
|
||||
canEnableTlsFlow({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
import { OutboundDomainStrategySchema } from '@/schemas/protocols/outbound';
|
||||
import { AmneziaWGOutboundSettingsSchema } from '@/schemas/protocols/outbound';
|
||||
import { canEnableTlsFlow } from '@/lib/xray/protocol-capabilities';
|
||||
import {
|
||||
dropEmptyFinalMask,
|
||||
normalizeStreamSettingsForWire,
|
||||
@@ -902,10 +903,23 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
||||
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
|
||||
}
|
||||
|
||||
// The flow select only hides off a Vision-capable transport; its stale value must not reach xray.
|
||||
export function effectiveVlessFlow(
|
||||
settings: Pick<VlessOutboundFormSettings, 'flow' | 'encryption'>,
|
||||
stream: { network?: string; security?: string } | undefined,
|
||||
): string {
|
||||
const allowed = canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { encryption: settings.encryption },
|
||||
streamSettings: { network: stream?.network, security: stream?.security },
|
||||
});
|
||||
return allowed ? settings.flow : '';
|
||||
}
|
||||
|
||||
function muxAllowed(values: OutboundFormValues): boolean {
|
||||
if (!MUX_PROTOCOLS.has(values.protocol)) return false;
|
||||
const flow =
|
||||
values.protocol === 'vless' ? (values.settings as VlessOutboundFormSettings).flow : '';
|
||||
values.protocol === 'vless' ? effectiveVlessFlow(values.settings, values.streamSettings) : '';
|
||||
if (flow) return false;
|
||||
const network =
|
||||
values.streamSettings && 'network' in values.streamSettings
|
||||
@@ -924,7 +938,10 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
|
||||
settings = vmessToWire(values.settings);
|
||||
break;
|
||||
case 'vless':
|
||||
settings = vlessToWire(values.settings);
|
||||
settings = vlessToWire({
|
||||
...values.settings,
|
||||
flow: effectiveVlessFlow(values.settings, values.streamSettings),
|
||||
});
|
||||
break;
|
||||
case 'trojan':
|
||||
settings = trojanToWire(values.settings);
|
||||
|
||||
@@ -74,10 +74,8 @@ export function canEnableTlsFlow(values: CapabilityProtocolSlice): boolean {
|
||||
// Classic XTLS Vision: raw TCP carried over TLS or REALITY.
|
||||
if (network === 'tcp' && (security === 'tls' || security === 'reality')) return true;
|
||||
|
||||
// vlessenc carries Vision over XHTTP without transport TLS.
|
||||
if (network === 'xhttp' && hasVlessEncryption(values.settings)) return true;
|
||||
|
||||
return false;
|
||||
// xray-core runs Vision inside VLESS encryption (encryption.CommonConn) on any transport.
|
||||
return hasVlessEncryption(values.settings);
|
||||
}
|
||||
|
||||
export function canEnableStream(values: { protocol: string }): boolean {
|
||||
|
||||
@@ -104,7 +104,11 @@ export default function OutboundFormModal({
|
||||
'') as string;
|
||||
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
|
||||
'none') as string;
|
||||
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
|
||||
const storedFlow = (useWatch({ control: methods.control, name: 'settings.flow' }) ??
|
||||
'') as string;
|
||||
const encryption = useWatch({ control: methods.control, name: 'settings.encryption' }) as
|
||||
| string
|
||||
| undefined;
|
||||
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
|
||||
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
|
||||
| string
|
||||
@@ -113,7 +117,12 @@ export default function OutboundFormModal({
|
||||
const streamAllowed = canEnableStream({ protocol });
|
||||
const tlsAllowed = canEnableOutboundTls({ protocol, streamSettings: { network, security } });
|
||||
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
|
||||
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
|
||||
const tlsFlowAllowed = canEnableTlsFlow({
|
||||
protocol,
|
||||
settings: { encryption },
|
||||
streamSettings: { network, security },
|
||||
});
|
||||
const flow = tlsFlowAllowed ? storedFlow : '';
|
||||
|
||||
/*
|
||||
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
|
||||
@@ -527,7 +536,7 @@ export default function OutboundFormModal({
|
||||
|
||||
{/* Vision seed knobs only meaningful for the exact
|
||||
xtls-rprx-vision flow, on TCP+(tls|reality). */}
|
||||
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
|
||||
{flow === 'xtls-rprx-vision' && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.visionTestpre')}
|
||||
@@ -590,7 +599,7 @@ export default function OutboundFormModal({
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuxForm protocol={protocol} network={network} />
|
||||
<MuxForm protocol={protocol} network={network} flow={flow} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -9,12 +9,12 @@ import { isMuxAllowed } from '../outbound-form-helpers';
|
||||
interface MuxFormProps {
|
||||
protocol: string;
|
||||
network: string;
|
||||
flow?: string;
|
||||
}
|
||||
|
||||
export default function MuxForm({ protocol, network }: MuxFormProps) {
|
||||
export default function MuxForm({ protocol, network, flow = '' }: MuxFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string;
|
||||
const muxEnabled = !!useWatch({ control, name: 'mux.enabled' });
|
||||
if (!isMuxAllowed(protocol, flow, network)) return null;
|
||||
return (
|
||||
|
||||
@@ -226,12 +226,21 @@ describe('protocol-capability helpers with raw coerced shapes', () => {
|
||||
streamSettings: { network: 'xhttp', security: 'none' },
|
||||
}),
|
||||
).toBe(false);
|
||||
// vlessenc is XHTTP-only: TCP without tls/reality is not Vision-capable.
|
||||
// xray-core runs Vision inside VLESS encryption on any transport, RAW and WS included.
|
||||
for (const network of ['tcp', 'ws', 'grpc', 'xdrive']) {
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { decryption: dec, encryption: enc },
|
||||
streamSettings: { network, security: 'none' },
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
canEnableTlsFlow({
|
||||
protocol: 'vless',
|
||||
settings: { decryption: dec, encryption: enc },
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
settings: { encryption: 'none' },
|
||||
streamSettings: { network: 'ws', security: 'tls' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption: 'none',
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'reality' },
|
||||
};
|
||||
const form = rawOutboundToFormValues(wire);
|
||||
expect(form.protocol).toBe('vless');
|
||||
@@ -119,6 +120,64 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
expect(settings.reverse).toMatchObject({ tag: 'rev-1' });
|
||||
});
|
||||
|
||||
// The flow select hides off TCP+TLS/REALITY but keeps its value; xray then fails every dial.
|
||||
it('vless drops a Vision flow its transport cannot carry, and lets mux back on', () => {
|
||||
const outbound = (streamSettings: Record<string, unknown>) =>
|
||||
formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 'srv',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption: 'none',
|
||||
testpre: 5,
|
||||
},
|
||||
streamSettings,
|
||||
mux: { enabled: true, concurrency: 8 },
|
||||
}),
|
||||
);
|
||||
|
||||
const xdrive = outbound({
|
||||
network: 'xdrive',
|
||||
security: 'none',
|
||||
xdriveSettings: { service: 'local', remoteFolder: '/tmp/shared' },
|
||||
});
|
||||
expect(xdrive.settings).toMatchObject({ flow: '' });
|
||||
expect(xdrive.settings).not.toHaveProperty('testpre');
|
||||
expect(xdrive.mux).toMatchObject({ enabled: true });
|
||||
|
||||
const ws = outbound({ network: 'ws', security: 'tls', wsSettings: { path: '/' } });
|
||||
expect(ws.settings).toMatchObject({ flow: '' });
|
||||
|
||||
const reality = outbound({
|
||||
network: 'tcp',
|
||||
security: 'reality',
|
||||
realitySettings: { serverName: 'a.example', publicKey: 'k', fingerprint: 'chrome' },
|
||||
});
|
||||
expect(reality.settings).toMatchObject({ flow: 'xtls-rprx-vision', testpre: 5 });
|
||||
expect(reality).not.toHaveProperty('mux');
|
||||
});
|
||||
|
||||
// VLESS encryption carries Vision on any transport; stripping it gets the server's "client flow is empty".
|
||||
it('vless keeps a Vision flow over VLESS encryption on RAW without TLS', () => {
|
||||
const wire = formValuesToWirePayload(
|
||||
rawOutboundToFormValues({
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
address: 'srv',
|
||||
port: 443,
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption: 'mlkem768x25519plus.native.0rtt.cbEXq-_hfkxDUex6HqmSaemEELvnFWc8qQ1MxlNDHnE',
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
}),
|
||||
);
|
||||
expect(wire.settings).toMatchObject({ flow: 'xtls-rprx-vision' });
|
||||
});
|
||||
|
||||
it('vless does not emit testpre/testseed unless flow is vision', () => {
|
||||
const wire = {
|
||||
protocol: 'vless',
|
||||
@@ -543,6 +602,7 @@ describe('outbound-form-adapter: round-trip', () => {
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption: 'none',
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'reality' },
|
||||
mux: { enabled: true },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -310,6 +310,60 @@ describe('OutboundFormModal', () => {
|
||||
|
||||
// xray-core reads Google Drive's three secrets by position (ClientID, ClientSecret,
|
||||
// RefreshToken); a misordered list fails OAuth only when the tunnel first dials.
|
||||
it('gates Flow and Mux on the flow the transport can actually carry', async () => {
|
||||
const vless = (encryption: string, streamSettings: Record<string, unknown>) => ({
|
||||
protocol: 'vless',
|
||||
tag: 'flow-out',
|
||||
settings: {
|
||||
address: 'example.com',
|
||||
port: 443,
|
||||
id: 'c9f0c2d0-0000-4000-8000-000000000000',
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption,
|
||||
},
|
||||
streamSettings,
|
||||
});
|
||||
const openModal = async (outbound: Record<string, unknown>) => {
|
||||
const queryClient = makeTestQueryClient();
|
||||
const tree = (open: boolean) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<OutboundFormModal
|
||||
open={open}
|
||||
outbound={outbound}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree(false));
|
||||
view.rerender(tree(true));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
return view;
|
||||
};
|
||||
|
||||
const ws = await openModal(
|
||||
vless('none', { network: 'ws', security: 'tls', wsSettings: { path: '/' } }),
|
||||
);
|
||||
expect(formItemByLabel('Transmission')?.textContent).toContain('WebSocket');
|
||||
expect(formItemByLabel('Flow')).toBeUndefined();
|
||||
expect(formItemByLabel('Mux')).toBeDefined();
|
||||
ws.unmount();
|
||||
|
||||
await openModal(
|
||||
vless('mlkem768x25519plus.native.0rtt.key', {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: { path: '/', mode: 'auto' },
|
||||
}),
|
||||
);
|
||||
expect(formItemByLabel('Flow')).toBeDefined();
|
||||
});
|
||||
|
||||
it('saves an XDRIVE stream picked in the transmission selector', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const queryClient = makeTestQueryClient();
|
||||
|
||||
+5
-13
@@ -1193,21 +1193,13 @@ func vlessEncryptionEnabled(settings map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// vlessFlowAllowed reports whether a client's XTLS Vision flow belongs in
|
||||
// generated links/configs. Mirrors inboundCanEnableTlsFlow in
|
||||
// internal/web/service: Vision runs on TCP with tls/reality (classic), and on
|
||||
// XHTTP whenever VLESS encryption (vlessenc / ML-KEM) is enabled — there the
|
||||
// VLESS-level encryption stands in for the transport TLS that Vision relies
|
||||
// on, regardless of the stream security layer (so XHTTP+REALITY+vlessenc
|
||||
// keeps its flow too).
|
||||
// vlessFlowAllowed mirrors inboundCanEnableTlsFlow in internal/web/service: Vision runs over
|
||||
// TLS/REALITY on raw TCP, or inside VLESS encryption on any transport.
|
||||
func vlessFlowAllowed(network, security string, settings map[string]any) bool {
|
||||
switch network {
|
||||
case "tcp":
|
||||
return security == "tls" || security == "reality"
|
||||
case "xhttp":
|
||||
return vlessEncryptionEnabled(settings)
|
||||
if network == "tcp" && (security == "tls" || security == "reality") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return vlessEncryptionEnabled(settings)
|
||||
}
|
||||
|
||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
|
||||
@@ -26,7 +26,8 @@ func TestVlessFlowAllowed(t *testing.T) {
|
||||
{"tcp tls", "tcp", "tls", noEnc, true},
|
||||
{"tcp reality", "tcp", "reality", noEnc, true},
|
||||
{"tcp none", "tcp", "none", noEnc, false},
|
||||
{"tcp none vlessenc", "tcp", "none", enc, false},
|
||||
{"tcp none vlessenc", "tcp", "none", enc, true},
|
||||
{"ws none vlessenc", "ws", "none", enc, true},
|
||||
{"xhttp none vlessenc", "xhttp", "none", enc, true},
|
||||
{"xhttp reality vlessenc (#5232)", "xhttp", "reality", enc, true},
|
||||
{"xhttp tls vlessenc", "xhttp", "tls", enc, true},
|
||||
|
||||
@@ -26,9 +26,8 @@ func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) {
|
||||
{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, "", ""},
|
||||
{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, "", ""},
|
||||
{"empty stream clears flow", model.VLESS, "", "", ""},
|
||||
// vlessenc (ML-KEM) keeps Vision flow without transport TLS only on XHTTP.
|
||||
// TCP without tls/reality clears it even with vlessenc set.
|
||||
{"vless tcp vlessenc clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, enc, ""},
|
||||
// vlessenc (ML-KEM) carries Vision without transport TLS on any transport.
|
||||
{"vless tcp vlessenc keeps flow", model.VLESS, `{"network":"tcp","security":"none"}`, enc, vision},
|
||||
{"vless xhttp vlessenc keeps flow", model.VLESS, `{"network":"xhttp","security":"none"}`, enc, vision},
|
||||
{"vless xhttp no encryption clears flow", model.VLESS, `{"network":"xhttp","security":"none"}`, `{"encryption":"none"}`, ""},
|
||||
{"vless xhttp empty settings clears flow", model.VLESS, `{"network":"xhttp","security":"none"}`, "", ""},
|
||||
|
||||
@@ -1005,6 +1005,78 @@ func validateFinalMaskSalamander(streamSettings string) error {
|
||||
return maskcompat.CheckSalamanderPasswords(stream["finalmask"])
|
||||
}
|
||||
|
||||
// validateXDriveStorage refuses at save the XDRIVE storage xray-core refuses when an inbound starts
|
||||
// listening (stopping every inbound) or an outbound dials.
|
||||
func validateXDriveStorage(streamSettings string) error {
|
||||
var stream struct {
|
||||
Network string `json:"network"`
|
||||
XDriveSettings struct {
|
||||
Service string `json:"service"`
|
||||
RemoteFolder string `json:"remoteFolder"`
|
||||
Secrets []string `json:"secrets"`
|
||||
Template json.RawMessage `json:"template"`
|
||||
} `json:"xdriveSettings"`
|
||||
}
|
||||
if json.Unmarshal([]byte(streamSettings), &stream) != nil || stream.Network != "xdrive" {
|
||||
return nil
|
||||
}
|
||||
xd := stream.XDriveSettings
|
||||
hasFolder := strings.TrimSpace(xd.RemoteFolder) != ""
|
||||
switch xd.Service {
|
||||
case "local":
|
||||
if !hasFolder {
|
||||
return common.NewError(`XDRIVE needs a "remoteFolder": the local folder both ends share.`)
|
||||
}
|
||||
case "Google Drive":
|
||||
if !hasFolder {
|
||||
return common.NewError(`XDRIVE needs a "remoteFolder": the id of the shared Google Drive folder.`)
|
||||
}
|
||||
if len(xd.Secrets) != 3 || slices.ContainsFunc(xd.Secrets, func(s string) bool { return strings.TrimSpace(s) == "" }) {
|
||||
return common.NewError("Google Drive needs 3 secrets, in order: ClientID, ClientSecret, RefreshToken.")
|
||||
}
|
||||
case "template":
|
||||
if len(xd.Template) == 0 || string(xd.Template) == "null" {
|
||||
return common.NewError(`XDRIVE service "template" needs a "template" object describing the storage API.`)
|
||||
}
|
||||
return validateXDriveTemplate(xd.Template)
|
||||
default:
|
||||
return common.NewError(fmt.Sprintf("unsupported XDRIVE service %q: use local, Google Drive or template.", xd.Service))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateXDriveTemplate applies the checks of xray-core's newTemplateStorage
|
||||
// (transport/internet/xdrive/template.go), which only run once the listener starts.
|
||||
func validateXDriveTemplate(raw json.RawMessage) error {
|
||||
type op struct {
|
||||
URL string `json:"url"`
|
||||
NamesRegex string `json:"namesRegex"`
|
||||
}
|
||||
var tmpl struct {
|
||||
Put op `json:"put"`
|
||||
Get op `json:"get"`
|
||||
Delete op `json:"delete"`
|
||||
List op `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tmpl); err != nil {
|
||||
return common.NewError("XDRIVE template is not a valid storage template:", err)
|
||||
}
|
||||
if tmpl.Put.URL == "" || tmpl.Get.URL == "" || tmpl.List.URL == "" || tmpl.Delete.URL == "" {
|
||||
return common.NewError("XDRIVE template needs put, get, list and delete operations, each with a url.")
|
||||
}
|
||||
if tmpl.List.NamesRegex == "" {
|
||||
return common.NewError("XDRIVE template list needs a namesRegex.")
|
||||
}
|
||||
names, err := regexp.Compile(tmpl.List.NamesRegex)
|
||||
if err != nil {
|
||||
return common.NewError("XDRIVE template namesRegex does not compile:", err)
|
||||
}
|
||||
if names.NumSubexp() < 1 {
|
||||
return common.NewError("XDRIVE template namesRegex needs one capture group for the object name.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFinalMaskJSONSalamander checks a stored whole finalmask: a host's, or the subscription's.
|
||||
func validateFinalMaskJSONSalamander(finalmask string) error {
|
||||
var fm map[string]any
|
||||
@@ -1178,6 +1250,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateXDriveStorage(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateShadowsocks2022Inbound(inbound.Protocol, inbound.Settings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
@@ -1826,6 +1901,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
if err := validateFinalMaskSalamander(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateXDriveStorage(inbound.StreamSettings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if err := validateShadowsocks2022Inbound(inbound.Protocol, inbound.Settings); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
|
||||
@@ -27,35 +27,21 @@ func inboundShadowsocksMethod(protocol, settings string) string {
|
||||
return s.Method
|
||||
}
|
||||
|
||||
// inboundCanEnableTlsFlow mirrors canEnableTlsFlow() from the frontend
|
||||
// (frontend/src/lib/xray/protocol-capabilities.ts). XTLS Vision is valid for
|
||||
// VLESS on TCP with tls or reality (classic), and on XHTTP when VLESS encryption
|
||||
// (vlessenc / ML-KEM) is enabled — there the post-quantum, VLESS-level
|
||||
// encryption stands in for the transport TLS that Vision relies on. settings is
|
||||
// the inbound's raw settings JSON, which carries the encryption value
|
||||
// (streamSettings does not).
|
||||
// inboundCanEnableTlsFlow mirrors the frontend's canEnableTlsFlow: xray-core runs Vision over
|
||||
// TLS/REALITY on raw TCP, or inside VLESS encryption (encryption.CommonConn) on any transport.
|
||||
func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
|
||||
if protocol != string(model.VLESS) {
|
||||
return false
|
||||
}
|
||||
if streamSettings == "" {
|
||||
return false
|
||||
}
|
||||
var stream struct {
|
||||
Network string `json:"network"`
|
||||
Security string `json:"security"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return false
|
||||
}
|
||||
switch stream.Network {
|
||||
case "tcp":
|
||||
return stream.Security == "tls" || stream.Security == "reality"
|
||||
case "xhttp":
|
||||
return vlessEncryptionEnabled(settings)
|
||||
default:
|
||||
return false
|
||||
if json.Unmarshal([]byte(streamSettings), &stream) == nil && stream.Network == "tcp" &&
|
||||
(stream.Security == "tls" || stream.Security == "reality") {
|
||||
return true
|
||||
}
|
||||
return vlessEncryptionEnabled(settings)
|
||||
}
|
||||
|
||||
// nodeEligibleProtocols mirrors the frontend's NODE_ELIGIBLE_PROTOCOLS. A sidecar
|
||||
@@ -141,7 +127,7 @@ func vlessEncValueSet(v string) bool {
|
||||
// inboundCanHostFallbacks gates the settings.fallbacks injection.
|
||||
// Xray only honors fallbacks on VLESS and Trojan inbounds carried over
|
||||
// TCP transport with TLS or Reality security. This is intentionally stricter
|
||||
// than inboundCanEnableTlsFlow (which also accepts XHTTP+vlessenc): fallbacks
|
||||
// than inboundCanEnableTlsFlow (which also accepts any vlessenc stream): fallbacks
|
||||
// are a raw-TCP-only feature.
|
||||
func inboundCanHostFallbacks(ib *model.Inbound) bool {
|
||||
if ib == nil {
|
||||
|
||||
@@ -26,12 +26,11 @@ func TestInboundCanEnableTlsFlow(t *testing.T) {
|
||||
{"vmess tcp tls", string(model.VMESS), `{"network":"tcp","security":"tls"}`, "", false},
|
||||
{"empty stream", string(model.VLESS), "", "", false},
|
||||
|
||||
// vlessenc is gated to XHTTP only. TCP without tls/reality is NOT
|
||||
// Vision-capable even with vlessenc set — the combination only works on
|
||||
// XHTTP in practice.
|
||||
{"vless tcp vlessenc not capable", string(model.VLESS), `{"network":"tcp","security":"none"}`, `{"decryption":"mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E","encryption":"mlkem768x25519plus.native.0rtt.hT4AY_tPWY9NVuKR3BIXxXq6zx9DqN2X86QPYW09XEM"}`, false},
|
||||
// ws is a framed transport — vlessenc never enables Vision there.
|
||||
{"vless ws vlessenc still off", string(model.VLESS), `{"network":"ws","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, false},
|
||||
// xray-core runs Vision inside VLESS encryption on any transport (encryption.CommonConn).
|
||||
{"vless raw vlessenc", string(model.VLESS), `{"network":"tcp","security":"none"}`, `{"decryption":"mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E","encryption":"mlkem768x25519plus.native.0rtt.hT4AY_tPWY9NVuKR3BIXxXq6zx9DqN2X86QPYW09XEM"}`, true},
|
||||
{"vless ws vlessenc", string(model.VLESS), `{"network":"ws","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true},
|
||||
{"vless xdrive vlessenc", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"` + vlessEncValue + `"}`, true},
|
||||
{"vless xdrive no encryption", string(model.VLESS), `{"network":"xdrive","security":"none"}`, `{"decryption":"none"}`, false},
|
||||
|
||||
// XHTTP + VLESS encryption (the #5157 case).
|
||||
{"vless xhttp vlessenc", string(model.VLESS), `{"network":"xhttp","security":"none"}`, `{"encryption":"` + vlessEncValue + `"}`, true},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func xdriveInbound(port int, xdriveSettings string) *model.Inbound {
|
||||
return &model.Inbound{
|
||||
Enable: true, Listen: "127.0.0.1", Port: port, Protocol: model.VLESS,
|
||||
Settings: `{"clients":[],"decryption":"none"}`,
|
||||
StreamSettings: `{"network":"xdrive","security":"none","xdriveSettings":` + xdriveSettings + `}`,
|
||||
}
|
||||
}
|
||||
|
||||
// xray-core opens XDRIVE storage when the listener starts; a storage it refuses there stops every inbound.
|
||||
func TestAddInboundRefusesXDriveStorageTheCoreCannotOpen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings string
|
||||
want string
|
||||
}{
|
||||
{"local without folder", `{"service":"local","remoteFolder":""}`, `XDRIVE needs a "remoteFolder"`},
|
||||
{"google drive without folder", `{"service":"Google Drive","remoteFolder":" ","secrets":["id","secret","token"]}`, `XDRIVE needs a "remoteFolder"`},
|
||||
{"google drive with two secrets", `{"service":"Google Drive","remoteFolder":"f","secrets":["id","secret"]}`, "Google Drive needs 3 secrets"},
|
||||
{"google drive with an empty secret", `{"service":"Google Drive","remoteFolder":"f","secrets":["id","","token"]}`, "Google Drive needs 3 secrets"},
|
||||
{"template without template", `{"service":"template","remoteFolder":"f"}`, `XDRIVE service "template" needs a "template" object`},
|
||||
{"template that is empty", `{"service":"template","remoteFolder":"f","template":{}}`, "XDRIVE template needs put, get, list and delete operations"},
|
||||
{"template that is not an object", `{"service":"template","remoteFolder":"f","template":"put"}`, "XDRIVE template is not a valid storage template"},
|
||||
{"template without a delete op", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"(.+)"}}}`, "XDRIVE template needs put, get, list and delete operations"},
|
||||
{"template without namesRegex", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u"},"delete":{"url":"u"}}}`, "XDRIVE template list needs a namesRegex"},
|
||||
{"template namesRegex without a group", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"name"},"delete":{"url":"u"}}}`, "XDRIVE template namesRegex needs one capture group"},
|
||||
{"unknown service", `{"service":"Dropbox","remoteFolder":"f"}`, `unsupported XDRIVE service "Dropbox"`},
|
||||
}
|
||||
for i, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
_, _, err := (&InboundService{}).AddInbound(xdriveInbound(18460+i, tc.settings))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("AddInbound err = %v, want one containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddInboundAcceptsACompleteXDriveTemplate(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
tmpl := `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"\"name\":\"([^\"]+)\""},"delete":{"url":"u"}}}`
|
||||
if _, _, err := (&InboundService{}).AddInbound(xdriveInbound(18480, tmpl)); err != nil {
|
||||
t.Fatalf("AddInbound of a template xray-core can open: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePathsRefuseXDriveWithoutStorage(t *testing.T) {
|
||||
const want = `XDRIVE needs a "remoteFolder"`
|
||||
const empty = `{"service":"local","remoteFolder":""}`
|
||||
tests := []struct {
|
||||
name string
|
||||
save func(t *testing.T) error
|
||||
}{
|
||||
{"update inbound", func(t *testing.T) error {
|
||||
in, _, err := (&InboundService{}).AddInbound(xdriveInbound(18470, `{"service":"local","remoteFolder":"/tmp/drive"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("AddInbound of a complete XDRIVE inbound: %v", err)
|
||||
}
|
||||
in.StreamSettings = xdriveInbound(18470, empty).StreamSettings
|
||||
_, _, err = (&InboundService{}).UpdateInbound(in)
|
||||
return err
|
||||
}},
|
||||
{"xray template inbound", func(t *testing.T) error {
|
||||
return (&XraySettingService{}).SaveXraySetting(`{"inbounds":[{"tag":"drive","port":18471,"protocol":"vless",
|
||||
"settings":{"clients":[],"decryption":"none"},"streamSettings":` + xdriveInbound(18471, empty).StreamSettings + `}],
|
||||
"outbounds":[{"protocol":"freedom","tag":"direct"}]}`)
|
||||
}},
|
||||
{"xray template outbound", func(t *testing.T) error {
|
||||
return (&XraySettingService{}).SaveXraySetting(`{"outbounds":[{"protocol":"freedom","tag":"direct"},
|
||||
{"protocol":"vless","tag":"drive-out","settings":{"address":"drive.example","port":443,"id":"11111111-2222-4333-8444-555555555555","encryption":"none"},
|
||||
"streamSettings":` + xdriveInbound(0, empty).StreamSettings + `}]}`)
|
||||
}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
if err := tc.save(t); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("save err = %v, want one containing %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,8 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
}
|
||||
settings := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
flowAllowed := !inbound.DisableFlow &&
|
||||
inboundCanEnableTlsFlow(string(inbound.Protocol), inbound.StreamSettings, inbound.Settings)
|
||||
var wireguardClientsByEmail map[string]model.Client
|
||||
if inbound.Protocol == model.WireGuard {
|
||||
inboundClients, _ := ParseInboundSettingsClients(inbound.Settings)
|
||||
@@ -245,7 +247,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
if flow == "xtls-rprx-vision-udp443" {
|
||||
flow = "xtls-rprx-vision"
|
||||
}
|
||||
if inbound.DisableFlow {
|
||||
if !flowAllowed {
|
||||
flow = ""
|
||||
}
|
||||
entry := map[string]any{"email": c.Email}
|
||||
|
||||
@@ -107,3 +107,71 @@ func TestGetXrayConfig_EnabledClientsStillEmitted(t *testing.T) {
|
||||
t.Errorf("client id not carried through: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// A flow kept from a TCP+REALITY stream makes xray demand Vision on a transport that cannot run it
|
||||
// without VLESS encryption: "XTLS only supports TLS and REALITY directly".
|
||||
func TestGetXrayConfig_OmitsVisionFlowOnIneligibleStream(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stream string
|
||||
}{
|
||||
{"xdrive", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`},
|
||||
{"ws over tls", `{"network":"ws","security":"tls","wsSettings":{"path":"/"}}`},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tag := "stale-flow"
|
||||
seedVlessInbound(t, tag, 43110+i, []model.Client{
|
||||
{Email: "vision@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Flow: "xtls-rprx-vision"},
|
||||
})
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).
|
||||
Update("stream_settings", tc.stream).Error; err != nil {
|
||||
t.Fatalf("set stream: %v", err)
|
||||
}
|
||||
value, _ := emittedClients(t, tag)
|
||||
list, ok := value.([]any)
|
||||
if !ok || len(list) != 1 {
|
||||
t.Fatalf("settings.clients = %#v, want the one enabled client", value)
|
||||
}
|
||||
entry, _ := list[0].(map[string]any)
|
||||
if flow, present := entry["flow"]; present {
|
||||
t.Fatalf("flow %q emitted for a %s stream", flow, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// xray-core runs Vision over TLS/REALITY on RAW, or over VLESS encryption on any transport.
|
||||
func TestGetXrayConfig_KeepsVisionFlowWhereTheCoreRunsIt(t *testing.T) {
|
||||
const vlessenc = `{"clients":[],"decryption":"mlkem768x25519plus.native.600s.WB3OymHLL_DJ1jdHiqcMC-u84TgvfNFlgbZIJE-NtUQ"}`
|
||||
cases := []struct {
|
||||
name string
|
||||
stream string
|
||||
settings string
|
||||
}{
|
||||
{"tcp reality", `{"network":"tcp","security":"reality","realitySettings":{}}`, `{"clients":[],"decryption":"none"}`},
|
||||
{"raw vlessenc", `{"network":"tcp","security":"none"}`, vlessenc},
|
||||
{"ws vlessenc", `{"network":"ws","security":"none","wsSettings":{"path":"/"}}`, vlessenc},
|
||||
{"xdrive vlessenc", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`, vlessenc},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tag := "vision-flow"
|
||||
seedVlessInbound(t, tag, 43120+i, []model.Client{
|
||||
{Email: "vision@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, Flow: "xtls-rprx-vision"},
|
||||
})
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).
|
||||
Updates(map[string]any{"stream_settings": tc.stream, "settings": tc.settings}).Error; err != nil {
|
||||
t.Fatalf("set stream: %v", err)
|
||||
}
|
||||
value, _ := emittedClients(t, tag)
|
||||
list, _ := value.([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("settings.clients = %#v, want one client", value)
|
||||
}
|
||||
if entry, _ := list[0].(map[string]any); entry["flow"] != "xtls-rprx-vision" {
|
||||
t.Fatalf("flow = %#v, want xtls-rprx-vision kept on %s", entry["flow"], tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
if err := validateFinalMaskSalamander(string(inbound.StreamSettings)); err != nil {
|
||||
return common.NewError("xray template inbound \""+inbound.Tag+"\":", err)
|
||||
}
|
||||
if err := validateXDriveStorage(string(inbound.StreamSettings)); err != nil {
|
||||
return common.NewError("xray template inbound \""+inbound.Tag+"\":", err)
|
||||
}
|
||||
}
|
||||
if len(xrayConfig.OutboundConfigs) > 0 {
|
||||
var outbounds []json.RawMessage
|
||||
@@ -90,6 +93,9 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
if err := validateFinalMaskSalamander(string(probe.StreamSettings)); err != nil {
|
||||
return common.NewError("xray template outbound \""+probe.Tag+"\":", err)
|
||||
}
|
||||
if err := validateXDriveStorage(string(probe.StreamSettings)); err != nil {
|
||||
return common.NewError("xray template outbound \""+probe.Tag+"\":", err)
|
||||
}
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user