feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client

Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound.

The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates.
This commit is contained in:
MHSanaei
2026-07-06 16:04:32 +02:00
parent 5e9606aa4d
commit d97bd8643e
54 changed files with 1160 additions and 453 deletions
+3
View File
@@ -234,6 +234,7 @@ export const EXAMPLES: Record<string, unknown> = {
"publicKey": "",
"reset": 0,
"reverse": null,
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
"security": "",
"subId": "",
"tgId": 0,
@@ -265,6 +266,7 @@ export const EXAMPLES: Record<string, unknown> = {
"publicKey": "",
"reset": 0,
"reverse": null,
"secret": "",
"security": "",
"subId": "",
"tgId": 0,
@@ -402,6 +404,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": true,
"id": 1,
"listen": "",
"mtprotoDomain": "",
"nodeAddress": "",
"nodeId": null,
"port": 443,
+12
View File
@@ -1141,6 +1141,11 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "VLESS simple reverse proxy settings",
"nullable": true
},
"secret": {
"description": "MTProto FakeTLS secret",
"example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
"type": "string"
},
"security": {
"description": "Security method (e.g., \"auto\", \"aes-128-gcm\")",
"type": "string"
@@ -1253,6 +1258,9 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer"
},
"reverse": {},
"secret": {
"type": "string"
},
"security": {
"type": "string"
},
@@ -1291,6 +1299,7 @@ export const SCHEMAS: Record<string, unknown> = {
"publicKey",
"reset",
"reverse",
"secret",
"security",
"subId",
"tgId",
@@ -1809,6 +1818,9 @@ export const SCHEMAS: Record<string, unknown> = {
"listen": {
"type": "string"
},
"mtprotoDomain": {
"type": "string"
},
"nodeAddress": {
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
"type": "string"
+3
View File
@@ -242,6 +242,7 @@ export interface Client {
publicKey?: string;
reset: number;
reverse?: ClientReverse | null;
secret?: string;
security: string;
subId: string;
tgId: number;
@@ -275,6 +276,7 @@ export interface ClientRecord {
publicKey: string;
reset: number;
reverse: unknown;
secret: string;
security: string;
subId: string;
tgId: number;
@@ -396,6 +398,7 @@ export interface InboundOption {
enable: boolean;
id: number;
listen?: string;
mtprotoDomain?: string;
nodeAddress?: string;
nodeId?: number | null;
port: number;
+3
View File
@@ -258,6 +258,7 @@ export const ClientSchema = z.object({
publicKey: z.string().optional(),
reset: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
secret: z.string().optional(),
security: z.string(),
subId: z.string(),
tgId: z.number().int(),
@@ -293,6 +294,7 @@ export const ClientRecordSchema = z.object({
publicKey: z.string(),
reset: z.number().int(),
reverse: z.unknown(),
secret: z.string(),
security: z.string(),
subId: z.string(),
tgId: z.number().int(),
@@ -423,6 +425,7 @@ export const InboundOptionSchema = z.object({
enable: z.boolean(),
id: z.number().int(),
listen: z.string().optional(),
mtprotoDomain: z.string().optional(),
nodeAddress: z.string().optional(),
nodeId: z.number().int().nullable().optional(),
port: z.number().int(),
+11 -18
View File
@@ -3,7 +3,7 @@ import { RandomUtil, Wireguard } from '@/utils';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
import type { MtprotoClient, MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
@@ -216,26 +216,19 @@ export function generateMtprotoSecret(domain: string): string {
return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`;
}
// mtprotoSecretForDomain rewrites only the domain suffix of an existing secret,
// preserving its 16-byte random middle when valid (generating one otherwise).
// Mirrors the Go model.HealMtprotoSecret so editing the FakeTLS domain doesn't
// needlessly rotate the secret's identity.
export function mtprotoSecretForDomain(currentSecret: string, domain: string): string {
let body = currentSecret;
if (body.startsWith('ee') || body.startsWith('dd')) {
body = body.slice(2);
}
const middle = /^[0-9a-f]{32}/i.test(body)
? body.slice(0, 32)
: RandomUtil.randomSeq(32, { type: 'hex' });
return `ee${middle}${domainToHex(domain)}`;
export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
return {
fakeTlsDomain: 'www.cloudflare.com',
clients: [],
};
}
export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
const fakeTlsDomain = 'www.cloudflare.com';
// createDefaultMtprotoClient seeds a new MTProto client with a fresh FakeTLS
// secret fronting the given domain. Mirrors the WireGuard client default: the
// backend re-derives the secret on save, so this is only for immediate display.
export function createDefaultMtprotoClient(domain: string): Partial<MtprotoClient> {
return {
fakeTlsDomain,
secret: generateMtprotoSecret(fakeTlsDomain),
secret: generateMtprotoSecret(domain || 'www.cloudflare.com'),
};
}
@@ -2,6 +2,7 @@ import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schem
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
HysteriaClientSchema,
MtprotoClientSchema,
ShadowsocksClientSchema,
TrojanClientSchema,
VlessClientSchema,
@@ -238,6 +239,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
case 'shadowsocks': return ShadowsocksClientSchema;
case 'hysteria': return HysteriaClientSchema;
case 'wireguard': return WireguardClientSchema;
case 'mtproto': return MtprotoClientSchema;
default: return null;
}
}
+12 -7
View File
@@ -778,18 +778,21 @@ export interface GenMtprotoLinkInput {
inbound: Inbound;
address: string;
port?: number;
clientSecret?: string;
remark?: string;
}
// Builds a Telegram proxy deep link for an mtproto inbound:
// Builds a per-client Telegram proxy deep link for an mtproto inbound from the
// client's own FakeTLS secret.
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
const { inbound, address, port = inbound.port } = input;
const { inbound, address, port = inbound.port, clientSecret = '', remark = '' } = input;
if (inbound.protocol !== 'mtproto') return '';
const secret = inbound.settings.secret ?? '';
if (secret.length === 0) return '';
if (clientSecret.length === 0) return '';
const url = new URL('tg://proxy');
url.searchParams.set('server', address);
url.searchParams.set('port', String(port));
url.searchParams.set('secret', secret);
url.searchParams.set('secret', clientSecret);
if (remark) url.hash = encodeURIComponent(remark);
return url.toString();
}
@@ -1044,7 +1047,7 @@ export function preferPublicHost(browserHost: string, publicHost: string): strin
// `this.clients` getter, which used isSSMultiUser to gate). Returns null
// for SS single-user, http, mixed, tunnel, wireguard, hysteria2-without-
// clients, and any protocol without a clients array.
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; email?: string; subId?: string };
type ClientShape = { id?: string; security?: VmessSecurity; flow?: VlessClient['flow']; password?: string; auth?: string; secret?: string; email?: string; subId?: string };
// Mirror of the Go subKey: the stable per-client identity spx derivation
// keys on — subscription id first, unique email as the fallback.
@@ -1062,6 +1065,8 @@ export function getInboundClients(inbound: Inbound): ClientShape[] | null {
return (inbound.settings.clients ?? []) as ClientShape[];
case 'hysteria':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'mtproto':
return (inbound.settings.clients ?? []) as ClientShape[];
case 'shadowsocks': {
const isMultiUser = inbound.settings.method !== '2022-blake3-chacha20-poly1305';
return isMultiUser ? ((inbound.settings.clients ?? []) as ClientShape[]) : null;
@@ -1125,7 +1130,7 @@ export function genLink(input: GenLinkInput): string {
externalProxy,
});
case 'mtproto':
return genMtprotoLink({ inbound, address, port });
return genMtprotoLink({ inbound, address, port, clientSecret: client.secret ?? '', remark });
default:
return '';
}
+7 -1
View File
@@ -25,6 +25,7 @@ const PROTOCOL_LABELS: Record<string, string> = {
hysteria: 'Hysteria',
wireguard: 'WireGuard',
wg: 'WireGuard',
tg: 'MTProto',
};
const PROTOCOL_COLORS: Record<string, string> = {
@@ -35,12 +36,14 @@ const PROTOCOL_COLORS: Record<string, string> = {
Hysteria: 'magenta',
Hysteria2: 'magenta',
WireGuard: 'cyan',
MTProto: 'blue',
};
const SECURITY_COLORS: Record<string, string> = {
TLS: 'green',
XTLS: 'green',
REALITY: 'purple',
FAKETLS: 'green',
};
const TRANSPORT_COLOR = 'gold';
@@ -83,10 +86,13 @@ export function parseLinkParts(link: string): LinkParts | null {
const url = new URL(trimmed);
network = url.searchParams.get('type') ?? '';
security = url.searchParams.get('security') ?? '';
port = url.port;
/* tg://proxy links (mtproto) carry the port in a `port` query param, not
the URL authority, so fall back to it when there is no authority port. */
port = url.port || (url.searchParams.get('port') ?? '');
const hash = url.hash.replace(/^#/, '');
try { remark = decodeURIComponent(hash); } catch { remark = hash; }
} catch { /* not URL-shaped, fall back to protocol only */ }
if (scheme === 'tg') security = 'FakeTLS';
}
if (security === 'none') security = '';
return {
+48 -1
View File
@@ -24,6 +24,7 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
@@ -35,7 +36,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -117,6 +118,7 @@ interface FormState {
wgPublicKey: string;
wgPreSharedKey: string;
wgAllowedIPs: string;
secret: string;
}
function emptyForm(): FormState {
@@ -145,6 +147,7 @@ function emptyForm(): FormState {
wgPublicKey: '',
wgPreSharedKey: '',
wgAllowedIPs: '',
secret: '',
};
}
@@ -249,6 +252,7 @@ export default function ClientFormModal({
wgPublicKey: client.publicKey || '',
wgPreSharedKey: client.preSharedKey || '',
wgAllowedIPs: client.allowedIPs || '',
secret: client.secret || '',
};
if (et < 0) {
next.delayedStart = true;
@@ -310,6 +314,22 @@ export default function ClientFormModal({
return ids;
}, [inbounds]);
const mtprotoIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'mtproto') ids.add(row.id);
}
return ids;
}, [inbounds]);
const mtprotoDomain = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain;
}
return 'www.cloudflare.com';
}, [form.inboundIds, inbounds]);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
@@ -345,11 +365,20 @@ export default function ClientFormModal({
[form.inboundIds, wireguardIds],
);
const showMtproto = useMemo(
() => (form.inboundIds || []).some((id) => mtprotoIds.has(id)),
[form.inboundIds, mtprotoIds],
);
function regenerateWireguardKeys() {
const kp = Wireguard.generateKeypair();
setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
}
function regenerateMtprotoSecret() {
update('secret', generateMtprotoSecret(mtprotoDomain));
}
useEffect(() => {
if (!showFlow && form.flow) {
@@ -373,6 +402,12 @@ export default function ClientFormModal({
));
}, [ss2022Method]);
useEffect(() => {
if (showMtproto && !form.secret) {
update('secret', generateMtprotoSecret(mtprotoDomain));
}
}, [showMtproto, form.secret, mtprotoDomain]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
@@ -502,6 +537,10 @@ export default function ClientFormModal({
}
}
if (showMtproto) {
clientPayload.secret = form.secret;
}
const externalLinks: ExternalLinkInput[] = form.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
@@ -822,6 +861,14 @@ export default function ClientFormModal({
</Form.Item>
</>
)}
{showMtproto && (
<Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.secret} style={{ flex: 1 }} onChange={(e) => update('secret', e.target.value)} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
</Space.Compact>
</Form.Item>
)}
</>
),
},
@@ -1,8 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
import { useOutboundTags } from '@/api/queries/useOutboundTags';
export default function MtprotoFields() {
@@ -12,29 +10,12 @@ export default function MtprotoFields() {
const { data: outboundTags } = useOutboundTags();
return (
<>
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
<Input
placeholder="www.cloudflare.com"
onChange={(e) => {
const current = (form.getFieldValue(['settings', 'secret']) as string) ?? '';
form.setFieldValue(['settings', 'secret'], mtprotoSecretForDomain(current, e.target.value));
}}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.mtprotoSecret')}>
<Space.Compact block>
<Form.Item name={['settings', 'secret']} noStyle>
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => {
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
form.setFieldValue(['settings', 'secret'], generateMtprotoSecret(domain as string));
}}
/>
</Space.Compact>
<Form.Item
name={['settings', 'fakeTlsDomain']}
label={t('pages.inbounds.form.fakeTlsDomain')}
tooltip={t('pages.inbounds.form.mtprotoFakeTlsDomainHint')}
>
<Input placeholder="www.cloudflare.com" />
</Form.Item>
<Form.Item
name={['settings', 'domainFronting', 'ip']}
@@ -75,6 +56,13 @@ export default function MtprotoFields() {
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name={['settings', 'throttleMaxConnections']}
label={t('pages.inbounds.form.mtgThrottleMaxConnections')}
tooltip={t('pages.inbounds.form.mtgThrottleMaxConnectionsHint')}
>
<InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name={['settings', 'routeThroughXray']}
label={t('pages.inbounds.form.mtgRouteThroughXray')}
@@ -633,15 +633,6 @@ export default function InboundInfoModal({
<dt>{t('pages.inbounds.form.fakeTlsDomain')}</dt>
<dd><Tag color="green" className="value-tag">{inbound.settings.fakeTlsDomain as string}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.form.mtprotoSecret')}</dt>
<dd className="value-block">
<code className="value-code">{inbound.settings.secret as string}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(inbound.settings.secret as string, t)} />
</Tooltip>
</dd>
</div>
{(() => {
const s = inbound.settings;
const df = s.domainFronting as { ip?: string; port?: number; proxyProtocol?: boolean } | undefined;
@@ -683,17 +674,6 @@ export default function InboundInfoModal({
</>
);
})()}
{links.length > 0 && (
<div className="info-row">
<dt>{t('pages.inbounds.copyLink')}</dt>
<dd className="value-block">
<code className="value-code">{links[0].link}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(links[0].link, t)} />
</Tooltip>
</dd>
</div>
)}
</dl>
)}
@@ -16,6 +16,7 @@ const LINK_PROTOCOLS: ReadonlySet<string> = new Set([
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
Protocols.MTPROTO,
]);
export function hasShareLink(protocol: string): boolean {
@@ -72,6 +72,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'vless':
case 'trojan':
case 'hysteria':
case 'mtproto':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
+2
View File
@@ -37,6 +37,7 @@ export const ClientRecordSchema = z.object({
allowedIPs: z.string().optional(),
preSharedKey: z.string().optional(),
keepAlive: z.number().optional(),
secret: z.string().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
}).loose();
@@ -52,6 +53,7 @@ export const InboundOptionSchema = z.object({
wgPublicKey: z.string().optional(),
wgMtu: z.number().optional(),
wgDns: z.string().optional(),
mtprotoDomain: z.string().optional(),
// Hosting node id; absent/null for this panel's own inbounds (#4997).
nodeId: z.number().nullable().optional(),
// Share-host resolution inputs, mirroring the backend resolveInboundAddress so
@@ -10,18 +10,41 @@ export const MtprotoDomainFrontingSchema = z.object({
});
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.
// An MTProto (Telegram) inbound client (multi-client model). Each client is one
// named FakeTLS secret the mtg-multi sidecar serves through its [secrets]
// section; `secret` is the ee-prefixed FakeTLS secret whose trailing domain the
// backend rebuilds on save. `fakeTlsDomain` is stored on the inbound as the
// default domain used when generating a new client's secret.
export const MtprotoClientSchema = z.object({
secret: z.string().default(''),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
totalGB: z.number().int().min(0).default(0),
expiryTime: z.number().int().default(0),
enable: z.boolean().default(true),
tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0),
subId: z.string().default(''),
comment: z.string().default(''),
reset: z.number().int().min(0).default(0),
created_at: z.number().int().optional(),
updated_at: z.number().int().optional(),
});
export type MtprotoClient = z.infer<typeof MtprotoClientSchema>;
// MTProto (Telegram) inbound. Served by an mtg-multi sidecar process, not Xray,
// so it has no stream settings. Each client carries its own FakeTLS secret and
// is served on the shared inbound port. The remaining fields map to optional mtg
// config knobs and are written to the generated mtg config only when set.
export const MtprotoInboundSettingsSchema = z.object({
fakeTlsDomain: z.string().default('www.cloudflare.com'),
secret: z.string().default(''),
clients: z.array(MtprotoClientSchema).default([]),
proxyProtocolListener: z.boolean().optional(),
preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
debug: z.boolean().optional(),
domainFronting: MtprotoDomainFrontingSchema.optional(),
// Caps concurrent connections across all users with a fair-share algorithm;
// 0 or unset disables throttling.
throttleMaxConnections: z.number().int().min(0).optional(),
// When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
// the Xray config so the egress obeys routing rules. `outboundTag` optionally
// forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
@@ -63,8 +63,21 @@ exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] =
{
"protocol": "mtproto",
"settings": {
"clients": [
{
"comment": "",
"email": "mtproto-user",
"enable": true,
"expiryTime": 0,
"limitIp": 0,
"reset": 0,
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"subId": "",
"tgId": 0,
"totalGB": 0,
},
],
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
},
}
`;
@@ -73,6 +86,20 @@ exports[`InboundSettingsSchema fixtures > parses mtproto-domain-fronting byte-st
{
"protocol": "mtproto",
"settings": {
"clients": [
{
"comment": "",
"email": "mtproto-user",
"enable": true,
"expiryTime": 0,
"limitIp": 0,
"reset": 0,
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"subId": "",
"tgId": 0,
"totalGB": 0,
},
],
"debug": true,
"domainFronting": {
"ip": "127.0.0.1",
@@ -82,7 +109,7 @@ exports[`InboundSettingsSchema fixtures > parses mtproto-domain-fronting byte-st
"fakeTlsDomain": "www.cloudflare.com",
"preferIp": "prefer-ipv4",
"proxyProtocolListener": true,
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"throttleMaxConnections": 5000,
},
}
`;
@@ -2,6 +2,12 @@
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
"clients": [
{
"email": "mtproto-user",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"enable": true
}
]
}
}
@@ -2,10 +2,17 @@
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"clients": [
{
"email": "mtproto-user",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
"enable": true
}
],
"proxyProtocolListener": true,
"preferIp": "prefer-ipv4",
"debug": true,
"throttleMaxConnections": 5000,
"domainFronting": {
"ip": "127.0.0.1",
"port": 9443,
+14
View File
@@ -26,4 +26,18 @@ describe('link-label parseLinkParts', () => {
it('returns null for an unparseable scheme', () => {
expect(parseLinkParts('not-a-link')).toBeNull();
});
// MTProto share links are tg://proxy deep links whose port rides in a query
// param, not the URL authority; they carry no transport and use FakeTLS.
it('labels an mtproto tg://proxy link with its query-param port and FakeTLS', () => {
const parts = parseLinkParts(
'tg://proxy?server=host.example.com&port=8443&secret=ee00#mt-inbound',
);
expect(parts?.protocol).toBe('MTProto');
expect(parts?.network).toBe('');
expect(parts?.security).toBe('FAKETLS');
expect(parts?.port).toBe('8443');
expect(parts?.remark).toBe('mt-inbound');
expect(parts && linkMetaText(parts)).toBe('mt-inbound:8443');
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { genInboundLinks } from '@/lib/xray/inbound-link';
import { InboundSchema } from '@/schemas/api/inbound';
// Multi-client MTProto renders one tg://proxy deep link per entry in
// settings.clients, each carrying that client's own FakeTLS secret.
function mtprotoInbound() {
return InboundSchema.parse({
id: 70,
remark: 'mt-mc',
port: 8443,
protocol: 'mtproto',
settings: {
fakeTlsDomain: 'www.cloudflare.com',
clients: [
{
email: 'alice',
secret: 'ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d',
enable: true,
},
{
email: 'bob',
secret: 'eeabcdefabcdefabcdefabcdefabcdef01676f6f676c652e636f6d',
enable: true,
},
],
},
});
}
describe('mtproto multi-client link fan-out', () => {
it('emits one tg://proxy per client from settings.clients', () => {
const out = genInboundLinks({ inbound: mtprotoInbound(), remark: 'mt-mc', fallbackHostname: 'mt.example.test' });
const links = out.split('\r\n').filter(Boolean);
expect(links).toHaveLength(2);
expect(links[0]).toContain('tg://proxy');
expect(links[0]).toContain('secret=ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d');
expect(links[1]).toContain('secret=eeabcdefabcdefabcdefabcdefabcdef01676f6f676c652e636f6d');
});
});