feat(mtproto): add domain-fronting and essential mtg options

Expose mtg's [domain-fronting] section (ip/port/proxy-protocol) plus
proxy-protocol-listener, prefer-ip, and debug on MTProto inbounds. Each
key is written to the generated mtg-<id>.toml only when set, so mtg's own
defaults apply otherwise. The instance fingerprint now covers these
fields, so editing an option restarts the sidecar.

Since MTProto is mtg-served (not Xray), sniffing does not apply: hide the
Sniffing tab and the Advanced sniffing sub-editor, drop it from the
Advanced "All" JSON view, and emit empty sniffing in the wire payload,
all gated by a new canEnableSniffing predicate.
This commit is contained in:
MHSanaei
2026-06-09 12:44:04 +02:00
parent f8e89cc848
commit 6c1594693d
27 changed files with 592 additions and 55 deletions
@@ -11,6 +11,7 @@ import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
// Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside
@@ -302,7 +303,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
protocol: values.protocol,
settings: JSON.stringify(settingsPruned),
streamSettings: streamPruned ? JSON.stringify(streamPruned) : '',
sniffing: JSON.stringify(normalizeSniffing(values.sniffing)),
// mtproto is mtg-served, not Xray, so sniffing never applies — emit empty
// rather than the default { enabled: false } so the row carries no sniffing.
sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
tag: values.tag,
};
if (values.nodeId != null) payload.nodeId = values.nodeId;
@@ -50,6 +50,12 @@ export function canEnableStream(values: { protocol: string }): boolean {
return STREAM_PROTOCOLS.includes(values.protocol);
}
// mtproto is served by an external mtg process, not Xray, so the Xray sniffing
// block does not apply to it. Every other inbound supports sniffing.
export function canEnableSniffing(values: { protocol: string }): boolean {
return values.protocol !== 'mtproto';
}
// Vision seed applies only when XTLS Vision (TCP/TLS) flow is selected
// AND at least one VLESS client uses the vision flow. Excludes UDP variant.
export function canEnableVisionSeed(values: CapabilityVlessSlice): boolean {
@@ -23,6 +23,7 @@ import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag';
import {
canEnableReality,
canEnableSniffing,
canEnableStream,
canEnableTls,
isSS2022,
@@ -160,6 +161,7 @@ export default function InboundFormModal({
const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
const streamEnabled = canEnableStream({ protocol });
const sniffingSupported = canEnableSniffing({ protocol });
const wPort = Form.useWatch('port', form);
const wListen = (Form.useWatch('listen', form) ?? '') as string;
@@ -776,7 +778,7 @@ export default function InboundFormModal({
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.allHelp')}
</div>
<AdvancedAllEditor form={form} streamEnabled={streamEnabled} />
<AdvancedAllEditor form={form} streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
</>
),
},
@@ -820,25 +822,27 @@ export default function InboundFormModal({
),
}]
: []),
{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.sniffingHelp')}{' '}
<code>{'{ sniffing: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
form={form}
path="sniffing"
wrapKey="sniffing"
minHeight="240px"
maxHeight="420px"
/>
</>
),
},
...(sniffingSupported
? [{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<>
<div className="advanced-editor-meta">
{t('pages.inbounds.advanced.sniffingHelp')}{' '}
<code>{'{ sniffing: { ... } }'}</code>.
</div>
<AdvancedSliceEditor
form={form}
path="sniffing"
wrapKey="sniffing"
minHeight="240px"
maxHeight="420px"
/>
</>
),
}]
: []),
]}
/>
</div>
@@ -896,7 +900,9 @@ export default function InboundFormModal({
{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true },
]
: []),
{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true },
...(sniffingSupported
? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
: []),
{ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
]} />
</Form>
@@ -92,9 +92,11 @@ export function AdvancedSliceEditor({
export function AdvancedAllEditor({
form,
streamEnabled,
sniffingEnabled,
}: {
form: FormInstance<InboundFormValues>;
streamEnabled: boolean;
sniffingEnabled: boolean;
}) {
// preserve: true — default useWatch returns only registered fields, so
// sub-trees we never bound (settings.clients/fallbacks, sniffing
@@ -127,8 +129,10 @@ export function AdvancedAllEditor({
protocol: wProtocol ?? '',
tag: wTag ?? '',
settings: settingsView,
sniffing: normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]),
};
if (sniffingEnabled) {
out.sniffing = normalizeSniffing(wSniffing as Parameters<typeof normalizeSniffing>[0]);
}
if (streamView) out.streamSettings = streamView;
return JSON.stringify(out, null, 2);
};
@@ -146,7 +150,7 @@ export function AdvancedAllEditor({
setText(formStr);
lastEmitRef.current = formStr;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled]);
}, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
return (
<JsonEditor
@@ -171,7 +175,7 @@ export function AdvancedAllEditor({
if (parsed.settings && typeof parsed.settings === 'object') {
form.setFieldValue('settings', parsed.settings);
}
if (parsed.sniffing && typeof parsed.sniffing === 'object') {
if (sniffingEnabled && parsed.sniffing && typeof parsed.sniffing === 'object') {
form.setFieldValue('sniffing', parsed.sniffing);
}
if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Space } from 'antd';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
@@ -32,8 +32,44 @@ export default function MtprotoFields() {
/>
</Space.Compact>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
<Alert type="info" showIcon message={t('pages.inbounds.form.mtprotoHint')} />
<Form.Item
name={['settings', 'domainFronting', 'ip']}
label={t('pages.inbounds.form.mtgDomainFrontingIp')}
tooltip={t('pages.inbounds.form.mtgDomainFrontingHint')}
>
<Input placeholder="127.0.0.1" />
</Form.Item>
<Form.Item name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
<InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name={['settings', 'domainFronting', 'proxyProtocol']}
label={t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['settings', 'proxyProtocolListener']}
label={t('pages.inbounds.form.mtgProxyProtocolListener')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
<Select
allowClear
placeholder="prefer-ipv6"
options={[
{ value: 'prefer-ipv6', label: 'prefer-ipv6' },
{ value: 'prefer-ipv4', label: 'prefer-ipv4' },
{ value: 'only-ipv6', label: 'only-ipv6' },
{ value: 'only-ipv4', label: 'only-ipv4' },
]}
/>
</Form.Item>
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
<Switch />
</Form.Item>
</>
);
@@ -640,6 +640,47 @@ export default function InboundInfoModal({
</Tooltip>
</dd>
</div>
{(() => {
const s = inbound.settings;
const df = s.domainFronting as { ip?: string; port?: number; proxyProtocol?: boolean } | undefined;
const frontingTarget = df && (df.ip || df.port)
? `${df.ip ?? ''}${df.port ? `:${df.port}` : ''}`
: '';
return (
<>
{frontingTarget && (
<div className="info-row">
<dt>{t('pages.inbounds.form.mtgDomainFrontingIp')}</dt>
<dd><Tag color="blue" className="value-tag">{frontingTarget}</Tag></dd>
</div>
)}
{df?.proxyProtocol && (
<div className="info-row">
<dt>{t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}</dt>
<dd><Tag color="green" className="value-tag">{t('enabled')}</Tag></dd>
</div>
)}
{Boolean(s.proxyProtocolListener) && (
<div className="info-row">
<dt>{t('pages.inbounds.form.mtgProxyProtocolListener')}</dt>
<dd><Tag color="green" className="value-tag">{t('enabled')}</Tag></dd>
</div>
)}
{Boolean(s.preferIp) && (
<div className="info-row">
<dt>{t('pages.inbounds.form.mtgPreferIp')}</dt>
<dd><Tag color="blue" className="value-tag">{s.preferIp as string}</Tag></dd>
</div>
)}
{Boolean(s.debug) && (
<div className="info-row">
<dt>{t('pages.inbounds.form.mtgDebug')}</dt>
<dd><Tag color="green" className="value-tag">{t('enabled')}</Tag></dd>
</div>
)}
</>
);
})()}
{links.length > 0 && (
<div className="info-row">
<dt>{t('pages.inbounds.copyLink')}</dt>
@@ -1,10 +1,26 @@
import { z } from 'zod';
// mtg's [domain-fronting] section: where the sidecar forwards non-Telegram
// traffic (e.g. an NGINX fake site). All optional — omitted keys fall back to
// mtg's defaults (DNS-resolve the FakeTLS host, port 443, no proxy protocol).
export const MtprotoDomainFrontingSchema = z.object({
ip: z.string().optional(),
port: z.number().int().min(0).max(65535).optional(),
proxyProtocol: z.boolean().optional(),
});
export type MtprotoDomainFronting = z.infer<typeof MtprotoDomainFrontingSchema>;
// MTProto (Telegram) inbound. Served by an mtg sidecar process, not Xray, so
// it has no clients and no stream settings. `secret` is the FakeTLS secret
// (ee-prefixed); the backend rebuilds it to match `fakeTlsDomain` on save.
// The remaining fields map to optional mtg config knobs and are written to the
// generated mtg.toml only when set.
export const MtprotoInboundSettingsSchema = z.object({
fakeTlsDomain: z.string().default('www.cloudflare.com'),
secret: z.string().default(''),
proxyProtocolListener: z.boolean().optional(),
preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
debug: z.boolean().optional(),
domainFronting: MtprotoDomainFrontingSchema.optional(),
});
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
@@ -672,6 +672,174 @@ exports[`protocol capability predicates > mtproto-basic :: xhttp/tls 1`] = `
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: grpc/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: grpc/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: grpc/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: httpupgrade/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: httpupgrade/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: kcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: tcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: tcp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: tcp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: ws/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: ws/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: xhttp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: xhttp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-domain-fronting :: xhttp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
{
"canEnableReality": false,
@@ -69,6 +69,24 @@ exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] =
}
`;
exports[`InboundSettingsSchema fixtures > parses mtproto-domain-fronting byte-stably 1`] = `
{
"protocol": "mtproto",
"settings": {
"debug": true,
"domainFronting": {
"ip": "127.0.0.1",
"port": 9443,
"proxyProtocol": true,
},
"fakeTlsDomain": "www.cloudflare.com",
"preferIp": "prefer-ipv4",
"proxyProtocolListener": true,
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
},
}
`;
exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
{
"protocol": "shadowsocks",
@@ -0,0 +1,15 @@
{
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"proxyProtocolListener": true,
"preferIp": "prefer-ipv4",
"debug": true,
"domainFronting": {
"ip": "127.0.0.1",
"port": 9443,
"proxyProtocol": true
}
}
}
@@ -142,6 +142,17 @@ describe('formValuesToWirePayload', () => {
expect(payload.streamSettings).toBe('');
});
it('emits empty sniffing for mtproto (mtg-served, not Xray)', () => {
const values = rawInboundToFormValues({
...vlessRow,
protocol: 'mtproto',
settings: { fakeTlsDomain: 'www.cloudflare.com', secret: 'ee00' },
});
const payload = formValuesToWirePayload(values);
expect(payload.protocol).toBe('mtproto');
expect(payload.sniffing).toBe('');
});
it('omits nodeId when null', () => {
const values = rawInboundToFormValues({ ...vlessRow, nodeId: null });
const payload = formValuesToWirePayload(values);