mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 17:46:08 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user