feat(mtproto): route Telegram egress through Xray routing rules

Add a per-inbound "Route through Xray" toggle (off by default) plus an
optional outbound picker on MTProto inbounds. mtg only supports a SOCKS5
upstream, so when enabled the panel injects a loopback SOCKS bridge into
the generated Xray config — tagged with the inbound's own tag — and mtg
dials Telegram through it via a [network] proxies upstream. The router
then governs Telegram egress: matchable in the Routing tab, or forced to a
chosen outbound/balancer via the picker.

- mtproto: Instance carries RouteThroughXray + XrayRoutePort (in the
  fingerprint); InstanceFromInbound parses them; renderConfig emits the
  socks5 [network] upstream; freeLocalPort exported as FreeLocalPort.
- xray.go: injectMtprotoEgress appends the loopback SOCKS bridge and
  prepends an optional inboundTag->outbound/balancer rule, hot-appliable
  like injectPanelEgress.
- inbound.go: backend-owned egress port persisted in settings, allocated
  once and carried across edits (stored value wins); stripped with the
  inert outboundTag when routing is off; allocation failure fails the save;
  routed add/update/del force a config regen.
- mtproto_job: skip folding mtg metrics for routed inbounds (the bridge,
  carrying the inbound tag, is metered by xray_traffic_job) to avoid
  double-counting.
- frontend: toggle + outbound/balancer Select (useOutboundTags) on the
  MTProto form; i18n keys for all locales.
This commit is contained in:
MHSanaei
2026-06-12 17:58:45 +02:00
parent 5716ae5987
commit 5eec178483
24 changed files with 602 additions and 4 deletions
@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import { fetchXrayConfig } from '@/hooks/useXraySetting';
// Available outbound (and balancer-eligible) tags the user can route an mtproto
// inbound's Telegram traffic to. Shares the cached xray config query so opening
// the inbound form costs no extra request when the Xray page was already
// visited; `select` derives just the tag list without disturbing other readers.
export function useOutboundTags() {
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
select: (data): string[] => {
const tags = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const tag = (o as { tag?: string } | null)?.tag;
if (tag) tags.add(tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) tags.add(t);
}
// Balancers are valid routing targets too — injectMtprotoEgress emits a
// balancerTag rule when the chosen tag names a balancer.
const balancers = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
for (const b of balancers ?? []) {
if (b?.tag) tags.add(b.tag);
}
return [...tags];
},
});
}
+1 -1
View File
@@ -81,7 +81,7 @@ export interface UseXraySettingResult {
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
@@ -3,10 +3,13 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
import { useOutboundTags } from '@/api/queries/useOutboundTags';
export default function MtprotoFields() {
const { t } = useTranslation();
const form = Form.useFormInstance();
const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
const { data: outboundTags } = useOutboundTags();
return (
<>
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
@@ -71,6 +74,28 @@ export default function MtprotoFields() {
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name={['settings', 'routeThroughXray']}
label={t('pages.inbounds.form.mtgRouteThroughXray')}
tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
valuePropName="checked"
>
<Switch />
</Form.Item>
{routeThroughXray && (
<Form.Item
name={['settings', 'outboundTag']}
label={t('pages.inbounds.form.mtgRouteOutbound')}
tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
/>
</Form.Item>
)}
</>
);
}
@@ -22,5 +22,12 @@ export const MtprotoInboundSettingsSchema = z.object({
preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
debug: z.boolean().optional(),
domainFronting: MtprotoDomainFrontingSchema.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
// bridge port; it is allocated and owned by the backend (never edited here).
routeThroughXray: z.boolean().optional(),
outboundTag: z.string().optional(),
routeXrayPort: z.number().int().min(0).max(65535).optional(),
});
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;